ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

自研轻量级CRM系统实战:架构设计、数据模型与权限控制

自研轻量级CRM系统实战:架构设计、数据模型与权限控制 我们团队去年接到一个内部需求给销售和客服做一套轻量级的 CRM。市面上成熟的 CRM 系统不少但对二三十人的服务型团队来说很多功能用不上反而被复杂的配置流程绊住脚。商量了几轮之后决定自己动手项目代号就叫DeskcommCRM。这个项目名称其实直接点出了产品定位——Desk桌面办公场景 CommCommunication 沟通它不是传统意义上的“数据库操作界面”而是围绕沟通记录、客户跟进、任务协同来组织的客户关系管理系统。这篇文章我就把这个项目的设计思路、核心模块拆解、实际开发中踩过的坑一起放出来给也想自建 CRM 的团队一个参考。我主要从四个方面来讲项目整体定位与设计取舍、核心功能模块和数据结构、后端技术选型与关键实现、以及上线之后遇到的典型问题和排查思路。不管你是技术负责人还是全栈开发这里面很多问题都是通用场景直接能搬到自己项目里用的。1. 项目定位与整体设计思路1.1 为什么叫 DeskcommCRM它到底解决什么问题CRM 全称是 Customer Relationship Management客户关系管理。市面上的 CRM 分成两大流派一派像 Salesforce 那样功能极其庞大从市场活动、销售流程到合同回款、客户服务全链路覆盖适合几百上千人的大型组织另一派是轻量型的客户管理工具本质上是一个带备注的通讯录适合个体销售或微型团队。这两者之间其实存在很大的空档——中小型团队最需要的不是管理客户的“数据”而是管理“跟客户之间的沟通过程”。“DeskcommCRM”这个名字就是从这个问题出发起的。Desk 代表桌面办公场景Comm 是 Communication 的前缀合在一起就是**“在桌面办公环境下以沟通驱动为核心的客户关系管理”**。产品的核心命题不是“把客户信息存下来”而是“让每一次和客户沟通的记录都可追溯、可转交、可协同”。这套系统的目标用户画像非常清晰销售坐席需要记录每天外呼、微信、面谈的客户反馈客服坐席需要把用户反馈升级为工单并跟进到闭环团队管理者需要查看每个客户的跟进状态、团队成员的工作饱和度一句话总结项目使命把散落在微信、通话记录、Excel、纸质便签里的客户沟通信息统一收拢到一套可检索、可流转的系统中。1.2 核心场景与功能边界我们做产品设计的时候第一步不是画原型而是把团队的真实业务场景列出来。这一步很重要我自己吃过亏——以前做系统喜欢先堆功能结果一半功能上线后没人用。这次我们先盯住三个核心使用场景场景一是“新客户分配”销售拿到一批线索需要快速登记客户基本信息并生成第一轮跟进任务。场景二是“日常跟进记录”销售每次和客户沟通完之后把通话结果、微信聊天要点、客户意向变化记录到系统里系统自动更新客户状态。场景三是“问题升级”客服接到投诉或技术支持请求后创建工单并指派给对应负责人整个处理过程透明可见。围绕这三个场景功能边界就清晰了模块核心功能对应场景客户管理客户档案、联系人、批量导入场景一沟通日志通话记录、跟进记录、附件上传场景二工单中心工单创建、分派、流转、催办场景三商机管理商机阶段、金额预估、赢单率场景二延展数据看板今日待办、跟进统计、成交漏斗管理需求没有做合同、回款、财务对账这些模块因为我们清楚边界——这些是财务系统的活硬塞进来只会把项目周期拉长。这个取舍后来被证明是对的系统上线后团队使用率远超预期核心原因就是功能少而精用户不用花时间学系统系统迁就用户的工作习惯。1.3 为什么不自研完整业务中台技术评审的时候有同事提出干脆做一套完整的业务中台后续扩展就不愁了。这个提议被我否了原因有三点第一业务中台听起来美好但本质上是高成本的基础设施投资。它要求团队用统一的标准去抽象所有业务模块这对当时的团队规模来说是不可承受的。第二中台化的系统通常需要多个领域的专家长期维护我们本来人力就紧张做中台等于给自己挖坑。第三客户管理这个垂直场景并不需要高度抽象——客户、联系人、沟通记录、商机、工单这几个实体之间的关系是稳定的用领域模型去设计完全够用。所以最终架构走的是模块化单体路线代码是单体仓库但业务模块之间通过清晰的接口边界解耦为将来拆服务留好口子。这个方案兼顾了开发效率和后续演进空间是整个项目最关键的决策之一。2. 核心模块拆解与数据模型设计2.1 六大业务模块的功能边界先看看模块划分。整个系统拆成六个核心模块组织用户、客户信息、沟通日志、商机管理、工单中心、数据看板。组织用户模块管的是账号、部门、角色权限采用的是标准 RBAC 模型。这里有个细节值得展开很多小团队做 RBAC 最后都做歪了把角色直接绑到用户身上导致权限调整的时候要改大量数据。我们的做法是用户—角色—权限三层模型角色是最小授权单位一个用户可以有多个角色一个角色可以拥有多组权限。这样管理员只需要维护角色后续团队规模变大也能撑得住。客户信息模块是系统的心脏以客户企业或个体为主数据联系人挂在客户下面一对一或一对多。客户信息除了基础字段名称、行业、规模、来源、区域还设计了自定义字段的能力允许管理员在后台添加扩展字段。这个功能看上去是锦上添花实际上很常用——不同团队的客户关注点差得很远有的看重行业有的看重产品线没这个能力系统就会逼用户迁就固定表单。沟通日志模块记录每一次与客户互动的内容是 DeskcommCRM 的重头戏。设计上的核心决策是支持多类型日志——电话、微信、面谈、邮件四种类型每类日志有独立的字段模板。比如电话日志记录通话时长、拨打方向面谈日志记录地点、参与人这样后续做数据分析的时候可以按类型聚合。商机管理模块的定位是“有戏的客户”——从客户池里把意向明确的商机拎出来单独推进。商机字段设计了金额、预计成交日期、所处阶段、赢单率且阶段流转有权限控制。不是所有客户都要进商机只有达到一定意向程度才允许销售创建商机。工单中心处理售后和投诉场景工单和客户强关联但独立于销售流程。客户来电投诉客服在系统里建一张工单关联客户ID指派给责任人工单状态从“待处理”走到“处理中”再到“已解决”“已关闭”。整个状态变更都记录在案管理层能追踪到一个投诉从提出到解决花了多久。数据看板模块不做花哨的可视化图表就盯几个核心指标今日新增客户、今日跟进任务数、本月成交金额、每个销售的商机阶段分布。数据直接来自业务表通过定时任务做轻量聚合查询响应控制在秒级以内。2.2 数据库表设计与核心字段数据库用的 MySQL 8.0InnoDB 引擎字符集 utf8mb4。核心表有这些客户表 customerCREATE TABLE customer ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, customer_no varchar(32) NOT NULL COMMENT 客户编号, name varchar(128) NOT NULL COMMENT 客户名称, industry varchar(64) DEFAULT NULL COMMENT 行业, source varchar(32) DEFAULT NULL COMMENT 客户来源, owner_id bigint(20) DEFAULT NULL COMMENT 负责人ID, level tinyint(4) DEFAULT 0 COMMENT 客户等级0-普通1-重要2-VIP, status tinyint(4) DEFAULT 1 COMMENT 状态1-启用0-停用, remark text COMMENT 备注, deleted tinyint(1) DEFAULT 0 COMMENT 软删除标记, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_status (owner_id, status, deleted), KEY idx_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT客户表;几个设计要点第一customer_no是业务编号用于导出、跨系统对账不能直接用自增ID防止客户信息被猜测遍历。第二deleted是软删除标记客户数据和沟通记录关联紧密硬删除会把历史轨迹全干掉用软删除保证数据可追溯。第三联合索引idx_owner_status直接命中“按负责人查客户列表”这个最高频查询。沟通日志表 interaction_logCREATE TABLE interaction_log ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, customer_id bigint(20) NOT NULL COMMENT 客户ID, contact_id bigint(20) DEFAULT NULL COMMENT 联系人ID, creator_id bigint(20) NOT NULL COMMENT 创建人ID, type tinyint(4) NOT NULL COMMENT 日志类型1-电话2-微信3-面谈4-邮件, content text NOT NULL COMMENT 沟通内容, next_follow_date date DEFAULT NULL COMMENT 计划下次跟进日期, attachment_ids varchar(512) DEFAULT NULL COMMENT 附件ID列表逗号分隔, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_customer_time (customer_id, create_time), KEY idx_creator_time (creator_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT沟通日志表;这里attachment_ids存的是逗号分隔的附件ID串严格来说违反第三范式但考虑到附件数量一般很少单独建子表反而增加查询复杂度所以用冗余方式存储。阿里云 OSS 上有独立目录存附件实体数据库只记录 meta 信息避免大字段拖慢查询。还有一点沟通内容用 text 类型而不是 varchar沟通记录经常是几百上千字的段落varchar(255) 根本不够用。任务表 task任务表的设计比较典型核心字段我贴一个简化版CREATE TABLE task ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, customer_id bigint(20) DEFAULT NULL COMMENT 关联客户ID, owner_id bigint(20) NOT NULL COMMENT 执行人ID, task_type tinyint(4) NOT NULL COMMENT 任务类型1-跟进2-回访3-其他, title varchar(255) NOT NULL COMMENT 任务标题, description text COMMENT 任务描述, due_date datetime DEFAULT NULL COMMENT 截止时间, status tinyint(4) DEFAULT 0 COMMENT 状态0-待办1-进行中2-已完成, priority tinyint(4) DEFAULT 1 COMMENT 优先级1-普通2-紧急, related_type tinyint(4) DEFAULT NULL COMMENT 关联对象类型1-商机2-工单, related_id bigint(20) DEFAULT NULL COMMENT 关联对象ID, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_owner_status_due (owner_id, status, due_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT任务表;任务表和客户表是松耦合关系customer_id允许为空因为有些任务是内部协作任务不直接关联具体客户。related_type和related_id用来把任务关联到商机或工单实现“在商机详情页就能看到这个商机下的所有任务”的效果。2.3 客户生命周期与状态机设计这个模块我认为是整个数据模型中最有价值的部分。客户不是静止的从线索录入到最终成交或流失是一个动态过程所以必须给客户设计状态机。客户状态机的流转路径新建客户被创建还没有任何跟进动作跟进中已经开始沟通但还没有明确商机商机阶段客户有明确意向进入商机管理成交订单完成流失确定不再合作设置流失原因沉睡超过30天未跟进系统自动打“沉睡”标记状态变化不是无约束的——比如“成交”状态不能直接变回“新建”只能变为“流失”或“沉睡”“流失”状态可以直接变回“跟进中”客户又被激活了。状态机定义在前端和后端各有一份后端是校验基准前端控制界面上可操作的按钮。这个设计带来一个直接的好处团队拿到的永远是最新客户状态不会再出现销售 A 把客户跟丢了销售 B 接手时完全不知道客户历史进展的情况。客户详情页就是一个完整的时间轴从创建开始到最近一次沟通一目了然。3. 技术选型与项目落地实操3.1 后端技术栈为什么用 Spring Boot MyBatis-Plus后端技术栈选择上我一开始考虑过 Node.js Express团队里有同事更熟这个开发起来确实快但最终选了 Java 生态Spring Boot 3.2 MyBatis-Plus MySQL Redis。选型逻辑就一句话Java 生态在 CRM 这个领域的技术积累和现成方案最丰富后续招人、维护、接第三方系统都省事。Spring Boot 3.x 本身已经非常成熟内嵌 Tomcat一个 fat JAR 就能跑起来部署成本低。MyBatis-Plus 在 MyBatis 基础上封装了通用 CRUD、分页插件、代码生成器开发效率提升明显特别适合 CRM 这种以业务逻辑为主、SQL 不太复杂的场景。Redis 在这里只干两件事缓存登录 Token 和做定时任务的分布式锁没有引入复杂的数据结构避免过度设计。服务端目录结构按模块分包大型单体应用的标准做法com.deskcomm.crm ├── controller // Web层接口入口 ├── service // 业务层核心业务逻辑 ├── mapper // 数据访问层MyBatis-Plus Mapper ├── model // 实体类、DTO、VO ├── config // 配置类安全、WebMVC、Redis ├── common // 工具类、常量、异常处理 └── task // 定时任务数据聚合、任务提醒3.2 前端技术栈Vue 3 Element Plus 的取舍前端选型没太多悬念Vue 3 Vite Pinia Element Plus Axios。Vue 3 的组合式 APIComposition API对复杂页面的状态管理更友好Element Plus 的表格、表单、弹窗组件覆盖了 CRM 90% 的界面需求不需要额外引 UI 库。有几个具体的落地细节权限控制路由是动态生成的用户登录后后端返回角色下的菜单和按钮权限码前端根据权限码决定哪些路由可以注册、哪些按钮可以渲染。按钮级权限用自定义指令v-permission实现比如“删除客户”按钮绑定了crm:customer:delete权限码没有该权限码的用户直接看不到这个按钮。状态管理当前登录用户信息、客户筛选条件、看板配置放在 Pinia 里业务数据不往全局 store 塞组件内自己管理防止状态污染。请求封装Axios 统一做token注入、错误提醒、401 跳转登录。交互上用 loading 状态和 Toast 反馈不做复杂的骨架屏小项目不值得为这个花时间。3.3 数据权限的两种实现策略这是权限体系里最容易被忽略但实际需求最高的部分。比如销售经理应该能看自己团队的客户普通销售只能看自己名下的客户老板能看全部客户。用 RBAC 能做菜单权限但做不了数据权限数据权限得单独设计。我们用了最简单也最可靠的策略通过 SQL 条件拼接来控制数据可见范围。查询客户列表时根据当前用户的数据权限范围自动拼接WHERE条件。数据权限分四个等级全部数据管理员可以看所有客户本部门数据部门负责人看本部门员工创建的客户本人及下属团队主管看自己和直属下属的客户仅本人数据普通销售只能看自己名下的客户实现上每个查询接口都要经过一个底层的权限拦截器从SecurityContext中取出当前用户的数据权限级别然后动态拼接对应的owner_id IN (...)条件。这个做法在数据量不大的场景下非常可靠而且 SQL 执行计划可以命中联合索引idx_owner_status性能也没有问题。3.4 部署架构与 Docker Compose 编排开发完成后我们用 Docker Compose 一键部署到一台 4 核 8G 的云服务器上整体架构如下Nginx反向代理 HTTPS 终结 前端静态文件后端服务Spring Boot 应用通过8080端口运行MySQL业务数据存储Redis登录 Token 缓存 定时任务锁阿里云 OSS附件存储部署时的docker-compose.yml重点看几个环境变量version: 3.8 services: mysql: image: mysql:8.0 container_name: deskcomm-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: deskcomm_crm volumes: - ./mysql-data:/var/lib/mysql restart: always redis: image: redis:7-alpine container_name: deskcomm-redis command: redis-server --requirepass ${REDIS_PASSWORD} restart: always backend: image: deskcomm/backend:latest container_name: deskcomm-backend depends_on: - mysql - redis environment: SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/deskcomm_crm?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai SPRING_DATASOURCE_USERNAME: root SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD} SPRING_DATA_REDIS_HOST: redis SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD} OSS_ENDPOINT: ${OSS_ENDPOINT} OSS_ACCESS_KEY_ID: ${OSS_ACCESS_KEY_ID} OSS_ACCESS_KEY_SECRET: ${OSS_ACCESS_KEY_SECRET} ports: - 8080:8080 restart: always nginx: image: nginx:1.25-alpine container_name: deskcomm-nginx volumes: - ./frontend-dist:/usr/share/nginx/html - ./nginx/nginx.conf:/etc/nginx/nginx.conf - ./nginx/ssl:/etc/nginx/ssl ports: - 80:80 - 443:443 depends_on: - backend restart: always注意环境变量用.env文件统一管理密钥、密码不进代码仓库。前端构建产物用npm run build生成静态文件挂载到 Nginx 容器/api路径反向代理到后端容器。另一个关键点是 HTTPS 证书。现在 Let‘s Encrypt 免费证书三个月一续为了省事直接配置了自动续期的 Certbot 容器定时执行续期任务证书快到期时自动更新。4. 关键模块实现与踩坑记录4.1 客户分页查询的动态条件处理客户列表是整个系统最核心的查询接口也是性能优化的重点。刚开始直接用了 MyBatis-Plus 的PageLambdaQueryWrapper结果一旦遇到用户同时筛选“所属行业 创建时间 负责人姓名”生成的 SQL 就走不了最佳索引查询慢得离谱。后来把客户列表查询全部改成了 XML 里的手写 SQL用script标签做动态条件拼接select idselectCustomerPage resultTypecom.deskcomm.crm.model.vo.CustomerVO SELECT c.id, c.customer_no, c.name, c.industry, c.source, c.level, c.status, u.real_name AS owner_name, (SELECT COUNT(*) FROM interaction_log l WHERE l.customer_id c.id AND l.deleted 0) AS log_count FROM customer c LEFT JOIN sys_user u ON c.owner_id u.id where c.deleted 0 if testquery.name ! null and query.name ! AND c.name LIKE CONCAT(%, #{query.name}, %) /if if testquery.industry ! null and query.industry ! AND c.industry #{query.industry} /if if testquery.ownerId ! null AND c.owner_id #{query.ownerId} /if if testquery.startTime ! null AND c.create_time gt; #{query.startTime} /if if testquery.status ! null AND c.status #{query.status} /if if testdataScope ! null and dataScope.ownerIds ! null AND c.owner_id IN foreach collectiondataScope.ownerIds itemownerId open( separator, close) #{ownerId} /foreach /if /where ORDER BY c.update_time DESC /select手写 SQL 有两个好处第一动态条件的每一个分支都清晰可见开发者一眼就能看出可能生成的慢查询第二LEFT JOIN 和子查询可以手动控制比如日志数量统计从主查询里的子查询挪到后端二次聚合避免一次查询做太多关联。分页用 MyBatis-Plus 的分页插件Page对象填current和size底层是拦截器自动改写 SQL加上LIMIT关键字。有个坑深度分页性能极差比如LIMIT 10000, 10会导致 MySQL 扫描前 10010 行再丢弃前 10000 行。这个问题在客户量过万之后就暴露出来了最终方案是把分页改成游标式分页——前端不传页码传上一页最后一条记录的id后端直接用WHERE c.id #{lastId} ORDER BY c.id DESC LIMIT 10性能恒定快。4.2 沟通日志的字数控制和附件上传沟通日志表单最初设计的很朴素一个富文本框、一个附件上传按钮。结果实际操作时发现几个问题某些销售把几百字的内容直接贴在富文本里数据库 text 字段根本不怕这个但前端渲染时整个页面卡顿上传附件没有大小限制有同事直接拖了 1 个 G 的视频进来上传中断没有重试机制客户在电话那头等着系统这边干转圈。针对这几个问题做了重构富文本换成轻量级的textarea只支持纯文本 简单换行。沟通记录不是写博客不需要花哨排版纯文本反而更适合检索。附件上传增加三个限制单文件大小不超过 20MB、单次上传不超过 5 个文件、支持类型限定为图片、PDF、Word、Excel。超限直接前端拦截不走上传接口。上传走 OSS 直传后端签名、前端直传不经过应用服务器中转。这个改动把上传耗时的瓶颈从应用服务器转移到了 OSS带宽和延迟都改善明显。上传逻辑的核心代码片段前端直传 OSSimport OSS from ali-oss async function uploadFile(file) { // 1. 向后端请求上传凭证STS临时凭证 const { credentials } await getUploadSignature({ fileName: file.name, fileSize: file.size, contentType: file.type }) // 2. 用临时凭证初始化 OSS Client const client new OSS({ region: credentials.region, accessKeyId: credentials.accessKeyId, accessKeySecret: credentials.accessKeySecret, stsToken: credentials.securityToken, bucket: credentials.bucket }) // 3. 执行上传带进度回调 const result await client.multipartUpload( crm/${Date.now()}-${file.name}, file, { progress: (p) { uploadProgress.value Math.round(p * 100) } } ) return result.res.requestUrls[0] }后端签名接口只做一件事校验当前用户权限、检查文件类型和大小然后调用 STS 服务获取临时凭证。临时凭证默认有效期 30 分钟超时后用户需要重新获取但这个策略在正常操作流程中很少触发因为上传动作都是即时的。4.3 定时任务提醒用 Redis 分布式锁防止重复推送任务提醒模块有一个需求每天早上 9 点准时给销售推送当天需要跟进的客户列表和待办任务如果任务还没完成每 2 小时再提醒一次。这个功能如果直接用 Spring 的Scheduled写在单机跑没问题但一旦未来要做多实例部署定时任务就会重复执行——A 实例推了一次B 实例又推了一次客户会被反复打扰。解决方案是给定时任务加 Redis 分布式锁。Spring 自带的Scheduled不支持分布式锁我封装了一个简单的注解DistributedLock用 Redis 的SETNX命令实现Component public class ScheduledLockAspect { Resource private StringRedisTemplate redisTemplate; Around(annotation(distributedLock)) public Object around(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable { String lockKey distributedLock.key(); String lockValue UUID.randomUUID().toString(); // 尝试获取锁10秒过期 Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { return joinPoint.proceed(); } finally { // 只有持有锁的实例才能释放锁 String currentValue redisTemplate.opsForValue().get(lockKey); if (lockValue.equals(currentValue)) { redisTemplate.delete(lockKey); } } } // 没抢到锁直接返回不执行业务 return null; } }使用方式就是在定时任务方法上打DistributedLock(key task:remind:daily)。这里有个细节要特别说明SETNX需要配合过期时间一起用setIfAbsent带Timeout防止持有锁的实例宕机锁永远释放不了。4.4 商机看板的拖拽更新与乐观锁商机看板是整个系统交互最重的页面仿 Trello 的看板把商机按照阶段列分布在泳道上销售可以直接把一张商机卡片从“初步接触”拖到“方案报价”拖拽完成前端调接口更新阶段字段。第一次实现的时候踩了个典型的并发问题两个销售同时拖同一张卡片后写入的覆盖了先写入的对方的前一个操作白白丢失。解决方案很简单——在商机表加一个version字段更新时带上版本号用乐观锁做并发控制Update(UPDATE business_opportunity SET stage_id #{stageId}, version version 1 WHERE id #{id} AND version #{version}) int updateStageWithVersion(Param(id) Long id, Param(stageId) Long stageId, Param(version) Integer version);如果更新影响行数为 0说明数据在读取之后被其他事务改掉了直接返回“操作冲突”给前端弹窗提示“商机状态已被他人修改请刷新后重试”。这个方案简单有效而且是数据库层面保证原子操作不需要引入分布式锁。看板拖拽属于低频操作并发冲突概率本身不高用乐观锁比悲观锁更合适。这个模块还有一个体验优化值得一提拖拽更新的响应速度直接影响用户体感我们在前端做了本地乐观更新——拖拽成功后先修改页面上的卡片位置再异步调后端接口接口失败再回滚到原位置并弹出错误提示。这样用户感知到的操作延迟几乎为零在弱网环境下体验也比一直转圈好得多。5. 上线后的常见问题与排查实录5.1 客户列表加载缓慢项目上线后两周销售反馈客户列表打开越来越慢最开始几百毫秒后来直接卡到 3 秒以上。排查路径是这样的第一步看 MySQL 慢查询日志。打开slow_query_log后抓到了几个慢查询集中在客户列表查询。第二步用EXPLAIN分析执行计划发现ORDER BY c.update_time DESC没有走索引全表排序了。原因很简单客户表已经有idx_owner_status联合索引但update_time排在索引最后当WHERE条件里只限定owner_id时索引无法同时处理排序。第三步加了个单列索引idx_update_time查询瞬间回落到百毫秒级。这个问题的根因是贪心设计索引以为联合索引能覆盖所有场景忽略了排序字段单独建索引的价值。后来定的规范是每个表必须有单列索引覆盖create_time和update_time因为按时间排序是管理类系统跑不掉的高频操作。5.2 定时任务漏推某天客户投诉说没收到跟进提醒查了一圈发现任务确实发了但推送通道出了岔子。问题出在提醒方式上了——早期版本提醒是通过系统站内信实现登录了系统才能看到销售根本没登录自然看不到。后来做了两个改进第一站内信之外增加企业微信通知通过企业微信的 webhook 推送给用户确保手机也能收到第二定时任务增加执行日志表每次推送动作都记录在案包括推送目标、推送通道、结果状态。查问题的时候直接看日志表一分钟内定位到是通道故障还是任务没触发。这里也验证了一个项目早期不确定、但上线后至关重要的设计所有定时任务必须留痕。不是为了审计而是为了出问题的时候能快速定位。我们自己写了个task_execution_log表主键、任务名、执行时间、执行结果、失败原因。配合 Spring 的Async异步执行任务时把结果主动写入日志统一的失败告警逻辑在任务异常时自动触发企业微信通知。5.3 附件上传失败Nginx 请求体限制测试环境一切正常生产环境上线第二天就有客服反馈上传超过 5MB 的图片一直报 413 错误。排查后发现是 Nginx 默认的client_max_body_size 1m在拦截。方案很简单在 Nginx 配置里把这个值调整到 25MB留出余量因为附件上限是 20MBserver { listen 443 ssl; server_name crm.example.com; client_max_body_size 25m; location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }这个问题本身不算难但容易踩是因为开发和测试环境通常直接访问后端端口不走 Nginx所以client_max_body_size的问题被环境差异掩盖了。上线检查清单里必须有一项和生产环境架构一致地验证一遍附件上传、大文件下载等流式接口。5.4 权限越权普通销售看到了全公司客户上线后第二周管理员在后台发现一个普通销售账号能看到其他团队的客户列表排查下来找到了问题根源客户列表查询接口从请求参数读取ownerId但这个参数是可选的——如果前端不传ownerId后端就不加过滤条件直接返回所有客户完全绕过了权限拦截器。这就是典型的服务端未做权限兜底。前端把ownerId藏在多级目录下普通情况下用户接触不到这个参数但真正安全的接口不能指望前端不传参数。修复方案是在后端新增一个必须执行的DataScopeAspect拦截器对带有RequiresDataScope注解的方法做增强——无论请求参数里有没有ownerId都会用当前用户的数据权限范围覆盖它。这个教训非常值钱权限校验永远是后端的事前端只是体验优化。所有查询接口的后端实现里只要涉及客户、商机、工单等等业务数据权限过滤条件必须由后端强制注入请求参数只能作为附加筛选条件不能作为权限判断的依据。5.5 数据字典管理字段值散落代码导致的噩梦最后一个问题不是 bug而是可维护性。项目早期做行业下拉框时直接在数据库表里存industry 1代码里写死1代表“制造业”2代表“互联网”。后来客户反馈行业选项不够要加“金融”“医疗”等分类——那一周我改了五张表的数据字典查了 20 多处硬编码。折腾完立刻做了一次系统性重构新增data_dict表存储所有字典类型和字典项后端提供统一的/dict/type/{type}接口前端下拉框数据全部动态加载代码中禁止出现任何魔法数字统一引用DictTypeConst常量类这次重构花了一天时间但之后的每次新增字典项都只改数据库半小时内搞定再也不用为一行硬编码翻遍全项目。6. 项目沉淀下来的几条经验DeskcommCRM 从立项到上线经历了需求梳理、技术选型、核心模块开发、联调测试、部署上线和问题修复整个过程有几个体会很深写在最后。第一中小团队做 CRM先做减法。能覆盖“客户信息 跟进记录 任务协同”这三件事的工具已经能解决 80% 的管理痛点。合同、财务、供应链这些模块除非是核心业务要求否则留给专业系统不要自己造轮子。第二数据权限必须优先设计。CRM 这类系统天然涉及销售数据隐私、客户信息安全稍有不慎就会出现越权访问。我们吃了晚做权限的亏后续反工了不少逻辑。这是我强烈建议所有自研管理系统的团队在数据库建模阶段就要考虑的问题。第三沟通日志的格式越简单越好。用富文本编辑器写跟进记录是个典型的过度设计导致页面加载慢、数据检索差。最后用纯文本解决体验反而更好。真正常用的功能往往不需要花哨稳定、快速、易检索才是核心。第四日志审计要前置。定时任务、操作记录、数据变更都要留痕不是为了追溯谁而是为了出问题时能快速定位。我们上线后才补这个能力中间有一次就是因为没有日志排查了一个晚上。这个项目后续我还想再扩展两块一是引入公海客户池机制——销售超过一定天数未跟进的客户自动流转到公共池其他销售可认领二是把客户打标签标签来源做成动态基于标签给销售推送可能感兴趣的客户。这些功能已经有雏形等整理完代码再写一篇分享。DeskcommCRM 这个项目最好的地方在于它没有被复杂的业务规则和庞大的功能列表压住始终围绕“沟通”这个真正有价值的核心在服务。做系统的人容易陷入堆功能的陷阱真正好用的工具往往是懂得取舍之后沉淀出来的那个。
返回列表