Files
stemcell/app/code/Stemcell/Commerce/Model/Wechat/WechatAvatarService.php
T
2026-09-15 10:26:34 +08:00

573 lines
19 KiB
PHP

<?php
/**
* 2025 D1m Group
*
* NOTICE OF LICENSE
*
* This source file is subject to the Open Software License (OSL 3.0) that is available
* through the world-wide-web at this URL: http://www.opensource.org/licenses/OSL-3.0
*
* @copyright 2025 D1m Group
* @license http://www.opensource.org/licenses/OSL-3.0 Open Software License (OSL 3.0)
*/
namespace Stemcell\Commerce\Model\Wechat;
use Magento\Framework\App\Filesystem\DirectoryList;
use Magento\Framework\Filesystem;
use Magento\Framework\Filesystem\Io\File;
use Magento\Framework\HTTP\Client\Curl;
use Magento\Store\Model\StoreManagerInterface;
use Psr\Log\LoggerInterface;
/**
* 微信头像服务类
*/
class WechatAvatarService
{
private const AVATAR_DIR = 'profile/wechat_avatar';
private const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
private const REQUEST_TIMEOUT = 30; // 30秒超时
/**
* WechatAvatarService constructor.
*
* @param Filesystem $filesystem
* @param File $ioFile
* @param Curl $curl
* @param StoreManagerInterface $storeManager
* @param LoggerInterface $logger
*/
public function __construct(
private Filesystem $filesystem,
private File $ioFile,
private Curl $curl,
private StoreManagerInterface $storeManager,
private LoggerInterface $logger
) {
// 使用PHP 7.4+构造函数属性提升,无需单独赋值
}
/**
* 下载并保存微信头像
*
* @param string $avatarUrl 微信头像URL
* @param string $openid 用户openid
* @return string|null 返回本地头像路径,失败返回null
*/
public function downloadAndSaveAvatar(string $avatarUrl, string $openid): ?string
{
try {
// 1. 输入验证
if (empty($avatarUrl) || empty($openid)) {
$this->logger->warning('WechatAvatarService: Empty avatar URL or openid', [
'avatar_url' => $avatarUrl,
'openid' => $openid
]);
return null;
}
// 2. URL白名单验证
if (!$this->validateAvatarUrl($avatarUrl)) {
$this->logger->error('WechatAvatarService: Invalid avatar URL', [
'url' => $avatarUrl,
'openid' => $openid
]);
return null;
}
// 3. 检查文件大小(通过HEAD请求)
if (!$this->validateFileSize($avatarUrl)) {
$this->logger->error('WechatAvatarService: File too large', [
'url' => $avatarUrl,
'openid' => $openid
]);
return null;
}
// 4. 下载文件
$imageData = $this->downloadWithTimeout($avatarUrl);
if (empty($imageData)) {
$this->logger->error('WechatAvatarService: Failed to download avatar', [
'avatar_url' => $avatarUrl,
'openid' => $openid
]);
return null;
}
// 5. 检测和验证文件格式
$fileExtension = $this->detectImageFormat($imageData, $avatarUrl);
if (!$fileExtension) {
$this->logger->error('WechatAvatarService: Unsupported image format', [
'avatar_url' => $avatarUrl,
'openid' => $openid
]);
return null;
}
// 6. 验证文件内容
if (!$this->validateImageContent($imageData, $fileExtension)) {
$this->logger->error('WechatAvatarService: Invalid image content', [
'avatar_url' => $avatarUrl,
'openid' => $openid,
'extension' => $fileExtension
]);
return null;
}
// 7. 保存文件
$fileName = md5($openid) . '.' . $fileExtension;
$fullPath = self::AVATAR_DIR . '/' . $fileName;
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
$mediaDirectory->create(self::AVATAR_DIR);
$mediaDirectory->writeFile($fullPath, $imageData);
$this->logger->info('WechatAvatarService: Avatar saved successfully', [
'openid' => $openid,
'local_path' => $fullPath,
'file_extension' => $fileExtension
]);
return $fullPath;
} catch (\Exception $e) {
$this->logger->error('WechatAvatarService: Exception while downloading avatar', [
'avatar_url' => $avatarUrl,
'openid' => $openid,
'exception' => $e->getMessage()
]);
return null;
}
}
/**
* 验证头像URL是否在白名单中
*
* @param string $url
* @return bool
*/
private function validateAvatarUrl(string $url): bool
{
// 只允许微信CDN域名
$allowedDomains = [
'thirdwx.qlogo.cn',
'wx.qlogo.cn',
'mmbiz.qpic.cn'
];
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['host'])) {
return false;
}
foreach ($allowedDomains as $domain) {
if (str_ends_with($parsedUrl['host'], $domain)) {
return true;
}
}
return false;
}
/**
* 验证文件大小
*
* @param string $url
* @return bool
*/
private function validateFileSize(string $url): bool
{
try {
// 使用HEAD请求获取文件大小
$headers = get_headers($url, 1);
if (isset($headers['Content-Length'])) {
$fileSize = (int)$headers['Content-Length'];
if ($fileSize > self::MAX_FILE_SIZE) {
$this->logger->warning('WechatAvatarService: File too large', [
'url' => $url,
'size' => $fileSize,
'max_size' => self::MAX_FILE_SIZE
]);
return false;
}
}
return true;
} catch (\Exception $e) {
$this->logger->warning('WechatAvatarService: Failed to validate file size', [
'url' => $url,
'exception' => $e->getMessage()
]);
return true; // 如果无法验证大小,允许下载但会在后续步骤中检查
}
}
/**
* 带超时的文件下载
*
* @param string $url
* @return string
* @throws \Exception
*/
private function downloadWithTimeout(string $url): string
{
$this->curl->setTimeout(self::REQUEST_TIMEOUT);
$this->curl->setOption(CURLOPT_FOLLOWLOCATION, false); // 禁止重定向
$this->curl->setOption(CURLOPT_MAXREDIRS, 0);
$this->curl->setOption(CURLOPT_SSL_VERIFYPEER, true);
$this->curl->setOption(CURLOPT_SSL_VERIFYHOST, 2);
$this->curl->get($url);
if ($this->curl->getStatus() !== 200) {
throw new \Exception('Download failed with status: ' . $this->curl->getStatus());
}
return $this->curl->getBody();
}
/**
* 验证图片内容
*
* @param string $imageData
* @param string $extension
* @return bool
*/
private function validateImageContent(string $imageData, string $extension): bool
{
// 检查文件大小
if (strlen($imageData) > self::MAX_FILE_SIZE) {
$this->logger->warning('WechatAvatarService: Downloaded file too large', [
'size' => strlen($imageData),
'max_size' => self::MAX_FILE_SIZE
]);
return false;
}
// 对于SVG文件,进行额外的安全检查
if ($extension === 'svg') {
return $this->validateSvgContent($imageData);
}
return true;
}
/**
* 验证SVG文件内容
*
* @param string $svgData
* @return bool
*/
private function validateSvgContent(string $svgData): bool
{
// 检查是否包含危险标签
$dangerousTags = ['script', 'object', 'embed', 'iframe'];
foreach ($dangerousTags as $tag) {
if (stripos($svgData, "<$tag") !== false) {
$this->logger->warning('WechatAvatarService: SVG contains dangerous tag', ['tag' => $tag]);
return false;
}
}
// 检查是否包含JavaScript事件
$dangerousEvents = ['onload', 'onclick', 'onerror', 'onmouseover', 'onfocus'];
foreach ($dangerousEvents as $event) {
if (stripos($svgData, $event) !== false) {
$this->logger->warning('WechatAvatarService: SVG contains dangerous event', ['event' => $event]);
return false;
}
}
return true;
}
/**
* 检测图片格式并返回文件后缀
*
* @param string $imageData 图片数据
* @param string $avatarUrl 头像URL
* @return string|null 返回文件后缀,失败返回null
*/
private function detectImageFormat(string $imageData, string $avatarUrl): ?string
{
// 支持的图片格式
$supportedFormats = [
'jpeg' => ["\xFF\xD8\xFF"],
'jpg' => ["\xFF\xD8\xFF"],
'png' => ["\x89\x50\x4E\x47"],
'gif' => ["GIF87a", "GIF89a"],
'webp' => ["RIFF"],
'bmp' => ["BM"],
'svg' => ["<?xml", "<svg"]
];
// 首先尝试从URL中获取文件后缀
$urlExtension = $this->getExtensionFromUrl($avatarUrl);
if ($urlExtension && array_key_exists(strtolower($urlExtension), $supportedFormats)) {
$this->logger->info('WechatAvatarService: Detected format from URL', [
'extension' => $urlExtension,
'url' => $avatarUrl
]);
return strtolower($urlExtension);
}
// 通过文件头检测图片格式
foreach ($supportedFormats as $extension => $signatures) {
foreach ($signatures as $signature) {
if (strpos($imageData, $signature) === 0) {
$this->logger->info('WechatAvatarService: Detected format from file header', [
'extension' => $extension,
'signature' => bin2hex($signature)
]);
return $extension;
}
}
}
// 特殊处理SVG格式(可能没有XML声明)
if (strpos($imageData, '<svg') !== false) {
$this->logger->info('WechatAvatarService: Detected SVG format from content');
return 'svg';
}
$this->logger->warning('WechatAvatarService: Unknown image format', [
'url' => $avatarUrl,
'data_length' => strlen($imageData),
'first_bytes' => bin2hex(substr($imageData, 0, 16))
]);
return null;
}
/**
* 从URL中提取文件后缀
*
* @param string $url
* @return string|null
*/
private function getExtensionFromUrl(string $url): ?string
{
$parsedUrl = parse_url($url);
if (!isset($parsedUrl['path'])) {
return null;
}
$path = $parsedUrl['path'];
$extension = pathinfo($path, PATHINFO_EXTENSION);
if (empty($extension)) {
return null;
}
// 清理扩展名(移除查询参数等)
$extension = strtolower(trim($extension, '.'));
return $extension;
}
/**
* 删除头像文件
*
* @param string $openid 用户openid
* @return bool
*/
public function deleteAvatar(string $openid): bool
{
try {
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
$avatarDir = self::AVATAR_DIR;
// 支持的图片格式
$supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg'];
// 尝试删除所有可能的文件格式
$deleted = false;
foreach ($supportedExtensions as $extension) {
$fileName = md5($openid) . '.' . $extension;
$fullPath = $avatarDir . '/' . $fileName;
if ($mediaDirectory->isExist($fullPath)) {
$mediaDirectory->delete($fullPath);
$this->logger->info('WechatAvatarService: Avatar deleted successfully', [
'openid' => $openid,
'file_path' => $fullPath,
'extension' => $extension
]);
$deleted = true;
}
}
return $deleted;
} catch (\Exception $e) {
$this->logger->error('WechatAvatarService: Exception while deleting avatar', [
'openid' => $openid,
'exception' => $e->getMessage()
]);
return false;
}
}
/**
* 保存base64图片数据
*
* @param string $base64Data base64编码的图片数据
* @param string $openid 用户openid
* @return string|null 返回本地头像路径,失败返回null
*/
public function saveBase64Avatar(string $base64Data, string $openid): ?string
{
try {
// 1. 输入验证
if (empty($base64Data) || empty($openid)) {
$this->logger->warning('WechatAvatarService: Empty base64 data or openid', [
'openid' => $openid,
'data_length' => strlen($base64Data)
]);
return null;
}
// 2. 解析base64数据
$imageData = $this->parseBase64Data($base64Data);
if (!$imageData) {
$this->logger->error('WechatAvatarService: Invalid base64 data', [
'openid' => $openid
]);
return null;
}
// 3. 检查文件大小
if (strlen($imageData) > self::MAX_FILE_SIZE) {
$this->logger->error('WechatAvatarService: Base64 image too large', [
'openid' => $openid,
'size' => strlen($imageData),
'max_size' => self::MAX_FILE_SIZE
]);
return null;
}
// 4. 检测图片格式
$fileExtension = $this->detectImageFormatFromData($imageData);
if (!$fileExtension) {
$this->logger->error('WechatAvatarService: Unsupported image format from base64', [
'openid' => $openid
]);
return null;
}
// 5. 验证图片内容
if (!$this->validateImageContent($imageData, $fileExtension)) {
$this->logger->error('WechatAvatarService: Invalid image content from base64', [
'openid' => $openid,
'extension' => $fileExtension
]);
return null;
}
// 6. 保存文件
$fileName = md5($openid) . '.' . $fileExtension;
$fullPath = self::AVATAR_DIR . '/' . $fileName;
$mediaDirectory = $this->filesystem->getDirectoryWrite(DirectoryList::MEDIA);
$mediaDirectory->create(self::AVATAR_DIR);
$mediaDirectory->writeFile($fullPath, $imageData);
$this->logger->info('WechatAvatarService: Base64 avatar saved successfully', [
'openid' => $openid,
'local_path' => $fullPath,
'file_extension' => $fileExtension,
'file_size' => strlen($imageData)
]);
return $fullPath;
} catch (\Exception $e) {
$this->logger->error('WechatAvatarService: Exception while saving base64 avatar', [
'openid' => $openid,
'exception' => $e->getMessage()
]);
return null;
}
}
/**
* 解析base64数据
*
* @param string $base64Data
* @return string|null
*/
private function parseBase64Data(string $base64Data): ?string
{
try {
// 移除可能的数据URL前缀
if (strpos($base64Data, 'data:') === 0) {
$parts = explode(',', $base64Data, 2);
if (count($parts) !== 2) {
return null;
}
$base64Data = $parts[1];
}
// 解码base64数据
$decodedData = base64_decode($base64Data, true);
if ($decodedData === false) {
$this->logger->warning('WechatAvatarService: Invalid base64 encoding');
return null;
}
return $decodedData;
} catch (\Exception $e) {
$this->logger->error('WechatAvatarService: Exception parsing base64 data', [
'exception' => $e->getMessage()
]);
return null;
}
}
/**
* 从图片数据检测格式
*
* @param string $imageData
* @return string|null
*/
private function detectImageFormatFromData(string $imageData): ?string
{
// 支持的图片格式
$supportedFormats = [
'jpeg' => ["\xFF\xD8\xFF"],
'jpg' => ["\xFF\xD8\xFF"],
'png' => ["\x89\x50\x4E\x47"],
'gif' => ["GIF87a", "GIF89a"],
'webp' => ["RIFF"],
'bmp' => ["BM"],
'svg' => ["<?xml", "<svg"]
];
// 通过文件头检测图片格式
foreach ($supportedFormats as $extension => $signatures) {
foreach ($signatures as $signature) {
if (strpos($imageData, $signature) === 0) {
$this->logger->info('WechatAvatarService: Detected format from data', [
'extension' => $extension,
'signature' => bin2hex($signature)
]);
return $extension;
}
}
}
// 特殊处理SVG格式(可能没有XML声明)
if (strpos($imageData, '<svg') !== false) {
$this->logger->info('WechatAvatarService: Detected SVG format from data');
return 'svg';
}
$this->logger->warning('WechatAvatarService: Unknown image format from data', [
'data_length' => strlen($imageData),
'first_bytes' => bin2hex(substr($imageData, 0, 16))
]);
return null;
}
}