1. 这个CRM项目是从一个很现实的问题开始的
之前在某家做企业服务的公司带后端团队的时候,接到一个特别典型的诉求:销售团队手里有几千个客户线索,但管理方式全靠Excel和微信聊天记录。销售离职的时候,客户资料跟着聊天记录一起消失,接手的同事只能重新培养信任。管理层想看销售漏斗数据,得等销售手动填周报,数据还经常对不上。
当时市面上现成的CRM系统不是没有,但要么是重型的国际产品,要按坐席买license,定制化成本吓人;要么是轻量级的SaaS工具,数据不在自己手里,也没法和内部的合同系统、工单系统打通。讨论了几轮之后,决定自己搭一套面向中小团队客户管理场景的Spring Boot系统。
这个项目做完之后,技术上的复杂度其实不算高,但踩过的坑让我印象很深——尤其是数据权限、状态流转、还有"智能"部分应该做到什么程度才算有用而不是噱头。这篇文章把我自己的设计思路和落地过程完整拆开讲一遍,包括模块划分、核心表结构、Spring Boot具体实现要点、以及哪些"智能功能"是真正值得做的。希望对准备做类似系统的同学有点参考价值。
2. 先想清楚:这个系统到底要解决谁的问题
我见过不少人做内部管理系统,第一步就扑到数据库建模上,结果做到一半发现业务方想要的根本不是这个。所以这个项目里我做的第一件事不是写代码,而是把核心用户分成了三类,分别梳理他们的痛点。
2.1 三类核心用户和他们的真实诉求
第一类是一线销售。他们最需要的是把客户资料集中管理、随时记录跟进情况、设置提醒不遗漏跟进节点。他们要的是"快",半天时间必须能上手,录入成本必须低。
第二类是销售主管。他们需要看团队的客户储备情况、跟进活跃度、商机推进到哪一步了。他们要的是"清晰",能够快速看出团队的整体状态。
第三类是公司管理层。他们关心的是预测:按照当前的商机储备,这个季度大概能签多少单。他们要的是"预测能力",即便是粗糙的预测也比拍脑袋强。
这三种诉求其实指向了三个能力方向:客户数据管理能力、流程可视化能力、基础的数据分析预测能力。这决定了后续的功能设计边界——不是一个财务级的精密系统,而是一个"让销售动作有迹可循、让管理者有数据可依"的运营型工具。
2.2 技术选型背后的几个硬性约束
业务方给的工期是两个月,运维团队只有一个人,而且不想引入太重的基础设施。这基本注定了选型的基调:
- Spring Boot 2.x:团队Java基础扎实,招聘也容易,生态成熟,社区资料极多,出问题搜得到答案。
- MySQL 8.x:业务量级在十万级客户以内,单库单表完全扛得住,不需要一上来就上分布式中间件。
- Redis:用于缓存热点数据,分布式锁,以及后续做消息通知的队列缓冲。这个组件运维成本低,值得引入。
- MyBatis-Plus:团队熟悉MyBatis,Plus提供了分页插件、逻辑删除、自动填充这些很实用的能力,能省下不少样板代码。
- 不引入工作流引擎:很多CRM喜欢接一套流程引擎(比如Flowable),但真正落地起来复杂度极高。我调研之后决定用状态机加枚举的方式处理商机状态流转,后面会详细说为什么。
这里要特别强调一点:选型不是选最流行的,而是选团队能长期维护的。某公司当时正在推广微服务架构,有同事建议把CRM直接拆成客户服务、跟进服务、商机服务、报表服务四个微服务。我坚决反对,原因很简单:团队加一起就八个人,还有别的项目要维护,微服务带来的部署成本、链路排查成本、分布式事务成本,远超这个体量系统能带来的收益。最终选择了模块化单体(Modular Monolith),后期如果某个模块压力确实大,再单独拆分出来。这是一个很重要的决策,帮助项目在两个月内准时交付。
3. 数据模型设计:一张表怎么拆,直接决定后面好不好改
客户关系管理系统的核心数据模型绕不开这几个实体:线索(Lead)、客户(Customer)、联系人(Contact)、商机(Opportunity)、跟进记录(Activity)。其中最容易设计错的,是把客户和联系人混在一张表里。
3.1 客户与联系人为什么必须分开
很多销售会说"客户就是那个联系人",刚起步的小公司确实可以这么理解。但只要业务稍微壮大一点,就会发现一个客户(公司)可能有多个联系人:一个负责采购的、一个负责技术的、一个负责财务审批的。如果把联系人字段放在客户表里,要么就得加冗余字段(联系人A、联系人B、联系人C),要么就完全没法记录多联系人场景。
我的设计是客户表存企业维度的信息(公司名称、行业、规模、来源渠道、所属销售),联系人表存个人维度的信息(姓名、职位、电话、微信、邮箱),联系人表通过customer_id关联客户表。这样客户详情页可以展示一个联系人列表,商机表再通过关联字段指向某个具体联系人,操作链路就通顺了。
核心表结构大致是这样(只列关键字段,省略create_time等通用列):
CREATE TABLE `customer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL COMMENT '公司名称', `industry` varchar(32) DEFAULT NULL COMMENT '行业', `scale` varchar(32) DEFAULT NULL COMMENT '规模', `source` varchar(32) DEFAULT NULL COMMENT '客户来源渠道', `level` char(1) DEFAULT 'C' COMMENT '客户级别 A/B/C/D', `owner_id` bigint(20) DEFAULT NULL COMMENT '归属销售ID', `status` tinyint(4) DEFAULT 1 COMMENT '1正常 2已流失 3已成交', `deleted` tinyint(4) DEFAULT 0 COMMENT '逻辑删除', PRIMARY KEY (`id`), KEY `idx_owner_id` (`owner_id`), KEY `idx_level_status` (`level`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `contact` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_id` bigint(20) NOT NULL, `name` varchar(64) NOT NULL, `position` varchar(64) DEFAULT NULL, `mobile` varchar(20) DEFAULT NULL, `wechat` varchar(64) DEFAULT NULL, `is_primary` tinyint(1) DEFAULT 0 COMMENT '是否主要联系人', PRIMARY KEY (`id`), KEY `idx_customer_id` (`customer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.2 数据权限:同一张表,不同角色能看到不同范围
这是整个项目里最容易踩坑的地方。CRM系统的数据天然有隐私属性,销售不应该看到其他销售的客户,主管可以看到自己团队的客户,老板可以看到全公司所有客户。很多团队是在每个查询SQL后面手动拼接where条件,比如owner_id = 当前用户id。这样做短期内没问题,但只要查询条件变多一点就会被遗漏,产生数据越权的bug。
我的方案是基于MyBatis-Plus的拦截器实现SQL级的数据权限过滤。思路是这样的:
- 自定义一个
@DataScope注解,标注在需要做数据权限控制的Mapper方法上。 - 通过拦截器在SQL执行之前解析当前登录用户的角色和部门。
- 根据角色动态改写SQL,往where子句中拼入对应的数据范围条件。
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class DataScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaObject = SystemMetaObject.forObject(statementHandler); MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement"); String originalSql = statementHandler.getBoundSql().getSql(); // 根据当前用户角色判断是否需要追加数据权限条件 if (需要数据权限控制(mappedStatement)) { String scopeSql = buildDataScopeSql(); // 构造数据范围子句 String newSql = injectWhereClause(originalSql, scopeSql); metaObject.setValue("delegate.boundSql.sql", newSql); } return invocation.proceed(); } }构建数据范围子句的时候要注意:管理员和普通销售的处理逻辑不同。管理员不需要任何过滤;部门主管需要限制在部门ID + 直属下级;普通销售只需要owner_id = 当前用户。这个逻辑不复杂,但必须统一放在拦截器里做,而不是散落在各个Service方法中。
3.3 状态流转:用状态机而不是if-else堆逻辑
线索到客户到商机再到成交,这个流程涉及非常多的状态:新建线索 → 已联系 → 已转化客户 → 商机阶段(初步沟通 / 方案确认 / 报价 / 商务谈判)→ 赢单 / 输单。
最直观的做法当然是if-else,但状态多了以后会变成一团乱麻。我的做法是用一个轻量的状态机框架:
public enum CustomerStatus { LEAD(0, "线索"), CONTACTED(1, "已联系"), CONVERTED(2, "已转化客户"), OPPORTUNITY(3, "商机阶段"), WON(4, "赢单"), LOST(5, "输单"); private final int code; private final String desc; private final Set<CustomerStatus> allowedTransitions; // 允许流转的状态集合 static { LEAD.allowedTransitions = EnumSet.of(CONTACTED, LOST); CONTACTED.allowedTransitions = EnumSet.of(CONVERTED, LOST); // ... } public boolean canTransitionTo(CustomerStatus target) { return allowedTransitions.contains(target); } }这样有两个好处:一是所有的状态流转规则都收敛在枚举定义里,新增一种状态、调整一条流转路径,只需要改一处代码;二是可以在Service层统一做状态校验,避免非法流转(比如未联系就直接变成商机)。
踩过的坑:一开始我把状态流转写成了Service层的一个独立方法,里面用switch做了很多判断。结果第三周需求变了——销售总监要求"线索可以直接转为赢单,但要标记为异常赢单"。改起来异常痛苦,后来重构成了上面的状态机枚举结构,后续加需求就顺畅多了。所以我的经验是:消费方流程的状态数量一旦超过五个,就值得上状态机机制了。
4. 功能落地:技术不炫技,但每一步都要能扛住生产环境
4.1 登录认证与鉴权的完整链路
认证方面直接用市面上验证过非常多的方案:Spring Security + JWT。没有引入OAuth2这种重方案,因为系统是纯内部使用,没有第三方授权登录的需求。
关键点有几个:
- JWT续期:access token有效期设短(2小时),refresh token设长(7天)。前端拿到401之后用refresh token自动换取新token,用户无感登录。
- 密码加密:BCrypt加密存储,不允许明文密码入库。
- 登录锁定:同一账号连续输错5次密码,锁定15分钟。用Redis记录失败次数,设过期时间即可实现。
- 登录后的用户上下文:解析token后把userId、roleId放入一个ThreadLocal容器(自定义的
LoginUserHolder),在后续的Service层直接用即可。注意ThreadLocal的清理,在拦截器afterCompletion里必须remove,否则线程池复用会导致用户身份串号。
public class LoginUserHolder { private static final ThreadLocal<LoginUser> LOCAL = new ThreadLocal<>(); public static void set(LoginUser user) { LOCAL.set(user); } public static LoginUser get() { return LOCAL.get(); } public static void clear() { LOCAL.remove(); } }4.2 客户详情页性能问题:解决"打开慢"的体验问题
客户详情页是销售每天打开最频繁的页面,它要展示客户基本信息、联系人列表、跟进记录、关联商机、待办提醒。如果每次请求都全量实时查库,随着数据量增长,页面响应速度会直线下降。
我采用了Redis缓存客户聚合信息的方案:
- key设计:
crm:customer:detail:{customerId},value直接存JSON字符串。 - 缓存更新策略:客户基本信息变更时,同步删除对应缓存;跟进记录新增时,只更新缓存中的跟进记录子集,而不是删除整个缓存。
- 缓存穿透处理:如果某个客户ID在DB中根本不存在,也不能让请求每次都穿透到DB,缓存一个空值对象,设置短过期时间(5分钟)。
实测下来,详情页接口P95响应时间从450ms下降到了90ms左右,体感提升非常明显。
这里有个细节要提醒:Redis缓存的一致性不用追求绝对实时,内部系统允许秒级延迟。所以我的做法是数据变更后直接删除缓存,下一次请求再回源DB并重建缓存,简单可靠。引入Canal监听Binlog、或者搞消息队列异步更新缓存这些方案,在这个业务体量下都属于过度设计。
4.3 跟进记录的分页查询优化:避免深分页
客户详情页里的"跟进记录"是一个典型的深分页场景。销售持续跟进一年,一条客户的跟进记录可能有数百条,如果用户一直往下翻,就可能访问到很深的页码。
MySQL深分页的问题大家都知道:LIMIT 10000, 20需要先扫描前10000行,成本很高。我的优化方案是使用游标分页(Keyset Pagination),即把LIMIT offset, size改成WHERE id < ? ORDER BY id DESC LIMIT size,利用主键索引直接跳过前面的数据。
接口传参改成前端传"上一页最后一条记录的id",而不是页码。这样百页以内的列表查询性能基本稳定,不会随着翻页深度变慢。
类似的优化思路也被用在操作日志查询、商机明细查询上。这个改造动手成本很低,但收益特别稳定。
4.4 销售漏斗报表的实现思路
销售主管和管理层核心看的漏斗报表,数据来源是商机表的阶段分布。用MySQL直接做统计:
SELECT stage, COUNT(*) AS cnt, SUM(amount) AS total_amount FROM opportunity WHERE owner_id IN (SELECT id FROM user WHERE dept_id = ?) AND status = 1 GROUP BY stage这个SQL本身很简单,但有两个问题:一是实时统计在大数据量下会有性能压力;二是漏斗报表不需要秒级实时,只需要每天/每小时级别的新鲜度。
所以我做了一个报表预聚合表,定时任务每30分钟跑一次,把各维度关键指标算好存起来。前端查询直接读聚合表。聚合表的数据量很小,查询永远是毫秒级。这个模式虽然简单,但对这个业务体量来说已经相当够用。
5. "智能"部分到底应该做成什么样,我的取舍
"智能管理系统"里的"智能"这两个字,其实是最大的坑。如果做一堆看起来很炫但业务方根本用不起来的AI功能,最后只会被丢在角落。我的思路是:每个智能功能都必须能直接辅助一个具体的业务动作。
5.1 客户价值分层:不是拍脑袋,而是可解释的评分算法
销售手上客户很多,每天时间有限,先跟谁聊、后跟谁谈,决定了产出效率。我实现了一套轻量的客户评分模型,总分100分,由几个维度加权计算:
| 维度 | 权重 | 评分规则 |
|---|---|---|
| 客户规模 | 25分 | 上市公司25分 / 中小企业15分 / 小微8分 |
| 需求匹配度 | 30分 | 有明确项目需求30分 / 有潜在需求15分 / 无明确需求5分 |
| 接触活跃度 | 20分 | 近7天有跟进20分 / 近30天有跟进10分 / 超过30天无跟进0分 |
| 决策链完整度 | 15分 | 已接触关键决策人15分 / 仅接触执行层8分 / 无联系人0分 |
| 来源渠道权重 | 10分 | 官网主动咨询10分 / 展会名片6分 / 购买名单3分 |
这套规则看着简单,但它好在所有字段都是系统里现有的结构化数据,不需要额外的人工录入,评分逻辑可以根据权重清晰解释——销售问"为什么这个客户是A级",可以直接告诉他是需求匹配度和决策链完整性加分了,而不是一个黑盒。
业务流程中,高分客户会自动进入销售主管的"重点客户关注列表",并且系统会给出下一个跟进时间建议。
5.2 成交概率预测:规则评分卡比机器学习模型更实用
我在做这个项目之前也和不少人交流过,发现一提到"预测"大家的第一反应就是上机器学习模型,比如用逻辑回归、XGBoost预测商机成交率。说实话,在这个数据量级下(几千个商机),模型的效果并不比规则评分卡好多少,但是维护成本却高了一个量级——数据要打标、特征要清洗、模型要定期重训。
我最终选择了基于规则的评分卡(Scorecard)方案:商机金额大小、当前阶段、客户属于哪个等级、最近跟进频率、决策链条完整度五个因素,每一项得分相加得到0-100的成交指数。对应到三个挡位:
- 成交指数≥75:高概率赢单,系统自动提醒主管重点介入。
- 成交指数40-74:正常推进,保持跟进频率。
- 成交指数<40:低概率赢单,建议降低投入,或作为风险预警信息展示。
这个方案的好处是结果可解释、逻辑可调整。业务方如果觉得某个因素占比不合理,直接在配置中心调整权重即可,不需要研发重新训练模型。
5.3 待办智能提醒:用定时任务解决"销售忘了联系客户"的痛点
销售最常出现的执行问题是:一个高意向客户,因为最近在忙另一单,三周没联系,结果客户被竞品签走了。智能提醒功能需要解决的就是这类问题。
我的实现方式就是一个定时任务,每天上午9点扫描所有客户:
- 客户的最近一次跟进时间距今超过3天:生成"跟进提醒"。
- 客户是A级高价值客户,但超过7天未跟进:追加一条"重点客户超期提醒"。
- 商机状态停留在"商务谈判"超过7天:给销售主管推送一条"商机停滞预警"。
提醒的触达渠道包括系统内部的消息中心、企业微信通知、以及邮件。
这套机制不复杂,但真正业务价值很高——销售觉得系统在帮他做事,而不是在给他添乱。
5.4 关于大模型能力的边界思考
2024年以来,"智能管理"绕不开大模型这个方向。我实际调研过几个现成方案,评估结论是:在客户关系管理这个场景里,目前最适合落地的是辅助生成型能力,而不是自动决策型能力。
我当时做过的两个试验性功能:
- 客户资料摘要生成:把客户的基本信息、历史跟进记录、关联商机动态汇总后,调用大模型生成一段200字的"客户全景摘要"。销售打开客户详情页时原本要读3分钟的内容,现在扫一眼摘要就够了。这个功能确实有效。
- 跟进行动建议:根据客户当前所处的阶段给出下一步建议,比如"该客户处于方案确认阶段,建议整理一份行业案例发送给对方"。这个生成结果可以作为参考,但要人工确认后再跟进。
没有做的事情是让AI自动代写发给客户的邮件、自动判断客户意向等级。原因很简单:AI自动代写的内容一旦有事实性错误,比如把客户名称写错、报价金额搞混,销售直接点发送就会造成客户投诉;自动判断客户意向等级则涉及业务判断的责任归属问题。在内部工具上,责任边界要非常清楚。辅助生成、人工确认,这是在当前阶段比较稳妥的实践边界。
6. 项目上线后:部署、性能、稳定性
系统上线前后的几个环节,也值得展开说说。
6.1 Docker部署与资源规划
这套系统最终是单机Docker Compose部署的,组件包括:一个Spring Boot应用容器、一个MySQL容器、一个Redis容器、一个Nginx容器。初期并发量小(内部不过百人同时在线),单机部署完全可以胜任。
Docker Compose的关键配置:
version: "3" services: mysql: image: mysql:8.0 container_name: crm-mysql environment: MYSQL_ROOT_PASSWORD: xxxxxx MYSQL_DATABASE: crm_db volumes: - ./mysql-data:/var/lib/mysql ports: - "3306:3306" command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci redis: image: redis:7.0 container_name: crm-redis ports: - "6379:6379" volumes: - ./redis-data:/data app: build: . container_name: crm-app depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql REDIS_HOST: redis ports: - "8080:8080" volumes: - ./uploads:/app/uploads部署中实际遇到的坑是应用启动时MySQL尚未就绪导致连接失败。解决方式有两个:最简单的是在Spring Boot的启动逻辑里加一个depends_on的healthcheck条件;更稳妥的是利用dockerize工具等待依赖服务端口通了再启动应用。这里要注意,单纯的depends_on并不保证MySQL已经完成初始化,需要加条件判断。
6.2 慢SQL的排查与索引优化
上线第一周就收到反馈:客户列表页的搜索接口变慢了。查了慢查询日志,发现是一个动态条件查询的SQL没有命中索引:
SELECT * FROM customer WHERE name LIKE CONCAT('%', 'xx公司', '%') AND industry = 'IT' AND status = 1 ORDER BY update_time DESC LIMIT 0, 20百分号前置的字段无法使用普通B+Tree索引,导致全表扫描。我的处理策略是:
- 行业和状态字段的组合索引:
(industry, status, update_time)先覆盖等值查询的过滤场景。 - 客户名称搜索场景改为全文索引(ngram parser),支持中文分词搜索,避免全表扫描。
- 接口层的处理:对可选搜索条件做限制,比如强制要求至少选择一个非「名称模糊搜索」的条件,否则查询退化为普通分页逻辑。
ALTER TABLE customer ADD FULLTEXT INDEX ft_search (name) WITH PARSER ngram;查询改写为:
SELECT * FROM customer WHERE MATCH(name) AGAINST('+"xx公司"' IN BOOLEAN MODE) AND industry = 'IT' AND status = 1 ORDER BY update_time DESC LIMIT 0, 20;上线后这条接口的耗时从1.8秒降到了80毫秒左右。
6.3 日志与请求追踪:没有链路追踪时怎么快速定位线上问题
系统没上微服务,所以没必要引入SkyWalking这类重量级APM组件。但线上排查问题的能力必须要有。我做了一个轻量级的方案:
- 网关过滤器为每个请求生成一个
traceId,传递到MVC层、Service层、DAO层。 - Logback的Pattern中输出
[%X{traceId}]。 - 通过公司现有的ELK日志平台索引traceId,即可将一次请求的所有日志从入口到SQL输出串联起来。
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>这样定位问题时直接按traceId搜日志,配合MySQL的慢查询日志、Redis的慢命令日志,线上问题大多都能在10分钟内定位到根因。
我记得有一次线上用户反映某个客户的详情页打不开,排查时就用traceId把前端的出错请求串到后端日志,发现了是第三方企业信息接口超时导致的异常未捕获。如果没有traceId,这个问题的定位可能要花上一小时。
7. 上线后维护阶段的一些细节体会
7.1 定时任务的确保只有一台执行
在单机部署阶段,@Scheduled开几个定时任务没什么问题。如果后面扩展到多节点部署,就会遇到同一时刻多个节点同时执行任务的情况,造成重复提醒、重复计算。
我在部署时提前用Redis分布式锁做了保护:
@Component public class ScheduleTaskGuard { @Autowired private StringRedisTemplate redisTemplate; public boolean tryLock(String taskKey, long expireSeconds) { Boolean success = redisTemplate.opsForValue() .setIfAbsent("crm:task:lock:" + taskKey, "locked", Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } }每个定时任务执行前先尝试获取锁,拿不到锁的直接跳过。这个保护机制现在看着是提前量,但真的到部署两节点的时候就省事多了。
7.2 操作审计:内部系统最容易忽略却很重要的功能
CRM里的数据涉及商业机密,销售误删客户资料、恶意修改商机金额,这类事情虽然少,但一旦发生没有追溯能力,问题就大了。
我的设计是基于Spring AOP统一记录操作日志:
- 自定义
@OpLog注解,标注在Controller或Service方法上。 - AOP切面在方法执行成功后,记录操作人、操作类型、目标对象ID、操作内容摘要、请求IP、耗时。
- 操作日志单独存一张表,不随业务数据一起逻辑删除。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface OpLog { String module() default ""; String action() default ""; }后续如果有人问"这个商机的金额是谁在什么时候改的",查一下操作日志表就能完整还原当时的操作链路。这个功能平时没有存在感,但真正的故障或争议场景里它价值极高。
7.3 数据备份策略
MySQL容器化部署之后,备份的注意点很多。我用了mysqldump每天凌晨全量备份到挂载的数据盘,保留最近7天的备份文件。Redis是纯缓存数据,不需要备份,重启后自动回源即可。
备份命令写成了脚本放在宿主机crontab里:
0 2 * * * /usr/bin/docker exec crm-mysql mysqldump --single-transaction -uroot -p****** crm_db > /data/backup/crm_$(date +\%Y\%m\%d).sql这个方案看着很原始,但胜在简单可靠。对内部系统来说,能恢复到前一天的数据已经满足绝大多数需求了。
8. 如果这个项目重新做一遍,我会改哪些事
这个系统做了大约两个月,上线后持续迭代了半年左右。如果现在再回到起点重新做,我会在这些方面做不同的决策:
一是领域模型的设计会更细一些。当时数据库表设计虽然能满足功能需求,但字段类型和命名上有不少"够用就好"的痕迹。比如客户级别用了单个char类型A/B/C/D,后来升级到更细分的分层模型时,花了很大力气兼容已有数据。如果一开始就设计成独立的字典表,扩展成本会低很多。
二是接口层会从一开始就统一响应结构。早期接口的返回格式存在不统一的问题,前端对接得很痛苦。后来才规范了统一响应体Result<T>:code/message/data三个字段,错误码也在枚举里定义清楚。后端改起来容易,但对前端来说这是非常影响开发体验的事。这个统一应该在项目第一天就强制执行。
三是会把文档和字段说明做得更完善。内部系统最大的坑是人员流动后,接手的开发对着几百张表根本无从下手。我会在建表的时候就同步维护好数据字典文档,注释写清楚每个字段的业务含义。这项工作不紧急,但长期来看,价值比写业务代码还要大。
四是会更早考虑多租户的问题。当时是单租户内部系统,后续如果想给其他公司提供SaaS服务,数据隔离方案就要返工。如果你从第一天就做产品化的系统,多租户的租户ID字段和数据权限设计必须提前规划,不然后续改造的成本非常大。
五是会更早引入统一异常处理机制。项目初期异常处理比较随意,有的地方直接抛出了原始异常信息给前端,有的地方又吞掉了异常。后来用了@RestControllerAdvice做统一异常捕获,配合自定义业务异常BusinessException,这才规范化。这同样应该是项目开始时就定下的规范,而不是等用户抱怨"报错看不懂"之后再补。
9. 一些我踩过的坑,提前帮你避掉
9.1 时区问题:本地好好的,生产环境差了8小时
这是Spring Boot项目几乎必踩的坑。本地开发时MySQL和JVM默认用的是本地时区,一切正常。但生产服务器时区可能不同,插入的时间字段会整体偏移8小时。
我的配置是:JDBC URL统一加上serverTimezone=Asia/Shanghai和useLegacyDatetimeCode=false,应用启动参数JVM设置-Duser.timezone=Asia/Shanghai,MySQL连接初始化时执行SET time_zone = '+08:00'。这三处一起设了,才能保证环境差异不影响时间。
9.2 批量导入的Excel解析内存溢出
销售团队的老客户数据都存在Excel里,上线初期需要一个数据导入功能。如果直接读取整个Excel到内存再解析成对象列表,十万行数据就可能导致OOM。
我后来改成基于事件模式逐行读取:使用EasyExcel的AnalysisEventListener,每读1000行就批量入库一次,然后主动GC区回收列表。这样内存峰值稳定控制在几百兆以内。
9.3 消息推送的重复打扰
做待办提醒的时候,刚开始设置了一条规则:只要客户超过3天没跟进,每天早上推一条提醒。结果半个月后销售集体抗议——一个常年不怎么联系的C级客户,连续被提醒了30天,消息列表全是噪音。
后来加了提醒次数的上限和降噪策略:
- 同一个客户每周最多提醒一次。
- 客户等级为C/D的客户,降低提醒频率甚至不提醒。
- 已连续提醒3次仍无跟进的客户,升级为任务给主管,由主管决定是否分配他人跟进。
这个调整上线后,销售对提醒的认可度一下子提升了不少。智能提醒不是提醒得越多越好,而是要在合适的时间提醒真正值得关注的事。
9.4 部署升级期间的数据一致性
某次发版更新了数据库表结构,加了两个字段。当时直接在生产库执行了ALTER TABLE,同时重启应用。结果由于新老代码并存,老代码写入的数据缺了字段默认值,导致部分数据不符合校验规则。
后来改成严格的发版流程:
- 先备份数据库。
- 执行增量DDL脚本,保证字段有合理的默认值。
- 发布新代码。
- 灰度验证后再全量切流。
这个流程看着简单,但前提是数据库变更脚本必须提前准备并评审。在团队协作中这一点尤为重要,任何alter操作都要有回滚计划。
9.5 Redis连接池耗尽导致的连锁故障
一次性能测试中发现,高并发场景下Redis连接池被占满,大量请求阻塞等待获取连接,进而拖垮了整个接口层。检查后发现原因有两个:一是代码里有个查询方法漏了释放连接(使用的是默认的连接池配置,连接池太小);二是没有设置合理的超时时间。
针对Redis连接池配置:
spring: redis: lettuce: pool: max-active: 50 max-idle: 20 min-idle: 5 max-wait: 3s timeout: 3s同时排查并修复了连接未释放的问题。从那以后,每次上线前我都会检查一次连接池配置和资源释放逻辑,这个好习惯帮我避免了不少线上事故。
10. 从技术架构到业务价值的回顾
这个Spring Boot客户关系智能管理系统做完之后,同样业务链条里的合同管理模块、售后工单模块后来也都在这套架构上扩展出来了。统一的技术底座、统一的数据权限方案、统一的审计日志机制,让后来的模块开发变得很快。
回到最初的目的:系统上线三个月后,销售团队的客户信息完整度从50%提升到了95%以上;主管不再需要手动汇总周报,漏斗报表页面直接看;管理层第一次有了相对可信的下季度业绩预测依据。这些成果完全不是靠什么高端算法达成的,而是把业务动作结构化、把数据沉淀下来,在正确的时间给出正确的提醒,这套"务实、可解释、好维护"的路子,在当前大多数业务场景下,比堆砌花哨的技术概念要有用得多。
如果这篇文章能给准备做类似系统的人一点参考,我的建议浓缩成一句话:先花足够的时间理解业务,再用最稳妥的技术把它落地,把智能用在真正能帮到用户的地方,剩下的交给时间去打磨。后续如果大家遇到具体的技术细节问题,欢迎在评论区交流,我会基于实际项目的经验尽量解答。