简介:B2C电商CRM客户关系管理系统CRMOnline(Release20100806-V16终结版)是一套完整的客户关系管理解决方案,面向Java Web开发人员、电商公司技术团队及需要毕设参考的高校学生,可直接部署或修改后商用。系统采用ExtJS+Servlet+Spring+Ibatis技术栈,B/S架构实现Web层与逻辑层分离,涵盖广告、机会、客户、订单、投诉、会员卡、计划、组织、试用装、呼叫及通话记录、使用记录、问卷调查、系统、缓存等模块,并提供API调用、代码模板、代码检测,以及系统集成和人事对接等扩展接口,业务覆盖较全面。压缩包约6.19MB,共2000个文件,其中包含838个Java源文件、428个JS脚本、240个XML配置、138个JSP页面、123个SQL脚本及116个CSS样式等,代码结构清晰,便于按模块检索学习。已有53人学习,适合需要完整CRM系统源码用于二次开发、业务分析与毕业设计的人群。
1. 电商仓库里翻出的CRMOnline:为什么一套2010年的代码仍值得拆
从RAR里解压出这套CRMOnline时,第一反应是“又是老古董”。但把ExtJS页面、Servlet控制器和Ibatis映射铺开之后,会发现它比很多新项目更完整。这套来自B2C电商公司内部的客户关系管理系统,覆盖广告投放、销售机会、客户档案、订单、投诉、会员卡、试用装、呼叫及通话记录、问卷调查和系统缓存等模块,页面与数据库脚本齐全。对正在做电商后台管理系统的人,它是一份反例与正例混合的参考:你能看到没有微服务、没有前后端分离时,电商CRM如何靠分层和缓存扛住日常运营。对刚入行的开发者,这套代码可以直接导入作为毕业设计或课程设计;对5年以上的工程师,值得关注的是它“Web层和逻辑层分离”的实现方式,以及OSCache、Log4j如何嵌进业务代码。
2. ExtJS + Spring + Ibatis:CRMOnline 的分层与模块清单
2.1 前端的ExtJS组件样式只是一部分
从资源文件的命名能看出来,前端不是随手写的HTML,而是ExtJS 3.x时代的界面体系:ext-all.css、extjs.css、panel.css、tabs.css、tree.css、grid.css、form.css,再加上app.newedit.css这种业务定制样式。app.newedit.css是对ExtJS默认外观的覆盖,专门用于“新建/编辑”类型弹窗,比如用户资料编辑、订单备注、投诉跟进。实际页面里,这些CSS配合ExtJS的Ext.Window和Ext.form.Panel,会让弹窗里的字段排版统一,而不会像普通HTML表单那样在各浏览器里变形。
如果直接用浏览器打开静态页面,会发现样式加载不了。原因是ExtJS的组件样式需要由Ext.BLANK_IMAGE_URL指向一个1像素透明GIF,同时页面必须通过Tomcat这类Servlet容器来访问,否则组件宽度和布局计算会乱。建议把前端代码部署在同一个Web应用的根目录下,通过index.html或main.jsp进入,而不是直接用file://协议打开。
2.2 请求流转:Servlet控制器与Spring业务层
后端不是Spring MVC,而是“Servlet + Spring + Ibatis”的经典组合。一个典型流程是:浏览器点击客户列表页,ExtJS的Ext.data.Store发起AJAX请求,请求打到web.xml注册的Servlet;Servlet负责解析请求参数和响应格式,Spring容器里的Service对象负责事务和业务规则,Ibatis的SqlMapClient负责最终SQL执行。
这里需要重点看web.xml中的ServletMapping。通常每个功能模块有一个对应的Servlet路径,比如/customer.do、/order.do、/membercard.do。我一般会在部署前把web.xml里所有url-pattern扫描一遍,先搞清楚前端请求对应哪一个Servlet,再往下追Service实现。如果只追到DAO层,很容易漏掉Servlet里对返回格式的封装。对应的映射片段一般是这样的:
<servlet> <servlet-name>customerServlet</servlet-name> <servlet-class>com.crmonline.web.CustomerServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>customerServlet</servlet-name> <url-pattern>/customer.do</url-pattern> </servlet-mapping>这段配置把/customer.do请求交给CustomerServlet处理。<servlet-class>必须写成完整类名,部署时如果项目改包名,这里会最先报ClassNotFoundException。<url-pattern>里的.do只是约束,不是Spring MVC的DispatcherServlet,所以Servlet内部自己要处理参数解析和Json输出。
在Spring配置里,常见的做法是把SqlMapClientFactoryBean配置成Ibatis的入口,让Service通过Spring拿到SqlMapClientTemplate。数据库连接池可以使用DBCP或C3P0,这里不强制。重点是事务边界要放在Service方法上,而不是Servlet里。
<bean id="sqlMapClient" class="org.springframework.orm.ibatis.SqlMapClientFactoryBean"> <property name="configLocation" value="/WEB-INF/sqlmap-config.xml"/> <property name="dataSource" ref="dataSource"/> </bean> <bean id="customerService" class="com.crmonline.service.CustomerService"> <property name="sqlMapClient" ref="sqlMapClient"/> </bean>这段配置把sqlmap-config.xml作为Ibatis的总入口。CustomerService直接持有SqlMapClient,在Service方法里通过queryForList("Customer.queryList", param)查数据。好处是Service层不感知具体SQL,坏处是SQL字符串一旦改名或者参数类型变化,Service层只有在运行时才会报错。调试时优先看sqlmap-config.xml里的namespace和statement id是否拼写一致。
2.3 模块地图:从广告到会员卡,数据流如何串起来
把摘要里提到的功能模块映射到实际业务表,可以得到一张模块关系表:
| 模块 | 典型数据实体 | 关联主流程 |
|---|---|---|
| 广告 | Ad, AdPosition | 广告计划带来机会 |
| 机会 | SalesOpportunity | 机会转客户 |
| 客户 | Customer, CustomerContact | 客户下订单 |
| 订单 | Order, OrderItem | 订单触发投诉/会员卡 |
| 投诉 | Complaint | 投诉关联客户与订单 |
| 会员卡 | MemberCard, MemberRule | 会员卡绑定客户 |
| 试用装 | TrialSample | 试用装推动客户回访 |
| 呼叫 | CallRecord | 呼叫记录关联机会与客户 |
| 问卷 | Questionnaire, Answer | 问卷沉淀客户偏好 |
| 系统集成 | AccessToken, ApiLog | 对外API鉴权,供人事系统拉取用户与组织 |
这张表是我逆向梳理代码时最常用的起点。不要在任何一个Service里钻太久,先拿这张表去对照sqlmap目录下的映射文件,确认每个模块对应的SQL statement id,再决定要改哪一层。
以订单模块为例,订单表和订单明细表是主从关系。在CRMOnline里,订单列表左侧是订单主表数据,右侧是选中订单的商品明细。这个交互由两个独立的ExtJS Grid完成,后端接口也拆成/order/list.do和/order/detail.do两个Servlet。如果我需要新增一个“订单备注”字段,就要同时改主表的SQL映射、Servlet的返回VO和前端Grid的ColumnModel,三层缺一不可。
RAR里的代码模板目录和代码检测目录属于工程工具,部署时不需要进Tomcat,但二次开发时按模板扩展会省很多事。尤其是“调用API”目录,里面预留了鉴权参数和签名示例,对接人事系统时可以直接参考,不需要重新设计加解密方案。
2.4 OSCache和Log4j在代码里的实际位置
OSCache在CRMOnline里一般用来缓存字典数据和统计报表。常见配置是oscache.properties中设置cache.memory=true和cache.capacity=5000,然后在Service方法外包装一层GeneralCacheAdministrator的存取判断。注意OSCache的key要能区分不同查询参数,否则不同条件的查询结果会互相污染。建议key使用“模块名+查询条件JSON串”的形式,比如"customer_list_" + condition.toString()。
Log4j的作用更直接:在Servlet入口、Service入口、Ibatis执行后分别打印入参、耗时和SQL结果行数。线上排查问题时,级别不要调到DEBUG,否则Ibatis会打出大量结果集内容。使用INFO级别记录请求路径和响应状态即可。用log4j.properties配置log4j.logger.com.ibatis=WARN,避免SQL日志灌满磁盘。
这个模块梳理算是一层地基。下一章专门说数据库脚本里最值得抄的几张表。
3. 从数据库脚本读业务:客户、订单、会员卡与呼叫记录的表关系
3.1 客户主表和联系人表
CRMOnline的客户模型不是单表结构,而是“客户主表 + 联系人表 + 地址表”。主表保存customer_id、客户等级、来源渠道、注册时间。联系人表保存联系人姓名、手机号、email、是否主联系人。这种设计在B2C场景下尤其重要:一个家庭客户可能有两个联系人,一个是下单人,一个是收货人。如果只把联系人字段放进客户主表,后续做短信营销时很难按联系人角色分组。
这里需要注意的是,原数据库脚本里通常会把地址表单独存放,地址信息包含country_code、province、city、detail_address。如果做跨境电商,country_code是必填项;只做本地电商时,这个字段可能被忽略,但保留它并不会带来负担。
示例建表脚本(从原库脚本简化而来):
CREATE TABLE customer ( customer_id INT PRIMARY KEY AUTO_INCREMENT, customer_no VARCHAR(32) NOT NULL, customer_name VARCHAR(64), customer_level TINYINT DEFAULT 0, source_chan VARCHAR(32), register_date DATETIME, status TINYINT DEFAULT 1 ); CREATE TABLE customer_contact ( contact_id INT PRIMARY KEY AUTO_INCREMENT, customer_id INT NOT NULL, contact_name VARCHAR(64), mobile VARCHAR(20), email VARCHAR(128), is_primary TINYINT DEFAULT 0, KEY idx_customer_id (customer_id) );customer表保存客户生命周期状态,customer_contact表保存变动频繁的联系信息。实际数据库脚本里可能还有create_time、modify_time,但核心逻辑就是这两张表。查询客户时,先按customer_no定位customer,再联查contact表取出主联系人。常见错误是只关联所有联系人,导致一条客户记录被拆成多行返回,ExtJS Grid里出现重复行。解决方法是使用子查询或者MAX(CASE WHEN is_primary=1 ...)把主联系人字段折叠到客户主查询里。
3.2 订单表和会员卡:交易与营销两套持久化
订单表结构比较常规,但有三个字段值得留意:order_status、pay_status、ship_status。很多电商项目把支付和发货状态杂糅在一个字段里,CRMOnline把它们分开,方便做售后退款统计。会员卡表和订单表之间不是直接外键,而是通过customer_id和手机号进行关联。会员卡表里会有卡号、余额、积分、等级、到期时间。B2C电商的会员运营通常把订单的支付金额回写到会员卡积分,这一步由定时任务或下单事务完成。
订单状态字段的取值建议如下:
| 字段 | 取值 | 含义 |
|---|---|---|
| order_status | 0/1/2/3 | 待支付/已支付/已关闭/已取消 |
| pay_status | 0/1/2 | 未支付/已支付/退款 |
| ship_status | 0/1/2 | 未发货/已发货/已签收 |
这里的实践要点是:不要轻易在订单表上增加“会员卡号”字段,而是通过客户ID和卡类型关联。不然同一个客户换卡之后,历史订单查询会产生歧义。CRMOnline里会员卡和订单解耦,卡挂失换卡只影响会员卡表,历史订单不变。在二次开发时,如果要加“订单是否使用积分”,建议在订单扩展表中增加used_points字段,不改主表结构。
3.3 销售机会和呼叫记录:CRM的核心过程数据
销售机会表保存从广告点击到成交之前的所有跟进节点。机会表的关键字段是expected_amount、probability、next_contact_time、follow_status。呼叫记录表保存每次通话的电话号码、通话时长、通话类型(呼入/呼出)、关联机会ID、通话录音文件名。这套结构和互联网公司的呼叫中心系统非常像,但更精简。
使用Ibatis查询呼叫记录和机会关联时,常见的方法是嵌套结果映射:
<resultMap id="callWithOpportunity" class="CallRecord"> <result property="callId" column="call_id"/> <result property="mobile" column="mobile"/> <result property="duration" column="duration"/> <association property="opportunity" column="opp_id" select="Opportunity.queryById"/> </resultMap>这里select="Opportunity.queryById"表示对每条呼叫记录再按机会ID查一次机会表。如果呼叫记录量很大,N+1查询问题会非常严重。线上优化时,建议改成一次LEFT JOIN查询,将机会主题直接映射进CallRecord对象。
3.4 问卷调查表:一份可以复用的动态表单设计
问卷模块采用了“问卷-题目-回答”三张表,而不是为每道题建一个字段。问卷表保存问卷编号和标题,题目表保存题目类型和选项JSON,回答表保存userId和题干快照。这种设计在电商售后回访里特别实用,因为运营人员可以随时改题目,不需要改表结构。
CREATE TABLE survey_answer ( answer_id INT PRIMARY KEY AUTO_INCREMENT, survey_id INT NOT NULL, customer_id INT NOT NULL, question_id INT NOT NULL, answer_value TEXT, answer_time DATETIME );answer_value用TEXT类型保存用户回答;对单选题存选项值,对多选题存逗号分隔的选项ID。配合问卷模块的导出功能,能直接生成统计报表。缺点是统计时需要脚本解析文本,不能直接SQL聚合。如果需要在数据库层做分析,我一般会增加冗余字段question_type,在回答时同步写入,避免每次统计都去关联题目表。
把这些查询案例放进Ibatis配置后会发现,CRMOnline的表设计遵循“主从分开、过程独立、营销冗余”的思路。下一部分进入部署,重点说怎么把脚本和配置落到本地环境里。
4. 解压、配置、跑起:CRMOnline 的本地化启动步骤
4.1 确认环境边界
这套系统发布年代决定了它更适合放在JDK 1.6或1.7容器里运行。如果本机只有JDK 8,大概率会碰到ClassNotFound或反射异常,因为老的CGLIB版本和JDK 8的强反射访问有兼容问题。建议准备一个独立的Tomcat 6或Tomcat 7实例。MySQL版本推荐5.1到5.5之间,5.7以上需要手动处理GROUP BY的ONLY_FULL_GROUP_BY模式。
不用盲目升级依赖。常见做法是在lib目录里先看spring.jar、ibatis.jar等文件,保持原jar不变。只要JDK和Tomcat兼容,系统就能起。升级Ibatis到MyBatis可以后面做,不要在启动阶段混着改。
4.2 初始化数据库
RAR里通常包含一个.sql数据库脚本。导入步骤如下:
- 在MySQL中创建数据库
crmonline,字符集选定utf8。 - 使用命令行执行脚本:
mysql -uroot -p crmonline < crmonline.sql - 导入后检查核心表和菜单表:
SELECT table_name FROM information_schema.tables WHERE table_schema='crmonline';
mysql -uroot -p crmonline < crmonline.sql这条命令用用户名root连接本地MySQL,-p会交互提示输入密码,crmonline是目标库,输入重定向将SQL脚本顺序执行。实际部署时建议创建专用账号,避免把root密码写进jdbc.properties。
脚本文件可能较大,如果导入时报Got a packet bigger than 'max_allowed_packet',需要修改my.ini中的max_allowed_packet。不要只改MySQL客户端,服务端也要改。否则后续写入问卷答案这类长文本会失败。
4.3 修改数据源、缓存和日志配置
Spring数据源通常配置在applicationContext.xml或jdbc.properties中。重点检查四项:
| 配置项 | 建议值 | 说明 |
|---|---|---|
| jdbc.driverClassName | com.mysql.jdbc.Driver | 老驱动支持5.x |
| jdbc.url | jdbc:mysql://localhost:3306/crmonline?useUnicode=true&characterEncoding=utf8 | 避免中文乱码 |
| jdbc.username | crmonline_user | 不要用root |
| jdbc.password | 自定义 | 明文存储,改后不要提交到仓库 |
改完数据库配置后,检查oscache.properties中的cache.memory。如果开发机内存较小,可以设置cache.memory=false、cache.path=/tmp/oscache,把缓存刷到磁盘。cache.capacity是缓存条目数,默认5000,如果查询字典频繁,建议提高到10000。
4.4 启动和验证
把项目打成war包,或者直接把WebContent目录拷贝到Tomcat的webapps下,启动Tomcat。启动后观察logs/catalina.out,出现Server startup in xxx ms即成功。
验证的首个页面不是登录页,而是登录页之前的健康检查接口。在浏览器访问某个不加权限的Servlet,比如/system/cacheManager.do?action=showStatus,看能否返回JSON或JSP内容。这一步能快速区分“没启动成功”和“登录后才出错”两种情况。
如果访问请求出现404,先检查Web应用根路径。Tomcat中项目目录如果叫crmonline,访问地址就是http://localhost:8080/crmonline/。ExtJS的Store请求路径如果是绝对路径/customer.do,则这里的斜杠会指向Tomcat根,需要改为crmonline/customer.do或使用相对路径。
4.5 高频报错对照表
| 报错 | 原因 | 处理 |
|---|---|---|
java.sql.SQLException: Unknown character set | jdbc.url缺characterEncoding | 按上面url配置补全 |
Invalid bound statement (not found) | Ibatis namespace或id拼写错 | 检查sqlmap配置文件 |
Ext.widget is not a function | ExtJS版本被替换 | 恢复原ext-all.js版本 |
ORA-00942 | 用Oracle脚本错导到MySQL | 重新匹配对应数据库脚本 |
OutOfMemoryError: PermGen space | JDK1.6的PermGen限制 | 在catalina.sh里增加-XX:MaxPermSize=256m |
最后一条在Mac和Linux上很常见。老版本Tomcat和JDK搭配运行时,PermGen不足会导致系统不定期宕机。调整JVM参数后重启即可。
提示:换JDK版本后,如果Tomcat一直报
UnsupportedClassVersionError,先执行java -version确认JAVA_HOME指向的是1.6,而不是在IDE里编译成了1.8的class。
5. 用OSCache日志和Log4j验证CRMOnline查询缓存的命中质量
5.1 开启逐条SQL日志
OSCache能掩盖不少数据库压力,但也会掩盖代码里的缓存key设计问题。验证的方法不是看监控面板,而是打开Log4j的Ibatis日志。
log4j.logger.com.ibatis=DEBUG log4j.logger.org.apache.ibatis=DEBUG如果项目用的Ibatis 2.x,第一条生效;如果已经部分升级到MyBatis,第二条生效。重启后在Tomcat控制台观察,每次后台查询都会打印出PreparedStatement的SQL和执行参数。这时重复点击客户列表的下一页,如果第二次点击没有打印新的SQL日志,说明上次结果被OSCache命中;如果每次都打印,就要检查查询条件里是不是带了时间戳或随机数。
注意操作窗口不要开太久,DEBUG日志会很快撑到几百MB。测完立刻改回WARN。
5.2 临时打印缓存命中点
日志只能看到SQL,看不到缓存层。为了定位是OSCache没进还是进了没命中,可以在Service方法临时加一段判断代码:
String cacheKey = "customer_list_" + customerName; try { Object cached = admin.getFromCache(cacheKey); return (List) cached; } catch (NeedsRefreshException e) { admin.cancelUpdate(cacheKey); List result = queryFromDatabase(customerName); admin.putInCache(cacheKey, result); return result; }这段代码的关键是NeedsRefreshException。第一次缓存未命中时,OSCache会抛出这个异常,并不是程序错误。很多初学者把它当成普通Exception catch掉后不更新缓存,导致每次查询都穿透到数据库。真正要做的是在异常分支里主动调用cancelUpdate释放条目锁,然后回填数据库查询结果。如果回填过程耗时较长,锁期间其他请求会等待,这在高并发下会拖慢接口,需要同步关注。
5.3 低命中率修复方向
如果发现某个列表页命中率低于预期,先看key的构造。常见问题出现在把new Date()、随机ID或者HTTP Session值拼进key里。纠正后,可以给不同模块设置不同过期时间:字典数据refreshPeriod设置300秒,订单列表设置30秒,客户详情设置60秒。改完再开SQL日志压一次,对比相同参数请求的日志条数。
源码里的调用API目录是给二次开发用的,里面预留了接口鉴权参数,对接外部系统时可以直接复用;代码模板和代码检测目录属于工程工具,部署时不用管,但扩展新模块前先看一眼模板,能帮你省掉重复写ExtJS窗口和Ibatis映射的时间。调整完缓存策略后再次压一下重复查询接口,对比SQL日志条数,以此确认OSCache在控制业务查询开销时没有把数据锁死。
本文还有配套的精品资源,点击获取