Phát triển hệ thống tin nhắn tức thời trên web: Các cải tiến chính

Bài viết này trình bày các cải tiến và tính năng mới trong phiên bản thứ hai của một hệ thống tin nhắn tức thời (IM) đơn giản dựa trên web. Các tối ưu hóa tập trung vào trải nghiệm người dùng, bảo mật, và khả năng lưu trữ dữ liệu, bao gồm:

  1. Tăng cường cơ chế chặn truy cập (login interception).
  2. Hiển thị danh sách bạn bè trực tuyến/ngoại tuyến sau khi đăng nhập, bao gồm thông báo khi bạn bè trực tuyến/ngoại tuyến.
  3. Chuyển đổi sử dụng ID người dùng cho tương tác backend và tên hiển thị (nickname) cho frontend.
  4. Lưu trữ tin nhắn trò chuyện và hiển thị lịch sử trò chuyện khi nhấp vào một người bạn.
  5. Hiển thị số lượng tin nhắn chưa đọc dưới dạng biểu tượng chấm tròn nhỏ (badge) sau khi đăng nhập.
  6. Chức năng tìm kiếm và thêm bạn bè.

Chi tiết các cải tiến

1. Tối ưu hóa xác thực người dùng

Thay vì dựa vào tên tài khoản trong đường dẫn hoặc danh sách kết nối WebSocket, hệ thống hiện tại sử dụng cookie để quản lý trạng thái đăng nhập. Cookie được thiết lập khi người dùng đăng nhập và xóa khi đăng xuất.

Cơ chế xác thực người dùng (Java - phần backend):


// File: ChatUserController.java
@RestController
@RequestMapping("/api/users")
public class ChatUserController {

    private final UserService userService;
    private final WebSocketSessionManager sessionManager;
    private final PasswordEncoder passwordEncoder; // Sử dụng interface để mã hóa

    public ChatUserController(UserService userService, WebSocketSessionManager sessionManager, PasswordEncoder passwordEncoder) {
        this.userService = userService;
        this.sessionManager = sessionManager;
        this.passwordEncoder = passwordEncoder;
    }

    /**
     * Xử lý yêu cầu đăng nhập
     */
    @PostMapping("/login")
    public ApiResponse<UserDTO> authenticateUser(@RequestBody LoginRequest loginReq, HttpServletResponse response) {
        String hashedPassword = passwordEncoder.encode(loginReq.getPassword()); // Mã hóa mật khẩu đầu vào
        UserDTO foundUser = userService.findUserByCredentials(loginReq.getUsername(), hashedPassword);

        if (foundUser != null) {
            // Loại bỏ thông tin nhạy cảm trước khi trả về
            foundUser.setPassword(null);

            // Thêm người dùng vào danh sách đang hoạt động
            sessionManager.addActiveUser(foundUser.getUsername(), foundUser);

            // Đặt cookie xác thực
            Cookie authCookie = new Cookie("chatAuthToken", foundUser.getUsername());
            authCookie.setMaxAge(60 * 30); // 30 phút
            authCookie.setPath("/");
            response.addCookie(authCookie);

            return ApiResponse.success(foundUser);
        } else {
            return ApiResponse.error("Tên đăng nhập hoặc mật khẩu không chính xác!");
        }
    }

    /**
     * Xử lý yêu cầu đăng xuất
     */
    @PostMapping("/logout/{username}")
    public ModelAndView logoffUser(HttpServletResponse response, @PathVariable String username) {
        sessionManager.removeUserSession(username, response);
        return new ModelAndView("redirect:/login.html"); // Chuyển hướng về trang đăng nhập
    }
}

Cơ chế quản lý WebSocket và đăng xuất (Java - phần backend):


// File: ChatWebSocketEndpoint.java
@ServerEndpoint("/chat/{userId}")
@Component
public class ChatWebSocketEndpoint {

    private static WebSocketSessionManager sessionManager; // Inject qua setter hoặc constructor

    @Autowired
    public void setSessionManager(WebSocketSessionManager sessionManager) {
        ChatWebSocketEndpoint.sessionManager = sessionManager;
    }

