diff --git a/.github/workflows/self-depoly.yaml b/.github/workflows/self-depoly.yaml index 20a1ebf8..2a3e8327 100644 --- a/.github/workflows/self-depoly.yaml +++ b/.github/workflows/self-depoly.yaml @@ -3,12 +3,52 @@ 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 diff --git a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java index 2d1c9fda..6e9bbb91 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.application.repository.ApplicationFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class ApplicationFailureLogCleanup implements LogCleanup { +public class ApplicationFailureLogCleanupExecutor implements LogCleanupExecutor { private final ApplicationFailureLogJpaRepository applicationFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java index 9989c403..a219a7a0 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/application/cron/ApplicationFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.application.entity.ApplicationJpaEntity; import life.mosu.mosuserver.domain.application.repository.ApplicationFailureLogJpaRepository; import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@DisallowConcurrentExecution -@Component +@CronTarget @RequiredArgsConstructor -public class ApplicationFailureLogDomainArchiver implements DomainArchiver { +public class ApplicationFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java new file mode 100644 index 00000000..8a1bb449 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/BlockedIpBatchAchiever.java @@ -0,0 +1,68 @@ +package life.mosu.mosuserver.application.caffeine; + +import com.github.benmanes.caffeine.cache.Cache; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import life.mosu.mosuserver.domain.caffeine.repository.BlockedIpHistoryLogJpaJpaRepository; +import life.mosu.mosuserver.global.filter.TimePenalty; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@CronTarget +@RequiredArgsConstructor +public class BlockedIpBatchAchiever implements DomainArchiveExecutor { + + private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); + private final static int BATCH_SIZE = 500; + + private final BlockedIpHistoryLogJpaJpaRepository blockedIpHistoryLogJpaRepository; + private final Cache blockedHistoryCache; + + + @Override + public void archive() { + Map blockedHistoryMap = blockedHistoryCache.asMap(); + + List logs = blockedHistoryMap.values().stream() + .filter(entry -> entry.getPenaltyLevel() == TimePenalty.LEVEL_5) + .map(this::createBlockedHistoryLog) + .toList(); + + if (logs.isEmpty()) { + log.debug("[BlockedIpArchiver] 저장할 로그가 없음."); + return; + } + + for (int i = 0; i < logs.size(); i += BATCH_SIZE) { + int end = Math.min(i + BATCH_SIZE, logs.size()); + List batch = logs.subList(i, end); + + try { + blockedIpHistoryLogJpaRepository.saveAllUsingBatch(batch); + log.debug("[BlockedIpArchiver] 저장 완료: {}개", batch.size()); + } catch (Exception e) { + log.error("[BlockedIpArchiver] 저장 실패: {}~{} 인덱스", i, end, e); + } + } + } + + @Override + public String getName() { + return "blocked-ip"; + } + + private BlockedIpHistoryLogJpaEntity createBlockedHistoryLog( + BlockedIpHistory blockedIpHistory) { + return new BlockedIpHistoryLogJpaEntity( + blockedIpHistory.getIp(), + blockedIpHistory.getPenaltyLevel(), + blockedIpHistory.getBlockedAt() + ); + } +} 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/InquiryAnswerAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java index 0440bc91..378d1d02 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerAttachmentService.java @@ -53,6 +53,15 @@ public List toAttachmentResponse .toList(); } + @Override + public void updateAttachment( + List requests, + InquiryAnswerJpaEntity answerEntity + ) { + deleteAttachment(answerEntity); + createAttachment(requests, answerEntity); + } + private InquiryDetailResponse.AttachmentResponse createAttachResponse( InquiryAnswerAttachmentEntity attachment) { String preSignedUrl = s3Service.getPreSignedUrl(attachment.getS3Key()); @@ -74,4 +83,5 @@ private InquiryDetailResponse.AttachmentDetailResponse createAttachDetailRespons ); } + } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java index bac2eda8..8987e8fa 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAnswerService.java @@ -4,6 +4,7 @@ import life.mosu.mosuserver.domain.inquiry.repository.InquiryJpaRepository; import life.mosu.mosuserver.domain.inquiryAnswer.entity.InquiryAnswerJpaEntity; import life.mosu.mosuserver.domain.inquiryAnswer.repository.InquiryAnswerJpaRepository; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerRequest; @@ -28,14 +29,14 @@ public class InquiryAnswerService { private final InquiryAnswerTxService eventTxService; @Transactional - public void createInquiryAnswer(Long postId, InquiryAnswerRequest request) { + public void createInquiryAnswer(Long postId, InquiryAnswerRequest request, UserJpaEntity user) { isAnswerAlreadyRegister(postId); InquiryJpaEntity inquiryEntity = getInquiry(postId); Long userId = inquiryEntity.getUserId(); try { InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.save( - request.toEntity(postId)); + request.toEntity(postId, user)); answerAttachmentService.createAttachment(request.attachments(), answerEntity); inquiryEntity.updateStatusToComplete(); @@ -51,12 +52,12 @@ public void createInquiryAnswer(Long postId, InquiryAnswerRequest request) { @Transactional public void deleteInquiryAnswer(Long postId) { InquiryJpaEntity inquiryEntity = getInquiry(postId); - InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.findByInquiryId(postId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.INQUIRY_ANSWER_NOT_FOUND)); inquiryAnswerJpaRepository.delete(answerEntity); inquiryEntity.updateStatusToPending(); + answerAttachmentService.deleteAttachment(answerEntity); } @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) @@ -71,17 +72,14 @@ public InquiryDetailResponse.InquiryAnswerDetailResponse getInquiryAnswerDetail( } @Transactional - public void updateInquiryAnswer(Long postId, InquiryAnswerUpdateRequest request) { - InquiryJpaEntity inquiryEntity = getInquiry(postId); - + public void updateInquiryAnswer(Long postId, InquiryAnswerUpdateRequest request, UserJpaEntity user) { InquiryAnswerJpaEntity answerEntity = inquiryAnswerJpaRepository.findByInquiryId(postId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.INQUIRY_ANSWER_NOT_FOUND)); - answerEntity.update(request.title(), request.content()); + answerEntity.update(request.title(), request.content(), user.getName()); inquiryAnswerJpaRepository.save(answerEntity); - answerAttachmentService.deleteAttachment(answerEntity); - answerAttachmentService.createAttachment(request.attachments(), answerEntity); + answerAttachmentService.updateAttachment(request.attachments(), answerEntity); } private InquiryJpaEntity getInquiry(Long postId) { @@ -96,4 +94,6 @@ private void isAnswerAlreadyRegister(Long postId) { } + + } diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java index 0298ff05..7939d7d9 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryAttachmentService.java @@ -38,6 +38,15 @@ public void deleteAttachment(InquiryJpaEntity entity) { inquiryAttachmentJpaRepository.deleteAll(attachments); } + @Override + public void updateAttachment( + List requests, + InquiryJpaEntity inquiryEntity + ) { + deleteAttachment(inquiryEntity); + createAttachment(requests, inquiryEntity); + } + public List toAttachmentResponses( InquiryJpaEntity inquiry) { 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 d98d6452..965f5f42 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java @@ -68,11 +68,9 @@ public void deleteInquiry(UserJpaEntity user, Long postId) { InquiryJpaEntity inquiry = getInquiry(postId); hasPermission(inquiry.getUserId(), user); - inquiryAnswerJpaRepository.findByInquiryId(postId).ifPresent(answer -> { - inquiryAnswerService.deleteInquiryAnswer(postId); - }); + inquiryAnswerService.deleteInquiryAnswer(postId); -// inquiryAttachmentService.deleteAttachment(inquiry); + inquiryAttachmentService.deleteAttachment(inquiry); inquiryJpaRepository.delete(inquiry); } diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java index 4319be53..d784b7a1 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeAttachmentService.java @@ -40,18 +40,14 @@ public void deleteAttachment(NoticeJpaEntity entity) { noticeAttachmentJpaRepository.deleteAll(attachments); } -// public List toAttachmentResponses(NoticeJpaEntity notice) { -// -// List attachments = noticeAttachmentJpaRepository.findAllByNoticeId( -// notice.getId()); -// -// return attachments.stream() -// .map(attachment -> new NoticeResponse.AttachmentResponse( -// attachment.getFileName(), -// fileUrl(attachment.getS3Key()) -// )) -// .toList(); -// } + @Override + public void updateAttachment( + List requests, + NoticeJpaEntity noticeEntity + ) { + deleteAttachment(noticeEntity); + createAttachment(requests, noticeEntity); + } public List toDetailAttResponses( NoticeJpaEntity notice) { @@ -70,7 +66,7 @@ public List toDetailAttResponses( } private String fileUrl(String s3Key) { - return s3Service.getPreSignedUrl(s3Key); + return s3Service.getPublicUrl(s3Key); } } 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 53bb0e93..4405792c 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -3,6 +3,7 @@ import java.util.List; import life.mosu.mosuserver.domain.notice.entity.NoticeJpaEntity; import life.mosu.mosuserver.domain.notice.repository.NoticeJpaRepository; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; @@ -28,8 +29,8 @@ public class NoticeService { private final NoticeAttachmentService attachmentService; @Transactional - public void createNotice(NoticeCreateRequest request) { - NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity()); + public void createNotice(NoticeCreateRequest request, UserJpaEntity user) { + NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity(user)); attachmentService.createAttachment(request.attachments(), noticeEntity); } @@ -45,24 +46,23 @@ public List getNotices(int page, int size) { @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public NoticeDetailResponse getNoticeDetail(Long noticeId) { - NoticeJpaEntity notice = getNoticeOrThrow(noticeId); + NoticeJpaEntity notice = getNotice(noticeId); return toNoticeDetailResponse(notice); } @Transactional public void deleteNotice(Long noticeId) { - NoticeJpaEntity noticeEntity = getNoticeOrThrow(noticeId); + NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeJpaRepository.delete(noticeEntity); } @Transactional - public void updateNotice(Long noticeId, NoticeUpdateRequest request) { - NoticeJpaEntity noticeEntity = getNoticeOrThrow(noticeId); + public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEntity user) { + NoticeJpaEntity noticeEntity = getNotice(noticeId); - noticeEntity.update(request.title(), request.content(), request.author()); - attachmentService.deleteAttachment(noticeEntity); - attachmentService.createAttachment(request.attachments(), noticeEntity); + noticeEntity.update(request.title(), request.content(), user.getName()); + attachmentService.updateAttachment(request.attachments(), noticeEntity); } private NoticeResponse toNoticeResponse(NoticeJpaEntity notice) { @@ -77,7 +77,7 @@ private NoticeDetailResponse toNoticeDetailResponse(NoticeJpaEntity notice) { ); } - private NoticeJpaEntity getNoticeOrThrow(Long noticeId) { + private NoticeJpaEntity getNotice(Long noticeId) { return noticeJpaRepository.findById(noticeId) .orElseThrow(() -> new CustomRuntimeException(ErrorCode.NOTICE_NOT_FOUND)); } diff --git a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java index 0ae0aa2b..8b8c7bca 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.payment.repository.PaymentFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class PaymentFailureLogCleanup implements LogCleanup { +public class PaymentFailureLogCleanupExecutor implements LogCleanupExecutor { private final PaymentFailureLogJpaRepository paymentFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java index acabbd1b..3dbb4ed3 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/cron/PaymentFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.payment.entity.PaymentJpaEntity; import life.mosu.mosuserver.domain.payment.repository.PaymentFailureLogJpaRepository; import life.mosu.mosuserver.domain.payment.repository.PaymentJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Component +@CronTarget @RequiredArgsConstructor -@DisallowConcurrentExecution -public class PaymentFailureLogDomainArchiver implements DomainArchiver { +public class PaymentFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; diff --git a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java similarity index 80% rename from src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java rename to src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java index fbf50778..3077bb8c 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogCleanupExecutor.java @@ -2,7 +2,7 @@ import java.time.LocalDateTime; import life.mosu.mosuserver.domain.refund.repository.RefundFailureLogJpaRepository; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; import lombok.RequiredArgsConstructor; import org.quartz.DisallowConcurrentExecution; import org.springframework.stereotype.Component; @@ -10,7 +10,7 @@ @DisallowConcurrentExecution @Component @RequiredArgsConstructor -public class RefundFailureLogCleanup implements LogCleanup { +public class RefundFailureLogCleanupExecutor implements LogCleanupExecutor { private final RefundFailureLogJpaRepository refundFailureLogJpaRepository; diff --git a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java similarity index 91% rename from src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java rename to src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java index 4cc6fec6..5f7e6e4a 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiver.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/cron/RefundFailureLogDomainArchiveExecutor.java @@ -11,18 +11,16 @@ import life.mosu.mosuserver.domain.refund.entity.RefundJpaEntity; import life.mosu.mosuserver.domain.refund.repository.RefundFailureLogJpaRepository; import life.mosu.mosuserver.domain.refund.repository.RefundJpaRepository; -import life.mosu.mosuserver.global.support.DomainArchiver; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronTarget; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.quartz.DisallowConcurrentExecution; -import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @Slf4j -@Component +@CronTarget @RequiredArgsConstructor -@DisallowConcurrentExecution -public class RefundFailureLogDomainArchiver implements DomainArchiver { +public class RefundFailureLogDomainArchiveExecutor implements DomainArchiveExecutor { private final static Duration DURATION_HOURS_STANDARD = Duration.ofHours(1); private final static int BATCH_SIZE = 500; 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 new file mode 100644 index 00000000..0c2d59c4 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.domain.caffeine.dto; + +import java.time.Duration; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.Getter; + +@Getter +public class BlockedIp { + private final TimePenalty penaltyLevel; + + public BlockedIp(TimePenalty penaltyLevel) { + this.penaltyLevel = penaltyLevel; + } + + public Duration getTtl(){ + return penaltyLevel.getDuration(); + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java new file mode 100644 index 00000000..59b71df2 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIpHistory.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.domain.caffeine.dto; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import java.time.LocalDateTime; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class BlockedIpHistory { + + private String ip; + + @Enumerated(EnumType.STRING) + private TimePenalty penaltyLevel; + + private LocalDateTime blockedAt; + + public void updateHistory(TimePenalty penaltyLevel) { + this.penaltyLevel = penaltyLevel; + this.blockedAt = LocalDateTime.now(); + } + + public BlockedIpHistory(String ip) { + this.ip = ip; + this.penaltyLevel = TimePenalty.LEVEL_0; + this.blockedAt = LocalDateTime.now(); + } + +} 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 new file mode 100644 index 00000000..7e852423 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java @@ -0,0 +1,12 @@ +package life.mosu.mosuserver.domain.caffeine.dto; + +import lombok.Getter; + +@Getter +public class RequestCounter { + private int count = 0; + + public void increment() { + count++; + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java new file mode 100644 index 00000000..223d90e1 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/entity/BlockedIpHistoryLogJpaEntity.java @@ -0,0 +1,40 @@ +package life.mosu.mosuserver.domain.caffeine.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import life.mosu.mosuserver.global.filter.TimePenalty; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Table(name = "blocked_ip_history_log") +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class BlockedIpHistoryLogJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String ip; + + @Enumerated(EnumType.STRING) + private TimePenalty penaltyLevel; + + private LocalDateTime blockedAt; + + @Builder + public BlockedIpHistoryLogJpaEntity(String ip, TimePenalty penaltyLevel, LocalDateTime blockedAt) { + this.ip = ip; + this.penaltyLevel = penaltyLevel; + this.blockedAt = blockedAt; + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java new file mode 100644 index 00000000..577ca954 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepository.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlockedIpHistoryLogJpaJpaRepository extends + JpaRepository, BlockedIpHistoryLogJpaRepositoryCustom { + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java new file mode 100644 index 00000000..4ad01c8f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaJpaRepositoryImpl.java @@ -0,0 +1,32 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import java.sql.Timestamp; +import java.util.List; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BlockedIpHistoryLogJpaJpaRepositoryImpl implements + BlockedIpHistoryLogJpaRepositoryCustom { + + private final JdbcTemplate jdbcTemplate; + + @Override + public void saveAllUsingBatch(List entities){ + String sql = """ + INSERT INTO blocked_ip_history_log (ip, penalty_level, blocked_at) + VALUES (?, ?, ?) + """; + + jdbcTemplate.batchUpdate(sql, entities, 500, (ps, entity) -> { + ps.setString(1, entity.getIp()); + ps.setString(2, String.valueOf(entity.getPenaltyLevel())); + ps.setTimestamp(3, Timestamp.valueOf(entity.getBlockedAt())); + }); + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java new file mode 100644 index 00000000..bffa765c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/repository/BlockedIpHistoryLogJpaRepositoryCustom.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.domain.caffeine.repository; + +import java.util.List; +import life.mosu.mosuserver.domain.caffeine.entity.BlockedIpHistoryLogJpaEntity; + +public interface BlockedIpHistoryLogJpaRepositoryCustom { + void saveAllUsingBatch(List logs); +} 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 73ea1319..9d56c56e 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 @@ -26,10 +26,10 @@ public class InquiryJpaEntity extends BaseTimeEntity { @Column(name = "inquiry_id", nullable = false) private Long id; - @Column(name = "title", nullable = false) + @Column(name = "title", nullable = false, length = 300) private String title; - @Column(name = "content", nullable = false) + @Column(name = "content", nullable = false, length = 1000) private String content; @Column(name = "user_id", nullable = false) diff --git a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java index 3e7f08b2..a1120f15 100644 --- a/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/inquiryAnswer/entity/InquiryAnswerJpaEntity.java @@ -25,15 +25,18 @@ public class InquiryAnswerJpaEntity extends BaseTimeEntity { @Column(name = "inquiry_answer_id", nullable = false) private Long id; - @Column(name = "title", nullable = false, length = 3000) + @Column(name = "title", nullable = false, length = 300) private String title; - @Column(name = "content", nullable = false) + @Column(name = "content", nullable = false, length = 1000) private String content; @Column(name = "inquiry_id", nullable = false) private Long inquiryId; + @Column(name = "author", nullable = false) + private String author; + @Column(name = "user_id", nullable = false) private Long userId; @@ -42,16 +45,20 @@ public InquiryAnswerJpaEntity( final String title, final String content, final Long inquiryId, - final Long userId + final Long userId, + final String author + ) { this.title = title; this.content = content; this.inquiryId = inquiryId; this.userId = userId; + this.author = author; } - public void update(final String title, final String content) { + 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/global/config/CaffeineCacheConfig.java b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java new file mode 100644 index 00000000..627b9545 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java @@ -0,0 +1,55 @@ +package life.mosu.mosuserver.global.config; + +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 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.RequestCounter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CaffeineCacheConfig { + + @Bean + public Cache ipRequestCountsCache(IpRateLimitingProperties properties) { + return Caffeine.newBuilder() + .expireAfterWrite(properties.getTimeWindowMs(), TimeUnit.MILLISECONDS) + .recordStats() + .build(); + } + + @Bean + public Cache blockedHistoryCache() { + return Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.DAYS) + .recordStats() + .build(); + } + + + @Bean + public LoadingCache blockedIpCache() { + return 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); + } +} 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/IpRateLimitingProperties.java b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java index d4f0ac89..fce56cc7 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java +++ b/src/main/java/life/mosu/mosuserver/global/config/IpRateLimitingProperties.java @@ -17,4 +17,5 @@ public class IpRateLimitingProperties { private int maxRequestsPerMinute; @Min(1) private long timeWindowMs; + } 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/exception/GlobalExceptionHandler.java b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java index c578e565..645da67f 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.global.exception; import jakarta.persistence.EntityNotFoundException; +import jakarta.servlet.http.HttpServletRequest; import java.util.LinkedHashMap; import java.util.Map; import life.mosu.mosuserver.infra.notify.NotifyClientAdapter; @@ -15,6 +16,8 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.reactive.resource.NoResourceFoundException; @Slf4j @RestControllerAdvice @@ -123,6 +126,31 @@ public ResponseEntity handleHttpMessageNotReadableException( return ResponseEntity.status(HttpStatus.CONFLICT).body(response); } + @ExceptionHandler(NoResourceFoundException.class ) + public ResponseEntity handleNotFound(Exception ex) { + notifyIfNeeded(ex); + + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.NOT_FOUND.value()) + .message("요청하신 리소스를 찾을 수 없습니다.") + .errors(ex.getMessage()) + .build(); + + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + notifyIfNeeded(ex); + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("TYPE_MISMATCH") + .message("요청 파라미터 타입이 올바르지 않습니다.") + .build(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleGeneralException(Exception ex) { notifyIfNeeded(ex); @@ -137,7 +165,9 @@ public ResponseEntity handleGeneralException(Exception ex) { } @ExceptionHandler(CustomRuntimeException.class) - public ResponseEntity handleCustomRuntimeException(CustomRuntimeException ex) { + public ResponseEntity handleCustomRuntimeException( + CustomRuntimeException ex + ) { notifyIfNeeded(ex); ErrorResponse response = ErrorResponse.builder() @@ -152,7 +182,7 @@ public ResponseEntity handleCustomRuntimeException(CustomRuntimeE private void notifyIfNeeded(Exception ex) { try { DiscordExceptionNotifyEventRequest request = DiscordExceptionNotifyEventRequest.of( - ex.getCause().toString(), + ex.getCause() != null ? ex.getCause().toString() : "Unknown Cause", ex.getMessage() ); notifier.send(request); 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 4cff09f7..49f1f3bb 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -3,34 +3,34 @@ import static life.mosu.mosuserver.global.util.IpUtil.getClientIp; import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.LoadingCache; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.concurrent.TimeUnit; 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; import org.springframework.web.filter.OncePerRequestFilter; @Slf4j @Component +@RequiredArgsConstructor public class IpRateLimitingFilter extends OncePerRequestFilter { private final IpRateLimitingProperties ipRateLimitingProperties; - private final Cache ipRequestCounts; - - public IpRateLimitingFilter(IpRateLimitingProperties ipRateLimitingProperties) { - this.ipRateLimitingProperties = ipRateLimitingProperties; - this.ipRequestCounts = Caffeine.newBuilder() - .expireAfterWrite(ipRateLimitingProperties.getTimeWindowMs(), TimeUnit.MILLISECONDS) - .recordStats() - .build(); - } + private final Cache ipRequestCountsCache; + private final Cache blockedHistoryCache; + private final LoadingCache blockedIpCache; + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -38,42 +38,52 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { if (!ipRateLimitingProperties.isEnabled()) { + log.info("IpRateLimitingFilter disabled"); filterChain.doFilter(request, response); return; } String ip = getClientIp(request); - RequestCounter counter = ipRequestCounts.get(ip, k -> new RequestCounter()); + + isAlreadyBlocked(ip); + + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); synchronized (counter) { counter.increment(); - if (isBlocked(counter)) { - log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.count); - handleBlockedIp(); + if (isOverPerMaxRequest(counter)) { + log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.getCount()); + handleBlockedIp(ip); } } - log.info("IP: {}, 요청 횟수 증가 후: {}", ip, counter.count); - log.debug("Cache stats: {}", ipRequestCounts.stats()); + log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, counter.getCount()); + log.debug("Cache stats: {}", ipRequestCountsCache.stats()); filterChain.doFilter(request, response); } - private boolean isBlocked(RequestCounter counter) { - return counter.count >= ipRateLimitingProperties.getMaxRequestsPerMinute(); + private boolean isOverPerMaxRequest(RequestCounter counter) { + return counter.getCount() >= ipRateLimitingProperties.getMaxRequestsPerMinute(); } - private void handleBlockedIp() { - throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); - } + private void handleBlockedIp(String ip) { + BlockedIpHistory history = blockedHistoryCache.get(ip, k -> new BlockedIpHistory(ip)); + TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel(); + history.updateHistory(nextPenaltyLevel); - private static class RequestCounter { + blockedIpCache.invalidate(ip); + blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel)); + log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel); - int count = 0; + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); + } - void increment() { - count++; + private void isAlreadyBlocked(String requestedIp) { + if(blockedIpCache.getIfPresent(requestedIp) != null){ + log.warn("이미 차단된 IP: {}", requestedIp); + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } } } diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java new file mode 100644 index 00000000..9a36da15 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java @@ -0,0 +1,27 @@ +package life.mosu.mosuserver.global.filter; + +import java.time.Duration; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TimePenalty { + LEVEL_0(0, Duration.ZERO), + LEVEL_1(1, Duration.ofMinutes(1)), + LEVEL_2(2, Duration.ofMinutes(5)), + LEVEL_3(3, Duration.ofMinutes(30)), + LEVEL_4(4, Duration.ofHours(1)), + LEVEL_5(5, Duration.ofDays(1)); + + private final int count; + private final Duration duration; + + public TimePenalty nextLevel() { + if (this == LEVEL_5) { + return this; + } + return TimePenalty.values()[this.ordinal() + 1]; + } + +} diff --git a/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java b/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java index 52534658..13dc60e0 100644 --- a/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java +++ b/src/main/java/life/mosu/mosuserver/global/scheduler/RollbackLogScheduler.java @@ -2,7 +2,6 @@ import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @Component @@ -11,7 +10,7 @@ public class RollbackLogScheduler { private final List> processors; - @Scheduled(fixedDelay = 10_000) + // @Scheduled(fixedDelay = 10_000) public void processAllRollbackLogs() { for (RollbackLogProcessor processor : processors) { processLogs(processor); diff --git a/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java b/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java deleted file mode 100644 index db8fe1d8..00000000 --- a/src/main/java/life/mosu/mosuserver/global/support/DomainArchiver.java +++ /dev/null @@ -1,8 +0,0 @@ -package life.mosu.mosuserver.global.support; - -public interface DomainArchiver { - - void archive(); - - String getName(); -} diff --git a/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java b/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java new file mode 100644 index 00000000..42eeae36 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/support/cron/DomainArchiveExecutor.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.global.support.cron; + +import life.mosu.mosuserver.infra.cron.support.CronJobExecutor; + +public interface DomainArchiveExecutor extends CronJobExecutor { + + void archive(); + + String getName(); +} diff --git a/src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java b/src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java similarity index 59% rename from src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java rename to src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java index 704cd3c3..340ba515 100644 --- a/src/main/java/life/mosu/mosuserver/global/support/LogCleanup.java +++ b/src/main/java/life/mosu/mosuserver/global/support/cron/LogCleanupExecutor.java @@ -1,8 +1,9 @@ -package life.mosu.mosuserver.global.support; +package life.mosu.mosuserver.global.support.cron; import java.time.LocalDateTime; +import life.mosu.mosuserver.infra.cron.support.CronJobExecutor; -public interface LogCleanup { +public interface LogCleanupExecutor extends CronJobExecutor { /** * 지정된 기준 날짜보다 오래된 로그 데이터를 삭제한다. diff --git a/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java deleted file mode 100644 index d20eaff2..00000000 --- a/src/main/java/life/mosu/mosuserver/infra/config/ArchivingOrchestratorCronConfig.java +++ /dev/null @@ -1,76 +0,0 @@ -package life.mosu.mosuserver.infra.config; - -import life.mosu.mosuserver.infra.cron.ArchivingOrchestratorJob; -import life.mosu.mosuserver.infra.cron.LogCleanupJob; -import life.mosu.mosuserver.infra.cron.di.AutowiringSpringBeanJobFactory; -import lombok.RequiredArgsConstructor; -import org.quartz.CronScheduleBuilder; -import org.quartz.JobBuilder; -import org.quartz.JobDetail; -import org.quartz.Trigger; -import org.quartz.TriggerBuilder; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.quartz.SchedulerFactoryBean; - -@Configuration -@RequiredArgsConstructor -public class ArchivingOrchestratorCronConfig { - - private final AutowireCapableBeanFactory beanFactory; - - @Bean - public JobDetail starvationCleanUpJobDetail() { - return JobBuilder.newJob(ArchivingOrchestratorJob.class) - .withIdentity("starvationCleanupJob") - .storeDurably() - .build(); - } - - @Bean - public Trigger starvationCleanupTrigger(JobDetail starvationCleanUpJobDetail) { - return TriggerBuilder.newTrigger() - .forJob(starvationCleanUpJobDetail) - .withIdentity("starvationCleanupTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0/30 2-5 * * ?")) -// .withSchedule(CronScheduleBuilder.cronSchedule("0/10 * * * * ?")) - .build(); - } - - @Bean - public JobDetail logCleanupJobDetail() { - return JobBuilder.newJob(LogCleanupJob.class) - .withIdentity("logCleanupJob") - .storeDurably() - .build(); - } - - @Bean - public Trigger logCleanupTrigger(JobDetail logCleanupJobDetail) { - return TriggerBuilder.newTrigger() - .forJob(logCleanupJobDetail) - .withIdentity("logCleanupTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 1 1/3 ?")) // 매 3개월마다 1일 03시 - .build(); - } - - @Bean - public AutowiringSpringBeanJobFactory springBeanJobFactory() { - return new AutowiringSpringBeanJobFactory(beanFactory); - } - - @Bean - public SchedulerFactoryBean schedulerFactoryBean( - Trigger starvationCleanupTrigger, - Trigger logCleanupTrigger, - JobDetail starvationCleanUpJobDetail, - JobDetail logCleanupJobDetail - ) { - SchedulerFactoryBean factory = new SchedulerFactoryBean(); - factory.setJobFactory(springBeanJobFactory()); - factory.setJobDetails(starvationCleanUpJobDetail, logCleanupJobDetail); - factory.setTriggers(starvationCleanupTrigger, logCleanupTrigger); - return factory; - } -} 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/config/QuartzAutoRegisterConfig.java b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java new file mode 100644 index 00000000..c9ff7922 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/config/QuartzAutoRegisterConfig.java @@ -0,0 +1,133 @@ +package life.mosu.mosuserver.infra.config; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import life.mosu.mosuserver.infra.cron.support.AutowiringSpringBeanJobFactory; +import lombok.RequiredArgsConstructor; +import org.quartz.CronScheduleBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.BeanCreationException; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; + +@Configuration +@RequiredArgsConstructor +public class QuartzAutoRegisterConfig { + + private final AutowireCapableBeanFactory beanFactory; + private final ApplicationContext applicationContext; + + @Bean + public SchedulerFactoryBean schedulerFactoryBean() { + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setJobFactory(new AutowiringSpringBeanJobFactory(beanFactory)); + + List jobDetails = new ArrayList<>(); + List triggers = new ArrayList<>(); + + for (Object jobBean : findQuartzScheduledBeans().values()) { + try { + JobDetail jobDetail = createJobDetail(jobBean); + Trigger trigger = createTrigger(jobBean, jobDetail); + + jobDetails.add(jobDetail); + triggers.add(trigger); + } catch (Exception e) { + throw new BeanCreationException("Failed to register Quartz job", e); + } + } + + factory.setJobDetails(jobDetails.toArray(new JobDetail[0])); + factory.setTriggers(triggers.toArray(new Trigger[0])); + + return factory; + } + + /** + * @QuartzScheduled 붙은 Bean 스캔 + */ + private Map findQuartzScheduledBeans() { + return applicationContext.getBeansWithAnnotation(CronJob.class); + } + + /** + * JobDetail 생성 + */ + @SuppressWarnings("unchecked") + private JobDetail createJobDetail(Object jobBean) { + Class jobClass = AopUtils.getTargetClass(jobBean); + CronJob annotation = getQuartzScheduledAnnotation(jobClass); + + validateJobClass(jobClass); + + String name = resolveJobName(jobClass, annotation); + + return JobBuilder.newJob((Class) jobClass) + .withIdentity(name) + .storeDurably() + .build(); + } + + /** + * Trigger 생성 + */ + private Trigger createTrigger(Object jobBean, JobDetail jobDetail) { + Class jobClass = AopUtils.getTargetClass(jobBean); + CronJob annotation = getQuartzScheduledAnnotation(jobClass); + + CronScheduleBuilder cronSchedule = buildCronSchedule(annotation.cron()); + + return TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(jobDetail.getKey().getName() + "Trigger") + .withSchedule(cronSchedule) + .build(); + } + + /** + * CronScheduleBuilder 생성 (검증 포함) + */ + private CronScheduleBuilder buildCronSchedule(String cron) { + try { + return CronScheduleBuilder.cronSchedule(cron) + .withMisfireHandlingInstructionDoNothing(); + } catch (Exception e) { + throw new IllegalArgumentException("잘못된 cron 표현식: " + cron, e); + } + } + + /** + * QuartzScheduled 애노테이션 가져오기 + */ + private CronJob getQuartzScheduledAnnotation(Class jobClass) { + return jobClass.getAnnotation(CronJob.class); + } + + /** + * Job 클래스 타입 검증 + */ + private void validateJobClass(Class jobClass) { + if (!Job.class.isAssignableFrom(jobClass)) { + throw new IllegalStateException(jobClass.getName() + "는 org.quartz.Job을 구현해야 합니다."); + } + } + + /** + * Job 이름 결정 + */ + private String resolveJobName(Class jobClass, CronJob annotation) { + String defaultName = jobClass.getPackageName() + "." + jobClass.getSimpleName(); + return annotation.name().isEmpty() ? defaultName : annotation.name(); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java deleted file mode 100644 index 8ec20eee..00000000 --- a/src/main/java/life/mosu/mosuserver/infra/cron/ArchivingOrchestratorJob.java +++ /dev/null @@ -1,66 +0,0 @@ -package life.mosu.mosuserver.infra.cron; - -import jakarta.annotation.PreDestroy; -import java.util.List; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import life.mosu.mosuserver.global.support.DomainArchiver; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.quartz.Job; -import org.quartz.JobExecutionContext; -import org.quartz.JobExecutionException; -import org.springframework.stereotype.Component; - - -@Component -@RequiredArgsConstructor -@Slf4j -public class ArchivingOrchestratorJob implements Job { - - private static final int corePoolSize = 2; - private final List domainArchivers; - private final ScheduledExecutorService executor = Executors.newScheduledThreadPool( - corePoolSize, - runnable -> { - Thread t = new Thread(runnable, "archiving-orchestrator"); - t.setDaemon(true); - return t; - }); - - @Override - public void execute(JobExecutionContext ctx) throws JobExecutionException { - if (domainArchivers == null || domainArchivers.isEmpty()) { - log.info("No domain archivers configured, skipping execution"); - return; - } - - for (int i = 0; i < domainArchivers.size(); ++i) { - DomainArchiver archiver = domainArchivers.get(i); - long delayMinutes = i * 10L; - log.info("Scheduling {} to run in {} minutes", archiver.getName(), delayMinutes); - executor.schedule(() -> { - try { - archiver.archive(); - log.debug("Archiving completed for {}", archiver.getName()); - } catch (Exception e) { - log.error("Archiving failed for {}", archiver.getName(), e); - } - }, delayMinutes, TimeUnit.SECONDS); // 10초 간격으로 시간차 줌 (Schedule 무시 될 때도 있음) - } - } - - @PreDestroy - public void shutdown() { - executor.shutdown(); - try { - if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - } - } -} diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java new file mode 100644 index 00000000..01231507 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronJob.java @@ -0,0 +1,17 @@ +package life.mosu.mosuserver.infra.cron.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Component; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Component +public @interface CronJob { + + String cron(); + + String name() default ""; +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java new file mode 100644 index 00000000..7b0491b9 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/annotation/CronTarget.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.infra.cron.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.springframework.stereotype.Component; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Component +public @interface CronTarget { + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java new file mode 100644 index 00000000..c77946e8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/ArchivingOrchestratorJob.java @@ -0,0 +1,40 @@ +package life.mosu.mosuserver.infra.cron.job; + +import java.util.List; +import life.mosu.mosuserver.global.support.cron.DomainArchiveExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; +import org.quartz.Job; +import org.quartz.JobExecutionContext; + +@Slf4j +@CronJob( + cron = "0 0 4 * * ?", // 매일 새벽 2시에 실행 + name = "archivingOrchestratorJob" +) +@DisallowConcurrentExecution +@RequiredArgsConstructor +public class ArchivingOrchestratorJob implements Job { + + private final List domainArchiveExecutors; + + @Override + public void execute(JobExecutionContext ctx) { + if (domainArchiveExecutors == null || domainArchiveExecutors.isEmpty()) { + log.info("No domain archivers configured, skipping execution"); + return; + } + + for (DomainArchiveExecutor archiver : domainArchiveExecutors) { + try { + log.info("Starting archive for {}", archiver.getName()); + archiver.archive(); + log.info("Archiving completed for {}", archiver.getName()); + } catch (Exception e) { + log.error("Archiving failed for {}", archiver.getName(), e); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java similarity index 65% rename from src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java rename to src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java index e8b50275..99f34460 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/LogCleanupJob.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/job/LogCleanupJob.java @@ -1,27 +1,31 @@ -package life.mosu.mosuserver.infra.cron; +package life.mosu.mosuserver.infra.cron.job; import java.time.LocalDateTime; import java.util.List; -import life.mosu.mosuserver.global.support.LogCleanup; +import life.mosu.mosuserver.global.support.cron.LogCleanupExecutor; +import life.mosu.mosuserver.infra.cron.annotation.CronJob; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; import org.quartz.Job; import org.quartz.JobExecutionContext; -import org.springframework.stereotype.Component; @Slf4j -@Component +@CronJob( + cron = "0 0 3 1 1/3 ?", + name = "logCleanupJob" +) +@DisallowConcurrentExecution @RequiredArgsConstructor public class LogCleanupJob implements Job { - //추가 분리 - private final List cleanups; + private final List cleanups; @Override public void execute(JobExecutionContext context) { LocalDateTime threshold = LocalDateTime.now().minusMonths(3); - for (LogCleanup cleanup : cleanups) { + for (LogCleanupExecutor cleanup : cleanups) { try { int deleted = cleanup.deleteLogsBefore(threshold); log.info("[LogCleanupJob] Deleted total {} logs older than {}", deleted, threshold); @@ -30,6 +34,5 @@ public void execute(JobExecutionContext context) { cleanup.getClass().getSimpleName(), e); } } - } } diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java b/src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java similarity index 92% rename from src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java rename to src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java index 643b3045..f61e839c 100644 --- a/src/main/java/life/mosu/mosuserver/infra/cron/di/AutowiringSpringBeanJobFactory.java +++ b/src/main/java/life/mosu/mosuserver/infra/cron/support/AutowiringSpringBeanJobFactory.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.infra.cron.di; +package life.mosu.mosuserver.infra.cron.support; import lombok.RequiredArgsConstructor; import org.quartz.spi.TriggerFiredBundle; diff --git a/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java b/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java new file mode 100644 index 00000000..c5a57245 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/cron/support/CronJobExecutor.java @@ -0,0 +1,5 @@ +package life.mosu.mosuserver.infra.cron.support; + +public interface CronJobExecutor { + +} diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java new file mode 100644 index 00000000..b80a4bdb --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/redis/support/LuaScriptsFunctionalRegistrar.java @@ -0,0 +1,90 @@ +package life.mosu.mosuserver.infra.persistence.redis.support; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.support.GenericApplicationContext; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.data.redis.core.script.DefaultRedisScript; + +/** + * FQCN + */ +@Slf4j +public class LuaScriptsFunctionalRegistrar implements + ApplicationContextInitializer { + + 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/infra/persistence/s3/AttachmentService.java b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java index 083b892b..19bca804 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/AttachmentService.java @@ -7,4 +7,6 @@ public interface AttachmentService { void createAttachment(List fileRequests, E entity); void deleteAttachment(E entity); + + void updateAttachment(List fileRequests, E entity); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java index cb3b7623..8bf7da39 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminInquiryController.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.presentation.admin; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.application.inquiry.InquiryAnswerService; import life.mosu.mosuserver.application.inquiry.InquiryService; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; @@ -15,6 +16,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -50,20 +52,22 @@ public ResponseEntity>> getInquiryList( @PostMapping("/{postId}/answer") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> inquiryAnswer( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long postId, @RequestBody InquiryAnswerRequest request ) { - inquiryAnswerService.createInquiryAnswer(postId, request); + inquiryAnswerService.createInquiryAnswer(postId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "답변 등록 성공")); } @PutMapping("/{postId}/answer") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> updateInquiryAnswer( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long postId, @RequestBody InquiryAnswerUpdateRequest request ) { - inquiryAnswerService.updateInquiryAnswer(postId, request); + inquiryAnswerService.updateInquiryAnswer(postId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "답변 수정 성공")); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java index 2985ca47..47a633b4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/docs/AdminInquiryControllerDocs.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerRequest; @@ -16,6 +17,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -42,6 +44,8 @@ ResponseEntity>> getInquiryList( @ApiResponse(responseCode = "200", description = "답변 등록 성공") }) ResponseEntity> inquiryAnswer( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "postId", description = "답변을 등록할 문의의 ID", in = ParameterIn.PATH) @PathVariable Long postId, @Parameter(description = "답변 내용") @@ -53,6 +57,8 @@ ResponseEntity> inquiryAnswer( @ApiResponse(responseCode = "200", description = "답변 수정 성공") }) ResponseEntity> updateInquiryAnswer( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "postId", description = "답변을 수정할 문의의 ID", in = ParameterIn.PATH) @PathVariable Long postId, @Parameter(description = "수정할 답변 내용") 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/dto/InquiryAnswerRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java index 01d5d075..ca422a90 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import java.util.List; import life.mosu.mosuserver.domain.inquiryAnswer.entity.InquiryAnswerJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record InquiryAnswerRequest( @@ -11,17 +12,16 @@ public record InquiryAnswerRequest( @NotNull String title, @Schema(description = "문의 내용", example = "포인트는 어떻게 사용하나요?") @NotNull String content, - @Schema(description = "작성자 ID 추후 토큰에서 추출하도록 변경 예정입니다.", example = "12") - Long userId, List attachments ) { - public InquiryAnswerJpaEntity toEntity(Long postId) { + public InquiryAnswerJpaEntity toEntity(Long postId, UserJpaEntity user) { return InquiryAnswerJpaEntity.builder() .inquiryId(postId) .title(title) .content(content) - .userId(userId) + .userId(user.getId()) + .author(user.getName()) .build(); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java index 265383b2..d3b4a7ba 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryCreateRequest.java @@ -2,14 +2,19 @@ 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.domain.inquiry.entity.InquiryJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record InquiryCreateRequest( + + @Size(max = 100, 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/java/life/mosu/mosuserver/presentation/notice/NoticeController.java b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java index a9207073..f863fe1d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeController.java @@ -2,7 +2,9 @@ import jakarta.validation.Valid; import java.util.List; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.application.notice.NoticeService; +import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; import life.mosu.mosuserver.presentation.notice.dto.NoticeDetailResponse; @@ -12,6 +14,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -32,8 +35,9 @@ public class NoticeController implements NoticeControllerDocs { @PostMapping @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> createNotice( + @AuthenticationPrincipal PrincipalDetails principalDetails, @Valid @RequestBody NoticeCreateRequest request) { - noticeService.createNotice(request); + noticeService.createNotice(request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.CREATED, "게시글 등록 성공")); } @@ -63,10 +67,11 @@ public ResponseEntity> deleteNotice(@PathVariable Long @PutMapping("/{noticeId}") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> updateNotice( + @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long noticeId, @Valid @RequestBody NoticeUpdateRequest request ) { - noticeService.updateNotice(noticeId, request); + noticeService.updateNotice(noticeId, request, principalDetails.user()); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "게시글 수정 성공")); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java index 2310bc7d..7652e558 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/NoticeControllerDocs.java @@ -10,12 +10,14 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import java.util.List; +import life.mosu.mosuserver.application.auth.PrincipalDetails; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.notice.dto.NoticeCreateRequest; import life.mosu.mosuserver.presentation.notice.dto.NoticeDetailResponse; import life.mosu.mosuserver.presentation.notice.dto.NoticeResponse; import life.mosu.mosuserver.presentation.notice.dto.NoticeUpdateRequest; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -28,7 +30,11 @@ public interface NoticeControllerDocs { @ApiResponse(responseCode = "201", description = "공지사항 등록 성공") }) ResponseEntity> createNotice( - @Parameter(description = "공지사항 생성에 필요한 정보") @Valid @RequestBody NoticeCreateRequest request + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, + + @Parameter(description = "공지사항 생성에 필요한 정보") + @Valid @RequestBody NoticeCreateRequest request ); @Operation(summary = "공지사항 목록 조회", description = "공지사항 리스트를 페이징하여 조회합니다.") @@ -67,6 +73,8 @@ ResponseEntity> deleteNotice( @ApiResponse(responseCode = "200", description = "공지사항 수정 성공") }) ResponseEntity> updateNotice( + @Parameter(hidden = true, description = "요청을 보낸 사용자 ID (관리자 권한 필요)") + @AuthenticationPrincipal PrincipalDetails principalDetails, @Parameter(name = "noticeId", description = "수정할 공지사항 ID", in = ParameterIn.PATH) @PathVariable Long noticeId, @Parameter(description = "수정할 공지사항 정보") diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java index 49ec21dc..0b5e9424 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeCreateRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.NotNull; import java.util.List; import life.mosu.mosuserver.domain.notice.entity.NoticeJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.presentation.common.FileRequest; public record NoticeCreateRequest( @@ -14,23 +15,17 @@ public record NoticeCreateRequest( @Schema(description = "공지사항 본문 내용", example = "6월 30일 오전 2시부터 서비스 점검이 진행됩니다.") @NotNull String content, - @Schema(description = "작성자 이름", example = "관리자") - @NotNull String author, - - @Schema(description = "작성자 ID (추후 토큰 기반 자동 추출 예정)", example = "1") - Long userId, - @Schema(description = "첨부파일 리스트 (S3 key 포함)") List attachments ) { - public NoticeJpaEntity toEntity() { + public NoticeJpaEntity toEntity(UserJpaEntity user) { return NoticeJpaEntity.builder() .title(title) .content(content) - .userId(userId) - .author(author) + .userId(user.getId()) + .author(user.getName()) .build(); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java index 965a5989..7cd77fca 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeResponse.java @@ -20,8 +20,6 @@ public record NoticeResponse( @Schema(description = "작성일시 (yyyy-MM-dd)", example = "2025-07-08") String createdAt -// @Schema(description = "첨부파일 목록") -// List attachments ) { public static NoticeResponse of(NoticeJpaEntity notice) { @@ -31,18 +29,6 @@ public static NoticeResponse of(NoticeJpaEntity notice) { notice.getContent(), notice.getAuthor(), notice.getCreatedAt() -// attachments ); } - -// public record AttachmentResponse( -// -// @Schema(description = "파일 이름", example = "service_guide.pdf") -// String fileName, -// -// @Schema(description = "S3 Presigned URL", example = "https://bucket.s3.amazonaws.com/.../service_guide.pdf") -// String url -// ) { -// -// } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java index 1e1959f7..e88ac79a 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/notice/dto/NoticeUpdateRequest.java @@ -15,13 +15,6 @@ public record NoticeUpdateRequest( @NotNull String content, - @Schema(description = "작성자 이름", example = "관리자") - @NotNull - String author, - - @Schema(description = "작성자 ID (토큰에서 추출 예정)", example = "42") - Long userId, - @Schema(description = "첨부파일 목록") 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/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