数据中心同步

This commit is contained in:
乘风
2026-01-05 10:16:20 +08:00
parent 0457528dd0
commit ba0ebcf273
98 changed files with 28583 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
<?php
namespace app\utils;
/**
* API 响应辅助工具类
*
* 提供统一的 API 响应格式
*/
class ApiResponseHelper
{
/**
* 判断是否为开发环境
*
* @return bool
*/
protected static function isDevelopment(): bool
{
return config('app.debug', false) || env('APP_ENV', 'production') === 'development';
}
/**
* 成功响应
*
* @param mixed $data 响应数据
* @param string $message 响应消息
* @param int $httpCode HTTP状态码
* @return \support\Response
*/
public static function success($data = null, string $message = 'ok', int $httpCode = 200): \support\Response
{
$response = [
'code' => 0,
'msg' => $message,
];
if ($data !== null) {
$response['data'] = $data;
}
return json($response, $httpCode);
}
/**
* 错误响应
*
* @param string $message 错误消息
* @param int $code 错误码业务错误码非HTTP状态码
* @param int $httpCode HTTP状态码
* @param array<string, mixed> $extra 额外信息
* @return \support\Response
*/
public static function error(
string $message,
int $code = 400,
int $httpCode = 400,
array $extra = []
): \support\Response {
$response = [
'code' => $code,
'msg' => $message,
];
// 开发环境可以返回更多调试信息
if (self::isDevelopment() && !empty($extra)) {
$response = array_merge($response, $extra);
}
return json($response, $httpCode);
}
/**
* 异常响应
*
* @param \Throwable $exception 异常对象
* @param int $httpCode HTTP状态码
* @return \support\Response
*/
public static function exception(\Throwable $exception, int $httpCode = 500): \support\Response
{
// 记录错误日志
LoggerHelper::logError($exception);
$code = 500;
$message = '内部服务器错误';
// 根据异常类型设置错误码和消息
if ($exception instanceof \InvalidArgumentException) {
$code = 400;
$message = $exception->getMessage();
} elseif ($exception instanceof \RuntimeException) {
$code = 500;
$message = $exception->getMessage();
}
$response = [
'code' => $code,
'msg' => $message,
];
// 开发环境返回详细错误信息
if (self::isDevelopment()) {
$response['debug'] = [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $exception->getTraceAsString(),
];
}
return json($response, $httpCode);
}
/**
* 验证错误响应
*
* @param array<string, string> $errors 验证错误列表
* @return \support\Response
*/
public static function validationError(array $errors): \support\Response
{
$message = '参数验证失败';
if (!empty($errors)) {
$firstError = reset($errors);
$message = is_array($firstError) ? $firstError[0] : $firstError;
}
$response = [
'code' => 400,
'msg' => $message,
'errors' => $errors,
];
return json($response, 400);
}
}

View File

