From 1deebf3f5c69ed10cef2c3b5755ba5fbd9a72a7c Mon Sep 17 00:00:00 2001 From: wong <106998207@qq.com> Date: Tue, 14 Apr 2026 09:37:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E5=BE=AE=E4=BF=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=AA=92=E4=BD=93=20OSS=20=E5=BD=92=E6=A1=A3=E4=B8=8E?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=9C=B0=E5=9D=80=E5=9B=9E=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MediaArchiveJob、MediaOssArchiveService、WechatMediaArchiveService 与回填命令 - Message/DataProcessing 等支持归档调度与持久化下载 URL - WebSocket 控制器整理;朋友圈与文档/定时任务说明更新 Made-with: Cursor --- .gitignore | 3 + Server/README_scheduler.md | 2 +- .../api/controller/MessageController.php | 36 ++ .../api/controller/WebSocketController.php | 91 +---- .../chukebao/controller/DataProcessing.php | 30 ++ Server/application/command.php | 1 + .../command/BackfillMediaOssCommand.php | 121 ++++++ .../command/SyncWechatDataToCkbTask.php | 2 +- .../common/service/MediaOssArchiveService.php | 261 ++++++++++++ .../service/WechatMediaArchiveService.php | 372 ++++++++++++++++++ .../wechat/GetWechatMomentsV1Controller.php | 15 +- Server/application/job/MediaArchiveJob.php | 57 +++ Server/crontab_tasks.md | 116 +++--- .../components/AudioMessage/AudioMessage.tsx | 4 +- .../components/FileMessage/index.tsx | 12 +- .../components/VideoMessage/index.tsx | 12 +- .../messageTypes/ImageMessage.tsx | 14 +- .../FriendsCicle/components/friendCard.tsx | 19 +- .../src/store/module/websocket/msgManage.ts | 29 +- 19 files changed, 1046 insertions(+), 151 deletions(-) create mode 100644 Server/application/command/BackfillMediaOssCommand.php create mode 100644 Server/application/common/service/MediaOssArchiveService.php create mode 100644 Server/application/common/service/WechatMediaArchiveService.php create mode 100644 Server/application/job/MediaArchiveJob.php diff --git a/.gitignore b/.gitignore index 3048ae356..68fcda8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ Touchkebao/.specstory/ Serverruntime/ Moncter/提示词/ *.log +*.code-workspace +项目整体分析报告.md +Server/docs/traffic_pool_design.md diff --git a/Server/README_scheduler.md b/Server/README_scheduler.md index 4c485684f..64d7d6ebd 100644 --- a/Server/README_scheduler.md +++ b/Server/README_scheduler.md @@ -44,7 +44,7 @@ ```bash # 每分钟执行一次调度器(调度器内部会根据 cron 表达式判断哪些任务需要执行) -* * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think scheduler:run >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/scheduler.log 2>&1 +* * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think scheduler:run >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/scheduler.log 2>&1 ``` ### 4. 系统要求 diff --git a/Server/application/api/controller/MessageController.php b/Server/application/api/controller/MessageController.php index 8e4dd7e4e..ff521bb98 100644 --- a/Server/application/api/controller/MessageController.php +++ b/Server/application/api/controller/MessageController.php @@ -4,7 +4,10 @@ namespace app\api\controller; use app\api\model\WechatMessageModel; use app\common\service\FriendTransferService; +use app\common\service\WechatMediaArchiveService; +use app\job\MediaArchiveJob; use think\Db; +use think\facade\Log; use think\facade\Request; class MessageController extends BaseController @@ -418,6 +421,7 @@ class MessageController extends BaseController 'type' => 1, 'accountId' => $item['accountId'], 'content' => $item['content'], + 'originalContent' => $item['content'], 'createTime' => $createTime, 'deleteTime' => $deleteTime, 'isDeleted' => $item['isDeleted'] ?? false, @@ -463,6 +467,9 @@ class MessageController extends BaseController }else{ $id = $data['id']; unset($data['id']); + if (!empty($exists['originalContent'])) { + unset($data['originalContent']); + } $res = $exists->save($data); } @@ -496,6 +503,9 @@ class MessageController extends BaseController } } } + if (in_array((int)($item['msgType'] ?? 0), [3, 34, 43, 47, 49], true) && !empty($id)) { + MediaArchiveJob::dispatch('message', $id, ['source' => 'saveMessage']); + } return true; } @@ -577,6 +587,9 @@ class MessageController extends BaseController throw new \Exception('更新群聊消息记录失败'); } } + if (in_array((int)($item['msgType'] ?? 0), [3, 34, 43, 47, 49], true)) { + MediaArchiveJob::dispatch('message', $item['id'], ['source' => 'saveChatroomMessage']); + } return true; } catch (\Exception $e) { // 记录错误日志,便于调试 @@ -588,6 +601,29 @@ class MessageController extends BaseController } } + /** + * 持久化视频/文件下载后的真实地址,并重新触发 OSS 归档 + * @param array $data + * @return bool + */ + public function updateDownloadedMessageMedia($data) + { + $messageId = (int)($data['friendMessageId'] ?? $data['chatroomMessageId'] ?? 0); + $downloadUrl = trim((string)($data['url'] ?? '')); + + if ($messageId <= 0 || empty($downloadUrl)) { + return false; + } + + $updated = WechatMediaArchiveService::updateDownloadedMessageMedia($messageId, $downloadUrl); + if (!$updated) { + return false; + } + + MediaArchiveJob::dispatch('message', $messageId, ['source' => $data['type'] ?? 'download_result']); + return true; + } + /** * 处理消息内容,提取发送者ID和消息内容 * @param string $content 原始消息内容 diff --git a/Server/application/api/controller/WebSocketController.php b/Server/application/api/controller/WebSocketController.php index 2c6424507..bbc954263 100644 --- a/Server/application/api/controller/WebSocketController.php +++ b/Server/application/api/controller/WebSocketController.php @@ -12,7 +12,7 @@ use think\facade\Env; use app\api\model\WechatFriendModel as WechatFriend; use app\api\model\WechatMomentsModel as WechatMoments; use think\facade\Cache; -use app\common\util\AliyunOSS; +use app\common\service\MediaOssArchiveService; class WebSocketController extends BaseController @@ -468,7 +468,7 @@ class WebSocketController extends BaseController // 更新数据库:保存原始URL和OSS URL,并标记已上传 $updateData = [ 'resUrls' => $urls, - 'isOssUploaded' => 1, // 标识已上传到OSS + 'isOssUploaded' => !empty($ossUrls) ? 1 : 0, 'update_time' => time() ]; @@ -513,7 +513,7 @@ class WebSocketController extends BaseController // 更新数据库:保存原始URL和OSS URL,并标记已上传 $updateData = [ 'resUrls' => $urls, - 'isOssUploaded' => 1, // 标识已上传到OSS + 'isOssUploaded' => !empty($ossUrls) ? 1 : 0, 'update_time' => time() ]; @@ -557,76 +557,23 @@ class WebSocketController extends BaseController if (empty($urls) || !is_array($urls)) { return $ossUrls; } - - try { - // 创建临时目录(兼容无 runtime_path() 辅助函数的环境) - 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 { - // 兜底:使用项目根目录下的 runtime 目录 - $baseRuntimePath = rtrim(ROOT_PATH, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'runtime' . DIRECTORY_SEPARATOR; + + foreach ($urls as $url) { + if (!MediaOssArchiveService::isRemoteHttpUrl($url)) { + continue; } - $tempDir = $baseRuntimePath . 'temp' . DIRECTORY_SEPARATOR . 'moments' . DIRECTORY_SEPARATOR . date('Y' . DIRECTORY_SEPARATOR . 'm' . DIRECTORY_SEPARATOR . 'd') . DIRECTORY_SEPARATOR; + $resourceType = preg_match('/\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i', $url) ? 'video' : 'image'; + $result = MediaOssArchiveService::archiveRemoteUrl($url, 'moments', $resourceType, (string)$snsId); + if (!empty($result['success']) && !empty($result['url'])) { + $ossUrls[] = $result['url']; + continue; + } - if (!is_dir($tempDir)) { - mkdir($tempDir, 0755, true); - } - - foreach ($urls as $index => $url) { - if (empty($url)) { - continue; - } - - try { - // 下载图片到临时文件 - $tempFile = $tempDir . md5($url . $snsId . $index) . '.jpg'; - - // 使用curl下载图片 - $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, 30); - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); - curl_exec($ch); - $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); - fclose($fp); - - if ($httpCode != 200 || !file_exists($tempFile) || filesize($tempFile) == 0) { - Log::warning('下载朋友圈图片失败:' . $url . ', HTTP Code: ' . $httpCode); - @unlink($tempFile); - continue; - } - - // 生成OSS对象名称 - $objectName = 'moments/' . date('Y/m/d/') . md5($snsId . $index . time()) . '.jpg'; - - // 上传到OSS - $result = AliyunOSS::uploadFile($tempFile, $objectName); - if ($result['success']) { - $ossUrls[] = $result['url']; - } else { - Log::error('朋友圈图片上传OSS失败:' . $url . ', 错误:' . ($result['error'] ?? '未知错误')); - } - - // 删除临时文件 - @unlink($tempFile); - - } catch (\Exception $e) { - Log::error('上传朋友圈图片到OSS异常:' . $e->getMessage() . ', URL: ' . $url); - if (isset($tempFile) && file_exists($tempFile)) { - @unlink($tempFile); - } - } - } - - } catch (\Exception $e) { - Log::error('上传朋友圈图片到OSS异常:' . $e->getMessage()); + Log::error('朋友圈媒体上传OSS失败:' . ($result['error'] ?? '未知错误'), [ + 'snsId' => $snsId, + 'url' => $url, + ]); } return $ossUrls; @@ -696,8 +643,8 @@ class WebSocketController extends BaseController } // 获取资源链接(检查是否已上传到OSS,如果已上传则跳过) - if(empty($momentEntity['urls']) || $moment['type'] != 1) { - // 如果没有urls或类型不是1,跳过 + if(empty($momentEntity['urls'])) { + // 如果没有urls,跳过 } elseif ($isOssUploaded == 1) { // 如果已上传到OSS,跳过采集 } else { diff --git a/Server/application/chukebao/controller/DataProcessing.php b/Server/application/chukebao/controller/DataProcessing.php index 5f0121cc6..56d3bd514 100644 --- a/Server/application/chukebao/controller/DataProcessing.php +++ b/Server/application/chukebao/controller/DataProcessing.php @@ -37,6 +37,8 @@ class DataProcessing extends BaseController 'CmdChatroomOperate', //修改群信息 {chatroomName(群名)、announce(公告)、extra(公告)、wechatAccountId、wechatChatroomId} 'CmdNewMessage', //接收消息 'CmdSendMessageResult', //更新消息状态 + 'CmdDownloadVideoResult', //视频下载结果回写 + 'CmdDownloadFileResult', //文件下载结果回写 'CmdPinToTop', //置顶 ]; @@ -168,6 +170,34 @@ class DataProcessing extends BaseController $msg = '更新消息状态成功'; break; + case 'CmdDownloadVideoResult': + case 'CmdDownloadFileResult': + $friendMessageId = $this->request->param('friendMessageId', 0); + $chatroomMessageId = $this->request->param('chatroomMessageId', 0); + $url = trim((string)$this->request->param('url', '')); + + if (empty($friendMessageId) && empty($chatroomMessageId)) { + return ResponseHelper::error('friendMessageId或chatroomMessageId至少提供一个'); + } + + if (empty($url)) { + return ResponseHelper::error('url不能为空'); + } + + $messageController = new MessageController(); + $updated = $messageController->updateDownloadedMessageMedia([ + 'friendMessageId' => $friendMessageId, + 'chatroomMessageId' => $chatroomMessageId, + 'url' => $url, + 'type' => $type, + ]); + + if (!$updated) { + return ResponseHelper::error('媒体地址回写失败'); + } + + $msg = '媒体地址回写成功'; + break; case 'CmdPinToTop': //置顶 $wechatFriendId = $this->request->param('wechatFriendId', 0); $wechatChatroomId = $this->request->param('wechatChatroomId', 0); diff --git a/Server/application/command.php b/Server/application/command.php index 45b6d7da8..65e4966ce 100644 --- a/Server/application/command.php +++ b/Server/application/command.php @@ -53,4 +53,5 @@ return [ // V2 流量池数据迁移 'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统 + 'media:archive' => 'app\command\BackfillMediaOssCommand', // 历史媒体资源归档到OSS ]; diff --git a/Server/application/command/BackfillMediaOssCommand.php b/Server/application/command/BackfillMediaOssCommand.php new file mode 100644 index 000000000..3c1a71e75 --- /dev/null +++ b/Server/application/command/BackfillMediaOssCommand.php @@ -0,0 +1,121 @@ +setName('media:archive') + ->setDescription('扫描聊天记录和朋友圈媒体资源并加入 OSS 归档队列') + ->addOption('scope', null, Option::VALUE_OPTIONAL, '归档范围:chat|moments|all', 'all') + ->addOption('limit', null, Option::VALUE_OPTIONAL, '单次扫描数量', 100) + ->addOption('startId', null, Option::VALUE_OPTIONAL, '仅扫描大于该ID的记录', 0) + ->addOption('messageId', null, Option::VALUE_OPTIONAL, '仅同步归档单条聊天记录(本地调试用,不走队列)', 0); + } + + protected function execute(Input $input, Output $output) + { + $singleMessageId = (int)$input->getOption('messageId'); + if ($singleMessageId > 0) { + return $this->archiveOneChatMessage($singleMessageId, $output); + } + + $scope = strtolower((string)$input->getOption('scope')); + $limit = max(1, (int)$input->getOption('limit')); + $startId = max(0, (int)$input->getOption('startId')); + + $messageCount = 0; + $momentCount = 0; + + if (in_array($scope, ['all', 'chat'])) { + $messageIds = Db::table('s2_wechat_message') + ->where('id', '>', $startId) + ->whereIn('msgType', [3, 34, 43, 47, 49]) + ->where(function ($query) { + $query->whereLike('content', 'http%') + ->whereOrLike('content', '%"url"%') + ->whereOrLike('content', '%"previewImage"%') + ->whereOrLike('content', '%"tencentUrl"%'); + }) + ->order('id', 'asc') + ->limit($limit) + ->column('id'); + + foreach ($messageIds as $id) { + MediaArchiveJob::dispatch('message', $id, ['source' => 'backfill_command']); + $messageCount++; + } + } + + if (in_array($scope, ['all', 'moments'])) { + $momentIds = Db::table('s2_wechat_moments') + ->where('id', '>', $startId) + ->where(function ($query) { + $query->where('isOssUploaded', 0) + ->whereOr('ossUrls', 'null') + ->whereOr('ossUrls', '') + ->whereOr('ossUrls', '[]'); + }) + ->where(function ($query) { + $query->where('resUrls', '<>', '') + ->whereOr('urls', '<>', ''); + }) + ->order('id', 'asc') + ->limit($limit) + ->column('id'); + + foreach ($momentIds as $id) { + MediaArchiveJob::dispatch('moment', $id, ['source' => 'backfill_command']); + $momentCount++; + } + } + + $output->writeln(sprintf( + '已加入 OSS 归档队列:聊天记录 %d 条,朋友圈 %d 条。', + $messageCount, + $momentCount + )); + + return 0; + } + + /** + * 单条聊天消息同步归档(用于本地验证 AliyunOSS 与下载链路) + */ + protected function archiveOneChatMessage($messageId, Output $output) + { + $row = Db::table('s2_wechat_message')->where('id', $messageId)->find(); + if (empty($row)) { + $output->writeln('错误:未找到消息 id=' . $messageId); + return 1; + } + + $preview = function ($s, $len) { + $s = (string)$s; + return function_exists('mb_substr') ? mb_substr($s, 0, $len) : substr($s, 0, $len); + }; + + $output->writeln('msgType=' . ($row['msgType'] ?? '') . ' content(前200字): ' . $preview($row['content'] ?? '', 200)); + + $ok = WechatMediaArchiveService::archiveMessageById($messageId); + if (!$ok) { + $output->writeln('失败:归档未执行或无需更新(请确认 msgType 为 3/34/43/47/49 且 content 含可下载 http 资源)'); + return 1; + } + + $after = Db::table('s2_wechat_message')->where('id', $messageId)->find(); + $output->writeln('成功:归档完成。更新后 content(前300字):'); + $output->writeln($preview($after['content'] ?? '', 300)); + + return 0; + } +} diff --git a/Server/application/command/SyncWechatDataToCkbTask.php b/Server/application/command/SyncWechatDataToCkbTask.php index 8d0409cbe..671fd0b48 100644 --- a/Server/application/command/SyncWechatDataToCkbTask.php +++ b/Server/application/command/SyncWechatDataToCkbTask.php @@ -9,7 +9,7 @@ use think\console\Command; use think\facade\App; use WeChatDeviceApi\Adapters\ChuKeBao\Adapter as ChuKeBaoAdapter; -// */7 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:wechatData >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/sync_wechat_data.log 2>&1 +// */7 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think sync:wechatData >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/sync_wechat_data.log 2>&1 class SyncWechatDataToCkbTask extends Command { protected $lockFile; diff --git a/Server/application/common/service/MediaOssArchiveService.php b/Server/application/common/service/MediaOssArchiveService.php new file mode 100644 index 000000000..1f150da63 --- /dev/null +++ b/Server/application/common/service/MediaOssArchiveService.php @@ -0,0 +1,261 @@ + '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; + } +} diff --git a/Server/application/common/service/WechatMediaArchiveService.php b/Server/application/common/service/WechatMediaArchiveService.php new file mode 100644 index 000000000..c587d9a19 --- /dev/null +++ b/Server/application/common/service/WechatMediaArchiveService.php @@ -0,0 +1,372 @@ +where('id', $messageId)->find(); + if (empty($row)) { + return false; + } + + $update = self::buildMessageArchiveUpdate($row); + if (empty($update)) { + return false; + } + + Db::table('s2_wechat_message')->where('id', $messageId)->update($update); + return true; + } + + public static function archiveMomentById($momentId) + { + $momentId = (int)$momentId; + if ($momentId <= 0) { + return false; + } + + $row = Db::table('s2_wechat_moments')->where('id', $momentId)->find(); + if (empty($row)) { + return false; + } + + $displayUrls = self::decodeJsonArray($row['resUrls'] ?? ''); + $rawUrls = self::decodeJsonArray($row['urls'] ?? ''); + $candidateUrls = !empty($displayUrls) ? $displayUrls : $rawUrls; + if (empty($candidateUrls)) { + return false; + } + + $ossUrls = []; + foreach ($candidateUrls as $index => $url) { + if (!MediaOssArchiveService::isRemoteHttpUrl($url)) { + continue; + } + + $resourceType = self::isVideoUrl($url) ? 'video' : 'image'; + $result = MediaOssArchiveService::archiveRemoteUrl($url, 'moments', $resourceType, (string)($row['snsId'] ?? $momentId), [ + 'index' => $index, + ]); + if (!empty($result['success']) && !empty($result['url'])) { + $ossUrls[] = $result['url']; + } + } + + if (empty($ossUrls)) { + return false; + } + + Db::table('s2_wechat_moments')->where('id', $momentId)->update([ + 'ossUrls' => json_encode($ossUrls, JSON_UNESCAPED_UNICODE), + 'isOssUploaded' => 1, + 'update_time' => time(), + ]); + + return true; + } + + public static function updateDownloadedMessageMedia($messageId, $downloadUrl) + { + $messageId = (int)$messageId; + $downloadUrl = trim((string)$downloadUrl); + if ($messageId <= 0 || empty($downloadUrl)) { + return false; + } + + $row = Db::table('s2_wechat_message')->where('id', $messageId)->find(); + if (empty($row)) { + return false; + } + + $content = (string)($row['content'] ?? ''); + $originalContent = (string)($row['originalContent'] ?? ''); + $msgType = (int)($row['msgType'] ?? 0); + + $update = []; + if (empty($originalContent)) { + $update['originalContent'] = $content; + } + + if ($msgType === 43) { + $payload = self::decodeJsonObject($content); + if (empty($payload)) { + $payload = []; + } + $payload['videoUrl'] = $downloadUrl; + $payload['isLoading'] = false; + $update['content'] = self::encodeJson($payload); + } elseif ($msgType === 49) { + $payload = self::decodeJsonObject($content); + if (empty($payload) || strtolower((string)($payload['type'] ?? '')) !== 'file') { + $payload = [ + 'type' => 'file', + 'title' => self::extractFileTitle($content), + ]; + } + $payload['url'] = $downloadUrl; + $payload['isDownloading'] = false; + $update['content'] = self::encodeJson($payload); + } else { + return false; + } + + Db::table('s2_wechat_message')->where('id', $messageId)->update($update); + return true; + } + + protected static function buildMessageArchiveUpdate(array $row) + { + $msgType = (int)($row['msgType'] ?? 0); + $content = (string)($row['content'] ?? ''); + $originalContent = (string)($row['originalContent'] ?? ''); + $sourceOriginalContent = $originalContent !== '' ? $originalContent : $content; + + $update = []; + switch ($msgType) { + case 3: + case 47: + $imageUpdate = self::archiveImageLikeContent($content, $msgType, $row['id']); + if (!empty($imageUpdate)) { + $update = array_merge($update, $imageUpdate); + } + break; + case 34: + $audioUpdate = self::archiveAudioContent($content, $row['id']); + if (!empty($audioUpdate)) { + $update = array_merge($update, $audioUpdate); + } + break; + case 43: + $videoUpdate = self::archiveVideoContent($content, $row['id']); + if (!empty($videoUpdate)) { + $update = array_merge($update, $videoUpdate); + } + break; + case 49: + $fileUpdate = self::archiveFileContent($content, $row['id']); + if (!empty($fileUpdate)) { + $update = array_merge($update, $fileUpdate); + } + break; + default: + break; + } + + if (!empty($update) && empty($originalContent) && !empty($sourceOriginalContent)) { + $update['originalContent'] = $sourceOriginalContent; + } + + return $update; + } + + protected static function archiveImageLikeContent($content, $msgType, $messageId) + { + $payload = self::decodeJsonObject($content); + if (!empty($payload) && !empty($payload['url'])) { + $sourceUrl = (string)($payload['originUrl'] ?? $payload['url']); + $result = MediaOssArchiveService::archiveRemoteUrl( + $sourceUrl, + 'messages', + $msgType == 47 ? 'emoji' : 'image', + (string)$messageId + ); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + $payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl; + $payload['ossUrl'] = $result['url']; + $payload['url'] = $result['url']; + return ['content' => self::encodeJson($payload)]; + } + + if (!MediaOssArchiveService::isRemoteHttpUrl($content)) { + return []; + } + + $result = MediaOssArchiveService::archiveRemoteUrl( + $content, + 'messages', + $msgType == 47 ? 'emoji' : 'image', + (string)$messageId + ); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + return ['content' => $result['url']]; + } + + protected static function archiveAudioContent($content, $messageId) + { + $payload = self::decodeJsonObject($content); + if (!empty($payload) && !empty($payload['url'])) { + $sourceUrl = (string)($payload['originUrl'] ?? $payload['url']); + $result = MediaOssArchiveService::archiveRemoteUrl($sourceUrl, 'messages', 'audio', (string)$messageId); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + $payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl; + $payload['ossUrl'] = $result['url']; + $payload['url'] = $result['url']; + return ['content' => self::encodeJson($payload)]; + } + + if (!MediaOssArchiveService::isRemoteHttpUrl($content)) { + return []; + } + + $result = MediaOssArchiveService::archiveRemoteUrl($content, 'messages', 'audio', (string)$messageId); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + return ['content' => $result['url']]; + } + + protected static function archiveVideoContent($content, $messageId) + { + $payload = self::decodeJsonObject($content); + if (empty($payload)) { + if (!MediaOssArchiveService::isRemoteHttpUrl($content)) { + return []; + } + + $result = MediaOssArchiveService::archiveRemoteUrl($content, 'messages', 'video', (string)$messageId); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + return ['content' => $result['url']]; + } + + $changed = false; + $previewSource = (string)($payload['previewImageOriginUrl'] ?? $payload['previewImage'] ?? ''); + if (MediaOssArchiveService::isRemoteHttpUrl($previewSource)) { + $previewResult = MediaOssArchiveService::archiveRemoteUrl($previewSource, 'messages', 'video_cover', (string)$messageId); + if (!empty($previewResult['success']) && !empty($previewResult['url'])) { + $payload['previewImageOriginUrl'] = $payload['previewImageOriginUrl'] ?? $previewSource; + $payload['previewImageOssUrl'] = $previewResult['url']; + $payload['previewImage'] = $previewResult['url']; + $changed = true; + } + } + + $videoSource = (string)($payload['originUrl'] ?? $payload['videoUrl'] ?? $payload['ossUrl'] ?? $payload['tencentUrl'] ?? $payload['url'] ?? ''); + if (MediaOssArchiveService::isRemoteHttpUrl($videoSource)) { + $videoResult = MediaOssArchiveService::archiveRemoteUrl($videoSource, 'messages', 'video', (string)$messageId); + if (!empty($videoResult['success']) && !empty($videoResult['url'])) { + $payload['originUrl'] = $payload['originUrl'] ?? $videoSource; + $payload['ossUrl'] = $videoResult['url']; + $payload['videoUrl'] = $videoResult['url']; + $payload['url'] = $videoResult['url']; + $payload['isLoading'] = false; + $changed = true; + } + } + + if (!$changed) { + return []; + } + + return ['content' => self::encodeJson($payload)]; + } + + protected static function archiveFileContent($content, $messageId) + { + $payload = self::decodeJsonObject($content); + if (empty($payload) || strtolower((string)($payload['type'] ?? '')) !== 'file') { + return []; + } + + $sourceUrl = (string)($payload['originUrl'] ?? $payload['url'] ?? ''); + if (!MediaOssArchiveService::isRemoteHttpUrl($sourceUrl)) { + return []; + } + + $result = MediaOssArchiveService::archiveRemoteUrl( + $sourceUrl, + 'messages', + 'file', + (string)$messageId, + [ + 'extension' => $payload['fileext'] ?? '', + ] + ); + if (empty($result['success']) || empty($result['url'])) { + return []; + } + + $payload['originUrl'] = $payload['originUrl'] ?? $sourceUrl; + $payload['ossUrl'] = $result['url']; + $payload['url'] = $result['url']; + $payload['isDownloading'] = false; + return ['content' => self::encodeJson($payload)]; + } + + protected static function decodeJsonObject($value) + { + if (!is_string($value)) { + return []; + } + + $decoded = json_decode(trim($value), true); + return is_array($decoded) ? $decoded : []; + } + + protected static function decodeJsonArray($value) + { + if (is_array($value)) { + return $value; + } + + if (!is_string($value) || trim($value) === '') { + return []; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + + protected static function encodeJson(array $payload) + { + return json_encode($payload, JSON_UNESCAPED_UNICODE); + } + + protected static function extractFileTitle($rawContent) + { + if (!is_string($rawContent) || trim($rawContent) === '') { + return '文件'; + } + + if (preg_match('/