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

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

Made-with: Cursor
This commit is contained in:
wong
2026-04-14 09:37:00 +08:00
parent ae1120f1e3
commit 1deebf3f5c
19 changed files with 1046 additions and 151 deletions

3
.gitignore vendored
View File

@@ -15,3 +15,6 @@ Touchkebao/.specstory/
Serverruntime/
Moncter/提示词/
*.log
*.code-workspace
项目整体分析报告.md
Server/docs/traffic_pool_design.md

View File

@@ -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. 系统要求

View File

@@ -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 原始消息内容

View File

@@ -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 {

View File

@@ -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);

View File

@@ -53,4 +53,5 @@ return [
// V2 流量池数据迁移
'migrate:trafficPoolV2' => 'app\command\MigrateTrafficPoolV2Command', // 迁移数据到 V2 流量池系统
'media:archive' => 'app\command\BackfillMediaOssCommand', // 历史媒体资源归档到OSS
];

View File

@@ -0,0 +1,121 @@
<?php
namespace app\command;
use app\common\service\WechatMediaArchiveService;
use app\job\MediaArchiveJob;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\console\input\Option;
use think\Db;
class BackfillMediaOssCommand extends Command
{
protected function configure()
{
$this->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;
}
}

View File

@@ -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;

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

View File

@@ -0,0 +1,372 @@
<?php
namespace app\common\service;
use think\Db;
use think\facade\Log;
class WechatMediaArchiveService
{
public static function archiveMessageById($messageId)
{
$messageId = (int)$messageId;
if ($messageId <= 0) {
return false;
}
$row = Db::table('s2_wechat_message')->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('/<title><!\[CDATA\[(.*?)\]\]><\/title>/i', $rawContent, $matches) && !empty($matches[1])) {
return trim($matches[1]);
}
if (preg_match('/<title>([^<]+)<\/title>/i', $rawContent, $matches) && !empty($matches[1])) {
return trim($matches[1]);
}
return '文件';
}
protected static function isVideoUrl($url)
{
if (!is_string($url)) {
return false;
}
return (bool)preg_match('/\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i', $url);
}
}

View File