@@ -0,0 +1,143 @@
<?php
namespace app\utils;
/**
* 数据脱敏工具类
*
* 用于在接口返回和日志中脱敏敏感信息
*/
class DataMaskingHelper
{
/**
* 脱敏身份证号
*
* @param string|null $idCard 身份证号
* @return string 脱敏后的身份证号110101********1234
*/
public static function maskIdCard(?string $idCard): string
{
if (empty($idCard)) {
return '';
}
$config = config('encryption.masking.id_card', []);
$prefixLength = $config['prefix_length'] ?? 6;
$suffixLength = $config['suffix_length'] ?? 4;
$maskChar = $config['mask_char'] ?? '*';
$length = mb_strlen($idCard);
if ($length <= $prefixLength + $suffixLength) {
// 如果长度不足以脱敏,返回全部用*替代
return str_repeat($maskChar, $length);
}
$prefix = mb_substr($idCard, 0, $prefixLength);
$suffix = mb_substr($idCard, -$suffixLength);
$maskLength = $length - $prefixLength - $suffixLength;
return $prefix . str_repeat($maskChar, $maskLength) . $suffix;
}
/**
* 脱敏手机号
*
* @param string|null $phone 手机号
* @return string 脱敏后的手机号138****5678
*/
public static function maskPhone(?string $phone): string
{
if (empty($phone)) {
return '';
}
$config = config('encryption.masking.phone', []);
$prefixLength = $config['prefix_length'] ?? 3;
$suffixLength = $config['suffix_length'] ?? 4;
$maskChar = $config['mask_char'] ?? '*';
$length = mb_strlen($phone);
if ($length <= $prefixLength + $suffixLength) {
return str_repeat($maskChar, $length);
}
$prefix = mb_substr($phone, 0, $prefixLength);
$suffix = mb_substr($phone, -$suffixLength);
$maskLength = $length - $prefixLength - $suffixLength;
return $prefix . str_repeat($maskChar, $maskLength) . $suffix;
}
/**
* 脱敏邮箱
*
* @param string|null $email 邮箱
* @return string 脱敏后的邮箱ab***@example.com
*/
public static function maskEmail(?string $email): string
{
if (empty($email)) {
return '';
}
$config = config('encryption.masking.email', []);
$prefixLength = $config['prefix_length'] ?? 2;
$maskChar = $config['mask_char'] ?? '*';
$atPos = mb_strpos($email, '@');
if ($atPos === false) {
// 如果没有@符号,按普通字符串处理
$length = mb_strlen($email);
if ($length <= $prefixLength) {
return str_repeat($maskChar, $length);
}
$prefix = mb_substr($email, 0, $prefixLength);
return $prefix . str_repeat($maskChar, $length - $prefixLength);
}
$localPart = mb_substr($email, 0, $atPos);
$domain = mb_substr($email, $atPos);
$localLength = mb_strlen($localPart);
if ($localLength <= $prefixLength) {
$maskedLocal = str_repeat($maskChar, $localLength);
} else {
$prefix = mb_substr($localPart, 0, $prefixLength);
$maskedLocal = $prefix . str_repeat($maskChar, $localLength - $prefixLength);
}
return $maskedLocal . $domain;
}
/**
* 脱敏数组中的敏感字段
*
* @param array<string, mixed> $data 数据数组
* @param array<string> $sensitiveFields 敏感字段列表(如:['id_card', 'phone', 'email']
* @return array<string, mixed> 脱敏后的数组
*/
public static function maskArray(array $data, array $sensitiveFields = ['id_card', 'id_card_encrypted', 'phone', 'email']): array
{
$masked = $data;
foreach ($sensitiveFields as $field) {
if (isset($masked[$field]) && is_string($masked[$field])) {
switch ($field) {
case 'id_card':
case 'id_card_encrypted':
$masked[$field] = self::maskIdCard($masked[$field]);
break;
case 'phone':
$masked[$field] = self::maskPhone($masked[$field]);
break;
case 'email':
$masked[$field] = self::maskEmail($masked[$field]);
break;
}
}
}
return $masked;
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace app\utils;
/**
* 加密工具类
*
* 提供身份证等敏感数据的加密、解密和哈希功能
*/
class EncryptionHelper
{
/**
* 加密字符串(使用 AES-256-CBC
*
* @param string $plaintext 明文
* @return string 加密后的密文base64编码包含IV
* @throws \RuntimeException
*/
public static function encrypt(string $plaintext): string
{
if (empty($plaintext)) {
return '';
}
$config = config('encryption', []);
$keyString = $config['aes']['key'] ?? '';
$cipher = $config['aes']['cipher'] ?? 'AES-256-CBC';
$ivLength = $config['aes']['iv_length'] ?? 16;
if (empty($keyString)) {
throw new \InvalidArgumentException('加密密钥配置错误,密钥不能为空');
}
// 使用 SHA256 哈希处理密钥确保密钥长度为32字节AES-256需要256位密钥
// 即使原始密钥长度不够,哈希后也会得到固定长度的密钥
$key = substr(hash('sha256', $keyString), 0, 32);
// 生成随机IV
$iv = openssl_random_pseudo_bytes($ivLength);
if ($iv === false) {
throw new \RuntimeException('无法生成随机IV');
}
// 加密
$encrypted = openssl_encrypt($plaintext, $cipher, $key, OPENSSL_RAW_DATA, $iv);
if ($encrypted === false) {
throw new \RuntimeException('加密失败: ' . openssl_error_string());
}
// 将IV和密文组合然后base64编码
return base64_encode($iv . $encrypted);
}
/**
* 解密字符串
*
* @param string $ciphertext 密文base64编码包含IV
* @return string 解密后的明文
* @throws \RuntimeException
*/
public static function decrypt(string $ciphertext): string
{
if (empty($ciphertext)) {
return '';
}
$config = config('encryption', []);
$keyString = $config['aes']['key'] ?? '';
$cipher = $config['aes']['cipher'] ?? 'AES-256-CBC';
$ivLength = $config['aes']['iv_length'] ?? 16;
if (empty($keyString)) {
throw new \InvalidArgumentException('加密密钥配置错误,密钥不能为空');
}
// 使用 SHA256 哈希处理密钥确保密钥长度为32字节
// 即使原始密钥长度不够,哈希后也会得到固定长度的密钥
$key = substr(hash('sha256', $keyString), 0, 32);
// 解码base64
$data = base64_decode($ciphertext, true);
if ($data === false) {
throw new \RuntimeException('密文格式错误base64解码失败');
}
// 提取IV和密文
if (strlen($data) < $ivLength) {
throw new \RuntimeException('密文格式错误(长度不足)');
}
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
// 解密
$decrypted = openssl_decrypt($encrypted, $cipher, $key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
throw new \RuntimeException('解密失败: ' . openssl_error_string());
}
return $decrypted;
}
/**
* 计算字符串的哈希值(用于身份证匹配)
*
* @param string $plaintext 明文
* @return string 哈希值hex编码
*/
public static function hash(string $plaintext): string
{
if (empty($plaintext)) {
return '';
}
$config = config('encryption', []);
$algorithm = $config['hash']['algorithm'] ?? 'sha256';
$useSalt = $config['hash']['use_salt'] ?? false;
$salt = $config['hash']['salt'] ?? '';
$data = $plaintext;
if ($useSalt && !empty($salt)) {
$data = $salt . $plaintext;
}
return hash($algorithm, $data);
}
/**
* 验证明文是否匹配哈希值
*
* @param string $plaintext 明文
* @param string $hash 哈希值
* @return bool
*/
public static function verifyHash(string $plaintext, string $hash): bool
{
$calculatedHash = self::hash($plaintext);
return hash_equals($calculatedHash, $hash);
}
}

View File

@@ -0,0 +1,102 @@
<?php
namespace app\utils;
/**
* 身份证工具类
*
* 职责:
* - 从身份证号中提取出生日期
* - 从身份证号中提取性别
* - 验证身份证号格式
*/
class IdCardHelper
{
/**
* 从身份证号中提取出生日期
*
* @param string $idCard 身份证号15位或18位
* @return \DateTimeImmutable|null 出生日期解析失败返回null
*/
public static function extractBirthday(string $idCard): ?\DateTimeImmutable
{
$idCard = trim($idCard);
$length = strlen($idCard);
if ($length === 18) {
// 18位身份证第7-14位是出生日期YYYYMMDD
$birthDateStr = substr($idCard, 6, 8);
$year = (int)substr($birthDateStr, 0, 4);
$month = (int)substr($birthDateStr, 4, 2);
$day = (int)substr($birthDateStr, 6, 2);
} elseif ($length === 15) {
// 15位身份证第7-12位是出生日期YYMMDD
$birthDateStr = substr($idCard, 6, 6);
$year = (int)substr($birthDateStr, 0, 2);
$month = (int)substr($birthDateStr, 2, 2);
$day = (int)substr($birthDateStr, 4, 2);
// 15位身份证的年份需要加上1900或2000
// 通常出生年份在1900-2000之间如果大于当前年份的后两位则加1900否则加2000
$currentYearLastTwo = (int)date('y');
if ($year > $currentYearLastTwo) {
$year += 1900;
} else {
$year += 2000;
}
} else {
return null;
}
// 验证日期是否有效
if (!checkdate($month, $day, $year)) {
return null;
}
try {
return new \DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day));
} catch (\Throwable $e) {
return null;
}
}
/**
* 从身份证号中提取性别
*
* @param string $idCard 身份证号15位或18位
* @return int 性别1=男2=女0=未知
*/
public static function extractGender(string $idCard): int
{
$idCard = trim($idCard);
$length = strlen($idCard);
if ($length === 18) {
// 18位身份证第17位索引16是性别码
$genderCode = (int)substr($idCard, 16, 1);
} elseif ($length === 15) {
// 15位身份证第15位索引14是性别码
$genderCode = (int)substr($idCard, 14, 1);
} else {
return 0; // 未知
}
// 奇数表示男性,偶数表示女性
return ($genderCode % 2 === 1) ? 1 : 2;
}
/**
* 从身份证号中提取所有可解析的信息
*
* @param string $idCard 身份证号
* @return array<string, mixed> 包含 birthday 和 gender 的数组
*/
public static function extractInfo(string $idCard): array
{
return [
'birthday' => self::extractBirthday($idCard),
'gender' => self::extractGender($idCard),
];
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace app\utils;
use Monolog\Processor\ProcessorInterface;
/**
* 日志脱敏处理器
*
* 自动对日志中的敏感信息进行脱敏处理
* 兼容 Monolog 2.x使用 array 格式)
*/
class LogMaskingProcessor implements ProcessorInterface
{
/**
* 敏感字段列表
*
* @var array<string>
*/
protected array $sensitiveFields = [
'id_card',
'id_card_encrypted',
'id_card_hash',
'phone',
'email',
'password',
'token',
'secret',
];
/**
* 处理日志记录,对敏感信息进行脱敏
*
* @param array<string, mixed> $record Monolog 2.x 格式的日志记录数组
* @return array<string, mixed> 处理后的日志记录数组
*/
public function __invoke(array $record): array
{
// 处理 context 中的敏感信息
if (isset($record['context']) && is_array($record['context'])) {
$record['context'] = $this->maskArray($record['context']);
}
// 处理 extra 中的敏感信息
if (isset($record['extra']) && is_array($record['extra'])) {
$record['extra'] = $this->maskArray($record['extra']);
}
// 对消息本身也进行脱敏(如果包含敏感信息)
if (isset($record['message']) && is_string($record['message'])) {
$record['message'] = $this->maskString($record['message']);
}
return $record;
}
/**
* 脱敏数组中的敏感字段
*
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
protected function maskArray(array $data): array
{
$masked = [];
foreach ($data as $key => $value) {
$lowerKey = strtolower($key);
// 检查字段名是否包含敏感关键词
$isSensitive = false;
foreach ($this->sensitiveFields as $field) {
if (strpos($lowerKey, $field) !== false) {
$isSensitive = true;
break;
}
}
if ($isSensitive && is_string($value)) {
// 根据字段类型选择脱敏方法
if (strpos($lowerKey, 'phone') !== false) {
$masked[$key] = DataMaskingHelper::maskPhone($value);
} elseif (strpos($lowerKey, 'email') !== false) {
$masked[$key] = DataMaskingHelper::maskEmail($value);
} elseif (strpos($lowerKey, 'id_card') !== false) {
$masked[$key] = DataMaskingHelper::maskIdCard($value);
} else {
// 其他敏感字段,用*替代
$masked[$key] = str_repeat('*', min(strlen($value), 20));
}
} elseif (is_array($value)) {
$masked[$key] = $this->maskArray($value);
} else {
$masked[$key] = $value;
}
}
return $masked;
}
/**
* 脱敏字符串中的敏感信息(简单模式,匹配常见格式)
*
* @param string $message
* @return string
*/
protected function maskString(string $message): string
{
// 匹配身份证号18位或15位数字
$message = preg_replace_callback(
'/\b\d{15}(\d{3})?[Xx]?\b/',
function ($matches) {
return DataMaskingHelper::maskIdCard($matches[0]);
},
$message
);
// 匹配手机号11位数字1开头
$message = preg_replace_callback(
'/\b1[3-9]\d{9}\b/',
function ($matches) {
return DataMaskingHelper::maskPhone($matches[0]);
},
$message
);
// 匹配邮箱
$message = preg_replace_callback(
'/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/',
function ($matches) {
return DataMaskingHelper::maskEmail($matches[0]);
},
$message
);
return $message;
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace app\utils;
use Monolog\Logger;
/**
* 日志辅助工具类
*
* 提供结构化的日志记录方法
*/
class LoggerHelper
{
/**
* 记录请求日志
*
* @param string $method HTTP方法
* @param string $path 请求路径
* @param array<string, mixed> $params 请求参数
* @param float|null $duration 请求耗时(秒)
*/
public static function logRequest(string $method, string $path, array $params = [], ?float $duration = null): void
{
$logger = \support\Log::channel('default');
$context = [
'type' => 'request',
'method' => $method,
'path' => $path,
'params' => $params,
];
if ($duration !== null) {
$context['duration'] = round($duration * 1000, 2) . 'ms';
}
$logger->info("请求: {$method} {$path}", $context);
}
/**
* 记录业务日志
*
* @param string $action 操作名称
* @param array<string, mixed> $context 上下文信息
* @param string $level 日志级别info/warning/error
*/
public static function logBusiness(string $action, array $context = [], string $level = 'info'): void
{
$logger = \support\Log::channel('default');
$context['type'] = 'business';
$context['action'] = $action;
$logger->$level("业务操作: {$action}", $context);
}
/**
* 记录标签计算日志
*
* @param string $userId 用户ID
* @param string $tagId 标签ID
* @param array<string, mixed> $result 计算结果
* @param float|null $duration 计算耗时(秒)
*/
public static function logTagCalculation(string $userId, string $tagId, array $result, ?float $duration = null): void
{
$logger = \support\Log::channel('default');
$context = [
'type' => 'tag_calculation',
'user_id' => $userId,
'tag_id' => $tagId,
'result' => $result,
];
if ($duration !== null) {
$context['duration'] = round($duration * 1000, 2) . 'ms';
}
$logger->info("标签计算: user_id={$userId}, tag_id={$tagId}", $context);
}
/**
* 记录错误日志
*
* @param \Throwable $exception 异常对象
* @param array<string, mixed> $context 额外上下文
*/
public static function logError(\Throwable $exception, array $context = []): void
{
$logger = \support\Log::channel('default');
$context['type'] = 'error';
// 限制 trace 长度,避免内存溢出
$trace = $exception->getTraceAsString();
$originalTraceLength = strlen($trace);
$maxTraceLength = 5000; // 最大 trace 长度(字符数)
// 限制 trace 行数只保留前50行
$traceLines = explode("\n", $trace);
$originalLineCount = count($traceLines);
if ($originalLineCount > 50) {
$traceLines = array_slice($traceLines, 0, 50);
$trace = implode("\n", $traceLines) . "\n... (trace truncated, total lines: {$originalLineCount})";
}
// 限制 trace 字符长度
if (strlen($trace) > $maxTraceLength) {
$trace = substr($trace, 0, $maxTraceLength) . "\n... (trace truncated, total length: {$originalTraceLength} bytes)";
}
$context['exception'] = [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'trace' => $trace,
'class' => get_class($exception),
];
// 如果上下文数据太大,也进行限制
$contextJson = json_encode($context);
if (strlen($contextJson) > 10000) {
// 如果上下文太大,只保留关键信息
$context = [
'type' => 'error',
'exception' => [
'message' => $exception->getMessage(),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'class' => get_class($exception),
'trace' => substr($trace, 0, 2000) . '... (truncated)',
],
];
}
$logger->error("异常: {$exception->getMessage()}", $context);
}
/**
* 记录性能日志
*
* @param string $operation 操作名称
* @param float $duration 耗时(秒)
* @param array<string, mixed> $context 上下文信息
*/
public static function logPerformance(string $operation, float $duration, array $context = []): void
{
$logger = \support\Log::channel('default');
$context['type'] = 'performance';
$context['operation'] = $operation;
$context['duration'] = round($duration * 1000, 2) . 'ms';
$level = $duration > 1.0 ? 'warning' : 'info';
$logger->$level("性能: {$operation} 耗时 {$context['duration']}", $context);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace app\utils;
use MongoDB\Client;
/**
* MongoDB 连接辅助工具类
*
* 统一 MongoDB DSN 构建和客户端创建逻辑
*/
class MongoDBHelper
{
/**
* 构建 MongoDB DSN 连接字符串
*
* @param array<string, mixed> $config 数据库配置
* @return string DSN 字符串
*/
public static function buildDsn(array $config): string
{
$host = $config['host'] ?? '192.168.1.106';
$port = $config['port'] ?? 27017;
$username = $config['username'] ?? '';
$password = $config['password'] ?? '';
$authSource = $config['auth_source'] ?? 'admin';
if (!empty($username) && !empty($password)) {
return "mongodb://{$username}:{$password}@{$host}:{$port}/{$authSource}";
}
return "mongodb://{$host}:{$port}";
}
/**
* 创建 MongoDB 客户端
*
* @param array<string, mixed> $config 数据库配置
* @param array<string, mixed> $options 额外选项(可选)
* @return Client MongoDB 客户端实例
*/
public static function createClient(array $config, array $options = []): Client
{
$defaultOptions = [
'connectTimeoutMS' => 5000,
'socketTimeoutMS' => 5000,
];
return new Client(
self::buildDsn($config),
array_merge($defaultOptions, $options)
);
}
}

View File

@@ -0,0 +1,247 @@
<?php
namespace app\utils;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
use PhpAmqpLib\Channel\AMQPChannel;
use app\utils\LoggerHelper;
/**
* 队列服务封装
*
* 职责:
* - 封装 RabbitMQ 连接和消息推送
* - 提供统一的队列操作接口
*/
class QueueService
{
private static ?AMQPStreamConnection $connection = null;
private static ?AMQPChannel $channel = null;
private static array $config = [];
/**
* 初始化连接(单例模式)
*/
private static function initConnection(): void
{
if (self::$connection !== null && self::$connection->isConnected()) {
return;
}
$config = config('queue.connections.rabbitmq');
self::$config = $config;
try {
self::$connection = new AMQPStreamConnection(
$config['host'],
$config['port'],
$config['user'],
$config['password'],
$config['vhost'],
false, // insist
'AMQPLAIN', // login_method
null, // login_response
'en_US', // locale
$config['timeout'] ?? 10.0, // connection_timeout
$config['timeout'] ?? 10.0, // read_write_timeout
null, // context
false, // keepalive
$config['heartbeat'] ?? 0 // heartbeat
);
self::$channel = self::$connection->channel();
// 声明数据同步交换机
if (isset($config['exchanges']['data_sync'])) {
$exchangeConfig = $config['exchanges']['data_sync'];
self::$channel->exchange_declare(
$exchangeConfig['name'],
$exchangeConfig['type'],
false, // passive
$exchangeConfig['durable'],
$exchangeConfig['auto_delete']
);
}
// 声明标签计算交换机
if (isset($config['exchanges']['tag_calculation'])) {
$exchangeConfig = $config['exchanges']['tag_calculation'];
self::$channel->exchange_declare(
$exchangeConfig['name'],
$exchangeConfig['type'],
false, // passive
$exchangeConfig['durable'],
$exchangeConfig['auto_delete']
);
}
// 声明队列
if (isset($config['queues']['tag_calculation'])) {
$queueConfig = $config['queues']['tag_calculation'];
self::$channel->queue_declare(
$queueConfig['name'],
false, // passive
$queueConfig['durable'],
false, // exclusive
$queueConfig['auto_delete'],
false, // nowait
$queueConfig['arguments'] ?? []
);
// 绑定队列到交换机
if (isset($config['routing_keys']['tag_calculation'])) {
self::$channel->queue_bind(
$queueConfig['name'],
$config['exchanges']['tag_calculation']['name'],
$config['routing_keys']['tag_calculation']
);
}
}
LoggerHelper::logBusiness('queue_connection_established', [
'host' => $config['host'],
'port' => $config['port'],
]);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'QueueService',
'action' => 'initConnection',
]);
throw $e;
}
}
/**
* 推送消息到数据同步队列
*
* @param array<string, mixed> $data 消息数据包含数据源ID、数据记录等
* @return bool 是否推送成功
*/
public static function pushDataSync(array $data): bool
{
try {
self::initConnection();
$config = self::$config;
$messageConfig = config('queue.message', []);
$messageBody = json_encode($data, JSON_UNESCAPED_UNICODE);
$message = new AMQPMessage(
$messageBody,
[
'delivery_mode' => $messageConfig['delivery_mode'] ?? AMQPMessage::DELIVERY_MODE_PERSISTENT,
'content_type' => $messageConfig['content_type'] ?? 'application/json',
]
);
$exchangeName = $config['exchanges']['data_sync']['name'];
$routingKey = $config['routing_keys']['data_sync'];
self::$channel->basic_publish($message, $exchangeName, $routingKey);
LoggerHelper::logBusiness('queue_message_pushed', [
'queue' => 'data_sync',
'data' => $data,
]);
return true;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'QueueService',
'action' => 'pushDataSync',
'data' => $data,
]);
return false;
}
}
/**
* 推送消息到标签计算队列
*
* @param array<string, mixed> $data 消息数据
* @return bool 是否推送成功
*/
public static function pushTagCalculation(array $data): bool
{
try {
self::initConnection();
$config = self::$config;
$messageConfig = config('queue.message', []);
$messageBody = json_encode($data, JSON_UNESCAPED_UNICODE);
$message = new AMQPMessage(
$messageBody,
[
'delivery_mode' => $messageConfig['delivery_mode'] ?? AMQPMessage::DELIVERY_MODE_PERSISTENT,
'content_type' => $messageConfig['content_type'] ?? 'application/json',
]
);
$exchangeName = $config['exchanges']['tag_calculation']['name'];
$routingKey = $config['routing_keys']['tag_calculation'];
self::$channel->basic_publish($message, $exchangeName, $routingKey);
LoggerHelper::logBusiness('queue_message_pushed', [
'queue' => 'tag_calculation',
'data' => $data,
]);
return true;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'QueueService',
'action' => 'pushTagCalculation',
'data' => $data,
]);
return false;
}
}
/**
* 关闭连接
*/
public static function closeConnection(): void
{
try {
if (self::$channel !== null) {
self::$channel->close();
self::$channel = null;
}
if (self::$connection !== null && self::$connection->isConnected()) {
self::$connection->close();
self::$connection = null;
}
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'QueueService',
'action' => 'closeConnection',
]);
}
}
/**
* 获取通道(用于消费者)
*
* @return AMQPChannel
*/
public static function getChannel(): AMQPChannel
{
self::initConnection();
return self::$channel;
}
/**
* 获取连接(用于消费者)
*
* @return AMQPStreamConnection
*/
public static function getConnection(): AMQPStreamConnection
{
self::initConnection();
return self::$connection;
}
}

