440 行
17 KiB
PHP
440 行
17 KiB
PHP
<?php
|
||
|
||
namespace app\common\library;
|
||
|
||
use think\Config;
|
||
use think\Log;
|
||
|
||
/**
|
||
* 腾讯云实名核验与 OCR 服务组件
|
||
* 封装腾讯云 API 3.0 TC3-HMAC-SHA256 签名,支持纯 API 身份证二要素权威比对与身份证文字识别
|
||
*/
|
||
class TencentVerifyService
|
||
{
|
||
const DEFAULT_REGION = 'ap-guangzhou';
|
||
|
||
/**
|
||
* 获取腾讯云 SecretId(严格读取站点配置或 .env,不在源码内硬编码密钥)
|
||
*/
|
||
public static function getSecretId()
|
||
{
|
||
$id = class_exists('\\think\\Config') ? \think\Config::get('site.tencent_secret_id') : null;
|
||
if (empty($id) && class_exists('\\think\\Env')) {
|
||
$id = \think\Env::get('tencent.secret_id', '');
|
||
}
|
||
return trim((string)$id);
|
||
}
|
||
|
||
/**
|
||
* 获取腾讯云 SecretKey(严格读取站点配置或 .env,不在源码内硬编码密钥)
|
||
*/
|
||
public static function getSecretKey()
|
||
{
|
||
$key = class_exists('\\think\\Config') ? \think\Config::get('site.tencent_secret_key') : null;
|
||
if (empty($key) && class_exists('\\think\\Env')) {
|
||
$key = \think\Env::get('tencent.secret_key', '');
|
||
}
|
||
return trim((string)$key);
|
||
}
|
||
|
||
protected static function log($msg, $type = 'info')
|
||
{
|
||
if (class_exists('\\think\\Log')) {
|
||
\think\Log::record($msg, $type);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 腾讯云 COS 数据万象动态防伪水印生成(方案1:URL规则实时加盖,不破坏原图)
|
||
*
|
||
* @param string $imageUrl 原图访问地址
|
||
* @param string $text 水印文字(默认:仅用于身份信息认证)
|
||
* @return string 带数据万象水印规则的安全访问地址
|
||
*/
|
||
public static function getWatermarkUrl($imageUrl, $text = '仅用于身份信息认证')
|
||
{
|
||
$imageUrl = trim((string)$imageUrl);
|
||
if (empty($imageUrl)) {
|
||
return '';
|
||
}
|
||
if (strpos($imageUrl, 'myqcloud.com') !== false || strpos($imageUrl, 'cos.') !== false) {
|
||
if (strpos($imageUrl, 'watermark') !== false) {
|
||
return $imageUrl;
|
||
}
|
||
$b64Text = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode($text));
|
||
$b64Color = str_replace(['+', '/', '='], ['-', '_', ''], base64_encode('#FFFFFF'));
|
||
$delimiter = strpos($imageUrl, '?') !== false ? '&' : '?';
|
||
return "{$imageUrl}{$delimiter}watermark/2/text/{$b64Text}/fill/{$b64Color}/fontsize/40/dissolve/85/shadow/60/gravity/center/degree/340";
|
||
}
|
||
return $imageUrl;
|
||
}
|
||
|
||
/**
|
||
* 身份证二要素核验(权威公安数据源比对)
|
||
* 传入真实姓名与身份证号码,校验二者是否一致与真实有效
|
||
*
|
||
* @param string $realname 真实姓名(如“张三”)
|
||
* @param string $idCardNo 18位身份证号码
|
||
* @return array ['code' => 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
|
||
];
|
||
}
|
||
}
|