    /**
     * Được gọi khi kết nối bị đóng
     */
    @OnClose
    public void onSessionClose(Session session) {
        String disconnectedUserId = null;

        // Tìm và xóa người dùng khỏi danh sách session
        for (Map.Entry<String, Session> entry : sessionManager.getUserSessionMap().entrySet()) {
            if (entry.getValue() == session) {
                disconnectedUserId = entry.getKey();
                break;
            }
        }
        if (disconnectedUserId != null) {
            sessionManager.removeUserSession(disconnectedUserId, null); // Truyền null cho response vì không phải HTTP request
        }
    }

    // Các phương thức khác của WebSocket (OnOpen, OnMessage, OnError)
}

// File: WebSocketSessionManager.java
@Component
public class WebSocketSessionManager {
    private static final Map<String, Session> userSessionMap = new ConcurrentHashMap<>();
    private static final Map<String, UserDTO> activeUsers = new ConcurrentHashMap<>(); // Sử dụng ConcurrentHashMap
    private static AtomicInteger activeConnectionsCount = new AtomicInteger(0);

    public Map<String, Session> getUserSessionMap() {
        return userSessionMap;
    }

    public void addActiveUser(String username, UserDTO user) {
        activeUsers.put(username, user);
    }

    /**
     * Xử lý khi người dùng ngắt kết nối hoặc đăng xuất
     */
    public void removeUserSession(String username, HttpServletResponse response) {
        // Xóa khỏi danh sách session
        userSessionMap.remove(username);

        // Giảm số lượng kết nối trực tuyến
        activeConnectionsCount.decrementAndGet();
        if (activeConnectionsCount.get() < 0) {
            activeConnectionsCount.set(0);
        }

        // Xóa người dùng khỏi danh sách đang hoạt động
        activeUsers.remove(username);

        // Xóa cookie nếu có phản hồi HTTP
        if (response != null) {
            Cookie authCookie = new Cookie("chatAuthToken", username);
            authCookie.setMaxAge(0); // Đặt thời gian sống về 0 để xóa cookie
            authCookie.setPath("/");
            response.addCookie(authCookie);
        }

        // Thông báo cho các người dùng khác về trạng thái online/offline
        notifyOnlineStatus(username, "{'type':'userStatus','activeConnections':" + activeConnectionsCount.get() + ",'username':'" + username + "'}");
    }

    public void notifyOnlineStatus(String username, String message) {
        // Gửi thông báo đến tất cả các session đang hoạt động, trừ người dùng hiện tại
        userSessionMap.forEach((user, session) -> {
            if (!user.equals(username) && session.isOpen()) {
                try {
                    session.getBasicRemote().sendText(message);
                } catch (IOException e) {
                    System.err.println("Lỗi khi gửi thông báo trạng thái: " + e.getMessage());
                }
            }
        });
    }
}

Bộ lọc xác thực (Java - phần backend):


// File: AuthenticationFilter.java
@Component
public class AuthenticationFilter implements Filter {

    @Autowired
    private WebSocketSessionManager sessionManager; // Inject WebSocketSessionManager

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;

        String authToken = "";
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("chatAuthToken".equals(cookie.getName())) {
                    authToken = cookie.getValue();
                    break;
                }
            }
        }

        // Kiểm tra nếu người dùng đã đăng nhập và đang hoạt động
        if (sessionManager.getActiveUsers().containsKey(authToken)) {
            filterChain.doFilter(servletRequest, servletResponse); // Tiếp tục xử lý yêu cầu
        } else {
            response.sendRedirect("/login.html"); // Chuyển hướng về trang đăng nhập
        }
    }

    // Các phương thức init và destroy
}

2. Hiển thị danh sách bạn bè trực tuyến

Danh sách người dùng hiển thị không còn là tất cả các kết nối WebSocket, mà là danh sách bạn bè của người dùng hiện tại, có phân biệt trạng thái trực tuyến/ngoại tuyến. Điều này yêu cầu một API mới để lấy danh sách bạn bè đang trực tuyến.

API lấy danh sách bạn bè trực tuyến (Java - phần backend):


// File: FriendService.java
@Service
public class FriendService {

    private final FriendRepository friendRepository;
    private final UserService userService;
    private final WebSocketSessionManager sessionManager;

    public FriendService(FriendRepository friendRepository, UserService userService, WebSocketSessionManager sessionManager) {
        this.friendRepository = friendRepository;
        this.userService = userService;
        this.sessionManager = sessionManager;
    }

