简介面向课程设计、毕业设计及JavaWeb入门进阶者的运动与健康管理系统完整源码与数据库包围绕用户管理、运动记录、健康监测、数据分析、提醒建议和社区互动等模块提供可参考的实现范例与表结构设计。压缩包共707个文件、约9.59MB包含74个JSP页面、34个Java源文件与对应class文件、15个jar依赖以及181个JavaScript、18个CSS和47个HTML等前端资源并配有图片素材与SQL初始化脚本基本覆盖系统运行与页面展示所需内容。已有2020人学习下载。读者可据此梳理Servlet/JSP与JavaBean的协作方式、用户表/运动记录表/健康指标表之间的关系理解卡路里统计、趋势图展示和个性化建议的实现思路也便于在本地部署后做功能裁剪与二次开发作为课程答辩和项目练习的对照材料。1. 一台跑不起来的运动健康系统卡住的往往不是业务代码前阵子有人丢过来一份 javaweb 运动与健康管理系统 的压缩包说库也建了、Tomcat 也起了登录页能打开可一点“保存运动记录”就白屏控制台刷出一片红。我打开看了十分钟问题根本不在业务逻辑jdbc.properties里还留着原作者本机的root/123456Servlet 里拿String直接往java.util.Date上塞驱动包是 5.x 却连着一个 8.x 的库。把这三处按顺序处理掉整套系统就跑通了。这类源码包的价值不在于代码多高级而在于它把“运动打卡 健康指标 饮食记录 后台统计”这条完整业务线铺开了MVC 分层干净表结构也能直接看懂。适合两类人一类是要拿它做课程设计、毕业设计底座的学生改改界面加两个报表就能交另一类是想把 JavaWeb 那套请求链路从头到尾走一遍的开发者Servlet 怎么接参数、DAO 怎么拼 SQL、JSP 怎么回显全在明面上。下面按我实际拆解的顺序来先看结构和表再把环境跑起来然后拆一条完整链路最后讲怎么往上加东西。2. 技术栈与目录结构一个 JavaWeb 工程的分层长什么样拿到一个没写 README 的源码包第一件事不是急着点运行而是先确认它属于哪一代技术栈。这决定了你后面要装什么、改哪里、能不能直接扔进 IDEA 跑。2.1 技术选型为什么还在用 JSP Servlet JDBC 这套组合拆开WEB-INF/lib目录基本就能定性。这份运动与健康管理系统用的是典型的原生 JavaWeb 三件套JSP 负责视图渲染Servlet 承担控制器JavaBean 做数据载体持久层直接手写 JDBC中间没有 Spring、没有 MyBatis也没有 Maven 的pom.xml。前端是 Bootstrap 3 加 jQuery统计页面上挂了 ECharts 画柱状图和折线图数据库是 MySQL。这套组合放在今天看确实老但它有几个对初学者特别友好的地方。第一是“所见即所得”没有框架的依赖注入和代理一个 HTTP 请求从index.jsp发出到XxxServlet.doPost()里被接住再到XxxDao里拼 SQL链路短得能一眼看穿出了错堆栈直接指到行号。第二是部署零门槛不用配 Maven 私服、不用管 Spring Boot 的自动装配把 jar 包往WEB-INF/lib里一拷整个工程丢进 Tomcat 的webapps就能跑。第三是改起来没心理负担想加个字段就在表里加一列、Bean 里加一个属性、JSP 里加一个input三处改完就通了。代价也很明显SQL 全靠手写注入防护得自己上PreparedStatement分页要手动算limit偏移量连接池如果作者没配那就是每次请求DriverManager.getConnection()新建连接压测一上来数据库直接拒绝服务。这些在后面章节会逐个说。技术栈清单大致是这样层次技术说明视图层JSP JSTL Bootstrap 3 jQuery页面在WebContent下含少量 EL 表达式控制层Servlet 3.0注解或 web.xml 注册每个业务模块一个 Servlet模型层普通 JavaBean与数据库表字段一一对应持久层原生 JDBC DBUtil工具类手写 SQL无 ORM数据库MySQL 5.7 / 8.0随包附带.sql脚本容器Tomcat 8.5 / 9.0Servlet 3.1 规范图表ECharts用于体重趋势、运动时长统计提示如果lib里出现spring-webmvc-x.x.jar或mybatis-x.x.jar说明这是 SSM 版本启动方式要换成applicationContext.xml加 Tomcat 插件别按纯 Servlet 的思路去改。2.2 目录结构src、WebContent、sql 三块怎么摆源码包解压后通常是三个平级目录加一个说明文档。结构大致如下路径名各家会有出入但分层逻辑基本一致sport-health/ ├── src/ # Java 源码根目录 │ ├── com/health/bean/ # 实体类User、SportRecord、HealthRecord │ ├── com/health/dao/ # 数据访问SportRecordDao、UserDao │ ├── com/health/servlet/ # 控制器LoginServlet、SportServlet │ ├── com/health/util/ # 工具类DBUtil、DateUtil、PageUtil │ └── db.properties # 数据库连接配置有的放在 src 根下 ├── WebContent/ # Web 根目录等价于 Maven 的 webapp │ ├── WEB-INF/ │ │ ├── lib/ # mysql-connector、jstl、servlet-api 等 jar │ │ └── web.xml # Servlet 映射、欢迎页、过滤器配置 │ ├── css/ js/ images/ # 静态资源含 bootstrap 与 echarts │ ├── common/ # 公共页头页尾如 header.jsp、footer.jsp │ ├── admin/ # 后台页面用户管理、数据审核 │ └── *.jsp # 前台页面登录、运动打卡、健康档案 └── sql/ └── health_db.sql # 建库建表 初始数据的完整脚本src下的包名是判断分层是否规范的关键。合格的写法是bean / dao / servlet / util四层拆开dao里只有 SQL 和结果集映射servlet里只做参数接收和跳转业务判断尽量薄。我见过不少改过的版本把所有逻辑塞进 Servlet 的doPost里一个方法两百行那种后面想加个统计功能基本等于重写。WebContent/WEB-INF/web.xml值得单独看一眼。如果是 Servlet 3.0 以上作者多半用WebServlet(/sport)注解注册web.xml里就只剩欢迎页和CharacterEncodingFilter的配置。字符编码过滤器非常关键缺了它JSP 提交的中文运动备注进库就是一堆问号这个坑在第 5 章会展开说。sql/health_db.sql是整个包里最该先读的文件。它比任何文档都直观库名、表名、字段类型、初始化的测试账号全在里面。很多人上来就点点点结果发现登录不进去其实脚本末尾就写着默认管理员账号密码。2.3 数据库表设计运动记录与健康指标怎么建运动与健康管理这类系统核心就四张业务表加一张用户表。字段名各家不同但关系模型是通的。下面这份是这类项目最典型的建表方式我按它讲你手上的包字段名可能叫sport_type而不是sportType意思一样-- 用户表登录与基础档案 CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(64) NOT NULL COMMENT 明文或MD5看作者实现, real_name varchar(50) DEFAULT NULL, gender tinyint(1) DEFAULT 0 COMMENT 0女 1男, age int(11) DEFAULT NULL, height decimal(5,2) DEFAULT NULL COMMENT 身高cm用于算BMI, role tinyint(1) DEFAULT 0 COMMENT 0普通用户 1管理员, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 运动记录表一次运动一条是系统的主业务表 CREATE TABLE sport_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, sport_type varchar(30) NOT NULL COMMENT 跑步/游泳/骑行等, duration int(11) NOT NULL COMMENT 时长单位分钟, calorie decimal(8,2) DEFAULT NULL COMMENT 消耗卡路里, sport_date date NOT NULL COMMENT 运动日期统计按这个字段分组, remark varchar(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_user_date (user_id,sport_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 健康指标表体重、体脂、心率等按天记录 CREATE TABLE health_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, weight decimal(5,2) DEFAULT NULL, body_fat decimal(5,2) DEFAULT NULL, heart_rate int(11) DEFAULT NULL, record_date date NOT NULL, PRIMARY KEY (id), KEY idx_user_date (user_id,record_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;表设计里有三个点值得注意。sport_record上建了(user_id, sport_date)的联合索引因为统计页最常见的查询就是“某用户某段时间的运动汇总”没这个索引数据量一过万就明显卡。health_record把体重和体脂拆成独立字段而不是键值对好处是 SQL 好写、图表好取坏处是加新指标要改表结构二次开发时如果想加“睡眠时长”就得走ALTER TABLE。user.height冗余存身高是为了在列表页直接算 BMI 而不用联表属于典型的用空间换查询简单。餐饮记录表和运动计划表结构类似都是user_id 日期 若干业务字段这里不重复贴。真正要留意的是外键这份脚本大概率没建物理外键只在 Java 层维护关联删用户的时候记得手动清理子表数据否则会留一堆孤儿记录统计报表上就出现“无主的运动数据”。3. 环境搭建与启动从导入工程到第一个页面出来结构看明白了接下来是让它在自己机器上跑起来。这段是最容易劝退的因为版本一错报错信息跟真实原因八竿子打不着。3.1 JDK、Tomcat、MySQL 的版本对齐三个组件的版本必须先定下来再动手。这类原生 JavaWeb 项目的兼容窗口其实很窄JDK 用 8 最稳。项目里如果出现% out.print(...) %和大量匿名内部类用 JDK 17 编译大概率会因为模块化限制报javax包找不到。真要用高版本把 Tomcat 升到 10 之前先想清楚Tomcat 10 把javax.servlet全改成了jakarta.servlet源码里所有import javax.servlet.*都得改工程量不小。Tomcat 用 8.5 或 9.0。9.0 是javax命名空间的最后一版跟 JDK 8 配合最省事。MySQL 用 5.7 或 8.0但驱动包版本必须跟着库走。连 5.7 用mysql-connector-java-5.1.x连 8.0 用8.0.x混搭就会出现Unknown system variable query_cache_size或者 SSL 握手警告。我一般会先看一眼WEB-INF/lib/mysql-connector-java-*.jar的版本号再决定装哪个版本的 MySQL而不是反过来。改驱动比改数据库省事得多。3.2 数据库导入与连接参数修改先建库再导数据。用命令行或者 Navicat 都行注意脚本里如果写了CREATE DATABASE直接整份执行如果只有USE得先手动建库。执行完检查一下表数量和字符集utf8mb4才对出现latin1后面中文一定出问题。接下来改连接配置。找到src/db.properties或src/com/health/util/DBUtil.java里的硬编码两种形态都常见# src/db.properties 典型内容 jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/health_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai jdbc.usernameroot jdbc.password你的本机密码三个参数必须改username和password换成自己的url里的库名health_db对上你实际建的库名。serverTimezone是 8.0 驱动的必需项不写会在插入date类型时报时区异常。如果用的是 5.1.x 驱动驱动类名是com.mysql.jdbc.Drivercj那一段要去掉这是最常见的复制粘贴翻车点。如果作者把配置写死在DBUtil里而不是配置文件就改常量// com/health/util/DBUtil.java public class DBUtil { // 注意这里的库名、账号、密码要和本机 MySQL 保持一致 private static final String URL jdbc:mysql://localhost:3306/health_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PWD 你的本机密码; // 静态块加载驱动整个应用生命周期只执行一次 static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL 驱动未找到检查 WEB-INF/lib 下是否有对应 jar); } } // 每次调用返回一个新连接调用方必须自己 close public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } }static块加载驱动只做一次getConnection()每次返回新连接这个设计简单但也有坑——调用方忘了close()就是连接泄漏几十次请求之后数据库端wait_timeout一到整个系统开始随机报错。后面第 5 章会讲怎么临时兜住。3.3 项目部署与访问验证部署分两步把工程放进 Tomcat把依赖 jar 补全。IDEA 里的操作路径是File → Project Structure → Modules把src标成 SourcesWebContent标成 Web Resource Directory然后配一个Tomcat Local的 Run ConfigurationDeployment 里加artifact: xxx:war explodedApplication context 填/sport之类。Eclipse 用户直接Import → Existing Projects into Workspace工程如果带了.project和.classpath会自动识别。命令行部署更直白把整个工程目录重命名为sport拷进TOMCAT_HOME/webapps/把WEB-INF/lib里的 jar 确认齐全至少要有mysql-connector、jstl、standard启动bin/startup.bat访问http://localhost:8080/sport/。验证顺序我一般这么走出问题能快速定位在哪一层先访问一个纯静态页或欢迎页能出来说明 Tomcat 和部署路径没问题。再访问login.jsp能渲染说明 JSP 编译没问题。用初始化脚本里的测试账号登录能进主页说明数据库连通、DBUtil配置正确。最后点一次运动记录保存能跳回列表且数据出现在表格里说明 Servlet 到 DAO 整条链路通了。四步里前三步都过、第四步白屏问题基本锁定在类型转换或 SQL 语法跟环境无关直接看控制台第一条Caused by就行。4. 核心模块拆解一次运动记录提交的完整链路环境跑通只是开始。真正决定你能不能改这个项目的是搞清楚“点一下保存”之后代码走了哪些地方。拿运动记录模块当样本从 JSP 到 Servlet 再到 DAO完整拆一遍。4.1 JSP 表单参数怎么传出去前台的运动打卡页面通常叫sport_add.jsp或addSport.jsp就是一个标准 HTML 表单外加 JSTL 回显当前登录用户%-- WebContent/sport_add.jsp --% % page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % form idsportForm action${pageContext.request.contextPath}/sport?actionadd methodpost input typehidden nameuserId value${sessionScope.loginUser.id}/ div classform-group label运动类型/label select namesportType classform-control c:forEach items${sportTypes} vart option value${t}${t}/option /c:forEach /select /div div classform-group label运动时长分钟/label input typenumber nameduration classform-control required/ /div div classform-group label运动日期/label input typedate namesportDate classform-control required/ /div textarea nameremark classform-control rows3/textarea button typesubmit classbtn btn-primary保存/button /form几个细节先说清。action里的${pageContext.request.contextPath}不能省省了以后一旦部署 context 不叫/请求就会打到 Tomcat 根路径上变成 404这是新手最常见的翻车方式。methodpost也必须写用 get 提交中文备注会被容器按 ISO-8859-1 解码进库就是乱码。userId放隐藏域而不是让用户填取值来源是登录时塞进 session 的loginUser对象——这也是判断整套系统登录逻辑对不对的第一个观察点。input typedate提交上来的格式是2024-05-01这样的字符串Servlet 拿到的是String转成java.sql.Date才能塞进数据库的date字段。这一步是所有 JavaWeb 项目里最爱出问题的地方。4.2 Servlet 控制层参数接收、校验与跳转Servlet 是这条链路的中枢。按action参数分发是最常见的写法一个 Servlet 管一个模块的增删改查// com/health/servlet/SportServlet.java WebServlet(/sport) public class SportServlet extends HttpServlet { private final SportRecordDao sportDao new SportRecordDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { doPost(req, resp); // 列表页、删除都用 get统一转 post 处理 } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在取参数之前设置编码否则中文备注必乱码 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String action req.getParameter(action); if (add.equals(action)) { add(req, resp); } else if (list.equals(action)) { list(req, resp); } else if (delete.equals(action)) { delete(req, resp); } else { list(req, resp); // 兜底避免 action 缺失导致空指针 } } private void add(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String userIdStr req.getParameter(userId); String durationStr req.getParameter(duration); String dateStr req.getParameter(sportDate); // 服务端校验不能省前端 required 可以被绕过 if (userIdStr null || durationStr null || dateStr null) { req.setAttribute(msg, 参数不完整请重新填写); req.getRequestDispatcher(/sport_add.jsp).forward(req, resp); return; } SportRecord r new SportRecord(); r.setUserId(Integer.parseInt(userIdStr)); r.setSportType(req.getParameter(sportType)); r.setDuration(Integer.parseInt(durationStr)); // String - java.sql.Date格式必须与前端 date 控件一致 r.setSportDate(java.sql.Date.valueOf(dateStr)); r.setRemark(req.getParameter(remark)); // 卡路里按“每分钟 5 千卡”粗算作者多写成固定系数 r.setCalorie(r.getDuration() * 5.0); boolean ok sportDao.insert(r); if (ok) { resp.sendRedirect(req.getContextPath() /sport?actionlist); } else { req.setAttribute(msg, 保存失败请稍后重试); req.getRequestDispatcher(/sport_add.jsp).forward(req, resp); } } }这段代码有三处值得记住的设计取舍。doGet直接转doPost是因为列表和删除都走链接跳转统一在doPost里按action分发能少写一套逻辑代价是 get 请求也会走一遍setCharacterEncoding——好在无害。java.sql.Date.valueOf(dateStr)是整条链路最容易炸的一行。它要求字符串严格是yyyy-MM-dd前端换了格式、或者用户手输成2024/5/1直接抛IllegalArgumentException页面白屏且堆栈不友好。健壮写法是包一层 try-catch用SimpleDateFormat解析后转java.sql.Date解析失败给出提示而不是让异常冒到容器。这一点在改造时值得优先补上。成功后用sendRedirect而不是forward是为了避免用户刷新页面重复提交。这个细节很多改过的版本会丢掉导致同一条运动记录被插两次统计数字虚高。4.3 DAO 层JDBC 封装与 SQL 参数绑定DAO 层负责把 Bean 变成 SQL。这份项目的写法是标准的PreparedStatement加手动结果集映射// com/health/dao/SportRecordDao.java public class SportRecordDao { // 新增一条运动记录 public boolean insert(SportRecord r) { String sql INSERT INTO sport_record(user_id, sport_type, duration, calorie, sport_date, remark) VALUES(?, ?, ?, ?, ?, ?); // try-with-resources 保证连接和语句一定被关闭避免连接泄漏 try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, r.getUserId()); ps.setString(2, r.getSportType()); ps.setInt(3, r.getDuration()); ps.setDouble(4, r.getCalorie()); ps.setDate(5, r.getSportDate()); // 参数必须是 java.sql.Date ps.setString(6, r.getRemark()); return ps.executeUpdate() 0; } catch (SQLException e) { e.printStackTrace(); // 生产环境应换成日志框架 return false; } } // 按用户查列表带简单分页 public ListSportRecord findByUser(int userId, int page, int size) { String sql SELECT * FROM sport_record WHERE user_id ? ORDER BY sport_date DESC LIMIT ?, ?; ListSportRecord list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, userId); ps.setInt(2, (page - 1) * size); // 偏移量页码从 1 开始 ps.setInt(3, size); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { SportRecord r new SportRecord(); r.setId(rs.getInt(id)); r.setUserId(rs.getInt(user_id)); r.setSportType(rs.getString(sport_type)); r.setDuration(rs.getInt(duration)); r.setCalorie(rs.getDouble(calorie)); r.setSportDate(rs.getDate(sport_date)); r.setRemark(rs.getString(remark)); list.add(r); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }参数说明几个要点。PreparedStatement的占位符从 1 开始数setDate收的是java.sql.Date而不是java.util.Date后者会编译不过——这也是很多人从 Servlet 里new java.util.Date()直接传进来时报错的根源。LIMIT ?, ?在 MySQL 里第一个参数是偏移量不是页码所以写(page - 1) * size少减这个 1第一页的数据就永远看不到。try-with-resources是这段代码里最值钱的写法。原生 JDBC 如果按老写法finally { conn.close(); }一旦prepareStatement抛异常conn就漏了。用上它连接、语句、结果集三层都会自动关数据库连接数不会随着使用悄悄涨上去。如果你手上的包是老式写法这是二次开发时优先重构的一处。4.4 统计模块按周聚合与 ECharts 数据组装统计页是这类系统里含金量最高的部分因为它把 SQL 聚合和前端图表串起来了。后台一般这么查// 统计某用户最近 7 天的运动总时长用于折线图 public ListMapString, Object statLast7Days(int userId) { String sql SELECT sport_date AS d, SUM(duration) AS total FROM sport_record WHERE user_id ? AND sport_date DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY sport_date ORDER BY sport_date; ListMapString, Object rows new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, userId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { MapString, Object m new HashMap(); m.put(date, rs.getString(d)); m.put(total, rs.getInt(total)); rows.add(m); } } } catch (SQLException e) { e.printStackTrace(); } return rows; }DATE_SUB(CURDATE(), INTERVAL 7 DAY)取的是滚动 7 天而不是“本周一到周日”。两者在周日晚上看差别很大做周报类功能时要想清楚是按自然周还是按滚动窗口改 SQL 就行不用动 Java。GROUP BY sport_date保证同一天多次运动会被合并成一条否则折线图上一天会出现多个点。结果集转成ListMap而不是新建一个 VO 类是因为字段太少不值得直接序列化成 JSON 给前端最省事。Servlet 里用 Gson 或 fastjson 转一下JSP 里拼进 ECharts 的series.data// 统计页内联脚本data 由后端渲染进来 var dates [], totals []; // chartData 是 Servlet 用 request.setAttribute 传过来的 JSON 字符串 var chartData ${requestScope.chartData}; chartData.forEach(function (item) { dates.push(item.date); totals.push(item.total); }); var chart echarts.init(document.getElementById(sportChart)); chart.setOption({ xAxis: { type: category, data: dates }, yAxis: { type: value, name: 分钟 }, series: [{ type: line, data: totals, smooth: true }] });这里chartData必须是合法 JSON 字符串后端序列化时注意日期字段别被转成时间戳否则 x 轴会显示一串数字。另外echarts.init要等 DOM 挂载完再调放在head里直接执行会拿到null画不出图。5. 避坑与排查这套老技术栈最容易翻车的几个地方跑起来之后真正的麻烦往往出现在改代码阶段。下面这几条都是我在同类项目上反复遇到的按“现象 → 原因 → 解决”记下来遇到时对照着看能省不少时间。5.1 中文备注进库变成问号或乱码现象运动记录的备注在页面上正常存进数据库用客户端一查全是???或者好这样的乱码。原因通常是三处编码不一致中的某一处JSP 页面pageEncoding写的是ISO-8859-1Servlet 没调req.setCharacterEncoding(UTF-8)数据库或表的字符集是latin1。排查顺序是从外到内先SHOW CREATE TABLE sport_record看表的CHARSET不是utf8mb4就直接ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4。表没问题再看 ServletsetCharacterEncoding必须在第一次getParameter之前调用写在后面等于没写。最后确认db.properties的 url 里带了useUnicodetruecharacterEncodingutf8。三处对齐乱码必然消失。5.2 启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver现象Tomcat 起得来一登录就抛驱动找不到。原因有两个方向一是WEB-INF/lib里根本没放mysql-connector-java的 jar工程在 IDE 里编译过了但部署产物里没有二是 jar 放了但版本和驱动类名对不上5.1.x 的包叫com.mysql.jdbc.Driver写成cj那个就加载不到。解决方法是先看lib目录里 jar 的实际版本号5.1.x 就把Class.forName和jdbc.driver都改成不带cj的类名8.0.x 保持cj。另外 IDEA 里要在Artifacts的WEB-INF/lib下确认 jar 已加入输出目录只在 Module Dependencies 里加了不算数这是“代码没错但就是跑不起来”的经典玄学。5.3 系统用着用着开始报 Too many connections现象刚启动一切正常点了几十次之后所有页面都卡住日志里出现Too many connections或者请求一直挂着不返回。原因是没有连接池DBUtil.getConnection()每次新建物理连接而某些 DAO 方法在异常分支里忘了close()连接只增不减MySQL 的max_connections默认 151很快打满。应急办法是重启 Tomcat 释放连接根治要么把所有 DAO 改成try-with-resources要么引一个连接池。用 Druid 或 HikariCP 都行在DBUtil静态块里初始化DataSourcegetConnection()改成从池里取改动集中在一个文件里半天能完成收益是并发能力和稳定性直接上一个台阶。5.4 保存运动记录时白屏控制台报 IllegalArgumentException现象表单填完点保存浏览器一片空白Tomcat 控制台第一行是java.lang.IllegalArgumentException往下追能到java.sql.Date.valueOf。原因是前端提交的日期字符串不是严格的yyyy-MM-dd常见于浏览器降级、手动输入、或者从 Excel 粘贴进来的数据。解决是在 Servlet 里包一层解析别直接valueOf// 健壮的日期解析失败时回填提示而不是抛异常 private java.sql.Date parseDate(String s, HttpServletRequest req) { if (s null || s.trim().isEmpty()) return null; try { // 先按标准格式解析兼容 2024-5-1 这种不补零的写法 java.text.SimpleDateFormat sdf new java.text.SimpleDateFormat(yyyy-M-d); sdf.setLenient(false); // 关闭宽松模式2024-02-31 会直接失败 return new java.sql.Date(sdf.parse(s.trim()).getTime()); } catch (java.text.ParseException e) { req.setAttribute(msg, 日期格式不正确请重新选择); return null; } }setLenient(false)这一行很关键默认宽松模式下2024-02-31会被悄悄转成 3 月 2 号数据错了还看不出来。5.5 部署后访问 404日志里却没有任何异常现象Tomcat 启动日志干干净净访问http://localhost:8080/项目名/就是 404。原因通常是三选一工程目录名和访问路径不一致web.xml里welcome-file-list配的首页文件名与实际文件对不上或者 IDEA 的 Artifact 没配置war exploded部署上去的是个空壳。排查方法很土但有效直接去TOMCAT_HOME/webapps/项目名/底下ls一眼看WEB-INF/classes里有没有编译后的.class没有就是部署配置的问题跟代码无关。另外 context path 如果配成了/访问地址就不带项目名很多人习惯性带上项目名反而 404。6. 二次开发把体重趋势做成可交互的折线图跑通之后大多数人第一件想做的事是“加点自己的东西”。最典型的需求是把健康档案里的体重数据画成趋势图顺便算个 BMI 分级。这块改造量不大但能把“表 → DAO → Servlet → 前端图表”整条链路再走一遍适合当第一个练手点。思路分三步。先在HealthRecordDao里加一个按时间段查体重的方法SQL 用record_date BETWEEN ? AND ?加ORDER BY record_date返回ListHealthRecord别直接返回 JSON保持 DAO 只产出对象。然后在 Servlet 里新增一个actionchart分支把查出来的列表转成两个数组——日期数组和体重数组用 Gson 序列化后setAttribute传给 JSP。最后在页面上用 ECharts 渲染顺手把 BMI 参考线画上去。BMI 的计算放在 Java 侧比放前端稳因为身高存在user表里前端拿不到完整数据// 在 Servlet 里组装图表数据顺便算 BMI 分级 ListHealthRecord records healthDao.findByRange(userId, start, end); ListString dates new ArrayList(); ListDouble weights new ArrayList(); // 身高从 session 里的登录用户取单位 cm 转 m double heightM loginUser.getHeight() / 100.0; double bmi 0; if (heightM 0 !records.isEmpty()) { // 取区间内最后一次体重算当前 BMI double latestWeight records.get(records.size() - 1).getWeight().doubleValue(); bmi latestWeight / (heightM * heightM); } for (HealthRecord r : records) { dates.add(r.getRecordDate().toString()); weights.add(r.getWeight().doubleValue()); } MapString, Object payload new HashMap(); payload.put(dates, dates); payload.put(weights, weights); payload.put(bmi, Math.round(bmi * 10) / 10.0); // 保留一位小数 req.setAttribute(chartJson, new Gson().toJson(payload)); req.getRequestDispatcher(/health_chart.jsp).forward(req, resp);改造完怎么验证我一般按数据边界测三组只录一条体重时曲线应该是一个孤点而不是报错跨月查询时 x 轴日期要连续不能因为某天没数据就整体错位身高字段为空的用户访问图表页BMI 要显示“未填写身高”而不是NaN或除零异常。第三组最容易被忽略因为初始化脚本里的测试账号往往身高是填好的。还有一个值得顺手做的优化把DBUtil里的直连换成连接池。改动只有静态块那几行但统计页那种一次请求跑好几条 SQL 的场景收益立竿见影。再往深了走可以考虑把 Servlet 里重复的参数校验抽成一个BaseServlet让每个模块继承它action分发用反射调方法名代码能瘦一半——不过那属于重构范畴第一次改建议别动太大先把一个功能完整跑通信心比架构重要。血泪经验是每改一处就重启验证一次别攒着三五处一起改再启动。这套老技术栈没有热部署的容错堆栈信息又常常指向框架内部一次改太多出问题时根本分不清是哪一处引入的。从那以后我每次动这类原生 JavaWeb 项目都强制自己“改一处、启一次、点一遍主流程”慢是慢了点但省下来的排查时间远不止这些。希望帮到你。本文还有配套的精品资源点击获取