feat: improve realtime messaging and media handling

This commit is contained in:
winchen
2026-07-28 19:43:14 +08:00
parent 051f60aaf0
commit 6f18980a64
31 changed files with 25265 additions and 21814 deletions
+219 -55
View File
@@ -6,12 +6,21 @@ header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
define(
'WEB_MEDIA_ROOT',
webImEnv(
'JC_WEB_IM_MEDIA_ROOT',
is_dir('/var/www/im/public/media')
? '/var/www/im/public/media'
: (is_dir('/var/www/IM/public/media') ? '/var/www/IM/public/media' : '/opt/im-server/media')
)
);
define('WEB_MEDIA_PUBLIC_PORT', (int)webImEnv('JC_WEB_IM_MEDIA_PORT', '80'));
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
const WEB_MINIO_PROJECT_CODE = '001';
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
@@ -72,6 +81,33 @@ function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $n
return $startsAtMs > 0 && $endsAtMs > $startsAtMs && $nowMs >= $startsAtMs && $nowMs < $endsAtMs;
}
function nativeGroupRoleFromType(mixed $type): string
{
$value = (int)$type;
if ($value === 2) {
return 'owner';
}
if ($value === 1) {
return 'admin';
}
return 'member';
}
function fetchNativeGroupForSender(PDO $db, string $sender, string $groupId): array
{
$statement = $db->prepare(
'select g.`_gid`, g.`_name`, g.`_mute`, g.`_deleted`, m.`_type` as `member_type`'
. ' from `t_group` g'
. ' join `t_group_member` m on m.`_gid` = g.`_gid`'
. ' where g.`_gid` = ? and g.`_deleted` = 0 and m.`_mid` = ? limit 1'
);
$statement->execute([$groupId, $sender]);
$group = $statement->fetch();
return is_array($group) ? $group : [];
}
function messageRequestValue(array $payload, string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $payload[$key] ?? $default));
@@ -162,7 +198,106 @@ function mediaPublicBaseUrl(): string
return $scheme . '://' . $host . ':' . $port;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): array
function messageStorageConfig(): array
{
$config = ['enabled' => false, 'url' => ''];
foreach (['t_app_system_conf', 'system_conf'] as $table) {
try {
$statement = imAppDb()->query(
'select `conf_name`, `conf_value` from `' . $table . '`'
. " where lower(`conf_name`) in ('is_minioplus', 'minio_url') and `is_valid` = 1"
);
$rows = $statement->fetchAll();
if (!$rows) {
continue;
}
foreach ($rows as $row) {
$name = strtolower((string)($row['conf_name'] ?? ''));
if ($name === 'is_minioplus') {
$config['enabled'] = (string)($row['conf_value'] ?? '0') === '1';
} elseif ($name === 'minio_url') {
$config['url'] = rtrim(trim((string)($row['conf_value'] ?? '')), '/');
}
}
break;
} catch (Throwable $error) {
continue;
}
}
return $config;
}
function saveMessageAttachmentToMinio(
array $file,
string $attachmentType,
string $sender,
string $baseUrl
): array {
$size = (int)($file['size'] ?? 0);
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file')));
$content = file_get_contents($tmpName);
if ($content === false) {
throw new RuntimeException('读取上传文件失败');
}
$requestBody = json_encode([
'projectCode' => WEB_MINIO_PROJECT_CODE,
'fileScene' => 'temp',
'fileName' => $originalName,
'contentType' => $mime,
'base64Content' => base64_encode($content),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($requestBody)) {
throw new RuntimeException('生成图片上传请求失败');
}
$handle = curl_init($baseUrl . '/project-storage/upload');
curl_setopt_array($handle, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 8,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: minio-' . WEB_MINIO_PROJECT_CODE . '-' . $sender,
],
CURLOPT_POSTFIELDS => $requestBody,
]);
$responseBody = curl_exec($handle);
$status = (int)curl_getinfo($handle, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($handle);
curl_close($handle);
$response = is_string($responseBody) ? json_decode($responseBody, true) : null;
$data = is_array($response) && is_array($response['data'] ?? null) ? $response['data'] : [];
$url = trim((string)($data['previewUrl'] ?? $data['downloadUrl'] ?? ''));
if ($status < 200 || $status >= 300 || (int)($response['code'] ?? -1) !== 0 || $url === '') {
$message = trim((string)($response['message'] ?? ''));
throw new RuntimeException(
$message !== ''
? 'MinIO上传失败:' . $message
: 'MinIO上传失败' . ($curlError !== '' ? ':' . $curlError : '')
);
}
return [
'type' => $attachmentType,
'url' => $url,
'name' => $originalName,
'mime' => $mime,
'size' => $size,
'mediaType' => $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE,
];
}
function saveMessageAttachmentFile(array $file, string $attachmentType, string $sender): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('文件上传失败');
@@ -172,10 +307,6 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
if ($size <= 0 || $size > $maxSize) {
throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB');
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$tmpName = (string)($file['tmp_name'] ?? '');
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
throw new RuntimeException('文件上传失败');
@@ -185,6 +316,17 @@ function saveMessageAttachmentFile(array $file, string $attachmentType): array
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$storage = messageStorageConfig();
if ($storage['enabled']) {
if ($storage['url'] === '') {
throw new RuntimeException('MinIO已开启但未配置服务地址');
}
return saveMessageAttachmentToMinio($file, $attachmentType, $sender, $storage['url']);
}
if (!is_dir(WEB_MEDIA_ROOT) || !is_writable(WEB_MEDIA_ROOT)) {
throw new RuntimeException('媒体存储目录不可写');
}
$mediaType = $attachmentType === 'image' ? WEB_MEDIA_TYPE_IMAGE : WEB_MEDIA_TYPE_FILE;
$datePath = 'fs/' . $mediaType . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(WEB_MEDIA_ROOT, '/') . '/' . $datePath;
@@ -249,7 +391,7 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
function attachmentFromRequest(string $messageType, string $sender): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
@@ -260,7 +402,61 @@ function attachmentFromRequest(string $messageType): ?array
respond(400, '缺少上传文件');
}
return saveMessageAttachmentFile($attachmentFile, $messageType);
return saveMessageAttachmentFile($attachmentFile, $messageType, $sender);
}
function messagePayloadBody(string $text, ?array $attachment): array
{
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
return $payloadBody;
}
function sendNativeGroupMessage(string $sender, string $target, string $text, ?array $attachment): array
{
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';
$timestamp = (int)($result['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
ensureWebImAppTables();
storeMessageAttachment(imAppDb(), $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
return [
'messageUid' => $messageUid !== '' ? $messageUid : null,
'timestamp' => $timestamp,
'attachment' => $attachment,
];
}
$contentTypeHeader = (string)($_SERVER['CONTENT_TYPE'] ?? '');
@@ -292,7 +488,17 @@ try {
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
$nativeGroup = fetchNativeGroupForSender(imDb(), $sender, $target);
if (!$nativeGroup) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType, $sender);
$nativeResult = sendNativeGroupMessage($sender, $target, $text, $attachment);
respond(0, 'success', [
...$nativeResult,
'senderRole' => nativeGroupRoleFromType($nativeGroup['member_type'] ?? 0),
'native' => true,
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
@@ -304,7 +510,7 @@ try {
if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) {
respond(403, '当前群组处于禁言时段');
}
$attachment = attachmentFromRequest($messageType);
$attachment = attachmentFromRequest($messageType, $sender);
$messageUid = webImMessageUid();
$insert = $appDb->prepare(
'insert into `t_web_group_messages`'
@@ -320,27 +526,6 @@ try {
$nativeError = '';
try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
@@ -348,7 +533,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
@@ -364,28 +549,7 @@ try {
]);
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
$payloadBody['extra'] = json_encode([
'name' => (string)$attachment['name'],
'mime' => (string)$attachment['mime'],
'size' => (int)$attachment['size'],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$attachment = attachmentFromRequest($messageType, $sender);
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
@@ -394,7 +558,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'payload' => messagePayloadBody($text, $attachment),
'userMessage' => true,
]), '发送失败');