    /**
     * Lấy danh sách bạn bè đang trực tuyến
     */
    public ApiResponse<List<UserDTO>> getOnlineFriendsForUser(Long userId) {
        // Lấy danh sách bạn bè của người dùng (chỉ những người đã chấp nhận)
        List<FriendRelationship> relationships = friendRepository.findBySourceUserIdAndStatus(userId, 1);

        List<UserDTO> onlineFriends = new ArrayList<>();
        for (FriendRelationship rel : relationships) {
            UserDTO friendUser = userService.findUserById(rel.getTargetUserId()); // Lấy thông tin chi tiết của người bạn
            if (friendUser != null && sessionManager.getUserSessionMap().containsKey(friendUser.getId().toString())) {
                onlineFriends.add(friendUser);
            }
        }
        return ApiResponse.success(onlineFriends);
    }

    // Các phương thức khác liên quan đến bạn bè
}

Frontend hiển thị danh sách bạn bè (JavaScript):


// File: chatUI.js (hoặc tương tự)
const BASE_URL = "/api"; // Cần được định nghĩa dựa trên môi trường của bạn

// Callback khi kết nối WebSocket thành công
websocket.onopen = function () {
    // Lấy danh sách bạn bè
    $.ajax({
        type: 'post',
        url: BASE_URL + "/friends/list",
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
        dataType: 'json',
        data: {userId: currentUser.id, status: 1}, // Chỉ lấy bạn bè đã chấp nhận
        success: function (response) {
            if (response.success) {
                let friends = response.data;
                $("#friends-list-container").empty(); // Xóa danh sách cũ
                for (let i = 0; i < friends.length; i++) {
                    let friend = friends[i].friendInfo; // Giả định friendInfo chứa UserDTO của bạn bè
                    let $friendItem = $(`
                        <div class="chat-friend-item" data-user-id="${friend.id}">
                            <img class="avatar" src="${friend.avatarUrl || 'default_avatar.png'}" alt="Avatar"/>
                            <span class="friend-name">${friend.nickname}</span>
                            <span id="status-${friend.id}" class="friend-status offline">[Ngoại tuyến]</span>
                            <div id="badge-${friend.id}" class="notification-badge">0</div>
                        </div>
                    `);
                    $friendItem[0].userData = friend; // Lưu dữ liệu người bạn vào DOM element
                    $("#friends-list-container").append($friendItem);
                }

                $("#friend-count").text(friends.length); // Cập nhật số lượng bạn bè
                fetchOnlineFriends(currentUser.id); // Gọi hàm để cập nhật trạng thái online
            }
        },
        error: function (xhr, status, error) {
            console.error("Lỗi AJAX khi lấy danh sách bạn bè:", error);
        }
    });
};

/**
 * Lấy danh sách bạn bè đang trực tuyến và cập nhật trạng thái UI
 */
function fetchOnlineFriends(userId) {
    $.ajax({
        type: 'post',
        url: BASE_URL + "/friends/online",
        contentType: 'application/x-www-form-urlencoded; charset=UTF-8',
        dataType: 'json',
        data: {userId: userId},
        success: function (response) {
            if (response.success) {
                let onlineFriends = response.data;
                $("#online-count").text(onlineFriends.length); // Cập nhật số lượng trực tuyến

                // Cập nhật trạng thái cho từng người bạn
                $("#friends-list-container .chat-friend-item").each(function() {
                    let friendId = $(this).data('user-id');
                    let isOnline = onlineFriends.some(f => f.id === friendId);

                    let $statusSpan = $(`#status-${friendId}`);
                    if (isOnline) {
                        $statusSpan.text("[Trực tuyến]").removeClass("offline").addClass("online");
                    } else {
                        $statusSpan.text("[Ngoại tuyến]").removeClass("online").addClass("offline");
                    }
                });
            }
        },
        error: function (xhr, status, error) {
            console.error("Lỗi AJAX khi lấy danh sách bạn bè trực tuyến:", error);
        }
    });
}

3. Sử dụng ID người dùng và tên hiển thị (nickname)

Hệ thống đã được sửa đổi để sử dụng ID người dùng làm định danh chính cho các tương tác backend, trong khi nickname được sử dụng để hiển thị trên giao diện người dùng, mang lại sự linh hoạt và rõ ràng hơn.

