我在帮学校做学生体温管理系统的时候刚接手并没有直接想着用什么技术框架而是需求确实摆在面前每天上午、下午两轮体温填报班主任要查看全班数据校医要盯着异常记录教务处要统计发热人数。用Excel收上来的表经常七零八落名字和体温对不上号补填、漏填也没有人管。后来我决定用Spring、SpringMVC、MyBatis这套SSM组合搭一个标准化的校园学生体温管理系统一套程序跑下来从学生登录到管理员导出数据整个链路才算是通了。这篇文章就把这套系统的开发思路、数据库设计、核心配置、部署调试经验完整写出来给正在做课设、毕设或者第一次完整接触SSM项目的人一个能直接参考的样板。这套系统适合谁看如果你在学校做信息管理类Web开发或者想在课设里找一个“功能不复杂但足够完整”的SSM项目那这篇内容就是给你准备的。我尽可能把每一步的“为什么”也讲清楚而不是单纯贴代码这样哪怕你用的是别的数据库、别的IDE也能举一反三。1. 项目整体设计与需求拆解1.1 需求不是做个登记表而是完成一个管理闭环很多人做这类系统时容易犯一个错误把“体温管理”理解成一张表单加一个列表学生填完、老师能看就觉得完事了。真正到了校园场景里这套系统的核心需求是一个闭环学生填报 - 班主任审核/标记 - 校医处理异常 - 管理员统计汇总。我梳理出的角色和用例大致是这样的学生登录系统当天在规定时间段内填报体温填写健康状况正常/咳嗽/乏力等查看自己的历史记录。班主任查看本班学生的填报情况提醒未填的学生补填对体温异常的学生进行确认和标记。管理员/校医查看全部班级的填报进度查看异常记录的处理状态按班级、按日期统计发热人数和平均体温并导出日报表。在后端设计上还要处理几个隐藏需求防止学生重复提交同一天同一时段的记录体温数据必须可追溯谁在什么时间填报的权限必须隔离学生不能查看别人的数据班主任只能看本班数据。这些看似不起眼的需求直接决定数据库表和Service层方法怎么设计一定要在一开始就放进需求清单里。1.2 为什么选SSM而不是Spring Boot现在很多新项目直接上Spring Boot但SSM这套组合在Java Web领域依然是“教科书级”的存在。我选SSM的核心原因有三个第一很多学校的课程设计和毕业设计题目会指定使用SSM因为它的分层结构非常清晰Controller控制请求Service处理业务Mapper负责数据库操作能让评委和老师一眼看出你有没有掌握Java Web的基础知识。第二SSM把配置显式地摆在你面前不像Spring Boot自动配置那么多“魔法”反而更适合理解Spring IoC、AOP、MVC这些核心原理。第三MyBatis在处理统计类SQL时非常灵活像“按日期分组统计发热人数”“查询未填报学生列表”这类需求用XML里写原生SQL比JPA顺手很多。这套系统的技术栈具体如下组件选型说明后端框架Spring 5.x SpringMVC负责IoC管理、事务控制、请求分发持久层框架MyBatis 3.5.xXML中编写SQL灵活完成统计查询数据库MySQL 5.7 / 8.0存储用户、班级、体温记录等数据连接池Druid 1.2.x提供数据库连接池和监控能力前端JSP JSTL Bootstrap服务端渲染界面简单稳定构建工具Maven 3.6管理依赖、打包部署运行环境Tomcat 8.5Java Web应用容器这套组合出来的系统虽然不“新潮”但稳定、可控特别适合要写论文的场景——你对每一层做了什么都能说得很清楚。2. 数据库设计体温数据怎么存才不别扭2.1 五张核心表的结构和设计思路我最终设计的库名叫body_temp_system一共五张核心表。这里先给出最重要的三张表剩下的在2.2里说明。t_user表存所有账号信息CREATE TABLE t_user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1班主任 2管理员, real_name VARCHAR(50) NOT NULL, class_id INT DEFAULT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0禁用, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_class表存班级信息CREATE TABLE t_class ( class_id INT NOT NULL AUTO_INCREMENT, class_name VARCHAR(100) NOT NULL, grade VARCHAR(20) DEFAULT NULL, PRIMARY KEY (class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;t_health_record表存体温填报记录这是整个系统最核心的表CREATE TABLE t_health_record ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL COMMENT 学生ID, report_date DATE NOT NULL COMMENT 填报日期, period TINYINT NOT NULL COMMENT 1上午 2下午, temperature DECIMAL(4,1) NOT NULL COMMENT 体温, health_status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1异常, symptom VARCHAR(255) DEFAULT NULL COMMENT 咳嗽/乏力等症状描述, remark VARCHAR(255) DEFAULT NULL, report_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_date_period (user_id, report_date, period), KEY idx_report_date (report_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;为什么单独把user_id、report_date、period三个字段建一个唯一索引因为业务上同一个人同一天同一个上报时段只能有一条记录。这个唯一索引是在数据库层面防止重复提交的最后一道防线比在Java代码里先查后插要可靠得多。2.2 关键字段的细节精度、时区、外键设计体温字段时我用的是DECIMAL(4,1)而不是FLOAT或者DOUBLE。原因很简单体温只需要精确到小数点后一位36.5、37.3这种数据用浮点数存储容易产生精度误差比如37.30可能变成37.2999999虽然界面显示没什么问题但做数值比较时很麻烦。用定点数直接从存储层面规避。report_time字段我使用了DATETIME类型并让数据库自动写入当前时间。这里有一个大坑如果使用MySQL 8.0以上版本JDBC连接串必须显式指定时区否则会报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized。我在jdbc.properties里是这样写的jdbc.urljdbc:mysql://localhost:3306/body_temp_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueallowPublicKeyRetrievaltrue是MySQL 8.0配合SSL关闭时常用的一个参数不然本地连接也可能报Public Key Retrieval is not allowed。关于外键很多初学者喜欢在表上直接加FOREIGN KEY约束。但在我这个系统里我使用的是逻辑外键没有在数据库层面建立物理外键。原因有两方面一是学生、班级数据在批量导入时会因为外键约束导致插入顺序必须严格一致二是系统的查询全是单表加简单关联MyBatis手动管理关联关系已经足够出现脏数据反而是业务层的校验没做好。所以我的建议是课程设计阶段可以用逻辑外键并在论文中说明为什么不做物理外键这也是一个亮点评分点。2.3 关键统计SQL从“查记录”到“看趋势”体温管理系统和普通增删改查系统的最大区别在于统计查询。我实际用到最多的三条SQL分享出来可以帮你少走弯路。查询当天未填报的学生列表。如果直接用WHERE report_date today会查不到“没记录的人”所以要用LEFT JOIN让学生表作为主表SELECT u.id, u.real_name, c.class_name FROM t_user u LEFT JOIN t_class c ON u.class_id c.class_id LEFT JOIN t_health_record r ON r.user_id u.id AND r.report_date #{today} AND r.period #{period} LEFT JOIN t_health_record r2 ON 10 -- 占位实际可省略 WHERE u.role 0 AND r.id IS NULL;实际上第三个LEFT JOIN是不需要的核心还是让关联条件带上日期和时段这样r.id IS NULL就表示当天该时段没有任何记录。统计某天各班级发热人数假设37.3℃以上算发热SELECT c.class_name, COUNT(DISTINCT r.user_id) AS fever_count FROM t_health_record r JOIN t_user u ON r.user_id u.id JOIN t_class c ON u.class_id c.class_id WHERE r.report_date #{date} AND r.temperature 37.3 GROUP BY c.class_name ORDER BY fever_count DESC;这里用COUNT(DISTINCT r.user_id)非常关键。虽然唯一索引已经控制了同一个人同一天同一时段不会重复填报但在跨时段统计时比如一天内发热人数一个人上午发热下午正常如果不加DISTINCT就会被统计两次。查询一周内每天的平均体温用来画趋势图SELECT report_date, ROUND(AVG(temperature), 2) AS avg_temp, COUNT(*) AS total_count FROM t_health_record WHERE report_date BETWEEN #{startDate} AND #{endDate} GROUP BY report_date ORDER BY report_date;这类SQL之所以适合写在MyBatis的Mapper XML里是因为你可以随时根据前端图表需求调整查询条件而不需要改Java代码。3. 开发环境与SSM整合实操3.1 开发工具清单和版本搭配很多人在环境搭建上就卡住了最典型的问题是JDK、Maven、Tomcat版本不一致。我这次使用的组合是JDK 1.8 Maven 3.6.3 MySQL 5.7 Tomcat 8.5 IDEA。如果你用IDEA建议用2020以上的版本自带Maven绑定和Tomcat集成能省不少事。在pom.xml中Maven依赖这一块建议固定版本别图省事用最新的大版本。特别是Spring、MyBatis和数据库驱动之间经常出现版本不兼容。我使用的基础依赖如下properties spring.version5.3.18/spring.version /properties dependencies !-- Spring核心 -- dependency groupIdorg.springframework/groupId artifactIdspring-context/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version${spring.version}/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-jdbc/artifactId version${spring.version}/version /dependency !-- MyBatis及与Spring整合的桥接包 -- dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.9/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.6/version /dependency !-- MySQL驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version5.1.49/version /dependency !-- Druid连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.8/version /dependency !-- 其他 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version3.1.0/version scopeprovided/scope /dependency dependency groupIdjstl/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId version2.12.4/version /dependency /dependencies这里有个细节如果你用的MySQL是8.0以上驱动版本那么mysql-connector-java的版本号要改成8.0.28以上同时驱动类要从com.mysql.jdbc.Driver改成com.mysql.cj.jdbc.Driver。别小看这个变化90%的“数据库连接失败”都出在这里。3.2 配置文件逐段跑通SSM最让人头疼的地方是三个配置文件之间的关系。我把它拆解成一句话web.xml负责调度spring容器负责管理业务组件springmvc容器负责管理Controller。两者通过父子容器相互关联。web.xml的核心配置context-param param-namecontextConfigLocation/param-name param-valueclasspath:spring.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namedispatcherServlet/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:springmvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcherServlet/servlet-name url-pattern//url-pattern /servlet-mapping 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 /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping特别提醒DispatcherServlet的url-pattern一定不要写成/*。写成/*会把JSP文件请求也交给SpringMVC处理导致返回的是Controller字符串而不是渲染后的页面出现典型的404错误。用/则可以同时处理/login这样的路径请求和静态资源映射。在spring.xml里核心是数据源、SqlSessionFactory、Mapper扫描和事务context:component-scan base-packagecom.system.service / bean iddataSource classcom.alibaba.druid.pool.DruidDataSource init-methodinit destroy-methodclose property namedriverClassName value${jdbc.driver} / property nameurl value${jdbc.url} / property nameusername value${jdbc.username} / property namepassword value${jdbc.password} / /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource / property nameconfigLocation valueclasspath:mybatis-config.xml / property namemapperLocations valueclasspath:mapper/*.xml / /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.system.mapper / /bean bean idtxManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource / /bean tx:advice idtxAdvice transaction-managertxManager tx:attributes tx:method nameadd* propagationREQUIRED rollback-forException / tx:method nameupdate* propagationREQUIRED rollback-forException / tx:method namedelete* propagationREQUIRED rollback-forException / tx:method nameget* read-onlytrue / /tx:attributes /tx:advice aop:config aop:advisor advice-reftxAdvice pointcutexecution(* com.system.service..*.*(..)) / /aop:config注意tx:advice这个配置在Spring 5里面依然可用但需要引入spring-aop和aspectjweaver依赖。很多项目报“找不到tx命名空间”就是因为pom里漏了spring-aop。在springmvc.xml里最重要的是两个开启注解驱动、配置视图解析器和静态资源放行。因为页面使用了JSP和静态JS、CSS必须告诉SpringMVC不要拦截/static/**context:component-scan base-packagecom.system.controller / mvc:annotation-driven / mvc:resources mapping/static/** location/static/ / bean classorg.springframework.web.servlet.view.InternalResourceViewResolver property nameprefix value/WEB-INF/views/ / property namesuffix value.jsp / /bean3.3 常用SSM注解与核心业务代码整个开发过程中你会反复用到这些SSM常用注解我先把要掌握的先列出来Controller声明控制器交给SpringMVC容器管理。RequestMapping映射URL可以用在类和方法上。ResponseBody将方法返回值直接写入响应体常用于返回JSON。RequestParam从请求参数中取值比如RequestParam(period) Integer period。PathVariable从URL路径中取值比如/record/delete/{id}。Autowired按类型自动注入依赖可以用于Service、Mapper等。Service声明业务层组件。Repository声明数据访问组件让Spring容器管理Mapper实现。Transactional声明事务一般加在Service方法上。以体温上报的核心逻辑为例这是最典型的“先检查、再插入、事务包裹”的场景。我写了一个简化的Controller和Service示例Controller RequestMapping(/record) public class HealthRecordController { Autowired private HealthRecordService recordService; PostMapping(/submit) ResponseBody public Result submit(RequestParam(temperature) Double temperature, RequestParam(period) Integer period, RequestParam(value symptom, required false) String symptom, HttpSession session) { User user (User) session.getAttribute(loginUser); if (user null) { return Result.error(登录已过期请重新登录); } if (temperature 35.0 || temperature 42.0) { return Result.error(体温超出合理范围请重新测量); } try { recordService.submitRecord(user.getId(), temperature, period, symptom); return Result.success(上报成功); } catch (DuplicateSubmitException e) { return Result.error(今天该时段已经上报过了不要重复提交); } } }Service层中Transactional保证了插入操作中所有的SQL要么全成功要么全失败。如果一张“每日填报统计表”在插入记录时同时要修改t_user_status这一步不要拆开执行最好是放进同一个方法Service public class HealthRecordServiceImpl implements HealthRecordService { Autowired private HealthRecordMapper recordMapper; Transactional(rollbackFor Exception.class) Override public void submitRecord(Integer userId, Double temperature, Integer period, String symptom) { // 先查重复再插入 int count recordMapper.countByUserAndPeriod(userId, new Date(), period); if (count 0) { throw new DuplicateSubmitException(重复提交); } HealthRecord record new HealthRecord(); record.setUserId(userId); record.setReportDate(new Date()); record.setPeriod(period); record.setTemperature(temperature); record.setSymptom(symptom); record.setHealthStatus(temperature 37.3 ? 1 : 0); recordMapper.insert(record); } }4. 调试部署中的坑与排查实录4.1 开发阶段最容易踩的5个坑我在开发这个系统的过程中几乎把SSM新手可能踩的坑都踩了一遍。这里挑五个最有代表性的配上实际情况和解决办法。第一个坑时区报错。只要你的MySQL是8.0且JDBC连接串里没加serverTimezoneAsia/Shanghai启动时几乎必然报时区错误。解决方案上面已经写了不再重复。补充一点如果你用IDEA自带的数据库工具测试连接同样要配置时区分项否则显示乱码且无法连接。第二个坑404但检查Controller和JSP路径都没问题。这时候优先排查web.xml里的url-pattern是不是被写成了/*。另外还要确认springmvc.xml里的context:component-scan是不是只扫描了Controller包如果把Service、Mapper也放进来扫描会出现“Bean重复定义”的提示。第三个坑Mapper接口和XML文件“对不上号”。比如调用selectByUserId时提示Invalid bound statement (not found)。原因基本就是mapperLocations没有扫描到对应的XML文件或者XML里的namespace没有写成Mapper接口的全限定名。我的习惯是Mapper XML文件的目录结构保持和接口包路径一致例如接口在com.system.mapperXML文件就放在resources/mapper下但namespace写成com.system.mapper.HealthRecordMapper。第四个坑中文乱码。这个问题的来源有三个层次数据库连接编码、Servlet过滤器编码、页面编码。数据库连接已经通过characterEncodingutf8解决请求编码通过CharacterEncodingFilter解决页面编码要在JSP顶部写% page contentTypetext/html;charsetUTF-8 languagejava %三者缺一不可。此外Tomcat的server.xml里最好给Connector加上URIEncodingUTF-8否则表单提交的中文参数仍可能乱码。第五个坑Druid连接池监控页面和生产连接冲突。如果开启了Druid的Web监控要注意StatViewServlet的URL映射不能和项目里已有路由冲突。我建议把监控页地址设置为/druid/*并且在web.xml单独声明servlet servlet-nameDruidStatView/servlet-name servlet-classcom.alibaba.druid.support.http.StatViewServlet/servlet-class init-param param-nameresetEnable/param-name param-valuefalse/param-value /init-param /servlet servlet-mapping servlet-nameDruidStatView/servlet-name url-pattern/druid/*/url-pattern /servlet-mapping4.2 常见问题速查表现象可能原因排查/解决建议启动Tomcat时连接数据库超时数据库服务未启动 / 连接串主机端口错误先确认mysql服务是否启动再检查jdbc.url页面能打开但所有CSS没样式静态资源被DispatcherServlet拦截配置mvc:resources或在web.xml放行静态资源表单提交后报400参数类型不匹配比如体温空值绑定DoubleController参数加requiredfalse或前端校验登录成功后刷新就回到登录页Session失效 / Cookie路径问题检查登录拦截器是否放行了登录和静态资源请求数据库连接数飙升Druid连接未正常释放检查事务切面是否生效Service方法是否被代理导出报表内容为空统计SQL关联条件错误用SQL客户端先执行同一条SQL确认有数据再排查前端4.3 从源码到运行环境的完整部署流程开发调试和最终部署是两件事。我第一次部署时走了弯路这里直接给出一个稳定流程。第一步用Maven打war包。在IDEA右侧Maven面板里执行clean然后再执行package。在pom.xml中记得把打包方式改成packagingwar/packaging。第二步确认war包生成位置。默认在项目的target目录下文件名一般是项目名-1.0-SNAPSHOT.war。建议把它重命名成简短的英文名比如bodytemp.war这样访问路径更好记。第三步部署到Tomcat。把bodytemp.war复制到Tomcat安装目录的webapps下启动Tomcat后会自动解压成bodytemp文件夹。访问地址就是http://localhost:8080/bodytemp/。第四步如果部署到云服务器或者VM虚拟机上还要检查防火墙是否开放了8080端口。Linux下可以使用firewall-cmd --zonepublic --add-port8080/tcp --permanent并重载防火墙。同时数据库要开放远程访问权限并且给MySQL用户授权的Host要填%而不是localhost。部署完成后建议按这个顺序做冒烟测试先访问登录页并登录管理员账号确认后台可以正常进入然后批量创建几个测试学生账号登录学生端提交一条体温记录再去班级列表确认记录是否同步最后到统计页面看对应日期和班级的数据是否出现。只要这个链路通了系统上线基本就没问题。4.4 几个提高排查效率的习惯这里再补充几个在开发调试中帮了我大忙的小习惯。第一把MyBatis的SQL日志打开。最简单的方式是在mybatis-config.xml里加settings setting namelogImpl valueSTDOUT_LOGGING/ /settings这样每次执行SQL都会在控制台打印预编译语句和参数你看一眼就知道SQL和参数是不是按预期传的。比在代码里打日志都方便。第二多用Postman测试JSON接口。系统里凡是使用ResponseBody的接口都可以先通过Postman带参数调用看返回的JSON是否正确。这能帮你把“后端问题”和“前端问题”快速分离不需要每次都在浏览器里反复调试。第三尽早用Druid监控页面看慢SQL。当页面统计报表加载很慢时往往不是Java代码的问题而是SQL没有走索引。打开/druid/sql.html看执行次数、耗时排行就能定位到具体SQL再回头优化索引或者改写关联条件。我个人在实际操作中的体会是这个SSM校园学生体温管理系统本身并不复杂但把数据库约束、事务边界、权限控制和部署流程全部理顺是需要花不少功夫的。我从最初只会照着教程写几个Mapper接口到后来能在脑子里完整画出数据流向靠的就是一遍遍调试这些报错。最后再分享一个小技巧。这类小型管理系统表结构设计时我一度想让t_health_record只存体温和状态把异常处理单独拆到另一张表。后来发现查询时要把学生表、班级表、记录表、处理表来回关联尤其是导出日报的时候SQL又长又容易出错。最终我把上报记录、异常标记、处理结果都收敛到“记录表加一张轻量扩展表”的方式统计SQL短了很多事务边界也更清晰。如果你接下来想扩展一套微信小程序报送端这个SSM后台完全可以继续复用改改接口视图层就能通这大概就是这套经典组合最让人省心的地方。