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

资讯详情

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

自建轻量级CRM系统:技术选型、数据模型与Docker部署实践

自建轻量级CRM系统:技术选型、数据模型与Docker部署实践 有个场景大家一定不陌生客户联系方式躺在销售个人的Excel里报价单在微信聊天记录里翻半天商务催着要客户分析报告数据却散落在好几个人的电脑上。DeskcommCRM就是冲着这个痛点来的它不是那种一上来就要求你配齐销售、市场、客服全套流程的重型系统而是把客户档案、跟进记录、商机阶段和日常协作沟通捏合成一个闭环让一个三五十人的团队从第一天起就能把客户资产真正沉淀下来。本文想把这套系统的设计思路、数据模型、部署实操和踩坑记录一次讲透适合正准备自建CRM或者想从Excel微信切换到正规工具的技术负责人、产品经理和全栈开发者参考。1. 项目整体设计与选型思路1.1 为什么不做大而全而是先做客户生命周期闭环我见过不少团队一上来就要求CRM具备ERP一样的复杂逻辑结果项目拖了半年还在设计阶段。DeskcommCRM的定位从一开始就很明确搞定客户从线索到成交再到售后跟进的完整记录让每一次沟通都有迹可循让管理层能基于数据而不是感觉做判断。所以功能优先级是这样排的第一优先级客户档案、联系人、线索转客户、商机阶段、跟进记录第二优先级任务提醒、团队协作评论、操作日志、数据看板第三优先级工单/售后跟踪、自定义字段、导入导出、API接口明确不做财务记账、库存管理、复杂审批流这个取舍很关键。CRM的核心不是管理客户而是管理跟客户的每一次互动。你不需要在系统里算清楚每笔订单的利润率但你必须知道上周给哪个客户打了电话、对方当时怎么回应、下一步该推什么方案。1.2 技术栈选型为什么是这套组合DeskcommCRM技术选型参考了2024年以来中型项目的主流做法追求的是开发效率高、部署不折腾、团队容易招到人。层面选择理由后端Python FastAPI异步高并发处理能力够用类型注解清晰维护成本低前端Vue 3 Element Plus中文文档完善表格表单类后台界面开发效率极高数据库PostgreSQL 15支持JSONB字段适合自定义字段扩展事务能力强缓存/队列Redis缓存热点数据、异步任务队列、分布式锁部署Docker Docker Compose单机部署友好生产环境也能用不需要一开始就上K8s认证JWT RBAC前后端分离场景的标准方案权限控制灵活选FastAPI而不是Django是因为DeskcommCRM更偏API服务而非全栈框架FastAPI的自动API文档、Pydantic校验、依赖注入系统让接口开发速度明显更快。前端选Vue 3则考虑到团队成员上手门槛以及后台管理界面大量表格场景下Element Plus组件库的成熟度。1.3 架构设计与目录结构项目采用前后端完全分离架构RESTful API传数据JWT做身份认证。整体目录结构如下deskcomm-crm/ ├── backend/ │ ├── app/ │ │ ├── api/ # 路由层 │ │ │ ├── v1/ # 版本化API │ │ ├── models/ # SQLAlchemy模型 │ │ ├── schemas/ # Pydantic校验模型 │ │ ├── services/ # 业务逻辑层 │ │ ├── core/ # 配置、安全、依赖 │ ├── tests/ # pytest测试 │ └── requirements.txt ├── frontend/ │ ├── src/ │ │ ├── views/ # 页面组件 │ │ ├── components/ # 公共组件 │ │ ├── api/ # 接口封装 │ ├── package.json ├── docker-compose.yml └── nginx/ └── default.conf分层原则是路由层只做参数接收和数据格式转换业务逻辑全部下沉到services层模型层只定义数据表和关系映射。这样后面要加定时任务、对接第三方系统时可以直接复用services层的函数不用在路由里翻逻辑。2. 核心功能模块与关键数据模型2.1 客户与联系人的主数据设计做CRM最先要理清楚的就是数据模型。客户Account和联系人Contact在业务上是两个不同维度必须拆成独立表。客户是组织级对象有公司名称、行业、规模、所属区域联系人是个人对象有姓名、职位、电话、邮箱、微信。一个客户下面挂多个联系人这才能支持打单时对接客户公司的采购、技术、财务多个角色的真实场景。核心表设计如下class Account(Base): __tablename__ crm_account id Column(Integer, primary_keyTrue) name Column(String(200), nullableFalse, indexTrue) # 公司名称 industry Column(String(100), indexTrue) # 所属行业 scale Column(String(50)) # 公司规模 source Column(String(50), default手动录入) # 客户来源 owner_id Column(Integer, ForeignKey(sys_user.id)) # 负责人 status Column(String(20), defaultactive) # active/disabled created_at Column(DateTime, defaultdatetime.utcnow) updated_at Column(DateTime, defaultdatetime.utcnow, onupdatedatetime.utcnow) class Contact(Base): __tablename__ crm_contact id Column(Integer, primary_keyTrue) account_id Column(Integer, ForeignKey(crm_account.id), indexTrue) name Column(String(100), nullableFalse) title Column(String(100)) # 职位 mobile Column(String(30), indexTrue) email Column(String(100)) wechat Column(String(100)) is_primary Column(Boolean, defaultFalse) # 是否主联系人有个细节值得注意is_primary字段用来标记主联系人客户详情页默认展示的、推送邮件时的收件人、跟进记录里默认关联的人都是主联系人。如果没有这个标记每次展示都要纠结到底显示哪个联系人特麻烦。2.2 线索转客户的完整状态机线索Lead和客户Account并存是CRM行业的通行做法。线索是还没确认价值的原始信息可能来自留言板、展会名片、同事推荐客户是已确认有跟进价值的组织。系统里状态机设计为新线索 - 已联系 - 已确认 - 转客户 \- 已流失在线索转客户时要做三个原子操作创建Account记录、把线索的联系人信息迁移为Contact、将线索状态改为已转换。这三个操作必须在一个数据库事务里完成否则容易出现客户建好了但线索还挂在列表里下个星期重复转换出两个一模一样的客户。db.transaction() def convert_lead_to_account(lead_id: int, user_id: int): lead get_lead(lead_id) if lead.status converted: raise BusinessError(该线索已转换请勿重复操作) account Account( namelead.company_name, sourcelead.source, owner_iduser_id, ) db.add(account) db.flush() contact Contact( account_idaccount.id, namelead.contact_name, mobilelead.mobile, emaillead.email, is_primaryTrue, ) db.add(contact) lead.status converted状态机用数据库显式状态字段来控制不要用隐式判断比如通过有没有关联客户来判断是不是已转换。显式状态更好排查问题也方便后续做统计报表。2.3 商机阶段与跟进记录的动态组合商机Opportunity是CRM里跟钱最直接相关的对象。DeskcommCRM的商机阶段设计为基础阶段自定义阶段的组合默认提供五个阶段初步接触、需求确认、方案报价、商务谈判、赢单/输单。但这个不是写死在代码里的而是存放在配置表里管理员可以在后台调整阶段名称和顺序。跟进记录Activity是系统里最频繁写入的数据。每次电话、会面、微信沟通都建议补充一条跟进记录。跟进记录上关联三个关键字段客户ID、商机ID可空、下次跟进时间。下次跟进时间这个字段太重要了它是任务提醒的触发器。class Activity(Base): __tablename__ crm_activity id Column(Integer, primary_keyTrue) account_id Column(Integer, ForeignKey(crm_account.id), indexTrue) opportunity_id Column(Integer, ForeignKey(crm_opportunity.id), nullableTrue) contact_id Column(Integer, ForeignKey(crm_contact.id), nullableTrue) activity_type Column(String(30)) # call/meeting/email/wechat/other content Column(Text, nullableFalse) next_follow_up_at Column(DateTime, nullableTrue) owner_id Column(Integer, ForeignKey(sys_user.id)) created_at Column(DateTime, defaultdatetime.utcnow)很多团队嫌麻烦不愿意写跟进记录所以系统里要有催更机制。我在DeskcommCRM里做了一个简单的策略每天上午10点定时任务扫描所有商机如果某个商机最近7天没有新增跟进记录且状态不是赢单/输单就给负责人推一条待办提醒。实测下来这个策略让跟进记录完整率从40%提升到75%。3. 从零搭建与部署实操3.1 环境准备与依赖安装假设你拿到的是DeskcommCRM的完整代码仓库本地开发环境需要准备Python 3.11Node.js 18PostgreSQL 15Redis 7后端依赖集中在requirements.txt里核心依赖版本建议锁定避免某天升级后接口行为突变。cd backend python -m venv venv source venv/bin/activate pip install -r requirements.txt前端则是标准Vue工程cd frontend npm install npm run dev本地开发时前后端通过Vite代理转发请求把/api开头的请求代理到后端8000端口避免开发环境跨域问题。Vite配置里加一段proxy即可// vite.config.ts export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true } } } })3.2 环境变量与关键配置环境变量管理遵循12-factor原则不同环境的配置差异全部走环境变量不写死在代码里。核心配置项如下# backend/.env 示例生产环境请用密钥管理服务 DATABASE_URLpostgresql://crm_user:your_passwordlocalhost:5432/deskcomm_crm REDIS_URLredis://localhost:6379/0 JWT_SECRET_KEYyour-secret-key-must-be-long-enough JWT_ALGORITHMHS256 ACCESS_TOKEN_EXPIRE_MINUTES720 CORS_ORIGINShttp://localhost:5173,https://crm.example.comJWT_SECRET_KEY这个值必须足够长且不可猜测上线前务必更换默认值。实际生产环境中我建议用openssl rand -hex 32生成一个128位以上的随机密钥并且配置密钥轮换机制。后端配置类用Pydantic Settings实现# backend/app/core/config.py from pydantic_settings import BaseSettings class Settings(BaseSettings): database_url: str redis_url: str jwt_secret_key: str jwt_algorithm: str HS256 access_token_expire_minutes: int 720 cors_origins: str http://localhost:5173 property def cors_origin_list(self) - list[str]: return [origin.strip() for origin in self.cors_origins.split(,)] class Config: env_file .env settings Settings()Pydantic Settings会自动从.env文件读取配置并做类型校验如果环境变量缺失或类型不对服务启动时直接报错这种失败要越早暴露越好。项目里还有一处容易忽略CORS配置。千万别在开发环境图省事设置allow_origins[*]尤其当系统要登录、携带Cookie时通配符会被浏览器拒绝而且将来要接第三方登录时会有安全风险。3.3 Docker Compose一键部署生产环境生产环境部署用Docker Compose把后端、前端、PostgreSQL、Redis、Nginx五个服务编排在一起。下面是实际使用的compose文件关键部分# docker-compose.yml version: 3.8 services: db: image: postgres:15-alpine environment: POSTGRES_USER: crm_user POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_DB: deskcomm_crm volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, pg_isready -U crm_user] interval: 10s timeout: 5s retries: 5 redis: image: redis:7-alpine command: redis-server --requirepass ${REDIS_PASSWORD} volumes: - redis_data:/data backend: build: ./backend env_file: .env depends_on: db: condition: service_healthy redis: condition: service_healthy expose: - 8000 frontend: build: context: ./frontend args: VITE_API_BASE_URL: /api depends_on: - backend expose: - 80 nginx: image: nginx:1.27-alpine ports: - 80:80 - 443:443 volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: - backend - frontend volumes: postgres_data: redis_data:这里有几个生产环境中很关键的细节PostgreSQL容器配置了healthcheck后端容器会等待数据库真正就绪后再启动避免启动瞬间数据库还没初始化好导致连接报错。Redis设置了requirepass虽然是内网访问但默认无密码害了多少项目这个习惯必须养成。前端镜像构建时通过ARG注入VITE_API_BASE_URL这样前端代码里所有接口请求都可以用相对路径/apiNginx统一转发规避跨域。Nginx配置的核心思路是静态资源请求直接交给前端容器/api开头的动态请求转发给后端容器处理。按实际的Nginx配置节选关键部分# nginx/default.conf server { listen 80; server_name crm.example.com; # 前端静态资源gzip压缩提升加载速度 location / { proxy_pass http://frontend:80; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Real-IP $remote_addr; gzip on; } # 后端API动态请求 location /api/ { proxy_pass http://backend:8000; 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; client_max_body_size 20m; } }Nginx这一层还可以加limit_req限流、Access Log访问日志、SSL终止等能力职责边界要清晰Nginx只做网关和静态服务业务逻辑一律不要往里塞。3.4 初始化数据库与创建管理员账号服务启动后第一件事就是初始化数据库表结构和默认数据。项目里提供了一个CLI命令简化这个流程docker compose exec backend python -m app.cli init-db这个命令会做四件事创建所有数据表基于SQLAlchemy模型映射插入基础字典数据行业分类、客户来源、商机阶段创建默认角色管理员、销售经理、普通销售创建初始管理员账号默认admin/admin123首次登录强制改密提一句数据库迁移的问题。SQLAlchemy的create_all()适合首次建表但后面修改表结构时千万别依赖它因为不会自动变更已有表结构。项目应该在早期就接入Alembic做迁移管理alembic init migrations alembic revision --autogenerate -m add contact wechat field alembic upgrade head接入Alembic的正确时机是第一个模型稳定之后、第二个功能上线之前越早越好。如果等生产环境跑了一堆数据再考虑迁移问题那时候每个字段变更都是胆战心惊的操作。3.5 验证部署是否正常部署完成后要做一个全链路验证。我习惯按这个顺序检查访问首页看前端是否正常加载F12看有没有404的资源请求用管理账号登录看JWT是否正常签发接口是否返回200新建一条测试客户上传一个头像附件验证上传和存储路径修改商机阶段确认权限控制生效普通销售改不了别人负责的商机检查日志输出确认没有SQLAlchemy警告、Redis连接报错# 查看所有服务容器状态 docker compose ps # 跟着后端日志实时排查 docker compose logs -f backend # 检查数据库连接数确认是否被异常占满 docker compose exec db psql -U crm_user -c SELECT count(*) FROM pg_stat_activity;有一个我踩过几次的坑部署完发现前端页面能打开但登录时提示Network Error查了半天发现是后端容器内存被系统OOM Killer杀了。因为服务器内存只有2GPostgreSQL默认配置会吃掉不少内存再把后端和Redis挤上去就爆了。现在我在部署文档里都会特别提醒生产服务器内存建议4G起步如果要跑PostgreSQLRedis后端前端四个容器2G内存很紧张。4. 常见问题与排查技巧实录4.1 高频问题速查表现象可能原因排查方法登录后接口全部401JWT过期时间过短前端未做自动刷新检查ACCESS_TOKEN_EXPIRE_MINUTES配置确认当前凭证生成时间上传附件失败Nginx返回413client_max_body_size设置太小修改nginx配置中的该参数重启nginx容器客户列表打开慢缺少索引数据量大时全表扫描检查数据库慢查询日志给account表的owner_id、created_at加索引我创建的活动记录其他人看不到权限范围设置成了仅本人检查角色权限配置看是否有数据范围data scope控制Docker部署后数据库连不上容器内部网络DNS解析或者健康检查失败用docker compose exec backend ping db确认网络连通导出Excel接口超时数据量太大同步导出阻塞改为异步任务推送下载链接模式排查网络问题有个好习惯先分清是前端发不出请求还是后端没收到请求。前端浏览器F12的Network面板一目了然如果请求已经发出且后台有日志输出问题就在后端如果压根没有请求记录问题在前端配置或nginx转发规则。不要一上来就查后端代码能少走很多弯路。4.2 权限越权问题排查DeskcommCRM的权限模型采用RBAC基于角色的访问控制但光有角色还不够数据范围Data Scope也必须控制。实际运营中常见的需求是普通销售只能看到自己负责的客户销售经理能看到自己部门所有客户的记录管理员全库数据可见数据范围在代码里通过一个通用查询条件来实现def scope_account_query(user: User): 根据用户角色和部门返回可访问的客户ID过滤条件 if user.is_admin: return Account.id.isnot(None) if user.role.code sales_manager: return Account.owner_id.in_( select(User.id).where(User.department_id user.department_id) ) return Account.owner_id user.id接入这个逻辑后所有列表查询和详情查询都要走同一个过滤入口不能漏掉。最容易出问题的就是详情页和报表统计这两个地方。列表页通常会注意权限但详情页如果图省事用SELECT WHERE idxxx直接查就会导致知道URL就能看到别人客户的越权漏洞。做安全测试时一定要专门针对这两种接口覆盖测试用例。还有一点前端隐藏掉无权访问的按钮不等于安全后端API必须做真正的权限校验。前端隐藏只是体验优化后端校验才是安全底线。4.3 性能优化从列表加载3秒到800毫秒项目上线运行两个月后客户数据量到了10万级别列表页开始变慢。实测下来主要瓶颈有三个第一N1查询问题。客户列表页要显示每条的负责人名称、最新跟进时间、商机金额如果ORM逐条查询外键关联一次展示20条记录可能触发60多次SQL。解决方案是一次性用joinedload或selectinload把关联数据加载进来。# 优化前每次循环都查一次User表 # 优化后预加载关联字段 query ( select(Account) .options( selectinload(Account.owner), selectinload(Account.latest_activity), selectinload(Account.opportunities), ) .order_by(Account.updated_at.desc()) )第二缺少覆盖索引。原来的查询条件是WHERE owner_id ? ORDER BY updated_at DESC但单独的owner_id索引不足以支持排序。增加联合索引(owner_id, updated_at)排序查询直接走索引不用再做file sort性能提升明显。CREATE INDEX ix_account_owner_updated ON crm_account (owner_id, updated_at DESC);第三Redis缓存热点数据。像数据字典、行业选项这类几乎不变的配置第一次从数据库读取后放进Redis后续请求直接走缓存。这个改动不算大但列表页的响应时间削掉了近30%。优化后的针对一个5万客户规模、50个并发请求的压测场景列表接口P95延迟从2.8秒降到了800毫秒已经完全满足日常使用。5. 后续扩展方向与我的实操心得5.1 值得优先做的扩展DeskcommCRM跑稳之后有几个扩展方向是性价比比较高的。第一个是跟企业微信/钉钉的对接。销售大多数时间在即时通讯工具里如果把客户的沟通记录自动同步到CRM跟进记录的完整度会大幅提升。最轻量级的做法是提供Webhook接口收到外部IM应用推送的聊天记录后自动打上客户标签并生成活动记录。第二个是数据看板升级。目前的看板都是基于预聚合SQL查询比如本月新增客户数、商机转化率、各阶段金额汇总。当数据量再往上走可以引入ClickHouse或者直接用分组聚合的物化视图这样管理层看板就不需要每次去扫描全表。第三个是自定义字段。不同行业对客户有很多个性化信息要记录比如教育行业要学员年级SaaS行业要套餐版本。可以引入EAV模型或者用PostgreSQL的JSONB字段存扩展属性搭配一个字段管理界面让超管在后台动态加字段。5.2 做这类系统最值得记住的几件事这个项目从设计、开发到部署上线给我最大的教训是CRM系统本质上是一个习惯养成工具技术再漂亮如果一线销售不愿意用项目就是失败的。所以在做设计时永远把减少录入成本放在第一位。能用下拉框绝不用文本框能带出上次记录就绝不让用户重新填能自动创建跟进记录的操作就绝不要求手动补录。DeskcommCRM里我专门做了一个快捷记一笔入口——在任意页面按快捷键就能弹出一个记录跟进的小窗口默认带出当前客户和最近联系人用户只需要敲一句话、选个下次跟进时间整个过程不用切换页面。这个功能上线后活跃度涨了非常明显。另一个感受是权限设计宁可前期做细一点也不要后期补。有一个客户是中途提出希望能看其他人的客户但不要影响他们编辑的需求当时数据范围逻辑已经写得比较分散改起来很费劲。如果一开始就规划了角色数据范围两个维度的控制体系后续扩展会省很多事。最后提醒一点当你部署任何一套类似DeskcommCRM的客户管理系统时不要忽视备份策略。我见过太多团队把数据存在数据库里却从来没有做过恢复演练直到磁盘故障才开始慌。建议至少配置每日自动备份到异地并且每个月做一次真实的恢复测试。PostgreSQL的pg_dump配合cron任务花不了多少资源但它能在意外发生时保住团队几个月的心血。
返回列表