<AraQ> 기술설명서

정윤서·2024년 1월 22일
0
post-custom-banner

✔️ 개요 및 목적

🔗 개요

  • 싱글 유저들을 위한 온라인 매칭 플랫폼

🔗 목적

  • 웹 서비스를 통해 다른 유저들과 매칭을 신청하고 새로운 사람을 만나는 매칭 기회 제공
  • 매칭이 성사됐을 때 편리한 채팅 기능을 통한 상대방과의 소통

✔️ Github


✔️ AraQ 주소


✔️ 사용된 기술

🔗 버전관리

  • Github

🔗 배포

  • AWS
  • Ubuntu
  • Ngnix
  • MariaDB
  • FileZila
  • Putty

🔗 개발환경

  • IntelliJ IDEA
  • MySQL
  • Windows11
  • Chrome

🔗 기술스택

  • Java
  • Spring Boot
  • HTML, CSS, JS
  • JQuery
  • Bootstrap
  • WebSocket

🔗 API

  • Kakao Login API
  • Google Sign-In API
  • GitHub OAuth API
  • I'mport Payment API
  • CoolSMS API

✔️ 요구사항

🙍 회원

- 회원가입
- 휴대폰 인증
- 로그인
- 로그아웃
- 아이디/비밀번호 찾기
- 프로필 변경
- 자기소개 음성 녹음
- 신고
- 회원 탈퇴


🧑‍🤝‍🧑 친구

- 친구 추가
- 친구 삭제
- 쪽지 보내기/받기
- 친구 목록(온·오프라인 표시)
- 쪽지 알림
- 쪽지 읽음/안읽음 표시


💬 채팅

- 채팅 신청
- 채팅 수락/거절
- 채팅 보내기/받기
- 채팅 알림
- 채팅 읽음/안읽음 표시
- 음성 채팅
- 사진 전송
- 상대방 평가


👨‍👩‍👧‍👦 광장

- 광장 생성
- 광장 설정 변경
- 마우스 클릭 이동
- 키보드 조작키 이동
- 백그라운드 탭일 때 유저 아이콘 블러 처리
- 채팅
- 방장 위임
- 강제 퇴장


💳 결제

- 충전(카카오페이, 토스페이먼츠, 휴대폰결제)
- 결제 내역 확인
- 결제 취소
- 이용권 구매


💘 매칭

- 매칭 신청
- 매칭 수락/거절
- 매칭 상태 표시
- 이상형 선택
- 연애관 테스트
- 내 위치 주변, 이상형, 성향 및 관심사 매칭, 랜덤 매칭 추천


✏️ 게시판

- 게시물 작성, 조회, 수정, 삭제
- 댓글 작성, 조회, 수정, 삭제


📞 고객 지원

📌 문의

- 문의 작성, 조회
- 답변 상태 표시
- 답변 시 메일 알림

📌 후기

- 후기 작성, 조회, 수정, 삭제
- 등록일순, 별점순 조회


👤 관리자

📌 대시보드

- 최근 일주일 신규 가입자 수 선 그래프
- 운영진 테이블
- 남녀 성비 원 그래프
- 최근 일주일 매칭 성공률 막대 그래프

📌 회원 관리

- 회원 정보 조회
- 아이디, 닉네임 검색
- 회원 삭제

📌 게시물 관리

- 게시물 조회
- 게시물 삭제
- 내용, 작성자 닉네임 검색

📌 후기 관리

- 후기 조회
- 후기 삭제
- 내용, 작성자 닉네임 검색

📌 결제 관리

- 결제 내역 조회
- 결제 취소
- 결제 번호, 주문자 닉네임 검색

📌 문의 관리

- 문의 조회
- 문의 삭제
- 문의 답변
- 문의 제목, 내용, 작성자 닉네임 검색

📌 신고 관리

- 신고 내역 조회
- 정지/영구 정지 조치
- 신고된 채팅방 확인

📌 공지

- 공지 등록, 수정, 조회, 삭제


