简介面向需要学习Java Web开发与企业绩效管理流程的开发者这套基于Servlet、JSP、Spring MVC及Hibernate/MyBatis等技术栈实现的绩效考评系统完整覆盖了从员工档案维护、考核指标配置、周期设定到评分录入、自动算分与报表导出的闭环流程。资源包共509个文件除91个Java源码和39个JSP页面外还包含91个class编译文件、244个SVN版本记录、SQL数据库脚本、JS/CSS/图片等前端资源整体大小仅982KB解压后即可导入IDE进行二次开发。已有175人参与学习下载。系统内置员工管理、绩效指标定义、评价录入、权重计算、报表生成与反馈机制等核心模块并结合Spring事务控制与ORM持久化实现数据操作有助于理解Java Web分层架构、权限管理及数据库设计适合作为课程设计或中小企业绩效管理系统的参考项目。1. 从压箱底的 RAR 里看透架构:一份 JSP Servlet 绩效考评系统的底牌“绩效考评系统—java web.rar”这个压缩包,拿到手先不要急着解压。光看文件清单,一套 Java Web 绩效考评系统的技术底牌已经摆了出来:LoginServlet、ScoreDAOImpl、ScoreingServlet、UserUpdateMyinfoServlet 这些类名,配上 WdatePicker.js.bak 这个日期控件备份,基本可以断定,这是原生的 JSP Servlet 工程,没有 Spring 全家桶,数据访问走 DAO 加 JDBC 手工 SQL。这类系统适合两类人,一是刚学完 Servlet 和数据库、想找一个完整链路做课程设计复盘的初学者,二是要维护老系统、需要快速读懂别人代码的工程师。它没有框架帮你藏住细节,反而每一步都能看到 HTTP 请求、Session、PreparedStatement 是怎么串起来的。2. 登录链路与权限边界:LoginServlet、UserDAOImpl 的 3 个关键点讲这套系统,第一个绕不开的入口就是登录。不管是评分还是查报表,用户身份不过关,后面所有权限都无从谈起。这一章从用户表查询、Session 写入到角色拦截,把登录链路完整拆一遍。2.1 数据访问层先立住:UserDAOImpl 的 JDBC 手工写法这套系统没有 MyBatis 也没有 Hibernate,数据访问全靠 DAO 实现类直接写 JDBC。有人会问,为什么不直接用框架?因为这类老项目诞生在框架普及之前,或者作者学 Servlet 时还没接触框架,所以 JDBC 全链路都是手工控制。好处是每一步都透明,连接从哪来、SQL 怎么拼、结果集怎么封装,全都能在源码里看到。在这个工程里,UserDAOImpl 的查询方法通常是这个写法:public User findUserByLogin(String account, String password) { Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn DBUtil.getConnection(); String sql SELECT user_id, user_name, role, dept_id FROM sys_user WHERE account ? AND password ?; ps conn.prepareStatement(sql); ps.setString(1, account); ps.setString(2, password); rs ps.executeQuery(); if (rs.next()) { User u new User(); u.setUserId(rs.getInt(user_id)); u.setUserName(rs.getString(user_name)); u.setRole(rs.getString(role)); u.setDeptId(rs.getInt(dept_id)); return u; } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(rs, ps, conn); } return null; }这里必须用 PreparedStatement 而不是 Statement 拼字符串。原因很简单:PreparedStatement 用占位符传参,数据库会把参数当成数据而不是 SQL 语法,账号里带个单引号也不至于把查询语句给改了。防 SQL 注入不算高深技术,但在这类手工 JDBC 的老代码里,这一行选择就决定了下限。DBUtil 是我接手这类项目时第一个看的类。它里面一般封装了 Class.forName 加载驱动、DriverManager.getConnection 获取连接、以及 close 三个方法。连接配置通常单独放在 db.properties 里,后面部署章节再展开。顺手提一句,这种手工事务的老系统,有些方法会忘了在 finally 里把 Connection 关闭,长时间跑就会出现连接耗尽,页面卡死。读源码时看到 finally 里没有 close,就要多留个心眼。2.2 登录校验与 Session 写入:三个容易被忽略的参数LoginServlet 的 doPost 逻辑非常直白:取参数、查库、跳转。代码量不大,但有三处细节在二次开发时特别容易踩,下面这段是常见写法:protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding(UTF-8); String account request.getParameter(account); String password request.getParameter(password); User user new UserDAOImpl().findUserByLogin(account, password); if (user ! null) { HttpSession session request.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(1800); response.sendRedirect(request.getContextPath() /index.jsp); } else { request.setAttribute(loginError, 账号或密码不正确); request.getRequestDispatcher(/login.jsp).forward(request, response); } }第一,request.setCharacterEncoding(UTF-8) 必须在第一次调用 getParameter 之前执行。Tomcat 7 及更早的版本,POST 表单默认按 ISO-8859-1 解析,不在开头设置编码,取出来的中文就是问号,这种乱码问题改半天页面都没用。第二,登录成功用 sendRedirect 还是 forward,行为差异很大。sendRedirect 会告诉浏览器重新发起一次请求,地址栏变成 index.jsp,刷新页面不会把登录表单再提交一次;forward 是服务端跳转,地址栏不变,刷新时会弹“确认重新提交表单”的对话框。登录成功用前者,失败用后者,因为失败时要保留 request 域里的 loginError 给页面展示。第三,session.setMaxInactiveInterval(1800) 是滑动超时,单位秒。不写这行就用 web.xml 的默认配置,一般也是 30 分钟,但各容器默认值有差异,显式写出来逻辑上更可控。登录成功之后,index.jsp 里通常用 EL 表达式把 session 里的 loginUser 显示出来,比如欢迎语“你好,${loginUser.userName}”。如果页面一直拿不到这个值,先检查 session.setAttribute 的 key 和 JSP 里取值的 key 是否一致,这是新手最容易犯的拼写问题。2.3 角色字段与 Filter 拦截:权限不做在后端就是裸奔这套系统从命名上能看出有普通员工、考评人、管理员几种身份,但权限控制是手工的。常见设计是 sys_user 表带一个 role 字段,取值比如 admin、manager、employee。admin 维护员工和考评项目,manager 录入评分和看部门报表,employee 只能看自己的评分结果。权限判断建议都放到 Filter 里,而不是在每个 Servlet 里重复 if 判断。以下是一个简化版 AuthFilter:public class AuthFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpSession session request.getSession(false); String uri request.getRequestURI(); if (uri.endsWith(/login.jsp) || uri.contains(/login) || uri.endsWith(.js) || uri.endsWith(.css)) { chain.doFilter(req, res); return; } if (session null || session.getAttribute(loginUser) null) { ((HttpServletResponse) res).sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, res); } }这里有三条放行规则缺一不可:登录页本身、登录提交接口、css/js 静态资源。如果漏掉登录接口,用户输入账号密码点登录,请求会被 Filter 拦截后重定向回登录页,表现就是“怎么都登不进去”,这个故障很迷惑人,因为代码逻辑看起来都对。Filter 的 url-pattern 在 web.xml 里写成 /* 就可以覆盖全部请求。再往细做的角色控制,是根据 URL 前缀分组。比如 /admin/ 路径只允许 admin, /score/ 路径只允许 manager 和 admin。Filter 里取出 session 中的 user,getRole() 后做字符串匹配,不通过就跳出一个 403 页面。这种做法虽然原始,但在没有 Spring Security 的老项目里,是成本最低且能用的方案。有一点要注意:Filter 判断里不要每次都 request.getSession(),否则会无中生有地创建 Session,正确写法是先 getSession(false),拿不到就说明没登录过。提示:AuthFilter 写完后,记得在 web.xml 里把 filter-mapping 放在 servlet-mapping 之前,否则部分容器按配置顺序解析,会出现拦截规则不生效的怪问题。3. 评分业务链走通:ScoreingServlet 的评分入库与权重算法登录只是门卫,绩效考评系统的核心在 ScoreingServlet、ScoreDAOImpl 和评分数据表这三件事。这一章按业务顺序走一遍,先说考评流程怎么搭,再算权重,最后说查询回显。3.1 考评主流程:建项目、定周期、录评分从 ProjectAddServlet 这个类名能看出,这套系统把“考评项目”当成基础数据来管理。所谓考评项目,财务部门叫指标、人事部门叫维度,落到系统里就是一个可以打分的条目,比如“工作完成度”“团队协作”“创新贡献”。一个完整的考评周期操作通常是:管理员新增考评项目,设置项目名称、类型定量/定性、权重;管理员启动一个考评周期,设定起止日期和适用部门;考评人进入评分页,对被评人逐项打分,填写评分意见;系统按权重汇总综合得分。新增项目的 SQL 大概是这样:INSERT INTO project(proj_name, proj_type, weight, status) VALUES (工作完成度, 定量, 40, ACTIVE);这里 status 字段是一个被特别看重的设计。老系统常见的偷懒做法是“停用即删除”,把不需要的项目 DELETE 掉,结果历史评分记录的外键悬空,报表 join 时出现大量空值。规范做法是加一个状态位,ACTIVE 表示启用,INACTIVE 表示停用,删除操作永远不物理执行,历史数据才保得住。考评人点击提交后,ScoreingServlet 从 request 里循环取出每个评分项目的得分和评语,组装成 ScoreItem 列表,然后交给 ScoreDAOImpl 批量写入。工程里的批量插入通常长这样:public int batchInsertScore(ListScoreItem items) throws SQLException { String sql INSERT INTO score(user_id, proj_id, cycle_id, score_value, score_comment, score_time, scorer_id) VALUES (?, ?, ?, ?, ?, NOW(), ?); Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); try { for (ScoreItem item : items) { ps.setInt(1, item.getUserId()); ps.setInt(2, item.getProjId()); ps.setInt(3, item.getCycleId()); ps.setBigDecimal(4, item.getScoreValue()); ps.setString(5, item.getScoreComment()); ps.setInt(6, item.getScorerId()); ps.addBatch(); } return ps.executeBatch().length; } finally { ps.close(); conn.close(); } }batch 批量提交的核心是 addBatch 和 executeBatch,不是单条循环 insert。评分项目一多,比如一次要评十个维度,单条插入会产生十次网络往返,批量提交只要一次。注意这个写法里 Connection 和 PreparedStatement 用了手动 finally 关闭,如果工程编译环境是 JDK 6,这反而是更稳的写法;如果已经切到 JDK 8,可以改成 try-with-resources 让代码更短。还有一个容易卡住人的细节:ScoreingServlet 这个类名,“Scoreing” 少了一个 r,正确的拼写是 Scoring。原工程就是这么写的,类名、变量名里到处都是这个语法错误。你在 IDE 里全局搜索“Scoring”会搜不到这个类,必须按 ScoreingServlet 的原名去搜。我第一次接手这个系统时,在这上面至少浪费了十分钟,这个拼写错误反而成了辨认源码的一个路标。3.2 综合得分怎么算:权重折算与 BigDecimal 精度控制评分数据录进 score 表之后,系统要算每个人的综合得分。最常见的算法是加权平均:综合得分 Σ(项目得分 × 项目权重) / Σ(项目权重)。如果所有启用项目的权重正好是 100,分母就是 100 或者直接等于 1,逻辑更简单。权重字段如果按照整数存储,比如 40、30、30,计算时需要先把权重换算成小数。这里有一个经典翻车点:Java 里 double 做浮点运算会丢精度,85.0 * 0.4 90.0 * 0.3 的结果可能不是 89.5,而是 89.49999999999999。老系统页面显示这种数字,会被业务部门认为是系统算错了。正确的做法是全程用 BigDecimal:BigDecimal totalScore BigDecimal.ZERO; BigDecimal totalWeight BigDecimal.ZERO; for (ScoreItem item : scoreItemList) { BigDecimal itemWeight new BigDecimal(item.getWeight()); BigDecimal itemScore new BigDecimal(item.getScore()); totalScore totalScore.add(itemScore.multiply(itemWeight)); totalWeight totalWeight.add(itemWeight); } BigDecimal finalScore totalScore.divide(totalWeight, 2, RoundingMode.HALF_UP);这段代码看着简单,但有两个参数不能改。一个是 new BigDecimal(item.getWeight()) 必须用字符串或整数构造,不能用 new BigDecimal(0.4),否则构造出来的 BigDecimal 本身就带着二进制浮点的误差。另一个是 divide 必须传三个参数:除数、保留位数、舍入模式。只写两个参数时,如果除不尽会抛 ArithmeticException,而且只在特定数据下出现,属于典型的“偶尔报错查不到原因”的坑。保留 2 位小数配 HALF_UP 四舍五入,是财务核算的默认组合。顺便提一句,数据库里 score_value 和综合得分字段,类型建议用 DECIMAL(5,2) 而不是 FLOAT。DECIMAL 是精确保存,FLOAT 是近似值,页面显示和计算都不如 DECIMAL 稳。这是我吃过大亏以后改成的习惯。3.3 评分结果查询:按用户和周期回显的 SQL 设计评分页面有两个典型场景:考评人看“我负责评谁”,被评人看“我的得分是多少”。查的是同一张 score 表,区别在过滤条件不同。待评分列表以 scorer_id 为条件,加一个 status 字段区分 PENDING 和 SUBMITTED,可以防止重复提交。被评人查询以 user_id 为条件,并指定 cycle_id 定位到具体考评周期。得分详情页不能只查 score 表,还要 join project 表把项目名称和权重显示出来,否则页面上全是 project_id,谁也看不懂。查询 SQL 类似这样:SELECT p.proj_name, p.weight, s.score_value, s.score_comment, s.score_time FROM score s JOIN project p ON s.proj_id p.proj_id WHERE s.user_id ? AND s.cycle_id ? ORDER BY p.weight DESC;score 表里只存 proj_id 而不是冗余 proj_name,这是刻意的设计。一旦考评项目改名,只需要更新 project 表一条记录,历史评分自动跟着变。如果当初把项目名称冗余到 score 表,项目改名时要 update 全部历史分数,一个项目跑三年五年,这个代价就大了。4. 数据维护模块拆解:项目新增、用户自助更新与日期控件登录和评分是主链路,数据维护模块决定这套系统好不好维护。这一章把 ProjectAddServlet、UserUpdateMyinfoServlet、WdatePicker.js 三个点拆开说。另外两个更新类 Servlet——TZUpdateServlet 和 RPUpdateServlet,从命名看分别是调整类更新和评分相关更新,处理的套路和 4.2 节一致,字段边界的原则通用。4.1 ProjectAddServlet 的参数校验:别让异常直接打到页面上新增考评项目的表单,前端提交过来一般是项目名称、项目类型、权重几个字段。最粗糙的写法是拿到参数直接拼 SQL,权重转 Integer 时一个 NumberFormatException 就能让页面变成 500。代码评审时看到这种 Servlet,第一件事就是加参数白名单校验。服务端校验按三层走:非空、类型、业务规则。下面是一段简化实现:String projName request.getParameter(projName); String weightStr request.getParameter(weight); if (projName null || projName.trim().isEmpty()) { request.setAttribute(error, 项目名称不能为空); request.getRequestDispatcher(/project_add.jsp).forward(request, response); return; } int weight; try { weight Integer.parseInt(weightStr.trim()); } catch (NumberFormatException e) { request.setAttribute(error, 权重必须是 0-100 之间的数字); request.getRequestDispatcher(/project_add.jsp).forward(request, response); return; }注意两个细节:一是 weightStr.trim() 要先去空格再转,否则 “ 40 ” 会抛异常;二是校验不通过用 forward 回到新增页,并把 error 属性塞进 request 域,前端 JSP 用 ${error} 展示。这样做用户只需改出错的字段,不需要重新把整个表单填一遍。只靠页面 JS 的 required 是不够的,前端的校验在客户端就能绕过,服务端校验才是最后一道关。参数过了类型校验,还要做业务规则校验。考评项目的权重总和必须控制在合理范围,通常要求所有启用项目的权重加起来是 100,否则综合得分折算会出现偏差。比如权重 30 和权重 70 的两个项目,与权重 40 和权重 60 的组合,算出来的满分口径就不一样。新增项目或修改权重时,在 Servlet 里查一次当前所有启用项目的权重总和,再加新增的权重,超过 100 就拒绝提交:Integer currentSum new ProjectDAOImpl().sumActiveWeight(); if (currentSum weight 100) { request.setAttribute(error, 所有启用项目权重总和不能超过 100); request.getRequestDispatcher(/project_add.jsp).forward(request, response); return; }这套三层校验的套路,放在同样处理新增的 TZUpdateServlet 里也适用。先把参数接住,再逐层判断,最后才碰数据库,能让绝大多数异常请求在进入 DAO 之前就被拦下来。4.2 UserUpdateMyinfoServlet 的字段边界:update 不能动 role 和 dept_id用户自助更新信息这类 Servlet,风险点不在参数校验,而在 update 语句的字段范围。我见过一个系统,员工修改联系方式页面的 form 只有手机号和邮箱,但 Servlet 里的 SQL 写成 UPDATE sys_user SET ...,把 role 也一起更新了。攻击者用抓包工具改 form 数据,往 role 字段塞一个 admin 值,就把自己变成管理员。这类漏洞在真实系统里不是假设,而是实际发生过的事故。正确做法是白名单更新:只更新前端表单允许的字段。SQL 大约长这样:UPDATE sys_user SET phone ?, email ?, address ? WHERE user_id ?这条语句里坚决不能出现 role 和 dept_id。哪怕页面表单没有这两个输入项,只要 update SQL 里有,就多了一个被伪造请求利用的口子。user_id 从哪里来?必须从 session 里取,不能从 request 参数里取,否则用户改 URL 里的 user_id 就能改别人的资料。这是一个非常容易被忽略的信任边界:凡是涉及数据权限的,当前用户身份必须认 session,不能认请求参数。这套更新类 Servlet 有一个通用的处理顺序值得抄:先判断 session 是否有效,再取出当前用户身份,然后按白名单字段组装 update SQL,最后用 update 方法的返回值判断是否真的影响了行数。TZUpdateServlet 和 RPUpdateServlet 如果是调整部门、调整评分状态之类的操作,同样遵守这个顺序,不要在 Servlet 里直接 new 一个 User 对象去更新所有字段。4.3 WdatePicker.js 日期控件的用法与 .bak 处理日期控件在这个系统里承担考评周期的开始、结束日期选择。原工程里用的是 My97 日期控件,WdatePicker.js 是它的核心文件,配套还有 calendar.js 和相关皮肤目录。页面引用方式常见如下:script languagejavascript typetext/javascript srcjs/WdatePicker.js/script input idcycleStartDate onfocusWdatePicker({dateFmt:yyyy-MM-dd}) classWdate /dateFmt 控制日期显示格式,minDate 和 maxDate 控制可选范围。比如规定评分周期开始日期不能早于今天,加 minDate 参数就可以。需要说明的是,WdatePicker.js.bak 这个文件是开发过程中对原 js 做过修改后留的备份。部署时要确认正式引用的是 WdatePicker.js,而不是把 .bak 当作唯一文件丢到线上。如果目录下没有 .js 只有 .bak,页面加载日期控件时浏览器会报 404,这个报错打开浏览器网络面板很快能定位。日期控件选择到的日期是 yyyy-MM-dd 格式的字符串,传到后端后通常用 SimpleDateFormat 解析成 java.util.Date,再写入数据库:SimpleDateFormat sdf new SimpleDateFormat(yyyy-MM-dd); Date startDate sdf.parse(request.getParameter(cycleStartDate));这里要注意 parse 方法会抛出 ParseException,日期格式不对时页面直接 500。老系统处理这种字符串日期时,最常见的错误是页面传了 yyyy/MM/dd,后端却按 yyyy-MM-dd 解析,结果正常输入也报错。前后端格式约定必须写死在接口文档里,或者在 JSP 里统一用 WdatePicker 的 dateFmt 保证格式一致。5. 避坑笔记:部署老系统会踩的 5 个真实翻车点这一章写的都是我在实际部署和二次开发时遇到过的问题。每条按现象、原因、解决的顺序展开,照着核对能少浪费半天时间。5.1 WdatePicker.js.bak 残留让日期控件打不开现象:部署后打开周期设置页,点击日期输入框,日历面板弹不出来,浏览器 Console 报 404 或 Script error。原因:工程包里只有 WdatePicker.js.bak,没有还原成 WdatePicker.js;或者 JSP 里引用的 js 路径与实际目录不一致。浏览器缓存了一份旧的 js 文件,也会让改动不生效。解决:把 .bak 后缀去掉,恢复为 WdatePicker.js,确认 JSP 里 src 的路径与实际文件位置一致,再清一次浏览器缓存或者开无痕窗口验证。检查 Tomcat 的部署目录,确保旧缓存没有残留在 webapps 下。5.2 数据库从 MySQL 5.7 升到 8.0,jdbc 连接直接挂现象:改成 MySQL 8.0 后,Tomcat 启动日志报 ClassNotFoundException: com.mysql.jdbc.Driver,或者报 Access denied 但密码明明是对的。原因:MySQL 8 的驱动包把推荐驱动类名改成了 com.mysql.cj.jdbc.Driver,旧类名 com.mysql.jdbc.Driver 虽然还在但已标记废弃;更关键的是 JDBC URL 必须显式指定时区,这是 8.0 驱动新增的强制要求,不写会抛 serverTimezone 相关的 SQLException。解决:换掉 mysql-connector-java 的 jar 包,把 db.properties 里的两行配置改成下面这样,并注意两处必须一起改:jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/performance_db?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/Shanghai版本驱动类名URL 必填参数MySQL 5.7com.mysql.jdbc.Driver无强制时区MySQL 8.0com.mysql.cj.jdbc.DriverserverTimezone 必须显式指定useSSLfalse 是关闭 SSL 握手,内网部署可以关掉,不写时日志会刷 SSL 告警。serverTimezone 推荐 Asia/Shanghai,不要用 GMT8 这种表达式,部分版本的驱动解析会有问题。5.3 中文乱码不是靠一个 UTF-8 能治好的现象:登录后系统正常,但 JSP 页面上“团队协作”显示成问号或 ,部分老浏览器显示成“鍥㈤槦”这类乱码。原因:请求编码、响应编码、数据库连接编码三层中至少有一层是默认值。Tomcat 对 POST 请求默认按 ISO-8859-1 解析;JSP 的 pageEncoding 没设 UTF-8 时按容器默认编码;JDBC 连接 URL 没带 characterEncodingUTF-8 时,驱动按数据库默认字符集写入。解决:三层统一。Servlet 的 doGet 和 doPost 开头都写 request.setCharacterEncoding(UTF-8) 和 response.setContentType(text/html;charsetUTF-8);JSP 头部用 % page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 % 两层设置;JDBC URL 带上 characterEncodingUTF-8。只改一处没用,三个环节全部对齐乱码才会消失。5.4 Servlet 访问 404:web.xml 映射没配对现象:LoginServlet.class 编译后放进 WEB-INF/classes,访问 /loginServlet 直接 404,Tomcat 日志没有任何异常。原因:Servlet 不是靠类名自动注册的,必须在 web.xml 里同时配置 和 。只有类文件没有映射,Tomcat 找不到路由入口。如果用了 Servlet 3.0 的 WebServlet 注解,但工程里同时还存在旧 web.xml 里相同 url-pattern 的配置,会发生冲突,表现也是 404 或 500。解决:打开 web.xml,核对 servlet-name、servlet-class、url-pattern 三段。servlet-class 必须是全限定类名,url-pattern 必须以 / 开头,比如 /loginServlet。如果改用注解方式,就把 web.xml 里对应的旧配置删掉,避免双份映射。修改 web.xml 后必须重启 Tomcat,热部署对 web.xml 的改动通常不生效。5.5 综合得分偶尔出现 89.49999999999999现象:大部分考评周期的综合得分正常,个别周期算出来尾数不对,页面显示一长串小数。原因:权重换算成小数后,double 的二进制浮点表示有精度误差。比如权重 40 换算成 0.4,double 实际存的是一个比 0.4 略小的近似值,乘上得分再累加,误差就露出来了。解决:计算统一用 BigDecimal,不要在中间步骤转 double。除法必须带 RoundingMode.HALF_UP,保留两位。数据库综合得分字段改用 DECIMAL(5,2),它按十进制精确存储,不与 Java 的 double 玩近似游戏。改完之后,把历史数据重新算一遍,或者造一条测试数据验证 40 30 30 的组合,结果应该是 89.50 而不是 89.49999999。6. 快速验证数据库连接:30 秒定位 JDBC 问题的检查单接手这类 RAR 工程时,我第一件做的事不是启动 Tomcat,而是先验证 JDBC 能连上库。数据库连不上时,直接起 Tomcat 会看到一堆 ClassNotFoundException 和 SQLException,系统完全分不清是驱动问题、密码问题还是 web 配置问题。先给 db.properties 设置一份固定格式:jdbc.drivercom.mysql.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/performance_db?useUnicodetruecharacterEncodingUTF-8 jdbc.usernameroot jdbc.password123456然后写一个独立的测试类,不依赖 Tomcat,直接运行:import java.sql.Connection; import java.sql.DriverManager; public class TestConn { public static void main(String[] args) throws Exception { Class.forName(com.mysql.jdbc.Driver); String url jdbc:mysql://127.0.0.1:3306/performance_db?useUnicodetruecharacterEncodingUTF-8; Connection conn DriverManager.getConnection(url, root, 123456); System.out.println(connect ok: conn.getCatalog()); conn.close(); } }编译运行时报错的落点很有讲究。Class.forName 那行报 ClassNotFound,说明 jar 包没放到 classpath 或者驱动类名写错;DriverManager.getConnection 那行报 SQLException,说明库连不上,继续看 message 里的 cause 定位是密码、IP 还是端口;报 Access denied 而密码又是对的,大概率是 root 账号只允许 localhost 登录,改成 -h 127.0.0.1 或者授权远程访问就能解决。这套检查清单的作用,是把数据库问题从 web 容器里剥离出来,三十秒就能给出一个确认过的结论。从那以后,我每次部署任何带 JDBC 的 Java Web 老项目,都把这一步当作强制前置动作,宁可多花一分钟,也不愿意被 Tomcat 日志里的噪音绕晕。希望帮到你。本文还有配套的精品资源点击获取