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

资讯详情

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

从零构建轻量级CRM:DeskcommCRM设计与实践

从零构建轻量级CRM:DeskcommCRM设计与实践 我最早开始折腾DeskcommCRM这套系统是在上一家SaaS公司做内部客户管理改造的时候。当时团队对市面上一堆通用CRM的吐槽高度统一要么字段太死、改个状态都要提工单要么流程太重、销售根本不想每天点一堆按钮。后来我们干脆自己动手做了一套“桌面优先、沟通协作优先”的轻量级客户关系管理系统也就是DeskcommCRM。今天这篇就围绕这个项目把设计思路、核心模块、落地过程中的坑和排查经验一次性讲透。这套东西适合谁参考如果你正在规划企业内部客户管理系统或者想把分散在Excel表格、微信聊天、邮箱里的客户跟进信息收敛到一个统一平台那么DeskcommCRM的完整拆解会有直接参考价值。如果你是后端工程师或全栈开发想了解一个真实业务系统的数据模型设计和权限控制方案后面几节也可以直接抄作业。1. 项目整体设计与思路拆解1.1 为什么叫“Deskcomm”而不是“XX CRM”项目最初叫“内部客户登记表”后来越做越重才改名DeskcommCRM。Desk代表桌面端优先comm是communication的缩写。这个名字背后是一个很明确的产品判断销售和客户成功人员的日常工作大部分时间都坐在办公桌前处理信息包括回复消息、整理资料、看报表、录入跟进记录。如果CRM把重心放在移动端或复杂的工作流配置上反而会拉低效率。所以DeskcommCRM的核心体验设计是“桌面端为第一优先列表页密集高效详情页信息分层”。这和很多CRM产品一上来就做大而全的仪表盘不同。我们的目标是让使用者打开系统后三秒内就能看到今天该跟进谁、哪个客户状态有变化、哪些线索需要分配。1.2 核心场景与目标用户DeskcommCRM解决的实际问题有三个。第一线索来源乱。市场部从不同渠道投放带来的线索落到销售手里时经常缺少来源标记导致后续无法评估渠道ROI。第二跟进过程黑盒。销售有没有联系客户、聊了什么、客户什么态度管理者只能靠开会追问信息全部存在销售的个人微信和记事本里。第三数据口径不统一。“有效线索”的定义市场和销售各说各话客户阶段字段有人填“意向”有人填“考虑中”报表根本没法看。我们的目标用户是20到200人左右的B2B业务团队角色包括一线销售、客户成功、销售主管和市场运营。系统设计时不做复杂的多级审批流也不做自定义对象引擎而是把“客户档案、跟进记录、任务提醒、数据看板”这四个模块做到极致简单可用。1.3 技术选型与架构取舍技术选型这块当时有两个方案在团队里争论了几天。第一套是经典Java技术栈Spring Boot加MySQL稳妥但开发效率偏慢第二套是Node.js加MongoDB灵活但事务一致性要自己掌控。最后选了Python FastAPI加PostgreSQL理由很务实团队当时Python熟练度最高FastAPI的异步能力和自动API文档能显著缩短联调时间PostgreSQL对JSON字段、事务和全文检索的支持都够用不用再额外引入一套文档数据库。前端用的是React加Ant Design但做了一些桌面端的紧凑化改造。为什么不选更时髦的Vue3和Element Plus没有特别深奥的理由单纯是React生态里现有可复用的表格组件、看板组件更成熟能让我们把精力放在业务逻辑上而不是反复调UI。整体架构是典型的前后端分离。前端部署在Nginx上后端采用FastAPI进程数据库用PostgreSQL缓存用Redis。文件附件走本地磁盘加Nginx静态映射没有上对象存储因为初期文件量不大。下面是当时方案选型时做的对比记录。模块最终选型备选方案选择理由后端框架FastAPIDjango REST Framework异步性能好、自动文档、轻量数据库PostgreSQLMySQL、MongoDBJSON支持好、事务稳定、全文检索缓存RedisMemcached数据结构丰富、可做延迟队列前端框架React Ant DesignVue3 Element Plus表格/看板组件成熟、团队熟悉部署方式Docker ComposeKubernetes规模不大、维护简单2. 核心功能与模块实现细节2.1 客户数据模型把“字段自由”关进笼子很多团队做CRM时犯的第一个错误是想给客户对象配置几十个自定义字段。实际用下来一线销售根本不会填那么多。DeskcommCRM在设计客户表时只保留了几个核心字段客户名称、行业、规模、来源渠道、负责人、客户阶段、下次跟进时间、线索评分。剩下那些“公司地址、官网、成立年份”之类的信息统一放进一个JSONB类型的attributes字段。为什么这样做因为PostgreSQL的JSONB字段既支持灵活存储又可以建GIN索引做查询过滤。客户详情页左侧展示结构化核心字段右侧展示attributes里的扩展信息前端看起来反而更整齐。如果哪天真需要新增一个高频查询字段再把它提升为独立列也不迟迁移成本很低。客户阶段这个字段我们没有做成自由文本而是定义了一个枚举类型的stage包括新线索、已联系、意向确认、方案报价、谈判中、赢单、输单。所有候选值都可在后台配置但配置修改需要管理员权限防止销售随意加一个“待定”之类的脏数据。阶段变更记录会写入操作日志表方便主管查看某个客户是怎么一步步推进的。2.2 跟进记录与沟通流水唯一真相源CRM里最容易变成摆设的就是跟进记录模块。销售嫌打字麻烦管理者嫌信息太少。DeskcommCRM的策略是“非侵入式记录”销售不需要写长篇大论系统把每次跟进拆成几个结构化维度跟进方式电话、微信、邮件、上门、客户反馈态度感兴趣、一般、拒绝、未联系上、下一步计划。叙述性文字当作补充字段可填可不填。跟进记录表和客户表是多对一的关系每次跟进都会产生一条audit_log记录。这样做有三个好处。第一客户详情页可以直接按时间线展示跟进流水不需要额外做复杂聚合。第二管理者查看员工工作量时不需要人肉统计聊天记录直接SQL分组计数即可。第三如果销售在跟进时勾选了“下一步计划”系统会自动生成一条待办任务避免“聊完就忘”的尴尬。这里要特别说明一个设计决策跟进记录采用追加式写入不做编辑和删除。如果销售填错了只能新增一条更正记录。这样做虽然看起来不够灵活却能保证审计追溯的完整性尤其在后面对接邮件和通话记录时这个“不可变流水”的设计让数据可信度大幅提升。2.3 办公桌视图管线看板的另一种实现DeskcommCRM的办公桌视图是系统里最受欢迎的页面。它的形态类似Trello的看板但逻辑上不是简单的卡片拖拽。每一列代表一个客户阶段卡片代表一个客户卡片上展示客户名称、金额、下次跟进时间倒计时、最近一条跟进记录摘要。销售每天打开系统先看自己名下的看板就知道哪些客户该跟进了。看板数据来自实时聚合查询按owner_id过滤后分组stage。为了性能后端接口做了Redis缓存缓存key设计成user_id加看板维度缓存时间设为60秒。每次阶段变更或新增跟进时主动删除对应用户的看板缓存而不是等它自然过期。这样既保证数据基本实时又避免每次打开页面对数据库压一次全量分组查询。看板的卡片拖拽机制前端负责视觉效果真正生效靠的是dragEnd后调用更新阶段接口。接口内部会做状态机校验比如“赢单”状态不能直接拖回“新线索”必须经过“谈判中”等前置阶段。这种规则在UI层可以放开一些自由度但后端必须守住底线防止脏数据回流。2.4 轻量级权限模型搞定“谁能看到谁的客户”权限模型是CRM系统里最容易被低估的部分。DeskcommCRM没有采用RBAC的大而全方案而是用“角色加数据范围”两个维度组合成一套轻量权限模型。角色只有三种管理员、主管、普通员工。数据范围有三种全部数据、本部门数据、仅本人数据。普通员工默认只能看到自己负责的客户和线索主管可以看到本部门所有数据管理员看全部。这套逻辑写成一个依赖注入函数在查询客户列表和详情时自动拼接到SQL里。用SQLAlchemy的query filter实现代码量不大但能避免大部分越权访问问题。对于部门管理系统里设置了一个简单的department字段没有建完整的组织架构树。20到200人的团队一般不会出现超过三级的汇报关系部门层级建深了反而让权限判断变得复杂。部门主管查询本部门数据时用department_id等于自己部门ID这个条件就够了。权限这块有个非常容易踩的坑前端把按钮隐藏了后端却没有做拦截。比如销售页面不显示“删除客户”按钮但有人直接调DELETE接口一样可以删。DeskcommCRM的后端对所有写操作都做了角色校验不依赖前端做任何权限控制。前端隐藏只是提升体验后端权限校验才是安全边界。3. 实操过程与核心环节实现3.1 初始化项目与基础框架实际操作时我推荐用Docker Compose把依赖环境一次性拉起来。本地开发环境包括PostgreSQL、Redis和后端服务。后端项目的目录结构可以这样组织deskcomm-crm/ ├── app/ │ ├── api/ │ │ ├── v1/ │ │ │ ├── endpoints/ │ │ │ │ ├── customers.py │ │ │ │ ├── leads.py │ │ │ │ ├── auth.py │ │ │ │ └── dashboard.py │ │ │ └── router.py │ ├── core/ │ │ ├── config.py │ │ ├── security.py │ │ └── permissions.py │ ├── models/ │ │ ├── customer.py │ │ ├── lead.py │ │ ├── activity.py │ │ └── user.py │ ├── schemas/ │ ├── services/ │ └── main.py ├── scripts/ ├── docker-compose.yml └── requirements.txtFastAPI的主入口文件非常简洁主要工作是加载配置、初始化数据库连接、注册路由和中间件。一个实际可用的最小main.py可以写成这样from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.api.v1.router import api_router from app.core.config import settings from app.db.session import engine, SessionLocal app FastAPI(titlesettings.PROJECT_NAME) app.add_middleware( CORSMiddleware, allow_originssettings.BACKEND_CORS_ORIGINS, allow_credentialsTrue, allow_methods[*], allow_headers[*], ) app.include_router(api_router, prefixsettings.API_V1_STR)3.2 数据模型与迁移脚本客户和线索表是系统的核心。这里给出Customer模型的简化版本import uuid from sqlalchemy import Column, String, Integer, Date, Enum, JSON, DateTime, ForeignKey from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.sql import func from .base import Base class Customer(Base): __tablename__ customers id Column(UUID(as_uuidTrue), primary_keyTrue, defaultuuid.uuid4) name Column(String(200), nullableFalse, indexTrue) industry Column(String(100)) scale Column(String(50)) source_channel Column(String(100), indexTrue) owner_id Column(UUID(as_uuidTrue), ForeignKey(users.id), indexTrue) department_id Column(UUID(as_uuidTrue), indexTrue) stage Column(Enum(new, contacted, confirmed, quoted, negotiating, won, lost, namecustomer_stage), defaultnew) next_follow_up_at Column(DateTime(timezoneTrue), indexTrue) attributes Column(JSONB, defaultdict) score Column(Integer, default0) created_at Column(DateTime(timezoneTrue), server_defaultfunc.now()) updated_at Column(DateTime(timezoneTrue), onupdatefunc.now())这里需要提醒两个细节。第一主键用UUID而不是自增整数。原因很简单客户ID在URL和API里会直接暴露如果是自增ID竞争对手可以通过访问次数判断业务量UUID还能避免多客户端插入时的冲突问题。第二stage字段单独定义为枚举类型虽然比String类型在代码里写起来啰嗦一点但后续做报表统计时能省很多处理脏数据的麻烦。数据模型写好后用Alembic生成迁移脚本。建议一次规划好所有核心表的初始迁移不要今天加一张表明天加一个字段。刚开始做得随意后面迁移脚本会越来越乱。如果是老项目改造迁移前一定要备份数据哪怕本地环境也要习惯性备份。3.3 关键API逻辑线索去重、批量分配、阶段变更线索去重是CRM里必然遇到的问题。销售从客户那里拿到一个手机号或公司名称如果库里已经存在同一条线索重复录入会让后续统计翻倍。DeskcommCRM的做法是创建线索时用公司名称做精确匹配同时用手机号做前缀匹配如果发现疑似重复返回一条重复提示由销售决定是强行新建还是关联到已有客户。实现上不搞复杂的相似度算法只用数据库查询就能完成。线索表存储phone字段时统一格式去掉所有空格和中划线查询时直接比对。这个逻辑看起来简单但能拦截绝大多数重复录入。核心代码大概类似def create_lead(db: Session, lead_data: LeadCreate, current_user: User): # 校验权限 if not current_user.has_role(employee): raise PermissionDenied(当前用户无创建线索权限) normalized_phone normalize_phone(lead_data.phone) # 精确查重 existed db.query(Lead).filter( or_( Lead.company_name lead_data.company_name, Lead.phone normalized_phone ) ).first() if existed: return {code: -1, message: 疑似重复线索, existed_lead: existed.id} # ...批量分配的逻辑也很常见。主管勾选一批无主线索选择某个销售后一键分配。后端接口接收线索ID数组和目标销售ID用事务包裹确保要么全部成功要么全部回滚。分配时同时写入一条系统跟进记录方便销售知道这条线索是什么时候、由谁分配的。阶段变更接口需要做状态机校验。我写了一个简单的映射表ALLOWED_TRANSITIONS { new: [contacted, lost], contacted: [confirmed, lost], confirmed: [quoted, lost], quoted: [negotiating, lost, won], negotiating: [won, lost], won: [], lost: [new], }阶段变更时先判断新状态是否在合法列表里不在就直接拒绝并提示原因。同时记录stage_history表保留每个客户的历史阶段和变更时间为后续看板漏斗分析提供基础数据。3.4 看板数据的聚合查询办公桌看板的数据接口最直接的方式是遍历客户的stage枚举值然后分别统计每个阶段的客户数量和总金额。SQLAlchemy实现起来很直观from sqlalchemy import func from sqlalchemy.orm import Session def get_kanban_data(db: Session, user): filters build_permission_filters(db, user) rows ( db.query( Customer.stage, func.count(Customer.id).label(count), ) .filter(*filters) .group_by(Customer.stage) .all() ) result {stage.value: {count: 0, customers: []} for stage in CustomerStage} for stage, count in rows: result[stage.value][count] count return result这种聚合查询在数据量达到几十万条后可能变慢但前期完全够用。为了减轻数据库压力可以在查询前把Redis缓存命中逻辑加上。具体做法是缓存key包含userId和查询日期用户操作客户阶段后主动删除缓存。实测下来100人团队日常使用数据库负载稳定在很低的水平。3.5 与IMAP和通话记录的浅集成DeskcommCRM要对接邮件和通话记录大部分第三方CRM都是收费的这里我们采取了一个轻量的方案。邮件方面系统内置了一个定时任务通过IMAP协议拉取指定邮箱的邮件标题和发送人把它作为一条客户跟进记录展示。实现上不需要复杂的邮件解析库Python自带的imaplib加上email库就能处理基础需求。通话记录方面对接了企业微信或呼叫中心导出的CSV文件系统提供一个上传入口解析后按手机号匹配客户并写入对应的跟进流水。不追求实时同步因为呼叫平台通常有独立的报表系统CRM里只需要保留“什么时候联系过、结论是什么”这一层信息。这种浅集成的价值在于不花一分钱就能让客户详情页的时间线覆盖全渠道触点销售不用来回切换系统查看客户的历史沟通记录。如果团队规模变大后续可以单独开发针对指定协议的适配器架构上预留了扩展位。3.6 前端快速落地的思路与建议前端开发时不建议一上来就写完整的设计稿。先用Ant Design的ProLayout搭一个桌面端框架左侧菜单放“办公桌”“客户列表”“线索池”“数据报表”顶部放全局搜索和当前用户信息。客户列表页使用ProTable设置好列配置和筛选条件一周内就能做出可用程度不错的内测版本。办公桌看板用react-beautiful-dnd实现卡片拖拽再配合Ant Design的Card组件交互体验和视觉效果都能接受。这里要提醒一个重点拖拽卡片后调接口需要做防抖和乐观更新否则在弱网环境下销售把卡片拖到下一列后立刻看到它弹回原位体验会非常差。乐观更新的思路是拖拽成功后先修改本地状态前端把卡片先显示在新列里同时异步请求后端接口。如果接口失败再回滚到原列并弹出错误提示。这样用户操作反馈能快1到2秒体感好很多。4. 常见问题与排查技巧实录4.1 线索去重为什么总是漏我们第一次做线索去重时只匹配了公司名称结果发现同一条线索被完整录入了三次。排查后发现是销售录入时对同一家公司的写法不同比如“北京字节跳动科技有限公司”和“字节跳动”都算一条。后来改成同时匹配公司名称和手机号并统一手机号格式后重复率从18%降到了2%以下。这里还要注意一个情况同一客户可能在多个渠道留下了不同联系方式导致多条客户记录无法自动合并。解决方案是在客户详情页提供一个“合并客户”操作管理员可以把两个客户档案合并成一个同时保留双方的跟进记录。合并操作需要记录日志防止误操作后数据不可追溯。4.2 阶段变更后看板数字对不上看板数字对不上最常见的原因是阶段变更接口在更新客户表时没有同步更新stage_history表。导致客户实际阶段改了但历史分析报表按照旧阶段聚合出现了前后不一致。解决方法是把阶段变更和历史记录写在同一个数据库事务里保证要么都成功要么都失败。第二个原因是Redis缓存没有及时失效。用户改了阶段后看板还是旧数据需要等缓存过期才能看到最新结果。排查时先看Redis的key是否存在确认是缓存问题还是在应用层。解决方式是引入一个简单的事件监听在更新阶段时按用户维度删除看板缓存。4.3 数量多时的写操作超时当一个客户有多条关联跟进记录、附件文件、任务提醒时写操作会因为跨表处理变慢。这在PostgreSQL中非常常见其实不一定是慢查询而是事务中嵌套了比较耗时的外部调用。比如发送通知邮件时直接放在主事务里导致数据库连接长时间占用。建议把耗时子任务异步化用Redis列表或者Celery延迟队列处理。比如客户阶段变更后需要给主管发送消息通知这事完全可以在事务提交后异步执行。前端不需要等待邮件发送结果用户感知到的是阶段切换速度变快后端连接池的压力也减轻了。4.4 邮件同步的重复和乱码IMAP拉取邮件时经常遇到同一封邮件被重复拉取的情况因为IMAP协议中没有游标概念每次拉取都是全量对比。我的做法是存储邮件的Message-ID字段作为一个唯一索引每次拉取后先判断Message-ID是否已经存在存在就跳过。邮件正文乱码问题通常是编码解析没处理好。Python的email库获取正文时要注意根据Content-Transfer-Encoding字段判断是否base64或quoted-printable编码。处理时统一转成UTF-8能覆盖绝大多数场景。这个坑很多建议在开发前就写好编码解析的单元测试。4.5 权限检查必须放后端不能只靠前端路由权限方案上线两周后我们发现一个严重的越权漏洞有销售通过浏览器开发者工具修改了自己的前端角色信息然后尝试访问管理员接口幸运的是后端接口有校验没造成数据泄露。但这次事件让我们把权限检查又整体加固了一遍。现在的原则很明确前端路由守卫只负责页面显示的友好性所有接口调用后端统一通过依赖注入校验用户的角色和数据范围。列表查询、详情查看、字段更新、删除操作每一个接口都必须过权限校验不允许省事。常见问题可能原因排查方法解决方案线索重复录入公司名/手机号格式不统一查看录入样例和去重日志统一电话格式、双重匹配看板数字不一致忘记写stage_history或缓存未清查数据库事务和Redis缓存事务包裹、主动删缓存写操作超时事务内外部调用阻塞看慢查询日志、连接池状态异步化通知、拆分事务邮件重复/乱码Message-ID未去重、编码解析错误查看拉取日志和原始邮件头存储唯一Message-ID、统一转UTF-8权限越权前端隐藏但后端未校验用管理员Token调测试接口后端统一权限校验依赖写在最后半夜改完最后一个脚本我习惯性打开DeskcommCRM的看板把一周的数据盯了一遍。这套系统的价值不完全在功能而在于把一个团队原本散落的客户知识收纳成了一条清晰的流水线。每次看到销售在“已联系”和“意向确认”之间拖动着卡片我都觉得当初坚持“非侵入式记录”和“后端权限兜底”这两个决定做对了。如果你也在规划自己的CRM我建议先别急着堆功能。把客户模型想清楚把跟进流水管好把权限边界扎住这三个地方做扎实了系统已经能覆盖绝大多数业务场景。等用的人多了自然会发现更多需要补充的细节。保持迭代的心态比一开始做个完美的大东西更重要。
返回列表