✔️ ERD


✔️ 주요 기능 설명

📌 채팅 기능

  • 유저 2 → 유저 1에게 채팅 신청
  • 유저 1이 채팅 수락 또는 거절 선택
  • 수락 시 채팅방으로 이동
  • 채팅 VIEW
<form id="sendChatForm" autocomplete="off">
  <div class="card" id="imagePreviewForm">
    <div class="card-body" id="imagePreview"></div>
  </div>
  <textarea type="text" id="msgContent" class="form-control"></textarea>
  <div class="d-flex justify-content-between align-items-center m-2">
    <a th:href="@{/chat/list}" style="padding:3px 5px;"><i class="bi bi-box-arrow-right"
                                                           style="font-size: 30px;"></i></a>
    <a href="javascript:void(0)" id="chatImageBtn">
      <i class="bi bi-images" style="font-size: 30px;"></i>
    </a>
    <a href="javascript:void(0)" id="voiceChatBtn">
      <i class="bi bi-telephone-fill" style="font-size: 30px;"></i>
    </a>
    <a href="javascript:void(0)" id="chatReportBtn">
      <img th:src="@{/image/etc/siren.png}" alt=""
           style="height: 30px; width: 30px; margin-bottom: 10px;">
    </a>
    <a href="javascript:void(0)" id="rateBtn" th:if="${rate == null && room.possible == true}">
      <i class="bi bi-pencil-square" style="font-size: 30px;"></i>
    </a>
    <i class="fas fa-smile" id="emoji-icon" data-bs-toggle="collapse" data-bs-target="#emojiCollapse"
       aria-expanded="false" aria-controls="emojiCollapse" type="button" style="font-size: 30px;"></i>
    <button type="submit" style="border:0;">
      <i class="bi bi-send-fill" style="font-size: 30px; color: rgb(146, 132, 168)"></i>
    </button>
  </div>
  <div class="collapse" id="emojiCollapse" style="max-height: 200px; overflow: auto;">
    <div class="border-top p-2" id="emoji"></div>
  </div>
</form>

1. WebSocket 및 STOMP 클라이언트 설정

const socket = new SockJS("/ws");
const stompClient = Stomp.over(socket);

2. 채팅 메시지 전송 처리

$("#sendChatForm").submit(function (event) {
  event.preventDefault();
  // 이미지 파일과 채팅 내용을 포함하는 formData 생성
  var files = $("#chatImageInput")[0].files;
  var chatContent = {
    content: $("#msgContent").val(),
    writer: user,
    code: roomCode,
    target: target
  };
  if (files.length > 0) {
    var formData = new FormData();
    // 이미지 파일 처리
    for (var i = 0; i < files.length; i++) {
      formData.append('files', files[i]);
    }
    formData.append('chatContainer', JSON.stringify(chatContent));
    // 이미지 업로드를 위한 Ajax 요청
    $.ajax({
      // ...
    });
  } else {
    // 텍스트 채팅 메시지 전송
    if ($("#msgContent").val().trim() !== "") {
      stompClient.send("/app/send", {}, JSON.stringify(chatContent));
    }
  }
  // 폼 초기화
  $("#msgContent").val("");
  $("#msgContent").focus();
});

3. 실시간 채팅 메시지 수신 및 표시

