尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

从零构建轻量级CRM系统:技术选型、数据建模与权限设计实战

从零构建轻量级CRM系统:技术选型、数据建模与权限设计实战 1. 项目背景与核心定位1.1 为什么我会做一个叫 DeskcommCRM 的系统先交代一下背景。我所在的是一个二十来人的销售型团队前期一直用共享表格管理客户最开始只有几十个客户还挺清爽等线索量涨到几百、上千各种问题全冒出来了销售跟进的记录散落在微信聊天记录里老板想看一眼这个月真正有效的新增客户有多少得让每个人都手动汇报一遍统计口径还不一样客户被两个人同时跟的时候谁也不知道对方已经报价了最要命的是表格误删以后没有恢复机制月底盘点的时候数据对不上整个团队对着屏幕傻眼。盯了整整一个月的“原始社会”我决定自己动手做一个适合小团队使用的 CRM。项目名定为 DeskcommCRM英文 Desk桌面 Comm通信/协同本意就是“坐在办公桌前就能完成客户沟通和协同管理”的轻量化工具不搞大而全的重系统不要求专门的实施团队业务人员自己花半天时间就能上手。这个定位在后来被证明是团队能坚持用下来的最大原因。1.2 这个系统到底解决什么问题DeskcommCRM 的核心目标可以压缩成三条把客户数据从“散落的表格和聊天记录”收拢到一个结构化系统里把销售跟进的过程透明化管理者随时能看到每个客户处于什么阶段把重复性工作用自动化和提醒替代掉减少漏跟、忘跟。所以这个系统本质上不是“管客户的软件”而是“管销售行为和协作流程的软件”。客户只是一个被跟进的对象真正的主角是跟进动作。这一点想清楚以后后面的表结构、页面设计、权限模型全都围绕“行为数据”展开而不是围绕一个静态的客户档案展开。这也是 DeskcommCRM 和市面上很多客户管理软件在理念上最大的区别。如果你也是三五人到三五十人的团队觉得大厂 CRM 太重、Excel 又太不靠谱那这套系统的设计思路甚至可以直接抄走。即使你不想自己写这篇文章里关于数据建模、权限隔离、自动化规则的设计框架也可以拿去作为选型和内部流程梳理的参考。2. 整体设计与技术选型2.1 先想清楚模块边界再写代码我见过不少人做内部系统一上来就画了一个巨大的导航栏客户、订单、合同、工单、财务、考核应有尽有结果做了三个月还在做客户列表。DeskcommCRM 在设计阶段做了一次刻意收敛第一版只保留五个模块线索池所有新进来的客户都先落在这里待分配、待清洗。客户管理通过审核的线索转成正式客户这里是销售每天工作台。跟进记录每次打电话、发微信、见面拜访都要留痕时间轴串联。任务提醒包括待办事项、跟进计划、公海回收规则。统计报表按人、按来源、按阶段统计转化情况给管理者做决策。这五个模块之间是递进关系线索池是入口客户管理是主战场跟进记录是过程证据任务提醒是驱动机制报表是结果反馈。任何多余的东西第一版都坚决不做比如订单管理、合同审批这些等业务真正需要的时候再迭代。2.2 技术栈选择的真实考量技术上我选了 Python 系的 FastAPI 做后端接口前端用 Vue 3 Element Plus数据库用 PostgreSQL缓存和队列用 Redis任务调度用 Celery。这组选型看起来中规中矩但每一条都是基于团队现状做的取舍。后端 FastAPI团队里 Python 熟悉度高FastAPI 自带 OpenAPI 文档前端联调可以直接对着文档调接口省掉了维护接口文档的环节。异步支持对后面接 webhook 推送、导入导出这些 IO 密集任务很有帮助。前端 Vue 3 Element PlusElement Plus 的表单、表格、对话框组件非常齐全做后台管理类系统效率极高。你不用自己画一个日期选择器或者级联选择框组件库基本全包了。数据库 PostgreSQL这里我特意没用 MySQL。客户管理系统里面最值钱的是“查询灵活性”PostgreSQL 的 JSONB 字段可以让自定义字段实现起来极其轻量全文检索能力也是吊打 MySQL 的后面做客户名称搜地址、搜备注时优势很大。Redis Celery用来做异步任务、延迟任务和消息队列。比如“7天未跟进自动转入公海”这种规则用 Celery 的定时任务加延迟任务配合实现成本很低。这套组合本质上没什么“黑科技”都是社区里非常成熟的方案。对一个内部系统来说稳定、好招人、社区资料多比技术新潮重要得多。2.3 为什么不自研工作流引擎很多同类产品会强调工作流、审批流这种能力我第一版没做只做了一种“触发器”。所谓触发器就是满足条件就执行动作比如“客户超过 7 天未跟进则自动变为公共客户”“线索导入超过 500 条自动通知管理员”。它本质上是规则引擎的最小子集一条规则包含三个要素触发条件、执行动作、生效范围。用一张规则表存储配置用 Celery 的 beat 每分钟扫描一次实现起来两百行代码都不到。而完整的工作流引擎还需要支持多分支、多条件、会签、或签等那不是中小团队的核心痛点反而会把系统复杂度抬高一个量级。你自己用或给团队用的时候一定要分清哪些是真实需求哪些是看着别人有我也要有。用户嘴上说“我要流程审批”真实场景很可能只是“客户要降价超过 10% 的时候提醒我一声”。3. 核心功能拆解与关键实现3.1 线索池从录入到分配的一条龙自动化线索是 CRM 的源头这个阶段的数据质量直接决定后续转化率。DeskcommCRM 的线索池设计引入了“状态机”概念每个线索从进来开始就按照固定的状态流动新建 → 已沟通 → 已确认 → 已转客户也可以从任意正常状态流转到“无效”或者“待定”这两个终止/挂起状态。每次流转我们都建议写一句备注这样后续查“这条线索为什么被否了”的时候有据可依。从操作层面看销售只需要在列表页点击“转为客户”按钮系统就会自动做下面这些动作把线索数据复制到客户表、生成一条首次跟进记录、给负责人发一条站内消息、把线索状态改为“已转客户”。整条链路一气呵成不会出现“客户已经转了线索里还躺着一条旧数据”的脏数据情况。线索分配我们用的策略是“自动轮询 手动兜底”。管理员可以配置两个参数按人平均分配还是按能力加权分配每条线索进来以后是立即分配还是先放进公共池子由销售自行领取。这个功能用一段很简单的带权轮询算法就能实现好多人纠结要不要上抢单模式我建议中小团队别搞抢单模式看着热闹实际会制造内部竞争矛盾还是按规则自动分配更和谐。3.2 客户 360 视图一屏掌握全生命周期客户列表页长得和 Excel 很像双击单元格就可以编辑字段。但是点进详情页以后呈现的是一个“客户 360 视图”这是 DeskcommCRM 里最有价值的部分。页面上半部分是客户基本信息和关键标签下半部分全部是时间轴按照时间顺序展示每一个跟进记录、每一次状态变化、每一个待办任务。时间轴的数据来自一张统一的 timeline_events 表结构大概是这样的CREATE TABLE timeline_events ( id BIGSERIAL PRIMARY KEY, customer_id BIGINT NOT NULL, event_type VARCHAR(32) NOT NULL, event_data JSONB NOT NULL DEFAULT {}, operator_id BIGINT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_timeline_customer_time ON timeline_events (customer_id, created_at DESC);之所以把事件数据设计成 JSONB是因为不同类型的事件字段差异太大——跟进记录要有“沟通内容”状态变更要有“从A到B”任务完成要有“耗时”。你不可能给每一种事件建一张表那样查询和渲染都会很痛苦。JSONB 的做法相当于牺牲一点点存储和查询效率换来了极致的灵活性和扩展性。另外还做了一个细节跟进记录里如果包含电话号码、快递单号这类疑似隐私信息前端回显时默认打码鼠标点击以后才显示。这是应对内部数据泄露的一种低成本手段后面在权限模型里还会专门讲。3.3 跟进计划与自动提醒防止客户从指缝里溜走“销售忘记跟进”是 CRM 里最昂贵的问题。DeskcommCRM 针对这件事做了一个“跟进计划”的功能逻辑就像你给自己设的闹钟。销售可以给一个客户创建计划例如“三天后回访报价结果”系统到时间会自动完成两件事在待办列表里生成一条任务通过站内消息和邮件各推送一次通知。这里比较关键的是实现延迟任务的方案。我们用的 Celery没有用 Redis 的过期事件因为 Redis 过期事件不一定能精确触发而且键过期以后你拿不到完整的事件上下文。Celery 的 eta 参数可以指定任务在某个时间点执行执行的时候任务的入参里带上任务 ID然后去数据库把详情捞出来处理。这样即便执行的时候上下文变了也能基于最新数据做判断。公海回收规则是这一块的延伸如果一个客户的最后跟进时间距今超过 N 天并且当前没有未完成的跟进计划系统自动把这个客户重新放入公海池原负责人失去操作权限。这个规则建议先在测试部门跑两周把回收天数调成合理值不然容易造成销售不满。3.4 自定义字段让销售把系统用成自己的习惯每个团队的客户属性都不一样有的要记录“装修预算”有的要记录“公司人数”有的要记录“结算账期”我还是那句话别去改数据库表结构。DeskcommCRM 里直接做了自定义字段管理管理员可以在后台添加字段字段类型支持文本、数字、日期、下拉单选、多选、关联客户等。底层用两张表实现一张存字段定义一张存字段值CREATE TABLE custom_field_defs ( id BIGSERIAL PRIMARY KEY, module VARCHAR(32) NOT NULL, field_key VARCHAR(64) NOT NULL, field_name VARCHAR(128) NOT NULL, field_type VARCHAR(32) NOT NULL, options JSONB, sort_order INT DEFAULT 0 ); CREATE TABLE custom_field_values ( id BIGSERIAL PRIMARY KEY, module VARCHAR(32) NOT NULL, record_id BIGINT NOT NULL, field_id BIGINT NOT NULL REFERENCES custom_field_defs(id), field_value JSONB );数据存储全部 JSONB展示和筛选的时候再按类型解析。这在数据量到百万级别以前完全够用而且不用为每一个新字段做迁移成本极低。实际测试下来自定义字段从保存到展示的延迟可以忽略不计用户体验和固定字段几乎没差别。3.5 搜索与筛选没有全文检索的 CRM 就是电子垃圾CRM 里数据一旦过千搜索体验就是生死的分水岭。DeskcommCRM 的搜索框直接用的 PostgreSQL 自带的全文检索能力支持中文分词和拼音首字母匹配。比如你输入“beijing”就能搜出公司名里面带“北京”的客户输入“zhangsan”能搜出联系人为“张三”的客户。这一点对于实际使用体验的提升比任何炫酷 UI 都重要。PostgreSQL 的全文检索需要在字段上建立 tsvector 类型的生成列然后用 GIN 索引加速查询ALTER TABLE customers ADD COLUMN search_vector tsvector GENERATED ALWAYS AS ( to_tsvector(simple, coalesce(name, ) || || coalesce(contact_name, ) || || coalesce(contact_phone, ) ) ) STORED; CREATE INDEX idx_customers_search ON customers USING GIN (search_vector);查询的时候把用户输入的字符串拼成 tsquery 去匹配配合一个简单的排序函数让命中方式更靠前的记录排在上面。这套方案跑下来几万条客户的数据搜索基本都是毫秒级响应比用 LIKE 模糊查询不知道高到哪里去了。4. 权限模型与数据安全设计4.1 角色权限设计先管住谁能看什么小团队的权限设计往往被忽略但一旦人多了以后这是最容易出事故的地方。DeskcommCRM 采用 RBAC 数据范围的双层控制。第一层是角色按功能菜单分配比如“销售”角色能用客户列表和跟进记录不能进系统设置“管理员”角色全部可用。第二层是数据范围控制一个用户能看到哪些客户有四种级别仅本人、本部门、全部客户、自定义按标签筛选。从实现上看角色权限在前端路由守卫和后端接口装饰器里各做了一层校验。后端是真正的安全边界前端只是隐藏入口。举个例子一个销售如果想猜接口地址绕过按钮限制直接把 GET /api/customers 打到自己浏览器里后端会先判断他的角色是否允许访问该模块再判断他的数据范围是否包含这个客户的 owner_id两层都过了才返回数据。这里有一个细节判断数据范围时不能只比对 owner_id因为还有“协作人”这种概念。如果你的业务里经常会有“这个客户是 A 的但 B 也参与了跟进”那最好单独维护一张 customer_access 表记录所有有权限的人而不是简单拼一层 owner 条件。否则做共享客户池的时候权限会漏。4.2 敏感字段脱敏机制某次内部复盘时我们发现有员工给朋友截了个客户列表图客户的手机号全在里面。从那时起我就把“敏感字段脱敏”提上了优先级。DeskcommCRM 的字段定义里增加了一个敏感级别属性分三级普通、敏感、高敏。手机号属于高敏默认列表页展示为 138****1234只有点击“明文查看”并二次确认时才把完整号码展示出来。这背后的实现不复杂后端返回数据时根据当前用户角色判断是否脱敏脱敏规则由管理员配置字段敏感级别销售可见销售经理可见管理员可见客户名称普通明文明文明文联系电话高敏脱敏明文明文微信/备注敏感明文明文明文脱敏在高敏字段上绝不能只做前端手段后端必须在接口层就返回打码后的字符串否则任何人打开浏览器的调试工具就能看到接口返回里的真实数据。这条要写进 Code Review 检查清单别问我为什么知道。4.3 操作审计关键时刻能救命审计日志是安全体系里的“黑匣子”平时没人看出了事故它就是唯一的还原现场的工具。DeskcommCRM 在每一个核心操作中间都埋了审计点记录时间、操作人、操作类型、操作对象以及操作前后关键字段的变化快照。具体存的是执行动作前的旧值 JSON 和动作后的新值 JSON方便日后做数据回溯。有一次团队误操作批量删除了两百多条客户记录就是靠审计日志和回收站机制恢复的。我们设计了一个软删除机制所有删除操作不是真正的 DELETE而是把 deleted_at 字段写当前时间数据还留在表里。列表查询时统一过滤 deleted_at IS NULL恢复时只需把 deleted_at 置回 NULL。这样做最大的好处就是误删可以随时还原而且不会破坏外键关联关系。5. 集成与部署落地5.1 邮件集成把往来沟通自动归档到时间轴和客户沟通的场景不只有系统站内大量往来发生在邮箱里。DeskcommCRM 提供了一个邮件集成功能通过 IMAP 协议配置一个共享邮箱系统每五分钟拉取一次新邮件如果发件人或者收件人能够在客户表里匹配到这封邮件就会自动归档到对应客户的时间轴里。实现时有几个坑值得说一下。第一IMAP 拉取之后要去重邮件的 Message-ID 是天然的去重键要把拉取过的 ID 存在 Redis 里防止同一封邮件被重复归档。第二回复邮件的时候需要带上系统自己的 Message-ID 或者自定义的 X-Entity-Ref-ID否则客户回复时很难和原来那封关联起来。第三不要把密码明文存在数据库里用系统密钥对用户的邮箱密码做加密存储密钥放环境变量不要提交到 Git 仓库。这个功能上线之后销售和客户之间“说了什么、什么时候说的、对方有没有回复”全部有迹可循。每次开周会复盘丢单原因时直接翻时间轴比空口说“客户最近很忙”要有说服力得多。5.2 私有化部署Docker Compose 一键起服务DeskcommCRM 从设计之初就把私有化部署当成第一优先级因为很多客户企业有内部网络隔离要求。交付物是一个 Docker Compose 编排文件包含前端 Nginx 容器、后端 API 容器、Celery Worker 容器、Celery Beat 定时任务容器、PostgreSQL 容器、Redis 容器以及一个 Nginx 反代容器。整体目录结构大致是这样version: 3.8 services: db: image: postgres:15 environment: POSTGRES_DB: deskcomm POSTGRES_USER: deskcomm POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pg_data:/var/lib/postgresql/data redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data api: build: ./backend depends_on: - db - redis environment: DATABASE_URL: postgresql://deskcomm:${DB_PASSWORD}db:5432/deskcomm REDIS_URL: redis://redis:6379/0 volumes: - uploads:/app/uploads worker: build: ./backend command: celery -A deskcomm.worker worker --loglevelinfo beat: build: ./backend command: celery -A deskcomm.worker beat --loglevelinfo web: build: ./frontend depends_on: - api ports: - 8080:80 volumes: pg_data: redis_data: uploads:部署的时候只需要三件事装好 Docker 和 Docker Compose 插件、填好.env文件里的密码和密钥、在服务器上执行docker compose up -d。整个系统用的是标准 HTTP 协议没有依赖任何外部云服务所以内网环境里也能跑得很稳。5.3 数据备份策略凌晨两点不备份哭都来不及再怎么强调备份都不为过。我在 deliver 里配了一个 cron 任务每天凌晨 2:30 用pg_dump导出全量 SQL 文件然后压缩加密传到另一个存储区域保留最近 7 天的备份。同时每周一凌晨做一次整体快照保留 4 周。恢复流程我演练过两回整体过程大概 20 分钟能恢复到最近一天的状态。PostgreSQL 的备份有一点要注意不要直接去复制数据目录除非你用文件系统快照能力。最稳妥的方式是pg_dump逻辑备份虽然慢一点但跨版本恢复兼容性更好。另外备份文件传异地时要加公钥加密不要裸传否则数据文件泄露等同客户信息裸奔。6. 常见问题与排查实录6.1 时区问题导致时间轴错乱系统上线后第一天就有销售反馈跟进记录的创建时间怎么看怎么不对有的快 8 个小时有的慢 8 个小时。查了一圈后端代码里写死datetime.now()而服务器系统时间设的是 UTC0数据库字段用的 TIMESTAMPTZ 没问题但业务层取的本地时间却来自操作系统时区自然就乱了。排查思路很简单统一在数据库层用now()生成时间后端所有新增时间字段都依赖数据库默认值前端展示的时候根据浏览器时区做格式化。不要在 Python 或 JavaScript 里手动拼接时间字符串。这是个老掉牙的问题但踩过一次的人都知道它带来的数据混乱有多恶心。6.2 全文检索搜不出中文地址PostgreSQL 默认的全文检索分词对中文支持很差因为中文不像英文那样天然按空格分词。我们测试时发现搜“北京”搜不出“北京市朝阳区某某大厦”搜“朝阳”也搜不出来。解决方案是用pg_jieba扩展做中文分词它基于结巴分词实现能识别大部分中文词边界。安装扩展以后重新生成 search_vector 列里 to_tsvector 的配置把它从 simple 改成 jiebacfg重建索引中英文混合搜索就都能命中了。如果你不想引入扩展退而求其次的做法是手动维护关键词表把客户名称、地址、标签拆成多个关键词、短语但在数据量大的情况下维护成本很高。6.3 导入大量线索时接口超时做数据初始化时客户给了 2 万条线索的 Excel 表格直接一次性 POST 到后端接口前端超时后端数据库连接池也被挤满了。后来把导入改成两个阶段先上传文件到临时目录后端异步解析文件解析完成以后逐批写入数据库再通过 WebSocket 推送进度给前端页面。每次解析后先写 Redis 队列再由 Celery 消费者逐个消费入库。这样用户不需要一直等待页面响应随时可以关掉页面导入完成以后会收到站内通知。这种“文件上传 异步解析 队列写入 进度通知”的模式后续所有批量导入场景都能复用不建议再把大文件往同步接口上塞了。6.4 自动回收公海任务的重复触发公海回收这个定时任务曾经出现过一种情况同一个客户被回收了两次导致两个负责人同时收到“客户已回收”的提醒。原因是 Celery Beat 在任务执行过程中上一次没跑完、下一个周期又启动了两个任务同时读到“已超期未跟进”的客户列表就重复执行了回收动作。解决这个问题用了两招。第一回收任务在开始处理一个客户前先给客户加锁可以用SELECT ... FOR UPDATEPostgreSQL 的悲观锁锁住的客户不会被第二个任务重复处理。第二把任务改成幂等函数执行之前检查该客户当前 owner 和回收标记如果发现已经是公海客户直接就跳过不做重复变更。自此以后这个 bug 再没复现过。7. 扩展方向和最后想说的话DeskcommCRM 当前版本更像一个“数字化客户台账 跟进过程记录仪”它帮我解决了团队从混乱到有序的核心问题。后续如果继续演进我想做两个方向一是把销售邮件和通话录音的自动摘要接入系统让跟进记录不需要人工手动录入二是做一个基于自定义字段的报表引擎让业务负责人可以自己拖拉拽生成统计图表而不是每次都要找开发写 SQL。说到最后我的真实体会是内部工具的价值不在于功能多而在于能不能真正贴合一线的工作流。我在设计时反复提醒自己每个按钮、每个字段都要能回答一个问题——它能帮着销售省下多少麻烦。如果一个功能上了以后团队里大多数人都在绕过它用表格那多半不是人不行而是设计没到位。DeskcommCRM 真正的核心资产不是那几张表而是让团队养成了“每一个客户动作都有记录、每一个商机阶段都有依据”的工作习惯。这个习惯一旦建立起来任何系统都只是承载它的工具而已。
返回列表