1.没置顶的消息,到最上面去了 2.无法转发图片 3.用户ID 是一串字母+数字 4.视频无法显示 5.消息无法置顶 6.可以引用文字,无法艾特用户 7.图片点开只能显示一半 8.转发不能多选,只能一个个转发 9.转发有转发提示,需取消

This commit is contained in:
annali168290-ux
2026-10-11 00:26:07 +07:00
parent cf66f936e5
commit 1cabf661ca
29 changed files with 3906 additions and 1579 deletions
+71 -13
View File
@@ -425,12 +425,38 @@ function currentMuteSetting(PDO $appDb, string $groupId): array
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
$groupStatement = $imDb->prepare('select * from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$nativeGroup = $groupStatement->fetch();
$memberStatement = $imDb->prepare('select `_type`, `_alias` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$memberStatement->execute([$groupId, $userId]);
$nativeMember = $memberStatement->fetch();
if (!$nativeGroup || !$nativeMember) {
respond(403, '你不在该群组中');
}
$ownerStatement = $imDb->prepare('select `_mid` from `t_group_member` where `_gid` = ? and `_type` = 2 limit 1');
$ownerStatement->execute([$groupId]);
$ownerId = (string)($ownerStatement->fetchColumn() ?: '');
$users = fetchWebUserMap($imDb, array_filter([$ownerId, $userId]), $userId);
return ['group' => [
'groupId' => $groupId,
'publicId' => (string)($nativeGroup['id'] ?? ''),
'name' => (string)($nativeGroup['_name'] ?? $groupId),
'category' => 'group',
'announcement' => (string)($nativeGroup['_bulletin'] ?? ''),
'welcomeText' => '',
'ownerUid' => $ownerId,
'ownerName' => (string)($users[$ownerId]['displayName'] ?? $ownerId),
'myRole' => ((int)($nativeMember['_type'] ?? 0) === 2 ? 'owner' : ((int)($nativeMember['_type'] ?? 0) === 1 ? 'admin' : 'member')),
'myAlias' => (string)($nativeMember['_alias'] ?? ''),
'myDisplayName' => (string)($nativeMember['_alias'] ?? '') ?: (string)($users[$userId]['displayName'] ?? $userId),
'muteSetting' => ['enabled' => false, 'mode' => 'off'],
]];
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
@@ -443,8 +469,9 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'group' => [
'groupId' => (string)$group['group_id'],
'publicId' => (string)($group['id'] ?? ''),
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
@@ -459,11 +486,42 @@ function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupI
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
$membership = $imDb->prepare('select 1 from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1');
$membership->execute([$groupId, $userId]);
if (!$membership->fetchColumn()) {
respond(403, '你不在该群组中');
}
$nativeStatement = $imDb->prepare(
'select `_mid` as `user_id`, `_type`, `_alias`, `_create_dt` from `t_group_member`'
. ' where `_gid` = ? order by (`_type` = 2) desc, `_create_dt` asc'
);
$nativeStatement->execute([$groupId]);
$nativeMembers = $nativeStatement->fetchAll();
$nativeUsers = fetchWebUserMap($imDb, array_column($nativeMembers, 'user_id'), $userId);
return ['members' => array_map(static function (array $member) use ($nativeUsers): array {
$uid = (string)$member['user_id'];
$user = $nativeUsers[$uid] ?? ['displayName' => $uid, 'mobile' => '', 'portrait' => '', 'isContact' => false];
$alias = trim((string)($member['_alias'] ?? ''));
$type = (int)($member['_type'] ?? 0);
return [
'uid' => $uid,
'role' => $type === 2 ? 'owner' : ($type === 1 ? 'admin' : 'member'),
'alias' => $alias,
'contactAlias' => trim((string)($user['contactAlias'] ?? '')),
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => trim((string)($user['contactAlias'] ?? '')) ?: ($alias ?: (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $nativeMembers)];
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_STREAM_ROOT = '/opt/im-server/media';
function failMediaStream(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Cache-Control: no-store');
echo $message;
exit;
}
$url = trim((string)($_GET['url'] ?? ''));
if ($url === '') {
failMediaStream(400, '缺少视频地址');
}
$path = rawurldecode((string)(parse_url($url, PHP_URL_PATH) ?: ''));
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failMediaStream(400, '视频地址不合法');
}
$root = realpath(WEB_MEDIA_STREAM_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_STREAM_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failMediaStream(404, '视频文件不存在');
}
$size = (int)filesize($fullPath);
$mime = (string)(mime_content_type($fullPath) ?: 'video/mp4');
if (!str_starts_with($mime, 'video/')) {
$extension = strtolower((string)pathinfo($fullPath, PATHINFO_EXTENSION));
$mime = $extension === 'webm' ? 'video/webm' : ($extension === 'mov' ? 'video/quicktime' : 'video/mp4');
}
$start = 0;
$end = max(0, $size - 1);
$range = trim((string)($_SERVER['HTTP_RANGE'] ?? ''));
if ($range !== '' && preg_match('/^bytes=(\d*)-(\d*)$/', $range, $matches)) {
if ($matches[1] !== '') $start = (int)$matches[1];
if ($matches[2] !== '') $end = min($end, (int)$matches[2]);
if ($start < 0 || $start > $end || $start >= $size) {
header('Content-Range: bytes */' . $size);
failMediaStream(416, '请求范围无效');
}
http_response_code(206);
header('Content-Range: bytes ' . $start . '-' . $end . '/' . $size);
}
$length = $end - $start + 1;
header('Content-Type: ' . $mime);
header('Content-Length: ' . $length);
header('Accept-Ranges: bytes');
header('Content-Disposition: inline');
header('Cache-Control: public, max-age=86400');
header('X-Content-Type-Options: nosniff');
$stream = fopen($fullPath, 'rb');
if ($stream === false) failMediaStream(500, '视频文件无法读取');
fseek($stream, $start);
$remaining = $length;
while ($remaining > 0 && !feof($stream)) {
$chunk = fread($stream, min(1024 * 1024, $remaining));
if ($chunk === false || $chunk === '') break;
echo $chunk;
$remaining -= strlen($chunk);
flush();
}
fclose($stream);
+22 -6
View File
@@ -97,26 +97,41 @@ function ensureMessageEditsTable(): void
function ensurePinnedMessagesTable(): void
{
imAppDb()->exec(
$db = imAppDb();
$db->exec(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null,'
. '`conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null,'
. '`message_uid` varchar(32) not null,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null,'
. 'primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
$db->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
}
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid): void
function savePinnedMessage(string $userId, string $conversationType, string $targetId, string $messageUid, array $message): void
{
ensurePinnedMessagesTable();
$statement = imAppDb()->prepare(
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`), `updated_at_ms` = values(`updated_at_ms`)'
'insert into `t_web_pinned_messages` (`user_id`, `conversation_type`, `target_id`, `message_uid`, `message_json`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?) on duplicate key update `message_uid` = values(`message_uid`),'
. ' `message_json` = values(`message_json`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$userId, $conversationType, $targetId, $messageUid, nowTimestampMs()]);
$statement->execute([
$userId,
$conversationType,
$targetId,
$messageUid,
json_encode($message, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
nowTimestampMs(),
]);
}
function removePinnedMessage(string $userId, string $conversationType, string $targetId): void
@@ -263,7 +278,8 @@ try {
if ($targetId === '') respond(400, '缺少会话ID');
$userId = resolveActionUserId($userId);
if ($action === 'pin') {
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText);
$message = is_array($payload['message'] ?? null) ? $payload['message'] : [];
savePinnedMessage($userId, $conversationType, $targetId, $messageUidText, $message);
} else {
removePinnedMessage($userId, $conversationType, $targetId);
}
+16 -4
View File
@@ -597,6 +597,7 @@ function formatWebGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -964,6 +965,7 @@ function formatNativeGroupMessage(
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'userId' => (string)($senderUser['_name'] ?? ''),
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
@@ -1064,7 +1066,7 @@ function loadNativeGroupMessages(PDO $db, string $userId, string $groupId, int $
}
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 4, $limit), 400);
$scanLimit = min(max($limit * 4, $limit), 5000);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 1 and `_line` = 0 and `_target` = ?'
@@ -1624,16 +1626,26 @@ function messageSyncResult(
'create table if not exists `t_web_pinned_messages` ('
. '`user_id` varchar(64) not null, `conversation_type` varchar(16) not null,'
. '`target_id` varchar(128) not null, `message_uid` varchar(32) not null,'
. '`message_json` text null,'
. '`updated_at_ms` bigint not null, primary key (`user_id`, `conversation_type`, `target_id`)'
. ') engine=InnoDB default charset=utf8mb4'
);
try {
imAppDb()->exec('alter table `t_web_pinned_messages` add column `message_json` text null after `message_uid`');
} catch (Throwable) {
// The column already exists.
}
$statement = imAppDb()->prepare(
'select `message_uid` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
'select `message_uid`, `message_json` from `t_web_pinned_messages` where `user_id` = ? and `conversation_type` = ? and `target_id` = ? limit 1'
);
$statement->execute([$userId, $conversationType, $targetId]);
$result['pinnedMessageUid'] = (string)($statement->fetchColumn() ?: '');
$pinnedRow = $statement->fetch();
$result['pinnedMessageUid'] = (string)($pinnedRow['message_uid'] ?? '');
$pinnedMessage = json_decode((string)($pinnedRow['message_json'] ?? ''), true);
$result['pinnedMessage'] = is_array($pinnedMessage) ? $pinnedMessage : null;
} catch (Throwable) {
$result['pinnedMessageUid'] = '';
$result['pinnedMessage'] = null;
}
}
@@ -1643,7 +1655,7 @@ function messageSyncResult(
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$limit = clampLimit($payload['limit'] ?? 1000, 50, 1000);
$markRead = payloadBool($payload['markRead'] ?? false);
$includeConversations = payloadBool($payload['includeConversations'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
+112 -28
View File
@@ -8,8 +8,9 @@ require __DIR__ . '/im_web_bootstrap.php';
const WEB_MEDIA_ROOT = '/opt/im-server/media';
const WEB_MEDIA_PUBLIC_PORT = 18000;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_MEDIA_TYPE_IMAGE = 1;
const WEB_MEDIA_TYPE_VIDEO = 3;
const WEB_MEDIA_TYPE_FILE = 4;
const WEB_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
@@ -245,13 +246,32 @@ function storeMessageAttachment(
]);
}
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'file'], true)) {
return null;
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
function attachmentFromRequest(string $messageType): ?array
{
if (!in_array($messageType, ['image', 'video', 'file'], true)) {
return null;
}
$forwardUrl = trim((string)($_POST['attachmentUrl'] ?? ''));
if ($forwardUrl !== '') {
$parts = parse_url($forwardUrl);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
if (!in_array($scheme, ['http', 'https'], true) || empty($parts['host']) || strlen($forwardUrl) > 2048) {
respond(400, '媒体地址不合法');
}
$mime = trim((string)($_POST['attachmentMime'] ?? 'application/octet-stream'));
$name = safeUploadName((string)($_POST['attachmentName'] ?? ($messageType === 'image' ? '图片' : ($messageType === 'video' ? '视频.mp4' : '文件'))));
return [
'type' => $messageType,
'url' => $forwardUrl,
'name' => $name,
'mime' => $mime,
'size' => max(0, (int)($_POST['attachmentSize'] ?? 0)),
'mediaType' => $messageType === 'image' ? WEB_MEDIA_TYPE_IMAGE : ($messageType === 'video' ? WEB_MEDIA_TYPE_VIDEO : WEB_MEDIA_TYPE_FILE),
];
}
$attachmentFile = $_FILES['attachment'] ?? $_FILES['file'] ?? null;
if (!is_array($attachmentFile)) {
respond(400, '缺少上传文件');
}
@@ -266,11 +286,15 @@ $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);
$conversationType = $_POST['conversationType'] ?? $_POST['type'] ?? $payload['conversationType'] ?? $payload['type'] ?? 0;
$isGroupConversation = in_array((string)$conversationType, ['1', 'group', 'channel'], true);
$mentionedUserIds = $payload['mentionedUserIds'] ?? [];
$mentionedUserIds = is_array($mentionedUserIds)
? array_values(array_unique(array_filter(array_map(static fn($value): string => trim((string)$value), $mentionedUserIds))))
: [];
$attachment = null;
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'file'], true))) {
if ($sender === '' || $target === '' || ($text === '' && !in_array($messageType, ['image', 'video', 'file'], true))) {
respond(400, '缺少发送者、接收者或消息内容');
}
@@ -285,11 +309,69 @@ try {
. ' where g.`deleted` = 0 and g.`group_id` = ? and m.`user_id` = ?'
. ' limit 1'
);
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
respond(403, '你不在该群组中');
}
$memberStatement->execute([$target, $sender]);
$group = $memberStatement->fetch();
if (!$group) {
// 手机端创建的原生群不一定存在于 Web 自建群表中,需回退检查 IM 群成员表。
$imDb = imDb();
$nativeGroupStatement = $imDb->prepare(
'select 1 from `t_group` where `_gid` = ? and `_deleted` = 0 limit 1'
);
$nativeGroupStatement->execute([$target]);
$nativeMemberStatement = $imDb->prepare(
'select `_type` from `t_group_member` where `_gid` = ? and `_mid` = ? limit 1'
);
$nativeMemberStatement->execute([$target, $sender]);
$nativeMemberType = $nativeMemberStatement->fetchColumn();
if (!$nativeGroupStatement->fetchColumn() || $nativeMemberType === false) {
respond(403, '你不在该群组中');
}
$attachment = attachmentFromRequest($messageType);
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
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);
}
$nativeResult = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => ['type' => 1, 'target' => $target, 'line' => 0],
'payload' => $payloadBody,
]), '原生群消息发送失败');
$messageUid = isset($nativeResult['messageUid']) ? (string)$nativeResult['messageUid'] : '';
$timestamp = (int)($nativeResult['timestamp'] ?? webImNowMs());
if ($attachment && $messageUid !== '') {
storeMessageAttachment($appDb, $messageUid, 'group', $target, $sender, $attachment, $timestamp);
}
$role = (int)$nativeMemberType === 2 ? 'owner' : ((int)$nativeMemberType === 1 ? 'admin' : 'member');
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $timestamp,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => '',
]);
}
$category = (string)($group['category'] ?? 'group');
$role = (string)($group['role'] ?? 'member');
if ($category === 'channel' && !in_array($role, ['owner', 'admin'], true)) {
@@ -319,15 +401,19 @@ try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
'persistFlag' => 3,
];
'persistFlag' => 3,
];
if ($mentionedUserIds) {
$payloadBody['mentionedType'] = 1;
$payloadBody['mentionedTarget'] = $mentionedUserIds;
}
if ($attachment) {
$payloadBody['mediaType'] = (int)$attachment['mediaType'];
$payloadBody['remoteMediaUrl'] = (string)$attachment['url'];
@@ -344,8 +430,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
@@ -364,10 +449,10 @@ try {
$pushContent = $text !== ''
? $text
: ($attachment
? ((string)$attachment['type'] === 'image' ? '[图片]' : '[文件] ' . (string)$attachment['name'])
? ((string)$attachment['type'] === 'image' ? '[图片]' : ((string)$attachment['type'] === 'video' ? '[视频]' : '[文件] ' . (string)$attachment['name']))
: $text);
$payloadBody = [
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : 1,
'type' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : ((string)$attachment['type'] === 'video' ? 6 : 5)) : 1,
'searchableContent' => $pushContent,
'pushContent' => $pushContent,
'content' => $text,
@@ -390,8 +475,7 @@ try {
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
'payload' => $payloadBody,
]), '发送失败');
$messageUid = isset($result['messageUid']) ? (string)$result['messageUid'] : '';