stompClient.connect({}, function (frame) {
      // 브라우저의 푸시 알림 권한을 요청
    if (Notification.permission !== "granted") {
      Notification.requestPermission().then(r => {
        if (r === "granted") alert("푸시 알림이 설정되었습니다.");
      });
    }
      // 개별 사용자에 대한 메시지 구독
    stompClient.subscribe("/topic/all/" + $("#hiddenUserName").val(), function (message) {
      const data = JSON.parse(message.body);
      const chatToast = new bootstrap.Toast($('#chatToast'), {
        autohide: false
    });

    if (data.type === "chatRequest") {
      $("#requestUsername").val(data.username);
      $("#chatRequestModal .modal-title").text(data.content);
      $("#chatRequestModal .nickName").text(data.nickname);
      $("#chatRequestModal .userAge").text(data.age);
      $("#chatRequestModal .introduce").text(data.introduce);
      $("#chatRequestModal .profileImage").attr("src", data.image);
      $("#chatRequestModal").modal("show");
    } else if (data.type === "refuse")
      alert(data.content);

    else if (data.type === "acceptChat") {
      alert(data.nickname + "님이 수락했습니다. 채팅방으로 이동합니다.");
      window.location.href = "/chat/join/" + data.content;
    } else if (data.type === "sendChat" && !window.location.pathname.includes(data.target)) {
      $("#chatToast .profile").attr("src", data.image);
      $("#chatToast .nickName").text(data.nickname);
      $("#chatToast .toast-body > a").text(data.content);
      $("#chatToast .toast-body > a").attr("href", "/chat/join/" + data.target);
      chatToast.show();
    } 

4. 채팅 발신 서버

@MessageMapping("/send")
public void sendMessage(ChatDto chatDto) {
  SiteUser user = userService.getByUsername(chatDto.getWriter());
  SiteUser target = userService.getByUsername(chatDto.getTarget());
  Room room = roomService.get(chatDto.getCode());

  Chat chat = chatService.create(room, user, target, chatDto.getContent(), room.getRecentDate());
  roomService.setRecent(room, chat.getCreateDate());
  roomService.setConfirm(room, target.getUsername());

  chatDto.setWriterNick(user.getNickName());
  chatDto.setWriterImage(user.getImage());
  chatDto.setCreateDate(chat.getCreateDate());

  MessageDto messageDto = new MessageDto();
  messageDto.setType("sendChat");
  messageDto.setNickname(user.getNickName());
  messageDto.setContent(chatDto.getContent());
  messageDto.setImage(user.getImage());
  messageDto.setTarget(chatDto.getCode());

  this.roomService.saveChatNumbers(room);

  Notification notification = new Notification("채팅 알림",
                                               user.getNickName() + "님이 채팅를 보냈습니다.", user.getUsername(), target.getUsername(),
                                               "/chat/join/" + chatDto.getCode());

  simpMessagingTemplate.convertAndSend("/topic/chat/" + chatDto.getCode(), chatDto);
  simpMessagingTemplate.convertAndSend("/topic/all/" + target.getUsername(), messageDto);
  simpMessagingTemplate.convertAndSend("/topic/notification/" + target.getUsername(), notification);
}

📌 음성 채팅 기능

  • 음성 채팅 요청 후 수락 시 음성 채팅 연결

  • 음성 채팅 1분 제한(1분 초과 시 자동으로 연결 해제)

1. 보이스 채팅 요청 버튼 클릭 이벤트 처리

$("#voiceChatBtn").on('click', function () {
    // 사용자가 보이스 채팅 요청을 확인하면, 서버로 요청 메시지를 전송
    if (confirm(targetNick + "님에게 보이스 채팅을 요청하시겠습니까?")) {
        stompClient.send("/app/all/" + target, {}, JSON.stringify({
            type: "requestVoice",
            sender: user,
            senderNick: nickName
        }));
    }
});

2. 서버로부터의 메시지 수선 및 보이스 채팅 관련 처리

// 서버로부터의 메시지를 수신하고, 각 메시지 유형에 따라 적절한 처리 수행
stompClient.subscribe("/topic/all/" + $("#hiddenUserName").val(), function (message) {
    const data = JSON.parse(message.body);

    // 다른 사용자로부터의 보이스 채팅 요청 처리
    if (data.type === "requestVoice") {
        if (confirm(data.senderNick + "님이 보이스 채팅을 요청했습니다. 수락하시겠습니까?")) {
            // 보이스 채팅 수락: WebRTC 연결 설정 및 서버에 수락 메시지 전송
            stompClient.send("/app/all/" + data.sender, {}, JSON.stringify({
                type: "acceptVoiceRequest",
                sender: $("#hiddenUserName").val()
            }));

            targetPeer = data.sender;
            pc = new RTCPeerConnection(iceServers);

            pc.addEventListener('icecandidate', function (event) {
                if (event.candidate) {
                    stompClient.send("/app/peer/candidate/" + targetPeer, {}, JSON.stringify(event.candidate));
                }
            });

            pc.ontrack = function (event) {
                if (event.track.kind === 'audio') {
                    const remoteAudio = document.getElementById('voiceChatPlayer');
                    remoteAudio.srcObject = event.streams[0];
                }
            };
        } else {
            // 보이스 채팅 거절: 서버에 거절 메시지 전송
            stompClient.send("/app/all/" + data.sender, {}, JSON.stringify({
                type: "refuse",
                content: data.senderNick + "님이 보이스 채팅 요청을 거절하였습니다."
            }));
        }
    }
    // 기타 보이스 채팅 관련 메시지 처리
});

📌 광장 기능

  • 광장 입퇴장 알림
  • 광장 화면이 활성 탭일 때 초록색으로 활성화 표시, 백그라운드 탭일 때 노란색 아이콘과 블러 처리
  • 키보드 방향 키나 마우스 좌클릭으로 유저 아이콘 이동 가능
    이미지1 이미지2
  • 채팅 기능(말풍선 색 커스텀 가능)
    이미지1 이미지2
  • 광장 VIEW
<div class="card" id="plazaBoard">
  <div th:each="online : ${onlinePlazaUsers}" th:class="|avatar ${online.username}|"
       th:classappend="${online.plazaFocus.equals('focus')} ? 'statusFocus' : 'statusBlur'">
    <div class="talkBox card" th:style="|background:${online.chatBackground}; color:${online.chatColor}|"></div>
    <img class="userImage" th:src="${online.image}">
    <div class="d-flex">
      <div class="nickname" th:text="${online.nickName}"></div>
    </div>
    <div class="status" th:classappend="${online.plazaFocus.equals('focus')} ? 'focus' : 'blur'"></div>
    <input type="hidden" th:value="${online.locationTop}">
    <input type="hidden" th:value="${online.locationLeft}">
  </div>
  <div id="plazaChatContainer">
    <div id="plazaChatBoard"></div>
    <form id="plazaChatForm">
      <input type="text" class="form-control" id="plazaChatParam">
    </form>
  </div>
</div>
<script th:inline="javascript">
  const user = [[${user.username}]];
  const nick = [[${user.nickName}]];
  const manager = [[${plaza.manager.username}]];
  const code = [[${plaza.code}]];
  let background = [[${plaza.background}]];
</script>

1. 웹소켓 연결 및 이벤트 구독

const socket = new SockJS("/ws");
const stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
  // 사용자가 광장에 참여할 때의 처리
  stompClient.subscribe("/topic/plaza/join/" + code, function (message) {
    const data = JSON.parse(message.body);
    if ($("." + data.username).length === 0) {
      createAvatar(data);
      createParticipantList(data);
    }
  });
  // 사용자가 광장에서 나갈 때의 처리
  stompClient.subscribe("/topic/plaza/exit/" + code, function (message) {
    $("." + message.body).remove();
    $(".p_" + message.body).remove();
  });
  // 기타 이벤트 구독
});

2. 사용자 위치 이동 및 메시지 표시

function moveLocation(data) {
  const element = $("." + data.sender);
  const parentWidth = parseInt(element.parent().css("width"));
  const parentHeight = parseInt(element.parent().css("height"));
  const top = parseInt(data.top) * parentHeight / 100 + 5;
  const left = parseInt(data.left) * parentWidth / 100 + 5;
  element.css({
    top: top,
    left: left
  });
}

function showMessage(data) {
  const element = $("." + data.sender + " > .talkBox");
  const boardMessage = $("<p class='normalMessage'></p>");
  boardMessage.text(data.nick + ": " + data.content);

  element.text(data.content);
  element.show();

  if (timer.has(data.sender)) clearTimeout(timer.get(data.sender));
  timer.set(data.sender, setTimeout(function () {
    element.hide();
  }, 3000));

  $("#plazaChatBoard").append(boardMessage);

  const chatBoard = document.getElementById("plazaChatBoard");
  chatBoard.scrollTop = chatBoard.scrollHeight;
}

3. 사용자 상호작용 처리

$(window).blur(function () {
    stompClient.send("/app/plaza/focus/" + code, {}, JSON.stringify({
        sender: user,
        status: "blur"
    }));
});

$(window).focus(function () {
    stompClient.send("/app/plaza/focus/" + code, {}, JSON.stringify({
        sender: user,
        status: "focus"
    }));
});

$("#plazaBoard").click(function (e) {
    const offset = $(this).offset();
    const clickX = e.pageX - offset.left;
    const clickY = e.pageY - offset.top;
    sendLocationOnClick(clickX - 100, clickY - 25);
});

4. 광장 관리 기능

$("#participant-tab-pane").on("click", ".fire", function () {
    if (confirm($(this).data("nick") + "님을 강퇴하시겠습니까?")) {
        stompClient.send("/app/plaza/fire/" + code, {}, $(this).data("value"));
    }
});

$("#plazaManageBtn").click(function () {
    $("#plazaManageModal").modal("show");
});

📌 매칭 기능

  • 내 위치 주변 매칭
  • 이상형 선택 후 이상형에 부합하는 유저 추천 매칭
  • 회원 가입할 때 입력한 유저의 성향과 일치하는 회원 추천
  • 아라큐(매칭) 신청 시 매칭 상태 확인

  • 수락 시 채팅 신청, 연애관 테스트, 신고 기능 활성화

1. 아라큐 요청 이벤트

$('.araQBtn').on('click', function () {
  var nickname = $(this).data("nick");
  var username = $(this).data("user");
  // 사용자에게 아라큐 요청 전송 확인
  if (confirm(nickname + "님에게 아라큐 요청을 보냅니다!")) {
    // 서버에 아라큐 요청 전송
    $.ajax({
      url: "/like/request",
      type: "POST",
      headers: {
        [csrfHeader]: csrfToken
      },
      contentType: "text/plain",
      data: username,
      success: function (response) {
        // 요청 결과에 대한 알림
        alert(response);
        location.reload();
      },
      error: function (error) {
        // 오류 처리
        console.error(error);
      }
    });
  }
});

2. 아라큐 요청 처리

@PostMapping("/request")
@ResponseBody
public String like(@RequestBody String username, Principal principal) {
    // 현재 사용자와 '좋아요' 대상 사용자 정보 조회
    SiteUser user = this.userService.getByUsername(principal.getName());
    SiteUser likedUser = this.userService.getByUsername(username);

    // 이미 요청이 있었는지 확인
    if (this.likeService.getListForCheck(likedUser, user) != null) {
        return "이미 " + likedUser.getNickName() + "님께 아라큐 요청을 받았습니다. 매칭 상태를 확인해주세요.";
    }
    else if (this.likeService.getListForCheck(user, likedUser) != null) {
        return "이미 " + likedUser.getNickName() + "님께 아라큐 요청을 보냈습니다. 매칭 상태를 확인해주세요.";
    }

    // 아라큐 신청권 확인 및 사용
    else if (user.getAraQPass() == 0) return "아라큐 신청권이 필요합니다.";
    this.likeService.likeUser(user, likedUser);
    this.userService.useAraQPass(user);

    // 알림 생성 및 전송
    Notification notification = new Notification("아라큐 요청", user.getNickName() + "님이 아라큐 요청을 보냈습니다.",
            user.getUsername(), username, "#");
    simpMessagingTemplate.convertAndSend("/topic/notification/" + username, notification);

    return "성공적으로 요청되었습니다.";
}
post-custom-banner

0개의 댓글