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 36de86c..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 @@ -2,8 +2,13 @@ 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; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -24,41 +29,73 @@ @Tag(name = "Meeting", description = "회의 관련 API") 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( + @Parameter(hidden = true) @CurrentMember Long memberId, @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 = "새로운 회의를 생성하는 API입니다.") + @Operation(summary = "회의 생성 API", description = """ + + 새로운 회의를 생성하는 API입니다. 생성하면 실시긴 회의방이 하나 생성됩니다. + 해당 API는 방 생성만 담당합니다. 회의 참여를 위해서는 웹소켓 연결이 필요합니다. + + """) public ApiResponse createMeeting( + @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @Valid @RequestBody MeetingRequest.MeetingCreateDTO request) { - return ApiResponse.onSuccess(null); + MeetingResponse.MeetingCreateResponseDTO result = meetingCommandService.makeMeetingRoom(memberId, teamId, request); + return ApiResponse.onSuccess(result); } @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}") - @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") @@ -82,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/meeting/dto/MeetingResponse.java b/src/main/java/com/whylog/server/domain/meeting/dto/MeetingResponse.java index 07cd4e5..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,5 +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; @@ -24,7 +26,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 @@ -112,7 +118,7 @@ public static class MeetingDetailDTO { private Integer memberCount; @Schema(description = "회의 참여자 목록", example = "[1, 2, 3]") - private List members; + private List members; } @Getter @@ -210,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 478f1e8..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 @@ -1,24 +1,19 @@ 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.meeting.enums.MeetingStatus; 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; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -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 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; import lombok.Getter; import lombok.NoArgsConstructor; @@ -46,8 +41,54 @@ public class Meeting extends BaseEntity { @Column(name = "end_date_time") private LocalDateTime endDateTime; -// @OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) -// private final List meetingMembers = new ArrayList<>(); + @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(); + } + + public MeetingStatus getStatus(){ + return this.endDateTime == null ? MeetingStatus.ONGOING : MeetingStatus.COMPLETED; + } + + public boolean isOngoing(){ + return this.endDateTime == null; + } + + public LocalDateTime endMeeting() { + this.endDateTime = LocalDateTime.now(); + return this.endDateTime; + } + + public Long getDuration() { + if(this.endDateTime == null) return null; + return ChronoUnit.MINUTES.between(startDateTime, endDateTime); + } + + 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<>(); // // @OneToOne(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true) // private final MeetingAnalysis meetingAnalyses; 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/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/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 new file mode 100644 index 0000000..4e10518 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingMemberRepository.java @@ -0,0 +1,9 @@ +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 { + boolean existsByMemberIdAndMeetingId(Long memberId, Long meetingId); +} 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..d60cf9c --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/repository/MeetingRepository.java @@ -0,0 +1,31 @@ +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; + +import java.util.List; +import java.util.Optional; + +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); + + @Query(""" + SELECT m FROM Meeting m + LEFT JOIN FETCH MeetingMember mm + ON mm.meeting.id = m.id + WHERE m.id = :meetingId + """) + 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 new file mode 100644 index 0000000..55fca3d --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingCommandService.java @@ -0,0 +1,109 @@ +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.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; +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; + +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class MeetingCommandService { + + private final MeetingMemberRepository meetingMemberRepository; + private final MeetingRepository meetingRepository; + + private final MemberUseCase memberUseCase; + private final TeamUseCase teamUseCase; + + private final MeetingSocketRoomService meetingSocketRoomService; + + /* + 회의를 생성합니다. + + 회의를 생성하여 endDateTime이 null인 회의를 저장합니다. + - endDateTime == null : 진행 중인 회의를 의미합니다. + + 회의와 회의참여자 정보가 함께 저장됩니다. + */ + @Transactional + public MeetingResponse.MeetingCreateResponseDTO 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); + + // 실시간 회의 추가 + meetingSocketRoomService.createRoomIfAbsent(savedMeeting.getId()); // 현재 참여자 추가 + + // dto 생성 후 반환 + return MeetingResponse.MeetingCreateResponseDTO.builder() + .meetingId(savedMeeting.getId()) + .name(savedMeeting.getName()) + .startDateTime(savedMeeting.getStartDateTime()) + .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/service/MeetingQueryService.java b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java new file mode 100644 index 0000000..088db8a --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/service/MeetingQueryService.java @@ -0,0 +1,80 @@ +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.exception.MeetingNotFoundException; +import com.whylog.server.domain.meeting.repository.MeetingRepository; +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; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MeetingQueryService { + + private final MeetingRepository meetingRepository; + private final MeetingUseCase meetingUseCase; + + // 미팅 목록 조회 + @Transactional(readOnly = true) + public List getMeetings(Long teamId, MeetingStatus status){ + + // 기본값: 진행완료 + 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로 반환 + .build() + ).toList(); + + } + + // 회의 기본 정보 조회 + @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(); + } + + private boolean checkMeetingStatus(Meeting meeting, MeetingStatus status){ + return meeting.getStatus() == status; + } + +} 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/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..43c69fd --- /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.getName()); + } + + // 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..e6f747c --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/MeetingSocketRoomService.java @@ -0,0 +1,189 @@ +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; +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.time.LocalDateTime; +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()); + } + }); + } + + // 회의 종료 메시지를 현재 회의방 참가자 전체에게 전송합니다. + 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); + } + + // 메모리에 올라와 있는 회의방 저장소를 조회합니다. + 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/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 new file mode 100644 index 0000000..de2d1e6 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/meeting/socket/message/MeetingMessageType.java @@ -0,0 +1,41 @@ +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"), + MEETING_ENDED("meeting_ended"), + 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/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 new file mode 100644 index 0000000..01f53cc --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/exception/TeamErrorCode.java @@ -0,0 +1,45 @@ +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", "존재하지 않는 팀입니다."), + + // 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; + 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/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 new file mode 100644 index 0000000..9221589 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamRepository.java @@ -0,0 +1,10 @@ +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 { + + 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/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/dto/AuthRequest.java b/src/main/java/com/whylog/server/domain/user/dto/AuthRequest.java index b015d5d..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,11 +15,15 @@ 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; - @Schema(description = "비밀번호", example = "wtf1234") + @Schema(description = "비밀번호", example = "wtf12345") @NotBlank @Size(min = 8, max = 100) private String password; @@ -36,7 +40,7 @@ public static class LoginDTO { @NotBlank @Email private String email; - @Schema(description = "비밀번호", example = "wtf1234") + @Schema(description = "비밀번호", example = "wtf12345") @NotBlank private String password; } 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..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 @@ -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; @@ -26,6 +27,9 @@ public class Member extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "name", length = 50, nullable = false) + private String name; + @Column(length = 50, nullable = false, unique = true) private String email; @@ -40,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/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/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); } 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); + } +} 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..50c0349 --- /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().findAndRegisterModules(); + + // 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(failKeyword, 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); + } +}