setTimezone(new DateTimeZone(IM_DB_TIMEZONE)); $currentMinutes = ((int)$now->format('H')) * 60 + (int)$now->format('i'); [$startHour, $startMinute] = array_map('intval', explode(':', $dailyStart)); [$endHour, $endMinute] = array_map('intval', explode(':', $dailyEnd)); $startMinutes = $startHour * 60 + $startMinute; $endMinutes = $endHour * 60 + $endMinute; if ($startMinutes === $endMinutes) { return true; } if ($startMinutes < $endMinutes) { return $currentMinutes >= $startMinutes && $currentMinutes < $endMinutes; } return $currentMinutes >= $startMinutes || $currentMinutes < $endMinutes; } function isGroupMutedForSender(PDO $appDb, string $groupId, string $role, int $nowMs): bool { if ($role === 'owner') { return false; } $statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? and `enabled` = 1 limit 1'); $statement->execute([$groupId]); $rule = $statement->fetch(); if (!is_array($rule)) { return false; } $mode = (string)$rule['mode']; if ($mode === 'off') { return false; } if ($mode === 'all_except_admins' && $role === 'admin') { return false; } if (!in_array($mode, ['all_include_admins', 'all_except_admins'], true)) { return false; } $scheduleType = (string)$rule['schedule_type']; if ($scheduleType === 'manual') { return true; } if ($scheduleType === 'daily') { return isDailyMuteActive((string)$rule['daily_start'], (string)$rule['daily_end'], $nowMs); } $startsAtMs = (int)$rule['starts_at_ms']; $endsAtMs = (int)$rule['ends_at_ms']; 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)); } function safeUploadName(string $fileName): string { $name = basename(str_replace('\\', '/', $fileName)); $name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: 'file'; $name = trim($name, " .\t\n\r\0\x0B"); return $name !== '' ? mb_substr($name, 0, 180, 'UTF-8') : 'file'; } function uploadExtension(string $fileName, string $mime, string $attachmentType): string { $extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION)); $extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: ''; if ($extension === 'jpeg') { $extension = 'jpg'; } if ($attachmentType === 'image') { if (in_array($extension, ['jpg', 'png', 'gif', 'webp', 'bmp', 'svg', 'avif', 'heic', 'heif'], true)) { return $extension; } return match ($mime) { 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/bmp', 'image/x-ms-bmp' => 'bmp', 'image/svg+xml' => 'svg', 'image/avif' => 'avif', 'image/heic' => 'heic', 'image/heif' => 'heif', default => 'jpg', }; } if ($extension !== '') { return $extension; } return 'bin'; } function detectedUploadMime(string $tmpName): string { $mime = mime_content_type($tmpName) ?: ''; if ($mime !== '' && $mime !== 'application/octet-stream') { return $mime; } $imageInfo = @getimagesize($tmpName); if (is_array($imageInfo) && !empty($imageInfo['mime'])) { return (string)$imageInfo['mime']; } return $mime !== '' ? $mime : 'application/octet-stream'; } function normalizedImageUploadMime(string $tmpName, string $mime): string { if (str_starts_with($mime, 'image/')) { return $mime; } $imageInfo = @getimagesize($tmpName); $imageMime = is_array($imageInfo) ? (string)($imageInfo['mime'] ?? '') : ''; if ($imageMime !== '' && str_starts_with($imageMime, 'image/')) { return $imageMime; } throw new RuntimeException('请选择图片文件'); } function mediaPublicBaseUrl(): string { $isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off'; $scheme = $isHttps ? 'https' : 'http'; $host = trim(preg_replace('/:\d+$/', '', (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1')) ?: '127.0.0.1'); $port = (int)WEB_MEDIA_PUBLIC_PORT; if ($port <= 0 || ($scheme === 'http' && $port === 80) || ($scheme === 'https' && $port === 443)) { return $scheme . '://' . $host; } return $scheme . '://' . $host . ':' . $port; } 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('文件上传失败'); } $size = (int)($file['size'] ?? 0); $maxSize = $attachmentType === 'image' ? WEB_IMAGE_MAX_BYTES : WEB_FILE_MAX_BYTES; if ($size <= 0 || $size > $maxSize) { throw new RuntimeException($attachmentType === 'image' ? '图片不能超过10MB' : '文件不能超过50MB'); } $tmpName = (string)($file['tmp_name'] ?? ''); if ($tmpName === '' || !is_uploaded_file($tmpName)) { throw new RuntimeException('文件上传失败'); } $mime = detectedUploadMime($tmpName); if ($attachmentType === 'image') { $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; if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) { throw new RuntimeException('媒体存储目录创建失败'); } $originalName = safeUploadName((string)($file['name'] ?? ($attachmentType === 'image' ? 'image.jpg' : 'file'))); $extension = uploadExtension($originalName, $mime, $attachmentType); $targetName = bin2hex(random_bytes(12)) . '.' . $extension; $targetPath = $targetDir . '/' . $targetName; if (!move_uploaded_file($tmpName, $targetPath)) { throw new RuntimeException('文件保存失败'); } @chmod($targetPath, 0644); return [ 'type' => $attachmentType, 'url' => mediaPublicBaseUrl() . '/' . $datePath . '/' . $targetName, 'name' => $originalName, 'mime' => $mime, 'size' => $size, 'mediaType' => $mediaType, ]; } function storeMessageAttachment( PDO $appDb, string $messageUid, string $conversationType, string $target, string $sender, array $attachment, int $timestamp ): void { if ($messageUid === '' || empty($attachment['url'])) { return; } $statement = $appDb->prepare( 'insert into `t_web_message_attachments`' . ' (`message_uid`, `conversation_type`, `target_id`, `sender_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`, `created_at_ms`)' . ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' . ' on duplicate key update' . ' `attachment_type` = values(`attachment_type`),' . ' `url` = values(`url`),' . ' `name` = values(`name`),' . ' `mime` = values(`mime`),' . ' `size_bytes` = values(`size_bytes`)' ); $statement->execute([ $messageUid, $conversationType, $target, $sender, (string)$attachment['type'], (string)$attachment['url'], (string)$attachment['name'], (string)$attachment['mime'], (int)$attachment['size'], $timestamp, ]); } function attachmentFromRequest(string $messageType, string $sender): ?array { if (!in_array($messageType, ['image', 'file'], true)) { return null; } $attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null; if (!is_array($attachmentFile)) { respond(400, '缺少上传文件'); } 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'] ?? ''); $isMultipart = stripos($contentTypeHeader, 'multipart/form-data') !== false; $payload = $isMultipart ? [] : readJsonPayload(); $sender = messageRequestValue($payload, 'sender'); $target = messageRequestValue($payload, 'target'); $text = messageRequestValue($payload, 'text'); $messageType = messageRequestValue($payload, 'messageType', 'text'); $conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0; $isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true); $attachment = null; if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) { respond(400, '缺少发送者、接收者或消息内容'); } try { if ($isGroupConversation) { ensureWebImAppTables(); $appDb = imAppDb(); $memberStatement = $appDb->prepare( 'select g.`group_id`, g.`category`, g.`owner_uid`, m.`role`' . ' from `t_web_groups` g' . ' join `t_web_group_members` m on m.`group_id` = g.`group_id` and m.`status` = 0' . ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?' . ' limit 1' ); $memberStatement->execute([$target, $sender]); $group = $memberStatement->fetch(); if (!$group) { $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'); if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) { respond(403, '公告群仅管理员可发言'); } $now = webImNowMs(); if ($category !== 'channel' && isGroupMutedForSender($appDb, $target, $role, $now)) { respond(403, '当前群组处于禁言时段'); } $attachment = attachmentFromRequest($messageType, $sender); $messageUid = webImMessageUid(); $insert = $appDb->prepare( 'insert into `t_web_group_messages`' . ' (`message_uid`, `group_id`, `sender_uid`, `text`, `created_at_ms`, `edited_at_ms`, `recalled`)' . ' values (?, ?, ?, ?, ?, 0, 0)' ); $insert->execute([$messageUid, $target, $sender, $text, $now]); if ($attachment) { storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $now); } $updateGroup = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?'); $updateGroup->execute([$now, $target]); $nativeError = ''; try { requireImSuccess(imAdminPost('/admin/message/send', [ 'sender' => $sender, 'conv' => [ 'type' => 1, 'target' => $target, 'line' => 0, ], 'payload' => messagePayloadBody($text, $attachment), 'userMessage' => true, ]), '原生群消息发送失败'); } catch (Throwable $error) { $nativeError = $error->getMessage() ?: '原生群消息发送失败'; } respond(0, 'success', [ 'messageUid' => $messageUid, 'timestamp' => $now, 'senderRole' => $role, 'attachment' => $attachment, 'nativeError' => $nativeError, ]); } $attachment = attachmentFromRequest($messageType, $sender); $result = requireImSuccess(imAdminPost('/admin/message/send', [ 'sender' => $sender, 'conv' => [ 'type' => 0, '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, 'single', $target, $sender, $attachment, $timestamp); } respond(0, 'success', [ 'messageUid' => $messageUid !== '' ? $messageUid : null, 'timestamp' => $timestamp, 'attachment' => $attachment, ]); } catch (Throwable $error) { respond(500, $error->getMessage() ?: '发送失败'); }