diff --git a/.github/workflows/docker-depoly.yaml b/.github/workflows/docker-depoly.yaml index fb544e6e..e0f4ca14 100644 --- a/.github/workflows/docker-depoly.yaml +++ b/.github/workflows/docker-depoly.yaml @@ -22,9 +22,16 @@ jobs: script: | cd /home/ubuntu/mosu - echo "${{ secrets.ENV_BLUE }}" > .env.blue - echo "${{ secrets.ENV_GREEN }}" > .env.green + echo "${{ secrets.ENV_BASE }}" > .env + echo "${{ secrets.ENV_BASE }}" > .env.blue + echo "${{ secrets.ENV_BASE }}" > .env.green + echo "${{ secrets.ENV }}" >> .env + echo "${{ secrets.ENV }}" >> .env.blue + echo "${{ secrets.ENV }}" >> .env.green + + echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.blue echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.green + ./deploy.sh diff --git a/.github/workflows/self-depoly.yaml b/.github/workflows/self-depoly.yaml index 20a1ebf8..f077bbdc 100644 --- a/.github/workflows/self-depoly.yaml +++ b/.github/workflows/self-depoly.yaml @@ -3,20 +3,68 @@ name: Docker CI/CD - Deploy on: workflow_dispatch: branches: - - test + - develop jobs: deploy: runs-on: self-hosted steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle files + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Clone external repo with jar into libs/ + run: | + mkdir -p libs + git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/mosu-dev/mosu-kmc-jar.git temp-jar + cp temp-jar/*.jar libs/ + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker image + run: docker build -t kangtaehyun1107/mosu-server:${{ github.sha }} . + working-directory: + - name: Push Docker image + run: docker push kangtaehyun1107/mosu-server:${{ github.sha }} + - name: Deploy via SSH run: | cd ~/mosu-server - echo "${{ secrets.TEST_ENV_BLUE }}" > .env.blue - echo "${{ secrets.TEST_ENV_GREEN }}" > .env.green + echo "${{ secrets.ENV_BASE }}" > .env + echo "${{ secrets.ENV_BASE }}" > .env.blue + echo "${{ secrets.ENV_BASE }}" > .env.green + + echo "${{ secrets.ENV_TEST }}" >> .env + echo "${{ secrets.ENV_TEST }}" >> .env.blue + echo "${{ secrets.ENV_TEST }}" >> .env.green + + echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.blue echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.green + sudo docker stop $(sudo docker ps -aq) || true sudo docker rm $(sudo docker ps -aq) || true echo "Stopping all containers..." diff --git a/build.gradle b/build.gradle index 99ddb470..55d7bc5e 100644 --- a/build.gradle +++ b/build.gradle @@ -29,16 +29,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - testImplementation 'org.projectlombok:lombok' - testImplementation 'org.projectlombok:lombok' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testImplementation 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + // 인증사 관련 의존성 implementation 'javax.servlet:jstl:1.2' implementation "org.apache.tomcat.embed:tomcat-embed-jasper" @@ -69,7 +70,6 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter:1.19.3' testImplementation 'org.testcontainers:mysql:1.20.0' - // security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -103,11 +103,6 @@ dependencies { runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.3.5' - testImplementation 'org.testcontainers:testcontainers:1.19.3' - testImplementation 'org.testcontainers:junit-jupiter:1.19.3' - testImplementation 'org.testcoscntainers:mysql:1.20.0' - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" implementation 'org.apache.commons:commons-pool2:2.12.1' diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheConfig.java b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheConfig.java new file mode 100644 index 00000000..44de15be --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheConfig.java @@ -0,0 +1,42 @@ +package life.mosu.mosuserver.application.caffeine; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +import life.mosu.mosuserver.domain.caffeine.CacheGroup; +import life.mosu.mosuserver.domain.caffeine.CacheType; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@EnableCaching +@Configuration +public class LocalCacheConfig { + + @Bean + public LocalCacheManager localCacheManager() { + List caches = Arrays.stream(CacheGroup.values()) + .filter(g -> g.getCacheType() == CacheType.LOCAL + || g.getCacheType() == CacheType.COMPOSITE) + .map(g -> new CaffeineCache( + g.getCacheName(), + Caffeine.newBuilder() + .recordStats() + .expireAfterWrite(g.getExpiredAfterWrite()) + .build() + )).collect(Collectors.toList()); + + return new LocalCacheManager(caches); + } + + @Bean + @Primary + public CacheManager appCacheManager(LocalCacheManager localCacheManager) { + return localCacheManager; + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java new file mode 100644 index 00000000..e800e56d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java @@ -0,0 +1,59 @@ +package life.mosu.mosuserver.application.caffeine; + +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +public class LocalCacheManager implements CacheManager, UpdatableCacheManager { + + private final List caches; + private Map cacheMap = new ConcurrentHashMap<>(); + private volatile Set cacheNames = Collections.emptySet(); + + public LocalCacheManager(List caches) { + this.caches = (caches != null) ? caches : Collections.emptyList(); + } + + @PostConstruct + public void init() { + Set cacheNamesSet = new LinkedHashSet<>(caches.size()); + Map cacheMapTemp = new ConcurrentHashMap<>(16); + + for (Cache cache : caches) { + String name = cache.getName(); + cacheNamesSet.add(name); + cacheMapTemp.put(name, cache); + } + this.cacheMap = cacheMapTemp; + this.cacheNames = cacheNamesSet; + } + + @Override + @Nullable + public Cache getCache(String name) { + return cacheMap.get(name); + } + + @Override + public Collection getCacheNames() { + return cacheNames; + } + + @Override + public void putIfAbsent(Cache cache, String key, Object value) { + Cache localCache = getCache(cache.getName()); + if (localCache != null) { + localCache.putIfAbsent(key, value); + } + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java b/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java new file mode 100644 index 00000000..92939647 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.application.caffeine; + +import org.springframework.cache.Cache; + +public interface UpdatableCacheManager { + + void putIfAbsent(Cache cache, String key, Object value); +} diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java index 85679e97..b246598c 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaDecrementOperator.java @@ -1,6 +1,8 @@ package life.mosu.mosuserver.application.exam.cache; import java.util.List; +import java.util.Map; +import java.util.Objects; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; @@ -15,12 +17,17 @@ public class AtomicExamQuotaDecrementOperator implements VoidCacheAtomicOperator private final RedisTemplate redisTemplate; private final DefaultRedisScript decrementScript; + public AtomicExamQuotaDecrementOperator( RedisTemplate redisTemplate, - @Qualifier("decrementExamQuotaScript") DefaultRedisScript decrementScript + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examLuaScripts") + Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.decrementScript = decrementScript; + this.decrementScript = Objects.requireNonNull(examLuaScripts.get("decrementQuota"), + "Redis script 'decrementQuota' not found"); } @Override diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java index ef7657b2..bfd4d235 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/AtomicExamQuotaIncrementOperator.java @@ -1,26 +1,34 @@ package life.mosu.mosuserver.application.exam.cache; import java.util.List; +import java.util.Map; +import java.util.Objects; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Component; @Component +@Slf4j public class AtomicExamQuotaIncrementOperator implements VoidCacheAtomicOperator { private final RedisTemplate redisTemplate; - private final DefaultRedisScript decrementScript; + private final DefaultRedisScript incrementScript; public AtomicExamQuotaIncrementOperator( RedisTemplate redisTemplate, - @Qualifier("incrementExamQuotaScript") DefaultRedisScript decrementScript + + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examLuaScripts") + Map> examLuaScripts ) { this.redisTemplate = redisTemplate; - this.decrementScript = decrementScript; + this.incrementScript = Objects.requireNonNull(examLuaScripts.get("incrementQuota"), + "Redis script 'incrementQuota' not found"); } @Override @@ -36,7 +44,7 @@ public String getActionName() { @Override public void execute(String key) { try { - Long result = redisTemplate.execute(decrementScript, List.of( + Long result = redisTemplate.execute(incrementScript, List.of( ExamQuotaPrefix.CURRENT_APPLICATIONS.with(key), ExamQuotaPrefix.MAX_CAPACITY.with(key) )); diff --git a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java index a0056cc3..9f8b3ed9 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/cache/ExamQuotaCacheManager.java @@ -14,6 +14,7 @@ import life.mosu.mosuserver.infra.persistence.redis.operator.VoidCacheAtomicOperator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -29,7 +30,9 @@ public ExamQuotaCacheManager( CacheWriter cacheWriter, CacheReader cacheReader, - @Qualifier("examCacheAtomicOperatorMap") + @Lazy + @SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection") + @Qualifier("examQuotaCacheAtomicOperatorMap") Map> cacheAtomicOperatorMap, ExamJpaRepository examJpaRepository ) { diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java index 965f5f42..addec98a 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java @@ -3,16 +3,15 @@ import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; import life.mosu.mosuserver.domain.inquiry.repository.InquiryJpaRepository; -import life.mosu.mosuserver.domain.inquiryAnswer.repository.InquiryAnswerJpaRepository; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserRole; -import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse.InquiryAnswerDetailResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -26,11 +25,9 @@ @RequiredArgsConstructor public class InquiryService { - private final UserJpaRepository userJpaRepository; private final InquiryAttachmentService inquiryAttachmentService; private final InquiryJpaRepository inquiryJpaRepository; private final InquiryAnswerService inquiryAnswerService; - private final InquiryAnswerJpaRepository inquiryAnswerJpaRepository; @Transactional public void createInquiry(UserJpaEntity user, InquiryCreateRequest request) { @@ -74,6 +71,17 @@ public void deleteInquiry(UserJpaEntity user, Long postId) { inquiryJpaRepository.delete(inquiry); } + @Transactional + public void updateInquiry(UserJpaEntity user, InquiryUpdateRequest request, Long postId) { + InquiryJpaEntity inquiry = getInquiry(postId); + hasPermission(inquiry.getUserId(), user); + + inquiry.update(request.title(), request.content(), user.getName()); + inquiryJpaRepository.save(inquiry); + + inquiryAttachmentService.updateAttachment(request.attachments(), inquiry); + } + private InquiryDetailResponse toInquiryDetailResponse(InquiryJpaEntity inquiry) { InquiryAnswerDetailResponse answer = inquiryAnswerService.getInquiryAnswerDetail( inquiry.getId()); diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java index 4405792c..379df88b 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -12,6 +12,8 @@ import life.mosu.mosuserver.presentation.notice.dto.NoticeUpdateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -28,12 +30,15 @@ public class NoticeService { private final NoticeJpaRepository noticeJpaRepository; private final NoticeAttachmentService attachmentService; + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void createNotice(NoticeCreateRequest request, UserJpaEntity user) { NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity(user)); attachmentService.createAttachment(request.attachments(), noticeEntity); } + @Cacheable(cacheNames = "notice", + key = "'page=' + #page + ',size=' + #size") @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public List getNotices(int page, int size) { Pageable pageable = PageRequest.of(page, size, Sort.by("id")); @@ -44,6 +49,7 @@ public List getNotices(int page, int size) { .toList(); } + @Cacheable(cacheNames = "notice", key = "#noticeId") @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public NoticeDetailResponse getNoticeDetail(Long noticeId) { NoticeJpaEntity notice = getNotice(noticeId); @@ -51,12 +57,14 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { return toNoticeDetailResponse(notice); } + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void deleteNotice(Long noticeId) { NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeJpaRepository.delete(noticeEntity); } + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEntity user) { NoticeJpaEntity noticeEntity = getNotice(noticeId); diff --git a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java new file mode 100644 index 00000000..3e823364 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java @@ -0,0 +1,47 @@ +package life.mosu.mosuserver.application.oauth; + +import life.mosu.mosuserver.domain.user.entity.AuthProvider; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserRole; +import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; +import life.mosu.mosuserver.global.processor.StepProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class OAuthUserPersistenceProcessor implements StepProcessor { + + private final UserJpaRepository userRepository; + + @Override + @Transactional + public UserJpaEntity process(final OAuthUserInfo info) { + return userRepository.findByLoginId(info.email()) + .map(existingUser -> { + existingUser.updateOAuthUser( + info.gender(), + info.name(), + info.phoneNumber(), + info.birthDay(), + info.marketingAgreed()); + return existingUser; + }) + .orElseGet(() -> { + final UserJpaEntity newUser = UserJpaEntity.builder() + .loginId(info.email()) + .gender(info.gender()) + .name(info.name()) + .birth(info.birthDay()) + .phoneNumber(info.phoneNumber()) + .userRole(UserRole.ROLE_PENDING) + .provider(AuthProvider.KAKAO) + .agreedToTermsOfService(true) + .agreedToPrivacyPolicy(true) + .agreedToMarketing(info.marketingAgreed()) + .build(); + return userRepository.save(newUser); + }); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java index f67b968d..3c0ddf67 100644 --- a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java +++ b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java @@ -1,15 +1,10 @@ package life.mosu.mosuserver.application.oauth; -import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.domain.profile.entity.Gender; import life.mosu.mosuserver.domain.profile.repository.ProfileJpaRepository; -import life.mosu.mosuserver.domain.user.entity.AuthProvider; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; -import life.mosu.mosuserver.domain.user.entity.UserRole; -import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; @@ -25,7 +20,7 @@ @RequiredArgsConstructor public class OAuthUserService extends DefaultOAuth2UserService { - private final UserJpaRepository userRepository; + private final OAuthUserPersistenceProcessor oAuthUserPersistenceProcessor; private final ProfileJpaRepository profileRepository; private final WebClient webClient; @@ -44,12 +39,15 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) agreedToMarketing = termsList.stream() .filter(term -> term instanceof Map) .map(term -> (Map) term) - .filter(termMap -> "terms_03".equals(termMap.get("tag"))) + .filter(termMap -> + "terms_03".equals(termMap.get("tag"))) .findFirst() .map(termMap -> (Boolean) termMap.get("agreed")) .orElse(false); } + log.info("동의 여부{}", agreedToMarketing); + final String registrationId = userRequest.getClientRegistration().getRegistrationId(); final String userNameAttributeName = userRequest.getClientRegistration() .getProviderDetails() @@ -59,7 +57,7 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) final OAuthUserInfo userInfo = OAuthUserInfo.of(OAuthProvider.from(registrationId), oAuth2UserAttributes, agreedToMarketing); - final UserJpaEntity oAuthUser = updateOrWrite(userInfo); + final UserJpaEntity oAuthUser = oAuthUserPersistenceProcessor.process(userInfo); Boolean isProfileRegistered = profileRepository.existsByUserId(oAuthUser.getId()); @@ -67,35 +65,6 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) isProfileRegistered); } - private UserJpaEntity updateOrWrite(final OAuthUserInfo info) { - return userRepository.findByLoginId(info.email()) - .map(existingUser -> { - existingUser.updateOAuthUser( - info.gender(), - info.name(), - info.phoneNumber(), - info.birthDay() != null ? info.birthDay() : LocalDate.of(1900, 1, 1)); - return existingUser; - }) - .orElseGet(() -> { - final UserJpaEntity newUser = UserJpaEntity.builder() - .loginId(info.email() != null ? info.email() : "NA") - .gender(info.gender() != null ? info.gender() : Gender.PENDING) - .name(info.name() != null ? info.name() : "NA") - .birth(info.birthDay() != null ? info.birthDay() - : LocalDate.EPOCH) - .phoneNumber(info.phoneNumber() != null ? info.phoneNumber() - : "010-0000-0000") - .userRole(UserRole.ROLE_PENDING) - .provider(AuthProvider.KAKAO) - .agreedToTermsOfService(true) - .agreedToPrivacyPolicy(true) - .agreedToMarketing(info.marketingAgreed()) - .build(); - return userRepository.save(newUser); - }); - } - private Map getServiceTerms(String accessToken) { String url = "https://kapi.kakao.com/v2/user/service_terms"; diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java new file mode 100644 index 00000000..af1f57e4 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java @@ -0,0 +1,33 @@ +package life.mosu.mosuserver.domain.caffeine; + +import java.time.Duration; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheGroup { + + NOTICE( + "notice", + Duration.ofMinutes(10), + CacheType.LOCAL + ), + + INQUIRY( + "inquiry", + Duration.ofMinutes(10), + CacheType.GLOBAL + ), + + COMPOSITE_ALL( + "composite", + Duration.ofMinutes(10), + CacheType.COMPOSITE + ); + + + private final String cacheName; + private final Duration expiredAfterWrite; + private final CacheType cacheType; +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java new file mode 100644 index 00000000..98c4e603 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.domain.caffeine; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum CacheType { + LOCAL("로컬 타입만 적용"), + GLOBAL("분산 캐시만 적용"), + COMPOSITE("로컬 + 분산 캐시 모두 적용"); + + private final String type; + +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java index 0c2d59c4..77d54a97 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java @@ -6,15 +6,18 @@ @Getter public class BlockedIp { + private final TimePenalty penaltyLevel; public BlockedIp(TimePenalty penaltyLevel) { this.penaltyLevel = penaltyLevel; } - public Duration getTtl(){ - return penaltyLevel.getDuration(); + public static BlockedIp init() { + return new BlockedIp(TimePenalty.LEVEL_0); } - + public Duration getTtl() { + return penaltyLevel.getDuration(); + } } diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java index 7e852423..c05c272b 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java @@ -1,12 +1,16 @@ package life.mosu.mosuserver.domain.caffeine.dto; -import lombok.Getter; +import java.util.concurrent.atomic.AtomicInteger; -@Getter public class RequestCounter { - private int count = 0; - public void increment() { - count++; + private final AtomicInteger count = new AtomicInteger(); + + public int incrementAndGet() { + return count.incrementAndGet(); + } + + public int getCount() { + return count.get(); } } diff --git a/src/main/java/life/mosu/mosuserver/domain/file/File.java b/src/main/java/life/mosu/mosuserver/domain/file/File.java index b9ae33fb..8886a3b0 100644 --- a/src/main/java/life/mosu/mosuserver/domain/file/File.java +++ b/src/main/java/life/mosu/mosuserver/domain/file/File.java @@ -15,8 +15,8 @@ public abstract class File { @Column private String fileName; - - @Column + + @Column(columnDefinition = "TEXT") private String s3Key; @Enumerated(EnumType.STRING) diff --git a/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java index 9d56c56e..17b8454e 100644 --- a/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/inquiry/entity/InquiryJpaEntity.java @@ -57,4 +57,10 @@ public void updateStatusToComplete() { public void updateStatusToPending() { this.status = InquiryStatus.PENDING; } + + public void update(final String title, final String content, final String author) { + this.title = title; + this.content = content; + this.author = author; + } } diff --git a/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java index 280b4a13..a936bcc2 100644 --- a/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java @@ -93,12 +93,14 @@ public void updateOAuthUser( Gender gender, String name, String phoneNumber, - LocalDate birth + LocalDate birth, + boolean agreedToMarketing ) { this.gender = gender; this.name = name; this.phoneNumber = phoneNumber; this.birth = birth; + this.agreedToMarketing = agreedToMarketing; } public void updateUserInfo( diff --git a/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java b/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java index 1fa17704..d03eb5f6 100644 --- a/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java +++ b/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -13,7 +12,6 @@ regexp = "^01[016789]-\\d{3,4}-\\d{4}$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다." ) -@NotBlank @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {}) diff --git a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java b/src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java similarity index 87% rename from src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java rename to src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java index 627b9545..52fab7fa 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java @@ -5,14 +5,14 @@ import com.github.benmanes.caffeine.cache.Expiry; import com.github.benmanes.caffeine.cache.LoadingCache; import java.util.concurrent.TimeUnit; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -public class CaffeineCacheConfig { +public class CaffeineFilterCacheConfig { @Bean public Cache ipRequestCountsCache(IpRateLimitingProperties properties) { @@ -41,12 +41,14 @@ public long expireAfterCreate(String key, BlockedIp value, long currentTime) { } @Override - public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) { - return currentDuration; + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return value.getTtl().toNanos(); } @Override - public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) { + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { return currentDuration; } }) diff --git a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java b/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java deleted file mode 100644 index e7da0024..00000000 --- a/src/main/java/life/mosu/mosuserver/global/config/ExamQuotaAtomicOperationConfig.java +++ /dev/null @@ -1,66 +0,0 @@ -package life.mosu.mosuserver.global.config; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Map; -import life.mosu.mosuserver.application.exam.cache.AtomicExamQuotaDecrementOperator; -import life.mosu.mosuserver.application.exam.cache.AtomicExamQuotaIncrementOperator; -import life.mosu.mosuserver.infra.persistence.redis.operator.CacheAtomicOperator; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.Resource; -import org.springframework.data.redis.core.script.DefaultRedisScript; - -@Configuration -public class ExamQuotaAtomicOperationConfig { - - @Value("classpath:scripts/decrement_exam_quota.lua") - private Resource decrementScript; - - @Value("classpath:scripts/increment_exam_quota.lua") - private Resource incrementScript; - - @Bean - @Qualifier("decrementExamQuotaScript") - public DefaultRedisScript decrementExamQuotaScript() { - DefaultRedisScript script = new DefaultRedisScript<>(); - script.setResultType(Long.class); - try { - String lua = new String(decrementScript.getInputStream().readAllBytes(), - StandardCharsets.UTF_8); - script.setScriptText(lua); - } catch (IOException e) { - throw new RuntimeException("Failed to load decrement_exam_quota.lua", e); - } - return script; - } - - @Bean - @Qualifier("incrementExamQuotaScript") - public DefaultRedisScript incrementExamQuotaScript() { - DefaultRedisScript script = new DefaultRedisScript<>(); - script.setResultType(Long.class); - try { - String lua = new String(incrementScript.getInputStream().readAllBytes(), - StandardCharsets.UTF_8); - script.setScriptText(lua); - } catch (IOException e) { - throw new RuntimeException("Failed to load increment_exam_quota.lua", e); - } - return script; - } - - @Bean - @Qualifier("examCacheAtomicOperatorMap") - public Map> examCacheAtomicOperatorMap( - AtomicExamQuotaIncrementOperator incrementOp, - AtomicExamQuotaDecrementOperator decrementOp - ) { - return Map.of( - incrementOp.getActionName(), incrementOp, - decrementOp.getActionName(), decrementOp - ); - } -} diff --git a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java index c6c9f251..9ebc44a0 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java @@ -4,15 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.application.oauth.OAuthUserService; -import life.mosu.mosuserver.global.filter.TokenExceptionFilter; -import life.mosu.mosuserver.global.filter.TokenFilter; -import life.mosu.mosuserver.global.handler.AuthLogoutHandler; -import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; -import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -39,6 +31,16 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import life.mosu.mosuserver.application.oauth.OAuthUserService; +import life.mosu.mosuserver.global.filter.TokenExceptionFilter; +import life.mosu.mosuserver.global.filter.TokenFilter; +import life.mosu.mosuserver.global.handler.AuthLogoutHandler; +import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; +import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; +import lombok.RequiredArgsConstructor; + @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -51,7 +53,8 @@ public class SecurityConfig { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ); private final OAuthUserService userService; private final OAuth2LoginSuccessHandler loginSuccessHandler; diff --git a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java index 806d59aa..6a4ccdb7 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java @@ -1,14 +1,16 @@ package life.mosu.mosuserver.global.config; import java.util.List; -import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; -import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; +import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { @@ -33,7 +35,8 @@ public void addCorsMappings(CorsRegistry registry) { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ) .allowCredentials(true) .maxAge(3600); diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index 49f1f3bb..053d2e47 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -9,13 +9,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import life.mosu.mosuserver.global.config.IpRateLimitingProperties; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; - -import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -31,59 +30,60 @@ public class IpRateLimitingFilter extends OncePerRequestFilter { private final Cache blockedHistoryCache; private final LoadingCache blockedIpCache; - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { if (!ipRateLimitingProperties.isEnabled()) { - log.info("IpRateLimitingFilter disabled"); + log.debug("IpRateLimitingFilter disabled"); filterChain.doFilter(request, response); return; } String ip = getClientIp(request); - isAlreadyBlocked(ip); RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + int after = counter.incrementAndGet(); + int max = ipRateLimitingProperties.getMaxRequestsPerMinute(); - synchronized (counter) { - counter.increment(); - - if (isOverPerMaxRequest(counter)) { - log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.getCount()); - handleBlockedIp(ip); - } + if (after > max) { + handleBlockedIp(ip); } - log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, counter.getCount()); + log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, after); log.debug("Cache stats: {}", ipRequestCountsCache.stats()); filterChain.doFilter(request, response); } - private boolean isOverPerMaxRequest(RequestCounter counter) { - return counter.getCount() >= ipRateLimitingProperties.getMaxRequestsPerMinute(); - } - private void handleBlockedIp(String ip) { - BlockedIpHistory history = blockedHistoryCache.get(ip, k -> new BlockedIpHistory(ip)); - TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel(); - history.updateHistory(nextPenaltyLevel); + BlockedIp existing = blockedIpCache.asMap().putIfAbsent(ip, BlockedIp.init()); + if (existing != null) { + log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, existing.getPenaltyLevel()); + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); + } + + TimePenalty level = blockedHistoryCache.asMap().compute(ip, (k, history) -> { + BlockedIpHistory h = (history == null) ? new BlockedIpHistory(ip) : history; + TimePenalty next = h.getPenaltyLevel().nextLevel(); + h.updateHistory(next); + return h; + }).getPenaltyLevel(); - blockedIpCache.invalidate(ip); - blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel)); - log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel); + blockedIpCache.asMap().computeIfPresent(ip, (k, v) -> new BlockedIp(level)); throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } - private void isAlreadyBlocked(String requestedIp) { - if(blockedIpCache.getIfPresent(requestedIp) != null){ - log.warn("이미 차단된 IP: {}", requestedIp); + private void isAlreadyBlocked(String ip) { + BlockedIp blockedIp = blockedIpCache.getIfPresent(ip); + if (blockedIp != null) { + log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, blockedIp.getPenaltyLevel()); throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } } -} +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java index 9a36da15..12770172 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java @@ -7,7 +7,7 @@ @Getter @RequiredArgsConstructor public enum TimePenalty { - LEVEL_0(0, Duration.ZERO), + LEVEL_0(0, Duration.ofSeconds(10)), LEVEL_1(1, Duration.ofMinutes(1)), LEVEL_2(2, Duration.ofMinutes(5)), LEVEL_3(3, Duration.ofMinutes(30)), diff --git a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java index 5f70c3b5..0e64e7a6 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java @@ -41,7 +41,6 @@ public enum Whitelist { FAQ("/api/v1/faq", WhitelistMethod.GET), NOTICE("/api/v1/notice", WhitelistMethod.GET), USER_ID_CHECK("/api/v1/user/check-id", WhitelistMethod.GET), - CUSTOMER_KEY_CHECK("/api/v1/user/customer-key", WhitelistMethod.GET), EXAM("/api/v1/exam", WhitelistMethod.GET), EXAM_AREAS("/api/v1/exam/areas", WhitelistMethod.GET), EXAM_ALL("/api/v1/exam/all", WhitelistMethod.GET), diff --git a/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java b/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java new file mode 100644 index 00000000..a1524f00 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/config/AtomicOperatorAutoRegistrar.java @@ -0,0 +1,50 @@ +package life.mosu.mosuserver.infra.config; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import life.mosu.mosuserver.infra.persistence.redis.operator.CacheAtomicOperator; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.config.ConfigurableListableBeanFactory; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.beans.factory.support.GenericBeanDefinition; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Slf4j +public class AtomicOperatorAutoRegistrar implements SmartInitializingSingleton { + + private final ConfigurableListableBeanFactory beanFactory; + + public AtomicOperatorAutoRegistrar(ConfigurableListableBeanFactory beanFactory) { + this.beanFactory = beanFactory; + } + + @Override + public void afterSingletonsInstantiated() { + Map allOperators = beanFactory.getBeansOfType( + CacheAtomicOperator.class); + + Map> grouped = allOperators.values().stream() + .collect(Collectors.groupingBy(CacheAtomicOperator::getName)); + + DefaultListableBeanFactory registry = (DefaultListableBeanFactory) beanFactory; + for (Map.Entry> entry : grouped.entrySet()) { + String domain = entry.getKey(); + List operators = entry.getValue(); + + Map mapValue = operators.stream() + .collect(Collectors.toMap(CacheAtomicOperator::getActionName, + Function.identity())); + + String beanName = domain + "CacheAtomicOperatorMap"; + GenericBeanDefinition beanDefinition = new GenericBeanDefinition(); + beanDefinition.setBeanClass(Map.class); + beanDefinition.setInstanceSupplier(() -> mapValue); + + registry.registerBeanDefinition(beanName, beanDefinition); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java b/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java index 8decec77..0cc943a0 100644 --- a/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java +++ b/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.infra.notify; +import jakarta.annotation.PostConstruct; import java.util.Map; import life.mosu.mosuserver.infra.notify.dto.discord.DiscordExceptionNotifyEventRequest; import lombok.RequiredArgsConstructor; @@ -20,15 +21,25 @@ public class DiscordNotifier implements NotifyClientAdapter { + + private static final String SCRIPT_BASE_PATH = "classpath:scripts/"; + private static final String SCRIPT_PATH_PREFIX = "/scripts/"; + + @Override + public void initialize(GenericApplicationContext context) { + try { + PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + Resource[] resources = resolver.getResources(SCRIPT_BASE_PATH + "**/*.lua"); + + Map>> domainScriptsMap = new HashMap<>(); + + for (Resource resource : resources) { + String path = resource.getURL().getPath(); + int idx = path.lastIndexOf(SCRIPT_PATH_PREFIX); + if (idx < 0) { + continue; + } + + String relativePath = path.substring(idx + SCRIPT_PATH_PREFIX.length()); + String[] parts = relativePath.split("/"); + if (parts.length < 2) { + continue; + } + + String domain = parts[0]; + String filename = parts[1]; + String scriptName = toCamelCase(filename.replace(".lua", "")); + + DefaultRedisScript script = new DefaultRedisScript<>(); + script.setResultType(Long.class); + + try (InputStream is = resource.getInputStream()) { + String lua = new String(is.readAllBytes(), StandardCharsets.UTF_8); + script.setScriptText(lua); + } + + domainScriptsMap + .computeIfAbsent(domain, k -> new HashMap<>()) + .put(scriptName, script); + } + + for (Map.Entry>> entry : domainScriptsMap.entrySet()) { + String beanName = entry.getKey() + "LuaScripts"; + log.info("Registering Lua scripts for domain: {}", beanName); + Map> scripts = entry.getValue(); + + String keys = String.join(", ", scripts.keySet()); + log.info("Lua script keys: [{}]", keys); + + context.registerBean(beanName, Map.class, () -> scripts); + } + } catch (Exception e) { + throw new RuntimeException("Failed to load Lua scripts", e); + } + } + + private String toCamelCase(String snakeCase) { + StringBuilder result = new StringBuilder(); + boolean toUpper = false; + for (char c : snakeCase.toCharArray()) { + if (c == '_') { + toUpper = true; + } else { + result.append(toUpper ? Character.toUpperCase(c) : c); + toUpper = false; + } + } + return result.toString(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java index b4ae1690..2bab8039 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java @@ -58,7 +58,10 @@ public UserJpaEntity toAuthEntity(PasswordEncoder passwordEncoder) { .agreedToTermsOfService(true) .agreedToPrivacyPolicy(true) .agreedToMarketing(serviceTermRequest.agreedToMarketing()) - .gender(Gender.PENDING) + .gender(Gender.fromName(gender)) + .name(userName) + .phoneNumber(phoneNumber) + .birth(birth) .provider(AuthProvider.MOSU) .userRole(UserRole.ROLE_PENDING) .build(); diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java index a34c0d74..f9bff42d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java @@ -8,6 +8,7 @@ import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -40,6 +42,16 @@ public ResponseEntity> create( return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.CREATED, "질문 등록 성공")); } + @PutMapping("/{postId}") + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> update( + @PathVariable Long postId, + @AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody @Valid InquiryUpdateRequest request) { + inquiryService.updateInquiry(principalDetails.user(), request, postId); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "질문 수정 성공")); + } + @GetMapping("/my") @PreAuthorize("isAuthenticated() and hasRole('USER')") public ResponseEntity>> getMyInquiries( diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java new file mode 100644 index 00000000..4d83c432 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.presentation.inquiry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import life.mosu.mosuserver.presentation.common.FileRequest; + +public record InquiryUpdateRequest( + @Size(max = 300, message = "제목은 최대 300자까지 입력 가능합니다.") + @Schema(description = "문의 제목", example = "서비스 이용 관련 질문입니다.") + @NotNull String title, + + @Size(max = 1000, message = "본문은 최대 1000자까지 입력 가능합니다.") + @Schema(description = "문의 내용", example = "포인트는 어떻게 사용하나요?") + @NotNull String content, + List attachments +) { + +} diff --git a/src/main/resources/META-INF/.gitkeep b/src/main/resources/META-INF/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 00000000..f508cd52 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1,2 @@ +org.springframework.context.ApplicationContextInitializer=\ +life.mosu.mosuserver.infra.persistence.redis.support.LuaScriptsFunctionalRegistrar \ No newline at end of file diff --git a/src/main/resources/application-base.yml b/src/main/resources/application-base.yml new file mode 100644 index 00000000..4df54a41 --- /dev/null +++ b/src/main/resources/application-base.yml @@ -0,0 +1,83 @@ +server: + port: ${SPRING_PORT} + servlet: + context-path: ${BASE_PATH} + session: + cookie: + same-site: none + secure: false + error: + include-stacktrace: never + +spring: + devtools: + restart: + enabled: false + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail.smtp.debug: true + mail.smtp.connectiontimeout: 1000 + mail.starttls.enable: true + mail.smtp.auth: true + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 15 + minimum-idle: 15 + + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE} + max-request-size: ${MAX_REQUEST_SIZE} + jpa: + open-in-view: false + show-sql: true + hibernate: + ddl-auto: update + + properties: + hibernate: + show_sql: true + format_sql: true + highlight_sql: true + use_sql_comments: true + jdbc: + time_zone: Asia/Seoul + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: ${REDIS_HOST} + port: ${VELKEY_PORT} + lettuce: + pool: + enabled: true + max-active: 32 + max-idle: 8 + min-idle: 4 + max-wait: 1000 + messages: + basename: messages + encoding: UTF-8 + mvc: + view: + prefix: /WEB-INF/views/ + suffix: .jsp +aws: + s3: + bucket-name: ${AWS_BUCKET_NAME} + region: ${AWS_REGION} + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + pre-signed-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} + +toss: + api: + base-url: https://api.tosspayments.com/v1 + diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..3c3dc3fb --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,16 @@ +logging: + level: + root: TRACE +toss: + secret-key: ${TOSS_SECRET_KEY} +discord: + base-url: "" + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..99c3af57 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,26 @@ +logging: + file: + name: ./logs/app.log + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" +toss: + secret-key: ${TOSS_SECRET_KEY} + +discord: + base-url: ${DISCORD_URL} + + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..5ef36ffc --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,26 @@ +logging: + file: + name: ./logs/app.log + level: + root: info + +management: + endpoints: + web: + exposure: + include: "*" +toss: + secret-key: ${TOSS_SECRET_KEY} + +discord: + base-url: "" + + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0deb7721..18ee0f4c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,112 +1,9 @@ -server: - port: ${SPRING_PORT} - servlet: - context-path: ${BASE_PATH} - session: - cookie: - same-site: none - secure: false - error: - include-stacktrace: never - spring: - mail: - host: ${MAIL_HOST} - port: ${MAIL_PORT} - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail.smtp.debug: true - mail.smtp.connectiontimeout: 1000 - mail.starttls.enable: true - mail.smtp.auth: true config: import: - optional:file:.env[.properties] - security-config.yml - swagger-config.yml - datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - maximum-pool-size: 15 - minimum-idle: 15 - - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE} - max-request-size: ${MAX_REQUEST_SIZE} - jpa: - open-in-view: false - show-sql: true - hibernate: - ddl-auto: update - - properties: - hibernate: - show_sql: true - format_sql: true - highlight_sql: true - use_sql_comments: true - jdbc: - time_zone: Asia/Seoul - dialect: org.hibernate.dialect.MySQLDialect - data: - redis: - host: ${REDIS_HOST} - port: ${VELKEY_PORT} - lettuce: - pool: - enabled: true - max-active: 32 - max-idle: 8 - min-idle: 4 - max-wait: 1000 - messages: - basename: messages - encoding: UTF-8 - mvc: - view: - prefix: /WEB-INF/views/ - suffix: .jsp - -management: - endpoints: - web: - exposure: - include: "*" - -aws: - s3: - bucket-name: ${AWS_BUCKET_NAME} - region: ${AWS_REGION} - access-key: ${AWS_ACCESS_KEY} - secret-key: ${AWS_SECRET_KEY} - pre-signed-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} - -logging: - file: - path: ./logs - name: app.log - level: - root: INFO - - -toss: - secret-key: ${TOSS_SECRET_KEY} - api: - base-url: https://api.tosspayments.com/v1 - -alimtalk: - user-id: ${ALIMTALK_USER_ID} - api-key: ${ALIMTALK_API_KEY} - api: - base-url: ${ALIMTALK_URL} - -kakao: - channel-id: ${KAKAO_CHANNEL_ID} - -discord: - base-url: ${DISCORD_URL} \ No newline at end of file + profiles: + active: ${APPLICATION_PROFILE:prod} + include: base \ No newline at end of file diff --git a/src/main/resources/db/data/data.sql b/src/main/resources/db/data/data.sql new file mode 100644 index 00000000..2bb55f4b --- /dev/null +++ b/src/main/resources/db/data/data.sql @@ -0,0 +1,60 @@ +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.931747', '2025-08-03 05:01:17.931747', '강남구 대치동 987', '서울특별시', + '06234', 'DAECHI', 532, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '대치중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.939018', '2025-08-03 05:01:17.939018', '양천구 목동서로 369', '서울특별시', + '07995', 'MOKDONG', 896, '2025-10-19 23:59:59.000000', '2025-10-26', '고정 도시락', 9000, + '목운중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.945445', '2025-08-03 05:01:17.945445', '양천구 신정로 250', '서울특별시', + '08018', 'MOKDONG', 896, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, + '신서중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.952291', '2025-08-03 05:01:17.952291', '강남구 개포로 619', '서울특별시', + '06327', 'DAECHI', 840, '2025-10-19 23:59:59.000000', '2025-10-26', '고정 도시락', 9000, '개원중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.958425', '2025-08-03 05:01:17.958425', '강남구 개포로 619', '서울특별시', + '06327', 'DAECHI', 840, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '개원중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.965381', '2025-08-03 05:01:17.965381', '영등포구 문래로 195', '서울특별시', + '07291', 'MOKDONG', 558, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, + '문래중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.972408', '2025-08-03 05:01:17.972408', '노원구 덕릉로 70길 99', '서울특별시', + '01673', 'NOWON', 448, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '온곡중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.978612', '2025-08-03 05:01:17.978612', '노원구 덕릉로 70길 99', '서울특별시', + '01673', 'NOWON', 448, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '온곡중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.982574', '2025-08-03 05:01:17.982574', '대구광역시 달서구 장기로 76', '서울특별시', + '42677', 'DAEGU', 392, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '노변중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.988270', '2025-08-03 05:01:17.988270', '대구광역시 달서구 장기로 76', '서울특별시', + '42677', 'DAEGU', 392, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '노변중학교', + false, 'OPEN'); \ No newline at end of file diff --git a/src/main/resources/scripts/.gitkeep b/src/main/resources/scripts/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/resources/scripts/decrement_exam_quota.lua b/src/main/resources/scripts/exam/decrement_quota.lua similarity index 100% rename from src/main/resources/scripts/decrement_exam_quota.lua rename to src/main/resources/scripts/exam/decrement_quota.lua diff --git a/src/main/resources/scripts/increment_exam_quota.lua b/src/main/resources/scripts/exam/increment_quota.lua similarity index 100% rename from src/main/resources/scripts/increment_exam_quota.lua rename to src/main/resources/scripts/exam/increment_quota.lua diff --git a/src/main/resources/security-config.yml b/src/main/resources/security-config.yml index 523cb69c..41ed8fac 100644 --- a/src/main/resources/security-config.yml +++ b/src/main/resources/security-config.yml @@ -16,7 +16,7 @@ spring: - birthday - birthyear - phone_number - service-terms: terms_03 + service-terms: terms_01,terms_02,terms_03 client-name: kakao provider: kakao: diff --git a/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java b/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java new file mode 100644 index 00000000..e38ce290 --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java @@ -0,0 +1,44 @@ +package life.mosu.mosuserver.application.oauth; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("카카오_추가기능_응답_테스트") +class OAuthUserServiceTest { + + @Test + @DisplayName("Service Terms 응답에 마케팅 동의(terms_03)가 없는 경우 false로 파싱한다") + void 마케팅_동의_여부를_파싱한다() { + Map term1 = Map.of( + "tag", "terms_02", + "required", true, + "agreed", true + ); + Map term2 = Map.of( + "tag", "terms_01", + "required", true, + "agreed", true + ); + + Map serviceTermsAttributes = new HashMap<>(); + serviceTermsAttributes.put("id", 4342056184L); + serviceTermsAttributes.put("service_terms", List.of(term1, term2)); + + boolean agreedToMarketing = false; + if (serviceTermsAttributes.get("service_terms") instanceof List termsList) { + agreedToMarketing = termsList.stream() + .filter(term -> term instanceof Map) + .map(term -> (Map) term) + .filter(termMap -> "terms_03".equals(termMap.get("tag"))) + .findFirst() + .map(termMap -> (Boolean) termMap.get("agreed")) + .orElse(false); + } + + Assertions.assertFalse(agreedToMarketing); + } +} \ No newline at end of file diff --git a/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java b/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java new file mode 100644 index 00000000..679ec31b --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java @@ -0,0 +1,494 @@ +package life.mosu.mosuserver.global.filter; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.awaitility.Awaitility.await; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; +import life.mosu.mosuserver.global.config.IpRateLimitingProperties; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@Slf4j +public class IpRateLimitingFilterMultiUserIsolationTest { + + private IpRateLimitingFilter filter; + private IpRateLimitingProperties properties; + private Cache ipRequestCountsCache; + private Cache blockedHistoryCache; + private LoadingCache blockedIpCache; + + @BeforeEach + public void setup() { + properties = Mockito.mock(IpRateLimitingProperties.class); + Mockito.when(properties.isEnabled()).thenReturn(true); + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(100); + + ipRequestCountsCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(1)) + .build(); + + blockedHistoryCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + + blockedIpCache = Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + @Override + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(key -> null); + + filter = new IpRateLimitingFilter(properties, ipRequestCountsCache, blockedHistoryCache, + blockedIpCache); + } + + @Test + public void 임계치_이하에서는_요청_모두_통과하고_카운터증가_체인호출됨() throws InterruptedException { + int userCount = 500; + int requestsPerUser = 50; + int repeatCount = 10; + + // IP 리스트 500개 생성 + List ips = IntStream.range(1, userCount + 1) + .mapToObj(i -> "192.168." + (i / 255) + "." + (i % 255)) + .collect(Collectors.toList()); + + // 각 IP별 누적 카운트 저장용 배열 (500명 × 누적 합) + long[] cumulativeCounts = new long[userCount]; + + for (int run = 1; run <= repeatCount; run++) { + // 캐시 초기화 (필요시) + ipRequestCountsCache.invalidateAll(); + blockedHistoryCache.invalidateAll(); + blockedIpCache.invalidateAll(); + + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(userCount * requestsPerUser); + + for (String ip : ips) { + for (int i = 0; i < requestsPerUser; i++) { + executor.submit(() -> { + try { + HttpServletRequest request = new MockHttpServletRequest() { + @Override + public String getRemoteAddr() { + return ip; + } + }; + HttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = (req, res) -> { + }; + + filter.doFilterInternal(request, response, filterChain); + + } catch (CustomRuntimeException e) { + // 차단 예외 무시 + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + } + + latch.await(); + executor.shutdown(); + + for (int idx = 0; idx < userCount; idx++) { + String ip = ips.get(idx); + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + long count = counter.getCount(); + cumulativeCounts[idx] += count; + log.debug("[Run {}] IP: {}, 카운트: {}", run, ip, count); + + assertThat(count) + .withFailMessage("Run %d: IP %s 카운트가 %d이어야 합니다, 실제: %d", run, ip, + requestsPerUser, count) + .isEqualTo(requestsPerUser); + } + } + + // 평균 카운트 계산 및 출력 + for (int idx = 0; idx < userCount; idx++) { + double avg = cumulativeCounts[idx] / (double) repeatCount; + log.info("IP: {}, 평균 카운트 ({}회 반복): {}", ips.get(idx), repeatCount, avg); + } + } + + @Test + void 임계치_이하에서는_카운터정확_체인_모두_호출됨() throws Exception { + // (기존 임계치_초과시_즉시차단되고_체인호출안됨_차단캐시에저장됨 본문 그대로) + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + String ip = "10.0.0.1"; + int requests = 200; + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + for (int i = 0; i < requests; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + assertThat(counter.getCount()).isEqualTo(requests); + assertThat(chainCalls.get()).isEqualTo(requests); + } + + + @Test + void 이미_차단된_IP는_카운터와무관하게_즉시차단_체인호출안됨() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(10); + String ip = "10.0.0.2"; + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + + for (int i = 0; i < 10; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + + boolean blockedThrown = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + + assertThat(blockedThrown).isTrue(); + assertThat(chainCalls.get()).isEqualTo(10); + BlockedIp blocked = blockedIpCache.getIfPresent(ip); + assertThat(blocked).isNotNull(); + } + + + @Test + void 차단상태에서는_카운터증가없고_체인호출안됨() throws Exception { + String ip = "10.0.0.3"; + BlockedIp mockedBlocked = Mockito.mock(BlockedIp.class); + Mockito.when(mockedBlocked.getTtl()).thenReturn(Duration.ofSeconds(30)); + blockedIpCache.put(ip, mockedBlocked); + ipRequestCountsCache.put(ip, new RequestCounter()); + ipRequestCountsCache.get(ip, k -> new RequestCounter()).incrementAndGet(); + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + boolean blockedThrown = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + assertThat(blockedThrown).isTrue(); + assertThat(chainCalls.get()).isEqualTo(0); + assertThat(ipRequestCountsCache.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(1); + } + + @Test + void 카운터_윈도우_만료후_초기화된다() throws Exception { + Cache shortLivedCount = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofSeconds(2)) + .build(); + Cache history = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + LoadingCache blocked = Caffeine.newBuilder() + .expireAfter(new Expiry() { + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(k -> null); + + IpRateLimitingFilter localFilter = new IpRateLimitingFilter(properties, shortLivedCount, + history, blocked); + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + + String ip = "10.0.0.4"; + FilterChain chain = (req, res) -> { + }; + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + assertThat(shortLivedCount.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(2); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + RequestCounter c = shortLivedCount.getIfPresent(ip); + assertThat(c).isNull(); + }); + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + assertThat(shortLivedCount.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(1); + } + + @Test + void 차단_TTL_만료후_요청_통과된다() throws Exception { + LoadingCache localBlocked = Caffeine.newBuilder() + .expireAfter(new Expiry() { + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(k -> null); + + Cache counts = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(1)) + .build(); + Cache history = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + IpRateLimitingFilter localFilter = new IpRateLimitingFilter(properties, counts, history, + localBlocked); + + String ip = "10.0.0.5"; + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + + BlockedIp mockedBlocked = Mockito.mock(BlockedIp.class); + Mockito.when(mockedBlocked.getTtl()).thenReturn(Duration.ofSeconds(2)); + + localBlocked.put(ip, mockedBlocked); + + boolean blockedThrown = false; + try { + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), + (req, res) -> { + }); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + assertThat(blockedThrown).isTrue(); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + BlockedIp b = localBlocked.getIfPresent(ip); + assertThat(b).isNull(); + }); + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + } + + @Test + void 차단은_IP_간_독립적으로_동작한다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(5); + String bad = "10.0.0.6"; + String good = "10.0.0.7"; + AtomicInteger goodCalls = new AtomicInteger(); + + for (int i = 0; i < 5; i++) { + filter.doFilterInternal(reqForIp(bad), new MockHttpServletResponse(), (req, res) -> { + }); + } + boolean blocked = false; + try { + filter.doFilterInternal(reqForIp(bad), new MockHttpServletResponse(), (req, res) -> { + }); + } catch (CustomRuntimeException e) { + blocked = true; + } + assertThat(blocked).isEqualTo(true); + + filter.doFilterInternal(reqForIp(good), new MockHttpServletResponse(), + (req, res) -> goodCalls.incrementAndGet()); + assertThat(goodCalls.get()).isEqualTo(1); + } + + @Test + void 단일IP_고경합에서도_카운터와_체인호출이_정확하다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(100_000); + String ip = "10.0.0.8"; + int threads = 200; + int perThread = 50; + ExecutorService exec = Executors.newFixedThreadPool(50); + CountDownLatch latch = new CountDownLatch(threads * perThread); + AtomicInteger chainCalls = new AtomicInteger(); + + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + + for (int t = 0; t < threads; t++) { + exec.submit(() -> { + try { + for (int i = 0; i < perThread; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + for (int i = 0; i < perThread; i++) { + latch.countDown(); + } + } + }); + } + latch.await(); + exec.shutdown(); + + long expected = (long) threads * perThread; + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + assertThat(counter.getCount()).isEqualTo(expected); + assertThat(chainCalls.get()).isEqualTo(expected); + } + + @Test + void 차단중에는_모든요청이_차단되고_체인호출없다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1); + String ip = "10.0.0.9"; + + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + + boolean secondBlocked = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + } catch (CustomRuntimeException e) { + secondBlocked = true; + } + assertThat(secondBlocked).isEqualTo(true); + + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + int concurrent = 100; + ExecutorService exec = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(concurrent); + for (int i = 0; i < concurrent; i++) { + exec.submit(() -> { + try { + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException ignored) { + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + exec.shutdown(); + + assertThat(chainCalls.get()).isEqualTo(0); + } + + private HttpServletRequest reqForIp(String ip) { + return new MockHttpServletRequest() { + @Override + public String getRemoteAddr() { + return ip; + } + }; + } + + @Test + void 다중스레드_동시차단_시도에도_차단이력은_한단계만_상승한다() throws Exception { + // 임계치 1: 첫 요청만 허용, 그 이후는 모두 차단 + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1); + String ip = "10.0.0.200"; + + // 1) 첫 요청: 허용되어 카운터가 1이 됨 + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + + // 2) 동시에 많은 요청을 던져서 handleBlockedIp가 동시 다발로 호출되도록 유도 + int concurrent = 200; + CountDownLatch latch = new CountDownLatch(concurrent); + ExecutorService exec = Executors.newFixedThreadPool(40); + + for (int i = 0; i < concurrent; i++) { + exec.submit(() -> { + try { + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), + (req, res) -> { + }); + } catch (CustomRuntimeException ignored) { + // 차단 예외는 무시 (정상 동작) + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + exec.shutdown(); + + // 3) 검증 + // - blockedIpCache에 엔트리가 있어야 함 (차단 상태) + // - BlockedIpHistory의 penalty level이 '정확히 한 단계'만 상승해야 함 + // (여러 스레드가 동시에 업데이트해도 compute로 원자적 갱신이므로 LEVEL_1 이 되어야 함) + + BlockedIp blocked = blockedIpCache.getIfPresent(ip); + assertThat(blocked).isNotNull(); + + BlockedIpHistory history = blockedHistoryCache.getIfPresent(ip); + assertThat(history).isNotNull(); + + // 초기 레벨이 LEVEL_0이라고 가정하고, 상승 후 LEVEL_1을 기대 + log.info("history: {}", history); + assertThat(history.getPenaltyLevel()).isEqualTo(TimePenalty.LEVEL_1); + + } +} + +