ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于SSM的家庭健康管理系统:指标录入、趋势图与部署避坑

基于SSM的家庭健康管理系统:指标录入、趋势图与部署避坑

简介:本资源是一套面向Java Web初学者与课程设计/毕业设计需求者的家庭健康管理系统源码,基于SSM(SpringMVC)框架搭建,后端采用Java,前端使用HTML、JSP与JavaScript,重点解决家庭成员健康档案与健康指标的管理、上报和报告生成问题,适合用来练习MVC分层开发与数据库设计。压缩包共107个文件,以34个java源文件、21个jsp页面、17个xml配置为主,另有js、css等前端资源,以及sql建表脚本、properties配置和md说明文档,整体约806KB,结构紧凑便于阅读与二次修改。系统覆盖家庭管理、成员管理、指标管理、用户管理、指标上报与健康报告等模块,界面部分引入bootstrap及其日期选择组件,读者可据此参考需求分析到详细设计的完整实现思路,理解实体建模、权限与数据流转,并借助sql脚本快速还原数据库、跑通项目。目前已有42人学习浏览,适合作为同类健康管理系统的入门参考。

1. 家庭健康管理系统拆解:一张体检单背后的数据账

家里老人量完血压顺手记在纸上,三个月后想看看走势,纸找不着了。这种事用不上云端健康平台,可纯 Excel 又扛不住「多个家庭成员 + 多项健康指标 + 按时间轴回溯」这三件事。基于 SSM 框架的家庭健康管理系统正好卡在这个缝里:Java 后端 Spring + SpringMVC + MyBatis,前端 HTML + JSP + jQuery,MySQL 存成员档案与指标记录,能录入血压、血糖、体重、心率这类日常数据,按人、按指标、按时间段拉出变化曲线,数值超出参考范围自动打标记。它适合两类人:一类是要交课程设计或毕设,需要一套分层清楚、能讲明白的 Java Web 工程;另一类是想练手 SSM 三层架构,拿一个业务不复杂但链路完整的健康管理项目把 Controller、Service、DAO 走通。下面按「结构怎么分、代码怎么写、环境怎么跑、坑在哪、怎么改」的顺序拆。

2. 技术栈与工程结构:SSM 三件套在这类项目里怎么落位

2.1 选型:为什么不上 Spring Boot

这类健康管理项目多数是从课程作业或毕设长出来的,第一诉求不是「先进」,而是「每一层都看得见」。Spring Boot 把自动配置藏进 starter,初学者排查一个问题要先翻源码;SSM 的 XML 配置啰嗦,但每个 bean、每个事务切点、每个 Mapper 扫描路径都摆在明面上,请求从 DispatcherServlet 进来以后拐了几个弯能一路对上。再加上工程打成 war 丢进 Tomcat 就能跑,答辩或演示时换台机器不用重装环境,这在实验室机房里是很实际的优势。

技术搭配是这样的:

层次技术常见搭配在这项目里管什么
视图HTML / JSP / jQuery / BootstrapJSP 2.x表单录入、列表渲染、图表容器
控制Spring MVCSpring 4.x路由映射、参数绑定、JSON 返回
业务Spring IoC + AOP同上Service 装配、声明式事务
持久MyBatis3.xSQL 映射、resultMap 关联查询
存储MySQL5.7成员、指标字典、健康记录
容器Tomcat8.xwar 部署

值得单独说一句的是前端为什么用 JSP 而不是前后端分离。这个项目的页面大多是「查列表 → 填表单 → 提交 → 回列表」,用 JSP 加 JSTL 直接渲染,少一层接口联调;只有趋势图那一块需要 JSON,单独开一个@ResponseBody接口喂给 ECharts 就够了。分工清楚,改起来不打架。

2.2 目录结构:一个包对应一层,别混着装

工程是标准的 Maven war 结构,src/main/java下按层分包,src/main/webapp下放页面和静态资源,src/main/resources下放配置和映射文件。这个划分不新鲜,但落到具体文件上很多人会乱放,比如把 Mapper XML 塞进java目录导致打包丢失,或者把jdbc.properties放在 webapp 里被明文下载。