View File

@@ -0,0 +1,267 @@
<?php
namespace app\utils;
use Predis\Client;
use app\utils\LoggerHelper;
/**
* Redis 工具类
*
* 职责:
* - 封装 Redis 连接和基础操作
* - 提供分布式锁功能
*/
class RedisHelper
{
private static ?Client $client = null;
private static array $config = [];
/**
* 获取 Redis 客户端(单例模式)
*
* @return Client Redis 客户端
*/
public static function getClient(): Client
{
if (self::$client !== null) {
return self::$client;
}
// 从 session 配置中读取 Redis 配置(临时方案,后续可创建独立的 cache.php
$sessionConfig = config('session.config.redis', []);
self::$config = [
'host' => $sessionConfig['host'] ?? getenv('REDIS_HOST') ?: '127.0.0.1',
'port' => (int)($sessionConfig['port'] ?? getenv('REDIS_PORT') ?: 6379),
'password' => $sessionConfig['auth'] ?? getenv('REDIS_PASSWORD') ?: null,
'database' => (int)($sessionConfig['database'] ?? getenv('REDIS_DATABASE') ?: 0),
'timeout' => $sessionConfig['timeout'] ?? 2.0,
];
$parameters = [
'host' => self::$config['host'],
'port' => self::$config['port'],
];
if (!empty(self::$config['password'])) {
$parameters['password'] = self::$config['password'];
}
if (self::$config['database'] > 0) {
$parameters['database'] = self::$config['database'];
}
$options = [
'timeout' => self::$config['timeout'],
];
self::$client = new Client($parameters, $options);
// 测试连接
try {
self::$client->ping();
LoggerHelper::logBusiness('redis_connected', [
'host' => self::$config['host'],
'port' => self::$config['port'],
]);
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'getClient',
]);
throw $e;
}
return self::$client;
}
/**
* 获取分布式锁
*
* @param string $key 锁的键
* @param int $ttl 锁的过期时间(秒)
* @param int $retryTimes 重试次数
* @param int $retryDelay 重试延迟(毫秒)
* @return bool 是否获取成功
*/
public static function acquireLock(string $key, int $ttl = 300, int $retryTimes = 3, int $retryDelay = 1000): bool
{
$client = self::getClient();
$lockKey = "lock:{$key}";
$lockValue = uniqid(gethostname() . '_', true); // 唯一值,用于安全释放锁
for ($i = 0; $i <= $retryTimes; $i++) {
// 尝试获取锁SET key value NX EX ttl
$result = $client->set($lockKey, $lockValue, 'EX', $ttl, 'NX');
if ($result) {
LoggerHelper::logBusiness('redis_lock_acquired', [
'key' => $key,
'ttl' => $ttl,
]);
return true;
}
// 如果还有重试机会,等待后重试
if ($i < $retryTimes) {
usleep($retryDelay * 1000); // 转换为微秒
}
}
LoggerHelper::logBusiness('redis_lock_failed', [
'key' => $key,
'retry_times' => $retryTimes,
]);
return false;
}
/**
* 释放分布式锁
*
* @param string $key 锁的键
* @return bool 是否释放成功
*/
public static function releaseLock(string $key): bool
{
$client = self::getClient();
$lockKey = "lock:{$key}";
try {
$result = $client->del([$lockKey]);
if ($result > 0) {
LoggerHelper::logBusiness('redis_lock_released', [
'key' => $key,
]);
return true;
}
return false;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'releaseLock',
'key' => $key,
]);
return false;
}
}
/**
* 设置键值对
*
* @param string $key 键
* @param mixed $value 值
* @param int|null $ttl 过期时间null 表示不过期
* @return bool 是否设置成功
*/
public static function set(string $key, $value, ?int $ttl = null): bool
{
try {
$client = self::getClient();
$serialized = is_string($value) ? $value : json_encode($value, JSON_UNESCAPED_UNICODE);
if ($ttl !== null) {
$client->setex($key, $ttl, $serialized);
} else {
$client->set($key, $serialized);
}
return true;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'set',
'key' => $key,
]);
return false;
}
}
/**
* 获取键值
*
* @param string $key 键
* @return mixed 值,不存在返回 null
*/
public static function get(string $key)
{
try {
$client = self::getClient();
$value = $client->get($key);
if ($value === null) {
return null;
}
// 尝试 JSON 解码
$decoded = json_decode($value, true);
return $decoded !== null ? $decoded : $value;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'get',
'key' => $key,
]);
return null;
}
}
/**
* 删除键
*
* @param string $key 键
* @return bool 是否删除成功
*/
public static function delete(string $key): bool
{
try {
$client = self::getClient();
$result = $client->del([$key]);
return $result > 0;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'delete',
'key' => $key,
]);
return false;
}
}
/**
* 检查键是否存在
*
* @param string $key 键
* @return bool 是否存在
*/
public static function exists(string $key): bool
{
try {
$client = self::getClient();
$result = $client->exists($key);
return $result > 0;
} catch (\Throwable $e) {
LoggerHelper::logError($e, [
'component' => 'RedisHelper',
'action' => 'exists',
'key' => $key,
]);
return false;
}
}
/**
* 删除键别名兼容del方法
*
* @param string $key 键
* @return bool 是否删除成功
*/
public static function del(string $key): bool
{
return self::delete($key);
}
}