1. 项目概述:为什么一个“免费CRM”最终会逼你亲手搭起自己的网站
最近三个月,我帮六家中小团队做过客户管理系统的选型咨询,几乎每一家都经历过同样的路径:先用某款标榜“永久免费”的SaaS CRM——界面清爽、开箱即用、连微信扫码登录都配好了;结果不到两个月,问题开始扎堆出现:销售线索导出被限频,API调用突然收费,客户数据字段被强制同步到厂商后台做“AI分析”,更关键的是,当公司签下一个需要数据本地留存的政府类项目时,法务直接否决了所有云CRM方案。这时候,“自建部署”四个字才真正从技术文档里跳出来,变成一张必须签下的责任状。
这本指南不讲虚的,只说我在真实场景中踩过坑、改过三次架构、重写过两轮核心模块后沉淀下来的实操路径。标题里的“DeskcommCRM”不是广告,而是我最终选定并深度改造的开源基座——它用纯PHP 7.4+编写,无Composer强依赖,单文件路由入口清晰,数据库层抽象得足够干净,最关键的是,它把“数据主权”这件事,写进了每一行注释里。你不需要是PHP专家,但得愿意在终端敲几条命令、看懂SQL语句、理解HTTPS证书怎么续期。如果你只想点几下鼠标就拥有一个“永久在线的CRM网站”,那这篇内容可能让你失望;但如果你希望知道:当销售总监深夜发来一条“客户合同扫描件必须今晚入库,且不能经过任何第三方服务器”时,你该打开哪个文件、修改哪三行代码、重启哪个服务,那接下来的内容,就是为你写的。
核心关键词在这里已经自然嵌入:CRM系统本质是客户关系的数字化容器,而“私有化”不是加个防火墙那么简单,它意味着你对数据生成、存储、流转、销毁的全链路控制权;“自建部署”也不是把源码扔进服务器就完事,它是一套包含环境适配、权限隔离、备份策略、升级机制的运维契约;至于PHP,它在这里不是过时的代名词,而是以极低的学习成本、极高的可控性、极丰富的国产化适配案例,成为中小企业私有化落地最务实的技术栈。下面所有内容,都围绕这三个锚点展开。
2. 整体设计思路:放弃“一键部署”,拥抱“可审计的最小闭环”
很多人看到“自建CRM”第一反应是找Docker镜像、拉Compose文件、跑起来再说。我试过三次,全部推倒重来。第一次用某流行CRM的Docker版,跑通后发现日志全打在容器里,审计时根本无法追溯操作人;第二次换了个带Web安装向导的PHP系统,结果安装脚本偷偷往数据库里写了一堆埋点表;第三次才真正静下心来,把整个架构拆成四个不可妥协的硬性原则:
2.1 原则一:数据落盘必须裸露可查
所有客户数据,必须以明文或标准加密格式(AES-256-CBC)存于MySQL InnoDB表中,禁止任何形式的JSON大字段封装客户核心信息(如姓名、电话、合同金额)。我见过太多系统把整个“客户档案”塞进一个profileTEXT字段,美其名曰“灵活扩展”,结果法务要查某客户2023年所有沟通记录时,DBA只能写全文检索SQL,响应时间超47秒。DeskcommCRM的contacts表结构是这样的:
CREATE TABLE `contacts` ( `id` int(11) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '客户姓名,强制非空', `mobile` varchar(20) DEFAULT NULL COMMENT '手机号,独立字段便于索引', `email` varchar(150) DEFAULT NULL COMMENT '邮箱,单独建索引', `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `owner_id` int(11) NOT NULL COMMENT '归属销售ID,外键关联users表', PRIMARY KEY (`id`), KEY `idx_mobile` (`mobile`), KEY `idx_email` (`email`), KEY `idx_owner` (`owner_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意三个细节:mobile和email分列且建索引,不是塞进extra_infoJSON里;owner_id强制外键,避免数据归属混乱;created_at/updated_at由数据库自动维护,不依赖PHP代码。这个设计让任何DBA用Navicat连上就能直接查,无需读源码。
2.2 原则二:身份认证必须与企业现有体系打通
绝不接受“CRM自建账号体系”。我们公司用钉钉OA,所有销售入职时HR在钉钉后台创建账号,CRM必须能通过钉钉OpenAPI实时拉取用户信息,并将钉钉ID作为CRM内users.id。DeskcommCRM的auth.php里,我把原生的密码登录逻辑彻底注释掉,替换成:
// 钉钉免密登录回调处理 if (isset($_GET['code'])) { $code = $_GET['code']; $token_url = "https://oapi.dingtalk.com/gettoken?appkey=".DD_APPKEY."&appsecret=".DD_APPSECRET; $token_res = json_decode(file_get_contents($token_url), true); $user_url = "https://oapi.dingtalk.com/user/getuserinfo?access_token={$token_res['access_token']}&code={$code}"; $user_res = json_decode(file_get_contents($user_url), true); // 关键:用钉钉unionid作为CRM用户唯一标识 $ding_user_id = $user_res['unionid']; $db->query("SELECT id FROM users WHERE ding_unionid = ?", [$ding_user_id]); if (!$db->num_rows()) { // 首次登录,自动创建CRM用户,但仅同步姓名、部门,不拉取手机号等敏感字段 $db->query("INSERT INTO users (name, department, ding_unionid, created_at) VALUES (?, ?, ?, NOW())", [$user_res['name'], $user_res['department'], $ding_user_id]); } // 生成CRM session,跳转首页 $_SESSION['user_id'] = $db->insert_id(); header("Location: /dashboard.php"); }这段代码的价值在于:销售离职时,HR只需在钉钉禁用账号,CRM下次验证ding_unionid失败即自动登出,无需人工清理CRM后台。这才是真正的权限生命周期闭环。
2.3 原则三:备份必须“三地四份”且可手动触发
“永久在线”不等于“永不丢失”。我要求所有生产环境必须满足:本地服务器保留7天增量备份 + NAS网络存储保留30天全量备份 + 阿里云OSS冷备一份加密快照 + 运维负责人手机存一份离线恢复脚本。DeskcommCRM没有内置备份功能,所以我写了这个backup.sh:
#!/bin/bash # 每日凌晨2点执行:mysqldump + tar.gz + ossutil上传 DATE=$(date +%Y%m%d) DB_NAME="deskcomm_crm" BACKUP_DIR="/data/backup/$DATE" mkdir -p $BACKUP_DIR # 1. 数据库导出(含建表语句) mysqldump -u root -p'your_password' --single-transaction --routines --triggers $DB_NAME > $BACKUP_DIR/db.sql # 2. 附件目录打包(CRM所有上传文件在/uploads下) tar -zcf $BACKUP_DIR/uploads.tar.gz -C /var/www/html/ uploads/ # 3. 生成校验码 sha256sum $BACKUP_DIR/db.sql $BACKUP_DIR/uploads.tar.gz > $BACKUP_DIR/checksum.sha256 # 4. 上传至OSS(ossutil已配置好AK/SK) ossutil cp $BACKUP_DIR/ oss://crm-backup/daily/$DATE/ --update # 5. 清理7天前备份 find /data/backup -name "*" -mtime +7 -delete重点在第4步:ossutil cp命令必须带--update参数,确保只传新文件,避免重复上传耗带宽。这个脚本我放在crontab -e里,但每次重大版本升级前,我一定会手动执行一次./backup.sh && echo "手动备份完成",因为自动化永远不如人眼确认可靠。
2.4 原则四:升级必须“灰度验证+回滚开关”
CRM不是静态网站,销售流程会变、字段会增、报表需求会迭代。DeskcommCRM的升级机制被我重构成“双版本共存”模式:/v1/目录放稳定版,/v2/目录放测试版,Nginx配置里用map指令根据Cookie分流:
map $cookie_crm_version $backend { default "v1"; "v2" "v2"; } location / { proxy_pass http://127.0.0.1:808$backend; }销售组长被分配crm_version=v2的Cookie,他用新版测试两周,没问题再全量切换。而回滚更简单:rm -rf /var/www/html/v2 && ln -s v1 /var/www/html/v2,3秒完成。这种设计让升级从“提心吊胆的手术”变成“按需切换的开关”。
这四个原则不是理论,是我在给教育机构部署时,因未坚持第一条导致客户数据泄露被罚;在给律所部署时,因未打通第二条引发权限纠纷;在给制造企业部署时,因备份失效丢失3天订单数据——用真金白银买来的教训。现在,它们是我启动任何私有化CRM项目的检查清单。
3. 核心细节解析:DeskcommCRM的三处致命改造与PHP实现逻辑
DeskcommCRM作为开源基座,优点是轻量、无框架依赖、SQL直写清晰,但原始版本有三处设计缺陷,若不改造,会在6个月后成为运维噩梦。我花了17个小时逐行调试、压测、重写,以下是必须做的核心改造:
3.1 改造一:将“联系人-跟进记录”一对多关系,重构为“事件流”模型
原始DeskcommCRM中,每个联系人页面下方有个“跟进记录”列表,数据存在followups表,结构是:
CREATE TABLE `followups` ( `id` int(11) NOT NULL AUTO_INCREMENT, `contact_id` int(11) NOT NULL, `content` text NOT NULL, `created_by` int(11) NOT NULL, PRIMARY KEY (`id`) );问题在于:当销售A给客户X发了邮件,销售B又打了电话,这两条记录在followups表里只是两条孤立数据,无法体现“事件先后顺序”和“跨角色协作脉络”。我把它升级为events表:
CREATE TABLE `events` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `type` enum('email','call','meeting','task','note') NOT NULL COMMENT '事件类型', `subject` varchar(200) NOT NULL COMMENT '事件主题,如"合同续签沟通"', `content` text COMMENT '详情,支持HTML', `related_to` varchar(50) NOT NULL COMMENT '关联对象类型,如"contact","deal","task"', `related_id` int(11) NOT NULL COMMENT '关联对象ID', `created_by` int(11) NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_related` (`related_to`,`related_id`), KEY `idx_user_time` (`created_by`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;关键变化有三点:
type字段枚举化:不再用content字段里写“【电话】今天跟王总聊了价格”,而是明确标记type='call',为后续BI分析打基础;related_to/related_id解耦:一条事件可以同时关联客户(contact_id=123)和商机(deal_id=456),比如“会议纪要”既更新客户状态,也推进商机阶段;- 时间戳双写:
created_at记录发生时间,updated_at记录编辑时间,销售修改会议纪要时,created_at不变,updated_at更新,审计时一目了然。
PHP层改造集中在EventService.php:
class EventService { public function create($type, $subject, $content, $relatedTo, $relatedId, $userId) { // 强制校验:只有销售经理才能创建"type='task'"的事件 if ($type === 'task' && !$this->isManager($userId)) { throw new Exception("无权创建任务事件"); } // 自动提取联系人ID(如果relatedTo是deal,则从deal表查contact_id) $contactId = $this->getContactIdFromRelated($relatedTo, $relatedId); $sql = "INSERT INTO events (type, subject, content, related_to, related_id, created_by, contact_id) VALUES (?, ?, ?, ?, ?, ?, ?)"; $this->db->query($sql, [$type, $subject, $content, $relatedTo, $relatedId, $userId, $contactId]); // 关键:触发Webhook通知相关人(如任务指派人) if ($type === 'task') { $this->triggerTaskWebhook($relatedId, $userId); } } }这个改造让CRM从“记录本”升级为“协作中枢”,销售主管看仪表盘时,能一眼看出“上周所有客户事件中,电话占比32%,会议占比18%,而未跟进超3天的客户有7个”,这才是数据驱动的起点。
3.2 改造二:附件上传从“直接存服务器”改为“对象存储+CDN加速”
原始版本所有文件上传到/uploads/目录,问题有三:一是磁盘爆满无人知晓(某客户上传2万张产品图,占满120G空间);二是下载慢(销售在外用4G网络打开合同PDF要等40秒);三是备份困难(/uploads/目录太大,mysqldump无法包含)。我引入阿里云OSS,但没用SDK,而是用最朴素的curl:
// upload_file.php $oss_bucket = "crm-attachments"; $oss_region = "oss-cn-hangzhou"; $oss_ak = "your_access_key"; $oss_sk = "your_secret_key"; // 生成OSS签名(简化版,生产环境请用官方SDK) $expire = time() + 300; // 5分钟有效期 $stringToSign = "PUT\n\n\n{$expire}\n/{$oss_bucket}/{$_POST['filename']}"; $signature = base64_encode(hash_hmac('sha1', $stringToSign, $oss_sk, true)); // 返回前端可直传的OSS参数 $response = [ 'url' => "https://{$oss_bucket}.{$oss_region}.aliyuncs.com/{$_POST['filename']}", 'host' => "https://{$oss_bucket}.{$oss_region}.aliyuncs.com", 'policy' => base64_encode(json_encode(['expiration' => date('c', $expire), 'conditions' => [['bucket' => $oss_bucket]]])), 'OSSAccessKeyId' => $oss_ak, 'signature' => $signature, 'key' => $_POST['filename'], 'success_action_status' => '200' ]; echo json_encode($response);前端用这个参数直传OSS,文件不经过CRM服务器,零带宽消耗。而CRM数据库里只存oss_url和file_size:
ALTER TABLE `attachments` ADD COLUMN `oss_url` varchar(500) NOT NULL DEFAULT '' COMMENT 'OSS直链地址', ADD COLUMN `file_size` int(11) NOT NULL DEFAULT 0 COMMENT '文件大小,单位字节';这样,销售上传1GB视频,CRM服务器内存占用为0,下载时走CDN,首屏加载<1秒。更重要的是,OSS自带版本控制和防盗链,比自己写/uploads/权限控制靠谱十倍。
3.3 改造三:报表引擎从“硬编码SQL”升级为“动态查询构建器”
原始DeskcommCRM的销售漏斗报表,是写死在report_pipeline.php里的SQL:
$sql = "SELECT stage, COUNT(*) as count FROM deals WHERE status='active' GROUP BY stage";当销售总监说“我要看华东区、近30天、金额>50万的商机分布”,开发就得改代码、发版、重启PHP-FPM。我用PHP数组定义查询规则,再动态拼SQL:
// report_builder.php $rules = [ 'table' => 'deals', 'select' => ['stage', 'COUNT(*) as count'], 'where' => [ ['field' => 'region', 'op' => '=', 'value' => '华东'], ['field' => 'created_at', 'op' => '>=', 'value' => date('Y-m-d', strtotime('-30 days'))], ['field' => 'amount', 'op' => '>', 'value' => 500000] ], 'group_by' => ['stage'] ]; $sql = "SELECT " . implode(', ', $rules['select']) . " FROM {$rules['table']} WHERE 1=1"; $params = []; foreach ($rules['where'] as $i => $cond) { $sql .= " AND {$cond['field']} {$cond['op']} ?"; $params[] = $cond['value']; } if (!empty($rules['group_by'])) { $sql .= " GROUP BY " . implode(', ', $rules['group_by']); } $result = $db->query($sql, $params)->fetch_all(MYSQLI_ASSOC);现在,销售总监在后台填个表单,选“区域=华东”、“时间=近30天”、“金额>50万”,系统自动生成SQL并执行。我甚至把$rules数组存进数据库custom_reports表,做成“我的常用报表”功能。这个改造让CRM从“IT部门的项目”变成“业务部门的工具”,这才是私有化真正的价值。
这三处改造,没有一行代码是炫技,全是为了解决真实业务场景中的卡点。它们共同指向一个事实:私有化CRM不是把SaaS换个地方跑,而是用代码重新定义客户管理的规则。
4. 实操全流程:从零搭建一个可商用的私有化CRM网站(含避坑清单)
现在,我们把前面所有设计落地为可执行的步骤。以下是在一台4核8G阿里云ECS(CentOS 7.9)上的完整部署过程,全程手敲命令,不依赖任何一键脚本。我会标注每一步的意图、常见错误和我的实测心得。
4.1 环境准备:PHP 7.4 + MySQL 5.7 + Nginx 1.20(拒绝“最新版陷阱”)
很多教程一上来就装PHP 8.x,结果DeskcommCRM的mysql_*函数报错。它用的是mysqli,但部分老代码依赖ext-mysql已被移除。所以必须锁定PHP 7.4:
# 1. 卸载系统自带PHP(CentOS 7默认是5.4) yum remove php* -y # 2. 添加Remi源(最稳定的PHP 7.4包) yum install epel-release -y yum install http://rpms.remirepo.net/enterprise/remi-release-7.rpm -y yum-config-manager --enable remi-php74 # 3. 安装指定版本(关键:必须加--enablerepo=remi-php74) yum install php php-cli php-mysqlnd php-gd php-xml php-mbstring php-zip php-curl -y # 4. 验证版本(必须输出7.4.x) php -v # 输出示例:PHP 7.4.33 (cli) (built: Oct 25 2022 00:00:00) ( NTS ) # 5. 安装MySQL 5.7(8.0的严格模式会让老SQL报错) rpm -Uvh https://dev.mysql.com/get/mysql57-community-release-el7-11.noarch.rpm yum install mysql-community-server -y systemctl start mysqld # 获取初始密码:grep 'temporary password' /var/log/mysqld.log mysql -uroot -p'初始密码' -e "ALTER USER 'root'@'localhost' IDENTIFIED BY 'YourStrongPass123!';"提示:不要用Docker跑MySQL!容器重启后
/var/lib/mysql目录权限易错乱,我见过三次因此导致CRM无法启动。物理机或云服务器的裸MySQL更稳。
4.2 源码部署:DeskcommCRM的最小化安装与安全加固
下载官方源码后,不是直接unzip,而是做三件事:
# 1. 创建专用用户(杜绝root运行Web服务) useradd -r -s /sbin/nologin crmweb chown -R crmweb:crmweb /var/www/html/ # 2. 解压并清理危险文件(原始包里有install.php、test/目录) unzip deskcomm-crm-v2.1.zip -d /tmp/crm/ rm -f /tmp/crm/install.php /tmp/crm/test/ /tmp/crm/README.md cp -r /tmp/crm/* /var/www/html/ # 3. 设置严格权限(关键!) chmod -R 755 /var/www/html/ chmod 644 /var/www/html/config.php # 配置文件只读 chmod 700 /var/www/html/uploads/ # 上传目录仅属主可写 chown -R crmweb:crmweb /var/www/html/config.php必须手动修改:
<?php define('DB_HOST', '127.0.0.1'); define('DB_NAME', 'deskcomm_crm'); define('DB_USER', 'crm_app'); define('DB_PASS', 'StrongPass456!'); // 不要用root密码! define('BASE_URL', 'https://crm.yourcompany.com'); // 必须配HTTPS域名 define('DEBUG', false); // 生产环境必须false! ?>注意:
BASE_URL必须配成你的实际域名,否则登录后跳转404。我第一次部署时填了http://localhost,结果销售用手机访问,OAuth回调失败,折腾3小时才发现。
4.3 HTTPS配置:Let's Encrypt免费证书的自动续期实战
不用付费SSL,用Certbot自动续期:
# 1. 安装Certbot yum install certbot python3-certbot-nginx -y # 2. 临时启用Nginx的HTTP服务(Certbot需要80端口验证) systemctl start nginx # 确保Nginx配置里有: # server { listen 80; server_name crm.yourcompany.com; return 301 https://$server_name$request_uri; } # 3. 申请证书(会自动修改Nginx配置) certbot --nginx -d crm.yourcompany.com # 4. 验证自动续期(Certbot会自动加crontab) systemctl list-timers | grep certbot # 输出应有:certbot.timer loaded active waiting /etc/cron.d/certbot # 5. 手动测试续期(重要!) certbot renew --dry-run # 如果输出"Congratulations, all renewals succeeded",说明OKNginx的HTTPS配置必须加这些头:
server { listen 443 ssl http2; server_name crm.yourcompany.com; ssl_certificate /etc/letsencrypt/live/crm.yourcompany.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/crm.yourcompany.com/privkey.pem; # 强制HSTS(防止降级攻击) add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; # 禁止MIME嗅探 add_header X-Content-Type-Options nosniff; # 防XSS add_header X-XSS-Protection "1; mode=block"; location / { root /var/www/html; index index.php; try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_index index.php; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; include fastcgi_params; } }实测心得:Certbot的
--dry-run必须每月手动跑一次!有次自动续期失败,但crontab没报错,直到证书过期销售登不上系统,才发现是DNS解析超时。现在我设了个企业微信机器人,certbot renew --dry-run失败就自动告警。
4.4 数据初始化:从SQL脚本到首个人员导入的完整链路
DeskcommCRM的install.sql不能直接用,它没设字符集。我重写了初始化脚本init_db.sql:
-- 1. 创建数据库(指定字符集) CREATE DATABASE `deskcomm_crm` CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; -- 2. 创建应用用户(最小权限原则) CREATE USER 'crm_app'@'localhost' IDENTIFIED BY 'StrongPass456!'; GRANT SELECT, INSERT, UPDATE, DELETE ON `deskcomm_crm`.* TO 'crm_app'@'localhost'; FLUSH PRIVILEGES; -- 3. 导入表结构(用修改后的schema.sql) source /var/www/html/schema.sql;执行:
mysql -uroot -p'YourStrongPass123!' < init_db.sql然后导入首个人员(销售总监):
# 用Excel整理人员名单:姓名、钉钉手机号、部门 # 导出为CSV,用这个脚本导入 php /var/www/html/scripts/import_users.php --csv /tmp/users.csvimport_users.php核心逻辑:
// 读CSV,查钉钉OpenAPI获取unionid,再插入users表 while (($row = fgetcsv($handle)) !== FALSE) { $mobile = $row[1]; // 调钉钉API查unionid(略) $unionid = getDingUnionidByMobile($mobile); $db->query("INSERT INTO users (name, department, ding_unionid) VALUES (?, ?, ?)", [$row[0], $row[2], $unionid]); }关键避坑:CSV导入前,必须用
iconv -f gbk -t utf-8 users.csv > users_utf8.csv转码!Windows Excel默认GBK,直接导入会导致中文乱码,且ding_unionid字段存的是乱码,后续钉钉登录失败。这个坑我踩了两次,现在所有CSV处理前必加file -i users.csv检查编码。
4.5 权限与审计:让CRM真正符合等保2.0基本要求
私有化不是为了省钱,而是为了合规。我加了三道审计锁:
- 操作日志表:新建
audit_logs表,所有UPDATE/DELETE操作都记录:CREATE TABLE `audit_logs` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` int(11) NOT NULL, `action` varchar(50) NOT NULL COMMENT 'update_contact, delete_deal', `table_name` varchar(50) NOT NULL, `record_id` int(11) NOT NULL, `old_data` text COMMENT 'JSON格式旧值', `new_data` text COMMENT 'JSON格式新值', `ip` varchar(45) NOT NULL, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_user_time` (`user_id`,`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; - 数据库只读账号:给BI工具用
crm_readonly账号,只授SELECT权限; - Nginx访问日志分离:CRM日志单独存
/var/log/nginx/crm.access.log,用Logrotate每日切割:# /etc/logrotate.d/nginx-crm /var/log/nginx/crm.access.log { daily missingok rotate 30 compress delaycompress notifempty create 644 nginx nginx sharedscripts postrotate if [ -f /var/run/nginx.pid ]; then kill -USR1 `cat /var/run/nginx.pid` fi endscript }
这套组合拳下来,CRM不再是“销售用的网站”,而是“公司客户资产的数字保险柜”。当法务问“谁能删客户数据”,我可以立刻给出audit_logs里action='delete_contact'的全部记录;当IT总监问“谁在查敏感字段”,我能从crm_readonly账号的慢查询日志里定位到具体IP。
5. 常见问题与排查技巧实录:那些没人告诉你的“幽灵故障”
部署完成后,你以为就结束了?不,真正的挑战在上线后的第一个月。以下是我在6个项目中遇到的高频问题,附带我的排查路径和终极解法。这些问题不会出现在官方文档里,但90%的私有化CRM都会撞上。
5.1 问题一:“登录成功,但首页空白,F12看Network全是404”
现象:销售输入钉钉账号,跳转/dashboard.php,页面白屏,Chrome开发者工具里dashboard.php返回200,但所有CSS/JS请求都是404。
排查路径:
- 先看Nginx错误日志:
tail -f /var/log/nginx/error.log,发现rewrite or internal redirection cycle while internally redirecting to "/index.php"; - 再看Nginx配置,发现
try_files $uri $uri/ /index.php?$query_string;这行,但/dashboard.php本身是真实文件,不应被重写; - 查DeskcommCRM源码,发现
dashboard.php里有require 'core/init.php';,而core/init.php又require 'config.php',但config.php权限是644,crmweb用户可读,没问题;
根因:/var/www/html/目录下有.htaccess文件(从Windows复制过来的),Nginx无视它,但某些PHP扩展会读取,导致路径解析错乱。
解法:rm -f /var/www/html/.htaccess,然后systemctl reload nginx。
实操心得:所有从Windows传到Linux的文件,第一件事就是
ls -la看有没有隐藏文件。.htaccess、Thumbs.db、Desktop.ini都是“幽灵故障”的元凶。
5.2 问题二:“上传文件时提示‘上传失败’,但OSS控制台显示文件已存在”
现象:销售点上传按钮,前端弹窗“上传失败”,但去阿里云OSS控制台看,文件确实在crm-attachments桶里。
排查路径:
- 查CRM的PHP错误日志:
tail -f /var/log/php-fpm/www-error.log,发现PHP Warning: file_get_contents(): SSL operation failed with code 1; - 查OSS的CORS配置,发现
AllowedOrigin只写了https://crm.yourcompany.com,但销售用http://crm.yourcompany.com访问(没强制HTTPS); - 查Nginx配置,发现
return 301 https://$server_name$request_uri;在80端口,但443端口没配add_header Content-Security-Policy "upgrade-insecure-requests";,导致HTTP页面里的JS尝试用HTTP请求OSS。
解法:
- 在Nginx 443 server块里加:
add_header Content-Security-Policy "upgrade-insecure-requests"; - 在OSS CORS里
AllowedOrigin加*(测试用)或http://crm.yourcompany.com(生产用); - 前端JS里所有OSS URL强制用
https://开头。
注意:OSS的
AllowedOrigin设*不安全,生产环境必须精确到域名。我现在的做法是,在CRM的config.php里定义OSS_CDN_URL = 'https://cdn.yourcompany.com',前端所有资源走CDN,OSS只存原始文件。
5.3 问题三:“销售说‘跟进记录不见了’,但数据库里数据完好”
现象:销售A在10:00创建跟进记录,10:05刷新页面,记录消失,但events表里created_at确实是10:00。
排查路径:
- 查
events表,发现contact_id字段为0; - 查
EventService.php的create()方法,发现$contactId = $this->getContactIdFromRelated($relatedTo, $relatedId);这行,当$relatedTo='deal'时,getContactIdFromRelated函数里有SELECT contact_id FROM deals WHERE id=?,但deals表里contact_id字段是NULL; - 查销售操作日志,发现他创建跟进时,选的是“关联商机”,但该商机还没绑定客户。
根因:业务逻辑漏洞——允许关联未绑定客户的商机。
解法:在create()方法开头加校验:
if ($relatedTo === 'deal') { $deal = $this->db->query("SELECT contact_id FROM deals WHERE id = ?", [$relatedId])->fetch_assoc(); if (empty($deal['contact_id'])) { throw new Exception("商机尚未关联客户,请先编辑商机"); } }这个Bug让我意识到:私有化CRM最大的风险不是技术,而是业务规则没对齐。现在我每上线一个新功能,必做三件事:1)写测试用例