family-health/ ├── src/main/java/com/family/health/ │ ├── controller/ # 请求入口,只做参数校验和转发 │ │ ├── MemberController.java │ │ ├── RecordController.java │ │ └── LoginController.java │ ├── service/ # 业务规则,指标判定写在这里 │ │ ├── MemberService.java │ │ └── impl/MemberServiceImpl.java │ ├── dao/ # 只声明接口,SQL 在 XML 里 │ │ ├── MemberMapper.java │ │ └── RecordMapper.java │ ├── entity/ # 与表字段一一对应 │ │ ├── Member.java │ │ ├── Indicator.java │ │ └── HealthRecord.java │ ├── vo/ # 页面用的组合对象,如趋势点 │ └── util/ # 日期工具、指标判定工具 ├── src/main/resources/ │ ├── jdbc.properties # 数据源四要素 │ ├── spring-mybatis.xml # 数据源 + SqlSessionFactory + 事务 │ ├── spring-mvc.xml # 扫描 controller、视图解析器 │ └── mapper/ # 与 dao 接口同名的 XML ├── src/main/webapp/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── views/ # jsp 页面 │ └── static/ # js、css、图片 └── pom.xml

dao接口和mapper下的 XML 靠「同包名 + 同类名」的约定绑定,前提是mybatis-config里配了mapperLocations。这一点如果不配,启动时不报错,调用时才抛BindingException: Invalid bound statement,属于典型的延迟爆炸。我一般会在spring-mybatis.xml里显式写死路径:

<!-- spring-mybatis.xml 片段 --> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <!-- 关键:告诉 MyBatis 去哪里找 Mapper XML,路径必须精确到 *.xml --> <property name="mapperLocations" value="classpath:mapper/*.xml"/> <!-- 实体包别名,XML 里 resultType 可以直接写 Member --> <property name="typeAliasesPackage" value="com.family.health.entity"/> </bean> <!-- DAO 接口批量扫描,省去一个个写 MapperFactoryBean --> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.family.health.dao"/> </bean> <!-- 声明式事务:Service 层方法抛异常回滚 --> <bean id="txManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="txManager"/>

