转头说说我为什么放着现成的SaaS CRM不用,非要自己写一套“DeskcommCRM”。
这个念头其实是某天晚上加班时冒出来的。当时我点开一个用了几年的云端客户管理工具,准备导一份上季度的客户名单,结果系统提示“导出功能已调整,请联系客服开通”,而那会儿客服肯定已经下班了。更让人“上头”的是,我翻到合同一栏才发现,当初选的是按年付费的商业版,可很多功能从开通到现在压根没碰过。做小生意、带小团队的人应该能懂那种感觉:客户资料、跟进记录、报价单,明明是我们一笔一画填进去的,取出来的时候却要看平台脸色。
后来我给自己团队写了一套跑在桌面端的客户管理系统,取名叫“DeskcommCRM”。名字拆开看,Desk 指桌面端运行,comm 是 communication 的缩写,侧重点在沟通记录的沉淀上,CRM 则是对客户全生命周期做管理。这套系统解决的痛点是三件:客户数据真正放在自己手里、跟进过程中的沟通往来全部自动留痕、销售看板随时能看且不受网络和席位限制。对于三五十人以内、希望数据自主可控的销售团队或小微企业,我认为这条路径比继续给云端SaaS续费更踏实。这篇文章就把它里里外外的设计思路、技术选型、核心代码和踩坑记录摊开讲一遍。
1. 为什么要把CRM做成“本地优先”而不是继续用云SaaS
1.1 SaaS CRM在中小团队场景里的四个真实短板
现在市面上主流的客户管理软件确实做得漂亮,但站在一个实际运营团队的立场上,我总结了几条对中小团队比较敏感的痛点。
第一条是数据主权的问题。我们的客户名单、报价记录、历史沟通内容,存到云端之后,本质上就变成了平台数据库里的一行行记录。大部分平台允许导出,但导出的字段粒度、格式、频次都受限制。真要换系统时,迁移成本高到让不少团队宁可继续忍气吞声地续费。
第二条是按席位收费带来的“隐性浪费”。通常一套CRM按“用户数×年费”收费,5个账号起步,一年下来费用不低。可实际使用中,很多人只把CRM当成通讯录和订单登记本在用,稀释下来,每个账号的真实成本高得离谱。
第三条是定制能力跟不上业务变化。中小团队的业务流程三天两头在变:这周销售说要在客户表里加一个“意向等级”,下周老板说合同审批要走两个节点。在云SaaS里这些都要通过工单系统提交给平台方排期,小需求一等就是几个迭代。
第四条是网络依赖。跑外勤、去工厂、下车间,很多地方网络信号根本不靠谱。手机端界面做得再好,没网的时候客户电话都调不出来。而桌面端应用天然不依赖持续在线,配合本地数据库,断网状态下所有操作照常。
1.2 “Desk” 和 “comm” 分别解决什么问题
命名是个挺较真的过程。我一开始想过叫“FastCRM”“EasyCRM”之类的名字,后来发现根本没法体现这套系统的差异点,于是回归到它最核心的两个特征上。
“Desk” 表达的是应用形态。它跑在Windows/Mac/Linux桌面上,数据默认落在本机SQLite/PostgreSQL里,也可以把数据库放在公司内网服务器上,让所有人连同一个库。这个概念类似“本地优先”:你的客户数据不是一个需要远程加载的网页,而是像本地文件一样随取随用。
“comm” 强调的是沟通集成。刚做客户管理的时候,我发现很多问题根本不出在“客户信息记不齐”,而出在“信息对不上”——销售手里有客户微信聊天记录,邮件里有一封报价往来,电话里又确认过一次交付时间,但这些沟通痕迹分布在不同的工具里,谁也看不见全貌。DeskcommCRM 把邮件、拨号记录、面对面跟进结果统一塞进一个“客户时间轴”,每次打开客户档案,最近一次接触是什么时候、对方承诺过什么、我们报过什么价,一目了然。所以它不追求大而全,核心战场就是数据和沟通的闭环。
1.3 假设你也是一个人数不多但客户关系密集的小团队
如果你所在团队每天打交道的客户数量在几百到几千这个量级,跟进过程高度依赖个人记忆,平时还得给领导汇报数据,那么这种本地优先的CRM优势会被放大得很明显。我们自己的使用场景是设备销售,销售员每人维护一百来个客户,每周几十通电话和十来封邮件。过去用共享表格管理,最大的问题是并发编辑互相覆盖;用云CRM又嫌定制和导出受约束。DeskcommCRM 就是把“共享表格的轻”和“专业CRM的严”折中了一下。
这并不等于说云SaaS一无是处。如果你的团队分布在全国甚至全球、需要随时随地协同办公,那云端的便捷性确实难以替代。DeskcommCRM 更适合“核心团队坐在一起、对数据安全有明确要求、业务逻辑需要频繁调整”的场景。
2. DeskcommCRM 的六大核心功能模块与业务闭环
很多人一提到CRM就想到一张客户表格,但客户管理真正的复杂度在“关联”。一个客户下面有多个联系人,每个联系人有多次跟进记录,每次跟进可能产生报价或订单,所有数据还要能按时间串成一条清晰的主线。DeskcommCRM按这个思路把功能拆成了六块。
2.1 客户与联系人管理:给数据打上可检索的标签
客户表是整棵业务树的根。每条客户记录包含公司名称、行业、规模、来源渠道、归属销售、创建时间等基础字段;联系人表挂在客户下面,记录姓名、职位、手机、微信、邮箱、生日等联系方式。这里比较关键的是“动态标签”设计,标签不是固定的几个预设值,而是允许自定义的组合,比如“高意向”“已报价未回款”“老客户转介绍”“展会来源”。标签可以叠加,后续做筛选和邮件群发时就非常方便。
2.2 销售漏斗与阶段流转:所有变更都有迹可循
销售阶段我参考了常见的B2B销售流程,设置了“初步接洽→需求确认→方案报价→商务谈判→赢单/输单”这几个阶段。每个客户在系统里对应一个当前阶段,拖动卡片即可完成阶段变更。同时系统自动记录阶段变更的历史时间点,什么时候从“需求确认”进入“方案报价”、在哪个阶段停留了多久,后续算销售周期和转化率全靠这些历史数据。
2.3 跟进任务与待办提醒:不靠脑子记事情
销售每天最不缺少的就是待办事项。DeskcommCRM的任务模块支持为指定客户或联系人生成“打电话”“发邮件”“拜访”“提交方案”四类任务,每个任务有负责人、截止时间和优先级。到点前系统会在桌面弹通知提醒,逾期未完成任务会自动进入“逾期列表”,并在每日晨会前生成一份“风险跟进清单”。在写代码时我给所有提醒做成事件驱动的调度器,而不是简单轮询数据库,这样可以显著降低无谓的系统开销。
2.4 沟通记录时间轴:邮件、电话、面谈全部留痕
这是DeskcommCRM里最重要也最花心思的模块。设计灵感来自一个实在的困扰:以前翻客户资料,销售各自记在笔记本或Excel里的信息,换个人接手时基本等于从零开始。时间轴把所有沟通痕迹统一汇总,格式类似社交媒体里的信息流,按时间倒序排列。人工录入只是一部分,更大的亮点是可以通过配置IMAP协议自动抓取企业邮箱的往来邮件,把同一客户的多封邮件自动规整到对应客户档案下。电话记录则通过和桌面软电话集成,呼出和呼入自动弹出客户信息,通话结束自动写入时间轴。
2.5 报价单与订单草稿:业务流转的最后一步
CRM不该到“记了个客户”就结束,还需要支撑交易闭环。DeskcommCRM内置轻量级的报价单模块,可以勾选产品、填写数量、单价、折扣,自动计算含税金额,并生成一份适合打印的PDF报价单。客户确认后,报价单即可转换为订单草稿,订单状态支持“待收款”“部分收款”“已结清”。金额数据最终汇入统计看板,老板一眼看到哪笔大单还悬在哪个阶段。
2.6 数据看板:让数字替你说话
有了客户、阶段、任务、沟通、报价这些数据,最后一层是聚合展示。仪表盘上默认展示几个核心指标:本月新增客户数、销售漏斗总金额、各阶段转化率、团队业绩排名、逾期任务数。全部指标支持按时间范围筛选,也支持按销售维度下钻。这一层本质上就是若干条SQL,但为了在前端渲染快,我在后端做了定时预聚合,把结果缓存成JSON,避免每次刷新都跑一遍全量统计。
2.7 用一条业务线把模块串起来
举个例子。公司参加了一场行业展会,现场收到的名片被录入系统,来源渠道标记“展会”,并打上“新线索”标签。次日销售给联系人拨了第一通电话,系统弹出该联系人的历史背景,通话结束自动生成一条跟进记录。聊完觉得有戏,就新建一个商机放入“需求确认”阶段,并给下周留一个“上门拜访”任务。拜访回来,在时间轴里补一条面谈纪要,阶段拖到“方案报价”,上传一份报价单PDF。客户邮件回复说价格偏高,通过自动抓取邮件进入时间轴,销售直接在客户页面基于历史报价创建修订版。最后订单成交,回款在订单模块登记完成,销售漏斗里的数字自动更新。这一条流程走下来,每个动作都在系统里有对应记录,既方便自己复盘,也方便管理者掌握节奏。
3. 技术栈选型与数据库模型设计
3.1 技术选型不是追新,而是图省心
我在选技术栈时只有一个原则:好维护、好招人、遇到问题能搜到答案。最终定下来的是这样一套组合。
| 层次 | 选型 | 选型理由 |
|---|---|---|
| 桌面壳 | Electron(主推)+ Tauri(试验) | 前端生态成熟,Vue/React都能直接跑;Tauri包体更小但底层依赖Rust,团队不熟练时不建议初期就上 |
| 后端框架 | Python FastAPI | 开发速度快,自带OpenAPI文档,Pydantic做数据校验很省事 |
| 前端框架 | Vue 3 + Element Plus | 组件库齐全,做后台管理类界面效率极高 |
| 本地数据库 | SQLite | 单机场景零运维,一个文件搞定所有数据,备份就是复制文件 |
| 多人数据库 | PostgreSQL | 需要多人同时在线时切换到PostgreSQL,SQLite的SQL基本兼容,迁移成本可控 |
| ORM | SQLAlchemy 2.x | 既能写模型映射,又保留了手写SQL的兜底能力 |
| 任务调度 | APScheduler | 实现本地定时任务(提醒、邮件抓取、日报汇总)非常方便 |
这套选型里并不存在“性能极致”的选项,但胜在皮实。FastAPI 本身是异步框架,处理常规业务接口毫无压力;SQLite 在几千客户、几万条跟进记录的量级下完全没有性能瓶颈。真正等到数据量达到百万级,再平滑替换 PostgreSQL 也不迟。
3.2 核心表结构设计:字段和索引怎么规划
这里直接给出建表SQL里最核心的几张表。实际的业务表比这个多,但这几张是撑起整套系统的地基。
-- 用户表 CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username VARCHAR(64) NOT NULL UNIQUE, password_hash VARCHAR(128) NOT NULL, display_name VARCHAR(64), is_active BOOLEAN DEFAULT 1, role VARCHAR(32) DEFAULT 'sales', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 客户表 CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name VARCHAR(128) NOT NULL, industry VARCHAR(64), source VARCHAR(32), owner_id INTEGER REFERENCES users(id), stage VARCHAR(32) DEFAULT 'lead', contact_count INTEGER DEFAULT 0, deal_amount_cents BIGINT DEFAULT 0, tags TEXT DEFAULT '' , remark TEXT, is_deleted BOOLEAN DEFAULT 0, created_at DATETIME, updated_at DATETIME ); CREATE INDEX idx_customers_owner ON customers(owner_id); CREATE INDEX idx_customers_stage ON customers(stage); CREATE INDEX idx_customers_deleted ON customers(is_deleted); -- 联系人表 CREATE TABLE contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL REFERENCES customers(id), name VARCHAR(64) NOT NULL, position VARCHAR(64), mobile VARCHAR(32), wechat VARCHAR(64), email VARCHAR(128), birthday DATE, is_primary BOOLEAN DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_contacts_customer ON contacts(customer_id); -- 商机/售阶段表 CREATE TABLE deals ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL REFERENCES customers(id), title VARCHAR(128) NOT NULL, amount_cents BIGINT DEFAULT 0, stage VARCHAR(32) DEFAULT 'discovery', owner_id INTEGER REFERENCES users(id), expected_close_date DATE, won_at DATETIME, lost_reason VARCHAR(255), created_at DATETIME, updated_at DATETIME ); CREATE INDEX idx_deals_stage ON deals(stage); CREATE INDEX idx_deals_customer ON deals(customer_id); -- 活动/沟通记录表 CREATE TABLE activities ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER, contact_id INTEGER, user_id INTEGER, act_type VARCHAR(32), -- call, email, meeting, note, quote content TEXT, occurred_at DATETIME, created_at DATETIME ); CREATE INDEX idx_activities_customer_time ON activities(customer_id, occurred_at); -- 跟进任务表 CREATE TABLE tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER, contact_id INTEGER, owner_id INTEGER NOT NULL, task_type VARCHAR(32), title VARCHAR(255), due_at DATETIME, done_at DATETIME, status VARCHAR(16) DEFAULT 'open', priority INTEGER DEFAULT 1, created_at DATETIME ); CREATE INDEX idx_tasks_owner_status ON tasks(owner_id, status);这些表的设计有几个容易忽略的细节,我特别提一下。
金额一律用整数分存储,不要用浮点数。刚开始我图省事用amount DECIMAL(10,2),后来前端做加减法时经常出现 0.1 + 0.2 不等于 0.3 的类型问题。改成BIGINT存“分”之后,所有金额计算全部变成整数运算,彻底避开浮点误差。
时间字段统一存储UTC时间,展示时按本地时区转换。这是一个经典的坑。如果数据库里直接存东八区时间,一旦将来换了机器、跨了时区,所有记录的时间点都是错乱的。我在所有created_at字段里存UTC,前端拿到后通过浏览器的时区设置自动转换成本地时间。
业务数据尽量软删除。客户信息被误删是灾难性的,所以我给customers表加了is_deleted字段,删除时只标记不物理删除。查询时统一加WHERE is_deleted = 0条件。代价是要在写每一句业务查询时多带一个条件,但这个代价比起误删后的恢复工作来说,完全不值一提。
3.3 后端接口如何组织:按业务模块划分路由
FastAPI组织路由很灵活,我按业务域拆成多个router文件。/customers负责客户档案,/deals负责商机,/activities负责动态记录,/tasks负责待办。每个文件内部再进行细分。一个好处是后期加功能时,不会在一个巨型文件里反复翻找。
4. 从零搭建一个能跑起来的DeskcommCRM最小系统
这一部分我会给出可直接运行的代码骨架。演示场景是“客户添加-查询-漏斗统计”,虽然只覆盖部分功能,但套上这套骨架,扩展其他模块只是复制粘贴的事。
4.1 环境准备
Python 3.11+ Node.js 18+ SQLite 3.x后端依赖写入requirements.txt:
fastapi==0.110.0 uvicorn[standard]==0.29.0 sqlalchemy==2.0.29 pydantic==2.6.4 apscheduler==3.10.4 python-multipart==0.0.9 openpyxl==3.1.2 aiosmtplib==3.0.1安装命令:
pip install -r requirements.txt前端我用Vue 3的官方脚手架创建项目,按下述方式把Element Plus引进来。这一部分比较常规,展开讲会占用篇幅,先用命令将项目跑通,后面的核心代码着重讲后端。
npm create vue@latest deskmobile-web cd deskmobile-web npm install element-plus axios npm run dev4.2 后端应用骨架:数据库连接、模型、路由挂载
首先是数据库连接配置database.py:
from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, declarative_base # 本地模式使用 SQLite,文件路径可以配置 DATABASE_URL = "sqlite:///./deskcomm.db" engine = create_engine( DATABASE_URL, connect_args={"check_same_thread": False}, echo=False, ) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) Base = declarative_base() def get_db(): db = SessionLocal() try: yield db finally: db.close()然后是models.py,我用SQLAlchemy定义了客户和商机的模型,和前面的建表SQL一一对应。
from sqlalchemy import Column, Integer, String, Boolean, BigInteger, DateTime, Text, Date, ForeignKey from sqlalchemy.sql import func from database import Base class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String(64), unique=True, nullable=False) password_hash = Column(String(128), nullable=False) display_name = Column(String(64)) role = Column(String(32), default="sales") class Customer(Base): __tablename__ = "customers" id = Column(Integer, primary_key=True, index=True) name = Column(String(128), nullable=False) industry = Column(String(64)) source = Column(String(32)) owner_id = Column(Integer, ForeignKey("users.id")) stage = Column(String(32), default="lead") tags = Column(Text, default="") remark = Column(Text) is_deleted = Column(Boolean, default=False) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) class Deal(Base): __tablename__ = "deals" id = Column(Integer, primary_key=True, index=True) customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False) title = Column(String(128), nullable=False) amount_cents = Column(BigInteger, default=0) stage = Column(String(32), default="discovery") owner_id = Column(Integer, ForeignKey("users.id")) expected_close_date = Column(Date) won_at = Column(DateTime) lost_reason = Column(String(255)) created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())应用入口main.py长这样:
from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import customers import deals import dashboard app = FastAPI(title="DeskcommCRM API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.include_router(customers.router, prefix="/api", tags=["customers"]) app.include_router(deals.router, prefix="/api", tags=["deals"]) app.include_router(dashboard.router, prefix="/api", tags=["dashboard"]) if __name__ == "__main__": import uvicorn uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)4.3 三块核心功能的实现:客户接口、漏斗统计、批量导入
客户创建接口。我加了两个逻辑:先按“客户名+是否删除”做查重,再自动构造标签字符串。这里标签用JSON字符串存,方便后续扩展。
# customers.py from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session import json from datetime import datetime from database import get_db from models import Customer from pydantic import BaseModel router = APIRouter() class CustomerCreate(BaseModel): name: str industry: str | None = None source: str | None = None owner_id: int | None = None stage: str = "lead" tags: list[str] = [] remark: str | None = None class CustomerOut(CustomerCreate): id: int created_at: datetime class Config: from_attributes = True @router.post("/customers", response_model=CustomerOut) def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)): # 查重:排除已删除记录 existed = db.query(Customer).filter( Customer.name == payload.name, Customer.is_deleted.is_(False), ).first() if existed: raise HTTPException(status_code=400, detail="同名客户已存在") customer = Customer( name=payload.name, industry=payload.industry, source=payload.source, owner_id=payload.owner_id or 1, stage=payload.stage, tags=json.dumps(payload.tags, ensure_ascii=False), remark=payload.remark, ) db.add(customer) db.commit() db.refresh(customer) return customer销售漏斗统计接口。这个接口要按阶段分组,同时统计每个阶段下商机金额的汇总值。为什么要按stage和won_at双条件?因为赢单后的商机不应该再出现在漏斗数字里。
# dashboard.py from fastapi import APIRouter, Depends from sqlalchemy.orm import Session from sqlalchemy import func from database import get_db from models import Deal router = APIRouter() STAGES = ["discovery", "requirement", "quotation", "negotiation", "won"] @router.get("/funnel") def get_funnel(db: Session = Depends(get_db)): # 按阶段汇总金额(排除已赢单) rows = ( db.query( Deal.stage, func.count(Deal.id).label("deal_count"), func.sum(Deal.amount_cents).label("amount_sum"), ) .filter(Deal.stage != "won") .group_by(Deal.stage) .all() ) summary = {stage: {"count": 0, "amount_sum": 0} for stage in STAGES} for stage, cnt, amount in rows: summary[stage] = { "count": cnt, "amount_sum": amount or 0, } # 追加赢单总金额 won_amount = ( db.query(func.sum(Deal.amount_cents)) .filter(Deal.stage == "won") .scalar() ) return {"funnel": summary, "won_amount": won_amount or 0}Excel批量导入。导入是CRM里使用频率非常高的功能。这里我使用openpyxl的只读模式逐行扫描,避免一次把整个Excel文件读进内存。
# import_export.py from fastapi import APIRouter, Depends, UploadFile, File from sqlalchemy.orm import Session from database import get_db from models import Customer import openpyxl import json router = APIRouter() @router.post("/customers/import") async def import_customers(file: UploadFile = File(...), db: Session = Depends(get_db)): if not file.filename.endswith((".xlsx", ".xls")): return {"success": False, "message": "仅支持 .xlsx / .xls 文件"} wb = openpyxl.load_workbook(file.file, read_only=True, data_only=True) ws = wb.active row_count = 0 skips = [] # 假设第一行是表头:公司名称、行业、来源、负责人ID、标签 for idx, row in enumerate(ws.iter_rows(min_row=2, values_only=True)): name = row[0] if not name: continue existed = db.query(Customer).filter( Customer.name == str(name).strip(), Customer.is_deleted.is_(False), ).first() if existed: skips.append({"row": idx + 1, "reason": f"客户[{name}]已存在"}) continue customer = Customer( name=str(name).strip(), industry=row[1] or None, source=row[2] or None, owner_id=int(row[3]) if row[3] else 1, tags=json.dumps([str(row[4])] if row[4] else [], ensure_ascii=False), ) db.add(customer) row_count += 1 # 每500行提交一次,避免长事务占用锁 if row_count % 500 == 0: db.commit() db.commit() wb.close() return {"success": True, "imported": row_count, "skipped": skips}4.4 前端页面和这几个接口怎么对接
前端的日常操作界面主要分三块:客户列表页、客户详情页、Dashboard看板。客户列表页通过axios调GET /api/customers拿到数据渲染表格,详情页加载时间轴和商机记录,Dashboard调GET /api/funnel渲染漏斗卡片。考虑到这不是一篇纯前端教程,这里就不贴大段Vue代码了。但有一个细节值得提醒:不要把接口地址写死成localhost:8000,放到.env文件里用VITE_API_BASE管理,方便后续打包部署时切换。
5. 开发与使用中踩过的五个坑(含排查链路)
任何系统都不是一次写成的。这五个问题是我在真实开发和使用DeskcommCRM时逐步踩出来的,每个都记录一下现象、根因和最终处理方式,希望能帮你绕开。
5.1 两万行Excel导入直接把桌面应用卡死
现象:首次做批量导入测试,放了一份两万多个客户的Excel文件,应用直接无响应接近两分钟。
排查过程:一开始我怀疑后端SQLite事务有问题,后来在代码里加日志,发现卡顿发生在load_workbook加载阶段——不是SQL问题,而是整个文件被一次性读入了内存。openpyxl默认会把整个工作簿载入,每个单元格都是一个Python对象,两万行的内存占用直接爆掉。
解法:使用read_only=True模式,让它按行流式读取,同时分批提交数据库事务。文件导入从“读取全部”变成“边读边写”,内存占用从几百MB降到几十MB。经验是:需要处理大文件时,不要相信任何默认载入方式,先确认API是否支持流式读取。
5.2 权限校验只做了前端导致一个销售能改别人的客户
现象:内部测试时一个销售登录系统,通过浏览器控制台手工调用接口,成功修改了另一个销售名下的客户负责人。
排查过程:问题出在我最初只在Vue前端做了“只有本人能编辑”的按钮隐藏,后端接口完全没有做鉴权判断。只要知道接口的URL和参数,任何人都能直接调用。这说明了一个老生常谈的问题:前端权限只是体验优化,后端权限才是安全底线。
解法:后端统一引入用户登录态中间件,每个需要权限的请求都从Token里解析出当前用户ID;对客户、商机的修改,在接口里先校验owner_id是否匹配。对于管理员角色再单独赋予跨用户操作的权限。这套RBAC逻辑虽然多写几十行代码,但它是多用户系统的分水岭。
5.3 时区“穿越”导致客户生日提醒数据错乱
现象:有次测试发现客户生日提醒时间奇怪,10月1日过生日的人,在9月30日晚上8点就收到了提醒短信。
排查过程:检查后发现数据库存的是UTC时间,而执行定时任务的APScheduler时区设置的是本地时区。定时任务在本地时区9月30日晚8点跑的时候,所对应的UTC时间已经是10月1日0点之后,于是“按UTC日期判断今天是否有人过生日”的查询命中了次日的数据。
解法:所有日期条件判断都统一转成目标业务时区后再做比较,绝不能直接用UTC日期去比对“本地生日”。代码里写死了业务时区为Asia/Shanghai,在数据库查询前先做datetime.now(ZoneInfo("Asia/Shanghai"))转换,再查生日字段。这个教训的普适性在于:任何带日期的业务逻辑,都需要先明确“这个字段是给谁看的”,再确定存储和比较的时区基准。
5.4 软删除字段遇上唯一索引,重复点击创建导致报错
现象:删除客户A后,重新创建一个同名客户,接口返回“唯一索引冲突”。
排查过程:我在customers.name上加了唯一索引,但软删除只是把is_deleted设为1,名字这个字段依然占着索引位置。新建同名客户时数据库直接拒绝写入。
解法:唯一索引改成联合唯一索引,把is_deleted也包含进去。这样同一个名字可以对应一条未删除记录和多条已删除记录,互不冲突。SQL改成UNIQUE KEY uk_customer_name (name, is_deleted)即可。
如果要做得更严谨,可以在删除时给名字加一个时间戳后缀(例如
客户A__del_20250101120000),彻底避免和正常客户名撞车。这种方式适合同时还要保留客户历史记录可追踪的场景。
5.5 Electron自动更新失败,用户永远停留在旧版本
现象:发布一版修复Bug的更新后,过了两周发现还有不少用户的版本号停留在一个月前。
排查过程:Electron的自动更新默认依赖electron-updater和更新服务器,我们的安装包发布在内网,更新服务器域名是HTTP而不是HTTPS,部分系统默认禁用了不安全的更新源。而且更新是“后台默默检查”的模式,用户完全不感知,失败也没有提示弹窗,时间一长大家都以为自己在用最新版。
解法:增加启动时校验版本的逻辑,前端在每次启动后向后端请求GET /api/meta/version,如果低于最新版本就弹出提示框,引导用户重新下载安装包。同时把更新检查结果统一记录到日志文件里,方便排查更新链路故障。这条经验对做桌面端应用的人特别有价值:自动更新必须要有“版本兜底”机制,不能只在后台静默更新。
6. 这套系统真正适合谁,以及后续扩展方向
6.1 适用人群和不适用场景
适合用DeskcommCRM的,大概率是下面几类团队:对客户信息安全敏感、不想把核心数据交给第三方平台的团队;业务流程频繁变动、需要快速自定义字段和状态的小团队;编制在五人左右、希望用极低费用获得专业CRM能力的团队;还有经常在弱网环境工作的销售团队。
不适合的就比较明显了:几十人以上、跨地域协同要求很高的组织,还是老实选择成熟的云端CRM产品;对财务、供应链、进销存深度依赖的团队,也需要配套的ERP能力,CRM只是其中一个环节,单独用桌面CRM很难撑起整体业务流。
6.2 下一步我打算补齐的几个能力
第一是日历同步。目前任务提醒是在应用内弹窗,说实话不够醒目。我计划接入本地日历(比如CalDAV协议),让销售在系统里安排的拜访计划能同步到手机日历上,做到真正随身携带。
第二是附件管理。报价单PDF、合同扫描件、客户发来的需求文档,目前散落在本机磁盘,和客户档案没有直接关联。准备做成一个统一的附件库,挂在客户详情页和时间轴下面,所有文件统一落盘、统一做索引。
第三是更细粒度的操作日志。除了阶段变更,字段级的历史修改也要记录,比如“客户来源从展会改成转介绍”,这样能回放客户信息的整个变化过程。
另外还有一个方向是多人模式下的离线同步。目前的架构是每人连同一个PostgreSQL库,离线能力不如单机模式。可以考虑给每个客户端加一个本地缓存,在线时自动同步,但这个功能的复杂度较单机版高不少,需要认真设计冲突策略,大概率要留到下一阶段再启动。
6.3 如果你想从零复刻一套,建议按这个顺序推进
如果你是开发者,想基于这套思路做一套自己团队的CRM,我给一个按依赖关系排列的开发顺序,可以减少返工:
- 先做后端的用户、客户、商机三个核心模型,把增删改查接口跑通。
- 再做前端客户列表和详情页,实现基本录入和查看。
- 接着做商机看板和漏斗统计,把销售最关心的数据展示出来。
- 然后做任务提醒和沟通时间轴,这两块会让系统从“记录工具”升级为“协作工具”。
- 最后再考虑批量导入导出、报表订阅、附件等“锦上添花”功能。
- 从一开始就给所有表加上软删除、审计字段、UTC时间,这类底层设计后面补会很痛苦。
最后再分享一点实在的体会
我当初决定自己写CRM,并不是因为市面上没有好的产品,而是因为“够用”和“可控”这两个词在现有工具里越来越难同时满足。DeskcommCRM到现在已经跑了几个月,日常使用中我最大的感受是:给销售省下的记忆负担,比想象中多得多。以前开会回忆“这个客户上次聊到哪了”,现在打开客户详情就有一整条时间线;以前月底做数据统计靠翻微信记录,现在漏斗看板点开就有。这种掌控感,是花钱买不来的。
如果你也在做类似的产品或内部工具,我的建议只有三条:数据模型最先设计但要预留扩展空间;权限安全从第一版就做完整;所有看似“以后再说”的底层规范,几乎都会在项目后期回来找你麻烦。希望这篇文章能给你一些启发,也欢迎在实际使用中多交流遇到的问题。