JSP+Servlet+MySQL客户管理系统拆解:从环境搭建到部署避坑
简介一套采用JSP、Servlet与MySQL实现的客户管理系统属于B/S架构的Java Web项目可部署于Tomcat服务器主要面向Java Web初学者、毕业设计人员以及需要完整项目参照的开发者。系统前端采用Layui、HTML、CSS、JS和jQuery后端整合JSP、Servlet与MyBatis框架实现了登录认证、客户信息维护、系统权限管理、市场营销分析、数据统计报表、线索跟踪、交易记录与联系人管理等模块业务逻辑较为完整非常适合用来学习MVC分层设计、数据库关联查询和基本的增删改查操作。压缩包总共包含六百九十七个文件包括六十多个Java源代码文件、四十多个JSP页面、九十多个JS脚本、四十个CSS样式、二十八个Less样式、五十多个XML配置、十九个Jar依赖包、一个SQL数据库脚本以及一百五十个GIF演示图片整体大小为23.06MB目录结构清晰导入开发环境后即可查看和运行。目前已有169人浏览学习借助该资源可以快速搭建客户管理系统深入理解项目结构、业务功能和前后端交互过程也便于在此基础上进行二次开发与毕业设计扩展。1. 一个 JSPServlet 的客户管理系统为什么 2025 年还有人用它做毕业设计后台管理类项目里「JSPServletMySQL 客户管理系统」是出现频率最高的组合之一。它不花哨却是理解 Java Web 请求生命周期最直接的路径浏览器发一个 HTTP 请求Servlet 接住MyBatis 查库JSP 把数据渲染回页面。这套流程跑通了后面学 Spring MVC、Spring Boot 都是同一个套路换层壳。这个项目覆盖的模块很典型登录认证、用户管理、客户管理、线索管理、交易管理、市场活动管理、联系人管理外加数据统计和系统管理正好是一套完整 B/S 系统的骨架。技术栈是 Java JSP Servlet MyBatis MySQL LayuiJDK1.8、Tomcat8、MySQL5.7 就能跑起来。适合两类人一类是用它交课程设计或毕设的在校生另一类是刚入行想搞懂「一个 Web 项目从建表到部署全流程」的初级开发。它解决的核心问题不是业务有多复杂而是让你在一套真实可运行的项目里看清前后端数据是怎么一步步串起来的。2. 项目骨架与运行环境先把 B/S 架构的每一层对上号2.1 目录结构里藏着 MVC 的真相拿到项目压缩包先别急着点 run花十分钟把目录结构过一遍。这个项目是标准的 B/S 架构后端按 MVC 思想组织但因为是 Servlet 写的所以没有 Spring 容器帮你管理对象一切都要靠手动 new 和请求转发来串联。src/main/java ├── com.system.controller │ ├── UserController.java │ ├── ClueController.java │ ├── ActivityController.java │ ├── TranController.java │ └── ContactsController.java ├── com.system.service │ ├── UserService.java │ ├── ClueService.java │ └── ... ├── com.system.dao │ ├── UserDao.java │ └── ... └── com.system.entity ├── User.java ├── Clue.java └── ... src/main/resources ├── mybatis-config.xml └── mapper ├── UserMapper.xml └── ... WebContent ├── static │ ├── layui │ ├── css │ └── js ├── WEB-INF │ └── web.xml └── *.jspController 层只有一个类名还不够关键要看每个类里的方法是怎么分发请求的。这个项目里没有 Spring MVC 的RequestMapping而是一个 Controller 里写多个方法用method参数或者路径后缀来区分动作。比如UserController里可能有login()、logout()、toPassword()web.xml 里配一个 servlet-mapping请求都进同一个类类内部再分流。这里有个值得注意的设计实体类entity和数据库表一一对应dao层接口定义方法mapper里写 SQL。这是 MyBatis 的典型用法但因为是手动管理事务需要自己控制后文会专门讲。2.2 环境选型的三个理由JDK1.8、Tomcat8、MySQL5.7这个项目给你的环境要求是 Win10 JDK1.8 MySQL5.7 Tomcat8。这不是随便定的三个版本选择都有实际原因。JDK1.8 是兼容性之王。JSP 项目经常要跑别人的代码如果用的是 JDK11 以上Tomcat 版本和编译选项都可能出问题。JDK1.8 配 Tomcat8 是经过大量项目验证的稳定组合不会出现模块化系统导致反射报错这类玄学问题。MySQL5.7 是因为它和 JSP 时代的数据库驱动兼容性最好。MySQL8 之后默认认证插件改成了 caching_sha2_password老版本的 mysql-connector-java 5.x 驱动连不上会报Unable to load authentication plugin错误。如果非要用 MySQL8驱动得换成 8.x 版本同时连接 URL 加useSSLfalseserverTimezoneUTC。后面避坑章节会详细说。Tomcat8 支持 Servlet 3.1 规范这个项目用的 Servlet 注解和异步特性在 Tomcat7 上也能跑但 Tomcat8 对 JSP 编译和静态资源处理更稳定IDS 里默认的 Tomcat 版本也大多是 8.5 系列。2.3 web.xml 里那两行配置决定了请求能不能分对路用 Eclipse 或 IDEA 部署项目时web.xml 是最容易被忽略但最容易出问题的地方。这个项目的请求分发方式很直接所有 Controller 都注册在 web.xml 里字段映射要精确到类名和 URL 模式。servlet servlet-nameUserController/servlet-name servlet-classcom.system.controller.UserController/servlet-class /servlet servlet-mapping servlet-nameUserController/servlet-name url-pattern/user/url-pattern /servlet-mapping servlet servlet-nameClueController/servlet-name servlet-classcom.system.controller.ClueController/servlet-class /servlet servlet-mapping servlet-nameClueController/servlet-name url-pattern/clue/url-pattern /servlet-mapping/user这个 URL 模式意味着所有/user?methodlogin这样的请求都会进UserController具体执行哪个方法靠 request 参数method来匹配。这种做法的好处是 Servlet 注册数量少一个模块一个入口代码结构干净坏处是方法分发要自己写 if 或 switch可读性比注解差一些但作为教学项目反而更适合逐行读。提示在 IDEA 的 Artifacts 配置里注意把WEB-INF/lib下的 jar 包确认完整特别是 mysql 驱动和 mybatis 的 jar。缺少任何一个启动时不会报错跑起来才发现ClassNotFoundException那时排查成本就高了。3. 数据库设计与登录认证从建表脚本开始跑通第一个闭环3.1 五张核心表和它们的关联关系客户管理系统的数据模型不算复杂但一定要吃透表之间的关系。这个项目的表主要围绕「客户」这个中心实体展开客户表存最核心的客户资料线索表存未转化的潜在客户交易表存成交记录联系人表存客户下的联系人市场活动表存活动信息用户表存后台账号。-- 用户表 CREATE TABLE tbl_user ( id INT NOT NULL AUTO_INCREMENT, login_act VARCHAR(32) DEFAULT NULL COMMENT 登录账号, name VARCHAR(32) DEFAULT NULL COMMENT 用户姓名, login_pwd VARCHAR(64) DEFAULT NULL COMMENT 登录密码, email VARCHAR(64) DEFAULT NULL COMMENT 邮箱, expire_time VARCHAR(20) DEFAULT NULL COMMENT 账号失效时间, create_time VARCHAR(20) DEFAULT NULL COMMENT 创建时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 客户表 CREATE TABLE tbl_customer ( id VARCHAR(32) NOT NULL, owner VARCHAR(32) DEFAULT NULL COMMENT 所有者, name VARCHAR(128) DEFAULT NULL COMMENT 客户名称, website VARCHAR(128) DEFAULT NULL, phone VARCHAR(32) DEFAULT NULL, industry VARCHAR(32) DEFAULT NULL COMMENT 行业, description VARCHAR(512) DEFAULT NULL, create_time VARCHAR(20) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 线索表 CREATE TABLE tbl_clue ( id VARCHAR(32) NOT NULL, owner VARCHAR(32) DEFAULT NULL, company VARCHAR(128) DEFAULT NULL COMMENT 公司, phone VARCHAR(32) DEFAULT NULL, source VARCHAR(32) DEFAULT NULL COMMENT 线索来源, state VARCHAR(32) DEFAULT NULL COMMENT 线索状态, create_time VARCHAR(20) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意几个设计细节。第一主键用 VARCHAR(32) 而不是自增 INT这是客户关系管理系统里常见做法因为客户数据经常要做多节点同步字符串主键可以避免自增 ID 的冲突问题生成方式一般是 UUID。第二时间字段用 VARCHAR(20)这类项目通常把时间格式化后再入库省去数据库时间类型的各种时区麻烦。第三所有表都用 utf8mb4 字符集这是 MySQL5.5 之后的推荐做法能存 emoji 和生僻字。表之间的关系靠外键逻辑关联但不物理建外键。交易表存customer_id联系人表存customer_id线索表通过company和phone与客户表建立业务关联。这种设计在实际 CRM 里叫「软关联」数据表之间不设物理外键靠业务层控制一致性好处是查询和删除灵活坏处是代码里必须自己保证关联字段的正确性。3.2 UserController 登录验证的完整链路登录模块是整个项目第一个闭环值得逐行读懂。它的核心逻辑是用户提交账号密码 → Servlet 接收 → Service 查库比对 → 成功则写入 Session → 跳转首页失败则返回错误信息。public class UserController extends HttpServlet { private UserService userService new UserService(); Override protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 设置编码防止中文乱码 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); String method request.getParameter(method); if (login.equals(method)) { doLogin(request, response); } else if (logout.equals(method)) { doLogout(request, response); } else if (updatePwd.equals(method)) { doUpdatePwd(request, response); } } private void doLogin(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String loginAct request.getParameter(loginAct); String loginPwd request.getParameter(loginPwd); // 使用 MD5 加密后比对 String md5Pwd MD5Util.getMD5(loginPwd); User user userService.login(loginAct, md5Pwd); if (user ! null) { // 判断账号是否失效 String expireTime user.getExpireTime(); boolean expired DateUtil.compareDate(expireTime, DateUtil.getNowTime()); if (expired) { request.setAttribute(msg, 账号已失效); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } HttpSession session request.getSession(); session.setAttribute(user, user); response.sendRedirect(/customer/index); } else { request.setAttribute(msg, 账号或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); } } }这段代码有四个关键点。第一service()方法统一拦截所有请求设置 UTF-8 编码这是 JSP 项目中文乱码的第一道防线不少项目在 Servlet 里忘了这行页面表单提交的中文到后台就成了问号。第二密码用 MD5 加盐前的简单哈希存储直接对比login_pwd字段。MD5 本身不安全是事实但这个项目定位是教学演示至少做到了不存明文。第三compareDate判断账号是否过期expireTime字段存在说明这个系统有试用账号管理的场景。第四成功后重定向到/customer/index而失败用 forward 回登录页并携带msg属性——重定向跳转 URL 会变防止表单重复提交forward 可以带 request 属性回显错误信息。3.3 MyBatis 的核心 SQL 与参数写法在 mapper 层最核心的是UserMapper.xml里根据账号密码查用户的 SQL。不少初学者在这上面翻车符号写错一个就查不到数据。select idselectUser parameterTypemap resultTypeuser select * from tbl_user where login_act #{loginAct} and login_pwd #{loginPwd} /select#{loginAct}是 MyBatis 预编译占位符自动加引号并转义能防 SQL 注入。有同学踩坑是因为用了${loginAct}——它不做预编译直接把字符串拼进 SQL单引号都不帮你补结果要么报语法错误要么被注入攻击。这个项目里所有用户输入的地方都应该用#{}而不是${}。parameterTypemap意味着调用时传 Map 对象DAO 接口方法写法是User selectUser(MapString, Object map); // 调用时 MapString, Object map new HashMap(); map.put(loginAct, loginAct); map.put(loginPwd, md5Pwd); User user userDao.selectUser(map);Map 传参的优点是参数名字灵活不用定义 DTO 类。缺点是 key 拼错时 MyBatis 不报错只返回 null排查时要先打印 map 内容确认 key 是否正确。4. 市场活动与线索转交易Controller 分层的实战拆解4.1 市场活动模块的分页查询与条件组合市场活动模块ActivityController是这个项目里最能体现「列表查询」通用套路的部分。它包含分页、模糊搜索、数据统计三个子场景。分页在 JSP 项目里有两种实现用 PageHelper 插件或者手动写LIMIT offset, pageSize。这个项目用后者更直观也更适合理解分页原理。private void queryActivityByCondition(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 接收查询条件 String name request.getParameter(name); String owner request.getParameter(owner); String startDate request.getParameter(startDate); String endDate request.getParameter(endDate); String pageNoStr request.getParameter(pageNo); String pageSizeStr request.getParameter(pageSize); int pageNo 1; int pageSize 10; if (pageNoStr ! null !.equals(pageNoStr)) { pageNo Integer.parseInt(pageNoStr); } if (pageSizeStr ! null !.equals(pageSizeStr)) { pageSize Integer.parseInt(pageSizeStr); } // 2. 计算分页起始行 int skipCount (pageNo - 1) * pageSize; // 3. 封装查询条件到 Map MapString, Object map new HashMap(); map.put(name, name); map.put(owner, owner); map.put(startDate, startDate); map.put(endDate, endDate); map.put(skipCount, skipCount); map.put(pageSize, pageSize); // 4. 查询数据列表和总条数 ListActivity activityList activityService.queryActivityByCondition(map); int total activityService.queryActivityCount(map); // 5. 计算总页数 int totalPages (total % pageSize 0) ? (total / pageSize) : (total / pageSize 1); // 6. 封装成 Map 返回 JSON MapString, Object result new HashMap(); result.put(activityList, activityList); result.put(total, total); result.put(pageNo, pageNo); result.put(totalPages, totalPages); // 7. 使用 gson 转 JSON 输出 response.getWriter().write(new Gson().toJson(result)); }这段代码里skipCount是分页的关键。pageNo从 1 开始第一页就是(1-1)*100对应 SQL 的LIMIT 0,10第二页就是LIMIT 10,10。这个计算很多人喜欢写在 SQL 里算但在 Java 层算出来再传参更清晰也方便打印日志调试。前端配合拿到 JSON 后要手动拼表格这是 Layui 的常见用法。Layui 的table.render可以接后端 JSON 数据但分页参数名要对上pageNo和totalPages。如果数据出不来八成是字段名对不上比如后端返回totalPages前端却读totalPage。4.2 线索转交易的业务流转为什么需要 Transaction线索模块的交易转换是 CRM 项目的核心业务。线索Clue代表一个潜在的销售机会它通过客户名称、电话、公司名等字段识别当某个线索被确认为真实客户并进入交易阶段时系统要完成四件事把线索信息复制生成客户记录、生成联系人记录、生成交易记录、删除或标记原线索状态。public boolean convertClueToTransaction(String clueId, String tranName, String tranAmount, String tranStage) { // 1. 查询线索原始数据 Clue clue clueDao.selectClueById(clueId); if (clue null) { return false; } // 2. 生成客户记录 Customer customer new Customer(); customer.setId(UUIDUtil.getUUID()); customer.setName(clue.getCompany()); customer.setOwner(clue.getOwner()); customer.setPhone(clue.getPhone()); customer.setIndustry(clue.getIndustry()); customerDao.insertCustomer(customer); // 3. 生成联系人记录 Contacts contacts new Contacts(); contacts.setId(UUIDUtil.getUUID()); contacts.setCustomerId(customer.getId()); contacts.setFullname(clue.getFullName()); contacts.setMobile(clue.getPhone()); contactsDao.insertContacts(contacts); // 4. 生成交易记录 Tran tran new Tran(); tran.setId(UUIDUtil.getUUID()); tran.setCustomerId(customer.getId()); tran.setContactsId(contacts.getId()); tran.setName(tranName); tran.setAmount(tranAmount); tran.setStage(tranStage); tranDao.insertTran(tran); // 5. 更新线索状态为已转换 clueDao.updateClueState(clueId, 已转换); return true; }这段逻辑读起来简单但真实的坑在事务控制。这个项目没有 Spring也就没有Transactional每个 DAO 操作默认都是自动提交意味着如果第 4 步插入交易失败前面已经插入的客户和联系人记录不会回滚。数据就脏了。常见做法是手动控制事务Connection设置setAutoCommit(false)执行完所有 SQL 后统一commit()任何一个步骤抛异常就到catch块里rollback()Service 层把 Connection 从 Dao 层传入保证同一个连接操作多张表。public boolean convertWithTransaction(String clueId, ...) { Connection conn DBUtil.getConn(); try { conn.setAutoCommit(false); // 所有 DAO 方法都改成传入 conn 的重载版本 clueDao.insertCustomer(conn, customer); contactsDao.insertContacts(conn, contacts); tranDao.insertTran(conn, tran); conn.commit(); return true; } catch (Exception e) { conn.rollback(); e.printStackTrace(); return false; } finally { DBUtil.close(conn); } }提示DAO 方法要不要把 Connection 暴露出来是代码洁癖和实用之间的取舍。正统三层架构里 Connection 应该封装在 DAO 内部但一旦要多表联合操作且没有 Spring 事务管理唯一的办法就是传 Connection。项目以能跑通、数据一致为首要目标所以这个取舍是合理的。4.3 Layui 表格与后端 JSON 的对接细节前端列表页用的是 Layui 的 table 模块。这个项目的前端技术是 Layui HTML CSS JS JQueryLayui 是老牌前端框架自带表格、分页、弹层、表单美化。前端和后端的数据交接方式基本是$.get或$.post请求 Servlet 返回 JSON再用 JQuery 手动渲染。function loadActivityList(pageNo, pageSize) { $.ajax({ url: /activity?methodqueryActivityByCondition, type: post, data: { name: $(#name).val(), owner: $(#owner).val(), startDate: $(#startDate).val(), endDate: $(#endDate).val(), pageNo: pageNo || 1, pageSize: pageSize || 10 }, success: function (res) { var data JSON.parse(res); var html ; for (var i 0; i data.activityList.length; i) { var item data.activityList[i]; html tr; html tdinput typecheckbox nameid value item.id /td; html td item.name /td; html td item.owner /td; html td item.startDate ~ item.endDate /td; html td item.cost /td; html tda hrefjavascript:void(0) onclicktoEdit(\ item.id \)修改/a/td; html /tr; } $(#activityTable tbody).html(html); renderPagination(data.totalPages, data.pageNo); }, error: function () { alert(请求失败); } }); }这里有一个细节必须注意后端返回的是 JSON 字符串前端拿到后要JSON.parse再遍历。如果你看到页面空白但 Network 面板有响应大多数是res已经是对象了你又 parse 了一遍导致取不到字段。用 JQuery 的$.get时如果指定了dataType: jsonjQuery 会自动解析就不要再看调用JSON.parse。分页控件用的是 Layui 的laypage模块它有自己的render方法指定curr为当前页pages为总页数。后端返回的totalPages要算对否则点下一页没有任何反应。5. 部署避坑与排查JSP 项目最容易翻车的五个现场JSP 项目跑不起来大部分原因不是代码逻辑问题而是环境、编码和路径问题。以下五条来自我实际帮人排查的经历每一条都对应一次真实的翻车。5.1 现象404 错误访问/user找不到资源原因web.xml 里的 servlet-mapping 没配或者配了但/user被 JSP 文件占用了。还有一种情况是项目部署名和请求路径不一致。比如项目名是customerManage但前端请求写死了/user没有带项目部署名。解决先看请求 URL 是否包含项目名即http://localhost:8080/项目名/user?methodlogin。如果用了 IDE 内部浏览器它会自动带如果用独立浏览器访问必须手动加上。其次检查 web.xml 里url-pattern/user/url-pattern是否唯一不能有多个 servlet 映射同一个路径。5.2 现象MySQL 连接报 SSL 错误控制台满屏警告原因MySQL5.7 默认开了 SSL但项目用的 JDBC 驱动是 5.x 的老版本连接的 URL 没设置useSSLfalse时驱动会尝试建立 SSL 加密连接导致握手失败。解决在数据库连接配置的 URL 上加参数property nameurl valuejdbc:mysql://localhost:3306/crm?useSSLfalseamp;serverTimezoneUTCamp;characterEncodingutf8/注意 web.xml 或 properties 文件里写要转义成amp;否则 XML 解析直接报错。服务器的时区也要确认MySQL8 用serverTimezoneAsia/ShanghaiMySQL5.7 用serverTimezoneUTC问题不大。5.3 现象表单提交的中文到数据库变成问号原因三层编码不统一。JSP 页面本身是 UTF-8但 Servlet 接收时没setCharacterEncoding(UTF-8)数据到数据库又按 latin1 写入。解决三层都统一。JSP 页面顶部加% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%Servlet 的 post 请求开头必须加request.setCharacterEncoding(UTF-8)。数据库连接串加characterEncodingutf8建表 SQL 使用CHARSETutf8mb4。这三层只要有一个漏了中文就变成问号或乱码。排查技巧在 DAO 层打印 SQL 参数看 Java 层拿到的中文是否正常以此判断问题在 Servlet 层还是数据库层。5.4 现象页面样式全丢了Layui 的图标显示成方块原因静态资源被拦截或路径不对。最常见的是 web.xml 里配置了/的 Servlet 把静态资源也拦截了或者 JSP 页面用相对路径引用 CSS导致请求到的是不存在路径。解决在 web.xml 给静态资源单独放行用 Tomcat 默认的DefaultServlet对*.js、*.css、*.png做映射servlet-mapping servlet-namedefault/servlet-name url-pattern/static/*/url-pattern /servlet-mapping同时 JSP 页面里尽量用绝对路径引用比如${pageContext.request.contextPath}/static/layui/css/layui.css。这个问题在 IDEA 里特别容易触发因为 IDEA 的 Web 项目结构有时会把静态资源排除出编译目录导致部署后 static 目录整个缺失。5.5 现象数据统计页全是 0SQL 单独执行有数据原因MyBatis 映射的字段名和数据库列名对不上。数据库列是create_time实体类属性是createTimeMyBatis 的resultType自动映射时找不到对应字段返回 null。数值统计时就变成 0。解决在 mapper XML 里显式定义resultMap把别名对齐resultMap idActivityMap typeactivity result columncreate_time propertycreateTime/ result columnstart_date propertystartDate/ result columnend_date propertyendDate/ /resultMap如果用了select *而数据库表的列名和实体类字段名不一致所有驼峰映射的字段都会是 null。这是 MyBatis 新手最容易踩的坑没有之一。提示排查这类问题第一步永远不是猜而是打印 SQL 执行结果。把 MyBatis 的日志级别调到 DEBUG控制台能看到完整 SQL 和参数值比盯代码快得多。6. 把数据统计模块升级成可视化图表一个能直接落地的扩展方案项目里的数据统计模块如果还停留在「表格加数字」的阶段可以花一个下午把它改成 ECharts 折线图和柱状图。这个改动不需要动后端结构只加一个返回 JSON 的 Servlet 方法前端用 ECharts 渲染整个模块的完成度立刻上一个档次。先在后端ActivityController里加一个统计方法按月份分组查询市场活动数量private void activityStatsByMonth(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { ListMapString, Object list activityService.selectStatsByMonth(); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(new Gson().toJson(list)); }对应的 mapper SQLselect idselectStatsByMonth resultTypemap select DATE_FORMAT(create_time, %Y-%m) as month, count(*) as total from tbl_activity group by DATE_FORMAT(create_time, %Y-%m) order by month /select前端在统计 JSP 页面引入 EChartsdiv idactivityChart stylewidth: 800px; height: 400px;/div script src${pageContext.request.contextPath}/static/echarts/echarts.min.js/script script var chart echarts.init(document.getElementById(activityChart)); $.get(${pageContext.request.contextPath}/activity?methodactivityStatsByMonth, function (res) { var data JSON.parse(res); var months []; var counts []; for (var i 0; i data.length; i) { months.push(data[i].month); counts.push(data[i].total); } chart.setOption({ title: { text: 每月市场活动数量 }, tooltip: {}, xAxis: { data: months }, yAxis: {}, series: [{ name: 活动数, type: bar, data: counts }] }); }); /script这个扩展的核心价值在于把 MyBatis 的resultTypemap用到了实处。返回 ListMap 是最灵活的形式字段名和值都很自由适合做图表这样「结构不固定」的统计输出。如果你要统计交易金额按阶段分布只需要换一条 SQL前端多一个 series完全不改架构。做完图表把数据汇总成 PDF 报表导出是我见过不少同学后续常做的事。提醒一句导出 PDF 用 iText 或 POI 都行但中文支持需要额外引入字体文件这一块坑比较多不要最后一天赶工做。我从第一次带这种 JSP 项目到现在养成了一个习惯任何模块动代码之前先把对应的 mapper 的 SQL 在 Navicat 里跑一遍确认数据能查出来再回头写 Java 和前端。这个习惯让我的排查时间至少省了一半也让我很少再出现「后端返回空、前端干瞪眼」的局面。希望这篇拆解能帮你在复现这个项目的时候少走几步弯路。本文还有配套的精品资源点击获取