拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

信创环境CKEditor图片上传失败:PHP与国产数据库兼容性实战改造

信创环境CKEditor图片上传失败:PHP与国产数据库兼容性实战改造

前阵子一个老项目迁移到信创环境,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连接这些库,归纳下来只有三条路:

  1. 官方原生扩展,比如达梦提供的pdo_dm或dm扩展;
  2. 复用它兼容的生态驱动,比如金仓是PostgreSQL系,直接加载pdo_pgsql就能连;
  3. 走ODBC通用驱动,实在没有专用扩展时的兜底方案。

我的建议是:能用PDO就一律用PDO。原因很实际:PDO提供了统一的prepare/execute接口,未来就算从达梦换到金仓,业务代码不用动,只需改DSN和驱动配置。如果项目里还散落着一堆mysqli_query或pg_query,信创改造时你会改到怀疑人生。

具体到每种库的连接方式,整理成表格方便对照:

数据库类型PHP驱动DSN示意备注
达梦 DM8pdo_dm或官方ODBCdm://host:5236?database=DBNAME官方提供Linux/Windows扩展,需手动安装
人大金仓pdo_pgsqlpgsql:host=HOST;port=5432;dbname=DBNAME金仓兼容PostgreSQL协议,可直接复用PG驱动
GBase 8spdo_informix或pdo_odbc使用Informix DSN格式部分版本PHP没有官方驱动,ODBC兜底
OceanBase MySQL 模式pdo_mysqlmysql: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字段,数据库和文件一起走备份。

信创场景下,我强烈建议用“文件系统+路径入库”的方案。原因很实在:

  1. BLOB字段在国产库上的实现差异极大,达梦有BLOB,金仓用BYTEA或BLOB,GBase的BLOB和MySQL又有区别,读写代码改起来没完没了;
  2. 图片文件本来就要通过Web访问,放磁盘上由Nginx直接服务,性能比从数据库读出来再输出强一个数量级;
  3. 信创项目往往涉及等保备案,日志审计要求高,文件系统上的图片可以用对象存储或独立文件服务器统一管理,路径入库反而便于审计。

所以下面第三部分的完整实现,采用“文件落盘 + 数据库记路径”的方案。对于确实需要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)
长文本TEXTCLOBTEXTTEXT/CLOB
二进制BLOBBLOBBYTEABLOB
日期时间DATETIMETIMESTAMPTIMESTAMPDATETIME YEAR TO SECOND
布尔TINYINT(1)BITBOOLEANBOOLEAN

如果你的上传日志还带一个“上传来源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 中文乱码与字符集的三层问题

图片上传记录里如果包含中文文件名,很容易在某一个环节变成乱码。信创环境这个问题被进一步放大,因为:

  1. 前端页面编码必须UTF-8,TCP传输也是UTF-8;
  2. PHP连接数据库时,如果DSN或连接后没有设置SET NAMES,驱动会用数据库默认字符集(达梦默认可能不是UTF-8);
  3. 数据库表本身的字符集如果不是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。

解决步骤分三步:

  1. 确认达梦数据库安装目录下的samples里有没有配套的pdo_dm.so,或者从达梦官网驱动包中获取;
  2. 把扩展文件复制到PHP的extension_dir目录;
  3. 在php.ini里加上extension=pdo_dm.so,重启PHP-FPM,用php -m验证。

第四步才是关键:验证扩展加载状态。php -m的输出里如果有pdo_dm字样,才算加载成功。这一步千万不能跳过,因为PHP-FPM和CLI是两个独立的SAPI,命令行下加载了不等于Web进程加载了。

5.3 返回给前端的中文文件名乱码

症状:上传成功,但CKEditor正文中出现的图片名称是乱码,或者数据库里存的是乱码。

这种问题几乎都是字符集串了。按这个顺序排查:

  1. HTTP响应头是否设置charset=utf-8;
  2. PHP文件本身保存的编码是不是UTF-8(有的老编辑器是GBK编码保存的PHP文件,会自带BOM和乱码隐患);
  3. 数据库连接有没有执行SET NAMES UTF8;
  4. 数据库实例和表字段的字符集是不是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"; }

这个脚本我现在几乎每个项目都会留一份,价值不亚于业务代码本身。万一哪天换了新环境、换了新库,先跑脚本确认地基稳了,再谈业务功能,排查效率能翻一倍。

返回列表