4. Lưu trữ và hiển thị tin nhắn

Mỗi tin nhắn gửi đi giữa các người dùng hiện được lưu trữ vào cơ sở dữ liệu. Khi người dùng nhấp vào một người bạn để trò chuyện, lịch sử tin nhắn sẽ được tải và hiển thị.

Lưu tin nhắn (Java - phần backend):


// File: ChatWebSocketEndpoint.java (@OnMessage method)
@OnMessage
public void onMessage(String message, Session session) {
    try {
        Map<String, Object> messageData = new ObjectMapper().readValue(message, new TypeReference<Map<String, Object>>(){});

        String messageType = (String) messageData.get("type");
        Map<String, Object> senderInfo = (Map<String, Object>) messageData.get("sender");
        Map<String, Object> receiverInfo = (Map<String, Object>) messageData.get("receiver");

        Long senderId = Long.valueOf(senderInfo.get("userId").toString());
        Long receiverId = Long.valueOf(receiverInfo.get("userId").toString());
        String content = (String) messageData.get("content");
        String timestampStr = (String) messageData.get("timestamp");

        // Tạo đối tượng tin nhắn để lưu trữ
        ChatMessageDTO chatMessage = new ChatMessageDTO();
        chatMessage.setSenderId(senderId);
        chatMessage.setReceiverId(receiverId);
        chatMessage.setContent(content);
        
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            chatMessage.setSentTime(sdf.parse(timestampStr));
            chatMessage.setReadStatus(false); // Mặc định là chưa đọc
        } catch (ParseException e) {
            System.err.println("Lỗi phân tích cú pháp thời gian: " + e.getMessage());
            chatMessage.setSentTime(new Date());
        }

        // Lưu tin nhắn vào cơ sở dữ liệu
        chatMessageService.saveMessage(chatMessage);

        // Xử lý logic gửi tin nhắn đến người nhận qua WebSocket
        // (logic này tương tự như phiên bản gốc, không thay đổi nhiều)

    } catch (IOException e) {
        System.err.println("Lỗi xử lý tin nhắn WebSocket: " + e.getMessage());
    }
}

Lấy lịch sử trò chuyện (JavaScript):


// File: chatUI.js (trong sự kiện click vào người bạn)
// userId là ID của người dùng hiện tại, targetUserId là ID của người bạn đang chat
$.post(BASE_URL + "/messages/history", {
    senderId: currentUser.id,
    receiverId: targetUserId
}, function (response) {
    if (response.success) {
        $("#chat-history-container").empty(); // Xóa lịch sử cũ
        for (let i = 0; i < response.data.length; i++) {
            let msg = response.data[i];
            let messageClass = (msg.senderId === currentUser.id) ? 'message-sent' : 'message-received';
            let senderName = (msg.senderId === currentUser.id) ? currentUser.nickname : targetFriend.nickname; // targetFriend là object bạn bè

            let $messageHtml = $(`
                <div class="chat-message ${messageClass}">
                    <span class="message-sender">${senderName}:</span>
                    <p class="message-content">${msg.content}</p>
                    <span class="message-timestamp">${msg.sentTime}</span>
                </div>
            `);
            $("#chat-history-container").append($messageHtml);
        }
        // Cuộn xuống cuối khung chat
        $("#chat-history-container").scrollTop($("#chat-history-container")[0].scrollHeight);
    }
});

Backend API lấy lịch sử trò chuyện (Java):


// File: ChatMessageService.java
@Service
public class ChatMessageService {

    private final ChatMessageRepository messageRepository;

    public ChatMessageService(ChatMessageRepository messageRepository) {
        this.messageRepository = messageRepository;
    }

    /**
     * Lấy lịch sử trò chuyện giữa hai người dùng A và B
     */
    public ApiResponse<List<ChatMessageDTO>> getChatHistory(Long userIdA, Long userIdB) {
        List<ChatMessage> messages = messageRepository.findBySenderIdAndReceiverIdOrReceiverIdAndSenderIdOrderBySentTimeAsc(
                userIdA, userIdB, userIdA, userIdB);
        
        List<ChatMessageDTO> dtos = messages.stream()
            .map(this::convertToDto) // Chuyển đổi từ entity sang DTO
            .collect(Collectors.toList());
            
        return ApiResponse.success(dtos);
    }
    