mapperLocations里的classpath:前缀不能省,写成mapper/*.xml在某些容器下会解析到 webapp 目录去,结果就是 XML 明明在包里却加载不到。typeAliasesPackage是省事用的,代价是同一个类名不能重复,工程小的时候无所谓。事务管理器认的是dataSource,所以数据源 bean 的 id 必须和这里ref一致,改名的时候两处一起改。

2.3 三处必改配置:web.xml、数据源、视图解析

接到一个 SSM 工程先别急着跑,把这三处对齐基本能省掉一半的启动问题。web.xml决定请求怎么被接住,数据源决定连不连得上,视图解析决定页面出不出得来。

<!-- web.xml 核心片段 --> <servlet> <servlet-name>dispatcher</servlet-name> <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class> <!-- 指定 MVC 配置文件,不写默认找 /WEB-INF/dispatcher-servlet.xml --> <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> <!-- 拦截 "/" 但放过静态资源,配合 mvc:resources 使用 --> <url-pattern>/</url-pattern> </servlet-mapping> <!-- Spring 根容器,加载 Service、数据源、事务 --> <context-param> <param-name>contextConfigLocation</param-name> <param-value>classpath:spring-mybatis.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- 编码过滤器,中文乱码的头号源头 --> <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> <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>

这里有两个容器:ContextLoaderListener起的是根容器,管 Service、DAO、数据源;DispatcherServlet起的是子容器,管 Controller。子容器能看到父容器的 bean,反过来不行——所以 Controller 里@Autowired一个 Service 没问题,但如果在 Service 里注入 Controller 就会拿到 null。url-pattern写/而不是/*,是为了给静态资源留出交由默认 servlet 处理的空间,配/*会把 css、js 一起卷进 DispatcherServlet,页面直接白板。

jdbc.properties四项加一个时区,MySQL 8 的驱动不写时区会报The server time zone value is unrecognized:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/family_health?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码

useUnicode和characterEncoding是给连接层用的,和web.xml里的编码过滤器是两回事,两边都要设。serverTimezone在 MySQL 5.7 驱动上可以省,8.x 上基本必写。视图解析器放在spring-mvc.xml:

<bean class="org.springframework.web.servlet.view.InternalResourceViewResolver"> <property name="prefix" value="/WEB-INF/views/"/> <property name="suffix" value=".jsp"/> </bean> <!-- 静态资源放行 --> <mvc:resources mapping="/static/**" location="/static/"/>

prefix指向WEB-INF下是为了让 JSP 不能被浏览器直连访问,必须走 Controller。这个细节在做权限控制时很关键——如果页面放在 webapp 根目录,用户绕过登录直接敲 URL 就能看到数据。

3. 核心模块拆解:健康指标从录入到趋势图的完整链路

3.1 表结构:四张表撑起健康管理

这个项目的业务不复杂,但表必须切干净,不然后面加指标就得改代码。核心是四张表:账号表、成员表、指标字典表、记录表。指标字典单独拆出来,是为了让「血压、血糖、体重、心率、血氧」这些项能增删,而不是把字段硬编码在实体里。

-- 家庭成员表:一个账号可以管多个成员(老人、孩子) CREATE TABLE t_member ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL COMMENT '所属账号', name VARCHAR(32) NOT NULL, gender TINYINT DEFAULT 1 COMMENT '1男 2女', birth_date DATE, relation VARCHAR(16) COMMENT '父亲/母亲/本人/子女', height DECIMAL(5,1) COMMENT '身高cm,用于算BMI', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 指标字典表:参考范围存在这里,判定逻辑就不用写死 CREATE TABLE t_indicator ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL UNIQUE COMMENT '如 sbp/dbp/glu/weight', name VARCHAR(32) NOT NULL, unit VARCHAR(16), ref_min DECIMAL(8,2) COMMENT '参考下限', ref_max DECIMAL(8,2) COMMENT '参考上限', sort_no INT DEFAULT 0 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 健康记录表:一条记录 = 一个成员 + 一个指标 + 一个时间点 CREATE TABLE t_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, member_id INT NOT NULL, indicator_code VARCHAR(32) NOT NULL, value DECIMAL(8,2) NOT NULL, status TINYINT DEFAULT 1 COMMENT '0偏低 1正常 2偏高', measure_time DATETIME NOT NULL, remark VARCHAR(255), KEY idx_member_ind_time (member_id, indicator_code, measure_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

t_record上那个联合索引(member_id, indicator_code, measure_time)是给趋势图查询用的。页面最常打的一条 SQL 是「某个成员在某个时间段内某项指标的全部值,按时间升序」,顺序刚好和索引一致,能走索引覆盖。不建这个索引,记录攒到几千条以后图会明显变慢,这是我自己踩过的。

status冗余存一份是有意的:判定规则以后可能要改(比如不同年龄段阈值不同),如果每次都实时算,历史记录的状态会跟着变,用户翻旧数据会发现「上个月显示偏高,现在变正常了」,很迷惑。所以录入时算一次落库,改规则时只影响新数据,老数据保持当时的判断。

3.2 实体与 Mapper:一个成员带出最近一次记录

成员列表页要显示「最近一次测量时间和数值」,典型的一对一关联。用 MyBatis 的resultMap加association解决,比在 Java 里循环查 N 次干净得多。

// MemberVO:成员 + 最近一条记录,供列表页使用 public class MemberVO extends Member { private String lastIndicatorName; private BigDecimal lastValue; private Date lastTime; // getter / setter 省略 }
<!-- MemberMapper.xml --> <resultMap id="memberVOMap" type="com.family.health.vo.MemberVO"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="relation" column="relation"/> <result property="birthDate" column="birth_date"/> <!-- 最近一次记录,子查询只取一条 --> <association property="lastValue" column="id" select="com.family.health.dao.RecordMapper.selectLatestValue"/> </resultMap> <select id="listByUser" resultMap="memberVOMap"> SELECT id, name, relation, birth_date FROM t_member WHERE user_id = #{userId} ORDER BY id </select>
<!-- RecordMapper.xml:关联查询的目标语句 --> <select id="selectLatestValue" resultType="java.math.BigDecimal"> SELECT r.value FROM t_record r WHERE r.member_id = #{id} ORDER BY r.measure_time DESC LIMIT 1 </select>

association的select属性走的是嵌套查询,好处是 SQL 简单、能复用,代价是 N+1——列表有 10 个成员就发 11 条 SQL。家庭成员量级很小,这点开销可以接受;如果换成设备上报数据,就得改成LEFT JOIN加窗口函数或者子查询取 max,一次查完。

提示:association的column写的是传给子查询的参数列名,不是属性名。写成memberId而 SQL 里别名是id,子查询会收到 null,结果全是空值,不报错,很难发现。

3.3 Service 层:指标判定与事务边界

判定逻辑放在 Service,不放在 Controller 也不放在 SQL 里。原因很直接:录入时要用(决定 status),查询统计时要用(算异常占比),如果写两遍迟早不一致。

@Service public class RecordServiceImpl implements RecordService { @Autowired private RecordMapper recordMapper; @Autowired private IndicatorMapper indicatorMapper; /** * 录入一条健康指标记录 * @param record 前端传入,含 memberId、indicatorCode、value、measureTime * @return 落库后的记录,status 已回填 */ @Override @Transactional(rollbackFor = Exception.class) public HealthRecord save(HealthRecord record) { // 1. 取出指标字典,拿参考范围 Indicator ind = indicatorMapper.selectByCode(record.getIndicatorCode()); if (ind == null) { throw new IllegalArgumentException("未知指标编码:" + record.getIndicatorCode()); } // 2. 判定状态:低于下限 0,高于上限 2,其余 1 BigDecimal v = record.getValue(); int status = 1; if (ind.getRefMin() != null && v.compareTo(ind.getRefMin()) < 0) { status = 0; } else if (ind.getRefMax() != null && v.compareTo(ind.getRefMax()) > 0) { status = 2; } record.setStatus(status); // 3. 测点时间没传就用当前时间,避免插入 null 后排序错乱 if (record.getMeasureTime() == null) { record.setMeasureTime(new Date()); } recordMapper.insert(record); return record; } }

BigDecimal比较必须用compareTo,用equals会连 scale 一起比,5.0和5.00返回 false,判定直接翻车。@Transactional加rollbackFor = Exception.class是因为 Spring 默认只对RuntimeException回滚,抛受检异常时事务不回滚,脏数据就留在库里了。

measureTime的兜底也很重要。前端用日期控件传值,如果用户没选,绑定到Date上是 null,插入后这一行在ORDER BY measure_time DESC里会跑到最前面或最后面(取决于数据库),趋势图的时间轴就乱了。宁可在这里补当前时间。

3.4 Controller 与前端:给 ECharts 喂一份 JSON

趋势图那块不需要页面刷新,单独开一个接口返回数组,前端用 ajax 拉回来直接塞给 ECharts。Controller 只做参数接收和格式转换,不做业务判断。

@Controller @RequestMapping("/record") public class RecordController { @Autowired private RecordService recordService; /** * 趋势图数据接口 * @param memberId 成员 id * @param code 指标编码,如 sbp * @param days 最近天数,默认 30 */ @RequestMapping("/trend") @ResponseBody public Map<String, Object> trend(@RequestParam Integer memberId, @RequestParam String code, @RequestParam(defaultValue = "30") Integer days) { // 起止时间在 Service 里算,Controller 不碰业务 List<HealthRecord> list = recordService.listTrend(memberId, code, days); List<String> axis = new ArrayList<>(); List<BigDecimal> data = new ArrayList<>(); SimpleDateFormat fmt = new SimpleDateFormat("MM-dd"); for (HealthRecord r : list) { axis.add(fmt.format(r.getMeasureTime())); data.add(r.getValue()); } Map<String, Object> result = new HashMap<>(); result.put("axis", axis); result.put("data", data); return result; } }

@ResponseBody依赖spring-mvc.xml里的<mvc:annotation-driven/>,少了这行,返回值会被当成视图名去解析,浏览器报 404 而不是返回 JSON——这是个高频误判点,看日志会以为是路径写错了。

前端拿数据:

// 页面加载时按成员和指标拉趋势 function loadTrend(memberId, code) { $.get('/record/trend', {memberId: memberId, code: code, days: 30}, function (res) { var chart = echarts.init(document.getElementById('trendBox')); chart.setOption({ xAxis: {type: 'category', data: res.axis}, yAxis: {type: 'value'}, series: [{type: 'line', data: res.data, smooth: true}] }); }, 'json'); }

days默认值 30 靠defaultValue兜底,前端不传也不会 400。ECharts 容器必须有明确高度,只写宽度不写高度时图渲染不出来,控制台无报错,纯白——第一次遇到能查半天。

4. 跑起来:环境准备、建库到部署的完整步骤

4.1 环境清单与版本对齐

跑 SSM 老工程最省事的办法是「按它出生的年代配环境」,别硬上新版本。常见搭配是 JDK 8、Maven 3.6、Tomcat 8.5、MySQL 5.7 或 8.0、IDEA 社区版以上。

组件建议版本检查命令常见问题
JDK8java -version11/17 编译报package javax.servlet不存在
Maven3.6+mvn -v镜像未配,依赖拉半小时
Tomcat8.5启动日志版本号10.x 把 javax 换成 jakarta,全崩
MySQL5.7 / 8.0mysql -V8.0 要额外配时区与驱动类名

Tomcat 10 是最容易踩的:它把javax.servlet.*全部改成jakarta.servlet.*,而这个工程的pom.xml里依赖的是老坐标,编译能过,部署时ClassNotFoundException一堆。要么降到 8.5,要么整体迁移,前者五分钟,后者半天。

4.2 建库与导入初始数据

先建库再导 SQL,字符集统一utf8mb4,不然成员姓名里有生僻字会变成问号。

# 1. 登录并建库 mysql -u root -p -e "CREATE DATABASE family_health DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" # 2. 导入结构与初始数据(脚本里一般带建表语句和指标字典) mysql -u root -p family_health < sql/family_health.sql # 3. 确认字典表有数据,没有的话判定逻辑会全部抛「未知指标」 mysql -u root -p -e "SELECT code, name, ref_min, ref_max FROM family_health.t_indicator;"

第三步容易被跳过。有些工程的 SQL 脚本只建表不插字典,跑起来后录第一条记录就抛IllegalArgumentException,看着像代码 bug,其实是数据没初始化。字典表至少要塞进收缩压(90–140)、舒张压(60–90)、空腹血糖(3.9–6.1)、体重、心率这几项。

4.3 改配置并打包

拿到工程后要改的地方集中在配置文件,不改的话连不上库。

# src/main/resources/jdbc.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/family_health?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=改成你自己的

com.mysql.cj.jdbc.Driver对应 MySQL 8 的mysql-connector-java 8.x,如果pom.xml里是 5.1.x 的依赖,驱动类要写回com.mysql.jdbc.Driver,否则启动报ClassNotFoundException。allowPublicKeyRetrieval=true是 8.0 默认加密方式下本地连接常需要的参数,缺了会报Public Key Retrieval is not allowed。

改完配置打包:

# 跳过测试打包,产出 target/*.war mvn clean package -DskipTests # 把 war 拷进 Tomcat 的 webapps,重命名为 ROOT.war 可省掉一级路径 cp target/family-health.war $TOMCAT_HOME/webapps/ $TOMCAT_HOME/bin/startup.sh

-DskipTests是因为这类工程里的测试类经常是空的或者依赖本地数据库,跑了反而失败。生产项目别这么干,这是本地验证的省事做法。

4.4 启动验证:按依赖顺序看日志

启动不是「没报错就算成功」,要按catalina.out里的顺序确认三件事:根容器加载完、数据源连上、Mapper 绑定成功。

# 盯日志里的关键行 tail -f $TOMCAT_HOME/logs/catalina.out | grep -E "Root WebApplicationContext|Initializing|Mapped|BindingException"

看到Root WebApplicationContext: initialization completed说明 Service 那层起来了;出现Mapped "{[/record/trend]}"说明 Controller 映射成功;如果刷出Invalid bound statement (not found),就是 2.2 节说的mapperLocations没配或路径写错。三条都过之后,浏览器访问http://localhost:8080/family-health/login,用脚本里的初始化账号登入,先建一个成员,再录一条血压,回列表看status是不是按参考范围标了色。这条链路通了,后面改功能才有意义。

5. 避坑排查:SSM 老项目最容易翻车的五个地方

5.1 启动报 404,日志里一条映射都没有

现象:Tomcat 启动正常,访问任何页面都是 404,catalina.out里搜不到Mapped。

原因:web.xml里DispatcherServlet的contextConfigLocation指错了,或者spring-mvc.xml里的<context:component-scan base-package="...">扫的包名和实际不一致,导致 Controller 根本没被注册。

解决:先看spring-mvc.xml的扫描路径是不是覆盖到com.family.health.controller,再看web.xml的init-param是不是写成了classpath*:spring-mvc.xml之外的位置。判断依据是日志里有没有Detected @RequestMapping之类的行,没有就是没扫到。

5.2 表单提交中文全变问号

现象:页面输入的中文姓名存进库变成???,从库里读出来也乱。

原因:三个环节任一没设 UTF-8 都会出问题——页面<%@ page contentType="text/html;charset=UTF-8" %>、web.xml的CharacterEncodingFilter、数据库连接的characterEncoding=utf8,还有表本身的字符集。

解决:从后往前查。先SHOW CREATE TABLE t_member看是不是utf8mb4,再看jdbc.url有没有characterEncoding,最后看过滤器是不是forceEncoding=true。forceEncoding很重要,只设encoding的话,响应流的编码不会被强制,GET 请求的参数还是会乱。

5.3 一对多查询只返回一条

现象:一个成员有多条记录,用collection查出来列表里只有第一条。

原因:resultMap里的<id>标签用错了列。MyBatis 靠<id>判断是不是同一行,如果collection关联的resultMap里把主表的 id 当成了<id>,多条子记录会被合并成一条。

解决:主表和子表的resultMap各自用各自的主键列做<id>,子表用t_record.id,主表用t_member.id。另外检查 SQL 里有没有LIMIT 1被顺手带进来了。

5.4 页面上的日期后面多个「.0」

现象:2024-05-12 08:30:00.0,时间后面的毫秒位多出来一截,看着很业余。

原因:java.util.Date在 JSP 上用${record.measureTime}直接输出时,走的是toString(),带毫秒。

解决:用 JSTL 的<fmt:formatDate value="${record.measureTime}" pattern="yyyy-MM-dd HH:mm"/>输出。或者干脆在实体里换成java.time.LocalDateTime,但老工程里Date用得多,改起来牵连面大,格式化更稳。

5.5 静态资源全部 404,页面裸奔

现象:JSP 出来了,但 css、js 全丢,控制台一堆 404。

原因:DispatcherServlet的url-pattern写成了/,静态资源请求也被它接管,但没配mvc:resources,找不到处理器。

解决:在spring-mvc.xml里加<mvc:resources mapping="/static/**" location="/static/"/>,同时确认<mvc:annotation-driven/>存在。如果页面路径是/css/app.css这种直接挂在根下的,mapping 要相应改成/css/**。

6. 二次开发:把指标阈值和成员隔离抽出来

原始工程里最该改的两处,一是指标参考范围,二是数据归属。参考范围现在存在t_indicator表里,已经比硬编码好,但它只有一组全局阈值——老人和小孩的正常血压不一样,一套值套全家迟早出问题。比较顺手的改法是加一张t_indicator_ref,按年龄段分段:

字段类型说明
indicator_codeVARCHAR(32)关联字典表
age_min / age_maxINT年龄段,左闭右开
genderTINYINT0 不限,1 男,2 女
ref_min / ref_maxDECIMAL(8,2)该段参考范围

Service 里判定时先按成员出生日期算年龄,再按年龄和性别去查阈值,查不到就回落到字典表的默认值:

// 按年龄取阈值,取不到就用指标字典的默认范围 IndicatorRef ref = refMapper.selectByAge(code, age, gender); BigDecimal min = ref != null ? ref.getRefMin() : ind.getRefMin(); BigDecimal max = ref != null ? ref.getRefMax() : ind.getRefMax();

另一处是数据归属。现在listByUser(userId)靠前端传的userId,登录后从 session 取,但成员详情、记录删除这些接口如果没校验memberId是不是当前账号的,改个 id 就能看别人家的数据。我一般加一个HandlerInterceptor,在preHandle里统一校验成员归属,校验不过直接返回 403,比在每个 Controller 里写一遍 if 靠谱。

验证方法就三条:注册两个账号各建一个成员,用 A 的会话去请求 B 的memberId,应该被拦;给同一个指标配两组年龄段阈值,用不同年龄的成员各录一条相同数值,status应该不同;把旧数据翻出来看,历史记录的status不应该因为改了阈值表而变化。

从那以后我每次接手这类 SSM 老项目,第一件事都是先把阈值、角色、数据归属这三样从代码里抽出来看一眼,抽不出来的就先记一笔——不然后面每加一个功能都得回来补一遍。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进