简介:一套采用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. 一个 JSP+Servlet 的客户管理系统:为什么 2025 年还有人用它做毕业设计
后台管理类项目里,「JSP+Servlet+MySQL 客户管理系统」是出现频率最高的组合之一。它不花哨,却是理解 Java Web 请求生命周期最直接的路径:浏览器发一个 HTTP 请求,Servlet 接住,MyBatis 查库,JSP 把数据渲染回页面。这套流程跑通了,后面学 Spring MVC、Spring Boot 都是同一个套路换层壳。
这个项目覆盖的模块很典型:登录认证、用户管理、客户管理、线索管理、交易管理、市场活动管理、联系人管理,外加数据统计和系统管理,正好是一套完整 B/S 系统的骨架。技术栈是 Java + JSP + Servlet + MyBatis + MySQL + Layui,JDK1.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 加useSSL=false&serverTimezone=UTC。后面避坑章节会详细说。
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-name>UserController</servlet-name> <servlet-class>com.system.controller.UserController</servlet-class> </servlet> <servlet-mapping> <servlet-name>UserController</servlet-name> <url-pattern>/user</url-pattern> </servlet-mapping> <servlet> <servlet-name>ClueController</servlet-name> <servlet-class>com.system.controller.ClueController</servlet-class> </servlet> <servlet-mapping> <servlet-name>ClueController</servlet-name> <url-pattern>/clue</url-pattern> </servlet-mapping>/user这个 URL 模式意味着所有/user?method=login这样的请求都会进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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 客户表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 线索表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意几个设计细节。第一,主键用 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;charset=UTF-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 id="selectUser" parameterType="map" resultType="user"> select * from tbl_user where login_act = #{loginAct} and login_pwd = #{loginPwd} </select>#{loginAct}是 MyBatis 预编译占位符,自动加引号并转义,能防 SQL 注入。有同学踩坑是因为用了${loginAct}——它不做预编译,直接把字符串拼进 SQL,单引号都不帮你补,结果要么报语法错误要么被注入攻击。这个项目里所有用户输入的地方,都应该用#{}而不是${}。
parameterType="map"意味着调用时传 Map 对象,DAO 接口方法写法是:
User selectUser(Map<String, Object> map); // 调用时: Map<String, 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 Map<String, 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. 查询数据列表和总条数 List<Activity> activityList = activityService.queryActivityByCondition(map); int total = activityService.queryActivityCount(map); // 5. 计算总页数 int totalPages = (total % pageSize == 0) ? (total / pageSize) : (total / pageSize + 1); // 6. 封装成 Map 返回 JSON Map<String, 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)*10=0,对应 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 + JQuery,Layui 是老牌前端框架,自带表格、分页、弹层、表单美化。前端和后端的数据交接方式基本是$.get或$.post请求 Servlet 返回 JSON,再用 JQuery 手动渲染。
function loadActivityList(pageNo, pageSize) { $.ajax({ url: '/activity?method=queryActivityByCondition', 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 += '<td><input type="checkbox" name="id" 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 += '<td><a href="javascript:void(0)" onclick="toEdit(\'' + 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: 'json',jQuery 会自动解析,就不要再看调用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?method=login。如果用了 IDE 内部浏览器,它会自动带;如果用独立浏览器访问,必须手动加上。其次检查 web.xml 里<url-pattern>/user</url-pattern>是否唯一,不能有多个 servlet 映射同一个路径。
5.2 现象:MySQL 连接报 SSL 错误,控制台满屏警告
原因:MySQL5.7 默认开了 SSL,但项目用的 JDBC 驱动是 5.x 的老版本,连接的 URL 没设置useSSL=false时,驱动会尝试建立 SSL 加密连接,导致握手失败。
解决:在数据库连接配置的 URL 上加参数:
<property name="url" value="jdbc:mysql://localhost:3306/crm?useSSL=false&serverTimezone=UTC&characterEncoding=utf8"/>注意 web.xml 或 properties 文件里写&要转义成&,否则 XML 解析直接报错。服务器的时区也要确认,MySQL8 用serverTimezone=Asia/Shanghai,MySQL5.7 用serverTimezone=UTC问题不大。
5.3 现象:表单提交的中文到数据库变成问号
原因:三层编码不统一。JSP 页面本身是 UTF-8,但 Servlet 接收时没setCharacterEncoding("UTF-8"),数据到数据库又按 latin1 写入。
解决:三层都统一。JSP 页面顶部加:
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>Servlet 的 post 请求开头必须加request.setCharacterEncoding("UTF-8")。数据库连接串加characterEncoding=utf8,建表 SQL 使用CHARSET=utf8mb4。这三层只要有一个漏了,中文就变成问号或乱码。排查技巧:在 DAO 层打印 SQL 参数,看 Java 层拿到的中文是否正常,以此判断问题在 Servlet 层还是数据库层。
5.4 现象:页面样式全丢了,Layui 的图标显示成方块
原因:静态资源被拦截或路径不对。最常见的是 web.xml 里配置了/的 Servlet 把静态资源也拦截了,或者 JSP 页面用相对路径引用 CSS,导致请求到的是不存在路径。
解决:在 web.xml 给静态资源单独放行,用 Tomcat 默认的DefaultServlet对*.js、*.css、*.png做映射:
<servlet-mapping> <servlet-name>default</servlet-name> <url-pattern>/static/*</url-pattern> </servlet-mapping>同时 JSP 页面里尽量用绝对路径引用,比如${pageContext.request.contextPath}/static/layui/css/layui.css。这个问题在 IDEA 里特别容易触发,因为 IDEA 的 Web 项目结构有时会把静态资源排除出编译目录,导致部署后 static 目录整个缺失。
5.5 现象:数据统计页全是 0,SQL 单独执行有数据
原因:MyBatis 映射的字段名和数据库列名对不上。数据库列是create_time,实体类属性是createTime,MyBatis 的resultType自动映射时找不到对应字段,返回 null。数值统计时就变成 0。
解决:在 mapper XML 里显式定义resultMap,把别名对齐:
<resultMap id="ActivityMap" type="activity"> <result column="create_time" property="createTime"/> <result column="start_date" property="startDate"/> <result column="end_date" property="endDate"/> </resultMap>如果用了select *,而数据库表的列名和实体类字段名不一致,所有驼峰映射的字段都会是 null。这是 MyBatis 新手最容易踩的坑,没有之一。
提示:排查这类问题,第一步永远不是猜,而是打印 SQL 执行结果。把 MyBatis 的日志级别调到 DEBUG,控制台能看到完整 SQL 和参数值,比盯代码快得多。
6. 把数据统计模块升级成可视化图表:一个能直接落地的扩展方案
项目里的数据统计模块如果还停留在「表格加数字」的阶段,可以花一个下午把它改成 ECharts 折线图和柱状图。这个改动不需要动后端结构,只加一个返回 JSON 的 Servlet 方法,前端用 ECharts 渲染,整个模块的完成度立刻上一个档次。
先在后端ActivityController里加一个统计方法,按月份分组查询市场活动数量:
private void activityStatsByMonth(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { List<Map<String, Object>> list = activityService.selectStatsByMonth(); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(new Gson().toJson(list)); }对应的 mapper SQL:
<select id="selectStatsByMonth" resultType="map"> 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 页面引入 ECharts:
<div id="activityChart" style="width: 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?method=activityStatsByMonth', 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 的resultType="map"用到了实处。返回 List<Map> 是最灵活的形式,字段名和值都很自由,适合做图表这样「结构不固定」的统计输出。如果你要统计交易金额按阶段分布,只需要换一条 SQL,前端多一个 series,完全不改架构。
做完图表把数据汇总成 PDF 报表导出,是我见过不少同学后续常做的事。提醒一句:导出 PDF 用 iText 或 POI 都行,但中文支持需要额外引入字体文件,这一块坑比较多,不要最后一天赶工做。
我从第一次带这种 JSP 项目到现在,养成了一个习惯:任何模块动代码之前,先把对应的 mapper 的 SQL 在 Navicat 里跑一遍,确认数据能查出来,再回头写 Java 和前端。这个习惯让我的排查时间至少省了一半,也让我很少再出现「后端返回空、前端干瞪眼」的局面。希望这篇拆解能帮你在复现这个项目的时候少走几步弯路。
本文还有配套的精品资源,点击获取