文件
yjyf/server/application/common/library/TencentVerifyService.php

440 行
17 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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
];
}
}