@@ -201,7 +201,7 @@ class GetWechatMomentsV1Controller extends BaseController
// 格式化数据
$rows = [];
foreach ($moments as $moment) {
$resUrls = $this->decodeJson($moment['resUrls'] ?? null);
$resUrls = $this->preferredMediaUrls($moment);
$imageUrls = is_array($resUrls) ? $resUrls : [];
// 格式化日期和时间
@@ -300,7 +300,8 @@ class GetWechatMomentsV1Controller extends BaseController
'content' => $row['content'] ?? '',
'commentList' => $this->decodeJson($row['commentList'] ?? null),
'likeList' => $this->decodeJson($row['likeList'] ?? null),
'resUrls' => $this->decodeJson($row['resUrls'] ?? null),
'resUrls' => $this->preferredMediaUrls($row),
'ossUrls' => $this->decodeJson($row['ossUrls'] ?? null),
'createTime' => $formatTime($row['createTime'] ?? null),
'momentEntity' => [
'lat' => $row['lat'] ?? 0,
@@ -312,6 +313,16 @@ class GetWechatMomentsV1Controller extends BaseController
];
}
protected function preferredMediaUrls(array $row): array
{
$ossUrls = $this->decodeJson($row['ossUrls'] ?? null);
if (!empty($ossUrls)) {
return $ossUrls;
}
return $this->decodeJson($row['resUrls'] ?? null);
}
/**
* JSON字段解析
*

View File

@@ -0,0 +1,57 @@
<?php
namespace app\job;
use app\common\service\WechatMediaArchiveService;
use think\facade\Config;
use think\facade\Log;
use think\queue\Job;
use think\Queue;
class MediaArchiveJob
{
const QUEUE_NAME = 'media_archive';
public function fire(Job $job, $data)
{
try {
$scope = $data['scope'] ?? '';
$id = (int)($data['id'] ?? 0);
if (empty($scope) || $id <= 0) {
$job->delete();
return;
}
$success = false;
if ($scope === 'message') {
$success = WechatMediaArchiveService::archiveMessageById($id);
} elseif ($scope === 'moment') {
$success = WechatMediaArchiveService::archiveMomentById($id);
}
if ($success || $job->attempts() >= 3) {
$job->delete();
return;
}
$job->release(Config::get('queue.failed_delay', 10));
} catch (\Exception $e) {
Log::error('媒体资源归档任务失败:' . $e->getMessage(), $data ?: []);
if ($job->attempts() >= 3) {
$job->delete();
} else {
$job->release(Config::get('queue.failed_delay', 10));
}
}
}
public static function dispatch($scope, $id, array $extra = [])
{
$payload = array_merge($extra, [
'scope' => $scope,
'id' => (int)$id,
]);
return Queue::push(self::class, $payload, self::QUEUE_NAME);
}
}

View File

@@ -4,85 +4,85 @@
```bash
# 设备列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/device_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think device:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/device_list.log 2>&1
# 微信好友列表
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_friends_list.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/wechat_friends_list.log 2>&1
# 微信群列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_chatroom_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatChatroom:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/wechat_chatroom_list.log 2>&1
# 添加好友任务列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think friendTask:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/friend_task_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think friendTask:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/friend_task_list.log 2>&1
# 微信客服列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatList:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/wechat_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatList:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/wechat_list.log 2>&1
# 公司账号列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think account:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/account_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think account:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/account_list.log 2>&1
# 微信好友消息列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:friendsList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/message_friends_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think message:friendsList >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/message_friends_list.log 2>&1
# 微信群聊消息列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:chatroomList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/message_chatroom_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think message:chatroomList >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/message_chatroom_list.log 2>&1
# 部门列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think department:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/department_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think department:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/department_list.log 2>&1
# 同步内容库
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:sync >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/content_sync.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think content:sync >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/content_sync.log 2>&1
# 微信群好友列表
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think groupFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/group_friends_list.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think groupFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/group_friends_list.log 2>&1
# 获取通话记录
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think call-recording:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/call_recording.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think call-recording:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/call_recording.log 2>&1
# 分配规则列表
0 3 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think allotrule:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/allot_rule_list.log 2>&1
0 3 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think allotrule:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/allot_rule_list.log 2>&1
# 自动创建分配规则
0 4 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think allotrule:autocreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/allot_rule_autocreate.log 2>&1
0 4 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think allotrule:autocreate >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/allot_rule_autocreate.log 2>&1
# 内容采集任务
0 5 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/content_collect.log 2>&1
0 5 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think content:collect >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/content_collect.log 2>&1
# 朋友圈采集任务
0 6 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/moments_collect.log 2>&1
0 6 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think moments:collect >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/moments_collect.log 2>&1
# 工作台自动点赞任务
0 7 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:autoLike >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_auto_like.log 2>&1
0 7 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:autoLike >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_auto_like.log 2>&1
# 工作台朋友圈同步任务
0 8 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:moments >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_moments.log 2>&1
0 8 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:moments >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_moments.log 2>&1
# 同步微信数据到存客宝
0 9 * * * 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
0 9 * * * 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
# 工作台群发消息
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:groupPush >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_groupPush.log 2>&1
# 工作台建群
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:groupCreate >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_groupCreate.log 2>&1
# 工作台通讯录导入
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:import-contact >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/import_contact.log 2>&1
# 工作台流量分发
0 9 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
0 9 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:trafficDistribute >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/traffic_distribute.log 2>&1
# 预防性切换好友
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think switch:friends >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/switch_friends.log 2>&1
# 消息提醒
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think kf:notice >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/kf_notice.log 2>&1
# 客服评分
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechat:calculate-score >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/calculate_score.log 2>&1
@@ -92,12 +92,12 @@
## 说明
- 所有命令都在 `/www/wwwroot/mckb_quwanzhi_com/Server` 目录下执行
- 所有命令都在 `/www/wwwroot/ckbapi.quwanzhi.com/Server` 目录下执行
- 默认只获取未删除(活跃)的设备、微信好友和群聊
- 已注释的命令(以#开头)是获取已删除或已停用数据的任务,可根据需要取消注释启用
- 每个命令的执行结果都会记录到对应的日志文件中
- 日志文件名格式包含了数据状态(如 `_active`, `_deleted`, `_stopped`
- 日志文件位于 `/www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/` 目录下
- 日志文件位于 `/www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/` 目录下
- 大部分任务每5分钟执行一次`*/5 * * * *` 表示每小时的第0,5,10,15...55分钟执行)
- 设备列表的未删除设备任务每天凌晨1点执行一次`0 1 * * *`
- 自动创建分配规则每小时整点执行一次(`0 * * * *`
@@ -120,64 +120,64 @@ crontab -l
# 设备列表 - 未删除设备(每半小时执行)
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_active.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think device:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_device_active.log 2>&1
# 设备列表 - 已删除设备每天1点执行
0 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_deleted.log 2>&1
0 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think device:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_device_deleted.log 2>&1
# 设备列表 - 已停用设备每天1:10执行
10 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think device:list --isDel=2 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_device_stopped.log 2>&1
10 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think device:list --isDel=2 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_device_stopped.log 2>&1
# 微信好友列表 - 未删除好友每1分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_active.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatFriends:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_wechatFriends_active.log 2>&1
# 微信好友列表 - 已删除好友每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatFriends:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatFriends_deleted.log 2>&1
30 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatFriends:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_wechatFriends_deleted.log 2>&1
# 微信群列表 - 未删除群每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=0 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_active.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatChatroom:list --isDel=0 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_wechatChatroom_active.log 2>&1
# 微信群列表 - 已删除群每天1:30分执行
30 1 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatChatroom:list --isDel=1 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatChatroom_deleted.log 2>&1
30 1 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatChatroom:list --isDel=1 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_wechatChatroom_deleted.log 2>&1
# 微信群好友列表没5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think groupFriends:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_groupFriends.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think groupFriends:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_groupFriends.log 2>&1
# 添加好友任务列表(每1分钟执行)
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think friendTask:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_friendTask.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think friendTask:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_friendTask.log 2>&1
# 微信客服列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechatList:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_wechatList.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechatList:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_wechatList.log 2>&1
# 公司账号列表每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think account:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_account.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think account:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_account.log 2>&1
# 微信好友消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:friendsList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageFriends.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think message:friendsList >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_messageFriends.log 2>&1
# 微信群聊消息列表每30分钟执行
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think message:chatroomList >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_messageChatroom.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think message:chatroomList >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_messageChatroom.log 2>&1
# 获取通话记录
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think call-recording:list >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/call_recording.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think call-recording:list >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/call_recording.log 2>&1
# 清洗微信数据
*/2 * * * * 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
*/2 * * * * 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
# 内容采集任务每5分钟执行
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think content:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_contentCollect.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think content:collect >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_contentCollect.log 2>&1
# 工作台任务_自动点赞每10分钟执行
*/6 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:autoLike >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/crontab_workbench_autoLike.log 2>&1
*/6 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:autoLike >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/crontab_workbench_autoLike.log 2>&1
# 每3天的3点同步所有好友
0 3 */3 * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think sync:allFriends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/all_friends.log 2>&1
0 3 */3 * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think sync:allFriends >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/all_friends.log 2>&1
# 工作台流量分发
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:trafficDistribute >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/traffic_distribute.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:trafficDistribute >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/traffic_distribute.log 2>&1
# 工作台朋友圈同步任务
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:moments >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_moments.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:moments >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_moments.log 2>&1
# 工作台群发消息
#*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupPush >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupPush.log 2>&1
#*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:groupPush >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_groupPush.log 2>&1
# 预防性切换好友
*/2 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think switch:friends >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/switch_friends.log 2>&1
*/2 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think switch:friends >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/switch_friends.log 2>&1
# 工作台建群
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupCreate >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupCreate.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:groupCreate >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_groupCreate.log 2>&1
# 工作台通讯录导入
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:import-contact >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/import_contact.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:import-contact >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/import_contact.log 2>&1
# 工作台入群欢迎语
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think workbench:groupWelcome >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/workbench_groupWelcome.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think workbench:groupWelcome >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/workbench_groupWelcome.log 2>&1
# 消息提醒
*/1 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think kf:notice >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/kf_notice.log 2>&1
*/1 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think kf:notice >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/kf_notice.log 2>&1
# 客服评分
0 2 * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think wechat:calculate-score >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/calculate_score.log 2>&1
0 2 * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think wechat:calculate-score >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/calculate_score.log 2>&1
# 采集客服自己的朋友圈
*/30 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think own:moments:collect >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/own_moments_collect.log 2>&1
*/30 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think own:moments:collect >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/own_moments_collect.log 2>&1
# 检查未读/未回复消息并自动迁移好友
*/5 * * * * cd /www/wwwroot/mckb_quwanzhi_com/Server && php think check:unread-message --minutes=30 >> /www/wwwroot/mckb_quwanzhi_com/Server/runtime/log/check_unread_message.log 2>&1
*/5 * * * * cd /www/wwwroot/ckbapi.quwanzhi.com/Server && php think check:unread-message --minutes=30 >> /www/wwwroot/ckbapi.quwanzhi.com/Server/runtime/log/check_unread_message.log 2>&1
# 每分钟执行一次调度器(调度器内部会自动判断哪些任务需要执行)
* * * * * 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

View File

@@ -18,10 +18,10 @@ const parseAudioUrl = (audioUrl: string): AudioData => {
try {
// 尝试解析为JSON
const parsed = JSON.parse(audioUrl);
if (parsed.url) {
if (parsed.url || parsed.ossUrl) {
return {
durationMs: parsed.durationMs,
url: parsed.url,
url: parsed.ossUrl || parsed.url,
text: parsed.text,
};
}

View File

@@ -226,20 +226,22 @@ export const FileMessage: React.FC<FileMessageProps> = ({
// 提取文件信息
const {
url = "",
ossUrl = "",
title,
fileName,
filename,
fileext,
isDownloading = false,
} = fileMessageData;
const resolvedUrl = ossUrl || url;
// 解析文件名优先级title > fileName > filename > URL中提取
const resolvedFileName =
title ||
fileName ||
filename ||
(typeof url === "string" && url
? url.split("/").pop()?.split("?")[0]
(typeof resolvedUrl === "string" && resolvedUrl
? resolvedUrl.split("/").pop()?.split("?")[0]
: "") ||
"文件";
@@ -278,7 +280,7 @@ export const FileMessage: React.FC<FileMessageProps> = ({
// 判断是否有可用的文件URL
const isUrlAvailable =
typeof url === "string" && url.trim().length > 0;
typeof resolvedUrl === "string" && resolvedUrl.trim().length > 0;
// 文件下载处理函数
const handleFileDownload = () => {
@@ -305,7 +307,7 @@ export const FileMessage: React.FC<FileMessageProps> = ({
event.stopPropagation();
if (isUrlAvailable) {
try {
window.open(url, "_blank");
window.open(resolvedUrl, "_blank");
} catch (e) {
console.error("文件打开失败:", e);
}
@@ -320,7 +322,7 @@ export const FileMessage: React.FC<FileMessageProps> = ({
className={styles.fileCard}
onClick={() => {
if (isUrlAvailable) {
window.open(url, "_blank");
window.open(resolvedUrl, "_blank");
} else if (!isDownloading) {
handleFileDownload();
}

View File

@@ -88,34 +88,36 @@ const VideoMessage: React.FC<VideoMessageProps> = ({
videoData &&
typeof videoData === "object" &&
videoData.previewImage &&
videoData.tencentUrl
(videoData.tencentUrl || videoData.videoUrl || videoData.ossUrl)
) {
const previewImageUrl = String(videoData.previewImage).replace(
/[`"']/g,
"",
);
const resolvedVideoUrl =
videoData.videoUrl || videoData.ossUrl || videoData.tencentUrl;
// 创建点击处理函数
const handlePlayClick = (e: React.MouseEvent, msg: ChatRecord) => {
e.stopPropagation();
// 如果没有视频URL且不在加载中则发起下载请求
if (!videoData.videoUrl && !videoData.isLoading) {
if (!resolvedVideoUrl && !videoData.isLoading) {
handleVideoPlayRequest(videoData.tencentUrl, msg.id);
}
};
// 如果已有视频URL显示视频播放器
if (videoData.videoUrl) {
if (videoData.videoUrl || videoData.ossUrl) {
return (
<div className={styles.videoMessage}>
<div className={styles.videoContainer}>
<video
controls
src={videoData.videoUrl}
src={resolvedVideoUrl}
style={{ maxWidth: "100%", borderRadius: "8px" }}
/>
<a
href={videoData.videoUrl}
href={resolvedVideoUrl}
download
className={styles.downloadButton}
style={{ display: "flex" }}

View File

@@ -10,6 +10,16 @@ interface ImageMessageProps {
* msgType = 3
*/
export const ImageMessage: React.FC<ImageMessageProps> = ({ content }) => {
let imageUrl = content;
try {
const parsed = JSON.parse(content);
if (parsed && typeof parsed === "object") {
imageUrl = parsed.ossUrl || parsed.url || parsed.originUrl || content;
}
} catch (error) {
imageUrl = content;
}
const handleImageError = (event: React.SyntheticEvent<HTMLImageElement>) => {
const target = event.target as HTMLImageElement;
const parent = target.parentElement;
@@ -22,14 +32,14 @@ export const ImageMessage: React.FC<ImageMessageProps> = ({ content }) => {
<div className={styles.messageBubble}>
<div className={styles.imageMessage}>
<img
src={content}
src={imageUrl}
alt="图片消息"
style={{
maxWidth: "200px",
maxHeight: "200px",
borderRadius: "8px",
}}
onClick={() => window.open(content, "_blank")}
onClick={() => window.open(imageUrl, "_blank")}
onError={handleImageError}
/>
</div>

View File

@@ -33,7 +33,11 @@ export const FriendCard: React.FC<FriendCardProps> = ({
formatTime,
}) => {
const content = monent?.momentEntity?.content || "";
const images = monent?.momentEntity?.resUrls || [];
const mediaUrls = monent?.momentEntity?.resUrls || [];
const isVideoUrl = (url: string) =>
/\.(mp4|mov|avi|webm|mkv)(\?.*)?$/i.test(url || "");
const videos = mediaUrls.filter((url: string) => isVideoUrl(url));
const images = mediaUrls.filter((url: string) => !isVideoUrl(url));
const time = formatTime(monent.createTime);
const likesCount = monent?.likeList?.length || 0;
const commentsCount = monent?.commentList?.length || 0;
@@ -153,6 +157,19 @@ export const FriendCard: React.FC<FriendCardProps> = ({
<div className={styles.itemContent}>
<div className={styles.contentText}>{content}</div>
{videos.length > 0 && (
<div className={styles.imageContainer}>
{videos.map((video, index) => (
<video
key={`video-${index}`}
src={video}
controls
className={styles.contentImage}
style={{ background: "#000" }}
/>
))}
</div>
)}
{images && images.length > 0 && (
<div className={styles.imageContainer}>
{images.map((image, index) => (

View File

@@ -35,6 +35,7 @@ const getWeChatStoreMethods = () => {
updateMessage: state.updateMessage,
updateMomentCommonLoading: state.updateMomentCommonLoading,
addMomentCommon: state.addMomentCommon,
setVideoUrl: state.setVideoUrl,
setFileDownloadUrl: state.setFileDownloadUrl,
setFileDownloading: state.setFileDownloading,
};
@@ -508,9 +509,24 @@ const messageHandlers: Record<string, MessageHandler> = {
},
CmdDownloadVideoResult: message => {
// 在这里添加具体的处理逻辑
const { setVideoUrl } = getWeChatStoreMethods();
const messageId = message.friendMessageId || message.chatroomMessageId;
console.log("视频下载结果:", message);
// setVideoUrl(message.friendMessageId, message.url);
if (!messageId || !message.url) {
return;
}
setVideoUrl(messageId, message.url);
dataProcessing({
type: "CmdDownloadVideoResult",
wechatAccountId: message.wechatAccountId || 1,
friendMessageId: message.friendMessageId,
chatroomMessageId: message.chatroomMessageId,
url: message.url,
}).catch(error => {
console.error("回写视频下载地址失败:", error);
});
},
CmdDownloadFileResult: message => {
const { setFileDownloadUrl, setFileDownloading } = getWeChatStoreMethods();
@@ -528,6 +544,15 @@ const messageHandlers: Record<string, MessageHandler> = {
}
setFileDownloadUrl(messageId, message.url);
dataProcessing({
type: "CmdDownloadFileResult",
wechatAccountId: message.wechatAccountId || 1,
friendMessageId: message.friendMessageId,
chatroomMessageId: message.chatroomMessageId,
url: message.url,
}).catch(error => {
console.error("回写文件下载地址失败:", error);
});
},
CmdFetchMomentResult: message => {