diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a4110d0..334a58ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,17 @@ jobs: S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} S3_BUCKET_REGION: ${{ secrets.S3_BUCKET_REGION }} + services: + redis: + image: redis:7.0-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - uses: actions/checkout@v4 - name: Set up JDK 17 diff --git a/build.gradle b/build.gradle index 463e4952..3080bc99 100644 --- a/build.gradle +++ b/build.gradle @@ -37,6 +37,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt:0.12.6' implementation 'com.resend:resend-java:4.1.1' implementation 'com.googlecode.libphonenumber:libphonenumber:9.0.9' + implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2' + implementation 'net.javacrumbs.shedlock:shedlock-provider-redis-spring:6.9.2' + implementation 'org.redisson:redisson-spring-boot-starter:3.46.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index 25eec76c..89b07e83 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -78,7 +78,7 @@ public UserRegisterResponse register(UserRegisterRequest req) { .build(); userAuthRepository.save(userAuth); - eventPublisher.publishEvent(new UserRegisteredEvent(userId, email)); + eventPublisher.publishEvent(new UserRegisteredEvent(email)); return UserRegisterResponse.from(userId); } diff --git a/src/main/java/project/flipnote/common/config/RedissonConfig.java b/src/main/java/project/flipnote/common/config/RedissonConfig.java new file mode 100644 index 00000000..c34257f2 --- /dev/null +++ b/src/main/java/project/flipnote/common/config/RedissonConfig.java @@ -0,0 +1,51 @@ +package project.flipnote.common.config; + +import java.util.Arrays; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.util.StringUtils; + +@Configuration +public class RedissonConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Value("${spring.data.redis.cluster.nodes:}") + private String clusterNodes; + + @Bean(destroyMethod = "shutdown") + public RedissonClient redissonClient() { + Config config = new Config(); + + if (!clusterNodes.isBlank()) { + config.useClusterServers() + .addNodeAddress( + Arrays.stream(clusterNodes.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map(addr -> addr.startsWith("redis://") || addr.startsWith("rediss://") ? addr : + "redis://" + addr) + .toArray(String[]::new) + ) + .setPassword(StringUtils.hasText(password) ? password : null); + } else { + config.useSingleServer() + .setAddress("redis://" + host + ":" + port) + .setPassword(StringUtils.hasText(password) ? password : null); + } + + return Redisson.create(config); + } +} diff --git a/src/main/java/project/flipnote/common/config/SchedulerConfig.java b/src/main/java/project/flipnote/common/config/SchedulerConfig.java new file mode 100644 index 00000000..9fa1e7a2 --- /dev/null +++ b/src/main/java/project/flipnote/common/config/SchedulerConfig.java @@ -0,0 +1,9 @@ +package project.flipnote.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableScheduling +@Configuration +public class SchedulerConfig { +} diff --git a/src/main/java/project/flipnote/common/config/ShedLockConfig.java b/src/main/java/project/flipnote/common/config/ShedLockConfig.java new file mode 100644 index 00000000..cbd5074d --- /dev/null +++ b/src/main/java/project/flipnote/common/config/ShedLockConfig.java @@ -0,0 +1,19 @@ +package project.flipnote.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; + +import net.javacrumbs.shedlock.core.LockProvider; +import net.javacrumbs.shedlock.provider.redis.spring.RedisLockProvider; +import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock; + +@EnableSchedulerLock(defaultLockAtMostFor = "PT30S") +@Configuration +public class ShedLockConfig { + + @Bean + public LockProvider lockProvider(RedisConnectionFactory connectionFactory) { + return new RedisLockProvider(connectionFactory); + } +} diff --git a/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java b/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java index 4ec670c9..efac2972 100644 --- a/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java +++ b/src/main/java/project/flipnote/common/event/UserRegisteredEvent.java @@ -1,7 +1,6 @@ package project.flipnote.common.event; public record UserRegisteredEvent( - Long userId, String email ) { } diff --git a/src/main/java/project/flipnote/common/exception/CommonErrorCode.java b/src/main/java/project/flipnote/common/exception/CommonErrorCode.java index 5a253e9c..c0df7645 100644 --- a/src/main/java/project/flipnote/common/exception/CommonErrorCode.java +++ b/src/main/java/project/flipnote/common/exception/CommonErrorCode.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum CommonErrorCode implements ErrorCode { INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR.value(), "COMMON_001", "예기치 않은 오류가 발생했습니다."), - INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST.value(), "COMMON_002", "입력값이 올바르지 않습니다."); + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST.value(), "COMMON_002", "입력값이 올바르지 않습니다."), + SERVICE_TEMPORARILY_UNAVAILABLE(HttpStatus.TOO_MANY_REQUESTS.value(), "COMMON_003", "요청이 많아 처리되지 않았습니다. 잠시 후 다시 시도해주세요."); private final int status; private final String code; diff --git a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java index 19e45079..a9ebea9f 100644 --- a/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java +++ b/src/main/java/project/flipnote/group/controller/GroupInvitationQueryController.java @@ -27,8 +27,8 @@ public class GroupInvitationQueryController implements GroupInvitationQueryContr @GetMapping("/groups/{groupId}/invitations") public ResponseEntity> getOutgoingInvitations( @PathVariable("groupId") Long groupId, - @Min(0) @RequestParam(defaultValue = "0") int page, - @Min(1) @Min(30) @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { PageResponse res @@ -39,8 +39,8 @@ public ResponseEntity> getOutgoing @GetMapping("/group-invitations") public ResponseEntity> getIncomingInvitations( - @Min(0) @RequestParam(defaultValue = "0") int page, - @Min(1) @Min(30) @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { PageResponse res diff --git a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java index 72bb4335..b849c98c 100644 --- a/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java +++ b/src/main/java/project/flipnote/group/controller/docs/GroupInvitationQueryControllerDocs.java @@ -5,6 +5,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import project.flipnote.common.response.PageResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.model.IncomingGroupInvitationResponse; @@ -16,15 +18,15 @@ public interface GroupInvitationQueryControllerDocs { @Operation(summary = "그룹 초대 보낸 목록 조회", security = {@SecurityRequirement(name = "access-token")}) ResponseEntity> getOutgoingInvitations( Long groupId, - int page, - int size, + @Min(0) int page, + @Min(1) @Max(30) int size, AuthPrinciple authPrinciple ); @Operation(summary = "그룹 초대 받은 목록 조회", security = {@SecurityRequirement(name = "access-token")}) ResponseEntity> getIncomingInvitations( - int page, - int size, + @Min(0) int page, + @Min(1) @Max(30) int size, AuthPrinciple authPrinciple ); } diff --git a/src/main/java/project/flipnote/group/entity/Group.java b/src/main/java/project/flipnote/group/entity/Group.java index ee138a75..d87398fc 100644 --- a/src/main/java/project/flipnote/group/entity/Group.java +++ b/src/main/java/project/flipnote/group/entity/Group.java @@ -32,6 +32,7 @@ @SQLDelete(sql = "UPDATE app_groups SET deleted_at = now() WHERE id = ?") @SQLRestriction("deleted_at IS NULL") public class Group extends BaseEntity { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -59,6 +60,11 @@ public class Group extends BaseEntity { private String imageUrl; + @Column(nullable = false) + @Min(1) + @Max(100) + private Integer memberCount; + @Column(name = "deleted_at") private LocalDateTime deletedAt; @@ -79,11 +85,17 @@ private Group( this.publicVisible = publicVisible; this.maxMember = maxMember; this.imageUrl = imageUrl; + this.memberCount = 1; } public void validateJoinable() { - if (maxMember < 1 || maxMember >= 100) { - throw new BizException(GroupErrorCode.INVALID_MAX_MEMBER); + if (memberCount >= maxMember) { + throw new BizException(GroupErrorCode.GROUP_IS_ALREADY_MAX_MEMBER); } } + + public void increaseMemberCount() { + validateJoinable(); + memberCount++; + } } diff --git a/src/main/java/project/flipnote/group/entity/GroupInvitation.java b/src/main/java/project/flipnote/group/entity/GroupInvitation.java index 140a4606..8bf24625 100644 --- a/src/main/java/project/flipnote/group/entity/GroupInvitation.java +++ b/src/main/java/project/flipnote/group/entity/GroupInvitation.java @@ -1,5 +1,8 @@ package project.flipnote.group.entity; +import java.time.LocalDateTime; +import java.util.Objects; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -17,12 +20,14 @@ import lombok.Getter; import lombok.NoArgsConstructor; import project.flipnote.common.entity.BaseEntity; +import project.flipnote.common.exception.BizException; +import project.flipnote.group.exception.GroupInvitationErrorCode; @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table( - name = "group_invitation", + name = "group_invitations", indexes = { @Index(name = "idx_group_invitee_user", columnList = "group_id, invitee_user_id, status"), @Index(name = "idx_group_invitee_email", columnList = "group_id, invitee_email, status"), @@ -36,6 +41,8 @@ ) public class GroupInvitation extends BaseEntity { + private static final long DEFAULT_EXPIRATION_DAYS = 7L; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -55,6 +62,9 @@ public class GroupInvitation extends BaseEntity { @Column(nullable = false) private GroupInvitationStatus status; + @Column(nullable = false) + private LocalDateTime expiredAt; + @Builder public GroupInvitation(Group group, Long inviterUserId, Long inviteeUserId, String inviteeEmail) { this.group = group; @@ -62,9 +72,33 @@ public GroupInvitation(Group group, Long inviterUserId, Long inviteeUserId, Stri this.inviteeUserId = inviteeUserId; this.inviteeEmail = inviteeEmail; this.status = GroupInvitationStatus.PENDING; + this.expiredAt = LocalDateTime.now().plusDays(DEFAULT_EXPIRATION_DAYS); } public void respond(GroupInvitationStatus status) { this.status = status; } + + public void validateNotExpired() { + if (isExpired()) { + throw new BizException(GroupInvitationErrorCode.INVITATION_NOT_FOUND); + } + } + + public boolean isExpired() { + if (this.status == GroupInvitationStatus.EXPIRED) { + return true; + } + if (this.status != GroupInvitationStatus.PENDING) { + return false; + } + return this.expiredAt.isBefore(LocalDateTime.now()); + } + + public GroupInvitationStatus getStatus() { + if (isExpired()) { + return GroupInvitationStatus.EXPIRED; + } + return status; + } } diff --git a/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java b/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java index f4a0ffea..c470f825 100644 --- a/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java +++ b/src/main/java/project/flipnote/group/entity/GroupInvitationStatus.java @@ -1,5 +1,5 @@ package project.flipnote.group.entity; public enum GroupInvitationStatus { - PENDING, ACCEPTED, REJECTED + PENDING, ACCEPTED, REJECTED, EXPIRED; } diff --git a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java index 5617905c..d00d5a75 100644 --- a/src/main/java/project/flipnote/group/exception/GroupErrorCode.java +++ b/src/main/java/project/flipnote/group/exception/GroupErrorCode.java @@ -1,6 +1,5 @@ package project.flipnote.group.exception; - import org.springframework.http.HttpStatus; import lombok.Getter; @@ -12,7 +11,9 @@ public enum GroupErrorCode implements ErrorCode { GROUP_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_002", "그룹이 존재하지 않습니다."), INVALID_MAX_MEMBER(HttpStatus.BAD_REQUEST, "GROUP_001", "최대 인원 수는 1 이상 100 이하여야 합니다."), - USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다."); + USER_NOT_IN_GROUP(HttpStatus.NOT_FOUND, "GROUP_003", "그룹에 유저가 존재하지 않습니다."), + GROUP_IS_ALREADY_MAX_MEMBER(HttpStatus.CONFLICT, "GROUP_004", "그룹 정원이 가득 찼습니다."), + ALREADY_GROUP_MEMBER(HttpStatus.CONFLICT, "GROUP_005", "이미 그룹 회원입니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java b/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java index 1bca0b37..820947ab 100644 --- a/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java +++ b/src/main/java/project/flipnote/group/exception/GroupInvitationErrorCode.java @@ -1,6 +1,5 @@ package project.flipnote.group.exception; - import org.springframework.http.HttpStatus; import lombok.Getter; @@ -13,8 +12,7 @@ public enum GroupInvitationErrorCode implements ErrorCode { ALREADY_INVITED(HttpStatus.CONFLICT, "GROUP_INVITATION_001", "이미 초대된 사용자입니다."), NO_INVITATION_PERMISSION(HttpStatus.FORBIDDEN, "GROUP_INVITATION_002", "해당 그룹에 초대할 권한이 없습니다."), INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "GROUP_INVITATION_003", "유효하지 않은 초대입니다."), - CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "GROUP_INVITATION_004", "본인을 초대할 수 없습니다."), - ALREADY_GROUP_MEMBER(HttpStatus.CONFLICT, "GROUP_INVITATION_005", "이미 그룹 회원입니다."); + CANNOT_INVITE_SELF(HttpStatus.BAD_REQUEST, "GROUP_INVITATION_004", "본인을 초대할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java index 9c6264d7..d17e5b15 100644 --- a/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java +++ b/src/main/java/project/flipnote/group/listener/UserRegisteredEventListener.java @@ -27,11 +27,11 @@ public class UserRegisteredEventListener { ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void HandleUserRegisteredEvent(UserRegisteredEvent event) { - groupInvitationService.acceptPendingInvitationsOnRegister(event.userId(), event.email()); + groupInvitationService.acceptPendingInvitationsOnRegister(event.email()); } @Recover public void recover(Exception ex, UserRegisteredEvent event) { - log.error("회원가입 후속 처리 예외 발생: userId={}, email={}", event.userId(), event.email(), ex); + log.error("회원가입 후속 처리 예외 발생: email={}", event.email(), ex); } } diff --git a/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java index 07736e1d..f2f3dbe2 100644 --- a/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java +++ b/src/main/java/project/flipnote/group/model/GroupInvitationStatus.java @@ -1,7 +1,7 @@ package project.flipnote.group.model; public enum GroupInvitationStatus { - PENDING, ACCEPTED, REJECTED; + PENDING, ACCEPTED, REJECTED, EXPIRED; public static GroupInvitationStatus from(project.flipnote.group.entity.GroupInvitationStatus status) { if (status == null) { @@ -12,6 +12,7 @@ public static GroupInvitationStatus from(project.flipnote.group.entity.GroupInvi case PENDING -> PENDING; case ACCEPTED -> ACCEPTED; case REJECTED -> REJECTED; + case EXPIRED -> EXPIRED; default -> throw new IllegalArgumentException("Unknown GroupInvitationStatus: " + status); }; } diff --git a/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java index 8bff8219..4cf2e292 100644 --- a/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java +++ b/src/main/java/project/flipnote/group/repository/GroupInvitationRepository.java @@ -1,11 +1,13 @@ package project.flipnote.group.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,38 +18,24 @@ public interface GroupInvitationRepository extends JpaRepository findByIdAndStatus(Long id, GroupInvitationStatus status); - @Query(""" - SELECT gi - FROM GroupInvitation gi - JOIN FETCH gi.group g - WHERE gi.id = :id - AND g.id = :groupId - AND gi.inviteeUserId = :inviteeUserId - AND gi.status = :status - """) - Optional findWithGroupByIdAndGroup_IdAndInviteeUserIdAndStatus( - @Param("id") Long id, - @Param("groupId") Long groupId, - @Param("inviteeUserId") Long inviteeUserId, - @Param("status") GroupInvitationStatus status + Optional findByIdAndGroup_IdAndInviteeUserIdAndStatus( + Long id, Long groupId, Long inviteeUserId, GroupInvitationStatus status ); Page findAllByGroup_Id(Long groupId, Pageable pageable); - @Query(""" - SELECT gi - FROM GroupInvitation gi - JOIN FETCH gi.group g - WHERE gi.inviteeEmail = :inviteeEmail - AND gi.status = :status - """) - List findAllWithGroupByInviteeEmailAndStatus( - @Param("inviteeEmail") String inviteeEmail, - @Param("status") GroupInvitationStatus status - ); + List findAllByInviteeEmailAndStatus(String inviteeEmail, GroupInvitationStatus status); + Page findAllByInviteeUserId(Long inviteeUserId, Pageable pageable); boolean existsByGroup_IdAndInviteeUserIdAndStatus(Long groupId, Long inviteeUserId, GroupInvitationStatus status); boolean existsByGroup_IdAndInviteeEmailAndStatus(Long groupId, String inviteeEmail, GroupInvitationStatus status); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE GroupInvitation gi " + + "SET gi.status = project.flipnote.group.entity.GroupInvitationStatus.EXPIRED " + + "WHERE gi.status = project.flipnote.group.entity.GroupInvitationStatus.PENDING " + + "AND gi.expiredAt < :now") + int bulkExpire(@Param("now") LocalDateTime now); } diff --git a/src/main/java/project/flipnote/group/repository/GroupRepository.java b/src/main/java/project/flipnote/group/repository/GroupRepository.java index ec4279be..68ab6ff7 100644 --- a/src/main/java/project/flipnote/group/repository/GroupRepository.java +++ b/src/main/java/project/flipnote/group/repository/GroupRepository.java @@ -1,22 +1,23 @@ package project.flipnote.group.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import jakarta.persistence.LockModeType; import project.flipnote.group.entity.Group; -import project.flipnote.group.entity.GroupMember; - -import java.util.List; -import java.util.Optional; @Repository public interface GroupRepository extends JpaRepository { - Optional findByIdAndDeletedAtIsNull(Long groupId); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query("select g from Group g where g.id = :id") - Optional findByIdForUpdate(Long groupId); + Optional findByIdAndDeletedAtIsNull(Long groupId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select g from Group g where g.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + } diff --git a/src/main/java/project/flipnote/group/scheduler/GroupInvitationExpireScheduler.java b/src/main/java/project/flipnote/group/scheduler/GroupInvitationExpireScheduler.java new file mode 100644 index 00000000..b4bc2191 --- /dev/null +++ b/src/main/java/project/flipnote/group/scheduler/GroupInvitationExpireScheduler.java @@ -0,0 +1,39 @@ +package project.flipnote.group.scheduler; + +import java.time.LocalDateTime; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.group.repository.GroupInvitationRepository; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GroupInvitationExpireScheduler { + + private final GroupInvitationRepository groupInvitationRepository; + + @Transactional + @SchedulerLock( + name = "GroupInvitationExpireScheduler_ExpireJob", + lockAtLeastFor = "PT59M", + lockAtMostFor = "PT65M" + ) + @Scheduled(cron = "0 0 * * * *", zone = "Asia/Seoul") + public void runExpireJob() { + LocalDateTime now = LocalDateTime.now(); + + log.info("[GroupInvitationExpireScheduler] 만료 처리 시작 - 기준 시각: {}", now); + + // TODO: 배치로 리팩토링 필요 + int updatedCount = groupInvitationRepository.bulkExpire(now); + + log.info("[GroupInvitationExpireScheduler] 만료 처리 완료 - 변경 건수: {}", updatedCount); + } +} diff --git a/src/main/java/project/flipnote/group/service/GroupInvitationService.java b/src/main/java/project/flipnote/group/service/GroupInvitationService.java index 884f4e59..d0feafd2 100644 --- a/src/main/java/project/flipnote/group/service/GroupInvitationService.java +++ b/src/main/java/project/flipnote/group/service/GroupInvitationService.java @@ -9,17 +9,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import project.flipnote.common.exception.BizException; import project.flipnote.common.response.PageResponse; import project.flipnote.common.security.dto.AuthPrinciple; -import project.flipnote.group.entity.Group; import project.flipnote.group.entity.GroupInvitation; import project.flipnote.group.entity.GroupInvitationStatus; -import project.flipnote.group.entity.GroupMember; -import project.flipnote.group.entity.GroupMemberRole; import project.flipnote.group.entity.GroupPermissionStatus; +import project.flipnote.group.exception.GroupErrorCode; import project.flipnote.group.exception.GroupInvitationErrorCode; import project.flipnote.group.model.GroupInvitationCreateRequest; import project.flipnote.group.model.GroupInvitationCreateResponse; @@ -42,7 +39,7 @@ public class GroupInvitationService { private final GroupService groupService; private final GroupRepository groupRepository; private final GroupMemberRepository groupMemberRepository; - private final EntityManager em; + private final GroupMemberPolicyService groupMemberPolicyService; /** * 그룹에 회원 혹은 비회원 초대 @@ -53,8 +50,11 @@ public class GroupInvitationService { * @author 윤정환 */ @Transactional - public GroupInvitationCreateResponse createGroupInvitation(AuthPrinciple authPrinciple, Long groupId, - GroupInvitationCreateRequest req) { + public GroupInvitationCreateResponse createGroupInvitation( + AuthPrinciple authPrinciple, + Long groupId, + GroupInvitationCreateRequest req + ) { Long inviterUserId = authPrinciple.userId(); validateGroupInvitePermission(inviterUserId, groupId); @@ -106,17 +106,24 @@ public void respondToGroupInvitation( Long invitationId, GroupInvitationRespondRequest req ) { - GroupInvitation invitation = groupInvitationRepository.findWithGroupByIdAndGroup_IdAndInviteeUserIdAndStatus( + GroupInvitation invitation = groupInvitationRepository.findByIdAndGroup_IdAndInviteeUserIdAndStatus( invitationId, groupId, inviteeUserId, GroupInvitationStatus.PENDING ) .orElseThrow(() -> new BizException(GroupInvitationErrorCode.INVITATION_NOT_FOUND)); - invitation.getGroup().validateJoinable(); + invitation.validateNotExpired(); invitation.respond(req.toEntityStatus()); - if (Objects.equals(invitation.getStatus(), GroupInvitationStatus.ACCEPTED)) { - addGroupMember(inviteeUserId, groupId); + if (invitation.getStatus() == GroupInvitationStatus.ACCEPTED) { + try { + groupMemberPolicyService.addGroupMember(inviteeUserId, groupId); + } catch (BizException ex) { + // 이미 그룹 멤버인 경우 롤백되지 않게 하기 위함 + if (ex.getErrorCode() != GroupErrorCode.ALREADY_GROUP_MEMBER) { + throw ex; + } + } } } @@ -177,23 +184,27 @@ public PageResponse getIncomingInvitations(Long /** * 회원가입시 비회원 그룹 초대를 수락 * - * @param inviteeUserId 초대 받은 회원 ID - * @param inviteeEmail 초대 받은 회원 email + * @param inviteeEmail 초대 받은 회원 email * @author 윤정환 */ @Transactional - public void acceptPendingInvitationsOnRegister(Long inviteeUserId, String inviteeEmail) { + public void acceptPendingInvitationsOnRegister(String inviteeEmail) { List invitations = groupInvitationRepository - .findAllWithGroupByInviteeEmailAndStatus(inviteeEmail, GroupInvitationStatus.PENDING); + .findAllByInviteeEmailAndStatus(inviteeEmail, GroupInvitationStatus.PENDING); for (GroupInvitation invitation : invitations) { - Group group = invitation.getGroup(); - - group.validateJoinable(); - - invitation.respond(GroupInvitationStatus.ACCEPTED); - - addGroupMember(inviteeUserId, group.getId()); + if (invitation.isExpired()) { + continue; + } + + try { + groupMemberPolicyService.addGroupMember(invitation.getInviteeUserId(), invitation.getGroup().getId()); + invitation.respond(GroupInvitationStatus.ACCEPTED); + } catch (BizException ex) { + if (ex.getErrorCode() == GroupErrorCode.ALREADY_GROUP_MEMBER) { + invitation.respond(GroupInvitationStatus.ACCEPTED); + } + } catch (Exception ignored) { } } } @@ -220,7 +231,7 @@ private void validateGroupInvitePermission(Long userId, Long groupId) { */ private Long createUserInvitation(Long inviterUserId, Long groupId, UserProfile inviteeUser) { if (groupMemberRepository.existsByGroup_idAndUser_id(groupId, inviteeUser.getId())) { - throw new BizException(GroupInvitationErrorCode.ALREADY_GROUP_MEMBER); + throw new BizException(GroupErrorCode.ALREADY_GROUP_MEMBER); } if (groupInvitationRepository @@ -268,25 +279,4 @@ private Long createGuestInvitation(Long inviterUserId, Long groupId, String invi return invitation.getId(); } - - /** - * 그룹에 회원 추가 - * - * @param inviteeUserId 초대 받는 회원 ID - * @param groupId 초대한 그룹 ID - * @author 윤정환 - */ - private void addGroupMember(Long inviteeUserId, Long groupId) { - if (groupMemberRepository.existsByGroup_idAndUser_id(groupId, inviteeUserId)) { - return; - } - - GroupMember groupMember = GroupMember.builder() - .group(groupRepository.getReferenceById(groupId)) - .user(em.getReference(UserProfile.class, inviteeUserId)) - .role(GroupMemberRole.MEMBER) - .build(); - - groupMemberRepository.save(groupMember); - } } diff --git a/src/main/java/project/flipnote/group/service/GroupMemberPolicyService.java b/src/main/java/project/flipnote/group/service/GroupMemberPolicyService.java new file mode 100644 index 00000000..75953cfc --- /dev/null +++ b/src/main/java/project/flipnote/group/service/GroupMemberPolicyService.java @@ -0,0 +1,69 @@ +package project.flipnote.group.service; + +import java.util.concurrent.TimeUnit; + +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.BizException; +import project.flipnote.common.exception.CommonErrorCode; +import project.flipnote.group.entity.Group; +import project.flipnote.group.entity.GroupMember; +import project.flipnote.group.entity.GroupMemberRole; +import project.flipnote.group.exception.GroupErrorCode; +import project.flipnote.group.repository.GroupMemberRepository; +import project.flipnote.group.repository.GroupRepository; +import project.flipnote.user.entity.UserProfile; + +@RequiredArgsConstructor +@Service +public class GroupMemberPolicyService { + + private final GroupRepository groupRepository; + private final GroupMemberRepository groupMemberRepository; + private final EntityManager em; + private final RedissonClient redissonClient; + + @Transactional + public void addGroupMember(Long inviteeUserId, Long groupId) { + // TODO: AOP로 분산락 적용할 수 있도록 수정 예정 + String lockKey = "group_member_lock:" + groupId; + RLock lock = redissonClient.getLock(lockKey); + + boolean isLocked = false; + try { + isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS); + if (!isLocked) { + throw new BizException(CommonErrorCode.SERVICE_TEMPORARILY_UNAVAILABLE); + } + + Group lockedGroup = groupRepository.findByIdForUpdate(groupId) + .orElseThrow(() -> new BizException(GroupErrorCode.GROUP_NOT_FOUND)); + + lockedGroup.validateJoinable(); + if (groupMemberRepository.existsByGroup_idAndUser_id(groupId, inviteeUserId)) { + throw new BizException(GroupErrorCode.ALREADY_GROUP_MEMBER); + } + + GroupMember groupMember = GroupMember.builder() + .group(groupRepository.getReferenceById(groupId)) + .user(em.getReference(UserProfile.class, inviteeUserId)) + .role(GroupMemberRole.MEMBER) + .build(); + + groupMemberRepository.save(groupMember); + lockedGroup.increaseMemberCount(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new BizException(CommonErrorCode.SERVICE_TEMPORARILY_UNAVAILABLE); + } finally { + if (isLocked && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9e9faa2d..b502bb05 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -27,7 +27,7 @@ spring: data: redis: host: ${SPRING_DATA_REDIS_HOST} - password: ${SPRING_DATA_REDIS_PASSWORD} + password: ${SPRING_DATA_REDIS_PASSWORD:} port: ${SPRING_DATA_REDIS_PORT} management: