1. 为什么我要自己动手写一套轻量CRM
市面上的客户管理系统我前前后后试过不下十款,从开源的套件到按年付费的SaaS,功能列表一个比一个长,但真正落到日常使用场景里,八成以上的功能我压根没点开过。销售漏斗、自动化营销、AI预测这些听起来很唬人,可对于一个三五人的小团队或者独立接单的自由职业者来说,核心诉求其实就三件事:记住每个客户是谁、上次聊到哪了、下一步该干什么。为了这三件事去养一套重型系统,纯属给自己找罪受。
所以我决定自己写一套简单版本的CRM。所谓“简单版本”,不是功能残缺,而是只保留最核心的客户管理闭环,去掉一切为了显得专业而堆砌的模块。这套系统最终实现的效果是:打开浏览器就能用,客户信息一目了然,跟进记录随手可查,待办提醒不会漏。整个项目从设计到跑通,我花了大概一周的业余时间,代码量控制在两千行以内,部署在一台最低配的云主机上绰绰有余。
这篇文章我会把整套设计思路、技术选型、数据库结构、核心代码实现、以及我在实际使用中踩过的坑,全部摊开来讲。适合谁看?如果你是个体从业者、小团队负责人、或者刚入门的开发者想拿一个真实项目练手,这套方案可以直接抄作业。我不打算讲什么高深架构,就讲怎么用最朴素的技术解决最实际的问题。
2. 整体设计思路与技术选型
2.1 核心需求拆解:到底要管什么
在动手写代码之前,我拿纸笔列了整整两页的需求,然后一条条划掉,最后剩下的才是真正要做的。这个过程很痛苦,但非常值得。最终确定的核心需求只有四条:
- 客户档案管理:记录客户的基本信息,包括姓名、联系方式、来源渠道、当前状态。状态我简化为“潜在、跟进中、已成交、已流失”四种,再多就是给自己找麻烦。
- 跟进记录:每次和客户沟通后,能快速记一笔,包括沟通时间、方式、内容和下一步计划。这是CRM的灵魂,没有跟进记录的CRM就是个通讯录。
- 待办提醒:根据跟进记录里的“下一步计划”,自动生成待办事项,到期在首页提醒。这个功能让我再也没漏掉过回访。
- 简单统计:首页能看到客户总数、本月新增、本月成交、待跟进数量这几个数字。够用了,饼图柱状图都是虚的。
至于权限管理、多租户、自定义字段、工作流引擎这些,我全部砍掉。一个人用或者小团队用,大家共享一套数据就行,搞那么复杂纯属内耗。
2.2 技术选型:为什么选这套“老土”的组合
技术选型上我刻意避开了时髦的方案。后端用Python + Flask,数据库用SQLite,前端用服务端渲染的Jinja2模板 + 少量原生JavaScript,CSS用Water.css这个极简框架。整套东西没有Node.js构建步骤,没有Docker,没有Redis,没有消息队列。
为什么这么选?理由很直接:
- Flask足够轻,一个文件就能跑起来,路由和视图函数一目了然。我不需要Django那种大而全的框架,它的Admin和ORM对我来说太重了。
- SQLite是单文件数据库,备份就是复制一个文件,迁移就是拷贝走。对于数据量在十万条以内的场景,性能完全够用。我实测过,五千个客户加上五万条跟进记录,查询响应都在毫秒级。
- 服务端渲染意味着我不需要写API、不需要处理跨域、不需要管理前端状态。表单提交后刷新页面,简单粗暴但极其可靠。
- Water.css是一个无需class的CSS框架,引入一个链接就能让页面变得干净整洁。我不需要设计师,也不需要调样式调到半夜。
提示:这套选型的前提是你的用户量在几十人以内,数据量在十万条以内。超过这个量级,SQLite的写入并发会成为瓶颈,到时候再换PostgreSQL也不迟,Flask的ORM层改起来很快。
2.3 数据库表结构设计:三张表搞定
整个系统只需要三张表,我反复推敲过字段,确保每个字段都有明确的用途,没有一个是“以后可能用得上”的。
客户表(customers):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| name | TEXT | 客户姓名,必填 |
| phone | TEXT | 电话,可选 |
| TEXT | 邮箱,可选 | |
| source | TEXT | 来源渠道,如“转介绍”“线上咨询” |
| status | TEXT | 状态:potential/following/won/lost |
| note | TEXT | 备注,记录客户偏好等 |
| created_at | DATETIME | 创建时间 |
| updated_at | DATETIME | 最后更新时间 |
跟进记录表(follow_ups):
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键,自增 |
| customer_id | INTEGER | 外键,关联客户 |
| method | TEXT | 沟通方式:电话/微信/邮件/见面 |
| content | TEXT | 沟通内容摘要 |
| next_action | TEXT | 下一步计划 |
| next_date | DATE | 下一步计划日期,可为空 |
| created_at | DATETIME | 记录时间 |
待办表(todos):这张表其实可以省略,直接从跟进记录里查next_date小于等于今天的记录即可。但我还是单独建了一张表,原因是有些待办不来自跟进记录,比如“整理合同模板”这种内部事务。表结构很简单:id、title、due_date、done、created_at。
三张表之间的关系非常清晰:一个客户有多条跟进记录,一条跟进记录可能生成一条待办。没有多对多,没有自关联,新手也能看懂。
3. 核心功能模块的实操实现
3.1 客户列表页:搜索、筛选与分页
客户列表是使用频率最高的页面,我的设计目标是:三秒内找到目标客户。页面上方是搜索框,支持按姓名和电话模糊搜索;下方是状态筛选标签,点击“跟进中”就只显示跟进中的客户;再下面是分页列表,每页二十条。
搜索的实现我用的是SQLite的LIKE语句,虽然效率不如全文索引,但在几千条数据下完全无感。代码大概长这样:
@app.route('/customers') def customer_list(): keyword = request.args.get('q', '').strip() status = request.args.get('status', '') page = request.args.get('page', 1, type=int) query = Customer.query if keyword: query = query.filter( db.or_( Customer.name.like(f'%{keyword}%'), Customer.phone.like(f'%{keyword}%') ) ) if status: query = query.filter(Customer.status == status) pagination = query.order_by(Customer.updated_at.desc()).paginate( page=page, per_page=20, error_out=False ) return render_template('customers.html', pagination=pagination, keyword=keyword, status=status)这里有个细节值得说:排序用的是updated_at降序,而不是created_at。为什么?因为最近有互动的客户应该排在最前面,这样我打开列表就能看到活跃客户,而不是被一堆陈年旧客淹没。
注意:
LIKE查询在数据量超过五万条时会明显变慢。如果你预计客户量会很大,可以在SQLite上启用FTS5全文索引,或者干脆换PostgreSQL用pg_trgm扩展。但说实话,小团队一年能积累五千个客户就算不错了。
3.2 客户详情页:信息聚合与快速操作
点进一个客户,详情页要在一屏内展示所有关键信息。我的布局是:左侧是客户基本信息和状态切换按钮,右侧是跟进记录时间线,时间线顶部有一个“添加跟进”的快捷表单。
状态切换我用了四个按钮,点击后通过一个简单的POST请求更新状态,页面刷新后按钮高亮当前状态。这个交互很土,但比下拉框选完再点保存要快得多。代码实现就是一个路由:
@app.route('/customer/<int:id>/status', methods=['POST']) def update_status(id): customer = Customer.query.get_or_404(id) new_status = request.form.get('status') if new_status in ['potential', 'following', 'won', 'lost']: customer.status = new_status customer.updated_at = datetime.now() db.session.commit() return redirect(url_for('customer_detail', id=id))跟进记录的时间线用了一个简单的循环渲染,每条记录显示沟通方式图标、内容摘要、下一步计划和记录时间。如果next_date已经过期,这条记录会标红提醒。这个视觉提示非常有效,我一眼就能看出哪些客户的跟进已经拖延了。
添加跟进的表单我做了防重复提交处理:提交按钮点击后变成禁用状态并显示“提交中”。这个细节看似不起眼,但如果没有,网络稍慢时用户连点两下就会产生两条重复记录。我踩过这个坑,后来在表单提交的JavaScript里加了一行this.disabled = true就解决了。
3.3 待办提醒:让跟进不再遗漏
待办提醒是我认为这套系统最有价值的功能。逻辑很简单:每天第一次打开首页时,系统查询所有next_date <= 今天且未完成的待办,以及所有next_date <= 今天的跟进记录,合并后显示在首页顶部。
实现上我写了一个get_today_todos()函数:
def get_today_todos(): today = date.today() # 来自跟进记录的待办 follow_todos = db.session.query(FollowUp).filter( FollowUp.next_date <= today, FollowUp.next_date.isnot(None) ).order_by(FollowUp.next_date.asc()).all() # 来自手动添加的待办 manual_todos = Todo.query.filter( Todo.due_date <= today, Todo.done == False ).order_by(Todo.due_date.asc()).all() return follow_todos, manual_todos首页模板里,这两类待办分开展示,每条待办旁边有一个“完成”按钮。点击后,跟进记录生成的待办会把next_date置空,手动待办则把done设为True。这样待办列表就会自动清理,不会越积越多。
提示:我建议把
next_date设置为可空。有些跟进确实没有明确的下一步时间,强行填一个日期只会制造假待办。空着就空着,等有明确计划时再填。
3.4 数据导入导出:迁移与备份的保命手段
虽然系统简单,但数据是无价的。我做了两个保命功能:CSV导出和CSV导入。
导出功能把所有客户和跟进记录导出成一个CSV文件,字段用逗号分隔,编码用UTF-8 with BOM,这样用Excel打开不会乱码。导入功能支持从CSV批量创建客户,我预设了一个模板格式,用户填好后上传即可。导入时做了去重判断:如果姓名和电话都相同,就跳过不重复创建。
这两个功能代码量不大,但给了我极大的安全感。万一哪天服务器挂了,我手里还有一份CSV,换个地方十分钟就能重新跑起来。
4. 实际使用中踩过的坑与排查技巧
4.1 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 页面打开空白 | 模板变量未传或拼写错误 | 查看Flask控制台报错 | 检查render_template的参数 |
| 中文显示乱码 | 数据库编码或响应头问题 | 检查HTML的meta charset | 确保UTF-8贯穿始终 |
| 提交表单后数据没保存 | 数据库会话未提交 | 查看是否有db.session.commit() | 补上提交语句 |
| 搜索无结果 | LIKE语句大小写敏感 | 用SQLite命令行测试 | 统一转小写或用COLLATE NOCASE |
| 待办不显示 | 日期比较逻辑错误 | 打印today和next_date的值 | 确保都是date类型 |
| 部署后静态文件404 | 路径配置错误 | 查看浏览器网络面板 | 用url_for生成静态路径 |
4.2 三个让我印象深刻的坑
第一个坑:SQLite的日期类型。我一开始把next_date存成了字符串,结果比较的时候“2024-1-5”和“2024-01-05”被当成不同的值,导致待办提醒时有时无。后来统一用Python的date对象存入,SQLAlchemy会自动处理格式,问题才解决。这个坑让我明白,日期字段一定要用数据库的日期类型,不要图省事存字符串。
第二个坑:并发写入。有一次我同时开了两个浏览器标签页,一个在添加跟进,一个在修改客户状态,结果后提交的那个把前一个的修改覆盖了。SQLite的默认隔离级别是SERIALIZABLE,但Flask-SQLAlchemy的会话管理在并发下还是会有问题。我的解决方案很简单:所有写操作都加一个全局锁,虽然粗暴,但对于单用户或小团队场景完全够用。具体就是在写操作的路由上加一个threading.Lock()。
第三个坑:备份策略。我一开始觉得SQLite就是个文件,直接复制就行。后来发现如果复制的时候正好有写入操作,复制出来的文件可能损坏。正确的做法是用SQLite的.backup命令,或者先停掉应用再复制。我现在设置了一个每天凌晨三点的定时任务,用sqlite3命令的.backup功能生成备份文件,保留最近三十天。
4.3 几个提升效率的小技巧
- 快捷键提交:在跟进记录表单里,我加了
Ctrl+Enter提交的快捷键。写跟进记录时手不用离开键盘,效率提升明显。 - 默认值填充:添加跟进时,沟通方式默认选中“微信”,因为这是我最常用的方式。减少一次点击就是节省一秒。
- 颜色标记:客户状态用不同颜色标记,潜在是灰色,跟进中是蓝色,已成交是绿色,已流失是红色。列表页一眼扫过去就能判断客户分布。
- 快速搜索:搜索框支持输入后自动聚焦,页面加载完光标就在搜索框里,直接打字就行。
5. 部署与日常维护的实操建议
5.1 最低成本部署方案
部署我选的是最朴素的方案:一台最低配的云服务器,装Python和Nginx,用Gunicorn跑Flask应用,Nginx做反向代理。没有Docker,没有Kubernetes,没有CI/CD。部署步骤就四步:
- 把代码传到服务器
- 创建虚拟环境并安装依赖
- 用Gunicorn启动应用,绑定到本地端口
- 配置Nginx转发请求到Gunicorn
Nginx配置里我加了Basic Auth,因为这套系统没有登录功能,但直接暴露在公网又太危险。Basic Auth虽然简陋,但配合HTTPS足够安全。HTTPS证书用Let's Encrypt免费申请,自动续期。
注意:如果你打算多人使用,Basic Auth就不够了,需要加一个简单的用户表。但我的建议是,小团队直接用一套共享账号就行,没必要搞复杂的权限体系。
5.2 日常维护清单
这套系统我维护了半年多,总结了一个每周花十分钟就能完成的维护清单:
- 检查备份文件:确认每天的备份任务正常执行,随机打开一个备份文件验证完整性。
- 清理无用数据:把“已流失”超过一年的客户归档,减少列表页的干扰。
- 更新依赖:每月检查一次Python包的安全更新,用
pip list --outdated查看。 - 查看日志:检查Gunicorn的错误日志,看看有没有异常请求或报错。
这套系统最大的优势就是维护成本极低。没有复杂的中间件,没有需要调优的参数,没有半夜告警。它就像一个安静的记事本,需要的时候打开,不需要的时候完全不打扰我。
5.3 后续可以扩展的方向
虽然我说“简单版本”,但并不意味着永远不扩展。如果将来需求变了,我会按这个优先级来加功能:
第一优先级是邮件集成,把客户邮件自动关联到对应客户的时间线。第二优先级是简单的数据看板,用Chart.js画一个月度成交趋势图。第三优先级是移动端适配,现在的页面在手机上能用但不够顺手。
但这些都是后话。至少目前,这套两千行的系统完美满足了我的需求。它让我把精力花在客户身上,而不是花在折腾工具上。这大概就是“简单版本”的真正意义:工具应该消失在背景里,让你专注于真正重要的事。
我在实际使用中最大的体会是,CRM的核心从来不是功能多强大,而是你愿不愿意每天打开它。一个你愿意每天用的简单工具,远胜过一个功能齐全但你懒得打开的复杂系统。如果你也在纠结要不要自己写一套,我的建议是:先拿纸笔列出你真正需要的三个功能,然后用最熟悉的技术把它们实现出来。不要追求完美,先跑起来,再慢慢改。