diff --git a/.github/workflows/open-api-code-review.yml b/.github/workflows/open-api-code-review.yml new file mode 100644 index 000000000..c35fd3472 --- /dev/null +++ b/.github/workflows/open-api-code-review.yml @@ -0,0 +1,19 @@ +name: Code Review + +permissions: + contents: read + pull-requests: write + +on: + pull_request: + types: [ opened ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: anc95/ChatGPT-CodeReview@main + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OPENAI_API_KEY: ${{ secrets.OPEN_API_KEY }} + LANGUAGE: Korean diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java new file mode 100644 index 000000000..979ca9174 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/storage/service/StorageService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.api.apis.storage.service; + +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.net.URI; + +@Slf4j +@Service +@RequiredArgsConstructor +public class StorageService { + private final AwsS3Provider awsS3Provider; + + public URI getPresignedUrl(String type, String ext, String userId, String chatroomId) { + return awsS3Provider.generatedPresignedUrl(type, ext, userId, chatroomId); + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java new file mode 100644 index 000000000..4a173cb38 --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/apis/users/service/DeviceTokenRegisterService.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.api.apis.users.service; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DeviceTokenRegisterService { + private final UserService userService; + private final DeviceTokenService deviceTokenService; + + @Transactional + public DeviceToken execute(Long userId, String token) { + User user = userService.readUser(userId).orElseThrow(() -> new UserErrorException(UserErrorCode.NOT_FOUND)); + + return getOrCreateDevice(user, token); + } + + /** + * 사용자의 디바이스 토큰을 가져오거나 생성한다. + *

+ * 이미 등록된 디바이스 토큰인 경우 마지막 로그인 시간을 갱신한다. + */ + private DeviceToken getOrCreateDevice(User user, String token) { + Optional deviceToken = deviceTokenService.readDeviceByUserIdAndToken(user.getId(), token); + + if (deviceToken.isPresent()) { + DeviceToken device = deviceToken.get(); + device.activate(); + device.updateLastSignedInAt(); + return device; + } else { + return deviceTokenService.createDevice(DeviceToken.of(token, user)); + } + } +} diff --git a/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java new file mode 100644 index 000000000..2109b844d --- /dev/null +++ b/pennyway-app-external-api/src/main/java/kr/co/pennyway/api/common/security/jwt/JwtClaimsParserUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.api.common.security.jwt; + +import kr.co.pennyway.infra.common.jwt.JwtClaims; + +import java.util.function.Function; + +public class JwtClaimsParserUtil { + /** + * JwtClaims에서 key에 해당하는 값을 반환하는 메서드 + * + * @return key에 해당하는 값이 없거나, 타입이 일치하지 않을 경우 null을 반환한다. + */ + @SuppressWarnings("unchecked") + public static T getClaimsValue(JwtClaims claims, String key, Class type) { + Object value = claims.getClaims().get(key); + if (value != null && type.isAssignableFrom(value.getClass())) { + return (T) value; + } + return null; + } + + /** + * JwtClaims에서 valueConverter를 이용하여 key에 해당하는 값을 반환하는 메서드 + * + * @param valueConverter : String 타입의 값을 T 타입으로 변환하는 함수 + * @return key에 해당하는 값이 없을 경우 null을 반환한다. + */ + public static T getClaimsValue(JwtClaims claims, String key, Function valueConverter) { + Object value = claims.getClaims().get(key); + if (value != null) { + return valueConverter.apply((String) value); + } + return null; + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java new file mode 100644 index 000000000..32036b723 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/service/NameUpdateServiceTest.java @@ -0,0 +1,82 @@ +package kr.co.pennyway.api.apis.users.service; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.auth.service.PhoneVerificationService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.common.redis.phone.PhoneCodeService; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import kr.co.pennyway.domain.domains.user.exception.UserErrorException; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.infra.client.aws.s3.AwsS3Provider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserProfileUpdateService.class, UserService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class NameUpdateServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private UserProfileUpdateService userProfileUpdateService; + + @MockBean + private AwsS3Provider awsS3Provider; + + @MockBean + private PhoneVerificationService phoneVerificationService; + + @MockBean + private PhoneCodeService phoneCodeService; + + @MockBean + private JPAQueryFactory queryFactory; + + @Test + @Transactional + @DisplayName("사용자가 삭제된 유저인 경우 NOT_FOUND 에러를 반환한다.") + void updateNameWhenUserIsDeleted() { + // given + String newName = "양재서"; + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + userService.deleteUser(originUser); + + // when - then + UserErrorException ex = assertThrows(UserErrorException.class, () -> userProfileUpdateService.updateName(originUser.getId(), newName)); + assertEquals("삭제된 사용자인 경우 Not Found를 반환한다.", UserErrorCode.NOT_FOUND, ex.getBaseErrorCode()); + } + + @Test + @Transactional + @DisplayName("사용자의 이름이 성공적으로 변경된다.") + void updateName() { + // given + User originUser = UserFixture.GENERAL_USER.toUser(); + userService.createUser(originUser); + String newName = "양재서"; + + // when + userProfileUpdateService.updateName(originUser.getId(), newName); + + // then + User updatedUser = userService.readUser(originUser.getId()).orElseThrow(); + assertEquals("사용자 이름이 변경되어 있어야 한다.", newName, updatedUser.getName()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java new file mode 100644 index 000000000..4c99a9cd6 --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenRegisterServiceTest.java @@ -0,0 +1,115 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.dto.DeviceTokenDto; +import kr.co.pennyway.api.apis.users.service.DeviceTokenRegisterService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.util.AssertionErrors.*; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenRegisterService.class, UserService.class, DeviceTokenService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class DeviceTokenRegisterServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private DeviceTokenService deviceTokenService; + + @Autowired + private DeviceTokenRegisterService deviceTokenRegisterService; + + @MockBean + private JPAQueryFactory queryFactory; + + private User requestUser; + + @BeforeEach + void setUp() { + requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser()); + } + + @Test + @Transactional + @DisplayName("[1] token 등록 요청이 들어왔을 때, 새로운 디바이스 토큰을 등록한다.") + void registerNewDevice() { + // given + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken()); + assertEquals("디바이스 ID가 일치해야 한다.", response.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[2] token에 대한 활성화 디바이스 토큰이 이미 존재하는 경우 기존 데이터를 반환한다.") + void registerNewDeviceWhenDeviceIsAlreadyExists() { + // given + DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(originDeviceToken); + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), request.token()).ifPresentOrElse( + device -> { + assertEquals("요청한 디바이스 토큰과 동일해야 한다.", response.getToken(), device.getToken()); + assertEquals("디바이스 ID가 일치해야 한다.", originDeviceToken.getId(), device.getId()); + assertTrue("디바이스가 사용자 ID와 연결되어 있어야 한다.", device.getUser().getId().equals(requestUser.getId())); + assertTrue("디바이스가 활성화 상태여야 한다.", device.getActivated()); + System.out.println("device = " + device); + }, + () -> fail("신규 디바이스가 등록되어 있어야 한다.") + ); + } + + @Test + @Transactional + @DisplayName("[3] token 등록 요청이 들어왔을 때, 활성화되지 않은 디바이스 토큰이 존재하는 경우 토큰을 활성화 상태로 변경한다.") + void registerNewDeviceWhenDeviceIsNotActivated() { + // given + DeviceToken originDeviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + originDeviceToken.deactivate(); + deviceTokenService.createDevice(originDeviceToken); + DeviceTokenDto.RegisterReq request = DeviceTokenFixture.INIT.toRegisterReq(); + + // when + DeviceToken response = deviceTokenRegisterService.execute(requestUser.getId(), request.token()); + + // then + assertTrue("디바이스가 활성화 상태여야 한다.", response.getActivated()); + } +} diff --git a/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java new file mode 100644 index 000000000..07f02b0fb --- /dev/null +++ b/pennyway-app-external-api/src/test/java/kr/co/pennyway/api/apis/users/usecase/DeviceTokenUnregisterServiceTest.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.api.apis.users.usecase; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.api.apis.users.service.DeviceTokenUnregisterService; +import kr.co.pennyway.api.config.ExternalApiDBTestConfig; +import kr.co.pennyway.api.config.fixture.DeviceTokenFixture; +import kr.co.pennyway.api.config.fixture.UserFixture; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorCode; +import kr.co.pennyway.domain.domains.device.exception.DeviceTokenErrorException; +import kr.co.pennyway.domain.domains.device.service.DeviceTokenService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertFalse; + +@ExtendWith(MockitoExtension.class) +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, DeviceTokenUnregisterService.class, UserService.class, DeviceTokenService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +public class DeviceTokenUnregisterServiceTest extends ExternalApiDBTestConfig { + @Autowired + private UserService userService; + + @Autowired + private DeviceTokenService deviceTokenService; + + @Autowired + private DeviceTokenUnregisterService deviceTokenUnregisterService; + + @MockBean + private JPAQueryFactory queryFactory; + + private User requestUser; + + @BeforeEach + void setUp() { + requestUser = userService.createUser(UserFixture.GENERAL_USER.toUser()); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 origin token에 매칭되는 활성 디바이스가 존재하는 경우 디바이스를 비활성화한다.") + void unregisterDevice() { + // given + DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(deviceToken); + + // when + deviceTokenUnregisterService.execute(requestUser.getId(), deviceToken.getToken()); + + // then + DeviceToken deletedDevice = deviceTokenService.readDeviceByUserIdAndToken(requestUser.getId(), deviceToken.getToken()).get(); + assertFalse("디바이스가 비활성화 되어있어야 한다.", deletedDevice.isActivated()); + } + + @Test + @Transactional + @DisplayName("사용자 ID와 token에 매칭되는 디바이스가 존재하지 않는 경우 NOT_FOUND_DEVICE 에러를 반환한다.") + void unregisterDeviceWhenDeviceIsNotExists() { + // given + DeviceToken deviceToken = DeviceTokenFixture.INIT.toDevice(requestUser); + deviceTokenService.createDevice(deviceToken); + + // when - then + DeviceTokenErrorException ex = assertThrows(DeviceTokenErrorException.class, () -> deviceTokenUnregisterService.execute(requestUser.getId(), "notExistsToken")); + assertEquals("디바이스 토큰이 존재하지 않으면 Not Found를 반환한다.", DeviceTokenErrorCode.NOT_FOUND_DEVICE, ex.getBaseErrorCode()); + } +} diff --git a/pennyway-batch/Dockerfile b/pennyway-batch/Dockerfile new file mode 100644 index 000000000..c9bf20fb6 --- /dev/null +++ b/pennyway-batch/Dockerfile @@ -0,0 +1,8 @@ +FROM openjdk:17 +ARG JAR_FILE=./build/libs/*.jar +COPY ${JAR_FILE} app.jar + +ARG PROFILE=dev +ENV PROFILE=${PROFILE} + +ENTRYPOINT ["java","-jar","/app.jar","--spring.profiles.active=${PROFILE}","-Djava.security.egd=file:/dev/./urandom","-Duser.timezone=Asia/Seoul"] \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java new file mode 100644 index 000000000..8144c4cb0 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/DomainPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain; + +public interface DomainPackageLocation { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java new file mode 100644 index 000000000..11783e25a --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisCacheManager.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisCacheManager") +public @interface DomainRedisCacheManager { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java new file mode 100644 index 000000000..12c232a3b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisConnectionFactory.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("redisCacheConnectionFactory") +public @interface DomainRedisConnectionFactory { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java new file mode 100644 index 000000000..5e8a4edf3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/annotation/DomainRedisTemplate.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.annotation; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.*; + +@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, + ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Qualifier("domainRedisTemplate") +public @interface DomainRedisTemplate { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java new file mode 100644 index 000000000..05bdd3e42 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransaction.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.aop; + +import org.aspectj.lang.ProceedingJoinPoint; + +public interface CallTransaction { + Object proceed(ProceedingJoinPoint joinPoint) throws Throwable; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java new file mode 100644 index 000000000..77c7ed50b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/CallTransactionFactory.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CallTransactionFactory { + private final RedissonCallNewTransaction redissonCallNewTransaction; + private final RedissonCallSameTransaction redissonCallSameTransaction; + + public CallTransaction getCallTransaction(boolean isNewTransaction) { + return isNewTransaction ? redissonCallNewTransaction : redissonCallSameTransaction; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java new file mode 100644 index 000000000..a3b76c745 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/DistributedLockAspect.java @@ -0,0 +1,56 @@ +package kr.co.pennyway.domain.common.aop; + +import kr.co.pennyway.domain.common.redisson.DistributedLock; +import kr.co.pennyway.domain.common.util.CustomSpringELParser; +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 java.lang.reflect.Method; + +/** + * {@link DistributedLock} 어노테이션을 사용한 메소드에 대한 분산 락 처리를 위한 AOP + */ +@Slf4j +@Aspect +@RequiredArgsConstructor +public class DistributedLockAspect { + private static final String REDISSON_LOCK_PREFIX = "LOCK:"; + + private final RedissonClient redissonClient; + private final CallTransactionFactory callTransactionFactory; + + @Around("@annotation(kr.co.pennyway.domain.common.redisson.DistributedLock)") + public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key()); + RLock rLock = redissonClient.getLock(key); + + try { + boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit()); + if (!available) { + return false; + } + log.info("{} : Redisson Lock 진입 : {} {}", Thread.currentThread().getId(), method.getName(), key); + + return callTransactionFactory.getCallTransaction(distributedLock.needNewTransaction()).proceed(joinPoint); + } catch (InterruptedException e) { + throw new InterruptedException("Failed to acquire lock: " + key); + } finally { + try { + log.info("{} : Redisson Lock 해제 : {} {}", Thread.currentThread().getId(), method.getName(), key); + rLock.unlock(); + } catch (IllegalMonitorStateException ignored) { + log.error("Redisson lock is already unlocked: {} {}", method.getName(), key); + } + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java new file mode 100644 index 000000000..ba28b682e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallNewTransaction.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class RedissonCallNewTransaction implements CallTransaction { + /** + * 다른 트랜잭션이 실행 중인 경우에도 새로운 트랜잭션을 생성하여 이 메서드를 실행한다. + * 동시성 환경에서 데이터 정합성을 보장하기 위해 트랜잭션 커밋 이후 락이 해제된다. + */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java new file mode 100644 index 000000000..af6148eea --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/aop/RedissonCallSameTransaction.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.common.aop; + +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +public class RedissonCallSameTransaction implements CallTransaction { + /** + * 기존 트랜잭션 내에서 이 메서드를 실행하며, 새로운 트랜잭션을 생성하지 않는다. + * 트랜잭션이 활성화되어 있지 않으면 예외를 발생시킨다. + */ + @Override + @Transactional(propagation = Propagation.MANDATORY, timeout = 2) + public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java new file mode 100644 index 000000000..09cafe044 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AbstractLegacyEnumAttributeConverter.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.AttributeConverter; +import kr.co.pennyway.domain.common.util.LegacyEnumValueConvertUtil; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public class AbstractLegacyEnumAttributeConverter & LegacyCommonType> implements AttributeConverter { + /** + * 대상 Enum 클래스 {@link Class} 객체 + */ + private final Class targetEnumClass; + + /** + * nullable = false면, 변환할 값이 null로 들어왔을 때 예외를 발생시킨다.
+ * nullable = true면, 변환할 값이 null로 들어왔을 때 예외 없이 실행하며,
+ * legacy code로 변환 시엔 빈 문자열("")로 변환한다. + */ + private final boolean nullable; + + /** + * nullable = false일 때 출력할 오류 메시지에서 enum에 대한 설명을 위해 Enum의 설명적 이름을 받는다. + */ + private final String enumName; + + public AbstractLegacyEnumAttributeConverter(Class targetEnumClass, boolean nullable, String enumName) { + this.targetEnumClass = targetEnumClass; + this.nullable = nullable; + this.enumName = enumName; + } + + @Override + public String convertToDatabaseColumn(E attribute) { + if (!nullable && attribute == null) { + throw new IllegalArgumentException(String.format("%s을(를) null로 변환할 수 없습니다.", enumName)); + } + return LegacyEnumValueConvertUtil.toLegacyCode(attribute); + } + + @Override + public E convertToEntityAttribute(String dbData) { + if (!nullable && !StringUtils.hasText(dbData)) { + throw new IllegalArgumentException(String.format("%s(이)가 DB에 null 혹은 Empty로(%s) 저장되어 있습니다.", enumName, dbData)); + } + return LegacyEnumValueConvertUtil.ofLegacyCode(targetEnumClass, dbData); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java new file mode 100644 index 000000000..6c995aa96 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/AnnouncementConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +@Converter +public class AnnouncementConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "공지 타입"; + + public AnnouncementConverter() { + super(Announcement.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java new file mode 100644 index 000000000..3b251147e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/LegacyCommonType.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.converter; + +public interface LegacyCommonType { + /** + * Legacy Super System 공통 코드를 반환한다. + * + * @return String 공통 코드 + */ + String getCode(); +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java new file mode 100644 index 000000000..0653382d9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/NoticeTypeConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; + +@Converter +public class NoticeTypeConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "알림 타입"; + + public NoticeTypeConverter() { + super(NoticeType.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java new file mode 100644 index 000000000..cf2f42639 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProfileVisibilityConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; + +@Converter +public class ProfileVisibilityConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "프로필 공개 범위"; + + public ProfileVisibilityConverter() { + super(ProfileVisibility.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java new file mode 100644 index 000000000..1ef15c1c6 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/ProviderConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.oauth.type.Provider; + +@Converter +public class ProviderConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "제공자"; + + public ProviderConverter() { + super(Provider.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java new file mode 100644 index 000000000..d40a102f3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/QuestionCategoryConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.question.domain.QuestionCategory; + +@Converter +public class QuestionCategoryConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "문의 카테고리"; + + public QuestionCategoryConverter() { + super(QuestionCategory.class, false, ENUM_NAME); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java new file mode 100644 index 000000000..ee6f5da55 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/RoleConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.user.type.Role; + +@Converter +public class RoleConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "유저 권한"; + + public RoleConverter() { + super(Role.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java new file mode 100644 index 000000000..b9e567b88 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/converter/SpendingCategoryConverter.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.converter; + +import jakarta.persistence.Converter; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; + +@Converter +public class SpendingCategoryConverter extends AbstractLegacyEnumAttributeConverter { + private static final String ENUM_NAME = "지출 카테고리"; + + public SpendingCategoryConverter() { + super(SpendingCategory.class, false, ENUM_NAME); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java new file mode 100644 index 000000000..4867a3cf3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/EnablePennywayDomainConfig.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.domain.common.importer; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(PennywayDomainConfigImportSelector.class) +public @interface EnablePennywayDomainConfig { + PennywayDomainConfigGroup[] value(); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java new file mode 100644 index 000000000..0c06696a0 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfig.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.common.importer; + +/** + * Pennyway Domain의 Configurations를 나타내는 Marker Interface + */ +public interface PennywayDomainConfig { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java new file mode 100644 index 000000000..391e6483b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigGroup.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.domain.config.RedissonConfig; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PennywayDomainConfigGroup { + REDISSON(RedissonConfig.class); + + private final Class configClass; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java new file mode 100644 index 000000000..01add00bf --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/importer/PennywayDomainConfigImportSelector.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.importer; + +import kr.co.pennyway.common.util.MapUtils; +import org.springframework.context.annotation.DeferredImportSelector; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.lang.NonNull; + +import java.util.Arrays; +import java.util.Map; + +public class PennywayDomainConfigImportSelector implements DeferredImportSelector { + @NonNull + @Override + public String[] selectImports(@NonNull AnnotationMetadata metadata) { + return Arrays.stream(getGroups(metadata)) + .map(v -> v.getConfigClass().getName()) + .toArray(String[]::new); + } + + private PennywayDomainConfigGroup[] getGroups(AnnotationMetadata metadata) { + Map attributes = metadata.getAnnotationAttributes(EnablePennywayDomainConfig.class.getName()); + return (PennywayDomainConfigGroup[]) MapUtils.getObject(attributes, "value", new PennywayDomainConfigGroup[]{}); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java new file mode 100644 index 000000000..3ae674cca --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/model/DateAuditable.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.common.model; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class DateAuditable { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java new file mode 100644 index 000000000..5b017dbfa --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/RedisPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain.common.redis; + +public interface RedisPackageLocation { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java new file mode 100644 index 000000000..84dc4cd93 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenToken.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@RedisHash("forbiddenToken") +public class ForbiddenToken { + @Id + private final String accessToken; + private final Long userId; + @TimeToLive + private final long ttl; + + @Builder + private ForbiddenToken(String accessToken, Long userId, long ttl) { + this.accessToken = accessToken; + this.userId = userId; + this.ttl = ttl; + } + + public static ForbiddenToken of(String accessToken, Long userId, long ttl) { + return new ForbiddenToken(accessToken, userId, ttl); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof ForbiddenToken that)) return false; + return accessToken.equals(that.accessToken) && userId.equals(that.userId); + } + + @Override + public int hashCode() { + int result = accessToken.hashCode(); + result = ((1 << 5) - 1) * result + userId.hashCode(); + return result; + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java new file mode 100644 index 000000000..83db477af --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import org.springframework.data.repository.CrudRepository; + +public interface ForbiddenTokenRepository extends CrudRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java new file mode 100644 index 000000000..12ea5d41b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/forbidden/ForbiddenTokenService.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.common.redis.forbidden; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor(access = AccessLevel.PROTECTED) +@DomainService +public class ForbiddenTokenService { + private final ForbiddenTokenRepository forbiddenTokenRepository; + + /** + * 토큰을 블랙 리스트에 등록합니다. + * + * @param accessToken String : 블랙 리스트에 등록할 액세스 토큰 + * @param userId Long : 블랙 리스트에 등록할 유저 아이디 + * @param expiresAt LocalDateTime : 블랙 리스트에 등록할 토큰의 만료 시간 (등록할 access token의 만료시간을 추출한 값) + */ + public void createForbiddenToken(String accessToken, Long userId, LocalDateTime expiresAt) { + final LocalDateTime now = LocalDateTime.now(); + final long timeToLive = Duration.between(now, expiresAt).toSeconds(); + + log.info("forbidden token ttl : {}", timeToLive); + + ForbiddenToken forbiddenToken = ForbiddenToken.of(accessToken, userId, timeToLive); + forbiddenTokenRepository.save(forbiddenToken); + log.info("forbidden token registered. about User : {}", forbiddenToken.getUserId()); + } + + /** + * 토큰이 블랙 리스트에 등록되어 있는지 확인합니다. + * + * @return : 블랙 리스트에 등록되어 있으면 true, 아니면 false + */ + public boolean isForbidden(String accessToken) { + return forbiddenTokenRepository.existsById(accessToken); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java new file mode 100644 index 000000000..6be3174cf --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeKeyType.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PhoneCodeKeyType { + SIGN_UP("signUp"), + OAUTH_SIGN_UP_KAKAO("oauthSignUp:kakao"), + OAUTH_SIGN_UP_GOOGLE("oauthSignUp:google"), + OAUTH_SIGN_UP_APPLE("oauthSignUp:apple"), + FIND_USERNAME("username"), + FIND_PASSWORD("password"), + PHONE("phone"); + + private final String prefix; + + public static PhoneCodeKeyType getOauthSignUpTypeByProvider(Provider provider) { + return switch (provider) { + case KAKAO -> OAUTH_SIGN_UP_KAKAO; + case GOOGLE -> OAUTH_SIGN_UP_GOOGLE; + case APPLE -> OAUTH_SIGN_UP_APPLE; + }; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java new file mode 100644 index 000000000..68ee158f9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeRepository.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Objects; + +@Repository +public class PhoneCodeRepository { + private final RedisTemplate redisTemplate; + + public PhoneCodeRepository(@DomainRedisTemplate RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public LocalDateTime save(String phone, String code, PhoneCodeKeyType codeType) { + LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(5); + redisTemplate.opsForValue().set(codeType.getPrefix() + ":" + phone, code, Duration.between(LocalDateTime.now(), expiresAt)); + return expiresAt; + } + + public String findCodeByPhone(String phone, PhoneCodeKeyType codeType) throws NullPointerException { + return Objects.requireNonNull(redisTemplate.opsForValue().get(codeType.getPrefix() + ":" + phone)).toString(); + } + + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + redisTemplate.expire(codeType.getPrefix() + ":" + phone, Duration.ofMinutes(5)); + } + + public void delete(String phone, PhoneCodeKeyType codeType) { + redisTemplate.opsForValue().getAndDelete(codeType.getPrefix() + ":" + phone); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java new file mode 100644 index 000000000..d5a26e93a --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/phone/PhoneCodeService.java @@ -0,0 +1,65 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class PhoneCodeService { + private final PhoneCodeRepository phoneCodeRepository; + + /** + * 휴대폰 번호와 코드를 저장한다. (5분간 유효) + *
+ * redis에 저장되는 key는 codeType:phone, value는 code이다. + * + * @param phone String : 휴대폰 번호 + * @param code String : 6자리 정수 코드 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + * @return LocalDateTime : 만료 시간 + */ + public LocalDateTime create(String phone, String code, PhoneCodeKeyType codeType) { + return phoneCodeRepository.save(phone, code, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 조회한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + * @return String : 6자리 정수 코드 + * @throws IllegalArgumentException : 코드가 없을 경우 + */ + public String readByPhone(String phone, PhoneCodeKeyType codeType) throws IllegalArgumentException { + try { + return phoneCodeRepository.findCodeByPhone(phone, codeType); + } catch (NullPointerException e) { + log.error("{}:{}에 해당하는 키가 존재하지 않습니다.", phone, codeType); + throw new IllegalArgumentException(e); + } + } + + /** + * 휴대폰 번호로 저장된 데이터의 ttl을 5분으로 연장(롤백)한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + */ + public void extendTimeToLeave(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.extendTimeToLeave(phone, codeType); + } + + /** + * 휴대폰 번호로 저장된 코드를 삭제한다. + * + * @param phone String : 휴대폰 번호 + * @param codeType {@link PhoneCodeKeyType} : 코드 타입 + */ + public void delete(String phone, PhoneCodeKeyType codeType) { + phoneCodeRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java new file mode 100644 index 000000000..db38f7a4c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshToken.java @@ -0,0 +1,38 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; + +@RedisHash("refreshToken") +@Getter +@ToString(of = {"userId", "token", "ttl"}) +@EqualsAndHashCode(of = {"userId", "token"}) +public class RefreshToken { + @Id + private final Long userId; + private final long ttl; + private String token; + + @Builder + private RefreshToken(String token, Long userId, long ttl) { + this.token = token; + this.userId = userId; + this.ttl = ttl; + } + + public static RefreshToken of(Long userId, String token, long ttl) { + return RefreshToken.builder() + .userId(userId) + .token(token) + .ttl(ttl) + .build(); + } + + protected void rotation(String token) { + this.token = token; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java new file mode 100644 index 000000000..35467af12 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java new file mode 100644 index 000000000..98158f8b7 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenService.java @@ -0,0 +1,31 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +public interface RefreshTokenService { + /** + * refresh token을 redis에 저장한다. + * + * @param refreshToken : {@link RefreshToken} + */ + void save(RefreshToken refreshToken); + + /** + * 사용자가 보낸 refresh token으로 기존 refresh token과 비교 검증 후, 새로운 refresh token으로 저장한다. + * + * @param userId : 토큰 주인 pk + * @param oldRefreshToken : 사용자가 보낸 refresh token + * @param newRefreshToken : 교체할 refresh token + * @return {@link RefreshToken} + * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException; + + /** + * access token 으로 refresh token을 찾아서 제거 (로그아웃) + * + * @param userId : 토큰 주인 pk + * @param refreshToken : 검증용 refresh token + * @throws IllegalArgumentException : userId에 해당하는 refresh token이 없을 경우 + */ + void delete(Long userId, String refreshToken) throws IllegalArgumentException; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java new file mode 100644 index 000000000..194ed90b9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceImpl.java @@ -0,0 +1,69 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import kr.co.pennyway.common.annotation.DomainService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class RefreshTokenServiceImpl implements RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + @Override + public void save(RefreshToken refreshToken) { + refreshTokenRepository.save(refreshToken); + log.debug("리프레시 토큰 저장 : {}", refreshToken); + } + + @Override + public RefreshToken refresh(Long userId, String oldRefreshToken, String newRefreshToken) throws IllegalArgumentException, IllegalStateException { + RefreshToken refreshToken = findOrElseThrow(userId); + + validateToken(oldRefreshToken, refreshToken); + + refreshToken.rotation(newRefreshToken); + refreshTokenRepository.save(refreshToken); + + log.info("사용자 {}의 리프레시 토큰 갱신", userId); + return refreshToken; + } + + @Override + public void delete(Long userId, String refreshToken) throws IllegalArgumentException { + RefreshToken token = findOrElseThrow(userId); + refreshTokenRepository.delete(token); + log.info("사용자 {}의 리프레시 토큰 삭제", userId); + } + + private RefreshToken findOrElseThrow(Long userId) { + return refreshTokenRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("refresh token not found")); + } + + /** + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @throws IllegalStateException : 요청한 토큰과 저장된 토큰이 다르다면 토큰이 탈취되었다고 판단하여 값 삭제 + */ + private void validateToken(String requestRefreshToken, RefreshToken expectedRefreshToken) throws IllegalStateException { + if (isTakenAway(requestRefreshToken, expectedRefreshToken.getToken())) { + log.warn("리프레시 토큰 불일치(탈취). expected : {}, actual : {}", requestRefreshToken, expectedRefreshToken.getToken()); + refreshTokenRepository.delete(expectedRefreshToken); + log.info("사용자 {}의 리프레시 토큰 삭제", expectedRefreshToken.getUserId()); + + throw new IllegalStateException("refresh token mismatched"); + } + } + + /** + * 토큰 탈취 여부 확인 + * + * @param requestRefreshToken String : 사용자가 보낸 refresh token + * @param expectedRefreshToken String : Redis에 저장된 refresh token + * @return boolean : 탈취되었다면 true, 아니면 false + */ + private boolean isTakenAway(String requestRefreshToken, String expectedRefreshToken) { + return !requestRefreshToken.equals(expectedRefreshToken); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java new file mode 100644 index 000000000..b0bd55832 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLock.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.common.redisson; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + /** + * Lock 이름 + */ + String key(); + + /** + * Lock 유지 시간 (초) + */ + TimeUnit timeUnit() default TimeUnit.SECONDS; + + /** + * Lock 유지 시간 (DEFAULT: 10초) + * LOCK 획득을 위해 waitTime만큼 대기한다. + */ + long waitTime() default 10L; + + /** + * Lock 임대 시간 (DEFAULT: 5초) + * LOCK 획득 이후 leaseTime이 지나면 LOCK을 해제한다. + */ + long leaseTime() default 5L; + + /** + * 동일한 트랜잭션에서 Lock을 획득할지 여부 (DEFAULT: true)
+ * - true : Propagation.REQUIRES_NEW 전파 방식을 사용하여 새로운 트랜잭션에서 Lock을 획득한다.
+ * - false : Propagation.MANDATORY 전파 방식을 사용하여 동일한 트랜잭션에서 Lock을 획득한다. + */ + boolean needNewTransaction() default true; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java new file mode 100644 index 000000000..ba1b14788 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/redisson/DistributedLockPrefix.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.common.redisson; + +/** + * 분산 락을 위한 prefix + */ +public class DistributedLockPrefix { + public static final String TARGET_AMOUNT_USER = "TargetAmount_User_"; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java new file mode 100644 index 000000000..3198523a9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepository.java @@ -0,0 +1,10 @@ +package kr.co.pennyway.domain.common.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.repository.NoRepositoryBean; + +import java.io.Serializable; + +@NoRepositoryBean +public interface ExtendedRepository extends JpaRepository, QueryDslSearchRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java new file mode 100644 index 000000000..dd6574cb8 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/ExtendedRepositoryFactory.java @@ -0,0 +1,53 @@ +package kr.co.pennyway.domain.common.repository; + +import jakarta.persistence.EntityManager; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryComposition; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; +import org.springframework.lang.NonNull; + +public class ExtendedRepositoryFactory, E, ID> extends JpaRepositoryFactoryBean { + /** + * Creates a new {@link JpaRepositoryFactoryBean} for the given repository interface. + * + * @param repositoryInterface must not be {@literal null}. + */ + public ExtendedRepositoryFactory(Class repositoryInterface) { + super(repositoryInterface); + } + + @Override + @NonNull + protected RepositoryFactorySupport createRepositoryFactory(@NonNull EntityManager em) { + return new InnerRepositoryFactory(em); + } + + private static class InnerRepositoryFactory extends JpaRepositoryFactory { + private final EntityManager em; + + public InnerRepositoryFactory(EntityManager em) { + super(em); + this.em = em; + } + + @Override + @NonNull + protected RepositoryComposition.RepositoryFragments getRepositoryFragments(@NonNull RepositoryMetadata metadata) { + RepositoryComposition.RepositoryFragments fragments = super.getRepositoryFragments(metadata); + + if (QueryDslSearchRepository.class.isAssignableFrom(metadata.getRepositoryInterface())) { + var implExtendedJpa = super.instantiateClass( + QueryDslSearchRepositoryImpl.class, + this.getEntityInformation(metadata.getDomainType()), + this.em + ); + fragments = fragments.append(RepositoryComposition.RepositoryFragments.just(implExtendedJpa)); + } + + return fragments; + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java new file mode 100644 index 000000000..5a525ea09 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepository.java @@ -0,0 +1,183 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * QueryDsl을 이용한 검색 조건을 처리하는 기본적인 메서드를 선언한 인터페이스 + * + * @author YANG JAESEO + * @version 1.1 + */ +public interface QueryDslSearchRepository { + + /** + * 검색 조건에 해당하는 도메인 리스트를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *

+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, sort);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + List findList(Predicate predicate, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 도메인 페이지를 조회하는 메서드 + * + * @param predicate : 검색 조건 + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private Entity select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findList(predicate, queryHandler, pageable);
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ + Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable); + + /** + * 검색 조건에 해당하는 DTO 리스트를 조회하는 메서드
+ * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param sort : 정렬 조건 + * + * // @formatter:off + *
+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Sort sort = Sort.by(Sort.Order.desc("entity.id"));
+     *
+     *          return searchRepository.findList(predicate, EntityDto.class, this.buildBindings(), queryHandler, sort);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *
+     *          return bindings;
+     *      }
+     * }
+     * }
+     * 
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort); + + /** + * 검색 조건에 해당하는 DTO 페이지를 조회하는 메서드 + * bindings가 {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다.
+ * 만약 bindings가 삽입 순서를 보장하지 않을 경우, Dto는 기본 생성자와 setter 메서드를 제공해야 하며, 모든 필드의 final 키워드를 제거해야 한다. + * + * @param predicate : 검색 조건 + * @param type : 조회할 도메인(혹은 DTO) 타입 + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드. {@link LinkedHashMap}을 구현체로 사용하는 경우 Dto 생성자 파라미터 순서에 맞게 삽입해야 한다. + * @param queryHandler : 검색 조건에 추가적으로 적용할 조건 + * @param pageable : 페이지 정보 + * + * // @formatter:off + *

+     * {@code
+     * @Component
+     * class SearchService {
+     *      private final QEntity entity = QEntity.entity;
+     *      private final QEntityChild entityChild = QEntityChild.entityChild;
+     *
+     *      private EntityDto select() {
+     *          Predicate predicate = new BooleanBuilder();
+     *          predicate.and(entity.id.eq(1L));
+     *          QueryHandler queryHandler = query -> query.leftJoin(entityChild).on(entity.id.eq(entityChild.entity.id));
+     *          Pageable pageable = PageRequest.of(0, 10, Sort.by(Sort.Order.desc("entity.id")));
+     *
+     *          return searchRepository.findPage(predicate, EntityDto.class, this.buildBindings(), queryHandler, pageable);
+     *      }
+     *
+     *      private Map> buildBindings() {
+     *          Map> bindings = new HashMap<>();
+     *          bindings.put("id", entity.id);
+     *          bindings.put("name", entity.name);
+     *          return bindings;
+     *      }
+     *  }
+     *  }
+     *  
+ * // @formatter:on + * + * @see Predicate + * @see QueryHandler + * @see org.springframework.data.domain.PageRequest + */ +

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java new file mode 100644 index 000000000..2c751efeb --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryDslSearchRepositoryImpl.java @@ -0,0 +1,136 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.common.util.QueryDslUtil; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.querydsl.QSort; +import org.springframework.data.querydsl.SimpleEntityPathResolver; +import org.springframework.util.Assert; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public class QueryDslSearchRepositoryImpl implements QueryDslSearchRepository { + private final EntityManager em; + private final JPAQueryFactory queryFactory; + private final EntityPath path; + + public QueryDslSearchRepositoryImpl(JpaEntityInformation entityInformation, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = SimpleEntityPathResolver.INSTANCE.createPath(entityInformation.getJavaType()); + } + + public QueryDslSearchRepositoryImpl(Class type, EntityManager entityManager) { + this.em = entityManager; + this.queryFactory = new JPAQueryFactory(entityManager); + this.path = new EntityPathBase<>(type, "entity"); + } + + @Override + public List findList(Predicate predicate, QueryHandler queryHandler, Sort sort) { + return this.buildWithoutSelect(predicate, null, queryHandler, sort).select(path).fetch(); + } + + @Override + public Page findPage(Predicate predicate, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, null, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + return new PageImpl<>(query.select(path).fetch(), pageable, totalSize); + } + + @Override + public

List

selectList(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Sort sort) { + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, sort); + + if (bindings instanceof LinkedHashMap) { + return query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(); + } + + return query.select(Projections.bean(type, bindings)).fetch(); + } + + @Override + public

Page

selectPage(Predicate predicate, Class

type, Map> bindings, QueryHandler queryHandler, Pageable pageable) { + Assert.notNull(pageable, "pageable must not be null!"); + + JPAQuery query = this.buildWithoutSelect(predicate, bindings, queryHandler, pageable.getSort()).select(path); + + int totalSize = query.fetch().size(); + query = query.offset(pageable.getOffset()).limit(pageable.getPageSize()); + + if (bindings instanceof LinkedHashMap) { + return new PageImpl<>(query.select(Projections.constructor(type, bindings.values().toArray(new Expression[0]))).fetch(), pageable, totalSize); + } + + return new PageImpl<>(query.select(Projections.bean(type, bindings)).fetch(), pageable, totalSize); + } + + /** + * 파라미터를 기반으로 Querydsl의 JPAQuery를 생성하는 메서드 + */ + private JPAQuery buildWithoutSelect(Predicate predicate, Map> bindings, QueryHandler queryHandler, Sort sort) { + JPAQuery query = queryFactory.from(path); + + applyPredicate(predicate, query); + applyQueryHandler(queryHandler, query); + applySort(query, sort, bindings); + + return query; + } + + /** + * Querydsl의 JPAQuery에 Predicate를 적용하는 메서드
+ * Predicate가 null이 아닐 경우에만 적용 + */ + private void applyPredicate(Predicate predicate, JPAQuery query) { + if (predicate != null) query.where(predicate); + } + + /** + * Querydsl의 JPAQuery에 QueryHandler를 적용하는 메서드
+ * QueryHandler가 null이 아닐 경우에만 적용 + */ + private void applyQueryHandler(QueryHandler queryHandler, JPAQuery query) { + if (queryHandler != null) queryHandler.apply(query); + } + + /** + * Querydsl의 JPAQuery에 Sort를 적용하는 메서드
+ * Sort가 null이 아닐 경우에만 적용
+ * Sort가 QSort일 경우에는 OrderSpecifier를 적용하고, 그 외의 경우에는 OrderSpecifier를 생성하여 적용 + */ + private void applySort(JPAQuery query, Sort sort, Map> bindings) { + if (sort != null) { + if (sort instanceof QSort qSort) { + query.orderBy(qSort.getOrderSpecifiers().toArray(new OrderSpecifier[0])); + } else { + applySortOrders(query, sort, bindings); + } + } + } + + private void applySortOrders(JPAQuery query, Sort sort, Map> bindings) { + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling queryDslNullHandling = QueryDslUtil.getQueryDslNullHandling(order); + + OrderSpecifier os = QueryDslUtil.getOrderSpecifier(order, bindings, queryDslNullHandling); + + query.orderBy(os); + } + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java new file mode 100644 index 000000000..93af7feee --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/repository/QueryHandler.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.common.repository; + +import com.querydsl.jpa.impl.JPAQuery; + +/** + * QueryDsl의 명시적 조인을 위한 함수형 인터페이스 + * + * @author YANG JAESEO + */ +@FunctionalInterface +public interface QueryHandler { + JPAQuery apply(JPAQuery query); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java new file mode 100644 index 000000000..e202b5ac5 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/CustomSpringELParser.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +/** + * Spring Expression Language (SpEL)을 사용한 커스텀 EL 파서 + */ +public class CustomSpringELParser { + /** + * SpEL을 사용하여 동적으로 값을 평가한다. + * + * @param parameterNames : 메서드 파라미터 이름 + * @param args : 메서드 파라미터 값 + * @param key : SpEL 표현식 + * @return : 평가된 값 + */ + public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { + ExpressionParser parser = new SpelExpressionParser(); + StandardEvaluationContext context = new StandardEvaluationContext(); + + // 메서드 파라미터 이름과 값을 SpEL 컨텍스트에 변수로 설정 + 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/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java new file mode 100644 index 000000000..d79b46c32 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/LegacyEnumValueConvertUtil.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.common.util; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.springframework.util.StringUtils; + +import java.util.EnumSet; + +/** + * {@link LegacyCommonType} enum을 String과 상호 변환하는 유틸리티 클래스 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LegacyEnumValueConvertUtil { + public static & LegacyCommonType> T ofLegacyCode(Class enumClass, String code) { + if (!StringUtils.hasText(code)) return null; + return EnumSet.allOf(enumClass).stream() + .filter(e -> e.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + String.format("enum=[%s], code=[%s]가 존재하지 않습니다.", enumClass.getName(), code))); + } + + public static & LegacyCommonType> String toLegacyCode(T enumValue) { + if (enumValue == null) return ""; + return enumValue.getCode(); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java new file mode 100644 index 000000000..5cc6b18f4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/QueryDslUtil.java @@ -0,0 +1,94 @@ +package kr.co.pennyway.domain.common.util; + +import com.querydsl.core.types.*; +import com.querydsl.core.types.dsl.Expressions; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * QueryDsl의 편의 기능을 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +@Slf4j +public class QueryDslUtil { + private static final Function castToQueryDsl = nullHandling -> switch (nullHandling) { + case NATIVE -> OrderSpecifier.NullHandling.Default; + case NULLS_FIRST -> OrderSpecifier.NullHandling.NullsFirst; + case NULLS_LAST -> OrderSpecifier.NullHandling.NullsLast; + }; + + /** + * Pageable의 sort를 QueryDsl의 OrderSpecifier로 변환하는 메서드 + * + * @param sort : {@link Sort} + */ + public static List> getOrderSpecifier(Sort sort) { + List> orders = new ArrayList<>(); + + for (Sort.Order order : sort) { + OrderSpecifier.NullHandling nullHandling = castToQueryDsl.apply(order.getNullHandling()); + orders.add(getOrderSpecifier(order, nullHandling)); + } + + return orders; + } + + /** + * Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 반환하는 메서드 + * + * @param order : {@link Sort.Order} + * @return {@link OrderSpecifier.NullHandling} + */ + public static OrderSpecifier.NullHandling getQueryDslNullHandling(Sort.Order order) { + return castToQueryDsl.apply(order.getNullHandling()); + } + + /** + * OrderSpecifier를 생성할 때, Sort.Order의 정보를 이용하여 OrderSpecifier.NullHandling을 적용하는 메서드 + * + * @param order : {@link Sort.Order} + * @param nullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, OrderSpecifier.NullHandling nullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), nullHandling); + } + + /** + * Expression이 Operation이고 Operator가 ALIAS일 경우, OrderSpecifier를 생성할 때, Expression을 StringPath로 변환하여 생성한다.
+ * 그 외의 경우에는 OrderSpecifier를 생성한다. + * + * @param order : {@link Sort.Order} + * @param bindings : 검색 조건에 해당하는 도메인(혹은 DTO)의 필드 정보. {@code binding}은 Map> 형태로 전달된다. + * @param queryDslNullHandling : {@link OrderSpecifier.NullHandling} + * @return {@link OrderSpecifier} + */ + public static OrderSpecifier getOrderSpecifier(Sort.Order order, Map> bindings, OrderSpecifier.NullHandling queryDslNullHandling) { + Order orderBy = order.isAscending() ? Order.ASC : Order.DESC; + + if (bindings != null && bindings.containsKey(order.getProperty())) { + Expression expression = bindings.get(order.getProperty()); + return createOrderSpecifier(orderBy, expression, queryDslNullHandling); + } else { + return createOrderSpecifier(orderBy, Expressions.stringPath(order.getProperty()), queryDslNullHandling); + } + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static OrderSpecifier createOrderSpecifier(Order orderBy, Expression expression, OrderSpecifier.NullHandling queryDslNullHandling) { + if (expression instanceof Operation && ((Operation) expression).getOperator() == Ops.ALIAS) { + return new OrderSpecifier<>(orderBy, Expressions.stringPath(((Operation) expression).getArg(1).toString()), queryDslNullHandling); + } else { + return new OrderSpecifier(orderBy, expression, queryDslNullHandling); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java new file mode 100644 index 000000000..7c5d3916e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/common/util/SliceUtil.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.common.util; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; + +/** + * Slice를 생성하는 메서드를 제공하는 유틸리티 클래스 + * + * @author YANG JAESEO + * @version 1.0 + */ +public class SliceUtil { + /** + * List로 받은 contents를 Slice로 변환한다. + * + * @param contents : 변환할 List + * @param pageable : Pageable + * @return Slice : 변환된 Slice. 단, contents.size()가 pageable.getPageSize()보다 작을 경우 hasNext는 true이며, Slice의 size는 contents.size() - 1이다. + */ + public static Slice toSlice(List contents, Pageable pageable) { + boolean hasNext = isContentSizeGreaterThanPageSize(contents, pageable); + return new SliceImpl<>(hasNext ? subListLastContent(contents, pageable) : contents, pageable, hasNext); + } + + private static boolean isContentSizeGreaterThanPageSize(List content, Pageable pageable) { + return pageable.isPaged() && content.size() > pageable.getPageSize(); + } + + private static List subListLastContent(List content, Pageable pageable) { + return content.subList(0, pageable.getPageSize()); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java new file mode 100644 index 000000000..c068ec90c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/JpaConfig.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.DomainPackageLocation; +import kr.co.pennyway.domain.common.repository.ExtendedRepositoryFactory; +import kr.co.pennyway.domain.domains.JpaPackageLocation; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EntityScan(basePackageClasses = DomainPackageLocation.class) +@EnableJpaRepositories(basePackageClasses = JpaPackageLocation.class, repositoryFactoryBeanClass = ExtendedRepositoryFactory.class) +public class JpaConfig { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java new file mode 100644 index 000000000..7b40ad85d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/QueryDslConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + @Primary + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java new file mode 100644 index 000000000..e485eb36f --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisConfig.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.common.annotation.DomainRedisCacheManager; +import kr.co.pennyway.domain.common.annotation.DomainRedisConnectionFactory; +import kr.co.pennyway.domain.common.annotation.DomainRedisTemplate; +import kr.co.pennyway.domain.common.redis.RedisPackageLocation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import java.time.Duration; + +@Configuration +@EnableRedisRepositories(basePackageClasses = RedisPackageLocation.class) +@EnableTransactionManagement +public class RedisConfig { + private final String host; + private final int port; + private final String password; + + public RedisConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + @DomainRedisConnectionFactory + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port); + config.setPassword(password); + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder().build(); + return new LettuceConnectionFactory(config, clientConfig); + } + + @Bean + @Primary + @DomainRedisTemplate + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + + template.setConnectionFactory(redisConnectionFactory()); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return template; + } + + @Bean + @DomainRedisCacheManager + public RedisCacheManager redisCacheManager(@DomainRedisConnectionFactory RedisConnectionFactory cf) { + RedisCacheConfiguration redisCacheConfiguration = + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer())) + .entryTtl(Duration.ofHours(1L)); + + return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(cf) + .cacheDefaults(redisCacheConfiguration) + .build(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java new file mode 100644 index 000000000..2a5f06660 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedisUnitTest.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@ComponentScan(basePackages = "kr.co.pennyway.domain.common.redis") +@EnableAutoConfiguration +@EnableRedisRepositories(basePackages = "kr.co.pennyway.domain.common.redis") +@Documented +public @interface RedisUnitTest { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java new file mode 100644 index 000000000..32e995f8e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/config/RedissonConfig.java @@ -0,0 +1,58 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.domain.common.aop.CallTransactionFactory; +import kr.co.pennyway.domain.common.aop.DistributedLockAspect; +import kr.co.pennyway.domain.common.aop.RedissonCallNewTransaction; +import kr.co.pennyway.domain.common.aop.RedissonCallSameTransaction; +import kr.co.pennyway.domain.common.importer.PennywayDomainConfig; +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; + +public class RedissonConfig implements PennywayDomainConfig { + private static final String REDISSON_HOST_PREFIX = "redis://"; + private final String host; + private final int port; + private final String password; + + public RedissonConfig( + @Value("${spring.data.redis.host}") String host, + @Value("${spring.data.redis.port}") int port, + @Value("${spring.data.redis.password}") String password + ) { + this.host = host; + this.port = port; + this.password = password; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer() + .setAddress(REDISSON_HOST_PREFIX + host + ":" + port) + .setPassword(password); + return Redisson.create(config); + } + + @Bean + public RedissonCallNewTransaction redissonCallNewTransaction() { + return new RedissonCallNewTransaction(); + } + + @Bean + public RedissonCallSameTransaction redissonCallSameTransaction() { + return new RedissonCallSameTransaction(); + } + + @Bean + public CallTransactionFactory callTransactionFactory(RedissonCallNewTransaction redissonCallNewTransaction, RedissonCallSameTransaction redissonCallSameTransaction) { + return new CallTransactionFactory(redissonCallNewTransaction, redissonCallSameTransaction); + } + + @Bean + public DistributedLockAspect distributedLockAspect(RedissonClient redissonClient, CallTransactionFactory callTransactionFactory) { + return new DistributedLockAspect(redissonClient, callTransactionFactory); + } +} \ No newline at end of file diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java new file mode 100644 index 000000000..c46f1eca0 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/JpaPackageLocation.java @@ -0,0 +1,4 @@ +package kr.co.pennyway.domain.domains; + +public interface JpaPackageLocation { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java new file mode 100644 index 000000000..b291ac360 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/domain/DeviceToken.java @@ -0,0 +1,84 @@ +package kr.co.pennyway.domain.domains.device.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "device_token") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeviceToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String token; + + @ColumnDefault("true") + private Boolean activated; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime lastSignedInAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private DeviceToken(String token, Boolean activated, User user) { + this.token = Objects.requireNonNull(token, "token은 null이 될 수 없습니다."); + this.activated = Objects.requireNonNull(activated, "activated는 null이 될 수 없습니다."); + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.lastSignedInAt = LocalDateTime.now(); + } + + public static DeviceToken of(String token, User user) { + return new DeviceToken(token, Boolean.TRUE, user); + } + + public Boolean isActivated() { + return activated; + } + + public void activate() { + this.activated = Boolean.TRUE; + } + + public void deactivate() { + this.activated = Boolean.FALSE; + } + + public void updateLastSignedInAt() { + this.lastSignedInAt = LocalDateTime.now(); + } + + /** + * 디바이스 토큰이 만료되었는지 확인한다. + * + * @return 토큰이 갱신된지 7일이 지났으면 true, 그렇지 않으면 false + */ + public boolean isExpired() { + LocalDateTime now = LocalDateTime.now(); + + return lastSignedInAt.plusDays(7).isBefore(now); + } + + @Override + public String toString() { + return "DeviceToken {" + + "id=" + id + + ", token='" + token + '\'' + + ", activated=" + activated + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java new file mode 100644 index 000000000..32d05abf9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorCode.java @@ -0,0 +1,27 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum DeviceTokenErrorCode implements BaseErrorCode { + /* 404 NOT_FOUND */ + NOT_FOUND_DEVICE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "디바이스를 찾을 수 없습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java new file mode 100644 index 000000000..2a83c00c1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/exception/DeviceTokenErrorException.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.device.exception; + +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class DeviceTokenErrorException extends GlobalErrorException { + private final DeviceTokenErrorCode deviceTokenErrorCode; + + public DeviceTokenErrorException(DeviceTokenErrorCode deviceTokenErrorCode) { + super(deviceTokenErrorCode); + this.deviceTokenErrorCode = deviceTokenErrorCode; + } + + public String getExplainError() { + return deviceTokenErrorCode.getExplainError(); + } + + public String getErrorCode() { + return deviceTokenErrorCode.name(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java new file mode 100644 index 000000000..4e2ace8b4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/repository/DeviceTokenRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.device.repository; + +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +public interface DeviceTokenRepository extends JpaRepository { + @Query("SELECT d FROM DeviceToken d WHERE d.user.id = :userId AND d.token = :token") + Optional findByUser_IdAndToken(Long userId, String token); + + List findAllByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE DeviceToken d SET d.activated = false WHERE d.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java new file mode 100644 index 000000000..21af5877b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/device/service/DeviceTokenService.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.device.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.device.domain.DeviceToken; +import kr.co.pennyway.domain.domains.device.repository.DeviceTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class DeviceTokenService { + private final DeviceTokenRepository deviceTokenRepository; + + @Transactional + public DeviceToken createDevice(DeviceToken deviceToken) { + return deviceTokenRepository.save(deviceToken); + } + + @Transactional + public Optional readDeviceByUserIdAndToken(Long userId, String token) { + return deviceTokenRepository.findByUser_IdAndToken(userId, token); + } + + @Transactional + public void deleteDevicesByUserIdInQuery(Long userId) { + deviceTokenRepository.deleteAllByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java new file mode 100644 index 000000000..49034994b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/domain/Notification.java @@ -0,0 +1,138 @@ +package kr.co.pennyway.domain.domains.notification.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.AnnouncementConverter; +import kr.co.pennyway.domain.common.converter.NoticeTypeConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "notification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Notification extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private LocalDateTime readAt; + @Convert(converter = NoticeTypeConverter.class) + private NoticeType type; + @Convert(converter = AnnouncementConverter.class) + private Announcement announcement; // 공지 종류 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "sender") + private User sender; + private String senderName; + + private Long toId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "receiver") + private User receiver; + private String receiverName; + + private Notification(NoticeType type, Announcement announcement, User sender, String senderName, Long toId, User receiver, String receiverName) { + this.type = Objects.requireNonNull(type); + this.announcement = Objects.requireNonNull(announcement); + this.sender = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(sender) : sender; + this.senderName = (!type.equals(NoticeType.ANNOUNCEMENT)) ? Objects.requireNonNull(senderName) : senderName; + this.toId = toId; + this.receiver = Objects.requireNonNull(receiver); + this.receiverName = Objects.requireNonNull(receiverName); + } + + @Override + public String toString() { + return "Notification{" + + "id=" + id + + ", readAt=" + readAt + + ", type=" + type + + ", announcement=" + announcement + + ", senderName='" + senderName + '\'' + + ", toId=" + toId + + ", receiverName='" + receiverName + '\'' + + '}'; + } + + /** + * 공지 제목을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 제목을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedTitle() { + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + + return formatAnnouncementTitle(); + } + + private String formatAnnouncementTitle() { + if (announcement.equals(Announcement.MONTHLY_TARGET_AMOUNT)) { + return announcement.createFormattedTitle(String.valueOf(getCreatedAt().getMonthValue())); + } + + return announcement.createFormattedTitle(receiverName); + } + + /** + * 공지 내용을 생성한다. + *
+ * 이 메서드는 내부적으로 알림 타입의 종류에 따라 공지 내용을 포맷팅한다. + * + * @apiNote 이 메서드는 {@link NoticeType#ANNOUNCEMENT} 타입에 대해서만 동작한다. 다른 타입의 알림을 포맷팅해야 하는 경우 해당 메서드를 확장해야 한다. + */ + public String createFormattedContent() { + if (!type.equals(NoticeType.ANNOUNCEMENT)) { + return ""; // TODO: 알림 종류가 신규로 추가될 때, 해당 로직을 구현해야 함. + } + + return announcement.createFormattedContent(receiverName); + } + + public static class Builder { + private final NoticeType type; + private final Announcement announcement; + private final User receiver; + private final String receiverName; + + private User sender; + private String senderName; + + private Long toId; + + public Builder(NoticeType type, Announcement announcement, User receiver) { + this.type = type; + this.announcement = announcement; + this.receiver = receiver; + this.receiverName = receiver.getName(); + } + + public Builder sender(User sender) { + this.sender = sender; + this.senderName = sender.getName(); + return this; + } + + public Builder toId(Long toId) { + this.toId = toId; + return this; + } + + public Notification build() { + return new Notification(type, announcement, sender, senderName, toId, receiver, receiverName); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java new file mode 100644 index 000000000..e85b146b2 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepository.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.domains.notification.type.Announcement; + +import java.util.List; + +public interface NotificationCustomRepository { + boolean existsUnreadNotification(Long userId); + + /** + * 사용자들에게 정기 지출 등록 알림을 저장한다. (발송이 아님) + * 만약 이미 전송하려는 데이터가 년-월-일에 해당하는 생성일을 가지고 있고, 그 알림의 announcement 타입까지 같다면 저장하지 않는다. + * + *

+     * {@code
+     * INSERT INTO notification(type, announcement, created_at, updated_at, receiver, receiver_name)
+     * SELECT ?, ?, NOW(), NOW(), u.id, u.name
+     * FROM user u
+     * WHERE u.id IN (?)
+     * AND NOT EXISTS (
+     * 	SELECT n.receiver
+     * 	FROM notification n
+     * 	WHERE n.receiver = u.id
+     *     AND n.created_at >= CURDATE()
+     *     AND n.created_at < CURDATE() + INTERVAL 1 DAY
+     * 	AND n.type = '0'
+     * 	AND n.announcement = 1
+     * );
+     * }
+     * 
+ * + * @param userIds : 등록할 사용자 아이디 목록 + * @param announcement : 공지 타입 {@link Announcement} + */ + void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java new file mode 100644 index 000000000..7e20aa278 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationCustomRepositoryImpl.java @@ -0,0 +1,93 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.BatchPreparedStatementSetter; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class NotificationCustomRepositoryImpl implements NotificationCustomRepository { + private final JPAQueryFactory queryFactory; + private final JdbcTemplate jdbcTemplate; + + private final QNotification notification = QNotification.notification; + + private final int BATCH_SIZE = 1000; + + @Override + public boolean existsUnreadNotification(Long userId) { + return queryFactory + .select(notification.id) + .from(notification) + .where(notification.receiver.id.eq(userId) + .and(notification.readAt.isNull())) + .fetchFirst() != null; + } + + @Override + public void saveDailySpendingAnnounceInBulk(List userIds, Announcement announcement) { + int batchCount = 0; + List subItems = new ArrayList<>(); + + for (int i = 0; i < userIds.size(); ++i) { + subItems.add(userIds.get(i)); + + if ((i + 1) % BATCH_SIZE == 0) { + batchCount = batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); + } + } + + if (!subItems.isEmpty()) { + batchInsert(batchCount, subItems, NoticeType.ANNOUNCEMENT, announcement); + } + + log.info("Notification saved. announcement: {}, count: {}", announcement, userIds.size()); + } + + private int batchInsert(int batchCount, List userIds, NoticeType noticeType, Announcement announcement) { + String sql = "INSERT INTO notification(id, read_at, type, announcement, created_at, updated_at, receiver, receiver_name) " + + "SELECT NULL, NULL, ?, ?, NOW(), NOW(), u.id, u.name " + + "FROM user u " + + "WHERE u.id IN (?) " + + "AND NOT EXISTS ( " + + " SELECT n.receiver " + + " FROM notification n " + + " WHERE n.receiver = u.id " + + " AND n.created_at >= CURDATE() " + + " AND n.created_at < CURDATE() + INTERVAL 1 DAY " + + " AND n.type = ? " + + " AND n.announcement = ? " + + ")"; + + jdbcTemplate.batchUpdate(sql, new BatchPreparedStatementSetter() { + @Override + public void setValues(PreparedStatement ps, int i) throws SQLException { + ps.setString(1, noticeType.getCode()); + ps.setString(2, announcement.getCode()); + ps.setLong(3, userIds.get(i)); + ps.setString(4, noticeType.getCode()); + ps.setString(5, announcement.getCode()); + } + + @Override + public int getBatchSize() { + return userIds.size(); + } + }); + + userIds.clear(); + return ++batchCount; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..ad7217f11 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepository.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface NotificationRepository extends ExtendedRepository, NotificationCustomRepository { + @Modifying(clearAutomatically = true) + @Transactional + @Query("update Notification n set n.readAt = current_timestamp where n.id in ?1") + void updateReadAtByIdsInBulk(List notificationIds); + + @Transactional(readOnly = true) + @Query("select count(n) from Notification n where n.receiver.id = ?1 and n.id in ?2 and n.readAt is null") + long countUnreadNotificationsByIds(Long userId, List notificationIds); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java new file mode 100644 index 000000000..599a66cb4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/service/NotificationService.java @@ -0,0 +1,54 @@ +package kr.co.pennyway.domain.domains.notification.service; + +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.domain.QNotification; +import kr.co.pennyway.domain.domains.notification.repository.NotificationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class NotificationService { + private final NotificationRepository notificationRepository; + + private final QNotification notification = QNotification.notification; + + @Transactional(readOnly = true) + public Slice readNotificationsSlice(Long userId, Pageable pageable) { + Predicate predicate = notification.receiver.id.eq(userId); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(notificationRepository.findList(predicate, queryHandler, sort), pageable); + } + + @Transactional(readOnly = true) + public boolean isExistsUnreadNotification(Long userId) { + return notificationRepository.existsUnreadNotification(userId); + } + + @Transactional(readOnly = true) + public long countUnreadNotifications(Long userId, List notificationIds) { + return notificationRepository.countUnreadNotificationsByIds(userId, notificationIds); + } + + @Transactional + public void updateReadAtByIdsInBulk(List notificationIds) { + notificationRepository.updateReadAtByIdsInBulk(notificationIds); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java new file mode 100644 index 000000000..30fd87df9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/Announcement.java @@ -0,0 +1,70 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import org.springframework.util.StringUtils; + +@Getter +public enum Announcement implements LegacyCommonType { + NOT_ANNOUNCE("0", "", ""), + + // 정기 지출 알림 + DAILY_SPENDING("1", "%s님, 3분 카레보다 빨리 끝나요!", "많은 친구들이 소비 기록에 참여하고 있어요👀"), + MONTHLY_TARGET_AMOUNT("2", "%s월의 첫 시작! 두구두구..🥁", "%s님의 이번 달 목표 소비 금액은?"); + + private final String code; + private final String title; + private final String content; + + Announcement(String code, String title, String content) { + this.code = code; + this.title = title; + this.content = content; + } + + /** + * 수신자의 이름을 받아서 공지 제목을 생성한다. + *
+ * 만약 해당 타입의 제목에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 제목 + */ + public String createFormattedTitle(String name) { + validateName(name); + + if (this.title.indexOf("%") == -1) { + return this.title; + } + + return String.format(title, name); + } + + /** + * 수신자의 이름을 받아서 공지 내용을 생성한다. + *
+ * 만약 해당 타입의 내용에서 % 문자가 없다면 그대로 반환한다. + * + * @param name 수신자의 이름 + * @return 포맷팅된 공지 내용 + */ + public String createFormattedContent(String name) { + validateName(name); + + if (this.content.indexOf("%") == -1) { + return this.content; + } + + return String.format(content, name); + } + + private void validateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name must not be empty"); + } + + if (this == NOT_ANNOUNCE) { + throw new IllegalArgumentException("NOT_ANNOUNCE type is not allowed"); + } + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java new file mode 100644 index 000000000..05bc2646b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/notification/type/NoticeType.java @@ -0,0 +1,32 @@ +package kr.co.pennyway.domain.domains.notification.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; + +/** + * 알림 종류를 정의하기 위한 타입 + * + *

+ * 알림 타입은 [도메인]_[액션]_[FROM]_[TO?] 형태로 정의한다. + * 각 알림 타입에 대한 이름, 제목, 내용 형식을 지정하며, 알림 타입에 따라 내용을 생성하는 기능을 제공한다. + *

+ * + * @author YANG JAESEO + * @since 2024-07-04 + */ +@Getter +public enum NoticeType implements LegacyCommonType { + ANNOUNCEMENT("0", "%s", "%s"); // 공지 사항은 별도 제목을 설정하여 사용한다. + + private final String code; + private final String title; + private final String contentFormat; + private final String navigablePlaceholders = "{%s_%d}"; + private final String plainTextPlaceholders = "%s"; + + NoticeType(String code, String title, String contentFormat) { + this.code = code; + this.title = title; + this.contentFormat = contentFormat; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java new file mode 100644 index 000000000..0a566a20d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/domain/Oauth.java @@ -0,0 +1,81 @@ +package kr.co.pennyway.domain.domains.oauth.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProviderConverter; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "oauth") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +@DynamicInsert +@SQLDelete(sql = "UPDATE oauth SET deleted_at = NOW() WHERE id = ?") +public class Oauth { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Convert(converter = ProviderConverter.class) + private Provider provider; + private String oauthId; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Builder(access = AccessLevel.PRIVATE) + private Oauth(Provider provider, String oauthId, User user) { + if (!StringUtils.hasText(oauthId)) { + throw new IllegalArgumentException("oauthId는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.provider = Objects.requireNonNull(provider, "provider는 null이 될 수 없습니다."); + this.oauthId = oauthId; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + } + + public static Oauth of(Provider provider, String oauthId, User user) { + return Oauth.builder() + .provider(provider) + .oauthId(oauthId) + .user(user) + .build(); + } + + public boolean isDeleted() { + return deletedAt != null; + } + + @Override + public String toString() { + return "Oauth{" + + "id=" + id + + ", provider=" + provider + + ", oauthId='" + oauthId + '\'' + + ", createdAt=" + createdAt + + ", deletedAt=" + deletedAt + + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java new file mode 100644 index 000000000..2c2657149 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthErrorCode.java @@ -0,0 +1,43 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum OauthErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_OAUTH_SYNC_REQUEST(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "Oauth 동기화 요청이 잘못되었습니다."), + + /* 401 Unauthorized */ + NOT_MATCHED_OAUTH_ID(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "OAuth ID가 일치하지 않습니다."), + + /* 404 Not Found */ + NOT_FOUND_OAUTH(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 제공자로 가입된 이력을 찾을 수 없습니다."), + + /* 409 Conflict */ + CANNOT_UNLINK_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "해당 제공자로만 가입된 사용자는 연동을 해제할 수 없습니다."), + ALREADY_USED_OAUTH(StatusCode.CONFLICT, ReasonCode.REQUEST_CONFLICTS_WITH_CURRENT_STATE_OF_RESOURCE, "이미 다른 계정에서 사용 중인 계정입니다."), + ALREADY_SIGNUP_OAUTH(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 제공자로 가입된 사용자입니다."), + + /* 422 Unprocessable Entity */ + INVALID_PROVIDER(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 제공자입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java new file mode 100644 index 000000000..2276a4478 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/exception/OauthException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.oauth.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class OauthException extends GlobalErrorException { + private final OauthErrorCode errorCode; + + public OauthException(OauthErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java new file mode 100644 index 000000000..5012fbeaa --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepository.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; + +public interface OauthRepository extends JpaRepository { + Optional findByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); + + Optional findByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); + + Set findAllByUser_Id(Long userId); + + boolean existsByUser_IdAndProviderAndDeletedAtIsNull(Long userId, Provider provider); + + boolean existsByOauthIdAndProviderAndDeletedAtIsNull(String oauthId, Provider provider); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Oauth o SET o.deletedAt = NOW() WHERE o.user.id = :userId AND o.deletedAt IS NULL") + void deleteAllByUser_IdAndDeletedAtNullInQuery(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java new file mode 100644 index 000000000..02ac5ca0c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/service/OauthService.java @@ -0,0 +1,66 @@ +package kr.co.pennyway.domain.domains.oauth.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.repository.OauthRepository; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Set; + +@DomainService +@RequiredArgsConstructor +public class OauthService { + private final OauthRepository oauthRepository; + + @Transactional + public Oauth createOauth(Oauth oauth) { + return oauthRepository.save(oauth); + } + + @Transactional + public Optional readOauth(Long id) { + return oauthRepository.findById(id); + } + + /** + * oauthId와 provider로 Oauth를 조회한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public Optional readOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); + } + + @Transactional(readOnly = true) + public Set readOauthsByUserId(Long userId) { + return oauthRepository.findAllByUser_Id(userId); + } + + /** + * userId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public boolean isExistOauthByUserIdAndProvider(Long userId, Provider provider) { + return oauthRepository.existsByUser_IdAndProviderAndDeletedAtIsNull(userId, provider); + } + + /** + * oauthId와 provider로 Oauth가 존재하는지 확인한다. 이 때, deletedAt이 null인 Oauth만 조회한다. + */ + @Transactional(readOnly = true) + public boolean isExistOauthByOauthIdAndProvider(String oauthId, Provider provider) { + return oauthRepository.existsByOauthIdAndProviderAndDeletedAtIsNull(oauthId, provider); + } + + @Transactional + public void deleteOauth(Oauth oauth) { + oauthRepository.delete(oauth); + } + + @Transactional + public void deleteOauthsByUserIdInQuery(Long userId) { + oauthRepository.deleteAllByUser_IdAndDeletedAtNullInQuery(userId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java new file mode 100644 index 000000000..e405919ae --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/oauth/type/Provider.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.oauth.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum Provider implements LegacyCommonType { + KAKAO("1", "카카오"), + GOOGLE("2", "구글"), + APPLE("3", "애플"); + + private final String code; + private final String type; + + @JsonCreator + public Provider fromString(String type) { + return valueOf(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return name().toLowerCase(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java new file mode 100644 index 000000000..6f369708d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/Question.java @@ -0,0 +1,50 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.QuestionCategoryConverter; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "question") +@SQLDelete(sql = "UPDATE question SET deleted_at = NOW() WHERE id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Question { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) + private String email; + @Convert(converter = QuestionCategoryConverter.class) + @Column(nullable = false) + private QuestionCategory category; + private String content; + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + private LocalDateTime deletedAt; + + @Builder + private Question(String email, QuestionCategory category, String content) { + if (!StringUtils.hasText(email)) { + throw new IllegalArgumentException("email은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(content)) { + throw new IllegalArgumentException("content는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.email = email; + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.content = content; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java new file mode 100644 index 000000000..6fabf68b1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/domain/QuestionCategory.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.domain; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public enum QuestionCategory implements LegacyCommonType { + UTILIZATION("1", "이용 관련"), + BUG_REPORT("2", "오류 신고"), + SUGGESTION("3", "서비스 제안"), + ETC("4", "기타"); + + private final String code; + private final String title; +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java new file mode 100644 index 000000000..6b20844a1 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorCode.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public enum QuestionErrorCode implements BaseErrorCode { + INTERNAL_MAIL_ERROR(StatusCode.INTERNAL_SERVER_ERROR, ReasonCode.UNEXPECTED_ERROR, "메일 발송에 실패했습니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java new file mode 100644 index 000000000..3eb89c218 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/exception/QuestionErrorException.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.question.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; +import kr.co.pennyway.domain.domains.user.exception.UserErrorCode; +import lombok.Getter; + +public class QuestionErrorException extends GlobalErrorException { + private final QuestionErrorCode questionErrorCode; + + public QuestionErrorException(QuestionErrorCode questionErrorCode) { + super(questionErrorCode); + this.questionErrorCode = questionErrorCode; + } + + public CausedBy causedBy() { + return questionErrorCode.causedBy(); + } + + public String getExplainError() { + return questionErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java new file mode 100644 index 000000000..d50f22cdd --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/repository/QuestionRepository.java @@ -0,0 +1,7 @@ +package kr.co.pennyway.domain.domains.question.repository; + +import kr.co.pennyway.domain.domains.question.domain.Question; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface QuestionRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java new file mode 100644 index 000000000..a8e68ce4d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/question/service/QuestionService.java @@ -0,0 +1,19 @@ +package kr.co.pennyway.domain.domains.question.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.question.domain.Question; +import kr.co.pennyway.domain.domains.question.repository.QuestionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +@DomainService +@RequiredArgsConstructor +public class QuestionService { + private final QuestionRepository questionRepository; + + @Transactional + public void createQuestion(Question question) { + questionRepository.save(question); + } + +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java new file mode 100644 index 000000000..bdad51667 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/Spending.java @@ -0,0 +1,108 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.dto.CategoryInfo; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "spending") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE spending SET deleted_at = NOW() WHERE id = ?") +public class Spending extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Integer amount; + @Convert(converter = SpendingCategoryConverter.class) + private SpendingCategory category; + private LocalDateTime spendAt; + private String accountName; + private String memo; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + /* category가 OTHER일 경우 spendingCustomCategory를 참조 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "spending_custom_category_id") + private SpendingCustomCategory spendingCustomCategory; + + @Builder + private Spending(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, User user, SpendingCustomCategory spendingCustomCategory) { + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); + } + + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); + this.accountName = accountName; + this.memo = memo; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.spendingCustomCategory = spendingCustomCategory; + } + + public int getDay() { + return spendAt.getDayOfMonth(); + } + + /** + * 지출 내역의 소비 카테고리를 조회하는 메서드
+ * SpendingCategory가 OTHER일 경우 SpendingCustomCategory를 정보를 조회하여 반환한다. + * + * @return {@link CategoryInfo} + */ + public CategoryInfo getCategory() { + if (this.category.equals(SpendingCategory.CUSTOM)) { + SpendingCustomCategory category = getSpendingCustomCategory(); + return CategoryInfo.of(category.getId(), category.getName(), category.getIcon()); + } + + return CategoryInfo.of(-1L, this.category.getType(), this.category); + } + + public void update(Integer amount, SpendingCategory category, LocalDateTime spendAt, String accountName, String memo, SpendingCustomCategory spendingCustomCategory) { + if (spendingCustomCategory == null && (category.equals(SpendingCategory.CUSTOM) || category.equals(SpendingCategory.OTHER))) { + throw new IllegalArgumentException("서비스 제공 아이콘을 등록할 때는 CUSTOM, OHTER 아이콘을 사용할 수 없습니다."); + } else if (spendingCustomCategory != null && !category.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 아이콘을 등록할 때는 CUSTOM 아이콘이어야 합니다."); + } + + this.amount = Objects.requireNonNull(amount, "amount는 null이 될 수 없습니다."); + this.category = Objects.requireNonNull(category, "category는 null이 될 수 없습니다."); + this.spendAt = Objects.requireNonNull(spendAt, "spendAt는 null이 될 수 없습니다."); + this.accountName = accountName; + this.memo = memo; + this.spendingCustomCategory = spendingCustomCategory; + } + + @Override + public String toString() { + return "Spending{" + + "id=" + id + + ", amount=" + amount + + ", category=" + category + + ", spendAt=" + spendAt + + ", accountName='" + accountName + '\'' + + ", memo='" + memo + "'}"; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java new file mode 100644 index 000000000..083f6a3c3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/domain/SpendingCustomCategory.java @@ -0,0 +1,67 @@ +package kr.co.pennyway.domain.domains.spending.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.SpendingCategoryConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "spending_custom_category") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE spending_custom_category SET deleted_at = NOW() WHERE id = ?") +public class SpendingCustomCategory extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + @Convert(converter = SpendingCategoryConverter.class) + private SpendingCategory icon; + private LocalDateTime deletedAt; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private SpendingCustomCategory(String name, SpendingCategory icon, User user) { + if (icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + + this.name = name; + this.icon = icon; + this.user = user; + } + + public static SpendingCustomCategory of(String name, SpendingCategory icon, User user) { + return new SpendingCustomCategory(name, icon, user); + } + + public void update(String name, SpendingCategory icon) { + if (icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("OTHER 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."); + } + + this.name = name; + this.icon = icon; + } + + @Override + public String toString() { + return "SpendingCustomCategory{" + + "id=" + id + + ", name='" + name + '\'' + + ", icon=" + icon + + ", deletedAt=" + deletedAt + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java new file mode 100644 index 000000000..9934bb592 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/CategoryInfo.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.util.StringUtils; + +import java.util.Objects; + +/** + * 지출 카테고리 정보를 담은 DTO + * + * @param isCustom boolean : 사용자 정의 카테고리 여부 + * @param id Long : 카테고리 ID. 사용자 정의 카테고리가 아니라면 -1, 사용자 정의 카테고리라면 0 이상의 값을 갖는다. + * @param name String : 카테고리 이름 + * @param icon String : 카테고리 아이콘 + */ +public record CategoryInfo( + boolean isCustom, + Long id, + String name, + SpendingCategory icon +) { + public CategoryInfo { + Objects.requireNonNull(id, "id는 null일 수 없습니다."); + Objects.requireNonNull(icon, "icon은 null일 수 없습니다."); + + if (isCustom && id < 0 || !isCustom && id != -1) { + throw new IllegalArgumentException("isCustom이 " + isCustom + "일 때 id는 " + (isCustom ? "0 이상" : "-1") + "이어야 합니다."); + } + + if (isCustom && icon.equals(SpendingCategory.CUSTOM)) { + throw new IllegalArgumentException("사용자 정의 카테고리는 OTHER가 될 수 없습니다."); + } + + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열일 수 없습니다."); + } + } + + public static CategoryInfo of(Long id, String name, SpendingCategory icon) { + return new CategoryInfo(!id.equals(-1L), id, name, icon); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java new file mode 100644 index 000000000..a21c4504d --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/dto/TotalSpendingAmount.java @@ -0,0 +1,28 @@ +package kr.co.pennyway.domain.domains.spending.dto; + +import java.time.YearMonth; + +/** + * 사용자의 해당 년/월 총 지출 금액을 담는 DTO + */ +public record TotalSpendingAmount( + int year, + int month, + long totalSpending +) { + public TotalSpendingAmount(int year, int month, long totalSpending) { + this.year = year; + this.month = month; + this.totalSpending = totalSpending; + } + + /** + * YearMonth 객체로 변환하는 메서드 + * + * @return 해당 년/월을 나타내는 YearMonth 객체 + */ + public YearMonth getYearMonth() { + return YearMonth.of(year, month); + } + +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java new file mode 100644 index 000000000..eb38dc5f4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorCode.java @@ -0,0 +1,36 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SpendingErrorCode implements BaseErrorCode { + /* 400 Bad Request */ + INVALID_ICON(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "CUSTOM 아이콘은 커스텀 카테고리의 icon으로 사용할 수 없습니다."), + INVALID_ICON_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "icon의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_TYPE_WITH_CATEGORY_ID(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "type의 정보와 categoryId의 정보가 존재할 수 없는 조합입니다."), + INVALID_CATEGORY_TYPE(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "존재하지 않는 카테고리 타입입니다."), + + /* 404 Not Found */ + NOT_FOUND_SPENDING(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 지출 내역입니다."), + NOT_FOUND_CUSTOM_CATEGORY(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "존재하지 않는 커스텀 카테고리입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java new file mode 100644 index 000000000..74eb92be4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/exception/SpendingErrorException.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.spending.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class SpendingErrorException extends GlobalErrorException { + private final SpendingErrorCode errorCode; + + public SpendingErrorException(SpendingErrorCode errorCode) { + super(errorCode); + this.errorCode = errorCode; + } + + @Override + public CausedBy causedBy() { + return errorCode.causedBy(); + } + + public String getExplainError() { + return errorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java new file mode 100644 index 000000000..7632740da --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomCategoryRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface SpendingCustomCategoryRepository extends JpaRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + List findAllByUser_Id(Long userId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE SpendingCustomCategory s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java new file mode 100644 index 000000000..3c94a3481 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepository.java @@ -0,0 +1,13 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; + +import java.util.List; +import java.util.Optional; + +public interface SpendingCustomRepository { + Optional findTotalSpendingAmountByUserId(Long userId, int year, int month); + + List findByYearAndMonth(Long userId, int year, int month); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java new file mode 100644 index 000000000..7c780895c --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingCustomRepositoryImpl.java @@ -0,0 +1,64 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.common.util.QueryDslUtil; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class SpendingCustomRepositoryImpl implements SpendingCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; + + @Override + public Optional findTotalSpendingAmountByUserId(Long userId, int year, int month) { + TotalSpendingAmount result = queryFactory.select( + Projections.constructor( + TotalSpendingAmount.class, + spending.spendAt.year().intValue(), + spending.spendAt.month().intValue(), + spending.amount.sum().longValue() + ) + ).from(user) + .leftJoin(spending).on(user.id.eq(spending.user.id)) + .where(user.id.eq(userId) + .and(spending.spendAt.year().eq(year)) + .and(spending.spendAt.month().eq(month))) + .groupBy(spending.spendAt.year(), spending.spendAt.month()) + .fetchOne(); + + return Optional.ofNullable(result); + } + + @Override + public List findByYearAndMonth(Long userId, int year, int month) { + Sort sort = Sort.by(Sort.Order.desc("spendAt")); + List> orderSpecifiers = QueryDslUtil.getOrderSpecifier(sort); + + return queryFactory.selectFrom(spending) + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .where(spending.spendAt.year().eq(year) + .and(spending.spendAt.month().eq(month)) + .and(spending.user.id.eq(userId)) + ) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java new file mode 100644 index 000000000..f9e65322b --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/repository/SpendingRepository.java @@ -0,0 +1,59 @@ +package kr.co.pennyway.domain.domains.spending.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +public interface SpendingRepository extends ExtendedRepository, SpendingCustomRepository { + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); + + @Transactional(readOnly = true) + int countByUser_IdAndSpendingCustomCategory_Id(Long userId, Long categoryId); + + @Transactional(readOnly = true) + int countByUser_IdAndCategory(Long userId, SpendingCategory spendingCategory); + + @Transactional(readOnly = true) + long countByUserIdAndIdIn(Long userId, List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId, s.category = :custom WHERE s.category = :fromCategory") + void updateCategoryByCustomCategoryInQuery(SpendingCategory fromCategory, Long toCategoryId, SpendingCategory custom); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.category = :toCategory WHERE s.category = :fromCategory") + void updateCategoryByCategoryInQuery(SpendingCategory fromCategory, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory.id = :toCategoryId WHERE s.spendingCustomCategory.id = :fromCategoryId") + void updateCustomCategoryByCustomCategoryInQuery(Long fromCategoryId, Long toCategoryId); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.spendingCustomCategory = null, s.category = :toCategory WHERE s.spendingCustomCategory.id = :fromCategoryId") + void updateCustomCategoryByCategoryInQuery(Long fromCategoryId, SpendingCategory toCategory); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() where s.id IN :spendingIds") + void deleteAllByIdAndDeletedAtNullInQuery(List spendingIds); + + @Modifying(clearAutomatically = true) + @Transactional + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.user.id = :userId") + void deleteAllByUserIdInQuery(Long userId); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE Spending s SET s.deletedAt = NOW() WHERE s.spendingCustomCategory.id = :categoryId") + void deleteAllByCategoryIdAndDeletedAtNullInQuery(Long categoryId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java new file mode 100644 index 000000000..57f0cd43e --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingCustomCategoryService.java @@ -0,0 +1,48 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.spending.domain.SpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.repository.SpendingCustomCategoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingCustomCategoryService { + private final SpendingCustomCategoryRepository spendingCustomCategoryRepository; + + @Transactional + public SpendingCustomCategory createSpendingCustomCategory(SpendingCustomCategory spendingCustomCategory) { + return spendingCustomCategoryRepository.save(spendingCustomCategory); + } + + @Transactional(readOnly = true) + public Optional readSpendingCustomCategory(Long id) { + return spendingCustomCategoryRepository.findById(id); + } + + @Transactional(readOnly = true) + public List readSpendingCustomCategories(Long userId) { + return spendingCustomCategoryRepository.findAllByUser_Id(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsSpendingCustomCategory(Long userId, Long categoryId) { + return spendingCustomCategoryRepository.existsByIdAndUser_Id(categoryId, userId); + } + + @Transactional + public void deleteSpendingCustomCategory(Long categoryId) { + spendingCustomCategoryRepository.deleteById(categoryId); + } + + @Transactional + public void deleteSpendingCustomCategoriesByUserIdInQuery(Long userId) { + spendingCustomCategoryRepository.deleteAllByUserIdInQuery(userId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java new file mode 100644 index 000000000..f8bff76cd --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/service/SpendingService.java @@ -0,0 +1,177 @@ +package kr.co.pennyway.domain.domains.spending.service; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.common.util.SliceUtil; +import kr.co.pennyway.domain.domains.spending.domain.QSpending; +import kr.co.pennyway.domain.domains.spending.domain.QSpendingCustomCategory; +import kr.co.pennyway.domain.domains.spending.domain.Spending; +import kr.co.pennyway.domain.domains.spending.dto.TotalSpendingAmount; +import kr.co.pennyway.domain.domains.spending.repository.SpendingRepository; +import kr.co.pennyway.domain.domains.spending.type.SpendingCategory; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class SpendingService { + private final SpendingRepository spendingRepository; + + private final QUser user = QUser.user; + private final QSpending spending = QSpending.spending; + private final QSpendingCustomCategory spendingCustomCategory = QSpendingCustomCategory.spendingCustomCategory; + + @Transactional + public Spending createSpending(Spending spending) { + return spendingRepository.save(spending); + } + + @Transactional(readOnly = true) + public Optional readSpending(Long spendingId) { + return spendingRepository.findById(spendingId); + } + + @Transactional(readOnly = true) + public List readSpendings(Long userId, int year, int month) { + return spendingRepository.findByYearAndMonth(userId, year, month); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategoryId(Long userId, Long categoryId) { + return spendingRepository.countByUser_IdAndSpendingCustomCategory_Id(userId, categoryId); + } + + @Transactional(readOnly = true) + public int readSpendingTotalCountByCategory(Long userId, SpendingCategory spendingCategory) { + return spendingRepository.countByUser_IdAndCategory(userId, spendingCategory); + } + + /** + * 사용자 정의 카테고리 ID로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategoryId(Long userId, Long categoryId, Pageable pageable) { + Predicate predicate = spending.user.id.eq(userId).and(spendingCustomCategory.id.eq(categoryId)); + + QueryHandler queryHandler = query -> query + .leftJoin(spending.spendingCustomCategory, spendingCustomCategory).fetchJoin() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + /** + * 시스템 제공 카테고리 code로 지출 내역 리스트를 조회한다. + * + * @return 지출 내역 리스트를 {@link Slice}에 담아서 반환한다. + */ + @Transactional(readOnly = true) + public Slice readSpendingsSliceByCategory(Long userId, SpendingCategory spendingCategory, Pageable pageable) { + if (spendingCategory.equals(SpendingCategory.CUSTOM) || spendingCategory.equals(SpendingCategory.OTHER)) { + throw new IllegalArgumentException("지출 카테고리가 시스템 제공 카테고리가 아닙니다."); + } + + Predicate predicate = spending.user.id.eq(userId).and(spending.category.eq(spendingCategory)); + + QueryHandler queryHandler = query -> query + .offset(pageable.getOffset()) + .limit(pageable.getPageSize() + 1); + + Sort sort = pageable.getSort(); + + return SliceUtil.toSlice(spendingRepository.findList(predicate, queryHandler, sort), pageable); + } + + @Transactional(readOnly = true) + public Optional readTotalSpendingAmountByUserId(Long userId, LocalDate date) { + return spendingRepository.findTotalSpendingAmountByUserId(userId, date.getYear(), date.getMonthValue()); + } + + @Transactional(readOnly = true) + public List readTotalSpendingsAmountByUserId(Long userId) { + Predicate predicate = user.id.eq(userId); + + QueryHandler queryHandler = query -> query.leftJoin(spending).on(user.id.eq(spending.user.id)) + .groupBy(spending.spendAt.year(), spending.spendAt.month()); + + Sort sort = Sort.by(Sort.Order.desc("year(spendAt)"), Sort.Order.desc("month(spendAt)")); + + Map> bindings = new LinkedHashMap<>(); + bindings.put("year", spending.spendAt.year().intValue()); + bindings.put("month", spending.spendAt.month().intValue()); + bindings.put("totalSpending", spending.amount.sum().longValue()); + + return spendingRepository.selectList(predicate, TotalSpendingAmount.class, bindings, queryHandler, sort); + } + + @Transactional(readOnly = true) + public boolean isExistsSpending(Long userId, Long spendingId) { + return spendingRepository.existsByIdAndUser_Id(spendingId, userId); + } + + @Transactional(readOnly = true) + public long countByUserIdAndIdIn(Long userId, List spendingIds) { + return spendingRepository.countByUserIdAndIdIn(userId, spendingIds); + } + + @Transactional + public void updateCategoryByCustomCategory(SpendingCategory fromCategory, Long toId) { + SpendingCategory custom = SpendingCategory.CUSTOM; + spendingRepository.updateCategoryByCustomCategoryInQuery(fromCategory, toId, custom); + } + + @Transactional + public void updateCategoryByCategory(SpendingCategory fromCategory, SpendingCategory toCategory) { + + spendingRepository.updateCategoryByCategoryInQuery(fromCategory, toCategory); + } + + @Transactional + public void updateCustomCategoryByCustomCategory(Long fromId, Long toId) { + spendingRepository.updateCustomCategoryByCustomCategoryInQuery(fromId, toId); + } + + @Transactional + public void updateCustomCategoryByCategory(Long fromId, SpendingCategory toCategory) { + spendingRepository.updateCustomCategoryByCategoryInQuery(fromId, toCategory); + } + + @Transactional + public void deleteSpending(Spending spending) { + spendingRepository.delete(spending); + } + + @Transactional + public void deleteSpendingsInQuery(List spendingIds) { + spendingRepository.deleteAllByIdAndDeletedAtNullInQuery(spendingIds); + } + + @Transactional + public void deleteSpendingsByUserIdInQuery(Long userId) { + spendingRepository.deleteAllByUserIdInQuery(userId); + } + + @Transactional + public void deleteSpendingsByCategoryIdInQuery(Long categoryId) { + spendingRepository.deleteAllByCategoryIdAndDeletedAtNullInQuery(categoryId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java new file mode 100644 index 000000000..aa5ad5431 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/spending/type/SpendingCategory.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.domains.spending.type; + +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.stream.Stream; + +@Getter +@RequiredArgsConstructor +public enum SpendingCategory implements LegacyCommonType { + CUSTOM("0", "사용자 정의"), + FOOD("1", "식비"), + TRANSPORTATION("2", "교통"), + BEAUTY_OR_FASHION("3", "뷰티/패션"), + CONVENIENCE_STORE("4", "편의점/마트"), + EDUCATION("5", "교육"), + LIVING("6", "생활"), + HEALTH("7", "건강"), + HOBBY("8", "취미/여가"), + TRAVEL("9", "여행/숙박"), + ALCOHOL_OR_ENTERTAINMENT("10", "술/유흥"), + MEMBERSHIP_OR_FAMILY_EVENT("11", "회비/경조사"), + OTHER("12", "기타"); + + private final String code; + private final String type; + + public static SpendingCategory fromCode(String code) { + return Stream.of(values()) + .filter(v -> v.getCode().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리 코드입니다.")); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java new file mode 100644 index 000000000..8576bdf77 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/domain/TargetAmount.java @@ -0,0 +1,72 @@ +package kr.co.pennyway.domain.domains.target.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.domain.User; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; + +import java.time.YearMonth; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "target_amount") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE target_amount SET amount = -1, is_read = 1 WHERE id = ?") +public class TargetAmount extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private int amount; + private boolean isRead; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + private TargetAmount(int amount, User user) { + this.amount = amount; + this.user = Objects.requireNonNull(user, "user는 null이 될 수 없습니다."); + this.isRead = false; + } + + /** + * @param amount 목표 금액은 null을 허용하지 않는다. + * @param user 사용자는 null을 허용하지 않는다. + * @throws NullPointerException amount가 null이거나 user가 null일 때 + */ + public static TargetAmount of(int amount, User user) { + return new TargetAmount(amount, user); + } + + /** + * @param amount 변경할 목표 금액은 null을 허용하지 않는다. + */ + public void updateAmount(int amount) { + this.amount = amount; + this.isRead = true; + } + + public boolean isAllocatedAmount() { + return this.amount >= 0; + } + + /** + * 해당 TargetAmount가 당월 데이터인지 확인한다. + * + * @return 당월 데이터라면 true, 아니라면 false + */ + public boolean isThatMonth() { + YearMonth yearMonth = YearMonth.now(); + return this.getCreatedAt().getYear() == yearMonth.getYear() && this.getCreatedAt().getMonth() == yearMonth.getMonth(); + } + + @Override + public String toString() { + return "TargetAmount(id=" + this.getId() + ", amount=" + this.getAmount() + ", year = " + this.getCreatedAt().getYear() + ", month = " + this.getCreatedAt().getMonthValue() + ")"; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java new file mode 100644 index 000000000..2a85e2351 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorCode.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum TargetAmountErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + INVALID_TARGET_AMOUNT_DATE(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "당월 목표 금액에 대한 요청이 아닙니다."), + + /* 404 NOT_FOUND */ + NOT_FOUND_TARGET_AMOUNT(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "해당 월의 목표 금액이 존재하지 않습니다."), + + /* 409 Conflict */ + ALREADY_EXIST_TARGET_AMOUNT(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 해당 월의 목표 금액 데이터가 존재합니다."), + ; + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java new file mode 100644 index 000000000..669ab9b96 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/exception/TargetAmountErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.target.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class TargetAmountErrorException extends GlobalErrorException { + private final TargetAmountErrorCode targetAmountErrorCode; + + public TargetAmountErrorException(TargetAmountErrorCode targetAmountErrorCode) { + super(targetAmountErrorCode); + this.targetAmountErrorCode = targetAmountErrorCode; + } + + public CausedBy causedBy() { + return targetAmountErrorCode.causedBy(); + } + + public String getExplainError() { + return targetAmountErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java new file mode 100644 index 000000000..b97857b6f --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepository.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; + +import java.time.LocalDate; +import java.util.Optional; + +public interface TargetAmountCustomRepository { + Optional findRecentOneByUserId(Long userId); + + boolean existsByUserIdThatMonth(Long userId, LocalDate date); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java new file mode 100644 index 000000000..f3803491a --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountCustomRepositoryImpl.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.domains.target.domain.QTargetAmount; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class TargetAmountCustomRepositoryImpl implements TargetAmountCustomRepository { + private final JPAQueryFactory queryFactory; + + private final QUser user = QUser.user; + private final QTargetAmount targetAmount = QTargetAmount.targetAmount; + + /** + * 사용자의 가장 최근 목표 금액을 조회한다. + * + * @return 최근 목표 금액이 존재하지 않을 경우 Optional.empty()를 반환하며, 당월 목표 금액 정보일 수도 있다. + */ + @Override + public Optional findRecentOneByUserId(Long userId) { + TargetAmount result = queryFactory.selectFrom(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.amount.gt(-1))) + .orderBy(targetAmount.createdAt.desc()) + .fetchFirst(); + + return Optional.ofNullable(result); + } + + @Override + public boolean existsByUserIdThatMonth(Long userId, LocalDate date) { + return queryFactory.selectOne().from(targetAmount) + .innerJoin(user).on(targetAmount.user.id.eq(user.id)) + .where(user.id.eq(userId) + .and(targetAmount.createdAt.year().eq(date.getYear())) + .and(targetAmount.createdAt.month().eq(date.getMonthValue()))) + .fetchFirst() != null; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java new file mode 100644 index 000000000..1a3405763 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/repository/TargetAmountRepository.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +public interface TargetAmountRepository extends ExtendedRepository, TargetAmountCustomRepository { + @Transactional(readOnly = true) + @Query("SELECT ta FROM TargetAmount ta WHERE ta.user.id = :userId AND YEAR(ta.createdAt) = YEAR(:date) AND MONTH(ta.createdAt) = MONTH(:date)") + Optional findByUserIdThatMonth(Long userId, LocalDate date); + + @Transactional(readOnly = true) + List findByUser_Id(Long userId); + + @Transactional(readOnly = true) + boolean existsByIdAndUser_Id(Long id, Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java new file mode 100644 index 000000000..115dd2f18 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/target/service/TargetAmountService.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.domains.target.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.target.domain.TargetAmount; +import kr.co.pennyway.domain.domains.target.repository.TargetAmountRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +@Slf4j +@DomainService +@RequiredArgsConstructor +public class TargetAmountService { + private final TargetAmountRepository targetAmountRepository; + + @Transactional + public TargetAmount createTargetAmount(TargetAmount targetAmount) { + return targetAmountRepository.save(targetAmount); + } + + @Transactional(readOnly = true) + public Optional readTargetAmount(Long id) { + return targetAmountRepository.findById(id); + } + + @Transactional(readOnly = true) + public Optional readTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.findByUserIdThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public List readTargetAmountsByUserId(Long userId) { + return targetAmountRepository.findByUser_Id(userId); + } + + @Transactional(readOnly = true) + public Optional readRecentTargetAmount(Long userId) { + return targetAmountRepository.findRecentOneByUserId(userId); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountThatMonth(Long userId, LocalDate date) { + return targetAmountRepository.existsByUserIdThatMonth(userId, date); + } + + @Transactional(readOnly = true) + public boolean isExistsTargetAmountByIdAndUserId(Long id, Long userId) { + return targetAmountRepository.existsByIdAndUser_Id(id, userId); + } + + + @Transactional + public void deleteTargetAmount(TargetAmount targetAmount) { + targetAmountRepository.delete(targetAmount); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java new file mode 100644 index 000000000..373a4fe78 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/NotifySetting.java @@ -0,0 +1,61 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; +import lombok.ToString; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@ToString(of = {"accountBookNotify", "feedNotify", "chatNotify"}) +public class NotifySetting { + @ColumnDefault("true") + private boolean accountBookNotify; + @ColumnDefault("true") + private boolean feedNotify; + @ColumnDefault("true") + private boolean chatNotify; + + @Builder + private NotifySetting(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { + this.accountBookNotify = accountBookNotify; + this.feedNotify = feedNotify; + this.chatNotify = chatNotify; + } + + public static NotifySetting of(boolean accountBookNotify, boolean feedNotify, boolean chatNotify) { + return NotifySetting.builder() + .accountBookNotify(accountBookNotify) + .feedNotify(feedNotify) + .chatNotify(chatNotify) + .build(); + } + + public void updateNotifySetting(NotifyType notifyType, boolean flag) { + switch (notifyType) { + case ACCOUNT_BOOK -> this.accountBookNotify = flag; + case FEED -> this.feedNotify = flag; + case CHAT -> this.chatNotify = flag; + } + } + + public boolean isAccountBookNotify() { + return accountBookNotify; + } + + public boolean isFeedNotify() { + return feedNotify; + } + + public boolean isChatNotify() { + return chatNotify; + } + + public enum NotifyType { + ACCOUNT_BOOK, FEED, CHAT + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java new file mode 100644 index 000000000..6bf99ffe3 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/domain/User.java @@ -0,0 +1,126 @@ +package kr.co.pennyway.domain.domains.user.domain; + +import jakarta.persistence.*; +import kr.co.pennyway.domain.common.converter.ProfileVisibilityConverter; +import kr.co.pennyway.domain.common.converter.RoleConverter; +import kr.co.pennyway.domain.common.model.DateAuditable; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Objects; + +@Entity +@Getter +@Table(name = "user") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@DynamicInsert +@SQLRestriction("deleted_at IS NULL") +@SQLDelete(sql = "UPDATE user SET deleted_at = NOW() WHERE id = ?") +public class User extends DateAuditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String username; + private String name; + @ColumnDefault("NULL") + private String password; + @ColumnDefault("NULL") + private LocalDateTime passwordUpdatedAt; + @ColumnDefault("NULL") + private String profileImageUrl; + private String phone; + @Convert(converter = RoleConverter.class) + private Role role; + @Convert(converter = ProfileVisibilityConverter.class) + private ProfileVisibility profileVisibility; + @ColumnDefault("false") + private boolean locked; + @Embedded + private NotifySetting notifySetting; + @ColumnDefault("NULL") + private LocalDateTime deletedAt; + + @Builder + private User(String username, String name, String password, LocalDateTime passwordUpdatedAt, String profileImageUrl, String phone, Role role, + ProfileVisibility profileVisibility, NotifySetting notifySetting, boolean locked) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } else if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.username = username; + this.name = name; + this.password = password; + this.passwordUpdatedAt = passwordUpdatedAt; + this.profileImageUrl = profileImageUrl; + this.phone = Objects.requireNonNull(phone, "phone은 null이 될 수 없습니다."); + this.role = Objects.requireNonNull(role, "role은 null이 될 수 없습니다."); + this.profileVisibility = Objects.requireNonNull(profileVisibility, "profileVisibility는 null이 될 수 없습니다."); + this.notifySetting = Objects.requireNonNull(notifySetting, "notifySetting은 null이 될 수 없습니다."); + this.locked = locked; + } + + public void updatePassword(String password) { + if (!StringUtils.hasText(password)) { + throw new IllegalArgumentException("password는 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.password = password; + this.passwordUpdatedAt = LocalDateTime.now(); + } + + public void updateName(String name) { + if (!StringUtils.hasText(name)) { + throw new IllegalArgumentException("name은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.name = name; + } + + public void updateUsername(String username) { + if (!StringUtils.hasText(username)) { + throw new IllegalArgumentException("username은 null이거나 빈 문자열이 될 수 없습니다."); + } + + this.username = username; + } + + public void updateProfileImageUrl(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void updatePhone(String phone) { + this.phone = phone; + } + + public boolean isGeneralSignedUpUser() { + return password != null; + } + + public boolean isLocked() { + return locked; + } + + @Override + public String toString() { + return "User{" + + "id=" + id + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", role=" + role + + ", deletedAt=" + deletedAt + + '}'; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java new file mode 100644 index 000000000..a44a5b225 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorCode.java @@ -0,0 +1,49 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.BaseErrorCode; +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.ReasonCode; +import kr.co.pennyway.common.exception.StatusCode; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum UserErrorCode implements BaseErrorCode { + /* 400 BAD_REQUEST */ + NOT_MATCHED_PASSWORD(StatusCode.BAD_REQUEST, ReasonCode.INVALID_REQUEST, "비밀번호가 일치하지 않습니다."), + PASSWORD_NOT_CHANGED(StatusCode.BAD_REQUEST, ReasonCode.CLIENT_ERROR, "현재 비밀번호와 동일한 비밀번호로 변경할 수 없습니다."), + + /* 401 UNAUTHORIZED */ + INVALID_USERNAME_OR_PASSWORD(StatusCode.UNAUTHORIZED, ReasonCode.MISSING_OR_INVALID_AUTHENTICATION_CREDENTIALS, "유효하지 않은 아이디 또는 비밀번호입니다."), + + /* 403 FORBIDDEN */ + ALREADY_WITHDRAWAL(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "이미 탈퇴한 유저입니다."), + DO_NOT_GENERAL_SIGNED_UP(StatusCode.FORBIDDEN, ReasonCode.ACCESS_TO_THE_REQUESTED_RESOURCE_IS_FORBIDDEN, "일반 회원가입 계정이 아닙니다."), + + /* 404 NOT_FOUND */ + NOT_ALLOCATED_PROFILE_IMAGE(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "프로필 이미지가 할당되지 않았습니다."), + + /* 409 Conflict */ + ALREADY_SIGNUP(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 회원가입한 유저입니다."), + ALREADY_EXIST_USERNAME(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 아이디입니다."), + ALREADY_EXIST_PHONE(StatusCode.CONFLICT, ReasonCode.RESOURCE_ALREADY_EXISTS, "이미 존재하는 휴대폰 번호입니다."), + + /* 404 NOT_FOUND */ + NOT_FOUND(StatusCode.NOT_FOUND, ReasonCode.REQUESTED_RESOURCE_NOT_FOUND, "유저를 찾을 수 없습니다."), + + /* 422 UNPROCESSABLE_ENTITY */ + INVALID_NOTIFY_TYPE(StatusCode.UNPROCESSABLE_CONTENT, ReasonCode.TYPE_MISMATCH_ERROR_IN_REQUEST_BODY, "유효하지 않은 알림 타입입니다."); + + private final StatusCode statusCode; + private final ReasonCode reasonCode; + private final String message; + + @Override + public CausedBy causedBy() { + return CausedBy.of(statusCode, reasonCode); + } + + @Override + public String getExplainError() throws NoSuchFieldError { + return message; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java new file mode 100644 index 000000000..e0f352ed4 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/exception/UserErrorException.java @@ -0,0 +1,21 @@ +package kr.co.pennyway.domain.domains.user.exception; + +import kr.co.pennyway.common.exception.CausedBy; +import kr.co.pennyway.common.exception.GlobalErrorException; + +public class UserErrorException extends GlobalErrorException { + private final UserErrorCode userErrorCode; + + public UserErrorException(UserErrorCode userErrorCode) { + super(userErrorCode); + this.userErrorCode = userErrorCode; + } + + public CausedBy causedBy() { + return userErrorCode.causedBy(); + } + + public String getExplainError() { + return userErrorCode.getExplainError(); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java new file mode 100644 index 000000000..8db6e9c33 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/repository/UserRepository.java @@ -0,0 +1,24 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import kr.co.pennyway.domain.common.repository.ExtendedRepository; +import kr.co.pennyway.domain.domains.user.domain.User; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +public interface UserRepository extends ExtendedRepository { + Optional findByPhone(String phone); + + Optional findByUsername(String username); + + boolean existsByUsername(String username); + + boolean existsByPhone(String phone); + + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE User u SET u.deletedAt = NOW() WHERE u.id = :userId") + void deleteByIdInQuery(Long userId); +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java new file mode 100644 index 000000000..225ac2597 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/service/UserService.java @@ -0,0 +1,60 @@ +package kr.co.pennyway.domain.domains.user.service; + +import kr.co.pennyway.common.annotation.DomainService; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@DomainService +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + + @Transactional + public User createUser(User user) { + return userRepository.save(user); + } + + @Transactional(readOnly = true) + public Optional readUser(Long id) { + return userRepository.findById(id); + } + + @Transactional(readOnly = true) + public Optional readUserByPhone(String phone) { + return userRepository.findByPhone(phone); + } + + @Transactional(readOnly = true) + public Optional readUserByUsername(String username) { + return userRepository.findByUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistUser(Long id) { + return userRepository.existsById(id); + } + + @Transactional(readOnly = true) + public boolean isExistUsername(String username) { + return userRepository.existsByUsername(username); + } + + @Transactional(readOnly = true) + public boolean isExistPhone(String phone) { + return userRepository.existsByPhone(phone); + } + + @Transactional + public void deleteUser(User user) { + userRepository.delete(user); + } + + @Transactional + public void deleteUser(Long userId) { + userRepository.deleteByIdInQuery(userId); + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java new file mode 100644 index 000000000..3d4e505b9 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/ProfileVisibility.java @@ -0,0 +1,34 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ProfileVisibility implements LegacyCommonType { + PUBLIC("0", "전체 공개"), + FRIEND("1", "친구 공개"), + PRIVATE("2", "비공개"); + + private final String code; + private final String type; + + @Override + public String getCode() { + return code; + } + + public String getType() { + return type; + } + + @JsonValue + public String createJson() { + return name(); + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java new file mode 100644 index 000000000..1d46744f6 --- /dev/null +++ b/pennyway-domain/src/main/java/kr/co/pennyway/domain/domains/user/type/Role.java @@ -0,0 +1,42 @@ +package kr.co.pennyway.domain.domains.user.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; +import kr.co.pennyway.domain.common.converter.LegacyCommonType; +import lombok.RequiredArgsConstructor; + +import java.util.Map; +import java.util.stream.Stream; + +import static java.util.stream.Collectors.toMap; + +@RequiredArgsConstructor +public enum Role implements LegacyCommonType { + ADMIN("0", "ROLE_ADMIN"), + USER("1", "ROLE_USER"); + + private static final Map stringToEnum = + Stream.of(values()).collect(toMap(Object::toString, e -> e)); + private final String code; + private final String type; + + @JsonCreator + public static Role fromString(String type) { + return stringToEnum.get(type.toUpperCase()); + } + + @Override + public String getCode() { + return code; + } + + @JsonValue + public String getType() { + return type; + } + + @Override + public String toString() { + return type; + } +} diff --git a/pennyway-domain/src/main/resources/application-domain.yml b/pennyway-domain/src/main/resources/application-domain.yml new file mode 100644 index 000000000..170b91654 --- /dev/null +++ b/pennyway-domain/src/main/resources/application-domain.yml @@ -0,0 +1,88 @@ +spring: + profiles: + group: + local: common + dev: common + + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3300/pennyway?serverTimezone=Asia/Seoul&characterEncoding=utf8&postfileSQL=true&logger=Slf4JLogger&rewriteBatchedStatements=true} + username: ${DB_USER_NAME:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + + data.redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + autoconfigure: + exclude: + - org.redisson.spring.starter.RedissonAutoConfigurationV2 + +--- +spring: + config: + activate: + on-profile: local + + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + ROOT: INFO + org.hibernate: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + org.hibernate.sql: debug + org.hibernate.type: trace + com.zaxxer.hikari.HikariConfig: DEBUG + org.springframework.orm: TRACE + org.springframework.transaction: TRACE + com.zaxxer.hikari: TRACE + com.mysql.cj.jdbc: TRACE + +--- +spring: + config: + activate: + on-profile: dev + + jpa: + database: MySQL + open-in-view: false + generate-ddl: false + hibernate: + ddl-auto: none + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +--- +spring: + config: + activate: + on-profile: test + + jpa: + database: MySQL + open-in-view: false + generate-ddl: true + hibernate: + ddl-auto: create + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + +logging: + level: + org.springframework.jdbc: debug \ No newline at end of file diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java new file mode 100644 index 000000000..9bb610d32 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/phone/PhoneValidationDaoTest.java @@ -0,0 +1,83 @@ +package kr.co.pennyway.domain.common.redis.phone; + +import kr.co.pennyway.domain.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@DisplayName("휴대폰 검증 Redis 서비스 테스트") +@SpringBootTest(classes = {PhoneCodeRepository.class, RedisConfig.class}) +@ActiveProfiles("local") +public class PhoneValidationDaoTest extends ContainerRedisTestConfig { + @Autowired + private PhoneCodeRepository phoneCodeRepository; + private String phone; + private String code; + private PhoneCodeKeyType codeType; + + @BeforeEach + void setUp() { + phone = "01012345678"; + code = "123456"; + codeType = PhoneCodeKeyType.SIGN_UP; + } + + @AfterEach + void tearDown() { + phoneCodeRepository.delete(phone, codeType); + } + + @Test + @DisplayName("Redis에 데이터를 저장하면 {'codeType:phone':code}로 데이터가 저장된다.") + void codeSaveTest() { + // given + phoneCodeRepository.save(phone, code, codeType); + + // when + String savedCode = phoneCodeRepository.findCodeByPhone(phone, codeType); + + // then + assertEquals(code, savedCode); + System.out.println("savedCode = " + savedCode); + } + + @Test + @DisplayName("Redis에 'codeType:phone'에 해당하는 값이 없으면 NullPointerException이 발생한다.") + void codeReadError() { + // given + phoneCodeRepository.delete(phone, codeType); + String wrongPhone = "01087654321"; + + // when - then + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(wrongPhone, codeType)); + } + + @Test + @DisplayName("Redis에 저장된 데이터를 삭제하면 해당 데이터가 삭제된다.") + void codeRemoveTest() { + // given + phoneCodeRepository.save(phone, code, codeType); + + // when + phoneCodeRepository.delete(phone, codeType); + + // then + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); + } + + @Test + @DisplayName("저장되지 않은 데이터를 삭제해도 에러가 발생하지 않는다.") + void codeRemoveError() { + // when - thengi + assertThrows(NullPointerException.class, () -> phoneCodeRepository.findCodeByPhone(phone, codeType)); + phoneCodeRepository.delete(phone, codeType); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java new file mode 100644 index 000000000..204755be6 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redis/refresh/RefreshTokenServiceIntegrationTest.java @@ -0,0 +1,89 @@ +package kr.co.pennyway.domain.common.redis.refresh; + +import kr.co.pennyway.domain.config.ContainerRedisTestConfig; +import kr.co.pennyway.domain.config.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.data.redis.DataRedisTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertFalse; + +@Slf4j +@DataRedisTest(properties = "spring.config.location=classpath:application-domain.yml") +@ContextConfiguration(classes = {RedisConfig.class, RefreshTokenServiceImpl.class}) +@ActiveProfiles("test") +public class RefreshTokenServiceIntegrationTest extends ContainerRedisTestConfig { + @Autowired + private RefreshTokenRepository refreshTokenRepository; + private RefreshTokenService refreshTokenService; + + @BeforeEach + void setUp() { + this.refreshTokenService = new RefreshTokenServiceImpl(refreshTokenRepository); + } + + @Test + @DisplayName("리프레시 토큰 저장 테스트") + void saveTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + + // when + refreshTokenService.save(refreshToken); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + assertEquals("저장된 리프레시 토큰이 일치하지 않습니다.", refreshToken, savedRefreshToken); + log.info("저장된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("리프레시 토큰 갱신 테스트") + void refreshTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + refreshTokenService.refresh(1L, "refreshToken", "newRefreshToken"); + + // then + RefreshToken savedRefreshToken = refreshTokenRepository.findById(1L).orElse(null); + assertEquals("갱신된 리프레시 토큰이 일치하지 않습니다.", "newRefreshToken", savedRefreshToken.getToken()); + log.info("갱신된 리프레시 토큰 정보 : {}", savedRefreshToken); + } + + @Test + @DisplayName("요청한 리프레시 토큰과 저장된 리프레시 토큰이 다를 경우 토큰이 탈취되었다고 판단하여 값 삭제") + void validateTokenTest() { + // given + RefreshToken refreshToken = RefreshToken.builder() + .userId(1L) + .token("refreshToken") + .ttl(1000L) + .build(); + refreshTokenService.save(refreshToken); + + // when + IllegalStateException exception = assertThrows(IllegalStateException.class, () -> refreshTokenService.refresh(1L, "anotherRefreshToken", "newRefreshToken")); + + // then + assertEquals("리프레시 토큰이 탈취되었을 때 예외가 발생해야 합니다.", "refresh token mismatched", exception.getMessage()); + assertFalse("리프레시 토큰이 탈취되었을 때 저장된 리프레시 토큰이 삭제되어야 합니다.", refreshTokenRepository.existsById(1L)); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java new file mode 100644 index 000000000..f637ff6dd --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/common/redisson/CouponDecreaseLockTest.java @@ -0,0 +1,93 @@ +package kr.co.pennyway.domain.common.redisson; + +import kr.co.pennyway.domain.config.ContainerDBTestConfig; +import kr.co.pennyway.domain.config.DomainIntegrationTest; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.coupon.TestCoupon; +import kr.co.pennyway.domain.domains.coupon.TestCouponDecreaseService; +import kr.co.pennyway.domain.domains.coupon.TestCouponRepository; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.context.annotation.Import; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@Slf4j +@DomainIntegrationTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@EntityScan(basePackageClasses = {TestCoupon.class}) +@Import(TestJpaConfig.class) +public class CouponDecreaseLockTest extends ContainerDBTestConfig { + @Autowired + private TestCouponDecreaseService testCouponDecreaseService; + @Autowired + private TestCouponRepository testCouponRepository; + private TestCoupon coupon; + + @BeforeEach + void setUp() { + coupon = new TestCoupon("COUPON_001", 300L); + testCouponRepository.save(coupon); + } + + @Test + @Order(1) + @Disabled + void 쿠폰차감_분산락_적용_동시성_300명_테스트() throws InterruptedException { + // given + int threadCount = 300; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + testCouponDecreaseService.decreaseStockWithLock(coupon.getId(), "COUPON_001"); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new); + assertThat(persistedCoupon.getAvailableStock()).isZero(); + log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock()); + } + + @Test + @Order(2) + @Disabled + void 쿠폰차감_분산락_미적용_동시성_300명_테스트() throws InterruptedException { + // given + int threadCount = 300; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + testCouponDecreaseService.decreaseStock(coupon.getId()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + // then + TestCoupon persistedCoupon = testCouponRepository.findById(coupon.getId()).orElseThrow(IllegalArgumentException::new); + log.debug("잔여 쿠폰 수량: " + persistedCoupon.getAvailableStock()); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java new file mode 100644 index 000000000..fdfd1817c --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerDBTestConfig.java @@ -0,0 +1,47 @@ +package kr.co.pennyway.domain.config; + +import com.redis.testcontainers.RedisContainer; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public class ContainerDBTestConfig { + private static final String REDIS_CONTAINER_IMAGE = "redis:7.2.4-alpine"; + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final RedisContainer REDIS_CONTAINER; + private static final MySQLContainer MYSQL_CONTAINER; + + static { + REDIS_CONTAINER = + new RedisContainer(DockerImageName.parse(REDIS_CONTAINER_IMAGE)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") + .withReuse(true); + + REDIS_CONTAINER.start(); + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java new file mode 100644 index 000000000..4af33fca1 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerMySqlTestConfig.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@ActiveProfiles("test") +public class ContainerMySqlTestConfig { + private static final String MYSQL_CONTAINER_IMAGE = "mysql:8.0.26"; + + private static final MySQLContainer MYSQL_CONTAINER; + + static { + MYSQL_CONTAINER = + new MySQLContainer<>(DockerImageName.parse(MYSQL_CONTAINER_IMAGE)) + .withDatabaseName("pennyway") + .withUsername("root") + .withPassword("testpass") + .withCommand("--sql_mode=STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION") + .withReuse(true); + + MYSQL_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", () -> String.format("jdbc:mysql://%s:%s/pennyway?serverTimezone=UTC&characterEncoding=utf8", MYSQL_CONTAINER.getHost(), MYSQL_CONTAINER.getMappedPort(3306))); + registry.add("spring.datasource.username", () -> "root"); + registry.add("spring.datasource.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java new file mode 100644 index 000000000..95d0a0c31 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/ContainerRedisTestConfig.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.config; + +import org.junit.jupiter.api.DisplayName; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +@DisplayName("Container Redis 설정") +public abstract class ContainerRedisTestConfig { + private static final String REDIS_CONTAINER_NAME = "redis:7.2.4-alpine"; + private static final GenericContainer REDIS_CONTAINER; + + static { + REDIS_CONTAINER = + new GenericContainer<>(DockerImageName.parse(REDIS_CONTAINER_NAME)) + .withExposedPorts(6379) + .withCommand("redis-server", "--requirepass testpass") + .withReuse(true); + + REDIS_CONTAINER.start(); + } + + @DynamicPropertySource + public static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> String.valueOf(REDIS_CONTAINER.getMappedPort(6379))); + registry.add("spring.data.redis.password", () -> "testpass"); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java new file mode 100644 index 000000000..bbfc35f0e --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationProfileResolver.java @@ -0,0 +1,12 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.lang.NonNull; +import org.springframework.test.context.ActiveProfilesResolver; + +public class DomainIntegrationProfileResolver implements ActiveProfilesResolver { + @Override + @NonNull + public String[] resolve(@NonNull Class testClass) { + return new String[]{"common", "domain"}; + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java new file mode 100644 index 000000000..7f22c7d7f --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTest.java @@ -0,0 +1,14 @@ +package kr.co.pennyway.domain.config; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@SpringBootTest(classes = DomainIntegrationTestConfig.class) +@ActiveProfiles(profiles = {"test"}, resolver = DomainIntegrationProfileResolver.class) +@Documented +public @interface DomainIntegrationTest { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java new file mode 100644 index 000000000..4d0f499d7 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/DomainIntegrationTestConfig.java @@ -0,0 +1,16 @@ +package kr.co.pennyway.domain.config; + +import kr.co.pennyway.common.PennywayCommonApplication; +import kr.co.pennyway.domain.DomainPackageLocation; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan( + basePackageClasses = { + DomainPackageLocation.class, + PennywayCommonApplication.class + } +) +public class DomainIntegrationTestConfig { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java new file mode 100644 index 000000000..793f11181 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/config/TestJpaConfig.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.domain.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +@TestConfiguration +public class TestJpaConfig { + @PersistenceContext + private EntityManager em; + + @Bean + @ConditionalOnMissingBean + public JPAQueryFactory testJpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java new file mode 100644 index 000000000..878c4dac1 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCoupon.java @@ -0,0 +1,40 @@ +package kr.co.pennyway.domain.domains.coupon; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TestCoupon { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + private String name; + + /** + * 사용 가능 재고수량 + */ + private long availableStock; + + public TestCoupon(String name, long availableStock) { + this.name = name; + this.availableStock = availableStock; + } + + public void decreaseStock() { + validateStock(); + this.availableStock--; + } + + private void validateStock() { + if (availableStock < 1) { + throw new IllegalArgumentException("재고가 부족합니다."); + } + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java new file mode 100644 index 000000000..9cad14ca1 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponDecreaseService.java @@ -0,0 +1,30 @@ +package kr.co.pennyway.domain.domains.coupon; + +import kr.co.pennyway.domain.common.redisson.DistributedLock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +@Component +@ActiveProfiles("test") +@RequiredArgsConstructor +public class TestCouponDecreaseService { + private final TestCouponRepository couponRepository; + + @Transactional + public void decreaseStock(Long couponId) { + TestCoupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다.")); + + coupon.decreaseStock(); + } + + @DistributedLock(key = "#lockName") + public void decreaseStockWithLock(Long couponId, String lockName) { + TestCoupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰입니다.")); + + coupon.decreaseStock(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java new file mode 100644 index 000000000..bccf946ff --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/coupon/TestCouponRepository.java @@ -0,0 +1,8 @@ +package kr.co.pennyway.domain.domains.coupon; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +public interface TestCouponRepository extends JpaRepository { +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java new file mode 100644 index 000000000..c703263b8 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/NotificationRepositoryUnitTest.java @@ -0,0 +1,190 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.*; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) +@ActiveProfiles("test") +public class NotificationRepositoryUnitTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + @Autowired + private NotificationRepository notificationRepository; + + @Test + @Transactional + @DisplayName("여러 사용자에게 일일 소비 알림을 저장할 수 있다.") + public void saveDailySpendingAnnounceInBulk() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + User user3 = userRepository.save(createUser("test")); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId(), user3.getId()), + Announcement.DAILY_SPENDING + ); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertEquals("알림 타입이 일일 소비 알림이어야 한다.", Announcement.DAILY_SPENDING, notification.getAnnouncement()); + }); + } + + @Test + @Transactional + @DisplayName("이미 당일에 알림을 받은 사용자에게 데이터가 중복 저장되지 않아야 한다.") + public void notSaveDuplicateNotification() { + // given + User user1 = userRepository.save(createUser("jayang")); + User user2 = userRepository.save(createUser("mock")); + + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user1) + .build(); + notificationRepository.save(notification); + + // when + notificationRepository.saveDailySpendingAnnounceInBulk( + List.of(user1.getId(), user2.getId()), + Announcement.DAILY_SPENDING + ); + + // then + List notifications = notificationRepository.findAll(); + log.debug("notifications: {}", notifications); + assertEquals("알림이 중복 저장되지 않아야 한다.", 2, notifications.size()); + } + + @Test + @DisplayName("사용자의 여러 알림을 읽음 처리할 수 있다.") + void updateReadAtSuccessfully() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + + // when + notificationRepository.updateReadAtByIdsInBulk(notifications.stream().map(Notification::getId).toList()); + + // then + notificationRepository.findAll().forEach(notification -> { + log.info("notification: {}", notification); + assertNotNull("알림이 읽음 처리 되어야 한다.", notification.getReadAt()); + }); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림 개수를 조회할 수 있다.") + void countUnreadNotificationsByIds() { + // given + User user = userRepository.save(createUser("jayang")); + + List notifications = notificationRepository.saveAll(List.of( + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(), + new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build())); + List ids = notifications.stream().map(Notification::getId).toList(); + + notificationRepository.updateReadAtByIdsInBulk(List.of(ids.get(1))); + + // when + long count = notificationRepository.countUnreadNotificationsByIds( + user.getId(), + notifications.stream().map(Notification::getId).toList() + ); + + // then + assertEquals("읽지 않은 알림 개수가 2개여야 한다.", 2L, count); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하면 true를 반환한다.") + void existsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertTrue("읽지 않은 알림이 존재하면 true를 반환해야 한다.", exists); + } + + @Test + @DisplayName("사용자의 읽지 않은 알림이 존재하지 않으면 false를 반환한다.") + void notExistsTopByReceiver_IdAndReadAtIsNull() { + // given + User user = userRepository.save(createUser("jayang")); + + Notification notification1 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification2 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + Notification notification3 = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + + ReflectionTestUtils.setField(notification1, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification2, "readAt", LocalDateTime.now()); + ReflectionTestUtils.setField(notification3, "readAt", LocalDateTime.now()); + + notificationRepository.saveAll(List.of(notification1, notification2, notification3)); + + // when + boolean exists = notificationRepository.existsUnreadNotification(user.getId()); + + // then + assertFalse("읽지 않은 알림이 존재하지 않으면 false를 반환해야 한다.", exists); + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java new file mode 100644 index 000000000..66aaaf358 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/notification/repository/ReadNotificationsSliceUnitTest.java @@ -0,0 +1,121 @@ +package kr.co.pennyway.domain.domains.notification.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.notification.domain.Notification; +import kr.co.pennyway.domain.domains.notification.service.NotificationService; +import kr.co.pennyway.domain.domains.notification.type.Announcement; +import kr.co.pennyway.domain.domains.notification.type.NoticeType; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.springframework.test.util.AssertionErrors.assertEquals; +import static org.springframework.test.util.AssertionErrors.assertTrue; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class, NotificationService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(TestJpaConfig.class) +@ActiveProfiles("test") +public class ReadNotificationsSliceUnitTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private NotificationService notificationService; + + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @Transactional + @DisplayName("특정 사용자의 알림 목록을 슬라이스로 조회하며, 결과는 최신순으로 정렬되어야 한다.") + public void readNotificationsSliceSorted() { + // given + User user = userRepository.save(createUser("jayang")); + Pageable pa = PageRequest.of(0, 5, Sort.by(Sort.Order.desc("notification.createdAt"))); + + List notifications = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + Notification notification = new Notification.Builder(NoticeType.ANNOUNCEMENT, Announcement.DAILY_SPENDING, user).build(); + notifications.add(notification); + } + bulkInsertNotifications(notifications); + + // when + Slice result = notificationService.readNotificationsSlice(user.getId(), pa); + + // then + assertEquals("Slice 데이터 개수는 5개여야 한다.", 5, result.getNumberOfElements()); + assertTrue("hasNext()는 true여야 한다.", result.hasNext()); + for (int i = 0; i < result.getNumberOfElements() - 1; i++) { + Notification current = result.getContent().get(i); + Notification next = result.getContent().get(i + 1); + log.debug("current: {}, next: {}", current.getCreatedAt(), next.getCreatedAt()); + log.debug("notification: {}", current); + assert current.getCreatedAt().isAfter(next.getCreatedAt()); + } + } + + private User createUser(String name) { + return User.builder() + .username("test") + .name(name) + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + private void bulkInsertNotifications(List notifications) { + String sql = String.format(""" + INSERT INTO `%s` (type, announcement, created_at, updated_at, receiver, receiver_name) + VALUES (:type, :announcement, :createdAt, :updatedAt, :receiver, :receiverName); + """, "notification"); + + LocalDateTime date = LocalDateTime.now(); + SqlParameterSource[] params = new SqlParameterSource[notifications.size()]; + + for (int i = 0; i < notifications.size(); i++) { + Notification notification = notifications.get(i); + params[i] = new MapSqlParameterSource() + .addValue("type", notification.getType().getCode()) + .addValue("announcement", notification.getAnnouncement().getCode()) + .addValue("createdAt", date) + .addValue("updatedAt", date) + .addValue("receiver", notification.getReceiver().getId()) + .addValue("receiverName", notification.getReceiverName()); + date = date.minusDays(1); + } + + jdbcTemplate.batchUpdate(sql, params); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java new file mode 100644 index 000000000..946dc36c4 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/oauth/repository/OauthRepositoryTest.java @@ -0,0 +1,81 @@ +package kr.co.pennyway.domain.domains.oauth.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public class OauthRepositoryTest extends ContainerMySqlTestConfig { + @Autowired + private UserRepository userRepository; + + @Autowired + private OauthRepository oauthRepository; + + @MockBean + private JPAQueryFactory jpaQueryFactory; + + private User user; + + @Test + @DisplayName("soft delete된 다른 user_id를 가지면서, 같은 oauth_id, provider를 갖는 정보가 존재해도, 하나의 결과만을 반환한다.") + @Transactional + public void test() { + // given + User user = createUser(); + Oauth oauth = Oauth.of(Provider.KAKAO, "oauth_id", user); + + User newUser = createUser(); + Oauth newOauth = Oauth.of(Provider.KAKAO, "oauth_id", newUser); + + // when (소셜 회원가입 ⇾ 회원 탈퇴 ⇾ 동일 정보 소셜 회원가입 ⇾ 조회 성공) + userRepository.save(user); + oauthRepository.save(oauth); + log.debug("user: {}, oauth: {}", user, oauth); + + userRepository.delete(user); + oauthRepository.delete(oauth); + + userRepository.save(newUser); + oauthRepository.save(newOauth); + log.debug("newUser: {}, newOauth: {}", newUser, newOauth); + + // then + assertDoesNotThrow(() -> oauthRepository.findByOauthIdAndProviderAndDeletedAtIsNull(newOauth.getOauthId(), newOauth.getProvider())); + } + + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java new file mode 100644 index 000000000..326f78775 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/target/repository/RecentTargetAmountSearchTest.java @@ -0,0 +1,124 @@ +package kr.co.pennyway.domain.domains.target.repository; + +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.repository.UserRepository; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = JpaConfig.class) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(TestJpaConfig.class) +public class RecentTargetAmountSearchTest extends ContainerMySqlTestConfig { + private final Collection mockTargetAmounts = List.of( + MockTargetAmount.of(10000, true, LocalDateTime.of(2021, 1, 1, 0, 0, 0), LocalDateTime.of(2021, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(20000, true, LocalDateTime.of(2022, 5, 1, 0, 0, 0), LocalDateTime.of(2022, 5, 1, 0, 0, 0)), + MockTargetAmount.of(30000, true, LocalDateTime.of(2023, 7, 1, 0, 0, 0), LocalDateTime.of(2023, 7, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 2, 1, 0, 0, 0), LocalDateTime.of(2024, 2, 1, 0, 0, 0)) + ); + private final Collection mockTargetAmountsMinus = List.of( + MockTargetAmount.of(-1, true, LocalDateTime.of(2022, 3, 1, 0, 0, 0), LocalDateTime.of(2022, 3, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, false, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)), + MockTargetAmount.of(-1, true, LocalDateTime.of(2024, 1, 1, 0, 0, 0), LocalDateTime.of(2024, 1, 1, 0, 0, 0)) + ); + @Autowired + private UserRepository userRepository; + @Autowired + private TargetAmountRepository targetAmountRepository; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @Test + @DisplayName("사용자의 가장 최근 목표 금액을 조회할 수 있다.") + @Transactional + public void 가장_최근_사용자_목표_금액_조회() { + // given + User user = userRepository.save(createUser()); + bulkInsertTargetAmount(user, mockTargetAmounts); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> assertEquals(targetAmount.getAmount(), 30000), + () -> Assertions.fail("최근 목표 금액이 존재하지 않습니다.") + ); + } + + @Test + @DisplayName("사용자의 가장 최근 목표 금액이 존재하지 않으면 Optional.empty()를 반환한다.") + @Transactional + public void 가장_최근_사용자_목표_금액_미존재() { + // given + User user = userRepository.save(createUser()); + bulkInsertTargetAmount(user, mockTargetAmountsMinus); + + // when - then + targetAmountRepository.findRecentOneByUserId(user.getId()) + .ifPresentOrElse( + targetAmount -> Assertions.fail("최근 목표 금액이 존재합니다."), + () -> log.info("최근 목표 금액이 존재하지 않습니다.") + ); + } + + private void bulkInsertTargetAmount(User user, Collection targetAmounts) { + String sql = String.format(""" + INSERT INTO `%s` (amount, is_read, user_id, created_at, updated_at) + VALUES (:amount, true, :userId, :createdAt, :updatedAt) + """, "target_amount"); + SqlParameterSource[] params = targetAmounts.stream() + .map(mockTargetAmount -> new MapSqlParameterSource() + .addValue("amount", mockTargetAmount.amount) + .addValue("userId", user.getId()) + .addValue("createdAt", mockTargetAmount.createdAt) + .addValue("updatedAt", mockTargetAmount.updatedAt)) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private User createUser() { + return User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("010-1234-5678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + private record MockTargetAmount(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + public static MockTargetAmount of(int amount, boolean isRead, LocalDateTime createdAt, LocalDateTime updatedAt) { + return new MockTargetAmount(amount, isRead, createdAt, updatedAt); + } + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java new file mode 100644 index 000000000..6742972dc --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserExtendedRepositoryTest.java @@ -0,0 +1,297 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Predicate; +import kr.co.pennyway.domain.common.repository.QueryHandler; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.config.TestJpaConfig; +import kr.co.pennyway.domain.domains.oauth.domain.Oauth; +import kr.co.pennyway.domain.domains.oauth.domain.QOauth; +import kr.co.pennyway.domain.domains.oauth.type.Provider; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.QUser; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +import static java.time.LocalDateTime.now; +import static org.springframework.test.util.AssertionErrors.*; + +@Slf4j +@DataJpaTest(properties = {"spring.jpa.hibernate.ddl-auto=create"}) +@ContextConfiguration(classes = {JpaConfig.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@Import(TestJpaConfig.class) +public class UserExtendedRepositoryTest extends ContainerMySqlTestConfig { + private static final String USER_TABLE = "user"; + private static final String OAUTH_TABLE = "oauth"; + + private final QUser qUser = QUser.user; + private final QOauth qOauth = QOauth.oauth; + + @Autowired + private UserRepository userRepository; + @Autowired + private NamedParameterJdbcTemplate jdbcTemplate; + + @BeforeEach + public void setUp() { + List users = getRandomUsers(); + bulkInsertUser(users); + + users = userRepository.findAll(); + + List oauths = getRandomOauths(users); + bulkInsertOauth(oauths); + } + + @Test + @DisplayName(""" + Entity findList 테스트: 이름이 양재서고, 일반 회원가입 이력이 존재하면서, lock이 걸려있지 않은 사용자 정보를 조회한다. + 이때, 결과는 id 내림차순으로 정렬한다. + """) + @Transactional + public void findList() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qUser.password.isNotNull()) + .and(qUser.locked.isFalse()); + + QueryHandler queryHandler = null; // queryHandler는 사용하지 않으므로 null로 설정 + + Sort sort = Sort.by(Sort.Order.desc("id")); + + // when + List users = userRepository.findList(predicate, queryHandler, sort); + + // then + Long maxValue = 100000L; + for (User user : users) { + log.info("user: {}", user); + + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertTrue("일반 회원가입 이력이 존재해야 한다.", user.isGeneralSignedUpUser()); + assertFalse("lock이 걸려있지 않아야 한다.", user.isLocked()); + + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Entity findPage 테스트: 이름이 양재서고, Kakao로 가입한 Oauth 정보를 조회한다. + 단, 결과는 처음 5개만 조회하며, id 내림차순으로 정렬한다. + """) + @Transactional + public void findPage() { + // given + Predicate predicate = qUser.name.eq("양재서") + .and(qOauth.provider.eq(Provider.KAKAO)); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = Sort.by(Sort.Order.desc("user.id")); + + int pageNumber = 0, pageSize = 5; + Pageable pageable = PageRequest.of(pageNumber, pageSize, sort); + + // when + Page users = userRepository.findPage(predicate, queryHandler, pageable); + + // then + assertEquals("users의 크기는 5여야 한다.", 5, users.getSize()); + Long maxValue = 100000L; + for (User user : users.getContent()) { + log.debug("user: {}", user); + assertTrue("id는 내림차순 정렬되어야 한다.", user.getId() <= maxValue); + assertEquals("이름이 양재서여야 한다.", "양재서", user.getName()); + maxValue = user.getId(); + } + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + LinkedHashMap을 사용하여 Dto 생성자 파라미터 순서에 맞게 삽입하면, Dto의 불변성을 유지할 수 있다. + """) + @Transactional + public void selectListUseLinkedHashMap() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new LinkedHashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfo.class, bindings, queryHandler, sort); + + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.name()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.provider()); + }); + } + + @Test + @DisplayName(""" + Dto selectList 테스트: 사용자 이름이 양재서인 사용자의 username, name, phone 그리고 연동된 Oauth 정보를 조회한다. + HashMap을 사용하더라도 Dto의 setter를 명시하고 final 키워드를 제거하면 결과를 조회할 수 있다. + """) + @Transactional + public void selectListUseHashMap() { + // given + Predicate predicate = qUser.name.eq("양재서"); + + QueryHandler queryHandler = query -> query.leftJoin(qOauth).on(qUser.id.eq(qOauth.user.id)); + Sort sort = null; + + Map> bindings = new HashMap<>(); + + bindings.put("userId", qUser.id); + bindings.put("username", qUser.username); + bindings.put("name", qUser.name); + bindings.put("phone", qUser.phone); + bindings.put("oauthId", qOauth.id); + bindings.put("provider", qOauth.provider); + + // when + List userAndOauthInfos = userRepository.selectList(predicate, UserAndOauthInfoNotImmutable.class, bindings, queryHandler, sort); + + // then + userAndOauthInfos.forEach(userAndOauthInfo -> { + log.debug("userAndOauthInfo: {}", userAndOauthInfo); + assertEquals("이름이 양재서인 사용자만 조회되어야 한다.", "양재서", userAndOauthInfo.getName()); + assertEquals("provider는 KAKAO여야 한다.", Provider.KAKAO, userAndOauthInfo.getProvider()); + }); + } + + private List getRandomUsers() { + List users = new ArrayList<>(100); + List name = List.of("양재서", "이진우", "안성윤", "최희진", "아우신얀", "강병준", "이의찬", "이수민", "이주원"); + + for (int i = 0; i < 100; ++i) { + User user = User.builder() + .username("jayang" + i) + .name(name.get(i % name.size())) + .password((i % 2 == 0) ? null : "password" + i) + .passwordUpdatedAt((i % 2 == 0) ? null : now()) + .profileVisibility(ProfileVisibility.PUBLIC) + .phone("010-1111-1" + String.format("%03d", i)) + .role(Role.USER) + .locked((i % 10 == 0)) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + + users.add(user); + } + + return users; + } + + private List getRandomOauths(Collection users) { + List oauths = new ArrayList<>(users.size()); + + for (User user : users) { + Oauth oauth = Oauth.of(Provider.KAKAO, "providerId" + user.getId(), user); + oauths.add(oauth); + } + + return oauths; + } + + private void bulkInsertUser(Collection users) { + String sql = String.format(""" + INSERT INTO `%s` (username, name, password, password_updated_at, profile_image_url, phone, role, profile_visibility, locked, created_at, updated_at, account_book_notify, feed_notify, chat_notify, deleted_at) + VALUES (:username, :name, :password, :passwordUpdatedAt, :profileImageUrl, :phone, '1', '0', :locked, now(), now(), 1, 1, 1, :deletedAt) + """, USER_TABLE); + SqlParameterSource[] params = users.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + private void bulkInsertOauth(Collection oauths) { + String sql = String.format(""" + INSERT INTO `%s` (provider, oauth_id, user_id, created_at, deleted_at) + VALUES (1, :oauthId, :user.id, now(), NULL) + """, OAUTH_TABLE); + SqlParameterSource[] params = oauths.stream() + .map(BeanPropertySqlParameterSource::new) + .toArray(SqlParameterSource[]::new); + jdbcTemplate.batchUpdate(sql, params); + } + + public record UserAndOauthInfo(Long userId, String username, String name, String phone, Long oauthId, + Provider provider) { + @Override + public String toString() { + return "UserAndOauthInfo{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } + + @Setter + @Getter + public static class UserAndOauthInfoNotImmutable { + private Long userId; + private String username; + private String name; + private String phone; + private Long oauthId; + private Provider provider; + + public UserAndOauthInfoNotImmutable() { + } + + @Override + public String toString() { + return "UserAndOauthInfoNotImmutable{" + + "userId=" + userId + + ", username='" + username + '\'' + + ", name='" + name + '\'' + + ", phone='" + phone + '\'' + + ", oauthId=" + oauthId + + ", provider=" + provider + + '}'; + } + } +} diff --git a/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java new file mode 100644 index 000000000..f708f45c8 --- /dev/null +++ b/pennyway-domain/src/test/java/kr/co/pennyway/domain/domains/user/repository/UserSoftDeleteTest.java @@ -0,0 +1,126 @@ +package kr.co.pennyway.domain.domains.user.repository; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import kr.co.pennyway.domain.config.ContainerMySqlTestConfig; +import kr.co.pennyway.domain.config.JpaConfig; +import kr.co.pennyway.domain.domains.user.domain.NotifySetting; +import kr.co.pennyway.domain.domains.user.domain.User; +import kr.co.pennyway.domain.domains.user.service.UserService; +import kr.co.pennyway.domain.domains.user.type.ProfileVisibility; +import kr.co.pennyway.domain.domains.user.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.util.AssertionErrors.*; + +@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create") +@ContextConfiguration(classes = {JpaConfig.class, UserService.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +public class UserSoftDeleteTest extends ContainerMySqlTestConfig { + @Autowired + private UserService userService; + + @Autowired + private EntityManager em; + + @MockBean + private JPAQueryFactory jpaQueryFactory; + + private User user; + + @BeforeEach + public void setUp() { + user = User.builder() + .username("test") + .name("pannyway") + .password("test") + .phone("01012345678") + .role(Role.USER) + .profileVisibility(ProfileVisibility.PUBLIC) + .notifySetting(NotifySetting.of(true, true, true)) + .build(); + } + + @Test + @DisplayName("[명제] em.createNativeQuery를 사용해도 영속성 컨텍스트에 저장된 엔티티를 조회할 수 있다.") + @Transactional + public void findByEntityMangerUsingNativeQuery() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + Object foundUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("foundUser는 nll이 아니어야 한다.", foundUser); + assertEquals("동등성 보장에 성공해야 한다.", savedUser, foundUser); + assertTrue("동일성 보장에 성공해야 한다.", savedUser == foundUser); + System.out.println("foundUser = " + foundUser); + } + + @Test + @DisplayName("유저가 삭제되면 deletedAt이 업데이트된다.") + @Transactional + public void deleteUser() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + Object deletedUser = em.createNativeQuery("SELECT * FROM user WHERE id = ?", User.class) + .setParameter(1, userId) + .getSingleResult(); + + // then + assertNotNull("유저가 삭제되면 deletedAt이 업데이트된다. ", ((User) deletedUser).getDeletedAt()); + System.out.println("deletedUser = " + deletedUser); + } + + @Test + @DisplayName("유저가 삭제되면 findBy와 existsBy로 조회할 수 없다.") + @Transactional + public void deleteUserAndFindById() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + userService.deleteUser(savedUser); + + // then + assertFalse("유저가 삭제되면 existsById로 조회할 수 없다. ", userService.isExistUser(userId)); + assertNull("유저가 삭제되면 findById로 조회할 수 없다. ", userService.readUser(userId).orElse(null)); + System.out.println("after delete: savedUser = " + savedUser); + } + + @Test + @DisplayName("유저가 삭제되지 않으면 findById로 조회할 수 있다.") + @Transactional + public void findUserNotDeleted() { + // given + User savedUser = userService.createUser(user); + Long userId = savedUser.getId(); + + // when + User foundUser = userService.readUser(userId).orElse(null); + + // then + assertNotNull("foundUser는 null이 아니어야 한다.", foundUser); + assertEquals("foundUser는 savedUser와 같아야 한다.", savedUser, foundUser); + System.out.println("foundUser = " + foundUser); + } +} diff --git a/pennyway-domain/src/test/resources/logback-test.xml b/pennyway-domain/src/test/resources/logback-test.xml new file mode 100644 index 000000000..198192602 --- /dev/null +++ b/pennyway-domain/src/test/resources/logback-test.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java new file mode 100644 index 000000000..8cd66b54d --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatProfileUrlGenerator.java @@ -0,0 +1,26 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + if (chatroomId == null) { + chatroomId = UUIDUtil.generateUUID(); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + variablesMap.put("user_id", userId); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java new file mode 100644 index 000000000..54e446bff --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatUrlGenerator.java @@ -0,0 +1,20 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + variablesMap.put("chat_id", UUIDUtil.generateUUID()); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java new file mode 100644 index 000000000..98604d31f --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ChatroomProfileUrlGenerator.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ChatroomProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String ext, String userId, String chatId, String chatroomId) { + if (chatroomId == null) { + chatroomId = UUIDUtil.generateUUID(); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("chatroom_id", chatroomId); + return variablesMap; + } +} + diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java new file mode 100644 index 000000000..4f4e630be --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/FeedUrlGenerator.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class FeedUrlGenerator implements UrlGenerator { + @Override + public Map generate(String type, String ext, String userId, String chatroomId) { + Map variablesMap = new HashMap<>(); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + variablesMap.put("ext", ext); + variablesMap.put("feed_id", UUIDUtil.generateUUID()); + return variablesMap; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java new file mode 100644 index 000000000..57e756997 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyPattern.java @@ -0,0 +1,35 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.regex.Pattern; + +public class ObjectKeyPattern { + public static final String USER_ID_PATTERN = "([^/]+)"; + public static final String UUID_PATTERN = "([^_]+)"; + public static final String TIMESTAMP_PATTERN = "([^\\.]+)"; + public static final String EXT_PATTERN = "([^/]+)"; + public static final String FEED_ID_PATTERN = "([^/]+)"; + public static final String CHATROOM_ID_PATTERN = "([^/]+)"; + public static final String CHAT_ID_PATTERN = "([^/]+)"; + + public static final Pattern PROFILE_PATTERN = Pattern.compile( + createRegex("delete/profile/{userId}/{uuid}_{timestamp}.{ext}")); + public static final Pattern FEED_PATTERN = Pattern.compile( + createRegex("delete/feed/{feed_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHATROOM_PROFILE_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHAT_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/chat/{chat_id}/{uuid}_{timestamp}.{ext}")); + public static final Pattern CHAT_PROFILE_PATTERN = Pattern.compile( + createRegex("delete/chatroom/{chatroom_id}/chat_profile/{userId}/{uuid}_{timestamp}.{ext}")); + + private static String createRegex(String template) { + return template + .replace("{userId}", USER_ID_PATTERN) + .replace("{uuid}", UUID_PATTERN) + .replace("{timestamp}", TIMESTAMP_PATTERN) + .replace("{ext}", EXT_PATTERN) + .replace("{feed_id}", FEED_ID_PATTERN) + .replace("{chatroom_id}", CHATROOM_ID_PATTERN) + .replace("{chat_id}", CHAT_ID_PATTERN); + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java new file mode 100644 index 000000000..da14b2a7c --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ObjectKeyTemplate.java @@ -0,0 +1,18 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.Map; + +import lombok.AllArgsConstructor; + +@AllArgsConstructor +public class ObjectKeyTemplate { + private String template; + + public String apply(Map variables) { + String result = template; + for (Map.Entry entry : variables.entrySet()) { + result = result.replace("{" + entry.getKey() + "}", entry.getValue()); + } + return result; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java new file mode 100644 index 000000000..00dbb45df --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/ProfileUrlGenerator.java @@ -0,0 +1,22 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.HashMap; +import java.util.Map; + +import kr.co.pennyway.common.util.UUIDUtil; + +public class ProfileUrlGenerator implements UrlGenerator { + @Override + public Map generate(String type, String ext, String userId, String chatroomId) { + if (userId == null) { + throw new IllegalArgumentException("userId는 필수입니다."); + } + Map variablesMap = new HashMap<>(); + variablesMap.put("type", type); + variablesMap.put("ext", ext); + variablesMap.put("userId", userId); + variablesMap.put("uuid", UUIDUtil.generateUUID()); + variablesMap.put("timestamp", String.valueOf(System.currentTimeMillis())); + return variablesMap; + } +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java new file mode 100644 index 000000000..a55037584 --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGenerator.java @@ -0,0 +1,15 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import java.util.Map; + +public interface UrlGenerator { + /** + * type에 해당하는 ObjectKeyTemplate을 적용하여 ObjectKey(S3에 저장하기 위한 정적 파일의 경로 및 이름)를 생성한다. + * @param type + * @param ext + * @param userId + * @param chatroomId + * @return ObjectKey + */ + Map generate(String type, String ext, String userId, String chatroomId); +} diff --git a/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java new file mode 100644 index 000000000..ac143541e --- /dev/null +++ b/pennyway-infra/src/main/java/kr/co/pennyway/infra/client/aws/s3/UrlGeneratorFactory.java @@ -0,0 +1,23 @@ +package kr.co.pennyway.infra.client.aws.s3; + +import kr.co.pennyway.infra.common.exception.StorageErrorCode; +import kr.co.pennyway.infra.common.exception.StorageException; + +public class UrlGeneratorFactory { + public static UrlGenerator getUrlGenerator(ObjectKeyType type) { + switch (type) { + case PROFILE: + return new ProfileUrlGenerator(); + case FEED: + return new FeedUrlGenerator(); + case CHATROOM_PROFILE: + return new ChatroomProfileUrlGenerator(); + case CHAT: + return new ChatUrlGenerator(); + case CHAT_PROFILE: + return new ChatProfileUrlGenerator(); + default: + throw new StorageException(StorageErrorCode.INVALID_TYPE); + } + } +}