资讯动态

Java Web CRM实战:JSP+Servlet+MySQL构建可交付客户管理系统

发布时间:2026/10/4 18:44:50 来源:尧图企业网站定制
简介本资源是一份面向计算机专业本科生及Java初学者的客户关系管理系统CRM课程设计文档聚焦B/S架构下企业级客户信息管理的完整实现方案。文档系统阐述了基于Java、JSP、MySQL与Tomcat的技术选型依据、数据库表结构设计、核心功能模块如客户信息管理、销售机会跟踪、服务请求处理划分逻辑、界面交互设计思路及系统测试方法兼具理论分析与工程实践指导价值。资源为单个3.84MB的Word文档.docx内容涵盖摘要、关键词、中英文摘要、目录、绪论、可行性分析、功能设计、数据库规划、模块测试说明及界面截图与操作说明结构完整、图文并茂便于读者理解系统全貌并复现关键设计环节。目前已有72人学习下载适合用于课程设计参考、毕业设计选题拓展或Java Web开发入门实践。1. 为什么一个“基于Java的客户关系管理系统”至今仍是校招和中小项目最硬的敲门砖你可能已经见过太多“Spring Boot Vue”的CRM演示项目但真正能跑在Windows Server上、被三四线城市汽配城老板盯着看订单跟进状态、让销售主管每天导出Excel发到微信群里的系统往往就长这样JSP页面Servlet控制层MySQL单库Tomcat部署——没有Redis缓存、不接消息队列、连Druid连接池都算高级配置。它不炫技但胜在可交付、可维护、可审计数据库字段改一个前端表单自动对齐销售离职了新员工用同一套账号密码登录就能查历史跟进记录税务稽查要2022年所有客户合同扫描件管理员点两下就能按客户ID打包下载。这不是过时的技术栈而是B/S架构下最小可行闭环的实体业务映射——客户录入→联系人管理→商机跟踪→合同归档→回款登记→服务工单每一步都对应真实岗位动作。适合刚毕业想扎进业务逻辑里练手的开发者也适合预算有限、IT人员只有1人的小微企业。本文不讲微服务拆分或低代码平台只带你用最朴素的Java Web技术组合把这套系统从.docx文档里的UML图变成能真正在局域网里被5个销售同时操作、数据不丢、页面不卡、导出不乱码的可运行体。2. 用JSPServletMySQL搭起CRM骨架从ER图到可运行war包的6步实操这个系统不是靠框架自动生成的它的骨架必须手动夯实在三层结构里表现层JSP、业务逻辑层Servlet、数据访问层JDBC直连。之所以坚持不用Spring MVC起步是因为你要看清HTTP请求如何穿透容器、SQL如何绑定参数、事务边界在哪划——这些在Spring Boot里被Transaction注解掩盖的细节恰恰是排查“客户信息保存后联系人没同步”这类问题的命门。2.1 先画清三张核心表客户、联系人、跟进记录的主外键约束CRM不是堆字段而是建关系。我们只聚焦最刚需的三张表其他如产品、合同、工单后续可扩展用MySQL 5.7语法建模严格启用外键约束很多教程为省事关掉结果导致删除客户时联系人残留成脏数据-- 客户主表企业级客户 CREATE TABLE customer ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 主键, name VARCHAR(100) NOT NULL COMMENT 客户名称, industry VARCHAR(50) DEFAULT COMMENT 所属行业, level ENUM(A,B,C) DEFAULT B COMMENT 客户等级, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户主表; -- 联系人表每个客户可有多个联系人 CREATE TABLE contact ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL COMMENT 关联客户ID, name VARCHAR(50) NOT NULL COMMENT 姓名, position VARCHAR(50) DEFAULT COMMENT 职位, phone VARCHAR(20) DEFAULT COMMENT 手机号, email VARCHAR(100) DEFAULT COMMENT 邮箱, FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT联系人表; -- 跟进记录表销售每次拜访/电话都要记一笔 CREATE TABLE follow_up ( id BIGINT PRIMARY KEY AUTO_INCREMENT, customer_id BIGINT NOT NULL COMMENT 客户ID, contact_id BIGINT DEFAULT NULL COMMENT 具体联系人ID可为空, content TEXT NOT NULL COMMENT 跟进内容, next_plan VARCHAR(200) DEFAULT COMMENT 下次计划, follow_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customer(id) ON DELETE CASCADE, FOREIGN KEY (contact_id) REFERENCES contact(id) ON DELETE SET NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT跟进记录表;关键说明ON DELETE CASCADE确保删客户时自动清空其联系人ON DELETE SET NULL让联系人被删后跟进记录仍保留客户线索避免历史记录断裂ENGINEInnoDB是唯一支持外键的引擎MyISAM在此场景下直接出局utf8mb4防止微信昵称里的emoji存不进去别信“utf8就够用”的玄学说法。2.2 Servlet层写死三个核心动作增客户、查列表、导Excel不要一上来就搞MVC分层先用一个CustomerServlet承载全部CRUD等逻辑稳定再拆。重点看如何用原生JDBC规避常见坑// CustomerServlet.java简化版仅保留新增客户逻辑 WebServlet(/customer/add) public class CustomerServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 获取表单参数注意JSP里name属性必须与这里一致 String name req.getParameter(name); String industry req.getParameter(industry); String level req.getParameter(level); // 2. 构建SQL用PreparedStatement防SQL注入 String sql INSERT INTO customer (name, industry, level) VALUES (?, ?, ?); try (Connection conn DBUtil.getConnection(); // 自定义工具类封装DriverManager PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, name); ps.setString(2, industry); ps.setString(3, level); int affectedRows ps.executeUpdate(); if (affectedRows 0) { throw new SQLException(插入客户失败); } // 3. 获取自增主键用于跳转详情页 try (ResultSet rs ps.getGeneratedKeys()) { if (rs.next()) { long newId rs.getLong(1); resp.sendRedirect(customer/detail.jsp?id newId); // 重定向防重复提交 return; } } } catch (SQLException e) { e.printStackTrace(); req.setAttribute(error, 保存失败 e.getMessage()); req.getRequestDispatcher(add.jsp).forward(req, resp); // 返回原页面带错误提示 } } }参数说明req.getParameter()只取第一个同名参数多选框要用getParameterValues()ps.setString()自动处理NULL和特殊字符比拼接字符串安全百倍Statement.RETURN_GENERATED_KEYS是获取自增ID的唯一可靠方式别用SELECT LAST_INSERT_ID()sendRedirect而非forward避免F5刷新重复提交——这是新手翻车最多的地方。2.3 JSP页面用JSTLEL表达式写活数据流拒绝scriptlet硬编码JSP不是HTML它的价值在于动态渲染标签复用。禁用% %脚本片段全部用JSTL标准标签% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % !DOCTYPE html html head title客户列表/title link relstylesheet hrefcss/bootstrap.min.css /head body div classcontainer mt-4 h2客户管理/h2 a hrefcustomer/add.jsp classbtn btn-primary mb-3新增客户/a !-- 表格数据由Servlet塞入requestScope -- table classtable table-striped thead tr thID/th th客户名称/th th行业/th th等级/th th操作/th /tr /thead tbody c:forEach items${customers} varcust tr td${cust.id}/td td${cust.name}/td td${cust.industry}/td tdspan classbadge bg-${cust.levelA?success:warning}${cust.level}/span/td td a hrefcustomer/detail.jsp?id${cust.id} classbtn btn-sm btn-info详情/a a hrefcustomer/edit.jsp?id${cust.id} classbtn btn-sm btn-warning编辑/a /td /tr /c:forEach /tbody /table /div /body /html逻辑说明${customers}是Servlet中req.setAttribute(customers, list)塞进来的List c:forEach自动遍历比手写for循环安全空集合不报错classbadge bg-${...}动态设置Bootstrap徽章颜色A级绿色、B/C级黄色所有URL路径用相对路径customer/detail.jsp避免硬编码上下文路径/crm/customer/detail.jsp。2.4 Tomcat部署war包从eclipse导出到服务器运行的实操链路本地开发用EclipseTomcat插件很爽但交付时必须生成标准war包。不要用IDE一键部署功能那只是模拟真正的交付包必须手动验证在Eclipse中右键项目 →Export → WAR file目标文件名crm.war勾选Export source files方便现场调试不勾选Save as Ant script无用将crm.war上传至Linux服务器的$TOMCAT_HOME/webapps/目录scp crm.war user192.168.1.100:/opt/tomcat/webapps/启动Tomcat确保端口8080未被占用cd /opt/tomcat/bin ./startup.sh tail -f ../logs/catalina.out # 实时查看启动日志验证是否成功浏览器访问http://192.168.1.100:8080/crm/查看Tomcat日志是否有INFO [main] org.apache.catalina.startup.HostConfig.deployWAR Deploying web application archive若报ClassNotFoundException: com.mysql.cj.jdbc.Driver说明MySQL驱动jar没放对位置——必须放在$TOMCAT_HOME/lib/下不能只丢在WEB-INF/lib这是Tomcat类加载机制决定的。血泪经验war包解压后WEB-INF/web.xml中的servlet-mapping必须与WebServlet注解路径一致否则404中文乱码在$TOMCAT_HOME/conf/server.xml的Connector节点加URIEncodingUTF-8页面CSS不生效检查link路径是否漏了/crm/上下文前缀JSP中用c:url value/css/xxx.css/自动补全。3. 数据一致性怎么守用JDBC事务手动回滚兜住销售误操作CRM系统最怕的不是功能少而是数据错乱比如新增客户时客户表插入成功但联系人表因手机号格式错误插入失败结果数据库里留下一个“没联系人的客户”。这种问题在单表操作时不会暴露一旦涉及多表联动客户→联系人→跟进记录就必须显式控制事务边界。Spring的Transactional在这里是奢侈品我们用原生JDBC手动管理。3.1 把“新增客户默认联系人”包装成原子操作销售新建客户时系统应自动创建一个“负责人”联系人姓名填“张三”职位填“总经理”。这必须在一个事务里完成// CustomerService.java业务逻辑层剥离Servlet public class CustomerService { public boolean addCustomerWithContact(String name, String industry, String level, String contactName, String position) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关键关闭自动提交 // 步骤1插入客户 String custSql INSERT INTO customer (name, industry, level) VALUES (?, ?, ?); try (PreparedStatement custPs conn.prepareStatement(custSql, Statement.RETURN_GENERATED_KEYS)) { custPs.setString(1, name); custPs.setString(2, industry); custPs.setString(3, level); custPs.executeUpdate(); // 获取新客户ID long customerId 0; try (ResultSet rs custPs.getGeneratedKeys()) { if (rs.next()) customerId rs.getLong(1); } // 步骤2插入联系人关联刚生成的customerId String contactSql INSERT INTO contact (customer_id, name, position) VALUES (?, ?, ?); try (PreparedStatement contactPs conn.prepareStatement(contactSql)) { contactPs.setLong(1, customerId); contactPs.setString(2, contactName); contactPs.setString(3, position); contactPs.executeUpdate(); } } conn.commit(); // 全部成功才提交 return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); // 任一环节失败全部回滚 } catch (SQLException rollbackEx) { rollbackEx.printStackTrace(); } } e.printStackTrace(); return false; } finally { DBUtil.closeConnection(conn); // 确保连接释放 } } }为什么不用Connection.setSavepoint()Savepoint适合嵌套事务如批量导入中某条失败跳过而CRM新增客户是强原子操作——要么全成功要么全失败Savepoint反而增加复杂度。3.2 导出Excel时锁表用SELECT ... FOR UPDATE精准锁定行销售主管要导出“近30天跟进过的A级客户”这个查询本身不修改数据但若导出过程中有销售正在编辑该客户信息就可能出现导出的是旧数据而界面上显示的是新数据的不一致。解决方案不是加读锁影响并发而是用SELECT ... FOR UPDATE在事务中锁定目标行// ExportService.java public ListCustomer getAStarCustomersForExport() { ListCustomer result new ArrayList(); String sql SELECT * FROM customer WHERE level A AND id IN ( SELECT DISTINCT customer_id FROM follow_up WHERE follow_time DATE_SUB(NOW(), INTERVAL 30 DAY) ) ORDER BY created_at DESC FOR UPDATE; // 关键FOR UPDATE try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { conn.setAutoCommit(false); // 开启事务让FOR UPDATE生效 try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Customer c new Customer(); c.setId(rs.getLong(id)); c.setName(rs.getString(name)); c.setIndustry(rs.getString(industry)); result.add(c); } } conn.commit(); // 提交事务释放锁 } catch (SQLException e) { e.printStackTrace(); } return result; }参数说明FOR UPDATE只在InnoDB引擎下有效MyISAM直接忽略锁定的是满足WHERE条件的行不是整张表其他B/C级客户操作不受影响必须配合setAutoCommit(false)否则SELECT语句执行完立即释放锁导出完成后必须commit()否则锁会一直持有直到连接超时默认8小时灾难性。3.3 防止“联系人手机号重复”数据库唯一索引应用层双重校验CRM里常要求“同一客户下联系人手机号不能重复”但跨客户允许重复张三在A公司和B公司都留了138****1234。这无法用简单唯一索引实现必须结合数据库约束和应用层逻辑数据库层面给(customer_id, phone)建联合唯一索引ALTER TABLE contact ADD UNIQUE KEY uk_customer_phone (customer_id, phone);应用层校验兜底防止索引失效或并发冲突public boolean isPhoneDuplicate(long customerId, String phone) { String sql SELECT COUNT(*) FROM contact WHERE customer_id ? AND phone ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, customerId); ps.setString(2, phone); try (ResultSet rs ps.executeQuery()) { if (rs.next()) return rs.getInt(1) 0; } } catch (SQLException e) { e.printStackTrace(); } return false; }为什么双重校验数据库索引是最终防线但INSERT时若发生唯一键冲突抛出SQLIntegrityConstraintViolationException需捕获并友好提示用户应用层提前查一次能在表单提交前给出实时反馈如手机号已存在标红提示体验更好并发场景下两次查询之间仍有极小概率被其他线程插入所以必须以数据库异常为准应用层校验只是优化。4. 避坑JSPServletMySQL组合里5个让老手都踩过坑的致命细节这套技术栈看似简单但每个环节都有反直觉陷阱。以下是我在线上环境修过的真实问题按现象→原因→解决整理不讲理论只说怎么救火。4.1 现象JSP页面中文显示为“???”但数据库里存的是正确汉字原因Tomcat默认用ISO-8859-1解码GET请求参数而浏览器实际用UTF-8发送。POST请求虽可通过req.setCharacterEncoding(UTF-8)解决但GET的QueryString无法用此方法。解决在$TOMCAT_HOME/conf/server.xml的Connector节点加URIEncodingUTF-8同时在JSP顶部声明% page contentTypetext/html;charsetUTF-8 %额外加固对GET参数手动转码万不得已时用String name new String(req.getParameter(name).getBytes(ISO-8859-1), UTF-8);4.2 现象MySQL插入时间字段为0000-00-00 00:00:00而非当前时间原因MySQL 5.7默认开启STRICT_TRANS_TABLES模式datetime字段设DEFAULT CURRENT_TIMESTAMP时若插入NULL值会报错而JDBC驱动版本过低如5.1.x不识别CURRENT_TIMESTAMP传NULL过去。解决升级MySQL Connector/J到8.0mysql-connector-java-8.0.33.jar建表时明确写created_at DATETIME DEFAULT CURRENT_TIMESTAMP不要写成DEFAULT NOW()NOW()在某些驱动下解析异常Java代码中对时间字段不设值让数据库自动生成而不是传null或new Date()。4.3 现象导出Excel文件下载后打开提示“文件损坏”用记事本看开头是HTML源码原因Servlet响应头没设对浏览器把二进制Excel当成HTML解析。常见错误是只设Content-Type忘了Content-Disposition。解决resp.setContentType(application/vnd.ms-excel); resp.setHeader(Content-Disposition, attachment; filenamecustomers_ new SimpleDateFormat(yyyyMMdd_HHmmss).format(new Date()) .xls); // 关键关闭out流防止JSP模板输出干扰 PrintWriter out resp.getWriter(); out.close(); // 必须调用否则后续write()会写到HTML流里 // 然后用HSSFWorkbook.write(resp.getOutputStream())4.4 现象Tomcat重启后JSP页面编译失败报org.apache.jasper.JasperException: Unable to compile class for JSP原因Eclipse生成的work/Catalina/localhost/crm/缓存目录残留旧class文件与新JSP不匹配。解决彻底删除$TOMCAT_HOME/work/Catalina/localhost/crm/目录清空Eclipse的workspace/.metadata/.plugins/org.eclipse.wst.server.core/tmp0/work/Catalina/localhost/crm/预防在web.xml中加init-paramparam-namedevelopment/param-nameparam-valuefalse/param-value/init-param关闭JSP热编译生产环境必须关。4.5 现象客户列表分页时第2页数据和第1页重复总数也不对原因用了LIMIT offset, size但没加ORDER BYMySQL对无序结果集的分页是不确定的尤其数据有更新时。解决所有分页查询必须带确定性排序SELECT * FROM customer ORDER BY id DESC LIMIT 0,10更健壮的做法是用游标分页Cursor-based PaginationSELECT * FROM customer WHERE id ? ORDER BY id DESC LIMIT 10下一页传上一页最后一条的id值彻底避免OFFSET偏移问题。5. 让CRM真正可用三个被90%教程忽略但生产环境必备的落地技巧教科书式的CRM能跑通增删改查但离“能用”还差三步权限隔离、操作留痕、离线容灾。这些不是锦上添花而是老板问“昨天谁删了客户”时你能立刻甩出证据链的底气。5.1 行级权限销售只能看自己录入的客户主管看全部B/S系统最大的安全盲区是“前端隐藏按钮权限控制”。真实做法是在SQL查询层就过滤数据。给customer表加creator_id字段录入人ID并在所有查询中强制追加WHERE条件// BaseDAO.java所有DAO继承 protected String buildQueryWithAuth(String baseSql, long userId) { // 普通销售只能看自己录的 // 主管看全部role1 String roleSql SELECT role FROM user WHERE id ?; int role 0; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(roleSql)) { ps.setLong(1, userId); try (ResultSet rs ps.executeQuery()) { if (rs.next()) role rs.getInt(role); } } catch (SQLException e) { e.printStackTrace(); } if (role 2) { // 销售角色 return baseSql WHERE creator_id userId; } return baseSql; // 主管角色不加限制 } // CustomerDAO.java中调用 public ListCustomer listAll(long userId) { String sql SELECT * FROM customer; sql buildQueryWithAuth(sql, userId); // 自动注入WHERE条件 // ... 执行查询 }为什么不用Shiro或Spring Security这些框架适合复杂RBAC而CRM只需区分“销售/主管”两级硬上框架反而增加部署复杂度。行级SQL过滤简单、透明、无学习成本。5.2 操作日志谁在什么时候改了哪个客户的哪个字段CRM的核心资产是客户数据而数据变更必须可追溯。不要只记“张三修改了客户A”要精确到字段级CREATE TABLE audit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, table_name VARCHAR(50) NOT NULL COMMENT 操作表名, record_id BIGINT NOT NULL COMMENT 记录ID, field_name VARCHAR(50) NOT NULL COMMENT 字段名, old_value TEXT COMMENT 旧值, new_value TEXT COMMENT 新值, operator_id BIGINT NOT NULL COMMENT 操作人ID, operate_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在CustomerServlet的编辑逻辑里对比前后对象差异再写日志// 编辑客户时 Customer oldCust customerDao.findById(id); Customer newCust parseFromRequest(req); // 从表单解析新对象 if (!Objects.equals(oldCust.getName(), newCust.getName())) { auditLogDao.insert(customer, id, name, oldCust.getName(), newCust.getName(), userId); } if (!Objects.equals(oldCust.getIndustry(), newCust.getIndustry())) { auditLogDao.insert(customer, id, industry, oldCust.getIndustry(), newCust.getIndustry(), userId); } // ... 其他字段逐一比对关键设计日志表不存客户全量快照太占空间只存变更字段old_value/new_value用TEXT类型兼容长文本如跟进内容查询时用SELECT * FROM audit_log WHERE table_namecustomer AND record_id123 ORDER BY operate_time DESC即可还原完整修改史。5.3 离线备份每天凌晨自动导出客户表为SQL文件存本地NAS线上CRM不怕宕机怕数据丢失。MySQL自带mysqldump命令但必须做成无人值守任务写备份脚本backup_crm.sh#!/bin/bash DATE$(date %Y%m%d_%H%M%S) BACKUP_DIR/nas/crm_backup mysqldump -u root -pyour_password --databases crm $BACKUP_DIR/crm_${DATE}.sql # 只保留最近7天备份 find $BACKUP_DIR -name crm_*.sql -mtime 7 -delete加入crontab每天凌晨2点执行0 2 * * * /bin/bash /opt/scripts/backup_crm.sh /var/log/crm_backup.log 21验证备份有效性每月人工抽检随机选一个备份文件用mysql -u root -p crm crm_20240501.sql恢复到测试库检查SELECT COUNT(*) FROM customer是否与线上一致重点验证外键关联数据如SELECT c.name, co.name FROM customer c JOIN contact co ON c.idco.customer_id LIMIT 10。我的习惯备份脚本里密码明文是权宜之计生产环境应改用MySQL配置文件~/.my.cnf存储凭证NAS路径必须挂载为本地目录mount -t cifs //nas-ip/share /nas -o usernameuser,passwordpass避免网络中断导致备份失败日志文件crm_backup.log要定期轮转否则几年后单个文件超GB。这套CRM系统我亲手交付过17家小微企业从汽配城到建材批发最深的体会是技术栈越朴素越要抠细节功能越简单越要防人性漏洞。当销售总监指着屏幕说“这个客户上周明明跟进过怎么记录没了”而你30秒内从audit_log表里调出修改人、时间、字段变更再从NAS里找出三天前的备份SQL——那一刻你写的不是Java代码是信任契约。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