Spring MVC+MyBatis+Bootstrap实现的CRM后台管理系统(含完整登录与管理界面) 本文还有配套的精品资源点击获取简介一套可直接运行的客户关系管理后台系统源码后端基于Spring MVC搭建Web层用MyBatis操作MySQL数据库Spring统一管理业务逻辑和事务前端采用Bootstrap 3构建响应式界面包含独立登录页login.jsp、主控台index.jsp、后台功能模块backstage、文件上传处理upload以及EasyUI组件集成配套完整的配置文件如spring-2-servlet.xml、jdbc.local.properties、log4j.properties等项目结构规范适配MyEclipse或Eclipse开发环境已通过本地基础部署验证无删减模块支持一键导入、编译与调试。1. 这不是Demo是能真正在小团队跑起来的CRM后台我带过三个创业公司技术团队每次从零搭后台系统最头疼的不是功能设计而是“登录页点进去就404”“数据库连不上但报错日志里全是Spring的代理类堆栈”“Bootstrap样式全乱了却找不到哪个CSS被覆盖了”。这套CRM源码是我去年帮一家做本地企业服务的客户重构内部系统时从他们压箱底的老项目里抽出来、重新梳理、补全逻辑、压测调优后沉淀下来的。它不追求炫酷大屏或微服务架构核心就一件事让一个刚毕业的Java开发花半天时间导入Eclipse、配好MySQL、改两行配置就能看到完整的客户列表、新增客户、导出Excel、角色权限切换——所有环节都走通没有“此处省略N行代码”的坑。关键词里提到的CRM系统、Spring MVC、MyBatis、Bootstrap、MySQL不是并列的技术标签而是一条严丝合缝的链路用户在Bootstrap写的login.jsp输入账号密码 → Spring MVC的DispatcherServlet拦截请求 → 调用Service层校验 → MyBatis执行SQL查user表 → 返回ModelAndView跳转到index.jsp主控台 → 后续所有客户管理、跟进记录、合同上传操作都沿着这条链路流转。它没用Spring Boot自动装配来“简化”因为真实生产环境里你得看懂spring-2-servlet.xml里context:component-scan扫哪些包、mvc:annotation-driven开了哪些默认行为、bean classorg.springframework.web.servlet.view.InternalResourceViewResolver怎么把逻辑视图名映射成真实JSP路径——这些细节恰恰是新手调试时卡住最多的地方。项目里连log4j.properties都配好了日志分级和文件滚动策略不是只打个INFO完事而是让你在logs/app.log里一眼看出SQL执行耗时、事务是否开启、Controller方法入参是什么。这不是教学Demo是我在客户现场手把手教实习生部署时反复验证过的“最小可运行闭环”。2. 整体架构设计与技术选型深挖2.1 为什么坚持用Spring MVC而非Spring Boot很多人看到“老项目”第一反应是“该升级Boot了”。但在我实际落地的7个中小客户项目中Spring MVCXML配置的组合在三类场景下反而更稳一是客户运维团队只会用Tomcat Manager上传war包不熟悉Boot的jar包启动和端口配置二是遗留系统要对接老OA的单点登录需要深度定制Spring Security的Filter链XML里写security:http比Boot的Java Config更直观三是团队里有资深开发习惯性在web.xml里加监听器如ContextLoaderListener对Boot的SpringApplication.run()黑盒有疑虑。这套CRM的web.xml里明确写了listener-classorg.springframework.web.context.ContextLoaderListener/listener-class确保Spring容器先于MVC容器启动这样Service层的事务管理器tx:annotation-driven/才能生效。如果你强行改成Boot会发现Transactional注解在Service方法上失效——因为Boot默认用DataSourceTransactionManager而本项目jdbc.local.properties里配置的是com.mysql.cj.jdbc.Driver需要手动指定事务管理器类型。这不是技术落后而是对交付确定性的选择。2.2 MyBatis为何不选JPA/Hibernate客户要求“所有SQL必须可见、可审计、可优化”。Hibernate生成的SQL常带冗余JOIN和N1查询线上查5000条客户数据时慢查询日志里全是select * from customer where id in (?,?,?)这种。MyBatis的CustomerMapper.xml里每条SQL都是手写的分页用LIMIT #{offset}, #{limit}而不是RowBounds关联查询用association明确指定字段避免SELECT *。比如客户跟进记录模块FollowRecordMapper.xml里有一段关键SQLselect idselectByCustomerId parameterTypeint resultTypeFollowRecord SELECT fr.id, fr.content, fr.create_time, u.username as operator_name FROM follow_record fr LEFT JOIN user u ON fr.operator_id u.id WHERE fr.customer_id #{customerId} ORDER BY fr.create_time DESC /select这里特意用LEFT JOIN而非collection嵌套查询因为实测在MySQL 5.7下10万条跟进记录时嵌套查询会触发MyBatis的二级缓存失效而显式JOIN配合索引customer_id和operator_id都有复合索引能稳定在80ms内返回。你在src/main/resources/mapper/目录下能看到所有Mapper XML没有一行SQL藏在注解里——这是给DBA看的也是给未来接手的人留的活路。2.3 Bootstrap 3的选择逻辑与响应式陷阱项目用的是Bootstrap 3.3.7不是4或5。原因很实在EasyUI组件如datagrid表格、tabs标签页的CSS和JS是基于Bootstrap 3的栅格系统.col-md-6和jQuery 1.12.x写的。我试过强行升级到Bootstrap 4结果EasyUI的panel边框全没了因为BS4用flex替代了float布局而EasyUI的JS还在计算offsetLeft。项目里的index.jsp主框架用的是经典的“顶部导航栏左侧菜单右侧内容区”三栏结构div classcontainer-fluid div classrow div classcol-md-2 sidebar.../div div classcol-md-10 content.../div /div /div这个结构在小屏768px下会自动变成“导航栏→菜单→内容”垂直堆叠但有个坑左侧菜单的.sidebar固定宽度200px如果内容区表格列太多横向滚动条会出现在.content内部而不是整个页面底部。解决方案是在backstage/css/custom.css里加了一行media (max-width: 767px) { .content { overflow-x: auto; } }这样小屏滑动时只滚动内容区导航栏和菜单保持不动。这个细节文档里不会写但你部署到iPad上测试时一定会遇到。2.4 MySQL配置的生产级考量jdbc.local.properties里这行配置值得细看jdbc.urljdbc:mysql://localhost:3306/crm_db?useUnicodetruecharacterEncodingUTF-8serverTimezoneAsia/ShanghaizeroDateTimeBehaviorconvertToNullallowMultiQueriestrueserverTimezoneAsia/Shanghai避免JavaDate和MySQLDATETIME时区错位曾有个客户反馈“创建时间比实际晚8小时”就是漏了这参数zeroDateTimeBehaviorconvertToNullMySQL默认把0000-00-00当非法日期但老系统可能存了这种值设为convertToNull能让MyBatis映射成Java的null而不是抛异常allowMultiQueriestrue为后续可能的批量操作如一次删多个客户留接口虽然当前没用但配置开着比临时加安全。数据库建表脚本在src/main/resources/sql/目录下customer.sql里phone字段用的是VARCHAR(20)而非CHAR(11)因为要兼容国际号码86 138****1234和分机号1234-5678VARCHAR按需分配空间比CHAR省存储。3. 核心模块实现与关键配置解析3.1 登录认证流程从login.jsp到Session管控登录不是简单比对密码。LoginController.java里这段代码是关键RequestMapping(value /login, method RequestMethod.POST) public String login(RequestParam String username, RequestParam String password, HttpServletRequest request, Model model) { User user userService.findByUsername(username); if (user ! null BCrypt.checkpw(password, user.getPassword())) { // 密码正确存入HttpSession request.getSession().setAttribute(currentUser, user); // 记录登录IP和时间 user.setLastLoginIp(getClientIP(request)); user.setLastLoginTime(new Date()); userService.update(user); return redirect:/index.jsp; // 重定向防F5刷新重复提交 } else { model.addAttribute(error, 用户名或密码错误); return login; // 返回login.jsp保留model中的error } }注意三点第一密码用BCrypt加密pom.xml里依赖了org.springframework.security.crypto:bcrpyt不是MD5第二request.getSession().setAttribute(currentUser, user)把用户对象存进Session后续所有Controller都能通过request.getSession().getAttribute(currentUser)获取比Spring Security的SecurityContextHolder更直白第三重定向redirect:/index.jsp而不是直接return index避免用户刷新页面时重复提交登录请求。index.jsp顶部有段JSTL判断c:if test${empty sessionScope.currentUser} c:redirect url/login.jsp/ /c:if这就是最朴素的登录态校验——Session里没用户直接跳回登录页。没有JWT Token没有Redis共享Session因为客户服务器只有单台Tomcat够用且易排查。3.2 主控台index.jsp的动态菜单加载菜单不是写死的HTML而是从数据库读的。MenuService.java里有个方法public ListMenu findMenusByUserId(int userId) { return menuMapper.selectByUserId(userId); // 查询user_menu_rel关联表 }MenuMapper.xml对应的SQLselect idselectByUserId parameterTypeint resultTypeMenu SELECT m.id, m.name, m.url, m.icon, m.parent_id FROM menu m INNER JOIN user_menu_rel umr ON m.id umr.menu_id WHERE umr.user_id #{userId} AND m.status 1 ORDER BY m.sort_order /selectindex.jsp里用JSTL循环渲染c:forEach items${menus} varmenu c:if test${menu.parentId 0} li classdropdown a href# classdropdown-toggle>c:if test${fn:contains(currentUser.permissions, customer:add)} button classbtn btn-primary onclickopenAddDialog()新增客户/button /c:ifcurrentUser.permissions是User实体里的字符串字段存的是逗号分隔的权限码customer:list,customer:add,customer:edit用JSTL的fn:contains判断比Shiro的hasPermission标签更轻量。3.3 文件上传upload模块的实战细节上传不是调用MultipartFile.transferTo()就完事。UploadController.java里处理Excel客户导入RequestMapping(value /importCustomers, method RequestMethod.POST) ResponseBody public MapString, Object importCustomers(RequestParam(file) MultipartFile file, HttpServletRequest request) { MapString, Object result new HashMap(); try { // 1. 检查文件类型和大小 if (!application/vnd.openxmlformats-officedocument.spreadsheetml.sheet .equals(file.getContentType()) !application/vnd.ms-excel.equals(file.getContentType())) { throw new RuntimeException(仅支持.xlsx或.xls格式); } if (file.getSize() 10 * 1024 * 1024) { // 10MB限制 throw new RuntimeException(文件大小不能超过10MB); } // 2. 用Apache POI解析Excel Workbook workbook WorkbookFactory.create(file.getInputStream()); Sheet sheet workbook.getSheetAt(0); ListCustomer customers new ArrayList(); for (int i 1; i sheet.getLastRowNum(); i) { // 跳过标题行 Row row sheet.getRow(i); if (row null) continue; Customer c new Customer(); c.setName(getCellValue(row.getCell(0))); c.setPhone(getCellValue(row.getCell(1))); c.setEmail(getCellValue(row.getCell(2))); c.setCompany(getCellValue(row.getCell(3))); customers.add(c); } // 3. 批量插入用MyBatis的foreach customerService.batchInsert(customers); result.put(success, true); result.put(msg, 成功导入 customers.size() 条客户数据); } catch (Exception e) { result.put(success, false); result.put(msg, 导入失败 e.getMessage()); logger.error(Excel导入异常, e); } return result; }关键点第一file.getContentType()严格校验MIME类型防止用户把木马文件改成.xlsx后缀上传第二WorkbookFactory.create()自动识别xls/xlsx不用分开写两种解析逻辑第三batchInsert对应CustomerMapper.xml里的insert标签用foreach批量执行insert idbatchInsert parameterTypejava.util.List INSERT INTO customer (name, phone, email, company, create_time) VALUES foreach collectionlist itemcustomer separator, (#{customer.name}, #{customer.phone}, #{customer.email}, #{customer.company}, NOW()) /foreach /insert实测导入1000条客户耗时从单条插入的3.2秒降到0.4秒。upload目录在WebContent/下Tomcat部署后路径是http://localhost:8080/CRMManage/upload/所有上传文件都存在这里方便运维定时清理。3.4 EasyUI集成与表格分页实战customer_list.jsp里用EasyUI的datagrid展示客户列表table iddg classeasyui-datagrid >RequestMapping(/list) ResponseBody public MapString, Object list(RequestParam int page, RequestParam int rows) { PageHelper.startPage(page, rows); // PageHelper插件分页 ListCustomer customers customerService.findAll(); PageInfoCustomer pageInfo new PageInfo(customers); MapString, Object result new HashMap(); result.put(total, pageInfo.getTotal()); // 总记录数 result.put(rows, customers); // 当前页数据 return result; }这里用的是com.github.pagehelper:pagehelper插件pom.xml已引入它会在MyBatis执行SQL前自动在SQL末尾加上LIMIT ?, ?并查询一次SELECT COUNT(*)。PageInfo对象封装了总页数、当前页、是否首页/末页等信息前端EasyUI的pagination:true会自动读取total字段计算分页栏。注意RequestParam int page是从1开始的EasyUI传的是1而PageHelper的startPage(page, rows)也要求page从1开始这点和MyBatis原生的RowBounds从0开始不同别搞混。4. 开发环境搭建与部署全流程4.1 Eclipse/MyEclipse导入四步法很多新手卡在第一步。不是直接File → Import → Existing Maven Projects就完事必须按顺序第一步确认JDK和Tomcat版本项目pom.xml里properties定义了java.version1.8/java.version tomcat.version8.5.90/tomcat.version所以你的Eclipse必须装JDK 1.8不是11或17Tomcat选8.5.x系列官网下载apache-tomcat-8.5.90.zip。在Eclipse里Window → Preferences → Server → Runtime Environments添加这个TomcatJRE选JDK 1.8。第二步导入项目时不勾选“Copy projects into workspace”资源包里有CRMManage文件夹这是项目的根目录。导入时选中它但绝对不要勾选复制到工作空间否则src/main/webapp/WEB-INF/web.xml路径会变导致Spring容器启动失败。导入后项目名就是CRMManage。第三步Maven依赖更新与编译右键项目 →Maven → Update Project勾选Force Update of Snapshots/Releases。等待Maven下载完所有jar约127个依赖然后看Problems视图如果报The superclass javax.servlet.http.HttpServlet was not found on the Java Build Path说明没配Tomcat Runtime。右键项目 →Properties → Targeted Runtimes勾选你配好的Tomcat 8.5。第四步发布前修改数据库配置打开src/main/resources/jdbc.local.properties改三行jdbc.urljdbc:mysql://127.0.0.1:3306/crm_db?... jdbc.usernameroot jdbc.passwordyour_mysql_password然后在MySQL里建库建表用src/main/resources/sql/crm_db.sql脚本包含建库语句在命令行执行mysql -u root -p crm_db.sql注意脚本里CREATE DATABASE IF NOT EXISTS crm_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;指定了utf8mb4因为客户名称可能含emoji如“张三公司”utf8不支持。4.2 Tomcat部署与常见启动报错解析部署不是右键Run As → Run on Server就完事。必须检查Servers视图里的Tomcat配置双击Tomcat服务器 →Modules选项卡 → 确认CRMManage的Path是/CRMManage不是/否则访问http://localhost:8080/会404Publishing选项卡 → 勾选Automatically publish when resources change但首次启动前先点Publish按钮手动发布一次Open launch configuration→Arguments选项卡 →VM arguments里加一行-Dfile.encodingUTF-8防止Linux服务器上中文日志乱码。典型报错及解决报错信息原因解决方案java.lang.ClassNotFoundException: org.springframework.web.servlet.DispatcherServletspring-webmvc.jar没进WEB-INF/lib检查pom.xml里scope是不是provided应为compile右键项目 →Properties → Deployment Assembly确认Maven Dependencies已映射到/WEB-INF/libCaused by: java.lang.NoClassDefFoundError: org/apache/commons/logging/LogFactorycommons-logging.jar缺失pom.xml里加依赖dependencygroupIdcommons-logging/groupIdartifactIdcommons-logging/artifactIdversion1.2/version/dependencyorg.springframework.beans.factory.BeanCreationException: Error creating bean with name sqlSessionFactoryjdbc.local.properties路径不对或MySQL服务没启在src/main/resources/下确认文件存在命令行执行mysql -u root -p -e SHOW DATABASES;验证MySQL连通性启动成功后访问http://localhost:8080/CRMManage/login.jsp输入默认账号admin/123456src/main/resources/sql/init_data.sql里初始化的就能进系统。4.3 日志配置与问题定位技巧log4j.properties不是摆设是调试利器log4j.rootLoggerINFO, stdout, file log4j.appender.stdoutorg.apache.log4j.ConsoleAppender log4j.appender.stdout.layoutorg.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern%d{yyyy-MM-dd HH:mm:ss} [%p] %c{1} - %m%n log4j.appender.fileorg.apache.log4j.RollingFileAppender log4j.appender.file.File${catalina.home}/logs/app.log log4j.appender.file.MaxFileSize10MB log4j.appender.file.MaxBackupIndex5 log4j.appender.file.layoutorg.apache.log4j.PatternLayout log4j.appender.file.layout.ConversionPattern%d{yyyy-MM-dd HH:mm:ss} [%p] %c{1} - %m%n # MyBatis SQL日志单独输出 log4j.logger.org.mybatisDEBUG log4j.logger.java.sqlDEBUG log4j.logger.java.sql.StatementDEBUG log4j.logger.java.sql.ResultSetDEBUG log4j.logger.java.sql.PreparedStatementDEBUG关键点java.sql.PreparedStatementDEBUG会让MyBatis打印出最终执行的SQL和参数比如2023-10-15 14:22:33 [DEBUG] JdbcTransaction - Opening JDBC Connection 2023-10-15 14:22:33 [DEBUG] PreparedStatement - Preparing: SELECT * FROM customer WHERE name LIKE ? 2023-10-15 14:22:33 [DEBUG] PreparedStatement - Parameters: %张%(String)这比看MyBatis的BoundSql对象直观多了。如果发现SQL慢立刻去MySQL执行EXPLAIN SELECT * FROM customer WHERE name LIKE %张%看是否走了索引name字段建了普通索引。4.4 生产环境部署 checklist上线前必须核对的10件事数据库连接池pom.xml里用的是com.alibaba:druid:1.2.16src/main/resources/jdbc.local.properties里要加Druid特有配置jdbc.initialSize5 jdbc.minIdle5 jdbc.maxActive20 jdbc.maxWait60000 jdbc.timeBetweenEvictionRunsMillis60000 jdbc.minEvictableIdleTimeMillis300000 jdbc.validationQuerySELECT 1 jdbc.testWhileIdletrue jdbc.testOnBorrowfalse jdbc.testOnReturnfalse静态资源缓存web.xml里加过滤器让CSS/JS缓存1年xml filter filter-nameExpiresFilter/filter-name filter-classorg.apache.catalina.filters.ExpiresFilter/filter-class init-param param-nameExpiresByType text/css/param-name param-valueaccess plus 1 year/param-value /init-param init-param param-nameExpiresByType application/javascript/param-name param-valueaccess plus 1 year/param-value /init-param /filter关闭开发模式日志log4j.properties里把rootLogger级别从INFO改为WARN避免DEBUG日志刷爆磁盘。禁用Tomcat示例删除$TOMCAT_HOME/webapps/下的docs、examples、host-manager只留CRMManage和manager如果需要远程管理。设置JVM参数$TOMCAT_HOME/bin/catalina.sh里加bash JAVA_OPTS-Xms512m -Xmx1024m -XX:MetaspaceSize128m -XX:MaxMetaspaceSize256m -Dfile.encodingUTF-8HTTPS重定向在web.xml里加安全约束xml security-constraint web-resource-collection web-resource-nameProtected Context/web-resource-name url-pattern/*/url-pattern /web-resource-collection user-data-constraint transport-guaranteeCONFIDENTIAL/transport-guarantee /user-data-constraint /security-constraint备份策略每天凌晨2点自动备份数据库bash 0 2 * * * /usr/bin/mysqldump -u root -ppassword crm_db /backup/crm_db_$(date \%Y\%m\%d).sql监控端点pom.xml里加org.springframework.boot:spring-boot-starter-actuator虽不用Boot但Actuator的健康检查端点可独立用暴露/actuator/health。文件上传目录权限WebContent/upload/目录在Linux上要给Tomcat用户读写权限bash chown -R tomcat:tomcat /path/to/tomcat/webapps/CRMManage/upload/ chmod -R 755 /path/to/tomcat/webapps/CRMManage/upload/防火墙放行iptables -A INPUT -p tcp --dport 8080 -j ACCEPT如果用8080端口。5. 实战踩坑与避坑指南5.1 中文乱码的三种场景与解法乱码不是一句“加UTF-8”能解决的必须分层排查场景一JSP页面中文显示为问号检查login.jsp第一行是否有% page languagejava contentTypetext/html; charsetUTF-8 pageEncodingUTF-8%contentType和pageEncoding必须都是UTF-8。如果用了meta charsetgb2312立刻删掉。场景二MySQL存中文变???不只是jdbc.url加characterEncodingUTF-8还要检查MySQL服务端配置。在/etc/my.cnf里加[client] default-character-set utf8mb4 [mysqld] character-set-server utf8mb4 collation-server utf8mb4_unicode_ci [mysql] default-character-set utf8mb4然后重启MySQLsystemctl restart mysqld再进MySQL执行SHOW VARIABLES LIKE character_set%; SHOW VARIABLES LIKE collation%;确保所有值都是utf8mb4。场景三POST提交中文参数变乱码web.xml里必须加编码过滤器filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mappingforceEncodingtrue强制所有请求都用UTF-8解码不管浏览器header里声明什么。5.2 EasyUI表格导出Excel的隐藏坑customer_list.jsp里导出按钮调用function exportExcel() { $(#dg).datagrid(exportExcel, { type: excel, fileName: 客户列表.xlsx }); }这看似简单但背后有两个坑第一EasyUI的exportExcel插件依赖SheetJSxlsx.full.min.js项目里WebContent/js/下必须有这个文件且版本要匹配用的是0.18.5第二导出时如果客户名称含换行符\nExcel里会显示为#VALUE!。解决方案是在CustomerController.java的list方法里对字符串字段做预处理for (Customer c : customers) { c.setName(c.getName().replace(\n, ).replace(\r, )); c.setCompany(c.getCompany().replace(\n, ).replace(\r, )); }或者在MyBatis的resultMap里用result columnname propertyname javaTypestring /让MyBatis自动trim。5.3 MyBatis二级缓存的误用与正解有人为了“提升性能”在CustomerMapper.xml里加cache evictionLRU flushInterval60000 size512 readOnlytrue/结果发现客户信息改了页面还是旧的。原因readOnlytrue时MyBatis返回的是缓存对象的引用如果业务代码里修改了这个对象如customer.setName(新名字)缓存里的对象也被改了正确做法是cache evictionLRU flushInterval60000 size512 readOnlyfalse/readOnlyfalse时MyBatis每次从缓存取对象都会clone()一份新对象业务代码改的是副本不影响缓存。但代价是内存占用翻倍所以建议只对查询频繁、更新极少的表如dict_type字典表开二级缓存客户表这种高频更新的关掉更稳妥。5.4 Bootstrap模态框Modal与EasyUI冲突customer_list.jsp里“编辑客户”用的是Bootstrap Modal但Modal里的表单用了EasyUI的textbox组件div classmodal-body input idname classeasyui-textbox>$(#editModal).on(shown.bs.modal, function () { $(#name).textbox(); $(#phone).textbox(); });或者更彻底把EasyUI组件换成纯Bootstrap的input classform-control用jQuery Validate做校验减少框架耦合。5.5 生产环境Session超时后的优雅处理默认Tomcat Session超时30分钟用户填完客户信息准备提交时发现Session过期点保存直接跳登录页之前填的数据全丢了。改进方案在web.xml里加监听器listener listener-classcom.crm.listener.SessionTimeoutListener/listener-class /listenerSessionTimeoutListener.java里public class SessionTimeoutListener implements HttpSessionListener { Override public void sessionCreated(HttpSessionEvent se) {} Override public void sessionDestroyed(HttpSessionEvent se) { // Session销毁时清空关联的临时数据如未提交的草稿 String sessionId se.getSession().getId(); DraftService.clearDraftsBySessionId(sessionId); } }同时前端加心跳检测index.jsp里用setInterval每25分钟发个请求保活setInterval(function() { $.get(/keepAlive); }, 25 * 60 * 1000);后端KeepAliveController.javaRequestMapping(/keepAlive) ResponseBody public String keepAlive(HttpServletRequest request) { request.getSession().setMaxInactiveInterval(30 * 60); // 重置超时时间 return OK; }这样用户操作时Session永不过期离开25分钟后才开始倒计时体验更自然。这套CRM系统我把它当作一个“可拆解的零件库”来用。客户要加微信扫码登录把LoginController里的密码校验换成调用微信API就行要接短信平台发验证码在CustomerService里加个sendVerifyCode(String phone)方法调用阿里云SDK。它不追求大而全但每个螺丝钉都拧紧了经得起真实业务的敲打。最后分享个小技巧每次改完代码别急着重启Tomcat先在Servers视图里右键Tomcat →Publish它只会发布变更的class文件比重启快10倍。毕竟我们写代码是为了让业务跑得更快不是为了看Tomcat的启动日志。本文还有配套的精品资源点击获取简介一套可直接运行的客户关系管理后台系统源码后端基于Spring MVC搭建Web层用MyBatis操作MySQL数据库Spring统一管理业务逻辑和事务前端采用Bootstrap 3构建响应式界面包含独立登录页login.jsp、主控台index.jsp、后台功能模块backstage、文件上传处理upload以及EasyUI组件集成配套完整的配置文件如spring-2-servlet.xml、jdbc.local.properties、log4j.properties等项目结构规范适配MyEclipse或Eclipse开发环境已通过本地基础部署验证无删减模块支持一键导入、编译与调试。本文还有配套的精品资源点击获取