XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEToolBox

【WPF】XFE工具箱

公开
关注 0 Fork 0 Star 0
UTF-8
using Microsoft.Data.Sqlite;
using XFEToolBox.Core.Chat;

namespace XFEToolBox.Server.Core.Chat;

public sealed partial class ChatRepository
{
    public bool IsGroupMember(string groupId, string userId) =>
        IsGroupMemberAsync(groupId, userId).GetAwaiter().GetResult();

    public async Task<bool> IsGroupMemberAsync(
        string groupId,
        string userId,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(groupId);
        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var command = connection.CreateCommand();
        command.CommandText = "SELECT EXISTS(SELECT 1 FROM group_members WHERE group_id = $groupId AND user_id = $userId);";
        command.Parameters.AddWithValue("$groupId", groupId);
        command.Parameters.AddWithValue("$userId", userId);
        return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)) != 0;
    }

    public IReadOnlyList<string> GetGroupMemberUserIds(string groupId) =>
        GetGroupMemberUserIdsAsync(groupId).GetAwaiter().GetResult();

    public async Task<IReadOnlyList<string>> GetGroupMemberUserIdsAsync(
        string groupId,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(groupId);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var command = connection.CreateCommand();
        command.CommandText = "SELECT user_id FROM group_members WHERE group_id = $groupId ORDER BY joined_at_utc, user_id;";
        command.Parameters.AddWithValue("$groupId", groupId);
        var result = new List<string>();
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) result.Add(reader.GetString(0));
        return result;
    }

    public async Task<ChatGroupRecord> CreateGroupAsync(
        string ownerUserId,
        string name,
        string? description,
        ChatGroupVisibility visibility,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
        name = name?.Trim() ?? string.Empty;
        description = (description ?? string.Empty).Trim();
        if (name.Length is < 1 or > 80)
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "群名称长度应为 1-80 个字符。");
        if (description.Length > 500)
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "群简介不能超过 500 个字符。");
        if (!Enum.IsDefined(visibility))
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "群可见性无效。");

        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var id = NewId();
        var now = DateTimeOffset.UtcNow;
        string? groupNumber = null;
        for (var attempt = 0; attempt < 12 && groupNumber is null; attempt++)
        {
            var candidate = NewGroupNumber();
            try
            {
                await using var command = connection.CreateCommand();
                command.Transaction = transaction;
                command.CommandText = """
                                      INSERT INTO chat_groups(
                                          id, group_number, name, description, avatar_url,
                                          visibility, owner_user_id, created_at_utc, updated_at_utc)
                                      VALUES($id, $number, $name, $description, '',
                                             $visibility, $owner, $created, $updated);
                                      """;
                command.Parameters.AddWithValue("$id", id);
                command.Parameters.AddWithValue("$number", candidate);
                command.Parameters.AddWithValue("$name", name);
                command.Parameters.AddWithValue("$description", description);
                command.Parameters.AddWithValue("$visibility", (int)visibility);
                command.Parameters.AddWithValue("$owner", ownerUserId);
                command.Parameters.AddWithValue("$created", ToUnixMilliseconds(now));
                command.Parameters.AddWithValue("$updated", ToUnixMilliseconds(now));
                await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
                groupNumber = candidate;
            }
            catch (SqliteException exception) when (IsConstraintViolation(exception))
            {
                // The generated public number collided; retry with another number.
            }
        }
        if (groupNumber is null)
            throw new ChatRepositoryException(ChatRepositoryError.Conflict, "暂时无法分配群号,请重试。");

        await using (var memberCommand = connection.CreateCommand())
        {
            memberCommand.Transaction = transaction;
            memberCommand.CommandText = """
                                        INSERT INTO group_members(group_id, user_id, role, joined_at_utc)
                                        VALUES($groupId, $userId, $role, $joined);
                                        """;
            memberCommand.Parameters.AddWithValue("$groupId", id);
            memberCommand.Parameters.AddWithValue("$userId", ownerUserId);
            memberCommand.Parameters.AddWithValue("$role", (int)ChatGroupRole.Owner);
            memberCommand.Parameters.AddWithValue("$joined", ToUnixMilliseconds(now));
            await memberCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }

        var conversationId = NewId();
        await using (var conversationCommand = connection.CreateCommand())
        {
            conversationCommand.Transaction = transaction;
            conversationCommand.CommandText = """
                                              INSERT INTO conversations(
                                                  id, kind, group_id, created_at_utc, updated_at_utc)
                                              VALUES($id, 1, $groupId, $created, $updated);
                                              """;
            conversationCommand.Parameters.AddWithValue("$id", conversationId);
            conversationCommand.Parameters.AddWithValue("$groupId", id);
            conversationCommand.Parameters.AddWithValue("$created", ToUnixMilliseconds(now));
            conversationCommand.Parameters.AddWithValue("$updated", ToUnixMilliseconds(now));
            await conversationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }

        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return new ChatGroupRecord(
            id, groupNumber, name, description, string.Empty, visibility, ownerUserId,
            1, ChatGroupRole.Owner, conversationId, now, now);
    }

    public Task<IReadOnlyList<ChatGroupRecord>> GetRecommendedGroupsAsync(
        string currentUserId,
        string? query,
        int limit = 30,
        int offset = 0,
        CancellationToken cancellationToken = default) =>
        QueryGroupsAsync(currentUserId, query, publicOnly: true, mineOnly: false, limit, offset, cancellationToken);

    public Task<IReadOnlyList<ChatGroupRecord>> GetMyGroupsAsync(
        string currentUserId,
        int limit = 200,
        CancellationToken cancellationToken = default) =>
        QueryGroupsAsync(currentUserId, query: null, publicOnly: false, mineOnly: true, limit, offset: 0, cancellationToken);

    public async Task<ChatGroupRecord?> FindGroupByNumberAsync(
        string groupNumber,
        string currentUserId,
        CancellationToken cancellationToken = default)
    {
        ConsumeGroupNumberAttempt(currentUserId);
        groupNumber = groupNumber?.Trim() ?? string.Empty;
        if (groupNumber.Length != GroupNumberLength || groupNumber.Any(character => !char.IsAsciiDigit(character)))
            return null;
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        return await FindGroupAsync(connection, transaction: null, "g.group_number = $value", groupNumber, currentUserId, cancellationToken)
            .ConfigureAwait(false);
    }

    public async Task<ChatGroupRecord?> FindGroupByIdAsync(
        string groupId,
        string currentUserId,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(groupId);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        return await FindGroupAsync(connection, transaction: null, "g.id = $value", groupId, currentUserId, cancellationToken)
            .ConfigureAwait(false);
    }

    public async Task<ChatGroupRecord> JoinGroupByNumberAsync(
        string groupNumber,
        string userId,
        CancellationToken cancellationToken = default)
    {
        ConsumeGroupNumberAttempt(userId);
        groupNumber = groupNumber?.Trim() ?? string.Empty;
        if (groupNumber.Length != GroupNumberLength || groupNumber.Any(character => !char.IsAsciiDigit(character)))
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群聊不存在。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var group = await FindGroupAsync(connection, transaction, "g.group_number = $value", groupNumber, userId, cancellationToken)
            .ConfigureAwait(false)
                    ?? throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群聊不存在。");
        if (group.CurrentUserRole is null)
        {
            var now = DateTimeOffset.UtcNow;
            await using var command = connection.CreateCommand();
            command.Transaction = transaction;
            command.CommandText = """
                                  INSERT INTO group_members(group_id, user_id, role, joined_at_utc)
                                  VALUES($groupId, $userId, $role, $joined)
                                  ON CONFLICT(group_id, user_id) DO NOTHING;
                                  """;
            command.Parameters.AddWithValue("$groupId", group.Id);
            command.Parameters.AddWithValue("$userId", userId);
            command.Parameters.AddWithValue("$role", (int)ChatGroupRole.Member);
            command.Parameters.AddWithValue("$joined", ToUnixMilliseconds(now));
            await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return (await FindGroupByIdAsync(group.Id, userId, cancellationToken).ConfigureAwait(false))!;
    }

    public async Task<ChatGroupInvitationRecord> InviteFriendToGroupAsync(
        string groupId,
        string inviterUserId,
        string invitedUserId,
        string? message,
        CancellationToken cancellationToken = default)
    {
        NormalizePair(inviterUserId, invitedUserId);
        message = (message ?? string.Empty).Trim();
        if (message.Length > 300)
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "邀请附言不能超过 300 个字符。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        if (await GetGroupRoleAsync(connection, transaction, groupId, inviterUserId, cancellationToken).ConfigureAwait(false) is null)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只有群成员可以邀请好友。");
        if (await GetGroupRoleAsync(connection, transaction, groupId, invitedUserId, cancellationToken).ConfigureAwait(false) is not null)
            throw new ChatRepositoryException(ChatRepositoryError.Conflict, "该用户已经在群聊中。");
        var (low, high) = NormalizePair(inviterUserId, invitedUserId);
        if (!await AreFriendsAsync(connection, transaction, low, high, cancellationToken).ConfigureAwait(false))
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只能邀请自己的好友。");

        var groupExists = await GroupExistsAsync(connection, transaction, groupId, cancellationToken).ConfigureAwait(false);
        if (!groupExists)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群聊不存在。");

        await ExpireInvitationsAsync(connection, transaction, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);

        var now = DateTimeOffset.UtcNow;
        var expires = now.AddDays(7);
        var invitationId = NewId();
        try
        {
            await using var invitationCommand = connection.CreateCommand();
            invitationCommand.Transaction = transaction;
            invitationCommand.CommandText = """
                                            INSERT INTO group_invitations(
                                                id, group_id, invited_by_user_id, invited_user_id,
                                                message, status, created_at_utc, expires_at_utc)
                                            VALUES($id, $groupId, $inviter, $invited,
                                                   $message, 0, $created, $expires);
                                            """;
            invitationCommand.Parameters.AddWithValue("$id", invitationId);
            invitationCommand.Parameters.AddWithValue("$groupId", groupId);
            invitationCommand.Parameters.AddWithValue("$inviter", inviterUserId);
            invitationCommand.Parameters.AddWithValue("$invited", invitedUserId);
            invitationCommand.Parameters.AddWithValue("$message", message);
            invitationCommand.Parameters.AddWithValue("$created", ToUnixMilliseconds(now));
            invitationCommand.Parameters.AddWithValue("$expires", ToUnixMilliseconds(expires));
            await invitationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }
        catch (SqliteException exception) when (IsConstraintViolation(exception))
        {
            throw new ChatRepositoryException(ChatRepositoryError.Conflict, "该好友已有待处理的群邀请。");
        }

        var conversation = await GetOrCreateDirectConversationAsync(
            connection, transaction, low, high, now, cancellationToken).ConfigureAwait(false);
        _ = await InsertMessageAsync(
            connection,
            transaction,
            conversation.Id,
            inviterUserId,
            ChatMessageType.GroupInvitation,
            message,
            attachmentId: null,
            groupInvitationId: invitationId,
            clientMessageId: $"group-invitation:{invitationId}",
            replyToMessageId: null,
            now,
            cancellationToken).ConfigureAwait(false);

        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return new ChatGroupInvitationRecord(
            invitationId, groupId, inviterUserId, invitedUserId,
            ChatGroupInvitationStatus.Pending, message, now, expires, null);
    }

    public async Task<IReadOnlyList<ChatGroupInvitationRecord>> GetGroupInvitationsAsync(
        string userId,
        int limit = 100,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(userId);
        limit = Math.Clamp(limit, 1, 200);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await ExpireInvitationsAsync(connection, transaction: null, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false);
        await using var command = connection.CreateCommand();
        command.CommandText = """
                              SELECT id, group_id, invited_by_user_id, invited_user_id, status,
                                     message, created_at_utc, expires_at_utc, responded_at_utc
                              FROM group_invitations
                              WHERE invited_user_id = $userId OR invited_by_user_id = $userId
                              ORDER BY CASE WHEN status = 0 THEN 0 ELSE 1 END,
                                       created_at_utc DESC
                              LIMIT $limit;
                              """;
        command.Parameters.AddWithValue("$userId", userId);
        command.Parameters.AddWithValue("$limit", limit);
        var result = new List<ChatGroupInvitationRecord>();
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) result.Add(ReadGroupInvitation(reader));
        return result;
    }

    public async Task<ChatGroupInvitationRecord?> FindGroupInvitationByIdAsync(
        string invitationId,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(invitationId);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var result = await FindGroupInvitationAsync(connection, transaction, invitationId, cancellationToken).ConfigureAwait(false);
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return result;
    }

    public async Task<ChatGroupInvitationRecord> RespondToGroupInvitationAsync(
        string invitationId,
        string respondingUserId,
        bool accept,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(invitationId);
        ArgumentException.ThrowIfNullOrWhiteSpace(respondingUserId);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var invitation = await FindGroupInvitationAsync(connection, transaction, invitationId, cancellationToken).ConfigureAwait(false)
                         ?? throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群邀请不存在。");
        if (!string.Equals(invitation.InvitedUserId, respondingUserId, StringComparison.Ordinal))
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只能响应发给自己的群邀请。");
        if (invitation.Status != ChatGroupInvitationStatus.Pending)
            throw new ChatRepositoryException(ChatRepositoryError.Conflict, "群邀请已经处理。");

        var now = DateTimeOffset.UtcNow;
        var status = invitation.ExpiresAtUtc <= now
            ? ChatGroupInvitationStatus.Expired
            : accept ? ChatGroupInvitationStatus.Accepted : ChatGroupInvitationStatus.Rejected;
        await using (var command = connection.CreateCommand())
        {
            command.Transaction = transaction;
            command.CommandText = """
                                  UPDATE group_invitations
                                  SET status = $status, responded_at_utc = $responded
                                  WHERE id = $id AND status = 0;
                                  """;
            command.Parameters.AddWithValue("$status", (int)status);
            command.Parameters.AddWithValue("$responded", ToUnixMilliseconds(now));
            command.Parameters.AddWithValue("$id", invitationId);
            if (await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) != 1)
                throw new ChatRepositoryException(ChatRepositoryError.Conflict, "群邀请已被其他请求处理。");
        }

        if (status == ChatGroupInvitationStatus.Accepted)
        {
            await using var command = connection.CreateCommand();
            command.Transaction = transaction;
            command.CommandText = """
                                  INSERT INTO group_members(group_id, user_id, role, joined_at_utc)
                                  VALUES($groupId, $userId, $role, $joined)
                                  ON CONFLICT(group_id, user_id) DO NOTHING;
                                  """;
            command.Parameters.AddWithValue("$groupId", invitation.GroupId);
            command.Parameters.AddWithValue("$userId", respondingUserId);
            command.Parameters.AddWithValue("$role", (int)ChatGroupRole.Member);
            command.Parameters.AddWithValue("$joined", ToUnixMilliseconds(now));
            await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }

        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return invitation with { Status = status, RespondedAtUtc = now };
    }

    public async Task<ChatGroupRecord> UpdateGroupAsync(
        string groupId,
        string actingUserId,
        string name,
        string? description,
        ChatGroupVisibility visibility,
        CancellationToken cancellationToken = default)
    {
        name = name?.Trim() ?? string.Empty;
        description = (description ?? string.Empty).Trim();
        if (name.Length is < 1 or > 80 || description.Length > 500 || !Enum.IsDefined(visibility))
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "群资料格式无效。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var role = await GetGroupRoleAsync(connection, transaction, groupId, actingUserId, cancellationToken).ConfigureAwait(false);
        if (role is null || role < ChatGroupRole.Administrator)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "需要群管理员权限。");
        var now = DateTimeOffset.UtcNow;
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = """
                              UPDATE chat_groups
                              SET name = $name, description = $description,
                                  visibility = $visibility, updated_at_utc = $updated
                              WHERE id = $id;
                              """;
        command.Parameters.AddWithValue("$name", name);
        command.Parameters.AddWithValue("$description", description);
        command.Parameters.AddWithValue("$visibility", (int)visibility);
        command.Parameters.AddWithValue("$updated", ToUnixMilliseconds(now));
        command.Parameters.AddWithValue("$id", groupId);
        if (await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false) != 1)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群聊不存在。");
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
        return (await FindGroupByIdAsync(groupId, actingUserId, cancellationToken).ConfigureAwait(false))!;
    }

    public async Task<IReadOnlyList<ChatGroupMemberRecord>> GetGroupMembersAsync(
        string groupId,
        string requestingUserId,
        CancellationToken cancellationToken = default)
    {
        if (!await IsGroupMemberAsync(groupId, requestingUserId, cancellationToken).ConfigureAwait(false))
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只有群成员可以查看成员列表。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var command = connection.CreateCommand();
        command.CommandText = """
                              SELECT group_id, user_id, role, joined_at_utc
                              FROM group_members
                              WHERE group_id = $groupId
                              ORDER BY role DESC, joined_at_utc, user_id;
                              """;
        command.Parameters.AddWithValue("$groupId", groupId);
        var result = new List<ChatGroupMemberRecord>();
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
            result.Add(new ChatGroupMemberRecord(
                reader.GetString(0), reader.GetString(1), (ChatGroupRole)reader.GetInt32(2),
                FromUnixMilliseconds(reader.GetInt64(3))));
        return result;
    }

    public async Task RemoveGroupMemberAsync(
        string groupId,
        string actingUserId,
        string targetUserId,
        CancellationToken cancellationToken = default)
    {
        if (string.Equals(actingUserId, targetUserId, StringComparison.Ordinal))
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "请使用退出群聊接口。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var actingRole = await GetGroupRoleAsync(connection, transaction, groupId, actingUserId, cancellationToken).ConfigureAwait(false);
        var targetRole = await GetGroupRoleAsync(connection, transaction, groupId, targetUserId, cancellationToken).ConfigureAwait(false);
        if (targetRole is null)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群成员不存在。");
        if (actingRole is null || actingRole < ChatGroupRole.Administrator || actingRole <= targetRole)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "不能移除同级或更高权限的成员。");
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "DELETE FROM group_members WHERE group_id = $groupId AND user_id = $userId;";
        command.Parameters.AddWithValue("$groupId", groupId);
        command.Parameters.AddWithValue("$userId", targetUserId);
        await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
    }

    public async Task SetGroupMemberRoleAsync(
        string groupId,
        string actingUserId,
        string targetUserId,
        ChatGroupRole role,
        CancellationToken cancellationToken = default)
    {
        if (role is not (ChatGroupRole.Member or ChatGroupRole.Administrator))
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "成员角色无效。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var actingRole = await GetGroupRoleAsync(connection, transaction, groupId, actingUserId, cancellationToken).ConfigureAwait(false);
        var targetRole = await GetGroupRoleAsync(connection, transaction, groupId, targetUserId, cancellationToken).ConfigureAwait(false);
        if (actingRole != ChatGroupRole.Owner)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只有群主可以设置管理员。");
        if (targetRole is null)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "群成员不存在。");
        if (targetRole == ChatGroupRole.Owner)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "不能修改群主角色。");
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "UPDATE group_members SET role = $role WHERE group_id = $groupId AND user_id = $userId;";
        command.Parameters.AddWithValue("$role", (int)role);
        command.Parameters.AddWithValue("$groupId", groupId);
        command.Parameters.AddWithValue("$userId", targetUserId);
        await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
    }

    public async Task TransferGroupOwnershipAsync(
        string groupId,
        string ownerUserId,
        string targetUserId,
        CancellationToken cancellationToken = default)
    {
        if (string.Equals(ownerUserId, targetUserId, StringComparison.Ordinal))
            throw new ChatRepositoryException(ChatRepositoryError.InvalidRequest, "目标用户已经是群主。");
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        if (await GetGroupRoleAsync(connection, transaction, groupId, ownerUserId, cancellationToken).ConfigureAwait(false) != ChatGroupRole.Owner)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "只有群主可以转让群聊。");
        if (await GetGroupRoleAsync(connection, transaction, groupId, targetUserId, cancellationToken).ConfigureAwait(false) is null)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "目标用户不是群成员。");
        await using (var command = connection.CreateCommand())
        {
            command.Transaction = transaction;
            command.CommandText = """
                                  UPDATE group_members
                                  SET role = CASE WHEN user_id = $oldOwner THEN $administrator ELSE $owner END
                                  WHERE group_id = $groupId AND user_id IN ($oldOwner, $newOwner);
                                  UPDATE chat_groups
                                  SET owner_user_id = $newOwner, updated_at_utc = $updated
                                  WHERE id = $groupId AND owner_user_id = $oldOwner;
                                  """;
            command.Parameters.AddWithValue("$administrator", (int)ChatGroupRole.Administrator);
            command.Parameters.AddWithValue("$owner", (int)ChatGroupRole.Owner);
            command.Parameters.AddWithValue("$groupId", groupId);
            command.Parameters.AddWithValue("$oldOwner", ownerUserId);
            command.Parameters.AddWithValue("$newOwner", targetUserId);
            command.Parameters.AddWithValue("$updated", ToUnixMilliseconds(DateTimeOffset.UtcNow));
            await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
    }

    public async Task LeaveGroupAsync(
        string groupId,
        string userId,
        CancellationToken cancellationToken = default)
    {
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var transaction = (SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
        var role = await GetGroupRoleAsync(connection, transaction, groupId, userId, cancellationToken).ConfigureAwait(false);
        if (role is null)
            throw new ChatRepositoryException(ChatRepositoryError.NotFound, "你不在该群聊中。");
        if (role == ChatGroupRole.Owner)
            throw new ChatRepositoryException(ChatRepositoryError.Forbidden, "群主需先转让群聊才能退出。");
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "DELETE FROM group_members WHERE group_id = $groupId AND user_id = $userId;";
        command.Parameters.AddWithValue("$groupId", groupId);
        command.Parameters.AddWithValue("$userId", userId);
        await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
    }

    private async Task<IReadOnlyList<ChatGroupRecord>> QueryGroupsAsync(
        string currentUserId,
        string? query,
        bool publicOnly,
        bool mineOnly,
        int limit,
        int offset,
        CancellationToken cancellationToken)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(currentUserId);
        query = (query ?? string.Empty).Trim();
        limit = Math.Clamp(limit, 1, 200);
        offset = Math.Clamp(offset, 0, 100_000);
        await using var connection = await OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
        await using var command = connection.CreateCommand();
        command.CommandText = $"""
                               SELECT g.id, g.group_number, g.name, g.description, g.avatar_url,
                                      g.visibility, g.owner_user_id, COUNT(all_members.user_id),
                                      me.role, c.id, g.created_at_utc, g.updated_at_utc
                               FROM chat_groups AS g
                               LEFT JOIN group_members AS all_members ON all_members.group_id = g.id
                               LEFT JOIN group_members AS me ON me.group_id = g.id AND me.user_id = $userId
                               JOIN conversations AS c ON c.kind = 1 AND c.group_id = g.id
                               WHERE {(publicOnly ? "g.visibility = 1" : "1 = 1")}
                                 AND {(mineOnly ? "me.user_id IS NOT NULL" : "1 = 1")}
                                 AND ($query = '' OR g.name LIKE '%' || $query || '%' COLLATE NOCASE
                                      OR g.description LIKE '%' || $query || '%' COLLATE NOCASE)
                               GROUP BY g.id, me.role, c.id
                               ORDER BY g.updated_at_utc DESC, g.id
                               LIMIT $limit OFFSET $offset;
                               """;
        command.Parameters.AddWithValue("$userId", currentUserId);
        command.Parameters.AddWithValue("$query", query);
        command.Parameters.AddWithValue("$limit", limit);
        command.Parameters.AddWithValue("$offset", offset);
        var result = new List<ChatGroupRecord>();
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) result.Add(ReadGroup(reader));
        return result;
    }

    private static async Task<ChatGroupRecord?> FindGroupAsync(
        SqliteConnection connection,
        SqliteTransaction? transaction,
        string predicate,
        string value,
        string currentUserId,
        CancellationToken cancellationToken)
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = $"""
                               SELECT g.id, g.group_number, g.name, g.description, g.avatar_url,
                                      g.visibility, g.owner_user_id, COUNT(all_members.user_id),
                                      me.role, c.id, g.created_at_utc, g.updated_at_utc
                               FROM chat_groups AS g
                               LEFT JOIN group_members AS all_members ON all_members.group_id = g.id
                               LEFT JOIN group_members AS me ON me.group_id = g.id AND me.user_id = $userId
                               JOIN conversations AS c ON c.kind = 1 AND c.group_id = g.id
                               WHERE {predicate}
                               GROUP BY g.id, me.role, c.id;
                               """;
        command.Parameters.AddWithValue("$value", value);
        command.Parameters.AddWithValue("$userId", currentUserId);
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? ReadGroup(reader) : null;
    }

    private static ChatGroupRecord ReadGroup(SqliteDataReader reader) => new(
        reader.GetString(0),
        reader.GetString(1),
        reader.GetString(2),
        reader.GetString(3),
        reader.GetString(4),
        (ChatGroupVisibility)reader.GetInt32(5),
        reader.GetString(6),
        reader.GetInt32(7),
        reader.IsDBNull(8) ? null : (ChatGroupRole)reader.GetInt32(8),
        reader.GetString(9),
        FromUnixMilliseconds(reader.GetInt64(10)),
        FromUnixMilliseconds(reader.GetInt64(11)));

    private static async Task<ChatGroupRole?> GetGroupRoleAsync(
        SqliteConnection connection,
        SqliteTransaction transaction,
        string groupId,
        string userId,
        CancellationToken cancellationToken)
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "SELECT role FROM group_members WHERE group_id = $groupId AND user_id = $userId;";
        command.Parameters.AddWithValue("$groupId", groupId);
        command.Parameters.AddWithValue("$userId", userId);
        var value = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
        return value is null or DBNull ? null : (ChatGroupRole)Convert.ToInt32(value);
    }

    private static async Task<bool> GroupExistsAsync(
        SqliteConnection connection,
        SqliteTransaction transaction,
        string groupId,
        CancellationToken cancellationToken)
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = "SELECT EXISTS(SELECT 1 FROM chat_groups WHERE id = $groupId);";
        command.Parameters.AddWithValue("$groupId", groupId);
        return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false)) != 0;
    }

    private static async Task ExpireInvitationsAsync(
        SqliteConnection connection,
        SqliteTransaction? transaction,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = """
                              UPDATE group_invitations
                              SET status = $expired, responded_at_utc = $now
                              WHERE status = $pending AND expires_at_utc <= $now;
                              """;
        command.Parameters.AddWithValue("$expired", (int)ChatGroupInvitationStatus.Expired);
        command.Parameters.AddWithValue("$pending", (int)ChatGroupInvitationStatus.Pending);
        command.Parameters.AddWithValue("$now", ToUnixMilliseconds(now));
        await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
    }

    private static async Task<ChatGroupInvitationRecord?> FindGroupInvitationAsync(
        SqliteConnection connection,
        SqliteTransaction transaction,
        string invitationId,
        CancellationToken cancellationToken)
    {
        await using var command = connection.CreateCommand();
        command.Transaction = transaction;
        command.CommandText = """
                              SELECT id, group_id, invited_by_user_id, invited_user_id, status,
                                     message, created_at_utc, expires_at_utc, responded_at_utc
                              FROM group_invitations WHERE id = $id;
                              """;
        command.Parameters.AddWithValue("$id", invitationId);
        await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
        return await reader.ReadAsync(cancellationToken).ConfigureAwait(false) ? ReadGroupInvitation(reader) : null;
    }

    private static ChatGroupInvitationRecord ReadGroupInvitation(SqliteDataReader reader) => new(
        reader.GetString(0),
        reader.GetString(1),
        reader.GetString(2),
        reader.GetString(3),
        (ChatGroupInvitationStatus)reader.GetInt32(4),
        reader.GetString(5),
        FromUnixMilliseconds(reader.GetInt64(6)),
        FromUnixMilliseconds(reader.GetInt64(7)),
        FromNullableUnixMilliseconds(reader, 8));
}