ThinkPHP 唯一索引冲突时需捕获 PDOException,解析 errorInfo()[2] 中的 key 名并映射到业务字段,才能精准提示;联合唯一索引需语义化兜底提示,映射关系须人工维护且依赖命名规范。

MySQL 唯一索引冲突时 ThinkPHP 默认只抛 QueryException,不告诉你哪个字段撞了
ThinkPHP 的 save() 或 create() 在遇到唯一索引冲突时,底层 PDO 抛出的是通用的 QueryException,错误信息类似 SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'xxx' for key 'xxx'。它不会自动解析出是 email 还是 username 字段重复——这导致前端无法精准提示“邮箱已被注册”还是“用户名已存在”。
根本原因是 MySQL 错误信息里虽然含字段名(比如 'user_email'),但 ThinkPHP 没做提取和映射,直接把原始 SQL 错误甩给你。
- 别指望
validate(true)能覆盖数据库层唯一约束:验证器只跑在写入前,无法感知并发写入或联合唯一场景 - 不要在控制器里 try/catch 后硬匹配错误字符串:MySQL 错误格式不稳定(不同版本、不同存储引擎返回字段名位置可能变)
- 真实场景中,
UNIQUE KEY `uk_email` (`email`)和UNIQUE KEY `uk_phone` (`phone`)共存时,必须区分响应
用 getPdo()->errorInfo() 提取原生错误码 + 错误消息,再正则匹配字段名
绕过 ThinkPHP 封装,直连 PDO 获取更细粒度错误信息,是目前最稳的方案。关键不是捕获异常,而是从异常里捞出 errorInfo() 的第三项(SQLSTATE 错误消息),再用正则定位字段名。
示例逻辑:
立即学习“PHP免费学习笔记(深入)”;
try {
$user = new User();
$user->save($data);
} catch (\think\exception\PDOException $e) {
$errorInfo = $e->getPdo()->errorInfo();
$sqlMessage = $errorInfo[2] ?? '';
if (preg_match("/Duplicate entry .*? for key '(.*?)'/", $sqlMessage, $matches)) {
$keyName = $matches[1];
// 映射:uk_email → email,uk_phone → phone
$fieldMap = [
'uk_email' => 'email',
'uk_phone' => 'phone',
'uk_username' => 'username',
];
$conflictField = $fieldMap[$keyName] ?? 'unknown';
throw new \think\Exception("字段 {$conflictField} 已存在");
}
}
- 必须用
PDOException(不是QueryException),因为只有它带getPdo()方法 -
$errorInfo[2]是 MySQL 原生错误消息,[1]是错误码(如 1062),[0]是 SQLSTATE - 正则里用单引号包裹 key 名是 MySQL 5.7+ 默认行为,但低版本可能用反引号,建议同时兼容:
/for key '(.*?)'|`(.*?)`/
在模型基类统一处理,避免每个 save() 都写 try/catch
把冲突解析逻辑下沉到模型基类的 save() 方法里,让业务模型继承后自动获得“友好提示”能力,比到处写重复 catch 更可持续。
在 app\common\model\Base.php 中重写:
public function save($data = [], $replace = false)
{
try {
return parent::save($data, $replace);
} catch (\think\exception\PDOException $e) {
$errorInfo = $e->getPdo()->errorInfo();
$msg = $errorInfo[2] ?? '';
if (preg_match("/for key '(.*?)'|`(.*?)`/", $msg, $matches)) {
$key = $matches[1] ?: $matches[2];
$field = $this->getConflictFieldByKeyName($key);
if ($field) {
throw new \think\Exception("字段 {$field} 的值已存在");
}
}
throw $e; // 不匹配则原样抛出
}
}
-
getConflictFieldByKeyName()是你定义的映射方法,可读取模型里的$uniqueKeys = ['uk_email' => 'email']配置 - 注意:该重写仅对主动调用
save()有效;update()、insert()等需单独处理 - TP6.1+ 支持
Db::transaction()内部也走同一套 PDO 实例,所以事务内冲突同样被捕获
联合唯一索引(如 UNIQUE KEY `uk_role_dept` (`role_id`, `dept_id`))怎么提示?
联合索引冲突时,MySQL 错误消息里仍只显示 key 名(如 uk_role_dept),不会列出具体哪几个字段重复。这时候不能靠字段名映射,得靠业务语义兜底。
- 在模型里显式声明联合约束:
protected $compositeUniques = ['uk_role_dept' => ['role_id', 'dept_id']] - 当检测到
uk_role_dept冲突时,不提示“role_id 或 dept_id 重复”,而提示“角色与部门组合已存在”——这是用户能理解的表达 - 如果必须拆解,可在保存前查一次:
User::where(['role_id' => $r, 'dept_id' => $d])->count(),但有并发风险,仅作辅助 - TP 自带的
unique验证规则不支持多字段,['role_id' => 'require|unique:user,role_id^dept_id', 'dept_id' => 'require']这种写法无效,别试
真正难的不是正则匹配字段名,而是 key 名和业务字段之间那层映射关系——它得人工维护,且一旦 DBA 改了索引名,提示就失效。所以最好在建表时约定命名规范,比如一律用 uk_{table}_{field} 或 uk_{table}_{f1}_{f2}。


