    // Phương thức chuyển đổi Entity sang DTO
    private ChatMessageDTO convertToDto(ChatMessage entity) {
        ChatMessageDTO dto = new ChatMessageDTO();
        dto.setId(entity.getId());
        dto.setSenderId(entity.getSenderId());
        dto.setReceiverId(entity.getReceiverId());
        dto.setContent(entity.getContent());
        dto.setSentTime(entity.getSentTime());
        dto.setReadStatus(entity.isReadStatus());
        return dto;
    }
}

5. Hiển thị tin nhắn chưa đọc

Khi đăng nhập, hệ thống sẽ kiểm tra tất cả tin nhắn chưa đọc của người dùng và hiển thị số lượng tương ứng dưới dạng các chấm tròn nhỏ (notification badges) bên cạnh tên bạn bè.


// File: chatUI.js (trong websocket.onopen hoặc sau khi load danh sách bạn bè)

// Lấy tin nhắn chưa đọc
$.post(BASE_URL + "/messages/unread", {
    toUserId: currentUser.id
}, function(response){
    if(response.success){
        let unreadCounts = {}; // Lưu trữ số tin nhắn chưa đọc cho mỗi người gửi

        // Đếm số tin nhắn chưa đọc từ mỗi người gửi
        for (let i = 0; i < response.data.length; i++) {
            let msg = response.data[i];
            unreadCounts[msg.senderId] = (unreadCounts[msg.senderId] || 0) + 1;
        }

        // Cập nhật các badge thông báo trên UI
        for (let senderId in unreadCounts) {
            let count = unreadCounts[senderId];
            $(`#badge-${senderId}`).text(count).css("opacity", "1");
        }
    }
});

6. Tìm kiếm và thêm bạn bè

Người dùng có thể tìm kiếm bạn bè theo tài khoản (chính xác) hoặc nickname (tìm kiếm tương đối). Chức năng thêm bạn bè bao gồm quy trình gửi và chấp nhận lời mời.

Tìm kiếm bạn bè (JavaScript):


// File: chatUI.js
function searchUsers() {
    let searchTerm = $("#user-search-input").val();
    if (!searchTerm) {
        displayToast("Vui lòng nhập tên tài khoản hoặc nickname.");
        return;
    }

    $.post(BASE_URL + "/users/search", {
        query: searchTerm // Backend sẽ xử lý tìm theo username hoặc nickname
    }, function (response) {
        if (response.success) {
            $("#search-results-list").empty();
            for (let i = 0; i < response.data.length; i++) {
                let user = response.data[i];
                let $userResult = $(`
                    <div class="search-result-item">
                        <img class="avatar" src="${user.avatarUrl || 'default_avatar.png'}"/>
                        <span>${user.nickname} (${user.username})</span>
                        <button onclick="showUserProfile(${user.id})">Xem hồ sơ</button>
                        <button onclick="sendFriendRequest(${user.id})">Thêm bạn</button>
                    </div>
                `);
                $userResult[0].userData = user;
                $("#search-results-list").append($userResult);
            }
        } else {
            displayToast(response.message);
        }
    });
}

Backend API tìm kiếm người dùng (Java):


// File: UserService.java
@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    /**
     * Tìm kiếm người dùng theo username (chính xác) hoặc nickname (tương đối)
     */
    public ApiResponse<List<UserDTO>> findUsersBySearchTerm(String searchTerm) {
        List<ChatUser> users = userRepository.findByUsernameEqualsIgnoreCaseOrNicknameContainingIgnoreCase(searchTerm, searchTerm);
        
        List<UserDTO> dtos = users.stream()
            .map(this::convertToDto) // Chuyển đổi từ entity sang DTO
            .collect(Collectors.toList());
            
        return ApiResponse.success(dtos);
    }
    
    // Phương thức chuyển đổi Entity sang DTO
    private UserDTO convertToDto(ChatUser entity) {
        UserDTO dto = new UserDTO();
        dto.setId(entity.getId());
        dto.setUsername(entity.getUsername());
        dto.setNickname(entity.getNickname());
        dto.setAvatarUrl(entity.getAvatarUrl());
        return dto;
    }
}

