Import JC Web IM
This commit is contained in:
@@ -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() ?: '添加联系人服务暂不可用');
|
||||
}
|
||||
@@ -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() ?: '联系人服务暂不可用');
|
||||
}
|
||||
@@ -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() ?: '会话操作失败');
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('IM_ADMIN_URL', getenv('IM_ADMIN_URL') ?: 'http://127.0.0.1:18080');
|
||||
define('IM_ADMIN_SECRET', getenv('IM_ADMIN_SECRET') ?: '');
|
||||
define('IM_DB_DSN', getenv('IM_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=wfchat;charset=utf8mb4');
|
||||
define('IM_DB_USER', getenv('IM_DB_USER') ?: '');
|
||||
define('IM_DB_PASSWORD', getenv('IM_DB_PASSWORD') ?: '');
|
||||
define('IM_APP_DB_DSN', getenv('IM_APP_DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=appdata;charset=utf8mb4');
|
||||
define('IM_APP_DB_USER', getenv('IM_APP_DB_USER') ?: '');
|
||||
define('IM_APP_DB_PASSWORD', getenv('IM_APP_DB_PASSWORD') ?: '');
|
||||
define('IM_DB_TIMEZONE', getenv('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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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() ?: '消息操作失败');
|
||||
}
|
||||
@@ -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() ?: '消息详情加载失败');
|
||||
}
|
||||
@@ -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() ?: '消息同步失败');
|
||||
}
|
||||
@@ -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/logo.png';
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -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() ?: '用户资料服务暂不可用');
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
echo json_encode([
|
||||
'code' => 0,
|
||||
'message' => 'success',
|
||||
'data' => [
|
||||
'methods' => [
|
||||
'phone' => true,
|
||||
'email' => false,
|
||||
'username' => false,
|
||||
],
|
||||
'mobile_real_verify' => false,
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
@@ -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() ?: '发送失败');
|
||||
}
|
||||
@@ -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() ?: '备注名服务暂不可用');
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
window.JC_WEB_IM_CONFIG = {
|
||||
adminServer: 'http://winchen.ezpay.asia:8024',
|
||||
appServer: 'http://winchen.ezpay.asia:8888',
|
||||
qrAppServer: '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'
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 958 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,14 @@
|
||||
<!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="<%= BASE_URL %>favicon.ico">
|
||||
<script src="<%= BASE_URL %>config/runtime-config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>请启用 JavaScript 后使用 Web IM。</noscript>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user