int, 'pass' => bool, 'msg' => string, 'raw' => array] */ public static function verifyIdCard($realname, $idCardNo) { $realname = trim((string)$realname); $idCardNo = strtoupper(trim((string)$idCardNo)); if (empty($realname) || empty($idCardNo)) { return [ 'code' => -1, 'pass' => false, 'msg' => '姓名和身份证号不能为空', 'raw' => [] ]; } // 基础格式校验(18位合法身份证号格式) if (!preg_match('/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/', $idCardNo)) { return [ 'code' => -2, 'pass' => false, 'msg' => '身份证号码格式不正确', 'raw' => [] ]; } $payload = [ 'Name' => $realname, 'IdCard' => $idCardNo ]; try { $resp = self::request('faceid', '2018-03-01', 'IdCardVerification', $payload); if (!isset($resp['Response'])) { return [ 'code' => -99, 'pass' => false, 'msg' => '核验服务未返回有效响应', 'raw' => $resp ]; } $response = $resp['Response']; // 存在错误信息 if (isset($response['Error'])) { $errCode = $response['Error']['Code'] ?? ''; $errMsg = $response['Error']['Message'] ?? '核验服务异常'; self::log("[TencentVerify] IdCardVerification Error: {$errCode} - {$errMsg}", 'error'); return [ 'code' => -9, 'pass' => false, 'msg' => '实名核验接口调用失败:' . $errMsg, 'raw' => $response ]; } $result = (string)($response['Result'] ?? ''); $desc = (string)($response['Description'] ?? ''); // Result: 0 一致, -1 不一致, -2 非法身份证号, -3 非法姓名, -4 证件库服务异常, -5 证件库中无此身份证记录 if ($result === '0') { return [ 'code' => 0, 'pass' => true, 'msg' => '实名核验一致', 'raw' => $response ]; } $msgMap = [ '-1' => '姓名与身份证号不匹配,请核对后重试', '-2' => '身份证号码不合法,请检查输入', '-3' => '姓名格式不合法,请确认无误', '-4' => '全国公安公民身份库系统繁忙,请稍后重试', '-5' => '全国身份库中无此身份证记录,请核对信息' ]; $friendlyMsg = $msgMap[$result] ?? ($desc ?: '实名核验未通过,请核对信息'); return [ 'code' => (int)$result, 'pass' => false, 'msg' => $friendlyMsg, 'raw' => $response ]; } catch (\Exception $e) { self::log("[TencentVerify] Exception in verifyIdCard: " . $e->getMessage(), 'error'); return [ 'code' => -999, 'pass' => false, 'msg' => '实名核验系统通信异常:' . $e->getMessage(), 'raw' => [] ]; } } /** * 身份证 OCR 文字识别 * 支持公网 ImageUrl、本地文件路径自动 Base64 转换以及原始 Base64 编码 * * @param string $image 图片 URL 或本地文件相对路径/Base64 * @param string $cardSide FRONT:人像面, BACK:国徽面 * @return array ['code' => int, 'success' => bool, 'data' => array, 'msg' => string] */ public static function idCardOcr($image, $cardSide = 'FRONT') { $image = trim((string)$image); $cardSide = strtoupper(trim((string)$cardSide)) === 'BACK' ? 'BACK' : 'FRONT'; if (empty($image)) { return [ 'code' => -1, 'success' => false, 'data' => [], 'msg' => '图片地址不能为空' ]; } $payload = [ 'CardSide' => $cardSide ]; // 识别输入类型:若为 Base64 或本地文件,优先使用 ImageBase64(保障内网或CDN延迟时依然100%识别成功) if (strpos($image, 'data:image') === 0) { $payload['ImageBase64'] = preg_replace('/^data:image\/\w+;base64,/', '', $image); } elseif (strlen($image) > 500 && strpos($image, 'http') !== 0) { $payload['ImageBase64'] = $image; } elseif (preg_match('/^https?:\/\//i', $image)) { $payload['ImageUrl'] = $image; } else { // 本地相对路径检查 $publicPath = defined('ROOT_PATH') ? (ROOT_PATH . 'public') : (dirname(dirname(dirname(__DIR__))) . '/public'); $localFile = $publicPath . (strpos($image, '/') === 0 ? '' : '/') . $image; if (file_exists($localFile) && is_readable($localFile)) { $payload['ImageBase64'] = base64_encode(file_get_contents($localFile)); } else { $cdnUrl = class_exists('\\think\\Config') ? (\think\Config::get('upload.cdnurl') ?: \think\Config::get('site.cdnurl')) : ''; $payload['ImageUrl'] = !empty($cdnUrl) ? (rtrim($cdnUrl, '/') . '/' . ltrim($image, '/')) : $image; } } try { $resp = self::request('ocr', '2018-11-19', 'IDCardOCR', $payload); if (!isset($resp['Response'])) { return [ 'code' => -99, 'success' => false, 'data' => [], 'msg' => 'OCR 服务无有效响应' ]; } $response = $resp['Response']; if (isset($response['Error'])) { $errMsg = $response['Error']['Message'] ?? '识别失败'; return [ 'code' => -2, 'success' => false, 'data' => [], 'msg' => '身份证识别失败:' . $errMsg ]; } $parsed = [ 'side' => $cardSide, ]; if ($cardSide === 'FRONT') { $parsed['name'] = (string)($response['Name'] ?? ''); $parsed['sex'] = (string)($response['Sex'] ?? ''); $parsed['nation'] = (string)($response['Nation'] ?? ''); $parsed['birth'] = (string)($response['Birth'] ?? ''); $parsed['address'] = (string)($response['Address'] ?? ''); $parsed['idcard_no'] = (string)($response['IdNum'] ?? ''); } else { $authority = (string)($response['Authority'] ?? ''); $validDate = (string)($response['ValidDate'] ?? ''); $parsed['authority'] = $authority; $parsed['valid_date'] = $validDate; // 智能检测身份证是否已过有效期 $expireInfo = self::checkIdCardExpired($validDate); $parsed['is_expired'] = $expireInfo['is_expired']; $parsed['expire_info'] = $expireInfo; } return [ 'code' => 0, 'success' => true, 'data' => $parsed, 'msg' => '识别成功' ]; } catch (\Exception $e) { self::log("[TencentVerify] Exception in idCardOcr: " . $e->getMessage(), 'error'); return [ 'code' => -999, 'success' => false, 'data' => [], 'msg' => 'OCR 系统通信异常:' . $e->getMessage() ]; } } /** * 发送腾讯云 API 3.0 请求 (TC3-HMAC-SHA256 签名) * * @param string $service 服务标识 (如 faceid, ocr) * @param string $version 接口版本 (如 2018-03-01) * @param string $action 接口动作 (如 IdCardVerification) * @param array $payload 请求体 JSON 字典 * @param string $region 地域标识 * @return array * @throws \Exception */ public static function request($service, $version, $action, array $payload, $region = '') { $secretId = self::getSecretId(); $secretKey = self::getSecretKey(); if ($secretId === '' || $secretKey === '') { throw new \Exception('腾讯云实名核验密钥未配置,请在后台站点配置或 .env 中设置 tencent_secret_id / tencent_secret_key'); } $region = !empty($region) ? $region : self::DEFAULT_REGION; $host = "{$service}.tencentcloudapi.com"; $payloadJson = json_encode($payload, JSON_UNESCAPED_UNICODE); $timestamp = time(); $date = gmdate("Y-m-d", $timestamp); // 1. 规范请求串 $httpRequestMethod = "POST"; $canonicalUri = "/"; $canonicalQueryString = ""; $canonicalHeaders = "content-type:application/json; charset=utf-8\nhost:" . $host . "\nx-tc-action:" . strtolower($action) . "\n"; $signedHeaders = "content-type;host;x-tc-action"; $hashedRequestPayload = hash("sha256", $payloadJson); $canonicalRequest = $httpRequestMethod . "\n" . $canonicalUri . "\n" . $canonicalQueryString . "\n" . $canonicalHeaders . "\n" . $signedHeaders . "\n" . $hashedRequestPayload; // 2. 待签名字符串 $algorithm = "TC3-HMAC-SHA256"; $credentialScope = $date . "/" . $service . "/tc3_request"; $hashedCanonicalRequest = hash("sha256", $canonicalRequest); $stringToSign = $algorithm . "\n" . $timestamp . "\n" . $credentialScope . "\n" . $hashedCanonicalRequest; // 3. 计算签名 $secretDate = hash_hmac("sha256", $date, "TC3" . $secretKey, true); $secretService = hash_hmac("sha256", $service, $secretDate, true); $secretSigning = hash_hmac("sha256", "tc3_request", $secretService, true); $signature = hash_hmac("sha256", $stringToSign, $secretSigning); // 4. 组装请求头 $authorization = $algorithm . " " . "Credential=" . $secretId . "/" . $credentialScope . ", " . "SignedHeaders=" . $signedHeaders . ", " . "Signature=" . $signature; $headers = [ "Authorization: " . $authorization, "Content-Type: application/json; charset=utf-8", "Host: " . $host, "X-TC-Action: " . $action, "X-TC-Timestamp: " . $timestamp, "X-TC-Version: " . $version, "X-TC-Region: " . $region ]; // 5. 执行 cURL 请求 $ch = curl_init(); curl_setopt($ch, CURLOPT_URL, "https://" . $host); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $payloadJson); curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_TIMEOUT, 15); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); $response = curl_exec($ch); $curlError = curl_error($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($curlError) { throw new \Exception("cURL 执行失败: " . $curlError); } $decoded = json_decode($response, true); if ($decoded === null) { throw new \Exception("解析腾讯云响应 JSON 失败(HTTP {$httpCode}): {$response}"); } return $decoded; } /** * 智能检测身份证是否已过有效期 * * @param string $validDateStr 腾讯云 OCR 返回的有效期字符串,如 "2024.01.15-2044.01.15" 或 "2014.05.20-长期" * @return array ['has_date' => bool, 'is_expired' => bool, 'is_permanent' => bool, 'expire_date' => string, 'text' => string] */ public static function checkIdCardExpired($validDateStr) { $validDateStr = trim((string)$validDateStr); if (empty($validDateStr)) { return [ 'has_date' => false, 'is_expired' => false, 'is_permanent' => false, 'expire_date' => '', 'text' => '未检测到有效期限' ]; } // 若包含“长期”字样,判定永久有效 if (mb_strpos($validDateStr, '长期') !== false) { return [ 'has_date' => true, 'is_expired' => false, 'is_permanent' => true, 'expire_date' => '长期', 'text' => '长期有效' ]; } // 正则提取所有符合 YYYY.MM.DD / YYYY-MM-DD / YYYYMMDD 的日期片段 preg_match_all('/(\d{4})[\.\-\/年]?(\d{2})[\.\-\/月]?(\d{2})/', $validDateStr, $matches); if (!empty($matches[0])) { $lastIdx = count($matches[0]) - 1; $year = $matches[1][$lastIdx]; $month = $matches[2][$lastIdx]; $day = $matches[3][$lastIdx]; $formatted = "{$year}-{$month}-{$day}"; $expireTs = strtotime("{$formatted} 23:59:59"); $todayTs = strtotime(date('Y-m-d 00:00:00')); $isExpired = ($expireTs < $todayTs); return [ 'has_date' => true, 'is_expired' => $isExpired, 'is_permanent' => false, 'expire_date' => $formatted, 'text' => $formatted . ($isExpired ? '(已过期)' : '(有效)') ]; } return [ 'has_date' => true, 'is_expired' => false, 'is_permanent' => false, 'expire_date' => $validDateStr, 'text' => $validDateStr ]; } }