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 52abc0f..b791f7d 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 @@ -3,10 +3,16 @@ 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.exception.MeetingErrorCode; import com.whylog.server.domain.meeting.service.MeetingCommandService; import com.whylog.server.domain.meeting.service.MeetingQueryService; import com.whylog.server.domain.meeting.service.MeetingRtcService; +import com.whylog.server.domain.team.exception.TeamErrorCode; +import com.whylog.server.domain.user.exception.MemberErrorStatus; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -48,6 +54,10 @@ public class MeetingController { 페이징 없습니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + }) public ApiResponse> getMeetings( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @@ -72,6 +82,13 @@ public ApiResponse> getMeetings( 실제 실시간 음성 전달은 WebRTC/SFU 경로를 사용합니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND") + }) public ApiResponse createMeeting( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long teamId, @@ -85,6 +102,12 @@ public ApiResponse createMeeting( 현재 로그인한 사용자가 해당 회의의 LiveKit SFU room에 접속할 수 있도록 join token을 발급합니다. 프론트는 이 토큰과 serverUrl을 사용해 WebRTC 음성 연결을 수립합니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND") + }) public ApiResponse issueRtcToken( @Parameter(hidden = true) @CurrentMember Long memberId, @PathVariable Long meetingId @@ -98,6 +121,13 @@ public ApiResponse issueRtcToken( 회의 종료 시 실시간 회의 참여자들에게 종료를 알리는 웹소켓 메시지를 전송합니다. 종료 이후의 음성 파일 처리, STT, 회의 분석은 비동기 후처리 파이프라인에서 수행합니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_INVALID_MEMBER"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_ALREADY_ENDED") + }) public ApiResponse endMeeting( @Parameter(hidden= true) @CurrentMember Long memberId, @PathVariable Long meetingId) { @@ -110,6 +140,12 @@ public ApiResponse endMeeting( 회의명, 날짜, 기간, 참여자 정보를 제공합니다. 본 API에서 분석결과, 대화기록은 제공하지 않습니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_PARAMETER_REQUIRED"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + }) public ApiResponse getMeetingDetail( @PathVariable Long meetingId) { return ApiResponse.onSuccess(meetingQueryService.getMeetingDefaultInfo(meetingId)); @@ -117,6 +153,11 @@ public ApiResponse getMeetingDetail( @GetMapping("/meetings/{meetingId}/history") @Operation(summary = "회의 대화 기록 조회 API", description = "특정 회의의 대화 기록을 조회하는 API입니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + }) public ApiResponse getHistory( @PathVariable Long meetingId) { return ApiResponse.onSuccess(null); @@ -124,6 +165,11 @@ public ApiResponse getHistory( @GetMapping("/meetings/{meetingId}/analysis") @Operation(summary = "회의 분석 결과 조회 API", description = "회의 분석 결과를 조회하는 API입니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + }) public ApiResponse getAnalysisResult( @PathVariable Long meetingId) { return ApiResponse.onSuccess(null); @@ -131,6 +177,11 @@ public ApiResponse getAnalysisResult( @GetMapping("/meetings/{meetingId}/audio") @Operation(summary = "오디오 리플레이 API", description = "회의 오디오 파일을 리플레이하는 API입니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = MeetingErrorCode.class, name = "MEETING_NOT_FOUND") + }) public ApiResponse getAudio( @PathVariable Long meetingId) { return ApiResponse.onSuccess(null); 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 c76624d..04d4bf2 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 @@ -10,7 +10,9 @@ import com.whylog.server.global.apiPayload.ApiResponse; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; +import com.whylog.server.global.external.s3.S3ErrorCode; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Encoding; import io.swagger.v3.oas.annotations.media.Schema; @@ -42,6 +44,10 @@ public class TeamController { @GetMapping("/{teamId}/decisions") @Operation(summary = "결정사항 목록 조회 API", description = "특정 팀의 결정사항 목록을 조회하는 API입니다.") + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST") + }) public ApiResponse> getDecisions( @PathVariable Long teamId) { List decisions = teamQueryService.decisions(teamId); @@ -53,6 +59,8 @@ public ApiResponse> getDecisions( 특정 사용자를 팀에 초대합니다. 팀 초대를 하면 상대는 즉시 팀원으로 추가됩니다. """) @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NOT_FOUND"), @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_MEMBER_ALREADY_EXISTS") @@ -100,8 +108,15 @@ public ApiResponse sendInvitation( ) ) @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_ALREADY_EXISTS"), - @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_LENGTH") + @ApiErrorCodeExample(value = TeamErrorCode.class, name = "TEAM_NAME_LENGTH"), + @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") }) public ApiResponse createTeam( @Parameter(hidden = true) @CurrentMember Long memberId, 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 index a90496d..4f4b88a 100644 --- a/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java +++ b/src/main/java/com/whylog/server/domain/team/repository/TeamMemberRepository.java @@ -2,9 +2,22 @@ import com.whylog.server.domain.team.entity.TeamMember; import com.whylog.server.domain.team.entity.TeamMemberId; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TeamMemberRepository extends JpaRepository { boolean existsByTeamIdAndMemberIdAndActiveTrue(Long teamId, Long memberId); + + @Query(""" + SELECT tm + FROM TeamMember tm + JOIN FETCH tm.team t + WHERE tm.member.id = :memberId + AND tm.active = true + ORDER BY t.id ASC + """) + List findActiveTeamsByMemberId(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/whylog/server/domain/user/controller/AuthController.java b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java index 830e2cb..45caa83 100644 --- a/src/main/java/com/whylog/server/domain/user/controller/AuthController.java +++ b/src/main/java/com/whylog/server/domain/user/controller/AuthController.java @@ -3,9 +3,13 @@ import com.whylog.server.domain.user.dto.AccessTokenGenerateResponse; import com.whylog.server.domain.user.dto.AuthRequest; import com.whylog.server.domain.user.dto.AuthResponse; +import com.whylog.server.domain.user.exception.AuthErrorStatus; import com.whylog.server.domain.user.service.AuthenticationService; import com.whylog.server.domain.user.service.LocalLoginService; import com.whylog.server.domain.user.exception.AuthSuccessStatus; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; import com.whylog.server.global.auth.jwt.application.TokenService; import com.whylog.server.global.apiPayload.ApiResponse; @@ -59,6 +63,10 @@ public class AuthController { 그래도... - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "EMAIL_ALREADY_EXISTS") + }) public ApiResponse signup( @Valid @RequestBody AuthRequest.SignUpDTO request, HttpServletResponse httpServletResponse @@ -93,6 +101,10 @@ public ApiResponse signup( 그래도... - 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎 """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "LOGIN_FAILED") + }) public ApiResponse login( @Valid @RequestBody AuthRequest.LoginDTO request, HttpServletResponse httpServletResponse @@ -126,6 +138,12 @@ public ApiResponse login( - 새 액세스 토큰 TTL: 1시간 (`3600000ms`) - 기존 리프레시 토큰 JWT TTL: 14일 (`1209600000ms`) """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "INVALID_REFRESH_TOKEN"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_EXPIRED"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_NOT_FOUND") + }) public ApiResponse refreshToken( @CookieValue(REFRESH_TOKEN) String refreshToken ) { @@ -151,6 +169,10 @@ public ApiResponse refreshToken( - `Set-Cookie: refreshToken=; Max-Age=0; Path=/; HttpOnly; SameSite=Lax` - 브라우저 기준으로 refresh token 쿠키가 제거됩니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = AuthErrorStatus.class, name = "REFRESH_TOKEN_NOT_FOUND") + }) public ApiResponse logout( @CurrentMember Long memberId, HttpServletResponse httpServletResponse diff --git a/src/main/java/com/whylog/server/domain/user/controller/MemberController.java b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java index afbbd4c..042d2f9 100644 --- a/src/main/java/com/whylog/server/domain/user/controller/MemberController.java +++ b/src/main/java/com/whylog/server/domain/user/controller/MemberController.java @@ -1,14 +1,22 @@ package com.whylog.server.domain.user.controller; import com.whylog.server.domain.user.dto.MemberResponse; +import com.whylog.server.domain.user.exception.MemberErrorStatus; import com.whylog.server.domain.user.service.MemberCommandService; +import com.whylog.server.domain.user.service.MemberQueryService; import com.whylog.server.global.apiPayload.ApiResponse; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExample; +import com.whylog.server.global.apiPayload.annotation.ApiErrorCodeExamples; +import com.whylog.server.global.apiPayload.code.status.ErrorStatus; import com.whylog.server.global.auth.annotation.CurrentMember; +import com.whylog.server.global.external.s3.S3ErrorCode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -22,6 +30,26 @@ public class MemberController { private final MemberCommandService memberCommandService; + private final MemberQueryService memberQueryService; + + @GetMapping("/teams") + @Operation(summary = "내 소속 팀 목록 조회 API", description = """ + + ## 설명 + 현재 로그인한 멤버가 소속된 활성 팀 목록을 조회합니다. + + ## 응답 + `team_id`, `name`, `team_image` 필드를 가진 배열을 반환합니다. + + """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED") + }) + public ApiResponse> getMyTeams( + @Parameter(hidden = true) @CurrentMember Long memberId + ) { + return ApiResponse.onSuccess(memberQueryService.getTeams(memberId)); + } @PostMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @Operation(summary = "멤버 프로필 이미지 업로드 API", description = """ @@ -43,6 +71,14 @@ public class MemberController { `Content-Type`은 직접 지정하지 않습니다. 브라우저가 boundary를 포함한 `multipart/form-data` 값을 자동으로 생성해야 합니다. """) + @ApiErrorCodeExamples({ + @ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"), + @ApiErrorCodeExample(value = MemberErrorStatus.class, name = "MEMBER_NOT_FOUND"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_FILE_NAME_EMPTY"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_BUCKET_NOT_CONFIGURED"), + @ApiErrorCodeExample(value = S3ErrorCode.class, name = "S3_UPLOAD_FAILED") + }) public ApiResponse uploadProfileImage( @Parameter(hidden = true) @CurrentMember Long memberId, @RequestPart("image") MultipartFile image diff --git a/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java b/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java index b8f29c5..2b8392c 100644 --- a/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java +++ b/src/main/java/com/whylog/server/domain/user/dto/MemberResponse.java @@ -21,4 +21,21 @@ public static class ProfileImageUploadResponseDTO { @Schema(description = "프로필 이미지 URL", example = "https://server-images-437659978683-ap-northeast-2-an.s3.ap-northeast-2.amazonaws.com/member_profile/member_profile_image_2026-04-15-03-23-22-262.png") private String profileImageUrl; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "소속 팀 목록 조회 응답") + public static class TeamListResponseDTO { + + @Schema(description = "팀 ID", example = "1") + private Long teamId; + + @Schema(description = "팀명", example = "팀명이 어떻게 다마고치") + private String name; + + @Schema(description = "팀 이미지 URL", example = "https://cdn.whylog.com/teams/team-image.png") + private String teamImage; + } } diff --git a/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java b/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java new file mode 100644 index 0000000..aa65bd0 --- /dev/null +++ b/src/main/java/com/whylog/server/domain/user/service/MemberQueryService.java @@ -0,0 +1,32 @@ +package com.whylog.server.domain.user.service; + +import com.whylog.server.domain.team.entity.Team; +import com.whylog.server.domain.team.repository.TeamMemberRepository; +import com.whylog.server.domain.user.dto.MemberResponse; +import com.whylog.server.global.external.s3.S3Client; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberQueryService { + + private final TeamMemberRepository teamMemberRepository; + private final S3Client s3Client; + + @Transactional(readOnly = true) + public List getTeams(Long memberId) { + return teamMemberRepository.findActiveTeamsByMemberId(memberId).stream() + .map(teamMember -> { + Team team = teamMember.getTeam(); + return MemberResponse.TeamListResponseDTO.builder() + .teamId(team.getId()) + .name(team.getName()) + .teamImage(s3Client.getFileUrl(team.getImage())) + .build(); + }) + .toList(); + } +}