Initial Web IM deployment

This commit is contained in:
winchen
2026-06-30 10:13:48 +07:00
commit 1cbc3c4cf0
67 changed files with 55851 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$keyword = trim((string)($payload['keyword'] ?? ''));
$reason = trim((string)($payload['reason'] ?? '添加联系人'));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
if ($keyword === '') {
respond(400, '请输入要添加的联系人');
}
function tryImUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户查询失败');
return $result ?: null;
} catch (Throwable $error) {
return null;
}
}
function normalizeUserInfo(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? '');
return [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'alias' => '',
];
}
try {
$target = tryImUserInfo(['userId' => $keyword]);
if (!$target && preg_match('/^\+?\d{5,32}$/', $keyword)) {
$target = tryImUserInfo(['mobile' => $keyword]);
}
if (!$target) {
$target = tryImUserInfo(['name' => $keyword]);
}
if (!$target) {
respond(404, '没有找到该用户');
}
$contact = normalizeUserInfo($target);
if ($contact['uid'] === '') {
respond(404, '没有找到该用户');
}
if ($contact['uid'] === $userId) {
respond(400, '不能添加自己为联系人');
}
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'好友列表检查失败'
);
$friendIds = array_values(array_filter($friendResult['list'] ?? [], 'is_string'));
if (in_array($contact['uid'], $friendIds, true)) {
respond(409, '该用户已经是联系人', ['contact' => $contact]);
}
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $userId,
'friendUid' => $contact['uid'],
'status' => 0,
]),
'添加联系人失败'
);
requireImSuccess(
imAdminPost('/admin/friend/status', [
'userId' => $contact['uid'],
'friendUid' => $userId,
'status' => 0,
]),
'添加联系人失败'
);
respond(0, 'success', ['contact' => $contact, 'reason' => $reason]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '添加联系人服务暂不可用');
}
+71
View File
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$friendResult = requireImSuccess(
imAdminPost('/admin/friend/list', ['userId' => $userId]),
'联系人加载失败'
);
$friendIds = array_values(array_unique(array_filter(array_map(static function ($uid): string {
return trim((string)$uid);
}, $friendResult['list'] ?? []))));
if (!$friendIds) {
respond(0, 'success', ['contacts' => []]);
}
$usersResult = requireImSuccess(
imAdminPost('/admin/user/batch_get_infos', ['list' => $friendIds]),
'联系人资料加载失败'
);
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $userId, $friendIds);
} catch (Throwable) {
$remarkMap = [];
}
$contactsByUid = [];
foreach (array_values(array_filter($usersResult['userInfos'] ?? [], 'is_array')) as $user) {
$uid = (string)($user['userId'] ?? '');
if ($uid === '' || isset($contactsByUid[$uid])) {
continue;
}
$alias = '';
try {
$aliasResult = requireImSuccess(
imAdminPost('/admin/friend/get_alias', ['operator' => $userId, 'targetId' => $uid]),
'联系人备注加载失败'
);
$alias = trim((string)($aliasResult['alias'] ?? ''));
} catch (Throwable $error) {
$alias = '';
}
if ($alias === '') {
$alias = $remarkMap[$uid] ?? '';
}
$contactsByUid[$uid] = [
'uid' => $uid,
'name' => (string)($user['name'] ?? $uid),
'displayName' => (string)($user['displayName'] ?? $user['name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'address' => (string)($user['address'] ?? ''),
'alias' => $alias,
];
}
respond(0, 'success', ['contacts' => array_values($contactsByUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '联系人服务暂不可用');
}
+990
View File
@@ -0,0 +1,990 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function webActionList(mixed $value): array
{
if (is_array($value)) {
$items = $value;
} else {
$items = preg_split('/[\s,,;;]+/u', (string)$value) ?: [];
}
return array_values(array_unique(array_filter(array_map(static function ($item): string {
return trim((string)$item);
}, $items))));
}
function appPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function resolveWebUserIds(PDO $db, array $values): array
{
$values = webActionList($values);
if (!$values) {
return [];
}
$resolved = [];
foreach ($values as $value) {
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
if (is_string($uid) && $uid !== '') {
$resolved[] = $uid;
}
}
return array_values(array_unique($resolved));
}
function webGroupConversation(array $group, string $role = 'member'): array
{
$name = (string)($group['name'] ?? $group['_name'] ?? $group['group_id'] ?? $group['_gid'] ?? '');
$groupId = (string)($group['group_id'] ?? $group['_gid'] ?? '');
$category = (string)($group['category'] ?? 'group');
$title = $name !== '' ? $name : $groupId;
$updatedAt = (int)($group['updated_at_ms'] ?? $group['_dt'] ?? webImNowMs());
return [
'id' => 'group-' . $groupId,
'type' => $category === 'channel' ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $category === 'channel' ? '公告群' : '群组',
'unread' => 0,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => $role,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => $role,
],
];
}
function fetchWebGroup(PDO $db, string $groupKey): ?array
{
$statement = $db->prepare(
'select * from `t_web_groups`'
. ' where `deleted` = 0 and (`group_id` = ? or `name` = ?)'
. ' order by (`group_id` = ?) desc limit 1'
);
$statement->execute([$groupKey, $groupKey, $groupKey]);
$group = $statement->fetch();
return is_array($group) ? $group : null;
}
function fetchWebGroupRole(PDO $db, string $groupId, string $userId): string
{
$statement = $db->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : 'member';
}
function fetchWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$statement = $db->prepare(
'select * from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$member = $statement->fetch();
return is_array($member) ? $member : [];
}
function requireWebGroupMember(PDO $db, string $groupId, string $userId): array
{
$member = fetchWebGroupMember($db, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
return $member;
}
function requireWebGroupRole(PDO $db, string $groupId, string $userId, array $roles): array
{
$member = requireWebGroupMember($db, $groupId, $userId);
if (!in_array((string)$member['role'], $roles, true)) {
respond(403, '没有群组管理权限');
}
return $member;
}
function upsertWebGroupMember(PDO $db, string $groupId, string $userId, string $role): void
{
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_group_members`'
. ' (`group_id`, `user_id`, `role`, `joined_at_ms`, `updated_at_ms`, `status`)'
. ' values (?, ?, ?, ?, ?, 0)'
. ' on duplicate key update'
. ' `role` = if(`role` = "owner", `role`, values(`role`)),'
. ' `updated_at_ms` = values(`updated_at_ms`),'
. ' `status` = 0'
);
$statement->execute([$groupId, $userId, $role, $now, $now]);
}
function createNativeGroup(string $operator, string $groupId, string $name, string $category, array $members): array
{
$nativeMembers = [];
foreach ($members as $memberId) {
$nativeMembers[] = [
'member_id' => $memberId,
'type' => $memberId === $operator ? 2 : 0,
];
}
return requireImSuccess(imAdminPost('/admin/group/create', [
'operator' => $operator,
'group' => [
'group_info' => [
'target_id' => $groupId,
'name' => $name,
'owner' => $operator,
'type' => 0,
'mute' => $category === 'channel' ? 1 : 0,
'join_type' => 0,
'searchable' => 1,
'history_message' => 1,
'extra' => json_encode(['webCategory' => $category], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
],
'members' => $nativeMembers,
],
'to_lines' => [0],
]), '原生群组创建失败');
}
function addNativeGroupMember(string $operator, string $groupId, string $userId): void
{
requireImSuccess(imAdminPost('/admin/group/member/add', [
'operator' => $operator,
'group_id' => $groupId,
'members' => [
['member_id' => $userId, 'type' => 0],
],
'to_lines' => [0],
]), '加入原生群组失败');
}
function removeNativeGroupMember(string $operator, string $groupId, array $userIds): void
{
if (!$userIds) {
return;
}
requireImSuccess(imAdminPost('/admin/group/member/del', [
'operator' => $operator,
'group_id' => $groupId,
'members' => array_values($userIds),
'to_lines' => [0],
]), '移除原生成员失败');
}
function dismissNativeGroup(string $operator, string $groupId): void
{
requireImSuccess(imAdminPost('/admin/group/del', [
'operator' => $operator,
'group_id' => $groupId,
'to_lines' => [0],
]), '解散原生群组失败');
}
function transferNativeGroup(string $operator, string $groupId, string $newOwner): void
{
requireImSuccess(imAdminPost('/admin/group/transfer', [
'operator' => $operator,
'group_id' => $groupId,
'new_owner' => $newOwner,
'to_lines' => [0],
]), '转让原生群组失败');
}
function sendWebGroupWelcomeMessage(PDO $appDb, string $groupId, string $senderId, string $welcomeText): void
{
$text = trim($welcomeText);
if ($text === '') {
return;
}
$now = webImNowMs();
$messageUid = webImMessageUid();
$statement = $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)'
);
$statement->execute([$messageUid, $groupId, $senderId, $text, $now]);
$update = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $senderId,
'conv' => [
'type' => 1,
'target' => $groupId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '欢迎词发送失败');
} catch (Throwable) {
}
}
function createWebGroupAction(PDO $imDb, PDO $appDb, string $userId, array $payload, string $category): array
{
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, $category === 'channel' ? '请输入公告群名称' : '请输入群组名称');
}
$memberIds = resolveWebUserIds($imDb, webActionList($payload['memberIds'] ?? []));
$memberIds[] = $userId;
$memberIds = array_values(array_unique(array_filter($memberIds)));
if ($category === 'group' && count($memberIds) < 2) {
respond(400, '请选择至少一位联系人');
}
$groupId = 'webg_' . bin2hex(random_bytes(8));
$now = webImNowMs();
$nativeStatus = 'pending';
$nativeGroupId = '';
$nativeError = '';
try {
$nativeResult = createNativeGroup($userId, $groupId, $name, $category, $memberIds);
$nativeGroupId = (string)($nativeResult['group_id'] ?? $nativeResult['groupId'] ?? $groupId);
$nativeStatus = 'ok';
} catch (Throwable $error) {
$nativeStatus = 'fallback';
$nativeError = mb_substr($error->getMessage() ?: '原生群组创建失败', 0, 255, 'UTF-8');
}
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `welcome_text`, `native_group_id`, `native_status`, `native_error`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, ?, ?, "", ?, "open", ?, ?, ?, ?, ?, ?, ?, ?, 0)'
);
$statement->execute([
$groupId,
$category,
$name,
$userId,
$announcement,
$welcomeText,
$nativeGroupId,
$nativeStatus,
$nativeError,
$userId,
$now,
$now,
]);
foreach ($memberIds as $memberId) {
upsertWebGroupMember($appDb, $groupId, $memberId, $memberId === $userId ? 'owner' : 'member');
}
sendWebGroupWelcomeMessage($appDb, $groupId, $userId, $welcomeText);
$group = fetchWebGroup($appDb, $groupId);
return [
'group' => $group,
'conversation' => webGroupConversation($group ?: [
'group_id' => $groupId,
'category' => $category,
'name' => $name,
'updated_at_ms' => $now,
], 'owner'),
'nativeStatus' => $nativeStatus,
'nativeError' => $nativeError,
];
}
function fetchWebUserMap(PDO $imDb, array $userIds, string $ownerId = ''): array
{
$userIds = webActionList($userIds);
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . appPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$displayName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'name' => (string)($row['_name'] ?? ''),
'displayName' => $displayName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . appPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['isContact'] = true;
$users[$friendUid]['friendAlias'] = trim((string)($row['_alias'] ?? ''));
}
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['remarkAlias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['friendAlias'] ?? ''));
$remarkAlias = trim((string)($user['remarkAlias'] ?? ''));
$users[$uid]['contactAlias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function currentMuteSetting(PDO $appDb, string $groupId): array
{
$statement = $appDb->prepare('select * from `t_web_group_mute_rules` where `group_id` = ? limit 1');
$statement->execute([$groupId]);
$row = $statement->fetch();
if (!is_array($row)) {
return [
'mode' => 'off',
'scheduleType' => 'once',
'startsAtMs' => 0,
'endsAtMs' => 0,
'dailyStart' => '',
'dailyEnd' => '',
'enabled' => false,
];
}
return [
'mode' => (string)$row['mode'],
'scheduleType' => (string)$row['schedule_type'],
'startsAtMs' => (int)$row['starts_at_ms'],
'endsAtMs' => (int)$row['ends_at_ms'],
'dailyStart' => (string)$row['daily_start'],
'dailyEnd' => (string)$row['daily_end'],
'enabled' => (bool)$row['enabled'],
];
}
function groupDetailAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$ownerId = (string)$group['owner_uid'];
$users = fetchWebUserMap($imDb, [$ownerId, $userId], $userId);
$owner = $users[$ownerId] ?? ['uid' => $ownerId, 'displayName' => $ownerId, 'contactAlias' => ''];
$me = $users[$userId] ?? ['uid' => $userId, 'displayName' => $userId];
$myAlias = (string)($member['alias'] ?? '');
return [
'group' => [
'groupId' => (string)$group['group_id'],
'name' => (string)$group['name'],
'category' => (string)$group['category'],
'announcement' => (string)($group['announcement'] ?? ''),
'welcomeText' => (string)($group['welcome_text'] ?? ''),
'ownerUid' => $ownerId,
'ownerName' => (string)($owner['contactAlias'] ?? '') !== '' ? (string)$owner['contactAlias'] : (string)$owner['displayName'],
'myRole' => (string)$member['role'],
'myAlias' => $myAlias,
'myDisplayName' => $myAlias !== '' ? $myAlias : (string)$me['displayName'],
'muteSetting' => currentMuteSetting($appDb, (string)$group['group_id']),
],
];
}
function groupMembersAction(PDO $imDb, PDO $appDb, string $userId, string $groupId): array
{
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$statement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 order by (`role` = "owner") desc, `joined_at_ms` asc'
);
$statement->execute([$groupId]);
$members = $statement->fetchAll();
$users = fetchWebUserMap($imDb, array_column($members, 'user_id'), $userId);
return [
'members' => array_map(static function (array $member) use ($users): array {
$uid = (string)$member['user_id'];
$user = $users[$uid] ?? [
'uid' => $uid,
'displayName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'friendAlias' => '',
'remarkAlias' => '',
'isContact' => false,
];
$alias = trim((string)($member['alias'] ?? ''));
$contactAlias = trim((string)($user['contactAlias'] ?? ''));
return [
'uid' => $uid,
'role' => (string)$member['role'],
'alias' => $alias,
'contactAlias' => $contactAlias,
'friendAlias' => trim((string)($user['friendAlias'] ?? '')),
'remarkAlias' => trim((string)($user['remarkAlias'] ?? '')),
'isContact' => (bool)($user['isContact'] ?? false),
'displayName' => $contactAlias !== '' ? $contactAlias : ($alias !== '' ? $alias : (string)$user['displayName']),
'rawDisplayName' => (string)$user['displayName'],
'groupDisplayName' => $alias,
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
];
}, $members),
];
}
function updateGroupAliasAction(PDO $appDb, string $userId, string $groupId, string $alias): array
{
if (mb_strlen($alias, 'UTF-8') > 64) {
respond(400, '本群昵称过长');
}
if (!fetchWebGroupMember($appDb, $groupId, $userId)) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `alias` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0'
);
$statement->execute([$alias, $now, $groupId, $userId]);
return ['alias' => $alias];
}
function leaveGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
$member = fetchWebGroupMember($appDb, $groupId, $userId);
if (!$member) {
respond(403, '你不在该群组中');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_group_members` set `status` = 1, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$statement->execute([$now, $groupId, $userId]);
$groupUpdate = $appDb->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, $groupId]);
try {
requireImSuccess(imAdminPost('/admin/group/member/quit', [
'operator' => $userId,
'group_id' => $groupId,
'to_lines' => [0],
]), '退出原生群组失败');
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function dismissGroupAction(PDO $appDb, string $userId, string $groupId): array
{
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$now = webImNowMs();
$update = $appDb->prepare('update `t_web_groups` set `deleted` = 1, `updated_at_ms` = ? where `group_id` = ?');
$update->execute([$now, $groupId]);
$memberUpdate = $appDb->prepare('update `t_web_group_members` set `status` = 1, `updated_at_ms` = ? where `group_id` = ?');
$memberUpdate->execute([$now, $groupId]);
try {
dismissNativeGroup($userId, $groupId);
} catch (Throwable) {
}
return ['groupId' => $groupId];
}
function transferGroupAction(PDO $appDb, string $userId, string $groupId, string $targetUserId): array
{
if ($targetUserId === '' || $targetUserId === $userId) {
respond(400, '请选择新的群主');
}
$group = fetchWebGroup($appDb, $groupId);
if (!$group) {
respond(404, '没有找到该群组');
}
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$target = requireWebGroupMember($appDb, $groupId, $targetUserId);
if ((string)$target['role'] === 'owner') {
respond(400, '该成员已经是群主');
}
$now = webImNowMs();
$appDb->beginTransaction();
try {
$groupUpdate = $appDb->prepare('update `t_web_groups` set `owner_uid` = ?, `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$targetUserId, $now, $groupId]);
$oldOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$oldOwnerUpdate->execute([$now, $groupId, $userId]);
$newOwnerUpdate = $appDb->prepare(
'update `t_web_group_members` set `role` = "owner", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` = ?'
);
$newOwnerUpdate->execute([$now, $groupId, $targetUserId]);
$appDb->commit();
} catch (Throwable $error) {
$appDb->rollBack();
throw $error;
}
try {
transferNativeGroup($userId, $groupId, $targetUserId);
} catch (Throwable) {
}
return ['groupId' => $groupId, 'newOwner' => $targetUserId];
}
function setGroupAdminsAction(PDO $appDb, string $userId, string $groupId, array $adminIds): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner']);
$adminIds = array_values(array_unique(array_filter(array_map('strval', $adminIds), static fn(string $uid): bool => $uid !== $userId)));
$now = webImNowMs();
$reset = $appDb->prepare(
'update `t_web_group_members` set `role` = "member", `updated_at_ms` = ?'
. ' where `group_id` = ? and `role` = "admin" and `status` = 0'
);
$reset->execute([$now, $groupId]);
if ($adminIds) {
$update = $appDb->prepare(
'update `t_web_group_members` set `role` = "admin", `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($adminIds) . ')'
. ' and `role` <> "owner" and `status` = 0'
);
$update->execute(array_merge([$now, $groupId], $adminIds));
}
return ['adminIds' => $adminIds];
}
function updateGroupProfileAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$name = trim((string)($payload['name'] ?? ''));
$announcement = trim((string)($payload['announcement'] ?? ''));
$welcomeText = trim((string)($payload['welcomeText'] ?? ''));
if ($name === '') {
respond(400, '请输入群名称');
}
if (mb_strlen($name, 'UTF-8') > 128) {
respond(400, '群名称过长');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'update `t_web_groups` set `name` = ?, `announcement` = ?, `welcome_text` = ?, `updated_at_ms` = ?'
. ' where `group_id` = ? and `deleted` = 0'
);
$statement->execute([$name, $announcement, $welcomeText, $now, $groupId]);
return [
'groupId' => $groupId,
'name' => $name,
'announcement' => $announcement,
'welcomeText' => $welcomeText,
'updatedAt' => $now,
];
}
function kickGroupMembersAction(PDO $appDb, string $userId, string $groupId, array $targetIds): array
{
$operator = requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$operatorRole = (string)$operator['role'];
$targetIds = array_values(array_unique(array_filter(array_map('strval', $targetIds), static fn(string $uid): bool => $uid !== '')));
$targetIds = array_values(array_filter($targetIds, static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择要移除的群员');
}
$statement = $appDb->prepare(
'select `user_id`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($targetIds) . ') and `status` = 0'
);
$statement->execute(array_merge([$groupId], $targetIds));
$targets = $statement->fetchAll();
if (!$targets) {
respond(400, '没有可移除的群员');
}
$allowedIds = [];
foreach ($targets as $target) {
$role = (string)$target['role'];
if ($role === 'owner') {
continue;
}
if ($operatorRole === 'admin' && $role !== 'member') {
continue;
}
$allowedIds[] = (string)$target['user_id'];
}
if (!$allowedIds) {
respond(403, '没有可移除的群员');
}
$now = webImNowMs();
$update = $appDb->prepare(
'update `t_web_group_members` set `status` = 2, `updated_at_ms` = ?'
. ' where `group_id` = ? and `user_id` in (' . appPlaceholders($allowedIds) . ')'
);
$update->execute(array_merge([$now, $groupId], $allowedIds));
try {
removeNativeGroupMember($userId, $groupId, $allowedIds);
} catch (Throwable) {
}
return ['removedIds' => $allowedIds];
}
function updateMuteSettingAction(PDO $appDb, string $userId, string $groupId, array $payload): array
{
requireWebGroupRole($appDb, $groupId, $userId, ['owner', 'admin']);
$mode = trim((string)($payload['mode'] ?? 'off'));
$allowedModes = ['off', 'all_include_admins', 'all_except_admins'];
if (!in_array($mode, $allowedModes, true)) {
respond(400, '禁言模式不正确');
}
$scheduleType = trim((string)($payload['scheduleType'] ?? 'once'));
if (!in_array($scheduleType, ['manual', 'once', 'daily'], true)) {
respond(400, '禁言时段类型不正确');
}
$startsAtMs = max(0, (int)($payload['startsAtMs'] ?? 0));
$endsAtMs = max(0, (int)($payload['endsAtMs'] ?? 0));
$dailyStart = trim((string)($payload['dailyStart'] ?? ''));
$dailyEnd = trim((string)($payload['dailyEnd'] ?? ''));
$enabled = $mode !== 'off';
if ($enabled && $scheduleType === 'once' && ($startsAtMs <= 0 || $endsAtMs <= $startsAtMs)) {
respond(400, '请设置有效的单次禁言时段');
}
if ($enabled && $scheduleType === 'daily' && (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd))) {
respond(400, '请设置有效的每日禁言时段');
}
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_mute_rules`'
. ' (`group_id`, `mode`, `schedule_type`, `starts_at_ms`, `ends_at_ms`, `daily_start`, `daily_end`, `enabled`, `created_by`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
. ' on duplicate key update'
. ' `mode` = values(`mode`),'
. ' `schedule_type` = values(`schedule_type`),'
. ' `starts_at_ms` = values(`starts_at_ms`),'
. ' `ends_at_ms` = values(`ends_at_ms`),'
. ' `daily_start` = values(`daily_start`),'
. ' `daily_end` = values(`daily_end`),'
. ' `enabled` = values(`enabled`),'
. ' `created_by` = values(`created_by`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $mode, $scheduleType, $startsAtMs, $endsAtMs, $dailyStart, $dailyEnd, $enabled ? 1 : 0, $userId, $now]);
return ['muteSetting' => currentMuteSetting($appDb, $groupId)];
}
function joinWebGroupAction(PDO $appDb, string $userId, string $groupKey): array
{
if ($groupKey === '') {
respond(400, '请输入群组ID或群名称');
}
$group = fetchWebGroup($appDb, $groupKey);
if (!$group) {
try {
$native = requireImSuccess(imAdminPost('/admin/group/get_info', ['groupId' => $groupKey]), '群组不存在');
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_groups`'
. ' (`group_id`, `category`, `name`, `portrait`, `owner_uid`, `join_mode`, `announcement`,'
. ' `native_group_id`, `native_status`, `created_by`, `created_at_ms`, `updated_at_ms`, `deleted`)'
. ' values (?, "group", ?, ?, ?, "open", "", ?, "ok", ?, ?, ?, 0)'
. ' on duplicate key update `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([
$groupKey,
(string)($native['name'] ?? $groupKey),
(string)($native['portrait'] ?? ''),
(string)($native['owner'] ?? ''),
$groupKey,
$userId,
$now,
$now,
]);
$group = fetchWebGroup($appDb, $groupKey);
} catch (Throwable) {
respond(404, '没有找到该群组');
}
}
$groupId = (string)$group['group_id'];
upsertWebGroupMember($appDb, $groupId, $userId, 'member');
try {
addNativeGroupMember((string)($group['owner_uid'] ?: $userId), $groupId, $userId);
} catch (Throwable $error) {
// Web membership is authoritative for this web build; native group sync is best-effort.
}
$role = fetchWebGroupRole($appDb, $groupId, $userId);
return [
'group' => fetchWebGroup($appDb, $groupId),
'conversation' => webGroupConversation($group, $role),
];
}
function sendBroadcastAction(PDO $imDb, PDO $appDb, string $userId, array $payload): array
{
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '请输入群发内容');
}
$targetIds = resolveWebUserIds($imDb, webActionList($payload['targetIds'] ?? []));
$targetIds = array_values(array_filter(array_unique($targetIds), static fn(string $uid): bool => $uid !== $userId));
if (!$targetIds) {
respond(400, '请选择群发对象');
}
$results = [];
foreach ($targetIds as $targetId) {
try {
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $userId,
'conv' => [
'type' => 0,
'target' => $targetId,
'line' => 0,
],
'payload' => [
'type' => 1,
'searchableContent' => $text,
'pushContent' => $text,
'content' => $text,
'persistFlag' => 3,
],
'userMessage' => true,
]), '发送失败');
$results[] = [
'targetId' => $targetId,
'ok' => true,
'messageUid' => isset($result['messageUid']) ? (string)$result['messageUid'] : '',
];
} catch (Throwable $error) {
$results[] = [
'targetId' => $targetId,
'ok' => false,
'error' => $error->getMessage() ?: '发送失败',
];
}
}
$successCount = count(array_filter($results, static fn(array $item): bool => !empty($item['ok'])));
$failedCount = count($results) - $successCount;
$now = webImNowMs();
$statement = $appDb->prepare(
'insert into `t_web_broadcast_logs`'
. ' (`sender_uid`, `target_count`, `success_count`, `failed_count`, `text`, `target_json`, `result_json`, `created_at_ms`)'
. ' values (?, ?, ?, ?, ?, ?, ?, ?)'
);
$statement->execute([
$userId,
count($targetIds),
$successCount,
$failedCount,
$text,
json_encode($targetIds, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
$now,
]);
return [
'targetCount' => count($targetIds),
'successCount' => $successCount,
'failedCount' => $failedCount,
'results' => $results,
];
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
if ($userId === '') {
respond(400, '缺少当前用户ID');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$appDb = imAppDb();
if ($action === 'create_group') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'group'));
}
if ($action === 'create_channel') {
respond(0, 'success', createWebGroupAction($imDb, $appDb, $userId, $payload, 'channel'));
}
if ($action === 'join_group') {
respond(0, 'success', joinWebGroupAction($appDb, $userId, trim((string)($payload['groupKey'] ?? ''))));
}
if ($action === 'group_detail') {
respond(0, 'success', groupDetailAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'group_members') {
respond(0, 'success', groupMembersAction($imDb, $appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'update_group_alias') {
respond(0, 'success', updateGroupAliasAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['alias'] ?? ''))
));
}
if ($action === 'leave_group') {
respond(0, 'success', leaveGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'dismiss_group') {
respond(0, 'success', dismissGroupAction($appDb, $userId, trim((string)($payload['groupId'] ?? ''))));
}
if ($action === 'transfer_group') {
respond(0, 'success', transferGroupAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
trim((string)($payload['targetUserId'] ?? ''))
));
}
if ($action === 'set_group_admins') {
respond(0, 'success', setGroupAdminsAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['adminIds'] ?? null) ? $payload['adminIds'] : []
));
}
if ($action === 'update_group_profile') {
respond(0, 'success', updateGroupProfileAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'kick_group_members') {
respond(0, 'success', kickGroupMembersAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
is_array($payload['targetIds'] ?? null) ? $payload['targetIds'] : []
));
}
if ($action === 'update_mute_setting') {
respond(0, 'success', updateMuteSettingAction(
$appDb,
$userId,
trim((string)($payload['groupId'] ?? '')),
$payload
));
}
if ($action === 'broadcast') {
$result = sendBroadcastAction($imDb, $appDb, $userId, $payload);
if ((int)$result['successCount'] <= 0) {
respond(500, '群发失败', $result);
}
respond(0, 'success', $result);
}
respond(400, '未知操作');
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '会话操作失败');
}
+348
View File
@@ -0,0 +1,348 @@
<?php
declare(strict_types=1);
const IM_ADMIN_URL = 'http://127.0.0.1:18080';
const IM_ADMIN_SECRET = '123456';
const IM_DB_DSN = 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4';
const IM_DB_USER = 'winchen';
const IM_DB_PASSWORD = 'winchen@0904';
const IM_APP_DB_DSN = 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4';
const IM_APP_DB_USER = 'winchen';
const IM_APP_DB_PASSWORD = 'winchen@0904';
const IM_DB_TIMEZONE = 'Asia/Bangkok';
function respond(int $code, string $message, array $result = []): void
{
echo json_encode([
'code' => $code,
'message' => $message,
'result' => $result,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function readJsonPayload(): array
{
$payload = json_decode(file_get_contents('php://input') ?: '[]', true);
return is_array($payload) ? $payload : [];
}
function imAdminPost(string $path, array $payload): array
{
$nonce = (string)random_int(1, 1000000);
$timestamp = (string)((int)floor(microtime(true) * 1000));
$sign = sha1($nonce . '|' . IM_ADMIN_SECRET . '|' . $timestamp);
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$curl = curl_init(IM_ADMIN_URL . $path);
if ($curl === false) {
throw new RuntimeException('IM admin client init failed');
}
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_0,
CURLOPT_CONNECTTIMEOUT_MS => 1000,
CURLOPT_TIMEOUT_MS => 4000,
CURLOPT_FRESH_CONNECT => true,
CURLOPT_FORBID_REUSE => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json; charset=utf-8',
'Connection: close',
'nonce: ' . $nonce,
'timestamp: ' . $timestamp,
'sign: ' . $sign,
],
]);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
curl_close($curl);
if ($responseBody === false || $responseBody === '') {
throw new RuntimeException($curlError ?: 'IM admin request failed');
}
$data = json_decode($responseBody, true);
if (!is_array($data)) {
throw new RuntimeException('Invalid IM admin response');
}
return $data;
}
function requireImSuccess(array $response, string $fallbackMessage): array
{
if (($response['code'] ?? -1) !== 0) {
throw new RuntimeException((string)($response['msg'] ?? $response['message'] ?? $fallbackMessage));
}
return is_array($response['result'] ?? null) ? $response['result'] : [];
}
function imDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_DB_DSN, IM_DB_USER, IM_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function imAppDb(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$pdo = new PDO(IM_APP_DB_DSN, IM_APP_DB_USER, IM_APP_DB_PASSWORD, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return $pdo;
}
function ensureWebImAppTables(): void
{
static $ensured = false;
if ($ensured) {
return;
}
$ensured = true;
$db = imAppDb();
$db->exec(
"create table if not exists `t_web_groups` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`category` varchar(20) not null default 'group',
`name` varchar(128) not null,
`portrait` varchar(1024) not null default '',
`owner_uid` varchar(64) not null,
`join_mode` varchar(20) not null default 'open',
`announcement` text null,
`native_group_id` varchar(64) not null default '',
`native_status` varchar(32) not null default 'pending',
`native_error` varchar(255) not null default '',
`created_by` varchar(64) not null,
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`deleted` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_groups_group_id` (`group_id`),
key `idx_t_web_groups_owner` (`owner_uid`),
key `idx_t_web_groups_category` (`category`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
try {
$db->exec("alter table `t_web_groups` add column `welcome_text` text null after `announcement`");
} catch (Throwable) {
}
$db->exec(
"create table if not exists `t_web_group_members` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`role` varchar(20) not null default 'member',
`alias` varchar(128) not null default '',
`joined_at_ms` bigint not null,
`updated_at_ms` bigint not null,
`status` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_members_gid_uid` (`group_id`, `user_id`),
key `idx_t_web_group_members_user` (`user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_messages` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`group_id` varchar(64) not null,
`sender_uid` varchar(64) not null,
`text` text not null,
`created_at_ms` bigint not null,
`edited_at_ms` bigint not null default 0,
`recalled` tinyint not null default 0,
primary key (`id`),
unique key `uk_t_web_group_messages_uid` (`message_uid`),
key `idx_t_web_group_messages_group_time` (`group_id`, `created_at_ms`),
key `idx_t_web_group_messages_sender` (`sender_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_message_attachments` (
`id` bigint unsigned not null auto_increment,
`message_uid` varchar(64) not null,
`conversation_type` varchar(16) not null default 'single',
`target_id` varchar(128) not null default '',
`sender_uid` varchar(64) not null default '',
`attachment_type` varchar(16) not null,
`url` varchar(1024) not null,
`name` varchar(255) not null default '',
`mime` varchar(128) not null default '',
`size_bytes` bigint not null default 0,
`created_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_message_attachments_uid` (`message_uid`),
key `idx_t_web_message_attachments_target` (`conversation_type`, `target_id`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_reads` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`user_id` varchar(64) not null,
`read_at_ms` bigint not null default 0,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_reads_gid_uid` (`group_id`, `user_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_group_mute_rules` (
`id` bigint unsigned not null auto_increment,
`group_id` varchar(64) not null,
`mode` varchar(32) not null default 'off',
`schedule_type` varchar(16) not null default 'once',
`starts_at_ms` bigint not null default 0,
`ends_at_ms` bigint not null default 0,
`daily_start` varchar(8) not null default '',
`daily_end` varchar(8) not null default '',
`enabled` tinyint not null default 0,
`created_by` varchar(64) not null default '',
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_group_mute_rules_group` (`group_id`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_broadcast_logs` (
`id` bigint unsigned not null auto_increment,
`sender_uid` varchar(64) not null,
`target_count` int not null default 0,
`success_count` int not null default 0,
`failed_count` int not null default 0,
`text` text not null,
`target_json` text null,
`result_json` text null,
`created_at_ms` bigint not null,
primary key (`id`),
key `idx_t_web_broadcast_logs_sender_time` (`sender_uid`, `created_at_ms`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
$db->exec(
"create table if not exists `t_web_user_remarks` (
`id` bigint unsigned not null auto_increment,
`owner_uid` varchar(64) not null,
`target_uid` varchar(64) not null,
`alias` varchar(128) not null default '',
`created_at_ms` bigint not null,
`updated_at_ms` bigint not null,
primary key (`id`),
unique key `uk_t_web_user_remarks_owner_target` (`owner_uid`, `target_uid`),
key `idx_t_web_user_remarks_target` (`target_uid`)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_unicode_ci"
);
}
function webImPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function fetchWebUserRemarkMap(PDO $db, string $ownerId, array $targetIds): array
{
$targetIds = array_values(array_unique(array_filter(array_map(static function ($targetId): string {
return trim((string)$targetId);
}, $targetIds))));
if ($ownerId === '' || !$targetIds) {
return [];
}
$statement = $db->prepare(
'select `target_uid`, `alias` from `t_web_user_remarks`'
. ' where `owner_uid` = ? and `target_uid` in (' . webImPlaceholders($targetIds) . ')'
);
$statement->execute(array_merge([$ownerId], $targetIds));
$remarks = [];
foreach ($statement->fetchAll() as $row) {
$remarks[(string)$row['target_uid']] = trim((string)($row['alias'] ?? ''));
}
return $remarks;
}
function saveWebUserRemark(PDO $db, string $ownerId, string $targetId, string $alias): void
{
if ($ownerId === '' || $targetId === '') {
return;
}
if ($alias === '') {
$delete = $db->prepare('delete from `t_web_user_remarks` where `owner_uid` = ? and `target_uid` = ?');
$delete->execute([$ownerId, $targetId]);
return;
}
$now = webImNowMs();
$statement = $db->prepare(
'insert into `t_web_user_remarks` (`owner_uid`, `target_uid`, `alias`, `created_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?, ?)'
. ' on duplicate key update `alias` = values(`alias`), `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$ownerId, $targetId, $alias, $now, $now]);
}
function webImNowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function webImMessageUid(): string
{
$timestampPart = (webImNowMs() - 1514736000000) << 21;
return (string)($timestampPart | random_int(1, 0x1FFFFF));
}
function javaStringHashCode(string $value): int
{
$hash = 0;
$length = strlen($value);
for ($i = 0; $i < $length; $i++) {
$hash = (31 * $hash + ord($value[$i])) & 0xFFFFFFFF;
if ($hash >= 0x80000000) {
$hash -= 0x100000000;
}
}
return $hash;
}
function imUserMessagesTable(string $userId): string
{
return 't_user_messages_' . (abs(javaStringHashCode($userId)) % 128);
}
function imMessageTable(string $messageId): string
{
$timestamp = ((int)$messageId >> 21) + 1514736000000;
$date = (new DateTimeImmutable('@' . (int)floor($timestamp / 1000)))
->setTimezone(new DateTimeZone(IM_DB_TIMEZONE));
$year = (int)$date->format('Y');
$month = (int)$date->format('n') - 1;
return 't_messages_' . (($year % 3) * 12 + $month);
}
+58
View File
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
const WEB_MEDIA_ROOT = '/opt/im-server/media';
function failDownload(int $status, string $message): never
{
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
echo $message;
exit;
}
function safeDownloadName(string $fileName, string $fallback): string
{
$name = basename(str_replace('\\', '/', $fileName));
$name = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $name) ?: '';
$name = trim($name, " .\t\n\r\0\x0B");
if ($name === '') {
$name = $fallback;
}
return mb_substr($name, 0, 180, 'UTF-8');
}
$url = trim((string)($_GET['url'] ?? ''));
$requestedName = trim((string)($_GET['name'] ?? ''));
if ($url === '') {
failDownload(400, '缺少媒体地址');
}
$path = (string)(parse_url($url, PHP_URL_PATH) ?: $url);
$path = rawurldecode($path);
if (!preg_match('#^/fs/[0-9]+/[A-Za-z0-9_./ -]+$#', $path) || str_contains($path, '..')) {
failDownload(400, '媒体地址不合法');
}
$root = realpath(WEB_MEDIA_ROOT);
$fullPath = realpath(rtrim(WEB_MEDIA_ROOT, '/') . $path);
if ($root === false || $fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) {
failDownload(404, '媒体文件不存在');
}
$fallbackName = basename($fullPath);
$downloadName = safeDownloadName($requestedName, $fallbackName);
if (!pathinfo($downloadName, PATHINFO_EXTENSION)) {
$downloadName .= '.' . (pathinfo($fallbackName, PATHINFO_EXTENSION) ?: 'jpg');
}
$mime = mime_content_type($fullPath) ?: 'application/octet-stream';
header('Content-Type: ' . $mime);
header('Content-Length: ' . (string)filesize($fullPath));
header(
'Content-Disposition: attachment; filename="' . addcslashes($downloadName, "\\\"") . '"; filename*=UTF-8\'\''
. rawurlencode($downloadName)
);
header('Cache-Control: no-store');
readfile($fullPath);
+288
View File
@@ -0,0 +1,288 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
class UserActionException extends RuntimeException
{
}
function resolveActionUserId(string $value): string
{
if ($value === '') {
return '';
}
$db = imDb();
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function messageTimestampMs(string $messageUid): int
{
return (((int)$messageUid) >> 21) + 1514736000000;
}
function nowTimestampMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function recallLimitMinutes(): float
{
$statement = imAppDb()->prepare(
'select `conf_value` from `system_conf`'
. ' where `conf_name` = ? and `is_valid` = 1 limit 1'
);
$statement->execute(['RetractMessage']);
$value = trim((string)($statement->fetchColumn() ?: '0'));
if ($value === '' || !is_numeric($value)) {
return 0.0;
}
return max(0.0, (float)$value);
}
function assertRecallWithinLimit(string $messageUid): void
{
$limitMinutes = recallLimitMinutes();
if ($limitMinutes <= 0) {
return;
}
$messageAgeMs = nowTimestampMs() - messageTimestampMs($messageUid);
$limitMs = (int)round($limitMinutes * 60 * 1000);
if ($messageAgeMs > $limitMs) {
throw new UserActionException('已超过可撤回时间');
}
}
function fetchActionMessageRow(string $messageUid): array
{
$table = imMessageTable($messageUid);
$statement = imDb()->prepare(
'select `_mid`, `_from`, `_content_type`, `_searchable_key` from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function ensureMessageEditsTable(): void
{
imDb()->exec(
'create table if not exists `t_web_message_edits` ('
. '`message_uid` varchar(32) not null,'
. '`editor_uid` varchar(64) not null,'
. '`edited_text` text null,'
. '`edited_at_ms` bigint not null,'
. '`created_at` datetime not null default current_timestamp,'
. '`updated_at` datetime null default current_timestamp on update current_timestamp,'
. 'primary key (`message_uid`),'
. 'key `idx_editor_uid` (`editor_uid`),'
. 'key `idx_edited_at_ms` (`edited_at_ms`)'
. ') engine=InnoDB default charset=utf8mb4'
);
}
function editTextMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchActionMessageRow($messageUid);
if (!$row) {
return editWebGroupMessage($operatorId, $messageUid, $text);
}
if ((string)$row['_from'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)$row['_content_type'] !== 1) {
throw new UserActionException('只能编辑文字消息');
}
$editedAt = nowTimestampMs();
$table = imMessageTable($messageUid);
$update = imDb()->prepare('update `' . $table . '` set `_searchable_key` = ? where `_mid` = ?');
$update->execute([$text, $messageUid]);
ensureMessageEditsTable();
$upsert = imDb()->prepare(
'insert into `t_web_message_edits` (`message_uid`, `editor_uid`, `edited_text`, `edited_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `editor_uid` = values(`editor_uid`),'
. ' `edited_text` = values(`edited_text`),'
. ' `edited_at_ms` = values(`edited_at_ms`),'
. ' `updated_at` = current_timestamp'
);
$upsert->execute([$messageUid, $operatorId, $text, $editedAt]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function fetchWebGroupActionMessage(string $messageUid): array
{
ensureWebImAppTables();
$statement = imAppDb()->prepare(
'select m.*, g.`category` from `t_web_group_messages` m'
. ' join `t_web_groups` g on g.`group_id` = m.`group_id` and g.`deleted` = 0'
. ' where m.`message_uid` = ? limit 1'
);
$statement->execute([$messageUid]);
$row = $statement->fetch();
return is_array($row) ? $row : [];
}
function editWebGroupMessage(string $operatorId, string $messageUid, string $text): array
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
throw new UserActionException('消息不存在');
}
if ((string)$row['sender_uid'] !== $operatorId) {
throw new UserActionException('只能编辑自己发送的消息');
}
if ((int)($row['recalled'] ?? 0) !== 0) {
throw new UserActionException('已撤回的消息请重新编辑发送');
}
$editedAt = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `text` = ?, `edited_at_ms` = ? where `message_uid` = ?'
);
$update->execute([$text, $editedAt, $messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$editedAt, (string)$row['group_id']]);
return [
'messageUid' => $messageUid,
'text' => $text,
'editedAt' => $editedAt,
];
}
function webGroupMemberRoleForAction(string $groupId, string $userId): string
{
$statement = imAppDb()->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) ? $role : '';
}
function recallWebGroupMessage(string $operatorId, string $messageUid): bool
{
$row = fetchWebGroupActionMessage($messageUid);
if (!$row) {
return false;
}
$isSender = (string)$row['sender_uid'] === $operatorId;
if (!$isSender) {
$role = webGroupMemberRoleForAction((string)$row['group_id'], $operatorId);
if (!in_array($role, ['owner', 'admin'], true)) {
throw new UserActionException('只有群主或管理员可以撤回他人消息');
}
}
assertRecallWithinLimit($messageUid);
$now = nowTimestampMs();
$update = imAppDb()->prepare(
'update `t_web_group_messages` set `recalled` = 1 where `message_uid` = ?'
);
$update->execute([$messageUid]);
$groupUpdate = imAppDb()->prepare('update `t_web_groups` set `updated_at_ms` = ? where `group_id` = ?');
$groupUpdate->execute([$now, (string)$row['group_id']]);
return true;
}
$payload = readJsonPayload();
$action = trim((string)($payload['action'] ?? ''));
$userId = trim((string)($payload['userId'] ?? ''));
$messageUidText = trim((string)($payload['messageUid'] ?? ''));
$messageUid = ctype_digit($messageUidText) ? (int)$messageUidText : 0;
if ($action === '' || $messageUid <= 0) {
respond(400, '缺少操作类型或消息ID');
}
try {
if ($action === 'recall') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$userId = resolveActionUserId($userId);
$row = fetchActionMessageRow($messageUidText);
if (!$row && recallWebGroupMessage($userId, $messageUidText)) {
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if (!$row) {
throw new UserActionException('消息不存在');
}
assertRecallWithinLimit($messageUidText);
requireImSuccess(imAdminPost('/admin/message/recall', [
'operator' => $userId,
'messageUid' => $messageUid,
'userRecall' => true,
'isUserRecall' => true,
]), '撤回失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
if ($action === 'edit') {
if ($userId === '') {
respond(400, '缺少操作者ID');
}
$text = trim((string)($payload['text'] ?? ''));
if ($text === '') {
respond(400, '编辑内容不能为空');
}
if (mb_strlen($text, 'UTF-8') > 5000) {
respond(400, '编辑内容过长');
}
$userId = resolveActionUserId($userId);
respond(0, 'success', [
'message' => editTextMessage($userId, $messageUidText, $text),
'action' => $action,
]);
}
if ($action === 'delete') {
requireImSuccess(imAdminPost('/admin/message/delete', [
'messageUid' => $messageUid,
]), '删除失败');
respond(0, 'success', [
'messageUid' => (string)$messageUid,
'action' => $action,
]);
}
respond(400, '不支持的消息操作');
} catch (Throwable $error) {
respond($error instanceof UserActionException ? 400 : 500, $error->getMessage() ?: '消息操作失败');
}
+387
View File
@@ -0,0 +1,387 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function detailPlaceholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function detailTimestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return 0;
}
function detailResolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function detailConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
return in_array($type, ['1', 'group', 'channel'], true) ? 'group' : 'single';
}
function detailDeliveryMap(PDO $db, array $userIds): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, max(`_dt`) as `_dt` from `t_delivery_report`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ') group by `_uid`'
);
$statement->execute($userIds);
$items = [];
foreach ($statement->fetchAll() as $row) {
$items[(string)$row['_uid']] = (int)$row['_dt'];
}
return $items;
}
function detailReadAt(PDO $db, string $readerId, array $targets): int
{
$targets = array_values(array_unique(array_filter(array_map('strval', $targets))));
if ($readerId === '' || !$targets) {
return 0;
}
$statement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($targets) . ')'
);
$statement->execute(array_merge([$readerId], $targets));
$value = $statement->fetchColumn();
return is_numeric($value) ? (int)$value : 0;
}
function detailSingleMessageVisible(PDO $db, string $userId, string $targetId, string $messageUid): bool
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$statement = $db->prepare(
'select 1 from `' . $table . '`'
. ' where `_uid` = ? and `_mid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . detailPlaceholders($conversationKeys) . ') limit 1'
);
$statement->execute(array_merge([$userId, $messageUid], $conversationKeys));
return (bool)$statement->fetchColumn();
}
function detailUserMap(PDO $imDb, PDO $appDb, array $userIds, string $ownerId, string $groupId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $imDb->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait` from `t_user`'
. ' where `_uid` in (' . detailPlaceholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$uid = (string)$row['_uid'];
$rawName = trim((string)($row['_display_name'] ?? ''))
?: trim((string)($row['_name'] ?? ''))
?: trim((string)($row['_mobile'] ?? ''))
?: $uid;
$users[$uid] = [
'uid' => $uid,
'rawName' => $rawName,
'mobile' => (string)($row['_mobile'] ?? ''),
'portrait' => (string)($row['_portrait'] ?? ''),
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $rawName,
];
}
if ($ownerId !== '') {
$aliasStatement = $imDb->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . detailPlaceholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$uid = (string)$row['_friend_uid'];
if (isset($users[$uid])) {
$users[$uid]['contactAlias'] = trim((string)($row['_alias'] ?? ''));
}
}
try {
$remarkMap = fetchWebUserRemarkMap($appDb, $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $uid => $alias) {
if (isset($users[$uid])) {
$users[$uid]['remarkAlias'] = trim((string)$alias);
}
}
}
if ($groupId !== '') {
$memberStatement = $appDb->prepare(
'select `user_id`, `alias`, `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($userIds) . ')'
);
$memberStatement->execute(array_merge([$groupId], $userIds));
foreach ($memberStatement->fetchAll() as $row) {
$uid = (string)$row['user_id'];
if (isset($users[$uid])) {
$users[$uid]['groupAlias'] = trim((string)($row['alias'] ?? ''));
$users[$uid]['role'] = (string)($row['role'] ?? '');
}
}
}
foreach ($users as $uid => $user) {
$alias = (string)$user['contactAlias'] !== ''
? (string)$user['contactAlias']
: ((string)$user['remarkAlias'] !== '' ? (string)$user['remarkAlias'] : (string)$user['groupAlias']);
$users[$uid]['displayName'] = $alias !== '' ? $alias : (string)$user['rawName'];
}
foreach ($userIds as $uid) {
if (!isset($users[$uid])) {
$users[$uid] = [
'uid' => $uid,
'rawName' => $uid,
'mobile' => '',
'portrait' => '',
'contactAlias' => '',
'remarkAlias' => '',
'groupAlias' => '',
'role' => '',
'displayName' => $uid,
];
}
}
return $users;
}
function detailMember(array $user, int $time = 0): array
{
return [
'uid' => (string)$user['uid'],
'name' => (string)$user['displayName'],
'rawName' => (string)$user['rawName'],
'mobile' => (string)($user['mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? ''),
'role' => (string)($user['role'] ?? ''),
'time' => $time,
];
}
function singleDetail(PDO $imDb, string $userId, string $targetId, string $messageUid): array
{
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$table = imMessageTable($messageUid);
$statement = $imDb->prepare(
'select `_mid`, `_from`, `_target`, `_dt`, `_content_type`, `_searchable_key`, `_to`'
. ' from `' . $table . '` where `_mid` = ? limit 1'
);
$statement->execute([$messageUid]);
$message = $statement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该消息');
}
$senderId = (string)$message['_from'];
if (!detailSingleMessageVisible($imDb, $userId, $targetId, $messageUid)) {
respond(403, '没有权限查看该消息');
}
$receiverId = $senderId === $userId ? $targetId : $userId;
if ($senderId !== $userId && $senderId !== $targetId) {
respond(403, '没有权限查看该消息');
}
$sentAt = detailTimestampMs((string)($message['_dt'] ?? ''), $messageUid);
$targets = [$senderId, $senderId . '|' . $receiverId, $receiverId . '|' . $senderId];
$deliveredRaw = detailDeliveryMap($imDb, [$receiverId])[$receiverId] ?? 0;
$readRaw = detailReadAt($imDb, $receiverId, $targets);
$users = detailUserMap($imDb, imAppDb(), [$senderId, $receiverId], $userId);
return [
'messageUid' => $messageUid,
'conversationType' => 'single',
'sentAt' => $sentAt,
'sender' => detailMember($users[$senderId]),
'receiver' => detailMember($users[$receiverId]),
'deliveredAt' => $deliveredRaw >= $sentAt ? $deliveredRaw : 0,
'readAt' => $readRaw >= $sentAt ? $readRaw : 0,
];
}
function groupDetail(PDO $imDb, PDO $appDb, string $userId, string $groupId, string $messageUid): array
{
ensureWebImAppTables();
if (!preg_match('/^\d+$/', $messageUid)) {
respond(400, '缺少可查询的消息ID');
}
$myMember = $appDb->prepare(
'select `role` from `t_web_group_members` where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$myMember->execute([$groupId, $userId]);
if (!$myMember->fetchColumn()) {
respond(403, '你不在该群组中');
}
$messageStatement = $appDb->prepare(
'select `message_uid`, `group_id`, `sender_uid`, `created_at_ms`, `edited_at_ms`, `recalled`'
. ' from `t_web_group_messages` where `group_id` = ? and `message_uid` = ? limit 1'
);
$messageStatement->execute([$groupId, $messageUid]);
$message = $messageStatement->fetch();
if (!is_array($message)) {
respond(404, '没有找到该群消息');
}
$sentAt = (int)$message['created_at_ms'];
$editedAt = (int)($message['edited_at_ms'] ?? 0);
$activityAt = max($sentAt, $editedAt);
$senderId = (string)$message['sender_uid'];
$memberStatement = $appDb->prepare(
'select `user_id`, `role`, `alias`, `joined_at_ms` from `t_web_group_members`'
. ' where `group_id` = ? and `status` = 0 and `joined_at_ms` <= ?'
. ' order by (`role` = "owner") desc, (`role` = "admin") desc, `joined_at_ms` asc'
);
$memberStatement->execute([$groupId, $sentAt]);
$members = array_values(array_filter($memberStatement->fetchAll(), static function (array $member) use ($senderId): bool {
return (string)$member['user_id'] !== $senderId;
}));
$memberIds = array_values(array_unique(array_map(static fn(array $member): string => (string)$member['user_id'], $members)));
$users = detailUserMap($imDb, $appDb, array_merge([$senderId], $memberIds), $userId, $groupId);
$deliveryMap = detailDeliveryMap($imDb, $memberIds);
$readMap = [];
if ($memberIds) {
$readStatement = $appDb->prepare(
'select `user_id`, `read_at_ms` from `t_web_group_reads`'
. ' where `group_id` = ? and `user_id` in (' . detailPlaceholders($memberIds) . ')'
);
$readStatement->execute(array_merge([$groupId], $memberIds));
foreach ($readStatement->fetchAll() as $row) {
$readMap[(string)$row['user_id']] = (int)$row['read_at_ms'];
}
}
$read = [];
$unread = [];
$undelivered = [];
foreach ($memberIds as $uid) {
$readAt = (int)($readMap[$uid] ?? 0);
$deliveredAt = (int)($deliveryMap[$uid] ?? 0);
$member = detailMember($users[$uid], $readAt >= $activityAt ? $readAt : ($deliveredAt >= $sentAt ? $deliveredAt : 0));
if ($readAt >= $activityAt) {
$read[] = $member;
} elseif ($deliveredAt >= $sentAt) {
$unread[] = $member;
} else {
$undelivered[] = $member;
}
}
return [
'messageUid' => $messageUid,
'conversationType' => 'group',
'groupId' => $groupId,
'sentAt' => $sentAt,
'editedAt' => $editedAt,
'recalled' => (bool)$message['recalled'],
'sender' => detailMember($users[$senderId] ?? ['uid' => $senderId, 'displayName' => $senderId, 'rawName' => $senderId]),
'group' => [
'read' => $read,
'unread' => $unread,
'undelivered' => $undelivered,
'counts' => [
'read' => count($read),
'unread' => count($unread),
'undelivered' => count($undelivered),
'total' => count($memberIds),
],
],
];
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$messageUid = trim((string)($payload['messageUid'] ?? $payload['messageId'] ?? ''));
$conversationType = detailConversationType($payload['conversationType'] ?? $payload['type'] ?? 'single');
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($target === '') {
respond(400, '缺少会话对象');
}
if ($messageUid === '') {
respond(400, '缺少消息ID');
}
try {
$imDb = imDb();
$appDb = imAppDb();
$userId = detailResolveUserId($imDb, $userId);
if ($conversationType === 'group') {
respond(0, 'success', ['detail' => groupDetail($imDb, $appDb, $userId, $target, $messageUid)]);
}
$targetId = detailResolveUserId($imDb, $target);
respond(0, 'success', ['detail' => singleDetail($imDb, $userId, $targetId, $messageUid)]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息详情加载失败');
}
+900
View File
@@ -0,0 +1,900 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
function clampLimit(mixed $value, int $default, int $max): int
{
$limit = (int)$value;
if ($limit <= 0) {
return $default;
}
return min($limit, $max);
}
function placeholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
function payloadBool(mixed $value): bool
{
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
function timestampMs(?string $datetime, string $messageId = '0'): int
{
$id = (int)$messageId;
if ($id > 0) {
return ($id >> 21) + 1514736000000;
}
if ($datetime !== null && $datetime !== '') {
$date = DateTimeImmutable::createFromFormat(
'Y-m-d H:i:s',
$datetime,
new DateTimeZone(IM_DB_TIMEZONE)
);
if ($date instanceof DateTimeImmutable) {
return $date->getTimestamp() * 1000;
}
}
return (int)floor(microtime(true) * 1000);
}
function nowMs(): int
{
return (int)floor(microtime(true) * 1000);
}
function markUserDelivered(PDO $db, string $userId, int $timestamp): void
{
if ($userId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'insert into `t_delivery_report` (`_uid`, `_dt`) values (?, ?)'
. ' on duplicate key update `_dt` = if(`_dt` < values(`_dt`), values(`_dt`), `_dt`)'
);
$statement->execute([$userId, $timestamp]);
}
function markConversationRead(PDO $db, string $userId, string $targetId, int $timestamp): void
{
if ($userId === '' || $targetId === '' || $timestamp <= 0) {
return;
}
$statement = $db->prepare(
'select `id`, `_dt` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0 and `_target` = ?'
. ' order by `_dt` desc limit 1'
);
$statement->execute([$userId, $targetId]);
$row = $statement->fetch();
if ($row) {
if ($timestamp > (int)$row['_dt']) {
$update = $db->prepare('update `t_read_report` set `_dt` = ? where `id` = ?');
$update->execute([$timestamp, (int)$row['id']]);
}
return;
}
$insert = $db->prepare(
'insert into `t_read_report` (`_uid`, `_type`, `_line`, `_target`, `_dt`) values (?, 0, 0, ?, ?)'
);
$insert->execute([$userId, $targetId, $timestamp]);
}
function resolveUserId(PDO $db, string $value): string
{
if ($value === '') {
return '';
}
$statement = $db->prepare(
'select `_uid` from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ? limit 1'
);
$statement->execute([$value, $value, $value]);
$uid = $statement->fetchColumn();
return is_string($uid) && $uid !== '' ? $uid : $value;
}
function peerFromConversationTarget(string $userId, string $target): string
{
$parts = array_values(array_filter(explode('|', $target), static fn($part) => $part !== ''));
if (count($parts) < 2) {
return $target !== $userId ? $target : '';
}
return $parts[0] === $userId ? $parts[1] : $parts[0];
}
function fetchMessageRows(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
$idsByTable = [];
foreach ($messageIds as $messageId) {
$idsByTable[imMessageTable($messageId)][] = $messageId;
}
$messages = [];
foreach ($idsByTable as $table => $ids) {
$sql = 'select `_mid`, `_from`, `_type`, `_target`, `_line`, `_searchable_key`, `_dt`, `_content_type`, `_to`'
. ' from `' . $table . '` where `_mid` in (' . placeholders($ids) . ')';
$statement = $db->prepare($sql);
$statement->execute($ids);
foreach ($statement->fetchAll() as $row) {
$messages[(string)$row['_mid']] = $row;
}
}
return $messages;
}
function fetchMessageEditMap(PDO $db, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
try {
$statement = $db->prepare(
'select `message_uid`, `edited_at_ms` from `t_web_message_edits`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
} catch (Throwable) {
return [];
}
$edits = [];
foreach ($statement->fetchAll() as $row) {
$edits[(string)$row['message_uid']] = [
'editedAt' => (int)$row['edited_at_ms'],
];
}
return $edits;
}
function normalizeAttachmentRow(array $row): array
{
return [
'type' => (string)$row['attachment_type'],
'url' => (string)$row['url'],
'name' => (string)($row['name'] ?? ''),
'mime' => (string)($row['mime'] ?? ''),
'size' => (int)($row['size_bytes'] ?? 0),
];
}
function fetchAttachmentMap(PDO $appDb, array $messageIds): array
{
$messageIds = array_values(array_unique(array_filter(array_map('strval', $messageIds))));
if (!$messageIds) {
return [];
}
ensureWebImAppTables();
$statement = $appDb->prepare(
'select `message_uid`, `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments`'
. ' where `message_uid` in (' . placeholders($messageIds) . ')'
);
$statement->execute($messageIds);
$attachments = [];
foreach ($statement->fetchAll() as $row) {
$attachments[(string)$row['message_uid']] = normalizeAttachmentRow($row);
}
return $attachments;
}
function attachmentFallbackText(?array $attachment): string
{
if (!$attachment) {
return '';
}
if ((string)$attachment['type'] === 'image') {
return '[图片]';
}
return '[文件] ' . ((string)($attachment['name'] ?? '') ?: '文件');
}
function attachmentDisplayText(string $text, ?array $attachment): string
{
if (!$attachment) {
return $text;
}
return $text === attachmentFallbackText($attachment) ? '' : $text;
}
function messagePreviewText(?array $message): string
{
if (!$message) {
return '';
}
$attachment = is_array($message['attachment'] ?? null) ? $message['attachment'] : null;
if ($attachment) {
$text = trim((string)($message['text'] ?? ''));
return $text !== '' ? $text : attachmentFallbackText($attachment);
}
return (string)($message['text'] ?? '');
}
function fetchUserInfoMap(PDO $db, array $userIds, string $ownerId = ''): array
{
$userIds = array_values(array_unique(array_filter(array_map('strval', $userIds))));
if (!$userIds) {
return [];
}
$statement = $db->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address` from `t_user`'
. ' where `_uid` in (' . placeholders($userIds) . ')'
);
$statement->execute($userIds);
$users = [];
foreach ($statement->fetchAll() as $row) {
$row['_is_friend'] = false;
$row['_alias'] = '';
$row['_friend_alias'] = '';
$row['_remark_alias'] = '';
$users[(string)$row['_uid']] = $row;
}
if ($ownerId !== '') {
$aliasStatement = $db->prepare(
'select `_friend_uid`, `_alias` from `t_friend` where `_uid` = ?'
. ' and `_friend_uid` in (' . placeholders($userIds) . ')'
);
$aliasStatement->execute(array_merge([$ownerId], $userIds));
foreach ($aliasStatement->fetchAll() as $row) {
$friendUid = (string)$row['_friend_uid'];
if (!isset($users[$friendUid])) {
continue;
}
$users[$friendUid]['_is_friend'] = true;
$users[$friendUid]['_friend_alias'] = trim((string)($row['_alias'] ?? ''));
}
try {
ensureWebImAppTables();
$remarkMap = fetchWebUserRemarkMap(imAppDb(), $ownerId, $userIds);
} catch (Throwable) {
$remarkMap = [];
}
foreach ($remarkMap as $targetUid => $alias) {
if (!isset($users[$targetUid])) {
continue;
}
$users[$targetUid]['_remark_alias'] = $alias;
}
foreach ($users as $uid => $user) {
$friendAlias = trim((string)($user['_friend_alias'] ?? ''));
$remarkAlias = trim((string)($user['_remark_alias'] ?? ''));
$users[$uid]['_alias'] = $friendAlias !== '' ? $friendAlias : $remarkAlias;
}
}
return $users;
}
function userTitle(array $users, string $userId): string
{
$user = $users[$userId] ?? null;
if (!$user) {
return $userId;
}
return (string)($user['_alias'] ?? '')
?: (string)($user['_display_name'] ?? '')
?: (string)($user['_name'] ?? '')
?: (string)($user['_mobile'] ?? '')
?: $userId;
}
function loadPeerReceiptState(PDO $db, string $currentUserId, string $peerId): array
{
$deliveryStatement = $db->prepare('select `_dt` from `t_delivery_report` where `_uid` = ? limit 1');
$deliveryStatement->execute([$peerId]);
$deliveredAt = $deliveryStatement->fetchColumn();
$readTargets = array_values(array_unique([
$currentUserId,
$currentUserId . '|' . $peerId,
$peerId . '|' . $currentUserId,
]));
$readStatement = $db->prepare(
'select max(`_dt`) from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($readTargets) . ')'
);
$readStatement->execute(array_merge([$peerId], $readTargets));
$readAt = $readStatement->fetchColumn();
return [
'deliveredAt' => is_numeric($deliveredAt) ? (int)$deliveredAt : 0,
'readAt' => is_numeric($readAt) ? (int)$readAt : 0,
];
}
function loadOwnReadStateMap(PDO $db, string $userId, array $peerIds): array
{
$peerIds = array_values(array_unique(array_filter(array_map('strval', $peerIds))));
if (!$peerIds) {
return [];
}
$statement = $db->prepare(
'select `_target`, max(`_dt`) as `_read_at` from `t_read_report`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (' . placeholders($peerIds) . ')'
. ' group by `_target`'
);
$statement->execute(array_merge([$userId], $peerIds));
$readAtByPeer = [];
foreach ($statement->fetchAll() as $row) {
$readAtByPeer[(string)$row['_target']] = (int)$row['_read_at'];
}
return $readAtByPeer;
}
function outgoingDeliveryStatus(int $timestamp, array $receiptState): string
{
$readAt = (int)($receiptState['readAt'] ?? 0);
if ($readAt > 0 && $timestamp <= $readAt) {
return 'read';
}
$deliveredAt = (int)($receiptState['deliveredAt'] ?? 0);
if ($deliveredAt > 0 && $timestamp <= $deliveredAt) {
return 'delivered';
}
return 'sent';
}
function messageActivityTimestamp(int $timestamp, array $editState = []): int
{
return max($timestamp, (int)($editState['editedAt'] ?? 0));
}
function normalizeConversationType(mixed $value): string
{
$type = strtolower(trim((string)$value));
if (in_array($type, ['1', 'group', 'channel'], true)) {
return 'group';
}
return 'single';
}
function isWebGroupTarget(PDO $appDb, string $targetId): bool
{
if ($targetId === '') {
return false;
}
ensureWebImAppTables();
$statement = $appDb->prepare('select 1 from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$statement->execute([$targetId]);
return (bool)$statement->fetchColumn();
}
function markWebGroupRead(PDO $appDb, string $userId, string $groupId, int $timestamp): void
{
if ($userId === '' || $groupId === '' || $timestamp <= 0) {
return;
}
$now = nowMs();
$statement = $appDb->prepare(
'insert into `t_web_group_reads` (`group_id`, `user_id`, `read_at_ms`, `updated_at_ms`)'
. ' values (?, ?, ?, ?)'
. ' on duplicate key update'
. ' `read_at_ms` = if(`read_at_ms` < values(`read_at_ms`), values(`read_at_ms`), `read_at_ms`),'
. ' `updated_at_ms` = values(`updated_at_ms`)'
);
$statement->execute([$groupId, $userId, $timestamp, $now]);
}
function webGroupRole(PDO $appDb, string $userId, string $groupId): string
{
$statement = $appDb->prepare(
'select `role` from `t_web_group_members`'
. ' where `group_id` = ? and `user_id` = ? and `status` = 0 limit 1'
);
$statement->execute([$groupId, $userId]);
$role = $statement->fetchColumn();
return is_string($role) && $role !== '' ? $role : '';
}
function formatWebGroupMessage(
array $row,
string $currentUserId,
array $group,
?array $attachment = null,
array $users = []
): array
{
$groupId = (string)$row['group_id'];
$from = (string)$row['sender_uid'];
$senderUser = $users[$from] ?? [];
$senderAlias = trim((string)($row['sender_alias'] ?? ''));
$senderContactAlias = trim((string)($senderUser['_alias'] ?? ''));
$senderFriendAlias = trim((string)($senderUser['_friend_alias'] ?? ''));
$senderRemarkAlias = trim((string)($senderUser['_remark_alias'] ?? ''));
$senderRawName = trim((string)($senderUser['_display_name'] ?? ''))
?: trim((string)($senderUser['_name'] ?? ''))
?: trim((string)($senderUser['_mobile'] ?? ''))
?: $from;
$senderName = $senderContactAlias
?: $senderAlias
?: $senderRawName;
$senderRole = (string)($row['sender_role'] ?? 'member');
if (!in_array($senderRole, ['owner', 'admin'], true)) {
$senderRole = 'member';
}
$timestamp = (int)$row['created_at_ms'];
$editedAt = (int)($row['edited_at_ms'] ?? 0);
$activityTimestamp = max($timestamp, $editedAt);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : 'text';
$contentType = $messageType === 'image' ? 3 : ($messageType === 'file' ? 5 : 1);
return [
'id' => (string)$row['message_uid'],
'messageUid' => (string)$row['message_uid'],
'conversationId' => 'group-' . $groupId,
'from' => $from,
'senderRole' => $senderRole,
'senderName' => $senderName,
'senderPortrait' => (string)($senderUser['_portrait'] ?? ''),
'senderProfile' => [
'uid' => $from,
'displayName' => $senderName,
'rawName' => $senderRawName,
'contactAlias' => $senderContactAlias,
'friendAlias' => $senderFriendAlias,
'remarkAlias' => $senderRemarkAlias,
'groupAlias' => $senderAlias,
'isContact' => (bool)($senderUser['_is_friend'] ?? false),
'mobile' => (string)($senderUser['_mobile'] ?? ''),
'address' => (string)($senderUser['_address'] ?? ''),
'portrait' => (string)($senderUser['_portrait'] ?? ''),
'role' => $senderRole,
],
'to' => $groupId,
'target' => $groupId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $contentType,
'text' => attachmentDisplayText((string)$row['text'], $attachment),
'attachment' => $attachment,
'status' => $direction === 'out' ? 'sent' : 'sent',
'deliveryStatus' => $direction === 'out' ? 'sent' : '',
'recalled' => false,
'recallStatus' => '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
'conversationType' => (string)($group['category'] ?? 'group'),
];
}
function loadWebGroupMessages(PDO $appDb, string $userId, string $groupId, int $limit, bool $markRead): array
{
ensureWebImAppTables();
$role = webGroupRole($appDb, $userId, $groupId);
if ($role === '') {
return [];
}
$groupStatement = $appDb->prepare('select * from `t_web_groups` where `group_id` = ? and `deleted` = 0 limit 1');
$groupStatement->execute([$groupId]);
$group = $groupStatement->fetch();
if (!$group) {
return [];
}
$statement = $appDb->prepare(
'select msg.*,'
. ' coalesce(sender_member.`role`, if(g.`owner_uid` = msg.`sender_uid`, "owner", "member")) as `sender_role`,'
. ' coalesce(sender_member.`alias`, "") as `sender_alias`'
. ' from `t_web_group_messages` msg'
. ' join `t_web_groups` g on g.`group_id` = msg.`group_id` and g.`deleted` = 0'
. ' left join `t_web_group_members` sender_member'
. ' on sender_member.`group_id` = msg.`group_id`'
. ' and sender_member.`user_id` = msg.`sender_uid`'
. ' and sender_member.`status` = 0'
. ' where msg.`group_id` = ? and msg.`recalled` = 0'
. ' order by msg.`created_at_ms` desc, msg.`id` desc limit ' . $limit
);
$statement->execute([$groupId]);
$rows = array_reverse($statement->fetchAll());
$attachmentMap = fetchAttachmentMap($appDb, array_column($rows, 'message_uid'));
$users = fetchUserInfoMap(imDb(), array_column($rows, 'sender_uid'), $userId);
$readTimestamp = 0;
$messages = [];
foreach ($rows as $row) {
$messageId = (string)$row['message_uid'];
$message = formatWebGroupMessage($row, $userId, $group, $attachmentMap[$messageId] ?? null, $users);
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
$messages[] = $message;
}
if ($markRead) {
markWebGroupRead($appDb, $userId, $groupId, $readTimestamp);
}
return $messages;
}
function loadWebGroupConversations(PDO $appDb, string $userId, int $limit): array
{
ensureWebImAppTables();
$statement = $appDb->prepare(
'select g.*, 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 m.`user_id` = ?'
. ' order by g.`updated_at_ms` desc limit ' . min(max($limit, 50), 100)
);
$statement->execute([$userId]);
$groups = $statement->fetchAll();
if (!$groups) {
return [];
}
$lastMessageStatement = $appDb->prepare(
'select * from `t_web_group_messages`'
. ' where `group_id` = ? and `recalled` = 0'
. ' order by greatest(`created_at_ms`, `edited_at_ms`) desc, `id` desc limit 1'
);
$readStatement = $appDb->prepare(
'select `read_at_ms` from `t_web_group_reads` where `group_id` = ? and `user_id` = ? limit 1'
);
$lastAttachmentStatement = $appDb->prepare(
'select `attachment_type`, `url`, `name`, `mime`, `size_bytes`'
. ' from `t_web_message_attachments` where `message_uid` = ? limit 1'
);
$unreadStatement = $appDb->prepare(
'select count(*) from `t_web_group_messages`'
. ' where `group_id` = ? and `sender_uid` <> ? and `recalled` = 0'
. ' and greatest(`created_at_ms`, `edited_at_ms`) > ?'
);
$memberCountStatement = $appDb->prepare(
'select count(*) from `t_web_group_members` where `group_id` = ? and `status` = 0'
);
$conversations = [];
foreach ($groups as $group) {
$groupId = (string)$group['group_id'];
$lastMessageStatement->execute([$groupId]);
$lastMessage = $lastMessageStatement->fetch();
$readStatement->execute([$groupId, $userId]);
$readAt = (int)($readStatement->fetchColumn() ?: 0);
$unreadStatement->execute([$groupId, $userId, $readAt]);
$unread = (int)$unreadStatement->fetchColumn();
$memberCountStatement->execute([$groupId]);
$memberCount = (int)$memberCountStatement->fetchColumn();
$title = (string)$group['name'];
$category = (string)$group['category'];
$isChannel = $category === 'channel';
$lastAttachment = null;
if ($lastMessage) {
$lastAttachmentStatement->execute([(string)$lastMessage['message_uid']]);
$lastAttachmentRow = $lastAttachmentStatement->fetch();
$lastAttachment = $lastAttachmentRow ? normalizeAttachmentRow($lastAttachmentRow) : null;
}
$subtitle = $lastMessage
? (messagePreviewText([
'text' => attachmentDisplayText((string)$lastMessage['text'], $lastAttachment),
'attachment' => $lastAttachment,
]) ?: (string)$lastMessage['text'])
: ($isChannel ? '公告群' : ($memberCount > 0 ? $memberCount . ' 位成员' : '群组'));
$updatedAt = $lastMessage
? max((int)$lastMessage['created_at_ms'], (int)($lastMessage['edited_at_ms'] ?? 0))
: (int)$group['updated_at_ms'];
$conversations[] = [
'id' => 'group-' . $groupId,
'type' => $isChannel ? 'channel' : 'group',
'target' => $groupId,
'title' => $title,
'subtitle' => $subtitle,
'unread' => $unread,
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $updatedAt,
'role' => (string)$group['role'],
'memberCount' => $memberCount,
'raw' => [
'type' => 'group',
'target' => $groupId,
'line' => 0,
'category' => $category,
'role' => (string)$group['role'],
],
];
}
return $conversations;
}
function isRecallMessage(array $row): bool
{
return (int)($row['_content_type'] ?? 0) === 80;
}
function shouldDisplayMessageForUser(array $row, string $currentUserId): bool
{
if (!isRecallMessage($row)) {
return true;
}
return (string)$row['_from'] === $currentUserId;
}
function formatMessage(
array $row,
string $currentUserId,
string $peerId,
array $receiptState = [],
array $editState = [],
?array $attachment = null
): array
{
$messageId = (string)$row['_mid'];
$from = (string)$row['_from'];
$contentType = (int)($row['_content_type'] ?? 0);
$isRecalled = $contentType === 80;
$text = (string)($row['_searchable_key'] ?? '');
$timestamp = timestampMs((string)($row['_dt'] ?? ''), $messageId);
$direction = $from === $currentUserId ? 'out' : 'in';
$messageType = $attachment ? (string)$attachment['type'] : (($contentType === 1 || $isRecalled) ? 'text' : 'unknown');
$messageText = $text !== '' ? $text : ($isRecalled || $attachment ? '' : '[暂不支持的消息类型]');
$messageText = attachmentDisplayText($messageText, $attachment);
$editedAt = (int)($editState['editedAt'] ?? 0);
$activityTimestamp = messageActivityTimestamp($timestamp, $editState);
$deliveryStatus = $direction === 'out' && !$isRecalled
? outgoingDeliveryStatus($activityTimestamp, $receiptState)
: '';
return [
'id' => $messageId,
'messageUid' => $messageId,
'conversationId' => 'single-' . $peerId,
'from' => $from,
'to' => $from === $currentUserId ? $peerId : $currentUserId,
'target' => $peerId,
'direction' => $direction,
'type' => $messageType,
'contentType' => $attachment ? ((string)$attachment['type'] === 'image' ? 3 : 5) : $contentType,
'text' => $messageText,
'attachment' => $attachment,
'status' => $isRecalled && $direction === 'out' ? 'recalled' : ($direction === 'out' ? $deliveryStatus : 'sent'),
'deliveryStatus' => $deliveryStatus,
'recalled' => $isRecalled,
'recallStatus' => $isRecalled ? '已撤回' : '',
'editedAt' => $editedAt,
'timestamp' => $timestamp,
'activityTimestamp' => $activityTimestamp,
'displayTimestamp' => $timestamp,
];
}
function loadConversationMessages(PDO $db, string $userId, string $targetId, int $limit, bool $markRead): array
{
$table = imUserMessagesTable($userId);
$conversationKeys = [$userId . '|' . $targetId, $targetId . '|' . $userId];
$scanLimit = min(max($limit * 4, $limit), 400);
$statement = $db->prepare(
'select `_mid`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' and `_target` in (?, ?)'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId, $conversationKeys[0], $conversationKeys[1]]);
$userRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($userRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($userRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($userRows, '_mid'));
$receiptState = loadPeerReceiptState($db, $userId, $targetId);
$messages = [];
$seenMessageIds = [];
$readTimestamp = 0;
foreach ($userRows as $userRow) {
$messageId = (string)$userRow['_mid'];
if (isset($seenMessageIds[$messageId]) || !isset($messageRows[$messageId])) {
continue;
}
$seenMessageIds[$messageId] = true;
if (!shouldDisplayMessageForUser($messageRows[$messageId], $userId)) {
continue;
}
$message = formatMessage(
$messageRows[$messageId],
$userId,
$targetId,
$receiptState,
$editMap[$messageId] ?? [],
$attachmentMap[$messageId] ?? null
);
$messages[] = $message;
$readTimestamp = max($readTimestamp, (int)$message['activityTimestamp']);
if (count($messages) >= $limit) {
break;
}
}
$messages = array_reverse($messages);
if ($markRead) {
markConversationRead($db, $userId, $targetId, $readTimestamp);
}
return $messages;
}
function loadRecentConversations(PDO $db, string $userId, int $limit): array
{
$table = imUserMessagesTable($userId);
$scanLimit = min(max($limit * 20, 200), 1000);
$statement = $db->prepare(
'select `_mid`, `_target`, `_dt` from `' . $table . '`'
. ' where `_uid` = ? and `_type` = 0 and `_line` = 0'
. ' order by `_mid` desc limit ' . $scanLimit
);
$statement->execute([$userId]);
$scanRows = $statement->fetchAll();
$messageRows = fetchMessageRows($db, array_column($scanRows, '_mid'));
$editMap = fetchMessageEditMap($db, array_column($scanRows, '_mid'));
$attachmentMap = fetchAttachmentMap(imAppDb(), array_column($scanRows, '_mid'));
$latestByPeer = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
$message = $messageRows[$messageId] ?? null;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
if ($peerId === '' || !$message) {
continue;
}
if (!shouldDisplayMessageForUser($message, $userId)) {
continue;
}
$activityTimestamp = messageActivityTimestamp(
timestampMs((string)($message['_dt'] ?? ''), $messageId),
$editMap[$messageId] ?? []
);
if (!isset($latestByPeer[$peerId]) || $activityTimestamp > (int)($latestByPeer[$peerId]['_activity_at'] ?? 0)) {
$row['_activity_at'] = $activityTimestamp;
$latestByPeer[$peerId] = $row;
}
}
uasort($latestByPeer, static fn($a, $b) => (int)($b['_activity_at'] ?? 0) <=> (int)($a['_activity_at'] ?? 0));
$latestByPeer = array_slice($latestByPeer, 0, $limit, true);
$users = fetchUserInfoMap($db, array_keys($latestByPeer), $userId);
$readAtByPeer = loadOwnReadStateMap($db, $userId, array_keys($latestByPeer));
$unreadByPeer = [];
$seenUnreadMessageIds = [];
foreach ($scanRows as $row) {
$messageId = (string)$row['_mid'];
if (isset($seenUnreadMessageIds[$messageId])) {
continue;
}
$seenUnreadMessageIds[$messageId] = true;
$peerId = peerFromConversationTarget($userId, (string)$row['_target']);
$message = $messageRows[$messageId] ?? null;
if ($peerId === '' || !$message || !isset($latestByPeer[$peerId])) {
continue;
}
if ((string)$message['_from'] === $userId) {
continue;
}
if (isRecallMessage($message)) {
continue;
}
$timestamp = timestampMs((string)($message['_dt'] ?? ''), $messageId);
$activityTimestamp = messageActivityTimestamp($timestamp, $editMap[$messageId] ?? []);
if ($activityTimestamp > (int)($readAtByPeer[$peerId] ?? 0)) {
$unreadByPeer[$peerId] = (int)($unreadByPeer[$peerId] ?? 0) + 1;
}
}
$conversations = [];
foreach ($latestByPeer as $peerId => $userRow) {
$messageId = (string)$userRow['_mid'];
$message = isset($messageRows[$messageId])
? formatMessage($messageRows[$messageId], $userId, $peerId, [], $editMap[$messageId] ?? [], $attachmentMap[$messageId] ?? null)
: null;
$title = userTitle($users, $peerId);
$conversations[] = [
'id' => 'single-' . $peerId,
'type' => 'single',
'target' => $peerId,
'title' => $title,
'subtitle' => $message ? (messagePreviewText($message) !== '' ? messagePreviewText($message) : (empty($message['recalled']) ? '' : '已撤回')) : '',
'unread' => (int)($unreadByPeer[$peerId] ?? 0),
'portrait' => (string)($users[$peerId]['_portrait'] ?? ''),
'avatarText' => mb_strtoupper(mb_substr($title, 0, 1, 'UTF-8'), 'UTF-8'),
'updatedAt' => $message ? $message['activityTimestamp'] : timestampMs((string)($userRow['_dt'] ?? ''), $messageId),
'raw' => ['type' => 'single', 'target' => $peerId, 'line' => 0],
];
}
try {
$conversations = array_merge($conversations, loadWebGroupConversations(imAppDb(), $userId, $limit));
} catch (Throwable) {
// Keep single-chat sync available if appdata group metadata is temporarily unavailable.
}
usort($conversations, static fn($a, $b) => $b['updatedAt'] <=> $a['updatedAt']);
return array_slice($conversations, 0, $limit);
}
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$target = trim((string)($payload['target'] ?? ''));
$limit = clampLimit($payload['limit'] ?? 50, 50, 100);
$markRead = payloadBool($payload['markRead'] ?? false);
$conversationType = normalizeConversationType($payload['conversationType'] ?? $payload['type'] ?? 0);
if ($userId === '') {
respond(400, '缺少用户ID');
}
try {
$db = imDb();
$userId = resolveUserId($db, $userId);
markUserDelivered($db, $userId, nowMs());
if ($target !== '') {
$targetId = resolveUserId($db, $target);
if ($conversationType === 'group' || isWebGroupTarget(imAppDb(), $target)) {
respond(0, 'success', [
'messages' => loadWebGroupMessages(imAppDb(), $userId, $target, $limit, $markRead),
]);
}
respond(0, 'success', [
'messages' => loadConversationMessages($db, $userId, $targetId, $limit, $markRead),
]);
}
respond(0, 'success', [
'conversations' => loadRecentConversations($db, $userId, $limit),
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '消息同步失败');
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$defaultName = 'Web IM';
$defaultLogo = '/img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico';
try {
$statement = imAppDb()->prepare(
'select `conf_name`, `conf_value`, `updated_at` from `system_conf` where `conf_name` in (?, ?)'
);
$statement->execute(['PlatformName', 'sys_logo']);
$config = [];
$updatedAtValues = [];
foreach ($statement->fetchAll() as $row) {
$config[(string)$row['conf_name']] = trim((string)($row['conf_value'] ?? ''));
$updatedAtValues[] = (string)($row['updated_at'] ?? '');
}
$platformName = $config['PlatformName'] ?? '';
$logo = $config['sys_logo'] ?? '';
$version = implode('|', array_values(array_filter($updatedAtValues ?? [])));
respond(0, 'success', [
'platformName' => $platformName !== '' ? $platformName : $defaultName,
'logo' => $logo !== '' ? $logo : $defaultLogo,
'version' => $version,
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '平台配置加载失败', [
'platformName' => $defaultName,
'logo' => $defaultLogo,
]);
}
+321
View File
@@ -0,0 +1,321 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
const PROFILE_DISPLAY_NAME_FLAG = 0x01;
const PROFILE_PORTRAIT_FLAG = 0x02;
const PROFILE_PORTRAIT_MEDIA_TYPE = 5;
const IM_LOCAL_MEDIA_ROOT = '/opt/im-server/media';
const IM_LOCAL_MEDIA_PUBLIC_PORT = 18000;
function profileRequestValue(string $key, string $default = ''): string
{
return trim((string)($_POST[$key] ?? $_GET[$key] ?? $default));
}
function normalizeProfileUser(array $user): array
{
$uid = (string)($user['userId'] ?? $user['uid'] ?? $user['_uid'] ?? '');
return [
'uid' => $uid,
'userId' => $uid,
'name' => (string)($user['name'] ?? $user['_name'] ?? ''),
'displayName' => (string)($user['displayName'] ?? $user['_display_name'] ?? $user['name'] ?? $user['_name'] ?? $uid),
'mobile' => (string)($user['mobile'] ?? $user['_mobile'] ?? ''),
'portrait' => (string)($user['portrait'] ?? $user['_portrait'] ?? ''),
'address' => (string)($user['address'] ?? $user['_address'] ?? ''),
];
}
function tryProfileUserInfo(array $payload): ?array
{
try {
$result = requireImSuccess(imAdminPost('/admin/user/get_info', $payload), '用户资料加载失败');
return $result ?: null;
} catch (Throwable) {
return null;
}
}
function loadProfileUserFromDb(string $identity): ?array
{
$statement = imDb()->prepare(
'select `_uid`, `_name`, `_display_name`, `_mobile`, `_portrait`, `_address`'
. ' from `t_user` where `_uid` = ? or `_mobile` = ? or `_name` = ?'
. ' order by case when `_uid` = ? then 0 when `_mobile` = ? then 1 when `_name` = ? then 2 else 3 end'
. ' limit 1'
);
$statement->execute([$identity, $identity, $identity, $identity, $identity, $identity]);
$row = $statement->fetch();
return $row ? normalizeProfileUser($row) : null;
}
function mergeProfileUser(array $primary, ?array $fallback): array
{
if (!$fallback) {
return $primary;
}
$fallback = normalizeProfileUser($fallback);
foreach (['name', 'displayName', 'mobile', 'portrait', 'address'] as $key) {
if ((string)($primary[$key] ?? '') === '' && (string)($fallback[$key] ?? '') !== '') {
$primary[$key] = (string)$fallback[$key];
}
}
return $primary;
}
function persistProfileUserToDb(string $userId, string $displayName, string $portrait): void
{
if ($portrait !== '') {
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_portrait` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, $portrait, webImNowMs(), $userId]);
return;
}
$statement = imDb()->prepare(
'update `t_user` set `_display_name` = ?, `_dt` = ? where `_uid` = ? limit 1'
);
$statement->execute([$displayName, webImNowMs(), $userId]);
}
function loadProfileUser(string $identity, bool $preferMobile = true): array
{
$identity = trim($identity);
if ($identity === '') {
respond(400, '缺少用户ID');
}
$dbUser = loadProfileUserFromDb($identity);
if ($dbUser) {
$fallback = null;
if ((string)($dbUser['portrait'] ?? '') === '') {
$fallback = tryProfileUserInfo(['userId' => $dbUser['userId']]);
if (!$fallback && (string)($dbUser['mobile'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['mobile' => $dbUser['mobile']]);
}
if (!$fallback && (string)($dbUser['name'] ?? '') !== '') {
$fallback = tryProfileUserInfo(['name' => $dbUser['name']]);
}
}
return mergeProfileUser($dbUser, $fallback);
}
$user = null;
if ($preferMobile && preg_match('/^\+?\d{5,32}$/', $identity)) {
$user = tryProfileUserInfo(['mobile' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['userId' => $identity]);
}
if (!$user) {
$user = tryProfileUserInfo(['name' => $identity]);
}
if (!$user) {
respond(404, '用户资料不存在');
}
return normalizeProfileUser($user);
}
function readProfileJavaSerializedString(string $data, int $offset): ?array
{
if ($offset + 3 > strlen($data) || ord($data[$offset]) !== 0x74) {
return null;
}
$length = (ord($data[$offset + 1]) << 8) | ord($data[$offset + 2]);
$start = $offset + 3;
if ($start + $length > strlen($data)) {
return null;
}
return [substr($data, $start, $length), $start + $length];
}
function profileSessionUserId(string $sessionData): string
{
$marker = "\x74\x00\x06userId";
$offset = strpos($sessionData, $marker);
if ($offset === false) {
return '';
}
$decoded = readProfileJavaSerializedString($sessionData, $offset + strlen($marker));
$userId = is_array($decoded) ? (string)$decoded[0] : '';
if (!preg_match('/^[A-Za-z0-9_:@.+-]{1,128}$/', $userId)) {
return '';
}
return $userId;
}
function requireProfileUpdateAuth(string $targetUserId): void
{
$authToken = trim((string)($_SERVER['HTTP_AUTHTOKEN'] ?? $_SERVER['HTTP_AUTH_TOKEN'] ?? ''));
if ($authToken === '') {
respond(401, '请重新登录后再修改资料');
}
$statement = imAppDb()->prepare('select `session_data` from `shiro_session` where `session_id` = ? limit 1');
$statement->execute([$authToken]);
$row = $statement->fetch();
if (!$row) {
respond(401, '登录状态已失效,请重新登录');
}
$sessionUserId = profileSessionUserId((string)$row['session_data']);
if ($sessionUserId === '' || $sessionUserId !== $targetUserId) {
respond(403, '只能修改自己的资料');
}
}
function profileUploadedFileExtension(string $fileName, string $mime): string
{
$extension = strtolower((string)pathinfo($fileName, PATHINFO_EXTENSION));
$extension = preg_replace('/[^a-z0-9]/', '', $extension) ?: '';
if ($extension === 'jpeg') {
$extension = 'jpg';
}
$allowedExtensions = ['jpg', 'png', 'gif', 'webp', 'bmp', 'ico'];
if (in_array($extension, $allowedExtensions, 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/x-icon', 'image/vnd.microsoft.icon' => 'ico',
default => 'jpg',
};
}
function profilePublicMediaBaseUrl(): string
{
$isHttps = (string)($_SERVER['HTTPS'] ?? '') !== '' && strtolower((string)$_SERVER['HTTPS']) !== 'off';
$scheme = $isHttps ? 'https' : 'http';
$host = (string)($_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? '127.0.0.1');
$host = trim(preg_replace('/:\d+$/', '', $host) ?: '127.0.0.1');
return $scheme . '://' . $host . ':' . IM_LOCAL_MEDIA_PUBLIC_PORT;
}
function uploadProfilePortrait(array $file): string
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new RuntimeException('头像上传失败');
}
if (($file['size'] ?? 0) <= 0 || ($file['size'] ?? 0) > 5 * 1024 * 1024) {
throw new RuntimeException('头像不能超过5MB');
}
$tmpName = (string)($file['tmp_name'] ?? '');
$mime = mime_content_type($tmpName) ?: '';
if (!str_starts_with($mime, 'image/')) {
throw new RuntimeException('头像必须是图片文件');
}
if (!is_dir(IM_LOCAL_MEDIA_ROOT) || !is_writable(IM_LOCAL_MEDIA_ROOT)) {
throw new RuntimeException('头像存储目录不可写');
}
$datePath = 'fs/' . PROFILE_PORTRAIT_MEDIA_TYPE . '/' . date('Y/m/d/H/i/s');
$targetDir = rtrim(IM_LOCAL_MEDIA_ROOT, '/') . '/' . $datePath;
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true)) {
throw new RuntimeException('头像存储目录创建失败');
}
$extension = profileUploadedFileExtension((string)($file['name'] ?? 'avatar.jpg'), $mime);
$targetName = bin2hex(random_bytes(12)) . '.' . $extension;
$targetPath = $targetDir . '/' . $targetName;
if (!move_uploaded_file($tmpName, $targetPath)) {
throw new RuntimeException('头像文件保存失败');
}
@chmod($targetPath, 0644);
return profilePublicMediaBaseUrl() . '/' . $datePath . '/' . $targetName;
}
try {
$contentType = (string)($_SERVER['CONTENT_TYPE'] ?? '');
$isMultipart = stripos($contentType, 'multipart/form-data') !== false;
$payload = $isMultipart ? [] : readJsonPayload();
$action = $isMultipart
? profileRequestValue('action', 'update')
: trim((string)($payload['action'] ?? 'get'));
$userId = $isMultipart
? profileRequestValue('userId')
: trim((string)($payload['userId'] ?? $_GET['userId'] ?? ''));
$currentProfile = loadProfileUser($userId);
if ($action === 'get') {
respond(0, 'success', ['profile' => $currentProfile]);
}
if ($action !== 'update') {
respond(400, '不支持的资料操作');
}
requireProfileUpdateAuth((string)$currentProfile['userId']);
$displayName = $isMultipart
? profileRequestValue('displayName')
: trim((string)($payload['displayName'] ?? ''));
if ($displayName === '') {
respond(400, '昵称不能为空');
}
if (mb_strlen($displayName, 'UTF-8') > 64) {
respond(400, '昵称不能超过64个字');
}
$portrait = '';
if (!empty($_FILES['portraitFile']) && ($_FILES['portraitFile']['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) {
$portrait = uploadProfilePortrait($_FILES['portraitFile']);
}
$flag = PROFILE_DISPLAY_NAME_FLAG;
$userInfo = [
'userId' => $currentProfile['userId'],
'displayName' => $displayName,
];
if ($portrait !== '') {
$flag |= PROFILE_PORTRAIT_FLAG;
$userInfo['portrait'] = $portrait;
}
requireImSuccess(
imAdminPost('/admin/user/update', [
'flag' => $flag,
'userInfo' => $userInfo,
]),
'用户资料保存失败'
);
persistProfileUserToDb($currentProfile['userId'], $displayName, $portrait);
$updatedProfile = loadProfileUser($currentProfile['userId'], false);
if ($portrait === '' && (string)$updatedProfile['portrait'] === '' && (string)($currentProfile['portrait'] ?? '') !== '') {
$updatedProfile['portrait'] = (string)$currentProfile['portrait'];
}
if ($portrait !== '' && (string)$updatedProfile['portrait'] === '') {
$updatedProfile['portrait'] = $portrait;
}
if ((string)$updatedProfile['displayName'] === '') {
$updatedProfile['displayName'] = $displayName;
}
respond(0, 'success', ['profile' => $updatedProfile]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '用户资料服务暂不可用');
}
+411
View File
@@ -0,0 +1,411 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
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_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
const WEB_FILE_MAX_BYTES = 50 * 1024 * 1024;
function isDailyMuteActive(string $dailyStart, string $dailyEnd, int $nowMs): bool
{
if (!preg_match('/^\d{2}:\d{2}$/', $dailyStart) || !preg_match('/^\d{2}:\d{2}$/', $dailyEnd)) {
return false;
}
$now = (new DateTimeImmutable('@' . (int)floor($nowMs / 1000)))
->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 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');
return $scheme . '://' . $host . ':' . WEB_MEDIA_PUBLIC_PORT;
}
function saveMessageAttachmentFile(array $file, string $attachmentType): 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');
}
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('文件上传失败');
}
$mime = detectedUploadMime($tmpName);
if ($attachmentType === 'image') {
$mime = normalizedImageUploadMime($tmpName, $mime);
}
$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): ?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);
}
$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) {
respond(403, '你不在该群组中');
}
$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);
$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 {
$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' => [
'type' => 1,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'userMessage' => true,
]), '原生群消息发送失败');
} catch (Throwable $error) {
$nativeError = $error->getMessage() ?: '原生群消息发送失败';
}
respond(0, 'success', [
'messageUid' => $messageUid,
'timestamp' => $now,
'senderRole' => $role,
'attachment' => $attachment,
'nativeError' => $nativeError,
]);
}
$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);
}
$result = requireImSuccess(imAdminPost('/admin/message/send', [
'sender' => $sender,
'conv' => [
'type' => 0,
'target' => $target,
'line' => 0,
],
'payload' => $payloadBody,
'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() ?: '发送失败');
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require __DIR__ . '/im_web_bootstrap.php';
$payload = readJsonPayload();
$userId = trim((string)($payload['userId'] ?? ''));
$targetId = trim((string)($payload['targetId'] ?? $payload['friendUid'] ?? ''));
$alias = trim((string)($payload['alias'] ?? ''));
if ($userId === '') {
respond(400, '缺少用户ID');
}
if ($targetId === '') {
respond(400, '缺少联系人ID');
}
if ($targetId === $userId) {
respond(400, '不能给自己设置备注名');
}
if (mb_strlen($alias, 'UTF-8') > 32) {
respond(400, '备注名不能超过32个字');
}
try {
ensureWebImAppTables();
$imDb = imDb();
$exists = $imDb->prepare('select 1 from `t_user` where `_uid` = ? limit 1');
$exists->execute([$targetId]);
if (!$exists->fetchColumn()) {
respond(404, '没有找到该用户');
}
$friend = $imDb->prepare('select 1 from `t_friend` where `_uid` = ? and `_friend_uid` = ? limit 1');
$friend->execute([$userId, $targetId]);
$isContact = (bool)$friend->fetchColumn();
if ($isContact) {
requireImSuccess(
imAdminPost('/admin/friend/set_alias', [
'operator' => $userId,
'targetId' => $targetId,
'alias' => $alias,
]),
'备注名修改失败'
);
}
saveWebUserRemark(imAppDb(), $userId, $targetId, $alias);
respond(0, 'success', [
'contact' => [
'uid' => $targetId,
'alias' => $alias,
'isContact' => $isContact,
],
]);
} catch (Throwable $error) {
respond(500, $error->getMessage() ?: '备注名服务暂不可用');
}
+11
View File
@@ -0,0 +1,11 @@
window.JC_WEB_IM_CONFIG = {
adminServer: 'http://winchen.ezpay.asia:8024',
appServer: 'http://winchen.ezpay.asia:18888',
platformConfigApi: '/api/platform_config.php',
registerConfigApi: 'http://winchen.ezpay.asia:8024/api/register-config',
imServerHost: 'winchen.ezpay.asia',
imRoutePort: 18000,
useWss: false,
wsPath: '/ws',
sdkMode: 'http'
};
+2778
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Web IM</title><link rel="icon" href="img/5IuOieu9PF8MJtp43kD2kCkdLVjVwDp7.ico"><script src="config/runtime-config.js"></script><script defer="defer" src="js/chunk-vendors.fa6d4264.js"></script><script defer="defer" src="js/app.3d85aa5a.js"></script><link href="css/app.0d8c504a.css" rel="stylesheet"></head><body><noscript>请启用 JavaScript 后使用 Web IM。</noscript><div id="app"></div></body></html>
+9020
View File
File diff suppressed because it is too large Load Diff
+1
View File
File diff suppressed because one or more lines are too long
+24384
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long