diff --git a/src/main/java/project/flipnote/group/controller/GroupController.java b/src/main/java/project/flipnote/group/controller/GroupController.java index 64ef4b9b..561cc1fc 100644 --- a/src/main/java/project/flipnote/group/controller/GroupController.java +++ b/src/main/java/project/flipnote/group/controller/GroupController.java @@ -18,6 +18,7 @@ import lombok.RequiredArgsConstructor; import project.flipnote.common.model.response.CursorPagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.group.controller.docs.GroupControllerDocs; import project.flipnote.group.model.FindGroupMemberResponse; import project.flipnote.group.model.GroupCreateRequest; import project.flipnote.group.model.GroupCreateResponse; @@ -31,7 +32,7 @@ @RequiredArgsConstructor @RestController @RequestMapping("/v1/groups") -public class GroupController { +public class GroupController implements GroupControllerDocs { private final GroupService groupService; //그룹 생성 API diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupControllerDocs.java new file mode 100644 index 00000000..cdd7b341 --- /dev/null +++ b/src/main/java/project/flipnote/group/controller/docs/GroupControllerDocs.java @@ -0,0 +1,261 @@ +package project.flipnote.group.controller.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import project.flipnote.common.model.response.CursorPagingResponse; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.group.model.FindGroupMemberResponse; +import project.flipnote.group.model.GroupCreateRequest; +import project.flipnote.group.model.GroupCreateResponse; +import project.flipnote.group.model.GroupDetailResponse; +import project.flipnote.group.model.GroupInfo; +import project.flipnote.group.model.GroupListRequest; +import project.flipnote.group.model.GroupPutRequest; +import project.flipnote.group.model.GroupPutResponse; + +@Tag(name = "그룹", description = "그룹 생성/수정/상세/삭제/멤버/목록 API") +@SecurityRequirement(name = "access-token") +public interface GroupControllerDocs { + + //그룹 생성 + @Operation( + summary = "그룹 생성", + description = "새 그룹을 생성하고 생성자를 OWNER로 등록합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GroupCreateResponse.class), + examples = @ExampleObject(name = "성공", value = "{\"groupId\":123}") + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청(최대 인원/카테고리 등)"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + ResponseEntity create( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "그룹 생성 요청 바디", + required = true, + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GroupCreateRequest.class), + examples = @ExampleObject(name = "요청 예시", value = """ + { + "name": "백엔드 스터디", + "category": "IT", + "description": "스프링/인프라 중심의 백엔드 스터디 그룹입니다.", + "applicationRequired": true, + "publicVisible": true, + "maxMember": 20, + "image": "https://cdn.example.com/group/cover.jpg" + } + """) + ) + ) + @Valid GroupCreateRequest req + ); + + //그룹 수정 + @Operation( + summary = "그룹 수정", + description = "기존 그룹 정보를 수정합니다. 오너만 가능합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GroupPutResponse.class), + examples = @ExampleObject(name = "응답 예시", value = """ + { + "name": "백엔드 스터디(수정)", + "category": "IT", + "description": "소개 수정", + "applicationRequired": false, + "publicVisible": true, + "maxMember": 30, + "imageUrl": "https://cdn.example.com/group/cover_v2.png", + "createdAt": "2025-08-20T12:34:56", + "modifiedAt": "2025-08-31T16:10:00" + } + """) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청(최대 인원/카테고리 등)"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(오너 아님)"), + @ApiResponse(responseCode = "404", description = "그룹 없음") + }) + ResponseEntity changeGroup( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "그룹 수정 요청 바디", + required = true, + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GroupPutRequest.class), + examples = @ExampleObject(name = "요청 예시", value = """ + { + "name": "백엔드 스터디(수정)", + "category": "IT", + "description": "소개 수정", + "applicationRequired": false, + "publicVisible": true, + "maxMember": 30, + "image": "https://cdn.example.com/group/cover_v2.png" + } + """) + ) + ) + @Valid GroupPutRequest req, + @Parameter(description = "그룹 ID", required = true, example = "1") Long groupId + ); + + @Operation(summary = "그룹 상세", description = "그룹 상세 정보를 조회합니다. 그룹 멤버만 접근 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = GroupDetailResponse.class) + ) + ), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "그룹 없음/그룹 내 유저 없음") + }) + ResponseEntity findGroupDetail( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", required = true, example = "1") Long groupId + ); + + @Operation(summary = "그룹 삭제", description = "오너만 그룹을 삭제할 수 있습니다. 오너 외 멤버가 존재하면 삭제 불가입니다.") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(오너 아님)"), + @ApiResponse(responseCode = "404", description = "그룹 없음"), + @ApiResponse(responseCode = "409", description = "오너 외 멤버 존재") + }) + ResponseEntity deleteGroup( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", required = true, example = "123") Long groupId + ); + + @Operation(summary = "그룹내 멤버 조회", description = "그룹 멤버 목록을 조회합니다. 그룹 멤버만 접근 가능합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = FindGroupMemberResponse.class) + ) + ), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "그룹 없음/그룹 내 유저 없음") + }) + ResponseEntity findGroupMembers( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", required = true, example = "123") Long groupId + ); + + @Operation(summary = "그룹 전체 조회(커서 페이징)", description = "카테고리/커서/사이즈로 그룹 목록을 조회합니다.") + @Parameters({ + @Parameter( + name = "cursor", + description = "커서 ID (이전 응답의 nextCursor). 기본값: null", + example = "40", + schema = @Schema(nullable = true) + ), + @Parameter( + name = "size", + description = "페이지 크기. 기본값: 10", + example = "10", + schema = @Schema(defaultValue = "10", minimum = "1") + ), + @Parameter( + name = "category", + description = "카테고리 필터 (예: IT). 기본값: null", + example = "IT", + schema = @Schema(nullable = true) + ), + @Parameter( + name = "sortBy", + description = "(더미) 현재 미사용", + example = "string", + deprecated = true + ), + @Parameter( + name = "order", + description = "(더미) 현재 미사용", + example = "string", + deprecated = true + ) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CursorPagingResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청(카테고리 등)"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + ResponseEntity> findGroup( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @org.springdoc.core.annotations.ParameterObject + @Valid GroupListRequest req + ); + + @Operation(summary = "내 그룹 전체 조회(커서 페이징)", description = "현재 사용자 기준으로 가입한 그룹 목록을 커서 페이징으로 조회합니다.") + @Parameters({ + @Parameter( + name = "cursor", + description = "커서 ID (이전 응답의 nextCursor). 기본값: null", + example = "40", + schema = @Schema(nullable = true) + ), + @Parameter( + name = "size", + description = "페이지 크기. 기본값: 10", + example = "10", + schema = @Schema(defaultValue = "10", minimum = "1") + ), + @Parameter( + name = "category", + description = "카테고리 필터 (예: IT). 기본값: null", + example = "IT", + schema = @Schema(nullable = true) + ), + @Parameter( + name = "sortBy", + description = "(더미) 현재 미사용", + example = "string", + deprecated = true + ), + @Parameter( + name = "order", + description = "(더미) 현재 미사용", + example = "string", + deprecated = true + ) + }) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = CursorPagingResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "잘못된 요청(카테고리 등)"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + ResponseEntity> findMyGroup( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @org.springdoc.core.annotations.ParameterObject + @Valid GroupListRequest req + ); +} diff --git a/src/main/java/project/flipnote/group/model/GroupCreateRequest.java b/src/main/java/project/flipnote/group/model/GroupCreateRequest.java index 0c17bb3e..78e6b374 100644 --- a/src/main/java/project/flipnote/group/model/GroupCreateRequest.java +++ b/src/main/java/project/flipnote/group/model/GroupCreateRequest.java @@ -1,29 +1,34 @@ package project.flipnote.group.model; +import org.hibernate.validator.constraints.URL; + import jakarta.validation.constraints.*; import project.flipnote.group.entity.Category; public record GroupCreateRequest( - @NotBlank - @Size(max = 50) - String name, + @NotBlank(message = "그룹 이름을 입력해주세요.") + @Size(max = 50, message = "그룹 이름은 최대 50자까지 입력할 수 있습니다.") + String name, - @NotNull - Category category, + @NotNull(message = "그룹 카테고리를 선택해야 합니다.") + Category category, - @NotBlank - @Size(max = 150) - String description, + @NotBlank(message = "그룹 설명을 입력해주세요.") + @Size(max = 150, message = "그룹 설명은 최대 150자까지 입력할 수 있습니다.") + String description, - @NotNull - Boolean applicationRequired, + @NotNull(message = "가입 승인 필요 여부를 선택해주세요.") + Boolean applicationRequired, - @NotNull - Boolean publicVisible, + @NotNull(message = "공개 여부를 선택해주세요.") + Boolean publicVisible, - @NotNull - Integer maxMember, + @NotNull(message = "최대 인원 수를 입력해주세요.") + @Min(value = 1, message = "최대 인원 수는 1명 이상이어야 합니다.") + @Max(value = 100, message = "최대 인원 수는 100명을 초과할 수 없습니다.") + Integer maxMember, - String image + @URL(message = "이미지 URL 형식이 올바르지 않습니다.") + String image ) { } diff --git a/src/main/java/project/flipnote/group/model/GroupPutRequest.java b/src/main/java/project/flipnote/group/model/GroupPutRequest.java index 6f3b614e..e07fdd1d 100644 --- a/src/main/java/project/flipnote/group/model/GroupPutRequest.java +++ b/src/main/java/project/flipnote/group/model/GroupPutRequest.java @@ -1,31 +1,38 @@ package project.flipnote.group.model; +import org.hibernate.validator.constraints.URL; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import project.flipnote.group.entity.Category; public record GroupPutRequest( - @NotBlank - @Size(max = 50) + @NotBlank(message = "그룹 이름을 입력해주세요.") + @Size(max = 50, message = "그룹 이름은 최대 50자까지 입력할 수 있습니다.") String name, - @NotNull + @NotNull(message = "그룹 카테고리를 선택해야 합니다.") Category category, - @NotBlank - @Size(max = 150) + @NotBlank(message = "그룹 설명을 입력해주세요.") + @Size(max = 150, message = "그룹 설명은 최대 150자까지 입력할 수 있습니다.") String description, - @NotNull + @NotNull(message = "가입 승인 필요 여부를 선택해주세요.") Boolean applicationRequired, - @NotNull + @NotNull(message = "공개 여부를 선택해주세요.") Boolean publicVisible, - @NotNull + @NotNull(message = "최대 인원 수를 입력해주세요.") + @Min(value = 1, message = "최대 인원 수는 1명 이상이어야 합니다.") + @Max(value = 100, message = "최대 인원 수는 100명을 초과할 수 없습니다.") Integer maxMember, + @URL(message = "이미지 URL 형식이 올바르지 않습니다.") String image ) { } diff --git a/src/main/java/project/flipnote/group/service/GroupService.java b/src/main/java/project/flipnote/group/service/GroupService.java index 49cb337f..cd3f4a9e 100644 --- a/src/main/java/project/flipnote/group/service/GroupService.java +++ b/src/main/java/project/flipnote/group/service/GroupService.java @@ -2,7 +2,10 @@ import java.util.Arrays; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -108,6 +111,34 @@ public Group getGroup(Long groupId) { ); } + private List getOrCreateGroupPermissions() { + List all = groupPermissionRepository.findAll(); + + if (all.size() == GroupPermissionStatus.values().length) { + return all; + } + + Set existing = all.stream() + .map(GroupPermission::getName) + .collect(Collectors.toSet()); + + List missing = Arrays.stream(GroupPermissionStatus.values()) + .filter(s -> !existing.contains(s)) + .map(s -> GroupPermission.builder().name(s).build()) + .toList(); + + if (!missing.isEmpty()) { + try { + groupPermissionRepository.saveAll(missing); + } catch (DataIntegrityViolationException ignore) { + // 다른 트랜잭션이 먼저 넣은 경우: 무시하고 재조회 + } + all = groupPermissionRepository.findAll(); + } + return all; + } + + /* 그룹 생성 */ @@ -150,7 +181,7 @@ public Boolean hasPermission(Long groupId, Long userId, GroupPermissionStatus gr 최초 그룹 권한 설정 */ private void initializeGroupPermissions(Group group) { - List groupPermissions = groupPermissionRepository.findAll(); + List groupPermissions = getOrCreateGroupPermissions(); List groupRolePermissions = Arrays.stream(GroupMemberRole.values()) .flatMap(role -> groupPermissions.stream() diff --git a/src/main/java/project/flipnote/groupjoin/controller/GroupJoinController.java b/src/main/java/project/flipnote/groupjoin/controller/GroupJoinController.java index 2ae492c6..530953de 100644 --- a/src/main/java/project/flipnote/groupjoin/controller/GroupJoinController.java +++ b/src/main/java/project/flipnote/groupjoin/controller/GroupJoinController.java @@ -8,13 +8,14 @@ import lombok.RequiredArgsConstructor; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.groupjoin.controller.docs.GroupJoinControllerDocs; import project.flipnote.groupjoin.model.*; import project.flipnote.groupjoin.service.GroupJoinService; @RestController @RequestMapping("/v1") @RequiredArgsConstructor -public class GroupJoinController { +public class GroupJoinController implements GroupJoinControllerDocs { private final GroupJoinService groupJoinService; diff --git a/src/main/java/project/flipnote/groupjoin/controller/docs/GroupJoinControllerDocs.java b/src/main/java/project/flipnote/groupjoin/controller/docs/GroupJoinControllerDocs.java new file mode 100644 index 00000000..fff86c3b --- /dev/null +++ b/src/main/java/project/flipnote/groupjoin/controller/docs/GroupJoinControllerDocs.java @@ -0,0 +1,124 @@ +package project.flipnote.groupjoin.controller.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.groupjoin.model.FindGroupJoinListMeResponse; +import project.flipnote.groupjoin.model.GroupJoinListResponse; +import project.flipnote.groupjoin.model.GroupJoinRequest; +import project.flipnote.groupjoin.model.GroupJoinRespondRequest; +import project.flipnote.groupjoin.model.GroupJoinRespondResponse; +import project.flipnote.groupjoin.model.GroupJoinResponse; + +@Tag(name = "그룹 가입신청", description = "그룹 가입신청 관리 API") +@SecurityRequirement(name = "access-token") +public interface GroupJoinControllerDocs { + @Operation( + summary = "가입 신청 요청", + description = "공개 그룹에 대해 가입 신청을 생성합니다. 그룹 정책에 따라 PENDING 또는 즉시 ACCEPT로 저장됩니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "가입 신청 생성 성공", + content = @Content(schema = @Schema(implementation = GroupJoinResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (검증 실패 등)"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "비공개 그룹 → GROUP_JOIN_003", + content = @Content(schema = @Schema(example = "{\"code\":\"GROUP_JOIN_003\",\"message\":\"그룹이 비공개입니다.\"}"))), + @ApiResponse(responseCode = "404", description = "그룹 없음"), + @ApiResponse(responseCode = "409", description = "이미 가입 신청 존재 → GROUP_JOIN_005 / 정원 초과 → GROUP_JOIN_006", + content = @Content(schema = @Schema(example = "{\"code\":\"GROUP_JOIN_005\",\"message\":\"이미 신청한 그룹입니다.\"}"))) + }) + ResponseEntity joinRequest( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", example = "123") Long groupId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "가입 신청 요청 데이터", + required = true, + content = @Content(schema = @Schema(implementation = GroupJoinRequest.class)) + ) + @Valid GroupJoinRequest req + ); + + @Operation( + summary = "그룹 내 가입 신청 리스트 조회", + description = "가입신청 관리 권한을 가진 멤버가 그룹 내 신청 내역을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = GroupJoinListResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음 → GROUP_JOIN_002", + content = @Content(schema = @Schema(example = "{\"code\":\"GROUP_JOIN_002\",\"message\":\"그룹 내 권한이 없습니다.\"}"))), + @ApiResponse(responseCode = "404", description = "그룹 없음") + }) + ResponseEntity findGroupJoinList( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", example = "123") Long groupId + ); + + @Operation( + summary = "가입 신청 응답", + description = "그룹 관리 권한자가 특정 가입 신청을 승인(ACCEPT) 또는 거절(DENY)합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "응답 처리 성공", + content = @Content(schema = @Schema(implementation = GroupJoinRespondResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음 → GROUP_JOIN_002"), + @ApiResponse(responseCode = "404", description = "가입신청 없음 → GROUP_JOIN_004"), + @ApiResponse(responseCode = "409", description = "정원 초과 → GROUP_JOIN_006", + content = @Content(schema = @Schema(example = "{\"code\":\"GROUP_JOIN_006\",\"message\":\"그룹 정원이 가득 찼습니다.\"}"))) + }) + ResponseEntity respondToJoinRequest( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", example = "123") Long groupId, + @Parameter(description = "가입신청 ID", example = "456") Long joinId, + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "승인/거절 상태", + required = true, + content = @Content(schema = @Schema(implementation = GroupJoinRespondRequest.class)) + ) + @Valid GroupJoinRespondRequest req + ); + + //가입 신청 삭제 + @Operation( + summary = "가입 신청 삭제(취소)", + description = "신청자가 자신의 가입 신청을 취소합니다. 실제 삭제가 아닌 상태를 CANCEL로 변경합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "취소 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(본인 아님/그룹 불일치 등)"), + @ApiResponse(responseCode = "404", description = "가입신청 없음") + }) + public ResponseEntity groupJoinDelete( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple, + @Parameter(description = "그룹 ID", required = true, example = "123") Long groupId, + @Parameter(description = "가입신청 ID", required = true, example = "456") Long joinId + ); + + //내가 신청한 가입신청 리스트 조회 + @Operation( + summary = "내가 신청한 가입신청 리스트 조회", + description = "현재 사용자 기준으로 본인이 신청한 가입신청 목록을 조회합니다." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(schema = @Schema(implementation = FindGroupJoinListMeResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + public ResponseEntity findGroupJoinMe( + @Parameter(hidden = true) @AuthenticationPrincipal AuthPrinciple authPrinciple + ); +} diff --git a/src/main/java/project/flipnote/groupjoin/model/FindGroupJoinListMeResponse.java b/src/main/java/project/flipnote/groupjoin/model/FindGroupJoinListMeResponse.java index b9c3565c..29bfa053 100644 --- a/src/main/java/project/flipnote/groupjoin/model/FindGroupJoinListMeResponse.java +++ b/src/main/java/project/flipnote/groupjoin/model/FindGroupJoinListMeResponse.java @@ -5,9 +5,9 @@ import project.flipnote.groupjoin.entity.GroupJoin; public record FindGroupJoinListMeResponse( - List groupJoins + List groupJoins ) { - public static FindGroupJoinListMeResponse from(List groupJoins) { + public static FindGroupJoinListMeResponse from(List groupJoins) { return new FindGroupJoinListMeResponse(groupJoins); } } diff --git a/src/main/java/project/flipnote/groupjoin/model/GroupJoinInfo.java b/src/main/java/project/flipnote/groupjoin/model/GroupJoinInfo.java new file mode 100644 index 00000000..82aa179d --- /dev/null +++ b/src/main/java/project/flipnote/groupjoin/model/GroupJoinInfo.java @@ -0,0 +1,15 @@ +package project.flipnote.groupjoin.model; + +import project.flipnote.groupjoin.entity.GroupJoinStatus; + +public record GroupJoinInfo( + Long groupJoinId, + Long userId, + String nickname, + String joinIntro, + GroupJoinStatus status + ) { + public static GroupJoinInfo from(Long groupJoinId, Long userId, String nickname, String joinIntro, GroupJoinStatus status) { + return new GroupJoinInfo(groupJoinId, userId, nickname, joinIntro, status); + } +} diff --git a/src/main/java/project/flipnote/groupjoin/model/GroupJoinListResponse.java b/src/main/java/project/flipnote/groupjoin/model/GroupJoinListResponse.java index 96cec14a..40420f83 100644 --- a/src/main/java/project/flipnote/groupjoin/model/GroupJoinListResponse.java +++ b/src/main/java/project/flipnote/groupjoin/model/GroupJoinListResponse.java @@ -5,9 +5,9 @@ import java.util.List; public record GroupJoinListResponse( - List groupJoins + List groupJoins ) { - public static GroupJoinListResponse from(List groupJoins) { + public static GroupJoinListResponse from(List groupJoins) { return new GroupJoinListResponse(groupJoins); } } diff --git a/src/main/java/project/flipnote/groupjoin/model/GroupJoinRespondRequest.java b/src/main/java/project/flipnote/groupjoin/model/GroupJoinRespondRequest.java index 06501efc..0cbf9508 100644 --- a/src/main/java/project/flipnote/groupjoin/model/GroupJoinRespondRequest.java +++ b/src/main/java/project/flipnote/groupjoin/model/GroupJoinRespondRequest.java @@ -1,8 +1,10 @@ package project.flipnote.groupjoin.model; +import jakarta.validation.constraints.NotNull; import project.flipnote.groupjoin.entity.GroupJoinStatus; public record GroupJoinRespondRequest( - GroupJoinStatus status + @NotNull(message = "그룹 신청 상태를 선택해주세요.") + GroupJoinStatus status ) { } diff --git a/src/main/java/project/flipnote/groupjoin/model/MyGroupJoinInfo.java b/src/main/java/project/flipnote/groupjoin/model/MyGroupJoinInfo.java new file mode 100644 index 00000000..226789b2 --- /dev/null +++ b/src/main/java/project/flipnote/groupjoin/model/MyGroupJoinInfo.java @@ -0,0 +1,15 @@ +package project.flipnote.groupjoin.model; + +import project.flipnote.groupjoin.entity.GroupJoinStatus; + +public record MyGroupJoinInfo( + Long groupJoinId, + Long groupId, + String groupName, + String joinIntro, + GroupJoinStatus status +) { + public static MyGroupJoinInfo from(Long groupJoinId, Long groupId, String groupName, String joinIntro, GroupJoinStatus status) { + return new MyGroupJoinInfo(groupJoinId, groupId, groupName,joinIntro,status); + } +} diff --git a/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepository.java b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepository.java index a6c5d55a..e54e4680 100644 --- a/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepository.java +++ b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepository.java @@ -10,10 +10,6 @@ import project.flipnote.user.entity.UserProfile; @Repository -public interface GroupJoinRepository extends JpaRepository { - List findAllByGroup(Group group); - - List findAllByUser(UserProfile userProfile); - +public interface GroupJoinRepository extends JpaRepository, GroupJoinRepositoryCustom { boolean existsByGroup_idAndUser_id(Long groupId, Long userId); } diff --git a/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryCustom.java b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryCustom.java new file mode 100644 index 00000000..2af1ef22 --- /dev/null +++ b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryCustom.java @@ -0,0 +1,12 @@ +package project.flipnote.groupjoin.repository; + +import java.util.List; + +import project.flipnote.groupjoin.model.GroupJoinInfo; +import project.flipnote.groupjoin.model.MyGroupJoinInfo; + +public interface GroupJoinRepositoryCustom { + List findByGroup(Long groupId); + + List findByUser(Long userId); +} diff --git a/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryImpl.java b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryImpl.java new file mode 100644 index 00000000..c2bf2fde --- /dev/null +++ b/src/main/java/project/flipnote/groupjoin/repository/GroupJoinRepositoryImpl.java @@ -0,0 +1,54 @@ +package project.flipnote.groupjoin.repository; + +import java.util.List; + +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import project.flipnote.group.entity.QGroup; +import project.flipnote.group.model.GroupMemberInfo; +import project.flipnote.groupjoin.entity.QGroupJoin; +import project.flipnote.groupjoin.model.GroupJoinInfo; +import project.flipnote.groupjoin.model.MyGroupJoinInfo; +import project.flipnote.user.entity.QUserProfile; + +@RequiredArgsConstructor +public class GroupJoinRepositoryImpl implements GroupJoinRepositoryCustom{ + + private final JPAQueryFactory queryFactory; + + QGroup group = QGroup.group; + QGroupJoin groupJoin = QGroupJoin.groupJoin; + QUserProfile userProfile = QUserProfile.userProfile; + + @Override + public List findByGroup(Long groupId) { + return queryFactory.select(Projections.constructor( + GroupJoinInfo.class, + groupJoin.id, + groupJoin.user.id, + groupJoin.user.nickname, + groupJoin.joinIntro, + groupJoin.status + )) + .from(groupJoin) + .where(groupJoin.group.id.eq(groupId)) + .fetch(); + } + + @Override + public List findByUser(Long userId) { + return queryFactory.select(Projections.constructor( + MyGroupJoinInfo.class, + groupJoin.id, + groupJoin.group.id, + groupJoin.group.name, + groupJoin.joinIntro, + groupJoin.status + )) + .from(groupJoin) + .where(groupJoin.user.id.eq(userId)) + .fetch(); + } +} diff --git a/src/main/java/project/flipnote/groupjoin/service/GroupJoinService.java b/src/main/java/project/flipnote/groupjoin/service/GroupJoinService.java index c66dcbde..5ea97fa3 100644 --- a/src/main/java/project/flipnote/groupjoin/service/GroupJoinService.java +++ b/src/main/java/project/flipnote/groupjoin/service/GroupJoinService.java @@ -27,11 +27,13 @@ import project.flipnote.groupjoin.entity.GroupJoinStatus; import project.flipnote.groupjoin.exception.GroupJoinErrorCode; import project.flipnote.groupjoin.model.FindGroupJoinListMeResponse; +import project.flipnote.groupjoin.model.GroupJoinInfo; import project.flipnote.groupjoin.model.GroupJoinListResponse; import project.flipnote.groupjoin.model.GroupJoinRequest; import project.flipnote.groupjoin.model.GroupJoinRespondRequest; import project.flipnote.groupjoin.model.GroupJoinRespondResponse; import project.flipnote.groupjoin.model.GroupJoinResponse; +import project.flipnote.groupjoin.model.MyGroupJoinInfo; import project.flipnote.groupjoin.repository.GroupJoinRepository; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; @@ -84,7 +86,7 @@ private void checkMaxMember(Group group) { //그룹 내 권한 정보 조회 private Boolean hasPermission(Group group, UserProfile userProfile) { - GroupMember groupMember = groupMemberRepository.findByGroupAndUser(group, userProfile).orElseThrow( + GroupMember groupMember = groupMemberRepository.findByGroup_IdAndUser_Id(group.getId(), userProfile.getId()).orElseThrow( () -> new BizException(GroupJoinErrorCode.USER_NOT_IN_GROUP) ); @@ -98,8 +100,13 @@ private Boolean hasPermission(Group group, UserProfile userProfile) { } //그룹 내 모든 가입신청 요청 조회 - private List findGroupJoins(Group group) { - return groupJoinRepository.findAllByGroup(group); + private List findGroupJoins(Group group) { + return groupJoinRepository.findByGroup(group.getId()); + } + + //내가 가입한 가입신청 조회 + private List findMyGroupJoins(UserProfile user) { + return groupJoinRepository.findByUser(user.getId()); } //가입 신청 조회 @@ -192,7 +199,7 @@ public GroupJoinListResponse findGroupJoinList(AuthPrinciple authPrinciple, Long } //그룹 내 가입 신청 리스트 조회 - List groupJoins = findGroupJoins(group); + List groupJoins = findGroupJoins(group); //반환 return GroupJoinListResponse.from(groupJoins); @@ -279,7 +286,7 @@ public FindGroupJoinListMeResponse findGroupJoinListMe(AuthPrinciple authPrincip UserProfile user = findUser(authPrinciple); //유저별 그룹 신청 리스트 조회 - List groupJoins = groupJoinRepository.findAllByUser(user); + List groupJoins = groupJoinRepository.findByUser(user.getId()); return FindGroupJoinListMeResponse.from(groupJoins); } diff --git a/src/main/java/project/flipnote/image/controller/ImageUploadController.java b/src/main/java/project/flipnote/image/controller/ImageUploadController.java index 8594cbe2..0d73e81f 100644 --- a/src/main/java/project/flipnote/image/controller/ImageUploadController.java +++ b/src/main/java/project/flipnote/image/controller/ImageUploadController.java @@ -10,6 +10,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.image.controller.docs.ImageUploadControllerDocs; import project.flipnote.image.model.ImageUploadRequestDto; import project.flipnote.image.model.ImageUploadResponseDto; import project.flipnote.image.service.ImageUploadService; @@ -17,8 +18,7 @@ @RestController @RequestMapping("/v1/images") @RequiredArgsConstructor -public class ImageUploadController { - +public class ImageUploadController implements ImageUploadControllerDocs { private final ImageUploadService fileService; //파일 업로드 API diff --git a/src/main/java/project/flipnote/image/controller/docs/ImageUploadControllerDocs.java b/src/main/java/project/flipnote/image/controller/docs/ImageUploadControllerDocs.java new file mode 100644 index 00000000..8fb29351 --- /dev/null +++ b/src/main/java/project/flipnote/image/controller/docs/ImageUploadControllerDocs.java @@ -0,0 +1,36 @@ +package project.flipnote.image.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import project.flipnote.image.model.ImageUploadRequestDto; +import project.flipnote.image.model.ImageUploadResponseDto; + +@Tag(name = "이미지 업로드", description = "S3 Presigned URL 관리 API") +public interface ImageUploadControllerDocs { + //이미지 업로드 url 생성 + @Operation( + summary = "이미지 업로드 URL 생성", + description = "S3에 이미지를 업로드할 수 있는 Presigned PUT URL을 발급합니다. " + + "파일 이름은 32자리 MD5 + 확장자(jpg|jpeg|png|gif) 형식이어야 합니다." + ) + @RequestBody( + description = "이미지 업로드 요청(파일명)", + required = true, + content = @Content(schema = @Schema(implementation = ImageUploadRequestDto.class)) + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "URL 발급 성공", + content = @Content(schema = @Schema(implementation = ImageUploadResponseDto.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청(파일명 형식 오류 등)"), + @ApiResponse(responseCode = "409", description = "이미 존재하는 파일명(CONFLICT_IMAGE)"), + @ApiResponse(responseCode = "500", description = "S3 서비스 오류(S3_SERVICE_ERROR)") + }) + public ResponseEntity getPresignedUrl(ImageUploadRequestDto req); +} diff --git a/src/main/java/project/flipnote/image/model/ImageUploadRequestDto.java b/src/main/java/project/flipnote/image/model/ImageUploadRequestDto.java index 85429f4f..231a24e4 100644 --- a/src/main/java/project/flipnote/image/model/ImageUploadRequestDto.java +++ b/src/main/java/project/flipnote/image/model/ImageUploadRequestDto.java @@ -1,5 +1,6 @@ package project.flipnote.image.model; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; public record ImageUploadRequestDto( @@ -7,6 +8,7 @@ public record ImageUploadRequestDto( regexp = "^[a-fA-F0-9]{32}\\.(jpg|jpeg|png|gif)$", message = "파일 이름은 32자리 MD5 해시와 jpg/jpeg/png/gif 확장자 형식이어야 합니다." ) + @NotNull(message = "파일 이름을 입력해주세요.") String fileName ) { }