ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

中小企业私有化CRM自建指南:PHP+MySQL实现数据主权落地

中小企业私有化CRM自建指南:PHP+MySQL实现数据主权落地 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意三个细节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_NAMEdeskcomm_crm BACKUP_DIR/data/backup/$DATE mkdir -p $BACKUP_DIR # 1. 数据库导出含建表语句 mysqldump -u root -pyour_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. 上传至OSSossutil已配置好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_versionv2的Cookie他用新版测试两周没问题再全量切换。而回滚更简单rm -rf /var/www/html/v2 ln -s v1 /var/www/html/v23秒完成。这种设计让升级从“提心吊胆的手术”变成“按需切换的开关”。这四个原则不是理论是我在给教育机构部署时因未坚持第一条导致客户数据泄露被罚在给律所部署时因未打通第二条引发权限纠纷在给制造企业部署时因备份失效丢失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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;关键变化有三点type字段枚举化不再用content字段里写“【电话】今天跟王总聊了价格”而是明确标记typecall为后续BI分析打基础related_to/related_id解耦一条事件可以同时关联客户contact_id123和商机deal_id456比如“会议纪要”既更新客户状态也推进商机阶段时间戳双写created_at记录发生时间updated_at记录编辑时间销售修改会议纪要时created_at不变updated_at更新审计时一目了然。PHP层改造集中在EventService.phpclass EventService { public function create($type, $subject, $content, $relatedTo, $relatedId, $userId) { // 强制校验只有销售经理才能创建typetask的事件 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_sizeALTER 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 statusactive 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 11; $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阿里云ECSCentOS 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. 卸载系统自带PHPCentOS 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. 安装指定版本关键必须加--enablereporemi-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.78.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 rootlocalhost 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配置Lets 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-age31536000; includeSubDomains always; # 禁止MIME嗅探 add_header X-Content-Type-Options nosniff; # 防XSS add_header X-XSS-Protection 1; modeblock; 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_applocalhost IDENTIFIED BY StrongPass456!; GRANT SELECT, INSERT, UPDATE, DELETE ON deskcomm_crm.* TO crm_applocalhost; FLUSH PRIVILEGES; -- 3. 导入表结构用修改后的schema.sql source /var/www/html/schema.sql;执行mysql -uroot -pYourStrongPass123! 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;数据库只读账号给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里actiondelete_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权限是644crmweb用户可读没问题根因/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前端所有资源走CDNOSS只存原始文件。5.3 问题三“销售说‘跟进记录不见了’但数据库里数据完好”现象销售A在10:00创建跟进记录10:05刷新页面记录消失但events表里created_at确实是10:00。排查路径查events表发现contact_id字段为0查EventService.php的create()方法发现$contactId $this-getContactIdFromRelated($relatedTo, $relatedId);这行当$relatedTodeal时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写测试用例
返回列表