diff --git a/backend/core/build.gradle b/backend/core/build.gradle index 7520ee105..196b0558a 100644 --- a/backend/core/build.gradle +++ b/backend/core/build.gradle @@ -50,6 +50,7 @@ dependencies { //redis implementation 'org.springframework.data:spring-data-redis' implementation 'io.lettuce:lettuce-core:6.3.0.RELEASE' + implementation 'org.redisson:redisson-spring-boot-starter:3.30.0' //RabbitMQ implementation 'org.springframework.boot:spring-boot-starter-amqp' @@ -85,6 +86,7 @@ dependencies { testImplementation 'org.testcontainers:testcontainers:1.19.1' testImplementation 'org.testcontainers:junit-jupiter:1.19.1' testImplementation 'org.testcontainers:mysql:1.19.1' + testImplementation 'com.redis:testcontainers-redis:2.2.2' } tasks.named('test') { diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/dto/GroupCapsuleCreateRequestDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/dto/GroupCapsuleCreateRequestDto.java index 603fd70bf..a98e16fdb 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/dto/GroupCapsuleCreateRequestDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/dto/GroupCapsuleCreateRequestDto.java @@ -13,7 +13,6 @@ @Builder public record GroupCapsuleCreateRequestDto( - List groupMemberIds, List imageNames, List videoNames, Long capsuleSkinId, diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/reqeust/GroupCapsuleCreateRequest.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/reqeust/GroupCapsuleCreateRequest.java index 7754b3ae0..7d9b12843 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/reqeust/GroupCapsuleCreateRequest.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/data/reqeust/GroupCapsuleCreateRequest.java @@ -14,10 +14,6 @@ @Schema(description = "그룹 캡슐 생성 포맷") public record GroupCapsuleCreateRequest( - @Schema(description = "그룹 멤버 아이디들") - @NotNull(message = "그룹 멤버 아이디들는 필수입니다.") - List groupMemberIds, - @Schema(description = "업로드한 이미지 경로 ex) xxx.jpg") List<@Image String> imageNames, @@ -56,7 +52,6 @@ public record GroupCapsuleCreateRequest( public GroupCapsuleCreateRequestDto toGroupCapsuleCreateRequestDto() { return GroupCapsuleCreateRequestDto.builder() - .groupMemberIds(groupMemberIds) .videoNames(videoNames) .imageNames(imageNames) .capsuleSkinId(capsuleSkinId) diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/facade/GroupCapsuleFacade.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/facade/GroupCapsuleFacade.java index de10d78e8..c4e160daa 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/facade/GroupCapsuleFacade.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsule/group_capsule/facade/GroupCapsuleFacade.java @@ -1,5 +1,6 @@ package site.timecapsulearchive.core.domain.capsule.group_capsule.facade; +import java.util.List; import lombok.RequiredArgsConstructor; import org.locationtech.jts.geom.Point; import org.springframework.stereotype.Component; @@ -16,6 +17,7 @@ import site.timecapsulearchive.core.domain.group.service.query.GroupQueryService; import site.timecapsulearchive.core.domain.member.entity.Member; import site.timecapsulearchive.core.domain.member.service.MemberService; +import site.timecapsulearchive.core.domain.member_group.service.MemberGroupQueryService; import site.timecapsulearchive.core.global.geography.GeoTransformManager; @Component @@ -25,6 +27,7 @@ public class GroupCapsuleFacade { private final MemberService memberService; private final GroupCapsuleService groupCapsuleService; private final GroupQueryService groupQueryService; + private final MemberGroupQueryService memberGroupQueryService; private final ImageService imageService; private final VideoService videoService; private final GeoTransformManager geoTransformManager; @@ -51,6 +54,8 @@ public void saveGroupCapsule( imageService.bulkSave(dto.imageNames(), capsule, member); videoService.bulkSave(dto.videoNames(), capsule, member); - groupCapsuleOpenService.bulkSave(dto.groupMemberIds(), capsule); + final List groupMemberIds = memberGroupQueryService.findGroupMemberIds(groupId); + + groupCapsuleOpenService.bulkSave(groupMemberIds, capsule); } } \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsuleskin/service/CapsuleSkinService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsuleskin/service/CapsuleSkinService.java index 809d2f69e..655552796 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsuleskin/service/CapsuleSkinService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/capsuleskin/service/CapsuleSkinService.java @@ -5,9 +5,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; -import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.domain.capsuleskin.data.dto.CapsuleSkinCreateDto; import site.timecapsulearchive.core.domain.capsuleskin.data.dto.CapsuleSkinSummaryDto; @@ -59,12 +57,9 @@ public CapsuleSkinStatusResponse sendCapsuleSkinCreateMessage( if (isNotExistMotionNameAndRetarget(dto)) { CapsuleSkin capsuleSkin = capsuleSkinMapper.createDtoToEntity(dto, foundMember); - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - capsuleSkinRepository.save(capsuleSkin); - } - }); + transactionTemplate.executeWithoutResult(status -> + capsuleSkinRepository.save(capsuleSkin) + ); return CapsuleSkinStatusResponse.success(); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java index 14cd4c0d6..7fe063959 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/api/query/FriendQueryApi.java @@ -131,10 +131,10 @@ ResponseEntity> searchMembersByPhones( @Operation( summary = "친구 검색", description = """ - 친구의 tag로 친구 검색을 한다. -
- 태그가 일치하면 일치하는 태그를 가진 사용자를 일치하지 않으면 가장 비슷한 태그를 가진 사용자를 반환한다. - """, + 친구의 tag로 친구 검색을 한다. +
+ 태그가 일치하면 일치하는 태그를 가진 사용자를 일치하지 않으면 가장 비슷한 태그를 가진 사용자를 반환한다. + """, security = {@SecurityRequirement(name = "user_token")}, tags = {"friend"} ) diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/command/FriendCommandService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/command/FriendCommandService.java index 00ada5929..255fe7a62 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/command/FriendCommandService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/friend/service/command/FriendCommandService.java @@ -4,9 +4,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.domain.friend.entity.FriendInvite; import site.timecapsulearchive.core.domain.friend.entity.MemberFriend; @@ -34,15 +32,12 @@ public void requestFriends(Long memberId, List friendIds) { final Member[] owner = new Member[1]; final List[] foundFriendIds = new List[1]; - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - owner[0] = memberRepository.findMemberById(memberId) - .orElseThrow(MemberNotFoundException::new); - foundFriendIds[0] = memberRepository.findMemberIdsByIds(friendIds); + transactionTemplate.executeWithoutResult(status -> { + owner[0] = memberRepository.findMemberById(memberId) + .orElseThrow(MemberNotFoundException::new); + foundFriendIds[0] = memberRepository.findMemberIdsByIds(friendIds); - friendInviteRepository.bulkSave(owner[0].getId(), foundFriendIds[0]); - } + friendInviteRepository.bulkSave(owner[0].getId(), foundFriendIds[0]); }); socialNotificationManager.sendFriendRequestMessages( @@ -64,12 +59,9 @@ public void requestFriend(final Long memberId, final Long friendId) { final FriendInvite createfriendInvite = FriendInvite.createOf(owner, friend); - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - friendInviteRepository.save(createfriendInvite); - } - }); + transactionTemplate.executeWithoutResult(status -> + friendInviteRepository.save(createfriendInvite) + ); socialNotificationManager.sendFriendReqMessage(owner.getNickname(), friendId); } @@ -93,31 +85,28 @@ private void validateTwoWayInvite(final Long memberId, final Long friendId) { public void acceptFriend(final Long memberId, final Long friendId) { validateFriendDuplicateId(memberId, friendId); - final String[] ownerNickname = new String[1]; - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - final List friendInvites = friendInviteRepository - .findFriendInviteWithMembersByOwnerIdAndFriendId(memberId, friendId); + final String ownerNickname = transactionTemplate.execute(status -> { + final List friendInvites = friendInviteRepository + .findFriendInviteWithMembersByOwnerIdAndFriendId(memberId, friendId); - if (friendInvites.isEmpty()) { - throw new FriendInviteNotFoundException(); - } + if (friendInvites.isEmpty()) { + throw new FriendInviteNotFoundException(); + } - final FriendInvite friendInvite = friendInvites.get(0); + final FriendInvite friendInvite = friendInvites.get(0); - final MemberFriend ownerRelation = friendInvite.ownerRelation(); - ownerNickname[0] = ownerRelation.getOwnerNickname(); + final MemberFriend ownerRelation = friendInvite.ownerRelation(); - final MemberFriend friendRelation = friendInvite.friendRelation(); + final MemberFriend friendRelation = friendInvite.friendRelation(); - memberFriendRepository.save(ownerRelation); - memberFriendRepository.save(friendRelation); - friendInvites.forEach(friendInviteRepository::delete); - } + memberFriendRepository.save(ownerRelation); + memberFriendRepository.save(friendRelation); + friendInvites.forEach(friendInviteRepository::delete); + + return ownerRelation.getOwnerNickname(); }); - socialNotificationManager.sendFriendAcceptMessage(ownerNickname[0], friendId); + socialNotificationManager.sendFriendAcceptMessage(ownerNickname, friendId); } @Transactional diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApi.java index 6c67826dc..42695d39b 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApi.java @@ -3,13 +3,13 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; -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 java.time.ZonedDateTime; import org.springframework.http.ResponseEntity; import site.timecapsulearchive.core.domain.group.data.response.GroupDetailResponse; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberInfosResponse; import site.timecapsulearchive.core.domain.group.data.response.GroupsSliceResponse; import site.timecapsulearchive.core.global.common.response.ApiSpec; @@ -38,7 +38,7 @@ public interface GroupQueryApi { ResponseEntity> findGroupDetailById( Long memberId, - @Parameter(in = ParameterIn.PATH, description = "조회할 그룹 아이디", required = true, schema = @Schema()) + @Parameter(in = ParameterIn.PATH, description = "조회할 그룹 아이디", required = true) Long groupId ); diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApiController.java index 4836f71d9..4eb6a53ce 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApiController.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/api/query/GroupQueryApiController.java @@ -1,6 +1,7 @@ package site.timecapsulearchive.core.domain.group.api.query; import java.time.ZonedDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Slice; import org.springframework.http.ResponseEntity; @@ -10,9 +11,11 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import site.timecapsulearchive.core.domain.group.data.dto.CompleteGroupSummaryDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupDetailTotalDto; -import site.timecapsulearchive.core.domain.group.data.dto.GroupSummaryDto; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.group.data.response.GroupDetailResponse; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberInfosResponse; import site.timecapsulearchive.core.domain.group.data.response.GroupsSliceResponse; import site.timecapsulearchive.core.domain.group.service.query.GroupQueryService; import site.timecapsulearchive.core.global.common.response.ApiSpec; @@ -28,7 +31,7 @@ public class GroupQueryApiController implements GroupQueryApi { private final S3PreSignedUrlManager s3PreSignedUrlManager; @GetMapping( - value = "/{group_id}", + value = "/{group_id}/detail", produces = {"application/json"} ) @Override @@ -57,7 +60,8 @@ public ResponseEntity> findGroups( @RequestParam(defaultValue = "20", value = "size") final int size, @RequestParam(value = "created_at") final ZonedDateTime createdAt ) { - final Slice groupsSlice = groupQueryService.findGroupsSlice(memberId, size, + final Slice groupsSlice = groupQueryService.findGroupsSlice(memberId, + size, createdAt); return ResponseEntity.ok( diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/CompleteGroupSummaryDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/CompleteGroupSummaryDto.java new file mode 100644 index 000000000..2c6b74013 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/CompleteGroupSummaryDto.java @@ -0,0 +1,26 @@ +package site.timecapsulearchive.core.domain.group.data.dto; + +import java.util.function.Function; +import site.timecapsulearchive.core.domain.group.data.response.GroupSummaryResponse; + +public record CompleteGroupSummaryDto( + + GroupSummaryDto groupSummaryDto, + String groupOwnerProfileUrl, + Long totalGroupMemberCount + +) { + + public GroupSummaryResponse toResponse(final Function preSignedUrlFunction) { + return GroupSummaryResponse.builder() + .id(groupSummaryDto.id()) + .name(groupSummaryDto.groupName()) + .groupOwnerProfileUrl(groupOwnerProfileUrl) + .profileUrl(preSignedUrlFunction.apply(groupSummaryDto.groupProfileUrl())) + .totalGroupMemberCount(totalGroupMemberCount) + .createdAt(groupSummaryDto.createdAt()) + .isOwner(groupSummaryDto.isOwner()) + .build(); + } + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailDto.java index d7852ebdf..5401ade5b 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailDto.java @@ -20,7 +20,8 @@ public static GroupDetailDto as( Boolean isOwner, List members ) { - return new GroupDetailDto(groupName, groupDescription, groupProfileUrl, createdAt, isOwner, members); + return new GroupDetailDto(groupName, groupDescription, groupProfileUrl, createdAt, isOwner, + members); } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailTotalDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailTotalDto.java index 55209b7c3..1770c5b8f 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailTotalDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupDetailTotalDto.java @@ -4,7 +4,7 @@ import java.util.List; import java.util.function.Function; import site.timecapsulearchive.core.domain.group.data.response.GroupDetailResponse; -import site.timecapsulearchive.core.domain.group.data.response.GroupMemberResponse; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberWithRelationResponse; public record GroupDetailTotalDto( String groupName, @@ -38,8 +38,8 @@ public static GroupDetailTotalDto as( } public GroupDetailResponse toResponse(final Function singlePreSignUrlFunction) { - List members = this.members.stream() - .map(GroupMemberWithRelationDto::toResponse) + List groupMemberResponses = members.stream() + .map(member -> member.toResponse(singlePreSignUrlFunction)) .toList(); return GroupDetailResponse.builder() @@ -49,7 +49,7 @@ public GroupDetailResponse toResponse(final Function singlePreSi .createdAt(createdAt) .groupCapsuleTotalCount(groupCapsuleTotalCount) .canGroupEdit(canGroupEdit) - .members(members) + .members(groupMemberResponses) .build(); } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberDto.java index ab9ae4506..2701cbf21 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberDto.java @@ -1,6 +1,8 @@ package site.timecapsulearchive.core.domain.group.data.dto; import java.util.List; +import java.util.function.Function; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberInfoResponse; public record GroupMemberDto( Long memberId, @@ -20,4 +22,15 @@ public GroupMemberWithRelationDto toRelationDto(final List friendIds) { .isFriend(friendIds.contains(memberId)) .build(); } + + public GroupMemberInfoResponse toInfoResponse( + final Function singlePreSignUrlFunction) { + return GroupMemberInfoResponse.builder() + .memberId(memberId) + .profileUrl(singlePreSignUrlFunction.apply(profileUrl)) + .nickname(nickname) + .tag(tag) + .isOwner(isOwner) + .build(); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberWithRelationDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberWithRelationDto.java index 29f3dbf12..dbe86a084 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberWithRelationDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupMemberWithRelationDto.java @@ -1,7 +1,8 @@ package site.timecapsulearchive.core.domain.group.data.dto; +import java.util.function.Function; import lombok.Builder; -import site.timecapsulearchive.core.domain.group.data.response.GroupMemberResponse; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberWithRelationResponse; @Builder public record GroupMemberWithRelationDto( @@ -13,10 +14,11 @@ public record GroupMemberWithRelationDto( Boolean isFriend ) { - public GroupMemberResponse toResponse() { - return GroupMemberResponse.builder() + public GroupMemberWithRelationResponse toResponse( + final Function singlePreSignUrlFunction) { + return GroupMemberWithRelationResponse.builder() .memberId(memberId) - .profileUrl(profileUrl) + .profileUrl(singlePreSignUrlFunction.apply(profileUrl)) .nickname(nickname) .tag(tag) .isOwner(isOwner) diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupSummaryDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupSummaryDto.java index e1a9de247..73194d571 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupSummaryDto.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/dto/GroupSummaryDto.java @@ -1,26 +1,13 @@ package site.timecapsulearchive.core.domain.group.data.dto; import java.time.ZonedDateTime; -import java.util.function.Function; -import site.timecapsulearchive.core.domain.group.data.response.GroupSummaryResponse; public record GroupSummaryDto( Long id, String groupName, - String groupDescription, String groupProfileUrl, ZonedDateTime createdAt, Boolean isOwner ) { - public GroupSummaryResponse toResponse(final Function preSignedUrlFunction) { - return GroupSummaryResponse.builder() - .id(id) - .name(groupName) - .description(groupDescription) - .profileUrl(preSignedUrlFunction.apply(groupProfileUrl)) - .createdAt(createdAt) - .isOwner(isOwner) - .build(); - } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupDetailResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupDetailResponse.java index ed3852aa8..e6fafea82 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupDetailResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupDetailResponse.java @@ -29,7 +29,7 @@ public record GroupDetailResponse( Boolean canGroupEdit, @Schema(description = "그룹원 리스트") - List members + List members ) { public GroupDetailResponse { diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberDetailResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberDetailResponse.java deleted file mode 100644 index 1d4f72fb9..000000000 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberDetailResponse.java +++ /dev/null @@ -1,21 +0,0 @@ -package site.timecapsulearchive.core.domain.group.data.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "그룹원 상세 정보") -public record GroupMemberDetailResponse( - - @Schema(description = "그룹원 아이디") - Long id, - - @Schema(description = "그룹원 이름") - String nickname, - - @Schema(description = "프로필 url") - String profileUrl, - - @Schema(description = "사용자와 친구 여부") - Boolean isFriend -) { - -} \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfoResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfoResponse.java new file mode 100644 index 000000000..de4ed215f --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfoResponse.java @@ -0,0 +1,26 @@ +package site.timecapsulearchive.core.domain.group.data.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +@Schema(description = "그룹원 정보") +public record GroupMemberInfoResponse( + + @Schema(description = "그룹원 아이디") + Long memberId, + + @Schema(description = "그룹원 프로필 url") + String profileUrl, + + @Schema(description = "그룹원 닉네임") + String nickname, + + @Schema(description = "그룹원 태그") + String tag, + + @Schema(description = "그룹장 여부") + Boolean isOwner +) { + +} \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfosResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfosResponse.java new file mode 100644 index 000000000..93b54068e --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberInfosResponse.java @@ -0,0 +1,27 @@ +package site.timecapsulearchive.core.domain.group.data.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.function.Function; +import lombok.Builder; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; + +@Builder +@Schema(description = "그룹원들 정보 리스트") +public record GroupMemberInfosResponse( + + @Schema(description = "그룹원들 정보") + List groupMemberResponses +) { + + public static GroupMemberInfosResponse createOf( + final List groupMemberDtos, + final Function singlePreSignUrlFunction + ) { + List groupMemberResponses = groupMemberDtos.stream() + .map(dto -> dto.toInfoResponse(singlePreSignUrlFunction)) + .toList(); + + return new GroupMemberInfosResponse(groupMemberResponses); + } +} \ No newline at end of file diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberWithRelationResponse.java similarity index 92% rename from backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberResponse.java rename to backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberWithRelationResponse.java index 440ff6e3d..e4feca504 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupMemberWithRelationResponse.java @@ -5,7 +5,7 @@ @Builder @Schema(description = "그룹원 정보") -public record GroupMemberResponse( +public record GroupMemberWithRelationResponse( @Schema(description = "그룹원 아이디") Long memberId, diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupSummaryResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupSummaryResponse.java index d43993dce..8856a6036 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupSummaryResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupSummaryResponse.java @@ -18,8 +18,11 @@ public record GroupSummaryResponse( @Schema(description = "그룹 프로필 url") String profileUrl, - @Schema(description = "그룹 설명") - String description, + @Schema(description = "그룹장 프로필 url") + String groupOwnerProfileUrl, + + @Schema(description = "총 그룹원 수") + Long totalGroupMemberCount, @Schema(description = "그룹 생성일") ZonedDateTime createdAt, diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupsSliceResponse.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupsSliceResponse.java index d0fd0c1af..c5cdefe02 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupsSliceResponse.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/data/response/GroupsSliceResponse.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; import java.util.function.Function; -import site.timecapsulearchive.core.domain.group.data.dto.GroupSummaryDto; +import site.timecapsulearchive.core.domain.group.data.dto.CompleteGroupSummaryDto; @Schema(description = "사용자의 그룹 목록 응답") public record GroupsSliceResponse( @@ -16,7 +16,7 @@ public record GroupsSliceResponse( ) { public static GroupsSliceResponse createOf( - final List groups, + final List groups, final Boolean hasNext, final Function preSignedUrlFunction ) { diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepository.java index a3294b43d..b13299a40 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepository.java @@ -1,20 +1,28 @@ package site.timecapsulearchive.core.domain.group.repository; import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Slice; import site.timecapsulearchive.core.domain.group.data.dto.GroupDetailDto; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupSummaryDto; public interface GroupQueryRepository { - Slice findGroupsSlice( + Slice findGroupSummaries( final Long memberId, final int size, final ZonedDateTime createdAt ); + List getGroupOwnerProfileUrls(final List groupIds); + + List getTotalGroupMemberCount(final List groupIds); + Optional findGroupDetailByGroupIdAndMemberId(final Long groupId, final Long memberId); + + Optional getTotalGroupMemberCount(Long groupId); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepositoryImpl.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepositoryImpl.java index bffb4dd09..f01d8c9de 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepositoryImpl.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/repository/GroupQueryRepositoryImpl.java @@ -6,7 +6,6 @@ import static site.timecapsulearchive.core.domain.member.entity.QMember.member; import static site.timecapsulearchive.core.domain.member_group.entity.QMemberGroup.memberGroup; -import com.querydsl.core.types.Expression; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -30,18 +29,18 @@ public class GroupQueryRepositoryImpl implements GroupQueryRepository { private final JPAQueryFactory jpaQueryFactory; - public Slice findGroupsSlice( + @Override + public Slice findGroupSummaries( final Long memberId, final int size, final ZonedDateTime createdAt ) { - final List groups = jpaQueryFactory + final List groupSummaryDtos = jpaQueryFactory .select( Projections.constructor( GroupSummaryDto.class, group.id, group.groupName, - group.groupDescription, group.groupProfileUrl, group.createdAt, memberGroup.isOwner @@ -50,23 +49,50 @@ public Slice findGroupsSlice( .from(memberGroup) .join(memberGroup.group, group) .where(memberGroup.member.id.eq(memberId).and(memberGroup.createdAt.lt(createdAt))) + .orderBy(group.id.desc()) .limit(size + 1) .fetch(); - final boolean hasNext = groups.size() > size; + final boolean hasNext = groupSummaryDtos.size() > size; if (hasNext) { - groups.remove(size); + groupSummaryDtos.remove(size); } - return new SliceImpl<>(groups, Pageable.ofSize(size), groups.size() > size); + return new SliceImpl<>(groupSummaryDtos, Pageable.ofSize(size), hasNext); + } + + @Override + public List getGroupOwnerProfileUrls(final List groupIds) { + return jpaQueryFactory + .select(member.profileUrl) + .from(memberGroup) + .join(memberGroup.group, group) + .join(memberGroup.member, member) + .where(memberGroup.group.id.in(groupIds) + .and(memberGroup.isOwner.eq(true))) + .orderBy(group.id.desc()) + .fetch(); + } + + @Override + public List getTotalGroupMemberCount(final List groupIds) { + return jpaQueryFactory + .select(memberGroup.count()) + .from(memberGroup) + .where(memberGroup.group.id.in(groupIds)) + .groupBy(memberGroup.group.id) + .orderBy(memberGroup.group.id.desc()) + .fetch(); } /** * 사용자를 제외한 그룹원 정보와 그룹의 상세정보를 반환한다. - * @param groupId 상세정보를 찾을 그룹 아이디 + * + * @param groupId 상세정보를 찾을 그룹 아이디 * @param memberId 사용자 아이디 * @return 그룹의 상세정보({@code memberId} 제외 그룹원) */ + @Override public Optional findGroupDetailByGroupIdAndMemberId(final Long groupId, final Long memberId) { GroupDetailDto groupDetailDtoIncludeMe = @@ -124,4 +150,14 @@ public Optional findGroupDetailByGroupIdAndMemberId(final Long g ) ); } + + @Override + public Optional getTotalGroupMemberCount(final Long groupId) { + return Optional.ofNullable(jpaQueryFactory + .select(memberGroup.count()) + .from(memberGroup) + .where(memberGroup.group.id.eq(groupId)) + .fetchOne() + ); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/command/GroupCommandService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/command/GroupCommandService.java index 0b7d4d9ce..423288cd9 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/command/GroupCommandService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/command/GroupCommandService.java @@ -4,8 +4,6 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.domain.capsule.group_capsule.repository.GroupCapsuleQueryRepository; import site.timecapsulearchive.core.domain.group.data.dto.GroupCreateDto; @@ -48,13 +46,10 @@ public void createGroup(final Long memberId, final GroupCreateDto dto) { final MemberGroup memberGroup = MemberGroup.createGroupOwner(member, group); - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - groupRepository.save(group); - memberGroupRepository.save(memberGroup); - groupInviteRepository.bulkSave(memberId, group.getId(), dto.targetIds()); - } + transactionTemplate.executeWithoutResult(status -> { + groupRepository.save(group); + memberGroupRepository.save(memberGroup); + groupInviteRepository.bulkSave(memberId, group.getId(), dto.targetIds()); }); socialNotificationManager.sendGroupInviteMessage(member.getNickname(), @@ -73,7 +68,7 @@ protected void doInTransactionWithoutResult(TransactionStatus status) { * @param groupId 그룹 아이디 */ public void deleteGroup(final Long memberId, final Long groupId) { - final String groupProfilePath = transactionTemplate.execute(ignored -> { + final String groupProfilePath = transactionTemplate.execute(status -> { final Group group = groupRepository.findGroupById(groupId) .orElseThrow(GroupNotFoundException::new); diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/query/GroupQueryService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/query/GroupQueryService.java index d4d22f3a8..344d3e7e0 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/query/GroupQueryService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/group/service/query/GroupQueryService.java @@ -2,12 +2,15 @@ import java.time.ZonedDateTime; import java.util.List; +import java.util.stream.IntStream; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import site.timecapsulearchive.core.domain.capsule.group_capsule.repository.GroupCapsuleQueryRepository; import site.timecapsulearchive.core.domain.friend.repository.member_friend.MemberFriendRepository; +import site.timecapsulearchive.core.domain.group.data.dto.CompleteGroupSummaryDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupDetailDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupDetailTotalDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; @@ -30,12 +33,32 @@ public Group findGroupById(final Long groupId) { .orElseThrow(GroupNotFoundException::new); } - public Slice findGroupsSlice( + public Slice findGroupsSlice( final Long memberId, final int size, final ZonedDateTime createdAt ) { - return groupRepository.findGroupsSlice(memberId, size, createdAt); + final Slice groupSummaryDtos = groupRepository.findGroupSummaries(memberId, + size, createdAt); + final List groupIds = groupSummaryDtos.getContent().stream().map(GroupSummaryDto::id) + .toList(); + + final List groupOwnerProfileUrls = groupRepository.getGroupOwnerProfileUrls( + groupIds); + final List totalGroupMemberCount = groupRepository.getTotalGroupMemberCount(groupIds); + + final List finalGroupSummaryDtos = IntStream.range(0, + groupSummaryDtos.getContent().size()) + .mapToObj(i -> new CompleteGroupSummaryDto( + groupSummaryDtos.getContent().get(i), + groupOwnerProfileUrls.get(i), + totalGroupMemberCount.get(i) + ) + ) + .toList(); + + return new SliceImpl<>(finalGroupSummaryDtos, groupSummaryDtos.getPageable(), + groupSummaryDtos.hasNext()); } public GroupDetailTotalDto findGroupDetailByGroupId(final Long memberId, final Long groupId) { diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberRepository.java index 2d7d5f2bd..8efa30cea 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/repository/MemberRepository.java @@ -17,8 +17,6 @@ public interface MemberRepository extends Repository, MemberQueryR Optional findMemberById(Long memberId); - List findMemberByIdIsIn(List memberIds); - @Modifying(clearAutomatically = true) @Query("UPDATE Member m SET m.fcmToken = :fcmToken WHERE m.id = :memberId") int updateMemberFCMToken(@Param("memberId") Long memberId, @Param("fcmToken") String fcmToken); diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java index 023ac2d41..2bf412d28 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member/service/MemberService.java @@ -1,7 +1,6 @@ package site.timecapsulearchive.core.domain.member.service; import java.time.ZonedDateTime; -import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Slice; diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApi.java index e193e273e..9b9d85e67 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApi.java @@ -104,10 +104,7 @@ ResponseEntity> acceptGroupInvitation( Long memberId, @Parameter(in = ParameterIn.PATH, description = "그룹 아이디", required = true) - Long groupId, - - @Parameter(in = ParameterIn.PATH, description = "대상 회원 아이디", required = true) - Long targetId + Long groupId ); @Operation( diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApiController.java index 6af6bce41..1c6434a44 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApiController.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/command/MemberGroupCommandApiController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import site.timecapsulearchive.core.domain.member_group.data.request.SendGroupRequest; +import site.timecapsulearchive.core.domain.member_group.facade.MemberGroupFacade; import site.timecapsulearchive.core.domain.member_group.service.MemberGroupCommandService; import site.timecapsulearchive.core.global.common.response.ApiSpec; import site.timecapsulearchive.core.global.common.response.SuccessCode; @@ -20,6 +21,7 @@ public class MemberGroupCommandApiController implements MemberGroupCommandApi { private final MemberGroupCommandService memberGroupCommandService; + private final MemberGroupFacade memberGroupFacade; @DeleteMapping(value = "/{group_id}/members/quit") @Override @@ -47,14 +49,13 @@ public ResponseEntity> inviteGroup( ); } - @PostMapping(value = "/{group_id}/member/{target_id}/accept") + @PostMapping(value = "/{group_id}/accept") @Override public ResponseEntity> acceptGroupInvitation( @AuthenticationPrincipal final Long memberId, - @PathVariable("group_id") final Long groupId, - @PathVariable("target_id") final Long targetId + @PathVariable("group_id") final Long groupId ) { - memberGroupCommandService.acceptGroupInvite(memberId, groupId, targetId); + memberGroupFacade.acceptGroupInvite(memberId, groupId); return ResponseEntity.ok( ApiSpec.empty( diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApi.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApi.java index 92489c5c3..803c81b57 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApi.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApi.java @@ -10,6 +10,8 @@ import org.springframework.http.ResponseEntity; import site.timecapsulearchive.core.domain.member_group.data.response.GroupReceivingInvitesSliceResponse; import site.timecapsulearchive.core.domain.member_group.data.response.GroupSendingInvitesResponse; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberInfosResponse; +import site.timecapsulearchive.core.domain.member_group.data.response.GroupInviteSummaryResponses; import site.timecapsulearchive.core.global.common.response.ApiSpec; public interface MemberGroupQueryApi { @@ -37,6 +39,25 @@ ResponseEntity> findGroupReceivingIn ZonedDateTime createdAt ); + @Operation( + summary = "그룹에 대한 그룹 멤버 조회", + description = "그룹의 그룹원들 정보를 조회한다.", + security = {@SecurityRequirement(name = "user_token")}, + tags = {"group"} + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "ok" + ) + }) + ResponseEntity> findGroupMemberInfos( + Long memberId, + + @Parameter(in = ParameterIn.PATH, description = "조회할 그룹 아이디", required = true) + Long groupId + ); + @Operation( summary = "그룹 요청 보낸 목록 조회", description = "그룹장이 그룹 별로 그룹 초대 요청을 보낸 사용자 목록을 조회한다. 최대 30개가 반환된다.", diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApiController.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApiController.java index 2d1e1e6ff..ba7eb7371 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApiController.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/api/query/MemberGroupQueryApiController.java @@ -11,6 +11,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; +import site.timecapsulearchive.core.domain.group.data.response.GroupMemberInfosResponse; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupInviteSummaryDto; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupSendingInviteMemberDto; import site.timecapsulearchive.core.domain.member_group.data.response.GroupReceivingInvitesSliceResponse; @@ -54,6 +56,29 @@ public ResponseEntity> findGroupRece ); } + @GetMapping( + value = "/{group_id}/members", + produces = {"application/json"} + ) + @Override + public ResponseEntity> findGroupMemberInfos( + @AuthenticationPrincipal final Long memberId, + @PathVariable("group_id") final Long groupId + ) { + final List groupMemberDtos = memberGroupQueryService.findGroupMemberInfos( + memberId, groupId); + + return ResponseEntity.ok( + ApiSpec.success( + SuccessCode.SUCCESS, + GroupMemberInfosResponse.createOf( + groupMemberDtos, + s3PreSignedUrlManager::getS3PreSignedUrlForGet + ) + ) + ); + } + @GetMapping( value = "/{group_id}/sending-invites", produces = {"application/json"} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/data/dto/GroupAcceptNotificationDto.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/data/dto/GroupAcceptNotificationDto.java new file mode 100644 index 000000000..d3f61eeb1 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/data/dto/GroupAcceptNotificationDto.java @@ -0,0 +1,8 @@ +package site.timecapsulearchive.core.domain.member_group.data.dto; + +public record GroupAcceptNotificationDto( + String groupMemberNickname, + Long groupOwnerId +) { + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/facade/MemberGroupFacade.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/facade/MemberGroupFacade.java new file mode 100644 index 000000000..9ac25e5dc --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/facade/MemberGroupFacade.java @@ -0,0 +1,25 @@ +package site.timecapsulearchive.core.domain.member_group.facade; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import site.timecapsulearchive.core.domain.member_group.data.dto.GroupAcceptNotificationDto; +import site.timecapsulearchive.core.domain.member_group.service.MemberGroupCommandService; +import site.timecapsulearchive.core.infra.queue.manager.SocialNotificationManager; + +@Component +@RequiredArgsConstructor +public class MemberGroupFacade { + + private final MemberGroupCommandService memberGroupCommandService; + private final SocialNotificationManager socialNotificationManager; + + public void acceptGroupInvite(final Long memberId, final Long groupId) { + final GroupAcceptNotificationDto groupAcceptNotificationDto = memberGroupCommandService.acceptGroupInvite( + memberId, groupId); + socialNotificationManager.sendGroupAcceptMessage( + groupAcceptNotificationDto.groupMemberNickname(), + groupAcceptNotificationDto.groupOwnerId()) + ; + } + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteQueryRepositoryImpl.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteQueryRepositoryImpl.java index fd3b95d2c..7f2b33ec6 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteQueryRepositoryImpl.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteQueryRepositoryImpl.java @@ -31,6 +31,7 @@ public class GroupInviteQueryRepositoryImpl implements GroupInviteQueryRepositor private final JdbcTemplate jdbcTemplate; private final JPAQueryFactory jpaQueryFactory; + @Override public void bulkSave(final Long groupOwnerId, final Long groupId, final List groupMemberIds) { jdbcTemplate.batchUpdate( diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteRepository.java index 51b5f37e7..b882c940d 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/group_invite_repository/GroupInviteRepository.java @@ -12,8 +12,17 @@ public interface GroupInviteRepository extends Repository, void save(GroupInvite groupInvite); - int deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId(Long groupId, Long groupOwnerId, - Long groupMemberId); + @Query("delete from GroupInvite gi " + + "where gi.group.id =:groupId " + + "and gi.groupOwner.id =:groupOwnerId " + + "and gi.groupMember.id =:groupMemberId" + ) + @Modifying + int deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + @Param("groupId") Long groupId, + @Param("groupOwnerId") Long groupOwnerId, + @Param("groupMemberId") Long groupMemberId + ); @Query("delete from GroupInvite gi where gi.id in :groupInviteIds") @Modifying diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepository.java index 8cf25fa5a..f664cc0cc 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepository.java @@ -1,6 +1,8 @@ package site.timecapsulearchive.core.domain.member_group.repository.member_group_repository; +import java.util.List; import java.util.Optional; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupOwnerSummaryDto; public interface MemberGroupQueryRepository { @@ -8,4 +10,12 @@ public interface MemberGroupQueryRepository { Optional findOwnerInMemberGroup(Long groupId, Long memberId); Optional findIsOwnerByMemberIdAndGroupId(Long groupOwnerId, Long groupId); + + Optional> findGroupMemberIds(Long groupId); + + Optional findGroupOwnerId(Long groupId); + + List findGroupMemberInfos(Long memberId, Long groupId); + + Optional findGroupMembersCount(Long groupId); } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepositoryImpl.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepositoryImpl.java index e0b22628d..9eafc40a2 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepositoryImpl.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupQueryRepositoryImpl.java @@ -6,9 +6,11 @@ import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupOwnerSummaryDto; @Repository @@ -51,4 +53,53 @@ public Optional findIsOwnerByMemberIdAndGroupId( .fetchOne() ); } + + @Override + public Optional> findGroupMemberIds(final Long groupId) { + return Optional.ofNullable(jpaQueryFactory + .select(memberGroup.member.id) + .from(memberGroup) + .where(memberGroup.group.id.eq(groupId)) + .fetch()); + } + + @Override + public Optional findGroupOwnerId(final Long groupId) { + return Optional.ofNullable(jpaQueryFactory + .select(memberGroup.member.id) + .from(memberGroup) + .where(memberGroup.group.id.eq(groupId).and(memberGroup.isOwner.eq(true))) + .fetchOne()); + } + + @Override + public List findGroupMemberInfos( + final Long memberId, + final Long groupId + ) { + return jpaQueryFactory + .select(Projections.constructor( + GroupMemberDto.class, + member.id, + member.profileUrl, + member.nickname, + member.tag, + memberGroup.isOwner + ) + ) + .from(memberGroup) + .join(memberGroup.member, member) + .where(memberGroup.group.id.eq(groupId)) + .fetch(); + } + + @Override + public Optional findGroupMembersCount(final Long groupId) { + return Optional.ofNullable(jpaQueryFactory + .select(memberGroup.count()) + .from(memberGroup) + .where(memberGroup.group.id.eq(groupId)) + .fetchOne() + ); + } } diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupRepository.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupRepository.java index 6f2a9cf28..39393cb7b 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupRepository.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/repository/member_group_repository/MemberGroupRepository.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.Optional; import org.springframework.data.repository.Repository; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.member_group.entity.MemberGroup; public interface MemberGroupRepository extends Repository, diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandService.java index 077ff4666..25cf1fe49 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandService.java @@ -3,9 +3,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.support.TransactionCallbackWithoutResult; import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.domain.group.entity.Group; import site.timecapsulearchive.core.domain.group.exception.GroupNotFoundException; @@ -13,6 +11,7 @@ import site.timecapsulearchive.core.domain.member.entity.Member; import site.timecapsulearchive.core.domain.member.exception.MemberNotFoundException; import site.timecapsulearchive.core.domain.member.repository.MemberRepository; +import site.timecapsulearchive.core.domain.member_group.data.dto.GroupAcceptNotificationDto; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupOwnerSummaryDto; import site.timecapsulearchive.core.domain.member_group.data.request.SendGroupRequest; import site.timecapsulearchive.core.domain.member_group.entity.MemberGroup; @@ -21,6 +20,9 @@ import site.timecapsulearchive.core.domain.member_group.exception.MemberGroupKickDuplicatedIdException; import site.timecapsulearchive.core.domain.member_group.exception.MemberGroupNotFoundException; import site.timecapsulearchive.core.domain.member_group.exception.NoGroupAuthorityException; +import site.timecapsulearchive.core.domain.member_group.repository.groupInviteRepository.GroupInviteRepository; +import site.timecapsulearchive.core.domain.member_group.repository.memberGroupRepository.MemberGroupRepository; +import site.timecapsulearchive.core.global.config.redis.RedissonLock; import site.timecapsulearchive.core.domain.member_group.repository.group_invite_repository.GroupInviteRepository; import site.timecapsulearchive.core.domain.member_group.repository.member_group_repository.MemberGroupRepository; import site.timecapsulearchive.core.infra.queue.manager.SocialNotificationManager; @@ -38,28 +40,26 @@ public class MemberGroupCommandService { public void inviteGroup(final Long memberId, final SendGroupRequest sendGroupRequest) { final List friendIds = sendGroupRequest.targetIds(); - final List groupMembers = memberRepository.findMemberByIdIsIn(friendIds); - if (groupMembers.size() + friendIds.size() > 30) { + final Long groupMembersCount = memberGroupRepository.findGroupMembersCount( + sendGroupRequest.groupId()).orElseThrow(GroupNotFoundException::new); + if (groupMembersCount + friendIds.size() > 30) { throw new GroupMemberCountLimitException(); } - final GroupOwnerSummaryDto[] summaryDto = new GroupOwnerSummaryDto[1]; - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - summaryDto[0] = memberGroupRepository.findOwnerInMemberGroup( - sendGroupRequest.groupId(), memberId).orElseThrow(GroupNotFoundException::new); + final GroupOwnerSummaryDto groupOwnerSummaryDto = transactionTemplate.execute(status -> { + GroupOwnerSummaryDto dto = memberGroupRepository.findOwnerInMemberGroup( + sendGroupRequest.groupId(), memberId).orElseThrow(GroupNotFoundException::new); - if (!summaryDto[0].isOwner()) { - throw new NoGroupAuthorityException(); - } - - groupInviteRepository.bulkSave(memberId, sendGroupRequest.groupId(), friendIds); + if (!dto.isOwner()) { + throw new NoGroupAuthorityException(); } + + groupInviteRepository.bulkSave(memberId, sendGroupRequest.groupId(), friendIds); + return dto; }); - socialNotificationManager.sendGroupInviteMessage(summaryDto[0].nickname(), - summaryDto[0].groupProfileUrl(), friendIds); + socialNotificationManager.sendGroupInviteMessage(groupOwnerSummaryDto.nickname(), + groupOwnerSummaryDto.groupProfileUrl(), friendIds); } @Transactional @@ -72,27 +72,38 @@ public void rejectRequestGroup(final Long memberId, final Long groupId, final Lo } } - public void acceptGroupInvite(final Long memberId, final Long groupId, final Long targetId) { + @RedissonLock(value = "#groupId") + public GroupAcceptNotificationDto acceptGroupInvite(final Long memberId, final Long groupId) { + final Long totalGroupMemberCount = groupRepository.getTotalGroupMemberCount(groupId) + .orElseThrow(GroupNotFoundException::new); + + final Long groupOwnerId = memberGroupRepository.findGroupOwnerId(groupId) + .orElseThrow(GroupNotFoundException::new); + + if (totalGroupMemberCount == 30) { + deleteGroupInvite(memberId, groupId, groupOwnerId); + throw new GroupMemberCountLimitException(); + } + final Member groupMember = memberRepository.findMemberById(memberId) .orElseThrow(MemberNotFoundException::new); final Group group = groupRepository.findGroupById(groupId) .orElseThrow(GroupNotFoundException::new); - transactionTemplate.execute(new TransactionCallbackWithoutResult() { - @Override - protected void doInTransactionWithoutResult(TransactionStatus status) { - final int isDenyRequest = groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( - groupId, targetId, memberId); - - if (isDenyRequest != 1) { - throw new GroupInviteNotFoundException(); - } - - memberGroupRepository.save(MemberGroup.createGroupMember(groupMember, group)); - } + transactionTemplate.executeWithoutResult(status -> { + deleteGroupInvite(memberId, groupId, groupOwnerId); + memberGroupRepository.save(MemberGroup.createGroupMember(groupMember, group)); }); - socialNotificationManager.sendGroupAcceptMessage(groupMember.getNickname(), targetId); + return new GroupAcceptNotificationDto(groupMember.getNickname(), groupOwnerId); + } + + private void deleteGroupInvite(final Long memberId, final Long groupId, final Long groupOwnerId) { + final int isDenyRequest = groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + groupId, groupOwnerId, memberId); + if (isDenyRequest != 1) { + throw new GroupInviteNotFoundException(); + } } /** diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupQueryService.java b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupQueryService.java index e44aa6f7f..e62f7b264 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupQueryService.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupQueryService.java @@ -6,7 +6,11 @@ import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; +import site.timecapsulearchive.core.domain.group.exception.GroupNotFoundException; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupInviteSummaryDto; +import site.timecapsulearchive.core.domain.member_group.repository.groupInviteRepository.GroupInviteRepository; +import site.timecapsulearchive.core.domain.member_group.repository.memberGroupRepository.MemberGroupRepository; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupSendingInviteMemberDto; import site.timecapsulearchive.core.domain.member_group.repository.group_invite_repository.GroupInviteRepository; @@ -16,6 +20,7 @@ public class MemberGroupQueryService { private final GroupInviteRepository groupInviteRepository; + private final MemberGroupRepository memberGroupRepository; public Slice findGroupReceivingInvitesSlice( final Long memberId, @@ -25,6 +30,18 @@ public Slice findGroupReceivingInvitesSlice( return groupInviteRepository.findGroupReceivingInvitesSlice(memberId, size, createdAt); } + public List findGroupMemberIds(final Long groupId) { + return memberGroupRepository.findGroupMemberIds(groupId).orElseThrow( + GroupNotFoundException::new); + } + + public List findGroupMemberInfos( + final Long memberId, + final Long groupId + ) { + return memberGroupRepository.findGroupMemberInfos(memberId, groupId); + } + public List findGroupSendingInvites( final Long memberId, final Long groupId diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/RedisConfig.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisConfig.java similarity index 97% rename from backend/core/src/main/java/site/timecapsulearchive/core/global/config/RedisConfig.java rename to backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisConfig.java index c1a31b2a3..992541d4f 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/RedisConfig.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisConfig.java @@ -1,4 +1,4 @@ -package site.timecapsulearchive.core.global.config; +package site.timecapsulearchive.core.global.config.redis; import jakarta.persistence.EntityManagerFactory; import org.springframework.context.annotation.Bean; diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisProperties.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisProperties.java new file mode 100644 index 000000000..05700ef07 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedisProperties.java @@ -0,0 +1,11 @@ +package site.timecapsulearchive.core.global.config.redis; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "spring.data.redis") +public record RedisProperties( + String host, + int port +) { + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonConfig.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonConfig.java new file mode 100644 index 000000000..d5ed3ec15 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonConfig.java @@ -0,0 +1,26 @@ +package site.timecapsulearchive.core.global.config.redis; + +import lombok.RequiredArgsConstructor; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class RedissonConfig { + + private static final String REDISSON_HOST_PREFIX = "redis://"; + private static final String DIVISION = ":"; + private final RedisProperties redisProperties; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress( + REDISSON_HOST_PREFIX + redisProperties.host() + DIVISION + redisProperties.port()); + return Redisson.create(config); + } +} + diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLock.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLock.java new file mode 100644 index 000000000..548630007 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLock.java @@ -0,0 +1,19 @@ +package site.timecapsulearchive.core.global.config.redis; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RedissonLock { + + String value(); + + long waitTime() default 5000L; + + long leaseTime() default 3000L; +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLockAspect.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLockAspect.java new file mode 100644 index 000000000..f4444c523 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/config/redis/RedissonLockAspect.java @@ -0,0 +1,65 @@ +package site.timecapsulearchive.core.global.config.redis; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import java.lang.reflect.Method; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import site.timecapsulearchive.core.global.error.ErrorCode; +import site.timecapsulearchive.core.global.error.exception.RedisLockException; +import site.timecapsulearchive.core.global.util.RedisLockSpELParser; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class RedissonLockAspect { + + private static final String DIVISION = ":"; + private final RedissonClient redissonClient; + + @Around("@annotation(RedissonLock)") + public Object redissonLock(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + RedissonLock redissonLock = method.getAnnotation(RedissonLock.class); + + String lockKey = + method.getName() + DIVISION + RedisLockSpELParser.getLockKey(signature.getParameterNames(), + joinPoint.getArgs(), redissonLock.value()); + + long waitTime = redissonLock.waitTime(); + long leaseTime = redissonLock.leaseTime(); + + RLock lock = redissonClient.getLock(lockKey); + boolean isLocked = false; + + try { + isLocked = lock.tryLock(waitTime, leaseTime, MILLISECONDS); + if (isLocked) { + log.info("락을 얻는데 성공하였습니다. (락 키 : {})", lockKey); + return joinPoint.proceed(); + } else { + log.error("락을 얻는데 실패하였습니다. (락 키 : {})", lockKey); + throw new RedisLockException(ErrorCode.REDIS_FAILED_GET_LOCK_ERROR); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("락을 얻는데 인터럽트가 발생하였습니다. (락 키 : {})", lockKey); + throw new RedisLockException(ErrorCode.REDIS_INTERRUPT_ERROR); + } finally { + if (isLocked) { + lock.unlock(); + log.info("락을 해제하는데 성공하였습니다. (락 키 : {})", lockKey); + } + } + } + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/error/ErrorCode.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/error/ErrorCode.java index 2f97178f9..f29833617 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/error/ErrorCode.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/error/ErrorCode.java @@ -36,6 +36,10 @@ public enum ErrorCode { //외부 API EXTERNAL_API_ERROR(500, "EXTERNAL-001", "외부 api 호출에 실패했습니다. 잠시 후 요청해주세요."), + //Redis 분산 락 + REDIS_FAILED_GET_LOCK_ERROR(500, "LOCK-001", "분산 락을 얻는데 실패 하였습니다."), + REDIS_INTERRUPT_ERROR(500, "LOCK-002", "락을 얻는데 인터럽트가 발생 하였습니다."), + //member LOGIN_ON_NOT_VERIFIED_ERROR(400, "MEMBER-001", "인증되지 않은 사용자로 로그인을 시도했습니다."), diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/error/exception/RedisLockException.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/error/exception/RedisLockException.java new file mode 100644 index 000000000..35c219f8a --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/error/exception/RedisLockException.java @@ -0,0 +1,10 @@ +package site.timecapsulearchive.core.global.error.exception; + +import site.timecapsulearchive.core.global.error.ErrorCode; + +public class RedisLockException extends RuntimeException { + + public RedisLockException(ErrorCode errorCode) { + super(errorCode.getMessage()); + } +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/util/RedisLockSpELParser.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/RedisLockSpELParser.java new file mode 100644 index 000000000..8d5f8c9a7 --- /dev/null +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/RedisLockSpELParser.java @@ -0,0 +1,19 @@ +package site.timecapsulearchive.core.global.util; + +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +public final class RedisLockSpELParser { + + public static Object getLockKey(String[] parameterNames, Object[] args, String key) { + SpelExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + + return parser.parseExpression(key).getValue(context, Object.class); + } + +} diff --git a/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java index e1dab1ae9..d02ff813e 100644 --- a/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java +++ b/backend/core/src/main/java/site/timecapsulearchive/core/global/util/TagGenerator.java @@ -15,9 +15,10 @@ public final class TagGenerator { /** * 이메일과 소셜 타입(대문자)으로 태그를 생성한다. * - * @param email 이메일 + * @param email 이메일 * @param socialType 소셜 타입 - * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> {@code "test1234-123456GG"} + * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> + * {@code "test1234-123456GG"} */ public static String generate(final String email, final SocialType socialType) { final String randomInts = generateRandomInts(); @@ -41,9 +42,10 @@ private static String generateRandomInts() { /** * 이메일과 소셜 타입(소문자)으로 태그를 생성한다. * - * @param email 이메일 + * @param email 이메일 * @param socialType 소셜 타입 - * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> {@code "test1234-123456gg"} + * @return 생성된 태그 ex) {@code test1234@gmail.com, SocialType.GOOGLE} -> + * {@code "test1234-123456gg"} */ public static String lowercase(final String email, final SocialType socialType) { final String randomInts = generateRandomInts(); diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTest.java new file mode 100644 index 000000000..6f7f38444 --- /dev/null +++ b/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTest.java @@ -0,0 +1,8 @@ +package site.timecapsulearchive.core.common; + +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; + +@DataRedisTest +public abstract class RedissonTest extends RedissonTestContainer { + +} diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTestContainer.java b/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTestContainer.java new file mode 100644 index 000000000..8d578336d --- /dev/null +++ b/backend/core/src/test/java/site/timecapsulearchive/core/common/RedissonTestContainer.java @@ -0,0 +1,26 @@ +package site.timecapsulearchive.core.common; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.utility.DockerImageName; + +public abstract class RedissonTestContainer { + + private static final String REDIS_IMAGE = "redis:alpine"; + private static final int REDIS_PORT = 6379; + private static final RedisContainer REDIS_CONTAINER; + + static { + REDIS_CONTAINER = new RedisContainer( + DockerImageName.parse(REDIS_IMAGE)).withExposedPorts(REDIS_PORT); + REDIS_CONTAINER.start(); + } + + @DynamicPropertySource + private static void registerRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(REDIS_PORT) + .toString()); + } +} diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/repository/MemberGroupQueryRepositoryTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/repository/MemberGroupQueryRepositoryTest.java index a9709d0bc..04e9cfb94 100644 --- a/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/repository/MemberGroupQueryRepositoryTest.java +++ b/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/repository/MemberGroupQueryRepositoryTest.java @@ -8,7 +8,6 @@ import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.List; -import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -22,8 +21,6 @@ import site.timecapsulearchive.core.common.fixture.domain.GroupFixture; import site.timecapsulearchive.core.common.fixture.domain.MemberFixture; import site.timecapsulearchive.core.common.fixture.domain.MemberGroupFixture; -import site.timecapsulearchive.core.domain.group.data.dto.GroupDetailDto; -import site.timecapsulearchive.core.domain.group.data.dto.GroupMemberDto; import site.timecapsulearchive.core.domain.group.data.dto.GroupSummaryDto; import site.timecapsulearchive.core.domain.group.entity.Group; import site.timecapsulearchive.core.domain.group.repository.GroupQueryRepository; @@ -89,7 +86,7 @@ void setup(@Autowired EntityManager entityManager) { ZonedDateTime now = ZonedDateTime.now().plusDays(3); //when - Slice groupsSlice = groupQueryRepository.findGroupsSlice(memberId, + Slice groupsSlice = groupQueryRepository.findGroupSummaries(memberId, size, now); //then @@ -103,15 +100,16 @@ void setup(@Autowired EntityManager entityManager) { ZonedDateTime now = ZonedDateTime.now().plusDays(3); //when - List groupsSlice = groupQueryRepository.findGroupsSlice(memberId, + List groupsSlice = groupQueryRepository.findGroupSummaries(memberId, size, now).getContent(); //then assertSoftly(softly -> { softly.assertThat(groupsSlice).allMatch(dto -> dto.id() != null); - softly.assertThat(groupsSlice).allMatch(dto -> !dto.groupName().isBlank()); - softly.assertThat(groupsSlice).allMatch(dto -> !dto.groupDescription().isBlank()); - softly.assertThat(groupsSlice).allMatch(dto -> !dto.groupProfileUrl().isBlank()); + softly.assertThat(groupsSlice) + .allMatch(dto -> !dto.groupName().isBlank()); + softly.assertThat(groupsSlice) + .allMatch(dto -> !dto.groupProfileUrl().isBlank()); softly.assertThat(groupsSlice).allMatch(dto -> dto.isOwner() != null); }); } @@ -123,7 +121,7 @@ void setup(@Autowired EntityManager entityManager) { ZonedDateTime now = ZonedDateTime.now().plusDays(3); //when - List groupsSlice = groupQueryRepository.findGroupsSlice( + List groupsSlice = groupQueryRepository.findGroupSummaries( memberIdWithNoGroup, size, now) @@ -140,7 +138,7 @@ void setup(@Autowired EntityManager entityManager) { ZonedDateTime now = ZonedDateTime.now().minusDays(5); //when - List groupsSlice = groupQueryRepository.findGroupsSlice(memberId, + List groupsSlice = groupQueryRepository.findGroupSummaries(memberId, size, now).getContent(); //then diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandServiceTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandServiceTest.java index 2ea2586ee..4f983a78c 100644 --- a/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandServiceTest.java +++ b/backend/core/src/test/java/site/timecapsulearchive/core/domain/member_group/service/MemberGroupCommandServiceTest.java @@ -1,5 +1,6 @@ package site.timecapsulearchive.core.domain.member_group.service; +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -13,6 +14,7 @@ import java.util.List; import java.util.Optional; +import org.assertj.core.api.SoftAssertions; import org.junit.jupiter.api.Test; import org.springframework.transaction.support.TransactionTemplate; import site.timecapsulearchive.core.common.dependency.TestTransactionTemplate; @@ -25,15 +27,19 @@ import site.timecapsulearchive.core.domain.group.repository.GroupRepository; import site.timecapsulearchive.core.domain.member.entity.Member; import site.timecapsulearchive.core.domain.member.repository.MemberRepository; +import site.timecapsulearchive.core.domain.member_group.data.dto.GroupAcceptNotificationDto; import site.timecapsulearchive.core.domain.member_group.data.dto.GroupOwnerSummaryDto; import site.timecapsulearchive.core.domain.member_group.data.request.SendGroupRequest; import site.timecapsulearchive.core.domain.member_group.entity.MemberGroup; import site.timecapsulearchive.core.domain.member_group.exception.GroupInviteNotFoundException; +import site.timecapsulearchive.core.domain.member_group.exception.GroupMemberCountLimitException; import site.timecapsulearchive.core.domain.member_group.exception.GroupQuitException; import site.timecapsulearchive.core.domain.member_group.exception.MemberGroupKickDuplicatedIdException; import site.timecapsulearchive.core.domain.member_group.exception.MemberGroupNotFoundException; -import site.timecapsulearchive.core.domain.member_group.exception.GroupMemberCountLimitException; import site.timecapsulearchive.core.domain.member_group.exception.NoGroupAuthorityException; +import site.timecapsulearchive.core.domain.member_group.facade.MemberGroupFacade; +import site.timecapsulearchive.core.domain.member_group.repository.groupInviteRepository.GroupInviteRepository; +import site.timecapsulearchive.core.domain.member_group.repository.memberGroupRepository.MemberGroupRepository; import site.timecapsulearchive.core.domain.member_group.repository.group_invite_repository.GroupInviteRepository; import site.timecapsulearchive.core.domain.member_group.repository.member_group_repository.MemberGroupRepository; import site.timecapsulearchive.core.global.error.ErrorCode; @@ -58,6 +64,11 @@ class MemberGroupCommandServiceTest { socialNotificationManager ); + private final MemberGroupFacade groupMemberFacade = new MemberGroupFacade( + groupMemberCommandService, + socialNotificationManager + ); + @Test void 그룹장이_그룹원에게_그룹초대를_하면_그룹초대_알림이_요청된다() { @@ -66,8 +77,8 @@ class MemberGroupCommandServiceTest { SendGroupRequest request = MemberGroupDtoFixture.sendGroupRequest(1L, List.of(2L)); GroupOwnerSummaryDto groupOwnerSummaryDto = GroupDtoFixture.groupOwnerSummaryDto(true); - given(memberRepository.findMemberByIdIsIn(request.targetIds())).willReturn( - List.of(MemberFixture.member(2))); + given(memberGroupRepository.findGroupMembersCount(request.groupId())).willReturn( + Optional.of(10L)); given(memberGroupRepository.findOwnerInMemberGroup(request.groupId(), memberId)).willReturn( Optional.of(groupOwnerSummaryDto)); @@ -85,8 +96,8 @@ class MemberGroupCommandServiceTest { Long memberId = 1L; SendGroupRequest request = MemberGroupDtoFixture.sendGroupRequest(1L, List.of(2L)); - given(memberRepository.findMemberByIdIsIn(request.targetIds())).willReturn( - MemberFixture.membersWithMemberId(1, 40)); + given(memberGroupRepository.findGroupMembersCount(request.groupId())).willReturn( + Optional.of(40L)); //when assertThatThrownBy(() -> groupMemberCommandService.inviteGroup(memberId, request)) @@ -101,8 +112,8 @@ class MemberGroupCommandServiceTest { Long memberId = 1L; SendGroupRequest request = MemberGroupDtoFixture.sendGroupRequest(1L, List.of(2L)); - given(memberRepository.findMemberByIdIsIn(request.targetIds())).willReturn( - List.of(MemberFixture.member(2))); + given(memberGroupRepository.findGroupMembersCount(request.groupId())).willReturn( + Optional.of(10L)); given(memberGroupRepository.findOwnerInMemberGroup(request.groupId(), memberId)).willReturn( Optional.empty()); @@ -120,8 +131,8 @@ class MemberGroupCommandServiceTest { SendGroupRequest request = MemberGroupDtoFixture.sendGroupRequest(1L, List.of(2L)); GroupOwnerSummaryDto groupOwnerSummaryDto = GroupDtoFixture.groupOwnerSummaryDto(false); - given(memberRepository.findMemberByIdIsIn(request.targetIds())).willReturn( - List.of(MemberFixture.member(2))); + given(memberGroupRepository.findGroupMembersCount(request.groupId())).willReturn( + Optional.of(10L)); given(memberGroupRepository.findOwnerInMemberGroup(request.groupId(), memberId)).willReturn(Optional.of(groupOwnerSummaryDto)); @@ -172,44 +183,95 @@ class MemberGroupCommandServiceTest { //given Long memberId = 1L; Long groupId = 1L; - Long targetId = 2L; Member groupMember = MemberFixture.member(1); + given(groupRepository.getTotalGroupMemberCount(groupId)).willReturn(Optional.of(10L)); given(memberRepository.findMemberById(memberId)).willReturn(Optional.of(groupMember)); given(groupRepository.findGroupById(groupId)).willReturn( Optional.of(GroupFixture.group())); + given(memberGroupRepository.findGroupOwnerId(groupId)).willReturn(Optional.of(2L)); + given(groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( - groupId, targetId, memberId)).willReturn(1); + groupId, 2L, memberId)).willReturn(1); //when - groupMemberCommandService.acceptGroupInvite(memberId, groupId, targetId); + groupMemberFacade.acceptGroupInvite(memberId, groupId); //then verify(socialNotificationManager, times(1)).sendGroupAcceptMessage(anyString(), anyLong()); } + @Test + void 그룹원은_그룹초대를_수락하면_알림을_보내기_위해_그룹원_이름과_그룹장_아이디를_반환한다() { + //given + Long memberId = 1L; + Long groupId = 1L; + Member groupMember = MemberFixture.member(1); + Long groupOwnerId = 2L; + + given(groupRepository.getTotalGroupMemberCount(groupId)).willReturn(Optional.of(10L)); + given(memberRepository.findMemberById(memberId)).willReturn(Optional.of(groupMember)); + given(groupRepository.findGroupById(groupId)).willReturn( + Optional.of(GroupFixture.group())); + given(memberGroupRepository.findGroupOwnerId(groupId)).willReturn(Optional.of(groupOwnerId)); + + given(groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + groupId, groupOwnerId, memberId)).willReturn(1); + + //when + GroupAcceptNotificationDto groupAcceptNotificationDto = groupMemberCommandService.acceptGroupInvite( + memberId, groupId); + + //then + SoftAssertions.assertSoftly( + softly -> { + assertThat(groupAcceptNotificationDto.groupMemberNickname()).isEqualTo(groupMember.getNickname()); + assertThat(groupAcceptNotificationDto.groupOwnerId()).isEqualTo(groupOwnerId); + } + ); + } + @Test void 그룹원은_그룹초대를_수락할_때_그룹초대가_존재하지_않으면_예외가_발생한다() { //given Long memberId = 1L; Long groupId = 1L; - Long targetId = 2L; Member groupMember = MemberFixture.member(1); + given(groupRepository.getTotalGroupMemberCount(groupId)).willReturn(Optional.of(10L)); given(memberRepository.findMemberById(memberId)).willReturn(Optional.of(groupMember)); given(groupRepository.findGroupById(groupId)).willReturn( Optional.of(GroupFixture.group())); + given(memberGroupRepository.findGroupOwnerId(groupId)).willReturn(Optional.of(2L)); + given(groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( - groupId, targetId, memberId)).willReturn(0); + groupId, 2L, memberId)).willReturn(0); //when //then assertThatThrownBy( - () -> groupMemberCommandService.acceptGroupInvite(memberId, groupId, targetId)) + () -> groupMemberCommandService.acceptGroupInvite(memberId, groupId)) .isInstanceOf(GroupInviteNotFoundException.class) .hasMessageContaining(ErrorCode.GROUP_INVITATION_NOT_FOUND_ERROR.getMessage()); } + @Test + void 그룹원은_그룹초대를_수락할_때_그룹초대_인원이_이미_최대_인원이면_예외가_발생한다() { + //given + Long memberId = 1L; + Long groupId = 1L; + + given(groupRepository.getTotalGroupMemberCount(groupId)).willReturn(Optional.of(30L)); + given(memberGroupRepository.findGroupOwnerId(groupId)).willReturn(Optional.of(2L)); + given(groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + groupId, 2L, memberId)).willReturn(1); + + assertThatThrownBy( + () -> groupMemberCommandService.acceptGroupInvite(memberId, groupId)) + .isInstanceOf(GroupMemberCountLimitException.class) + .hasMessageContaining(ErrorCode.GROUP_MEMBER_COUNT_LIMIT_ERROR.getMessage()); + } + @Test void 그룹장인_사용자가_그룹_탈퇴를_시도하면_예외가_발생한다() { diff --git a/backend/core/src/test/java/site/timecapsulearchive/core/infrastructure/RedisConcurrencyTest.java b/backend/core/src/test/java/site/timecapsulearchive/core/infrastructure/RedisConcurrencyTest.java new file mode 100644 index 000000000..dfebd87e9 --- /dev/null +++ b/backend/core/src/test/java/site/timecapsulearchive/core/infrastructure/RedisConcurrencyTest.java @@ -0,0 +1,126 @@ +package site.timecapsulearchive.core.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.TestConstructor; +import org.springframework.test.context.TestConstructor.AutowireMode; +import site.timecapsulearchive.core.common.RedissonTest; +import site.timecapsulearchive.core.common.dependency.TestTransactionTemplate; +import site.timecapsulearchive.core.common.fixture.domain.GroupFixture; +import site.timecapsulearchive.core.common.fixture.domain.MemberFixture; +import site.timecapsulearchive.core.domain.group.repository.GroupRepository; +import site.timecapsulearchive.core.domain.member.entity.Member; +import site.timecapsulearchive.core.domain.member.repository.MemberRepository; +import site.timecapsulearchive.core.domain.member_group.facade.MemberGroupFacade; +import site.timecapsulearchive.core.domain.member_group.repository.groupInviteRepository.GroupInviteRepository; +import site.timecapsulearchive.core.domain.member_group.repository.memberGroupRepository.MemberGroupRepository; +import site.timecapsulearchive.core.domain.member_group.service.MemberGroupCommandService; +import site.timecapsulearchive.core.global.error.ErrorCode; +import site.timecapsulearchive.core.global.error.exception.RedisLockException; +import site.timecapsulearchive.core.infra.queue.manager.SocialNotificationManager; + +@TestConstructor(autowireMode = AutowireMode.ALL) +class RedisConcurrencyTest extends RedissonTest { + + private static final int MAX_THREADS_COUNT = 10; + + private final MemberRepository memberRepository = mock(MemberRepository.class); + private final GroupRepository groupRepository = mock(GroupRepository.class); + private final MemberGroupRepository memberGroupRepository = mock(MemberGroupRepository.class); + private final GroupInviteRepository groupInviteRepository = mock(GroupInviteRepository.class); + private final SocialNotificationManager socialNotificationManager = mock( + SocialNotificationManager.class); + + private final MemberGroupCommandService groupMemberCommandService = spy( + new MemberGroupCommandService( + memberRepository, + groupRepository, + memberGroupRepository, + groupInviteRepository, + TestTransactionTemplate.spied(), + socialNotificationManager) + ); + + private final MemberGroupFacade memberGroupFacade = spy( + new MemberGroupFacade(groupMemberCommandService, socialNotificationManager) + ); + + + @Test + void 사용자는_그룹_초대_요청을_수락할_때_레디스_분산락을_통해_동기적으로_처리한다() throws InterruptedException { + //given + ExecutorService executorService = Executors.newFixedThreadPool(MAX_THREADS_COUNT); + CountDownLatch latch = new CountDownLatch(MAX_THREADS_COUNT); + AtomicInteger countCheck = new AtomicInteger(MAX_THREADS_COUNT); + + Long memberId = 1L; + Long groupId = 1L; + + Member groupMember = MemberFixture.member(1); + + given(groupRepository.getTotalGroupMemberCount(groupId)) + .willReturn(Optional.of(10L)); + given(memberRepository.findMemberById(memberId)) + .willReturn(Optional.of(groupMember)); + given(groupRepository.findGroupById(groupId)).willReturn( + Optional.of(GroupFixture.group())); + given(memberGroupRepository.findGroupOwnerId(groupId)) + .willReturn(Optional.of(2L)); + + given(groupInviteRepository.deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + groupId, 2L, memberId)).willReturn(1); + + //when + for (int i = 0; i < MAX_THREADS_COUNT; i++) { + executorService.submit(() -> { + try { + memberGroupFacade.acceptGroupInvite(memberId, groupId); + } finally { + latch.countDown(); + int expectedCount = countCheck.decrementAndGet(); + assertThat(expectedCount).isEqualTo(latch.getCount()); + } + }); + } + + latch.await(); + executorService.shutdown(); + + //then + verify(groupInviteRepository, + times(MAX_THREADS_COUNT)).deleteGroupInviteByGroupIdAndGroupOwnerIdAndGroupMemberId( + anyLong(), anyLong(), anyLong()); + verify(memberGroupRepository, times(MAX_THREADS_COUNT)).save(any()); + } + + @Test + void 사용자는_그룹_초대_요청을_수락할_때_락을_얻지_못하면_예외가_발생한다() { + //given + Long memberId = 1L; + Long groupId = 1L; + + willThrow(new RedisLockException(ErrorCode.REDIS_FAILED_GET_LOCK_ERROR)) + .given(groupMemberCommandService).acceptGroupInvite(memberId, groupId); + + //when + //then + assertThatThrownBy(() -> groupMemberCommandService.acceptGroupInvite(memberId, groupId)) + .isInstanceOf(RedisLockException.class) + .hasMessage(ErrorCode.REDIS_FAILED_GET_LOCK_ERROR.getMessage()); + } +}