SSM+JSP打造“夕阳红”老年服务系统:实战记录与避坑指南

发布时间:2026/10/2 8:54:27
SSM+JSP打造“夕阳红”老年服务系统:实战记录与避坑指南 刚从社区那边回来一个做养老服务站的朋友问我能不能给他们搞一套老人信息管理系统。我第一反应是上Spring Boot Vue但聊了几句后发现他们站里连个专职运维都没有电脑还是七八年前的Windows 7浏览器默认的还是老IE最后我拍板用Java的SSM框架Spring SpringMVC MyBatis加JSP来做。这个决定在不少人看来有点“复古”但恰恰是这套组合最适合这种中小型机构、低并发、重实用的小项目。如果你也正打算做类似的管理系统——不管是社区养老、老年大学还是服务机构的后台这篇文章就是我把“夕阳红”老年服务系统从零搭起来、跑通、踩坑、最后交付的全部过程。1. 为什么在微服务满天飞的今天我还是选了SSMJSP1.1 这个系统到底要解决什么问题“夕阳红”老年服务系统听起来很大落到实际需求上就三件事老人信息要能管服务记录要能查活动报名要能办。社区服务站日常要登记的老年人信息包括姓名、年龄、住址、家属联系方式、基础健康状况还有定期上门回访的记录、组织健康讲座和文娱活动的报名名单等等。这些数据一天也就新增几十条并发量几乎可以忽略不计但要求界面直观、操作简单、打印方便。这种场景下技术选型的第一原则不是“新”而是“稳”。SSMJSP这套东西在Java Web领域跑了十几年相关问题和解决方案一搜一大堆即使出问题也不愁找不到人帮忙看。最重要的一点是JSP页面是服务端渲染的浏览器打开就是完整HTML不会出现要等AJAX拿数据的问题对那种网速一般、电脑配置不高的使用环境特别友好。1.2 SSM不是老古董而是“刚刚好”很多同学一听到SSM就皱眉觉得这是课设、毕设才用的东西。实际上Spring管理对象、SpringMVC处理请求路由、MyBatis操作数据库这三者组合起来就是一个非常清晰的请求-处理-存储链路Spring负责把Service、Mapper这些对象放进容器统一管理解决对象之间的依赖问题SpringMVC负责接收前端页面传来的请求把参数绑定到Controller方法上再返回逻辑视图名MyBatis负责把Java对象和数据库记录互相转换SQL你自己写灵活性拉满。对比Spring BootSSM确实多了一大堆XML配置但这恰恰是理解框架工作原理的最好途径。你用Spring Boot的时候一个SpringBootApplication注解点下去背后自动配置了一大堆东西出了问题往往一头雾水。而SSM每个Bean、每个扫描路径、每个处理器适配器都是你亲手配的跑起来之后心里是踏实的。至于JSP它被说“过时”主要是因为前后端分离的大趋势但在这种内部管理系统里JSPJSTL做循环输出、条件判断非常顺手改一个页面效果保存后刷新就能看到开发效率并不低。1.3 这套技术栈真正有价值的地方在哪我始终觉得做项目不能只盯着“能不能跑”得看看跑完之后你收获了什么。“夕阳红”这个系统麻雀虽小五脏俱全从环境配置、数据库设计、Controller编写到JSP页面的数据渲染你等于把Java Web开发的主干流程完整走了一遍。对还在校或者刚转行的朋友来说SSM是理解后面Spring Boot、甚至微服务体系的必经之路。面试的时候被问“Spring的IoC是什么”“MyBatis的#{}和${}有什么区别”如果你真的从SSM项目里摸爬滚打过回答起来是有血有肉的而不是背八股。做这个项目的过程中我还有一个很深的感触给老人用的系统核心逻辑往往不复杂复杂的是对数据边界和异常情况的考虑。老人档案里的出生日期可能不完整联系电话可能填的是子女的紧急联系人可能有三个……这些“脏数据”在传统管理系统里太常见了。如果不做校验和兜底页面一渲染就报错那这个系统就是失败的。所以下面我会重点讲我在编码过程中做的各种防御性处理这些才是项目里真正值钱的部分。2. 开工前的三板斧环境搭建、数据库设计和目录结构2.1 开发环境的版本选型和依赖配置做这个项目的开发环境我建议按这个组合来都是经过实测的稳定版本组件推荐版本备注JDK1.8兼容性最强Tomcat、老工程都没问题Maven3.6.3管理依赖和打包3.8之后有点闹脾气Tomcat8.5或9.0支持Servlet 3.1跑SSM绰绰有余MySQL5.7稳定语法兼容广Windows和Linux都好装IDEA2022以上版本社区版也够用但旗舰版对Tomcat支持更好Maven的pom.xml是项目的命根子。核心依赖就这几个spring-context、spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector-java再加上jackson-databind后面做AJAX接口用、jstl和taglibsJSP页面要用标准标签库。这里有个坑我后面详细说jstl和taglibs的两个依赖一定要同时引光引一个会导致JSP页面上c:forEach标签全部失效。数据库连接我建议直接用dbcp2连接池比c3p0更省心配置简单。举个例子spring-dao.xml里的数据源配置长这样bean iddataSource classorg.apache.commons.dbcp2.BasicDataSource destroy-methodclose property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/elderly_care?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword valueyourpassword/ property nameinitialSize value5/ property namemaxTotal value20/ /bean注意URL里的useUnicodetruecharacterEncodingutf8少了这个后面中文数据全部乱码而且是没有商量余地的乱码。每个使用SSM的开发者都至少被这个坑咬过一口。2.2 数据库表设计从老人档案到服务记录表结构是这个系统的心脏我用了大概半天时间去理需求、画模型最终落定五张核心表elderly_info老人信息、health_record健康档案、activity_info活动信息、activity_signup活动报名、service_record服务记录。elderly_info表是最关键的字段设计上我特别注意了“信息完整度”这个概念。常见的设计是把姓名、年龄、电话、地址都设为NOT NULL但在实际业务里服务站拿到的老人信息往往是残缺的。所以我的做法是核心字段姓名、身份证号、家属电话NOT NULL扩展字段疾病史、用药情况、住址详情允许为空每次编辑的时候校验非空字段是否合法但空字段要有默认值兜底。CREATE TABLE elderly_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 老人姓名, id_card VARCHAR(18) NOT NULL UNIQUE COMMENT 身份证号, birthday DATE COMMENT 出生日期, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, phone VARCHAR(20) COMMENT 本人电话, emergency_name VARCHAR(50) COMMENT 紧急联系人, emergency_phone VARCHAR(20) COMMENT 紧急联系电话, address VARCHAR(255) COMMENT 现住址, health_status VARCHAR(500) COMMENT 基础健康状况摘要, service_level TINYINT DEFAULT 1 COMMENT 服务等级1一般 2重点关注 3紧急, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;说说设计心得。身份证号加唯一约束这是必备的。但是18位身份证字符串作为唯一索引每秒写入几十条没有任何问题。服务等级字段是我和养老服务站反复确认后加的他们那儿分三类老人能自理的、半自理的、卧床的。后续的提醒服务和上门频次全按这个等级来配置。字段注释一定要写清楚不然过一个月你自己都不知道service_level表示的是啥。健康档案表和服务记录表我采用了一对多的设计一个老人可以有多条健康体检记录、多条上门服务记录。查询时按老人ID捞出来按时间倒序排列页面上展示最近几条这一块非常符合实际使用习惯。活动报名表则是活动和老人之间的关联表里面额外存了报名时间、是否到场两个字段方便站里统计活动的实际参与率。2.3 包结构和分层思想很多初学SSM的人喜欢把类往几个包里乱塞这在项目小的时候看不出来一旦模块多了改一个功能要找半天代码。我按照标准的Controller-Service-Mapper三层结构分包com.sunset.elderly ├── controller ├── service │ ├── impl ├── mapper ├── pojo │ ├── entity │ ├── vo ├── interceptor ├── common └── utilController层只管接收参数、调Service、返回视图Service层管业务逻辑比如报名活动前检查这个活动人数是不是满了Mapper层只做数据读写不要写业务判断。很多人喜欢在Controller里直接注入Mapper图一时省事后面想复用逻辑的时候就傻眼了还是老老实实一层层穿过去比较好。JSP页面的物理路径也很讲究。我放在WEB-INF/views下面分成elderly、activity、health、service几个子目录。关键点放在WEB-INF下的页面不能通过URL直接访问只能由Controller转发进去相当于白得了一层访问控制。SpringMVC的视图解析器配置如下bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views// property namesuffix value.jsp/ /bean3. 核心功能开发的完整链路登录、CRUD和那些不能明说的细节3.1 登录鉴权和拦截器的实现逻辑第一个要写的是登录模块。管理系统的常规做法是Session存用户状态加一个拦截器统一判断。我在这个项目里用了一个比较简单的角色机制系统用户表里只分ADMIN管理员和STAFF工作人员管理员能看所有数据、能删记录工作人员能录入、能修改、但不能删除。这样权限清晰又不会因为角色太多把代码搞复杂。拦截器的核心代码大概是这样public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session request.getSession(); Object user session.getAttribute(loginUser); if (user null) { response.sendRedirect(request.getContextPath() /login); return false; } return true; } }然后要在spring-mvc.xml里把拦截器注册进去并排除登录请求本身和静态资源路径mvc:interceptors interceptor mvc:mapping path/**/ mvc:exclude-mapping path/login/ mvc:exclude-mapping path/register/ mvc:exclude-mapping path/static/**/ mvc:exclude-mapping path/css/**/ mvc:exclude-mapping path/js/**/ mvc:exclude-mapping path/images/**/ /interceptor /mvc:interceptors这个mvc:exclude-mapping的配置特别关键。如果不排除静态资源你会发现页面上的CSS和JS全部加载不出来因为拦截器把静态文件请求也给拦了全跳转到登录页了。这个问题排查起来很闹心——因为浏览器里看Network面板静态文件请求的状态码是302好像被重定向了。密码存储这一块我只在代码里做了简单的MD5加盐处理。严格来说生产环境用MD5已经不够安全了应该上BCrypt。但考虑到服务站内网的威胁模型再加上维护人员的接受程度我用MD5加一个固定盐然后加一层随机盐。完整代码不贴了思路就是password MD5(盐 MD5(原始密码 固定盐))数据库里同时存盐和最终哈希值。虽然不比BCrypt但至少比明文强了十万八千里。3.2 MyBatis的SQL灵活运用动态筛选与模糊搜索老年服务系统最常用的操作就是按条件查人。站里的工作人员经常记不全老人的全名可能只记得“王阿姨”、“住在三栋的李大爷”这样的信息。所以查询接口必须支持模糊搜索而且各种条件可空。我写了一个dynamicWhere的查询方法用MyBatis的动态SQL标签解决select idsearchElderly resultTypecom.sunset.elderly.pojo.entity.ElderlyInfo SELECT * FROM elderly_info where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if if testgender ! null AND gender #{gender} /if if testserviceLevel ! null AND service_level #{serviceLevel} /if if testidCard ! null and idCard ! AND id_card #{idCard} /if /where ORDER BY update_time DESC LIMIT #{offset}, #{pageSize} /selectwhere标签有一个特性如果元素内部没有满足条件的if它不会生成任何内容如果有条件成立它会在最前面自动补上WHERE关键字还会把第一个条件开头的AND或OR去掉。这个设计太实用了不然你得拼一堆where 11这样的垃圾代码。模糊查询这里有个性能隐患要提醒LIKE %关键字%这种写法无法走常规索引数据量少的时候无所谓一旦表超过几万条查询会明显变慢。但养老服务站的数据量一年也就几千条完全不用纠结这个。如果以后数据量上来了可以考虑全文索引或者上ES但那都是另一个话题了。3.3 JSP页面的数据渲染和分页处理JSP页面我用了JSTL标签库主页面的列表渲染是这样写的c:forEach items${pageInfo.list} varelder tr td${elder.name}/td td${elder.gender 1 ? 男 : 女}/td td${elder.phone}/td td${elder.address}/td td c:if test${elder.serviceLevel 3} span classtag-danger重点关注/span /c:if c:if test${elder.serviceLevel 2} span classtag-warning优先服务/span /c:if c:if test${elder.serviceLevel 1} span classtag-normal一般/span /c:if /td /tr /c:forEachJSP里的${elder.gender 1 ? 男 : 女}这种三元运算符是我比较推荐的处理方式比在Java后端拼好再传值要清晰得多。性别这类字典值在最大程度保持页面简洁性的前提下用JSTL判断比用EL表达式更直观。关于分页我在一开始就引入了MyBatis的PageHelper插件。它用起来非常简单在查询之前调用PageHelper.startPage(pageNum, pageSize)紧接着的第一次查询就会自动拼上LIMIT并顺带查一个COUNT总数。返回的PageInfo对象里有当前页数据、总记录数、总页数、当前页码、上一页/下一页这些完整分页信息直接塞到Model里传给JSP。但启动的时候一定要注意PageHelper的引入时机必须在MyBatis事务之前。我踩过的坑是调用startPage后紧接着进行的不是查询而是其他业务操作导致分页参数作用到了错误的SQL上出来的页面数据莫名其妙地不完整。排查这个问题的思路很简单在startPage之后的第一个数据库操作必然是分页查询目标中途不能插入别的Mapper调用。3.4 表单提交的日期和空值处理老人信息录入表单日期格式特别容易出错。HTML里的input typedate传过来的格式是yyyy-MM-dd而Java后端如果直接把String映射到Date会报类型转换失败。我在POJO里的日期字段用的是java.util.Date然后在spring-mvc.xml里配置一个全局的日期转换器mvc:annotation-driven conversion-serviceconversionService/ bean idconversionService classorg.springframework.format.support.FormattingConversionServiceFactoryBean property nameformatters list bean classorg.springframework.format.datetime.DateFormatter property namepattern valueyyyy-MM-dd/ /bean /list /property /bean这样前端传来的日期字符串就能自动转成Date了。如果是用Spring Boot一个DateTimeFormat(patternyyyy-MM-dd)注解就能搞定但在SSM中手动配置这一套会让你对整个日期处理的流程更加了然于胸。4. 踩坑集中营那些会让项目断送在交付前的细节4.1 症状页面中文全部变成问号这个坑我几乎每做一个SSM项目都会碰一次所以必须放在第一位。现象很统一数据库里中文正常但JSP页面上显示全是???或者乱码。排查链路我是这么走的先看数据库连接URL上有没有characterEncodingutf8。没有的话不管页面和代码怎么写全白搭再看JSP页面顶部的contentType必须要有pageEncodingutf-8然后看SpringMVC的CharacterEncodingFilter过滤器必须配置在web.xml的第一个位置见下方代码最后检查MySQL的库表字符集是不是utf8mb4而不是latin1。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping这个过滤器一定要配在web.xml的过滤器列表最前面因为forceEncoding设为true后它会在请求进入后续处理之前强制整个请求体的编码是UTF-8。如果配在别的过滤器后面前面已经有过滤器读了请求体编码转换就会失效。这里面的原理是Servlet规范里请求参数一旦被读取过就会被缓存后续再想用不同编码重新解析是不行的除非配置了request.setCharacterEncoding在把读操作之前。所以要放在最前面。4.2 症状JSP页面上的c:forEach标签不生效页面能正常打开但所有被c:forEach包裹的内容都没有渲染甚至直接把标签源代码显示在页面上。这个问题在引入JSTL时报的经典错误。根因是我一开始只引用了javax.servlet.jsp.jstl依赖没有把javax.servlet.jsp.jstl对应的taglibs实现包引进来。JSTL分两部分一个是规范APIjstl-api一个是实现taglibs-standard-impl。只引API不引实现Tomcat找不到具体的标签处理器就只能把标签当普通文本输出了。解决方式很简单pom.xml里同时加上dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdtaglibs/groupId artifactIdstandard/artifactId version1.1.2/version /dependencyJSP顶上也要写清楚% taglib prefixc urihttp://java.sun.com/jsp/jstl/core %特别是这个uri老的教程里写的是http://java.sun.com/jstl/core少了个/jsp也会导致标签没法解析。这个版本差异非常坑因为网上老博客鱼龙混杂抄的时候极容易踩中。4.3 症状CSS和JS全部失效页面裸奔项目联调阶段打开首页发现所有样式丢了。浏览器F12Network面板里发现/static/css/style.css请求状态码是302被重定向到/login。我在3.1提到过这是拦截器把静态资源也拦了。当时LoginInterceptor配置的拦截路径是/**这会囊括所有的请求包括静态资源。所以必须在拦截器注册的exclude-mapping里加入所有静态资源目录。但是这里还有一个更隐蔽的坑如果项目指定了DispatcherServlet的url-pattern为/不是*.do那么SpringMVC会接管所有请求静态资源默认是找HandlerMapping的找不到就报404。要放行静态资源除了拦截器排除之外还得在spring-mvc.xml里加mvc:resources mapping/static/** location/static//这一行表示所有/static/开头的请求直接去webapp根目录下的/static/文件夹找文件不经过Controller。加了它之后CSS和JS才能正常加载。这个配置是SSM项目的标配但新手特别容易漏掉。4.4 症状PageHelper分页莫名其妙丢数据这个坑在3.3我提过一嘴。具体现象是第一页数据显示正常第二页数据却跟第一页一模一样或者总页数不准确。排查过程是这样的老项目里配置了多个数据源PageHelper用的数据库是一套而分页插件扫描到的MyBatis会话是另一套。实际上PageHelper通过拦截器机制在SqlSession的Executor层切入如果你在同一个线程的同一个SqlSession里执行了多次查询第二次查询会沿用第一次的LIMIT参数如果没重新startPage。我之前用PageHelper踩过一个大坑在Service里做了一个批量插入操作插入之后需要查询刚刚插入的数据列表。因为前面查过一次用了startPage后面的每次查询都会被分页拦截器影响导致返回的记录条数不对。排查半天才意识到PageHelper是ThreadLocal模式startPage后紧接着的一次数据库查询会消费掉分页参数但如果那次“紧接查询”是插入操作有些版本的PageHelper对非查询语句也进行处理分页参数不会自动清除后续的查询就遭殃了。解决方式是在查询结束之后手动调用PageHelper.clearPage()或者在finally块里清理。现在这个操作的写法是PageHelper.startPage(pageNum, pageSize); ListElderlyInfo list elderlyMapper.selectList(condition); PageInfoElderlyInfo pageInfo new PageInfo(list); PageHelper.clearPage(); // 手动清理防脏数据5. 部署上线war包、Tomcat和后续可以继续做的事5.1 从IDEA打包到Tomcat部署的完整过程传统SSM项目的部署方式就是打成war包往Tomcat里丢。IDEA打包的步骤我用文字理一遍照着做就能出包确认pom.xml里packagingwar/packaging打开Maven工具窗口双击clean然后双击package如果一切正常target目录下会出现xxx.war把这个war文件直接拷贝到Tomcat的webapps目录下启动bin/startup.shLinux或bin/startup.batWindowsTomcat启动时会自动解压war包通过http://ip:8080/xxx访问。这里有一个要注意的地方很多项目在IDEA里能跑起来是因为IDEA的Tomcat插件内部做了exploded部署直接把编译后的文件映射到了Tomcat容器里。但打包成war后因为JSP页面在WEB-INF下路径会多一层应用上下文名超链接和表单提交路径若不使用${pageContext.request.contextPath}所有跳转都会丢前缀。我见过最崩溃的例子本地跑得好好的部署到服务器上一登录就404就是因为页面里所有action都写成了/elderly/add没有加上项目前缀。解决办法是在每个表单的action前拼接${pageContext.request.contextPath}或者更省事的方法在JSP顶部做一次全局重定义% String path request.getContextPath(); String basePath request.getScheme()://request.getServerName():request.getServerPort()path/; % base href%basePath%加了这个base标签后页面上的相对路径全部以应用根路径为基准迁移服务器时不需要逐个改链接。这个细节在传统JSP项目里属于那种“没人提醒、自己踩、踩完才牢”的经典配置。5.2 现有系统的边界在哪以及下一步可以怎么扩展交付后我给服务站画的扩展路线图是这样的。短期能做的把录入和查询做顺手就不错了再加个Excel导出功能。老人数据汇总上报是服务站最常见的需求我预留了一个导出接口用POI生成Excel文件把当前查询结果直接导出去比他们手动抄写效率高十倍。中期的方向是消息提醒。如果给service_record加一个计划回访时间字段定时任务每天扫一遍找出“今天该回访但还没有回访记录”的老人给工作人员发个站内通知甚至短信这会是一个很实用的能力。定时任务用Quartz就能搞定跟Spring配置一番每天凌晨跑一次即可。稍微远一点的规划是向Spring Boot迁移。我用SSM做完这个项目后其实对代码重构很有感触如果哪天要换成Spring BootDAO层和Service层的逻辑可以原封不动搬过去主要工作就是删掉XML配置、改成注解或配置类视图层JSP可以保留把application.properties配好启动类加一个EnableAutoConfiguration基本就能跑起来。这也是我建议大家用SSM做学习项目的核心原因因为拆过、配置过、踩过坑走到Spring Boot才会觉得“啊原来是这么个道理”。5.3 如果让我重新设计一次有些地方我会换做完这个项目我复盘了很久。如果重新再做一遍有些地方我会做不同的选择。比如JSP页面我可能会考虑在局部引入一点Vue用CDN的方式在某个复杂表格页面做前端渲染。这不是推翻JSP而是“该SSR的地方SSR该交互的地方交互”的思路。JSP负责首屏和数据直出局部密集操作交给Vue处理这种混合模式对于内网老浏览器环境其实更加友好。另外整个系统的日志我只用了System.out和简单的log4j真正交付之后我才意识到日志的重要性。有一天服务站的人打电话说“系统进不去了”我远程看了半天才发现是MySQL连不上了但代码里没有任何日志输出能告诉我数据库连接失败这个事实。后来我在关键Service方法里补了Slf4j日志把异常堆栈完整记录下来。做管理系统日志不是装饰品是你深夜爬起来处理故障时的眼睛。还有一个我特别后悔没在第一次就做的操作审计日志。这个系统的用户是服务站工作人员删除老人档案这种操作万一被误操作了数据就回不来了。虽然普通工作人员在代码层面被限制不能删除数据但管理员是有权限的。实际运营中万一出现误删没有日志想找回记录等于大海捞针。强烈建议所有做这类管理系统的开发者在建表的第一天就记得加delete_log这样一个表哪怕只记谁在什么时间删了哪条记录都能在出问题时保住你的口碑。从接到需求到交付这个“夕阳红”老年服务系统让我重新审视了一遍Java Web中最经典的这套技术组合。它确实不酷没有微服务没有分布式缓存没有前后端分离但它在一个只有几台老电脑的社区服务站里稳定跑起来了工作人员用Excel导出了第一份月度报表的时候那种满足感是纯粹追新技术的项目很难给的。如果你也正在规划类似的系统我的建议很简单先别急着上大而全的框架把需求拆透把表设计清楚把你选定的这版技术栈在这个场景下的边界摸清楚然后老老实实把每一步踩实。这套流程放诸任何技术栈皆准。