Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,6 +54,10 @@ public class MeetingController {
페이징 없습니다.

""")
@ApiErrorCodeExamples({
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_UNAUTHORIZED"),
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST")
})
public ApiResponse<List<MeetingResponse.MeetingListDTO>> getMeetings(
@Parameter(hidden = true) @CurrentMember Long memberId,
@PathVariable Long teamId,
Expand All @@ -72,6 +82,13 @@ public ApiResponse<List<MeetingResponse.MeetingListDTO>> 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<MeetingResponse.MeetingCreateResponseDTO> createMeeting(
@Parameter(hidden = true) @CurrentMember Long memberId,
@PathVariable Long teamId,
Expand All @@ -85,6 +102,12 @@ public ApiResponse<MeetingResponse.MeetingCreateResponseDTO> 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<MeetingResponse.MeetingRtcTokenDTO> issueRtcToken(
@Parameter(hidden = true) @CurrentMember Long memberId,
@PathVariable Long meetingId
Expand All @@ -98,6 +121,13 @@ public ApiResponse<MeetingResponse.MeetingRtcTokenDTO> 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<MeetingResponse.MeetingEndResponseDTO> endMeeting(
@Parameter(hidden= true) @CurrentMember Long memberId,
@PathVariable Long meetingId) {
Expand All @@ -110,27 +140,48 @@ public ApiResponse<MeetingResponse.MeetingEndResponseDTO> 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<MeetingResponse.MeetingDetailDTO> getMeetingDetail(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(meetingQueryService.getMeetingDefaultInfo(meetingId));
}

@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<MeetingResponse.HistoryListDTO> getHistory(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(null);
}

@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<MeetingResponse.AnalysisResultDTO> getAnalysisResult(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(null);
}

@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<MeetingResponse.AudioDTO> getAudio(
@PathVariable Long meetingId) {
return ApiResponse.onSuccess(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<List<DecisionResponse.DecisionListDTO>> getDecisions(
@PathVariable Long teamId) {
List<DecisionResponse.DecisionListDTO> decisions = teamQueryService.decisions(teamId);
Expand All @@ -53,6 +59,8 @@ public ApiResponse<List<DecisionResponse.DecisionListDTO>> 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")
Expand Down Expand Up @@ -100,8 +108,15 @@ public ApiResponse<TeamResponse.InvitationResponseDTO> 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<TeamResponse.TeamCreateResponseDTO> createTeam(
@Parameter(hidden = true) @CurrentMember Long memberId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TeamMember, TeamMemberId> {

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<TeamMember> findActiveTeamsByMemberId(@Param("memberId") Long memberId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AuthResponse.LoginResponseDTO> signup(
@Valid @RequestBody AuthRequest.SignUpDTO request,
HttpServletResponse httpServletResponse
Expand Down Expand Up @@ -93,6 +101,10 @@ public ApiResponse<AuthResponse.LoginResponseDTO> signup(
그래도...
- 쿠키로 처리하기 귀찮거나...번거로울 경우를 대비해 Refresh Token을 Response Body에도 담아 보냅니다...😎
""")
@ApiErrorCodeExamples({
@ApiErrorCodeExample(value = ErrorStatus.class, name = "_BAD_REQUEST"),
@ApiErrorCodeExample(value = AuthErrorStatus.class, name = "LOGIN_FAILED")
})
public ApiResponse<AuthResponse.LoginResponseDTO> login(
@Valid @RequestBody AuthRequest.LoginDTO request,
HttpServletResponse httpServletResponse
Expand Down Expand Up @@ -126,6 +138,12 @@ public ApiResponse<AuthResponse.LoginResponseDTO> 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<AccessTokenGenerateResponse> refreshToken(
@CookieValue(REFRESH_TOKEN) String refreshToken
) {
Expand All @@ -151,6 +169,10 @@ public ApiResponse<AccessTokenGenerateResponse> 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<Void> logout(
@CurrentMember Long memberId,
HttpServletResponse httpServletResponse
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<List<MemberResponse.TeamListResponseDTO>> 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 = """
Expand All @@ -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<MemberResponse.ProfileImageUploadResponseDTO> uploadProfileImage(
@Parameter(hidden = true) @CurrentMember Long memberId,
@RequestPart("image") MultipartFile image
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MemberResponse.TeamListResponseDTO> 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();
}
}
Loading