feat(media): 微信消息媒体 OSS 归档与下载地址回写

- 新增 MediaArchiveJob、MediaOssArchiveService、WechatMediaArchiveService 与回填命令
- Message/DataProcessing 等支持归档调度与持久化下载 URL
- WebSocket 控制器整理;朋友圈与文档/定时任务说明更新

Made-with: Cursor
This commit is contained in:
wong
2026-04-13 17:23:02 +08:00
parent 3bf1b2aee9
commit b1bfcec35b
12 changed files with 970 additions and 134 deletions

View File

@@ -0,0 +1,261 @@
<?php
namespace app\common\service;
use app\common\util\AliyunOSS;
use think\facade\Log;
class MediaOssArchiveService
{
protected static $mimeExtensionMap = [
'image/jpeg' => 'jpg',
'image/jpg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/bmp' => 'bmp',
'image/svg+xml' => 'svg',
'video/mp4' => 'mp4',
'video/quicktime' => 'mov',
'video/x-msvideo' => 'avi',
'video/webm' => 'webm',
'audio/mpeg' => 'mp3',
'audio/mp3' => 'mp3',
'audio/wav' => 'wav',
'audio/x-wav' => 'wav',
'audio/amr' => 'amr',
'audio/aac' => 'aac',
'audio/mp4' => 'm4a',
'audio/ogg' => 'ogg',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/vnd.ms-powerpoint' => 'ppt',
'application/vnd.openxmlformats-officedocument.presentationml.presentation' => 'pptx',
'application/zip' => 'zip',
'application/x-rar-compressed' => 'rar',
'application/x-7z-compressed' => '7z',
'text/plain' => 'txt',
];
public static function isRemoteHttpUrl($url)
{
if (!is_string($url)) {
return false;
}
return (bool)preg_match('/^https?:\/\//i', trim($url));
}
public static function isOwnOssUrl($url)
{
if (!self::isRemoteHttpUrl($url)) {
return false;
}
$host = strtolower((string)parse_url($url, PHP_URL_HOST));
if (empty($host)) {
return false;
}
$customHost = strtolower((string)parse_url(AliyunOSS::ossUrl, PHP_URL_HOST));
$bucketHost = strtolower(AliyunOSS::BUCKET . '.' . AliyunOSS::ENDPOINT);
return $host === $customHost || $host === $bucketHost || strpos($host, strtolower(AliyunOSS::BUCKET . '.')) === 0;
}
public static function archiveRemoteUrl($url, $bizType, $resourceType, $bizKey, array $options = [])
{
$url = trim((string)$url);
if (!self::isRemoteHttpUrl($url)) {
return [
'success' => false,
'error' => 'URL不是有效的http(s)地址',
'url' => '',
];
}
if (self::isOwnOssUrl($url)) {
return [
'success' => true,
'alreadyArchived' => true,
'url' => self::normalizeOssUrl($url),
'originalUrl' => $url,
'object_name' => '',
];
}
$tempFile = '';
try {
$tempDir = self::ensureTempDirectory();
$tempFile = $tempDir . md5($url . microtime(true) . mt_rand()) . '.tmp';
$downloadResult = self::downloadToLocal($url, $tempFile, (int)($options['timeout'] ?? 60));
if (!$downloadResult['success']) {
return $downloadResult;
}
$extension = self::detectExtension(
$url,
$tempFile,
$downloadResult['contentType'] ?? '',
$options['extension'] ?? ''
);
$objectName = self::buildObjectName($bizType, $resourceType, $bizKey, $extension);
$result = AliyunOSS::uploadFile($tempFile, $objectName);
if (!$result['success']) {
return [
'success' => false,
'error' => $result['error'] ?? '上传OSS失败',
'url' => '',
];
}
return [
'success' => true,
'alreadyArchived' => false,
'url' => self::normalizeOssUrl($result['url'] ?? ''),
'originalUrl' => $url,
'object_name' => $objectName,
'extension' => $extension,
'mime_type' => $result['mime_type'] ?? ($downloadResult['contentType'] ?? ''),
'size' => $result['size'] ?? 0,
];
} catch (\Exception $e) {
Log::error('媒体资源上传OSS失败' . $e->getMessage(), [
'url' => $url,
'bizType' => $bizType,
'resourceType' => $resourceType,
'bizKey' => $bizKey,
]);
return [
'success' => false,
'error' => $e->getMessage(),
'url' => '',
];
} finally {
if (!empty($tempFile) && file_exists($tempFile)) {
@unlink($tempFile);
}
}
}
protected static function ensureTempDirectory()
{
if (function_exists('runtime_path')) {
$baseRuntimePath = rtrim(runtime_path(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} elseif (defined('RUNTIME_PATH')) {
$baseRuntimePath = rtrim(RUNTIME_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
} else {
$baseRuntimePath = rtrim(ROOT_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR;
}
$tempDir = $baseRuntimePath . 'temp' . DIRECTORY_SEPARATOR . 'media_archive' . DIRECTORY_SEPARATOR . date('Y' . DIRECTORY_SEPARATOR . 'm' . DIRECTORY_SEPARATOR . 'd') . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir)) {
mkdir($tempDir, 0755, true);
}
return $tempDir;
}
protected static function downloadToLocal($url, $tempFile, $timeout)
{
$ch = curl_init($url);
$fp = fopen($tempFile, 'wb');
curl_setopt($ch, CURLOPT_FILE, $fp);
curl_setopt($ch, CURLOPT_HEADER, 0);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
curl_setopt($ch, CURLOPT_USERAGENT, 'CKB-MediaArchive/1.0');
curl_exec($ch);
$curlError = curl_error($ch);
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$contentType = (string)curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
curl_close($ch);
fclose($fp);
if (!empty($curlError)) {
@unlink($tempFile);
return [
'success' => false,
'error' => '下载资源失败:' . $curlError,
'url' => '',
];
}
if ($httpCode < 200 || $httpCode >= 300 || !file_exists($tempFile) || filesize($tempFile) <= 0) {
@unlink($tempFile);
return [
'success' => false,
'error' => '下载资源失败HTTP状态码' . $httpCode,
'url' => '',
];
}
return [
'success' => true,
'contentType' => $contentType,
'httpCode' => $httpCode,
];
}
protected static function detectExtension($url, $tempFile, $contentType = '', $forcedExtension = '')
{
$forcedExtension = strtolower(trim((string)$forcedExtension, '. '));
if (!empty($forcedExtension)) {
return $forcedExtension;
}
$pathExtension = strtolower((string)pathinfo((string)parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (preg_match('/^[a-z0-9]{1,8}$/i', $pathExtension)) {
return $pathExtension;
}
$contentType = strtolower(trim((string)$contentType));
if (!empty($contentType)) {
$contentType = trim(explode(';', $contentType)[0]);
if (!empty(self::$mimeExtensionMap[$contentType])) {
return self::$mimeExtensionMap[$contentType];
}
}
$mimeType = '';
if (function_exists('mime_content_type')) {
$mimeType = strtolower((string)mime_content_type($tempFile));
}
if (!empty($mimeType) && !empty(self::$mimeExtensionMap[$mimeType])) {
return self::$mimeExtensionMap[$mimeType];
}
return 'bin';
}
protected static function buildObjectName($bizType, $resourceType, $bizKey, $extension)
{
$bizType = trim((string)$bizType, '/');
$resourceType = trim((string)$resourceType, '/');
$bizKey = preg_replace('/[^a-zA-Z0-9_\-]/', '_', (string)$bizKey);
$extension = trim((string)$extension, '.');
return $bizType . '/' . $resourceType . '/' . date('Y/m/d/') . $bizKey . '_' . substr(md5(uniqid('', true)), 0, 16) . '.' . $extension;
}
protected static function normalizeOssUrl($url)
{
$url = trim((string)$url);
if (strpos($url, 'http://') === 0) {
return 'https://' . substr($url, 7);
}
return $url;
}
}