简介:面向社区信息化管理场景,这份docx设计文档完整记录基于SSM框架的社区住户信息管理系统从需求分析到系统实现的全部过程,适合Java Web学习者、毕业设计开发者以及社区管理信息化项目人员参考。资源包为单个docx文档,大小1.5MB,内容涵盖系统总体架构、数据库设计及源码核心逻辑说明,并以登录、站内新闻管理、报修与投诉管理、短信信息管理、退出等核心模块为主线展开。文档依托Spring的IoC/AOP、SpringMVC请求分发与MyBatis数据持久化组合,结合MySQL数据库与Eclipse工具完成系统落地,对SSM整合流程、业务模块划分和前后端交互均有较清晰呈现。已有73人学习下载,对于正在设计同类管理系统或需要论文写作参考的读者,可直接借鉴其中模块划分、数据库表结构、功能流程图与实现思路,节省从零搭建框架和整理文档的时间。
1. 为什么一个住户管理系统要拆三层架构
社区住户信息管理这类系统,在很多刚入行的开发者眼里就是“增删改查”,但真正落地时会发现,报修工单的状态流转、多角色权限控制、短信记录与住户档案的关联查询,这些功能如果全堆在 JSP 页面里,后期维护成本会迅速失控。我从一个实际交付过的项目出发,基于SSM(Spring + SpringMVC + MyBatis)框架完整实现了社区住户信息管理系统,用 Eclipse 作为开发工具、MySQL 存储数据,跑通了从住户建档、站内新闻发布、报修与投诉处理、短信信息管理到论坛互动的全过程。
这套系统的价值在于它完整覆盖了一个典型 Java Web 后台管理项目的全部关键环节:登录与权限区分、文件上传、分页查询、多表关联、状态流转。无论你是准备课程设计、毕业设计,还是想找一个能复现的 SSM 整合案例,都可以直接参考本文的库表设计和代码结构。下面我会先讲清楚为什么选 SSM 而不是 Spring Boot,再逐步拆解每个模块的实现思路和关键代码,最后补充测试与部署环节容易踩的坑。
2. SSM 框架选型与项目骨架搭建
2.1 为什么是 SSM 而不是 Spring Boot
SSM 即 Spring + SpringMVC + MyBatis 三个开源框架的整合,在 Spring Boot 流行之前,它是 Java Web 后台开发的主流组合。现在很多教学和课程设计仍然指定 SSM,除了历史原因,它确实有值得理解的价值:SSM 是显式配置驱动,你能看到每一个 Bean 是怎么注册的、每一个 Mapper 是怎么扫描的、事务是怎么织入的。Spring Boot 把这些都自动装配了,反而容易让初学者跳过原理。
本项目的技术选型有三个关键决策:
- Spring:负责对象管理和依赖注入。通过 IoC 容器统一管理系统中的 Service、Dao 等组件,避免到处
new对象造成的耦合。比如住户服务需要调用短信服务,不需要在住户 Service 里手动创建短信 Service 实例,而是通过@Autowired注入。 - SpringMVC:负责 Web 层的请求分发。核心是
DispatcherServlet前端控制器,所有请求先经过它,再由它转发到对应的 Controller 方法。这种设计保证了 Controller、Model、View 三者之间的低耦合。 - MyBatis:负责数据持久化。对比 Hibernate,MyBatis 是半自动 ORM 框架,SQL 由开发者自己编写,灵活性高,适合报表查询、多表关联这种 SQL 变化较多的业务场景。社区住户信息管理系统里有很多条件查询(按楼栋、按状态、按时间),用 MyBatis 写动态 SQL 非常顺手。
2.2 项目目录结构与核心依赖
系统采用标准的Maven 多模块分层结构,按 Controller → Service → Dao 三层划分,视图层使用 JSP:
community-manager/ ├── pom.xml ├── src/main/java │ ├── com.xxx.community.controller │ │ ├── AdminController.java │ │ ├── HouseholderController.java │ │ ├── RepairController.java │ │ ├── ComplaintController.java │ │ ├── NewsController.java │ │ └── SmsController.java │ ├── com.xxx.community.service │ │ ├── HouseholderService.java │ │ ├── RepairService.java │ │ ├── ComplaintService.java │ │ └── impl/ (各 Service 的实现类) │ ├── com.xxx.community.dao │ │ ├── HouseholderMapper.java │ │ ├── RepairMapper.java │ │ ├── ComplaintMapper.java │ │ └── ... (MyBatis Mapper 接口) │ └── com.xxx.community.entity │ ├── Householder.java │ ├── Repair.java │ ├── Complaint.java │ ├── News.java │ └── SmsInfo.java ├── src/main/resources │ ├── jdbc.properties │ ├── spring-mybatis.xml │ ├── spring-mvc.xml │ └── mapper/ (MyBatis XML 映射文件) └── src/main/webapp ├── WEB-INF │ ├── web.xml │ └── jsp/ └── static/pom.xml 中的核心依赖如下,版本以稳定可用为准:
<!-- Spring --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.20</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-jdbc</artifactId> <version>5.3.20</version> </dependency> <!-- MyBatis 与整合包 --> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.13</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis-spring</artifactId> <version>2.1.0</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>5.1.49</version> </dependency> <!-- 连接池 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency>这里说明两个关键点。mybatis-spring是 MyBatis 与 Spring 整合的桥梁,它负责把 MyBatis 的SqlSessionFactory交给 Spring 容器管理,这样 Mapper 接口就可以通过@Autowired直接注入到 Service 层。Druid 连接池相比默认的 dbcp 有更好的监控能力,在开发环境下可以通过 Druid 的监控页面查看 SQL 执行耗时,排查慢查询很有用。
2.3 Spring 与 MyBatis 整合配置
SSM 整合的核心在 spring-mybatis.xml 配置文件里,它把数据源、SqlSessionFactory、Mapper 扫描三者串起来:
<!-- 加载数据库连接配置 --> <context:property-placeholder location="classpath:jdbc.properties"/> <!-- 配置 Druid 数据源 --> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <!-- 配置 SqlSessionFactory,关联 MyBatis 全局配置与 Mapper XML --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <property name="typeAliasesPackage" value="com.xxx.community.entity"/> </bean> <!-- 扫描 Mapper 接口,自动生成代理实现类 --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.xxx.community.dao"/> </bean> <!-- 开启事务管理 --> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>配置说明:mapperLocations指定了 XML 映射文件的路径,MyBatis 会在启动时加载这些文件,把里面的 SQL 语句与 Mapper 接口方法绑定;typeAliasesPackage设置了实体类别名包,这样在 XML 中写resultType="Householder"而不需要写全限定类名。事务管理器使用DataSourceTransactionManager,并开启了注解驱动,因此 Service 层方法上只要加@Transactional就能获得事务控制。
2.4 web.xml 与 SpringMVC 配置
web.xml 是 Web 应用的入口配置,需要注册 Spring 的上下文监听器和 SpringMVC 的前端控制器 DispatcherServlet:
<!-- 加载 Spring 容器 --> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mybatis.xml</param-value> </context-param> <!-- 配置 SpringMVC 前端控制器 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <init-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mvc.xml</param-value> </init-param> <load-on-startup>1</load-on-startup> </servlet> <servlet-mapping> <servlet-name>dispatcher</servlet-name> <url-pattern>/</url-pattern> </servlet-mapping>这里要说明的是,ContextLoaderListener和DispatcherServlet各自加载不同的配置文件,分别对应 Spring 根容器和 SpringMVC 子容器。Service、Dao 等业务组件放入根容器,Controller 放入子容器,子容器可以访问父容器的 Bean,反过来不行。这个层级关系理解不透彻的话,容易遇到NoSuchBeanDefinitionException这类报错。
spring-mvc.xml 中需要开启注解驱动、配置视图解析器和静态资源映射:
<mvc:annotation-driven/> <context:component-scan base-package="com.xxx.community.controller"/> <!-- 视图解析器 --> <bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/jsp/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 静态资源放行 --> <mvc:resources location="/static/" mapping="/static/**"/>InternalResourceViewResolver的 prefix 和 suffix 决定了 Controller 返回的字符串会拼成实际的 JSP 路径。比如返回"householder/list",最终解析到/WEB-INF/jsp/householder/list.jsp。静态资源放行是必须的,否则 CSS、JS、图片会被 DispatcherServlet 拦截导致页面样式丢失。
3. 数据库设计与权限控制实现
3.1 核心数据表结构
社区住户信息管理系统的核心数据模型围绕人和事展开。住户是基础数据,报修、投诉、短信都是围绕住户产生的业务数据,站内新闻则是面向全体用户的信息发布。我设计的时候把住户信息独立成表,业务表通过外键关联,这样避免数据冗余。
-- 管理员表:区分超级管理员与普通用户 CREATE TABLE `admin` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT '密码(MD5加密)', `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `role` TINYINT(4) DEFAULT '1' COMMENT '角色: 0超级管理员 1普通用户', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 住户信息表 CREATE TABLE `householder` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `house_no` VARCHAR(50) NOT NULL COMMENT '门牌号,如 3-2-1201', `name` VARCHAR(50) NOT NULL COMMENT '住户姓名', `id_card` VARCHAR(18) DEFAULT NULL COMMENT '身份证号', `phone` VARCHAR(20) DEFAULT NULL COMMENT '联系电话', `members` INT(11) DEFAULT '1' COMMENT '常住人口数', `move_in_date` DATE DEFAULT NULL COMMENT '入住日期', `status` TINYINT(4) DEFAULT '0' COMMENT '状态: 0在住 1已搬离', PRIMARY KEY (`id`), UNIQUE KEY `uk_house_no` (`house_no`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 报修信息表 CREATE TABLE `repair` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `householder_id` INT(11) NOT NULL COMMENT '报修住户ID', `repair_type` VARCHAR(50) DEFAULT NULL COMMENT '报修类型: 水电/门窗/家电/其他', `description` VARCHAR(500) DEFAULT NULL COMMENT '问题描述', `status` TINYINT(4) DEFAULT '0' COMMENT '状态: 0待处理 1处理中 2已完成 3已驳回', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `handle_time` DATETIME DEFAULT NULL COMMENT '处理时间', `handler` VARCHAR(50) DEFAULT NULL COMMENT '处理人', PRIMARY KEY (`id`), KEY `idx_householder` (`householder_id`), CONSTRAINT `fk_repair_householder` FOREIGN KEY (`householder_id`) REFERENCES `householder` (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;表结构设计上有几个细节值得注意。householder 表用house_no做唯一索引,因为一个小区的门牌号是唯一的,这是天然的业务主键;move_in_date和status字段组合,可以统计入住率、空置率。repair 表用status字段通过 Integer 值表示工单状态,比用字符串更省空间、查询更快,但必须保证状态含义清晰,我通常在代码里用常量类来定义。另外,业务表统一加create_time字段,既方便排查问题,也能做时间维度的统计。
再看短信信息表和站内新闻表:
-- 短信信息表 CREATE TABLE `sms_info` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `householder_id` INT(11) NOT NULL COMMENT '接收住户ID', `phone` VARCHAR(20) DEFAULT NULL COMMENT '接收手机号', `content` VARCHAR(500) NOT NULL COMMENT '短信内容', `send_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间', `status` TINYINT(4) DEFAULT '0' COMMENT '状态: 0未发送 1已发送 2发送失败', PRIMARY KEY (`id`), KEY `idx_householder` (`householder_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 站内新闻表 CREATE TABLE `news` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `title` VARCHAR(100) NOT NULL COMMENT '新闻标题', `category` VARCHAR(50) DEFAULT NULL COMMENT '新闻类别', `content` TEXT COMMENT '新闻内容', `image_url` VARCHAR(200) DEFAULT NULL COMMENT '封面图片', `publisher` VARCHAR(50) DEFAULT NULL COMMENT '发布人', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;sms_info表的householder_id是逻辑外键,我故意没有建物理外键约束,因为短信记录是需要长期保留的日志型数据。如果住户搬离后从 householder 表删除记录,物理外键会导致删除失败或短信记录被连带删除。日志型数据表保留冗余的手机号字段,是因为实际发送短信时用的是这个快照号码,即使住户信息以后改了,历史短信记录仍然能追溯到发送目标。
3.2 多角色登录与权限拦截实现
系统区分超级管理员和普通用户两种角色。超级管理员可以管理住户信息、发布新闻、处理报修、查看全部短信记录,而普通用户登录后只能查看公告、提交报修和投诉、查看自己的处理进度。这个权限控制用 SpringMVC 的HandlerInterceptor实现,比在每一个 Controller 方法里判断角色要优雅得多。
先看登录逻辑,密码使用 MD5 加密存储,登录成功后把用户信息放入 Session:
@Controller @RequestMapping("/admin") public class LoginController { @Autowired private AdminService adminService; @PostMapping("/login") public String login(String username, String password, String code, HttpSession session, Model model) { // 校验验证码 String sessionCode = (String) session.getAttribute("captchaCode"); if (sessionCode == null || !sessionCode.equalsIgnoreCase(code)) { model.addAttribute("error", "验证码错误"); return "login"; } // 校验用户名密码 Admin admin = adminService.login(username, MD5Util.md5(password)); if (admin == null) { model.addAttribute("error", "用户名或密码错误"); return "login"; } // 登录成功,将用户信息存入 Session session.setAttribute("loginAdmin", admin); session.setAttribute("role", admin.getRole()); return "redirect:/index"; } }这段登录逻辑里有几个容易被忽略的点。验证码先校验,再校验用户名密码,这样可以让攻击者先消耗一次图形验证码的解析成本,降低暴力破解的效率。密码在 Service 层做 MD5 加密后再和数据库比对,数据库中不存明文密码。如果表结构调整过,Admin 对象存了密码字段,建议在存入 Session 前把 password 置空,避免敏感信息留在服务端会话中。
权限拦截器的核心代码:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Admin admin = (Admin) session.getAttribute("loginAdmin"); // 未登录用户直接跳回登录页 if (admin == null) { response.sendRedirect(request.getContextPath() + "/login"); return false; } // 普通用户访问 /admin/ 下的管理接口时拦截 String uri = request.getRequestURI(); if (admin.getRole() == 1 && uri.contains("/admin/")) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无权限访问"); return false; } return true; } }拦截器注册到 SpringMVC 配置中,通过<mvc:interceptors>指定拦截路径和排除路径:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/static/**"/> <mvc:exclude-mapping path="/captcha"/> <bean class="com.xxx.community.interceptor.AuthInterceptor"/> </mvc:interceptor> </mvc:interceptors>这里排除登录接口和静态资源是因为它们不需要登录即可访问,这个配置遗漏会导致一个典型问题:页面能打开但 CSS、JS 请求全部被拦截,页面样式错乱。另一个常见错误是只配置了 mapping 没配置 exclude-mapping,排查这类问题时先看拦截器配置再查代码逻辑会快很多。
4. 核心业务模块的实现与表关联查询
4.1 住户信息管理的分页查询与条件筛选
住户信息管理是系统的基础模块,它要解决的核心问题是:数据量大了之后怎么快速找到目标记录。实际使用中管理员的查询条件通常是“几号楼 + 是否在住 + 姓名关键字”的组合。我在 Mapper 里使用了 MyBatis 的动态 SQL 来处理这种多条件组合查询,按楼栋号前缀、状态、姓名关键字三个维度筛选,所有条件都可选:
<select id="findByCondition" parameterType="map" resultType="Householder"> SELECT * FROM householder <where> <if test="houseNo != null and houseNo != ''"> AND house_no LIKE CONCAT(#{houseNo}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </where> ORDER BY house_no LIMIT #{offset}, #{pageSize} </select><where>标签会自动去除第一个条件前面的AND关键字,这样即使所有条件都为空,生成的 SQL 也是合法的全表查询,不会出现WHERE AND这种语法错误。LIMIT #{offset}, #{pageSize}是手写分页的方式,offset的计算放在 Service 层:offset = (currentPage - 1) * pageSize。这种方式在数据量不大(几千条)时性能足够,如果后续数据量到了十万级以上,建议换成 PageHelper 插件或者基于游标的分页方案。
对应地需要查总数来计算总页数,用一个独立的 SQL:
<select id="countByCondition" parameterType="map" resultType="long"> SELECT COUNT(*) FROM householder <where> <if test="houseNo != null and houseNo != ''"> AND house_no LIKE CONCAT(#{houseNo}, '%') </if> <if test="status != null"> AND status = #{status} </if> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </where> </select>Service 层把查询结果和总记录数封装成一个分页对象返回给前端:
public PageResult<Householder> queryPage(HouseholderQuery query) { Map<String, Object> params = new HashMap<>(); params.put("houseNo", query.getHouseNo()); params.put("status", query.getStatus()); params.put("name", query.getName()); // 计算分页偏移量 params.put("offset", (query.getPageNum() - 1) * query.getPageSize()); params.put("pageSize", query.getPageSize()); List<Householder> list = householderMapper.findByCondition(params); long total = householderMapper.countByCondition(params); PageResult<Householder> result = new PageResult<>(); result.setList(list); result.setTotal(total); result.setPageNum(query.getPageNum()); result.setPageSize(query.getPageSize()); // 计算总页数 result.setPages((int) Math.ceil((double) total / query.getPageSize())); return result; }分页对象PageResult是统一的分页响应结构,包含list、total、pageNum、pageSize、pages五个字段。前端 JSP 页面通过 JSTL 标签遍历list渲染表格,页码导航直接在 JSP 里根据pages和pageNum生成链接。这里有一个前端的细节:页码链接要把当前查询条件带上去,否则翻页后查询条件丢失,用户会看到第一页之外的数据不符合预期。
4.2 报修与投诉的状态流转实现
报修与投诉模块本质是一个轻量的工单系统。状态流转是核心逻辑:用户提交(状态 0)→ 管理员受理(状态 1)→ 处理完成(状态 2),或管理员驳回(状态 3)。数据库层面就是一个status字段的更新,但业务层面必须保证状态只能按顺序推进,不能从“已完成”跳回“待处理”。
这个模块的 Service 层是关键,状态更新的方法必须加@Transactional事务注解:
@Service public class RepairServiceImpl implements RepairService { @Autowired private RepairMapper repairMapper; @Override @Transactional(rollbackFor = Exception.class) public void handleRepair(Integer repairId, Integer targetStatus, String handler, String handleRemark) { Repair repair = repairMapper.selectById(repairId); if (repair == null) { throw new BusinessException("报修记录不存在"); } // 状态校验:待处理 -> 处理中 -> 已完成 if (repair.getStatus() == 0 && targetStatus == 1) { repair.setStatus(1); repair.setHandler(handler); repair.setHandleRemark(handleRemark); } else if (repair.getStatus() == 1 && targetStatus == 2) { repair.setStatus(2); repair.setHandleTime(new Date()); } else { throw new BusinessException("非法的状态流转"); } repairMapper.updateStatus(repair); // 给住户发送短信通知 smsService.sendRepairNotice(repair.getHouseholderId(), targetStatus); } }事务注解rollbackFor = Exception.class指定了任何异常都触发回滚。这里有个重要细节:如果smsService.sendRepairNotice里调用了第三方短信接口,而这个接口超时抛异常,那么状态更新也会一起回滚。实际项目里我的处理方式是让短信发送结果不影响主流程,把发送逻辑放到事务外执行,或者用消息队列异步发送。这种业务聚合在单事务里虽然数据一致性最好,但要评估第三方依赖的可靠性和耗时。
投诉处理逻辑与报修类似,区别在于投诉记录往往需要关联一个处理结果回复。投诉表中有一个reply_content字段存储处理回复,住户在“我的投诉”页面可以查看管理员回复内容。在处理投诉时,必须先更新状态再填入回复内容,这两步操作放在同一个事务方法里,避免出现状态已经变成“已处理”但回复内容为空的数据不一致情况。
4.3 站内新闻发布与文件上传
站内新闻管理模块的功能点包括发布、编辑、删除、分页列表。发布时需要上传封面图片,这是 SSM 项目里比较经典的问题:Multipart 文件上传的配置。先在 spring-mvc.xml 里配置上传解析器:
<bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver"> <property name="maxUploadSize" value="5242880"/> <property name="defaultEncoding" value="UTF-8"/> </bean>maxUploadSize设置为 5MB,限制单个请求上传的文件总大小。defaultEncoding设置为 UTF-8,防止上传文件名中文乱码。Controller 里接收文件的写法:
@PostMapping("/news/add") public String addNews(@RequestParam("title") String title, @RequestParam("category") String category, @RequestParam("content") String content, @RequestParam(value = "image", required = false) MultipartFile image, HttpSession session) throws IOException { News news = new News(); news.setTitle(title); news.setCategory(category); news.setContent(content); // 处理图片上传 if (image != null && !image.isEmpty()) { String realPath = session.getServletContext() .getRealPath("/static/upload/"); File dir = new File(realPath); if (!dir.exists()) { dir.mkdirs(); } String fileName = System.currentTimeMillis() + "_" + image.getOriginalFilename(); image.transferTo(new File(realPath + fileName)); news.setImageUrl("/static/upload/" + fileName); } Admin admin = (Admin) session.getAttribute("loginAdmin"); news.setPublisher(admin.getRealName()); newsService.add(news); return "redirect:/news/list"; }这里用System.currentTimeMillis()作为文件名前缀,是为了避免不同用户上传同名文件时互相覆盖。图片存储在项目部署目录下的static/upload/中,数据库存的是相对 URL 路径。这个方案的局限是图片和项目代码耦合在一起,重新部署时需要备份 upload 目录。
一个实际部署经验是:如果生产环境使用 Nginx 做静态资源服务,应当把上传目录配置到项目外部,比如/data/community/upload/,然后通过 Nginx 映射/static/upload/到该目录。这样代码更新发布时不会影响已经上传的图片。
同时,新闻列表页做了分页。新闻发布后需要在前台页面展示给住户查看,因此列表接口需要支持按分类筛选、按标题模糊查询。前台展示的查询与后台管理用的是同一个 Service 方法,只是 Controller 不同,这样减少重复代码。分页参数通过PageResult返回后,前台展示用 JSTL 的c:forEach循环渲染即可。
4.4 短信信息管理与发送记录落库
短信信息模块的功能是给住户发送通知短信,比如停水停电通知、缴费提醒、报修处理反馈。系统的设计是:先创建短信记录(状态 0 未发送),发送后更新状态(状态 1 已发送或 2 发送失败)。这种设计把短信内容和发送状态分开管理,便于重发失败的短信和追溯历史。
短信发送的 Service 实现:
@Service public class SmsServiceImpl implements SmsService { @Autowired private SmsMapper smsMapper; @Override @Transactional(rollbackFor = Exception.class) public boolean sendBatchSms(List<Integer> householderIds, String content) { for (Integer id : householderIds) { Householder hh = householderMapper.selectById(id); if (hh == null || hh.getPhone() == null) { continue; } SmsInfo sms = new SmsInfo(); sms.setHouseholderId(id); sms.setPhone(hh.getPhone()); sms.setContent(content); sms.setStatus(0); // 初始状态:未发送 smsMapper.insert(sms); // 模拟调用短信网关发送 boolean success = callSmsGateway(hh.getPhone(), content); sms.setStatus(success ? 1 : 2); smsMapper.updateStatus(sms); } return true; } }这里会按传入的住户 ID 列表逐个发送,先插入一条状态为 0 的记录,再调用callSmsGateway模拟发送过程,最后根据发送结果更新状态。有一个值得讨论的细节:当前实现是同步逐条发送,如果住户量很大或者短信网关响应慢,这个接口的耗时可能达到几十秒。改进方案是把发送任务丢到线程池异步执行,页面直接返回“已提交,正在发送”,前端通过轮询接口获取发送进度。
callSmsGateway在真实项目中通常是对一个 HTTP 接口的调用,比如阿里云短信、腾讯云短信。模拟实现时返回true,但生产环境需要重试机制,失败后自动重试两次再标记为发送失败。重试要注意幂等性,避免同一条短信被重复扣费。
4.5 前台页面数据展示
除了后台管理功能,系统还有面向住户的前台页面。住户可以查看站内新闻列表和详情,可以提交报修和投诉工单,可以查看自己工单的处理状态。前台的报修提交与后台的状态管理使用同一个表,区别在于查询范围:住户只能看到自己提交的记录,管理员能看到全量记录。
这里用到了 Service 层的一个查询方法:
public List<Repair> getMyRepairs(Integer householderId) { Map<String, Object> params = new HashMap<>(); params.put("householderId", householderId); return repairMapper.selectByCondition(params); }前台展示的关键是把数据库中的状态数字翻译成用户能看懂的文字。这个转换我放在 JSP 的 JSTL 标签里做的,但更推荐在 Controller 或 Service 层把状态值转换成状态名称字段返回。比如:
public String getStatusText(Integer status) { switch (status) { case 0: return "待处理"; case 1: return "处理中"; case 2: return "已完成"; case 3: return "已驳回"; default: return "未知"; } }选择在 Service 层转换的原因是避免 JSP 里出现复杂逻辑,保持视图层只负责展示。同时状态值在页面上可以配合不同的 CSS class,实现不同颜色高亮,比如“待处理”显示红色,“已完成”显示绿色,这能让住户快速判断工单当前所处阶段。
5. 系统测试与 Tomcat 部署的落地细节
5.1 登录与权限的单元测试
系统测试环节做了单元测试和集成测试两个层级。单元测试主要针对 Service 层,使用 JUnit 4 + Spring Test 来加载 Spring 容器,测试登录校验逻辑。重点覆盖几种典型场景:正确用户名密码、错误密码、被禁用账号、权限不足访问。
核心测试代码如下:
@RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(locations = "classpath:spring-mybatis.xml") public class AdminServiceTest { @Autowired private AdminService adminService; @Test public void testLoginSuccess() { Admin admin = adminService.login("admin", MD5Util.md5("123456")); Assert.assertNotNull("正确账号密码能登录成功", admin); Assert.assertEquals("admin", admin.getUsername()); } @Test public void testLoginFailure() { Admin admin = adminService.login("admin", MD5Util.md5("wrong")); Assert.assertNull("错误密码登录失败", admin); } }这个测试类直接加载 spring-mybatis.xml 配置,因此会真实连接 MySQL 数据库。测试前需要在数据库里准备好测试数据。这里有一个实际工作中的经验:测试代码使用的数据库要和开发库分离。我通常建一个community_test库,测试前跑一遍测试数据的初始化 SQL,避免测试数据污染开发环境。
5.2 报修流程的集成测试
集成测试验证的是多个模块组合后的业务流程。以报修为例,完整的链路是:住户提交报修 → 数据库插入记录 → 管理员查询到待处理工单 → 管理员受理 → 更新状态并给住户发送短信通知。这个流程涉及 RepairController、RepairService、SmsService 三个层次,集成测试重点验证状态流转正确性和数据一致性。
@Test @Transactional public void testHandleRepairFullFlow() { // 1. 模拟住户提交报修 Repair repair = new Repair(); repair.setHouseholderId(1); repair.setRepairType("水电"); repair.setDescription("厨房水龙头漏水"); repair.setStatus(0); repairMapper.insert(repair); // 2. 管理员受理 repairService.handleRepair(repair.getId(), 1, "张工", "已派单"); // 3. 断言状态为处理中 Repair handled = repairMapper.selectById(repair.getId()); Assert.assertEquals(Integer.valueOf(1), handled.getStatus()); Assert.assertEquals("张工", handled.getHandler()); // 4. 管理员完成处理 repairService.handleRepair(repair.getId(), 2, "张工", null); Repair finished = repairMapper.selectById(repair.getId()); Assert.assertEquals(Integer.valueOf(2), finished.getStatus()); Assert.assertNotNull(finished.getHandleTime()); }@Transactional带来的效果是测试结束后事务回滚,数据库恢复原状,不会留下脏数据。这种测试方式效率高,适合在开发环境的集成测试中使用。如果想测试事务回滚行为,可以构造一个模拟短信发送失败的情况,验证报修状态更新是否也被回滚,确保不会再出现“状态已改但短信没发出去”的数据不一致场景。
5.3 Eclipse + Tomcat 部署的关键配置
系统基于 Eclipse 开发,部署时使用Tomcat 8.5。项目右键选择 Run As → Run on Server 即可。但有几个配置会影响运行是否顺利,总结如下:
JDK 版本与编译级别一致。项目 Properties → Java Compiler 里把编译级别设为 1.8,同时 Project Facets 里 Java 版本也设为 1.8。如果 JDK 使用的是 1.7 而代码里用了 Lambda 表达式,编译直接报错。这个错误通常表现为:项目构建成功但运行时类文件版本错误,UnsupportedClassVersionError。
web.xml 版本与 Tomcat 匹配。如果 web.xml 头声明的是 Servlet 3.1,Tomcat 7 及以下版本会拒绝启动。SSM 项目建议使用 Servlet 3.0 或 3.1 规范,对应 Tomcat 8 及以上版本。一个典型问题是项目默认创建的 web.xml 是 2.5 版本,这种情况下 HTTP 请求路径会带有项目名,且mvc:resources的映射行为可能有差异,建议统一用 3.1。
MySQL 时区问题。新版驱动连接字符串里必须有serverTimezone参数。推荐的 JDBC URL 是:
jdbc:mysql://localhost:3306/community?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai如果缺少serverTimezone,会报The server time zone value'Öйú±ê׼ʱ¼ä' is unrecognized异常。这个报错在部署时非常常见,直接加上这个参数就能解决。
Druid 连接池配置。开发环境建议在 jdbc.properties 里增加 Druid 的监控配置,方便排查 SQL 性能:
# 初始化连接数 / 最大活跃连接数 druid.initialSize=5 druid.maxActive=20 # 打开监控统计功能 druid.web-stat-filter.enabled=true druid.stat-view-servlet.enabled=true druid.stat-view-servlet.login-username=admin druid.stat-view-servlet.login-password=admin123开启后通过访问/druid/index.html可以查看 SQL 的执行次数和耗时。在系统测试阶段,我用这个页面定位过一条慢查询:住户列表页加载耗时 800ms,通过 Druid 监控发现是householder表的status字段没有索引,全表扫描导致。加了索引后降到 50ms 以内。这类性能问题在功能测试阶段很难暴露,但通过监控页面可以快速定位。
5.4 常见问题与排查命令
项目部署和测试过程中,最常见的几类错误和排查思路如下:
错误一:Invalid bound statement (not found)
这是 Mapper 接口与 XML 映射文件没有正确绑定的经典报错。排查顺序是:检查mapperLocations配置的路径是否正确;检查 XML 文件的 namespace 是否与接口全限定名一致;检查 XML 中 statement 的 id 是否与方法名一致;最后检查 target/classes 目录下是否真的生成了 XML 文件。有时候项目没有执行 clean 导致旧的 class 文件残留,mvn clean后再启动即可。
错误二:No qualifying bean of type 'xxxService'
这通常是包扫描配置遗漏导致 Spring 容器没有注册对应的 Bean。排查context:component-scan的 base-package 是否覆盖到了 Service 实现类所在的包,以及 Service 实现类是否标注了@Service注解。另外,如果 Service 接口和实现类在不同包,确保两个包都被扫描到。这种错误在我的经验里有八九成是漏了注解或者扫描包路径不完整。
错误三:JSP 页面 EL 表达式不解析
Tomcat 8 默认开启 EL 表达式,如果页面输出${xxx}而不是具体值,说明 web.xml 里配置了isELIgnored=true。检查 web.xml 的<jsp-config>配置,把isELIgnored设为 false。另一种可能是 JSP 中使用的是 JSTL 标签但忘了在页面头部声明taglib指令,页面会直接报错而不是优雅降级。
错误四:请求路径 404 但配置看起来正确
排查思路是检查web.xml中<servlet-mapping>的<url-pattern>。如果配置的是/,那么 DispatcherServlet 会拦截所有请求,包括静态资源和 JSP 访问。此时访问静态资源需要依赖<mvc:resources>配置放行,访问 JSP 需要依赖视图解析器。如果配置的是/*,会导致 JSP 页面也走 DispatcherServlet 流程,出现“视图渲染后返回的内容被当成字符串返回”的问题。正确做法是使用/而不是/*。
部署验证的完整步骤是:启动 Tomcat → 打开浏览器访问登录页 → 输入管理员账号密码 → 验证住户信息分页查询 → 新增一条住户记录 → 提交一条报修工单 → 管理端处理工单 → 查看短信记录是否生成 → 验证普通用户角色无法访问管理接口。按这个顺序走一遍,系统的核心功能就都覆盖到了。
提示:在生产环境部署时记得修改 Druid 监控页面的默认账号密码,同时把 SpringMVC 的配置文件里异常信息输出级别调低,避免把 SQL 语句和参数直接暴露在错误页面中。
本文还有配套的精品资源,点击获取