From a2215e1ab8a87550eb858730d9bfc60cec416807 Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 27 Mar 2026 21:18:23 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 9 ++- .../server/domain/meeting/entity/Meeting.java | 23 +++++-- .../domain/meeting/entity/MeetingMember.java | 18 +++++- .../meeting/entity/MeetingMemberId.java | 5 ++ .../repository/MeetingMemberRepository.java | 8 +++ .../meeting/repository/MeetingRepository.java | 10 +++ .../service/MeetingCommandService.java | 63 +++++++++++++++++++ .../service/RealTimeMeetingService.java | 28 +++++++++ .../domain/team/exception/TeamErrorCode.java | 38 +++++++++++ .../team/exception/TeamNotFoundException.java | 11 ++++ .../team/repository/TeamRepository.java | 7 +++ .../domain/team/service/TeamUseCase.java | 20 ++++++ .../user/exception/MemberErrorStatus.java | 39 ++++++++++++ .../exception/MemberNotFoundException.java | 13 ++++ .../domain/user/service/MemberUseCase.java | 21 +++++++ .../apiPayload/code/status/ErrorStatus.java | 4 +- .../exception/ParameterRequiredException.java | 9 +++ 17 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java create mode 100644 src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java create mode 100644 src/main/java/com/whylog/server/domain/team/exception/TeamNotFoundException.java create mode 100644 src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java create mode 100644 src/main/java/com/whylog/server/domain/team/service/TeamUseCase.java create mode 100644 src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java create mode 100644 src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java create mode 100644 src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java create mode 100644 src/main/java/com/whylog/server/global/apiPayload/exception/ParameterRequiredException.java diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 36de86c..73225fd 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -2,8 +2,11 @@ import com.whylog.server.domain.meeting.dto.MeetingRequest; import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,6 +27,8 @@ @Tag(name = "Meeting", description = "회의 관련 API") public class MeetingController { + private final MeetingCommandService meetingCommandService; + @GetMapping("/teams/{teamId}/meetings") @Operation(summary = "회의 목록 조회 API", description = "특정 팀의 회의 목록을 조회하는 API입니다. (status: ONGOING/COMPLETED)") public ApiResponse> getMeetings( @@ -40,10 +45,12 @@ public ApiResponse joinMeeting( } @PostMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 생성 API", description = "새로운 회의를 생성하는 API입니다.") + @Operation(summary = "회의 생성 API", description = "새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다.") public ApiResponse createMeeting( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody MeetingRequest.MeetingCreateDTO request) { + meetingCommandService.makeMeetingRoom(memberId, teamId, request); return ApiResponse.onSuccess(null); } diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 478f1e8..6307788 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -1,9 +1,8 @@ package com.whylog.server.domain.meeting.entity; -import com.whylog.server.domain.decision.entity.Decision; +import com.whylog.server.domain.meeting.dto.MeetingRequest; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -12,13 +11,10 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -46,6 +42,21 @@ public class Meeting extends BaseEntity { @Column(name = "end_date_time") private LocalDateTime endDateTime; + @Builder + private Meeting(String name, Team team) { + this.name = name; + this.team = team; + this.startDateTime = LocalDateTime.now(); + this.endDateTime = null; + } + + public static Meeting create(MeetingRequest.MeetingCreateDTO dto, Team team) { + return Meeting.builder() + .name(dto.getName()) + .team(team) + .build(); + } + // @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) // private final List meetingMembers = new ArrayList<>(); // diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java index d6c3567..15a2f07 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMember.java @@ -12,6 +12,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.MapsId; import jakarta.persistence.Table; +import lombok.Builder; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -38,5 +39,20 @@ public class MeetingMember extends BaseEntity { @Enumerated(EnumType.STRING) private MeetingRole role; - + @Builder + private MeetingMember(MeetingMemberId id, Meeting meeting, Member member, MeetingRole role) { + this.id = id; + this.meeting = meeting; + this.member = member; + this.role = role; + } + + public static MeetingMember create(Meeting meeting, Member member, MeetingRole role) { + return MeetingMember.builder() + .id(new MeetingMemberId(meeting.getId(), member.getId())) + .meeting(meeting) + .member(member) + .role(role) + .build(); + } } diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMemberId.java b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMemberId.java index f2e87f7..5c807f1 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMemberId.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/MeetingMemberId.java @@ -19,4 +19,9 @@ public class MeetingMemberId implements Serializable { @Column(name = "member_id") private Long memberId; + + public MeetingMemberId(Long meetingId, Long memberId) { + this.meetingId = meetingId; + this.memberId = memberId; + } } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java new file mode 100644 index 0000000..4b7ac57 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java @@ -0,0 +1,8 @@ +package com.whylog.server.domain.meeting.repository; + +import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.entity.MeetingMemberId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MeetingMemberRepository extends JpaRepository { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java new file mode 100644 index 0000000..6e6893a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.meeting.repository; + +import com.whylog.server.domain.meeting.entity.Meeting; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MeetingRepository extends JpaRepository { + + + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java new file mode 100644 index 0000000..8bdb7e1 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -0,0 +1,63 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.dto.MeetingRequest; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.enums.MeetingRole; +import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MeetingCommandService { + + private final MeetingMemberRepository meetingMemberRepository; + private final MeetingRepository meetingRepository; + + private final MemberUseCase memberUseCase; + private final TeamUseCase teamUseCase; + + private final RealTimeMeetingService realTimeMeetingService; + + /* + 회의를 생성합니다. + + 회의를 생성하여 endDateTime이 null인 회의를 저장합니다. + - endDateTime == null : 진행 중인 회의를 의미합니다. + + 회의와 회의참여자 정보가 함께 저장됩니다. + */ + @Transactional + public void makeMeetingRoom(Long memberId, Long teamId, MeetingRequest.MeetingCreateDTO requestDTO){ + + // null 체크 + if(teamId == null) throw new ParameterRequiredException(); + + // API 호출한 유저 정보 조회 + Member member = memberUseCase.findMemberById(memberId); + + // 팀 조회 + Team team = teamUseCase.findTeamById(teamId); + + // Meeting, MeetingMember 같이 저장 + Meeting meeting = Meeting.create(requestDTO, team); + Meeting savedMeeting = meetingRepository.save(meeting); + MeetingMember meetingMember = MeetingMember.create(savedMeeting, member, MeetingRole.OWNER); + meetingMemberRepository.save(meetingMember); + + // 실시간 회의 정보 갱신 + realTimeMeetingService.addMember(savedMeeting.getId(), member.getId()); // 현재 참여자 추가 + + // TODO: 회의 분석 시작 + + } + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java b/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java new file mode 100644 index 0000000..8724d78 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java @@ -0,0 +1,28 @@ +package com.whylog.server.domain.meeting.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * 실시간 회의 정보입니다. + */ +@Service +@RequiredArgsConstructor +public class RealTimeMeetingService { + + // meetingId, memberId 순서쌍 + private final ConcurrentHashMap currentMeetingMemberId = new ConcurrentHashMap<>(); + + // 실시간 회의 팀원 참여 + public void addMember(Long meetingId, Long memberId) { + // TODO: 실시간 회의 정보 업데이트 추가 + } + + // 실시간 회의 팀원 퇴장 + + // 실시간 회의 종료 + + +} diff --git a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java new file mode 100644 index 0000000..7e889ee --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java @@ -0,0 +1,38 @@ +package com.whylog.server.domain.team.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum TeamErrorCode implements BaseErrorCode { + + TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM_404", "존재하지 않는 팀입니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/team/exception/TeamNotFoundException.java b/src/main/java/com/whylog/server/domain/team/exception/TeamNotFoundException.java new file mode 100644 index 0000000..8bf7766 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamNotFoundException.java @@ -0,0 +1,11 @@ +package com.whylog.server.domain.team.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class TeamNotFoundException extends GeneralException { + + public TeamNotFoundException() { + super(TeamErrorCode.TEAM_NOT_FOUND); + } + +} diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java new file mode 100644 index 0000000..5e413e3 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java @@ -0,0 +1,7 @@ +package com.whylog.server.domain.team.repository; + +import com.whylog.server.domain.team.entity.Team; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamRepository extends JpaRepository { +} diff --git a/src/main/java/com/whylog/server/domain/team/service/TeamUseCase.java b/src/main/java/com/whylog/server/domain/team/service/TeamUseCase.java new file mode 100644 index 0000000..93a9767 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/service/TeamUseCase.java @@ -0,0 +1,20 @@ +package com.whylog.server.domain.team.service; + +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.exception.TeamNotFoundException; +import com.whylog.server.domain.team.repository.TeamRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TeamUseCase { + + private final TeamRepository teamRepository; + + public Team findTeamById(Long id){ + return teamRepository.findById(id) + .orElseThrow(TeamNotFoundException::new); + } + +} diff --git a/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java b/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java new file mode 100644 index 0000000..2ba1094 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/exception/MemberErrorStatus.java @@ -0,0 +1,39 @@ +package com.whylog.server.domain.user.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MemberErrorStatus implements BaseErrorCode { + + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER_404", "찾을 수 없는 유저입니다.") + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + +} diff --git a/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java b/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java new file mode 100644 index 0000000..7ad6262 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/exception/MemberNotFoundException.java @@ -0,0 +1,13 @@ +package com.whylog.server.domain.user.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MemberNotFoundException extends GeneralException { + + public MemberNotFoundException() { + super(MemberErrorStatus.MEMBER_NOT_FOUND); + } + + + +} diff --git a/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java new file mode 100644 index 0000000..8d821a2 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/service/MemberUseCase.java @@ -0,0 +1,21 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.exception.MemberNotFoundException; +import com.whylog.server.domain.user.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MemberUseCase { + + private final MemberRepository memberRepository; + + // id로 member 조회 + public Member findMemberById(Long id){ + return memberRepository.findById(id) + .orElseThrow(MemberNotFoundException::new); + } + +} diff --git a/src/main/java/com/whylog/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/whylog/server/global/apiPayload/code/status/ErrorStatus.java index 51bc58a..c0d7b70 100644 --- a/src/main/java/com/whylog/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/whylog/server/global/apiPayload/code/status/ErrorStatus.java @@ -15,7 +15,9 @@ public enum ErrorStatus implements BaseErrorCode { _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."), - _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."); + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + _PARAMETER_REQUIRED(HttpStatus.BAD_REQUEST, "COMMON404", "파라미터 내놔."), + ; private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/whylog/server/global/apiPayload/exception/ParameterRequiredException.java b/src/main/java/com/whylog/server/global/apiPayload/exception/ParameterRequiredException.java new file mode 100644 index 0000000..fd34dd9 --- /dev/null +++ b/src/main/java/com/whylog/server/global/apiPayload/exception/ParameterRequiredException.java @@ -0,0 +1,9 @@ +package com.whylog.server.global.apiPayload.exception; + +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; + +public class ParameterRequiredException extends GeneralException { + public ParameterRequiredException() { + super(ErrorStatus._PARAMETER_REQUIRED); + } +} From f6d8c5ee2bfa4b50d107e347c5866ae62fd2d49d Mon Sep 17 00:00:00 2001 From: junyong Date: Fri, 27 Mar 2026 23:44:16 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=ED=89=B5?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=20=20-=20=EC=9B=B9=EC=86=8C=EC=BC=93=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(=20handler,=20interceptor,=20config=20)=20=20=20-?= =?UTF-8?q?=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=9A=8C=EC=9D=98=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4(=20=ED=9A=8C=EC=9D=98=EB=B0=A9=20=EC=A0=95=EB=B3=B4,?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=EC=A0=95=EB=B3=B4=20)?= =?UTF-8?q?=EB=A5=BC=20ConcurrentHashMap=EC=9C=BC=EB=A1=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=20=20-=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=ED=9A=8C?= =?UTF-8?q?=EC=9D=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81(service)=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=20=20-=20=ED=9A=8C=EC=9D=98=20=EC=9E=85?= =?UTF-8?q?=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84=20MeetingController=20->?= =?UTF-8?q?=20MeetingCommandService?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../meeting/controller/MeetingController.java | 11 +- .../service/MeetingCommandService.java | 16 +- .../service/RealTimeMeetingService.java | 28 --- .../meeting/socket/MeetingParticipant.java | 13 ++ .../socket/MeetingSocketAuthInterceptor.java | 114 ++++++++++ .../meeting/socket/MeetingSocketHandler.java | 203 ++++++++++++++++++ .../socket/MeetingSocketRoomService.java | 173 +++++++++++++++ .../socket/message/ConnectedMessage.java | 29 +++ .../meeting/socket/message/ErrorMessage.java | 8 + .../socket/message/MeetingMessageType.java | 40 ++++ .../socket/message/MeetingSocketMessage.java | 12 ++ .../socket/message/MeetingTextMessage.java | 38 ++++ .../message/ParticipantJoinedMessage.java | 22 ++ .../message/ParticipantLeftMessage.java | 11 + .../socket/message/ParticipantSummary.java | 15 ++ .../meeting/socket/message/RosterMessage.java | 23 ++ .../repository/MeetingRoomRepository.java | 34 +++ .../MeetingSocketRoomRepository.java | 29 +++ .../socket/util/WebSocketTimeUtil.java | 13 ++ .../server/global/config/SecurityConfig.java | 1 + .../server/global/config/WebSocketConfig.java | 38 ++++ .../global/util/json/JsonConverter.java | 32 +++ 23 files changed, 869 insertions(+), 35 deletions(-) delete mode 100644 src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/MeetingParticipant.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/ConnectedMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/ErrorMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingSocketMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingTextMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantJoinedMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantLeftMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantSummary.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/RosterMessage.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java create mode 100644 src/main/java/com/whylog/server/global/config/WebSocketConfig.java create mode 100644 src/main/java/com/whylog/server/global/util/json/JsonConverter.java diff --git a/build.gradle b/build.gradle index 9040cd9..3a0f8e7 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-websocket' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 73225fd..68752b3 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -45,13 +45,18 @@ public ApiResponse joinMeeting( } @PostMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 생성 API", description = "새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다.") + @Operation(summary = "회의 생성 API", description = """ + + 새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다. + 해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다. + + """) public ApiResponse createMeeting( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody MeetingRequest.MeetingCreateDTO request) { - meetingCommandService.makeMeetingRoom(memberId, teamId, request); - return ApiResponse.onSuccess(null); + MeetingResponse.MeetingCreateResponseDTO result = meetingCommandService.makeMeetingRoom(memberId, teamId, request); + return ApiResponse.onSuccess(result); } @PatchMapping("/meetings/{meetingId}/end") diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index 8bdb7e1..c859c2c 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -1,11 +1,13 @@ package com.whylog.server.domain.meeting.service; import com.whylog.server.domain.meeting.dto.MeetingRequest; +import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingRole; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.domain.team.service.TeamUseCase; import com.whylog.server.domain.user.entity.Member; @@ -25,7 +27,7 @@ public class MeetingCommandService { private final MemberUseCase memberUseCase; private final TeamUseCase teamUseCase; - private final RealTimeMeetingService realTimeMeetingService; + private final MeetingSocketRoomService meetingSocketRoomService; /* 회의를 생성합니다. @@ -36,7 +38,7 @@ public class MeetingCommandService { 회의와 회의참여자 정보가 함께 저장됩니다. */ @Transactional - public void makeMeetingRoom(Long memberId, Long teamId, MeetingRequest.MeetingCreateDTO requestDTO){ + public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, Long teamId, MeetingRequest.MeetingCreateDTO requestDTO){ // null 체크 if(teamId == null) throw new ParameterRequiredException(); @@ -53,11 +55,17 @@ public void makeMeetingRoom(Long memberId, Long teamId, MeetingRequest.MeetingCr MeetingMember meetingMember = MeetingMember.create(savedMeeting, member, MeetingRole.OWNER); meetingMemberRepository.save(meetingMember); - // 실시간 회의 정보 갱신 - realTimeMeetingService.addMember(savedMeeting.getId(), member.getId()); // 현재 참여자 추가 + // 실시간 회의 추가 + meetingSocketRoomService.createRoomIfAbsent(savedMeeting.getId()); // 현재 참여자 추가 // TODO: 회의 분석 시작 + // dto 생성 후 반환 + return MeetingResponse.MeetingCreateResponseDTO.builder() + .meetingId(savedMeeting.getId()) + .name(savedMeeting.getName()) + .startDateTime(savedMeeting.getStartDateTime()) + .build(); } } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java b/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java deleted file mode 100644 index 8724d78..0000000 --- a/src/main/java/com/whylog/server/domain/meeting/service/RealTimeMeetingService.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.whylog.server.domain.meeting.service; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -import java.util.concurrent.ConcurrentHashMap; - -/** - * 실시간 회의 정보입니다. - */ -@Service -@RequiredArgsConstructor -public class RealTimeMeetingService { - - // meetingId, memberId 순서쌍 - private final ConcurrentHashMap currentMeetingMemberId = new ConcurrentHashMap<>(); - - // 실시간 회의 팀원 참여 - public void addMember(Long meetingId, Long memberId) { - // TODO: 실시간 회의 정보 업데이트 추가 - } - - // 실시간 회의 팀원 퇴장 - - // 실시간 회의 종료 - - -} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingParticipant.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingParticipant.java new file mode 100644 index 0000000..cae5df0 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingParticipant.java @@ -0,0 +1,13 @@ +package com.whylog.server.domain.meeting.socket; + +import org.springframework.web.socket.WebSocketSession; + +// 웹소켓 세션 하나에 매핑되는 회의 참가자 정보를 담는 레코드입니다. +public record MeetingParticipant( + String sessionId, + Long memberId, + String name, + Long meetingId, + WebSocketSession socketSession +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java new file mode 100644 index 0000000..fb3a341 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java @@ -0,0 +1,114 @@ +package com.whylog.server.domain.meeting.socket; + +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.auth.jwt.provider.JwtTokenProvider; +import com.whylog.server.global.auth.jwt.provider.JwtValidationType; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; + +// 웹소켓 핸드셰이크 시 쿼리 파라미터의 회의 ID와 JWT를 검증하고 세션 속성에 담습니다. +@Component +@RequiredArgsConstructor +public class MeetingSocketAuthInterceptor implements HandshakeInterceptor { + + public static final String MEETING_ID_ATTRIBUTE = "meetingId"; + public static final String MEMBER_ID_ATTRIBUTE = "memberId"; + public static final String MEMBER_NAME_ATTRIBUTE = "memberName"; + + private final JwtTokenProvider jwtTokenProvider; + private final MemberUseCase memberUseCase; + + // 웹소켓 연결 전에 meetingId, accessToken, 표시 이름을 확인하고 세션 속성을 초기화합니다. + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes + ) { + MultiValueMap queryParams = UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams(); + + Long meetingId = parseMeetingId(queryParams.getFirst("meetingId")); + String token = decode(queryParams.getFirst("accessToken")); + + if (meetingId == null || !StringUtils.hasText(token)) { + setStatus(response, HttpServletResponse.SC_BAD_REQUEST); + return false; + } + + JwtValidationType validationType = jwtTokenProvider.validateToken(token); + if (validationType != JwtValidationType.VALID_JWT) { + setStatus(response, HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + Long memberId = jwtTokenProvider.getMemberIdFromJwt(token); + Member member = memberUseCase.findMemberById(memberId); + + attributes.put(MEETING_ID_ATTRIBUTE, meetingId); + attributes.put(MEMBER_ID_ATTRIBUTE, memberId); + attributes.put(MEMBER_NAME_ATTRIBUTE, resolveName(queryParams, member)); + return true; + } + + // 핸드셰이크 이후 추가 작업은 없어서 비워 둡니다. + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception + ) { + } + + // 문자열 meetingId를 Long 타입으로 안전하게 변환합니다. + private Long parseMeetingId(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + + try { + return Long.valueOf(value); + } catch (NumberFormatException exception) { + return null; + } + } + + // 클라이언트가 넘긴 이름이 있으면 사용하고, 없으면 회원 이메일을 기본 이름으로 사용합니다. + private String resolveName(MultiValueMap queryParams, Member member) { + return Optional.ofNullable(queryParams.getFirst("name")) + .map(this::decode) + .filter(StringUtils::hasText) + .orElse(member.getEmail()); + } + + // URL 인코딩된 쿼리 파라미터 값을 디코딩합니다. + private String decode(String value) { + if (!StringUtils.hasText(value)) { + return value; + } + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + // 핸드셰이크 실패 시 HTTP 상태 코드를 응답에 기록합니다. + private void setStatus(ServerHttpResponse response, int statusCode) { + if (response instanceof ServletServerHttpResponse servletResponse) { + servletResponse.getServletResponse().setStatus(statusCode); + } + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java new file mode 100644 index 0000000..73c19b4 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketHandler.java @@ -0,0 +1,203 @@ +package com.whylog.server.domain.meeting.socket; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.whylog.server.domain.meeting.socket.message.*; +import com.whylog.server.global.util.json.JsonConverter; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import java.nio.ByteBuffer; +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +// 회의 웹소켓 연결의 입장, 퇴장, 텍스트 메시지, 오디오 바이너리 중계를 처리합니다. +@Component +@RequiredArgsConstructor +public class MeetingSocketHandler extends BinaryWebSocketHandler { + + private final MeetingSocketRoomService meetingSocketRoomService; + + // 웹소켓 연결 직후 참가자를 방에 등록하고 현재 참여자 목록과 입장 이벤트를 전파합니다. + @Override + public void afterConnectionEstablished(@NonNull WebSocketSession session) throws Exception { + MeetingParticipant participant = createParticipant(session); + if (!meetingSocketRoomService.existsMeeting(participant.meetingId())) { + session.close(CloseStatus.BAD_DATA); + return; + } + + meetingSocketRoomService.join(participant); + + ConnectedMessage connectedMessage = ConnectedMessage.create( + participant, participantSummaries(participant.meetingId()) + ); + session.sendMessage(new TextMessage(JsonConverter.toJson(connectedMessage))); + + broadcastRoster(participant.meetingId()); + meetingSocketRoomService.broadcastText( + participant.meetingId(), + JsonConverter.toJson(ParticipantJoinedMessage.create(participant)) + ); + } + + // 채팅, 자막, 시그널링 같은 텍스트 기반 회의 메시지를 파싱해 브로드캐스트 또는 단건 전달합니다. + @Override + protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) { + MeetingParticipant participant = createParticipant(session); + MeetingSocketMessage incoming; + try { + incoming = JsonConverter.readValue(message, MeetingSocketMessage.class); + } catch (JsonProcessingException exception) { + throw new IllegalArgumentException("Invalid websocket message payload", exception); + } + + MeetingMessageType type = incoming.type(); + if (type == null) { + sendError(session, "Unsupported message type: null"); + return; + } + + switch (type) { + case CHAT, SPEECH, AUDIO_TEXT -> meetingSocketRoomService.broadcastText( + participant.meetingId(), + JsonConverter.toJson(MeetingTextMessage.createTextMessage( + participant, + type, + null, + Optional.ofNullable(incoming.text()).orElse(""), + incoming.payload() + )) + ); + case OFFER, ANSWER, ICE -> { + if (incoming.targetMemberId() == null) { + sendError(session, "targetMemberId is required for " + type.value()); + return; + } + + meetingSocketRoomService.sendToMember( + participant.meetingId(), + incoming.targetMemberId(), + JsonConverter.toJson(MeetingTextMessage.createTextMessage( + participant, + type, + incoming.targetMemberId(), + null, + incoming.payload() + )) + ); + } + default -> sendError(session, "Unsupported message type: " + type.value()); + } + } + + // 클라이언트가 보낸 오디오 청크를 발신자 memberId와 함께 다른 참가자들에게 중계합니다. + @Override + protected void handleBinaryMessage(@NonNull WebSocketSession session, BinaryMessage message) throws Exception { + MeetingParticipant participant = createParticipant(session); + ByteBuffer outbound = ByteBuffer.allocate(Long.BYTES + message.getPayloadLength()); + outbound.putLong(participant.memberId()); + outbound.put(message.getPayload().asReadOnlyBuffer()); + outbound.flip(); + + meetingSocketRoomService.broadcastAudio(participant.meetingId(), participant.sessionId(), outbound); + } + + // 정상 종료된 세션을 회의방에서 제거하고 퇴장 이벤트를 전파합니다. + @Override + public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull CloseStatus status) throws Exception { + removeParticipant(session); + } + + // 전송 오류가 발생한 세션을 정리하고 필요하면 서버 에러 상태로 연결을 닫습니다. + @Override + public void handleTransportError(@NonNull WebSocketSession session, @NonNull Throwable exception) throws Exception { + removeParticipant(session); + if (session.isOpen()) { + session.close(CloseStatus.SERVER_ERROR); + } + } + + // 세션을 회의방에서 제거하고 퇴장 이벤트 및 갱신된 roster를 방송합니다. + private void removeParticipant(WebSocketSession session) { + Long meetingId = getAttribute(session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); + if (meetingId == null) { + return; + } + + MeetingParticipant removed = meetingSocketRoomService.leave(meetingId, session.getId()); + if (removed == null) { + return; + } + + meetingSocketRoomService.broadcastText(meetingId, JsonConverter.toJson(new ParticipantLeftMessage( + MeetingMessageType.PARTICIPANT_LEFT, + meetingId, + removed.memberId(), + removed.name(), + now() + ))); + broadcastRoster(meetingId); + } + + // 현재 회의 참가자 목록을 모든 클라이언트에 전파합니다. + private void broadcastRoster(Long meetingId) { + meetingSocketRoomService.broadcastText( + meetingId, + JsonConverter.toJson(RosterMessage.create(meetingId, participantSummaries(meetingId))) + ); + } + + // 핸드셰이크에서 저장한 속성으로 참가자 정보를 복원합니다. + private MeetingParticipant createParticipant(WebSocketSession session) { + Long meetingId = getAttribute(session, MeetingSocketAuthInterceptor.MEETING_ID_ATTRIBUTE, Long.class); + Long memberId = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_ID_ATTRIBUTE, Long.class); + String name = getAttribute(session, MeetingSocketAuthInterceptor.MEMBER_NAME_ATTRIBUTE, String.class); + + if (meetingId == null || memberId == null || !StringUtils.hasText(name)) { + throw new IllegalStateException("WebSocket participant attributes are missing"); + } + + return new MeetingParticipant(session.getId(), memberId, name, meetingId, session); + } + + // 잘못된 요청이나 지원하지 않는 타입에 대한 에러 메시지를 클라이언트에 보냅니다. + private void sendError(WebSocketSession session, String message) { + try { + session.sendMessage(new TextMessage( + JsonConverter.toJson( + new ErrorMessage( + MeetingMessageType.ERROR, message + ) + ))); + } catch (Exception exception) { + throw new IllegalStateException("Failed to send websocket error message", exception); + } + } + + // 웹소켓 세션 속성에서 지정한 타입의 값을 안전하게 꺼냅니다. + private T getAttribute(WebSocketSession session, String key, Class type) { + Object value = session.getAttributes().get(key); + if (type.isInstance(value)) { + return type.cast(value); + } + return null; + } + + private List participantSummaries(Long meetingId) { + return meetingSocketRoomService.listParticipants(meetingId); + } + + // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. + private String now() { + return Instant.now().toString(); + } + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java new file mode 100644 index 0000000..8767f97 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -0,0 +1,173 @@ +package com.whylog.server.domain.meeting.socket; + +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.meeting.socket.message.ParticipantSummary; +import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository; +import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketMessage; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.function.Function; + +// 회의별 참가자 세션 저장소 역할을 하며 텍스트/오디오 메시지 전달을 담당합니다. +@Service +@RequiredArgsConstructor +public class MeetingSocketRoomService { + + private final MeetingRepository meetingRepository; + private final MeetingSocketRoomRepository meetingSocketRoomRepository; + + // 웹소켓으로 연결하려는 회의가 DB에 실제로 존재하는지 확인합니다. + @Transactional(readOnly = true) + public boolean existsMeeting(Long meetingId) { + return meetingRepository.existsById(meetingId); + } + + // 메모리 상에 회의방 엔트리가 없으면 새로 생성합니다. + public void createRoomIfAbsent(Long meetingId) { + meetingSocketRoomRepository.getOrCreate(meetingId); + } + + // 회의 종료 등으로 더 이상 사용하지 않는 회의방 엔트리를 제거합니다. + public void closeRoom(Long meetingId) { + meetingSocketRoomRepository.delete(meetingId); + } + + // 참가자 세션을 해당 회의방에 입장시킵니다. + public void join(MeetingParticipant participant) { + MeetingRoomRepository room = getOrCreateRoom(participant.meetingId()); + room.addParticipant(participant); + } + + // 참가자 세션을 회의방에서 제거하고, 방이 비면 엔트리도 함께 정리합니다. + public MeetingParticipant leave(Long meetingId, String sessionId) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return null; + } + + MeetingParticipant removed = room.removeParticipant(sessionId); + if (room.isEmpty()) { + closeRoom(meetingId); + } + return removed; + } + + // 클라이언트에 보여 줄 현재 참가자 목록을 이름순으로 반환합니다. + public List listParticipants(Long meetingId) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return List.of(); + } + + return room.participants().stream() + .map(ParticipantSummary::create) + .sorted(Comparator.comparing(ParticipantSummary::name)) + .toList(); + } + + // 텍스트 메시지를 회의방의 모든 참가자에게 브로드캐스트합니다. + public void broadcastText(Long meetingId, String payload) { + broadcast( + meetingId, + participant -> new TextMessage(payload), + participant -> false); + } + + // 발신자를 제외한 나머지 참가자들에게 오디오 바이너리 청크를 전달합니다. + public void broadcastAudio(Long meetingId, String senderSessionId, ByteBuffer payload) { + broadcast( + meetingId, + participant -> new BinaryMessage(payload.asReadOnlyBuffer()), + participant -> participant.sessionId().equals(senderSessionId) + ); + } + + // 특정 대상 참가자 한 명에게만 시그널링 메시지를 전달합니다. + public void sendToMember(Long meetingId, Long targetMemberId, String payload) { + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return; + } + + room.participants().stream() + .filter(participant -> participant.memberId().equals(targetMemberId)) + .findFirst() + .ifPresent(participant -> { + if (!participant.socketSession().isOpen()) { + leave(meetingId, participant.sessionId()); + return; + } + + try { + participant.socketSession().sendMessage(new TextMessage(payload)); + } catch (IOException exception) { + leave(meetingId, participant.sessionId()); + } + }); + } + + // 회의방이 이미 있으면 반환하고, 없으면 새로 생성해서 반환합니다. + private MeetingRoomRepository getOrCreateRoom(Long meetingId) { + return meetingSocketRoomRepository.getOrCreate(meetingId); + } + + // 메모리에 올라와 있는 회의방 저장소를 조회합니다. + private MeetingRoomRepository getRoom(Long meetingId) { + return meetingSocketRoomRepository.findByMeetingId(meetingId); + } + + // 회의방 참가자 전체를 순회하면서 메시지를 보내고 끊어진 세션은 정리합니다. + private void broadcast( + Long meetingId, + Function> messageFactory, + Function skipCondition + ) { + + MeetingRoomRepository room = getRoom(meetingId); + if (room == null) { + return; + } + + List disconnectedParticipants = new ArrayList<>(); + for (MeetingParticipant participant : room.participants()) { + if (skipCondition.apply(participant)) { + continue; + } + + if (!sendMessage(participant, messageFactory.apply(participant))) { + disconnectedParticipants.add(participant); + } + } + + cleanupDisconnectedParticipants(meetingId, disconnectedParticipants); + } + + // 단일 참가자에게 메시지를 전송하고 성공 여부를 반환합니다. + private boolean sendMessage(MeetingParticipant participant, WebSocketMessage message) { + if (!participant.socketSession().isOpen()) { + return false; + } + + try { + participant.socketSession().sendMessage(message); + return true; + } catch (IOException exception) { + return false; + } + } + + // 전송 중 끊어진 세션들을 회의방에서 제거합니다. + private void cleanupDisconnectedParticipants(Long meetingId, List disconnectedParticipants) { + disconnectedParticipants.forEach(participant -> leave(meetingId, participant.sessionId())); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/ConnectedMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/ConnectedMessage.java new file mode 100644 index 0000000..16cd065 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/ConnectedMessage.java @@ -0,0 +1,29 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import com.whylog.server.domain.meeting.socket.util.WebSocketTimeUtil; + +import java.util.List; + +// 웹소켓 연결 직후 현재 참가자 목록과 함께 전달하는 초기 응답입니다. +public record ConnectedMessage( + MeetingMessageType type, + Long meetingId, + Long fromMemberId, + String fromName, + String timestamp, + List participants +) { + + public static ConnectedMessage create(MeetingParticipant participant, List participantSummaries) { + return new ConnectedMessage( + MeetingMessageType.CONNECTED, + participant.meetingId(), + participant.memberId(), + participant.name(), + WebSocketTimeUtil.now(), + participantSummaries + ); + } + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/ErrorMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/ErrorMessage.java new file mode 100644 index 0000000..9bd0209 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/ErrorMessage.java @@ -0,0 +1,8 @@ +package com.whylog.server.domain.meeting.socket.message; + +// 잘못된 요청이나 처리 실패를 클라이언트에 전달하는 에러 메시지입니다. +public record ErrorMessage( + MeetingMessageType type, + String message +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java new file mode 100644 index 0000000..44d97f6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Arrays; + +// 웹소켓에서 주고받는 메시지 타입 상수입니다. +public enum MeetingMessageType { + CONNECTED("connected"), + PARTICIPANT_JOINED("participant_joined"), + PARTICIPANT_LEFT("participant_left"), + ROSTER("roster"), + ERROR("error"), + CHAT("chat"), + SPEECH("speech"), + AUDIO_TEXT("audio_text"), + OFFER("offer"), + ANSWER("answer"), + ICE("ice"); + + private final String value; + + MeetingMessageType(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static MeetingMessageType from(String value) { + return Arrays.stream(values()) + .filter(type -> type.value.equals(value)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingSocketMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingSocketMessage.java new file mode 100644 index 0000000..9c9e4d7 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingSocketMessage.java @@ -0,0 +1,12 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.fasterxml.jackson.databind.JsonNode; + +// 클라이언트가 웹소켓 텍스트 프레임으로 보내는 회의 메시지 형식입니다. +public record MeetingSocketMessage( + MeetingMessageType type, + Long targetMemberId, + String text, + JsonNode payload +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingTextMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingTextMessage.java new file mode 100644 index 0000000..7376b35 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingTextMessage.java @@ -0,0 +1,38 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.fasterxml.jackson.databind.JsonNode; +import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import com.whylog.server.domain.meeting.socket.util.WebSocketTimeUtil; + +// 채팅, 자막, 시그널링 등 텍스트 프레임 기반 서버 송신 메시지입니다. +public record MeetingTextMessage( + MeetingMessageType type, + Long meetingId, + Long fromMemberId, + String fromName, + String timestamp, + Long targetMemberId, + String text, + JsonNode payload +) { + + public static MeetingTextMessage createTextMessage( + MeetingParticipant participant, + MeetingMessageType type, + Long targetMemberId, + String text, + JsonNode payload + ) { + return new MeetingTextMessage( + type, + participant.meetingId(), + participant.memberId(), + participant.name(), + WebSocketTimeUtil.now(), + targetMemberId, + text, + payload + ); + } + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantJoinedMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantJoinedMessage.java new file mode 100644 index 0000000..95cd08a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantJoinedMessage.java @@ -0,0 +1,22 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.whylog.server.domain.meeting.socket.MeetingParticipant; +import java.time.Instant; + +// 새 참가자가 회의방에 입장했음을 알리는 서버 메시지입니다. +public record ParticipantJoinedMessage( + MeetingMessageType type, + Long meetingId, + Long memberId, + String name, + String timestamp +) { + public static ParticipantJoinedMessage create(MeetingParticipant participant) { + return new ParticipantJoinedMessage( + MeetingMessageType.PARTICIPANT_JOINED, + participant.meetingId(), + participant.memberId(), + participant.name(), + Instant.now().toString()); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantLeftMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantLeftMessage.java new file mode 100644 index 0000000..a3e3df8 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantLeftMessage.java @@ -0,0 +1,11 @@ +package com.whylog.server.domain.meeting.socket.message; + +// 참가자 퇴장을 알리는 서버 메시지입니다. +public record ParticipantLeftMessage( + MeetingMessageType type, + Long meetingId, + Long memberId, + String name, + String timestamp +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantSummary.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantSummary.java new file mode 100644 index 0000000..41061c9 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/ParticipantSummary.java @@ -0,0 +1,15 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.whylog.server.domain.meeting.socket.MeetingParticipant; + +// 클라이언트에 노출할 최소 참가자 정보입니다. +public record ParticipantSummary( + Long memberId, + String name +) { + + public static ParticipantSummary create(MeetingParticipant participantRepository) { + return new ParticipantSummary(participantRepository.memberId(), participantRepository.name()); + } + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/RosterMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/RosterMessage.java new file mode 100644 index 0000000..3b40036 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/RosterMessage.java @@ -0,0 +1,23 @@ +package com.whylog.server.domain.meeting.socket.message; + +import com.whylog.server.domain.meeting.socket.util.WebSocketTimeUtil; + +import java.util.List; + +// 회의방의 최신 참가자 목록을 통째로 전달하는 서버 메시지입니다. +public record RosterMessage( + MeetingMessageType type, + Long meetingId, + List participants, + String timestamp +) { + + public static RosterMessage create(Long meetingId, List participants) { + return new RosterMessage( + MeetingMessageType.ROSTER, + meetingId, + participants, + WebSocketTimeUtil.now() + ); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java new file mode 100644 index 0000000..462dc2b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingRoomRepository.java @@ -0,0 +1,34 @@ +package com.whylog.server.domain.meeting.socket.repository; + +import com.whylog.server.domain.meeting.socket.MeetingParticipant; + +import java.util.Collection; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +// 하나의 회의방에 연결된 웹소켓 참가자 세션들을 메모리에서 관리합니다. +public class MeetingRoomRepository { + + // sessionId -> MeetingParticipantSession + private final Map participants = new ConcurrentHashMap<>(); + + // 새로 연결된 참가자 세션을 회의방에 추가합니다. + public void addParticipant(MeetingParticipant participant) { + participants.put(participant.sessionId(), participant); + } + + // 연결이 종료된 참가자 세션을 회의방에서 제거합니다. + public MeetingParticipant removeParticipant(String sessionId) { + return participants.remove(sessionId); + } + + // 현재 회의방에 연결된 전체 참가자 세션 목록을 반환합니다. + public Collection participants() { + return participants.values(); + } + + // 회의방에 남아 있는 참가자가 없는지 확인합니다. + public boolean isEmpty() { + return participants.isEmpty(); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java new file mode 100644 index 0000000..354279b --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/repository/MeetingSocketRoomRepository.java @@ -0,0 +1,29 @@ +package com.whylog.server.domain.meeting.socket.repository; + +import org.springframework.stereotype.Repository; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +// meetingId 기준으로 실시간 회의방 저장소를 관리하는 메모리 레포지토리입니다. +@Repository +public class MeetingSocketRoomRepository { + + // meetingId -> 해당 회의방의 실시간 참가자 저장소 + private final Map rooms = new ConcurrentHashMap<>(); + + // 회의방이 이미 있으면 반환하고, 없으면 새로 생성해서 반환합니다. + public MeetingRoomRepository getOrCreate(Long meetingId) { + return rooms.computeIfAbsent(meetingId, ignored -> new MeetingRoomRepository()); + } + + // 메모리에 올라와 있는 회의방 저장소를 조회합니다. + public MeetingRoomRepository findByMeetingId(Long meetingId) { + return rooms.get(meetingId); + } + + // 회의방 저장소를 제거합니다. + public void delete(Long meetingId) { + rooms.remove(meetingId); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java b/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java new file mode 100644 index 0000000..527d211 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/util/WebSocketTimeUtil.java @@ -0,0 +1,13 @@ +package com.whylog.server.domain.meeting.socket.util; + +import java.time.Instant; + +// 웹소켓 연산 담당 클래스 +public class WebSocketTimeUtil { + + // 웹소켓 메시지에 사용할 현재 시각 문자열을 생성합니다. + public static String now() { + return Instant.now().toString(); + } + +} diff --git a/src/main/java/com/whylog/server/global/config/SecurityConfig.java b/src/main/java/com/whylog/server/global/config/SecurityConfig.java index 3b22fa3..3ac0c7e 100644 --- a/src/main/java/com/whylog/server/global/config/SecurityConfig.java +++ b/src/main/java/com/whylog/server/global/config/SecurityConfig.java @@ -41,6 +41,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/auth/signup", "/api/auth/login", "/api/auth/refresh-token", + "/ws/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", diff --git a/src/main/java/com/whylog/server/global/config/WebSocketConfig.java b/src/main/java/com/whylog/server/global/config/WebSocketConfig.java new file mode 100644 index 0000000..194bccc --- /dev/null +++ b/src/main/java/com/whylog/server/global/config/WebSocketConfig.java @@ -0,0 +1,38 @@ +package com.whylog.server.global.config; + +import com.whylog.server.domain.meeting.socket.MeetingSocketAuthInterceptor; +import com.whylog.server.domain.meeting.socket.MeetingSocketHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +import java.util.Arrays; + +// 회의용 웹소켓 엔드포인트와 핸드셰이크 인터셉터를 등록하는 설정 클래스입니다. +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final MeetingSocketHandler meetingSocketHandler; + private final MeetingSocketAuthInterceptor meetingSocketAuthInterceptor; + + @Value("${cors.allowed-origins}") + private String[] allowedOrigins; + + // 회의 웹소켓 핸들러를 `/ws/meetings` 경로에 등록하고 CORS 및 인증 인터셉터를 연결합니다. + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + String[] originPatterns = Arrays.stream(allowedOrigins) + .map(String::trim) + .filter(origin -> !origin.isEmpty()) + .toArray(String[]::new); + + registry.addHandler(meetingSocketHandler, "/ws/meetings") + .addInterceptors(meetingSocketAuthInterceptor) + .setAllowedOriginPatterns(originPatterns); + } +} diff --git a/src/main/java/com/whylog/server/global/util/json/JsonConverter.java b/src/main/java/com/whylog/server/global/util/json/JsonConverter.java new file mode 100644 index 0000000..a004154 --- /dev/null +++ b/src/main/java/com/whylog/server/global/util/json/JsonConverter.java @@ -0,0 +1,32 @@ +package com.whylog.server.global.util.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.web.socket.TextMessage; + +public class JsonConverter { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + // keyword 없을 때 + public static String toJson(Object value, String keyword) { + + String failKeyword = keyword != null ? keyword : "객체 -> Json 변환 실패"; + + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException exception) { + throw new IllegalStateException("객체 -> Json 변환 실패", exception); + } + } + + // keyword 있을 때 + public static String toJson(Object value) { + return toJson(value, null); + } + + // 객체를 원하는 타입으로 역직렬화할 때 + public static T readValue(TextMessage message, Class returnType) throws JsonProcessingException { + return objectMapper.readValue(message.getPayload(), returnType); + } +} From 9653d0e82753d25ce05772a237c657ba524c8104 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 28 Mar 2026 01:28:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 34 ++++++++--- .../domain/meeting/dto/MeetingResponse.java | 7 ++- .../server/domain/meeting/entity/Meeting.java | 25 ++++++++ .../domain/meeting/enums/MeetingStatus.java | 9 +++ .../meeting/repository/MeetingRepository.java | 10 ++++ .../meeting/service/MeetingQueryService.java | 44 ++++++++++++++ .../team/controller/TeamController.java | 19 ++++++ .../server/domain/team/dto/TeamRequest.java | 14 +++++ .../server/domain/team/dto/TeamResponse.java | 16 +++++ .../server/domain/team/entity/Team.java | 15 ++++- .../server/domain/team/entity/TeamMember.java | 18 ++++++ .../domain/team/entity/TeamMemberId.java | 6 ++ .../domain/team/exception/TeamErrorCode.java | 9 ++- .../team/repository/TeamMemberRepository.java | 8 +++ .../team/repository/TeamRepository.java | 3 + .../team/service/TeamCommandService.java | 58 +++++++++++++++++++ .../server/domain/user/dto/AuthRequest.java | 4 +- 17 files changed, 285 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/enums/MeetingStatus.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java create mode 100644 src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java create mode 100644 src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 68752b3..369c680 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -2,7 +2,9 @@ import com.whylog.server.domain.meeting.dto.MeetingRequest; import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.meeting.service.MeetingCommandService; +import com.whylog.server.domain.meeting.service.MeetingQueryService; import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; @@ -28,22 +30,36 @@ public class MeetingController { private final MeetingCommandService meetingCommandService; + private final MeetingQueryService meetingQueryService; @GetMapping("/teams/{teamId}/meetings") - @Operation(summary = "회의 목록 조회 API", description = "특정 팀의 회의 목록을 조회하는 API입니다. (status: ONGOING/COMPLETED)") + @Operation(summary = "회의 목록 조회 API", description = """ + + 특정 팀의 회의 목록을 조회하는 API입니다. + status: ONGOING/COMPLETED + - status는 필수가 아니며, 기본값은 COMPLETED 입니다. + + elapse : 경과시간 + - 시:분:초 형태 + - 완료된 회의라면 null로 반환함( 표시할 필요 없으니까 ) + + 페이징 없습니다. + + """) public ApiResponse> getMeetings( @PathVariable Long teamId, - @RequestParam(required = false) String status) { - return ApiResponse.onSuccess(null); - } + @RequestParam(required = false, defaultValue = "COMPLETED") MeetingStatus status) { - @PostMapping("/meetings/{meetingId}/join") - @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") - public ApiResponse joinMeeting( - @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingQueryService.getMeetings(teamId, status)); } +// @PostMapping("/meetings/{meetingId}/join") +// @Operation(summary = "회의 입장 API", description = "특정 회의에 입장하는 API입니다.") +// public ApiResponse joinMeeting( +// @PathVariable Long meetingId) { +// return ApiResponse.onSuccess(null); +// } + @PostMapping("/teams/{teamId}/meetings") @Operation(summary = "회의 생성 API", description = """ diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 07cd4e5..34e9892 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.meeting.dto; +import com.whylog.server.domain.meeting.enums.MeetingStatus; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -24,7 +25,11 @@ public static class MeetingListDTO { private String name; @Schema(description = "회의 상태", example = "ONGOING") - private String status; + private MeetingStatus status; + + @Schema(description = "경과시간 (시:분:초)", example = "00:00:00") + private String elapse; + } @Getter diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 6307788..33aa555 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.entity; import com.whylog.server.domain.meeting.dto.MeetingRequest; +import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.Column; @@ -12,7 +13,10 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; + +import java.time.Duration; import java.time.LocalDateTime; + import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -57,6 +61,27 @@ public static Meeting create(MeetingRequest.MeetingCreateDTO dto, Team team) { .build(); } + public MeetingStatus getStatus(){ + return this.endDateTime == null ? MeetingStatus.ONGOING : MeetingStatus.COMPLETED; + } + + public boolean isOngoing(){ + return this.endDateTime == null; + } + + public String getElapse() { + + Duration duration = Duration.between(startDateTime, LocalDateTime.now()); + + long seconds = duration.getSeconds(); + + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + long secs = seconds % 60; + + return String.format("%02d:%02d:%02d", hours, minutes, secs); + } + // @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) // private final List meetingMembers = new ArrayList<>(); // diff --git a/src/main/java/com/whylog/server/domain/meeting/enums/MeetingStatus.java b/src/main/java/com/whylog/server/domain/meeting/enums/MeetingStatus.java new file mode 100644 index 0000000..3f2ce6a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/enums/MeetingStatus.java @@ -0,0 +1,9 @@ +package com.whylog.server.domain.meeting.enums; + +public enum MeetingStatus { + + ONGOING, + COMPLETED + ; + +} diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 6e6893a..31a74c1 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -2,9 +2,19 @@ import com.whylog.server.domain.meeting.entity.Meeting; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; public interface MeetingRepository extends JpaRepository { + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH Team t + ON t.id = :teamId + """) + List findByTeamId(@Param("teamId") Long teamId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java new file mode 100644 index 0000000..0d33fd6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -0,0 +1,44 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.dto.MeetingResponse; +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.enums.MeetingStatus; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.service.TeamUseCase; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MeetingQueryService { + + private final MeetingRepository meetingRepository; + private final TeamUseCase teamUseCase; + + // 미팅 목록 조회 + @Transactional(readOnly = true) + public List getMeetings(Long teamId, MeetingStatus status){ + + // 기본값: 진행완료 + if (status == null) { + status = MeetingStatus.COMPLETED; + } + + List meetings = meetingRepository.findByTeamId(teamId); + + return meetings.stream() + .map(m -> MeetingResponse.MeetingListDTO.builder() + .meetingId(m.getId()) + .name(m.getName()) + .status(m.getStatus()) + .elapse( m.isOngoing() ? m.getElapse() : null ) // 진행완료일 경우 null로 반환 + .build() + ).toList(); + + } + +} diff --git a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java index 1eb0c22..c88fa56 100644 --- a/src/main/java/com/whylog/server/domain/team/controller/TeamController.java +++ b/src/main/java/com/whylog/server/domain/team/controller/TeamController.java @@ -3,8 +3,11 @@ import com.whylog.server.domain.decision.dto.DecisionResponse; import com.whylog.server.domain.team.dto.TeamRequest; import com.whylog.server.domain.team.dto.TeamResponse; +import com.whylog.server.domain.team.service.TeamCommandService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -21,6 +24,8 @@ @Tag(name = "Team", description = "팀 관련 API") public class TeamController { + private final TeamCommandService teamCommandService; + @GetMapping("/{teamId}/decisions") @Operation(summary = "결정사항 목록 조회 API", description = "특정 팀의 결정사항 목록을 조회하는 API입니다.") public ApiResponse getDecisions( @@ -35,4 +40,18 @@ public ApiResponse sendInvitation( @Valid @RequestBody TeamRequest.InvitationDTO request) { return ApiResponse.onSuccess(null); } + + @PostMapping + @Operation(summary = "팀 생성 API", description = """ + 팀을 생성합니다. + 팀명은 50글자 미만입니다. + 팀 생성과 동시에 팀에 참여합니다. ( 따로 호출 X ) + """) + public ApiResponse createTeam( + @Parameter(hidden = true) @CurrentMember Long memberId, + @Valid @RequestBody TeamRequest.TeamCreateDTO request + ) { + return ApiResponse.onSuccess(teamCommandService.createTeam(memberId, request)); + } + } diff --git a/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java b/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java index e786272..f2a6d43 100644 --- a/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java +++ b/src/main/java/com/whylog/server/domain/team/dto/TeamRequest.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; import lombok.*; @@ -19,4 +20,17 @@ public static class InvitationDTO { private String memberEmail; } + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @AllArgsConstructor + @Builder + @Schema(description = "팀 생성") + public static class TeamCreateDTO { + + @Schema(description = "팀명 - 50글자 이내", example = "팀명이어떻게다마고치") + @NotBlank + private String name; + + } + } diff --git a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java index df7a85d..201feb0 100644 --- a/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java +++ b/src/main/java/com/whylog/server/domain/team/dto/TeamResponse.java @@ -21,4 +21,20 @@ public static class InvitationResponseDTO { @Schema(description = "초대받은 사용자 이메일", example = "member@example.com") private String memberEmail; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "팀 생성 응답") + public static class TeamCreateResponseDTO { + + @Schema(description = "팀 ID", example = "1") + private Long teamId; + + @Schema(description = "팀명", example = "팀명이 어떻게 다마고치") + private String name; + + } + } diff --git a/src/main/java/com/whylog/server/domain/team/entity/Team.java b/src/main/java/com/whylog/server/domain/team/entity/Team.java index 3168f64..06be70e 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/Team.java +++ b/src/main/java/com/whylog/server/domain/team/entity/Team.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.team.entity; +import com.whylog.server.domain.team.dto.TeamRequest; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -12,6 +13,7 @@ import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -26,9 +28,20 @@ public class Team extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(length = 50, nullable = false) + @Column(length = 50, nullable = false, unique = true) private String name; + @Builder + private Team(String name) { + this.name = name; + } + + public static Team create(TeamRequest.TeamCreateDTO dto) { + return Team.builder() + .name(dto.getName()) + .build(); + } + // @OneToMany(mappedBy = "team", cascade = CascadeType.ALL, orphanRemoval = true) // private final List teamMembers = new ArrayList<>(); // diff --git a/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java b/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java index e4e314f..87fc26c 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java +++ b/src/main/java/com/whylog/server/domain/team/entity/TeamMember.java @@ -14,6 +14,7 @@ import jakarta.persistence.MapsId; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -42,5 +43,22 @@ public class TeamMember extends BaseEntity { @Enumerated(EnumType.STRING) private TeamRole role; + @Builder + private TeamMember(TeamMemberId id, Team team, Member member, Boolean active, TeamRole role) { + this.id = id; + this.team = team; + this.member = member; + this.active = active; + this.role = role; + } + public static TeamMember create(Team team, Member member, TeamRole role) { + return TeamMember.builder() + .id(TeamMemberId.of(team.getId(), member.getId())) + .team(team) + .member(member) + .active(true) + .role(role) + .build(); + } } diff --git a/src/main/java/com/whylog/server/domain/team/entity/TeamMemberId.java b/src/main/java/com/whylog/server/domain/team/entity/TeamMemberId.java index aeb37c4..c70c8b6 100644 --- a/src/main/java/com/whylog/server/domain/team/entity/TeamMemberId.java +++ b/src/main/java/com/whylog/server/domain/team/entity/TeamMemberId.java @@ -3,6 +3,7 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import java.io.Serializable; +import lombok.AllArgsConstructor; import lombok.AccessLevel; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -11,6 +12,7 @@ @Getter @Embeddable @EqualsAndHashCode +@AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TeamMemberId implements Serializable { @@ -19,4 +21,8 @@ public class TeamMemberId implements Serializable { @Column(name = "member_id") private Long memberId; + + public static TeamMemberId of(Long teamId, Long memberId) { + return new TeamMemberId(teamId, memberId); + } } diff --git a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java index 7e889ee..01f53cc 100644 --- a/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java @@ -10,7 +10,14 @@ @RequiredArgsConstructor public enum TeamErrorCode implements BaseErrorCode { - TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM_404", "존재하지 않는 팀입니다.") + TEAM_NOT_FOUND(HttpStatus.NOT_FOUND, "TEAM_404", "존재하지 않는 팀입니다."), + + // 400 Bad Request + TEAM_NAME_LENGTH(HttpStatus.BAD_REQUEST, "TEAM_400", "팀명 길이는 50글자 미만이어야 합니다."), + + // 422 Unprocessable Entity + TEAM_NAME_ALREADY_EXISTS(HttpStatus.UNPROCESSABLE_ENTITY, "TEAM_420", "이미 존재하는 팀명입니다.") + ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java new file mode 100644 index 0000000..c542960 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -0,0 +1,8 @@ +package com.whylog.server.domain.team.repository; + +import com.whylog.server.domain.team.entity.TeamMember; +import com.whylog.server.domain.team.entity.TeamMemberId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TeamMemberRepository extends JpaRepository { +} diff --git a/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java index 5e413e3..9221589 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java @@ -4,4 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TeamRepository extends JpaRepository { + + Boolean existsByName(String teamName); + } diff --git a/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java new file mode 100644 index 0000000..e372ff9 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/service/TeamCommandService.java @@ -0,0 +1,58 @@ +package com.whylog.server.domain.team.service; + +import com.whylog.server.domain.team.dto.TeamRequest; +import com.whylog.server.domain.team.dto.TeamResponse; +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.entity.TeamMember; +import com.whylog.server.domain.team.enums.TeamRole; +import com.whylog.server.domain.team.exception.TeamErrorCode; +import com.whylog.server.domain.team.repository.TeamMemberRepository; +import com.whylog.server.domain.team.repository.TeamRepository; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.domain.user.service.MemberUseCase; +import com.whylog.server.global.apiPayload.exception.handler.ErrorHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TeamCommandService { + + private final TeamRepository teamRepository; + private final TeamMemberRepository teamMemberRepository; + private final MemberUseCase memberUseCase; + + @Transactional + public TeamResponse.TeamCreateResponseDTO createTeam(Long memberId, TeamRequest.TeamCreateDTO request){ + + // 팀명 이미 존재하면 예외 발생 + if(teamRepository.existsByName(request.getName())){ + throw new ErrorHandler(TeamErrorCode.TEAM_NAME_ALREADY_EXISTS); + } + + // 팀명 길이 체크 + if (request.getName().isEmpty() || request.getName().length() > 50) { + throw new ErrorHandler(TeamErrorCode.TEAM_NAME_LENGTH); + } + + // 팀 생성 및 저장 + Team team = Team.create(request); + teamRepository.save(team); + + // 팀원으로 등록 + Member member = memberUseCase.findMemberById(memberId); + addMember(team, member, TeamRole.OWNER); + + return TeamResponse.TeamCreateResponseDTO.builder() + .teamId(team.getId()) + .name(team.getName()) + .build(); + } + + private void addMember(Team team, Member member, TeamRole role){ + TeamMember teamMember = TeamMember.create(team, member, role); + teamMemberRepository.save(teamMember); + } + +} diff --git a/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java index b015d5d..e15d2ba 100644 --- a/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java +++ b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java @@ -19,7 +19,7 @@ public static class SignUpDTO { @NotBlank @Email private String email; - @Schema(description = "비밀번호", example = "wtf1234") + @Schema(description = "비밀번호", example = "wtf12345") @NotBlank @Size(min = 8, max = 100) private String password; @@ -36,7 +36,7 @@ public static class LoginDTO { @NotBlank @Email private String email; - @Schema(description = "비밀번호", example = "wtf1234") + @Schema(description = "비밀번호", example = "wtf12345") @NotBlank private String password; } From 7fafa07ae0012aa0c28dab4fb3607468374c9cb8 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 28 Mar 2026 01:50:26 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 9 +++- .../server/domain/meeting/entity/Meeting.java | 5 +++ .../MeetingAlreadyEndedException.java | 10 +++++ .../meeting/exception/MeetingErrorCode.java | 40 ++++++++++++++++++ .../MeetingInvalidMemberException.java | 9 ++++ .../exception/MeetingNotFoundException.java | 10 +++++ .../repository/MeetingMemberRepository.java | 1 + .../service/MeetingCommandService.java | 42 ++++++++++++++++++- .../socket/MeetingSocketRoomService.java | 16 +++++++ .../socket/message/MeetingEndedMessage.java | 11 +++++ .../socket/message/MeetingMessageType.java | 1 + 11 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyEndedException.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingInvalidMemberException.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/exception/MeetingNotFoundException.java create mode 100644 src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingEndedMessage.java diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 369c680..99927d7 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -47,6 +47,7 @@ public class MeetingController { """) public ApiResponse> getMeetings( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @RequestParam(required = false, defaultValue = "COMPLETED") MeetingStatus status) { @@ -76,10 +77,14 @@ public ApiResponse createMeeting( } @PatchMapping("/meetings/{meetingId}/end") - @Operation(summary = "회의 종료 API", description = "진행 중인 회의를 종료하는 API입니다.") + @Operation(summary = "회의 종료 API", description = """ + 진행 중인 회의를 종료하는 API입니다. + 회의 종료 시 실시간 회의 참여자들에게 종료를 알리는 웹소켓 메시지를 전송합니다. + """) public ApiResponse endMeeting( + @Parameter(hidden= true) @CurrentMember Long memberId, @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingCommandService.endMeeting(memberId, meetingId)); } @GetMapping("/meetings/{meetingId}") diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 33aa555..8fb4d2f 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -69,6 +69,11 @@ public boolean isOngoing(){ return this.endDateTime == null; } + public LocalDateTime endMeeting() { + this.endDateTime = LocalDateTime.now(); + return this.endDateTime; + } + public String getElapse() { Duration duration = Duration.between(startDateTime, LocalDateTime.now()); diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyEndedException.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyEndedException.java new file mode 100644 index 0000000..f5d9eaf --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingAlreadyEndedException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MeetingAlreadyEndedException extends GeneralException { + + public MeetingAlreadyEndedException() { + super(MeetingErrorCode.MEETING_ALREADY_ENDED); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java new file mode 100644 index 0000000..c91d187 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingErrorCode.java @@ -0,0 +1,40 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.code.BaseErrorCode; +import com.whylog.server.global.apiPayload.code.ErrorReasonDTO; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum MeetingErrorCode implements BaseErrorCode { + + MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "MEETING_404", "존재하지 않는 회의입니다."), + MEETING_ALREADY_ENDED(HttpStatus.CONFLICT, "MEETING_409", "이미 종료된 회의입니다."), + MEETING_INVALID_MEMBER(HttpStatus.CONFLICT, "MEETING_410", "회의에 소속된 참여자가 아닙니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingInvalidMemberException.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingInvalidMemberException.java new file mode 100644 index 0000000..b2069ed --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingInvalidMemberException.java @@ -0,0 +1,9 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MeetingInvalidMemberException extends GeneralException { + public MeetingInvalidMemberException() { + super(MeetingErrorCode.MEETING_INVALID_MEMBER); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/exception/MeetingNotFoundException.java b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingNotFoundException.java new file mode 100644 index 0000000..6d0e82a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/exception/MeetingNotFoundException.java @@ -0,0 +1,10 @@ +package com.whylog.server.domain.meeting.exception; + +import com.whylog.server.global.apiPayload.exception.GeneralException; + +public class MeetingNotFoundException extends GeneralException { + + public MeetingNotFoundException() { + super(MeetingErrorCode.MEETING_NOT_FOUND); + } +} diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java index 4b7ac57..4e10518 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java @@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface MeetingMemberRepository extends JpaRepository { + boolean existsByMemberIdAndMeetingId(Long memberId, Long meetingId); } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index c859c2c..0944555 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -5,6 +5,9 @@ import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.entity.MeetingMember; import com.whylog.server.domain.meeting.enums.MeetingRole; +import com.whylog.server.domain.meeting.exception.MeetingAlreadyEndedException; +import com.whylog.server.domain.meeting.exception.MeetingInvalidMemberException; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingMemberRepository; import com.whylog.server.domain.meeting.repository.MeetingRepository; import com.whylog.server.domain.meeting.socket.MeetingSocketRoomService; @@ -17,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor public class MeetingCommandService { @@ -58,8 +63,6 @@ public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, L // 실시간 회의 추가 meetingSocketRoomService.createRoomIfAbsent(savedMeeting.getId()); // 현재 참여자 추가 - // TODO: 회의 분석 시작 - // dto 생성 후 반환 return MeetingResponse.MeetingCreateResponseDTO.builder() .meetingId(savedMeeting.getId()) @@ -68,4 +71,39 @@ public MeetingResponse.MeetingCreateResponseDTO makeMeetingRoom(Long memberId, L .build(); } + /* + 회의를 종료합니다. + - 실시간 회의 참여자들에게 회의 종료를 메시지를 보냅니다. + - 실시간 데이터로 다루는 실시간 회의 정보도 제거합니다. + */ + @Transactional + public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meetingId) { + + // 조회 및 검증 + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(MeetingNotFoundException::new); + + if(meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) // 회의 참여자 존재 검증 + throw new MeetingInvalidMemberException(); + + if (!meeting.isOngoing()) { // 이미 종료된 회의인지 검증 + throw new MeetingAlreadyEndedException(); + } + + // 정보 갱신 + LocalDateTime endDateTime = meeting.endMeeting(); + meetingRepository.save(meeting); + + // 웹소켓 메시지 전송 + meetingSocketRoomService.broadcastMeetingEnded(meetingId, endDateTime); // 회의 참여한 사람들에게 알림 + meetingSocketRoomService.closeRoom(meetingId); // 메모리 내의 실시간 회의 정보 제거 + + // TODO: 회의 종료 후 AI 분석 시작 + + return MeetingResponse.MeetingEndResponseDTO.builder() + .meetingId(meeting.getId()) + .endDateTime(endDateTime) + .build(); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java index 8767f97..e6f747c 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -1,9 +1,12 @@ package com.whylog.server.domain.meeting.socket; import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.meeting.socket.message.MeetingEndedMessage; +import com.whylog.server.domain.meeting.socket.message.MeetingMessageType; import com.whylog.server.domain.meeting.socket.message.ParticipantSummary; import com.whylog.server.domain.meeting.socket.repository.MeetingRoomRepository; import com.whylog.server.domain.meeting.socket.repository.MeetingSocketRoomRepository; +import com.whylog.server.global.util.json.JsonConverter; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,6 +16,7 @@ import java.io.IOException; import java.nio.ByteBuffer; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.List; @@ -116,6 +120,18 @@ public void sendToMember(Long meetingId, Long targetMemberId, String payload) { }); } + // 회의 종료 메시지를 현재 회의방 참가자 전체에게 전송합니다. + public void broadcastMeetingEnded(Long meetingId, LocalDateTime endedAt) { + broadcastText( + meetingId, + JsonConverter.toJson(new MeetingEndedMessage( + MeetingMessageType.MEETING_ENDED, + meetingId, + endedAt + )) + ); + } + // 회의방이 이미 있으면 반환하고, 없으면 새로 생성해서 반환합니다. private MeetingRoomRepository getOrCreateRoom(Long meetingId) { return meetingSocketRoomRepository.getOrCreate(meetingId); diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingEndedMessage.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingEndedMessage.java new file mode 100644 index 0000000..4a60f77 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingEndedMessage.java @@ -0,0 +1,11 @@ +package com.whylog.server.domain.meeting.socket.message; + +import java.time.LocalDateTime; + +// 회의 종료를 알리는 서버 메시지입니다. +public record MeetingEndedMessage( + MeetingMessageType type, + Long meetingId, + LocalDateTime endedAt +) { +} diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java index 44d97f6..de2d1e6 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java @@ -8,6 +8,7 @@ // 웹소켓에서 주고받는 메시지 타입 상수입니다. public enum MeetingMessageType { CONNECTED("connected"), + MEETING_ENDED("meeting_ended"), PARTICIPANT_JOINED("participant_joined"), PARTICIPANT_LEFT("participant_left"), ROSTER("roster"), From e2956a6decdf9575cafa37cf6a1f798828cbf2b1 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 28 Mar 2026 02:36:32 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 8 +++- .../domain/meeting/dto/MeetingResponse.java | 22 ++++++++++- .../server/domain/meeting/entity/Meeting.java | 21 +++++----- .../meeting/repository/MeetingRepository.java | 9 +++++ .../meeting/service/MeetingQueryService.java | 39 +++++++++++++++++-- .../meeting/service/MeetingUseCase.java | 30 ++++++++++++++ .../server/domain/user/entity/Member.java | 3 ++ 7 files changed, 115 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 99927d7..1e36aa4 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -88,10 +88,14 @@ public ApiResponse endMeeting( } @GetMapping("/meetings/{meetingId}") - @Operation(summary = "회의 기본 정보 조회 API", description = "특정 회의의 기본 정보를 조회하는 API입니다.") + @Operation(summary = "회의 기본 정보 조회 API", description = """ + 특정 회의의 기본 정보를 조회하는 API입니다. + 회의명, 날짜, 기간, 참여자 정보를 제공합니다. + 본 API에서 분석결과, 대화기록은 제공하지 않습니다. + """) public ApiResponse getMeetingDetail( @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); + return ApiResponse.onSuccess(meetingQueryService.getMeetingDefaultInfo(meetingId)); } @GetMapping("/meetings/{meetingId}/history") diff --git a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 34e9892..5d4b537 100644 --- a/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java @@ -1,6 +1,7 @@ package com.whylog.server.domain.meeting.dto; import com.whylog.server.domain.meeting.enums.MeetingStatus; +import com.whylog.server.domain.meeting.socket.MeetingParticipant; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -117,7 +118,7 @@ public static class MeetingDetailDTO { private Integer memberCount; @Schema(description = "회의 참여자 목록", example = "[1, 2, 3]") - private List members; + private List members; } @Getter @@ -215,4 +216,23 @@ public static class ApplicationDTO { @Schema(description = "적용사항명", example = "Redis 기술 변경") private String name; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "미팅 내 참여자 정보") + public static class MeetingParticipantInfo{ + + @Schema(description = "멤버 id", example = "1") + private Long memberId; + + @Schema(description = "유저이름", example = "아무개") + private String name; + + @Schema(description = "프로필이미지", example = "https://example.com/profile/user-1.jpg") + private String profileImage; + + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index 8fb4d2f..b7942b5 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -4,18 +4,13 @@ import com.whylog.server.domain.meeting.enums.MeetingStatus; import com.whylog.server.domain.team.entity.Team; import com.whylog.server.global.entity.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; +import jakarta.persistence.*; import java.time.Duration; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import lombok.AccessLevel; import lombok.Builder; @@ -74,6 +69,10 @@ public LocalDateTime endMeeting() { return this.endDateTime; } + public Long getDuration() { + return ChronoUnit.MINUTES.between(startDateTime, endDateTime); + } + public String getElapse() { Duration duration = Duration.between(startDateTime, LocalDateTime.now()); @@ -87,8 +86,8 @@ public String getElapse() { return String.format("%02d:%02d:%02d", hours, minutes, secs); } -// @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List meetingMembers = new ArrayList<>(); + @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) + private final List meetingMembers = new ArrayList<>(); // // @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) // private final MeetingAnalysis meetingAnalyses; diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index 31a74c1..ea200b8 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface MeetingRepository extends JpaRepository { @@ -16,5 +17,13 @@ public interface MeetingRepository extends JpaRepository { """) List findByTeamId(@Param("teamId") Long teamId); + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH MeetingMember mm + WHERE m.id = :meetingId + AND mm.meeting = m + """) + Optional findWithMembers(@Param("meetingId") Long meetingId); + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index 0d33fd6..28aa3fb 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -3,9 +3,10 @@ import com.whylog.server.domain.meeting.dto.MeetingResponse; import com.whylog.server.domain.meeting.entity.Meeting; import com.whylog.server.domain.meeting.enums.MeetingStatus; +import com.whylog.server.domain.meeting.exception.MeetingNotFoundException; import com.whylog.server.domain.meeting.repository.MeetingRepository; -import com.whylog.server.domain.team.entity.Team; -import com.whylog.server.domain.team.service.TeamUseCase; +import com.whylog.server.domain.user.entity.Member; +import com.whylog.server.global.apiPayload.exception.ParameterRequiredException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,7 +18,7 @@ public class MeetingQueryService { private final MeetingRepository meetingRepository; - private final TeamUseCase teamUseCase; + private final MeetingUseCase meetingUseCase; // 미팅 목록 조회 @Transactional(readOnly = true) @@ -41,4 +42,36 @@ public List getMeetings(Long teamId, MeetingStat } + // 회의 기본 정보 조회 + @Transactional(readOnly = true) + public MeetingResponse.MeetingDetailDTO getMeetingDefaultInfo(Long meetingId){ + + if (meetingId == null) { // null check + throw new ParameterRequiredException(); + } + + Meeting meeting = meetingRepository.findWithMembers(meetingId) + .orElseThrow(MeetingNotFoundException::new); + + return MeetingResponse.MeetingDetailDTO.builder() + .meetingId(meeting.getId()) + .name(meeting.getName()) + .startDateTime(meeting.getStartDateTime()) + .endDateTime(meeting.getEndDateTime()) + .duration(meeting.getDuration()) + .memberCount( meetingUseCase.getMeetingMemberCount(meeting) ) + .members( memberToParticipantsInfo(meetingUseCase.getParticipantsInfo(meeting)) ) + .build(); + } + + private List memberToParticipantsInfo(List members){ + return members.stream() + .map(member -> MeetingResponse.MeetingParticipantInfo.builder() + .memberId(member.getId()) + .name(member.getName()) + .profileImage(member.getProfileImage()) + .build() + ).toList(); + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java new file mode 100644 index 0000000..4b2c897 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingUseCase.java @@ -0,0 +1,30 @@ +package com.whylog.server.domain.meeting.service; + +import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.entity.MeetingMember; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +import com.whylog.server.domain.user.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MeetingUseCase { + + private final MeetingRepository meetingRepository; + + // 회의 참여자 수 + public int getMeetingMemberCount(Meeting meeting){ + return meeting.getMeetingMembers().size(); + } + + // 회의의 참여자 정보 + public List getParticipantsInfo(Meeting meeting){ + return meeting.getMeetingMembers().stream() + .map(MeetingMember::getMember) + .toList(); + } + +} diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 5e9b882..5e4d264 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -26,6 +26,9 @@ public class Member extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "name", length = 50) + private String name; + @Column(length = 50, nullable = false, unique = true) private String email; From 59805eb0ae8418aec87fecbb07dfdddbc57013e7 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 28 Mar 2026 02:49:47 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=EB=A9=A4=EB=B2=84=20=EB=88=84?= =?UTF-8?q?=EB=9D=BD=20=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting/controller/MeetingController.java | 12 ++++++------ .../whylog/server/domain/user/dto/AuthRequest.java | 4 ++++ .../whylog/server/domain/user/entity/Member.java | 14 +++++++++++++- .../domain/user/service/LocalLoginService.java | 6 +----- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java index 1e36aa4..672f1db 100644 --- a/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java +++ b/src/main/java/com/whylog/server/domain/meeting/controller/MeetingController.java @@ -119,10 +119,10 @@ public ApiResponse getAudio( return ApiResponse.onSuccess(null); } - @GetMapping("/meetings/{meetingId}/applications") - @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") - public ApiResponse> getApplications( - @PathVariable Long meetingId) { - return ApiResponse.onSuccess(null); - } +// @GetMapping("/meetings/{meetingId}/applications") +// @Operation(summary = "적용사항 목록 조회 API", description = "특정 회의의 적용사항 목록을 조회하는 API입니다.") +// public ApiResponse> getApplications( +// @PathVariable Long meetingId) { +// return ApiResponse.onSuccess(null); +// } } diff --git a/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java index e15d2ba..5d2f4fd 100644 --- a/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java +++ b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java @@ -15,6 +15,10 @@ public class AuthRequest { @Schema(description = "회원가입 요청") public static class SignUpDTO { + @Schema(description = "이름", example = "아무개") + @NotBlank + private String name; + @Schema(description = "이메일", example = "user@example.com") @NotBlank @Email private String email; diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 5e4d264..70ed485 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -1,5 +1,6 @@ package com.whylog.server.domain.user.entity; +import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.enums.Role; import com.whylog.server.global.entity.BaseEntity; import jakarta.persistence.Column; @@ -43,10 +44,21 @@ public class Member extends BaseEntity { private Role role; @Builder - private Member(String email, String password, String profileImage, Role role) { + private Member(String name, String email, String password, String profileImage, Role role) { + this.name = name; this.email = email; this.password = password; this.profileImage = profileImage; this.role = role; } + + public static Member create(AuthRequest.SignUpDTO dto, Role role) { + return Member.builder() + .name(dto.getName()) + .email(dto.getEmail()) + .password(dto.getPassword()) + .role(role) + .build(); + } + } diff --git a/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java index c24d212..aedd0e9 100644 --- a/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java +++ b/src/main/java/com/whylog/server/domain/user/service/LocalLoginService.java @@ -26,11 +26,7 @@ public AuthResponse.LoginResponseDTO signUp(AuthRequest.SignUpDTO request) { throw new ErrorHandler(AuthErrorStatus.EMAIL_ALREADY_EXISTS); } - Member member = memberRepository.save(Member.builder() - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .role(Role.USER) - .build()); + Member member = memberRepository.save(Member.create(request, Role.USER)); return authenticationService.generateLoginResponse(member); } From 382710bea77ab40b0733af0f15e022810743d8c3 Mon Sep 17 00:00:00 2001 From: junyong Date: Sat, 28 Mar 2026 03:22:02 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9D=98=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20api=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=20?= =?UTF-8?q?=20-=20duration=20npe=20=EC=88=98=EC=A0=95=20=20=20-=20?= =?UTF-8?q?=ED=9A=8C=EC=9D=98=20=EC=83=81=EC=84=B8=20join=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=20=20-=20=ED=9A=8C=EC=9D=98=20=EC=A2=85=EB=A3=8C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=B0=B8=EC=97=AC=EC=9E=90=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EB=B0=98=EB=8C=80=EB=A1=9C=20=EB=90=9C?= =?UTF-8?q?=EA=B1=B0=20=EC=88=98=EC=A0=95=20=20=20-=20=ED=9A=8C=EC=9D=98?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=83=81=ED=83=9C=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20=20=20-=20=EC=9B=B9?= =?UTF-8?q?=EC=86=8C=EC=BC=93=20=EC=B0=B8=EA=B0=80=EC=9E=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EA=B8=B0=EB=B3=B8=20=EC=9D=B4=EB=A6=84=20email=20-?= =?UTF-8?q?>=20member.name=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=20=20?= =?UTF-8?q?-=20JsonConverter=EC=97=90=20Jackson=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../whylog/server/domain/meeting/entity/Meeting.java | 1 + .../domain/meeting/repository/MeetingRepository.java | 4 +++- .../domain/meeting/service/MeetingCommandService.java | 2 +- .../domain/meeting/service/MeetingQueryService.java | 11 +++++++---- .../meeting/socket/MeetingSocketAuthInterceptor.java | 2 +- .../com/whylog/server/domain/user/entity/Member.java | 2 +- .../whylog/server/global/util/json/JsonConverter.java | 4 ++-- 7 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java index b7942b5..dd660ce 100644 --- a/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java +++ b/src/main/java/com/whylog/server/domain/meeting/entity/Meeting.java @@ -70,6 +70,7 @@ public LocalDateTime endMeeting() { } public Long getDuration() { + if(this.endDateTime == null) return null; return ChronoUnit.MINUTES.between(startDateTime, endDateTime); } diff --git a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java index ea200b8..d60cf9c 100644 --- a/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -1,6 +1,8 @@ package com.whylog.server.domain.meeting.repository; import com.whylog.server.domain.meeting.entity.Meeting; +import com.whylog.server.domain.meeting.enums.MeetingStatus; +import io.swagger.v3.oas.annotations.Parameter; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -20,8 +22,8 @@ public interface MeetingRepository extends JpaRepository { @Query(""" SELECT m FROM Meeting m LEFT JOIN FETCH MeetingMember mm + ON mm.meeting.id = m.id WHERE m.id = :meetingId - AND mm.meeting = m """) Optional findWithMembers(@Param("meetingId") Long meetingId); diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java index 0944555..55fca3d 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -83,7 +83,7 @@ public MeetingResponse.MeetingEndResponseDTO endMeeting(Long memberId, Long meet Meeting meeting = meetingRepository.findById(meetingId) .orElseThrow(MeetingNotFoundException::new); - if(meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) // 회의 참여자 존재 검증 + if(!meetingMemberRepository.existsByMemberIdAndMeetingId(memberId, meetingId)) // 회의 참여자 존재 검증 throw new MeetingInvalidMemberException(); if (!meeting.isOngoing()) { // 이미 종료된 회의인지 검증 diff --git a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java index 28aa3fb..088db8a 100644 --- a/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -25,18 +25,17 @@ public class MeetingQueryService { public List getMeetings(Long teamId, MeetingStatus status){ // 기본값: 진행완료 - if (status == null) { - status = MeetingStatus.COMPLETED; - } + MeetingStatus targetStatus = status != null ? status : MeetingStatus.COMPLETED; List meetings = meetingRepository.findByTeamId(teamId); return meetings.stream() + .filter( m -> checkMeetingStatus(m, targetStatus)) // 상태 일치 체크 .map(m -> MeetingResponse.MeetingListDTO.builder() .meetingId(m.getId()) .name(m.getName()) .status(m.getStatus()) - .elapse( m.isOngoing() ? m.getElapse() : null ) // 진행완료일 경우 null로 반환 + .elapse( !m.isOngoing() ? m.getElapse() : null ) // 진행완료일 경우 null로 반환 .build() ).toList(); @@ -74,4 +73,8 @@ private List memberToParticipantsInfo(Li ).toList(); } + private boolean checkMeetingStatus(Meeting meeting, MeetingStatus status){ + return meeting.getStatus() == status; + } + } diff --git a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java index fb3a341..43c69fd 100644 --- a/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketAuthInterceptor.java @@ -94,7 +94,7 @@ private String resolveName(MultiValueMap queryParams, Member mem return Optional.ofNullable(queryParams.getFirst("name")) .map(this::decode) .filter(StringUtils::hasText) - .orElse(member.getEmail()); + .orElse(member.getName()); } // URL 인코딩된 쿼리 파라미터 값을 디코딩합니다. diff --git a/src/main/java/com/whylog/server/domain/user/entity/Member.java b/src/main/java/com/whylog/server/domain/user/entity/Member.java index 70ed485..ad6327f 100644 --- a/src/main/java/com/whylog/server/domain/user/entity/Member.java +++ b/src/main/java/com/whylog/server/domain/user/entity/Member.java @@ -27,7 +27,7 @@ public class Member extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "name", length = 50) + @Column(name = "name", length = 50, nullable = false) private String name; @Column(length = 50, nullable = false, unique = true) diff --git a/src/main/java/com/whylog/server/global/util/json/JsonConverter.java b/src/main/java/com/whylog/server/global/util/json/JsonConverter.java index a004154..50c0349 100644 --- a/src/main/java/com/whylog/server/global/util/json/JsonConverter.java +++ b/src/main/java/com/whylog/server/global/util/json/JsonConverter.java @@ -6,7 +6,7 @@ public class JsonConverter { - private static final ObjectMapper objectMapper = new ObjectMapper(); + private static final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules(); // keyword 없을 때 public static String toJson(Object value, String keyword) { @@ -16,7 +16,7 @@ public static String toJson(Object value, String keyword) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException exception) { - throw new IllegalStateException("객체 -> Json 변환 실패", exception); + throw new IllegalStateException(failKeyword, exception); } }