前阵子一个老项目迁移到信创环境,OA系统里所有页面的图片突然都传不上去了。用户说得很简单:“编辑器里插入图片,点了没反应。”我远程上去一看,问题远比想象中复杂——CKEditor前端报404,PHP日志里全是数据库连接失败的堆栈,后台配置的国产数据库驱动根本没加载。这个组合坑,表面上是“图片上传失败”,实际上是前端编辑器、PHP上传链路、国产数据库兼容性三个环节叠在一起的连锁问题。
这篇文章就把整个排查和改造过程完整梳理一遍,从CKEditor上传机制讲到底层SQL方言差异,再到BLOB字段的读写法,全部基于真实环境。做国产化适配的PHP工程师、系统集成商的项目实施人员,或者正在把老系统往信创环境搬的朋友,可以直接照着改。
1. 先拆需求:为什么“编辑器传图”在信创环境特别容易翻车
1.1 一句话理解图片上传的完整链路
任何网页编辑器里插入图片,背后都不是一个组件能搞定的。它牵扯到至少四个参与者:
- 浏览器里的CKEditor,负责发上传请求、接收返回值并把图片渲染到正文;
- PHP接口,负责接收文件流、校验格式、保存到服务器磁盘;
- 数据库,负责记录文件元信息(路径、大小、上传人、时间);
- 服务器目录权限与Web配置,负责让上传好的图片能被浏览器访问到。
在普通X86+MySQL环境里,这套链路闭着眼睛都能跑通。可一旦整体换成信创环境,操作系统变了、数据库变了、PHP扩展重新编译了,任何一个环节没对齐,整条链路就断。最麻烦的是,国产数据库对PHP的支持远没有MySQL那么“无脑”——驱动要不要手动装、DSN怎么拼接、函数兼容到什么程度,全得自己趟。
1.2 CKEditor两种上传模式,行为完全不同
先搞清楚CKEditor是怎么传图的。CKEditor 4和5的上传模式差异很大,信创适配时不少人就是在这一步栽的跟头。
CKEditor 4的经典做法是配置filebrowserUploadUrl,指向一个PHP上传地址。它有两种提交方式:
form方式:编辑器内部创建一个隐藏的iframe,把文件按表单提交过去,后端返回一段JSON,CKEditor解析JSON里的url字段;xhr方式(默认):走XMLHttpRequest异步上传,后端同样返回JSON。
CKEditor 5则彻底改版,不再提供内置的filebrowserUploadUrl,必须自己实现一个UploadAdapter,通过loader.file拿到文件,然后用fetch或XMLHttpRequest发送到后端。
无论哪个版本,后端PHP接口返回的JSON格式都有严格约定。CKEditor 4的标准返回是:
{ "uploaded": 1, "fileName": "example.jpg", "url": "/uploads/example.jpg", "error": { "message": "可选错误信息" } }CKEditor 5的标准返回是:
{ "uploaded": true, "url": "/uploads/example.jpg" }这个JSON格式在信创改造中特别容易出问题。很多老项目用的是早期的CKEditor 4,上传接口返回的是{"success": true, "path": "..."}这种自定义结构,普通环境里配合自定义JS还能跑,但一旦换了新版编辑器组件或者前端框架,格式对不上,图片就永远插不进去。所以第一步不是急着改数据库,而是先把前端和上传接口的“接口协议”统一掉。
1.3 数据库在最末端,却最容易把整个功能拖死
很多人有个误区,觉得图片上传嘛,重点是文件和HTTP,数据库只是记个日志而已。但实际排错时发现,信创环境里最大的拦路虎恰恰是数据库。原因有三点:
- PHP没有现成的国产数据库扩展,得按不同的库手动编译或加载对应驱动;
- 不同国产库对PDO支持的成熟度差异很大,有的库PDO驱动有bug,有的库只能走ODBC;
- SQL方言不兼容,老项目里那些MySQL写法在国产库上直接报语法错误。
所以下面第二部分先讲架构选型,把“连库”这层地基打好,再往后做上传和入库。
2. 兼容性方案选型:先把数据库连接这层地基打牢
2.1 驱动选型:PDO是唯一不出错的选择
信创环境里能遇到的国产数据库,常见的有达梦、人大金仓、GBase、OceanBase、GaussDB等。PHP连接这些库,归纳下来只有三条路:
- 官方原生扩展,比如达梦提供的
pdo_dm或dm扩展; - 复用它兼容的生态驱动,比如金仓是PostgreSQL系,直接加载
pdo_pgsql就能连; - 走ODBC通用驱动,实在没有专用扩展时的兜底方案。
我的建议是:能用PDO就一律用PDO。原因很实际:PDO提供了统一的prepare/execute接口,未来就算从达梦换到金仓,业务代码不用动,只需改DSN和驱动配置。如果项目里还散落着一堆mysqli_query或pg_query,信创改造时你会改到怀疑人生。
具体到每种库的连接方式,整理成表格方便对照:
| 数据库类型 | PHP驱动 | DSN示意 | 备注 |
|---|---|---|---|
| 达梦 DM8 | pdo_dm或官方ODBC | dm://host:5236?database=DBNAME | 官方提供Linux/Windows扩展,需手动安装 |
| 人大金仓 | pdo_pgsql | pgsql:host=HOST;port=5432;dbname=DBNAME | 金仓兼容PostgreSQL协议,可直接复用PG驱动 |
| GBase 8s | pdo_informix或pdo_odbc | 使用Informix DSN格式 | 部分版本PHP没有官方驱动,ODBC兜底 |
| OceanBase MySQL 模式 | pdo_mysql | mysql:host=HOST;port=3306;dbname=DBNAME;charset=utf8mb4 | 完全兼容MySQL协议,连接层无痛 |
| GaussDB | 取决于内核模式 | 分布式增强版需用openGauss驱动,集中式可走ODBC | 兼容性坑多,建议先做冒烟测试 |
这一环节最容易踩的坑,是PHP环境里根本没装对应驱动。很多信创服务器出厂时预装的PHP只带了pdo_mysql,连达梦的扩展包都没编译进去。第一次连库直接抛could not find driver,你会完全摸不着头脑。
2.2 写一个DSN工厂,把连接配置收敛到一处
既然要兼容多种数据库,最忌讳的就是在业务代码里到处写死DSN。我习惯的做法是写一个简单的工厂函数,根据配置文件里的db_type字段动态拼接DSN。
<?php /** * 根据数据库类型组装PDO的DSN * 支持 dm / kingbase / gbase / oceanbase / gaussdb / mysql */ function buildDsn(array $dbConfig): string { $type = strtolower($dbConfig['db_type'] ?? 'mysql'); $host = $dbConfig['host'] ?? '127.0.0.1'; $port = $dbConfig['port'] ?? ''; $dbname = $dbConfig['dbname'] ?? ''; switch ($type) { case 'dm': // 达梦官方PDO驱动的DSN格式 return "dm://{$host}:{$port}?database={$dbname}"; case 'kingbase': // 金仓兼容PostgreSQL return "pgsql:host={$host};port={$port};dbname={$dbname};options='--client_encoding=UTF8'"; case 'gbase': // GBase 8s,走Informix兼容驱动 return "informix:host={$host};service={$port};database={$dbname};server=ol_gbase;protocol=onsoctcp"; case 'oceanbase': // OceanBase MySQL模式 return "mysql:host={$host};port={$port};dbname={$dbname};charset=utf8mb4"; case 'gaussdb': // GaussDB集中式,视环境走ODBC或PDO return "odbc:Driver={GaussDB};HOSTNAME={$host};PORT={$port};DATABASE={$dbname}"; case 'mysql': default: return "mysql:host={$host};port={$port};dbname={$dbname};charset=utf8mb4"; } }这个函数的重点在于,把“不同库DSN格式不同”这件事隔离在单个函数里。以后新增一种国产库,只改这一个函数,上传模块、日志模块、用户模块全都不用动。
2.3 数据库存储策略:路径入库还是BLOB入库
图片元数据要不要真正存进数据库?这里有两种流派:
- 文件系统存储,数据库只存路径(
file_url、file_name),这是绝大多数CMS的标准做法; - 图片二进制直接写进BLOB字段,数据库和文件一起走备份。
信创场景下,我强烈建议用“文件系统+路径入库”的方案。原因很实在:
- BLOB字段在国产库上的实现差异极大,达梦有
BLOB,金仓用BYTEA或BLOB,GBase的BLOB和MySQL又有区别,读写代码改起来没完没了; - 图片文件本来就要通过Web访问,放磁盘上由Nginx直接服务,性能比从数据库读出来再输出强一个数量级;
- 信创项目往往涉及等保备案,日志审计要求高,文件系统上的图片可以用对象存储或独立文件服务器统一管理,路径入库反而便于审计。
所以下面第三部分的完整实现,采用“文件落盘 + 数据库记路径”的方案。对于确实需要BLOB的场景,文章第四部分会单独给兼容写法。
3. 核心实现:完整上传链路编码实录
3.1 前端对接:CKEditor 4和5分别怎么配
先看CKEditor 4。简单粗暴的方式是在页面初始化时指定上传URL,让编辑器内部自己处理表单提交:
CKEDITOR.replace('editorContent', { filebrowserUploadUrl: '/upload/image.php', filebrowserUploadMethod: 'xhr' });如果存在跨域问题,比如前台上传域名和接口域名不同,CKEditor 4还支持jsonp方式:
CKEDITOR.replace('editorContent', { filebrowserUploadUrl: '/upload/image.php?callback=callback', filebrowserUploadMethod: 'jsonp' });注意这里后端必须有对应的callback参数处理逻辑,我习惯在PHP里统一判断:
<?php $callback = isset($_GET['callback']) ? preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['callback']) : ''; // 正常业务逻辑,组装$result数组 if ($callback) { header('Content-Type: application/javascript; charset=utf-8'); echo $callback . '(' . json_encode($result, JSON_UNESCAPED_UNICODE) . ')'; exit; } header('Content-Type: application/json; charset=utf-8'); echo json_encode($result, JSON_UNESCAPED_UNICODE);CKEditor 5就不一样了,必须在ClassicEditor.create时把自定义的UploadAdapter传进去。下面是兼容CKEditor 5的标准实现,代码里采用fetch方式发送,后端接口返回统一JSON。
class ImageUploadAdapter { constructor(loader) { this.loader = loader; } upload() { return this.loader.file.then( file => new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', file); formData.append('action', 'uploadImage'); fetch('/upload/image.php', { method: 'POST', body: formData, credentials: 'same-origin' }) .then(response => response.json()) .then(result => { if (result.uploaded) { resolve({ default: result.url }); } else { reject(result.error && result.error.message ? result.error.message : '上传失败'); } }) .catch(error => reject('网络错误: ' + error.message)); }) ); } abort() { // 需要取消上传时可以在这里调用AbortController中断请求 } } const editor = await ClassicEditor.create(document.querySelector('#editorContent'), { extraPlugins: [function ImageUploadAdapterPlugin(editor) { editor.plugins.get('FileRepository').createUploadAdapter = (loader) => { return new ImageUploadAdapter(loader); }; }] });这段代码踩过一个比较隐蔽的坑:CKEditor 5的resolve({default: result.url}),default是关键字,ES6的简写属性语法完全合法,但如果你用Babel转译且配置不对,这里会被转成'default': result.url然后依然正常工作,问题不大。真正容易出问题的是没判断result.uploaded,后端返回错误时前端还把url塞进正文,导致编辑器显示一个裂图。
3.2 PHP上传接口完整实现
后端的/upload/image.php是整个链路的枢纽。它要做的事情包括:校验文件、生成安全文件名、保存文件、记录数据库、返回CKEditor规定的JSON。下面是我在信创项目里实际用的版本,去掉了业务噪声,保留了核心逻辑。
<?php // 统一返回格式 function uploadResponse($uploaded, $fileName = '', $url = '', $errorMsg = '') { $result = [ 'uploaded' => $uploaded, 'fileName' => $fileName, 'url' => $url ]; if ($errorMsg !== '') { $result['error'] = ['message' => $errorMsg]; } return $result; } // 1. 检查请求和文件 if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['file'])) { echo json_encode(uploadResponse(0, '', '', '缺少上传文件'), JSON_UNESCAPED_UNICODE); exit; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { echo json_encode(uploadResponse(0, '', '', '文件上传过程出错,错误码:' . $file['error']), JSON_UNESCAPED_UNICODE); exit; } // 2. 校验扩展名和MIME $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); $allowExt = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp']; if (!in_array($ext, $allowExt, true)) { echo json_encode(uploadResponse(0, '', '', '不支持的图片格式:' . $ext), JSON_UNESCAPED_UNICODE); exit; } // 额外校验:用getimagesize防止伪造图片 $imageInfo = @getimagesize($file['tmp_name']); if ($imageInfo === false) { echo json_encode(uploadResponse(0, '', '', '文件内容不是有效图片'), JSON_UNESCAPED_UNICODE); exit; } // 3. 生成存储文件名,避免原始文件名带来的路径穿越和中文乱码问题 $datePath = date('Ymd'); $dir = __DIR__ . '/../uploads/' . $datePath . '/'; if (!is_dir($dir)) { mkdir($dir, 0755, true); } $newFileName = date('YmdHis') . '_' . mt_rand(10000, 99999) . '.' . $ext; $destFile = $dir . $newFileName; if (!move_uploaded_file($file['tmp_name'], $destFile)) { echo json_encode(uploadResponse(0, '', '', '文件保存失败,请检查目录权限'), JSON_UNESCAPED_UNICODE); exit; } $publicUrl = '/uploads/' . $datePath . '/' . $newFileName; // 4. 写入数据库记录(下面兼容层封装见3.3) $db = getPdoInstance(); $sql = "INSERT INTO t_upload_log (file_name, file_url, file_size, mime_type, create_time) VALUES (?, ?, ?, ?, ?)"; $stmt = $db->prepare($sql); $stmt->execute([ $file['name'], $publicUrl, $file['size'], $imageInfo['mime'], date('Y-m-d H:i:s') ]); // 5. 返回给CKEditor header('Content-Type: application/json; charset=utf-8'); echo json_encode(uploadResponse(1, $file['name'], $publicUrl), JSON_UNESCAPED_UNICODE);几个容易被忽略的细节:
- 文件名我从来不用用户原始文件名,而是用“日期+时间+随机数+扩展名”。原因有两个:一是原始文件名可能包含中文、空格、特殊字符,国产库的字符集设置如果不对,写入时就乱码;二是原始文件名可能是
../../evil.php这种路径穿越格式,虽然move_uploaded_file的目标目录已经写死,但多一层防御没有坏处。 - MIME校验不能只看
$_FILES['type'],这个值客户端可以伪造。getimagesize()是PHP内置函数,能真实解析图片文件头,伪造文件在这里会被拦住。
3.3 数据库访问的兼容封装
getPdoInstance()这个函数是兼容层的核心。它读取配置,实例化PDO,并设置异常模式和字符集。这里有个信创环境的特殊处理:达梦和金仓的PDO驱动,对ATTR_EMULATE_PREPARES的支持不太一样,我统一设置为false,让驱动走服务端预处理。
<?php function getPdoInstance(): PDO { static $pdo = null; if ($pdo instanceof PDO) { return $pdo; } $config = include __DIR__ . '/db_config.php'; $dsn = buildDsn($config); $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, ]; try { $pdo = new PDO($dsn, $config['username'], $config['password'], $options); } catch (PDOException $ex) { // 记录错误日志,不要直接打印DSN和密码到页面 error_log('DB CONNECT ERROR: ' . $ex->getMessage() . ' DSN TYPE: ' . $config['db_type']); throw new RuntimeException('数据库连接失败,请查看服务端日志', 500); } // 特殊兼容处理 $dbType = strtolower($config['db_type'] ?? ''); if ($dbType === 'kingbase' || $dbType === 'oceanbase') { $pdo->exec("SET NAMES 'utf8mb4'"); } elseif ($dbType === 'dm') { $pdo->exec("SET NAMES UTF8"); } return $pdo; }这段代码值得注意的一点是异常处理。生产环境的信创项目,数据库连接失败是家常便饭——驱动没装、端口被封、密码策略限制、数据库服务本身没启动。如果在页面直接输出$ex->getMessage(),PDO异常会把DSN字符串和用户名带出来,这是安全隐患。所以我在捕获异常后只记录日志,页面统一返回500。
3.4 上传记录的查询与分页兼容
上传日志管理界面通常需要分页查询。MySQL写惯了LIMIT,换成达梦或金仓就报错。这里给一个兼容的查询写法。
<?php function getUploadList(PDO $db, int $page, int $pageSize): array { $offset = ($page - 1) * $pageSize; $dbType = strtolower(getDbType()); switch ($dbType) { case 'dm': // 达梦支持LIMIT,但部分版本要求LIMIT后必须只有一个参数 $sql = "SELECT id, file_name, file_url, file_size, create_time FROM t_upload_log ORDER BY id DESC LIMIT $pageSize OFFSET $offset"; break; case 'gbase': // GBase 8s的分页语法是SKIP/FIRST $sql = "SELECT SKIP $offset FIRST $pageSize id, file_name, file_url, file_size, create_time FROM t_upload_log ORDER BY id DESC"; break; case 'kingbase': case 'oceanbase': case 'mysql': default: $sql = "SELECT id, file_name, file_url, file_size, create_time FROM t_upload_log ORDER BY id DESC LIMIT $pageSize OFFSET $offset"; break; } $stmt = $db->query($sql); return $stmt->fetchAll(); }我刻意在函数内部switch了SQL方言而不是用统一的LIMIT,是因为不同数据库对LIMIT的实现细节不一样。达梦的LIMIT语法和MySQL接近,但GBase 8s走的是SKIP/FIRST,金仓走PostgreSQL路线。如果你硬要写一条SQL兼容所有库,大概率两边都跑不了。
4. 国产数据库适配的硬核细节与DDL设计
4.1 自增主键:三种写法差异明显
兼容国产库时,最无语的就是建表语句。MySQL经典写法是AUTO_INCREMENT,金仓和达梦都不认。下面是同一张上传日志表在三种主流国产库下的DDL对照:
达梦:
CREATE TABLE t_upload_log ( id INT IDENTITY(1,1) PRIMARY KEY, file_name VARCHAR(255), file_url VARCHAR(255), file_size INT, mime_type VARCHAR(100), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );人大金仓:
CREATE TABLE t_upload_log ( id SERIAL PRIMARY KEY, file_name VARCHAR(255), file_url VARCHAR(255), file_size INT, mime_type VARCHAR(100), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );GBase 8s:
CREATE TABLE t_upload_log ( id SERIAL PRIMARY KEY, file_name VARCHAR(255), file_url VARCHAR(255), file_size INT, mime_type VARCHAR(100), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );lastInsertId()的兼容性也要注意。PDO的lastInsertId()在MySQL库上表现正常,但达梦驱动有些版本需要传入序列名才能正确返回,否则返回0。稳妥的做法是插入后直接查询刚刚写入的记录,或者用唯一业务字段回查:
<?php // 如果lastInsertId()返回0,使用回查方式 $stmt = $db->prepare("INSERT INTO t_upload_log (file_name, file_url, file_size, mime_type, create_time) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([...]); $newId = $db->lastInsertId(); if ((int)$newId === 0) { $query = $db->prepare("SELECT id FROM t_upload_log WHERE file_url = ? AND create_time = ? ORDER BY id DESC LIMIT 1"); $query->execute([$publicUrl, date('Y-m-d H:i:s')]); $newId = $query->fetchColumn(); }4.2 字段类型映射:别拿MySQL思维写DDL
国产数据库的字段类型,和MySQL不是一一对应的。老项目迁移时最容易踩的坑是TEXT、VARCHAR长度、DATETIME这些类型。
| 用途 | MySQL习惯 | 达梦 | 金仓 | GBase 8s |
|---|---|---|---|---|
| 短文本 | VARCHAR(255) | VARCHAR(255) | VARCHAR(255) | VARCHAR(255) |
| 长文本 | TEXT | CLOB | TEXT | TEXT/CLOB |
| 二进制 | BLOB | BLOB | BYTEA | BLOB |
| 日期时间 | DATETIME | TIMESTAMP | TIMESTAMP | DATETIME YEAR TO SECOND |
| 布尔 | TINYINT(1) | BIT | BOOLEAN | BOOLEAN |
如果你的上传日志还带一个“上传来源IP”的字段,IP地址在MySQL里能用VARCHAR(15)存,在达梦里VARCHAR长度定义如果超过255需要指定VARCHAR(2048)这种,超过这个边界就得用CLOB了,但IP根本用不着那么长。所以迁移时不要一股脑照搬,重新设计一下字段是正道。
4.3 字符串函数与拼接的兼容写法
业务逻辑中常见的CONCAT函数,MySQL支持,达梦和金仓也支持,但GBase 8s在某些版本里CONCAT只能接收两个参数,导致老代码里一句CONCAT(a, b, c)直接报错。
兼容写法有两个方向:
- 改成数据库标准的
||拼接符,所有主流国产库都支持; - 单独封装字符串拼接函数,根据库类型走不同SQL。
我推荐统一用||,这是最接近SQL标准的写法。举个例子:
<?php // 不兼容的写法(GBase可能报错) // $sql = "SELECT id, CONCAT(file_name, '|', mime_type) AS info FROM t_upload_log"; // 兼容写法 $sql = "SELECT id, file_name || '|' || mime_type AS info FROM t_upload_log";4.4 中文乱码与字符集的三层问题
图片上传记录里如果包含中文文件名,很容易在某一个环节变成乱码。信创环境这个问题被进一步放大,因为:
- 前端页面编码必须UTF-8,TCP传输也是UTF-8;
- PHP连接数据库时,如果DSN或连接后没有设置
SET NAMES,驱动会用数据库默认字符集(达梦默认可能不是UTF-8); - 数据库表本身的字符集如果不是UTF-8,写入就变问号。
三层缺一不可。我自己的项目组里吃过亏:达梦数据库建的实例字符集是GBK,页面和PHP都是UTF-8,导致前端传进来的中文文件名在SELECT出来时全部乱码。后来统一到SQL层面SET NAMES UTF8,并且把表字段都改成VARCHAR加CHARACTER SET UTF8,才算彻底解决。
如果用户上传的图片文件名本身就带着中文,我建议不要直接存原始文件名到数据库。稳妥做法是像第三部分那样,文件系统上存重命名后的ASCII文件名,数据库里另存一个original_name字段记录原始名称,这个字段用于展示和下载,不乱码也不影响磁盘。
5. 常见问题与排查实录
5.1 编辑器一直转圈,图片就是插不进去
症状:CKEditor 5点击上传按钮后一直加载动画,开发者工具能看到/upload/image.php返回200,但前端不渲染图片。
排查思路:先看返回的JSON是否符合CKEditor 5的规范。很多人后端返回的是{"success": 1, "data": {"url": "..."}}这种自定义结构,CKEditor 5需要的是{"uploaded": true, "url": "..."},识别不了自然就卡住。
解决办法很简单——两种方案任选其一:
- 改后端返回结构,统一成CKEditor标准;
- 在
UploadAdapter的upload()里做一次数据转换,把自定义结构映射成标准结构。
我强烈推荐第一种,因为标准结构不仅在CKEditor里能用,以后换任何编辑器,接口都能复用。
5.2 PHP连不上达梦数据库:could not find driver
症状:getPdoInstance()抛出PDOException: could not find driver。
原因非常直白:PHP环境里没有安装达梦的PDO扩展。很多信创服务器出厂时虽然装了PHP,但扩展列表里只有pdo_mysql和pgsql。
解决步骤分三步:
- 确认达梦数据库安装目录下的
samples里有没有配套的pdo_dm.so,或者从达梦官网驱动包中获取; - 把扩展文件复制到PHP的
extension_dir目录; - 在
php.ini里加上extension=pdo_dm.so,重启PHP-FPM,用php -m验证。
第四步才是关键:验证扩展加载状态。php -m的输出里如果有pdo_dm字样,才算加载成功。这一步千万不能跳过,因为PHP-FPM和CLI是两个独立的SAPI,命令行下加载了不等于Web进程加载了。
5.3 返回给前端的中文文件名乱码
症状:上传成功,但CKEditor正文中出现的图片名称是乱码,或者数据库里存的是乱码。
这种问题几乎都是字符集串了。按这个顺序排查:
- HTTP响应头是否设置
charset=utf-8; - PHP文件本身保存的编码是不是UTF-8(有的老编辑器是GBK编码保存的PHP文件,会自带BOM和乱码隐患);
- 数据库连接有没有执行
SET NAMES UTF8; - 数据库实例和表字段的字符集是不是UTF-8。
最诡异的一次排查经历是,前三个全对,数据库表也是UTF-8,但达梦实例的初始化参数CHARACTER_SET是GBK,导致JDBC和PHP写入时做了一次隐式转码,中文直接变俩问号。这个只能在数据库初始化时改,或者用UTF8参数重新建库。
5.4 上传大图时PHP直接报500或内存耗尽
症状:上传2MB以上的图片,接口返回500,PHP日志显示内存耗尽或者POST Content-Length超出限制。
信创环境的PHP配置文件经常被运维初始化为比较保守的值。需要检查并修改php.ini:
file_uploads = On upload_max_filesize = 20M post_max_size = 25M memory_limit = 128M max_file_uploads = 20注意post_max_size必须大于upload_max_filesize,否则文件本身没超过限制,但PHP会认为请求体超限直接拒绝。这是新手最容易忽略的点。另外,Nginx层也有client_max_body_size,默认为1MB,不改的话会先于PHP把请求拦掉。
5.5 跨域上传与JSONP的兼容处理
如果编辑器页面和上传接口不在同一个域名下,CKEditor 4的xhr方式会被浏览器的同源策略拦掉。解决思路有三个:
- 后端接口加CORS头,最简单;
- CKEditor 4走
jsonp方式,需要前端配置和后端callback配合; - 通过Nginx反代把上传接口代理到同域名下,最稳妥。
CORS头的写法就三行:
header('Access-Control-Allow-Origin: https://editor.example.com'); header('Access-Control-Allow-Methods: POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type');但信创内网环境经常有多个安全设备链路,加CORS头有时会被WAF策略拦掉预检请求。所以我的优先推荐是第三种:Nginx把/upload/前缀代理到后端的PHP接口,浏览器看到的是同源,从根上规避跨域。
5.6 上传接口权限与目录的坑
上传后图片无法访问,表现为404,这个和数据库无关,纯粹是目录和Web配置问题。
需要检查:
uploads目录是否存在,PHP进程(通常是www或nginx用户)是否有写权限;- 目录权限不能直接
chmod 777,生产环境建议chown给PHP运行用户,目录设置0755; - Nginx的
root路径配置是否正确,/uploads/是否映射到实际磁盘路径。
有一次我排查了半小时,发现是uploads目录挂载在一个独立的磁盘分区上,分区满了,文件写入失败但PHP没报错(move_uploaded_file返回了false但被日志淹没了)。所以建议每次上传失败都记录详细错误日志,包括磁盘剩余空间。
最后再分享一个排查技巧
这套东西做完之后,我最大的体会是:信创环境下的PHP项目调试,日志比什么都重要。普通环境下你还能用Xdebug断点走一遍,信创服务器上很多调试工具用不了,连扩展都是特殊编译的。所以从一开始就要把错误日志写全,error_log里至少要有时间、数据库类型、SQL语句、错误码、当前用户,这样出问题才能快速定位。
另外一个实用技巧是准备一个独立的“环境自检脚本”,放在项目的tools/目录里,专门用来验证数据库连接、字符集、上传目录权限和JSON返回格式。每次部署到一个新的信创环境,先跑一遍这个脚本,能在5分钟内把环境问题全部暴露,不用等业务方点出“图片传不了”才发现问题。
<?php // 自检脚本 check_environment.php echo "=== PHP版本 ===\n", phpversion(), "\n"; echo "=== 已加载PDO驱动 ===\n"; print_r(PDO::getAvailableDrivers()); echo "=== 上传限制 ===\n"; echo "upload_max_filesize:", ini_get('upload_max_filesize'), "\n"; echo "post_max_size:", ini_get('post_max_size'), "\n"; echo "=== 数据库连接 ===", "\n"; try { $db = getPdoInstance(); echo "连接成功,数据库版本: ", $db->getAttribute(PDO::ATTR_SERVER_VERSION), "\n"; $db->exec("SET NAMES UTF8"); echo "字符集设置完成\n"; } catch (Exception $ex) { echo "数据库连接失败: ", $ex->getMessage(), "\n"; }这个脚本我现在几乎每个项目都会留一份,价值不亚于业务代码本身。万一哪天换了新环境、换了新库,先跑脚本确认地基稳了,再谈业务功能,排查效率能翻一倍。