从电商CRM到Spring Boot:前后端分离分层架构实战
简介一套来自电商公司实际项目的CRM系统完整源代码基于ExtJSServletSpringIbatis技术栈实现Web层与逻辑层分离适合Java开发者学习、毕业设计参考或中小团队二次开发商用。压缩包共包含2000个文件以786个Java源码、394个JS前端脚本、232个XML配置、118个CSS样式、104个JSP页面及95个SQL数据库脚本为主另含少量HTML、JSON、Properties等辅助文件整体仅7.47MB目录结构清晰便于快速定位代码与脚本。系统覆盖广告、机会、客户、订单、投诉、会员卡、计划、组织、试用装、呼叫与通话记录、使用记录、问卷调查等完整业务模块同时提供调用API、代码模板、代码检测工具并预留系统集成与人事对接扩展接口附带可直接导入的数据库脚本能快速搭建本地环境进行功能验证。已有54人学习下载较适合需要完整CRM参考实现或课设、毕设场景的开发者。1. 从电商CRM到分层架构一套能上线的前后端分离样板这套CRM是早年在电商公司从需求到上线完整落地的客户关系管理系统离职时把代码和数据库脚本一起整理了出来。技术栈是 ExtJS Servlet Spring Ibatis配合 OSCache 做列表缓存、log4j 做操作日志B/S 架构下 Web 展示层和业务逻辑层彻底分开严格说就是国内早期「前后端分离」的一种务实落地。广告、机会、客户、订单、投诉、会员卡、呼叫记录、试用装、问卷调查等模块全部覆盖代码里还带了调用 API、代码模板和代码检测工具。适合正在做客户管理类系统、或想拆解老牌 Java Web 分层设计的人。2. Web层与逻辑层分离ExtJS前端与Servlet/Spring的边界设计2.1 为什么说它是“前后端分离”的早期形态现在流行的 Vue/React 前后端分离本质是前端负责渲染和交互后端只提供数据接口。这套 CRM 在当年用 ExtJS 实现了同样的效果页面模板里没有 JSP 脚本所有表格、表单、弹窗都由 ExtJS 组件在浏览器端动态生成数据通过 Ajax 从后端接口获取。后端 Servlet 不再向浏览器输出 HTML只返回 JSON 给前端做数据源这就是典型的逻辑层与 Web 展示层分离。这样做最大的收益有两个。第一前端交互逻辑可以独立调试打开浏览器直接看 Network 就能定位是接口问题还是渲染问题不用像 JSP 那样在服务端混着写。第二业务逻辑全部收敛到 Spring 管理的 Service 层Servlet 只做参数接收和结果序列化换掉前端或者加一套移动端接口后端完全不用动。我后来接手其他系统时也是按这个边界来拆老代码的。2.2 从web.xml看请求分发与Spring容器Web 层的入口在web.xml里做了明确分工Servlet 负责接收来自 ExtJS 的 Ajax 请求Spring 的ContextLoaderListener负责初始化业务逻辑层容器。以下是精简后的配置context-param param-namecontextConfigLocation/param-name param-valueclasspath:applicationContext.xml/param-value /context-param listener listener-classorg.springframework.web.context.ContextLoaderListener/listener-class /listener servlet servlet-namecrmDispatcher/servlet-name servlet-classcom.crm.web.servlet.DispatcherServlet/servlet-class init-param param-nameactionPackage/param-name param-valuecom.crm.web.action/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namecrmDispatcher/servlet-name url-pattern/servlet/*/url-pattern /servlet-mappingcontextConfigLocation指定的是 Spring 根容器配置文件里面装配了 Service、DAO、数据源等逻辑层组件。DispatcherServlet只负责把/servlet/*的请求转发到具体的 Action 类Action 类再通过 Spring 拿到 Service。参数actionPackage告诉分发器去哪个包扫描请求处理器这样新加一个模块只需要在包下新建 Action 类不需要反复改web.xml。当时的 Servlet 分发策略不算复杂根据 URL 最后一段路径找到类名和方法名比如/servlet/customer/list对应CustomerAction.list()。这个约定比 Spring MVC 的注解直观也方便前后端分离时让前端快速对齐接口地址。2.3 一个典型的Handler接收请求、调用服务、返回JSON下面是一个客户列表查询的 Action 片段它演示了 Web 层如何做到「不碰逻辑」只做协议转换package com.crm.web.action; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import com.alibaba.fastjson.JSONObject; import com.crm.core.bean.Customer; import com.crm.core.service.CustomerService; public class CustomerAction extends BaseAction { private CustomerService customerService; Override public void setSession(HttpServletRequest request) { // 从Spring容器中获取业务层实例 this.customerService (CustomerService) request.getSession() .getServletContext() .getAttribute(springContext) .getBean(customerService); } public void list(HttpServletRequest request, HttpServletResponse response) throws Exception { String page request.getParameter(page); String rows request.getParameter(limit); Customer query new Customer(); query.setName(request.getParameter(name)); // 业务层负责计算分页结果 PageResultCustomer result customerService.queryByPage(query, page, rows); // 序列化为前端ExtJS需要的格式 JSONObject json new JSONObject(); json.put(total, result.getTotal()); json.put(rows, result.getList()); this.renderJson(response, json); } }这里把customerService从 Spring 容器中取出是通过一个全局的springContext属性完成的。setSession是 BaseAction 里定义的钩子每次请求进来先注入 Service再执行业务方法。值得注意的参数是page和limit前者是页码后者是每页条数前端 ExtJS 的 Store 默认会携带这两个参数后端分页接口必须严格对齐命名否则表格会一直拿不到数据。renderJson是 BaseAction 的公共方法统一设置Content-Type为application/json; charsetutf-8并处理 JSONP 回调。实际项目里还过滤了空值避免null直接暴露给前端导致 ExtJS 组件报错。2.4 前端ExtJS端如何对接接口ExtJS 的数据接入不必写构造函数直接通过Ext.data.Store的proxy声明接口地址和参数格式Ext.define(Crm.store.CustomerStore, { extend: Ext.data.Store, model: Crm.model.Customer, autoLoad: false, proxy: { type: ajax, url: /crm/servlet/customer/list, method: POST, extraParams: { keyword: , status: }, reader: { type: json, rootProperty: rows, totalProperty: total } } });这段配置里的rootProperty和totalProperty与后端返回的 JSON 字段一一对应。前端只知道自己需要什么字段不知道 SQL 怎么写、缓存怎么失效。反过来后端把接口返回格式固定后前端可以并行开发。这套约定在后期迁移到 Spring Boot 时几乎不用改只需把 URL 前缀换掉。3. 持久层与缓存Ibatis映射、OSCache失效策略和日志埋点3.1 为什么选Ibatis而不是Hibernate逻辑层选用 Ibatis也就是 MyBatis 的前身核心原因是电商 CRM 里有大量复杂查询。多表关联、动态条件筛选、统计报表如果用 Hibernate 的 HQL 或者 Criteria 拼查件团队磨合成本高SQL 优化也不直观。Ibatis 把 SQL 写在 XML 里由开发者全控参数映射和结果映射用简单的标签声明业务复杂了也能在数据库端直接压住性能。另外当时这套系统要同时支撑 Web 端和呼叫坐席端同一份查询逻辑需要不同的返回字段。用 Ibatis 的resultMap可以轻松定义多种映射一个查询方法在 XML 里配多个resultMap就能适配不同场景。这一点在拆分层时特别有用因为逻辑层不必知道前端到底要渲染哪些列。3.2 一个客户查询的SqlMap配置与参数绑定以下是Customer.xml中的核心查询片段里面展示了动态条件、分页和结果映射sqlMap namespaceCustomer typeAlias aliasCustomer typecom.crm.core.bean.Customer/ resultMap idCustomerResult classCustomer result propertyid columncust_id/ result propertyname columncust_name/ result propertylevel columncust_level/ result propertyphone columncust_phone/ result propertyagentId columnagent_id/ /resultMap select idqueryByPage parameterClassjava.util.Map resultMapCustomerResult SELECT cust_id, cust_name, cust_level, cust_phone, agent_id FROM crm_customer dynamic prependWHERE isNotEmpty prependAND propertyname cust_name LIKE CONCAT(%, #name#, %) /isNotEmpty isNotEmpty prependAND propertylevel cust_level #level# /isNotEmpty /dynamic ORDER BY cust_id DESC LIMIT #offset#, #pageSize# /select /sqlMapparameterClass接收的是一个包含name、level、offset、pageSize的 Map。offset是起始行数pageSize是每页大小这一对参数由逻辑层从page和limit换算得来。dynamic标签会在对应属性非空时拼入条件避免写多个if/elseSQL。注意LIKE用CONCAT拼%能防止用户输入的特殊符号破坏 SQL 结构也是当时防注入的常用手段。逻辑层调用时维护一个 DAO 接口就够了。Service 里只传入业务参数不知道 Ibatis 的存在这就保证了逻辑层和持久层的边界。3.3 OSCache在CRM列表页上的缓存应用客户列表和产品字典这类读多写少的数据每次全查数据库会影响页面响应。系统引入 OSCache 后把查询结果按「查询条件 页码」作为缓存键设置 5 分钟过期。配置写在oscache.properties里cache.memorytrue cache.capacity1000 cache.algorithmcom.opensymphony.oscache.base.algorithm.LRU cache.persistence.classcom.opensymphony.oscache.plugins.disk.DiskPersistenceListener cache.path/tmp/oscache这段配置的含义是允许内存缓存最多缓存 1000 组数据淘汰规则用 LRU当内存不足时把过期缓存序列化到磁盘。cache.persistence.class指定了磁盘持久化监听器配合cache.path控制落盘位置。在电商 CRM 场景里客户字典和地区列表不会频繁变化用磁盘持久化可以避免应用重启后缓存全丢。调用层通常用 OSCache 的GeneralCacheAdministrator封装一个工具类读取时先查缓存没有则回源查询并放入缓存。一个容易踩的坑是缓存 key 不能只放参数拼接还要包含查询的命名空间。否则不同表、相同参数的查询会互相串数据。我曾经就在这里出过一个问题客户分页和订单分页都用了类似list_1_20的 key结果订单列表里混进了客户 ID。后来 key 统一改成Customer.queryByPage.1.20这种带类名和方法名的格式再没出过乱。3.4 log4j按模块输出访问痕迹整个系统用 log4j 1.x 做日志没有引入其他框架。log4j 配置里最关键的是按模块拆分文件方便按业务域排查问题log4j.rootLoggerINFO, stdout log4j.logger.com.crm.core.serviceINFO, serviceLog log4j.appender.serviceLogorg.apache.log4j.DailyRollingFileAppender log4j.appender.serviceLog.File${webapp.root}/WEB-INF/logs/service.log log4j.appender.serviceLog.DatePattern_yyyyMMdd log4j.appender.serviceLog.layoutorg.apache.log4j.PatternLayout log4j.appender.serviceLog.layout.ConversionPattern%d{yyyy-MM-dd HH:mm:ss} [%t] %-5p %c - %m%n log4j.logger.com.crm.web.actionINFO, actionLog log4j.appender.actionLogorg.apache.log4j.RollingFileAppender log4j.appender.actionLog.File${webapp.root}/WEB-INF/logs/action.log log4j.appender.actionLog.MaxFileSize20MB log4j.appender.actionLog.MaxBackupIndex10logger后面的包名决定了该包里产生的日志落入哪个文件。Service 包记录业务逻辑的关键步骤Action 包记录 HTTP 请求的访问痕迹。DailyRollingFileAppender每天生成一个文件适合跨天跟踪客户的呼叫和订单变化RollingFileAppender限制单文件大小和备份数量防止访问日志占满磁盘。业务代码里的日志点也有讲究。比如创建机会时除了记录成功后的 ID还要把操作人、来源广告 ID 一并写进日志。这样 CRM 系统每天的统计能直接从日志里捞出「哪个广告带来了多少有效机会」不需要额外开发埋点平台。4. 从机会到呼叫记录核心模块的表结构与状态机流转4.1 机会表怎么设计CRM 的核心是「机会」也就是从一个广告、一次呼叫里挖掘出的潜在购买意向。机会表必须能追溯来源同时记录当前所处阶段。以下是经过删减的建表脚本CREATE TABLE crm_opportunity ( opp_id INT AUTO_INCREMENT PRIMARY KEY, cust_id INT NOT NULL, ad_id INT, source_type TINYINT COMMENT 1-广告 2-呼叫 3-活动, stage TINYINT COMMENT 1-新建 2-跟进中 3-已成交 4-已流失, amount DECIMAL(10,2), product_intent VARCHAR(200), owner_id INT, next_follow_time DATETIME, created_by INT, created_time DATETIME, updated_time DATETIME, KEY idx_cust (cust_id), KEY idx_owner_stage (owner_id, stage) ) ENGINEInnoDB DEFAULT CHARSETutf8;这张表把客户、广告来源、负责人都关联了起来。source_type用来区分机会来自哪个渠道stage是状态机核心字段。索引idx_owner_stage特别重要销售首页和机会看板都是按负责人的组内成员加状态过滤的如果没有这个复合索引数据量到十万级以后查询会明显变慢。next_follow_time是给跟进计划用的每天定时任务扫描这个字段把超时未跟进的机会推给销售。4.2 订单与投诉的状态流转订单和投诉模块之间有强联动客户投诉可能导致订单状态变更订单状态又会影响会员积分。状态字段用TINYINT存储业务层通过一个枚举类来定义状态间的合法跳转。这样避免出现「投诉未处理就关单」这种脏数据。模块状态值状态语义允许流转到订单1待付款2, 5订单2已付款待发货3, 5订单3已发货4, 5订单4已完成5订单5已取消-投诉1待受理2, 5投诉2处理中3, 5投诉3已解决4投诉4已回访-投诉5无效-这张表同时说明了一个常见设计订单状态和投诉状态放在各自模块内但 Service 层在修改订单状态时必须同步检查是否有未关闭的投诉。比如订单要流转到「已完成」但该订单存在状态为「处理中」的投诉当前操作就应该被拦截。这套 CRM 在逻辑层单独抽了一个StateFlowEngine接收当前状态、目标状态和业务类型再读取预定义的跳转矩阵来决定是否放行。4.3 呼叫中心和试用装的关联记录呼叫中心和试用装是电商 CRM 里比较特有的模块。每次呼入电话会先经过crm_call_log记录通话时间、坐席人员、电话号码再根据号码匹配客户。试用装模块则记录用户申请的样品信息并把对应的快递单号与机会表关联。下面这句 SQL 展示了如何把试用装的领取人关联到已有客户UPDATE crm_customer c INNER JOIN crm_trial_apply t ON c.phone t.receiver_phone SET c.trial_applied 1, c.trial_product t.product_sku WHERE t.apply_date 2023-01-01;这里的批量更新是为了初始化历史数据将申请过试用装的手机号标记到客户档案里。日常业务中类似操作要避免在事务里全表扫描建议先筛选出apply_date的范围再执行更新。呼叫记录和试用装的核心价值不在单表而在关联后的客户画像一个客户是否有过投诉、是否领过试用装、是否在 7 天内再次来电这些字段组合起来才能指导运营决策。5. 迁移到Spring Boot接口工程留用前端和数据库的改造技巧5.1 先搭一个Spring Boot骨架兼容旧Servlet路径如果要把这套 CRM 改造为 Spring Boot 工程不需要重写前端。第一步是建一个 Maven 工程把原来的web.xml里的映射改为控制器映射。为了让 ExtJS 里的 URL 不变新工程里直接用RequestMapping处理旧路径RestController RequestMapping(/servlet/customer) public class CustomerController { Resource private CustomerService customerService; PostMapping(/list) public MapString, Object list(String page, String limit, String name, String status) { Customer query new Customer(); query.setName(name); query.setStatus(status); PageResultCustomer result customerService.queryByPage(query, page, limit); MapString, Object json new HashMap(); json.put(total, result.getTotal()); json.put(rows, result.getList()); return json; } }RestController返回的 Map 会被自动序列化为 JSON响应体的字段名和旧renderJson输出保持一致。page和limit直接绑定到方法参数Spring Boot 会完成 HTTP 参数到基础类型的转换。改造的关键是保证原有CustomerService的接口不变只把 Servlet 层替换为 Controller 层这样逻辑层和持久层完全不需要动。5.2 把Ibatis映射平移为MyBatisSpring Boot 时代最常用的是 MyBatis。原 Ibatis 的 SqlMap 文件里sqlMap改名mapperparameterClass改为parameterType命名空间改成 Mapper 接口的全限定名。比如public interface CustomerMapper { ListCustomer queryByPage(MapString, Object params); }对应的 XML 里select idqueryByPage parameterTypemap resultTypeCustomer SELECT cust_id, cust_name, cust_level, cust_phone, agent_id FROM crm_customer where if testname ! null and name ! AND cust_name LIKE CONCAT(%, #{name}, %) /if if testlevel ! null and level ! AND cust_level #{level} /if /where ORDER BY cust_id DESC LIMIT #{offset}, #{pageSize} /select注意 MyBatis 的动态条件用的是if和where与原 Ibatis 的dynamic和isNotEmpty不同。#{}对应原来的#编译时生成占位符。迁移时可以用脚本批量替换但一定要检查每个字段的类型标签比如时间范围查询需要把date字段包装成Date。映射文件迁移后冒烟测试的重点是分页查询旧参数offset和pageSize的命名不要改动否则CustomerService里的分页换算代码也要跟着改。5.3 权限与Session在前后端分离下的处理技巧原来的 CRM 依靠 Servlet 的 HttpSession 保存登录态前端 ExtJS 每次请求自动携带 Cookie所以逻辑层可以直接request.getSession().getAttribute(loginUser)。拆成 Spring Boot 接口工程后如果前端仍部署在同域下Cookie 方案还可以继续使用这是最省事的迁移路径。如果未来要把微信小程序或 App 接入同一套逻辑层需要改成 Token 方式。常见做法是在登录接口生成一个随机 UUID存到 Redis 并设置过期时间后续请求通过Authorization头传递。Spring Boot 里用一个拦截器统一解析public class TokenInterceptor implements HandlerInterceptor { Resource private StringRedisTemplate stringRedisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String token request.getHeader(Authorization); if (token null || Boolean.FALSE.equals(stringRedisTemplate.hasKey(token: token))) { response.setStatus(401); return false; } String userId stringRedisTemplate.opsForValue().get(token: token); request.setAttribute(currentUserId, userId); return true; } }这个拦截器的逻辑是每次请求先检查 Redis 里是否存在该 Token存在就把用户 ID 放到请求属性里供后续 Controller 使用。Token 过期时间可以根据 CRM 的会话要求设置比如 2 小时滑动过期。改造时把原来所有session.getAttribute(loginUser)换成request.getAttribute(currentUserId)一个全局替换就能完成。前端 ExtJS 侧也需要小幅调整在请求头里带上 Token。可以通过Ext.Ajax.extraParams或者自定义一个beforeRequest事件从 localStorage 读取 Token 再写入headers。这样改造完成后老页面、新接口、数据库脚本都能继续复用唯一丢掉的就是那个老旧的 Servlet 分发器。本文还有配套的精品资源点击获取