diff --git a/src/main/java/team/wego/wegobackend/group/application/dto/response/GroupImageItemResponse.java b/src/main/java/team/wego/wegobackend/group/application/dto/response/GroupImageItemResponse.java index b8d4a64..54ee65e 100644 --- a/src/main/java/team/wego/wegobackend/group/application/dto/response/GroupImageItemResponse.java +++ b/src/main/java/team/wego/wegobackend/group/application/dto/response/GroupImageItemResponse.java @@ -3,9 +3,9 @@ import team.wego.wegobackend.group.domain.entity.GroupImage; public record GroupImageItemResponse( + int sortOrder, Long imageId440x240, Long imageId100x100, - int sortOrder, String imageUrl440x240, String imageUrl100x100 ) { @@ -25,9 +25,9 @@ public static GroupImageItemResponse from( String thumbUrlVal = (thumb != null) ? thumb.getImageUrl() : null; return new GroupImageItemResponse( + sortOrder, mainId, thumbId, - sortOrder, mainUrlVal, thumbUrlVal ); diff --git a/src/main/java/team/wego/wegobackend/group/application/service/GroupService.java b/src/main/java/team/wego/wegobackend/group/application/service/GroupService.java index 0059249..dcecb67 100644 --- a/src/main/java/team/wego/wegobackend/group/application/service/GroupService.java +++ b/src/main/java/team/wego/wegobackend/group/application/service/GroupService.java @@ -3,6 +3,7 @@ import static team.wego.wegobackend.group.domain.entity.GroupUserStatus.ATTEND; import static team.wego.wegobackend.group.domain.entity.GroupUserStatus.LEFT; +import java.time.LocalDateTime; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -28,6 +29,7 @@ import team.wego.wegobackend.group.domain.entity.GroupRole; import team.wego.wegobackend.group.domain.entity.GroupTag; import team.wego.wegobackend.group.domain.entity.GroupUser; +import team.wego.wegobackend.group.domain.entity.MyGroupType; import team.wego.wegobackend.group.domain.exception.GroupErrorCode; import team.wego.wegobackend.group.domain.exception.GroupException; import team.wego.wegobackend.group.domain.repository.GroupImageRepository; @@ -567,4 +569,97 @@ public void deleteGroup(Long userId, Long groupId) { groupRepository.delete(group); } + + @Transactional(readOnly = true) + public GetGroupListResponse getMyGroups( + Long userId, + String type, + Long cursor, + int size + ) { + MyGroupType myGroupType = MyGroupType.from(type); + + int pageSize = Math.max(1, Math.min(size, 50)); + + return switch (myGroupType) { + case CURRENT -> getMyCurrentGroups(userId, cursor, pageSize); + case MY_POST -> getMyPostGroups(userId, cursor, pageSize); + case PAST -> getMyPastGroups(userId, cursor, pageSize); + }; + } + + private GetGroupListResponse getMyCurrentGroups(Long userId, Long cursor, int size) { + LocalDateTime now = LocalDateTime.now(); + + // 지금은 ATTEND 만 사용, 나중에 LEFT 도 포함하려면 여기만 변경하면 됨 + List statuses = List.of(ATTEND.name()); + + List groups = groupRepository.findCurrentGroupsByUser( + userId, + statuses, + cursor, + now, + size + 1 // 다음 페이지 여부 판단용 + ); + + Long nextCursor = null; + if (groups.size() > size) { + Group lastExtra = groups.remove(size); + nextCursor = lastExtra.getId(); + } + + List items = groups.stream() + .map(this::toGroupListItemResponse) + .toList(); + + return GetGroupListResponse.of(items, nextCursor); + } + + private GetGroupListResponse getMyPostGroups(Long userId, Long cursor, int size) { + List groups = groupRepository.findMyPostGroupsByHost( + userId, + cursor, + size + 1 + ); + + Long nextCursor = null; + if (groups.size() > size) { + Group lastExtra = groups.remove(size); + nextCursor = lastExtra.getId(); + } + + List items = groups.stream() + .map(this::toGroupListItemResponse) + .toList(); + + return GetGroupListResponse.of(items, nextCursor); + } + + private GetGroupListResponse getMyPastGroups(Long userId, Long cursor, int size) { + LocalDateTime now = LocalDateTime.now(); + + // 추후 LEFT 포함하고 싶으면 여기만 변경 + List statuses = List.of(ATTEND.name()); + + List groups = groupRepository.findPastGroupsByUser( + userId, + statuses, + cursor, + now, + size + 1 + ); + + Long nextCursor = null; + if (groups.size() > size) { + Group lastExtra = groups.remove(size); + nextCursor = lastExtra.getId(); + } + + List items = groups.stream() + .map(this::toGroupListItemResponse) + .toList(); + + return GetGroupListResponse.of(items, nextCursor); + } } + diff --git a/src/main/java/team/wego/wegobackend/group/domain/entity/MyGroupType.java b/src/main/java/team/wego/wegobackend/group/domain/entity/MyGroupType.java new file mode 100644 index 0000000..92c5b1d --- /dev/null +++ b/src/main/java/team/wego/wegobackend/group/domain/entity/MyGroupType.java @@ -0,0 +1,33 @@ +package team.wego.wegobackend.group.domain.entity; + +import lombok.AccessLevel; +import lombok.Getter; +import team.wego.wegobackend.group.domain.exception.GroupErrorCode; +import team.wego.wegobackend.group.domain.exception.GroupException; + +@Getter(AccessLevel.PUBLIC) +public enum MyGroupType { + CURRENT("current"), + MY_POST("myPost"), + PAST("past"); + + private final String value; + + MyGroupType(String value) { + this.value = value; + } + + public static MyGroupType from(String value) { + if (value == null) { + throw new GroupException(GroupErrorCode.MY_GROUP_TYPE_NOT_NULL); + } + return switch (value) { + case "current" -> CURRENT; + case "myPost" -> MY_POST; + case "past" -> PAST; + default -> throw new GroupException(GroupErrorCode.INVALID_MY_GROUP_TYPE, value); + + + }; + } +} diff --git a/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java b/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java index 491d091..427379e 100644 --- a/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java +++ b/src/main/java/team/wego/wegobackend/group/domain/exception/GroupErrorCode.java @@ -22,9 +22,13 @@ public enum GroupErrorCode implements ErrorCode { GROUP_CAPACITY_EXCEEDED(HttpStatus.BAD_REQUEST, "모임: 모임 최대 참가자 수를 초과했습니다. 모임 ID: %s"), NOT_ATTEND_GROUP(HttpStatus.BAD_REQUEST, "모임: 참여한 적 없거나 이미 나간 상태입니다. 모임 ID: %s 회원 ID: %s"), HOST_CANNOT_LEAVE_OWN_GROUP(HttpStatus.BAD_REQUEST, "모임: HOST는 나갈 수 없습니다. 모임 ID: %s 회원 ID: %s"), - NO_PERMISSION_TO_UPDATE_GROUP(HttpStatus.FORBIDDEN, "모임: 해당 모임을 수정할 권한이 없습니다. 모임 ID: %s 회원 ID: %s"), - INVALID_MAX_PARTICIPANTS_LESS_THAN_CURRENT(HttpStatus.BAD_REQUEST, "모임: 현재 참여 인원 수(%s)보다 작은 값으로 최대 인원을 줄일 수 없습니다."), - NO_PERMISSION_TO_DELETE_GROUP(HttpStatus.UNAUTHORIZED, "모임: 삭제할 수 있는 권한이 없습니다."); + NO_PERMISSION_TO_UPDATE_GROUP(HttpStatus.FORBIDDEN, + "모임: 해당 모임을 수정할 권한이 없습니다. 모임 ID: %s 회원 ID: %s"), + INVALID_MAX_PARTICIPANTS_LESS_THAN_CURRENT(HttpStatus.BAD_REQUEST, + "모임: 현재 참여 인원 수(%s)보다 작은 값으로 최대 인원을 줄일 수 없습니다."), + NO_PERMISSION_TO_DELETE_GROUP(HttpStatus.UNAUTHORIZED, "모임: 삭제할 수 있는 권한이 없습니다."), + MY_GROUP_TYPE_NOT_NULL(HttpStatus.BAD_REQUEST, "모임: MyGroupType 값은 null일 수 없습니다."), + INVALID_MY_GROUP_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 MyGroupType: %s"); private final HttpStatus status; private final String message; diff --git a/src/main/java/team/wego/wegobackend/group/domain/repository/GroupRepository.java b/src/main/java/team/wego/wegobackend/group/domain/repository/GroupRepository.java index 8e2f65a..35d1644 100644 --- a/src/main/java/team/wego/wegobackend/group/domain/repository/GroupRepository.java +++ b/src/main/java/team/wego/wegobackend/group/domain/repository/GroupRepository.java @@ -1,5 +1,6 @@ package team.wego.wegobackend.group.domain.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -33,4 +34,70 @@ List findGroupsWithKeywordAndCursor( @Param("cursor") Long cursor, @Param("limit") int limit ); + + /** + * 내가 참여 중인 모임 (CURRENT) + */ + @Query(value = """ + SELECT DISTINCT g.* + FROM v1_groups g + JOIN v1_group_users gu ON gu.group_id = g.group_id + WHERE g.deleted_at IS NULL + AND gu.user_id = :userId + AND gu.group_user_status IN (:statuses) + AND (:cursor IS NULL OR g.group_id < :cursor) + AND (g.end_time IS NULL OR g.end_time >= :now) + ORDER BY g.group_id DESC + LIMIT :limit + """, nativeQuery = true) + List findCurrentGroupsByUser( + @Param("userId") Long userId, + @Param("statuses") List statuses, + @Param("cursor") Long cursor, + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + /** + * 과거에 참여했던 모임 (PAST) + */ + @Query(value = """ + SELECT DISTINCT g.* + FROM v1_groups g + JOIN v1_group_users gu ON gu.group_id = g.group_id + WHERE g.deleted_at IS NULL + AND gu.user_id = :userId + AND gu.group_user_status IN (:statuses) + AND (:cursor IS NULL OR g.group_id < :cursor) + AND g.end_time IS NOT NULL + AND g.end_time < :now + ORDER BY g.group_id DESC + LIMIT :limit + """, nativeQuery = true) + List findPastGroupsByUser( + @Param("userId") Long userId, + @Param("statuses") List statuses, + @Param("cursor") Long cursor, + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + /** + * 내가 만든 모임 (MY_POST) – group_users 안타고 host 기준으로만 + */ + @Query(value = """ + SELECT DISTINCT g.* + FROM v1_groups g + WHERE g.deleted_at IS NULL + AND g.host_id = :userId + AND (:cursor IS NULL OR g.group_id < :cursor) + ORDER BY g.group_id DESC + LIMIT :limit + """, nativeQuery = true) + List findMyPostGroupsByHost( + @Param("userId") Long userId, + @Param("cursor") Long cursor, + @Param("limit") int limit + ); } + diff --git a/src/main/java/team/wego/wegobackend/group/presentation/GroupController.java b/src/main/java/team/wego/wegobackend/group/presentation/GroupController.java index 1554635..aa5dea5 100644 --- a/src/main/java/team/wego/wegobackend/group/presentation/GroupController.java +++ b/src/main/java/team/wego/wegobackend/group/presentation/GroupController.java @@ -123,6 +123,20 @@ public ResponseEntity> deleteGroup( .body(ApiResponse.success(HttpStatus.NO_CONTENT.value(), null)); } - // 나의 모임 목록 조회 + + @GetMapping("/me") + public ResponseEntity> getMyGroups( + @RequestParam Long userId, // TODO: 나중에 인증 정보에서 꺼내기 + @RequestParam String type, + @RequestParam(required = false) Long cursor, + @RequestParam int size + ) { + GetGroupListResponse response = + groupService.getMyGroups(userId, type, cursor, size); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success(HttpStatus.OK.value(), response)); + } } diff --git a/src/test/http/group/me.http b/src/test/http/group/me.http new file mode 100644 index 0000000..e253922 --- /dev/null +++ b/src/test/http/group/me.http @@ -0,0 +1,287 @@ +### 0-1. 회원가입(HOST) +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#", + "nickName": "Beemo", + "phoneNumber": "010-1234-5678" +} + +> {% + client.global.set("userId", response.body.data.userId); +%} + +### 0-2. 로그인(HOST) +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "test@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("accessToken", response.body.data.accessToken); +%} + + +### 1-1. 모임 이미지 선 업로드 (png / jpg 2장) +POST http://localhost:8080/api/v1/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="img1.png" +Content-Type: image/png + +< ../image/resources/img1.png +--boundary +Content-Disposition: form-data; name="images"; filename="img2.jpg" +Content-Type: image/jpeg + +< ../image/resources/img2.jpg +--boundary-- + +> {% + const images = response.body.data.images; + + client.global.set("img0_main", images[0].imageUrl440x240); + client.global.set("img0_thumb", images[0].imageUrl100x100); + + client.global.set("img1_main", images[1].imageUrl440x240); + client.global.set("img1_thumb", images[1].imageUrl100x100); +%} + + +### 1-2. 모임 생성 (업로드된 URL로 생성) +POST http://localhost:8080/api/v1/groups/create?userId={{userId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +{ + "title": "강남에서 하는 자바 스터디 - PNG/JPG 테스트", + "location": "서울 강남구", + "locationDetail": "강남역 2번 출구 근처 카페", + "startTime": "2025-12-11T19:00:00", + "endTime": "2025-12-15T21:00:00", + "tags": [ + "자바", + "백엔드", + "스터디" + ], + "description": "PNG/JPG 업로드 후 URL을 이용해서 모임을 생성하는 테스트입니다.", + "maxParticipants": 12, + "images": [ + { + "sortOrder": 0, + "imageUrl440x240": "{{img0_main}}", + "imageUrl100x100": "{{img0_thumb}}" + }, + { + "sortOrder": 1, + "imageUrl440x240": "{{img1_main}}", + "imageUrl100x100": "{{img1_thumb}}" + } + ] +} + +> {% + client.global.set("groupId_png_jpg", response.body.data.id); +%} + + +### 1-3. 회원가입(MEMBER 1) +POST http://localhost:8080/api/v1/auth/signup +Content-Type: application/json + +{ + "email": "test1@example.com", + "password": "Test1234!@#", + "nickName": "Heemo", + "phoneNumber": "010-1234-5678" +} + +> {% + client.global.set("memberId1", response.body.data.userId); +%} + + +### 1-4. 로그인(MEMBER 1) +POST http://localhost:8080/api/v1/auth/login +Content-Type: application/json + +{ + "email": "test1@example.com", + "password": "Test1234!@#" +} + +> {% + client.global.set("accessTokenMember1", response.body.data.accessToken); +%} + + +### 1-5. 모임 참여 (MEMBER 1) +POST http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}/attend?userId={{memberId1}} +Content-Type: application/json +Authorization: Bearer {{accessTokenMember1}} + + + +### 2-1. 모임 이미지 수정용 새 이미지 업로드 (예: 1장만) +POST http://localhost:8080/api/v1/groups/images/upload +Content-Type: multipart/form-data; boundary=boundary +Authorization: Bearer {{accessToken}} + +--boundary +Content-Disposition: form-data; name="images"; filename="img3.jpeg" +Content-Type: image/jpeg + +< ../image/resources/img3.jpeg +--boundary-- + +> {% + const images2 = response.body.data.images; + + // 이번에는 1장만 업로드했다고 가정 + client.global.set("new_img0_main", images2[0].imageUrl440x240); + client.global.set("new_img0_thumb", images2[0].imageUrl100x100); +%} + + +### 2-2. 모임 이미지 수정 (기존 2장 → 새 이미지 1장으로 교체) +PATCH http://localhost:8080/api/v1/groups/images/{{groupId_png_jpg}}?userId={{userId}} +Content-Type: application/json +Authorization: Bearer {{accessToken}} + +[ + { + "sortOrder": 0, + "imageUrl440x240": "{{new_img0_main}}", + "imageUrl100x100": "{{new_img0_thumb}}" + } +] + + +### 3-1. 모임 이미지 전체 삭제 +DELETE http://localhost:8080/api/v1/groups/images/{{groupId_png_jpg}}?userId={{userId}} +Authorization: Bearer {{accessToken}} + +{ +} + +### 4-1. 내 모임 목록 조회 - HOST 기준 (현재 참여 중인 모임: current) +GET http://localhost:8080/api/v1/groups/me?userId={{userId}}&type=current&size=20 +Content-Type: application/json +Authorization: Bearer {{accessToken}} + + +> {% + const currentHostItems = response.body.data.items; + + if (currentHostItems && currentHostItems.length > 0) { + const last = currentHostItems[currentHostItems.length - 1]; + client.global.set("myGroupsCurrentCursor_host", last.id); + } +%} + + +### 4-2. 내 모임 목록 조회 - HOST 기준 (current, 2페이지 예시) +GET http://localhost:8080/api/v1/groups/me + ?userId={{userId}} + &type=current + &cursor={{myGroupsCurrentCursor_host}} + &size=20 +Authorization: Bearer {{accessToken}} + + + +### 4-3. 내 모임 목록 조회 - HOST 기준 (내가 만든 모임: myPost) +GET http://localhost:8080/api/v1/groups/me?userId={{userId}}&type=myPost&size=20 +Authorization: Bearer {{accessToken}} + +> {% + const myPostHostItems = response.body.data.items; + + if (myPostHostItems && myPostHostItems.length > 0) { + const last = myPostHostItems[myPostHostItems.length - 1]; + client.global.set("myPostCursor_host", last.id); + } +%} + + +### 4-4. 내 모임 목록 조회 - HOST 기준 (myPost, 2페이지 예시) +GET http://localhost:8080/api/v1/groups/me + ?userId={{userId}} + &type=myPost + &cursor={{myPostCursor_host}} + &size=20 +Authorization: Bearer {{accessToken}} + + + +### 5-1. 내 모임 목록 조회 - MEMBER 1 기준 (현재 참여 중인 모임: current) +GET http://localhost:8080/api/v1/groups/me?userId={{memberId1}}&type=current&size=20 +Authorization: Bearer {{accessTokenMember1}} + +> {% + const currentMemberItems = response.body.data.items; + + if (currentMemberItems && currentMemberItems.length > 0) { + const last = currentMemberItems[currentMemberItems.length - 1]; + client.global.set("myGroupsCurrentCursor_member1", last.id); + } +%} + + +### 5-2. 내 모임 목록 조회 - MEMBER 1 기준 (current, 2페이지 예시) +GET http://localhost:8080/api/v1/groups/me + ?userId={{memberId1}} + &type=current + &cursor={{myGroupsCurrentCursor_member1}} + &size=20 +Authorization: Bearer {{accessTokenMember1}} + + + +### 5-3. 내 모임 목록 조회 - MEMBER 1 기준 (과거 참여 모임: past) +# 참고: endTime < now 인 모임이 없으면 빈 배열이 나올 수 있음 +GET http://localhost:8080/api/v1/groups/me?userId={{memberId1}}&type=past&size=20 +Authorization: Bearer {{accessTokenMember1}} + +> {% + const pastMemberItems = response.body.data.items; + + if (pastMemberItems && pastMemberItems.length > 0) { + const last = pastMemberItems[pastMemberItems.length - 1]; + client.global.set("myGroupsPastCursor_member1", last.id); + } +%} + + +### 5-4. 내 모임 목록 조회 - MEMBER 1 기준 (past, 2페이지 예시) +GET http://localhost:8080/api/v1/groups/me + ?userId={{memberId1}} + &type=past + &cursor={{myGroupsPastCursor_member1}} + &size=20 +Authorization: Bearer {{accessTokenMember1}} + + + +### 6-1. 모임 삭제 (HOST가 자신이 만든 모임 삭제) +DELETE http://localhost:8080/api/v1/groups/{{groupId_png_jpg}}?userId={{userId}} +Authorization: Bearer {{accessToken}} + + + +### 6-2. 모임 삭제 후 - HOST 기준 내 모임 목록(current) 재조회 (비어있는지 확인용) +GET http://localhost:8080/api/v1/groups/me?userId={{userId}}&type=current&size=20 +Authorization: Bearer {{accessToken}} + + + +### 6-3. 모임 삭제 후 - MEMBER 1 기준 내 모임 목록(current) 재조회 (비어있는지 확인용) +GET http://localhost:8080/api/v1/groups/me?userId={{memberId1}}&type=current&size=20 +Authorization: Bearer {{accessTokenMember1}}