从0到1自研桌面端CRM:DeskcommCRM架构设计与实践解析

📅 发布时间:2026/10/19 9:26:26
从0到1自研桌面端CRM:DeskcommCRM架构设计与实践解析
1. 项目背景与目标定位1.1 为什么会做 DeskcommCRM先说清楚这是个什么东西。DeskcommCRM名字拆开看就是 Desk Communication CRM一套定位在桌面端的客户沟通与关系管理工具。说白了它解决的问题很具体销售和客服每天要在微信、企微、邮件、电话、工单系统之间来回切换客户信息散落在各个聊天记录和表格里跟进到哪一步全靠个人记忆协作起来一团乱麻。我们团队当时的情况是客户量从几百涨到几千靠 Excel 和微信群已经撑不住了。销售之间撞单、漏跟、重复联系的情况越来越频繁管理层也拿不到准确的转化数据。市面上的 CRM 要么太重要搭一堆流程才能用起来要么太贵按坐席按模块收费要么太死字段和流程改不动。几个核心诉求摆到桌面上要能快速记录每一次沟通、要把客户状态管起来、要能看出每个销售手上有多少单子在推进、要能导出数据做报表。调研了一圈最后决定自己做一个内部用的轻量级桌面 CRM这就是 DeskcommCRM 的由来。1.2 这套系统适合什么场景和团队如果你也是这种处境那 DeskcommCRM 的思路可以直接抄作业20 到 100 人规模的小型销售或客服团队目前还在用 Excel、共享表格管客户想要一个更规范的客户管理工具。已有企业微信或飞书作为主要沟通工具但聊天记录和客户档案是割裂的想统一沉淀。业务节奏快流程变化频繁需要一个能随时改字段、改状态的自定义系统而不是被固定模块绑死。有一定开发能力或愿意投入低代码平台想用最小的成本换来最大的掌控权。这个系统从设计第一天就没打算对标 Salesforce 那类重型产品。它的核心定位很朴素打开桌面端就能看到今天要跟谁、客户在什么阶段、上次聊了什么、下一步该做什么。把客户管理从“麻烦事”变成“顺手事”而不是让销售多填一堆表格。1.3 技术方案的生命周期选择这里要强调一个很容易被忽视的点工具选型必须考虑团队未来的演进能力而不是只看当下能不能用。我们选择走“自研 可替换”的路线有几个具体考虑第一市面上的低代码平台比如明道云、简道云确实能快速搭出客户表但一旦涉及企业微信消息同步、自定义报表、自动化提醒这类偏定制的能力后期往往要花数倍时间去绕过平台限制。第二开源 CRM比如 SuiteCRM功能完整但代码体系老旧学习成本和维护成本都不低改起来很痛苦。第三团队当时已经有不少开发积累与其被别人的规则约束不如用最熟悉的语言和技术栈搭一套自己的框架。后面整个系统的技术栈、数据模型、权限方案全部围绕“够用、灵活、可替换”这三个词来设计。这也是我认为 DeskcommCRM 最值得参考的地方——它不是某个具体技术有多牛而是整套取舍逻辑经得起推敲。2. 技术选型与架构设计思路2.1 后端框架与数据库选型技术栈这块我们锚定的是Python 3.11 FastAPI SQLAlchemy 2.0 PostgreSQL 15。后端之所以选 FastAPI 而不是 Django核心原因是团队当时主要做接口服务FastAPI 的 Pydantic 模型直接解决参数校验和序列化问题自带 OpenAPI 文档也让前后端联调快很多。Django 的 admin 后台虽然方便但它的 ORM 和权限体系对我们这种偏定制化的桌面端需求来说太重了。数据库选 PostgreSQL 几乎没什么悬念。客户数据虽然量不大几千客户但关联关系复杂客户、联系人、沟通记录、工单、跟进任务、附件这些表之间的关联查询和统计报表特别多。PostgreSQL 在复杂查询、JSON 字段灵活性方面都比 MySQL 舒服不少而且后面要做的全文检索可以直接用它的内置能力省掉额外部署 Elasticsearch 的运维成本。前端选型上我们用了Vue 3 TypeScript Element Plus桌面端外壳用 Tauri 而非 Electron。Tauri 打包体积小、内存占用少对销售每天要长时间挂机的场景来说体验差异很明显。如果你对桌面端没执念也完全可以只跑 Web 版本后端接口和前端组件天然支持两种形态共存。2.2 为什么采用模块化单体而不是微服务这个决策本来不该成为话题但确实看到不少团队在初期就为微服务付出了沉重代价。我们一开始就明确DeskcommCRM 在可见的几年内都是单体应用只在代码结构上做模块化拆分。客户模块、工单模块、消息模块、报表模块各自独立成目录未来如果某个模块流量大到需要拆出去再按模块边界拆成独立服务也不迟。单体架构带来的最大好处是开发调试极其顺畅事务管理不用考虑跨服务的一致性部署也只需跑一个容器。整个项目的目录保持清晰之后模块之间的依赖关系是显式的不会出现微服务那种“改一个字段要同步改三个服务”的窘境。对 20 到 100 人的团队来说单体就是效率最高的形态。2.3 核心抽象模型客户、联系人与活动的三角关系信息系统最关键的是数据模型这一块我前前后后改了三版。最初的设计和很多初版 CRM 一样把所有信息塞在一张客户表里后来发现销售在录入时会纠结“这条信息到底该放客户上还是联系人上”于是干脆重新梳理成经典的三角模型Customer客户指一个组织或公司承载基本信息、所属行业、来源渠道、整体状态。Contact联系人指客户组织下的具体个人承载姓名、职位、手机、微信、邮箱。Activity活动指每一次与客户的交互包括电话、邮件、面谈、微信消息关联到具体客户和联系人。这个模型在真实使用中有一个特别关键的体验提升销售不再需要区分“我是在跟公司聊还是在跟人聊”这件事。记录活动时系统通过选定的联系人自动带出所属客户所有统计报表按客户维度聚合。正因为这个设计后来做数据分析时几乎没有遇到“数据归谁”这种常见困境。数据库表结构上我们遵循了几个原则主键统一用 BIGINT 自增所有表都带 created_at、updated_at、deleted_at 三个时间字段。deleted_at 不是为了省空间而是为了满足审计需求——销售数据随时可能被复查和追溯。带 deleted_at 的查询都需要在 ORM 层处理好全局过滤这个坑后面会在常见问题里详细说。3. 核心功能模块拆解与实操要点3.1 客户档案从“录入负担”到“顺手沉淀”客户档案是 CRM 的门面但很多系统的档案录入流程是反人类的——二十多个字段全是必填销售看到就头大。我们的策略是必填字段只有三个客户名称、所属销售、来源渠道其余字段全部可空且通过“智能补充”按钮从公开工商信息和历史沟通记录中自动提取。举个具体的例子销售拿着一个电话号码打进来只要录入号码系统会自动回填客户名称、地址、行业等基础信息如果是老客户来电系统会直接弹出“该号码关联的客户档案”显示历史沟通记录和待办事项。这个设计极大降低了录入门槛数据质量反而不降反升——因为销售是在有动机的时候顺便把信息补全的而不是为了完成录入任务而刷数据。客户阶段这块我们没采用通用的“潜在客户-意向客户-成交客户-流失客户”模型而是根据自家业务拆成了八个阶段新线索、初步沟通、需求确认、方案提交、商务谈判、赢单、搁置、流失。每一条都用不同颜色标识。状态变更时支持自定义流转规则比如“赢单”必须填写成交金额并上传合同附件才能保存。给终态加必填校验这个细节太重要了它能保证后期的统计报表不会出现缺数据的尴尬情况。3.2 工单与任务让每一件待办有闭环客户管理不只是记录更重要的是跟进。我们的工单模块参考了经典 ITSM 的标准流程但做了简化核心只有五个字段标题、关联客户、负责人、到期时间、状态。状态机定义如下pending待处理- processing处理中- resolved已解决- closed已关闭 \- reopened重新打开这里有一个拿捏得很准的设计允许 reopen但只能从 resolved 回到 processing不能直接回到 pending。这样既保留了“客户可能不满意需要重新处理”的灵活性又避免工单在“待处理”和“处理中”之间无限循环让管理层的统计口径始终清晰。自动提醒也是工单模块的重头戏。我们接了一个轻量的计划任务组件APScheduler每天上午九点扫描所有未关闭且快到期的工单通过企业微信机器人把待办列表推送给对应负责人。实测下来工单的按期关闭率从接入提醒前的不到六成涨到了八成以上。这个效果说明很多时候问题不是销售不想干活而是没有人把最该做的事放到他眼前。3.3 沟通记录与消息归档从截图到结构化的转变沟通记录是整个系统里最吵争议的部分。销售们开始很抵触忙着跟客户聊哪有时间回系统补记录我们的突破口是让记录这件事尽可能自动化并降低人工录入成本。具体做了三件事企业微信会话存档接入通过企业微信的会话存档接口把销售与客户的企业微信聊天记录实时同步到系统自动关联到对应客户档案。这个功能上线后“聊天记录要不要补录”的争论直接消失因为记录已经自动存在了。快捷记录模板内置了几种高频场景模板比如“电话沟通”“线下拜访”“报价发送”模板里预置了字段和必填项销售点两下就能生成一条结构化记录。附件随手传聊天里收到的报价单、合同照片、客户证件支持直接转发到系统里归档。这里我们接的是对象存储的最小接口前端直传后端只做权限校验所以转存几乎无感。3.4 数据看板销售管理者真正需要的数字数据看板往往是面子工程的重灾区——一堆酷炫图表但看完不知道该干嘛。我们当时连BI工具都试用了一圈最终结论是这个阶段自研才是最合适的第三方 BI 导入导出数据要polling做不到当日实时而且数据隔离和权限控制不够细。最终我们自研了三层看板第一层今日总览。展示今日新增客户、待跟进的工单数、今日沟通次数、本月新增商机金额。这层是给销售自己看的规则是“打开系统第一眼就能判断今天该怎么安排时间”。第二层漏斗分析。按客户阶段统计数量与金额展示从线索到赢单的转化率。这层给销售主管看能直观找出瓶颈卡在哪个环节。第三层团队排行与趋势。按销售维度统计新增客户数、沟通频次、赢单金额并支持时间范围筛选。这层给管理层看但注意我们只展示趋势曲线和排名不做公开的“大家互相比较”的刺激型设计。自研看板时踩的一个坑是SQL 聚合查询在数据量上来之后会明显变慢尤其是按小时粒度统计时。最后的解法是引入了一个轻量物化视图每天凌晨定时刷新统计结果日常查询直接读视图把毫秒级响应变成了现实。这个方案比上实时数仓划算得多也够用。3.5 权限与审计内部系统也得有安全底线内部工具往往容易在安全上偷懒但 CRM 里的客户信息是团队核心资产这一点绝对不能含糊。我们采用了RBAC基于角色的访问控制数据库里就五张表用户表、角色表、菜单权限表、数据权限表和用户角色关联表。数据权限这里有个关键设计客户数据权限不是“全部可见”或“本人可见”的两极模式而是引入了“客户所属团队”概念。每个销售属于一个业务小组默认只能看到自己名下客户和本组客户如果需要跨组协作可以通过“共享客户”功能临时授权。这个折中方案既解决了数据孤岛问题又保护了销售的核心私域实测落地效果很好。审计日志方面我们记录了所有敏感操作的日志客户档案的创建、修改、删除、导出以及工单状态的变更。字段只记录操作人、操作时间、操作类型、操作前后值。在真正发生“客户被离职销售带走”的事情之前你可能很难理解审计日志的价值但一旦发生这套日志就是你的保护伞。4. 实操过程与关键实现细节4.1 环境搭建与项目初始化环境这一步说得实际一点开发机上建议直接装 Docker DesktopPostgreSQL 用官方镜像跑起来最省心# 拉取 PostgreSQL 15 镜像并启动 docker run -d \ --name deskcomm-postgres \ -e POSTGRES_USERdeskcomm \ -e POSTGRES_PASSWORDyourpassword \ -e POSTGRES_DBdeskcomm \ -p 5432:5432 \ -v deskcomm_pgdata:/var/lib/postgresql/data \ postgres:15-alpine后端项目初始化用 uv 管理依赖对 Python 环境的管理体验比 pip venv 好很多强烈推荐# 安装 uvmacOS / Linux curl -LsSf https://astral.sh/uv/install.sh | sh # 初始化项目 uv init deskcomm-backend cd deskcomm-backend uv add fastapi uvicorn sqlalchemy psycopg[binary] pydantic-settings alembic apscheduler4.2 数据模型定义以客户和工单表为例下面给出两个最核心表的 SQLAlchemy 模型定义风格上我们统一使用 Mapped 注解式写法类型安全可读性好from datetime import datetime, date from typing import Optional from sqlalchemy import String, Date, Boolean, ForeignKey, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from app.core.database import Base class Customer(Base): __tablename__ customers id: Mapped[int] mapped_column(primary_keyTrue) name: Mapped[str] mapped_column(String(200), indexTrue) industry: Mapped[Optional[str]] mapped_column(String(100)) source_channel: Mapped[Optional[str]] mapped_column(String(50)) stage: Mapped[str] mapped_column(String(30), defaultnew_lead) owner_id: Mapped[Optional[int]] mapped_column(ForeignKey(users.id)) owner_team_id: Mapped[Optional[int]] mapped_column(ForeignKey(teams.id)) address: Mapped[Optional[str]] mapped_column(Text) website: Mapped[Optional[str]] mapped_column(String(300)) deal_amount: Mapped[float] mapped_column(default0.0) remark: Mapped[Optional[str]] mapped_column(Text) created_at: Mapped[datetime] mapped_column(defaultdatetime.now) updated_at: Mapped[datetime] mapped_column(defaultdatetime.now, onupdatedatetime.now) deleted_at: Mapped[Optional[datetime]] mapped_column(defaultNone) contacts: Mapped[list[Contact]] relationship(back_populatescustomer) activities: Mapped[list[Activity]] relationship(back_populatescustomer) class Ticket(Base): __tablename__ tickets id: Mapped[int] mapped_column(primary_keyTrue) title: Mapped[str] mapped_column(String(200)) customer_id: Mapped[int] mapped_column(ForeignKey(customers.id), indexTrue) contact_id: Mapped[Optional[int]] mapped_column(ForeignKey(contacts.id)) assignee_id: Mapped[Optional[int]] mapped_column(ForeignKey(users.id), indexTrue) status: Mapped[str] mapped_column(String(30), defaultpending, indexTrue) priority: Mapped[str] mapped_column(String(20), defaultmedium) due_at: Mapped[Optional[datetime]] mapped_column(indexTrue) resolved_at: Mapped[Optional[datetime]] mapped_column(defaultNone) closed_at: Mapped[Optional[datetime]] mapped_column(defaultNone) created_at: Mapped[datetime] mapped_column(defaultdatetime.now) updated_at: Mapped[datetime] mapped_column(defaultdatetime.now, onupdatedatetime.now) deleted_at: Mapped[Optional[datetime]] mapped_column(defaultNone)这里补充一个细节所有表的主键我们统一叫id不要用customer_id之类作为主键名否则联表查询时 ORM 生成的条件可读性会很差。外键字段命名带业务语义比如owner_id但主键永远只是id这个小规范能让 SQLAlchemy 自动关联查询时少掉不少头发。4.3 工单状态流转的实现控制谁能在什么时候改什么状态机不只是定几个枚举值就完了关键是要把流转规则落地成代码。我们的实现基于一张配置表来定义允许的变迁路径from enum import Enum class TicketStatus(str, Enum): PENDING pending PROCESSING processing RESOLVED resolved CLOSED closed REOPENED reopened # 定义状态机每个当前状态允许跳转到的目标状态 STATUS_TRANSITIONS { TicketStatus.PENDING: {TicketStatus.PROCESSING, TicketStatus.RESOLVED}, TicketStatus.PROCESSING: {TicketStatus.RESOLVED, TicketStatus.PENDING}, TicketStatus.RESOLVED: {TicketStatus.CLOSED, TicketStatus.PROCESSING}, TicketStatus.CLOSED: set(), TicketStatus.REOPENED: {TicketStatus.PROCESSING, TicketStatus.RESOLVED}, }流转校验逻辑写在服务层而不是模型层的save方法里。为什么因为服务层可以拿到操作人、操作上下文能同时做权限判断和业务校验。下一版我们计划把状态机配置移到数据库里做成可视化流程设计器但目前配置表硬编码已经能满足需求不提前做过度设计。4.4 看板核心查询物化视图实战销售漏斗看板的核心查询最朴素也是最好理解的方式就是按状态分组统计。数据量不大时直接跑原始 SQL 没问题但上了物化视图之后查询从几百毫秒降到了十几毫秒体验提升非常明显-- 创建客户阶段分布的物化视图 CREATE MATERIALIZED VIEW mv_customer_stage_stats AS SELECT owner_team_id, owner_id, stage, COUNT(*) AS customer_count, SUM(deal_amount) AS total_amount, DATE_TRUNC(day, created_at) AS stat_date FROM customers WHERE deleted_at IS NULL GROUP BY owner_team_id, owner_id, stage, DATE_TRUNC(day, created_at); -- 手动刷新配合 APScheduler 每天凌晨执行 REFRESH MATERIALIZED VIEW mv_customer_stage_stats;刷新时机这里我们有讲究凌晨 3 点刷新避开销售上班高峰期。真要做实时看板可以让刷新间隔压缩到 5 分钟不过当前业务没有这个需求不增加系统负担。4.5 企微会话存档对接要点这块是 DeskcommCRM 比较“人无我有”的亮点但也是实施过程中踩坑最多的部分。核心流程不复杂企业微信管理后台开启会话存档功能获得密钥和证书然后通过回调接口接收加密的消息流解密后解析出文本、图片、文件等消息。有几个非常关键的注意点消息解密必须用企业微信提供的 RSA2048 密钥这个密钥在管理后台只能下载一次下载后要立刻放到私有的配置中心别直接写死在代码里或者放到 Git 仓库。回调接口必须快速应答。企业微信的服务端要求 5 秒内响应否则会判定投递失败频繁失败还会触发限流。我们在回调接口里只做消息持久化不做复杂业务处理把消息放进一个内部队列后台任务再异步解构和关联客户。消息去重。企微的投递有“至少一次”语义同一条消息可能重推所以数据库里必须对消息 ID 建唯一索引否则跑一天就会发现一堆重复记录。这部分代码涉及加密和解密细节篇幅太长就不全贴了建议直接参考企业微信官方文档的 Java 和 Python 样例代码不要自己造轮子。5. 常见问题与排查技巧实录5.1 时区错乱的定位与解决上线第一周我们就遇到了客户反馈工单到期时间是下午三点但提醒早晨七点就发了。排查下来是 PostgreSQL 默认存储的 timestamp 不带时区而 FastAPI 传入的是带时区的 datetime经过 SQLAlchemy 写入后时区信息被丢弃读取时又按 UTC 解析导致时间偏移。这个问题的统一解决方案是在数据库连接串中强制指定时区参数且在应用层统一使用 UTC 存储展示层转换成本地时区。简单来说存的时候全存 UTC显示的时候再转北京时间。这比改 PostgreSQL 的 timezone 配置更稳妥因为数据库迁移到云厂商时配置又会变。5.2 软删除带来的唯一索引冲突我们的客户表用了软删除deleted_at客户名称上有唯一索引结果就出现了一个很经典的坑把一个客户标记为“流失”逻辑删之后下次再新增同名客户唯一索引直接冲突。数据库并没有把已删除的行当作“不存在”——它确实还在表里。解决思路是给唯一索引加上条件用 PostgreSQL 的部分索引partial indexCREATE UNIQUE INDEX uk_customers_name_active ON customers(name) WHERE deleted_at IS NULL;这个方案唯一的限制是如果两次逻辑删除后又要恢复其中一个客户会存在两个活跃同名客户并存的边缘情况。业务上的约定是出现这种情况时必须先把原客户彻底删除或改名再新增新客户。这个约束条件写在了接口文档里上线至今还没出现过让人头疼的纠纷。5.3 工单计数器的并发问题工单编号我们想按照“TC-2025-0001”这种格式连续生成一开始直接查当前最大值加一并发场景下就出现了重复编号。这个问题的标准解法有几种。我们最终采用的是独立计数器表 行锁SELECT FOR UPDATE保证同一时刻只有一个事务能拿到最新的序号并递增它。CREATE TABLE ticket_sequence ( id INTEGER PRIMARY KEY, prefix VARCHAR(10) NOT NULL, current_value INTEGER NOT NULL DEFAULT 0 ); -- 在分配编号前执行 UPDATE ticket_sequence SET current_value current_value 1 WHERE id 1 RETURNING current_value;UPDATE ... RETURNING是原子的天然带锁不用额外写 SELECT FOR UPDATE 再更新代码更干净。从实测效果看并发 200 的压测下编号依然唯一且连续完全够用。5.4 权限变更后不生效的排查有段时间销售反馈某个客户被共享给同事了但同事还是看不到。查下来发现是前端项目里做了客户列表的本地缓存用 LocalStorage 存了一份导致每次下拉刷新拿到的是旧数据。权限验证的逻辑本身没问题问题出在缓存策略上。最终的解决方案是客户列表只做内存级缓存且所有涉及权限变更的接口都会返回一个权限版本号前端监测到版本号变化就主动失效缓存。局部改动很小但彻底解决了这个“幽灵权限缓存”问题。5.5 搜索变慢后的临时救火方案客户名、联系人、手机号这几个字段是使用频率最高的搜索条件。到两万条数据后搜索明显卡顿。实测最快的救火方案是 PostgreSQL 自带的 pg_trgm 模糊搜索索引CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX idx_customers_name_trgm ON customers USING gin (name gin_trgm_ops);这比直接加普通 B-tree 索引和LIKE %xxx%查询的效果好得多。当然如果数据量再上一个量级比如几十万客户那时候可以考虑上专门的全文检索组件但在那之前先把手头的工具用透才是正道。6. 从 0 到 1 做内部工具的建议这里分享几个基于 DeskcommCRM 开发过程的体会希望你在做同类内部系统时能少走弯路。首先是尽早给系统建立一套统一的全局搜索入口。销售人员在日常对话中最常做的是输入客户名或者手机号立刻调出档案而不是在菜单里一层层点。我们早期没有做全局搜索销售用起来总觉得“慢半拍”。补上之后系统的真实使用率提升明显——评价一个内部系统好不好用看大家打开频率就知道了。其次是把“导数据”当成一等公民来设计。每个模块的关键列表页都加了导出 Excel 的按钮导出时的权限校验和操作审计同样打通。销售主管跟老板汇报时最省事的方式就是导出一份最新状态表。这个小功能管理价值极高但开发成本极低强烈建议排在计划前面。再次是日志和审计功能从一开始就要做不要等出问题才补。内部工具很容易在安全上偷懒但 CRM 是敏感数据集中地。至少要做到谁在什么时间改了客户名、谁导出了全部客户、谁把工单状态从已关闭改成处理中——这些记录是团队的护城河也是未来做合规审查的唯一凭据。关于运维多说一句不要一开始就用 k8s。一个内部工具Docker Compose 撑起来已经绰绰有余。应用容器、PostgreSQL 容器、Redis 容器三个服务一编排任何一台服务器都能跑备份恢复都很直接。等到用户规模真的上来了再考虑容器编排也不迟初期别把精力耗在基础设施上。最后如果让我重新做一遍 DeskcommCRM最优先改进的会是打通更多外部沟通渠道的消息引入。目前只接入企业微信但不少重要客户还在用个人微信、邮件甚至电话。如果能把这些渠道的消息也统一归档进客户时间线那客户画像的完整度会再上一个台阶。这可能是这类桌面沟通型 CRM 未来的一个明显演进方向。