// File: UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<ChatUser, Long> {
    // Sửa đổi query để tìm kiếm theo username (chính xác) hoặc nickname (tương đối)
    @Query(value = "SELECT * FROM chat_users cu WHERE LOWER(cu.username) = LOWER(:username) OR LOWER(cu.nickname) LIKE %:nickname%", nativeQuery = true)
    List<ChatUser> findByUsernameEqualsIgnoreCaseOrNicknameContainingIgnoreCase(@Param("username") String username, @Param("nickname") String nickname);
}

Cấu trúc bảng `chat_friend_relations` (SQL):


-- Bảng lưu trữ mối quan hệ bạn bè
DROP TABLE IF EXISTS `chat_friend_relations`;
CREATE TABLE `chat_friend_relations`  (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT 'Khóa chính tự tăng',
  `source_user_id` BIGINT NOT NULL COMMENT 'ID của người dùng gửi yêu cầu hoặc khởi tạo mối quan hệ',
  `target_user_id` BIGINT NOT NULL COMMENT 'ID của người dùng nhận yêu cầu hoặc là bạn bè',
  `relationship_type` INT NULL DEFAULT 1 COMMENT 'Loại quan hệ (ví dụ: 1-bạn bè, 2-gia đình)',
  `notes` VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT 'Ghi chú về người bạn',
  `status` INT NOT NULL DEFAULT 0 COMMENT 'Trạng thái yêu cầu: 0-đang chờ, 1-đã chấp nhận, -1-đã từ chối',
  `created_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Thời gian tạo mối quan hệ/yêu cầu',
  `updated_at` DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Thời gian cập nhật mối quan hệ',
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `idx_source_target` (`source_user_id`, `target_user_id`) USING BTREE,
  INDEX `idx_target_status` (`target_user_id`, `status`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1000 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = 'Bảng quan hệ bạn bè' ROW_FORMAT = Compact;

Thêm bạn bè và chấp nhận yêu cầu (JavaScript):


// File: chatUI.js

// Lắng nghe sự kiện click vào icon "Tin nhắn hệ thống"
$("body").on("click", "#system-notifications-btn", function () {
    if ($(".system-notification-popup").length <= 0) {
        // Mở popup tin nhắn hệ thống
        openNotificationDialog();
    } else {
        $(".system-notification-popup").toggle(); // Đóng/mở nếu đã tồn tại
    }
    // Đặt lại badge thông báo
    $("#system-notifications-btn").find(".notification-badge").css("opacity", 0).text(0);

    // Lấy các yêu cầu kết bạn đang chờ xử lý
    $.post(BASE_URL + "/friends/requests/received", {
        targetUserId: currentUser.id,
        status: 0 // Đang chờ
    }, function (response) {
        if (response.success) {
            $(".system-notification-popup .content").empty();
            if (response.data.length === 0) {
                $(".system-notification-popup .content").append("<p>Không có tin nhắn hệ thống nào.</p>");
            } else {
                for (let i = 0; i < response.data.length; i++) {
                    let request = response.data[i];
                    let requestingUser = request.sourceUserInfo; // Giả sử chứa UserDTO của người gửi yêu cầu
                    let $requestDiv = $(`
                        <div class="friend-request-item">
                            <img class="avatar" src="${requestingUser.avatarUrl || 'default_avatar.png'}"/>
                            <span>${requestingUser.nickname} (${requestingUser.username})</span> đã gửi lời mời kết bạn.
                            <br/>
                            <button onclick="showUserProfile(${requestingUser.id})">Xem hồ sơ</button>
                            <button onclick="acceptFriendRequest(${request.id})">Chấp nhận</button>
                            <button onclick="declineFriendRequest(${request.id})">Từ chối</button>
                        </div>
                    `);
                    $(".system-notification-popup .content").append($requestDiv);
                }
            }
        }
    });
});

// Hàm gửi yêu cầu kết bạn
function sendFriendRequest(friendUserId) {
    let currentTime = new Date().toISOString();
    $.post(BASE_URL + "/friends/request/send", {
        sourceUserId: currentUser.id,
        targetUserId: friendUserId,
        relationshipType: 1,
        status: 0, // Đang chờ
        createdAt: currentTime,
        updatedAt: currentTime,
    }, function (response) {
        if (response.success) {
            displayToast({text: "Lời mời kết bạn đã được gửi. Chờ đối phương chấp nhận!", time: 3000});
        } else {
            displayToast(response.message);
        }
    });
}

// Hàm chấp nhận yêu cầu kết bạn
function acceptFriendRequest(requestId) {
    let currentTime = new Date().toISOString();
    $.post(BASE_URL + "/friends/request/accept", {
        id: requestId,
        status: 1, // Đã chấp nhận
        updatedAt: currentTime,
    }, function (response) {
        if (response.success) {
            // Sau khi cập nhật yêu cầu đầu tiên, tạo mối quan hệ ngược lại
            let originalRequest = response.data; // Dữ liệu yêu cầu gốc từ backend
            $.post(BASE_URL + "/friends/request/send", {
                sourceUserId: originalRequest.targetUserId, // Người nhận yêu cầu gốc giờ là người gửi
                targetUserId: originalRequest.sourceUserId, // Người gửi yêu cầu gốc giờ là người nhận
                relationshipType: 1,
                status: 1, // Đã chấp nhận ngay lập tức
                createdAt: currentTime,
                updatedAt: currentTime,
            }, function (resp) {
                if (resp.success) {
                    displayToast({text: "Bạn và người này đã là bạn bè! Có thể bắt đầu trò chuyện.", time: 2000});
                    // Cập nhật UI (tải lại danh sách bạn bè, vv.)
                } else {
                    displayToast("Lỗi khi tạo mối quan hệ ngược lại: " + resp.message);
                }
            });
        } else {
            displayToast("Lỗi khi chấp nhận yêu cầu: " + response.message);
        }
    });
}

// Hàm để lấy các yêu cầu kết bạn đang chờ xử lý và cập nhật badge
function updateFriendRequestBadge(userId) {
    $.post(BASE_URL + "/friends/requests/received", {
        targetUserId: userId,
        status: 0, // Đang chờ
    }, function (response) {
        if (response.success && response.data.length > 0) {
            $("#system-notifications-btn").find(".notification-badge").css("opacity", 1).text(response.data.length);
        } else {
            $("#system-notifications-btn").find(".notification-badge").css("opacity", 0).text(0);
        }
    });
}

Sửa lỗi hiển thị trạng thái trực tuyến/ngoại tuyến:

Một lỗi đã được phát hiện và khắc phục liên quan đến việc hiển thị trạng thái trực tuyến/ngoại tuyến của bạn bè. Vấn đề là do việc tham chiếu sai thuộc tính trong đối tượng bạn bè liên kết. Cụ thể, thay vì sử dụng getUser(), cần sử dụng getFriendUser() để truy cập đúng đối tượng người dùng bạn bè đã được liên kết thông qua annotation @OneToOne trong entity FriendRelationship.


// File: FriendService.java (phiên bản sửa lỗi)
@Override
public ApiResponse<List<UserDTO>> getOnlineFriendsForUser(Long userId) {
    // Chỉ lấy bạn bè có trạng thái đã chấp nhận
    List<FriendRelationship> friendRelations = friendRepository.findBySourceUserIdAndStatus(userId, 1);

    List<UserDTO> onlineFriendsList = new ArrayList<>();

    for (FriendRelationship relationship : friendRelations) {
        // CHỈNH SỬA: Thay đổi từ getUser() sang getFriendUser() để lấy đúng đối tượng người bạn
        UserDTO friendUser = convertToUserDTO(relationship.getFriendUser()); // Giả định getFriendUser trả về ChatUser entity
        if (friendUser != null && sessionManager.getUserSessionMap().containsKey(friendUser.getId().toString())) {
            onlineFriendsList.add(friendUser);
        }
    }
    return ApiResponse.success(onlineFriendsList);
}

Lưu ý về `ConcurrentHashMap`:

Để đảm bảo an toàn luồng trong môi trường đa luồng của ứng dụng web, đặc biệt là với các map lưu trữ trạng thái người dùng hoặc session WebSocket, việc sử dụng ConcurrentHashMap thay vì HashMap là rất quan trọng. ConcurrentHashMap cung cấp hiệu suất tốt hơn so với Collections.synchronizedMap() và là lựa chọn phù hợp cho các cấu trúc dữ liệu cần được truy cập đồng thời.

Thẻ: WebSocket Spring Boot Java JavaScript Instant Messaging

Đăng vào ngày 9 tháng 8 lúc 14:04