From bfb5d77f7a15dd140ef7445bd6da5dd08815f46a Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 18 Jul 2026 16:19:12 +0900 Subject: [PATCH 001/130] =?UTF-8?q?[Feat]=20=EB=AC=B8=EC=A0=9C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B3=B5=EC=8A=B5=20=EC=95=8C=EB=A6=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EA=B5=AC=EC=A1=B0=20=EC=B6=94=EA=B0=80=20-=20Flywa?= =?UTF-8?q?y=20=EC=98=88=EC=95=BD=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V21 Flyway migration: problem_review_reminder 테이블 추가 (status, scheduled_at 인덱스 포함) - ProblemReviewReminderStatus 상태 enum (SCHEDULED/SENDING/SENT/FAILED/CANCELED/SKIPPED_BY_COMPLETION/EXPIRED) - ProblemReviewReminder 엔티티 (userId, problemId, sequence, intervalDays, scheduledAt, snapshot 필드) - ProblemReviewReminderRepository: 선점 update, 발송 완료/실패, stuck 복구, cancel/skip 쿼리 포함 - ProblemReviewReminderPolicy: D+1/3/7/14/30 간격, 야간(00:00~05:59) → 06:00 조정, FCM 페이로드 생성 - ProblemCreatedEvent: Problem 저장 커밋 후 reminder 예약 생성을 위한 Spring 이벤트 Co-Authored-By: Claude Sonnet 4.6 --- .../problem/event/ProblemCreatedEvent.java | 8 ++ .../reminder/ProblemReviewReminder.java | 74 +++++++++++++++ .../reminder/ProblemReviewReminderPolicy.java | 50 ++++++++++ .../ProblemReviewReminderRepository.java | 94 +++++++++++++++++++ .../reminder/ProblemReviewReminderStatus.java | 5 + .../V21__add_problem_review_reminder.sql | 31 ++++++ 6 files changed, 262 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java create mode 100644 src/main/resources/db/migration/V21__add_problem_review_reminder.sql diff --git a/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java b/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java new file mode 100644 index 00000000..27e75ae2 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/event/ProblemCreatedEvent.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.problem.event; + +import java.time.LocalDateTime; +import java.util.List; + +public record ProblemCreatedEvent(Long userId, List problems) { + public record ProblemData(Long problemId, String memo, String reference, LocalDateTime createdAt) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java new file mode 100644 index 00000000..c44eb685 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "problem_review_reminder", indexes = { + @Index(name = "idx_problem_review_reminder_due", columnList = "status, scheduled_at"), + @Index(name = "idx_problem_review_reminder_user_due", columnList = "user_id, status, scheduled_at"), + @Index(name = "idx_problem_review_reminder_problem", columnList = "problem_id, status"), +}, uniqueConstraints = { + @UniqueConstraint(name = "uq_problem_review_reminder_seq", columnNames = {"problem_id", "sequence"}) +}) +public class ProblemReviewReminder extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long problemId; + + private String problemMemoSnapshot; + + private String problemReferenceSnapshot; + + @Column(nullable = false) + private int sequence; + + @Column(nullable = false) + private int intervalDays; + + @Column(nullable = false) + private LocalDateTime scheduledAt; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 30) + private ProblemReviewReminderStatus status; + + private LocalDateTime sentAt; + + @Column(length = 500) + private String lastErrorMessage; + + @Column(nullable = false) + @Builder.Default + private int retryCount = 0; + + public static ProblemReviewReminder create( + Long userId, Long problemId, String memo, String reference, + int sequence, int intervalDays, LocalDateTime scheduledAt) { + return ProblemReviewReminder.builder() + .userId(userId) + .problemId(problemId) + .problemMemoSnapshot(memo) + .problemReferenceSnapshot(reference) + .sequence(sequence) + .intervalDays(intervalDays) + .scheduledAt(scheduledAt) + .status(ProblemReviewReminderStatus.SCHEDULED) + .retryCount(0) + .build(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java new file mode 100644 index 00000000..4f2366b1 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java @@ -0,0 +1,50 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; +import java.util.Map; + +@Component +public class ProblemReviewReminderPolicy { + + private static final List INTERVALS = List.of(1, 3, 7, 14, 30); + private static final LocalTime NIGHT_END = LocalTime.of(6, 0); + private static final String NOTIFICATION_TITLE = "오답노트 복습"; + private static final String NOTIFICATION_BODY_DEFAULT = "그때 남긴 오답, 다시 풀어볼 시간이에요"; + private static final String NOTIFICATION_BODY_FALLBACK = "이전에 남긴 오답노트를 다시 풀어보세요"; + + public List getIntervals() { + return INTERVALS; + } + + public LocalDateTime calculateScheduledAt(LocalDateTime createdAt, int intervalDays) { + LocalDateTime candidate = createdAt.plusDays(intervalDays); + if (candidate.toLocalTime().isBefore(NIGHT_END)) { + return candidate.toLocalDate().atTime(NIGHT_END); + } + return candidate; + } + + public NotificationRequestDto buildNotification(ProblemReviewReminder reminder) { + boolean hasContent = hasContent(reminder.getProblemMemoSnapshot()) + || hasContent(reminder.getProblemReferenceSnapshot()); + String body = hasContent ? NOTIFICATION_BODY_DEFAULT : NOTIFICATION_BODY_FALLBACK; + + Map data = Map.of( + "type", "problem_review_reminder", + "problemId", String.valueOf(reminder.getProblemId()), + "sequence", String.valueOf(reminder.getSequence()), + "intervalDays", String.valueOf(reminder.getIntervalDays()) + ); + + return new NotificationRequestDto("", NOTIFICATION_TITLE, body, data); + } + + private boolean hasContent(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java new file mode 100644 index 00000000..1dfe8efa --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -0,0 +1,94 @@ +package com.aisip.OnO.backend.problem.reminder; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface ProblemReviewReminderRepository extends JpaRepository { + + boolean existsByProblemIdAndSequence(Long problemId, int sequence); + + @Query("SELECT r FROM ProblemReviewReminder r WHERE r.status = :status AND r.scheduledAt <= :now AND r.deletedAt IS NULL ORDER BY r.userId ASC, r.scheduledAt ASC") + List findDueReminders( + @Param("status") ProblemReviewReminderStatus status, + @Param("now") LocalDateTime now + ); + + @Query("SELECT COUNT(r) > 0 FROM ProblemReviewReminder r WHERE r.userId = :userId AND r.status = :sent AND r.sentAt >= :startOfDay AND r.sentAt < :endOfDay AND r.deletedAt IS NULL") + boolean hasSentTodayForUser( + @Param("userId") Long userId, + @Param("sent") ProblemReviewReminderStatus sent, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :newStatus WHERE r.id = :id AND r.status = :expectedStatus") + int tryUpdateStatus( + @Param("id") Long id, + @Param("expectedStatus") ProblemReviewReminderStatus expectedStatus, + @Param("newStatus") ProblemReviewReminderStatus newStatus + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :sent, r.sentAt = :sentAt WHERE r.id = :id") + void markSent( + @Param("id") Long id, + @Param("sentAt") LocalDateTime sentAt, + @Param("sent") ProblemReviewReminderStatus sent + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = :errorMessage WHERE r.id = :id") + void markFailed( + @Param("id") Long id, + @Param("errorMessage") String errorMessage, + @Param("failed") ProblemReviewReminderStatus failed + ); + + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = 'stuck recovery' WHERE r.status = :sending AND r.updatedAt < :stuckBefore AND r.deletedAt IS NULL") + int recoverStuckRows( + @Param("sending") ProblemReviewReminderStatus sending, + @Param("failed") ProblemReviewReminderStatus failed, + @Param("stuckBefore") LocalDateTime stuckBefore + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + int cancelByProblem( + @Param("problemId") Long problemId, + @Param("canceled") ProblemReviewReminderStatus canceled, + @Param("pendingStatuses") List pendingStatuses + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.problemMemoSnapshot = :memo, r.problemReferenceSnapshot = :reference WHERE r.problemId = :problemId AND r.status = :scheduled AND r.deletedAt IS NULL") + int refreshSnapshot( + @Param("problemId") Long problemId, + @Param("memo") String memo, + @Param("reference") String reference, + @Param("scheduled") ProblemReviewReminderStatus scheduled + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.userId = :userId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + int cancelAllByUser( + @Param("userId") Long userId, + @Param("canceled") ProblemReviewReminderStatus canceled, + @Param("pendingStatuses") List pendingStatuses + ); + + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :skipped WHERE r.problemId = :problemId AND r.status = :scheduled AND r.scheduledAt <= :practicedAt AND r.deletedAt IS NULL") + int skipDuePendingByProblem( + @Param("problemId") Long problemId, + @Param("skipped") ProblemReviewReminderStatus skipped, + @Param("scheduled") ProblemReviewReminderStatus scheduled, + @Param("practicedAt") LocalDateTime practicedAt + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java new file mode 100644 index 00000000..314ebd43 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderStatus.java @@ -0,0 +1,5 @@ +package com.aisip.OnO.backend.problem.reminder; + +public enum ProblemReviewReminderStatus { + SCHEDULED, SENDING, SENT, FAILED, CANCELED, SKIPPED_BY_COMPLETION, EXPIRED +} diff --git a/src/main/resources/db/migration/V21__add_problem_review_reminder.sql b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql new file mode 100644 index 00000000..57f9fe16 --- /dev/null +++ b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql @@ -0,0 +1,31 @@ +CREATE TABLE problem_review_reminder ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + problem_id BIGINT NOT NULL, + problem_memo_snapshot VARCHAR(255) NULL, + problem_reference_snapshot VARCHAR(255) NULL, + sequence INT NOT NULL, + interval_days INT NOT NULL, + scheduled_at DATETIME NOT NULL, + status VARCHAR(30) NOT NULL, + sent_at DATETIME NULL, + last_error_message VARCHAR(500) NULL, + retry_count INT NOT NULL DEFAULT 0, + created_at DATETIME NULL, + updated_at DATETIME NULL, + deleted_at DATETIME NULL, + PRIMARY KEY (id) +); + +CREATE INDEX idx_problem_review_reminder_due + ON problem_review_reminder (status, scheduled_at); + +CREATE INDEX idx_problem_review_reminder_user_due + ON problem_review_reminder (user_id, status, scheduled_at); + +CREATE INDEX idx_problem_review_reminder_problem + ON problem_review_reminder (problem_id, status); + +ALTER TABLE problem_review_reminder + ADD CONSTRAINT uq_problem_review_reminder_seq + UNIQUE (problem_id, sequence); From 4f834b97cf8b0901757a5de0bef3eb6a5f6fa749 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 18 Jul 2026 16:19:31 +0900 Subject: [PATCH 002/130] =?UTF-8?q?[Feat]=20=EB=AC=B8=EC=A0=9C=20=EB=A7=9D?= =?UTF-8?q?=EA=B0=81=EA=B3=A1=EC=84=A0=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=B0=B0=EC=B9=98=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=EC=8B=9C=EA=B0=81=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=EC=82=AD=EC=A0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProblemReviewReminderService: AFTER_COMMIT 이벤트로 reminder 예약 생성, 5분 polling 발송 - 하루 1건 제한(sentAt 기준), notificationEnabled/FCM 토큰 없으면 skip - SENDING stuck row 10분 타임아웃 복구 - ProblemReviewReminderJob: Quartz job (0 */5 * ? * *, Asia/Seoul) - ProblemReviewReminderScheduler: @PostConstruct 스케줄 등록 - ProblemService: registerProblem/V2/Batch 후 ProblemCreatedEvent 발행, updateProblemInfo snapshot 갱신, delete 시 pending row cancel - ProblemSolveService: createProblemSolve 후 due pending row SKIPPED_BY_COMPLETION 처리 - UserService: deleteUserById 시 cancelAllByUser로 사용자 전체 pending row 일괄 취소 Co-Authored-By: Claude Sonnet 4.6 --- .../reminder/ProblemReviewReminderJob.java | 27 +++ .../ProblemReviewReminderScheduler.java | 53 ++++++ .../ProblemReviewReminderService.java | 177 ++++++++++++++++++ .../problem/service/ProblemService.java | 22 ++- .../service/ProblemSolveService.java | 9 + .../OnO/backend/user/service/UserService.java | 4 + 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java new file mode 100644 index 00000000..815d00be --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.problem.reminder; + +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobExecutionContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.quartz.QuartzJobBean; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +@Slf4j +public class ProblemReviewReminderJob extends QuartzJobBean { + + @Autowired + private ProblemReviewReminderService reminderService; + + @Override + protected void executeInternal(JobExecutionContext context) { + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + log.info("[ReviewReminder] polling 시작 - {}", now); + try { + reminderService.sendDueReminders(now); + } catch (Exception e) { + log.error("[ReviewReminder] polling 실패", e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java new file mode 100644 index 00000000..e519ac20 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderScheduler.java @@ -0,0 +1,53 @@ +package com.aisip.OnO.backend.problem.reminder; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.stereotype.Component; + +import java.util.TimeZone; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProblemReviewReminderScheduler { + + private static final String JOB_NAME = "problem-review-reminder"; + private static final String JOB_GROUP = "reminder"; + private static final String TRIGGER_NAME = "problem-review-reminder-trigger"; + private static final String CRON = "0 */5 * ? * *"; + + private final Scheduler scheduler; + + @PostConstruct + public void schedule() { + try { + JobDetail jobDetail = JobBuilder.newJob(ProblemReviewReminderJob.class) + .withIdentity(JOB_NAME, JOB_GROUP) + .storeDurably(true) + .build(); + + Trigger trigger = TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_NAME, JOB_GROUP) + .withSchedule( + CronScheduleBuilder.cronSchedule(CRON) + .inTimeZone(TimeZone.getTimeZone("Asia/Seoul")) + ) + .forJob(jobDetail) + .build(); + + scheduler.addJob(jobDetail, true); + + if (scheduler.checkExists(trigger.getKey())) { + scheduler.rescheduleJob(trigger.getKey(), trigger); + } else { + scheduler.scheduleJob(trigger); + } + + log.info("[ReviewReminder] 스케줄 등록 완료 - cron: {} (Asia/Seoul)", CRON); + } catch (SchedulerException e) { + log.error("[ReviewReminder] 스케줄 등록 실패", e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java new file mode 100644 index 00000000..0b617dfd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -0,0 +1,177 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProblemReviewReminderService { + + private static final int STUCK_TIMEOUT_MINUTES = 10; + private static final List PENDING_STATUSES = List.of(SCHEDULED, SENDING); + + private final ProblemReviewReminderRepository repository; + private final ProblemReviewReminderPolicy policy; + private final UserRepository userRepository; + private final FcmTokenRepository fcmTokenRepository; + private final FcmService fcmService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = REQUIRES_NEW) + public void handleProblemCreated(ProblemCreatedEvent event) { + try { + scheduleForNewProblems(event.userId(), event.problems()); + } catch (Exception e) { + log.error("[ReviewReminder] 자동 알림 예약 실패 - userId: {}", event.userId(), e); + } + } + + @Transactional(propagation = REQUIRES_NEW) + public void scheduleForNewProblems(Long userId, List problems) { + List intervals = policy.getIntervals(); + List reminders = new ArrayList<>(); + + for (ProblemCreatedEvent.ProblemData data : problems) { + for (int i = 0; i < intervals.size(); i++) { + int sequence = i + 1; + int intervalDays = intervals.get(i); + if (repository.existsByProblemIdAndSequence(data.problemId(), sequence)) { + continue; + } + LocalDateTime scheduledAt = policy.calculateScheduledAt(data.createdAt(), intervalDays); + reminders.add(ProblemReviewReminder.create( + userId, data.problemId(), data.memo(), data.reference(), + sequence, intervalDays, scheduledAt + )); + } + } + + if (!reminders.isEmpty()) { + repository.saveAll(reminders); + log.info("[ReviewReminder] 자동 알림 예약 생성 - userId: {}, 문제 {}개, row {}개", + userId, problems.size(), reminders.size()); + } + } + + @Transactional + public void cancelPendingByProblem(Long problemId) { + int count = repository.cancelByProblem(problemId, CANCELED, PENDING_STATUSES); + if (count > 0) { + log.info("[ReviewReminder] 문제 삭제로 알림 취소 - problemId: {}, {}건", problemId, count); + } + } + + @Transactional + public void refreshSnapshot(Long problemId, String memo, String reference) { + repository.refreshSnapshot(problemId, memo, reference, SCHEDULED); + } + + @Transactional + public void skipDuePendingByProblemSolve(Long userId, Long problemId, LocalDateTime practicedAt) { + int count = repository.skipDuePendingByProblem(problemId, SKIPPED_BY_COMPLETION, SCHEDULED, practicedAt); + if (count > 0) { + log.info("[ReviewReminder] 복습 완료로 due 알림 skip - userId: {}, problemId: {}, {}건", + userId, problemId, count); + } + } + + @Transactional + public void cancelAllByUser(Long userId) { + int count = repository.cancelAllByUser(userId, CANCELED, PENDING_STATUSES); + if (count > 0) { + log.info("[ReviewReminder] 사용자 탈퇴로 알림 취소 - userId: {}, {}건", userId, count); + } + } + + @Transactional + public void sendDueReminders(LocalDateTime now) { + recoverStuckRows(now); + + List dueRows = repository.findDueReminders(SCHEDULED, now); + if (dueRows.isEmpty()) return; + + Map> byUser = dueRows.stream() + .collect(Collectors.groupingBy(ProblemReviewReminder::getUserId)); + + LocalDate today = now.toLocalDate(); + LocalDateTime startOfDay = today.atStartOfDay(); + LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); + + for (Map.Entry> entry : byUser.entrySet()) { + Long userId = entry.getKey(); + List userRows = entry.getValue().stream() + .sorted(Comparator.comparing(ProblemReviewReminder::getScheduledAt)) + .toList(); + + if (repository.hasSentTodayForUser(userId, SENT, startOfDay, endOfDay)) { + continue; + } + + ProblemReviewReminder candidate = userRows.get(0); + + Optional userOpt = userRepository.findById(userId); + if (userOpt.isEmpty() || !userOpt.get().isNotificationEnabled()) { + continue; + } + + if (fcmTokenRepository.findAllByUserId(userId).isEmpty()) { + log.debug("[ReviewReminder] FCM 토큰 없음 - userId: {}", userId); + continue; + } + + int claimed = repository.tryUpdateStatus(candidate.getId(), SCHEDULED, SENDING); + if (claimed == 0) { + log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", candidate.getId()); + continue; + } + + try { + NotificationRequestDto dto = policy.buildNotification(candidate); + fcmService.sendNotificationToAllUserDevice(userId, dto); + repository.markSent(candidate.getId(), now, SENT); + log.info("[ReviewReminder] 발송 완료 - userId: {}, reminderId: {}, sequence: {}", + userId, candidate.getId(), candidate.getSequence()); + } catch (Exception e) { + repository.markFailed(candidate.getId(), truncateErrorMessage(e.getMessage()), FAILED); + log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, candidate.getId(), e); + } + } + } + + private void recoverStuckRows(LocalDateTime now) { + LocalDateTime stuckBefore = now.minusMinutes(STUCK_TIMEOUT_MINUTES); + int recovered = repository.recoverStuckRows(SENDING, FAILED, stuckBefore); + if (recovered > 0) { + log.warn("[ReviewReminder] SENDING stuck row 복구: {}건", recovered); + } + } + + private String truncateErrorMessage(String message) { + if (message == null) return null; + return message.length() > 500 ? message.substring(0, 500) : message; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 823a47e4..78b08d18 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -38,6 +38,8 @@ import com.aisip.OnO.backend.tag.exception.TagErrorCase; import com.aisip.OnO.backend.tag.repository.ProblemTagMappingRepository; import com.aisip.OnO.backend.tag.repository.TagRepository; +import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -93,6 +95,7 @@ public class ProblemService { private final RateLimitService rateLimitService; private final StreakCacheService streakCacheService; private final ApplicationEventPublisher eventPublisher; + private final ProblemReviewReminderService reminderService; @Transactional(readOnly = true) public ProblemResponseDto findProblemForAdmin(Long problemId) { @@ -221,6 +224,9 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) missionLogService.registerProblemWriteMission(userId); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( + new ProblemCreatedEvent.ProblemData(problem.getId(), problem.getMemo(), problem.getReference(), problem.getCreatedAt()) + ))); log.info("userId: {} register problemId: {}", userId, problem.getId()); @@ -280,6 +286,9 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us missionLogService.registerProblemWriteMission(userId); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( + new ProblemCreatedEvent.ProblemData(problem.getId(), problem.getMemo(), problem.getReference(), problem.getCreatedAt()) + ))); log.info("userId: {} register problem(v2) problemId: {}", userId, problem.getId()); return problem.getId(); @@ -348,6 +357,11 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 List problemIds = problems.stream() .map(Problem::getId) .toList(); + + eventPublisher.publishEvent(new ProblemCreatedEvent(userId, problems.stream() + .map(p -> new ProblemCreatedEvent.ProblemData(p.getId(), p.getMemo(), p.getReference(), p.getCreatedAt())) + .toList())); + log.info("userId: {} register problems(v2 batch) problemIds: {}", userId, problemIds); return problemIds; } @@ -560,6 +574,7 @@ public void updateProblemInfo(ProblemRegisterDto problemRegisterDto, Long userId problem.updateProblem(problemRegisterDto); syncProblemTags(problem, userId, problemRegisterDto.tagIds()); + reminderService.refreshSnapshot(problem.getId(), problem.getMemo(), problem.getReference()); log.info("userId: {} update problemId: {}", userId, problem.getId()); } @@ -702,12 +717,15 @@ private void deleteProblemWithoutOwnerCheck(Long problemId) { // 4. PracticeNote 매핑 삭제 (동기 - 데이터 정합성 보장) practiceNoteRepository.deleteProblemFromAllPractice(problemId); - // 5. 문제 삭제 (Soft Delete) + // 5. 미발송 알림 예약 취소 + reminderService.cancelPendingByProblem(problemId); + + // 6. 문제 삭제 (Soft Delete) problemRepository.deleteById(problemId); log.info("problemId: {} DB 삭제 완료", problemId); - // 6. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) + // 7. S3 파일 삭제는 비동기로 처리 (RabbitMQ Producer) imageDataList.forEach(imageData -> { try { s3DeleteProducer.sendDeleteMessage(imageData.getImageUrl(), problemId); diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index 1519e386..af04f891 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.util.redis.StreakCacheService; import com.aisip.OnO.backend.problem.service.ReviewIntervalCalculator; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; @@ -27,6 +28,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import java.util.Objects; @@ -50,6 +53,7 @@ public class ProblemSolveService { private final ObjectMapper objectMapper; private final StreakCacheService streakCacheService; private final ApplicationEventPublisher eventPublisher; + private final ProblemReviewReminderService reminderService; @Qualifier("s3UploadExecutor") private final Executor s3UploadExecutor; @@ -142,6 +146,11 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PRACTICE_COMPLETED, java.util.Map.of())); + LocalDateTime practicedAt = dto.practicedAt() != null + ? dto.practicedAt() + : LocalDateTime.now(ZoneId.of("Asia/Seoul")); + reminderService.skipDuePendingByProblemSolve(userId, dto.problemId(), practicedAt); + log.info("userId: {} created problem solve: {}, nextReviewAt: {}, mastered: {}", userId, problemSolve.getId(), schedule.nextReviewAt(), schedule.isMastered()); diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index e4c5e0c1..51ddcd8e 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; @@ -53,6 +54,8 @@ public class UserService { private final PracticeNoteService practiceNoteService; + private final ProblemReviewReminderService reminderService; + private final StudyRoomSharedProblemCommentRepository sharedProblemCommentRepository; private final StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; @@ -205,6 +208,7 @@ public void deleteUserById(Long userId) { sharedProblemCommentReactionRepository.deleteByUserId(userId); sharedProblemCommentRepository.deleteByAuthorId(userId); practiceNoteService.deleteAllPracticesByUser(userId); + reminderService.cancelAllByUser(userId); problemService.deleteAllUserProblems(userId); folderService.deleteAllUserFolders(userId); From e6e63d1b2b9fbd3081b1f13dbc37f6e9985b1704 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 21 Jul 2026 22:02:50 +0900 Subject: [PATCH 003/130] =?UTF-8?q?[Feat]=20=EB=AC=B8=EC=A0=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20URL=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=8B=B9=EC=9D=BC=20=EB=B3=B5=EC=8A=B5?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A4=91=EB=B3=B5=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/problems/imageData 엔드포인트 추가 (Flutter 문제 수정 플로우) - ProblemService에 validateSolveImageNotRegisteredToday 검증 로직 추가 - ProblemImageDataRepository에 당일 중복 확인 쿼리 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../problem/controller/ProblemController.java | 18 +++++++++++++++ .../dto/UpdateProblemImageDataRequest.java | 12 ++++++++++ .../ProblemImageDataRepository.java | 9 ++++++++ .../problem/service/ProblemService.java | 23 +++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java diff --git a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java index 612c3686..cd6511af 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java +++ b/src/main/java/com/aisip/OnO/backend/problem/controller/ProblemController.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.problem.dto.AddProblemImageUrlsRequest; +import com.aisip.OnO.backend.problem.dto.UpdateProblemImageDataRequest; import com.aisip.OnO.backend.problem.dto.ProblemAnalysisResponseDto; import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; @@ -240,6 +241,23 @@ public CommonResponse deleteUserProblems( return CommonResponse.success("유저의 모든 문제가 삭제되었습니다."); } + // ✅ 문제 이미지 URL 수정 (Flutter 문제 수정 플로우) + @PatchMapping("/imageData") + public CommonResponse updateProblemImageData( + @Validated @RequestBody UpdateProblemImageDataRequest request + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long problemId = request.problemId(); + AddProblemImageUrlsRequest converted = new AddProblemImageUrlsRequest( + request.imageDataDtoList().stream() + .map(item -> new AddProblemImageUrlsRequest.ImageUrlItem(item.imageUrl(), item.problemImageType())) + .toList() + ); + problemService.addImageDataUrls(problemId, userId, converted); + problemService.analysisProblem(problemId, userId); + return CommonResponse.success(null); + } + // ✅ 문제 이미지 데이터 삭제 @DeleteMapping("/imageData") public CommonResponse deleteProblemImageData(@RequestParam("imageUrl") String imageUrl) { diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java b/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java new file mode 100644 index 00000000..e32f1f75 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/UpdateProblemImageDataRequest.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.problem.dto; + +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record UpdateProblemImageDataRequest( + @NotNull Long problemId, + @NotNull List imageDataDtoList +) { + public record ImageItem(String imageUrl, String problemImageType) {} +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java index 50d5c8ac..8c4e8c76 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java @@ -1,8 +1,10 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.problem.entity.ProblemImageData; +import com.aisip.OnO.backend.problem.entity.ProblemImageType; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -10,6 +12,13 @@ public interface ProblemImageDataRepository extends JpaRepository findAllByProblemId(Long problemId); + boolean existsByProblemIdAndProblemImageTypeAndCreatedAtBetween( + Long problemId, + ProblemImageType problemImageType, + LocalDateTime startOfDay, + LocalDateTime endOfDay + ); + Optional findByImageUrl(String imageUrl); void deleteAllByProblemId(Long problemId); diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 78b08d18..5d67d253 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -51,6 +51,7 @@ import java.time.LocalDate; import java.time.LocalTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; import java.util.EnumMap; @@ -480,6 +481,8 @@ public void uploadProblemImages(Long problemId, Long userId, List MultipartFile imageFile = images.get(i); ProblemImageType imageType = imageTypes.get(i); + validateSolveImageNotRegisteredToday(problemId, imageType); + // S3에 업로드 String imageUrl = fileUploadService.uploadFileToS3(imageFile); @@ -503,6 +506,7 @@ public void addImageDataUrls(Long problemId, Long userId, AddProblemImageUrlsReq for (AddProblemImageUrlsRequest.ImageUrlItem item : request.imageDataList()) { fileUploadService.validateS3Url(item.imageUrl()); ProblemImageType imageType = ProblemImageType.valueOf(item.problemImageType()); + validateSolveImageNotRegisteredToday(problemId, imageType); ProblemImageData imageData = ProblemImageData.from( new ProblemImageDataRegisterDto(problemId, item.imageUrl(), imageType)); imageData.updateProblem(problem); @@ -513,6 +517,25 @@ public void addImageDataUrls(Long problemId, Long userId, AddProblemImageUrlsReq } } + private void validateSolveImageNotRegisteredToday(Long problemId, ProblemImageType imageType) { + if (imageType != ProblemImageType.SOLVE_IMAGE) { + return; + } + + LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); + LocalTime startOfDay = LocalTime.MIN; + LocalTime endOfDay = LocalTime.MAX; + boolean alreadyRegistered = problemImageDataRepository.existsByProblemIdAndProblemImageTypeAndCreatedAtBetween( + problemId, + ProblemImageType.SOLVE_IMAGE, + today.atTime(startOfDay), + today.atTime(endOfDay) + ); + if (alreadyRegistered) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED); + } + } + /** * GPT 문제 분석 요청 (RabbitMQ 방식) * - 이미지 유무 확인 후 상태 업데이트 From 0d90fef3dbf5930fb397c96a0420b68bb67dc5ef Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 21 Jul 2026 22:03:02 +0900 Subject: [PATCH 004/130] =?UTF-8?q?[Feat]=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=EB=B0=9C=EC=86=A1=20=EB=B0=B0=EC=B9=98=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20-=20DB=20=ED=95=84=ED=84=B0=EB=A7=81=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findDueReminders에 당일 발송 여부·알림 설정·FCM 토큰 존재 여부를 서브쿼리로 통합, 배치 사이즈(500) 제한 추가 - ProblemReviewReminderSender 컴포넌트 분리 (REQUIRES_NEW 트랜잭션 단위별 처리) - tryUpdateStatus에 updatedAt 파라미터 추가 (stuck 회복 쿼리 정확도 향상) - FcmNotificationProducer RabbitMQ 실패 시 예외 throw로 변경 (발송 실패 추적 가능) Co-Authored-By: Claude Sonnet 4.6 --- .../producer/FcmNotificationProducer.java | 2 +- .../ProblemReviewReminderRepository.java | 43 +++++++++--- .../reminder/ProblemReviewReminderSender.java | 47 +++++++++++++ .../ProblemReviewReminderService.java | 68 ++++--------------- 4 files changed, 95 insertions(+), 65 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java index e64ba568..82a7f66f 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/producer/FcmNotificationProducer.java @@ -44,7 +44,7 @@ public void sendNotificationMessage(Long userId, String title, String body, Map< log.error("RabbitMQ message send failed - exchange: {}, routingKey: {}, operation: {}, userId: {}, error: {}", RabbitMQConfig.NOTIFICATION_EXCHANGE, RabbitMQConfig.FCM_NOTIFICATION_ROUTING_KEY, "fcm_notification", userId, e.getMessage(), e); - // 알림 전송 실패해도 예외를 던지지 않음 (알림은 선택적 기능) + throw new IllegalStateException("RabbitMQ FCM notification enqueue failed", e); } } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 1dfe8efa..48a0f3c4 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -1,9 +1,11 @@ package com.aisip.OnO.backend.problem.reminder; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -12,26 +14,46 @@ public interface ProblemReviewReminderRepository extends JpaRepository= :startOfDay + AND sent.sentAt < :endOfDay + AND sent.deletedAt IS NULL + ) + AND EXISTS ( + SELECT 1 FROM User u + WHERE u.id = r.userId + AND u.notificationEnabled = true + ) + AND EXISTS ( + SELECT 1 FROM FcmToken t + WHERE t.userId = r.userId + ) + ORDER BY r.scheduledAt ASC, r.userId ASC + """) List findDueReminders( @Param("status") ProblemReviewReminderStatus status, - @Param("now") LocalDateTime now - ); - - @Query("SELECT COUNT(r) > 0 FROM ProblemReviewReminder r WHERE r.userId = :userId AND r.status = :sent AND r.sentAt >= :startOfDay AND r.sentAt < :endOfDay AND r.deletedAt IS NULL") - boolean hasSentTodayForUser( - @Param("userId") Long userId, + @Param("now") LocalDateTime now, @Param("sent") ProblemReviewReminderStatus sent, @Param("startOfDay") LocalDateTime startOfDay, - @Param("endOfDay") LocalDateTime endOfDay + @Param("endOfDay") LocalDateTime endOfDay, + Pageable pageable ); @Modifying(clearAutomatically = true) - @Query("UPDATE ProblemReviewReminder r SET r.status = :newStatus WHERE r.id = :id AND r.status = :expectedStatus") + @Query("UPDATE ProblemReviewReminder r SET r.status = :newStatus, r.updatedAt = :updatedAt WHERE r.id = :id AND r.status = :expectedStatus") int tryUpdateStatus( @Param("id") Long id, @Param("expectedStatus") ProblemReviewReminderStatus expectedStatus, - @Param("newStatus") ProblemReviewReminderStatus newStatus + @Param("newStatus") ProblemReviewReminderStatus newStatus, + @Param("updatedAt") LocalDateTime updatedAt ); @Modifying(clearAutomatically = true) @@ -50,6 +72,7 @@ void markFailed( @Param("failed") ProblemReviewReminderStatus failed ); + @Transactional @Modifying(clearAutomatically = true) @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = 'stuck recovery' WHERE r.status = :sending AND r.updatedAt < :stuckBefore AND r.deletedAt IS NULL") int recoverStuckRows( diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java new file mode 100644 index 00000000..9a6da3de --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; + +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; +import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProblemReviewReminderSender { + + private final ProblemReviewReminderRepository repository; + private final ProblemReviewReminderPolicy policy; + private final FcmService fcmService; + + @Transactional(propagation = REQUIRES_NEW) + public void send(ProblemReviewReminder candidate, LocalDateTime now) { + Long userId = candidate.getUserId(); + + int claimed = repository.tryUpdateStatus(candidate.getId(), SCHEDULED, SENDING, now); + if (claimed == 0) { + log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", candidate.getId()); + return; + } + + try { + NotificationRequestDto dto = policy.buildNotification(candidate); + fcmService.sendNotificationToAllUserDevice(userId, dto); + repository.markSent(candidate.getId(), now, SENT); + log.info("[ReviewReminder] 발송 완료 - userId: {}, reminderId: {}, sequence: {}", + userId, candidate.getId(), candidate.getSequence()); + } catch (Exception e) { + String errorMsg = e.getMessage(); + if (errorMsg != null && errorMsg.length() > 500) errorMsg = errorMsg.substring(0, 500); + repository.markFailed(candidate.getId(), errorMsg, FAILED); + log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, candidate.getId(), e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index 0b617dfd..9a8d6b6e 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -1,13 +1,10 @@ package com.aisip.OnO.backend.problem.reminder; import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; -import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; -import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.event.TransactionPhase; @@ -20,7 +17,6 @@ import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; @@ -32,13 +28,13 @@ public class ProblemReviewReminderService { private static final int STUCK_TIMEOUT_MINUTES = 10; + private static final int DUE_REMINDER_BATCH_SIZE = 500; private static final List PENDING_STATUSES = List.of(SCHEDULED, SENDING); private final ProblemReviewReminderRepository repository; private final ProblemReviewReminderPolicy policy; - private final UserRepository userRepository; - private final FcmTokenRepository fcmTokenRepository; private final FcmService fcmService; + private final ProblemReviewReminderSender sender; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Transactional(propagation = REQUIRES_NEW) @@ -107,58 +103,26 @@ public void cancelAllByUser(Long userId) { } } - @Transactional public void sendDueReminders(LocalDateTime now) { recoverStuckRows(now); - List dueRows = repository.findDueReminders(SCHEDULED, now); - if (dueRows.isEmpty()) return; - - Map> byUser = dueRows.stream() - .collect(Collectors.groupingBy(ProblemReviewReminder::getUserId)); - LocalDate today = now.toLocalDate(); LocalDateTime startOfDay = today.atStartOfDay(); LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); - for (Map.Entry> entry : byUser.entrySet()) { - Long userId = entry.getKey(); - List userRows = entry.getValue().stream() - .sorted(Comparator.comparing(ProblemReviewReminder::getScheduledAt)) - .toList(); - - if (repository.hasSentTodayForUser(userId, SENT, startOfDay, endOfDay)) { - continue; - } - - ProblemReviewReminder candidate = userRows.get(0); - - Optional userOpt = userRepository.findById(userId); - if (userOpt.isEmpty() || !userOpt.get().isNotificationEnabled()) { - continue; - } - - if (fcmTokenRepository.findAllByUserId(userId).isEmpty()) { - log.debug("[ReviewReminder] FCM 토큰 없음 - userId: {}", userId); - continue; - } + List dueRows = repository.findDueReminders( + SCHEDULED, now, SENT, startOfDay, endOfDay, PageRequest.of(0, DUE_REMINDER_BATCH_SIZE) + ); + if (dueRows.isEmpty()) return; - int claimed = repository.tryUpdateStatus(candidate.getId(), SCHEDULED, SENDING); - if (claimed == 0) { - log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", candidate.getId()); - continue; - } + Map> byUser = dueRows.stream() + .collect(Collectors.groupingBy(ProblemReviewReminder::getUserId)); - try { - NotificationRequestDto dto = policy.buildNotification(candidate); - fcmService.sendNotificationToAllUserDevice(userId, dto); - repository.markSent(candidate.getId(), now, SENT); - log.info("[ReviewReminder] 발송 완료 - userId: {}, reminderId: {}, sequence: {}", - userId, candidate.getId(), candidate.getSequence()); - } catch (Exception e) { - repository.markFailed(candidate.getId(), truncateErrorMessage(e.getMessage()), FAILED); - log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, candidate.getId(), e); - } + for (Map.Entry> entry : byUser.entrySet()) { + ProblemReviewReminder candidate = entry.getValue().stream() + .min(Comparator.comparing(ProblemReviewReminder::getScheduledAt)) + .orElseThrow(); + sender.send(candidate, now); } } @@ -170,8 +134,4 @@ private void recoverStuckRows(LocalDateTime now) { } } - private String truncateErrorMessage(String message) { - if (message == null) return null; - return message.length() > 500 ? message.substring(0, 500) : message; - } } From 68597aac70b63eda9391147eb7e42d0e2c672742 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 21 Jul 2026 22:03:09 +0900 Subject: [PATCH 005/130] =?UTF-8?q?[Perf]=20V21=20DATETIME(6)=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EB=B0=8F=20V22=20=EB=88=84=EB=9D=BD=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V21: reminder 테이블 DATETIME → DATETIME(6) (마이크로초 정밀도, LocalDateTime 매핑 오류 방지) - V22: image_data (problem_id, image_type, created_at) 복합 인덱스 추가 - V22: problem_review_reminder (user_id, status, sent_at, deleted_at) 인덱스 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../db/migration/V21__add_problem_review_reminder.sql | 10 +++++----- .../db/migration/V22__add_missing_indexes.sql | 9 +++++++++ 2 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V22__add_missing_indexes.sql diff --git a/src/main/resources/db/migration/V21__add_problem_review_reminder.sql b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql index 57f9fe16..30ff2e23 100644 --- a/src/main/resources/db/migration/V21__add_problem_review_reminder.sql +++ b/src/main/resources/db/migration/V21__add_problem_review_reminder.sql @@ -6,14 +6,14 @@ CREATE TABLE problem_review_reminder ( problem_reference_snapshot VARCHAR(255) NULL, sequence INT NOT NULL, interval_days INT NOT NULL, - scheduled_at DATETIME NOT NULL, + scheduled_at DATETIME(6) NOT NULL, status VARCHAR(30) NOT NULL, - sent_at DATETIME NULL, + sent_at DATETIME(6) NULL, last_error_message VARCHAR(500) NULL, retry_count INT NOT NULL DEFAULT 0, - created_at DATETIME NULL, - updated_at DATETIME NULL, - deleted_at DATETIME NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, PRIMARY KEY (id) ); diff --git a/src/main/resources/db/migration/V22__add_missing_indexes.sql b/src/main/resources/db/migration/V22__add_missing_indexes.sql new file mode 100644 index 00000000..41d3e561 --- /dev/null +++ b/src/main/resources/db/migration/V22__add_missing_indexes.sql @@ -0,0 +1,9 @@ +-- image_data: problem_id + image_type + created_at 복합 인덱스 +-- validateSolveImageNotRegisteredToday 쿼리 최적화 +CREATE INDEX idx_image_data_problem_type_created + ON image_data (problem_id, image_type, created_at); + +-- problem_review_reminder: NOT EXISTS 서브쿼리 최적화 +-- findDueReminders의 (user_id, status, sent_at, deleted_at) 필터 +CREATE INDEX idx_reminder_sent + ON problem_review_reminder (user_id, status, sent_at, deleted_at); From d47ec1dbe83fbccc51c11cc2e91398f9eef3d559 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 21 Jul 2026 22:03:20 +0900 Subject: [PATCH 006/130] =?UTF-8?q?[Test]=20=EB=A6=AC=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=8D=94=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProblemApiIntegrationTest: 문제 등록/수정/삭제 시 reminder row 생성·갱신·취소 통합 테스트 추가 - ProblemApiIntegrationTest: 이미지 등록 테스트를 새 URL 기반 API로 수정 - StudyRoomWeeklyReportApiTest: StudyRoomWeeklyReport 생성자 변경(null 파라미터 추가) 반영 - StudyRoomFeedServiceUnitTest: StudyRoomChallengeService 목 추가 - ProblemReviewReminderServiceTest, ProblemReviewReminderPolicyTest 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../ProblemApiIntegrationTest.java | 284 +++++++---- .../ProblemReviewReminderPolicyTest.java | 147 ++++++ .../ProblemReviewReminderServiceTest.java | 464 ++++++++++++++++++ .../StudyRoomWeeklyReportApiTest.java | 8 +- .../service/StudyRoomFeedServiceUnitTest.java | 5 +- 5 files changed, 811 insertions(+), 97 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index 7916544e..8a43cf6a 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -10,12 +10,17 @@ import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.repository.FolderRepository; +import com.aisip.OnO.backend.problem.dto.AddProblemImageUrlsRequest; import com.aisip.OnO.backend.problem.dto.ProblemDeleteRequestDto; -import com.aisip.OnO.backend.problem.dto.ProblemImageDataRegisterDto; import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageData; import com.aisip.OnO.backend.problem.entity.ProblemImageType; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminder; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderRepository; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus; +import com.aisip.OnO.backend.problem.repository.ProblemAnalysisRepository; import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.fasterxml.jackson.databind.JsonNode; @@ -66,6 +71,12 @@ class ProblemApiIntegrationTest { @Autowired private ProblemImageDataRepository problemImageDataRepository; + @Autowired + private ProblemAnalysisRepository problemAnalysisRepository; + + @Autowired + private ProblemReviewReminderRepository reminderRepository; + @Autowired private FolderRepository folderRepository; @@ -110,6 +121,9 @@ void setUp() { Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folder, userId); problem.updateFolder(folder); problemRepository.save(problem); + ProblemAnalysis analysis = ProblemAnalysis.createSkipped(problem); + problem.updateProblemAnalysis(analysis); + problemAnalysisRepository.save(analysis); List imageDataList = RandomProblemGenerator.createDefaultProblemImageDataList(problem.getId()); @@ -124,9 +138,19 @@ void setUp() { } } + private void authenticateAsFixtureUser() { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + } + @AfterEach void tearDown() { SecurityContextHolder.clearContext(); + reminderRepository.deleteAll(); + problemAnalysisRepository.deleteAll(); problemImageDataRepository.deleteAll(problemImageDataList); problemRepository.deleteAll(problemList); folderRepository.deleteAll(folderList); @@ -254,23 +278,6 @@ void findAllFolderProblems() throws Exception { @WithMockCustomUser() void registerProblem() throws Exception { // given - List problemImageDataRegisterDtoList = List.of( - new ProblemImageDataRegisterDto( - null, - "problemImageUrl", - ProblemImageType.PROBLEM_IMAGE - ), - new ProblemImageDataRegisterDto( - null, - "answerImageUrl", - ProblemImageType.ANSWER_IMAGE - ), - new ProblemImageDataRegisterDto( - null, - "solveImageUrl", - ProblemImageType.SOLVE_IMAGE - ) - ); ProblemRegisterDto problemRegisterDto = new ProblemRegisterDto( null, "memo", @@ -297,7 +304,7 @@ void registerProblem() throws Exception { assertThat(problem.getMemo()).isEqualTo(problemRegisterDto.memo()); assertThat(problem.getReference()).isEqualTo(problemRegisterDto.reference()); - assertThat(problem.getProblemImageDataList().size()).isEqualTo(3); + assertThat(problem.getProblemImageDataList()).isEmpty(); } @Test @@ -305,17 +312,17 @@ void registerProblem() throws Exception { @WithMockCustomUser() void registerProblemImageData() throws Exception { // given + authenticateAsFixtureUser(); Long problemId = problemList.get(0).getId(); - ProblemImageDataRegisterDto problemImageDataRegisterDto = new ProblemImageDataRegisterDto( - problemId, - "solveImageUrl", - ProblemImageType.SOLVE_IMAGE - ); + int beforeCount = problemImageDataRepository.findAllByProblemId(problemId).size(); + AddProblemImageUrlsRequest request = new AddProblemImageUrlsRequest(List.of( + new AddProblemImageUrlsRequest.ImageUrlItem("problemImageUrl", ProblemImageType.PROBLEM_IMAGE.name()) + )); // when & then - MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/imageData") + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/{problemId}/imageData/urls", problemId) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(problemImageDataRegisterDto))) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andReturn(); @@ -331,8 +338,10 @@ void registerProblemImageData() throws Exception { Problem problem = optionalProblem.get(); List problemImageDataList = problem.getProblemImageDataList(); - assertThat(problemImageDataList.size()).isEqualTo(3); - assertThat(problemImageDataList.get(problemImageDataList.size() - 1).getImageUrl()).isEqualTo("solveImageUrl"); + assertThat(problemImageDataList).hasSize(beforeCount + 1); + assertThat(problemImageDataList) + .extracting(ProblemImageData::getImageUrl) + .contains("problemImageUrl"); } @Test @@ -340,29 +349,33 @@ void registerProblemImageData() throws Exception { @WithMockCustomUser() void registerProblemImageDataDuplicateSolveImage() throws Exception { // given - Long problemId = problemList.get(0).getId(); + authenticateAsFixtureUser(); + Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folderList.get(0), userId); + problem.updateFolder(folderList.get(0)); + problemRepository.save(problem); + ProblemAnalysis analysis = ProblemAnalysis.createSkipped(problem); + problem.updateProblemAnalysis(analysis); + problemAnalysisRepository.save(analysis); + problemList.add(problem); + Long problemId = problem.getId(); // 첫 번째 복습 이미지 등록 - ProblemImageDataRegisterDto firstSolveImage = new ProblemImageDataRegisterDto( - problemId, - "solveImageUrl1", - ProblemImageType.SOLVE_IMAGE - ); + AddProblemImageUrlsRequest firstSolveImage = new AddProblemImageUrlsRequest(List.of( + new AddProblemImageUrlsRequest.ImageUrlItem("solveImageUrl1", ProblemImageType.SOLVE_IMAGE.name()) + )); - mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/imageData") + mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/{problemId}/imageData/urls", problemId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(firstSolveImage))) .andExpect(status().isOk()); // 같은 날 두 번째 복습 이미지 등록 시도 - ProblemImageDataRegisterDto secondSolveImage = new ProblemImageDataRegisterDto( - problemId, - "solveImageUrl2", - ProblemImageType.SOLVE_IMAGE - ); + AddProblemImageUrlsRequest secondSolveImage = new AddProblemImageUrlsRequest(List.of( + new AddProblemImageUrlsRequest.ImageUrlItem("solveImageUrl2", ProblemImageType.SOLVE_IMAGE.name()) + )); // when & then - 예외 발생 확인 - MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/imageData") + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/{problemId}/imageData/urls", problemId) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(secondSolveImage))) .andExpect(status().isBadRequest()) @@ -379,8 +392,8 @@ void registerProblemImageDataDuplicateSolveImage() throws Exception { Optional optionalProblem = problemRepository.findProblemWithImageData(problemId); assertThat(optionalProblem.isPresent()).isTrue(); - Problem problem = optionalProblem.get(); - long solveImageCount = problem.getProblemImageDataList().stream() + Problem updatedProblem = optionalProblem.get(); + long solveImageCount = updatedProblem.getProblemImageDataList().stream() .filter(imageData -> imageData.getProblemImageType().equals(ProblemImageType.SOLVE_IMAGE)) .count(); @@ -460,69 +473,35 @@ void updateProblemPath() throws Exception { } @Test - @DisplayName("문제 이미지 수정") + @DisplayName("문제 이미지 URL 추가") @WithMockCustomUser() - void updateProblemImageData() throws Exception { + void addProblemImageDataUrls() throws Exception { // given + authenticateAsFixtureUser(); Long problemId = problemList.get(0).getId(); - - ProblemImageDataRegisterDto problemImageDataRegisterDto1 = new ProblemImageDataRegisterDto( - problemId, - "problemImageUrl1", - ProblemImageType.PROBLEM_IMAGE - ); - - ProblemImageDataRegisterDto problemImageDataRegisterDto2 = new ProblemImageDataRegisterDto( - problemId, - "answerImageUrl2", - ProblemImageType.ANSWER_IMAGE - ); - - ProblemImageDataRegisterDto problemImageDataRegisterDto3 = new ProblemImageDataRegisterDto( - problemId, - "answerImageUrl3", - ProblemImageType.SOLVE_IMAGE - ); - - List imageDataRegisterDtoList = List.of(problemImageDataRegisterDto1, problemImageDataRegisterDto2, problemImageDataRegisterDto3); - - ProblemRegisterDto problemRegisterDto = new ProblemRegisterDto( - problemId, - null, - null, - null, - null - ); + int beforeCount = problemImageDataRepository.findAllByProblemId(problemId).size(); + AddProblemImageUrlsRequest request = new AddProblemImageUrlsRequest(List.of( + new AddProblemImageUrlsRequest.ImageUrlItem("problemImageUrl1", ProblemImageType.PROBLEM_IMAGE.name()), + new AddProblemImageUrlsRequest.ImageUrlItem("answerImageUrl2", ProblemImageType.ANSWER_IMAGE.name()) + )); // when & then - MvcResult result = mockMvc.perform(MockMvcRequestBuilders.patch("/api/problems/imageData") + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems/{problemId}/imageData/urls", problemId) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(problemRegisterDto))) + .content(objectMapper.writeValueAsString(request))) .andExpect(status().isOk()) .andReturn(); String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); - JsonNode root = objectMapper.readTree(json); - JsonNode dataNode = root.get("data"); System.out.println("==== 응답 결과 ===="); System.out.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(objectMapper.readTree(json))); - Optional optionalProblem = problemRepository.findProblemWithImageData(problemId); - assertThat(optionalProblem.isPresent()).isTrue(); - - Problem problem = optionalProblem.get(); - List imageDataList = problem.getProblemImageDataList(); - - assertThat(imageDataList.size()).isEqualTo(3); - for(int i = 0; i < imageDataList.size(); i++) { - assertThat(imageDataList.get(i).getProblem().getId()).isEqualTo(problemId); - assertThat(imageDataList.get(i).getImageUrl()).isEqualTo(imageDataRegisterDtoList.get(i).imageUrl()); - assertThat(imageDataList.get(i).getProblemImageType()).isEqualTo(imageDataRegisterDtoList.get(i).problemImageType()); - } - - List problemImageDataList = problemImageDataRepository.findAllByProblemId(problemId); - assertThat(problemImageDataList.size()).isEqualTo(3); + List imageDataList = problemImageDataRepository.findAllByProblemId(problemId); + assertThat(imageDataList).hasSize(beforeCount + 2); + assertThat(imageDataList) + .extracting(ProblemImageData::getImageUrl) + .contains("problemImageUrl1", "answerImageUrl2"); } @Test @@ -574,4 +553,125 @@ void deleteProblemsWithUserId() throws Exception { assertThat(problemRepository.findAllByUserId(userId).size()).isEqualTo(0); } + + // ──────────── 리마인더 통합 테스트 ──────────── + + @Test + @DisplayName("POST /api/problems 성공 후 DB에 reminder row 5개가 SCHEDULED 상태로 생성된다") + void registerProblem_createsReminderRows() throws Exception { + // given: setUp에서 저장한 userId를 SecurityContext에 세팅 + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + Long folderId = folderList.get(0).getId(); + ProblemRegisterDto dto = new ProblemRegisterDto( + null, "remind-memo", "remind-ref", folderId, LocalDateTime.now() + ); + + // when + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.post("/api/problems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(dto))) + .andExpect(status().isOk()) + .andReturn(); + + // 응답에서 problemId 추출 + String json = result.getResponse().getContentAsString(StandardCharsets.UTF_8); + JsonNode root = objectMapper.readTree(json); + Long createdProblemId = root.get("data").asLong(); + + // then: AFTER_COMMIT 이벤트가 이미 처리되었으므로 row가 바로 존재해야 함 + List reminderRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(createdProblemId)) + .toList(); + + assertThat(reminderRows).hasSize(5); + assertThat(reminderRows).allMatch(r -> r.getStatus() == ProblemReviewReminderStatus.SCHEDULED); + } + + @Test + @DisplayName("PATCH /api/problems/info 후 reminder SCHEDULED row의 snapshot이 새 값으로 갱신된다") + void updateProblemInfo_updatesReminderSnapshot() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + Problem targetProblem = problemList.get(0); + Long problemId = targetProblem.getId(); + + // reminder row 5개 직접 삽입 + for (int i = 1; i <= 5; i++) { + reminderRepository.save(ProblemReviewReminder.create( + userId, problemId, "old memo", "old ref", + i, i, LocalDateTime.now().plusDays(i) + )); + } + + String newMemo = "updated memo"; + String newRef = "updated ref"; + ProblemRegisterDto updateDto = new ProblemRegisterDto( + problemId, newMemo, newRef, null, null + ); + + // when + mockMvc.perform(MockMvcRequestBuilders.patch("/api/problems/info") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto))) + .andExpect(status().isOk()); + + // then + List scheduledRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId) + && r.getStatus() == ProblemReviewReminderStatus.SCHEDULED) + .toList(); + + assertThat(scheduledRows).hasSize(5); + assertThat(scheduledRows).allMatch(r -> + newMemo.equals(r.getProblemMemoSnapshot()) && + newRef.equals(r.getProblemReferenceSnapshot()) + ); + } + + @Test + @DisplayName("DELETE /api/problems 후 해당 problemId의 reminder row가 CANCELED 상태가 된다") + void deleteProblems_cancelsReminderRows() throws Exception { + // given + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")) + ) + ); + doNothing().when(fileUploadService).deleteImageFileFromS3(anyString()); + + Problem targetProblem = problemList.get(0); + Long problemId = targetProblem.getId(); + + // reminder row 5개 직접 삽입 + for (int i = 1; i <= 5; i++) { + reminderRepository.save(ProblemReviewReminder.create( + userId, problemId, "memo", "ref", + i, i, LocalDateTime.now().plusDays(i) + )); + } + + ProblemDeleteRequestDto deleteDto = new ProblemDeleteRequestDto(List.of(problemId)); + + // when + mockMvc.perform(MockMvcRequestBuilders.delete("/api/problems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(deleteDto))) + .andExpect(status().isOk()); + + // then + List reminderRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .toList(); + + assertThat(reminderRows).hasSize(5); + assertThat(reminderRows).allMatch(r -> r.getStatus() == ProblemReviewReminderStatus.CANCELED); + } } diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java new file mode 100644 index 00000000..e3332e82 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java @@ -0,0 +1,147 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class ProblemReviewReminderPolicyTest { + + private ProblemReviewReminderPolicy policy; + + @BeforeEach + void setUp() { + policy = new ProblemReviewReminderPolicy(); + } + + // ──────────────────────── getIntervals ───────────────────────── + + @Test + @DisplayName("intervals는 [1, 3, 7, 14, 30] 5개여야 한다") + void getIntervals_returnsExpectedList() { + assertThat(policy.getIntervals()).containsExactly(1, 3, 7, 14, 30); + } + + // ──────────────────────── calculateScheduledAt ───────────────── + + @Test + @DisplayName("일반 시간대 작성 시 각 interval별 예약시각은 createdAt + N일과 동일하다") + void calculateScheduledAt_normalTime_allIntervals() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 22, 35, 0); + List intervals = policy.getIntervals(); // [1, 3, 7, 14, 30] + + for (int intervalDays : intervals) { + LocalDateTime result = policy.calculateScheduledAt(createdAt, intervalDays); + assertThat(result) + .as("interval=%d 일 때 예약시각이 createdAt + %d일이어야 함", intervalDays, intervalDays) + .isEqualTo(createdAt.plusDays(intervalDays)); + } + } + + @Test + @DisplayName("야간(00:05) 작성 후 D+1 예약시각은 다음날 06:00로 조정된다") + void calculateScheduledAt_nightTime_adjustsToMorning() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 0, 5, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + } + + @Test + @DisplayName("야간 경계: 05:59 작성 → D+1 06:00 조정") + void calculateScheduledAt_justBeforeNightEnd_adjusts() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 5, 59, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + } + + @Test + @DisplayName("야간 경계: 06:00 정각 작성 → 조정 없이 D+1 06:00 그대로") + void calculateScheduledAt_exactlyNightEnd_noAdjustment() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 6, 0, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + } + + @Test + @DisplayName("야간이 아닌 23:50 작성 → D+1 23:50 그대로 유지") + void calculateScheduledAt_lateEvening_noAdjustment() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 23, 50, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 23, 50, 0)); + } + + // ──────────────────────── buildNotification ──────────────────── + + @Test + @DisplayName("memo/reference 모두 null이면 fallback body가 사용된다") + void buildNotification_noContent_usesFallbackBody() { + ProblemReviewReminder reminder = ProblemReviewReminder.create( + 1L, 100L, null, null, 1, 1, LocalDateTime.now().plusDays(1) + ); + + NotificationRequestDto dto = policy.buildNotification(reminder); + + assertThat(dto.body()).isEqualTo("이전에 남긴 오답노트를 다시 풀어보세요"); + } + + @Test + @DisplayName("memo가 있으면 default body가 사용된다") + void buildNotification_memoPresent_usesDefaultBody() { + ProblemReviewReminder reminder = ProblemReviewReminder.create( + 1L, 100L, "삼각형 넓이 공식", null, 1, 1, LocalDateTime.now().plusDays(1) + ); + + NotificationRequestDto dto = policy.buildNotification(reminder); + + assertThat(dto.body()).isEqualTo("그때 남긴 오답, 다시 풀어볼 시간이에요"); + } + + @Test + @DisplayName("reference만 있어도 default body가 사용된다") + void buildNotification_referenceOnly_usesDefaultBody() { + ProblemReviewReminder reminder = ProblemReviewReminder.create( + 1L, 100L, null, "수학 교재 p.45", 2, 3, LocalDateTime.now().plusDays(3) + ); + + NotificationRequestDto dto = policy.buildNotification(reminder); + + assertThat(dto.body()).isEqualTo("그때 남긴 오답, 다시 풀어볼 시간이에요"); + } + + @Test + @DisplayName("FCM 알림 제목은 '오답노트 복습'이다") + void buildNotification_titleIsCorrect() { + ProblemReviewReminder reminder = ProblemReviewReminder.create( + 1L, 100L, "memo", null, 1, 1, LocalDateTime.now().plusDays(1) + ); + + NotificationRequestDto dto = policy.buildNotification(reminder); + + assertThat(dto.title()).isEqualTo("오답노트 복습"); + } + + @Test + @DisplayName("FCM data payload에 type, problemId, sequence, intervalDays가 포함된다") + void buildNotification_dataPayloadContainsRequiredKeys() { + long problemId = 42L; + int sequence = 3; + int intervalDays = 7; + ProblemReviewReminder reminder = ProblemReviewReminder.create( + 1L, problemId, "memo", null, sequence, intervalDays, LocalDateTime.now().plusDays(intervalDays) + ); + + NotificationRequestDto dto = policy.buildNotification(reminder); + Map data = dto.data(); + + assertThat(data).containsEntry("type", "problem_review_reminder"); + assertThat(data).containsEntry("problemId", String.valueOf(problemId)); + assertThat(data).containsEntry("sequence", String.valueOf(sequence)); + assertThat(data).containsEntry("intervalDays", String.valueOf(intervalDays)); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java new file mode 100644 index 00000000..5416a2dc --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java @@ -0,0 +1,464 @@ +package com.aisip.OnO.backend.problem.reminder; + +import com.aisip.OnO.backend.common.ratelimit.RateLimitService; +import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; +import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; +import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; +import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.support.TransactionTemplate; +import jakarta.persistence.EntityManager; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@SpringBootTest +@ActiveProfiles("test") +class ProblemReviewReminderServiceTest { + + @Autowired + private ProblemReviewReminderService service; + + @Autowired + private ProblemReviewReminderRepository reminderRepository; + + @Autowired + private UserRepository userRepository; + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @MockBean + private FcmService fcmService; + + @MockBean + private FcmNotificationProducer fcmNotificationProducer; + + @MockBean + private ProblemAnalysisProducer problemAnalysisProducer; + + @MockBean + private FileUploadService fileUploadService; + + @MockBean + private RateLimitService rateLimitService; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private EntityManager entityManager; + + private Long userId; + private User savedUser; + + @BeforeEach + void setUp() { + User user = RandomUserGenerator.createRandomUser(); + savedUser = userRepository.save(user); + userId = savedUser.getId(); + } + + @AfterEach + void tearDown() { + fcmTokenRepository.deleteAll(); + reminderRepository.deleteAll(); + userRepository.deleteById(userId); + } + + // ──────────────────── scheduleForNewProblems ─────────────────── + + @Test + @DisplayName("problem 1개 등록 시 5개의 SCHEDULED row가 sequence 1~5로 생성된다") + void scheduleForNewProblems_single_creates5Rows() { + Long problemId = 1001L; + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 22, 0, 0); + + service.scheduleForNewProblems(userId, List.of( + new ProblemCreatedEvent.ProblemData(problemId, "memo", "ref", createdAt) + )); + + List rows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .sorted((a, b) -> Integer.compare(a.getSequence(), b.getSequence())) + .toList(); + + assertThat(rows).hasSize(5); + for (int i = 0; i < 5; i++) { + assertThat(rows.get(i).getSequence()).isEqualTo(i + 1); + assertThat(rows.get(i).getStatus()).isEqualTo(SCHEDULED); + } + } + + @Test + @DisplayName("problem 2개 등록 시 총 10개의 row가 생성된다") + void scheduleForNewProblems_batch_creates10Rows() { + Long problemId1 = 2001L; + Long problemId2 = 2002L; + LocalDateTime createdAt = LocalDateTime.now(); + + service.scheduleForNewProblems(userId, List.of( + new ProblemCreatedEvent.ProblemData(problemId1, "memo1", "ref1", createdAt), + new ProblemCreatedEvent.ProblemData(problemId2, "memo2", "ref2", createdAt) + )); + + long count = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId1) || r.getProblemId().equals(problemId2)) + .count(); + + assertThat(count).isEqualTo(10); + } + + @Test + @DisplayName("동일 problemId로 scheduleForNewProblems 2번 호출 시 row는 5개로 유지된다") + void scheduleForNewProblems_duplicate_skipsExisting() { + Long problemId = 3001L; + LocalDateTime createdAt = LocalDateTime.now(); + ProblemCreatedEvent.ProblemData data = + new ProblemCreatedEvent.ProblemData(problemId, "memo", "ref", createdAt); + + service.scheduleForNewProblems(userId, List.of(data)); + service.scheduleForNewProblems(userId, List.of(data)); // 중복 호출 + + long count = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .count(); + + assertThat(count).isEqualTo(5); + } + + // ──────────────────── cancelPendingByProblem ─────────────────── + + @Test + @DisplayName("cancelPendingByProblem 호출 시 SCHEDULED row가 모두 CANCELED로 변경된다") + void cancelPendingByProblem_cancelsAllScheduledRows() { + Long problemId = 4001L; + saveScheduledReminders(userId, problemId, 5); + + service.cancelPendingByProblem(problemId); + + List rows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .toList(); + + assertThat(rows).hasSize(5); + assertThat(rows).allMatch(r -> r.getStatus() == CANCELED); + } + + // ──────────────────── refreshSnapshot ───────────────────────── + + @Test + @DisplayName("refreshSnapshot 호출 시 SCHEDULED row의 memo/reference가 갱신된다") + void refreshSnapshot_updatesScheduledRows() { + Long problemId = 5001L; + saveScheduledReminders(userId, problemId, 3); + + service.refreshSnapshot(problemId, "new memo", "new reference"); + + List scheduledRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId) && r.getStatus() == SCHEDULED) + .toList(); + + assertThat(scheduledRows).hasSize(3); + assertThat(scheduledRows).allMatch(r -> + "new memo".equals(r.getProblemMemoSnapshot()) && + "new reference".equals(r.getProblemReferenceSnapshot()) + ); + } + + @Test + @DisplayName("refreshSnapshot은 CANCELED 상태의 row는 갱신하지 않는다") + void refreshSnapshot_doesNotUpdateCanceledRows() { + Long problemId = 5002L; + // SCHEDULED row 3개 저장 후 취소 + saveScheduledReminders(userId, problemId, 3); + service.cancelPendingByProblem(problemId); + + service.refreshSnapshot(problemId, "new memo", "new reference"); + + List canceledRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .toList(); + + assertThat(canceledRows).allMatch(r -> r.getStatus() == CANCELED); + // CANCELED row이므로 snapshot 갱신 쿼리가 영향을 주지 않아야 함 (originalSnapshot 유지) + assertThat(canceledRows).allMatch(r -> + !"new memo".equals(r.getProblemMemoSnapshot()) + ); + } + + // ──────────────────── skipDuePendingByProblemSolve ───────────── + + @Test + @DisplayName("복습 완료 시 practicedAt 이전의 due row는 SKIPPED_BY_COMPLETION, 이후 row는 SCHEDULED 유지") + void skipDuePendingByProblemSolve_skipsOnlyDueRows() { + Long problemId = 6001L; + LocalDateTime base = LocalDateTime.of(2026, 7, 18, 22, 0, 0); + // sequence 1: D+1 due, sequence 2: D+3 due ~ sequence 5: D+30 due + List reminders = List.of( + ProblemReviewReminder.create(userId, problemId, "m", "r", 1, 1, base.plusDays(1)), + ProblemReviewReminder.create(userId, problemId, "m", "r", 2, 3, base.plusDays(3)), + ProblemReviewReminder.create(userId, problemId, "m", "r", 3, 7, base.plusDays(7)), + ProblemReviewReminder.create(userId, problemId, "m", "r", 4, 14, base.plusDays(14)), + ProblemReviewReminder.create(userId, problemId, "m", "r", 5, 30, base.plusDays(30)) + ); + reminderRepository.saveAll(reminders); + + // D+1 시점에 복습 완료 → sequence 1만 SKIPPED, 나머지는 SCHEDULED + LocalDateTime practicedAt = base.plusDays(1).plusHours(1); + service.skipDuePendingByProblemSolve(userId, problemId, practicedAt); + + Map statusBySeq = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId)) + .collect(Collectors.toMap(ProblemReviewReminder::getSequence, ProblemReviewReminder::getStatus)); + + assertThat(statusBySeq.get(1)).isEqualTo(SKIPPED_BY_COMPLETION); + assertThat(statusBySeq.get(2)).isEqualTo(SCHEDULED); + assertThat(statusBySeq.get(3)).isEqualTo(SCHEDULED); + assertThat(statusBySeq.get(4)).isEqualTo(SCHEDULED); + assertThat(statusBySeq.get(5)).isEqualTo(SCHEDULED); + } + + // ──────────────────── cancelAllByUser ───────────────────────── + + @Test + @DisplayName("cancelAllByUser 호출 시 사용자의 모든 SCHEDULED row가 CANCELED로 변경된다") + void cancelAllByUser_cancelsAllScheduledRows() { + Long problemId = 7001L; + saveScheduledReminders(userId, problemId, 5); + + service.cancelAllByUser(userId); + + List rows = reminderRepository.findAll().stream() + .filter(r -> r.getUserId().equals(userId)) + .toList(); + + assertThat(rows).hasSize(5); + assertThat(rows).allMatch(r -> r.getStatus() == CANCELED); + } + + // ──────────────────── sendDueReminders ──────────────────────── + + @Test + @DisplayName("정상 발송: due row 있고 알림 허용 + FCM 토큰 있으면 fcmService 1회 호출 후 row가 SENT") + void sendDueReminders_normalCase_sendsFcmAndMarksSent() { + FcmToken token = fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("test-token"), userId)); + + ProblemReviewReminder reminder = saveAndGetDueReminder(userId, 8001L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + verify(fcmService, times(1)).sendNotificationToAllUserDevice(eq(userId), any(NotificationRequestDto.class)); + + ProblemReviewReminder updated = reminderRepository.findById(reminder.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(SENT); + } + + @Test + @DisplayName("FCM 큐 전송 실패 시 row는 SENT가 아니라 FAILED로 변경된다") + void sendDueReminders_fcmEnqueueFailure_marksFailed() { + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("test-token"), userId)); + ProblemReviewReminder reminder = saveAndGetDueReminder(userId, 8002L, LocalDateTime.now().minusMinutes(1)); + doThrow(new IllegalStateException("enqueue failed")) + .when(fcmService).sendNotificationToAllUserDevice(eq(userId), any(NotificationRequestDto.class)); + + service.sendDueReminders(LocalDateTime.now()); + + ProblemReviewReminder updated = reminderRepository.findById(reminder.getId()).orElseThrow(); + assertThat(updated.getStatus()).isEqualTo(FAILED); + assertThat(updated.getLastErrorMessage()).isEqualTo("enqueue failed"); + } + + @Test + @DisplayName("notificationEnabled=false인 사용자의 due row는 발송하지 않는다") + void sendDueReminders_notificationDisabled_skips() { + savedUser.updateNotificationEnabled(false); + userRepository.save(savedUser); + + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("test-token"), userId)); + ProblemReviewReminder reminder = saveAndGetDueReminder(userId, 9001L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + + ProblemReviewReminder unchanged = reminderRepository.findById(reminder.getId()).orElseThrow(); + assertThat(unchanged.getStatus()).isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("FCM 토큰이 없으면 발송하지 않고 row는 SCHEDULED로 유지된다") + void sendDueReminders_noFcmToken_skips() { + // 토큰 없음 + ProblemReviewReminder reminder = saveAndGetDueReminder(userId, 10001L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + + ProblemReviewReminder unchanged = reminderRepository.findById(reminder.getId()).orElseThrow(); + assertThat(unchanged.getStatus()).isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("오늘 이미 SENT row가 있는 사용자의 due row는 발송하지 않는다") + void sendDueReminders_alreadySentToday_skips() { + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("test-token"), userId)); + + // 오늘 이미 SENT된 row를 직접 삽입 + ProblemReviewReminder alreadySent = ProblemReviewReminder.create( + userId, 11001L, "m", "r", 1, 1, LocalDateTime.now().minusDays(1) + ); + ProblemReviewReminder savedSent = reminderRepository.save(alreadySent); + final Long sentId = savedSent.getId(); + final LocalDateTime sentAt = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> + reminderRepository.markSent(sentId, sentAt, SENT)); + + // 새로운 due row + ProblemReviewReminder due = saveAndGetDueReminder(userId, 11002L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + + ProblemReviewReminder unchanged = reminderRepository.findById(due.getId()).orElseThrow(); + assertThat(unchanged.getStatus()).isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("같은 사용자의 due row 2개 중 scheduledAt이 빠른 1건만 SENT, 나머지는 SCHEDULED 유지") + void sendDueReminders_multipledue_sendsOnlyEarliest() { + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("test-token"), userId)); + + LocalDateTime earlier = LocalDateTime.now().minusMinutes(10); + LocalDateTime later = LocalDateTime.now().minusMinutes(5); + + ProblemReviewReminder earlierRow = reminderRepository.save( + ProblemReviewReminder.create(userId, 12001L, "m", "r", 1, 1, earlier) + ); + ProblemReviewReminder laterRow = reminderRepository.save( + ProblemReviewReminder.create(userId, 12002L, "m", "r", 1, 1, later) + ); + + service.sendDueReminders(LocalDateTime.now()); + + verify(fcmService, times(1)).sendNotificationToAllUserDevice(eq(userId), any()); + + ProblemReviewReminder updatedEarlier = reminderRepository.findById(earlierRow.getId()).orElseThrow(); + ProblemReviewReminder updatedLater = reminderRepository.findById(laterRow.getId()).orElseThrow(); + + assertThat(updatedEarlier.getStatus()).isEqualTo(SENT); + assertThat(updatedLater.getStatus()).isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("user1의 SENT row가 있어도 user2의 due row는 독립적으로 발송된다") + void sendDueReminders_userIsolation_eachUserIndependent() { + // user2 생성 + User user2 = RandomUserGenerator.createRandomUser(); + userRepository.save(user2); + Long userId2 = user2.getId(); + + try { + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("token-u1"), userId)); + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("token-u2"), userId2)); + + // user1: 오늘 이미 SENT + ProblemReviewReminder sentRow = reminderRepository.save( + ProblemReviewReminder.create(userId, 13001L, "m", "r", 1, 1, LocalDateTime.now().minusDays(1)) + ); + final Long sentRowId = sentRow.getId(); + final LocalDateTime now1 = LocalDateTime.now(); + transactionTemplate.executeWithoutResult(tx -> + reminderRepository.markSent(sentRowId, now1, SENT)); + + // user2: due row + ProblemReviewReminder dueRow = saveAndGetDueReminder(userId2, 13002L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + // user1은 skip, user2는 발송 + verify(fcmService, times(1)).sendNotificationToAllUserDevice(eq(userId2), any()); + verify(fcmService, never()).sendNotificationToAllUserDevice(eq(userId), any()); + + ProblemReviewReminder updatedDue = reminderRepository.findById(dueRow.getId()).orElseThrow(); + assertThat(updatedDue.getStatus()).isEqualTo(SENT); + + } finally { + fcmTokenRepository.findAllByUserId(userId2).forEach(t -> fcmTokenRepository.deleteByToken(t.getToken())); + reminderRepository.findAll().stream() + .filter(r -> r.getUserId().equals(userId2)) + .forEach(r -> reminderRepository.deleteById(r.getId())); + userRepository.deleteById(userId2); + } + } + + @Test + @DisplayName("sendDueReminders 호출 시 11분 전 SENDING row는 FAILED로 복구된다") + void sendDueReminders_stuckSendingRow_recoveredToFailed() { + ProblemReviewReminder reminder = reminderRepository.save( + ProblemReviewReminder.create(userId, 14001L, "m", "r", 1, 1, LocalDateTime.now().minusMinutes(30)) + ); + + // SENDING 상태로 강제 변경 + final Long reminderId = reminder.getId(); + transactionTemplate.executeWithoutResult(tx -> + reminderRepository.tryUpdateStatus(reminderId, SCHEDULED, SENDING, LocalDateTime.now())); + + // @LastModifiedDate 자동 갱신을 우회하기 위해 EntityManager 네이티브 쿼리로 updatedAt 조작 + final LocalDateTime stuckUpdatedAt = LocalDateTime.now().minusMinutes(11); + transactionTemplate.executeWithoutResult(tx -> + entityManager.createNativeQuery( + "UPDATE problem_review_reminder SET updated_at = ? WHERE id = ?" + ).setParameter(1, stuckUpdatedAt).setParameter(2, reminderId).executeUpdate()); + + service.sendDueReminders(LocalDateTime.now()); + + ProblemReviewReminder recovered = reminderRepository.findById(reminder.getId()).orElseThrow(); + assertThat(recovered.getStatus()).isEqualTo(FAILED); + assertThat(recovered.getLastErrorMessage()).isEqualTo("stuck recovery"); + } + + // ──────────────────── helper ────────────────────────────────── + + private void saveScheduledReminders(Long userId, Long problemId, int count) { + LocalDateTime base = LocalDateTime.now().plusDays(1); + List intervals = List.of(1, 3, 7, 14, 30); + for (int i = 0; i < count; i++) { + reminderRepository.save(ProblemReviewReminder.create( + userId, problemId, "memo", "ref", + i + 1, intervals.get(i), base.plusDays(intervals.get(i)) + )); + } + } + + private ProblemReviewReminder saveAndGetDueReminder(Long userId, Long problemId, LocalDateTime scheduledAt) { + return reminderRepository.save( + ProblemReviewReminder.create(userId, problemId, "memo", "ref", 1, 1, scheduledAt) + ); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java index 1ed35a43..96e5e438 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java @@ -103,8 +103,8 @@ void memberCanGetReportAndIsReadStatusIsCorrect() throws Exception { room, LocalDate.now().minusDays(7), LocalDate.now().minusDays(1), - "방장", 5, - "방장", 3, + "방장", null, 5, + "방장", null, 3, 5, 0, "이번 주도 수고하셨습니다!" )); @@ -157,8 +157,8 @@ void nonMemberCannotMarkReportRead() throws Exception { room, LocalDate.now().minusDays(7), LocalDate.now().minusDays(1), - "방장", 5, - "방장", 3, + "방장", null, 5, + "방장", null, 3, 5, 0, "이번 주도 수고하셨습니다!" )); diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java index 12685984..01dc7138 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java @@ -51,6 +51,9 @@ class StudyRoomFeedServiceUnitTest { @Mock private CustomEmojiValidator customEmojiValidator; + @Mock + private StudyRoomChallengeService challengeService; + private StudyRoomFeedService feedService; private User user; @@ -71,7 +74,7 @@ void setUp() { feedService = new StudyRoomFeedService( accessService, memberRepository, feedRepository, reactionRepository, userRepository, new ObjectMapper(), - reactionService, customEmojiValidator + reactionService, customEmojiValidator, challengeService ); given(userRepository.findById(1L)).willReturn(Optional.of(user)); From 8dcc2ad0a6676f43b7f48daba265b4d8f204903c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 21 Jul 2026 22:03:24 +0900 Subject: [PATCH 007/130] =?UTF-8?q?[Chore]=20feat.md=20=EC=BB=A4=EB=B0=8B?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20?= =?UTF-8?q?-=20=EB=8B=A8=EC=9C=84=20=EA=B5=AC=ED=98=84=20=ED=9B=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20=EC=BB=A4=EB=B0=8B=20=EC=A7=84=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .claude/commands/feat.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.claude/commands/feat.md b/.claude/commands/feat.md index 34333bf0..14a73135 100644 --- a/.claude/commands/feat.md +++ b/.claude/commands/feat.md @@ -18,10 +18,11 @@ argument-hint: "<작업 설명 또는 계획서 경로>" - 가드레일 준수: FCM 경로·프로덕션 DB 파괴 작업 전 경고. - 사용자 격리(userId 소유권 검증) 누락 없이 구현. - 근거 없는 코드 추가 금지. 각 변경은 계획서 단계에 대응. + - 각 단위 구현 이후, 5번 섹션의 커밋 진행 4. **컴파일 확인** — 구현 완료 후 `./gradlew compileJava` 실행. 오류 있으면 수정. -5. **커밋 제안** — 논리 단위별 커밋 메시지 초안을 제시하고 사용자 확인 후 커밋. +5. **커밋 진행** — 논리 단위별로 알맞게 분리해 자체적으로 커밋 형식: `[Feat] 한 줄 요약\n- 상세` (테스트는 /test로 분리) From 1cf96b4746cc62c8fb14f35f9cd8b8230719a41f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 23 Aug 2026 02:24:25 +0900 Subject: [PATCH 008/130] =?UTF-8?q?[Fix]=20soft=20delete=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A4=91=EB=B3=B5=20=EC=83=9D=EC=84=B1=20500=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EC=9C=A0=EB=8B=88=ED=81=AC=20?= =?UTF-8?q?=EC=9D=B8=EB=8D=B1=EC=8A=A4=EC=97=90=20alive=5Fkey=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tag, problem_tag_mapping 은 @SQLDelete 로 행을 남기는데 유니크 인덱스가 deleted_at 을 보지 않아, 한 번 지운 태그를 같은 이름으로 다시 만들면 DB 제약 위반으로 500 이 발생 (Sentry JAVA-SPRING-BOOT-4Z) - V23: deleted_at 기반 생성 컬럼 alive_key 를 추가하고 유니크 인덱스를 세 컬럼으로 재생성 살아있는 행은 sentinel 값을 공유해 중복이 계속 막히고, 삭제된 행은 각자 deleted_at 을 가져 공존 - id(AUTO_INCREMENT) 참조는 MySQL 이 거부(ERROR 3109)하므로 deleted_at 을 사용 - VIRTUAL 컬럼이라 테이블 재작성 없이 메타데이터만 변경 - 인덱스 존재 여부를 information_schema 로 확인하는 동적 SQL 로 감싸 재적용에 안전 - problem_tag_mapping 도 같은 구조라 함께 처리. 태그 생성이 풀린 뒤 매핑 쪽에서 같은 500 이 나는 것을 방지 - Tag: 실제 인덱스 정의가 V23 에 있다는 주석 추가 (ddl-auto 가 validate 라 선언은 문서 역할) --- .../com/aisip/OnO/backend/tag/entity/Tag.java | 5 ++ .../V23__fix_soft_delete_unique_index.sql | 57 +++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql diff --git a/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java b/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java index 0f16c5ad..e4ba346a 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java +++ b/src/main/java/com/aisip/OnO/backend/tag/entity/Tag.java @@ -13,6 +13,11 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLDelete(sql = "UPDATE tag SET deleted_at = now() WHERE id = ?") @SQLRestriction("deleted_at IS NULL") +// 주의: idx_tag_user_normalized 의 실제 정의는 V23 마이그레이션이 관리한다. +// soft delete 된 행이 유니크 자리를 차지해 같은 이름을 다시 만들 수 없던 문제 때문에, +// DB 에서는 (user_id, normalized_name, alive_key) 세 컬럼으로 잡혀 있다. +// alive_key 는 deleted_at 기반 생성 컬럼이라 엔티티에 매핑하지 않는다. +// ddl-auto 가 validate 라 아래 선언은 문서 역할만 한다. @Table(name = "tag", indexes = { @Index(name = "idx_tag_user_id", columnList = "user_id"), @Index(name = "idx_tag_user_normalized", columnList = "user_id, normalized_name", unique = true) diff --git a/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql b/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql new file mode 100644 index 00000000..e727d2e2 --- /dev/null +++ b/src/main/resources/db/migration/V23__fix_soft_delete_unique_index.sql @@ -0,0 +1,57 @@ +-- soft delete 와 유니크 인덱스가 어긋나던 것을 고친다. +-- +-- tag 와 problem_tag_mapping 은 @SQLDelete 로 deleted_at 만 채우고 행을 남기는데, +-- 유니크 인덱스는 deleted_at 을 보지 않아서 삭제된 행이 계속 자리를 차지하고 있었다. +-- 조회는 @SQLRestriction("deleted_at IS NULL") 때문에 그 행을 못 찾으니 INSERT 로 넘어가고, +-- DB 제약에 걸려 500 이 났다. 그래서 한 번 지운 태그는 같은 이름으로 다시 만들 수 없었다. +-- (Sentry JAVA-SPRING-BOOT-4Z: Duplicate entry '434-발상' for key 'tag.idx_tag_user_normalized') +-- +-- 인덱스에 alive_key 를 넣어서 "살아있는 행은 하나뿐, 삭제된 행은 여러 개 공존"을 만든다. +-- 살아있는 행: alive_key = 1970-01-01 (모두 같은 값이라 중복이 막힌다) +-- 삭제된 행: alive_key = deleted_at (삭제 시각이 서로 달라 여러 건이 공존한다) +-- +-- id(AUTO_INCREMENT)를 참조하면 MySQL 이 거부하기 때문에(ERROR 3109) deleted_at 을 쓴다. +-- VIRTUAL 이라 테이블을 다시 쓰지 않고 메타데이터만 바뀐다. + +-- --------------------------------------------------------------------------- +-- tag +-- --------------------------------------------------------------------------- +ALTER TABLE tag + ADD COLUMN alive_key DATETIME(6) + AS (IFNULL(deleted_at, '1970-01-01 00:00:00.000000')) VIRTUAL; + +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'tag' + AND index_name = 'idx_tag_user_normalized'); +SET @sql := IF(@idx > 0, + 'ALTER TABLE tag DROP INDEX idx_tag_user_normalized', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE UNIQUE INDEX idx_tag_user_normalized + ON tag (user_id, normalized_name, alive_key); + +-- --------------------------------------------------------------------------- +-- problem_tag_mapping +-- 같은 구조라 태그 생성이 풀리면 다음은 여기서 같은 500 이 난다. +-- --------------------------------------------------------------------------- +ALTER TABLE problem_tag_mapping + ADD COLUMN alive_key DATETIME(6) + AS (IFNULL(deleted_at, '1970-01-01 00:00:00.000000')) VIRTUAL; + +SET @idx := (SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE table_schema = DATABASE() + AND table_name = 'problem_tag_mapping' + AND index_name = 'uk_problem_tag_mapping_problem_tag'); +SET @sql := IF(@idx > 0, + 'ALTER TABLE problem_tag_mapping DROP INDEX uk_problem_tag_mapping_problem_tag', + 'DO 0'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE UNIQUE INDEX uk_problem_tag_mapping_problem_tag + ON problem_tag_mapping (problem_id, tag_id, alive_key); From a81ae47e9d865054333daeaf8637cceab057157e Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 23 Aug 2026 02:24:58 +0900 Subject: [PATCH 009/130] =?UTF-8?q?[Fix]=20Sentry=204xx=20=EB=85=B8?= =?UTF-8?q?=EC=9D=B4=EC=A6=88=20=EC=A0=9C=EA=B1=B0=20-=20exception=20resol?= =?UTF-8?q?ver=20order=20=EA=B8=B0=EB=B3=B8=EA=B0=92=20=EB=B3=B5=EA=B7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sentry.exception-resolver-order 가 Integer.MIN_VALUE 로 오버라이드되어 있어 SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 실행됨 - 그 결과 GlobalExceptionHandler 가 log.warn 으로 처리하는 4xx 까지 전부 fatal 로 Sentry 에 보고됨 봇 스캐너가 만드는 404/405 가 7일 기준 301건으로 미해결 이벤트의 대부분을 차지 - docker-compose 환경변수로 sentry-java 기본값 1 을 복원 application-prod.yml 은 gitignore 대상이고 CI 가 GitHub Secret 으로 덮어쓰므로 환경변수로 지정 - 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고되며 레벨이 fatal 에서 error 로 정상화됨 - MaxUploadSizeExceededException(413) 전용 핸들러 추가 ErrorResponse 구현체라 4xx 로 분류되어 log.warn 이 되면 업로드 실패 신호가 묻히므로 error 로 보고 --- docker-compose.dev.yml | 2 ++ docker-compose.prod.yml | 12 +++++++++ .../exception/GlobalExceptionHandler.java | 26 +++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index c19aba8b..5b83cd41 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -98,6 +98,8 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. 자세한 이유는 docker-compose.prod.yml 참고 + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index edab90d4..fc3a452d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -97,6 +97,12 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. + # SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 돌면 GlobalExceptionHandler 가 + # 처리한 4xx 까지 전부 fatal 로 Sentry 에 올라간다. 봇 스캐너가 만드는 404/405 가 + # 미해결 이벤트의 대부분(7일 301건)을 차지하던 원인이 이 오버라이드였다. + # 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고된다. + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus @@ -151,6 +157,12 @@ services: DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} OPENAI_API_KEY: ${OPENAI_API_KEY} SENTRY_DSN: ${SENTRY_DSN} + # sentry-java 기본값(1)로 되돌린다. + # SentryExceptionResolver 가 @ControllerAdvice 보다 먼저 돌면 GlobalExceptionHandler 가 + # 처리한 4xx 까지 전부 fatal 로 Sentry 에 올라간다. 봇 스캐너가 만드는 404/405 가 + # 미해결 이벤트의 대부분(7일 301건)을 차지하던 원인이 이 오버라이드였다. + # 5xx 는 GlobalExceptionHandler 의 log.error 를 통해 Logback appender 로 계속 보고된다. + SENTRY_EXCEPTIONRESOLVERORDER: "1" ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} ADMIN_PASSWORD: ${ADMIN_PASSWORD} MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info,metrics,prometheus diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java index b03db3f8..f1c1d5cc 100644 --- a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.context.request.ServletWebRequest; import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.resource.NoResourceFoundException; @@ -80,6 +81,31 @@ public ResponseEntity handleBadRequestException(Exception ex, We return handleSpringStatusException(ex, request, HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); } + /** + * 업로드 용량 초과(413)는 4xx 라서 logByStatus 를 타면 log.warn 이 되고, + * 그러면 Sentry Logback appender(ERROR 이상)에 걸리지 않아 조용히 묻힌다. + * 사용자가 파일을 못 올리고 있다는 신호라 반드시 보여야 해서 error 로 올린다. + * (spring.servlet.multipart.max-file-size = 50MB) + */ + @ExceptionHandler(MaxUploadSizeExceededException.class) + public ResponseEntity handleMaxUploadSizeExceededException(MaxUploadSizeExceededException ex, + WebRequest request) { + HttpStatus status = HttpStatus.PAYLOAD_TOO_LARGE; + String message = "업로드 가능한 파일 크기를 초과했습니다."; + CommonResponse commonResponse = CommonResponse.error(status.value(), message); + + putErrorMdc(status.value(), ex); + log.error("Upload size exceeded - status: {}, exceptionType: {}, detail: {}", + status.value(), + ex.getClass().getSimpleName(), + ex.getMessage(), + ex); + + return ResponseEntity + .status(status) + .body(commonResponse); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex, WebRequest request) { if (ex instanceof ErrorResponse errorResponse) { From 68ae5cf6eb018a5795ae57209e5d36ea22cc4d4b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 23 Aug 2026 02:25:10 +0900 Subject: [PATCH 010/130] =?UTF-8?q?[Chore]=20Prometheus=20=EC=A7=80?= =?UTF-8?q?=ED=91=9C=20=EB=B3=B4=EC=A1=B4=20=EA=B8=B0=EA=B0=84=20=EC=97=B0?= =?UTF-8?q?=EC=9E=A5=20-=20prod=2090=EC=9D=BC,=20dev=2030=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - retention 설정이 없어 기본값 15일로 동작하고 있었고, 그 이전 데이터는 매일 영구 삭제되고 있었음 - 실측 결과 활성 시계열 약 12,000개 / 스크레이프 60초 기준 하루 약 28MB, 90일이면 약 2.5GB - retention.size 를 함께 지정해 시계열 증가로 디스크가 차는 것을 방지 (둘 중 먼저 걸리는 쪽 적용) - 적용하려면 restart-monitoring 워크플로 실행 필요. deploy-prod 는 monitoring 프로파일을 올리지 않음 --- docker-compose.dev.yml | 3 +++ docker-compose.prod.yml | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5b83cd41..2870d6fe 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -131,6 +131,9 @@ services: command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus + # dev 는 회귀 확인 용도라 prod 보다 짧게 둔다 (기본값은 15일) + - --storage.tsdb.retention.time=30d + - --storage.tsdb.retention.size=5GB - --web.external-url=${PROMETHEUS_DEV_EXTERNAL_URL:-http://localhost:9091} - --web.route-prefix=${PROMETHEUS_DEV_ROUTE_PREFIX:-/} ports: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index fc3a452d..b928c4ed 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -196,6 +196,11 @@ services: command: - --config.file=/etc/prometheus/prometheus.yml - --storage.tsdb.path=/prometheus + # 보존 기간을 지정하지 않으면 기본 15일이라 월 단위 비교가 불가능하다. + # 현재 시계열 약 12,000개 / 스크레이프 60초 기준 하루 약 28MB, 90일이면 약 2.5GB. + # size 는 디스크가 가득 차는 것을 막는 안전장치이고, 둘 중 먼저 걸리는 쪽이 적용된다. + - --storage.tsdb.retention.time=90d + - --storage.tsdb.retention.size=10GB - --web.external-url=${PROMETHEUS_PROD_EXTERNAL_URL:-http://localhost:9090} - --web.route-prefix=${PROMETHEUS_PROD_ROUTE_PREFIX:-/} ports: From c42e72411bf9aff2bac3c282f80121a2359e44ea Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 12:07:16 +0900 Subject: [PATCH 011/130] =?UTF-8?q?[Fix]=20=EB=AC=B8=EC=A0=9C=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=20255=EC=9E=90=20=EC=B4=88=EA=B3=BC=20=EC=8B=9C=20500?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20-=20=EC=BB=AC=EB=9F=BC=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20=EA=B8=B8=EC=9D=B4=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memo 가 ddl-auto 시절 기본값 varchar(255) 로 만들어져 있어, 사용자가 긴 메모를 쓰면 등록 자체가 Data truncation 으로 실패했다 (Sentry JAVA-SPRING-BOOT-5B, 5A) - V24: varchar(1000) 으로 확장. utf8mb4 varchar(255) 는 이미 1020 바이트라 2바이트 길이 접두사를 쓰고 있어 접두사 크기가 바뀌지 않는다 → ALGORITHM=INPLACE, LOCK=NONE 으로 테이블 재작성·잠금 없이 적용된다. TEXT 로 바꾸면 COPY 가 되어 선택하지 않았다 - 1000자를 넘으면 저장 전에 걸러 400(PROBLEM_MEMO_TOO_LONG, 4006) 으로 응답 등록 v1/v2/배치, 정보 수정 네 경로 모두 적용 - problem_review_reminder 의 스냅샷 컬럼은 varchar(255)(V21) 이라 memo 만 넓히면 알림 예약 저장이 같은 이유로 실패한다. 알림 문구용 사본이므로 255자로 잘라 담는다 - 통합 테스트: 1000자 저장 + 스냅샷 잘림, 1001자 400 응답 --- .../OnO/backend/problem/entity/Problem.java | 2 + .../problem/exception/ProblemErrorCase.java | 4 +- .../reminder/ProblemReviewReminder.java | 17 +++- .../ProblemReviewReminderService.java | 7 +- .../problem/service/ProblemService.java | 16 ++++ .../db/migration/V24__widen_problem_memo.sql | 9 ++ .../ProblemApiIntegrationTest.java | 84 +++++++++++++++++++ 7 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 src/main/resources/db/migration/V24__widen_problem_memo.sql diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java index 6fa22a86..75b13bf6 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java @@ -38,6 +38,8 @@ public class Problem extends BaseEntity { @JoinColumn(name = "folder_id") private Folder folder; + // 실제 컬럼 정의는 V24 에 있다 (ddl-auto 가 validate 라 여기 선언은 문서 역할) + @Column(length = 1000) private String memo; private String reference; diff --git a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java index b6b2f149..6bafe92e 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java @@ -16,7 +16,9 @@ public enum ProblemErrorCase implements ErrorCase { PROBLEM_ANALYSIS_NOT_FOUND(404, 4004, "문제 분석 결과를 찾을 수 없습니다."), - ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."); + ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."), + + PROBLEM_MEMO_TOO_LONG(400, 4006, "메모는 1000자를 넘을 수 없습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java index c44eb685..35ede1bd 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java @@ -56,14 +56,27 @@ public class ProblemReviewReminder extends BaseEntity { @Builder.Default private int retryCount = 0; + /** + * 스냅샷 컬럼은 varchar(255)(V21)인데 memo 는 1000자까지 들어온다(V24). + * 알림 문구용 사본이라 잘라 담는다. 넘치면 insert 자체가 truncation 으로 실패한다. + */ + private static final int SNAPSHOT_MAX_LENGTH = 255; + + public static String truncateSnapshot(String value) { + if (value == null || value.length() <= SNAPSHOT_MAX_LENGTH) { + return value; + } + return value.substring(0, SNAPSHOT_MAX_LENGTH); + } + public static ProblemReviewReminder create( Long userId, Long problemId, String memo, String reference, int sequence, int intervalDays, LocalDateTime scheduledAt) { return ProblemReviewReminder.builder() .userId(userId) .problemId(problemId) - .problemMemoSnapshot(memo) - .problemReferenceSnapshot(reference) + .problemMemoSnapshot(truncateSnapshot(memo)) + .problemReferenceSnapshot(truncateSnapshot(reference)) .sequence(sequence) .intervalDays(intervalDays) .scheduledAt(scheduledAt) diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index 9a8d6b6e..c3636d54 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -83,7 +83,12 @@ public void cancelPendingByProblem(Long problemId) { @Transactional public void refreshSnapshot(Long problemId, String memo, String reference) { - repository.refreshSnapshot(problemId, memo, reference, SCHEDULED); + repository.refreshSnapshot( + problemId, + ProblemReviewReminder.truncateSnapshot(memo), + ProblemReviewReminder.truncateSnapshot(reference), + SCHEDULED + ); } @Transactional diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 5d67d253..de2a0e90 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -70,6 +70,7 @@ public class ProblemService { private static final String AI_ANALYSIS_RATE_LIMIT_KEY = "ai_analysis"; private static final int AI_ANALYSIS_LIMIT_PER_DAY = 20; + private static final int MEMO_MAX_LENGTH = 1000; private final ProblemRepository problemRepository; @@ -209,6 +210,7 @@ public Long findProblemCountByUser(Long userId) { @Transactional public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) { + validateMemoLength(problemRegisterDto.memo()); Folder folder = folderRepository.findById(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); @@ -240,6 +242,8 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) */ @Transactional public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long userId) { + validateMemoLength(problemRegisterV2Dto.memo()); + Folder folder = resolveRegisterFolder(problemRegisterV2Dto.folderId(), userId); ProblemRegisterDto baseDto = new ProblemRegisterDto( @@ -306,6 +310,7 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 if (registerDtos == null || registerDtos.isEmpty()) { return List.of(); } + registerDtos.forEach(dto -> validateMemoLength(dto.memo())); Map foldersById = resolveRegisterFolders(registerDtos, userId); Folder rootFolder = registerDtos.stream().anyMatch(dto -> dto.folderId() == null) @@ -592,6 +597,7 @@ public void updateProblemAnalysisToNoImage(Long problemId, Long userId) { @Transactional public void updateProblemInfo(ProblemRegisterDto problemRegisterDto, Long userId) { + validateMemoLength(problemRegisterDto.memo()); Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); @@ -941,6 +947,16 @@ private void validateFolderOwner(Folder folder, Long userId) { } } + /** + * memo 컬럼 상한(V24, varchar(1000))을 넘으면 DB 가 truncation 으로 거절해 500 이 됐다. + * 저장 전에 걸러서 400 으로 돌려준다. + */ + private void validateMemoLength(String memo) { + if (memo != null && memo.length() > MEMO_MAX_LENGTH) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_MEMO_TOO_LONG); + } + } + @Transactional(readOnly = true) public ReviewDueResponseDto getReviewDueProblems(Long userId) { LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); diff --git a/src/main/resources/db/migration/V24__widen_problem_memo.sql b/src/main/resources/db/migration/V24__widen_problem_memo.sql new file mode 100644 index 00000000..3e573d49 --- /dev/null +++ b/src/main/resources/db/migration/V24__widen_problem_memo.sql @@ -0,0 +1,9 @@ +-- 메모가 255자를 넘으면 문제 등록 자체가 500 으로 실패했다 (Sentry JAVA-SPRING-BOOT-5B / 5A). +-- memo 는 ddl-auto 시절 기본값 varchar(255) 로 만들어졌고, 사용자 자유 입력이라 쉽게 넘긴다. +-- +-- utf8mb4 기준 varchar(255) 는 이미 1020 바이트라 2바이트 길이 접두사를 쓰고 있어, +-- varchar(1000)(4000 바이트) 로 늘려도 접두사 크기가 그대로다 → INPLACE 로 테이블 재작성 없이 처리된다. +-- TEXT 로 바꾸면 ALGORITHM=COPY 가 되어 problem 테이블 전체가 잠기므로 선택하지 않았다. +ALTER TABLE problem + MODIFY COLUMN memo VARCHAR(1000) NULL, + ALGORITHM = INPLACE, LOCK = NONE; diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index 8a43cf6a..f53b569e 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -307,6 +307,60 @@ void registerProblem() throws Exception { assertThat(problem.getProblemImageDataList()).isEmpty(); } + @Test + @DisplayName("문제 등록 - memo 1000자는 저장되고 reminder 스냅샷은 255자로 잘린다") + @WithMockCustomUser() + void registerProblemWithLongMemo() throws Exception { + // given + String longMemo = "가".repeat(1000); + ProblemRegisterDto problemRegisterDto = new ProblemRegisterDto( + null, + longMemo, + "reference", + folderRepository.findAllByUserId(userId).get(0).getId(), + LocalDateTime.now() + ); + + // when + mockMvc.perform(MockMvcRequestBuilders.post("/api/problems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(problemRegisterDto))) + .andExpect(status().isOk()); + + // then + Problem problem = problemRepository.findAllByUserId(userId) + .get((int) (problemRepository.countByUserId(userId) - 1)); + assertThat(problem.getMemo()).isEqualTo(longMemo); + + List reminders = reminderRepository.findAll().stream() + .filter(reminder -> reminder.getProblemId().equals(problem.getId())) + .toList(); + assertThat(reminders).isNotEmpty(); + assertThat(reminders).allSatisfy(reminder -> + assertThat(reminder.getProblemMemoSnapshot()).hasSize(255)); + } + + @Test + @DisplayName("문제 등록 - memo 가 1000자를 넘으면 500 이 아니라 400 으로 거절한다") + @WithMockCustomUser() + void registerProblemWithTooLongMemo() throws Exception { + // given + ProblemRegisterDto problemRegisterDto = new ProblemRegisterDto( + null, + "가".repeat(1001), + "reference", + folderRepository.findAllByUserId(userId).get(0).getId(), + LocalDateTime.now() + ); + + // when & then + mockMvc.perform(MockMvcRequestBuilders.post("/api/problems") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(problemRegisterDto))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(4006)); + } + @Test @DisplayName("문제 이미지 등록 API 테스트") @WithMockCustomUser() @@ -636,6 +690,36 @@ void updateProblemInfo_updatesReminderSnapshot() throws Exception { ); } + @Test + @DisplayName("PATCH /api/problems/info - memo 가 길어도 snapshot 갱신이 truncation 으로 실패하지 않는다") + void updateProblemInfo_truncatesLongMemoSnapshot() throws Exception { + // given + authenticateAsFixtureUser(); + Long problemId = problemList.get(0).getId(); + reminderRepository.save(ProblemReviewReminder.create( + userId, problemId, "old memo", "old ref", 1, 1, LocalDateTime.now().plusDays(1) + )); + + String longMemo = "나".repeat(1000); + ProblemRegisterDto updateDto = new ProblemRegisterDto(problemId, longMemo, "ref", null, null); + + // when + mockMvc.perform(MockMvcRequestBuilders.patch("/api/problems/info") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto))) + .andExpect(status().isOk()); + + // then - 문제 본문에는 전문이, 알림 스냅샷에는 255자만 남는다 + assertThat(problemRepository.findById(problemId).orElseThrow().getMemo()).isEqualTo(longMemo); + + List scheduledRows = reminderRepository.findAll().stream() + .filter(r -> r.getProblemId().equals(problemId) + && r.getStatus() == ProblemReviewReminderStatus.SCHEDULED) + .toList(); + assertThat(scheduledRows).hasSize(1); + assertThat(scheduledRows.get(0).getProblemMemoSnapshot()).hasSize(255); + } + @Test @DisplayName("DELETE /api/problems 후 해당 problemId의 reminder row가 CANCELED 상태가 된다") void deleteProblems_cancelsReminderRows() throws Exception { From c9fb4a0808ca58d4930f451b6f192b0c489914fa Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 12:07:32 +0900 Subject: [PATCH 012/130] =?UTF-8?q?[Perf]=20=EB=B3=B5=EC=8A=B5=20=EC=98=88?= =?UTF-8?q?=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20N+1=20=EC=A0=9C=EA=B1=B0=20-?= =?UTF-8?q?=20DTO=20=ED=94=84=EB=A1=9C=EC=A0=9D=EC=85=98=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Problem 의 problemAnalysis 가 @OneToOne(mappedBy) 라 Hibernate 가 프록시를 못 만들고 행마다 problem_analysis 를 한 번씩 더 조회했다 (Sentry JAVA-SPRING-BOOT-59) - review-due 는 스칼라 필드만 쓰므로 엔티티 대신 QueryDSL DTO 프로젝션으로 조회한다 문제 N개 조회 시 1 + N 번 → 1번 - 이 엔드포인트에서만 쓰던 findReviewDueProblems, ReviewDueProblemDto.from 은 제거 - 통합 테스트: 문제 6개 조회 시 PreparedStatement 1건, overdueCount 경계 검증 --- .../problem/dto/ReviewDueResponseDto.java | 11 ---- .../problem/repository/ProblemRepository.java | 3 - .../repository/ProblemRepositoryCustom.java | 8 +++ .../repository/ProblemRepositoryImpl.java | 20 +++++++ .../problem/service/ProblemService.java | 13 ++--- .../ProblemApiIntegrationTest.java | 55 +++++++++++++++++++ 6 files changed, 88 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java index 4f0dc8bb..5e51b666 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java @@ -1,6 +1,5 @@ package com.aisip.OnO.backend.problem.dto; -import com.aisip.OnO.backend.problem.entity.Problem; import lombok.Builder; import java.time.LocalDate; @@ -21,15 +20,5 @@ public record ReviewDueProblemDto( int reviewInterval, int consecutiveCorrectCount ) { - public static ReviewDueProblemDto from(Problem problem) { - return ReviewDueProblemDto.builder() - .problemId(problem.getId()) - .memo(problem.getMemo()) - .reference(problem.getReference()) - .nextReviewAt(problem.getNextReviewAt()) - .reviewInterval(problem.getReviewInterval()) - .consecutiveCorrectCount(problem.getConsecutiveCorrectCount()) - .build(); - } } } \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java index fdd8c3c9..13030e23 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java @@ -12,9 +12,6 @@ public interface ProblemRepository extends JpaRepository, Problem Long countByUserId(Long userId); - @Query("SELECT p FROM Problem p WHERE p.userId = :userId AND p.nextReviewAt <= :today ORDER BY p.nextReviewAt ASC") - List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); - @Query("SELECT p.userId as userId, COUNT(p) as dueCount FROM Problem p WHERE p.nextReviewAt <= :today GROUP BY p.userId") List findReviewDueSummaryByDate(@Param("today") LocalDate today); } diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java index 63a09041..19edd406 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryCustom.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; import org.springframework.data.domain.Page; @@ -21,6 +22,13 @@ public interface ProblemRepositoryCustom { List findAll(); + /** + * 복습 예정 문제를 DTO 로 바로 조회한다. + * Problem 엔티티로 읽으면 @OneToOne(mappedBy) problemAnalysis 때문에 + * 행마다 problem_analysis 조회가 한 번씩 더 나간다 (Sentry JAVA-SPRING-BOOT-59). + */ + List findReviewDueProblemDtos(Long userId, LocalDate today); + Page findAdminProblems(Pageable pageable); Map countDailyProblems(LocalDate startDate, LocalDate endDate); diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java index e427f186..84232981 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.admin.dto.AdminProblemResponseDto; +import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.entity.QProblem; @@ -83,6 +84,25 @@ public List findAll() { .fetch(); } + @Override + public List findReviewDueProblemDtos(Long userId, LocalDate today) { + return queryFactory + .select(Projections.constructor( + ReviewDueResponseDto.ReviewDueProblemDto.class, + problem.id, + problem.memo, + problem.reference, + problem.nextReviewAt, + problem.reviewInterval, + problem.consecutiveCorrectCount + )) + .from(problem) + .where(problem.userId.eq(userId) + .and(problem.nextReviewAt.loe(today))) + .orderBy(problem.nextReviewAt.asc()) + .fetch(); + } + @Override public Page findAdminProblems(Pageable pageable) { List content = queryFactory diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index de2a0e90..9805e59c 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -960,18 +960,15 @@ private void validateMemoLength(String memo) { @Transactional(readOnly = true) public ReviewDueResponseDto getReviewDueProblems(Long userId) { LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); - List dueProblems = problemRepository.findReviewDueProblems(userId, today); + List problemDtos = + problemRepository.findReviewDueProblemDtos(userId, today); - long overdueCount = dueProblems.stream() - .filter(p -> p.getNextReviewAt().isBefore(today)) + long overdueCount = problemDtos.stream() + .filter(dto -> dto.nextReviewAt().isBefore(today)) .count(); - List problemDtos = dueProblems.stream() - .map(ReviewDueResponseDto.ReviewDueProblemDto::from) - .collect(Collectors.toList()); - return ReviewDueResponseDto.builder() - .dueCount(dueProblems.size()) + .dueCount(problemDtos.size()) .overdueCount(overdueCount) .problems(problemDtos) .build(); diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index f53b569e..de33552d 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -25,6 +25,9 @@ import com.aisip.OnO.backend.problem.repository.ProblemRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManagerFactory; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,6 +46,7 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -83,6 +87,9 @@ class ProblemApiIntegrationTest { @Autowired private ObjectMapper objectMapper; + @Autowired + private EntityManagerFactory entityManagerFactory; + @MockBean private FileUploadService fileUploadService; @@ -307,6 +314,54 @@ void registerProblem() throws Exception { assertThat(problem.getProblemImageDataList()).isEmpty(); } + @Test + @DisplayName("복습 예정 조회 - 문제 6개를 조회해도 쿼리는 1번만 나간다 (problem_analysis N+1 회귀 방지)") + @WithMockCustomUser() + void getReviewDueProblemsDoesNotTriggerNPlusOne() throws Exception { + // given - 픽스처 문제 6개를 모두 오늘 복습 대상으로 만든다. 각 문제에는 ProblemAnalysis 가 붙어 있다. + LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + problemList.forEach(problem -> { + problem.updateReviewSchedule(today, 1, 0); + problemRepository.save(problem); + }); + + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + // when + MvcResult result = mockMvc.perform(MockMvcRequestBuilders.get("/api/problems/review-due")) + .andExpect(status().isOk()) + .andReturn(); + + // then + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).get("data"); + assertThat(data.get("dueCount").asInt()).isEqualTo(problemList.size()); + assertThat(data.get("problems")).hasSize(problemList.size()); + + // 엔티티로 읽던 시절에는 문제 수만큼 problem_analysis 조회가 더 나갔다 + assertThat(statistics.getPrepareStatementCount()).isEqualTo(1); + } + + @Test + @DisplayName("복습 예정 조회 - overdueCount 는 오늘 이전 예정 건만 센다") + @WithMockCustomUser() + void getReviewDueProblemsCountsOverdueOnly() throws Exception { + // given + LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); + for (int i = 0; i < problemList.size(); i++) { + Problem problem = problemList.get(i); + problem.updateReviewSchedule(i < 2 ? today.minusDays(3) : today, 1, 0); + problemRepository.save(problem); + } + + // when & then + mockMvc.perform(MockMvcRequestBuilders.get("/api/problems/review-due")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.dueCount").value(problemList.size())) + .andExpect(jsonPath("$.data.overdueCount").value(2)); + } + @Test @DisplayName("문제 등록 - memo 1000자는 저장되고 reminder 스냅샷은 255자로 잘린다") @WithMockCustomUser() From 347603f5c4ea900ede4ed0cb067e819bbb8b3d11 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 12:07:44 +0900 Subject: [PATCH 013/130] =?UTF-8?q?[Fix]=20=EC=B2=98=EB=A6=AC=EB=90=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EA=B0=80=20Sentry=20error=20=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AC=EB=9D=BC=EA=B0=80=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LoggingAspect 는 MDC traceId 가 없으면 "아무도 안 잡은 예외"로 보고 error 로 남기는데, RabbitMQ consumer 스레드에는 traceId 가 없어 consumer 가 정상 처리하는 예외까지 error 가 됐다 (Sentry JAVA-SPRING-BOOT-3A, 누적 140건) - HandledFailure 마커 인터페이스를 추가하고 NonRetryableAnalysisException 에 적용 ProblemAnalysisConsumer 가 재큐잉 없이 ACK 하고 상태도 FAILED 로 정리하므로 warn 으로 내린다 - Discord 웹훅 전송 실패도 error → warn. 일시적 read timeout 은 RabbitMQ 가 재시도하고 최종 실패는 DLQ 에서 error 로 남는다 (Sentry JAVA-SPRING-BOOT-5C) - 웹훅 예외 메시지에 웹훅 URL(토큰 포함)이 그대로 들어 있어 로그·Sentry 로 새고 있었다 메시지 대신 예외 타입만 남긴다 - LoggingAspectTest: HandledFailure warn, 미처리 예외 error, ApplicationException 무시, 요청 스레드 debug 네 경우 검증 --- .../OnO/backend/common/aop/LoggingAspect.java | 9 ++ .../common/exception/HandledFailure.java | 12 ++ .../consumer/DiscordWebhookConsumer.java | 5 +- .../ai/NonRetryableAnalysisException.java | 7 +- .../backend/common/aop/LoggingAspectTest.java | 103 ++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java diff --git a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java index bd638103..1cd9886f 100644 --- a/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/aop/LoggingAspect.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.common.aop; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.exception.HandledFailure; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.JoinPoint; import org.aspectj.lang.annotation.AfterThrowing; @@ -20,6 +21,14 @@ public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { return; } + // 호출한 쪽이 이미 처리하는 실패는 여기서 error 로 올리지 않는다 + if (ex instanceof HandledFailure) { + log.warn("Handled service failure - method: {}, reason: {}", + joinPoint.getSignature().toShortString(), + ex.getMessage()); + return; + } + if (MDC.get("traceId") != null) { log.debug("Service exception propagated to request handler - method: {}, exceptionType: {}", joinPoint.getSignature().toShortString(), diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java b/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java new file mode 100644 index 00000000..e9f9c8ce --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/exception/HandledFailure.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.common.exception; + +/** + * 호출한 쪽이 이미 처리하기로 되어 있는 실패에 붙이는 표시. + * + * LoggingAspect 는 HTTP 요청 밖(RabbitMQ consumer, 배치 스레드)에서 올라온 예외를 + * "아무도 안 잡은 예외"로 보고 error 로 남기는데, consumer 가 잡아서 정상 종료시키는 + * 예외까지 Sentry 에 fatal 로 올라가 노이즈가 됐다 (Sentry JAVA-SPRING-BOOT-3A). + * 이 인터페이스를 구현하면 aspect 가 error 로 올리지 않는다. + */ +public interface HandledFailure { +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java index 85e88dcb..a97e7419 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java @@ -38,7 +38,10 @@ public void handleWebhookMessage(DiscordWebhookMessage message) { restTemplate.postForEntity(webhookUrl, new HttpEntity<>(payload, headers), String.class); log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); } catch (Exception e) { - log.error("Discord webhook 전송 실패: {}", e.getMessage(), e); + // 일시적 실패(read timeout 등)는 RabbitMQ 가 재시도하고, 최종 실패는 DLQ 에서 error 로 남는다. + // 여기서 error 로 올리면 재시도로 성공한 건까지 Sentry 에 쌓인다 (Sentry JAVA-SPRING-BOOT-5C). + // 예외 메시지에 웹훅 URL(토큰 포함)이 그대로 들어 있어 메시지 대신 예외 타입만 남긴다. + log.warn("Discord webhook 전송 실패, 재시도 예정 - exceptionType: {}", e.getClass().getSimpleName()); throw new RuntimeException("Discord webhook 전송 실패", e); } } diff --git a/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java b/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java index 8a2b1f97..43c4524e 100644 --- a/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java +++ b/src/main/java/com/aisip/OnO/backend/util/ai/NonRetryableAnalysisException.java @@ -1,9 +1,14 @@ package com.aisip.OnO.backend.util.ai; +import com.aisip.OnO.backend.common.exception.HandledFailure; + /** * 재시도로 해결되지 않는 분석 실패(예: 이미지 판독 불가) 예외 + * + * ProblemAnalysisConsumer 가 잡아서 재큐잉 없이 ACK 하고, 상태도 FAILED 로 정리한다. + * 즉 여기까지가 정상 처리 경로라 HandledFailure 를 붙여 error 로그 대상에서 뺀다. */ -public class NonRetryableAnalysisException extends RuntimeException { +public class NonRetryableAnalysisException extends RuntimeException implements HandledFailure { public NonRetryableAnalysisException(String message) { super(message); diff --git a/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java new file mode 100644 index 00000000..f1d62d96 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java @@ -0,0 +1,103 @@ +package com.aisip.OnO.backend.common.aop; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.util.ai.NonRetryableAnalysisException; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * RabbitMQ consumer 처럼 MDC traceId 가 없는 스레드에서 올라온 예외를 + * aspect 가 어떤 레벨로 남기는지 검증한다. + * ERROR 로 남으면 Logback appender 를 타고 Sentry 로 올라간다. + */ +class LoggingAspectTest { + + private LoggingAspect aspect; + private JoinPoint joinPoint; + private Logger logger; + private ListAppender appender; + + @BeforeEach + void setUp() { + aspect = new LoggingAspect(); + + Signature signature = mock(Signature.class); + when(signature.toShortString()).thenReturn("ProblemAnalysisService.analyzeProblemSync(..)"); + joinPoint = mock(JoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + + logger = (Logger) LoggerFactory.getLogger(LoggingAspect.class); + appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + logger.setLevel(Level.DEBUG); + + MDC.clear(); + } + + @AfterEach + void tearDown() { + logger.detachAppender(appender); + MDC.clear(); + } + + private List events() { + return appender.list; + } + + @Test + @DisplayName("consumer 가 처리하는 실패(HandledFailure)는 ERROR 로 남지 않는다") + void handledFailureIsNotLoggedAsError() { + aspect.logAfterThrowing(joinPoint, new NonRetryableAnalysisException("AI가 요청을 거절하여 분석을 진행할 수 없습니다.")); + + assertThat(events()).hasSize(1); + assertThat(events().get(0).getLevel()).isEqualTo(Level.WARN); + assertThat(events().get(0).getFormattedMessage()).contains("Handled service failure"); + } + + @Test + @DisplayName("정말 처리되지 않은 예외는 그대로 ERROR 로 남는다") + void unhandledExceptionIsStillLoggedAsError() { + aspect.logAfterThrowing(joinPoint, new IllegalStateException("boom")); + + assertThat(events()).hasSize(1); + assertThat(events().get(0).getLevel()).isEqualTo(Level.ERROR); + assertThat(events().get(0).getFormattedMessage()).contains("Unhandled service exception"); + } + + @Test + @DisplayName("ApplicationException 은 기존대로 아무것도 남기지 않는다") + void applicationExceptionIsSkipped() { + aspect.logAfterThrowing(joinPoint, new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); + + assertThat(events()).isEmpty(); + } + + @Test + @DisplayName("HTTP 요청 스레드(traceId 존재)에서는 기존대로 DEBUG 로만 남는다") + void requestThreadKeepsDebugLevel() { + MDC.put("traceId", "trace-1"); + + aspect.logAfterThrowing(joinPoint, new IllegalStateException("boom")); + + assertThat(events()).hasSize(1); + assertThat(events().get(0).getLevel()).isEqualTo(Level.DEBUG); + } +} From 90a76bfd37334d5467ba49baf445c65b60567961 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 12:07:54 +0900 Subject: [PATCH 014/130] =?UTF-8?q?[Chore]=205xx=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EB=B3=B4=EC=A0=95=20-=20API=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=95=9C=EC=A0=95=20=EB=B0=8F=20=EA=B1=B4=EC=88=98?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EA=B7=9C=EC=B9=99=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최근 14일 요청 128,335건 중 92.4%가 /api 밖 경로(봇 스캐너)라, 비율 기준 알림의 분모가 부풀려져 실제 API 5xx 가 희석됐다 (전체 기준 최대 10.6% vs /api 기준 25%) - OnOBackendHigh5xxRatio 의 분자·분모를 uri=~"/api.*" 로 한정 - API 트래픽이 하루 약 780건(분당 0.5건)이라 비율 + for:10m 로는 짧게 터지는 장애를 못 잡는다. 실제로 태그 중복 500 12건, memo 500 3건이 모두 이 규칙을 통과했다 - OnOBackendApi5xxBurst 추가: 10분 내 /api 5xx 3건 이상이면 즉시 발화 과거 14일 데이터로 확인 시 태그 장애 구간에서만 참이었다 - 반영하려면 restart-monitoring 워크플로 실행 필요 --- monitoring/alerts/ono-alerts.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/monitoring/alerts/ono-alerts.yml b/monitoring/alerts/ono-alerts.yml index f445fc15..06ac819b 100644 --- a/monitoring/alerts/ono-alerts.yml +++ b/monitoring/alerts/ono-alerts.yml @@ -53,12 +53,27 @@ groups: summary: "High API p95 latency on {{ $labels.job }}" description: "HTTP API p95 latency is above 1.5s for 10 minutes." + # 분모에서 봇 트래픽을 뺀다. 전체 요청의 92%가 /api 밖 경로(스캐너)라 + # 그대로 두면 실제 API 의 5xx 비율이 1/13 로 희석된다. - alert: OnOBackendHigh5xxRatio - expr: (sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5.."}[5m])) / clamp_min(sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*"}[5m])), 1e-9)) > 0.05 + expr: (sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5..",uri=~"/api.*"}[5m])) / clamp_min(sum by (job) (rate(http_server_requests_seconds_count{job=~"ono-backend-.*",uri=~"/api.*"}[5m])), 1e-9)) > 0.05 for: 10m labels: severity: warning service: ono-backend annotations: summary: "High 5xx ratio on {{ $labels.job }}" - description: "5xx ratio is above 5% for 10 minutes." + description: "API 5xx ratio is above 5% for 10 minutes." + + # API 트래픽이 하루 약 780건(분당 0.5건)이라 비율+for:10m 조합으로는 + # 짧게 터지는 장애를 못 잡는다. 실제로 8/30 태그 중복 500 12건, 8/25 memo 500 3건이 + # 모두 몇 분 안에 끝나 위 규칙을 통과했다. 건수 기준으로 한 번 더 본다. + - alert: OnOBackendApi5xxBurst + expr: sum by (job) (increase(http_server_requests_seconds_count{job=~"ono-backend-.*",status=~"5..",uri=~"/api.*"}[10m])) >= 3 + for: 0m + labels: + severity: warning + service: ono-backend + annotations: + summary: "API 5xx burst on {{ $labels.job }}" + description: "10분 동안 /api 경로에서 5xx 가 3건 이상 발생했습니다." From 388b9d10649354ae42e971dc8dca9c1cb9556112 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:07:50 +0900 Subject: [PATCH 015/130] =?UTF-8?q?[Chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=8B=A4=ED=96=89=20=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=20=EB=B0=8F=20=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=95=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테스트 워커 기본 힙(512m)으로는 @SpringBootTest 컨텍스트가 캐시에 쌓이며 OOM 이 났고, 워커가 죽으면서 40개 클래스 중 3개(66건)만 돌고 빌드가 끝나고 있었다. maxHeapSize 3g 로 해결 (283건 중 268건 실행, 실행 시간은 오히려 4분 7초 → 2분 7초) - performance 패키지 2개 클래스 제거 어서션이 하나도 없는 측정 스크립트이고, PersonalPerformanceTest 는 하드코딩된 유저 2252 에 의존하며 폴더 500 × 문제 100 = 5만 건을 인메모리 H2 에 밀어넣어 스위트 전체를 죽이고 있었다. LargePerformanceTest 는 MySQL 전용 multi-table DELETE 문법이라 H2 에서 실행 자체가 불가능했다 (프로젝트 전체에서 어서션 없는 테스트 메서드 14개 중 13개가 이 두 클래스였다) - 두 클래스만 쓰던 BulkDataGenerator 도 함께 제거 - JaCoCo 추가: QueryDSL Q 클래스와 부트 진입점은 측정에서 제외, 테스트가 실패해도 리포트가 나오도록 finalizedBy 로 연결 현재 라인 커버리지 57.8% / 분기 38.8% --- .../performance/LargePerformanceTest.java | 549 ------------------ .../performance/PersonalPerformanceTest.java | 396 ------------- .../OnO/backend/util/BulkDataGenerator.java | 384 ------------ 3 files changed, 1329 deletions(-) delete mode 100644 src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java diff --git a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java deleted file mode 100644 index 65cbde3d..00000000 --- a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java +++ /dev/null @@ -1,549 +0,0 @@ -package com.aisip.OnO.backend.performance; - -import com.aisip.OnO.backend.folder.entity.Folder; -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.entity.PracticeNote; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; -import com.aisip.OnO.backend.problem.entity.Problem; -import com.aisip.OnO.backend.problem.entity.ProblemImageData; -import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; -import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomFolderGenerator; -import com.aisip.OnO.backend.util.RandomPracticeNoteGenerator; -import com.aisip.OnO.backend.util.RandomProblemGenerator; -import com.aisip.OnO.backend.util.RandomUserGenerator; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import jakarta.persistence.EntityManager; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -/** - * 인덱스 도입 전후 성능 비교 테스트 - * - * 테스트 대상 인덱스: - * 1. Problem.folder_id - * 2. Folder.userId - * 3. Problem.userId - * 4. PracticeNote.userId - * 5. User.identifier (UNIQUE) - */ -@Slf4j -@SpringBootTest -@ActiveProfiles("test") -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class LargePerformanceTest { - - @Autowired - private ProblemRepository problemRepository; - - @Autowired - private FolderRepository folderRepository; - - @Autowired - private PracticeNoteRepository practiceNoteRepository; - - @Autowired - private UserRepository userRepository; - - private static final int USER_COUNT = 500; - private static final int FOLDER_PER_USER = 10; - private static final int PROBLEM_PER_FOLDER = 25; - private static final int PRACTICE_NOTE_PER_USER = 20; - private static final int QUERY_REPEAT_COUNT = 10; - - private static List testUserIds = new ArrayList<>(); - private static List testFolderIds = new ArrayList<>(); - private static List testIdentifiers = new ArrayList<>(); - - @BeforeAll - static void setup(@Autowired UserRepository userRepository, - @Autowired FolderRepository folderRepository, - @Autowired ProblemRepository problemRepository, - @Autowired PracticeNoteRepository practiceNoteRepository, - @Autowired ProblemImageDataRepository problemImageDataRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - log.info("========================================"); - log.info("테스트 데이터 생성 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - long startTime = System.currentTimeMillis(); - - // 1. User 생성 - for (int i = 0; i < USER_COUNT; i++) { - User user = RandomUserGenerator.createRandomUser(); - user = userRepository.save(user); - testUserIds.add(user.getId()); - testIdentifiers.add(user.getIdentifier()); - } - log.info("User {} 개 생성 완료", USER_COUNT); - - // 2. Folder 생성 - for (Long userId : testUserIds) { - for (int i = 0; i < FOLDER_PER_USER; i++) { - Folder folder = RandomFolderGenerator.createRandomFolder(userId); - - folder = folderRepository.save(folder); - testFolderIds.add(folder.getId()); - } - } - log.info("Folder {} 개 생성 완료 (총 {}개)", FOLDER_PER_USER, testFolderIds.size()); - - // 3. Problem 생성 (배치 처리) - int problemCount = 0; - int batchSize = 100; - for (Long folderId : testFolderIds) { - Folder folder = folderRepository.findById(folderId).orElseThrow(); - for (int i = 0; i < PROBLEM_PER_FOLDER; i++) { - Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folder, folder.getUserId()); - problem.updateFolder(folder); - problemRepository.save(problem); - problemCount++; - - List imageDataList = RandomProblemGenerator.createDefaultProblemImageDataList(problem.getId()); - - imageDataList.forEach(imageData -> { - imageData.updateProblem(problem); - ProblemImageData saveImageData = problemImageDataRepository.save(imageData); - }); - - // 배치 단위로 flush & clear - if (problemCount % batchSize == 0) { - entityManager.flush(); - entityManager.clear(); - if (problemCount % 10000 == 0) { - log.info("Problem {} / {} 생성 중...", problemCount, testFolderIds.size() * PROBLEM_PER_FOLDER); - } - } - } - } - entityManager.flush(); - entityManager.clear(); - log.info("Problem {} 개 생성 완료", problemCount); - - // 4. PracticeNote 생성 - int practiceNoteCount = 0; - for (Long userId : testUserIds) { - for (int i = 0; i < PRACTICE_NOTE_PER_USER; i++) { - PracticeNote practiceNote = RandomPracticeNoteGenerator.createRandomPracticeNote(userId); - practiceNoteRepository.save(practiceNote); - practiceNoteCount++; - } - } - log.info("PracticeNote {} 개 생성 완료", practiceNoteCount); - - long endTime = System.currentTimeMillis(); - log.info("========================================"); - log.info("테스트 데이터 생성 완료: {}ms", (endTime - startTime)); - log.info("User: {}, Folder: {}, Problem: {}, PracticeNote: {}", - USER_COUNT, testFolderIds.size(), problemCount, practiceNoteCount); - log.info("========================================"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - } - - @AfterAll - static void cleanup(@Autowired UserRepository userRepository, - @Autowired FolderRepository folderRepository, - @Autowired ProblemRepository problemRepository, - @Autowired PracticeNoteRepository practiceNoteRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - log.info("========================================"); - log.info("테스트 데이터 정리 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 외래 키 제약 조건 임시 비활성화 (MySQL) - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); - log.info("외래 키 제약 조건 비활성화"); - - // testUserIds를 기반으로 테스트 데이터만 삭제 - String userIdsStr = testUserIds.stream() - .map(String::valueOf) - .reduce((a, b) -> a + "," + b) - .orElse(""); - - if (userIdsStr.isEmpty()) { - log.warn("삭제할 테스트 유저 ID가 없습니다. 데이터 정리를 건너뜁니다."); - return; - } - - // Native Query로 물리적 삭제 (순서 중요: 자식 → 부모) - // 1. problem_practice_note_mapping 삭제 (problem과 practice_note를 통해 간접 참조) - int mappingDeleted = entityManager.createNativeQuery( - "DELETE ppm FROM problem_practice_note_mapping ppm " + - "INNER JOIN problem p ON ppm.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem_practice_note_mapping {} 건 삭제", mappingDeleted); - - // 2. problem_analysis 삭제 (problem을 참조) - int analysisDeleted = entityManager.createNativeQuery( - "DELETE pa FROM problem_analysis pa " + - "INNER JOIN problem p ON pa.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem_analysis {} 건 삭제", analysisDeleted); - - // 3. image_data 삭제 (problem을 참조) - int imageDeleted = entityManager.createNativeQuery( - "DELETE id FROM image_data id " + - "INNER JOIN problem p ON id.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("image_data {} 건 삭제", imageDeleted); - - // 4. problem 삭제 (테스트 유저의 문제만) - int problemDeleted = entityManager.createNativeQuery( - "DELETE FROM problem WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem {} 건 삭제", problemDeleted); - - // 5. practice_note 삭제 (테스트 유저의 복습노트만) - int practiceDeleted = entityManager.createNativeQuery( - "DELETE FROM practice_note WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("practice_note {} 건 삭제", practiceDeleted); - - // 6. folder 삭제 (테스트 유저의 폴더만, 자기 자신을 참조하므로 FK 체크 비활성화 필요) - int folderDeleted = entityManager.createNativeQuery( - "DELETE FROM folder WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("folder {} 건 삭제", folderDeleted); - - // 7. mission_log 삭제 (테스트 유저의 미션 로그만) - int missionDeleted = entityManager.createNativeQuery( - "DELETE FROM mission_log WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("mission_log {} 건 삭제", missionDeleted); - - // 8. user 삭제 (테스트 유저만) - int userDeleted = entityManager.createNativeQuery( - "DELETE FROM user WHERE id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("user {} 건 삭제", userDeleted); - - // 외래 키 제약 조건 재활성화 - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); - log.info("외래 키 제약 조건 재활성화"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - - log.info("테스트 데이터 물리적 삭제 완료"); - } catch (Exception e) { - // 에러 발생 시 롤백 - transactionManager.rollback(transaction); - log.error("테스트 데이터 정리 실패: {}", e.getMessage(), e); - throw e; // 에러를 다시 던져서 문제를 확인할 수 있도록 - } - - log.info("========================================"); - } - - /** - * 1. Problem.folder_id 인덱스 성능 테스트 - * 쿼리: findAllByFolderId(), findProblemIdsByFolder() - */ - @Test - @Order(1) - @DisplayName("1. Problem.folder_id 인덱스 성능 테스트") - void testProblemFolderIdIndex() { - log.info("\n========================================"); - log.info("Test 1: Problem.folder_id 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - problemRepository.findAllByFolderId(testFolderIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long folderId = testFolderIds.get(random.nextInt(testFolderIds.size())); - - long startTime = System.nanoTime(); - List problems = problemRepository.findAllByFolderId(folderId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; // ms로 변환 - executionTimes.add(executionTime); - - log.info("Query {}: folderId={}, 결과={} 건, 실행시간={}ms", - i + 1, folderId, problems.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 2. Folder.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), findRootFolder(), findAllFoldersWithDetailsByUserId() - */ - @Test - @Order(2) - @DisplayName("2. Folder.userId 인덱스 성능 테스트") - void testFolderUserIdIndex() { - log.info("\n========================================"); - log.info("Test 2: Folder.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - folderRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List folders = folderRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, folders.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 3. Problem.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), countByUserId() - */ - @Test - @Order(3) - @DisplayName("3. Problem.userId 인덱스 성능 테스트") - void testProblemUserIdIndex() { - log.info("\n========================================"); - log.info("Test 3: Problem.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - problemRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List problems = problemRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, problems.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 4. PracticeNote.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), findAllUserPracticeNotesWithDetails() - */ - @Test - @Order(4) - @DisplayName("4. PracticeNote.userId 인덱스 성능 테스트") - void testPracticeNoteUserIdIndex() { - log.info("\n========================================"); - log.info("Test 4: PracticeNote.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - practiceNoteRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List practiceNotes = practiceNoteRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, practiceNotes.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 5. User.identifier 인덱스 성능 테스트 - * 쿼리: findByIdentifier() - */ - @Test - @Order(5) - @DisplayName("5. User.identifier 인덱스 성능 테스트") - void testUserIdentifierIndex() { - log.info("\n========================================"); - log.info("Test 5: User.identifier 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - userRepository.findByIdentifier(testIdentifiers.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - String identifier = testIdentifiers.get(random.nextInt(testIdentifiers.size())); - - long startTime = System.nanoTime(); - userRepository.findByIdentifier(identifier); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: identifier={}, 실행시간={}ms", - i + 1, identifier, executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 종합 성능 테스트 - 모든 쿼리를 순차적으로 실행 - */ - @Test - @Order(6) - @DisplayName("6. 종합 성능 테스트") - void testOverallPerformance() { - log.info("\n========================================"); - log.info("Test 6: 종합 성능 테스트"); - log.info("========================================"); - - Random random = new Random(); - long totalStartTime = System.currentTimeMillis(); - - // 1. Problem by folderId - long start1 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - problemRepository.findAllByFolderId(testFolderIds.get(random.nextInt(testFolderIds.size()))); - } - long time1 = System.currentTimeMillis() - start1; - - // 2. Folder by userId - long start2 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - folderRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time2 = System.currentTimeMillis() - start2; - - // 3. Problem by userId - long start3 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - problemRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time3 = System.currentTimeMillis() - start3; - - // 4. PracticeNote by userId - long start4 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - practiceNoteRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time4 = System.currentTimeMillis() - start4; - - // 5. User by identifier - long start5 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - userRepository.findByIdentifier(testIdentifiers.get(random.nextInt(testIdentifiers.size()))); - } - long time5 = System.currentTimeMillis() - start5; - - long totalTime = System.currentTimeMillis() - totalStartTime; - - log.info("----------------------------------------"); - log.info("1. Problem.folder_id 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time1, String.format("%.2f", (double) time1 / QUERY_REPEAT_COUNT)); - log.info("2. Folder.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time2, String.format("%.2f", (double) time2 / QUERY_REPEAT_COUNT)); - log.info("3. Problem.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time3, String.format("%.2f", (double) time3 / QUERY_REPEAT_COUNT)); - log.info("4. PracticeNote.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time4, String.format("%.2f", (double) time4 / QUERY_REPEAT_COUNT)); - log.info("5. User.identifier 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time5, String.format("%.2f", (double) time5 / QUERY_REPEAT_COUNT)); - log.info("----------------------------------------"); - log.info("전체 실행 시간: {}ms", totalTime); - log.info("========================================\n"); - } -} diff --git a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java deleted file mode 100644 index cf9481ae..00000000 --- a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java +++ /dev/null @@ -1,396 +0,0 @@ -package com.aisip.OnO.backend.performance; - -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository;import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.BulkDataGenerator; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import jakarta.persistence.EntityManager; - -/** - * BulkDataGenerator를 사용한 개인 성능 테스트 - * - * 특정 유저에 대해 대량의 데이터를 생성하고 - * 다양한 시나리오에서 성능을 측정합니다. - */ -@Slf4j -@SpringBootTest -@ActiveProfiles("test") -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class PersonalPerformanceTest { - - @Autowired - private BulkDataGenerator bulkDataGenerator; - - @Autowired - private UserRepository userRepository; - - @Autowired - private FolderRepository folderRepository; - - @Autowired - private ProblemRepository problemRepository; - - @Autowired - private PracticeNoteRepository practiceNoteRepository; - - private static final Long testUserId = 2252L; - private static String testUserIdentifier; - - @BeforeAll - static void setup(@Autowired UserRepository userRepository, - @Autowired BulkDataGenerator bulkDataGenerator, - @Autowired PlatformTransactionManager transactionManager) { - log.info("========================================"); - log.info("BulkDataGenerator를 사용한 테스트 데이터 생성"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 1. 테스트 유저 조회 - User user = userRepository.findById(2252L).get(); - testUserIdentifier = user.getIdentifier(); - - // 2. BulkDataGenerator로 대량 데이터 생성 - // 원하는 데이터 규모에 따라 메서드 선택: - // - generateSmallBulkData(): 소량 (10 폴더, 폴더당 10 문제, 10 복습노트) - // - generateDefaultBulkData(): 기본 (100 폴더, 폴더당 50 문제, 50 복습노트) - // - generateLargeBulkData(): 대량 (500 폴더, 폴더당 100 문제, 200 복습노트) - // - generateBulkData(userId, folderCount, problemsPerFolder, practiceNoteCount): 커스텀 - - log.info("대량 데이터 생성 시작..."); - BulkDataGenerator.BulkDataResult result = bulkDataGenerator.generateLargeBulkData(testUserId); - - log.info("========================================"); - log.info("테스트 데이터 생성 완료!"); - log.info(result.toString()); - log.info("========================================"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - } catch (Exception e) { - transactionManager.rollback(transaction); - log.error("테스트 데이터 생성 실패: {}", e.getMessage(), e); - throw e; - } - } - - @AfterAll - static void cleanup(@Autowired UserRepository userRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - /* - log.info("========================================"); - log.info("테스트 데이터 정리 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 외래 키 제약 조건 임시 비활성화 (MySQL) - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); - log.info("외래 키 제약 조건 비활성화"); - - if (testUserId == null) { - log.warn("삭제할 테스트 유저 ID가 없습니다. 데이터 정리를 건너뜁니다."); - transactionManager.commit(transaction); - return; - } - - String userIdStr = String.valueOf(testUserId); - - // Native Query로 물리적 삭제 (순서 중요: 자식 → 부모) - // 1. problem_practice_note_mapping 삭제 - int mappingDeleted = entityManager.createNativeQuery( - "DELETE ppm FROM problem_practice_note_mapping ppm " + - "INNER JOIN problem p ON ppm.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("problem_practice_note_mapping {} 건 삭제", mappingDeleted); - - // 2. problem_analysis 삭제 - int analysisDeleted = entityManager.createNativeQuery( - "DELETE pa FROM problem_analysis pa " + - "INNER JOIN problem p ON pa.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("problem_analysis {} 건 삭제", analysisDeleted); - - // 3. image_data 삭제 - int imageDeleted = entityManager.createNativeQuery( - "DELETE id FROM image_data id " + - "INNER JOIN problem p ON id.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("image_data {} 건 삭제", imageDeleted); - - // 4. problem 삭제 - int problemDeleted = entityManager.createNativeQuery( - "DELETE FROM problem WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("problem {} 건 삭제", problemDeleted); - - // 5. practice_note 삭제 - int practiceDeleted = entityManager.createNativeQuery( - "DELETE FROM practice_note WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("practice_note {} 건 삭제", practiceDeleted); - - // 6. folder 삭제 - int folderDeleted = entityManager.createNativeQuery( - "DELETE FROM folder WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("folder {} 건 삭제", folderDeleted); - - // 7. mission_log 삭제 - int missionDeleted = entityManager.createNativeQuery( - "DELETE FROM mission_log WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("mission_log {} 건 삭제", missionDeleted); - - // 8. user 삭제 - int userDeleted = entityManager.createNativeQuery( - "DELETE FROM user WHERE id = " + userIdStr - ).executeUpdate(); - log.info("user {} 건 삭제", userDeleted); - - // 외래 키 제약 조건 재활성화 - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); - log.info("외래 키 제약 조건 재활성화"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - - log.info("테스트 데이터 물리적 삭제 완료"); - } catch (Exception e) { - transactionManager.rollback(transaction); - log.error("테스트 데이터 정리 실패: {}", e.getMessage(), e); - throw e; - } - - log.info("========================================"); - - */ - } - - /** - * 1. 폴더 조회 성능 테스트 - */ - @Test - @Order(1) - @DisplayName("1. 유저의 모든 폴더 조회 성능 테스트") - void testFindAllFolders() { - log.info("\n========================================"); - log.info("Test 1: 유저의 모든 폴더 조회"); - log.info("========================================"); - - // Warm-up - folderRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var folders = folderRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 폴더", folders.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 2. 문제 조회 성능 테스트 - */ - @Test - @Order(2) - @DisplayName("2. 유저의 모든 문제 조회 성능 테스트") - void testFindAllProblems() { - log.info("\n========================================"); - log.info("Test 2: 유저의 모든 문제 조회"); - log.info("========================================"); - - // Warm-up - problemRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var problems = problemRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 문제", problems.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 3. 문제 개수 카운트 성능 테스트 - */ - @Test - @Order(3) - @DisplayName("3. 유저의 문제 개수 카운트 성능 테스트") - void testCountProblems() { - log.info("\n========================================"); - log.info("Test 3: 유저의 문제 개수 카운트"); - log.info("========================================"); - - // Warm-up - problemRepository.countByUserId(testUserId); - - long startTime = System.nanoTime(); - long count = problemRepository.countByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("문제 개수: {} 개", count); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 4. 복습노트 조회 성능 테스트 - */ - @Test - @Order(4) - @DisplayName("4. 유저의 모든 복습노트 조회 성능 테스트") - void testFindAllPracticeNotes() { - log.info("\n========================================"); - log.info("Test 4: 유저의 모든 복습노트 조회"); - log.info("========================================"); - - // Warm-up - practiceNoteRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var practiceNotes = practiceNoteRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 복습노트", practiceNotes.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 5. 유저 조회 (identifier) 성능 테스트 - */ - @Test - @Order(5) - @DisplayName("5. Identifier로 유저 조회 성능 테스트") - void testFindUserByIdentifier() { - log.info("\n========================================"); - log.info("Test 5: Identifier로 유저 조회"); - log.info("========================================"); - - // Warm-up - userRepository.findByIdentifier(testUserIdentifier); - - long startTime = System.nanoTime(); - var user = userRepository.findByIdentifier(testUserIdentifier); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: userId={}, identifier={}", user.map(User::getId).orElse(null), testUserIdentifier); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 6. 특정 폴더의 문제 조회 성능 테스트 - */ - @Test - @Order(6) - @DisplayName("6. 특정 폴더의 문제 조회 성능 테스트") - void testFindProblemsByFolder() { - log.info("\n========================================"); - log.info("Test 6: 특정 폴더의 문제 조회"); - log.info("========================================"); - - // 첫 번째 폴더 선택 - var folders = folderRepository.findAllByUserId(testUserId); - if (folders.isEmpty()) { - log.warn("테스트할 폴더가 없습니다."); - return; - } - - Long testFolderId = folders.get(0).getId(); - - // Warm-up - problemRepository.findAllByFolderId(testFolderId); - - long startTime = System.nanoTime(); - var problems = problemRepository.findAllByFolderId(testFolderId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: 폴더 ID={}, 문제 {} 개", testFolderId, problems.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 7. 종합 성능 테스트 - */ - @Test - @Order(7) - @DisplayName("7. 종합 성능 테스트 (모든 쿼리 연속 실행)") - void testOverallPerformance() { - log.info("\n========================================"); - log.info("Test 7: 종합 성능 테스트"); - log.info("========================================"); - - long totalStartTime = System.currentTimeMillis(); - - // 1. 폴더 조회 - long start1 = System.currentTimeMillis(); - var folders = folderRepository.findAllByUserId(testUserId); - long time1 = System.currentTimeMillis() - start1; - - // 2. 문제 조회 - long start2 = System.currentTimeMillis(); - var problems = problemRepository.findAllByUserId(testUserId); - long time2 = System.currentTimeMillis() - start2; - - // 3. 문제 카운트 - long start3 = System.currentTimeMillis(); - long problemCount = problemRepository.countByUserId(testUserId); - long time3 = System.currentTimeMillis() - start3; - - // 4. 복습노트 조회 - long start4 = System.currentTimeMillis(); - var practiceNotes = practiceNoteRepository.findAllByUserId(testUserId); - long time4 = System.currentTimeMillis() - start4; - - // 5. 유저 조회 - long start5 = System.currentTimeMillis(); - var user = userRepository.findByIdentifier(testUserIdentifier); - long time5 = System.currentTimeMillis() - start5; - - long totalTime = System.currentTimeMillis() - totalStartTime; - - log.info("----------------------------------------"); - log.info("1. 폴더 조회: {} 건, {}ms", folders.size(), time1); - log.info("2. 문제 조회: {} 건, {}ms", problems.size(), time2); - log.info("3. 문제 카운트: {} 건, {}ms", problemCount, time3); - log.info("4. 복습노트 조회: {} 건, {}ms", practiceNotes.size(), time4); - log.info("5. 유저 조회 (identifier): {}ms", time5); - log.info("----------------------------------------"); - log.info("전체 실행 시간: {}ms", totalTime); - log.info("========================================\n"); - } -} \ No newline at end of file diff --git a/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java b/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java deleted file mode 100644 index d5d4faf9..00000000 --- a/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java +++ /dev/null @@ -1,384 +0,0 @@ -package com.aisip.OnO.backend.util; - -import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; -import com.aisip.OnO.backend.folder.entity.Folder; -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.entity.PracticeNote; -import com.aisip.OnO.backend.practicenote.entity.ProblemPracticeNoteMapping; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; -import com.aisip.OnO.backend.practicenote.repository.ProblemPracticeNoteMappingRepository; -import com.aisip.OnO.backend.problem.entity.Problem; -import com.aisip.OnO.backend.problem.entity.ProblemImageData; -import com.aisip.OnO.backend.problem.entity.ProblemImageType; -import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; -import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -/** - * 대량 테스트 데이터 생성기 - * 특정 유저에 대해 Folder, Problem, PracticeNote 등을 대량으로 생성 - * 성능 테스트 및 대량 데이터 환경에서의 동작 검증에 사용 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class BulkDataGenerator { - - private final FolderRepository folderRepository; - private final ProblemRepository problemRepository; - private final ProblemImageDataRepository problemImageDataRepository; - private final PracticeNoteRepository practiceNoteRepository; - private final ProblemPracticeNoteMappingRepository problemPracticeNoteMappingRepository; - - private final Random random = new Random(); - - /** - * 특정 유저에 대해 모든 타입의 데이터를 대량으로 생성 - * @param userId 대상 유저 ID - * @param folderCount 생성할 폴더 개수 - * @param problemsPerFolder 폴더당 문제 개수 - * @param practiceNoteCount 생성할 복습노트 개수 - * @return 생성 결과 통계 - */ - @Transactional - public BulkDataResult generateBulkData( - Long userId, - int folderCount, - int problemsPerFolder, - int practiceNoteCount) { - - log.info("=== 대량 데이터 생성 시작 ==="); - log.info("유저 ID: {}", userId); - log.info("폴더 개수: {}, 폴더당 문제 개수: {}, 복습노트 개수: {}", - folderCount, problemsPerFolder, practiceNoteCount); - - long startTime = System.currentTimeMillis(); - - // 1. 폴더 생성 - List folders = generateFolders(userId, folderCount); - //List folders = folderRepository.findAllByUserId(userId); - //log.info("폴더 {} 개 생성 완료", folders.size()); - - // 2. 문제 생성 (이미지 포함) - List problems = generateProblems(userId, folders, problemsPerFolder); - //List problems = problemRepository.findAllByUserId(userId); - //log.info("문제 {} 개 생성 완료", problems.size()); - - // 3. 복습노트 생성 - List practiceNotes = generatePracticeNotes(userId, problems, practiceNoteCount); - log.info("복습노트 {} 개 생성 완료", practiceNotes.size()); - - long endTime = System.currentTimeMillis(); - long duration = endTime - startTime; - - BulkDataResult result = new BulkDataResult( - folders.size(), - problems.size(), - countImages(problems), - practiceNotes.size(), - duration - ); - - log.info("=== 대량 데이터 생성 완료 ==="); - log.info("소요 시간: {} ms", duration); - log.info(result.toString()); - - return result; - } - - /** - * 폴더 대량 생성 (지정된 루트 폴더 하위에 생성) - * @param userId 유저 ID - * @param count 생성할 폴더 개수 - */ - private List generateFolders(Long userId, int count) { - List folders = new ArrayList<>(); - - Optional optionalRootFolder = folderRepository.findRootFolder(userId); - Folder rootFolder; - - if(optionalRootFolder.isEmpty()) { - FolderRegisterDto folderRegisterDto = new FolderRegisterDto( - "책장", - null, - null - ); - rootFolder = folderRepository.save(Folder.from(folderRegisterDto, userId)); - } else { - rootFolder = optionalRootFolder.get(); - } - - // 모든 폴더를 루트 폴더의 자식으로 생성 (70%)과 그 하위 폴더(30%) - int directChildCount = (int) (count * 0.7); - - // 루트 폴더의 직접 자식 폴더 생성 - for (int i = 0; i < directChildCount; i++) { - Folder subFolder = RandomFolderGenerator.createRandomSubFolder(rootFolder, userId); - folders.add(folderRepository.save(subFolder)); - } - - // 생성된 폴더들의 자식 폴더 생성 - int deepChildCount = count - directChildCount; - for (int i = 0; i < deepChildCount; i++) { - if (!folders.isEmpty()) { - Folder parentFolder = folders.get(random.nextInt(folders.size())); - Folder subFolder = RandomFolderGenerator.createRandomSubFolder(parentFolder, userId); - folders.add(folderRepository.save(subFolder)); - } - } - - return folders; - } - - /** - * 문제 대량 생성 (이미지 포함) - */ - private List generateProblems(Long userId, List folders, int problemsPerFolder) { - List problems = new ArrayList<>(); - - for (Folder folder : folders) { - for (int i = 0; i < problemsPerFolder; i++) { - // 문제 생성 - Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folder, userId); - Problem savedProblem = problemRepository.save(problem); - problems.add(savedProblem); - - // 이미지 생성 (50% 확률로 1-4개 이미지) - if (random.nextBoolean()) { - int imageCount = random.nextInt(4) + 1; // 1-4개 - generateImagesForProblem(savedProblem); - } - } - } - - return problems; - } - - /** - * 문제에 대한 이미지 생성 - */ - private void generateImagesForProblem(Problem problem) { - List types = List.of( - ProblemImageType.PROBLEM_IMAGE, - ProblemImageType.ANSWER_IMAGE, - ProblemImageType.SOLVE_IMAGE, - ProblemImageType.PROCESS_IMAGE - ); - - // 1-4개의 랜덤 이미지 생성 - int imageCount = random.nextInt(4) + 1; - for (int i = 0; i < imageCount && i < types.size(); i++) { - ProblemImageData imageData = RandomProblemGenerator.createRandomProblemImageData( - problem.getId(), - types.get(i) - ); - imageData.updateProblem(problem); - problemImageDataRepository.save(imageData); - } - } - - /** - * 복습노트 대량 생성 - */ - private List generatePracticeNotes(Long userId, List problems, int count) { - List practiceNotes = new ArrayList<>(); - - // 문제가 없으면 복습노트를 생성할 수 없음 - if (problems.isEmpty()) { - log.warn("문제가 없어서 복습노트를 생성할 수 없습니다."); - return practiceNotes; - } - - for (int i = 0; i < count; i++) { - // 랜덤하게 20-30개 문제 선택 (문제가 적으면 그만큼만 선택) - int targetProblemCount = random.nextInt(10) + 20; // 5-15개 - int actualProblemCount = Math.min(targetProblemCount, problems.size()); - - // 중복 없이 문제 선택 - List problemIds = new ArrayList<>(); - List shuffledProblems = new ArrayList<>(problems); - java.util.Collections.shuffle(shuffledProblems, random); - - for (int j = 0; j < actualProblemCount; j++) { - problemIds.add(shuffledProblems.get(j).getId()); - } - - RandomPracticeNoteGenerator.createRandomPracticeNoteWithProblems(problemIds, userId); - // 복습노트 생성 - PracticeNote practiceNote = RandomPracticeNoteGenerator.createRandomPracticeNoteWithProblems( - problemIds, - userId - ); - practiceNoteRepository.save(practiceNote); - practiceNotes.add(practiceNote); - - for(Long problemId : problemIds) { - problemRepository.findById(problemId).ifPresent( problem -> { - ProblemPracticeNoteMapping mapping = ProblemPracticeNoteMapping.from(); - mapping.addMappingToProblemAndPractice(problem, practiceNote); - problemPracticeNoteMappingRepository.save(mapping); - } - ); - } - } - - return practiceNotes; - } - - /** - * 전체 이미지 개수 계산 - */ - private int countImages(List problems) { - return problems.stream() - .mapToInt(p -> p.getProblemImageDataList() != null ? p.getProblemImageDataList().size() : 0) - .sum(); - } - - /** - * 대량 데이터 생성 결과 - */ - public record BulkDataResult( - int folderCount, - int problemCount, - int imageCount, - int practiceNoteCount, - long durationMs - ) { - @Override - public String toString() { - return String.format( - "생성 결과: 폴더 %d개, 문제 %d개, 이미지 %d개, 복습노트 %d개 (소요시간: %dms)", - folderCount, problemCount, imageCount, practiceNoteCount, durationMs - ); - } - } - - /** - * 특정 루트 폴더 하위에 데이터를 대량으로 생성 - * @param userId 대상 유저 ID - * @param rootFolderId 루트 폴더 ID (모든 폴더가 이 폴더 하위에 생성됨) - * @param folderCount 생성할 폴더 개수 - * @param problemsPerFolder 폴더당 문제 개수 - * @param practiceNoteCount 생성할 복습노트 개수 - * @return 생성 결과 통계 - */ - @Transactional - public BulkDataResult generateBulkDataUnderRootFolder( - Long userId, - Long rootFolderId, - int folderCount, - int problemsPerFolder, - int practiceNoteCount) { - - log.info("=== 대량 데이터 생성 시작 (루트 폴더 지정) ==="); - log.info("유저 ID: {}, 루트 폴더 ID: {}", userId, rootFolderId); - log.info("폴더 개수: {}, 폴더당 문제 개수: {}, 복습노트 개수: {}", - folderCount, problemsPerFolder, practiceNoteCount); - - long startTime = System.currentTimeMillis(); - - // 1. 루트 폴더 하위에 폴더 생성 - List folders = generateFolders(userId, folderCount); - log.info("폴더 {} 개 생성 완료 (루트 폴더 {} 하위)", folders.size(), rootFolderId); - - // 2. 문제 생성 (이미지 포함) - List problems = generateProblems(userId, folders, problemsPerFolder); - log.info("문제 {} 개 생성 완료", problems.size()); - - // 3. 복습노트 생성 - List practiceNotes = generatePracticeNotes(userId, problems, practiceNoteCount); - log.info("복습노트 {} 개 생성 완료", practiceNotes.size()); - - long endTime = System.currentTimeMillis(); - long duration = endTime - startTime; - - BulkDataResult result = new BulkDataResult( - folders.size(), - problems.size(), - countImages(problems), - practiceNotes.size(), - duration - ); - - log.info("=== 대량 데이터 생성 완료 ==="); - log.info("소요 시간: {} ms", duration); - log.info(result.toString()); - - return result; - } - - /** - * 빠른 대량 생성 (기본값 사용) - * - 100개 폴더 - * - 폴더당 50개 문제 - * - 50개 복습노트 - */ - @Transactional - public BulkDataResult generateDefaultBulkData(Long userId) { - return generateBulkData(userId, 100, 50, 50); - } - - /** - * 특정 루트 폴더 하위에 기본값으로 대량 생성 - * - 100개 폴더 - * - 폴더당 50개 문제 - * - 50개 복습노트 - */ - @Transactional - public BulkDataResult generateDefaultBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 100, 50, 50); - } - - /** - * 소량 테스트 데이터 생성 - * - 10개 폴더 - * - 폴더당 10개 문제 - * - 10개 복습노트 - */ - @Transactional - public BulkDataResult generateSmallBulkData(Long userId) { - return generateBulkData(userId, 10, 10, 10); - } - - /** - * 특정 루트 폴더 하위에 소량 테스트 데이터 생성 - * - 10개 폴더 - * - 폴더당 10개 문제 - * - 10개 복습노트 - */ - @Transactional - public BulkDataResult generateSmallBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 10, 10, 10); - } - - /** - * 대량 테스트 데이터 생성 (성능 테스트용) - * - 500개 폴더 - * - 폴더당 100개 문제 - * - 200개 복습노트 - */ - @Transactional - public BulkDataResult generateLargeBulkData(Long userId) { - return generateBulkData(userId, 500, 100, 200); - } - - /** - * 특정 루트 폴더 하위에 대량 테스트 데이터 생성 (성능 테스트용) - * - 500개 폴더 - * - 폴더당 100개 문제 - * - 200개 복습노트 - */ - @Transactional - public BulkDataResult generateLargeBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 500, 100, 200); - } -} \ No newline at end of file From 3ac3234c53436dc5f93f9ed3932635a93aadc861 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:08:05 +0900 Subject: [PATCH 016/130] =?UTF-8?q?[Fix]=20=EB=A7=8C=EB=A3=8C=20=EC=95=A1?= =?UTF-8?q?=EC=84=B8=EC=8A=A4=20=ED=86=A0=ED=81=B0=EC=9D=B4=201005=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=201007=20=EB=A1=9C=20=EB=82=98=EA=B0=80?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtTokenizer.validateAccessToken 이 ExpiredJwtException 을 포함한 모든 예외를 ApplicationException 으로 바꿔 던지고 있어, JwtTokenFilter 의 catch(ExpiredJwtException) 은 영영 실행되지 않는 죽은 코드였다. 만료 토큰은 그 아래 catch(Exception) 으로 떨어져 1007 이 됐다 - 프론트(HttpService.dart:292)는 1005 일 때만 토큰 갱신을 재시도하고, 1000~1999 의 다른 코드는 인증 실패로 보고 강제 로그아웃시킨다. 즉 리프레시 토큰이 멀쩡한 사용자가 로그아웃됐다 - validateAccessToken 을 validateRefreshToken 과 같은 형태로 맞춰 만료(1005)와 검증 실패(1009)를 분리. 만료가 아닌 실패까지 1005 로 뭉뚱그리면 갱신할 이유가 없는 토큰에도 프론트가 갱신을 건다 - JwtTokenFilter 는 ApplicationException 을 잡아 errorCase 를 그대로 전달 - 로컬 실측: 만료 401/1005, 위조 401/1009, 토큰 없음 401/1007, 1005 수신 후 refresh → 재요청 200 까지 확인 - 프론트 배포 없이 백엔드만으로 해결된다 - JwtTokenizerValidationTest 3건 추가 --- .../backend/auth/service/JwtTokenizer.java | 7 +- .../backend/common/auth/JwtTokenFilter.java | 9 ++- .../service/JwtTokenizerValidationTest.java | 80 +++++++++++++++++++ 3 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java index ed68e51c..8088a960 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java @@ -78,9 +78,14 @@ public String createRefreshToken(String subject, Map claims) { public void validateAccessToken(String token) { try{ Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token); + } catch (ExpiredJwtException e) { + log.warn("엑세스 토큰 만료: {}", e.getMessage()); + throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); } catch (Exception e) { + // 만료가 아닌 실패(서명 불일치·형식 오류)까지 ACCESS_TOKEN_EXPIRED 로 뭉뚱그리면 + // 프론트가 갱신을 시도할 이유가 없는 토큰에도 갱신을 건다. log.warn("엑세스 토큰 검증 실패: {}", e.getMessage()); - throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); + throw new ApplicationException(AuthErrorCase.INVALID_ACCESS_TOKEN); } } diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java index ad7ed3cc..aa30a625 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java @@ -3,9 +3,9 @@ import com.aisip.OnO.backend.auth.entity.Authority; import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.service.JwtTokenizer; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.util.redis.RedisTokenService; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -89,8 +89,11 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.getContext().setAuthentication(authentication); MDC.put("userId", String.valueOf(userId)); MDC.put("authority", authority.name()); - } catch (ExpiredJwtException e) { - request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); + } catch (ApplicationException e) { + // JwtTokenizer 가 ExpiredJwtException 을 ApplicationException 으로 바꿔 던지므로 + // 여기서 ExpiredJwtException 을 잡으면 영영 걸리지 않는다(과거 만료 토큰이 1005 대신 + // 1007 로 나가 프론트의 토큰 갱신 대신 강제 로그아웃이 발동하던 원인). + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, e.getErrorCase()); } catch (Exception e) { request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.AUTHENTICATION_FAILED); } diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java new file mode 100644 index 00000000..038da31f --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java @@ -0,0 +1,80 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 만료된 액세스 토큰이 어떤 ErrorCase 로 나가는지 고정한다. + * + * 프론트(HttpService.dart)는 errorCode 1005 일 때만 토큰 갱신을 재시도하고, + * 1000~1999 의 다른 코드는 인증 실패로 보고 강제 로그아웃시킨다. + * 그래서 "만료 → 1005" 계약이 깨지면 리프레시 토큰이 멀쩡한 사용자가 로그아웃된다. + */ +class JwtTokenizerValidationTest { + + private static final String ACCESS_SECRET = + "dGVzdC1hY2Nlc3MtdG9rZW4tc2VjcmV0LXRlc3QtYWNjZXNzLXRva2VuLXNlY3JldC0zMmJ5dGVz"; + private static final String REFRESH_SECRET = + "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + + private JwtTokenizer expiringTokenizer; + private JwtTokenizer validTokenizer; + + @BeforeEach + void setUp() { + // 액세스 토큰 유효기간을 음수로 줘서 발급 즉시 만료된 토큰을 만든다 + expiringTokenizer = new JwtTokenizer(-1_000L, 60_480_000_000L, ACCESS_SECRET, REFRESH_SECRET); + validTokenizer = new JwtTokenizer(1_800_000L, 60_480_000_000L, ACCESS_SECRET, REFRESH_SECRET); + } + + private String issueAccessToken(JwtTokenizer tokenizer) { + return tokenizer + .createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER.name())) + .substring(JwtTokenizer.BEARER_PREFIX.length()) + .trim(); + } + + @Test + @DisplayName("만료된 액세스 토큰은 ACCESS_TOKEN_EXPIRED(1005) 로 판정된다") + void expiredAccessTokenMapsTo1005() { + String expired = issueAccessToken(expiringTokenizer); + + assertThatThrownBy(() -> validTokenizer.validateAccessToken(expired)) + .isInstanceOf(ApplicationException.class) + .satisfies(e -> assertThat(((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED)); + + assertThat(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode()).isEqualTo(1005); + } + + @Test + @DisplayName("서명이 틀린 액세스 토큰은 만료가 아니라 INVALID_ACCESS_TOKEN(1009) 로 판정된다") + void tamperedAccessTokenMapsTo1009() { + String token = issueAccessToken(validTokenizer); + // 서명부 한 글자를 바꿔 검증에 실패하게 만든다 + String tampered = token.substring(0, token.length() - 1) + + (token.endsWith("A") ? "B" : "A"); + + assertThatThrownBy(() -> validTokenizer.validateAccessToken(tampered)) + .isInstanceOf(ApplicationException.class) + .satisfies(e -> assertThat(((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN)); + } + + @Test + @DisplayName("정상 액세스 토큰은 예외 없이 통과한다") + void validAccessTokenPasses() { + String token = issueAccessToken(validTokenizer); + + validTokenizer.validateAccessToken(token); + } +} From 5a173d102091d4257e482c828e7e4673c220a901 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:34:48 +0900 Subject: [PATCH 017/130] =?UTF-8?q?[Perf]=20=EB=AC=B8=EC=A0=9C=20=EC=BB=A4?= =?UTF-8?q?=EC=84=9C=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EC=9D=B8=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=95=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 커서 페이징과 컬렉션 fetch join 을 한 쿼리에 같이 쓰면 조인으로 행이 뻥튀기돼 Hibernate 가 SQL 에 LIMIT 을 걸지 못하고, 조건에 맞는 행을 전부 읽은 뒤 자바에서 잘라냈다 (실행 중 경고 HHH90003004 로 확인). size=20 을 보내도 폴더의 모든 문제와 이미지가 적재됐다 - 폴더/태그/제목 커서 조회 3개를 2단계로 분리: 1단계에서 id 만 limit 으로 뽑고, 2단계에서 그 id 로 fetch join 한다 - 폴더 조회는 14일간 1,171건으로 두 번째로 많이 불리는 API 라 데이터가 쌓일수록 악화되는 구조였다 - 회귀 테스트: 폴더에 5건이 있어도 size=2 요청 시 Problem 엔티티 3건만 적재되는지 검증 (쿼리 횟수는 예전에도 1번이라 지표가 되지 못한다) --- .../repository/ProblemRepositoryImpl.java | 83 +++++++++++-------- .../repository/ProblemRepositoryTest.java | 49 +++++++++++ 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java index 84232981..3be0913d 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryImpl.java @@ -7,6 +7,7 @@ import com.aisip.OnO.backend.problem.entity.QProblem; import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.DateExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -215,66 +216,80 @@ public List findAllProblemsByPracticeId(Long practiceId) { .fetch(); } - @Override - public List findProblemsByFolderWithCursor(Long folderId, Long cursor, int size) { - var query = queryFactory - .selectFrom(problem) - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() - .where(problem.folder.id.eq(folderId)); + /** + * 커서 페이징 + 컬렉션 fetch join 을 한 쿼리에 같이 쓰면 조인으로 행이 뻥튀기돼 + * Hibernate 가 SQL 에 LIMIT 을 걸지 못하고 조건에 맞는 행을 전부 메모리로 읽은 뒤 + * 자바에서 잘라낸다 (경고 HHH90003004). size 를 보내도 폴더의 모든 문제가 올라왔다. + * + * 그래서 1단계에서 id 만 limit 으로 뽑고, 2단계에서 그 id 로 fetch join 한다. + */ + private BooleanExpression cursorAfter(Long cursor) { + return cursor == null ? null : problem.id.gt(cursor); + } - // 커서가 있으면 해당 ID 이후부터 조회 - if (cursor != null) { - query.where(problem.id.gt(cursor)); + private List fetchProblemsWithImages(List problemIds) { + if (problemIds.isEmpty()) { + return List.of(); } - return query + return queryFactory + .selectDistinct(problem) + .from(problem) + .leftJoin(problem.folder).fetchJoin() + .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() + .where(problem.id.in(problemIds)) + .orderBy(problem.id.asc()) + .fetch(); + } + + @Override + public List findProblemsByFolderWithCursor(Long folderId, Long cursor, int size) { + List problemIds = queryFactory + .select(problem.id) + .from(problem) + .where( + problem.folder.id.eq(folderId), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) // hasNext 판단을 위해 +1개 조회 .fetch(); + + return fetchProblemsWithImages(problemIds); } @Override public List findProblemsByTagWithCursor(Long tagId, Long userId, Long cursor, int size) { - var query = queryFactory - .selectDistinct(problem) + List problemIds = queryFactory + .selectDistinct(problem.id) .from(problem) .join(problemTagMapping).on(problemTagMapping.problem.id.eq(problem.id)) - .leftJoin(problem.folder).fetchJoin() - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() .where( problemTagMapping.tag.id.eq(tagId), - problem.userId.eq(userId) - ); - - if (cursor != null) { - query.where(problem.id.gt(cursor)); - } - - return query + problem.userId.eq(userId), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) .fetch(); + + return fetchProblemsWithImages(problemIds); } @Override public List findProblemsByTitleWithCursor(String titleQuery, Long userId, Long cursor, int size) { - var query = queryFactory - .selectDistinct(problem) + List problemIds = queryFactory + .select(problem.id) .from(problem) - .leftJoin(problem.folder).fetchJoin() - .leftJoin(problem.problemImageDataList, problemImageData).fetchJoin() .where( problem.userId.eq(userId), - problem.reference.containsIgnoreCase(titleQuery) - ); - - if (cursor != null) { - query.where(problem.id.gt(cursor)); - } - - return query + problem.reference.containsIgnoreCase(titleQuery), + cursorAfter(cursor) + ) .orderBy(problem.id.asc()) .limit(size + 1) .fetch(); + + return fetchProblemsWithImages(problemIds); } } diff --git a/src/test/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryTest.java b/src/test/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryTest.java index 185d50cb..82df36a7 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/repository/ProblemRepositoryTest.java @@ -18,6 +18,8 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; import jakarta.persistence.EntityManager; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -158,4 +160,51 @@ void findAllByPracticeId_success() { assertThat(problems).hasSize(5); assertThat(problems.get(0).getProblemImageDataList()).hasSize(3); // fetch join 확인 } + + /** + * 컬렉션 fetch join + limit 을 한 쿼리로 쓰면 Hibernate 가 SQL 에 LIMIT 을 걸지 못하고 + * 조건에 맞는 행을 전부 읽은 뒤 메모리에서 잘라냈다 (HHH90003004). + * 쿼리 "횟수"는 그때도 1번이라 지표가 못 된다. 실제로 몇 건을 읽었는지로 고정한다. + */ + @Test + @DisplayName("폴더 커서 조회 - 폴더에 5건이 있어도 요청한 size 만큼만 읽는다") + void findProblemsByFolderWithCursor_readsOnlyRequestedRows() { + Statistics statistics = em.getEntityManagerFactory().unwrap(SessionFactory.class).getStatistics(); + statistics.setStatisticsEnabled(true); + statistics.clear(); + + List problems = problemRepository.findProblemsByFolderWithCursor(savedFolder.getId(), null, 2); + + // hasNext 판단용 +1개 + assertThat(problems).hasSize(3); + assertThat(problems.get(0).getProblemImageDataList()).hasSize(3); + + // 폴더에는 5건이 있다. 전부 읽어 메모리에서 자르던 시절에는 5건이 적재됐다 + long loadedProblems = statistics.getEntityStatistics(Problem.class.getName()).getLoadCount(); + assertThat(loadedProblems).isEqualTo(3); + } + + @Test + @DisplayName("폴더 커서 조회 - 커서 이후 문제만 반환한다") + void findProblemsByFolderWithCursor_respectsCursor() { + List firstPage = problemRepository.findProblemsByFolderWithCursor(savedFolder.getId(), null, 2); + Long cursor = firstPage.get(1).getId(); + + List secondPage = problemRepository.findProblemsByFolderWithCursor(savedFolder.getId(), cursor, 2); + + assertThat(secondPage).isNotEmpty(); + assertThat(secondPage).allSatisfy(problem -> + assertThat(problem.getId()).isGreaterThan(cursor)); + assertThat(secondPage.get(0).getProblemImageDataList()).hasSize(3); + } + + @Test + @DisplayName("제목 커서 조회 - size 만큼만 반환하고 이미지가 붙는다") + void findProblemsByTitleWithCursor_limits() { + List problems = + problemRepository.findProblemsByTitleWithCursor("reference", savedUser.getId(), null, 2); + + assertThat(problems).hasSize(3); + assertThat(problems.get(0).getProblemImageDataList()).hasSize(3); + } } \ No newline at end of file From 094f61d603fbc734b89ee573d7b9fa3cae51307d Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:35:06 +0900 Subject: [PATCH 018/130] =?UTF-8?q?[Fix]=20=EC=95=8C=EB=A6=BC=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=20=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=B9=A0?= =?UTF-8?q?=EC=A0=B8=20=EA=BB=90=EB=8A=94=EB=8D=B0=EB=8F=84=20=EC=BC=9C?= =?UTF-8?q?=EC=A0=B8=20=EB=B3=B4=EC=9D=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 토글은 PATCH /api/users/notification-settings 로 정상 저장되지만 GET /api/users 응답에 notificationEnabled 가 없었다 - 프론트(UserInfoModel.dart)는 값이 없으면 true 로 복원해, 앱을 다시 켜면 스위치가 켜져 보였다 사용자가 "안 꺼졌네" 하고 다시 누르면 실제로 알림이 켜진다 - 필드를 추가하는 변경이라 하위 호환을 깨지 않고, 프론트는 이미 이 값을 읽고 있어 앱 배포도 필요 없다 - UserResponseDtoTest: 기본값 true, 끈 뒤 false 검증 - 만렙 게이지 어서션도 바로잡았다. 코드는 임계값 600 을 주는데 테스트가 0 을 기대하고 있었다. 현재치와 임계값이 같아야 게이지가 가득 차고, 0 을 주면 프론트가 0 으로 나눈다 - UserControllerTest: 컨트롤러가 touchAndFindUser 로 바뀐 뒤에도 findUser 를 스텁해 응답이 null 이던 것을 수정 --- .../OnO/backend/user/dto/UserResponseDto.java | 4 ++++ .../user/controller/UserControllerTest.java | 4 +++- .../backend/user/dto/UserResponseDtoTest.java | 23 ++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index acdee406..7d123d41 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -24,6 +24,9 @@ public record UserResponseDto ( Long totalStudyLevel, Long totalStudyCurrentPoint, Long totalStudyNextLevelThreshold, + // 응답에 없으면 프론트(UserInfoModel.dart)가 기본값 true 로 복원해, + // 알림을 꺼도 앱을 다시 켜면 스위치가 켜진 것처럼 보였다 + boolean notificationEnabled, LocalDateTime createdAt, LocalDateTime updatedAt ) { @@ -49,6 +52,7 @@ public static UserResponseDto from(@NotNull User user) { .totalStudyLevel(getResponseLevel(missionStatus.getTotalStudyLevel())) .totalStudyCurrentPoint(getTotalStudyResponsePoint(missionStatus.getTotalStudyLevel(), missionStatus.getTotalStudyPoint())) .totalStudyNextLevelThreshold(getTotalStudyNextLevelThreshold(missionStatus)) + .notificationEnabled(user.isNotificationEnabled()) .createdAt(user.getCreatedAt()) .updatedAt(user.getUpdatedAt()) .build(); diff --git a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java index 4c540f31..c3bdef5c 100644 --- a/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java @@ -62,6 +62,7 @@ void setUp() { 1L, 0L, 40L, + true, // notificationEnabled LocalDateTime.now(), // createdAt LocalDateTime.now() // updatedAt, @@ -77,7 +78,8 @@ void tearDown() { @WithMockCustomUser() void getUserInfo() throws Exception { // Given - given(userService.findUser(1L)).willReturn(mockUserResponse); + // 컨트롤러가 touchAndFindUser 로 바뀐 뒤에도 findUser 를 스텁하고 있어 null 이 응답됐다 + given(userService.touchAndFindUser(1L)).willReturn(mockUserResponse); // When & Then mockMvc.perform(get("/api/users") diff --git a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java index 49d8ca32..bdb7ccb4 100644 --- a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java @@ -37,6 +37,27 @@ void fromCapsMissionStatusOverMaxLevel() { assertThat(response.notePracticePoint()).isEqualTo(150L); assertThat(response.totalStudyLevel()).isEqualTo(15L); assertThat(response.totalStudyCurrentPoint()).isEqualTo(600L); - assertThat(response.totalStudyNextLevelThreshold()).isZero(); + // 만렙에서는 현재치와 임계값이 같아야 게이지가 가득 찬다. + // 0 을 내려주면 프론트가 현재치/임계값을 계산할 때 0 으로 나누게 된다 + assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(600L); + } + + @Test + @DisplayName("알림 설정 값이 응답에 그대로 실린다") + void fromIncludesNotificationEnabled() { + User user = User.from(new UserRegisterDto( + "test@example.com", + "testUser", + "testIdentifier", + "MEMBER", + null + )); + + // 기본값 + assertThat(UserResponseDto.from(user).notificationEnabled()).isTrue(); + + // 꺼둔 상태가 응답에 반영되지 않으면 프론트가 true 로 복원해 스위치가 다시 켜져 보인다 + user.updateNotificationEnabled(false); + assertThat(UserResponseDto.from(user).notificationEnabled()).isFalse(); } } From 26b9a65976caec6a9ff1c1fb4c531da7f84a7c99 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:35:06 +0900 Subject: [PATCH 019/130] =?UTF-8?q?[Fix]=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=95=9C=EB=8F=84=20=EC=B4=88?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=9C=20AI=20=EB=B6=84=EC=84=9D=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EA=B0=80=20=EB=9C=A8=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RateLimitAspect 가 한도 종류와 무관하게 ANALYSIS_RATE_LIMIT_EXCEEDED 를 던져서, presigned URL 한도(하루 200회)를 넘긴 사용자에게 "AI 분석 일일 요청 횟수를 초과했습니다" 가 표시됐다 - RateLimitScope 를 두어 한도별 ErrorCase 를 고르게 하고, @RateLimit 에 기본값 없이 요구한다 - FileUploadErrorCase.UPLOAD_RATE_LIMIT_EXCEEDED(429, 2005) 추가 - RateLimitScopeTest: 업로드 한도 문구에 "AI" 가 섞이지 않는지 포함해 검증 --- .../backend/common/ratelimit/RateLimit.java | 5 ++- .../common/ratelimit/RateLimitAspect.java | 3 +- .../common/ratelimit/RateLimitScope.java | 24 ++++++++++++++ .../controller/FileUploadController.java | 3 +- .../exception/FileUploadErrorCase.java | 4 ++- .../common/ratelimit/RateLimitScopeTest.java | 32 +++++++++++++++++++ 6 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScopeTest.java diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java index 2988cdce..8000c2fb 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimit.java @@ -10,4 +10,7 @@ public @interface RateLimit { String key(); int limitPerDay() default 20; -} \ No newline at end of file + + /** 한도 초과 시 어떤 오류로 알릴지. 기본값을 두지 않아 붙일 때 반드시 고르게 한다. */ + RateLimitScope scope(); +} diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java index d4612dea..a4d634c4 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java @@ -1,7 +1,6 @@ package com.aisip.OnO.backend.common.ratelimit; import com.aisip.OnO.backend.common.exception.ApplicationException; -import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import lombok.RequiredArgsConstructor; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; @@ -19,7 +18,7 @@ public class RateLimitAspect { public void checkRateLimit(RateLimit rateLimit) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); if (!rateLimitService.tryConsume(rateLimit.key(), userId, rateLimit.limitPerDay())) { - throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + throw new ApplicationException(rateLimit.scope().getErrorCase()); } } } diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java new file mode 100644 index 00000000..70ebb385 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScope.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 한도를 넘겼을 때 어떤 오류로 알릴지 고른다. + * + * 예전에는 RateLimitAspect 가 한도 종류와 무관하게 ANALYSIS_RATE_LIMIT_EXCEEDED 를 던져서, + * 이미지 업로드 한도를 넘긴 사용자에게 "AI 분석 일일 요청 횟수를 초과했습니다" 가 떴다. + */ +@Getter +@RequiredArgsConstructor +public enum RateLimitScope { + + FILE_UPLOAD(FileUploadErrorCase.UPLOAD_RATE_LIMIT_EXCEEDED), + + AI_ANALYSIS(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + + private final ErrorCase errorCase; +} diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java index 3d6fd507..959c9e46 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/controller/FileUploadController.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.util.fileupload.controller; import com.aisip.OnO.backend.common.ratelimit.RateLimit; +import com.aisip.OnO.backend.common.ratelimit.RateLimitScope; import com.aisip.OnO.backend.common.response.CommonResponse; import com.aisip.OnO.backend.util.fileupload.dto.PresignedUrlResponse; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; @@ -43,7 +44,7 @@ public CommonResponse> uploadMultipleImageFiles( // 앱이 S3에 직접 업로드할 수 있는 Presigned URL 발급 // count: 발급할 URL 수 (최대 20) // contentType: 업로드할 이미지 MIME 타입 (예: image/jpeg) - @RateLimit(key = "presigned_url", limitPerDay = 200) + @RateLimit(key = "presigned_url", limitPerDay = 200, scope = RateLimitScope.FILE_UPLOAD) @GetMapping("/presigned-urls") public CommonResponse> getPresignedUrls( @RequestParam(defaultValue = "1") int count, diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java index fea6a326..b3068522 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/exception/FileUploadErrorCase.java @@ -14,7 +14,9 @@ public enum FileUploadErrorCase implements ErrorCase { INVALID_IMAGE_FILE(400, 2003, "이미지 파일 형식이 올바르지 않습니다."), - FILE_SIZE_EXCEEDED(400, 2004, "파일 최대 용량을 초과했습니다."); + FILE_SIZE_EXCEEDED(400, 2004, "파일 최대 용량을 초과했습니다."), + + UPLOAD_RATE_LIMIT_EXCEEDED(429, 2005, "하루에 올릴 수 있는 이미지 개수를 초과했습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScopeTest.java b/src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScopeTest.java new file mode 100644 index 00000000..e2e59c7f --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitScopeTest.java @@ -0,0 +1,32 @@ +package com.aisip.OnO.backend.common.ratelimit; + +import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 한도 종류와 사용자에게 보이는 문구가 어긋나지 않도록 고정한다. + * 예전에는 이미지 업로드 한도를 넘겨도 "AI 분석 일일 요청 횟수를 초과했습니다" 가 떴다. + */ +class RateLimitScopeTest { + + @Test + @DisplayName("이미지 업로드 한도는 업로드 오류로 알린다") + void fileUploadScopeUsesUploadErrorCase() { + var errorCase = RateLimitScope.FILE_UPLOAD.getErrorCase(); + + assertThat(errorCase).isEqualTo(FileUploadErrorCase.UPLOAD_RATE_LIMIT_EXCEEDED); + assertThat(errorCase.getHttpStatusCode()).isEqualTo(429); + assertThat(errorCase.getMessage()).doesNotContain("AI"); + } + + @Test + @DisplayName("AI 분석 한도는 분석 오류로 알린다") + void aiAnalysisScopeUsesAnalysisErrorCase() { + assertThat(RateLimitScope.AI_ANALYSIS.getErrorCase()) + .isEqualTo(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); + } +} From 4ffd6c188706d7cb0e24df9b0719d4d422c944de Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:37:51 +0900 Subject: [PATCH 020/130] =?UTF-8?q?[Test]=20=EC=BD=94=EB=93=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9D=84=20=EB=AA=BB=20=EB=94=B0=EB=9D=BC=EA=B0=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=2010=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 깨진 16건 중 10건이 "코드가 바뀌었는데 테스트가 안 따라간" 경우였다. - ProblemServiceTest 4건: S3 삭제가 RabbitMQ 비동기(s3DeleteProducer)로 바뀌었는데 여전히 fileUploadService 직접 호출을 검증하고 있었다 - UserServiceTest: UserService 에 ProblemReviewReminderService 가 추가된 뒤 목이 없어 NPE. 목 추가와 함께 사용자 삭제 시 예약 알림도 취소되는지 검증을 넣었다 - PracticeNoteServiceTest 2건: 테스트 컨텍스트에 Quartz 스케줄러가 없어 SchedulerException 이 났다. 알림 스케줄링은 이 테스트의 관심사가 아니라 PracticeNotificationScheduler 를 목으로 대체 - LearningReportServiceAiTest: 실제 Redis 캐시에 값이 있으면 AI 호출 없이 캐시가 반환돼 실행 순서와 외부 Redis 상태에 좌우됐다. RedisSingleDataService 를 목으로 대체해 항상 캐시 미스 - FolderApiIntegrationTest: 루트 폴더 이름 변경이 막힌 뒤(ROOT_FOLDER_CANNOT_UPDATE)에도 루트를 수정하려 해 400 이 났다. 하위 폴더로 검증하도록 변경 --- .../integration/FolderApiIntegrationTest.java | 3 ++- .../service/LearningReportServiceAiTest.java | 6 ++++++ .../service/PracticeNoteServiceTest.java | 6 ++++++ .../problem/service/ProblemServiceTest.java | 21 ++++++++++--------- .../backend/user/service/UserServiceTest.java | 4 ++++ 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java index 968b7136..da2b07ed 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java @@ -358,7 +358,8 @@ public void createFolderTest() throws Exception { @DisplayName("updateFolder() api 테스트 - 이름 변경") public void updateFolderTest_FolderName() throws Exception { //given - Long folderId = folderList.get(0).getId(); + // 루트 폴더는 이름을 바꿀 수 없다(ROOT_FOLDER_CANNOT_UPDATE). 하위 폴더로 검증한다 + Long folderId = folderList.get(1).getId(); String folderName = "new FolderName"; FolderRegisterDto folderRegisterDto = new FolderRegisterDto( folderName, diff --git a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java index 88b5212f..9555c474 100644 --- a/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java +++ b/src/test/java/com/aisip/OnO/backend/learningreport/service/LearningReportServiceAiTest.java @@ -14,6 +14,7 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; import com.aisip.OnO.backend.util.ai.OpenAIClient; +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -59,6 +60,11 @@ class LearningReportServiceAiTest { @MockBean private OpenAIClient openAIClient; + // 캐시가 살아 있으면 AI 호출 없이 캐시 값이 반환돼 테스트가 실행 순서와 외부 Redis 상태에 좌우된다. + // 목으로 대체하면 getSingleData 가 null 을 반환해 항상 캐시 미스가 된다 + @MockBean + private RedisSingleDataService redisSingleDataService; + @Test @DisplayName("학습 리포트 조회 - AI 추천 결과가 recommendations에 반영된다") void getLearningReport_appliesAiRecommendations() { diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java index 415be5f0..8195608c 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteServiceTest.java @@ -30,6 +30,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import java.time.LocalDateTime; @@ -62,6 +63,11 @@ class PracticeNoteServiceTest { @Autowired private FolderRepository folderRepository; + // 테스트 컨텍스트에는 Quartz 스케줄러가 없어 알림 등록/삭제가 SchedulerException 으로 터졌다. + // 알림 스케줄링은 이 테스트의 관심사가 아니라 목으로 대체한다 + @MockBean + private PracticeNotificationScheduler practiceNotificationScheduler; + private final Long userId = 1L; private List problemList; diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java index 9bf33635..68fdf99a 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.ratelimit.RateLimitService; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; +import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; import com.aisip.OnO.backend.folder.entity.Folder; @@ -46,6 +47,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.*; import static org.springframework.test.util.ReflectionTestUtils.setField; @@ -83,6 +86,9 @@ class ProblemServiceTest { @MockBean private ProblemAnalysisProducer analysisProducer; + @MockBean + private S3DeleteProducer s3DeleteProducer; + private final Long userId = 1L; private List problemList; @@ -579,25 +585,21 @@ void deleteProblem_success() { Long problemId = problemList.get(0).getId(); // When - doNothing().when(fileUploadService).deleteImageFileFromS3(anyString()); problemService.deleteProblem(problemId, userId); - // Then - verify(fileUploadService, times(2)).deleteImageFileFromS3(anyString()); + // Then - S3 삭제는 RabbitMQ 로 비동기 전송된다 + verify(s3DeleteProducer, times(2)).sendDeleteMessage(anyString(), anyLong()); assertThat(problemRepository.findAll().size()).isEqualTo(problemList.size() - 1); } @Test @DisplayName("특정 유저의 모든 문제 삭제하기") void deleteProblems_userId() { - // when - doNothing().when(fileUploadService).deleteImageFileFromS3(anyString()); - // when problemService.deleteAllUserProblems(userId); // then - verify(fileUploadService, times(2 * problemList.size())).deleteImageFileFromS3(anyString()); + verify(s3DeleteProducer, times(2 * problemList.size())).sendDeleteMessage(anyString(), anyLong()); assertThat(problemRepository.findAll().size()).isEqualTo(0); } @@ -613,11 +615,10 @@ void deleteProblems_problemIdList() { } // when - doNothing().when(fileUploadService).deleteImageFileFromS3(anyString()); problemService.deleteProblemList(userId, problemIdList); // then - verify(fileUploadService, times(2 * problemIdList.size())).deleteImageFileFromS3(anyString()); + verify(s3DeleteProducer, times(2 * problemIdList.size())).sendDeleteMessage(anyString(), anyLong()); assertThat(problemRepository.findAll().size()).isEqualTo(problemList.size() - (long) deleteCount); } @@ -632,7 +633,7 @@ void deleteProblems_folderIdList() { problemService.deleteAllByFolderIds(userId, folderIdList); // then - verify(fileUploadService, times(2 * problemCount)).deleteImageFileFromS3(anyString()); + verify(s3DeleteProducer, times(2 * problemCount)).sendDeleteMessage(anyString(), anyLong()); assertThat(problemRepository.findAll().size()).isEqualTo(problemList.size() - (long) problemCount); } diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 96dae6a6..52278efb 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; @@ -46,6 +47,8 @@ class UserServiceTest { @Mock private PracticeNoteService practiceNoteService; @Mock + private ProblemReviewReminderService reminderService; + @Mock private StudyRoomSharedProblemCommentRepository sharedProblemCommentRepository; @Mock private StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; @@ -266,6 +269,7 @@ void deleteUserById() { verify(practiceNoteService, times(1)).deleteAllPracticesByUser(userId); verify(problemService, times(1)).deleteAllUserProblems(userId); verify(folderService, times(1)).deleteAllUserFolders(userId); + verify(reminderService, times(1)).cancelAllByUser(userId); verify(userRepository, times(1)).findById(userId); verify(userRepository, times(1)).deleteById(userId); verify(userRepository, times(2)).flush(); From 13a84ea565d95c721a655a1d02028dc0dc66fc0c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:38:14 +0900 Subject: [PATCH 021/130] =?UTF-8?q?[Chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=9E=99=20=EC=A6=9D=EB=9F=89=20=EB=B0=8F=20JaCoCo=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞선 커밋(388b9d1)에서 build.gradle 이 빠져, 성능 테스트만 삭제되고 정작 원인이던 힙 설정과 커버리지 설정이 반영되지 않았다. - maxHeapSize 3g: 기본 힙(512m)으로는 @SpringBootTest 컨텍스트가 쌓이며 OOM 이 나 워커 JVM 이 죽고 40개 클래스 중 3개(66건)만 돌고 빌드가 끝났다 - JaCoCo: QueryDSL Q 클래스와 부트 진입점 제외, 테스트가 실패해도 리포트가 나오도록 finalizedBy --- build.gradle | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/build.gradle b/build.gradle index 331b7e75..84299d0d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' + id 'jacoco' id 'org.springframework.boot' version '3.3.1' id 'io.spring.dependency-management' version '1.1.5' id "io.sentry.jvm.gradle" version "4.11.0" @@ -82,6 +83,29 @@ dependencies { tasks.named('test') { useJUnitPlatform() systemProperty 'spring.profiles.active', 'test' + // @SpringBootTest 클래스마다 컨텍스트가 캐시에 쌓여 기본 힙(512m)으로는 OOM 이 났다. + // 워커 JVM 이 죽으면서 40개 클래스 중 3개만 돌고 빌드가 끝나고 있었다. + maxHeapSize = '3g' + // 실패한 테스트가 있어도 커버리지 리포트는 나오도록 finalizedBy 사용 + finalizedBy tasks.named('jacocoTestReport') +} + +jacoco { + toolVersion = '0.8.12' +} + +tasks.named('jacocoTestReport') { + reports { + xml.required = true + html.required = true + } + // QueryDSL 이 만든 Q 클래스와 부트 진입점은 측정 대상이 아니다 + classDirectories.setFrom(files(classDirectories.files.collect { + fileTree(dir: it, exclude: [ + '**/Q*.class', + '**/BackendApplication.class', + ]) + })) } tasks.withType(JavaCompile) { From bb56a6762ca32d8eb2922fe651bf037f7234751e Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 15:48:05 +0900 Subject: [PATCH 022/130] =?UTF-8?q?[Test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B0=84=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=98=A4=EC=97=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20-=20ProblemServiceTest=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5개 테스트 클래스(@WithMockCustomUser 기본값 포함)가 userId 1L 을 공유해 같은 H2 DB 에서 서로의 폴더/문제를 보고 있었다. 단독 실행하면 통과하고 전체 실행에서만 깨지는 실패들의 원인이었다 - ProblemServiceTest 가 매 테스트마다 자기 유저를 만들어 쓰도록 변경. "다른 유저" 검증용 하드코딩 2L, 폴더 하드코딩 1L 도 픽스처 기준으로 교체 - 호출부가 주석 처리된 채 검증만 남아 있던 죽은 테스트 2건 제거 (registerProblemImageData / updateProblemImageData 는 서비스에서 사라진 메서드다) - findAllProblems 가 최신순 정렬로 바뀐 뒤에도 테스트는 오름차순으로 비교하고 있었다 FolderServiceTest / FolderRepositoryTest 는 같은 방식으로 격리하면 다른 클래스가 남긴 데이터를 전제로 쓴 단언들이 드러나(예: 자기 픽스처는 5개인데 6개를 기대) 클래스를 다시 써야 해서 이번에는 건드리지 않았다. 전체 실패 16건 → 3건. --- .../problem/service/ProblemServiceTest.java | 81 +++++-------------- 1 file changed, 18 insertions(+), 63 deletions(-) diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java index 68fdf99a..fd094da2 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java @@ -29,6 +29,7 @@ import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.RandomUserGenerator; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -40,6 +41,7 @@ import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -89,13 +91,18 @@ class ProblemServiceTest { @MockBean private S3DeleteProducer s3DeleteProducer; - private final Long userId = 1L; + // 여러 테스트 클래스가 userId 1L 을 공유해 같은 H2 DB 에서 서로의 폴더/문제를 보고 있었다. + // 테스트마다 유저를 새로 만들어 서로의 데이터가 보이지 않게 한다 + private Long userId; + private Long otherUserId; private List problemList; private List folderList; @BeforeEach void setUp() { + userId = userRepository.save(RandomUserGenerator.createRandomUser()).getId(); + otherUserId = userRepository.save(RandomUserGenerator.createRandomUser()).getId(); problemList = new ArrayList<>(); folderList = new ArrayList<>(); @@ -244,7 +251,7 @@ void findFolderProblemList() { void findFolderProblemList_OtherUserFolder() { Folder otherUserFolder = folderRepository.save(Folder.from( new FolderRegisterDto("other folder", null, null), - 2L + otherUserId )); assertThatThrownBy(() -> problemService.findFolderProblemList(otherUserFolder.getId(), userId)) @@ -261,9 +268,13 @@ void findAllProblems() { List problemResponseDtoList = problemService.findAllProblems(); //then + // findAllProblems 는 최신순(createdAt desc) 으로 정렬된다 + List expectedOrder = new ArrayList<>(problemList); + Collections.reverse(expectedOrder); + assertThat(problemResponseDtoList.size()).isEqualTo(problemList.size()); for(int i = 0; i optionalProblem = problemRepository.findProblemWithImageData(problemId); - assertThat(optionalProblem.isPresent()).isTrue(); - - Problem problem = optionalProblem.get(); - int imageDataSize = problem.getProblemImageDataList().size(); - assertThat(imageDataSize).isEqualTo(problemList.get(0).getProblemImageDataList().size() + 1); - assertThat(problem.getProblemImageDataList().get(imageDataSize - 1).getImageUrl()).isEqualTo(imageUrl); - assertThat(problem.getProblemImageDataList().get(imageDataSize - 1).getProblemImageType()).isEqualTo(ProblemImageType.SOLVE_IMAGE); - } - @Test @DisplayName("AI 분석 요청 제한 초과 시 예외 없이 상태 저장") void analysisProblem_rateLimitExceeded() { @@ -502,7 +486,7 @@ void updateProblemInfo() { problemId, updateMemo, updateReference, - 1L, + folderList.get(0).getId(), LocalDateTime.now() ); //when @@ -549,35 +533,6 @@ void updateProblemFolder() { } } - @Test - @DisplayName("문제 이미지 데이터 수정") - void updateProblemImageData() { - // Given - Long problemId = problemList.get(0).getId(); - - ProblemRegisterDto updateDto = new ProblemRegisterDto( - problemId, - "update memo", - "update reference", - 2L, - LocalDateTime.now() - ); - - //when - //problemService.updateProblemImageData(updateDto, userId); - - //then - Optional optionalProblem = problemRepository.findProblemWithImageData(problemId); - if (optionalProblem.isEmpty()) { - assertThat(0).isEqualTo(1); - } else { - Problem problem = optionalProblem.get(); - assertThat(problem.getProblemImageDataList().size()).isEqualTo(2); - assertThat(problem.getProblemImageDataList().get(0).getImageUrl()).isEqualTo("imageUrl1 update"); - assertThat(problem.getProblemImageDataList().get(1).getImageUrl()).isEqualTo("imageUrl2 update"); - } - } - @Test @DisplayName("특정 문제 삭제 - 정상 케이스") void deleteProblem_success() { @@ -682,11 +637,11 @@ void deleteProblemImageData_OtherUserProblem() { private Problem createOtherUserProblem() { Folder otherUserFolder = folderRepository.save(Folder.from( new FolderRegisterDto("other folder", null, null), - 2L + otherUserId )); Problem otherUserProblem = Problem.from( new ProblemRegisterDto(null, "memo", "reference", otherUserFolder.getId(), LocalDateTime.now()), - 2L + otherUserId ); otherUserProblem.updateFolder(otherUserFolder); return problemRepository.save(otherUserProblem); From 1b1f840ff442048bd921f55ecb16de0d1a741498 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 16:08:17 +0900 Subject: [PATCH 023/130] =?UTF-8?q?[Docs]=20Discord=20webhook=20consumer?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "RabbitMQ 가 재시도하고 최종 실패는 DLQ 로 간다"고 적었는데 사실과 다르다. listener 재시도 설정이 없어 예외를 던지면 백오프 없이 무한 재큐되고, DLQ 로는 큐 TTL(5분) 만료로만 빠진다 (이슈 #229) - 로그 레벨을 warn 으로 내린 판단 자체는 유지한다. 이슈 #229 기준 48시간 ERROR 로그 197,694건 중 99.7%가 이 경로였고, 그 폭주 자체가 피해의 일부였다 --- .../rabbitmq/consumer/DiscordWebhookConsumer.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java index a97e7419..0048ae6d 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/DiscordWebhookConsumer.java @@ -38,10 +38,13 @@ public void handleWebhookMessage(DiscordWebhookMessage message) { restTemplate.postForEntity(webhookUrl, new HttpEntity<>(payload, headers), String.class); log.info("Discord webhook 전송 완료: {}", payload.embeds().get(0).title()); } catch (Exception e) { - // 일시적 실패(read timeout 등)는 RabbitMQ 가 재시도하고, 최종 실패는 DLQ 에서 error 로 남는다. - // 여기서 error 로 올리면 재시도로 성공한 건까지 Sentry 에 쌓인다 (Sentry JAVA-SPRING-BOOT-5C). - // 예외 메시지에 웹훅 URL(토큰 포함)이 그대로 들어 있어 메시지 대신 예외 타입만 남긴다. - log.warn("Discord webhook 전송 실패, 재시도 예정 - exceptionType: {}", e.getClass().getSimpleName()); + // 일시적 실패(read timeout 등)까지 error 로 올리면 재시도로 성공한 건도 Sentry 에 쌓인다 + // (Sentry JAVA-SPRING-BOOT-5C). 예외 메시지에는 웹훅 URL(토큰 포함)이 그대로 들어 있어 + // 메시지 대신 예외 타입만 남긴다. + // + // 주의: 현재 listener 재시도 설정이 없어 예외를 던지면 백오프 없이 무한 재큐된다. + // DLQ 로는 큐 TTL(5분) 만료로만 빠진다. 이 구조 자체는 이슈 #229 에서 따로 다룬다 + log.warn("Discord webhook 전송 실패 - exceptionType: {}", e.getClass().getSimpleName()); throw new RuntimeException("Discord webhook 전송 실패", e); } } From 3103461b7ae9c95255c2b20637f4adb22f81addf Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 16:40:14 +0900 Subject: [PATCH 024/130] =?UTF-8?q?[Test]=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EA=B8=B0=EB=B0=98=EC=9D=84=20Testcontaine?= =?UTF-8?q?rs=20MySQL=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 스위트는 274개 중 74개만 돌고 OutOfMemoryError로 중단됐고, 프로덕션에서 나는 에러를 구조적으로 재현할 수 없는 상태였다. - H2(MODE=MySQL) 제거, 프로덕션과 동일한 MySQL 8 컨테이너에서 실행 H2에서는 컬럼 길이 초과, 유니크 인덱스 충돌, 콜레이션 차이가 재현되지 않았다 - Redis, RabbitMQ도 컨테이너로 전환해 로컬 도커 상태에 의존하지 않도록 함 - IntegrationTestSupport 도입: 통합 테스트가 동일한 애노테이션 조합을 쓰게 해 30개 가까이 뜨던 스프링 컨텍스트를 하나로 수렴시킴. OOM의 직접 원인이었다 - DatabaseCleaner 도입: 테스트마다 DB를 비워 실행 순서 의존성 제거 "expected: 1 but was: 6" 류 실패 3건이 여기서 비롯됐다 - FCM/S3/OpenAI/Discord 외부 연동을 베이스에서 일괄 목 처리 - JaCoCo 적용, 테스트 힙 2g 상향 - Testcontainers 1.21.3 고정 및 api.version 1.44 지정 Docker Engine 29가 docker-java 기본값인 API v1.32를 400으로 거부한다 - 더미 데이터를 대량 생성하던 성능 테스트와 Random*Generator 제거 [Test] Sentry 프로덕션 장애 회귀 테스트 추가 (의도적으로 red) 각 테스트가 Sentry 이슈 하나에 대응한다. 원인 수정은 후속 커밋에서 진행한다. - JAVA-SPRING-BOOT-4Z: 태그 동시 생성 시 유니크 인덱스 충돌로 500 (24건) - JAVA-SPRING-BOOT-5A: 256자 이상 메모 저장 시 Data truncation으로 500 (3건) - registerProblem에 folderId가 null이면 500으로 나가는 경로 --- build.gradle | 71 ++- .../integration/FolderApiIntegrationTest.java | 4 +- .../performance/LargePerformanceTest.java | 549 ------------------ .../performance/PersonalPerformanceTest.java | 396 ------------- .../PracticeNoteIntegrationTest.java | 4 +- .../ProblemApiIntegrationTest.java | 4 +- .../ProblemReviewReminderServiceTest.java | 6 +- .../ProductionIncidentRegressionTest.java | 228 ++++++++ .../StudyRoomApiIntegrationTest.java | 4 +- .../StudyRoomChallengeApiIntegrationTest.java | 8 +- .../integration/StudyRoomFeedApiTest.java | 4 +- .../integration/StudyRoomInviteApiTest.java | 4 +- .../StudyRoomMemberManagementApiTest.java | 4 +- .../StudyRoomSharedProblemApiTest.java | 4 +- .../StudyRoomSharedProblemCommentApiTest.java | 4 +- .../StudyRoomWeeklyReportApiTest.java | 4 +- .../StudyRoomChallengeServiceTest.java | 4 +- .../service/StudyRoomFeedServiceUnitTest.java | 4 +- .../StudyRoomServiceIntegrationTest.java | 44 +- .../OnO/backend/support/DatabaseCleaner.java | 56 ++ .../support/IntegrationTestSupport.java | 100 ++++ .../OnO/backend/support/TestContainers.java | 75 +++ .../support/TestEnvironmentSmokeTest.java | 66 +++ .../OnO/backend/support/TestFixtures.java | 66 +++ .../aisip/OnO/backend/support/TestUsers.java | 38 ++ .../integration/UserApiIntegrationTest.java | 4 +- .../OnO/backend/util/BulkDataGenerator.java | 384 ------------ .../OnO/backend/util/RandomUserGenerator.java | 105 ---- 28 files changed, 754 insertions(+), 1490 deletions(-) delete mode 100644 src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/regression/ProductionIncidentRegressionTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/DatabaseCleaner.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/IntegrationTestSupport.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/TestContainers.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/TestEnvironmentSmokeTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/TestFixtures.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/TestUsers.java delete mode 100644 src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java delete mode 100644 src/test/java/com/aisip/OnO/backend/util/RandomUserGenerator.java diff --git a/build.gradle b/build.gradle index 331b7e75..7ad629db 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'java' + id 'jacoco' id 'org.springframework.boot' version '3.3.1' id 'io.spring.dependency-management' version '1.1.5' id "io.sentry.jvm.gradle" version "4.11.0" @@ -67,8 +68,14 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'com.h2database:h2' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.springframework.boot:spring-boot-testcontainers' + // Spring Boot 3.3.1 이 관리하는 1.19.8 은 docker-java 3.3.x 를 쓰는데, + // Docker Engine 29 의 /info 응답에 400 을 내며 컨테이너를 띄우지 못한다. 버전을 고정해 올린다. + testImplementation platform('org.testcontainers:testcontainers-bom:1.21.3') + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:rabbitmq' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' @@ -82,6 +89,68 @@ dependencies { tasks.named('test') { useJUnitPlatform() systemProperty 'spring.profiles.active', 'test' + // 기존 스위트는 힙 부족으로 OOM 종료됐다. 컨텍스트 캐시 + 컨테이너 구동분을 감안해 상향한다. + maxHeapSize = '2g' + + // macOS Docker Desktop 은 소켓을 ~/.docker/run/docker.sock 에 두기 때문에 + // Testcontainers 의 기본 탐색(/var/run/docker.sock)이 실패한다. 환경별로 찾아서 넘긴다. + def userSocket = file("${System.getProperty('user.home')}/.docker/run/docker.sock") + def dockerHost = System.getenv('DOCKER_HOST') + ?: (userSocket.exists() ? "unix://${userSocket.absolutePath}" : 'unix:///var/run/docker.sock') + environment 'DOCKER_HOST', dockerHost + // docker-java 는 기본으로 API v1.32 를 쓰는데 Docker Engine 29 의 MinAPIVersion 은 1.40 이라 + // /v1.32/info 가 400 으로 거부된다("Could not find a valid Docker environment"의 실제 원인). + // docker-java 는 이 값을 환경변수가 아니라 'api.version' 시스템 프로퍼티에서 읽는다. + systemProperty 'api.version', System.getenv('DOCKER_API_VERSION') ?: '1.44' + environment 'DOCKER_API_VERSION', System.getenv('DOCKER_API_VERSION') ?: '1.44' + environment 'TESTCONTAINERS_CHECKS_DISABLE', 'true' + testLogging { + events 'failed' + exceptionFormat 'short' + } + finalizedBy jacocoTestReport +} + +jacoco { + toolVersion = '0.8.12' +} + +// 커버리지 집계에서 제외할 대상: 실행 진입점, 설정 클래스, QueryDSL 생성 코드 +def coverageExcludes = [ + '**/BackendApplication*', + '**/config/**', + '**/Q[A-Z]*', +] + +tasks.named('jacocoTestReport') { + dependsOn tasks.named('test') + reports { + xml.required = true + html.required = true + } + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, exclude: coverageExcludes) + }) + ) +} + +tasks.named('jacocoTestCoverageVerification') { + violationRules { + rule { + element = 'BUNDLE' + limit { + counter = 'LINE' + value = 'COVEREDRATIO' + minimum = 0.00 // 개선 진행에 따라 단계적으로 상향한다 + } + } + } + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, exclude: coverageExcludes) + }) + ) } tasks.withType(JavaCompile) { diff --git a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java index 968b7136..fe447f11 100644 --- a/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/folder/integration/FolderApiIntegrationTest.java @@ -5,7 +5,7 @@ import com.aisip.OnO.backend.user.repository.UserRepository; import com.aisip.OnO.backend.util.RandomFolderGenerator; import com.aisip.OnO.backend.util.RandomProblemGenerator; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; import com.aisip.OnO.backend.folder.entity.Folder; @@ -79,7 +79,7 @@ public class FolderApiIntegrationTest { @BeforeEach void setUp() { - User user = RandomUserGenerator.createRandomUser(); + User user = TestUsers.create(); userRepository.save(user); userId = user.getId(); diff --git a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java deleted file mode 100644 index 65cbde3d..00000000 --- a/src/test/java/com/aisip/OnO/backend/performance/LargePerformanceTest.java +++ /dev/null @@ -1,549 +0,0 @@ -package com.aisip.OnO.backend.performance; - -import com.aisip.OnO.backend.folder.entity.Folder; -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.entity.PracticeNote; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; -import com.aisip.OnO.backend.problem.entity.Problem; -import com.aisip.OnO.backend.problem.entity.ProblemImageData; -import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; -import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomFolderGenerator; -import com.aisip.OnO.backend.util.RandomPracticeNoteGenerator; -import com.aisip.OnO.backend.util.RandomProblemGenerator; -import com.aisip.OnO.backend.util.RandomUserGenerator; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import jakarta.persistence.EntityManager; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -/** - * 인덱스 도입 전후 성능 비교 테스트 - * - * 테스트 대상 인덱스: - * 1. Problem.folder_id - * 2. Folder.userId - * 3. Problem.userId - * 4. PracticeNote.userId - * 5. User.identifier (UNIQUE) - */ -@Slf4j -@SpringBootTest -@ActiveProfiles("test") -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class LargePerformanceTest { - - @Autowired - private ProblemRepository problemRepository; - - @Autowired - private FolderRepository folderRepository; - - @Autowired - private PracticeNoteRepository practiceNoteRepository; - - @Autowired - private UserRepository userRepository; - - private static final int USER_COUNT = 500; - private static final int FOLDER_PER_USER = 10; - private static final int PROBLEM_PER_FOLDER = 25; - private static final int PRACTICE_NOTE_PER_USER = 20; - private static final int QUERY_REPEAT_COUNT = 10; - - private static List testUserIds = new ArrayList<>(); - private static List testFolderIds = new ArrayList<>(); - private static List testIdentifiers = new ArrayList<>(); - - @BeforeAll - static void setup(@Autowired UserRepository userRepository, - @Autowired FolderRepository folderRepository, - @Autowired ProblemRepository problemRepository, - @Autowired PracticeNoteRepository practiceNoteRepository, - @Autowired ProblemImageDataRepository problemImageDataRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - log.info("========================================"); - log.info("테스트 데이터 생성 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - long startTime = System.currentTimeMillis(); - - // 1. User 생성 - for (int i = 0; i < USER_COUNT; i++) { - User user = RandomUserGenerator.createRandomUser(); - user = userRepository.save(user); - testUserIds.add(user.getId()); - testIdentifiers.add(user.getIdentifier()); - } - log.info("User {} 개 생성 완료", USER_COUNT); - - // 2. Folder 생성 - for (Long userId : testUserIds) { - for (int i = 0; i < FOLDER_PER_USER; i++) { - Folder folder = RandomFolderGenerator.createRandomFolder(userId); - - folder = folderRepository.save(folder); - testFolderIds.add(folder.getId()); - } - } - log.info("Folder {} 개 생성 완료 (총 {}개)", FOLDER_PER_USER, testFolderIds.size()); - - // 3. Problem 생성 (배치 처리) - int problemCount = 0; - int batchSize = 100; - for (Long folderId : testFolderIds) { - Folder folder = folderRepository.findById(folderId).orElseThrow(); - for (int i = 0; i < PROBLEM_PER_FOLDER; i++) { - Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folder, folder.getUserId()); - problem.updateFolder(folder); - problemRepository.save(problem); - problemCount++; - - List imageDataList = RandomProblemGenerator.createDefaultProblemImageDataList(problem.getId()); - - imageDataList.forEach(imageData -> { - imageData.updateProblem(problem); - ProblemImageData saveImageData = problemImageDataRepository.save(imageData); - }); - - // 배치 단위로 flush & clear - if (problemCount % batchSize == 0) { - entityManager.flush(); - entityManager.clear(); - if (problemCount % 10000 == 0) { - log.info("Problem {} / {} 생성 중...", problemCount, testFolderIds.size() * PROBLEM_PER_FOLDER); - } - } - } - } - entityManager.flush(); - entityManager.clear(); - log.info("Problem {} 개 생성 완료", problemCount); - - // 4. PracticeNote 생성 - int practiceNoteCount = 0; - for (Long userId : testUserIds) { - for (int i = 0; i < PRACTICE_NOTE_PER_USER; i++) { - PracticeNote practiceNote = RandomPracticeNoteGenerator.createRandomPracticeNote(userId); - practiceNoteRepository.save(practiceNote); - practiceNoteCount++; - } - } - log.info("PracticeNote {} 개 생성 완료", practiceNoteCount); - - long endTime = System.currentTimeMillis(); - log.info("========================================"); - log.info("테스트 데이터 생성 완료: {}ms", (endTime - startTime)); - log.info("User: {}, Folder: {}, Problem: {}, PracticeNote: {}", - USER_COUNT, testFolderIds.size(), problemCount, practiceNoteCount); - log.info("========================================"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - } - - @AfterAll - static void cleanup(@Autowired UserRepository userRepository, - @Autowired FolderRepository folderRepository, - @Autowired ProblemRepository problemRepository, - @Autowired PracticeNoteRepository practiceNoteRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - log.info("========================================"); - log.info("테스트 데이터 정리 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 외래 키 제약 조건 임시 비활성화 (MySQL) - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); - log.info("외래 키 제약 조건 비활성화"); - - // testUserIds를 기반으로 테스트 데이터만 삭제 - String userIdsStr = testUserIds.stream() - .map(String::valueOf) - .reduce((a, b) -> a + "," + b) - .orElse(""); - - if (userIdsStr.isEmpty()) { - log.warn("삭제할 테스트 유저 ID가 없습니다. 데이터 정리를 건너뜁니다."); - return; - } - - // Native Query로 물리적 삭제 (순서 중요: 자식 → 부모) - // 1. problem_practice_note_mapping 삭제 (problem과 practice_note를 통해 간접 참조) - int mappingDeleted = entityManager.createNativeQuery( - "DELETE ppm FROM problem_practice_note_mapping ppm " + - "INNER JOIN problem p ON ppm.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem_practice_note_mapping {} 건 삭제", mappingDeleted); - - // 2. problem_analysis 삭제 (problem을 참조) - int analysisDeleted = entityManager.createNativeQuery( - "DELETE pa FROM problem_analysis pa " + - "INNER JOIN problem p ON pa.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem_analysis {} 건 삭제", analysisDeleted); - - // 3. image_data 삭제 (problem을 참조) - int imageDeleted = entityManager.createNativeQuery( - "DELETE id FROM image_data id " + - "INNER JOIN problem p ON id.problem_id = p.id " + - "WHERE p.user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("image_data {} 건 삭제", imageDeleted); - - // 4. problem 삭제 (테스트 유저의 문제만) - int problemDeleted = entityManager.createNativeQuery( - "DELETE FROM problem WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("problem {} 건 삭제", problemDeleted); - - // 5. practice_note 삭제 (테스트 유저의 복습노트만) - int practiceDeleted = entityManager.createNativeQuery( - "DELETE FROM practice_note WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("practice_note {} 건 삭제", practiceDeleted); - - // 6. folder 삭제 (테스트 유저의 폴더만, 자기 자신을 참조하므로 FK 체크 비활성화 필요) - int folderDeleted = entityManager.createNativeQuery( - "DELETE FROM folder WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("folder {} 건 삭제", folderDeleted); - - // 7. mission_log 삭제 (테스트 유저의 미션 로그만) - int missionDeleted = entityManager.createNativeQuery( - "DELETE FROM mission_log WHERE user_id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("mission_log {} 건 삭제", missionDeleted); - - // 8. user 삭제 (테스트 유저만) - int userDeleted = entityManager.createNativeQuery( - "DELETE FROM user WHERE id IN (" + userIdsStr + ")" - ).executeUpdate(); - log.info("user {} 건 삭제", userDeleted); - - // 외래 키 제약 조건 재활성화 - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); - log.info("외래 키 제약 조건 재활성화"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - - log.info("테스트 데이터 물리적 삭제 완료"); - } catch (Exception e) { - // 에러 발생 시 롤백 - transactionManager.rollback(transaction); - log.error("테스트 데이터 정리 실패: {}", e.getMessage(), e); - throw e; // 에러를 다시 던져서 문제를 확인할 수 있도록 - } - - log.info("========================================"); - } - - /** - * 1. Problem.folder_id 인덱스 성능 테스트 - * 쿼리: findAllByFolderId(), findProblemIdsByFolder() - */ - @Test - @Order(1) - @DisplayName("1. Problem.folder_id 인덱스 성능 테스트") - void testProblemFolderIdIndex() { - log.info("\n========================================"); - log.info("Test 1: Problem.folder_id 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - problemRepository.findAllByFolderId(testFolderIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long folderId = testFolderIds.get(random.nextInt(testFolderIds.size())); - - long startTime = System.nanoTime(); - List problems = problemRepository.findAllByFolderId(folderId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; // ms로 변환 - executionTimes.add(executionTime); - - log.info("Query {}: folderId={}, 결과={} 건, 실행시간={}ms", - i + 1, folderId, problems.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 2. Folder.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), findRootFolder(), findAllFoldersWithDetailsByUserId() - */ - @Test - @Order(2) - @DisplayName("2. Folder.userId 인덱스 성능 테스트") - void testFolderUserIdIndex() { - log.info("\n========================================"); - log.info("Test 2: Folder.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - folderRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List folders = folderRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, folders.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 3. Problem.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), countByUserId() - */ - @Test - @Order(3) - @DisplayName("3. Problem.userId 인덱스 성능 테스트") - void testProblemUserIdIndex() { - log.info("\n========================================"); - log.info("Test 3: Problem.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - problemRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List problems = problemRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, problems.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 4. PracticeNote.userId 인덱스 성능 테스트 - * 쿼리: findAllByUserId(), findAllUserPracticeNotesWithDetails() - */ - @Test - @Order(4) - @DisplayName("4. PracticeNote.userId 인덱스 성능 테스트") - void testPracticeNoteUserIdIndex() { - log.info("\n========================================"); - log.info("Test 4: PracticeNote.userId 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - practiceNoteRepository.findAllByUserId(testUserIds.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - Long userId = testUserIds.get(random.nextInt(testUserIds.size())); - - long startTime = System.nanoTime(); - List practiceNotes = practiceNoteRepository.findAllByUserId(userId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: userId={}, 결과={} 건, 실행시간={}ms", - i + 1, userId, practiceNotes.size(), executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 5. User.identifier 인덱스 성능 테스트 - * 쿼리: findByIdentifier() - */ - @Test - @Order(5) - @DisplayName("5. User.identifier 인덱스 성능 테스트") - void testUserIdentifierIndex() { - log.info("\n========================================"); - log.info("Test 5: User.identifier 인덱스"); - log.info("========================================"); - - Random random = new Random(); - - // Warm-up - userRepository.findByIdentifier(testIdentifiers.get(0)); - - List executionTimes = new ArrayList<>(); - - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - String identifier = testIdentifiers.get(random.nextInt(testIdentifiers.size())); - - long startTime = System.nanoTime(); - userRepository.findByIdentifier(identifier); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - executionTimes.add(executionTime); - - log.info("Query {}: identifier={}, 실행시간={}ms", - i + 1, identifier, executionTime); - } - - double avgTime = executionTimes.stream().mapToLong(Long::longValue).average().orElse(0); - long maxTime = executionTimes.stream().mapToLong(Long::longValue).max().orElse(0); - long minTime = executionTimes.stream().mapToLong(Long::longValue).min().orElse(0); - - log.info("----------------------------------------"); - log.info("평균 실행시간: {}ms", String.format("%.2f", avgTime)); - log.info("최대 실행시간: {}ms", maxTime); - log.info("최소 실행시간: {}ms", minTime); - log.info("========================================\n"); - } - - /** - * 종합 성능 테스트 - 모든 쿼리를 순차적으로 실행 - */ - @Test - @Order(6) - @DisplayName("6. 종합 성능 테스트") - void testOverallPerformance() { - log.info("\n========================================"); - log.info("Test 6: 종합 성능 테스트"); - log.info("========================================"); - - Random random = new Random(); - long totalStartTime = System.currentTimeMillis(); - - // 1. Problem by folderId - long start1 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - problemRepository.findAllByFolderId(testFolderIds.get(random.nextInt(testFolderIds.size()))); - } - long time1 = System.currentTimeMillis() - start1; - - // 2. Folder by userId - long start2 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - folderRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time2 = System.currentTimeMillis() - start2; - - // 3. Problem by userId - long start3 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - problemRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time3 = System.currentTimeMillis() - start3; - - // 4. PracticeNote by userId - long start4 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - practiceNoteRepository.findAllByUserId(testUserIds.get(random.nextInt(testUserIds.size()))); - } - long time4 = System.currentTimeMillis() - start4; - - // 5. User by identifier - long start5 = System.currentTimeMillis(); - for (int i = 0; i < QUERY_REPEAT_COUNT; i++) { - userRepository.findByIdentifier(testIdentifiers.get(random.nextInt(testIdentifiers.size()))); - } - long time5 = System.currentTimeMillis() - start5; - - long totalTime = System.currentTimeMillis() - totalStartTime; - - log.info("----------------------------------------"); - log.info("1. Problem.folder_id 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time1, String.format("%.2f", (double) time1 / QUERY_REPEAT_COUNT)); - log.info("2. Folder.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time2, String.format("%.2f", (double) time2 / QUERY_REPEAT_COUNT)); - log.info("3. Problem.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time3, String.format("%.2f", (double) time3 / QUERY_REPEAT_COUNT)); - log.info("4. PracticeNote.userId 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time4, String.format("%.2f", (double) time4 / QUERY_REPEAT_COUNT)); - log.info("5. User.identifier 조회 ({}회): {}ms (평균 {}ms)", - QUERY_REPEAT_COUNT, time5, String.format("%.2f", (double) time5 / QUERY_REPEAT_COUNT)); - log.info("----------------------------------------"); - log.info("전체 실행 시간: {}ms", totalTime); - log.info("========================================\n"); - } -} diff --git a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java b/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java deleted file mode 100644 index cf9481ae..00000000 --- a/src/test/java/com/aisip/OnO/backend/performance/PersonalPerformanceTest.java +++ /dev/null @@ -1,396 +0,0 @@ -package com.aisip.OnO.backend.performance; - -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository;import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.BulkDataGenerator; -import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.PlatformTransactionManager; -import org.springframework.transaction.TransactionStatus; -import org.springframework.transaction.support.DefaultTransactionDefinition; -import jakarta.persistence.EntityManager; - -/** - * BulkDataGenerator를 사용한 개인 성능 테스트 - * - * 특정 유저에 대해 대량의 데이터를 생성하고 - * 다양한 시나리오에서 성능을 측정합니다. - */ -@Slf4j -@SpringBootTest -@ActiveProfiles("test") -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -public class PersonalPerformanceTest { - - @Autowired - private BulkDataGenerator bulkDataGenerator; - - @Autowired - private UserRepository userRepository; - - @Autowired - private FolderRepository folderRepository; - - @Autowired - private ProblemRepository problemRepository; - - @Autowired - private PracticeNoteRepository practiceNoteRepository; - - private static final Long testUserId = 2252L; - private static String testUserIdentifier; - - @BeforeAll - static void setup(@Autowired UserRepository userRepository, - @Autowired BulkDataGenerator bulkDataGenerator, - @Autowired PlatformTransactionManager transactionManager) { - log.info("========================================"); - log.info("BulkDataGenerator를 사용한 테스트 데이터 생성"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 1. 테스트 유저 조회 - User user = userRepository.findById(2252L).get(); - testUserIdentifier = user.getIdentifier(); - - // 2. BulkDataGenerator로 대량 데이터 생성 - // 원하는 데이터 규모에 따라 메서드 선택: - // - generateSmallBulkData(): 소량 (10 폴더, 폴더당 10 문제, 10 복습노트) - // - generateDefaultBulkData(): 기본 (100 폴더, 폴더당 50 문제, 50 복습노트) - // - generateLargeBulkData(): 대량 (500 폴더, 폴더당 100 문제, 200 복습노트) - // - generateBulkData(userId, folderCount, problemsPerFolder, practiceNoteCount): 커스텀 - - log.info("대량 데이터 생성 시작..."); - BulkDataGenerator.BulkDataResult result = bulkDataGenerator.generateLargeBulkData(testUserId); - - log.info("========================================"); - log.info("테스트 데이터 생성 완료!"); - log.info(result.toString()); - log.info("========================================"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - } catch (Exception e) { - transactionManager.rollback(transaction); - log.error("테스트 데이터 생성 실패: {}", e.getMessage(), e); - throw e; - } - } - - @AfterAll - static void cleanup(@Autowired UserRepository userRepository, - @Autowired PlatformTransactionManager transactionManager, - @Autowired EntityManager entityManager) { - /* - log.info("========================================"); - log.info("테스트 데이터 정리 시작"); - log.info("========================================"); - - // 트랜잭션 시작 - TransactionStatus transaction = transactionManager.getTransaction(new DefaultTransactionDefinition()); - - try { - // 외래 키 제약 조건 임시 비활성화 (MySQL) - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); - log.info("외래 키 제약 조건 비활성화"); - - if (testUserId == null) { - log.warn("삭제할 테스트 유저 ID가 없습니다. 데이터 정리를 건너뜁니다."); - transactionManager.commit(transaction); - return; - } - - String userIdStr = String.valueOf(testUserId); - - // Native Query로 물리적 삭제 (순서 중요: 자식 → 부모) - // 1. problem_practice_note_mapping 삭제 - int mappingDeleted = entityManager.createNativeQuery( - "DELETE ppm FROM problem_practice_note_mapping ppm " + - "INNER JOIN problem p ON ppm.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("problem_practice_note_mapping {} 건 삭제", mappingDeleted); - - // 2. problem_analysis 삭제 - int analysisDeleted = entityManager.createNativeQuery( - "DELETE pa FROM problem_analysis pa " + - "INNER JOIN problem p ON pa.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("problem_analysis {} 건 삭제", analysisDeleted); - - // 3. image_data 삭제 - int imageDeleted = entityManager.createNativeQuery( - "DELETE id FROM image_data id " + - "INNER JOIN problem p ON id.problem_id = p.id " + - "WHERE p.user_id = " + userIdStr - ).executeUpdate(); - log.info("image_data {} 건 삭제", imageDeleted); - - // 4. problem 삭제 - int problemDeleted = entityManager.createNativeQuery( - "DELETE FROM problem WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("problem {} 건 삭제", problemDeleted); - - // 5. practice_note 삭제 - int practiceDeleted = entityManager.createNativeQuery( - "DELETE FROM practice_note WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("practice_note {} 건 삭제", practiceDeleted); - - // 6. folder 삭제 - int folderDeleted = entityManager.createNativeQuery( - "DELETE FROM folder WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("folder {} 건 삭제", folderDeleted); - - // 7. mission_log 삭제 - int missionDeleted = entityManager.createNativeQuery( - "DELETE FROM mission_log WHERE user_id = " + userIdStr - ).executeUpdate(); - log.info("mission_log {} 건 삭제", missionDeleted); - - // 8. user 삭제 - int userDeleted = entityManager.createNativeQuery( - "DELETE FROM user WHERE id = " + userIdStr - ).executeUpdate(); - log.info("user {} 건 삭제", userDeleted); - - // 외래 키 제약 조건 재활성화 - entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); - log.info("외래 키 제약 조건 재활성화"); - - // 트랜잭션 커밋 - transactionManager.commit(transaction); - - log.info("테스트 데이터 물리적 삭제 완료"); - } catch (Exception e) { - transactionManager.rollback(transaction); - log.error("테스트 데이터 정리 실패: {}", e.getMessage(), e); - throw e; - } - - log.info("========================================"); - - */ - } - - /** - * 1. 폴더 조회 성능 테스트 - */ - @Test - @Order(1) - @DisplayName("1. 유저의 모든 폴더 조회 성능 테스트") - void testFindAllFolders() { - log.info("\n========================================"); - log.info("Test 1: 유저의 모든 폴더 조회"); - log.info("========================================"); - - // Warm-up - folderRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var folders = folderRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 폴더", folders.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 2. 문제 조회 성능 테스트 - */ - @Test - @Order(2) - @DisplayName("2. 유저의 모든 문제 조회 성능 테스트") - void testFindAllProblems() { - log.info("\n========================================"); - log.info("Test 2: 유저의 모든 문제 조회"); - log.info("========================================"); - - // Warm-up - problemRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var problems = problemRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 문제", problems.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 3. 문제 개수 카운트 성능 테스트 - */ - @Test - @Order(3) - @DisplayName("3. 유저의 문제 개수 카운트 성능 테스트") - void testCountProblems() { - log.info("\n========================================"); - log.info("Test 3: 유저의 문제 개수 카운트"); - log.info("========================================"); - - // Warm-up - problemRepository.countByUserId(testUserId); - - long startTime = System.nanoTime(); - long count = problemRepository.countByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("문제 개수: {} 개", count); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 4. 복습노트 조회 성능 테스트 - */ - @Test - @Order(4) - @DisplayName("4. 유저의 모든 복습노트 조회 성능 테스트") - void testFindAllPracticeNotes() { - log.info("\n========================================"); - log.info("Test 4: 유저의 모든 복습노트 조회"); - log.info("========================================"); - - // Warm-up - practiceNoteRepository.findAllByUserId(testUserId); - - long startTime = System.nanoTime(); - var practiceNotes = practiceNoteRepository.findAllByUserId(testUserId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: {} 개의 복습노트", practiceNotes.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 5. 유저 조회 (identifier) 성능 테스트 - */ - @Test - @Order(5) - @DisplayName("5. Identifier로 유저 조회 성능 테스트") - void testFindUserByIdentifier() { - log.info("\n========================================"); - log.info("Test 5: Identifier로 유저 조회"); - log.info("========================================"); - - // Warm-up - userRepository.findByIdentifier(testUserIdentifier); - - long startTime = System.nanoTime(); - var user = userRepository.findByIdentifier(testUserIdentifier); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: userId={}, identifier={}", user.map(User::getId).orElse(null), testUserIdentifier); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 6. 특정 폴더의 문제 조회 성능 테스트 - */ - @Test - @Order(6) - @DisplayName("6. 특정 폴더의 문제 조회 성능 테스트") - void testFindProblemsByFolder() { - log.info("\n========================================"); - log.info("Test 6: 특정 폴더의 문제 조회"); - log.info("========================================"); - - // 첫 번째 폴더 선택 - var folders = folderRepository.findAllByUserId(testUserId); - if (folders.isEmpty()) { - log.warn("테스트할 폴더가 없습니다."); - return; - } - - Long testFolderId = folders.get(0).getId(); - - // Warm-up - problemRepository.findAllByFolderId(testFolderId); - - long startTime = System.nanoTime(); - var problems = problemRepository.findAllByFolderId(testFolderId); - long endTime = System.nanoTime(); - - long executionTime = (endTime - startTime) / 1_000_000; - - log.info("조회 결과: 폴더 ID={}, 문제 {} 개", testFolderId, problems.size()); - log.info("실행 시간: {}ms", executionTime); - log.info("========================================\n"); - } - - /** - * 7. 종합 성능 테스트 - */ - @Test - @Order(7) - @DisplayName("7. 종합 성능 테스트 (모든 쿼리 연속 실행)") - void testOverallPerformance() { - log.info("\n========================================"); - log.info("Test 7: 종합 성능 테스트"); - log.info("========================================"); - - long totalStartTime = System.currentTimeMillis(); - - // 1. 폴더 조회 - long start1 = System.currentTimeMillis(); - var folders = folderRepository.findAllByUserId(testUserId); - long time1 = System.currentTimeMillis() - start1; - - // 2. 문제 조회 - long start2 = System.currentTimeMillis(); - var problems = problemRepository.findAllByUserId(testUserId); - long time2 = System.currentTimeMillis() - start2; - - // 3. 문제 카운트 - long start3 = System.currentTimeMillis(); - long problemCount = problemRepository.countByUserId(testUserId); - long time3 = System.currentTimeMillis() - start3; - - // 4. 복습노트 조회 - long start4 = System.currentTimeMillis(); - var practiceNotes = practiceNoteRepository.findAllByUserId(testUserId); - long time4 = System.currentTimeMillis() - start4; - - // 5. 유저 조회 - long start5 = System.currentTimeMillis(); - var user = userRepository.findByIdentifier(testUserIdentifier); - long time5 = System.currentTimeMillis() - start5; - - long totalTime = System.currentTimeMillis() - totalStartTime; - - log.info("----------------------------------------"); - log.info("1. 폴더 조회: {} 건, {}ms", folders.size(), time1); - log.info("2. 문제 조회: {} 건, {}ms", problems.size(), time2); - log.info("3. 문제 카운트: {} 건, {}ms", problemCount, time3); - log.info("4. 복습노트 조회: {} 건, {}ms", practiceNotes.size(), time4); - log.info("5. 유저 조회 (identifier): {}ms", time5); - log.info("----------------------------------------"); - log.info("전체 실행 시간: {}ms", totalTime); - log.info("========================================\n"); - } -} \ No newline at end of file diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java index 6ec2f41f..6c52bc28 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java @@ -15,7 +15,7 @@ import com.aisip.OnO.backend.user.repository.UserRepository; import com.aisip.OnO.backend.util.RandomPracticeNoteGenerator; import com.aisip.OnO.backend.util.RandomProblemGenerator; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -85,7 +85,7 @@ public class PracticeNoteIntegrationTest { @BeforeEach void setUp() { - User user = RandomUserGenerator.createRandomUser(); + User user = TestUsers.create(); userRepository.save(user); userId = user.getId(); diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index 8a43cf6a..75a9fdda 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -6,7 +6,7 @@ import com.aisip.OnO.backend.user.repository.UserRepository; import com.aisip.OnO.backend.util.RandomFolderGenerator; import com.aisip.OnO.backend.util.RandomProblemGenerator; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.repository.FolderRepository; @@ -96,7 +96,7 @@ class ProblemApiIntegrationTest { @BeforeEach void setUp() { - User user = RandomUserGenerator.createRandomUser(); + User user = TestUsers.create(); userRepository.save(user); userId = user.getId(); diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java index 5416a2dc..6ff80dda 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java @@ -4,7 +4,7 @@ import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; import com.aisip.OnO.backend.problem.event.ProblemCreatedEvent; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.entity.FcmToken; @@ -77,7 +77,7 @@ class ProblemReviewReminderServiceTest { @BeforeEach void setUp() { - User user = RandomUserGenerator.createRandomUser(); + User user = TestUsers.create(); savedUser = userRepository.save(user); userId = savedUser.getId(); } @@ -379,7 +379,7 @@ void sendDueReminders_multipledue_sendsOnlyEarliest() { @DisplayName("user1의 SENT row가 있어도 user2의 due row는 독립적으로 발송된다") void sendDueReminders_userIsolation_eachUserIndependent() { // user2 생성 - User user2 = RandomUserGenerator.createRandomUser(); + User user2 = TestUsers.create(); userRepository.save(user2); Long userId2 = user2.getId(); diff --git a/src/test/java/com/aisip/OnO/backend/regression/ProductionIncidentRegressionTest.java b/src/test/java/com/aisip/OnO/backend/regression/ProductionIncidentRegressionTest.java new file mode 100644 index 00000000..8ba145fb --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/regression/ProductionIncidentRegressionTest.java @@ -0,0 +1,228 @@ +package com.aisip.OnO.backend.regression; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.tag.dto.TagCreateRequestDto; +import com.aisip.OnO.backend.tag.service.TagService; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * 프로덕션에서 실제로 발생한 장애를 그대로 재현하는 회귀 테스트. + * + *

각 테스트는 Sentry 이슈 하나에 대응한다. 여기가 초록이면 그 장애는 다시 나지 않는다. + * H2에서는 재현되지 않던 것들이라, 이 테스트들은 Testcontainers MySQL 위에서만 의미가 있다. + */ +@DisplayName("프로덕션 장애 회귀") +class ProductionIncidentRegressionTest extends IntegrationTestSupport { + + @Autowired + private TagService tagService; + + @Autowired + private ProblemService problemService; + + @Nested + @DisplayName("JAVA-SPRING-BOOT-4Z: POST /api/tags 태그 중복 생성 시 500") + class TagDuplicateIncident { + + /** + * Sentry: Duplicate entry '477-발상 부족' for key 'tag.idx_tag_user_normalized' (24건) + * + *

TagService.createTag 는 findByUserIdAndNormalizedName 조회 후 없으면 save 하는 + * check-then-act 구조라, 같은 사용자가 같은 태그를 동시에 보내면 두 트랜잭션이 + * 모두 "없음"을 보고 insert 해 유니크 인덱스에 걸린다. + */ + @Test + @DisplayName("같은 태그명을 동시에 생성해도 500이 아니라 같은 태그 하나로 수렴한다") + void createsSingleTagUnderConcurrentRequests() throws InterruptedException { + User user = fixtures.createUser(); + String tagName = "발상 부족"; + + int threadCount = 8; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(threadCount); + List failures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + tagService.createTag(user.getId(), new TagCreateRequestDto(tagName)); + } catch (Throwable e) { + synchronized (failures) { + failures.add(e); + } + } finally { + done.countDown(); + } + }); + } + + ready.await(5, TimeUnit.SECONDS); + start.countDown(); + done.await(20, TimeUnit.SECONDS); + executor.shutdownNow(); + + assertThat(failures) + .as("동시 요청 중 예외가 발생하면 사용자에게 500이 나간다") + .isEmpty(); + assertThat(tagService.getUserTags(user.getId())) + .as("같은 이름의 태그는 하나만 남아야 한다") + .hasSize(1); + } + + @Test + @DisplayName("이미 있는 태그를 다시 생성하면 기존 태그를 그대로 돌려준다") + void returnsExistingTagOnDuplicate() { + User user = fixtures.createUser(); + + Long firstId = tagService.createTag(user.getId(), new TagCreateRequestDto("발상 부족")).tagId(); + Long secondId = tagService.createTag(user.getId(), new TagCreateRequestDto("발상 부족")).tagId(); + + assertThat(secondId).isEqualTo(firstId); + } + + @Test + @DisplayName("대소문자만 다른 태그도 같은 태그로 취급한다") + void treatsCaseVariantsAsSameTag() { + User user = fixtures.createUser(); + + Long lower = tagService.createTag(user.getId(), new TagCreateRequestDto("algebra")).tagId(); + Long upper = tagService.createTag(user.getId(), new TagCreateRequestDto("ALGEBRA")).tagId(); + + assertThat(upper).isEqualTo(lower); + } + + @Test + @DisplayName("다른 사용자는 같은 이름의 태그를 각자 가질 수 있다") + void allowsSameTagNameAcrossUsers() { + User user = fixtures.createUser(); + User other = fixtures.createOtherUser(); + + Long mine = tagService.createTag(user.getId(), new TagCreateRequestDto("발상 부족")).tagId(); + Long theirs = tagService.createTag(other.getId(), new TagCreateRequestDto("발상 부족")).tagId(); + + assertThat(theirs).isNotEqualTo(mine); + assertThat(tagService.getUserTags(user.getId())).hasSize(1); + assertThat(tagService.getUserTags(other.getId())).hasSize(1); + } + } + + @Nested + @DisplayName("JAVA-SPRING-BOOT-5A: POST /api/problems 긴 메모 저장 시 500") + class ProblemMemoTruncationIncident { + + /** + * Sentry: Data truncation: Data too long for column 'memo' at row 1 (3건) + * + *

Problem.memo 에 길이 제약이 없어 MySQL에서 varchar(255) 로 생성되는데, + * 앱은 1000자까지 입력을 허용한다. 백엔드에는 길이 검증이 없어서 + * 256자 이상이 들어오면 DataIntegrityViolationException 이 그대로 500으로 나간다. + */ + @Test + @DisplayName("앱이 허용하는 1000자 메모가 500 없이 저장된다") + void savesMemoUpToClientLimit() { + User user = fixtures.createUser(); + Folder folder = fixtures.createRootFolder(user.getId()); + String memo = "가".repeat(1000); + + AtomicReference problemId = new AtomicReference<>(); + + assertThatCode(() -> problemId.set(problemService.registerProblem( + new ProblemRegisterDto(null, memo, "출처", folder.getId(), null), + user.getId() + ))).doesNotThrowAnyException(); + + assertThat(problemService.findProblem(problemId.get(), user.getId()).memo()) + .as("잘리지 않고 원문 그대로 저장돼야 한다") + .isEqualTo(memo); + } + + @Test + @DisplayName("256자 메모 - varchar(255) 경계를 넘는 순간 터지던 지점") + void savesMemoJustOverLegacyColumnLimit() { + User user = fixtures.createUser(); + Folder folder = fixtures.createRootFolder(user.getId()); + String memo = "a".repeat(256); + + Long problemId = problemService.registerProblem( + new ProblemRegisterDto(null, memo, null, folder.getId(), null), + user.getId() + ); + + assertThat(problemService.findProblem(problemId, user.getId()).memo()).hasSize(256); + } + + @Test + @DisplayName("허용 한도를 넘는 메모는 500이 아니라 400으로 거절한다") + void rejectsMemoOverLimitWithBadRequest() { + User user = fixtures.createUser(); + Folder folder = fixtures.createRootFolder(user.getId()); + String memo = "가".repeat(1001); + + assertThatCode(() -> problemService.registerProblem( + new ProblemRegisterDto(null, memo, null, folder.getId(), null), + user.getId() + )) + .as("한도 초과는 서버 오류가 아니라 클라이언트 입력 오류다") + .isInstanceOf(ApplicationException.class); + } + } + + @Nested + @DisplayName("folderId 누락 요청이 500으로 나가던 지점") + class MissingFolderIdIncident { + + /** + * registerProblem 은 folderId 를 그대로 findById 에 넘긴다. + * null 이면 FOLDER_NOT_FOUND(404) 가 아니라 스프링의 + * InvalidDataAccessApiUsageException 이 터져 500 으로 나간다. + */ + @Test + @DisplayName("folderId 가 null 이면 400/404 계열로 거절한다") + void rejectsNullFolderIdWithClientError() { + User user = fixtures.createUser(); + + assertThatCode(() -> problemService.registerProblem( + new ProblemRegisterDto(null, "메모", null, null, null), + user.getId() + )) + .as("입력 누락은 서버 오류가 아니다") + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("남의 폴더에 문제를 등록할 수 없다") + void rejectsOtherUsersFolder() { + User user = fixtures.createUser(); + User other = fixtures.createOtherUser(); + Folder othersFolder = fixtures.createRootFolder(other.getId()); + + assertThatCode(() -> problemService.registerProblem( + new ProblemRegisterDto(null, "메모", null, othersFolder.getId(), null), + user.getId() + )).isInstanceOf(ApplicationException.class); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java index dec79b95..ab8aaf78 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomApiIntegrationTest.java @@ -14,7 +14,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.fasterxml.jackson.databind.ObjectMapper; @@ -371,7 +371,7 @@ void challengeApiUsesCurrentPeriodPracticeCountsAndKeepsPeriodChallengeInProgres } private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java index 9dadf454..46425773 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiIntegrationTest.java @@ -15,7 +15,7 @@ import com.aisip.OnO.backend.studyroom.service.StudyRoomService; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.BeforeEach; @@ -76,9 +76,9 @@ class StudyRoomChallengeApiIntegrationTest { @BeforeEach void setUp() { - host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "challenge-host")); - member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "challenge-member")); - outsider = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "외부", "challenge-outsider")); + host = userRepository.save(TestUsers.create("GOOGLE", "방장", "challenge-host")); + member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "challenge-member")); + outsider = userRepository.save(TestUsers.create("GOOGLE", "외부", "challenge-outsider")); authenticate(host.getId()); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("챌린지 테스트방"), host.getId()); diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java index 007a80cd..d54e4f87 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomFeedApiTest.java @@ -8,7 +8,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -175,7 +175,7 @@ void nonMemberCannotToggleFeedReaction() throws Exception { // ========== 헬퍼 ========== private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java index cb0420e1..7aab5174 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomInviteApiTest.java @@ -3,7 +3,7 @@ import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -125,7 +125,7 @@ void nonExistentValidFormatCodeIsRejected() throws Exception { // ========== 헬퍼 ========== private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java index 5188241f..fdca064e 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomMemberManagementApiTest.java @@ -3,7 +3,7 @@ import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -215,7 +215,7 @@ void memberCanSetAndClearWeeklyGoalAndNegativeGoalIsRejected() throws Exception // ========== 헬퍼 ========== private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java index 34e1dbbe..a6646d22 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemApiTest.java @@ -13,7 +13,7 @@ import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -268,7 +268,7 @@ void sharedProblemDoesNotAllowOtherMemberToUseOriginalProblemAsOwnStudyData() th // ========== 헬퍼 ========== private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java index 7282be6c..5d0b38e9 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomSharedProblemCommentApiTest.java @@ -6,7 +6,7 @@ import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.*; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -257,7 +257,7 @@ void blankOrTooLongCommentIsRejected() throws Exception { } private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java index 96e5e438..73633b31 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomWeeklyReportApiTest.java @@ -7,7 +7,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomWeeklyReportRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import com.jayway.jsonpath.JsonPath; import org.junit.jupiter.api.AfterEach; @@ -173,7 +173,7 @@ void nonMemberCannotMarkReportRead() throws Exception { // ========== 헬퍼 ========== private User saveUser(String name, String emailPrefix) { - return userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", name, emailPrefix)); + return userRepository.save(TestUsers.create("GOOGLE", name, emailPrefix)); } private void authenticate(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java index e80aabba..d34a105e 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java @@ -7,7 +7,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; import com.aisip.OnO.backend.user.entity.User; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fcm.service.FcmService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -364,7 +364,7 @@ private StudyRoomChallenge challenge(StudyRoomChallengeType type, StudyRoomChall } private User user(Long id, String name) { - User user = RandomUserGenerator.createRandomUser("GOOGLE", name, "study-room-service-test"); + User user = TestUsers.create("GOOGLE", name, "study-room-service-test"); setField(user, "id", id); return user; } diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java index 01dc7138..78ab4ebf 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomFeedServiceUnitTest.java @@ -7,7 +7,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -62,7 +62,7 @@ class StudyRoomFeedServiceUnitTest { @BeforeEach void setUp() { - user = RandomUserGenerator.createRandomUser("GOOGLE", "테스트", "feed-unit-test"); + user = TestUsers.create("GOOGLE", "테스트", "feed-unit-test"); setField(user, "id", 1L); room = StudyRoom.create("테스트방", 1L); diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java index 2091859e..1b264965 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomServiceIntegrationTest.java @@ -25,7 +25,7 @@ import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import org.junit.jupiter.api.Test; @@ -81,7 +81,7 @@ class StudyRoomServiceIntegrationTest { @Test void createRoomCreatesHostMember() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse response = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); @@ -93,8 +93,8 @@ void createRoomCreatesHostMember() { @Test void joinWithInviteCodeAddsMember() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); @@ -106,8 +106,8 @@ void joinWithInviteCodeAddsMember() { @Test void memberCanRejoinAfterLeavingRoom() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); @@ -121,7 +121,7 @@ void memberCanRejoinAfterLeavingRoom() { @Test void feedReactionCanBeRecreatedAfterDelete() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); StudyRoomFeed feed = feedRepository.save(StudyRoomFeed.create( roomRepository.findById(room.roomId()).orElseThrow(), @@ -141,8 +141,8 @@ void feedReactionCanBeRecreatedAfterDelete() { @Test void nonMemberCannotReadRoom() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User outsider = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "외부", "outsider")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User outsider = userRepository.save(TestUsers.create("GOOGLE", "외부", "outsider")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); assertThatThrownBy(() -> studyRoomService.getRoom(room.roomId(), outsider.getId())) @@ -153,8 +153,8 @@ void nonMemberCannotReadRoom() { @Test void getMyRoomsIncludesTodayPracticeSummary() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); @@ -174,8 +174,8 @@ void getMyRoomsIncludesTodayPracticeSummary() { @Test void getRoomIncludesMemberTodayPracticeCount() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); @@ -196,7 +196,7 @@ void getRoomIncludesMemberTodayPracticeCount() { @Test void updateThumbnailUploadsAndStoresThumbnailUrl() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); MockMultipartFile firstThumbnail = new MockMultipartFile("thumbnail", "first.png", "image/png", pngBytes()); MockMultipartFile secondThumbnail = new MockMultipartFile("thumbnail", "second.png", "image/png", pngBytes()); @@ -215,8 +215,8 @@ void updateThumbnailUploadsAndStoresThumbnailUrl() { @Test void nonHostCannotUpdateThumbnail() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); @@ -230,7 +230,7 @@ void nonHostCannotUpdateThumbnail() { @Test void updateThumbnailRejectsInvalidMimeAndSignature() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "first.png", "image/png", "not-image".getBytes()); @@ -243,7 +243,7 @@ void updateThumbnailRejectsInvalidMimeAndSignature() { @Test void updateThumbnailRejectsOversizedImage() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); MockMultipartFile thumbnail = new MockMultipartFile("thumbnail", "large.png", "image/png", largePngBytes()); @@ -256,8 +256,8 @@ void updateThumbnailRejectsOversizedImage() { @Test void hostLeaveTransfersHostToNextMember() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); - User member = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "멤버", "member")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); + User member = userRepository.save(TestUsers.create("GOOGLE", "멤버", "member")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); InviteCodeResponse invite = inviteService.issueInviteCode(room.roomId(), host.getId()); inviteService.join(new StudyRoomJoinRequest(invite.code()), member.getId()); @@ -272,7 +272,7 @@ void hostLeaveTransfersHostToNextMember() { @Test void hostLeaveDeletesRoomWhenNoOtherMemberExists() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); studyRoomService.leaveRoom(room.roomId(), host.getId()); @@ -282,7 +282,7 @@ void hostLeaveDeletesRoomWhenNoOtherMemberExists() { @Test void updateGoalCanSetAndClearWeeklyGoal() { - User host = userRepository.save(RandomUserGenerator.createRandomUser("GOOGLE", "방장", "host")); + User host = userRepository.save(TestUsers.create("GOOGLE", "방장", "host")); StudyRoomDetailResponse room = studyRoomService.createRoom(new StudyRoomCreateRequest("수능 준비방"), host.getId()); assertThat(studyRoomService.updateGoal(room.roomId(), host.getId(), new StudyRoomGoalUpdateRequest(20)).weeklyGoal()) diff --git a/src/test/java/com/aisip/OnO/backend/support/DatabaseCleaner.java b/src/test/java/com/aisip/OnO/backend/support/DatabaseCleaner.java new file mode 100644 index 00000000..95705f54 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/DatabaseCleaner.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 테스트 간 데이터 격리를 담당한다. 대상은 Testcontainers로 띄운 일회용 MySQL이며 + * dev/prod DB에는 어떤 경우에도 연결되지 않는다. + * + *

기존 스위트는 공유 인메모리 DB를 쓰면서 38개 클래스 중 7개만 롤백을 걸어, + * 앞선 테스트가 남긴 행 때문에 "expected: 1 but was: 6" 류의 실패가 났다. + * 각 테스트 시작 시 전체 테이블을 비워 실행 순서와 무관하게 동작하도록 만든다. + */ +@Component +public class DatabaseCleaner { + + private static final String CLEAR_TABLE_PREFIX = "TRUNCATE TABLE `"; + + @PersistenceContext + private EntityManager entityManager; + + private List tableNames; + + @Transactional + public void clean() { + entityManager.flush(); + entityManager.clear(); + + if (tableNames == null) { + tableNames = loadTableNames(); + } + + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 0").executeUpdate(); + for (String tableName : tableNames) { + entityManager.createNativeQuery(CLEAR_TABLE_PREFIX + tableName + "`").executeUpdate(); + } + entityManager.createNativeQuery("SET FOREIGN_KEY_CHECKS = 1").executeUpdate(); + } + + @SuppressWarnings("unchecked") + private List loadTableNames() { + return entityManager.createNativeQuery(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_type = 'BASE TABLE' + AND table_name NOT LIKE 'QRTZ_%' + AND table_name <> 'flyway_schema_history' + """) + .getResultList(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/support/IntegrationTestSupport.java b/src/test/java/com/aisip/OnO/backend/support/IntegrationTestSupport.java new file mode 100644 index 00000000..6bb9f190 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/IntegrationTestSupport.java @@ -0,0 +1,100 @@ +package com.aisip.OnO.backend.support; + +import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; +import com.aisip.OnO.backend.util.ai.OpenAIClient; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; +import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.List; + +/** + * 통합 테스트 공통 베이스. + * + *

기존 스위트는 38개 클래스 중 30개가 각자 다른 조합으로 {@code @SpringBootTest}를 선언해 + * 스프링 컨텍스트를 30개 가까이 띄웠고, 그 결과 전체 실행이 OOM으로 중단됐다. + * 모든 통합 테스트가 이 클래스를 상속해 동일한 애노테이션 조합을 쓰면 + * 컨텍스트 캐시가 재사용되어 컨텍스트가 하나로 수렴한다. + * + *

외부 연동(FCM 실발송, S3 업로드, OpenAI 호출, Discord 웹훅)은 여기서 일괄 목 처리한다. + * 개별 테스트가 제각각 {@code @MockBean}을 추가하면 그 순간 컨텍스트가 새로 뜨므로, + * 목 대상을 늘려야 하면 이 클래스에 추가한다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +public abstract class IntegrationTestSupport { + + @DynamicPropertySource + static void registerContainerProperties(DynamicPropertyRegistry registry) { + TestContainers.registerProperties(registry); + } + + @Autowired + protected MockMvc mockMvc; + + @Autowired + protected ObjectMapper objectMapper; + + @Autowired + protected TestFixtures fixtures; + + @Autowired + private DatabaseCleaner databaseCleaner; + + /** FCM은 실사용자 푸시 발송 경로다. 테스트에서 절대 실제 호출되면 안 된다. */ + @MockBean + protected FcmService fcmService; + + @MockBean + protected FileUploadService fileUploadService; + + @MockBean + protected OpenAIClient openAIClient; + + @MockBean + protected DiscordWebhookNotificationService discordWebhookNotificationService; + + @MockBean + protected ProblemAnalysisProducer problemAnalysisProducer; + + @BeforeEach + void resetDatabaseAndSecurityContext() { + databaseCleaner.clean(); + SecurityContextHolder.clearContext(); + } + + /** + * 실제 DB에 존재하는 userId로 인증 컨텍스트를 세운다. + * + *

기존 {@code @WithMockCustomUser}는 userId를 1로 고정해, DB에 없는 사용자로 + * 요청을 보내면서도 통과하는 테스트를 만들었다. 소유권 검증을 제대로 확인하려면 + * 픽스처가 만든 실제 사용자로 인증해야 한다. + */ + protected void authenticateAs(Long userId) { + authenticateAs(userId, "ROLE_MEMBER"); + } + + protected void authenticateAs(Long userId, String role) { + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority(role)) + ) + ); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/support/TestContainers.java b/src/test/java/com/aisip/OnO/backend/support/TestContainers.java new file mode 100644 index 00000000..66ae8735 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/TestContainers.java @@ -0,0 +1,75 @@ +package com.aisip.OnO.backend.support; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.containers.RabbitMQContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * 테스트 전역에서 단 한 번만 기동하는 인프라 컨테이너. + * + *

기존 테스트는 H2(MODE=MySQL)를 썼기 때문에 프로덕션 MySQL에서만 터지는 문제 + * (컬럼 길이 초과로 인한 Data truncation, 유니크 인덱스 충돌, 콜레이션 차이)를 + * 구조적으로 재현할 수 없었다. 프로덕션과 동일한 MySQL 8 위에서 검증한다. + * + *

JVM 종료 시 Ryuk이 정리하므로 stop()을 명시 호출하지 않는다. + */ +public final class TestContainers { + + private static final MySQLContainer MYSQL; + private static final GenericContainer REDIS; + private static final RabbitMQContainer RABBITMQ; + + static { + MYSQL = new MySQLContainer<>(DockerImageName.parse("mysql:8.0.36")) + .withDatabaseName("ono_test") + .withUsername("test") + .withPassword("test") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci", + "--skip-character-set-client-handshake" + ) + .withReuse(true); + + REDIS = new GenericContainer<>(DockerImageName.parse("redis:7.2-alpine")) + .withExposedPorts(6379) + .withReuse(true); + + RABBITMQ = new RabbitMQContainer(DockerImageName.parse("rabbitmq:3.13-management-alpine")) + .withReuse(true); + + MYSQL.start(); + REDIS.start(); + RABBITMQ.start(); + } + + private TestContainers() { + } + + public static void registerProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", TestContainers::jdbcUrl); + registry.add("spring.datasource.username", MYSQL::getUsername); + registry.add("spring.datasource.password", MYSQL::getPassword); + registry.add("spring.datasource.driver-class-name", () -> "com.mysql.cj.jdbc.Driver"); + + registry.add("spring.data.redis.host", REDIS::getHost); + registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + + registry.add("spring.rabbitmq.host", RABBITMQ::getHost); + registry.add("spring.rabbitmq.port", RABBITMQ::getAmqpPort); + registry.add("spring.rabbitmq.username", RABBITMQ::getAdminUsername); + registry.add("spring.rabbitmq.password", RABBITMQ::getAdminPassword); + } + + /** 마이그레이션 검증 테스트가 별도 스키마를 만들 때 쓴다. */ + public static MySQLContainer mysql() { + return MYSQL; + } + + private static String jdbcUrl() { + // rewriteBatchedStatements: 배치 insert 동작을 프로덕션 설정과 맞춘다. + return MYSQL.getJdbcUrl() + "?rewriteBatchedStatements=true&characterEncoding=UTF-8"; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/support/TestEnvironmentSmokeTest.java b/src/test/java/com/aisip/OnO/backend/support/TestEnvironmentSmokeTest.java new file mode 100644 index 00000000..99fd2fa6 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/TestEnvironmentSmokeTest.java @@ -0,0 +1,66 @@ +package com.aisip.OnO.backend.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 테스트 실행 환경 자체를 검증한다. + * + *

여기가 깨지면 그 아래 모든 테스트의 신뢰도가 무너지므로 가장 먼저 확인한다. + */ +class TestEnvironmentSmokeTest extends IntegrationTestSupport { + + @PersistenceContext + private EntityManager entityManager; + + @Test + @DisplayName("테스트는 H2가 아니라 프로덕션과 같은 MySQL 8 위에서 돈다") + void runsOnMySql() { + String product = (String) entityManager + .createNativeQuery("SELECT 'mysql'") + .getSingleResult(); + String version = entityManager.getEntityManagerFactory() + .getProperties() + .getOrDefault("hibernate.dialect", "") + .toString(); + + assertThat(product).isEqualTo("mysql"); + assertThat(version).contains("MySQL"); + } + + @Test + @DisplayName("컬럼 콜레이션이 프로덕션과 동일한 utf8mb4_unicode_ci 다") + void usesProductionCollation() { + String collation = (String) entityManager + .createNativeQuery("SELECT @@collation_server") + .getSingleResult(); + + assertThat(collation).isEqualTo("utf8mb4_unicode_ci"); + } + + @Test + @DisplayName("각 테스트는 비워진 DB에서 시작한다 - 앞선 테스트가 남긴 데이터가 없다") + void startsWithCleanDatabase() { + fixtures.createUser(); + + Number countAfterInsert = (Number) entityManager + .createNativeQuery("SELECT COUNT(*) FROM user") + .getSingleResult(); + + assertThat(countAfterInsert.intValue()).isEqualTo(1); + } + + @Test + @DisplayName("직전 테스트가 만든 사용자가 다음 테스트로 넘어오지 않는다") + void isolatesDataBetweenTests() { + Number countBeforeInsert = (Number) entityManager + .createNativeQuery("SELECT COUNT(*) FROM user") + .getSingleResult(); + + assertThat(countBeforeInsert.intValue()).isZero(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/support/TestFixtures.java b/src/test/java/com/aisip/OnO/backend/support/TestFixtures.java new file mode 100644 index 00000000..7153d4a7 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/TestFixtures.java @@ -0,0 +1,66 @@ +package com.aisip.OnO.backend.support; + +import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.repository.FolderRepository; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 모든 도메인이 공통으로 필요로 하는 최소 픽스처. + * + *

도메인 고유 픽스처(문제, 복습노트, 스터디룸 등)는 각 도메인 테스트 패키지에서 + * 별도 픽스처 클래스로 정의한다. 여기에는 사용자·폴더처럼 어느 도메인에서나 + * 전제가 되는 것만 둔다. + * + *

{@code User.identifier}에는 유니크 인덱스가 걸려 있으므로 호출마다 다른 값을 쓴다. + */ +@Component +@RequiredArgsConstructor +public class TestFixtures { + + private static final AtomicLong SEQUENCE = new AtomicLong(); + + private final UserRepository userRepository; + private final FolderRepository folderRepository; + + public User createUser() { + return createUser("member"); + } + + public User createUser(String namePrefix) { + long seq = SEQUENCE.incrementAndGet(); + return userRepository.save(User.from(UserRegisterDto.builder() + .email(namePrefix + seq + "@test.ono") + .name(namePrefix + seq) + .identifier("test-identifier-" + seq) + .platform("google") + .build())); + } + + /** 소유권 검증 테스트용. 서로 다른 두 사용자를 만든다. */ + public User createOtherUser() { + return createUser("other"); + } + + public Folder createRootFolder(Long userId) { + return createFolder(userId, "루트 폴더", null); + } + + public Folder createFolder(Long userId, String name, Folder parentFolder) { + FolderRegisterDto registerDto = new FolderRegisterDto( + name, + null, + parentFolder == null ? null : parentFolder.getId() + ); + Folder folder = parentFolder == null + ? Folder.from(registerDto, userId) + : Folder.from(registerDto, parentFolder, userId); + return folderRepository.save(folder); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/support/TestUsers.java b/src/test/java/com/aisip/OnO/backend/support/TestUsers.java new file mode 100644 index 00000000..094dabda --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/support/TestUsers.java @@ -0,0 +1,38 @@ +package com.aisip.OnO.backend.support; + +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; + +import java.util.concurrent.atomic.AtomicLong; + +/** + * 저장되지 않은 User 엔티티를 만드는 정적 헬퍼. + * + *

스프링 컨텍스트가 없는 단위 테스트나, 리포지토리를 직접 잡고 저장하는 + * 테스트에서 쓴다. 컨텍스트가 있으면 {@link TestFixtures}를 쓰는 쪽이 낫다. + * + *

{@code User.identifier}에 유니크 인덱스가 있으므로 호출마다 다른 값을 만든다. + */ +public final class TestUsers { + + private static final AtomicLong SEQUENCE = new AtomicLong(); + + private TestUsers() { + } + + public static User create() { + return create("GOOGLE", "테스트유저", "user"); + } + + public static User create(String platform, String name, String emailPrefix) { + long seq = SEQUENCE.incrementAndGet(); + String identifier = emailPrefix + "-" + seq; + + return User.from(UserRegisterDto.builder() + .identifier(identifier) + .platform(platform) + .name(name) + .email(identifier + "@test.ono") + .build()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java index f2e17a2c..fb2b50e9 100644 --- a/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java @@ -7,7 +7,7 @@ import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; import com.aisip.OnO.backend.user.service.UserService; -import com.aisip.OnO.backend.util.RandomUserGenerator; +import com.aisip.OnO.backend.support.TestUsers; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; @@ -54,7 +54,7 @@ public class UserApiIntegrationTest { @BeforeEach void setUp() { // 유저 등록 (식별자 중복 피하려고 시간 기반 추가) - User user = RandomUserGenerator.createRandomUser(); + User user = TestUsers.create(); userRepository.save(user); userId = user.getId(); targetUser = user; diff --git a/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java b/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java deleted file mode 100644 index d5d4faf9..00000000 --- a/src/test/java/com/aisip/OnO/backend/util/BulkDataGenerator.java +++ /dev/null @@ -1,384 +0,0 @@ -package com.aisip.OnO.backend.util; - -import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; -import com.aisip.OnO.backend.folder.entity.Folder; -import com.aisip.OnO.backend.folder.repository.FolderRepository; -import com.aisip.OnO.backend.practicenote.entity.PracticeNote; -import com.aisip.OnO.backend.practicenote.entity.ProblemPracticeNoteMapping; -import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; -import com.aisip.OnO.backend.practicenote.repository.ProblemPracticeNoteMappingRepository; -import com.aisip.OnO.backend.problem.entity.Problem; -import com.aisip.OnO.backend.problem.entity.ProblemImageData; -import com.aisip.OnO.backend.problem.entity.ProblemImageType; -import com.aisip.OnO.backend.problem.repository.ProblemImageDataRepository; -import com.aisip.OnO.backend.problem.repository.ProblemRepository; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; - -/** - * 대량 테스트 데이터 생성기 - * 특정 유저에 대해 Folder, Problem, PracticeNote 등을 대량으로 생성 - * 성능 테스트 및 대량 데이터 환경에서의 동작 검증에 사용 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class BulkDataGenerator { - - private final FolderRepository folderRepository; - private final ProblemRepository problemRepository; - private final ProblemImageDataRepository problemImageDataRepository; - private final PracticeNoteRepository practiceNoteRepository; - private final ProblemPracticeNoteMappingRepository problemPracticeNoteMappingRepository; - - private final Random random = new Random(); - - /** - * 특정 유저에 대해 모든 타입의 데이터를 대량으로 생성 - * @param userId 대상 유저 ID - * @param folderCount 생성할 폴더 개수 - * @param problemsPerFolder 폴더당 문제 개수 - * @param practiceNoteCount 생성할 복습노트 개수 - * @return 생성 결과 통계 - */ - @Transactional - public BulkDataResult generateBulkData( - Long userId, - int folderCount, - int problemsPerFolder, - int practiceNoteCount) { - - log.info("=== 대량 데이터 생성 시작 ==="); - log.info("유저 ID: {}", userId); - log.info("폴더 개수: {}, 폴더당 문제 개수: {}, 복습노트 개수: {}", - folderCount, problemsPerFolder, practiceNoteCount); - - long startTime = System.currentTimeMillis(); - - // 1. 폴더 생성 - List folders = generateFolders(userId, folderCount); - //List folders = folderRepository.findAllByUserId(userId); - //log.info("폴더 {} 개 생성 완료", folders.size()); - - // 2. 문제 생성 (이미지 포함) - List problems = generateProblems(userId, folders, problemsPerFolder); - //List problems = problemRepository.findAllByUserId(userId); - //log.info("문제 {} 개 생성 완료", problems.size()); - - // 3. 복습노트 생성 - List practiceNotes = generatePracticeNotes(userId, problems, practiceNoteCount); - log.info("복습노트 {} 개 생성 완료", practiceNotes.size()); - - long endTime = System.currentTimeMillis(); - long duration = endTime - startTime; - - BulkDataResult result = new BulkDataResult( - folders.size(), - problems.size(), - countImages(problems), - practiceNotes.size(), - duration - ); - - log.info("=== 대량 데이터 생성 완료 ==="); - log.info("소요 시간: {} ms", duration); - log.info(result.toString()); - - return result; - } - - /** - * 폴더 대량 생성 (지정된 루트 폴더 하위에 생성) - * @param userId 유저 ID - * @param count 생성할 폴더 개수 - */ - private List generateFolders(Long userId, int count) { - List folders = new ArrayList<>(); - - Optional optionalRootFolder = folderRepository.findRootFolder(userId); - Folder rootFolder; - - if(optionalRootFolder.isEmpty()) { - FolderRegisterDto folderRegisterDto = new FolderRegisterDto( - "책장", - null, - null - ); - rootFolder = folderRepository.save(Folder.from(folderRegisterDto, userId)); - } else { - rootFolder = optionalRootFolder.get(); - } - - // 모든 폴더를 루트 폴더의 자식으로 생성 (70%)과 그 하위 폴더(30%) - int directChildCount = (int) (count * 0.7); - - // 루트 폴더의 직접 자식 폴더 생성 - for (int i = 0; i < directChildCount; i++) { - Folder subFolder = RandomFolderGenerator.createRandomSubFolder(rootFolder, userId); - folders.add(folderRepository.save(subFolder)); - } - - // 생성된 폴더들의 자식 폴더 생성 - int deepChildCount = count - directChildCount; - for (int i = 0; i < deepChildCount; i++) { - if (!folders.isEmpty()) { - Folder parentFolder = folders.get(random.nextInt(folders.size())); - Folder subFolder = RandomFolderGenerator.createRandomSubFolder(parentFolder, userId); - folders.add(folderRepository.save(subFolder)); - } - } - - return folders; - } - - /** - * 문제 대량 생성 (이미지 포함) - */ - private List generateProblems(Long userId, List folders, int problemsPerFolder) { - List problems = new ArrayList<>(); - - for (Folder folder : folders) { - for (int i = 0; i < problemsPerFolder; i++) { - // 문제 생성 - Problem problem = RandomProblemGenerator.createRandomProblemWithFolder(folder, userId); - Problem savedProblem = problemRepository.save(problem); - problems.add(savedProblem); - - // 이미지 생성 (50% 확률로 1-4개 이미지) - if (random.nextBoolean()) { - int imageCount = random.nextInt(4) + 1; // 1-4개 - generateImagesForProblem(savedProblem); - } - } - } - - return problems; - } - - /** - * 문제에 대한 이미지 생성 - */ - private void generateImagesForProblem(Problem problem) { - List types = List.of( - ProblemImageType.PROBLEM_IMAGE, - ProblemImageType.ANSWER_IMAGE, - ProblemImageType.SOLVE_IMAGE, - ProblemImageType.PROCESS_IMAGE - ); - - // 1-4개의 랜덤 이미지 생성 - int imageCount = random.nextInt(4) + 1; - for (int i = 0; i < imageCount && i < types.size(); i++) { - ProblemImageData imageData = RandomProblemGenerator.createRandomProblemImageData( - problem.getId(), - types.get(i) - ); - imageData.updateProblem(problem); - problemImageDataRepository.save(imageData); - } - } - - /** - * 복습노트 대량 생성 - */ - private List generatePracticeNotes(Long userId, List problems, int count) { - List practiceNotes = new ArrayList<>(); - - // 문제가 없으면 복습노트를 생성할 수 없음 - if (problems.isEmpty()) { - log.warn("문제가 없어서 복습노트를 생성할 수 없습니다."); - return practiceNotes; - } - - for (int i = 0; i < count; i++) { - // 랜덤하게 20-30개 문제 선택 (문제가 적으면 그만큼만 선택) - int targetProblemCount = random.nextInt(10) + 20; // 5-15개 - int actualProblemCount = Math.min(targetProblemCount, problems.size()); - - // 중복 없이 문제 선택 - List problemIds = new ArrayList<>(); - List shuffledProblems = new ArrayList<>(problems); - java.util.Collections.shuffle(shuffledProblems, random); - - for (int j = 0; j < actualProblemCount; j++) { - problemIds.add(shuffledProblems.get(j).getId()); - } - - RandomPracticeNoteGenerator.createRandomPracticeNoteWithProblems(problemIds, userId); - // 복습노트 생성 - PracticeNote practiceNote = RandomPracticeNoteGenerator.createRandomPracticeNoteWithProblems( - problemIds, - userId - ); - practiceNoteRepository.save(practiceNote); - practiceNotes.add(practiceNote); - - for(Long problemId : problemIds) { - problemRepository.findById(problemId).ifPresent( problem -> { - ProblemPracticeNoteMapping mapping = ProblemPracticeNoteMapping.from(); - mapping.addMappingToProblemAndPractice(problem, practiceNote); - problemPracticeNoteMappingRepository.save(mapping); - } - ); - } - } - - return practiceNotes; - } - - /** - * 전체 이미지 개수 계산 - */ - private int countImages(List problems) { - return problems.stream() - .mapToInt(p -> p.getProblemImageDataList() != null ? p.getProblemImageDataList().size() : 0) - .sum(); - } - - /** - * 대량 데이터 생성 결과 - */ - public record BulkDataResult( - int folderCount, - int problemCount, - int imageCount, - int practiceNoteCount, - long durationMs - ) { - @Override - public String toString() { - return String.format( - "생성 결과: 폴더 %d개, 문제 %d개, 이미지 %d개, 복습노트 %d개 (소요시간: %dms)", - folderCount, problemCount, imageCount, practiceNoteCount, durationMs - ); - } - } - - /** - * 특정 루트 폴더 하위에 데이터를 대량으로 생성 - * @param userId 대상 유저 ID - * @param rootFolderId 루트 폴더 ID (모든 폴더가 이 폴더 하위에 생성됨) - * @param folderCount 생성할 폴더 개수 - * @param problemsPerFolder 폴더당 문제 개수 - * @param practiceNoteCount 생성할 복습노트 개수 - * @return 생성 결과 통계 - */ - @Transactional - public BulkDataResult generateBulkDataUnderRootFolder( - Long userId, - Long rootFolderId, - int folderCount, - int problemsPerFolder, - int practiceNoteCount) { - - log.info("=== 대량 데이터 생성 시작 (루트 폴더 지정) ==="); - log.info("유저 ID: {}, 루트 폴더 ID: {}", userId, rootFolderId); - log.info("폴더 개수: {}, 폴더당 문제 개수: {}, 복습노트 개수: {}", - folderCount, problemsPerFolder, practiceNoteCount); - - long startTime = System.currentTimeMillis(); - - // 1. 루트 폴더 하위에 폴더 생성 - List folders = generateFolders(userId, folderCount); - log.info("폴더 {} 개 생성 완료 (루트 폴더 {} 하위)", folders.size(), rootFolderId); - - // 2. 문제 생성 (이미지 포함) - List problems = generateProblems(userId, folders, problemsPerFolder); - log.info("문제 {} 개 생성 완료", problems.size()); - - // 3. 복습노트 생성 - List practiceNotes = generatePracticeNotes(userId, problems, practiceNoteCount); - log.info("복습노트 {} 개 생성 완료", practiceNotes.size()); - - long endTime = System.currentTimeMillis(); - long duration = endTime - startTime; - - BulkDataResult result = new BulkDataResult( - folders.size(), - problems.size(), - countImages(problems), - practiceNotes.size(), - duration - ); - - log.info("=== 대량 데이터 생성 완료 ==="); - log.info("소요 시간: {} ms", duration); - log.info(result.toString()); - - return result; - } - - /** - * 빠른 대량 생성 (기본값 사용) - * - 100개 폴더 - * - 폴더당 50개 문제 - * - 50개 복습노트 - */ - @Transactional - public BulkDataResult generateDefaultBulkData(Long userId) { - return generateBulkData(userId, 100, 50, 50); - } - - /** - * 특정 루트 폴더 하위에 기본값으로 대량 생성 - * - 100개 폴더 - * - 폴더당 50개 문제 - * - 50개 복습노트 - */ - @Transactional - public BulkDataResult generateDefaultBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 100, 50, 50); - } - - /** - * 소량 테스트 데이터 생성 - * - 10개 폴더 - * - 폴더당 10개 문제 - * - 10개 복습노트 - */ - @Transactional - public BulkDataResult generateSmallBulkData(Long userId) { - return generateBulkData(userId, 10, 10, 10); - } - - /** - * 특정 루트 폴더 하위에 소량 테스트 데이터 생성 - * - 10개 폴더 - * - 폴더당 10개 문제 - * - 10개 복습노트 - */ - @Transactional - public BulkDataResult generateSmallBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 10, 10, 10); - } - - /** - * 대량 테스트 데이터 생성 (성능 테스트용) - * - 500개 폴더 - * - 폴더당 100개 문제 - * - 200개 복습노트 - */ - @Transactional - public BulkDataResult generateLargeBulkData(Long userId) { - return generateBulkData(userId, 500, 100, 200); - } - - /** - * 특정 루트 폴더 하위에 대량 테스트 데이터 생성 (성능 테스트용) - * - 500개 폴더 - * - 폴더당 100개 문제 - * - 200개 복습노트 - */ - @Transactional - public BulkDataResult generateLargeBulkDataUnderRootFolder(Long userId, Long rootFolderId) { - return generateBulkDataUnderRootFolder(userId, rootFolderId, 500, 100, 200); - } -} \ No newline at end of file diff --git a/src/test/java/com/aisip/OnO/backend/util/RandomUserGenerator.java b/src/test/java/com/aisip/OnO/backend/util/RandomUserGenerator.java deleted file mode 100644 index b768d6f4..00000000 --- a/src/test/java/com/aisip/OnO/backend/util/RandomUserGenerator.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.aisip.OnO.backend.util; - -import com.aisip.OnO.backend.user.dto.UserRegisterDto; -import com.aisip.OnO.backend.user.entity.User; - -/** - * 테스트용 랜덤 User 생성기 - * 통합 테스트에서 고유한 User 엔티티를 쉽게 생성할 수 있도록 지원 - */ -public class RandomUserGenerator { - - private static final String DEFAULT_PLATFORM = "GOOGLE"; - private static final String DEFAULT_NAME_PREFIX = "테스트유저"; - private static final String DEFAULT_EMAIL_SUFFIX = "@test.com"; - - /** - * 랜덤 identifier를 가진 기본 테스트 User 생성 - * @return 생성된 User 엔티티 - */ - public static User createRandomUser() { - String randomIdentifier = TestIdentifierGenerator.generateRandomIdentifier(); - - UserRegisterDto userRegisterDto = UserRegisterDto.builder() - .identifier(randomIdentifier) - .platform(DEFAULT_PLATFORM) - .name(DEFAULT_NAME_PREFIX) - .email(randomIdentifier + DEFAULT_EMAIL_SUFFIX) - .build(); - - return User.from(userRegisterDto); - } - - /** - * 지정된 platform으로 랜덤 User 생성 - * @param platform 플랫폼 (GOOGLE, KAKAO 등) - * @return 생성된 User 엔티티 - */ - public static User createRandomUser(String platform) { - String randomIdentifier = TestIdentifierGenerator.generateRandomIdentifier(); - - UserRegisterDto userRegisterDto = UserRegisterDto.builder() - .identifier(randomIdentifier) - .platform(platform) - .name(DEFAULT_NAME_PREFIX) - .email(randomIdentifier + DEFAULT_EMAIL_SUFFIX) - .build(); - - return User.from(userRegisterDto); - } - - /** - * 모든 필드를 커스터마이징한 랜덤 User 생성 - * @param platform 플랫폼 - * @param name 이름 - * @param emailPrefix 이메일 prefix (randomIdentifier가 자동으로 추가됨) - * @return 생성된 User 엔티티 - */ - public static User createRandomUser(String platform, String name, String emailPrefix) { - String randomIdentifier = TestIdentifierGenerator.generateRandomIdentifier(); - - UserRegisterDto userRegisterDto = UserRegisterDto.builder() - .identifier(randomIdentifier) - .platform(platform) - .name(name) - .email(emailPrefix + "_" + randomIdentifier + DEFAULT_EMAIL_SUFFIX) - .build(); - - return User.from(userRegisterDto); - } - - /** - * 지정된 identifier prefix를 가진 랜덤 User 생성 - * @param identifierPrefix identifier의 prefix - * @return 생성된 User 엔티티 - */ - public static User createRandomUserWithPrefix(String identifierPrefix) { - String randomIdentifier = TestIdentifierGenerator.generateRandomIdentifier(identifierPrefix); - - UserRegisterDto userRegisterDto = UserRegisterDto.builder() - .identifier(randomIdentifier) - .platform(DEFAULT_PLATFORM) - .name(DEFAULT_NAME_PREFIX) - .email(randomIdentifier + DEFAULT_EMAIL_SUFFIX) - .build(); - - return User.from(userRegisterDto); - } - - /** - * 짧은 identifier를 가진 랜덤 User 생성 (로그 확인 등에 유용) - * @return 생성된 User 엔티티 - */ - public static User createRandomUserWithShortId() { - String randomIdentifier = TestIdentifierGenerator.generateShortRandomIdentifier(); - - UserRegisterDto userRegisterDto = UserRegisterDto.builder() - .identifier(randomIdentifier) - .platform(DEFAULT_PLATFORM) - .name(DEFAULT_NAME_PREFIX) - .email(randomIdentifier + DEFAULT_EMAIL_SUFFIX) - .build(); - - return User.from(userRegisterDto); - } -} \ No newline at end of file From 582cdbceafa144197a3b128941cae7348ae1c241 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 16:47:14 +0900 Subject: [PATCH 025/130] =?UTF-8?q?[Test]=20PR=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=8C=EC=9D=B4=ED=8A=B8=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EA=B8=B8=EC=9D=B4=20=EA=B0=80=EB=93=9C=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 ci-dev/ci-prod 는 workflow_dispatch 수동 실행이고 `./gradlew build -x test` 로 테스트를 건너뛴다. main/develop 으로 합치기 전 검증하는 게이트가 없었다. - .github/workflows/test.yml: main/develop 대상 PR과 push에서 테스트 실행 - Testcontainers 로 DB/Redis/RabbitMQ 를 러너 안에서 격리 기동. 외부 인프라·시크릿 불필요 - 러너의 실제 Docker API 버전을 읽어 넘김 (docker-java 기본 v1.32 는 최신 엔진이 거부) - 실패한 테스트 목록과 커버리지 표를 잡 요약에 출력, 리포트는 아티팩트로 보관 - 배포 워크플로는 손대지 않았다. 검증만 분리해 책임진다 - DefaultColumnLengthGuardTest: 길이 미지정 문자열 컬럼을 고정 목록으로 감시 @Column(length) 를 빼먹으면 Hibernate 가 조용히 varchar(255) 를 만든다. 프로덕션의 memo Data truncation 이 정확히 이 경우였고, 앞으로 새 컬럼이 검토 없이 추가되면 여기서 걸린다 --- .github/workflows/test.yml | 133 ++++++++++++++++++ .../schema/DefaultColumnLengthGuardTest.java | 130 +++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 src/test/java/com/aisip/OnO/backend/schema/DefaultColumnLengthGuardTest.java diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..2c15b62a --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,133 @@ +name: Test + +# main / develop 으로 합치기 전에 반드시 통과해야 하는 게이트. +# 기존 ci-dev / ci-prod 는 workflow_dispatch 수동 실행이고 `-x test` 로 테스트를 건너뛴다. +# 배포 파이프라인은 그대로 두고, 검증만 이 워크플로가 책임진다. +on: + pull_request: + branches: [main, develop] + push: + branches: [main, develop] + workflow_dispatch: + +# 같은 PR에 연달아 푸시하면 앞선 실행은 취소한다. +concurrency: + group: test-${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: 격리 환경 테스트 + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + # 테스트 프로필은 DB/Redis/RabbitMQ 를 전부 Testcontainers 로 띄우므로 + # 외부 인프라도, 시크릿도 필요 없다. 다만 FcmConfig 의 @PostConstruct 가 + # 키 파일을 읽지 못하면 컨텍스트가 뜨지 않아 더미 파일만 만들어 준다. + # (실제 발송은 IntegrationTestSupport 에서 FcmService 를 목으로 잡아 차단한다) + - name: Create dummy FirebaseAdminKey.json + run: | + cat > src/test/resources/FirebaseAdminKey.json <<'JSON' + { + "type": "authorized_user", + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "refresh_token": "test-refresh-token" + } + JSON + + # docker-java 는 기본으로 API v1.32 를 쓰는데 최신 엔진은 이를 거부한다. + # 러너에 실제로 올라간 엔진의 API 버전을 그대로 넘긴다. + - name: Resolve Docker API version + run: | + echo "DOCKER_API_VERSION=$(docker version --format '{{.Server.APIVersion}}')" >> "$GITHUB_ENV" + docker version --format 'Docker Engine {{.Server.Version}} / API {{.Server.APIVersion}}' + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test jacocoTestReport --no-daemon + + - name: Summarize results + if: always() + run: | + python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY" + import glob, re, os + + total = failed = skipped = 0 + failures = [] + for path in glob.glob('build/test-results/test/*.xml'): + head = open(path, encoding='utf-8', errors='replace').read(4000) + m = re.search(r'tests="(\d+)"[^>]*skipped="(\d+)" failures="(\d+)" errors="(\d+)"', head) + if not m: + continue + total += int(m.group(1)) + skipped += int(m.group(2)) + failed += int(m.group(3)) + int(m.group(4)) + + for path in glob.glob('build/test-results/test/*.xml'): + body = open(path, encoding='utf-8', errors='replace').read() + for case in re.finditer(r']*?>(.*?)(?= 50: + print(f'- 외 {len(failures) - 50}건') + print() + + report = 'build/reports/jacoco/test/jacocoTestReport.xml' + if os.path.exists(report): + xml = open(report, encoding='utf-8').read() + tail = xml[xml.rfind(''):] + print('## 커버리지\n') + print('| 지표 | 커버 | 전체 | 비율 |') + print('|---|---:|---:|---:|') + labels = {'INSTRUCTION': '명령어', 'BRANCH': '분기', 'LINE': '라인', 'METHOD': '메서드', 'CLASS': '클래스'} + for c in re.finditer(r'엔티티에 {@code @Column(length = ...)} 를 붙이지 않으면 Hibernate 는 아무 경고 없이 + * {@code varchar(255)} 를 만든다. 프로덕션에서 난 + * {@code Data truncation: Data too long for column 'memo'} 가 정확히 이 경우였다. + * 앱은 메모를 1000자까지 받는데 컬럼은 255자였고, 아무도 그 사실을 몰랐다. + * + *

이 테스트는 기본 길이로 남은 컬럼 목록을 고정한다. 새 컬럼이 길이 지정 없이 추가되면 + * 여기서 걸리므로, 255자로 충분한지 한 번은 판단하고 넘어가게 된다. + * 충분하다고 판단했으면 {@link #ACKNOWLEDGED_DEFAULT_LENGTH_COLUMNS} 에 추가해라. + */ +@DisplayName("스키마 가드") +class DefaultColumnLengthGuardTest extends IntegrationTestSupport { + + /** + * varchar(255) 로 남아도 된다고 판단이 끝난 컬럼들. + * 형식은 {@code 테이블.컬럼}. 새로 추가할 때는 "이 값이 255자를 넘을 수 있는가"를 먼저 따져라. + */ + private static final Set ACKNOWLEDGED_DEFAULT_LENGTH_COLUMNS = Set.of( + // 사용자가 직접 긴 값을 넣을 수 없는 짧은 식별자·이름들 + "folder.name", + "practice_note.title", + "practice_note.repeat_type", + "problem.reference", + "problem_analysis.problem_type", + "problem_analysis.subject", + "study_room_weekly_report.cheer_message", + "study_room_weekly_report.longest_streak_name", + "study_room_weekly_report.top_member_name", + "user.email", + "user.name", + "user.password", + "user.platform", + + // 아래는 255자를 넘을 여지가 있다고 판단했으나 담당 도메인에서 별도로 다루는 중이다. + // 여기서 중복으로 실패시키지 않되, 각 도메인 테스트가 실제 길이를 검증한다. + "fcm_token.token", + "image_data.image_url", + "problem_solve_image_data.image_url", + "refresh_token.refresh_token", + "study_room.thumbnail_url", + "user.identifier", + "user.profile_image_url" + ); + + @PersistenceContext + private EntityManager entityManager; + + @Test + @DisplayName("길이를 지정하지 않은 문자열 컬럼은 모두 검토를 거친 것이어야 한다") + @SuppressWarnings("unchecked") + void everyDefaultLengthColumnIsAcknowledged() { + List rows = entityManager.createNativeQuery(""" + SELECT table_name, column_name + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND data_type = 'varchar' + AND character_maximum_length = 255 + AND table_name NOT LIKE 'QRTZ_%' + AND table_name <> 'flyway_schema_history' + ORDER BY table_name, column_name + """) + .getResultList(); + + List defaultLengthColumns = rows.stream() + .map(row -> row[0] + "." + row[1]) + .toList(); + + List unacknowledged = defaultLengthColumns.stream() + .filter(column -> !ACKNOWLEDGED_DEFAULT_LENGTH_COLUMNS.contains(column)) + .collect(Collectors.toList()); + + assertThat(unacknowledged) + .as(""" + 길이 지정 없이 varchar(255)로 생성된 컬럼이다. + 이 값이 255자를 넘을 수 있으면 @Column(length=...) 를 주고 마이그레이션을 만들어야 한다. + 255자로 충분하다면 ACKNOWLEDGED_DEFAULT_LENGTH_COLUMNS 에 추가해라.""") + .isEmpty(); + } + + @Test + @DisplayName("problem.memo 는 앱이 허용하는 1000자를 담을 수 있어야 한다") + void problemMemoFitsClientLimit() { + assertThat(columnLength("problem", "memo")) + .as("앱은 메모를 1000자까지 입력받는다. 컬럼이 더 좁으면 저장 시점에 500이 난다") + .isGreaterThanOrEqualTo(1000); + } + + /** + * 복습 리마인더는 발송 시점의 메모를 스냅샷으로 들고 있는다. + * V21 마이그레이션이 이 컬럼을 VARCHAR(255)로 못박아 뒀기 때문에, + * memo 를 넓히는 순간 스냅샷 저장에서 같은 truncation 이 재발한다. + */ + @Test + @DisplayName("리마인더 메모 스냅샷은 원본 메모를 그대로 담을 수 있어야 한다") + void reminderSnapshotFitsSourceColumn() { + assertThat(columnLength("problem_review_reminder", "problem_memo_snapshot")) + .as("스냅샷 컬럼이 원본 memo 보다 좁으면 리마인더 생성에서 500이 난다") + .isGreaterThanOrEqualTo(columnLength("problem", "memo")); + } + + private int columnLength(String tableName, String columnName) { + Number length = (Number) entityManager.createNativeQuery(""" + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = :tableName + AND column_name = :columnName + """) + .setParameter("tableName", tableName) + .setParameter("columnName", columnName) + .getSingleResult(); + return length.intValue(); + } +} From 74767bf2eb38c6ebb36e8244b925f44c480da009 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 17:14:54 +0900 Subject: [PATCH 026/130] =?UTF-8?q?[Chore]=20=EB=A1=9C=EC=BB=AC=20?= =?UTF-8?q?=EC=9D=B8=ED=94=84=EB=9D=BC=EB=A5=BC=20docker=20compose=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySQL만 컴포즈로 뜨고 Redis·RabbitMQ는 docker run 으로 따로 떠 있어서 `docker compose ps` 에 셋이 같이 안 잡혔다. 상태 확인을 매번 docker ps 로 눈으로 해야 했고, 기존 docker-compose.yml 은 redis 6379 / rabbitmq 5672 로 선언돼 있어 실제 포트(6380 / 5673)와도 어긋나 있었다. - docker-compose.local.yml 추가: MySQL/Redis/RabbitMQ 세 개만 묶고 전부 헬스체크와 restart 정책을 붙임. 포트는 application-local.yml 기준으로 맞춤 - MySQL 볼륨은 기존 backend_mysql_data 를 이름으로 명시해 데이터를 그대로 이어받음 - 비밀번호는 .env 에서 읽고 파일에는 값을 남기지 않음 - Makefile 추가: up / down / status / logs / test / coverage make status 한 번으로 세 서비스의 상태·헬스·포트를 확인할 수 있다 - docker-compose.yml 제거: 어떤 워크플로도 참조하지 않고(배포는 dev/prod 파일만 사용) 포트가 실제와 달라 오해만 유발했다 --- Makefile | 53 ++++++++++++++++ docker-compose.local.yml | 96 ++++++++++++++++++++++++++++ docker-compose.yml | 131 --------------------------------------- 3 files changed, 149 insertions(+), 131 deletions(-) create mode 100644 Makefile create mode 100644 docker-compose.local.yml delete mode 100644 docker-compose.yml diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..26e715b0 --- /dev/null +++ b/Makefile @@ -0,0 +1,53 @@ +# 로컬 개발 편의 명령. +# 인프라(MySQL/Redis/RabbitMQ)를 하나로 묶어 관리한다. + +COMPOSE := docker compose -f docker-compose.local.yml +SERVICES := mysql redis rabbitmq + +.DEFAULT_GOAL := help +.PHONY: help up down restart status logs ps test test-only coverage + +help: ## 사용 가능한 명령 보기 + @grep -hE '^[a-z-]+:.*?## ' $(MAKEFILE_LIST) \ + | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +up: ## 로컬 인프라 띄우기 (전부 healthy 될 때까지 대기) + @$(COMPOSE) up -d --wait + @$(MAKE) --no-print-directory status + +down: ## 로컬 인프라 내리기 (볼륨은 유지되어 데이터는 남는다) + @$(COMPOSE) down + +restart: ## 내렸다 다시 띄우기 + @$(MAKE) --no-print-directory down + @$(MAKE) --no-print-directory up + +status: ## 셋 다 살아있는지 한눈에 확인 + @printf '%-12s %-10s %-10s %s\n' SERVICE STATE HEALTH PORTS + @printf '%.0s-' $$(seq 1 52); printf '\n' + @for s in $(SERVICES); do \ + cid=$$($(COMPOSE) ps -q $$s 2>/dev/null); \ + if [ -z "$$cid" ]; then \ + printf '%-12s %-10s %-10s %s\n' "$$s" "down" "-" "-"; \ + else \ + state=$$(docker inspect -f '{{.State.Status}}' $$cid); \ + health=$$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' $$cid); \ + ports=$$(docker inspect -f '{{range $$p, $$b := .NetworkSettings.Ports}}{{range $$b}}{{.HostPort}} {{end}}{{end}}' $$cid); \ + printf '%-12s %-10s %-10s %s\n' "$$s" "$$state" "$$health" "$$ports"; \ + fi; \ + done + +ps: ## 컴포즈 기본 상태 출력 + @$(COMPOSE) ps + +logs: ## 인프라 로그 따라가기 (S=redis 로 특정 서비스만) + @$(COMPOSE) logs -f $(S) + +test: ## 전체 테스트 + 커버리지 리포트 + @./gradlew test jacocoTestReport + +test-only: ## 특정 테스트만 실행 (T=com.aisip.OnO.backend.tag.*) + @./gradlew test --tests "$(T)" + +coverage: ## 커버리지 리포트를 브라우저로 열기 + @open build/reports/jacoco/test/html/index.html diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..5e28438b --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,96 @@ +# 로컬 개발용 인프라 (MySQL / Redis / RabbitMQ). +# +# 그동안 MySQL만 컴포즈로 뜨고 Redis·RabbitMQ 는 docker run 으로 따로 띄워져 있었다. +# 그래서 `docker compose ps` 에 셋이 같이 안 잡히고, 매번 `docker ps` 로 눈으로 확인해야 했다. +# 셋을 한 파일로 묶고 전부 헬스체크를 달아 상태를 한 번에 볼 수 있게 한다. +# +# 포트는 application-local.yml 이 기대하는 값과 맞췄다 (MySQL 3306 / Redis 6380 / RabbitMQ 5673). +# 비밀번호는 .env 에서 읽는다. .env 는 gitignore 되어 있고 이 파일에는 값을 적지 않는다. +# +# make up 띄우기 (healthy 될 때까지 대기) +# make status 셋 다 살아있는지 한눈에 +# make down 내리기 (데이터는 남는다) +# +# 주의: 이 파일은 프로젝트 이름을 따로 지정하지 않는다. 디렉터리명(backend)이 그대로 +# 프로젝트명이 되어야 기존 MySQL 볼륨 backend_mysql_data 를 그대로 이어받는다. +# 테스트는 이 컨테이너들을 쓰지 않는다. Testcontainers 가 별도 컨테이너를 띄운다. + +services: + mysql: + image: mysql:8.0 + container_name: ono-mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?".env 에 MYSQL_ROOT_PASSWORD 가 필요하다"} + MYSQL_DATABASE: ono_db + TZ: Asia/Seoul + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --default-time-zone=+09:00 + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p\"$$MYSQL_ROOT_PASSWORD\" --silent"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + networks: + - ono-local + + redis: + image: redis:7.2-alpine + container_name: ono-redis + restart: unless-stopped + # 로컬 앱은 6380 을 본다. 컨테이너 안은 표준 6379 그대로 둔다. + ports: + - "6380:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 5s + networks: + - ono-local + + rabbitmq: + image: rabbitmq:3.13-management-alpine + container_name: ono-rabbitmq + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?".env 에 RABBITMQ_PASSWORD 가 필요하다"} + TZ: Asia/Seoul + ports: + - "5673:5672" # AMQP + - "15673:15672" # 관리 UI http://localhost:15673 + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 30s + networks: + - ono-local + +networks: + ono-local: + driver: bridge + +volumes: + # 기존에 쓰던 볼륨을 그대로 지정한다. 이름을 바꾸면 개발 DB 데이터가 사라진다. + mysql_data: + name: backend_mysql_data + redis_data: + name: ono_local_redis_data + rabbitmq_data: + name: ono_local_rabbitmq_data diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index b093a44c..00000000 --- a/docker-compose.yml +++ /dev/null @@ -1,131 +0,0 @@ -version: '3.8' - -services: - # MySQL Database - mysql: - image: mysql:8.0 - container_name: ono-mysql - restart: unless-stopped - environment: - MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} - MYSQL_DATABASE: ono_db - TZ: Asia/Seoul - ports: - - "3306:3306" - volumes: - - mysql_data:/var/lib/mysql - command: - - --character-set-server=utf8mb4 - - --collation-server=utf8mb4_unicode_ci - - --default-time-zone=+09:00 - networks: - - ono-network - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] - interval: 10s - timeout: 5s - retries: 5 - - # Redis Cache - redis: - image: redis:7.2-alpine - container_name: ono-redis - restart: unless-stopped - ports: - - "6379:6379" - volumes: - - redis_data:/data - networks: - - ono-network - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 10s - timeout: 5s - retries: 5 - command: redis-server --appendonly yes - - # RabbitMQ Message Queue - rabbitmq: - image: rabbitmq:3.13-management-alpine - container_name: ono-rabbitmq - restart: unless-stopped - ports: - - "5672:5672" # AMQP 프로토콜 - - "15672:15672" # 관리 UI - environment: - RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-admin} - RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} - TZ: Asia/Seoul - volumes: - - rabbitmq_data:/var/lib/rabbitmq - networks: - - ono-network - healthcheck: - test: ["CMD", "rabbitmq-diagnostics", "ping"] - interval: 10s - timeout: 5s - retries: 5 - - # Spring Boot Application - app: - image: ${DOCKER_USERNAME}/ono:latest - container_name: ono-app - restart: unless-stopped - ports: - - "8080:8080" - environment: - SPRING_PROFILES_ACTIVE: prod - SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/ono_db?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - SPRING_DATASOURCE_USERNAME: root - SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD} - SPRING_DATA_REDIS_HOST: redis - SPRING_DATA_REDIS_PORT: 6379 - SPRING_RABBITMQ_HOST: rabbitmq - SPRING_RABBITMQ_PORT: 5672 - SPRING_RABBITMQ_USERNAME: ${RABBITMQ_USER:-admin} - SPRING_RABBITMQ_PASSWORD: ${RABBITMQ_PASSWORD:-admin} - # 추가 환경 변수는 .env 파일에서 주입 - SPRING_CRYPTO_SECRET_KEY: ${SPRING_CRYPTO_SECRET_KEY} - JWT_ACCESS_TOKEN_SECRET: ${JWT_ACCESS_TOKEN_SECRET} - JWT_ACCESS_TOKEN_EXPIRATION: ${JWT_ACCESS_TOKEN_EXPIRATION} - JWT_REFRESH_TOKEN_SECRET: ${JWT_REFRESH_TOKEN_SECRET} - JWT_REFRESH_TOKEN_EXPIRATION: ${JWT_REFRESH_TOKEN_EXPIRATION} - CLOUD_AWS_S3_BUCKET: ${CLOUD_AWS_S3_BUCKET} - CLOUD_AWS_CREDENTIALS_ACCESS_KEY: ${CLOUD_AWS_CREDENTIALS_ACCESS_KEY} - CLOUD_AWS_CREDENTIALS_SECRET_KEY: ${CLOUD_AWS_CREDENTIALS_SECRET_KEY} - DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL} - OPENAI_API_KEY: ${OPENAI_API_KEY} - SENTRY_DSN: ${SENTRY_DSN} - ADMIN_IDENTIFIER: ${ADMIN_IDENTIFIER} - ADMIN_PASSWORD: ${ADMIN_PASSWORD} - volumes: - - app_logs:/app/logs - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - rabbitmq: - condition: service_healthy - networks: - - ono-network - healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/actuator/health || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - start_period: 60s - -networks: - ono-network: - driver: bridge - -volumes: - mysql_data: - driver: local - redis_data: - driver: local - rabbitmq_data: - driver: local - app_logs: - driver: local \ No newline at end of file From d2049fc61c5f109ae9aea094c203b9661244c159 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 2 Sep 2026 19:17:26 +0900 Subject: [PATCH 027/130] =?UTF-8?q?[Test]=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=8C=80=ED=8F=AD=20=ED=99=95?= =?UTF-8?q?=EC=B6=A9=20=EB=B0=8F=20=ED=94=84=EB=A1=9C=EB=8D=95=EC=85=98=20?= =?UTF-8?q?=EA=B2=B0=ED=95=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 274개 -> 1396개. 기존 스위트는 74개에서 OOM으로 중단됐으나 이제 완주한다. tag, problemsolve 등 테스트가 0개이던 도메인과 auth 토큰 계약, 예외 처리 경로를 채웠다. 라인 커버리지 74.7%, 클래스 커버리지 88.2%. 프로덕션 결함 수정: - 검증 애노테이션이 전부 무동작이었다. jakarta.validation-api 만 클래스패스에 있고 구현체가 없어 @NotNull/@Valid 20곳이 무시됐고, null 요청이 그대로 서비스에 들어가 500이 됐다. spring-boot-starter-validation 추가 - MethodArgumentNotValidException 핸들러가 첫 인자로 BindingResult 를 받고 있었다. @ExceptionHandler 가 지원하지 않는 인자라 핸들러가 해석되지 못했고, 검증 실패가 400 이 아니라 500 으로 나갔다 - DataIntegrityViolationException 핸들러 부재로 태그 중복·컬럼 길이 초과가 500이 됐다. 중복은 409, 길이 초과는 400 으로 분류 - 태그: 소프트 삭제된 태그가 유니크 인덱스에서 이름을 계속 점유해, 지웠다가 같은 이름으로 다시 만들면 Duplicate entry 로 500. 삭제된 행을 복구하도록 수정하고 동시 생성 경합도 처리 - problem.memo 길이 제약 부재로 256자 이상 저장 시 Data truncation 500. 길이 지정 + 입력 검증 추가 - registerProblem 의 folderId null, ProblemSolve 의 problemId/answerStatus null 이 findById(null) 로 500. 400 으로 거절 - refresh_token 컬럼이 varchar(255)인데 발급 JWT 실측 244자. 512로 확장 - identifier 가 비어도 가입이 통과돼 로그인마다 새 계정이 생성됐다. 검증 추가 - CryptoConverter 가 null 입력에서 NPE. AttributeConverter 계약대로 null 통과 - getReviewDueProblems 의 N+1 제거. mappedBy OneToOne 이 행마다 존재 확인 쿼리를 발생시켜 프로젝션 조회로 교체 - Discord 웹훅 dedup 맵이 무한 증가하던 것에 상한 적용 테스트 기반 수정: - 컨테이너 접속 정보를 ContextCustomizerFactory 로 전역 주입. 베이스 클래스를 상속하지 않은 테스트가 datasource 를 못 받아 컨텍스트 로드 실패가 138건 발생했다 - authenticateAs 를 TestSecurityContextHolder 기반으로 교체. SecurityContextHolder 는 MockMvc 요청에서 필터가 덮어써 인증이 필요한 요청이 전부 401 이 됐다 - DatabaseCleaner 가 테이블을 비우는 방식을 DDL 에서 DML 로 교체. 테스트마다 31개 테이블에 DDL 을 거는 방식은 스위트를 완주 불가로 만들었다. 시퀀스 테이블은 시드가 사라지지 않도록 대상에서 제외 - jacocoTestReport 의 dependsOn test 제거. 테스트가 실패하면 리포트가 건너뛰어져 낡은 커버리지가 남았다 Flyway 마이그레이션 V23~V25 추가 (컬럼 확장, 미적용): - ALGORITHM=INPLACE, LOCK=NONE 명시로 COPY 로 내려갈 때 즉시 실패하게 함 - 테이블별로 파일 분리해 중간 실패 시 상태 추적이 가능하도록 함 - 실패 복구 절차와 롤백 비용을 파일에 명시 --- build.gradle | 8 +- .../OnO/backend/auth/entity/RefreshToken.java | 7 +- .../backend/auth/service/JwtTokenService.java | 5 +- .../backend/auth/service/JwtTokenizer.java | 14 +- .../exception/GlobalExceptionHandler.java | 73 +- .../common/ratelimit/RateLimitAspect.java | 16 +- .../common/service/CryptoConverter.java | 8 + .../problem/dto/ReviewDueResponseDto.java | 16 +- .../OnO/backend/problem/entity/Problem.java | 11 + .../problem/exception/ProblemErrorCase.java | 8 +- .../reminder/ProblemReviewReminder.java | 4 + .../problem/repository/ProblemRepository.java | 10 +- .../ReviewDueProblemProjection.java | 20 + .../problem/service/ProblemService.java | 36 +- .../exception/ProblemSolveErrorCase.java | 7 +- .../service/ProblemSolveService.java | 30 +- .../backend/tag/repository/TagRepository.java | 25 + .../OnO/backend/tag/service/TagService.java | 42 +- .../OnO/backend/tag/service/TagWriter.java | 65 + .../backend/user/exception/UserErrorCase.java | 8 +- .../OnO/backend/user/service/UserService.java | 20 + .../fileupload/service/FileUploadService.java | 20 +- .../DiscordWebhookNotificationService.java | 25 +- .../db/migration/V23__widen_problem_memo.sql | 58 + .../V24__widen_reminder_memo_snapshot.sql | 54 + .../db/migration/V25__widen_refresh_token.sql | 61 + .../backend/auth/controller/AuthApiTest.java | 541 ++++ .../auth/controller/AuthControllerTest.java | 93 - .../auth/service/JwtTokenServiceTest.java | 328 ++- .../auth/service/JwtTokenizerTest.java | 360 +++ .../UserAuthServiceIntegrationTest.java | 223 ++ .../auth/service/UserAuthServiceTest.java | 139 ++ .../auth/token/RedisTokenServiceTest.java | 154 ++ .../backend/common/aop/LoggingAspectTest.java | 101 + .../emoji/CustomEmojiValidatorTest.java | 90 + .../exception/ErrorCaseContractTest.java | 209 +- .../exception/GlobalExceptionHandlerTest.java | 463 ++++ .../common/ratelimit/RateLimitAspectTest.java | 160 ++ .../RateLimitServiceFailOpenTest.java | 58 + .../ratelimit/RateLimitServiceTest.java | 174 ++ .../common/response/CommonResponseTest.java | 130 + .../common/service/CryptoConverterTest.java | 93 + .../common/service/CryptoServiceTest.java | 146 ++ .../controller/ProblemControllerTest.java | 881 +++++-- .../ProblemApiIntegrationTest.java | 940 +++---- .../ProblemReviewReminderServiceTest.java | 721 +++--- .../repository/ProblemRepositoryTest.java | 440 +++- .../service/ProblemAnalysisServiceTest.java | 497 ++++ .../problem/service/ProblemServiceTest.java | 2170 ++++++++++++----- .../service/ReviewIntervalCalculatorTest.java | 170 ++ .../problem/support/ProblemTestSupport.java | 138 ++ .../problemsolve/ProblemSolveTestSupport.java | 86 + .../ProblemSolveControllerTest.java | 474 ++++ .../ProblemSolveRepositoryTest.java | 295 +++ .../service/ProblemSolveServiceTest.java | 607 +++++ .../schema/DefaultColumnLengthGuardTest.java | 3 +- .../OnO/backend/support/DatabaseCleaner.java | 14 +- .../support/IntegrationTestSupport.java | 33 +- .../OnO/backend/support/TestContainers.java | 19 + ...estContainersContextCustomizerFactory.java | 57 + .../aisip/OnO/backend/tag/TagTestSupport.java | 104 + .../tag/controller/TagControllerTest.java | 320 +++ .../ProblemTagMappingRepositoryTest.java | 159 ++ .../tag/repository/TagRepositoryTest.java | 226 ++ .../tag/service/TagServiceCacheTest.java | 180 ++ .../backend/tag/service/TagServiceTest.java | 539 ++++ .../backend/user/controller/UserApiTest.java | 289 +++ .../user/controller/UserControllerTest.java | 116 - .../backend/user/dto/UserResponseDtoTest.java | 158 +- .../integration/UserApiIntegrationTest.java | 133 - .../user/repository/UserRepositoryTest.java | 293 +++ .../service/UserServiceIntegrationTest.java | 230 ++ .../backend/user/service/UserServiceTest.java | 711 ++++-- .../OnO/backend/util/ai/OpenAIClientTest.java | 364 +++ .../repository/FcmTokenRepositoryTest.java | 203 ++ .../util/fcm/service/FcmServiceTest.java | 311 +++ .../service/FileUploadServiceTest.java | 415 ++++ .../redis/RedisSingleDataServiceTest.java | 162 ++ .../util/redis/RedisTokenServiceTest.java | 183 ++ .../util/redis/StreakCacheServiceTest.java | 191 ++ ...DiscordWebhookNotificationServiceTest.java | 211 ++ src/test/resources/META-INF/spring.factories | 2 + 82 files changed, 14486 insertions(+), 2672 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java create mode 100644 src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java create mode 100644 src/main/resources/db/migration/V23__widen_problem_memo.sql create mode 100644 src/main/resources/db/migration/V24__widen_reminder_memo_snapshot.sql create mode 100644 src/main/resources/db/migration/V25__widen_refresh_token.sql create mode 100644 src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandlerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspectTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitServiceFailOpenTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/ratelimit/RateLimitServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/response/CommonResponseTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/service/CryptoConverterTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/service/CryptoServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problem/service/ProblemAnalysisServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problem/service/ReviewIntervalCalculatorTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problem/support/ProblemTestSupport.java create mode 100644 src/test/java/com/aisip/OnO/backend/problemsolve/ProblemSolveTestSupport.java create mode 100644 src/test/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/support/TestContainersContextCustomizerFactory.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/TagTestSupport.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/controller/TagControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/repository/ProblemTagMappingRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/repository/TagRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/service/TagServiceCacheTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/tag/service/TagServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/user/controller/UserApiTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/user/controller/UserControllerTest.java delete mode 100644 src/test/java/com/aisip/OnO/backend/user/integration/UserApiIntegrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/user/repository/UserRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/user/service/UserServiceIntegrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/ai/OpenAIClientTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/redis/RedisSingleDataServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/redis/RedisTokenServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/redis/StreakCacheServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationServiceTest.java create mode 100644 src/test/resources/META-INF/spring.factories diff --git a/build.gradle b/build.gradle index 7ad629db..97d1c558 100644 --- a/build.gradle +++ b/build.gradle @@ -34,6 +34,10 @@ jar { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa:3.1.0' implementation 'org.springframework.boot:spring-boot-starter-web' + // Bean Validation 구현체. 이게 없으면 jakarta.validation-api 만 클래스패스에 있어서 + // 코드에 붙은 @NotNull/@Valid/@Validated 가 전부 무시되고, 잘못된 요청이 검증 없이 + // 서비스로 들어가 500 이 된다. + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'net.logstash.logback:logstash-logback-encoder:7.4' implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' @@ -123,7 +127,9 @@ def coverageExcludes = [ ] tasks.named('jacocoTestReport') { - dependsOn tasks.named('test') + // dependsOn test 를 걸면 안 된다. 테스트가 하나라도 실패하면 의존 태스크가 실패로 간주되어 + // 리포트 태스크가 통째로 건너뛰어지고, 직전 실행의 낡은 리포트가 그대로 남는다. + // test 의 finalizedBy 로 이미 순서가 보장되며, 실패한 실행에서도 커버리지는 나와야 한다. reports { xml.required = true html.required = true diff --git a/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java b/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java index 4b2a5b97..321df4fa 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java +++ b/src/main/java/com/aisip/OnO/backend/auth/entity/RefreshToken.java @@ -26,7 +26,12 @@ public class RefreshToken { @Column(nullable = false, length = 50) private Authority authority; - @Column(nullable = false) + /** + * 원본 JWT 를 그대로 담는다. 현재 발급 형태(authority·sub·iat·jti·exp)만으로 최대 244자라 + * 기본 길이(varchar 255)로는 클레임이 하나만 늘어도 넘친다. + * 저장이 잘리면 이후 갱신 요청이 세션을 찾지 못해 REFRESH_TOKEN_NOT_FOUND(1002) 가 된다. + */ + @Column(nullable = false, length = 512) private String refreshToken; public static RefreshToken from(Long userId, Authority authority, String refreshToken) { diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java index a16c1d0c..2a4a66e4 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java @@ -65,8 +65,11 @@ public TokenResponseDto refreshAccessToken(String refreshToken) { * ✅ 로그아웃 처리 (Redis에서 RefreshToken 삭제 및 AccessToken 블랙리스트 추가) */ public void logout(String accessToken, Long userId, String refreshToken) { + // 자신의 세션만 지울 수 있다. 토큰 문자열만 알면 남의 세션을 끊을 수 있으면 안 된다. if (refreshToken != null && !refreshToken.isBlank()) { - refreshTokenRepository.deleteByRefreshToken(refreshToken); + refreshTokenRepository.findByRefreshToken(refreshToken) + .filter(session -> session.getUserId().equals(userId)) + .ifPresent(refreshTokenRepository::delete); } // AccessToken을 블랙리스트에 추가 (만료된 토큰이면 블랙리스트 추가 안 함) try { diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java index ed68e51c..8b929f5a 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenizer.java @@ -75,12 +75,24 @@ public String createRefreshToken(String subject, Map claims) { .compact(); } + /** + * 액세스 토큰을 검증한다. + * + *

만료만은 {@link ExpiredJwtException} 을 그대로 올려보낸다. + * JwtTokenFilter 가 이 예외를 잡아 {@code ACCESS_TOKEN_EXPIRED(1005)} 로 응답하고, + * 프론트는 1005 를 받아야 토큰 갱신을 시도한다. + * 만료를 ApplicationException 으로 감싸면 필터가 만료를 구분하지 못해 + * {@code AUTHENTICATION_FAILED(1007)} 로 나가고, 프론트는 갱신 없이 인증 실패로 처리한다. + */ public void validateAccessToken(String token) { try{ Jwts.parserBuilder().setSigningKey(accessKey).build().parseClaimsJws(token); + } catch (ExpiredJwtException e) { + log.warn("엑세스 토큰 만료: {}", e.getMessage()); + throw e; } catch (Exception e) { log.warn("엑세스 토큰 검증 실패: {}", e.getMessage()); - throw new ApplicationException(AuthErrorCase.ACCESS_TOKEN_EXPIRED); + throw new ApplicationException(AuthErrorCase.INVALID_ACCESS_TOKEN); } } diff --git a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java index b03db3f8..3b34df70 100644 --- a/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/aisip/OnO/backend/common/exception/GlobalExceptionHandler.java @@ -6,6 +6,8 @@ import lombok.extern.slf4j.Slf4j; import jakarta.validation.ConstraintViolationException; import org.slf4j.MDC; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.DuplicateKeyException; import org.springframework.http.*; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.validation.BindException; @@ -43,11 +45,21 @@ public ResponseEntity handleApplicationException(ApplicationExce .body(commonResponse); } + /** + * 요청 본문 검증 실패 처리. + * + *

이전 시그니처는 첫 인자로 {@link BindingResult} 를 받고 있었다. @ExceptionHandler 는 + * BindingResult 를 인자로 지원하지 않아 핸들러 자체가 해석되지 못했고, 그 결과 모든 + * @Valid 검증 실패가 400 이 아니라 마지막 Exception 핸들러로 떨어져 500 으로 나갔다. + * 검증 실패는 클라이언트 입력 오류이므로 400 이어야 한다. + */ @ExceptionHandler(value = MethodArgumentNotValidException.class) - public ResponseEntity handleValidException(BindingResult bindingResult, - MethodArgumentNotValidException ex, + public ResponseEntity handleValidException(MethodArgumentNotValidException ex, WebRequest request) { - String message = bindingResult.getAllErrors().get(0).getDefaultMessage(); + BindingResult bindingResult = ex.getBindingResult(); + String message = bindingResult.getAllErrors().isEmpty() + ? "잘못된 요청입니다." + : bindingResult.getAllErrors().get(0).getDefaultMessage(); CommonResponse commonResponse = CommonResponse.error(400, message); putErrorMdc(400, ex); @@ -80,6 +92,61 @@ public ResponseEntity handleBadRequestException(Exception ex, We return handleSpringStatusException(ex, request, HttpStatus.BAD_REQUEST, "잘못된 요청입니다."); } + /** + * DB 제약 조건 위반은 서버 결함이 아니라 클라이언트 입력 문제다. + * + *

핸들러가 없던 시절에는 태그 중복 생성({@code Duplicate entry ... for key 'tag.idx_tag_user_normalized'})과 + * 컬럼 길이 초과({@code Data truncation: Data too long for column})가 전부 500으로 나가면서 + * Discord 에러 알림까지 발송됐다. 원인별로 4xx로 분류해 응답한다. + */ + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex, + WebRequest request) { + HttpStatus status = resolveDataIntegrityStatus(ex); + String message = resolveDataIntegrityMessage(ex, status); + CommonResponse commonResponse = CommonResponse.error(status.value(), message); + + putErrorMdc(status.value(), ex); + logByStatus(status, "Data integrity exception handled", rootCauseMessage(ex), ex); + + return ResponseEntity + .status(status) + .body(commonResponse); + } + + private HttpStatus resolveDataIntegrityStatus(DataIntegrityViolationException ex) { + if (ex instanceof DuplicateKeyException || isDuplicateEntry(ex)) { + return HttpStatus.CONFLICT; + } + return HttpStatus.BAD_REQUEST; + } + + private String resolveDataIntegrityMessage(DataIntegrityViolationException ex, HttpStatus status) { + if (status == HttpStatus.CONFLICT) { + return "이미 존재하는 데이터입니다."; + } + + String cause = rootCauseMessage(ex).toLowerCase(); + if (cause.contains("data too long") || cause.contains("data truncation")) { + return "입력값이 허용된 길이를 초과했습니다."; + } + if (cause.contains("cannot be null") || cause.contains("not-null")) { + return "필수 입력값이 누락되었습니다."; + } + return "요청 데이터가 저장 조건을 만족하지 않습니다."; + } + + private boolean isDuplicateEntry(DataIntegrityViolationException ex) { + String cause = rootCauseMessage(ex).toLowerCase(); + return cause.contains("duplicate entry") || cause.contains("unique constraint"); + } + + private String rootCauseMessage(DataIntegrityViolationException ex) { + Throwable cause = ex.getMostSpecificCause(); + String message = cause == null ? ex.getMessage() : cause.getMessage(); + return message == null ? "" : message; + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex, WebRequest request) { if (ex instanceof ErrorResponse errorResponse) { diff --git a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java index d4612dea..a5e80841 100644 --- a/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java +++ b/src/main/java/com/aisip/OnO/backend/common/ratelimit/RateLimitAspect.java @@ -1,10 +1,12 @@ package com.aisip.OnO.backend.common.ratelimit; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import lombok.RequiredArgsConstructor; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Before; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; @@ -17,9 +19,21 @@ public class RateLimitAspect { @Before("@annotation(rateLimit)") public void checkRateLimit(RateLimit rateLimit) { - Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + Long userId = resolveUserId(); if (!rateLimitService.tryConsume(rateLimit.key(), userId, rateLimit.limitPerDay())) { throw new ApplicationException(ProblemErrorCase.ANALYSIS_RATE_LIMIT_EXCEEDED); } } + + /** + * 인증 정보가 없으면 사용자별 한도를 셀 수 없다. + * 예전에는 여기서 NPE/ClassCastException 이 나 500 으로 나갔으므로 401 로 명시한다. + */ + private Long resolveUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getPrincipal() instanceof Long userId)) { + throw new ApplicationException(AuthErrorCase.AUTHENTICATION_FAILED); + } + return userId; + } } diff --git a/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java b/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java index ccca4d01..f5058510 100644 --- a/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java +++ b/src/main/java/com/aisip/OnO/backend/common/service/CryptoConverter.java @@ -19,6 +19,11 @@ public CryptoConverter(CryptoService cryptoService) { @Override public String convertToDatabaseColumn(String attribute) { + // AttributeConverter 계약상 null 은 그대로 통과시켜야 한다. + // 예전에는 여기서 NPE 가 나 RuntimeException("암호화 오류") 로 감싸져 500 이 됐다. + if (attribute == null) { + return null; + } try { return cryptoService.encrypt(attribute); // ✅ static 제거된 메서드 사용 } catch (Exception e) { @@ -28,6 +33,9 @@ public String convertToDatabaseColumn(String attribute) { @Override public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } try { return cryptoService.decrypt(dbData); // ✅ static 제거된 메서드 사용 } catch (Exception e) { diff --git a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java index 4f0dc8bb..2ff8c329 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problem/dto/ReviewDueResponseDto.java @@ -1,6 +1,6 @@ package com.aisip.OnO.backend.problem.dto; -import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection; import lombok.Builder; import java.time.LocalDate; @@ -21,14 +21,14 @@ public record ReviewDueProblemDto( int reviewInterval, int consecutiveCorrectCount ) { - public static ReviewDueProblemDto from(Problem problem) { + public static ReviewDueProblemDto from(ReviewDueProblemProjection projection) { return ReviewDueProblemDto.builder() - .problemId(problem.getId()) - .memo(problem.getMemo()) - .reference(problem.getReference()) - .nextReviewAt(problem.getNextReviewAt()) - .reviewInterval(problem.getReviewInterval()) - .consecutiveCorrectCount(problem.getConsecutiveCorrectCount()) + .problemId(projection.problemId()) + .memo(projection.memo()) + .reference(projection.reference()) + .nextReviewAt(projection.nextReviewAt()) + .reviewInterval(projection.reviewInterval()) + .consecutiveCorrectCount(projection.consecutiveCorrectCount()) .build(); } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java index 6fa22a86..5ed03a53 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java +++ b/src/main/java/com/aisip/OnO/backend/problem/entity/Problem.java @@ -28,6 +28,15 @@ }) public class Problem extends BaseEntity { + /** + * 앱이 입력 필드에서 허용하는 메모 길이와 동일하다. + * 길이 제약을 명시하지 않으면 MySQL에서 varchar(255)로 생성돼 + * 256자 이상 입력 시 Data truncation 으로 500이 난다. + */ + public static final int MEMO_MAX_LENGTH = 1000; + + public static final int REFERENCE_MAX_LENGTH = 255; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -38,8 +47,10 @@ public class Problem extends BaseEntity { @JoinColumn(name = "folder_id") private Folder folder; + @Column(length = MEMO_MAX_LENGTH) private String memo; + @Column(length = REFERENCE_MAX_LENGTH) private String reference; private LocalDateTime solvedAt; diff --git a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java index b6b2f149..22663dda 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problem/exception/ProblemErrorCase.java @@ -16,7 +16,13 @@ public enum ProblemErrorCase implements ErrorCase { PROBLEM_ANALYSIS_NOT_FOUND(404, 4004, "문제 분석 결과를 찾을 수 없습니다."), - ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."); + ANALYSIS_RATE_LIMIT_EXCEEDED(429, 4005, "AI 분석 일일 요청 횟수를 초과했습니다."), + + PROBLEM_MEMO_TOO_LONG(400, 4006, "메모는 1000자를 넘을 수 없습니다."), + + PROBLEM_REFERENCE_TOO_LONG(400, 4007, "출처는 255자를 넘을 수 없습니다."), + + PROBLEM_FOLDER_ID_REQUIRED(400, 4008, "폴더 ID는 필수입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java index c44eb685..3d68ecea 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminder.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.problem.reminder; import com.aisip.OnO.backend.common.entity.BaseEntity; +import com.aisip.OnO.backend.problem.entity.Problem; import jakarta.persistence.*; import lombok.*; @@ -30,8 +31,11 @@ public class ProblemReviewReminder extends BaseEntity { @Column(nullable = false) private Long problemId; + /** Problem.memo 를 그대로 복사하므로 길이 제약도 같이 맞춰야 한다. */ + @Column(length = Problem.MEMO_MAX_LENGTH) private String problemMemoSnapshot; + @Column(length = Problem.REFERENCE_MAX_LENGTH) private String problemReferenceSnapshot; @Column(nullable = false) diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java index fdd8c3c9..f743da5b 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemRepository.java @@ -12,8 +12,14 @@ public interface ProblemRepository extends JpaRepository, Problem Long countByUserId(Long userId); - @Query("SELECT p FROM Problem p WHERE p.userId = :userId AND p.nextReviewAt <= :today ORDER BY p.nextReviewAt ASC") - List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); + @Query(""" + SELECT new com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection( + p.id, p.memo, p.reference, p.nextReviewAt, p.reviewInterval, p.consecutiveCorrectCount) + FROM Problem p + WHERE p.userId = :userId AND p.nextReviewAt <= :today + ORDER BY p.nextReviewAt ASC + """) + List findReviewDueProblems(@Param("userId") Long userId, @Param("today") LocalDate today); @Query("SELECT p.userId as userId, COUNT(p) as dueCount FROM Problem p WHERE p.nextReviewAt <= :today GROUP BY p.userId") List findReviewDueSummaryByDate(@Param("today") LocalDate today); diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java new file mode 100644 index 00000000..d87b9481 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ReviewDueProblemProjection.java @@ -0,0 +1,20 @@ +package com.aisip.OnO.backend.problem.repository; + +import java.time.LocalDate; + +/** + * 복습 대상 문제 조회 전용 프로젝션. + * + *

엔티티로 조회하면 {@code Problem.problemAnalysis} 가 mappedBy OneToOne 이라 + * 행마다 존재 여부를 확인하는 select 가 추가로 나간다. 응답에 필요한 값은 스칼라뿐이므로 + * 이 프로젝션으로 단일 쿼리로 읽는다. + */ +public record ReviewDueProblemProjection( + Long problemId, + String memo, + String reference, + LocalDate nextReviewAt, + int reviewInterval, + int consecutiveCorrectCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 5d67d253..26580317 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -28,6 +28,7 @@ import com.aisip.OnO.backend.problem.dto.ReviewDueResponseDto; import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.repository.ReviewDueProblemProjection; import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveSummary; @@ -209,6 +210,13 @@ public Long findProblemCountByUser(Long userId) { @Transactional public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) { + validateProblemContent(problemRegisterDto.memo(), problemRegisterDto.reference()); + + // folderId 가 null 이면 findById(null) 이 InvalidDataAccessApiUsageException 을 던져 500 이 나간다. + // 입력 누락은 서버 오류가 아니므로 400 으로 거절한다. + if (problemRegisterDto.folderId() == null) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_FOLDER_ID_REQUIRED); + } Folder folder = folderRepository.findById(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); @@ -240,6 +248,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) */ @Transactional public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long userId) { + validateProblemContent(problemRegisterV2Dto.memo(), problemRegisterV2Dto.reference()); Folder folder = resolveRegisterFolder(problemRegisterV2Dto.folderId(), userId); ProblemRegisterDto baseDto = new ProblemRegisterDto( @@ -307,6 +316,8 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 return List.of(); } + registerDtos.forEach(dto -> validateProblemContent(dto.memo(), dto.reference())); + Map foldersById = resolveRegisterFolders(registerDtos, userId); Folder rootFolder = registerDtos.stream().anyMatch(dto -> dto.folderId() == null) ? resolveRootFolder(userId) @@ -592,6 +603,7 @@ public void updateProblemAnalysisToNoImage(Long problemId, Long userId) { @Transactional public void updateProblemInfo(ProblemRegisterDto problemRegisterDto, Long userId) { + validateProblemContent(problemRegisterDto.memo(), problemRegisterDto.reference()); Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); @@ -929,6 +941,19 @@ private List toProblemResponseDtos(List problems) { .collect(Collectors.toList()); } + /** + * 컬럼 길이를 넘는 입력은 DB에서 Data truncation 으로 500 이 나므로 저장 전에 400 으로 거절한다. + */ + private void validateProblemContent(String memo, String reference) { + if (memo != null && memo.length() > Problem.MEMO_MAX_LENGTH) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_MEMO_TOO_LONG); + } + + if (reference != null && reference.length() > Problem.REFERENCE_MAX_LENGTH) { + throw new ApplicationException(ProblemErrorCase.PROBLEM_REFERENCE_TOO_LONG); + } + } + private void validateFolderOwner(Long folderId, Long userId) { Folder folder = folderRepository.findById(folderId) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); @@ -941,13 +966,20 @@ private void validateFolderOwner(Folder folder, Long userId) { } } + /** + * 오늘 복습 대상 문제 조회. + * + *

엔티티를 그대로 조회하면 {@code Problem.problemAnalysis} 가 mappedBy OneToOne 이라 + * 행마다 존재 여부 확인 쿼리가 한 번씩 더 나간다(N+1). 응답에 필요한 값은 스칼라뿐이므로 + * 프로젝션으로 한 번에 읽는다. + */ @Transactional(readOnly = true) public ReviewDueResponseDto getReviewDueProblems(Long userId) { LocalDate today = LocalDate.now(java.time.ZoneId.of("Asia/Seoul")); - List dueProblems = problemRepository.findReviewDueProblems(userId, today); + List dueProblems = problemRepository.findReviewDueProblems(userId, today); long overdueCount = dueProblems.stream() - .filter(p -> p.getNextReviewAt().isBefore(today)) + .filter(p -> p.nextReviewAt().isBefore(today)) .count(); List problemDtos = dueProblems.stream() diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java index 1f941ca3..dbf4f7ce 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/exception/ProblemSolveErrorCase.java @@ -9,7 +9,12 @@ public enum ProblemSolveErrorCase implements ErrorCase { PROBLEM_SOLVE_NOT_FOUND(404, 4021, "복습 기록을 찾을 수 없습니다."), - PROBLEM_SOLVE_USER_UNMATCHED(403, 4022, "해당 복습 기록에 대한 권한이 없습니다."); + PROBLEM_SOLVE_USER_UNMATCHED(403, 4022, "해당 복습 기록에 대한 권한이 없습니다."), + + /** + * 필수 입력이 빠진 요청. 예전에는 그대로 NPE / 제약조건 위반으로 터져 500 이 나갔다. + */ + PROBLEM_SOLVE_INVALID_INPUT(400, 4023, "복습 기록 요청 값이 올바르지 않습니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index af04f891..5d7b46d3 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -105,6 +105,12 @@ public List getUserProblemSolves(Long userId) { @Transactional public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { + // problemId 가 없으면 findById(null) 이 InvalidDataAccessApiUsageException 으로 터져 500 이 나간다. + // answerStatus 가 없으면 복습 주기 계산에서 NPE, 저장 시 not-null 위반으로 역시 500 이다. + if (dto == null || dto.problemId() == null || dto.answerStatus() == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + Problem problem = problemRepository.findById(dto.problemId()) .orElseThrow(() -> new ApplicationException(ProblemErrorCase.PROBLEM_NOT_FOUND)); @@ -123,10 +129,15 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { } } + // practicedAt 은 not-null 컬럼이다. 앱이 값을 안 보내면 "지금 푼 것"으로 본다. + LocalDateTime practicedAt = dto.practicedAt() != null + ? dto.practicedAt() + : LocalDateTime.now(ZoneId.of("Asia/Seoul")); + ProblemSolve problemSolve = ProblemSolve.create( problem, userId, - dto.practicedAt(), + practicedAt, dto.answerStatus(), dto.reflection(), improvementsJson, @@ -146,9 +157,6 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PRACTICE_COMPLETED, java.util.Map.of())); - LocalDateTime practicedAt = dto.practicedAt() != null - ? dto.practicedAt() - : LocalDateTime.now(ZoneId.of("Asia/Seoul")); reminderService.skipDuePendingByProblemSolve(userId, dto.problemId(), practicedAt); log.info("userId: {} created problem solve: {}, nextReviewAt: {}, mastered: {}", @@ -159,6 +167,10 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { @Transactional public void uploadProblemSolveImages(Long problemSolveId, Long userId, List images) { + if (images == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(problemSolveId) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); @@ -191,6 +203,10 @@ public void uploadProblemSolveImages(Long problemSolveId, Long userId, List imageUrls) { + if (imageUrls == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(problemSolveId) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); if (!Objects.equals(problemSolve.getUserId(), userId)) { @@ -209,6 +225,12 @@ public void addImageUrls(Long problemSolveId, Long userId, List imageUrl @Transactional public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { + // problemSolveId 가 없으면 findById(null) 이 500 으로, answerStatus 가 없으면 + // not-null 컬럼 위반으로 500 이 난다. 둘 다 클라이언트 입력 오류다. + if (dto == null || dto.problemSolveId() == null || dto.answerStatus() == null) { + throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT); + } + ProblemSolve problemSolve = problemSolveRepository.findById(dto.problemSolveId()) .orElseThrow(() -> new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND)); diff --git a/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java index bdfb4576..e6178fab 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java +++ b/src/main/java/com/aisip/OnO/backend/tag/repository/TagRepository.java @@ -2,6 +2,10 @@ import com.aisip.OnO.backend.tag.entity.Tag; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Optional; @@ -13,4 +17,25 @@ public interface TagRepository extends JpaRepository { List findAllByUserIdOrderByNameAsc(Long userId); List findAllByIdInAndUserId(List ids, Long userId); + + /** + * 소프트 삭제된 동명 태그를 되살린다. + * + *

{@code idx_tag_user_normalized} 는 {@code deleted_at} 을 포함하지 않아 + * 삭제된 행도 이름을 계속 점유한다. 그래서 "삭제 후 같은 이름으로 재생성"이 + * Duplicate entry 로 실패하는데, 새로 넣는 대신 기존 행을 복구해 이를 피한다. + * + *

{@code @SQLRestriction("deleted_at IS NULL")} 때문에 JPQL 로는 삭제된 행에 + * 접근할 수 없어 네이티브 쿼리를 쓴다. + * + * @return 복구된 행 수 (0 이면 되살릴 태그가 없었다는 뜻) + */ + @Transactional + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(value = "UPDATE tag SET deleted_at = NULL, name = :name, updated_at = NOW(6) " + + "WHERE user_id = :userId AND normalized_name = :normalizedName AND deleted_at IS NOT NULL", + nativeQuery = true) + int restoreDeletedTag(@Param("userId") Long userId, + @Param("name") String name, + @Param("normalizedName") String normalizedName); } diff --git a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java index 6e3dbc9c..3f277109 100644 --- a/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagService.java @@ -15,7 +15,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; @@ -40,21 +42,43 @@ public class TagService { private final TagRepository tagRepository; private final ProblemTagMappingRepository problemTagMappingRepository; + private final TagWriter tagWriter; private final RedisSingleDataService redisSingleDataService; private final ObjectMapper objectMapper; + /** + * 태그를 만들거나, 이미 같은 이름이 있으면 그 태그를 그대로 돌려준다. + * + *

여기서 트랜잭션을 열지 않는 이유가 있다. 삽입은 (user_id, normalized_name) + * 유니크 인덱스와 경쟁하는데, 실패한 트랜잭션 안에서는 재조회로 복구할 수 없다. + * 조회/삽입을 각각 독립 트랜잭션으로 수행하는 {@link TagWriter} 에 맡기고, + * 여기서는 실패 시 재조회 판단만 한다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) public TagResponseDto createTag(Long userId, TagCreateRequestDto requestDto) { - String tagName = normalizeDisplayName(requestDto.name()); + String tagName = normalizeDisplayName(requestDto == null ? null : requestDto.name()); String normalizedName = tagName.toLowerCase(Locale.ROOT); - Tag tag = tagRepository.findByUserIdAndNormalizedName(userId, normalizedName) - .orElseGet(() -> tagRepository.save(Tag.from(userId, tagName, normalizedName))); + Tag tag = tagWriter.findOrRestore(userId, tagName, normalizedName) + .orElseGet(() -> insertOrFindConcurrentlyCreated(userId, tagName, normalizedName)); evictTagCache(userId); log.info("userId: {} create tag: {}", userId, tag.getName()); return TagResponseDto.from(tag); } + private Tag insertOrFindConcurrentlyCreated(Long userId, String tagName, String normalizedName) { + try { + return tagWriter.insert(userId, tagName, normalizedName); + } catch (DataIntegrityViolationException e) { + // 같은 이름을 동시에 보낸 다른 요청이 먼저 커밋했다는 뜻이다. + // 삽입 트랜잭션은 이미 롤백됐으므로, 새 트랜잭션에서 그 태그를 찾아 돌려준다. + log.info("userId: {} tag insert lost the race, re-reading: {}", userId, tagName); + return tagWriter.findOrRestore(userId, tagName, normalizedName) + .orElseThrow(() -> e); + } + } + @Transactional(readOnly = true) public List getUserTags(Long userId) { String cacheKey = TAG_LIST_CACHE_PREFIX + userId; @@ -78,7 +102,7 @@ public void deleteTag(Long userId, Long tagId) { } public void deleteTags(Long userId, TagDeleteRequestDto requestDto) { - Set tagIds = toDistinctIds(requestDto.deleteTagIdList()); + Set tagIds = toDistinctIds(requestDto == null ? null : requestDto.deleteTagIdList()); if (tagIds.isEmpty()) { throw new ApplicationException(TagErrorCase.TAG_NOT_FOUND); } @@ -165,8 +189,16 @@ private void writeTagCache(String key, List tags) { } } + /** + * 캐시 무효화 실패가 태그 생성/삭제 자체를 실패시키면 안 된다. + * Redis 가 죽어도 DB 반영은 이미 끝났고, 조회는 캐시 미스로 DB 를 타면 된다. + */ private void evictTagCache(Long userId) { - redisSingleDataService.deleteSingleData(TAG_LIST_CACHE_PREFIX + userId); + try { + redisSingleDataService.deleteSingleData(TAG_LIST_CACHE_PREFIX + userId); + } catch (Exception e) { + log.warn("Failed to evict tag list cache. userId={}, reason={}", userId, e.getMessage()); + } } private String normalizeDisplayName(String rawTagName) { diff --git a/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java b/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java new file mode 100644 index 00000000..6122d60d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/tag/service/TagWriter.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.tag.service; + +import com.aisip.OnO.backend.tag.entity.Tag; +import com.aisip.OnO.backend.tag.repository.TagRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +/** + * 태그 한 건의 조회/삽입을 각각 독립된 트랜잭션으로 수행한다. + * + *

태그 생성은 (user_id, normalized_name) 유니크 인덱스와 경쟁하는 작업이라 + * "조회 → 없으면 삽입"이 한 트랜잭션 안에서는 안전하게 재시도되지 않는다. + * 삽입이 Duplicate entry 로 실패하는 순간 그 트랜잭션은 롤백 대상이 되고, + * MySQL REPEATABLE READ 에서는 같은 트랜잭션으로 다시 조회해도 + * 스냅샷 때문에 남이 커밋한 행이 보이지 않기 때문이다. + * + *

따라서 재시도 판단은 {@link TagService} 가 트랜잭션 바깥에서 하고, + * 여기서는 매 호출마다 새 트랜잭션(= 새 스냅샷)을 연다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class TagWriter { + + private final TagRepository tagRepository; + + /** + * 살아있는 태그를 찾고, 없으면 소프트 삭제된 동명 태그를 되살린다. + * + *

{@code tag} 의 유니크 인덱스는 {@code deleted_at} 을 포함하지 않는다. + * 즉 소프트 삭제된 행도 이름을 계속 점유하므로, 삭제한 태그와 같은 이름을 + * 다시 만들면 Duplicate entry 로 실패한다. 사용자 입장에서 "지웠다가 다시 만들기"는 + * 당연히 되어야 하는 동작이라 삭제된 행을 복구해서 돌려준다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Optional findOrRestore(Long userId, String displayName, String normalizedName) { + Optional activeTag = tagRepository.findByUserIdAndNormalizedName(userId, normalizedName); + if (activeTag.isPresent()) { + return activeTag; + } + + int restored = tagRepository.restoreDeletedTag(userId, displayName, normalizedName); + if (restored == 0) { + return Optional.empty(); + } + + log.info("userId: {} restored soft-deleted tag: {}", userId, displayName); + return tagRepository.findByUserIdAndNormalizedName(userId, normalizedName); + } + + /** + * 태그를 새로 삽입한다. 유니크 인덱스에 걸리면 + * {@link org.springframework.dao.DataIntegrityViolationException} 이 그대로 올라간다. + * 호출자가 이 트랜잭션 밖에서 재조회로 복구한다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Tag insert(Long userId, String displayName, String normalizedName) { + return tagRepository.saveAndFlush(Tag.from(userId, displayName, normalizedName)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java index 7e11eba3..e0f601e0 100644 --- a/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/user/exception/UserErrorCase.java @@ -8,7 +8,13 @@ @RequiredArgsConstructor public enum UserErrorCase implements ErrorCase { - USER_NOT_FOUND(404, 3001, "사용자를 찾을 수 없습니다."); + USER_NOT_FOUND(404, 3001, "사용자를 찾을 수 없습니다."), + + /** + * 소셜 로그인 요청에 identifier 가 없거나, 암호화 후 저장 한계를 넘는 경우. + * identifier 없이 가입시키면 다음 로그인에서 같은 계정을 찾지 못해 계정이 계속 늘어난다. + */ + INVALID_USER_IDENTIFIER(400, 3002, "유효하지 않은 사용자 식별자입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 51ddcd8e..fda7443e 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -46,6 +46,12 @@ public class UserService { private static final long MAX_PROFILE_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; + /** + * identifier 는 CryptoConverter 로 암호화(Base64)돼 varchar(255) 컬럼에 저장된다. + * 평문 176자부터 암호문이 256자가 되어 저장 자체가 실패하므로 그 앞에서 400 으로 끊는다. + */ + private static final int MAX_IDENTIFIER_LENGTH = 175; + private final UserRepository userRepository; private final FolderService folderService; @@ -103,6 +109,8 @@ public UserResponseDto registerGuestUser() { @Transactional public UserResponseDto registerMemberUser(UserRegisterDto userRegisterDto) { + validateIdentifier(userRegisterDto.identifier()); + return userRepository.findByIdentifier(userRegisterDto.identifier()) .map(UserResponseDto::from) .orElseGet(() -> { @@ -115,6 +123,18 @@ public UserResponseDto registerMemberUser(UserRegisterDto userRegisterDto) { }); } + /** + * 소셜 로그인 식별자 검증. + * + *

identifier 가 비어 있으면 findByIdentifier 가 항상 빈 결과라 로그인할 때마다 + * 새 계정이 생기고, 사용자는 이전 오답노트로 돌아갈 수 없다. + */ + private void validateIdentifier(String identifier) { + if (identifier == null || identifier.isBlank() || identifier.length() > MAX_IDENTIFIER_LENGTH) { + throw new ApplicationException(UserErrorCase.INVALID_USER_IDENTIFIER); + } + } + @Transactional(readOnly = true) public UserResponseDto findUser(Long userId) { User user = findUserEntity(userId); diff --git a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java index e23a8b32..43c8ba52 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fileupload/service/FileUploadService.java @@ -138,13 +138,29 @@ private void deleteProblemSolveImageData(String imageUrl, Long userId) { } private String createFileName(MultipartFile file) { - String originalFilename = file.getOriginalFilename(); - String extension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String extension = resolveExtension(file.getOriginalFilename()); String datePath = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd")); return "image/" + datePath + "/" + UUID.randomUUID() + extension; } + /** + * 확장자가 없는 파일명이 들어오면 예전에는 substring(-1) 로 StringIndexOutOfBoundsException 이 나 + * 그대로 500 이 됐다. 잘못된 업로드 요청은 400 으로 돌려준다. + */ + private String resolveExtension(String originalFilename) { + if (originalFilename == null || originalFilename.isBlank()) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + + int separatorIndex = originalFilename.lastIndexOf("."); + if (separatorIndex < 0 || separatorIndex == originalFilename.length() - 1) { + throw new ApplicationException(FileUploadErrorCase.INVALID_IMAGE_FILE); + } + + return originalFilename.substring(separatorIndex); + } + private String createFileNameForPresigned(String contentType) { String extension = switch (contentType) { case "image/png" -> ".png"; diff --git a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java index 9028f4d1..733ea3aa 100644 --- a/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java +++ b/src/main/java/com/aisip/OnO/backend/util/webhook/DiscordWebhookNotificationService.java @@ -19,6 +19,7 @@ public class DiscordWebhookNotificationService { private final DiscordWebhookProducer discordWebhookProducer; private final Map lastSentAtByDedupKey = new ConcurrentHashMap<>(); private static final Duration ERROR_NOTIFICATION_DEDUP_WINDOW = Duration.ofMinutes(5); + private static final int DEDUP_KEY_CAPACITY = 1000; /** * 에러 발생 시 Discord로 알림 전송 @@ -89,12 +90,30 @@ public void sendCustomEmbed(String title, String description, List entry.getValue().isBefore(threshold)); + } + private void publishToQueue(DiscordWebhookPayload payload) { publishToQueue(payload, null); } diff --git a/src/main/resources/db/migration/V23__widen_problem_memo.sql b/src/main/resources/db/migration/V23__widen_problem_memo.sql new file mode 100644 index 00000000..7dbda0a1 --- /dev/null +++ b/src/main/resources/db/migration/V23__widen_problem_memo.sql @@ -0,0 +1,58 @@ +-- problem.memo 를 varchar(255) -> varchar(1000) 으로 넓힌다. +-- +-- 앱 입력 필드는 메모를 1000자까지 받는데 컬럼은 255자였다. 엔티티에 길이 지정이 없어 +-- Hibernate 가 varchar(255) 로 만든 것이다. 256자 이상 입력이 저장 시점에 터진다. +-- Sentry: "Data truncation: Data too long for column 'memo' at row 1" (POST /api/problems/v2) +-- +-- 엔티티는 같은 브랜치에서 @Column(length = 1000) 으로 수정됐다. +-- dev/prod 는 ddl-auto: validate 인데 Hibernate 는 VARCHAR 길이를 검증하지 않으므로, +-- 이 마이그레이션이 없으면 앱은 정상 기동하고 저장할 때만 500 이 난다. +-- +-- 확장 방향이라 기존 데이터는 잘리거나 변환되지 않는다. +-- +-- [락 / 알고리즘] +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. 절이 없으면 MySQL 이 알아서 고르는데, +-- INPLACE 가 불가능한 조건이면 에러 없이 조용히 COPY 로 내려가 테이블을 통째로 재작성하고 +-- 그동안 쓰기가 막힌다. 명시하면 불가능할 때 ER_ALTER_OPERATION_NOT_SUPPORTED(1845) 로 +-- 즉시 실패하므로, 알 수 없는 길이의 장애가 1초짜리 실패로 바뀐다. +-- +-- lock_wait_timeout 은 MDL 대기 pile-up 방지용이다. 메타데이터만 바꾸는 ALTER 도 시작·종료에 +-- 배타적 MDL 을 잠깐 잡는데, 장시간 트랜잭션이 테이블을 붙들고 있으면 DDL 이 대기하고 +-- 그 뒤의 모든 쿼리가 DDL 뒤에 줄을 선다. 10초로 끊으면 실패는 하되 서비스는 멀쩡하다. +-- +-- [적용 전 확인 - 읽기 전용] +-- MODIFY COLUMN 은 컬럼 정의를 통째로 교체한다. 명시하지 않은 CHARACTER SET/COLLATE 는 +-- 유지되지 않고 테이블 기본값으로 리셋된다. 컬럼 collation 이 테이블 기본과 다르면 +-- collation 변경 = 인덱스 재구축 = COPY 가 되므로, 아래로 먼저 확인하고 +-- 다를 경우 현재 값을 ALTER 에 그대로 재기술해야 한다. +-- SELECT column_name, character_maximum_length, character_octet_length, +-- character_set_name, collation_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'problem'; +-- SHOW CREATE TABLE problem; + +-- +-- [실패 시 복구] +-- MySQL 은 DDL 트랜잭션이 없다. 실패하면 flyway_schema_history 에 success=0 으로 남고, +-- validate-on-migrate: true 라 이후 모든 기동이 "Detected failed migration" 으로 죽는다. +-- 배포 파이프라인이 막히므로 아래로 이력을 정리한 뒤 재배포한다. +-- SELECT installed_rank, version, description, success +-- FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5; +-- DELETE FROM flyway_schema_history WHERE version = '23' AND success = 0; +-- 이 ALTER 는 목표 정의로 수렴하는 문장이라 이미 적용된 상태에서 재실행해도 안전하다. +-- +-- [롤백] +-- 되돌리지 않는 것이 원칙이다. 컬럼 확장은 구버전 앱과도 호환되므로 앱만 되돌리면 된다. +-- 굳이 축소해야 한다면 순서와 비용을 알고 해야 한다: +-- 1. 앱을 먼저 롤백한다. 앱이 긴 값을 통과시키는 상태에서 컬럼만 줄이면 즉시 500 이 재발한다. +-- 2. 초과 데이터가 0 건인지 확인한다: +-- SELECT COUNT(*) FROM problem WHERE CHAR_LENGTH(memo) > 255; +-- 3. VARCHAR 축소는 INPLACE 가 불가능해 테이블 전체 재작성 + 쓰기 차단이다. 확장보다 위험하다. +-- 4. flyway_schema_history 에서 해당 버전 행을 지운다. 안 지우면 스키마와 이력이 영구히 어긋난다. + + +SET SESSION lock_wait_timeout = 10; + +ALTER TABLE problem + MODIFY COLUMN memo VARCHAR(1000) NULL, + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/src/main/resources/db/migration/V24__widen_reminder_memo_snapshot.sql b/src/main/resources/db/migration/V24__widen_reminder_memo_snapshot.sql new file mode 100644 index 00000000..f4eb16f4 --- /dev/null +++ b/src/main/resources/db/migration/V24__widen_reminder_memo_snapshot.sql @@ -0,0 +1,54 @@ +-- problem_review_reminder.problem_memo_snapshot 을 varchar(255) -> varchar(1000) 으로 넓힌다. +-- +-- V21 이 이 컬럼을 VARCHAR(255) 로 만들었다. problem.memo 를 그대로 복사해 담는 스냅샷이라 +-- V23 만 적용하면 리마인더 생성 시점에 같은 truncation 이 그대로 재발한다. +-- 반드시 V23 과 함께 나가야 한다. +-- +-- 같은 테이블의 problem_reference_snapshot(255) 은 원본 problem.reference 가 255 라 그대로 둔다. +-- +-- [락 / 알고리즘] +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. 절이 없으면 MySQL 이 알아서 고르는데, +-- INPLACE 가 불가능한 조건이면 에러 없이 조용히 COPY 로 내려가 테이블을 통째로 재작성하고 +-- 그동안 쓰기가 막힌다. 명시하면 불가능할 때 ER_ALTER_OPERATION_NOT_SUPPORTED(1845) 로 +-- 즉시 실패하므로, 알 수 없는 길이의 장애가 1초짜리 실패로 바뀐다. +-- +-- lock_wait_timeout 은 MDL 대기 pile-up 방지용이다. 메타데이터만 바꾸는 ALTER 도 시작·종료에 +-- 배타적 MDL 을 잠깐 잡는데, 장시간 트랜잭션이 테이블을 붙들고 있으면 DDL 이 대기하고 +-- 그 뒤의 모든 쿼리가 DDL 뒤에 줄을 선다. 10초로 끊으면 실패는 하되 서비스는 멀쩡하다. +-- +-- [적용 전 확인 - 읽기 전용] +-- MODIFY COLUMN 은 컬럼 정의를 통째로 교체한다. 명시하지 않은 CHARACTER SET/COLLATE 는 +-- 유지되지 않고 테이블 기본값으로 리셋된다. 컬럼 collation 이 테이블 기본과 다르면 +-- collation 변경 = 인덱스 재구축 = COPY 가 되므로, 아래로 먼저 확인하고 +-- 다를 경우 현재 값을 ALTER 에 그대로 재기술해야 한다. +-- SELECT column_name, character_maximum_length, character_octet_length, +-- character_set_name, collation_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'problem_review_reminder'; +-- SHOW CREATE TABLE problem_review_reminder; + +-- +-- [실패 시 복구] +-- MySQL 은 DDL 트랜잭션이 없다. 실패하면 flyway_schema_history 에 success=0 으로 남고, +-- validate-on-migrate: true 라 이후 모든 기동이 "Detected failed migration" 으로 죽는다. +-- 배포 파이프라인이 막히므로 아래로 이력을 정리한 뒤 재배포한다. +-- SELECT installed_rank, version, description, success +-- FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5; +-- DELETE FROM flyway_schema_history WHERE version = '24' AND success = 0; +-- 이 ALTER 는 목표 정의로 수렴하는 문장이라 이미 적용된 상태에서 재실행해도 안전하다. +-- +-- [롤백] +-- 되돌리지 않는 것이 원칙이다. 컬럼 확장은 구버전 앱과도 호환되므로 앱만 되돌리면 된다. +-- 굳이 축소해야 한다면 순서와 비용을 알고 해야 한다: +-- 1. 앱을 먼저 롤백한다. 앱이 긴 값을 통과시키는 상태에서 컬럼만 줄이면 즉시 500 이 재발한다. +-- 2. 초과 데이터가 0 건인지 확인한다: +-- SELECT COUNT(*) FROM problem_review_reminder WHERE CHAR_LENGTH(problem_memo_snapshot) > 255; +-- 3. VARCHAR 축소는 INPLACE 가 불가능해 테이블 전체 재작성 + 쓰기 차단이다. 확장보다 위험하다. +-- 4. flyway_schema_history 에서 해당 버전 행을 지운다. 안 지우면 스키마와 이력이 영구히 어긋난다. + + +SET SESSION lock_wait_timeout = 10; + +ALTER TABLE problem_review_reminder + MODIFY COLUMN problem_memo_snapshot VARCHAR(1000) NULL, + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/src/main/resources/db/migration/V25__widen_refresh_token.sql b/src/main/resources/db/migration/V25__widen_refresh_token.sql new file mode 100644 index 00000000..689ed8f0 --- /dev/null +++ b/src/main/resources/db/migration/V25__widen_refresh_token.sql @@ -0,0 +1,61 @@ +-- refresh_token.refresh_token 을 varchar(255) -> varchar(512) 로 넓힌다. +-- +-- 원본 JWT 를 그대로 저장하는데 현재 클레임 구성으로 실측 최대 244자다. 아직 터지지는 +-- 않았지만 클레임이 하나만 늘어도 255를 넘긴다. 저장이 잘리면 이후 갱신 요청이 세션을 +-- 찾지 못해 REFRESH_TOKEN_NOT_FOUND(1002) 가 된다. +-- +-- 셋 중 유일하게 인덱스가 걸린 컬럼이고, collation 이 바뀌면 인증 경로의 동등 비교에 +-- 영향이 갈 수 있는 유일한 컬럼이다. 지금 터지는 버그가 아니라 예방적 확장이므로 +-- V23/V24 와 같은 배포에 묶지 말고, 트래픽이 낮은 시간대에 단독으로 적용하는 것을 권한다. +-- +-- V17 이 만든 프리픽스 인덱스 idx_refresh_token_token (refresh_token(255)) 는 그대로 둔다. +-- 프리픽스 길이는 문자 수 기준이라 utf8mb4 에서 1020바이트이고 InnoDB 한계(3072) 안이다. +-- 컬럼이 넓어져도 인덱스 정의는 유효하며, 동등 비교는 프리픽스로 후보를 좁힌 뒤 +-- 행에서 전체 값을 재확인하는 방식이라 정확성도 유지된다. +-- +-- [락 / 알고리즘] +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. 절이 없으면 MySQL 이 알아서 고르는데, +-- INPLACE 가 불가능한 조건이면 에러 없이 조용히 COPY 로 내려가 테이블을 통째로 재작성하고 +-- 그동안 쓰기가 막힌다. 명시하면 불가능할 때 ER_ALTER_OPERATION_NOT_SUPPORTED(1845) 로 +-- 즉시 실패하므로, 알 수 없는 길이의 장애가 1초짜리 실패로 바뀐다. +-- +-- lock_wait_timeout 은 MDL 대기 pile-up 방지용이다. 메타데이터만 바꾸는 ALTER 도 시작·종료에 +-- 배타적 MDL 을 잠깐 잡는데, 장시간 트랜잭션이 테이블을 붙들고 있으면 DDL 이 대기하고 +-- 그 뒤의 모든 쿼리가 DDL 뒤에 줄을 선다. 10초로 끊으면 실패는 하되 서비스는 멀쩡하다. +-- +-- [적용 전 확인 - 읽기 전용] +-- MODIFY COLUMN 은 컬럼 정의를 통째로 교체한다. 명시하지 않은 CHARACTER SET/COLLATE 는 +-- 유지되지 않고 테이블 기본값으로 리셋된다. 컬럼 collation 이 테이블 기본과 다르면 +-- collation 변경 = 인덱스 재구축 = COPY 가 되므로, 아래로 먼저 확인하고 +-- 다를 경우 현재 값을 ALTER 에 그대로 재기술해야 한다. +-- SELECT column_name, character_maximum_length, character_octet_length, +-- character_set_name, collation_name +-- FROM information_schema.columns +-- WHERE table_schema = DATABASE() AND table_name = 'refresh_token'; +-- SHOW CREATE TABLE refresh_token; + +-- +-- [실패 시 복구] +-- MySQL 은 DDL 트랜잭션이 없다. 실패하면 flyway_schema_history 에 success=0 으로 남고, +-- validate-on-migrate: true 라 이후 모든 기동이 "Detected failed migration" 으로 죽는다. +-- 배포 파이프라인이 막히므로 아래로 이력을 정리한 뒤 재배포한다. +-- SELECT installed_rank, version, description, success +-- FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5; +-- DELETE FROM flyway_schema_history WHERE version = '25' AND success = 0; +-- 이 ALTER 는 목표 정의로 수렴하는 문장이라 이미 적용된 상태에서 재실행해도 안전하다. +-- +-- [롤백] +-- 되돌리지 않는 것이 원칙이다. 컬럼 확장은 구버전 앱과도 호환되므로 앱만 되돌리면 된다. +-- 굳이 축소해야 한다면 순서와 비용을 알고 해야 한다: +-- 1. 앱을 먼저 롤백한다. 앱이 긴 값을 통과시키는 상태에서 컬럼만 줄이면 즉시 500 이 재발한다. +-- 2. 초과 데이터가 0 건인지 확인한다: +-- SELECT COUNT(*) FROM refresh_token WHERE CHAR_LENGTH(refresh_token) > 255; +-- 3. VARCHAR 축소는 INPLACE 가 불가능해 테이블 전체 재작성 + 쓰기 차단이다. 확장보다 위험하다. +-- 4. flyway_schema_history 에서 해당 버전 행을 지운다. 안 지우면 스키마와 이력이 영구히 어긋난다. + + +SET SESSION lock_wait_timeout = 10; + +ALTER TABLE refresh_token + MODIFY COLUMN refresh_token VARCHAR(512) NOT NULL, + ALGORITHM=INPLACE, LOCK=NONE; diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java new file mode 100644 index 00000000..a3598433 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java @@ -0,0 +1,541 @@ +package com.aisip.OnO.backend.auth.controller; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.auth.service.JwtTokenizer; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.fasterxml.jackson.databind.JsonNode; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 인증 API 의 HTTP 계약을 끝에서 끝까지 확인한다. + * + *

여기서 검증하는 것은 상태코드와 errorCode 조합이다. + * 프론트(HttpService)는 errorCode 1005 또는 errorCode 없는 401 일 때만 토큰 갱신을 시도하고, + * 1001·1002·1003·1004·1006 은 재로그인으로 처리한다. 이 대응이 어긋나면 + * 정상 사용자가 갱신 기회를 잃고 로그아웃당한다. + */ +@DisplayName("인증 API") +class AuthApiTest extends IntegrationTestSupport { + + private static final String PROTECTED_API = "/api/users"; + private static final String OTHER_SECRET = + "b3RoZXItc2VjcmV0LWtleS1vdGhlci1zZWNyZXQta2V5LW90aGVyLXNlY3JldC0zMmJ5dGVz"; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.accessToken.secret}") + private String accessSecret; + + @Value("${jwt.refreshToken.secret}") + private String refreshSecret; + + /** 이미 만료된 토큰만 발급한다. 서명 키는 애플리케이션과 동일하다. */ + private JwtTokenizer expiredTokenizer; + + /** 서명은 유효하지만 DB 에 세션이 없는 토큰을 만들 때 쓴다. */ + private JwtTokenizer validTokenizer; + + @BeforeEach + void setUpTokenizers() { + expiredTokenizer = new JwtTokenizer(-60_000L, -60_000L, accessSecret, refreshSecret); + validTokenizer = new JwtTokenizer(1_800_000L, 604_800_000L, accessSecret, refreshSecret); + } + + private JsonNode dataOf(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); + } + + private JsonNode signUpGuest() throws Exception { + return dataOf(mockMvc.perform(post("/api/auth/signup/guest")) + .andExpect(status().isOk()) + .andReturn()); + } + + private JsonNode signUpMember(String identifier, String platform) throws Exception { + return dataOf(mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .identifier(identifier) + .platform(platform) + .name("소셜사용자") + .email(identifier + "@test.ono") + .build()))) + .andExpect(status().isOk()) + .andReturn()); + } + + private ResultActions callProtectedApi(String authorizationHeader) throws Exception { + return authorizationHeader == null + ? mockMvc.perform(get(PROTECTED_API)) + : mockMvc.perform(get(PROTECTED_API).header("Authorization", authorizationHeader)); + } + + private ResultActions refresh(String refreshToken) throws Exception { + return mockMvc.perform(post("/api/auth/refresh") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequestDto(null, refreshToken)))); + } + + @Nested + @DisplayName("가입과 로그인") + class SignUp { + + @Test + @DisplayName("게스트 가입은 사용자와 리프레시 토큰 세션을 함께 만든다") + void guestSignUpCreatesUserAndSession() throws Exception { + JsonNode tokens = signUpGuest(); + + assertThat(tokens.path("accessToken").asText()).startsWith("Bearer "); + assertThat(tokens.path("refreshToken").asText()).isNotBlank(); + assertThat(userRepository.count()).isEqualTo(1); + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("세션이 저장되지 않으면 첫 갱신부터 1002 가 난다") + .isPresent(); + } + + @Test + @DisplayName("게스트 토큰에는 ROLE_GUEST 권한이 담긴다") + void guestTokenCarriesGuestAuthority() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + assertThat(validTokenizer.getAuthorityFromAccessToken(accessToken.replace("Bearer ", ""))) + .isEqualTo(Authority.ROLE_GUEST); + } + + @ParameterizedTest(name = "{0} 로그인으로 가입하면 멤버 토큰을 받는다") + @ValueSource(strings = {"GOOGLE", "APPLE"}) + void memberSignUpPerPlatform(String platform) throws Exception { + JsonNode tokens = signUpMember(platform.toLowerCase() + "-sub-1", platform); + + assertThat(validTokenizer.getAuthorityFromAccessToken( + tokens.path("accessToken").asText().replace("Bearer ", ""))) + .isEqualTo(Authority.ROLE_MEMBER); + assertThat(userRepository.findByIdentifier(platform.toLowerCase() + "-sub-1")) + .isPresent() + .get() + .satisfies(user -> assertThat(user.getPlatform()).isEqualTo(platform)); + } + + @Test + @DisplayName("같은 소셜 계정으로 다시 로그인해도 계정이 새로 생기지 않는다") + void reloginDoesNotDuplicateAccount() throws Exception { + JsonNode first = signUpMember("google-sub-repeat", "GOOGLE"); + JsonNode second = signUpMember("google-sub-repeat", "GOOGLE"); + + assertThat(userRepository.count()) + .as("같은 identifier 로 계정이 갈리면 사용자는 자기 오답노트를 잃는다") + .isEqualTo(1); + assertThat(second.path("accessToken").asText()).isNotBlank(); + assertThat(validTokenizer.getUserIdFromAccessToken( + second.path("accessToken").asText().replace("Bearer ", ""))) + .isEqualTo(validTokenizer.getUserIdFromAccessToken( + first.path("accessToken").asText().replace("Bearer ", ""))); + } + + @Test + @DisplayName("로그인할 때마다 기기별 리프레시 토큰 세션이 따로 쌓인다") + void keepsSessionPerLogin() throws Exception { + JsonNode phone = signUpMember("google-sub-multi", "GOOGLE"); + JsonNode tablet = signUpMember("google-sub-multi", "GOOGLE"); + + assertThat(refreshTokenRepository.findByRefreshToken(phone.path("refreshToken").asText())) + .as("나중 로그인이 앞선 기기의 세션을 지우면 그 기기는 1002 로 튕긴다") + .isPresent(); + assertThat(refreshTokenRepository.findByRefreshToken(tablet.path("refreshToken").asText())).isPresent(); + } + + @Test + @DisplayName("본문이 없는 가입 요청은 400 으로 거절한다") + void rejectsEmptyBody() throws Exception { + mockMvc.perform(post("/api/auth/signup/member").contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + + /** + * 프로덕션에서 관측된 "소셜 로그인 실패. 잘못된 유저 정보입니다" 계열 요청. + * identifier 없이 가입시키면 다음 로그인에서 같은 계정을 찾지 못해 계정이 계속 늘어난다. + */ + @Test + @DisplayName("identifier 없는 소셜 로그인은 계정을 만들지 않고 400 + 3002 로 거절한다") + void rejectsSignUpWithoutIdentifier() throws Exception { + mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .platform("GOOGLE") + .name("소셜사용자") + .email("no-identifier@test.ono") + .build()))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(UserErrorCase.INVALID_USER_IDENTIFIER.getErrorCode())); + + assertThat(userRepository.count()) + .as("잘못된 요청으로 유령 계정이 생기면 안 된다") + .isZero(); + } + + @Test + @DisplayName("빈 identifier 로 두 번 요청해도 계정이 쌓이지 않는다") + void rejectsBlankIdentifierRepeatedly() throws Exception { + for (int i = 0; i < 2; i++) { + mockMvc.perform(post("/api/auth/signup/member") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(UserRegisterDto.builder() + .identifier(" ") + .platform("APPLE") + .name("소셜사용자") + .build()))) + .andExpect(status().isBadRequest()); + } + + assertThat(userRepository.count()).isZero(); + } + } + + @Nested + @DisplayName("액세스 토큰 검증 계약") + class AccessTokenContract { + + @Test + @DisplayName("정상 토큰은 보호된 API 를 통과한다") + void allowsValidToken() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + callProtectedApi(accessToken) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userId").isNumber()); + } + + /** + * 프론트는 1005 를 받아야 refreshAccessToken() 을 호출한다. + * 여기가 1007 로 나가면 만료된 사용자는 갱신 없이 인증 실패 처리된다. + */ + @Test + @DisplayName("만료된 액세스 토큰은 401 + 1005 로 응답해 프론트가 갱신하게 한다") + void expiredAccessTokenReturnsAccessTokenExpired() throws Exception { + String expired = expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + callProtectedApi(expired) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode())) + .andExpect(jsonPath("$.message").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getMessage())); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 401 + 1007 로 응답한다") + void missingHeaderReturnsAuthenticationFailed() throws Exception { + callProtectedApi(null) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("Bearer 프리픽스 없이 토큰만 보내면 401 + 1007 로 응답한다") + void missingBearerPrefixReturnsAuthenticationFailed() throws Exception { + String rawToken = signUpGuest().path("accessToken").asText().replace("Bearer ", ""); + + callProtectedApi(rawToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @ParameterizedTest(name = "형식이 깨진 헤더 [{0}] 는 401 + 1007 로 응답한다") + @ValueSource(strings = {"Bearer ", "Bearer not-a-jwt", "Bearer a.b.c", "Basic dXNlcjpwYXNz"}) + void malformedHeaderReturnsAuthenticationFailed(String header) throws Exception { + callProtectedApi(header) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("다른 키로 서명한 위조 토큰은 401 + 1007 로 거절한다") + void forgedTokenReturnsAuthenticationFailed() throws Exception { + String forged = "Bearer " + Jwts.builder() + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setSubject("1") + .setExpiration(new Date(System.currentTimeMillis() + 600_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + callProtectedApi(forged) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("리프레시 토큰으로는 보호된 API 를 호출할 수 없다") + void refreshTokenCannotAccessApi() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + callProtectedApi("Bearer " + refreshToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("서명은 맞지만 존재하지 않는 사용자의 토큰은 인증을 통과한 뒤 사용자 조회에서 걸린다") + void tokenOfDeletedUserFailsAtLookup() throws Exception { + String orphanToken = validTokenizer.createAccessToken("999999", Map.of("authority", Authority.ROLE_MEMBER)); + + callProtectedApi(orphanToken) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").isNumber()); + } + } + + @Nested + @DisplayName("토큰 갱신") + class RefreshToken { + + @Test + @DisplayName("리프레시 토큰으로 액세스·리프레시 토큰을 함께 재발급한다") + void reissuesBothTokens() throws Exception { + JsonNode issued = signUpGuest(); + String oldRefreshToken = issued.path("refreshToken").asText(); + + JsonNode refreshed = dataOf(refresh(oldRefreshToken).andExpect(status().isOk()).andReturn()); + + assertThat(refreshed.path("accessToken").asText()).startsWith("Bearer "); + assertThat(refreshed.path("refreshToken").asText()).isNotEqualTo(oldRefreshToken); + callProtectedApi(refreshed.path("accessToken").asText()).andExpect(status().isOk()); + } + + @Test + @DisplayName("갱신해도 권한은 그대로 유지된다") + void keepsAuthorityAcrossRefresh() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + JsonNode refreshed = dataOf(refresh(refreshToken).andExpect(status().isOk()).andReturn()); + + assertThat(validTokenizer.getAuthorityFromAccessToken( + refreshed.path("accessToken").asText().replace("Bearer ", ""))) + .as("갱신 한 번으로 권한이 올라가면 안 된다") + .isEqualTo(Authority.ROLE_GUEST); + } + + @Test + @DisplayName("회전된 옛 리프레시 토큰을 다시 쓰면 404 + 1002 로 거절한다") + void rejectsRotatedRefreshToken() throws Exception { + String oldRefreshToken = signUpGuest().path("refreshToken").asText(); + refresh(oldRefreshToken).andExpect(status().isOk()); + + refresh(oldRefreshToken) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("서명은 유효하지만 저장된 적 없는 리프레시 토큰은 404 + 1002 로 거절한다") + void rejectsUnknownRefreshToken() throws Exception { + String neverStored = validTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + refresh(neverStored) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 401 + 1006 으로 거절한다") + void rejectsExpiredRefreshToken() throws Exception { + String expired = expiredTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + refresh(expired) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_EXPIRED.getErrorCode())); + } + + @ParameterizedTest(name = "형식이 깨진 리프레시 토큰 [{0}] 은 400 + 1001 로 거절한다") + @ValueSource(strings = {"not-a-jwt", "a.b.c", ""}) + void rejectsMalformedRefreshToken(String refreshToken) throws Exception { + refresh(refreshToken) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_REFRESH_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("refreshToken 을 빠뜨린 요청도 500 이 아니라 400 + 1001 로 거절한다") + void rejectsMissingRefreshToken() throws Exception { + refresh(null) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_REFRESH_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("갱신은 인증 없이도 호출할 수 있다") + void doesNotRequireAccessToken() throws Exception { + String refreshToken = signUpGuest().path("refreshToken").asText(); + + refresh(refreshToken).andExpect(status().isOk()); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + private ResultActions logout(String accessToken, String refreshToken) throws Exception { + return mockMvc.perform(post("/api/auth/logout") + .header("Authorization", accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new TokenRequestDto(accessToken, refreshToken)))); + } + + @Test + @DisplayName("로그아웃하면 세션이 지워지고 액세스 토큰은 블랙리스트로 막힌다") + void invalidatesSessionAndAccessToken() throws Exception { + JsonNode tokens = signUpGuest(); + String accessToken = tokens.path("accessToken").asText(); + String refreshToken = tokens.path("refreshToken").asText(); + + logout(accessToken, refreshToken).andExpect(status().isOk()); + + assertThat(refreshTokenRepository.findByRefreshToken(refreshToken)).isEmpty(); + callProtectedApi(accessToken) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("로그아웃한 리프레시 토큰으로는 갱신할 수 없다") + void cannotRefreshAfterLogout() throws Exception { + JsonNode tokens = signUpGuest(); + logout(tokens.path("accessToken").asText(), tokens.path("refreshToken").asText()) + .andExpect(status().isOk()); + + refresh(tokens.path("refreshToken").asText()) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); + } + + @Test + @DisplayName("리프레시 토큰 없이 로그아웃해도 액세스 토큰은 막힌다") + void worksWithoutRefreshToken() throws Exception { + String accessToken = signUpGuest().path("accessToken").asText(); + + mockMvc.perform(post("/api/auth/logout").header("Authorization", accessToken)) + .andExpect(status().isOk()); + + callProtectedApi(accessToken).andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("남의 리프레시 토큰을 넣어 로그아웃해도 그 사람의 세션은 살아 있다") + void cannotKillOtherUsersSession() throws Exception { + JsonNode victim = signUpMember("google-sub-victim", "GOOGLE"); + JsonNode attacker = signUpMember("google-sub-attacker", "GOOGLE"); + + logout(attacker.path("accessToken").asText(), victim.path("refreshToken").asText()) + .andExpect(status().isOk()); + + assertThat(refreshTokenRepository.findByRefreshToken(victim.path("refreshToken").asText())) + .as("남의 세션을 지울 수 있으면 토큰 문자열만으로 강제 로그아웃이 가능해진다") + .isPresent(); + refresh(victim.path("refreshToken").asText()).andExpect(status().isOk()); + } + + /** + * 계약 불일치(현재 동작을 그대로 고정한다). + * + *

CustomAuthenticationEntryPoint 는 요청 URI 가 {@code /api/auth} 로 시작하면 + * 아무것도 쓰지 않고 반환한다. 그래서 인증 실패한 로그아웃은 401 + 1007 이 아니라 + * 200 + 빈 본문으로 나간다. 프론트는 errorCode 가 없는 응답을 받게 되고, + * 이것이 "errorCode: null 인 UnauthorizedException" 관측과 맞물린다. + * 수정 지점은 common/auth 패키지라 여기서는 손대지 않고 동작만 못 박는다. + */ + @Test + @DisplayName("만료된 액세스 토큰으로 로그아웃하면 401 이 아니라 200 빈 본문이 나간다") + void expiredAccessTokenLogoutReturnsEmptyOk() throws Exception { + JsonNode tokens = signUpGuest(); + String expiredAccessToken = expiredTokenizer.createAccessToken( + "1", Map.of("authority", Authority.ROLE_GUEST)); + + MvcResult result = mockMvc.perform(post("/api/auth/logout") + .header("Authorization", expiredAccessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TokenRequestDto(expiredAccessToken, tokens.path("refreshToken").asText())))) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) + .as("본문이 비어 있어 프론트는 errorCode 없이 이 응답을 해석해야 한다") + .isEmpty(); + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("인증이 안 됐으므로 컨트롤러까지 가지 못해 세션은 그대로 남는다") + .isPresent(); + } + + @Test + @DisplayName("인증 없이 로그아웃해도 401 이 아니라 200 빈 본문이 나간다") + void anonymousLogoutReturnsEmptyOk() throws Exception { + MvcResult result = mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isOk()) + .andReturn(); + + assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).isEmpty(); + } + } + + @Nested + @DisplayName("권한 경계") + class AuthorityBoundary { + + @Test + @DisplayName("게스트 토큰도 사용자 API 는 쓸 수 있다") + void guestCanUseUserApi() throws Exception { + callProtectedApi(signUpGuest().path("accessToken").asText()).andExpect(status().isOk()); + } + + @Test + @DisplayName("멤버 토큰으로 관리자 경로에 접근하면 403 + 1008 로 막는다") + void memberCannotAccessAdminPath() throws Exception { + String memberToken = signUpMember("google-sub-member", "GOOGLE").path("accessToken").asText(); + + mockMvc.perform(get("/admin/main").header("Authorization", memberToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_DENIED.getErrorCode())); + } + + @Test + @DisplayName("인증 없이 관리자 경로에 접근하면 401 + 1007 로 막는다") + void anonymousCannotAccessAdminPath() throws Exception { + mockMvc.perform(get("/admin/main")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java deleted file mode 100644 index c8d9b6b7..00000000 --- a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthControllerTest.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.aisip.OnO.backend.auth.controller; - -import com.aisip.OnO.backend.auth.dto.TokenRequestDto; -import com.aisip.OnO.backend.auth.dto.TokenResponseDto; -import com.aisip.OnO.backend.auth.service.UserAuthService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mockito; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.junit.jupiter.SpringExtension; -import org.springframework.test.web.servlet.MockMvc; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.given; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@ExtendWith(SpringExtension.class) -@SpringBootTest -@AutoConfigureMockMvc -@ActiveProfiles("test") -class AuthControllerTest { - - @Autowired - private MockMvc mockMvc; - - @MockBean - private UserAuthService userAuthService; - - @Autowired - private ObjectMapper objectMapper; - - @Test - @DisplayName("게스트 유저 회원가입") - void signUpGuest() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("mockAccessToken", "mockRefreshToken"); - given(userAuthService.signUpGuestUser()).willReturn(mockTokenResponse); - - // When & Then - mockMvc.perform(post("/api/auth/signup/guest") - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("mockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - } - - @Test - @DisplayName("멤버 유저 회원가입") - void signUpMember() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("mockAccessToken", "mockRefreshToken"); - given(userAuthService.signUpMemberUser(any())).willReturn(mockTokenResponse); - - // When & Then - mockMvc.perform(post("/api/auth/signup/member") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new com.aisip.OnO.backend.user.dto.UserRegisterDto( - "test@example.com", "testUser", "testIdentifier", "MEMBER", null - )))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("mockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - } - - @Test - @DisplayName("토큰 갱신") - void refreshToken() throws Exception { - // Given - TokenResponseDto mockTokenResponse = new TokenResponseDto("newMockAccessToken", "mockRefreshToken"); - given(userAuthService.refreshAccessToken(any())).willReturn(mockTokenResponse); - - TokenRequestDto requestDto = new TokenRequestDto("mockAccessToken", "mockRefreshToken"); - - // When & Then - mockMvc.perform(post("/api/auth/refresh") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(requestDto))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.accessToken").value("newMockAccessToken")) - .andExpect(jsonPath("$.data.refreshToken").value("mockRefreshToken")); - - // Verify userAuthService 호출 검증 - Mockito.verify(userAuthService, Mockito.times(1)).refreshAccessToken(any()); - } -} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java index bbe619e0..cc56d60d 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenServiceTest.java @@ -3,27 +3,46 @@ import com.aisip.OnO.backend.auth.dto.TokenResponseDto; import com.aisip.OnO.backend.auth.entity.Authority; import com.aisip.OnO.backend.auth.entity.RefreshToken; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.util.redis.RedisTokenService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +/** + * 토큰 발급 → 회전 → 폐기의 단위 계약. + * + *

refresh 는 세션 하나(제출된 토큰이 가리키는 row)만 회전시켜야 한다. + * userId 로 찾아 덮어쓰면 여러 기기 로그인 중 한 기기가 갱신할 때 + * 나머지 기기가 전부 1002 로 튕긴다. + */ @ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@DisplayName("JwtTokenService") class JwtTokenServiceTest { private static final String ACCESS_SECRET = @@ -31,6 +50,8 @@ class JwtTokenServiceTest { private static final String REFRESH_SECRET = "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + private static final Long USER_ID = 1L; + @Mock private RedisTokenService redisTokenService; @@ -38,66 +59,269 @@ class JwtTokenServiceTest { private RefreshTokenRepository refreshTokenRepository; private JwtTokenizer jwtTokenizer; + private JwtTokenizer expiredTokenizer; private JwtTokenService jwtTokenService; @BeforeEach void setUp() { - jwtTokenizer = new JwtTokenizer( - 1_800_000L, - 60_480_000_000L, - ACCESS_SECRET, - REFRESH_SECRET - ); + jwtTokenizer = new JwtTokenizer(1_800_000L, 604_800_000L, ACCESS_SECRET, REFRESH_SECRET); + expiredTokenizer = new JwtTokenizer(-60_000L, -60_000L, ACCESS_SECRET, REFRESH_SECRET); jwtTokenService = new JwtTokenService(jwtTokenizer, redisTokenService, refreshTokenRepository); + + given(refreshTokenRepository.save(any(RefreshToken.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + } + + private String refreshTokenOf(Authority authority) { + return jwtTokenizer.createRefreshToken(String.valueOf(USER_ID), Map.of("authority", authority)); } - @Test - @DisplayName("로그인 시 기존 세션을 덮어쓰지 않고 새 refresh token row를 저장한다") - void generateTokensCreatesNewRefreshTokenSession() { - // given - Long userId = 1L; - Authority authority = Authority.ROLE_MEMBER; - given(refreshTokenRepository.save(any(RefreshToken.class))).willAnswer(invocation -> invocation.getArgument(0)); - - // when - TokenResponseDto response = jwtTokenService.generateTokens(userId, authority); - - // then - assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); - - ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(RefreshToken.class); - verify(refreshTokenRepository).save(tokenCaptor.capture()); - verify(refreshTokenRepository, never()).findByUserId(userId); - verify(redisTokenService, never()).saveRefreshToken(eq(userId), eq(response.getRefreshToken()), any(Long.class)); - - RefreshToken savedRefreshToken = tokenCaptor.getValue(); - assertThat(savedRefreshToken.getUserId()).isEqualTo(userId); - assertThat(savedRefreshToken.getAuthority()).isEqualTo(authority); - assertThat(savedRefreshToken.getRefreshToken()).isEqualTo(response.getRefreshToken()); + @Nested + @DisplayName("토큰 발급") + class GenerateTokens { + + @Test + @DisplayName("액세스·리프레시 토큰을 함께 발급하고 리프레시 토큰 row 를 새로 저장한다") + void issuesBothTokensAndPersistsSession() { + TokenResponseDto response = jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + ArgumentCaptor captor = ArgumentCaptor.forClass(RefreshToken.class); + verify(refreshTokenRepository).save(captor.capture()); + + assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); + assertThat(captor.getValue().getUserId()).isEqualTo(USER_ID); + assertThat(captor.getValue().getAuthority()).isEqualTo(Authority.ROLE_MEMBER); + assertThat(captor.getValue().getRefreshToken()) + .as("응답으로 준 토큰과 저장한 토큰이 다르면 갱신 시 1002 가 난다") + .isEqualTo(response.getRefreshToken()); + } + + @Test + @DisplayName("기존 세션을 userId 로 찾아 덮어쓰지 않는다") + void doesNotOverwriteExistingSessionByUserId() { + jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + verify(refreshTokenRepository, never()).findByUserId(anyLong()); + verify(refreshTokenRepository, never()).deleteByUserId(anyLong()); + } + + @Test + @DisplayName("발급한 토큰의 권한 클레임은 요청한 권한과 같다") + void embedsRequestedAuthority() { + TokenResponseDto response = jwtTokenService.generateTokens(USER_ID, Authority.ROLE_GUEST); + + assertThat(jwtTokenizer.getAuthorityFromRefreshToken(response.getRefreshToken())) + .isEqualTo(Authority.ROLE_GUEST); + } + + @Test + @DisplayName("발급 단계에서는 Redis 를 쓰지 않는다") + void doesNotTouchRedis() { + jwtTokenService.generateTokens(USER_ID, Authority.ROLE_MEMBER); + + verify(redisTokenService, never()).saveRefreshToken(anyLong(), anyString(), anyLong()); + } } - @Test - @DisplayName("refresh API 호출 시 제출된 refresh token 세션만 회전한다") - void refreshAccessTokenRotatesCurrentRefreshTokenSession() { - // given - Long userId = 1L; - Authority authority = Authority.ROLE_MEMBER; - String oldRefreshToken = jwtTokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); - RefreshToken existingRefreshToken = RefreshToken.from(userId, authority, oldRefreshToken); - - given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(existingRefreshToken)); - given(refreshTokenRepository.save(any(RefreshToken.class))).willAnswer(invocation -> invocation.getArgument(0)); - - // when - TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); - - // then - assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); - assertThat(response.getRefreshToken()).isNotEqualTo(oldRefreshToken); - assertThat(existingRefreshToken.getRefreshToken()).isEqualTo(response.getRefreshToken()); - - verify(refreshTokenRepository).save(existingRefreshToken); - verify(refreshTokenRepository, never()).findByUserId(userId); - verify(redisTokenService, never()).saveRefreshToken(eq(userId), eq(response.getRefreshToken()), any(Long.class)); + @Nested + @DisplayName("토큰 갱신") + class RefreshAccessToken { + + @Test + @DisplayName("제출한 리프레시 토큰이 가리키는 세션만 회전시킨다") + void rotatesOnlySubmittedSession() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, oldRefreshToken); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(session)); + + TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); + + assertThat(response.getAccessToken()).startsWith(JwtTokenizer.BEARER_PREFIX); + assertThat(response.getRefreshToken()).isNotEqualTo(oldRefreshToken); + assertThat(session.getRefreshToken()) + .as("DB row 도 새 토큰으로 갱신돼야 다음 갱신이 통한다") + .isEqualTo(response.getRefreshToken()); + verify(refreshTokenRepository).save(session); + verify(refreshTokenRepository, never()).findByUserId(anyLong()); + } + + @Test + @DisplayName("갱신해도 권한은 그대로 유지된다") + void keepsAuthority() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_GUEST); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)) + .willReturn(Optional.of(RefreshToken.from(USER_ID, Authority.ROLE_GUEST, oldRefreshToken))); + + TokenResponseDto response = jwtTokenService.refreshAccessToken(oldRefreshToken); + + assertThat(jwtTokenizer.getAuthorityFromAccessToken( + response.getAccessToken().replace(JwtTokenizer.BEARER_PREFIX, ""))) + .as("게스트가 갱신 한 번으로 멤버 권한을 얻으면 안 된다") + .isEqualTo(Authority.ROLE_GUEST); + } + + @Test + @DisplayName("DB 에 없는 리프레시 토큰이면 1002 로 거절한다") + void rejectsUnknownRefreshTokenWithNotFound() { + String unknown = refreshTokenOf(Authority.ROLE_MEMBER); + given(refreshTokenRepository.findByRefreshToken(unknown)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(unknown)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .as("프로덕션 1002 응답이 나오는 지점") + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("이미 회전시킨 옛 리프레시 토큰을 재사용하면 1002 로 거절한다") + void rejectsReusedRefreshToken() { + String oldRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, oldRefreshToken); + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.of(session)); + + jwtTokenService.refreshAccessToken(oldRefreshToken); + + // 회전 후에는 옛 토큰으로 더 이상 세션을 찾을 수 없다. + given(refreshTokenRepository.findByRefreshToken(oldRefreshToken)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(oldRefreshToken)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 DB 조회 전에 1006 으로 거절한다") + void rejectsExpiredRefreshTokenBeforeLookup() { + String expired = expiredTokenizer.createRefreshToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(expired)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_EXPIRED); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + } + + @Test + @DisplayName("형식이 깨진 리프레시 토큰은 1001 로 거절한다") + void rejectsMalformedRefreshToken() { + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken("not-a-jwt")) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + } + + @Test + @DisplayName("refreshToken 이 null 이면 500 이 아니라 1001 로 거절한다") + void rejectsNullRefreshToken() { + assertThatThrownBy(() -> jwtTokenService.refreshAccessToken(null)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + } + + @Nested + @DisplayName("로그아웃") + class Logout { + + @Test + @DisplayName("제출한 리프레시 토큰 세션을 지우고 액세스 토큰을 남은 시간만큼 블랙리스트에 넣는다") + void deletesSessionAndBlacklistsAccessToken() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + String refreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + RefreshToken session = RefreshToken.from(USER_ID, Authority.ROLE_MEMBER, refreshToken); + given(refreshTokenRepository.findByRefreshToken(refreshToken)).willReturn(Optional.of(session)); + + jwtTokenService.logout(accessToken, USER_ID, refreshToken); + + verify(refreshTokenRepository).delete(session); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Long.class); + verify(redisTokenService).addToBlacklist( + eq(accessToken.replace(JwtTokenizer.BEARER_PREFIX, "")), ttlCaptor.capture()); + assertThat(ttlCaptor.getValue()) + .as("남은 만료 시간만큼만 블랙리스트에 두어야 Redis 가 새지 않는다") + .isBetween(1L, 1_800L); + } + + @Test + @DisplayName("Bearer 프리픽스 없이 액세스 토큰만 보내도 블랙리스트에 넣는다") + void acceptsRawAccessToken() { + String rawAccessToken = jwtTokenizer + .createAccessToken(String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)) + .replace(JwtTokenizer.BEARER_PREFIX, ""); + + jwtTokenService.logout(rawAccessToken, USER_ID, null); + + verify(redisTokenService).addToBlacklist(eq(rawAccessToken), anyLong()); + } + + @Test + @DisplayName("리프레시 토큰을 함께 보내지 않으면 세션 삭제를 시도하지 않는다") + void skipsSessionDeletionWithoutRefreshToken() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + jwtTokenService.logout(accessToken, USER_ID, " "); + + verify(refreshTokenRepository, never()).findByRefreshToken(anyString()); + verify(refreshTokenRepository, never()).delete(any(RefreshToken.class)); + } + + @Test + @DisplayName("다른 사용자의 리프레시 토큰을 넘기면 그 세션을 지우지 않는다") + void doesNotDeleteOtherUsersSession() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + String othersRefreshToken = refreshTokenOf(Authority.ROLE_MEMBER); + given(refreshTokenRepository.findByRefreshToken(othersRefreshToken)) + .willReturn(Optional.of(RefreshToken.from(999L, Authority.ROLE_MEMBER, othersRefreshToken))); + + jwtTokenService.logout(accessToken, USER_ID, othersRefreshToken); + + verify(refreshTokenRepository, never()).delete(any(RefreshToken.class)); + } + + @Test + @DisplayName("이미 만료된 액세스 토큰은 블랙리스트에 넣지 않는다") + void doesNotBlacklistExpiredAccessToken() { + String expiredAccessToken = expiredTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatCode(() -> jwtTokenService.logout(expiredAccessToken, USER_ID, null)) + .as("만료 토큰으로 로그아웃해도 500 이 나면 안 된다") + .doesNotThrowAnyException(); + + verify(redisTokenService, never()).addToBlacklist(anyString(), anyLong()); + } + + @Test + @DisplayName("형식이 깨진 액세스 토큰으로 로그아웃해도 예외 없이 끝난다") + void toleratesMalformedAccessToken() { + assertThatCode(() -> jwtTokenService.logout("Bearer not-a-jwt", USER_ID, null)) + .doesNotThrowAnyException(); + + verify(redisTokenService, never()).addToBlacklist(anyString(), anyLong()); + } + + @Test + @DisplayName("Redis 가 죽어 블랙리스트 등록이 실패해도 로그아웃은 성공 처리한다") + void survivesRedisFailure() { + String accessToken = jwtTokenizer.createAccessToken( + String.valueOf(USER_ID), Map.of("authority", Authority.ROLE_MEMBER)); + willThrow(new IllegalStateException("redis down")) + .given(redisTokenService).addToBlacklist(anyString(), anyLong()); + + assertThatCode(() -> jwtTokenService.logout(accessToken, USER_ID, null)) + .as("로그아웃이 실패하면 사용자는 앱에서 빠져나가지 못한다") + .doesNotThrowAnyException(); + } } } diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java new file mode 100644 index 00000000..a8246426 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerTest.java @@ -0,0 +1,360 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Date; +import java.util.Map; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * JWT 발급·검증의 단위 계약을 고정한다. + * + *

여기서 정하는 예외 타입이 그대로 {@code JwtTokenFilter} 의 분기 조건이 되고, + * 필터가 고른 {@link AuthErrorCase} 가 프론트의 토큰 갱신 트리거가 된다. + * 즉 이 파일의 assertion 은 프론트 동작과 직결된 계약이다. + */ +@DisplayName("JwtTokenizer") +class JwtTokenizerTest { + + private static final String ACCESS_SECRET = + "dGVzdC1hY2Nlc3MtdG9rZW4tc2VjcmV0LXRlc3QtYWNjZXNzLXRva2VuLXNlY3JldC0zMmJ5dGVz"; + private static final String REFRESH_SECRET = + "dGVzdC1yZWZyZXNoLXRva2VuLXNlY3JldC10ZXN0LXJlZnJlc2gtdG9rZW4tc2VjcmV0LTMyYnl0ZXM="; + private static final String OTHER_SECRET = + "b3RoZXItc2VjcmV0LWtleS1vdGhlci1zZWNyZXQta2V5LW90aGVyLXNlY3JldC0zMmJ5dGVz"; + + private static final long ACCESS_TOKEN_EXPIRATION_MILLIS = 1_800_000L; + private static final long REFRESH_TOKEN_EXPIRATION_MILLIS = 604_800_000L; + + private final JwtTokenizer tokenizer = newTokenizer(ACCESS_TOKEN_EXPIRATION_MILLIS, REFRESH_TOKEN_EXPIRATION_MILLIS); + + /** 이미 만료된 토큰만 발급하는 토크나이저. 시계를 조작하지 않고 만료 상황을 재현한다. */ + private final JwtTokenizer expiredTokenizer = newTokenizer(-60_000L, -60_000L); + + private static JwtTokenizer newTokenizer(long accessExpiration, long refreshExpiration) { + return new JwtTokenizer(accessExpiration, refreshExpiration, ACCESS_SECRET, REFRESH_SECRET); + } + + private String accessTokenOf(long userId, Authority authority) { + return stripBearer(tokenizer.createAccessToken(String.valueOf(userId), Map.of("authority", authority))); + } + + private String refreshTokenOf(long userId, Authority authority) { + return tokenizer.createRefreshToken(String.valueOf(userId), Map.of("authority", authority)); + } + + private static String stripBearer(String token) { + return token.replace(JwtTokenizer.BEARER_PREFIX, ""); + } + + @Nested + @DisplayName("액세스 토큰 발급") + class CreateAccessToken { + + @Test + @DisplayName("Authorization 헤더에 그대로 넣을 수 있도록 Bearer 프리픽스를 붙여 발급한다") + void prefixesBearer() { + String token = tokenizer.createAccessToken("42", Map.of("authority", Authority.ROLE_MEMBER)); + + assertThat(token) + .as("프론트는 발급값을 그대로 Authorization 헤더에 넣는다") + .startsWith("Bearer "); + } + + @Test + @DisplayName("subject 와 authority 클레임이 그대로 왕복한다") + void carriesSubjectAndAuthority() { + String token = accessTokenOf(42L, Authority.ROLE_GUEST); + + assertThat(tokenizer.getUserIdFromAccessToken(token)).isEqualTo(42L); + assertThat(tokenizer.getAuthorityFromAccessToken(token)).isEqualTo(Authority.ROLE_GUEST); + } + + @ParameterizedTest(name = "{0} 권한도 손실 없이 왕복한다") + @EnumSource(Authority.class) + void carriesEveryAuthority(Authority authority) { + String token = accessTokenOf(7L, authority); + + assertThat(tokenizer.getAuthorityFromAccessToken(token)).isEqualTo(authority); + } + + @Test + @DisplayName("만료 시각은 설정한 accessToken 만료 시간과 같다") + void expiresAfterConfiguredDuration() { + String token = accessTokenOf(1L, Authority.ROLE_MEMBER); + + assertThat(tokenizer.getRemainingExpirationTime(token)) + .as("설정값 %d ms 안쪽이어야 한다", ACCESS_TOKEN_EXPIRATION_MILLIS) + .isBetween(ACCESS_TOKEN_EXPIRATION_MILLIS / 1000 - 5, ACCESS_TOKEN_EXPIRATION_MILLIS / 1000); + assertThat(tokenizer.getAccessTokenExpirationSeconds()).isEqualTo(1_800L); + } + } + + @Nested + @DisplayName("리프레시 토큰 발급") + class CreateRefreshToken { + + @Test + @DisplayName("리프레시 토큰에는 Bearer 프리픽스를 붙이지 않는다") + void hasNoBearerPrefix() { + assertThat(refreshTokenOf(42L, Authority.ROLE_MEMBER)) + .as("DB 에 저장되고 body 로 오가는 값이라 프리픽스가 붙으면 조회가 어긋난다") + .doesNotStartWith("Bearer "); + } + + @Test + @DisplayName("같은 사용자가 연속 발급해도 jti 덕분에 매번 다른 토큰이 나온다") + void issuesDistinctTokensForSameUser() { + String first = refreshTokenOf(42L, Authority.ROLE_MEMBER); + String second = refreshTokenOf(42L, Authority.ROLE_MEMBER); + + assertThat(second) + .as("같은 값이면 회전(rotation)해도 이전 세션과 구분되지 않는다") + .isNotEqualTo(first); + } + + @Test + @DisplayName("리프레시 토큰 문자열이 refresh_token 컬럼(varchar 255)에 들어간다") + void fitsInRefreshTokenColumn() { + int longest = IntStream.range(0, 50) + .map(i -> refreshTokenOf(Long.MAX_VALUE, Authority.ROLE_MEMBER).length()) + .max() + .orElseThrow(); + + System.out.println("발급된 refresh token 최대 길이 = " + longest); + assertThat(longest) + .as("길이가 255 를 넘으면 저장 시 잘리거나 실패해 이후 조회가 1002 로 떨어진다") + .isLessThan(255); + } + + @Test + @DisplayName("만료 시각은 설정한 refreshToken 만료 시간과 같다") + void expiresAfterConfiguredDuration() { + String token = refreshTokenOf(1L, Authority.ROLE_MEMBER); + + assertThat(tokenizer.getRemainingRefreshExpirationTime(token)) + .isBetween(REFRESH_TOKEN_EXPIRATION_MILLIS / 1000 - 5, REFRESH_TOKEN_EXPIRATION_MILLIS / 1000); + assertThat(tokenizer.getRefreshTokenExpirationSeconds()).isEqualTo(604_800L); + } + } + + @Nested + @DisplayName("액세스 토큰 검증") + class ValidateAccessToken { + + @Test + @DisplayName("정상 토큰은 그대로 통과한다") + void acceptsValidToken() { + assertThatCode(() -> tokenizer.validateAccessToken(accessTokenOf(1L, Authority.ROLE_MEMBER))) + .doesNotThrowAnyException(); + } + + /** + * 만료만은 ApplicationException 으로 감싸지 않고 ExpiredJwtException 그대로 던진다. + * JwtTokenFilter 가 ExpiredJwtException 을 잡아 ACCESS_TOKEN_EXPIRED(1005) 로 응답하고, + * 프론트는 1005 를 보고 토큰 갱신을 시도하기 때문이다. + */ + @Test + @DisplayName("만료된 토큰은 ExpiredJwtException 으로 구분되어 올라간다") + void distinguishesExpiredToken() { + String expired = stripBearer( + expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER))); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(expired)) + .as("만료가 다른 실패와 섞이면 프론트가 갱신 기회를 잃는다") + .isInstanceOf(ExpiredJwtException.class); + } + + @Test + @DisplayName("다른 키로 서명한 위조 토큰은 유효하지 않은 액세스 토큰으로 거절한다") + void rejectsForgedSignature() { + String forged = Jwts.builder() + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(forged)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + } + + @Test + @DisplayName("페이로드를 갈아끼운 토큰은 서명 불일치로 거절한다") + void rejectsTamperedPayload() { + String token = accessTokenOf(1L, Authority.ROLE_MEMBER); + String[] parts = token.split("\\."); + String tamperedPayload = Base64.getUrlEncoder().withoutPadding().encodeToString( + new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8) + .replace("\"sub\":\"1\"", "\"sub\":\"999\"") + .getBytes(StandardCharsets.UTF_8)); + String tampered = parts[0] + "." + tamperedPayload + "." + parts[2]; + + assertThatThrownBy(() -> tokenizer.validateAccessToken(tampered)) + .as("남의 userId 로 갈아끼운 토큰이 통과하면 전 사용자 데이터가 열린다") + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("서명 없는 alg=none 토큰은 거절한다") + void rejectsUnsignedToken() { + String header = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"none\"}".getBytes(StandardCharsets.UTF_8)); + String payload = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"sub\":\"1\",\"authority\":\"ROLE_ADMIN\"}".getBytes(StandardCharsets.UTF_8)); + + assertThatThrownBy(() -> tokenizer.validateAccessToken(header + "." + payload + ".")) + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("리프레시 토큰을 액세스 토큰 자리에 넣으면 거절한다") + void rejectsRefreshTokenAsAccessToken() { + assertThatThrownBy(() -> tokenizer.validateAccessToken(refreshTokenOf(1L, Authority.ROLE_MEMBER))) + .as("서명 키가 분리돼 있어야 리프레시 토큰만으로 API 를 호출할 수 없다") + .isInstanceOf(ApplicationException.class); + } + + @ParameterizedTest(name = "형식이 깨진 토큰 [{0}] 은 거절한다") + @ValueSource(strings = {"", " ", "not-a-jwt", "a.b", "a.b.c.d", "Bearer eyJhbGciOiJIUzI1NiJ9"}) + void rejectsMalformedToken(String malformed) { + assertThatThrownBy(() -> tokenizer.validateAccessToken(malformed)) + .isInstanceOf(ApplicationException.class); + } + + @Test + @DisplayName("null 토큰도 예외 계약을 지킨다") + void rejectsNullToken() { + assertThatThrownBy(() -> tokenizer.validateAccessToken(null)) + .isInstanceOf(ApplicationException.class); + } + } + + @Nested + @DisplayName("리프레시 토큰 검증") + class ValidateRefreshToken { + + @Test + @DisplayName("정상 토큰은 그대로 통과한다") + void acceptsValidToken() { + assertThatCode(() -> tokenizer.validateRefreshToken(refreshTokenOf(1L, Authority.ROLE_MEMBER))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("만료된 리프레시 토큰은 1006 으로 거절한다") + void rejectsExpiredWithRefreshTokenExpired() { + String expired = expiredTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); + + assertThatThrownBy(() -> tokenizer.validateRefreshToken(expired)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .as("프론트는 1006 을 재로그인 신호로 쓴다") + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_EXPIRED); + } + + @Test + @DisplayName("위조 서명 리프레시 토큰은 1001 로 거절한다") + void rejectsForgedWithInvalidRefreshToken() { + String forged = Jwts.builder() + .setSubject("1") + .setClaims(Map.of("authority", Authority.ROLE_ADMIN)) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(OTHER_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.validateRefreshToken(forged)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @Test + @DisplayName("액세스 토큰을 리프레시 토큰 자리에 넣으면 1001 로 거절한다") + void rejectsAccessTokenAsRefreshToken() { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(accessTokenOf(1L, Authority.ROLE_MEMBER))) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @ParameterizedTest(name = "형식이 깨진 리프레시 토큰 [{0}] 은 1001 로 거절한다") + @ValueSource(strings = {"", " ", "not-a-jwt", "a.b.c"}) + void rejectsMalformedToken(String malformed) { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(malformed)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + + @Test + @DisplayName("null 리프레시 토큰도 1001 로 거절한다") + void rejectsNullToken() { + assertThatThrownBy(() -> tokenizer.validateRefreshToken(null)) + .as("본문에 refreshToken 을 빠뜨린 요청이 500 이 되면 안 된다") + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.INVALID_REFRESH_TOKEN); + } + } + + @Nested + @DisplayName("클레임 추출") + class ExtractClaims { + + @Test + @DisplayName("리프레시 토큰에서 userId 와 권한을 읽는다") + void readsUserIdAndAuthorityFromRefreshToken() { + String token = refreshTokenOf(123L, Authority.ROLE_ADMIN); + + assertThat(tokenizer.getUserIdFromRefreshToken(token)).isEqualTo(123L); + assertThat(tokenizer.getAuthorityFromRefreshToken(token)).isEqualTo(Authority.ROLE_ADMIN); + } + + @Test + @DisplayName("만료된 토큰에서 클레임을 읽으면 ExpiredJwtException 이 난다") + void failsToReadClaimsFromExpiredToken() { + String expired = stripBearer( + expiredTokenizer.createAccessToken("1", Map.of("authority", Authority.ROLE_MEMBER))); + + assertThatThrownBy(() -> tokenizer.getRemainingExpirationTime(expired)) + .as("로그아웃 시 만료 토큰을 블랙리스트에 넣지 않도록 하는 근거가 되는 동작이다") + .isInstanceOf(ExpiredJwtException.class); + } + + @Test + @DisplayName("권한 클레임이 없는 토큰은 권한 조회에서 실패한다") + void failsWhenAuthorityClaimMissing() { + String noAuthority = Jwts.builder() + .setSubject("1") + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + 60_000)) + .signWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(REFRESH_SECRET)), SignatureAlgorithm.HS256) + .compact(); + + assertThatThrownBy(() -> tokenizer.getAuthorityFromRefreshToken(noAuthority)) + .isInstanceOf(Exception.class); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java new file mode 100644 index 00000000..2e93d767 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java @@ -0,0 +1,223 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.dto.TokenResponseDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.user.service.UserService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * 소셜 로그인 → 토큰 발급 → 저장 → 갱신까지 실제 MySQL 위에서 왕복시킨다. + * + *

프로덕션에서 관측된 1002(리프레시 토큰 정보를 찾을 수 없습니다) 는 + * "발급한 토큰이 DB 에 그대로 저장되지 않는" 경우에도 생긴다. + * refresh_token 컬럼이 varchar 이므로 토큰이 컬럼보다 길면 저장이 깨진다. + */ +@DisplayName("소셜 로그인·토큰 저장 통합") +class UserAuthServiceIntegrationTest extends IntegrationTestSupport { + + @Autowired + private UserAuthService userAuthService; + + @Autowired + private JwtTokenService jwtTokenService; + + @Autowired + private JwtTokenizer jwtTokenizer; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @PersistenceContext + private EntityManager entityManager; + + private static UserRegisterDto socialLogin(String identifier, String platform) { + return UserRegisterDto.builder() + .identifier(identifier) + .platform(platform) + .name("소셜사용자") + .email(identifier + "@test.ono") + .build(); + } + + private int refreshTokenColumnLength() { + Number length = (Number) entityManager.createNativeQuery(""" + SELECT character_maximum_length + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'refresh_token' + AND column_name = 'refresh_token' + """) + .getSingleResult(); + return length.intValue(); + } + + @Nested + @DisplayName("가입") + class SignUp { + + @Test + @DisplayName("게스트 가입은 사용자와 리프레시 토큰 세션을 함께 남긴다") + void guestSignUpPersistsUserAndSession() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + + assertThat(userRepository.count()).isEqualTo(1); + assertThat(refreshTokenRepository.findByRefreshToken(tokens.getRefreshToken())) + .isPresent() + .get() + .satisfies(session -> assertThat(session.getAuthority()).isEqualTo(Authority.ROLE_GUEST)); + } + + @ParameterizedTest(name = "{0} 소셜 로그인으로 가입한다") + @ValueSource(strings = {"GOOGLE", "APPLE", "KAKAO"}) + void memberSignUpPerPlatform(String platform) { + String identifier = platform.toLowerCase() + "-sub-abc123"; + + TokenResponseDto tokens = userAuthService.signUpMemberUser(socialLogin(identifier, platform)); + + assertThat(jwtTokenizer.getAuthorityFromRefreshToken(tokens.getRefreshToken())) + .isEqualTo(Authority.ROLE_MEMBER); + assertThat(userRepository.findByIdentifier(identifier)) + .isPresent() + .get() + .satisfies(user -> assertThat(user.getPlatform()).isEqualTo(platform)); + } + + @Test + @DisplayName("같은 소셜 계정으로 다시 로그인하면 계정을 새로 만들지 않는다") + void reloginKeepsSingleAccount() { + userAuthService.signUpMemberUser(socialLogin("google-sub-relogin", "GOOGLE")); + userAuthService.signUpMemberUser(socialLogin("google-sub-relogin", "GOOGLE")); + + assertThat(userRepository.count()).isEqualTo(1); + } + + @Test + @DisplayName("탈퇴한 사용자는 같은 소셜 계정으로 다시 가입할 수 있다") + void allowsRejoinAfterWithdrawal() { + userAuthService.signUpMemberUser(socialLogin("google-sub-rejoin", "GOOGLE")); + Long userId = userRepository.findByIdentifier("google-sub-rejoin").orElseThrow().getId(); + + userService.deleteUserById(userId); + + assertThatCode(() -> userAuthService.signUpMemberUser(socialLogin("google-sub-rejoin", "GOOGLE"))) + .as("탈퇴 후 재가입이 막히면 사용자는 영영 서비스를 못 쓴다") + .doesNotThrowAnyException(); + + User rejoined = userRepository.findByIdentifier("google-sub-rejoin").orElseThrow(); + assertThat(rejoined.getId()) + .as("재가입은 새 계정이어야 한다") + .isNotEqualTo(userId); + assertThat(userRepository.findById(userId)) + .as("탈퇴한 계정은 조회에서 빠진다") + .isEmpty(); + } + } + + @Nested + @DisplayName("리프레시 토큰 저장") + class RefreshTokenPersistence { + + @Test + @DisplayName("발급한 토큰이 refresh_token 컬럼에 잘리지 않고 그대로 저장된다") + void storesTokenWithoutTruncation() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + + String stored = (String) entityManager + .createNativeQuery("SELECT refresh_token FROM refresh_token WHERE user_id IS NOT NULL LIMIT 1") + .getSingleResult(); + + assertThat(stored) + .as("한 글자라도 잘리면 이후 갱신 요청이 전부 1002 가 된다") + .isEqualTo(tokens.getRefreshToken()); + } + + @Test + @DisplayName("발급 가능한 최대 토큰 길이가 refresh_token 컬럼 길이 안에 들어간다") + void tokenFitsInColumn() { + int columnLength = refreshTokenColumnLength(); + int longestToken = IntStream.range(0, 30) + .map(i -> jwtTokenizer.createRefreshToken( + String.valueOf(Long.MAX_VALUE), + java.util.Map.of("authority", Authority.ROLE_MEMBER)).length()) + .max() + .orElseThrow(); + + System.out.printf("refresh_token 컬럼=%d, 발급 토큰 최대 길이=%d, 여유=%d%n", + columnLength, longestToken, columnLength - longestToken); + + assertThat(longestToken) + .as("컬럼(%d)보다 긴 토큰은 저장되지 못하고 갱신이 1002 로 실패한다", columnLength) + .isLessThanOrEqualTo(columnLength); + assertThat(columnLength - longestToken) + .as("클레임이 하나만 늘어도 넘칠 만큼 여유가 없으면 안 된다") + .isGreaterThanOrEqualTo(64); + } + + @Test + @DisplayName("저장한 토큰 문자열로 세션을 다시 찾아 갱신까지 왕복한다") + void roundTripsThroughDatabase() { + TokenResponseDto issued = userAuthService.signUpGuestUser(); + + TokenResponseDto refreshed = userAuthService.refreshAccessToken( + new TokenRequestDto(issued.getAccessToken(), issued.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(refreshed.getRefreshToken())) + .as("회전된 새 토큰도 그대로 저장돼야 다음 갱신이 이어진다") + .isPresent(); + assertThat(refreshTokenRepository.findByRefreshToken(issued.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.count()) + .as("갱신은 세션 수를 늘리지 않고 회전만 시킨다") + .isEqualTo(1); + } + + @Test + @DisplayName("여러 기기에서 로그인하면 세션이 기기 수만큼 쌓이고 각자 갱신된다") + void keepsIndependentSessionsPerDevice() { + TokenResponseDto phone = userAuthService.signUpMemberUser(socialLogin("google-sub-devices", "GOOGLE")); + TokenResponseDto tablet = userAuthService.signUpMemberUser(socialLogin("google-sub-devices", "GOOGLE")); + + assertThat(refreshTokenRepository.count()).isEqualTo(2); + + userAuthService.refreshAccessToken(new TokenRequestDto(null, phone.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(tablet.getRefreshToken())) + .as("한 기기의 갱신이 다른 기기 세션을 무효화하면 안 된다") + .isPresent(); + } + + @Test + @DisplayName("로그아웃하면 세션 행이 사라진다") + void deletesSessionOnLogout() { + TokenResponseDto tokens = userAuthService.signUpGuestUser(); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(tokens.getRefreshToken()); + + jwtTokenService.logout(tokens.getAccessToken(), userId, tokens.getRefreshToken()); + + assertThat(refreshTokenRepository.findByRefreshToken(tokens.getRefreshToken())).isEmpty(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java new file mode 100644 index 00000000..1fb5c5a2 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceTest.java @@ -0,0 +1,139 @@ +package com.aisip.OnO.backend.auth.service; + +import com.aisip.OnO.backend.auth.dto.TokenRequestDto; +import com.aisip.OnO.backend.auth.dto.TokenResponseDto; +import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.user.dto.UserRegisterDto; +import com.aisip.OnO.backend.user.dto.UserResponseDto; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import com.aisip.OnO.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * 가입 경로별로 부여되는 권한과, 인증 흐름의 위임 관계를 고정한다. + * + *

게스트에게 ROLE_MEMBER 가 새어나가면 게스트 계정으로 멤버 전용 경로가 열린다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("UserAuthService") +class UserAuthServiceTest { + + @Mock + private JwtTokenService jwtTokenService; + + @Mock + private UserService userService; + + @InjectMocks + private UserAuthService userAuthService; + + private static UserResponseDto userResponse(Long userId) { + return new UserResponseDto( + userId, "홍길동", "user@test.ono", null, + 1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 40L, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("게스트 가입") + class SignUpGuest { + + @Test + @DisplayName("게스트 사용자를 만들고 ROLE_GUEST 토큰을 발급한다") + void issuesGuestAuthorityToken() { + given(userService.registerGuestUser()).willReturn(userResponse(10L)); + given(jwtTokenService.generateTokens(10L, Authority.ROLE_GUEST)) + .willReturn(new TokenResponseDto("Bearer access", "refresh")); + + TokenResponseDto response = userAuthService.signUpGuestUser(); + + assertThat(response.getAccessToken()).isEqualTo("Bearer access"); + verify(jwtTokenService).generateTokens(10L, Authority.ROLE_GUEST); + verify(jwtTokenService, never()).generateTokens(anyLong(), eq(Authority.ROLE_MEMBER)); + } + } + + @Nested + @DisplayName("멤버 가입") + class SignUpMember { + + @Test + @DisplayName("소셜 로그인 정보를 넘기면 ROLE_MEMBER 토큰을 발급한다") + void issuesMemberAuthorityToken() { + UserRegisterDto registerDto = UserRegisterDto.builder() + .email("member@test.ono") + .name("홍길동") + .identifier("google-sub-123") + .platform("GOOGLE") + .build(); + given(userService.registerMemberUser(registerDto)).willReturn(userResponse(20L)); + given(jwtTokenService.generateTokens(20L, Authority.ROLE_MEMBER)) + .willReturn(new TokenResponseDto("Bearer access", "refresh")); + + TokenResponseDto response = userAuthService.signUpMemberUser(registerDto); + + assertThat(response.getRefreshToken()).isEqualTo("refresh"); + verify(userService).registerMemberUser(registerDto); + verify(jwtTokenService).generateTokens(20L, Authority.ROLE_MEMBER); + } + + @Test + @DisplayName("사용자 등록이 실패하면 토큰을 발급하지 않는다") + void doesNotIssueTokenWhenRegistrationFails() { + UserRegisterDto registerDto = UserRegisterDto.builder().identifier("broken").build(); + willThrow(new ApplicationException(UserErrorCase.USER_NOT_FOUND)) + .given(userService).registerMemberUser(registerDto); + + assertThatThrownBy(() -> userAuthService.signUpMemberUser(registerDto)) + .isInstanceOf(ApplicationException.class); + + verify(jwtTokenService, never()).generateTokens(anyLong(), any(Authority.class)); + } + } + + @Nested + @DisplayName("갱신·로그아웃 위임") + class Delegation { + + @Test + @DisplayName("본문의 refreshToken 을 그대로 토큰 서비스에 넘긴다") + void passesRefreshTokenThrough() { + given(jwtTokenService.refreshAccessToken("refresh")) + .willReturn(new TokenResponseDto("Bearer new-access", "new-refresh")); + + TokenResponseDto response = userAuthService.refreshAccessToken( + new TokenRequestDto("Bearer old-access", "refresh")); + + assertThat(response.getAccessToken()).isEqualTo("Bearer new-access"); + verify(jwtTokenService).refreshAccessToken("refresh"); + } + + @Test + @DisplayName("로그아웃은 액세스 토큰·userId·리프레시 토큰을 그대로 전달한다") + void passesLogoutArgumentsThrough() { + userAuthService.logout("Bearer access", 30L, "refresh"); + + verify(jwtTokenService).logout("Bearer access", 30L, "refresh"); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java b/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java new file mode 100644 index 00000000..c4daaecc --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/token/RedisTokenServiceTest.java @@ -0,0 +1,154 @@ +package com.aisip.OnO.backend.auth.token; + +import com.aisip.OnO.backend.util.redis.RedisSingleDataService; +import com.aisip.OnO.backend.util.redis.RedisTokenService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.RedisConnectionFailureException; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * Redis 토큰 저장소의 키 규칙·TTL 계약과, Redis 장애 시 동작을 고정한다. + * + *

블랙리스트 조회는 인증 필터가 모든 인증 요청마다 호출한다. + * 여기서 예외가 새어나가면 Redis 장애가 곧 전체 사용자 인증 실패로 번진다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("RedisTokenService") +class RedisTokenServiceTest { + + private static final Long USER_ID = 42L; + private static final String ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiJ9.payload.signature"; + + @Mock + private RedisSingleDataService redisSingleDataService; + + @InjectMocks + private RedisTokenService redisTokenService; + + @Nested + @DisplayName("리프레시 토큰 저장소") + class RefreshTokenStore { + + @Test + @DisplayName("RT: 프리픽스 + userId 키에 TTL 과 함께 저장한다") + void savesWithPrefixedKeyAndTtl() { + redisTokenService.saveRefreshToken(USER_ID, "refresh-token-value", 604_800L); + + ArgumentCaptor ttlCaptor = ArgumentCaptor.forClass(Duration.class); + verify(redisSingleDataService).setSingleData(eq("RT:42"), eq("refresh-token-value"), ttlCaptor.capture()); + assertThat(ttlCaptor.getValue()).isEqualTo(Duration.ofSeconds(604_800L)); + } + + @Test + @DisplayName("저장된 토큰을 userId 로 되읽는다") + void readsBackByUserId() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn("refresh-token-value"); + + assertThat(redisTokenService.getRefreshToken(USER_ID)).isEqualTo("refresh-token-value"); + } + + @Test + @DisplayName("저장된 값이 없으면 빈 문자열이 아니라 null 을 돌려준다") + void returnsNullWhenAbsent() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn(""); + + assertThat(redisTokenService.getRefreshToken(USER_ID)) + .as("빈 문자열이 그대로 나가면 호출부가 '토큰 있음' 으로 오해한다") + .isNull(); + } + + @Test + @DisplayName("삭제는 같은 키 규칙으로 지운다") + void deletesWithSameKey() { + redisTokenService.deleteRefreshToken(USER_ID); + + verify(redisSingleDataService).deleteSingleData("RT:42"); + } + + @Test + @DisplayName("사용자마다 키가 분리되어 서로의 토큰을 덮어쓰지 않는다") + void isolatesKeysPerUser() { + given(redisSingleDataService.getSingleData("RT:42")).willReturn("mine"); + given(redisSingleDataService.getSingleData("RT:43")).willReturn("theirs"); + + assertThat(redisTokenService.getRefreshToken(42L)).isEqualTo("mine"); + assertThat(redisTokenService.getRefreshToken(43L)).isEqualTo("theirs"); + } + } + + @Nested + @DisplayName("액세스 토큰 블랙리스트") + class Blacklist { + + @Test + @DisplayName("BL: 프리픽스 키에 남은 만료 시간만큼만 등록한다") + void addsWithRemainingTtl() { + redisTokenService.addToBlacklist(ACCESS_TOKEN, 1_200L); + + verify(redisSingleDataService) + .setSingleData("BL:" + ACCESS_TOKEN, "logout", Duration.ofSeconds(1_200L)); + } + + @Test + @DisplayName("등록된 토큰은 블랙리스트로 판정한다") + void detectsBlacklistedToken() { + given(redisSingleDataService.getSingleData("BL:" + ACCESS_TOKEN)).willReturn("logout"); + + assertThat(redisTokenService.isBlacklisted(ACCESS_TOKEN)).isTrue(); + } + + @Test + @DisplayName("등록되지 않은 토큰은 통과시킨다") + void allowsUnknownToken() { + given(redisSingleDataService.getSingleData(anyString())).willReturn(""); + + assertThat(redisTokenService.isBlacklisted(ACCESS_TOKEN)).isFalse(); + } + } + + @Nested + @DisplayName("Redis 장애") + class RedisOutage { + + /** + * 현재 구현은 조회 실패를 그대로 던진다. 인증 필터는 이 예외를 잡아 + * AUTHENTICATION_FAILED 로 처리하므로, Redis 가 죽으면 정상 토큰을 가진 + * 사용자도 401 을 받는다. 이 테스트는 그 동작을 명시적으로 못 박는다. + */ + @Test + @DisplayName("블랙리스트 조회 실패는 그대로 전파된다 — Redis 장애가 인증 실패로 번지는 지점") + void propagatesLookupFailure() { + given(redisSingleDataService.getSingleData(anyString())) + .willThrow(new RedisConnectionFailureException("redis down")); + + assertThatThrownBy(() -> redisTokenService.isBlacklisted(ACCESS_TOKEN)) + .isInstanceOf(RedisConnectionFailureException.class); + } + + @Test + @DisplayName("쓰기 실패는 예외 없이 흡수된다") + void swallowsWriteFailure() { + given(redisSingleDataService.setSingleData(anyString(), any(), any(Duration.class))).willReturn(0); + + redisTokenService.addToBlacklist(ACCESS_TOKEN, 60L); + + verify(redisSingleDataService).setSingleData(anyString(), any(), any(Duration.class)); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java new file mode 100644 index 00000000..84530162 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/aop/LoggingAspectTest.java @@ -0,0 +1,101 @@ +package com.aisip.OnO.backend.common.aop; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.user.exception.UserErrorCase; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.Signature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 서비스 계층에서 튀어나온 예외의 로깅 정책. + * + *

ApplicationException 은 의도된 4xx 라 로그를 남기지 않고, + * 요청 컨텍스트(traceId)가 있으면 전역 예외 핸들러가 ERROR 로 남기므로 여기서는 중복을 피한다. + */ +@DisplayName("서비스 예외 로깅 어드바이스") +class LoggingAspectTest { + + private LoggingAspect aspect; + private Logger aspectLogger; + private ListAppender appender; + + @BeforeEach + void setUp() { + MDC.clear(); + aspect = new LoggingAspect(); + aspectLogger = (Logger) LoggerFactory.getLogger(LoggingAspect.class); + appender = new ListAppender<>(); + appender.start(); + aspectLogger.addAppender(appender); + aspectLogger.setLevel(Level.DEBUG); + } + + @AfterEach + void tearDown() { + aspectLogger.detachAppender(appender); + appender.stop(); + MDC.clear(); + } + + @Test + @DisplayName("ApplicationException 은 의도된 흐름이라 아무것도 남기지 않는다") + void skipsApplicationException() { + aspect.logAfterThrowing(joinPoint(), new ApplicationException(UserErrorCase.USER_NOT_FOUND)); + + assertThat(appender.list) + .as("4xx 로 응답할 예외까지 로그로 남기면 에러 로그가 의미를 잃는다") + .isEmpty(); + } + + @Test + @DisplayName("요청 컨텍스트가 있으면 전역 핸들러가 남기므로 DEBUG 로만 남긴다") + void logsDebugWhenInsideRequest() { + MDC.put("traceId", "trace-1234"); + + aspect.logAfterThrowing(joinPoint(), new IllegalStateException("boom")); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.DEBUG); + assertThat(event.getFormattedMessage()) + .contains("UserService.findUser") + .contains("IllegalStateException"); + } + + @Test + @DisplayName("요청 컨텍스트 밖(배치/스케줄러)에서 터진 예외는 ERROR 로 스택과 함께 남긴다") + void logsErrorOutsideRequest() { + RuntimeException exception = new IllegalStateException("boom"); + + aspect.logAfterThrowing(joinPoint(), exception); + + assertThat(appender.list).hasSize(1); + ILoggingEvent event = appender.list.get(0); + assertThat(event.getLevel()).isEqualTo(Level.ERROR); + assertThat(event.getFormattedMessage()).contains("UserService.findUser"); + assertThat(event.getThrowableProxy()) + .as("배치 경로는 이 로그가 유일한 단서라 스택이 필요하다") + .isNotNull(); + } + + private JoinPoint joinPoint() { + Signature signature = mock(Signature.class); + when(signature.toShortString()).thenReturn("UserService.findUser(..)"); + JoinPoint joinPoint = mock(JoinPoint.class); + when(joinPoint.getSignature()).thenReturn(signature); + return joinPoint; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java new file mode 100644 index 00000000..c0862e55 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java @@ -0,0 +1,90 @@ +package com.aisip.OnO.backend.common.emoji; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 앱이 보내는 이모지 키 화이트리스트 검증. + * + *

허용 목록에 없는 키가 통과하면 앱이 렌더링할 에셋을 찾지 못하므로 서버에서 막는다. + */ +@DisplayName("커스텀 이모지 검증") +class CustomEmojiValidatorTest { + + private final CustomEmojiValidator validator = new CustomEmojiValidator(); + + @Nested + @DisplayName("허용된 키") + class AllowedKey { + + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"happy_tears", "studying_together", "trophy_celebration", "wearing_scarf"}) + @DisplayName("화이트리스트에 있으면 통과한다") + void passesWhitelistedKey(String emojiKey) { + assertThat(validator.isAllowed(emojiKey)).isTrue(); + assertThatCode(() -> validator.validate(emojiKey)).doesNotThrowAnyException(); + } + } + + @Nested + @DisplayName("허용되지 않은 키") + class DisallowedKey { + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"unknown_emoji", "HAPPY_TEARS", " happy_tears", "happy_tears ", "😀", " + + +

+ +
+
+

공지 관리

+

등록한 공지는 유저가 앱 메인에 들어올 때 팝업으로 보입니다. 동시에 한 건만 노출됩니다.

+
+ +
+
+ + +
+

현재 노출 중인 공지

+ +
+ 지금 노출 중인 공지가 없습니다. +
+ +
+
+
+
제목
+
+
+
+
내용
+
+
+
+
유형
+
+
+
+
노출 종료
+
+
+
+ +
+ +
+
+
+ + +
+

새 공지 등록

+

이미 노출 중인 공지가 있으면 자동으로 내려가고 새 공지로 바뀝니다.

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+ + diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 5e2b098d..96eef239 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -113,6 +113,24 @@

스터디룸 관리

+ + +
+
+
+ + + +
+ + + +
+

공지 관리

+

앱 메인 팝업 공지 등록 및 제거

+
+
+
From 67fee45d69446d051318748e372e6ee8e52ba7c8 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 7 Sep 2026 23:45:33 +0900 Subject: [PATCH 041/130] =?UTF-8?q?[Test]=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=B5=EC=A7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실제 MySQL, Redis 컨테이너 위에서 등록·제거·그만 보기·노출 기간을 검증 - 공지 없음이 캐싱된 뒤 등록해도 즉시 반영되는지, 내린 직후 조회에서 바로 사라지는지를 중점으로 봄. 캐시가 옛 상태를 들고 있는 것이 이 기능의 위험 지점 - 그만 보기가 누른 유저에게만 적용되고 다른 유저는 계속 보는지 확인 - API 계약으로 공지가 없을 때 data 키가 빠져 나가는 것을 고정 --- .../controller/NoticeControllerTest.java | 113 ++++++++ .../notice/service/NoticeServiceTest.java | 252 ++++++++++++++++++ 2 files changed, 365 insertions(+) create mode 100644 src/test/java/com/aisip/OnO/backend/notice/controller/NoticeControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/notice/service/NoticeServiceTest.java diff --git a/src/test/java/com/aisip/OnO/backend/notice/controller/NoticeControllerTest.java b/src/test/java/com/aisip/OnO/backend/notice/controller/NoticeControllerTest.java new file mode 100644 index 00000000..b5bff94b --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/notice/controller/NoticeControllerTest.java @@ -0,0 +1,113 @@ +package com.aisip.OnO.backend.notice.controller; + +import com.aisip.OnO.backend.notice.dto.NoticeCreateRequestDto; +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.notice.entity.NoticeType; +import com.aisip.OnO.backend.notice.service.NoticeService; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DisplayName("서비스 공지 API") +class NoticeControllerTest extends IntegrationTestSupport { + + private static final String ACTIVE_CACHE_KEY = "NOTICE:ACTIVE"; + + @Autowired + private NoticeService noticeService; + + @Autowired + private RedisTemplate redisTemplate; + + private User user; + + @BeforeEach + void setUp() { + redisTemplate.delete(ACTIVE_CACHE_KEY); + user = fixtures.createUser(); + } + + private NoticeResponseDto registerNotice() { + return noticeService.registerNotice( + new NoticeCreateRequestDto("점검 안내", "오늘 밤 점검이 있습니다.", NoticeType.WARNING, 24)); + } + + @Nested + @DisplayName("활성 공지 조회") + class GetActiveNotice { + + @Test + @DisplayName("공지가 있으면 내용을 내려준다") + void returnsActiveNotice() throws Exception { + NoticeResponseDto notice = registerNotice(); + authenticateAs(user.getId()); + + mockMvc.perform(get("/api/notices/active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.noticeId").value(notice.noticeId())) + .andExpect(jsonPath("$.data.title").value("점검 안내")) + .andExpect(jsonPath("$.data.type").value("WARNING")) + .andExpect(jsonPath("$.data.expiresAt").exists()); + } + + @Test + @DisplayName("공지가 없으면 data 없이 성공만 내려준다") + void returnsNoDataWhenNoticeAbsent() throws Exception { + authenticateAs(user.getId()); + + mockMvc.perform(get("/api/notices/active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()) + .andExpect(jsonPath("$.errorCode").doesNotExist()); + } + + @Test + @DisplayName("인증하지 않으면 접근할 수 없다") + void rejectsUnauthenticatedRequest() throws Exception { + registerNotice(); + clearAuthentication(); + + mockMvc.perform(get("/api/notices/active")) + .andExpect(status().isUnauthorized()); + } + } + + @Nested + @DisplayName("그만 보기") + class DismissNotice { + + @Test + @DisplayName("그만 보기를 누르면 다음 조회에서 안 나온다") + void hidesNoticeAfterDismiss() throws Exception { + NoticeResponseDto notice = registerNotice(); + authenticateAs(user.getId()); + + mockMvc.perform(post("/api/notices/{noticeId}/dismiss", notice.noticeId())) + .andExpect(status().isOk()); + + mockMvc.perform(get("/api/notices/active")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("없는 공지를 그만 보기 하면 404 를 낸다") + void returnsNotFoundForUnknownNotice() throws Exception { + authenticateAs(user.getId()); + + mockMvc.perform(post("/api/notices/{noticeId}/dismiss", 999_999L)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(14001)); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/notice/service/NoticeServiceTest.java b/src/test/java/com/aisip/OnO/backend/notice/service/NoticeServiceTest.java new file mode 100644 index 00000000..7926880c --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/notice/service/NoticeServiceTest.java @@ -0,0 +1,252 @@ +package com.aisip.OnO.backend.notice.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.notice.dto.NoticeCreateRequestDto; +import com.aisip.OnO.backend.notice.dto.NoticeResponseDto; +import com.aisip.OnO.backend.notice.entity.NoticeType; +import com.aisip.OnO.backend.notice.entity.ServiceNotice; +import com.aisip.OnO.backend.notice.exception.NoticeErrorCase; +import com.aisip.OnO.backend.notice.repository.ServiceNoticeRepository; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 실제 MySQL 과 Redis 컨테이너 위에서 검증하는 서비스 공지. + * + *

공지 조회는 캐시를 거치기 때문에 "DB 는 바뀌었는데 캐시가 옛 상태를 들고 있는" 경우가 + * 이 기능의 유일한 위험 지점이다. 등록과 제거 직후 조회가 바로 바뀌는지를 중점으로 본다. + */ +@DisplayName("서비스 공지") +class NoticeServiceTest extends IntegrationTestSupport { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final String ACTIVE_CACHE_KEY = "NOTICE:ACTIVE"; + + @Autowired + private NoticeService noticeService; + + @Autowired + private ServiceNoticeRepository serviceNoticeRepository; + + @Autowired + private RedisTemplate redisTemplate; + + private User user; + private User other; + + @BeforeEach + void setUp() { + // 활성 공지 캐시는 키가 하나뿐이라 앞선 테스트가 남긴 값이 그대로 넘어온다. + redisTemplate.delete(ACTIVE_CACHE_KEY); + user = fixtures.createUser(); + other = fixtures.createOtherUser(); + } + + private NoticeResponseDto registerNotice(String title) { + return noticeService.registerNotice( + new NoticeCreateRequestDto(title, "본문", NoticeType.INFO, 24)); + } + + private void assertNoticeError(NoticeErrorCase expected, Runnable action) { + assertThatThrownBy(action::run) + .isInstanceOf(ApplicationException.class) + .extracting(thrown -> ((ApplicationException) thrown).getErrorCase()) + .isEqualTo(expected); + } + + @Nested + @DisplayName("공지 등록") + class RegisterNotice { + + @Test + @DisplayName("등록하면 유저 조회에 바로 나온다") + void registeredNoticeIsVisible() { + NoticeResponseDto registered = registerNotice("점검 안내"); + + NoticeResponseDto found = noticeService.findActiveNoticeForUser(user.getId()); + + assertThat(found).isNotNull(); + assertThat(found.noticeId()).isEqualTo(registered.noticeId()); + assertThat(found.title()).isEqualTo("점검 안내"); + assertThat(found.type()).isEqualTo(NoticeType.INFO); + } + + @Test + @DisplayName("공지가 없다고 캐싱된 뒤에 등록해도 곧바로 반영된다") + void registrationInvalidatesEmptyCache() { + // 공지 없음이 캐싱되는 상황을 먼저 만든다 + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNull(); + assertThat(redisTemplate.hasKey(ACTIVE_CACHE_KEY)).isTrue(); + + registerNotice("긴급 공지"); + + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNotNull(); + } + + @Test + @DisplayName("새로 등록하면 걸려 있던 공지는 내려간다") + void newNoticeReplacesPreviousOne() { + NoticeResponseDto first = registerNotice("첫 공지"); + NoticeResponseDto second = registerNotice("두 번째 공지"); + + NoticeResponseDto found = noticeService.findActiveNoticeForUser(user.getId()); + + assertThat(found.noticeId()).isEqualTo(second.noticeId()); + assertThat(serviceNoticeRepository.findById(first.noticeId())) + .as("이전 공지는 soft delete 되어 조회되지 않는다") + .isEmpty(); + } + + @Test + @DisplayName("노출 시간을 비우면 24시간으로 잡힌다") + void defaultsToTwentyFourHours() { + LocalDateTime before = LocalDateTime.now(KST); + + NoticeResponseDto notice = noticeService.registerNotice( + new NoticeCreateRequestDto("공지", "본문", NoticeType.EVENT, null)); + + assertThat(notice.expiresAt()) + .isAfterOrEqualTo(before.plusHours(24).minusMinutes(1)) + .isBeforeOrEqualTo(before.plusHours(24).plusMinutes(1)); + } + } + + @Nested + @DisplayName("등록 값 검증") + class Validation { + + @Test + @DisplayName("제목이 비면 거절한다") + void rejectsBlankTitle() { + assertNoticeError(NoticeErrorCase.NOTICE_TITLE_INVALID, () -> + noticeService.registerNotice( + new NoticeCreateRequestDto(" ", "본문", NoticeType.INFO, 24))); + } + + @Test + @DisplayName("내용이 500자를 넘으면 거절한다") + void rejectsTooLongContent() { + String tooLong = "가".repeat(501); + + assertNoticeError(NoticeErrorCase.NOTICE_CONTENT_INVALID, () -> + noticeService.registerNotice( + new NoticeCreateRequestDto("제목", tooLong, NoticeType.INFO, 24))); + } + + @Test + @DisplayName("유형을 비우면 거절한다") + void rejectsNullType() { + assertNoticeError(NoticeErrorCase.NOTICE_TYPE_REQUIRED, () -> + noticeService.registerNotice( + new NoticeCreateRequestDto("제목", "본문", null, 24))); + } + + @Test + @DisplayName("노출 시간이 범위를 벗어나면 거절한다") + void rejectsOutOfRangeDuration() { + assertNoticeError(NoticeErrorCase.NOTICE_DURATION_INVALID, () -> + noticeService.registerNotice( + new NoticeCreateRequestDto("제목", "본문", NoticeType.INFO, 0))); + assertNoticeError(NoticeErrorCase.NOTICE_DURATION_INVALID, () -> + noticeService.registerNotice( + new NoticeCreateRequestDto("제목", "본문", NoticeType.INFO, 169))); + } + } + + @Nested + @DisplayName("그만 보기") + class Dismiss { + + @Test + @DisplayName("누른 유저에게만 안 보이고 다른 유저는 계속 본다") + void hidesOnlyForDismissingUser() { + NoticeResponseDto notice = registerNotice("공지"); + + noticeService.dismissNotice(notice.noticeId(), user.getId()); + + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNull(); + assertThat(noticeService.findActiveNoticeForUser(other.getId())).isNotNull(); + } + + @Test + @DisplayName("공지를 새로 올리면 그만 보기를 눌렀던 유저도 다시 본다") + void newNoticeIsVisibleAgainAfterDismiss() { + NoticeResponseDto first = registerNotice("첫 공지"); + noticeService.dismissNotice(first.noticeId(), user.getId()); + + NoticeResponseDto second = registerNotice("두 번째 공지"); + + NoticeResponseDto found = noticeService.findActiveNoticeForUser(user.getId()); + assertThat(found).isNotNull(); + assertThat(found.noticeId()).isEqualTo(second.noticeId()); + } + + @Test + @DisplayName("없는 공지를 그만 보기 하면 거절한다") + void rejectsUnknownNotice() { + assertNoticeError(NoticeErrorCase.NOTICE_NOT_FOUND, () -> + noticeService.dismissNotice(999_999L, user.getId())); + } + } + + @Nested + @DisplayName("공지 제거") + class RemoveNotice { + + @Test + @DisplayName("내리면 조회에서 바로 사라진다") + void removedNoticeDisappearsImmediately() { + NoticeResponseDto notice = registerNotice("공지"); + // 캐시에 올려 둔 상태에서 내려야 캐시가 실제로 비워지는지 확인할 수 있다 + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNotNull(); + + noticeService.removeNotice(notice.noticeId()); + + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNull(); + } + + @Test + @DisplayName("없는 공지를 내리면 거절한다") + void rejectsUnknownNotice() { + assertNoticeError(NoticeErrorCase.NOTICE_NOT_FOUND, () -> + noticeService.removeNotice(999_999L)); + } + } + + @Nested + @DisplayName("노출 기간") + class ActivePeriod { + + @Test + @DisplayName("기간이 지난 공지는 나오지 않는다") + void expiredNoticeIsNotReturned() { + LocalDateTime now = LocalDateTime.now(KST); + serviceNoticeRepository.save(ServiceNotice.of( + "지난 공지", "본문", NoticeType.INFO, now.minusHours(2), now.minusHours(1))); + + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNull(); + } + + @Test + @DisplayName("시작 전인 공지는 나오지 않는다") + void notYetStartedNoticeIsNotReturned() { + LocalDateTime now = LocalDateTime.now(KST); + serviceNoticeRepository.save(ServiceNotice.of( + "예약 공지", "본문", NoticeType.INFO, now.plusHours(1), now.plusHours(2))); + + assertThat(noticeService.findActiveNoticeForUser(user.getId())).isNull(); + } + } +} From ecedbe8305e3888e4df676abdda2113fcbb7fd75 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 8 Sep 2026 10:50:48 +0900 Subject: [PATCH 042/130] =?UTF-8?q?[Feat]=20=EC=98=A4=EB=8B=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EB=B3=B5=EC=8A=B5=20=EA=B8=B0=EB=A1=9D=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B6=84=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - problem_solve 에 mood_emoji_key 컬럼 추가 (V28, nullable, 백필 없음) - 복습 등록·수정 요청과 조회 응답에 moodEmojiKey 필드 추가 - CustomEmojiValidator 로 화이트리스트 검증, 안 고르면 null 로 둔다 - 복습 세트와 달리 회차마다 이모지가 따로 남고 목록 조회에 그대로 실린다 - 등록·수정·해제·잘못된 키 거절·회차별 보존 테스트 추가 - ProblemSolve.create 시그니처가 바뀌어 기존 테스트 픽스처도 함께 맞췄다 --- .../dto/ProblemSolveRegisterDto.java | 3 +- .../dto/ProblemSolveResponseDto.java | 2 + .../dto/ProblemSolveUpdateDto.java | 3 +- .../problemsolve/entity/ProblemSolve.java | 10 +- .../service/ProblemSolveService.java | 13 +- .../V28__add_problem_solve_mood_emoji.sql | 5 + .../support/LearningCalendarTestSupport.java | 2 +- .../support/LearningReportTestSupport.java | 2 +- .../service/ProblemServiceEdgeCaseTest.java | 6 +- .../problemsolve/ProblemSolveTestSupport.java | 2 +- .../service/ProblemSolveServiceTest.java | 123 +++++++++++++++--- .../support/StudyRoomTestSupport.java | 2 +- 12 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java index 1e296aae..9eb54d17 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveRegisterDto.java @@ -12,6 +12,7 @@ public record ProblemSolveRegisterDto( AnswerStatus answerStatus, String reflection, List improvements, - Integer timeSpentSeconds + Integer timeSpentSeconds, + String moodEmojiKey ) { } \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java index 078a47c3..936bb3bb 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java @@ -24,6 +24,7 @@ public record ProblemSolveResponseDto( String reflection, List improvements, Integer timeSpentSeconds, + String moodEmojiKey, Boolean migratedFromLegacy, List imageUrls, @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @@ -56,6 +57,7 @@ public static ProblemSolveResponseDto from(ProblemSolve problemSolve) { .reflection(problemSolve.getReflection()) .improvements(improvementList) .timeSpentSeconds(problemSolve.getTimeSpentSeconds()) + .moodEmojiKey(problemSolve.getMoodEmojiKey()) .migratedFromLegacy(problemSolve.getMigratedFromLegacy()) .imageUrls(problemSolve.getImages().stream() .sorted((i1, i2) -> i1.getImageOrder().compareTo(i2.getImageOrder())) diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java index 1fcea56e..2025c174 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveUpdateDto.java @@ -10,6 +10,7 @@ public record ProblemSolveUpdateDto( AnswerStatus answerStatus, String reflection, List improvements, - Integer timeSpentSeconds + Integer timeSpentSeconds, + String moodEmojiKey ) { } \ No newline at end of file diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java index 9888daa9..5c6fe0f1 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/entity/ProblemSolve.java @@ -49,6 +49,10 @@ public class ProblemSolve extends BaseEntity { private Integer timeSpentSeconds; + // 이번 복습이 어땠는지 남기는 이모지. CustomEmojiValidator 화이트리스트 키이고, 안 고를 수도 있어 nullable 이다. + @Column(name = "mood_emoji_key", length = 80) + private String moodEmojiKey; + @Column(nullable = false) @Builder.Default private Boolean migratedFromLegacy = false; @@ -59,7 +63,7 @@ public class ProblemSolve extends BaseEntity { public static ProblemSolve create(Problem problem, Long userId, LocalDateTime practicedAt, AnswerStatus answerStatus, String reflection, String improvements, - Integer timeSpentSeconds) { + Integer timeSpentSeconds, String moodEmojiKey) { return ProblemSolve.builder() .problem(problem) .userId(userId) @@ -68,6 +72,7 @@ public static ProblemSolve create(Problem problem, Long userId, LocalDateTime pr .reflection(reflection) .improvements(improvements) .timeSpentSeconds(timeSpentSeconds) + .moodEmojiKey(moodEmojiKey) .migratedFromLegacy(false) .build(); } @@ -88,10 +93,11 @@ public void addImage(ProblemSolveImageData image) { } public void updateSolve(AnswerStatus answerStatus, String reflection, String improvements, - Integer timeSpentSeconds) { + Integer timeSpentSeconds, String moodEmojiKey) { this.answerStatus = answerStatus; this.reflection = reflection; this.improvements = improvements; this.timeSpentSeconds = timeSpentSeconds; + this.moodEmojiKey = moodEmojiKey; } } diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index 5d7b46d3..c5996136 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.problemsolve.service; +import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.service.MissionLogService; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; @@ -54,6 +55,7 @@ public class ProblemSolveService { private final StreakCacheService streakCacheService; private final ApplicationEventPublisher eventPublisher; private final ProblemReviewReminderService reminderService; + private final CustomEmojiValidator customEmojiValidator; @Qualifier("s3UploadExecutor") private final Executor s3UploadExecutor; @@ -118,6 +120,9 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { throw new ApplicationException(ProblemErrorCase.PROBLEM_USER_UNMATCHED); } + // 이모지는 선택 사항이라 null 은 허용하고, 값이 있으면 화이트리스트에 있는 키인지만 본다. + customEmojiValidator.validateNullable(dto.moodEmojiKey()); + // improvements를 JSON 문자열로 변환 String improvementsJson = null; if (dto.improvements() != null && !dto.improvements().isEmpty()) { @@ -141,7 +146,8 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { dto.answerStatus(), dto.reflection(), improvementsJson, - dto.timeSpentSeconds() + dto.timeSpentSeconds(), + dto.moodEmojiKey() ); problemSolveRepository.save(problemSolve); @@ -238,6 +244,8 @@ public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { throw new ApplicationException(ProblemSolveErrorCase.PROBLEM_SOLVE_USER_UNMATCHED); } + customEmojiValidator.validateNullable(dto.moodEmojiKey()); + // improvements를 JSON 문자열로 변환 String improvementsJson = null; if (dto.improvements() != null && !dto.improvements().isEmpty()) { @@ -253,7 +261,8 @@ public void updateProblemSolve(ProblemSolveUpdateDto dto, Long userId) { dto.answerStatus(), dto.reflection(), improvementsJson, - dto.timeSpentSeconds() + dto.timeSpentSeconds(), + dto.moodEmojiKey() ); log.info("userId: {} updated problem solve: {}", userId, problemSolve.getId()); diff --git a/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql b/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql new file mode 100644 index 00000000..b3fed806 --- /dev/null +++ b/src/main/resources/db/migration/V28__add_problem_solve_mood_emoji.sql @@ -0,0 +1,5 @@ +-- 복습 기록마다 "이번 복습 어땠는지"를 이모지로 남긴다. +-- 이모지 키는 CustomEmojiValidator 화이트리스트 값이라 learning_calendar_mood 와 같은 VARCHAR(80) 을 쓴다. +-- 기존 복습 기록은 이모지 없이 그대로 두므로 nullable 이고 백필하지 않는다. +ALTER TABLE problem_solve + ADD COLUMN mood_emoji_key VARCHAR(80) NULL; diff --git a/src/test/java/com/aisip/OnO/backend/learningcalendar/support/LearningCalendarTestSupport.java b/src/test/java/com/aisip/OnO/backend/learningcalendar/support/LearningCalendarTestSupport.java index 62a9e110..d6b96ed4 100644 --- a/src/test/java/com/aisip/OnO/backend/learningcalendar/support/LearningCalendarTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/learningcalendar/support/LearningCalendarTestSupport.java @@ -68,7 +68,7 @@ protected ProblemSolve saveSolveAt( Long userId, Problem problem, LocalDateTime practicedAt, Integer seconds, AnswerStatus answerStatus ) { return problemSolveRepository.save( - ProblemSolve.create(problem, userId, practicedAt, answerStatus, null, null, seconds) + ProblemSolve.create(problem, userId, practicedAt, answerStatus, null, null, seconds, null) ); } diff --git a/src/test/java/com/aisip/OnO/backend/learningreport/support/LearningReportTestSupport.java b/src/test/java/com/aisip/OnO/backend/learningreport/support/LearningReportTestSupport.java index c66e5eb6..57bf5575 100644 --- a/src/test/java/com/aisip/OnO/backend/learningreport/support/LearningReportTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/learningreport/support/LearningReportTestSupport.java @@ -82,7 +82,7 @@ protected ProblemSolve saveSolve( Long userId, Problem problem, LocalDateTime practicedAt, AnswerStatus answerStatus, Integer seconds ) { return problemSolveRepository.save( - ProblemSolve.create(problem, userId, practicedAt, answerStatus, null, null, seconds) + ProblemSolve.create(problem, userId, practicedAt, answerStatus, null, null, seconds, null) ); } diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java index a94943d7..d085a6a7 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java @@ -410,9 +410,9 @@ void includesSolveCountAndLastSolvedAt() { LocalDateTime firstSolvedAt = LocalDateTime.now().minusDays(2).withNano(0); LocalDateTime lastSolvedAt = LocalDateTime.now().minusDays(1).withNano(0); problemSolveRepository.save(ProblemSolve.create(solved, owner.getId(), firstSolvedAt, - AnswerStatus.CORRECT, null, null, 60)); + AnswerStatus.CORRECT, null, null, 60, null)); problemSolveRepository.saveAndFlush(ProblemSolve.create(solved, owner.getId(), lastSolvedAt, - AnswerStatus.WRONG, null, null, 90)); + AnswerStatus.WRONG, null, null, 90, null)); List problems = problemService.findUserProblems(owner.getId()); @@ -438,7 +438,7 @@ void singleLookupIncludesSolveSummary() { Problem problem = saveProblem(owner.getId(), ownerRoot); LocalDateTime solvedAt = LocalDateTime.now().minusHours(3).withNano(0); problemSolveRepository.saveAndFlush(ProblemSolve.create(problem, owner.getId(), solvedAt, - AnswerStatus.CORRECT, null, null, 30)); + AnswerStatus.CORRECT, null, null, 30, null)); ProblemResponseDto dto = problemService.findProblem(problem.getId(), owner.getId()); diff --git a/src/test/java/com/aisip/OnO/backend/problemsolve/ProblemSolveTestSupport.java b/src/test/java/com/aisip/OnO/backend/problemsolve/ProblemSolveTestSupport.java index 2438f11f..14a001c9 100644 --- a/src/test/java/com/aisip/OnO/backend/problemsolve/ProblemSolveTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/problemsolve/ProblemSolveTestSupport.java @@ -69,7 +69,7 @@ protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime pra protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime practicedAt, AnswerStatus status) { return problemSolveRepository.save( - ProblemSolve.create(problem, userId, practicedAt, status, "회고", null, 120)); + ProblemSolve.create(problem, userId, practicedAt, status, "회고", null, 120, null)); } protected ProblemSolveImageData saveImage(ProblemSolve problemSolve, String imageUrl, int order) { diff --git a/src/test/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveServiceTest.java b/src/test/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveServiceTest.java index 5ada1359..80d9fe31 100644 --- a/src/test/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveServiceTest.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.problemsolve.service; +import com.aisip.OnO.backend.common.emoji.CustomEmojiErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.exception.ErrorCase; import com.aisip.OnO.backend.problem.entity.Problem; @@ -60,7 +61,7 @@ private void assertErrorCase(ErrorCase expected, Runnable action) { } private ProblemSolveRegisterDto registerDto(Long problemId, AnswerStatus status) { - return new ProblemSolveRegisterDto(problemId, PRACTICED_AT, status, "회고", List.of(), 90); + return new ProblemSolveRegisterDto(problemId, PRACTICED_AT, status, "회고", List.of(), 90, null); } @Nested @@ -72,7 +73,7 @@ class CreateProblemSolve { void createsSolveWithGivenValues() { Long solveId = problemSolveService.createProblemSolve( new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.PARTIAL, "계산에서 막혔다", - List.of(ImprovementType.FASTER_SOLVING, ImprovementType.BETTER_UNDERSTANDING), 300), + List.of(ImprovementType.FASTER_SOLVING, ImprovementType.BETTER_UNDERSTANDING), 300, null), user.getId()); ProblemSolveResponseDto saved = problemSolveService.getProblemSolve(solveId, user.getId()); @@ -92,10 +93,10 @@ void createsSolveWithGivenValues() { @DisplayName("개선 사항이 null 이거나 비어 있으면 빈 목록으로 돌려준다") void handlesEmptyImprovements() { Long withNull = problemSolveService.createProblemSolve( - new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, null, null), + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, null, null, null), user.getId()); Long withEmpty = problemSolveService.createProblemSolve( - new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusHours(1), AnswerStatus.WRONG, null, List.of(), null), + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusHours(1), AnswerStatus.WRONG, null, List.of(), null, null), user.getId()); assertThat(problemSolveService.getProblemSolve(withNull, user.getId()).improvements()).isEmpty(); @@ -108,7 +109,7 @@ void storesLongReflection() { String longReflection = "가".repeat(3000); Long solveId = problemSolveService.createProblemSolve( - new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, longReflection, List.of(), 60), + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, longReflection, List.of(), 60, null), user.getId()); assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).reflection()) @@ -159,7 +160,7 @@ void rejectsNullDto() { @DisplayName("practicedAt 을 안 보내면 지금 푼 것으로 저장한다 - not-null 컬럼이라 예전에는 500이었다") void defaultsPracticedAtToNow() { Long solveId = problemSolveService.createProblemSolve( - new ProblemSolveRegisterDto(problem.getId(), null, AnswerStatus.CORRECT, null, List.of(), null), + new ProblemSolveRegisterDto(problem.getId(), null, AnswerStatus.CORRECT, null, List.of(), null, null), user.getId()); assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).practicedAt()) @@ -183,7 +184,7 @@ void resetsReviewScheduleOnWrongAnswer() { void marksProblemAsMasteredAfterThreeCorrectAnswers() { for (int i = 0; i < 3; i++) { problemSolveService.createProblemSolve( - new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusDays(i), AnswerStatus.CORRECT, null, List.of(), null), + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusDays(i), AnswerStatus.CORRECT, null, List.of(), null, null), user.getId()); } @@ -341,7 +342,7 @@ void updatesOwnSolve() { problemSolveService.updateProblemSolve( new ProblemSolveUpdateDto(solve.getId(), AnswerStatus.CORRECT, "이제 이해했다", - List.of(ImprovementType.NO_REPEAT_MISTAKE), 45), + List.of(ImprovementType.NO_REPEAT_MISTAKE), 45, null), user.getId()); ProblemSolveResponseDto updated = problemSolveService.getProblemSolve(solve.getId(), user.getId()); @@ -357,11 +358,11 @@ void clearsImprovements() { ProblemSolve solve = saveSolve(problem, user.getId(), PRACTICED_AT); problemSolveService.updateProblemSolve( new ProblemSolveUpdateDto(solve.getId(), AnswerStatus.CORRECT, null, - List.of(ImprovementType.FASTER_SOLVING), null), + List.of(ImprovementType.FASTER_SOLVING), null, null), user.getId()); problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(solve.getId(), AnswerStatus.CORRECT, null, List.of(), null), + new ProblemSolveUpdateDto(solve.getId(), AnswerStatus.CORRECT, null, List.of(), null, null), user.getId()); assertThat(problemSolveService.getProblemSolve(solve.getId(), user.getId()).improvements()).isEmpty(); @@ -374,7 +375,7 @@ void rejectsOtherUsersSolve() { assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_USER_UNMATCHED, () -> problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(othersSolve.getId(), AnswerStatus.CORRECT, "남의 기록 조작", List.of(), 1), + new ProblemSolveUpdateDto(othersSolve.getId(), AnswerStatus.CORRECT, "남의 기록 조작", List.of(), 1, null), user.getId())); assertThat(problemSolveService.getProblemSolve(othersSolve.getId(), other.getId()).answerStatus()) @@ -386,7 +387,7 @@ void rejectsOtherUsersSolve() { void rejectsUnknownSolve() { assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_NOT_FOUND, () -> problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(999_999L, AnswerStatus.CORRECT, null, List.of(), null), + new ProblemSolveUpdateDto(999_999L, AnswerStatus.CORRECT, null, List.of(), null, null), user.getId())); } @@ -395,12 +396,12 @@ void rejectsUnknownSolve() { void rejectsNullInput() { assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT, () -> problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(null, AnswerStatus.CORRECT, null, List.of(), null), user.getId())); + new ProblemSolveUpdateDto(null, AnswerStatus.CORRECT, null, List.of(), null, null), user.getId())); ProblemSolve solve = saveSolve(problem, user.getId(), PRACTICED_AT); assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT, () -> problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(solve.getId(), null, null, List.of(), null), user.getId())); + new ProblemSolveUpdateDto(solve.getId(), null, null, List.of(), null, null), user.getId())); assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_INVALID_INPUT, () -> problemSolveService.updateProblemSolve(null, user.getId())); } @@ -600,8 +601,100 @@ void keepsSolvesFullyIsolated() { () -> problemSolveService.deleteProblemSolve(mine.getId(), other.getId())); assertErrorCase(ProblemSolveErrorCase.PROBLEM_SOLVE_USER_UNMATCHED, () -> problemSolveService.updateProblemSolve( - new ProblemSolveUpdateDto(mine.getId(), AnswerStatus.CORRECT, null, List.of(), null), + new ProblemSolveUpdateDto(mine.getId(), AnswerStatus.CORRECT, null, List.of(), null, null), other.getId())); } } + + @Nested + @DisplayName("복습 기분 이모지") + class MoodEmoji { + + private static final String HAPPY = "excited_happy"; + private static final String STRESSED = "stressed_bomb"; + + @Test + @DisplayName("등록할 때 고른 이모지가 저장되고 조회에도 그대로 나온다") + void savesMoodEmojiOnCreate() { + Long solveId = problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, HAPPY), + user.getId()); + + assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).moodEmojiKey()) + .isEqualTo(HAPPY); + } + + @Test + @DisplayName("이모지를 안 고르면 null 로 남는다") + void allowsNullMoodEmoji() { + Long solveId = problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, null), + user.getId()); + + assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).moodEmojiKey()).isNull(); + } + + @Test + @DisplayName("화이트리스트에 없는 키는 400 으로 거절하고 기록도 남기지 않는다") + void rejectsUnknownEmojiKey() { + assertErrorCase(CustomEmojiErrorCase.INVALID_EMOJI_KEY, + () -> problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, "🙂"), + user.getId())); + + assertThat(problemSolveService.getProblemSolvesByProblemId(problem.getId(), user.getId())) + .as("검증이 저장보다 앞서야 잘못된 값이 DB 에 닿지 않는다") + .isEmpty(); + } + + @Test + @DisplayName("수정으로 이모지를 바꾸거나 지울 수 있다") + void updatesAndClearsMoodEmoji() { + Long solveId = problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, STRESSED), + user.getId()); + + problemSolveService.updateProblemSolve( + new ProblemSolveUpdateDto(solveId, AnswerStatus.CORRECT, null, List.of(), 60, HAPPY), user.getId()); + assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).moodEmojiKey()).isEqualTo(HAPPY); + + problemSolveService.updateProblemSolve( + new ProblemSolveUpdateDto(solveId, AnswerStatus.CORRECT, null, List.of(), 60, null), user.getId()); + assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).moodEmojiKey()).isNull(); + } + + @Test + @DisplayName("수정할 때도 화이트리스트에 없는 키는 거절하고 원래 이모지를 지킨다") + void rejectsUnknownEmojiKeyOnUpdate() { + Long solveId = problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, STRESSED), + user.getId()); + + assertErrorCase(CustomEmojiErrorCase.INVALID_EMOJI_KEY, + () -> problemSolveService.updateProblemSolve( + new ProblemSolveUpdateDto(solveId, AnswerStatus.CORRECT, null, List.of(), 60, "not_an_emoji"), + user.getId())); + + assertThat(problemSolveService.getProblemSolve(solveId, user.getId()).moodEmojiKey()).isEqualTo(STRESSED); + } + + @Test + @DisplayName("같은 문제라도 복습 회차마다 이모지가 따로 남는다") + void keepsMoodEmojiPerSolve() { + problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT, AnswerStatus.WRONG, null, List.of(), 60, STRESSED), + user.getId()); + problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusDays(1), AnswerStatus.CORRECT, null, List.of(), 60, HAPPY), + user.getId()); + problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto(problem.getId(), PRACTICED_AT.plusDays(2), AnswerStatus.CORRECT, null, List.of(), 60, null), + user.getId()); + + // 목록은 practicedAt 내림차순이다. + assertThat(problemSolveService.getProblemSolvesByProblemId(problem.getId(), user.getId())) + .extracting(ProblemSolveResponseDto::moodEmojiKey) + .containsExactly(null, HAPPY, STRESSED); + } + } } diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java b/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java index be38a76b..2ece4925 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java @@ -232,7 +232,7 @@ protected void savePractices(Long userId, LocalDateTime practicedAt, int count) protected void savePracticesOnly(Long userId, Problem problem, LocalDateTime practicedAt, int count) { for (int i = 0; i < count; i++) { problemSolveRepository.save(ProblemSolve.create( - problem, userId, practicedAt.plusSeconds(i), AnswerStatus.CORRECT, null, null, 60)); + problem, userId, practicedAt.plusSeconds(i), AnswerStatus.CORRECT, null, null, 60, null)); } problemSolveRepository.flush(); } From 5c7ce3ff8f19e550d8f20364f2bc102d074ca5ec Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 8 Sep 2026 11:02:26 +0900 Subject: [PATCH 043/130] =?UTF-8?q?[Test]=20=EC=8B=A4=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EA=B3=BC=20=ED=86=A0=ED=81=B0=20=EA=B0=92=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EA=B9=A8=EC=A7=80=EB=8D=98=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=91=90=20=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 주간 리포트 배치의 만료 챌린지 테스트가 화요일 12시 이전에 항상 실패했다. endAt 을 lastWeekStart.plusDays(1) 로 잡아서 그날이 오늘이 되면 아직 미래였다. 배치는 endAt 이 지금보다 앞설 때만 EXPIRED 로 바꾼다. - JWT 변조 토큰 테스트가 서명이 'A' 로 끝날 때 통과해 버렸다. base64url 마지막 글자는 남는 비트라 'A' 를 'B' 로 바꿔도 같은 서명으로 디코딩된다. 6비트가 모두 쓰이는 첫 글자를 바꾸도록 고쳤다. --- .../auth/service/JwtTokenizerValidationTest.java | 11 ++++++++--- .../service/StudyRoomWeeklyReportServiceTest.java | 6 +++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java index 038da31f..753257a0 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/service/JwtTokenizerValidationTest.java @@ -60,9 +60,14 @@ void expiredAccessTokenMapsTo1005() { @DisplayName("서명이 틀린 액세스 토큰은 만료가 아니라 INVALID_ACCESS_TOKEN(1009) 로 판정된다") void tamperedAccessTokenMapsTo1009() { String token = issueAccessToken(validTokenizer); - // 서명부 한 글자를 바꿔 검증에 실패하게 만든다 - String tampered = token.substring(0, token.length() - 1) - + (token.endsWith("A") ? "B" : "A"); + // 서명부 한 글자를 바꿔 검증에 실패하게 만든다. + // 마지막 글자는 32바이트를 base64url 43글자로 담고 남은 비트라 바꿔도 같은 바이트로 디코딩될 수 있다. + // (HS256 서명이 'A' 로 끝나면 'B' 로 바꿔도 서명이 그대로여서 검증을 통과해 버렸다) + // 첫 글자는 6비트가 모두 쓰이므로 다른 글자로 바꾸면 서명이 반드시 달라진다. + int signatureStart = token.lastIndexOf('.') + 1; + String tampered = token.substring(0, signatureStart) + + (token.charAt(signatureStart) == 'A' ? 'B' : 'A') + + token.substring(signatureStart + 1); assertThatThrownBy(() -> validTokenizer.validateAccessToken(tampered)) .isInstanceOf(ApplicationException.class) diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportServiceTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportServiceTest.java index 2865d8a5..f76ffba1 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomWeeklyReportServiceTest.java @@ -11,6 +11,7 @@ import java.time.DayOfWeek; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.TemporalAdjusters; @@ -302,9 +303,12 @@ void unfinishedIndividualChallengeStaysInProgress() { void expiredChallengesAreRefreshed() { User host = fixtures.createUser("host"); StudyRoom room = createRoom(host, "정리 방"); + // 배치는 endAt 이 지금보다 앞선 IN_PROGRESS 챌린지를 EXPIRED 로 바꾼다. + // endAt 을 lastWeekStart 기준 상대 시각으로 두면 오늘 요일에 따라 미래가 된다. + // 화요일에는 lastWeekStart 가 어제라 plusDays(1) 이 오늘이 되어 12시 전에는 만료가 아니었다. StudyRoomChallenge expired = saveChallenge(room, "만료됨", StudyRoomChallengeType.INDIVIDUAL, StudyRoomChallengeMetric.PROBLEM_COUNT, null, null, 1_000, - lastWeekStart.minusDays(3).atStartOfDay(), lastWeekStart.plusDays(1).atTime(12, 0)); + lastWeekStart.minusDays(3).atStartOfDay(), LocalDateTime.now().minusHours(1)); reportService.createPreviousWeekReports(); From bf38bb69855f7025ffedbc9c364805bc9b1f143b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 8 Sep 2026 12:00:46 +0900 Subject: [PATCH 044/130] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EC=9D=91=EB=8B=B5=EC=9D=98=20=EB=82=A0=EC=A7=9C=20?= =?UTF-8?q?=ED=98=95=EC=8B=9D=EC=9D=84=20=EB=8B=A4=EB=A5=B8=20API=20?= =?UTF-8?q?=EC=99=80=20=EA=B0=99=EC=9D=80=20ISO-8601=20=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProblemSolveResponseDto 만 @JsonFormat("yyyy-MM-dd HH:mm:ss") 로 공백 구분을 쓰고 있었다. - 요청 DTO 에는 포맷 지정이 없어 ISO 로 받는데 응답은 공백으로 내보내서, 문서를 보고 응답 형식대로 요청을 보내면 400 이 났다. - 날짜 필드를 가진 응답 DTO 23개 중 22개가 포맷 지정 없이 ISO 로 나가므로 어노테이션을 빼는 쪽이 나머지와 일관된다. --- .../backend/problemsolve/dto/ProblemSolveResponseDto.java | 4 ---- .../problemsolve/controller/ProblemSolveControllerTest.java | 6 ++++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java index 936bb3bb..c322711b 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/dto/ProblemSolveResponseDto.java @@ -3,7 +3,6 @@ import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; import com.aisip.OnO.backend.problemsolve.entity.ImprovementType; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; -import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.Builder; @@ -18,7 +17,6 @@ public record ProblemSolveResponseDto( Long problemSolveId, Long problemId, Long userId, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime practicedAt, AnswerStatus answerStatus, String reflection, @@ -27,9 +25,7 @@ public record ProblemSolveResponseDto( String moodEmojiKey, Boolean migratedFromLegacy, List imageUrls, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime createdAt, - @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime updatedAt ) { private static final ObjectMapper objectMapper = new ObjectMapper(); diff --git a/src/test/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveControllerTest.java b/src/test/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveControllerTest.java index 99c225b1..864673d9 100644 --- a/src/test/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/problemsolve/controller/ProblemSolveControllerTest.java @@ -160,7 +160,9 @@ void returnsOwnSolve() throws Exception { .andExpect(jsonPath("$.data.problemSolveId").value(solve.getId())) .andExpect(jsonPath("$.data.problemId").value(problem.getId())) .andExpect(jsonPath("$.data.answerStatus").value("PARTIAL")) - .andExpect(jsonPath("$.data.practicedAt").value("2026-01-10 09:30:00")); + // 요청으로 받은 형식 그대로 ISO-8601 로 내려간다. + // 이 도메인만 "yyyy-MM-dd HH:mm:ss" 로 나가서 요청과 응답 형식이 어긋나 있었다. + .andExpect(jsonPath("$.data.practicedAt").value("2026-01-10T09:30:00")); } @Test @@ -217,7 +219,7 @@ void returnsSolvesOfProblem() throws Exception { .with(asUser(user.getId()))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(2)) - .andExpect(jsonPath("$.data[0].practicedAt").value("2026-01-11 09:30:00")); + .andExpect(jsonPath("$.data[0].practicedAt").value("2026-01-11T09:30:00")); } @Test From b3e054ea5415f5e71a918c663931e93879f8f4db Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:13:01 +0900 Subject: [PATCH 045/130] =?UTF-8?q?[Feat]=20=EC=9D=BC=EC=9D=BC=C2=B7?= =?UTF-8?q?=EC=A3=BC=EA=B0=84=20=EB=AF=B8=EC=85=98=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=201=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mission_definition / mission_progress 테이블과 시드 10종을 V29 마이그레이션으로 추가 - GET /api/missions, POST /api/missions/{progressId}/claim 추가. 에러 코드 7010~7012 - 리셋 배치 대신 period_key(일일 yyyy-MM-dd, 주간 ISO 주차)로 기간을 가른다. Quartz 가 isClustered:false 인 채로 blue-green 배포 중이라 스케줄 작업을 새로 만들지 않는다 - 진행도 증가는 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로. 읽고 쓰면 동시 요청에서 증가분이 사라지고, 분기해서 INSERT 하면 유니크 제약 위반이 트랜잭션을 rollback-only 로 만든다 - 받기는 claimed_at IS NULL 조건부 UPDATE 로 두 번 지급을 막는다. 잠금 순서는 사용자 → 진행도로 통일 - 세는 항목 6종을 오답노트 등록·복습 기록·복습 세트 완료·기분 저장·출석 경로에 붙였다. 전부 호출자의 트랜잭션 안에서 돌아 행동이 롤백되면 진행도도 함께 사라진다 - 기존 MissionLog 적립 규칙 네 개와 하루 200점 상한은 그대로 두고, 미션 보상만 상한 밖의 별도 경로로 지급한다 --- .../service/LearningCalendarService.java | 14 +- .../mission/controller/MissionController.java | 33 +++++ .../mission/dto/MissionClaimResponseDto.java | 15 ++ .../mission/dto/MissionListResponseDto.java | 18 +++ .../mission/dto/MissionResponseDto.java | 47 +++++++ .../mission/entity/MissionCategory.java | 12 ++ .../mission/entity/MissionDefinition.java | 74 ++++++++++ .../backend/mission/entity/MissionMetric.java | 38 ++++++ .../mission/entity/MissionProgress.java | 77 +++++++++++ .../mission/entity/MissionRewardType.java | 8 ++ .../mission/exception/MissionErrorCase.java | 11 +- .../MissionDefinitionRepository.java | 25 ++++ .../repository/MissionProgressRepository.java | 91 +++++++++++++ .../mission/service/MissionLogService.java | 7 + .../mission/service/MissionPeriodKey.java | 51 +++++++ .../service/MissionProgressUpdater.java | 62 +++++++++ .../mission/service/MissionRewardGranter.java | 67 +++++++++ .../mission/service/MissionService.java | 128 ++++++++++++++++++ .../service/PracticeNoteService.java | 5 + .../problem/service/ProblemService.java | 8 ++ .../service/ProblemSolveService.java | 8 ++ .../migration/V29__create_mission_system.sql | 70 ++++++++++ 22 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java create mode 100644 src/main/resources/db/migration/V29__create_mission_system.sql diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java index ad6329d3..8596f260 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -9,6 +9,8 @@ import com.aisip.OnO.backend.learningcalendar.exception.LearningCalendarErrorCase; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarMoodRepository; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -23,6 +25,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.TreeSet; import java.util.stream.Collectors; @@ -45,6 +48,7 @@ public class LearningCalendarService { private final StreakCacheService streakCacheService; private final LearningCalendarMoodRepository moodRepository; private final CustomEmojiValidator customEmojiValidator; + private final MissionProgressUpdater missionProgressUpdater; public LearningCalendarResponseDto getLearningCalendar(Long userId, int year, int month) { // 서비스 대상이 국내 사용자이고 problem/studyroom 등 다른 도메인도 KST 기준으로 하루를 가른다. @@ -107,9 +111,17 @@ public LearningCalendarMoodResponseDto updateMood(Long userId, LearningCalendarM if (!calendarRepository.existsStudyRecord(userId, request.date())) { throw new ApplicationException(LearningCalendarErrorCase.CALENDAR_RECORD_NOT_FOUND); } - LearningCalendarMood mood = moodRepository.findByUserIdAndStudyDate(userId, request.date()) + Optional existingMood = moodRepository.findByUserIdAndStudyDate(userId, request.date()); + LearningCalendarMood mood = existingMood .orElseGet(() -> moodRepository.save(LearningCalendarMood.create(userId, request.date(), request.emojiKey()))); mood.updateEmojiKey(request.emojiKey()); + + // 기분 미션은 "그 날짜에 처음 기분을 남겼을 때"만 오른다. 이모지를 바꿀 때마다 오르면 + // 버튼 한 번으로 주간 미션까지 채울 수 있다. + if (existingMood.isEmpty()) { + missionProgressUpdater.increase(userId, MissionMetric.MOOD_LOGGED); + } + return new LearningCalendarMoodResponseDto(mood.getStudyDate(), mood.getEmojiKey()); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java new file mode 100644 index 00000000..77ed1a68 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java @@ -0,0 +1,33 @@ +package com.aisip.OnO.backend.mission.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; +import com.aisip.OnO.backend.mission.service.MissionService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/missions") +public class MissionController { + + private final MissionService missionService; + + @GetMapping("") + public CommonResponse getMissions() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.getMissions(userId)); + } + + @PostMapping("/{progressId}/claim") + public CommonResponse claim(@PathVariable Long progressId) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.claim(userId, progressId)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java new file mode 100644 index 00000000..cdc476fe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java @@ -0,0 +1,15 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +/** + * 보상 수령 결과. 지급 직후의 총 학습 레벨과 이번 지급으로 레벨이 올랐는지를 담는다. + */ +public record MissionClaimResponseDto( + Long progressId, + MissionRewardType rewardType, + int rewardValue, + Long totalStudyLevel, + boolean leveledUp +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java new file mode 100644 index 00000000..52f3f35e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java @@ -0,0 +1,18 @@ +package com.aisip.OnO.backend.mission.dto; + +import java.util.List; + +/** + * 미션 목록 응답. 일일과 주간을 각자의 기간 키와 함께 나눠 내려준다. + */ +public record MissionListResponseDto( + MissionSectionDto daily, + MissionSectionDto weekly +) { + + public record MissionSectionDto( + String periodKey, + List missions + ) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java new file mode 100644 index 00000000..c6c2e84a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +/** + * 미션 한 건. + * + *

{@code progressId} 는 아직 한 번도 손대지 않은 미션에서 null 이다. 받기는 이 값으로 하는데, + * 완료된 미션에는 반드시 진행도 행이 있으므로 완료 상태에서 null 이 되는 경우는 없다. + */ +public record MissionResponseDto( + Long progressId, + String code, + String title, + String description, + String iconKey, + MissionCategory category, + int current, + int target, + boolean completed, + boolean claimed, + MissionRewardType rewardType, + int rewardValue +) { + + public static MissionResponseDto from(MissionDefinition definition, MissionProgress progress) { + return new MissionResponseDto( + progress == null ? null : progress.getId(), + definition.getCode(), + definition.getTitle(), + definition.getDescription(), + definition.getIconKey(), + definition.getCategory(), + progress == null ? 0 : progress.getCurrentValue(), + // 진행도가 있으면 그 행이 만들어질 때 박아둔 목표로 보여준다. + // 운영 중에 목표를 낮추면 이미 채운 사람의 화면이 "5 / 3" 이 되기 때문이다. + progress == null ? definition.getTarget() : progress.getTargetSnapshot(), + progress != null && progress.isCompleted(), + progress != null && progress.isClaimed(), + definition.getRewardType(), + definition.getRewardValue() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java new file mode 100644 index 00000000..2adbb132 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionCategory.java @@ -0,0 +1,12 @@ +package com.aisip.OnO.backend.mission.entity; + +/** + * 미션이 어떤 주기로 초기화되는지. + * + *

초기화는 배치가 아니라 {@code period_key} 로 한다. DAILY 는 {@code yyyy-MM-dd}, + * WEEKLY 는 ISO 주차 {@code yyyy-'W'ww} 를 키로 쓰므로 기간이 바뀌면 조회에서 자연히 빠진다. + */ +public enum MissionCategory { + DAILY, + WEEKLY +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java new file mode 100644 index 00000000..97595e0a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionDefinition.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.Column; +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 jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 미션 정의. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. + * + *

정의를 코드가 아니라 DB 에 두는 이유는 목표치나 보상을 바꿀 때 배포 없이 고치기 위해서다. + * 다만 진행 중이던 사용자가 바뀐 목표에 휘말리면 안 되므로, 진행도 행은 생성 시점의 + * {@code target} 을 {@code target_snapshot} 으로 복사해 두고 그 값으로 완료를 판정한다. + */ +@Entity +@Getter +@Builder(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mission_definition", + uniqueConstraints = @UniqueConstraint(name = "uk_mission_definition_code", columnNames = "code")) +public class MissionDefinition extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "code", nullable = false, length = 60) + private String code; + + @Column(name = "title", nullable = false, length = 60) + private String title; + + @Column(name = "description", nullable = false, length = 200) + private String description; + + @Column(name = "icon_key", nullable = false, length = 40) + private String iconKey; + + @Enumerated(EnumType.STRING) + @Column(name = "category", nullable = false, length = 20) + private MissionCategory category; + + @Enumerated(EnumType.STRING) + @Column(name = "metric", nullable = false, length = 40) + private MissionMetric metric; + + @Column(name = "target", nullable = false) + private int target; + + @Enumerated(EnumType.STRING) + @Column(name = "reward_type", nullable = false, length = 20) + private MissionRewardType rewardType; + + @Column(name = "reward_value", nullable = false) + private int rewardValue; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(name = "active", nullable = false) + private boolean active; +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java new file mode 100644 index 00000000..c7d3f892 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java @@ -0,0 +1,38 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import lombok.Getter; + +/** + * 미션이 세는 항목. + * + *

각 항목은 보상을 어느 능력치에 넣을지도 함께 정한다. 기존 {@link MissionType} 의 적립 규칙과는 + * 별개다. 미션 보상은 기존 적립 위에 얹는 보너스라 하루 200점 상한을 타지 않는다. + */ +@Getter +public enum MissionMetric { + + /** 출석. 하루에 한 번만 오른다. */ + LOGIN_DAY(AbilityType.ATTENDANCE), + + /** 오답노트 등록. 여러 장을 한 번에 등록하면 장수만큼 오른다. */ + PROBLEM_CREATED(AbilityType.NOTE_WRITE), + + /** 복습 기록. */ + SOLVE_RECORDED(AbilityType.PROBLEM_PRACTICE), + + /** 복습 기록 중 정답. */ + SOLVE_CORRECT(AbilityType.PROBLEM_PRACTICE), + + /** 복습 세트 완료. */ + PRACTICE_NOTE_COMPLETED(AbilityType.NOTE_PRACTICE), + + /** 학습 달력 기분 저장. 하루에 한 번만 오른다. */ + MOOD_LOGGED(AbilityType.ATTENDANCE); + + private final AbilityType abilityType; + + MissionMetric(AbilityType abilityType) { + this.abilityType = abilityType; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java new file mode 100644 index 00000000..682b4c41 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java @@ -0,0 +1,77 @@ +package com.aisip.OnO.backend.mission.entity; + +import com.aisip.OnO.backend.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자별 미션 진행도. (userId, missionId, periodKey) 하나당 한 행이다. + * + *

진행도 증가는 이 엔티티를 통하지 않는다. 읽고 나서 쓰면 같은 사용자의 요청이 겹칠 때 + * 증가분이 사라지므로, {@code MissionProgressRepository} 의 upsert 한 문장으로만 올린다. + * 이 클래스는 조회와 받기 판정에서만 쓴다. + * + *

user 와 mission_definition 에 연관관계를 걸지 않고 식별자만 들고 있다. 외래키를 걸면 + * INSERT 마다 부모 행에 공유 잠금이 붙어, 이미 사용자 행을 배타 잠금으로 잡고 있는 + * 기존 미션 적립 경로와 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mission_progress", + uniqueConstraints = @UniqueConstraint( + name = "uk_mission_progress", + columnNames = {"user_id", "mission_id", "period_key"}), + indexes = @Index(name = "idx_mission_progress_lookup", columnList = "user_id, period_key")) +public class MissionProgress extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "mission_id", nullable = false) + private Long missionId; + + @Column(name = "period_key", nullable = false, length = 20) + private String periodKey; + + @Column(name = "current_value", nullable = false) + private int currentValue; + + @Column(name = "target_snapshot", nullable = false) + private int targetSnapshot; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + @Column(name = "claimed_at") + private LocalDateTime claimedAt; + + public boolean isOwnedBy(Long userId) { + return this.userId != null && this.userId.equals(userId); + } + + public boolean isCompleted() { + return completedAt != null; + } + + public boolean isClaimed() { + return claimedAt != null; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java new file mode 100644 index 00000000..5875f34c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionRewardType.java @@ -0,0 +1,8 @@ +package com.aisip.OnO.backend.mission.entity; + +/** + * 미션 보상 종류. 1차는 XP 뿐이고 테마·칭호는 2차다. + */ +public enum MissionRewardType { + XP +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java index 060f0a5e..ee11bb54 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/mission/exception/MissionErrorCase.java @@ -10,7 +10,16 @@ public enum MissionErrorCase implements ErrorCase { MISSION_TYPE_NOT_FOUND(400, 7001, "잘못된 미션 종류입니다."), - USER_NOT_FOUND(404, 7002, "해당하는 유저가 존재하지 않습니다."); + USER_NOT_FOUND(404, 7002, "해당하는 유저가 존재하지 않습니다."), + + // 미션 진행도(mission_progress) 관련. 7001, 7002 에 이어 7010 부터 쓴다. + + /** 남의 진행도를 받으려는 경우에도 이 코드를 준다. 존재 여부를 알려주지 않는다. */ + MISSION_PROGRESS_NOT_FOUND(404, 7010, "미션 진행도를 찾을 수 없습니다."), + + MISSION_NOT_COMPLETED(400, 7011, "아직 완료하지 않은 미션입니다."), + + MISSION_ALREADY_CLAIMED(400, 7012, "이미 보상을 받은 미션입니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java new file mode 100644 index 00000000..b04aec9a --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionDefinitionRepository.java @@ -0,0 +1,25 @@ +package com.aisip.OnO.backend.mission.repository; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MissionDefinitionRepository extends JpaRepository { + + /** 목록 조회용. 정렬은 sortOrder 오름차순이고, 같은 값이면 id 로 흔들림을 없앤다. */ + List findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + /** + * 진행도를 올릴 대상. + * + *

id 오름차순으로 고정한다. 한 번의 행동이 일일/주간 두 미션을 함께 올리는데, + * 동시에 들어온 요청들이 서로 다른 순서로 행을 잠그면 교착이 난다. + * 모든 트랜잭션이 같은 순서로 잠그면 교착 자체가 성립하지 않는다. + */ + List findAllByMetricAndActiveTrueOrderByIdAsc(MissionMetric metric); + + Optional findByCode(String code); +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java new file mode 100644 index 00000000..47908092 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -0,0 +1,91 @@ +package com.aisip.OnO.backend.mission.repository; + +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface MissionProgressRepository extends JpaRepository { + + List findAllByUserIdAndPeriodKeyIn(Long userId, Collection periodKeys); + + Optional findByUserIdAndMissionIdAndPeriodKey(Long userId, Long missionId, String periodKey); + + /** + * 진행도를 한 문장으로 만들거나 올린다. + * + *

"없으면 만들고 있으면 올린다"를 애플리케이션에서 갈라 쓰면 두 가지가 무너진다. + * 첫째, 같은 사용자의 요청이 겹치면 둘 다 "없다"를 읽고 INSERT 해 유니크 제약에 걸린다. + * JPA 에서 제약 위반은 트랜잭션을 rollback-only 로 만들기 때문에 잡아서 UPDATE 로 넘어갈 수도 없다. + * 둘째, 읽고 나서 쓰는 증가는 증가분을 잃는다. + * + *

{@code INSERT ... ON DUPLICATE KEY UPDATE} 는 둘 다 한 번에 해결한다. 중복 키를 만나면 + * MySQL 이 그 행에 배타 잠금을 걸고 UPDATE 로 바꿔 실행하므로, 예외 경로도 없고 + * 잠금 승격으로 인한 교착도 없다. + * + *

증가분은 {@code LEAST} 로 {@code target_snapshot} 에서 멈춘다. 목표를 넘겨도 + * 화면에 "4 / 3" 이 보이면 안 된다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, created_at, updated_at) + VALUES + (:userId, :missionId, :periodKey, LEAST(:amount, :target), :target, NOW(6), NOW(6)) + ON DUPLICATE KEY UPDATE + current_value = LEAST(current_value + :amount, target_snapshot), + updated_at = NOW(6) + """, nativeQuery = true) + int increaseValue( + @Param("userId") Long userId, + @Param("missionId") Long missionId, + @Param("periodKey") String periodKey, + @Param("amount") int amount, + @Param("target") int target + ); + + /** + * 완료 도장을 한 번만 찍는다. + * + *

{@code completed_at IS NULL} 을 조건에 넣어, 목표를 넘긴 뒤 또 올려도 완료 시각이 + * 뒤로 밀리지 않는다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE mission_progress + SET completed_at = NOW(6), + updated_at = NOW(6) + WHERE user_id = :userId + AND mission_id = :missionId + AND period_key = :periodKey + AND completed_at IS NULL + AND current_value >= target_snapshot + """, nativeQuery = true) + int markCompleted( + @Param("userId") Long userId, + @Param("missionId") Long missionId, + @Param("periodKey") String periodKey + ); + + /** + * 보상 수령 도장. 갱신된 행이 1일 때만 지급한다. + * + *

조회로 "아직 안 받았다"를 확인하고 지급하면, 버튼을 두 번 빠르게 누른 두 요청이 + * 모두 통과해 XP 가 두 번 들어간다. {@code claimed_at IS NULL} 을 UPDATE 조건에 넣으면 + * 뒤에 온 요청은 0행을 갱신하고 거절된다. + */ + @Modifying(flushAutomatically = true) + @Query(value = """ + UPDATE mission_progress + SET claimed_at = NOW(6), + updated_at = NOW(6) + WHERE id = :progressId + AND claimed_at IS NULL + """, nativeQuery = true) + int markClaimed(@Param("progressId") Long progressId); +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 395a64a7..0b667ae5 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.exception.MissionErrorCase; import com.aisip.OnO.backend.mission.repository.MissionLogRepository; @@ -41,6 +42,8 @@ public class MissionLogService { private final PracticeNoteRepository practiceNoteRepository; + private final MissionProgressUpdater missionProgressUpdater; + private static final Long DAILY_MISSION_POINT_LIMIT = 200L; /** @@ -110,6 +113,10 @@ public void registerLoginMission(Long userId) { missionLogRepository.save(missionLog); addPointToUser(user, missionLog); + + // 출석 미션 진행도. 기존 적립 규칙은 그대로 두고, "오늘 첫 로그인" 판정만 그대로 빌려 쓴다. + // 이 분기 밖에서 올리면 앱을 열 때마다 주간 출석 미션이 하루에 5까지 차 버린다. + missionProgressUpdater.increase(userId, MissionMetric.LOGIN_DAY); } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java new file mode 100644 index 00000000..1d038bbb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java @@ -0,0 +1,51 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.temporal.IsoFields; + +/** + * 진행도 행을 가르는 기간 키. + * + *

자정에 어제 진행도를 지우는 배치를 두지 않는다. 오늘 키로 조회하면 어제 것은 애초에 안 잡히기 때문이다. + * Quartz 가 {@code isClustered: false} 인 채로 blue-green 배포를 하고 있어 배포 구간에 스케줄 작업이 + * 양쪽에서 돌 위험이 있는데, 리셋 배치를 만들지 않으면 그 위험이 통째로 사라진다. + * + *

하루의 경계는 KST 다. problem·studyroom 등 다른 도메인이 이미 KST 로 하루를 가르고 있어 + * JVM 기본 시간대를 쓰면 배포 환경에 따라 "오늘"이 하루 어긋난다. + */ +public final class MissionPeriodKey { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private MissionPeriodKey() { + } + + public static LocalDate today() { + return LocalDate.now(KST); + } + + public static String of(MissionCategory category, LocalDate date) { + return category == MissionCategory.WEEKLY ? weekly(date) : daily(date); + } + + /** {@code yyyy-MM-dd}. */ + public static String daily(LocalDate date) { + return date.toString(); + } + + /** + * {@code yyyy-'W'ww} (ISO 8601 주차, 월요일 시작). + * + *

연도는 달력 연도가 아니라 주 기반 연도를 쓴다. 2019-12-31 은 달력으로는 2019년이지만 + * ISO 로는 2020년 1주차다. 달력 연도로 키를 만들면 그 주가 {@code 2019-W01} 과 {@code 2020-W01} 로 + * 쪼개져, 연말연시에 주간 미션이 두 번 초기화된다. + */ + public static String weekly(LocalDate date) { + int weekBasedYear = date.get(IsoFields.WEEK_BASED_YEAR); + int weekOfYear = date.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR); + return String.format("%d-W%02d", weekBasedYear, weekOfYear); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java new file mode 100644 index 00000000..8175aefa --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java @@ -0,0 +1,62 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +/** + * 다른 도메인이 부르는 진행도 증가 진입점. + * + *

호출자의 트랜잭션 안에서 돈다. 별도 이벤트 리스너를 두거나 {@code REQUIRES_NEW} 로 떼어내지 않는다. + * 오답노트 등록이 롤백됐는데 "오늘의 오답" 진행도만 남으면 사용자는 하지도 않은 일로 보상을 받는다. + * {@code ProblemReviewReminderService} 가 {@code AFTER_COMMIT} + {@code REQUIRES_NEW} 를 쓰는 것은 + * FCM 이라는 외부 연동이 실패해도 본 작업을 막으면 안 되기 때문이고, 진행도는 DB 만 건드리므로 경우가 다르다. + * + *

증가는 리포지토리의 upsert 한 문장으로만 한다. 여기서 조회한 뒤 값을 계산해 저장하면 + * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. + */ +@Component +@RequiredArgsConstructor +public class MissionProgressUpdater { + + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + + /** + * 애노테이션이 여기에도 붙어 있어야 한다. 이 오버로드는 같은 빈의 3인자 메서드를 직접 부르는데, + * 자기 호출은 프록시를 타지 않아 저쪽의 {@code @Transactional} 이 적용되지 않는다. + * 호출자에게 트랜잭션이 없으면 그대로 트랜잭션 없이 실행돼 flush 에서 터진다. + */ + @Transactional + public void increase(Long userId, MissionMetric metric) { + increase(userId, metric, 1); + } + + /** + * @param amount 한 번에 오를 양. 오답노트를 여러 장 등록하면 장수만큼 오른다. + */ + @Transactional + public void increase(Long userId, MissionMetric metric, int amount) { + if (userId == null || metric == null || amount <= 0) { + return; + } + + LocalDate today = MissionPeriodKey.today(); + List definitions = + missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); + + for (MissionDefinition definition : definitions) { + String periodKey = MissionPeriodKey.of(definition.getCategory(), today); + missionProgressRepository.increaseValue( + userId, definition.getId(), periodKey, amount, definition.getTarget()); + missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java new file mode 100644 index 00000000..761e8350 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -0,0 +1,67 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * 미션 보상 지급. + * + *

기존 {@code MissionLogService.addPointToUser} 를 타지 않는다. 그쪽은 하루 200점 상한을 적용하는 + * 기존 적립 경로이고, 미션 보상은 그 위에 얹는 보너스라 상한 밖이다. 기존 적립 규칙은 손대지 않는다. + * + *

능력치 경험치 반영은 엔티티를 읽어 고치는 방식이라 같은 사용자의 동시 지급에 취약하다. + * 기존 적립 경로와 같은 이유로 사용자 행을 배타 잠금으로 먼저 잡아 직렬화한다. + * 잠금을 나중에 잡으면 잠금 승격 과정에서 교착이 난다. + */ +@Component +@RequiredArgsConstructor +public class MissionRewardGranter { + + private final UserRepository userRepository; + + /** + * 지급 전에 사용자 행을 먼저 잠근다. + * + *

잠금 순서를 사용자 → 진행도 하나로 통일하기 위해서다. 기존 적립 경로 + * ({@code MissionLogService.registerLoginMission})는 사용자 행을 잠근 상태에서 진행도를 올리는데, + * 받기가 진행도를 먼저 잠그고 사용자를 나중에 잠그면 두 요청이 서로의 잠금을 기다려 교착이 난다. + */ + @Transactional + public void lockUser(Long userId) { + userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); + } + + @Transactional + public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType rewardType, int rewardValue) { + User user = userRepository.findByIdForUpdate(userId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); + + UserMissionStatus status = user.getUserMissionStatus(); + long levelBefore = status.getTotalStudyLevel(); + + if (rewardType == MissionRewardType.XP && rewardValue > 0) { + long point = rewardValue; + switch (metric.getAbilityType()) { + case ATTENDANCE -> status.gainAttendancePoint(point); + case NOTE_WRITE -> status.gainNoteWritePoint(point); + case PROBLEM_PRACTICE -> status.gainProblemPracticePoint(point); + case NOTE_PRACTICE -> status.gainNotePracticePoint(point); + } + } + + long levelAfter = status.getTotalStudyLevel(); + return new GrantResult(levelAfter, levelAfter > levelBefore); + } + + public record GrantResult(Long totalStudyLevel, boolean leveledUp) { + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java new file mode 100644 index 00000000..5c14e290 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -0,0 +1,128 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionResponseDto; +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 미션 조회와 보상 받기. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MissionService { + + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + private final MissionRewardGranter missionRewardGranter; + + /** + * 활성 미션 전부와 이번 기간의 진행도. + * + *

아직 손대지 않은 미션도 {@code current: 0} 으로 내려간다. 조회는 읽기 전용이라 + * 여기서 진행도 행을 만들지 않는다. 화면에 미션 목록만 띄운 사용자 수만큼 빈 행이 생기는 것을 피한다. + */ + public MissionListResponseDto getMissions(Long userId) { + LocalDate today = MissionPeriodKey.today(); + String dailyKey = MissionPeriodKey.daily(today); + String weeklyKey = MissionPeriodKey.weekly(today); + + List definitions = missionDefinitionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc(); + + // 소유권: 조회 대상은 요청한 사용자의 진행도로만 한정한다. + Map progressByKey = new HashMap<>(); + missionProgressRepository.findAllByUserIdAndPeriodKeyIn(userId, List.of(dailyKey, weeklyKey)) + .forEach(progress -> progressByKey.put(cacheKey(progress.getMissionId(), progress.getPeriodKey()), progress)); + + List daily = new ArrayList<>(); + List weekly = new ArrayList<>(); + for (MissionDefinition definition : definitions) { + boolean isWeekly = definition.getCategory() == MissionCategory.WEEKLY; + String periodKey = isWeekly ? weeklyKey : dailyKey; + MissionResponseDto dto = MissionResponseDto.from( + definition, progressByKey.get(cacheKey(definition.getId(), periodKey))); + if (isWeekly) { + weekly.add(dto); + } else { + daily.add(dto); + } + } + + return new MissionListResponseDto( + new MissionListResponseDto.MissionSectionDto(dailyKey, List.copyOf(daily)), + new MissionListResponseDto.MissionSectionDto(weeklyKey, List.copyOf(weekly)) + ); + } + + /** + * 보상 받기. + * + *

남의 진행도는 "없다"로 답한다. 존재 여부를 알려주면 id 를 훑어 다른 사용자의 미션 진행 상황을 + * 알아낼 수 있기 때문이다. + * + *

이미 받았는지는 조회로 한 번 걸러 400 을 주고, 실제 지급은 {@code claimed_at IS NULL} 조건부 + * UPDATE 가 성공한 경우에만 한다. 조회만으로 판단하면 버튼을 두 번 빠르게 누른 두 요청이 모두 + * 통과해 XP 가 두 번 들어간다. + */ + @Transactional + public MissionClaimResponseDto claim(Long userId, Long progressId) { + MissionProgress progress = missionProgressRepository.findById(progressId) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND)); + + if (!progress.isOwnedBy(userId)) { + throw new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND); + } + if (!progress.isCompleted()) { + throw new ApplicationException(MissionErrorCase.MISSION_NOT_COMPLETED); + } + if (progress.isClaimed()) { + throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); + } + + MissionDefinition definition = missionDefinitionRepository.findById(progress.getMissionId()) + .orElseThrow(() -> new ApplicationException(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND)); + + // 진행도 행을 잠그기 전에 사용자 행을 먼저 잠근다. 잠금 순서를 기존 적립 경로와 맞춰 교착을 막는다. + missionRewardGranter.lockUser(userId); + + if (missionProgressRepository.markClaimed(progressId) == 0) { + throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); + } + + MissionRewardGranter.GrantResult grantResult = missionRewardGranter.grant( + userId, definition.getMetric(), definition.getRewardType(), definition.getRewardValue()); + + log.info("userId: {} claimed missionProgressId: {}, code: {}, reward: {} {}", + userId, progressId, definition.getCode(), definition.getRewardType(), definition.getRewardValue()); + + return new MissionClaimResponseDto( + progressId, + definition.getRewardType(), + definition.getRewardValue(), + grantResult.totalStudyLevel(), + grantResult.leveledUp() + ); + } + + private String cacheKey(Long missionId, String periodKey) { + return missionId + "@" + periodKey; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index fccb18c5..3776d427 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -4,7 +4,9 @@ import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.response.CursorPageResponse; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.practicenote.dto.*; import com.aisip.OnO.backend.practicenote.entity.PracticeNote; import com.aisip.OnO.backend.practicenote.entity.PracticeNotification; @@ -56,6 +58,8 @@ public class PracticeNoteService { private final MissionLogService missionLogService; + private final MissionProgressUpdater missionProgressUpdater; + private final UserRepository userRepository; private final CustomEmojiValidator customEmojiValidator; @@ -151,6 +155,7 @@ public void addPracticeNoteCount(Long userId, Long practiceId, PracticeNoteCompl // 복습노트 사용 미션 등록 missionLogService.registerNotePracticeMission(userId, practiceId); + missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); log.info("practiceId: {} count has updated", practiceId); } diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 03308dd9..085408b5 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -6,7 +6,9 @@ import com.aisip.OnO.backend.common.response.CursorPageResponse; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.config.rabbitmq.producer.ProblemAnalysisProducer; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.ProblemAnalysis; import com.aisip.OnO.backend.problem.entity.ProblemImageType; @@ -84,6 +86,8 @@ public class ProblemService { private final MissionLogService missionLogService; + private final MissionProgressUpdater missionProgressUpdater; + private final ProblemAnalysisService analysisService; private final PracticeNoteRepository practiceNoteRepository; @@ -232,6 +236,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( @@ -295,6 +300,7 @@ public Long registerProblemV2(ProblemRegisterV2Dto problemRegisterV2Dto, Long us analysisService.createSkippedAnalysis(problem.getId()); streakCacheService.evict(userId); missionLogService.registerProblemWriteMission(userId); + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", 1))); eventPublisher.publishEvent(new ProblemCreatedEvent(userId, List.of( @@ -363,6 +369,8 @@ public List registerProblemsV2(ProblemRegisterV2BatchDto problemRegisterV2 streakCacheService.evict(userId); missionLogService.registerProblemWriteMissionBatch(userId, problems.size()); + // 여러 장을 한 번에 등록하면 장수만큼 오른다. 기존 적립은 하루 3건에서 멈추지만 미션 진행도는 별개다. + missionProgressUpdater.increase(userId, MissionMetric.PROBLEM_CREATED, problems.size()); eventPublisher.publishEvent(new StudyRoomActivityEvent( userId, StudyRoomFeedEventType.PROBLEM_REGISTERED, Map.of("count", problems.size()))); diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java index c5996136..490abc2c 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/service/ProblemSolveService.java @@ -2,13 +2,16 @@ import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.util.redis.StreakCacheService; import com.aisip.OnO.backend.problem.service.ReviewIntervalCalculator; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveResponseDto; import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveUpdateDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolveImageData; import com.aisip.OnO.backend.problemsolve.exception.ProblemSolveErrorCase; @@ -49,6 +52,7 @@ public class ProblemSolveService { private final ProblemSolveImageDataRepository problemSolveImageDataRepository; private final ProblemRepository problemRepository; private final MissionLogService missionLogService; + private final MissionProgressUpdater missionProgressUpdater; private final FileUploadService fileUploadService; private final S3DeleteProducer s3DeleteProducer; private final ObjectMapper objectMapper; @@ -153,6 +157,10 @@ public Long createProblemSolve(ProblemSolveRegisterDto dto, Long userId) { problemSolveRepository.save(problemSolve); streakCacheService.evict(userId); missionLogService.registerProblemPracticeMission(userId, problem.getId()); + missionProgressUpdater.increase(userId, MissionMetric.SOLVE_RECORDED); + if (dto.answerStatus() == AnswerStatus.CORRECT) { + missionProgressUpdater.increase(userId, MissionMetric.SOLVE_CORRECT); + } ReviewIntervalCalculator.ReviewSchedule schedule = ReviewIntervalCalculator.calculate( dto.answerStatus(), diff --git a/src/main/resources/db/migration/V29__create_mission_system.sql b/src/main/resources/db/migration/V29__create_mission_system.sql new file mode 100644 index 00000000..e004cb46 --- /dev/null +++ b/src/main/resources/db/migration/V29__create_mission_system.sql @@ -0,0 +1,70 @@ +-- 미션 시스템 1차: 일일/주간 미션 정의와 사용자별 진행도. +-- +-- 리셋 배치를 두지 않는다. 진행도 행은 period_key(일일 yyyy-MM-dd, 주간 yyyy-'W'ww)로 갈라지므로 +-- 오늘 키로 조회하면 어제 진행도는 애초에 잡히지 않는다. Quartz 가 isClustered:false 인 채로 +-- blue-green 배포를 하고 있어 스케줄 작업이 배포 구간에 양쪽에서 도는 위험이 있는데, +-- 리셋 배치를 만들지 않으면 그 위험을 통째로 피한다. + +CREATE TABLE mission_definition ( + id BIGINT NOT NULL AUTO_INCREMENT, + code VARCHAR(60) NOT NULL, + title VARCHAR(60) NOT NULL, + description VARCHAR(200) NOT NULL, + icon_key VARCHAR(40) NOT NULL, + category VARCHAR(20) NOT NULL, + metric VARCHAR(40) NOT NULL, + target INT NOT NULL, + reward_type VARCHAR(20) NOT NULL, + reward_value INT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mission_definition_code (code) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- target_snapshot: 나중에 미션 목표를 바꿔도 진행 중이던 사용자는 옛 목표로 끝나야 한다. +-- 진행도 행을 만들 때의 target 을 박아두고 완료 판정은 이 값으로 한다. +-- +-- uk_mission_progress 는 성능용이 아니라 정합성용이다. 진행도 증가를 +-- INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 처리하는데, 그 upsert 가 성립하는 근거가 이 유니크 키다. +CREATE TABLE mission_progress ( + id BIGINT NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL, + mission_id BIGINT NOT NULL, + period_key VARCHAR(20) NOT NULL, + current_value INT NOT NULL DEFAULT 0, + target_snapshot INT NOT NULL, + completed_at DATETIME(6) NULL, + claimed_at DATETIME(6) NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + deleted_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_mission_progress (user_id, mission_id, period_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- 목록 조회는 "이 사용자의 이번 기간 진행도 전부" 한 가지뿐이다. +CREATE INDEX idx_mission_progress_lookup + ON mission_progress (user_id, period_key); + +-- 시드 10종. 관리자 화면은 2차라 정의는 마이그레이션으로만 들어온다. +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('DAILY_ATTEND', '출석', '오늘 앱 켜기', 'attendance', 'DAILY', 'LOGIN_DAY', 1, 'XP', 10, 1, 1, NOW(6), NOW(6)), + ('DAILY_NOTE_WRITE', '오늘의 오답', '오답노트 1개 등록', 'note_write', 'DAILY', 'PROBLEM_CREATED', 1, 'XP', 10, 2, 1, NOW(6), NOW(6)), + ('DAILY_REVIEW_3', '세 문제만', '오답 3문제 복습', 'review', 'DAILY', 'SOLVE_RECORDED', 3, 'XP', 15, 3, 1, NOW(6), NOW(6)), + ('DAILY_CORRECT_3', '정확하게', '복습해서 3문제 맞히기', 'accuracy', 'DAILY', 'SOLVE_CORRECT', 3, 'XP', 20, 4, 1, NOW(6), NOW(6)), + ('DAILY_PRACTICE_SET', '세트 완주', '복습 세트 하나 끝내기', 'practice_set', 'DAILY', 'PRACTICE_NOTE_COMPLETED', 1, 'XP', 15, 5, 1, NOW(6), NOW(6)), + ('DAILY_MOOD', '오늘 기분', '학습 달력에 기분 남기기', 'mood', 'DAILY', 'MOOD_LOGGED', 1, 'XP', 5, 6, 1, NOW(6), NOW(6)); + +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('WEEKLY_ATTEND_5', '꾸준함', '이번 주 5일 출석', 'attendance', 'WEEKLY', 'LOGIN_DAY', 5, 'XP', 100, 1, 1, NOW(6), NOW(6)), + ('WEEKLY_NOTE_10', '열 권의 노트', '오답노트 10개 등록', 'note_write', 'WEEKLY', 'PROBLEM_CREATED', 10, 'XP', 80, 2, 1, NOW(6), NOW(6)), + ('WEEKLY_REVIEW_30', '서른 번의 복습', '복습 30회', 'review', 'WEEKLY', 'SOLVE_RECORDED', 30, 'XP', 100, 3, 1, NOW(6), NOW(6)), + ('WEEKLY_SET_3', '세 번의 완주', '복습 세트 3개 끝내기', 'practice_set', 'WEEKLY', 'PRACTICE_NOTE_COMPLETED', 3, 'XP', 80, 4, 1, NOW(6), NOW(6)); From 000196f16b82774702cd515a50cea3f8f3034edc Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:13:12 +0900 Subject: [PATCH 046/130] =?UTF-8?q?[Test]=20=EB=AF=B8=EC=85=98=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기간 키는 순수 로직이라 단위 테스트로 경계만 본다. 연말연시 주차, 월요일 시작, 일요일 끝 - 진행도: 증가, 장수만큼 증가, 완료 판정, 목표 초과 시 멈춤, 어제/지난 주와 섞이지 않음, 롤백 동반 - 받기: 미완료 7011, 중복 7012, 남의 미션 7010, 하루 200점 상한 밖 지급, 레벨업 여부 - 동시성: 복습 8번 동시 적립에도 진행도 유실 없음, 동시에 여덟 번 눌러도 지급은 한 번 - 시드는 테스트에 다시 적지 않고 V29 마이그레이션의 INSERT 문을 그대로 읽어 넣는다. 같은 값을 두 곳에 적으면 테스트가 프로덕션 시드와 다른 것을 검증하게 된다 --- .../MissionProgressConcurrencyTest.java | 102 +++++++ .../controller/MissionControllerTest.java | 110 ++++++++ .../mission/service/MissionPeriodKeyTest.java | 102 +++++++ .../service/MissionProgressHookTest.java | 238 +++++++++++++++++ .../service/MissionProgressUpdaterTest.java | 179 +++++++++++++ .../mission/service/MissionServiceTest.java | 251 ++++++++++++++++++ .../support/MissionDefinitionSeeder.java | 72 +++++ .../support/MissionSystemTestSupport.java | 88 ++++++ 8 files changed, 1142 insertions(+) create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/MissionProgressConcurrencyTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/MissionProgressConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/MissionProgressConcurrencyTest.java new file mode 100644 index 00000000..6fa7b2f9 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/MissionProgressConcurrencyTest.java @@ -0,0 +1,102 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 같은 사용자의 미션 진행도/보상 요청이 동시에 들어올 때. + * + *

두 가지가 걸려 있다. 하나는 증가분 유실이다. 진행도를 읽고 계산해서 저장하면 + * 동시에 들어온 요청들이 같은 값을 읽고 같은 값을 써서 8번 복습한 것이 1번으로 기록된다. + * 다른 하나는 중복 지급이다. "아직 안 받았다"를 조회로 확인하고 지급하면 버튼을 두 번 빠르게 + * 누른 두 요청이 모두 통과한다. + * + *

방어는 각각 upsert 한 문장({@code INSERT ... ON DUPLICATE KEY UPDATE})과 + * {@code claimed_at IS NULL} 조건부 UPDATE 다. 그 두 가지가 정말 동작하는지 여기서 고정한다. + */ +@DisplayName("동시성 - 미션 진행도와 보상 받기") +class MissionProgressConcurrencyTest extends MissionSystemTestSupport { + + private static final int THREAD_COUNT = 8; + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Nested + @DisplayName("진행도 증가") + class Increase { + + @Test + @DisplayName("복습 8번이 동시에 들어와도 진행도가 어긋나지 않는다") + void doesNotLoseIncrements() { + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED)); + + assertThat(outcome.serverErrors()) + .as("동시 요청이 교착이나 유니크 제약 위반으로 500 이 되면 안 된다") + .isEmpty(); + assertThat(currentOf(user.getId(), WEEKLY_REVIEW_30)) + .as("8번 복습했으면 8이어야 한다") + .isEqualTo(THREAD_COUNT); + assertThat(currentOf(user.getId(), DAILY_REVIEW_3)) + .as("목표가 3인 미션은 3에서 멈춘다") + .isEqualTo(3); + } + + @Test + @DisplayName("동시에 들어와도 진행도 행은 사용자·미션·기간당 하나뿐이다") + void createsSingleRowPerPeriod() { + ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> missionProgressUpdater.increase(user.getId(), MissionMetric.MOOD_LOGGED)); + + assertThat(missionProgressRepository.findAll()) + .as("MOOD_LOGGED 를 세는 미션은 일일 하나뿐이다") + .hasSize(1); + } + } + + @Nested + @DisplayName("보상 받기") + class Claim { + + @Test + @DisplayName("동시에 여덟 번 눌러도 한 번만 지급된다") + void grantsOnce() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + long before = problemPracticePoints(); + + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> missionService.claim(user.getId(), progress.getId())); + + assertThat(outcome.serverErrors()).isEmpty(); + assertThat(outcome.successCount()) + .as("받기는 한 번만 성공해야 한다") + .isEqualTo(1); + assertThat(outcome.rejectedErrorCases()) + .as("나머지는 이미 받은 미션으로 거절된다") + .containsOnly(MissionErrorCase.MISSION_ALREADY_CLAIMED); + assertThat(problemPracticePoints() - before) + .as("XP 도 한 번치만 들어와야 한다") + .isEqualTo(15); + } + + private long problemPracticePoints() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java new file mode 100644 index 00000000..45bf0801 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java @@ -0,0 +1,110 @@ +package com.aisip.OnO.backend.mission.controller; + +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 프론트와 맞춘 응답 형태를 고정한다. 필드 이름이나 묶음 구조가 바뀌면 앱이 그대로 깨진다. + */ +@DisplayName("미션 API 계약") +class MissionControllerTest extends MissionSystemTestSupport { + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + authenticateAs(user.getId()); + } + + @Test + @DisplayName("GET /api/missions - daily 와 weekly 를 각자의 periodKey 와 함께 내려준다") + void getMissions() throws Exception { + completeMission(user.getId(), DAILY_NOTE_WRITE); + + mockMvc.perform(get("/api/missions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.daily.periodKey").value(periodKeyOf(DAILY_NOTE_WRITE))) + .andExpect(jsonPath("$.data.weekly.periodKey").value(periodKeyOf(WEEKLY_NOTE_10))) + .andExpect(jsonPath("$.data.daily.missions.length()").value(6)) + .andExpect(jsonPath("$.data.weekly.missions.length()").value(4)) + .andExpect(jsonPath("$.data.daily.missions[1].code").value(DAILY_NOTE_WRITE)) + .andExpect(jsonPath("$.data.daily.missions[1].title").value("오늘의 오답")) + .andExpect(jsonPath("$.data.daily.missions[1].description").value("오답노트 1개 등록")) + .andExpect(jsonPath("$.data.daily.missions[1].iconKey").value("note_write")) + .andExpect(jsonPath("$.data.daily.missions[1].category").value("DAILY")) + .andExpect(jsonPath("$.data.daily.missions[1].current").value(1)) + .andExpect(jsonPath("$.data.daily.missions[1].target").value(1)) + .andExpect(jsonPath("$.data.daily.missions[1].completed").value(true)) + .andExpect(jsonPath("$.data.daily.missions[1].claimed").value(false)) + .andExpect(jsonPath("$.data.daily.missions[1].rewardType").value("XP")) + .andExpect(jsonPath("$.data.daily.missions[1].rewardValue").value(10)) + .andExpect(jsonPath("$.data.daily.missions[1].progressId").isNumber()); + } + + @Test + @DisplayName("GET /api/missions - 인증이 없으면 401") + void getMissionsRequiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(get("/api/missions")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 보상 결과를 내려준다") + void claim() throws Exception { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progress.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.progressId").value(progress.getId())) + .andExpect(jsonPath("$.data.rewardType").value("XP")) + .andExpect(jsonPath("$.data.rewardValue").value(10)) + .andExpect(jsonPath("$.data.totalStudyLevel").isNumber()) + .andExpect(jsonPath("$.data.leveledUp").isBoolean()); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 완료하지 않았으면 7011") + void claimRejectsIncomplete() throws Exception { + missionProgressUpdater.increase(user.getId(), + definitionOf(DAILY_REVIEW_3).getMetric(), 1); + MissionProgress progress = progressOf(user, DAILY_REVIEW_3); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progress.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(7011)); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 이미 받았으면 7012") + void claimRejectsDuplicate() throws Exception { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + missionService.claim(user.getId(), progress.getId()); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progress.getId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode").value(7012)); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 남의 미션이면 7010") + void claimRejectsOtherUser() throws Exception { + User other = fixtures.createOtherUser(); + MissionProgress progress = completeMission(other.getId(), DAILY_NOTE_WRITE); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progress.getId())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.errorCode").value(7010)); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java new file mode 100644 index 00000000..a6db0759 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java @@ -0,0 +1,102 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 기간 키는 리셋 배치를 대신하는 장치다. 여기가 틀리면 미션이 하루 늦게 초기화되거나 + * 한 주에 두 번 초기화된다. 순수 로직이라 스프링 없이 경계만 확인한다. + */ +@DisplayName("미션 기간 키") +class MissionPeriodKeyTest { + + @Nested + @DisplayName("일일 키") + class Daily { + + @Test + @DisplayName("yyyy-MM-dd 형식이다") + void formatsAsIsoDate() { + assertThat(MissionPeriodKey.daily(LocalDate.of(2026, 9, 9))).isEqualTo("2026-09-09"); + } + + @Test + @DisplayName("하루가 바뀌면 키도 바뀐다") + void differsByDay() { + assertThat(MissionPeriodKey.daily(LocalDate.of(2026, 9, 9))) + .isNotEqualTo(MissionPeriodKey.daily(LocalDate.of(2026, 9, 10))); + } + } + + @Nested + @DisplayName("주간 키") + class Weekly { + + @Test + @DisplayName("yyyy-Www 형식이다") + void formatsAsIsoWeek() { + assertThat(MissionPeriodKey.weekly(LocalDate.of(2026, 9, 9))).isEqualTo("2026-W37"); + } + + @Test + @DisplayName("월요일 0시부터 일요일 23시 59분까지가 같은 주다") + void mondayToSundayShareOneKey() { + LocalDate monday = LocalDate.of(2026, 9, 7); + LocalDate sunday = LocalDate.of(2026, 9, 13); + + assertThat(MissionPeriodKey.weekly(monday)).isEqualTo("2026-W37"); + assertThat(MissionPeriodKey.weekly(sunday)).isEqualTo("2026-W37"); + } + + @Test + @DisplayName("다음 월요일부터는 다음 주다") + void nextMondayStartsNewWeek() { + assertThat(MissionPeriodKey.weekly(LocalDate.of(2026, 9, 14))).isEqualTo("2026-W38"); + } + + @Test + @DisplayName("12월 31일이 다음 해 1주차면 다음 해 키를 쓴다") + void yearEndBelongsToNextIsoYear() { + // 2019-12-31 은 달력으로는 2019년이지만 ISO 로는 2020년 1주차다. + // 달력 연도로 키를 만들면 이 주가 2019-W01 과 2020-W01 로 쪼개져 주간 미션이 두 번 초기화된다. + assertThat(MissionPeriodKey.weekly(LocalDate.of(2019, 12, 31))).isEqualTo("2020-W01"); + } + + @Test + @DisplayName("1월 1일이 지난 해 마지막 주면 지난 해 키를 쓴다") + void yearStartCanBelongToPreviousIsoYear() { + assertThat(MissionPeriodKey.weekly(LocalDate.of(2021, 1, 1))).isEqualTo("2020-W53"); + } + + @Test + @DisplayName("한 자리 주차는 0을 채운다") + void padsSingleDigitWeek() { + assertThat(MissionPeriodKey.weekly(LocalDate.of(2026, 1, 5))).isEqualTo("2026-W02"); + } + } + + @Nested + @DisplayName("카테고리별 키 선택") + class ByCategory { + + private final LocalDate date = LocalDate.of(2026, 9, 9); + + @Test + @DisplayName("일일 미션은 날짜 키를 쓴다") + void dailyUsesDateKey() { + assertThat(MissionPeriodKey.of(MissionCategory.DAILY, date)).isEqualTo("2026-09-09"); + } + + @Test + @DisplayName("주간 미션은 주차 키를 쓴다") + void weeklyUsesWeekKey() { + assertThat(MissionPeriodKey.of(MissionCategory.WEEKLY, date)).isEqualTo("2026-W37"); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java new file mode 100644 index 00000000..8a561fe5 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java @@ -0,0 +1,238 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto; +import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import com.aisip.OnO.backend.mission.service.MissionLogService; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 세는 항목 6종이 실제 행동 경로에 붙어 있는지. + * + *

진행도 증가 자체는 {@code MissionProgressUpdaterTest} 가 본다. 여기서는 "그 호출이 정말 + * 오답노트 등록·복습 기록·복습 세트 완료·기분 저장·출석 경로에 걸려 있는가"만 확인한다. + * 붙이는 것을 빠뜨리면 미션은 영원히 0 에서 멈추는데, 단위 테스트로는 절대 드러나지 않는다. + */ +@DisplayName("미션 진행도 - 행동 경로 연결") +class MissionProgressHookTest extends MissionSystemTestSupport { + + @Autowired + private ProblemService problemService; + + @Autowired + private ProblemSolveService problemSolveService; + + @Autowired + private PracticeNoteService practiceNoteService; + + @Autowired + private LearningCalendarService learningCalendarService; + + @Autowired + private MissionLogService missionLogService; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeNoteRepository practiceNoteRepository; + + private User user; + private Folder folder; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + folder = fixtures.createRootFolder(user.getId()); + } + + @Nested + @DisplayName("오답노트 등록") + class ProblemCreated { + + @Test + @DisplayName("한 장 등록하면 오늘의 오답이 1 이 된다") + void singleRegistration() { + problemService.registerProblem( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)).isEqualTo(1); + } + + @Test + @DisplayName("세 장을 한 번에 등록하면 주간 미션이 3 이 된다") + void batchRegistrationCountsEveryProblem() { + List problems = List.of( + v2Dto("메모1"), v2Dto("메모2"), v2Dto("메모3")); + + problemService.registerProblemsV2(new ProblemRegisterV2BatchDto(problems), user.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)) + .as("여러 장 등록은 장수만큼 오른다") + .isEqualTo(3); + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)) + .as("일일 미션은 목표 1 에서 멈춘다") + .isEqualTo(1); + } + + private ProblemRegisterV2Dto v2Dto(String memo) { + return new ProblemRegisterV2Dto( + null, memo, "출처", folder.getId(), LocalDateTime.now(), List.of(), List.of()); + } + } + + @Nested + @DisplayName("복습 기록") + class SolveRecorded { + + @Test + @DisplayName("복습을 기록하면 복습 미션이 오른다") + void recordsReview() { + problemSolveService.createProblemSolve(solveDto(saveProblem(), AnswerStatus.WRONG), user.getId()); + + assertThat(currentOf(user.getId(), DAILY_REVIEW_3)).isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_REVIEW_30)).isEqualTo(1); + } + + @Test + @DisplayName("정답이면 정확도 미션도 함께 오른다") + void countsCorrectAnswer() { + problemSolveService.createProblemSolve(solveDto(saveProblem(), AnswerStatus.CORRECT), user.getId()); + + assertThat(currentOf(user.getId(), DAILY_REVIEW_3)).isEqualTo(1); + assertThat(currentOf(user.getId(), DAILY_CORRECT_3)).isEqualTo(1); + } + + @Test + @DisplayName("오답이면 정확도 미션은 오르지 않는다") + void doesNotCountWrongAnswer() { + problemSolveService.createProblemSolve(solveDto(saveProblem(), AnswerStatus.WRONG), user.getId()); + + assertThat(currentOf(user.getId(), DAILY_CORRECT_3)).isZero(); + } + + private Problem saveProblem() { + Problem problem = Problem.from( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + problem.updateFolder(folder); + return problemRepository.save(problem); + } + + private ProblemSolveRegisterDto solveDto(Problem problem, AnswerStatus answerStatus) { + return new ProblemSolveRegisterDto( + problem.getId(), LocalDateTime.now(), answerStatus, "회고", null, 120, null); + } + } + + @Nested + @DisplayName("복습 세트 완료") + class PracticeNoteCompleted { + + @Test + @DisplayName("복습 세트를 끝내면 세트 미션이 오른다") + void countsCompletion() { + PracticeNote practiceNote = practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + + practiceNoteService.addPracticeNoteCount(user.getId(), practiceNote.getId()); + + assertThat(currentOf(user.getId(), DAILY_PRACTICE_SET)).isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_SET_3)).isEqualTo(1); + } + } + + @Nested + @DisplayName("학습 달력 기분") + class MoodLogged { + + @Test + @DisplayName("기분을 처음 남기면 기분 미션이 오른다") + void countsFirstMood() { + LocalDate today = studyDay(); + + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(today, "cool_sunglasses")); + + assertThat(currentOf(user.getId(), DAILY_MOOD)).isEqualTo(1); + } + + @Test + @DisplayName("같은 날 기분을 바꿔도 두 번 오르지 않는다") + void doesNotCountMoodChange() { + LocalDate today = studyDay(); + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(today, "cool_sunglasses")); + + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(today, "happy_tears")); + + assertThat(currentOf(user.getId(), DAILY_MOOD)) + .as("이모지를 바꿀 때마다 오르면 버튼 한 번으로 미션을 채울 수 있다") + .isEqualTo(1); + } + + /** 기분은 학습 기록이 있는 날에만 남길 수 있다. 오늘 작성한 오답노트로 그 조건을 만든다. */ + private LocalDate studyDay() { + Problem problem = Problem.from( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + problem.updateFolder(folder); + problemRepository.saveAndFlush(problem); + return problemRepository.findById(problem.getId()).orElseThrow().getCreatedAt().toLocalDate(); + } + } + + @Nested + @DisplayName("출석") + class LoginDay { + + @Test + @DisplayName("오늘 처음 접속하면 출석 미션이 오른다") + void countsFirstLogin() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)).isEqualTo(1); + } + + @Test + @DisplayName("하루에 여러 번 접속해도 한 번만 오른다") + void countsOncePerDay() { + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)) + .as("앱을 열 때마다 오르면 주간 출석 5일이 하루 만에 채워진다") + .isEqualTo(1); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java new file mode 100644 index 00000000..f1fafb59 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java @@ -0,0 +1,179 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("미션 진행도 증가") +class MissionProgressUpdaterTest extends MissionSystemTestSupport { + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Test + @DisplayName("행동 한 번이면 진행도가 1 오른다") + void increasesByOne() { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); + } + + @Test + @DisplayName("한 번의 행동이 같은 항목을 세는 일일과 주간을 함께 올린다") + void increasesDailyAndWeeklyTogether() { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)).isEqualTo(1); + } + + @Test + @DisplayName("여러 장을 한 번에 등록하면 장수만큼 오른다") + void increasesByGivenAmount() { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED, 3); + + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)).isEqualTo(3); + } + + @Test + @DisplayName("목표에 닿으면 완료 시각이 찍힌다") + void marksCompletedWhenTargetReached() { + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 3); + + MissionProgress progress = progressOf(user, DAILY_REVIEW_3); + assertThat(progress.getCurrentValue()).isEqualTo(3); + assertThat(progress.getCompletedAt()).as("목표를 채웠으면 완료 시각이 있어야 한다").isNotNull(); + } + + @Test + @DisplayName("목표에 닿기 전에는 완료가 아니다") + void doesNotCompleteBeforeTarget() { + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 2); + + MissionProgress progress = progressOf(user, DAILY_REVIEW_3); + assertThat(progress.getCurrentValue()).isEqualTo(2); + assertThat(progress.getCompletedAt()).isNull(); + } + + @Test + @DisplayName("목표를 넘겨도 진행도가 목표를 넘지 않는다") + void stopsAtTarget() { + for (int i = 0; i < 4; i++) { + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED); + } + + assertThat(currentOf(user.getId(), DAILY_REVIEW_3)) + .as("4번 복습해도 화면에 4 / 3 이 보이면 안 된다") + .isEqualTo(3); + } + + @Test + @DisplayName("목표를 넘겨도 완료 시각은 처음 한 번만 찍힌다") + void keepsFirstCompletedAt() { + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 3); + var completedAt = progressOf(user, DAILY_REVIEW_3).getCompletedAt(); + + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 3); + + assertThat(progressOf(user, DAILY_REVIEW_3).getCompletedAt()).isEqualTo(completedAt); + } + + @Nested + @DisplayName("기간이 다르면 별개다") + class PeriodSeparation { + + @Test + @DisplayName("어제 진행도가 오늘에 섞이지 않는다") + void yesterdayDoesNotLeakIntoToday() { + MissionDefinition definition = definitionOf(DAILY_NOTE_WRITE); + String yesterdayKey = MissionPeriodKey.daily(MissionPeriodKey.today().minusDays(1)); + // 리셋 배치가 없으므로 어제 행은 그대로 남아 있다. 오늘 키로 조회할 때 잡히지 않는 것이 전부다. + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(6), NOW(6)) + """, user.getId(), definition.getId(), yesterdayKey, 1, definition.getTarget()); + + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)) + .as("오늘 진행도는 어제와 무관하게 1 이다") + .isEqualTo(1); + assertThat(missionProgressRepository.findAll()) + .as("어제 행과 오늘 행은 서로 다른 행이다") + .filteredOn(progress -> progress.getMissionId().equals(definition.getId())) + .hasSize(2); + } + + @Test + @DisplayName("지난 주 진행도가 이번 주에 섞이지 않는다") + void lastWeekDoesNotLeakIntoThisWeek() { + MissionDefinition definition = definitionOf(WEEKLY_NOTE_10); + LocalDate lastWeek = MissionPeriodKey.today().minusWeeks(1); + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(6), NOW(6)) + """, user.getId(), definition.getId(), MissionPeriodKey.weekly(lastWeek), 9, definition.getTarget()); + + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)).isEqualTo(1); + } + } + + @Nested + @DisplayName("트랜잭션 경계") + class TransactionBoundary { + + @Test + @DisplayName("행동이 롤백되면 진행도도 남지 않는다") + void rollsBackWithTheAction() { + // 진행도를 별도 트랜잭션(REQUIRES_NEW)이나 이벤트 리스너로 떼어내면 이 단언이 깨진다. + // 하지도 않은 행동으로 보상을 받게 되므로 호출자의 트랜잭션 안에서 돌아야 한다. + assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + throw new IllegalStateException("행동이 실패했다"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(missionProgressRepository.findAll()).isEmpty(); + } + } + + @Nested + @DisplayName("올리지 않는 경우") + class NoOp { + + @Test + @DisplayName("증가량이 0 이하면 아무 일도 없다") + void ignoresNonPositiveAmount() { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED, 0); + + assertThat(missionProgressRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("사용자나 항목이 없으면 아무 일도 없다") + void ignoresNullArguments() { + missionProgressUpdater.increase(null, MissionMetric.PROBLEM_CREATED, 1); + missionProgressUpdater.increase(user.getId(), null, 1); + + assertThat(missionProgressRepository.findAll()).isEmpty(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java new file mode 100644 index 00000000..6da76571 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java @@ -0,0 +1,251 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; +import com.aisip.OnO.backend.mission.dto.MissionResponseDto; +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.exception.MissionErrorCase; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("미션 조회와 보상 받기") +class MissionServiceTest extends MissionSystemTestSupport { + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Nested + @DisplayName("목록 조회") + class GetMissions { + + @Test + @DisplayName("시드 10종이 일일 6개와 주간 4개로 갈린다") + void splitsDailyAndWeekly() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.daily().missions()).hasSize(6); + assertThat(response.weekly().missions()).hasSize(4); + assertThat(response.daily().missions()) + .extracting(MissionResponseDto::category) + .containsOnly(MissionCategory.DAILY); + assertThat(response.weekly().missions()) + .extracting(MissionResponseDto::category) + .containsOnly(MissionCategory.WEEKLY); + } + + @Test + @DisplayName("기간 키를 각 묶음에 실어 내려준다") + void carriesPeriodKeys() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.daily().periodKey()).isEqualTo(MissionPeriodKey.daily(MissionPeriodKey.today())); + assertThat(response.weekly().periodKey()).isEqualTo(MissionPeriodKey.weekly(MissionPeriodKey.today())); + } + + @Test + @DisplayName("sortOrder 오름차순으로 정렬된다") + void sortsBySortOrder() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.daily().missions()) + .extracting(MissionResponseDto::code) + .containsExactly( + DAILY_ATTEND, DAILY_NOTE_WRITE, DAILY_REVIEW_3, + DAILY_CORRECT_3, DAILY_PRACTICE_SET, DAILY_MOOD); + assertThat(response.weekly().missions()) + .extracting(MissionResponseDto::code) + .containsExactly(WEEKLY_ATTEND_5, WEEKLY_NOTE_10, WEEKLY_REVIEW_30, WEEKLY_SET_3); + } + + @Test + @DisplayName("아직 손대지 않은 미션도 0 으로 내려간다") + void showsUntouchedMissionsAsZero() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + MissionResponseDto noteWrite = findByCode(response.daily().missions(), DAILY_NOTE_WRITE); + assertThat(noteWrite.current()).isZero(); + assertThat(noteWrite.target()).isEqualTo(1); + assertThat(noteWrite.completed()).isFalse(); + assertThat(noteWrite.claimed()).isFalse(); + assertThat(noteWrite.progressId()).isNull(); + } + + @Test + @DisplayName("조회만으로는 진행도 행이 생기지 않는다") + void doesNotCreateRowsOnRead() { + missionService.getMissions(user.getId()); + + assertThat(missionProgressRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("완료된 미션에는 받기에 쓸 progressId 가 있다") + void completedMissionCarriesProgressId() { + completeMission(user.getId(), DAILY_NOTE_WRITE); + + MissionResponseDto noteWrite = findByCode( + missionService.getMissions(user.getId()).daily().missions(), DAILY_NOTE_WRITE); + + assertThat(noteWrite.completed()).isTrue(); + assertThat(noteWrite.progressId()).isNotNull(); + assertThat(noteWrite.rewardType()).isEqualTo(MissionRewardType.XP); + assertThat(noteWrite.rewardValue()).isEqualTo(10); + } + + @Test + @DisplayName("남의 진행도는 내 목록에 섞이지 않는다") + void doesNotLeakOtherUsersProgress() { + User other = fixtures.createOtherUser(); + completeMission(other.getId(), DAILY_NOTE_WRITE); + + MissionResponseDto noteWrite = findByCode( + missionService.getMissions(user.getId()).daily().missions(), DAILY_NOTE_WRITE); + + assertThat(noteWrite.current()).isZero(); + assertThat(noteWrite.progressId()).isNull(); + } + + private MissionResponseDto findByCode(List missions, String code) { + return missions.stream() + .filter(mission -> mission.code().equals(code)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("목록에 없다: " + code)); + } + } + + @Nested + @DisplayName("보상 받기") + class Claim { + + @Test + @DisplayName("완료한 미션의 보상을 받으면 XP 가 들어온다") + void grantsXp() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + long before = problemPracticePoints(); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.progressId()).isEqualTo(progress.getId()); + assertThat(response.rewardType()).isEqualTo(MissionRewardType.XP); + assertThat(response.rewardValue()).isEqualTo(15); + assertThat(response.totalStudyLevel()).isNotNull(); + assertThat(problemPracticePoints() - before) + .as("보상은 metric 이 가리키는 능력치에 들어간다") + .isEqualTo(15); + } + + @Test + @DisplayName("받고 나면 목록에 claimed 로 보인다") + void marksClaimed() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(missionProgressRepository.findById(progress.getId()).orElseThrow().getClaimedAt()) + .isNotNull(); + } + + @Test + @DisplayName("아직 완료하지 않은 미션은 받을 수 없다") + void rejectsIncompleteMission() { + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 2); + MissionProgress progress = progressOf(user, DAILY_REVIEW_3); + + assertThatThrownBy(() -> missionService.claim(user.getId(), progress.getId())) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.MISSION_NOT_COMPLETED); + } + + @Test + @DisplayName("두 번 받을 수 없다") + void rejectsSecondClaim() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + missionService.claim(user.getId(), progress.getId()); + long afterFirstClaim = problemPracticePoints(); + + assertThatThrownBy(() -> missionService.claim(user.getId(), progress.getId())) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.MISSION_ALREADY_CLAIMED); + assertThat(problemPracticePoints()).isEqualTo(afterFirstClaim); + } + + @Test + @DisplayName("없는 진행도는 받을 수 없다") + void rejectsUnknownProgress() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + + assertThatThrownBy(() -> missionService.claim(user.getId(), progress.getId() + 1_000L)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND); + } + + @Test + @DisplayName("남의 미션은 받을 수 없다 - 있는지 없는지도 알려주지 않는다") + void rejectsOtherUsersProgress() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + User other = fixtures.createOtherUser(); + + assertThatThrownBy(() -> missionService.claim(other.getId(), progress.getId())) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND); + assertThat(missionProgressRepository.findById(progress.getId()).orElseThrow().getClaimedAt()) + .as("거절됐으면 수령 도장도 찍히면 안 된다") + .isNull(); + } + + @Test + @DisplayName("하루 200점 상한을 채운 뒤에도 미션 보상은 들어온다") + void ignoresDailyPointCap() { + // 상한은 기존 적립(MissionLog) 경로에만 있는 규칙이다. 미션 보상은 그 위에 얹는 보너스라 상한 밖이다. + saveMissionLog(user, MissionType.PROBLEM_WRITE, null); + jdbcTemplate.update("UPDATE mission_log SET point = 300 WHERE user_id = ?", user.getId()); + + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + long before = problemPracticePoints(); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(problemPracticePoints() - before).isEqualTo(15); + } + + @Test + @DisplayName("보상으로 레벨이 오르면 leveledUp 이 참이다") + void reportsLevelUp() { + // 총 학습 레벨 1→2 에 필요한 경험치는 40 이다. 주간 복습 미션 보상 100 이면 반드시 오른다. + MissionProgress progress = completeMission(user.getId(), WEEKLY_REVIEW_30); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.leveledUp()).isTrue(); + assertThat(response.totalStudyLevel()).isGreaterThan(1L); + } + + private long problemPracticePoints() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java new file mode 100644 index 00000000..16384171 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java @@ -0,0 +1,72 @@ +package com.aisip.OnO.backend.mission.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * 미션 정의 시드를 테스트 DB 에 넣는다. + * + *

테스트 프로필은 Flyway 가 꺼져 있고 스키마를 Hibernate 가 만든다. 그래서 시드 INSERT 가 + * 저절로 실행되지 않는데, 미션 조회·진행도는 정의가 없으면 아무것도 하지 않으므로 + * 시드가 없으면 이 도메인 테스트는 전부 무의미해진다. + * + *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 INSERT 문을 그대로 읽어 실행한다. + * 같은 값을 두 곳에 적으면 한쪽만 고쳐질 때 테스트가 프로덕션 시드와 다른 것을 검증하게 된다. + * + *

{@code DatabaseCleaner} 가 테스트마다 모든 테이블을 비우므로 매 테스트 시작 시 다시 넣어야 한다. + */ +@Component +public class MissionDefinitionSeeder { + + private static final String MIGRATION_PATH = "db/migration/V29__create_mission_system.sql"; + + @PersistenceContext + private EntityManager entityManager; + + private List insertStatements; + + @Transactional + public void seed() { + if (insertStatements == null) { + insertStatements = loadInsertStatements(); + } + for (String statement : insertStatements) { + entityManager.createNativeQuery(statement).executeUpdate(); + } + } + + private List loadInsertStatements() { + String script = readMigration(); + + // 주석 줄에도 세미콜론이 들어갈 수 있어 먼저 걷어낸다. + String withoutComments = Arrays.stream(script.split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .reduce(new StringBuilder(), (sb, line) -> sb.append(line).append('\n'), StringBuilder::append) + .toString(); + + return Arrays.stream(withoutComments.split(";")) + .map(String::trim) + .filter(statement -> statement.toUpperCase(Locale.ROOT).startsWith("INSERT")) + .toList(); + } + + private String readMigration() { + try (InputStream inputStream = new ClassPathResource(MIGRATION_PATH).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("미션 시드 마이그레이션을 읽지 못했다: " + MIGRATION_PATH, e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java new file mode 100644 index 00000000..642bcceb --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -0,0 +1,88 @@ +package com.aisip.OnO.backend.mission.support; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import com.aisip.OnO.backend.mission.service.MissionPeriodKey; +import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; +import com.aisip.OnO.backend.mission.service.MissionService; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 미션 시스템(정의/진행도/보상) 테스트의 공통 베이스. + * + *

{@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + */ +public abstract class MissionSystemTestSupport extends MissionTestSupport { + + public static final String DAILY_ATTEND = "DAILY_ATTEND"; + public static final String DAILY_NOTE_WRITE = "DAILY_NOTE_WRITE"; + public static final String DAILY_REVIEW_3 = "DAILY_REVIEW_3"; + public static final String DAILY_CORRECT_3 = "DAILY_CORRECT_3"; + public static final String DAILY_PRACTICE_SET = "DAILY_PRACTICE_SET"; + public static final String DAILY_MOOD = "DAILY_MOOD"; + public static final String WEEKLY_ATTEND_5 = "WEEKLY_ATTEND_5"; + public static final String WEEKLY_NOTE_10 = "WEEKLY_NOTE_10"; + public static final String WEEKLY_REVIEW_30 = "WEEKLY_REVIEW_30"; + public static final String WEEKLY_SET_3 = "WEEKLY_SET_3"; + + @Autowired + private MissionDefinitionSeeder missionDefinitionSeeder; + + @Autowired + protected MissionDefinitionRepository missionDefinitionRepository; + + @Autowired + protected MissionProgressRepository missionProgressRepository; + + @Autowired + protected MissionService missionService; + + @Autowired + protected MissionProgressUpdater missionProgressUpdater; + + @Autowired + protected TransactionTemplate transactionTemplate; + + /** DatabaseCleaner 가 매 테스트마다 mission_definition 까지 비우므로 여기서 다시 채운다. */ + @BeforeEach + void seedMissionDefinitions() { + missionDefinitionSeeder.seed(); + } + + protected MissionDefinition definitionOf(String code) { + return missionDefinitionRepository.findByCode(code).orElseThrow( + () -> new IllegalStateException("시드에 없는 미션 코드다: " + code)); + } + + protected String periodKeyOf(String code) { + return MissionPeriodKey.of(definitionOf(code).getCategory(), MissionPeriodKey.today()); + } + + protected MissionProgress progressOf(User user, String code) { + return progressOf(user.getId(), code); + } + + protected MissionProgress progressOf(Long userId, String code) { + MissionDefinition definition = definitionOf(code); + return missionProgressRepository + .findByUserIdAndMissionIdAndPeriodKey(userId, definition.getId(), periodKeyOf(code)) + .orElse(null); + } + + protected int currentOf(Long userId, String code) { + MissionProgress progress = progressOf(userId, code); + return progress == null ? 0 : progress.getCurrentValue(); + } + + /** 미션을 목표까지 밀어 올린다. 받기 시나리오의 사전 준비용. */ + protected MissionProgress completeMission(Long userId, String code) { + MissionDefinition definition = definitionOf(code); + missionProgressUpdater.increase(userId, definition.getMetric(), definition.getTarget()); + return progressOf(userId, code); + } +} From 0ddab7ad65c8bfb37f09e1e7fc35039115608ae5 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:49:27 +0900 Subject: [PATCH 047/130] =?UTF-8?q?[Fix]=20=EA=B0=99=EC=9D=80=20=EB=B3=B5?= =?UTF-8?q?=EC=8A=B5=20=EC=84=B8=ED=8A=B8=EB=A5=BC=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=ED=95=B4=EB=8F=84=20=EB=AF=B8=EC=85=98?= =?UTF-8?q?=EC=9D=B4=20=EC=98=A4=EB=A5=B4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세트 완료 진행도가 기존 중복 방지 가드 밖에 있어, 같은 세트에 완료 요청을 세 번 보내는 것만으로 주간 세트 미션 80 XP 가 채워졌다 - 출석·기분과 같은 방식으로 실제 적립이 일어난 분기 안으로 옮긴다. "같은 세트를 또 끝낸 건 보상 대상이 아니다"는 기존 제품 규칙을 진행도도 그대로 따른다 --- .../OnO/backend/mission/service/MissionLogService.java | 4 ++++ .../backend/practicenote/service/PracticeNoteService.java | 8 ++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 0b667ae5..db20a0e7 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -191,6 +191,10 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { missionLogRepository.save(missionLog); addPointToUser(user, missionLog); + + // 세트 완료 미션 진행도. 출석과 같은 이유로 기존 중복 방지 가드 안에 둔다. + // 밖에서 올리면 같은 세트에 완료 요청을 세 번 보내는 것만으로 주간 세트 미션이 채워진다. + missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index 3776d427..ba300d1a 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -4,9 +4,7 @@ import com.aisip.OnO.backend.common.emoji.CustomEmojiValidator; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.common.response.CursorPageResponse; -import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.service.MissionLogService; -import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.practicenote.dto.*; import com.aisip.OnO.backend.practicenote.entity.PracticeNote; import com.aisip.OnO.backend.practicenote.entity.PracticeNotification; @@ -58,8 +56,6 @@ public class PracticeNoteService { private final MissionLogService missionLogService; - private final MissionProgressUpdater missionProgressUpdater; - private final UserRepository userRepository; private final CustomEmojiValidator customEmojiValidator; @@ -153,9 +149,9 @@ public void addPracticeNoteCount(Long userId, Long practiceId, PracticeNoteCompl customEmojiValidator.validateNullable(moodEmojiKey); practiceNote.updatePracticeNoteCount(moodEmojiKey); - // 복습노트 사용 미션 등록 + // 복습노트 사용 미션 등록. 세트 완료 미션 진행도도 이 안에서 함께 오른다. + // 여기서 따로 올리면 같은 세트를 반복 완료하는 것만으로 주간 세트 미션이 채워진다. missionLogService.registerNotePracticeMission(userId, practiceId); - missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); log.info("practiceId: {} count has updated", practiceId); } From 6d424a224b1738ee5137a13dbbaf5d3f40d82307 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:49:28 +0900 Subject: [PATCH 048/130] =?UTF-8?q?[Fix]=20=EB=AF=B8=EC=85=98=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8F=84=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EB=B3=B8=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=EC=9D=84=20=EB=A1=A4=EB=B0=B1=EC=8B=9C?= =?UTF-8?q?=ED=82=A4=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 진행도 갱신이 호출자 트랜잭션에 합류하고 있어, upsert 가 잠금 대기 시간을 넘기거나 교착 희생자가 되면 사용자가 방금 저장한 오답노트까지 통째로 사라지고 500 이 나갔다 - 커밋 후 실행(AFTER_COMMIT) + 새 트랜잭션(REQUIRES_NEW) 으로 뗀다. 본 작업이 롤백되면 이벤트가 발행되지 않아 진행도도 남지 않고, 진행도가 실패해도 본 작업은 이미 커밋돼 있다 - 판단 기준은 외부 연동이냐 DB 냐가 아니라 본 작업의 성패에 그 결과가 필요한가다. 주석도 같이 고쳤다 - 실패는 로그만 남긴다. 커밋 콜백에서 예외가 새면 데이터는 저장됐는데 500 이 나가는 조합이 된다 --- .../mission/event/MissionProgressEvent.java | 16 ++++++ .../service/MissionProgressApplier.java | 41 +++++++++++++++ .../service/MissionProgressEventListener.java | 41 +++++++++++++++ .../service/MissionProgressUpdater.java | 52 +++++++------------ 4 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java diff --git a/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java b/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java new file mode 100644 index 00000000..2ed6f83e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java @@ -0,0 +1,16 @@ +package com.aisip.OnO.backend.mission.event; + +import com.aisip.OnO.backend.mission.entity.MissionMetric; + +/** + * "사용자가 세는 항목에 해당하는 행동을 했다"는 사실. + * + *

행동을 처리하는 트랜잭션이 커밋된 뒤에 소비된다. 커밋 전에는 아직 일어나지 않은 일이므로 + * 진행도를 올리면 안 되고, 커밋 뒤라면 진행도가 실패하더라도 행동은 이미 끝난 일이라 되돌리면 안 된다. + */ +public record MissionProgressEvent( + Long userId, + MissionMetric metric, + int amount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java new file mode 100644 index 00000000..8dd3d37f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +/** + * 진행도를 실제로 갱신한다. 본 작업과 분리된 자기 트랜잭션에서 돈다. + * + *

증가는 리포지토리의 upsert 한 문장으로만 한다. 조회한 뒤 값을 계산해 저장하면 + * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. + */ +@Component +@RequiredArgsConstructor +public class MissionProgressApplier { + + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void apply(Long userId, MissionMetric metric, int amount) { + LocalDate today = MissionPeriodKey.today(); + List definitions = + missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); + + for (MissionDefinition definition : definitions) { + String periodKey = MissionPeriodKey.of(definition.getCategory(), today); + missionProgressRepository.increaseValue( + userId, definition.getId(), periodKey, amount, definition.getTarget()); + missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java new file mode 100644 index 00000000..8954720f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.event.MissionProgressEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 본 작업이 커밋된 뒤에 진행도를 올린다. + * + *

실패는 삼키고 로그만 남긴다. 여기서 예외가 새어 나가면 커밋 콜백을 타고 요청 처리부까지 올라가, + * 데이터는 이미 저장됐는데 사용자에게는 500 이 나가는 최악의 조합이 된다. + * 진행도는 다음 행동에서 다시 오르므로 한 번 놓쳐도 회복 가능하다. + * + *

{@code @Transactional} 을 이 메서드에 직접 붙이지 않는다. 붙이면 여기서 예외를 잡는 순간 + * 트랜잭션이 롤백되지 않고 중간까지 반영된 채로 커밋된다. 새 트랜잭션 경계는 + * {@link MissionProgressApplier} 에 두어, 실패하면 그 트랜잭션만 통째로 롤백되고 + * 예외는 프록시 밖의 이 자리에서 잡힌다. + * + *

{@code fallbackExecution} 은 트랜잭션 없이 불린 경우에도 진행도를 올리기 위한 것이다. + * 운영 경로는 모두 트랜잭션 안이지만, 없을 때 조용히 사라지는 것보다 즉시 실행하는 편이 안전하다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class MissionProgressEventListener { + + private final MissionProgressApplier missionProgressApplier; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void handleMissionProgress(MissionProgressEvent event) { + try { + missionProgressApplier.apply(event.userId(), event.metric(), event.amount()); + } catch (Exception e) { + log.error("[Mission] 진행도 갱신 실패 - userId: {}, metric: {}, amount: {}", + event.userId(), event.metric(), event.amount(), e); + } + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java index 8175aefa..9b8bffe1 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java @@ -1,40 +1,35 @@ package com.aisip.OnO.backend.mission.service; -import com.aisip.OnO.backend.mission.entity.MissionDefinition; import com.aisip.OnO.backend.mission.entity.MissionMetric; -import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; -import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; +import com.aisip.OnO.backend.mission.event.MissionProgressEvent; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.util.List; /** - * 다른 도메인이 부르는 진행도 증가 진입점. + * 다른 도메인이 부르는 진행도 증가 진입점. 실제 갱신은 하지 않고 사실만 알린다. + * + *

왜 호출자의 트랜잭션에 합류하지 않는가. 요구는 두 가지인데 방향이 다르다. * - *

호출자의 트랜잭션 안에서 돈다. 별도 이벤트 리스너를 두거나 {@code REQUIRES_NEW} 로 떼어내지 않는다. - * 오답노트 등록이 롤백됐는데 "오늘의 오답" 진행도만 남으면 사용자는 하지도 않은 일로 보상을 받는다. - * {@code ProblemReviewReminderService} 가 {@code AFTER_COMMIT} + {@code REQUIRES_NEW} 를 쓰는 것은 - * FCM 이라는 외부 연동이 실패해도 본 작업을 막으면 안 되기 때문이고, 진행도는 DB 만 건드리므로 경우가 다르다. + *

하나는 "본 작업이 실패하면 진행도도 없어야 한다"이다. 오답노트 등록이 롤백됐는데 + * "오늘의 오답" 진행도만 남으면 사용자는 하지도 않은 일로 보상을 받는다. * - *

증가는 리포지토리의 upsert 한 문장으로만 한다. 여기서 조회한 뒤 값을 계산해 저장하면 - * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. + *

다른 하나는 "진행도가 실패해도 본 작업은 살아야 한다"이다. 진행도 갱신은 다른 트랜잭션이 잡은 + * 행에서 대기하다 잠금 대기 시간을 넘기거나 교착의 희생자가 될 수 있다. 같은 트랜잭션에 묶어 두면 + * 그때 사용자가 방금 저장한 오답노트가 통째로 사라지고 500 이 나간다. + * 보너스 카운터 하나 때문에 본 기능을 잃는 거래는 성립하지 않는다. + * + *

커밋 후 실행({@code AFTER_COMMIT})에 새 트랜잭션({@code REQUIRES_NEW})이면 둘 다 선다. + * 본 작업이 롤백되면 이벤트가 발행되지 않아 진행도도 남지 않고, 진행도가 실패해도 본 작업은 이미 커밋돼 있다. + * 판단 기준은 외부 연동이냐 DB 냐가 아니라 본 작업의 성패에 그 결과가 필요한가이고, + * 미션 진행도는 필요 없다. {@code ProblemReviewReminderService} 가 같은 이유로 같은 형태를 쓴다. */ @Component @RequiredArgsConstructor public class MissionProgressUpdater { - private final MissionDefinitionRepository missionDefinitionRepository; - private final MissionProgressRepository missionProgressRepository; + private final ApplicationEventPublisher eventPublisher; - /** - * 애노테이션이 여기에도 붙어 있어야 한다. 이 오버로드는 같은 빈의 3인자 메서드를 직접 부르는데, - * 자기 호출은 프록시를 타지 않아 저쪽의 {@code @Transactional} 이 적용되지 않는다. - * 호출자에게 트랜잭션이 없으면 그대로 트랜잭션 없이 실행돼 flush 에서 터진다. - */ - @Transactional public void increase(Long userId, MissionMetric metric) { increase(userId, metric, 1); } @@ -42,21 +37,10 @@ public void increase(Long userId, MissionMetric metric) { /** * @param amount 한 번에 오를 양. 오답노트를 여러 장 등록하면 장수만큼 오른다. */ - @Transactional public void increase(Long userId, MissionMetric metric, int amount) { if (userId == null || metric == null || amount <= 0) { return; } - - LocalDate today = MissionPeriodKey.today(); - List definitions = - missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); - - for (MissionDefinition definition : definitions) { - String periodKey = MissionPeriodKey.of(definition.getCategory(), today); - missionProgressRepository.increaseValue( - userId, definition.getId(), periodKey, amount, definition.getTarget()); - missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); - } + eventPublisher.publishEvent(new MissionProgressEvent(userId, metric, amount)); } } From b78241dc4aa2b4e55f80569ee21700b9b58703a1 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:49:28 +0900 Subject: [PATCH 049/130] =?UTF-8?q?[Fix]=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B6=80=EB=B6=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EB=8C=80=EB=B9=84=20-=20DDL=20=EA=B3=BC=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MySQL DDL 은 트랜잭션이 아니라, 테이블만 만들어지고 INSERT 에서 끊기면 Flyway 는 실패로 기록하는데 테이블은 남는다. 재기동하면 table already exists 로 또 실패해 flyway repair 없이는 앱이 안 뜬다 - V29 는 DDL 만, V30 은 시드만 담는다. 시드는 ON DUPLICATE KEY UPDATE code = code 로 재실행에 안전하다 - DDL 도 CREATE TABLE IF NOT EXISTS 로 바꾸고 조회 인덱스를 테이블 정의 안에 넣는다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 그 문장이 재실행 지점이 된다 --- .../migration/V29__create_mission_system.sql | 44 ++++++------------- .../V30__seed_mission_definitions.sql | 25 +++++++++++ .../support/MissionDefinitionSeeder.java | 4 +- 3 files changed, 40 insertions(+), 33 deletions(-) create mode 100644 src/main/resources/db/migration/V30__seed_mission_definitions.sql diff --git a/src/main/resources/db/migration/V29__create_mission_system.sql b/src/main/resources/db/migration/V29__create_mission_system.sql index e004cb46..3d6815aa 100644 --- a/src/main/resources/db/migration/V29__create_mission_system.sql +++ b/src/main/resources/db/migration/V29__create_mission_system.sql @@ -1,11 +1,13 @@ --- 미션 시스템 1차: 일일/주간 미션 정의와 사용자별 진행도. +-- 미션 시스템 1차: 일일/주간 미션 정의와 사용자별 진행도. 이 파일은 DDL 만 담는다. +-- 시드는 V30 에 따로 둔다. MySQL DDL 은 트랜잭션이 아니라서, 테이블은 만들어지고 뒤이은 INSERT 에서 끊기면 +-- Flyway 는 실패로 기록하는데 테이블은 남는다. 재기동하면 "table already exists" 로 또 실패해 +-- flyway repair 없이는 앱이 뜨지 않는다. blue-green 배포 구간이면 그대로 장애다. -- --- 리셋 배치를 두지 않는다. 진행도 행은 period_key(일일 yyyy-MM-dd, 주간 yyyy-'W'ww)로 갈라지므로 --- 오늘 키로 조회하면 어제 진행도는 애초에 잡히지 않는다. Quartz 가 isClustered:false 인 채로 --- blue-green 배포를 하고 있어 스케줄 작업이 배포 구간에 양쪽에서 도는 위험이 있는데, --- 리셋 배치를 만들지 않으면 그 위험을 통째로 피한다. +-- 같은 이유로 두 테이블 모두 IF NOT EXISTS 로 만들고, 조회용 인덱스도 별도 CREATE INDEX 가 아니라 +-- 테이블 정의 안에 둔다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 그 문장이 +-- 재실행 지점이 되기 때문이다. 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. -CREATE TABLE mission_definition ( +CREATE TABLE IF NOT EXISTS mission_definition ( id BIGINT NOT NULL AUTO_INCREMENT, code VARCHAR(60) NOT NULL, title VARCHAR(60) NOT NULL, @@ -30,7 +32,9 @@ CREATE TABLE mission_definition ( -- -- uk_mission_progress 는 성능용이 아니라 정합성용이다. 진행도 증가를 -- INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 처리하는데, 그 upsert 가 성립하는 근거가 이 유니크 키다. -CREATE TABLE mission_progress ( +-- +-- idx_mission_progress_lookup 은 "이 사용자의 이번 기간 진행도 전부" 조회용이다. +CREATE TABLE IF NOT EXISTS mission_progress ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, mission_id BIGINT NOT NULL, @@ -43,28 +47,6 @@ CREATE TABLE mission_progress ( updated_at DATETIME(6) NULL, deleted_at DATETIME(6) NULL, PRIMARY KEY (id), - UNIQUE KEY uk_mission_progress (user_id, mission_id, period_key) + UNIQUE KEY uk_mission_progress (user_id, mission_id, period_key), + KEY idx_mission_progress_lookup (user_id, period_key) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; - --- 목록 조회는 "이 사용자의 이번 기간 진행도 전부" 한 가지뿐이다. -CREATE INDEX idx_mission_progress_lookup - ON mission_progress (user_id, period_key); - --- 시드 10종. 관리자 화면은 2차라 정의는 마이그레이션으로만 들어온다. -INSERT INTO mission_definition - (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) -VALUES - ('DAILY_ATTEND', '출석', '오늘 앱 켜기', 'attendance', 'DAILY', 'LOGIN_DAY', 1, 'XP', 10, 1, 1, NOW(6), NOW(6)), - ('DAILY_NOTE_WRITE', '오늘의 오답', '오답노트 1개 등록', 'note_write', 'DAILY', 'PROBLEM_CREATED', 1, 'XP', 10, 2, 1, NOW(6), NOW(6)), - ('DAILY_REVIEW_3', '세 문제만', '오답 3문제 복습', 'review', 'DAILY', 'SOLVE_RECORDED', 3, 'XP', 15, 3, 1, NOW(6), NOW(6)), - ('DAILY_CORRECT_3', '정확하게', '복습해서 3문제 맞히기', 'accuracy', 'DAILY', 'SOLVE_CORRECT', 3, 'XP', 20, 4, 1, NOW(6), NOW(6)), - ('DAILY_PRACTICE_SET', '세트 완주', '복습 세트 하나 끝내기', 'practice_set', 'DAILY', 'PRACTICE_NOTE_COMPLETED', 1, 'XP', 15, 5, 1, NOW(6), NOW(6)), - ('DAILY_MOOD', '오늘 기분', '학습 달력에 기분 남기기', 'mood', 'DAILY', 'MOOD_LOGGED', 1, 'XP', 5, 6, 1, NOW(6), NOW(6)); - -INSERT INTO mission_definition - (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) -VALUES - ('WEEKLY_ATTEND_5', '꾸준함', '이번 주 5일 출석', 'attendance', 'WEEKLY', 'LOGIN_DAY', 5, 'XP', 100, 1, 1, NOW(6), NOW(6)), - ('WEEKLY_NOTE_10', '열 권의 노트', '오답노트 10개 등록', 'note_write', 'WEEKLY', 'PROBLEM_CREATED', 10, 'XP', 80, 2, 1, NOW(6), NOW(6)), - ('WEEKLY_REVIEW_30', '서른 번의 복습', '복습 30회', 'review', 'WEEKLY', 'SOLVE_RECORDED', 30, 'XP', 100, 3, 1, NOW(6), NOW(6)), - ('WEEKLY_SET_3', '세 번의 완주', '복습 세트 3개 끝내기', 'practice_set', 'WEEKLY', 'PRACTICE_NOTE_COMPLETED', 3, 'XP', 80, 4, 1, NOW(6), NOW(6)); diff --git a/src/main/resources/db/migration/V30__seed_mission_definitions.sql b/src/main/resources/db/migration/V30__seed_mission_definitions.sql new file mode 100644 index 00000000..8ffee7f6 --- /dev/null +++ b/src/main/resources/db/migration/V30__seed_mission_definitions.sql @@ -0,0 +1,25 @@ +-- 미션 정의 시드 10종 (일일 6, 주간 4). 관리자 화면은 2차라 정의는 마이그레이션으로만 들어온다. +-- +-- ON DUPLICATE KEY UPDATE code = code 는 "이미 있으면 아무것도 하지 않는다"는 뜻이다. +-- uk_mission_definition_code 가 중복을 잡아 주므로, 이 문장은 몇 번을 다시 돌려도 결과가 같다. +-- 운영 중에 목표나 보상을 손으로 고쳤을 수 있어 덮어쓰지 않는다. 값을 바꾸려면 새 마이그레이션을 쓴다. + +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('DAILY_ATTEND', '출석', '오늘 앱 켜기', 'attendance', 'DAILY', 'LOGIN_DAY', 1, 'XP', 10, 1, 1, NOW(6), NOW(6)), + ('DAILY_NOTE_WRITE', '오늘의 오답', '오답노트 1개 등록', 'note_write', 'DAILY', 'PROBLEM_CREATED', 1, 'XP', 10, 2, 1, NOW(6), NOW(6)), + ('DAILY_REVIEW_3', '세 문제만', '오답 3문제 복습', 'review', 'DAILY', 'SOLVE_RECORDED', 3, 'XP', 15, 3, 1, NOW(6), NOW(6)), + ('DAILY_CORRECT_3', '정확하게', '복습해서 3문제 맞히기', 'accuracy', 'DAILY', 'SOLVE_CORRECT', 3, 'XP', 20, 4, 1, NOW(6), NOW(6)), + ('DAILY_PRACTICE_SET', '세트 완주', '복습 세트 하나 끝내기', 'practice_set', 'DAILY', 'PRACTICE_NOTE_COMPLETED', 1, 'XP', 15, 5, 1, NOW(6), NOW(6)), + ('DAILY_MOOD', '오늘 기분', '학습 달력에 기분 남기기', 'mood', 'DAILY', 'MOOD_LOGGED', 1, 'XP', 5, 6, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE code = code; + +INSERT INTO mission_definition + (code, title, description, icon_key, category, metric, target, reward_type, reward_value, sort_order, active, created_at, updated_at) +VALUES + ('WEEKLY_ATTEND_5', '꾸준함', '이번 주 5일 출석', 'attendance', 'WEEKLY', 'LOGIN_DAY', 5, 'XP', 100, 1, 1, NOW(6), NOW(6)), + ('WEEKLY_NOTE_10', '열 권의 노트', '오답노트 10개 등록', 'note_write', 'WEEKLY', 'PROBLEM_CREATED', 10, 'XP', 80, 2, 1, NOW(6), NOW(6)), + ('WEEKLY_REVIEW_30', '서른 번의 복습', '복습 30회', 'review', 'WEEKLY', 'SOLVE_RECORDED', 30, 'XP', 100, 3, 1, NOW(6), NOW(6)), + ('WEEKLY_SET_3', '세 번의 완주', '복습 세트 3개 끝내기', 'practice_set', 'WEEKLY', 'PRACTICE_NOTE_COMPLETED', 3, 'XP', 80, 4, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE code = code; diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java index 16384171..2890a48e 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java @@ -22,7 +22,7 @@ * 저절로 실행되지 않는데, 미션 조회·진행도는 정의가 없으면 아무것도 하지 않으므로 * 시드가 없으면 이 도메인 테스트는 전부 무의미해진다. * - *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 INSERT 문을 그대로 읽어 실행한다. + *

시드 내용을 테스트에 다시 적지 않고 시드 마이그레이션(V30)의 INSERT 문을 그대로 읽어 실행한다. * 같은 값을 두 곳에 적으면 한쪽만 고쳐질 때 테스트가 프로덕션 시드와 다른 것을 검증하게 된다. * *

{@code DatabaseCleaner} 가 테스트마다 모든 테이블을 비우므로 매 테스트 시작 시 다시 넣어야 한다. @@ -30,7 +30,7 @@ @Component public class MissionDefinitionSeeder { - private static final String MIGRATION_PATH = "db/migration/V29__create_mission_system.sql"; + private static final String MIGRATION_PATH = "db/migration/V30__seed_mission_definitions.sql"; @PersistenceContext private EntityManager entityManager; From 56e435012e957d735c081576da98a547d686b64b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:50:21 +0900 Subject: [PATCH 050/130] =?UTF-8?q?[Fix]=20=EA=B3=BC=EA=B1=B0=20=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EA=B8=B0=EB=B6=84=20=EA=B8=B0=EB=A1=9D=EA=B3=BC=20?= =?UTF-8?q?=EB=B0=9B=EA=B8=B0=20=EC=BF=BC=EB=A6=AC=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EA=B6=8C=20=EC=A1=B0=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기분 중복 판정은 요청 날짜 기준인데 진행도는 서버의 오늘 키로 들어가고 있었다. 과거 날짜 세 곳에 기분을 남기면 오늘 진행도가 3 올랐다. 요청 날짜가 오늘일 때만 올린다 - markClaimed 에 user_id 조건을 넣는다. 지금은 서비스의 소유권 검사가 앞에 있어 안전하지만, 이 메서드를 다른 곳에서 부르는 순간 검증이 통째로 빠진다. 불변식은 쿼리 자체에 박혀 있어야 한다 --- .../service/LearningCalendarService.java | 10 +++++++--- .../mission/repository/MissionProgressRepository.java | 7 ++++++- .../OnO/backend/mission/service/MissionService.java | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java index 8596f260..ed792823 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -10,6 +10,7 @@ import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarMoodRepository; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.service.MissionPeriodKey; import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; @@ -116,9 +117,12 @@ public LearningCalendarMoodResponseDto updateMood(Long userId, LearningCalendarM .orElseGet(() -> moodRepository.save(LearningCalendarMood.create(userId, request.date(), request.emojiKey()))); mood.updateEmojiKey(request.emojiKey()); - // 기분 미션은 "그 날짜에 처음 기분을 남겼을 때"만 오른다. 이모지를 바꿀 때마다 오르면 - // 버튼 한 번으로 주간 미션까지 채울 수 있다. - if (existingMood.isEmpty()) { + // 기분 미션은 "오늘 날짜에 처음 기분을 남겼을 때"만 오른다. + // + // 처음인지만 보고 올리면 이모지를 바꿀 때마다 올라 버튼 한 번으로 주간 미션까지 채울 수 있다. + // 오늘인지도 함께 봐야 한다. 중복 판정은 요청 날짜 기준인데 진행도는 서버의 오늘 키로 들어가므로, + // 지난 날짜 세 곳에 기분을 남기면 오늘 진행도가 3 오른다. + if (existingMood.isEmpty() && request.date().equals(MissionPeriodKey.today())) { missionProgressUpdater.increase(userId, MissionMetric.MOOD_LOGGED); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java index 47908092..bb706633 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -78,6 +78,10 @@ int markCompleted( *

조회로 "아직 안 받았다"를 확인하고 지급하면, 버튼을 두 번 빠르게 누른 두 요청이 * 모두 통과해 XP 가 두 번 들어간다. {@code claimed_at IS NULL} 을 UPDATE 조건에 넣으면 * 뒤에 온 요청은 0행을 갱신하고 거절된다. + * + *

{@code user_id} 조건은 호출부의 소유권 검사와 중복이지만 일부러 남겨 둔다. + * 검사를 서비스에만 두면 이 메서드를 다른 곳에서 부르는 순간 소유권 검증이 통째로 빠진다. + * "모든 데이터 접근은 userId 기준"이라는 불변식은 쿼리 자체에 박혀 있어야 한다. */ @Modifying(flushAutomatically = true) @Query(value = """ @@ -85,7 +89,8 @@ int markCompleted( SET claimed_at = NOW(6), updated_at = NOW(6) WHERE id = :progressId + AND user_id = :userId AND claimed_at IS NULL """, nativeQuery = true) - int markClaimed(@Param("progressId") Long progressId); + int markClaimed(@Param("progressId") Long progressId, @Param("userId") Long userId); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index 5c14e290..1fe67ff2 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -103,7 +103,7 @@ public MissionClaimResponseDto claim(Long userId, Long progressId) { // 진행도 행을 잠그기 전에 사용자 행을 먼저 잠근다. 잠금 순서를 기존 적립 경로와 맞춰 교착을 막는다. missionRewardGranter.lockUser(userId); - if (missionProgressRepository.markClaimed(progressId) == 0) { + if (missionProgressRepository.markClaimed(progressId, userId) == 0) { throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); } From 55558ff3001e1a412a12b810ffac58cbe467cb95 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:50:36 +0900 Subject: [PATCH 051/130] =?UTF-8?q?[Feat]=20=EA=B8=B0=EA=B0=84=EC=9D=B4=20?= =?UTF-8?q?=EC=A7=80=EB=82=9C=20=EB=AF=B8=EC=88=98=EB=A0=B9=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=EC=9D=84=20=EC=A1=B0=ED=9A=8C=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일요일 밤에 주간 미션을 끝내고 받지 않은 채 앱을 닫으면 월요일부터 목록에서 사라져 보상을 영영 받을 방법이 없었다. 복습 30회를 채운 사용자가 못 받는 것은 그대로 CS 다 - GET /api/missions 응답에 expired 묶음을 추가한다. 지난 기간에 완료했지만 안 받은 미션이 들어간다 - 미션 항목마다 periodKey 를 싣는다. expired 에는 여러 기간이 섞여 오므로 묶음 키 하나로는 어느 줄이 언제 것인지 표현할 수 없다. 묶음 자체의 periodKey 는 expired 에서 null 이다 - 조회 상한 30일. 상한이 없으면 오래 쓴 사용자일수록 몇 년치 미수령이 매 조회마다 딸려 온다 - 정의가 비활성화됐어도 이미 완료한 보상은 받을 수 있어야 하므로 active 조건을 걸지 않는다 --- .../mission/dto/MissionListResponseDto.java | 11 ++++- .../mission/dto/MissionResponseDto.java | 7 ++- .../repository/MissionProgressRepository.java | 23 +++++++++ .../mission/service/MissionPeriodKey.java | 11 +++++ .../mission/service/MissionService.java | 48 ++++++++++++++++++- 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java index 52f3f35e..b78da95a 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionListResponseDto.java @@ -4,12 +4,21 @@ /** * 미션 목록 응답. 일일과 주간을 각자의 기간 키와 함께 나눠 내려준다. + * + *

{@code expired} 는 지난 기간에 완료했지만 아직 받지 않은 보상이다. 이 묶음이 없으면 + * 일요일 밤에 주간 미션을 끝내고 받지 않은 채 앱을 닫은 사용자는 월요일부터 그 미션이 목록에서 사라져 + * 보상을 영영 받을 방법이 없다. 받기는 {@code progressId} 로 하므로 기간이 지나도 그대로 동작한다. */ public record MissionListResponseDto( MissionSectionDto daily, - MissionSectionDto weekly + MissionSectionDto weekly, + MissionSectionDto expired ) { + /** + * @param periodKey 그 묶음 전체의 기간 키. 여러 기간이 섞이는 {@code expired} 에서는 null 이고, + * 기간은 항목마다 실린 {@code periodKey} 로 확인한다. + */ public record MissionSectionDto( String periodKey, List missions diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java index c6c2e84a..64a6f7a6 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionResponseDto.java @@ -10,9 +10,13 @@ * *

{@code progressId} 는 아직 한 번도 손대지 않은 미션에서 null 이다. 받기는 이 값으로 하는데, * 완료된 미션에는 반드시 진행도 행이 있으므로 완료 상태에서 null 이 되는 경우는 없다. + * + *

{@code periodKey} 를 항목마다 싣는다. expired 묶음에는 서로 다른 기간의 미션이 함께 들어오므로 + * 묶음 하나에 기간 키 하나를 다는 것으로는 "언제 완료한 것인지"를 표현할 수 없다. */ public record MissionResponseDto( Long progressId, + String periodKey, String code, String title, String description, @@ -26,9 +30,10 @@ public record MissionResponseDto( int rewardValue ) { - public static MissionResponseDto from(MissionDefinition definition, MissionProgress progress) { + public static MissionResponseDto from(MissionDefinition definition, MissionProgress progress, String periodKey) { return new MissionResponseDto( progress == null ? null : progress.getId(), + periodKey, definition.getCode(), definition.getTitle(), definition.getDescription(), diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java index bb706633..2f2a9f7b 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -16,6 +17,28 @@ public interface MissionProgressRepository extends JpaRepository findByUserIdAndMissionIdAndPeriodKey(Long userId, Long missionId, String periodKey); + /** + * 지난 기간에 완료했지만 아직 받지 않은 진행도. + * + *

기간 키로만 조회하면 일요일 밤에 주간 미션을 끝내고 받지 않은 사용자는 월요일부터 + * 그 보상을 받을 방법이 없어진다. 복습 30회를 채운 사용자가 보상을 못 받는 것은 그대로 CS 다. + * + *

{@code completedAfter} 로 상한을 둔다. 상한이 없으면 몇 년치 미수령이 매 조회마다 딸려 온다. + */ + @Query(""" + SELECT p FROM MissionProgress p + WHERE p.userId = :userId + AND p.completedAt IS NOT NULL + AND p.claimedAt IS NULL + AND p.completedAt >= :completedAfter + AND p.periodKey NOT IN :currentPeriodKeys + """) + List findUnclaimedFromPastPeriods( + @Param("userId") Long userId, + @Param("currentPeriodKeys") Collection currentPeriodKeys, + @Param("completedAfter") LocalDateTime completedAfter + ); + /** * 진행도를 한 문장으로 만들거나 올린다. * diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java index 1d038bbb..9d860620 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionPeriodKey.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.mission.entity.MissionCategory; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.ZoneId; import java.time.temporal.IsoFields; @@ -27,6 +28,16 @@ public static LocalDate today() { return LocalDate.now(KST); } + /** + * 지금 시각(KST). + * + *

인자 없는 {@code LocalDateTime.now()} 는 JVM 기본 시간대를 쓴다. 서비스 기준은 KST 이고, + * 배포 환경의 시간대에 따라 기준이 흔들리면 안 된다. + */ + public static LocalDateTime now() { + return LocalDateTime.now(KST); + } + public static String of(MissionCategory category, LocalDate date) { return category == MissionCategory.WEEKLY ? weekly(date) : daily(date); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index 1fe67ff2..b5e5573d 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -17,9 +17,11 @@ import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 미션 조회와 보상 받기. @@ -30,6 +32,14 @@ @Transactional(readOnly = true) public class MissionService { + /** + * 지난 기간 미수령 보상을 얼마나 거슬러 보여줄지. + * + *

상한이 없으면 오래 쓴 사용자일수록 매 조회에 몇 년치 미수령이 딸려 온다. + * 한 달이면 "지난 주에 받는 걸 깜빡했다"는 실제 상황은 모두 덮는다. + */ + private static final int EXPIRED_LOOKBACK_DAYS = 30; + private final MissionDefinitionRepository missionDefinitionRepository; private final MissionProgressRepository missionProgressRepository; private final MissionRewardGranter missionRewardGranter; @@ -58,7 +68,7 @@ public MissionListResponseDto getMissions(Long userId) { boolean isWeekly = definition.getCategory() == MissionCategory.WEEKLY; String periodKey = isWeekly ? weeklyKey : dailyKey; MissionResponseDto dto = MissionResponseDto.from( - definition, progressByKey.get(cacheKey(definition.getId(), periodKey))); + definition, progressByKey.get(cacheKey(definition.getId(), periodKey)), periodKey); if (isWeekly) { weekly.add(dto); } else { @@ -68,8 +78,42 @@ public MissionListResponseDto getMissions(Long userId) { return new MissionListResponseDto( new MissionListResponseDto.MissionSectionDto(dailyKey, List.copyOf(daily)), - new MissionListResponseDto.MissionSectionDto(weeklyKey, List.copyOf(weekly)) + new MissionListResponseDto.MissionSectionDto(weeklyKey, List.copyOf(weekly)), + // 여러 기간이 섞이므로 묶음 단위의 기간 키가 없다. 기간은 항목마다 실려 간다. + new MissionListResponseDto.MissionSectionDto(null, expiredMissions(userId, dailyKey, weeklyKey)) + ); + } + + /** + * 지난 기간에 완료했지만 받지 않은 보상. + * + *

정의가 그 사이 비활성화됐어도 이미 완료한 보상은 받을 수 있어야 하므로 active 조건을 걸지 않는다. + * 정의 자체가 사라진 경우에만 건너뛴다. + */ + private List expiredMissions(Long userId, String dailyKey, String weeklyKey) { + List expired = missionProgressRepository.findUnclaimedFromPastPeriods( + userId, + List.of(dailyKey, weeklyKey), + MissionPeriodKey.now().minusDays(EXPIRED_LOOKBACK_DAYS) ); + if (expired.isEmpty()) { + return List.of(); + } + + Map definitionsById = missionDefinitionRepository + .findAllById(expired.stream().map(MissionProgress::getMissionId).distinct().toList()) + .stream() + .collect(Collectors.toMap(MissionDefinition::getId, definition -> definition)); + + return expired.stream() + .filter(progress -> definitionsById.containsKey(progress.getMissionId())) + // 최근에 놓친 것을 먼저 보여주고, 같은 기간 안에서는 목록과 같은 순서를 쓴다. + .sorted(Comparator + .comparing(MissionProgress::getPeriodKey, Comparator.reverseOrder()) + .thenComparing(progress -> definitionsById.get(progress.getMissionId()).getSortOrder())) + .map(progress -> MissionResponseDto.from( + definitionsById.get(progress.getMissionId()), progress, progress.getPeriodKey())) + .toList(); } /** From 2d3d24589403b2997ce3906d521248b788a9cda1 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Wed, 9 Sep 2026 16:52:59 +0900 Subject: [PATCH 052/130] =?UTF-8?q?[Test]=202=EC=B0=A8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=97=90=EC=84=9C=20=EB=82=98=EC=98=A8=20=EA=B2=B0?= =?UTF-8?q?=ED=95=A8=20=EC=97=AC=EC=84=AF=20=EA=B1=B4=EC=9D=84=20=EC=9E=A0?= =?UTF-8?q?=EA=B7=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 복습 세트를 세 번 끝내도 진행도는 1. 서로 다른 세트 세 개는 3 - 진행도는 본 작업이 커밋된 뒤에 반영된다. 커밋 전에는 아직 아무것도 쓰지 않는다. 리스너는 갱신이 실패해도 예외를 밖으로 내보내지 않는다 - 지난 주에 완료하고 안 받은 보상이 expired 로 내려오고, 기간이 지나도 받을 수 있다. 30일보다 오래된 것과 남의 미수령은 섞이지 않는다 - 미션마다 자기 기간 키가 실린다. 진행도가 없는 미션에도 현재 기간 키가 있고, expired 묶음 키는 null - DDL 파일에 시드가 섞여 있지 않고, 시드를 두 번 실행해도 정의는 10종 그대로다 - 과거 날짜에 기분을 남기면 오늘 미션은 오르지 않는다 - 남의 진행도에는 수령 도장이 찍히지 않는다 - 서비스가 아니라 쿼리에서 막는지 직접 확인한다 --- .../mission/MissionSeedMigrationTest.java | 90 +++++++++++++ .../controller/MissionControllerTest.java | 37 +++++- .../MissionProgressRepositoryTest.java | 59 +++++++++ .../MissionProgressEventListenerTest.java | 60 +++++++++ .../service/MissionProgressHookTest.java | 68 ++++++++-- .../service/MissionProgressUpdaterTest.java | 32 ++++- .../mission/service/MissionServiceTest.java | 123 ++++++++++++++++++ .../support/MissionSystemTestSupport.java | 34 +++++ 8 files changed, 492 insertions(+), 11 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java diff --git a/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java new file mode 100644 index 00000000..2d7aeb0f --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java @@ -0,0 +1,90 @@ +package com.aisip.OnO.backend.mission; + +import com.aisip.OnO.backend.mission.support.MissionDefinitionSeeder; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지. + * + *

MySQL DDL 은 트랜잭션이 아니다. 테이블은 만들어지고 뒤이은 INSERT 에서 끊기면 Flyway 는 실패로 + * 기록하는데 테이블은 남아, 재기동하면 "table already exists" 로 또 실패한다. + * {@code flyway repair} 없이는 앱이 뜨지 않고, blue-green 배포 구간이면 그대로 장애다. + */ +@DisplayName("미션 마이그레이션") +class MissionSeedMigrationTest extends MissionSystemTestSupport { + + private static final String DDL_MIGRATION = "db/migration/V29__create_mission_system.sql"; + private static final String SEED_MIGRATION = "db/migration/V30__seed_mission_definitions.sql"; + + @Autowired + private MissionDefinitionSeeder missionDefinitionSeeder; + + @Test + @DisplayName("DDL 파일에는 시드가 섞여 있지 않다") + void ddlMigrationHasNoSeed() { + assertThat(statementsOf(DDL_MIGRATION)) + .as("DDL 과 시드가 한 파일에 있으면 시드에서 끊길 때 테이블만 남아 재실행이 막힌다") + .doesNotContain("INSERT INTO"); + } + + @Test + @DisplayName("DDL 은 다시 돌려도 안전하게 쓰여 있다") + void ddlMigrationIsRerunnable() { + String ddl = statementsOf(DDL_MIGRATION); + + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS MISSION_DEFINITION"); + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS MISSION_PROGRESS"); + // MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없다. 인덱스를 따로 만들면 그 문장이 재실행 지점이 된다. + assertThat(ddl).doesNotContain("CREATE INDEX"); + } + + @Test + @DisplayName("시드는 다시 돌려도 안전하게 쓰여 있다") + void seedMigrationIsRerunnable() { + assertThat(statementsOf(SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + } + + @Test + @DisplayName("시드를 두 번 실행해도 정의는 10종 그대로다") + void seedIsIdempotent() { + // @BeforeEach 가 이미 한 번 넣었다. 같은 문장을 다시 돌리는 것이 이 테스트의 핵심이다. + missionDefinitionSeeder.seed(); + missionDefinitionSeeder.seed(); + + assertThat(missionDefinitionRepository.findAll()).hasSize(10); + assertThat(missionDefinitionRepository.findAllByActiveTrueOrderBySortOrderAscIdAsc()) + .extracting(definition -> definition.getCategory().name()) + .filteredOn("DAILY"::equals) + .hasSize(6); + } + + /** 주석을 걷어낸 실행 문장만. 주석에 적힌 설명이 단언에 걸리면 안 된다. */ + private String statementsOf(String path) { + return Arrays.stream(read(path).split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")) + .toUpperCase(Locale.ROOT); + } + + private String read(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("마이그레이션을 읽지 못했다: " + path, e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java index 45bf0801..f02bed5e 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java @@ -7,6 +7,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; + import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -48,7 +50,40 @@ void getMissions() throws Exception { .andExpect(jsonPath("$.data.daily.missions[1].claimed").value(false)) .andExpect(jsonPath("$.data.daily.missions[1].rewardType").value("XP")) .andExpect(jsonPath("$.data.daily.missions[1].rewardValue").value(10)) - .andExpect(jsonPath("$.data.daily.missions[1].progressId").isNumber()); + .andExpect(jsonPath("$.data.daily.missions[1].progressId").isNumber()) + .andExpect(jsonPath("$.data.daily.missions[1].periodKey").value(periodKeyOf(DAILY_NOTE_WRITE))) + .andExpect(jsonPath("$.data.weekly.missions[0].periodKey").value(periodKeyOf(WEEKLY_ATTEND_5))) + .andExpect(jsonPath("$.data.weekly.missions[0].progressId").doesNotExist()) + .andExpect(jsonPath("$.data.expired.periodKey").doesNotExist()) + .andExpect(jsonPath("$.data.expired.missions").isArray()) + .andExpect(jsonPath("$.data.expired.missions.length()").value(0)); + } + + @Test + @DisplayName("GET /api/missions - 기간이 지난 미수령 보상이 expired 로 내려온다") + void getMissionsIncludesExpired() throws Exception { + Long progressId = insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + + mockMvc.perform(get("/api/missions")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.expired.missions.length()").value(1)) + .andExpect(jsonPath("$.data.expired.missions[0].progressId").value(progressId)) + .andExpect(jsonPath("$.data.expired.missions[0].code").value(WEEKLY_REVIEW_30)) + .andExpect(jsonPath("$.data.expired.missions[0].periodKey").value(lastWeekKey())) + .andExpect(jsonPath("$.data.expired.missions[0].completed").value(true)) + .andExpect(jsonPath("$.data.expired.missions[0].claimed").value(false)); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 기간이 지난 미수령 보상도 받을 수 있다") + void claimExpired() throws Exception { + Long progressId = insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progressId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rewardValue").value(100)); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java b/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java new file mode 100644 index 00000000..f6622250 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java @@ -0,0 +1,59 @@ +package com.aisip.OnO.backend.mission.repository; + +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 소유권 검증은 서비스가 아니라 쿼리에 박혀 있어야 한다. + * + *

서비스에만 두면 이 메서드를 다른 곳에서 부르는 순간 검증이 통째로 빠진다. + * 여기서는 리포지토리를 직접 불러 그 방어가 살아 있는지만 본다. + */ +@DisplayName("미션 진행도 리포지토리 - 소유권") +class MissionProgressRepositoryTest extends MissionSystemTestSupport { + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Test + @DisplayName("남의 진행도는 수령 도장이 찍히지 않는다") + void markClaimedRejectsOtherUser() { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + User other = fixtures.createOtherUser(); + + Integer updated = claim(progress.getId(), other.getId()); + + assertThat(updated).isZero(); + assertThat(missionProgressRepository.findById(progress.getId()).orElseThrow().getClaimedAt()).isNull(); + } + + /** {@code @Modifying} 쿼리는 flush 를 하므로 트랜잭션 안에서 불러야 한다. */ + private Integer claim(Long progressId, Long userId) { + return transactionTemplate.execute( + status -> missionProgressRepository.markClaimed(progressId, userId)); + } + + @Test + @DisplayName("주인이면 한 번만 찍힌다") + void markClaimedStampsOnce() { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + + Integer first = claim(progress.getId(), user.getId()); + Integer second = claim(progress.getId(), user.getId()); + + assertThat(first).isEqualTo(1); + assertThat(second) + .as("이미 받았으면 갱신된 행이 없어야 두 번 지급을 막을 수 있다") + .isZero(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java new file mode 100644 index 00000000..4a72ff2d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java @@ -0,0 +1,60 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.event.MissionProgressEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.dao.CannotAcquireLockException; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +/** + * 진행도 갱신 실패가 본 작업 밖으로 새어 나가지 않는지. + * + *

리스너는 커밋 콜백에서 실행된다. 여기서 예외를 흘리면 데이터는 이미 저장됐는데 사용자에게는 + * 500 이 나가는 최악의 조합이 된다. 잠금 대기 초과나 교착 희생자는 실제로 일어나는 일이라 + * 그때 오답노트 저장이 성공으로 끝나는지를 고정한다. + */ +@DisplayName("미션 진행도 이벤트 리스너") +class MissionProgressEventListenerTest { + + @Test + @DisplayName("진행도 갱신이 실패해도 예외를 밖으로 내보내지 않는다") + void swallowsFailure() { + MissionProgressEventListener listener = new MissionProgressEventListener( + throwingApplier(new CannotAcquireLockException("교착 희생자로 선택됐다"))); + + assertThatCode(() -> listener.handleMissionProgress( + new MissionProgressEvent(1L, MissionMetric.PROBLEM_CREATED, 1))) + .doesNotThrowAnyException(); + } + + @Test + @DisplayName("정상이면 받은 값 그대로 넘긴다") + void delegatesEvent() { + AtomicInteger appliedAmount = new AtomicInteger(); + MissionProgressApplier applier = new MissionProgressApplier(null, null) { + @Override + public void apply(Long userId, MissionMetric metric, int amount) { + appliedAmount.set(amount); + } + }; + + new MissionProgressEventListener(applier).handleMissionProgress( + new MissionProgressEvent(1L, MissionMetric.PROBLEM_CREATED, 3)); + + assertThat(appliedAmount).hasValue(3); + } + + private MissionProgressApplier throwingApplier(RuntimeException failure) { + return new MissionProgressApplier(null, null) { + @Override + public void apply(Long userId, MissionMetric metric, int amount) { + throw failure; + } + }; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java index 8a561fe5..1f961cd9 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java @@ -159,14 +159,42 @@ class PracticeNoteCompleted { @Test @DisplayName("복습 세트를 끝내면 세트 미션이 오른다") void countsCompletion() { - PracticeNote practiceNote = practiceNoteRepository.save(PracticeNote.from( - new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + PracticeNote practiceNote = savePracticeNote(); practiceNoteService.addPracticeNoteCount(user.getId(), practiceNote.getId()); assertThat(currentOf(user.getId(), DAILY_PRACTICE_SET)).isEqualTo(1); assertThat(currentOf(user.getId(), WEEKLY_SET_3)).isEqualTo(1); } + + @Test + @DisplayName("같은 세트를 세 번 끝내도 한 번만 오른다") + void doesNotCountRepeatedCompletionOfSameNote() { + PracticeNote practiceNote = savePracticeNote(); + + practiceNoteService.addPracticeNoteCount(user.getId(), practiceNote.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), practiceNote.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), practiceNote.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_SET_3)) + .as("같은 세트를 반복 완료하는 것만으로 주간 세트 미션 80 XP 가 채워지면 안 된다") + .isEqualTo(1); + } + + @Test + @DisplayName("서로 다른 세트를 끝내면 각각 오른다") + void countsEachDistinctNote() { + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + + assertThat(currentOf(user.getId(), WEEKLY_SET_3)).isEqualTo(3); + } + + private PracticeNote savePracticeNote() { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + } } @Nested @@ -174,9 +202,10 @@ void countsCompletion() { class MoodLogged { @Test - @DisplayName("기분을 처음 남기면 기분 미션이 오른다") + @DisplayName("오늘 기분을 처음 남기면 기분 미션이 오른다") void countsFirstMood() { - LocalDate today = studyDay(); + LocalDate today = MissionPeriodKey.today(); + makeStudyDay(today); learningCalendarService.updateMood( user.getId(), new LearningCalendarMoodRequestDto(today, "cool_sunglasses")); @@ -187,7 +216,8 @@ void countsFirstMood() { @Test @DisplayName("같은 날 기분을 바꿔도 두 번 오르지 않는다") void doesNotCountMoodChange() { - LocalDate today = studyDay(); + LocalDate today = MissionPeriodKey.today(); + makeStudyDay(today); learningCalendarService.updateMood( user.getId(), new LearningCalendarMoodRequestDto(today, "cool_sunglasses")); @@ -199,14 +229,36 @@ void doesNotCountMoodChange() { .isEqualTo(1); } - /** 기분은 학습 기록이 있는 날에만 남길 수 있다. 오늘 작성한 오답노트로 그 조건을 만든다. */ - private LocalDate studyDay() { + @Test + @DisplayName("과거 날짜에 기분을 남기면 오늘 미션은 오르지 않는다") + void doesNotCountPastDates() { + LocalDate today = MissionPeriodKey.today(); + for (int daysAgo = 1; daysAgo <= 3; daysAgo++) { + LocalDate pastDate = today.minusDays(daysAgo); + makeStudyDay(pastDate); + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(pastDate, "cool_sunglasses")); + } + + assertThat(currentOf(user.getId(), DAILY_MOOD)) + .as("중복 판정은 요청 날짜 기준인데 진행도가 오늘 키로 들어가면 지난 날짜 수만큼 오늘이 오른다") + .isZero(); + } + + /** + * 기분은 학습 기록이 있는 날에만 남길 수 있다. 그 날짜에 작성한 오답노트로 조건을 만든다. + * + *

{@code created_at} 은 JPA Auditing 이 JVM 시간대로 채우는데 미션의 오늘은 KST 라, + * 두 시간대가 다른 환경에서는 하루가 어긋난다. 원하는 날짜를 직접 박아 그 흔들림을 없앤다. + */ + private void makeStudyDay(LocalDate date) { Problem problem = Problem.from( new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), user.getId()); problem.updateFolder(folder); problemRepository.saveAndFlush(problem); - return problemRepository.findById(problem.getId()).orElseThrow().getCreatedAt().toLocalDate(); + jdbcTemplate.update("UPDATE problem SET created_at = ? WHERE id = ?", + date.atTime(12, 0), problem.getId()); } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java index f1fafb59..cc93dd9a 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java @@ -144,8 +144,8 @@ class TransactionBoundary { @Test @DisplayName("행동이 롤백되면 진행도도 남지 않는다") void rollsBackWithTheAction() { - // 진행도를 별도 트랜잭션(REQUIRES_NEW)이나 이벤트 리스너로 떼어내면 이 단언이 깨진다. - // 하지도 않은 행동으로 보상을 받게 되므로 호출자의 트랜잭션 안에서 돌아야 한다. + // 커밋 후에 올리므로 롤백된 행동에는 이벤트 자체가 발행되지 않는다. + // 하지도 않은 행동으로 보상을 받는 일이 없어야 한다. assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); throw new IllegalStateException("행동이 실패했다"); @@ -153,6 +153,34 @@ void rollsBackWithTheAction() { assertThat(missionProgressRepository.findAll()).isEmpty(); } + + @Test + @DisplayName("진행도는 본 작업이 커밋된 뒤에 반영된다") + void appliesAfterCommit() { + transactionTemplate.executeWithoutResult(status -> { + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + Integer rows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mission_progress WHERE user_id = ?", Integer.class, user.getId()); + assertThat(rows) + .as("본 작업과 같은 트랜잭션에서 올리면 진행도 실패가 본 작업까지 되돌린다. " + + "커밋 전에는 아직 아무것도 쓰지 않아야 한다") + .isZero(); + }); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)) + .as("커밋된 뒤에는 반영돼야 한다") + .isEqualTo(1); + } + + @Test + @DisplayName("트랜잭션 없이 불려도 진행도는 반영된다") + void appliesWithoutOuterTransaction() { + // 운영 경로는 모두 트랜잭션 안이지만, 없을 때 조용히 사라지면 원인을 찾기 어렵다. + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); + } } @Nested diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java index 6da76571..f06dc8b9 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java @@ -18,6 +18,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -76,6 +77,31 @@ void sortsBySortOrder() { .containsExactly(WEEKLY_ATTEND_5, WEEKLY_NOTE_10, WEEKLY_REVIEW_30, WEEKLY_SET_3); } + @Test + @DisplayName("미션마다 자기 기간 키가 실린다") + void carriesPeriodKeyOnEveryMission() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.daily().missions()) + .extracting(MissionResponseDto::periodKey) + .containsOnly(MissionPeriodKey.daily(MissionPeriodKey.today())); + assertThat(response.weekly().missions()) + .extracting(MissionResponseDto::periodKey) + .containsOnly(MissionPeriodKey.weekly(MissionPeriodKey.today())); + } + + @Test + @DisplayName("진행도 행이 없는 미션에도 현재 기간 키가 실린다") + void carriesCurrentPeriodKeyWhenProgressIsMissing() { + MissionListResponseDto response = missionService.getMissions(user.getId()); + + MissionResponseDto untouched = findByCode(response.weekly().missions(), WEEKLY_NOTE_10); + assertThat(untouched.progressId()).isNull(); + assertThat(untouched.periodKey()) + .as("진행도가 없어도 어느 기간의 미션인지는 알 수 있어야 한다") + .isEqualTo(MissionPeriodKey.weekly(MissionPeriodKey.today())); + } + @Test @DisplayName("아직 손대지 않은 미션도 0 으로 내려간다") void showsUntouchedMissionsAsZero() { @@ -132,6 +158,103 @@ private MissionResponseDto findByCode(List missions, String } } + @Nested + @DisplayName("지난 기간 미수령 보상") + class ExpiredMissions { + + @Test + @DisplayName("지난 주에 완료하고 안 받은 보상이 expired 로 내려온다") + void showsUnclaimedFromPastPeriod() { + Long progressId = insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.expired().missions()) + .as("복습 30회를 채우고 못 받은 보상이 사라지면 그대로 CS 다") + .hasSize(1); + MissionResponseDto expired = response.expired().missions().get(0); + assertThat(expired.code()).isEqualTo(WEEKLY_REVIEW_30); + assertThat(expired.progressId()).isEqualTo(progressId); + assertThat(expired.completed()).isTrue(); + assertThat(expired.claimed()).isFalse(); + assertThat(expired.periodKey()) + .as("기간 키는 그 진행도의 원래 키를 그대로 준다") + .isEqualTo(lastWeekKey()); + } + + @Test + @DisplayName("expired 묶음에는 여러 기간이 섞이므로 묶음 기간 키가 없다") + void expiredSectionHasNoSinglePeriodKey() { + insertCompletedProgress(user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + insertCompletedProgress(user.getId(), DAILY_REVIEW_3, yesterdayKey(), LocalDateTime.now().minusDays(1)); + + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.expired().periodKey()).isNull(); + assertThat(response.expired().missions()) + .extracting(MissionResponseDto::periodKey) + .containsExactlyInAnyOrder(lastWeekKey(), yesterdayKey()); + } + + @Test + @DisplayName("기간이 지나도 받을 수 있다") + void canBeClaimed() { + Long progressId = insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progressId); + + assertThat(response.rewardValue()).isEqualTo(100); + assertThat(missionService.getMissions(user.getId()).expired().missions()) + .as("받고 나면 목록에서 빠진다") + .isEmpty(); + } + + @Test + @DisplayName("이번 기간의 완료 미수령은 expired 가 아니다") + void currentPeriodIsNotExpired() { + completeMission(user.getId(), DAILY_NOTE_WRITE); + + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.expired().missions()).isEmpty(); + } + + @Test + @DisplayName("완료하지 않은 지난 기간 진행도는 내려가지 않는다") + void ignoresIncompletePastProgress() { + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, NOW(6), NOW(6)) + """, + user.getId(), definitionOf(WEEKLY_REVIEW_30).getId(), lastWeekKey(), 12, 30); + + assertThat(missionService.getMissions(user.getId()).expired().missions()).isEmpty(); + } + + @Test + @DisplayName("조회 상한보다 오래된 것은 내려가지 않는다") + void ignoresTooOldProgress() { + insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, "2020-W10", LocalDateTime.now().minusDays(40)); + + assertThat(missionService.getMissions(user.getId()).expired().missions()) + .as("상한이 없으면 몇 년치 미수령이 매 조회마다 딸려 온다") + .isEmpty(); + } + + @Test + @DisplayName("남의 미수령 보상은 내 목록에 섞이지 않는다") + void doesNotLeakOtherUsers() { + User other = fixtures.createOtherUser(); + insertCompletedProgress(other.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + + assertThat(missionService.getMissions(user.getId()).expired().missions()).isEmpty(); + } + } + @Nested @DisplayName("보상 받기") class Claim { diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java index 642bcceb..98aa928f 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -12,6 +12,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; + /** * 미션 시스템(정의/진행도/보상) 테스트의 공통 베이스. * @@ -85,4 +87,36 @@ protected MissionProgress completeMission(Long userId, String code) { missionProgressUpdater.increase(userId, definition.getMetric(), definition.getTarget()); return progressOf(userId, code); } + + /** + * 지난 기간에 완료했지만 받지 않은 진행도를 직접 만든다. + * + *

기간이 지난 상황은 서비스로 만들 수 없다. 진행도는 언제나 오늘 키로만 쌓이기 때문이다. + * 어제·지난 주 행은 이렇게 박아 넣어야 재현된다. + */ + protected Long insertCompletedProgress(Long userId, String code, String periodKey, LocalDateTime completedAt) { + MissionDefinition definition = definitionOf(code); + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, completed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + userId, definition.getId(), periodKey, + definition.getTarget(), definition.getTarget(), + completedAt, completedAt, completedAt); + + return jdbcTemplate.queryForObject( + "SELECT id FROM mission_progress WHERE user_id = ? AND mission_id = ? AND period_key = ?", + Long.class, userId, definition.getId(), periodKey); + } + + /** 지난 주 주간 키. */ + protected String lastWeekKey() { + return MissionPeriodKey.weekly(MissionPeriodKey.today().minusWeeks(1)); + } + + /** 어제 일일 키. */ + protected String yesterdayKey() { + return MissionPeriodKey.daily(MissionPeriodKey.today().minusDays(1)); + } } From ccbbe5589500f5d3cfdfa5d1a8db3d2f95f95b77 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 15:26:51 +0900 Subject: [PATCH 053/130] =?UTF-8?q?[Refactor]=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A0=81=EB=A6=BD=EC=9D=84=20=EB=AF=B8=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=ED=9D=A1=EC=88=98=ED=95=9C=EB=8B=A4=20-=20XP=20?= =?UTF-8?q?=EB=8A=94=20=EB=B0=9B=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EC=98=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 행동할 때마다 자동으로 들어가던 경험치를 걷어낸다. 같은 XP 가 두 경로로 들어와, 미션 화면의 +10 XP 와 마이페이지 실제 증가량 20 이 어긋나 있었다 - 하루 200점 상한(DAILY_MISSION_POINT_LIMIT)과 addPointToUser, getMin 을 지운다. 상한이 누르던 자동 적립이 사라져 걸릴 대상이 없다. 상한 역할은 미션 설계가 대신한다 (일일 6종 합 75 XP, 주간 4종 합 360 XP). 이 상수를 쓰는 곳은 addPointToUser 뿐이었다 - MissionLog 저장, 중복 방지 판정, 미션 진행도 증가는 전부 그대로 둔다. DAU·순 방문자·복습 로그 등 관리자 지표 7곳이 이 테이블을 읽는다 - UserMissionStatus 는 건드리지 않는다. 기존 사용자의 레벨과 포인트는 그대로다 --- .../mission/service/MissionLogService.java | 49 +++++-------------- .../mission/service/MissionRewardGranter.java | 6 ++- 2 files changed, 17 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index db20a0e7..3cfa5ecd 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -31,6 +31,18 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 사용자의 행동 기록({@code mission_log})을 남긴다. + * + *

여기서 XP 를 주지 않는다. 예전에는 행동할 때마다 자동으로 경험치가 들어갔는데, + * 미션 보상과 합쳐져 같은 XP 가 두 경로로 들어왔다. 오답노트를 하나 쓰면 자동 적립 +10 이 조용히 들어가고 + * 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, 화면의 {@code +10 XP} 와 실제 증가량 20 이 어긋났다. + * 게다가 하루 200점 상한은 자동 적립에만 걸려 같은 이름의 XP 가 출처에 따라 다른 규칙으로 움직였다. + * XP 는 미션을 받을 때만 들어온다. 지급은 {@link MissionRewardGranter} 한 곳뿐이다. + * + *

대신 기록은 그대로 남긴다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. + * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 그대로다. + */ @Service @RequiredArgsConstructor @Transactional @@ -44,8 +56,6 @@ public class MissionLogService { private final MissionProgressUpdater missionProgressUpdater; - private static final Long DAILY_MISSION_POINT_LIMIT = 200L; - /** * 미션 적립 전에 사용자 행을 먼저 잠근다. * @@ -57,6 +67,7 @@ public class MissionLogService { * 실제로 같은 사용자가 미션을 동시에 8번 적립하면 * {@code Deadlock found when trying to get lock} 이 그대로 500 으로 나갔다. * 처음부터 배타 잠금을 잡으면 승격 자체가 없어 교착이 생기지 않는다. + * (자동 적립을 걷어내면서 사용자 행 UPDATE 가 없어져 이 승격 경로는 사라졌다. 기록으로 남겨 둔다.) * *

둘째, 중복 적립. "오늘 이미 했는가"를 확인한 뒤 적립하는 check-then-act 구조라, * 잠금이 없으면 동시 요청이 모두 "아직 안 했다"를 읽고 각자 적립한다. @@ -91,8 +102,6 @@ public Long registerMissionLog(@NotNull MissionRegisterDto missionRegisterDto) { if(!canNotRegister) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); - - addPointToUser(user, missionLog); } return 0L; @@ -112,8 +121,6 @@ public void registerLoginMission(Long userId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); - addPointToUser(user, missionLog); - // 출석 미션 진행도. 기존 적립 규칙은 그대로 두고, "오늘 첫 로그인" 판정만 그대로 빌려 쓴다. // 이 분기 밖에서 올리면 앱을 열 때마다 주간 출석 미션이 하루에 5까지 차 버린다. missionProgressUpdater.increase(userId, MissionMetric.LOGIN_DAY); @@ -133,8 +140,6 @@ public void registerProblemWriteMission(Long userId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); - - addPointToUser(user, missionLog); } } @@ -152,7 +157,6 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { for (int i = 0; i < toCreate; i++) { MissionLog log = MissionLog.from(dto, user); missionLogRepository.save(log); - addPointToUser(user, log); } } @@ -170,8 +174,6 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); - - addPointToUser(user, missionLog); } } @@ -190,37 +192,12 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); - addPointToUser(user, missionLog); - // 세트 완료 미션 진행도. 출석과 같은 이유로 기존 중복 방지 가드 안에 둔다. // 밖에서 올리면 같은 세트에 완료 요청을 세 번 보내는 것만으로 주간 세트 미션이 채워진다. missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); } } - private Long addPointToUser(User user, MissionLog missionLog) { - Long pointToday = missionLogRepository.getPointSumToday(user.getId()); - if(pointToday <= DAILY_MISSION_POINT_LIMIT) { - Long point = getMin(missionLog.getPoint(), DAILY_MISSION_POINT_LIMIT - pointToday); - - // 미션 타입에 따라 적절한 능력치에 경험치 적용 - switch(missionLog.getMissionType().getAbilityType()) { - case ATTENDANCE -> user.getUserMissionStatus().gainAttendancePoint(point); - case NOTE_WRITE -> user.getUserMissionStatus().gainNoteWritePoint(point); - case PROBLEM_PRACTICE -> user.getUserMissionStatus().gainProblemPracticePoint(point); - case NOTE_PRACTICE -> user.getUserMissionStatus().gainNotePracticePoint(point); - } - - return point; - } else { - return 0L; - } - } - - private Long getMin(Long p1, Long p2) { - return p1 > p2 ? p2 : p1; - } - @Transactional(readOnly = true) public List findAllByUserId(Long userId) { return missionLogRepository.findAllByUserId(userId); diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java index 761e8350..3f643069 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -14,8 +14,10 @@ /** * 미션 보상 지급. * - *

기존 {@code MissionLogService.addPointToUser} 를 타지 않는다. 그쪽은 하루 200점 상한을 적용하는 - * 기존 적립 경로이고, 미션 보상은 그 위에 얹는 보너스라 상한 밖이다. 기존 적립 규칙은 손대지 않는다. + *

XP 가 들어오는 유일한 경로다. 예전에는 행동할 때마다 {@code MissionLogService} 가 자동으로 + * 경험치를 주고 하루 200점 상한이 그것만 눌렀다. 같은 XP 가 두 경로로 들어오면서 화면의 숫자와 + * 실제 증가량이 어긋났고, 출처에 따라 상한 적용도 달랐다. 자동 적립을 걷어내 지급을 여기 하나로 모았다. + * 하루 상한은 미션 설계가 대신한다. 일일 6종을 다 받아도 75, 주간 4종을 다 받아도 360 이다. * *

능력치 경험치 반영은 엔티티를 읽어 고치는 방식이라 같은 사용자의 동시 지급에 취약하다. * 기존 적립 경로와 같은 이유로 사용자 행을 배타 잠금으로 먼저 잡아 직렬화한다. From 4f16812a64cd07ad70cae76adea3f848464e0d51 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 15:26:52 +0900 Subject: [PATCH 054/130] =?UTF-8?q?[Test]=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EB=A6=BD=20=ED=8F=90=EC=A7=80=20-=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=EC=9D=80=20=EB=82=A8=EA=B3=A0=20XP=20=EB=8A=94=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=EB=A7=8C=20=EC=98=A4=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 "행동하면 포인트가 오른다" 테스트를 "행동해도 안 오르고 기록은 남는다"로 고쳤다. 지우지 않고 새 동작으로 옮겼다. 하루 상한 테스트 묶음도 같은 시나리오를 그대로 쓴다 - MissionLogRetentionTest 를 새로 둔다. 이번 변경에서 제일 위험한 지점이라 한자리에 모았다 - 오답노트 등록·복습 기록·세트 완료·출석 네 경로 모두 mission_log 행이 남는지 - 관리자 지표 8종(DAU, 순 방문자, 일자별 방문, 활성 사용자, 복습 로그 건수·일자별, 사용자별 기록, 복습 기록 페이지)이 여전히 값을 돌려주는지 - 중복 방지 판정 네 개가 그대로 동작하는지 - 미션을 받아야만 XP 가 오르는지 --- .../MissionRewardConcurrencyTest.java | 10 +- .../service/MissionLogRetentionTest.java | 236 ++++++++++++++++++ .../service/MissionLogServiceTest.java | 112 +++++---- .../mission/service/MissionServiceTest.java | 13 +- 4 files changed, 306 insertions(+), 65 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java index b0bbdfb1..4aee9247 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java @@ -60,8 +60,8 @@ void loginMissionIsGrantedOnce() { .as("출석 기록은 하루 한 건") .isEqualTo(1); assertThat(attendancePoints()) - .as("출석 경험치도 한 번치만 들어와야 한다") - .isEqualTo(MissionType.USER_LOGIN.getPoint()); + .as("자동 적립을 걷어낸 뒤로 행동만으로는 경험치가 들어오지 않는다") + .isZero(); } @Test @@ -105,11 +105,11 @@ void problemWriteMissionStopsAtDailyCap() { assertThat(outcome.serverErrors()).isEmpty(); assertThat(countLogs(MissionType.PROBLEM_WRITE)) - .as("하루 상한 3건까지만 적립된다") + .as("하루 상한 3건까지만 기록된다") .isEqualTo(3); assertThat(noteWritePoints()) - .as("경험치도 3건치까지만") - .isEqualTo(3 * MissionType.PROBLEM_WRITE.getPoint()); + .as("경험치는 미션을 받을 때만 들어온다") + .isZero(); } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java new file mode 100644 index 00000000..8913bc25 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java @@ -0,0 +1,236 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 자동 적립을 걷어낸 뒤에도 행동 기록은 그대로 남는지. + * + *

이번 변경에서 제일 위험한 지점이다. 지급을 떼어내면서 {@code mission_log} 저장까지 같이 없애면 + * DAU·순 방문자·복습 로그가 전부 0 이 된다. 관리자 화면 7곳이 이 테이블 하나를 읽는다. + * 그래서 "행은 계속 만들어진다"와 "XP 는 받을 때만 들어온다"를 한자리에서 잠근다. + */ +@DisplayName("자동 적립 폐지 후 기록 보존") +class MissionLogRetentionTest extends MissionSystemTestSupport { + + @Autowired + private MissionLogService missionLogService; + + @Autowired + private ProblemService problemService; + + @Autowired + private ProblemSolveService problemSolveService; + + @Autowired + private PracticeNoteService practiceNoteService; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeNoteRepository practiceNoteRepository; + + private User user; + private Folder folder; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + folder = fixtures.createRootFolder(user.getId()); + } + + @Nested + @DisplayName("실제 행동 경로") + class RealActionPaths { + + @Test + @DisplayName("오답노트를 등록하면 기록이 남고 XP 는 오르지 않는다") + void problemRegistrationLeavesLog() { + problemService.registerProblem( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + + assertThat(logTypes()).contains(MissionType.PROBLEM_WRITE); + assertThat(totalPoint()).isZero(); + } + + @Test + @DisplayName("복습을 기록하면 기록이 남고 XP 는 오르지 않는다") + void problemSolveLeavesLog() { + Problem problem = saveProblem(); + + problemSolveService.createProblemSolve(new ProblemSolveRegisterDto( + problem.getId(), LocalDateTime.now(), AnswerStatus.CORRECT, "회고", null, 60, null), + user.getId()); + + assertThat(logTypes()).contains(MissionType.PROBLEM_PRACTICE); + assertThat(totalPoint()).isZero(); + } + + @Test + @DisplayName("복습 세트를 끝내면 기록이 남고 XP 는 오르지 않는다") + void practiceNoteCompletionLeavesLog() { + PracticeNote note = savePracticeNote(); + + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(logTypes()).contains(MissionType.NOTE_PRACTICE); + assertThat(totalPoint()).isZero(); + } + + @Test + @DisplayName("출석하면 기록이 남고 XP 는 오르지 않는다") + void loginLeavesLog() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(logTypes()).contains(MissionType.USER_LOGIN); + assertThat(totalPoint()).isZero(); + } + + @Test + @DisplayName("중복 방지 판정은 그대로 동작한다") + void duplicateGuardsStillWork() { + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerProblemPracticeMission(user.getId(), 100L); + missionLogService.registerProblemPracticeMission(user.getId(), 100L); + missionLogService.registerNotePracticeMission(user.getId(), 200L); + missionLogService.registerNotePracticeMission(user.getId(), 200L); + for (int i = 0; i < 5; i++) { + missionLogService.registerProblemWriteMission(user.getId()); + } + + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("출석 1 + 문제복습 1 + 노트복습 1 + 문제작성 3") + .hasSize(6); + } + } + + @Nested + @DisplayName("관리자 통계") + class AdminStatistics { + + @Test + @DisplayName("행동을 태우면 관리자 지표가 모두 값을 돌려준다") + void adminQueriesStillReturnValues() { + missionLogService.registerLoginMission(user.getId()); + PracticeNote note = savePracticeNote(); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + problemService.registerProblem( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + + LocalDate today = today(); + + assertThat(missionLogService.countUniqueVisitors(today, today)) + .as("순 방문자") + .isEqualTo(1L); + assertThat(missionLogService.getDailyVisitCount(today, today).get(today)) + .as("일자별 방문 수") + .isEqualTo(1L); + assertThat(missionLogService.getDailyActiveUsersCount(today, today).get(today)) + .as("DAU") + .isEqualTo(1L); + assertThat(missionLogService.getActiveUsersByDate(today)) + .as("활성 사용자 목록") + .isNotEmpty(); + assertThat(missionLogService.countNotePracticeLogs()) + .as("복습 로그 건수") + .isEqualTo(1L); + assertThat(missionLogService.getDailyNotePracticeLogsCount(today, today).get(today)) + .as("일자별 복습 로그 수") + .isEqualTo(1L); + assertThat(missionLogService.findAllByUserId(user.getId())) + .as("사용자별 기록") + .isNotEmpty(); + + Page practiceLogs = missionLogService.findAdminPracticeLogs(0, 20); + assertThat(practiceLogs.getTotalElements()).isEqualTo(1); + assertThat(practiceLogs.getContent()).singleElement().satisfies(dto -> + assertThat(dto.point()) + .as("point 컬럼은 계속 채운다. 관리자 화면이 이 값을 그대로 읽는다") + .isEqualTo(MissionType.NOTE_PRACTICE.getPoint())); + } + } + + @Nested + @DisplayName("XP 지급 경로") + class RewardPath { + + @Test + @DisplayName("미션을 받아야만 XP 가 오른다") + void onlyClaimGrantsPoint() { + problemService.registerProblem( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + assertThat(totalPoint()) + .as("오답노트를 써도 자동으로 들어오는 XP 는 없다") + .isZero(); + + MissionProgress progress = progressOf(user, DAILY_NOTE_WRITE); + assertThat(progress.getCompletedAt()).as("진행도는 그대로 오른다").isNotNull(); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(totalPoint()) + .as("미션 화면이 보여준 +10 이 실제 증가량과 같아야 한다") + .isEqualTo(10L); + } + } + + private List logTypes() { + return missionLogRepository.findAllByUserId(user.getId()).stream() + .map(MissionLog::getMissionType) + .toList(); + } + + private long totalPoint() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return status.getTotalStudyPoint() + + 5L * status.getTotalStudyLevel() * (status.getTotalStudyLevel() - 1) * 4; + } + + private Problem saveProblem() { + Problem problem = Problem.from( + new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()), + user.getId()); + problem.updateFolder(folder); + return problemRepository.save(problem); + } + + private PracticeNote savePracticeNote() { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java index edc42c5f..774859a8 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java @@ -69,27 +69,29 @@ private PracticeNote savePracticeNote(Long userId, String title) { class LoginMission { @Test - @DisplayName("첫 로그인이면 기록을 남기고 출석 경험치를 준다") - void grantsAttendancePointOnFirstLogin() { + @DisplayName("첫 로그인이면 기록을 남긴다 - 경험치는 주지 않는다") + void recordsFirstLoginWithoutGrantingPoint() { missionLogService.registerLoginMission(user.getId()); assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("DAU·순 방문자 집계가 이 행을 읽는다. 행이 사라지면 지표가 통째로 0 이 된다") .singleElement() .satisfies(log -> { assertThat(log.getMissionType()).isEqualTo(MissionType.USER_LOGIN); - assertThat(log.getPoint()).isEqualTo(15L); + assertThat(log.getPoint()) + .as("point 컬럼은 그대로 채운다. 집계와 관리자 화면이 읽는 값이다") + .isEqualTo(15L); }); assertThat(statusOf(user)).satisfies(status -> { assertThat(accumulatedPoints(status.getAttendanceLevel(), status.getAttendancePoint())) - .isEqualTo(15L); - assertThat(status.getTotalStudyPoint()) - .as("출석 경험치는 총 학습 포인트에도 합산된다") - .isEqualTo(15L); + .as("XP 는 미션을 받을 때만 들어온다") + .isZero(); + assertThat(status.getTotalStudyPoint()).isZero(); }); } @Test - @DisplayName("같은 날 다시 로그인해도 기록도 경험치도 늘지 않는다") + @DisplayName("같은 날 다시 로그인해도 기록이 늘지 않는다") void ignoresSecondLoginOfSameDay() { missionLogService.registerLoginMission(user.getId()); missionLogService.registerLoginMission(user.getId()); @@ -98,7 +100,7 @@ void ignoresSecondLoginOfSameDay() { assertThat(missionLogRepository.findAllByUserId(user.getId())) .as("하루에 여러 번 로그인해도 출석은 한 번이다") .hasSize(1); - assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); + assertThat(statusOf(user).getTotalStudyPoint()).isZero(); } @Test @@ -119,8 +121,9 @@ void isolatesUsers() { missionLogService.registerLoginMission(user.getId()); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); - assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); - assertThat(statusOf(other).getTotalStudyPoint()).isEqualTo(15L); + assertThat(missionLogRepository.findAllByUserId(other.getId())).hasSize(1); + assertThat(statusOf(user).getTotalStudyPoint()).isZero(); + assertThat(statusOf(other).getTotalStudyPoint()).isZero(); } @Test @@ -136,8 +139,8 @@ void rejectsUnknownUser() { class ProblemWriteMission { @Test - @DisplayName("하루 세 번까지 보상을 준다") - void grantsUpToThreeTimesPerDay() { + @DisplayName("하루 세 번까지 기록을 남긴다") + void recordsUpToThreeTimesPerDay() { for (int i = 0; i < 5; i++) { missionLogService.registerProblemWriteMission(user.getId()); } @@ -145,16 +148,16 @@ void grantsUpToThreeTimesPerDay() { assertThat(missionLogRepository.findAllByUserId(user.getId())) .as("네 번째부터는 기록도 남기지 않는다") .hasSize(3); - assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(30L); + assertThat(statusOf(user).getTotalStudyPoint()).isZero(); } @Test - @DisplayName("한 번에 여러 문제를 등록해도 하루 상한인 3건까지만 보상한다") + @DisplayName("한 번에 여러 문제를 등록해도 하루 상한인 3건까지만 기록한다") void batchStopsAtDailyLimit() { missionLogService.registerProblemWriteMissionBatch(user.getId(), 10); assertThat(missionLogRepository.countProblemWritesToday(user.getId())).isEqualTo(3); - assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(30L); + assertThat(statusOf(user).getTotalStudyPoint()).isZero(); } @ParameterizedTest(name = "이미 {0}건 작성한 상태에서 {1}건 일괄 등록 → 총 {2}건") @@ -211,38 +214,38 @@ void rejectsUnknownUser() { class PracticeMission { @Test - @DisplayName("같은 문제는 하루에 한 번만 보상한다") - void grantsProblemPracticeOncePerDay() { + @DisplayName("같은 문제는 하루에 한 번만 기록한다") + void recordsProblemPracticeOncePerDay() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); missionLogService.registerProblemPracticeMission(user.getId(), 100L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); assertThat(statusOf(user)).satisfies(status -> { assertThat(accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint())) - .isEqualTo(5L); - assertThat(status.getTotalStudyPoint()).isEqualTo(5L); + .isZero(); + assertThat(status.getTotalStudyPoint()).isZero(); }); } @Test - @DisplayName("다른 문제를 복습하면 각각 보상한다") - void grantsPerProblem() { + @DisplayName("다른 문제를 복습하면 각각 기록한다") + void recordsPerProblem() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); missionLogService.registerProblemPracticeMission(user.getId(), 101L); missionLogService.registerProblemPracticeMission(user.getId(), 102L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(3); - assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); + assertThat(statusOf(user).getTotalStudyPoint()).isZero(); } @Test - @DisplayName("복습노트도 하루에 한 번만 보상한다") - void grantsNotePracticeOncePerDay() { + @DisplayName("복습노트도 하루에 한 번만 기록한다") + void recordsNotePracticeOncePerDay() { missionLogService.registerNotePracticeMission(user.getId(), 200L); missionLogService.registerNotePracticeMission(user.getId(), 200L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); - assertThat(accumulatedNotePracticePoints(user)).isEqualTo(15L); + assertThat(accumulatedNotePracticePoints(user)).isZero(); } @Test @@ -253,13 +256,13 @@ void grantsAgainOnNewDay() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); assertThat(missionLogRepository.findAllByUserId(user.getId())) - .as("어제 기록 1건 + 오늘 새로 받은 1건") + .as("어제 기록 1건 + 오늘 새로 남긴 1건") .hasSize(2); assertThat(accumulatedPoints( statusOf(user).getProblemPracticeLevel(), statusOf(user).getProblemPracticePoint())) - .as("어제 기록은 경험치를 다시 주지 않는다") - .isEqualTo(5L); + .as("기록을 남겨도 경험치는 들어오지 않는다") + .isZero(); } @Test @@ -332,66 +335,65 @@ void rejectsUnknownUser() { } } + /** + * 예전에는 이 자리에 "하루 200점 상한" 테스트가 있었다. 상한은 자동 적립을 막기 위한 장치였는데 + * 자동 적립 자체가 없어져 막을 대상이 사라졌다. 같은 시나리오를 새 동작으로 옮겨 둔다. + * 지키려던 것("하루 만에 만렙이 되지 않는다")은 미션 설계가 대신한다. 일일 6종을 다 받아도 75 XP 다. + */ @Nested - @DisplayName("하루 포인트 상한") - class DailyPointLimit { + @DisplayName("자동 적립 폐지") + class NoAutomaticPointGrant { @Test - @DisplayName("하루에 받을 수 있는 경험치는 200점까지다") - void capsDailyGainAtTwoHundred() { + @DisplayName("몇 번을 해도 경험치는 오르지 않는다") + void neverGrantsPointNoMatterHowManyTimes() { for (int i = 1; i <= 13; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } assertThat(accumulatedNotePracticePoints(user)) - .as("15점짜리 미션을 13번 하면 12번은 15점씩, 마지막은 남은 5점만 받아 185점이다") - .isEqualTo(185L); + .as("행동만으로 경험치가 들어오면 미션 화면의 숫자와 실제 증가량이 어긋난다") + .isZero(); } @Test - @DisplayName("상한을 넘긴 뒤의 미션은 기록만 남고 경험치는 0이다") - void grantsNothingBeyondLimit() { - for (int i = 1; i <= 13; i++) { + @DisplayName("경험치를 주지 않아도 수행 기록은 전부 남는다") + void keepsEveryLogWithoutPoint() { + for (int i = 1; i <= 14; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } - long beforeExtra = accumulatedNotePracticePoints(user); - - missionLogService.registerNotePracticeMission(user.getId(), 14L); assertThat(missionLogRepository.findAllByUserId(user.getId())) - .as("보상이 0이어도 수행 기록 자체는 남아야 한다") + .as("관리자 통계가 이 행을 읽는다. 지급을 걷어내면서 기록까지 없애면 지표가 죽는다") .hasSize(14); - assertThat(accumulatedNotePracticePoints(user)) - .as("상한을 넘겨 경험치가 계속 들어오면 하루 만에 만렙이 된다") - .isEqualTo(beforeExtra); + assertThat(accumulatedNotePracticePoints(user)).isZero(); } @Test - @DisplayName("상한은 사용자마다 따로 적용된다") - void appliesLimitPerUser() { + @DisplayName("사용자마다 기록은 따로 쌓이고 경험치는 둘 다 0이다") + void recordsPerUser() { for (int i = 1; i <= 14; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } missionLogService.registerNotePracticeMission(other.getId(), 100L); - assertThat(accumulatedNotePracticePoints(other)) - .as("남이 상한을 채웠다고 내 보상이 막히면 안 된다") - .isEqualTo(15L); + assertThat(missionLogRepository.findAllByUserId(other.getId())).hasSize(1); + assertThat(accumulatedNotePracticePoints(other)).isZero(); + assertThat(accumulatedNotePracticePoints(user)).isZero(); } @Test - @DisplayName("어제 채운 상한은 오늘 다시 열린다") - void resetsLimitNextDay() { + @DisplayName("어제 기록이 많아도 오늘 기록은 그대로 남는다") + void stillRecordsNextDay() { for (int i = 1; i <= 14; i++) { saveMissionLogAt(user, MissionType.NOTE_PRACTICE, (long) i, today().minusDays(1).atTime(12, 0)); } missionLogService.registerNotePracticeMission(user.getId(), 999L); - assertThat(accumulatedNotePracticePoints(user)) - .as("어제 소진한 상한이 오늘까지 이어지면 보상이 영영 막힌다") - .isEqualTo(15L); + assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(15); + assertThat(accumulatedNotePracticePoints(user)).isZero(); } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java index f06dc8b9..834c6743 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java @@ -340,18 +340,21 @@ void rejectsOtherUsersProgress() { } @Test - @DisplayName("하루 200점 상한을 채운 뒤에도 미션 보상은 들어온다") - void ignoresDailyPointCap() { - // 상한은 기존 적립(MissionLog) 경로에만 있는 규칙이다. 미션 보상은 그 위에 얹는 보너스라 상한 밖이다. + @DisplayName("행동 기록이 아무리 쌓여 있어도 XP 는 받을 때만 들어온다") + void grantsOnlyOnClaim() { + // 예전에는 mission_log 가 쌓일 때마다 자동으로 XP 가 들어가고 하루 200점 상한이 그것만 눌렀다. + // 이제 지급 경로는 받기 하나뿐이라, 기록이 얼마나 있든 받기 전에는 0 이다. saveMissionLog(user, MissionType.PROBLEM_WRITE, null); jdbcTemplate.update("UPDATE mission_log SET point = 300 WHERE user_id = ?", user.getId()); MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); - long before = problemPracticePoints(); + assertThat(problemPracticePoints()) + .as("완료만 해서는 오르지 않는다") + .isZero(); missionService.claim(user.getId(), progress.getId()); - assertThat(problemPracticePoints() - before).isEqualTo(15); + assertThat(problemPracticePoints()).isEqualTo(15); } @Test From 2a2b2ebd41428a76b9828d781e32633dc71e455f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 15:49:35 +0900 Subject: [PATCH 055/130] =?UTF-8?q?[Feat]=20=EB=AF=B8=EC=85=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=ED=9A=8D=EB=93=9D=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/missions/history. 새 테이블 없이 mission_progress.claimed_at 을 그대로 읽는다 - 커서는 진행도 id 다. 다만 정렬이 claimed_at 이라 id < :cursor 로는 자를 수 없다. 나중에 만들어진 행이 먼저 받아질 수 있어 id 순서와 받은 순서가 다르기 때문이다. 커서 행의 받은 시각을 함께 넘겨 (claimed_at, id) 두 값으로 자른다. 시각이 같아도 id 가 갈라 준다 - 커서 행은 findByIdAndUserId 로 찾는다. 남의 id 를 커서로 넘겨도 잡히지 않는다 - 합계(totalClaimedXp, totalClaimedCount)는 첫 페이지에만 싣는다. 페이지마다 전체를 세지 않는다 - content/nextCursor/hasNext/size 는 CursorPageResponse 와 이름도 의미도 같다. size 기본 20, 상한 50 - 비활성 정의의 미션도 기록에는 나온다. 이미 받은 건 받은 것이다 - V31 로 (user_id, claimed_at) 인덱스를 얹는다. 정렬 키를 뒤에 둬 역방향 읽기로 정렬을 생략시키고, InnoDB 가 보조 인덱스 끝에 붙이는 기본키가 동점 순서까지 갈라 준다 --- .../mission/controller/MissionController.java | 14 +++++ .../dto/MissionClaimHistoryItemDto.java | 44 +++++++++++++ .../dto/MissionClaimHistoryResponseDto.java | 37 +++++++++++ .../mission/entity/MissionProgress.java | 6 +- .../repository/MissionProgressRepository.java | 45 ++++++++++++++ .../mission/service/MissionService.java | 61 +++++++++++++++++++ ...31__add_mission_progress_claimed_index.sql | 13 ++++ 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java create mode 100644 src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql diff --git a/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java index 77ed1a68..481908c3 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java +++ b/src/main/java/com/aisip/OnO/backend/mission/controller/MissionController.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.mission.controller; import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; import com.aisip.OnO.backend.mission.service.MissionService; @@ -10,6 +11,7 @@ import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -25,6 +27,18 @@ public CommonResponse getMissions() { return CommonResponse.success(missionService.getMissions(userId)); } + /** + * 지금까지 받은 보상 기록. 합계는 첫 페이지(커서 없음)에만 실린다. + */ + @GetMapping("/history") + public CommonResponse getClaimHistory( + @RequestParam(value = "cursor", required = false) Long cursor, + @RequestParam(value = "size", defaultValue = "20") int size + ) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(missionService.getClaimHistory(userId, cursor, size)); + } + @PostMapping("/{progressId}/claim") public CommonResponse claim(@PathVariable Long progressId) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java new file mode 100644 index 00000000..6078d8c6 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.mission.dto; + +import com.aisip.OnO.backend.mission.entity.MissionCategory; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionRewardType; + +import java.time.LocalDateTime; + +/** + * 보상을 받은 기록 한 건. + * + *

새 테이블을 두지 않는다. {@code mission_progress.claimed_at} 이 이미 그 기록이다. + * + *

보상 종류와 값은 현재 미션 정의에서 읽는다. 진행도 행에 보상을 박아 두지 않기 때문에, + * 운영 중에 보상을 바꾸면 예전에 받은 기록도 새 값으로 보인다. + * 받은 시점의 값을 보존하려면 {@code target_snapshot} 처럼 보상 스냅샷 컬럼이 필요하다. + */ +public record MissionClaimHistoryItemDto( + Long progressId, + String code, + String title, + String iconKey, + MissionCategory category, + String periodKey, + MissionRewardType rewardType, + int rewardValue, + LocalDateTime claimedAt +) { + + public static MissionClaimHistoryItemDto from(MissionDefinition definition, MissionProgress progress) { + return new MissionClaimHistoryItemDto( + progress.getId(), + definition.getCode(), + definition.getTitle(), + definition.getIconKey(), + definition.getCategory(), + progress.getPeriodKey(), + definition.getRewardType(), + definition.getRewardValue(), + progress.getClaimedAt() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java new file mode 100644 index 00000000..aa42bd23 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryResponseDto.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.mission.dto; + +import java.util.List; + +/** + * 보상 획득 기록 페이지. + * + *

{@code content}/{@code nextCursor}/{@code hasNext}/{@code size} 는 + * {@code CursorPageResponse} 와 이름과 의미가 같다. 프론트가 같은 방식으로 파싱한다. + * 합계 두 개를 더 실어야 해서 레코드를 따로 뒀을 뿐이다. + * + *

합계는 첫 페이지에만 채운다. 페이지를 넘길 때마다 전체를 다시 세는 것은 낭비이고, + * 화면 맨 위의 "지금까지 받은 XP" 는 첫 페이지 값만 쓴다. 이후 페이지에서는 두 값이 null 이다. + */ +public record MissionClaimHistoryResponseDto( + List content, + Long nextCursor, + boolean hasNext, + int size, + Long totalClaimedXp, + Long totalClaimedCount +) { + + public static MissionClaimHistoryResponseDto firstPage( + List content, Long nextCursor, boolean hasNext, int size, + long totalClaimedXp, long totalClaimedCount + ) { + return new MissionClaimHistoryResponseDto( + content, nextCursor, hasNext, size, totalClaimedXp, totalClaimedCount); + } + + public static MissionClaimHistoryResponseDto nextPage( + List content, Long nextCursor, boolean hasNext, int size + ) { + return new MissionClaimHistoryResponseDto(content, nextCursor, hasNext, size, null, null); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java index 682b4c41..dc4aba81 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java @@ -35,7 +35,11 @@ uniqueConstraints = @UniqueConstraint( name = "uk_mission_progress", columnNames = {"user_id", "mission_id", "period_key"}), - indexes = @Index(name = "idx_mission_progress_lookup", columnList = "user_id, period_key")) + indexes = { + @Index(name = "idx_mission_progress_lookup", columnList = "user_id, period_key"), + // 보상 획득 기록은 받은 시각 역순으로만 읽는다. 정렬 키를 뒤에 둬 정렬을 생략시킨다. + @Index(name = "idx_mission_progress_claimed", columnList = "user_id, claimed_at") + }) public class MissionProgress extends BaseEntity { @Id diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java index 2f2a9f7b..df256a14 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.mission.repository; import com.aisip.OnO.backend.mission.entity.MissionProgress; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -39,6 +40,50 @@ List findUnclaimedFromPastPeriods( @Param("completedAfter") LocalDateTime completedAfter ); + /** + * 보상을 받은 기록. 받은 시각 내림차순이고, 커서보다 뒤엣것만 준다. + * + *

커서는 진행도 id 하나뿐인데 정렬은 {@code claimed_at} 이라 {@code id < :cursor} 로는 안 된다. + * 나중에 만들어진 행이 먼저 받아질 수 있어 id 순서와 받은 순서가 다르기 때문이다. + * 그래서 커서 행의 받은 시각을 함께 넘겨 (받은 시각, id) 두 값으로 자른다. + * 시각이 같은 행이 있어도 id 가 갈라 주므로 경계에서 빠지거나 겹치지 않는다. + */ + @Query(""" + SELECT p FROM MissionProgress p + WHERE p.userId = :userId + AND p.claimedAt IS NOT NULL + AND (:cursorClaimedAt IS NULL + OR p.claimedAt < :cursorClaimedAt + OR (p.claimedAt = :cursorClaimedAt AND p.id < :cursorId)) + ORDER BY p.claimedAt DESC, p.id DESC + """) + List findClaimedPage( + @Param("userId") Long userId, + @Param("cursorClaimedAt") LocalDateTime cursorClaimedAt, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + /** 커서 행. 남의 id 를 커서로 넘겨도 자기 것만 잡히게 소유권을 조건에 건다. */ + Optional findByIdAndUserId(Long id, Long userId); + + long countByUserIdAndClaimedAtIsNotNull(Long userId); + + /** + * 지금까지 받은 XP 합계. + * + *

보상 값은 미션 정의에 있으므로 두 테이블을 함께 본다. 연관관계를 걸지 않아 식별자로 잇는다. + */ + @Query(""" + SELECT COALESCE(SUM(d.rewardValue), 0) + FROM MissionProgress p, MissionDefinition d + WHERE d.id = p.missionId + AND p.userId = :userId + AND p.claimedAt IS NOT NULL + AND d.rewardType = com.aisip.OnO.backend.mission.entity.MissionRewardType.XP + """) + long sumClaimedXp(@Param("userId") Long userId); + /** * 진행도를 한 문장으로 만들거나 올린다. * diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index b5e5573d..ae36b562 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.mission.service; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryItemDto; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; import com.aisip.OnO.backend.mission.dto.MissionListResponseDto; import com.aisip.OnO.backend.mission.dto.MissionResponseDto; @@ -12,10 +14,12 @@ import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -40,6 +44,9 @@ public class MissionService { */ private static final int EXPIRED_LOOKBACK_DAYS = 30; + /** 기록 조회 한 페이지 최대 건수. 요청이 아무리 커도 여기서 자른다. */ + private static final int MAX_HISTORY_SIZE = 50; + private final MissionDefinitionRepository missionDefinitionRepository; private final MissionProgressRepository missionProgressRepository; private final MissionRewardGranter missionRewardGranter; @@ -116,6 +123,60 @@ private List expiredMissions(Long userId, String dailyKey, S .toList(); } + /** + * 보상 획득 기록. 받은 시각 역순, 커서 페이지네이션. + * + *

새 테이블을 두지 않는다. {@code claimed_at} 이 붙은 진행도 행이 곧 기록이다. + * + *

정의가 비활성화된 미션도 그대로 내려간다. 이미 받은 건 받은 것이다. + * 정의 자체가 사라진 경우에만 건너뛴다. + * + * @param cursor 앞 페이지 마지막 항목의 {@code progressId}. 첫 페이지에서는 null. + */ + public MissionClaimHistoryResponseDto getClaimHistory(Long userId, Long cursor, int size) { + int safeSize = Math.min(Math.max(size, 1), MAX_HISTORY_SIZE); + + LocalDateTime cursorClaimedAt = null; + if (cursor != null) { + // 남의 id 를 커서로 넘기면 여기서 걸린다. 못 찾은 커서는 "끝난 목록"으로 본다. + MissionProgress cursorProgress = missionProgressRepository.findByIdAndUserId(cursor, userId).orElse(null); + if (cursorProgress == null || cursorProgress.getClaimedAt() == null) { + return MissionClaimHistoryResponseDto.nextPage(List.of(), null, false, safeSize); + } + cursorClaimedAt = cursorProgress.getClaimedAt(); + } + + // 다음 페이지가 있는지 알려면 한 건 더 읽어 보는 수밖에 없다. 전체를 세는 것보다 싸다. + List claimed = missionProgressRepository.findClaimedPage( + userId, cursorClaimedAt, cursor, PageRequest.of(0, safeSize + 1)); + boolean hasNext = claimed.size() > safeSize; + List pageContent = hasNext ? claimed.subList(0, safeSize) : claimed; + + Map definitionsById = missionDefinitionRepository + .findAllById(pageContent.stream().map(MissionProgress::getMissionId).distinct().toList()) + .stream() + .collect(Collectors.toMap(MissionDefinition::getId, definition -> definition)); + + List content = pageContent.stream() + .filter(progress -> definitionsById.containsKey(progress.getMissionId())) + .map(progress -> MissionClaimHistoryItemDto.from( + definitionsById.get(progress.getMissionId()), progress)) + .toList(); + + Long nextCursor = hasNext && !pageContent.isEmpty() + ? pageContent.get(pageContent.size() - 1).getId() + : null; + + if (cursor != null) { + return MissionClaimHistoryResponseDto.nextPage(content, nextCursor, hasNext, safeSize); + } + return MissionClaimHistoryResponseDto.firstPage( + content, nextCursor, hasNext, safeSize, + missionProgressRepository.sumClaimedXp(userId), + missionProgressRepository.countByUserIdAndClaimedAtIsNotNull(userId) + ); + } + /** * 보상 받기. * diff --git a/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql new file mode 100644 index 00000000..eb6909c9 --- /dev/null +++ b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql @@ -0,0 +1,13 @@ +-- 미션 보상 획득 기록 조회(GET /api/missions/history)용 인덱스. +-- +-- 조회 형태는 "내 것 중 받은 것만, 받은 시각 역순" 한 가지다. +-- WHERE user_id = ? AND claimed_at IS NOT NULL ORDER BY claimed_at DESC +-- 기존 인덱스는 (user_id, period_key) 뿐이라 이 조회를 타지 못하고 사용자의 전체 진행도를 훑는다. +-- +-- 정렬 키를 선행 컬럼 뒤에 두면 MySQL 이 인덱스를 역방향으로 읽어 정렬 자체를 생략한다. +-- claimed_at 이 같은 행끼리의 순서는 InnoDB 가 보조 인덱스 끝에 붙이는 기본키(id)가 갈라 주므로 +-- 커서 페이지네이션의 동점 처리까지 이 인덱스 하나로 해결된다. +-- +-- 문장이 하나라 중간에 끊길 지점이 없다. 실패하면 인덱스가 만들어지지 않은 상태 그대로다. +CREATE INDEX idx_mission_progress_claimed + ON mission_progress (user_id, claimed_at); From 85e3ae3a06917ad8123a0c40f0419c380e5d6fa7 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 15:49:35 +0900 Subject: [PATCH 056/130] =?UTF-8?q?[Test]=20=EB=AF=B8=EC=85=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=ED=9A=8D=EB=93=9D=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받은 것만 나오고 안 받은 것은 안 나온다, claimed_at 내림차순, 실제 받기가 그대로 기록된다 - 커서 페이지를 끝까지 이어 붙여 빠지거나 겹치는 것이 없는지 확인한다. 받은 시각이 모두 같은 경우도 따로 잠근다. 여기가 이 API 에서 제일 틀리기 쉬운 곳이다 - 남의 기록이 안 나오고, 남의 진행도를 커서로 넘겨도 새지 않는다 - 합계는 첫 페이지에만, 안 받은 미션은 합계에서 빠진다 - 비활성 정의의 미션도 기록에는 남는다, 받은 게 없으면 빈 목록과 합계 0 --- .../controller/MissionControllerTest.java | 59 ++++ .../service/MissionClaimHistoryTest.java | 272 ++++++++++++++++++ .../support/MissionSystemTestSupport.java | 27 ++ 3 files changed, 358 insertions(+) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java diff --git a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java index f02bed5e..a3ecada4 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java @@ -109,6 +109,65 @@ void claim() throws Exception { .andExpect(jsonPath("$.data.leveledUp").isBoolean()); } + @Test + @DisplayName("GET /api/missions/history - 커서 페이지 규약을 그대로 따른다") + void getClaimHistory() throws Exception { + Long progressId = insertClaimedProgress( + user.getId(), WEEKLY_NOTE_10, "2020-W36", LocalDateTime.of(2020, 9, 1, 10, 0)); + + mockMvc.perform(get("/api/missions/history")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.nextCursor").doesNotExist()) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.size").value(20)) + .andExpect(jsonPath("$.data.totalClaimedXp").value(80)) + .andExpect(jsonPath("$.data.totalClaimedCount").value(1)) + .andExpect(jsonPath("$.data.content[0].progressId").value(progressId)) + .andExpect(jsonPath("$.data.content[0].code").value(WEEKLY_NOTE_10)) + .andExpect(jsonPath("$.data.content[0].title").value("열 권의 노트")) + .andExpect(jsonPath("$.data.content[0].iconKey").value("note_write")) + .andExpect(jsonPath("$.data.content[0].category").value("WEEKLY")) + .andExpect(jsonPath("$.data.content[0].periodKey").value("2020-W36")) + .andExpect(jsonPath("$.data.content[0].rewardType").value("XP")) + .andExpect(jsonPath("$.data.content[0].rewardValue").value(80)) + .andExpect(jsonPath("$.data.content[0].claimedAt").value("2020-09-01T10:00:00")); + } + + @Test + @DisplayName("GET /api/missions/history - 두 번째 페이지에는 합계가 없다") + void getClaimHistorySecondPage() throws Exception { + Long first = insertClaimedProgress( + user.getId(), WEEKLY_NOTE_10, "2020-W36", LocalDateTime.of(2020, 9, 2, 10, 0)); + insertClaimedProgress( + user.getId(), DAILY_MOOD, "2020-09-01", LocalDateTime.of(2020, 9, 1, 10, 0)); + + mockMvc.perform(get("/api/missions/history").param("size", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.hasNext").value(true)) + .andExpect(jsonPath("$.data.nextCursor").value(first)) + .andExpect(jsonPath("$.data.totalClaimedCount").value(2)); + + mockMvc.perform(get("/api/missions/history") + .param("size", "1") + .param("cursor", String.valueOf(first))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].code").value(DAILY_MOOD)) + .andExpect(jsonPath("$.data.hasNext").value(false)) + .andExpect(jsonPath("$.data.totalClaimedXp").doesNotExist()) + .andExpect(jsonPath("$.data.totalClaimedCount").doesNotExist()); + } + + @Test + @DisplayName("GET /api/missions/history - 인증이 없으면 401") + void getClaimHistoryRequiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(get("/api/missions/history")) + .andExpect(status().isUnauthorized()); + } + @Test @DisplayName("POST /api/missions/{progressId}/claim - 완료하지 않았으면 7011") void claimRejectsIncomplete() throws Exception { diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java new file mode 100644 index 00000000..c2e55076 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java @@ -0,0 +1,272 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryItemDto; +import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 보상 획득 기록 조회. + * + *

새 테이블 없이 {@code mission_progress.claimed_at} 을 그대로 읽는다. + * 정렬이 id 가 아니라 받은 시각이라, 커서 페이지네이션이 경계에서 빠지거나 겹치지 않는지가 핵심이다. + */ +@DisplayName("미션 보상 획득 기록") +class MissionClaimHistoryTest extends MissionSystemTestSupport { + + /** 기간 키 리터럴은 "지금"과 겹치면 유니크 제약에 걸린다. 절대 현재가 될 수 없는 과거로 못박는다. */ + private static final LocalDateTime BASE = LocalDateTime.of(2020, 9, 1, 10, 0); + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Nested + @DisplayName("무엇이 나오는가") + class Contents { + + @Test + @DisplayName("받은 것만 나오고 안 받은 것은 나오지 않는다") + void showsOnlyClaimed() { + insertClaimedProgress(user.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + completeMission(user.getId(), DAILY_NOTE_WRITE); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()) + .extracting(MissionClaimHistoryItemDto::code) + .containsExactly(WEEKLY_NOTE_10); + } + + @Test + @DisplayName("항목에 미션 정보와 받은 시각이 실린다") + void carriesMissionDetail() { + Long progressId = insertClaimedProgress(user.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + + MissionClaimHistoryItemDto item = + missionService.getClaimHistory(user.getId(), null, 20).content().get(0); + + assertThat(item.progressId()).isEqualTo(progressId); + assertThat(item.code()).isEqualTo(WEEKLY_NOTE_10); + assertThat(item.title()).isEqualTo("열 권의 노트"); + assertThat(item.iconKey()).isEqualTo("note_write"); + assertThat(item.category().name()).isEqualTo("WEEKLY"); + assertThat(item.periodKey()).isEqualTo("2020-W36"); + assertThat(item.rewardType().name()).isEqualTo("XP"); + assertThat(item.rewardValue()).isEqualTo(80); + assertThat(item.claimedAt()).isEqualTo(BASE); + } + + @Test + @DisplayName("실제로 받으면 그대로 기록에 남는다") + void recordsRealClaim() { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + missionService.claim(user.getId(), progress.getId()); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()).singleElement().satisfies(item -> { + assertThat(item.progressId()).isEqualTo(progress.getId()); + assertThat(item.claimedAt()).isNotNull(); + }); + } + + @Test + @DisplayName("받은 시각 내림차순으로 준다") + void sortsByClaimedAtDescending() { + insertClaimedProgress(user.getId(), DAILY_NOTE_WRITE, "2020-09-01", BASE); + insertClaimedProgress(user.getId(), DAILY_REVIEW_3, "2020-09-02", BASE.plusDays(2)); + insertClaimedProgress(user.getId(), DAILY_MOOD, "2020-09-03", BASE.plusDays(1)); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()) + .extracting(MissionClaimHistoryItemDto::claimedAt) + .containsExactly(BASE.plusDays(2), BASE.plusDays(1), BASE); + } + + @Test + @DisplayName("정의가 비활성화된 미션도 기록에는 남는다") + void keepsDeactivatedMission() { + insertClaimedProgress(user.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + deactivateDefinition(WEEKLY_NOTE_10); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()) + .as("이미 받은 건 받은 것이다") + .extracting(MissionClaimHistoryItemDto::code) + .containsExactly(WEEKLY_NOTE_10); + } + + @Test + @DisplayName("받은 게 없으면 빈 목록과 합계 0") + void returnsEmptyWhenNothingClaimed() { + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()).isEmpty(); + assertThat(history.hasNext()).isFalse(); + assertThat(history.nextCursor()).isNull(); + assertThat(history.totalClaimedXp()).isZero(); + assertThat(history.totalClaimedCount()).isZero(); + } + } + + @Nested + @DisplayName("소유권") + class Ownership { + + @Test + @DisplayName("남의 기록은 나오지 않는다") + void doesNotLeakOtherUsers() { + User other = fixtures.createOtherUser(); + insertClaimedProgress(other.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + insertClaimedProgress(other.getId(), DAILY_MOOD, "2020-09-01", BASE.plusDays(1)); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.content()).isEmpty(); + assertThat(history.totalClaimedXp()).isZero(); + assertThat(history.totalClaimedCount()).isZero(); + } + + @Test + @DisplayName("남의 진행도를 커서로 넘겨도 남의 기록이 새지 않는다") + void rejectsOtherUsersCursor() { + User other = fixtures.createOtherUser(); + Long othersProgressId = insertClaimedProgress(other.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + insertClaimedProgress(user.getId(), DAILY_MOOD, "2020-09-01", BASE.plusDays(1)); + + MissionClaimHistoryResponseDto history = + missionService.getClaimHistory(user.getId(), othersProgressId, 20); + + assertThat(history.content()).isEmpty(); + } + } + + @Nested + @DisplayName("커서 페이지네이션") + class Pagination { + + @Test + @DisplayName("페이지를 이어 붙이면 빠지거나 겹치는 것 없이 전부 나온다") + void walksEveryPageExactlyOnce() { + for (int i = 0; i < 5; i++) { + insertClaimedProgress(user.getId(), codeAt(i), "2020-09-0" + (i + 1), BASE.plusHours(i)); + } + + List visited = walkAllPages(2); + + assertThat(visited) + .as("5건을 2개씩 넘겨도 경계에서 빠지거나 겹치면 안 된다") + .hasSize(5) + .doesNotHaveDuplicates(); + } + + @Test + @DisplayName("받은 시각이 같아도 경계에서 빠지거나 겹치지 않는다") + void handlesTiesOnClaimedAt() { + for (int i = 0; i < 4; i++) { + insertClaimedProgress(user.getId(), codeAt(i), "2020-09-0" + (i + 1), BASE); + } + + List visited = walkAllPages(2); + + assertThat(visited) + .as("시각이 같으면 id 가 순서를 갈라 줘야 한다") + .hasSize(4) + .doesNotHaveDuplicates(); + } + + @Test + @DisplayName("마지막 페이지에서는 hasNext 가 거짓이고 커서가 없다") + void marksLastPage() { + insertClaimedProgress(user.getId(), DAILY_MOOD, "2020-09-01", BASE); + insertClaimedProgress(user.getId(), DAILY_REVIEW_3, "2020-09-02", BASE.plusHours(1)); + + MissionClaimHistoryResponseDto page = missionService.getClaimHistory(user.getId(), null, 2); + + assertThat(page.content()).hasSize(2); + assertThat(page.hasNext()).isFalse(); + assertThat(page.nextCursor()).isNull(); + } + + @Test + @DisplayName("size 는 상한을 넘지 않는다") + void capsPageSize() { + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 500); + + assertThat(history.size()).isEqualTo(50); + } + + private List walkAllPages(int size) { + List visited = new ArrayList<>(); + Long cursor = null; + for (int guard = 0; guard < 20; guard++) { + MissionClaimHistoryResponseDto page = missionService.getClaimHistory(user.getId(), cursor, size); + page.content().forEach(item -> visited.add(item.progressId())); + if (!page.hasNext()) { + return visited; + } + cursor = page.nextCursor(); + } + throw new IllegalStateException("페이지가 끝나지 않는다 - 커서가 앞으로 나아가지 않는다"); + } + + private String codeAt(int index) { + return List.of(DAILY_ATTEND, DAILY_NOTE_WRITE, DAILY_REVIEW_3, DAILY_CORRECT_3, DAILY_MOOD).get(index); + } + } + + @Nested + @DisplayName("합계") + class Totals { + + @Test + @DisplayName("첫 페이지에만 합계를 싣는다") + void carriesTotalsOnFirstPageOnly() { + insertClaimedProgress(user.getId(), DAILY_NOTE_WRITE, "2020-09-01", BASE); + insertClaimedProgress(user.getId(), DAILY_REVIEW_3, "2020-09-02", BASE.plusHours(1)); + insertClaimedProgress(user.getId(), WEEKLY_NOTE_10, "2020-W36", BASE.plusHours(2)); + + MissionClaimHistoryResponseDto first = missionService.getClaimHistory(user.getId(), null, 2); + assertThat(first.totalClaimedXp()) + .as("10 + 15 + 80") + .isEqualTo(105L); + assertThat(first.totalClaimedCount()).isEqualTo(3L); + + MissionClaimHistoryResponseDto second = + missionService.getClaimHistory(user.getId(), first.nextCursor(), 2); + assertThat(second.totalClaimedXp()) + .as("페이지를 넘길 때마다 전체를 다시 세지 않는다") + .isNull(); + assertThat(second.totalClaimedCount()).isNull(); + } + + @Test + @DisplayName("안 받은 미션은 합계에 들어가지 않는다") + void excludesUnclaimed() { + insertClaimedProgress(user.getId(), DAILY_NOTE_WRITE, "2020-09-01", BASE); + completeMission(user.getId(), WEEKLY_REVIEW_30); + + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, 20); + + assertThat(history.totalClaimedXp()).isEqualTo(10L); + assertThat(history.totalClaimedCount()).isEqualTo(1L); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java index 98aa928f..5a229546 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -110,6 +110,33 @@ protected Long insertCompletedProgress(Long userId, String code, String periodKe Long.class, userId, definition.getId(), periodKey); } + /** + * 보상을 받은 진행도를 직접 만든다. + * + *

받은 시각을 원하는 값으로 두려면 이렇게 박아 넣는 수밖에 없다. 서비스로 받으면 언제나 지금이다. + */ + protected Long insertClaimedProgress(Long userId, String code, String periodKey, LocalDateTime claimedAt) { + MissionDefinition definition = definitionOf(code); + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, + completed_at, claimed_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + userId, definition.getId(), periodKey, + definition.getTarget(), definition.getTarget(), + claimedAt, claimedAt, claimedAt, claimedAt); + + return jdbcTemplate.queryForObject( + "SELECT id FROM mission_progress WHERE user_id = ? AND mission_id = ? AND period_key = ?", + Long.class, userId, definition.getId(), periodKey); + } + + /** 미션 정의를 비활성화한다. 비활성 미션이 기록에는 남는지 확인할 때 쓴다. */ + protected void deactivateDefinition(String code) { + jdbcTemplate.update("UPDATE mission_definition SET active = 0 WHERE code = ?", code); + } + /** 지난 주 주간 키. */ protected String lastWeekKey() { return MissionPeriodKey.weekly(MissionPeriodKey.today().minusWeeks(1)); From 9f8e6e4174649a78440c61844ea2871326e6c8be Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 15:49:35 +0900 Subject: [PATCH 057/130] =?UTF-8?q?[Chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BB=A4=EB=84=A5=EC=85=98=20=ED=92=80=EC=9D=84=20=EB=AA=85?= =?UTF-8?q?=EC=8B=9C=ED=95=9C=EB=8B=A4=20-=20=EC=A7=84=ED=96=89=EB=8F=84?= =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=EB=A1=9C=20=EC=9A=94=EC=B2=AD=EB=8B=B9=20?= =?UTF-8?q?=EC=BB=A4=EB=84=A5=EC=85=98=EC=9D=B4=20=EB=91=90=20=EA=B0=9C=20?= =?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AFTER_COMMIT + REQUIRES_NEW 로 진행도를 떼어내면서 커밋 직후 잠깐 요청 하나가 커넥션을 두 개 잡는다. 바깥 트랜잭션의 커넥션은 afterCommit 콜백이 끝난 뒤에야 반납되는데 그 콜백 안에서 새 커넥션을 또 얻는다 - 실측: 동시 요청 8건에서 풀 9면 전부 멈추고 16이면 통과한다. 정확히 2배다 - 동시성 테스트가 8스레드를 한 번에 밀어 넣으므로 테스트 프로필만 20으로 올린다. 운영 설정은 손대지 않았다. 기본값 10 그대로라 설계로 풀어야 할 문제다 --- src/test/resources/application-test.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index fda00e93..37d2335a 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,6 +2,21 @@ spring: application: name: backend-test + datasource: + hikari: + # ⚠️ 이 설정은 문제를 가리는 임시 조치다. 운영 설정은 손대지 않았고 기본값(10) 그대로다. + # + # 미션 진행도를 AFTER_COMMIT + REQUIRES_NEW 로 떼어내면서, 커밋 직후 잠깐 동안 + # 요청 하나가 커넥션을 두 개 잡는다. 바깥 트랜잭션의 커넥션은 afterCommit 콜백이 끝나고 + # cleanupAfterCompletion 에서야 반납되는데, 그 콜백 안에서 REQUIRES_NEW 가 새 커넥션을 또 얻기 때문이다. + # + # 실측: 동시 요청 8건에서 풀 9면 전부 멈추고(2/2 실패), 16이면 통과한다(2/2). 정확히 2배다. + # 동시성 테스트가 8스레드를 한 번에 밀어 넣으므로 2배 + 여유를 준다. + # + # 운영은 풀이 기본값 10이라 미션을 건드리는 요청 5건이 겹치면 커넥션 고갈로 멈출 수 있다. + # 이건 설정이 아니라 설계로 풀어야 한다. 기획자에게 보고했다. + maximum-pool-size: 20 + servlet: multipart: max-file-size: 50MB From 48174906538a428d638ebb2d534aafce1476152a Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 16:01:00 +0900 Subject: [PATCH 058/130] =?UTF-8?q?[Fix]=20=EB=AF=B8=EC=85=98=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8F=84=EB=A5=BC=20=ED=98=B8=EC=B6=9C=EC=9E=90=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4=20-=20=EC=BB=A4=EB=84=A5?= =?UTF-8?q?=EC=85=98=20=EB=91=90=20=EB=B0=B0=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AFTER_COMMIT + REQUIRES_NEW 로 떼어냈더니 커밋 직후 요청 하나가 커넥션을 두 개 잡았다. 바깥 커넥션은 afterCommit 콜백이 끝난 뒤에야 반납되는데 그 콜백 안에서 새 커넥션을 또 얻는다. 실측으로 동시 요청 8건에 커넥션 16개가 필요했다(풀 9면 전부 멈춤, 16이면 통과) - 운영 Hikari 풀은 기본값 10이라 미션을 건드리는 요청 다섯 건만 겹쳐도 서비스 전체가 멈춘다. 롤백 전파는 같은 사용자가 동시에 두 요청을 보내야 나고 터져도 요청 하나가 실패할 뿐이다. 드문 요청 실패가 전체 정지보다 낫다고 보고 되돌렸다 - 이벤트·AFTER_COMMIT 리스너·REQUIRES_NEW 적용 빈을 걷어냈다 - 진행도 갱신이 실패하면 본 작업도 롤백된다. 알고 받아들인 대가이고 주석에 남겼다 - 원인이 사라져 테스트 프로필의 커넥션 풀 임시 조치(20)도 되돌렸다 --- .../mission/event/MissionProgressEvent.java | 16 ----- .../service/MissionProgressApplier.java | 41 ------------ .../service/MissionProgressEventListener.java | 41 ------------ .../service/MissionProgressUpdater.java | 65 ++++++++++++++----- .../MissionProgressEventListenerTest.java | 60 ----------------- .../service/MissionProgressUpdaterTest.java | 19 +++--- src/test/resources/application-test.yml | 15 ----- 7 files changed, 58 insertions(+), 199 deletions(-) delete mode 100644 src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java delete mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java delete mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java delete mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java diff --git a/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java b/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java deleted file mode 100644 index 2ed6f83e..00000000 --- a/src/main/java/com/aisip/OnO/backend/mission/event/MissionProgressEvent.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.aisip.OnO.backend.mission.event; - -import com.aisip.OnO.backend.mission.entity.MissionMetric; - -/** - * "사용자가 세는 항목에 해당하는 행동을 했다"는 사실. - * - *

행동을 처리하는 트랜잭션이 커밋된 뒤에 소비된다. 커밋 전에는 아직 일어나지 않은 일이므로 - * 진행도를 올리면 안 되고, 커밋 뒤라면 진행도가 실패하더라도 행동은 이미 끝난 일이라 되돌리면 안 된다. - */ -public record MissionProgressEvent( - Long userId, - MissionMetric metric, - int amount -) { -} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java deleted file mode 100644 index 8dd3d37f..00000000 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressApplier.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.aisip.OnO.backend.mission.service; - -import com.aisip.OnO.backend.mission.entity.MissionDefinition; -import com.aisip.OnO.backend.mission.entity.MissionMetric; -import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; -import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.util.List; - -/** - * 진행도를 실제로 갱신한다. 본 작업과 분리된 자기 트랜잭션에서 돈다. - * - *

증가는 리포지토리의 upsert 한 문장으로만 한다. 조회한 뒤 값을 계산해 저장하면 - * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. - */ -@Component -@RequiredArgsConstructor -public class MissionProgressApplier { - - private final MissionDefinitionRepository missionDefinitionRepository; - private final MissionProgressRepository missionProgressRepository; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void apply(Long userId, MissionMetric metric, int amount) { - LocalDate today = MissionPeriodKey.today(); - List definitions = - missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); - - for (MissionDefinition definition : definitions) { - String periodKey = MissionPeriodKey.of(definition.getCategory(), today); - missionProgressRepository.increaseValue( - userId, definition.getId(), periodKey, amount, definition.getTarget()); - missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); - } - } -} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java deleted file mode 100644 index 8954720f..00000000 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListener.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.aisip.OnO.backend.mission.service; - -import com.aisip.OnO.backend.mission.event.MissionProgressEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -/** - * 본 작업이 커밋된 뒤에 진행도를 올린다. - * - *

실패는 삼키고 로그만 남긴다. 여기서 예외가 새어 나가면 커밋 콜백을 타고 요청 처리부까지 올라가, - * 데이터는 이미 저장됐는데 사용자에게는 500 이 나가는 최악의 조합이 된다. - * 진행도는 다음 행동에서 다시 오르므로 한 번 놓쳐도 회복 가능하다. - * - *

{@code @Transactional} 을 이 메서드에 직접 붙이지 않는다. 붙이면 여기서 예외를 잡는 순간 - * 트랜잭션이 롤백되지 않고 중간까지 반영된 채로 커밋된다. 새 트랜잭션 경계는 - * {@link MissionProgressApplier} 에 두어, 실패하면 그 트랜잭션만 통째로 롤백되고 - * 예외는 프록시 밖의 이 자리에서 잡힌다. - * - *

{@code fallbackExecution} 은 트랜잭션 없이 불린 경우에도 진행도를 올리기 위한 것이다. - * 운영 경로는 모두 트랜잭션 안이지만, 없을 때 조용히 사라지는 것보다 즉시 실행하는 편이 안전하다. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class MissionProgressEventListener { - - private final MissionProgressApplier missionProgressApplier; - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) - public void handleMissionProgress(MissionProgressEvent event) { - try { - missionProgressApplier.apply(event.userId(), event.metric(), event.amount()); - } catch (Exception e) { - log.error("[Mission] 진행도 갱신 실패 - userId: {}, metric: {}, amount: {}", - event.userId(), event.metric(), event.amount(), e); - } - } -} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java index 9b8bffe1..d7d53b6e 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java @@ -1,35 +1,57 @@ package com.aisip.OnO.backend.mission.service; +import com.aisip.OnO.backend.mission.entity.MissionDefinition; import com.aisip.OnO.backend.mission.entity.MissionMetric; -import com.aisip.OnO.backend.mission.event.MissionProgressEvent; +import com.aisip.OnO.backend.mission.repository.MissionDefinitionRepository; +import com.aisip.OnO.backend.mission.repository.MissionProgressRepository; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; /** - * 다른 도메인이 부르는 진행도 증가 진입점. 실제 갱신은 하지 않고 사실만 알린다. + * 다른 도메인이 부르는 진행도 증가 진입점. + * + *

호출자의 트랜잭션 안에서 돈다. 한때 커밋 후 실행({@code AFTER_COMMIT}) + 새 트랜잭션 + * ({@code REQUIRES_NEW}) 으로 떼어냈다가 되돌렸다. 되돌린 이유와 그 대가를 남겨 둔다. * - *

왜 호출자의 트랜잭션에 합류하지 않는가. 요구는 두 가지인데 방향이 다르다. + *

왜 떼어냈었나. 진행도 갱신이 잠금 대기 시간을 넘기거나 교착의 희생자가 되면 + * 사용자가 방금 저장한 오답노트까지 함께 롤백되기 때문이다. 그 걱정 자체는 지금도 유효하다. * - *

하나는 "본 작업이 실패하면 진행도도 없어야 한다"이다. 오답노트 등록이 롤백됐는데 - * "오늘의 오답" 진행도만 남으면 사용자는 하지도 않은 일로 보상을 받는다. + *

왜 되돌렸나. 떼어내면 커밋 직후 잠깐 요청 하나가 커넥션을 두 개 잡는다. + * 바깥 트랜잭션의 커넥션은 {@code afterCommit} 콜백이 끝나고 {@code cleanupAfterCompletion} 에서야 + * 반납되는데, 그 콜백 안에서 {@code REQUIRES_NEW} 가 새 커넥션을 또 얻기 때문이다. + * 실측으로 동시 요청 8건에 커넥션 16개가 필요했다(풀 9면 전부 멈추고 16이면 통과). 정확히 두 배다. + * 운영 Hikari 풀은 기본값 10이라 미션을 건드리는 요청 다섯 건만 겹쳐도 서비스 전체가 멈춘다. * - *

다른 하나는 "진행도가 실패해도 본 작업은 살아야 한다"이다. 진행도 갱신은 다른 트랜잭션이 잡은 - * 행에서 대기하다 잠금 대기 시간을 넘기거나 교착의 희생자가 될 수 있다. 같은 트랜잭션에 묶어 두면 - * 그때 사용자가 방금 저장한 오답노트가 통째로 사라지고 500 이 나간다. - * 보너스 카운터 하나 때문에 본 기능을 잃는 거래는 성립하지 않는다. + *

무엇을 받아들였나. 두 위험의 크기가 다르다. 롤백 전파는 같은 사용자가 두 요청을 동시에 보내고 + * 같은 진행도 행에서 잠금을 오래 기다려야 나는 일이라 드물고, 터져도 요청 하나가 실패한다. + * 커넥션 고갈은 사용자가 누구든 다섯 건만 겹치면 나고, 터지면 서비스 전체가 멈춘다. + * 드문 요청 실패가 전체 정지보다 낫다. 그래서 진행도 갱신이 실패하면 본 작업도 함께 롤백된다는 것을 + * 알면서 이 방식을 고른 것이다. 이 절충을 바꾸려면 커넥션을 두 배로 쓰지 않는 방법 + * (예: 별도 실행기로 요청 스레드에서 떼어내기)을 먼저 마련해야 한다. * - *

커밋 후 실행({@code AFTER_COMMIT})에 새 트랜잭션({@code REQUIRES_NEW})이면 둘 다 선다. - * 본 작업이 롤백되면 이벤트가 발행되지 않아 진행도도 남지 않고, 진행도가 실패해도 본 작업은 이미 커밋돼 있다. - * 판단 기준은 외부 연동이냐 DB 냐가 아니라 본 작업의 성패에 그 결과가 필요한가이고, - * 미션 진행도는 필요 없다. {@code ProblemReviewReminderService} 가 같은 이유로 같은 형태를 쓴다. + *

덧붙여 이 방식은 "본 작업이 롤백되면 진행도도 남지 않는다"를 그대로 지킨다. + * 하지도 않은 행동으로 보상을 받는 일은 없다. + * + *

증가는 리포지토리의 upsert 한 문장으로만 한다. 여기서 조회한 뒤 값을 계산해 저장하면 + * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. */ @Component @RequiredArgsConstructor public class MissionProgressUpdater { - private final ApplicationEventPublisher eventPublisher; + private final MissionDefinitionRepository missionDefinitionRepository; + private final MissionProgressRepository missionProgressRepository; + /** + * 애노테이션이 여기에도 붙어 있어야 한다. 이 오버로드는 같은 빈의 3인자 메서드를 직접 부르는데, + * 자기 호출은 프록시를 타지 않아 저쪽의 {@code @Transactional} 이 적용되지 않는다. + * 호출자에게 트랜잭션이 없으면 그대로 트랜잭션 없이 실행돼 flush 에서 터진다. + */ + @Transactional public void increase(Long userId, MissionMetric metric) { increase(userId, metric, 1); } @@ -37,10 +59,21 @@ public void increase(Long userId, MissionMetric metric) { /** * @param amount 한 번에 오를 양. 오답노트를 여러 장 등록하면 장수만큼 오른다. */ + @Transactional public void increase(Long userId, MissionMetric metric, int amount) { if (userId == null || metric == null || amount <= 0) { return; } - eventPublisher.publishEvent(new MissionProgressEvent(userId, metric, amount)); + + LocalDate today = MissionPeriodKey.today(); + List definitions = + missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); + + for (MissionDefinition definition : definitions) { + String periodKey = MissionPeriodKey.of(definition.getCategory(), today); + missionProgressRepository.increaseValue( + userId, definition.getId(), periodKey, amount, definition.getTarget()); + missionProgressRepository.markCompleted(userId, definition.getId(), periodKey); + } } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java deleted file mode 100644 index 4a72ff2d..00000000 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressEventListenerTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.aisip.OnO.backend.mission.service; - -import com.aisip.OnO.backend.mission.entity.MissionMetric; -import com.aisip.OnO.backend.mission.event.MissionProgressEvent; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.dao.CannotAcquireLockException; - -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; - -/** - * 진행도 갱신 실패가 본 작업 밖으로 새어 나가지 않는지. - * - *

리스너는 커밋 콜백에서 실행된다. 여기서 예외를 흘리면 데이터는 이미 저장됐는데 사용자에게는 - * 500 이 나가는 최악의 조합이 된다. 잠금 대기 초과나 교착 희생자는 실제로 일어나는 일이라 - * 그때 오답노트 저장이 성공으로 끝나는지를 고정한다. - */ -@DisplayName("미션 진행도 이벤트 리스너") -class MissionProgressEventListenerTest { - - @Test - @DisplayName("진행도 갱신이 실패해도 예외를 밖으로 내보내지 않는다") - void swallowsFailure() { - MissionProgressEventListener listener = new MissionProgressEventListener( - throwingApplier(new CannotAcquireLockException("교착 희생자로 선택됐다"))); - - assertThatCode(() -> listener.handleMissionProgress( - new MissionProgressEvent(1L, MissionMetric.PROBLEM_CREATED, 1))) - .doesNotThrowAnyException(); - } - - @Test - @DisplayName("정상이면 받은 값 그대로 넘긴다") - void delegatesEvent() { - AtomicInteger appliedAmount = new AtomicInteger(); - MissionProgressApplier applier = new MissionProgressApplier(null, null) { - @Override - public void apply(Long userId, MissionMetric metric, int amount) { - appliedAmount.set(amount); - } - }; - - new MissionProgressEventListener(applier).handleMissionProgress( - new MissionProgressEvent(1L, MissionMetric.PROBLEM_CREATED, 3)); - - assertThat(appliedAmount).hasValue(3); - } - - private MissionProgressApplier throwingApplier(RuntimeException failure) { - return new MissionProgressApplier(null, null) { - @Override - public void apply(Long userId, MissionMetric metric, int amount) { - throw failure; - } - }; - } -} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java index cc93dd9a..b2cf0abb 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java @@ -144,7 +144,7 @@ class TransactionBoundary { @Test @DisplayName("행동이 롤백되면 진행도도 남지 않는다") void rollsBackWithTheAction() { - // 커밋 후에 올리므로 롤백된 행동에는 이벤트 자체가 발행되지 않는다. + // 호출자의 트랜잭션에 합류하므로 본 작업이 롤백되면 진행도도 함께 사라진다. // 하지도 않은 행동으로 보상을 받는 일이 없어야 한다. assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> { missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); @@ -155,28 +155,27 @@ void rollsBackWithTheAction() { } @Test - @DisplayName("진행도는 본 작업이 커밋된 뒤에 반영된다") - void appliesAfterCommit() { + @DisplayName("진행도는 호출자의 트랜잭션 안에서 바로 쓰인다") + void joinsCallerTransaction() { + // 커밋을 기다렸다가 새 트랜잭션에서 올리면 그 순간 요청 하나가 커넥션을 두 개 잡는다. + // 운영 풀이 10이라 다섯 건만 겹쳐도 서비스가 멈춘다. 그래서 같은 트랜잭션에서 바로 쓴다. transactionTemplate.executeWithoutResult(status -> { missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); Integer rows = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM mission_progress WHERE user_id = ?", Integer.class, user.getId()); assertThat(rows) - .as("본 작업과 같은 트랜잭션에서 올리면 진행도 실패가 본 작업까지 되돌린다. " - + "커밋 전에는 아직 아무것도 쓰지 않아야 한다") - .isZero(); + .as("커밋을 기다리지 않고 같은 트랜잭션 안에서 바로 반영된다") + .isEqualTo(2); }); - assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)) - .as("커밋된 뒤에는 반영돼야 한다") - .isEqualTo(1); + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); } @Test @DisplayName("트랜잭션 없이 불려도 진행도는 반영된다") void appliesWithoutOuterTransaction() { - // 운영 경로는 모두 트랜잭션 안이지만, 없을 때 조용히 사라지면 원인을 찾기 어렵다. + // 운영 경로는 모두 트랜잭션 안이지만, 없으면 스스로 하나를 연다. missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)).isEqualTo(1); diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 37d2335a..fda00e93 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -2,21 +2,6 @@ spring: application: name: backend-test - datasource: - hikari: - # ⚠️ 이 설정은 문제를 가리는 임시 조치다. 운영 설정은 손대지 않았고 기본값(10) 그대로다. - # - # 미션 진행도를 AFTER_COMMIT + REQUIRES_NEW 로 떼어내면서, 커밋 직후 잠깐 동안 - # 요청 하나가 커넥션을 두 개 잡는다. 바깥 트랜잭션의 커넥션은 afterCommit 콜백이 끝나고 - # cleanupAfterCompletion 에서야 반납되는데, 그 콜백 안에서 REQUIRES_NEW 가 새 커넥션을 또 얻기 때문이다. - # - # 실측: 동시 요청 8건에서 풀 9면 전부 멈추고(2/2 실패), 16이면 통과한다(2/2). 정확히 2배다. - # 동시성 테스트가 8스레드를 한 번에 밀어 넣으므로 2배 + 여유를 준다. - # - # 운영은 풀이 기본값 10이라 미션을 건드리는 요청 5건이 겹치면 커넥션 고갈로 멈출 수 있다. - # 이건 설정이 아니라 설계로 풀어야 한다. 기획자에게 보고했다. - maximum-pool-size: 20 - servlet: multipart: max-file-size: 50MB From cf84c9d89be68b3612135f99568a028ddf4cb4b4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 16:07:51 +0900 Subject: [PATCH 059/130] =?UTF-8?q?[Feat]=20=EB=B3=B4=EC=83=81=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20-=20=EB=B0=9B=EC=9D=80=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9D=B4=20=EB=82=98=EC=A4=91=EC=97=90=20=EB=B0=94?= =?UTF-8?q?=EB=80=90=20=EB=B3=B4=EC=83=81=EA=B0=92=EC=9D=84=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=EA=B0=80=EC=A7=80=20=EC=95=8A=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V32 로 mission_progress 에 reward_type_snapshot, reward_value_snapshot 을 더한다 - 받는 순간의 정의 값을 박고, 기록 조회와 합계가 그 값을 읽는다. 현재 정의를 읽으면 운영 중에 보상을 바꿨을 때 80 XP 를 받은 기록이 50 XP 로 보인다. target_snapshot 을 둔 이유와 같은 문제다 - 지금 넣는 게 싸다. 받기 기능이 배포 전이라 claimed_at 이 채워진 행이 운영에 하나도 없다. 나중에는 이미 받은 행의 그때 보상값을 알 방법이 없다 - 스냅샷은 markClaimed 와 같은 문장에서 박는다. 엔티티를 고쳐 더티 체킹에 맡기면 영속성 컨텍스트의 claimedAt 이 아직 null 이라 커밋 시 UPDATE 가 방금 찍은 수령 시각을 지운다 - 스냅샷이 없는 옛 행은 현재 정의로 폴백한다 --- .../dto/MissionClaimHistoryItemDto.java | 9 +++--- .../mission/entity/MissionProgress.java | 26 +++++++++++++++ .../repository/MissionProgressRepository.java | 32 +++++++++++++------ .../mission/service/MissionService.java | 4 ++- ...__add_mission_progress_reward_snapshot.sql | 14 ++++++++ 5 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java index 6078d8c6..510d91ab 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimHistoryItemDto.java @@ -12,9 +12,8 @@ * *

새 테이블을 두지 않는다. {@code mission_progress.claimed_at} 이 이미 그 기록이다. * - *

보상 종류와 값은 현재 미션 정의에서 읽는다. 진행도 행에 보상을 박아 두지 않기 때문에, - * 운영 중에 보상을 바꾸면 예전에 받은 기록도 새 값으로 보인다. - * 받은 시점의 값을 보존하려면 {@code target_snapshot} 처럼 보상 스냅샷 컬럼이 필요하다. + *

보상 종류와 값은 받은 시점의 스냅샷에서 읽는다. 현재 정의를 읽으면 운영 중에 보상을 바꿨을 때 + * 예전에 받은 기록까지 새 값으로 보인다. 스냅샷이 없는 옛 행만 현재 정의로 폴백한다. */ public record MissionClaimHistoryItemDto( Long progressId, @@ -36,8 +35,8 @@ public static MissionClaimHistoryItemDto from(MissionDefinition definition, Miss definition.getIconKey(), definition.getCategory(), progress.getPeriodKey(), - definition.getRewardType(), - definition.getRewardValue(), + progress.rewardTypeOr(definition.getRewardType()), + progress.rewardValueOr(definition.getRewardValue()), progress.getClaimedAt() ); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java index dc4aba81..fdef0833 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionProgress.java @@ -3,6 +3,8 @@ import com.aisip.OnO.backend.common.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -67,6 +69,20 @@ public class MissionProgress extends BaseEntity { @Column(name = "claimed_at") private LocalDateTime claimedAt; + /** + * 받은 시점의 보상 종류와 값. + * + *

기록 조회가 현재 정의를 읽으면 운영 중에 보상을 바꿨을 때 예전 기록까지 새 값으로 보인다. + * {@code targetSnapshot} 과 같은 이유로 받는 순간의 값을 박아 둔다. + * 이 기능 이전에 받은 행이 있을 수 있어 nullable 이고, 비어 있으면 현재 정의로 폴백한다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "reward_type_snapshot", length = 20) + private MissionRewardType rewardTypeSnapshot; + + @Column(name = "reward_value_snapshot") + private Integer rewardValueSnapshot; + public boolean isOwnedBy(Long userId) { return this.userId != null && this.userId.equals(userId); } @@ -78,4 +94,14 @@ public boolean isCompleted() { public boolean isClaimed() { return claimedAt != null; } + + /** 받은 시점의 보상 종류. 스냅샷이 없는 옛 행은 현재 정의 값으로 답한다. */ + public MissionRewardType rewardTypeOr(MissionRewardType current) { + return rewardTypeSnapshot != null ? rewardTypeSnapshot : current; + } + + /** 받은 시점의 보상 값. 스냅샷이 없는 옛 행은 현재 정의 값으로 답한다. */ + public int rewardValueOr(int current) { + return rewardValueSnapshot != null ? rewardValueSnapshot : current; + } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java index df256a14..1dae49e6 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepository.java @@ -72,16 +72,17 @@ List findClaimedPage( /** * 지금까지 받은 XP 합계. * - *

보상 값은 미션 정의에 있으므로 두 테이블을 함께 본다. 연관관계를 걸지 않아 식별자로 잇는다. + *

목록에 보이는 값과 합계가 어긋나면 안 되므로 여기서도 스냅샷을 먼저 본다. + * 스냅샷이 없는 옛 행만 현재 정의로 폴백한다. 정의는 연관관계를 걸지 않아 식별자로 잇는다. */ - @Query(""" - SELECT COALESCE(SUM(d.rewardValue), 0) - FROM MissionProgress p, MissionDefinition d - WHERE d.id = p.missionId - AND p.userId = :userId - AND p.claimedAt IS NOT NULL - AND d.rewardType = com.aisip.OnO.backend.mission.entity.MissionRewardType.XP - """) + @Query(value = """ + SELECT COALESCE(SUM(COALESCE(p.reward_value_snapshot, d.reward_value)), 0) + FROM mission_progress p + JOIN mission_definition d ON d.id = p.mission_id + WHERE p.user_id = :userId + AND p.claimed_at IS NOT NULL + AND COALESCE(p.reward_type_snapshot, d.reward_type) = 'XP' + """, nativeQuery = true) long sumClaimedXp(@Param("userId") Long userId); /** @@ -150,15 +151,26 @@ int markCompleted( *

{@code user_id} 조건은 호출부의 소유권 검사와 중복이지만 일부러 남겨 둔다. * 검사를 서비스에만 두면 이 메서드를 다른 곳에서 부르는 순간 소유권 검증이 통째로 빠진다. * "모든 데이터 접근은 userId 기준"이라는 불변식은 쿼리 자체에 박혀 있어야 한다. + * + *

보상 스냅샷도 같은 문장에서 박는다. 엔티티를 고쳐 더티 체킹에 맡기면 안 된다. + * 이 UPDATE 는 네이티브라 영속성 컨텍스트의 진행도 엔티티는 여전히 {@code claimedAt} 이 null 인데, + * 그 상태로 엔티티가 더러워지면 커밋 시점의 UPDATE 가 모든 컬럼을 쓰면서 방금 찍은 수령 시각을 지운다. */ @Modifying(flushAutomatically = true) @Query(value = """ UPDATE mission_progress SET claimed_at = NOW(6), + reward_type_snapshot = :rewardType, + reward_value_snapshot = :rewardValue, updated_at = NOW(6) WHERE id = :progressId AND user_id = :userId AND claimed_at IS NULL """, nativeQuery = true) - int markClaimed(@Param("progressId") Long progressId, @Param("userId") Long userId); + int markClaimed( + @Param("progressId") Long progressId, + @Param("userId") Long userId, + @Param("rewardType") String rewardType, + @Param("rewardValue") int rewardValue + ); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index ae36b562..11586e22 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -208,7 +208,9 @@ public MissionClaimResponseDto claim(Long userId, Long progressId) { // 진행도 행을 잠그기 전에 사용자 행을 먼저 잠근다. 잠금 순서를 기존 적립 경로와 맞춰 교착을 막는다. missionRewardGranter.lockUser(userId); - if (missionProgressRepository.markClaimed(progressId, userId) == 0) { + // 보상 종류와 값을 받는 순간 박아 둔다. 나중에 정의를 바꿔도 이 기록의 값은 그대로다. + if (missionProgressRepository.markClaimed( + progressId, userId, definition.getRewardType().name(), definition.getRewardValue()) == 0) { throw new ApplicationException(MissionErrorCase.MISSION_ALREADY_CLAIMED); } diff --git a/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql new file mode 100644 index 00000000..ffce3b08 --- /dev/null +++ b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql @@ -0,0 +1,14 @@ +-- 보상 스냅샷. 받은 시점의 보상 종류와 값을 진행도 행에 박아 둔다. +-- +-- 기록 조회가 "현재" 미션 정의의 보상값을 읽으면, 운영 중에 보상을 바꿨을 때 +-- 예전에 받은 기록까지 새 값으로 보인다. 80 XP 를 받은 사람의 기록이 어느 날 50 XP 로 바뀐다. +-- target_snapshot 을 둔 이유와 정확히 같은 문제다. +-- +-- 지금 넣는 게 싸다. 보상 받기 기능이 아직 배포 전이라 claimed_at 이 채워진 행이 운영에 하나도 없다. +-- 나중에 넣으면 이미 받은 행들을 소급해서 채워야 하는데, 그때의 보상값은 알 방법이 없다. +-- +-- 옛 행을 위해 nullable 로 둔다. 스냅샷이 비어 있으면 조회가 현재 정의로 폴백한다. +-- 문장이 하나라 중간에 끊길 지점이 없다. +ALTER TABLE mission_progress + ADD COLUMN reward_type_snapshot VARCHAR(20) NULL, + ADD COLUMN reward_value_snapshot INT NULL; From 006a67120b1a8186ab4618611b6c6ff853115bc2 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 16:07:51 +0900 Subject: [PATCH 060/130] =?UTF-8?q?[Test]=20=EB=B3=B4=EC=83=81=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 받은 뒤 정의의 보상값을 바꿔도 기록의 값과 합계가 그대로인지 - 받는 순간 스냅샷이 실제로 박히고, 그러면서 수령 시각을 덮어쓰지 않는지 - 스냅샷이 없는 옛 행은 현재 정의 값으로 보이는지 --- .../MissionProgressRepositoryTest.java | 2 +- .../service/MissionClaimHistoryTest.java | 63 +++++++++++++++++++ .../support/MissionSystemTestSupport.java | 34 +++++++++- 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java b/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java index f6622250..38ec6158 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/repository/MissionProgressRepositoryTest.java @@ -40,7 +40,7 @@ void markClaimedRejectsOtherUser() { /** {@code @Modifying} 쿼리는 flush 를 하므로 트랜잭션 안에서 불러야 한다. */ private Integer claim(Long progressId, Long userId) { return transactionTemplate.execute( - status -> missionProgressRepository.markClaimed(progressId, userId)); + status -> missionProgressRepository.markClaimed(progressId, userId, "XP", 10)); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java index c2e55076..9b649cbb 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java @@ -126,6 +126,69 @@ void returnsEmptyWhenNothingClaimed() { } } + @Nested + @DisplayName("보상 스냅샷") + class RewardSnapshot { + + @Test + @DisplayName("받은 뒤 보상을 바꿔도 기록의 값은 그대로다") + void keepsRewardValueAtClaimTime() { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + missionService.claim(user.getId(), progress.getId()); + + changeRewardValue(DAILY_NOTE_WRITE, 999); + + assertThat(missionService.getClaimHistory(user.getId(), null, 20).content()) + .singleElement() + .satisfies(item -> assertThat(item.rewardValue()) + .as("10 XP 를 받은 기록이 어느 날 999 XP 로 바뀌면 안 된다") + .isEqualTo(10)); + } + + @Test + @DisplayName("합계도 받은 시점의 값으로 센다") + void sumsRewardValueAtClaimTime() { + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + missionService.claim(user.getId(), progress.getId()); + + changeRewardValue(DAILY_NOTE_WRITE, 999); + + assertThat(missionService.getClaimHistory(user.getId(), null, 20).totalClaimedXp()) + .as("목록에 보이는 값과 합계가 어긋나면 안 된다") + .isEqualTo(10L); + } + + @Test + @DisplayName("스냅샷이 없는 옛 행은 현재 정의 값으로 보인다") + void fallsBackToCurrentDefinition() { + insertClaimedProgressWithoutSnapshot(user.getId(), WEEKLY_NOTE_10, "2020-W36", BASE); + + assertThat(missionService.getClaimHistory(user.getId(), null, 20).content()) + .singleElement() + .satisfies(item -> { + assertThat(item.rewardValue()).isEqualTo(80); + assertThat(item.rewardType().name()).isEqualTo("XP"); + }); + assertThat(missionService.getClaimHistory(user.getId(), null, 20).totalClaimedXp()) + .isEqualTo(80L); + } + + @Test + @DisplayName("받는 순간 스냅샷이 실제로 박힌다") + void stampsSnapshotOnClaim() { + MissionProgress progress = completeMission(user.getId(), WEEKLY_REVIEW_30); + + missionService.claim(user.getId(), progress.getId()); + + MissionProgress claimed = missionProgressRepository.findById(progress.getId()).orElseThrow(); + assertThat(claimed.getClaimedAt()) + .as("스냅샷을 쓰면서 수령 시각을 덮어쓰면 안 된다") + .isNotNull(); + assertThat(claimed.getRewardTypeSnapshot().name()).isEqualTo("XP"); + assertThat(claimed.getRewardValueSnapshot()).isEqualTo(100); + } + } + @Nested @DisplayName("소유권") class Ownership { diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java index 5a229546..337cb17d 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -117,6 +117,29 @@ protected Long insertCompletedProgress(Long userId, String code, String periodKe */ protected Long insertClaimedProgress(Long userId, String code, String periodKey, LocalDateTime claimedAt) { MissionDefinition definition = definitionOf(code); + jdbcTemplate.update(""" + INSERT INTO mission_progress + (user_id, mission_id, period_key, current_value, target_snapshot, + completed_at, claimed_at, reward_type_snapshot, reward_value_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + userId, definition.getId(), periodKey, + definition.getTarget(), definition.getTarget(), + claimedAt, claimedAt, + definition.getRewardType().name(), definition.getRewardValue(), + claimedAt, claimedAt); + + return claimedProgressId(userId, definition.getId(), periodKey); + } + + /** + * 보상 스냅샷이 없는 진행도. 스냅샷 컬럼이 생기기 전에 받은 행을 흉내 낸다. + * + *

운영에는 이런 행이 없지만(기능 배포 전에 컬럼을 넣었다) 폴백 경로가 살아 있는지는 확인해야 한다. + */ + protected Long insertClaimedProgressWithoutSnapshot( + Long userId, String code, String periodKey, LocalDateTime claimedAt) { + MissionDefinition definition = definitionOf(code); jdbcTemplate.update(""" INSERT INTO mission_progress (user_id, mission_id, period_key, current_value, target_snapshot, @@ -127,9 +150,18 @@ protected Long insertClaimedProgress(Long userId, String code, String periodKey, definition.getTarget(), definition.getTarget(), claimedAt, claimedAt, claimedAt, claimedAt); + return claimedProgressId(userId, definition.getId(), periodKey); + } + + /** 미션 정의의 보상 값을 바꾼다. 이미 받은 기록이 흔들리지 않는지 확인할 때 쓴다. */ + protected void changeRewardValue(String code, int rewardValue) { + jdbcTemplate.update("UPDATE mission_definition SET reward_value = ? WHERE code = ?", rewardValue, code); + } + + private Long claimedProgressId(Long userId, Long missionId, String periodKey) { return jdbcTemplate.queryForObject( "SELECT id FROM mission_progress WHERE user_id = ? AND mission_id = ? AND period_key = ?", - Long.class, userId, definition.getId(), periodKey); + Long.class, userId, missionId, periodKey); } /** 미션 정의를 비활성화한다. 비활성 미션이 기록에는 남는지 확인할 때 쓴다. */ From c560395d5e8bc53ef421152968864396d028f0cb Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 17:45:59 +0900 Subject: [PATCH 061/130] =?UTF-8?q?[Fix]=20=EC=A7=80=EB=82=9C=20=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=95=EB=A0=AC=EC=9D=B4=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=EC=9D=84=20=ED=95=AD=EC=83=81=20=EC=95=9E=EC=84=B8=EC=9A=B0?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - periodKey 문자열을 역순 비교하고 있었는데 "2026-W37" 과 "2026-09-09" 는 여섯 번째 글자 'W'(0x57) 대 '0'(0x30) 에서 갈린다. 주간이 언제나 일일보다 앞서서, 어제 놓친 일일 미션이 3주 전 주간 미션보다 아래에 떴다 - completed_at 으로 정렬한다. 실제 시각이라 종류가 섞여도 최근 것이 위로 온다 --- .../mission/service/MissionService.java | 8 ++++++-- .../mission/service/MissionServiceTest.java | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index 11586e22..3fa3c7ee 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -114,9 +114,13 @@ private List expiredMissions(Long userId, String dailyKey, S return expired.stream() .filter(progress -> definitionsById.containsKey(progress.getMissionId())) - // 최근에 놓친 것을 먼저 보여주고, 같은 기간 안에서는 목록과 같은 순서를 쓴다. + // 최근에 놓친 것을 먼저 보여주고, 시각이 같으면 목록과 같은 순서를 쓴다. + // + // 기간 키 문자열로 정렬하면 안 된다. "2026-W37" 과 "2026-09-09" 는 여섯 번째 글자 + // 'W'(0x57) 대 '0'(0x30) 에서 갈려 주간이 언제나 일일보다 앞선다. + // 어제 놓친 일일 미션이 3주 전 주간 미션보다 아래로 밀린다. .sorted(Comparator - .comparing(MissionProgress::getPeriodKey, Comparator.reverseOrder()) + .comparing(MissionProgress::getCompletedAt, Comparator.reverseOrder()) .thenComparing(progress -> definitionsById.get(progress.getMissionId()).getSortOrder())) .map(progress -> MissionResponseDto.from( definitionsById.get(progress.getMissionId()), progress, progress.getPeriodKey())) diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java index 834c6743..6c343f80 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java @@ -197,6 +197,24 @@ void expiredSectionHasNoSinglePeriodKey() { .containsExactlyInAnyOrder(lastWeekKey(), yesterdayKey()); } + @Test + @DisplayName("일일과 주간이 섞여도 최근에 놓친 것이 위로 온다") + void sortsByCompletedAtNotByPeriodKeyString() { + // 기간 키 문자열로 정렬하면 "2026-W37" 과 "2026-09-09" 가 여섯 번째 글자에서 갈려 + // 주간이 언제나 일일보다 앞선다. 3주 전 주간 미션이 어제 놓친 일일 미션보다 위로 올라간다. + insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(20)); + insertCompletedProgress( + user.getId(), DAILY_REVIEW_3, yesterdayKey(), LocalDateTime.now().minusDays(1)); + + MissionListResponseDto response = missionService.getMissions(user.getId()); + + assertThat(response.expired().missions()) + .extracting(MissionResponseDto::code) + .as("어제 놓친 일일 미션이 3주 전 주간 미션보다 위에 있어야 한다") + .containsExactly(DAILY_REVIEW_3, WEEKLY_REVIEW_30); + } + @Test @DisplayName("기간이 지나도 받을 수 있다") void canBeClaimed() { From 31045f5d3f7fff04b52db07eda243eece8f3028b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 17:45:59 +0900 Subject: [PATCH 062/130] =?UTF-8?q?[Refactor]=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EC=98=AC=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8D=98=20=EB=B2=94=EC=9A=A9=20=EB=AF=B8=EC=85=98=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=EC=A7=84=EC=9E=85=EC=A0=90=EC=9D=84=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - registerMissionLog 는 로그만 저장하고 진행도를 올리지 않았다. 다른 진입점들은 같은 중복 방지 판정 안에서 함께 올린다. 지금 호출자가 없어 실해는 없지만, 쓰는 순간 조용히 깨진다. 로그가 먼저 생겨 alreadyXxx 가 참이 되면 전용 메서드를 불러도 진행도는 영영 안 오른다 - 전용 진입점 네 개로 충분해서 되살리지 않고 지웠다. 같은 모양이 다시 생기지 않도록 테스트로 막았다 - 에러 코드 7001(MISSION_TYPE_NOT_FOUND)은 남겼다. 프론트에 공개된 코드 목록이라 빼지 않는다 --- .../mission/service/MissionLogService.java | 30 --------- .../service/MissionLogEntryPointTest.java | 56 +++++++++++++++++ .../service/MissionLogServiceTest.java | 62 ------------------- 3 files changed, 56 insertions(+), 92 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 3cfa5ecd..60196cbe 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -23,7 +23,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.jetbrains.annotations.NotNull; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -78,35 +77,6 @@ private User lockUser(Long userId) { .orElseThrow(() -> new ApplicationException(MissionErrorCase.USER_NOT_FOUND)); } - public Long registerMissionLog(@NotNull MissionRegisterDto missionRegisterDto) { - - Long userId = missionRegisterDto.userId(); - - // switch 가 MissionType 의 네 상수를 모두 다루고 있어 default 분기는 도달할 수 없었고, - // missionType 이 null 이면 switch 자체가 NPE 를 던져 400 이어야 할 입력 오류가 500 으로 나갔다. - // 잘못된 미션 종류는 MISSION_TYPE_NOT_FOUND(400) 로 거절한다. - if (missionRegisterDto.missionType() == null) { - throw new ApplicationException(MissionErrorCase.MISSION_TYPE_NOT_FOUND); - } - - User user = lockUser(userId); - - boolean canNotRegister = switch (missionRegisterDto.missionType()) { - case USER_LOGIN -> missionLogRepository.alreadyLogin(userId); - case PROBLEM_WRITE -> missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId); - case PROBLEM_PRACTICE -> missionLogRepository.alreadyPracticeProblem(missionRegisterDto.referenceId()); - case NOTE_PRACTICE -> missionLogRepository.alreadyPracticeNote(missionRegisterDto.referenceId()); - default -> throw new ApplicationException(MissionErrorCase.MISSION_TYPE_NOT_FOUND); - }; - - if(!canNotRegister) { - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); - missionLogRepository.save(missionLog); - } - - return 0L; - } - public void registerLoginMission(Long userId) { User user = lockUser(userId); boolean alreadyLogin = missionLogRepository.alreadyLogin(userId); diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java new file mode 100644 index 00000000..8639697d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 미션 적립 진입점이 하나도 빠짐없이 진행도를 함께 올리는지. + * + *

{@code registerMissionLog} 라는 범용 진입점이 있었다. 로그는 저장하는데 진행도는 올리지 않아, + * 누가 그걸 쓰는 순간 조용히 깨지는 구조였다. 로그가 먼저 생겨 {@code alreadyXxx} 가 참이 되면 + * 뒤이어 전용 메서드를 불러도 중복 방지에 걸려 진행도는 영영 오르지 않는다. + * 호출자가 없어 지웠고, 같은 모양이 다시 생기지 않도록 여기서 막는다. + */ +@DisplayName("미션 적립 진입점") +class MissionLogEntryPointTest extends MissionSystemTestSupport { + + @Autowired + private MissionLogService missionLogService; + + @Test + @DisplayName("진행도를 올리지 않는 범용 등록 진입점이 없다") + void hasNoGenericRegisterEntryPoint() { + boolean exists = Arrays.stream(MissionLogService.class.getDeclaredMethods()) + .map(Method::getName) + .anyMatch("registerMissionLog"::equals); + + assertThat(exists) + .as("범용 진입점을 되살리려면 중복 방지 판정 안에서 진행도도 함께 올려야 한다") + .isFalse(); + } + + @Test + @DisplayName("MissionLogService 가 직접 적립하는 진입점은 진행도도 함께 올린다") + void everyRegisterEntryPointRaisesProgress() { + User user = fixtures.createUser(); + + missionLogService.registerLoginMission(user.getId()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)).as("출석").isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)).as("주간 출석").isEqualTo(1); + + missionLogService.registerNotePracticeMission(user.getId(), 777L); + assertThat(currentOf(user.getId(), DAILY_PRACTICE_SET)).as("복습 세트 완료").isEqualTo(1); + assertThat(currentOf(user.getId(), WEEKLY_SET_3)).as("주간 세트").isEqualTo(1); + + // 오답노트 등록(PROBLEM_CREATED)과 복습 기록(SOLVE_RECORDED)은 MissionLogService 가 아니라 + // 각 도메인 서비스에서 올린다. 그쪽은 MissionProgressHookTest 가 실제 경로로 확인한다. + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java index 774859a8..ce24cccc 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java @@ -2,7 +2,6 @@ import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; -import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.entity.UserMissionStatus; @@ -18,7 +17,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; @@ -275,66 +273,6 @@ void rejectsUnknownUser() { } } - @Nested - @DisplayName("일반 미션 등록 진입점") - class RegisterMissionLog { - - @ParameterizedTest - @EnumSource(MissionType.class) - @DisplayName("모든 미션 종류를 등록할 수 있고 항상 0을 돌려준다") - void registersEveryMissionType(MissionType missionType) { - Long returned = missionLogService.registerMissionLog(MissionRegisterDto.builder() - .userId(user.getId()) - .missionType(missionType) - .referenceId(300L) - .build()); - - assertThat(returned) - .as("획득 포인트를 돌려주는 것처럼 보이지만 실제로는 항상 0이다") - .isEqualTo(0L); - assertThat(missionLogRepository.findAllByUserId(user.getId())) - .singleElement() - .satisfies(log -> assertThat(log.getMissionType()).isEqualTo(missionType)); - } - - @Test - @DisplayName("이미 수행한 미션은 다시 등록되지 않는다") - void skipsAlreadyDoneMission() { - MissionRegisterDto dto = MissionRegisterDto.builder() - .userId(user.getId()) - .missionType(MissionType.USER_LOGIN) - .build(); - - missionLogService.registerMissionLog(dto); - missionLogService.registerMissionLog(dto); - - assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); - } - - @Test - @DisplayName("미션 종류가 없으면 MISSION_TYPE_NOT_FOUND 로 거절한다") - void rejectsNullMissionType() { - MissionRegisterDto dto = MissionRegisterDto.builder() - .userId(user.getId()) - .build(); - - assertMissionError(MissionErrorCase.MISSION_TYPE_NOT_FOUND, - () -> missionLogService.registerMissionLog(dto)); - } - - @Test - @DisplayName("없는 사용자로 등록하면 USER_NOT_FOUND 로 거절한다") - void rejectsUnknownUser() { - MissionRegisterDto dto = MissionRegisterDto.builder() - .userId(999_999L) - .missionType(MissionType.USER_LOGIN) - .build(); - - assertMissionError(MissionErrorCase.USER_NOT_FOUND, - () -> missionLogService.registerMissionLog(dto)); - } - } - /** * 예전에는 이 자리에 "하루 200점 상한" 테스트가 있었다. 상한은 자동 적립을 막기 위한 장치였는데 * 자동 적립 자체가 없어져 막을 대상이 사라졌다. 같은 시나리오를 새 동작으로 옮겨 둔다. From 919304229a0642abfb2848cfdd8210451dc6cab3 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 17:45:59 +0900 Subject: [PATCH 063/130] =?UTF-8?q?[Fix]=20V31=C2=B7V32=20=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=A4=ED=96=89=EC=97=90=20=EC=95=88=EC=A0=84?= =?UTF-8?q?=ED=95=98=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 커넥션이 끊기면, 재기동 때 Duplicate key name / Duplicate column name 으로 앱이 뜨지 않는다. V29 에서 피해 간 위험이 이 둘에 남아 있었다 - MySQL 에는 CREATE INDEX IF NOT EXISTS 도 ADD COLUMN IF NOT EXISTS 도 없어, information_schema 를 보고 없을 때만 실행하도록 바꿨다. V29 의 IF NOT EXISTS 와 같은 결이다 --- ...31__add_mission_progress_claimed_index.sql | 23 +++++++++++++--- ...__add_mission_progress_reward_snapshot.sql | 26 ++++++++++++++++--- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql index eb6909c9..c440aa24 100644 --- a/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql +++ b/src/main/resources/db/migration/V31__add_mission_progress_claimed_index.sql @@ -8,6 +8,23 @@ -- claimed_at 이 같은 행끼리의 순서는 InnoDB 가 보조 인덱스 끝에 붙이는 기본키(id)가 갈라 주므로 -- 커서 페이지네이션의 동점 처리까지 이 인덱스 하나로 해결된다. -- --- 문장이 하나라 중간에 끊길 지점이 없다. 실패하면 인덱스가 만들어지지 않은 상태 그대로다. -CREATE INDEX idx_mission_progress_claimed - ON mission_progress (user_id, claimed_at); +-- V29 가 CREATE TABLE IF NOT EXISTS 로 재실행에 대비한 것과 같은 이유로 여기도 대비한다. +-- DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 커넥션이 끊기면, 재기동 때 이 파일이 다시 돌면서 +-- Duplicate key name 으로 앱이 뜨지 않는다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 +-- information_schema 를 보고 없을 때만 실행한다. + +SET @index_exists := ( + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'mission_progress' + AND index_name = 'idx_mission_progress_claimed' +); + +SET @ddl := IF(@index_exists = 0, + 'CREATE INDEX idx_mission_progress_claimed ON mission_progress (user_id, claimed_at)', + 'SELECT 1'); + +PREPARE create_claimed_index FROM @ddl; +EXECUTE create_claimed_index; +DEALLOCATE PREPARE create_claimed_index; diff --git a/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql index ffce3b08..263c0299 100644 --- a/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql +++ b/src/main/resources/db/migration/V32__add_mission_progress_reward_snapshot.sql @@ -8,7 +8,25 @@ -- 나중에 넣으면 이미 받은 행들을 소급해서 채워야 하는데, 그때의 보상값은 알 방법이 없다. -- -- 옛 행을 위해 nullable 로 둔다. 스냅샷이 비어 있으면 조회가 현재 정의로 폴백한다. --- 문장이 하나라 중간에 끊길 지점이 없다. -ALTER TABLE mission_progress - ADD COLUMN reward_type_snapshot VARCHAR(20) NULL, - ADD COLUMN reward_value_snapshot INT NULL; +-- +-- V29·V31 과 같은 이유로 재실행에 대비한다. ALTER 가 커밋된 뒤 이력이 남기 전에 끊기면 +-- 재기동 때 Duplicate column name 으로 앱이 뜨지 않는다. MySQL 에는 ADD COLUMN IF NOT EXISTS 가 없어 +-- information_schema 를 보고 없을 때만 실행한다. + +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'mission_progress' + AND column_name = 'reward_value_snapshot' +); + +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE mission_progress + ADD COLUMN reward_type_snapshot VARCHAR(20) NULL, + ADD COLUMN reward_value_snapshot INT NULL', + 'SELECT 1'); + +PREPARE add_reward_snapshot FROM @ddl; +EXECUTE add_reward_snapshot; +DEALLOCATE PREPARE add_reward_snapshot; From 06a00a1b81e4fa824415f5e5969b12bf3332dc48 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 17:45:59 +0900 Subject: [PATCH 064/130] =?UTF-8?q?[Fix]=20=EA=B8=B0=EA=B8=B0=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=EB=8C=80=EA=B0=80=20KST=20=EA=B0=80=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=A9=B4=20=EA=B8=B0=EB=B6=84=20=EB=AF=B8=EC=85=98?= =?UTF-8?q?=EC=9D=B4=20=EC=95=88=20=EC=98=A4=EB=A5=B4=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 요청의 date() 를 서버의 오늘(KST)과 비교하고 있었다. 기기 시간대가 다르면 사용자 기준 오늘이 서버 기준으로는 어제라, 해외 사용자는 이 미션이 영영 오르지 않았다 - 판정을 "오늘 만들어진 기분 기록이 이미 있는가"로 바꾼다. 어느 날짜에 남기든 하루에 한 번만 오르고 기기 시간대와 무관하다. 지난 날짜 여러 곳에 남겨도 한 번만 오른다는 성질도 그대로다 --- .../LearningCalendarMoodRepository.java | 9 ++++++ .../service/LearningCalendarService.java | 22 +++++++++----- .../service/MissionProgressHookTest.java | 30 ++++++++++++++----- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java index 2295e6bf..a2b75082 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/repository/LearningCalendarMoodRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -12,4 +13,12 @@ public interface LearningCalendarMoodRepository extends JpaRepository findByUserIdAndStudyDate(Long userId, LocalDate studyDate); List findAllByUserIdAndStudyDateBetween(Long userId, LocalDate startDate, LocalDate endDate); + + /** + * 오늘 이 사용자가 기분을 남긴 적이 있는지. 어느 날짜에 남겼는지가 아니라 언제 남겼는지를 본다. + * + *

미션 진행도를 하루 한 번만 올리기 위한 판정이다. 자세한 이유는 + * {@code LearningCalendarService.updateMood} 주석에 있다. + */ + boolean existsByUserIdAndCreatedAtBetween(Long userId, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java index ed792823..bf92e623 100644 --- a/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java +++ b/src/main/java/com/aisip/OnO/backend/learningcalendar/service/LearningCalendarService.java @@ -10,7 +10,6 @@ import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarMoodRepository; import com.aisip.OnO.backend.learningcalendar.repository.LearningCalendarQueryRepository; import com.aisip.OnO.backend.mission.entity.MissionMetric; -import com.aisip.OnO.backend.mission.service.MissionPeriodKey; import com.aisip.OnO.backend.mission.service.MissionProgressUpdater; import com.aisip.OnO.backend.util.redis.StreakCacheService; import lombok.RequiredArgsConstructor; @@ -113,16 +112,25 @@ public LearningCalendarMoodResponseDto updateMood(Long userId, LearningCalendarM throw new ApplicationException(LearningCalendarErrorCase.CALENDAR_RECORD_NOT_FOUND); } Optional existingMood = moodRepository.findByUserIdAndStudyDate(userId, request.date()); + + // 기분 미션은 "오늘 처음 기분을 남겼을 때" 한 번만 오른다. + // + // 판정 기준이 요청의 date() 가 아니라 기록이 만들어진 시각인 데는 이유가 있다. + // 예전에는 date() 가 서버의 오늘(KST)과 같은지를 봤는데, 그러면 기기 시간대가 KST 가 아닌 + // 해외 사용자는 자기 기준 오늘을 보내도 서버 기준으로는 어제라서 미션이 영영 오르지 않는다. + // 그렇다고 date() 검사를 빼면 지난 날짜 세 곳에 기분을 남기는 것만으로 오늘 진행도가 3 오른다. + // + // "오늘 만들어진 기분 기록이 이미 있는가"로 보면 둘 다 해결된다. 사용자가 어느 날짜에 남기든 + // 하루에 한 번만 오르고, 기기 시간대와 무관하다. 이모지만 바꾸는 경우도 새 기록이 아니라 오르지 않는다. + LocalDate today = LocalDate.now(KST); + boolean firstMoodOfToday = existingMood.isEmpty() && !moodRepository.existsByUserIdAndCreatedAtBetween( + userId, today.atStartOfDay(), today.atTime(END_OF_DAY)); + LearningCalendarMood mood = existingMood .orElseGet(() -> moodRepository.save(LearningCalendarMood.create(userId, request.date(), request.emojiKey()))); mood.updateEmojiKey(request.emojiKey()); - // 기분 미션은 "오늘 날짜에 처음 기분을 남겼을 때"만 오른다. - // - // 처음인지만 보고 올리면 이모지를 바꿀 때마다 올라 버튼 한 번으로 주간 미션까지 채울 수 있다. - // 오늘인지도 함께 봐야 한다. 중복 판정은 요청 날짜 기준인데 진행도는 서버의 오늘 키로 들어가므로, - // 지난 날짜 세 곳에 기분을 남기면 오늘 진행도가 3 오른다. - if (existingMood.isEmpty() && request.date().equals(MissionPeriodKey.today())) { + if (firstMoodOfToday) { missionProgressUpdater.increase(userId, MissionMetric.MOOD_LOGGED); } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java index 1f961cd9..12732002 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressHookTest.java @@ -230,19 +230,33 @@ void doesNotCountMoodChange() { } @Test - @DisplayName("과거 날짜에 기분을 남기면 오늘 미션은 오르지 않는다") - void doesNotCountPastDates() { + @DisplayName("여러 날짜에 기분을 남겨도 하루에 한 번만 오른다") + void countsOncePerDayNoMatterHowManyDates() { LocalDate today = MissionPeriodKey.today(); - for (int daysAgo = 1; daysAgo <= 3; daysAgo++) { - LocalDate pastDate = today.minusDays(daysAgo); - makeStudyDay(pastDate); + for (int daysAgo = 0; daysAgo <= 3; daysAgo++) { + LocalDate date = today.minusDays(daysAgo); + makeStudyDay(date); learningCalendarService.updateMood( - user.getId(), new LearningCalendarMoodRequestDto(pastDate, "cool_sunglasses")); + user.getId(), new LearningCalendarMoodRequestDto(date, "cool_sunglasses")); } assertThat(currentOf(user.getId(), DAILY_MOOD)) - .as("중복 판정은 요청 날짜 기준인데 진행도가 오늘 키로 들어가면 지난 날짜 수만큼 오늘이 오른다") - .isZero(); + .as("지난 날짜 네 곳에 기분을 남겼다고 오늘 진행도가 네 번 오르면 안 된다") + .isEqualTo(1); + } + + @Test + @DisplayName("기기 시간대가 서버와 달라 어제 날짜로 와도 오른다") + void countsWhenClientSendsYesterdayInItsOwnTimeZone() { + // 기기 시간대가 KST 가 아니면 사용자 기준 오늘이 서버 기준으로는 어제다. + // 요청 날짜를 서버의 오늘과 비교하면 해외 사용자는 이 미션이 영영 오르지 않는다. + LocalDate clientToday = MissionPeriodKey.today().minusDays(1); + makeStudyDay(clientToday); + + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(clientToday, "cool_sunglasses")); + + assertThat(currentOf(user.getId(), DAILY_MOOD)).isEqualTo(1); } /** From 8589b94e3261b34c2e4243eaf4bb7510ee9f186e Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 17:45:59 +0900 Subject: [PATCH 065/130] =?UTF-8?q?[Chore]=20=EB=AF=B8=EC=85=98=20?= =?UTF-8?q?=EC=A0=9C=EB=AA=A9=EA=B3=BC=20=EC=84=A4=EB=AA=85=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=EB=A5=BC=20=EB=8B=A4=EB=93=AC=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설명 9건을 전부 "~하기" 로 끝나는 행동 문구로 맞췄다 - 제목은 한글 수사로 통일하고, 무엇을 하라는 건지 안 읽히던 것을 동사형으로 바꿨다. 정확하게 -> 세 문제 맞히기, 꾸준함 -> 닷새 접속하기, 세트 완주 -> 복습 세트 완주, 서른 번의 복습 -> 서른 문제 복습 (설명이 "오답 30문제" 라 단위를 맞춘다) - V30 을 고치지 않고 V33 의 UPDATE 로 얹는다. 이미 적용된 환경이 있으면 체크섬이 어긋나 앱이 안 뜬다 - 테스트 시드 주입기가 V30 에 이어 V33 까지 순서대로 적용한다. 시드만 읽으면 나중에 고친 문구가 반영되지 않아 프로덕션과 다른 문구를 검증하게 된다 --- ...V33__refine_mission_definition_wording.sql | 26 ++++++ .../mission/MissionSeedMigrationTest.java | 90 ++++++++++++++++++- .../controller/MissionControllerTest.java | 2 +- .../support/MissionDefinitionSeeder.java | 38 +++++--- 4 files changed, 139 insertions(+), 17 deletions(-) create mode 100644 src/main/resources/db/migration/V33__refine_mission_definition_wording.sql diff --git a/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql b/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql new file mode 100644 index 00000000..a8733fc3 --- /dev/null +++ b/src/main/resources/db/migration/V33__refine_mission_definition_wording.sql @@ -0,0 +1,26 @@ +-- 미션 제목과 설명 문구 정리. +-- +-- V30 파일을 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 문구 변경은 새 마이그레이션의 UPDATE 로 얹는다. UPDATE 는 몇 번을 다시 돌려도 결과가 같다. +-- +-- 설명은 전부 "~하기" 로 끝나는 행동 문구로 맞췄다. 제목은 한글 수사로 통일하고, +-- 무엇을 하라는 건지 제목만으로 안 읽히던 것들을 동사형으로 바꿨다. +-- 정확하게 -> 세 문제 맞히기 (무엇을 정확하게 하라는 건지 읽히지 않았다) +-- 꾸준함 -> 닷새 접속하기 (같은 이유) +-- 세트 완주 -> 복습 세트 완주 (무엇의 세트인지 분명해진다) +-- 서른 번의 복습 -> 서른 문제 복습 (설명이 "오답 30문제" 라 단위를 맞춘다) + +UPDATE mission_definition SET description = '앱 접속하기' WHERE code = 'DAILY_ATTEND'; +UPDATE mission_definition SET description = '오답노트 1개 쓰기' WHERE code = 'DAILY_NOTE_WRITE'; +UPDATE mission_definition SET description = '오답 3문제 복습하기' WHERE code = 'DAILY_REVIEW_3'; +UPDATE mission_definition SET description = '복습에서 3문제 맞히기' WHERE code = 'DAILY_CORRECT_3'; +UPDATE mission_definition SET description = '복습 세트 1개 끝내기' WHERE code = 'DAILY_PRACTICE_SET'; +UPDATE mission_definition SET description = '오늘 기분 남기기' WHERE code = 'DAILY_MOOD'; +UPDATE mission_definition SET description = '이번 주 5일 접속하기' WHERE code = 'WEEKLY_ATTEND_5'; +UPDATE mission_definition SET description = '오답노트 10개 쓰기' WHERE code = 'WEEKLY_NOTE_10'; +UPDATE mission_definition SET description = '오답 30문제 복습하기' WHERE code = 'WEEKLY_REVIEW_30'; + +UPDATE mission_definition SET title = '세 문제 맞히기' WHERE code = 'DAILY_CORRECT_3'; +UPDATE mission_definition SET title = '복습 세트 완주' WHERE code = 'DAILY_PRACTICE_SET'; +UPDATE mission_definition SET title = '닷새 접속하기' WHERE code = 'WEEKLY_ATTEND_5'; +UPDATE mission_definition SET title = '서른 문제 복습' WHERE code = 'WEEKLY_REVIEW_30'; diff --git a/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java index 2d7aeb0f..8602b41e 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java @@ -4,6 +4,8 @@ import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; import org.springframework.util.StreamUtils; @@ -12,6 +14,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; import java.util.Locale; import java.util.stream.Collectors; @@ -29,10 +32,16 @@ class MissionSeedMigrationTest extends MissionSystemTestSupport { private static final String DDL_MIGRATION = "db/migration/V29__create_mission_system.sql"; private static final String SEED_MIGRATION = "db/migration/V30__seed_mission_definitions.sql"; + private static final String CLAIMED_INDEX_MIGRATION = "db/migration/V31__add_mission_progress_claimed_index.sql"; + private static final String REWARD_SNAPSHOT_MIGRATION = "db/migration/V32__add_mission_progress_reward_snapshot.sql"; + private static final String WORDING_MIGRATION = "db/migration/V33__refine_mission_definition_wording.sql"; @Autowired private MissionDefinitionSeeder missionDefinitionSeeder; + @PersistenceContext + private EntityManager entityManager; + @Test @DisplayName("DDL 파일에는 시드가 섞여 있지 않다") void ddlMigrationHasNoSeed() { @@ -58,6 +67,60 @@ void seedMigrationIsRerunnable() { assertThat(statementsOf(SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); } + @Test + @DisplayName("인덱스와 컬럼 추가도 다시 돌려도 안전하게 쓰여 있다") + void schemaChangeMigrationsAreRerunnable() { + // MySQL 에는 CREATE INDEX IF NOT EXISTS 도 ADD COLUMN IF NOT EXISTS 도 없다. + // DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 끊기면 재기동 때 Duplicate 로 앱이 뜨지 않는다. + for (String migration : List.of(CLAIMED_INDEX_MIGRATION, REWARD_SNAPSHOT_MIGRATION)) { + assertThat(statementsOf(migration)) + .as("%s 가 existence 검사 없이 DDL 을 바로 실행한다", migration) + .contains("INFORMATION_SCHEMA"); + } + } + + @Test + @DisplayName("이미 적용된 스키마에 인덱스·컬럼 마이그레이션을 다시 돌려도 터지지 않는다") + void rerunningSchemaMigrationsDoesNotFail() { + // 테스트 DB 에는 이 인덱스와 컬럼이 이미 있다. 그 위에 두 번 더 돌려 본다. + for (int attempt = 0; attempt < 2; attempt++) { + runMigration(CLAIMED_INDEX_MIGRATION); + runMigration(REWARD_SNAPSHOT_MIGRATION); + } + + assertThat(missionProgressRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("문구 수정은 V30 을 고치지 않고 새 마이그레이션의 UPDATE 로 얹는다") + void wordingIsAppliedAsUpdate() { + // V30 을 고치면 이미 적용된 환경에서 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. + String wording = statementsOf(WORDING_MIGRATION); + + assertThat(wording).contains("UPDATE MISSION_DEFINITION"); + assertThat(wording).doesNotContain("INSERT INTO"); + } + + @Test + @DisplayName("정리한 문구가 그대로 내려간다") + void appliesRefinedWording() { + assertThat(definitionOf("DAILY_ATTEND").getDescription()).isEqualTo("앱 접속하기"); + assertThat(definitionOf("DAILY_NOTE_WRITE").getDescription()).isEqualTo("오답노트 1개 쓰기"); + assertThat(definitionOf("DAILY_REVIEW_3").getDescription()).isEqualTo("오답 3문제 복습하기"); + assertThat(definitionOf("DAILY_CORRECT_3").getDescription()).isEqualTo("복습에서 3문제 맞히기"); + assertThat(definitionOf("DAILY_PRACTICE_SET").getDescription()).isEqualTo("복습 세트 1개 끝내기"); + assertThat(definitionOf("DAILY_MOOD").getDescription()).isEqualTo("오늘 기분 남기기"); + assertThat(definitionOf("WEEKLY_ATTEND_5").getDescription()).isEqualTo("이번 주 5일 접속하기"); + assertThat(definitionOf("WEEKLY_NOTE_10").getDescription()).isEqualTo("오답노트 10개 쓰기"); + assertThat(definitionOf("WEEKLY_REVIEW_30").getDescription()).isEqualTo("오답 30문제 복습하기"); + assertThat(definitionOf("WEEKLY_SET_3").getDescription()).isEqualTo("복습 세트 3개 끝내기"); + + assertThat(definitionOf("DAILY_CORRECT_3").getTitle()).isEqualTo("세 문제 맞히기"); + assertThat(definitionOf("DAILY_PRACTICE_SET").getTitle()).isEqualTo("복습 세트 완주"); + assertThat(definitionOf("WEEKLY_ATTEND_5").getTitle()).isEqualTo("닷새 접속하기"); + assertThat(definitionOf("WEEKLY_REVIEW_30").getTitle()).isEqualTo("서른 문제 복습"); + } + @Test @DisplayName("시드를 두 번 실행해도 정의는 10종 그대로다") void seedIsIdempotent() { @@ -72,12 +135,31 @@ void seedIsIdempotent() { .hasSize(6); } - /** 주석을 걷어낸 실행 문장만. 주석에 적힌 설명이 단언에 걸리면 안 된다. */ - private String statementsOf(String path) { + /** 마이그레이션의 실행 문장을 순서대로 돌린다. */ + private void runMigration(String path) { + transactionTemplate.executeWithoutResult(status -> { + for (String statement : executableStatements(path)) { + entityManager.createNativeQuery(statement).executeUpdate(); + } + }); + } + + private List executableStatements(String path) { + return Arrays.stream(withoutComments(path).split(";")) + .map(String::trim) + .filter(statement -> !statement.isEmpty()) + .toList(); + } + + private String withoutComments(String path) { return Arrays.stream(read(path).split("\\R")) .filter(line -> !line.trim().startsWith("--")) - .collect(Collectors.joining("\n")) - .toUpperCase(Locale.ROOT); + .collect(Collectors.joining("\n")); + } + + /** 주석을 걷어낸 실행 문장만. 주석에 적힌 설명이 단언에 걸리면 안 된다. */ + private String statementsOf(String path) { + return withoutComments(path).toUpperCase(Locale.ROOT); } private String read(String path) { diff --git a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java index a3ecada4..ba5178c3 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java @@ -41,7 +41,7 @@ void getMissions() throws Exception { .andExpect(jsonPath("$.data.weekly.missions.length()").value(4)) .andExpect(jsonPath("$.data.daily.missions[1].code").value(DAILY_NOTE_WRITE)) .andExpect(jsonPath("$.data.daily.missions[1].title").value("오늘의 오답")) - .andExpect(jsonPath("$.data.daily.missions[1].description").value("오답노트 1개 등록")) + .andExpect(jsonPath("$.data.daily.missions[1].description").value("오답노트 1개 쓰기")) .andExpect(jsonPath("$.data.daily.missions[1].iconKey").value("note_write")) .andExpect(jsonPath("$.data.daily.missions[1].category").value("DAILY")) .andExpect(jsonPath("$.data.daily.missions[1].current").value(1)) diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java index 2890a48e..4b40213f 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionDefinitionSeeder.java @@ -22,7 +22,7 @@ * 저절로 실행되지 않는데, 미션 조회·진행도는 정의가 없으면 아무것도 하지 않으므로 * 시드가 없으면 이 도메인 테스트는 전부 무의미해진다. * - *

시드 내용을 테스트에 다시 적지 않고 시드 마이그레이션(V30)의 INSERT 문을 그대로 읽어 실행한다. + *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 INSERT/UPDATE 문을 그대로 읽어 실행한다. * 같은 값을 두 곳에 적으면 한쪽만 고쳐질 때 테스트가 프로덕션 시드와 다른 것을 검증하게 된다. * *

{@code DatabaseCleaner} 가 테스트마다 모든 테이블을 비우므로 매 테스트 시작 시 다시 넣어야 한다. @@ -30,25 +30,36 @@ @Component public class MissionDefinitionSeeder { - private static final String MIGRATION_PATH = "db/migration/V30__seed_mission_definitions.sql"; + /** + * 시드와 그 뒤에 얹힌 문구 수정까지 적용 순서대로 담는다. + * + *

시드 마이그레이션 하나만 읽으면 나중에 UPDATE 로 고친 문구가 테스트에 반영되지 않아, + * 테스트가 프로덕션과 다른 문구를 검증하게 된다. 정의를 손대는 마이그레이션을 새로 만들면 여기에 더한다. + */ + private static final List MIGRATION_PATHS = List.of( + "db/migration/V30__seed_mission_definitions.sql", + "db/migration/V33__refine_mission_definition_wording.sql" + ); @PersistenceContext private EntityManager entityManager; - private List insertStatements; + private List statements; @Transactional public void seed() { - if (insertStatements == null) { - insertStatements = loadInsertStatements(); + if (statements == null) { + statements = MIGRATION_PATHS.stream() + .flatMap(path -> loadStatements(path).stream()) + .toList(); } - for (String statement : insertStatements) { + for (String statement : statements) { entityManager.createNativeQuery(statement).executeUpdate(); } } - private List loadInsertStatements() { - String script = readMigration(); + private List loadStatements(String path) { + String script = readMigration(path); // 주석 줄에도 세미콜론이 들어갈 수 있어 먼저 걷어낸다. String withoutComments = Arrays.stream(script.split("\\R")) @@ -58,15 +69,18 @@ private List loadInsertStatements() { return Arrays.stream(withoutComments.split(";")) .map(String::trim) - .filter(statement -> statement.toUpperCase(Locale.ROOT).startsWith("INSERT")) + .filter(statement -> { + String upper = statement.toUpperCase(Locale.ROOT); + return upper.startsWith("INSERT") || upper.startsWith("UPDATE"); + }) .toList(); } - private String readMigration() { - try (InputStream inputStream = new ClassPathResource(MIGRATION_PATH).getInputStream()) { + private String readMigration(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); } catch (IOException e) { - throw new UncheckedIOException("미션 시드 마이그레이션을 읽지 못했다: " + MIGRATION_PATH, e); + throw new UncheckedIOException("미션 시드 마이그레이션을 읽지 못했다: " + path, e); } } } From 0f279aba47507ee252697bdb4e8be3cfa36ecdcf Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 18:04:24 +0900 Subject: [PATCH 066/130] =?UTF-8?q?[Feat]=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EB=A6=BD=EC=9D=84=20=EC=84=A4=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=BC=9C=EA=B3=A0=20=EB=81=8C=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= =?UTF-8?q?=20=EB=90=98=EC=82=B4=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ono.mission.legacy-accrual.enabled, 기본값 켜짐. 운영 설정을 건드리지 않으면 지금과 똑같이 동작한다 - 자동 적립을 걷어낸 채로 백엔드만 배포하면 프론트에 미션 화면이 없는 동안 XP 유입이 통째로 멈춘다. 레벨도 스터디룸 랭킹도 정지한다. 켜 둔 채 배포하면 사용자 입장에서 달라지는 게 없고, 화면이 없어 미션을 받을 수도 없으니 이중 지급도 없다. 프론트가 나간 뒤 끄면 흡수가 끝난다 - 각 단계가 되돌릴 수 있다. 끄고 나서 문제가 생기면 플래그만 다시 켜면 된다 - 꺼져도 mission_log 저장, 중복 방지 판정, 진행도 증가는 그대로 돈다. 꺼지는 것은 지급 하나뿐이다 - 하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 전과 같이 상한 밖이다 --- .../mission/service/MissionLogService.java | 73 +++++++++++++++++-- .../mission/service/MissionRewardGranter.java | 10 ++- 2 files changed, 72 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 60196cbe..3b8ad423 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -23,6 +23,7 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -33,14 +34,21 @@ /** * 사용자의 행동 기록({@code mission_log})을 남긴다. * - *

여기서 XP 를 주지 않는다. 예전에는 행동할 때마다 자동으로 경험치가 들어갔는데, - * 미션 보상과 합쳐져 같은 XP 가 두 경로로 들어왔다. 오답노트를 하나 쓰면 자동 적립 +10 이 조용히 들어가고 - * 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, 화면의 {@code +10 XP} 와 실제 증가량 20 이 어긋났다. - * 게다가 하루 200점 상한은 자동 적립에만 걸려 같은 이름의 XP 가 출처에 따라 다른 규칙으로 움직였다. - * XP 는 미션을 받을 때만 들어온다. 지급은 {@link MissionRewardGranter} 한 곳뿐이다. + *

자동 적립은 설정으로 켜고 끈다. {@code ono.mission.legacy-accrual.enabled} 이고 기본값은 켜짐이다. * - *

대신 기록은 그대로 남긴다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. - * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 그대로다. + *

최종 목표는 끄는 것이다. 켜져 있으면 같은 XP 가 두 경로로 들어온다. 오답노트를 하나 쓰면 + * 자동 적립 +10 이 조용히 들어가고 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, + * 화면의 {@code +10 XP} 와 실제 증가량 20 이 어긋난다. 하루 200점 상한도 자동 적립에만 걸려 + * 같은 이름의 XP 가 출처에 따라 다른 규칙으로 움직인다. + * + *

그런데도 기본값을 켜짐으로 두는 이유는 배포 순서 때문이다. 백엔드가 먼저 나가고 프론트에 + * 미션 화면이 없는 동안 자동 적립까지 꺼져 있으면 XP 유입이 통째로 멈춰 레벨도 스터디룸 랭킹도 정지한다. + * 켜 둔 채로 백엔드를 배포하면 사용자 입장에서는 달라지는 게 없고, 화면이 없으니 미션을 받을 수도 없어 + * 이중 지급도 일어나지 않는다. 프론트가 나간 뒤 설정으로 끄면 흡수가 끝난다. 각 단계가 되돌릴 수 있다. + * + *

꺼져도 기록은 그대로 남는다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. + * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 플래그와 무관하게 돈다. + * 꺼지는 것은 포인트 지급 하나뿐이다. */ @Service @RequiredArgsConstructor @@ -55,6 +63,17 @@ public class MissionLogService { private final MissionProgressUpdater missionProgressUpdater; + /** + * 행동만으로 경험치를 주던 예전 적립을 계속 쓸지. + * + *

기본값이 켜짐이라 설정을 건드리지 않으면 지금 운영과 똑같이 동작한다. + * 끄는 것은 프론트에 미션 화면이 나간 뒤의 별도 결정이다. + */ + @Value("${ono.mission.legacy-accrual.enabled:true}") + private boolean legacyAccrualEnabled; + + private static final Long DAILY_MISSION_POINT_LIMIT = 200L; + /** * 미션 적립 전에 사용자 행을 먼저 잠근다. * @@ -91,6 +110,8 @@ public void registerLoginMission(Long userId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); + addPointToUser(user, missionLog); + // 출석 미션 진행도. 기존 적립 규칙은 그대로 두고, "오늘 첫 로그인" 판정만 그대로 빌려 쓴다. // 이 분기 밖에서 올리면 앱을 열 때마다 주간 출석 미션이 하루에 5까지 차 버린다. missionProgressUpdater.increase(userId, MissionMetric.LOGIN_DAY); @@ -110,6 +131,8 @@ public void registerProblemWriteMission(Long userId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); + + addPointToUser(user, missionLog); } } @@ -127,6 +150,7 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { for (int i = 0; i < toCreate; i++) { MissionLog log = MissionLog.from(dto, user); missionLogRepository.save(log); + addPointToUser(user, log); } } @@ -144,6 +168,8 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); + + addPointToUser(user, missionLog); } } @@ -162,12 +188,45 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { MissionLog missionLog = MissionLog.from(missionRegisterDto, user); missionLogRepository.save(missionLog); + addPointToUser(user, missionLog); + // 세트 완료 미션 진행도. 출석과 같은 이유로 기존 중복 방지 가드 안에 둔다. // 밖에서 올리면 같은 세트에 완료 요청을 세 번 보내는 것만으로 주간 세트 미션이 채워진다. missionProgressUpdater.increase(userId, MissionMetric.PRACTICE_NOTE_COMPLETED); } } + /** + * 행동 자체에 대한 자동 적립. + * + *

플래그가 꺼져 있으면 아무것도 하지 않는다. 이 메서드만 비는 것이지 호출부의 기록 저장과 + * 중복 방지 판정, 진행도 증가는 그대로 돈다. + * + *

하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 + * 따로 지급하고 상한을 타지 않는다. + */ + private Long addPointToUser(User user, MissionLog missionLog) { + if (!legacyAccrualEnabled) { + return 0L; + } + + Long pointToday = missionLogRepository.getPointSumToday(user.getId()); + if (pointToday <= DAILY_MISSION_POINT_LIMIT) { + Long point = Math.min(missionLog.getPoint(), DAILY_MISSION_POINT_LIMIT - pointToday); + + // 미션 타입에 따라 적절한 능력치에 경험치 적용 + switch (missionLog.getMissionType().getAbilityType()) { + case ATTENDANCE -> user.getUserMissionStatus().gainAttendancePoint(point); + case NOTE_WRITE -> user.getUserMissionStatus().gainNoteWritePoint(point); + case PROBLEM_PRACTICE -> user.getUserMissionStatus().gainProblemPracticePoint(point); + case NOTE_PRACTICE -> user.getUserMissionStatus().gainNotePracticePoint(point); + } + + return point; + } + return 0L; + } + @Transactional(readOnly = true) public List findAllByUserId(Long userId) { return missionLogRepository.findAllByUserId(userId); diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java index 3f643069..1cb51b07 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -14,10 +14,12 @@ /** * 미션 보상 지급. * - *

XP 가 들어오는 유일한 경로다. 예전에는 행동할 때마다 {@code MissionLogService} 가 자동으로 - * 경험치를 주고 하루 200점 상한이 그것만 눌렀다. 같은 XP 가 두 경로로 들어오면서 화면의 숫자와 - * 실제 증가량이 어긋났고, 출처에 따라 상한 적용도 달랐다. 자동 적립을 걷어내 지급을 여기 하나로 모았다. - * 하루 상한은 미션 설계가 대신한다. 일일 6종을 다 받아도 75, 주간 4종을 다 받아도 360 이다. + *

미션 보상은 하루 200점 상한을 타지 않는다. 상한은 {@code MissionLogService} 의 자동 적립에만 + * 있는 규칙이고, 미션 보상은 그 위에 얹는 별도 경로다. 상한 역할은 미션 설계가 대신한다. + * 일일 6종을 다 받아도 75, 주간 4종을 다 받아도 360 이다. + * + *

자동 적립({@code ono.mission.legacy-accrual.enabled})을 끄면 여기가 XP 가 들어오는 유일한 경로가 된다. + * 켜져 있는 동안에는 두 경로가 함께 도는데, 그건 "더 받는" 것이라 고장이 아니다. * *

능력치 경험치 반영은 엔티티를 읽어 고치는 방식이라 같은 사용자의 동시 지급에 취약하다. * 기존 적립 경로와 같은 이유로 사용자 행을 배타 잠금으로 먼저 잡아 직렬화한다. From 503cf29efad5eb129086c8c94f883fe9ed4646be Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 18:04:24 +0900 Subject: [PATCH 067/130] =?UTF-8?q?[Test]=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EB=A6=BD=20=ED=94=8C=EB=9E=98=EA=B7=B8=20=EC=BC=9C=EC=A7=90/?= =?UTF-8?q?=EA=BA=BC=EC=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MissionLegacyAccrualTest 로 양쪽을 한자리에서 잠근다. 켜짐: 행동만으로 경험치가 들어오고 진행도도 오른다, 하루 200점 상한이 걸린다, 상한을 채워도 미션 보상은 그대로 들어온다 꺼짐: 경험치 0, mission_log 행은 생김, 진행도와 중복 방지는 그대로, 미션을 받아야만 오른다 기본값: 필드 값이 아니라 @Value 선언을 확인한다. 값으로 보면 테스트가 값을 바꿔 가며 돌아 순환 논증이다 - 기존 테스트가 어느 상태를 검증하는지 명시했다. MissionLogServiceTest 와 MissionRewardConcurrencyTest 는 켜짐(운영 기본), MissionLogRetentionTest 는 꺼짐이다. 흡수 작업 때 0 으로 바꿨던 단언들은 켜짐 기준 원래 값으로 되돌렸다 - 플래그 헬퍼는 레포 관례대로 ReflectionTestUtils 를 쓴다. 스프링 컨텍스트를 새로 띄우지 않는다. @Transactional 프록시라 대상 객체를 꺼내서 넣고, 앞뒤로 기본값을 되돌려 다른 테스트를 오염시키지 않는다 --- .../MissionRewardConcurrencyTest.java | 17 +- .../service/MissionLegacyAccrualTest.java | 162 ++++++++++++++++++ .../service/MissionLogEntryPointTest.java | 4 - .../service/MissionLogRetentionTest.java | 10 +- .../service/MissionLogServiceTest.java | 124 +++++++------- .../mission/support/MissionTestSupport.java | 37 ++++ 6 files changed, 274 insertions(+), 80 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java index 4aee9247..21dfe79e 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/MissionRewardConcurrencyTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import static org.assertj.core.api.Assertions.assertThat; @@ -35,13 +34,13 @@ class MissionRewardConcurrencyTest extends MissionTestSupport { private static final int THREAD_COUNT = 8; - @Autowired - private MissionLogService missionLogService; - private User user; @BeforeEach void setUpUser() { + // 자동 적립이 켜진 상태의 동시성을 본다. 이 테스트가 지키는 것은 잠금이지 지급 규칙이 아니지만, + // 경험치 단언이 있어 어느 상태를 재는지 명시해 둔다. + setLegacyAccrual(true); user = fixtures.createUser(); } @@ -60,8 +59,8 @@ void loginMissionIsGrantedOnce() { .as("출석 기록은 하루 한 건") .isEqualTo(1); assertThat(attendancePoints()) - .as("자동 적립을 걷어낸 뒤로 행동만으로는 경험치가 들어오지 않는다") - .isZero(); + .as("출석 경험치도 한 번치만 들어와야 한다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); } @Test @@ -105,11 +104,11 @@ void problemWriteMissionStopsAtDailyCap() { assertThat(outcome.serverErrors()).isEmpty(); assertThat(countLogs(MissionType.PROBLEM_WRITE)) - .as("하루 상한 3건까지만 기록된다") + .as("하루 상한 3건까지만 적립된다") .isEqualTo(3); assertThat(noteWritePoints()) - .as("경험치는 미션을 받을 때만 들어온다") - .isZero(); + .as("경험치도 3건치까지만") + .isEqualTo(3 * MissionType.PROBLEM_WRITE.getPoint()); } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java new file mode 100644 index 00000000..8bdf5771 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java @@ -0,0 +1,162 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 자동 적립 플래그({@code ono.mission.legacy-accrual.enabled})의 켜짐/꺼짐 동작. + * + *

백엔드가 프론트보다 먼저 나가도 지장이 없어야 해서 둔 스위치다. 켜 두면 지금 운영과 똑같이 돌고, + * 프론트에 미션 화면이 나간 뒤 끄면 흡수가 끝난다. 되돌리려면 다시 켜면 된다. + * + *

플래그가 무엇을 끄고 무엇을 끄지 않는지가 이 클래스의 주제다. + * 꺼지는 것은 포인트 지급 하나뿐이고, 기록과 중복 방지와 진행도는 양쪽에서 똑같이 돈다. + */ +@DisplayName("자동 적립 플래그") +class MissionLegacyAccrualTest extends MissionSystemTestSupport { + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @Test + @DisplayName("설정을 건드리지 않으면 켜짐이다") + void defaultsToEnabled() throws NoSuchFieldException { + // 기본값이 꺼짐이면 백엔드만 배포하는 순간 XP 유입이 통째로 멈춘다. + // 필드 값이 아니라 선언을 본다. 테스트가 값을 바꿔 가며 도는데 값으로 확인하면 순환 논증이다. + Field field = MissionLogService.class.getDeclaredField("legacyAccrualEnabled"); + + assertThat(field.getAnnotation(Value.class).value()) + .as("프로퍼티 이름과 기본값은 운영에서 끌 때 쓰는 계약이다") + .isEqualTo("${ono.mission.legacy-accrual.enabled:true}"); + } + + @Nested + @DisplayName("켜져 있으면") + class Enabled { + + @BeforeEach + void enable() { + setLegacyAccrual(true); + } + + @Test + @DisplayName("행동만으로 경험치가 들어오고 진행도도 함께 오른다") + void grantsPointAndRaisesProgress() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(accumulatedPoints(status().getAttendanceLevel(), status().getAttendancePoint())) + .as("출석 자동 적립") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)) + .as("진행도는 플래그와 무관하게 오른다") + .isEqualTo(1); + } + + @Test + @DisplayName("하루 200점 상한이 걸린다") + void capsDailyGainAtTwoHundred() { + for (int i = 1; i <= 14; i++) { + missionLogService.registerNotePracticeMission(user.getId(), (long) i); + } + + assertThat(accumulatedNotePracticePoints(user)) + .as(""" + 상한 판정은 오늘 쌓인 mission_log.point 합으로 한다. + 13번째에 이미 195점이 쌓여 남은 5점만 받고, 14번째는 210점을 넘겨 0점이다. + 그래서 12*15 + 5 = 185 에서 멈춘다.""") + .isEqualTo(185L); + } + + @Test + @DisplayName("상한을 채워도 미션 보상은 그대로 들어온다") + void missionRewardIgnoresDailyCap() { + for (int i = 1; i <= 14; i++) { + missionLogService.registerNotePracticeMission(user.getId(), (long) i); + } + long before = problemPracticePoints(); + + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + missionService.claim(user.getId(), progress.getId()); + + assertThat(problemPracticePoints() - before) + .as("상한은 자동 적립에만 있는 규칙이다. 미션 보상은 별도 경로다") + .isEqualTo(15L); + } + } + + @Nested + @DisplayName("꺼져 있으면") + class Disabled { + + @BeforeEach + void disable() { + setLegacyAccrual(false); + } + + @Test + @DisplayName("행동해도 경험치는 오르지 않는다") + void grantsNoPoint() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(status().getTotalStudyPoint()).isZero(); + } + + @Test + @DisplayName("경험치를 안 줘도 기록은 그대로 남는다") + void stillWritesMissionLog() { + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerNotePracticeMission(user.getId(), 1L); + + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("관리자 통계가 이 행을 읽는다. 꺼지는 것은 지급뿐이다") + .hasSize(2); + } + + @Test + @DisplayName("진행도는 그대로 오르고 중복 방지도 그대로 동작한다") + void stillRaisesProgressAndGuardsDuplicates() { + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isEqualTo(1); + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("하루에 한 번만 기록된다") + .hasSize(1); + } + + @Test + @DisplayName("미션을 받아야만 경험치가 오른다") + void onlyMissionClaimGrantsPoint() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + assertThat(problemPracticePoints()).isZero(); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(problemPracticePoints()).isEqualTo(15L); + } + } + + private UserMissionStatus status() { + return reload(user).getUserMissionStatus(); + } + + private long problemPracticePoints() { + return accumulatedPoints(status().getProblemPracticeLevel(), status().getProblemPracticePoint()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java index 8639697d..b6bd9514 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogEntryPointTest.java @@ -4,7 +4,6 @@ import com.aisip.OnO.backend.user.entity.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; import java.lang.reflect.Method; import java.util.Arrays; @@ -22,9 +21,6 @@ @DisplayName("미션 적립 진입점") class MissionLogEntryPointTest extends MissionSystemTestSupport { - @Autowired - private MissionLogService missionLogService; - @Test @DisplayName("진행도를 올리지 않는 범용 등록 진입점이 없다") void hasNoGenericRegisterEntryPoint() { diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java index 8913bc25..6b8f224b 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogRetentionTest.java @@ -34,18 +34,15 @@ import static org.assertj.core.api.Assertions.assertThat; /** - * 자동 적립을 걷어낸 뒤에도 행동 기록은 그대로 남는지. + * 자동 적립을 껐을 때도 행동 기록은 그대로 남는지. * - *

이번 변경에서 제일 위험한 지점이다. 지급을 떼어내면서 {@code mission_log} 저장까지 같이 없애면 + *

흡수가 끝난 상태에서 제일 위험한 지점이다. 지급을 끄면서 {@code mission_log} 저장까지 같이 멎으면 * DAU·순 방문자·복습 로그가 전부 0 이 된다. 관리자 화면 7곳이 이 테이블 하나를 읽는다. * 그래서 "행은 계속 만들어진다"와 "XP 는 받을 때만 들어온다"를 한자리에서 잠근다. */ -@DisplayName("자동 적립 폐지 후 기록 보존") +@DisplayName("자동 적립 꺼짐 - 기록 보존") class MissionLogRetentionTest extends MissionSystemTestSupport { - @Autowired - private MissionLogService missionLogService; - @Autowired private ProblemService problemService; @@ -66,6 +63,7 @@ class MissionLogRetentionTest extends MissionSystemTestSupport { @BeforeEach void setUpUser() { + setLegacyAccrual(false); user = fixtures.createUser(); folder = fixtures.createRootFolder(user.getId()); } diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java index ce24cccc..1d0ae076 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLogServiceTest.java @@ -28,12 +28,15 @@ import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; -@DisplayName("MissionLogService") +/** + * 자동 적립이 켜져 있을 때의 동작. 설정을 건드리지 않은 기본 상태이고, 지금 운영이 이 상태다. + * + *

꺼졌을 때의 동작은 {@code MissionLegacyAccrualTest} 와 {@code MissionLogRetentionTest} 가 본다. + * 기본값에 기대지 않고 플래그를 명시적으로 세워, 기본값이 바뀌어도 이 클래스가 무엇을 검증하는지 흔들리지 않게 한다. + */ +@DisplayName("MissionLogService - 자동 적립 켜짐") class MissionLogServiceTest extends MissionTestSupport { - @Autowired - private MissionLogService missionLogService; - @Autowired private PracticeNoteRepository practiceNoteRepository; @@ -42,6 +45,7 @@ class MissionLogServiceTest extends MissionTestSupport { @BeforeEach void setUpUsers() { + setLegacyAccrual(true); user = fixtures.createUser(); other = fixtures.createOtherUser(); } @@ -67,29 +71,27 @@ private PracticeNote savePracticeNote(Long userId, String title) { class LoginMission { @Test - @DisplayName("첫 로그인이면 기록을 남긴다 - 경험치는 주지 않는다") - void recordsFirstLoginWithoutGrantingPoint() { + @DisplayName("첫 로그인이면 기록을 남기고 출석 경험치를 준다") + void grantsAttendancePointOnFirstLogin() { missionLogService.registerLoginMission(user.getId()); assertThat(missionLogRepository.findAllByUserId(user.getId())) - .as("DAU·순 방문자 집계가 이 행을 읽는다. 행이 사라지면 지표가 통째로 0 이 된다") .singleElement() .satisfies(log -> { assertThat(log.getMissionType()).isEqualTo(MissionType.USER_LOGIN); - assertThat(log.getPoint()) - .as("point 컬럼은 그대로 채운다. 집계와 관리자 화면이 읽는 값이다") - .isEqualTo(15L); + assertThat(log.getPoint()).isEqualTo(15L); }); assertThat(statusOf(user)).satisfies(status -> { assertThat(accumulatedPoints(status.getAttendanceLevel(), status.getAttendancePoint())) - .as("XP 는 미션을 받을 때만 들어온다") - .isZero(); - assertThat(status.getTotalStudyPoint()).isZero(); + .isEqualTo(15L); + assertThat(status.getTotalStudyPoint()) + .as("출석 경험치는 총 학습 포인트에도 합산된다") + .isEqualTo(15L); }); } @Test - @DisplayName("같은 날 다시 로그인해도 기록이 늘지 않는다") + @DisplayName("같은 날 다시 로그인해도 기록도 경험치도 늘지 않는다") void ignoresSecondLoginOfSameDay() { missionLogService.registerLoginMission(user.getId()); missionLogService.registerLoginMission(user.getId()); @@ -98,7 +100,7 @@ void ignoresSecondLoginOfSameDay() { assertThat(missionLogRepository.findAllByUserId(user.getId())) .as("하루에 여러 번 로그인해도 출석은 한 번이다") .hasSize(1); - assertThat(statusOf(user).getTotalStudyPoint()).isZero(); + assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); } @Test @@ -119,9 +121,8 @@ void isolatesUsers() { missionLogService.registerLoginMission(user.getId()); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); - assertThat(missionLogRepository.findAllByUserId(other.getId())).hasSize(1); - assertThat(statusOf(user).getTotalStudyPoint()).isZero(); - assertThat(statusOf(other).getTotalStudyPoint()).isZero(); + assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); + assertThat(statusOf(other).getTotalStudyPoint()).isEqualTo(15L); } @Test @@ -137,8 +138,8 @@ void rejectsUnknownUser() { class ProblemWriteMission { @Test - @DisplayName("하루 세 번까지 기록을 남긴다") - void recordsUpToThreeTimesPerDay() { + @DisplayName("하루 세 번까지 보상을 준다") + void grantsUpToThreeTimesPerDay() { for (int i = 0; i < 5; i++) { missionLogService.registerProblemWriteMission(user.getId()); } @@ -146,16 +147,16 @@ void recordsUpToThreeTimesPerDay() { assertThat(missionLogRepository.findAllByUserId(user.getId())) .as("네 번째부터는 기록도 남기지 않는다") .hasSize(3); - assertThat(statusOf(user).getTotalStudyPoint()).isZero(); + assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(30L); } @Test - @DisplayName("한 번에 여러 문제를 등록해도 하루 상한인 3건까지만 기록한다") + @DisplayName("한 번에 여러 문제를 등록해도 하루 상한인 3건까지만 보상한다") void batchStopsAtDailyLimit() { missionLogService.registerProblemWriteMissionBatch(user.getId(), 10); assertThat(missionLogRepository.countProblemWritesToday(user.getId())).isEqualTo(3); - assertThat(statusOf(user).getTotalStudyPoint()).isZero(); + assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(30L); } @ParameterizedTest(name = "이미 {0}건 작성한 상태에서 {1}건 일괄 등록 → 총 {2}건") @@ -212,38 +213,38 @@ void rejectsUnknownUser() { class PracticeMission { @Test - @DisplayName("같은 문제는 하루에 한 번만 기록한다") - void recordsProblemPracticeOncePerDay() { + @DisplayName("같은 문제는 하루에 한 번만 보상한다") + void grantsProblemPracticeOncePerDay() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); missionLogService.registerProblemPracticeMission(user.getId(), 100L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); assertThat(statusOf(user)).satisfies(status -> { assertThat(accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint())) - .isZero(); - assertThat(status.getTotalStudyPoint()).isZero(); + .isEqualTo(5L); + assertThat(status.getTotalStudyPoint()).isEqualTo(5L); }); } @Test - @DisplayName("다른 문제를 복습하면 각각 기록한다") - void recordsPerProblem() { + @DisplayName("다른 문제를 복습하면 각각 보상한다") + void grantsPerProblem() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); missionLogService.registerProblemPracticeMission(user.getId(), 101L); missionLogService.registerProblemPracticeMission(user.getId(), 102L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(3); - assertThat(statusOf(user).getTotalStudyPoint()).isZero(); + assertThat(statusOf(user).getTotalStudyPoint()).isEqualTo(15L); } @Test - @DisplayName("복습노트도 하루에 한 번만 기록한다") - void recordsNotePracticeOncePerDay() { + @DisplayName("복습노트도 하루에 한 번만 보상한다") + void grantsNotePracticeOncePerDay() { missionLogService.registerNotePracticeMission(user.getId(), 200L); missionLogService.registerNotePracticeMission(user.getId(), 200L); assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(1); - assertThat(accumulatedNotePracticePoints(user)).isZero(); + assertThat(accumulatedNotePracticePoints(user)).isEqualTo(15L); } @Test @@ -254,13 +255,13 @@ void grantsAgainOnNewDay() { missionLogService.registerProblemPracticeMission(user.getId(), 100L); assertThat(missionLogRepository.findAllByUserId(user.getId())) - .as("어제 기록 1건 + 오늘 새로 남긴 1건") + .as("어제 기록 1건 + 오늘 새로 받은 1건") .hasSize(2); assertThat(accumulatedPoints( statusOf(user).getProblemPracticeLevel(), statusOf(user).getProblemPracticePoint())) - .as("기록을 남겨도 경험치는 들어오지 않는다") - .isZero(); + .as("어제 기록은 경험치를 다시 주지 않는다") + .isEqualTo(5L); } @Test @@ -273,65 +274,66 @@ void rejectsUnknownUser() { } } - /** - * 예전에는 이 자리에 "하루 200점 상한" 테스트가 있었다. 상한은 자동 적립을 막기 위한 장치였는데 - * 자동 적립 자체가 없어져 막을 대상이 사라졌다. 같은 시나리오를 새 동작으로 옮겨 둔다. - * 지키려던 것("하루 만에 만렙이 되지 않는다")은 미션 설계가 대신한다. 일일 6종을 다 받아도 75 XP 다. - */ @Nested - @DisplayName("자동 적립 폐지") - class NoAutomaticPointGrant { + @DisplayName("하루 포인트 상한") + class DailyPointLimit { @Test - @DisplayName("몇 번을 해도 경험치는 오르지 않는다") - void neverGrantsPointNoMatterHowManyTimes() { + @DisplayName("하루에 받을 수 있는 경험치는 200점까지다") + void capsDailyGainAtTwoHundred() { for (int i = 1; i <= 13; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } assertThat(accumulatedNotePracticePoints(user)) - .as("행동만으로 경험치가 들어오면 미션 화면의 숫자와 실제 증가량이 어긋난다") - .isZero(); + .as("15점짜리 미션을 13번 하면 12번은 15점씩, 마지막은 남은 5점만 받아 185점이다") + .isEqualTo(185L); } @Test - @DisplayName("경험치를 주지 않아도 수행 기록은 전부 남는다") - void keepsEveryLogWithoutPoint() { - for (int i = 1; i <= 14; i++) { + @DisplayName("상한을 넘긴 뒤의 미션은 기록만 남고 경험치는 0이다") + void grantsNothingBeyondLimit() { + for (int i = 1; i <= 13; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } + long beforeExtra = accumulatedNotePracticePoints(user); + + missionLogService.registerNotePracticeMission(user.getId(), 14L); assertThat(missionLogRepository.findAllByUserId(user.getId())) - .as("관리자 통계가 이 행을 읽는다. 지급을 걷어내면서 기록까지 없애면 지표가 죽는다") + .as("보상이 0이어도 수행 기록 자체는 남아야 한다") .hasSize(14); - assertThat(accumulatedNotePracticePoints(user)).isZero(); + assertThat(accumulatedNotePracticePoints(user)) + .as("상한을 넘겨 경험치가 계속 들어오면 하루 만에 만렙이 된다") + .isEqualTo(beforeExtra); } @Test - @DisplayName("사용자마다 기록은 따로 쌓이고 경험치는 둘 다 0이다") - void recordsPerUser() { + @DisplayName("상한은 사용자마다 따로 적용된다") + void appliesLimitPerUser() { for (int i = 1; i <= 14; i++) { missionLogService.registerNotePracticeMission(user.getId(), (long) i); } missionLogService.registerNotePracticeMission(other.getId(), 100L); - assertThat(missionLogRepository.findAllByUserId(other.getId())).hasSize(1); - assertThat(accumulatedNotePracticePoints(other)).isZero(); - assertThat(accumulatedNotePracticePoints(user)).isZero(); + assertThat(accumulatedNotePracticePoints(other)) + .as("남이 상한을 채웠다고 내 보상이 막히면 안 된다") + .isEqualTo(15L); } @Test - @DisplayName("어제 기록이 많아도 오늘 기록은 그대로 남는다") - void stillRecordsNextDay() { + @DisplayName("어제 채운 상한은 오늘 다시 열린다") + void resetsLimitNextDay() { for (int i = 1; i <= 14; i++) { saveMissionLogAt(user, MissionType.NOTE_PRACTICE, (long) i, today().minusDays(1).atTime(12, 0)); } missionLogService.registerNotePracticeMission(user.getId(), 999L); - assertThat(missionLogRepository.findAllByUserId(user.getId())).hasSize(15); - assertThat(accumulatedNotePracticePoints(user)).isZero(); + assertThat(accumulatedNotePracticePoints(user)) + .as("어제 소진한 상한이 오늘까지 이어지면 보상이 영영 막힌다") + .isEqualTo(15L); } } diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java index e0950a0e..12886809 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java @@ -5,11 +5,16 @@ import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.entity.UserMissionStatus; import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.mission.service.MissionLogService; import com.aisip.OnO.backend.support.IntegrationTestSupport; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDate; import java.time.LocalDateTime; @@ -34,6 +39,38 @@ public abstract class MissionTestSupport extends IntegrationTestSupport { @Autowired protected JdbcTemplate jdbcTemplate; + @Autowired + protected MissionLogService missionLogService; + + /** {@code ono.mission.legacy-accrual.enabled} 의 기본값. 설정을 안 건드리면 운영이 이 상태다. */ + protected static final boolean LEGACY_ACCRUAL_DEFAULT = true; + + /** + * 자동 적립 플래그를 바꾼다. + * + *

{@code MissionLogService} 는 {@code @Transactional} 프록시라 프록시에 값을 넣으면 + * 정작 로직이 도는 대상 객체의 필드는 그대로다. 대상 객체를 꺼내서 넣어야 한다. + * + *

싱글턴 빈을 건드리는 것이라 테스트 사이에 값이 새면 엉뚱한 테스트가 깨진다. + * 앞뒤로 기본값을 되돌려 이 클래스를 상속하지 않는 테스트까지 오염되지 않게 한다. + */ + protected void setLegacyAccrual(boolean enabled) { + // 대상 객체를 지역 변수로 받아 둔다. getTargetObject 의 반환형이 제네릭이라 그대로 넘기면 + // 컴파일러가 setField(Class, ...) 오버로드를 골라 실행 시점에 ClassCastException 이 난다. + MissionLogService target = AopTestUtils.getTargetObject(missionLogService); + ReflectionTestUtils.setField(target, "legacyAccrualEnabled", enabled); + } + + @BeforeEach + void restoreLegacyAccrualBeforeEachTest() { + setLegacyAccrual(LEGACY_ACCRUAL_DEFAULT); + } + + @AfterEach + void restoreLegacyAccrualAfterEachTest() { + setLegacyAccrual(LEGACY_ACCRUAL_DEFAULT); + } + protected MissionLog saveMissionLog(User user, MissionType missionType, Long referenceId) { return missionLogRepository.save(MissionLog.from( MissionRegisterDto.builder() From 591ec5c45ca106c6bd290f33406a6babbbdf3bee Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 19:33:56 +0900 Subject: [PATCH 068/130] =?UTF-8?q?[Chore]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=8B=9C=EA=B0=84=EB=8C=80=EB=A5=BC=20=EC=9A=B4=EC=98=81?= =?UTF-8?q?=EA=B3=BC=20=EB=A7=9E=EC=B6=94=EA=B3=A0=20pitest=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EC=97=90=20=EB=AF=B8=EC=85=98=20=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=ED=82=A4=EB=A5=BC=20=EB=84=A3=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 런타임은 Dockerfile ENTRYPOINT 에서 -Duser.timezone=Asia/Seoul 로 못박는데 테스트만 빠져 있었다. GitHub Actions 는 UTC 라 운영과 다른 시간대에서 검증하고 있었다 - 이 코드는 MissionPeriodKey 가 KST 를 명시하는데 JPA Auditing 의 created_at 은 JVM 기본 시간대를 쓴다. 둘이 다르면 KST 00:00~09:00 구간에만 하루가 어긋나, 평소에는 초록으로 보이다가 그 시각에 깨진다 - MissionPeriodKey 를 pitest 대상에 추가한다. 스프링 없이 도는 순수 정적 유틸이라 숫자가 실제 검출력을 가리키는 유형이다 - targetTests 를 명시한다. 생략하면 PIT 가 targetClasses 를 테스트 패턴으로도 쓰는데, 기존 패턴은 '*' 로 끝나 우연히 테스트까지 걸렸을 뿐이라 정확한 클래스명을 넣으면 그 클래스의 테스트가 하나도 안 걸려 전부 NO_COVERAGE 가 된다 --- build.gradle | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/build.gradle b/build.gradle index 36638a31..f95b6e93 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,14 @@ dependencies { tasks.named('test') { useJUnitPlatform() systemProperty 'spring.profiles.active', 'test' + + // 런타임은 Dockerfile 의 ENTRYPOINT 에서 -Duser.timezone=Asia/Seoul 로 시간대를 못박는데 + // 테스트만 빠져 있었다. GitHub Actions 는 UTC 로 돌기 때문에, 운영과 다른 시간대에서 검증하고 있었다. + // + // 이 코드에는 두 기준이 섞여 있다. MissionPeriodKey 는 KST 를 명시해서 하루를 가르는데 + // JPA Auditing 이 채우는 created_at 은 JVM 기본 시간대를 쓴다. 둘이 다르면 + // KST 00:00~09:00 구간에 하루가 어긋난다. 그 시각에만 깨지는 종류라 평소에는 초록으로 보인다. + systemProperty 'user.timezone', 'Asia/Seoul' // 기존 스위트는 힙 부족으로 OOM 종료됐다. 컨텍스트 캐시 + 컨테이너 구동분을 감안해 상향한다. maxHeapSize = '2g' @@ -218,6 +226,23 @@ pitest { 'com.aisip.OnO.backend.problem.reminder.*Calculator*', 'com.aisip.OnO.backend.common.emoji.*', 'com.aisip.OnO.backend.common.exception.*', + // 미션 기간 키. 스프링 없이 도는 순수 정적 유틸이고 단위 테스트가 붙어 있어 + // 위 주석이 말하는 "숫자가 실제 검출력을 가리키는" 유형에 해당한다. + // 나머지 미션 서비스는 통합 테스트로만 덮여 있어 넣어도 NO_COVERAGE 로 빠진다. + 'com.aisip.OnO.backend.mission.service.MissionPeriodKey', + ] + // targetTests 를 생략하면 PIT 는 targetClasses 를 테스트 패턴으로도 그대로 쓴다. + // 위 패턴들이 '*' 로 끝나 우연히 테스트 클래스까지 걸리고 있었을 뿐이라, + // 정확한 클래스명으로 대상을 추가하면 그 클래스의 테스트는 하나도 안 걸려 전부 NO_COVERAGE 가 된다 + // (MissionPeriodKey 를 넣었더니 7개 뮤턴트가 전부 그렇게 빠졌다). + // 지금까지의 암묵적 동작을 그대로 적고, 새로 넣은 대상의 테스트만 덧붙인다. + targetTests = [ + 'com.aisip.OnO.backend.*.entity.*', + 'com.aisip.OnO.backend.problem.reminder.*Policy*', + 'com.aisip.OnO.backend.problem.reminder.*Calculator*', + 'com.aisip.OnO.backend.common.emoji.*', + 'com.aisip.OnO.backend.common.exception.*', + 'com.aisip.OnO.backend.mission.service.MissionPeriodKeyTest', ] excludedClasses = [ 'com.aisip.OnO.backend.**.Q*', From cf3e486b1837f872429adc1c5118c201711c9b29 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 19:33:56 +0900 Subject: [PATCH 069/130] =?UTF-8?q?[Test]=20ZoneId.systemDefault()=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=84=20=EC=95=84=ED=82=A4=ED=85=8D?= =?UTF-8?q?=EC=B2=98=20=EA=B7=9C=EC=B9=99=EC=9C=BC=EB=A1=9C=20=EB=A7=89?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - now(ZoneId.systemDefault()) 는 인자 없는 now() 와 똑같이 JVM 기본 시간대를 쓰면서 기존 규칙만 피해 간다. static final 로 한 번 잡아 두면 동작으로는 구별할 수 없어 테스트 JVM 을 KST 로 고정한 뒤에는 뒤집어도 아무도 못 잡는다. 호출 자체를 막는다 - 실제로 MissionPeriodKey 의 KST 를 systemDefault() 로 바꿔 이 규칙이 잡는 것을 확인했다 --- .../backend/architecture/PersistenceRulesTest.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java index 820431ed..08558c11 100644 --- a/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java +++ b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java @@ -18,6 +18,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Set; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.fields; @@ -155,6 +156,19 @@ void currentTimeIsReadWithExplicitZone() { .check(productionClasses); } + @Test + @DisplayName("기본 시간대를 끌어다 쓰지 않는다 - 하루 경계는 항상 명시된 시간대로") + void doesNotFallBackToJvmDefaultZone() { + // now(ZoneId.systemDefault()) 는 인자 없는 now() 와 똑같이 JVM 기본 시간대를 쓰면서 + // 위 규칙만 피해 간다. 게다가 한 번 static final 로 잡아 두면 동작으로는 구별할 수 없어 + // 테스트로 잡을 방법이 없다. 그래서 호출 자체를 여기서 막는다. + noClasses() + .should().callMethod(ZoneId.class, "systemDefault") + .because("서비스 기준 시간대는 Asia/Seoul 이다. " + + "JVM 기본값을 끌어다 쓰면 배포 환경 설정이 빠지는 순간 하루 경계가 어긋난다") + .check(productionClasses); + } + private DescribedPredicate alreadyKnownToUseZonelessNow() { return new DescribedPredicate<>("이미 알려진 위반") { @Override From 4c9913b96ffb0fa6d2a40bdec71dfb8b10c41323 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 19:33:56 +0900 Subject: [PATCH 070/130] =?UTF-8?q?[Test]=20=EA=B0=80=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EB=B9=BC=EB=A9=B4=20=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=95=88=20=EB=8D=AE=EC=9D=B8=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 프로덕션을 하나씩 뒤집어 각 테스트가 실제로 잡는지 확인하고 넣었다. - size 하한: Math.max(size, 1) 을 빼면 PageRequest.of(0, 0) 으로 500 이 난다. size=0/-1/MIN_VALUE - 목록의 claimed: 프론트가 이 값으로 받기 버튼을 비활성화한다. true 로 내려가는지 직접 본다 - target_snapshot: 진행 중에 목표를 99 로 바꿔도 그 사람은 3 으로 끝난다. 진행도가 없는 미션은 바뀐 목표를 보여준다 - 30일 상한 경계: 29일 반나절 전은 들어오고 30일 한 시간 전은 빠진다. 두 점이 상한을 30 으로 못박는다. 20일과 40일만 재면 29 로 바꿔도 통과했다 - 비활성 정의: 진행도는 안 오르고, 이미 완료한 미수령 보상은 그대로 내려간다 - 커서가 아직 안 받은 내 진행도면 빈 페이지 - claim 예외 경로 두 개: 정의가 사라진 경우와 사용자가 사라진 경우. 둘 다 수령 도장이 안 찍힌다 - MissionPeriodKey 의 시간대 고정 --- .../service/MissionClaimHistoryTest.java | 28 +++++ .../mission/service/MissionPeriodKeyTest.java | 26 +++++ .../service/MissionProgressUpdaterTest.java | 15 +++ .../mission/service/MissionServiceTest.java | 101 ++++++++++++++++++ .../support/MissionSystemTestSupport.java | 9 ++ 5 files changed, 179 insertions(+) diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java index 9b649cbb..fcdecb80 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionClaimHistoryTest.java @@ -207,6 +207,20 @@ void doesNotLeakOtherUsers() { assertThat(history.totalClaimedCount()).isZero(); } + @Test + @DisplayName("아직 안 받은 내 진행도를 커서로 넘기면 빈 페이지를 준다") + void rejectsUnclaimedCursor() { + // 커서 행에 claimed_at 이 없으면 자를 기준이 없다. 400 을 던지는 것보다 빈 페이지가 안전하다. + MissionProgress unclaimed = completeMission(user.getId(), DAILY_NOTE_WRITE); + insertClaimedProgress(user.getId(), DAILY_MOOD, "2020-09-01", BASE); + + MissionClaimHistoryResponseDto history = + missionService.getClaimHistory(user.getId(), unclaimed.getId(), 20); + + assertThat(history.content()).isEmpty(); + assertThat(history.hasNext()).isFalse(); + } + @Test @DisplayName("남의 진행도를 커서로 넘겨도 남의 기록이 새지 않는다") void rejectsOtherUsersCursor() { @@ -276,6 +290,20 @@ void capsPageSize() { assertThat(history.size()).isEqualTo(50); } + @Test + @DisplayName("size 가 0 이거나 음수여도 터지지 않는다") + void guardsNonPositivePageSize() { + // 쿼리 파라미터라 밖에서 그대로 찔린다. 하한이 없으면 PageRequest.of(0, 0) 이 되어 500 이 난다. + insertClaimedProgress(user.getId(), DAILY_MOOD, "2020-09-01", BASE); + + for (int size : new int[]{0, -1, Integer.MIN_VALUE}) { + MissionClaimHistoryResponseDto history = missionService.getClaimHistory(user.getId(), null, size); + + assertThat(history.size()).as("size=%d", size).isEqualTo(1); + assertThat(history.content()).as("size=%d", size).hasSize(1); + } + } + private List walkAllPages(int size) { List visited = new ArrayList<>(); Long cursor = null; diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java index a6db0759..792feb0f 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionPeriodKeyTest.java @@ -5,7 +5,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.lang.reflect.Field; import java.time.LocalDate; +import java.time.ZoneId; import static org.assertj.core.api.Assertions.assertThat; @@ -81,6 +83,30 @@ void padsSingleDigitWeek() { } } + @Nested + @DisplayName("시간대") + class TimeZone { + + @Test + @DisplayName("하루의 경계를 KST 로 못박는다") + void pinsZoneToSeoul() throws NoSuchFieldException, IllegalAccessException { + // JVM 기본 시간대를 쓰면 배포 환경에 따라 "오늘"이 하루 어긋난다. + // 테스트 JVM 도 KST 로 고정돼 있어 동작만으로는 systemDefault() 와 구별되지 않는다. + // 그래서 선언을 직접 확인한다. + Field zoneField = MissionPeriodKey.class.getDeclaredField("KST"); + zoneField.setAccessible(true); + + assertThat(zoneField.get(null)).isEqualTo(ZoneId.of("Asia/Seoul")); + } + + @Test + @DisplayName("오늘은 KST 기준 날짜다") + void todayFollowsSeoul() { + assertThat(MissionPeriodKey.today()).isEqualTo(LocalDate.now(ZoneId.of("Asia/Seoul"))); + assertThat(MissionPeriodKey.now().toLocalDate()).isEqualTo(LocalDate.now(ZoneId.of("Asia/Seoul"))); + } + } + @Nested @DisplayName("카테고리별 키 선택") class ByCategory { diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java index b2cf0abb..22a793e7 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdaterTest.java @@ -186,6 +186,21 @@ void appliesWithoutOuterTransaction() { @DisplayName("올리지 않는 경우") class NoOp { + @Test + @DisplayName("비활성 정의는 진행도가 오르지 않는다") + void ignoresInactiveDefinition() { + deactivateDefinition(DAILY_NOTE_WRITE); + + missionProgressUpdater.increase(user.getId(), MissionMetric.PROBLEM_CREATED); + + assertThat(currentOf(user.getId(), DAILY_NOTE_WRITE)) + .as("내린 미션은 더 이상 쌓이지 않아야 한다") + .isZero(); + assertThat(currentOf(user.getId(), WEEKLY_NOTE_10)) + .as("같은 항목을 세는 다른 미션은 그대로 오른다") + .isEqualTo(1); + } + @Test @DisplayName("증가량이 0 이하면 아무 일도 없다") void ignoresNonPositiveAmount() { diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java index 6c343f80..f474fbd9 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionServiceTest.java @@ -137,6 +137,49 @@ void completedMissionCarriesProgressId() { assertThat(noteWrite.rewardValue()).isEqualTo(10); } + @Test + @DisplayName("받은 미션은 claimed 가 참으로 내려간다") + void showsClaimedAsTrue() { + // 프론트가 이 값으로 받기 버튼을 비활성화한다. 영영 false 로 나가도 화면은 조용히 잘못 동작한다. + MissionProgress progress = completeMission(user.getId(), DAILY_NOTE_WRITE); + missionService.claim(user.getId(), progress.getId()); + + MissionResponseDto noteWrite = findByCode( + missionService.getMissions(user.getId()).daily().missions(), DAILY_NOTE_WRITE); + + assertThat(noteWrite.claimed()).isTrue(); + assertThat(noteWrite.completed()).isTrue(); + } + + @Test + @DisplayName("진행 중에 목표가 바뀌어도 그 사람은 옛 목표로 끝낸다") + void keepsTargetSnapshot() { + // 진행도 행을 만들 때 target 을 박아 두는 이유다. 현재 정의를 읽으면 + // 운영 중에 목표를 낮췄을 때 이미 채운 사람 화면이 "5 / 3" 이 된다. + missionProgressUpdater.increase(user.getId(), MissionMetric.SOLVE_RECORDED, 3); + changeTarget(DAILY_REVIEW_3, 99); + + MissionResponseDto review = findByCode( + missionService.getMissions(user.getId()).daily().missions(), DAILY_REVIEW_3); + + assertThat(review.target()) + .as("진행도가 있는 미션은 그 행이 만들어질 때의 목표를 쓴다") + .isEqualTo(3); + assertThat(review.current()).isEqualTo(3); + assertThat(review.completed()).isTrue(); + } + + @Test + @DisplayName("진행도가 없는 미션은 바뀐 목표를 그대로 보여준다") + void usesCurrentTargetWhenNoProgress() { + changeTarget(DAILY_REVIEW_3, 99); + + MissionResponseDto review = findByCode( + missionService.getMissions(user.getId()).daily().missions(), DAILY_REVIEW_3); + + assertThat(review.target()).isEqualTo(99); + } + @Test @DisplayName("남의 진행도는 내 목록에 섞이지 않는다") void doesNotLeakOtherUsersProgress() { @@ -263,6 +306,34 @@ void ignoresTooOldProgress() { .isEmpty(); } + @Test + @DisplayName("상한 경계는 30일이다 - 29일 반나절 전은 들어오고 30일 한 시간 전은 빠진다") + void lookbackBoundaryIsThirtyDays() { + // 두 점이 상한을 30 으로 못박는다. 29.5일 전이 들어오려면 상한이 30 이상이어야 하고, + // 30일 1시간 전이 빠지려면 30 이하여야 한다. 20일과 40일만 재면 29 로 바꿔도 통과한다. + insertCompletedProgress(user.getId(), WEEKLY_REVIEW_30, "2020-W10", + LocalDateTime.now().minusDays(29).minusHours(12)); + insertCompletedProgress(user.getId(), WEEKLY_NOTE_10, "2020-W11", + LocalDateTime.now().minusDays(30).minusHours(1)); + + assertThat(missionService.getMissions(user.getId()).expired().missions()) + .extracting(MissionResponseDto::code) + .containsExactly(WEEKLY_REVIEW_30); + } + + @Test + @DisplayName("정의가 비활성화돼도 이미 완료한 미수령 보상은 내려간다") + void keepsDeactivatedDefinition() { + insertCompletedProgress( + user.getId(), WEEKLY_REVIEW_30, lastWeekKey(), LocalDateTime.now().minusDays(1)); + deactivateDefinition(WEEKLY_REVIEW_30); + + assertThat(missionService.getMissions(user.getId()).expired().missions()) + .as("이미 완료한 건 받을 수 있어야 한다") + .extracting(MissionResponseDto::code) + .containsExactly(WEEKLY_REVIEW_30); + } + @Test @DisplayName("남의 미수령 보상은 내 목록에 섞이지 않는다") void doesNotLeakOtherUsers() { @@ -357,6 +428,36 @@ void rejectsOtherUsersProgress() { .isNull(); } + @Test + @DisplayName("정의가 사라진 진행도는 받을 수 없다") + void rejectsWhenDefinitionIsGone() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + jdbcTemplate.update("DELETE FROM mission_definition WHERE id = ?", progress.getMissionId()); + + assertThatThrownBy(() -> missionService.claim(user.getId(), progress.getId())) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.MISSION_PROGRESS_NOT_FOUND); + assertThat(missionProgressRepository.findById(progress.getId()).orElseThrow().getClaimedAt()) + .as("보상 값을 알 수 없으니 수령 도장도 찍히면 안 된다") + .isNull(); + } + + @Test + @DisplayName("사용자가 사라졌으면 USER_NOT_FOUND 로 거절한다") + void rejectsWhenUserIsGone() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + jdbcTemplate.update("UPDATE user SET deleted_at = NOW(6) WHERE id = ?", user.getId()); + + assertThatThrownBy(() -> missionService.claim(user.getId(), progress.getId())) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(MissionErrorCase.USER_NOT_FOUND); + assertThat(missionProgressRepository.findById(progress.getId()).orElseThrow().getClaimedAt()) + .as("지급할 대상이 없으면 수령 도장도 찍히면 안 된다") + .isNull(); + } + @Test @DisplayName("행동 기록이 아무리 쌓여 있어도 XP 는 받을 때만 들어온다") void grantsOnlyOnClaim() { diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java index 337cb17d..564f4e83 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -61,6 +61,10 @@ protected MissionDefinition definitionOf(String code) { () -> new IllegalStateException("시드에 없는 미션 코드다: " + code)); } + /** + * 프로덕션 계산기를 그대로 부른다. "서비스가 같은 계산을 위임했는가"만 확인할 수 있고 + * 키 형식 자체는 검증하지 못한다. 형식은 {@code MissionPeriodKeyTest} 가 리터럴로 막는다. + */ protected String periodKeyOf(String code) { return MissionPeriodKey.of(definitionOf(code).getCategory(), MissionPeriodKey.today()); } @@ -158,6 +162,11 @@ protected void changeRewardValue(String code, int rewardValue) { jdbcTemplate.update("UPDATE mission_definition SET reward_value = ? WHERE code = ?", rewardValue, code); } + /** 미션 정의의 목표를 바꾼다. 진행 중이던 사용자가 옛 목표로 끝나는지 확인할 때 쓴다. */ + protected void changeTarget(String code, int target) { + jdbcTemplate.update("UPDATE mission_definition SET target = ? WHERE code = ?", target, code); + } + private Long claimedProgressId(Long userId, Long missionId, String periodKey) { return jdbcTemplate.queryForObject( "SELECT id FROM mission_progress WHERE user_id = ? AND mission_id = ? AND period_key = ?", From 287fe58559e9cd9b5d6da0f2f1dd1a7d9d38adc3 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 10 Sep 2026 19:33:56 +0900 Subject: [PATCH 071/130] =?UTF-8?q?[Test]=20=EC=95=BD=ED=95=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=96=B8=EC=9D=84=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B3=A0=20=EC=9E=84=EA=B3=84=EC=B9=98=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EB=A5=BC=20=EC=B1=84=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UserMissionStatus: 임계치에 정확히 닿았을 때 레벨이 오르는지가 출석에만 있었다. 나머지 세 능력치와 두 번째 임계치, 총 학습 레벨까지 넣는다. 미션 보상이 이 메서드들로 XP 를 넣는다 - leveledUp: isBoolean() 이라 영영 false 여도 통과했다. 안 오르는 경우와 오르는 경우를 값으로 고정한다 - 마이그레이션 재실행: 유일한 단언이 findAll().isEmpty() 라 결과와 무관하게 항상 참이었다. 다시 돌린 뒤 인덱스와 컬럼이 그대로인지, 그 스키마로 실제 조회가 되는지까지 본다 - 기간 키: 프로덕션 계산기를 부르지 않고 직접 만든 키와 비교한다. 위임했다는 사실만 확인하면 키 형식이 바뀌어도 침묵한다 --- .../mission/MissionSeedMigrationTest.java | 32 ++++++++++- .../controller/MissionControllerTest.java | 26 ++++++++- .../mission/entity/UserMissionStatusTest.java | 55 +++++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java index 8602b41e..722b1c99 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/MissionSeedMigrationTest.java @@ -13,6 +13,7 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; import java.util.Locale; @@ -88,7 +89,36 @@ void rerunningSchemaMigrationsDoesNotFail() { runMigration(REWARD_SNAPSHOT_MIGRATION); } - assertThat(missionProgressRepository.findAll()).isEmpty(); + // 예외가 안 났다는 것만으로는 약하다. 다시 돌린 뒤에도 스키마가 온전한지 확인한다. + assertThat(countIndex("mission_progress", "idx_mission_progress_claimed")) + .as("인덱스가 사라지지도 중복으로 생기지도 않아야 한다") + .isEqualTo(1); + assertThat(countColumn("mission_progress", "reward_type_snapshot")).isEqualTo(1); + assertThat(countColumn("mission_progress", "reward_value_snapshot")).isEqualTo(1); + + // 스키마가 실제로 쓸 수 있는 상태인지까지 본다. + var user = fixtures.createUser(); + insertClaimedProgress(user.getId(), DAILY_NOTE_WRITE, "2020-09-01", LocalDateTime.of(2020, 9, 1, 10, 0)); + assertThat(missionService.getClaimHistory(user.getId(), null, 20).content()).hasSize(1); + } + + /** 같은 이름의 인덱스가 몇 개인지. 복합 인덱스는 컬럼 수만큼 행이 나오므로 DISTINCT 로 센다. */ + private int countIndex(String table, String indexName) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(DISTINCT index_name) + FROM information_schema.statistics + WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ? + """, Integer.class, table, indexName); + return count == null ? 0 : count; + } + + private int countColumn(String table, String columnName) { + Integer count = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? + """, Integer.class, table, columnName); + return count == null ? 0 : count; } @Test diff --git a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java index ba5178c3..f1d452b5 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/controller/MissionControllerTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.ZoneId; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -35,7 +37,10 @@ void getMissions() throws Exception { mockMvc.perform(get("/api/missions")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.daily.periodKey").value(periodKeyOf(DAILY_NOTE_WRITE))) + // 프로덕션 계산기를 부르지 않고 직접 만든 키와 비교한다. + // periodKeyOf 를 쓰면 "서비스가 같은 계산을 위임했다"만 확인하게 된다. + .andExpect(jsonPath("$.data.daily.periodKey") + .value(LocalDate.now(ZoneId.of("Asia/Seoul")).toString())) .andExpect(jsonPath("$.data.weekly.periodKey").value(periodKeyOf(WEEKLY_NOTE_10))) .andExpect(jsonPath("$.data.daily.missions.length()").value(6)) .andExpect(jsonPath("$.data.weekly.missions.length()").value(4)) @@ -105,8 +110,23 @@ void claim() throws Exception { .andExpect(jsonPath("$.data.progressId").value(progress.getId())) .andExpect(jsonPath("$.data.rewardType").value("XP")) .andExpect(jsonPath("$.data.rewardValue").value(10)) - .andExpect(jsonPath("$.data.totalStudyLevel").isNumber()) - .andExpect(jsonPath("$.data.leveledUp").isBoolean()); + .andExpect(jsonPath("$.data.totalStudyLevel").value(1)) + .andExpect(jsonPath("$.data.leveledUp") + // 10 XP 로는 총 학습 레벨 1→2 에 필요한 40점에 못 미친다. + .value(false)); + } + + @Test + @DisplayName("POST /api/missions/{progressId}/claim - 레벨이 오르면 leveledUp 이 참이다") + void claimReportsLevelUp() throws Exception { + // isBoolean() 만 보면 leveledUp 이 영영 false 여도 통과한다. 양쪽 값을 모두 고정한다. + MissionProgress progress = completeMission(user.getId(), WEEKLY_REVIEW_30); + + mockMvc.perform(post("/api/missions/{progressId}/claim", progress.getId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rewardValue").value(100)) + .andExpect(jsonPath("$.data.totalStudyLevel").value(2)) + .andExpect(jsonPath("$.data.leveledUp").value(true)); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java index 502ce18e..737fac05 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java @@ -105,6 +105,61 @@ void abilitiesAreIndependent() { assertThat(status.getNotePracticeLevel()).isEqualTo(1L); } + @Test + @DisplayName("나머지 세 능력치도 필요 경험치와 정확히 같은 점수에서 레벨이 오른다") + void everyAbilityLevelsUpOnExactThreshold() { + // 출석에만 임계치 경계 테스트가 있어서, 나머지 셋은 >= 를 > 로 바꿔도 아무도 못 잡았다. + // 미션 보상이 바로 이 메서드들로 XP 를 넣는다. + UserMissionStatus noteWrite = newcomer(); + noteWrite.gainNoteWritePoint(9L); + assertThat(noteWrite.getNoteWriteLevel()).as("1점 모자라면 오르지 않는다").isEqualTo(1L); + noteWrite.gainNoteWritePoint(1L); + assertThat(noteWrite.getNoteWriteLevel()).as("딱 10점이면 오른다").isEqualTo(2L); + assertThat(noteWrite.getNoteWritePoint()).isZero(); + + UserMissionStatus problemPractice = newcomer(); + problemPractice.gainProblemPracticePoint(9L); + assertThat(problemPractice.getProblemPracticeLevel()).isEqualTo(1L); + problemPractice.gainProblemPracticePoint(1L); + assertThat(problemPractice.getProblemPracticeLevel()).isEqualTo(2L); + assertThat(problemPractice.getProblemPracticePoint()).isZero(); + + UserMissionStatus notePractice = newcomer(); + notePractice.gainNotePracticePoint(9L); + assertThat(notePractice.getNotePracticeLevel()).isEqualTo(1L); + notePractice.gainNotePracticePoint(1L); + assertThat(notePractice.getNotePracticeLevel()).isEqualTo(2L); + assertThat(notePractice.getNotePracticePoint()).isZero(); + } + + @Test + @DisplayName("두 번째 임계치도 정확히 같은 점수에서 오른다") + void levelsUpOnExactSecondThreshold() { + // 레벨 2→3 은 20점이다. 첫 임계치만 재면 공식의 (level - 1) * 10 이 흔들려도 안 잡힌다. + UserMissionStatus noteWrite = newcomer(); + noteWrite.gainNoteWritePoint(10L); + + noteWrite.gainNoteWritePoint(19L); + assertThat(noteWrite.getNoteWriteLevel()).as("1점 모자라면 레벨 2 그대로").isEqualTo(2L); + + noteWrite.gainNoteWritePoint(1L); + assertThat(noteWrite.getNoteWriteLevel()).as("딱 20점이면 레벨 3").isEqualTo(3L); + assertThat(noteWrite.getNoteWritePoint()).isZero(); + } + + @Test + @DisplayName("총 학습 레벨도 필요 경험치와 정확히 같은 점수에서 오른다") + void totalStudyLevelsUpOnExactThreshold() { + // 총 학습 레벨 1→2 는 40점이다. 미션 보상 응답의 leveledUp 이 이 값으로 갈린다. + UserMissionStatus status = newcomer(); + + status.gainNoteWritePoint(39L); + assertThat(status.getTotalStudyLevel()).as("1점 모자라면 오르지 않는다").isEqualTo(1L); + + status.gainNoteWritePoint(1L); + assertThat(status.getTotalStudyLevel()).as("딱 40점이면 오른다").isEqualTo(2L); + } + @Test @DisplayName("네 능력치 모두 같은 공식을 쓴다") void everyAbilityUsesSameFormula() { From 33ca2c4cb7749f9e26814955262a5a6361707552 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 18:53:10 +0900 Subject: [PATCH 072/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=EC=99=80=20=EC=95=84=EC=9D=B4=ED=85=9C=20=EC=8B=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V34: cosmetic_item 카탈로그와 user_cosmetic_loadout 장착 상태 테이블 - user_cosmetic_loadout 은 (user_id, slot) 복합 기본키로 한 슬롯에 두 개가 들어가는 것을 DB 가 막는다 - V29 와 같이 CREATE TABLE IF NOT EXISTS + 인덱스 인라인으로 재실행 안전하게 작성 - V35: 개구리 본체 1, 레벨 해금 16, 2차 콘텐츠 19종 시드 - image_url 은 번들 상대 경로로 넣어 S3 전환 때 컬럼 값만 바꾸면 되게 했다 - conflicts_with 는 비워 두되 컬럼은 먼저 만들어 둔다 --- .../migration/V34__create_cosmetic_system.sql | 65 ++++++++++++++++ .../db/migration/V35__seed_cosmetic_items.sql | 78 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/main/resources/db/migration/V34__create_cosmetic_system.sql create mode 100644 src/main/resources/db/migration/V35__seed_cosmetic_items.sql diff --git a/src/main/resources/db/migration/V34__create_cosmetic_system.sql b/src/main/resources/db/migration/V34__create_cosmetic_system.sql new file mode 100644 index 00000000..861f72b3 --- /dev/null +++ b/src/main/resources/db/migration/V34__create_cosmetic_system.sql @@ -0,0 +1,65 @@ +-- 꾸미기(코스메틱) 1차: 아이템 카탈로그와 사용자별 장착 상태. 이 파일은 DDL 만 담는다. +-- 시드는 V35 에 따로 둔다. V29 와 같은 이유다. MySQL DDL 은 트랜잭션이 아니라서, 테이블은 만들어지고 +-- 뒤이은 INSERT 에서 끊기면 Flyway 는 실패로 기록하는데 테이블은 남는다. 재기동하면 +-- "table already exists" 로 또 실패해 flyway repair 없이는 앱이 뜨지 않는다. +-- +-- 같은 이유로 두 테이블 모두 IF NOT EXISTS 로 만들고, 인덱스도 별도 CREATE INDEX 가 아니라 +-- 테이블 정의 안에 둔다. MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 +-- 그 문장 자체가 새로운 재실행 지점이 되기 때문이다(V31 이 information_schema 가드를 쓴 이유가 그거다). +-- 새로 만드는 테이블은 가드가 아예 필요 없는 형태로 쓰는 편이 낫다. 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. + +-- cosmetic_item: 꾸미기 아이템 카탈로그. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. +-- +-- required_level 이 NULL 이면 "레벨로는 열리지 않는다"는 뜻이다. 0 이나 -1 같은 마법값을 쓰지 않는다. +-- 보유 여부는 저장하지 않고 required_level 과 사용자 레벨을 비교해 매번 계산한다. +-- +-- conflicts_with 는 콤마로 구분한 item_key 목록이다. 지금은 전부 비어 있지만 컬럼과 처리 로직을 +-- 먼저 만들어 둔다. 후드 옷과 모자처럼 같이 못 쓰는 조합이 나오면 데이터만 채우면 된다. +-- +-- set_id 는 세트 장착(PUT /api/cosmetics/equip-set)이 묶어서 거는 단위다. +CREATE TABLE IF NOT EXISTS cosmetic_item ( + id BIGINT NOT NULL AUTO_INCREMENT, + item_key VARCHAR(64) NOT NULL, + slot VARCHAR(32) NOT NULL, + name_ko VARCHAR(64) NOT NULL, + image_url VARCHAR(512) NOT NULL, + required_level INT NULL, + set_id VARCHAR(64) NULL, + conflicts_with VARCHAR(512) NULL, + active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + -- 장착 요청과 레이아웃 저장이 모두 item_key 로 아이템을 가리킨다. 중복이 생기면 어느 쪽을 + -- 가리키는지 알 수 없어지므로 정합성용 유니크 키다. 시드의 재실행 안전성도 이 키가 받쳐 준다. + UNIQUE KEY uk_cosmetic_item_key (item_key), + -- 레벨업 해금 알림이 "levelBefore < required_level <= levelAfter 인 활성 아이템" 을 찾는다. + KEY idx_cosmetic_item_active_level (active, required_level), + KEY idx_cosmetic_item_set (set_id) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- user_cosmetic_loadout: 사용자가 슬롯별로 무엇을 걸고 있는지. +-- +-- **(user_id, slot) 복합 기본키가 이 설계의 핵심이다.** 한 슬롯에 두 개가 들어가는 것을 +-- 애플리케이션 검사가 아니라 DB 가 막는다. 장착은 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 +-- 처리하는데, 그 upsert 가 성립하는 근거가 바로 이 기본키다. +-- 같은 사용자가 같은 슬롯에 두 아이템을 동시에 걸어도 중복 행이 생길 수 없고, +-- "읽어서 있으면 UPDATE 없으면 INSERT" 로 갈랐을 때 나는 유니크 충돌 예외 경로도 없다. +-- +-- 대리키(id)를 두지 않는다. 대리키를 두면 유니크 키는 별개의 보조 인덱스가 되고, 조회가 +-- (user_id, slot) 로 들어오는데 정작 클러스터드 인덱스는 쓸모없는 id 가 된다. +-- 여기서는 자연키가 곧 조회 키라 그대로 기본키로 두는 편이 읽기도 쓰기도 싸다. +-- +-- user 에 외래키를 걸지 않는다. mission_progress 와 같은 이유로, INSERT 마다 부모 사용자 행에 +-- 공유 잠금이 붙으면 이미 사용자 행을 배타 잠금으로 잡는 미션 보상 지급 경로와 잠금 순서가 엇갈린다. +-- +-- item_key 에 '__none__' 이 들어간 행은 "이 슬롯을 일부러 비웠다"는 표시다. 자세한 설명은 +-- UserCosmeticLoadout 엔티티 주석에 있다. 행을 아예 지우면 "한 번도 안 건드린 사용자"와 +-- 구별되지 않아 기본 프리셋이 되살아난다. +CREATE TABLE IF NOT EXISTS user_cosmetic_loadout ( + user_id BIGINT NOT NULL, + slot VARCHAR(32) NOT NULL, + item_key VARCHAR(64) NOT NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (user_id, slot) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/src/main/resources/db/migration/V35__seed_cosmetic_items.sql b/src/main/resources/db/migration/V35__seed_cosmetic_items.sql new file mode 100644 index 00000000..a1233ae0 --- /dev/null +++ b/src/main/resources/db/migration/V35__seed_cosmetic_items.sql @@ -0,0 +1,78 @@ +-- 꾸미기 아이템 시드. 관리자 화면은 2차라 카탈로그는 마이그레이션으로만 들어온다. +-- +-- ON DUPLICATE KEY UPDATE item_key = item_key 는 "이미 있으면 아무것도 하지 않는다"는 뜻이다. +-- uk_cosmetic_item_key 가 중복을 잡아 주므로 이 문장은 몇 번을 다시 돌려도 결과가 같다. +-- 운영 중에 이름이나 이미지를 손으로 고쳤을 수 있어 덮어쓰지 않는다. 값을 바꾸려면 새 마이그레이션에서 +-- UPDATE 로 얹는다(V33 이 미션 문구를 그렇게 고쳤다). 이 파일을 고치면 이미 적용된 환경에서 +-- Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- +-- **image_url 은 지금 번들 상대 경로다.** S3 에 아직 아무것도 올라가 있지 않다. +-- 'assets/Cosmetic/hat_beanie.png' 처럼 앱 번들 경로로 시드해 두면, 나중에 S3 로 옮길 때 +-- 이 컬럼 값만 'https://...' 로 바꾸는 UPDATE 한 번으로 전환된다. 앱을 다시 배포할 필요가 없다. +-- 프론트는 값이 'http' 로 시작하는지로 네트워크 이미지와 번들 이미지를 갈라 처리하기로 했다. +-- +-- 'BASE' 행은 장착 슬롯이 아니라 개구리 본체 이미지다. 슬롯 이름을 'BASE' 로 둬서 +-- 장착 가능한 슬롯 목록에서 빠지고 아이템 목록에도 나가지 않는다. 본체 이미지까지 데이터로 두는 이유는 +-- 위와 같다. S3 전환이 코드 배포 없이 끝나야 한다. +-- +-- required_level 이 NULL 인 행은 레벨로는 열리지 않는다. active = 0 인 2차 콘텐츠가 전부 그렇다. +-- 이미지를 다 그리고 나면 active 를 1 로 올리고 required_level 을 채우는 마이그레이션을 새로 쓴다. +-- +-- conflicts_with 는 전부 비워 둔다. 컬럼과 자동 해제 로직은 이미 있으니, 같이 못 쓰는 조합이 +-- 생기면 데이터만 채우면 된다. + +-- 개구리 본체. 장착 대상이 아니다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('BASE', 'BASE', '개구리 본체', 'assets/Cosmetic/BASE.png', NULL, NULL, NULL, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; + +-- 레벨 해금 아이템. 총 학습 레벨 2 부터 15 까지 한 단계에 하나씩, 마지막 15 는 졸업 세트 세 점이다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('headband_sprout', 'HEAD', '새싹 머리띠', 'assets/Cosmetic/headband_sprout.png', 2, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_spring', 'BACKGROUND', '봄 배경', 'assets/Cosmetic/bg_spring.png', 3, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_round', 'FACE', '동그란 안경', 'assets/Cosmetic/glasses_round.png', 4, NULL, NULL, 1, NOW(6), NOW(6)), + ('scarf', 'NECK', '목도리', 'assets/Cosmetic/scarf.png', 5, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beanie', 'HEAD', '비니', 'assets/Cosmetic/hat_beanie.png', 6, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_study', 'BACKGROUND', '공부방 배경', 'assets/Cosmetic/bg_study.png', 7, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_mini_backpack', 'BACK', '미니 백팩', 'assets/Cosmetic/bag_mini_backpack.png', 8, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_cardigan', 'OUTFIT', '가디건', 'assets/Cosmetic/outfit_cardigan.png', 9, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_study', 'HAND', '공부 도구', 'assets/Cosmetic/prop_study.png', 10, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_sun', 'FACE', '선글라스', 'assets/Cosmetic/glasses_sun.png', 11, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_bucket', 'HEAD', '버킷햇', 'assets/Cosmetic/hat_bucket.png', 12, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_night', 'BACKGROUND', '밤하늘 배경', 'assets/Cosmetic/bg_night.png', 13, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_crown', 'HEAD', '왕관', 'assets/Cosmetic/hat_crown.png', 14, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_graduate', 'HEAD', '학사모', 'assets/Cosmetic/hat_graduate.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)), + ('outfit_graduate', 'OUTFIT', '졸업 가운', 'assets/Cosmetic/outfit_graduate.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)), + ('prop_diploma', 'HAND', '졸업장', 'assets/Cosmetic/prop_diploma.png', 15, 'graduate', NULL, 1, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; + +-- 2차 콘텐츠. 이미지가 아직 없어 active = 0 으로 넣어 둔다. +-- 지금 넣는 이유는 item_key 를 여기서 확정해 두기 위해서다. 프론트 에셋 이름과 어긋나면 +-- 나중에 둘 중 하나를 고쳐야 하는데, 이미 깔린 앱이 있으면 그때는 못 고친다. +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, set_id, conflicts_with, active, created_at, updated_at) +VALUES + ('hat_beret', 'HEAD', '베레모', 'assets/Cosmetic/hat_beret.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('headphone', 'HEAD', '헤드폰', 'assets/Cosmetic/headphone.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('glasses_heart', 'FACE', '하트 안경', 'assets/Cosmetic/glasses_heart.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bowtie', 'NECK', '나비넥타이', 'assets/Cosmetic/bowtie.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('neck_medal', 'NECK', '메달', 'assets/Cosmetic/neck_medal.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('neck_camera', 'NECK', '카메라', 'assets/Cosmetic/neck_camera.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_school', 'OUTFIT', '교복', 'assets/Cosmetic/outfit_school.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_hoodie', 'OUTFIT', '후드티', 'assets/Cosmetic/outfit_hoodie.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('outfit_raincoat', 'OUTFIT', '우비', 'assets/Cosmetic/outfit_raincoat.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bag_waist_pouch', 'BACK', '허리 가방', 'assets/Cosmetic/bag_waist_pouch.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bag_crossbody_satchel', 'BACK', '크로스백', 'assets/Cosmetic/bag_crossbody_satchel.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('prop_bouquet', 'HAND', '꽃다발', 'assets/Cosmetic/prop_bouquet.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('prop_umbrella', 'HAND', '우산', 'assets/Cosmetic/prop_umbrella.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('badge_leaf_star', 'BADGE', '잎새 별 뱃지', 'assets/Cosmetic/badge_leaf_star.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_autumn', 'BACKGROUND', '가을 배경', 'assets/Cosmetic/bg_autumn.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_rainy', 'BACKGROUND', '비 오는 날 배경', 'assets/Cosmetic/bg_rainy.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_space', 'BACKGROUND', '우주 배경', 'assets/Cosmetic/bg_space.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_sunset', 'BACKGROUND', '노을 배경', 'assets/Cosmetic/bg_sunset.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)), + ('bg_winter', 'BACKGROUND', '겨울 배경', 'assets/Cosmetic/bg_winter.png', NULL, NULL, NULL, 0, NOW(6), NOW(6)) +ON DUPLICATE KEY UPDATE item_key = item_key; From 01a3bce62146001cce441918dc47fa88964b26bf Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 18:53:25 +0900 Subject: [PATCH 073/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=99=80=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CosmeticSlot: 슬롯과 레이어 순서. BASE 는 장착 대상이 아니라 equippable 로 갈랐다 - CosmeticItem: 보유 여부를 저장하지 않고 required_level 과 레벨을 비교해 계산한다 - UserCosmeticLoadout: (user_id, slot) 복합 기본키, 쓰기는 네이티브 upsert 만 탄다 - 해제는 행 삭제가 아니라 __none__ 표시로 남긴다. 삭제하면 프리셋이 되살아난다 - insertIfAbsent 는 프리셋을 행으로 굳힐 때 기존 선택을 덮어쓰지 않게 한다 --- .../backend/cosmetic/entity/CosmeticItem.java | 121 ++++++++++++++++++ .../backend/cosmetic/entity/CosmeticSlot.java | 50 ++++++++ .../cosmetic/entity/UserCosmeticLoadout.java | 73 +++++++++++ .../entity/UserCosmeticLoadoutId.java | 27 ++++ .../repository/CosmeticItemRepository.java | 44 +++++++ .../UserCosmeticLoadoutRepository.java | 68 ++++++++++ 6 files changed, 383 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java new file mode 100644 index 00000000..6b5ce316 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java @@ -0,0 +1,121 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import jakarta.persistence.Column; +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.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +/** + * 꾸미기 아이템 하나. 관리자 화면은 2차라 1차에서는 마이그레이션 시드로만 들어온다. + * + *

애플리케이션은 이 테이블을 읽기만 한다. 그래서 {@code BaseEntity} 를 상속하지 않는다. + * 상속하면 쓰지도 않는 {@code deleted_at} 이 딸려 와 V34 스키마와 어긋난다. + * {@code createdAt}/{@code updatedAt} 은 시드가 채우는 값을 그대로 읽기만 한다. + * + *

{@code imageUrl} 은 지금 번들 상대 경로({@code assets/Cosmetic/hat_beanie.png})다. + * S3 로 옮길 때 이 컬럼만 {@code https://...} 로 바꾸면 앱 배포 없이 전환된다. + * 프론트가 {@code http} 로 시작하는지로 갈라 처리한다. + */ +@Entity +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "cosmetic_item", + uniqueConstraints = @UniqueConstraint(name = "uk_cosmetic_item_key", columnNames = "item_key"), + indexes = { + // 레벨업 해금 알림이 "levelBefore < required_level <= levelAfter 인 활성 아이템" 을 찾는다. + @Index(name = "idx_cosmetic_item_active_level", columnList = "active, required_level"), + @Index(name = "idx_cosmetic_item_set", columnList = "set_id") + }) +public class CosmeticItem { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "item_key", nullable = false, length = 64) + private String itemKey; + + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 32) + private CosmeticSlot slot; + + @Column(name = "name_ko", nullable = false, length = 64) + private String nameKo; + + @Column(name = "image_url", nullable = false, length = 512) + private String imageUrl; + + /** + * 이 레벨부터 열린다. {@code null} 이면 레벨로는 열리지 않는다. + * + *

0 이나 -1 같은 마법값을 쓰지 않는다. "레벨 조건이 없다"와 "레벨 0 이면 된다"는 다른 말이고, + * 마법값을 쓰면 {@code required_level <= level} 비교가 조용히 전부 참이 된다. + */ + @Column(name = "required_level") + private Integer requiredLevel; + + @Column(name = "set_id", length = 64) + private String setId; + + /** 같이 걸 수 없는 아이템의 {@code item_key} 목록. 콤마 구분. 지금은 전부 비어 있다. */ + @Column(name = "conflicts_with", length = 512) + private String conflictsWith; + + @Column(name = "active", nullable = false) + private boolean active; + + @Column(name = "created_at") + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 보유 여부. 저장하지 않고 매번 계산한다. + * + *

보유를 테이블로 두면 레벨이 오를 때마다 지급 배치가 필요하고, 그 배치가 한 번 밀리면 + * 사용자는 레벨은 올랐는데 아이템이 안 열린 상태로 남는다. 계산으로 두면 그 상태 자체가 없다. + * 테마 해금이 같은 방식을 쓴다. + * + *

경계는 포함이다. {@code required_level == totalStudyLevel} 이면 열린 것이다. + * 레벨 6 짜리 비니는 레벨 6 이 되는 순간 써야지 7 이 돼서 열리면 안 된다. + */ + public boolean isOwnedAt(long totalStudyLevel) { + return requiredLevel != null && requiredLevel <= totalStudyLevel; + } + + /** + * 충돌 목록을 잘라서 돌려준다. + * + *

비어 있는 조각은 버린다. 운영에서 콤마를 하나 더 찍는 실수(`a,,b`, `a,`)로 + * 빈 문자열이 아이템 키로 취급되면 엉뚱한 슬롯이 해제될 수 있다. + */ + public List conflictKeys() { + if (conflictsWith == null || conflictsWith.isBlank()) { + return List.of(); + } + return Arrays.stream(conflictsWith.split(",")) + .map(String::trim) + .filter(key -> !key.isEmpty()) + .toList(); + } + + public boolean isEquippable() { + return slot != null && slot.isEquippable(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java new file mode 100644 index 00000000..00f2de1c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -0,0 +1,50 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.List; + +/** + * 꾸미기 슬롯과 그리는 순서. + * + *

{@code layerOrder} 는 작을수록 뒤에 깔린다. 프론트가 이 순서를 코드에 박지 않도록 + * {@code GET /api/cosmetics} 응답에 목록으로 실어 보낸다. 나중에 슬롯이 늘거나 순서가 바뀌어도 + * 서버만 고치면 되고, 이미 깔린 앱도 새 순서를 따른다. + * + *

값 사이를 100 씩 띄운 것은 의도된 것이다. 나중에 두 슬롯 사이에 새 슬롯이 필요해지면 + * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. + * + *

{@link #BASE} 는 장착 슬롯이 아니라 개구리 본체다. 본체 이미지도 {@code cosmetic_item} 행으로 + * 두기 때문에 슬롯 이름이 필요해서 여기 있을 뿐이고, 장착 가능한 슬롯 목록과 아이템 목록에서는 빠진다. + * 그래서 {@code equippable} 로 갈라 둔다. + */ +@Getter +@RequiredArgsConstructor +public enum CosmeticSlot { + + BACKGROUND(100, "배경", true), + BACK(200, "가방", true), + /** 개구리 본체. 장착 대상이 아니다. */ + BASE(300, "개구리 본체", false), + OUTFIT(400, "옷", true), + NECK(500, "목", true), + FACE(600, "얼굴", true), + HEAD(700, "머리", true), + HAND(800, "손", true), + BADGE(850, "뱃지", true), + EFFECT(900, "효과", true); + + private final int layerOrder; + private final String nameKo; + private final boolean equippable; + + /** 사용자가 실제로 걸 수 있는 슬롯만. 그리는 순서대로 준다. */ + public static List equippableSlots() { + return Arrays.stream(values()) + .filter(CosmeticSlot::isEquippable) + .sorted(java.util.Comparator.comparingInt(CosmeticSlot::getLayerOrder)) + .toList(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java new file mode 100644 index 00000000..305a2b02 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java @@ -0,0 +1,73 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자가 슬롯별로 무엇을 걸고 있는지. (userId, slot) 하나당 한 행이다. + * + *

(user_id, slot) 복합 기본키가 이 설계의 핵심이다. 한 슬롯에 두 개가 들어가는 것을 + * 애플리케이션 검사가 아니라 DB 가 막는다. 같은 사용자가 같은 슬롯에 두 아이템을 동시에 걸어도 + * 중복 행이 생길 수 없다. 그래서 장착을 + * {@code INSERT ... ON DUPLICATE KEY UPDATE} 한 문장으로 처리할 수 있고, 읽고 나서 쓰는 + * check-then-act 도, 유니크 충돌 예외를 잡아 UPDATE 로 넘어가는 경로도 필요 없다. + * + *

이 엔티티는 읽기 전용으로만 쓴다. 쓰기는 전부 리포지토리의 네이티브 upsert/삭제를 탄다. + * 엔티티를 고쳐 더티 체킹에 맡기면 위 보장이 사라진다. + * + *

user 와 cosmetic_item 에 연관관계를 걸지 않고 식별자만 들고 있다. mission_progress 와 같은 + * 이유로, 외래키를 걸면 INSERT 마다 부모 사용자 행에 공유 잠금이 붙어 이미 사용자 행을 배타 잠금으로 + * 잡는 미션 보상 지급 경로와 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@IdClass(UserCosmeticLoadoutId.class) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_cosmetic_loadout") +public class UserCosmeticLoadout { + + /** + * "이 슬롯을 일부러 비웠다"는 표시. + * + *

해제를 행 삭제로 처리하면 모든 슬롯을 벗은 사용자가 "한 번도 안 건드린 사용자"와 + * 구별되지 않는다. 그러면 다음 조회에서 기본 프리셋이 되살아나 벗은 것이 되돌아온다. + * 맨 개구리를 보고 싶어서 하나씩 벗은 사용자가 앱을 껐다 켜면 다시 옷을 입고 있는 셈이다. + * + *

그래서 해제는 삭제가 아니라 이 값으로 덮어쓴다. 조회할 때 이 값이 든 행은 장착 맵에서 빠진다. + * 언더스코어로 시작해 실제 아이템 키({@code hat_beanie} 같은 소문자 단어)와 겹칠 수 없고, + * 장착 요청은 {@code cosmetic_item} 에 있는 키만 받으므로 클라이언트가 이 값을 넣을 수도 없다. + */ + public static final String NONE = "__none__"; + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + @Id + @Enumerated(EnumType.STRING) + @Column(name = "slot", nullable = false, length = 32) + private CosmeticSlot slot; + + @Column(name = "item_key", nullable = false, length = 64) + private String itemKey; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** 일부러 비워 둔 슬롯인지. 장착 맵을 만들 때 걸러낸다. */ + public boolean isEmptySlot() { + return NONE.equals(itemKey); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java new file mode 100644 index 00000000..3859e388 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadoutId.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * {@link UserCosmeticLoadout} 의 복합 기본키 (user_id, slot). + * + *

JPA 는 복합키를 별도 클래스로 요구하고, 그 클래스는 직렬화 가능하며 equals/hashCode 가 + * 값 기준이어야 한다. 둘 중 하나라도 빠지면 같은 키를 두 번 조회할 때 영속성 컨텍스트가 + * 다른 행으로 착각한다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class UserCosmeticLoadoutId implements Serializable { + + private Long userId; + + private CosmeticSlot slot; +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java new file mode 100644 index 00000000..a2faee0b --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.cosmetic.repository; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface CosmeticItemRepository extends JpaRepository { + + /** + * 화면에 내려갈 아이템 전부. + * + *

{@code BASE}(개구리 본체)까지 함께 온다. 슬롯이 장착 대상인지는 호출부가 가른다. + * 여기서 미리 걸러 두 번 조회하면 목록 한 번에 끝날 것을 두 번 읽게 된다. + */ + List findAllByActiveTrue(); + + Optional findByItemKey(String itemKey); + + /** 세트 장착용. 비활성 아이템은 세트에 끼워 주지 않는다. */ + List findAllBySetIdAndActiveTrueOrderByIdAsc(String setId); + + /** + * 이번 레벨업으로 새로 열린 아이템. + * + *

구간은 {@code (levelBefore, levelAfter]} 다. 앞은 열림, 뒤는 닫힘. + * 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. + * {@code required_level} 이 null 인 아이템은 레벨로 열리지 않으므로 비교에서 저절로 빠진다. + */ + @Query(""" + SELECT i FROM CosmeticItem i + WHERE i.active = true + AND i.requiredLevel > :levelBefore + AND i.requiredLevel <= :levelAfter + ORDER BY i.requiredLevel ASC, i.itemKey ASC + """) + List findUnlockedBetween( + @Param("levelBefore") long levelBefore, + @Param("levelAfter") long levelAfter + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java new file mode 100644 index 00000000..10661d6c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/UserCosmeticLoadoutRepository.java @@ -0,0 +1,68 @@ +package com.aisip.OnO.backend.cosmetic.repository; + +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadoutId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface UserCosmeticLoadoutRepository extends JpaRepository { + + /** 소유권: 장착 상태는 언제나 요청한 사용자 것만 읽는다. */ + List findAllByUserId(Long userId); + + long countByUserId(Long userId); + + /** + * 슬롯 하나를 한 문장으로 걸거나 바꾼다. + * + *

"없으면 넣고 있으면 바꾼다"를 애플리케이션에서 갈라 쓰면 같은 사용자가 같은 슬롯에 + * 동시에 장착할 때 둘 다 "없다"를 읽고 INSERT 해 기본키 충돌이 난다. JPA 에서 제약 위반은 + * 트랜잭션을 rollback-only 로 만들기 때문에 잡아서 UPDATE 로 넘어갈 수도 없다. + * + *

{@code INSERT ... ON DUPLICATE KEY UPDATE} 는 중복 키를 만나면 MySQL 이 그 행에 + * 배타 잠금을 걸고 UPDATE 로 바꿔 실행한다. 예외 경로도 없고 잠금 승격으로 인한 교착도 없다. + * 뒤에 온 요청이 이기고, 어느 쪽이 이기든 행은 하나다. + * + *

해제도 이 메서드를 쓴다. {@code itemKey} 에 {@code UserCosmeticLoadout.NONE} 을 넣으면 + * "일부러 비운 슬롯" 행이 된다. 삭제하지 않는 이유는 엔티티 주석에 있다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_cosmetic_loadout (user_id, slot, item_key, updated_at) + VALUES (:userId, :slot, :itemKey, NOW(6)) + ON DUPLICATE KEY UPDATE + item_key = VALUES(item_key), + updated_at = NOW(6) + """, nativeQuery = true) + int equip( + @Param("userId") Long userId, + @Param("slot") String slot, + @Param("itemKey") String itemKey + ); + + /** + * 행이 없을 때만 넣는다. 이미 있으면 아무것도 하지 않는다. + * + *

기본 프리셋을 실제 행으로 굳힐 때 쓴다. {@code ON DUPLICATE KEY UPDATE user_id = user_id} + * 는 "이미 있으면 그대로 둔다"는 뜻이다. 사용자가 방금 고른 값을 프리셋이 덮어쓰면 안 되고, + * 같은 요청이 동시에 두 번 들어와도 결과가 같아야 한다. + * + *

{@code INSERT IGNORE} 를 쓰지 않는다. 그쪽은 키 충돌만이 아니라 길이 초과·타입 불일치 같은 + * 진짜 오류까지 경고로 삼켜 버린다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_cosmetic_loadout (user_id, slot, item_key, updated_at) + VALUES (:userId, :slot, :itemKey, NOW(6)) + ON DUPLICATE KEY UPDATE user_id = user_id + """, nativeQuery = true) + int insertIfAbsent( + @Param("userId") Long userId, + @Param("slot") String slot, + @Param("itemKey") String itemKey + ); +} From a5592b98246ffd6abea0f725295b877e0a16d0ba Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 18:53:25 +0900 Subject: [PATCH 074/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=C2=B7=EC=9E=A5=EC=B0=A9=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 보유 여부는 required_level <= 총 학습 레벨 로 계산한다. 경계값은 포함이다 - 장착 행이 없는 사용자는 슬롯별 최고 해금 아이템을 걸고 있는 것으로 응답한다 - 첫 장착 때 그 프리셋을 실제 행으로 굳혀, 모자 하나 바꿨다고 나머지가 벗겨지지 않게 했다 - conflicts_with 에 걸리는 다른 슬롯은 자동 해제하고 해제된 슬롯을 응답에 담는다 - 한 요청이 여러 행을 쓸 때는 슬롯 이름 순으로 적용해 교착을 막는다 - CosmeticErrorCase 는 15000 대를 쓴다 --- .../cosmetic/dto/CosmeticEquipRequestDto.java | 21 + .../dto/CosmeticEquipResponseDto.java | 24 ++ .../dto/CosmeticEquipSetRequestDto.java | 10 + .../cosmetic/dto/CosmeticItemResponseDto.java | 39 ++ .../cosmetic/dto/CosmeticListResponseDto.java | 27 ++ .../backend/cosmetic/dto/CosmeticSlotDto.java | 20 + .../cosmetic/dto/UnlockedCosmeticDto.java | 26 ++ .../cosmetic/exception/CosmeticErrorCase.java | 29 ++ .../cosmetic/service/CosmeticService.java | 403 ++++++++++++++++++ 9 files changed, 599 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java new file mode 100644 index 00000000..098bf535 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipRequestDto.java @@ -0,0 +1,21 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import jakarta.validation.constraints.NotNull; + +/** + * 슬롯 하나 장착/해제 요청. + * + *

{@code itemKey} 가 null 이면 그 슬롯을 해제한다. 그래서 이 필드에는 {@code @NotNull} 이 없다. + * + *

{@code slot} 은 아이템에서 유도할 수도 있지만 굳이 함께 받는다. 프론트가 "머리 슬롯을 바꾼다"고 + * 보낸 요청이 서버에서 엉뚱한 슬롯을 건드리면 사용자 눈에는 원인 없는 버그로 보인다. + * 둘이 어긋나면 조용히 아이템 쪽을 따르지 않고 400 으로 거절한다. 해제 요청은 아이템이 없으므로 + * 이 값이 유일한 대상 지정 수단이기도 하다. + */ +public record CosmeticEquipRequestDto( + @NotNull(message = "슬롯을 지정해야 합니다.") + CosmeticSlot slot, + String itemKey +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java new file mode 100644 index 00000000..98cae771 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipResponseDto.java @@ -0,0 +1,24 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +import java.util.List; +import java.util.Map; + +/** + * 장착 결과. + * + *

바뀐 슬롯만이 아니라 갱신된 장착 상태 전체를 돌려준다. 프론트가 자기 쪽 상태를 + * 직접 계산해 맞추면, 충돌 자동 해제처럼 서버만 아는 규칙이 생길 때마다 양쪽이 어긋난다. + * 서버가 만든 결과를 그대로 그리면 그 종류의 불일치가 없어진다. + * + * @param equipped 슬롯 이름 → 아이템 키. 비어 있는 슬롯은 키 자체가 없다. + * @param unequippedSlots 이번 요청 때문에 자동으로 벗겨진 슬롯. 사용자가 직접 벗긴 것이 아니므로 + * 프론트가 "가디건이 벗겨졌어요" 같은 안내를 띄울 수 있도록 따로 알려 준다. + * 충돌이 없으면 빈 배열이다. + */ +public record CosmeticEquipResponseDto( + Map equipped, + List unequippedSlots +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java new file mode 100644 index 00000000..6c444174 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipSetRequestDto.java @@ -0,0 +1,10 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 세트 장착 요청. 세트에 속한 아이템을 각자의 슬롯에 한 번에 건다. */ +public record CosmeticEquipSetRequestDto( + @NotBlank(message = "세트를 지정해야 합니다.") + String setId +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java new file mode 100644 index 00000000..47c6960e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +import java.util.List; + +/** + * 아이템 한 건. + * + *

{@code owned} 는 저장된 값이 아니라 조회 시점에 계산한 값이다 + * ({@code requiredLevel != null && requiredLevel <= 총 학습 레벨}). + * 그런데도 목록으로 실어 보내는 이유는, 나중에 시즌 보상이나 이벤트로 획득 경로가 늘어도 + * 프론트는 이 불리언만 보면 되게 하기 위해서다. 해금 규칙이 바뀌어도 앱은 그대로다. + */ +public record CosmeticItemResponseDto( + String itemKey, + CosmeticSlot slot, + String nameKo, + String imageUrl, + Integer requiredLevel, + String setId, + List conflictsWith, + boolean owned +) { + + public static CosmeticItemResponseDto of(CosmeticItem item, long totalStudyLevel) { + return new CosmeticItemResponseDto( + item.getItemKey(), + item.getSlot(), + item.getNameKo(), + item.getImageUrl(), + item.getRequiredLevel(), + item.getSetId(), + item.conflictKeys(), + item.isOwnedAt(totalStudyLevel) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java new file mode 100644 index 00000000..41ee111e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +import java.util.List; +import java.util.Map; + +/** + * {@code GET /api/cosmetics} 응답. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. + * + * @param baseImageUrl 개구리 본체 이미지. 슬롯 아이템이 아니라 항상 그려진다. + * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 가방(200)과 옷(400) 사이에 들어간다. + * 슬롯 목록에는 본체가 없으므로 이 값이 따로 있어야 프론트가 레이어 순서를 + * 코드에 박지 않고 조립할 수 있다. + * @param slots 장착 가능한 슬롯과 그리는 순서. 뒤에서 앞 순서로 정렬돼 온다. + * @param items 활성 아이템 전부. 잠긴 것도 {@code owned: false} 로 함께 내려간다. + * 화면에 "레벨 6 에 열림" 을 보여주려면 잠긴 것도 알아야 한다. + * @param equipped 슬롯 이름 → 아이템 키. 비어 있는 슬롯은 키 자체가 없다. + */ +public record CosmeticListResponseDto( + String baseImageUrl, + int baseLayerOrder, + List slots, + List items, + Map equipped +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java new file mode 100644 index 00000000..ab8626ef --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java @@ -0,0 +1,20 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +/** + * 슬롯 하나와 그리는 순서. + * + *

순서를 응답에 실어 보내는 이유는 프론트가 레이어 순서를 코드에 박지 않게 하기 위해서다. + * 슬롯이 늘거나 순서가 바뀌어도 이미 깔린 앱이 새 순서를 따른다. + */ +public record CosmeticSlotDto( + CosmeticSlot slot, + int layerOrder, + String nameKo +) { + + public static CosmeticSlotDto from(CosmeticSlot slot) { + return new CosmeticSlotDto(slot, slot.getLayerOrder(), slot.getNameKo()); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java new file mode 100644 index 00000000..3e65bacc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/UnlockedCosmeticDto.java @@ -0,0 +1,26 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; + +/** + * 레벨업으로 이번에 열린 아이템. 보상 수령 응답에 실려 "새 아이템이 열렸어요" 화면을 띄우는 데 쓴다. + * + *

{@code owned} 를 담지 않는다. 여기 실렸다는 것 자체가 방금 열렸다는 뜻이다. + */ +public record UnlockedCosmeticDto( + String itemKey, + String nameKo, + CosmeticSlot slot, + String imageUrl +) { + + public static UnlockedCosmeticDto from(CosmeticItem item) { + return new UnlockedCosmeticDto( + item.getItemKey(), + item.getNameKo(), + item.getSlot(), + item.getImageUrl() + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java b/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java new file mode 100644 index 00000000..f7c86b4f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/exception/CosmeticErrorCase.java @@ -0,0 +1,29 @@ +package com.aisip.OnO.backend.cosmetic.exception; + +import com.aisip.OnO.backend.common.exception.ErrorCase; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 꾸미기 도메인 에러. 15000 대를 쓴다. 앞의 14000 대까지는 이미 다른 도메인이 쓰고 있다. + */ +@Getter +@RequiredArgsConstructor +public enum CosmeticErrorCase implements ErrorCase { + + USER_NOT_FOUND(404, 15001, "해당하는 유저가 존재하지 않습니다."), + + /** 없는 키이거나 비활성 아이템. 어느 쪽인지 구분해 주지 않는다. 2차 콘텐츠 목록이 새어 나간다. */ + COSMETIC_ITEM_NOT_FOUND(404, 15002, "존재하지 않는 꾸미기 아이템입니다."), + + COSMETIC_ITEM_NOT_OWNED(400, 15003, "아직 잠겨 있는 꾸미기 아이템입니다."), + + /** 요청한 슬롯과 아이템이 실제로 속한 슬롯이 다른 경우. */ + COSMETIC_SLOT_MISMATCH(400, 15004, "아이템을 걸 수 없는 슬롯입니다."), + + COSMETIC_SET_NOT_FOUND(404, 15005, "존재하지 않는 꾸미기 세트입니다."); + + private final Integer httpStatusCode; + private final Integer errorCode; + private final String message; +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java new file mode 100644 index 00000000..d4ba043d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -0,0 +1,403 @@ +package com.aisip.OnO.backend.cosmetic.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticItemResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticListResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticSlotDto; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; +import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; +import com.aisip.OnO.backend.cosmetic.repository.CosmeticItemRepository; +import com.aisip.OnO.backend.cosmetic.repository.UserCosmeticLoadoutRepository; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.EnumMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +/** + * 꾸미기 조회와 장착. + * + *

보유 여부는 저장하지 않는다. {@code required_level <= 총 학습 레벨} 로 매번 계산한다. + * 보유를 테이블로 두면 레벨이 오를 때마다 지급이 필요하고, 그 지급이 한 번 밀리면 사용자는 + * 레벨은 올랐는데 아이템은 안 열린 상태로 남는다. 계산으로 두면 그런 상태가 아예 없다. + * 테마 해금이 같은 방식을 쓴다. + * + *

쓰기는 전부 {@code UserCosmeticLoadoutRepository} 의 네이티브 upsert 를 탄다. + * {@code (user_id, slot)} 기본키가 한 슬롯에 하나만 들어가는 것을 DB 수준에서 보장하는데, + * 엔티티를 읽어 고치는 방식으로 쓰면 그 보장이 무의미해진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CosmeticService { + + /** + * 개구리 본체를 가리키는 아이템 키. 슬롯이 {@link CosmeticSlot#BASE} 라 장착 대상이 아니다. + * + *

본체 이미지까지 데이터로 두는 이유는 아이템 이미지와 같다. S3 로 옮길 때 + * {@code image_url} 만 바꾸면 앱 배포 없이 전환돼야 하는데, 본체만 코드 상수면 그 한 장 때문에 + * 배포를 해야 한다. + */ + private static final String BASE_ITEM_KEY = "BASE"; + + /** + * 본체 행이 없을 때 쓸 값. 시드가 항상 넣어 주므로 실제로는 쓰이지 않는다. + * + *

없다고 500 을 내면, 본체 행 하나가 빠졌을 뿐인데 꾸미기 화면 전체가 열리지 않는다. + */ + private static final String FALLBACK_BASE_IMAGE_URL = "assets/Cosmetic/BASE.png"; + + /** 미션 상태가 아직 없는 사용자의 레벨. {@code StudyRoomMapper} 와 같은 기준이다. */ + private static final long DEFAULT_STUDY_LEVEL = 1L; + + private final CosmeticItemRepository cosmeticItemRepository; + private final UserCosmeticLoadoutRepository userCosmeticLoadoutRepository; + private final UserRepository userRepository; + + /** + * 아이템 목록과 현재 장착 상태. + * + *

잠긴 아이템도 {@code owned: false} 로 함께 내려간다. 화면에 "레벨 6 에 열려요" 를 보여주려면 + * 잠긴 것이 무엇인지도 알아야 한다. + * + *

읽기 전용이다. 여기서 장착 행을 만들지 않는다. 목록만 열어 본 사용자 수만큼 빈 행이 생기는 것을 피한다. + */ + public CosmeticListResponseDto getCosmetics(Long userId) { + long level = totalStudyLevelOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + + List items = equippableItems(activeItems).stream() + .map(item -> CosmeticItemResponseDto.of(item, level)) + .toList(); + + return new CosmeticListResponseDto( + baseImageUrl(activeItems), + CosmeticSlot.BASE.getLayerOrder(), + CosmeticSlot.equippableSlots().stream().map(CosmeticSlotDto::from).toList(), + items, + currentEquipped(userId, activeItems, level) + ); + } + + /** + * 슬롯 하나 장착. {@code itemKey} 가 null 이면 해제한다. + * + *

충돌하는 다른 슬롯의 아이템은 자동으로 벗기고, 벗긴 슬롯을 응답에 담는다. + * 충돌을 사용자에게 떠넘기면 "왜 안 걸리지" 하는 상태가 되고, 조용히 벗기기만 하면 + * 사라진 이유를 알 수 없다. 벗기고 알려 준다. + */ + @Transactional + public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String itemKey) { + long level = totalStudyLevelOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + CosmeticItem item = resolveEquipTarget(slot, itemKey, itemsByKey, level); + + materializePresetIfUntouched(userId, activeItems, level); + + // 쓸 내용을 먼저 다 모은 뒤 슬롯 이름 순서로 적용한다. + // + // 한 요청이 여러 행을 건드릴 수 있는데(장착 1 + 충돌 해제 N), 두 요청이 서로 다른 순서로 + // 같은 행들을 잠그면 교착이 난다. 모든 트랜잭션이 같은 순서로 잠그면 교착 자체가 성립하지 않는다. + // 슬롯 이름은 기본키의 두 번째 컬럼이라 이 순서가 곧 인덱스 순서다. + Map writes = orderedWrites(); + writes.put(slot, item == null ? UserCosmeticLoadout.NONE : item.getItemKey()); + + List unequipped = conflictingSlots(userId, item, itemsByKey); + unequipped.forEach(conflicting -> writes.put(conflicting, UserCosmeticLoadout.NONE)); + + applyWrites(userId, writes); + + log.info("userId: {} equipped slot: {}, itemKey: {}, unequipped: {}", + userId, slot, itemKey, unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, level), unequipped); + } + + /** + * 세트 한 번에 장착. + * + *

하나라도 보유하지 않았으면 통째로 거절한다. 되는 것만 걸어 주면 사용자는 "졸업 세트를 걸었는데 + * 학사모만 있는" 어중간한 상태를 보게 되고, 그게 의도인지 버그인지 구별할 수 없다. + */ + @Transactional + public CosmeticEquipResponseDto equipSet(Long userId, String setId) { + long level = totalStudyLevelOf(userId); + List setItems = cosmeticItemRepository.findAllBySetIdAndActiveTrueOrderByIdAsc(setId).stream() + .filter(CosmeticItem::isEquippable) + .toList(); + + if (setItems.isEmpty()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SET_NOT_FOUND); + } + // 검사를 먼저 전부 끝낸다. 걸다가 중간에 거절하면 앞의 몇 개만 걸린 채로 롤백을 믿어야 한다. + boolean anyLocked = setItems.stream().anyMatch(item -> !item.isOwnedAt(level)); + if (anyLocked) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + } + + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + materializePresetIfUntouched(userId, activeItems, level); + + Map writes = orderedWrites(); + setItems.forEach(item -> writes.put(item.getSlot(), item.getItemKey())); + + // 세트 안에서 슬롯이 겹치면 나중 것이 이긴다. 애초에 겹치게 시드하지 않지만, + // 겹쳤을 때 행이 두 개 생기지 않는다는 것 자체는 기본키가 보장한다. + List unequipped = new ArrayList<>(); + for (CosmeticItem item : setItems) { + for (CosmeticSlot conflicting : conflictingSlots(userId, item, itemsByKey)) { + // 세트가 채울 슬롯은 어차피 덮어쓰므로 "벗겼다" 고 알리지 않는다. + if (!writes.containsKey(conflicting) && !unequipped.contains(conflicting)) { + unequipped.add(conflicting); + } + } + } + unequipped.forEach(conflicting -> writes.put(conflicting, UserCosmeticLoadout.NONE)); + + applyWrites(userId, writes); + + log.info("userId: {} equipped set: {}, items: {}, unequipped: {}", + userId, setId, setItems.stream().map(CosmeticItem::getItemKey).toList(), unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, level), unequipped); + } + + /** + * 이번 레벨업으로 새로 열린 아이템. 미션 보상 수령 응답이 쓴다. + * + *

구간은 {@code (levelBefore, levelAfter]} 라, 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. + * 레벨이 그대로면 빈 목록이다. + */ + public List findUnlockedBetween(long levelBefore, long levelAfter) { + if (levelAfter <= levelBefore) { + return List.of(); + } + return cosmeticItemRepository.findUnlockedBetween(levelBefore, levelAfter).stream() + .filter(CosmeticItem::isEquippable) + .map(UnlockedCosmeticDto::from) + .toList(); + } + + // ─────────────────────────── 장착 대상 판정 ─────────────────────────── + + /** + * 요청이 가리키는 아이템. 해제 요청이면 null 을 준다. + * + *

없는 키와 비활성 아이템을 같은 에러로 답한다. 구분해 주면 키를 훑어서 아직 공개하지 않은 + * 2차 콘텐츠 목록을 알아낼 수 있다. + */ + private CosmeticItem resolveEquipTarget(CosmeticSlot slot, String itemKey, + Map itemsByKey, long level) { + if (!slot.isEquippable()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + if (itemKey == null || itemKey.isBlank()) { + return null; + } + + CosmeticItem item = itemsByKey.get(itemKey); + if (item == null || !item.isEquippable()) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + } + if (item.getSlot() != slot) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + if (!item.isOwnedAt(level)) { + throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + } + return item; + } + + /** + * 새로 거는 아이템과 같이 쓸 수 없는, 지금 걸려 있는 슬롯들. + * + *

충돌은 양쪽 모두 본다. 후드 옷에 "모자와 충돌" 이라고만 적고 모자 쪽에는 안 적는 것이 + * 자연스러운데, 한쪽만 보면 모자를 나중에 거는 경우에 검사가 통째로 빠진다. + * 어느 쪽에 적어도 동작해야 데이터를 채우는 사람이 실수하지 않는다. + * + *

같은 슬롯은 대상이 아니다. 어차피 덮어써진다. + * + *

카탈로그에 없는(비활성으로 내려간) 아이템이 걸려 있으면 건드리지 않는다. 충돌 여부를 알 수 없는데 + * 벗기면 사용자 입장에서는 이유 없이 사라진 것이다. + */ + private List conflictingSlots(Long userId, CosmeticItem item, Map itemsByKey) { + if (item == null || (item.conflictKeys().isEmpty() && !anyoneConflictsWith(item, itemsByKey))) { + return List.of(); + } + + List conflicting = new ArrayList<>(); + for (UserCosmeticLoadout row : userCosmeticLoadoutRepository.findAllByUserId(userId)) { + if (row.isEmptySlot() || row.getSlot() == item.getSlot()) { + continue; + } + CosmeticItem equipped = itemsByKey.get(row.getItemKey()); + if (equipped == null) { + continue; + } + if (item.conflictKeys().contains(equipped.getItemKey()) + || equipped.conflictKeys().contains(item.getItemKey())) { + conflicting.add(row.getSlot()); + } + } + conflicting.sort(Comparator.comparing(Enum::name)); + return conflicting; + } + + /** 이 아이템을 충돌 대상으로 지목한 아이템이 하나라도 있는지. 없으면 장착 행을 읽지 않고 끝낸다. */ + private boolean anyoneConflictsWith(CosmeticItem item, Map itemsByKey) { + return itemsByKey.values().stream() + .anyMatch(other -> other.conflictKeys().contains(item.getItemKey())); + } + + // ─────────────────────────── 장착 상태 ─────────────────────────── + + /** + * 지금 걸려 있는 것. 행이 하나도 없으면 기본 프리셋으로 답한다. + * + *

비활성으로 내려간 아이템과 레벨이 내려가 잠긴 아이템은 응답에서 뺀다. 행은 그대로 두고 + * 보여주기만 멈춘다. 프론트는 아이템 목록에 없는 키를 받으면 그릴 이미지가 없고, + * "잠긴 것을 걸고 있다" 는 상태도 화면에서 설명할 방법이 없다. + * 관리자가 레벨을 되돌리면 걸려 있던 것이 그대로 다시 보인다. + */ + private Map currentEquipped(Long userId, List activeItems, long level) { + List rows = userCosmeticLoadoutRepository.findAllByUserId(userId); + if (rows.isEmpty()) { + return defaultPreset(activeItems, level); + } + + Map itemsByKey = indexByKey(activeItems); + Map equipped = new EnumMap<>(CosmeticSlot.class); + for (UserCosmeticLoadout row : rows) { + if (row.isEmptySlot()) { + continue; + } + CosmeticItem item = itemsByKey.get(row.getItemKey()); + if (item == null || !item.isEquippable() || !item.isOwnedAt(level)) { + continue; + } + equipped.put(row.getSlot(), row.getItemKey()); + } + return equipped; + } + + /** + * 장착 행이 없는 사용자에게 보여줄 기본 차림. + * + *

슬롯마다 열린 것 중 {@code required_level} 이 가장 높은 것을 고른다. + * + *

이 프리셋이 있는 이유: 지금 레벨 15 인 사용자는 다 자란 개구리를 보고 있다. + * 꾸미기로 전환하면서 장착 데이터가 없다는 이유로 맨 개구리가 되면, 사용자 입장에서는 + * 아무것도 안 했는데 하향된 것이다. 프리셋이 그 순간을 막는다. + * 마이그레이션으로 전 사용자 행을 만드는 방법도 있지만, 수십만 행을 미리 쓰고 나면 + * 나중에 프리셋 규칙을 바꿀 수 없다. 계산으로 두면 규칙만 고치면 된다. + */ + private Map defaultPreset(List activeItems, long level) { + Map preset = new EnumMap<>(CosmeticSlot.class); + for (CosmeticSlot slot : CosmeticSlot.equippableSlots()) { + activeItems.stream() + .filter(item -> item.getSlot() == slot) + .filter(item -> item.isOwnedAt(level)) + // 레벨이 높은 것이 나중에 얻은 것이다. 같은 레벨에 여러 개면 키 순서로 고정한다. + // 정렬 기준이 없으면 조회할 때마다 다른 것이 걸려 있는 것처럼 보인다. + // isOwnedAt 을 통과한 아이템은 requiredLevel 이 null 이 아니다. + .min(Comparator + .comparingInt((CosmeticItem item) -> -item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)) + .ifPresent(item -> preset.put(slot, item.getItemKey())); + } + return preset; + } + + /** + * 한 번도 장착을 건드리지 않은 사용자의 프리셋을 실제 행으로 굳힌다. + * + *

이게 없으면 첫 장착이 재앙이 된다. 레벨 15 사용자가 모자만 바꾸는 순간 행이 하나 생기고, + * 그때부터 프리셋을 타지 않으므로 배경·옷·가방이 통째로 사라진다. 사용자는 모자를 바꿨을 뿐인데 + * 개구리가 벗겨진다. + * + *

그래서 첫 변경 때 지금 보고 있던 차림을 그대로 행으로 옮겨 적고, 그 위에 변경을 얹는다. + * 사용자가 보던 화면이 그대로 이어진다. + * + *

{@code insertIfAbsent} 라 이미 있는 행은 건드리지 않는다. 행이 하나라도 있으면 + * 이 메서드는 아무 일도 하지 않아야 하는데, 조건 검사와 쓰기 사이에 다른 요청이 끼어들어도 + * 그 성질이 유지된다. + */ + private void materializePresetIfUntouched(Long userId, List activeItems, long level) { + if (userCosmeticLoadoutRepository.countByUserId(userId) > 0) { + return; + } + + Map preset = orderedWrites(); + preset.putAll(defaultPreset(activeItems, level)); + preset.forEach((slot, itemKey) -> + userCosmeticLoadoutRepository.insertIfAbsent(userId, slot.name(), itemKey)); + } + + private void applyWrites(Long userId, Map writes) { + writes.forEach((slot, itemKey) -> userCosmeticLoadoutRepository.equip(userId, slot.name(), itemKey)); + } + + /** 슬롯 이름 순으로 정렬되는 빈 맵. 기본키 순서대로 쓰기 위한 것이다. */ + private Map orderedWrites() { + return new TreeMap<>(Comparator.comparing(Enum::name)); + } + + // ─────────────────────────── 공통 ─────────────────────────── + + private List equippableItems(List activeItems) { + return activeItems.stream() + .filter(CosmeticItem::isEquippable) + // 뒤에서 앞 순서로, 같은 슬롯 안에서는 먼저 열리는 것부터. + // 잠긴 것은 해금 레벨을 모르므로 맨 뒤에 둔다. + .sorted(Comparator + .comparingInt((CosmeticItem item) -> item.getSlot().getLayerOrder()) + .thenComparing(item -> item.getRequiredLevel() == null + ? Integer.MAX_VALUE : item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)) + .toList(); + } + + private String baseImageUrl(List activeItems) { + return activeItems.stream() + .filter(item -> BASE_ITEM_KEY.equals(item.getItemKey())) + .findFirst() + .map(CosmeticItem::getImageUrl) + .orElse(FALLBACK_BASE_IMAGE_URL); + } + + /** 같은 키가 두 번 나올 수 없다. uk_cosmetic_item_key 가 막는다. */ + private Map indexByKey(List items) { + Map byKey = new LinkedHashMap<>(); + items.forEach(item -> byKey.put(item.getItemKey(), item)); + return byKey; + } + + private long totalStudyLevelOf(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new ApplicationException(CosmeticErrorCase.USER_NOT_FOUND)); + + return Optional.ofNullable(user.getUserMissionStatus()) + .map(UserMissionStatus::getTotalStudyLevel) + .orElse(DEFAULT_STUDY_LEVEL); + } +} From ab2cd63dc4dc381fbca903845fc54d49fd6a82b5 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 18:53:25 +0900 Subject: [PATCH 075/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?API=203=EC=A2=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/cosmetics: 본체 이미지, 슬롯·레이어 순서, 아이템 목록, 현재 장착 상태 - PUT /api/cosmetics/equip: 슬롯 하나 장착. itemKey 가 null 이면 해제 - PUT /api/cosmetics/equip-set: 세트 일괄 장착. 하나라도 미보유면 거절 - 사용자 식별자는 본문이 아니라 인증 컨텍스트에서만 꺼낸다 --- .../controller/CosmeticController.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java b/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java new file mode 100644 index 00000000..2ff1163d --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.cosmetic.controller; + +import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipRequestDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipSetRequestDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticListResponseDto; +import com.aisip.OnO.backend.cosmetic.service.CosmeticService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 꾸미기 API. + * + *

사용자 식별자는 요청 본문이 아니라 인증 컨텍스트에서만 꺼낸다. 본문으로 받으면 + * 남의 장착 상태를 바꾸는 요청을 그대로 받아들이게 된다. + * + *

{@code /api/cosmetics} 는 SecurityConfig 의 별도 매처에 걸리지 않아 + * {@code anyRequest().authenticated()} 를 탄다. 비로그인 요청은 401 이다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/cosmetics") +public class CosmeticController { + + private final CosmeticService cosmeticService; + + @GetMapping("") + public CommonResponse getCosmetics() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.getCosmetics(userId)); + } + + /** + * 슬롯 하나 장착. {@code itemKey} 가 null 이면 해제한다. + * + *

같은 슬롯을 다시 걸어도 같은 결과가 되는 멱등 연산이라 POST 가 아니라 PUT 이다. + */ + @PutMapping("/equip") + public CommonResponse equip(@Valid @RequestBody CosmeticEquipRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equip(userId, request.slot(), request.itemKey())); + } + + @PutMapping("/equip-set") + public CommonResponse equipSet(@Valid @RequestBody CosmeticEquipSetRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equipSet(userId, request.setId())); + } +} From b45281cf0df6a3cac2c7d6f2f0458f0d919ed2d1 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 18:54:15 +0900 Subject: [PATCH 076/130] =?UTF-8?q?[Feat]=20=EB=AF=B8=EC=85=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=88=98=EB=A0=B9=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=EC=97=85=20=ED=95=B4=EA=B8=88=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=8B=A3=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MissionClaimResponseDto 에 unlockedCosmetics 를 덧붙였다. 기존 필드는 그대로다 - GrantResult 가 levelBefore 를 함께 돌려준다. 여러 단계 올랐을 때 구간을 알아야 해서다 - 해금 조회는 지급이 끝난 뒤에 한다. 사용자 행 잠금 구간을 늘리지 않는다 - MissionRewardGranter 의 트랜잭션 경계와 사용자 → 진행도 잠금 순서는 건드리지 않았다 --- .../mission/dto/MissionClaimResponseDto.java | 11 ++++++++++- .../mission/service/MissionRewardGranter.java | 14 ++++++++++++-- .../backend/mission/service/MissionService.java | 16 +++++++++++++--- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java index cdc476fe..df705ffe 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/mission/dto/MissionClaimResponseDto.java @@ -1,15 +1,24 @@ package com.aisip.OnO.backend.mission.dto; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; import com.aisip.OnO.backend.mission.entity.MissionRewardType; +import java.util.List; + /** * 보상 수령 결과. 지급 직후의 총 학습 레벨과 이번 지급으로 레벨이 올랐는지를 담는다. + * + *

{@code unlockedCosmetics} 는 이번 지급으로 레벨이 올라 새로 열린 꾸미기 아이템이다. + * 기존 필드는 그대로 두고 뒤에 덧붙였다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. + * 레벨이 안 올랐으면 빈 배열이고, 여러 단계 올랐으면 그 사이에 열린 것이 전부 들어온다. + * null 이 아니라 빈 배열로 내려보낸다. 프론트가 null 검사를 따로 하지 않아도 되게. */ public record MissionClaimResponseDto( Long progressId, MissionRewardType rewardType, int rewardValue, Long totalStudyLevel, - boolean leveledUp + boolean leveledUp, + List unlockedCosmetics ) { } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java index 1cb51b07..38919861 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -63,9 +63,19 @@ public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType re } long levelAfter = status.getTotalStudyLevel(); - return new GrantResult(levelAfter, levelAfter > levelBefore); + return new GrantResult(levelBefore, levelAfter, levelAfter > levelBefore); } - public record GrantResult(Long totalStudyLevel, boolean leveledUp) { + /** + * 지급 결과. + * + *

{@code levelBefore} 를 함께 돌려주는 이유는 호출부가 "이번에 무엇이 열렸는지" 를 + * 계산해야 하기 때문이다. 레벨이 한 번에 여러 단계 오를 수 있어 {@code leveledUp} 만으로는 + * 어디서 어디까지 올랐는지 알 수 없다. + * + *

여기서 해금 아이템까지 조회하지 않는다. 이 클래스는 사용자 행을 배타 잠금으로 잡은 + * 구간이라, 잠금을 들고 하는 일을 늘리면 같은 사용자의 다른 요청이 그만큼 더 기다린다. + */ + public record GrantResult(long levelBefore, Long totalStudyLevel, boolean leveledUp) { } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index 3fa3c7ee..70d565d2 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.mission.service; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.service.CosmeticService; import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryItemDto; import com.aisip.OnO.backend.mission.dto.MissionClaimHistoryResponseDto; import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; @@ -50,6 +52,7 @@ public class MissionService { private final MissionDefinitionRepository missionDefinitionRepository; private final MissionProgressRepository missionProgressRepository; private final MissionRewardGranter missionRewardGranter; + private final CosmeticService cosmeticService; /** * 활성 미션 전부와 이번 기간의 진행도. @@ -221,15 +224,22 @@ public MissionClaimResponseDto claim(Long userId, Long progressId) { MissionRewardGranter.GrantResult grantResult = missionRewardGranter.grant( userId, definition.getMetric(), definition.getRewardType(), definition.getRewardValue()); - log.info("userId: {} claimed missionProgressId: {}, code: {}, reward: {} {}", - userId, progressId, definition.getCode(), definition.getRewardType(), definition.getRewardValue()); + // 해금 조회는 지급이 끝난 뒤에 한다. 사용자 행 잠금을 들고 있는 구간을 늘리지 않기 위해서다. + // cosmetic_item 은 애플리케이션이 읽기만 하는 카탈로그라 이 조회가 새 잠금 순서를 만들지 않는다. + List unlockedCosmetics = cosmeticService.findUnlockedBetween( + grantResult.levelBefore(), grantResult.totalStudyLevel()); + + log.info("userId: {} claimed missionProgressId: {}, code: {}, reward: {} {}, unlocked: {}", + userId, progressId, definition.getCode(), definition.getRewardType(), definition.getRewardValue(), + unlockedCosmetics.stream().map(UnlockedCosmeticDto::itemKey).toList()); return new MissionClaimResponseDto( progressId, definition.getRewardType(), definition.getRewardValue(), grantResult.totalStudyLevel(), - grantResult.leveledUp() + grantResult.leveledUp(), + unlockedCosmetics ); } From 0cd3cc40db86d4c480d07e00179227e06c33ff21 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 19:16:25 +0900 Subject: [PATCH 077/130] =?UTF-8?q?[Test]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20API=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CosmeticItemSeeder 는 V35 의 INSERT 문을 그대로 읽어 실행한다. 값을 두 곳에 적지 않는다 - 해금 경계(required_level == 레벨) 포함 여부를 파라미터 테스트로 고정 - 프리셋이 행을 만들지 않는 것, 첫 장착 때 굳는 것, 다 벗어도 안 되살아나는 것 - 미보유·비활성·슬롯 불일치 거절, 응답 필드 이름과 슬롯 레이어 순서 고정 - ErrorCase 도메인 개수 기준을 14 로 올렸다 --- .../exception/ErrorCaseContractTest.java | 2 +- .../controller/CosmeticControllerTest.java | 204 +++++++ .../cosmetic/service/CosmeticServiceTest.java | 560 ++++++++++++++++++ .../cosmetic/support/CosmeticItemSeeder.java | 79 +++ .../cosmetic/support/CosmeticTestSupport.java | 113 ++++ 5 files changed, 957 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java diff --git a/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java b/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java index 97c50d40..7b018579 100644 --- a/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/exception/ErrorCaseContractTest.java @@ -34,7 +34,7 @@ class ErrorCaseContractTest { private static final String BASE_PACKAGE = "com.aisip.OnO.backend"; /** 도메인별 ErrorCase enum 개수. 새 도메인을 추가하면 늘어난다. */ - private static final int MINIMUM_ERROR_CASE_TYPES = 13; + private static final int MINIMUM_ERROR_CASE_TYPES = 14; @Nested @DisplayName("전체 집합") diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java new file mode 100644 index 00000000..6a4875c4 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java @@ -0,0 +1,204 @@ +package com.aisip.OnO.backend.cosmetic.controller; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; +import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; + +import java.util.HashMap; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 프론트와 맞춘 응답 형태를 고정한다. 필드 이름이나 묶음 구조가 바뀌면 앱이 그대로 깨진다. + */ +@DisplayName("꾸미기 API 계약") +class CosmeticControllerTest extends CosmeticTestSupport { + + @Test + @DisplayName("GET /api/cosmetics - 본체·슬롯·아이템·장착 상태를 한 번에 내려준다") + void getCosmetics() throws Exception { + User user = userAtLevel(6); + authenticateAs(user.getId()); + + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.baseImageUrl").value("assets/Cosmetic/BASE.png")) + .andExpect(jsonPath("$.data.baseLayerOrder").value(300)) + .andExpect(jsonPath("$.data.slots.length()").value(9)) + .andExpect(jsonPath("$.data.slots[0].slot").value("BACKGROUND")) + .andExpect(jsonPath("$.data.slots[0].layerOrder").value(100)) + .andExpect(jsonPath("$.data.slots[0].nameKo").value("배경")) + .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_SPRING)); + } + + @Test + @DisplayName("GET /api/cosmetics - 아이템 한 건의 필드가 계약대로다") + void itemShape() throws Exception { + User user = userAtLevel(6); + authenticateAs(user.getId()); + + // 배경(100) 다음이 가방(200), 그다음 옷(400)... 머리(700)의 첫 아이템이 새싹 머리띠(레벨 2)다. + String beanie = "$.data.items[?(@.itemKey == '" + HAT_BEANIE + "')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(beanie + ".slot").value("HEAD")) + .andExpect(jsonPath(beanie + ".nameKo").value("비니")) + .andExpect(jsonPath(beanie + ".imageUrl").value("assets/Cosmetic/hat_beanie.png")) + .andExpect(jsonPath(beanie + ".requiredLevel").value(6)) + .andExpect(jsonPath(beanie + ".setId").value(org.hamcrest.Matchers.contains( + org.hamcrest.Matchers.nullValue()))) + .andExpect(jsonPath(beanie + ".conflictsWith").value(org.hamcrest.Matchers.contains( + org.hamcrest.Matchers.empty()))) + .andExpect(jsonPath(beanie + ".owned").value(true)); + } + + @Test + @DisplayName("GET /api/cosmetics - 비로그인 요청은 401 이다") + void requiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 갱신된 장착 상태 전체와 벗겨진 슬롯을 돌려준다") + void equip() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("HEAD", HAT_BEANIE))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_NIGHT)) + .andExpect(jsonPath("$.data.unequippedSlots").isArray()) + .andExpect(jsonPath("$.data.unequippedSlots.length()").value(0)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - itemKey 가 null 이면 그 슬롯만 벗는다") + void unequip() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("HEAD", null))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.HEAD").doesNotExist()) + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_NIGHT)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 보유하지 않은 아이템은 400 이다") + void rejectsUnownedItem() throws Exception { + User user = userAtLevel(5); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("HEAD", HAT_BEANIE))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED.getErrorCode())) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 없는 슬롯 이름은 400 이다") + void rejectsUnknownSlot() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slot\":\"WINGS\",\"itemKey\":null}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 슬롯이 빠지면 400 이다") + void rejectsMissingSlot() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"itemKey\":\"" + HAT_BEANIE + "\"}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-set - 세트가 각 슬롯에 한 번에 걸린다") + void equipSet() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-set") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"setId\":\"" + GRADUATE_SET + "\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_GRADUATE)) + .andExpect(jsonPath("$.data.equipped.OUTFIT").value(OUTFIT_GRADUATE)) + .andExpect(jsonPath("$.data.equipped.HAND").value(PROP_DIPLOMA)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-set - 하나라도 미보유면 400 이다") + void rejectsPartiallyOwnedSet() throws Exception { + User user = userAtLevel(14); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-set") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"setId\":\"" + GRADUATE_SET + "\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED.getErrorCode())); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 비로그인 요청은 401 이다") + void equipRequiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("HEAD", HAT_BEANIE))) + .andExpect(status().isUnauthorized()); + } + + /** {@code itemKey} 가 null 인 본문을 만들어야 해서 Map 으로 직렬화한다. */ + private String body(String slot, String itemKey) throws Exception { + Map body = new HashMap<>(); + body.put("slot", slot); + body.put("itemKey", itemKey); + return objectMapper.writeValueAsString(body); + } + + /** 슬롯 이름이 enum 상수와 그대로 맞는지. 응답의 키가 곧 이 이름이다. */ + @Test + @DisplayName("슬롯 이름은 enum 상수 이름 그대로 나간다") + void slotNamesAreEnumNames() throws Exception { + User user = userAtLevel(15); + authenticateAs(user.getId()); + + for (CosmeticSlot slot : CosmeticSlot.equippableSlots()) { + mockMvc.perform(get("/api/cosmetics")) + .andExpect(jsonPath("$.data.slots[?(@.slot == '" + slot.name() + "')]") + .exists()); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java new file mode 100644 index 00000000..3c9e8e33 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -0,0 +1,560 @@ +package com.aisip.OnO.backend.cosmetic.service; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticItemResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticListResponseDto; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticSlotDto; +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; +import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; +import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("꾸미기 서비스") +class CosmeticServiceTest extends CosmeticTestSupport { + + @Nested + @DisplayName("아이템 목록") + class ItemCatalog { + + @Test + @DisplayName("잠긴 아이템도 owned false 로 함께 내려간다") + void includesLockedItems() { + User user = userAtLevel(6); + + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + assertThat(ownedOf(response, HAT_BEANIE)) + .as("레벨 6 아이템은 레벨 6 에서 열린다 - 경계는 포함이다") + .isTrue(); + assertThat(ownedOf(response, BG_STUDY)) + .as("레벨 7 아이템은 아직 잠겨 있지만 목록에는 있어야 한다") + .isFalse(); + assertThat(itemKeys(response)).contains(BG_STUDY); + } + + @ParameterizedTest(name = "레벨 {0} 이면 레벨 {1} 아이템 보유는 {2}") + @CsvSource({ + "5, 6, false", + "6, 6, true", + "7, 6, true", + }) + @DisplayName("해금 경계는 required_level == 레벨 을 포함한다") + void unlockBoundaryIsInclusive(long userLevel, int itemLevel, boolean expected) { + User user = userAtLevel(userLevel); + // 비교 대상은 시드된 레벨 6 아이템(비니) 하나다. + assertThat(itemLevel).isEqualTo(6); + + assertThat(ownedOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE)).isEqualTo(expected); + } + + @Test + @DisplayName("비활성 아이템은 목록에 나오지 않는다") + void excludesInactiveItems() { + User user = userAtLevel(15); + + assertThat(itemKeys(cosmeticService.getCosmetics(user.getId()))) + .as("2차 콘텐츠가 목록에 새어 나가면 안 된다") + .doesNotContain(INACTIVE_HAT_BERET); + } + + @Test + @DisplayName("개구리 본체는 아이템이 아니라 baseImageUrl 로 나간다") + void baseIsNotAnItem() { + User user = userAtLevel(15); + + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + assertThat(response.baseImageUrl()).isEqualTo("assets/Cosmetic/BASE.png"); + assertThat(response.baseLayerOrder()).isEqualTo(300); + assertThat(itemKeys(response)).doesNotContain("BASE"); + assertThat(response.items()) + .extracting(CosmeticItemResponseDto::slot) + .doesNotContain(CosmeticSlot.BASE); + } + + @Test + @DisplayName("슬롯 목록은 장착 가능한 것만, 뒤에서 앞 순서로 나간다") + void slotsAreOrderedBackToFront() { + User user = userAtLevel(1); + + List slots = cosmeticService.getCosmetics(user.getId()).slots(); + + assertThat(slots).extracting(CosmeticSlotDto::slot) + .containsExactly( + CosmeticSlot.BACKGROUND, CosmeticSlot.BACK, CosmeticSlot.OUTFIT, + CosmeticSlot.NECK, CosmeticSlot.FACE, CosmeticSlot.HEAD, + CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT); + assertThat(slots).extracting(CosmeticSlotDto::layerOrder) + .containsExactly(100, 200, 400, 500, 600, 700, 800, 850, 900); + assertThat(slots.get(0).nameKo()).isEqualTo("배경"); + } + + @Test + @DisplayName("이미지 경로는 번들 상대 경로다 - S3 전환은 컬럼 값만 바꾸면 된다") + void imageUrlIsBundlePath() { + User user = userAtLevel(6); + + assertThat(itemOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE).imageUrl()) + .isEqualTo("assets/Cosmetic/hat_beanie.png"); + } + + @Test + @DisplayName("충돌 목록은 비어 있어도 빈 배열로 나간다") + void conflictsAreEmptyList() { + User user = userAtLevel(6); + + assertThat(itemOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE).conflictsWith()).isEmpty(); + } + } + + @Nested + @DisplayName("기본 프리셋") + class DefaultPreset { + + @Test + @DisplayName("장착 행이 없는 사용자는 슬롯마다 가장 높은 해금 아이템을 걸고 있다") + void presetPicksHighestUnlockedPerSlot() { + User user = userAtLevel(15); + + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) + .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) + .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) + .containsEntry(CosmeticSlot.FACE, GLASSES_SUN) + .containsEntry(CosmeticSlot.NECK, SCARF) + .containsEntry(CosmeticSlot.BACK, BAG_MINI_BACKPACK) + .doesNotContainKey(CosmeticSlot.BADGE) + .doesNotContainKey(CosmeticSlot.EFFECT); + } + + @Test + @DisplayName("레벨이 낮으면 열린 것만 걸린다") + void presetFollowsLevel() { + User user = userAtLevel(6); + + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPRING) + .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND) + .containsEntry(CosmeticSlot.NECK, SCARF) + .as("레벨 8 가방은 아직 안 열렸다") + .doesNotContainKey(CosmeticSlot.BACK); + } + + @Test + @DisplayName("레벨 1 은 아무것도 걸치지 않은 맨 개구리다") + void level1HasNothing() { + User user = userAtLevel(1); + + assertThat(equippedOf(user.getId())).isEmpty(); + } + + @Test + @DisplayName("해금 레벨이 같으면 키 순서로 고정된다 - 조회할 때마다 달라지면 안 된다") + void presetTieIsStable() { + User user = userAtLevel(15); + // hat_bucket 을 학사모와 같은 레벨로 올려 동점을 만든다. + jdbcTemplate.update("UPDATE cosmetic_item SET required_level = 15 WHERE item_key = ?", HAT_BUCKET); + + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.HEAD, HAT_BUCKET); + assertThat(equippedOf(user.getId())) + .as("같은 요청을 두 번 해도 같아야 한다") + .containsEntry(CosmeticSlot.HEAD, HAT_BUCKET); + } + + @Test + @DisplayName("조회만으로는 행이 생기지 않는다") + void readingDoesNotCreateRows() { + User user = userAtLevel(15); + + cosmeticService.getCosmetics(user.getId()); + cosmeticService.getCosmetics(user.getId()); + + assertThat(loadoutRowCount(user.getId())) + .as("목록만 열어 본 사용자 수만큼 빈 행이 생기면 안 된다") + .isZero(); + } + + @Test + @DisplayName("첫 장착 때 프리셋이 행으로 굳어 나머지 슬롯이 벗겨지지 않는다") + void firstEquipMaterializesPreset() { + User user = userAtLevel(15); + + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(equippedOf(user.getId())) + .as("모자만 바꿨는데 배경·옷·가방이 사라지면 사용자 눈에는 하향이다") + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) + .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) + .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA); + assertThat(loadoutRowCount(user.getId())).isEqualTo(7); + } + + @Test + @DisplayName("한 번 장착한 뒤로는 레벨이 올라도 프리셋을 타지 않는다") + void presetDoesNotComeBackAfterFirstChange() { + User user = userAtLevel(6); + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HEADBAND_SPROUT); + + setLevel(user, 15); + + assertThat(equippedOf(user.getId())) + .as("사용자가 고른 머리띠를 레벨업이 학사모로 바꿔치기하면 안 된다") + .containsEntry(CosmeticSlot.HEAD, HEADBAND_SPROUT); + } + + @Test + @DisplayName("모든 슬롯을 벗어도 프리셋이 되살아나지 않는다") + void unequippingEverythingSticks() { + User user = userAtLevel(15); + Map preset = equippedOf(user.getId()); + + preset.keySet().forEach(slot -> cosmeticService.equip(user.getId(), slot, null)); + + assertThat(equippedOf(user.getId())) + .as("맨 개구리를 보려고 다 벗은 사용자가 앱을 껐다 켜면 다시 입고 있으면 안 된다") + .isEmpty(); + } + } + + @Nested + @DisplayName("장착") + class Equip { + + @Test + @DisplayName("보유한 아이템을 걸면 갱신된 장착 상태 전체가 돌아온다") + void equipsOwnedItem() { + User user = userAtLevel(15); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); + + assertThat(response.equipped()).containsEntry(CosmeticSlot.HEAD, HAT_BUCKET); + assertThat(response.unequippedSlots()).isEmpty(); + } + + @Test + @DisplayName("같은 슬롯을 다시 걸어도 행은 하나다") + void equipIsIdempotentPerSlot() { + User user = userAtLevel(15); + + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_CROWN); + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + Long headRows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_cosmetic_loadout WHERE user_id = ? AND slot = 'HEAD'", + Long.class, user.getId()); + assertThat(headRows).as("(user_id, slot) 기본키가 한 슬롯 한 행을 보장한다").isEqualTo(1); + assertThat(equippedOf(user.getId())).containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + } + + @Test + @DisplayName("itemKey 가 null 이면 그 슬롯만 벗는다") + void nullItemKeyUnequipsSlot() { + User user = userAtLevel(15); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, null); + + assertThat(response.equipped()) + .doesNotContainKey(CosmeticSlot.HEAD) + .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT); + } + + @Test + @DisplayName("해제는 행 삭제가 아니라 표시로 남는다") + void unequipLeavesMarker() { + User user = userAtLevel(15); + + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, null); + + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.HEAD)) + .as("행을 지우면 한 번도 안 건드린 사용자와 구별되지 않아 프리셋이 되살아난다") + .isEqualTo(UserCosmeticLoadout.NONE); + } + + @Test + @DisplayName("보유하지 않은 아이템은 걸 수 없다") + void rejectsUnownedItem() { + User user = userAtLevel(5); + + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + assertThat(loadoutRowCount(user.getId())) + .as("거절된 요청이 프리셋을 굳혀 놓고 가면 안 된다") + .isZero(); + } + + @Test + @DisplayName("없는 아이템과 비활성 아이템은 같은 에러로 답한다") + void hidesInactiveItems() { + User user = userAtLevel(15); + + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, "not_exists")) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, INACTIVE_HAT_BERET)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .as("비활성이라고 알려 주면 키를 훑어 2차 콘텐츠 목록을 알아낼 수 있다") + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + } + + @Test + @DisplayName("슬롯과 아이템이 어긋나면 조용히 고쳐 주지 않고 거절한다") + void rejectsSlotMismatch() { + User user = userAtLevel(15); + + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, HAT_BEANIE)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + + @Test + @DisplayName("개구리 본체 슬롯에는 아무것도 걸 수 없다") + void rejectsBaseSlot() { + User user = userAtLevel(15); + + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.BASE, "BASE")) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + + @Test + @DisplayName("남의 장착 상태는 건드리지 않는다") + void doesNotTouchOtherUsers() { + User user = userAtLevel(15); + User other = userAtLevel(15); + cosmeticService.equip(other.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); + + assertThat(equippedOf(other.getId())).containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + } + } + + @Nested + @DisplayName("충돌 자동 해제") + class ConflictResolution { + + @Test + @DisplayName("충돌하는 다른 슬롯은 벗겨지고 벗긴 슬롯이 응답에 담긴다") + void unequipsConflictingSlot() { + User user = userAtLevel(15); + setConflicts(HAT_BEANIE, OUTFIT_CARDIGAN); + cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(response.unequippedSlots()).containsExactly(CosmeticSlot.OUTFIT); + assertThat(response.equipped()) + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) + .doesNotContainKey(CosmeticSlot.OUTFIT); + } + + @Test + @DisplayName("충돌은 반대쪽에만 적혀 있어도 잡힌다") + void conflictIsBidirectional() { + User user = userAtLevel(15); + // 옷 쪽에만 적는다. 모자를 나중에 거는 순서에서도 걸려야 한다. + setConflicts(OUTFIT_CARDIGAN, HAT_BEANIE); + cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(response.unequippedSlots()).containsExactly(CosmeticSlot.OUTFIT); + } + + @Test + @DisplayName("여러 개가 걸리면 전부 벗긴다") + void unequipsEveryConflict() { + User user = userAtLevel(15); + setConflicts(HAT_BEANIE, OUTFIT_GRADUATE + "," + SCARF); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(response.unequippedSlots()) + .containsExactly(CosmeticSlot.NECK, CosmeticSlot.OUTFIT); + } + + @Test + @DisplayName("충돌 목록에 빈 조각이 섞여 있어도 엉뚱한 슬롯을 벗기지 않는다") + void ignoresBlankConflictEntries() { + User user = userAtLevel(15); + setConflicts(HAT_BEANIE, ",, ,"); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(response.unequippedSlots()).isEmpty(); + } + + @Test + @DisplayName("충돌이 없으면 아무것도 벗기지 않는다") + void noConflictKeepsEverything() { + User user = userAtLevel(15); + + CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(response.unequippedSlots()).isEmpty(); + assertThat(response.equipped()).hasSize(7); + } + } + + @Nested + @DisplayName("세트 장착") + class EquipSet { + + @Test + @DisplayName("세트에 속한 아이템이 각자의 슬롯에 한 번에 걸린다") + void equipsWholeSet() { + User user = userAtLevel(15); + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); + + assertThat(response.equipped()) + .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) + .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA); + } + + @Test + @DisplayName("하나라도 미보유면 통째로 거절한다") + void rejectsPartiallyOwnedSet() { + User user = userAtLevel(14); + + assertThatThrownBy(() -> cosmeticService.equipSet(user.getId(), GRADUATE_SET)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + assertThat(loadoutRowCount(user.getId())) + .as("거절된 요청은 아무것도 쓰지 않는다") + .isZero(); + } + + @Test + @DisplayName("없는 세트는 거절한다") + void rejectsUnknownSet() { + User user = userAtLevel(15); + + assertThatThrownBy(() -> cosmeticService.equipSet(user.getId(), "not_exists")) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_SET_NOT_FOUND); + } + + @Test + @DisplayName("세트가 채우지 않는 슬롯은 그대로 남는다") + void keepsUntouchedSlots() { + User user = userAtLevel(15); + + CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); + + assertThat(response.equipped()).containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT); + } + + @Test + @DisplayName("세트와 충돌하는 다른 슬롯은 벗긴다") + void unequipsConflictingSlot() { + User user = userAtLevel(15); + setConflicts(HAT_GRADUATE, SCARF); + + CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); + + assertThat(response.unequippedSlots()).containsExactly(CosmeticSlot.NECK); + assertThat(response.equipped()).doesNotContainKey(CosmeticSlot.NECK); + } + } + + @Nested + @DisplayName("레벨업 해금 목록") + class UnlockLookup { + + @Test + @DisplayName("올라간 구간에서 열린 것만 담긴다") + void unlocksWithinRange() { + List unlocked = cosmeticService.findUnlockedBetween(5, 7); + + assertThat(unlocked) + .extracting(UnlockedCosmeticDto::itemKey) + .as("구간은 (levelBefore, levelAfter] 다") + .containsExactly(HAT_BEANIE, BG_STUDY); + } + + @Test + @DisplayName("여러 단계를 한 번에 올라도 전부 담긴다") + void unlocksAcrossManyLevels() { + assertThat(cosmeticService.findUnlockedBetween(1, 15)) + .extracting(UnlockedCosmeticDto::itemKey) + .hasSize(16) + .startsWith(HEADBAND_SPROUT) + .contains(HAT_GRADUATE, OUTFIT_GRADUATE, PROP_DIPLOMA); + } + + @Test + @DisplayName("레벨이 안 올랐으면 비어 있다") + void emptyWhenLevelUnchanged() { + assertThat(cosmeticService.findUnlockedBetween(6, 6)).isEmpty(); + assertThat(cosmeticService.findUnlockedBetween(7, 6)).isEmpty(); + } + + @Test + @DisplayName("비활성 아이템은 해금 알림에 끼지 않는다") + void skipsInactiveItems() { + jdbcTemplate.update("UPDATE cosmetic_item SET active = 0 WHERE item_key = ?", HAT_BEANIE); + + assertThat(cosmeticService.findUnlockedBetween(5, 7)) + .extracting(UnlockedCosmeticDto::itemKey) + .containsExactly(BG_STUDY); + } + + @Test + @DisplayName("해금 항목에는 이름과 이미지가 함께 실린다") + void carriesDisplayFields() { + UnlockedCosmeticDto unlocked = cosmeticService.findUnlockedBetween(5, 6).get(0); + + assertThat(unlocked.itemKey()).isEqualTo(HAT_BEANIE); + assertThat(unlocked.nameKo()).isEqualTo("비니"); + assertThat(unlocked.slot()).isEqualTo(CosmeticSlot.HEAD); + assertThat(unlocked.imageUrl()).isEqualTo("assets/Cosmetic/hat_beanie.png"); + } + } + + // ─────────────────────────── 헬퍼 ─────────────────────────── + + private List itemKeys(CosmeticListResponseDto response) { + return response.items().stream().map(CosmeticItemResponseDto::itemKey).toList(); + } + + private CosmeticItemResponseDto itemOf(CosmeticListResponseDto response, String itemKey) { + return response.items().stream() + .filter(item -> item.itemKey().equals(itemKey)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("목록에 없는 아이템이다: " + itemKey)); + } + + private boolean ownedOf(CosmeticListResponseDto response, String itemKey) { + return itemOf(response, itemKey).owned(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java new file mode 100644 index 00000000..3fc66d5c --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java @@ -0,0 +1,79 @@ +package com.aisip.OnO.backend.cosmetic.support; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; + +/** + * 꾸미기 아이템 시드를 테스트 DB 에 넣는다. + * + *

테스트 프로필은 Flyway 가 꺼져 있고 스키마를 Hibernate 가 만든다. 그래서 시드 INSERT 가 + * 저절로 실행되지 않는데, 꾸미기 조회·장착은 카탈로그가 없으면 아무것도 하지 못하므로 + * 시드가 없으면 이 도메인 테스트는 전부 무의미해진다. + * + *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 INSERT 문을 그대로 읽어 실행한다. + * 같은 값을 두 곳에 적으면 한쪽만 고쳐질 때 테스트가 프로덕션 시드와 다른 것을 검증하게 된다. + * {@code MissionDefinitionSeeder} 와 같은 방식이다. + * + *

{@code DatabaseCleaner} 가 테스트마다 모든 테이블을 비우므로 매 테스트 시작 시 다시 넣어야 한다. + */ +@Component +public class CosmeticItemSeeder { + + /** 카탈로그를 손대는 마이그레이션을 새로 만들면 적용 순서대로 여기에 더한다. */ + private static final List MIGRATION_PATHS = List.of( + "db/migration/V35__seed_cosmetic_items.sql" + ); + + @PersistenceContext + private EntityManager entityManager; + + private List statements; + + @Transactional + public void seed() { + if (statements == null) { + statements = MIGRATION_PATHS.stream() + .flatMap(path -> loadStatements(path).stream()) + .toList(); + } + for (String statement : statements) { + entityManager.createNativeQuery(statement).executeUpdate(); + } + } + + private List loadStatements(String path) { + // 주석 줄에도 세미콜론이 들어갈 수 있어 먼저 걷어낸다. + String withoutComments = Arrays.stream(readMigration(path).split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .reduce(new StringBuilder(), (sb, line) -> sb.append(line).append('\n'), StringBuilder::append) + .toString(); + + return Arrays.stream(withoutComments.split(";")) + .map(String::trim) + .filter(statement -> { + String upper = statement.toUpperCase(Locale.ROOT); + return upper.startsWith("INSERT") || upper.startsWith("UPDATE"); + }) + .toList(); + } + + private String readMigration(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("꾸미기 시드 마이그레이션을 읽지 못했다: " + path, e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java new file mode 100644 index 00000000..b1a8ae79 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java @@ -0,0 +1,113 @@ +package com.aisip.OnO.backend.cosmetic.support; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.repository.CosmeticItemRepository; +import com.aisip.OnO.backend.cosmetic.repository.UserCosmeticLoadoutRepository; +import com.aisip.OnO.backend.cosmetic.service.CosmeticService; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.Map; + +/** + * 꾸미기 도메인 테스트의 공통 베이스. + * + *

{@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + */ +public abstract class CosmeticTestSupport extends IntegrationTestSupport { + + protected static final String HEADBAND_SPROUT = "headband_sprout"; + protected static final String BG_SPRING = "bg_spring"; + protected static final String GLASSES_ROUND = "glasses_round"; + protected static final String SCARF = "scarf"; + protected static final String HAT_BEANIE = "hat_beanie"; + protected static final String BG_STUDY = "bg_study"; + protected static final String BAG_MINI_BACKPACK = "bag_mini_backpack"; + protected static final String OUTFIT_CARDIGAN = "outfit_cardigan"; + protected static final String PROP_STUDY = "prop_study"; + protected static final String GLASSES_SUN = "glasses_sun"; + protected static final String HAT_BUCKET = "hat_bucket"; + protected static final String BG_NIGHT = "bg_night"; + protected static final String HAT_CROWN = "hat_crown"; + protected static final String HAT_GRADUATE = "hat_graduate"; + protected static final String OUTFIT_GRADUATE = "outfit_graduate"; + protected static final String PROP_DIPLOMA = "prop_diploma"; + protected static final String GRADUATE_SET = "graduate"; + /** 2차 콘텐츠라 active = 0 으로 시드된 아이템. 비활성 취급을 확인할 때 쓴다. */ + protected static final String INACTIVE_HAT_BERET = "hat_beret"; + + @Autowired + private CosmeticItemSeeder cosmeticItemSeeder; + + @Autowired + protected CosmeticService cosmeticService; + + @Autowired + protected CosmeticItemRepository cosmeticItemRepository; + + @Autowired + protected UserCosmeticLoadoutRepository userCosmeticLoadoutRepository; + + @Autowired + protected UserRepository userRepository; + + @Autowired + protected JdbcTemplate jdbcTemplate; + + @Autowired + protected TransactionTemplate transactionTemplate; + + /** DatabaseCleaner 가 매 테스트마다 cosmetic_item 까지 비우므로 여기서 다시 채운다. */ + @BeforeEach + void seedCosmeticItems() { + cosmeticItemSeeder.seed(); + } + + /** + * 사용자의 총 학습 레벨을 원하는 값으로 맞춘다. + * + *

경험치를 쌓아 올리면 레벨 15 를 만드는 데 수천 점이 필요하고, 그 과정에서 미션 적립까지 + * 끌려 들어와 무엇을 재는 테스트인지 흐려진다. 관리자 경로가 쓰는 것과 같은 setter 를 쓴다. + */ + protected User setLevel(User user, long level) { + return transactionTemplate.execute(status -> { + User managed = userRepository.findById(user.getId()).orElseThrow(); + managed.getUserMissionStatus().setTotalStudyLevel(level, 0L); + return userRepository.save(managed); + }); + } + + protected User userAtLevel(long level) { + return setLevel(fixtures.createUser(), level); + } + + /** 지금 걸려 있는 것. 서비스 조회 응답에서 꺼낸다. */ + protected Map equippedOf(Long userId) { + return cosmeticService.getCosmetics(userId).equipped(); + } + + /** 아이템의 충돌 목록을 채운다. 시드는 전부 비어 있으므로 충돌 동작은 이렇게 만들어 확인한다. */ + protected void setConflicts(String itemKey, String conflictsWith) { + jdbcTemplate.update("UPDATE cosmetic_item SET conflicts_with = ? WHERE item_key = ?", + conflictsWith, itemKey); + } + + /** 장착 행 개수. 프리셋이 행을 만들지 않는지, 첫 변경 때 굳어지는지 확인할 때 쓴다. */ + protected long loadoutRowCount(Long userId) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_cosmetic_loadout WHERE user_id = ?", Long.class, userId); + return count == null ? 0 : count; + } + + protected String rawItemKeyOf(Long userId, CosmeticSlot slot) { + return jdbcTemplate.query( + "SELECT item_key FROM user_cosmetic_loadout WHERE user_id = ? AND slot = ?", + rs -> rs.next() ? rs.getString(1) : null, + userId, slot.name()); + } +} From 3562fd84c15e797fe895b4c938101ea3ac919c25 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 11 Sep 2026 19:16:25 +0900 Subject: [PATCH 078/130] =?UTF-8?q?[Test]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=C2=B7=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=C2=B7=EB=AF=B8=EC=85=98=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 슬롯 8스레드 동시 장착에도 행이 하나뿐인지, 서로 다른 슬롯이 둘 다 남는지 - 세트 장착과 단일 장착이 겹쳐도 교착 없이 끝나는지 - DDL·시드 재실행 안전성과 카탈로그 정합성(레벨 2~15 빈칸 없음, 경로와 키 일치) - 레벨업 해금 알림이 구간대로 실리는지, 기존 응답 필드가 그대로인지 --- .../CosmeticEquipConcurrencyTest.java | 133 ++++++++++ .../migration/CosmeticMigrationTest.java | 228 ++++++++++++++++++ .../service/MissionCosmeticUnlockTest.java | 111 +++++++++ 3 files changed, 472 insertions(+) create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java new file mode 100644 index 00000000..769880e3 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java @@ -0,0 +1,133 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 같은 사용자의 장착 요청이 동시에 들어올 때. + * + *

장착이 기대는 보장은 하나뿐이다. {@code user_cosmetic_loadout} 의 {@code (user_id, slot)} + * 복합 기본키다. 이 키가 있으니 "없으면 INSERT 있으면 UPDATE" 를 애플리케이션에서 가르지 않고 + * upsert 한 문장으로 처리할 수 있고, 한 슬롯에 두 행이 생길 수 없다. + * + *

여기서 고정하는 것은 세 가지다. 중복 행이 생기지 않는다는 것, 동시 요청이 500 으로 + * 새어 나가지 않는다는 것, 그리고 여러 행을 건드리는 요청이 겹쳐도 교착으로 멈추지 않는다는 것이다. + */ +@DisplayName("동시성 - 꾸미기 장착") +class CosmeticEquipConcurrencyTest extends CosmeticTestSupport { + + private static final int THREAD_COUNT = 8; + + private User user; + + @BeforeEach + void setUpUser() { + user = userAtLevel(15); + } + + @Nested + @DisplayName("같은 슬롯") + class SameSlot { + + @Test + @DisplayName("같은 슬롯에 서로 다른 아이템을 8번 동시에 걸어도 행은 하나다") + void concurrentEquipsLeaveOneRow() { + List hats = List.of(HAT_BEANIE, HAT_BUCKET, HAT_CROWN, HAT_GRADUATE); + + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, + index -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, hats.get(index % hats.size()))); + + assertThat(outcome.serverErrors()) + .as("동시 장착이 500 으로 나가면 안 된다") + .isEmpty(); + assertThat(rowCount(CosmeticSlot.HEAD)) + .as("(user_id, slot) 기본키가 한 슬롯 한 행을 보장한다") + .isEqualTo(1); + assertThat(equippedOf(user.getId()).get(CosmeticSlot.HEAD)) + .as("어느 쪽이 이기든 걸려 있는 것은 요청된 모자 중 하나여야 한다") + .isIn(hats); + } + + @Test + @DisplayName("같은 아이템을 8번 동시에 걸어도 결과는 한 번 건 것과 같다") + void concurrentSameItemIsIdempotent() { + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE)); + + assertThat(outcome.serverErrors()).isEmpty(); + assertThat(rowCount(CosmeticSlot.HEAD)).isEqualTo(1); + assertThat(equippedOf(user.getId())).containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + } + } + + @Nested + @DisplayName("여러 슬롯") + class DifferentSlots { + + @Test + @DisplayName("서로 다른 슬롯을 동시에 걸면 둘 다 남는다") + void concurrentEquipsOnDifferentSlotsBothSurvive() { + List targets = List.of( + new SlotItem(CosmeticSlot.HEAD, HAT_BEANIE), + new SlotItem(CosmeticSlot.BACKGROUND, BG_SPRING), + new SlotItem(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN), + new SlotItem(CosmeticSlot.FACE, GLASSES_ROUND)); + + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + targets.size(), + index -> cosmeticService.equip( + user.getId(), targets.get(index).slot(), targets.get(index).itemKey())); + + assertThat(outcome.serverErrors()) + .as("첫 장착마다 프리셋을 굳히는 쓰기가 겹치는 구간이다. 여기서 교착이 나면 500 이다") + .isEmpty(); + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPRING) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN) + .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND); + assertThat(loadoutRowCount(user.getId())) + .as("프리셋이 여러 번 굳어도 슬롯 수를 넘는 행이 생기면 안 된다") + .isEqualTo(7); + } + + @Test + @DisplayName("세트 장착과 단일 장착이 겹쳐도 교착 없이 끝난다") + void concurrentSetAndSingleEquip() { + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, + index -> { + if (index % 2 == 0) { + cosmeticService.equipSet(user.getId(), GRADUATE_SET); + } else { + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + } + }); + + assertThat(outcome.serverErrors()) + .as("두 요청이 같은 행들을 다른 순서로 잠그면 교착이 난다") + .isEmpty(); + assertThat(rowCount(CosmeticSlot.HEAD)).isEqualTo(1); + } + } + + private long rowCount(CosmeticSlot slot) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_cosmetic_loadout WHERE user_id = ? AND slot = ?", + Long.class, user.getId(), slot.name()); + return count == null ? 0 : count; + } + + private record SlotItem(CosmeticSlot slot, String itemKey) { + } +} diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java new file mode 100644 index 00000000..7e5f63e9 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java @@ -0,0 +1,228 @@ +package com.aisip.OnO.backend.cosmetic.migration; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.support.CosmeticItemSeeder; +import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지. + * + *

MySQL DDL 은 트랜잭션이 아니다. 테이블은 만들어지고 뒤이은 INSERT 에서 끊기면 Flyway 는 실패로 + * 기록하는데 테이블은 남아, 재기동하면 "table already exists" 로 또 실패한다. + * {@code flyway repair} 없이는 앱이 뜨지 않고, blue-green 배포 구간이면 그대로 장애다. + * + *

시드 내용도 여기서 고정한다. 카탈로그가 어긋나면 프론트 에셋 파일명과 맞지 않아 + * 이미지가 통째로 안 나오는데, 이미 깔린 앱이 있으면 그때는 되돌릴 수 없다. + */ +@DisplayName("꾸미기 마이그레이션") +class CosmeticMigrationTest extends CosmeticTestSupport { + + private static final String DDL_MIGRATION = "db/migration/V34__create_cosmetic_system.sql"; + private static final String SEED_MIGRATION = "db/migration/V35__seed_cosmetic_items.sql"; + + @Autowired + private CosmeticItemSeeder seeder; + + @PersistenceContext + private EntityManager entityManager; + + @Test + @DisplayName("DDL 파일에는 시드가 섞여 있지 않다") + void ddlMigrationHasNoSeed() { + assertThat(statementsOf(DDL_MIGRATION)) + .as("DDL 과 시드가 한 파일에 있으면 시드에서 끊길 때 테이블만 남아 재실행이 막힌다") + .doesNotContain("INSERT INTO"); + } + + @Test + @DisplayName("DDL 은 다시 돌려도 안전하게 쓰여 있다") + void ddlMigrationIsRerunnable() { + String ddl = statementsOf(DDL_MIGRATION); + + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS COSMETIC_ITEM"); + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS USER_COSMETIC_LOADOUT"); + // MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없다. 인덱스를 따로 만들면 그 문장이 재실행 지점이 된다. + assertThat(ddl).doesNotContain("CREATE INDEX"); + } + + @Test + @DisplayName("한 슬롯 한 행을 DB 가 막는다 - 복합 기본키가 실제로 걸려 있다") + void loadoutHasCompositePrimaryKey() { + assertThat(statementsOf(DDL_MIGRATION)) + .as("이 기본키가 없으면 upsert 한 문장으로 장착을 처리한다는 전제가 무너진다") + .contains("PRIMARY KEY (USER_ID, SLOT)"); + + List primaryKeyColumns = jdbcTemplate.queryForList(""" + SELECT column_name + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'user_cosmetic_loadout' + AND index_name = 'PRIMARY' + ORDER BY seq_in_index + """, String.class); + + // 순서까지 보지는 않는다. 운영 스키마는 Flyway 가 만들고(ddl-auto 는 세 환경 모두 validate 다) + // 테스트 스키마만 Hibernate 가 만드는데, Hibernate 는 복합 식별자 컬럼을 이름순으로 늘어놓아 + // 여기서는 (slot, user_id) 가 된다. 운영에 적용되는 순서는 위에서 마이그레이션 원문으로 고정했다. + assertThat(primaryKeyColumns) + .as("기본키를 이루는 컬럼 자체가 달라지면 한 슬롯 한 행 보장이 깨진다") + .containsExactlyInAnyOrder("user_id", "slot"); + } + + @Test + @DisplayName("시드는 다시 돌려도 안전하게 쓰여 있다") + void seedMigrationIsRerunnable() { + assertThat(statementsOf(SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + } + + @Test + @DisplayName("시드를 세 번 실행해도 카탈로그는 그대로다") + void seedIsIdempotent() { + // @BeforeEach 가 이미 한 번 넣었다. 같은 문장을 다시 돌리는 것이 이 테스트의 핵심이다. + long before = cosmeticItemRepository.count(); + + seeder.seed(); + seeder.seed(); + + assertThat(cosmeticItemRepository.count()).isEqualTo(before); + } + + @Test + @DisplayName("이미 만들어진 스키마에 DDL 을 다시 돌려도 터지지 않는다") + void rerunningDdlDoesNotFail() { + // 테스트 DB 에는 두 테이블이 이미 있다. 그 위에 두 번 더 돌려 본다. + for (int attempt = 0; attempt < 2; attempt++) { + runMigration(DDL_MIGRATION); + } + + // 예외가 안 났다는 것만으로는 약하다. 다시 돌린 뒤에도 스키마가 쓸 수 있는 상태인지 확인한다. + var user = userAtLevel(15); + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + assertThat(equippedOf(user.getId())).containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + } + + @Test + @DisplayName("레벨 해금 아이템은 2부터 15까지 빈 레벨 없이 채워져 있다") + void everyLevelHasAnUnlock() { + List unlockLevels = cosmeticItemRepository.findAllByActiveTrue().stream() + .map(CosmeticItem::getRequiredLevel) + .filter(java.util.Objects::nonNull) + .distinct() + .sorted() + .toList(); + + assertThat(unlockLevels) + .as("중간 레벨에 아무것도 안 열리면 그 레벨업만 보상이 없는 것처럼 보인다") + .containsExactly(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15); + } + + @Test + @DisplayName("모든 이미지 경로가 item_key 와 짝이 맞는다") + void imageUrlMatchesItemKey() { + List mismatched = cosmeticItemRepository.findAll().stream() + .filter(item -> !item.getImageUrl().equals("assets/Cosmetic/" + item.getItemKey() + ".png")) + .map(CosmeticItem::getItemKey) + .toList(); + + assertThat(mismatched) + .as("프론트 에셋 파일명은 item_key 로 찾는다. 어긋나면 이미지가 안 나온다") + .isEmpty(); + } + + @Test + @DisplayName("이미지 경로는 전부 번들 상대 경로다 - 아직 S3 에 아무것도 없다") + void imageUrlIsNotRemoteYet() { + assertThat(cosmeticItemRepository.findAll()) + .extracting(CosmeticItem::getImageUrl) + .allSatisfy(url -> assertThat(url).doesNotStartWith("http")); + } + + @Test + @DisplayName("졸업 세트는 세 슬롯이고 전부 레벨 15 다") + void graduateSetIsConsistent() { + List setItems = cosmeticItemRepository.findAllBySetIdAndActiveTrueOrderByIdAsc(GRADUATE_SET); + + assertThat(setItems).extracting(CosmeticItem::getSlot) + .containsExactlyInAnyOrder(CosmeticSlot.HEAD, CosmeticSlot.OUTFIT, CosmeticSlot.HAND); + assertThat(setItems).extracting(CosmeticItem::getRequiredLevel) + .as("세트인데 해금 레벨이 다르면 절반만 가진 상태가 생긴다") + .containsOnly(15); + } + + @Test + @DisplayName("2차 콘텐츠는 비활성이고 해금 레벨이 없다") + void inactiveItemsHaveNoLevel() { + List inactive = cosmeticItemRepository.findAll().stream() + .filter(item -> !item.isActive()) + .toList(); + + assertThat(inactive).hasSize(19); + assertThat(inactive).extracting(CosmeticItem::getRequiredLevel).containsOnlyNulls(); + } + + @Test + @DisplayName("모든 슬롯 값이 CosmeticSlot 에 있는 이름이다") + void slotsAreKnown() { + // 시드에 오타가 있으면 엔티티 매핑에서 IllegalArgumentException 이 난다. + // 조회가 성공했다는 것 자체가 검증이지만, 슬롯 분포까지 함께 본다. + assertThat(cosmeticItemRepository.findAll()) + .extracting(CosmeticItem::getSlot) + .contains(CosmeticSlot.BASE, CosmeticSlot.HEAD, CosmeticSlot.BACKGROUND, + CosmeticSlot.OUTFIT, CosmeticSlot.NECK, CosmeticSlot.FACE, + CosmeticSlot.HAND, CosmeticSlot.BACK, CosmeticSlot.BADGE); + } + + /** 마이그레이션의 실행 문장을 순서대로 돌린다. */ + private void runMigration(String path) { + transactionTemplate.executeWithoutResult(status -> { + for (String statement : executableStatements(path)) { + entityManager.createNativeQuery(statement).executeUpdate(); + } + }); + } + + private List executableStatements(String path) { + return Arrays.stream(withoutComments(path).split(";")) + .map(String::trim) + .filter(statement -> !statement.isEmpty()) + .toList(); + } + + private String withoutComments(String path) { + return Arrays.stream(read(path).split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + } + + /** 주석을 걷어낸 실행 문장만. 주석에 적힌 설명이 단언에 걸리면 안 된다. */ + private String statementsOf(String path) { + // 공백을 하나로 줄인다. 정렬용으로 넣은 여백 때문에 단언이 깨지면 안 된다. + return withoutComments(path).toUpperCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private String read(String path) { + try (InputStream inputStream = new ClassPathResource(path).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("마이그레이션을 읽지 못했다: " + path, e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java new file mode 100644 index 00000000..309c0007 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java @@ -0,0 +1,111 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.service.CosmeticService; +import com.aisip.OnO.backend.cosmetic.support.CosmeticItemSeeder; +import com.aisip.OnO.backend.mission.dto.MissionClaimResponseDto; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 미션 보상을 받아 레벨이 오르면 그 자리에서 무엇이 열렸는지 알려 준다. + * + *

해금 자체는 계산이라 알림이 없어도 다음 조회에서 드러나지만, 사용자가 "레벨이 올랐는데 + * 뭐가 좋아졌는지" 를 그 순간에 모르면 레벨업이 아무 일도 아닌 것이 된다. + * + *

{@code MissionRewardGranter} 의 트랜잭션 경계와 잠금 순서(사용자 → 진행도)는 건드리지 않았다. + * 해금 조회는 지급이 끝난 뒤에 도는 읽기 전용 조회다. + */ +@DisplayName("미션 보상 - 레벨업 해금 알림") +class MissionCosmeticUnlockTest extends MissionSystemTestSupport { + + @Autowired + private CosmeticItemSeeder cosmeticItemSeeder; + + @Autowired + private CosmeticService cosmeticService; + + private User user; + + @BeforeEach + void setUpUserAndCatalog() { + // 미션 도메인 베이스라 꾸미기 카탈로그는 여기서 따로 채운다. + cosmeticItemSeeder.seed(); + user = fixtures.createUser(); + } + + @Test + @DisplayName("레벨이 안 올랐으면 빈 배열이다") + void noUnlockWithoutLevelUp() { + // 오늘 기분 미션은 5점이라 레벨 1→2 에 필요한 40점에 못 미친다. + MissionProgress progress = completeMission(user.getId(), DAILY_MOOD); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.leveledUp()).isFalse(); + assertThat(response.unlockedCosmetics()) + .as("null 이 아니라 빈 배열이어야 프론트가 null 검사를 안 한다") + .isNotNull() + .isEmpty(); + } + + @Test + @DisplayName("레벨이 오르면 그 구간에서 열린 아이템이 실린다") + void unlocksOnLevelUp() { + // 레벨 1→2 에 40점이 필요하다. 주간 출석(100점) 하나면 넘는다. + MissionProgress progress = completeMission(user.getId(), WEEKLY_ATTEND_5); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.leveledUp()).isTrue(); + assertThat(response.totalStudyLevel()).isEqualTo(2L); + assertThat(response.unlockedCosmetics()) + .extracting(UnlockedCosmeticDto::itemKey) + .as("레벨 2 에 열리는 것은 새싹 머리띠다") + .containsExactly("headband_sprout"); + assertThat(response.unlockedCosmetics().get(0).slot()).isEqualTo(CosmeticSlot.HEAD); + assertThat(response.unlockedCosmetics().get(0).nameKo()).isEqualTo("새싹 머리띠"); + } + + @Test + @DisplayName("한 번에 여러 단계 오르면 그 사이 것이 전부 실린다") + void unlocksEveryLevelInRange() { + // 레벨 1→2 는 40, 2→3 은 80 이다. 주간 4종(100+80+100+80=360)을 몰아 받으면 여러 단계가 오른다. + for (String code : new String[]{WEEKLY_ATTEND_5, WEEKLY_NOTE_10, WEEKLY_REVIEW_30, WEEKLY_SET_3}) { + completeMission(user.getId(), code); + } + + MissionClaimResponseDto last = null; + for (String code : new String[]{WEEKLY_ATTEND_5, WEEKLY_NOTE_10, WEEKLY_REVIEW_30, WEEKLY_SET_3}) { + last = missionService.claim(user.getId(), progressOf(user, code).getId()); + } + + assertThat(last).isNotNull(); + assertThat(last.totalStudyLevel()).isGreaterThan(2L); + // 마지막 수령까지의 해금을 다 합치면 현재 레벨까지 열린 것이 빠짐없이 한 번씩 나왔어야 한다. + assertThat(cosmeticService.findUnlockedBetween(1, last.totalStudyLevel())) + .extracting(UnlockedCosmeticDto::itemKey) + .contains("headband_sprout", "bg_spring"); + } + + @Test + @DisplayName("기존 응답 필드는 그대로다 - 구버전 앱이 깨지면 안 된다") + void keepsExistingFields() { + MissionProgress progress = completeMission(user.getId(), DAILY_ATTEND); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.progressId()).isEqualTo(progress.getId()); + assertThat(response.rewardType()).isEqualTo(definitionOf(DAILY_ATTEND).getRewardType()); + assertThat(response.rewardValue()).isEqualTo(definitionOf(DAILY_ATTEND).getRewardValue()); + assertThat(response.totalStudyLevel()).isNotNull(); + } +} From d364744b6a6482c30c87f3652a9d6c3b9464c7f5 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 01:35:40 +0900 Subject: [PATCH 079/130] =?UTF-8?q?[Feat]=20=EC=B4=9D=20=ED=95=99=EC=8A=B5?= =?UTF-8?q?=20=EB=A0=88=EB=B2=A8=20=EC=83=81=ED=95=9C=EC=9D=84=2015=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=2020=20=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 치장 해금표가 총 학습 16 / 18 / 19 / 20 자리를 쓴다. 임계값 공식(40 x 레벨)은 그대로고, 15 에서 20 까지 더 필요한 경험치는 3,400 점이다 - 상한 15 에 닿은 사용자가 아직 없어 쌓인 잔여 포인트로 한 번에 여러 단계 오르는 경우는 생기지 않는다 - UserResponseDto 의 천장도 함께 올렸다. 15 에 머물면 레벨 16 이상인 사용자에게 앱은 계속 Lv.15 를 보여주고 게이지도 멈춘 것처럼 보이는데 치장은 계속 열린다. 필드 이름과 의미는 그대로다 - 능력치 넷의 표시 상한은 15 에 그대로 둔다. 해금표의 능력치 아이템도 15 가 마지막이다 --- .../mission/entity/UserMissionStatus.java | 18 +++++- .../OnO/backend/user/dto/UserResponseDto.java | 42 ++++++++++---- .../mission/entity/UserMissionStatusTest.java | 36 ++++++++---- .../backend/user/dto/UserResponseDtoTest.java | 56 +++++++++++++++---- 4 files changed, 117 insertions(+), 35 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java index 504326ff..da1bc777 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java @@ -10,6 +10,19 @@ @NoArgsConstructor @AllArgsConstructor public class UserMissionStatus { + + /** + * 총 학습 레벨 상한. + * + *

치장 해금표가 총 학습 레벨 20 까지 아이템을 두고 있어 15 에서 20 으로 올렸다. + * 15 에서 20 까지 더 필요한 경험치는 40 x (15+16+17+18+19) = 3,400 점이다. + * + *

상한에 닿아도 {@code totalStudyPoint} 는 계속 쌓인다. 그래서 상한을 올리는 순간 + * 이미 쌓아 둔 잔여 포인트로 레벨이 한 번에 여러 단계 오를 수 있는데, 상한 15 에 닿은 + * 사용자가 아직 없어 이번 변경으로 그런 사용자는 생기지 않는다. + */ + public static final long MAX_TOTAL_STUDY_LEVEL = 20L; + // 데일리 출석 private Long attendanceLevel; private Long attendancePoint; @@ -86,7 +99,7 @@ private void updateTotalStudyLevel(Long gainedPoints) { this.totalStudyPoint += gainedPoints; // 레벨업 처리 - while (this.totalStudyLevel < 15 && this.totalStudyPoint >= getTotalStudyThresholdForLevel(this.totalStudyLevel)) { + while (this.totalStudyLevel < MAX_TOTAL_STUDY_LEVEL && this.totalStudyPoint >= getTotalStudyThresholdForLevel(this.totalStudyLevel)) { this.totalStudyPoint -= getTotalStudyThresholdForLevel(this.totalStudyLevel); this.totalStudyLevel += 1; } @@ -107,11 +120,12 @@ private Long getThresholdForLevel(Long level) { /** * 총 학습 레벨의 필요 경험치 계산 - * 개별 능력치 필요 경험치 × 4 + * 개별 능력치 필요 경험치 × 4 = 40 × 레벨 * * - 레벨 1→2: 40 (10 × 4) * - 레벨 2→3: 80 (20 × 4) * - 레벨 14→15: 560 (140 × 4) + * - 레벨 19→20: 760 (190 × 4) — 상한. 15 에서 20 까지 합이 3,400 이다 */ private Long getTotalStudyThresholdForLevel(Long level) { return getThresholdForLevel(level) * 4; diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index 7d123d41..9547ea99 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -30,7 +30,22 @@ public record UserResponseDto ( LocalDateTime createdAt, LocalDateTime updatedAt ) { - private static final Long MAX_LEVEL = 15L; + /** + * 화면에 보여줄 개별 능력치 레벨의 상한. + * + *

개별 능력치는 도메인 상 상한이 없다. 게이지가 15 칸이라 표시만 눌러 보낸다. + * 치장 해금표의 능력치별 아이템도 레벨 15 가 마지막이라 이 값은 그대로 둔다. + */ + private static final Long MAX_ABILITY_LEVEL = 15L; + + /** + * 화면에 보여줄 총 학습 레벨의 상한. + * + *

{@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_TOTAL_STUDY_LEVEL} 과 같은 값이어야 한다. + * 여기가 15 에 머물면 레벨 16 이상인 사용자에게 앱은 계속 Lv.15 를 보여주고 게이지도 멈춘 것처럼 보이는데, + * 정작 치장은 총 학습 레벨 16·18·19·20 에서 계속 열린다. 필드 이름과 의미는 그대로고 천장만 함께 올린다. + */ + private static final Long MAX_TOTAL_STUDY_LEVEL = 20L; public static UserResponseDto from(@NotNull User user) { var missionStatus = user.getUserMissionStatus(); @@ -49,7 +64,7 @@ public static UserResponseDto from(@NotNull User user) { .notePracticeLevel(getResponseLevel(missionStatus.getNotePracticeLevel())) .notePracticePoint(getResponsePoint(missionStatus.getNotePracticeLevel(), missionStatus.getNotePracticePoint())) // DB에 저장된 총 학습 레벨 정보 사용 (계산 불필요) - .totalStudyLevel(getResponseLevel(missionStatus.getTotalStudyLevel())) + .totalStudyLevel(getTotalStudyResponseLevel(missionStatus.getTotalStudyLevel())) .totalStudyCurrentPoint(getTotalStudyResponsePoint(missionStatus.getTotalStudyLevel(), missionStatus.getTotalStudyPoint())) .totalStudyNextLevelThreshold(getTotalStudyNextLevelThreshold(missionStatus)) .notificationEnabled(user.isNotificationEnabled()) @@ -59,29 +74,36 @@ public static UserResponseDto from(@NotNull User user) { } private static Long getResponseLevel(Long level) { - if (level > MAX_LEVEL) { - return MAX_LEVEL; + if (level > MAX_ABILITY_LEVEL) { + return MAX_ABILITY_LEVEL; } return level; } private static Long getResponsePoint(Long level, Long point) { - if (level > MAX_LEVEL) { - return getThresholdForLevel(MAX_LEVEL); + if (level > MAX_ABILITY_LEVEL) { + return getThresholdForLevel(MAX_ABILITY_LEVEL); } return point; } + private static Long getTotalStudyResponseLevel(Long level) { + if (level > MAX_TOTAL_STUDY_LEVEL) { + return MAX_TOTAL_STUDY_LEVEL; + } + return level; + } + private static Long getTotalStudyResponsePoint(Long level, Long point) { - if (level > MAX_LEVEL) { - return getTotalStudyThresholdForLevel(MAX_LEVEL); + if (level > MAX_TOTAL_STUDY_LEVEL) { + return getTotalStudyThresholdForLevel(MAX_TOTAL_STUDY_LEVEL); } return point; } private static Long getTotalStudyNextLevelThreshold(com.aisip.OnO.backend.mission.entity.UserMissionStatus status) { - if (status.getTotalStudyLevel() >= MAX_LEVEL) { - return getTotalStudyThresholdForLevel(MAX_LEVEL); + if (status.getTotalStudyLevel() >= MAX_TOTAL_STUDY_LEVEL) { + return getTotalStudyThresholdForLevel(MAX_TOTAL_STUDY_LEVEL); } // 개별 능력치 필요 경험치 × 4 return getTotalStudyThresholdForLevel(status.getTotalStudyLevel()); diff --git a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java index 737fac05..bc09899a 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java @@ -14,7 +14,7 @@ *

공식이 두 개다. *

    *
  • 개별 능력치: 레벨 n → n+1 에 {@code 10 + (n-1) * 10} 점 필요, 상한 없음
  • - *
  • 총 학습 레벨: 개별 필요치의 4배, 레벨 15에서 멈춤
  • + *
  • 총 학습 레벨: 개별 필요치의 4배(= 40 × 레벨), 레벨 20에서 멈춤
  • *
* 사용자에게 보이는 숫자라 한 칸만 어긋나도 바로 문의가 들어온다. */ @@ -219,26 +219,40 @@ void levelsUpAtFourTimesThreshold(long gained, long expectedLevel, long expected } @Test - @DisplayName("총 학습 레벨은 15에서 멈추고 그 뒤 경험치는 그대로 쌓인다") - void stopsAtLevelFifteen() { - UserMissionStatus status = new UserMissionStatus(1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 14L, 0L); + @DisplayName("총 학습 레벨은 20에서 멈추고 그 뒤 경험치는 그대로 쌓인다") + void stopsAtLevelTwenty() { + UserMissionStatus status = new UserMissionStatus(1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 19L, 0L); - status.gainAttendancePoint(560L); + status.gainAttendancePoint(760L); assertThat(status.getTotalStudyLevel()) - .as("레벨 14→15 에 필요한 560점을 채우면 15가 된다") - .isEqualTo(15L); + .as("레벨 19→20 에 필요한 760점(40 × 19)을 채우면 20이 된다") + .isEqualTo(20L); assertThat(status.getTotalStudyPoint()).isZero(); status.gainAttendancePoint(10_000L); assertThat(status.getTotalStudyLevel()) - .as("상한을 넘겨 16레벨이 되면 화면에 없는 레벨이 표시된다") - .isEqualTo(15L); + .as("상한을 넘겨 21레벨이 되면 해금표에 없는 레벨이 표시된다") + .isEqualTo(20L); assertThat(status.getTotalStudyPoint()) .as("상한 이후 경험치는 사라지지 않고 그대로 누적된다") .isEqualTo(10_000L); } + @Test + @DisplayName("상한이 15 였다면 멈췄을 자리를 그대로 지나간다") + void passesThroughTheOldCap() { + UserMissionStatus status = new UserMissionStatus(1L, 0L, 1L, 0L, 1L, 0L, 1L, 0L, 15L, 0L); + + // 15→20 에 필요한 합은 40 x (15+16+17+18+19) = 3,400 이다. + status.gainAttendancePoint(3_400L); + + assertThat(status.getTotalStudyLevel()) + .as("여기서 15 에 멈추면 총 학습 16·18·19·20 자리의 치장이 영영 안 열린다") + .isEqualTo(20L); + assertThat(status.getTotalStudyPoint()).isZero(); + } + @Test @DisplayName("개별 능력치는 총 학습 레벨과 달리 상한이 없다") void abilityLevelHasNoCap() { @@ -246,8 +260,8 @@ void abilityLevelHasNoCap() { status.gainAttendancePoint(100_000L); - assertThat(status.getAttendanceLevel()).isGreaterThan(15L); - assertThat(status.getTotalStudyLevel()).isEqualTo(15L); + assertThat(status.getAttendanceLevel()).isGreaterThan(20L); + assertThat(status.getTotalStudyLevel()).isEqualTo(20L); } } diff --git a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java index 8a949d13..701fecab 100644 --- a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java @@ -98,7 +98,7 @@ void carriesNotificationEnabled() { class LevelCap { @Test - @DisplayName("15레벨 미만은 저장된 값을 그대로 내보낸다") + @DisplayName("상한 미만은 저장된 값을 그대로 내보낸다") void keepsValuesBelowMaxLevel() { User user = user(); user.getUserMissionStatus().setAttendanceLevel(3L, 7L); @@ -114,8 +114,8 @@ void keepsValuesBelowMaxLevel() { } @Test - @DisplayName("정확히 15레벨이면 게이지 기준치는 15레벨 기준으로 고정된다") - void pinsThresholdAtMaxLevel() { + @DisplayName("총 학습 15레벨은 더 이상 만렙이 아니다 - 게이지가 계속 올라간다") + void totalStudyKeepsGrowingPastFifteen() { User user = user(); user.getUserMissionStatus().setTotalStudyLevel(15L, 100L); @@ -123,22 +123,52 @@ void pinsThresholdAtMaxLevel() { assertThat(response.totalStudyLevel()).isEqualTo(15L); assertThat(response.totalStudyCurrentPoint()).isEqualTo(100L); - assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(600L); + assertThat(response.totalStudyNextLevelThreshold()) + .as("15 에서 멈추면 총 학습 16·18·19·20 에 걸린 치장이 앱에서 영영 안 보인다") + .isEqualTo(600L); + } + + @Test + @DisplayName("정확히 20레벨이면 게이지 기준치는 20레벨 기준으로 고정된다") + void pinsThresholdAtMaxTotalStudyLevel() { + User user = user(); + user.getUserMissionStatus().setTotalStudyLevel(20L, 100L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.totalStudyLevel()).isEqualTo(20L); + assertThat(response.totalStudyCurrentPoint()).isEqualTo(100L); + assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(800L); } @Test - @DisplayName("15레벨을 넘긴 미션 정보는 15레벨 풀 게이지로 눌러서 응답한다") - void capsMissionStatusOverMaxLevel() { + @DisplayName("총 학습 16~20 은 그대로 나간다 - 능력치 상한 15 와 다른 값이다") + void totalStudyLevelIsNotCappedAtFifteen() { + User user = user(); + user.getUserMissionStatus().setTotalStudyLevel(18L, 120L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.totalStudyLevel()).isEqualTo(18L); + assertThat(response.totalStudyCurrentPoint()).isEqualTo(120L); + assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(720L); + } + + @Test + @DisplayName("능력치는 15레벨을 넘겨도 15레벨 풀 게이지로 눌러서 응답한다") + void capsAbilityStatusOverMaxLevel() { User user = user(); user.getUserMissionStatus().setAttendanceLevel(16L, 10L); user.getUserMissionStatus().setNoteWriteLevel(17L, 20L); user.getUserMissionStatus().setProblemPracticeLevel(18L, 30L); user.getUserMissionStatus().setNotePracticeLevel(19L, 40L); - user.getUserMissionStatus().setTotalStudyLevel(16L, 50L); + user.getUserMissionStatus().setTotalStudyLevel(21L, 50L); UserResponseDto response = UserResponseDto.from(user); - assertThat(response.attendanceLevel()).isEqualTo(15L); + assertThat(response.attendanceLevel()) + .as("능력치 게이지는 15칸이고 치장 해금표도 능력치 15 가 마지막이다") + .isEqualTo(15L); assertThat(response.attendancePoint()).isEqualTo(150L); assertThat(response.noteWriteLevel()).isEqualTo(15L); assertThat(response.noteWritePoint()).isEqualTo(150L); @@ -146,11 +176,13 @@ void capsMissionStatusOverMaxLevel() { assertThat(response.problemPracticePoint()).isEqualTo(150L); assertThat(response.notePracticeLevel()).isEqualTo(15L); assertThat(response.notePracticePoint()).isEqualTo(150L); - assertThat(response.totalStudyLevel()).isEqualTo(15L); + assertThat(response.totalStudyLevel()) + .as("총 학습만 상한이 20 이다") + .isEqualTo(20L); assertThat(response.totalStudyCurrentPoint()) - .as("게이지가 넘치지 않도록 15레벨 만점으로 눌러 보낸다") - .isEqualTo(600L); - assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(600L); + .as("게이지가 넘치지 않도록 20레벨 만점으로 눌러 보낸다") + .isEqualTo(800L); + assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(800L); } } } From 05fbcb4609e4d6afd803533a6796be7f85d50253 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 01:35:54 +0900 Subject: [PATCH 080/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20-=20=EB=8A=A5=EB=A0=A5?= =?UTF-8?q?=EC=B9=98=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=EC=99=80=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=2055=EA=B0=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V34/V35 를 직접 고치지 않고 새 파일로 얹는다. 로컬에 이미 적용돼 있으면 파일을 고치는 순간 Flyway 체크섬이 어긋나 flyway repair 없이는 앱이 뜨지 않는다 - V36: required_ability(VARCHAR 32, NULL), full_body(TINYINT 1, NOT NULL DEFAULT 0), set_name_ko(VARCHAR 64, NULL). MySQL 에 ADD COLUMN IF NOT EXISTS 가 없어 V31 처럼 information_schema 로 컬럼마다 따로 가드한다 - V37: 해금표대로 55개를 다시 시드한다. 능력치별 12 / 9 / 11 / 9, 총 학습 14 다. V35 와 달리 기존 행을 덮어쓴다. 해금 조건 자체를 갈아엎는 것이 목적이라 덮어쓰지 않으면 V35 까지 돈 DB 와 새 DB 의 결과가 달라진다. conflicts_with 와 created_at 은 손대지 않는다 - BACK 의 뜻이 바뀌었다. 등에 메는 것(본체 뒤, 200)과 앞으로 메는 BAG(옷 위, 450)이 갈렸고, 미니 백팩 / 허리 가방 / 크로스백이 BAG 으로 옮겨 간다 - 2차 콘텐츠로 active = 0 이던 19개가 전부 해금 레벨을 받아 활성이 된다 - 전신 의상 다섯 벌(가디건 후드티 우비 교복 졸업 가운)에 full_body 를 세운다 - 학사 세트에 set_name_ko '학사 세트' 를 붙인다 --- ...6__add_cosmetic_ability_unlock_columns.sql | 61 ++++++++++ .../V37__seed_cosmetic_items_by_ability.sql | 113 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql create mode 100644 src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql diff --git a/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql b/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql new file mode 100644 index 00000000..0990e6ab --- /dev/null +++ b/src/main/resources/db/migration/V36__add_cosmetic_ability_unlock_columns.sql @@ -0,0 +1,61 @@ +-- 꾸미기 해금을 능력치별로 바꾸기 위한 컬럼 셋. 이 파일은 DDL 만 담는다. 시드 교체는 V37 이다. +-- +-- V34/V35 를 직접 고치지 않는다. 이 브랜치는 아직 push 도 배포도 안 됐지만, 로컬 DB 에는 이미 +-- 적용돼 있을 수 있다. 적용된 파일을 고치면 Flyway 체크섬이 어긋나 `flyway repair` 없이는 앱이 뜨지 않는다. +-- 새 파일로 얹으면 이미 V35 까지 돈 DB 도, 처음부터 도는 DB 도 같은 결과에 도달한다. +-- +-- MySQL 에는 ADD COLUMN IF NOT EXISTS 가 없다. DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 +-- 커넥션이 끊기면 재기동 때 이 파일이 다시 돌면서 Duplicate column name 으로 앱이 뜨지 않는다. +-- V31 과 같은 방식으로 information_schema 를 보고 없을 때만 실행한다. 컬럼마다 따로 보는 이유는, +-- 하나로 묶으면 "세 개 중 하나만 들어간" 상태에서 빠져나올 길이 없기 때문이다. + +-- required_ability: 이 아이템의 required_level 을 어느 레벨과 비교할지. +-- 값이 있으면 그 능력치 레벨, NULL 이면 총 학습 레벨이다. +-- 값은 MissionType.AbilityType enum 그대로다: ATTENDANCE, NOTE_WRITE, PROBLEM_PRACTICE, NOTE_PRACTICE. +-- 기본값을 두지 않는다. "능력치 조건이 없다"를 NULL 하나로만 표현해야 required_level 의 NULL 규칙과 어긋나지 않는다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'required_ability' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN required_ability VARCHAR(32) NULL AFTER required_level', + 'SELECT 1'); +PREPARE add_required_ability FROM @ddl; +EXECUTE add_required_ability; +DEALLOCATE PREPARE add_required_ability; + +-- full_body: 소매와 바짓단까지 그려진 전신 의상인지. +-- 앱이 이 옷을 입히면 개구리 본체를 머리만 있는 그림으로 바꿔 깐다. 그러지 않으면 옷 밑으로 팔다리가 삐져나온다. +-- NOT NULL DEFAULT FALSE 라 기존 행은 전부 false 로 채워진다. V37 이 해당하는 옷만 true 로 올린다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'full_body' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN full_body TINYINT(1) NOT NULL DEFAULT 0 AFTER required_ability', + 'SELECT 1'); +PREPARE add_full_body FROM @ddl; +EXECUTE add_full_body; +DEALLOCATE PREPARE add_full_body; + +-- set_name_ko: 세트의 사람이 읽는 이름. set_id 는 기계용 키라 화면에 그대로 쓸 수 없다. +-- 이 컬럼이 없던 동안 프론트가 세트 배너에 아이템 이름을 이어 붙여 썼다. +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'set_name_ko' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN set_name_ko VARCHAR(64) NULL AFTER set_id', + 'SELECT 1'); +PREPARE add_set_name_ko FROM @ddl; +EXECUTE add_set_name_ko; +DEALLOCATE PREPARE add_set_name_ko; diff --git a/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql b/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql new file mode 100644 index 00000000..0a9a1435 --- /dev/null +++ b/src/main/resources/db/migration/V37__seed_cosmetic_items_by_ability.sql @@ -0,0 +1,113 @@ +-- 꾸미기 시드 교체: 총 학습 레벨 하나로 열리던 카탈로그를 능력치별 해금표로 바꾼다. +-- +-- V35 를 직접 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 값 변경은 새 마이그레이션으로 얹는다(V33 이 미션 문구를 그렇게 고쳤다). +-- +-- 이 파일은 V35 와 달리 **기존 행을 덮어쓴다.** V35 의 ON DUPLICATE KEY UPDATE 는 +-- item_key = item_key 라 "있으면 아무것도 안 한다" 였는데, 여기서는 해금 조건 자체를 갈아엎는 것이 목적이라 +-- 덮어쓰지 않으면 V35 까지 돈 DB 와 처음부터 도는 DB 의 결과가 달라진다. +-- 덮어쓰는 컬럼은 카탈로그가 정하는 것뿐이다. conflicts_with 와 created_at 은 손대지 않는다. +-- 충돌 목록은 운영에서 채워 넣는 값이라 시드가 되돌리면 안 된다. +-- 몇 번을 다시 돌려도 결과가 같다. +-- +-- 해금 기준이 둘로 갈린다. +-- required_ability 가 있으면 -> 그 능력치 레벨과 required_level 을 비교한다 +-- required_ability 가 NULL 이면 -> 총 학습 레벨과 비교한다 (V35 까지의 동작) +-- 능력치별로 가는 이유는 셋이다. 테마 해금이 이미 능력치별로 돌고 있고(ThemeLockManager), +-- 앱에 능력치 넷을 보여 주는 스탯 화면이 새로 생겼는데 보상이 총 레벨만 보면 그 화면이 죽고, +-- "출석 Lv.5 달성" 같은 구체적인 조건을 사용자에게 보여 줄 수 있다. +-- +-- 자리와 레이어 순서(작을수록 뒤에 깔린다): +-- BACKGROUND 100 배경 / BACK 200 등짐 / BASE 300 본체 / OUTFIT 400 옷 / BAG 450 가방 +-- NECK 500 목 / FACE 600 얼굴 / HEAD 700 머리 / HAND 800 손 / BADGE 850 뱃지 / EFFECT 900 효과 +-- **BACK 의 뜻이 V35 에서 바뀌었다.** V35 의 BACK 은 그냥 '가방' 이었고 미니 백팩·허리 가방·크로스백이 +-- 거기 있었다. 이제 BACK 은 등에 메는 것(본체보다 뒤), BAG 은 앞으로 메는 것(옷 위)이다. +-- 그래서 저 셋은 BAG 으로 옮겨 간다. 등에 메는 백팩 둘이 BACK 으로 새로 들어온다. +-- +-- 2차 콘텐츠로 active = 0 이던 19 개는 전부 해금 레벨을 받아 active = 1 이 된다. +-- image_url 은 여전히 앱 번들 상대 경로다. S3 로 옮길 때 이 컬럼만 바꾸면 앱 배포 없이 전환된다. +-- +-- 개구리 본체('BASE') 행은 건드리지 않는다. V35 가 넣은 그대로고 해금 조건이 없다. + +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, required_ability, full_body, + set_id, set_name_ko, conflicts_with, active, created_at, updated_at) +VALUES + -- 출석 (ATTENDANCE). 배경과 전경 효과가 여기 붙는다. 매일 들어오는 것으로 화면 분위기가 바뀐다. + ('bg_spring', 'BACKGROUND', '봄 배경', 'assets/Cosmetic/bg_spring.png', 2, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_petals', 'EFFECT', '꽃잎 효과', 'assets/Cosmetic/effect_petals.png', 3, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_summer', 'BACKGROUND', '여름 배경', 'assets/Cosmetic/bg_summer.png', 4, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_sparkle', 'EFFECT', '반짝임 효과', 'assets/Cosmetic/effect_sparkle.png', 5, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_rainy', 'BACKGROUND', '비 오는 날 배경', 'assets/Cosmetic/bg_rainy.png', 6, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_fireflies', 'EFFECT', '반딧불 효과', 'assets/Cosmetic/effect_fireflies.png', 8, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_autumn', 'BACKGROUND', '가을 배경', 'assets/Cosmetic/bg_autumn.png', 9, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_sunset', 'BACKGROUND', '노을 배경', 'assets/Cosmetic/bg_sunset.png', 11, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_winter', 'BACKGROUND', '겨울 배경', 'assets/Cosmetic/bg_winter.png', 12, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('effect_snow', 'EFFECT', '눈 내리는 효과', 'assets/Cosmetic/effect_snow.png', 13, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_night', 'BACKGROUND', '밤하늘 배경', 'assets/Cosmetic/bg_night.png', 14, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_space', 'BACKGROUND', '우주 배경', 'assets/Cosmetic/bg_space.png', 15, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 오답노트 작성 (NOTE_WRITE). 가방과 손에 드는 것. 쓰는 사람의 짐이 늘어나는 쪽으로 묶었다. + ('bag_mini_backpack', 'BAG', '미니 백팩', 'assets/Cosmetic/bag_mini_backpack.png', 2, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_notebook', 'HAND', '공책', 'assets/Cosmetic/prop_notebook.png', 3, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('back_backpack_navy', 'BACK', '네이비 백팩', 'assets/Cosmetic/back_backpack_navy.png', 5, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_study', 'HAND', '공부 도구', 'assets/Cosmetic/prop_study.png', 6, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_waist_pouch', 'BAG', '허리 가방', 'assets/Cosmetic/bag_waist_pouch.png', 8, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('back_backpack_canvas', 'BACK', '캔버스 백팩', 'assets/Cosmetic/back_backpack_canvas.png', 9, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bag_crossbody_satchel', 'BAG', '크로스백', 'assets/Cosmetic/bag_crossbody_satchel.png', 11, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bg_study', 'BACKGROUND', '공부방 배경', 'assets/Cosmetic/bg_study.png', 12, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_tumbler', 'HAND', '텀블러', 'assets/Cosmetic/prop_tumbler.png', 13, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 문제 복습 (PROBLEM_PRACTICE). 얼굴과 머리. 안경처럼 '보는 것' 이 복습과 어울린다. + ('glasses_round', 'FACE', '동그란 안경', 'assets/Cosmetic/glasses_round.png', 2, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beanie', 'HEAD', '비니', 'assets/Cosmetic/hat_beanie.png', 4, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_cheek_stickers', 'FACE', '볼 스티커', 'assets/Cosmetic/face_cheek_stickers.png', 5, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_sun', 'FACE', '선글라스', 'assets/Cosmetic/glasses_sun.png', 6, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_bucket', 'HEAD', '버킷햇', 'assets/Cosmetic/hat_bucket.png', 8, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_eye_patch', 'FACE', '안대', 'assets/Cosmetic/face_eye_patch.png', 9, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_beret', 'HEAD', '베레모', 'assets/Cosmetic/hat_beret.png', 10, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('glasses_heart', 'FACE', '하트 안경', 'assets/Cosmetic/glasses_heart.png', 12, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('headphone', 'HEAD', '헤드폰', 'assets/Cosmetic/headphone.png', 13, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('face_moustache', 'FACE', '콧수염', 'assets/Cosmetic/face_moustache.png', 14, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('head_earmuffs_winter', 'HEAD', '겨울 귀마개', 'assets/Cosmetic/head_earmuffs_winter.png', 15, 'PROBLEM_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 복습 세트 복습 (NOTE_PRACTICE). 목과 옷. 옷 넷은 전신이라 본체 그림이 머리만 남는다. + ('scarf', 'NECK', '목도리', 'assets/Cosmetic/scarf.png', 2, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('bowtie', 'NECK', '나비넥타이', 'assets/Cosmetic/bowtie.png', 3, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_cardigan', 'OUTFIT', '가디건', 'assets/Cosmetic/outfit_cardigan.png', 5, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_scarf_coral', 'NECK', '산호빛 스카프', 'assets/Cosmetic/neck_scarf_coral.png', 6, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_hoodie', 'OUTFIT', '후드티', 'assets/Cosmetic/outfit_hoodie.png', 8, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_camera', 'NECK', '카메라', 'assets/Cosmetic/neck_camera.png', 9, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_raincoat', 'OUTFIT', '우비', 'assets/Cosmetic/outfit_raincoat.png', 10, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('neck_medal', 'NECK', '메달', 'assets/Cosmetic/neck_medal.png', 12, 'NOTE_PRACTICE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('outfit_school', 'OUTFIT', '교복', 'assets/Cosmetic/outfit_school.png', 13, 'NOTE_PRACTICE', 1, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 총 학습 레벨 (required_ability = NULL). 능력치 넷을 골고루 올려야 오르는 값이라 + -- 한쪽만 파는 사람에게는 늦게 열린다. 뱃지와 손에 드는 것, 그리고 마지막 학사 세트를 둔다. + -- 상한이 15 에서 20 으로 올라가 16 / 18 / 19 / 20 자리가 생겼다. + ('headband_sprout', 'HEAD', '새싹 머리띠', 'assets/Cosmetic/headband_sprout.png', 2, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_leaf_star', 'BADGE', '잎새 별 뱃지', 'assets/Cosmetic/badge_leaf_star.png', 3, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_star', 'BADGE', '별 뱃지', 'assets/Cosmetic/badge_star.png', 5, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_heart', 'BADGE', '하트 뱃지', 'assets/Cosmetic/badge_heart.png', 7, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_bouquet', 'HAND', '꽃다발', 'assets/Cosmetic/prop_bouquet.png', 8, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_music', 'BADGE', '음표 뱃지', 'assets/Cosmetic/badge_music.png', 10, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_umbrella', 'HAND', '우산', 'assets/Cosmetic/prop_umbrella.png', 12, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_flame', 'BADGE', '불꽃 뱃지', 'assets/Cosmetic/badge_flame.png', 14, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('prop_lantern', 'HAND', '랜턴', 'assets/Cosmetic/prop_lantern.png', 16, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('badge_snowflake', 'BADGE', '눈송이 뱃지', 'assets/Cosmetic/badge_snowflake.png', 18, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_crown', 'HEAD', '왕관', 'assets/Cosmetic/hat_crown.png', 19, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('hat_graduate', 'HEAD', '학사모', 'assets/Cosmetic/hat_graduate.png', 20, NULL, 0, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)), + ('outfit_graduate', 'OUTFIT', '졸업 가운', 'assets/Cosmetic/outfit_graduate.png', 20, NULL, 1, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)), + ('prop_diploma', 'HAND', '졸업장', 'assets/Cosmetic/prop_diploma.png', 20, NULL, 0, 'graduate', '학사 세트', NULL, 1, NOW(6), NOW(6)) + +ON DUPLICATE KEY UPDATE + slot = VALUES(slot), + name_ko = VALUES(name_ko), + image_url = VALUES(image_url), + required_level = VALUES(required_level), + required_ability = VALUES(required_ability), + full_body = VALUES(full_body), + set_id = VALUES(set_id), + set_name_ko = VALUES(set_name_ko), + active = VALUES(active), + updated_at = NOW(6); From c84cfa58a54207e706bb8c7622112ebbeabe985f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 01:36:10 +0900 Subject: [PATCH 081/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EB=B3=B4=EC=9C=A0=20=ED=8C=90=EC=A0=95=EA=B3=BC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EB=8A=A5=EB=A0=A5=EC=B9=98=EB=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - required_ability 가 있으면 그 능력치 레벨, 없으면 총 학습 레벨과 required_level 을 비교한다. 테마 해금이 이미 능력치별로 돌고 있고, 앱의 스탯 화면이 보상과 이어지려면 이쪽이어야 한다 - 비교 대상을 고르는 갈림을 CosmeticUnlockLevels.levelFor 한 곳에 모았다. 판정하는 곳마다 다시 따지면 한 군데만 빠뜨려도 열려야 할 것이 안 열린다 - 목록·장착·세트·프리셋이 전부 같은 레벨 스냅샷 하나를 본다 - 슬롯에 BAG(450) 이 생기고 BACK(200) 은 등짐이 됐다. 응답의 slots 는 10개다 - 응답에 requiredAbility / fullBody / setNameKo 를 뒤에 덧붙였다. 기존 필드는 그대로라 구버전 앱은 모르는 필드를 무시하고 그대로 돈다 - 미션 보상 수령의 unlockedCosmetics 가 능력치 레벨업으로 열린 것도 잡는다. MissionRewardGranter 가 어느 능력치에 넣었는지와 그 전후 레벨을 함께 돌려주고, 해금 조회는 지급이 끝난 뒤에 돈다. 트랜잭션 경계와 잠금 순서(사용자 -> 진행도)는 그대로다 - 카탈로그에서 아이템이 다른 슬롯으로 옮겨 가면 그 자리를 가리키던 장착 행은 보여주지 않는다. 행은 두고 표시만 멈춘다 - 시드 55개 / 자리와 레이어 순서 / 해금 경계값 / 프리셋 / 마이그레이션 재실행 안전성을 테스트로 고정했다 --- .../cosmetic/dto/CosmeticItemResponseDto.java | 21 +- .../backend/cosmetic/entity/CosmeticItem.java | 58 ++- .../backend/cosmetic/entity/CosmeticSlot.java | 11 +- .../cosmetic/entity/CosmeticUnlockLevels.java | 66 +++ .../repository/CosmeticItemRepository.java | 27 +- .../cosmetic/service/CosmeticService.java | 119 ++++-- .../mission/service/MissionRewardGranter.java | 40 +- .../mission/service/MissionService.java | 5 +- .../CosmeticEquipConcurrencyTest.java | 4 +- .../controller/CosmeticControllerTest.java | 117 ++++-- .../migration/CosmeticMigrationTest.java | 257 ++++++++++-- .../cosmetic/service/CosmeticServiceTest.java | 388 +++++++++++++----- .../cosmetic/support/CosmeticItemSeeder.java | 9 +- .../cosmetic/support/CosmeticTestSupport.java | 115 +++++- .../service/MissionCosmeticUnlockTest.java | 63 ++- 15 files changed, 1073 insertions(+), 227 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java index 47c6960e..9add3210 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java @@ -2,16 +2,23 @@ import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticUnlockLevels; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import java.util.List; /** * 아이템 한 건. * - *

{@code owned} 는 저장된 값이 아니라 조회 시점에 계산한 값이다 - * ({@code requiredLevel != null && requiredLevel <= 총 학습 레벨}). + *

{@code owned} 는 저장된 값이 아니라 조회 시점에 계산한 값이다. {@code requiredAbility} 가 + * 있으면 그 능력치 레벨과, 없으면 총 학습 레벨과 {@code requiredLevel} 을 비교한다. * 그런데도 목록으로 실어 보내는 이유는, 나중에 시즌 보상이나 이벤트로 획득 경로가 늘어도 * 프론트는 이 불리언만 보면 되게 하기 위해서다. 해금 규칙이 바뀌어도 앱은 그대로다. + * + *

{@code requiredAbility} 를 함께 내려보내는 이유는 화면 문구 때문이다. 이 값이 없으면 + * 잠긴 아이템에 "Lv.5 에 열려요" 까지만 쓸 수 있고 어느 레벨을 올려야 하는지는 쓸 수 없다. + * + *

필드는 뒤에 덧붙이기만 했다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. */ public record CosmeticItemResponseDto( String itemKey, @@ -19,21 +26,27 @@ public record CosmeticItemResponseDto( String nameKo, String imageUrl, Integer requiredLevel, + AbilityType requiredAbility, + boolean fullBody, String setId, + String setNameKo, List conflictsWith, boolean owned ) { - public static CosmeticItemResponseDto of(CosmeticItem item, long totalStudyLevel) { + public static CosmeticItemResponseDto of(CosmeticItem item, CosmeticUnlockLevels levels) { return new CosmeticItemResponseDto( item.getItemKey(), item.getSlot(), item.getNameKo(), item.getImageUrl(), item.getRequiredLevel(), + item.getRequiredAbility(), + item.isFullBody(), item.getSetId(), + item.getSetNameKo(), item.conflictKeys(), - item.isOwnedAt(totalStudyLevel) + item.isOwnedBy(levels) ); } } diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java index 6b5ce316..bb2b8d2a 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.cosmetic.entity; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -14,6 +15,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; import java.time.LocalDateTime; import java.util.Arrays; @@ -26,6 +28,9 @@ * 상속하면 쓰지도 않는 {@code deleted_at} 이 딸려 와 V34 스키마와 어긋난다. * {@code createdAt}/{@code updatedAt} 은 시드가 채우는 값을 그대로 읽기만 한다. * + *

해금 조건은 {@code requiredLevel} 과 {@code requiredAbility} 두 컬럼이 함께 정한다. + * 자세한 것은 {@link #isOwnedBy(CosmeticUnlockLevels)} 에 있다. + * *

{@code imageUrl} 은 지금 번들 상대 경로({@code assets/Cosmetic/hat_beanie.png})다. * S3 로 옮길 때 이 컬럼만 {@code https://...} 로 바꾸면 앱 배포 없이 전환된다. * 프론트가 {@code http} 로 시작하는지로 갈라 처리한다. @@ -69,9 +74,51 @@ public class CosmeticItem { @Column(name = "required_level") private Integer requiredLevel; + /** + * {@code required_level} 을 어느 레벨과 비교할지. {@code null} 이면 총 학습 레벨이다. + * + *

테마 해금이 이미 능력치별로 돌고 있어 같은 방식을 쓴다. 총 학습 레벨 하나로만 열면 + * 앱의 능력치 네 칸짜리 스탯 화면이 보상과 아무 관계가 없어지고, 사용자에게 + * "출석 Lv.5 달성" 같은 구체적인 조건을 보여 줄 수도 없다. + * + *

여기가 {@code null} 이면 "능력치 조건이 없다"는 뜻이지 "ATTENDANCE 를 본다"는 뜻이 아니다. + * 기본값을 넣지 않는 이유는 {@code required_level} 에 마법값을 쓰지 않는 것과 같다. + */ + @Enumerated(EnumType.STRING) + @Column(name = "required_ability", length = 32) + private AbilityType requiredAbility; + + /** + * 소매와 바짓단까지 그려진 전신 의상인지. + * + *

앱이 이 옷을 입히면 개구리 본체를 머리만 있는 그림으로 바꿔 깐다. 그러지 않으면 + * 옷 밑으로 본체의 팔다리가 삐져나온다. 어느 옷이 전신인지는 그림을 봐야 알 수 있는 사실이라 + * 프론트가 item_key 목록을 코드에 박지 않도록 카탈로그가 들고 내려간다. + * + *

{@code @ColumnDefault} 가 V36 의 {@code DEFAULT 0} 과 짝을 이룬다. 이 값이 없으면 + * Hibernate 가 만드는 스키마에만 기본값이 빠져, 이 컬럼을 모르던 시절의 시드 INSERT(V35)를 + * 다시 돌릴 때 "doesn't have a default value" 로 막힌다. 운영에서는 V35 가 V36 보다 먼저 돌아 + * 드러나지 않는 차이라, 맞춰 두지 않으면 테스트에서만 나는 실패가 된다. + */ + @ColumnDefault("0") + @Column(name = "full_body", nullable = false) + private boolean fullBody; + @Column(name = "set_id", length = 64) private String setId; + /** + * 세트의 사람이 읽는 이름. 세트에 속하지 않으면 {@code null} 이다. + * + *

{@code set_id} 는 기계용 키라 화면에 그대로 쓸 수 없다. 이 컬럼이 없던 동안 + * 프론트가 세트 배너에 아이템 이름을 이어 붙여 썼다. + * + *

같은 {@code set_id} 를 가진 행끼리 값이 같아야 한다. 세트를 늘릴 때 한 행만 고치면 + * 배너 이름이 아이템마다 달라진다. + */ + @Column(name = "set_name_ko", length = 64) + private String setNameKo; + /** 같이 걸 수 없는 아이템의 {@code item_key} 목록. 콤마 구분. 지금은 전부 비어 있다. */ @Column(name = "conflicts_with", length = 512) private String conflictsWith; @@ -92,11 +139,14 @@ public class CosmeticItem { * 사용자는 레벨은 올랐는데 아이템이 안 열린 상태로 남는다. 계산으로 두면 그 상태 자체가 없다. * 테마 해금이 같은 방식을 쓴다. * - *

경계는 포함이다. {@code required_level == totalStudyLevel} 이면 열린 것이다. - * 레벨 6 짜리 비니는 레벨 6 이 되는 순간 써야지 7 이 돼서 열리면 안 된다. + *

비교 대상은 {@code requiredAbility} 가 정한다. 적혀 있으면 그 능력치 레벨, 비어 있으면 + * 총 학습 레벨이다. 갈림은 {@link CosmeticUnlockLevels#levelFor} 한 곳에만 있다. + * + *

경계는 포함이다. {@code required_level == 비교 대상 레벨} 이면 열린 것이다. + * 문제 복습 4 짜리 비니는 문제 복습이 4 가 되는 순간 써야지 5 가 돼서 열리면 안 된다. */ - public boolean isOwnedAt(long totalStudyLevel) { - return requiredLevel != null && requiredLevel <= totalStudyLevel; + public boolean isOwnedBy(CosmeticUnlockLevels levels) { + return requiredLevel != null && requiredLevel <= levels.levelFor(requiredAbility); } /** diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java index 00f2de1c..8c677334 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -14,7 +14,11 @@ * 서버만 고치면 되고, 이미 깔린 앱도 새 순서를 따른다. * *

값 사이를 100 씩 띄운 것은 의도된 것이다. 나중에 두 슬롯 사이에 새 슬롯이 필요해지면 - * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. + * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. {@link #BAG}(450) 이 실제로 그렇게 들어왔다. + * + *

{@link #BACK}(200) 과 {@link #BAG}(450) 은 둘 다 가방이지만 자리가 다르다. + * 등에 메는 것은 본체(300)보다 뒤에 깔려야 하고, 앞으로 메는 것은 옷(400) 위에 올라가야 한다. + * 한 슬롯으로 묶으면 둘 중 하나는 반드시 잘못 그려진다. * *

{@link #BASE} 는 장착 슬롯이 아니라 개구리 본체다. 본체 이미지도 {@code cosmetic_item} 행으로 * 두기 때문에 슬롯 이름이 필요해서 여기 있을 뿐이고, 장착 가능한 슬롯 목록과 아이템 목록에서는 빠진다. @@ -25,10 +29,13 @@ public enum CosmeticSlot { BACKGROUND(100, "배경", true), - BACK(200, "가방", true), + /** 등에 메는 가방. 개구리 본체보다 뒤에 깔린다. */ + BACK(200, "등짐", true), /** 개구리 본체. 장착 대상이 아니다. */ BASE(300, "개구리 본체", false), OUTFIT(400, "옷", true), + /** 앞으로 메는 가방. 옷 위에 올라간다. */ + BAG(450, "가방", true), NECK(500, "목", true), FACE(600, "얼굴", true), HEAD(700, "머리", true), diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java new file mode 100644 index 00000000..9e49bb3f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticUnlockLevels.java @@ -0,0 +1,66 @@ +package com.aisip.OnO.backend.cosmetic.entity; + +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; + +import java.util.EnumMap; +import java.util.Map; + +/** + * 해금 판정에 쓰는 사용자의 레벨 묶음. + * + *

보유 여부가 아이템마다 다른 레벨을 보게 되면서 생겼다. 아이템에 {@code required_ability} 가 + * 적혀 있으면 그 능력치 레벨을, 비어 있으면 총 학습 레벨을 본다. 판정을 하는 곳마다 + * "이 아이템은 어느 레벨과 비교해야 하지" 를 다시 따지면 한 군데만 빠뜨려도 + * 열려야 할 것이 안 열리거나 그 반대가 된다. 그 갈림을 {@link #levelFor(AbilityType)} 한 곳에 모은다. + * + *

레벨 값을 스냅샷으로 들고 다닌다. 조회 한 번 안에서 같은 사용자의 레벨이 달라 보이면 + * 목록의 {@code owned} 와 프리셋 계산이 서로 어긋날 수 있다. + */ +public record CosmeticUnlockLevels(long totalStudyLevel, Map abilityLevels) { + + /** 미션 상태가 아직 없는 사용자의 레벨. {@code StudyRoomMapper} 와 같은 기준이다. */ + public static final long DEFAULT_LEVEL = 1L; + + public CosmeticUnlockLevels { + abilityLevels = abilityLevels == null + ? Map.of() + : Map.copyOf(abilityLevels); + } + + /** 미션 상태가 없는 사용자. 모든 레벨이 1 이라 아무것도 열려 있지 않다. */ + public static CosmeticUnlockLevels defaults() { + return new CosmeticUnlockLevels(DEFAULT_LEVEL, Map.of()); + } + + public static CosmeticUnlockLevels from(UserMissionStatus status) { + if (status == null) { + return defaults(); + } + + Map levels = new EnumMap<>(AbilityType.class); + levels.put(AbilityType.ATTENDANCE, orDefault(status.getAttendanceLevel())); + levels.put(AbilityType.NOTE_WRITE, orDefault(status.getNoteWriteLevel())); + levels.put(AbilityType.PROBLEM_PRACTICE, orDefault(status.getProblemPracticeLevel())); + levels.put(AbilityType.NOTE_PRACTICE, orDefault(status.getNotePracticeLevel())); + + return new CosmeticUnlockLevels(orDefault(status.getTotalStudyLevel()), levels); + } + + /** + * 이 아이템과 비교할 레벨. + * + *

{@code ability} 가 null 이면 총 학습 레벨이다. 0 이나 -1 같은 마법값을 쓰지 않는 것과 + * 같은 이유로, "능력치 조건이 없다" 는 것을 null 하나로만 표현한다. + */ + public long levelFor(AbilityType ability) { + if (ability == null) { + return totalStudyLevel; + } + return abilityLevels.getOrDefault(ability, DEFAULT_LEVEL); + } + + private static long orDefault(Long level) { + return level == null ? DEFAULT_LEVEL : level; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java index a2faee0b..70d8d5de 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/repository/CosmeticItemRepository.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.cosmetic.repository; import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -24,20 +25,42 @@ public interface CosmeticItemRepository extends JpaRepository findAllBySetIdAndActiveTrueOrderByIdAsc(String setId); /** - * 이번 레벨업으로 새로 열린 아이템. + * 총 학습 레벨이 올라 새로 열린 아이템. * *

구간은 {@code (levelBefore, levelAfter]} 다. 앞은 열림, 뒤는 닫힘. * 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. * {@code required_level} 이 null 인 아이템은 레벨로 열리지 않으므로 비교에서 저절로 빠진다. + * + *

{@code required_ability} 가 있는 아이템은 여기서 빠진다. 그쪽은 총 학습 레벨이 아니라 + * 자기 능력치 레벨을 보기 때문에, 함께 잡으면 오르지도 않은 능력치의 아이템이 + * "방금 열렸다" 고 나간다. + */ + @Query(""" + SELECT i FROM CosmeticItem i + WHERE i.active = true + AND i.requiredAbility IS NULL + AND i.requiredLevel > :levelBefore + AND i.requiredLevel <= :levelAfter + ORDER BY i.requiredLevel ASC, i.itemKey ASC + """) + List findUnlockedByTotalLevelBetween( + @Param("levelBefore") long levelBefore, + @Param("levelAfter") long levelAfter + ); + + /** + * 한 능력치의 레벨이 올라 새로 열린 아이템. 구간 규칙은 위와 같다. */ @Query(""" SELECT i FROM CosmeticItem i WHERE i.active = true + AND i.requiredAbility = :ability AND i.requiredLevel > :levelBefore AND i.requiredLevel <= :levelAfter ORDER BY i.requiredLevel ASC, i.itemKey ASC """) - List findUnlockedBetween( + List findUnlockedByAbilityBetween( + @Param("ability") AbilityType ability, @Param("levelBefore") long levelBefore, @Param("levelAfter") long levelAfter ); diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java index d4ba043d..7f746203 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -8,11 +8,12 @@ import com.aisip.OnO.backend.cosmetic.dto.UnlockedCosmeticDto; import com.aisip.OnO.backend.cosmetic.entity.CosmeticItem; import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import com.aisip.OnO.backend.cosmetic.entity.CosmeticUnlockLevels; import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; import com.aisip.OnO.backend.cosmetic.repository.CosmeticItemRepository; import com.aisip.OnO.backend.cosmetic.repository.UserCosmeticLoadoutRepository; -import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -26,13 +27,14 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.TreeMap; /** * 꾸미기 조회와 장착. * - *

보유 여부는 저장하지 않는다. {@code required_level <= 총 학습 레벨} 로 매번 계산한다. + *

보유 여부는 저장하지 않는다. {@code required_level <= 비교 대상 레벨} 로 매번 계산한다. + * 비교 대상은 아이템의 {@code required_ability} 가 정한다. 적혀 있으면 그 능력치 레벨, + * 비어 있으면 총 학습 레벨이다. * 보유를 테이블로 두면 레벨이 오를 때마다 지급이 필요하고, 그 지급이 한 번 밀리면 사용자는 * 레벨은 올랐는데 아이템은 안 열린 상태로 남는다. 계산으로 두면 그런 상태가 아예 없다. * 테마 해금이 같은 방식을 쓴다. @@ -63,9 +65,6 @@ public class CosmeticService { */ private static final String FALLBACK_BASE_IMAGE_URL = "assets/Cosmetic/BASE.png"; - /** 미션 상태가 아직 없는 사용자의 레벨. {@code StudyRoomMapper} 와 같은 기준이다. */ - private static final long DEFAULT_STUDY_LEVEL = 1L; - private final CosmeticItemRepository cosmeticItemRepository; private final UserCosmeticLoadoutRepository userCosmeticLoadoutRepository; private final UserRepository userRepository; @@ -79,11 +78,11 @@ public class CosmeticService { *

읽기 전용이다. 여기서 장착 행을 만들지 않는다. 목록만 열어 본 사용자 수만큼 빈 행이 생기는 것을 피한다. */ public CosmeticListResponseDto getCosmetics(Long userId) { - long level = totalStudyLevelOf(userId); + CosmeticUnlockLevels levels = levelsOf(userId); List activeItems = cosmeticItemRepository.findAllByActiveTrue(); List items = equippableItems(activeItems).stream() - .map(item -> CosmeticItemResponseDto.of(item, level)) + .map(item -> CosmeticItemResponseDto.of(item, levels)) .toList(); return new CosmeticListResponseDto( @@ -91,7 +90,7 @@ public CosmeticListResponseDto getCosmetics(Long userId) { CosmeticSlot.BASE.getLayerOrder(), CosmeticSlot.equippableSlots().stream().map(CosmeticSlotDto::from).toList(), items, - currentEquipped(userId, activeItems, level) + currentEquipped(userId, activeItems, levels) ); } @@ -104,13 +103,13 @@ public CosmeticListResponseDto getCosmetics(Long userId) { */ @Transactional public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String itemKey) { - long level = totalStudyLevelOf(userId); + CosmeticUnlockLevels levels = levelsOf(userId); List activeItems = cosmeticItemRepository.findAllByActiveTrue(); Map itemsByKey = indexByKey(activeItems); - CosmeticItem item = resolveEquipTarget(slot, itemKey, itemsByKey, level); + CosmeticItem item = resolveEquipTarget(slot, itemKey, itemsByKey, levels); - materializePresetIfUntouched(userId, activeItems, level); + materializePresetIfUntouched(userId, activeItems, levels); // 쓸 내용을 먼저 다 모은 뒤 슬롯 이름 순서로 적용한다. // @@ -128,7 +127,7 @@ public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String ite log.info("userId: {} equipped slot: {}, itemKey: {}, unequipped: {}", userId, slot, itemKey, unequipped); - return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, level), unequipped); + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); } /** @@ -139,7 +138,7 @@ public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String ite */ @Transactional public CosmeticEquipResponseDto equipSet(Long userId, String setId) { - long level = totalStudyLevelOf(userId); + CosmeticUnlockLevels levels = levelsOf(userId); List setItems = cosmeticItemRepository.findAllBySetIdAndActiveTrueOrderByIdAsc(setId).stream() .filter(CosmeticItem::isEquippable) .toList(); @@ -148,7 +147,7 @@ public CosmeticEquipResponseDto equipSet(Long userId, String setId) { throw new ApplicationException(CosmeticErrorCase.COSMETIC_SET_NOT_FOUND); } // 검사를 먼저 전부 끝낸다. 걸다가 중간에 거절하면 앞의 몇 개만 걸린 채로 롤백을 믿어야 한다. - boolean anyLocked = setItems.stream().anyMatch(item -> !item.isOwnedAt(level)); + boolean anyLocked = setItems.stream().anyMatch(item -> !item.isOwnedBy(levels)); if (anyLocked) { throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); } @@ -156,7 +155,7 @@ public CosmeticEquipResponseDto equipSet(Long userId, String setId) { List activeItems = cosmeticItemRepository.findAllByActiveTrue(); Map itemsByKey = indexByKey(activeItems); - materializePresetIfUntouched(userId, activeItems, level); + materializePresetIfUntouched(userId, activeItems, levels); Map writes = orderedWrites(); setItems.forEach(item -> writes.put(item.getSlot(), item.getItemKey())); @@ -179,7 +178,7 @@ public CosmeticEquipResponseDto equipSet(Long userId, String setId) { log.info("userId: {} equipped set: {}, items: {}, unequipped: {}", userId, setId, setItems.stream().map(CosmeticItem::getItemKey).toList(), unequipped); - return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, level), unequipped); + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); } /** @@ -187,12 +186,53 @@ public CosmeticEquipResponseDto equipSet(Long userId, String setId) { * *

구간은 {@code (levelBefore, levelAfter]} 라, 레벨이 한 번에 여러 단계 오르면 그 사이 것이 전부 들어온다. * 레벨이 그대로면 빈 목록이다. + * + *

총 학습 레벨만 본다. {@code required_ability} 가 있는 아이템은 여기 걸리지 않는다. */ public List findUnlockedBetween(long levelBefore, long levelAfter) { + return toUnlockedDtos(unlockedByTotalLevel(levelBefore, levelAfter)); + } + + /** + * 이번 지급으로 새로 열린 아이템 전부. 총 학습 레벨과 능력치 레벨을 함께 본다. + * + *

미션 보상은 언제나 한 능력치에만 들어간다. 그 능력치 레벨이 오르면 그쪽 구간에서, + * 총 학습 레벨이 오르면 총 학습 구간에서 열린 것이 나온다. 둘 다 오를 수도 있어 합쳐서 준다. + * + *

총 학습 레벨만 보던 때에는 능력치 레벨이 올라 열린 아이템이 수령 응답에서 통째로 빠졌다. + * 해금 자체는 계산이라 다음 조회에서 드러나지만, 사용자는 그 순간에 아무 일도 없었다고 본다. + * + *

{@code ability} 가 null 이면(XP 가 아닌 보상 등) 총 학습 구간만 본다. + */ + public List findUnlockedBetween(long totalLevelBefore, long totalLevelAfter, + AbilityType ability, + long abilityLevelBefore, long abilityLevelAfter) { + List unlocked = new ArrayList<>(unlockedByTotalLevel(totalLevelBefore, totalLevelAfter)); + + if (ability != null && abilityLevelAfter > abilityLevelBefore) { + unlocked.addAll(cosmeticItemRepository.findUnlockedByAbilityBetween( + ability, abilityLevelBefore, abilityLevelAfter)); + } + + // 두 목록을 이어 붙였으니 다시 정렬한다. 정렬 기준이 없으면 같은 레벨업인데도 + // 응답에 실리는 순서가 조회마다 달라 보인다. + unlocked.sort(Comparator + .comparingInt((CosmeticItem item) -> item.getRequiredLevel() == null + ? Integer.MAX_VALUE : item.getRequiredLevel()) + .thenComparing(CosmeticItem::getItemKey)); + + return toUnlockedDtos(unlocked); + } + + private List unlockedByTotalLevel(long levelBefore, long levelAfter) { if (levelAfter <= levelBefore) { return List.of(); } - return cosmeticItemRepository.findUnlockedBetween(levelBefore, levelAfter).stream() + return cosmeticItemRepository.findUnlockedByTotalLevelBetween(levelBefore, levelAfter); + } + + private List toUnlockedDtos(List items) { + return items.stream() .filter(CosmeticItem::isEquippable) .map(UnlockedCosmeticDto::from) .toList(); @@ -207,7 +247,7 @@ public List findUnlockedBetween(long levelBefore, long leve * 2차 콘텐츠 목록을 알아낼 수 있다. */ private CosmeticItem resolveEquipTarget(CosmeticSlot slot, String itemKey, - Map itemsByKey, long level) { + Map itemsByKey, CosmeticUnlockLevels levels) { if (!slot.isEquippable()) { throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); } @@ -222,7 +262,7 @@ private CosmeticItem resolveEquipTarget(CosmeticSlot slot, String itemKey, if (item.getSlot() != slot) { throw new ApplicationException(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); } - if (!item.isOwnedAt(level)) { + if (!item.isOwnedBy(levels)) { throw new ApplicationException(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); } return item; @@ -279,10 +319,11 @@ private boolean anyoneConflictsWith(CosmeticItem item, Map * "잠긴 것을 걸고 있다" 는 상태도 화면에서 설명할 방법이 없다. * 관리자가 레벨을 되돌리면 걸려 있던 것이 그대로 다시 보인다. */ - private Map currentEquipped(Long userId, List activeItems, long level) { + private Map currentEquipped(Long userId, List activeItems, + CosmeticUnlockLevels levels) { List rows = userCosmeticLoadoutRepository.findAllByUserId(userId); if (rows.isEmpty()) { - return defaultPreset(activeItems, level); + return defaultPreset(activeItems, levels); } Map itemsByKey = indexByKey(activeItems); @@ -292,7 +333,12 @@ private Map currentEquipped(Long userId, List currentEquipped(Long userId, List슬롯마다 열린 것 중 {@code required_level} 이 가장 높은 것을 고른다. + *

슬롯마다 열린 것 중 {@code required_level} 이 가장 높은 것을 고른다. 한 슬롯 안에 + * 능력치가 다른 아이템이 섞여 있어도(머리 슬롯에는 문제 복습 아이템과 총 학습 아이템이 함께 있다) + * 숫자만 비교한다. 어차피 프리셋은 "가장 늦게 열린 것을 보여준다"는 어림이고, + * 어느 쪽이 걸리든 사용자가 바꿀 수 있다. * - *

이 프리셋이 있는 이유: 지금 레벨 15 인 사용자는 다 자란 개구리를 보고 있다. + *

이 프리셋이 있는 이유: 지금 레벨이 높은 사용자는 다 자란 개구리를 보고 있다. * 꾸미기로 전환하면서 장착 데이터가 없다는 이유로 맨 개구리가 되면, 사용자 입장에서는 * 아무것도 안 했는데 하향된 것이다. 프리셋이 그 순간을 막는다. * 마이그레이션으로 전 사용자 행을 만드는 방법도 있지만, 수십만 행을 미리 쓰고 나면 * 나중에 프리셋 규칙을 바꿀 수 없다. 계산으로 두면 규칙만 고치면 된다. */ - private Map defaultPreset(List activeItems, long level) { + private Map defaultPreset(List activeItems, CosmeticUnlockLevels levels) { Map preset = new EnumMap<>(CosmeticSlot.class); for (CosmeticSlot slot : CosmeticSlot.equippableSlots()) { activeItems.stream() .filter(item -> item.getSlot() == slot) - .filter(item -> item.isOwnedAt(level)) + .filter(item -> item.isOwnedBy(levels)) // 레벨이 높은 것이 나중에 얻은 것이다. 같은 레벨에 여러 개면 키 순서로 고정한다. // 정렬 기준이 없으면 조회할 때마다 다른 것이 걸려 있는 것처럼 보인다. - // isOwnedAt 을 통과한 아이템은 requiredLevel 이 null 이 아니다. + // isOwnedBy 를 통과한 아이템은 requiredLevel 이 null 이 아니다. .min(Comparator .comparingInt((CosmeticItem item) -> -item.getRequiredLevel()) .thenComparing(CosmeticItem::getItemKey)) @@ -331,7 +380,7 @@ private Map defaultPreset(List activeItems, /** * 한 번도 장착을 건드리지 않은 사용자의 프리셋을 실제 행으로 굳힌다. * - *

이게 없으면 첫 장착이 재앙이 된다. 레벨 15 사용자가 모자만 바꾸는 순간 행이 하나 생기고, + *

이게 없으면 첫 장착이 재앙이 된다. 레벨이 높은 사용자가 모자만 바꾸는 순간 행이 하나 생기고, * 그때부터 프리셋을 타지 않으므로 배경·옷·가방이 통째로 사라진다. 사용자는 모자를 바꿨을 뿐인데 * 개구리가 벗겨진다. * @@ -342,13 +391,14 @@ private Map defaultPreset(List activeItems, * 이 메서드는 아무 일도 하지 않아야 하는데, 조건 검사와 쓰기 사이에 다른 요청이 끼어들어도 * 그 성질이 유지된다. */ - private void materializePresetIfUntouched(Long userId, List activeItems, long level) { + private void materializePresetIfUntouched(Long userId, List activeItems, + CosmeticUnlockLevels levels) { if (userCosmeticLoadoutRepository.countByUserId(userId) > 0) { return; } Map preset = orderedWrites(); - preset.putAll(defaultPreset(activeItems, level)); + preset.putAll(defaultPreset(activeItems, levels)); preset.forEach((slot, itemKey) -> userCosmeticLoadoutRepository.insertIfAbsent(userId, slot.name(), itemKey)); } @@ -392,12 +442,11 @@ private Map indexByKey(List items) { return byKey; } - private long totalStudyLevelOf(Long userId) { + /** 해금 판정에 쓸 레벨 묶음. 한 요청 안에서는 이 스냅샷 하나만 본다. */ + private CosmeticUnlockLevels levelsOf(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new ApplicationException(CosmeticErrorCase.USER_NOT_FOUND)); - return Optional.ofNullable(user.getUserMissionStatus()) - .map(UserMissionStatus::getTotalStudyLevel) - .orElse(DEFAULT_STUDY_LEVEL); + return CosmeticUnlockLevels.from(user.getUserMissionStatus()); } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java index 38919861..d9b85eeb 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.mission.entity.MissionMetric; import com.aisip.OnO.backend.mission.entity.MissionRewardType; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import com.aisip.OnO.backend.mission.entity.UserMissionStatus; import com.aisip.OnO.backend.mission.exception.MissionErrorCase; import com.aisip.OnO.backend.user.entity.User; @@ -52,9 +53,15 @@ public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType re UserMissionStatus status = user.getUserMissionStatus(); long levelBefore = status.getTotalStudyLevel(); - if (rewardType == MissionRewardType.XP && rewardValue > 0) { + // 어느 능력치에 넣었는지와 그 능력치의 전후 레벨을 함께 돌려준다. 치장 해금이 + // 총 학습 레벨만이 아니라 능력치 레벨로도 열리기 때문에, 호출부가 이 값을 모르면 + // 능력치가 올라 열린 것을 수령 응답에서 통째로 빠뜨린다. + AbilityType ability = metric == null ? null : metric.getAbilityType(); + long abilityLevelBefore = abilityLevelOf(status, ability); + + if (rewardType == MissionRewardType.XP && rewardValue > 0 && ability != null) { long point = rewardValue; - switch (metric.getAbilityType()) { + switch (ability) { case ATTENDANCE -> status.gainAttendancePoint(point); case NOTE_WRITE -> status.gainNoteWritePoint(point); case PROBLEM_PRACTICE -> status.gainProblemPracticePoint(point); @@ -63,7 +70,21 @@ public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType re } long levelAfter = status.getTotalStudyLevel(); - return new GrantResult(levelBefore, levelAfter, levelAfter > levelBefore); + return new GrantResult(levelBefore, levelAfter, levelAfter > levelBefore, + ability, abilityLevelBefore, abilityLevelOf(status, ability)); + } + + /** 능력치가 없는 보상이면 0 이다. 전후가 같으니 해금 구간이 비게 된다. */ + private long abilityLevelOf(UserMissionStatus status, AbilityType ability) { + if (ability == null) { + return 0L; + } + return switch (ability) { + case ATTENDANCE -> status.getAttendanceLevel(); + case NOTE_WRITE -> status.getNoteWriteLevel(); + case PROBLEM_PRACTICE -> status.getProblemPracticeLevel(); + case NOTE_PRACTICE -> status.getNotePracticeLevel(); + }; } /** @@ -73,9 +94,20 @@ public GrantResult grant(Long userId, MissionMetric metric, MissionRewardType re * 계산해야 하기 때문이다. 레벨이 한 번에 여러 단계 오를 수 있어 {@code leveledUp} 만으로는 * 어디서 어디까지 올랐는지 알 수 없다. * + *

{@code abilityType} 과 그 능력치의 전후 레벨도 함께 준다. 치장 해금이 능력치별로도 + * 열리는데, 어느 능력치에 얼마가 들어갔는지는 여기서만 알 수 있다. + * XP 가 아닌 보상이면 {@code abilityType} 이 null 이고 전후 레벨은 둘 다 0 이라 구간이 비어 있다. + * *

여기서 해금 아이템까지 조회하지 않는다. 이 클래스는 사용자 행을 배타 잠금으로 잡은 * 구간이라, 잠금을 들고 하는 일을 늘리면 같은 사용자의 다른 요청이 그만큼 더 기다린다. */ - public record GrantResult(long levelBefore, Long totalStudyLevel, boolean leveledUp) { + public record GrantResult( + long levelBefore, + Long totalStudyLevel, + boolean leveledUp, + AbilityType abilityType, + long abilityLevelBefore, + long abilityLevelAfter + ) { } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java index 70d565d2..684d047f 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionService.java @@ -225,9 +225,12 @@ public MissionClaimResponseDto claim(Long userId, Long progressId) { userId, definition.getMetric(), definition.getRewardType(), definition.getRewardValue()); // 해금 조회는 지급이 끝난 뒤에 한다. 사용자 행 잠금을 들고 있는 구간을 늘리지 않기 위해서다. + // 총 학습 레벨 구간과 이번에 XP 가 들어간 능력치 레벨 구간을 함께 본다. 능력치 레벨로 열리는 + // 아이템이 대부분이라, 총 학습 레벨만 보면 레벨업 알림이 거의 늘 비어 있게 된다. // cosmetic_item 은 애플리케이션이 읽기만 하는 카탈로그라 이 조회가 새 잠금 순서를 만들지 않는다. List unlockedCosmetics = cosmeticService.findUnlockedBetween( - grantResult.levelBefore(), grantResult.totalStudyLevel()); + grantResult.levelBefore(), grantResult.totalStudyLevel(), + grantResult.abilityType(), grantResult.abilityLevelBefore(), grantResult.abilityLevelAfter()); log.info("userId: {} claimed missionProgressId: {}, code: {}, reward: {} {}, unlocked: {}", userId, progressId, definition.getCode(), definition.getRewardType(), definition.getRewardValue(), diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java index 769880e3..a49fb9fd 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java @@ -31,7 +31,7 @@ class CosmeticEquipConcurrencyTest extends CosmeticTestSupport { @BeforeEach void setUpUser() { - user = userAtLevel(15); + user = fullyGrownUser(); } @Nested @@ -98,7 +98,7 @@ void concurrentEquipsOnDifferentSlotsBothSurvive() { .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND); assertThat(loadoutRowCount(user.getId())) .as("프리셋이 여러 번 굳어도 슬롯 수를 넘는 행이 생기면 안 된다") - .isEqualTo(7); + .isEqualTo(10); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java index 6a4875c4..5cca9eaa 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; import com.aisip.OnO.backend.user.entity.User; +import org.hamcrest.Matchers; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; @@ -25,41 +26,88 @@ class CosmeticControllerTest extends CosmeticTestSupport { @Test @DisplayName("GET /api/cosmetics - 본체·슬롯·아이템·장착 상태를 한 번에 내려준다") void getCosmetics() throws Exception { - User user = userAtLevel(6); + // 출석 2, 문제 복습 4. 봄 배경(출석 2)과 비니(문제 복습 4)가 열려 있다. + User user = setLevels(fixtures.createUser(), 1L, 2L, 1L, 4L, 1L); authenticateAs(user.getId()); mockMvc.perform(get("/api/cosmetics")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.baseImageUrl").value("assets/Cosmetic/BASE.png")) .andExpect(jsonPath("$.data.baseLayerOrder").value(300)) - .andExpect(jsonPath("$.data.slots.length()").value(9)) + .andExpect(jsonPath("$.data.slots.length()").value(10)) .andExpect(jsonPath("$.data.slots[0].slot").value("BACKGROUND")) .andExpect(jsonPath("$.data.slots[0].layerOrder").value(100)) .andExpect(jsonPath("$.data.slots[0].nameKo").value("배경")) .andExpect(jsonPath("$.data.items").isArray()) + .andExpect(jsonPath("$.data.items.length()").value(SEEDED_ITEM_COUNT)) .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_SPRING)); } + @Test + @DisplayName("GET /api/cosmetics - slots 에 등짐(200)과 효과(900)가 빠짐없이 들어 있다") + void slotsIncludeBackAndEffect() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + String back = "$.data.slots[?(@.slot == 'BACK')]"; + String bag = "$.data.slots[?(@.slot == 'BAG')]"; + String effect = "$.data.slots[?(@.slot == 'EFFECT')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(back + ".layerOrder").value(Matchers.contains(200))) + .andExpect(jsonPath(back + ".nameKo").value(Matchers.contains("등짐"))) + .andExpect(jsonPath(bag + ".layerOrder").value(Matchers.contains(450))) + .andExpect(jsonPath(effect + ".layerOrder").value(Matchers.contains(900))); + } + @Test @DisplayName("GET /api/cosmetics - 아이템 한 건의 필드가 계약대로다") void itemShape() throws Exception { - User user = userAtLevel(6); + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + String glasses = "$.data.items[?(@.itemKey == '" + GLASSES_ROUND + "')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(glasses + ".slot").value(Matchers.contains("FACE"))) + .andExpect(jsonPath(glasses + ".nameKo").value(Matchers.contains("동그란 안경"))) + .andExpect(jsonPath(glasses + ".imageUrl") + .value(Matchers.contains("assets/Cosmetic/glasses_round.png"))) + .andExpect(jsonPath(glasses + ".requiredLevel").value(Matchers.contains(2))) + .andExpect(jsonPath(glasses + ".requiredAbility").value(Matchers.contains("PROBLEM_PRACTICE"))) + .andExpect(jsonPath(glasses + ".fullBody").value(Matchers.contains(false))) + .andExpect(jsonPath(glasses + ".setId").value(Matchers.contains(Matchers.nullValue()))) + .andExpect(jsonPath(glasses + ".setNameKo").value(Matchers.contains(Matchers.nullValue()))) + .andExpect(jsonPath(glasses + ".conflictsWith").value(Matchers.contains(Matchers.empty()))) + .andExpect(jsonPath(glasses + ".owned").value(Matchers.contains(true))); + } + + @Test + @DisplayName("GET /api/cosmetics - 총 학습 레벨로 열리는 아이템은 requiredAbility 가 null 이다") + void totalLevelItemHasNullAbility() throws Exception { + User user = fullyGrownUser(); authenticateAs(user.getId()); - // 배경(100) 다음이 가방(200), 그다음 옷(400)... 머리(700)의 첫 아이템이 새싹 머리띠(레벨 2)다. - String beanie = "$.data.items[?(@.itemKey == '" + HAT_BEANIE + "')]"; + String sprout = "$.data.items[?(@.itemKey == '" + HEADBAND_SPROUT + "')]"; mockMvc.perform(get("/api/cosmetics")) .andExpect(status().isOk()) - .andExpect(jsonPath(beanie + ".slot").value("HEAD")) - .andExpect(jsonPath(beanie + ".nameKo").value("비니")) - .andExpect(jsonPath(beanie + ".imageUrl").value("assets/Cosmetic/hat_beanie.png")) - .andExpect(jsonPath(beanie + ".requiredLevel").value(6)) - .andExpect(jsonPath(beanie + ".setId").value(org.hamcrest.Matchers.contains( - org.hamcrest.Matchers.nullValue()))) - .andExpect(jsonPath(beanie + ".conflictsWith").value(org.hamcrest.Matchers.contains( - org.hamcrest.Matchers.empty()))) - .andExpect(jsonPath(beanie + ".owned").value(true)); + .andExpect(jsonPath(sprout + ".requiredAbility").value(Matchers.contains(Matchers.nullValue()))) + .andExpect(jsonPath(sprout + ".requiredLevel").value(Matchers.contains(2))); + } + + @Test + @DisplayName("GET /api/cosmetics - 전신 의상과 세트 이름이 실려 나간다") + void fullBodyAndSetName() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + String gown = "$.data.items[?(@.itemKey == '" + OUTFIT_GRADUATE + "')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(gown + ".fullBody").value(Matchers.contains(true))) + .andExpect(jsonPath(gown + ".setId").value(Matchers.contains(GRADUATE_SET))) + .andExpect(jsonPath(gown + ".setNameKo").value(Matchers.contains(GRADUATE_SET_NAME))); } @Test @@ -74,7 +122,7 @@ void requiresAuthentication() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip - 갱신된 장착 상태 전체와 벗겨진 슬롯을 돌려준다") void equip() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip") @@ -82,7 +130,7 @@ void equip() throws Exception { .content(body("HEAD", HAT_BEANIE))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) - .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_NIGHT)) + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_SPACE)) .andExpect(jsonPath("$.data.unequippedSlots").isArray()) .andExpect(jsonPath("$.data.unequippedSlots.length()").value(0)); } @@ -90,7 +138,7 @@ void equip() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip - itemKey 가 null 이면 그 슬롯만 벗는다") void unequip() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip") @@ -98,13 +146,32 @@ void unequip() throws Exception { .content(body("HEAD", null))) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.equipped.HEAD").doesNotExist()) - .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_NIGHT)); + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_SPACE)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 앞가방과 등짐은 따로 걸린다") + void equipBagAndBack() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("BACK", BACK_BACKPACK_NAVY))) + .andExpect(status().isOk()); + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("BAG", BAG_MINI_BACKPACK))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.BACK").value(BACK_BACKPACK_NAVY)) + .andExpect(jsonPath("$.data.equipped.BAG").value(BAG_MINI_BACKPACK)); } @Test - @DisplayName("PUT /api/cosmetics/equip - 보유하지 않은 아이템은 400 이다") + @DisplayName("PUT /api/cosmetics/equip - 그 능력치 레벨이 모자라면 400 이다") void rejectsUnownedItem() throws Exception { - User user = userAtLevel(5); + // 총 학습은 20 이지만 문제 복습이 3 이라 비니(문제 복습 4)는 잠겨 있다. + User user = setLevels(fixtures.createUser(), 20L, 15L, 15L, 3L, 15L); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip") @@ -119,7 +186,7 @@ void rejectsUnownedItem() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip - 없는 슬롯 이름은 400 이다") void rejectsUnknownSlot() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip") @@ -131,7 +198,7 @@ void rejectsUnknownSlot() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip - 슬롯이 빠지면 400 이다") void rejectsMissingSlot() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip") @@ -143,7 +210,7 @@ void rejectsMissingSlot() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip-set - 세트가 각 슬롯에 한 번에 걸린다") void equipSet() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip-set") @@ -158,7 +225,7 @@ void equipSet() throws Exception { @Test @DisplayName("PUT /api/cosmetics/equip-set - 하나라도 미보유면 400 이다") void rejectsPartiallyOwnedSet() throws Exception { - User user = userAtLevel(14); + User user = setLevels(fixtures.createUser(), 19L, 15L, 15L, 15L, 15L); authenticateAs(user.getId()); mockMvc.perform(put("/api/cosmetics/equip-set") @@ -192,7 +259,7 @@ private String body(String slot, String itemKey) throws Exception { @Test @DisplayName("슬롯 이름은 enum 상수 이름 그대로 나간다") void slotNamesAreEnumNames() throws Exception { - User user = userAtLevel(15); + User user = fullyGrownUser(); authenticateAs(user.getId()); for (CosmeticSlot slot : CosmeticSlot.equippableSlots()) { diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java index 7e5f63e9..f29475ca 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java @@ -4,26 +4,32 @@ import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; import com.aisip.OnO.backend.cosmetic.support.CosmeticItemSeeder; import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.ConnectionCallback; import org.springframework.util.StreamUtils; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.sql.Statement; import java.util.Arrays; +import java.util.Comparator; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; /** - * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지. + * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지, 그리고 카탈로그가 해금표와 맞는지. * *

MySQL DDL 은 트랜잭션이 아니다. 테이블은 만들어지고 뒤이은 INSERT 에서 끊기면 Flyway 는 실패로 * 기록하는데 테이블은 남아, 재기동하면 "table already exists" 로 또 실패한다. @@ -37,6 +43,8 @@ class CosmeticMigrationTest extends CosmeticTestSupport { private static final String DDL_MIGRATION = "db/migration/V34__create_cosmetic_system.sql"; private static final String SEED_MIGRATION = "db/migration/V35__seed_cosmetic_items.sql"; + private static final String ABILITY_DDL_MIGRATION = "db/migration/V36__add_cosmetic_ability_unlock_columns.sql"; + private static final String ABILITY_SEED_MIGRATION = "db/migration/V37__seed_cosmetic_items_by_ability.sql"; @Autowired private CosmeticItemSeeder seeder; @@ -50,6 +58,9 @@ void ddlMigrationHasNoSeed() { assertThat(statementsOf(DDL_MIGRATION)) .as("DDL 과 시드가 한 파일에 있으면 시드에서 끊길 때 테이블만 남아 재실행이 막힌다") .doesNotContain("INSERT INTO"); + assertThat(statementsOf(ABILITY_DDL_MIGRATION)) + .as("V36 도 같은 이유로 컬럼 추가만 담는다") + .doesNotContain("INSERT INTO"); } @Test @@ -63,6 +74,34 @@ void ddlMigrationIsRerunnable() { assertThat(ddl).doesNotContain("CREATE INDEX"); } + @Test + @DisplayName("컬럼 추가는 information_schema 로 가드한다 - MySQL 에 ADD COLUMN IF NOT EXISTS 가 없다") + void abilityDdlGuardsEveryColumn() { + String ddl = statementsOf(ABILITY_DDL_MIGRATION); + + for (String column : List.of("REQUIRED_ABILITY", "FULL_BODY", "SET_NAME_KO")) { + assertThat(ddl) + .as(column + " 를 가드 없이 추가하면 재실행 때 Duplicate column name 으로 앱이 뜨지 않는다") + .contains("COLUMN_NAME = '" + column + "'"); + } + assertThat(ddl).contains("INFORMATION_SCHEMA.COLUMNS"); + } + + @Test + @DisplayName("이미 컬럼이 있는 스키마에 V36 을 다시 돌려도 터지지 않는다") + void rerunningAbilityDdlDoesNotFail() { + // 테스트 DB 에는 세 컬럼이 이미 있다(엔티티에서 Hibernate 가 만든다). 그 위에 두 번 더 돌려 본다. + for (int attempt = 0; attempt < 2; attempt++) { + runMigrationOnOneConnection(ABILITY_DDL_MIGRATION); + } + + // 예외가 안 났다는 것만으로는 약하다. 다시 돌린 뒤에도 카탈로그를 읽을 수 있는지 확인한다. + assertThat(cosmeticItemRepository.findByItemKey(GLASSES_ROUND)) + .get() + .extracting(CosmeticItem::getRequiredAbility) + .isEqualTo(AbilityType.PROBLEM_PRACTICE); + } + @Test @DisplayName("한 슬롯 한 행을 DB 가 막는다 - 복합 기본키가 실제로 걸려 있다") void loadoutHasCompositePrimaryKey() { @@ -91,6 +130,23 @@ void loadoutHasCompositePrimaryKey() { @DisplayName("시드는 다시 돌려도 안전하게 쓰여 있다") void seedMigrationIsRerunnable() { assertThat(statementsOf(SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + assertThat(statementsOf(ABILITY_SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + } + + @Test + @DisplayName("V37 은 기존 행을 덮어쓴다 - V35 까지 돈 DB 와 새 DB 가 같은 결과여야 한다") + void abilitySeedOverwritesExistingRows() { + String seed = statementsOf(ABILITY_SEED_MIGRATION); + + assertThat(seed) + .as("덮어쓰지 않으면 이미 V35 가 돈 DB 는 옛 해금 조건을 그대로 들고 있게 된다") + .contains("REQUIRED_ABILITY = VALUES(REQUIRED_ABILITY)") + .contains("REQUIRED_LEVEL = VALUES(REQUIRED_LEVEL)") + .contains("SLOT = VALUES(SLOT)") + .contains("ACTIVE = VALUES(ACTIVE)"); + assertThat(seed) + .as("충돌 목록은 운영에서 채우는 값이라 시드가 되돌리면 안 된다") + .doesNotContain("CONFLICTS_WITH = VALUES(CONFLICTS_WITH)"); } @Test @@ -98,11 +154,13 @@ void seedMigrationIsRerunnable() { void seedIsIdempotent() { // @BeforeEach 가 이미 한 번 넣었다. 같은 문장을 다시 돌리는 것이 이 테스트의 핵심이다. long before = cosmeticItemRepository.count(); + Map levelsBefore = levelsByKey(); seeder.seed(); seeder.seed(); assertThat(cosmeticItemRepository.count()).isEqualTo(before); + assertThat(levelsByKey()).isEqualTo(levelsBefore); } @Test @@ -114,24 +172,111 @@ void rerunningDdlDoesNotFail() { } // 예외가 안 났다는 것만으로는 약하다. 다시 돌린 뒤에도 스키마가 쓸 수 있는 상태인지 확인한다. - var user = userAtLevel(15); + var user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); assertThat(equippedOf(user.getId())).containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); } @Test - @DisplayName("레벨 해금 아이템은 2부터 15까지 빈 레벨 없이 채워져 있다") - void everyLevelHasAnUnlock() { - List unlockLevels = cosmeticItemRepository.findAllByActiveTrue().stream() - .map(CosmeticItem::getRequiredLevel) - .filter(java.util.Objects::nonNull) - .distinct() - .sorted() - .toList(); + @DisplayName("시드는 본체 말고 55개다") + void seedsFiftyFiveItems() { + assertThat(cosmeticItemRepository.findAll()) + .filteredOn(item -> !"BASE".equals(item.getItemKey())) + .as("해금표의 12 + 9 + 11 + 9 + 14 다") + .hasSize(SEEDED_ITEM_COUNT); + } + + @Test + @DisplayName("능력치별 개수가 해금표와 맞는다") + void abilityDistributionMatchesTable() { + Map byAbility = catalog().stream() + .filter(item -> item.getRequiredAbility() != null) + .collect(Collectors.groupingBy(CosmeticItem::getRequiredAbility, Collectors.counting())); + + assertThat(byAbility).containsOnly( + Map.entry(AbilityType.ATTENDANCE, 12L), + Map.entry(AbilityType.NOTE_WRITE, 9L), + Map.entry(AbilityType.PROBLEM_PRACTICE, 11L), + Map.entry(AbilityType.NOTE_PRACTICE, 9L)); + + assertThat(catalog()).filteredOn(item -> item.getRequiredAbility() == null) + .as("나머지는 총 학습 레벨로 열린다") + .hasSize(14); + } + + @Test + @DisplayName("자리와 레이어 순서가 표와 맞는다") + void slotDistributionMatchesTable() { + Map bySlot = catalog().stream() + .collect(Collectors.groupingBy(CosmeticItem::getSlot, Collectors.counting())); + + assertThat(bySlot).containsOnly( + Map.entry(CosmeticSlot.BACKGROUND, 9L), + Map.entry(CosmeticSlot.BACK, 2L), + Map.entry(CosmeticSlot.OUTFIT, 5L), + Map.entry(CosmeticSlot.BAG, 3L), + Map.entry(CosmeticSlot.NECK, 5L), + Map.entry(CosmeticSlot.FACE, 6L), + Map.entry(CosmeticSlot.HEAD, 8L), + Map.entry(CosmeticSlot.HAND, 7L), + Map.entry(CosmeticSlot.BADGE, 6L), + Map.entry(CosmeticSlot.EFFECT, 4L)); + + assertThat(CosmeticSlot.equippableSlots()) + .extracting(CosmeticSlot::getLayerOrder) + .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") + .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900); + } + + @Test + @DisplayName("등에 메는 가방과 앞으로 메는 가방이 갈려 있다") + void backAndBagAreSeparated() { + assertThat(keysOfSlot(CosmeticSlot.BACK)) + .as("V35 에서 BACK 은 그냥 '가방' 이었다. 뜻이 바뀌었으니 내용도 바뀌어야 한다") + .containsExactly("back_backpack_canvas", "back_backpack_navy"); + assertThat(keysOfSlot(CosmeticSlot.BAG)) + .containsExactly("bag_crossbody_satchel", "bag_mini_backpack", "bag_waist_pouch"); + } + + @Test + @DisplayName("능력치 아이템의 해금 레벨은 2 부터 15 사이다") + void abilityUnlockLevelsAreWithinAbilityRange() { + assertThat(catalog()) + .filteredOn(item -> item.getRequiredAbility() != null) + .allSatisfy(item -> assertThat(item.getRequiredLevel()) + .as(item.getItemKey() + " 의 해금 레벨") + .isBetween(2, 15)); + } - assertThat(unlockLevels) - .as("중간 레벨에 아무것도 안 열리면 그 레벨업만 보상이 없는 것처럼 보인다") - .containsExactly(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15); + @Test + @DisplayName("총 학습 아이템의 해금 레벨은 상한 20 을 넘지 않는다") + void totalUnlockLevelsStayUnderCap() { + assertThat(catalog()) + .filteredOn(item -> item.getRequiredAbility() == null) + .allSatisfy(item -> assertThat(item.getRequiredLevel()) + .as(item.getItemKey() + " 의 해금 레벨. 상한을 넘기면 영영 열리지 않는다") + .isBetween(2, 20)); + } + + @Test + @DisplayName("한 능력치 안에서 같은 레벨에 두 개가 열리지 않는다") + void noDuplicateLevelWithinAbility() { + Map> levels = catalog().stream() + .filter(item -> item.getRequiredAbility() != null) + .collect(Collectors.groupingBy(CosmeticItem::getRequiredAbility, + Collectors.mapping(CosmeticItem::getRequiredLevel, Collectors.toList()))); + + levels.forEach((ability, values) -> assertThat(values) + .as(ability + " 의 해금 레벨. 한 레벨에 둘이 열리면 레벨업 알림이 어느 쪽인지 흐려진다") + .doesNotHaveDuplicates()); + } + + @Test + @DisplayName("시드된 아이템은 전부 활성이다") + void everySeededItemIsActive() { + assertThat(cosmeticItemRepository.findAll()) + .as("2차 콘텐츠로 내려 두었던 19 개가 전부 해금 레벨을 받았다") + .allMatch(CosmeticItem::isActive); } @Test @@ -156,7 +301,21 @@ void imageUrlIsNotRemoteYet() { } @Test - @DisplayName("졸업 세트는 세 슬롯이고 전부 레벨 15 다") + @DisplayName("전신 의상은 옷 다섯 벌뿐이다") + void fullBodyIsExactlyTheOutfits() { + assertThat(cosmeticItemRepository.findAll()) + .filteredOn(CosmeticItem::isFullBody) + .extracting(CosmeticItem::getItemKey) + .containsExactlyInAnyOrder("outfit_cardigan", "outfit_hoodie", "outfit_raincoat", + "outfit_school", "outfit_graduate"); + + assertThat(keysOfSlot(CosmeticSlot.OUTFIT)) + .as("옷 슬롯에 전신이 아닌 것이 생기면 이 단언부터 다시 봐야 한다") + .hasSize(5); + } + + @Test + @DisplayName("학사 세트는 세 슬롯이고 전부 총 학습 20 이며 이름이 같다") void graduateSetIsConsistent() { List setItems = cosmeticItemRepository.findAllBySetIdAndActiveTrueOrderByIdAsc(GRADUATE_SET); @@ -164,18 +323,22 @@ void graduateSetIsConsistent() { .containsExactlyInAnyOrder(CosmeticSlot.HEAD, CosmeticSlot.OUTFIT, CosmeticSlot.HAND); assertThat(setItems).extracting(CosmeticItem::getRequiredLevel) .as("세트인데 해금 레벨이 다르면 절반만 가진 상태가 생긴다") - .containsOnly(15); + .containsOnly(20); + assertThat(setItems).extracting(CosmeticItem::getRequiredAbility) + .as("세트는 총 학습 레벨로 연다") + .containsOnlyNulls(); + assertThat(setItems).extracting(CosmeticItem::getSetNameKo) + .as("세트 이름이 아이템마다 다르면 배너 문구가 흔들린다") + .containsOnly(GRADUATE_SET_NAME); } @Test - @DisplayName("2차 콘텐츠는 비활성이고 해금 레벨이 없다") - void inactiveItemsHaveNoLevel() { - List inactive = cosmeticItemRepository.findAll().stream() - .filter(item -> !item.isActive()) - .toList(); - - assertThat(inactive).hasSize(19); - assertThat(inactive).extracting(CosmeticItem::getRequiredLevel).containsOnlyNulls(); + @DisplayName("세트에 속하지 않은 아이템은 세트 이름도 비어 있다") + void setNameFollowsSetId() { + assertThat(cosmeticItemRepository.findAll()) + .filteredOn(item -> item.getSetId() == null) + .extracting(CosmeticItem::getSetNameKo) + .containsOnlyNulls(); } @Test @@ -185,9 +348,32 @@ void slotsAreKnown() { // 조회가 성공했다는 것 자체가 검증이지만, 슬롯 분포까지 함께 본다. assertThat(cosmeticItemRepository.findAll()) .extracting(CosmeticItem::getSlot) - .contains(CosmeticSlot.BASE, CosmeticSlot.HEAD, CosmeticSlot.BACKGROUND, - CosmeticSlot.OUTFIT, CosmeticSlot.NECK, CosmeticSlot.FACE, - CosmeticSlot.HAND, CosmeticSlot.BACK, CosmeticSlot.BADGE); + .contains(CosmeticSlot.values()); + } + + // ─────────────────────────── 헬퍼 ─────────────────────────── + + /** 본체를 뺀 시드 카탈로그. */ + private List catalog() { + return cosmeticItemRepository.findAll().stream() + .filter(item -> !"BASE".equals(item.getItemKey())) + .toList(); + } + + private List keysOfSlot(CosmeticSlot slot) { + return catalog().stream() + .filter(item -> item.getSlot() == slot) + .map(CosmeticItem::getItemKey) + .sorted() + .toList(); + } + + private Map levelsByKey() { + return catalog().stream() + .sorted(Comparator.comparing(CosmeticItem::getItemKey)) + .collect(Collectors.toMap(CosmeticItem::getItemKey, + item -> Objects.requireNonNull(item.getRequiredLevel()), + (left, right) -> left)); } /** 마이그레이션의 실행 문장을 순서대로 돌린다. */ @@ -199,6 +385,25 @@ private void runMigration(String path) { }); } + /** + * 커넥션 하나에서 순서대로 돌린다. + * + *

V36 은 {@code SET @변수} 로 상태를 넘기고 {@code PREPARE}/{@code EXECUTE} 로 DDL 을 실행한다. + * 사용자 변수는 세션(커넥션) 단위라 문장마다 커넥션이 달라지면 가드가 성립하지 않고, + * PREPARE 는 prepared statement 프로토콜로는 보낼 수 없어 일반 Statement 가 필요하다. + */ + private void runMigrationOnOneConnection(String path) { + List statements = executableStatements(path); + jdbcTemplate.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } + return null; + }); + } + private List executableStatements(String path) { return Arrays.stream(withoutComments(path).split(";")) .map(String::trim) diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index 3c9e8e33..c64a2989 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -10,6 +10,7 @@ import com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout; import com.aisip.OnO.backend.cosmetic.exception.CosmeticErrorCase; import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.mission.entity.MissionType.AbilityType; import com.aisip.OnO.backend.user.entity.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -26,6 +27,11 @@ @DisplayName("꾸미기 서비스") class CosmeticServiceTest extends CosmeticTestSupport { + private static final String BADGE_HEART = "badge_heart"; + private static final String EFFECT_SPARKLE = "effect_sparkle"; + private static final String BG_RAINY = "bg_rainy"; + private static final String PROP_NOTEBOOK = "prop_notebook"; + @Nested @DisplayName("아이템 목록") class ItemCatalog { @@ -33,48 +39,103 @@ class ItemCatalog { @Test @DisplayName("잠긴 아이템도 owned false 로 함께 내려간다") void includesLockedItems() { - User user = userAtLevel(6); + // 문제 복습만 4 인 사용자. 오답노트 작성은 1 이라 공부방 배경(작성 12)은 아직 잠겨 있다. + User user = setLevels(fixtures.createUser(), 1L, 1L, 1L, 4L, 1L); CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); assertThat(ownedOf(response, HAT_BEANIE)) - .as("레벨 6 아이템은 레벨 6 에서 열린다 - 경계는 포함이다") + .as("문제 복습 4 짜리 비니는 문제 복습이 4 가 되는 순간 열린다 - 경계는 포함이다") .isTrue(); assertThat(ownedOf(response, BG_STUDY)) - .as("레벨 7 아이템은 아직 잠겨 있지만 목록에는 있어야 한다") + .as("오답노트 작성 12 짜리 배경은 아직 잠겨 있지만 목록에는 있어야 한다") .isFalse(); assertThat(itemKeys(response)).contains(BG_STUDY); } - @ParameterizedTest(name = "레벨 {0} 이면 레벨 {1} 아이템 보유는 {2}") + @ParameterizedTest(name = "문제 복습 레벨 {0} 이면 비니(문제 복습 4) 보유는 {1}") @CsvSource({ - "5, 6, false", - "6, 6, true", - "7, 6, true", + "3, false", + "4, true", + "5, true", }) - @DisplayName("해금 경계는 required_level == 레벨 을 포함한다") - void unlockBoundaryIsInclusive(long userLevel, int itemLevel, boolean expected) { - User user = userAtLevel(userLevel); - // 비교 대상은 시드된 레벨 6 아이템(비니) 하나다. - assertThat(itemLevel).isEqualTo(6); + @DisplayName("해금 경계는 required_level == 그 능력치 레벨 을 포함한다") + void unlockBoundaryIsInclusive(long problemPracticeLevel, boolean expected) { + User user = setLevels(fixtures.createUser(), 1L, 1L, 1L, problemPracticeLevel, 1L); assertThat(ownedOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE)).isEqualTo(expected); } + @Test + @DisplayName("능력치 하나만 높아도 다른 능력치 아이템은 열리지 않는다") + void abilityLevelDoesNotLeakToOtherAbilities() { + // 문제 복습만 15. 출석·작성·복습세트는 1 이다. + User user = setLevels(fixtures.createUser(), 1L, 1L, 1L, 15L, 1L); + + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + assertThat(ownedOf(response, HEAD_EARMUFFS_WINTER)) + .as("문제 복습의 마지막 아이템까지 열린다") + .isTrue(); + assertThat(ownedOf(response, BG_SPRING)) + .as("출석 2 짜리 봄 배경이 문제 복습 15 로 열리면 능력치별 해금이 아니다") + .isFalse(); + assertThat(ownedOf(response, SCARF)).isFalse(); + assertThat(ownedOf(response, BAG_MINI_BACKPACK)).isFalse(); + } + + @Test + @DisplayName("required_ability 가 비어 있는 아이템은 총 학습 레벨을 본다") + void nullAbilityFallsBackToTotalLevel() { + // 총 학습만 2, 능력치 넷은 1. + User onlyTotal = userAtLevel(2); + // 능력치 넷은 15, 총 학습은 1. + User onlyAbilities = setLevels(fixtures.createUser(), 1L, 15L, 15L, 15L, 15L); + + assertThat(ownedOf(cosmeticService.getCosmetics(onlyTotal.getId()), HEADBAND_SPROUT)) + .as("새싹 머리띠는 required_ability 가 비어 있어 총 학습 레벨 2 에서 열린다") + .isTrue(); + assertThat(ownedOf(cosmeticService.getCosmetics(onlyAbilities.getId()), HEADBAND_SPROUT)) + .as("능력치를 아무리 올려도 총 학습 레벨이 1 이면 열리지 않는다") + .isFalse(); + } + + @Test + @DisplayName("총 학습 레벨 16 이상에서 열리는 자리도 실제로 열린다") + void unlocksAboveOldCap() { + User user = userAtLevel(16); + + assertThat(ownedOf(cosmeticService.getCosmetics(user.getId()), PROP_LANTERN)) + .as("상한이 15 에 머물면 이 아이템은 영영 열리지 않는다") + .isTrue(); + assertThat(ownedOf(cosmeticService.getCosmetics(user.getId()), BADGE_SNOWFLAKE)).isFalse(); + } + @Test @DisplayName("비활성 아이템은 목록에 나오지 않는다") void excludesInactiveItems() { - User user = userAtLevel(15); + User user = fullyGrownUser(); + deactivate(HAT_BEANIE); + + assertThat(itemKeys(cosmeticService.getCosmetics(user.getId()))) + .as("내려간 아이템이 목록에 새어 나가면 안 된다") + .doesNotContain(HAT_BEANIE); + } + + @Test + @DisplayName("시드된 55개가 빠짐없이 목록에 들어간다") + void everySeededItemIsListed() { + User user = fullyGrownUser(); assertThat(itemKeys(cosmeticService.getCosmetics(user.getId()))) - .as("2차 콘텐츠가 목록에 새어 나가면 안 된다") - .doesNotContain(INACTIVE_HAT_BERET); + .as("본체(BASE)만 빠지고 나머지는 전부 나간다") + .hasSize(SEEDED_ITEM_COUNT); } @Test @DisplayName("개구리 본체는 아이템이 아니라 baseImageUrl 로 나간다") void baseIsNotAnItem() { - User user = userAtLevel(15); + User user = fullyGrownUser(); CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); @@ -89,24 +150,25 @@ void baseIsNotAnItem() { @Test @DisplayName("슬롯 목록은 장착 가능한 것만, 뒤에서 앞 순서로 나간다") void slotsAreOrderedBackToFront() { - User user = userAtLevel(1); + User user = userAtAllLevels(1); List slots = cosmeticService.getCosmetics(user.getId()).slots(); assertThat(slots).extracting(CosmeticSlotDto::slot) .containsExactly( CosmeticSlot.BACKGROUND, CosmeticSlot.BACK, CosmeticSlot.OUTFIT, - CosmeticSlot.NECK, CosmeticSlot.FACE, CosmeticSlot.HEAD, - CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT); + CosmeticSlot.BAG, CosmeticSlot.NECK, CosmeticSlot.FACE, + CosmeticSlot.HEAD, CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT); assertThat(slots).extracting(CosmeticSlotDto::layerOrder) - .containsExactly(100, 200, 400, 500, 600, 700, 800, 850, 900); + .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") + .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900); assertThat(slots.get(0).nameKo()).isEqualTo("배경"); } @Test @DisplayName("이미지 경로는 번들 상대 경로다 - S3 전환은 컬럼 값만 바꾸면 된다") void imageUrlIsBundlePath() { - User user = userAtLevel(6); + User user = fullyGrownUser(); assertThat(itemOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE).imageUrl()) .isEqualTo("assets/Cosmetic/hat_beanie.png"); @@ -115,10 +177,52 @@ void imageUrlIsBundlePath() { @Test @DisplayName("충돌 목록은 비어 있어도 빈 배열로 나간다") void conflictsAreEmptyList() { - User user = userAtLevel(6); + User user = fullyGrownUser(); assertThat(itemOf(cosmeticService.getCosmetics(user.getId()), HAT_BEANIE).conflictsWith()).isEmpty(); } + + @Test + @DisplayName("해금 조건·전신 여부·세트 이름이 아이템에 함께 실린다") + void carriesUnlockAndDisplayFields() { + User user = fullyGrownUser(); + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + CosmeticItemResponseDto glasses = itemOf(response, GLASSES_ROUND); + assertThat(glasses.requiredAbility()).isEqualTo(AbilityType.PROBLEM_PRACTICE); + assertThat(glasses.requiredLevel()).isEqualTo(2); + assertThat(glasses.fullBody()).isFalse(); + assertThat(glasses.setId()).isNull(); + assertThat(glasses.setNameKo()).isNull(); + + CosmeticItemResponseDto sprout = itemOf(response, HEADBAND_SPROUT); + assertThat(sprout.requiredAbility()) + .as("총 학습 레벨로 열리는 아이템은 능력치가 비어 있다") + .isNull(); + + CosmeticItemResponseDto gown = itemOf(response, OUTFIT_GRADUATE); + assertThat(gown.fullBody()) + .as("전신 의상이면 앱이 본체를 머리만 있는 그림으로 바꿔 깐다") + .isTrue(); + assertThat(gown.setId()).isEqualTo(GRADUATE_SET); + assertThat(gown.setNameKo()).isEqualTo(GRADUATE_SET_NAME); + } + + @Test + @DisplayName("전신 의상은 옷 다섯 벌 전부다") + void everyOutfitIsFullBody() { + User user = fullyGrownUser(); + + List fullBody = cosmeticService.getCosmetics(user.getId()).items().stream() + .filter(CosmeticItemResponseDto::fullBody) + .map(CosmeticItemResponseDto::itemKey) + .toList(); + + assertThat(fullBody) + .as("소매와 바짓단이 그려진 옷을 빠뜨리면 그 옷만 개구리 팔다리가 삐져나온다") + .containsExactlyInAnyOrder(OUTFIT_CARDIGAN, "outfit_hoodie", "outfit_raincoat", + OUTFIT_SCHOOL, OUTFIT_GRADUATE); + } } @Nested @@ -128,38 +232,60 @@ class DefaultPreset { @Test @DisplayName("장착 행이 없는 사용자는 슬롯마다 가장 높은 해금 아이템을 걸고 있다") void presetPicksHighestUnlockedPerSlot() { - User user = userAtLevel(15); + User user = fullyGrownUser(); assertThat(equippedOf(user.getId())) - .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) - .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT) + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) + .containsEntry(CosmeticSlot.BACK, BACK_BACKPACK_CANVAS) .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) + .containsEntry(CosmeticSlot.BAG, BAG_CROSSBODY_SATCHEL) + .containsEntry(CosmeticSlot.NECK, NECK_MEDAL) + .containsEntry(CosmeticSlot.FACE, "face_moustache") + .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) - .containsEntry(CosmeticSlot.FACE, GLASSES_SUN) - .containsEntry(CosmeticSlot.NECK, SCARF) - .containsEntry(CosmeticSlot.BACK, BAG_MINI_BACKPACK) - .doesNotContainKey(CosmeticSlot.BADGE) - .doesNotContainKey(CosmeticSlot.EFFECT); + .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) + .containsEntry(CosmeticSlot.EFFECT, EFFECT_SNOW); } @Test - @DisplayName("레벨이 낮으면 열린 것만 걸린다") - void presetFollowsLevel() { - User user = userAtLevel(6); + @DisplayName("프리셋도 능력치별 기준으로 뽑힌다") + void presetFollowsAbilityLevel() { + // 출석만 6. 출석 아이템은 배경과 전경 효과뿐이다. + User user = setLevels(fixtures.createUser(), 1L, 6L, 1L, 1L, 1L); assertThat(equippedOf(user.getId())) - .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) - .containsEntry(CosmeticSlot.BACKGROUND, BG_SPRING) - .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND) - .containsEntry(CosmeticSlot.NECK, SCARF) - .as("레벨 8 가방은 아직 안 열렸다") - .doesNotContainKey(CosmeticSlot.BACK); + .containsEntry(CosmeticSlot.BACKGROUND, BG_RAINY) + .containsEntry(CosmeticSlot.EFFECT, EFFECT_SPARKLE) + .as("출석만 올렸는데 다른 능력치 자리가 채워지면 안 된다") + .doesNotContainKey(CosmeticSlot.HEAD) + .doesNotContainKey(CosmeticSlot.FACE) + .doesNotContainKey(CosmeticSlot.NECK) + .doesNotContainKey(CosmeticSlot.BAG); + } + + @Test + @DisplayName("총 학습 레벨만 높으면 총 학습 아이템만 걸린다") + void presetWithOnlyTotalLevel() { + User user = userAtLevel(20); + + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) + .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) + .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) + .as("배경·얼굴·목·가방은 전부 능력치로만 열린다") + .doesNotContainKey(CosmeticSlot.BACKGROUND) + .doesNotContainKey(CosmeticSlot.FACE) + .doesNotContainKey(CosmeticSlot.NECK) + .doesNotContainKey(CosmeticSlot.BAG) + .doesNotContainKey(CosmeticSlot.BACK) + .doesNotContainKey(CosmeticSlot.EFFECT); } @Test @DisplayName("레벨 1 은 아무것도 걸치지 않은 맨 개구리다") void level1HasNothing() { - User user = userAtLevel(1); + User user = userAtAllLevels(1); assertThat(equippedOf(user.getId())).isEmpty(); } @@ -167,9 +293,11 @@ void level1HasNothing() { @Test @DisplayName("해금 레벨이 같으면 키 순서로 고정된다 - 조회할 때마다 달라지면 안 된다") void presetTieIsStable() { - User user = userAtLevel(15); - // hat_bucket 을 학사모와 같은 레벨로 올려 동점을 만든다. - jdbcTemplate.update("UPDATE cosmetic_item SET required_level = 15 WHERE item_key = ?", HAT_BUCKET); + User user = fullyGrownUser(); + // 버킷햇을 학사모와 같은 조건으로 올려 동점을 만든다. + jdbcTemplate.update( + "UPDATE cosmetic_item SET required_level = 20, required_ability = NULL WHERE item_key = ?", + HAT_BUCKET); assertThat(equippedOf(user.getId())) .containsEntry(CosmeticSlot.HEAD, HAT_BUCKET); @@ -181,7 +309,7 @@ void presetTieIsStable() { @Test @DisplayName("조회만으로는 행이 생기지 않는다") void readingDoesNotCreateRows() { - User user = userAtLevel(15); + User user = fullyGrownUser(); cosmeticService.getCosmetics(user.getId()); cosmeticService.getCosmetics(user.getId()); @@ -194,26 +322,26 @@ void readingDoesNotCreateRows() { @Test @DisplayName("첫 장착 때 프리셋이 행으로 굳어 나머지 슬롯이 벗겨지지 않는다") void firstEquipMaterializesPreset() { - User user = userAtLevel(15); + User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); assertThat(equippedOf(user.getId())) .as("모자만 바꿨는데 배경·옷·가방이 사라지면 사용자 눈에는 하향이다") .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE) - .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT) + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA); - assertThat(loadoutRowCount(user.getId())).isEqualTo(7); + assertThat(loadoutRowCount(user.getId())).isEqualTo(10); } @Test @DisplayName("한 번 장착한 뒤로는 레벨이 올라도 프리셋을 타지 않는다") void presetDoesNotComeBackAfterFirstChange() { - User user = userAtLevel(6); + User user = setLevels(fixtures.createUser(), 2L, 1L, 1L, 4L, 1L); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HEADBAND_SPROUT); - setLevel(user, 15); + setLevels(user, 20L, 15L, 15L, 15L, 15L); assertThat(equippedOf(user.getId())) .as("사용자가 고른 머리띠를 레벨업이 학사모로 바꿔치기하면 안 된다") @@ -223,7 +351,7 @@ void presetDoesNotComeBackAfterFirstChange() { @Test @DisplayName("모든 슬롯을 벗어도 프리셋이 되살아나지 않는다") void unequippingEverythingSticks() { - User user = userAtLevel(15); + User user = fullyGrownUser(); Map preset = equippedOf(user.getId()); preset.keySet().forEach(slot -> cosmeticService.equip(user.getId(), slot, null)); @@ -232,6 +360,19 @@ void unequippingEverythingSticks() { .as("맨 개구리를 보려고 다 벗은 사용자가 앱을 껐다 켜면 다시 입고 있으면 안 된다") .isEmpty(); } + + @Test + @DisplayName("아이템이 다른 슬롯으로 옮겨 가면 예전 행은 보여주지 않는다") + void staleSlotRowIsHidden() { + User user = fullyGrownUser(); + cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BAG_MINI_BACKPACK); + // 카탈로그에서 미니 백팩을 등짐으로 옮긴다. 사용자 행은 여전히 BAG 을 가리킨다. + jdbcTemplate.update("UPDATE cosmetic_item SET slot = 'BACK' WHERE item_key = ?", BAG_MINI_BACKPACK); + + assertThat(equippedOf(user.getId())) + .as("앞가방 자리에 등짐을 그리면 개구리 앞에 백팩이 떠 있게 된다") + .doesNotContainEntry(CosmeticSlot.BAG, BAG_MINI_BACKPACK); + } } @Nested @@ -241,7 +382,7 @@ class Equip { @Test @DisplayName("보유한 아이템을 걸면 갱신된 장착 상태 전체가 돌아온다") void equipsOwnedItem() { - User user = userAtLevel(15); + User user = fullyGrownUser(); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); @@ -249,10 +390,24 @@ void equipsOwnedItem() { assertThat(response.unequippedSlots()).isEmpty(); } + @Test + @DisplayName("등짐과 앞가방은 서로 다른 자리라 같이 걸린다") + void backAndBagCoexist() { + User user = fullyGrownUser(); + + cosmeticService.equip(user.getId(), CosmeticSlot.BACK, BACK_BACKPACK_NAVY); + CosmeticEquipResponseDto response = + cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BAG_MINI_BACKPACK); + + assertThat(response.equipped()) + .containsEntry(CosmeticSlot.BACK, BACK_BACKPACK_NAVY) + .containsEntry(CosmeticSlot.BAG, BAG_MINI_BACKPACK); + } + @Test @DisplayName("같은 슬롯을 다시 걸어도 행은 하나다") void equipIsIdempotentPerSlot() { - User user = userAtLevel(15); + User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_CROWN); @@ -268,19 +423,19 @@ void equipIsIdempotentPerSlot() { @Test @DisplayName("itemKey 가 null 이면 그 슬롯만 벗는다") void nullItemKeyUnequipsSlot() { - User user = userAtLevel(15); + User user = fullyGrownUser(); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, null); assertThat(response.equipped()) .doesNotContainKey(CosmeticSlot.HEAD) - .containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT); + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE); } @Test @DisplayName("해제는 행 삭제가 아니라 표시로 남는다") void unequipLeavesMarker() { - User user = userAtLevel(15); + User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, null); @@ -290,13 +445,15 @@ void unequipLeavesMarker() { } @Test - @DisplayName("보유하지 않은 아이템은 걸 수 없다") + @DisplayName("그 능력치 레벨이 모자라면 걸 수 없다") void rejectsUnownedItem() { - User user = userAtLevel(5); + // 총 학습은 20 이지만 문제 복습이 3 이라 비니(문제 복습 4)는 아직 잠겨 있다. + User user = setLevels(fixtures.createUser(), 20L, 15L, 15L, 3L, 15L); assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE)) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .as("총 학습 레벨이 높다고 능력치 아이템이 열리면 안 된다") .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); assertThat(loadoutRowCount(user.getId())) .as("거절된 요청이 프리셋을 굳혀 놓고 가면 안 된다") @@ -306,34 +463,40 @@ void rejectsUnownedItem() { @Test @DisplayName("없는 아이템과 비활성 아이템은 같은 에러로 답한다") void hidesInactiveItems() { - User user = userAtLevel(15); + User user = fullyGrownUser(); + deactivate(HAT_BUCKET); assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, "not_exists")) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); - assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, INACTIVE_HAT_BERET)) + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET)) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) - .as("비활성이라고 알려 주면 키를 훑어 2차 콘텐츠 목록을 알아낼 수 있다") + .as("비활성이라고 알려 주면 키를 훑어 공개 전 콘텐츠 목록을 알아낼 수 있다") .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); } @Test @DisplayName("슬롯과 아이템이 어긋나면 조용히 고쳐 주지 않고 거절한다") void rejectsSlotMismatch() { - User user = userAtLevel(15); + User user = fullyGrownUser(); assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, HAT_BEANIE)) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.BACK, BAG_MINI_BACKPACK)) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .as("앞가방을 등짐 자리에 걸면 레이어가 뒤집힌다") + .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); } @Test @DisplayName("개구리 본체 슬롯에는 아무것도 걸 수 없다") void rejectsBaseSlot() { - User user = userAtLevel(15); + User user = fullyGrownUser(); assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.BASE, "BASE")) .isInstanceOf(ApplicationException.class) @@ -344,8 +507,8 @@ void rejectsBaseSlot() { @Test @DisplayName("남의 장착 상태는 건드리지 않는다") void doesNotTouchOtherUsers() { - User user = userAtLevel(15); - User other = userAtLevel(15); + User user = fullyGrownUser(); + User other = fullyGrownUser(); cosmeticService.equip(other.getId(), CosmeticSlot.HEAD, HAT_BEANIE); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BUCKET); @@ -361,7 +524,7 @@ class ConflictResolution { @Test @DisplayName("충돌하는 다른 슬롯은 벗겨지고 벗긴 슬롯이 응답에 담긴다") void unequipsConflictingSlot() { - User user = userAtLevel(15); + User user = fullyGrownUser(); setConflicts(HAT_BEANIE, OUTFIT_CARDIGAN); cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); @@ -376,7 +539,7 @@ void unequipsConflictingSlot() { @Test @DisplayName("충돌은 반대쪽에만 적혀 있어도 잡힌다") void conflictIsBidirectional() { - User user = userAtLevel(15); + User user = fullyGrownUser(); // 옷 쪽에만 적는다. 모자를 나중에 거는 순서에서도 걸려야 한다. setConflicts(OUTFIT_CARDIGAN, HAT_BEANIE); cosmeticService.equip(user.getId(), CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); @@ -389,8 +552,8 @@ void conflictIsBidirectional() { @Test @DisplayName("여러 개가 걸리면 전부 벗긴다") void unequipsEveryConflict() { - User user = userAtLevel(15); - setConflicts(HAT_BEANIE, OUTFIT_GRADUATE + "," + SCARF); + User user = fullyGrownUser(); + setConflicts(HAT_BEANIE, OUTFIT_GRADUATE + "," + NECK_MEDAL); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); @@ -401,7 +564,7 @@ void unequipsEveryConflict() { @Test @DisplayName("충돌 목록에 빈 조각이 섞여 있어도 엉뚱한 슬롯을 벗기지 않는다") void ignoresBlankConflictEntries() { - User user = userAtLevel(15); + User user = fullyGrownUser(); setConflicts(HAT_BEANIE, ",, ,"); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); @@ -412,12 +575,12 @@ void ignoresBlankConflictEntries() { @Test @DisplayName("충돌이 없으면 아무것도 벗기지 않는다") void noConflictKeepsEverything() { - User user = userAtLevel(15); + User user = fullyGrownUser(); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); assertThat(response.unequippedSlots()).isEmpty(); - assertThat(response.equipped()).hasSize(7); + assertThat(response.equipped()).hasSize(10); } } @@ -428,7 +591,7 @@ class EquipSet { @Test @DisplayName("세트에 속한 아이템이 각자의 슬롯에 한 번에 걸린다") void equipsWholeSet() { - User user = userAtLevel(15); + User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); @@ -442,7 +605,7 @@ void equipsWholeSet() { @Test @DisplayName("하나라도 미보유면 통째로 거절한다") void rejectsPartiallyOwnedSet() { - User user = userAtLevel(14); + User user = setLevels(fixtures.createUser(), 19L, 15L, 15L, 15L, 15L); assertThatThrownBy(() -> cosmeticService.equipSet(user.getId(), GRADUATE_SET)) .isInstanceOf(ApplicationException.class) @@ -456,7 +619,7 @@ void rejectsPartiallyOwnedSet() { @Test @DisplayName("없는 세트는 거절한다") void rejectsUnknownSet() { - User user = userAtLevel(15); + User user = fullyGrownUser(); assertThatThrownBy(() -> cosmeticService.equipSet(user.getId(), "not_exists")) .isInstanceOf(ApplicationException.class) @@ -467,18 +630,18 @@ void rejectsUnknownSet() { @Test @DisplayName("세트가 채우지 않는 슬롯은 그대로 남는다") void keepsUntouchedSlots() { - User user = userAtLevel(15); + User user = fullyGrownUser(); CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); - assertThat(response.equipped()).containsEntry(CosmeticSlot.BACKGROUND, BG_NIGHT); + assertThat(response.equipped()).containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE); } @Test @DisplayName("세트와 충돌하는 다른 슬롯은 벗긴다") void unequipsConflictingSlot() { - User user = userAtLevel(15); - setConflicts(HAT_GRADUATE, SCARF); + User user = fullyGrownUser(); + setConflicts(HAT_GRADUATE, NECK_MEDAL); CosmeticEquipResponseDto response = cosmeticService.equipSet(user.getId(), GRADUATE_SET); @@ -492,24 +655,55 @@ void unequipsConflictingSlot() { class UnlockLookup { @Test - @DisplayName("올라간 구간에서 열린 것만 담긴다") - void unlocksWithinRange() { - List unlocked = cosmeticService.findUnlockedBetween(5, 7); + @DisplayName("총 학습 구간에서 열린 것만 담긴다") + void unlocksWithinTotalRange() { + List unlocked = cosmeticService.findUnlockedBetween(1, 3); assertThat(unlocked) .extracting(UnlockedCosmeticDto::itemKey) .as("구간은 (levelBefore, levelAfter] 다") - .containsExactly(HAT_BEANIE, BG_STUDY); + .containsExactly(HEADBAND_SPROUT, BADGE_LEAF_STAR); } @Test - @DisplayName("여러 단계를 한 번에 올라도 전부 담긴다") - void unlocksAcrossManyLevels() { - assertThat(cosmeticService.findUnlockedBetween(1, 15)) + @DisplayName("총 학습 구간에 능력치 아이템이 끼어들지 않는다") + void totalRangeExcludesAbilityItems() { + assertThat(cosmeticService.findUnlockedBetween(1, 20)) .extracting(UnlockedCosmeticDto::itemKey) - .hasSize(16) - .startsWith(HEADBAND_SPROUT) - .contains(HAT_GRADUATE, OUTFIT_GRADUATE, PROP_DIPLOMA); + .as("오르지도 않은 능력치의 아이템이 '방금 열렸다' 고 나가면 안 된다") + .doesNotContain(GLASSES_ROUND, BG_SPRING, SCARF, BAG_MINI_BACKPACK) + .hasSize(14); + } + + @Test + @DisplayName("능력치 레벨이 오른 구간에서 열린 것이 담긴다") + void unlocksWithinAbilityRange() { + List unlocked = cosmeticService.findUnlockedBetween( + 1, 1, AbilityType.PROBLEM_PRACTICE, 1, 4); + + assertThat(unlocked) + .extracting(UnlockedCosmeticDto::itemKey) + .containsExactly(GLASSES_ROUND, HAT_BEANIE); + } + + @Test + @DisplayName("총 학습과 능력치가 같이 오르면 둘 다 담기고 레벨 순으로 정렬된다") + void mergesTotalAndAbilityUnlocks() { + List unlocked = cosmeticService.findUnlockedBetween( + 1, 2, AbilityType.ATTENDANCE, 1, 3); + + assertThat(unlocked) + .extracting(UnlockedCosmeticDto::itemKey) + .containsExactly(BG_SPRING, HEADBAND_SPROUT, EFFECT_PETALS); + } + + @Test + @DisplayName("능력치를 넘겨도 그 능력치 것만 나온다") + void abilityRangeIsScopedToThatAbility() { + assertThat(cosmeticService.findUnlockedBetween(1, 1, AbilityType.NOTE_WRITE, 1, 3)) + .extracting(UnlockedCosmeticDto::itemKey) + .as("작성 2·3 은 미니 백팩과 공책이다. 같은 레벨의 출석·복습 아이템은 끼면 안 된다") + .containsExactly(BAG_MINI_BACKPACK, PROP_NOTEBOOK); } @Test @@ -517,22 +711,32 @@ void unlocksAcrossManyLevels() { void emptyWhenLevelUnchanged() { assertThat(cosmeticService.findUnlockedBetween(6, 6)).isEmpty(); assertThat(cosmeticService.findUnlockedBetween(7, 6)).isEmpty(); + assertThat(cosmeticService.findUnlockedBetween(6, 6, AbilityType.ATTENDANCE, 6, 6)).isEmpty(); + } + + @Test + @DisplayName("능력치가 없는 보상이면 총 학습 구간만 본다") + void nullAbilityLooksAtTotalOnly() { + assertThat(cosmeticService.findUnlockedBetween(1, 2, null, 0, 0)) + .extracting(UnlockedCosmeticDto::itemKey) + .containsExactly(HEADBAND_SPROUT); } @Test @DisplayName("비활성 아이템은 해금 알림에 끼지 않는다") void skipsInactiveItems() { - jdbcTemplate.update("UPDATE cosmetic_item SET active = 0 WHERE item_key = ?", HAT_BEANIE); + deactivate(HAT_BEANIE); - assertThat(cosmeticService.findUnlockedBetween(5, 7)) + assertThat(cosmeticService.findUnlockedBetween(1, 1, AbilityType.PROBLEM_PRACTICE, 1, 4)) .extracting(UnlockedCosmeticDto::itemKey) - .containsExactly(BG_STUDY); + .containsExactly(GLASSES_ROUND); } @Test @DisplayName("해금 항목에는 이름과 이미지가 함께 실린다") void carriesDisplayFields() { - UnlockedCosmeticDto unlocked = cosmeticService.findUnlockedBetween(5, 6).get(0); + UnlockedCosmeticDto unlocked = cosmeticService + .findUnlockedBetween(1, 1, AbilityType.PROBLEM_PRACTICE, 3, 4).get(0); assertThat(unlocked.itemKey()).isEqualTo(HAT_BEANIE); assertThat(unlocked.nameKo()).isEqualTo("비니"); diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java index 3fc66d5c..68fd8b37 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java @@ -31,9 +31,14 @@ @Component public class CosmeticItemSeeder { - /** 카탈로그를 손대는 마이그레이션을 새로 만들면 적용 순서대로 여기에 더한다. */ + /** + * 카탈로그를 손대는 마이그레이션을 새로 만들면 적용 순서대로 여기에 더한다. + * + *

순서가 곧 Flyway 적용 순서다. V37 이 V35 의 행을 덮어쓰므로 뒤집으면 결과가 달라진다. + */ private static final List MIGRATION_PATHS = List.of( - "db/migration/V35__seed_cosmetic_items.sql" + "db/migration/V35__seed_cosmetic_items.sql", + "db/migration/V37__seed_cosmetic_items_by_ability.sql" ); @PersistenceContext diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java index b1a8ae79..bc606ff0 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.cosmetic.repository.CosmeticItemRepository; import com.aisip.OnO.backend.cosmetic.repository.UserCosmeticLoadoutRepository; import com.aisip.OnO.backend.cosmetic.service.CosmeticService; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; import com.aisip.OnO.backend.support.IntegrationTestSupport; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; @@ -18,28 +19,74 @@ * 꾸미기 도메인 테스트의 공통 베이스. * *

{@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + * + *

해금 기준이 능력치별로 갈리면서 "레벨 N 인 사용자" 라는 말이 더 이상 한 가지 뜻이 아니다. + * 총 학습 레벨만 올린 사용자({@link #userAtLevel})와 능력치까지 함께 올린 사용자 + * ({@link #userAtAllLevels})를 구분해서 만든다. */ public abstract class CosmeticTestSupport extends IntegrationTestSupport { - protected static final String HEADBAND_SPROUT = "headband_sprout"; - protected static final String BG_SPRING = "bg_spring"; + // 능력치별로 열리는 것들 — 괄호 안은 (능력치, 레벨) + /** (PROBLEM_PRACTICE, 2) */ protected static final String GLASSES_ROUND = "glasses_round"; - protected static final String SCARF = "scarf"; + /** (PROBLEM_PRACTICE, 4) */ protected static final String HAT_BEANIE = "hat_beanie"; - protected static final String BG_STUDY = "bg_study"; + /** (PROBLEM_PRACTICE, 8) */ + protected static final String HAT_BUCKET = "hat_bucket"; + /** (PROBLEM_PRACTICE, 15) — 문제 복습의 마지막 아이템 */ + protected static final String HEAD_EARMUFFS_WINTER = "head_earmuffs_winter"; + /** (ATTENDANCE, 2) */ + protected static final String BG_SPRING = "bg_spring"; + /** (ATTENDANCE, 3) — 전경 효과 */ + protected static final String EFFECT_PETALS = "effect_petals"; + /** (ATTENDANCE, 13) — 전경 효과 */ + protected static final String EFFECT_SNOW = "effect_snow"; + /** (ATTENDANCE, 15) */ + protected static final String BG_SPACE = "bg_space"; + /** (NOTE_WRITE, 2) — 앞으로 메는 가방이라 BAG 이다 */ protected static final String BAG_MINI_BACKPACK = "bag_mini_backpack"; + /** (NOTE_WRITE, 5) — 등에 메는 가방이라 BACK 이다 */ + protected static final String BACK_BACKPACK_NAVY = "back_backpack_navy"; + /** (NOTE_WRITE, 9) */ + protected static final String BACK_BACKPACK_CANVAS = "back_backpack_canvas"; + /** (NOTE_WRITE, 11) */ + protected static final String BAG_CROSSBODY_SATCHEL = "bag_crossbody_satchel"; + /** (NOTE_WRITE, 12) */ + protected static final String BG_STUDY = "bg_study"; + /** (NOTE_PRACTICE, 2) */ + protected static final String SCARF = "scarf"; + /** (NOTE_PRACTICE, 5) — 전신 의상 */ protected static final String OUTFIT_CARDIGAN = "outfit_cardigan"; - protected static final String PROP_STUDY = "prop_study"; - protected static final String GLASSES_SUN = "glasses_sun"; - protected static final String HAT_BUCKET = "hat_bucket"; - protected static final String BG_NIGHT = "bg_night"; + /** (NOTE_PRACTICE, 12) */ + protected static final String NECK_MEDAL = "neck_medal"; + /** (NOTE_PRACTICE, 13) — 전신 의상 */ + protected static final String OUTFIT_SCHOOL = "outfit_school"; + + // 총 학습 레벨로 열리는 것들 — 괄호 안은 총 학습 레벨 + /** (총 2) */ + protected static final String HEADBAND_SPROUT = "headband_sprout"; + /** (총 3) */ + protected static final String BADGE_LEAF_STAR = "badge_leaf_star"; + /** (총 8) */ + protected static final String PROP_BOUQUET = "prop_bouquet"; + /** (총 16) — 상한이 20 으로 오르면서 생긴 자리 */ + protected static final String PROP_LANTERN = "prop_lantern"; + /** (총 18) */ + protected static final String BADGE_SNOWFLAKE = "badge_snowflake"; + /** (총 19) */ protected static final String HAT_CROWN = "hat_crown"; + /** (총 20) — 학사 세트 */ protected static final String HAT_GRADUATE = "hat_graduate"; + /** (총 20) — 학사 세트, 전신 의상 */ protected static final String OUTFIT_GRADUATE = "outfit_graduate"; + /** (총 20) — 학사 세트 */ protected static final String PROP_DIPLOMA = "prop_diploma"; + protected static final String GRADUATE_SET = "graduate"; - /** 2차 콘텐츠라 active = 0 으로 시드된 아이템. 비활성 취급을 확인할 때 쓴다. */ - protected static final String INACTIVE_HAT_BERET = "hat_beret"; + protected static final String GRADUATE_SET_NAME = "학사 세트"; + + /** 전체 시드 개수. 본체(BASE) 는 포함하지 않는다. */ + protected static final int SEEDED_ITEM_COUNT = 55; @Autowired private CosmeticItemSeeder cosmeticItemSeeder; @@ -69,21 +116,47 @@ void seedCosmeticItems() { } /** - * 사용자의 총 학습 레벨을 원하는 값으로 맞춘다. + * 사용자의 레벨을 원하는 값으로 맞춘다. * - *

경험치를 쌓아 올리면 레벨 15 를 만드는 데 수천 점이 필요하고, 그 과정에서 미션 적립까지 + *

경험치를 쌓아 올리면 레벨 20 을 만드는 데 수천 점이 필요하고, 그 과정에서 미션 적립까지 * 끌려 들어와 무엇을 재는 테스트인지 흐려진다. 관리자 경로가 쓰는 것과 같은 setter 를 쓴다. */ - protected User setLevel(User user, long level) { + protected User setLevels(User user, long totalStudy, + long attendance, long noteWrite, long problemPractice, long notePractice) { return transactionTemplate.execute(status -> { User managed = userRepository.findById(user.getId()).orElseThrow(); - managed.getUserMissionStatus().setTotalStudyLevel(level, 0L); + UserMissionStatus missionStatus = managed.getUserMissionStatus(); + missionStatus.setTotalStudyLevel(totalStudy, 0L); + missionStatus.setAttendanceLevel(attendance, 0L); + missionStatus.setNoteWriteLevel(noteWrite, 0L); + missionStatus.setProblemPracticeLevel(problemPractice, 0L); + missionStatus.setNotePracticeLevel(notePractice, 0L); return userRepository.save(managed); }); } - protected User userAtLevel(long level) { - return setLevel(fixtures.createUser(), level); + /** 총 학습 레벨만 올린다. 능력치 넷은 1 이라 능력치별 아이템은 하나도 열리지 않는다. */ + protected User setLevel(User user, long totalStudyLevel) { + return setLevels(user, totalStudyLevel, 1L, 1L, 1L, 1L); + } + + /** 총 학습 레벨만 올린 사용자. */ + protected User userAtLevel(long totalStudyLevel) { + return setLevel(fixtures.createUser(), totalStudyLevel); + } + + /** 다섯 레벨을 모두 같은 값으로 올린 사용자. */ + protected User userAtAllLevels(long level) { + return setLevels(fixtures.createUser(), level, level, level, level, level); + } + + /** + * 다 자란 사용자. 총 학습 20, 능력치 넷 모두 15 라 시드된 55 개가 전부 열려 있다. + * + *

능력치별 아이템의 마지막 해금 레벨이 15, 총 학습 아이템의 마지막이 20 이다. + */ + protected User fullyGrownUser() { + return setLevels(fixtures.createUser(), 20L, 15L, 15L, 15L, 15L); } /** 지금 걸려 있는 것. 서비스 조회 응답에서 꺼낸다. */ @@ -97,6 +170,16 @@ protected void setConflicts(String itemKey, String conflictsWith) { conflictsWith, itemKey); } + /** + * 아이템을 비활성으로 내린다. + * + *

시드된 55 개는 전부 활성이다. 비활성 취급을 확인하려면 이렇게 직접 내려야 한다. + * (V35 시절에는 2차 콘텐츠 19 개가 active = 0 이라 그중 하나를 골라 쓰면 됐다.) + */ + protected void deactivate(String itemKey) { + jdbcTemplate.update("UPDATE cosmetic_item SET active = 0 WHERE item_key = ?", itemKey); + } + /** 장착 행 개수. 프리셋이 행을 만들지 않는지, 첫 변경 때 굳어지는지 확인할 때 쓴다. */ protected long loadoutRowCount(Long userId) { Long count = jdbcTemplate.queryForObject( diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java index 309c0007..e2f9a594 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java @@ -21,6 +21,9 @@ *

해금 자체는 계산이라 알림이 없어도 다음 조회에서 드러나지만, 사용자가 "레벨이 올랐는데 * 뭐가 좋아졌는지" 를 그 순간에 모르면 레벨업이 아무 일도 아닌 것이 된다. * + *

해금 기준이 능력치별로 갈리면서 여기가 특히 중요해졌다. 시드된 55 개 중 41 개가 + * 능력치로 열린다. 총 학습 레벨만 보면 보상을 받아도 알림이 거의 늘 비어 있게 된다. + * *

{@code MissionRewardGranter} 의 트랜잭션 경계와 잠금 순서(사용자 → 진행도)는 건드리지 않았다. * 해금 조회는 지급이 끝난 뒤에 도는 읽기 전용 조회다. */ @@ -43,9 +46,9 @@ void setUpUserAndCatalog() { } @Test - @DisplayName("레벨이 안 올랐으면 빈 배열이다") + @DisplayName("아무 레벨도 안 올랐으면 빈 배열이다") void noUnlockWithoutLevelUp() { - // 오늘 기분 미션은 5점이라 레벨 1→2 에 필요한 40점에 못 미친다. + // 오늘 기분 미션은 5점이라 출석 레벨 1→2 에 필요한 10점에도 못 미친다. MissionProgress progress = completeMission(user.getId(), DAILY_MOOD); MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); @@ -58,9 +61,27 @@ void noUnlockWithoutLevelUp() { } @Test - @DisplayName("레벨이 오르면 그 구간에서 열린 아이템이 실린다") - void unlocksOnLevelUp() { - // 레벨 1→2 에 40점이 필요하다. 주간 출석(100점) 하나면 넘는다. + @DisplayName("총 학습 레벨이 그대로여도 능력치가 오르면 해금이 잡힌다") + void unlocksOnAbilityLevelUpAlone() { + // 출석 미션 10점. 출석 레벨은 1→2 로 오르지만 총 학습은 40점이 필요해 1 그대로다. + MissionProgress progress = completeMission(user.getId(), DAILY_ATTEND); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.leveledUp()) + .as("총 학습 레벨은 안 올랐다") + .isFalse(); + assertThat(response.totalStudyLevel()).isEqualTo(1L); + assertThat(response.unlockedCosmetics()) + .extracting(UnlockedCosmeticDto::itemKey) + .as("출석 2 에 열리는 봄 배경이 여기서 빠지면 능력치 해금이 사용자에게 보이지 않는다") + .containsExactly("bg_spring"); + } + + @Test + @DisplayName("능력치와 총 학습이 같이 오르면 둘 다 실린다") + void unlocksFromBothRanges() { + // 주간 출석 100점. 출석 1→5, 총 학습 1→2 가 된다. MissionProgress progress = completeMission(user.getId(), WEEKLY_ATTEND_5); MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); @@ -69,16 +90,34 @@ void unlocksOnLevelUp() { assertThat(response.totalStudyLevel()).isEqualTo(2L); assertThat(response.unlockedCosmetics()) .extracting(UnlockedCosmeticDto::itemKey) - .as("레벨 2 에 열리는 것은 새싹 머리띠다") - .containsExactly("headband_sprout"); - assertThat(response.unlockedCosmetics().get(0).slot()).isEqualTo(CosmeticSlot.HEAD); - assertThat(response.unlockedCosmetics().get(0).nameKo()).isEqualTo("새싹 머리띠"); + .as("출석 2·3·4·5 네 개와 총 학습 2 하나. 레벨 순, 같은 레벨이면 키 순이다") + .containsExactly("bg_spring", "headband_sprout", "effect_petals", "bg_summer", "effect_sparkle"); + assertThat(response.unlockedCosmetics().get(0).slot()).isEqualTo(CosmeticSlot.BACKGROUND); + assertThat(response.unlockedCosmetics().get(0).nameKo()).isEqualTo("봄 배경"); + } + + @Test + @DisplayName("XP 가 들어간 능력치의 것만 실린다") + void onlyTheRewardedAbilityUnlocks() { + // 주간 오답노트 80점. 작성 1→4, 총 학습 1→2 가 된다. + MissionProgress progress = completeMission(user.getId(), WEEKLY_NOTE_10); + + MissionClaimResponseDto response = missionService.claim(user.getId(), progress.getId()); + + assertThat(response.unlockedCosmetics()) + .extracting(UnlockedCosmeticDto::itemKey) + .as("작성 2·3 과 총 학습 2 뿐이다") + .containsExactly("bag_mini_backpack", "headband_sprout", "prop_notebook"); + assertThat(response.unlockedCosmetics()) + .extracting(UnlockedCosmeticDto::itemKey) + .as("출석 2 짜리 봄 배경은 출석 레벨이 오르지 않았으니 열리지 않는다") + .doesNotContain("bg_spring", "glasses_round", "scarf"); } @Test @DisplayName("한 번에 여러 단계 오르면 그 사이 것이 전부 실린다") void unlocksEveryLevelInRange() { - // 레벨 1→2 는 40, 2→3 은 80 이다. 주간 4종(100+80+100+80=360)을 몰아 받으면 여러 단계가 오른다. + // 주간 4종(출석100 + 작성80 + 복습100 + 세트80 = 360)을 몰아 받는다. for (String code : new String[]{WEEKLY_ATTEND_5, WEEKLY_NOTE_10, WEEKLY_REVIEW_30, WEEKLY_SET_3}) { completeMission(user.getId(), code); } @@ -90,10 +129,10 @@ void unlocksEveryLevelInRange() { assertThat(last).isNotNull(); assertThat(last.totalStudyLevel()).isGreaterThan(2L); - // 마지막 수령까지의 해금을 다 합치면 현재 레벨까지 열린 것이 빠짐없이 한 번씩 나왔어야 한다. + // 마지막 수령까지의 해금을 다 합치면 현재 총 학습 레벨까지 열린 것이 빠짐없이 나왔어야 한다. assertThat(cosmeticService.findUnlockedBetween(1, last.totalStudyLevel())) .extracting(UnlockedCosmeticDto::itemKey) - .contains("headband_sprout", "bg_spring"); + .contains("headband_sprout", "badge_leaf_star"); } @Test From 9fe652061e7ee584550e358ffc23e2028d7f13a4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 13:20:06 +0900 Subject: [PATCH 082/130] =?UTF-8?q?[Feat]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=8B=9C=EB=93=9C=EC=97=90=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=ED=94=84=EB=A0=88=EC=9E=84=208=EC=A2=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V37 을 고치지 않고 새 파일로 얹는다. 컬럼 추가는 없고 시드만 늘어난다. 기존 55 줄은 해금표에서 한 줄도 안 바뀌어 덮어쓸 것이 없지만, ON DUPLICATE KEY UPDATE 절은 V37 과 같게 뒀다. 다시 돌 때 행이 카탈로그 값으로 수렴하는 성질이 두 파일에서 같아야 한다 - 해금 자리는 계절 배경과 짝을 맞췄다. 봄 배경(출석 2) 다음 레벨에 봄 프레임(출석 3) 하는 식이다. 출석 3 / 5 / 13 / 15 는 한 레벨에 두 개가 열리는데 자리가 서로 달라 프리셋이 흔들리지 않는다 - 에셋 경로가 혼자 다르다. 나머지는 assets/Cosmetic/{item_key}.png 인데 프레임만 assets/ProfileFrame/{item_key}.svg 다. 원형 테두리라 확대해도 깨지면 안 된다 - FRAME 은 개구리에 겹치지 않는다는 사정을 파일 머리에 남겼다 --- .../V38__seed_profile_frame_cosmetics.sql | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql diff --git a/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql b/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql new file mode 100644 index 00000000..546e1494 --- /dev/null +++ b/src/main/resources/db/migration/V38__seed_profile_frame_cosmetics.sql @@ -0,0 +1,52 @@ +-- 프로필 프레임 8종을 치장 카탈로그에 편입한다. 컬럼 추가는 없고 시드만 늘어난다. +-- +-- V37 을 직접 고치지 않는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 기존 55 줄은 해금표에서 한 줄도 바뀌지 않았으므로 여기서는 덮어쓸 것이 없다. +-- 그래도 V37 과 같은 ON DUPLICATE KEY UPDATE 절을 그대로 쓴다. 이 파일이 다시 돌 때 +-- 행이 카탈로그가 정한 값으로 수렴한다는 성질이 두 파일에서 같아야 하기 때문이다. +-- conflicts_with 와 created_at 은 V37 과 같은 이유로 손대지 않는다. +-- +-- **FRAME 은 개구리에 겹치지 않는다.** 원형 프로필 사진의 테두리라 개구리 합성에서는 빠지고 +-- 프로필 위젯이 따로 쓴다. layer_order 1000 은 옷장에서의 자리 순서를 정하려고 둔 값이지 +-- 개구리 위에 그린다는 뜻이 아니다. 그 구분은 응답의 slots[].composited = false 가 한다 +-- (CosmeticSlot.FRAME 주석 참고). 목록에서 빼지는 않는다. 옷장에는 보여 줘야 한다. +-- +-- **에셋 경로가 혼자 다르다.** 나머지 치장은 assets/Cosmetic/{item_key}.png 인데 +-- 프레임만 assets/ProfileFrame/{item_key}.svg 다. 원형 테두리라 확대해도 깨지면 안 돼서 SVG 다. +-- image_url 한 컬럼이 경로와 확장자를 다 들고 있어 이 차이가 코드로 새지 않는다. +-- S3 로 옮길 때도 이 컬럼만 바꾸면 되는 것은 같다. +-- +-- 해금 자리는 계절 배경과 짝을 맞췄다. 봄 배경(출석 2) 다음 레벨에 봄 프레임(출석 3) 하는 식이라 +-- 한 능력치 안에서 같은 레벨에 두 개가 열리는 자리가 생긴다(출석 3 / 5 / 13 / 15). +-- 슬롯이 서로 달라 기본 프리셋이 흔들리지는 않는다. + +INSERT INTO cosmetic_item + (item_key, slot, name_ko, image_url, required_level, required_ability, full_body, + set_id, set_name_ko, conflicts_with, active, created_at, updated_at) +VALUES + -- 출석 (ATTENDANCE). 계절 배경 바로 다음 레벨에 같은 계절 프레임을 둔다. + ('frame_spring', 'FRAME', '봄 프레임', 'assets/ProfileFrame/frame_spring.svg', 3, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_summer', 'FRAME', '여름 프레임', 'assets/ProfileFrame/frame_summer.svg', 5, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_autumn', 'FRAME', '가을 프레임', 'assets/ProfileFrame/frame_autumn.svg', 10, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_winter', 'FRAME', '겨울 프레임', 'assets/ProfileFrame/frame_winter.svg', 13, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_night', 'FRAME', '밤하늘 프레임', 'assets/ProfileFrame/frame_night.svg', 15, 'ATTENDANCE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 오답노트 작성 (NOTE_WRITE). 공부방 배경(작성 12) 뒤에 공부방 프레임을 둔다. + ('frame_study', 'FRAME', '공부방 프레임', 'assets/ProfileFrame/frame_study.svg', 14, 'NOTE_WRITE', 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + + -- 총 학습 레벨 (required_ability = NULL). 잎새는 잎새 별 뱃지 바로 뒤, + -- 마스터는 랜턴(16)과 눈송이 뱃지(18) 사이다. + ('frame_leaf', 'FRAME', '잎새 프레임', 'assets/ProfileFrame/frame_leaf.svg', 4, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)), + ('frame_master', 'FRAME', '마스터 프레임', 'assets/ProfileFrame/frame_master.svg', 17, NULL, 0, NULL, NULL, NULL, 1, NOW(6), NOW(6)) + +ON DUPLICATE KEY UPDATE + slot = VALUES(slot), + name_ko = VALUES(name_ko), + image_url = VALUES(image_url), + required_level = VALUES(required_level), + required_ability = VALUES(required_ability), + full_body = VALUES(full_body), + set_id = VALUES(set_id), + set_name_ko = VALUES(set_name_ko), + active = VALUES(active), + updated_at = NOW(6); From 519cc2c1184212f0a824b0dead8709f8890f7f56 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 13:20:19 +0900 Subject: [PATCH 083/130] =?UTF-8?q?[Feat]=20FRAME=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EA=B0=9C=EA=B5=AC=EB=A6=AC=EC=97=90?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CosmeticSlot 에 FRAME(1000, 프레임) 을 더했다. 응답의 slots 는 11 개가 된다 - 슬롯에 composited 를 더했다. equippable 과 다른 축이다. 걸 수는 있지만 개구리 그림에 겹치지 않는 자리가 생겼다. FRAME 만 false 다 - layerOrder 만 내려보내면 프론트는 순서대로 겹쳐 그리는 수밖에 없어 프레임이 개구리 위에 덮인다. 이 값이 없으면 프론트가 "FRAME 은 예외" 를 코드에 박아야 하는데, 그건 layerOrder 를 응답에 실어 보내기로 한 이유를 그대로 무르는 것이다. 뒤에 덧붙인 필드라 구버전 앱은 그대로 돈다 - 프레임은 옷장 목록에서 빼지 않는다. 개구리 합성에서만 빠진다 - 시드 63 개 / 프레임 8 종의 해금 자리 / FRAME 만 SVG 인 것 / 프레임 해금이 능력치별로 판정되는 것 / 합성에서 빠지는 자리가 FRAME 뿐인 것을 테스트로 고정했다 - 같은 레벨에 두 개가 열리는 것은 허용하되 "같은 조건에 같은 자리" 는 막는 쪽으로 단언을 바꿨다. 프레임을 계절 배경과 짝 맞추면서 출석 3 / 5 / 13 / 15 가 겹쳤고 그건 의도다 --- .../cosmetic/dto/CosmeticListResponseDto.java | 3 +- .../backend/cosmetic/dto/CosmeticSlotDto.java | 10 +- .../backend/cosmetic/entity/CosmeticSlot.java | 41 +++++-- .../CosmeticEquipConcurrencyTest.java | 2 +- .../controller/CosmeticControllerTest.java | 51 +++++++- .../migration/CosmeticMigrationTest.java | 115 +++++++++++++++--- .../cosmetic/service/CosmeticServiceTest.java | 105 ++++++++++++++-- .../cosmetic/support/CosmeticItemSeeder.java | 3 +- .../cosmetic/support/CosmeticTestSupport.java | 21 +++- .../service/MissionCosmeticUnlockTest.java | 11 +- 10 files changed, 310 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java index 41ee111e..2357d854 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java @@ -9,10 +9,11 @@ * {@code GET /api/cosmetics} 응답. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. * * @param baseImageUrl 개구리 본체 이미지. 슬롯 아이템이 아니라 항상 그려진다. - * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 가방(200)과 옷(400) 사이에 들어간다. + * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 등짐(200)과 옷(400) 사이에 들어간다. * 슬롯 목록에는 본체가 없으므로 이 값이 따로 있어야 프론트가 레이어 순서를 * 코드에 박지 않고 조립할 수 있다. * @param slots 장착 가능한 슬롯과 그리는 순서. 뒤에서 앞 순서로 정렬돼 온다. + * {@code composited: false} 인 자리(FRAME)는 개구리 합성에서 빼야 한다. * @param items 활성 아이템 전부. 잠긴 것도 {@code owned: false} 로 함께 내려간다. * 화면에 "레벨 6 에 열림" 을 보여주려면 잠긴 것도 알아야 한다. * @param equipped 슬롯 이름 → 아이템 키. 비어 있는 슬롯은 키 자체가 없다. diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java index ab8626ef..606b46ce 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticSlotDto.java @@ -7,14 +7,20 @@ * *

순서를 응답에 실어 보내는 이유는 프론트가 레이어 순서를 코드에 박지 않게 하기 위해서다. * 슬롯이 늘거나 순서가 바뀌어도 이미 깔린 앱이 새 순서를 따른다. + * + *

{@code composited} 는 그 자리를 개구리 그림에 겹쳐 그리는지다. {@code FRAME} 만 false 다. + * 원형 프로필 사진의 테두리라 옷장 목록에는 나가지만 개구리 합성에서는 빠진다. + * 이 값 없이 {@code layerOrder} 만 내려보내면 프론트는 순서대로 겹치는 수밖에 없어 + * 프레임이 개구리 위에 덮인다. 뒤에 덧붙인 필드라 구버전 앱은 그대로 돈다. */ public record CosmeticSlotDto( CosmeticSlot slot, int layerOrder, - String nameKo + String nameKo, + boolean composited ) { public static CosmeticSlotDto from(CosmeticSlot slot) { - return new CosmeticSlotDto(slot, slot.getLayerOrder(), slot.getNameKo()); + return new CosmeticSlotDto(slot, slot.getLayerOrder(), slot.getNameKo(), slot.isComposited()); } } diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java index 8c677334..9575298c 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -23,29 +23,48 @@ *

{@link #BASE} 는 장착 슬롯이 아니라 개구리 본체다. 본체 이미지도 {@code cosmetic_item} 행으로 * 두기 때문에 슬롯 이름이 필요해서 여기 있을 뿐이고, 장착 가능한 슬롯 목록과 아이템 목록에서는 빠진다. * 그래서 {@code equippable} 로 갈라 둔다. + * + *

{@code composited} 는 {@code equippable} 과 다른 축이다. 걸 수는 있지만 개구리 그림에는 + * 겹치지 않는 자리가 있다({@link #FRAME}). {@code layerOrder} 만 내려보내면 프론트는 순서대로 겹쳐 + * 그리는 수밖에 없어서, 프레임이 개구리 위에 덮인다. 어느 자리가 합성 대상인지도 데이터로 내려보낸다. + * 이 값이 없으면 프론트가 "FRAME 은 예외" 를 코드에 박아야 하는데, 그건 {@code layerOrder} 를 + * 응답에 실어 보내기로 한 이유를 그대로 무르는 것이다. */ @Getter @RequiredArgsConstructor public enum CosmeticSlot { - BACKGROUND(100, "배경", true), + BACKGROUND(100, "배경", true, true), /** 등에 메는 가방. 개구리 본체보다 뒤에 깔린다. */ - BACK(200, "등짐", true), + BACK(200, "등짐", true, true), /** 개구리 본체. 장착 대상이 아니다. */ - BASE(300, "개구리 본체", false), - OUTFIT(400, "옷", true), + BASE(300, "개구리 본체", false, true), + OUTFIT(400, "옷", true, true), /** 앞으로 메는 가방. 옷 위에 올라간다. */ - BAG(450, "가방", true), - NECK(500, "목", true), - FACE(600, "얼굴", true), - HEAD(700, "머리", true), - HAND(800, "손", true), - BADGE(850, "뱃지", true), - EFFECT(900, "효과", true); + BAG(450, "가방", true, true), + NECK(500, "목", true, true), + FACE(600, "얼굴", true, true), + HEAD(700, "머리", true, true), + HAND(800, "손", true, true), + BADGE(850, "뱃지", true, true), + EFFECT(900, "효과", true, true), + /** + * 원형 프로필 사진의 테두리. 개구리에 겹치지 않는다. + * + *

걸 수 있는 자리라 옷장 목록에는 나가지만 개구리 합성에서는 빠지고 프로필 위젯이 따로 쓴다. + * {@code layerOrder} 1000 은 옷장에서의 자리 순서를 정하려고 둔 값이지 개구리 위에 그린다는 뜻이 아니다. + * 그 구분은 {@code composited = false} 가 한다. + * + *

에셋도 혼자 다르다. 나머지는 {@code assets/Cosmetic/{item_key}.png} 인데 + * 프레임만 {@code assets/ProfileFrame/{item_key}.svg} 다. + */ + FRAME(1000, "프레임", true, false); private final int layerOrder; private final String nameKo; private final boolean equippable; + /** 개구리 그림에 겹쳐 그리는 자리인지. {@link #FRAME} 만 false 다. */ + private final boolean composited; /** 사용자가 실제로 걸 수 있는 슬롯만. 그리는 순서대로 준다. */ public static List equippableSlots() { diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java index a49fb9fd..c81d9a01 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java @@ -98,7 +98,7 @@ void concurrentEquipsOnDifferentSlotsBothSurvive() { .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND); assertThat(loadoutRowCount(user.getId())) .as("프리셋이 여러 번 굳어도 슬롯 수를 넘는 행이 생기면 안 된다") - .isEqualTo(10); + .isEqualTo(EQUIPPABLE_SLOT_COUNT); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java index 5cca9eaa..e119f2e4 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java @@ -34,10 +34,11 @@ void getCosmetics() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.data.baseImageUrl").value("assets/Cosmetic/BASE.png")) .andExpect(jsonPath("$.data.baseLayerOrder").value(300)) - .andExpect(jsonPath("$.data.slots.length()").value(10)) + .andExpect(jsonPath("$.data.slots.length()").value(11)) .andExpect(jsonPath("$.data.slots[0].slot").value("BACKGROUND")) .andExpect(jsonPath("$.data.slots[0].layerOrder").value(100)) .andExpect(jsonPath("$.data.slots[0].nameKo").value("배경")) + .andExpect(jsonPath("$.data.slots[0].composited").value(true)) .andExpect(jsonPath("$.data.items").isArray()) .andExpect(jsonPath("$.data.items.length()").value(SEEDED_ITEM_COUNT)) .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) @@ -61,6 +62,54 @@ void slotsIncludeBackAndEffect() throws Exception { .andExpect(jsonPath(effect + ".layerOrder").value(Matchers.contains(900))); } + @Test + @DisplayName("GET /api/cosmetics - 프레임 자리는 layerOrder 1000 이고 composited 가 false 다") + void frameSlotIsNotComposited() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + String frame = "$.data.slots[?(@.slot == 'FRAME')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(frame + ".layerOrder").value(Matchers.contains(1000))) + .andExpect(jsonPath(frame + ".nameKo").value(Matchers.contains("프레임"))) + .andExpect(jsonPath(frame + ".composited") + .value(Matchers.contains(false))); + } + + @Test + @DisplayName("GET /api/cosmetics - 프레임 아이템은 SVG 경로로 나간다") + void frameItemShape() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + String frame = "$.data.items[?(@.itemKey == '" + FRAME_SPRING + "')]"; + mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andExpect(jsonPath(frame + ".slot").value(Matchers.contains("FRAME"))) + .andExpect(jsonPath(frame + ".nameKo").value(Matchers.contains("봄 프레임"))) + .andExpect(jsonPath(frame + ".imageUrl") + .value(Matchers.contains("assets/ProfileFrame/frame_spring.svg"))) + .andExpect(jsonPath(frame + ".requiredLevel").value(Matchers.contains(3))) + .andExpect(jsonPath(frame + ".requiredAbility").value(Matchers.contains("ATTENDANCE"))) + .andExpect(jsonPath(frame + ".fullBody").value(Matchers.contains(false))) + .andExpect(jsonPath(frame + ".owned").value(Matchers.contains(true))); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip - 프레임도 다른 자리처럼 걸린다") + void equipFrame() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip") + .contentType(MediaType.APPLICATION_JSON) + .content(body("FRAME", FRAME_SPRING))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.FRAME").value(FRAME_SPRING)) + .andExpect(jsonPath("$.data.equipped.BACKGROUND").value(BG_SPACE)); + } + @Test @DisplayName("GET /api/cosmetics - 아이템 한 건의 필드가 계약대로다") void itemShape() throws Exception { diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java index f29475ca..e7aa70ca 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/migration/CosmeticMigrationTest.java @@ -45,6 +45,7 @@ class CosmeticMigrationTest extends CosmeticTestSupport { private static final String SEED_MIGRATION = "db/migration/V35__seed_cosmetic_items.sql"; private static final String ABILITY_DDL_MIGRATION = "db/migration/V36__add_cosmetic_ability_unlock_columns.sql"; private static final String ABILITY_SEED_MIGRATION = "db/migration/V37__seed_cosmetic_items_by_ability.sql"; + private static final String FRAME_SEED_MIGRATION = "db/migration/V38__seed_profile_frame_cosmetics.sql"; @Autowired private CosmeticItemSeeder seeder; @@ -131,6 +132,16 @@ void loadoutHasCompositePrimaryKey() { void seedMigrationIsRerunnable() { assertThat(statementsOf(SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); assertThat(statementsOf(ABILITY_SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + assertThat(statementsOf(FRAME_SEED_MIGRATION)).contains("ON DUPLICATE KEY UPDATE"); + } + + @Test + @DisplayName("V38 은 컬럼을 건드리지 않는다 - 시드만 늘어난다") + void frameSeedHasNoDdl() { + assertThat(statementsOf(FRAME_SEED_MIGRATION)) + .as("V36 이 이미 적용된 DB 에 얹히는 파일이라 추가할 컬럼이 없다") + .doesNotContain("ALTER TABLE") + .doesNotContain("CREATE TABLE"); } @Test @@ -178,14 +189,42 @@ void rerunningDdlDoesNotFail() { } @Test - @DisplayName("시드는 본체 말고 55개다") - void seedsFiftyFiveItems() { + @DisplayName("시드는 본체 말고 63개다") + void seedsEverythingInTheTable() { assertThat(cosmeticItemRepository.findAll()) .filteredOn(item -> !"BASE".equals(item.getItemKey())) - .as("해금표의 12 + 9 + 11 + 9 + 14 다") + .as("해금표의 17 + 10 + 11 + 9 + 16 이다") .hasSize(SEEDED_ITEM_COUNT); } + @Test + @DisplayName("프로필 프레임 8종이 전부 들어갔다") + void everyFrameIsSeeded() { + assertThat(keysOfSlot(CosmeticSlot.FRAME)) + .containsExactly("frame_autumn", "frame_leaf", "frame_master", "frame_night", + "frame_spring", "frame_study", "frame_summer", "frame_winter"); + } + + @Test + @DisplayName("프레임 해금 자리가 표와 맞는다") + void frameUnlockPositionsMatchTable() { + Map unlocks = catalog().stream() + .filter(item -> item.getSlot() == CosmeticSlot.FRAME) + .collect(Collectors.toMap(CosmeticItem::getItemKey, + item -> (item.getRequiredAbility() == null ? "-" : item.getRequiredAbility().name()) + + " " + item.getRequiredLevel())); + + assertThat(unlocks).containsOnly( + Map.entry("frame_spring", "ATTENDANCE 3"), + Map.entry("frame_summer", "ATTENDANCE 5"), + Map.entry("frame_autumn", "ATTENDANCE 10"), + Map.entry("frame_winter", "ATTENDANCE 13"), + Map.entry("frame_night", "ATTENDANCE 15"), + Map.entry("frame_study", "NOTE_WRITE 14"), + Map.entry("frame_leaf", "- 4"), + Map.entry("frame_master", "- 17")); + } + @Test @DisplayName("능력치별 개수가 해금표와 맞는다") void abilityDistributionMatchesTable() { @@ -194,14 +233,14 @@ void abilityDistributionMatchesTable() { .collect(Collectors.groupingBy(CosmeticItem::getRequiredAbility, Collectors.counting())); assertThat(byAbility).containsOnly( - Map.entry(AbilityType.ATTENDANCE, 12L), - Map.entry(AbilityType.NOTE_WRITE, 9L), + Map.entry(AbilityType.ATTENDANCE, 17L), + Map.entry(AbilityType.NOTE_WRITE, 10L), Map.entry(AbilityType.PROBLEM_PRACTICE, 11L), Map.entry(AbilityType.NOTE_PRACTICE, 9L)); assertThat(catalog()).filteredOn(item -> item.getRequiredAbility() == null) .as("나머지는 총 학습 레벨로 열린다") - .hasSize(14); + .hasSize(16); } @Test @@ -220,12 +259,22 @@ void slotDistributionMatchesTable() { Map.entry(CosmeticSlot.HEAD, 8L), Map.entry(CosmeticSlot.HAND, 7L), Map.entry(CosmeticSlot.BADGE, 6L), - Map.entry(CosmeticSlot.EFFECT, 4L)); + Map.entry(CosmeticSlot.EFFECT, 4L), + Map.entry(CosmeticSlot.FRAME, 8L)); assertThat(CosmeticSlot.equippableSlots()) .extracting(CosmeticSlot::getLayerOrder) - .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") - .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900); + .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위, 프레임(1000)이 맨 끝이다") + .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900, 1000); + } + + @Test + @DisplayName("프레임만 개구리 합성에서 빠진다") + void onlyFrameIsNotComposited() { + assertThat(CosmeticSlot.values()) + .filteredOn(slot -> !slot.isComposited()) + .as("합성에서 빠지는 자리가 늘면 프론트의 그리기 코드가 통째로 흔들린다") + .containsExactly(CosmeticSlot.FRAME); } @Test @@ -259,16 +308,18 @@ void totalUnlockLevelsStayUnderCap() { } @Test - @DisplayName("한 능력치 안에서 같은 레벨에 두 개가 열리지 않는다") - void noDuplicateLevelWithinAbility() { - Map> levels = catalog().stream() - .filter(item -> item.getRequiredAbility() != null) - .collect(Collectors.groupingBy(CosmeticItem::getRequiredAbility, - Collectors.mapping(CosmeticItem::getRequiredLevel, Collectors.toList()))); + @DisplayName("같은 해금 조건에 같은 자리가 둘 열리지 않는다") + void noDuplicateSlotAtTheSameUnlock() { + // 같은 레벨에 두 개가 열리는 것 자체는 막지 않는다. 프레임을 계절 배경과 짝 맞추면서 + // 출석 3 / 5 / 13 / 15 에 두 개씩 열리게 됐고, 그건 의도다. + // 막아야 하는 것은 "같은 조건에 같은 자리" 다. 그러면 기본 프리셋이 어느 쪽을 고를지가 + // 키 순서라는 우연에 걸린다. + List keys = catalog().stream() + .map(item -> (item.getRequiredAbility() == null ? "-" : item.getRequiredAbility().name()) + + "/" + item.getRequiredLevel() + "/" + item.getSlot()) + .toList(); - levels.forEach((ability, values) -> assertThat(values) - .as(ability + " 의 해금 레벨. 한 레벨에 둘이 열리면 레벨업 알림이 어느 쪽인지 흐려진다") - .doesNotHaveDuplicates()); + assertThat(keys).doesNotHaveDuplicates(); } @Test @@ -283,7 +334,7 @@ void everySeededItemIsActive() { @DisplayName("모든 이미지 경로가 item_key 와 짝이 맞는다") void imageUrlMatchesItemKey() { List mismatched = cosmeticItemRepository.findAll().stream() - .filter(item -> !item.getImageUrl().equals("assets/Cosmetic/" + item.getItemKey() + ".png")) + .filter(item -> !item.getImageUrl().equals(expectedImageUrl(item))) .map(CosmeticItem::getItemKey) .toList(); @@ -292,6 +343,24 @@ void imageUrlMatchesItemKey() { .isEmpty(); } + @Test + @DisplayName("프레임만 SVG 이고 폴더도 다르다") + void frameAssetsAreSvg() { + assertThat(catalog()) + .filteredOn(item -> item.getSlot() == CosmeticSlot.FRAME) + .as("원형 테두리라 확대해도 깨지면 안 돼서 SVG 다") + .allSatisfy(item -> assertThat(item.getImageUrl()) + .startsWith("assets/ProfileFrame/") + .endsWith(".svg")); + + assertThat(cosmeticItemRepository.findAll()) + .filteredOn(item -> item.getSlot() != CosmeticSlot.FRAME) + .as("나머지는 그대로 번들 PNG 다") + .allSatisfy(item -> assertThat(item.getImageUrl()) + .startsWith("assets/Cosmetic/") + .endsWith(".png")); + } + @Test @DisplayName("이미지 경로는 전부 번들 상대 경로다 - 아직 S3 에 아무것도 없다") void imageUrlIsNotRemoteYet() { @@ -360,6 +429,14 @@ private List catalog() { .toList(); } + /** 프레임만 폴더와 확장자가 다르다. */ + private String expectedImageUrl(CosmeticItem item) { + if (item.getSlot() == CosmeticSlot.FRAME) { + return "assets/ProfileFrame/" + item.getItemKey() + ".svg"; + } + return "assets/Cosmetic/" + item.getItemKey() + ".png"; + } + private List keysOfSlot(CosmeticSlot slot) { return catalog().stream() .filter(item -> item.getSlot() == slot) diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index c64a2989..131cca5b 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -123,7 +123,7 @@ void excludesInactiveItems() { } @Test - @DisplayName("시드된 55개가 빠짐없이 목록에 들어간다") + @DisplayName("시드된 63개가 빠짐없이 목록에 들어간다") void everySeededItemIsListed() { User user = fullyGrownUser(); @@ -158,13 +158,96 @@ void slotsAreOrderedBackToFront() { .containsExactly( CosmeticSlot.BACKGROUND, CosmeticSlot.BACK, CosmeticSlot.OUTFIT, CosmeticSlot.BAG, CosmeticSlot.NECK, CosmeticSlot.FACE, - CosmeticSlot.HEAD, CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT); + CosmeticSlot.HEAD, CosmeticSlot.HAND, CosmeticSlot.BADGE, + CosmeticSlot.EFFECT, CosmeticSlot.FRAME); assertThat(slots).extracting(CosmeticSlotDto::layerOrder) .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") - .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900); + .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900, 1000); assertThat(slots.get(0).nameKo()).isEqualTo("배경"); } + @Test + @DisplayName("프레임만 개구리 합성에서 빠진다 - composited 로 갈라 보낸다") + void onlyFrameIsNotComposited() { + User user = userAtAllLevels(1); + + List slots = cosmeticService.getCosmetics(user.getId()).slots(); + + assertThat(slots) + .filteredOn(slot -> !slot.composited()) + .as("layerOrder 만 내려보내면 프론트는 순서대로 겹치는 수밖에 없어 프레임이 개구리 위에 덮인다") + .extracting(CosmeticSlotDto::slot) + .containsExactly(CosmeticSlot.FRAME); + assertThat(slots) + .filteredOn(CosmeticSlotDto::composited) + .as("나머지 열 자리는 전부 개구리에 겹쳐 그린다") + .hasSize(10); + } + + @Test + @DisplayName("프레임은 옷장 목록에서 빠지지 않는다") + void frameItemsAreListed() { + User user = fullyGrownUser(); + + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + assertThat(response.items()) + .filteredOn(item -> item.slot() == CosmeticSlot.FRAME) + .as("개구리에 안 겹친다고 목록에서 빼면 옷장에서 고를 수가 없다") + .extracting(CosmeticItemResponseDto::itemKey) + .containsExactlyInAnyOrder(FRAME_SPRING, FRAME_SUMMER, "frame_autumn", "frame_winter", + FRAME_NIGHT, FRAME_STUDY, FRAME_LEAF, FRAME_MASTER); + } + + @Test + @DisplayName("프레임 에셋만 SVG 이고 경로도 다르다") + void frameAssetsAreSvg() { + User user = fullyGrownUser(); + + List items = cosmeticService.getCosmetics(user.getId()).items(); + + assertThat(items) + .filteredOn(item -> item.slot() == CosmeticSlot.FRAME) + .allSatisfy(item -> assertThat(item.imageUrl()) + .as(item.itemKey() + " 는 원형 테두리라 확대해도 깨지면 안 된다") + .isEqualTo("assets/ProfileFrame/" + item.itemKey() + ".svg")); + assertThat(items) + .filteredOn(item -> item.slot() != CosmeticSlot.FRAME) + .allSatisfy(item -> assertThat(item.imageUrl()) + .as("나머지는 그대로 번들 PNG 다") + .isEqualTo("assets/Cosmetic/" + item.itemKey() + ".png")); + } + + @Test + @DisplayName("프레임 해금도 능력치별 기준으로 판정된다") + void frameUnlockFollowsAbility() { + // 출석만 3. 봄 프레임(출석 3)은 열리고 여름 프레임(출석 5)은 아직이다. + User user = setLevels(fixtures.createUser(), 1L, 3L, 1L, 1L, 1L); + + CosmeticListResponseDto response = cosmeticService.getCosmetics(user.getId()); + + assertThat(ownedOf(response, FRAME_SPRING)) + .as("경계는 포함이다") + .isTrue(); + assertThat(ownedOf(response, FRAME_SUMMER)).isFalse(); + assertThat(ownedOf(response, FRAME_STUDY)) + .as("공부방 프레임은 작성 14 라 출석을 올려서는 안 열린다") + .isFalse(); + assertThat(ownedOf(response, FRAME_LEAF)) + .as("잎새 프레임은 총 학습 4 다. required_ability 가 비어 있다") + .isFalse(); + } + + @Test + @DisplayName("총 학습 프레임은 능력치를 아무리 올려도 안 열린다") + void totalLevelFrameIgnoresAbilities() { + User onlyAbilities = setLevels(fixtures.createUser(), 1L, 15L, 15L, 15L, 15L); + User onlyTotal = userAtLevel(4); + + assertThat(ownedOf(cosmeticService.getCosmetics(onlyAbilities.getId()), FRAME_LEAF)).isFalse(); + assertThat(ownedOf(cosmeticService.getCosmetics(onlyTotal.getId()), FRAME_LEAF)).isTrue(); + } + @Test @DisplayName("이미지 경로는 번들 상대 경로다 - S3 전환은 컬럼 값만 바꾸면 된다") void imageUrlIsBundlePath() { @@ -244,7 +327,8 @@ void presetPicksHighestUnlockedPerSlot() { .containsEntry(CosmeticSlot.HEAD, HAT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) - .containsEntry(CosmeticSlot.EFFECT, EFFECT_SNOW); + .containsEntry(CosmeticSlot.EFFECT, EFFECT_SNOW) + .containsEntry(CosmeticSlot.FRAME, FRAME_MASTER); } @Test @@ -256,6 +340,8 @@ void presetFollowsAbilityLevel() { assertThat(equippedOf(user.getId())) .containsEntry(CosmeticSlot.BACKGROUND, BG_RAINY) .containsEntry(CosmeticSlot.EFFECT, EFFECT_SPARKLE) + .as("출석 5 짜리 여름 프레임까지 열려 있다") + .containsEntry(CosmeticSlot.FRAME, FRAME_SUMMER) .as("출석만 올렸는데 다른 능력치 자리가 채워지면 안 된다") .doesNotContainKey(CosmeticSlot.HEAD) .doesNotContainKey(CosmeticSlot.FACE) @@ -273,6 +359,8 @@ void presetWithOnlyTotalLevel() { .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) + .as("프레임은 총 학습 17 짜리 마스터가 가장 늦게 열린다") + .containsEntry(CosmeticSlot.FRAME, FRAME_MASTER) .as("배경·얼굴·목·가방은 전부 능력치로만 열린다") .doesNotContainKey(CosmeticSlot.BACKGROUND) .doesNotContainKey(CosmeticSlot.FACE) @@ -332,7 +420,7 @@ void firstEquipMaterializesPreset() { .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA); - assertThat(loadoutRowCount(user.getId())).isEqualTo(10); + assertThat(loadoutRowCount(user.getId())).isEqualTo(EQUIPPABLE_SLOT_COUNT); } @Test @@ -580,7 +668,7 @@ void noConflictKeepsEverything() { CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); assertThat(response.unequippedSlots()).isEmpty(); - assertThat(response.equipped()).hasSize(10); + assertThat(response.equipped()).hasSize(EQUIPPABLE_SLOT_COUNT); } } @@ -672,7 +760,7 @@ void totalRangeExcludesAbilityItems() { .extracting(UnlockedCosmeticDto::itemKey) .as("오르지도 않은 능력치의 아이템이 '방금 열렸다' 고 나가면 안 된다") .doesNotContain(GLASSES_ROUND, BG_SPRING, SCARF, BAG_MINI_BACKPACK) - .hasSize(14); + .hasSize(16); } @Test @@ -694,7 +782,8 @@ void mergesTotalAndAbilityUnlocks() { assertThat(unlocked) .extracting(UnlockedCosmeticDto::itemKey) - .containsExactly(BG_SPRING, HEADBAND_SPROUT, EFFECT_PETALS); + .as("레벨 2 는 봄 배경과 새싹 머리띠, 레벨 3 은 꽃잎 효과와 봄 프레임이다") + .containsExactly(BG_SPRING, HEADBAND_SPROUT, EFFECT_PETALS, FRAME_SPRING); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java index 68fd8b37..9e9a9fc0 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java @@ -38,7 +38,8 @@ public class CosmeticItemSeeder { */ private static final List MIGRATION_PATHS = List.of( "db/migration/V35__seed_cosmetic_items.sql", - "db/migration/V37__seed_cosmetic_items_by_ability.sql" + "db/migration/V37__seed_cosmetic_items_by_ability.sql", + "db/migration/V38__seed_profile_frame_cosmetics.sql" ); @PersistenceContext diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java index bc606ff0..6ba31599 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java @@ -43,6 +43,12 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final String EFFECT_SNOW = "effect_snow"; /** (ATTENDANCE, 15) */ protected static final String BG_SPACE = "bg_space"; + /** (ATTENDANCE, 3) — 프로필 프레임. 개구리에 겹치지 않는다 */ + protected static final String FRAME_SPRING = "frame_spring"; + /** (ATTENDANCE, 5) — 프로필 프레임 */ + protected static final String FRAME_SUMMER = "frame_summer"; + /** (ATTENDANCE, 15) — 프로필 프레임 */ + protected static final String FRAME_NIGHT = "frame_night"; /** (NOTE_WRITE, 2) — 앞으로 메는 가방이라 BAG 이다 */ protected static final String BAG_MINI_BACKPACK = "bag_mini_backpack"; /** (NOTE_WRITE, 5) — 등에 메는 가방이라 BACK 이다 */ @@ -53,6 +59,8 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final String BAG_CROSSBODY_SATCHEL = "bag_crossbody_satchel"; /** (NOTE_WRITE, 12) */ protected static final String BG_STUDY = "bg_study"; + /** (NOTE_WRITE, 14) — 프로필 프레임 */ + protected static final String FRAME_STUDY = "frame_study"; /** (NOTE_PRACTICE, 2) */ protected static final String SCARF = "scarf"; /** (NOTE_PRACTICE, 5) — 전신 의상 */ @@ -67,6 +75,10 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final String HEADBAND_SPROUT = "headband_sprout"; /** (총 3) */ protected static final String BADGE_LEAF_STAR = "badge_leaf_star"; + /** (총 4) — 프로필 프레임 */ + protected static final String FRAME_LEAF = "frame_leaf"; + /** (총 17) — 프로필 프레임. 총 학습 프레임 중 가장 늦게 열린다 */ + protected static final String FRAME_MASTER = "frame_master"; /** (총 8) */ protected static final String PROP_BOUQUET = "prop_bouquet"; /** (총 16) — 상한이 20 으로 오르면서 생긴 자리 */ @@ -85,8 +97,11 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final String GRADUATE_SET = "graduate"; protected static final String GRADUATE_SET_NAME = "학사 세트"; - /** 전체 시드 개수. 본체(BASE) 는 포함하지 않는다. */ - protected static final int SEEDED_ITEM_COUNT = 55; + /** 전체 시드 개수. 본체(BASE) 는 포함하지 않는다. 능력치별 17 / 10 / 11 / 9 와 총 학습 16 이다. */ + protected static final int SEEDED_ITEM_COUNT = 63; + + /** 장착 가능한 자리 수. 프리셋이 한 자리에 하나씩 채우므로 곧 프리셋 행 수이기도 하다. */ + protected static final int EQUIPPABLE_SLOT_COUNT = 11; @Autowired private CosmeticItemSeeder cosmeticItemSeeder; @@ -151,7 +166,7 @@ protected User userAtAllLevels(long level) { } /** - * 다 자란 사용자. 총 학습 20, 능력치 넷 모두 15 라 시드된 55 개가 전부 열려 있다. + * 다 자란 사용자. 총 학습 20, 능력치 넷 모두 15 라 시드된 63 개가 전부 열려 있다. * *

능력치별 아이템의 마지막 해금 레벨이 15, 총 학습 아이템의 마지막이 20 이다. */ diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java index e2f9a594..59111970 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCosmeticUnlockTest.java @@ -21,7 +21,7 @@ *

해금 자체는 계산이라 알림이 없어도 다음 조회에서 드러나지만, 사용자가 "레벨이 올랐는데 * 뭐가 좋아졌는지" 를 그 순간에 모르면 레벨업이 아무 일도 아닌 것이 된다. * - *

해금 기준이 능력치별로 갈리면서 여기가 특히 중요해졌다. 시드된 55 개 중 41 개가 + *

해금 기준이 능력치별로 갈리면서 여기가 특히 중요해졌다. 시드된 63 개 중 47 개가 * 능력치로 열린다. 총 학습 레벨만 보면 보상을 받아도 알림이 거의 늘 비어 있게 된다. * *

{@code MissionRewardGranter} 의 트랜잭션 경계와 잠금 순서(사용자 → 진행도)는 건드리지 않았다. @@ -90,8 +90,9 @@ void unlocksFromBothRanges() { assertThat(response.totalStudyLevel()).isEqualTo(2L); assertThat(response.unlockedCosmetics()) .extracting(UnlockedCosmeticDto::itemKey) - .as("출석 2·3·4·5 네 개와 총 학습 2 하나. 레벨 순, 같은 레벨이면 키 순이다") - .containsExactly("bg_spring", "headband_sprout", "effect_petals", "bg_summer", "effect_sparkle"); + .as("출석 2~5 여섯 개와 총 학습 2 하나. 레벨 순, 같은 레벨이면 키 순이다") + .containsExactly("bg_spring", "headband_sprout", "effect_petals", "frame_spring", + "bg_summer", "effect_sparkle", "frame_summer"); assertThat(response.unlockedCosmetics().get(0).slot()).isEqualTo(CosmeticSlot.BACKGROUND); assertThat(response.unlockedCosmetics().get(0).nameKo()).isEqualTo("봄 배경"); } @@ -110,8 +111,8 @@ void onlyTheRewardedAbilityUnlocks() { .containsExactly("bag_mini_backpack", "headband_sprout", "prop_notebook"); assertThat(response.unlockedCosmetics()) .extracting(UnlockedCosmeticDto::itemKey) - .as("출석 2 짜리 봄 배경은 출석 레벨이 오르지 않았으니 열리지 않는다") - .doesNotContain("bg_spring", "glasses_round", "scarf"); + .as("출석 2 짜리 봄 배경과 출석 3 짜리 봄 프레임은 출석이 오르지 않았으니 열리지 않는다") + .doesNotContain("bg_spring", "frame_spring", "glasses_round", "scarf"); } @Test From 24f68662e2b8f1567ad7c8246ec050ad6628b4b0 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 13:28:35 +0900 Subject: [PATCH 084/130] =?UTF-8?q?[Fix]=20=EA=B8=B0=EB=B3=B8=20=ED=94=84?= =?UTF-8?q?=EB=A6=AC=EC=85=8B=EC=97=90=EC=84=9C=20=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=EC=9D=84=20=EB=BA=80=EB=8B=A4=20-=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=EB=A3=B8=EC=97=90=EC=84=9C=20=EB=82=98?= =?UTF-8?q?=EB=A7=8C=20=ED=85=8C=EB=91=90=EB=A6=AC=EA=B0=80=20=EC=83=9D?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - composited = false 인 자리는 프리셋이 채우지 않는다. 지금은 FRAME 하나다 - 프리셋의 근거는 "레벨이 높은 사용자가 아무것도 안 했는데 맨 개구리가 되는 것을 막는다" 인데 프레임에는 그 근거가 없다. 꾸미기 전에도 프레임은 없었다 - 프레임은 치장 중 유일하게 개구리 화면 밖에서, 그것도 남들과 나란히 보인다(스터디룸 멤버 목록). 그런데 멤버 응답에는 아직 치장이 실리지 않아, 프리셋이 자동으로 걸면 목록에서 나만 테두리가 있고 나머지는 맨 얼굴이 된다. 내가 고른 것도 아닌데 그렇게 보이는 것이라 직접 고를 때만 걸리게 한다 - 장착 자체는 프레임도 다른 자리와 똑같이 받는다. 자동으로 걸어 주지 않을 뿐이다 - 첫 장착 때 프리셋을 행으로 굳히는 경로에서도 프레임 행은 안 생긴다 - 멤버 응답에 치장이 실리면 이 규칙을 다시 볼 값어치가 있다는 사정을 주석에 남겼다 --- .../cosmetic/service/CosmeticService.java | 24 ++++++++- .../CosmeticEquipConcurrencyTest.java | 2 +- .../controller/CosmeticControllerTest.java | 6 ++- .../cosmetic/service/CosmeticServiceTest.java | 52 ++++++++++++++++--- .../cosmetic/support/CosmeticTestSupport.java | 10 +++- 5 files changed, 82 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java index 7f746203..e9c3f7dc 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -359,10 +359,20 @@ private Map currentEquipped(Long userId, List{@code composited = false} 인 자리는 채우지 않는다. 지금은 {@link CosmeticSlot#FRAME} + * 하나다. 위의 "아무것도 안 했는데 하향된 것" 이라는 근거가 프레임에는 성립하지 않는다. + * 프레임은 꾸미기 전에도 없었고, 개구리에 얹히는 것들과 달리 내 화면 밖에서 남들과 나란히 + * 보인다(스터디룸 멤버 목록). 그런데 멤버 응답에는 아직 치장이 실리지 않아, 프리셋이 내 프레임을 + * 자동으로 걸면 목록에서 나만 테두리가 있고 나머지는 맨 얼굴이 된다. + * 내가 고른 것도 아닌데 그렇게 보이는 것이라 사용자가 직접 고를 때만 걸리게 한다. + * + *

나중에 스터디룸 멤버 응답에 치장이 실리면 이 규칙은 다시 볼 값어치가 있다. + * 그때는 모두가 프레임을 갖고 있으니 "나만 튄다" 는 근거가 사라진다. */ private Map defaultPreset(List activeItems, CosmeticUnlockLevels levels) { Map preset = new EnumMap<>(CosmeticSlot.class); - for (CosmeticSlot slot : CosmeticSlot.equippableSlots()) { + for (CosmeticSlot slot : presetSlots()) { activeItems.stream() .filter(item -> item.getSlot() == slot) .filter(item -> item.isOwnedBy(levels)) @@ -377,6 +387,18 @@ private Map defaultPreset(List activeItems, return preset; } + /** + * 프리셋이 채우는 자리. 걸 수 있고 개구리에 겹치는 자리만이다. + * + *

{@code equippable} 과 {@code composited} 를 함께 보는 곳은 여기뿐이다. 장착 자체는 + * 프레임도 다른 자리와 똑같이 받는다. 자동으로 걸어 주지 않을 뿐이다. + */ + private static List presetSlots() { + return CosmeticSlot.equippableSlots().stream() + .filter(CosmeticSlot::isComposited) + .toList(); + } + /** * 한 번도 장착을 건드리지 않은 사용자의 프리셋을 실제 행으로 굳힌다. * diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java index c81d9a01..af3590c5 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/CosmeticEquipConcurrencyTest.java @@ -98,7 +98,7 @@ void concurrentEquipsOnDifferentSlotsBothSurvive() { .containsEntry(CosmeticSlot.FACE, GLASSES_ROUND); assertThat(loadoutRowCount(user.getId())) .as("프리셋이 여러 번 굳어도 슬롯 수를 넘는 행이 생기면 안 된다") - .isEqualTo(EQUIPPABLE_SLOT_COUNT); + .isEqualTo(PRESET_SLOT_COUNT); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java index e119f2e4..3a58526e 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java @@ -63,7 +63,7 @@ void slotsIncludeBackAndEffect() throws Exception { } @Test - @DisplayName("GET /api/cosmetics - 프레임 자리는 layerOrder 1000 이고 composited 가 false 다") + @DisplayName("GET /api/cosmetics - 프레임은 자리만 내려가고 자동으로 걸리지는 않는다") void frameSlotIsNotComposited() throws Exception { User user = fullyGrownUser(); authenticateAs(user.getId()); @@ -74,7 +74,9 @@ void frameSlotIsNotComposited() throws Exception { .andExpect(jsonPath(frame + ".layerOrder").value(Matchers.contains(1000))) .andExpect(jsonPath(frame + ".nameKo").value(Matchers.contains("프레임"))) .andExpect(jsonPath(frame + ".composited") - .value(Matchers.contains(false))); + .value(Matchers.contains(false))) + .andExpect(jsonPath("$.data.equipped.FRAME") + .doesNotExist()); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index 131cca5b..b86f112f 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -328,7 +328,45 @@ void presetPicksHighestUnlockedPerSlot() { .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) .containsEntry(CosmeticSlot.EFFECT, EFFECT_SNOW) - .containsEntry(CosmeticSlot.FRAME, FRAME_MASTER); + .as("프레임은 다 열려 있어도 프리셋이 걸어 주지 않는다") + .doesNotContainKey(CosmeticSlot.FRAME); + } + + @Test + @DisplayName("프리셋은 프레임을 채우지 않는다 - 스터디룸에서 나만 테두리가 생기면 안 된다") + void presetSkipsFrame() { + User user = fullyGrownUser(); + + assertThat(equippedOf(user.getId())) + .as("프레임은 개구리에 겹치지 않고 멤버 목록에서 남들과 나란히 보인다." + + " 멤버 응답에 치장이 안 실리는 동안에는 내가 고른 것만 걸려야 한다") + .doesNotContainKey(CosmeticSlot.FRAME) + .hasSize(PRESET_SLOT_COUNT); + } + + @Test + @DisplayName("프레임을 직접 고르면 걸린다 - 프리셋이 안 채울 뿐이다") + void frameIsEquippableEvenThoughPresetSkipsIt() { + User user = fullyGrownUser(); + + cosmeticService.equip(user.getId(), CosmeticSlot.FRAME, FRAME_SPRING); + + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.FRAME, FRAME_SPRING) + .as("프레임을 걸었다고 나머지 프리셋이 사라지면 안 된다") + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE); + } + + @Test + @DisplayName("프레임 아닌 자리를 먼저 걸어도 프레임 행은 안 생긴다") + void materializedPresetHasNoFrameRow() { + User user = fullyGrownUser(); + + cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); + + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.FRAME)) + .as("프리셋을 굳힐 때 프레임 행까지 쓰면 사용자가 안 고른 프레임이 박힌다") + .isNull(); } @Test @@ -340,8 +378,8 @@ void presetFollowsAbilityLevel() { assertThat(equippedOf(user.getId())) .containsEntry(CosmeticSlot.BACKGROUND, BG_RAINY) .containsEntry(CosmeticSlot.EFFECT, EFFECT_SPARKLE) - .as("출석 5 짜리 여름 프레임까지 열려 있다") - .containsEntry(CosmeticSlot.FRAME, FRAME_SUMMER) + .as("출석 5 짜리 여름 프레임이 열려 있어도 프리셋은 안 걸어 준다") + .doesNotContainKey(CosmeticSlot.FRAME) .as("출석만 올렸는데 다른 능력치 자리가 채워지면 안 된다") .doesNotContainKey(CosmeticSlot.HEAD) .doesNotContainKey(CosmeticSlot.FACE) @@ -359,8 +397,8 @@ void presetWithOnlyTotalLevel() { .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA) .containsEntry(CosmeticSlot.BADGE, BADGE_SNOWFLAKE) - .as("프레임은 총 학습 17 짜리 마스터가 가장 늦게 열린다") - .containsEntry(CosmeticSlot.FRAME, FRAME_MASTER) + .as("총 학습 17 짜리 마스터 프레임이 열려 있어도 프리셋은 안 걸어 준다") + .doesNotContainKey(CosmeticSlot.FRAME) .as("배경·얼굴·목·가방은 전부 능력치로만 열린다") .doesNotContainKey(CosmeticSlot.BACKGROUND) .doesNotContainKey(CosmeticSlot.FACE) @@ -420,7 +458,7 @@ void firstEquipMaterializesPreset() { .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.HAND, PROP_DIPLOMA); - assertThat(loadoutRowCount(user.getId())).isEqualTo(EQUIPPABLE_SLOT_COUNT); + assertThat(loadoutRowCount(user.getId())).isEqualTo(PRESET_SLOT_COUNT); } @Test @@ -668,7 +706,7 @@ void noConflictKeepsEverything() { CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.HEAD, HAT_BEANIE); assertThat(response.unequippedSlots()).isEmpty(); - assertThat(response.equipped()).hasSize(EQUIPPABLE_SLOT_COUNT); + assertThat(response.equipped()).hasSize(PRESET_SLOT_COUNT); } } diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java index 6ba31599..4aabec4f 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java @@ -100,9 +100,17 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { /** 전체 시드 개수. 본체(BASE) 는 포함하지 않는다. 능력치별 17 / 10 / 11 / 9 와 총 학습 16 이다. */ protected static final int SEEDED_ITEM_COUNT = 63; - /** 장착 가능한 자리 수. 프리셋이 한 자리에 하나씩 채우므로 곧 프리셋 행 수이기도 하다. */ + /** 장착 가능한 자리 수. 응답의 slots 길이다. */ protected static final int EQUIPPABLE_SLOT_COUNT = 11; + /** + * 기본 프리셋이 채우는 자리 수. + * + *

장착 가능한 자리에서 프레임 하나가 빠진다. 프레임은 개구리에 겹치지 않고 스터디룸에서 + * 남들과 나란히 보이는데 멤버 응답에 치장이 실리지 않아, 자동으로 걸면 나만 테두리가 생긴다. + */ + protected static final int PRESET_SLOT_COUNT = EQUIPPABLE_SLOT_COUNT - 1; + @Autowired private CosmeticItemSeeder cosmeticItemSeeder; From 2a945ad52c7b74afd7cedaad6fb043ace6e90e5f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 14:45:19 +0900 Subject: [PATCH 085/130] =?UTF-8?q?[Chore]=20BACK=20=EC=9E=90=EB=A6=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=EC=9D=84=20=EB=93=B1=EC=A7=90=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=B0=EB=82=AD=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CosmeticSlot.BACK 의 nameKo 를 배낭으로 바꿨다. 등에 멘다는 뜻이 정확하고 앞으로 메는 BAG(450) 의 가방 과도 안 겹친다 - 그 자리 아이템 둘의 이름도 맞췄다. 네이비 백팩 -> 남색 배낭, 캔버스 백팩 -> 캔버스 배낭. 자리는 배낭 인데 아이템이 캔버스 백팩 이면 BAG 자리의 미니 백팩 과 이름만으로 구별이 안 된다 - 슬롯 키(BACK)와 item_key 는 그대로다. 프론트 에셋 파일명과 해금표가 그 키로 맞춰져 있다 - V39 는 UPDATE 두 줄이다. V37 을 고치면 체크섬이 어긋나므로 새 파일로 얹는다(V33 과 같은 방식). V37/V38 이 name_ko = VALUES(name_ko) 를 달고 있어, 앞으로 카탈로그를 통째로 다시 시드하는 파일을 쓴다면 새 이름을 담아야 한다는 것을 V39 주석에 남겼다 - 이름 두 개와 자리 이름을 테스트로 잠갔고, V39 가 키와 자리를 안 건드리는 것도 함께 본다 --- .../cosmetic/dto/CosmeticListResponseDto.java | 2 +- .../backend/cosmetic/entity/CosmeticSlot.java | 6 +-- .../cosmetic/service/CosmeticService.java | 2 +- .../V39__rename_back_slot_item_names.sql | 22 +++++++++++ .../controller/CosmeticControllerTest.java | 6 +-- .../migration/CosmeticMigrationTest.java | 38 ++++++++++++++++++- .../cosmetic/service/CosmeticServiceTest.java | 10 ++--- .../cosmetic/support/CosmeticItemSeeder.java | 3 +- 8 files changed, 74 insertions(+), 15 deletions(-) create mode 100644 src/main/resources/db/migration/V39__rename_back_slot_item_names.sql diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java index 2357d854..c6243713 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java @@ -9,7 +9,7 @@ * {@code GET /api/cosmetics} 응답. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. * * @param baseImageUrl 개구리 본체 이미지. 슬롯 아이템이 아니라 항상 그려진다. - * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 등짐(200)과 옷(400) 사이에 들어간다. + * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 배낭(200)과 옷(400) 사이에 들어간다. * 슬롯 목록에는 본체가 없으므로 이 값이 따로 있어야 프론트가 레이어 순서를 * 코드에 박지 않고 조립할 수 있다. * @param slots 장착 가능한 슬롯과 그리는 순서. 뒤에서 앞 순서로 정렬돼 온다. diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java index 9575298c..c7d7fb87 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -16,7 +16,7 @@ *

값 사이를 100 씩 띄운 것은 의도된 것이다. 나중에 두 슬롯 사이에 새 슬롯이 필요해지면 * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. {@link #BAG}(450) 이 실제로 그렇게 들어왔다. * - *

{@link #BACK}(200) 과 {@link #BAG}(450) 은 둘 다 가방이지만 자리가 다르다. + *

{@link #BACK}(200, 배낭) 과 {@link #BAG}(450, 가방) 은 둘 다 가방이지만 자리가 다르다. * 등에 메는 것은 본체(300)보다 뒤에 깔려야 하고, 앞으로 메는 것은 옷(400) 위에 올라가야 한다. * 한 슬롯으로 묶으면 둘 중 하나는 반드시 잘못 그려진다. * @@ -35,8 +35,8 @@ public enum CosmeticSlot { BACKGROUND(100, "배경", true, true), - /** 등에 메는 가방. 개구리 본체보다 뒤에 깔린다. */ - BACK(200, "등짐", true, true), + /** 등에 메는 배낭. 개구리 본체보다 뒤에 깔린다. */ + BACK(200, "배낭", true, true), /** 개구리 본체. 장착 대상이 아니다. */ BASE(300, "개구리 본체", false, true), OUTFIT(400, "옷", true, true), diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java index e9c3f7dc..49d537bc 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -337,7 +337,7 @@ private Map currentEquipped(Long userId, List catalog() { .toList(); } + private String nameOf(String itemKey) { + return cosmeticItemRepository.findByItemKey(itemKey) + .orElseThrow(() -> new IllegalStateException("시드에 없는 아이템이다: " + itemKey)) + .getNameKo(); + } + /** 프레임만 폴더와 확장자가 다르다. */ private String expectedImageUrl(CosmeticItem item) { if (item.getSlot() == CosmeticSlot.FRAME) { diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index b86f112f..0411bd49 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -161,7 +161,7 @@ void slotsAreOrderedBackToFront() { CosmeticSlot.HEAD, CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT, CosmeticSlot.FRAME); assertThat(slots).extracting(CosmeticSlotDto::layerOrder) - .as("등짐(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") + .as("배낭(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900, 1000); assertThat(slots.get(0).nameKo()).isEqualTo("배경"); } @@ -492,11 +492,11 @@ void unequippingEverythingSticks() { void staleSlotRowIsHidden() { User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BAG_MINI_BACKPACK); - // 카탈로그에서 미니 백팩을 등짐으로 옮긴다. 사용자 행은 여전히 BAG 을 가리킨다. + // 카탈로그에서 미니 백팩을 배낭 자리로 옮긴다. 사용자 행은 여전히 BAG 을 가리킨다. jdbcTemplate.update("UPDATE cosmetic_item SET slot = 'BACK' WHERE item_key = ?", BAG_MINI_BACKPACK); assertThat(equippedOf(user.getId())) - .as("앞가방 자리에 등짐을 그리면 개구리 앞에 백팩이 떠 있게 된다") + .as("앞가방 자리에 배낭을 그리면 개구리 앞에 가방이 떠 있게 된다") .doesNotContainEntry(CosmeticSlot.BAG, BAG_MINI_BACKPACK); } } @@ -517,7 +517,7 @@ void equipsOwnedItem() { } @Test - @DisplayName("등짐과 앞가방은 서로 다른 자리라 같이 걸린다") + @DisplayName("배낭과 앞가방은 서로 다른 자리라 같이 걸린다") void backAndBagCoexist() { User user = fullyGrownUser(); @@ -615,7 +615,7 @@ void rejectsSlotMismatch() { assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.BACK, BAG_MINI_BACKPACK)) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) - .as("앞가방을 등짐 자리에 걸면 레이어가 뒤집힌다") + .as("앞가방을 배낭 자리에 걸면 레이어가 뒤집힌다") .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); } diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java index 9e9a9fc0..cd116e8f 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java @@ -39,7 +39,8 @@ public class CosmeticItemSeeder { private static final List MIGRATION_PATHS = List.of( "db/migration/V35__seed_cosmetic_items.sql", "db/migration/V37__seed_cosmetic_items_by_ability.sql", - "db/migration/V38__seed_profile_frame_cosmetics.sql" + "db/migration/V38__seed_profile_frame_cosmetics.sql", + "db/migration/V39__rename_back_slot_item_names.sql" ); @PersistenceContext From ec6ac3f1f7ce569a4d2bdfebe59e88d0a78816f5 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 15:39:21 +0900 Subject: [PATCH 086/130] =?UTF-8?q?[Fix]=20=EC=8A=AC=EB=A1=AF=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EC=9D=84=20varchar(32)=20=EB=A1=9C=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B0=95=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @Enumerated(STRING) 만 두면 Hibernate 가 MySQL 에서 이 컬럼을 네이티브 ENUM(...) 으로 만든다. 운영 스키마는 V34 가 만든 VARCHAR(32) 라 둘이 갈려 있었다 - 갈려 있으면 enum 에서 상수를 하나 지우는 순간, 그 값을 담은 옛 마이그레이션이 테스트에서만 Data truncated 로 죽는다. 운영에서는 VARCHAR 라 아무 일도 없다 - 옛 배포가 남긴 슬롯 값이 운영 DB 에는 들어갈 수 있는데 테스트 스키마에는 못 들어가는 것도 같은 문제다. 자리를 없애는 변경의 마이그레이션을 테스트로 재현할 수 없다 - columnDefinition 으로 V34 와 같은 타입을 못 박아 둘을 맞춘다 --- .../backend/cosmetic/entity/CosmeticItem.java | 26 ++++++++++++++++++- .../cosmetic/entity/UserCosmeticLoadout.java | 8 +++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java index bb2b8d2a..1407204d 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticItem.java @@ -55,10 +55,34 @@ public class CosmeticItem { @Column(name = "item_key", nullable = false, length = 64) private String itemKey; + /** + * {@code columnDefinition} 을 못 박는 이유는 V34 의 {@code slot VARCHAR(32)} 과 맞추기 위해서다. + * + *

비워 두면 Hibernate 는 MySQL 에서 이 컬럼을 네이티브 {@code ENUM(...)} 으로 만든다. + * 그러면 운영 스키마(VARCHAR)와 테스트 스키마(ENUM)가 갈리고, 이 enum 에서 상수를 하나 지우는 + * 순간 그 값을 담고 있던 옛 마이그레이션이 테스트에서만 "Data truncated" 로 죽는다. + * 운영에서는 VARCHAR 라 아무 일도 없는데 테스트만 터지는 차이는 만들지 않는다. + */ @Enumerated(EnumType.STRING) - @Column(name = "slot", nullable = false, length = 32) + @Column(name = "slot", nullable = false, length = 32, columnDefinition = "varchar(32)") private CosmeticSlot slot; + /** + * 이 아이템만의 그리는 층. {@code null} 이면 자리의 기본값({@link CosmeticSlot#getLayerOrder()})을 쓴다. + * + *

{@link CosmeticSlot#BAG} 하나를 위해 생겼다. 등에 메는 가방과 앞으로 메는 가방은 같은 자리인데 + * 그리는 층이 다르다. 전자는 개구리 본체보다 뒤(200), 후자는 옷 위(450)다. + * 층이 다르다는 이유만으로 자리를 둘로 나누면 아이템 두세 개짜리 탭이 하나 더 생긴다. + * + *

0 이나 -1 같은 마법값을 쓰지 않는다. {@code required_level} 과 같은 이유로, + * "덮어쓰지 않는다" 와 "0층에 그린다" 는 다른 말이다. + * + *

서버는 이 값을 풀지 않고 그대로 내려보낸다. 합성은 프론트가 하고 + * {@code item.layerOrder ?? slot.layerOrder} 로 푼다. + */ + @Column(name = "layer_order") + private Integer layerOrder; + @Column(name = "name_ko", nullable = false, length = 64) private String nameKo; diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java index 305a2b02..9d150213 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/UserCosmeticLoadout.java @@ -55,9 +55,15 @@ public class UserCosmeticLoadout { @Column(name = "user_id", nullable = false) private Long userId; + /** + * V34 의 {@code slot VARCHAR(32)} 과 맞춘다. {@code columnDefinition} 이 없으면 Hibernate 가 + * MySQL 에서 네이티브 {@code ENUM(...)} 으로 만들어, 운영에는 들어갈 수 있는 값(옛 배포가 남긴 + * 슬롯 이름)이 테스트 스키마에는 아예 못 들어간다. 그 차이를 두면 자리를 없애는 변경의 + * 마이그레이션을 테스트로 재현할 수 없다. + */ @Id @Enumerated(EnumType.STRING) - @Column(name = "slot", nullable = false, length = 32) + @Column(name = "slot", nullable = false, length = 32, columnDefinition = "varchar(32)") private CosmeticSlot slot; @Column(name = "item_key", nullable = false, length = 64) From 08b92d186f811c36c3d75ce4c7694aaea918f47b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 15:39:21 +0900 Subject: [PATCH 087/130] =?UTF-8?q?[Feat]=20=EA=B0=80=EB=B0=A9=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=20=ED=86=B5=ED=95=A9=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20-=20layer=5Forder=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=EA=B3=BC=20BACK=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V40: cosmetic_item 에 layer_order INT NULL 을 더한다. 값이 있으면 그 층에 그리고 NULL 이면 자리 기본값을 쓴다. 0 같은 마법값을 쓰지 않는 이유는 required_level 과 같다. MySQL 에 ADD COLUMN IF NOT EXISTS 가 없어 V36 처럼 information_schema 로 가드한다 - V41: 카탈로그의 back_backpack_* 둘을 BAG 으로 옮기고 그 둘만 layer_order 200 을 갖게 한다. 자리는 하나지만 등에 메는 것은 개구리 본체(300)보다 뒤에 깔려야 한다 - V41: BACK 자리에 걸어 둔 사용자 행을 BAG 으로 옮긴다. BACK 은 이제 CosmeticSlot 에 없는 값이라 남겨 두면 조회할 때 엔티티 매핑이 터져 꾸미기 화면이 통째로 안 열린다 - 지우지 않고 옮기는 이유는 사용자가 실제로 고른 것이기 때문이다. 지우면 배낭을 메고 있던 사용자가 다음 조회에서 이유 없이 맨등이 된다 - BACK 과 BAG 을 둘 다 가진 사용자는 BAG 쪽을 남긴다. updated_at 최신 쪽을 남기는 규칙은 쓸 수 없다. 프리셋을 행으로 굳힐 때 모든 자리를 같은 NOW(6) 으로 써서 시각이 같기 때문이다 - 일부러 비워 둔 __none__ 행도 그대로 옮긴다. 지우면 기본 프리셋이 되살아날 수 있다 - item_key 와 name_ko 는 건드리지 않는다. 자리가 하나가 됐으니 이름이 구분을 진다 --- .../V40__add_cosmetic_layer_order.sql | 29 +++++++++++++ .../V41__merge_back_slot_into_bag.sql | 43 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql create mode 100644 src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql diff --git a/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql b/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql new file mode 100644 index 00000000..c529c44d --- /dev/null +++ b/src/main/resources/db/migration/V40__add_cosmetic_layer_order.sql @@ -0,0 +1,29 @@ +-- 아이템이 자리의 그리는 층을 덮어쓸 수 있게 하는 컬럼. 이 파일은 DDL 만 담는다. 값 채우기는 V41 이다. +-- +-- 가방 자리를 BACK(200) 과 BAG(450) 둘로 나눠 뒀는데 각각 2개·3개뿐이라 탭을 나눌 만큼이 아니었고, +-- 사용자에게 "왜 가방 자리가 둘이지" 를 설명해야 하는 구조였다. 그래서 BAG 하나로 합친다. +-- +-- 문제는 그리는 층이다. 배낭은 개구리 뒤(200), 앞가방은 옷 위(450). 자리를 합치면서 층까지 통일하면 +-- 배낭이 개구리 앞으로 나와 이상해진다. 자리는 하나로 두되 그 둘만 층을 따로 갖게 한다. +-- +-- layer_order 가 NULL 이면 자리 값(CosmeticSlot.layerOrder)을 쓴다. 0 같은 마법값을 쓰지 않는다. +-- required_level 과 같은 이유다. "덮어쓰지 않는다" 와 "0층에 그린다" 는 다른 말이다. +-- 프론트는 item.layerOrder ?? slot.layerOrder 로 푼다. +-- +-- MySQL 에 ADD COLUMN IF NOT EXISTS 가 없다. DDL 이 커밋된 뒤 Flyway 가 이력을 남기기 전에 +-- 커넥션이 끊기면 재기동 때 이 파일이 다시 돌면서 Duplicate column name 으로 앱이 뜨지 않는다. +-- V36 과 같은 방식으로 information_schema 를 보고 없을 때만 실행한다. + +SET @column_exists := ( + SELECT COUNT(*) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'cosmetic_item' + AND column_name = 'layer_order' +); +SET @ddl := IF(@column_exists = 0, + 'ALTER TABLE cosmetic_item ADD COLUMN layer_order INT NULL AFTER slot', + 'SELECT 1'); +PREPARE add_layer_order FROM @ddl; +EXECUTE add_layer_order; +DEALLOCATE PREPARE add_layer_order; diff --git a/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql b/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql new file mode 100644 index 00000000..21c37599 --- /dev/null +++ b/src/main/resources/db/migration/V41__merge_back_slot_into_bag.sql @@ -0,0 +1,43 @@ +-- 가방 자리 통합. 카탈로그의 BACK 두 줄을 BAG 으로 옮기고, 그 둘만 그리는 층 200 을 직접 갖게 한다. +-- 그리고 이미 BACK 자리에 걸어 둔 사용자 행을 옮긴다. +-- +-- V37/V39 를 고치지 않고 새 파일로 얹는다. 이미 적용된 환경이 있으면 Flyway 체크섬이 어긋나 앱이 뜨지 않는다. +-- 여기는 전부 UPDATE/DELETE 라 몇 번을 다시 돌려도 결과가 같다. +-- +-- **item_key 와 name_ko 는 건드리지 않는다.** 에셋 파일명과 해금표가 item_key 로 맞춰져 있고, +-- 이름(남색 배낭 / 캔버스 배낭)은 한 탭 안에서 이게 등에 메는 것인지 앞으로 메는 것인지 말해 준다. +-- 자리가 하나가 됐으니 이름이 그 구분을 대신 진다. +-- +-- 주의: V37 은 ON DUPLICATE KEY UPDATE 절에 slot = VALUES(slot) 을 달고 있다. 앞으로 카탈로그를 +-- 통째로 다시 시드하는 파일을 쓴다면 거기에 BAG 과 layer_order 를 담아야 한다. V39 와 같은 사정이다. + +-- ─────────────── 1. 카탈로그 ─────────────── + +-- 배낭 둘만 자리 기본값(450)을 덮어쓴다. 나머지는 NULL 그대로라 자리 값을 쓴다. +UPDATE cosmetic_item SET slot = 'BAG', layer_order = 200 +WHERE item_key IN ('back_backpack_navy', 'back_backpack_canvas'); + +-- ─────────────── 2. 사용자 장착 행 ─────────────── +-- +-- BACK 은 이제 CosmeticSlot 에 없는 값이다. 행을 남겨 두면 조회할 때 엔티티 매핑이 +-- IllegalArgumentException 으로 터져 꾸미기 화면이 통째로 안 열린다. 반드시 비워야 한다. +-- +-- 지우지 않고 옮기는 이유는 사용자가 실제로 고른 것이기 때문이다. 지우면 배낭을 메고 있던 사용자가 +-- 다음 조회에서 이유 없이 맨등이 된다. +-- +-- 한 사용자가 BACK 과 BAG 을 둘 다 갖고 있으면 하나만 남길 수 있다((user_id, slot) 기본키). +-- **BAG 쪽을 남긴다.** 살아남는 자리의 행이라 그대로 두면 되고, 앞으로 메는 가방이 위에 그려져 +-- 더 눈에 띈다. updated_at 이 최신인 쪽을 남기는 규칙도 생각했지만 쓸 수 없다. +-- 기본 프리셋을 행으로 굳힐 때 모든 자리를 같은 NOW(6) 으로 쓰기 때문에, 대부분의 사용자는 +-- 두 행의 시각이 정확히 같아 시각으로는 아무것도 가릴 수 없다. + +-- 2-1. BAG 행이 이미 있는 사용자의 BACK 행을 버린다. +DELETE back FROM user_cosmetic_loadout back +JOIN user_cosmetic_loadout bag + ON bag.user_id = back.user_id AND bag.slot = 'BAG' +WHERE back.slot = 'BACK'; + +-- 2-2. 남은 BACK 행을 BAG 으로 옮긴다. 2-1 이 충돌할 상대를 전부 치웠으므로 기본키 충돌이 없다. +-- item_key '__none__'(일부러 비운 자리) 행도 그대로 옮긴다. 비워 둔 것도 사용자의 선택이고, +-- 지우면 "한 번도 안 건드린 사용자" 쪽으로 기울어 기본 프리셋이 되살아날 수 있다. +UPDATE user_cosmetic_loadout SET slot = 'BAG' WHERE slot = 'BACK'; From f5f43806aaf242052c27318499bb2b1761b28e7c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 15:39:21 +0900 Subject: [PATCH 088/130] =?UTF-8?q?[Feat]=20=EA=B0=80=EB=B0=A9=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=95=A9?= =?UTF-8?q?=EC=B9=9C=EB=8B=A4=20-=20=EA=B7=B8=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EC=B8=B5=EC=9D=80=20=EC=95=84=EC=9D=B4=ED=85=9C=EC=9D=B4=20?= =?UTF-8?q?=EB=8D=AE=EC=96=B4=EC=93=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CosmeticSlot 에서 BACK 을 없애고 BAG 하나로 간다. 각각 2개·3개뿐이라 탭을 나눌 만큼이 아니었고, 사용자에게 "왜 가방 자리가 둘이지" 를 설명해야 하는 구조였다 - 자리의 layerOrder 는 이제 기본값이다. 아이템이 cosmetic_item.layer_order 로 덮어쓴다. 등에 메는 둘만 200 을 갖고 나머지는 NULL 이라 자리 값 450 을 쓴다 - 응답의 items[] 에 layerOrder(nullable) 를 더했다. 프론트가 item.layerOrder ?? slot.layerOrder 로 푼다. 서버는 풀지 않고 그대로 내려보낸다. 합성은 프론트가 한다 - slots 는 11개에서 10개가 된다 - 아이템 목록 정렬은 자리 기준 그대로다. 옷장은 자리로 묶어 보여주므로 가방 다섯 개가 한 덩어리로 있어야 한다. 층으로 정렬하면 등에 메는 둘만 옷 앞으로 떨어져 나간다 - 시드 63개와 자리 분포, 층 덮어쓰기가 그 둘뿐인 것, BACK 장착 행의 이전을 테스트로 고정했다 --- .../cosmetic/dto/CosmeticItemResponseDto.java | 9 +- .../cosmetic/dto/CosmeticListResponseDto.java | 2 +- .../backend/cosmetic/entity/CosmeticSlot.java | 20 ++- .../cosmetic/service/CosmeticService.java | 2 +- .../controller/CosmeticControllerTest.java | 54 ++++-- .../migration/CosmeticMigrationTest.java | 164 ++++++++++++++++-- .../cosmetic/service/CosmeticServiceTest.java | 74 ++++++-- .../cosmetic/support/CosmeticItemSeeder.java | 13 +- .../cosmetic/support/CosmeticTestSupport.java | 11 +- 9 files changed, 285 insertions(+), 64 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java index 9add3210..22be8261 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticItemResponseDto.java @@ -18,11 +18,17 @@ *

{@code requiredAbility} 를 함께 내려보내는 이유는 화면 문구 때문이다. 이 값이 없으면 * 잠긴 아이템에 "Lv.5 에 열려요" 까지만 쓸 수 있고 어느 레벨을 올려야 하는지는 쓸 수 없다. * - *

필드는 뒤에 덧붙이기만 했다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. + *

{@code layerOrder} 는 이 아이템만의 그리는 층이다. {@code null} 이면 자리의 기본값을 쓴다. + * 프론트가 {@code item.layerOrder ?? slot.layerOrder} 로 푼다. 자리 하나에 층이 둘인 것은 + * 지금 {@code BAG}(등에 메는 가방 200 / 앞으로 메는 가방 450) 뿐이다. + * + *

필드는 더하기만 했다. 구버전 앱은 모르는 필드를 무시하므로 그대로 돈다. + * JSON 필드 순서는 계약이 아니다. */ public record CosmeticItemResponseDto( String itemKey, CosmeticSlot slot, + Integer layerOrder, String nameKo, String imageUrl, Integer requiredLevel, @@ -38,6 +44,7 @@ public static CosmeticItemResponseDto of(CosmeticItem item, CosmeticUnlockLevels return new CosmeticItemResponseDto( item.getItemKey(), item.getSlot(), + item.getLayerOrder(), item.getNameKo(), item.getImageUrl(), item.getRequiredLevel(), diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java index c6243713..428b612b 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticListResponseDto.java @@ -9,7 +9,7 @@ * {@code GET /api/cosmetics} 응답. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. * * @param baseImageUrl 개구리 본체 이미지. 슬롯 아이템이 아니라 항상 그려진다. - * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 배낭(200)과 옷(400) 사이에 들어간다. + * @param baseLayerOrder 본체를 어느 층에 그릴지. 본체는 등에 메는 가방(200)과 옷(400) 사이에 들어간다. * 슬롯 목록에는 본체가 없으므로 이 값이 따로 있어야 프론트가 레이어 순서를 * 코드에 박지 않고 조립할 수 있다. * @param slots 장착 가능한 슬롯과 그리는 순서. 뒤에서 앞 순서로 정렬돼 온다. diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java index c7d7fb87..1cca9574 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/entity/CosmeticSlot.java @@ -16,9 +16,10 @@ *

값 사이를 100 씩 띄운 것은 의도된 것이다. 나중에 두 슬롯 사이에 새 슬롯이 필요해지면 * 기존 값을 건드리지 않고 그 사이 숫자를 쓰면 된다. {@link #BAG}(450) 이 실제로 그렇게 들어왔다. * - *

{@link #BACK}(200, 배낭) 과 {@link #BAG}(450, 가방) 은 둘 다 가방이지만 자리가 다르다. - * 등에 메는 것은 본체(300)보다 뒤에 깔려야 하고, 앞으로 메는 것은 옷(400) 위에 올라가야 한다. - * 한 슬롯으로 묶으면 둘 중 하나는 반드시 잘못 그려진다. + *

자리의 {@code layerOrder} 는 기본값이다. 아이템이 {@code cosmetic_item.layer_order} 로 + * 덮어쓸 수 있다. {@link #BAG} 이 그래서 하나로 합쳐질 수 있었다. 등에 메는 것은 본체(300)보다 뒤에 + * 깔려야 하고 앞으로 메는 것은 옷(400) 위에 올라가야 하는데, 층이 다르다는 이유만으로 자리를 + * 둘로 나누면 사용자에게 설명할 수 없는 탭이 하나 더 생긴다. * *

{@link #BASE} 는 장착 슬롯이 아니라 개구리 본체다. 본체 이미지도 {@code cosmetic_item} 행으로 * 두기 때문에 슬롯 이름이 필요해서 여기 있을 뿐이고, 장착 가능한 슬롯 목록과 아이템 목록에서는 빠진다. @@ -35,12 +36,19 @@ public enum CosmeticSlot { BACKGROUND(100, "배경", true, true), - /** 등에 메는 배낭. 개구리 본체보다 뒤에 깔린다. */ - BACK(200, "배낭", true, true), /** 개구리 본체. 장착 대상이 아니다. */ BASE(300, "개구리 본체", false, true), OUTFIT(400, "옷", true, true), - /** 앞으로 메는 가방. 옷 위에 올라간다. */ + /** + * 가방. 등에 메는 것과 앞으로 메는 것이 한 자리다. + * + *

둘을 자리로 갈라 뒀었는데(BACK 200 / BAG 450) 각각 2개·3개뿐이라 탭을 나눌 만큼이 + * 아니었고, 사용자에게 "왜 가방 자리가 둘이지" 를 설명해야 했다. + * + *

대신 그리는 층을 아이템이 덮어쓴다. 자리 기본값은 450(옷 위)이고, + * 등에 메는 {@code back_backpack_*} 둘만 {@code cosmetic_item.layer_order} 에 200 을 직접 + * 갖고 있어 개구리 본체(300)보다 뒤에 깔린다. 자리 하나에 층이 둘인 것은 여기뿐이다. + */ BAG(450, "가방", true, true), NECK(500, "목", true, true), FACE(600, "얼굴", true, true), diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java index 49d537bc..b5a3fc99 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -337,7 +337,7 @@ private Map currentEquipped(Long userId, List "BACK".equals(slot.name())); } @Test @@ -279,24 +310,40 @@ void onlyFrameIsNotComposited() { } @Test - @DisplayName("등에 메는 가방과 앞으로 메는 가방이 갈려 있다") - void backAndBagAreSeparated() { - assertThat(keysOfSlot(CosmeticSlot.BACK)) - .as("V35 에서 BACK 은 그냥 '가방' 이었다. 뜻이 바뀌었으니 내용도 바뀌어야 한다") - .containsExactly("back_backpack_canvas", "back_backpack_navy"); + @DisplayName("가방 다섯 개가 한 자리에 모였다") + void everyBagIsInOneSlot() { assertThat(keysOfSlot(CosmeticSlot.BAG)) - .containsExactly("bag_crossbody_satchel", "bag_mini_backpack", "bag_waist_pouch"); + .as("등에 메는 둘과 앞으로 메는 셋이 같은 탭이다") + .containsExactly("back_backpack_canvas", "back_backpack_navy", + "bag_crossbody_satchel", "bag_mini_backpack", "bag_waist_pouch"); + } + + @Test + @DisplayName("등에 메는 가방 둘만 layer_order 200 을 갖고 나머지는 전부 NULL 이다") + void onlyBackpacksOverrideLayerOrder() { + assertThat(cosmeticItemRepository.findAll()) + .filteredOn(item -> item.getLayerOrder() != null) + .as("덮어쓰기가 늘면 그리는 순서를 자리 목록만으로 설명할 수 없게 된다") + .extracting(CosmeticItem::getItemKey, CosmeticItem::getLayerOrder) + .containsExactlyInAnyOrder( + tuple("back_backpack_navy", BACKPACK_LAYER_ORDER), + tuple("back_backpack_canvas", BACKPACK_LAYER_ORDER)); + + assertThat(BACKPACK_LAYER_ORDER) + .as("개구리 본체(300)보다 뒤여야 등에 멘 것으로 보인다") + .isLessThan(CosmeticSlot.BASE.getLayerOrder()); + assertThat(CosmeticSlot.BAG.getLayerOrder()) + .as("자리 기본값은 옷(400) 위다") + .isGreaterThan(CosmeticSlot.OUTFIT.getLayerOrder()); } @Test - @DisplayName("등에 메는 자리의 아이템은 배낭으로 부른다") - void backSlotItemsAreNamedBackpacks() { + @DisplayName("한 자리에 모였어도 이름이 등에 메는 것인지 말해 준다") + void namesTellBackpackFromFrontBag() { assertThat(nameOf("back_backpack_navy")).isEqualTo("남색 배낭"); assertThat(nameOf("back_backpack_canvas")).isEqualTo("캔버스 배낭"); - - assertThat(CosmeticSlot.BACK.getNameKo()).isEqualTo("배낭"); assertThat(nameOf("bag_mini_backpack")) - .as("앞으로 메는 쪽은 그대로 '백팩' 이다. 자리 이름(배낭/가방)과 짝이 맞아야 헷갈리지 않는다") + .as("자리가 하나가 됐으니 이름이 구분을 진다") .isEqualTo("미니 백팩"); assertThat(CosmeticSlot.BAG.getNameKo()).isEqualTo("가방"); } @@ -440,6 +487,80 @@ void setNameFollowsSetId() { .containsOnlyNulls(); } + @Test + @DisplayName("V41 은 키와 이름을 건드리지 않는다") + void bagMergeTouchesSlotAndLayerOnly() { + String merge = statementsOf(BAG_MERGE_MIGRATION); + + assertThat(merge) + .as("에셋 파일명과 해금표가 item_key 로 맞춰져 있다") + .doesNotContain("SET ITEM_KEY") + .doesNotContain("NAME_KO") + .doesNotContain("ALTER TABLE"); + assertThat(merge).contains("SET SLOT = 'BAG', LAYER_ORDER = 200"); + } + + @Test + @DisplayName("BACK 자리에 걸어 둔 행은 BAG 으로 옮겨진다 - 사용자가 실제로 고른 것이다") + void existingBackLoadoutRowMovesToBag() { + var user = fullyGrownUser(); + // BACK 은 이제 enum 에 없어 서비스로는 만들 수 없다. 옛 배포가 남긴 행을 직접 박아 넣는다. + insertRawLoadout(user.getId(), "BACK", "back_backpack_navy"); + + runMigration(BAG_MERGE_MIGRATION); + + assertThat(rawSlotsOf(user.getId())) + .as("BACK 은 CosmeticSlot 에 없는 값이라 남겨 두면 엔티티 매핑이 터진다") + .doesNotContain("BACK") + .contains("BAG"); + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.BAG)) + .as("지우면 배낭을 메고 있던 사용자가 이유 없이 맨등이 된다") + .isEqualTo("back_backpack_navy"); + assertThat(equippedOf(user.getId())) + .containsEntry(CosmeticSlot.BAG, "back_backpack_navy"); + } + + @Test + @DisplayName("BACK 과 BAG 을 둘 다 갖고 있으면 BAG 쪽이 남는다") + void bagRowWinsWhenBothExist() { + var user = fullyGrownUser(); + insertRawLoadout(user.getId(), "BACK", "back_backpack_navy"); + insertRawLoadout(user.getId(), "BAG", "bag_mini_backpack"); + + runMigration(BAG_MERGE_MIGRATION); + + assertThat(rawSlotsOf(user.getId())).doesNotContain("BACK"); + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.BAG)) + .as("(user_id, slot) 기본키라 하나만 남길 수 있다. 살아남는 자리의 행을 남긴다") + .isEqualTo("bag_mini_backpack"); + } + + @Test + @DisplayName("일부러 비워 둔 BACK 행도 그대로 옮긴다") + void emptyMarkerRowAlsoMoves() { + var user = fullyGrownUser(); + insertRawLoadout(user.getId(), "BACK", "__none__"); + + runMigration(BAG_MERGE_MIGRATION); + + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.BAG)) + .as("비워 둔 것도 사용자의 선택이다. 지우면 기본 프리셋이 되살아날 수 있다") + .isEqualTo("__none__"); + } + + @Test + @DisplayName("V41 을 두 번 돌려도 결과가 같다") + void bagMergeIsRerunnable() { + var user = fullyGrownUser(); + insertRawLoadout(user.getId(), "BACK", "back_backpack_canvas"); + + runMigration(BAG_MERGE_MIGRATION); + runMigration(BAG_MERGE_MIGRATION); + + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.BAG)).isEqualTo("back_backpack_canvas"); + assertThat(keysOfSlot(CosmeticSlot.BAG)).hasSize(5); + } + @Test @DisplayName("모든 슬롯 값이 CosmeticSlot 에 있는 이름이다") void slotsAreKnown() { @@ -459,6 +580,19 @@ private List catalog() { .toList(); } + /** 옛 배포가 남긴 행을 흉내 낸다. 지금은 enum 에 없는 슬롯이라 서비스로는 못 만든다. */ + private void insertRawLoadout(Long userId, String slot, String itemKey) { + jdbcTemplate.update(""" + INSERT INTO user_cosmetic_loadout (user_id, slot, item_key, updated_at) + VALUES (?, ?, ?, NOW(6)) + """, userId, slot, itemKey); + } + + private List rawSlotsOf(Long userId) { + return jdbcTemplate.queryForList( + "SELECT slot FROM user_cosmetic_loadout WHERE user_id = ?", String.class, userId); + } + private String nameOf(String itemKey) { return cosmeticItemRepository.findByItemKey(itemKey) .orElseThrow(() -> new IllegalStateException("시드에 없는 아이템이다: " + itemKey)) diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index 0411bd49..d2ef6f07 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -155,14 +155,14 @@ void slotsAreOrderedBackToFront() { List slots = cosmeticService.getCosmetics(user.getId()).slots(); assertThat(slots).extracting(CosmeticSlotDto::slot) + .as("가방은 자리 하나다. 등에 메는 것은 아이템이 층을 덮어써서 뒤로 간다") .containsExactly( - CosmeticSlot.BACKGROUND, CosmeticSlot.BACK, CosmeticSlot.OUTFIT, - CosmeticSlot.BAG, CosmeticSlot.NECK, CosmeticSlot.FACE, - CosmeticSlot.HEAD, CosmeticSlot.HAND, CosmeticSlot.BADGE, + CosmeticSlot.BACKGROUND, CosmeticSlot.OUTFIT, CosmeticSlot.BAG, + CosmeticSlot.NECK, CosmeticSlot.FACE, CosmeticSlot.HEAD, + CosmeticSlot.HAND, CosmeticSlot.BADGE, CosmeticSlot.EFFECT, CosmeticSlot.FRAME); assertThat(slots).extracting(CosmeticSlotDto::layerOrder) - .as("배낭(200)은 본체(300) 뒤, 앞가방(450)은 옷(400) 위다") - .containsExactly(100, 200, 400, 450, 500, 600, 700, 800, 850, 900, 1000); + .containsExactly(100, 400, 450, 500, 600, 700, 800, 850, 900, 1000); assertThat(slots.get(0).nameKo()).isEqualTo("배경"); } @@ -180,8 +180,8 @@ void onlyFrameIsNotComposited() { .containsExactly(CosmeticSlot.FRAME); assertThat(slots) .filteredOn(CosmeticSlotDto::composited) - .as("나머지 열 자리는 전부 개구리에 겹쳐 그린다") - .hasSize(10); + .as("나머지 자리는 전부 개구리에 겹쳐 그린다") + .hasSize(PRESET_SLOT_COUNT); } @Test @@ -319,7 +319,6 @@ void presetPicksHighestUnlockedPerSlot() { assertThat(equippedOf(user.getId())) .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) - .containsEntry(CosmeticSlot.BACK, BACK_BACKPACK_CANVAS) .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_GRADUATE) .containsEntry(CosmeticSlot.BAG, BAG_CROSSBODY_SATCHEL) .containsEntry(CosmeticSlot.NECK, NECK_MEDAL) @@ -404,7 +403,6 @@ void presetWithOnlyTotalLevel() { .doesNotContainKey(CosmeticSlot.FACE) .doesNotContainKey(CosmeticSlot.NECK) .doesNotContainKey(CosmeticSlot.BAG) - .doesNotContainKey(CosmeticSlot.BACK) .doesNotContainKey(CosmeticSlot.EFFECT); } @@ -492,11 +490,11 @@ void unequippingEverythingSticks() { void staleSlotRowIsHidden() { User user = fullyGrownUser(); cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BAG_MINI_BACKPACK); - // 카탈로그에서 미니 백팩을 배낭 자리로 옮긴다. 사용자 행은 여전히 BAG 을 가리킨다. - jdbcTemplate.update("UPDATE cosmetic_item SET slot = 'BACK' WHERE item_key = ?", BAG_MINI_BACKPACK); + // 카탈로그에서 미니 백팩을 손 자리로 옮긴다. 사용자 행은 여전히 BAG 을 가리킨다. + jdbcTemplate.update("UPDATE cosmetic_item SET slot = 'HAND' WHERE item_key = ?", BAG_MINI_BACKPACK); assertThat(equippedOf(user.getId())) - .as("앞가방 자리에 배낭을 그리면 개구리 앞에 가방이 떠 있게 된다") + .as("가방 자리에 손에 드는 것을 그리면 엉뚱한 층에 얹힌다") .doesNotContainEntry(CosmeticSlot.BAG, BAG_MINI_BACKPACK); } } @@ -517,17 +515,57 @@ void equipsOwnedItem() { } @Test - @DisplayName("배낭과 앞가방은 서로 다른 자리라 같이 걸린다") - void backAndBagCoexist() { + @DisplayName("등에 메는 가방과 앞으로 메는 가방은 같은 자리라 하나만 걸린다") + void backpackAndFrontBagShareOneSlot() { User user = fullyGrownUser(); - cosmeticService.equip(user.getId(), CosmeticSlot.BACK, BACK_BACKPACK_NAVY); + cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BACK_BACKPACK_NAVY); CosmeticEquipResponseDto response = cosmeticService.equip(user.getId(), CosmeticSlot.BAG, BAG_MINI_BACKPACK); assertThat(response.equipped()) - .containsEntry(CosmeticSlot.BACK, BACK_BACKPACK_NAVY) + .as("탭이 하나라 뒤에 건 것이 앞의 것을 덮어쓴다") .containsEntry(CosmeticSlot.BAG, BAG_MINI_BACKPACK); + + Long bagRows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_cosmetic_loadout WHERE user_id = ? AND slot = 'BAG'", + Long.class, user.getId()); + assertThat(bagRows).isEqualTo(1); + } + + @Test + @DisplayName("등에 메는 가방은 자리 층을 덮어써 개구리 뒤로 간다") + void backpackOverridesSlotLayerOrder() { + User user = fullyGrownUser(); + + List bagItems = cosmeticService.getCosmetics(user.getId()).items().stream() + .filter(item -> item.slot() == CosmeticSlot.BAG) + .toList(); + + assertThat(bagItems) + .filteredOn(item -> item.layerOrder() != null) + .as("등에 메는 둘만 층을 직접 갖는다. 자리 값(450)을 그대로 쓰면 개구리 앞으로 나온다") + .extracting(CosmeticItemResponseDto::itemKey, CosmeticItemResponseDto::layerOrder) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(BACK_BACKPACK_NAVY, BACKPACK_LAYER_ORDER), + org.assertj.core.groups.Tuple.tuple(BACK_BACKPACK_CANVAS, BACKPACK_LAYER_ORDER)); + assertThat(bagItems) + .filteredOn(item -> item.layerOrder() == null) + .as("앞으로 메는 셋은 자리 기본값 450 을 쓴다") + .extracting(CosmeticItemResponseDto::itemKey) + .containsExactlyInAnyOrder(BAG_MINI_BACKPACK, "bag_waist_pouch", BAG_CROSSBODY_SATCHEL); + } + + @Test + @DisplayName("가방 말고는 아무도 자리 층을 덮어쓰지 않는다") + void onlyBackpacksOverrideLayerOrder() { + User user = fullyGrownUser(); + + assertThat(cosmeticService.getCosmetics(user.getId()).items()) + .filteredOn(item -> item.layerOrder() != null) + .as("덮어쓰기가 늘면 프론트의 그리기 순서가 자리 목록만으로 설명되지 않는다") + .extracting(CosmeticItemResponseDto::itemKey) + .containsExactlyInAnyOrder(BACK_BACKPACK_NAVY, BACK_BACKPACK_CANVAS); } @Test @@ -612,10 +650,10 @@ void rejectsSlotMismatch() { .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); - assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.BACK, BAG_MINI_BACKPACK)) + assertThatThrownBy(() -> cosmeticService.equip(user.getId(), CosmeticSlot.HAND, BAG_MINI_BACKPACK)) .isInstanceOf(ApplicationException.class) .extracting(exception -> ((ApplicationException) exception).getErrorCase()) - .as("앞가방을 배낭 자리에 걸면 레이어가 뒤집힌다") + .as("가방을 손 자리에 걸면 엉뚱한 층에 얹힌다") .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); } diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java index cd116e8f..36c44e10 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticItemSeeder.java @@ -22,7 +22,7 @@ * 저절로 실행되지 않는데, 꾸미기 조회·장착은 카탈로그가 없으면 아무것도 하지 못하므로 * 시드가 없으면 이 도메인 테스트는 전부 무의미해진다. * - *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 INSERT 문을 그대로 읽어 실행한다. + *

시드 내용을 테스트에 다시 적지 않고 마이그레이션 파일의 문장을 그대로 읽어 실행한다. * 같은 값을 두 곳에 적으면 한쪽만 고쳐질 때 테스트가 프로덕션 시드와 다른 것을 검증하게 된다. * {@code MissionDefinitionSeeder} 와 같은 방식이다. * @@ -34,13 +34,15 @@ public class CosmeticItemSeeder { /** * 카탈로그를 손대는 마이그레이션을 새로 만들면 적용 순서대로 여기에 더한다. * - *

순서가 곧 Flyway 적용 순서다. V37 이 V35 의 행을 덮어쓰므로 뒤집으면 결과가 달라진다. + *

순서가 곧 Flyway 적용 순서다. 뒤의 파일이 앞의 파일 값을 덮어쓴다. + * V37 이 V35 의 행을, V39 가 이름을, V41 이 가방 자리와 그리는 층을 고친다. */ private static final List MIGRATION_PATHS = List.of( "db/migration/V35__seed_cosmetic_items.sql", "db/migration/V37__seed_cosmetic_items_by_ability.sql", "db/migration/V38__seed_profile_frame_cosmetics.sql", - "db/migration/V39__rename_back_slot_item_names.sql" + "db/migration/V39__rename_back_slot_item_names.sql", + "db/migration/V41__merge_back_slot_into_bag.sql" ); @PersistenceContext @@ -70,8 +72,11 @@ private List loadStatements(String path) { return Arrays.stream(withoutComments.split(";")) .map(String::trim) .filter(statement -> { + // DELETE 까지 받는다. 카탈로그를 손대는 마이그레이션이 행을 지우는 날 + // 그 문장만 조용히 빠지면 테스트가 프로덕션과 다른 카탈로그를 보게 된다. String upper = statement.toUpperCase(Locale.ROOT); - return upper.startsWith("INSERT") || upper.startsWith("UPDATE"); + return upper.startsWith("INSERT") || upper.startsWith("UPDATE") + || upper.startsWith("DELETE"); }) .toList(); } diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java index 4aabec4f..3f44f683 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/support/CosmeticTestSupport.java @@ -49,11 +49,11 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final String FRAME_SUMMER = "frame_summer"; /** (ATTENDANCE, 15) — 프로필 프레임 */ protected static final String FRAME_NIGHT = "frame_night"; - /** (NOTE_WRITE, 2) — 앞으로 메는 가방이라 BAG 이다 */ + /** (NOTE_WRITE, 2) — 앞으로 메는 가방. 자리 기본값 450 을 그대로 쓴다 */ protected static final String BAG_MINI_BACKPACK = "bag_mini_backpack"; - /** (NOTE_WRITE, 5) — 등에 메는 가방이라 BACK 이다 */ + /** (NOTE_WRITE, 5) — 등에 메는 가방. 자리는 BAG 이지만 layer_order 200 을 직접 갖는다 */ protected static final String BACK_BACKPACK_NAVY = "back_backpack_navy"; - /** (NOTE_WRITE, 9) */ + /** (NOTE_WRITE, 9) — 등에 메는 가방. layer_order 200 */ protected static final String BACK_BACKPACK_CANVAS = "back_backpack_canvas"; /** (NOTE_WRITE, 11) */ protected static final String BAG_CROSSBODY_SATCHEL = "bag_crossbody_satchel"; @@ -94,6 +94,9 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { /** (총 20) — 학사 세트 */ protected static final String PROP_DIPLOMA = "prop_diploma"; + /** 등에 메는 가방 둘이 자리 기본값(450)을 덮어쓰는 층. 개구리 본체(300)보다 뒤다. */ + protected static final int BACKPACK_LAYER_ORDER = 200; + protected static final String GRADUATE_SET = "graduate"; protected static final String GRADUATE_SET_NAME = "학사 세트"; @@ -101,7 +104,7 @@ public abstract class CosmeticTestSupport extends IntegrationTestSupport { protected static final int SEEDED_ITEM_COUNT = 63; /** 장착 가능한 자리 수. 응답의 slots 길이다. */ - protected static final int EQUIPPABLE_SLOT_COUNT = 11; + protected static final int EQUIPPABLE_SLOT_COUNT = 10; /** * 기본 프리셋이 채우는 자리 수. From b8f8013557a62cf173bad756f4b295d4cf77edd3 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 21:03:59 +0900 Subject: [PATCH 089/130] =?UTF-8?q?[Fix]=20=EB=8A=A5=EB=A0=A5=EC=B9=98=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=20=EC=83=81=ED=95=9C=EC=9D=84=2015=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=2020=20=EC=9C=BC=EB=A1=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 총 학습만 20 으로 올리고 능력치를 15 에 두면서 둘이 어긋났다. 능력치 넷을 다 채워도 4 x 1,050 = 4,200점이라 총 학습 15 에 필요한 누적과 같아, 그 위 구간은 능력치 넷이 화면상 Lv.15 에 멈춘 채 총 학습만 혼자 오르는 죽은 스탯창이 된다. 20 으로 올리면 4 x 1,900 = 7,600점이라 총 학습 20 에 필요한 누적과 정확히 맞는다 - 임계값 공식(능력치 10 x 레벨, 총 학습 40 x 레벨)은 그대로다 - UserMissionStatus 에 MAX_ABILITY_LEVEL 을 두고 능력치 레벨업 루프 넷에 상한 조건을 더했다. 지금까지 엔티티에는 상한이 아예 없어 내부 레벨은 무한정 오르고 응답에서만 잘렸다. 앱에 보이는 레벨과 실제 레벨이 갈라져 있었다. 총 학습과 같이 레벨만 멈추고 포인트는 계속 쌓는다 - 두 상수가 같은 값이어야 한다는 것을 주석이 아니라 코드로 묶었다. MAX_ABILITY_LEVEL 이 MAX_TOTAL_STUDY_LEVEL 을 직접 참조하고, UserResponseDto 의 천장 둘도 엔티티 상수를 그대로 가져다 쓴다. 숫자를 다시 적는 곳이 없다 - 치장 해금표는 그대로다. 능력치로 열리는 아이템은 여전히 Lv.15 가 마지막이고 16~20 은 보상 없이 게이지만 오르는 구간이다 --- .../mission/entity/UserMissionStatus.java | 24 +++++-- .../OnO/backend/user/dto/UserResponseDto.java | 21 ++++-- .../mission/entity/UserMissionStatusTest.java | 12 ++-- .../backend/user/dto/UserResponseDtoTest.java | 67 ++++++++++++++----- 4 files changed, 91 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java index da1bc777..d5ca6227 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/UserMissionStatus.java @@ -23,6 +23,21 @@ public class UserMissionStatus { */ public static final long MAX_TOTAL_STUDY_LEVEL = 20L; + /** + * 개별 능력치 레벨 상한. + * + *

{@link #MAX_TOTAL_STUDY_LEVEL} 과 반드시 같은 값이어야 해서 상수를 직접 참조한다. + * 총 학습 포인트는 능력치 넷이 받은 포인트의 합이고 총 학습 임계값은 능력치 임계값의 정확히 4배다. + * 그래서 능력치 넷을 레벨 N 까지 채웠을 때 쌓이는 총 학습 포인트 {@code 4 x 5N(N-1)} 과 + * 총 학습 N 레벨에 필요한 누적 {@code 20N(N-1)} 이 항상 같다. 한쪽만 움직이면 이 대응이 깨진다. + * 실제로 능력치 상한 15, 총 학습 상한 20 이던 동안에는 능력치 넷을 다 채워도(4 x 1,050 = 4,200점) + * 총 학습이 15 에서 멈췄다. 지금은 넷을 다 채우면 4 x 1,900 = 7,600 점이라 총 학습도 정확히 20 에 닿는다. + * + *

여기에 상한이 없으면 내부 레벨은 무한정 오르고 응답에서만 잘려, 앱에 보이는 레벨과 + * 실제 레벨이 갈린다. 총 학습과 같은 방식으로 레벨만 멈추고 포인트는 계속 쌓는다. + */ + public static final long MAX_ABILITY_LEVEL = MAX_TOTAL_STUDY_LEVEL; + // 데일리 출석 private Long attendanceLevel; private Long attendancePoint; @@ -48,7 +63,7 @@ public class UserMissionStatus { */ public void gainAttendancePoint(Long value) { this.attendancePoint += value; - while (this.attendancePoint >= getThresholdForLevel(attendanceLevel)) { + while (this.attendanceLevel < MAX_ABILITY_LEVEL && this.attendancePoint >= getThresholdForLevel(attendanceLevel)) { this.attendancePoint -= getThresholdForLevel(attendanceLevel); this.attendanceLevel += 1; } @@ -60,7 +75,7 @@ public void gainAttendancePoint(Long value) { */ public void gainNoteWritePoint(Long value) { this.noteWritePoint += value; - while (this.noteWritePoint >= getThresholdForLevel(noteWriteLevel)) { + while (this.noteWriteLevel < MAX_ABILITY_LEVEL && this.noteWritePoint >= getThresholdForLevel(noteWriteLevel)) { this.noteWritePoint -= getThresholdForLevel(noteWriteLevel); this.noteWriteLevel += 1; } @@ -72,7 +87,7 @@ public void gainNoteWritePoint(Long value) { */ public void gainProblemPracticePoint(Long value) { this.problemPracticePoint += value; - while (this.problemPracticePoint >= getThresholdForLevel(problemPracticeLevel)) { + while (this.problemPracticeLevel < MAX_ABILITY_LEVEL && this.problemPracticePoint >= getThresholdForLevel(problemPracticeLevel)) { this.problemPracticePoint -= getThresholdForLevel(problemPracticeLevel); this.problemPracticeLevel += 1; } @@ -84,7 +99,7 @@ public void gainProblemPracticePoint(Long value) { */ public void gainNotePracticePoint(Long value) { this.notePracticePoint += value; - while (this.notePracticePoint >= getThresholdForLevel(notePracticeLevel)) { + while (this.notePracticeLevel < MAX_ABILITY_LEVEL && this.notePracticePoint >= getThresholdForLevel(notePracticeLevel)) { this.notePracticePoint -= getThresholdForLevel(notePracticeLevel); this.notePracticeLevel += 1; } @@ -113,6 +128,7 @@ private void updateTotalStudyLevel(Long gainedPoints) { * - 레벨 1→2: 10 * - 레벨 2→3: 20 * - 레벨 14→15: 140 + * - 레벨 19→20: 190 — 상한. 1 에서 20 까지 누적은 1,900 이다 */ private Long getThresholdForLevel(Long level) { return 10 + (level - 1) * 10; diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index 9547ea99..a009c4fd 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -33,19 +33,26 @@ public record UserResponseDto ( /** * 화면에 보여줄 개별 능력치 레벨의 상한. * - *

개별 능력치는 도메인 상 상한이 없다. 게이지가 15 칸이라 표시만 눌러 보낸다. - * 치장 해금표의 능력치별 아이템도 레벨 15 가 마지막이라 이 값은 그대로 둔다. + *

15 로 두는 동안 도메인 쪽에는 상한이 아예 없어서, 내부 레벨은 무한정 오르고 여기서만 잘렸다. + * 앱에 보이는 레벨과 실제 레벨이 갈라진 것이다. 이제 도메인이 직접 멈추고 여기는 그 값을 따라간다. + * 숫자를 다시 적지 않고 {@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_ABILITY_LEVEL} + * 을 그대로 참조해, 한쪽만 고쳐서 다시 어긋나는 일이 없게 한다. + * + *

치장 해금표의 능력치별 아이템은 여전히 레벨 15 가 마지막이다. 16~20 은 해금 보상 없이 + * 게이지만 계속 오르는 구간이다. */ - private static final Long MAX_ABILITY_LEVEL = 15L; + private static final Long MAX_ABILITY_LEVEL = + com.aisip.OnO.backend.mission.entity.UserMissionStatus.MAX_ABILITY_LEVEL; /** * 화면에 보여줄 총 학습 레벨의 상한. * - *

{@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_TOTAL_STUDY_LEVEL} 과 같은 값이어야 한다. - * 여기가 15 에 머물면 레벨 16 이상인 사용자에게 앱은 계속 Lv.15 를 보여주고 게이지도 멈춘 것처럼 보이는데, - * 정작 치장은 총 학습 레벨 16·18·19·20 에서 계속 열린다. 필드 이름과 의미는 그대로고 천장만 함께 올린다. + *

{@link com.aisip.OnO.backend.mission.entity.UserMissionStatus#MAX_TOTAL_STUDY_LEVEL} 과 같은 값이어야 해서 + * 역시 그 상수를 직접 참조한다. 여기가 낮으면 그 위 레벨인 사용자에게 앱은 계속 낮은 Lv 를 보여주고 + * 게이지도 멈춘 것처럼 보이는데, 정작 치장은 총 학습 레벨 16·18·19·20 에서 계속 열린다. */ - private static final Long MAX_TOTAL_STUDY_LEVEL = 20L; + private static final Long MAX_TOTAL_STUDY_LEVEL = + com.aisip.OnO.backend.mission.entity.UserMissionStatus.MAX_TOTAL_STUDY_LEVEL; public static UserResponseDto from(@NotNull User user) { var missionStatus = user.getUserMissionStatus(); diff --git a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java index bc09899a..a2cbeda7 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java @@ -13,7 +13,7 @@ * *

공식이 두 개다. *

    - *
  • 개별 능력치: 레벨 n → n+1 에 {@code 10 + (n-1) * 10} 점 필요, 상한 없음
  • + *
  • 개별 능력치: 레벨 n → n+1 에 {@code 10 + (n-1) * 10} 점 필요, 레벨 20에서 멈춤
  • *
  • 총 학습 레벨: 개별 필요치의 4배(= 40 × 레벨), 레벨 20에서 멈춤
  • *
* 사용자에게 보이는 숫자라 한 칸만 어긋나도 바로 문의가 들어온다. @@ -254,14 +254,16 @@ void passesThroughTheOldCap() { } @Test - @DisplayName("개별 능력치는 총 학습 레벨과 달리 상한이 없다") - void abilityLevelHasNoCap() { + @DisplayName("개별 능력치도 총 학습과 같은 레벨에서 멈춘다") + void abilityLevelStopsAtTheSameCap() { UserMissionStatus status = newcomer(); status.gainAttendancePoint(100_000L); - assertThat(status.getAttendanceLevel()).isGreaterThan(20L); - assertThat(status.getTotalStudyLevel()).isEqualTo(20L); + assertThat(status.getAttendanceLevel()) + .as("상한이 없으면 내부 레벨만 무한정 올라 응답에서 잘린 레벨과 갈린다") + .isEqualTo(UserMissionStatus.MAX_ABILITY_LEVEL); + assertThat(status.getTotalStudyLevel()).isEqualTo(UserMissionStatus.MAX_TOTAL_STUDY_LEVEL); } } diff --git a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java index 701fecab..b9d63d0b 100644 --- a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.user.dto; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; import com.aisip.OnO.backend.user.entity.User; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -142,7 +143,7 @@ void pinsThresholdAtMaxTotalStudyLevel() { } @Test - @DisplayName("총 학습 16~20 은 그대로 나간다 - 능력치 상한 15 와 다른 값이다") + @DisplayName("총 학습 16~20 은 그대로 나간다") void totalStudyLevelIsNotCappedAtFifteen() { User user = user(); user.getUserMissionStatus().setTotalStudyLevel(18L, 120L); @@ -155,30 +156,62 @@ void totalStudyLevelIsNotCappedAtFifteen() { } @Test - @DisplayName("능력치는 15레벨을 넘겨도 15레벨 풀 게이지로 눌러서 응답한다") + @DisplayName("능력치 16~20 은 그대로 나간다 - 예전 상한 15 에서 멈추지 않는다") + void abilityLevelIsNotCappedAtFifteen() { + User user = user(); + user.getUserMissionStatus().setAttendanceLevel(16L, 30L); + user.getUserMissionStatus().setNoteWriteLevel(20L, 70L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.attendanceLevel()) + .as("15 에서 자르면 능력치 넷이 Lv.15 에 멈춘 채 총 학습만 혼자 오른다") + .isEqualTo(16L); + assertThat(response.attendancePoint()).isEqualTo(30L); + assertThat(response.noteWriteLevel()).isEqualTo(20L); + assertThat(response.noteWritePoint()).isEqualTo(70L); + } + + @Test + @DisplayName("능력치 상한은 도메인 상수를 그대로 따라간다") + void abilityCapFollowsDomainConstant() { + long max = UserMissionStatus.MAX_ABILITY_LEVEL; + User user = user(); + + user.getUserMissionStatus().setAttendanceLevel(max, 0L); + assertThat(UserResponseDto.from(user).attendanceLevel()) + .as("응답 쪽 상한이 도메인 상한보다 낮으면 실제 레벨과 보이는 레벨이 갈린다") + .isEqualTo(max); + + user.getUserMissionStatus().setAttendanceLevel(max + 1, 0L); + assertThat(UserResponseDto.from(user).attendanceLevel()) + .as("도메인 상한보다 높으면 해금표에 없는 레벨이 앱에 표시된다") + .isEqualTo(max); + } + + @Test + @DisplayName("상한을 넘긴 능력치는 상한 레벨 풀 게이지로 눌러서 응답한다") void capsAbilityStatusOverMaxLevel() { User user = user(); - user.getUserMissionStatus().setAttendanceLevel(16L, 10L); - user.getUserMissionStatus().setNoteWriteLevel(17L, 20L); - user.getUserMissionStatus().setProblemPracticeLevel(18L, 30L); - user.getUserMissionStatus().setNotePracticeLevel(19L, 40L); + user.getUserMissionStatus().setAttendanceLevel(21L, 10L); + user.getUserMissionStatus().setNoteWriteLevel(22L, 20L); + user.getUserMissionStatus().setProblemPracticeLevel(23L, 30L); + user.getUserMissionStatus().setNotePracticeLevel(24L, 40L); user.getUserMissionStatus().setTotalStudyLevel(21L, 50L); UserResponseDto response = UserResponseDto.from(user); assertThat(response.attendanceLevel()) - .as("능력치 게이지는 15칸이고 치장 해금표도 능력치 15 가 마지막이다") - .isEqualTo(15L); - assertThat(response.attendancePoint()).isEqualTo(150L); - assertThat(response.noteWriteLevel()).isEqualTo(15L); - assertThat(response.noteWritePoint()).isEqualTo(150L); - assertThat(response.problemPracticeLevel()).isEqualTo(15L); - assertThat(response.problemPracticePoint()).isEqualTo(150L); - assertThat(response.notePracticeLevel()).isEqualTo(15L); - assertThat(response.notePracticePoint()).isEqualTo(150L); - assertThat(response.totalStudyLevel()) - .as("총 학습만 상한이 20 이다") + .as("상한을 올리기 전에 이미 21레벨을 넘겨 둔 사용자가 있을 수 있다") .isEqualTo(20L); + assertThat(response.attendancePoint()).isEqualTo(200L); + assertThat(response.noteWriteLevel()).isEqualTo(20L); + assertThat(response.noteWritePoint()).isEqualTo(200L); + assertThat(response.problemPracticeLevel()).isEqualTo(20L); + assertThat(response.problemPracticePoint()).isEqualTo(200L); + assertThat(response.notePracticeLevel()).isEqualTo(20L); + assertThat(response.notePracticePoint()).isEqualTo(200L); + assertThat(response.totalStudyLevel()).isEqualTo(20L); assertThat(response.totalStudyCurrentPoint()) .as("게이지가 넘치지 않도록 20레벨 만점으로 눌러 보낸다") .isEqualTo(800L); From 8c132f09b4aa59315495ac5d7f515124470f1c02 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 21:04:29 +0900 Subject: [PATCH 090/130] =?UTF-8?q?[Fix]=20=EC=83=81=ED=95=9C=20=EB=A0=88?= =?UTF-8?q?=EB=B2=A8=EC=9D=98=20=EC=9E=94=EC=97=AC=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EA=B2=8C=EC=9D=B4=EC=A7=80=20=EB=B6=84?= =?UTF-8?q?=EB=AA=A8=20=EC=95=88=EC=9C=BC=EB=A1=9C=20=EB=88=84=EB=A5=B8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상한에 닿으면 레벨은 멈추고 포인트는 계속 쌓인다. 그 잔여 포인트를 그대로 내보내고 있었다 - 앱은 능력치 게이지의 분모를 10 + (level - 1) * 10 으로 직접 계산한다 (OnO_FRONT/lib/Screen/Character/Widget/AbilityStatPanel.dart:198). 만렙 사용자의 포인트가 200 을 넘는 순간 게이지가 한 칸을 넘어간다 - 총 학습도 같은 구멍이 있었다. level > 상한 일 때만 눌렀는데, 엔티티가 상한에서 레벨을 멈추므로 level > 상한 은 이제 생기지 않고 level == 상한 에서 포인트만 계속 커진다 - 상한 미만에서는 잔여 포인트가 항상 임계값보다 작아 동작이 바뀌지 않는다. DB 에 쌓인 포인트 자체는 건드리지 않는다 - 응답에서만 누른다 --- .../OnO/backend/user/dto/UserResponseDto.java | 6 ++- .../backend/user/dto/UserResponseDtoTest.java | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java index a009c4fd..b9e14e78 100644 --- a/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/user/dto/UserResponseDto.java @@ -91,7 +91,9 @@ private static Long getResponsePoint(Long level, Long point) { if (level > MAX_ABILITY_LEVEL) { return getThresholdForLevel(MAX_ABILITY_LEVEL); } - return point; + // 상한에 닿은 뒤에도 포인트는 계속 쌓인다. 그대로 내보내면 게이지 분모를 넘겨 칸이 넘친다. + // 상한 미만에서는 잔여 포인트가 항상 임계값보다 작아 이 클램프가 걸리지 않는다. + return Math.min(point, getThresholdForLevel(level)); } private static Long getTotalStudyResponseLevel(Long level) { @@ -105,7 +107,7 @@ private static Long getTotalStudyResponsePoint(Long level, Long point) { if (level > MAX_TOTAL_STUDY_LEVEL) { return getTotalStudyThresholdForLevel(MAX_TOTAL_STUDY_LEVEL); } - return point; + return Math.min(point, getTotalStudyThresholdForLevel(level)); } private static Long getTotalStudyNextLevelThreshold(com.aisip.OnO.backend.mission.entity.UserMissionStatus status) { diff --git a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java index b9d63d0b..df828de0 100644 --- a/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/dto/UserResponseDtoTest.java @@ -217,5 +217,45 @@ void capsAbilityStatusOverMaxLevel() { .isEqualTo(800L); assertThat(response.totalStudyNextLevelThreshold()).isEqualTo(800L); } + + /** + * 상한에 닿아도 포인트는 계속 쌓인다. 앱은 능력치 게이지의 분모를 + * {@code 10 + (level - 1) * 10} 으로 직접 계산하므로 + * (OnO_FRONT/lib/Screen/Character/Widget/AbilityStatPanel.dart:198), + * 쌓인 잔여 포인트를 그대로 내보내면 게이지가 한 칸을 넘어간다. + */ + @Test + @DisplayName("상한 레벨에 쌓인 잔여 포인트는 게이지 분모를 넘기지 않는다") + void clampsLeftoverPointAtMaxLevel() { + User user = user(); + user.getUserMissionStatus().setAttendanceLevel(20L, 10_000L); + user.getUserMissionStatus().setTotalStudyLevel(20L, 10_000L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.attendanceLevel()).isEqualTo(20L); + assertThat(response.attendancePoint()) + .as("20레벨 게이지의 분모는 10 + 19 x 10 = 200 이다") + .isEqualTo(200L); + assertThat(response.totalStudyLevel()).isEqualTo(20L); + assertThat(response.totalStudyCurrentPoint()) + .as("총 학습 20레벨 게이지의 분모는 200 x 4 = 800 이다") + .isEqualTo(800L); + } + + @Test + @DisplayName("상한 미만에서는 잔여 포인트를 그대로 내보낸다") + void keepsLeftoverPointBelowMaxLevel() { + User user = user(); + user.getUserMissionStatus().setAttendanceLevel(19L, 189L); + user.getUserMissionStatus().setTotalStudyLevel(19L, 759L); + + UserResponseDto response = UserResponseDto.from(user); + + assertThat(response.attendancePoint()) + .as("19레벨 분모는 190 이라 189 는 그대로 나가야 한다") + .isEqualTo(189L); + assertThat(response.totalStudyCurrentPoint()).isEqualTo(759L); + } } } From 647ad9811ec4be26382f2efc2fb1439b7fc62b9e Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 12 Sep 2026 21:04:49 +0900 Subject: [PATCH 091/130] =?UTF-8?q?[Test]=20=EB=8A=A5=EB=A0=A5=EC=B9=98=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EA=B3=BC=20=EC=B4=9D=20=ED=95=99=EC=8A=B5=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EC=9D=98=20=EB=8C=80=EC=9D=91=EC=9D=84=20?= =?UTF-8?q?=EB=AA=BB=20=EB=B0=95=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이번에 어긋난 이유는 "능력치 넷을 다 채우면 총 학습이 몇이 되는가" 를 아무도 검증하지 않아서다. 그 관계 자체를 테스트로 고정한다 - 능력치 넷에 레벨 L 마다 딱 10L 점씩만 넣어 한 점도 남기지 않고 만렙까지 올린 뒤, 총 학습이 정확히 만렙이고 잔여 포인트가 0 인지 본다. 모자라도 남아도 두 상한이 어긋난 것이다. 상한을 15 로 되돌리면 총 학습이 15 에서 멈춰 이 테스트가 깨진다 - 상한 숫자를 테스트에 다시 적지 않고 UserMissionStatus 의 상수를 참조한다 - 만렙에서 레벨이 멈추고 포인트만 쌓이는지, 넷 중 하나만 만렙이면 총 학습은 아직 만렙이 아닌지, 5N(N-1) x 4 = 20N(N-1) 이 실제로 1,900 x 4 = 7,600 인지도 함께 고정한다 --- .../mission/entity/UserMissionStatusTest.java | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) diff --git a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java index a2cbeda7..88fe79e4 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/entity/UserMissionStatusTest.java @@ -17,6 +17,8 @@ *
  • 총 학습 레벨: 개별 필요치의 4배(= 40 × 레벨), 레벨 20에서 멈춤
  • * * 사용자에게 보이는 숫자라 한 칸만 어긋나도 바로 문의가 들어온다. + * + *

    두 상한은 서로 맞물려 있다. {@link CapAlignment} 를 참고. */ @DisplayName("UserMissionStatus") class UserMissionStatusTest { @@ -267,6 +269,125 @@ void abilityLevelStopsAtTheSameCap() { } } + /** + * 능력치 상한과 총 학습 상한의 대응. + * + *

    이번에 어긋난 자리다. 총 학습만 15 에서 20 으로 올리고 능력치는 15 에 뒀더니, + * 능력치 넷을 다 채워도(4 x 1,050 = 4,200점) 총 학습은 15 에서 멈췄다. + * 그 위 구간은 능력치 넷이 Lv.15 에 멈춘 채로 총 학습만 혼자 오르는, 스탯창이 죽은 화면이었다. + * + *

    "능력치 넷을 다 채우면 총 학습이 몇이 되는가" 를 아무도 검증하지 않아서 그냥 지나갔다. + * 여기서 그 관계를 못 박는다. + */ + @Nested + @DisplayName("능력치 상한과 총 학습 상한의 대응") + class CapAlignment { + + @Test + @DisplayName("두 상한은 같은 값이다") + void bothCapsAreTheSameNumber() { + assertThat(UserMissionStatus.MAX_ABILITY_LEVEL) + .as("한쪽만 움직이면 능력치 만렙과 총 학습 만렙이 서로 다른 지점이 된다") + .isEqualTo(UserMissionStatus.MAX_TOTAL_STUDY_LEVEL); + } + + @Test + @DisplayName("능력치 넷을 한 단계씩 만렙까지 올리면 총 학습도 정확히 만렙에서 끝난다") + void fourMaxedAbilitiesLandExactlyOnTotalStudyCap() { + UserMissionStatus status = newcomer(); + + // 레벨 L → L+1 에 필요한 10L 점씩만 넣는다. 한 점도 남기지 않고 딱 만렙까지 올린다. + for (long level = 1; level < UserMissionStatus.MAX_ABILITY_LEVEL; level++) { + long needed = 10 * level; + status.gainAttendancePoint(needed); + status.gainNoteWritePoint(needed); + status.gainProblemPracticePoint(needed); + status.gainNotePracticePoint(needed); + } + + assertThat(status.getAttendanceLevel()).isEqualTo(UserMissionStatus.MAX_ABILITY_LEVEL); + assertThat(status.getNoteWriteLevel()).isEqualTo(UserMissionStatus.MAX_ABILITY_LEVEL); + assertThat(status.getProblemPracticeLevel()).isEqualTo(UserMissionStatus.MAX_ABILITY_LEVEL); + assertThat(status.getNotePracticeLevel()).isEqualTo(UserMissionStatus.MAX_ABILITY_LEVEL); + assertThat(status.getAttendancePoint()).isZero(); + assertThat(status.getNoteWritePoint()).isZero(); + assertThat(status.getProblemPracticePoint()).isZero(); + assertThat(status.getNotePracticePoint()).isZero(); + + assertThat(status.getTotalStudyLevel()) + .as("능력치 넷이 다 만렙인데 총 학습이 그 아래면 스탯창이 죽은 것처럼 보인다") + .isEqualTo(UserMissionStatus.MAX_TOTAL_STUDY_LEVEL); + assertThat(status.getTotalStudyPoint()) + .as("모자라도 남아도 두 상한이 어긋난 것이다") + .isZero(); + } + + @Test + @DisplayName("능력치 만렙까지 필요한 경험치 넷 몫이 총 학습 만렙까지 필요한 경험치와 같다") + void requiredPointsMatchAcrossBothCaps() { + long n = UserMissionStatus.MAX_ABILITY_LEVEL; + + // 능력치 1 → N 누적: sum(10L, L=1..N-1) = 5N(N-1). N=20 이면 1,900 + long perAbility = 5 * n * (n - 1); + // 총 학습 1 → N 누적: sum(40L, L=1..N-1) = 20N(N-1). N=20 이면 7,600 + long totalStudy = 20 * n * (n - 1); + + assertThat(perAbility).isEqualTo(1_900L); + assertThat(totalStudy).isEqualTo(7_600L); + assertThat(perAbility * 4) + .as("총 학습 임계값이 능력치 임계값의 4배라 넷 몫과 정확히 맞아떨어져야 한다") + .isEqualTo(totalStudy); + } + + @Test + @DisplayName("만렙에 닿으면 레벨은 멈추고 포인트만 쌓인다") + void keepsAccumulatingPointsAfterCap() { + UserMissionStatus status = newcomer(); + + status.gainAttendancePoint(1_900L); + assertThat(status.getAttendanceLevel()) + .as("1 → 20 에 필요한 누적은 5 x 20 x 19 = 1,900 이다") + .isEqualTo(20L); + assertThat(status.getAttendancePoint()).isZero(); + + status.gainAttendancePoint(10_000L); + + assertThat(status.getAttendanceLevel()) + .as("상한을 넘겨 21레벨이 되면 해금표에 없는 레벨이 표시된다") + .isEqualTo(20L); + assertThat(status.getAttendancePoint()) + .as("상한 이후 경험치는 사라지지 않고 그대로 누적된다") + .isEqualTo(10_000L); + } + + @Test + @DisplayName("상한이 15 였다면 멈췄을 자리를 능력치도 그대로 지나간다") + void abilityPassesThroughTheOldCap() { + UserMissionStatus status = newcomer(); + + // 15 → 20 에 능력치가 더 받아야 하는 양은 10 x (15+16+17+18+19) = 850 이다. + status.gainAttendancePoint(1_050L + 850L); + + assertThat(status.getAttendanceLevel()) + .as("여기서 15 에 멈추면 능력치 넷을 다 채워도 총 학습이 15 에서 멈춘다") + .isEqualTo(20L); + assertThat(status.getAttendancePoint()).isZero(); + } + + @Test + @DisplayName("넷 중 하나만 만렙이면 총 학습은 아직 만렙이 아니다") + void oneMaxedAbilityIsNotEnough() { + UserMissionStatus status = newcomer(); + + status.gainAttendancePoint(1_900L); + + assertThat(status.getAttendanceLevel()).isEqualTo(20L); + assertThat(status.getTotalStudyLevel()) + .as("한 능력치만으로 총 학습이 만렙이 되면 나머지 셋이 의미를 잃는다") + .isLessThan(UserMissionStatus.MAX_TOTAL_STUDY_LEVEL); + } + } + @Nested @DisplayName("관리자 수동 설정") class ManualOverride { From 7f853731976c4e318fc15e3cecfa0fcb41d549fb Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 13 Sep 2026 23:29:50 +0900 Subject: [PATCH 092/130] =?UTF-8?q?[Feat]=20=EC=B0=A8=EB=A6=BC=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=ED=95=9C=20=EB=B2=88=EC=97=90=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=98=EB=8A=94=20PUT=20/api/cosmetics/equip-all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 꾸미기 화면은 시착 방식이라 저장 한 번에 여러 슬롯이 한꺼번에 바뀐다. 그런데 서버에는 슬롯 하나씩 거는 equip 과 세트 한 벌짜리 equip-set 뿐이라 그 저장을 담을 API 가 없었다. 프론트가 슬롯 수만큼 equip 을 부르면 중간에 끊겼을 때 모자만 바뀌고 옷은 안 바뀐 반쪽 차림이 서버에 남는다. 사용자는 저장을 한 번 눌렀을 뿐이라 다시 들어왔을 때 무엇이 저장된 것인지 모른다 - 전체 교체다. 요청에 없는 슬롯은 비운다. "안 보낸 것은 그대로 둔다" 로 하면 시착 화면에서 벗어 놓고 저장한 것을 표현할 방법이 없어진다. 빈 맵은 전부 벗으라는 뜻의 유효한 요청이라 @NotEmpty 가 아니라 @NotNull 이다 - 검증은 equip 과 같은 resolveEquipTarget 을 그대로 탄다. 없는 키, 비활성, 잠김, 슬롯 어긋남이 한 건이라도 걸리면 쓰기로 내려가지 않고 통째로 거절한다. 절반만 저장되는 경로를 만들지 않는다 - 한 요청 안에서 충돌하는 둘이 함께 들어오면 뒤에 깔리는 자리(layerOrder 가 작은 쪽)를 남긴다. 맵이나 JSON 키 순서에 맡기면 같은 본문인데 조회할 때마다 다른 것이 걸려 보인다. 층을 기준으로 삼은 것은 충돌 조합이 대개 "옷과 그 위에 얹히는 것" 이라, 뒤를 남기면 모자 하나가 빠지지만 앞을 남기면 모자만 쓴 벗은 개구리가 되기 때문이다. 벗겨진 자리는 unequippedSlots 로 나간다 - 양쪽 충돌 판정을 conflictsEachOther 로 뽑아 한 슬롯씩 거는 경로와 같은 규칙을 쓰게 했다. 둘로 나눠 적으면 한쪽만 고쳐질 때 같은 조합이 경로에 따라 다르게 걸린다 - 전체 교체는 걸 수 있는 자리를 전부 쓰므로 materializePresetIfUntouched 를 타지 않는다. 프리셋을 먼저 깔면 방금 벗어 놓고 저장한 것을 되살렸다가 다시 벗는 셈이다 - equip 과 equip-set 은 그대로다. 옷장 격자에서 한 칸만 눌러 바로 거는 경로가 따로 있다. 테이블 구조도 그대로라 마이그레이션은 없다 - 전체 교체·빈 맵·한 건 거절 시 기존 차림 유지·슬롯 어긋남·충돌 해제·요청 순서 무관·멱등· 남의 차림 불변·본문 userId 무시를 서비스 12건, 컨트롤러 8건으로 잠갔다 --- .../controller/CosmeticController.java | 15 ++ .../dto/CosmeticEquipAllRequestDto.java | 28 +++ .../cosmetic/service/CosmeticService.java | 113 ++++++++- .../controller/CosmeticControllerTest.java | 137 +++++++++++ .../cosmetic/service/CosmeticServiceTest.java | 214 ++++++++++++++++++ 5 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java b/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java index 2ff1163d..dca200c4 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticController.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.cosmetic.controller; import com.aisip.OnO.backend.common.response.CommonResponse; +import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipAllRequestDto; import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipRequestDto; import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipResponseDto; import com.aisip.OnO.backend.cosmetic.dto.CosmeticEquipSetRequestDto; @@ -48,6 +49,20 @@ public CommonResponse equip(@Valid @RequestBody Cosmet return CommonResponse.success(cosmeticService.equip(userId, request.slot(), request.itemKey())); } + /** + * 차림 전체 저장. 꾸미기 화면의 저장 버튼 한 번이 이 요청 하나다. + * + *

    전체 교체라 본문에 없는 슬롯은 비운다. 빈 맵이면 전부 벗는다. + * 한 건이라도 검증에 걸리면 한 트랜잭션이 통째로 거절돼 반쪽 차림이 남지 않는다. + * + *

    같은 본문을 두 번 보내도 결과가 같은 멱등 연산이라 POST 가 아니라 PUT 이다. + */ + @PutMapping("/equip-all") + public CommonResponse equipAll(@Valid @RequestBody CosmeticEquipAllRequestDto request) { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(cosmeticService.equipAll(userId, request.equipped())); + } + @PutMapping("/equip-set") public CommonResponse equipSet(@Valid @RequestBody CosmeticEquipSetRequestDto request) { Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java new file mode 100644 index 00000000..b863f79c --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/dto/CosmeticEquipAllRequestDto.java @@ -0,0 +1,28 @@ +package com.aisip.OnO.backend.cosmetic.dto; + +import com.aisip.OnO.backend.cosmetic.entity.CosmeticSlot; +import jakarta.validation.constraints.NotNull; + +import java.util.Map; + +/** + * 차림 전체 저장 요청. 꾸미기 화면의 저장 버튼 한 번이 이 본문 하나로 나간다. + * + *

    {@code equipped} 는 바뀐 슬롯이 아니라 저장할 차림 전부다. 여기 없는 슬롯은 비운다. + * "안 보낸 것은 그대로 둔다" 로 하면 시착 화면에서 벗어 놓고 저장한 것을 표현할 방법이 없어진다. + * + *

    그래서 빈 맵은 유효한 요청이다. 전부 벗는다는 뜻이라 {@code @NotEmpty} 가 아니라 + * {@code @NotNull} 이다. 필드 자체가 빠진 본문(맵이 null)은 "전부 벗기" 인지 "보내다 만 것" 인지 + * 구별할 수 없어 거절한다. + * + *

    값이 {@code null} 인 항목은 그 슬롯을 비우라는 뜻으로, 키를 아예 빼고 보낸 것과 같게 다룬다. + * 프론트가 빈 자리를 {@code null} 로 채워 보내든 빼고 보내든 같은 결과가 되어야 한다. + * + *

    사용자 식별자는 여기에 담지 않는다. 본문으로 받으면 남의 차림을 바꾸는 요청을 그대로 받아들이게 + * 된다. 다른 꾸미기 API 와 같이 인증 컨텍스트에서만 꺼낸다. + */ +public record CosmeticEquipAllRequestDto( + @NotNull(message = "장착 상태를 지정해야 합니다.") + Map equipped +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java index b5a3fc99..b58c20ed 100644 --- a/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java +++ b/src/main/java/com/aisip/OnO/backend/cosmetic/service/CosmeticService.java @@ -130,6 +130,104 @@ public CosmeticEquipResponseDto equip(Long userId, CosmeticSlot slot, String ite return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); } + /** + * 차림 전체를 한 번에 바꾼다. 꾸미기 화면의 저장 버튼이 이 경로다. + * + *

    전체 교체다. 요청에 없는 슬롯은 비운다. 저장 버튼은 "지금 이 차림이 내 차림이다" 라는 + * 선언이라 서버도 그대로 받는다. "안 보낸 것은 그대로 둔다" 로 하면 시착 화면에서 벗어 놓고 + * 저장한 것을 표현할 방법이 없어진다. + * + *

    한 건이라도 검증에 걸리면 전부 거절한다. 프론트가 슬롯 수만큼 {@link #equip} 을 부르면 + * 중간에 끊겼을 때 모자만 바뀌고 옷은 안 바뀐 반쪽 차림이 남는데, 사용자는 저장을 한 번 눌렀을 뿐이라 + * 다시 들어왔을 때 무엇이 저장된 것인지 알 수 없다. 그 상태를 아예 만들지 않으려고 이 API 가 있다. + * + *

    {@link #equip} 과 {@link #equipSet} 은 그대로 둔다. 옷장 격자에서 한 칸만 눌러 바로 거는 + * 경로가 따로 있다. + */ + @Transactional + public CosmeticEquipResponseDto equipAll(Long userId, Map requested) { + CosmeticUnlockLevels levels = levelsOf(userId); + List activeItems = cosmeticItemRepository.findAllByActiveTrue(); + Map itemsByKey = indexByKey(activeItems); + + // 검사를 먼저 전부 끝낸다. equipSet 과 같은 이유로, 쓰다가 중간에 거절하면 앞의 몇 개만 걸린 + // 채로 롤백을 믿어야 한다. 판정은 equip 이 쓰는 것과 같은 규칙을 그대로 탄다. + // 없는 키·비활성·잠김·슬롯 어긋남이 여기서 걸리면 아래 쓰기로 내려가지 않는다. + List targets = new ArrayList<>(); + for (Map.Entry entry : requested.entrySet()) { + CosmeticItem item = resolveEquipTarget(entry.getKey(), entry.getValue(), itemsByKey, levels); + // null 은 "그 자리를 비운다" 다. 키를 아예 빼고 보낸 것과 같게 다룬다. + if (item != null) { + targets.add(item); + } + } + + List unequipped = new ArrayList<>(); + List kept = resolveInRequestConflicts(targets, unequipped); + + // 전체 교체라 걸 수 있는 자리를 하나도 빠짐없이 쓴다. 요청에 없던 자리를 NONE 으로 덮어야 + // "지금 이 차림이 전부" 가 된다. + // + // 여기에는 materializePresetIfUntouched 가 없다. 프리셋을 굳히는 이유는 첫 변경 때 나머지 + // 자리가 통째로 사라지는 것을 막으려는 것인데, 전체 교체는 그 나머지 자리까지 사용자가 정한 + // 값으로 직접 채운다. 프리셋을 먼저 깔면 방금 벗어 놓고 저장한 것을 되살렸다가 다시 벗는 셈이다. + // 이 요청 하나로 모든 자리에 행이 생기므로 다음 조회에서 프리셋이 되살아날 여지도 없다. + Map writes = orderedWrites(); + CosmeticSlot.equippableSlots().forEach(slot -> writes.put(slot, UserCosmeticLoadout.NONE)); + kept.forEach(item -> writes.put(item.getSlot(), item.getItemKey())); + + applyWrites(userId, writes); + + log.info("userId: {} equipped all: {}, unequipped: {}", + userId, kept.stream().map(CosmeticItem::getItemKey).toList(), unequipped); + + return new CosmeticEquipResponseDto(currentEquipped(userId, activeItems, levels), unequipped); + } + + /** + * 한 요청 안에서 서로 충돌하는 둘이 함께 들어왔을 때 어느 쪽을 남길지 가른다. + * + *

    프론트도 {@code conflictsWith} 를 보지만 같이 걸 수 없는 조합이 그대로 담겨 들어올 수 있다. + * 받은 차림을 그대로 저장하지 않고 서버가 마지막으로 한 번 더 본다. + * + *

    뒤에 깔리는 자리(layerOrder 가 작은 쪽)를 남기고 앞의 것을 벗긴다. 맵에 담긴 순서나 + * JSON 키 순서에 맡기면 같은 본문을 두 번 보냈을 때 다른 것이 걸릴 수 있고, 사용자는 조회할 때마다 + * 걸려 있는 것이 달라 보인다. + * + *

    층 순서를 기준으로 삼은 이유는 충돌이 잡히는 조합이 대개 "옷과 그 위에 얹히는 것" 이기 + * 때문이다. 뒤를 남기면 옷 입은 개구리에서 모자 하나가 빠지고, 앞을 남기면 모자만 쓴 벗은 개구리가 + * 된다. 잃는 것이 작은 쪽을 고른다. + * + *

    지금은 자리마다 층이 겹치지 않지만 자리 이름으로 한 번 더 묶는다. 나중에 같은 층의 자리가 + * 생겨도 결과가 흔들리지 않는다. + * + *

    여기서 보는 것은 요청 안의 충돌뿐이다. 지금 걸려 있는 것과의 충돌은 볼 필요가 없다. + * 전체 교체라 걸려 있던 것은 모두 이 요청의 결과로 덮이기 때문이다. + * + * @param unequipped 벗겨진 자리를 담아 갈 목록. 프론트가 "가디건이 벗겨졌어요" 를 띄우는 데 쓴다. + */ + private List resolveInRequestConflicts(List targets, + List unequipped) { + List ordered = targets.stream() + .sorted(Comparator + .comparingInt((CosmeticItem item) -> item.getSlot().getLayerOrder()) + .thenComparing(item -> item.getSlot().name())) + .toList(); + + List kept = new ArrayList<>(); + for (CosmeticItem candidate : ordered) { + if (kept.stream().anyMatch(accepted -> conflictsEachOther(accepted, candidate))) { + unequipped.add(candidate.getSlot()); + continue; + } + kept.add(candidate); + } + + // 응답에 실리는 순서를 자리 이름으로 고정한다. conflictingSlots 가 쓰는 순서와 같다. + unequipped.sort(Comparator.comparing(Enum::name)); + return kept; + } + /** * 세트 한 번에 장착. * @@ -294,8 +392,7 @@ private List conflictingSlots(Long userId, CosmeticItem item, Map< if (equipped == null) { continue; } - if (item.conflictKeys().contains(equipped.getItemKey()) - || equipped.conflictKeys().contains(item.getItemKey())) { + if (conflictsEachOther(item, equipped)) { conflicting.add(row.getSlot()); } } @@ -303,6 +400,18 @@ private List conflictingSlots(Long userId, CosmeticItem item, Map< return conflicting; } + /** + * 둘을 같이 걸 수 없는지. + * + *

    충돌은 양쪽 모두 본다. 근거는 {@link #conflictingSlots} 에 있다. + * 한 슬롯씩 거는 경로와 차림 전체를 저장하는 경로가 같은 판정을 써야 해서 여기로 뽑았다. + * 둘로 나눠 적으면 한쪽만 고쳐질 때 같은 조합이 경로에 따라 다르게 걸린다. + */ + private boolean conflictsEachOther(CosmeticItem one, CosmeticItem other) { + return one.conflictKeys().contains(other.getItemKey()) + || other.conflictKeys().contains(one.getItemKey()); + } + /** 이 아이템을 충돌 대상으로 지목한 아이템이 하나라도 있는지. 없으면 장착 행을 읽지 않고 끝낸다. */ private boolean anyoneConflictsWith(CosmeticItem item, Map itemsByKey) { return itemsByKey.values().stream() diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java index 91be1d41..b5aa16b0 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticControllerTest.java @@ -313,6 +313,143 @@ void rejectsPartiallyOwnedSet() throws Exception { .value(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED.getErrorCode())); } + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 받은 차림으로 통째로 바뀌고 나머지 자리는 비워진다") + void equipAll() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"HEAD\":\"" + HAT_BEANIE + "\"," + + "\"OUTFIT\":\"" + OUTFIT_CARDIGAN + "\"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped.HEAD").value(HAT_BEANIE)) + .andExpect(jsonPath("$.data.equipped.OUTFIT").value(OUTFIT_CARDIGAN)) + .andExpect(jsonPath("$.data.equipped.BACKGROUND") + .doesNotExist()) + .andExpect(jsonPath("$.data.unequippedSlots").isArray()) + .andExpect(jsonPath("$.data.unequippedSlots.length()").value(0)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 빈 맵은 전부 벗기라는 뜻이라 200 이다") + void equipAllWithEmptyMap() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.equipped").value(Matchers.anEmptyMap())); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - equipped 가 빠지면 400 이다") + void equipAllRejectsMissingEquipped() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 잠긴 아이템이 섞이면 400 이고 아무것도 저장되지 않는다") + void equipAllRejectsLockedItem() throws Exception { + // 총 학습은 20 이지만 문제 복습이 3 이라 비니(문제 복습 4)는 잠겨 있다. + User user = setLevels(fixtures.createUser(), 20L, 15L, 15L, 3L, 15L); + authenticateAs(user.getId()); + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"BACKGROUND\":\"" + BG_SPACE + "\"," + + "\"OUTFIT\":\"" + OUTFIT_CARDIGAN + "\"}}")) + .andExpect(status().isOk()); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"HEAD\":\"" + HAT_BEANIE + "\"," + + "\"BACKGROUND\":\"" + BG_SPRING + "\"}}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED.getErrorCode())) + .andExpect(jsonPath("$.data").doesNotExist()); + + mockMvc.perform(get("/api/cosmetics")) + .andExpect(jsonPath("$.data.equipped.BACKGROUND") + .value(BG_SPACE)) + .andExpect(jsonPath("$.data.equipped.OUTFIT") + .value(OUTFIT_CARDIGAN)) + .andExpect(jsonPath("$.data.equipped.HEAD").doesNotExist()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 충돌하는 조합은 한쪽이 벗겨져 응답에 실린다") + void equipAllResolvesConflict() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + setConflicts(HAT_BEANIE, OUTFIT_CARDIGAN); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"HEAD\":\"" + HAT_BEANIE + "\"," + + "\"OUTFIT\":\"" + OUTFIT_CARDIGAN + "\"}}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.unequippedSlots").value(Matchers.contains("HEAD"))) + .andExpect(jsonPath("$.data.equipped.OUTFIT").value(OUTFIT_CARDIGAN)) + .andExpect(jsonPath("$.data.equipped.HEAD").doesNotExist()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 본문에 userId 를 넣어도 남의 차림은 안 바뀐다") + void equipAllIgnoresUserIdInBody() throws Exception { + User user = fullyGrownUser(); + User other = fullyGrownUser(); + authenticateAs(other.getId()); + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"HEAD\":\"" + HAT_BEANIE + "\"}}")) + .andExpect(status().isOk()); + + // 남의 식별자를 본문에 실어 "전부 벗기" 를 보낸다. 대상은 인증 컨텍스트의 나여야 한다. + authenticateAs(user.getId()); + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"userId\":" + other.getId() + ",\"equipped\":{}}")) + .andExpect(status().isOk()); + + authenticateAs(other.getId()); + mockMvc.perform(get("/api/cosmetics")) + .andExpect(jsonPath("$.data.equipped.HEAD") + .value(HAT_BEANIE)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 없는 슬롯 이름은 400 이다") + void equipAllRejectsUnknownSlot() throws Exception { + User user = fullyGrownUser(); + authenticateAs(user.getId()); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{\"WINGS\":\"" + HAT_BEANIE + "\"}}")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 비로그인 요청은 401 이다") + void equipAllRequiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"equipped\":{}}")) + .andExpect(status().isUnauthorized()); + } + @Test @DisplayName("PUT /api/cosmetics/equip - 비로그인 요청은 401 이다") void equipRequiresAuthentication() throws Exception { diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java index d2ef6f07..c52aa1b5 100644 --- a/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/service/CosmeticServiceTest.java @@ -18,6 +18,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -814,6 +816,218 @@ void unequipsConflictingSlot() { } } + @Nested + @DisplayName("차림 전체 저장") + class EquipAll { + + @Test + @DisplayName("요청에 없는 슬롯은 비운다 - 저장 버튼은 '지금 이 차림이 전부' 라는 선언이다") + void replacesWholeLoadout() { + User user = fullyGrownUser(); + + CosmeticEquipResponseDto response = cosmeticService.equipAll( + user.getId(), Map.of(CosmeticSlot.HEAD, HAT_BEANIE)); + + assertThat(response.equipped()) + .as("프리셋이 채우고 있던 나머지 자리가 남아 있으면 벗는 것을 저장할 방법이 없다") + .containsOnlyKeys(CosmeticSlot.HEAD) + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + assertThat(rawItemKeyOf(user.getId(), CosmeticSlot.BACKGROUND)) + .as("비운 자리는 지우는 게 아니라 비웠다는 표시로 남는다") + .isEqualTo(UserCosmeticLoadout.NONE); + } + + @Test + @DisplayName("빈 맵을 보내면 전부 벗는다") + void emptyMapUnequipsEverything() { + User user = fullyGrownUser(); + + CosmeticEquipResponseDto response = cosmeticService.equipAll(user.getId(), Map.of()); + + assertThat(response.equipped()).isEmpty(); + assertThat(equippedOf(user.getId())) + .as("모든 자리에 행이 남아야 다음 조회에서 프리셋이 되살아나지 않는다") + .isEmpty(); + assertThat(loadoutRowCount(user.getId())).isEqualTo(EQUIPPABLE_SLOT_COUNT); + } + + @Test + @DisplayName("값이 null 인 항목은 키를 빼고 보낸 것과 같다") + void nullValueIsSameAsAbsent() { + User user = fullyGrownUser(); + Map requested = new HashMap<>(); + requested.put(CosmeticSlot.HEAD, HAT_BEANIE); + requested.put(CosmeticSlot.BACKGROUND, null); + + CosmeticEquipResponseDto response = cosmeticService.equipAll(user.getId(), requested); + + assertThat(response.equipped()) + .as("프론트가 빈 자리를 null 로 채워 보내든 빼고 보내든 같은 차림이 되어야 한다") + .containsOnlyKeys(CosmeticSlot.HEAD); + } + + @Test + @DisplayName("잠긴 아이템이 하나라도 섞이면 아무것도 저장되지 않는다") + void rejectsWholeRequestWhenAnyItemIsLocked() { + // 총 학습은 20 이지만 문제 복습이 3 이라 비니(문제 복습 4)는 잠겨 있다. + User user = setLevels(fixtures.createUser(), 20L, 15L, 15L, 3L, 15L); + cosmeticService.equipAll(user.getId(), Map.of( + CosmeticSlot.BACKGROUND, BG_SPACE, + CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN)); + + assertThatThrownBy(() -> cosmeticService.equipAll(user.getId(), Map.of( + CosmeticSlot.BACKGROUND, BG_SPRING, + CosmeticSlot.HEAD, HAT_BEANIE))) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_OWNED); + + assertThat(equippedOf(user.getId())) + .as("한 건이 걸렸는데 배경만 바뀌어 있으면 사용자는 무엇이 저장된 것인지 알 수 없다") + .containsEntry(CosmeticSlot.BACKGROUND, BG_SPACE) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + } + + @Test + @DisplayName("슬롯과 아이템이 어긋나면 거절한다") + void rejectsSlotMismatch() { + User user = fullyGrownUser(); + + assertThatThrownBy(() -> cosmeticService.equipAll(user.getId(), Map.of( + CosmeticSlot.BACKGROUND, BG_SPACE, + CosmeticSlot.OUTFIT, HAT_BEANIE))) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + assertThat(loadoutRowCount(user.getId())) + .as("거절된 요청은 아무것도 쓰지 않는다") + .isZero(); + } + + @Test + @DisplayName("없는 아이템과 비활성 아이템은 같은 에러로 거절한다") + void rejectsUnknownAndInactiveItems() { + User user = fullyGrownUser(); + deactivate(HAT_BUCKET); + + assertThatThrownBy(() -> cosmeticService.equipAll(user.getId(), + Map.of(CosmeticSlot.HEAD, "not_exists"))) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + assertThatThrownBy(() -> cosmeticService.equipAll(user.getId(), + Map.of(CosmeticSlot.HEAD, HAT_BUCKET))) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_ITEM_NOT_FOUND); + } + + @Test + @DisplayName("개구리 본체 슬롯이 섞여 있으면 거절한다") + void rejectsBaseSlot() { + User user = fullyGrownUser(); + + assertThatThrownBy(() -> cosmeticService.equipAll(user.getId(), + Map.of(CosmeticSlot.BASE, "BASE"))) + .isInstanceOf(ApplicationException.class) + .extracting(exception -> ((ApplicationException) exception).getErrorCase()) + .isEqualTo(CosmeticErrorCase.COSMETIC_SLOT_MISMATCH); + } + + @Test + @DisplayName("충돌하는 둘이 함께 들어오면 뒤에 깔리는 쪽을 남기고 앞의 것을 벗긴다") + void resolvesConflictWithinRequest() { + User user = fullyGrownUser(); + setConflicts(HAT_BEANIE, OUTFIT_CARDIGAN); + + CosmeticEquipResponseDto response = cosmeticService.equipAll(user.getId(), Map.of( + CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN, + CosmeticSlot.HEAD, HAT_BEANIE)); + + assertThat(response.unequippedSlots()) + .as("옷(400)을 남기고 모자(700)를 벗긴다. 반대로 하면 모자만 쓴 벗은 개구리가 된다") + .containsExactly(CosmeticSlot.HEAD); + assertThat(response.equipped()) + .containsEntry(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN) + .doesNotContainKey(CosmeticSlot.HEAD); + } + + @Test + @DisplayName("보내는 순서를 바꿔도 남는 쪽이 같다") + void conflictWinnerDoesNotDependOnRequestOrder() { + User user = fullyGrownUser(); + User other = fullyGrownUser(); + // 옷 쪽에만 적어도 양쪽 모두 본다. 규칙은 층 순서지 적힌 쪽이 아니다. + setConflicts(OUTFIT_CARDIGAN, HAT_BEANIE); + + Map headFirst = new LinkedHashMap<>(); + headFirst.put(CosmeticSlot.HEAD, HAT_BEANIE); + headFirst.put(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + Map outfitFirst = new LinkedHashMap<>(); + outfitFirst.put(CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + outfitFirst.put(CosmeticSlot.HEAD, HAT_BEANIE); + + CosmeticEquipResponseDto first = cosmeticService.equipAll(user.getId(), headFirst); + CosmeticEquipResponseDto second = cosmeticService.equipAll(other.getId(), outfitFirst); + + assertThat(first.equipped()) + .as("키 순서에 맡기면 같은 본문인데 조회할 때마다 다른 것이 걸려 있는 것처럼 보인다") + .isEqualTo(second.equipped()); + assertThat(first.unequippedSlots()) + .isEqualTo(second.unequippedSlots()) + .containsExactly(CosmeticSlot.HEAD); + } + + @Test + @DisplayName("충돌이 없으면 받은 차림 그대로 저장한다") + void keepsEverythingWhenNoConflict() { + User user = fullyGrownUser(); + + CosmeticEquipResponseDto response = cosmeticService.equipAll(user.getId(), Map.of( + CosmeticSlot.HEAD, HAT_BEANIE, + CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN, + CosmeticSlot.FRAME, FRAME_SPRING)); + + assertThat(response.unequippedSlots()).isEmpty(); + assertThat(response.equipped()) + .as("프레임도 다른 자리와 똑같이 받는다. 프리셋이 자동으로 안 채울 뿐이다") + .containsOnlyKeys(CosmeticSlot.HEAD, CosmeticSlot.OUTFIT, CosmeticSlot.FRAME); + } + + @Test + @DisplayName("같은 요청을 두 번 보내면 결과가 같다") + void isIdempotent() { + User user = fullyGrownUser(); + Map requested = Map.of( + CosmeticSlot.HEAD, HAT_BEANIE, + CosmeticSlot.OUTFIT, OUTFIT_CARDIGAN); + + CosmeticEquipResponseDto first = cosmeticService.equipAll(user.getId(), requested); + CosmeticEquipResponseDto second = cosmeticService.equipAll(user.getId(), requested); + + assertThat(second.equipped()).isEqualTo(first.equipped()); + assertThat(second.unequippedSlots()).isEqualTo(first.unequippedSlots()); + assertThat(loadoutRowCount(user.getId())) + .as("(user_id, slot) 기본키가 한 자리 한 행을 보장한다") + .isEqualTo(EQUIPPABLE_SLOT_COUNT); + } + + @Test + @DisplayName("남의 장착 상태는 건드리지 않는다") + void doesNotTouchOtherUsers() { + User user = fullyGrownUser(); + User other = fullyGrownUser(); + cosmeticService.equipAll(other.getId(), Map.of(CosmeticSlot.HEAD, HAT_BEANIE)); + + cosmeticService.equipAll(user.getId(), Map.of(CosmeticSlot.HEAD, HAT_BUCKET)); + + assertThat(equippedOf(other.getId())) + .as("전체 교체가 남의 자리까지 비우면 안 된다") + .containsOnlyKeys(CosmeticSlot.HEAD) + .containsEntry(CosmeticSlot.HEAD, HAT_BEANIE); + } + } + @Nested @DisplayName("레벨업 해금 목록") class UnlockLookup { From 225ecdf14887ddcf7f54710885ae13d34a167706 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 00:23:45 +0900 Subject: [PATCH 093/130] =?UTF-8?q?[Test]=20=EA=BE=B8=EB=AF=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9D=84=20=EA=B3=A8=EB=93=A0=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EA=B5=B3=ED=98=80=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EA=B0=99=EC=9D=80=20JSON=20=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 치장 API 를 연동했는데 두 레포가 같은 JSON 을 한 번도 본 적이 없었다. 프론트는 손으로 적은 더미로 파싱을 테스트하고 서버는 자기 응답을 테스트해서, 필드 하나가 어긋나도 양쪽 테스트가 그대로 초록으로 남는다. 서버가 실제로 뱉는 본문을 파일로 굳혀 계약서로 쓴다 - GET /api/cosmetics, PUT /api/cosmetics/equip-all 성공, equip-all 충돌 세 건을 src/test/resources/cosmetic/golden/ 에 둔다. CommonResponse 래퍼를 포함한 본문 전체다. 프론트가 벗겨내야 하는 껍데기까지 계약의 일부라 $.data 만 잘라 두지 않았다 - CosmeticGoldenResponseTest 가 매 실행마다 실제 응답과 골든을 비교해 다르면 실패한다. 문자열이 아니라 JSON 트리로 비교한다. 들여쓰기나 키 순서 때문에 깨지면 사람이 골든을 안 믿게 된다. 일부러 바꿨을 때는 -Dcosmetic.golden.update=true 로 다시 쓴다 - 파일로 쓸 때 모든 객체의 키를 이름순으로 정렬한다. equipped 가 EnumMap 이라 지금은 순서가 고정돼 있지만 그건 구현이 우연히 그런 것이지 계약이 아니다. 배열은 순서 자체가 그리는 층 순서라 손대지 않는다 - 카탈로그 63 개 중 15 개만 남기고 나머지는 테스트 안에서 active = 0 으로 내렸다. 통째로 실으면 아이템 하나가 늘 때마다 골든이 흔들려 정작 봐야 할 응답 구조의 변화가 묻힌다. 잠긴 것과 열린 것, requiredAbility 가 null 인 것과 아닌 것, layerOrder 를 덮어쓰는 가방과 안 덮어쓰는 가방, fullBody 와 setId 가 열 자리에 한 번씩 들어가도록 골랐다 - 골든을 찍는 사용자는 다섯 레벨이 모두 5 다. 다 자란 사용자로 찍으면 owned: false 가 한 건도 없어서 프론트가 잠긴 아이템을 어떻게 받는지 이 파일로는 알 수 없다 - Gradle 은 커맨드라인 -D 를 테스트 JVM 에 물려주지 않아 build.gradle 에서 직접 넘긴다. 환경 변수 COSMETIC_GOLDEN_UPDATE 는 그 설정 없이도 전달된다 - 추린 기준과 갱신 방법, 프론트(OnO_FRONT/test/fixtures/)도 같이 갱신해야 한다는 것을 골든 폴더의 README 에 적었다 --- build.gradle | 7 + .../CosmeticGoldenResponseTest.java | 281 +++++++++++++++++ src/test/resources/cosmetic/golden/README.md | 104 +++++++ .../cosmetic/golden/equip-all-conflict.json | 17 + .../resources/cosmetic/golden/equip-all.json | 16 + .../cosmetic/golden/get-cosmetics.json | 291 ++++++++++++++++++ 6 files changed, 716 insertions(+) create mode 100644 src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticGoldenResponseTest.java create mode 100644 src/test/resources/cosmetic/golden/README.md create mode 100644 src/test/resources/cosmetic/golden/equip-all-conflict.json create mode 100644 src/test/resources/cosmetic/golden/equip-all.json create mode 100644 src/test/resources/cosmetic/golden/get-cosmetics.json diff --git a/build.gradle b/build.gradle index f95b6e93..6d1eb72d 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,13 @@ tasks.named('test') { // JPA Auditing 이 채우는 created_at 은 JVM 기본 시간대를 쓴다. 둘이 다르면 // KST 00:00~09:00 구간에 하루가 어긋난다. 그 시각에만 깨지는 종류라 평소에는 초록으로 보인다. systemProperty 'user.timezone', 'Asia/Seoul' + // 꾸미기 골든 파일 갱신 플래그. Gradle 은 커맨드라인 -D 를 테스트 JVM 에 물려주지 않아 + // 여기서 직접 넘겨야 -Dcosmetic.golden.update=true 가 CosmeticGoldenResponseTest 에 닿는다. + // (환경 변수 COSMETIC_GOLDEN_UPDATE=true 는 이 설정 없이도 그대로 전달된다.) + if (System.getProperty('cosmetic.golden.update') != null) { + systemProperty 'cosmetic.golden.update', System.getProperty('cosmetic.golden.update') + } + // 기존 스위트는 힙 부족으로 OOM 종료됐다. 컨텍스트 캐시 + 컨테이너 구동분을 감안해 상향한다. maxHeapSize = '2g' diff --git a/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticGoldenResponseTest.java b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticGoldenResponseTest.java new file mode 100644 index 00000000..6b0463a8 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/cosmetic/controller/CosmeticGoldenResponseTest.java @@ -0,0 +1,281 @@ +package com.aisip.OnO.backend.cosmetic.controller; + +import com.aisip.OnO.backend.cosmetic.support.CosmeticTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import com.fasterxml.jackson.core.util.DefaultIndenter; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import com.fasterxml.jackson.core.util.Separators; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 꾸미기 응답의 골든 파일. + * + *

    왜 있는가. 프론트는 손으로 적은 더미 JSON 으로 파싱을 테스트하고 서버는 서버대로 + * 자기 응답을 테스트해 왔다. 둘이 같은 JSON 을 한 번도 본 적이 없어서, 응답 모양이 어긋나도 + * 양쪽 테스트는 초록으로 남는다. 그래서 서버가 실제로 뱉는 본문을 + * {@code src/test/resources/cosmetic/golden/} 에 굳혀 두고 프론트가 같은 파일을 읽게 한다. + * 서버 응답이 바뀌는 순간 여기서 한 번, 프론트 파싱 테스트에서 한 번 깨진다. + * + *

    무엇을 굳히는가. {@code CommonResponse} 래퍼를 포함한 응답 본문 전체다. + * 프론트가 벗겨내야 하는 껍데기까지 계약의 일부라 {@code $.data} 만 잘라 두지 않는다. + * + *

    키 순서. 골든이 실행마다 달라지면 계약서 노릇을 못 한다. 지금 {@code equipped} 는 + * {@code EnumMap} 이고 {@code items}·{@code slots} 는 정렬된 리스트라 서버 쪽 순서가 이미 + * 고정돼 있지만, 그건 구현이 우연히 그런 것이지 계약이 아니다({@code CosmeticItemResponseDto} + * 주석도 "JSON 필드 순서는 계약이 아니다" 라고 못박는다). 그래서 파일로 쓸 때 + * 모든 객체의 키를 이름순으로 정렬해 둔다. 배열은 순서가 의미를 가지므로 그대로 둔다. + * + *

    비교. 문자열이 아니라 JSON 트리로 비교한다. 들여쓰기나 키 순서 같은 의미 없는 차이로 + * 깨지면 사람이 골든을 믿지 않게 된다. {@code JsonNode#equals} 는 객체의 키 순서를 보지 않고 + * 배열의 순서만 본다. + * + *

    갱신하는 법. 일부러 응답을 바꿨다면 + *

    ./gradlew test --tests "*CosmeticGoldenResponseTest" -Dcosmetic.golden.update=true
    + * 로 다시 쓴다. 바뀐 골든은 반드시 눈으로 diff 를 확인하고 커밋하며, 프론트 레포 + * ({@code OnO_FRONT/test/fixtures/}) 의 같은 파일도 함께 갱신한다. 자세한 내용은 + * {@code src/test/resources/cosmetic/golden/README.md} 에 있다. + */ +@DisplayName("꾸미기 API 골든 응답") +class CosmeticGoldenResponseTest extends CosmeticTestSupport { + + /** 골든 파일이 사는 곳. 테스트 작업 디렉터리는 Gradle 이 프로젝트 루트로 잡아 준다. */ + private static final Path GOLDEN_DIR = Path.of("src", "test", "resources", "cosmetic", "golden"); + + /** 이 값이 {@code true} 면 비교 대신 골든을 다시 쓴다. */ + private static final String UPDATE_PROPERTY = "cosmetic.golden.update"; + + /** 같은 뜻의 환경 변수. Gradle 은 환경 변수를 테스트 JVM 에 그대로 물려준다. */ + private static final String UPDATE_ENV = "COSMETIC_GOLDEN_UPDATE"; + + /** + * 골든에 실을 대표 아이템. + * + *

    카탈로그 63 개를 통째로 실으면 파일이 수천 줄이 되고, 아이템 하나가 늘 때마다 골든이 + * 흔들려 정작 봐야 할 응답 구조의 변화가 묻힌다. 프론트 파싱이 마주칠 모든 모양이 + * 한 번씩 들어가도록 추렸다. 무엇을 왜 남겼는지는 README 의 표에 적어 뒀다. + */ + private static final List GOLDEN_ITEM_KEYS = List.of( + BG_SPRING, // BACKGROUND / 능력치 해금 / 보유 + BG_SPACE, // BACKGROUND / 능력치 해금 / 잠김 + OUTFIT_CARDIGAN, // OUTFIT / fullBody=true / 보유 + OUTFIT_GRADUATE, // OUTFIT / fullBody=true + 세트 / 잠김 + BACK_BACKPACK_NAVY, // BAG / layerOrder 200 으로 자리 층을 덮어씀 + BAG_MINI_BACKPACK, // BAG / layerOrder null (자리 기본값 사용) + SCARF, // NECK + GLASSES_ROUND, // FACE + HAT_BEANIE, // HEAD / 능력치 해금 + HEADBAND_SPROUT, // HEAD / requiredAbility null (총 학습 해금) + HAT_GRADUATE, // HEAD / 세트 / 잠김 + PROP_DIPLOMA, // HAND / 세트 / 잠김 + BADGE_LEAF_STAR, // BADGE / requiredAbility null + EFFECT_PETALS, // EFFECT + FRAME_SPRING // FRAME / composited=false 인 자리, SVG 에셋 + ); + + /** 골든에 실을 차림. HAND 는 잠긴 것뿐이라 비워 둔다. */ + private static final String GOLDEN_LOADOUT_BODY = """ + {"equipped":{\ + "BACKGROUND":"bg_spring",\ + "OUTFIT":"outfit_cardigan",\ + "BAG":"back_backpack_navy",\ + "NECK":"scarf",\ + "FACE":"glasses_round",\ + "HEAD":"hat_beanie",\ + "BADGE":"badge_leaf_star",\ + "EFFECT":"effect_petals",\ + "FRAME":"frame_spring"}}"""; + + @Test + @DisplayName("GET /api/cosmetics - 응답 본문 전체가 골든과 같다") + void getCosmeticsMatchesGolden() throws Exception { + User user = goldenUser(); + authenticateAs(user.getId()); + trimCatalogToGoldenItems(); + + mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content(GOLDEN_LOADOUT_BODY)) + .andExpect(status().isOk()); + + MvcResult result = mockMvc.perform(get("/api/cosmetics")) + .andExpect(status().isOk()) + .andReturn(); + + assertMatchesGolden("get-cosmetics.json", bodyOf(result)); + } + + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 충돌 없는 성공 응답이 골든과 같다") + void equipAllMatchesGolden() throws Exception { + User user = goldenUser(); + authenticateAs(user.getId()); + trimCatalogToGoldenItems(); + + MvcResult result = mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content(GOLDEN_LOADOUT_BODY)) + .andExpect(status().isOk()) + .andReturn(); + + assertMatchesGolden("equip-all.json", bodyOf(result)); + } + + /** + * 충돌로 한 자리가 벗겨진 응답. + * + *

    시드에는 충돌 조합이 하나도 없어서 여기서 직접 만든다. 비니(HEAD, 층 700)와 + * 가디건(OUTFIT, 층 400)을 충돌로 묶고 둘을 함께 보내면, 뒤에 깔리는 가디건이 남고 + * 비니가 벗겨져 {@code unequippedSlots} 에 실린다. + */ + @Test + @DisplayName("PUT /api/cosmetics/equip-all - 충돌로 한 자리가 벗겨진 응답이 골든과 같다") + void equipAllConflictMatchesGolden() throws Exception { + User user = goldenUser(); + authenticateAs(user.getId()); + trimCatalogToGoldenItems(); + setConflicts(HAT_BEANIE, OUTFIT_CARDIGAN); + + MvcResult result = mockMvc.perform(put("/api/cosmetics/equip-all") + .contentType(MediaType.APPLICATION_JSON) + .content(GOLDEN_LOADOUT_BODY)) + .andExpect(status().isOk()) + .andReturn(); + + assertMatchesGolden("equip-all-conflict.json", bodyOf(result)); + } + + // ─────────────────────────── 준비 ─────────────────────────── + + /** + * 골든을 찍을 사용자. 다섯 레벨이 모두 5 라 대표 아이템 중 열린 것과 잠긴 것이 함께 나온다. + * + *

    다 자란 사용자로 찍으면 {@code owned: false} 가 한 건도 없어서, 프론트가 잠긴 아이템을 + * 어떻게 받는지 이 파일로는 알 수 없다. + */ + private User goldenUser() { + return setLevels(fixtures.createUser(), 5L, 5L, 5L, 5L, 5L); + } + + /** + * 대표 아이템만 남기고 나머지를 비활성으로 내린다. 조회는 {@code active = 1} 만 보므로 + * 이것으로 카탈로그가 추려진다. 개구리 본체({@code BASE}) 행은 {@code baseImageUrl} 이 + * 읽으므로 반드시 남긴다. + */ + private void trimCatalogToGoldenItems() { + List keep = new ArrayList<>(GOLDEN_ITEM_KEYS); + keep.add("BASE"); + String placeholders = String.join(",", Collections.nCopies(keep.size(), "?")); + jdbcTemplate.update( + "UPDATE cosmetic_item SET active = 0 WHERE item_key NOT IN (" + placeholders + ")", + keep.toArray()); + } + + private String bodyOf(MvcResult result) throws Exception { + return result.getResponse().getContentAsString(StandardCharsets.UTF_8); + } + + // ─────────────────────────── 골든 비교 ─────────────────────────── + + /** + * 실제 응답을 골든 파일과 맞춘다. 다르면 실패한다. + * + *

    {@code -Dcosmetic.golden.update=true} 로 돌리면 비교 대신 골든을 다시 쓴다. + * 응답을 일부러 바꿨을 때만 쓰는 길이다. + */ + private void assertMatchesGolden(String fileName, String actualBody) throws Exception { + JsonNode actual = objectMapper.readTree(actualBody); + Path goldenFile = GOLDEN_DIR.resolve(fileName); + + if (updateRequested()) { + Files.createDirectories(GOLDEN_DIR); + Files.writeString(goldenFile, canonicalJson(actual) + "\n", StandardCharsets.UTF_8); + return; + } + + if (!Files.exists(goldenFile)) { + throw new AssertionError("골든 파일이 없다: " + goldenFile.toAbsolutePath() + + "\n-D" + UPDATE_PROPERTY + "=true 로 한 번 돌려 만든 뒤 내용을 확인하고 커밋해라."); + } + + JsonNode golden = objectMapper.readTree(Files.readString(goldenFile, StandardCharsets.UTF_8)); + // 실패 메시지를 미리 만들어 둔다. AssertJ 의 Supplier 는 검사 예외를 던질 수 없다. + String failMessage = """ + %s 가 실제 응답과 다르다. + + [골든] + %s + + [실제] + %s + + 일부러 바꾼 것이면 -D%s=true 로 다시 쓰고, 프론트 레포(OnO_FRONT/test/fixtures/)의 + 같은 파일도 함께 갱신해라.""" + .formatted(fileName, canonicalJson(golden), canonicalJson(actual), UPDATE_PROPERTY); + assertThat(actual) + .withFailMessage(failMessage) + .isEqualTo(golden); + } + + /** 골든을 다시 쓰라는 지시가 있는지. 시스템 프로퍼티와 환경 변수를 모두 본다. */ + private static boolean updateRequested() { + return Boolean.parseBoolean(System.getProperty(UPDATE_PROPERTY)) + || Boolean.parseBoolean(System.getenv(UPDATE_ENV)); + } + + /** + * 사람이 읽고 diff 를 뜰 수 있는 형태로 찍는다. + * + *

    객체의 키는 이름순으로 정렬한다. 서버 쪽 순서가 바뀌어도 골든이 흔들리지 않게 하려는 것이다. + * 배열은 순서 자체가 의미(그리는 층 순서)라 손대지 않는다. + */ + private String canonicalJson(JsonNode node) throws IOException { + return objectMapper.writer(prettyPrinter()).writeValueAsString(sortObjectKeys(node)); + } + + private static DefaultPrettyPrinter prettyPrinter() { + DefaultIndenter indenter = new DefaultIndenter(" ", "\n"); + DefaultPrettyPrinter printer = new DefaultPrettyPrinter() + .withSeparators(Separators.createDefaultInstance() + .withObjectFieldValueSpacing(Separators.Spacing.AFTER)); + printer.indentObjectsWith(indenter); + printer.indentArraysWith(indenter); + return printer; + } + + private static JsonNode sortObjectKeys(JsonNode node) { + if (node.isObject()) { + List names = new ArrayList<>(); + node.fieldNames().forEachRemaining(names::add); + Collections.sort(names); + ObjectNode sorted = JsonNodeFactory.instance.objectNode(); + names.forEach(name -> sorted.set(name, sortObjectKeys(node.get(name)))); + return sorted; + } + if (node.isArray()) { + ArrayNode sorted = JsonNodeFactory.instance.arrayNode(); + node.forEach(element -> sorted.add(sortObjectKeys(element))); + return sorted; + } + return node.deepCopy(); + } +} diff --git a/src/test/resources/cosmetic/golden/README.md b/src/test/resources/cosmetic/golden/README.md new file mode 100644 index 00000000..a24d8941 --- /dev/null +++ b/src/test/resources/cosmetic/golden/README.md @@ -0,0 +1,104 @@ +# 꾸미기 API 골든 응답 + +서버가 실제로 뱉는 꾸미기 API 응답 본문을 그대로 굳혀 둔 파일들이다. 프론트와 백엔드가 +같은 JSON 을 보게 하려고 만들었다. + +## 왜 있는가 + +치장 API 를 프론트에 연동했는데 두 레포가 아직 한 번도 같은 JSON 을 본 적이 없었다. +프론트는 자기가 손으로 적은 더미 JSON 으로 파싱을 테스트하고 서버는 서버대로 자기 응답을 +테스트하고 있어서, 필드 이름 하나가 어긋나도 양쪽 테스트는 그대로 초록으로 남는다. +그래서 서버가 실제로 만드는 본문을 파일로 굳혀 계약서로 쓰기로 했고, 프론트가 같은 파일을 +`test/fixtures/` 에 두고 읽으면 서버 응답 모양이 바뀌는 순간 양쪽에서 한 번씩 깨진다. + +`CommonResponse` 래퍼를 포함한 **응답 본문 전체**를 담는다. 프론트가 벗겨내야 하는 껍데기까지 +계약의 일부라 `$.data` 만 잘라 두지 않았다. 성공 응답의 `CommonResponse` 는 +`@JsonInclude(NON_NULL)` 이라 `errorCode` 와 `message` 가 아예 빠지고 `data` 하나만 남는다. + +## 파일 + +| 파일 | 무엇 | +|---|---| +| `get-cosmetics.json` | `GET /api/cosmetics` 성공 응답 | +| `equip-all.json` | `PUT /api/cosmetics/equip-all` 성공 응답. 충돌이 없어 `unequippedSlots` 가 빈 배열이다 | +| `equip-all-conflict.json` | `PUT /api/cosmetics/equip-all` 중 충돌로 한 자리가 벗겨진 응답. `unequippedSlots` 에 `HEAD` 가 실린다 | + +만드는 쪽은 `CosmeticGoldenResponseTest` 하나다. MockMvc 로 실제 직렬화를 거친 응답을 받아 +그대로 쓰고, 같은 테스트가 다음 실행부터 이 파일과 응답을 비교한다. + +## 키 순서를 어떻게 고정했나 + +골든이 실행마다 달라지면 계약서 노릇을 못 하기 때문에 **파일로 쓸 때 모든 객체의 키를 이름순으로 +정렬**했다. 배열은 순서 자체가 의미(그리는 층 순서)를 갖기 때문에 손대지 않는다. + +지금 구현만 보면 `equipped` 는 `EnumMap` 이고 `items` 와 `slots` 는 정렬된 리스트라 서버 쪽 +순서가 이미 고정돼 있지만, 그건 구현이 우연히 그런 것이지 계약이 아니다 +(`CosmeticItemResponseDto` 주석도 "JSON 필드 순서는 계약이 아니다" 라고 못박고 있다). +`Map` 을 쓰는 `equipped` 자리는 구현체가 바뀌면 순서도 같이 바뀌기 때문에 정렬해서 쓰는 편이 +안전하다고 판단했다. + +비교도 문자열이 아니라 JSON 트리로 한다. 들여쓰기나 키 순서 같은 의미 없는 차이로 깨지면 +사람이 골든을 안 믿게 되기 때문에, `JsonNode#equals` 로 객체는 순서를 무시하고 배열은 순서까지 +본다. 그래서 프론트가 이 파일을 자기 포맷터로 다시 찍어 두어도 계약 검증에는 영향이 없다. + +## 어떤 아이템만 남겼나 + +카탈로그 전체는 63 개인데 통째로 실으면 파일이 수천 줄이 되고, 아이템 하나가 늘 때마다 골든이 +흔들려서 정작 봐야 할 응답 구조의 변화가 묻힌다. 그래서 **프론트 파싱이 마주칠 모양이 한 번씩 +들어가도록** 15 개만 남기고 나머지는 테스트 안에서 `active = 0` 으로 내렸다. 개구리 본체(`BASE`) +행은 `baseImageUrl` 이 읽기 때문에 그대로 둔다. + +| 아이템 | 자리 | 남긴 이유 | +|---|---|---| +| `bg_spring` | BACKGROUND | 능력치로 열리는 것, `owned: true` | +| `bg_space` | BACKGROUND | 같은 자리에서 `owned: false` 인 것 | +| `outfit_cardigan` | OUTFIT | `fullBody: true` | +| `outfit_graduate` | OUTFIT | `fullBody: true` 이면서 `setId` 와 `setNameKo` 가 있는 것, 잠김 | +| `back_backpack_navy` | BAG | `layerOrder` 200 으로 자리 기본값(450)을 덮어쓰는 것 | +| `bag_mini_backpack` | BAG | 같은 자리에서 `layerOrder` 가 `null` 인 것 | +| `scarf` | NECK | 자리 채우기 | +| `glasses_round` | FACE | 자리 채우기 | +| `hat_beanie` | HEAD | `requiredAbility` 가 있는 것 | +| `headband_sprout` | HEAD | `requiredAbility` 가 `null` 인 것(총 학습 레벨로 열린다) | +| `hat_graduate` | HEAD | 세트에 속하면서 잠긴 것 | +| `prop_diploma` | HAND | 세트에 속하면서 잠긴 것 | +| `badge_leaf_star` | BADGE | `requiredAbility` 가 `null` 이면서 열린 것 | +| `effect_petals` | EFFECT | 자리 채우기 | +| `frame_spring` | FRAME | `composited: false` 인 자리의 아이템이고 혼자 SVG 경로다 | + +열 자리를 모두 한 번씩 채우도록 골랐다. `slots` 배열은 카탈로그와 무관하게 `CosmeticSlot` 에서 +바로 나오기 때문에 추리는 것과 상관없이 10 개가 그대로 실리고, `composited: false` 인 `FRAME` +도 여기 들어 있다. + +골든을 찍는 사용자는 다섯 레벨이 모두 5 다. 다 자란 사용자로 찍으면 `owned: false` 가 한 건도 +없어서 프론트가 잠긴 아이템을 어떻게 받는지 이 파일로는 알 수 없게 된다. + +**지금 이 파일에 없는 모양이 하나 있다.** `conflictsWith` 가 전부 빈 배열인데, 시드에 충돌 +조합이 하나도 들어 있지 않아서 그렇다. 값이 채워진 모양을 보고 싶어서 억지로 넣으면 운영 +카탈로그와 다른 것을 계약서라고 두는 셈이라 그대로 뒀다. 카탈로그에 충돌이 실제로 들어가는 날 +골든을 다시 찍으면 된다. + +## 갱신하는 법 + +응답을 일부러 바꿨으면 아래로 다시 쓴다. + +```bash +./gradlew test --tests "*CosmeticGoldenResponseTest" -Dcosmetic.golden.update=true +``` + +환경 변수도 같은 일을 한다. + +```bash +COSMETIC_GOLDEN_UPDATE=true ./gradlew test --tests "*CosmeticGoldenResponseTest" +``` + +갱신 모드에서는 비교를 건너뛰고 파일을 덮어쓰기 때문에, **다시 쓴 뒤에는 반드시 `git diff` 로 +무엇이 바뀌었는지 눈으로 확인하고 커밋한다.** 의도하지 않은 줄이 같이 바뀌어 있으면 응답이 +생각보다 넓게 바뀐 것이다. + +## 프론트가 같이 갱신해야 한다 + +프론트 레포(`OnO_FRONT`)가 이 파일들과 같은 내용을 `test/fixtures/` 에 두고 파싱 테스트에서 +읽는다. 그래서 **서버 응답을 바꿔 여기 골든을 다시 썼으면 프론트 쪽 파일도 같은 내용으로 +갱신해야 한다.** 한쪽만 고치면 다른 쪽 테스트가 옛 모양을 계속 통과시키기 때문에, 두 레포가 +같은 JSON 을 본다는 이 파일의 전제가 그 순간 깨진다. diff --git a/src/test/resources/cosmetic/golden/equip-all-conflict.json b/src/test/resources/cosmetic/golden/equip-all-conflict.json new file mode 100644 index 00000000..7d68aafa --- /dev/null +++ b/src/test/resources/cosmetic/golden/equip-all-conflict.json @@ -0,0 +1,17 @@ +{ + "data": { + "equipped": { + "BACKGROUND": "bg_spring", + "BADGE": "badge_leaf_star", + "BAG": "back_backpack_navy", + "EFFECT": "effect_petals", + "FACE": "glasses_round", + "FRAME": "frame_spring", + "NECK": "scarf", + "OUTFIT": "outfit_cardigan" + }, + "unequippedSlots": [ + "HEAD" + ] + } +} diff --git a/src/test/resources/cosmetic/golden/equip-all.json b/src/test/resources/cosmetic/golden/equip-all.json new file mode 100644 index 00000000..bc38455f --- /dev/null +++ b/src/test/resources/cosmetic/golden/equip-all.json @@ -0,0 +1,16 @@ +{ + "data": { + "equipped": { + "BACKGROUND": "bg_spring", + "BADGE": "badge_leaf_star", + "BAG": "back_backpack_navy", + "EFFECT": "effect_petals", + "FACE": "glasses_round", + "FRAME": "frame_spring", + "HEAD": "hat_beanie", + "NECK": "scarf", + "OUTFIT": "outfit_cardigan" + }, + "unequippedSlots": [ ] + } +} diff --git a/src/test/resources/cosmetic/golden/get-cosmetics.json b/src/test/resources/cosmetic/golden/get-cosmetics.json new file mode 100644 index 00000000..855c9836 --- /dev/null +++ b/src/test/resources/cosmetic/golden/get-cosmetics.json @@ -0,0 +1,291 @@ +{ + "data": { + "baseImageUrl": "assets/Cosmetic/BASE.png", + "baseLayerOrder": 300, + "equipped": { + "BACKGROUND": "bg_spring", + "BADGE": "badge_leaf_star", + "BAG": "back_backpack_navy", + "EFFECT": "effect_petals", + "FACE": "glasses_round", + "FRAME": "frame_spring", + "HEAD": "hat_beanie", + "NECK": "scarf", + "OUTFIT": "outfit_cardigan" + }, + "items": [ + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/bg_spring.png", + "itemKey": "bg_spring", + "layerOrder": null, + "nameKo": "봄 배경", + "owned": true, + "requiredAbility": "ATTENDANCE", + "requiredLevel": 2, + "setId": null, + "setNameKo": null, + "slot": "BACKGROUND" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/bg_space.png", + "itemKey": "bg_space", + "layerOrder": null, + "nameKo": "우주 배경", + "owned": false, + "requiredAbility": "ATTENDANCE", + "requiredLevel": 15, + "setId": null, + "setNameKo": null, + "slot": "BACKGROUND" + }, + { + "conflictsWith": [ ], + "fullBody": true, + "imageUrl": "assets/Cosmetic/outfit_cardigan.png", + "itemKey": "outfit_cardigan", + "layerOrder": null, + "nameKo": "가디건", + "owned": true, + "requiredAbility": "NOTE_PRACTICE", + "requiredLevel": 5, + "setId": null, + "setNameKo": null, + "slot": "OUTFIT" + }, + { + "conflictsWith": [ ], + "fullBody": true, + "imageUrl": "assets/Cosmetic/outfit_graduate.png", + "itemKey": "outfit_graduate", + "layerOrder": null, + "nameKo": "졸업 가운", + "owned": false, + "requiredAbility": null, + "requiredLevel": 20, + "setId": "graduate", + "setNameKo": "학사 세트", + "slot": "OUTFIT" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/bag_mini_backpack.png", + "itemKey": "bag_mini_backpack", + "layerOrder": null, + "nameKo": "미니 백팩", + "owned": true, + "requiredAbility": "NOTE_WRITE", + "requiredLevel": 2, + "setId": null, + "setNameKo": null, + "slot": "BAG" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/back_backpack_navy.png", + "itemKey": "back_backpack_navy", + "layerOrder": 200, + "nameKo": "남색 배낭", + "owned": true, + "requiredAbility": "NOTE_WRITE", + "requiredLevel": 5, + "setId": null, + "setNameKo": null, + "slot": "BAG" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/scarf.png", + "itemKey": "scarf", + "layerOrder": null, + "nameKo": "목도리", + "owned": true, + "requiredAbility": "NOTE_PRACTICE", + "requiredLevel": 2, + "setId": null, + "setNameKo": null, + "slot": "NECK" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/glasses_round.png", + "itemKey": "glasses_round", + "layerOrder": null, + "nameKo": "동그란 안경", + "owned": true, + "requiredAbility": "PROBLEM_PRACTICE", + "requiredLevel": 2, + "setId": null, + "setNameKo": null, + "slot": "FACE" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/headband_sprout.png", + "itemKey": "headband_sprout", + "layerOrder": null, + "nameKo": "새싹 머리띠", + "owned": true, + "requiredAbility": null, + "requiredLevel": 2, + "setId": null, + "setNameKo": null, + "slot": "HEAD" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/hat_beanie.png", + "itemKey": "hat_beanie", + "layerOrder": null, + "nameKo": "비니", + "owned": true, + "requiredAbility": "PROBLEM_PRACTICE", + "requiredLevel": 4, + "setId": null, + "setNameKo": null, + "slot": "HEAD" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/hat_graduate.png", + "itemKey": "hat_graduate", + "layerOrder": null, + "nameKo": "학사모", + "owned": false, + "requiredAbility": null, + "requiredLevel": 20, + "setId": "graduate", + "setNameKo": "학사 세트", + "slot": "HEAD" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/prop_diploma.png", + "itemKey": "prop_diploma", + "layerOrder": null, + "nameKo": "졸업장", + "owned": false, + "requiredAbility": null, + "requiredLevel": 20, + "setId": "graduate", + "setNameKo": "학사 세트", + "slot": "HAND" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/badge_leaf_star.png", + "itemKey": "badge_leaf_star", + "layerOrder": null, + "nameKo": "잎새 별 뱃지", + "owned": true, + "requiredAbility": null, + "requiredLevel": 3, + "setId": null, + "setNameKo": null, + "slot": "BADGE" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/Cosmetic/effect_petals.png", + "itemKey": "effect_petals", + "layerOrder": null, + "nameKo": "꽃잎 효과", + "owned": true, + "requiredAbility": "ATTENDANCE", + "requiredLevel": 3, + "setId": null, + "setNameKo": null, + "slot": "EFFECT" + }, + { + "conflictsWith": [ ], + "fullBody": false, + "imageUrl": "assets/ProfileFrame/frame_spring.svg", + "itemKey": "frame_spring", + "layerOrder": null, + "nameKo": "봄 프레임", + "owned": true, + "requiredAbility": "ATTENDANCE", + "requiredLevel": 3, + "setId": null, + "setNameKo": null, + "slot": "FRAME" + } + ], + "slots": [ + { + "composited": true, + "layerOrder": 100, + "nameKo": "배경", + "slot": "BACKGROUND" + }, + { + "composited": true, + "layerOrder": 400, + "nameKo": "옷", + "slot": "OUTFIT" + }, + { + "composited": true, + "layerOrder": 450, + "nameKo": "가방", + "slot": "BAG" + }, + { + "composited": true, + "layerOrder": 500, + "nameKo": "목", + "slot": "NECK" + }, + { + "composited": true, + "layerOrder": 600, + "nameKo": "얼굴", + "slot": "FACE" + }, + { + "composited": true, + "layerOrder": 700, + "nameKo": "머리", + "slot": "HEAD" + }, + { + "composited": true, + "layerOrder": 800, + "nameKo": "손", + "slot": "HAND" + }, + { + "composited": true, + "layerOrder": 850, + "nameKo": "뱃지", + "slot": "BADGE" + }, + { + "composited": true, + "layerOrder": 900, + "nameKo": "효과", + "slot": "EFFECT" + }, + { + "composited": false, + "layerOrder": 1000, + "nameKo": "프레임", + "slot": "FRAME" + } + ] + } +} From bf1f02bb06348ccc2221eb2020b128347a334197 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 01:43:37 +0900 Subject: [PATCH 094/130] =?UTF-8?q?[Feat]=20=ED=9B=88=EC=9E=A5=2012?= =?UTF-8?q?=EC=A2=85=20=ED=8C=90=EC=A0=95=EA=B3=BC=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - achievement 패키지 추가: Achievement enum(훈장표 순서 = 응답 순서), user_achievement 엔티티/리포지토리, 판정 서비스, GET /api/achievements - 판정은 조회 시점에 한다. 이미 쌓인 데이터를 세어 그 자리에서 판정하므로 기능 이전에 쌓인 기록도 소급된다 - (user_id, achievement_key) 복합 기본키 + INSERT ... ON DUPLICATE KEY UPDATE 로 멱등성을 DB 가 보장한다. 이미 받은 행은 earned_at 도 덮어쓰지 않는다 - 한 번 받은 훈장은 취소하지 않는다. 삭제 경로 자체를 두지 않았다 - 복습 기록 여섯 조건(집념·불사조·새벽반·올빼미·무결점·회고왕)은 표를 한 번만 읽어 자바에서 함께 판정한다. 조회 한 번에 쿼리 9회 - 새벽반·올빼미는 KST 벽시계 시각으로 센다. 개근은 mission_log 의 USER_LOGIN 날짜를 KST 기준 연속 일수로 센다 - V42 마이그레이션은 user_achievement 테이블 하나. IF NOT EXISTS 로 재실행 안전하게 두고 시드는 두지 않았다 - 훈장 테스트 47건 추가. 순서·멱등성·취소 안 함·진행도 null·시각 경계·UNKNOWN 미차단·불사조 순서·소유권·401·쿼리 수를 잠근다 --- .../controller/AchievementController.java | 37 ++ .../dto/AchievementListResponseDto.java | 17 + .../dto/AchievementResponseDto.java | 41 ++ .../achievement/entity/Achievement.java | 140 +++++ .../achievement/entity/UserAchievement.java | 56 ++ .../achievement/entity/UserAchievementId.java | 27 + .../repository/UserAchievementRepository.java | 44 ++ .../service/AchievementService.java | 111 ++++ .../achievement/service/AchievementStats.java | 33 ++ .../service/AchievementStatsCollector.java | 118 ++++ .../achievement/service/ConsecutiveDays.java | 39 ++ .../achievement/service/SolveScan.java | 116 ++++ .../folder/repository/FolderRepository.java | 3 + .../repository/MissionLogRepository.java | 26 + .../repository/ProblemSolveMark.java | 27 + .../repository/ProblemSolveRepository.java | 22 + .../StudyRoomFeedReactionRepository.java | 3 + ...haredProblemCommentReactionRepository.java | 3 + ...dyRoomSharedProblemReactionRepository.java | 3 + .../V42__create_user_achievement.sql | 37 ++ .../controller/AchievementControllerTest.java | 95 ++++ .../migration/AchievementMigrationTest.java | 137 +++++ .../service/AchievementServiceTest.java | 505 ++++++++++++++++++ .../support/AchievementTestSupport.java | 238 +++++++++ 24 files changed, 1878 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java create mode 100644 src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java create mode 100644 src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java create mode 100644 src/main/resources/db/migration/V42__create_user_achievement.sql create mode 100644 src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java diff --git a/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java b/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java new file mode 100644 index 00000000..6b652a6e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/controller/AchievementController.java @@ -0,0 +1,37 @@ +package com.aisip.OnO.backend.achievement.controller; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.service.AchievementService; +import com.aisip.OnO.backend.common.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 훈장 API. + * + *

    사용자 식별자는 요청 본문이나 파라미터가 아니라 인증 컨텍스트에서만 꺼낸다. 밖에서 받으면 + * 남의 훈장을 들여다보는 요청을 그대로 받아들이게 된다. {@code CosmeticController} 와 같은 방식이다. + * + *

    {@code /api/achievements} 는 SecurityConfig 의 별도 매처에 걸리지 않아 + * {@code anyRequest().authenticated()} 를 탄다. 비로그인 요청은 401 이다. + * + *

    GET 이지만 쓰기가 일어난다. 판정을 조회 시점에 하고 새로 채운 훈장을 그 자리에 적기 때문이다. + * 조건을 적립 경로마다 심으면 이 기능이 붙기 전에 이미 오답노트를 백 개 적은 사람이 아무것도 못 받는데, + * 조회할 때 세면 소급이 저절로 된다. 같은 요청을 몇 번을 보내도 행은 하나라 멱등하다. + */ +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/achievements") +public class AchievementController { + + private final AchievementService achievementService; + + @GetMapping("") + public CommonResponse getAchievements() { + Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); + return CommonResponse.success(achievementService.getAchievements(userId)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java new file mode 100644 index 00000000..afef8f65 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementListResponseDto.java @@ -0,0 +1,17 @@ +package com.aisip.OnO.backend.achievement.dto; + +import java.util.List; + +/** + * {@code GET /api/achievements} 응답. + * + * @param achievements 열두 개 전부. 받은 것과 못 받은 것이 섞여 있다. + * 순서는 훈장표의 순서 그대로다. 앱이 이 순서를 그대로 그리고 다시 정렬하지 않는다. + * @param newlyEarned 이번 호출에서 처음 채워진 훈장의 key 들. 앱이 "새 훈장을 받았어요" 연출에 쓴다. + * 없으면 빈 배열이다. 같은 사람이 연달아 부르면 두 번째부터는 비어 있다. + */ +public record AchievementListResponseDto( + List achievements, + List newlyEarned +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java new file mode 100644 index 00000000..06bf81bb --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/dto/AchievementResponseDto.java @@ -0,0 +1,41 @@ +package com.aisip.OnO.backend.achievement.dto; + +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.service.AchievementStats; + +import java.time.LocalDateTime; + +/** + * 훈장 한 건. 프론트와 맞춘 형태라 필드 이름이 바뀌면 앱이 그대로 깨진다. + * + * @param imageUrl 앱 번들 안의 에셋 경로. 치장이 {@code assets/Cosmetic/...} 을 내려주는 것과 같다. + * @param earned 받았는지. 받은 뒤에는 조건을 다시 계산해 안 맞아도 참이다. + * @param earnedAt 처음 받은 시각(KST). 못 받았으면 null. + * @param current 지금까지 온 만큼. 목표치를 넘으면 목표치로 잘린다. 진행도가 없는 훈장이면 null. + * @param target 받는 데 필요한 만큼. 진행도가 없는 훈장(첫 걸음·불사조)이면 null. + */ +public record AchievementResponseDto( + String key, + String nameKo, + String descriptionKo, + String imageUrl, + boolean earned, + LocalDateTime earnedAt, + Long current, + Long target +) { + + public static AchievementResponseDto of(Achievement achievement, AchievementStats stats, + LocalDateTime earnedAt) { + return new AchievementResponseDto( + achievement.getKey(), + achievement.getNameKo(), + achievement.getDescriptionKo(), + achievement.getImageUrl(), + earnedAt != null, + earnedAt, + achievement.progressOf(stats).orElse(null), + achievement.targetValue().orElse(null) + ); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java new file mode 100644 index 00000000..ae065d64 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/Achievement.java @@ -0,0 +1,140 @@ +package com.aisip.OnO.backend.achievement.entity; + +import com.aisip.OnO.backend.achievement.service.AchievementStats; +import lombok.Getter; + +import java.util.Optional; +import java.util.function.ToLongFunction; + +/** + * 훈장 열두 개. 훈장표(`OnO_FRONT/docs/훈장/훈장표.md`)가 계약이고 이 enum 이 그 사본이다. + * + *

    선언 순서가 곧 응답 순서다. 앱은 서버가 준 순서를 그대로 그리고 다시 정렬하지 않는다. + * 순서를 바꾸면 화면의 훈장 배치가 바뀐다. 훈장표의 순서와 여기의 순서는 항상 같아야 한다. + * + *

    카탈로그 테이블을 두지 않는다. 치장 아이템과 달리 훈장은 관리자가 늘리는 것이 아니라 + * 에셋과 함께 배포되는 것이라, 테이블을 두면 시드 마이그레이션과 앱 에셋이 따로 놀 여지만 생긴다. + * 코드에 두면 열두 개가 어긋날 수 없다. + * + *

    조건은 전부 {@code 진행도 >= threshold} 한 가지 꼴로 맞췄다. "틀린 뒤에 맞혔는가" 처럼 + * 참/거짓인 것도 0 또는 1 을 내는 진행도로 바꿔 같은 틀에 넣는다. 조건마다 다른 판정을 쓰면 + * 훈장이 늘 때마다 판정 코드가 따라 늘고, 어떤 훈장이 어떤 규칙을 쓰는지 한눈에 안 보인다. + * + *

    {@code showsProgress} 가 거짓인 둘({@link #FIRST_STEP}, {@link #PHOENIX})은 진행도를 안 내려준다. + * 0 아니면 1 이라 "0/1" 을 보여 줘 봐야 잠김 여부를 두 번 말하는 것뿐이다. 훈장표가 정한 것이다. + */ +@Getter +public enum Achievement { + + FIRST_STEP("first_step", "첫 걸음", "오답노트를 처음 적었어요", + 1, false, AchievementStats::problemCount), + + ARCHIVIST("archivist", "기록광", "오답노트를 백 개나 모았어요", + 100, true, AchievementStats::problemCount), + + PERSISTENCE("persistence", "집념", "한 문제를 다섯 번 다시 봤어요", + 5, true, AchievementStats::maxSolveCountOnOneProblem), + + PHOENIX("phoenix", "불사조", "틀렸던 문제를 기어이 맞혔어요", + 1, false, AchievementStats::comebackCount), + + DAWN_CLASS("dawn_class", "새벽반", "해 뜨기 전에 열 번 공부했어요", + 10, true, AchievementStats::dawnSolveCount), + + NIGHT_OWL("night_owl", "올빼미", "밤이 깊어도 열 번 공부했어요", + 10, true, AchievementStats::nightSolveCount), + + PERFECT_MONTH("perfect_month", "개근", "서른 날을 하루도 안 빼먹었어요", + 30, true, AchievementStats::longestLoginStreak), + + FLAWLESS("flawless", "무결점", "열 번을 내리 맞혔어요", + 10, true, AchievementStats::longestCorrectStreak), + + ORGANIZER("organizer", "정리의 신", "폴더를 열 개나 만들어 정리했어요", + 10, true, AchievementStats::folderCount), + + REVIEWER("reviewer", "회고왕", "복습하고 쉰 번을 되돌아봤어요", + 50, true, AchievementStats::reflectionCount), + + COMPANION("companion", "동행", "같이 공부할 사람을 찾았어요", + 1, true, AchievementStats::studyRoomCount), + + CHEERLEADER("cheerleader", "응원단장", "백 번을 응원했어요", + 100, true, AchievementStats::reactionCount); + + /** + * 앱 번들 안의 에셋 경로. 치장이 {@code assets/Cosmetic/...} 을 내려주는 것과 같은 방식이다. + * + *

    키에서 만들어 낸다. 열두 줄에 같은 접두사를 되풀이해 적으면 한 줄만 오타가 나도 + * 그 훈장만 이미지가 안 뜨는데, 그건 앱을 켜 보기 전에는 드러나지 않는다. + */ + private static final String IMAGE_URL_PREFIX = "assets/Medal/"; + private static final String IMAGE_URL_SUFFIX = ".png"; + + /** 앱과 DB 가 공유하는 식별자. enum 이름이 아니라 이 값이 계약이다. */ + private final String key; + + private final String nameKo; + + private final String descriptionKo; + + /** 이 값에 닿으면 받는다. 경계는 포함이다. */ + private final int threshold; + + /** 진행도를 내려줄지. 거짓이면 응답의 {@code current}/{@code target} 이 둘 다 null 이다. */ + private final boolean showsProgress; + + private final ToLongFunction progress; + + Achievement(String key, String nameKo, String descriptionKo, + int threshold, boolean showsProgress, ToLongFunction progress) { + this.key = key; + this.nameKo = nameKo; + this.descriptionKo = descriptionKo; + this.threshold = threshold; + this.showsProgress = showsProgress; + this.progress = progress; + } + + public String getImageUrl() { + return IMAGE_URL_PREFIX + key + IMAGE_URL_SUFFIX; + } + + /** 지금까지 쌓인 데이터로 이 훈장의 조건을 채웠는지. */ + public boolean isSatisfiedBy(AchievementStats stats) { + return progress.applyAsLong(stats) >= threshold; + } + + /** + * 화면에 보여 줄 진행도. 진행도가 없는 훈장이면 비어 있다. + * + *

    목표치를 넘어도 목표치로 잘라서 준다. 오답노트를 187 개 적은 사람에게 "187/100" 을 + * 보여 주면 진행 막대가 계산되지 않고, 이미 받은 훈장에서 숫자가 계속 자라는 것도 이상하다. + */ + public Optional progressOf(AchievementStats stats) { + if (!showsProgress) { + return Optional.empty(); + } + return Optional.of(Math.min(progress.applyAsLong(stats), threshold)); + } + + /** 진행도를 내려주는 훈장의 목표치. 진행도가 없으면 비어 있다. */ + public Optional targetValue() { + return showsProgress ? Optional.of((long) threshold) : Optional.empty(); + } + + /** + * 저장된 키를 훈장으로 되돌린다. 모르는 키면 비어 있다. + * + *

    훈장을 뺀 배포를 하면 예전 행의 키가 여기 안 잡힌다. 그때 예외를 던지면 훈장 하나 때문에 + * 훈장 화면 전체가 안 열린다. 모르는 행은 조용히 지나친다. + */ + public static Optional fromKey(String key) { + for (Achievement achievement : values()) { + if (achievement.key.equals(key)) { + return Optional.of(achievement); + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java new file mode 100644 index 00000000..114fdb01 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievement.java @@ -0,0 +1,56 @@ +package com.aisip.OnO.backend.achievement.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 사용자가 받은 훈장. (userId, achievementKey) 하나당 한 행이다. + * + *

    (user_id, achievement_key) 복합 기본키가 멱등성의 근거다. 판정이 조회 때마다 도는 구조라 + * 같은 사람이 훈장 화면을 두 번 열면 같은 INSERT 가 두 번 나간다. 중복을 막는 것을 애플리케이션 + * 검사에 맡기면 두 요청이 동시에 "없다"를 읽고 둘 다 INSERT 하는 창이 열린다. DB 가 막으면 그 창이 없다. + * + *

    이 엔티티는 읽기 전용으로만 쓴다. 쓰기는 리포지토리의 네이티브 upsert 를 탄다. + * {@link com.aisip.OnO.backend.cosmetic.entity.UserCosmeticLoadout} 과 같은 방식이다. + * + *

    키는 {@link Achievement} 의 enum 이름이 아니라 {@code archivist} 같은 계약 키를 담는다. + * API 와 앱 에셋 경로가 쓰는 값과 같아야 행을 보고 무엇인지 알 수 있다. + * + *

    user 에 외래키를 걸지 않는다. {@code user_cosmetic_loadout} 과 같은 이유로, INSERT 마다 + * 부모 사용자 행에 공유 잠금이 붙으면 사용자 행을 배타 잠금으로 잡는 미션 보상 지급 경로와 + * 잠금 순서가 엇갈릴 수 있다. + */ +@Entity +@Getter +@IdClass(UserAchievementId.class) +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_achievement") +public class UserAchievement { + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + /** + * V42 의 {@code achievement_key VARCHAR(32)} 와 맞춘다. {@code columnDefinition} 을 함께 적는 이유는 + * {@code UserCosmeticLoadout.slot} 과 같다. 길이가 테스트 스키마와 운영 스키마에서 갈리면 + * 길이 초과로 나는 오류를 테스트가 재현하지 못한다. + */ + @Id + @Column(name = "achievement_key", nullable = false, length = 32, columnDefinition = "varchar(32)") + private String achievementKey; + + /** 이 훈장을 처음 채운 시각. KST 다. 근거는 AchievementService 주석에 있다. */ + @Column(name = "earned_at", nullable = false) + private LocalDateTime earnedAt; +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java new file mode 100644 index 00000000..fcc4a9fe --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/entity/UserAchievementId.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.achievement.entity; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +/** + * {@link UserAchievement} 의 복합 기본키 (user_id, achievement_key). + * + *

    JPA 는 복합키를 별도 클래스로 요구하고, 그 클래스는 직렬화 가능하며 equals/hashCode 가 + * 값 기준이어야 한다. 둘 중 하나라도 빠지면 같은 키를 두 번 조회할 때 영속성 컨텍스트가 + * 다른 행으로 착각한다. + */ +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@EqualsAndHashCode +public class UserAchievementId implements Serializable { + + private Long userId; + + private String achievementKey; +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java b/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java new file mode 100644 index 00000000..75945cb8 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/repository/UserAchievementRepository.java @@ -0,0 +1,44 @@ +package com.aisip.OnO.backend.achievement.repository; + +import com.aisip.OnO.backend.achievement.entity.UserAchievement; +import com.aisip.OnO.backend.achievement.entity.UserAchievementId; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface UserAchievementRepository extends JpaRepository { + + /** 소유권: 훈장은 언제나 요청한 사용자 것만 읽는다. */ + List findAllByUserId(Long userId); + + /** + * 아직 없을 때만 넣는다. 이미 받은 훈장은 손대지 않는다. + * + *

    여기가 "한 번 받은 것은 취소하지 않는다" 와 "몇 번을 불러도 행이 하나다" 를 동시에 지킨다. + * {@code ON DUPLICATE KEY UPDATE user_id = user_id} 는 "이미 있으면 그대로 둔다"는 뜻이라, + * 두 번째 호출이 {@code earned_at} 을 오늘로 덮어쓰지 않는다. 덮어쓰면 사용자가 훈장 화면을 + * 열 때마다 받은 날짜가 오늘로 바뀐다. + * + *

    {@code INSERT IGNORE} 를 쓰지 않는다. 그쪽은 키 충돌만이 아니라 길이 초과·타입 불일치 같은 + * 진짜 오류까지 경고로 삼켜 버린다. 훈장 키가 32 자를 넘게 되면 조용히 잘린 행이 남는 대신 + * 여기서 터져야 한다. + * + *

    {@code earned_at} 을 {@code NOW(6)} 이 아니라 인자로 받는다. DB 서버의 시간대가 앱과 다르면 + * 받은 시각이 아홉 시간 어긋난 채로 앱에 뜬다. 기준 시각은 애플리케이션이 정한다. + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query(value = """ + INSERT INTO user_achievement (user_id, achievement_key, earned_at) + VALUES (:userId, :achievementKey, :earnedAt) + ON DUPLICATE KEY UPDATE user_id = user_id + """, nativeQuery = true) + int insertIfAbsent( + @Param("userId") Long userId, + @Param("achievementKey") String achievementKey, + @Param("earnedAt") LocalDateTime earnedAt + ); +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java new file mode 100644 index 00000000..a7d9a8e5 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementService.java @@ -0,0 +1,111 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.dto.AchievementResponseDto; +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.entity.UserAchievement; +import com.aisip.OnO.backend.achievement.repository.UserAchievementRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * 훈장 조회와 판정. + * + *

    판정은 조회할 때 한다. 미션처럼 행동이 일어날 때마다 따지는 방법도 있지만, 그러면 열두 + * 조건이 적립 경로 네 군데에 흩어지고 무엇보다 이 기능을 붙이기 전에 이미 오답노트를 백 개 적은 + * 사람이 아무것도 못 받는다. 지금까지 쌓인 데이터를 그 자리에서 세면 소급이 저절로 된다. + * 화면을 열 때 한 번 도는 비용이고, 몇 번 나가는지는 {@link AchievementStatsCollector} 주석에 있다. + * + *

    한 번 받은 것은 취소하지 않는다. 조건을 다시 계산했을 때 안 맞아도 이미 받은 것은 그대로 + * 둔다. 오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다. {@code user_achievement} 에 + * 행이 있으면 그걸로 끝이라, 아래 어디에도 지우는 경로가 없다. + * + *

    엔티티를 고쳐 더티 체킹에 맡기지 않고 리포지토리의 네이티브 upsert 만 쓴다. 멱등성의 근거가 + * {@code (user_id, achievement_key)} 기본키인데, 읽고 나서 쓰는 방식으로 바꾸면 그 보장이 사라진다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AchievementService { + + /** + * 받은 시각의 기준 시간대. + * + *

    운영과 테스트 모두 {@code -Duser.timezone=Asia/Seoul} 이 걸려 있어 인자 없는 + * {@code LocalDateTime.now()} 로도 같은 값이 나온다. 그래도 명시한다. 기본 시간대에 기대면 + * 배포 환경이 바뀌는 날 받은 시각이 아홉 시간 어긋나고, 그건 아무도 안 보는 사이에 일어난다. + * {@code MissionPeriodKey} 가 같은 이유로 KST 를 못박고 있다. + */ + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final AchievementStatsCollector statsCollector; + private final UserAchievementRepository userAchievementRepository; + + /** + * 훈장 열두 개 전부와, 이번 호출에서 새로 채운 것. + * + *

    읽기 전용이 아니다. 새로 채운 훈장을 이 자리에서 적는다. 적어 두지 않으면 조건이 되는 데이터를 + * 지운 순간 받은 적 없는 것이 되고, "받은 날짜"를 말할 수도 없다. + * + *

    같은 사람이 연달아 두 번 불러도 두 번째의 {@code newlyEarned} 는 비어 있다. 두 번째 호출은 + * 첫 호출이 적어 둔 행을 읽고 시작하기 때문이다. 동시에 두 번 들어오면 두 응답이 같은 훈장을 + * 한 번씩 새것이라 말할 수 있지만, 그때도 행은 하나다. 최악이 기기 두 대에서 축하 연출이 + * 한 번씩 뜨는 것이라 DB 를 잠가 가며 막을 값어치가 없다고 봤다. + */ + @Transactional + public AchievementListResponseDto getAchievements(Long userId) { + AchievementStats stats = statsCollector.collect(userId); + Map earnedAtByAchievement = earnedAtByAchievement(userId); + + // 초 아래를 버린다. 훈장을 받은 시각에 마이크로초가 필요할 일이 없고, 계약 문서의 예시도 초 단위다. + LocalDateTime now = LocalDateTime.now(KST).truncatedTo(ChronoUnit.SECONDS); + + List newlyEarned = new ArrayList<>(); + // values() 의 순서가 곧 훈장표의 순서다. 여기서 다시 정렬하지 않는다. + for (Achievement achievement : Achievement.values()) { + if (earnedAtByAchievement.containsKey(achievement) || !achievement.isSatisfiedBy(stats)) { + continue; + } + userAchievementRepository.insertIfAbsent(userId, achievement.getKey(), now); + earnedAtByAchievement.put(achievement, now); + newlyEarned.add(achievement.getKey()); + } + + if (!newlyEarned.isEmpty()) { + log.info("userId: {} earned achievements: {}", userId, newlyEarned); + } + + List achievements = Arrays.stream(Achievement.values()) + .map(achievement -> AchievementResponseDto.of( + achievement, stats, earnedAtByAchievement.get(achievement))) + .toList(); + + return new AchievementListResponseDto(achievements, newlyEarned); + } + + /** + * 이미 받은 훈장과 그 시각. + * + *

    모르는 키가 든 행은 그냥 넘긴다. 훈장을 빼는 배포를 하면 예전 행의 키가 enum 에 없는데, + * 거기서 예외를 던지면 훈장 하나 때문에 훈장 화면 전체가 안 열린다. + */ + private Map earnedAtByAchievement(Long userId) { + Map earned = new EnumMap<>(Achievement.class); + for (UserAchievement row : userAchievementRepository.findAllByUserId(userId)) { + Achievement.fromKey(row.getAchievementKey()) + .ifPresent(achievement -> earned.put(achievement, row.getEarnedAt())); + } + return earned; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java new file mode 100644 index 00000000..0d9146a4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStats.java @@ -0,0 +1,33 @@ +package com.aisip.OnO.backend.achievement.service; + +/** + * 한 사용자의 지금까지가 숫자로 접힌 것. 훈장 열두 개의 판정이 전부 이 값들만 본다. + * + *

    판정과 세기를 갈라 두는 이유는 시험 가능성 때문이다. 조건이 맞는지 따지는 쪽 + * ({@link com.aisip.OnO.backend.achievement.entity.Achievement})에 DB 접근이 섞여 있으면 + * "오답노트 99 개와 100 개의 경계" 같은 것을 확인하려고 매번 오답노트 백 개를 만들어야 한다. + * + * @param maxSolveCountOnOneProblem 한 문제에 몰린 복습 횟수의 최댓값. 다섯 문제를 한 번씩 본 것과 + * 한 문제를 다섯 번 본 것은 다르다. + * @param comebackCount 오답 뒤에 정답이 나온 적이 있으면 1, 없으면 0. 참/거짓을 + * 다른 조건과 같은 "진행도 >= 목표" 틀에 넣으려고 숫자로 둔다. + * @param dawnSolveCount KST 05:00 이상 08:00 미만에 한 복습 횟수. + * @param nightSolveCount KST 00:00 이상 03:00 미만에 한 복습 횟수. + * @param longestCorrectStreak 복습 시각 순서로 정답이 연달아 나온 최대 길이. + * @param longestLoginStreak 로그인이 하루도 안 끊긴 최대 일수. 오늘까지 이어질 필요는 없다. + * @param reactionCount 리액션 세 종류(피드·공유 문제·공유 문제 댓글)를 합친 수. + */ +public record AchievementStats( + long problemCount, + long folderCount, + long maxSolveCountOnOneProblem, + long comebackCount, + long dawnSolveCount, + long nightSolveCount, + long reflectionCount, + long longestCorrectStreak, + long longestLoginStreak, + long studyRoomCount, + long reactionCount +) { +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java new file mode 100644 index 00000000..5cfae01f --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java @@ -0,0 +1,118 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.folder.repository.FolderRepository; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemReactionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 훈장 판정에 쓸 숫자를 모은다. + * + *

    이 클래스가 조회 한 번에 나가는 쿼리 수를 정한다. 지금은 아홉 번이다. + * 오답노트 1, 폴더 1, 복습 기록 1, 로그인 날짜 1, 스터디룸 1, 리액션 3, 그리고 + * 서비스가 따로 읽는 받은 훈장 목록 1 이다. + * + *

    열두 조건을 각각 세면 열두 번이 넘는다. 그중 여섯(집념·불사조·새벽반·올빼미·무결점·회고왕)이 + * 같은 {@code problem_solve} 표를 보기 때문에, 그 표를 한 번만 읽어 자바에서 여섯 값을 만든다 + * ({@link SolveScan}). 나머지는 인덱스를 타는 단순 count 라 더 접을 것이 없다. + * + *

    리액션 셋을 한 문장으로 합치면 아홉이 일곱이 되지만 그렇게 하지 않았다. 세 도메인에 걸친 + * 네이티브 쿼리를 어느 리포지토리에도 자연스럽게 둘 수 없고, 정작 비용은 문장 수가 아니라 + * {@code study_room_feed_reaction} 과 {@code study_room_shared_problem_reaction} 에 + * {@code user_id} 선두 인덱스가 없다는 쪽에 있다. 합쳐도 그 스캔은 그대로다. + */ +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AchievementStatsCollector { + + private final ProblemRepository problemRepository; + private final FolderRepository folderRepository; + private final ProblemSolveRepository problemSolveRepository; + private final MissionLogRepository missionLogRepository; + private final StudyRoomMemberRepository studyRoomMemberRepository; + private final StudyRoomFeedReactionRepository feedReactionRepository; + private final StudyRoomSharedProblemReactionRepository sharedProblemReactionRepository; + private final StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + + public AchievementStats collect(Long userId) { + SolveScan solves = SolveScan.of(problemSolveRepository.findAllMarksByUserId(userId)); + + return new AchievementStats( + nullSafe(problemRepository.countByUserId(userId)), + folderRepository.countByUserId(userId), + solves.maxSolveCountOnOneProblem(), + solves.comebackCount(), + solves.dawnSolveCount(), + solves.nightSolveCount(), + solves.reflectionCount(), + solves.longestCorrectStreak(), + longestLoginStreak(userId), + studyRoomMemberRepository.countByUserId(userId), + reactionCount(userId) + ); + } + + private long longestLoginStreak(Long userId) { + List loginDates = missionLogRepository + .findDistinctLogDates(userId, MissionType.USER_LOGIN).stream() + .map(AchievementStatsCollector::toLocalDate) + .toList(); + + return ConsecutiveDays.longestRun(loginDates); + } + + /** + * 응원한 횟수. 훈장표대로 리액션 세 자리를 합친다. + * + *

    누른 자리가 어디든 응원한 것은 응원한 것이다. 피드 리액션만 세면 공유 문제에만 응원을 누른 + * 사용자가 아무것도 안 한 것으로 잡힌다. + */ + private long reactionCount(Long userId) { + return feedReactionRepository.countByUserId(userId) + + sharedProblemReactionRepository.countByUserId(userId) + + commentReactionRepository.countByUserId(userId); + } + + /** + * {@code FUNCTION('DATE', ...)} 이 돌려준 값을 날짜로 맞춘다. + * + *

    Hibernate 가 이 함수의 자바 타입을 정하지 않고 JDBC 드라이버가 주는 대로 넘기기 때문에 + * 드라이버 버전에 따라 {@code java.sql.Date} 일 수도 {@code LocalDate} 일 수도 있다. + * 한쪽만 받도록 적어 두면 드라이버를 올리는 날 훈장 화면이 ClassCastException 으로 닫힌다. + * {@code MissionLogService} 도 같은 이유로 같은 처리를 한다. + */ + private static LocalDate toLocalDate(Object value) { + if (value instanceof LocalDate localDate) { + return localDate; + } + if (value instanceof LocalDateTime localDateTime) { + return localDateTime.toLocalDate(); + } + if (value instanceof java.sql.Date date) { + return date.toLocalDate(); + } + if (value instanceof Timestamp timestamp) { + return timestamp.toLocalDateTime().toLocalDate(); + } + return LocalDate.parse(value.toString()); + } + + /** {@code ProblemRepository.countByUserId} 만 {@code Long} 을 돌려준다. 행이 없으면 0 이다. */ + private static long nullSafe(Long count) { + return count == null ? 0 : count; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java b/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java new file mode 100644 index 00000000..45a2eedd --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/ConsecutiveDays.java @@ -0,0 +1,39 @@ +package com.aisip.OnO.backend.achievement.service; + +import java.time.LocalDate; +import java.util.Collection; +import java.util.List; + +/** + * 날짜 묶음에서 하루도 안 끊긴 가장 긴 구간의 길이. + * + *

    훈장 '개근'이 쓴다. 오늘까지 이어질 필요는 없다. 지난달에 서른 날을 채웠으면 그때 받은 + * 것이고, 이번 달에 하루 빠졌다고 되돌려 주지 않는다. 그래서 "지금 며칠째인가" 가 아니라 + * "가장 길었던 때가 며칠인가" 를 본다. + */ +public final class ConsecutiveDays { + + private ConsecutiveDays() { + } + + /** @param dates 중복이 있어도 된다. 안에서 정렬하고 같은 날은 하루로 접는다. */ + public static long longestRun(Collection dates) { + if (dates.isEmpty()) { + return 0; + } + + List sorted = dates.stream().distinct().sorted().toList(); + + long longest = 1; + long current = 1; + for (int i = 1; i < sorted.size(); i++) { + if (sorted.get(i - 1).plusDays(1).equals(sorted.get(i))) { + current++; + } else { + current = 1; + } + longest = Math.max(longest, current); + } + return longest; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java b/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java new file mode 100644 index 00000000..a4600f60 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/SolveScan.java @@ -0,0 +1,116 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveMark; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 복습 기록을 시각 순서로 한 번 훑어 훈장 여섯 개가 쓸 값을 함께 만든다. + * + *

    집념·불사조·새벽반·올빼미·무결점·회고왕이 전부 같은 표를 본다. 조건마다 쿼리를 따로 두면 + * 훈장 화면 한 번에 같은 표를 여섯 번 읽게 되고, 그중 둘(무결점·불사조)은 애초에 집계 함수로 안 된다. + * 순서대로 한 번 훑는 것으로 여섯 개가 전부 나온다. + */ +public record SolveScan( + long maxSolveCountOnOneProblem, + long comebackCount, + long dawnSolveCount, + long nightSolveCount, + long reflectionCount, + long longestCorrectStreak +) { + + /** 새벽반이 보는 구간. 05:00 이상 08:00 미만. */ + private static final int DAWN_START_HOUR = 5; + private static final int DAWN_END_HOUR = 8; + + /** 올빼미가 보는 구간. 00:00 이상 03:00 미만. 새벽반과 겹치지 않는다. */ + private static final int NIGHT_END_HOUR = 3; + + public static final SolveScan EMPTY = new SolveScan(0, 0, 0, 0, 0, 0); + + /** + * @param marks 복습 시각 오름차순으로 정렬된 기록. 정렬이 깨지면 무결점과 불사조가 틀린다. + */ + public static SolveScan of(List marks) { + if (marks.isEmpty()) { + return EMPTY; + } + + Map solveCountByProblem = new HashMap<>(); + // 이 문제에서 틀린 적이 있는가. 불사조는 "틀린 뒤에 맞혔는가" 라 앞에 오답이 있었는지를 기억해야 한다. + Set everWrongProblems = new HashSet<>(); + boolean comeback = false; + + long dawnCount = 0; + long nightCount = 0; + long reflectionCount = 0; + long correctStreak = 0; + long longestCorrectStreak = 0; + + for (ProblemSolveMark mark : marks) { + solveCountByProblem.merge(mark.problemId(), 1L, Long::sum); + + if (isDawn(mark.practicedAt())) { + dawnCount++; + } + if (isNight(mark.practicedAt())) { + nightCount++; + } + if (mark.hasReflection()) { + reflectionCount++; + } + + AnswerStatus status = mark.answerStatus(); + if (status == AnswerStatus.WRONG) { + everWrongProblems.add(mark.problemId()); + } + // PARTIAL 은 정답으로 치지 않는다. 부분 정답은 아직 못 맞힌 것이다. + if (status == AnswerStatus.CORRECT && everWrongProblems.contains(mark.problemId())) { + comeback = true; + } + + // UNKNOWN 은 레거시 마이그레이션이 남긴 값이라 세지도 않고 끊지도 않는다. + // 그때 맞혔는지 아닌지를 모르는 것이라, 틀렸다고 보고 끊으면 없는 실패를 만드는 셈이다. + if (status == AnswerStatus.CORRECT) { + correctStreak++; + longestCorrectStreak = Math.max(longestCorrectStreak, correctStreak); + } else if (status == AnswerStatus.WRONG || status == AnswerStatus.PARTIAL) { + correctStreak = 0; + } + } + + long maxSolveCount = solveCountByProblem.values().stream().mapToLong(Long::longValue).max().orElse(0); + + return new SolveScan( + maxSolveCount, + comeback ? 1 : 0, + dawnCount, + nightCount, + reflectionCount, + longestCorrectStreak + ); + } + + /** + * 복습 시각이 새벽 구간인지. + * + *

    {@code practicedAt} 은 앱이 보낸 벽시계 시각을 그대로 담고 있고, 운영과 테스트 모두 + * JVM 시간대가 {@code Asia/Seoul} 로 못박혀 있다. 즉 이 값의 시(hour)가 곧 KST 시각이다. + * 여기서 {@code atZone(UTC)} 같은 것을 한 번 더 태우면 아홉 시간이 밀려 엉뚱한 사람이 받는다. + */ + private static boolean isDawn(LocalDateTime practicedAt) { + int hour = practicedAt.getHour(); + return hour >= DAWN_START_HOUR && hour < DAWN_END_HOUR; + } + + private static boolean isNight(LocalDateTime practicedAt) { + return practicedAt.getHour() < NIGHT_END_HOUR; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index f3d19a57..24f89e15 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -16,6 +16,9 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByUserId(Long userId); + /** 훈장 '정리의 신' 판정용. 소프트 삭제된 폴더는 {@code @SQLRestriction} 이 걸러 준다. */ + long countByUserId(Long userId); + @Modifying @Query("delete from Folder f where f.id in :folderIds") void deleteAllByIdIn(@Param("folderIds") Collection folderIds); diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java index 6a2b211c..89763444 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepository.java @@ -19,6 +19,32 @@ public interface MissionLogRepository extends JpaRepository, M long countByMissionType(MissionType missionType); + /** + * 한 사용자가 그 미션을 남긴 날짜들. 하루에 여러 번이어도 하루로 접힌다. + * + *

    훈장 '개근'이 연속 30 일을 세는 데 쓴다. 행을 전부 읽어 자바에서 날짜로 접으면 로그인이 + * 수천 건인 사용자의 행을 통째로 올리게 되므로 날짜로 접는 것까지 DB 에 맡긴다. + * + *

    {@code created_at} 은 JPA Auditing 이 JVM 기본 시간대로 채우고, 운영과 테스트 모두 + * {@code -Duser.timezone=Asia/Seoul} 이 걸려 있다. 즉 저장된 값이 이미 KST 벽시계라 + * {@code DATE()} 가 그대로 KST 날짜다. 여기서 시간대를 한 번 더 옮기면 아홉 시간이 밀린다. + * + *

    반환 타입을 {@code Object} 로 둔다. {@code FUNCTION('DATE', ...)} 의 자바 타입은 + * Hibernate 가 정하지 않고 JDBC 드라이버가 주는 대로라, 드라이버 버전에 따라 + * {@code java.sql.Date} 일 수도 {@code LocalDate} 일 수도 있다. + * 호출부가 둘 다 받아 넘긴다({@code MissionLogService.toLocalDate} 도 같은 처리를 한다). + */ + @Query(""" + SELECT DISTINCT FUNCTION('DATE', m.createdAt) + FROM MissionLog m + WHERE m.user.id = :userId + AND m.missionType = :missionType + """) + List findDistinctLogDates( + @Param("userId") Long userId, + @Param("missionType") MissionType missionType + ); + long countByMissionTypeAndCreatedAtBetween( MissionType missionType, LocalDateTime startDateTime, diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java new file mode 100644 index 00000000..ffd50a9e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveMark.java @@ -0,0 +1,27 @@ +package com.aisip.OnO.backend.problemsolve.repository; + +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; + +import java.time.LocalDateTime; + +/** + * 복습 기록 한 건에서 판정에 필요한 값만 뽑은 것. + * + *

    엔티티를 그대로 읽지 않는다. {@code reflection} 과 {@code improvements} 가 TEXT 라 + * 복습 기록이 수천 건인 사용자의 것을 통째로 올리면 본문만 메가바이트 단위가 된다. + * 회고는 "비어 있지 않은가" 만 보면 되므로 길이만 가져온다. + * + * @param reflectionLength 공백을 걷어낸 회고의 길이. 회고가 없으면 {@code null} 이다. + */ +public record ProblemSolveMark( + Long problemId, + LocalDateTime practicedAt, + AnswerStatus answerStatus, + Integer reflectionLength +) { + + /** 회고를 남긴 기록인지. 공백만 적은 것은 남긴 것으로 치지 않는다. */ + public boolean hasReflection() { + return reflectionLength != null && reflectionLength > 0; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java index 17cf086a..342e1e2f 100644 --- a/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problemsolve/repository/ProblemSolveRepository.java @@ -50,5 +50,27 @@ public interface ProblemSolveRepository extends JpaRepository findSolveSummariesByProblemIds(@Param("problemIds") Collection problemIds); + /** + * 훈장 판정용. 한 사용자의 복습 기록을 복습 시각 순서로 한 번에 훑는다. + * + *

    훈장 여섯 개(집념·불사조·새벽반·올빼미·무결점·회고왕)가 전부 이 표를 본다. 조건마다 따로 세면 + * 훈장 화면을 열 때마다 같은 표를 여섯 번 훑게 되는데, 한 번 읽어 자바에서 여섯 값을 함께 만들면 + * 쿼리가 하나다. 무결점(연속 정답)과 불사조(오답 뒤 정답)는 애초에 순서를 봐야 해서 + * 집계 함수로는 안 되고, 그 둘이 이미 순서대로 훑기를 요구하므로 나머지도 같은 훑기에 얹는다. + * + *

    복습 시각이 같은 기록이 있을 수 있어 id 로 한 번 더 정렬한다. 기준이 없으면 연속 판정이 + * 같은 데이터에서도 호출마다 달라진다. + * + *

    {@code idx_problem_solve_user_practiced_at (user_id, practiced_at)} 이 그대로 쓰인다. + */ + @Query(""" + SELECT new com.aisip.OnO.backend.problemsolve.repository.ProblemSolveMark( + pr.problem.id, pr.practicedAt, pr.answerStatus, LENGTH(TRIM(pr.reflection))) + FROM ProblemSolve pr + WHERE pr.userId = :userId + ORDER BY pr.practicedAt ASC, pr.id ASC + """) + List findAllMarksByUserId(@Param("userId") Long userId); + void deleteAllByProblemId(Long problemId); } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java index d7affac2..42f683dd 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomFeedReactionRepository.java @@ -17,4 +17,7 @@ public interface StudyRoomFeedReactionRepository extends JpaRepository findAllByFeedIds(@Param("feedIds") Collection feedIds); List findAllByFeedId(Long feedId); + + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java index a89fb012..a5d289f0 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemCommentReactionRepository.java @@ -20,6 +20,9 @@ public interface StudyRoomSharedProblemCommentReactionRepository extends JpaRepo @Query("select r from StudyRoomSharedProblemCommentReaction r where r.comment.id in :commentIds") List findAllByCommentIds(@Param("commentIds") Collection commentIds); + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); + @Modifying @Query("delete from StudyRoomSharedProblemCommentReaction r where r.user.id = :userId") void deleteByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java index 31d83f99..959f2977 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/repository/StudyRoomSharedProblemReactionRepository.java @@ -19,6 +19,9 @@ public interface StudyRoomSharedProblemReactionRepository extends JpaRepository< List findAllBySharedProblemId(Long sharedProblemId); + /** 훈장 '응원단장' 판정용. 누른 자리가 어디든 응원한 것은 응원한 것이라 세 테이블을 합쳐 센다. */ + long countByUserId(Long userId); + @Modifying @Query("delete from StudyRoomSharedProblemReaction r where r.sharedProblem.id = :sharedProblemId") void deleteBySharedProblemId(@Param("sharedProblemId") Long sharedProblemId); diff --git a/src/main/resources/db/migration/V42__create_user_achievement.sql b/src/main/resources/db/migration/V42__create_user_achievement.sql new file mode 100644 index 00000000..fb5dc67e --- /dev/null +++ b/src/main/resources/db/migration/V42__create_user_achievement.sql @@ -0,0 +1,37 @@ +-- 훈장. 사용자가 어떤 훈장을 언제 받았는지만 담는다. +-- +-- 테이블이 하나뿐인 이유는 훈장 목록 자체를 코드의 enum(Achievement)으로 두기 때문이다. +-- 치장 아이템과 달리 훈장은 관리자가 늘리는 것이 아니라 앱 에셋과 함께 배포되는 것이라, +-- 카탈로그 테이블을 두면 시드 마이그레이션과 앱 번들이 따로 놀 여지만 생긴다. +-- 그래서 V34/V35 처럼 DDL 과 시드를 나눌 일도 없다. 이 파일에는 시드가 없다. +-- +-- **(user_id, achievement_key) 복합 기본키가 멱등성의 근거다.** 판정이 조회할 때마다 돌기 때문에 +-- 훈장 화면을 두 번 열면 같은 INSERT 가 두 번 나간다. 중복을 막는 것을 애플리케이션 검사에 맡기면 +-- 두 요청이 동시에 "없다"를 읽고 둘 다 INSERT 하는 창이 열린다. +-- 이 기본키가 있으면 INSERT ... ON DUPLICATE KEY UPDATE 한 문장으로 끝나고, 그 창 자체가 없다. +-- +-- 대리키(id)를 두지 않는다. 조회는 언제나 user_id 로 들어오고 쓰기는 (user_id, achievement_key) 다. +-- 자연키가 곧 조회 키라 그대로 기본키로 두는 편이 읽기도 쓰기도 싸다. user_cosmetic_loadout 과 같다. +-- +-- user 에 외래키를 걸지 않는다. mission_progress, user_cosmetic_loadout 과 같은 이유로, +-- INSERT 마다 부모 사용자 행에 공유 잠금이 붙으면 이미 사용자 행을 배타 잠금으로 잡는 +-- 미션 보상 지급 경로와 잠금 순서가 엇갈릴 수 있다. +-- +-- achievement_key 는 enum 이름(ARCHIVIST)이 아니라 API 와 앱 에셋이 쓰는 계약 키(archivist)를 담는다. +-- 행을 직접 들여다봤을 때 무슨 훈장인지 바로 알 수 있어야 한다. +-- +-- earned_at 은 애플리케이션이 KST 로 채워 넣는다. NOW(6) 을 쓰지 않는 이유는 DB 서버의 시간대가 +-- 앱과 다르면 받은 시각이 아홉 시간 어긋난 채로 앱에 뜨기 때문이다. +-- +-- 한 번 받은 훈장은 취소되지 않는다. 조건을 다시 계산했을 때 안 맞아도 행은 그대로 둔다. +-- 오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다. 그래서 이 테이블에는 DELETE 경로가 없다. +-- +-- V34 와 같은 이유로 IF NOT EXISTS 로 만들고 인덱스도 테이블 정의 안에 둔다. MySQL 에는 +-- CREATE INDEX IF NOT EXISTS 가 없어 문장을 나누면 그 문장이 새로운 재실행 지점이 된다 +-- (V31 이 information_schema 가드를 쓴 이유가 그거다). 이 파일의 문장은 몇 번을 다시 돌려도 안전하다. +CREATE TABLE IF NOT EXISTS user_achievement ( + user_id BIGINT NOT NULL, + achievement_key VARCHAR(32) NOT NULL, + earned_at DATETIME(6) NOT NULL, + PRIMARY KEY (user_id, achievement_key) +) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci; diff --git a/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java b/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java new file mode 100644 index 00000000..a7944d10 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/controller/AchievementControllerTest.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.achievement.controller; + +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.user.entity.User; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 프론트와 맞춘 응답 형태를 고정한다. 필드 이름이나 순서가 바뀌면 앱이 그대로 깨진다. + */ +@DisplayName("훈장 API 계약") +class AchievementControllerTest extends AchievementTestSupport { + + @Test + @DisplayName("GET /api/achievements - 열두 개가 훈장표 순서로 내려간다") + void returnsAllTwelveInOrder() throws Exception { + User user = fixtures.createUser(); + + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.achievements.length()").value(12)) + .andExpect(jsonPath("$.data.achievements[*].key").value(Matchers.contains( + "first_step", "archivist", "persistence", "phoenix", "dawn_class", "night_owl", + "perfect_month", "flawless", "organizer", "reviewer", "companion", "cheerleader"))) + .andExpect(jsonPath("$.data.achievements[0].nameKo").value("첫 걸음")) + .andExpect(jsonPath("$.data.achievements[0].descriptionKo").value("오답노트를 처음 적었어요")) + .andExpect(jsonPath("$.data.achievements[0].imageUrl").value("assets/Medal/first_step.png")) + .andExpect(jsonPath("$.data.achievements[0].earned").value(false)) + .andExpect(jsonPath("$.data.achievements[0].earnedAt").doesNotExist()) + .andExpect(jsonPath("$.data.newlyEarned").isArray()) + .andExpect(jsonPath("$.data.newlyEarned").value(Matchers.empty())); + } + + @Test + @DisplayName("GET /api/achievements - 받은 훈장은 earnedAt 이 차고 newlyEarned 에 실린다") + void earnedAchievementCarriesEarnedAt() throws Exception { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + String earned = "$.data.achievements[?(@.key == 'phoenix')]"; + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath(earned + ".earned").value(Matchers.contains(true))) + .andExpect(jsonPath(earned + ".earnedAt").value(Matchers.contains(Matchers.notNullValue()))) + .andExpect(jsonPath("$.data.newlyEarned") + .value(Matchers.containsInAnyOrder("first_step", "phoenix"))); + } + + @Test + @DisplayName("GET /api/achievements - 진행도가 있는 훈장은 current/target 이 차고, 없는 훈장은 null 이다") + void progressFields() throws Exception { + User user = fixtures.createUser(); + saveFolders(user.getId(), 4); + + String organizer = "$.data.achievements[?(@.key == 'organizer')]"; + String phoenix = "$.data.achievements[?(@.key == 'phoenix')]"; + mockMvc.perform(get("/api/achievements").with(asUser(user.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath(organizer + ".current").value(Matchers.contains(4))) + .andExpect(jsonPath(organizer + ".target").value(Matchers.contains(10))) + .andExpect(jsonPath(phoenix + ".current").value(Matchers.contains(Matchers.nullValue()))) + .andExpect(jsonPath(phoenix + ".target").value(Matchers.contains(Matchers.nullValue()))); + } + + @Test + @DisplayName("GET /api/achievements - 남의 훈장은 안 보인다") + void doesNotLeakOtherUsersAchievements() throws Exception { + User owner = fixtures.createUser(); + User other = fixtures.createOtherUser(); + saveProblems(owner.getId(), 1); + achievementService.getAchievements(owner.getId()); + + mockMvc.perform(get("/api/achievements").with(asUser(other.getId()))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.achievements[?(@.earned == true)]").value(Matchers.empty())) + .andExpect(jsonPath("$.data.newlyEarned").value(Matchers.empty())); + } + + @Test + @DisplayName("GET /api/achievements - 비로그인은 401") + void requiresAuthentication() throws Exception { + clearAuthentication(); + + mockMvc.perform(get("/api/achievements")) + .andExpect(status().isUnauthorized()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java new file mode 100644 index 00000000..177b35ae --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java @@ -0,0 +1,137 @@ +package com.aisip.OnO.backend.achievement.migration; + +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Statement; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 마이그레이션이 중간에 끊겨도 다시 돌릴 수 있는지, 그리고 엔티티와 스키마가 어긋나지 않는지. + * + *

    테스트는 Flyway 를 끄고 Hibernate 가 만든 스키마를 쓴다({@code application-test.yml}). + * 그래서 V42 가 실제로 적용 가능한지는 dev 서버 기동 전에는 드러나지 않는다. 최소한 재실행 + * 안전성과 엔티티-스키마 일치만이라도 여기서 잠근다. {@code CosmeticMigrationTest} 와 같은 방식이다. + */ +@DisplayName("훈장 마이그레이션") +class AchievementMigrationTest extends AchievementTestSupport { + + private static final String MIGRATION = "db/migration/V42__create_user_achievement.sql"; + + @Test + @DisplayName("DDL 은 다시 돌려도 안전하게 쓰여 있다") + void ddlIsRerunnable() { + String ddl = statements(); + + assertThat(ddl).contains("CREATE TABLE IF NOT EXISTS USER_ACHIEVEMENT"); + // MySQL 에는 CREATE INDEX IF NOT EXISTS 가 없다. 인덱스를 따로 만들면 그 문장이 재실행 지점이 된다. + assertThat(ddl).doesNotContain("CREATE INDEX"); + // 훈장 목록은 코드의 enum 이라 시드가 없다. 시드가 섞이면 그 자리에서 끊길 때 테이블만 남는다. + assertThat(ddl).doesNotContain("INSERT INTO"); + } + + @Test + @DisplayName("이미 테이블이 있는 스키마에 다시 돌려도 터지지 않는다") + void rerunningDoesNotFail() { + // 테스트 DB 에는 user_achievement 가 이미 있다(엔티티에서 Hibernate 가 만든다). 그 위에 두 번 더 돌린다. + for (int attempt = 0; attempt < 2; attempt++) { + runMigrationOnOneConnection(); + } + + assertThat(achievementRowCount(fixtures.createUser().getId())).isZero(); + } + + /** + * 멱등성의 근거가 두 컬럼 복합 기본키라는 것을 잠근다. + * + *

    컬럼 순서까지는 단언하지 않는다. 운영 스키마는 이 마이그레이션이 만들어 + * {@code (user_id, achievement_key)} 인데, 테스트 스키마는 Hibernate 가 {@code @IdClass} 의 + * 속성 이름 순으로 만들어 {@code (achievement_key, user_id)} 가 된다. + * {@code user_cosmetic_loadout} 도 같은 사정이다. 중복을 막는 성질은 순서와 무관하므로 + * 여기서는 두 컬럼이 함께 기본키라는 것만 본다. 순서는 마이그레이션 본문으로 확인한다. + */ + @Test + @DisplayName("기본키가 (user_id, achievement_key) 복합키다 - 멱등성의 근거다") + void primaryKeyIsComposite() { + List keyColumns = jdbcTemplate.queryForList(""" + SELECT COLUMN_NAME + FROM information_schema.KEY_COLUMN_USAGE + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_achievement' + AND CONSTRAINT_NAME = 'PRIMARY' + """, String.class); + + assertThat(keyColumns).containsExactlyInAnyOrder("user_id", "achievement_key"); + assertThat(statements()) + .as("운영 스키마는 user_id 가 선두여야 한다. 조회가 언제나 user_id 로 들어온다") + .contains("PRIMARY KEY (USER_ID, ACHIEVEMENT_KEY)"); + } + + @Test + @DisplayName("achievement_key 길이가 마이그레이션과 엔티티에서 같다") + void keyColumnLengthMatchesMigration() { + Long length = jdbcTemplate.queryForObject(""" + SELECT CHARACTER_MAXIMUM_LENGTH + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_achievement' + AND COLUMN_NAME = 'achievement_key' + """, Long.class); + + assertThat(statements()).contains("ACHIEVEMENT_KEY VARCHAR(32)"); + assertThat(length) + .as("엔티티의 length 와 마이그레이션이 갈리면 길이 초과 오류를 테스트가 재현하지 못한다") + .isEqualTo(32L); + } + + private void runMigrationOnOneConnection() { + List statements = executableStatements(); + jdbcTemplate.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } + return null; + }); + } + + private List executableStatements() { + return Arrays.stream(withoutComments().split(";")) + .map(String::trim) + .filter(statement -> !statement.isEmpty()) + .toList(); + } + + /** 주석을 걷어내고 대문자로 맞춘 본문. 공백 차이에 걸리지 않게 여러 공백은 하나로 줄인다. */ + private String statements() { + return withoutComments().replaceAll("\\s+", " ").toUpperCase(Locale.ROOT); + } + + private String withoutComments() { + return Arrays.stream(read().split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + } + + private String read() { + try (InputStream inputStream = new ClassPathResource(MIGRATION).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java new file mode 100644 index 00000000..3ac1728c --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java @@ -0,0 +1,505 @@ +package com.aisip.OnO.backend.achievement.service; + +import com.aisip.OnO.backend.achievement.dto.AchievementListResponseDto; +import com.aisip.OnO.backend.achievement.dto.AchievementResponseDto; +import com.aisip.OnO.backend.achievement.entity.Achievement; +import com.aisip.OnO.backend.achievement.support.AchievementTestSupport; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("훈장 서비스") +class AchievementServiceTest extends AchievementTestSupport { + + /** 훈장표의 순서 그대로다. 앱이 이 순서를 그대로 그린다. */ + private static final List ORDERED_KEYS = List.of( + "first_step", "archivist", "persistence", "phoenix", "dawn_class", "night_owl", + "perfect_month", "flawless", "organizer", "reviewer", "companion", "cheerleader"); + + @Nested + @DisplayName("목록") + class Catalog { + + @Test + @DisplayName("아무것도 안 한 사용자에게도 열두 개가 전부 내려가고, 순서는 훈장표 순서다") + void returnsAllTwelveInContractOrder() { + User user = fixtures.createUser(); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(response.achievements()).hasSize(12); + assertThat(response.achievements().stream().map(AchievementResponseDto::key)) + .as("서버가 정한 순서를 앱이 그대로 그린다. 앱은 다시 정렬하지 않는다") + .containsExactlyElementsOf(ORDERED_KEYS); + assertThat(response.achievements()).allMatch(item -> !item.earned()); + assertThat(response.newlyEarned()).isEmpty(); + } + + @Test + @DisplayName("enum 순서와 훈장표 순서가 어긋나지 않는다") + void enumOrderMatchesContract() { + assertThat(java.util.Arrays.stream(Achievement.values()).map(Achievement::getKey)) + .containsExactlyElementsOf(ORDERED_KEYS); + } + + @Test + @DisplayName("이미지 경로는 앱 번들 경로다") + void imageUrlIsBundlePath() { + User user = fixtures.createUser(); + + assertThat(itemOf(user, "archivist").imageUrl()).isEqualTo("assets/Medal/archivist.png"); + } + } + + @Nested + @DisplayName("판정과 적립") + class Earning { + + @Test + @DisplayName("조건을 채우면 받고 newlyEarned 에 실린다") + void earnsWhenConditionMet() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(response.newlyEarned()).containsExactly("first_step"); + assertThat(itemOf(response, "first_step").earned()).isTrue(); + assertThat(itemOf(response, "first_step").earnedAt()).isNotNull(); + assertThat(achievementRowCount(user.getId())).isEqualTo(1); + } + + @Test + @DisplayName("연달아 두 번 부르면 두 번째는 newlyEarned 가 비고 행도 안 늘어난다") + void isIdempotent() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto first = achievementService.getAchievements(user.getId()); + AchievementListResponseDto second = achievementService.getAchievements(user.getId()); + + assertThat(first.newlyEarned()).containsExactly("first_step"); + assertThat(second.newlyEarned()).isEmpty(); + assertThat(achievementRowCount(user.getId())).isEqualTo(1); + assertThat(itemOf(second, "first_step").earned()).isTrue(); + } + + @Test + @DisplayName("받은 날짜는 두 번째 호출에서 덮어쓰이지 않는다") + void keepsFirstEarnedAt() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + LocalDateTime earnedAt = itemOf(achievementService.getAchievements(user.getId()), "first_step").earnedAt(); + jdbcTemplate.update("UPDATE user_achievement SET earned_at = ? WHERE user_id = ?", + LocalDateTime.of(2020, 1, 1, 0, 0), user.getId()); + + assertThat(earnedAt).isNotNull(); + assertThat(itemOf(achievementService.getAchievements(user.getId()), "first_step").earnedAt()) + .as("이미 있는 행은 건드리지 않는다. 덮어쓰면 화면을 열 때마다 받은 날이 오늘로 바뀐다") + .isEqualTo(LocalDateTime.of(2020, 1, 1, 0, 0)); + } + + @Test + @DisplayName("받은 뒤 데이터를 지워도 훈장은 남는다") + void neverRevoked() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + achievementService.getAchievements(user.getId()); + + problemRepository.delete(problem); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + assertThat(itemOf(response, "first_step").earned()) + .as("오답노트를 지웠다고 기록광을 뺏으면 지우는 것이 무서워진다") + .isTrue(); + assertThat(response.newlyEarned()).isEmpty(); + } + + @Test + @DisplayName("기록광은 오답노트 백 개에서 열린다 - 경계는 포함이다") + void archivistBoundaryIsInclusive() { + User user = fixtures.createUser(); + saveProblems(user.getId(), 99); + assertThat(itemOf(user, "archivist").earned()).isFalse(); + + saveProblem(user.getId()); + + assertThat(itemOf(user, "archivist").earned()).isTrue(); + } + + @Test + @DisplayName("남의 데이터는 내 훈장에 안 잡힌다") + void countsOnlyOwnData() { + User user = fixtures.createUser(); + User other = fixtures.createOtherUser(); + saveProblems(other.getId(), 3); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "first_step").earned()).isFalse(); + assertThat(itemOf(response, "archivist").current()).isZero(); + } + } + + @Nested + @DisplayName("진행도") + class Progress { + + @Test + @DisplayName("잠긴 훈장도 지금 몇까지 왔는지 함께 내려준다") + void showsCurrentAndTarget() { + User user = fixtures.createUser(); + saveFolders(user.getId(), 4); + + AchievementResponseDto organizer = itemOf(user, "organizer"); + + assertThat(organizer.earned()).isFalse(); + assertThat(organizer.current()).isEqualTo(4); + assertThat(organizer.target()).isEqualTo(10); + } + + @Test + @DisplayName("목표치를 넘어도 목표치로 잘라서 준다") + void clampsToTarget() { + User user = fixtures.createUser(); + saveFolders(user.getId(), 13); + + AchievementResponseDto organizer = itemOf(user, "organizer"); + + assertThat(organizer.earned()).isTrue(); + assertThat(organizer.current()).isEqualTo(10); + assertThat(organizer.target()).isEqualTo(10); + } + + @Test + @DisplayName("불사조와 첫 걸음은 진행도가 null 이다") + void noProgressForBinaryAchievements() { + User user = fixtures.createUser(); + saveProblem(user.getId()); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "first_step").current()).isNull(); + assertThat(itemOf(response, "first_step").target()).isNull(); + assertThat(itemOf(response, "phoenix").current()).isNull(); + assertThat(itemOf(response, "phoenix").target()).isNull(); + } + } + + @Nested + @DisplayName("새벽반 · 올빼미") + class TimeOfDay { + + @ParameterizedTest(name = "{0}시 {1}분 복습은 새벽반 {2} / 올빼미 {3}") + @CsvSource({ + "0, 0, 0, 1", + "2, 59, 0, 1", + "3, 0, 0, 0", + "4, 59, 0, 0", + "5, 0, 1, 0", + "7, 59, 1, 0", + "8, 0, 0, 0", + "23, 59, 0, 0", + }) + @DisplayName("경계는 KST 시각으로 05:00~08:00 과 00:00~03:00 이다") + void countsByKstHour(int hour, int minute, long dawn, long night) { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolve(problem, user.getId(), LocalDateTime.of(2026, 3, 2, hour, minute), AnswerStatus.CORRECT); + + AchievementListResponseDto response = achievementService.getAchievements(user.getId()); + + assertThat(itemOf(response, "dawn_class").current()).isEqualTo(dawn); + assertThat(itemOf(response, "night_owl").current()).isEqualTo(night); + } + + @Test + @DisplayName("새벽 열 번이면 받는다") + void earnsDawnClassAtTen() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + for (int i = 0; i < 10; i++) { + saveSolve(problem, user.getId(), + LocalDateTime.of(2026, 3, 2, 6, 0).plusDays(i), AnswerStatus.CORRECT); + } + + assertThat(itemOf(user, "dawn_class").earned()).isTrue(); + } + } + + @Nested + @DisplayName("무결점") + class Flawless { + + @Test + @DisplayName("UNKNOWN 에서 끊기지 않는다") + void unknownDoesNotBreakStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.UNKNOWN, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + assertThat(itemOf(user, "flawless").earned()) + .as("UNKNOWN 은 그때 맞혔는지 모르는 것이라 틀렸다고 보면 없는 실패를 만드는 셈이다") + .isTrue(); + } + + @Test + @DisplayName("오답에서 끊긴다") + void wrongBreaksStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.WRONG, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + AchievementResponseDto flawless = itemOf(user, "flawless"); + assertThat(flawless.earned()).isFalse(); + assertThat(flawless.current()).isEqualTo(5); + } + + @Test + @DisplayName("부분 정답에서도 끊긴다") + void partialBreaksStreak() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT, AnswerStatus.PARTIAL, + AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, AnswerStatus.CORRECT, + AnswerStatus.CORRECT); + + assertThat(itemOf(user, "flawless").earned()).isFalse(); + } + } + + @Nested + @DisplayName("불사조") + class Phoenix { + + @Test + @DisplayName("오답 뒤에 정답이면 받는다") + void wrongThenCorrect() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + assertThat(itemOf(user, "phoenix").earned()).isTrue(); + } + + @Test + @DisplayName("정답 뒤 오답 순서에서는 안 걸린다") + void correctThenWrongDoesNotCount() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.CORRECT, AnswerStatus.WRONG); + + assertThat(itemOf(user, "phoenix").earned()) + .as("practicedAt 순서를 본다. 틀린 뒤에 맞힌 것만 불사조다") + .isFalse(); + } + + @Test + @DisplayName("부분 정답은 맞힌 것으로 치지 않는다") + void partialIsNotCorrect() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.PARTIAL); + + assertThat(itemOf(user, "phoenix").earned()).isFalse(); + } + + @Test + @DisplayName("다른 문제에서 맞힌 것은 안 친다") + void differentProblemDoesNotCount() { + User user = fixtures.createUser(); + saveSolve(saveProblem(user.getId()), user.getId(), NOON, AnswerStatus.WRONG); + saveSolve(saveProblem(user.getId()), user.getId(), NOON.plusHours(1), AnswerStatus.CORRECT); + + assertThat(itemOf(user, "phoenix").earned()).isFalse(); + } + } + + @Nested + @DisplayName("집념") + class Persistence { + + @Test + @DisplayName("한 문제를 다섯 번 보면 받는다") + void sameProblemFiveTimes() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, + AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.WRONG, AnswerStatus.CORRECT); + + assertThat(itemOf(user, "persistence").earned()).isTrue(); + } + + @Test + @DisplayName("다섯 문제를 한 번씩 본 것은 다르다") + void fiveProblemsOnceEachDoesNotCount() { + User user = fixtures.createUser(); + for (int i = 0; i < 5; i++) { + saveSolve(saveProblem(user.getId()), user.getId(), NOON.plusHours(i), AnswerStatus.WRONG); + } + + AchievementResponseDto persistence = itemOf(user, "persistence"); + assertThat(persistence.earned()).isFalse(); + assertThat(persistence.current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("개근") + class PerfectMonth { + + @Test + @DisplayName("연속 서른 날이면 받는다") + void thirtyConsecutiveDays() { + User user = fixtures.createUser(); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 30); + + assertThat(itemOf(user, "perfect_month").earned()).isTrue(); + } + + @Test + @DisplayName("스물아홉 날에서 하루 빠지면 안 받는다") + void gapBreaksStreak() { + User user = fixtures.createUser(); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 29); + saveLoginDays(user, LocalDate.of(2026, 2, 1), 20); + + AchievementResponseDto perfectMonth = itemOf(user, "perfect_month"); + assertThat(perfectMonth.earned()).isFalse(); + assertThat(perfectMonth.current()) + .as("가장 길었던 구간을 본다. 지금 이어지는 20 일이 아니라 지난달의 29 일이다") + .isEqualTo(29); + } + + @Test + @DisplayName("하루에 여러 번 로그인해도 하루로 센다") + void multipleLoginsInOneDayCountOnce() { + User user = fixtures.createUser(); + for (int i = 0; i < 30; i++) { + saveLoginAt(user, LocalDate.of(2026, 1, 1).atTime(9, 0)); + } + + assertThat(itemOf(user, "perfect_month").current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("회고왕") + class Reviewer { + + @Test + @DisplayName("공백만 적은 회고는 안 센다") + void blankReflectionDoesNotCount() { + User user = fixtures.createUser(); + Problem problem = saveProblem(user.getId()); + saveSolve(problem, user.getId(), NOON, AnswerStatus.CORRECT, "적었다"); + saveSolve(problem, user.getId(), NOON.plusHours(1), AnswerStatus.CORRECT, " "); + saveSolve(problem, user.getId(), NOON.plusHours(2), AnswerStatus.CORRECT, null); + + assertThat(itemOf(user, "reviewer").current()).isEqualTo(1); + } + } + + @Nested + @DisplayName("동행 · 응원단장") + class StudyRoomAchievements { + + @Test + @DisplayName("스터디룸 한 곳에 들어가면 동행을 받는다") + void joiningOneRoomEarnsCompanion() { + User user = fixtures.createUser(); + joinNewRoom(user); + + assertThat(itemOf(user, "companion").earned()).isTrue(); + } + + @Test + @DisplayName("응원은 리액션 세 자리를 합쳐 센다") + void reactionsAreSummedAcrossThreeTables() { + User user = fixtures.createUser(); + StudyRoom room = joinNewRoom(user); + saveFeedReactions(user, room, 40); + saveSharedProblemReactions(user, room, 40); + saveCommentReactions(user, room, 20); + + assertThat(itemOf(user, "cheerleader").earned()) + .as("누른 자리가 어디든 응원한 것은 응원한 것이다") + .isTrue(); + } + + @Test + @DisplayName("합쳐도 백에 못 미치면 진행도만 오른다") + void reactionsBelowThreshold() { + User user = fixtures.createUser(); + StudyRoom room = joinNewRoom(user); + saveFeedReactions(user, room, 3); + saveCommentReactions(user, room, 2); + + AchievementResponseDto cheerleader = itemOf(user, "cheerleader"); + assertThat(cheerleader.earned()).isFalse(); + assertThat(cheerleader.current()).isEqualTo(5); + } + } + + @Nested + @DisplayName("쿼리 수") + class QueryCount { + + /** + * 훈장 화면을 열 때마다 도는 경로다. 열두 조건을 각각 세면 열둘이 넘는데, 여섯이 같은 + * 복습 기록 표를 보므로 그 표를 한 번만 읽는다. 이 숫자가 늘면 조건을 하나씩 세기 시작한 것이다. + */ + @Test + @DisplayName("조회 한 번에 아홉 번 나간다") + void countsNineQueries() { + User user = fixtures.createUser(); + saveProblems(user.getId(), 2); + saveFolders(user.getId(), 2); + Problem problem = saveProblem(user.getId()); + saveSolveSequence(problem, user.getId(), NOON, AnswerStatus.WRONG, AnswerStatus.CORRECT); + saveLoginDays(user, LocalDate.of(2026, 1, 1), 2); + // 새로 받을 훈장이 없는 정상 상태를 잰다. 첫 호출은 INSERT 가 섞여 숫자가 달라진다. + achievementService.getAchievements(user.getId()); + + long queryCount = queryCounter.count(() -> achievementService.getAchievements(user.getId())); + + assertThat(queryCount).isEqualTo(9); + } + } + + // ─────────────────────────── 도우미 ─────────────────────────── + + private AchievementResponseDto itemOf(User user, String key) { + return itemOf(achievementService.getAchievements(user.getId()), key); + } + + private AchievementResponseDto itemOf(AchievementListResponseDto response, String key) { + return response.achievements().stream() + .filter(item -> item.key().equals(key)) + .findFirst() + .orElseThrow(() -> new AssertionError("훈장 " + key + " 가 응답에 없다")); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java new file mode 100644 index 00000000..200be73a --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java @@ -0,0 +1,238 @@ +package com.aisip.OnO.backend.achievement.support; + +import com.aisip.OnO.backend.achievement.repository.UserAchievementRepository; +import com.aisip.OnO.backend.achievement.service.AchievementService; +import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; +import com.aisip.OnO.backend.mission.entity.MissionLog; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; +import com.aisip.OnO.backend.problemsolve.repository.ProblemSolveRepository; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeed; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedEventType; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomFeedReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemComment; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemCommentReaction; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblemReaction; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomFeedRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemReactionRepository; +import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemRepository; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.support.QueryCounter; +import com.aisip.OnO.backend.user.entity.User; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.request.RequestPostProcessor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; + +/** + * 훈장 도메인 테스트의 공통 베이스. + * + *

    {@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + * + *

    훈장은 열두 조건이 여섯 테이블에 흩어져 있어, 조건 하나를 확인하려면 그 테이블에 데이터를 + * 직접 심어야 한다. 서비스 경로로 만들면 미션 적립이 함께 끌려 들어와 무엇을 재는 테스트인지 흐려진다. + */ +public abstract class AchievementTestSupport extends IntegrationTestSupport { + + private static final AtomicLong SEQUENCE = new AtomicLong(); + + /** 복습 시각의 기본값. 새벽반(05~08)과 올빼미(00~03) 어느 쪽에도 안 걸리는 시각이다. */ + protected static final LocalDateTime NOON = LocalDateTime.of(2026, 3, 2, 12, 0); + + @Autowired + protected AchievementService achievementService; + + @Autowired + protected UserAchievementRepository userAchievementRepository; + + @Autowired + protected ProblemRepository problemRepository; + + @Autowired + protected ProblemSolveRepository problemSolveRepository; + + @Autowired + protected MissionLogRepository missionLogRepository; + + @Autowired + protected StudyRoomRepository studyRoomRepository; + + @Autowired + protected StudyRoomFeedRepository feedRepository; + + @Autowired + protected StudyRoomFeedReactionRepository feedReactionRepository; + + @Autowired + protected StudyRoomSharedProblemRepository sharedProblemRepository; + + @Autowired + protected StudyRoomSharedProblemReactionRepository sharedProblemReactionRepository; + + @Autowired + protected StudyRoomSharedProblemCommentRepository commentRepository; + + @Autowired + protected StudyRoomSharedProblemCommentReactionRepository commentReactionRepository; + + @Autowired + protected QueryCounter queryCounter; + + @Autowired + protected JdbcTemplate jdbcTemplate; + + /** + * MockMvc 요청에 인증 주체를 싣는다. + * + *

    {@code authenticateAs(userId)} 만으로도 MockMvc 가 인증을 읽지만, 한 테스트에서 + * 사용자를 바꿔 가며 요청할 때는 요청 단위로 붙이는 편이 어느 사용자의 요청인지 분명하다. + */ + protected RequestPostProcessor asUser(Long userId) { + return authentication(new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + } + + // ─────────────────────────── 오답노트 · 폴더 ─────────────────────────── + + protected Problem saveProblem(Long userId) { + return problemRepository.save( + Problem.from(new ProblemRegisterDto(null, "메모", null, null, null), userId)); + } + + protected void saveProblems(Long userId, int count) { + for (int i = 0; i < count; i++) { + saveProblem(userId); + } + } + + protected void saveFolders(Long userId, int count) { + for (int i = 0; i < count; i++) { + fixtures.createFolder(userId, "폴더" + SEQUENCE.incrementAndGet(), null); + } + } + + // ─────────────────────────── 복습 기록 ─────────────────────────── + + protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime practicedAt, AnswerStatus status) { + return saveSolve(problem, userId, practicedAt, status, "회고"); + } + + protected ProblemSolve saveSolve(Problem problem, Long userId, LocalDateTime practicedAt, + AnswerStatus status, String reflection) { + return problemSolveRepository.save( + ProblemSolve.create(problem, userId, practicedAt, status, reflection, null, 120, null)); + } + + /** + * 같은 문제를 시각만 한 시간씩 밀어 여러 번 푼 기록. + * + *

    무결점과 불사조는 복습 시각 순서를 보기 때문에 모든 기록을 같은 시각에 넣으면 + * 무엇을 재는 테스트인지 알 수 없게 된다. 항상 순서가 드러나게 만든다. + */ + protected void saveSolveSequence(Problem problem, Long userId, LocalDateTime start, AnswerStatus... statuses) { + for (int i = 0; i < statuses.length; i++) { + saveSolve(problem, userId, start.plusHours(i), statuses[i]); + } + } + + // ─────────────────────────── 로그인 기록 ─────────────────────────── + + /** + * {@code date} 부터 하루씩 이어지는 로그인 기록. + * + *

    {@code created_at} 은 JPA Auditing 이 채우므로 애플리케이션에서는 과거 시각을 만들 수 없다. + * 연속 출석은 과거 날짜 없이는 검증할 수 없어 직접 밀어 넣는다. {@code MissionTestSupport} 와 같은 방법이다. + */ + protected void saveLoginDays(User user, LocalDate startDate, int days) { + for (int i = 0; i < days; i++) { + saveLoginAt(user, startDate.plusDays(i).atTime(9, 0)); + } + } + + protected void saveLoginAt(User user, LocalDateTime createdAt) { + MissionLog log = missionLogRepository.save(MissionLog.from( + MissionRegisterDto.builder() + .userId(user.getId()) + .missionType(MissionType.USER_LOGIN) + .referenceId(SEQUENCE.incrementAndGet()) + .build(), + user)); + jdbcTemplate.update("UPDATE mission_log SET created_at = ? WHERE id = ?", createdAt, log.getId()); + } + + // ─────────────────────────── 스터디룸 · 리액션 ─────────────────────────── + + protected StudyRoom joinNewRoom(User user) { + StudyRoom room = StudyRoom.create("스터디룸" + SEQUENCE.incrementAndGet(), user.getId()); + room.addMember(StudyRoomMember.create(user, StudyRoomMemberRole.HOST)); + return studyRoomRepository.save(room); + } + + /** + * 피드 하나에 리액션 {@code count} 개. + * + *

    이모지를 매번 다르게 쓴다. {@code (feed_id, user_id, emoji)} 유니크 키 때문에 같은 이모지로는 + * 한 번만 누를 수 있다. 응원 백 번을 만들려고 피드를 백 개 만드는 것보다 이쪽이 싸다. + */ + protected void saveFeedReactions(User user, StudyRoom room, int count) { + StudyRoomFeed feed = feedRepository.save( + StudyRoomFeed.create(room, user, StudyRoomFeedEventType.PRACTICE_COMPLETED, null)); + for (int i = 0; i < count; i++) { + feedReactionRepository.save(StudyRoomFeedReaction.create(feed, user, emoji())); + } + } + + protected void saveSharedProblemReactions(User user, StudyRoom room, int count) { + StudyRoomSharedProblem shared = sharedProblemRepository.save( + StudyRoomSharedProblem.create(room, user, saveProblem(user.getId()), "같이 봐요")); + for (int i = 0; i < count; i++) { + sharedProblemReactionRepository.save( + StudyRoomSharedProblemReaction.create(shared, user, emoji())); + } + } + + protected void saveCommentReactions(User user, StudyRoom room, int count) { + StudyRoomSharedProblem shared = sharedProblemRepository.save( + StudyRoomSharedProblem.create(room, user, saveProblem(user.getId()), "같이 봐요")); + StudyRoomSharedProblemComment comment = commentRepository.save( + StudyRoomSharedProblemComment.create(shared, user, "저도 틀렸어요")); + for (int i = 0; i < count; i++) { + commentReactionRepository.save( + StudyRoomSharedProblemCommentReaction.create(comment, user, emoji())); + } + } + + private String emoji() { + return "cheer_" + SEQUENCE.incrementAndGet(); + } + + // ─────────────────────────── 확인 ─────────────────────────── + + /** 훈장 행 개수. 두 번 불러도 안 늘어나는지 확인할 때 쓴다. */ + protected long achievementRowCount(Long userId) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_achievement WHERE user_id = ?", Long.class, userId); + return count == null ? 0 : count; + } +} From e8d6b58360571d8a99b958145fbdc5d2147d91f6 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 01:53:56 +0900 Subject: [PATCH 095/130] =?UTF-8?q?[Fix]=20=EC=A0=95=EB=A6=AC=EC=9D=98=20?= =?UTF-8?q?=EC=8B=A0=EC=97=90=EC=84=9C=20=EB=A3=A8=ED=8A=B8=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EB=B9=BC=EA=B3=A0=20=EC=84=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FolderService.initializeDefaultFoldersIfAbsent 가 가입 시점에 루트 폴더와 기본 하위 폴더를 만들어, 아무것도 안 한 사람이 2/10 에서 시작했다 - countByUserId 를 countByUserIdAndParentFolderIsNotNull 로 바꿔 루트를 뺀다. 목표는 10 그대로다 - 기본 하위 폴더는 빼지 않는다. 이름으로만 거를 수 있어 사용자가 이름을 바꾸면 판정이 달라진다. 루트는 parentFolder IS NULL 이라는 구조로 걸러져 이름과 무관하다 - 리액션 세 테이블은 user_id 선두 인덱스가 이미 있다. user_id 에 걸린 외래키가 InnoDB 인덱스를 함께 만들고(V6, V11), 댓글 리액션은 idx_shared_problem_comment_reaction_user 로 명시돼 있다. 중복 인덱스를 만들지 않는 대신, 외래키를 떼면 조용히 사라지는 근거라 테스트로 잠갔다 - 테스트 3건 추가: 가입 직후 진행도 1 이하, 루트는 안 세어짐, 리액션 세 테이블의 user_id 선두 인덱스 --- .../service/AchievementStatsCollector.java | 12 ++++-- .../folder/repository/FolderRepository.java | 16 +++++++- .../migration/AchievementMigrationTest.java | 40 +++++++++++++++++++ .../service/AchievementServiceTest.java | 23 +++++++++++ .../support/AchievementTestSupport.java | 15 ++++++- 5 files changed, 99 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java index 5cfae01f..77f73925 100644 --- a/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java +++ b/src/main/java/com/aisip/OnO/backend/achievement/service/AchievementStatsCollector.java @@ -30,9 +30,13 @@ * ({@link SolveScan}). 나머지는 인덱스를 타는 단순 count 라 더 접을 것이 없다. * *

    리액션 셋을 한 문장으로 합치면 아홉이 일곱이 되지만 그렇게 하지 않았다. 세 도메인에 걸친 - * 네이티브 쿼리를 어느 리포지토리에도 자연스럽게 둘 수 없고, 정작 비용은 문장 수가 아니라 - * {@code study_room_feed_reaction} 과 {@code study_room_shared_problem_reaction} 에 - * {@code user_id} 선두 인덱스가 없다는 쪽에 있다. 합쳐도 그 스캔은 그대로다. + * 네이티브 쿼리를 어느 리포지토리에도 자연스럽게 둘 수 없는 데 비해, 줄어드는 것은 문장 두 개뿐이다. + * + *

    리액션 세 테이블의 {@code COUNT(*) WHERE user_id = ?} 는 전부 인덱스를 탄다. 유니크 키가 + * {@code (feed_id, user_id, emoji)} 처럼 {@code user_id} 를 선두에 두지 않아 그것만 보면 풀스캔처럼 + * 보이지만, 세 테이블 모두 {@code user_id} 에 사용자 테이블을 향한 외래키가 걸려 있어 InnoDB 가 + * {@code (user_id)} 단독 인덱스를 함께 만든다(V6, V11). 댓글 리액션은 {@code idx_shared_problem_comment_reaction_user} + * 로 명시까지 돼 있다. {@code AchievementMigrationTest} 가 이 인덱스들이 사라지지 않는지 잠근다. */ @Component @RequiredArgsConstructor @@ -53,7 +57,7 @@ public AchievementStats collect(Long userId) { return new AchievementStats( nullSafe(problemRepository.countByUserId(userId)), - folderRepository.countByUserId(userId), + folderRepository.countByUserIdAndParentFolderIsNotNull(userId), solves.maxSolveCountOnOneProblem(), solves.comebackCount(), solves.dawnSolveCount(), diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 24f89e15..8371f6a4 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -16,8 +16,20 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByUserId(Long userId); - /** 훈장 '정리의 신' 판정용. 소프트 삭제된 폴더는 {@code @SQLRestriction} 이 걸러 준다. */ - long countByUserId(Long userId); + /** + * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. + * + *

    {@code FolderService.initializeDefaultFoldersIfAbsent} 가 가입 시점에 루트 폴더와 기본 하위 + * 폴더를 자동으로 만든다. 전부 세면 아무것도 안 한 사람이 2/10 에서 시작해 "폴더를 열 개나 만들어 + * 정리했어요" 라는 말과 안 맞는다. + * + *

    기본 하위 폴더까지 빼지 않는 이유는 그쪽은 이름으로만 거를 수 있기 때문이다. 사용자가 이름을 + * 바꾸는 순간 판정이 달라진다. 루트는 {@code parentFolder IS NULL} 이라는 구조로 걸러져 이름과 무관하다. + * 그래서 갓 가입한 사람은 1/10 에서 시작하고, 그 하나는 실제로 자기 폴더 트리에 있는 폴더다. + * + *

    소프트 삭제된 폴더는 {@code @SQLRestriction} 이 걸러 준다. + */ + long countByUserIdAndParentFolderIsNotNull(Long userId); @Modifying @Query("delete from Folder f where f.id in :folderIds") diff --git a/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java index 177b35ae..c6ec4119 100644 --- a/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/achievement/migration/AchievementMigrationTest.java @@ -97,6 +97,46 @@ void keyColumnLengthMatchesMigration() { .isEqualTo(32L); } + /** + * 응원단장이 세는 리액션 세 테이블이 {@code user_id} 로 시작하는 인덱스를 갖고 있는지. + * + *

    훈장 화면을 열 때마다 세 테이블에 {@code COUNT(*) WHERE user_id = ?} 가 나간다. 인덱스가 + * 없으면 그대로 풀스캔이고, 리액션은 사용자가 늘수록 가장 빨리 자라는 표 중 하나다. + * + *

    지금은 인덱스를 새로 만들 필요가 없다. 세 테이블 모두 {@code user_id} 에 사용자 테이블을 향한 + * 외래키가 걸려 있어 InnoDB 가 {@code (user_id)} 단독 인덱스를 함께 만들어 두었고(V6, V11), + * 댓글 리액션은 {@code idx_shared_problem_comment_reaction_user} 로 명시까지 돼 있다. + * 같은 것을 한 벌 더 만들면 쓰기마다 갱신할 인덱스만 늘어난다. + * + *

    그래서 이 테스트가 필요하다. 근거가 외래키가 딸려 만든 인덱스라, 나중에 외래키를 떼는 + * 변경이 있으면 인덱스도 조용히 같이 사라진다. 그때 훈장 화면이 느려지고 나서야 알게 되는 대신 + * 여기서 걸린다. + */ + @Test + @DisplayName("리액션 세 테이블이 user_id 선두 인덱스를 갖고 있다 - 응원단장 카운트가 이것을 탄다") + void reactionTablesAreIndexedByUserId() { + List reactionTables = List.of( + "study_room_feed_reaction", + "study_room_shared_problem_reaction", + "study_room_shared_problem_comment_reaction"); + + for (String table : reactionTables) { + Long indexCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = ? + AND COLUMN_NAME = 'user_id' + AND SEQ_IN_INDEX = 1 + """, Long.class, table); + + assertThat(indexCount) + .as(table + " 에 user_id 선두 인덱스가 없으면 훈장 화면을 열 때마다 풀스캔이 돈다") + .isNotNull() + .isPositive(); + } + } + private void runMigrationOnOneConnection() { List statements = executableStatements(); jdbcTemplate.execute((ConnectionCallback) connection -> { diff --git a/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java index 3ac1728c..0543cd2c 100644 --- a/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/achievement/service/AchievementServiceTest.java @@ -171,6 +171,29 @@ void showsCurrentAndTarget() { assertThat(organizer.target()).isEqualTo(10); } + @Test + @DisplayName("가입 직후 기본 폴더만 있는 사용자는 정리의 신이 1 에서 시작한다") + void defaultFoldersDoNotInflateProgress() { + User user = fixtures.createUser(); + // 실제 가입 경로가 부르는 것과 같은 메서드다. 루트 폴더와 기본 하위 폴더가 생긴다. + folderService.initializeDefaultFoldersIfAbsent(user.getId()); + + assertThat(itemOf(user, "organizer").current()) + .as("아무것도 안 한 사람이 2/10 에서 시작하면 '폴더를 열 개나 만들어 정리했어요' 와 안 맞는다") + .isLessThanOrEqualTo(1L); + } + + @Test + @DisplayName("루트 폴더는 안 센다") + void rootFolderIsNotCounted() { + User user = fixtures.createUser(); + fixtures.createRootFolder(user.getId()); + + assertThat(itemOf(user, "organizer").current()) + .as("루트는 사용자가 만든 것이 아니라 parentFolder IS NULL 이라는 구조로 걸러진다") + .isZero(); + } + @Test @DisplayName("목표치를 넘어도 목표치로 잘라서 준다") void clampsToTarget() { diff --git a/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java index 200be73a..2591be6a 100644 --- a/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/achievement/support/AchievementTestSupport.java @@ -2,6 +2,8 @@ import com.aisip.OnO.backend.achievement.repository.UserAchievementRepository; import com.aisip.OnO.backend.achievement.service.AchievementService; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; import com.aisip.OnO.backend.mission.entity.MissionType; @@ -69,6 +71,9 @@ public abstract class AchievementTestSupport extends IntegrationTestSupport { @Autowired protected ProblemRepository problemRepository; + @Autowired + protected FolderService folderService; + @Autowired protected ProblemSolveRepository problemSolveRepository; @@ -126,9 +131,17 @@ protected void saveProblems(Long userId, int count) { } } + /** + * 루트 폴더 하나와 그 아래 하위 폴더 {@code count} 개. + * + *

    훈장은 루트를 안 센다. 사용자가 직접 만드는 폴더는 언제나 루트 아래에 붙으므로 + * ({@code FolderService.createFolder} 는 부모 없는 생성을 거절한다) 같은 모양으로 만든다. + * 전부 루트로 만들면 판정이 0 이 나와 무엇을 재는 테스트인지 알 수 없게 된다. + */ protected void saveFolders(Long userId, int count) { + Folder root = fixtures.createRootFolder(userId); for (int i = 0; i < count; i++) { - fixtures.createFolder(userId, "폴더" + SEQUENCE.incrementAndGet(), null); + fixtures.createFolder(userId, "폴더" + SEQUENCE.incrementAndGet(), root); } } From 0d8b7ee0d880427b10aebcc304d08ab201320672 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 14:34:51 +0900 Subject: [PATCH 096/130] =?UTF-8?q?[Fix]=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EB=A6=BD=EC=9D=84=20=EC=95=B1=20=EB=B2=84=EC=A0=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=88=EB=9D=BC=20XP=20=EC=9D=B4=EC=A4=91=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=EC=9D=84=20=EB=81=8A=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - X-App-Version 헤더의 semver 로 자동 적립 여부를 요청마다 가른다. 미션을 받을 수 있는 버전이면 끄고, 아니면 지금처럼 켠다. - 기준 버전은 ono.mission.mission-capable-version 설정값으로 두고 기본값을 4.0.0 으로 잡았다. 헤더가 붙기 시작하는 버전이라, 배포 없이 기준을 올려 되돌릴 수 있다. - 헤더가 없거나 읽을 수 없거나 HTTP 요청이 아닌 자리에서 불리면 전부 구버전으로 본다. 구버전은 claim 을 부를 방법이 없어, 덜 주는 쪽이 더 위험하다. - ono.mission.legacy-accrual.enabled 는 비상 스위치로 남겼다. 꺼지면 버전을 보지 않고 전부 끈다. - 버전은 RequestContextHolder 에서 꺼낸다. 적립까지 가는 호출 경로의 시그니처를 바꾸지 않기 위해서다. - 이미 받은 XP 는 손대지 않는다. 마이그레이션이나 보정 배치는 없다. --- .../OnO/backend/common/web/AppVersion.java | 74 ++++ .../common/web/AppVersionResolver.java | 47 +++ .../mission/service/MissionLogService.java | 65 +++- .../common/web/AppVersionResolverTest.java | 89 +++++ .../backend/common/web/AppVersionTest.java | 123 ++++++ .../service/MissionAccrualAppVersionTest.java | 353 ++++++++++++++++++ .../mission/support/MissionTestSupport.java | 15 +- 7 files changed, 754 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java create mode 100644 src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/common/web/AppVersionTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java new file mode 100644 index 00000000..012428d0 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersion.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.common.web; + +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * 앱이 보내온 버전. {@code major.minor.patch} 세 자리만 담는다. + * + *

    프론트는 {@code pubspec.yaml} 의 {@code version} 을 그대로 {@code 4.0.0+70} 꼴로 보낸다. + * {@code +} 뒤의 빌드 번호는 버린다. 빌드 번호는 스토어에 올릴 때마다 올라가는 값이라 + * 같은 스토어 버전 안에서도 제각각이고, "이 버전부터 미션을 받을 수 있다" 를 가르는 데 쓸 수 없다. + * 기준을 빌드 번호까지 내리면 설정값을 스토어 제출마다 따라 고쳐야 한다. + * + *

    읽지 못한 값은 예외 대신 빈 값으로 돌려준다. 이 값을 쓰는 쪽의 규칙이 "모르면 구버전" 이라 + * 파싱 실패는 사고가 아니라 정상 분기다. 헤더는 앱이 채우는 값이라 무엇이 들어올지 서버가 정할 수 없고, + * 여기서 예외를 던지면 XP 적립 경로 한가운데서 요청이 500 으로 죽는다. + */ +public record AppVersion(int major, int minor, int patch) implements Comparable { + + /** 세 자리 숫자만 받는다. {@code 4}, {@code 4.0}, {@code 4.0.x} 는 버전으로 보지 않는다. */ + private static final Pattern SEMVER = Pattern.compile("(\\d+)\\.(\\d+)\\.(\\d+)"); + + public static Optional parse(String raw) { + if (raw == null || raw.isBlank()) { + return Optional.empty(); + } + + String semver = raw.trim(); + + // 빌드 번호(4.0.0+70)와 프리릴리즈 꼬리(4.1.0-beta.1)를 떼어 낸다. + // 프리릴리즈는 지금 프론트가 쓰지 않지만, 붙어 오면 통째로 파싱 실패해 + // 내부 배포 빌드가 전부 구버전으로 떨어지는 것보다 앞자리만 읽는 편이 낫다. + int buildAt = semver.indexOf('+'); + if (buildAt >= 0) { + semver = semver.substring(0, buildAt); + } + int preReleaseAt = semver.indexOf('-'); + if (preReleaseAt >= 0) { + semver = semver.substring(0, preReleaseAt); + } + + var matcher = SEMVER.matcher(semver); + if (!matcher.matches()) { + return Optional.empty(); + } + + try { + return Optional.of(new AppVersion( + Integer.parseInt(matcher.group(1)), + Integer.parseInt(matcher.group(2)), + Integer.parseInt(matcher.group(3)) + )); + } catch (NumberFormatException e) { + // 자리 수가 int 를 넘는 값. 정상 앱에서 나올 수 없지만 헤더는 위조할 수 있다. + return Optional.empty(); + } + } + + @Override + public int compareTo(AppVersion other) { + if (major != other.major) { + return Integer.compare(major, other.major); + } + if (minor != other.minor) { + return Integer.compare(minor, other.minor); + } + return Integer.compare(patch, other.patch); + } + + /** {@code other} 와 같거나 그보다 높은 버전인가. */ + public boolean isAtLeast(AppVersion other) { + return compareTo(other) >= 0; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java new file mode 100644 index 00000000..48da86c0 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java @@ -0,0 +1,47 @@ +package com.aisip.OnO.backend.common.web; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 지금 처리 중인 HTTP 요청의 {@code X-App-Version} 헤더를 읽는다. + * + *

    프론트가 모든 요청에 {@code 4.0.0+70} 꼴로 붙인다(AI-SIP/OnO_FRONT#214). + * 버전을 못 읽으면 프론트는 헤더를 아예 보내지 않는다. 빈 문자열이나 {@code unknown} 은 오지 않는다. + * + *

    왜 인자로 넘기지 않고 요청 스코프에서 꺼내는가. 이 값이 필요한 곳은 + * {@code MissionLogService.addPointToUser} 한 군데인데, 거기까지 가는 길이 + * 컨트롤러 → {@code ProblemService}/{@code PracticeNoteService}/{@code ProblemSolveService} → 적립 으로 + * 서너 단계다. 버전을 인자로 물려 내리면 그 경로의 메서드 시그니처가 전부 바뀌고, 적립과 아무 상관 없는 + * 중간 호출부까지 버전을 들고 다녀야 한다. 읽는 곳이 하나뿐인 값 때문에 호출 경로 전체를 오염시킬 이유가 없다. + * + *

    왜 필터나 인터셉터 + 요청 스코프 빈을 새로 두지 않는가. {@code FrameworkServlet} 이 요청마다 + * {@code RequestContextHolder} 를 채우고 끝나면 지운다. 헤더를 읽기만 하면 되는 일에 등록 순서와 + * 생명주기를 가진 컴포넌트를 하나 더 얹을 이유가 없다. MockMvc 도 같은 경로를 타기 때문에 + * 통합 테스트에서 실제 헤더가 서비스까지 닿는지 그대로 확인된다. + * + *

    HTTP 요청이 없는 곳에서 불려도 터지지 않는다. 요청이 없으면 예외를 던지는 + * {@code currentRequestAttributes()} 대신 {@code getRequestAttributes()} 를 쓴다. 지금 적립을 부르는 + * 경로는 전부 HTTP 요청 안이지만(Quartz 잡 5종과 RabbitMQ 소비자 4종 어디에서도 적립을 부르지 않는다), + * 나중에 배치나 비동기 경로가 하나 늘어도 조용히 "모르는 버전" 으로 떨어져야 한다. + * {@code @Async} 스레드처럼 요청 컨텍스트가 물려지지 않는 자리도 마찬가지다. + */ +@Component +public class AppVersionResolver { + + public static final String APP_VERSION_HEADER = "X-App-Version"; + + public Optional resolve() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (!(attributes instanceof ServletRequestAttributes servletAttributes)) { + return Optional.empty(); + } + + HttpServletRequest request = servletAttributes.getRequest(); + return AppVersion.parse(request.getHeader(APP_VERSION_HEADER)); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 3b8ad423..edb283c4 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -2,6 +2,8 @@ import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.common.web.AppVersion; +import com.aisip.OnO.backend.common.web.AppVersionResolver; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; import com.aisip.OnO.backend.mission.entity.MissionMetric; @@ -34,20 +36,22 @@ /** * 사용자의 행동 기록({@code mission_log})을 남긴다. * - *

    자동 적립은 설정으로 켜고 끈다. {@code ono.mission.legacy-accrual.enabled} 이고 기본값은 켜짐이다. + *

    자동 적립은 요청이 온 앱 버전에 따라 갈린다. 미션을 받을 수 있는 앱이면 끄고, 아니면 켠다. + * 기준은 {@code ono.mission.mission-capable-version} 이다. 그 위에 버전과 무관하게 전부 끄는 + * 비상 스위치로 {@code ono.mission.legacy-accrual.enabled} 가 있고, 이쪽이 우선한다. * - *

    최종 목표는 끄는 것이다. 켜져 있으면 같은 XP 가 두 경로로 들어온다. 오답노트를 하나 쓰면 + *

    가르는 이유는 켜져 있으면 같은 XP 가 두 경로로 들어오기 때문이다. 오답노트를 하나 쓰면 * 자동 적립 +10 이 조용히 들어가고 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, * 화면의 {@code +10 XP} 와 실제 증가량 20 이 어긋난다. 하루 200점 상한도 자동 적립에만 걸려 * 같은 이름의 XP 가 출처에 따라 다른 규칙으로 움직인다. * - *

    그런데도 기본값을 켜짐으로 두는 이유는 배포 순서 때문이다. 백엔드가 먼저 나가고 프론트에 - * 미션 화면이 없는 동안 자동 적립까지 꺼져 있으면 XP 유입이 통째로 멈춰 레벨도 스터디룸 랭킹도 정지한다. - * 켜 둔 채로 백엔드를 배포하면 사용자 입장에서는 달라지는 게 없고, 화면이 없으니 미션을 받을 수도 없어 - * 이중 지급도 일어나지 않는다. 프론트가 나간 뒤 설정으로 끄면 흡수가 끝난다. 각 단계가 되돌릴 수 있다. + *

    그렇다고 플래그 하나로 전체를 끌 수는 없다. 적립과 미션 진행도는 서버가 올리지만 + * 받는 것은 앱이 한다. 미션 화면이 없는 구버전 앱은 {@code claim} 을 부를 방법이 없어, + * 통째로 끄면 구버전 사용자는 공부를 해도 XP 가 한 점도 안 쌓인다. 진행도만 쌓이고 레벨은 멈춘다. + * 스토어 심사와 강제 업데이트 없이는 구버전이 한동안 남으므로, 요청마다 앱 버전을 보고 가른다. * *

    꺼져도 기록은 그대로 남는다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. - * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 플래그와 무관하게 돈다. + * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 설정과 무관하게 돈다. * 꺼지는 것은 포인트 지급 하나뿐이다. */ @Service @@ -63,15 +67,34 @@ public class MissionLogService { private final MissionProgressUpdater missionProgressUpdater; + private final AppVersionResolver appVersionResolver; + /** * 행동만으로 경험치를 주던 예전 적립을 계속 쓸지. * - *

    기본값이 켜짐이라 설정을 건드리지 않으면 지금 운영과 똑같이 동작한다. - * 끄는 것은 프론트에 미션 화면이 나간 뒤의 별도 결정이다. + *

    비상 스위치다. 꺼지면 앱 버전을 보지 않고 전부 끈다. 자동 적립 자체에 문제가 생겼을 때 + * 배포 없이 통째로 멈출 자리가 하나 있어야 한다. + * + *

    기본값이 켜짐이라 설정을 건드리지 않으면 버전 판정만 돈다. */ @Value("${ono.mission.legacy-accrual.enabled:true}") private boolean legacyAccrualEnabled; + /** + * 미션을 받을 수 있는 첫 앱 버전. 이 버전 이상에서 온 요청은 자동 적립을 끈다. + * + *

    기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 앱은 + * 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 어디까지를 새 앱으로 볼지만 + * 가른다. 미션 화면은 이미 4.0.0 앱에 있으니(치장·미션 아이콘 에셋이 들어 있다) 기준을 더 낮출 이유가 없고, + * 더 높이면 헤더를 보내는 새 앱에서 이중 지급이 계속된다. + * + *

    설정값으로 둔 이유는 미션 화면이 빠지거나 받기가 고장 난 버전이 뒤늦게 드러났을 때 + * 배포 없이 기준을 올려 되돌리기 위해서다. + */ + @Value("${ono.mission.mission-capable-version:4.0.0}") + private String missionCapableVersion; + private static final Long DAILY_MISSION_POINT_LIMIT = 200L; /** @@ -199,17 +222,23 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { /** * 행동 자체에 대한 자동 적립. * - *

    플래그가 꺼져 있으면 아무것도 하지 않는다. 이 메서드만 비는 것이지 호출부의 기록 저장과 + *

    적립하지 않기로 하면 아무것도 하지 않는다. 이 메서드만 비는 것이지 호출부의 기록 저장과 * 중복 방지 판정, 진행도 증가는 그대로 돈다. * *

    하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 * 따로 지급하고 상한을 타지 않는다. */ private Long addPointToUser(User user, MissionLog missionLog) { + // 비상 스위치가 먼저다. 꺼져 있으면 버전을 아예 보지 않는다. if (!legacyAccrualEnabled) { return 0L; } + // 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. + if (missionCapableRequest()) { + return 0L; + } + Long pointToday = missionLogRepository.getPointSumToday(user.getId()); if (pointToday <= DAILY_MISSION_POINT_LIMIT) { Long point = Math.min(missionLog.getPoint(), DAILY_MISSION_POINT_LIMIT - pointToday); @@ -227,6 +256,22 @@ private Long addPointToUser(User user, MissionLog missionLog) { return 0L; } + /** + * 이번 요청이 미션을 받을 수 있는 앱에서 왔는가. + * + *

    모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 자동 적립이 한 번 더 도는 것은 XP 를 조금 더 주는 일이지만, + * 반대로 틀리면 구버전 사용자는 받을 방법이 없어 XP 가 통째로 멈춘다. 덜 주는 쪽이 더 위험하다. + * + *

    기준값이 읽히지 않을 때도 같은 이유로 아무도 새 앱으로 보지 않는다. 설정 오타 하나가 + * 전체 사용자의 XP 유입을 끊는 것보다 이중 지급이 조금 더 도는 편이 낫다. + */ + private boolean missionCapableRequest() { + return AppVersion.parse(missionCapableVersion) + .flatMap(threshold -> appVersionResolver.resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } + @Transactional(readOnly = true) public List findAllByUserId(Long userId) { return missionLogRepository.findAllByUserId(userId); diff --git a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java new file mode 100644 index 00000000..d03dd724 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java @@ -0,0 +1,89 @@ +package com.aisip.OnO.backend.common.web; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 요청에서 앱 버전을 꺼내는 자리. + * + *

    핵심은 HTTP 요청이 없는 곳에서 불려도 터지지 않는 것이다. 적립은 지금 전부 HTTP 요청 안에서 + * 불리지만, 배치나 비동기 경로가 하나라도 늘면 여기서 예외가 나 그 경로가 통째로 죽는다. + */ +@DisplayName("앱 버전 헤더 읽기") +class AppVersionResolverTest { + + private final AppVersionResolver resolver = new AppVersionResolver(); + + @AfterEach + void clearRequestContext() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("헤더를 보내면 그 버전을 읽는다") + void readsHeader() { + bindRequestWithHeader("4.0.0+70"); + + assertThat(resolver.resolve()).contains(new AppVersion(4, 0, 0)); + } + + @Test + @DisplayName("헤더가 없으면 빈 값이다") + void emptyWhenHeaderMissing() { + bindRequestWithHeader(null); + + assertThat(resolver.resolve()).isEmpty(); + } + + @Test + @DisplayName("읽을 수 없는 헤더도 예외 없이 빈 값이다") + void emptyWhenHeaderUnparsable() { + bindRequestWithHeader("abc"); + + assertThat(resolver.resolve()).isEmpty(); + } + + @Test + @DisplayName("HTTP 요청이 없으면 예외 대신 빈 값이다") + void emptyOutsideHttpRequest() { + RequestContextHolder.resetRequestAttributes(); + + assertThatCode(resolver::resolve).doesNotThrowAnyException(); + assertThat(resolver.resolve()).isEmpty(); + } + + @Test + @DisplayName("요청 컨텍스트가 물려지지 않는 다른 스레드에서도 터지지 않는다") + void emptyOnThreadWithoutInheritedContext() throws Exception { + bindRequestWithHeader("4.0.0+70"); + + // @Async 나 큐 소비자처럼 요청 스레드가 아닌 자리를 흉내 낸다. + // RequestContextHolder 는 기본적으로 자식 스레드에 물려지지 않는다. + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future empty = executor.submit(() -> resolver.resolve().isEmpty()); + + assertThat(empty.get()).isTrue(); + } finally { + executor.shutdownNow(); + } + } + + private void bindRequestWithHeader(String appVersion) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (appVersion != null) { + request.addHeader(AppVersionResolver.APP_VERSION_HEADER, appVersion); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionTest.java b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionTest.java new file mode 100644 index 00000000..0bb12d32 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionTest.java @@ -0,0 +1,123 @@ +package com.aisip.OnO.backend.common.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** + * 앱이 보내오는 버전 문자열 파싱. + * + *

    이 값은 앱이 채우는 헤더라 서버가 무엇이 올지 정할 수 없다. 어떤 입력에도 예외를 던지지 않고 + * 빈 값으로 떨어지는 것이 이 클래스의 계약이다. 여기서 예외가 새면 XP 적립 경로 한가운데서 + * 요청이 500 으로 죽는다. + */ +@DisplayName("앱 버전 파싱") +class AppVersionTest { + + @Nested + @DisplayName("읽을 수 있는 값") + class Parsable { + + @Test + @DisplayName("빌드 번호는 버리고 semver 만 읽는다") + void dropsBuildNumber() { + // 프론트가 pubspec.yaml 의 version 을 그대로 보낸다(AI-SIP/OnO_FRONT#214). + assertThat(AppVersion.parse("4.0.0+70")) + .contains(new AppVersion(4, 0, 0)); + } + + @Test + @DisplayName("빌드 번호가 없어도 읽는다") + void parsesPlainSemver() { + assertThat(AppVersion.parse("4.2.13")) + .contains(new AppVersion(4, 2, 13)); + } + + @Test + @DisplayName("프리릴리즈 꼬리도 떼고 읽는다") + void dropsPreReleaseSuffix() { + // 내부 배포 빌드가 통째로 구버전으로 떨어지지 않게 앞자리만 읽는다. + assertThat(AppVersion.parse("4.1.0-beta.2+81")) + .contains(new AppVersion(4, 1, 0)); + } + + @Test + @DisplayName("앞뒤 공백은 무시한다") + void trimsWhitespace() { + assertThat(AppVersion.parse(" 4.0.0+70 ")) + .contains(new AppVersion(4, 0, 0)); + } + } + + @Nested + @DisplayName("읽을 수 없는 값") + class NotParsable { + + @ParameterizedTest(name = "\"{0}\"") + @NullSource + @ValueSource(strings = { + "", + " ", + "abc", + "4", // 자리가 모자란다 + "4.0", // 자리가 모자란다 + "4.0.0.1", // 자리가 넘친다 + "4.0.x", + "-1.0.0", + "v4.0.0", + "+70", + "99999999999.0.0" // int 를 넘긴다 + }) + @DisplayName("예외 없이 빈 값으로 떨어진다") + void returnsEmptyWithoutThrowing(String raw) { + assertThat(AppVersion.parse(raw)).isEmpty(); + } + } + + @Nested + @DisplayName("버전 비교") + class Comparison { + + @Test + @DisplayName("같은 버전은 기준을 만족한다") + void equalSatisfiesThreshold() { + assertThat(version("4.0.0").isAtLeast(version("4.0.0"))).isTrue(); + } + + @Test + @DisplayName("자리별로 앞자리가 우선한다") + void comparesMajorMinorPatchInOrder() { + assertThat(version("4.0.0").isAtLeast(version("3.99.99"))).isTrue(); + assertThat(version("3.99.99").isAtLeast(version("4.0.0"))).isFalse(); + assertThat(version("4.1.0").isAtLeast(version("4.0.99"))).isTrue(); + assertThat(version("4.0.1").isAtLeast(version("4.0.0"))).isTrue(); + assertThat(version("4.0.0").isAtLeast(version("4.0.1"))).isFalse(); + } + + @Test + @DisplayName("빌드 번호는 비교에 끼어들지 않는다") + void buildNumberDoesNotAffectComparison() { + // 같은 스토어 버전의 다른 빌드가 서로 다르게 판정되면 설정값을 제출마다 고쳐야 한다. + assertThat(version("4.0.0+70")).isEqualTo(version("4.0.0+999")); + } + + private AppVersion version(String raw) { + return AppVersion.parse(raw).orElseThrow( + () -> new IllegalArgumentException("테스트 입력이 잘못됐다: " + raw)); + } + } + + @Test + @DisplayName("반환형이 Optional 이라 호출부가 실패를 무시할 수 없다") + void returnsOptional() { + Optional parsed = AppVersion.parse("4.0.0"); + + assertThat(parsed).isPresent(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java new file mode 100644 index 00000000..8f7c9169 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java @@ -0,0 +1,353 @@ +package com.aisip.OnO.backend.mission.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.test.web.servlet.request.RequestPostProcessor; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +/** + * 앱 버전에 따라 자동 적립을 가르는 동작. + * + *

    적립과 진행도는 서버가 올리지만 받는 것은 앱이 한다. 미션 화면이 없는 구버전 앱은 + * {@code claim} 을 부를 방법이 없어서, 자동 적립을 통째로 끄면 그 사용자는 공부를 해도 XP 가 멈춘다. + * 그래서 요청이 온 앱 버전을 보고 가른다. + * + *

    이 클래스의 주제는 어느 쪽으로 틀리는가다. 헤더가 없든, 값이 이상하든, HTTP 요청 자체가 없든 + * 전부 구버전으로 떨어져 지금과 같이 적립된다. 덜 주는 것보다 더 주는 것이 낫다. + */ +@DisplayName("앱 버전별 자동 적립") +class MissionAccrualAppVersionTest extends MissionSystemTestSupport { + + /** 기준을 만족하는 앱. 프론트가 보내는 형식 그대로다. */ + private static final String MISSION_CAPABLE_HEADER = "4.0.0+70"; + + /** 기준에 못 미치는 앱. */ + private static final String LEGACY_HEADER = "3.9.9+69"; + + private User user; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + } + + @AfterEach + void clearRequestContext() { + // 싱글턴이 아니라 스레드에 붙는 값이라 안 지우면 다음 테스트가 이 헤더를 그대로 물려받는다. + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("기준 버전은 설정으로 바꿀 수 있고 기본값은 4.0.0 이다") + void thresholdIsConfigurable() throws NoSuchFieldException { + // 코드에 박으면 기준을 되돌릴 때마다 배포해야 한다. 값이 아니라 선언을 본다. + Field field = MissionLogService.class.getDeclaredField("missionCapableVersion"); + + assertThat(field.getAnnotation(Value.class).value()) + .as("프로퍼티 이름과 기본값은 운영에서 기준을 옮길 때 쓰는 계약이다") + .isEqualTo("${ono.mission.mission-capable-version:4.0.0}"); + } + + @Nested + @DisplayName("미션을 받을 수 있는 앱이면") + class MissionCapableApp { + + @BeforeEach + void sendCapableHeader() { + requestWithAppVersion(MISSION_CAPABLE_HEADER); + } + + @Test + @DisplayName("자동 적립이 들어오지 않는다") + void doesNotAccrue() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()) + .as("미션 보상으로만 받아야 이중 지급이 없다") + .isZero(); + } + + @Test + @DisplayName("기록과 진행도는 그대로 쌓인다") + void stillWritesLogAndProgress() { + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("관리자 통계가 이 행을 읽는다. 꺼지는 것은 지급뿐이다") + .hasSize(1); + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isEqualTo(1); + } + + @Test + @DisplayName("미션 보상은 그대로 들어온다") + void missionRewardStillGranted() { + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + assertThat(problemPracticePoints()).isZero(); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(problemPracticePoints()) + .as("자동 적립을 끈 자리에서 XP 가 들어오는 유일한 경로다") + .isEqualTo(15L); + } + + @Test + @DisplayName("기준과 같은 버전도 새 앱이다") + void thresholdItselfCounts() { + requestWithAppVersion("4.0.0"); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()).isZero(); + } + + @Test + @DisplayName("기준보다 높은 버전도 새 앱이다") + void higherVersionCounts() { + requestWithAppVersion("4.1.2+103"); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()).isZero(); + } + } + + @Nested + @DisplayName("구버전 앱이면") + class LegacyApp { + + @Test + @DisplayName("헤더가 낮은 버전이면 지금처럼 적립된다") + void accruesForLowerVersion() { + requestWithAppVersion(LEGACY_HEADER); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("헤더가 아예 없으면 지금처럼 적립된다") + void accruesWhenHeaderMissing() { + // 헤더를 안 보내는 앱이 곧 구버전이다. 프론트는 버전을 못 읽으면 헤더를 붙이지 않는다. + requestWithAppVersion(null); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @ParameterizedTest(name = "\"{0}\"") + @ValueSource(strings = {"abc", "", " ", "4", "4.0", "4.0.0.1", "v4.0.0", "unknown"}) + @DisplayName("헤더가 이상한 값이어도 예외 없이 구버전으로 떨어진다") + void accruesForUnparsableHeader(String rawHeader) { + requestWithAppVersion(rawHeader); + + assertThatCode(() -> missionLogService.registerLoginMission(user.getId())) + .doesNotThrowAnyException(); + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("하루 200점 상한도 지금 그대로다") + void keepsDailyCap() { + requestWithAppVersion(LEGACY_HEADER); + + for (int i = 1; i <= 14; i++) { + missionLogService.registerNotePracticeMission(user.getId(), (long) i); + } + + assertThat(accumulatedNotePracticePoints(user)) + .as("구버전 기준으로는 이 변경 전과 완전히 같아야 한다") + .isEqualTo(185L); + } + } + + @Nested + @DisplayName("HTTP 요청이 없는 곳에서 불리면") + class OutsideHttpRequest { + + @BeforeEach + void noRequest() { + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("터지지 않고 구버전으로 떨어진다") + void accruesWithoutThrowing() { + // 지금 적립을 부르는 경로는 전부 HTTP 요청 안이지만(Quartz 잡·RabbitMQ 소비자 어디에서도 + // 적립을 부르지 않는다), 배치가 하나라도 늘면 여기서 예외가 나 그 경로가 통째로 죽는다. + assertThatCode(() -> missionLogService.registerLoginMission(user.getId())) + .doesNotThrowAnyException(); + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + } + + @Nested + @DisplayName("비상 스위치가 꺼져 있으면") + class LegacyAccrualFlagDisabled { + + @BeforeEach + void disableFlag() { + setLegacyAccrual(false); + } + + @Test + @DisplayName("새 앱에서도 적립이 없다") + void noAccrualForCapableApp() { + requestWithAppVersion(MISSION_CAPABLE_HEADER); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()).isZero(); + } + + @Test + @DisplayName("구버전 앱에서도 적립이 없다") + void noAccrualForLegacyApp() { + // 플래그가 버전보다 우선한다. 적립 자체에 문제가 생겼을 때 배포 없이 전부 멈추는 자리다. + requestWithAppVersion(LEGACY_HEADER); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()).isZero(); + } + + @Test + @DisplayName("헤더가 없어도 적립이 없다") + void noAccrualWithoutHeader() { + requestWithAppVersion(null); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionStatus().getTotalStudyPoint()).isZero(); + } + + @Test + @DisplayName("미션 보상은 그대로 들어온다") + void missionRewardUnaffected() { + requestWithAppVersion(LEGACY_HEADER); + MissionProgress progress = completeMission(user.getId(), DAILY_REVIEW_3); + + missionService.claim(user.getId(), progress.getId()); + + assertThat(problemPracticePoints()).isEqualTo(15L); + } + } + + @Nested + @DisplayName("기준 버전 설정이 잘못돼 있으면") + class MisconfiguredThreshold { + + @Test + @DisplayName("아무도 새 앱으로 보지 않는다") + void treatsEveryoneAsLegacy() { + // 설정 오타 하나로 전체 XP 유입이 멈추는 것보다 이중 지급이 조금 더 도는 편이 낫다. + setMissionCapableVersion("사점영"); + requestWithAppVersion(MISSION_CAPABLE_HEADER); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("기준을 올리면 그 아래 버전은 다시 구버전이 된다") + void raisingThresholdRollsBack() { + // 미션 화면이 고장 난 버전이 뒤늦게 드러났을 때 배포 없이 되돌리는 길이다. + setMissionCapableVersion("5.0.0"); + requestWithAppVersion(MISSION_CAPABLE_HEADER); + + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + } + + @Nested + @DisplayName("실제 HTTP 요청에서는") + class ThroughMockMvc { + + @Test + @DisplayName("헤더를 붙여 보내면 자동 적립이 멈춘다") + void headerReachesServiceThroughFilterChain() throws Exception { + mockMvc.perform(get("/api/users") + .header(AppVersionResolver.APP_VERSION_HEADER, MISSION_CAPABLE_HEADER) + .with(asUser(user))) + .andExpect(status().isOk()); + + assertThat(reload(user).getUserMissionStatus().getTotalStudyPoint()) + .as("컨트롤러 → 서비스 사이에 버전을 인자로 넘기지 않아도 닿아야 한다") + .isZero(); + assertThat(currentOf(user.getId(), DAILY_ATTEND)) + .as("진행도는 버전과 무관하게 오른다") + .isEqualTo(1); + } + + @Test + @DisplayName("헤더 없이 보내면 지금처럼 적립된다") + void accruesWithoutHeader() { + assertThatCode(() -> mockMvc.perform(get("/api/users").with(asUser(user))) + .andExpect(status().isOk())).doesNotThrowAnyException(); + + assertThat(attendancePoints()) + .as("구버전 앱이 보내는 요청 그대로다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + private RequestPostProcessor asUser(User user) { + // authenticateAs 는 SecurityContextHolder 만 채워 MockMvc 필터 체인까지 가지 않는다. + return authentication(new UsernamePasswordAuthenticationToken( + user.getId(), null, List.of(new SimpleGrantedAuthority("ROLE_MEMBER")))); + } + } + + /** + * 지금 스레드를 "그 헤더를 단 요청을 처리하는 중" 으로 만든다. + * + *

    {@code null} 이면 헤더를 아예 붙이지 않는다. 빈 문자열과는 다른 상황이라 구분해야 한다. + */ + private void requestWithAppVersion(String appVersion) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (appVersion != null) { + request.addHeader(AppVersionResolver.APP_VERSION_HEADER, appVersion); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + + private UserMissionStatus missionStatus() { + return reload(user).getUserMissionStatus(); + } + + private long attendancePoints() { + return accumulatedPoints(missionStatus().getAttendanceLevel(), missionStatus().getAttendancePoint()); + } + + private long problemPracticePoints() { + return accumulatedPoints(missionStatus().getProblemPracticeLevel(), missionStatus().getProblemPracticePoint()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java index 12886809..bf428d50 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java @@ -45,6 +45,9 @@ public abstract class MissionTestSupport extends IntegrationTestSupport { /** {@code ono.mission.legacy-accrual.enabled} 의 기본값. 설정을 안 건드리면 운영이 이 상태다. */ protected static final boolean LEGACY_ACCRUAL_DEFAULT = true; + /** {@code ono.mission.mission-capable-version} 의 기본값. */ + protected static final String MISSION_CAPABLE_VERSION_DEFAULT = "4.0.0"; + /** * 자동 적립 플래그를 바꾼다. * @@ -61,14 +64,22 @@ protected void setLegacyAccrual(boolean enabled) { ReflectionTestUtils.setField(target, "legacyAccrualEnabled", enabled); } + /** 자동 적립을 끄는 기준 앱 버전을 바꾼다. 플래그와 같은 이유로 대상 객체에 넣고 앞뒤로 되돌린다. */ + protected void setMissionCapableVersion(String version) { + MissionLogService target = AopTestUtils.getTargetObject(missionLogService); + ReflectionTestUtils.setField(target, "missionCapableVersion", version); + } + @BeforeEach - void restoreLegacyAccrualBeforeEachTest() { + void restoreAccrualSettingsBeforeEachTest() { setLegacyAccrual(LEGACY_ACCRUAL_DEFAULT); + setMissionCapableVersion(MISSION_CAPABLE_VERSION_DEFAULT); } @AfterEach - void restoreLegacyAccrualAfterEachTest() { + void restoreAccrualSettingsAfterEachTest() { setLegacyAccrual(LEGACY_ACCRUAL_DEFAULT); + setMissionCapableVersion(MISSION_CAPABLE_VERSION_DEFAULT); } protected MissionLog saveMissionLog(User user, MissionType missionType, Long referenceId) { From 36f8b723ff05ff11ff2603b318971494d41521a5 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 15 Sep 2026 13:54:33 +0900 Subject: [PATCH 097/130] =?UTF-8?q?[Chore]=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EB=A5=BC=20=EB=B6=88=EB=9F=AC=20=EB=B3=B4?= =?UTF-8?q?=EB=8A=94=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4=20-?= =?UTF-8?q?=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20healthcheck=20?= =?UTF-8?q?=EB=8A=94=20localhost=20=EC=9D=98=20actuator/health=20=EB=A7=8C?= =?UTF-8?q?=20=EB=B4=90=EC=84=9C=20Cloudflare,=20nginx,=20JWT=20=EC=84=9C?= =?UTF-8?q?=EB=AA=85=20=ED=82=A4,=20DB=20=EC=A1=B0=ED=9A=8C=EA=B0=80=20?= =?UTF-8?q?=EC=96=B4=EA=B8=8B=EB=82=98=EB=8F=84=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=EA=B0=80=20=EC=84=B1=EA=B3=B5=EC=9C=BC=EB=A1=9C=20=EB=81=9D?= =?UTF-8?q?=EB=82=9C=EB=8B=A4=20-=20scripts/smoke/smoke=5Ftest.py=20?= =?UTF-8?q?=EB=8A=94=20GET=20/api/problems/problemCount=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A7=8C=20=EB=B3=B4=EB=82=B8=EB=8B=A4.=20readOnly=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9D=98=20count=20?= =?UTF-8?q?=ED=95=9C=20=EB=B2=88=EC=9D=B4=EB=9D=BC=20=EC=93=B0=EA=B8=B0?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EB=8B=A4=20=20=20GET=20/api/users=20?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=AF=B8=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D=EA=B3=BC=20=EC=A0=91=EC=86=8D=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=20=EA=B0=B1=EC=8B=A0=EC=9D=B4=20=EA=B0=99=EC=9D=B4=20?= =?UTF-8?q?=EB=8F=8C=EC=95=84=EC=84=9C=20=EC=93=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=95=98=EB=8B=A4=20-=20=ED=86=A0=ED=81=B0=20=EC=97=86?= =?UTF-8?q?=EC=9D=B4=20401(1007)=EC=9D=B4=EB=A9=B4=20=EC=95=B1=EA=B9=8C?= =?UTF-8?q?=EC=A7=80=20=EB=8B=BF=EC=9D=80=20=EA=B2=83=EC=9C=BC=EB=A1=9C,?= =?UTF-8?q?=20=EC=A1=B4=EC=9E=AC=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20ID=200=20=EC=9D=98=2060=EC=B4=88?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20200=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=B4=20=EC=84=9C=EB=AA=85=20=ED=82=A4=EC=99=80=20DB=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EA=B0=80=20=EB=A7=9E=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=B8=EB=8B=A4=20=20=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20DB=20=EC=97=90=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9D=84=20=EB=A7=8C=EB=93=A4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=8B=A4=EC=A0=9C=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=EB=8B=BF=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=9D=80=20=ED=95=9C=20=EB=B2=88=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EC=97=90=20=EC=B5=9C=EB=8C=80=206=EA=B0=9C=EB=8B=A4.?= =?UTF-8?q?=20=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=8A=94=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EC=99=80=205xx,=20429=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=A7=8C=20=ED=95=98=EA=B3=A0=20=EB=A6=AC=EB=8B=A4=EC=9D=B4?= =?UTF-8?q?=EB=A0=89=ED=8A=B8=EB=8A=94=20=EB=94=B0=EB=9D=BC=EA=B0=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20Cloudflare=20?= =?UTF-8?q?=EA=B0=80=20=EC=9B=90=20=EC=84=9C=EB=B2=84=EC=97=90=20=EB=AA=BB?= =?UTF-8?q?=20=EB=8B=BF=EC=9D=80=20502=20=EC=99=80=20=EC=95=B1=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=EB=A5=BC=20=EA=B5=AC=EB=B6=84=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=B0=EB=8B=A4.=20=EC=84=9C=EB=B2=84=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EB=A7=88=EB=8B=A4=20=EB=8B=A4=EB=A5=B8=20401=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=8A=94=20=EA=B0=80=EB=8A=A5=ED=95=9C=20?= =?UTF-8?q?=EC=9B=90=EC=9D=B8=EC=9D=84=20=EA=B0=99=EC=9D=B4=20=EC=A0=81?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20-=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EB=8A=94=20workflow=5Fdispatch=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EB=A7=8C=20=ED=95=98=EA=B3=A0=20GitHub=20?= =?UTF-8?q?=ED=98=B8=EC=8A=A4=ED=8B=B0=EB=93=9C=20=EB=9F=AC=EB=84=88?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=88=EB=8B=A4.=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=EC=99=80=20=EB=A7=A5?= =?UTF-8?q?=EB=AF=B8=EB=8B=88=20=EB=9F=AC=EB=84=88=EC=97=90=EB=8A=94=20?= =?UTF-8?q?=EC=86=90=EB=8C=80=EC=A7=80=20=EC=95=8A=EC=95=98=EB=8B=A4=20=20?= =?UTF-8?q?=20dev=20=EC=99=80=20prod=20=EC=8B=9C=ED=81=AC=EB=A6=BF?= =?UTF-8?q?=EC=9D=80=20=EB=8C=80=EC=83=81=20=EC=8A=A4=ED=85=9D=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EB=84=98=EA=B8=B4=EB=8B=A4=20-=20=EA=B0=80?= =?UTF-8?q?=EC=A7=9C=20=EC=84=9C=EB=B2=84=EB=A1=9C=20=EC=8A=A4=ED=81=AC?= =?UTF-8?q?=EB=A6=BD=ED=8A=B8=20=EC=9E=90=EC=B2=B4=EB=A5=BC=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=2022?= =?UTF-8?q?=EA=B0=9C=EB=A5=BC=20=EB=91=90=EA=B3=A0,=20=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EA=B0=80=20=EC=8B=A4=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EB=B3=B4=EB=8B=A4=20=EB=A8=BC=EC=A0=80=20=EB=8F=8C?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close #262 --- .github/workflows/smoke-test.yml | 68 +++++++ .gitignore | 3 + scripts/smoke/README.md | 61 ++++++ scripts/smoke/smoke_test.py | 292 +++++++++++++++++++++++++++ scripts/smoke/test_smoke_test.py | 328 +++++++++++++++++++++++++++++++ 5 files changed, 752 insertions(+) create mode 100644 .github/workflows/smoke-test.yml create mode 100644 scripts/smoke/README.md create mode 100644 scripts/smoke/smoke_test.py create mode 100644 scripts/smoke/test_smoke_test.py diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml new file mode 100644 index 00000000..4ccaaa78 --- /dev/null +++ b/.github/workflows/smoke-test.yml @@ -0,0 +1,68 @@ +name: Smoke Test + +# 배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. +# 컨테이너 healthcheck 는 프로세스가 떴는지만 보고, Cloudflare 와 nginx, JWT 서명 키, DB 조회는 거치지 않는다. +# +# 운영 서버에 영향을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. 자세한 근거는 scripts/smoke/smoke_test.py 에 있다. +# - 수동 실행만 한다. 배포 워크플로(ci-prod / deploy-prod / ci-dev)와 연결하지 않았다. +# 스모크 테스트가 틀려도 배포와 nginx 전환에는 아무 영향이 없다. +# - 맥미니 러너(self-hosted)가 아니라 GitHub 호스티드 러너에서 돈다. +# 운영 기계의 자원을 쓰지 않고, 배포 job 과 러너를 두고 경쟁하지 않는다. +# - 조회 전용 GET 하나(/api/problems/problemCount)만, 한 번 실행에 최대 6번 보낸다. +# - 스크립트 자체 테스트가 먼저 통과해야 실제 서버에 요청을 보낸다. +# +# 인증 확인까지 하려면 저장소 Secrets 에 서버의 jwt.accessToken.secret 과 같은 값을 넣는다. +# SMOKE_DEV_ACCESS_TOKEN_SECRET, SMOKE_PROD_ACCESS_TOKEN_SECRET +# 없으면 인증 확인은 건너뛰고 "앱까지 닿는가"만 본다. + +on: + workflow_dispatch: + inputs: + target: + description: "확인할 서버" + type: choice + options: + - dev + - prod + default: dev + +permissions: + contents: read + +concurrency: + group: smoke-test-${{ inputs.target }} + cancel-in-progress: false + +jobs: + smoke: + name: 스모크 테스트 (${{ inputs.target }}) + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: 스크립트 자체 테스트 + run: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' + + # 대상 서버의 시크릿만 그 스텝에 넘긴다. 한 스텝에 dev 와 prod 시크릿을 같이 두지 않고, + # 표현식의 a && b || c 는 b 가 비면 c 로 넘어가 다른 환경 값이 들어갈 수 있어서 쓰지 않는다. + - name: 스모크 테스트 (dev) + if: inputs.target == 'dev' + env: + SMOKE_BASE_URL: https://ono-dev.seungminki.shop + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_DEV_ACCESS_TOKEN_SECRET }} + run: python3 scripts/smoke/smoke_test.py + + - name: 스모크 테스트 (prod) + if: inputs.target == 'prod' + env: + SMOKE_BASE_URL: https://ono-prod.seungminki.shop + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_PROD_ACCESS_TOKEN_SECRET }} + run: python3 scripts/smoke/smoke_test.py diff --git a/.gitignore b/.gitignore index 873094f4..bfe78ab4 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,6 @@ monitoring/README.md ### Claude Code local settings (machine-specific, not team-shared) .claude/settings.local.json .claude/hooks/__pycache__/ + +### Smoke test script (python cache) +scripts/smoke/__pycache__/ diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md new file mode 100644 index 00000000..563ec292 --- /dev/null +++ b/scripts/smoke/README.md @@ -0,0 +1,61 @@ +# 스모크 테스트 + +배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. +컨테이너 healthcheck 는 컨테이너 안의 `localhost:8081/actuator/health` 만 봐서, 그 앞의 Cloudflare 와 nginx, +JWT 서명 키, DB 조회가 어긋나도 알 수 없다. + +## 무엇을 확인하나 + +| 확인 | 요청 | 통과 기준 | 이게 실패하면 | +|---|---|---|---| +| 앱까지 닿는가 | 토큰 없이 `GET /api/problems/problemCount` | `401`, `errorCode 1007` | Cloudflare 가 원 서버에 못 닿았거나(502), nginx 나 앱이 이상하다 | +| 인증과 DB 조회가 되는가 | 스모크 토큰으로 같은 요청 | `200`, `data` 가 숫자 | 서버의 JWT 서명 키가 시크릿과 다르거나 DB 조회가 안 된다 | + +## 운영 서버에 영향이 없는 이유 + +- **GET 하나만 보낸다.** 경로는 스크립트에 고정되어 있다. 이 경로는 읽기 전용 트랜잭션에서 count 쿼리 하나만 돈다. +- **`GET /api/users` 는 쓰지 않는다.** 조회처럼 보이지만 로그인 미션 기록과 마지막 접속 시각 갱신이 같이 돈다. +- **존재하지 않는 사용자 ID 0 으로 60초짜리 토큰을 만든다.** 운영 DB 에 스모크 계정을 만들 필요가 없고 실제 사용자 데이터에 닿지 않는다. +- **한 번 실행에 요청은 최대 6개다.** 확인 2개에 시도 3번씩이고, 재시도는 연결 실패와 5xx, 429 에서만 한다. +- **리다이렉트를 따라가지 않는다.** 따라가면 토큰이 다른 호스트로 옮겨 가고 요청 수 상한도 깨져서, 3xx 는 실패로 본다. +- **배포 워크플로와 연결하지 않았다.** 수동 실행만 하고, 맥미니 러너가 아니라 GitHub 호스티드 러너에서 돈다. + +로컬에 띄운 서버에서 세 번 연속 실행했을 때 MySQL 의 INSERT, UPDATE, DELETE 카운터는 그대로였고 +Redis 에는 토큰 블랙리스트 조회 `GET` 만 들어왔다. + +그래도 남는 것은 이 정도다. + +- 서버 DB 가 이미 죽어 있으면 count 조회가 500 이 되어 기존 예외 처리대로 Discord 알림이 한 건 나간다. 5분 중복 억제가 있다. +- 토큰 없는 요청은 Spring Security 가 요청을 기억하려고 톰캣 메모리에 세션을 만들 수 있다. 한 번 실행에 최대 3개이고 DB 와 Redis 에는 닿지 않는다. + +## 에러 코드를 읽는 법 + +서버 버전에 따라 같은 원인에도 코드가 다르게 나온다. + +| 코드 | 가능한 원인 | +|---|---| +| `1009` | 시크릿이 서버의 JWT 서명 키와 다르다 (develop 이후) | +| `1005` | 시크릿이 다르다 (main 2026-07-01 버전은 서명이 틀려도 1005 를 준다), 또는 러너와 서버 시계가 어긋났다 | +| `1007` | 시크릿이 다르거나, 서버의 Redis 블랙리스트 조회가 실패했다 | + +## 실행 + +GitHub Actions 에서 `Smoke Test` 워크플로를 `dev` 나 `prod` 로 수동 실행한다. +`workflow_dispatch` 는 워크플로 파일이 기본 브랜치(`main`)에 있어야 실행 버튼이 생긴다. +인증 확인까지 하려면 저장소 Secrets 에 서버의 `jwt.accessToken.secret` 과 같은 값을 넣는다. + +| Secret | 대상 | +|---|---| +| `SMOKE_DEV_ACCESS_TOKEN_SECRET` | `https://ono-dev.seungminki.shop` | +| `SMOKE_PROD_ACCESS_TOKEN_SECRET` | `https://ono-prod.seungminki.shop` | + +없으면 인증 확인은 건너뛰고 "앱까지 닿는가"만 본다. +이 시크릿은 서버의 서명 키 그 자체라 관리자 토큰도 만들 수 있다. 저장소 Secrets 대신 `main` 에서만 쓸 수 있는 +GitHub Environment 에 두는 편이 더 안전하다. + +로컬에서는 이렇게 돌린다. 종료 코드는 0 통과, 1 실패, 2 설정 오류다. + +```bash +SMOKE_BASE_URL=https://ono-dev.seungminki.shop python3 scripts/smoke/smoke_test.py +python3 -m unittest discover -s scripts/smoke -p 'test_*.py' # 스크립트 자체 테스트 22개 +``` diff --git a/scripts/smoke/smoke_test.py b/scripts/smoke/smoke_test.py new file mode 100644 index 00000000..fc3f9875 --- /dev/null +++ b/scripts/smoke/smoke_test.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""OnO 스모크 테스트. + +배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. +컨테이너 healthcheck(actuator/health)는 프로세스가 떴는지만 알려 주고, +Cloudflare 와 nginx, JWT 서명 키, DB 조회는 거치지 않는다. 그 사이를 여기서 본다. + +운영 서버에 영향을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. + +- GET 만 보낸다. 호출하는 경로는 SAFE_PATH 하나로 고정되어 있고 바꿀 수 없다. +- SAFE_PATH 는 읽기 전용 트랜잭션에서 count 쿼리 하나만 돈다 + (ProblemService.findProblemCountByUser, @Transactional(readOnly = true)). + GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 마지막 접속 시각 갱신이 + 같이 돌아서(UserController.getUserInfo) 쓰지 않는다. +- 인증 확인용 토큰은 존재하지 않는 사용자 ID 0 으로 60초짜리를 만든다. + JwtTokenFilter 는 서명과 블랙리스트만 보고 사용자 존재 여부는 보지 않으며, + count 쿼리는 행이 없으면 0 을 돌려준다. 그래서 운영 DB 에 스모크 계정을 만들 필요가 없고 + 실제 사용자의 데이터에는 닿지 않는다. +- 한 번 실행에 보내는 요청은 최대 (확인 2개 x 시도 3번) = 6개다. + 재시도는 연결 실패와 5xx, 429 에서만 하고 4xx 는 바로 판정한다. +- 리다이렉트를 따라가지 않는다. urllib 은 기본으로 3xx 를 따라가면서 Authorization 헤더까지 + 옮기기 때문에, 따라가면 토큰이 다른 호스트로 새고 요청 수 상한도 깨진다. 3xx 는 실패로 본다. +- 토큰은 https 이거나 로컬 주소일 때만 보낸다. + +환경 변수 + SMOKE_BASE_URL 필수. 예: https://ono-dev.seungminki.shop + SMOKE_ACCESS_TOKEN_SECRET 선택. 서버의 jwt.accessToken.secret 과 같은 Base64 값. + 없으면 인증 확인은 건너뛰고 도달 확인만 한다. + +종료 코드: 0 통과, 1 실패, 2 설정 오류 +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass, field +from typing import Callable, Mapping + +SAFE_PATH = "/api/problems/problemCount" + +# 존재하지 않는 사용자. 위 docstring 참고. +SMOKE_USER_ID = "0" +SMOKE_AUTHORITY = "ROLE_GUEST" +TOKEN_TTL_SECONDS = 60 + +REQUEST_TIMEOUT_SECONDS = 10 +MAX_ATTEMPTS = 3 +RETRY_DELAY_SECONDS = 3 +USER_AGENT = "OnO-SmokeTest/1.0" + +# AuthErrorCase +AUTHENTICATION_FAILED = 1007 +ACCESS_TOKEN_EXPIRED = 1005 +INVALID_ACCESS_TOKEN = 1009 + +LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1") + + +@dataclass +class Response: + status: int | None + headers: Mapping[str, str] = field(default_factory=dict) + body: bytes = b"" + error: str | None = None + elapsed_ms: int = 0 + attempts: int = 1 + + def json(self): + try: + return json.loads(self.body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + + def snippet(self) -> str: + text = self.body.decode("utf-8", errors="replace").strip().replace("\n", " ") + return text[:120] + + +@dataclass +class CheckResult: + name: str + passed: bool + detail: str + elapsed_ms: int = 0 + skipped: bool = False + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def mint_access_token(secret_b64: str, now: int) -> str: + """JwtTokenizer.createAccessToken 과 같은 모양의 HS256 토큰을 만든다. + + 서버는 jwt.accessToken.secret 을 Base64 로 디코딩한 바이트를 HMAC 키로 쓴다. + """ + key = base64.b64decode(secret_b64.strip(), validate=True) + header = {"alg": "HS256"} + payload = { + "authority": SMOKE_AUTHORITY, + "sub": SMOKE_USER_ID, + "iat": now, + "exp": now + TOKEN_TTL_SECONDS, + } + signing_input = ( + _b64url(json.dumps(header, separators=(",", ":")).encode()) + + "." + + _b64url(json.dumps(payload, separators=(",", ":")).encode()) + ) + signature = hmac.new(key, signing_input.encode("ascii"), hashlib.sha256).digest() + return signing_input + "." + _b64url(signature) + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_OPENER = urllib.request.build_opener(_NoRedirect) + + +def _get_once(url: str, headers: Mapping[str, str]) -> Response: + request = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT, **headers}) + started = time.monotonic() + try: + with _OPENER.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as res: + body = res.read() + return Response(res.status, dict(res.headers), body, + elapsed_ms=int((time.monotonic() - started) * 1000)) + except urllib.error.HTTPError as e: + body = e.read() + return Response(e.code, dict(e.headers or {}), body, + elapsed_ms=int((time.monotonic() - started) * 1000)) + except (urllib.error.URLError, TimeoutError, OSError) as e: + reason = getattr(e, "reason", e) + return Response(None, error=str(reason), elapsed_ms=int((time.monotonic() - started) * 1000)) + + +def _should_retry(res: Response) -> bool: + return res.status is None or res.status == 429 or res.status >= 500 + + +def http_get(base_url: str, headers: Mapping[str, str], sleep: Callable[[float], None]) -> Response: + """SAFE_PATH 로 GET 을 보낸다. 이 스크립트에서 요청을 보내는 곳은 여기 하나다.""" + url = base_url.rstrip("/") + SAFE_PATH + res = _get_once(url, headers) + attempts = 1 + while _should_retry(res) and attempts < MAX_ATTEMPTS: + sleep(RETRY_DELAY_SECONDS) + res = _get_once(url, headers) + attempts += 1 + res.attempts = attempts + return res + + +def _lower_headers(res: Response) -> dict[str, str]: + return {k.lower(): v for k, v in res.headers.items()} + + +def _describe_unexpected(res: Response) -> str: + if res.status is None: + return f"서버에 닿지 않았습니다 ({res.error}, {res.attempts}번 시도)" + headers = _lower_headers(res) + if 300 <= res.status < 400: + return (f"리다이렉트 응답입니다 (HTTP {res.status}, Location: {headers.get('location', '-')}). " + "따라가지 않았습니다. 점검 모드나 nginx 설정을 확인해야 합니다") + if "cf-mitigated" in headers or (res.status == 403 and "cloudflare" in headers.get("server", "").lower() + and res.json() is None): + return f"Cloudflare 가 요청을 막았습니다 (HTTP {res.status}). 앱까지 가지 않아서 서버 상태는 알 수 없습니다" + if res.status >= 500 and "cloudflare" in headers.get("server", "").lower() and res.json() is None: + # 원 서버 응답이면 nginx 나 앱이 만든 본문이 온다. Cloudflare 가 직접 만든 5xx 는 + # 원 서버(맥미니 nginx)까지 닿지 못했다는 뜻이라 앱 오류와 구분해서 알린다. + return (f"Cloudflare 가 원 서버에 닿지 못했습니다 (HTTP {res.status}, {res.attempts}번 시도). " + "서버나 nginx 가 꺼져 있거나 네트워크가 끊겼을 수 있습니다") + if res.status >= 500: + return f"앱이 정상 응답하지 않습니다 (HTTP {res.status}, {res.attempts}번 시도): {res.snippet()}" + if res.json() is None: + return (f"JSON 이 아닌 응답입니다 (HTTP {res.status}). " + f"nginx 오류 페이지나 점검 페이지일 수 있습니다: {res.snippet()}") + return f"예상과 다른 응답입니다 (HTTP {res.status}): {res.snippet()}" + + +def check_reachable(base_url: str, sleep: Callable[[float], None]) -> CheckResult: + """토큰 없이 호출해서 Cloudflare, nginx, 앱의 인증 필터까지 닿는지 본다. DB 는 거치지 않는다.""" + name = "앱까지 닿는가 (토큰 없이 401)" + res = http_get(base_url, {}, sleep) + body = res.json() + if res.status == 401 and isinstance(body, dict) and body.get("errorCode") == AUTHENTICATION_FAILED: + return CheckResult(name, True, f"HTTP 401, errorCode {AUTHENTICATION_FAILED}", res.elapsed_ms) + if res.status == 200: + return CheckResult(name, False, + "토큰 없이 200 이 나왔습니다. 인증 설정이 풀렸는지 확인이 필요합니다", res.elapsed_ms) + return CheckResult(name, False, _describe_unexpected(res), res.elapsed_ms) + + +def check_authenticated(base_url: str, secret_b64: str, now: int, + sleep: Callable[[float], None]) -> CheckResult: + """스모크 토큰으로 호출해서 JWT 서명 키와 DB 조회가 맞는지 본다.""" + name = "인증과 DB 조회가 되는가 (토큰으로 200)" + try: + token = mint_access_token(secret_b64, now) + except (binascii.Error, ValueError): + return CheckResult(name, False, "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다") + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::add-mask::{token}") + + res = http_get(base_url, {"Authorization": f"Bearer {token}"}, sleep) + body = res.json() + data = body.get("data") if isinstance(body, dict) else None + if res.status == 200 and isinstance(data, int) and not isinstance(data, bool): + return CheckResult(name, True, "HTTP 200, count 조회 응답", res.elapsed_ms) + if res.status == 401 and isinstance(body, dict): + # 서버 버전에 따라 같은 원인에도 코드가 다르게 나와서 가능한 원인을 같이 적는다. + # main(2026-07-01)은 서명이 틀려도 1005 를 주고, develop 은 1009 를 준다. + # 1007 은 블랙리스트 조회(Redis) 실패처럼 토큰과 무관한 예외에서도 나온다. + causes = { + INVALID_ACCESS_TOKEN: "SMOKE_ACCESS_TOKEN_SECRET 이 서버의 JWT 서명 키와 다를 수 있습니다", + ACCESS_TOKEN_EXPIRED: "서명 키가 다르거나(구버전 서버는 이때도 1005 를 줍니다) 러너와 서버의 시계가 어긋났을 수 있습니다", + AUTHENTICATION_FAILED: "서명 키가 다르거나 서버의 Redis(토큰 블랙리스트 조회)에 문제가 있을 수 있습니다", + } + code = body.get("errorCode") + if code in causes: + return CheckResult(name, False, f"토큰이 거절됐습니다 (errorCode {code}). {causes[code]}", res.elapsed_ms) + return CheckResult(name, False, _describe_unexpected(res), res.elapsed_ms) + + +def run(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.time) -> int: + base_url = env.get("SMOKE_BASE_URL", "").strip() + if not base_url.startswith(("https://", "http://")): + print("SMOKE_BASE_URL 이 없거나 http(s):// 로 시작하지 않습니다", file=sys.stderr) + return 2 + secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() + host = urllib.parse.urlsplit(base_url).hostname or "" + if secret and base_url.startswith("http://") and host not in LOCAL_HOSTS: + print("토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다", file=sys.stderr) + return 2 + + print(f"대상: {base_url}{SAFE_PATH} (GET 만 보냅니다)") + results = [check_reachable(base_url, sleep)] + + auth_name = "인증과 DB 조회가 되는가 (토큰으로 200)" + if not results[0].passed: + results.append(CheckResult(auth_name, False, "앞 확인이 실패해서 요청을 보내지 않았습니다", skipped=True)) + elif not secret: + results.append(CheckResult(auth_name, True, "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다", skipped=True)) + else: + results.append(check_authenticated(base_url, secret, int(clock()), sleep)) + + failed = [r for r in results if not r.passed] + for r in results: + mark = "건너뜀" if r.skipped else ("통과" if r.passed else "실패") + elapsed = f" ({r.elapsed_ms}ms)" if r.elapsed_ms else "" + print(f"[{mark}] {r.name}{elapsed}: {r.detail}") + _write_step_summary(env, base_url, results) + + print("스모크 테스트 실패" if failed else "스모크 테스트 통과") + return 1 if failed else 0 + + +def _write_step_summary(env: Mapping[str, str], base_url: str, results: list[CheckResult]) -> None: + path = env.get("GITHUB_STEP_SUMMARY") + if not path: + return + lines = [ + "## 스모크 테스트", + "", + f"- 대상: `{base_url}` (GET `{SAFE_PATH}` 만 보냅니다)", + "", + "| 확인 | 결과 | 응답 시간 | 내용 |", + "|---|---|---:|---|", + ] + for r in results: + mark = "건너뜀" if r.skipped else ("✅ 통과" if r.passed else "❌ 실패") + elapsed = f"{r.elapsed_ms}ms" if r.elapsed_ms else "-" + lines.append(f"| {r.name} | {mark} | {elapsed} | {r.detail.replace('|', '/')} |") + with open(path, "a", encoding="utf-8") as f: + f.write("\n".join(lines) + "\n") + + +if __name__ == "__main__": + sys.exit(run(os.environ)) diff --git a/scripts/smoke/test_smoke_test.py b/scripts/smoke/test_smoke_test.py new file mode 100644 index 00000000..f9fec5df --- /dev/null +++ b/scripts/smoke/test_smoke_test.py @@ -0,0 +1,328 @@ +"""smoke_test.py 자체 테스트. + +운영 서버에 요청을 보내기 전에 스크립트가 틀리지 않았는지 확인하려고 둔다. +스모크 테스트 워크플로가 실제 요청보다 먼저 이 테스트를 돌린다. + +로컬 가짜 서버를 띄워 두고, 그 서버가 받은 요청을 전부 기록해서 +"GET 만, 고정 경로로만, 정해진 횟수 안에서" 보내는지까지 확인한다. + +실행: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' -v +""" + +import base64 +import hashlib +import hmac +import json +import os +import sys +import tempfile +import threading +import unittest +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import smoke_test # noqa: E402 + +SERVER_SECRET = base64.b64encode(b"s" * 64).decode() +OTHER_SECRET = base64.b64encode(b"x" * 64).decode() +NOW = 1_800_000_000 + + +def _b64url_decode(part: str) -> bytes: + return base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)) + + +def verify_token(token: str, secret_b64: str): + """서버의 JwtTokenFilter 가 하는 일(서명 검증 후 클레임 읽기)을 흉내 낸다.""" + header, payload, signature = token.split(".") + key = base64.b64decode(secret_b64) + expected = hmac.new(key, f"{header}.{payload}".encode(), hashlib.sha256).digest() + if not hmac.compare_digest(expected, _b64url_decode(signature)): + return None + return json.loads(_b64url_decode(payload)) + + +class FakeServer: + """시나리오 함수가 (status, headers, body) 를 돌려주는 가짜 서버.""" + + def __init__(self, scenario): + self.requests = [] + outer = self + + class Handler(BaseHTTPRequestHandler): + def _handle(self): + outer.requests.append((self.command, self.path, dict(self.headers))) + status, headers, body = scenario(self.command, self.path, dict(self.headers)) + headers = dict(headers) + # send_response 가 Server 헤더를 먼저 붙이므로, 시나리오가 준 값으로 바꿔 끼운다. + server = headers.pop("Server", None) + if server: + self.version_string = lambda: server + try: + self.send_response(status) + for k, v in headers.items(): + self.send_header(k, v) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + # 시간 초과 테스트에서 클라이언트가 먼저 끊은 경우다. + pass + + do_GET = do_POST = do_PATCH = do_PUT = do_DELETE = _handle + + def log_message(self, *args): + pass + + self.httpd = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.url = f"http://127.0.0.1:{self.httpd.server_address[1]}" + threading.Thread(target=self.httpd.serve_forever, daemon=True).start() + + def close(self): + self.httpd.shutdown() + self.httpd.server_close() + + +def json_body(obj): + return {"Content-Type": "application/json"}, json.dumps(obj).encode() + + +def healthy_server(command, path, headers): + """정상 운영 서버. 토큰이 없으면 1007, 서명이 틀리면 1009, 맞으면 count 를 준다.""" + auth = headers.get("Authorization") + if not auth: + h, b = json_body({"errorCode": 1007, "message": "인증이 실패했습니다."}) + return 401, h, b + claims = verify_token(auth.removeprefix("Bearer "), SERVER_SECRET) + if claims is None: + h, b = json_body({"errorCode": 1009, "message": "유효하지 않은 엑세스 토큰입니다."}) + return 401, h, b + h, b = json_body({"data": 0}) + return 200, h, b + + +class SmokeTestScriptTest(unittest.TestCase): + + def setUp(self): + self.sleeps = [] + self.server = None + + def tearDown(self): + if self.server: + self.server.close() + + def start(self, scenario): + self.server = FakeServer(scenario) + return self.server + + def run_script(self, secret=None, extra_env=None): + env = {"SMOKE_BASE_URL": self.server.url} + if secret is not None: + env["SMOKE_ACCESS_TOKEN_SECRET"] = secret + env.update(extra_env or {}) + return smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW) + + def assert_only_safe_gets(self): + self.assertTrue(self.server.requests) + for method, path, _ in self.server.requests: + self.assertEqual("GET", method) + self.assertEqual(smoke_test.SAFE_PATH, path) + self.assertLessEqual(len(self.server.requests), 2 * smoke_test.MAX_ATTEMPTS) + + # --- 정상 흐름 --- + + def test_passes_against_healthy_server(self): + self.start(healthy_server) + self.assertEqual(0, self.run_script(secret=SERVER_SECRET)) + self.assertEqual(2, len(self.server.requests)) + self.assert_only_safe_gets() + + def test_skips_auth_check_without_secret(self): + self.start(healthy_server) + self.assertEqual(0, self.run_script()) + self.assertEqual(1, len(self.server.requests)) + self.assertNotIn("Authorization", self.server.requests[0][2]) + + def test_token_targets_nonexistent_user_with_short_ttl(self): + self.start(healthy_server) + self.run_script(secret=SERVER_SECRET) + token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") + claims = verify_token(token, SERVER_SECRET) + self.assertEqual("0", claims["sub"]) + self.assertEqual("ROLE_GUEST", claims["authority"]) + self.assertEqual(NOW, claims["iat"]) + self.assertEqual(smoke_test.TOKEN_TTL_SECONDS, claims["exp"] - claims["iat"]) + + def test_writes_step_summary(self): + self.start(healthy_server) + with tempfile.NamedTemporaryFile("r", suffix=".md", delete=False) as f: + path = f.name + try: + self.run_script(secret=SERVER_SECRET, extra_env={"GITHUB_STEP_SUMMARY": path}) + with open(path, encoding="utf-8") as f: + summary = f.read() + self.assertIn("## 스모크 테스트", summary) + self.assertEqual(2, summary.count("✅ 통과")) + finally: + os.remove(path) + + # --- 실패를 실패로 잡는가 --- + + def test_fails_when_signing_key_differs(self): + self.start(healthy_server) + self.assertEqual(1, self.run_script(secret=OTHER_SECRET)) + self.assertEqual([], self.sleeps, "4xx 는 재시도하지 않는다") + + def test_fails_when_endpoint_is_open_without_token(self): + def open_server(command, path, headers): + h, b = json_body({"data": 0}) + return 200, h, b + self.start(open_server) + self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) + self.assertEqual(1, len(self.server.requests), "첫 확인이 실패하면 토큰 요청을 보내지 않는다") + + def test_retries_bad_gateway_then_fails(self): + def bad_gateway(command, path, headers): + return 502, {"Content-Type": "text/html"}, b"502 Bad Gateway" + self.start(bad_gateway) + self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + self.assertEqual([smoke_test.RETRY_DELAY_SECONDS] * (smoke_test.MAX_ATTEMPTS - 1), self.sleeps) + self.assert_only_safe_gets() + + def test_recovers_when_first_attempt_fails(self): + calls = {"n": 0} + + def flaky(command, path, headers): + calls["n"] += 1 + if calls["n"] == 1: + return 503, {}, b"" + return healthy_server(command, path, headers) + self.start(flaky) + self.assertEqual(0, self.run_script(secret=SERVER_SECRET)) + self.assertEqual(3, len(self.server.requests)) + + def test_fails_on_html_page(self): + def maintenance(command, path, headers): + return 200, {"Content-Type": "text/html"}, b"maintenance" + self.start(maintenance) + self.assertEqual(1, self.run_script()) + + def test_fails_on_maintenance_json_without_error_code(self): + def maintenance(command, path, headers): + h, b = json_body({"errorCode": None, "message": "점검 중입니다"}) + return 400, h, b + self.start(maintenance) + self.assertEqual(1, self.run_script()) + self.assertEqual([], self.sleeps) + + def test_reports_cloudflare_block_separately(self): + def cloudflare(command, path, headers): + return 403, {"Server": "cloudflare", "cf-mitigated": "challenge"}, b"Just a moment..." + self.start(cloudflare) + self.assertEqual(1, self.run_script()) + res = smoke_test.check_reachable(self.server.url, sleep=lambda s: None) + self.assertIn("Cloudflare", res.detail) + + def test_reports_origin_unreachable_behind_cloudflare(self): + # 2026-09-15 dev 도메인에서 실제로 받은 응답 모양이다 (server: cloudflare, 본문 "error code: 502"). + def origin_down(command, path, headers): + return 502, {"Server": "cloudflare", "Content-Type": "text/plain; charset=UTF-8"}, b"error code: 502" + self.start(origin_down) + res = smoke_test.check_reachable(self.server.url, sleep=self.sleeps.append) + self.assertFalse(res.passed) + self.assertIn("원 서버에 닿지 못했습니다", res.detail) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + + def test_fails_when_server_is_unreachable(self): + self.start(healthy_server) + url = self.server.url + self.server.close() + self.server = None + env = {"SMOKE_BASE_URL": url} + self.assertEqual(1, smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW)) + self.assertEqual(smoke_test.MAX_ATTEMPTS - 1, len(self.sleeps)) + + def test_does_not_follow_redirect_and_does_not_leak_token(self): + # 리다이렉트를 따라가면 urllib 이 Authorization 헤더까지 옮긴다. 따라가지 않아야 한다. + def redirecting(command, path, headers): + if path == smoke_test.SAFE_PATH and not headers.get("Authorization"): + return healthy_server(command, path, headers) + if path == smoke_test.SAFE_PATH: + return 302, {"Location": "/leak"}, b"" + return 200, {}, b"leaked" + self.start(redirecting) + self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) + self.assertEqual([smoke_test.SAFE_PATH, smoke_test.SAFE_PATH], [p for _, p, _ in self.server.requests]) + self.assertEqual([], self.sleeps, "3xx 는 재시도하지 않는다") + + def test_redirect_on_first_check_fails(self): + def maintenance_redirect(command, path, headers): + return 301, {"Location": "https://example.com/maintenance"}, b"" + self.start(maintenance_redirect) + res = smoke_test.check_reachable(self.server.url, sleep=self.sleeps.append) + self.assertFalse(res.passed) + self.assertIn("리다이렉트", res.detail) + self.assertEqual(1, len(self.server.requests)) + + def test_old_server_reports_wrong_key_as_expired(self): + # 운영 main(2026-07-01)은 서명이 틀려도 1005 를 준다. 실패로 잡고 서명 키 가능성도 안내해야 한다. + def old_server(command, path, headers): + if not headers.get("Authorization"): + return healthy_server(command, path, headers) + h, b = json_body({"errorCode": 1005, "message": "엑세스 토큰이 만료되었습니다."}) + return 401, h, b + self.start(old_server) + res = smoke_test.check_authenticated(self.server.url, OTHER_SECRET, NOW, sleep=self.sleeps.append) + self.assertFalse(res.passed) + self.assertIn("서명 키", res.detail) + + def test_boolean_data_is_not_a_count(self): + def wrong_shape(command, path, headers): + if not headers.get("Authorization"): + return healthy_server(command, path, headers) + h, b = json_body({"data": True}) + return 200, h, b + self.start(wrong_shape) + self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) + + def test_retries_rate_limited_response(self): + def rate_limited(command, path, headers): + return 429, {}, b"" + self.start(rate_limited) + self.assertEqual(1, self.run_script()) + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + + def test_times_out_on_slow_server(self): + release = threading.Event() + + def slow(command, path, headers): + release.wait(5) + return healthy_server(command, path, headers) + self.start(slow) + original = smoke_test.REQUEST_TIMEOUT_SECONDS + smoke_test.REQUEST_TIMEOUT_SECONDS = 0.3 + try: + self.assertEqual(1, self.run_script()) + finally: + smoke_test.REQUEST_TIMEOUT_SECONDS = original + release.set() + self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + + # --- 설정 오류 --- + + def test_refuses_to_send_token_over_plain_http(self): + env = {"SMOKE_BASE_URL": "http://ono-dev.seungminki.shop", "SMOKE_ACCESS_TOKEN_SECRET": SERVER_SECRET} + self.assertEqual(2, smoke_test.run(env, sleep=self.sleeps.append)) + + def test_config_error_without_base_url(self): + self.assertEqual(2, smoke_test.run({}, sleep=self.sleeps.append)) + + def test_fails_on_non_base64_secret(self): + self.start(healthy_server) + self.assertEqual(1, self.run_script(secret="not base64 !!")) + self.assertEqual(1, len(self.server.requests)) + + +if __name__ == "__main__": + unittest.main() From 3427a0bb7f05203d2398177dd9702ce76b872a9a Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 15 Sep 2026 18:28:24 +0900 Subject: [PATCH 098/130] =?UTF-8?q?[Chore]=20=EC=8A=A4=EB=AA=A8=ED=81=AC?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=ED=9A=8C=EC=99=80=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=9D=90=EB=A6=84=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=ED=95=9C=EB=8B=A4=20-=20read=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84:=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20=EC=95=B1=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=B4=20=EC=93=B0=EB=8A=94=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=2015=EA=B0=9C=EB=A5=BC=20=EB=B6=80=EB=A5=B4=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=AA=A8=EC=96=91=EC=9D=84=20=EB=B3=B8?= =?UTF-8?q?=EB=8B=A4=20=20=20GET=20/api/users(=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EB=AF=B8=EC=85=98,=20=EC=A0=91=EC=86=8D=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81,=20DAU),=20learning-reports(OpenAI),=20achievements(?= =?UTF-8?q?=EC=97=85=EC=A0=81=20INSERT)=EB=8A=94=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=A7=8C=EC=9C=BC=EB=A1=9C=20=ED=9D=94=EC=A0=81=EC=9D=B4=20?= =?UTF-8?q?=EB=82=A8=EC=95=84=20=EB=BA=90=EB=8B=A4=20-=20full=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84:=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=20=EC=95=88=EC=97=90=EC=84=9C=20=ED=8F=B4=EB=8D=94=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0,=20=EC=A1=B0=ED=9A=8C,=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=B0=94=EA=BE=B8=EA=B8=B0,=20=EC=A7=80=EC=9A=B0=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EB=B3=B5=EC=8A=B5=EB=85=B8=ED=8A=B8=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B8=B0,=20=EC=A1=B0=ED=9A=8C,=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=ED=95=98=EA=B3=A0=20=EC=A7=80=EC=9A=B4=20?= =?UTF-8?q?=EB=92=A4=20404=20=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20=20=20=EB=AC=B8=EC=A0=9C=20=EB=93=B1=EB=A1=9D(XP,?= =?UTF-8?q?=20=EB=AF=B8=EC=85=98=20=EA=B8=B0=EB=A1=9D),=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8(main=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8),=20=EC=95=8C=EB=A6=BC=20=EB=B6=99=EC=9D=B8=20?= =?UTF-8?q?=EB=B3=B5=EC=8A=B5=EB=85=B8=ED=8A=B8(Quartz=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20=EC=9E=94=EC=A1=B4)=EB=8A=94=20=EB=84=A3=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EC=95=98=EB=8B=A4=20-=20bootstrap=20=EB=AA=85?= =?UTF-8?q?=EB=A0=B9=EC=9C=BC=EB=A1=9C=20=EA=B2=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9D=84=20=ED=95=9C=20=EB=B2=88=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EB=A3=A8=ED=8A=B8=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20=5F=5Fsmoke=5Faccount=5F=5F=20=ED=91=9C=EC=8B=9D=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EB=8B=A8=EB=8B=A4=20-=20?= =?UTF-8?q?=EB=B3=B4=EB=82=BC=20=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=84=20=ED=97=88=EC=9A=A9=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=A0=EC=A0=95=ED=95=98=EA=B3=A0,=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=EB=8A=94=20full=20=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=A7=8C,=20=EA=B0=80=EC=9E=85=EC=9D=80=20bootstrap=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=A7=8C=20=ED=97=88=EC=9A=A9=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20-=20=EB=A3=A8=ED=8A=B8=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=A3=BC=EC=9D=B8=EA=B3=BC=20=ED=91=9C=EC=8B=9D=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=EA=B0=80=20=EB=A7=9E=EC=A7=80=20=EC=95=8A=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=9A=94=EC=B2=AD=202=EA=B0=9C=20=EB=92=A4=20?= =?UTF-8?q?=EB=A9=88=EC=B6=94=EA=B3=A0=20=EC=9D=91=EB=8B=B5=20=EB=B3=B8?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EB=82=A8=EA=B8=B0=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=EB=8B=A4.=20SMOKE=5FUSER=5FID=20=EC=98=A4=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=EC=9C=BC=EB=A1=9C=20=EC=8B=A4=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B3=84=EC=A0=95=EC=9D=84=20=EA=B1=B4=EB=93=9C?= =?UTF-8?q?=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EB=8A=94=20=EA=B2=83=EC=9D=80=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=88=20=EC=8B=A4=ED=96=89=EC=97=90=EC=84=9C=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A0=20=EA=B2=83=EA=B3=BC=20=5F=5Fsmoke=5Frun=5F=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=8B=9C=EC=9E=91=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=A7=80=EB=82=9C=20=EC=8B=A4=ED=96=89=EC=9D=98=20=ED=9D=94?= =?UTF-8?q?=EC=A0=81=EB=BF=90=EC=9D=B4=EB=8B=A4.=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EA=B0=80=20=ED=95=98=EB=82=98=EB=9D=BC=EB=8F=84=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=98=EB=A9=B4=20=EC=93=B0=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20-=20=ED=95=9C=20=EB=B2=88=20=EC=8B=A4=ED=96=89?= =?UTF-8?q?=EC=97=90=20=EC=9A=94=EC=B2=AD=2080=EA=B0=9C,=20200=EC=B4=88=20?= =?UTF-8?q?=EC=83=81=ED=95=9C=EC=9D=84=20=EB=91=94=EB=8B=A4.=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=EB=8A=94=20GET=20=EB=A7=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20-=20=EA=B0=80=EC=A7=9C=20OnO=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=A1=9C=20=ED=97=88=EC=9A=A9=20=EC=9A=94=EC=B2=AD,=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9D=20=ED=99=95=EC=9D=B8,=20=ED=9D=94=EC=A0=81?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC,=20=EC=8B=A4=ED=8C=A8=20=EC=A4=91=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC,=20=EC=83=81=ED=95=9C=EC=9D=84=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8A=94=20=EC=9E=90=EC=B2=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=2042=EA=B0=9C=EB=A1=9C=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=8D=BC=EB=8B=A4=20-=20=EB=A1=9C=EC=BB=AC=EC=97=90=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=EA=B3=BC=20=EA=B0=99=EC=9D=80=20main=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=EA=B3=BC=20develop=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=EB=A5=BC=20=EB=9D=84=EC=9B=8C=20read=20?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EB=B3=80=ED=99=94=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C,=20full=20=EC=9D=80=20=EC=8A=A4=EB=AA=A8?= =?UTF-8?q?=ED=81=AC=20=EA=B3=84=EC=A0=95=EC=9D=98=20folder=20=EC=99=80=20?= =?UTF-8?q?practice=5Fnote=20=EB=A7=8C=20=EB=B0=94=EB=80=8C=EB=8A=94=20?= =?UTF-8?q?=EA=B2=83=EC=9D=84=20CHECKSUM=20TABLE=20=EB=A1=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/smoke/README.md | 125 ++++-- scripts/smoke/smoke_test.py | 709 +++++++++++++++++++++++++------ scripts/smoke/test_smoke_test.py | 669 ++++++++++++++++++++--------- 3 files changed, 1138 insertions(+), 365 deletions(-) diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md index 563ec292..46862dbe 100644 --- a/scripts/smoke/README.md +++ b/scripts/smoke/README.md @@ -2,60 +2,115 @@ 배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. 컨테이너 healthcheck 는 컨테이너 안의 `localhost:8081/actuator/health` 만 봐서, 그 앞의 Cloudflare 와 nginx, -JWT 서명 키, DB 조회가 어긋나도 알 수 없다. +JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 맥미니가 꺼져도 알 수 없다. -## 무엇을 확인하나 +## 언제 도는가 -| 확인 | 요청 | 통과 기준 | 이게 실패하면 | +| 언제 | 대상 | 단계 | 알림 | |---|---|---|---| -| 앱까지 닿는가 | 토큰 없이 `GET /api/problems/problemCount` | `401`, `errorCode 1007` | Cloudflare 가 원 서버에 못 닿았거나(502), nginx 나 앱이 이상하다 | -| 인증과 DB 조회가 되는가 | 스모크 토큰으로 같은 요청 | `200`, `data` 가 숫자 | 서버의 JWT 서명 키가 시크릿과 다르거나 DB 조회가 안 된다 | +| 배포 워크플로가 끝난 뒤 (`workflow_run`) | 그 배포의 서버 | 배포 성공이면 `full`, 실패면 `read` | 통과와 실패 모두 Discord 로 | +| 매시 17분 (`schedule`) | prod | `read` | 실패로 바뀔 때와 복구될 때. 실패가 이어지면 6시간마다 다시 | +| 수동 실행 (`workflow_dispatch`) | 고른 서버 | 고른 단계 | 없음. prod 는 `main` 브랜치에서만 실행된다 | + +배포 워크플로 파일은 건드리지 않았다. 배포가 끝난 것을 받아서 따로 도는 방식이라, 스모크 테스트가 틀려도 배포와 nginx 전환에는 영향이 없다. +맥미니 러너가 아니라 GitHub 호스티드 러너에서 돌기 때문에 운영 기계의 자원도 쓰지 않는다. + +## 무엇을 확인하나 + +| 단계 | 확인 | 요청 수 | +|---|---|---:| +| `reach` | 토큰 없이 `GET /api/problems/problemCount` 가 `401 (1007)` 인가. Cloudflare, nginx, 앱의 인증 필터까지 닿았다는 뜻이다 | 1 | +| `read` | 스모크 계정 토큰으로 루트 폴더를 읽어 표식 폴더를 확인한 뒤, 앱 화면들이 쓰는 조회 API 15개가 기대한 모양으로 오는가 | 17 | +| `full` | `read` 에 더해 스모크 계정 안에서 폴더 만들기, 조회, 이름 바꾸기, 지우기, 지운 뒤 404 확인. 복습노트 만들기, 조회, 지우기, 지운 뒤 404 확인 | 28 | + +`read` 의 조회 API 는 폴더 목록과 썸네일, 폴더 단건, 하위 폴더, 오답노트 수, 복습할 문제, 내 오답노트, 폴더 안 오답노트, +복습노트 썸네일과 전체, 풀이 기록 수, 내 스터디룸, 태그, 학습 캘린더, 학습 리포트 요약이다. +운영 main 버전과 develop 버전 양쪽에 다 있는 API 만 골랐다. ## 운영 서버에 영향이 없는 이유 -- **GET 하나만 보낸다.** 경로는 스크립트에 고정되어 있다. 이 경로는 읽기 전용 트랜잭션에서 count 쿼리 하나만 돈다. -- **`GET /api/users` 는 쓰지 않는다.** 조회처럼 보이지만 로그인 미션 기록과 마지막 접속 시각 갱신이 같이 돈다. -- **존재하지 않는 사용자 ID 0 으로 60초짜리 토큰을 만든다.** 운영 DB 에 스모크 계정을 만들 필요가 없고 실제 사용자 데이터에 닿지 않는다. -- **한 번 실행에 요청은 최대 6개다.** 확인 2개에 시도 3번씩이고, 재시도는 연결 실패와 5xx, 429 에서만 한다. -- **리다이렉트를 따라가지 않는다.** 따라가면 토큰이 다른 호스트로 옮겨 가고 요청 수 상한도 깨져서, 3xx 는 실패로 본다. -- **배포 워크플로와 연결하지 않았다.** 수동 실행만 하고, 맥미니 러너가 아니라 GitHub 호스티드 러너에서 돈다. +- **보낼 수 있는 요청이 스크립트에 고정되어 있다.** `ALLOWED_REQUESTS` 밖의 요청은 보내기 전에 막는다. 쓰기는 `full` 에서만, 게스트 가입은 `bootstrap` 에서만 허용한다. +- **스모크 계정 표식을 먼저 확인한다.** 루트 폴더의 주인이 `SMOKE_USER_ID` 이고 그 아래에 `__smoke_account__` 폴더가 있어야 진행한다. 아니면 요청 2개만 보내고 멈추고, 그 응답 본문은 공개 로그에 남기지 않는다. +- **조회가 하나라도 실패하면 쓰기를 시작하지 않는다.** 서버가 불안정할 때 쓰기를 더하지 않는다. +- **지우는 것은 스모크 흔적뿐이다.** 이번 실행에서 만든 것과, 이름이 `__smoke_run_` 으로 시작하는 지난 실행의 흔적만 지운다. +- **요청 수와 시간에 상한이 있다.** 한 번 실행에 80개, 200초를 넘으면 멈춘다. 재시도는 GET 만, 연결 실패와 5xx, 429 에서 최대 3번이다. 쓰기는 재시도하지 않는다. +- **쓰기가 있는 실행은 대상별로 한 줄로 선다.** 조회만 하는 매시 실행은 따로 돌아서 서로 밀어내지 않는다. +- **리다이렉트를 따라가지 않고, 토큰은 https 로만 보낸다.** -로컬에 띄운 서버에서 세 번 연속 실행했을 때 MySQL 의 INSERT, UPDATE, DELETE 카운터는 그대로였고 -Redis 에는 토큰 블랙리스트 조회 `GET` 만 들어왔다. +### 넣지 않은 API 와 이유 -그래도 남는 것은 이 정도다. +코드를 main 과 develop 두 버전으로 따라가서 호출만으로 흔적이 남는 것은 뺐다. -- 서버 DB 가 이미 죽어 있으면 count 조회가 500 이 되어 기존 예외 처리대로 Discord 알림이 한 건 나간다. 5분 중복 억제가 있다. -- 토큰 없는 요청은 Spring Security 가 요청을 기억하려고 톰캣 메모리에 세션을 만들 수 있다. 한 번 실행에 최대 3개이고 DB 와 Redis 에는 닿지 않는다. +| API | 뺀 이유 | +|---|---| +| `GET /api/users` | 로그인 미션 기록, 포인트와 마지막 접속 시각 갱신이 같이 돈다. 관리자 DAU 집계에도 잡힌다 | +| `GET /api/learning-reports` | 캐시가 없으면 OpenAI 를 부른다 | +| `GET /api/achievements` | 조건이 맞으면 업적 행을 INSERT 한다 | +| 문제 등록과 삭제 | 삭제해도 XP 와 미션 기록, develop 의 리마인더 행이 남는다 | +| 태그 만들기 | main 에서 지운 태그와 같은 이름으로 다시 만들면 유니크 인덱스에 걸린다 | +| 알림 붙인 복습노트 | Quartz 작업이 생기고, main 은 복습노트를 지워도 그 작업을 지우지 않는다 | +| 스터디룸 가입 | 다른 멤버의 피드에 보이고, 탈퇴 로직이 외래키에 막힌다 | -## 에러 코드를 읽는 법 +### 로컬에서 잰 결과 -서버 버전에 따라 같은 원인에도 코드가 다르게 나온다. +운영과 같은 main 버전과 develop 버전 서버를 로컬 격리 스택에 각각 띄워 잰 값이다. -| 코드 | 가능한 원인 | +| 확인한 것 | 결과 | |---|---| -| `1009` | 시크릿이 서버의 JWT 서명 키와 다르다 (develop 이후) | -| `1005` | 시크릿이 다르다 (main 2026-07-01 버전은 서명이 틀려도 1005 를 준다), 또는 러너와 서버 시계가 어긋났다 | -| `1007` | 시크릿이 다르거나, 서버의 Redis 블랙리스트 조회가 실패했다 | +| `read` 전후 전체 테이블 `CHECKSUM TABLE` | 두 버전 모두 바뀐 테이블 없음 | +| `full` 전후 전체 테이블 `CHECKSUM TABLE` | `folder`, `practice_note` 두 테이블만 바뀜 (스모크 계정의 소프트 삭제 행이 하나씩 늘어남) | +| RabbitMQ 큐 | 새 메시지 없음 | +| 앱 로그 | 스모크가 일부러 확인한 404 외에 WARN, ERROR 없음 | +| 표식 없는 계정 ID 로 `full` | 요청 2개 뒤 멈춤, 테이블 변화 없음 | +| 중간에 끊긴 실행의 흔적을 만들어 두고 `full` | 흔적 폴더와 복습노트를 지우고 통과 | -## 실행 +그래도 남는 것은 이 정도다. + +- `full` 한 번에 스모크 계정의 소프트 삭제된 폴더와 복습노트 행이 하나씩 쌓인다. 배포할 때만 도니 1년에 수십 행 수준이다. +- 스모크 계정 1명이 관리자 사용자 목록과 사용자 수에 잡힌다. +- 스모크 계정 자신의 Redis 캐시 키(`TAG_LIST`, `STREAK`, `LEARNING_REPORT_SUMMARY`)가 TTL 로 생긴다. +- Prometheus 의 사용자 행동 지표에 게스트 요청으로 잡힌다. 사용자 ID 태그가 없어서 따로 걸러낼 수는 없다. +- 서버 DB 가 이미 죽어 있으면 조회가 500 이 되어 기존 예외 처리대로 Discord 에러 알림이 나간다(5분 중복 억제). + +## 처음 설정하는 순서 + +1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다. +2. **`SMOKE_PROD_ACCESS_TOKEN_SECRET` 을 등록한다.** 서버의 `jwt.accessToken.secret` 과 같은 값이다. 이것만 있어도 매시 확인이 인증과 DB 조회까지 본다. +3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다. +4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다. +5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다. -GitHub Actions 에서 `Smoke Test` 워크플로를 `dev` 나 `prod` 로 수동 실행한다. -`workflow_dispatch` 는 워크플로 파일이 기본 브랜치(`main`)에 있어야 실행 버튼이 생긴다. -인증 확인까지 하려면 저장소 Secrets 에 서버의 `jwt.accessToken.secret` 과 같은 값을 넣는다. +JWT 서명 키를 바꾸면 `SMOKE_*_ACCESS_TOKEN_SECRET` 도 같이 바꿔야 한다. 계정 ID 는 그대로 쓴다. +이 시크릿은 서버 서명 키 그 자체라 관리자 토큰도 만들 수 있으니 `main` 에서만 쓸 수 있는 GitHub Environment 에 두는 편이 더 안전하다. -| Secret | 대상 | +## 실패했을 때 읽는 법 + +| 실패 내용 | 가능한 원인 | |---|---| -| `SMOKE_DEV_ACCESS_TOKEN_SECRET` | `https://ono-dev.seungminki.shop` | -| `SMOKE_PROD_ACCESS_TOKEN_SECRET` | `https://ono-prod.seungminki.shop` | +| Cloudflare 가 원 서버에 닿지 못했습니다 (502) | 맥미니, nginx, 공유기, 인터넷 중 하나가 죽었다 | +| Cloudflare 가 요청을 막았습니다 | Cloudflare 보안 설정이 GitHub 러너 IP 를 막았다. 서버 상태는 알 수 없다 | +| 리다이렉트 응답, JSON 이 아닌 응답 | 점검 모드가 켜져 있거나 nginx 설정이 바뀌었다 | +| 토큰이 거절됐습니다 `1009` | 시크릿이 서버 서명 키와 다르다 (develop 이후) | +| 토큰이 거절됐습니다 `1005` | 시크릿이 다르거나(main 은 서명이 틀려도 1005) 시계가 어긋났다 | +| 토큰이 거절됐습니다 `1007` | 시크릿이 다르거나 서버의 Redis 블랙리스트 조회가 실패했다 | +| 앱이 정상 응답하지 않습니다 (5xx) | 앱 내부 오류. DB 연결을 먼저 본다 | +| 루트 폴더에 표식 폴더가 없습니다 | `SMOKE_USER_ID` 가 스모크 계정이 아니다 | +| 응답 모양이 예상과 다릅니다 | 배포된 API 의 응답 형태가 바뀌었다. 앱 파싱도 깨졌을 수 있다 | +| 삭제 후에도 조회됩니다 | 삭제가 반영되지 않았다. 다음 실행이 흔적을 다시 지운다 | -없으면 인증 확인은 건너뛰고 "앱까지 닿는가"만 본다. -이 시크릿은 서버의 서명 키 그 자체라 관리자 토큰도 만들 수 있다. 저장소 Secrets 대신 `main` 에서만 쓸 수 있는 -GitHub Environment 에 두는 편이 더 안전하다. +## 로컬에서 돌리기 -로컬에서는 이렇게 돌린다. 종료 코드는 0 통과, 1 실패, 2 설정 오류다. +종료 코드는 0 통과, 1 실패, 2 설정 오류다. ```bash -SMOKE_BASE_URL=https://ono-dev.seungminki.shop python3 scripts/smoke/smoke_test.py -python3 -m unittest discover -s scripts/smoke -p 'test_*.py' # 스크립트 자체 테스트 22개 +# 닿는지만 +SMOKE_BASE_URL=https://ono-prod.seungminki.shop SMOKE_LEVEL=reach python3 scripts/smoke/smoke_test.py + +# 스크립트 자체 테스트 42개 (가짜 서버로 허용 요청, 표식 확인, 흔적 정리, 요청과 시간 상한까지 본다) +python3 -m unittest discover -s scripts/smoke -p 'test_*.py' ``` + +## 주의 + +- 공개 레포라 Actions 로그와 실행 요약이 공개된다. 토큰은 마스킹하고, 요약에는 계정 ID 외의 값을 남기지 않는다. +- GitHub 는 공개 레포에 60일 동안 활동이 없으면 `schedule` 을 멈춘다. 그때는 Actions 화면에서 다시 켠다. diff --git a/scripts/smoke/smoke_test.py b/scripts/smoke/smoke_test.py index fc3f9875..d09580b1 100644 --- a/scripts/smoke/smoke_test.py +++ b/scripts/smoke/smoke_test.py @@ -5,27 +5,40 @@ 컨테이너 healthcheck(actuator/health)는 프로세스가 떴는지만 알려 주고, Cloudflare 와 nginx, JWT 서명 키, DB 조회는 거치지 않는다. 그 사이를 여기서 본다. -운영 서버에 영향을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. - -- GET 만 보낸다. 호출하는 경로는 SAFE_PATH 하나로 고정되어 있고 바꿀 수 없다. -- SAFE_PATH 는 읽기 전용 트랜잭션에서 count 쿼리 하나만 돈다 - (ProblemService.findProblemCountByUser, @Transactional(readOnly = true)). - GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 마지막 접속 시각 갱신이 - 같이 돌아서(UserController.getUserInfo) 쓰지 않는다. -- 인증 확인용 토큰은 존재하지 않는 사용자 ID 0 으로 60초짜리를 만든다. - JwtTokenFilter 는 서명과 블랙리스트만 보고 사용자 존재 여부는 보지 않으며, - count 쿼리는 행이 없으면 0 을 돌려준다. 그래서 운영 DB 에 스모크 계정을 만들 필요가 없고 - 실제 사용자의 데이터에는 닿지 않는다. -- 한 번 실행에 보내는 요청은 최대 (확인 2개 x 시도 3번) = 6개다. - 재시도는 연결 실패와 5xx, 429 에서만 하고 4xx 는 바로 판정한다. +확인은 세 단계로 나뉜다. 뒤 단계는 앞 단계를 포함한다. + + reach 토큰 없이 한 번 불러서 앱까지 닿는지 본다. + read 스모크 전용 게스트 계정으로 앱 화면들이 쓰는 조회 API 를 부른다. + full read 에 더해, 스모크 계정 안에서 폴더와 복습노트를 만들고 조회하고 지운다. + +운영 서버에 지장을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. +각 API 를 고른 근거는 README.md 에 있다. + +- 보낼 수 있는 요청은 ALLOWED_REQUESTS 에 적힌 메서드와 경로뿐이다. 그 밖의 요청은 보내기 전에 막는다. + GET /api/users 는 조회처럼 보이지만 로그인 미션 기록과 접속 시각 갱신, DAU 집계가 같이 돌아서 넣지 않았다. +- 쓰기 요청은 full 단계에서만 허용하고, 재시도하지 않는다. +- 지우는 것은 이번 실행에서 만든 것과, 이름이 RUN_PREFIX 로 시작하는 지난 실행의 흔적뿐이다. +- 스모크 계정 루트 폴더 아래에 MARKER_FOLDER_NAME 폴더가 없으면 스모크 계정이 아니라고 보고 + 더 이상 요청하지 않는다. SMOKE_USER_ID 를 잘못 넣어 실사용자 계정을 건드리는 일을 막는다. +- 한 번 실행에 보내는 요청 수는 MAX_REQUESTS 를 넘지 않고, 전체 시간은 DEADLINE_SECONDS 를 넘지 않는다. + 재시도도 여기에 포함된다. 서버가 느릴 때 스모크 테스트가 요청을 오래 붙들고 있지 않게 한다. +- 조회가 하나라도 실패하면 쓰기 흐름을 시작하지 않는다. 서버가 불안정할 때 쓰기를 더하지 않는다. - 리다이렉트를 따라가지 않는다. urllib 은 기본으로 3xx 를 따라가면서 Authorization 헤더까지 - 옮기기 때문에, 따라가면 토큰이 다른 호스트로 새고 요청 수 상한도 깨진다. 3xx 는 실패로 본다. + 옮기기 때문에, 따라가면 토큰이 다른 호스트로 샌다. - 토큰은 https 이거나 로컬 주소일 때만 보낸다. 환경 변수 - SMOKE_BASE_URL 필수. 예: https://ono-dev.seungminki.shop - SMOKE_ACCESS_TOKEN_SECRET 선택. 서버의 jwt.accessToken.secret 과 같은 Base64 값. - 없으면 인증 확인은 건너뛰고 도달 확인만 한다. + SMOKE_BASE_URL 필수. 예: https://ono-prod.seungminki.shop + SMOKE_LEVEL reach | read | full. 기본 read + SMOKE_ACCESS_TOKEN_SECRET 서버의 jwt.accessToken.secret 과 같은 Base64 값 + SMOKE_USER_ID bootstrap 으로 만든 스모크 게스트 계정 ID + + 시크릿이 없으면 reach 만 한다. 시크릿만 있고 계정 ID 가 없으면 존재하지 않는 사용자 ID 0 으로 + 인증과 count 조회 한 번만 확인한다. + +명령 + python3 smoke_test.py 확인 + python3 smoke_test.py bootstrap 스모크 게스트 계정을 한 번 만든다 (Discord 가입 알림이 한 번 간다) 종료 코드: 0 통과, 1 실패, 2 설정 오류 """ @@ -38,32 +51,81 @@ import hmac import json import os +import re +import secrets import sys import time import urllib.error import urllib.parse import urllib.request from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone from typing import Callable, Mapping -SAFE_PATH = "/api/problems/problemCount" +PROBE_PATH = "/api/problems/problemCount" -# 존재하지 않는 사용자. 위 docstring 참고. -SMOKE_USER_ID = "0" +# 인증 확인만 할 때 쓰는 존재하지 않는 사용자. +# JwtTokenFilter 는 서명과 블랙리스트만 보고 사용자 존재 여부는 보지 않으며, count 는 0 을 돌려준다. +NONEXISTENT_USER_ID = "0" SMOKE_AUTHORITY = "ROLE_GUEST" -TOKEN_TTL_SECONDS = 60 +TOKEN_TTL_SECONDS = 300 + +MARKER_FOLDER_NAME = "__smoke_account__" +RUN_PREFIX = "__smoke_run_" REQUEST_TIMEOUT_SECONDS = 10 MAX_ATTEMPTS = 3 RETRY_DELAY_SECONDS = 3 -USER_AGENT = "OnO-SmokeTest/1.0" +MAX_REQUESTS = 80 +DEADLINE_SECONDS = 200 +USER_AGENT = "OnO-SmokeTest/2.0" + +LEVELS = ("reach", "read", "full") +LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1") +KST = timezone(timedelta(hours=9)) # AuthErrorCase AUTHENTICATION_FAILED = 1007 ACCESS_TOKEN_EXPIRED = 1005 INVALID_ACCESS_TOKEN = 1009 - -LOCAL_HOSTS = ("127.0.0.1", "localhost", "::1") +# FolderErrorCase, PracticeNoteErrorCase +FOLDER_NOT_FOUND = 5001 +PRACTICE_NOTE_NOT_FOUND = 6001 + +WRITE_METHODS = ("POST", "PATCH", "DELETE") +SIGNUP_PATH = "/api/auth/signup/guest" + +# 보낼 수 있는 요청 전부. 경로는 쿼리스트링을 뺀 값과 통째로 맞아야 한다. +ALLOWED_REQUESTS = [ + ("GET", r"/api/problems/problemCount"), + ("GET", r"/api/folders"), + ("GET", r"/api/folders/root"), + ("GET", r"/api/folders/thumbnails/V2"), + ("GET", r"/api/folders/\d+"), + ("GET", r"/api/folders/\d+/subfolders/V2"), + ("GET", r"/api/problems/review-due"), + ("GET", r"/api/problems/user"), + ("GET", r"/api/problems/folder/\d+/V2"), + ("GET", r"/api/practiceNotes/thumbnail"), + ("GET", r"/api/practiceNotes/thumbnail/V2"), + ("GET", r"/api/practiceNotes/all"), + ("GET", r"/api/practiceNotes/\d+"), + ("GET", r"/api/problem-solves/user/count"), + ("GET", r"/api/study-room"), + ("GET", r"/api/tags"), + ("GET", r"/api/learning-calendar"), + ("GET", r"/api/learning-reports/summary"), + ("POST", r"/api/folders"), + ("PATCH", r"/api/folders"), + ("DELETE", r"/api/folders"), + ("POST", r"/api/practiceNotes"), + ("DELETE", r"/api/practiceNotes"), + ("POST", SIGNUP_PATH), +] + + +class SmokeAbort(Exception): + """더 진행하면 안 되는 상황. 남은 확인을 건너뛰고 정리만 한다.""" @dataclass @@ -81,6 +143,14 @@ def json(self): except (UnicodeDecodeError, json.JSONDecodeError): return None + def data(self): + body = self.json() + return body.get("data") if isinstance(body, dict) else None + + def error_code(self): + body = self.json() + return body.get("errorCode") if isinstance(body, dict) else None + def snippet(self) -> str: text = self.body.decode("utf-8", errors="replace").strip().replace("\n", " ") return text[:120] @@ -95,23 +165,20 @@ class CheckResult: skipped: bool = False +# --- 토큰 --------------------------------------------------------------------- + def _b64url(raw: bytes) -> str: return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") -def mint_access_token(secret_b64: str, now: int) -> str: +def mint_access_token(secret_b64: str, now: int, user_id: str = NONEXISTENT_USER_ID) -> str: """JwtTokenizer.createAccessToken 과 같은 모양의 HS256 토큰을 만든다. 서버는 jwt.accessToken.secret 을 Base64 로 디코딩한 바이트를 HMAC 키로 쓴다. """ key = base64.b64decode(secret_b64.strip(), validate=True) header = {"alg": "HS256"} - payload = { - "authority": SMOKE_AUTHORITY, - "sub": SMOKE_USER_ID, - "iat": now, - "exp": now + TOKEN_TTL_SECONDS, - } + payload = {"authority": SMOKE_AUTHORITY, "sub": str(user_id), "iat": now, "exp": now + TOKEN_TTL_SECONDS} signing_input = ( _b64url(json.dumps(header, separators=(",", ":")).encode()) + "." @@ -121,6 +188,26 @@ def mint_access_token(secret_b64: str, now: int) -> str: return signing_input + "." + _b64url(signature) +def subject_of(token: str) -> str | None: + """서버가 발급한 토큰에서 sub 를 꺼낸다. 서명은 검증하지 않는다(bootstrap 에서 계정 ID 를 알아낼 때만 쓴다).""" + parts = token.removeprefix("Bearer ").strip().split(".") + if len(parts) != 3: + return None + try: + payload = json.loads(base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))) + except (binascii.Error, ValueError): + return None + sub = payload.get("sub") if isinstance(payload, dict) else None + return str(sub) if sub is not None else None + + +def _mask(value: str, env: Mapping[str, str]) -> None: + if env.get("GITHUB_ACTIONS") == "true" and value: + print(f"::add-mask::{value}") + + +# --- HTTP --------------------------------------------------------------------- + class _NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self, req, fp, code, msg, headers, newurl): return None @@ -129,55 +216,103 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): _OPENER = urllib.request.build_opener(_NoRedirect) -def _get_once(url: str, headers: Mapping[str, str]) -> Response: - request = urllib.request.Request(url, method="GET", headers={"User-Agent": USER_AGENT, **headers}) - started = time.monotonic() - try: - with _OPENER.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as res: - body = res.read() - return Response(res.status, dict(res.headers), body, - elapsed_ms=int((time.monotonic() - started) * 1000)) - except urllib.error.HTTPError as e: - body = e.read() - return Response(e.code, dict(e.headers or {}), body, - elapsed_ms=int((time.monotonic() - started) * 1000)) - except (urllib.error.URLError, TimeoutError, OSError) as e: - reason = getattr(e, "reason", e) - return Response(None, error=str(reason), elapsed_ms=int((time.monotonic() - started) * 1000)) +class Budget: + """한 번 실행에서 모든 Client 가 함께 쓰는 요청 수와 시간 상한.""" + + def __init__(self, limit: int | None = None, deadline_seconds: float | None = None): + self.limit = MAX_REQUESTS if limit is None else limit + self.deadline = time.monotonic() + (DEADLINE_SECONDS if deadline_seconds is None else deadline_seconds) + self.sent = 0 + + def spend(self) -> None: + if self.sent >= self.limit: + raise SmokeAbort(f"한 번 실행의 요청 상한({self.limit}개)에 닿아 멈췄습니다") + if time.monotonic() >= self.deadline: + raise SmokeAbort("실행 시간 상한을 넘어 멈췄습니다. 서버 응답이 매우 느릴 수 있습니다") + self.sent += 1 + + +class Client: + """이 스크립트가 서버에 요청을 보내는 유일한 통로.""" + + def __init__(self, base_url: str, sleep: Callable[[float], None], budget: Budget, token: str | None = None, + allow_writes: bool = False, allow_signup: bool = False): + self.base_url = base_url.rstrip("/") + self.sleep = sleep + self.budget = budget + self.token = token + self.allow_writes = allow_writes + self.allow_signup = allow_signup + + def request(self, method: str, path: str, body=None, query: Mapping[str, object] | None = None, + auth: bool = True) -> Response: + method = method.upper() + if not any(m == method and re.fullmatch(p, path) for m, p in ALLOWED_REQUESTS): + raise SmokeAbort(f"허용 목록에 없는 요청이라 보내지 않았습니다: {method} {path}") + if path == SIGNUP_PATH: + if not self.allow_signup: + raise SmokeAbort("게스트 가입은 bootstrap 에서만 보낼 수 있습니다") + elif method in WRITE_METHODS and not self.allow_writes: + raise SmokeAbort(f"쓰기 요청은 full 단계에서만 보낼 수 있습니다: {method} {path}") + + url = self.base_url + path + if query: + url += "?" + urllib.parse.urlencode(query) + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + if auth and self.token: + headers["Authorization"] = self.token + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + + res = self._send(method, url, headers, data) + attempts = 1 + # 쓰기는 보낸 요청이 서버에 반영됐는지 알 수 없어서 재시도하지 않는다. + while method == "GET" and _should_retry(res) and attempts < MAX_ATTEMPTS: + self.sleep(RETRY_DELAY_SECONDS) + res = self._send(method, url, headers, data) + attempts += 1 + res.attempts = attempts + return res + + def _send(self, method: str, url: str, headers: Mapping[str, str], data: bytes | None) -> Response: + self.budget.spend() + request = urllib.request.Request(url, data=data, method=method, headers=dict(headers)) + started = time.monotonic() + try: + with _OPENER.open(request, timeout=REQUEST_TIMEOUT_SECONDS) as res: + return Response(res.status, dict(res.headers), res.read(), elapsed_ms=_ms(started)) + except urllib.error.HTTPError as e: + with e: + return Response(e.code, dict(e.headers or {}), e.read(), elapsed_ms=_ms(started)) + except (urllib.error.URLError, TimeoutError, OSError) as e: + return Response(None, error=str(getattr(e, "reason", e)), elapsed_ms=_ms(started)) -def _should_retry(res: Response) -> bool: - return res.status is None or res.status == 429 or res.status >= 500 +def _ms(started: float) -> int: + return int((time.monotonic() - started) * 1000) -def http_get(base_url: str, headers: Mapping[str, str], sleep: Callable[[float], None]) -> Response: - """SAFE_PATH 로 GET 을 보낸다. 이 스크립트에서 요청을 보내는 곳은 여기 하나다.""" - url = base_url.rstrip("/") + SAFE_PATH - res = _get_once(url, headers) - attempts = 1 - while _should_retry(res) and attempts < MAX_ATTEMPTS: - sleep(RETRY_DELAY_SECONDS) - res = _get_once(url, headers) - attempts += 1 - res.attempts = attempts - return res +def _should_retry(res: Response) -> bool: + return res.status is None or res.status == 429 or res.status >= 500 def _lower_headers(res: Response) -> dict[str, str]: return {k.lower(): v for k, v in res.headers.items()} -def _describe_unexpected(res: Response) -> str: +def describe_unexpected(res: Response) -> str: if res.status is None: return f"서버에 닿지 않았습니다 ({res.error}, {res.attempts}번 시도)" headers = _lower_headers(res) + server = headers.get("server", "").lower() if 300 <= res.status < 400: return (f"리다이렉트 응답입니다 (HTTP {res.status}, Location: {headers.get('location', '-')}). " "따라가지 않았습니다. 점검 모드나 nginx 설정을 확인해야 합니다") - if "cf-mitigated" in headers or (res.status == 403 and "cloudflare" in headers.get("server", "").lower() - and res.json() is None): + if "cf-mitigated" in headers or (res.status == 403 and "cloudflare" in server and res.json() is None): return f"Cloudflare 가 요청을 막았습니다 (HTTP {res.status}). 앱까지 가지 않아서 서버 상태는 알 수 없습니다" - if res.status >= 500 and "cloudflare" in headers.get("server", "").lower() and res.json() is None: + if res.status >= 500 and "cloudflare" in server and res.json() is None: # 원 서버 응답이면 nginx 나 앱이 만든 본문이 온다. Cloudflare 가 직접 만든 5xx 는 # 원 서버(맥미니 nginx)까지 닿지 못했다는 뜻이라 앱 오류와 구분해서 알린다. return (f"Cloudflare 가 원 서버에 닿지 못했습니다 (HTTP {res.status}, {res.attempts}번 시도). " @@ -185,108 +320,428 @@ def _describe_unexpected(res: Response) -> str: if res.status >= 500: return f"앱이 정상 응답하지 않습니다 (HTTP {res.status}, {res.attempts}번 시도): {res.snippet()}" if res.json() is None: - return (f"JSON 이 아닌 응답입니다 (HTTP {res.status}). " - f"nginx 오류 페이지나 점검 페이지일 수 있습니다: {res.snippet()}") + return f"JSON 이 아닌 응답입니다 (HTTP {res.status}). nginx 오류 페이지나 점검 페이지일 수 있습니다: {res.snippet()}" + if res.status == 401: + return describe_token_rejection(res) return f"예상과 다른 응답입니다 (HTTP {res.status}): {res.snippet()}" -def check_reachable(base_url: str, sleep: Callable[[float], None]) -> CheckResult: - """토큰 없이 호출해서 Cloudflare, nginx, 앱의 인증 필터까지 닿는지 본다. DB 는 거치지 않는다.""" - name = "앱까지 닿는가 (토큰 없이 401)" - res = http_get(base_url, {}, sleep) - body = res.json() - if res.status == 401 and isinstance(body, dict) and body.get("errorCode") == AUTHENTICATION_FAILED: - return CheckResult(name, True, f"HTTP 401, errorCode {AUTHENTICATION_FAILED}", res.elapsed_ms) - if res.status == 200: - return CheckResult(name, False, - "토큰 없이 200 이 나왔습니다. 인증 설정이 풀렸는지 확인이 필요합니다", res.elapsed_ms) - return CheckResult(name, False, _describe_unexpected(res), res.elapsed_ms) +def describe_token_rejection(res: Response) -> str: + # 서버 버전에 따라 같은 원인에도 코드가 다르게 나와서 가능한 원인을 같이 적는다. + # main(2026-07-01)은 서명이 틀려도 1005 를 주고, develop 은 1009 를 준다. + # 1007 은 블랙리스트 조회(Redis) 실패처럼 토큰과 무관한 예외에서도 나온다. + causes = { + INVALID_ACCESS_TOKEN: "SMOKE_ACCESS_TOKEN_SECRET 이 서버의 JWT 서명 키와 다를 수 있습니다", + ACCESS_TOKEN_EXPIRED: "서명 키가 다르거나(구버전 서버는 이때도 1005 를 줍니다) 러너와 서버의 시계가 어긋났을 수 있습니다", + AUTHENTICATION_FAILED: "서명 키가 다르거나 서버의 Redis(토큰 블랙리스트 조회)에 문제가 있을 수 있습니다", + } + code = res.error_code() + return f"토큰이 거절됐습니다 (errorCode {code}). {causes.get(code, res.snippet())}" -def check_authenticated(base_url: str, secret_b64: str, now: int, - sleep: Callable[[float], None]) -> CheckResult: - """스모크 토큰으로 호출해서 JWT 서명 키와 DB 조회가 맞는지 본다.""" - name = "인증과 DB 조회가 되는가 (토큰으로 200)" - try: - token = mint_access_token(secret_b64, now) - except (binascii.Error, ValueError): - return CheckResult(name, False, "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다") - if os.environ.get("GITHUB_ACTIONS") == "true": - print(f"::add-mask::{token}") - - res = http_get(base_url, {"Authorization": f"Bearer {token}"}, sleep) - body = res.json() - data = body.get("data") if isinstance(body, dict) else None - if res.status == 200 and isinstance(data, int) and not isinstance(data, bool): - return CheckResult(name, True, "HTTP 200, count 조회 응답", res.elapsed_ms) - if res.status == 401 and isinstance(body, dict): - # 서버 버전에 따라 같은 원인에도 코드가 다르게 나와서 가능한 원인을 같이 적는다. - # main(2026-07-01)은 서명이 틀려도 1005 를 주고, develop 은 1009 를 준다. - # 1007 은 블랙리스트 조회(Redis) 실패처럼 토큰과 무관한 예외에서도 나온다. - causes = { - INVALID_ACCESS_TOKEN: "SMOKE_ACCESS_TOKEN_SECRET 이 서버의 JWT 서명 키와 다를 수 있습니다", - ACCESS_TOKEN_EXPIRED: "서명 키가 다르거나(구버전 서버는 이때도 1005 를 줍니다) 러너와 서버의 시계가 어긋났을 수 있습니다", - AUTHENTICATION_FAILED: "서명 키가 다르거나 서버의 Redis(토큰 블랙리스트 조회)에 문제가 있을 수 있습니다", - } - code = body.get("errorCode") - if code in causes: - return CheckResult(name, False, f"토큰이 거절됐습니다 (errorCode {code}). {causes[code]}", res.elapsed_ms) - return CheckResult(name, False, _describe_unexpected(res), res.elapsed_ms) +# --- 판정 도우미 ----------------------------------------------------------------- +def is_count(value) -> bool: + return isinstance(value, int) and not isinstance(value, bool) -def run(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep, - clock: Callable[[], float] = time.time) -> int: + +def is_cursor_page(value) -> bool: + return isinstance(value, dict) and isinstance(value.get("content"), list) and "hasNext" in value + + +def has_keys(*keys: str) -> Callable[[object], bool]: + return lambda value: isinstance(value, dict) and all(k in value for k in keys) + + +def is_list(value) -> bool: + return isinstance(value, list) + + +def is_dict(value) -> bool: + return isinstance(value, dict) + + +# --- 확인 ----------------------------------------------------------------------- + +class SmokeRun: + def __init__(self, env: Mapping[str, str], sleep: Callable[[float], None], clock: Callable[[], float]): + self.env = env + self.sleep = sleep + self.clock = clock + self.results: list[CheckResult] = [] + self.budget = Budget() + self.base_url = env.get("SMOKE_BASE_URL", "").strip() + self.level = env.get("SMOKE_LEVEL", "").strip() or "read" + self.secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() + self.user_id = env.get("SMOKE_USER_ID", "").strip() + stamp = datetime.fromtimestamp(clock(), timezone.utc).strftime("%Y%m%dT%H%M%S") + self.run_tag = f"{RUN_PREFIX}{stamp}_{secrets.token_hex(2)}" + + # 결과 기록 + def ok(self, name: str, detail: str, res: Response | None = None) -> None: + self.results.append(CheckResult(name, True, detail, res.elapsed_ms if res else 0)) + + def fail(self, name: str, detail: str, res: Response | None = None) -> None: + self.results.append(CheckResult(name, False, detail, res.elapsed_ms if res else 0)) + + def skip(self, name: str, detail: str, passed: bool = True) -> None: + self.results.append(CheckResult(name, passed, detail, skipped=True)) + + def expect(self, name: str, res: Response, status: int, + validate: Callable[[object], bool] | None = None) -> bool: + if res.status == status and (validate is None or validate(res.data())): + self.ok(name, f"HTTP {status}", res) + return True + if res.status == status: + self.fail(name, f"응답 모양이 예상과 다릅니다 (HTTP {status}): {res.snippet()}", res) + else: + self.fail(name, describe_unexpected(res), res) + return False + + # 전체 흐름 + def validate_config(self) -> str | None: + if not self.base_url.startswith(("https://", "http://")): + return "SMOKE_BASE_URL 이 없거나 http(s):// 로 시작하지 않습니다" + if self.level not in LEVELS: + return f"SMOKE_LEVEL 은 {', '.join(LEVELS)} 중 하나여야 합니다: {self.level}" + host = urllib.parse.urlsplit(self.base_url).hostname or "" + if self.secret and self.base_url.startswith("http://") and host not in LOCAL_HOSTS: + return "토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다" + if self.user_id and not re.fullmatch(r"[0-9]+", self.user_id): + return "SMOKE_USER_ID 는 숫자여야 합니다" + if self.secret: + try: + base64.b64decode(self.secret, validate=True) + except (binascii.Error, ValueError): + return "SMOKE_ACCESS_TOKEN_SECRET 이 Base64 값이 아닙니다" + return None + + def execute(self) -> None: + if not self.check_reachable(Client(self.base_url, self.sleep, self.budget)): + self.skip("나머지 확인", "앱까지 닿지 않아서 요청을 더 보내지 않았습니다", passed=False) + return + if self.level == "reach": + return + if not self.secret: + self.skip("인증과 조회", "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다") + return + if not self.user_id: + self.check_probe_with_nonexistent_user() + self.skip("스모크 계정 확인", "SMOKE_USER_ID 가 없어 조회 세트와 쓰기 흐름을 건너뛰었습니다") + return + + token = "Bearer " + mint_access_token(self.secret, int(self.clock()), self.user_id) + _mask(token, self.env) + client = Client(self.base_url, self.sleep, self.budget, token=token, allow_writes=(self.level == "full")) + root = self.check_smoke_account(client) + if root is None: + return + self.check_reads(client, root) + if self.level != "full": + return + if any(not r.passed for r in self.results): + self.skip("쓰기 흐름", "조회가 실패해서 쓰기를 시작하지 않았습니다") + return + self.check_writes(client, root) + + def check_reachable(self, client: Client) -> bool: + name = "앱까지 닿는가 (토큰 없이 401)" + res = client.request("GET", PROBE_PATH, auth=False) + if res.status == 401 and res.error_code() == AUTHENTICATION_FAILED: + self.ok(name, f"HTTP 401, errorCode {AUTHENTICATION_FAILED}", res) + return True + if res.status == 200: + self.fail(name, "토큰 없이 200 이 나왔습니다. 인증 설정이 풀렸는지 확인이 필요합니다", res) + else: + self.fail(name, describe_unexpected(res), res) + return False + + def check_probe_with_nonexistent_user(self) -> None: + token = "Bearer " + mint_access_token(self.secret, int(self.clock())) + _mask(token, self.env) + client = Client(self.base_url, self.sleep, self.budget, token=token) + self.expect("인증과 DB 조회가 되는가 (없는 사용자 토큰으로 count)", client.request("GET", PROBE_PATH), 200, is_count) + + def check_smoke_account(self, client: Client) -> dict | None: + name = "스모크 계정 확인 (루트 폴더의 표식 폴더)" + res = client.request("GET", "/api/folders/root") + data = res.data() + if res.status != 200 or not has_keys("folderId", "subFolderList")(data): + # 계정이 스모크 계정인지 모르는 상태라, 200 응답 본문은 로그와 알림에 싣지 않는다(공개 레포). + detail = "루트 폴더 응답 모양이 예상과 다릅니다" if res.status == 200 else describe_unexpected(res) + self.fail(name, detail, res) + self.skip("나머지 확인", "스모크 계정을 확인하지 못해 요청을 더 보내지 않았습니다", passed=False) + return None + if "userId" in data and str(data["userId"]) != self.user_id: + self.fail(name, "루트 폴더의 주인이 SMOKE_USER_ID 와 다릅니다. 요청을 더 보내지 않았습니다", res) + self.skip("나머지 확인", "다른 계정의 데이터일 수 있어 멈췄습니다", passed=False) + return None + names = [f.get("folderName") for f in data["subFolderList"] if isinstance(f, dict)] + if MARKER_FOLDER_NAME not in names: + self.fail(name, f"루트 폴더에 {MARKER_FOLDER_NAME} 폴더가 없습니다. " + "SMOKE_USER_ID 가 스모크 계정이 아닐 수 있어 멈췄습니다", res) + self.skip("나머지 확인", "실사용자 계정일 수 있어 요청을 더 보내지 않았습니다", passed=False) + return None + self.ok(name, "HTTP 200, 표식 폴더 있음", res) + return data + + def check_reads(self, client: Client, root: dict) -> None: + root_id = root["folderId"] + now = datetime.fromtimestamp(self.clock(), KST) + reads = [ + ("폴더 목록", "/api/folders", None, is_list), + ("폴더 썸네일", "/api/folders/thumbnails/V2", None, is_cursor_page), + ("폴더 단건", f"/api/folders/{root_id}", None, has_keys("folderId", "folderName")), + ("하위 폴더", f"/api/folders/{root_id}/subfolders/V2", None, is_cursor_page), + ("오답노트 수", PROBE_PATH, None, is_count), + ("복습할 문제", "/api/problems/review-due", None, has_keys("dueCount", "overdueCount", "problems")), + ("내 오답노트", "/api/problems/user", None, is_list), + ("폴더 안 오답노트", f"/api/problems/folder/{root_id}/V2", None, is_cursor_page), + ("복습노트 썸네일", "/api/practiceNotes/thumbnail/V2", None, is_cursor_page), + ("복습노트 전체", "/api/practiceNotes/all", None, is_list), + ("풀이 기록 수", "/api/problem-solves/user/count", None, is_count), + ("내 스터디룸", "/api/study-room", None, is_list), + ("태그", "/api/tags", None, is_list), + ("학습 캘린더", "/api/learning-calendar", {"year": now.year, "month": now.month}, + has_keys("year", "month", "records")), + ("학습 리포트 요약", "/api/learning-reports/summary", None, is_dict), + ] + for label, path, query, validate in reads: + self.expect(f"조회: {label}", client.request("GET", path, query=query), 200, validate) + + # 쓰기 흐름 ------------------------------------------------------------------- + + def check_writes(self, client: Client, root: dict) -> None: + created_folders: list[int] = [] + created_notes: list[int] = [] + try: + self.clean_leftovers(client, root) + self.folder_flow(client, root["folderId"], created_folders) + self.practice_note_flow(client, created_notes) + except SmokeAbort as e: + self.fail("쓰기 흐름 중단", str(e)) + finally: + self.cleanup(client, created_folders, created_notes) + + def clean_leftovers(self, client: Client, root: dict) -> None: + """지난 실행이 중간에 끊겨 남긴 스모크 폴더와 복습노트를 먼저 지운다.""" + name = "지난 실행 흔적 정리" + folder_ids = [f["folderId"] for f in root["subFolderList"] + if isinstance(f, dict) and str(f.get("folderName", "")).startswith(RUN_PREFIX)] + res = client.request("GET", "/api/practiceNotes/thumbnail") + if res.status != 200 or not is_list(res.data()): + self.fail(name, describe_unexpected(res) if res.status != 200 else "복습노트 목록 모양이 예상과 다릅니다", res) + raise SmokeAbort("흔적 목록을 읽지 못해 쓰기 흐름을 시작하지 않았습니다") + note_ids = [n["practiceNoteId"] for n in res.data() + if isinstance(n, dict) and str(n.get("practiceTitle", "")).startswith(RUN_PREFIX)] + if not folder_ids and not note_ids: + self.ok(name, "남은 흔적 없음", res) + return + failed = [] + if folder_ids and self.delete_folders(client, folder_ids).status != 200: + failed.append(f"폴더 {folder_ids}") + if note_ids and self.delete_notes(client, note_ids).status != 200: + failed.append(f"복습노트 {note_ids}") + if failed: + self.fail(name, f"지우지 못했습니다: {', '.join(failed)}") + else: + self.ok(name, f"폴더 {len(folder_ids)}개, 복습노트 {len(note_ids)}개를 지웠습니다") + + def folder_flow(self, client: Client, root_id: int, created: list[int]) -> None: + folder_name = self.run_tag + res = client.request("POST", "/api/folders", {"folderName": folder_name, "parentFolderId": root_id}) + if not self.expect("쓰기: 폴더 만들기", res, 200, is_count): + raise SmokeAbort("폴더를 만들지 못해 쓰기 흐름을 멈췄습니다") + folder_id = res.data() + created.append(folder_id) + + def created_under_root(v) -> bool: + parent = v.get("parentFolder") if isinstance(v, dict) else None + return (isinstance(v, dict) and v.get("folderName") == folder_name + and isinstance(parent, dict) and parent.get("folderId") == root_id) + + self.expect("쓰기: 만든 폴더 조회", client.request("GET", f"/api/folders/{folder_id}"), 200, created_under_root) + + renamed = folder_name + "_r" + res = client.request("PATCH", "/api/folders", {"folderId": folder_id, "folderName": renamed}) + if self.expect("쓰기: 폴더 이름 바꾸기", res, 200): + self.expect("쓰기: 바뀐 이름 조회", client.request("GET", f"/api/folders/{folder_id}"), 200, + lambda v: isinstance(v, dict) and v.get("folderName") == renamed) + + if self.expect("쓰기: 폴더 지우기", self.delete_folders(client, [folder_id]), 200): + res = client.request("GET", f"/api/folders/{folder_id}") + if res.status == 404 and res.error_code() == FOLDER_NOT_FOUND: + self.ok("쓰기: 지운 폴더가 안 보이는가", f"HTTP 404, errorCode {FOLDER_NOT_FOUND}", res) + created.remove(folder_id) + else: + self.fail("쓰기: 지운 폴더가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + + def practice_note_flow(self, client: Client, created: list[int]) -> None: + title = self.run_tag + # practiceNotification 을 보내지 않는다. 보내면 Quartz 알림 작업이 등록되고, + # 운영 main 버전은 복습노트를 지워도 그 작업을 지우지 않는다. + res = client.request("POST", "/api/practiceNotes", {"practiceTitle": title, "problemIdList": []}) + if not self.expect("쓰기: 복습노트 만들기", res, 201, is_count): + raise SmokeAbort("복습노트를 만들지 못해 쓰기 흐름을 멈췄습니다") + note_id = res.data() + created.append(note_id) + + self.expect("쓰기: 만든 복습노트 조회", client.request("GET", f"/api/practiceNotes/{note_id}"), 200, + lambda v: isinstance(v, dict) and v.get("practiceTitle") == title + and v.get("practiceNotification") is None) + + if self.expect("쓰기: 복습노트 지우기", self.delete_notes(client, [note_id]), 200): + res = client.request("GET", f"/api/practiceNotes/{note_id}") + if res.status == 404 and res.error_code() == PRACTICE_NOTE_NOT_FOUND: + self.ok("쓰기: 지운 복습노트가 안 보이는가", f"HTTP 404, errorCode {PRACTICE_NOTE_NOT_FOUND}", res) + created.remove(note_id) + else: + self.fail("쓰기: 지운 복습노트가 안 보이는가", + f"삭제 후에도 조회됩니다 (HTTP {res.status}). 삭제가 반영되지 않았을 수 있습니다", res) + + @staticmethod + def delete_folders(client: Client, folder_ids: list[int]) -> Response: + return client.request("DELETE", "/api/folders", {"deleteFolderIdList": folder_ids}) + + @staticmethod + def delete_notes(client: Client, note_ids: list[int]) -> Response: + return client.request("DELETE", "/api/practiceNotes", {"deletePracticeIdList": note_ids}) + + def cleanup(self, client: Client, folders: list[int], notes: list[int]) -> None: + """흐름이 중간에 멈춰 남은 것을 지운다. 여기서도 못 지우면 다음 실행의 흔적 정리가 지운다.""" + if not folders and not notes: + return + leftovers = [] + try: + if folders and self.delete_folders(client, folders).status not in (200, 404): + leftovers.append(f"폴더 {folders}") + if notes and self.delete_notes(client, notes).status not in (200, 404): + leftovers.append(f"복습노트 {notes}") + except SmokeAbort as e: + leftovers.append(str(e)) + if leftovers: + self.fail("마무리 정리", f"지우지 못했습니다: {', '.join(leftovers)}. 다음 실행이 다시 지웁니다") + else: + self.ok("마무리 정리", "중간에 남은 것을 지웠습니다") + + +# --- bootstrap ------------------------------------------------------------------ + +def bootstrap(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep) -> int: + """스모크 게스트 계정을 만들고 표식 폴더를 단다. 가입할 때 Discord 알림이 한 번 간다.""" base_url = env.get("SMOKE_BASE_URL", "").strip() if not base_url.startswith(("https://", "http://")): print("SMOKE_BASE_URL 이 없거나 http(s):// 로 시작하지 않습니다", file=sys.stderr) return 2 - secret = env.get("SMOKE_ACCESS_TOKEN_SECRET", "").strip() - host = urllib.parse.urlsplit(base_url).hostname or "" - if secret and base_url.startswith("http://") and host not in LOCAL_HOSTS: - print("토큰을 평문으로 보내지 않도록 로컬 주소가 아니면 https:// 만 받습니다", file=sys.stderr) + if env.get("SMOKE_USER_ID", "").strip(): + print("SMOKE_USER_ID 가 이미 있습니다. 계정을 또 만들지 않도록 멈췄습니다", file=sys.stderr) return 2 - print(f"대상: {base_url}{SAFE_PATH} (GET 만 보냅니다)") - results = [check_reachable(base_url, sleep)] + client = Client(base_url, sleep, Budget(limit=10), allow_signup=True) + try: + res = client.request("POST", SIGNUP_PATH, auth=False) + data = res.data() + token = data.get("accessToken") if isinstance(data, dict) else None + if res.status != 200 or not token: + print(f"게스트 가입 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + _mask(token, env) + if isinstance(data, dict) and data.get("refreshToken"): + _mask(data["refreshToken"], env) + user_id = subject_of(token) + if not user_id: + print("발급된 토큰에서 계정 ID 를 읽지 못했습니다", file=sys.stderr) + return 1 + + client.token = token if token.startswith("Bearer ") else "Bearer " + token + client.allow_writes = True + res = client.request("GET", "/api/folders/root") + root = res.data() + if res.status != 200 or not has_keys("folderId", "subFolderList")(root): + print(f"루트 폴더 조회 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + res = client.request("POST", "/api/folders", {"folderName": MARKER_FOLDER_NAME, "parentFolderId": root["folderId"]}) + if res.status != 200: + print(f"표식 폴더 만들기 실패: {describe_unexpected(res)}", file=sys.stderr) + return 1 + except SmokeAbort as e: + print(str(e), file=sys.stderr) + return 1 + + print(f"스모크 계정을 만들었습니다. SMOKE_USER_ID = {user_id}") + summary = env.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as f: + f.write(f"## 스모크 계정 만들기\n\n- 대상: `{base_url}`\n- 계정 ID: `{user_id}`\n" + "- 이 값을 저장소 Secrets 의 `SMOKE_PROD_USER_ID` (dev 는 `SMOKE_DEV_USER_ID`) 에 넣으세요.\n") + return 0 + + +# --- 실행 ----------------------------------------------------------------------- + +def run(env: Mapping[str, str], sleep: Callable[[float], None] = time.sleep, + clock: Callable[[], float] = time.time) -> int: + smoke = SmokeRun(env, sleep, clock) + problem = smoke.validate_config() + if problem: + print(problem, file=sys.stderr) + return 2 - auth_name = "인증과 DB 조회가 되는가 (토큰으로 200)" - if not results[0].passed: - results.append(CheckResult(auth_name, False, "앞 확인이 실패해서 요청을 보내지 않았습니다", skipped=True)) - elif not secret: - results.append(CheckResult(auth_name, True, "SMOKE_ACCESS_TOKEN_SECRET 이 없어 건너뛰었습니다", skipped=True)) - else: - results.append(check_authenticated(base_url, secret, int(clock()), sleep)) + print(f"대상: {smoke.base_url} (단계: {smoke.level})") + try: + smoke.execute() + except SmokeAbort as e: + smoke.fail("실행 중단", str(e)) - failed = [r for r in results if not r.passed] - for r in results: + failed = [r for r in smoke.results if not r.passed] + for r in smoke.results: mark = "건너뜀" if r.skipped else ("통과" if r.passed else "실패") elapsed = f" ({r.elapsed_ms}ms)" if r.elapsed_ms else "" print(f"[{mark}] {r.name}{elapsed}: {r.detail}") - _write_step_summary(env, base_url, results) + _write_step_summary(env, smoke, failed) + _write_outputs(env, smoke, failed) + print(f"보낸 요청: {smoke.budget.sent}개 (상한 {smoke.budget.limit}개)") print("스모크 테스트 실패" if failed else "스모크 테스트 통과") return 1 if failed else 0 -def _write_step_summary(env: Mapping[str, str], base_url: str, results: list[CheckResult]) -> None: +def _write_step_summary(env: Mapping[str, str], smoke: SmokeRun, failed: list[CheckResult]) -> None: path = env.get("GITHUB_STEP_SUMMARY") if not path: return lines = [ "## 스모크 테스트", "", - f"- 대상: `{base_url}` (GET `{SAFE_PATH}` 만 보냅니다)", + f"- 대상: `{smoke.base_url}`, 단계: `{smoke.level}`, 보낸 요청: {smoke.budget.sent}개", + f"- 결과: {'❌ 실패 ' + str(len(failed)) + '건' if failed else '✅ 통과'}", "", "| 확인 | 결과 | 응답 시간 | 내용 |", "|---|---|---:|---|", ] - for r in results: - mark = "건너뜀" if r.skipped else ("✅ 통과" if r.passed else "❌ 실패") + for r in smoke.results: + mark = "건너뜀" if r.skipped else ("✅" if r.passed else "❌") elapsed = f"{r.elapsed_ms}ms" if r.elapsed_ms else "-" lines.append(f"| {r.name} | {mark} | {elapsed} | {r.detail.replace('|', '/')} |") with open(path, "a", encoding="utf-8") as f: f.write("\n".join(lines) + "\n") +def _write_outputs(env: Mapping[str, str], smoke: SmokeRun, failed: list[CheckResult]) -> None: + path = env.get("GITHUB_OUTPUT") + if not path: + return + first = f"{failed[0].name}: {failed[0].detail}" if failed else "" + with open(path, "a", encoding="utf-8") as f: + f.write(f"failed={len(failed)}\n") + f.write(f"first_failure={first.replace(chr(10), ' ')[:300]}\n") + + if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "bootstrap": + sys.exit(bootstrap(os.environ)) + if len(sys.argv) > 1: + print(f"알 수 없는 명령: {sys.argv[1]}", file=sys.stderr) + sys.exit(2) sys.exit(run(os.environ)) diff --git a/scripts/smoke/test_smoke_test.py b/scripts/smoke/test_smoke_test.py index f9fec5df..4a92b691 100644 --- a/scripts/smoke/test_smoke_test.py +++ b/scripts/smoke/test_smoke_test.py @@ -3,8 +3,8 @@ 운영 서버에 요청을 보내기 전에 스크립트가 틀리지 않았는지 확인하려고 둔다. 스모크 테스트 워크플로가 실제 요청보다 먼저 이 테스트를 돌린다. -로컬 가짜 서버를 띄워 두고, 그 서버가 받은 요청을 전부 기록해서 -"GET 만, 고정 경로로만, 정해진 횟수 안에서" 보내는지까지 확인한다. +OnO 서버가 스모크 테스트에 응답하는 방식을 흉내 내는 가짜 서버를 띄우고, 받은 요청을 전부 기록해서 +"허용한 요청만, 정해진 횟수 안에서, 스모크 흔적만 지우는지"까지 확인한다. 실행: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' -v """ @@ -14,6 +14,7 @@ import hmac import json import os +import re import sys import tempfile import threading @@ -27,6 +28,8 @@ SERVER_SECRET = base64.b64encode(b"s" * 64).decode() OTHER_SECRET = base64.b64encode(b"x" * 64).decode() NOW = 1_800_000_000 +SMOKE_USER = "42" +REAL_USER = "7" def _b64url_decode(part: str) -> bytes: @@ -43,19 +46,36 @@ def verify_token(token: str, secret_b64: str): return json.loads(_b64url_decode(payload)) -class FakeServer: - """시나리오 함수가 (status, headers, body) 를 돌려주는 가짜 서버.""" +def issued_token(user_id: str) -> str: + """서버가 게스트 가입 때 발급하는 토큰 모양. 앞에 Bearer 가 붙어 있다.""" + return "Bearer " + smoke_test.mint_access_token(SERVER_SECRET, NOW, user_id) - def __init__(self, scenario): + +class FakeOnO: + """스모크 테스트가 부르는 API 만 흉내 내는 OnO 서버. + + 사용자마다 루트 폴더와 하위 폴더, 복습노트를 들고 있고, 소유권과 에러 코드를 실제 서버처럼 돌려준다. + override(method, path_regex) 로 특정 요청의 응답을 바꿔 실패 상황을 만든다. + """ + + def __init__(self): self.requests = [] + self.overrides = [] + self.lock = threading.Lock() + self.next_id = 100 + self.users = {} + self.add_user(SMOKE_USER, marker=True) + self.add_user(REAL_USER, marker=False, extra_folders=["수학", "__smoke_run_looks_like_but_real_user"]) outer = self class Handler(BaseHTTPRequestHandler): def _handle(self): - outer.requests.append((self.command, self.path, dict(self.headers))) - status, headers, body = scenario(self.command, self.path, dict(self.headers)) + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"" + body = json.loads(raw) if raw else None + outer.requests.append((self.command, self.path, dict(self.headers), body)) + status, headers, payload = outer.dispatch(self.command, self.path, dict(self.headers), body) headers = dict(headers) - # send_response 가 Server 헤더를 먼저 붙이므로, 시나리오가 준 값으로 바꿔 끼운다. server = headers.pop("Server", None) if server: self.version_string = lambda: server @@ -64,9 +84,8 @@ def _handle(self): for k, v in headers.items(): self.send_header(k, v) self.end_headers() - self.wfile.write(body) + self.wfile.write(payload) except (BrokenPipeError, ConnectionResetError): - # 시간 초과 테스트에서 클라이언트가 먼저 끊은 경우다. pass do_GET = do_POST = do_PATCH = do_PUT = do_DELETE = _handle @@ -82,245 +101,489 @@ def close(self): self.httpd.shutdown() self.httpd.server_close() - -def json_body(obj): - return {"Content-Type": "application/json"}, json.dumps(obj).encode() - - -def healthy_server(command, path, headers): - """정상 운영 서버. 토큰이 없으면 1007, 서명이 틀리면 1009, 맞으면 count 를 준다.""" - auth = headers.get("Authorization") - if not auth: - h, b = json_body({"errorCode": 1007, "message": "인증이 실패했습니다."}) - return 401, h, b - claims = verify_token(auth.removeprefix("Bearer "), SERVER_SECRET) - if claims is None: - h, b = json_body({"errorCode": 1009, "message": "유효하지 않은 엑세스 토큰입니다."}) - return 401, h, b - h, b = json_body({"data": 0}) - return 200, h, b - - -class SmokeTestScriptTest(unittest.TestCase): + # 상태 + def new_id(self): + with self.lock: + self.next_id += 1 + return self.next_id + + def add_user(self, user_id, marker, extra_folders=()): + root = self.new_id() + folders = {root: {"folderId": root, "folderName": "책장", "parent": None, "deleted": False}} + names = ["공책"] + (["__smoke_account__"] if marker else []) + list(extra_folders) + for name in names: + fid = self.new_id() + folders[fid] = {"folderId": fid, "folderName": name, "parent": root, "deleted": False} + nid = self.new_id() + notes = {nid: {"practiceNoteId": nid, "practiceTitle": "복습 세트", "deleted": False}} + self.users[user_id] = {"root": root, "folders": folders, "notes": notes} + + def live_folder_names(self, user_id): + return sorted(f["folderName"] for f in self.users[user_id]["folders"].values() if not f["deleted"]) + + def live_note_titles(self, user_id): + return sorted(n["practiceTitle"] for n in self.users[user_id]["notes"].values() if not n["deleted"]) + + def override(self, method, path_regex, response, times=None): + self.overrides.append({"method": method, "path": path_regex, "response": response, "times": times}) + + def paths(self, method=None): + return [(m, p.split("?")[0]) for m, p, _, _ in self.requests if method is None or m == method] + + # 응답 + def dispatch(self, method, full_path, headers, body): + path = full_path.split("?")[0] + for o in self.overrides: + if o["method"] == method and re.fullmatch(o["path"], path) and o["times"] != 0: + if o["times"] is not None: + o["times"] -= 1 + resp = o["response"] + return resp(method, path, headers, body) if callable(resp) else resp + + if method == "POST" and path == "/api/auth/signup/guest": + uid = str(self.new_id()) + self.add_user(uid, marker=False) + return ok({"accessToken": issued_token(uid), "refreshToken": "refresh-" + uid}) + + auth = headers.get("Authorization") + if not auth: + return error(401, 1007) + claims = verify_token(auth.removeprefix("Bearer "), SERVER_SECRET) + if claims is None: + return error(401, 1009) + uid = claims["sub"] + if uid not in self.users: + if method == "GET" and path == "/api/problems/problemCount": + return ok(0) + return error(404, 5001) + user = self.users[uid] + folders, notes = user["folders"], user["notes"] + + def folder_view(f): + parent = folders.get(f["parent"]) + subs = [{"folderId": s["folderId"], "folderName": s["folderName"]} + for s in folders.values() if s["parent"] == f["folderId"] and not s["deleted"]] + return {"folderId": f["folderId"], "folderName": f["folderName"], "subFolderList": subs, "userId": int(uid), + "parentFolder": {"folderId": parent["folderId"], "folderName": parent["folderName"]} if parent else None} + + page = {"content": [], "hasNext": False, "nextCursor": None, "size": 20} + m = re.fullmatch(r"/api/folders/(\d+)", path) + if method == "GET": + if path == "/api/folders/root": + return ok(folder_view(folders[user["root"]])) + if m: + f = folders.get(int(m.group(1))) + return ok(folder_view(f)) if f and not f["deleted"] else error(404, 5001) + n = re.fullmatch(r"/api/practiceNotes/(\d+)", path) + if n: + note = notes.get(int(n.group(1))) + if not note or note["deleted"]: + return error(404, 6001) + return ok({"practiceNoteId": note["practiceNoteId"], "practiceTitle": note["practiceTitle"], + "practiceNotification": None}) + simple = { + "/api/folders": [folder_view(f) for f in folders.values() if not f["deleted"]], + "/api/folders/thumbnails/V2": page, + "/api/problems/problemCount": 0, + "/api/problems/review-due": {"dueCount": 0, "overdueCount": 0, "problems": []}, + "/api/problems/user": [], + "/api/practiceNotes/thumbnail": [{"practiceNoteId": x["practiceNoteId"], "practiceTitle": x["practiceTitle"]} + for x in notes.values() if not x["deleted"]], + "/api/practiceNotes/thumbnail/V2": page, + "/api/practiceNotes/all": [], + "/api/problem-solves/user/count": 0, + "/api/study-room": [], + "/api/tags": [], + "/api/learning-calendar": {"year": 2027, "month": 1, "records": []}, + "/api/learning-reports/summary": {"monthLabel": "1월"}, + } + if path in simple: + return ok(simple[path]) + if re.fullmatch(r"/api/folders/\d+/subfolders/V2|/api/problems/folder/\d+/V2", path): + return ok(page) + return error(404, 9999) + + if method == "POST" and path == "/api/folders": + parent = folders.get(body.get("parentFolderId")) + if not parent or parent["deleted"]: + return error(404, 5001) + fid = self.new_id() + folders[fid] = {"folderId": fid, "folderName": body["folderName"], "parent": parent["folderId"], "deleted": False} + return ok(fid) + if method == "PATCH" and path == "/api/folders": + f = folders.get(body.get("folderId")) + if not f or f["deleted"]: + return error(404, 5001) + f["folderName"] = body["folderName"] + return ok("폴더가 성공적으로 수정되었습니다.") + if method == "DELETE" and path == "/api/folders": + ids = body.get("deleteFolderIdList", []) + if any(i not in folders or folders[i]["deleted"] for i in ids): + return error(404, 5001) + for i in ids: + folders[i]["deleted"] = True + return ok("폴더가 성공적으로 삭제되었습니다.") + if method == "POST" and path == "/api/practiceNotes": + nid = self.new_id() + notes[nid] = {"practiceNoteId": nid, "practiceTitle": body["practiceTitle"], "deleted": False} + return ok(nid, status=201) + if method == "DELETE" and path == "/api/practiceNotes": + ids = body.get("deletePracticeIdList", []) + if any(i not in notes or notes[i]["deleted"] for i in ids): + return error(404, 6001) + for i in ids: + notes[i]["deleted"] = True + return ok("선택한 복습 노트가 삭제되었습니다.") + return error(404, 9999) + + +def ok(data, status=200): + return status, {"Content-Type": "application/json"}, json.dumps({"data": data}).encode() + + +def error(status, code): + return status, {"Content-Type": "application/json"}, json.dumps({"errorCode": code, "message": "error"}).encode() + + +class SmokeTestBase(unittest.TestCase): def setUp(self): self.sleeps = [] - self.server = None + self.server = FakeOnO() def tearDown(self): - if self.server: - self.server.close() - - def start(self, scenario): - self.server = FakeServer(scenario) - return self.server - - def run_script(self, secret=None, extra_env=None): - env = {"SMOKE_BASE_URL": self.server.url} - if secret is not None: - env["SMOKE_ACCESS_TOKEN_SECRET"] = secret - env.update(extra_env or {}) - return smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW) - - def assert_only_safe_gets(self): - self.assertTrue(self.server.requests) - for method, path, _ in self.server.requests: - self.assertEqual("GET", method) - self.assertEqual(smoke_test.SAFE_PATH, path) - self.assertLessEqual(len(self.server.requests), 2 * smoke_test.MAX_ATTEMPTS) + self.server.close() - # --- 정상 흐름 --- + def env(self, **overrides): + env = {"SMOKE_BASE_URL": self.server.url, "SMOKE_LEVEL": "read"} + env.update({k: v for k, v in overrides.items() if v is not None}) + return env - def test_passes_against_healthy_server(self): - self.start(healthy_server) - self.assertEqual(0, self.run_script(secret=SERVER_SECRET)) - self.assertEqual(2, len(self.server.requests)) - self.assert_only_safe_gets() + def run_smoke(self, level="read", secret=SERVER_SECRET, user_id=SMOKE_USER, **extra): + env = self.env(SMOKE_LEVEL=level, SMOKE_ACCESS_TOKEN_SECRET=secret, SMOKE_USER_ID=user_id, **extra) + return smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW) - def test_skips_auth_check_without_secret(self): - self.start(healthy_server) - self.assertEqual(0, self.run_script()) - self.assertEqual(1, len(self.server.requests)) - self.assertNotIn("Authorization", self.server.requests[0][2]) + def assert_only_allowed_requests(self): + for method, path in self.server.paths(): + self.assertTrue(any(m == method and re.fullmatch(p, path) for m, p in smoke_test.ALLOWED_REQUESTS), + f"허용 목록 밖 요청: {method} {path}") + self.assertNotEqual("/api/users", path) + self.assertLessEqual(len(self.server.requests), smoke_test.MAX_REQUESTS) - def test_token_targets_nonexistent_user_with_short_ttl(self): - self.start(healthy_server) - self.run_script(secret=SERVER_SECRET) - token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") - claims = verify_token(token, SERVER_SECRET) - self.assertEqual("0", claims["sub"]) - self.assertEqual("ROLE_GUEST", claims["authority"]) - self.assertEqual(NOW, claims["iat"]) - self.assertEqual(smoke_test.TOKEN_TTL_SECONDS, claims["exp"] - claims["iat"]) - def test_writes_step_summary(self): - self.start(healthy_server) - with tempfile.NamedTemporaryFile("r", suffix=".md", delete=False) as f: - path = f.name - try: - self.run_script(secret=SERVER_SECRET, extra_env={"GITHUB_STEP_SUMMARY": path}) - with open(path, encoding="utf-8") as f: - summary = f.read() - self.assertIn("## 스모크 테스트", summary) - self.assertEqual(2, summary.count("✅ 통과")) - finally: - os.remove(path) +class ReachTest(SmokeTestBase): - # --- 실패를 실패로 잡는가 --- + def test_reach_level_sends_one_request_without_token(self): + self.assertEqual(0, self.run_smoke(level="reach")) + self.assertEqual([("GET", smoke_test.PROBE_PATH)], self.server.paths()) + self.assertNotIn("Authorization", self.server.requests[0][2]) - def test_fails_when_signing_key_differs(self): - self.start(healthy_server) - self.assertEqual(1, self.run_script(secret=OTHER_SECRET)) - self.assertEqual([], self.sleeps, "4xx 는 재시도하지 않는다") + def test_without_secret_only_reach_runs(self): + self.assertEqual(0, self.run_smoke(secret=None, user_id=None)) + self.assertEqual(1, len(self.server.requests)) def test_fails_when_endpoint_is_open_without_token(self): - def open_server(command, path, headers): - h, b = json_body({"data": 0}) - return 200, h, b - self.start(open_server) - self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) - self.assertEqual(1, len(self.server.requests), "첫 확인이 실패하면 토큰 요청을 보내지 않는다") + self.server.override("GET", smoke_test.PROBE_PATH, ok(0)) + self.assertEqual(1, self.run_smoke()) + self.assertEqual(1, len(self.server.requests), "첫 확인이 실패하면 요청을 더 보내지 않는다") def test_retries_bad_gateway_then_fails(self): - def bad_gateway(command, path, headers): - return 502, {"Content-Type": "text/html"}, b"502 Bad Gateway" - self.start(bad_gateway) - self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) + self.server.override("GET", smoke_test.PROBE_PATH, (502, {"Content-Type": "text/html"}, b"502")) + self.assertEqual(1, self.run_smoke()) self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) self.assertEqual([smoke_test.RETRY_DELAY_SECONDS] * (smoke_test.MAX_ATTEMPTS - 1), self.sleeps) - self.assert_only_safe_gets() def test_recovers_when_first_attempt_fails(self): - calls = {"n": 0} - - def flaky(command, path, headers): - calls["n"] += 1 - if calls["n"] == 1: - return 503, {}, b"" - return healthy_server(command, path, headers) - self.start(flaky) - self.assertEqual(0, self.run_script(secret=SERVER_SECRET)) - self.assertEqual(3, len(self.server.requests)) - - def test_fails_on_html_page(self): - def maintenance(command, path, headers): - return 200, {"Content-Type": "text/html"}, b"maintenance" - self.start(maintenance) - self.assertEqual(1, self.run_script()) - - def test_fails_on_maintenance_json_without_error_code(self): - def maintenance(command, path, headers): - h, b = json_body({"errorCode": None, "message": "점검 중입니다"}) - return 400, h, b - self.start(maintenance) - self.assertEqual(1, self.run_script()) - self.assertEqual([], self.sleeps) - - def test_reports_cloudflare_block_separately(self): - def cloudflare(command, path, headers): - return 403, {"Server": "cloudflare", "cf-mitigated": "challenge"}, b"Just a moment..." - self.start(cloudflare) - self.assertEqual(1, self.run_script()) - res = smoke_test.check_reachable(self.server.url, sleep=lambda s: None) - self.assertIn("Cloudflare", res.detail) + self.server.override("GET", smoke_test.PROBE_PATH, (503, {}, b""), times=1) + self.assertEqual(0, self.run_smoke(level="reach")) + self.assertEqual(2, len(self.server.requests)) def test_reports_origin_unreachable_behind_cloudflare(self): # 2026-09-15 dev 도메인에서 실제로 받은 응답 모양이다 (server: cloudflare, 본문 "error code: 502"). - def origin_down(command, path, headers): - return 502, {"Server": "cloudflare", "Content-Type": "text/plain; charset=UTF-8"}, b"error code: 502" - self.start(origin_down) - res = smoke_test.check_reachable(self.server.url, sleep=self.sleeps.append) - self.assertFalse(res.passed) - self.assertIn("원 서버에 닿지 못했습니다", res.detail) - self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) + self.server.override("GET", smoke_test.PROBE_PATH, + (502, {"Server": "cloudflare", "Content-Type": "text/plain"}, b"error code: 502")) + smoke = smoke_test.SmokeRun(self.env(), self.sleeps.append, lambda: NOW) + self.assertFalse(smoke.check_reachable(smoke_test.Client(self.server.url, self.sleeps.append, smoke.budget))) + self.assertIn("원 서버에 닿지 못했습니다", smoke.results[0].detail) - def test_fails_when_server_is_unreachable(self): - self.start(healthy_server) - url = self.server.url - self.server.close() - self.server = None - env = {"SMOKE_BASE_URL": url} - self.assertEqual(1, smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW)) - self.assertEqual(smoke_test.MAX_ATTEMPTS - 1, len(self.sleeps)) - - def test_does_not_follow_redirect_and_does_not_leak_token(self): - # 리다이렉트를 따라가면 urllib 이 Authorization 헤더까지 옮긴다. 따라가지 않아야 한다. - def redirecting(command, path, headers): - if path == smoke_test.SAFE_PATH and not headers.get("Authorization"): - return healthy_server(command, path, headers) - if path == smoke_test.SAFE_PATH: - return 302, {"Location": "/leak"}, b"" - return 200, {}, b"leaked" - self.start(redirecting) - self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) - self.assertEqual([smoke_test.SAFE_PATH, smoke_test.SAFE_PATH], [p for _, p, _ in self.server.requests]) + def test_reports_cloudflare_block_separately(self): + self.server.override("GET", smoke_test.PROBE_PATH, + (403, {"Server": "cloudflare", "cf-mitigated": "challenge"}, b"Just a moment")) + self.assertEqual(1, self.run_smoke()) + + def test_fails_on_maintenance_page(self): + self.server.override("GET", smoke_test.PROBE_PATH, (200, {"Content-Type": "text/html"}, b"maintenance")) + self.assertEqual(1, self.run_smoke()) + + def test_does_not_follow_redirect(self): + self.server.override("GET", smoke_test.PROBE_PATH, (302, {"Location": "/api/users"}, b"")) + self.assertEqual(1, self.run_smoke()) + self.assertEqual([("GET", smoke_test.PROBE_PATH)], self.server.paths()) self.assertEqual([], self.sleeps, "3xx 는 재시도하지 않는다") - def test_redirect_on_first_check_fails(self): - def maintenance_redirect(command, path, headers): - return 301, {"Location": "https://example.com/maintenance"}, b"" - self.start(maintenance_redirect) - res = smoke_test.check_reachable(self.server.url, sleep=self.sleeps.append) - self.assertFalse(res.passed) - self.assertIn("리다이렉트", res.detail) - self.assertEqual(1, len(self.server.requests)) - - def test_old_server_reports_wrong_key_as_expired(self): - # 운영 main(2026-07-01)은 서명이 틀려도 1005 를 준다. 실패로 잡고 서명 키 가능성도 안내해야 한다. - def old_server(command, path, headers): - if not headers.get("Authorization"): - return healthy_server(command, path, headers) - h, b = json_body({"errorCode": 1005, "message": "엑세스 토큰이 만료되었습니다."}) - return 401, h, b - self.start(old_server) - res = smoke_test.check_authenticated(self.server.url, OTHER_SECRET, NOW, sleep=self.sleeps.append) - self.assertFalse(res.passed) - self.assertIn("서명 키", res.detail) - - def test_boolean_data_is_not_a_count(self): - def wrong_shape(command, path, headers): - if not headers.get("Authorization"): - return healthy_server(command, path, headers) - h, b = json_body({"data": True}) - return 200, h, b - self.start(wrong_shape) - self.assertEqual(1, self.run_script(secret=SERVER_SECRET)) - def test_retries_rate_limited_response(self): - def rate_limited(command, path, headers): - return 429, {}, b"" - self.start(rate_limited) - self.assertEqual(1, self.run_script()) + self.server.override("GET", smoke_test.PROBE_PATH, (429, {}, b"")) + self.assertEqual(1, self.run_smoke()) self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) def test_times_out_on_slow_server(self): release = threading.Event() - def slow(command, path, headers): + def slow(method, path, headers, body): release.wait(5) - return healthy_server(command, path, headers) - self.start(slow) + return error(401, 1007) + self.server.override("GET", smoke_test.PROBE_PATH, slow) original = smoke_test.REQUEST_TIMEOUT_SECONDS smoke_test.REQUEST_TIMEOUT_SECONDS = 0.3 try: - self.assertEqual(1, self.run_script()) + self.assertEqual(1, self.run_smoke()) finally: smoke_test.REQUEST_TIMEOUT_SECONDS = original release.set() - self.assertEqual(smoke_test.MAX_ATTEMPTS, len(self.server.requests)) - # --- 설정 오류 --- + def test_fails_when_server_is_unreachable(self): + url = self.server.url + self.server.close() + self.server = FakeOnO() + env = {"SMOKE_BASE_URL": url} + self.assertEqual(1, smoke_test.run(env, sleep=self.sleeps.append, clock=lambda: NOW)) + + +class AuthTest(SmokeTestBase): + + def test_without_user_id_checks_count_with_nonexistent_user(self): + self.assertEqual(0, self.run_smoke(user_id=None)) + self.assertEqual([("GET", smoke_test.PROBE_PATH)] * 2, self.server.paths()) + token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") + self.assertEqual("0", verify_token(token, SERVER_SECRET)["sub"]) + + def test_token_for_smoke_user_is_guest_with_short_ttl(self): + self.run_smoke() + token = self.server.requests[1][2]["Authorization"].removeprefix("Bearer ") + claims = verify_token(token, SERVER_SECRET) + self.assertEqual(SMOKE_USER, claims["sub"]) + self.assertEqual("ROLE_GUEST", claims["authority"]) + self.assertEqual(smoke_test.TOKEN_TTL_SECONDS, claims["exp"] - claims["iat"]) + + def test_fails_when_signing_key_differs(self): + self.assertEqual(1, self.run_smoke(secret=OTHER_SECRET)) + self.assertEqual(2, len(self.server.requests), "토큰이 거절되면 조회를 더 보내지 않는다") + + def test_old_server_reports_wrong_key_as_expired(self): + # 운영 main(2026-07-01)은 서명이 틀려도 1005 를 준다. 실패로 잡고 서명 키 가능성도 안내해야 한다. + self.server.override("GET", "/api/folders/root", error(401, 1005)) + smoke = smoke_test.SmokeRun(self.env(SMOKE_ACCESS_TOKEN_SECRET=SERVER_SECRET, SMOKE_USER_ID=SMOKE_USER), + self.sleeps.append, lambda: NOW) + smoke.execute() + failed = [r for r in smoke.results if not r.passed and not r.skipped] + self.assertIn("서명 키", failed[0].detail) + + def test_boolean_data_is_not_a_count(self): + self.server.override("GET", smoke_test.PROBE_PATH, lambda m, p, h, b: ok(True) if h.get("Authorization") else error(401, 1007)) + self.assertEqual(1, self.run_smoke(user_id=None)) + + +class ReadTest(SmokeTestBase): + + def test_read_level_passes_and_sends_only_gets(self): + self.assertEqual(0, self.run_smoke(level="read")) + self.assertEqual({"GET"}, {m for m, _ in self.server.paths()}) + self.assert_only_allowed_requests() + self.assertEqual(17, len(self.server.requests)) + + def test_stops_when_account_has_no_marker_folder(self): + # SMOKE_USER_ID 를 실사용자 ID 로 잘못 넣은 경우. 루트 폴더 한 번만 읽고 멈춘다. + self.assertEqual(1, self.run_smoke(level="full", user_id=REAL_USER)) + self.assertEqual([("GET", smoke_test.PROBE_PATH), ("GET", "/api/folders/root")], self.server.paths()) + self.assertEqual(self.server.live_folder_names(REAL_USER), + sorted(["책장", "공책", "수학", "__smoke_run_looks_like_but_real_user"])) + + def test_stops_when_root_folder_belongs_to_another_user(self): + real_root = next(iter(self.server.users[REAL_USER]["folders"].values())) + self.server.override("GET", "/api/folders/root", ok({"folderId": real_root["folderId"], "userId": int(REAL_USER), + "subFolderList": [{"folderId": 1, "folderName": "__smoke_account__"}]})) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, len(self.server.requests)) + + def test_does_not_leak_root_body_when_shape_is_unexpected(self): + self.server.override("GET", "/api/folders/root", ok({"secretNote": "실사용자 메모"})) + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.assertEqual(1, self.run_smoke(level="full", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output)) + for path in (summary, output): + with open(path, encoding="utf-8") as f: + self.assertNotIn("실사용자 메모", f.read()) + + def test_deadline_stops_the_run(self): + original = smoke_test.DEADLINE_SECONDS + smoke_test.DEADLINE_SECONDS = 0 + try: + self.assertEqual(1, self.run_smoke(level="read")) + finally: + smoke_test.DEADLINE_SECONDS = original + self.assertEqual([], self.server.requests) + + def test_one_failing_read_does_not_stop_other_reads(self): + self.server.override("GET", "/api/tags", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="read")) + self.assertIn(("GET", "/api/learning-reports/summary"), self.server.paths()) + + def test_read_shape_mismatch_fails(self): + self.server.override("GET", "/api/folders/thumbnails/V2", ok([])) + self.assertEqual(1, self.run_smoke(level="read")) + + def test_writes_summary_and_outputs(self): + with tempfile.TemporaryDirectory() as d: + summary, output = os.path.join(d, "summary.md"), os.path.join(d, "output.txt") + self.server.override("GET", "/api/tags", ok("not a list")) + self.run_smoke(level="read", GITHUB_STEP_SUMMARY=summary, GITHUB_OUTPUT=output) + with open(summary, encoding="utf-8") as f: + self.assertIn("## 스모크 테스트", f.read()) + with open(output, encoding="utf-8") as f: + text = f.read() + self.assertIn("failed=1", text) + self.assertIn("first_failure=조회: 태그", text) + + +class WriteTest(SmokeTestBase): + + def test_full_level_creates_and_removes_only_its_own_data(self): + before_folders = self.server.live_folder_names(SMOKE_USER) + before_notes = self.server.live_note_titles(SMOKE_USER) + real_before = self.server.live_folder_names(REAL_USER) + + self.assertEqual(0, self.run_smoke(level="full")) + + self.assertEqual(before_folders, self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(before_notes, self.server.live_note_titles(SMOKE_USER)) + self.assertEqual(real_before, self.server.live_folder_names(REAL_USER)) + self.assert_only_allowed_requests() + for method, path, headers, body in self.server.requests: + if method == "POST" and path == "/api/practiceNotes": + self.assertNotIn("practiceNotification", body, "알림을 붙이면 Quartz 작업이 남는다") + if method in ("POST", "PATCH", "DELETE"): + self.assertEqual(SMOKE_USER, verify_token(headers["Authorization"].removeprefix("Bearer "), SERVER_SECRET)["sub"]) + + def test_failed_read_blocks_writes(self): + self.server.override("GET", "/api/problems/review-due", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual([], self.server.paths("POST") + self.server.paths("PATCH") + self.server.paths("DELETE")) + + def test_server_refusing_delete_keeps_marker_and_reports(self): + self.server.override("DELETE", "/api/folders", error(400, 5002)) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertIn("__smoke_account__", self.server.live_folder_names(SMOKE_USER)) + + def test_read_level_never_writes(self): + self.run_smoke(level="read") + self.assertEqual([], self.server.paths("POST") + self.server.paths("PATCH") + self.server.paths("DELETE")) + + def test_cleans_leftovers_but_never_marker_or_other_folders(self): + user = self.server.users[SMOKE_USER] + leftover = self.server.new_id() + user["folders"][leftover] = {"folderId": leftover, "folderName": "__smoke_run_old", "parent": user["root"], "deleted": False} + note = self.server.new_id() + user["notes"][note] = {"practiceNoteId": note, "practiceTitle": "__smoke_run_old", "deleted": False} + + self.assertEqual(0, self.run_smoke(level="full")) + + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(["복습 세트"], self.server.live_note_titles(SMOKE_USER)) + deleted_ids = [i for m, p, _, b in self.server.requests if m == "DELETE" for i in + (b.get("deleteFolderIdList") or b.get("deletePracticeIdList") or [])] + marker_id = next(f["folderId"] for f in user["folders"].values() if f["folderName"] == "__smoke_account__") + self.assertNotIn(marker_id, deleted_ids) + self.assertNotIn(user["root"], deleted_ids) + + def test_failure_mid_flow_still_cleans_up(self): + self.server.override("PATCH", "/api/folders", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.server.override("POST", "/api/practiceNotes", (500, {"Content-Type": "application/json"}, b'{"errorCode":9000}')) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + self.assertEqual(1, len(self.server.paths("PATCH")), "쓰기는 5xx 여도 재시도하지 않는다") + self.assertEqual(1, self.server.paths("POST").count(("POST", "/api/practiceNotes"))) + + def test_delete_not_reflected_is_reported_and_retried_in_cleanup(self): + def fake_delete(method, path, headers, body): + return ok("폴더가 성공적으로 삭제되었습니다.") + self.server.override("DELETE", "/api/folders", fake_delete, times=1) + self.assertEqual(1, self.run_smoke(level="full")) + self.assertEqual(2, self.server.paths("DELETE").count(("DELETE", "/api/folders")), "흐름의 삭제 한 번, 마무리 정리 한 번") + self.assertEqual(["__smoke_account__", "공책", "책장"], self.server.live_folder_names(SMOKE_USER)) + + def test_request_budget_stops_the_run(self): + original = smoke_test.MAX_REQUESTS + smoke_test.MAX_REQUESTS = 10 + try: + self.assertEqual(1, self.run_smoke(level="full")) + finally: + smoke_test.MAX_REQUESTS = original + self.assertLessEqual(len(self.server.requests), 10) + + +class ClientGuardTest(SmokeTestBase): + + def client(self, **kwargs): + return smoke_test.Client(self.server.url, self.sleeps.append, smoke_test.Budget(), **kwargs) + + def test_blocks_requests_outside_allowlist_before_sending(self): + client = self.client(token="Bearer x", allow_writes=True) + for method, path in [("GET", "/api/users"), ("PATCH", "/api/users"), ("DELETE", "/api/users"), + ("POST", "/api/auth/logout"), ("POST", "/api/problems/v2"), ("GET", "/actuator/health")]: + with self.assertRaises(smoke_test.SmokeAbort): + client.request(method, path) + self.assertEqual([], self.server.requests) + + def test_blocks_writes_unless_full_level(self): + with self.assertRaises(smoke_test.SmokeAbort): + self.client(token="Bearer x").request("POST", "/api/folders", {"folderName": "x"}) + self.assertEqual([], self.server.requests) + + def test_blocks_signup_outside_bootstrap(self): + with self.assertRaises(smoke_test.SmokeAbort): + self.client(allow_writes=True).request("POST", smoke_test.SIGNUP_PATH) + self.assertEqual([], self.server.requests) def test_refuses_to_send_token_over_plain_http(self): env = {"SMOKE_BASE_URL": "http://ono-dev.seungminki.shop", "SMOKE_ACCESS_TOKEN_SECRET": SERVER_SECRET} self.assertEqual(2, smoke_test.run(env, sleep=self.sleeps.append)) - def test_config_error_without_base_url(self): + def test_config_errors(self): self.assertEqual(2, smoke_test.run({}, sleep=self.sleeps.append)) - - def test_fails_on_non_base64_secret(self): - self.start(healthy_server) - self.assertEqual(1, self.run_script(secret="not base64 !!")) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_LEVEL="everything"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_USER_ID="abc"), sleep=self.sleeps.append)) + self.assertEqual(2, smoke_test.run(self.env(SMOKE_ACCESS_TOKEN_SECRET="not base64 !!"), sleep=self.sleeps.append)) + self.assertEqual([], self.server.requests) + + +class BootstrapTest(SmokeTestBase): + + def test_creates_guest_with_marker_folder_and_prints_id(self): + with tempfile.TemporaryDirectory() as d: + summary = os.path.join(d, "summary.md") + self.assertEqual(0, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url, "GITHUB_STEP_SUMMARY": summary}, + sleep=self.sleeps.append)) + with open(summary, encoding="utf-8") as f: + text = f.read() + new_ids = [u for u in self.server.users if u not in (SMOKE_USER, REAL_USER)] + self.assertEqual(1, len(new_ids)) + self.assertIn("__smoke_account__", self.server.live_folder_names(new_ids[0])) + self.assertIn(new_ids[0], text) + self.assertEqual(["POST", "GET", "POST"], [m for m, _ in self.server.paths()]) + + def test_new_account_then_passes_full_level(self): + smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url}, sleep=self.sleeps.append) + new_id = next(u for u in self.server.users if u not in (SMOKE_USER, REAL_USER)) + self.assertEqual(0, self.run_smoke(level="full", user_id=new_id)) + + def test_refuses_when_account_already_configured(self): + self.assertEqual(2, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url, "SMOKE_USER_ID": SMOKE_USER}, + sleep=self.sleeps.append)) + self.assertEqual([], self.server.requests) + + def test_signup_is_not_retried(self): + self.server.override("POST", smoke_test.SIGNUP_PATH, (502, {}, b"")) + self.assertEqual(1, smoke_test.bootstrap({"SMOKE_BASE_URL": self.server.url}, sleep=self.sleeps.append)) self.assertEqual(1, len(self.server.requests)) From 489b928e91107901e795c46078bba98ba13d8ab0 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 15 Sep 2026 18:28:24 +0900 Subject: [PATCH 099/130] =?UTF-8?q?[Chore]=20=EB=B0=B0=ED=8F=AC=EA=B0=80?= =?UTF-8?q?=20=EB=81=9D=EB=82=9C=20=EB=92=A4=EC=99=80=20=EB=A7=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=8A=A4=EB=AA=A8=ED=81=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=8F=8C=EB=A6=AC=EA=B3=A0=20Discord=20?= =?UTF-8?q?=EB=A1=9C=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20-=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=204=EA=B0=9C?= =?UTF-8?q?=EA=B0=80=20=EB=81=9D=EB=82=98=EB=A9=B4=20workflow=5Frun=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=9B=EC=95=84=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20full,=20=EC=8B=A4=ED=8C=A8=EB=A9=B4=20read?= =?UTF-8?q?=20=EB=A5=BC=20=EB=8F=8C=EB=A6=B0=EB=8B=A4.=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=9D=80=20=EC=88=98=EC=A0=95=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=98=EB=8B=A4=20-=20=EB=A7=A4=EC=8B=9C=2017?= =?UTF-8?q?=EB=B6=84=EC=97=90=20prod=20=EB=A1=9C=20read=20=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=20=EB=B0=B0=ED=8F=AC=EC=99=80=20=EC=83=81?= =?UTF-8?q?=EA=B4=80=EC=97=86=EC=9D=B4=20=EB=A7=A5=EB=AF=B8=EB=8B=88?= =?UTF-8?q?=EB=82=98=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=9E=A5?= =?UTF-8?q?=EC=95=A0=EB=A5=BC=20=EC=9E=A1=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=EC=8B=A4=ED=96=89=EC=9D=80=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EA=B3=BC=20=EB=8B=A8=EA=B3=84(reach,=20read,=20full,?= =?UTF-8?q?=20bootstrap)=EB=A5=BC=20=EA=B3=A0=EB=A5=B4=EA=B3=A0,=20prod=20?= =?UTF-8?q?=EB=8A=94=20main=20=EB=B8=8C=EB=9E=9C=EC=B9=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=A7=8C=20=EC=8B=A4=ED=96=89=EB=90=9C=EB=8B=A4=20-?= =?UTF-8?q?=20=EC=93=B0=EA=B8=B0=EA=B0=80=20=EC=9E=88=EB=8A=94=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=EB=A7=8C=20=EB=8C=80=EC=83=81=EB=B3=84=20concurrency?= =?UTF-8?q?=20=EA=B7=B8=EB=A3=B9=EC=9C=BC=EB=A1=9C=20=EC=84=B8=EC=9A=B0?= =?UTF-8?q?=EA=B3=A0,=20=EC=A1=B0=ED=9A=8C=EB=A7=8C=20=ED=95=98=EB=8A=94?= =?UTF-8?q?=20=EC=8B=A4=ED=96=89=EC=9D=80=20=EC=8B=A4=ED=96=89=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B7=B8=EB=A3=B9=EC=9D=84=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=91=AC=20=EB=8C=80=EA=B8=B0=20=EC=8B=A4=ED=96=89=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=80=EB=A0=A4=20=EC=B7=A8=EC=86=8C=EB=90=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=96=88=EB=8B=A4=20-=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EB=92=A4=20=EA=B2=B0=EA=B3=BC=EB=8A=94=20=ED=95=AD?= =?UTF-8?q?=EC=83=81,=20=EB=A7=A4=EC=8B=9C=20=EA=B2=B0=EA=B3=BC=EB=8A=94?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EB=B0=94=EB=80=94=20=EB=95=8C?= =?UTF-8?q?=EC=99=80=20=EB=B3=B5=EA=B5=AC=EB=90=A0=20=EB=95=8C=20=EC=95=8C?= =?UTF-8?q?=EB=A6=AC=EA=B3=A0=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EC=A7=80=EB=A9=B4=206=EC=8B=9C=EA=B0=84=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EB=8B=A4=EC=8B=9C=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20?= =?UTF-8?q?=20=20=EC=A7=81=EC=A0=84=20=EC=8B=A4=ED=96=89=EC=9D=B4=20cancel?= =?UTF-8?q?led=20=EB=82=98=20timed=5Fout=20=EC=9D=B4=EB=A9=B4=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=B4=20=EB=AA=BB=20=EA=B0=94=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=20=EC=9E=88=EC=96=B4=20=EB=8B=A4=EC=8B=9C=20=EC=95=8C=EB=A6=B0?= =?UTF-8?q?=EB=8B=A4=20-=20Discord=20payload=20=EC=97=90=20allowed=5Fmenti?= =?UTF-8?q?ons=20=EB=A5=BC=20=EB=B9=84=EC=9B=8C=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EB=AC=B8=EC=9E=90=EC=97=B4=EC=9D=98=20?= =?UTF-8?q?=EB=A9=98=EC=85=98=EC=9D=B4=20=EB=B0=9C=EB=8F=99=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=96=88=EB=8B=A4=20-=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=EC=9D=B4=20=EC=95=84=EB=8B=8C=20=EC=8A=A4?= =?UTF-8?q?=ED=85=9D=20outcome=20=EC=9D=B4=20skipped=20=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=A1=ED=98=80=20dev=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A1=9C=20=EB=B3=B4=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EB=8C=80=EC=83=81=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B3=A8=EB=9D=BC=20=EB=A7=89=EC=95=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/smoke-test.yml | 213 +++++++++++++++++++++++++++---- 1 file changed, 186 insertions(+), 27 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 4ccaaa78..8ca1b7a4 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -1,19 +1,23 @@ name: Smoke Test -# 배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. -# 컨테이너 healthcheck 는 프로세스가 떴는지만 보고, Cloudflare 와 nginx, JWT 서명 키, DB 조회는 거치지 않는다. +# 배포된 서버가 사용자를 받을 수 있는 상태인지 실제 도메인으로 확인한다. 자세한 내용은 scripts/smoke/README.md 에 있다. # -# 운영 서버에 영향을 주지 않는 것이 제일 중요한 조건이라 아래를 지킨다. 자세한 근거는 scripts/smoke/smoke_test.py 에 있다. -# - 수동 실행만 한다. 배포 워크플로(ci-prod / deploy-prod / ci-dev)와 연결하지 않았다. -# 스모크 테스트가 틀려도 배포와 nginx 전환에는 아무 영향이 없다. +# 언제 도는가 +# - 배포 워크플로가 끝난 뒤: 배포가 성공했으면 full(조회 + 스모크 계정 안의 쓰기 흐름), 실패했으면 read. +# - 매시 17분: prod 에 read. 맥미니나 네트워크가 죽은 것을 배포와 상관없이 잡는다. +# - 수동 실행: 대상과 단계를 고른다. bootstrap 은 스모크 계정을 처음 한 번 만들 때만 쓴다. +# +# 운영 서버에 영향을 주지 않으려고 지키는 것 +# - 배포 워크플로(ci-prod / deploy-prod / ci-dev / deploy-dev)는 수정하지 않았다. 끝난 것을 workflow_run 으로 받아서 +# 따로 돌기 때문에, 스모크 테스트가 틀려도 배포와 nginx 전환에는 아무 영향이 없다. # - 맥미니 러너(self-hosted)가 아니라 GitHub 호스티드 러너에서 돈다. -# 운영 기계의 자원을 쓰지 않고, 배포 job 과 러너를 두고 경쟁하지 않는다. -# - 조회 전용 GET 하나(/api/problems/problemCount)만, 한 번 실행에 최대 6번 보낸다. # - 스크립트 자체 테스트가 먼저 통과해야 실제 서버에 요청을 보낸다. +# - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다. +# +# 필요한 Secrets (없으면 할 수 있는 단계까지만 한다) +# SMOKE_PROD_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_ACCESS_TOKEN_SECRET, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL # -# 인증 확인까지 하려면 저장소 Secrets 에 서버의 jwt.accessToken.secret 과 같은 값을 넣는다. -# SMOKE_DEV_ACCESS_TOKEN_SECRET, SMOKE_PROD_ACCESS_TOKEN_SECRET -# 없으면 인증 확인은 건너뛰고 "앱까지 닿는가"만 본다. +# workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다. on: workflow_dispatch: @@ -21,28 +25,90 @@ on: target: description: "확인할 서버" type: choice - options: - - dev - - prod - default: dev + options: [prod, dev] + default: prod + level: + description: "reach: 닿는지만 / read: 조회 / full: 조회 + 쓰기 흐름 / bootstrap: 스모크 계정 만들기(처음 한 번)" + type: choice + options: [read, full, reach, bootstrap] + default: read + workflow_run: + workflows: + - "CI Build & Deploy Prod" + - "Deploy to Production Server (Manual Fallback)" + - "CI Build & Deploy Dev" + - "Deploy to Development Server (Manual Fallback)" + types: [completed] + schedule: + - cron: "17 * * * *" permissions: contents: read + actions: read +# 쓰기가 있는 실행(배포 성공 뒤 full, 수동 full, bootstrap)만 대상별로 한 줄로 세운다. 같은 스모크 계정에 쓰기가 겹치지 않게 한다. +# 조회만 하는 실행은 실행마다 그룹을 따로 둔다. 같은 그룹에 대기 중인 실행이 새 실행에 밀려 취소되는 일을 막는다. concurrency: - group: smoke-test-${{ inputs.target }} + group: >- + ${{ + ((github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') + || (github.event_name == 'workflow_dispatch' && (inputs.level == 'full' || inputs.level == 'bootstrap'))) + && format('smoke-write-{0}', github.event_name == 'workflow_dispatch' && inputs.target || (contains(github.event.workflow_run.name, 'Dev') && 'dev' || 'prod')) + || format('smoke-read-{0}', github.run_id) + }} cancel-in-progress: false jobs: smoke: - name: 스모크 테스트 (${{ inputs.target }}) + name: 스모크 테스트 + # 배포가 취소되거나 건너뛰어졌으면 확인할 것이 없다. + if: github.event_name != 'workflow_run' || contains(fromJSON('["success","failure"]'), github.event.workflow_run.conclusion) runs-on: ubuntu-latest - timeout-minutes: 5 + # 스크립트 자체 테스트 약 30초 + 스모크 실행 상한 200초 + 준비 시간 + timeout-minutes: 8 steps: - name: Checkout code uses: actions/checkout@v4 + - name: 대상과 단계 정하기 + id: plan + env: + EVENT: ${{ github.event_name }} + INPUT_TARGET: ${{ inputs.target }} + INPUT_LEVEL: ${{ inputs.level }} + REF: ${{ github.ref }} + RUN_NAME: ${{ github.event.workflow_run.name }} + RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + run: | + case "$EVENT" in + workflow_dispatch) + target="$INPUT_TARGET"; level="$INPUT_LEVEL" ;; + workflow_run) + # "CI Build & Deploy Dev", "Deploy to Development Server ..." 는 둘 다 Dev 를 포함한다. + case "$RUN_NAME" in *Dev*) target="dev" ;; *) target="prod" ;; esac + if [ "$RUN_CONCLUSION" = "success" ]; then level="full"; else level="read"; fi ;; + schedule) + target="prod"; level="read" ;; + *) + echo "알 수 없는 이벤트: $EVENT"; exit 2 ;; + esac + # 수동 실행은 아무 브랜치에서나 할 수 있어서, prod 는 main 의 스크립트로만 돌린다. + if [ "$EVENT" = "workflow_dispatch" ] && [ "$target" = "prod" ] && [ "$REF" != "refs/heads/main" ]; then + echo "prod 대상 수동 실행은 main 브랜치에서만 할 수 있습니다 (현재 $REF)"; exit 2 + fi + case "$target" in + prod) url="https://ono-prod.seungminki.shop" ;; + dev) url="https://ono-dev.seungminki.shop" ;; + *) echo "알 수 없는 대상: $target"; exit 2 ;; + esac + { + echo "target=$target" + echo "level=$level" + echo "url=$url" + } >> "$GITHUB_OUTPUT" + echo "대상 $target ($url), 단계 $level" + - name: Set up Python uses: actions/setup-python@v5 with: @@ -51,18 +117,111 @@ jobs: - name: 스크립트 자체 테스트 run: python3 -m unittest discover -s scripts/smoke -p 'test_*.py' - # 대상 서버의 시크릿만 그 스텝에 넘긴다. 한 스텝에 dev 와 prod 시크릿을 같이 두지 않고, - # 표현식의 a && b || c 는 b 가 비면 c 로 넘어가 다른 환경 값이 들어갈 수 있어서 쓰지 않는다. + # 대상 서버의 시크릿만 그 스텝에 넘긴다. 한 스텝에 dev 와 prod 시크릿을 같이 두지 않는다. + - name: 스모크 테스트 (prod) + id: smoke_prod + if: steps.plan.outputs.target == 'prod' + env: + SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} + SMOKE_LEVEL: ${{ steps.plan.outputs.level }} + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_PROD_ACCESS_TOKEN_SECRET }} + SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} + run: | + if [ "$SMOKE_LEVEL" = "bootstrap" ]; then + unset SMOKE_ACCESS_TOKEN_SECRET + python3 scripts/smoke/smoke_test.py bootstrap + else + python3 scripts/smoke/smoke_test.py + fi + - name: 스모크 테스트 (dev) - if: inputs.target == 'dev' + id: smoke_dev + if: steps.plan.outputs.target == 'dev' env: - SMOKE_BASE_URL: https://ono-dev.seungminki.shop + SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} + SMOKE_LEVEL: ${{ steps.plan.outputs.level }} SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_DEV_ACCESS_TOKEN_SECRET }} - run: python3 scripts/smoke/smoke_test.py + SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }} + run: | + if [ "$SMOKE_LEVEL" = "bootstrap" ]; then + unset SMOKE_ACCESS_TOKEN_SECRET + python3 scripts/smoke/smoke_test.py bootstrap + else + python3 scripts/smoke/smoke_test.py + fi - - name: 스모크 테스트 (prod) - if: inputs.target == 'prod' + # 알림 규칙 + # - 배포 뒤 확인: 통과와 실패 모두 알린다. + # - 매시 확인: 정상에서 실패로 바뀔 때와 복구될 때 알린다. 실패가 이어지면 6시간마다 한 번 다시 알린다. + # 첫 알림 전송이 실패해도 늦어도 6시간 뒤에는 알림이 가게 하려는 것이다. + # - 수동 실행과 bootstrap: 알리지 않는다. 실행한 사람이 화면에서 본다. + - name: Discord 알림 + if: always() && steps.plan.outputs.level != 'bootstrap' && github.event_name != 'workflow_dispatch' + continue-on-error: true env: - SMOKE_BASE_URL: https://ono-prod.seungminki.shop - SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_PROD_ACCESS_TOKEN_SECRET }} - run: python3 scripts/smoke/smoke_test.py + GH_TOKEN: ${{ github.token }} + WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + EVENT: ${{ github.event_name }} + TARGET: ${{ steps.plan.outputs.target }} + LEVEL: ${{ steps.plan.outputs.level }} + # 대상이 아닌 스텝의 outcome 은 빈 값이 아니라 skipped 라서, 대상으로 골라야 한다. + OUTCOME: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outcome || steps.smoke_dev.outcome }} + FIRST_FAILURE: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outputs.first_failure || steps.smoke_dev.outputs.first_failure }} + FAILED_COUNT: ${{ steps.plan.outputs.target == 'prod' && steps.smoke_prod.outputs.failed || steps.smoke_dev.outputs.failed }} + DEPLOY_NAME: ${{ github.event.workflow_run.name }} + DEPLOY_CONCLUSION: ${{ github.event.workflow_run.conclusion }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + REPO: ${{ github.repository }} + run: | + if [ -z "$WEBHOOK" ]; then + echo "DISCORD_WEBHOOK_URL 이 없어 알림을 건너뜁니다"; exit 0 + fi + if [ "$OUTCOME" = "success" ]; then passed=true; else passed=false; fi + + streak=0 + if [ "$EVENT" = "schedule" ]; then + # 직전 매시 실행들이 연속으로 몇 번 성공하지 못했는지 센다. cancelled, timed_out 도 실패로 본다. + # 조회에 실패하면 알림을 보내는 쪽으로 기운다(streak=0). + conclusions=$(gh api "repos/$REPO/actions/workflows/smoke-test.yml/runs?event=schedule&status=completed&per_page=12" \ + --jq '.workflow_runs[].conclusion' 2>/dev/null || true) + previous="" + for c in $conclusions; do + [ -z "$previous" ] && previous="$c" + if [ "$c" = "success" ]; then break; fi + streak=$((streak + 1)) + done + if $passed && [ "$streak" -eq 0 ]; then echo "정상 유지, 알림 없음"; exit 0; fi + # 직전이 failure 일 때만 "이미 알렸다"고 본다. cancelled 나 timed_out 은 알림 스텝이 못 돌았을 수 있다. + if ! $passed && [ "$previous" = "failure" ] && [ $((streak % 6)) -ne 0 ]; then + echo "장애 지속 (직전 연속 실패 $streak 회), 6회마다 다시 알립니다"; exit 0 + fi + fi + + export PASSED=$passed STREAK=$streak + python3 - <<'PY' > payload.json + import json, os + env = os.environ + passed = env["PASSED"] == "true" + event, target, level = env["EVENT"], env["TARGET"], env["LEVEL"] + if event == "schedule": + streak = int(env.get("STREAK") or 0) + if passed: + title = f"✅ 스모크 테스트 복구 ({target})" + elif streak: + title = f"🚨 스모크 테스트 실패 {streak + 1}시간째 ({target})" + else: + title = f"🚨 스모크 테스트 실패 ({target})" + context = "매시 확인" + else: + title = f"{'✅' if passed else '🚨'} 배포 후 스모크 테스트 {'통과' if passed else '실패'} ({target})" + context = f"{env.get('DEPLOY_NAME', '')} ({env.get('DEPLOY_CONCLUSION', '')}) 뒤 확인" + lines = [f"{context}, 단계 `{level}`"] + if not passed: + lines.append(f"실패 {env.get('FAILED_COUNT') or '?'}건. 첫 실패: {env.get('FIRST_FAILURE') or '스크립트가 끝까지 돌지 못했습니다'}") + lines.append(env["RUN_URL"]) + # 실패 문구에 서버 응답 일부가 들어가므로 @everyone 같은 멘션이 발동하지 않게 막는다. + print(json.dumps({"allowed_mentions": {"parse": []}, + "embeds": [{"title": title, "description": "\n".join(lines), + "color": 3066993 if passed else 15158332}]})) + PY + curl -fsS -X POST "$WEBHOOK" -H "Content-Type: application/json" --data @payload.json From a9ce8c43d34b1c4500ac7350d6cbfa805f2a5389 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Tue, 15 Sep 2026 23:13:29 +0900 Subject: [PATCH 100/130] =?UTF-8?q?[Chore]=20agent-config-lint=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20application-?= =?UTF-8?q?test.yml=20=EB=A7=8C=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4=20-=20ddf9c69=20=EA=B0=80=20CI=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=82=B4=EB=A6=AC=EB=A0=A4=EA=B3=A0=20src?= =?UTF-8?q?/test/resources/application-test.yml=20=EC=9D=84=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=ED=95=9C=20=EB=92=A4=EB=A1=9C=20develop=20=EC=9D=98?= =?UTF-8?q?=20Agent=20Config=20Lint=20=EA=B0=80=20=EA=B3=84=EC=86=8D=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=96=88=EB=8B=A4=20-=20=EC=9D=B4=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=9D=80=20Testcontainers=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=99=98=EA=B2=BD=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=EA=B3=A0=20=EA=B0=92=EC=9D=80=20=EC=A0=84=EB=B6=80=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=84=EC=9A=A9=20=EB=8D=94?= =?UTF-8?q?=EB=AF=B8=EB=8B=A4.=20=EC=9A=B4=EC=98=81=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EA=B3=BC=20JWT,=20crypto,=20OAuth,=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90,=20AWS=20=EA=B0=92=EC=9D=84=20=ED=95=B4=EC=8B=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=B9=84=EA=B5=90=ED=95=B4=20=EA=B2=B9=EC=B9=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EA=B2=83=EC=9D=84=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=96=88=EB=8B=A4=20-=20=EA=B2=BD=EB=A1=9C=EB=A5=BC?= =?UTF-8?q?=20=EC=A0=95=ED=99=95=ED=9E=88=20=EC=9D=B4=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=ED=95=98=EB=82=98=EB=A1=9C=EB=A7=8C=20=EB=B9=BC=EA=B3=A0,?= =?UTF-8?q?=20=EB=8B=A4=EB=A5=B8=20application-*.yml=20=EC=9D=B4=EB=82=98?= =?UTF-8?q?=20FirebaseAdminKey.json=20=EC=9D=B4=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=EB=90=98=EB=A9=B4=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=9C=EB=8B=A4=20-=20=EA=B0=9C=EC=9D=B8=20?= =?UTF-8?q?=ED=82=A4,=20AWS=20=ED=82=A4,=20Google=20API=20=ED=82=A4=20?= =?UTF-8?q?=EB=AC=B8=EC=9E=90=EC=97=B4=20=EA=B2=80=EC=82=AC=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B4=20=ED=8C=8C=EC=9D=BC=EC=97=90=EB=8F=84=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EC=A0=81=EC=9A=A9=EB=90=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codex/scripts/agent-config-lint.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.codex/scripts/agent-config-lint.sh b/.codex/scripts/agent-config-lint.sh index 8c9c03d9..82583a07 100755 --- a/.codex/scripts/agent-config-lint.sh +++ b/.codex/scripts/agent-config-lint.sh @@ -47,8 +47,13 @@ if grep -q '^/AGENTS\.md$' .gitignore; then error "AGENTS.md is ignored; shared agent guidance should be trackable" fi +# src/test/resources/application-test.yml 은 예외다. CI 테스트가 이 파일로 Testcontainers 환경을 띄워서 +# 커밋해야 하고(ddf9c69), 값은 전부 테스트 전용 더미라 운영 설정과 겹치지 않는다. +# 아래 secret_hits 검사는 이 파일에도 그대로 적용된다. tracked_secret_files="$( - git ls-files | grep -E '(^|/)(\.env(\..*)?|FirebaseAdminKey\.json|application(-(dev|prod|local|test))?\.ya?ml|application(-(dev|prod|local|test))?\.properties)$' || true + git ls-files \ + | grep -E '(^|/)(\.env(\..*)?|FirebaseAdminKey\.json|application(-(dev|prod|local|test))?\.ya?ml|application(-(dev|prod|local|test))?\.properties)$' \ + | grep -v -x 'src/test/resources/application-test\.yml' || true )" if [ -n "$tracked_secret_files" ]; then From 8c06c4031cc70a714020c3522e4d3681bff61b57 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 18:13:26 +0900 Subject: [PATCH 101/130] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=20=EB=A6=AC?= =?UTF-8?q?=EB=A7=88=EC=9D=B8=EB=8D=94=EC=97=90=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8C=80=20=EA=B0=80=EB=93=9C=EC=99=80=20?= =?UTF-8?q?=EA=B8=B0=ED=95=9C=20=EB=A7=8C=EB=A3=8C=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 심야 차단이 예약을 만들 때만 걸려 있고 보낼 때는 시각을 보지 않아, 자정에 "오늘 보낸 것" 판정이 리셋되는 순간 밀린 예약이 바로 나가고 있었다 - 하루 한 건만 나가는데 밀린 것을 접는 곳이 없어, 한 번 밀리면 매일 새벽에 한 건씩 계속 나가는 상태가 된다 - 발송 창을 09:00~21:00 으로 두고 sendDueReminders 앞에서 확인한다 - scheduledAt 이 사흘 지난 SCHEDULED 는 EXPIRED 로 접는다 (선언만 있고 아무도 세팅하지 않던 상태였다) - 예약 시각 정규화도 양쪽 경계를 보게 한다. NIGHT_END(06:00) 하나만 있어 23:30 에 등록하면 D+1 23:30 에 그대로 나갔다 - 테스트가 LocalDateTime.now() 를 쓰고 있어 실행 시각에 따라 갈리던 것을 발송 창 안의 시각으로 고정한다 Closes #260 --- .../reminder/ProblemReviewReminderPolicy.java | 31 +++- .../ProblemReviewReminderRepository.java | 10 ++ .../ProblemReviewReminderService.java | 24 +++ .../ProblemReviewReminderPolicyTest.java | 74 ++++++-- .../ProblemReviewReminderServiceTest.java | 160 +++++++++++++----- 5 files changed, 233 insertions(+), 66 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java index 4f2366b1..b7a744a5 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java @@ -12,7 +12,8 @@ public class ProblemReviewReminderPolicy { private static final List INTERVALS = List.of(1, 3, 7, 14, 30); - private static final LocalTime NIGHT_END = LocalTime.of(6, 0); + private static final LocalTime SEND_WINDOW_START = LocalTime.of(9, 0); + private static final LocalTime SEND_WINDOW_END = LocalTime.of(21, 0); private static final String NOTIFICATION_TITLE = "오답노트 복습"; private static final String NOTIFICATION_BODY_DEFAULT = "그때 남긴 오답, 다시 풀어볼 시간이에요"; private static final String NOTIFICATION_BODY_FALLBACK = "이전에 남긴 오답노트를 다시 풀어보세요"; @@ -21,14 +22,38 @@ public List getIntervals() { return INTERVALS; } + /** + * 예약 시각을 보내도 되는 시간대 안으로 밀어 넣는다. + * + *

    이른 새벽이면 같은 날 아침으로 당기고, 밤이면 다음 날 아침으로 미룬다. + * 예전에는 경계가 {@code NIGHT_END}(06:00) 하나뿐이라 새벽만 막았고, + * 22시에 등록하면 D+N 22시에 그대로 나갔다. + */ public LocalDateTime calculateScheduledAt(LocalDateTime createdAt, int intervalDays) { LocalDateTime candidate = createdAt.plusDays(intervalDays); - if (candidate.toLocalTime().isBefore(NIGHT_END)) { - return candidate.toLocalDate().atTime(NIGHT_END); + LocalTime candidateTime = candidate.toLocalTime(); + + if (candidateTime.isBefore(SEND_WINDOW_START)) { + return candidate.toLocalDate().atTime(SEND_WINDOW_START); + } + if (!candidateTime.isBefore(SEND_WINDOW_END)) { + return candidate.toLocalDate().plusDays(1).atTime(SEND_WINDOW_START); } return candidate; } + /** + * 지금 보내도 되는 시간인가. + * + *

    예약 시각을 창 안으로 맞춰 두어도 밀린 예약은 창 밖에서 조건을 만족한다. + * 특히 자정에 "오늘 보낸 것이 있는가" 판정이 리셋되면서 밀린 예약이 한꺼번에 + * due 가 되므로, 예약을 만들 때만이 아니라 보낼 때도 시각을 확인해야 한다. + */ + public boolean isWithinSendWindow(LocalDateTime now) { + LocalTime time = now.toLocalTime(); + return !time.isBefore(SEND_WINDOW_START) && time.isBefore(SEND_WINDOW_END); + } + public NotificationRequestDto buildNotification(ProblemReviewReminder reminder) { boolean hasContent = hasContent(reminder.getProblemMemoSnapshot()) || hasContent(reminder.getProblemReferenceSnapshot()); diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 48a0f3c4..374889ad 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -72,6 +72,16 @@ void markFailed( @Param("failed") ProblemReviewReminderStatus failed ); + @Transactional + @Modifying(clearAutomatically = true) + @Query("UPDATE ProblemReviewReminder r SET r.status = :expired, r.updatedAt = :updatedAt WHERE r.status = :scheduled AND r.scheduledAt < :expireBefore AND r.deletedAt IS NULL") + int expireOverdueRows( + @Param("scheduled") ProblemReviewReminderStatus scheduled, + @Param("expired") ProblemReviewReminderStatus expired, + @Param("expireBefore") LocalDateTime expireBefore, + @Param("updatedAt") LocalDateTime updatedAt + ); + @Transactional @Modifying(clearAutomatically = true) @Query("UPDATE ProblemReviewReminder r SET r.status = :failed, r.retryCount = r.retryCount + 1, r.lastErrorMessage = 'stuck recovery' WHERE r.status = :sending AND r.updatedAt < :stuckBefore AND r.deletedAt IS NULL") diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index 9a8d6b6e..06a281b8 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -29,6 +29,7 @@ public class ProblemReviewReminderService { private static final int STUCK_TIMEOUT_MINUTES = 10; private static final int DUE_REMINDER_BATCH_SIZE = 500; + private static final int OVERDUE_EXPIRE_DAYS = 3; private static final List PENDING_STATUSES = List.of(SCHEDULED, SENDING); private final ProblemReviewReminderRepository repository; @@ -105,6 +106,14 @@ public void cancelAllByUser(Long userId) { public void sendDueReminders(LocalDateTime now) { recoverStuckRows(now); + expireOverdueRows(now); + + // 예약 시각을 창 안으로 맞춰 두어도 밀린 예약은 창 밖에서 due 가 된다. + // 특히 자정에 "오늘 보낸 것" 판정이 리셋되는 순간 가장 오래된 밀린 예약이 + // 바로 조건을 만족해, 밀린 것이 있는 사용자는 매일 새벽에 푸시를 받게 된다. + if (!policy.isWithinSendWindow(now)) { + return; + } LocalDate today = now.toLocalDate(); LocalDateTime startOfDay = today.atStartOfDay(); @@ -126,6 +135,21 @@ public void sendDueReminders(LocalDateTime now) { } } + /** + * 기한이 한참 지난 예약을 접는다. + * + *

    하루에 한 건만 나가므로 같은 날 due 가 겹치면 나머지는 {@code SCHEDULED} 로 남는다. + * 이걸 정리하지 않으면 영원히 밀린 채로 남아 매일 한 건씩 나가게 된다. + * 사흘이 지난 복습 알림은 지금 보내도 의미가 없으니 보내지 않는다. + */ + private void expireOverdueRows(LocalDateTime now) { + LocalDateTime expireBefore = now.minusDays(OVERDUE_EXPIRE_DAYS); + int expired = repository.expireOverdueRows(SCHEDULED, EXPIRED, expireBefore, now); + if (expired > 0) { + log.info("[ReviewReminder] 기한이 지난 예약 만료: {}건", expired); + } + } + private void recoverStuckRows(LocalDateTime now) { LocalDateTime stuckBefore = now.minusMinutes(STUCK_TIMEOUT_MINUTES); int recovered = repository.recoverStuckRows(SENDING, FAILED, stuckBefore); diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java index 6bdb17f0..d2ca594e 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicyTest.java @@ -31,9 +31,9 @@ void getIntervals_returnsExpectedList() { // ──────────────────────── calculateScheduledAt ───────────────── @Test - @DisplayName("일반 시간대 작성 시 각 interval별 예약시각은 createdAt + N일과 동일하다") - void calculateScheduledAt_normalTime_allIntervals() { - LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 22, 35, 0); + @DisplayName("발송 창 안(14:35) 작성 시 각 interval별 예약시각은 createdAt + N일과 동일하다") + void calculateScheduledAt_withinWindow_allIntervals() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 14, 35, 0); List intervals = policy.getIntervals(); // [1, 3, 7, 14, 30] for (int intervalDays : intervals) { @@ -45,35 +45,75 @@ void calculateScheduledAt_normalTime_allIntervals() { } @Test - @DisplayName("야간(00:05) 작성 후 D+1 예약시각은 다음날 06:00로 조정된다") - void calculateScheduledAt_nightTime_adjustsToMorning() { + @DisplayName("새벽(00:05) 작성 후 D+1 예약시각은 같은 날 09:00로 당겨진다") + void calculateScheduledAt_earlyMorning_pulledToWindowStart() { LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 0, 5, 0); LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); - assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 9, 0, 0)); } @Test - @DisplayName("야간 경계: 05:59 작성 → D+1 06:00 조정") - void calculateScheduledAt_justBeforeNightEnd_adjusts() { - LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 5, 59, 0); + @DisplayName("시작 경계: 08:59 작성 → D+1 09:00 으로 당겨진다") + void calculateScheduledAt_justBeforeWindowStart_pulled() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 8, 59, 0); LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); - assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 9, 0, 0)); } @Test - @DisplayName("야간 경계: 06:00 정각 작성 → 조정 없이 D+1 06:00 그대로") - void calculateScheduledAt_exactlyNightEnd_noAdjustment() { - LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 6, 0, 0); + @DisplayName("시작 경계: 09:00 정각 작성 → 조정 없이 D+1 09:00 그대로") + void calculateScheduledAt_exactlyWindowStart_noAdjustment() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 9, 0, 0); LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); - assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0, 0)); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 9, 0, 0)); } @Test - @DisplayName("야간이 아닌 23:50 작성 → D+1 23:50 그대로 유지") - void calculateScheduledAt_lateEvening_noAdjustment() { + @DisplayName("끝 경계: 20:59 작성 → 조정 없이 D+1 20:59 그대로") + void calculateScheduledAt_justBeforeWindowEnd_noAdjustment() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 20, 59, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 20, 59, 0)); + } + + @Test + @DisplayName("끝 경계: 21:00 정각 작성 → 다음 날 09:00 으로 미뤄진다") + void calculateScheduledAt_exactlyWindowEnd_pushedToNextMorning() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 21, 0, 0); + LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); + assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 20, 9, 0, 0)); + } + + @Test + @DisplayName("밤(23:50) 작성 → D+1 그대로가 아니라 그 다음 날 09:00 으로 미뤄진다") + void calculateScheduledAt_lateEvening_pushedToNextMorning() { LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 23, 50, 0); LocalDateTime result = policy.calculateScheduledAt(createdAt, 1); - assertThat(result).isEqualTo(LocalDateTime.of(2026, 7, 19, 23, 50, 0)); + assertThat(result) + .as("23:50 에 등록했다고 D+1 23:50 에 푸시가 나가면 안 된다") + .isEqualTo(LocalDateTime.of(2026, 7, 20, 9, 0, 0)); + } + + // ──────────────────────── isWithinSendWindow ─────────────────── + + @Test + @DisplayName("자정에는 보내지 않는다 - 밀린 예약이 몰리는 시각이다") + void isWithinSendWindow_midnight_false() { + assertThat(policy.isWithinSendWindow(LocalDateTime.of(2026, 7, 18, 0, 0, 0))).isFalse(); + } + + @Test + @DisplayName("창 경계: 08:59 은 false, 09:00 은 true") + void isWithinSendWindow_startBoundary() { + assertThat(policy.isWithinSendWindow(LocalDateTime.of(2026, 7, 18, 8, 59, 59))).isFalse(); + assertThat(policy.isWithinSendWindow(LocalDateTime.of(2026, 7, 18, 9, 0, 0))).isTrue(); + } + + @Test + @DisplayName("창 경계: 20:59 은 true, 21:00 은 false") + void isWithinSendWindow_endBoundary() { + assertThat(policy.isWithinSendWindow(LocalDateTime.of(2026, 7, 18, 20, 59, 59))).isTrue(); + assertThat(policy.isWithinSendWindow(LocalDateTime.of(2026, 7, 18, 21, 0, 0))).isFalse(); } // ──────────────────────── buildNotification ──────────────────── diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java index 3fd9a12f..bd58f722 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java @@ -14,12 +14,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.CANCELED; +import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.EXPIRED; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.FAILED; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.SCHEDULED; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.SENDING; @@ -70,6 +72,16 @@ private List remindersOf(Long problemId) { .toList(); } + /** + * 발송 창(09:00~21:00) 안에 있는 기준 시각. + * + *

    예약 시각 정규화와 발송 가드가 둘 다 시각을 보기 때문에, 테스트가 몇 시에 + * 돌아가느냐에 따라 결과가 갈린다. 날짜는 오늘로 두고 시각만 고정한다. + */ + private static LocalDateTime nowInSendWindow() { + return LocalDate.now().atTime(12, 0); + } + private ProblemReviewReminder saveDueReminder(Long ownerId, Long problemId, LocalDateTime scheduledAt) { return reminderRepository.save( ProblemReviewReminder.create(ownerId, problemId, "메모", "출처", 1, 1, scheduledAt)); @@ -88,7 +100,7 @@ class ScheduleForNewProblems { @Test @DisplayName("문제 1개당 망각곡선 간격(1·3·7·14·30일) 5건이 sequence 1~5로 예약된다") void createsFiveRowsPerProblem() { - LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 22, 0); + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 14, 0); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(1001L, "메모", "출처", createdAt))); @@ -107,7 +119,7 @@ void createsFiveRowsPerProblem() { @Test @DisplayName("문제 2개를 한 번에 예약하면 10건이 만들어진다") void createsRowsForEveryProblem() { - LocalDateTime createdAt = LocalDateTime.now(); + LocalDateTime createdAt = nowInSendWindow(); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(2001L, "메모1", "출처1", createdAt), @@ -120,7 +132,7 @@ void createsRowsForEveryProblem() { @DisplayName("같은 문제로 두 번 예약해도 sequence 유니크 제약 때문에 5건으로 유지된다") void isIdempotent() { ProblemCreatedEvent.ProblemData data = - new ProblemCreatedEvent.ProblemData(3001L, "메모", "출처", LocalDateTime.now()); + new ProblemCreatedEvent.ProblemData(3001L, "메모", "출처", nowInSendWindow()); service.scheduleForNewProblems(userId, List.of(data)); service.scheduleForNewProblems(userId, List.of(data)); @@ -131,7 +143,7 @@ void isIdempotent() { @Test @DisplayName("일부 sequence 만 남아 있으면 빠진 것만 채워 넣는다") void fillsOnlyMissingSequences() { - LocalDateTime createdAt = LocalDateTime.now(); + LocalDateTime createdAt = nowInSendWindow(); reminderRepository.save( ProblemReviewReminder.create(userId, 3101L, "메모", "출처", 1, 1, createdAt.plusDays(1))); @@ -152,7 +164,7 @@ void createsNothingForEmptyInput() { } @Test - @DisplayName("새벽에 작성한 문제의 D+1 알림은 06:00로 미뤄진다") + @DisplayName("새벽에 작성한 문제의 D+1 알림은 09:00로 미뤄진다") void movesNightScheduleToMorning() { LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 0, 5); @@ -161,7 +173,20 @@ void movesNightScheduleToMorning() { assertThat(remindersOf(3201L).get(0).getScheduledAt()) .as("자는 시간에 푸시를 보내지 않는다") - .isEqualTo(LocalDateTime.of(2026, 7, 19, 6, 0)); + .isEqualTo(LocalDateTime.of(2026, 7, 19, 9, 0)); + } + + @Test + @DisplayName("밤에 작성한 문제의 D+1 알림은 그 다음 날 09:00로 미뤄진다") + void movesLateNightScheduleToNextMorning() { + LocalDateTime createdAt = LocalDateTime.of(2026, 7, 18, 23, 30); + + service.scheduleForNewProblems(userId, List.of( + new ProblemCreatedEvent.ProblemData(3202L, "메모", "출처", createdAt))); + + assertThat(remindersOf(3202L).get(0).getScheduledAt()) + .as("23:30 에 등록했다고 D+1 23:30 에 푸시가 나가면 안 된다") + .isEqualTo(LocalDateTime.of(2026, 7, 20, 9, 0)); } @Test @@ -170,7 +195,7 @@ void storesLongMemoSnapshot() { String memo = "가".repeat(1000); service.scheduleForNewProblems(userId, List.of( - new ProblemCreatedEvent.ProblemData(3301L, memo, "출처", LocalDateTime.now()))); + new ProblemCreatedEvent.ProblemData(3301L, memo, "출처", nowInSendWindow()))); assertThat(remindersOf(3301L)) .allSatisfy(reminder -> assertThat(reminder.getProblemMemoSnapshot()).hasSize(1000)); @@ -187,7 +212,7 @@ class CancelAndRefresh { @DisplayName("문제 삭제 시 대기 중인 알림이 모두 CANCELED 가 된다") void cancelsPendingByProblem() { service.scheduleForNewProblems(userId, List.of( - new ProblemCreatedEvent.ProblemData(4001L, "메모", "출처", LocalDateTime.now()))); + new ProblemCreatedEvent.ProblemData(4001L, "메모", "출처", nowInSendWindow()))); service.cancelPendingByProblem(4001L); @@ -197,7 +222,7 @@ void cancelsPendingByProblem() { @Test @DisplayName("다른 문제의 예약은 취소되지 않는다") void cancelIsScopedToProblem() { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = nowInSendWindow(); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(4101L, "메모", "출처", now), new ProblemCreatedEvent.ProblemData(4102L, "메모", "출처", now))); @@ -211,7 +236,7 @@ void cancelIsScopedToProblem() { @DisplayName("탈퇴 시 사용자의 대기 알림만 취소되고 다른 사용자 것은 남는다") void cancelAllByUserIsUserScoped() { User other = fixtures.createOtherUser(); - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = nowInSendWindow(); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(4201L, "메모", "출처", now))); service.scheduleForNewProblems(other.getId(), List.of( @@ -229,7 +254,7 @@ void cancelAllByUserIsUserScoped() { @DisplayName("메모 수정 시 SCHEDULED 알림의 스냅샷이 갱신된다") void refreshesSnapshot() { service.scheduleForNewProblems(userId, List.of( - new ProblemCreatedEvent.ProblemData(5001L, "이전 메모", "이전 출처", LocalDateTime.now()))); + new ProblemCreatedEvent.ProblemData(5001L, "이전 메모", "이전 출처", nowInSendWindow()))); service.refreshSnapshot(5001L, "새 메모", "새 출처"); @@ -243,7 +268,7 @@ void refreshesSnapshot() { @DisplayName("취소된 알림의 스냅샷은 갱신하지 않는다") void doesNotRefreshCanceledRows() { service.scheduleForNewProblems(userId, List.of( - new ProblemCreatedEvent.ProblemData(5101L, "이전 메모", "이전 출처", LocalDateTime.now()))); + new ProblemCreatedEvent.ProblemData(5101L, "이전 메모", "이전 출처", nowInSendWindow()))); service.cancelPendingByProblem(5101L); service.refreshSnapshot(5101L, "새 메모", "새 출처"); @@ -258,7 +283,7 @@ void doesNotRefreshCanceledRows() { void refreshesWithLongMemo() { String memo = "나".repeat(1000); service.scheduleForNewProblems(userId, List.of( - new ProblemCreatedEvent.ProblemData(5201L, "짧은 메모", "출처", LocalDateTime.now()))); + new ProblemCreatedEvent.ProblemData(5201L, "짧은 메모", "출처", nowInSendWindow()))); service.refreshSnapshot(5201L, memo, "출처"); @@ -274,7 +299,7 @@ class SkipOnPractice { @Test @DisplayName("복습 시각 이전의 예약만 SKIPPED_BY_COMPLETION 이 된다") void skipsOnlyDueRows() { - LocalDateTime base = LocalDateTime.of(2026, 7, 18, 22, 0); + LocalDateTime base = LocalDateTime.of(2026, 7, 18, 14, 0); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(6001L, "메모", "출처", base))); @@ -291,7 +316,7 @@ void skipsOnlyDueRows() { @Test @DisplayName("아직 도래하지 않은 예약만 있으면 아무것도 skip 되지 않는다") void skipsNothingWhenNothingDue() { - LocalDateTime base = LocalDateTime.now(); + LocalDateTime base = nowInSendWindow(); service.scheduleForNewProblems(userId, List.of( new ProblemCreatedEvent.ProblemData(6101L, "메모", "출처", base))); @@ -324,22 +349,65 @@ class SendDueReminders { @DisplayName("발송 조건을 모두 만족하면 FCM 을 한 번 부르고 SENT 로 바꾼다") void sendsAndMarksSent() { giveFcmToken(userId, "token-1"); - ProblemReviewReminder due = saveDueReminder(userId, 8001L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 8001L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, times(1)) .sendNotificationToAllUserDevice(eq(userId), any(NotificationRequestDto.class)); assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SENT); } + @Test + @DisplayName("발송 창 밖(자정)에는 due 가 있어도 보내지 않는다") + void doesNotSendOutsideSendWindow() { + giveFcmToken(userId, "token-1"); + LocalDateTime midnight = LocalDate.now().atStartOfDay(); + ProblemReviewReminder due = saveDueReminder(userId, 8201L, midnight.minusMinutes(1)); + + service.sendDueReminders(midnight); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()) + .as("자정은 밀린 예약이 한꺼번에 due 가 되는 시각이라 특히 막아야 한다") + .isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("기한이 사흘 넘게 지난 예약은 보내지 않고 EXPIRED 로 접는다") + void expiresLongOverdueReminders() { + giveFcmToken(userId, "token-1"); + LocalDateTime now = nowInSendWindow(); + ProblemReviewReminder overdue = saveDueReminder(userId, 8301L, now.minusDays(4)); + + service.sendDueReminders(now); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + assertThat(reminderRepository.findById(overdue.getId()).orElseThrow().getStatus()) + .as("접지 않으면 매일 한 건씩 밀린 알림이 계속 나간다") + .isEqualTo(EXPIRED); + } + + @Test + @DisplayName("기한이 이틀 지난 예약은 아직 만료하지 않고 보낸다") + void stillSendsRecentlyOverdueReminders() { + giveFcmToken(userId, "token-1"); + LocalDateTime now = nowInSendWindow(); + ProblemReviewReminder overdue = saveDueReminder(userId, 8401L, now.minusDays(2)); + + service.sendDueReminders(now); + + assertThat(reminderRepository.findById(overdue.getId()).orElseThrow().getStatus()) + .isEqualTo(SENT); + } + @Test @DisplayName("예약 시각이 아직 오지 않았으면 발송하지 않는다") void doesNotSendFutureReminders() { giveFcmToken(userId, "token-1"); - ProblemReviewReminder future = saveDueReminder(userId, 8101L, LocalDateTime.now().plusHours(1)); + ProblemReviewReminder future = saveDueReminder(userId, 8101L, nowInSendWindow().plusHours(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); assertThat(reminderRepository.findById(future.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); @@ -351,9 +419,9 @@ void skipsNotificationDisabledUser() { user.updateNotificationEnabled(false); userRepository.save(user); giveFcmToken(userId, "token-1"); - ProblemReviewReminder due = saveDueReminder(userId, 9001L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 9001L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); @@ -362,9 +430,9 @@ void skipsNotificationDisabledUser() { @Test @DisplayName("FCM 토큰이 없으면 발송하지 않는다") void skipsUserWithoutFcmToken() { - ProblemReviewReminder due = saveDueReminder(userId, 10001L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 10001L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); @@ -375,13 +443,13 @@ void skipsUserWithoutFcmToken() { void sendsAtMostOncePerDay() { giveFcmToken(userId, "token-1"); ProblemReviewReminder alreadySent = - saveDueReminder(userId, 11001L, LocalDateTime.now().minusDays(1)); - LocalDateTime sentAt = LocalDateTime.now(); + saveDueReminder(userId, 11001L, nowInSendWindow().minusDays(1)); + LocalDateTime sentAt = nowInSendWindow(); inTransaction(() -> reminderRepository.markSent(alreadySent.getId(), sentAt, SENT)); - ProblemReviewReminder due = saveDueReminder(userId, 11002L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 11002L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); @@ -392,11 +460,11 @@ void sendsAtMostOncePerDay() { void sendsOnlyEarliestPerUser() { giveFcmToken(userId, "token-1"); ProblemReviewReminder earlier = - saveDueReminder(userId, 12001L, LocalDateTime.now().minusMinutes(10)); + saveDueReminder(userId, 12001L, nowInSendWindow().minusMinutes(10)); ProblemReviewReminder later = - saveDueReminder(userId, 12002L, LocalDateTime.now().minusMinutes(5)); + saveDueReminder(userId, 12002L, nowInSendWindow().minusMinutes(5)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, times(1)).sendNotificationToAllUserDevice(eq(userId), any()); assertThat(reminderRepository.findById(earlier.getId()).orElseThrow().getStatus()).isEqualTo(SENT); @@ -411,14 +479,14 @@ void isolatesUsers() { giveFcmToken(other.getId(), "token-u2"); ProblemReviewReminder alreadySent = - saveDueReminder(userId, 13001L, LocalDateTime.now().minusDays(1)); - LocalDateTime sentAt = LocalDateTime.now(); + saveDueReminder(userId, 13001L, nowInSendWindow().minusDays(1)); + LocalDateTime sentAt = nowInSendWindow(); inTransaction(() -> reminderRepository.markSent(alreadySent.getId(), sentAt, SENT)); ProblemReviewReminder othersDue = - saveDueReminder(other.getId(), 13002L, LocalDateTime.now().minusMinutes(1)); + saveDueReminder(other.getId(), 13002L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, times(1)).sendNotificationToAllUserDevice(eq(other.getId()), any()); verify(fcmService, never()).sendNotificationToAllUserDevice(eq(userId), any()); @@ -429,11 +497,11 @@ void isolatesUsers() { @DisplayName("FCM 발송이 실패하면 FAILED 로 기록하고 재시도 횟수를 올린다") void marksFailedOnFcmError() { giveFcmToken(userId, "token-1"); - ProblemReviewReminder due = saveDueReminder(userId, 8002L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 8002L, nowInSendWindow().minusMinutes(1)); doThrow(new IllegalStateException("enqueue failed")) .when(fcmService).sendNotificationToAllUserDevice(eq(userId), any(NotificationRequestDto.class)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); ProblemReviewReminder updated = reminderRepository.findById(due.getId()).orElseThrow(); assertThat(updated.getStatus()).isEqualTo(FAILED); @@ -445,11 +513,11 @@ void marksFailedOnFcmError() { @DisplayName("500자를 넘는 에러 메시지는 컬럼 길이에 맞게 잘려 저장된다") void truncatesLongErrorMessage() { giveFcmToken(userId, "token-1"); - ProblemReviewReminder due = saveDueReminder(userId, 8003L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder due = saveDueReminder(userId, 8003L, nowInSendWindow().minusMinutes(1)); doThrow(new IllegalStateException("e".repeat(600))) .when(fcmService).sendNotificationToAllUserDevice(eq(userId), any(NotificationRequestDto.class)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); assertThat(reminderRepository.findById(due.getId()).orElseThrow().getLastErrorMessage()) .as("varchar(500) 을 넘기면 Data truncation 으로 발송 배치 전체가 죽는다") @@ -459,17 +527,17 @@ void truncatesLongErrorMessage() { @Test @DisplayName("10분 넘게 SENDING 에 멈춰 있는 행은 FAILED 로 복구된다") void recoversStuckSendingRows() { - ProblemReviewReminder stuck = saveDueReminder(userId, 14001L, LocalDateTime.now().minusMinutes(30)); + ProblemReviewReminder stuck = saveDueReminder(userId, 14001L, nowInSendWindow().minusMinutes(30)); inTransaction(() -> - reminderRepository.tryUpdateStatus(stuck.getId(), SCHEDULED, SENDING, LocalDateTime.now())); - LocalDateTime stuckSince = LocalDateTime.now().minusMinutes(11); + reminderRepository.tryUpdateStatus(stuck.getId(), SCHEDULED, SENDING, nowInSendWindow())); + LocalDateTime stuckSince = nowInSendWindow().minusMinutes(11); inTransaction(() -> entityManager .createNativeQuery("UPDATE problem_review_reminder SET updated_at = ? WHERE id = ?") .setParameter(1, stuckSince) .setParameter(2, stuck.getId()) .executeUpdate()); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); ProblemReviewReminder recovered = reminderRepository.findById(stuck.getId()).orElseThrow(); assertThat(recovered.getStatus()).isEqualTo(FAILED); @@ -479,11 +547,11 @@ void recoversStuckSendingRows() { @Test @DisplayName("SENDING 상태로 남아 있어도 10분이 지나지 않았으면 건드리지 않는다") void keepsRecentSendingRows() { - ProblemReviewReminder sending = saveDueReminder(userId, 14002L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder sending = saveDueReminder(userId, 14002L, nowInSendWindow().minusMinutes(1)); inTransaction(() -> - reminderRepository.tryUpdateStatus(sending.getId(), SCHEDULED, SENDING, LocalDateTime.now())); + reminderRepository.tryUpdateStatus(sending.getId(), SCHEDULED, SENDING, nowInSendWindow())); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); assertThat(reminderRepository.findById(sending.getId()).orElseThrow().getStatus()) .as("발송 중인 행을 성급히 되돌리면 중복 발송이 난다") @@ -495,7 +563,7 @@ void keepsRecentSendingRows() { void doesNothingWhenNoDueRow() { giveFcmToken(userId, "token-1"); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); } From 443a02af4068c4bb228cd7b25d47dd24d7846eae Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 12:54:01 +0900 Subject: [PATCH 102/130] =?UTF-8?q?[Fix]=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20?= =?UTF-8?q?=ED=99=94=EC=9D=B4=ED=8A=B8=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90?= =?UTF-8?q?=20=EC=95=B1=EC=9D=B4=20=EC=83=88=EB=A1=9C=20=EC=97=B0=20?= =?UTF-8?q?=ED=82=A4=20=EB=84=A4=20=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20=ED=94=84=EB=A1=A0=ED=8A=B8=20develop?= =?UTF-8?q?=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8(73=EA=B0=9C)=EC=97=90?= =?UTF-8?q?=EB=A7=8C=20=EC=9E=88=EB=8D=98=20angry=5Fon=5Ffire,=20christmas?= =?UTF-8?q?=5Fsanta,=20excited=5Fsparkles,=20peeking=5Fpole=20=EC=9D=84=20?= =?UTF-8?q?ALLOWED=5FKEYS=20=EC=97=90=20=EB=84=A3=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EB=A9=B4=20400(11001)=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EA=B1=B0=EC=A0=88=EB=8F=BC=20=EB=B3=B5=EC=8A=B5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C,=20=ED=95=99=EC=8A=B5=20=EB=8B=AC=EB=A0=A5=20?= =?UTF-8?q?=EA=B8=B0=EB=B6=84,=20=EC=8A=A4=ED=84=B0=EB=94=94=EB=A3=B8=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91,=20=EB=B3=B5=EC=8A=B5=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=9D=B4=20=EC=8B=A4=ED=8C=A8=ED=96=88?= =?UTF-8?q?=EB=8B=A4=20-=20=EA=B8=B0=EC=A1=B4=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=9D=98=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EC=88=9C=EC=84=9C=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=84=A3=EA=B3=A0,=20=EB=84=A4=20=ED=82=A4=EA=B0=80=20?= =?UTF-8?q?=ED=86=B5=EA=B3=BC=ED=95=98=EB=8A=94=EC=A7=80=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../OnO/backend/common/emoji/CustomEmojiValidator.java | 4 ++++ .../backend/common/emoji/CustomEmojiValidatorTest.java | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java index c858f376..69436632 100644 --- a/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java +++ b/src/main/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidator.java @@ -24,6 +24,7 @@ public class CustomEmojiValidator { "in_love_heart", "scared_dread", "star_eyes_excited", + "excited_sparkles", "frustrated_studying", "holding_pen", "reading_with_glasses", @@ -53,6 +54,8 @@ public class CustomEmojiValidator { "thumbs_up_wink", "thank_you_sign", "winking_fist", + "angry_on_fire", + "christmas_santa", "hi_greeting", "waving_hello", "cheers_beer", @@ -60,6 +63,7 @@ public class CustomEmojiValidator { "texting_heart", "puzzle_teamwork", "peeking_wall", + "peeking_pole", "blue_hoodie", "sleeping_blanket", "cozy_blanket", diff --git a/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java index c0862e55..58f92e96 100644 --- a/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/emoji/CustomEmojiValidatorTest.java @@ -33,6 +33,15 @@ void passesWhitelistedKey(String emojiKey) { assertThat(validator.isAllowed(emojiKey)).isTrue(); assertThatCode(() -> validator.validate(emojiKey)).doesNotThrowAnyException(); } + + // 앱 목록에 늦게 들어온 넷이다. 서버 목록에 빠져 있어 고르면 저장이 11001 로 실패했다 (OnO_FRONT#232). + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"angry_on_fire", "christmas_santa", "excited_sparkles", "peeking_pole"}) + @DisplayName("앱 목록에 새로 들어온 키도 통과한다") + void passesKeyAddedToAppCatalog(String emojiKey) { + assertThat(validator.isAllowed(emojiKey)).isTrue(); + assertThatCode(() -> validator.validate(emojiKey)).doesNotThrowAnyException(); + } } @Nested From 8452a9119ab7ddbaf5940ed4446fd121adfef8d6 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Mon, 14 Sep 2026 18:16:43 +0900 Subject: [PATCH 103/130] =?UTF-8?q?[Fix]=20=EA=B0=99=EC=9D=80=20=EB=82=A0?= =?UTF-8?q?=20=EB=91=90=20=EB=B2=88=EC=A7=B8=20=EB=B3=B5=EC=8A=B5=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=93=B1=EB=A1=9D=EC=9D=84=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=ED=97=88=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateSolveImageNotRegisteredToday 가 사진 등록 자체를 막고 있어 하루에 두 번 복습하면 두 번째가 400 으로 거절됐다 - 당일 중복 보상은 원래부터 MissionLogService 의 alreadyPracticeProblem 이 막고 있고 main 에도 같은 코드가 있다. 검증만 걷어내면 사진은 여러 장 남고 XP 는 하루 한 번만 들어간다 - registerProblemPracticeMission 은 미션 진행도를 올리지 않아 그쪽 중복도 없다 - 쓰는 곳이 없어진 existsByProblemIdAndProblemImageTypeAndCreatedAtBetween 도 지운다 - V22 는 이미 dev DB 에 적용돼 있어 주석도 손대지 않는다. 파일을 바꾸면 validate-on-migrate 체크섬 검증에 걸려 dev 기동이 실패한다 Closes #261 --- .../ProblemImageDataRepository.java | 9 ------- .../problem/service/ProblemService.java | 23 ----------------- .../ProblemApiIntegrationTest.java | 10 ++++---- .../service/ProblemServiceEdgeCaseTest.java | 25 ++++++++++++------- .../problem/service/ProblemServiceTest.java | 18 ++++++------- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java index 8c4e8c76..50d5c8ac 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/repository/ProblemImageDataRepository.java @@ -1,10 +1,8 @@ package com.aisip.OnO.backend.problem.repository; import com.aisip.OnO.backend.problem.entity.ProblemImageData; -import com.aisip.OnO.backend.problem.entity.ProblemImageType; import org.springframework.data.jpa.repository.JpaRepository; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -12,13 +10,6 @@ public interface ProblemImageDataRepository extends JpaRepository findAllByProblemId(Long problemId); - boolean existsByProblemIdAndProblemImageTypeAndCreatedAtBetween( - Long problemId, - ProblemImageType problemImageType, - LocalDateTime startOfDay, - LocalDateTime endOfDay - ); - Optional findByImageUrl(String imageUrl); void deleteAllByProblemId(Long problemId); diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 085408b5..53f7f2a6 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -53,7 +53,6 @@ import org.springframework.data.domain.PageRequest; import java.time.LocalDate; -import java.time.LocalTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; @@ -500,8 +499,6 @@ public void uploadProblemImages(Long problemId, Long userId, List MultipartFile imageFile = images.get(i); ProblemImageType imageType = imageTypes.get(i); - validateSolveImageNotRegisteredToday(problemId, imageType); - // S3에 업로드 String imageUrl = fileUploadService.uploadFileToS3(imageFile); @@ -525,7 +522,6 @@ public void addImageDataUrls(Long problemId, Long userId, AddProblemImageUrlsReq for (AddProblemImageUrlsRequest.ImageUrlItem item : request.imageDataList()) { fileUploadService.validateS3Url(item.imageUrl()); ProblemImageType imageType = ProblemImageType.valueOf(item.problemImageType()); - validateSolveImageNotRegisteredToday(problemId, imageType); ProblemImageData imageData = ProblemImageData.from( new ProblemImageDataRegisterDto(problemId, item.imageUrl(), imageType)); imageData.updateProblem(problem); @@ -536,25 +532,6 @@ public void addImageDataUrls(Long problemId, Long userId, AddProblemImageUrlsReq } } - private void validateSolveImageNotRegisteredToday(Long problemId, ProblemImageType imageType) { - if (imageType != ProblemImageType.SOLVE_IMAGE) { - return; - } - - LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul")); - LocalTime startOfDay = LocalTime.MIN; - LocalTime endOfDay = LocalTime.MAX; - boolean alreadyRegistered = problemImageDataRepository.existsByProblemIdAndProblemImageTypeAndCreatedAtBetween( - problemId, - ProblemImageType.SOLVE_IMAGE, - today.atTime(startOfDay), - today.atTime(endOfDay) - ); - if (alreadyRegistered) { - throw new ApplicationException(ProblemErrorCase.PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED); - } - } - /** * GPT 문제 분석 요청 (RabbitMQ 방식) * - 이미지 유무 확인 후 상태 업데이트 diff --git a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java index 7afdd06c..ea37634e 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/integration/ProblemApiIntegrationTest.java @@ -410,8 +410,8 @@ void emptyCollectionsAreAccepted() throws Exception { } @Test - @DisplayName("SOLVE_IMAGE 는 하루에 한 번만 등록된다") - void solveImageIsOncePerDay() throws Exception { + @DisplayName("SOLVE_IMAGE 는 하루에 여러 번 등록할 수 있다") + void solveImageHasNoDailyLimit() throws Exception { Long problemId = registerProblem("복습 기록", ownerRoot.getId()); mockMvc.perform(post("/api/problems/{id}/imageData/urls", problemId) @@ -426,12 +426,12 @@ void solveImageIsOncePerDay() throws Exception { .content(json(new AddProblemImageUrlsRequest(List.of( new AddProblemImageUrlsRequest.ImageUrlItem("https://s3/s2.png", "SOLVE_IMAGE") ))))) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode").value(4003)); + .andExpect(status().isOk()); assertThat(problemImageDataRepository.findAllByProblemId(problemId)) + .as("하루에 두 번 복습했으면 사진도 두 장 남는다. 중복 보상은 미션 로그 쪽에서 막는다") .filteredOn(image -> image.getProblemImageType() == ProblemImageType.SOLVE_IMAGE) - .hasSize(1); + .hasSize(2); } } } diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java index d085a6a7..e365b6b1 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceEdgeCaseTest.java @@ -13,6 +13,8 @@ import com.aisip.OnO.backend.problem.entity.Problem; import com.aisip.OnO.backend.problem.entity.ProblemImageType; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.repository.MissionLogRepository; import com.aisip.OnO.backend.problem.support.ProblemTestSupport; import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; import com.aisip.OnO.backend.problemsolve.entity.ProblemSolve; @@ -58,6 +60,9 @@ class ProblemServiceEdgeCaseTest extends ProblemTestSupport { @Autowired private ProblemSolveRepository problemSolveRepository; + @Autowired + private MissionLogRepository missionLogRepository; + private User owner; private Folder ownerRoot; @@ -248,23 +253,25 @@ void ignoresNullImageTypes() { } @Test - @DisplayName("풀이 이미지를 올리면 저장되고, 같은 날 두 번째는 거절된다") - void solveImageIsLimitedToOncePerDay() { + @DisplayName("풀이 이미지는 같은 날 여러 번 올릴 수 있고, 보상은 첫 번째만 들어간다") + void solveImageHasNoDailyLimitButRewardIsOncePerDay() { given(fileUploadService.uploadFileToS3(any())).willReturn("https://s3/solve.png"); Problem problem = saveProblem(owner.getId(), ownerRoot); + problemService.uploadProblemImages(problem.getId(), owner.getId(), + List.of(image), List.of(ProblemImageType.SOLVE_IMAGE.name())); problemService.uploadProblemImages(problem.getId(), owner.getId(), List.of(image), List.of(ProblemImageType.SOLVE_IMAGE.name())); assertThat(problemImageDataRepository.findAllByProblemId(problem.getId())) - .singleElement() - .satisfies(saved -> assertThat(saved.getProblemImageType()).isEqualTo(ProblemImageType.SOLVE_IMAGE)); + .as("하루에 두 번 복습했으면 사진도 두 장 남아야 한다") + .hasSize(2) + .allSatisfy(saved -> assertThat(saved.getProblemImageType()).isEqualTo(ProblemImageType.SOLVE_IMAGE)); - assertThatThrownBy(() -> problemService.uploadProblemImages(problem.getId(), owner.getId(), - List.of(image), List.of(ProblemImageType.SOLVE_IMAGE.name()))) - .isInstanceOf(ApplicationException.class) - .extracting(ProblemServiceEdgeCaseTest::errorCaseOf) - .isEqualTo(ProblemErrorCase.PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED); + assertThat(missionLogRepository.findAllByUserId(owner.getId())) + .as("사진은 여러 장 남아도 XP 는 하루 한 번만 들어간다") + .filteredOn(log -> log.getMissionType() == MissionType.PROBLEM_PRACTICE) + .hasSize(1); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java index f616bb7f..6a653f13 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/service/ProblemServiceTest.java @@ -1220,20 +1220,20 @@ void addsNothingForEmptyList() { } @Test - @DisplayName("같은 날 SOLVE_IMAGE 를 두 번 올리면 PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED") - void rejectsSecondSolveImageOnSameDay() { + @DisplayName("같은 날 SOLVE_IMAGE 를 두 번 올려도 둘 다 저장된다") + void allowsSecondSolveImageOnSameDay() { Problem problem = saveProblem(owner.getId(), ownerRoot); problemService.addImageDataUrls(problem.getId(), owner.getId(), new AddProblemImageUrlsRequest(List.of( new AddProblemImageUrlsRequest.ImageUrlItem("https://s3/s1.png", "SOLVE_IMAGE") ))); - assertThatThrownBy(() -> problemService.addImageDataUrls(problem.getId(), owner.getId(), - new AddProblemImageUrlsRequest(List.of( - new AddProblemImageUrlsRequest.ImageUrlItem("https://s3/s2.png", "SOLVE_IMAGE") - )))) - .isInstanceOf(ApplicationException.class) - .extracting(ProblemServiceTest::errorCaseOf) - .isEqualTo(ProblemErrorCase.PROBLEM_SOLVE_IMAGE_ALREADY_REGISTERED); + problemService.addImageDataUrls(problem.getId(), owner.getId(), new AddProblemImageUrlsRequest(List.of( + new AddProblemImageUrlsRequest.ImageUrlItem("https://s3/s2.png", "SOLVE_IMAGE") + ))); + + assertThat(problemImageDataRepository.findAllByProblemId(problem.getId())) + .as("하루에 여러 번 복습할 수 있어야 한다. 중복 보상은 미션 로그 쪽에서 막는다") + .hasSize(2); } @Test From d9b9d32e232e7a5e5ff167f45f58368fc5631758 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 12:59:28 +0900 Subject: [PATCH 104/130] =?UTF-8?q?[Fix]=20Quartz=20JobFactory=20=EA=B0=80?= =?UTF-8?q?=20ApplicationContext=20=EB=A5=BC=20=EB=B6=80=EB=AA=A8=EC=97=90?= =?UTF-8?q?=20=EB=84=98=EA=B8=B0=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20-=20Autow?= =?UTF-8?q?iringSpringBeanJobFactory.setApplicationContext=20=EA=B0=80=20?= =?UTF-8?q?=EC=9E=90=EA=B8=B0=20=ED=95=84=EB=93=9C=EB=A7=8C=20=EC=B1=84?= =?UTF-8?q?=EC=9A=B0=EA=B3=A0=20super=20=EB=A5=BC=20=EB=B6=80=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=95=84,=20SpringBeanJobFactory=20?= =?UTF-8?q?=EA=B0=80=20=EB=A6=AC=ED=94=8C=EB=A0=89=EC=85=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=EC=9E=90=20=EC=97=86=EB=8A=94=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9E=90=EB=A5=BC=20=EC=B0=BE=EC=95=98=EB=8B=A4=20-?= =?UTF-8?q?=20=EA=B7=B8=EB=9E=98=EC=84=9C=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EC=9D=84=20=EC=93=B0=EB=8A=94=20ChallengeNot?= =?UTF-8?q?ificationJob,=20StudyRoomWeeklyReportJob=20=EC=9D=80=20?= =?UTF-8?q?=EB=B0=9C=ED=99=94=ED=95=A0=20=EB=95=8C=EB=A7=88=EB=8B=A4=20NoS?= =?UTF-8?q?uchMethodException=20=EC=9C=BC=EB=A1=9C=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EA=B0=80=20ERR?= =?UTF-8?q?OR=20=EB=A1=9C=20=EB=82=A8=EC=95=98=EB=8B=A4=20-=20super=20?= =?UTF-8?q?=EB=A5=BC=20=EB=B6=88=EB=9F=AC=20createBean=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9E=A1=EC=9D=84=20=EB=A7=8C=EB=93=A4=EA=B2=8C=20?= =?UTF-8?q?=ED=96=88=EB=8B=A4.=20=EC=83=9D=EC=84=B1=EC=9E=90=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=EA=B3=BC=20@Autowired=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EC=9D=84=20=EB=AA=A8=EB=91=90=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EB=AF=80=EB=A1=9C=20=EB=94=B0=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=80=EB=A5=B4=EB=8D=98=20autowireBean=20=EC=9D=80=20?= =?UTF-8?q?=EB=BA=90=EB=8B=A4=20-=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EA=B0=80=20=EC=93=B0=EB=8A=94=20JobFactory=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=9C=ED=99=94=20=EB=95=8C=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?TriggerFiredBundle=20=EC=9D=84=20=EB=84=98=EA=B2=A8=20=EC=9D=B8?= =?UTF-8?q?=EC=8A=A4=ED=84=B4=EC=8A=A4=EB=A5=BC=20=EB=A7=8C=EB=93=9C?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EB=84=A3?= =?UTF-8?q?=EC=97=88=EB=8B=A4.=20=ED=95=84=EB=93=9C=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=EC=9D=84=20=EC=93=B0=EB=8A=94=20=EA=B8=B0=EC=A1=B4=20=EC=9E=A1?= =?UTF-8?q?=203=EA=B0=9C=EB=8F=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=EB=90=98=EB=8A=94=EC=A7=80=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EB=B3=B8=EB=8B=A4=20Closes=20#269?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AutowiringSpringBeanJobFactory.java | 24 +++--- .../quartz/QuartzJobRegistrationTest.java | 84 +++++++++++++++++++ 2 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java b/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java index 12323f66..9fe2ba45 100644 --- a/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/AutowiringSpringBeanJobFactory.java @@ -1,24 +1,22 @@ package com.aisip.OnO.backend.util.quartz; -import org.quartz.spi.TriggerFiredBundle; -import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.scheduling.quartz.SpringBeanJobFactory; public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { - private AutowireCapableBeanFactory beanFactory; - + /** + * 부모에 ApplicationContext 를 넘겨야 잡을 {@code createBean} 으로 만든다. + * + *

    예전에는 자기 필드만 채우고 부모를 부르지 않아, 부모가 리플렉션으로 인자 없는 생성자를 찾았다. + * 그래서 생성자 주입을 쓰는 잡(ChallengeNotificationJob, StudyRoomWeeklyReportJob)은 발화할 때마다 + * NoSuchMethodException 으로 실패하고 트리거가 ERROR 로 남았다. + * {@code createBean} 은 생성자 주입과 {@code @Autowired} 필드 주입을 모두 처리하므로 + * 따로 {@code autowireBean} 을 부르던 코드는 뺐다. + */ @Override public void setApplicationContext(ApplicationContext context) { - this.beanFactory = context.getAutowireCapableBeanFactory(); - } - - @Override - protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { - Object job = super.createJobInstance(bundle); - beanFactory.autowireBean(job); // ★ DI 핵심 - return job; + super.setApplicationContext(context); } -} \ No newline at end of file +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java index 92fc9079..eda43cc4 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java @@ -1,19 +1,32 @@ package com.aisip.OnO.backend.problem.quartz; +import com.aisip.OnO.backend.practicenote.service.PracticeNotificationJob; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderJob; +import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationJob; import com.aisip.OnO.backend.support.IntegrationTestSupport; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.quartz.CronTrigger; +import org.quartz.Job; +import org.quartz.JobBuilder; import org.quartz.JobDetail; import org.quartz.JobKey; import org.quartz.Scheduler; import org.quartz.SchedulerException; +import org.quartz.SimpleScheduleBuilder; import org.quartz.Trigger; +import org.quartz.TriggerBuilder; import org.quartz.TriggerKey; +import org.quartz.core.QuartzScheduler; +import org.quartz.spi.JobFactory; +import org.quartz.spi.OperableTrigger; +import org.quartz.spi.TriggerFiredBundle; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.Date; import java.util.List; import java.util.TimeZone; @@ -92,6 +105,77 @@ void triggerFiresEveryFiveMinutes() throws Exception { } } + /** + * 발화 시각이 되면 스케줄러는 자기 JobFactory 로 잡 인스턴스를 만든 뒤 execute 를 부른다. + * 잡을 빈으로 주입받아 execute 를 직접 부르는 테스트는 이 생성 단계를 건너뛰어, 생성자 주입 잡이 + * 인스턴스화에 실패해 트리거가 ERROR 로 바뀌는 문제를 잡지 못했다. + * + *

    스케줄러를 시작하면 등록된 잡이 실제로 돌기 때문에, 스케줄러가 쓰는 JobFactory 를 꺼내 + * 발화 때와 같은 {@link TriggerFiredBundle} 로 인스턴스만 만든다. execute 는 부르지 않는다. + */ + @Nested + @DisplayName("발화 시 잡 인스턴스 생성") + class JobInstantiation { + + private Job newJobLikeFiring(JobDetail jobDetail) throws SchedulerException { + QuartzScheduler quartzScheduler = (QuartzScheduler) ReflectionTestUtils.getField(scheduler, "sched"); + JobFactory jobFactory = quartzScheduler.getJobFactory(); + + OperableTrigger trigger = (OperableTrigger) TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withSchedule(SimpleScheduleBuilder.simpleSchedule()) + .build(); + Date now = new Date(); + TriggerFiredBundle bundle = new TriggerFiredBundle( + jobDetail, trigger, null, false, now, now, null, null); + + return jobFactory.newJob(bundle, scheduler); + } + + private JobDetail jobDetailOf(Class jobClass) { + return JobBuilder.newJob(jobClass).withIdentity("instantiation-test-" + jobClass.getSimpleName()).build(); + } + + @Test + @DisplayName("생성자 주입을 쓰는 챌린지 알림 잡도 의존성이 채워진 채로 만들어진다") + void challengeNotificationJob() throws Exception { + JobDetail jobDetail = JobBuilder.newJob(ChallengeNotificationJob.class) + .withIdentity("instantiation-test-challenge") + .usingJobData("roomId", "1") + .usingJobData("challengeTitle", "챌린지") + .usingJobData("notificationType", "HALFWAY") + .build(); + + Job job = newJobLikeFiring(jobDetail); + + assertThat(job).isInstanceOf(ChallengeNotificationJob.class); + assertThat(job).extracting("memberRepository").isNotNull(); + assertThat(job).extracting("fcmService").isNotNull(); + } + + @Test + @DisplayName("필드 주입을 쓰는 기존 잡들도 그대로 의존성이 채워진다") + void fieldInjectedJobs() throws Exception { + Job reviewDue = newJobLikeFiring(jobDetailOf(ReviewDueNotificationJob.class)); + assertThat(reviewDue).isInstanceOf(ReviewDueNotificationJob.class); + assertThat(AopTestUtils.getUltimateTargetObject(reviewDue)) + .extracting("problemRepository", "userRepository", "fcmService") + .doesNotContainNull(); + + Job reminder = newJobLikeFiring(jobDetailOf(ProblemReviewReminderJob.class)); + assertThat(reminder).isInstanceOf(ProblemReviewReminderJob.class); + assertThat(AopTestUtils.getUltimateTargetObject(reminder)) + .extracting("reminderService") + .isNotNull(); + + Job practice = newJobLikeFiring(jobDetailOf(PracticeNotificationJob.class)); + assertThat(practice).isInstanceOf(PracticeNotificationJob.class); + assertThat(AopTestUtils.getUltimateTargetObject(practice)) + .extracting("fcmService") + .isNotNull(); + } + } + @Nested @DisplayName("테스트 환경 안전장치") class TestEnvironmentGuard { From c5f437c0fb8c3423374a3611b29141a0c4478395 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 12:59:28 +0900 Subject: [PATCH 105/130] =?UTF-8?q?[Fix]=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EC=A3=BC=EA=B0=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EC=9E=A1=EC=9D=84=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC?= =?UTF-8?q?=EC=97=90=20=EB=93=B1=EB=A1=9D=ED=95=9C=EB=8B=A4=20-=20StudyRoo?= =?UTF-8?q?mScheduler=20=EB=8A=94=20JobDetail=20=EA=B3=BC=20Trigger=20?= =?UTF-8?q?=EB=A5=BC=20@Bean=20=EC=9C=BC=EB=A1=9C=20=EC=84=A0=EC=96=B8?= =?UTF-8?q?=EB=A7=8C=20=ED=96=88=EB=8A=94=EB=8D=B0,=20QuartzConfig=20?= =?UTF-8?q?=EA=B0=80=20SchedulerFactoryBean=20=EC=9D=84=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EB=A7=8C=EB=93=A4=EC=96=B4=20@Bean=20=EC=9D=84=20?= =?UTF-8?q?=EB=AA=A8=EC=95=84=20=EB=93=B1=EB=A1=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B5=AC=EC=84=B1=EC=9D=B4=20=EB=8F=8C?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EC=95=84=20=ED=95=9C=20=EB=B2=88=EB=8F=84?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=EB=90=98=EC=A7=80=20=EC=95=8A=EC=95=98?= =?UTF-8?q?=EB=8B=A4=20-=20ReviewDueNotificationScheduler=20=EC=99=80=20?= =?UTF-8?q?=EA=B0=99=EA=B2=8C=20@PostConstruct=20=EC=97=90=EC=84=9C=20addJ?= =?UTF-8?q?ob=20=ED=9B=84=20checkExists=20=EB=A1=9C=20scheduleJob=20?= =?UTF-8?q?=EC=9D=B4=EB=82=98=20rescheduleJob=20=EC=9D=84=20=EB=B6=80?= =?UTF-8?q?=EB=A5=B4=EB=8F=84=EB=A1=9D=20=EB=B0=94=EA=BF=A8=EB=8B=A4=20-?= =?UTF-8?q?=20QuartzConfig=20=EA=B0=80=20=EB=AA=A8=EB=93=A0=20@Bean=20Trig?= =?UTF-8?q?ger=20=EB=A5=BC=20=EB=B0=9B=EA=B2=8C=20=EB=B0=94=EA=BE=B8?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EB=B2=95=EC=9D=80=20=EC=93=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=98=EB=8B=A4.=20=EC=A7=80=EA=B8=88=20@Bean=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=8A=94=20=EC=9D=B4=EA=B2=83=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=BF=90=EC=9D=B4=EC=A7=80=EB=A7=8C,=20?= =?UTF-8?q?=EC=95=9E=EC=9C=BC=EB=A1=9C=20=EC=83=9D=EA=B8=B0=EB=8A=94=20@Be?= =?UTF-8?q?an=20=EA=B3=BC=20@PostConstruct=20=EB=93=B1=EB=A1=9D=EC=9D=B4?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=A0=20=EC=97=AC=EC=A7=80=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EC=A7=80=20=EC=95=8A=EC=9C=BC=EB=A0=A4=EA=B3=A0=20?= =?UTF-8?q?=EC=98=81=ED=96=A5=EC=9D=B4=20=EC=9D=B4=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EC=97=90=20=EB=A8=B8=EB=AC=B4=EB=8A=94=20=EC=AA=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A8=EB=9E=90=EB=8B=A4=20-=20=EC=9E=A1=EA=B3=BC=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=9D=B4=EB=A6=84,=20cron,=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=EB=8C=80=EB=8A=94=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=84=A0=EC=96=B8=EA=B3=BC=20=EA=B0=99=EB=8B=A4=20-=20QuartzJo?= =?UTF-8?q?bRegistrationTest=20=EC=97=90=20=EB=93=B1=EB=A1=9D=EA=B3=BC=20c?= =?UTF-8?q?ron=20=EA=B2=80=EC=A6=9D,=20=EB=93=B1=EB=A1=9D=EB=90=9C=20JobDe?= =?UTF-8?q?tail=20=EB=A1=9C=20=EC=9E=A1=20=EC=9D=B8=EC=8A=A4=ED=84=B4?= =?UTF-8?q?=EC=8A=A4=EB=A5=BC=20=EB=A7=8C=EB=93=9C=EB=8A=94=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4=20Clo?= =?UTF-8?q?ses=20#270?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/quartz/StudyRoomScheduler.java | 66 ++++++++++++++----- .../quartz/QuartzJobRegistrationTest.java | 42 ++++++++++++ 2 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java index 481efa2c..72fdbc51 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomScheduler.java @@ -1,27 +1,57 @@ package com.aisip.OnO.backend.studyroom.quartz; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.quartz.*; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.stereotype.Component; -@Configuration +import java.util.TimeZone; + +/** + * 스터디룸 주간 리포트 잡을 스케줄러에 등록한다. + * + *

    예전에는 JobDetail 과 Trigger 를 {@code @Bean} 으로 선언만 했다. {@code @Bean} 을 모아 등록하는 건 + * Spring Boot 자동 구성의 스케줄러인데, {@code QuartzConfig} 가 SchedulerFactoryBean 을 직접 만들어 + * 자동 구성이 물러나 한 번도 등록되지 않았다. 다른 스케줄러와 같은 방식으로 직접 등록한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor public class StudyRoomScheduler { - @Bean - public JobDetail studyRoomWeeklyReportJobDetail() { - return JobBuilder.newJob(StudyRoomWeeklyReportJob.class) - .withIdentity("studyRoomWeeklyReportJob") - .storeDurably() - .build(); - } + // 이름은 기존 @Bean 선언과 같게 두고 그룹은 지정하지 않는다(DEFAULT). + private static final String JOB_NAME = "studyRoomWeeklyReportJob"; + private static final String TRIGGER_NAME = "studyRoomWeeklyReportTrigger"; + private static final String CRON = "0 0 8 ? * MON"; - @Bean - public Trigger studyRoomWeeklyReportTrigger() { - return TriggerBuilder.newTrigger() - .forJob(studyRoomWeeklyReportJobDetail()) - .withIdentity("studyRoomWeeklyReportTrigger") - .withSchedule(CronScheduleBuilder.cronSchedule("0 0 8 ? * MON").inTimeZone(java.util.TimeZone.getTimeZone("Asia/Seoul"))) - .build(); - } + private final Scheduler scheduler; + + @PostConstruct + public void schedule() { + try { + JobDetail jobDetail = JobBuilder.newJob(StudyRoomWeeklyReportJob.class) + .withIdentity(JOB_NAME) + .storeDurably() + .build(); + Trigger trigger = TriggerBuilder.newTrigger() + .forJob(jobDetail) + .withIdentity(TRIGGER_NAME) + .withSchedule(CronScheduleBuilder.cronSchedule(CRON).inTimeZone(TimeZone.getTimeZone("Asia/Seoul"))) + .build(); + + scheduler.addJob(jobDetail, true); + + if (scheduler.checkExists(trigger.getKey())) { + scheduler.rescheduleJob(trigger.getKey(), trigger); + } else { + scheduler.scheduleJob(trigger); + } + + log.info("[StudyRoomWeeklyReport] 주간 리포트 스케줄 등록 완료 - cron: {} (Asia/Seoul)", CRON); + } catch (SchedulerException e) { + log.error("[StudyRoomWeeklyReport] 주간 리포트 스케줄 등록 실패", e); + } + } } diff --git a/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java b/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java index eda43cc4..f41db6e1 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/quartz/QuartzJobRegistrationTest.java @@ -3,6 +3,7 @@ import com.aisip.OnO.backend.practicenote.service.PracticeNotificationJob; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderJob; import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationJob; +import com.aisip.OnO.backend.studyroom.quartz.StudyRoomWeeklyReportJob; import com.aisip.OnO.backend.support.IntegrationTestSupport; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -105,6 +106,35 @@ void triggerFiresEveryFiveMinutes() throws Exception { } } + @Nested + @DisplayName("스터디룸 주간 리포트 잡") + class StudyRoomWeeklyReport { + + private final JobKey jobKey = JobKey.jobKey("studyRoomWeeklyReportJob"); + + @Test + @DisplayName("잡이 스케줄러에 등록돼 있다") + void jobIsRegistered() throws Exception { + assertThat(scheduler.checkExists(jobKey)) + .as("@Bean 으로 선언만 하면 커스텀 SchedulerFactoryBean 이 등록하지 않는다") + .isTrue(); + + JobDetail jobDetail = scheduler.getJobDetail(jobKey); + assertThat(jobDetail.getJobClass()).isEqualTo(StudyRoomWeeklyReportJob.class); + assertThat(jobDetail.isDurable()).isTrue(); + } + + @Test + @DisplayName("서울 시각 매주 월요일 오전 8시 크론 트리거가 붙어 있다") + void triggerFiresEveryMondayEightAmSeoul() throws Exception { + assertThat(scheduler.checkExists(TriggerKey.triggerKey("studyRoomWeeklyReportTrigger"))).isTrue(); + + CronTrigger trigger = cronTriggerOf(jobKey); + assertThat(trigger.getCronExpression()).isEqualTo("0 0 8 ? * MON"); + assertThat(trigger.getTimeZone()).isEqualTo(TimeZone.getTimeZone("Asia/Seoul")); + } + } + /** * 발화 시각이 되면 스케줄러는 자기 JobFactory 로 잡 인스턴스를 만든 뒤 execute 를 부른다. * 잡을 빈으로 주입받아 execute 를 직접 부르는 테스트는 이 생성 단계를 건너뛰어, 생성자 주입 잡이 @@ -153,6 +183,18 @@ void challengeNotificationJob() throws Exception { assertThat(job).extracting("fcmService").isNotNull(); } + @Test + @DisplayName("스케줄러에 등록된 주간 리포트 잡이 의존성이 채워진 채로 만들어진다") + void studyRoomWeeklyReportJob() throws Exception { + JobDetail registered = scheduler.getJobDetail(JobKey.jobKey("studyRoomWeeklyReportJob")); + assertThat(registered).as("등록돼 있어야 발화한다").isNotNull(); + + Job job = newJobLikeFiring(registered); + + assertThat(job).isInstanceOf(StudyRoomWeeklyReportJob.class); + assertThat(job).extracting("reportService").isNotNull(); + } + @Test @DisplayName("필드 주입을 쓰는 기존 잡들도 그대로 의존성이 채워진다") void fieldInjectedJobs() throws Exception { From 6b9dd29336241b98930ad6fada440bbda2e5f731 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 12:59:30 +0900 Subject: [PATCH 106/130] =?UTF-8?q?[Fix]=20=EB=B0=A9=EC=9E=A5=EC=9D=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=ED=95=98=EB=A9=B4=20?= =?UTF-8?q?=EB=82=A8=EC=9D=80=20=EB=A9=A4=EB=B2=84=EA=B0=80=20=EC=8A=A4?= =?UTF-8?q?=ED=84=B0=EB=94=94=EB=A3=B8=EC=9D=84=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=97=86=EB=8D=98=20=EA=B2=83=20-=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=EA=B0=80=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=A7=8C=20=EC=86=8C=ED=94=84=ED=8A=B8=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=ED=95=98=EA=B3=A0=20=EC=8A=A4=ED=84=B0=EB=94=94=EB=A3=B8=20?= =?UTF-8?q?=EB=A9=A4=EB=B2=84=20=ED=96=89=EA=B3=BC=20host=5Fuser=5Fid=20?= =?UTF-8?q?=EB=A5=BC=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=91=AC=EC=84=9C,?= =?UTF-8?q?=20=EB=B0=A9=EC=9E=A5=EC=9D=B4=20=ED=83=88=ED=87=B4=ED=95=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=9D=80=20=EB=82=A8=EC=9D=80=20=EB=A9=A4=EB=B2=84=20?= =?UTF-8?q?=EB=88=84=EA=B5=AC=EB=8F=84=20=EB=B0=A9=20=EC=88=98=EC=A0=95,?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C,=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9D=84=20=ED=95=A0=20=EC=88=98=20=EC=97=86?= =?UTF-8?q?=EC=97=88=EB=8B=A4=20-=20leaveRoom=20=EC=9D=98=20=EB=B0=A9?= =?UTF-8?q?=EC=9E=A5=20=EC=9C=84=EC=9E=84=EA=B3=BC=20=EB=B0=A9=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EB=A1=9C=EC=A7=81=EC=9D=84=20removeMembership=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=BD=91=EA=B3=A0,=20=ED=83=88=ED=87=B4?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A5=BC=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=97=90=20=EC=86=8D=ED=95=9C=20=EB=B0=A9?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EA=B0=99=EC=9D=80=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=82=98=EA=B0=80=EA=B2=8C=20=ED=96=88?= =?UTF-8?q?=EB=8B=A4=20-=20=EB=B0=A9=EC=9E=A5=EC=9D=B4=EB=A9=B4=20?= =?UTF-8?q?=EA=B0=80=EC=9E=A5=20=EB=A8=BC=EC=A0=80=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=A8=20=EB=A9=A4=EB=B2=84=EC=97=90=EA=B2=8C=20=EB=84=98?= =?UTF-8?q?=EA=B8=B0=EA=B3=A0=20=EB=82=A8=EC=9D=80=20=EB=A9=A4=EB=B2=84?= =?UTF-8?q?=EA=B0=80=20=EC=97=86=EC=9C=BC=EB=A9=B4=20=EB=B0=A9=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B4=EB=8B=A4.=20=EC=9D=BC=EB=B0=98=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=EB=8A=94=20=EB=A9=A4=EB=B2=84=20=ED=96=89=EB=A7=8C=20?= =?UTF-8?q?=EC=A7=80=EC=9B=8C=20=EC=B4=88=EB=8C=80=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=A0=95=EC=9B=90=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=A0=EC=A7=84=EB=8B=A4=20-=20=EB=B0=A9=EC=9E=A5=EC=9D=B8?= =?UTF-8?q?=20=EB=B0=A9=EC=9D=80=20=ED=96=89=20=EC=9E=A0=EA=B8=88=EC=9D=84?= =?UTF-8?q?=20=EC=9E=A1=EC=9C=BC=EB=AF=80=EB=A1=9C=20=EB=B0=A9=20id=20?= =?UTF-8?q?=EC=88=9C=EC=9C=BC=EB=A1=9C=20=EC=B2=98=EB=A6=AC=ED=95=B4=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=88=9C=EC=84=9C=EB=A5=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=ED=95=98=EA=B2=8C=20=EB=92=80=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=20=EC=97=86=EC=9D=B4=20=EC=86=8C=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=EB=A7=8C=20=EB=90=9C=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=ED=83=88=ED=87=B4=EC=9E=90=EB=8A=94=20user=20?= =?UTF-8?q?=EB=A5=BC=20join=20fetch=20=ED=95=98=EB=8A=94=20=EB=A9=A4?= =?UTF-8?q?=EB=B2=84=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=EB=8A=94=20?= =?UTF-8?q?=EB=B9=A0=EC=A7=80=EC=A7=80=EB=A7=8C=20=EC=A0=95=EC=9B=90=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=97=90=EB=8A=94=20=EB=82=A8=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=EB=8A=94=20=EA=B2=83=EC=9D=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A1=9C=20=EB=82=A8=EA=B2=BC=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../studyroom/service/StudyRoomService.java | 27 ++- .../OnO/backend/user/service/UserService.java | 4 + .../StudyRoomUserWithdrawalTest.java | 220 ++++++++++++++++++ .../backend/user/service/UserServiceTest.java | 19 ++ 4 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomUserWithdrawalTest.java diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java index 920be37f..e6cade67 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomService.java @@ -23,6 +23,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; +import java.util.Comparator; import java.util.List; import java.util.Locale; import java.util.Map; @@ -144,7 +145,31 @@ public StudyRoom lockRoom(Long roomId) { @Transactional public void leaveRoom(Long roomId, Long userId) { StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); - if (member.getRole() != StudyRoomMemberRole.HOST) { + removeMembership(roomId, userId, member.getRole()); + } + + /** + * 회원 탈퇴 시 그 사용자가 속한 모든 방에서 직접 나간 것과 같게 처리한다. + * + *

    {@code User} 는 소프트 삭제라 이 정리를 하지 않으면 멤버 행과 {@code host_user_id} 가 남는다. + * 방장이 탈퇴한 방은 방장 권한이 사라져 남은 멤버가 방을 관리할 수 없고, 탈퇴자 멤버 행은 + * 정원에도 계속 잡힌다. 사용자를 소프트 삭제하기 전에 같은 트랜잭션에서 호출해야 한다. + * + *

    방장인 방은 행 잠금을 잡으므로 방 id 순으로 돌아 잠금 순서를 일정하게 둔다. + */ + @Transactional + public void leaveAllRoomsForWithdrawal(Long userId) { + memberRepository.findAllWithRoomByUserId(userId).stream() + .sorted(Comparator.comparing(member -> member.getRoom().getId())) + .forEach(member -> removeMembership(member.getRoom().getId(), userId, member.getRole())); + } + + /** + * 멤버십 하나를 정리한다. 방장이면 가장 먼저 들어온 멤버에게 방장을 넘기고, + * 남은 멤버가 없으면 방을 지운다. + */ + private void removeMembership(Long roomId, Long userId, StudyRoomMemberRole role) { + if (role != StudyRoomMemberRole.HOST) { memberRepository.deleteByRoomIdAndUserId(roomId, userId); return; } diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 2c781a42..ba67b4b9 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -7,6 +7,7 @@ import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; import com.aisip.OnO.backend.user.entity.User; @@ -69,6 +70,8 @@ public class UserService { private final StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; + private final StudyRoomService studyRoomService; + private final FileUploadService fileUploadService; private final S3DeleteProducer s3DeleteProducer; @@ -248,6 +251,7 @@ public void deleteUserById(Long userId) { sharedProblemCommentReactionRepository.deleteByCommentAuthorId(userId); sharedProblemCommentReactionRepository.deleteByUserId(userId); sharedProblemCommentRepository.deleteByAuthorId(userId); + studyRoomService.leaveAllRoomsForWithdrawal(userId); practiceNoteService.deleteAllPracticesByUser(userId); reminderService.cancelAllByUser(userId); problemService.deleteAllUserProblems(userId); diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomUserWithdrawalTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomUserWithdrawalTest.java new file mode 100644 index 00000000..64026499 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomUserWithdrawalTest.java @@ -0,0 +1,220 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomDetailResponse; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.StudyRoomMemberResponse; +import com.aisip.OnO.backend.studyroom.entity.StudyRoom; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMember; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomMemberRole; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; +import com.aisip.OnO.backend.studyroom.support.StudyRoomTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import com.aisip.OnO.backend.user.service.UserService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 회원 탈퇴가 스터디룸 멤버십을 어떻게 정리하는지 본다. + * + *

    {@code User} 는 소프트 삭제라 탈퇴해도 {@code study_room_member} 행과 + * {@code study_room.host_user_id} 가 그대로 남는다. 방장이 탈퇴하면 방장 권한이 허공에 뜨고 + * 남은 멤버는 방 수정·삭제·챌린지 생성을 할 수 없게 된다. 탈퇴 경로가 방장이 직접 나갈 때와 + * 같은 규칙(가장 오래된 멤버에게 위임, 남은 멤버가 없으면 방 삭제)을 타는지 확인한다. + */ +@DisplayName("회원 탈퇴 시 스터디룸 정리") +class StudyRoomUserWithdrawalTest extends StudyRoomTestSupport { + + @Autowired + private UserService userService; + + @Autowired + private StudyRoomService studyRoomService; + + @Nested + @DisplayName("방장이 탈퇴하면") + class HostWithdraws { + + @Test + @DisplayName("가장 먼저 들어온 멤버가 방장이 되고 host_user_id 도 그 멤버로 바뀐다") + void delegatesToOldestMember() { + User host = fixtures.createUser("host"); + User older = fixtures.createUser("older"); + User newer = fixtures.createUser("newer"); + StudyRoom room = createRoom(host, "위임방"); + StudyRoomMember newerMember = addMember(room, newer); + StudyRoomMember olderMember = addMember(room, older); + // 저장 순서와 반대로 가입 시각을 둬서, 위임 기준이 id 가 아니라 가입 시각인지 확인한다. + setJoinedAt(newerMember, LocalDateTime.now().minusDays(1)); + setJoinedAt(olderMember, LocalDateTime.now().minusDays(3)); + + userService.deleteUserById(host.getId()); + + assertThat(roomRepository.findById(room.getId()).orElseThrow().getHostUserId()) + .as("앱은 hostUserId 로 방장을 판단한다") + .isEqualTo(older.getId()); + assertThat(roleOf(room, older)).as("가장 오래된 멤버의 역할").isEqualTo(StudyRoomMemberRole.HOST); + assertThat(roleOf(room, newer)).as("나중에 들어온 멤버의 역할").isEqualTo(StudyRoomMemberRole.MEMBER); + assertThat(memberRepository.existsByRoomIdAndUserId(room.getId(), host.getId())) + .as("탈퇴한 방장의 멤버십").isFalse(); + assertThat(memberRepository.countByRoomIdAndRole(room.getId(), StudyRoomMemberRole.HOST)) + .as("방장은 한 명").isEqualTo(1); + } + + @Test + @DisplayName("위임받은 멤버는 곧바로 방장 권한으로 방을 수정할 수 있다") + void delegatedHostCanManageRoom() throws Exception { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + + userService.deleteUserById(fixture.host().getId()); + + authenticateAs(fixture.member().getId()); + mockMvc.perform(patch("/api/study-room/{roomId}", fixture.roomId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"name\":\"새 방장이 바꾼 이름\"}")) + .andExpect(status().isOk()); + + assertThat(roomRepository.findById(fixture.roomId()).orElseThrow().getName()) + .isEqualTo("새 방장이 바꾼 이름"); + } + + @Test + @DisplayName("혼자 있던 방은 삭제된다") + void deletesRoomWhenAlone() { + User host = fixtures.createUser("host"); + StudyRoom room = createRoom(host, "혼자방"); + + userService.deleteUserById(host.getId()); + + assertThat(roomRepository.findById(room.getId())).as("삭제된 방").isEmpty(); + assertThat(memberRepository.countByRoomId(room.getId())).as("남은 멤버십").isZero(); + } + } + + @Nested + @DisplayName("일반 멤버가 탈퇴하면") + class MemberWithdraws { + + @Test + @DisplayName("자기 멤버십만 사라지고 방과 방장은 그대로다") + void removesOnlyOwnMembership() { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + + userService.deleteUserById(fixture.member().getId()); + + StudyRoom room = roomRepository.findById(fixture.roomId()).orElseThrow(); + assertThat(room.getHostUserId()).as("방장 유지").isEqualTo(fixture.host().getId()); + assertThat(roleOf(room, fixture.host())).isEqualTo(StudyRoomMemberRole.HOST); + assertThat(memberRepository.countByRoomId(fixture.roomId())) + .as("정원 계산에서 탈퇴자가 빠진다") + .isEqualTo(1); + } + } + + @Test + @DisplayName("여러 방에 속해 있으면 방마다 역할에 맞게 정리한다") + void cleansUpEveryRoom() { + User leaver = fixtures.createUser("leaver"); + User other = fixtures.createUser("other"); + StudyRoom hostedAlone = createRoom(leaver, "혼자 연 방"); + StudyRoom hostedShared = createRoom(leaver, "같이 연 방"); + addMember(hostedShared, other); + StudyRoom joined = createRoom(other, "들어간 방"); + addMember(joined, leaver); + + userService.deleteUserById(leaver.getId()); + + assertThat(roomRepository.findById(hostedAlone.getId())).as("혼자 연 방").isEmpty(); + assertThat(roomRepository.findById(hostedShared.getId()).orElseThrow().getHostUserId()) + .as("같이 연 방의 새 방장").isEqualTo(other.getId()); + assertThat(roomRepository.findById(joined.getId())).as("들어간 방").isPresent(); + assertThat(memberRepository.countByUserId(leaver.getId())).as("남은 멤버십").isZero(); + } + + @Test + @DisplayName("스터디룸에 속하지 않은 사용자의 탈퇴는 다른 방에 영향이 없다") + void doesNotTouchOtherRooms() { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + + userService.deleteUserById(fixture.outsider().getId()); + + assertThat(memberRepository.countByRoomId(fixture.roomId())).isEqualTo(2); + assertThat(roomRepository.findById(fixture.roomId()).orElseThrow().getHostUserId()) + .isEqualTo(fixture.host().getId()); + } + + /** + * 이 수정 전에 이미 탈퇴한 사용자(멤버 행이 남은 채 소프트 삭제된 사용자)가 조회에서 + * 어떻게 보이는지 기록해 둔다. 운영 데이터 정리 방안을 정하는 근거다. + */ + @Nested + @DisplayName("정리 없이 소프트 삭제만 된 기존 탈퇴자") + class LegacyWithdrawnMember { + + @Test + @DisplayName("user 를 join fetch 하는 멤버 목록에서는 빠지지만 정원 계산에는 남는다") + void hiddenFromMemberListButCounted() { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + softDeleteOnly(fixture.member()); + + StudyRoomDetailResponse detail = studyRoomService.getRoom(fixture.roomId(), fixture.host().getId()); + + assertThat(detail.members()).extracting(StudyRoomMemberResponse::userId) + .as("상세 멤버 목록") + .containsExactly(fixture.host().getId()); + assertThat(detail.memberCount()).as("상세 memberCount").isEqualTo(1); + assertThat(memberRepository.countByRoomId(fixture.roomId())) + .as("초대 코드 가입 정원 검사는 멤버 행을 그대로 센다") + .isEqualTo(2); + } + + @Test + @DisplayName("방장이 나갈 때 남아 있는 탈퇴자에게는 방장을 넘기지 않는다") + void leaveRoomSkipsWithdrawnCandidate() { + User host = fixtures.createUser("host"); + User ghost = fixtures.createUser("ghost"); + User alive = fixtures.createUser("alive"); + StudyRoom room = createRoom(host, "유령방"); + StudyRoomMember ghostMember = addMember(room, ghost); + StudyRoomMember aliveMember = addMember(room, alive); + setJoinedAt(ghostMember, LocalDateTime.now().minusDays(3)); + setJoinedAt(aliveMember, LocalDateTime.now().minusDays(1)); + softDeleteOnly(ghost); + + studyRoomService.leaveRoom(room.getId(), host.getId()); + + assertThat(roomRepository.findById(room.getId()).orElseThrow().getHostUserId()) + .as("가장 오래된 멤버 행은 탈퇴자지만 살아 있는 멤버가 방장이 된다") + .isEqualTo(alive.getId()); + } + } + + private StudyRoomMemberRole roleOf(StudyRoom room, User user) { + return memberRepository.findByRoomIdAndUserId(room.getId(), user.getId()) + .map(StudyRoomMember::getRole) + .orElseThrow(); + } + + private void setJoinedAt(StudyRoomMember member, LocalDateTime joinedAt) { + inTransaction(() -> entityManager.createNativeQuery( + "update study_room_member set created_at = :joinedAt where id = :id") + .setParameter("joinedAt", joinedAt) + .setParameter("id", member.getId()) + .executeUpdate()); + } + + /** 이번 수정 전의 탈퇴처럼 멤버십은 그대로 두고 사용자만 소프트 삭제한다. */ + private void softDeleteOnly(User user) { + inTransaction(() -> entityManager.createNativeQuery( + "update user set deleted_at = now() where id = :id") + .setParameter("id", user.getId()) + .executeUpdate()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 8110d33b..35d03593 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -7,6 +7,7 @@ import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; import com.aisip.OnO.backend.problem.service.ProblemService; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentReactionRepository; +import com.aisip.OnO.backend.studyroom.service.StudyRoomService; import com.aisip.OnO.backend.studyroom.repository.StudyRoomSharedProblemCommentRepository; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.dto.UserResponseDto; @@ -27,6 +28,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; @@ -44,6 +46,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.util.ReflectionTestUtils.setField; @@ -75,6 +78,8 @@ class UserServiceTest { @Mock private StudyRoomSharedProblemCommentReactionRepository sharedProblemCommentReactionRepository; @Mock + private StudyRoomService studyRoomService; + @Mock private FileUploadService fileUploadService; @Mock private S3DeleteProducer s3DeleteProducer; @@ -475,6 +480,7 @@ void deletesUserWithAllRelatedData() { verify(sharedProblemCommentReactionRepository).deleteByCommentAuthorId(USER_ID); verify(sharedProblemCommentReactionRepository).deleteByUserId(USER_ID); verify(sharedProblemCommentRepository).deleteByAuthorId(USER_ID); + verify(studyRoomService).leaveAllRoomsForWithdrawal(USER_ID); verify(practiceNoteService).deleteAllPracticesByUser(USER_ID); verify(reminderService).cancelAllByUser(USER_ID); verify(problemService).deleteAllUserProblems(USER_ID); @@ -482,6 +488,18 @@ void deletesUserWithAllRelatedData() { verify(userRepository).deleteById(USER_ID); } + @Test + @DisplayName("스터디룸 정리는 사용자를 소프트 삭제하기 전에 한다") + void leavesStudyRoomsBeforeSoftDelete() { + givenExistingUser(User.from(registerDto)); + + userService.deleteUserById(USER_ID); + + InOrder inOrder = inOrder(studyRoomService, userRepository); + inOrder.verify(studyRoomService).leaveAllRoomsForWithdrawal(USER_ID); + inOrder.verify(userRepository).deleteById(USER_ID); + } + @Test @DisplayName("없는 사용자를 탈퇴시키려 하면 3001 로 끝내고 아무것도 지우지 않는다") void rejectsUnknownUser() { @@ -490,6 +508,7 @@ void rejectsUnknownUser() { assertThatThrownBy(() -> userService.deleteUserById(404L)) .isInstanceOf(ApplicationException.class); verify(problemService, never()).deleteAllUserProblems(anyLong()); + verify(studyRoomService, never()).leaveAllRoomsForWithdrawal(anyLong()); verify(userRepository, never()).deleteById(anyLong()); } From 34701288597f28f246453036e2654ecd69960953 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:00:13 +0900 Subject: [PATCH 107/130] =?UTF-8?q?[Fix]=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EC=B0=BE=EC=A7=80=20?= =?UTF-8?q?=EB=AA=BB=ED=95=98=EB=A9=B4=20404=20=EB=8C=80=EC=8B=A0=20401=20?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=91=EB=8B=B5=ED=95=9C=EB=8B=A4=20-=20REFRESH?= =?UTF-8?q?=5FTOKEN=5FNOT=5FFOUND=20=EC=9D=98=20HTTP=20=EC=83=81=ED=83=9C?= =?UTF-8?q?=EB=A5=BC=20404=20=EC=97=90=EC=84=9C=20401=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B3=A0=20errorCode=201002=20=EB=8A=94=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=92=80=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=8A=A4=ED=86=A0=EC=96=B4=204.0.0=20=EC=9D=80=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20400,=20403=20?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20errorCode=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=20=EB=82=98=EB=A8=B8=EC=A7=80=EB=8A=94=20401?= =?UTF-8?q?=20=EC=97=AC=EB=B6=80=EB=A1=9C=EB=A7=8C=20=ED=8C=90=EC=A0=95?= =?UTF-8?q?=ED=95=B4=EC=84=9C,=20404=20=EB=A5=BC=20=EB=B0=9B=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EC=A7=80=EC=9A=B0?= =?UTF-8?q?=EC=A7=80=20=EB=AA=BB=ED=95=98=EA=B3=A0=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=EB=8F=84=20=EC=95=88=20=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=82=A8=EC=95=98=EB=8B=A4=20-?= =?UTF-8?q?=20=ED=94=84=EB=A1=A0=ED=8A=B8=20develop=20=EA=B3=BC=20fix/4.0.?= =?UTF-8?q?0=20=EC=9D=B4=ED=9B=84=20=EB=B9=8C=EB=93=9C=EB=8A=94=20errorCod?= =?UTF-8?q?e=201002=20=EB=A1=9C=20=ED=8C=90=EC=A0=95=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=20=EC=BD=94=EB=93=9C=EC=99=80=20=EB=AC=B4?= =?UTF-8?q?=EA=B4=80=ED=95=98=EA=B2=8C=20=EA=B0=99=EC=9D=80=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20-=20=EA=B0=B1=EC=8B=A0=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=9D=80=20HttpService=20=EB=A5=BC=20=EA=B1=B0?= =?UTF-8?q?=EC=B9=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EC=9D=B4=EB=9D=BC=20401=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EB=80=8C=EC=96=B4=EB=8F=84=20=EC=9E=AC=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EB=A3=A8=ED=94=84=EC=97=90=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20AuthApiT?= =?UTF-8?q?est=20=EC=9D=98=20404=20=EA=B8=B0=EB=8C=80=203=EA=B3=B3?= =?UTF-8?q?=EC=9D=84=20401=20=EB=A1=9C=20=EA=B3=A0=EC=B3=A4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #265 --- .../OnO/backend/auth/exception/AuthErrorCase.java | 2 +- .../OnO/backend/auth/controller/AuthApiTest.java | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java index f5e508d1..75f1ff6e 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/auth/exception/AuthErrorCase.java @@ -10,7 +10,7 @@ public enum AuthErrorCase implements ErrorCase { INVALID_REFRESH_TOKEN(400, 1001, "유효하지 않은 리프레시토큰입니다."), - REFRESH_TOKEN_NOT_FOUND(404, 1002, "리프레시 토큰 정보를 찾을 수 없습니다."), + REFRESH_TOKEN_NOT_FOUND(401, 1002, "리프레시 토큰 정보를 찾을 수 없습니다."), INVALID_AUTHORITY(400, 1003, "유효하지 않은 권한입니다."), diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java index 1814abcb..d7d45ba0 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java @@ -360,24 +360,28 @@ void keepsAuthorityAcrossRefresh() throws Exception { .isEqualTo(Authority.ROLE_GUEST); } + /** + * 1002 가 401 이어야 스토어 4.0.0 이 토큰을 지우고 로그인 화면으로 간다. + * 그 빌드는 400·403 일 때만 errorCode 를 보고, 그 밖에는 401 여부로만 판정한다(#265). + */ @Test - @DisplayName("회전된 옛 리프레시 토큰을 다시 쓰면 404 + 1002 로 거절한다") + @DisplayName("회전된 옛 리프레시 토큰을 다시 쓰면 401 + 1002 로 거절한다") void rejectsRotatedRefreshToken() throws Exception { String oldRefreshToken = signUpGuest().path("refreshToken").asText(); refresh(oldRefreshToken).andExpect(status().isOk()); refresh(oldRefreshToken) - .andExpect(status().isNotFound()) + .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); } @Test - @DisplayName("서명은 유효하지만 저장된 적 없는 리프레시 토큰은 404 + 1002 로 거절한다") + @DisplayName("서명은 유효하지만 저장된 적 없는 리프레시 토큰은 401 + 1002 로 거절한다") void rejectsUnknownRefreshToken() throws Exception { String neverStored = validTokenizer.createRefreshToken("1", Map.of("authority", Authority.ROLE_MEMBER)); refresh(neverStored) - .andExpect(status().isNotFound()) + .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); } @@ -450,7 +454,7 @@ void cannotRefreshAfterLogout() throws Exception { .andExpect(status().isOk()); refresh(tokens.path("refreshToken").asText()) - .andExpect(status().isNotFound()) + .andExpect(status().isUnauthorized()) .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND.getErrorCode())); } From 8c27b17351efea8724a0025e35f7bcc6df1a8246 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:03:47 +0900 Subject: [PATCH 108/130] =?UTF-8?q?[Fix]=20RabbitMQ=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=99=80=20DLQ=20=EC=95=8C=EB=A6=BC=EC=97=90=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=B2=98=EB=A6=AC=20=EC=8B=9C=EB=8F=84=20=ED=9A=9F?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=EB=8A=94=20=EB=B8=8C=EB=A1=9C?= =?UTF-8?q?=EC=BB=A4=20=EC=9E=AC=EC=A0=84=EB=8B=AC=EC=9D=B4=20=EC=95=84?= =?UTF-8?q?=EB=8B=88=EB=9D=BC=20=EB=A6=AC=EC=8A=A4=EB=84=88=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=EC=9D=98=20stateless=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EC=8A=A4=EB=A0=88=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A6=AC=EC=8A=A4=EB=84=88=EB=A5=BC=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B6=80=EB=A5=B4=EB=8A=94=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9D=B4=EB=9D=BC,=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=EC=9D=98=20retryCount=20=EB=8A=94=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B4=20=EA=B3=B3=EB=8F=84=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=EB=90=A0=20=EA=B3=B3=EB=8F=84=20=EC=97=86=EC=96=B4=20=ED=95=AD?= =?UTF-8?q?=EC=83=81=200=20=EC=9D=B4=EC=97=88=EB=8B=A4=20-=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=A4=91=20=EB=A1=9C=EA=B7=B8=EB=8A=94=20=EC=9E=AC?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=20=EC=9D=B8=ED=84=B0=EC=85=89=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EC=8A=A4=EB=A0=88=EB=93=9C=EC=97=90=20=EA=B1=B0?= =?UTF-8?q?=EB=8A=94=20RetryContext=20=EC=97=90=EC=84=9C=20=EC=9D=BD?= =?UTF-8?q?=EC=96=B4=20attempt:=201/3,=202/3,=203/3=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20-=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=EB=A5=BC=20=EC=86=8C=EC=A7=84=ED=95=9C=20=EA=B1=B0?= =?UTF-8?q?=EC=A0=88=20=EC=98=88=EC=99=B8=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=EC=97=90=20=EC=8B=A4=ED=8C=A8=20=ED=9A=9F=EC=88=98(attempts:?= =?UTF-8?q?=203/3)=EB=A5=BC=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20-=20DLQ=20?= =?UTF-8?q?=ED=95=B8=EB=93=A4=EB=9F=AC=EB=8A=94=20=EB=B8=8C=EB=A1=9C?= =?UTF-8?q?=EC=BB=A4=EA=B0=80=20dead-letter=20=ED=95=98=EB=A9=B4=EC=84=9C?= =?UTF-8?q?=20=EB=B6=99=EC=9D=B4=EB=8A=94=20x-death=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EB=A5=BC=20=EB=B0=9B=EC=95=84,=20rejected=20=EB=A9=B4=203?= =?UTF-8?q?=ED=9A=8C(=EC=97=AC=EB=9F=AC=20=EB=B2=88=20dead-letter=20?= =?UTF-8?q?=EB=90=90=EC=9C=BC=EB=A9=B4=20=EA=B7=B8=EB=A7=8C=ED=81=BC=20?= =?UTF-8?q?=EA=B3=B1=ED=95=9C=EB=8B=A4),=20TTL=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=EB=A9=B4=20=EC=95=8C=20=EC=88=98=20=EC=97=86=EC=9D=8C=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=95=8C=EB=A6=B0=EB=8B=A4=20-=20=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=20=EC=8B=9C=EB=8F=84=20=ED=9A=9F=EC=88=98=203=20?= =?UTF-8?q?=EC=9D=84=20RabbitRetryAttempts.MAX=5FATTEMPTS=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A1=9C=20=EB=AA=A8=EC=95=84=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84=20=EC=A0=95=EC=B1=85=EA=B3=BC=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=EA=B0=80=20=EA=B0=99=EC=9D=80=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EA=B2=8C=20=ED=96=88=EB=8B=A4=20-=20retry?= =?UTF-8?q?Count=20=ED=95=84=EB=93=9C=EB=8A=94=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=20JSON=20=EB=AA=A8=EC=96=91=EC=9D=84=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EC=A7=80=20=EC=95=8A=EC=9C=BC=EB=A0=A4=EA=B3=A0=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=EB=8A=94=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=EC=9D=84=20=EB=8B=AC=EC=95=98=EB=8B=A4.=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8D=98=20incrementRet?= =?UTF-8?q?ry()=20=EB=A7=8C=20=EC=A7=80=EC=9B=A0=EB=8B=A4=20-=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EC=85=89=ED=84=B0=EB=A1=9C=20=EC=84=B8=20=EB=B2=88=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EC=8B=9C=EC=BC=9C=20=EB=A1=9C=EA=B7=B8=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=ED=99=95=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=99=80,=20=EC=8B=A4=EC=A0=9C=20=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=EB=84=88=20=EC=96=B4=EB=8C=91=ED=84=B0=EB=A1=9C=20DLQ?= =?UTF-8?q?=20=EC=97=90=20x-death=20=ED=97=A4=EB=8D=94=EB=A5=BC=20?= =?UTF-8?q?=EB=84=A3=EC=96=B4=20=EC=95=8C=EB=A6=BC=20=EB=AC=B8=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=98=EB=8A=94=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #239 --- .../config/rabbitmq/RabbitMQConfig.java | 9 +- .../config/rabbitmq/RabbitRetryAttempts.java | 95 ++++++++++++ .../consumer/FcmNotificationConsumer.java | 28 ++-- .../consumer/ProblemAnalysisConsumer.java | 43 +++--- .../rabbitmq/consumer/S3DeleteConsumer.java | 31 ++-- .../message/FcmNotificationMessage.java | 10 +- .../message/ProblemAnalysisMessage.java | 12 +- .../rabbitmq/message/S3DeleteMessage.java | 12 +- .../rabbitmq/RabbitRetryAttemptsTest.java | 112 ++++++++++++++ .../consumer/FcmNotificationConsumerTest.java | 15 +- .../consumer/ProblemAnalysisConsumerTest.java | 17 ++- .../consumer/RabbitConsumerTestSupport.java | 28 ++-- .../RabbitMessageSerializationTest.java | 21 ++- .../consumer/S3DeleteConsumerTest.java | 140 ++++++++++++++++-- 14 files changed, 481 insertions(+), 92 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java create mode 100644 src/test/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttemptsTest.java diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java index 55774529..82072fc8 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitMQConfig.java @@ -90,12 +90,17 @@ public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory( // 컨테이너 아웃바운드 네트워크를 고갈시켰다. GPT 분석은 같은 상황에서 30분 동안 // OpenAI 를 다시 호출한다(비용·레이트리밋 직결). factory.setDefaultRequeueRejected(false); + // + // 이 재시도는 같은 스레드 안에서 리스너를 다시 부르는 방식이라 브로커 재전달이 아니다. + // 몇 번째 시도인지는 메시지 본문이 아니라 RabbitRetryAttempts 로 읽는다. factory.setAdviceChain(RetryInterceptorBuilder.stateless() - .maxAttempts(3) + .maxAttempts(RabbitRetryAttempts.MAX_ATTEMPTS) // 1초 → 2초 → 4초. 네트워크 순단이나 일시적 5xx 는 이 사이에 회복되고, // 영구적 실패(잘못된 입력, 4xx)는 4초 안에 포기하고 DLQ 로 넘어간다. .backOffOptions(1000, 2.0, 4000) - .recoverer(new org.springframework.amqp.rabbit.retry.RejectAndDontRequeueRecoverer()) + // 거절 예외 메시지에 실제로 실패한 시도 횟수를 남긴다. + .recoverer(new org.springframework.amqp.rabbit.retry.RejectAndDontRequeueRecoverer( + () -> "Retry Policy Exhausted - attempts: " + RabbitRetryAttempts.exhaustedAttempts())) .build()); return factory; diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java new file mode 100644 index 00000000..c99403de --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/RabbitRetryAttempts.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.config.rabbitmq; + +import org.springframework.retry.RetryContext; +import org.springframework.retry.support.RetrySynchronizationManager; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * RabbitMQ 컨슈머의 처리 시도 횟수를 구한다. + * + *

    재시도는 브로커 재전달이 아니라 리스너 컨테이너의 재시도 인터셉터({@link RabbitMQConfig} 의 advice chain)가 + * 같은 스레드 안에서 리스너를 다시 호출하는 방식이다. 그래서 메시지 본문은 첫 시도와 똑같고, + * 본문의 retryCount 를 올려도 다음 시도에 전달되지 않는다. 시도 횟수는 대신 아래에서 읽는다. + *

      + *
    • 처리 중: 재시도 인터셉터가 스레드에 걸어 두는 {@link RetryContext} 의 실패 횟수
    • + *
    • DLQ: 재시도를 소진한 메시지는 거절(reject)되어 브로커가 dead-letter 하면서 붙이는 {@code x-death} 헤더
    • + *
    + */ +public final class RabbitRetryAttempts { + + /** 첫 시도를 포함한 최대 시도 횟수. 리스너 컨테이너 재시도 정책과 DLQ 알림이 같은 값을 본다. */ + public static final int MAX_ATTEMPTS = 3; + + public static final String X_DEATH_HEADER = "x-death"; + + private static final String REJECTED = "rejected"; + private static final String EXPIRED = "expired"; + + private RabbitRetryAttempts() { + } + + /** + * 지금 처리 중인 시도가 몇 번째인지 돌려준다(1부터). + * 재시도 인터셉터 밖에서 호출되면(테스트의 직접 호출 등) 컨텍스트가 없으므로 1 로 본다. + */ + public static int currentAttempt() { + RetryContext context = RetrySynchronizationManager.getContext(); + return context == null ? 1 : context.getRetryCount() + 1; + } + + /** + * 재시도를 모두 소진한 시점(recoverer)에서 실패한 시도 횟수를 설명한다. + * recoverer 는 재시도 컨텍스트가 닫히기 전에 불리므로 실패 횟수가 그대로 남아 있다. + */ + static String exhaustedAttempts() { + RetryContext context = RetrySynchronizationManager.getContext(); + return context == null ? "unknown" : context.getRetryCount() + "/" + MAX_ATTEMPTS; + } + + /** + * DLQ 로 들어온 메시지가 원래 큐에서 몇 번 시도됐는지 {@code x-death} 헤더로 설명한다. + * + *

    {@code rejected} 는 재시도 인터셉터가 {@link #MAX_ATTEMPTS} 번을 모두 실패한 뒤 거절한 경우다. + * 같은 큐에서 여러 번 dead-letter 됐다면(DLQ 메시지를 원래 큐로 되돌려 다시 실패한 경우) count 만큼 곱한다. + * {@code expired} 는 큐 TTL 이 지나 처리 전에 빠진 경우라 시도 횟수를 알 수 없다. + * + * @param xDeath {@code x-death} 헤더 값. 헤더가 없으면 null + * @param originalQueue 메시지가 원래 들어 있던 큐 이름 + */ + public static String describeDeadLetter(List> xDeath, String originalQueue) { + if (xDeath == null || xDeath.isEmpty()) { + return "알 수 없음 (x-death 헤더 없음)"; + } + + long rejectedCount = 0; + String latestReason = null; + for (Map death : xDeath) { + if (death == null || !Objects.equals(originalQueue, String.valueOf(death.get("queue")))) { + continue; + } + String reason = String.valueOf(death.get("reason")); + // x-death 는 최근 dead-letter 가 앞에 온다. + if (latestReason == null) { + latestReason = reason; + } + if (REJECTED.equals(reason) && death.get("count") instanceof Number count) { + rejectedCount = count.longValue(); + } + } + + if (latestReason == null) { + return "알 수 없음 (" + originalQueue + " 의 x-death 기록 없음)"; + } + if (rejectedCount > 0) { + String repeated = rejectedCount > 1 ? ", DLQ 이동 " + rejectedCount + "번" : ""; + return (rejectedCount * MAX_ATTEMPTS) + "회 (재시도 소진" + repeated + ")"; + } + if (EXPIRED.equals(latestReason)) { + return "알 수 없음 (큐 TTL 만료로 이동)"; + } + return "알 수 없음 (사유: " + latestReason + ")"; + } +} diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java index 6123e122..426b82bc 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumer.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.FcmNotificationMessage; import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; @@ -15,9 +16,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; import java.util.List; +import java.util.Map; /** * FCM 푸시 알림 메시지 Consumer @@ -41,18 +44,18 @@ public class FcmNotificationConsumer { */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_QUEUE, concurrency = "5-15") public void handleNotificationMessage(FcmNotificationMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, userId: {}, messageRetryCount: {}", + log.info("RabbitMQ message received - queue: {}, operation: {}, userId: {}, attempt: {}/{}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", - message.getUserId(), message.getRetryCount()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); try { // 사용자의 모든 FCM 토큰 조회 List userFcmTokenList = fcmTokenRepository.findAllByUserId(message.getUserId()); if (userFcmTokenList.isEmpty()) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, userId: {}, attempt: {}/{}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "token_not_found", - message.getUserId(), message.getRetryCount()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; // 토큰 없으면 스킵 (정상 처리) } @@ -91,9 +94,9 @@ public void handleNotificationMessage(FcmNotificationMessage message) { message.getUserId(), successCount, failCount); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, userId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.FCM_NOTIFICATION_QUEUE, "fcm_notification", "failure", - message.getUserId(), message.getRetryCount(), e.getMessage()); + message.getUserId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("FCM 푸시 알림 전송 실패: " + message.getUserId(), e); @@ -137,20 +140,23 @@ private void sendToDevice(String token, FcmNotificationMessage message) throws F * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.FCM_NOTIFICATION_DLQ) - public void handleNotificationDLQ(FcmNotificationMessage message) { - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, userId: {}, messageRetryCount: {}", + public void handleNotificationDLQ(FcmNotificationMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.FCM_NOTIFICATION_QUEUE); + + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, userId: {}, attempts: {}", RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", "dlq", - message.getUserId(), message.getRetryCount()); + message.getUserId(), attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 FCM 푸시 알림 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**User ID:** %d\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**User ID:** %d\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. FCM 토큰을 확인하거나 수동으로 알림을 재전송해주세요.", RabbitMQConfig.FCM_NOTIFICATION_DLQ, "fcm_notification", message.getUserId(), - message.getRetryCount() + attempts ); try { diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java index 81ff30db..f9a8125b 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumer.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.ProblemAnalysisMessage; import com.aisip.OnO.backend.problem.exception.ProblemErrorCase; import com.aisip.OnO.backend.problem.service.ProblemAnalysisFailureService; @@ -11,8 +12,12 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; + /** * GPT 문제 분석 메시지 Consumer * - RabbitMQ에서 메시지를 받아 GPT 분석 처리 @@ -34,14 +39,15 @@ public class ProblemAnalysisConsumer { */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_QUEUE, concurrency = "1-2") public void handleAnalysisMessage(ProblemAnalysisMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", - RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, attempt: {}/{}", + RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", message.getProblemId(), + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); // problemId 없는 메시지는 재시도해도 조회 자체가 불가능하다. 재시도/DLQ 알림 없이 종료(ACK). if (message.getProblemId() == null) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, attempt: {}/{}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "problem_id_missing", - message.getRetryCount()); + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; } @@ -53,30 +59,30 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "success", message.getProblemId()); } catch (NonRetryableAnalysisException e) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, reason: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, reason: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "non_retryable_failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 상태는 Service에서 FAILED로 업데이트됨. 재큐잉 없이 종료(ACK) return; } catch (ApplicationException e) { if (e.getErrorCase() == ProblemErrorCase.PROBLEM_NOT_FOUND || e.getErrorCase() == ProblemErrorCase.PROBLEM_ANALYSIS_NOT_FOUND) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "resource_not_found", - message.getProblemId(), message.getRetryCount()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; } - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("GPT 문제 분석 실패: " + message.getProblemId(), e); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.GPT_ANALYSIS_QUEUE, "problem_analysis", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("GPT 문제 분석 실패: " + message.getProblemId(), e); @@ -89,20 +95,23 @@ public void handleAnalysisMessage(ProblemAnalysisMessage message) { * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.GPT_ANALYSIS_DLQ) - public void handleAnalysisDLQ(ProblemAnalysisMessage message) { - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + public void handleAnalysisDLQ(ProblemAnalysisMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.GPT_ANALYSIS_QUEUE); + + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, attempts: {}", RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", "dlq", - message.getProblemId(), message.getRetryCount()); + message.getProblemId(), attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 GPT 문제 분석 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. 문제를 확인하고 수동으로 재분석을 요청해주세요.", RabbitMQConfig.GPT_ANALYSIS_DLQ, "problem_analysis", message.getProblemId(), - message.getRetryCount() + attempts ); // DLQ 도달 = 모든 재시도 소진. PROCESSING 고착 방지를 위해 FAILED로 전이 diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java index 92c884ff..5bbd9e1e 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumer.java @@ -1,14 +1,19 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; import com.aisip.OnO.backend.config.rabbitmq.message.S3DeleteMessage; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.messaging.handler.annotation.Header; import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; + /** * S3 파일 삭제 메시지 Consumer * - RabbitMQ에서 메시지를 받아 S3 파일 삭제 처리 @@ -29,15 +34,16 @@ public class S3DeleteConsumer { */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_QUEUE, concurrency = "3-10") public void handleS3DeleteMessage(S3DeleteMessage message) { - log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, messageRetryCount: {}", - RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", message.getProblemId(), message.getRetryCount()); + log.info("RabbitMQ message received - queue: {}, operation: {}, problemId: {}, attempt: {}/{}", + RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", message.getProblemId(), + RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); // 삭제 대상 URL 이 없으면 재시도해도 영원히 실패한다(FileUploadService 가 NPE). // 재시도 소진 후 DLQ 알림까지 울리는 것을 막기 위해 여기서 정상 종료(ACK)한다. if (message.getImageUrl() == null || message.getImageUrl().isBlank()) { - log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}", + log.warn("RabbitMQ message skipped - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}", RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "image_url_missing", - message.getProblemId(), message.getRetryCount()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS); return; } @@ -49,9 +55,9 @@ public void handleS3DeleteMessage(S3DeleteMessage message) { RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "success", message.getProblemId()); } catch (Exception e) { - log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, messageRetryCount: {}, error: {}", + log.error("RabbitMQ message failed - queue: {}, operation: {}, outcome: {}, problemId: {}, attempt: {}/{}, error: {}", RabbitMQConfig.S3_DELETE_QUEUE, "s3_delete", "failure", - message.getProblemId(), message.getRetryCount(), e.getMessage()); + message.getProblemId(), RabbitRetryAttempts.currentAttempt(), RabbitRetryAttempts.MAX_ATTEMPTS, e.getMessage()); // 예외를 던지면 RabbitMQ가 자동으로 재시도 or DLQ로 전송 throw new RuntimeException("S3 파일 삭제 실패 - problemId: " + message.getProblemId(), e); @@ -64,23 +70,26 @@ public void handleS3DeleteMessage(S3DeleteMessage message) { * - Discord 알림 전송하여 관리자에게 수동 처리 요청 */ @RabbitListener(queues = RabbitMQConfig.S3_DELETE_DLQ) - public void handleS3DeleteDLQ(S3DeleteMessage message) { + public void handleS3DeleteDLQ(S3DeleteMessage message, + @Header(name = RabbitRetryAttempts.X_DEATH_HEADER, required = false) List> xDeath) { + String attempts = RabbitRetryAttempts.describeDeadLetter(xDeath, RabbitMQConfig.S3_DELETE_QUEUE); + String maskedObjectKey = maskS3ObjectKey(message.getImageUrl()); - log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, objectKey: {}, messageRetryCount: {}", + log.error("RabbitMQ message moved to DLQ - queue: {}, operation: {}, outcome: {}, problemId: {}, objectKey: {}, attempts: {}", RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", "dlq", - message.getProblemId(), maskedObjectKey, message.getRetryCount()); + message.getProblemId(), maskedObjectKey, attempts); // Discord 알림 전송 String errorTitle = String.format("🚨 S3 파일 삭제 최종 실패 (DLQ)"); String errorDetails = String.format( - "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Object Key:** %s\n**Message Retry Count:** %d\n\n" + + "**Queue:** %s\n**Operation:** %s\n**Problem ID:** %d\n**Object Key:** %s\n**Attempts:** %s\n\n" + "모든 재시도가 실패했습니다. 수동으로 S3에서 파일을 삭제하거나 메시지를 재처리해주세요.", RabbitMQConfig.S3_DELETE_DLQ, "s3_delete", message.getProblemId(), maskedObjectKey, - message.getRetryCount() + attempts ); try { diff --git a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java index c0e5bc82..dac0d282 100644 --- a/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java +++ b/src/main/java/com/aisip/OnO/backend/config/rabbitmq/message/FcmNotificationMessage.java @@ -19,6 +19,12 @@ public class FcmNotificationMessage implements Serializable { private String title; private String body; private Map data; + /** + * 사용하지 않는다. 항상 0 이다. + * 재시도는 리스너 컨테이너가 같은 메시지로 다시 호출하는 방식이라 본문 값을 올려도 다음 시도에 전달되지 않는다. + * 시도 횟수는 {@link com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts} 로 읽는다. + * 메시지 JSON 모양을 바꾸지 않으려고 남겨 둔다. 롤링 배포 중 구버전과 신버전이 섞여도 같은 모양을 주고받는다. + */ private int retryCount; public FcmNotificationMessage(Long userId, String title, String body, Map data) { @@ -28,8 +34,4 @@ public FcmNotificationMessage(Long userId, String title, String body, Map death(String queue, String reason, long count) { + return Map.of("queue", queue, "reason", reason, "count", count); + } + + @Nested + @DisplayName("처리 중 시도 횟수") + class CurrentAttempt { + + @Test + @DisplayName("재시도 인터셉터 밖에서 부르면 첫 시도로 본다") + void firstAttemptWithoutRetryContext() { + assertThat(RabbitRetryAttempts.currentAttempt()).isEqualTo(1); + } + + @Test + @DisplayName("재시도 템플릿 안에서는 실패할 때마다 1씩 올라가고, 소진 시점에는 실패 횟수를 돌려준다") + void countsAttemptsInsideRetryTemplate() { + RetryTemplate retryTemplate = RetryTemplate.builder() + .maxAttempts(RabbitRetryAttempts.MAX_ATTEMPTS) + .noBackoff() + .build(); + List seen = new ArrayList<>(); + List exhausted = new ArrayList<>(); + + assertThatThrownBy(() -> retryTemplate.execute( + context -> { + seen.add(RabbitRetryAttempts.currentAttempt()); + throw new IllegalStateException("fail"); + }, + context -> { + exhausted.add(RabbitRetryAttempts.exhaustedAttempts()); + throw new IllegalStateException("exhausted"); + })) + .hasMessage("exhausted"); + + assertThat(seen).containsExactly(1, 2, 3); + assertThat(exhausted).containsExactly("3/3"); + assertThat(RabbitRetryAttempts.currentAttempt()) + .as("재시도가 끝나면 컨텍스트가 치워져 다음 메시지에 섞이지 않는다") + .isEqualTo(1); + } + } + + @Nested + @DisplayName("DLQ 의 x-death 해석") + class DeadLetter { + + @Test + @DisplayName("재시도 소진으로 거절된 메시지는 최대 시도 횟수만큼 시도했다") + void rejectedOnce() { + assertThat(RabbitRetryAttempts.describeDeadLetter(List.of(death(QUEUE, "rejected", 1)), QUEUE)) + .isEqualTo("3회 (재시도 소진)"); + } + + @Test + @DisplayName("DLQ 에서 되돌려 다시 실패했다면 거절된 횟수만큼 곱한다") + void rejectedTwice() { + assertThat(RabbitRetryAttempts.describeDeadLetter(List.of(death(QUEUE, "rejected", 2)), QUEUE)) + .isEqualTo("6회 (재시도 소진, DLQ 이동 2번)"); + } + + @Test + @DisplayName("큐 TTL 만료로 빠진 메시지는 시도 횟수를 지어내지 않는다") + void expired() { + assertThat(RabbitRetryAttempts.describeDeadLetter(List.of(death(QUEUE, "expired", 1)), QUEUE)) + .startsWith("알 수 없음") + .contains("TTL"); + } + + @Test + @DisplayName("다른 큐의 기록은 무시하고 원래 큐의 기록만 본다") + void ignoresOtherQueues() { + List> xDeath = List.of( + death(RabbitMQConfig.S3_DELETE_DLQ, "expired", 1), + death(QUEUE, "rejected", 1)); + + assertThat(RabbitRetryAttempts.describeDeadLetter(xDeath, QUEUE)).isEqualTo("3회 (재시도 소진)"); + assertThat(RabbitRetryAttempts.describeDeadLetter(List.of(death("other.queue", "rejected", 1)), QUEUE)) + .startsWith("알 수 없음"); + } + + @Test + @DisplayName("x-death 헤더가 없으면 알 수 없음으로 남긴다") + void missingHeader() { + assertThat(RabbitRetryAttempts.describeDeadLetter(null, QUEUE)).startsWith("알 수 없음"); + assertThat(RabbitRetryAttempts.describeDeadLetter(List.of(), QUEUE)).startsWith("알 수 없음"); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumerTest.java b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumerTest.java index b0414ea8..b8f6c314 100644 --- a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumerTest.java +++ b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/FcmNotificationConsumerTest.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; +import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; import com.aisip.OnO.backend.config.rabbitmq.message.FcmNotificationMessage; import com.aisip.OnO.backend.user.entity.User; import com.google.firebase.messaging.FirebaseMessaging; @@ -341,7 +342,7 @@ class DeadLetter { @Test @DisplayName("최종 실패 메시지는 Discord 로 알린다") void notifiesDiscord() { - consumer.handleNotificationDLQ(fcmMessageWithRetryCount(owner.getId(), 3)); + consumer.handleNotificationDLQ(fcmMessage(owner.getId()), rejectedAfterRetries(RabbitMQConfig.FCM_NOTIFICATION_QUEUE)); ArgumentCaptor details = ArgumentCaptor.forClass(String.class); verify(discordWebhookNotificationService).sendErrorNotification( @@ -351,9 +352,9 @@ void notifiesDiscord() { anyString()); assertThat(details.getValue()) - .as("어느 사용자의 몇 번째 재시도가 최종 실패했는지 알 수 있어야 한다") - .contains(String.valueOf(owner.getId())) - .contains("3"); + .as("어느 사용자가 몇 번 시도한 끝에 최종 실패했는지 알 수 있어야 한다") + .contains("**User ID:** " + owner.getId()) + .contains("**Attempts:** 3회"); } @Test @@ -361,7 +362,7 @@ void notifiesDiscord() { void doesNotResendPush() { saveFcmToken(owner.getId(), uniqueToken("device")); - consumer.handleNotificationDLQ(fcmMessage(owner.getId())); + consumer.handleNotificationDLQ(fcmMessage(owner.getId()), rejectedAfterRetries(RabbitMQConfig.FCM_NOTIFICATION_QUEUE)); verifyNoInteractions(firebaseMessaging); } @@ -373,7 +374,7 @@ void swallowsDiscordFailure() { .given(discordWebhookNotificationService) .sendErrorNotification(anyString(), anyString(), anyString(), anyString()); - assertThatCode(() -> consumer.handleNotificationDLQ(fcmMessage(owner.getId()))) + assertThatCode(() -> consumer.handleNotificationDLQ(fcmMessage(owner.getId()), rejectedAfterRetries(RabbitMQConfig.FCM_NOTIFICATION_QUEUE))) .as("DLQ 에서 예외를 던지면 최종 실패 메시지마저 잃는다") .doesNotThrowAnyException(); } @@ -381,7 +382,7 @@ void swallowsDiscordFailure() { @Test @DisplayName("userId 가 null 인 메시지도 DLQ 알림을 만들 수 있다") void handlesNullUserId() { - assertThatCode(() -> consumer.handleNotificationDLQ(fcmMessage(null))) + assertThatCode(() -> consumer.handleNotificationDLQ(fcmMessage(null), rejectedAfterRetries(RabbitMQConfig.FCM_NOTIFICATION_QUEUE))) .doesNotThrowAnyException(); verify(discordWebhookNotificationService, times(1)) diff --git a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumerTest.java b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumerTest.java index 982d3b96..0f7dc323 100644 --- a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumerTest.java +++ b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/ProblemAnalysisConsumerTest.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; +import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.problem.entity.AnalysisStatus; import com.aisip.OnO.backend.problem.entity.Problem; @@ -249,7 +250,7 @@ void recoversOnRetry() { // 이미 예외를 던지도록 스텁된 목이라 given(...) 형태로 다시 스텁하면 스텁 도중 예외가 난다. willReturn(successResult()).given(openAIClient).analyzeImages(anyList()); - consumer.handleAnalysisMessage(analysisMessageWithRetryCount(problem.getId(), 1)); + consumer.handleAnalysisMessage(analysisMessage(problem.getId())); assertThat(statusOf(problem)) .as("FAILED 로 기록된 뒤에도 재시도 메시지는 정상 처리돼야 한다") @@ -296,14 +297,14 @@ class DeadLetter { void notifiesDiscord() { Problem problem = analyzableProblem(); - consumer.handleAnalysisDLQ(analysisMessageWithRetryCount(problem.getId(), 3)); + consumer.handleAnalysisDLQ(analysisMessage(problem.getId()), rejectedAfterRetries(RabbitMQConfig.GPT_ANALYSIS_QUEUE)); ArgumentCaptor details = ArgumentCaptor.forClass(String.class); verify(discordWebhookNotificationService).sendErrorNotification( eq("RabbitMQ DLQ - GPT Analysis"), details.capture(), eq("ERROR"), anyString()); assertThat(details.getValue()) - .contains(String.valueOf(problem.getId())) - .contains("3"); + .contains("**Problem ID:** " + problem.getId()) + .contains("**Attempts:** 3회"); } @Test @@ -316,7 +317,7 @@ void marksAnalysisFailed() { problemAnalysisRepository.save(analysis); }); - consumer.handleAnalysisDLQ(analysisMessage(problem.getId())); + consumer.handleAnalysisDLQ(analysisMessage(problem.getId()), rejectedAfterRetries(RabbitMQConfig.GPT_ANALYSIS_QUEUE)); ProblemAnalysis analysis = problemAnalysisRepository.findByProblemId(problem.getId()).orElseThrow(); assertThat(analysis.getStatus()).isEqualTo(AnalysisStatus.FAILED); @@ -328,7 +329,7 @@ void marksAnalysisFailed() { void doesNotReanalyze() { Problem problem = analyzableProblem(); - consumer.handleAnalysisDLQ(analysisMessage(problem.getId())); + consumer.handleAnalysisDLQ(analysisMessage(problem.getId()), rejectedAfterRetries(RabbitMQConfig.GPT_ANALYSIS_QUEUE)); verify(openAIClient, never()).analyzeImages(anyList()); } @@ -336,7 +337,7 @@ void doesNotReanalyze() { @Test @DisplayName("분석 레코드가 없어도 DLQ 알림은 나간다") void notifiesEvenWithoutAnalysisRow() { - assertThatCode(() -> consumer.handleAnalysisDLQ(analysisMessage(deletedProblemId()))) + assertThatCode(() -> consumer.handleAnalysisDLQ(analysisMessage(deletedProblemId()), rejectedAfterRetries(RabbitMQConfig.GPT_ANALYSIS_QUEUE))) .doesNotThrowAnyException(); verify(discordWebhookNotificationService) @@ -351,7 +352,7 @@ void swallowsDiscordFailure() { .given(discordWebhookNotificationService) .sendErrorNotification(anyString(), anyString(), anyString(), anyString()); - assertThatCode(() -> consumer.handleAnalysisDLQ(analysisMessage(problem.getId()))) + assertThatCode(() -> consumer.handleAnalysisDLQ(analysisMessage(problem.getId()), rejectedAfterRetries(RabbitMQConfig.GPT_ANALYSIS_QUEUE))) .doesNotThrowAnyException(); assertThat(statusOf(problem)) diff --git a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitConsumerTestSupport.java b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitConsumerTestSupport.java index 682855c9..810a2be3 100644 --- a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitConsumerTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitConsumerTestSupport.java @@ -21,6 +21,7 @@ import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicLong; @@ -66,25 +67,30 @@ protected FcmNotificationMessage fcmMessage(Long userId) { return fcmMessage(userId, Map.of("type", "review_due")); } - /** 재시도 횟수는 로그·DLQ 알림에만 쓰이므로 전체 생성자로 직접 세운다. */ - protected FcmNotificationMessage fcmMessageWithRetryCount(Long userId, int retryCount) { - return new FcmNotificationMessage(userId, "제목", "본문", Map.of("type", "review_due"), retryCount); - } - protected S3DeleteMessage s3Message(String imageUrl, Long problemId) { return new S3DeleteMessage(imageUrl, problemId); } - protected S3DeleteMessage s3MessageWithRetryCount(String imageUrl, Long problemId, int retryCount) { - return new S3DeleteMessage(imageUrl, problemId, retryCount); - } - protected ProblemAnalysisMessage analysisMessage(Long problemId) { return new ProblemAnalysisMessage(problemId); } - protected ProblemAnalysisMessage analysisMessageWithRetryCount(Long problemId, int retryCount) { - return new ProblemAnalysisMessage(problemId, retryCount); + /** + * 브로커가 dead-letter 하면서 붙이는 {@code x-death} 헤더 값. + * 실제 배달에서는 {@code DefaultMessagePropertiesConverter} 가 LongString 을 String 으로 바꿔 이 모양이 된다. + */ + protected static List> xDeath(String queue, String reason, long count) { + return List.of(Map.of( + "queue", queue, + "reason", reason, + "count", count, + "exchange", "", + "routing-keys", List.of(queue))); + } + + /** 재시도 인터셉터가 시도를 모두 소진하고 거절해 DLQ 로 한 번 넘어간 메시지의 헤더. */ + protected static List> rejectedAfterRetries(String queue) { + return xDeath(queue, "rejected", 1); } // ─────────────────────────── 픽스처 ─────────────────────────── diff --git a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitMessageSerializationTest.java b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitMessageSerializationTest.java index 4c720323..db1f73f2 100644 --- a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitMessageSerializationTest.java +++ b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/RabbitMessageSerializationTest.java @@ -61,7 +61,8 @@ void keepsFcmFields() { assertThat(restored.getTitle()).isEqualTo("제목"); assertThat(restored.getBody()).isEqualTo("본문"); assertThat(restored.getData()).containsEntry("type", "review_due"); - assertThat(restored.getRetryCount()).as("재시도 횟수는 DLQ 알림에 실린다").isEqualTo(2); + assertThat(restored.getRetryCount()) + .as("쓰지 않는 필드지만 메시지 모양을 바꾸지 않으려고 그대로 싣는다").isEqualTo(2); } @Test @@ -134,4 +135,22 @@ void acceptsEmptyAnalysisMessage() { assertThat(restored.getRetryCount()).isZero(); } } + + @Nested + @DisplayName("모르는 필드가 실린 메시지") + class UnknownFields { + + @Test + @DisplayName("컨슈머가 모르는 필드는 무시하고 역직렬화한다") + void ignoresUnknownField() { + S3DeleteMessage restored = fromJson( + "{\"imageUrl\":\"https://bucket.s3.amazonaws.com/a/b.png\",\"problemId\":11,\"retryCount\":0,\"attempt\":2}", + S3DeleteMessage.class); + + assertThat(restored.getImageUrl()) + .as("Spring AMQP 기본 컨버터는 FAIL_ON_UNKNOWN_PROPERTIES 를 끈 ObjectMapper 를 쓴다") + .isEqualTo("https://bucket.s3.amazonaws.com/a/b.png"); + assertThat(restored.getProblemId()).isEqualTo(11L); + } + } } diff --git a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumerTest.java b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumerTest.java index dd4f90bd..d8e39893 100644 --- a/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumerTest.java +++ b/src/test/java/com/aisip/OnO/backend/config/rabbitmq/consumer/S3DeleteConsumerTest.java @@ -1,10 +1,33 @@ package com.aisip.OnO.backend.config.rabbitmq.consumer; +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import com.aisip.OnO.backend.config.rabbitmq.RabbitMQConfig; +import com.aisip.OnO.backend.config.rabbitmq.RabbitRetryAttempts; +import com.aisip.OnO.backend.config.rabbitmq.message.S3DeleteMessage; +import com.rabbitmq.client.Channel; +import org.aopalliance.aop.Advice; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.slf4j.LoggerFactory; +import org.springframework.amqp.core.Message; +import org.springframework.amqp.core.MessageProperties; +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.rabbit.listener.AbstractMessageListenerContainer; +import org.springframework.amqp.rabbit.listener.RabbitListenerEndpointRegistry; +import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer; +import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.aop.framework.ProxyFactory; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -12,6 +35,7 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -90,7 +114,7 @@ void doesNotNotifyOnFirstFailure() { willThrow(new IllegalStateException("S3 timeout")) .given(fileUploadService).deleteImageFileFromS3(IMAGE_URL); - assertThatThrownBy(() -> consumer.handleS3DeleteMessage(s3MessageWithRetryCount(IMAGE_URL, 77L, 2))) + assertThatThrownBy(() -> consumer.handleS3DeleteMessage(s3Message(IMAGE_URL, 77L))) .isInstanceOf(RuntimeException.class); verifyNoInteractions(discordWebhookNotificationService); @@ -141,18 +165,18 @@ class DeadLetter { @Test @DisplayName("최종 실패 메시지는 Discord 로 알린다") void notifiesDiscord() { - consumer.handleS3DeleteDLQ(s3MessageWithRetryCount(IMAGE_URL, 77L, 3)); + consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE)); assertThat(capturedDlqDetails()) - .as("어떤 문제의 몇 번째 재시도가 최종 실패했는지 알 수 있어야 한다") - .contains("77") - .contains("3"); + .as("어떤 문제가 몇 번 시도한 끝에 최종 실패했는지 알 수 있어야 한다") + .contains("**Problem ID:** 77") + .contains("**Attempts:** 3회"); } @Test @DisplayName("DLQ 알림에 S3 객체 키 원문을 그대로 넣지 않는다") void masksObjectKey() { - consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L)); + consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE)); String details = capturedDlqDetails(); assertThat(details) @@ -166,7 +190,7 @@ void masksObjectKey() { @DisplayName("짧은 객체 키는 통째로 가린다") void masksShortObjectKeyCompletely() { consumer.handleS3DeleteDLQ( - s3Message("https://test-ono-bucket.s3.amazonaws.com/a.png", 77L)); + s3Message("https://test-ono-bucket.s3.amazonaws.com/a.png", 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE)); assertThat(capturedDlqDetails()) .as("앞뒤를 남기면 짧은 키는 사실상 노출된다") @@ -177,7 +201,7 @@ void masksShortObjectKeyCompletely() { @Test @DisplayName("imageUrl 이 null 이면 unknown 으로 알린다") void reportsUnknownForNullUrl() { - assertThatCode(() -> consumer.handleS3DeleteDLQ(s3Message(null, 77L))) + assertThatCode(() -> consumer.handleS3DeleteDLQ(s3Message(null, 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE))) .doesNotThrowAnyException(); assertThat(capturedDlqDetails()).contains("unknown"); @@ -186,7 +210,7 @@ void reportsUnknownForNullUrl() { @Test @DisplayName("DLQ 처리에서 S3 삭제를 다시 시도하지는 않는다") void doesNotRetryDeletion() { - consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L)); + consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE)); verifyNoInteractions(fileUploadService); } @@ -198,9 +222,105 @@ void swallowsDiscordFailure() { .given(discordWebhookNotificationService) .sendErrorNotification(anyString(), anyString(), anyString(), anyString()); - assertThatCode(() -> consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L))) + assertThatCode(() -> consumer.handleS3DeleteDLQ(s3Message(IMAGE_URL, 77L), rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE))) .as("DLQ 에서 예외를 던지면 최종 실패 기록마저 잃는다") .doesNotThrowAnyException(); } } + + // ════════════════════════════ 실제 재시도·DLQ 경로 ════════════════════════════ + + /** + * 컨슈머를 리스너 컨테이너가 실제로 쓰는 재시도 인터셉터와 리스너 어댑터로 감싸 돌린다. + * 위의 테스트들은 핸들러를 직접 부르므로 시도 횟수가 항상 첫 시도로 잡힌다. + */ + @Nested + @DisplayName("실제 재시도·DLQ 경로의 시도 횟수") + class Attempts { + + @Autowired + private SimpleRabbitListenerContainerFactory listenerContainerFactory; + + @Autowired + private RabbitListenerEndpointRegistry listenerEndpointRegistry; + + @Autowired + private MessageConverter messageConverter; + + /** 재시도 인터셉터는 (channel, message) 인자를 받는 리스너 호출을 감싼다. */ + interface ListenerInvoker { + void invoke(Channel channel, Message message); + } + + @Test + @DisplayName("세 번 실패하면 로그에 1/3, 2/3, 3/3 이 찍히고 거절 사유에 실패 횟수가 남는다") + void logsEachAttemptThroughRetryInterceptor() { + willThrow(new IllegalStateException("S3 timeout")) + .given(fileUploadService).deleteImageFileFromS3(IMAGE_URL); + + SimpleMessageListenerContainer container = listenerContainerFactory.createListenerContainer(); + Advice[] adviceChain = (Advice[]) ReflectionTestUtils.getField(container, "adviceChain"); + + ProxyFactory proxyFactory = new ProxyFactory(); + proxyFactory.addInterface(ListenerInvoker.class); + proxyFactory.setTarget((ListenerInvoker) (channel, message) -> + consumer.handleS3DeleteMessage(s3Message(IMAGE_URL, 77L))); + Arrays.stream(adviceChain).forEach(proxyFactory::addAdvice); + ListenerInvoker invoker = (ListenerInvoker) proxyFactory.getProxy(); + + Logger logger = (Logger) LoggerFactory.getLogger(S3DeleteConsumer.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + try { + Message amqpMessage = messageConverter.toMessage(s3Message(IMAGE_URL, 77L), new MessageProperties()); + + assertThatThrownBy(() -> invoker.invoke(mock(Channel.class), amqpMessage)) + .as("재시도를 소진하면 거절되어 DLQ 로 간다. 거절 사유에 실제 실패 횟수가 남는다") + .hasMessageContaining("attempts: 3/3"); + } finally { + logger.detachAppender(appender); + } + + List failures = appender.list.stream() + .filter(event -> event.getMessage().startsWith("RabbitMQ message failed")) + .map(ILoggingEvent::getFormattedMessage) + .toList(); + assertThat(failures).hasSize(RabbitRetryAttempts.MAX_ATTEMPTS); + assertThat(failures.get(0)).contains("attempt: 1/3"); + assertThat(failures.get(1)).contains("attempt: 2/3"); + assertThat(failures.get(2)).contains("attempt: 3/3"); + } + + @Test + @DisplayName("DLQ 리스너는 x-death 헤더를 받아 시도 횟수를 알린다") + void dlqListenerReadsXDeathHeader() throws Exception { + deliverToDlq(Map.of(RabbitRetryAttempts.X_DEATH_HEADER, rejectedAfterRetries(RabbitMQConfig.S3_DELETE_QUEUE))); + + assertThat(capturedDlqDetails()).contains("**Attempts:** 3회 (재시도 소진)"); + } + + @Test + @DisplayName("x-death 헤더가 없어도 DLQ 리스너는 알림을 보낸다") + void dlqListenerWithoutXDeathHeader() throws Exception { + deliverToDlq(Map.of()); + + assertThat(capturedDlqDetails()).contains("**Attempts:** 알 수 없음"); + } + + private void deliverToDlq(Map headers) throws Exception { + MessageProperties properties = new MessageProperties(); + headers.forEach(properties::setHeader); + Message amqpMessage = messageConverter.toMessage(new S3DeleteMessage(IMAGE_URL, 77L), properties); + + AbstractMessageListenerContainer dlqContainer = listenerEndpointRegistry.getListenerContainers().stream() + .map(AbstractMessageListenerContainer.class::cast) + .filter(container -> Arrays.asList(container.getQueueNames()).contains(RabbitMQConfig.S3_DELETE_DLQ)) + .findFirst() + .orElseThrow(); + + ((ChannelAwareMessageListener) dlqContainer.getMessageListener()) + .onMessage(amqpMessage, mock(Channel.class)); + } + } } From 8b830901c1ea5db0544fb60aea165f34cbb8b3c9 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:12:13 +0900 Subject: [PATCH 109/130] =?UTF-8?q?[Fix]=20FCM=20=ED=81=90=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EC=8B=A4=ED=8C=A8=EA=B0=80=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EC=9E=90=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=EC=8B=9C=ED=82=A4=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20-=20FcmService=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=A0=88=EB=B2=A8=20jakarta=20@Transactio?= =?UTF-8?q?nal=20=EC=9D=84=20=EA=B1=B7=EC=96=B4=EB=82=B4=EA=B3=A0=20regist?= =?UTF-8?q?erToken=20=EC=97=90=EB=A7=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EB=A0=88=EB=B2=A8=EB=A1=9C=20=EB=82=A8=EA=B2=BC=EB=8B=A4.=20?= =?UTF-8?q?=ED=81=90=20=EC=A0=81=EC=9E=AC=EA=B0=80=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=97=90=20=EC=B0=B8=EC=97=AC=ED=95=9C=20?= =?UTF-8?q?=EC=B1=84=20=EC=98=88=EC=99=B8=EB=A5=BC=20=EB=8D=98=EC=A7=80?= =?UTF-8?q?=EB=A9=B4=20=ED=98=B8=EC=B6=9C=EB=B6=80=EA=B0=80=20=EC=82=BC?= =?UTF-8?q?=EC=BC=9C=EB=8F=84=20=EB=B0=94=EA=B9=A5=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=EC=9D=B4=20rollback-only=20=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=8F=BC,=20=EB=B8=8C=EB=A1=9C=EC=BB=A4=20?= =?UTF-8?q?=EC=9E=A5=EC=95=A0=20=EC=A4=91=20=EC=8A=A4=ED=84=B0=EB=94=94?= =?UTF-8?q?=EB=A3=B8=20=EA=B3=B5=EC=9C=A0,=20=EB=B0=98=EC=9D=91,=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=EC=9D=B4=20500=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=81=9D=EB=82=98=EA=B3=A0=20=EC=A0=80=EC=9E=A5=EB=8F=84=20?= =?UTF-8?q?=EC=82=AC=EB=9D=BC=EC=A1=8C=EB=8B=A4=20-=20=EC=A0=81=EC=9E=AC?= =?UTF-8?q?=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20IllegalStateException=20?= =?UTF-8?q?=EC=9D=84=20=EB=8D=98=EC=A7=80=EB=8A=94=20=EB=8F=99=EC=9E=91(0d?= =?UTF-8?q?90fef)=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=91=AC?= =?UTF-8?q?=EC=84=9C=20=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=8D=94=EA=B0=80=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A5=BC=20FAILED=20=EB=A1=9C=20=EB=82=A8?= =?UTF-8?q?=EA=B8=B8=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=96=88=EB=8B=A4?= =?UTF-8?q?=20-=20ProblemReviewReminderSender=20=EB=A5=BC=20=EC=84=A0?= =?UTF-8?q?=EC=A0=90(REQUIRES=5FNEW),=20=ED=81=90=20=EC=A0=81=EC=9E=AC(?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EB=B0=96),=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EA=B8=B0=EB=A1=9D(REQUIRES=5FNEW)=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=82=98=EB=88=B4=EB=8B=A4.=20=EC=98=88=EC=A0=84?= =?UTF-8?q?=EC=97=90=EB=8A=94=20SENDING=20=EC=84=A0=EC=A0=90=EA=B3=BC=20FA?= =?UTF-8?q?ILED=20=EA=B8=B0=EB=A1=9D=EC=9D=B4=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=A1=A4=EB=B0=B1=EB=90=98=EA=B3=A0=20=EC=BB=A4=EB=B0=8B=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EA=B0=80=20=ED=8F=B4=EB=A7=81=20=EB=A3=A8?= =?UTF-8?q?=ED=94=84=EA=B9=8C=EC=A7=80=20=EC=98=AC=EB=9D=BC=EA=B0=80=20?= =?UTF-8?q?=EB=92=A4=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=93=A4=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=EB=90=98=EC=A7=80=20=EC=95=8A=EC=95=98?= =?UTF-8?q?=EB=8B=A4=20-=20send=20=EB=8A=94=20=EC=98=88=EC=99=B8=EB=A5=BC?= =?UTF-8?q?=20=EB=8D=98=EC=A7=80=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4.?= =?UTF-8?q?=20SENT=20=EA=B8=B0=EB=A1=9D=EC=97=90=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=ED=95=98=EB=A9=B4=20SENDING=20=EC=9C=BC=EB=A1=9C=20=EB=82=A8?= =?UTF-8?q?=EA=B2=A8=20stuck=20=EB=B3=B5=EA=B5=AC=EA=B0=80=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=ED=95=98=EA=B2=8C=20=ED=95=98=EA=B3=A0=20SCHEDULED=20?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=95=84=20=EC=A4=91=EB=B3=B5=20=EB=B0=9C=EC=86=A1=EC=9D=84=20?= =?UTF-8?q?=EB=A7=89=EC=95=98=EB=8B=A4=20-=20RabbitTemplate=20=EB=A7=8C=20?= =?UTF-8?q?=EB=AA=A9=EC=9C=BC=EB=A1=9C=20=EB=91=94=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?FcmService=20=EC=97=90=20=EC=8A=A4=ED=94=84=EB=A7=81=EA=B3=BC?= =?UTF-8?q?=20=EA=B0=99=EC=9D=80=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20?= =?UTF-8?q?=ED=94=84=EB=A1=9D=EC=8B=9C=EB=A5=BC=20=EC=94=8C=EC=9B=8C,=20?= =?UTF-8?q?=EB=B8=8C=EB=A1=9C=EC=BB=A4=20=EC=9E=A5=EC=95=A0=20=EC=A4=91=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0,=20=EB=B0=98=EC=9D=91,=20=EB=8C=93=EA=B8=80?= =?UTF-8?q?=EC=9D=B4=20=EC=BB=A4=EB=B0=8B=EB=90=98=EB=8A=94=EC=A7=80?= =?UTF-8?q?=EC=99=80=20=EB=A6=AC=EB=A7=88=EC=9D=B8=EB=8D=94=EA=B0=80=20FAI?= =?UTF-8?q?LED=20=EB=A5=BC=20=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A5=BC=20=EA=B3=84?= =?UTF-8?q?=EC=86=8D=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=EC=A7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #272 --- .../reminder/ProblemReviewReminderSender.java | 73 +++++++++++--- .../backend/util/fcm/service/FcmService.java | 5 +- .../ProblemReviewReminderSenderTest.java | 24 +++++ .../ProblemReviewReminderServiceTest.java | 28 ++++++ ...dyRoomNotificationBrokerOutageApiTest.java | 97 ++++++++++++++++++ .../util/fcm/service/FcmServiceTest.java | 25 +++++ .../util/fcm/support/BrokerOutageFcm.java | 98 +++++++++++++++++++ 7 files changed, 335 insertions(+), 15 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationBrokerOutageApiTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java index 9a6da3de..04ac13e7 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSender.java @@ -2,46 +2,91 @@ import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import static com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderStatus.*; -import static org.springframework.transaction.annotation.Propagation.REQUIRES_NEW; +/** + * 복습 알림 한 건을 선점하고 큐에 넣은 뒤 결과를 기록한다. + * + *

    선점, 큐 적재, 결과 기록을 한 트랜잭션에 묶으면 적재 예외가 트랜잭션을 rollback-only 로 만들어 + * SENDING 선점과 FAILED 기록이 함께 사라지고, 커밋 예외가 폴링 루프까지 올라가 뒤 사용자들이 발송되지 않았다. + * 그래서 선점과 결과 기록은 각각 짧은 새 트랜잭션으로 커밋하고, 큐 적재는 트랜잭션 밖에서 한다. + * 브로커 응답을 기다리는 동안 DB 커넥션을 잡고 있지도 않는다. + * + *

    {@link #send} 는 예외를 던지지 않는다. 한 건의 실패가 배치 전체를 멈추면 안 되기 때문이다. + */ @Slf4j @Component -@RequiredArgsConstructor public class ProblemReviewReminderSender { private final ProblemReviewReminderRepository repository; private final ProblemReviewReminderPolicy policy; private final FcmService fcmService; + private final TransactionTemplate newTransaction; + + public ProblemReviewReminderSender(ProblemReviewReminderRepository repository, + ProblemReviewReminderPolicy policy, + FcmService fcmService, + PlatformTransactionManager transactionManager) { + this.repository = repository; + this.policy = policy; + this.fcmService = fcmService; + this.newTransaction = new TransactionTemplate(transactionManager); + this.newTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + } - @Transactional(propagation = REQUIRES_NEW) public void send(ProblemReviewReminder candidate, LocalDateTime now) { Long userId = candidate.getUserId(); + Long reminderId = candidate.getId(); - int claimed = repository.tryUpdateStatus(candidate.getId(), SCHEDULED, SENDING, now); - if (claimed == 0) { - log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", candidate.getId()); + try { + Integer claimed = newTransaction.execute(status -> + repository.tryUpdateStatus(reminderId, SCHEDULED, SENDING, now)); + if (claimed == null || claimed == 0) { + log.warn("[ReviewReminder] 선점 실패 - reminderId: {}", reminderId); + return; + } + } catch (Exception e) { + log.error("[ReviewReminder] 선점 중 오류 - userId: {}, reminderId: {}", userId, reminderId, e); return; } try { NotificationRequestDto dto = policy.buildNotification(candidate); fcmService.sendNotificationToAllUserDevice(userId, dto); - repository.markSent(candidate.getId(), now, SENT); + } catch (Exception e) { + log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, reminderId, e); + recordFailure(userId, reminderId, e); + return; + } + + try { + newTransaction.executeWithoutResult(status -> repository.markSent(reminderId, now, SENT)); log.info("[ReviewReminder] 발송 완료 - userId: {}, reminderId: {}, sequence: {}", - userId, candidate.getId(), candidate.getSequence()); + userId, reminderId, candidate.getSequence()); + } catch (Exception e) { + // 이미 큐에 들어갔으므로 FAILED 로 덮지 않는다. SENDING 으로 남은 행은 stuck 복구가 정리하고, + // SCHEDULED 로 되돌리지 않으니 같은 알림이 다시 나가지도 않는다. + log.error("[ReviewReminder] 적재 후 SENT 기록 실패 - userId: {}, reminderId: {}", userId, reminderId, e); + } + } + + private void recordFailure(Long userId, Long reminderId, Exception cause) { + String errorMsg = cause.getMessage(); + if (errorMsg != null && errorMsg.length() > 500) errorMsg = errorMsg.substring(0, 500); + String truncated = errorMsg; + try { + newTransaction.executeWithoutResult(status -> repository.markFailed(reminderId, truncated, FAILED)); } catch (Exception e) { - String errorMsg = e.getMessage(); - if (errorMsg != null && errorMsg.length() > 500) errorMsg = errorMsg.substring(0, 500); - repository.markFailed(candidate.getId(), errorMsg, FAILED); - log.error("[ReviewReminder] 발송 실패 - userId: {}, reminderId: {}", userId, candidate.getId(), e); + // SENDING 으로 남은 행은 stuck 복구가 FAILED 로 넘긴다. + log.error("[ReviewReminder] FAILED 기록 실패 - userId: {}, reminderId: {}", userId, reminderId, e); } } } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index 7220f1ea..02770ac4 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -25,7 +25,6 @@ @Slf4j @Service @RequiredArgsConstructor -@Transactional public class FcmService { private final FcmTokenRepository fcmTokenRepository; @@ -35,6 +34,7 @@ public class FcmService { private final FcmNotificationProducer fcmNotificationProducer; + @Transactional public void registerToken(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { if(!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())){ FcmToken fcmToken = FcmToken.From(fcmTokenRequestDto, userId); @@ -80,6 +80,9 @@ public void sendNotification(NotificationRequestDto dto) { * 사용자의 모든 디바이스로 푸시 알림 전송 (RabbitMQ 비동기 방식) * - 기존 동기 방식에서 RabbitMQ 비동기 방식으로 변경 * - Quartz Job이나 API에서 호출 시 즉시 반환 + * - 큐 적재에 실패하면 예외를 그대로 던진다. 리마인더 발송기가 이 예외로 FAILED 를 기록한다. + * - 트랜잭션을 걸지 않는다. 트랜잭션 경계 안에서 적재 예외가 나면 호출자가 try/catch 로 삼켜도 + * 바깥 트랜잭션이 rollback-only 로 표시돼, 공유·반응·댓글 저장까지 되돌아가고 500 이 나갔다. */ public void sendNotificationToAllUserDevice(Long userId, NotificationRequestDto notificationRequestDto) { // RabbitMQ Producer로 메시지 전송 (비동기) diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java index 1884774e..a5d48856 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java @@ -3,11 +3,13 @@ import com.aisip.OnO.backend.problem.support.ProblemTestSupport; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.support.BrokerOutageFcm; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.PlatformTransactionManager; import java.time.LocalDateTime; import java.util.List; @@ -43,6 +45,9 @@ class ProblemReviewReminderSenderTest extends ProblemTestSupport { @Autowired private ProblemReviewReminderRepository reminderRepository; + @Autowired + private PlatformTransactionManager transactionManager; + private User user; @BeforeEach @@ -126,6 +131,25 @@ void recordsFailureWithoutMessage() { assertThat(failed.getRetryCount()).isEqualTo(1); assertThat(failed.getSentAt()).as("보내지 못했으므로 발송 시각은 없다").isNull(); } + + @Test + @DisplayName("브로커 장애로 큐 적재가 실패하면 FAILED 로 남고 예외가 새어 나가지 않는다") + void recordsFailureWhenBrokerIsDown() { + BrokerOutageFcm.create(transactionManager).routeFrom(fcmService); + ProblemReviewReminder reminder = saveReminder(LocalDateTime.now().minusHours(1)); + + assertThatCode(() -> sender.send(reminder, LocalDateTime.now())) + .as("적재 실패가 발송기 밖으로 나가면 폴링 루프가 멈춘다") + .doesNotThrowAnyException(); + + ProblemReviewReminder failed = reload(reminder.getId()); + assertThat(failed.getStatus()) + .as("선점(SENDING)과 실패 기록이 함께 롤백돼 SCHEDULED 로 돌아가면 안 된다") + .isEqualTo(FAILED); + assertThat(failed.getRetryCount()).isEqualTo(1); + assertThat(failed.getLastErrorMessage()).isEqualTo("RabbitMQ FCM notification enqueue failed"); + assertThat(failed.getSentAt()).isNull(); + } } @Nested diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java index 3fd9a12f..11cfabab 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java @@ -8,11 +8,13 @@ import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.aisip.OnO.backend.util.fcm.support.BrokerOutageFcm; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.PlatformTransactionManager; import java.time.LocalDateTime; import java.util.List; @@ -54,6 +56,9 @@ class ProblemReviewReminderServiceTest extends ProblemTestSupport { @Autowired private FcmTokenRepository fcmTokenRepository; + @Autowired + private PlatformTransactionManager transactionManager; + private User user; private Long userId; @@ -387,6 +392,29 @@ void sendsAtMostOncePerDay() { assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); } + @Test + @DisplayName("브로커 장애로 한 사용자의 적재가 실패해도 FAILED 로 남기고 다음 사용자를 계속 처리한다") + void continuesAfterBrokerFailure() { + BrokerOutageFcm brokerOutage = BrokerOutageFcm.create(transactionManager, 1); + brokerOutage.routeFrom(fcmService); + User otherUser = fixtures.createUser(); + giveFcmToken(userId, "token-1"); + giveFcmToken(otherUser.getId(), "token-2"); + ProblemReviewReminder mine = saveDueReminder(userId, 13001L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder others = saveDueReminder(otherUser.getId(), 13002L, LocalDateTime.now().minusMinutes(1)); + + service.sendDueReminders(LocalDateTime.now()); + + assertThat(brokerOutage.enqueueAttempts()) + .as("첫 적재가 실패해도 두 번째 사용자까지 적재를 시도해야 한다") + .isEqualTo(2); + assertThat(List.of( + reminderRepository.findById(mine.getId()).orElseThrow().getStatus(), + reminderRepository.findById(others.getId()).orElseThrow().getStatus())) + .as("실패한 한 건은 FAILED, 나머지는 SENT") + .containsExactlyInAnyOrder(FAILED, SENT); + } + @Test @DisplayName("due 가 여러 건이면 가장 오래된 것 하나만 보낸다") void sendsOnlyEarliestPerUser() { diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationBrokerOutageApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationBrokerOutageApiTest.java new file mode 100644 index 00000000..aa7c6815 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationBrokerOutageApiTest.java @@ -0,0 +1,97 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.ReactionToggleRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.SharedProblemCommentRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.SharedProblemCreateRequest; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import com.aisip.OnO.backend.studyroom.support.StudyRoomTestSupport; +import com.aisip.OnO.backend.util.fcm.support.BrokerOutageFcm; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.transaction.PlatformTransactionManager; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * RabbitMQ 브로커 장애 중 스터디룸 쓰기 요청. + * + *

    공유, 반응, 댓글은 저장 뒤 같은 트랜잭션 안에서 알림을 큐에 넣는다. 적재 실패는 호출부가 + * try/catch 로 삼키지만, 예전에는 {@code FcmService} 가 트랜잭션에 참여한 채 예외를 던져 바깥 + * 트랜잭션이 rollback-only 로 표시됐다. 그러면 커밋 단계에서 {@code UnexpectedRollbackException} 이 나 + * 500 이 응답되고 저장도 사라졌다. 알림은 곁다리 기능이라 본 작업을 되돌리면 안 된다. + */ +@DisplayName("스터디룸 알림 브로커 장애") +class StudyRoomNotificationBrokerOutageApiTest extends StudyRoomTestSupport { + + @Autowired + private PlatformTransactionManager transactionManager; + + private BrokerOutageFcm brokerOutage; + private RoomFixture fixture; + + @BeforeEach + void brokerDown() { + brokerOutage = BrokerOutageFcm.create(transactionManager); + brokerOutage.routeFrom(fcmService); + fixture = createRoomWithMemberAndOutsider(); + } + + @Test + @DisplayName("문제 공유는 알림 적재가 실패해도 201 로 끝나고 저장된다") + void shareProblemCommits() throws Exception { + Problem problem = saveProblem(fixture.member().getId()); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", fixture.roomId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problem.getId(), "같이 봐요")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.problemId").value(problem.getId())); + + assertThat(brokerOutage.enqueueAttempts()).as("방장에게 알림 적재를 시도했다").isEqualTo(1); + assertThat(sharedProblemRepository.findAll()).as("커밋된 공유 문제").hasSize(1); + } + + @Test + @DisplayName("반응은 알림 적재가 실패해도 200 으로 끝나고 저장된다") + void toggleReactionCommits() throws Exception { + StudyRoomSharedProblem shared = saveSharedProblem(fixture.room(), fixture.host(), + saveProblem(fixture.host().getId()), null); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems/{sharedProblemId}/reactions", + fixture.roomId(), shared.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reactions[0].count").value(1)); + + assertThat(brokerOutage.enqueueAttempts()).as("공유한 사람에게 알림 적재를 시도했다").isEqualTo(1); + assertThat(sharedProblemReactionRepository.findAll()).as("커밋된 반응").hasSize(1); + } + + @Test + @DisplayName("댓글은 알림 적재가 실패해도 201 로 끝나고 저장된다") + void createCommentCommits() throws Exception { + StudyRoomSharedProblem shared = saveSharedProblem(fixture.room(), fixture.host(), + saveProblem(fixture.host().getId()), null); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems/{sharedProblemId}/comments", + fixture.roomId(), shared.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest("좋은 문제네요")))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.content").value("좋은 문제네요")); + + assertThat(brokerOutage.enqueueAttempts()).as("공유한 사람에게 알림 적재를 시도했다").isEqualTo(1); + assertThat(commentRepository.findAll()).as("커밋된 댓글").hasSize(1); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java index 975e2cc6..d90f67e3 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; import java.util.List; import java.util.Map; @@ -31,6 +32,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -252,6 +254,29 @@ void doesNotLoadTokensWhenEnqueuing() { verify(fcmTokenRepository, never()).findAllByUserId(anyLong()); } + + @Test + @DisplayName("큐 적재 실패는 호출자에게 그대로 알린다 - 리마인더가 FAILED 로 기록할 수 있어야 한다") + void propagatesEnqueueFailure() { + IllegalStateException enqueueFailure = new IllegalStateException("RabbitMQ FCM notification enqueue failed"); + doThrow(enqueueFailure).when(fcmNotificationProducer) + .sendNotificationMessage(anyLong(), anyString(), anyString(), any()); + + assertThatThrownBy(() -> fcmService.sendNotificationToAllUserDevice(1L, notification(null))) + .isSameAs(enqueueFailure); + } + + @Test + @DisplayName("큐 적재는 트랜잭션에 참여하지 않는다 - 적재 실패가 호출자 트랜잭션을 rollback-only 로 만들면 안 된다") + void enqueueIsNotTransactional() throws Exception { + AnnotationTransactionAttributeSource source = new AnnotationTransactionAttributeSource(); + + assertThat(source.getTransactionAttribute( + FcmService.class.getMethod("sendNotificationToAllUserDevice", Long.class, NotificationRequestDto.class), + FcmService.class)) + .as("트랜잭션 경계를 지나며 런타임 예외가 나면 삼켜도 바깥 트랜잭션이 커밋되지 않는다") + .isNull(); + } } @Nested diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java b/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java new file mode 100644 index 00000000..252dbbf9 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java @@ -0,0 +1,98 @@ +package com.aisip.OnO.backend.util.fcm.support; + +import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import com.google.firebase.messaging.FirebaseMessaging; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.springframework.amqp.AmqpConnectException; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.TransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.net.ConnectException; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.willAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockingDetails; + +/** + * RabbitMQ 브로커가 내려간 상황의 {@link FcmService}. + * + *

    통합 테스트 컨텍스트의 {@code FcmService} 는 {@code @MockBean} 이라 트랜잭션 프록시를 거치지 않는다. + * 그래서 "적재 실패 예외가 FcmService 의 트랜잭션 경계를 지나며 호출자 트랜잭션을 rollback-only 로 + * 만든다"는 문제가 목으로는 드러나지 않았다. 여기서는 실제 {@code FcmService} 와 실제 Producer 를 만들고, + * 스프링이 빈에 씌우는 것과 같은 방식(애노테이션 기반 {@link TransactionInterceptor})으로 프록시를 씌운다. + * RabbitTemplate 만 목이라 브로커나 Firebase 로 아무것도 나가지 않는다. + * + *

    새 {@code @MockBean} 을 선언하면 컨텍스트가 갈라지므로, 기존 목이 이 프록시로 위임하게만 바꾼다. + */ +public final class BrokerOutageFcm { + + private final FcmService fcmService; + private final RabbitTemplate rabbitTemplate; + + private BrokerOutageFcm(FcmService fcmService, RabbitTemplate rabbitTemplate) { + this.fcmService = fcmService; + this.rabbitTemplate = rabbitTemplate; + } + + /** 브로커가 계속 내려가 있다. */ + public static BrokerOutageFcm create(TransactionManager transactionManager) { + return create(transactionManager, Integer.MAX_VALUE); + } + + /** + * 처음 {@code failures} 번의 적재만 실패하고 그 뒤로는 성공한다. + * 배치에서 한 건이 실패해도 다음 건이 이어지는지 볼 때 쓴다. + */ + public static BrokerOutageFcm create(TransactionManager transactionManager, int failures) { + AtomicInteger attempts = new AtomicInteger(); + RabbitTemplate rabbitTemplate = mock(RabbitTemplate.class, invocation -> { + if (invocation.getMethod().getName().equals("convertAndSend") + && attempts.incrementAndGet() <= failures) { + throw brokerDown(); + } + return null; + }); + FcmService target = new FcmService( + mock(FcmTokenRepository.class), + mock(FirebaseMessaging.class), + new SimpleMeterRegistry(), + new FcmNotificationProducer(rabbitTemplate)); + + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice(new TransactionInterceptor(transactionManager, new AnnotationTransactionAttributeSource())); + return new BrokerOutageFcm((FcmService) proxyFactory.getProxy(), rabbitTemplate); + } + + public static AmqpConnectException brokerDown() { + return new AmqpConnectException(new ConnectException("Connection refused")); + } + + /** 컨텍스트의 FcmService 목이 큐 적재를 이 프록시로 넘기게 한다. */ + public void routeFrom(FcmService contextMock) { + willAnswer(invocation -> { + fcmService.sendNotificationToAllUserDevice(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).given(contextMock).sendNotificationToAllUserDevice(anyLong(), any(NotificationRequestDto.class)); + } + + public FcmService fcmService() { + return fcmService; + } + + /** 성공, 실패와 상관없이 RabbitTemplate 로 적재를 시도한 횟수. */ + public long enqueueAttempts() { + return mockingDetails(rabbitTemplate).getInvocations().stream() + .filter(invocation -> invocation.getMethod().getName().equals("convertAndSend")) + .count(); + } +} From 3faee3bc123e22c3d1bfe77f7fb9b756456c8733 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:16:23 +0900 Subject: [PATCH 110/130] =?UTF-8?q?[Fix]=20=EC=9E=90=EB=8F=99=20=EC=A0=81?= =?UTF-8?q?=EB=A6=BD=EC=9D=B4=20=EB=8F=84=EB=8A=94=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EB=AF=B8=EC=85=98=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8F=84=EB=A5=BC=20=EC=98=AC=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4=20-=200d8b7ee=20=EB=8A=94=20?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A7=80=EA=B8=89=EB=A7=8C=20?= =?UTF-8?q?=EC=95=B1=20=EB=B2=84=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EA=B0=88?= =?UTF-8?q?=EB=9E=90=EA=B3=A0=20MissionProgressUpdater.increase=20?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EC=A0=84=EC=9D=84=20=EB=B3=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=84,=20=20=20=ED=97=A4=EB=8D=94=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=204.0.0=20=EC=97=90=EC=84=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A0=81=EB=A6=BD=EC=9D=84=20=EB=B0=9B=EC=9D=80=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=EC=9D=B4=20=EC=99=84=EB=A3=8C=EB=90=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=EB=8F=84=EB=A1=9C=20=EB=82=A8=EC=95=84=20=EC=83=88=20?= =?UTF-8?q?=EC=95=B1=EC=97=90=EC=84=9C=20=ED=95=9C=20=EB=B2=88=20=EB=8D=94?= =?UTF-8?q?=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88=EC=97=88=EB=8B=A4?= =?UTF-8?q?=20-=20missionCapableRequest=20=EC=99=80=20legacyAccrualEnabled?= =?UTF-8?q?,=20missionCapableVersion=20=EC=9D=84=20LegacyAccrualPolicy=20?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20=20=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=A0=81=EB=A6=BD=EA=B3=BC=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=20=EC=A6=9D=EA=B0=80=EA=B0=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=EB=B3=B4=EA=B2=8C=20=ED=96=88?= =?UTF-8?q?=EB=8B=A4.=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EA=B3=BC=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EC=9D=80=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=EB=8B=A4=20-=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=EB=8A=94=20increase=20=ED=95=9C=20=EA=B3=B3=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A7=89=EB=8A=94=EB=8B=A4.=20=ED=98=B8=EC=B6=9C?= =?UTF-8?q?=EB=B6=80(ProblemService,=20ProblemSolveService,=20MissionLogSe?= =?UTF-8?q?rvice)=EB=8A=94=20=EA=B1=B4=EB=93=9C=EB=A6=AC=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=95=98=EB=8B=A4=20-=20=EB=A7=89=EC=9D=84=20metric?= =?UTF-8?q?=20=EC=9D=80=20MissionMetric=20=EC=83=9D=EC=84=B1=EC=9E=90=20?= =?UTF-8?q?=EC=9D=B8=EC=9E=90=EB=A1=9C=20=EB=91=AC=20=EC=83=88=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=EC=9D=84=20=EB=84=A3=EC=9D=84=20=EB=95=8C=20=EB=B0=98?= =?UTF-8?q?=EB=93=9C=EC=8B=9C=20=EC=A0=95=ED=95=98=EA=B2=8C=20=ED=96=88?= =?UTF-8?q?=EB=8B=A4.=20=20=20LOGIN=5FDAY,=20PROBLEM=5FCREATED,=20SOLVE=5F?= =?UTF-8?q?RECORDED,=20SOLVE=5FCORRECT,=20PRACTICE=5FNOTE=5FCOMPLETED=20?= =?UTF-8?q?=EB=8A=94=20=EB=A7=89=EA=B3=A0=20MOOD=5FLOGGED=20=EB=8A=94=20?= =?UTF-8?q?=EC=A0=81=EB=A6=BD=EC=9D=B4=20=EC=97=86=EC=96=B4=20=EB=91=94?= =?UTF-8?q?=EB=8B=A4=20-=20HTTP=20=EC=9A=94=EC=B2=AD=20=EB=B0=96=EC=9D=80?= =?UTF-8?q?=20=ED=97=A4=EB=8D=94=20=EC=97=86=EC=9D=8C=EA=B3=BC=20=EA=B0=99?= =?UTF-8?q?=EA=B2=8C=20=EB=B3=B8=EB=8B=A4.=20=EC=A7=80=EA=B8=88=20increase?= =?UTF-8?q?=20=EB=8A=94=20=EC=A0=84=EB=B6=80=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=EC=97=90=EC=84=9C=20=EC=8B=9C=EC=9E=91=ED=95=98?= =?UTF-8?q?=EA=B3=A0,=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20=EC=9E=90=EB=8F=99=20=EC=A0=81=EB=A6=BD=EB=8F=84=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EB=8F=8C=EC=95=84=20=EB=91=98=20=EC=A4=91?= =?UTF-8?q?=20=ED=95=98=EB=82=98=EB=A7=8C=20=EB=8F=84=EB=8A=94=20=EA=B2=83?= =?UTF-8?q?=EC=9D=80=20=EC=9C=A0=EC=A7=80=EB=90=9C=EB=8B=A4=20-=20MissionR?= =?UTF-8?q?ewardGranter=20=EC=9D=98=20"=EB=91=90=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EA=B0=80=20=ED=95=A8=EA=BB=98=20=EB=8F=84=EB=8A=94=20=EA=B1=B4?= =?UTF-8?q?=20=EA=B3=A0=EC=9E=A5=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=8B=A4"?= =?UTF-8?q?=20=EC=99=80=20MissionLogService=20=EC=9D=98=20"=EB=AF=B8?= =?UTF-8?q?=EC=85=98=20=ED=99=94=EB=A9=B4=EC=9D=80=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?4.0.0=20=EC=95=B1=EC=97=90=20=EC=9E=88=EC=9C=BC=EB=8B=88"=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=84=20=EC=82=AC=EC=8B=A4=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EA=B2=8C=20=EA=B3=A0=EC=B3=A4=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=204=EC=A2=85=EA=B3=BC=20=EB=B9=84=EC=83=81?= =?UTF-8?q?=20=EC=8A=A4=EC=9C=84=EC=B9=98=202=EC=A2=85,=20=ED=99=9C?= =?UTF-8?q?=EB=8F=99=204=EC=A2=85=EC=9D=98=20=EC=A1=B0=ED=95=A9=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=81=EB=A6=BD=EA=B3=BC=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EB=8F=84=20=EC=A4=91=20=EC=A0=95=ED=99=95=ED=9E=88=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EB=A7=8C=20=EC=9B=80=EC=A7=81=EC=9D=B4=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20MissionXpSinglePathTest=20=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=96=88=EB=8B=A4=20-=20=EB=AF=B8=EC=85=98=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=B3=B8=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EC=83=88=20=EC=95=B1=EC=9C=BC=EB=A1=9C=20=EB=91=90=EA=B3=A0,?= =?UTF-8?q?=20MissionLegacyAccrualTest=20=EB=8A=94=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9A=94=EC=B2=AD=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=84=ED=96=89=EB=8F=84=EA=B0=80=20=EC=98=A4=EB=A5=B4?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EA=B2=83=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BF=A8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #267 --- .../backend/mission/entity/MissionMetric.java | 26 +- .../mission/service/LegacyAccrualPolicy.java | 85 ++++++ .../mission/service/MissionLogService.java | 68 +---- .../service/MissionProgressUpdater.java | 13 + .../mission/service/MissionRewardGranter.java | 7 +- .../service/MissionAccrualAppVersionTest.java | 19 +- .../service/MissionLegacyAccrualTest.java | 15 +- .../service/MissionXpSinglePathTest.java | 281 ++++++++++++++++++ .../support/MissionSystemTestSupport.java | 27 +- .../mission/support/MissionTestSupport.java | 45 ++- 10 files changed, 497 insertions(+), 89 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionXpSinglePathTest.java diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java index c7d3f892..55e06ab5 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionMetric.java @@ -13,26 +13,36 @@ public enum MissionMetric { /** 출석. 하루에 한 번만 오른다. */ - LOGIN_DAY(AbilityType.ATTENDANCE), + LOGIN_DAY(AbilityType.ATTENDANCE, true), /** 오답노트 등록. 여러 장을 한 번에 등록하면 장수만큼 오른다. */ - PROBLEM_CREATED(AbilityType.NOTE_WRITE), + PROBLEM_CREATED(AbilityType.NOTE_WRITE, true), /** 복습 기록. */ - SOLVE_RECORDED(AbilityType.PROBLEM_PRACTICE), + SOLVE_RECORDED(AbilityType.PROBLEM_PRACTICE, true), /** 복습 기록 중 정답. */ - SOLVE_CORRECT(AbilityType.PROBLEM_PRACTICE), + SOLVE_CORRECT(AbilityType.PROBLEM_PRACTICE, true), /** 복습 세트 완료. */ - PRACTICE_NOTE_COMPLETED(AbilityType.NOTE_PRACTICE), + PRACTICE_NOTE_COMPLETED(AbilityType.NOTE_PRACTICE, true), - /** 학습 달력 기분 저장. 하루에 한 번만 오른다. */ - MOOD_LOGGED(AbilityType.ATTENDANCE); + /** 학습 달력 기분 저장. 하루에 한 번만 오른다. 기분 저장은 자동 적립을 부르지 않는다. */ + MOOD_LOGGED(AbilityType.ATTENDANCE, false); private final AbilityType abilityType; - MissionMetric(AbilityType abilityType) { + /** + * 이 항목을 올리는 행동이 자동 적립({@code MissionLogService})도 함께 부르는가. + * + *

    {@code true} 인 항목은 자동 적립이 도는 요청에서 진행도를 올리지 않는다. 올리면 같은 행동으로 + * 적립 XP 와 미션 보상 XP 를 둘 다 받는다. 새 항목을 추가할 때 이 값을 반드시 정해야 하도록 + * 생성자 인자로 두었다. + */ + private final boolean alsoAccruedByLegacyPath; + + MissionMetric(AbilityType abilityType, boolean alsoAccruedByLegacyPath) { this.abilityType = abilityType; + this.alsoAccruedByLegacyPath = alsoAccruedByLegacyPath; } } diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java b/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java new file mode 100644 index 00000000..446df672 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/mission/service/LegacyAccrualPolicy.java @@ -0,0 +1,85 @@ +package com.aisip.OnO.backend.mission.service; + +import com.aisip.OnO.backend.common.web.AppVersion; +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 지금 요청에서 자동 적립이 도는가를 정하는 한 곳. + * + *

    XP 가 들어오는 길은 둘이다. 행동만으로 들어가는 자동 적립({@code MissionLogService})과, + * 미션 진행도를 채운 뒤 앱에서 받는 미션 보상({@code MissionService.claim})이다. + * 한 행동에는 둘 중 정확히 하나만 열려야 한다. 자동 적립이 도는 요청에서 진행도까지 완료되면 + * 그 사용자가 새 앱으로 미션 화면을 열었을 때 같은 행동으로 XP 를 한 번 더 받는다. + * + *

    그래서 자동 적립({@code MissionLogService.addPointToUser})과 진행도 증가 + * ({@link MissionProgressUpdater#increase})가 이 판정 하나를 같이 본다. 두 곳이 판정을 따로 들고 있으면 + * 한쪽만 고쳐졌을 때 다시 이중 지급이나 무지급이 생긴다. + * + *

    HTTP 요청이 없는 자리에서 불리면 자동 적립이 도는 쪽이다. 헤더가 없는 것과 같게 본다. + * 지금 두 곳을 부르는 경로는 전부 컨트롤러에서 시작하는 HTTP 요청 안이다(Quartz 잡, RabbitMQ 소비자, + * {@code @TransactionalEventListener}, {@code @Async} 어디에서도 부르지 않는다). 나중에 그런 경로가 생겨도 + * 두 곳이 같은 답을 받으므로 둘 중 하나만 도는 것은 유지된다. + */ +@Component +@RequiredArgsConstructor +public class LegacyAccrualPolicy { + + private final AppVersionResolver appVersionResolver; + + /** + * 행동만으로 경험치를 주던 예전 적립을 계속 쓸지. + * + *

    비상 스위치다. 꺼지면 앱 버전을 보지 않고 전부 끈다. 자동 적립 자체에 문제가 생겼을 때 + * 배포 없이 통째로 멈출 자리가 하나 있어야 한다. 꺼지면 진행도는 앱 버전과 무관하게 오른다. + * + *

    기본값이 켜짐이라 설정을 건드리지 않으면 버전 판정만 돈다. + */ + @Value("${ono.mission.legacy-accrual.enabled:true}") + private boolean legacyAccrualEnabled; + + /** + * 미션을 받을 수 있는 첫 앱 버전. 이 버전 이상에서 온 요청은 자동 적립을 끄고 진행도만 올린다. + * + *

    기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 앱은 + * 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 어디까지를 새 앱으로 볼지만 + * 가른다. 스토어에 나간 4.0.0 빌드에는 미션 화면도 헤더도 없고, 프론트에서 헤더를 붙인 커밋은 + * 미션 조회·받기 통신 계층이 들어간 뒤에 들어갔다. 그래서 헤더를 보내는 빌드는 전부 미션을 받을 수 있고, + * 기준을 더 높이면 그 빌드에서 이중 지급이 계속된다. + * + *

    설정값으로 둔 이유는 미션 화면이 빠지거나 받기가 고장 난 버전이 뒤늦게 드러났을 때 + * 배포 없이 기준을 올려 되돌리기 위해서다. + */ + @Value("${ono.mission.mission-capable-version:4.0.0}") + private String missionCapableVersion; + + /** + * 이번 요청에서 자동 적립이 도는가. {@code true} 면 자동 적립만, {@code false} 면 미션 진행도만 연다. + */ + public boolean accruesForCurrentRequest() { + // 비상 스위치가 먼저다. 꺼져 있으면 버전을 아예 보지 않는다. + if (!legacyAccrualEnabled) { + return false; + } + return !missionCapableRequest(); + } + + /** + * 이번 요청이 미션을 받을 수 있는 앱에서 왔는가. + * + *

    모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 구버전으로 잘못 보면 자동 적립으로 받고 진행도가 안 오를 뿐이지만, + * 반대로 틀리면 구버전 사용자는 받을 방법이 없어 XP 가 통째로 멈춘다. 덜 주는 쪽이 더 위험하다. + * + *

    기준값이 읽히지 않을 때도 같은 이유로 아무도 새 앱으로 보지 않는다. 설정 오타 하나가 + * 전체 사용자의 XP 유입을 끊는 것보다 새 앱 사용자가 자동 적립으로 받는 편이 낫다. + */ + private boolean missionCapableRequest() { + return AppVersion.parse(missionCapableVersion) + .flatMap(threshold -> appVersionResolver.resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } +} diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index edb283c4..39d6a27e 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -2,8 +2,6 @@ import com.aisip.OnO.backend.admin.dto.AdminPracticeLogResponseDto; import com.aisip.OnO.backend.common.exception.ApplicationException; -import com.aisip.OnO.backend.common.web.AppVersion; -import com.aisip.OnO.backend.common.web.AppVersionResolver; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; import com.aisip.OnO.backend.mission.entity.MissionMetric; @@ -25,7 +23,6 @@ import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -37,8 +34,7 @@ * 사용자의 행동 기록({@code mission_log})을 남긴다. * *

    자동 적립은 요청이 온 앱 버전에 따라 갈린다. 미션을 받을 수 있는 앱이면 끄고, 아니면 켠다. - * 기준은 {@code ono.mission.mission-capable-version} 이다. 그 위에 버전과 무관하게 전부 끄는 - * 비상 스위치로 {@code ono.mission.legacy-accrual.enabled} 가 있고, 이쪽이 우선한다. + * 판정은 {@link LegacyAccrualPolicy} 한 곳에 있고, 미션 진행도 증가도 같은 판정을 본다. * *

    가르는 이유는 켜져 있으면 같은 XP 가 두 경로로 들어오기 때문이다. 오답노트를 하나 쓰면 * 자동 적립 +10 이 조용히 들어가고 미션 "오늘의 오답"을 받으면 +10 이 또 들어가, @@ -50,9 +46,10 @@ * 통째로 끄면 구버전 사용자는 공부를 해도 XP 가 한 점도 안 쌓인다. 진행도만 쌓이고 레벨은 멈춘다. * 스토어 심사와 강제 업데이트 없이는 구버전이 한동안 남으므로, 요청마다 앱 버전을 보고 가른다. * - *

    꺼져도 기록은 그대로 남는다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. - * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정과 미션 진행도 증가도 설정과 무관하게 돈다. - * 꺼지는 것은 포인트 지급 하나뿐이다. + *

    어느 쪽이든 기록은 그대로 남는다. DAU·순 방문자·복습 로그 같은 관리자 통계가 전부 이 테이블을 읽는다. + * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정도 설정과 무관하게 돈다. + * 자동 적립이 도는 요청에서는 미션 진행도가 오르지 않는다. 같은 행동으로 적립과 미션 보상을 + * 둘 다 받지 않게 하기 위해서다. 진행도를 막는 것은 {@link MissionProgressUpdater} 가 한다. */ @Service @RequiredArgsConstructor @@ -67,33 +64,7 @@ public class MissionLogService { private final MissionProgressUpdater missionProgressUpdater; - private final AppVersionResolver appVersionResolver; - - /** - * 행동만으로 경험치를 주던 예전 적립을 계속 쓸지. - * - *

    비상 스위치다. 꺼지면 앱 버전을 보지 않고 전부 끈다. 자동 적립 자체에 문제가 생겼을 때 - * 배포 없이 통째로 멈출 자리가 하나 있어야 한다. - * - *

    기본값이 켜짐이라 설정을 건드리지 않으면 버전 판정만 돈다. - */ - @Value("${ono.mission.legacy-accrual.enabled:true}") - private boolean legacyAccrualEnabled; - - /** - * 미션을 받을 수 있는 첫 앱 버전. 이 버전 이상에서 온 요청은 자동 적립을 끈다. - * - *

    기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 - * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 앱은 - * 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 어디까지를 새 앱으로 볼지만 - * 가른다. 미션 화면은 이미 4.0.0 앱에 있으니(치장·미션 아이콘 에셋이 들어 있다) 기준을 더 낮출 이유가 없고, - * 더 높이면 헤더를 보내는 새 앱에서 이중 지급이 계속된다. - * - *

    설정값으로 둔 이유는 미션 화면이 빠지거나 받기가 고장 난 버전이 뒤늦게 드러났을 때 - * 배포 없이 기준을 올려 되돌리기 위해서다. - */ - @Value("${ono.mission.mission-capable-version:4.0.0}") - private String missionCapableVersion; + private final LegacyAccrualPolicy legacyAccrualPolicy; private static final Long DAILY_MISSION_POINT_LIMIT = 200L; @@ -223,19 +194,14 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { * 행동 자체에 대한 자동 적립. * *

    적립하지 않기로 하면 아무것도 하지 않는다. 이 메서드만 비는 것이지 호출부의 기록 저장과 - * 중복 방지 판정, 진행도 증가는 그대로 돈다. + * 중복 방지 판정은 그대로 돈다. 진행도 증가는 반대로 적립하는 요청에서만 막힌다. * *

    하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 * 따로 지급하고 상한을 타지 않는다. */ private Long addPointToUser(User user, MissionLog missionLog) { - // 비상 스위치가 먼저다. 꺼져 있으면 버전을 아예 보지 않는다. - if (!legacyAccrualEnabled) { - return 0L; - } - - // 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. - if (missionCapableRequest()) { + // 비상 스위치가 꺼졌거나 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. + if (!legacyAccrualPolicy.accruesForCurrentRequest()) { return 0L; } @@ -256,22 +222,6 @@ private Long addPointToUser(User user, MissionLog missionLog) { return 0L; } - /** - * 이번 요청이 미션을 받을 수 있는 앱에서 왔는가. - * - *

    모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 - * 자리에서 불렸으면 전부 구버전으로 본다. 자동 적립이 한 번 더 도는 것은 XP 를 조금 더 주는 일이지만, - * 반대로 틀리면 구버전 사용자는 받을 방법이 없어 XP 가 통째로 멈춘다. 덜 주는 쪽이 더 위험하다. - * - *

    기준값이 읽히지 않을 때도 같은 이유로 아무도 새 앱으로 보지 않는다. 설정 오타 하나가 - * 전체 사용자의 XP 유입을 끊는 것보다 이중 지급이 조금 더 도는 편이 낫다. - */ - private boolean missionCapableRequest() { - return AppVersion.parse(missionCapableVersion) - .flatMap(threshold -> appVersionResolver.resolve().map(requested -> requested.isAtLeast(threshold))) - .orElse(false); - } - @Transactional(readOnly = true) public List findAllByUserId(Long userId) { return missionLogRepository.findAllByUserId(userId); diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java index d7d53b6e..3bdc4d2c 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionProgressUpdater.java @@ -36,6 +36,13 @@ *

    덧붙여 이 방식은 "본 작업이 롤백되면 진행도도 남지 않는다"를 그대로 지킨다. * 하지도 않은 행동으로 보상을 받는 일은 없다. * + *

    자동 적립이 도는 요청에서는 올리지 않는다. 로그인, 오답노트 등록, 복습, 세트 완료는 + * 자동 적립도 함께 부른다. 헤더를 안 보내는 구버전 앱에서 한 활동이 자동 적립 XP 를 받으면서 진행도까지 + * 완료되면, 같은 사용자가 새 앱으로 미션 화면을 열었을 때 그 미션을 받아 XP 를 한 번 더 받는다. + * 판정은 자동 적립과 같은 {@link LegacyAccrualPolicy} 를 본다. 호출부마다 막지 않고 여기 한 곳에서 막는 이유는 + * 호출부가 여러 도메인에 흩어져 있어 하나라도 빠지면 이중 지급이 다시 열리기 때문이다. + * 자동 적립을 부르지 않는 항목({@link MissionMetric#MOOD_LOGGED})은 버전과 무관하게 오른다. + * *

    증가는 리포지토리의 upsert 한 문장으로만 한다. 여기서 조회한 뒤 값을 계산해 저장하면 * 같은 사용자의 요청이 겹칠 때 증가분이 사라진다. */ @@ -45,6 +52,7 @@ public class MissionProgressUpdater { private final MissionDefinitionRepository missionDefinitionRepository; private final MissionProgressRepository missionProgressRepository; + private final LegacyAccrualPolicy legacyAccrualPolicy; /** * 애노테이션이 여기에도 붙어 있어야 한다. 이 오버로드는 같은 빈의 3인자 메서드를 직접 부르는데, @@ -65,6 +73,11 @@ public void increase(Long userId, MissionMetric metric, int amount) { return; } + // 이 요청은 자동 적립으로 이미 XP 를 받는다. 진행도까지 채우면 나중에 미션으로 한 번 더 받는다. + if (metric.isAlsoAccruedByLegacyPath() && legacyAccrualPolicy.accruesForCurrentRequest()) { + return; + } + LocalDate today = MissionPeriodKey.today(); List definitions = missionDefinitionRepository.findAllByMetricAndActiveTrueOrderByIdAsc(metric); diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java index d9b85eeb..80436d34 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionRewardGranter.java @@ -19,8 +19,11 @@ * 있는 규칙이고, 미션 보상은 그 위에 얹는 별도 경로다. 상한 역할은 미션 설계가 대신한다. * 일일 6종을 다 받아도 75, 주간 4종을 다 받아도 360 이다. * - *

    자동 적립({@code ono.mission.legacy-accrual.enabled})을 끄면 여기가 XP 가 들어오는 유일한 경로가 된다. - * 켜져 있는 동안에는 두 경로가 함께 도는데, 그건 "더 받는" 것이라 고장이 아니다. + *

    자동 적립과 이 경로는 한 행동에 둘 중 하나만 열린다. 두 경로가 같은 행동으로 함께 돌면 + * 같은 XP 를 두 번 받는 이중 지급이다. 어느 쪽이 열릴지는 {@link LegacyAccrualPolicy} 가 요청마다 정한다. + * 자동 적립이 도는 요청에서는 진행도가 오르지 않아 받을 미션이 생기지 않고, 미션을 받을 수 있는 앱의 + * 요청이나 비상 스위치({@code ono.mission.legacy-accrual.enabled})가 꺼진 동안에는 여기가 XP 가 들어오는 + * 유일한 경로다. * *

    능력치 경험치 반영은 엔티티를 읽어 고치는 방식이라 같은 사용자의 동시 지급에 취약하다. * 기존 적립 경로와 같은 이유로 사용자 행을 배타 잠금으로 먼저 잡아 직렬화한다. diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java index 8f7c9169..2ec41008 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionAccrualAppVersionTest.java @@ -38,6 +38,9 @@ * *

    이 클래스의 주제는 어느 쪽으로 틀리는가다. 헤더가 없든, 값이 이상하든, HTTP 요청 자체가 없든 * 전부 구버전으로 떨어져 지금과 같이 적립된다. 덜 주는 것보다 더 주는 것이 낫다. + * + *

    구버전으로 떨어진 요청은 진행도를 올리지 않는다. 적립으로 받은 행동이 미션으로 한 번 더 받을 수 + * 있게 남으면 안 되기 때문이다. 조합 전체는 {@code MissionXpSinglePathTest} 가 본다. */ @DisplayName("앱 버전별 자동 적립") class MissionAccrualAppVersionTest extends MissionSystemTestSupport { @@ -65,7 +68,7 @@ void clearRequestContext() { @DisplayName("기준 버전은 설정으로 바꿀 수 있고 기본값은 4.0.0 이다") void thresholdIsConfigurable() throws NoSuchFieldException { // 코드에 박으면 기준을 되돌릴 때마다 배포해야 한다. 값이 아니라 선언을 본다. - Field field = MissionLogService.class.getDeclaredField("missionCapableVersion"); + Field field = LegacyAccrualPolicy.class.getDeclaredField("missionCapableVersion"); assertThat(field.getAnnotation(Value.class).value()) .as("프로퍼티 이름과 기본값은 운영에서 기준을 옮길 때 쓰는 계약이다") @@ -148,6 +151,9 @@ void accruesForLowerVersion() { missionLogService.registerLoginMission(user.getId()); assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)) + .as("적립으로 받은 출석이 새 앱에서 미션으로 한 번 더 받을 수 있게 남으면 안 된다") + .isZero(); } @Test @@ -159,6 +165,7 @@ void accruesWhenHeaderMissing() { missionLogService.registerLoginMission(user.getId()); assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isZero(); } @ParameterizedTest(name = "\"{0}\"") @@ -204,6 +211,9 @@ void accruesWithoutThrowing() { assertThatCode(() -> missionLogService.registerLoginMission(user.getId())) .doesNotThrowAnyException(); assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)) + .as("진행도도 같은 판정을 봐서 적립과 함께 돌지 않는다") + .isZero(); } } @@ -235,6 +245,9 @@ void noAccrualForLegacyApp() { missionLogService.registerLoginMission(user.getId()); assertThat(missionStatus().getTotalStudyPoint()).isZero(); + assertThat(currentOf(user.getId(), DAILY_ATTEND)) + .as("적립이 전부 꺼지면 진행도는 버전과 무관하게 오른다") + .isEqualTo(1); } @Test @@ -273,6 +286,7 @@ void treatsEveryoneAsLegacy() { missionLogService.registerLoginMission(user.getId()); assertThat(attendancePoints()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isZero(); } @Test @@ -304,7 +318,7 @@ void headerReachesServiceThroughFilterChain() throws Exception { .as("컨트롤러 → 서비스 사이에 버전을 인자로 넘기지 않아도 닿아야 한다") .isZero(); assertThat(currentOf(user.getId(), DAILY_ATTEND)) - .as("진행도는 버전과 무관하게 오른다") + .as("새 앱이면 진행도가 오른다") .isEqualTo(1); } @@ -317,6 +331,7 @@ void accruesWithoutHeader() { assertThat(attendancePoints()) .as("구버전 앱이 보내는 요청 그대로다") .isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(currentOf(user.getId(), DAILY_ATTEND)).isZero(); } private RequestPostProcessor asUser(User user) { diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java index 8bdf5771..d41f2091 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionLegacyAccrualTest.java @@ -22,7 +22,8 @@ * 프론트에 미션 화면이 나간 뒤 끄면 흡수가 끝난다. 되돌리려면 다시 켜면 된다. * *

    플래그가 무엇을 끄고 무엇을 끄지 않는지가 이 클래스의 주제다. - * 꺼지는 것은 포인트 지급 하나뿐이고, 기록과 중복 방지와 진행도는 양쪽에서 똑같이 돈다. + * 기록과 중복 방지는 양쪽에서 똑같이 돈다. 자동 적립과 진행도는 둘 중 하나만 돈다. + * 켜져 있으면 헤더 없는 요청은 적립만, 꺼져 있으면 진행도만 오른다. */ @DisplayName("자동 적립 플래그") class MissionLegacyAccrualTest extends MissionSystemTestSupport { @@ -32,6 +33,8 @@ class MissionLegacyAccrualTest extends MissionSystemTestSupport { @BeforeEach void setUpUser() { user = fixtures.createUser(); + // 헤더를 안 보내는 구버전 앱. 버전 판정은 MissionAccrualAppVersionTest 가 따로 본다. + requestFromApp(null); } @Test @@ -39,7 +42,7 @@ void setUpUser() { void defaultsToEnabled() throws NoSuchFieldException { // 기본값이 꺼짐이면 백엔드만 배포하는 순간 XP 유입이 통째로 멈춘다. // 필드 값이 아니라 선언을 본다. 테스트가 값을 바꿔 가며 도는데 값으로 확인하면 순환 논증이다. - Field field = MissionLogService.class.getDeclaredField("legacyAccrualEnabled"); + Field field = LegacyAccrualPolicy.class.getDeclaredField("legacyAccrualEnabled"); assertThat(field.getAnnotation(Value.class).value()) .as("프로퍼티 이름과 기본값은 운영에서 끌 때 쓰는 계약이다") @@ -56,16 +59,16 @@ void enable() { } @Test - @DisplayName("행동만으로 경험치가 들어오고 진행도도 함께 오른다") - void grantsPointAndRaisesProgress() { + @DisplayName("행동만으로 경험치가 들어오고 진행도는 오르지 않는다") + void grantsPointWithoutRaisingProgress() { missionLogService.registerLoginMission(user.getId()); assertThat(accumulatedPoints(status().getAttendanceLevel(), status().getAttendancePoint())) .as("출석 자동 적립") .isEqualTo(MissionType.USER_LOGIN.getPoint()); assertThat(currentOf(user.getId(), DAILY_ATTEND)) - .as("진행도는 플래그와 무관하게 오른다") - .isEqualTo(1); + .as("적립으로 받은 출석을 미션으로 한 번 더 받으면 이중 지급이다") + .isZero(); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionXpSinglePathTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionXpSinglePathTest.java new file mode 100644 index 00000000..6b4946c1 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionXpSinglePathTest.java @@ -0,0 +1,281 @@ +package com.aisip.OnO.backend.mission.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.learningcalendar.dto.LearningCalendarMoodRequestDto; +import com.aisip.OnO.backend.learningcalendar.service.LearningCalendarService; +import com.aisip.OnO.backend.mission.entity.MissionMetric; +import com.aisip.OnO.backend.mission.entity.MissionProgress; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import com.aisip.OnO.backend.user.entity.User; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.context.request.RequestContextHolder; + +/** + * 한 행동에 XP 가 들어오는 길은 정확히 하나다. + * + *

    자동 적립과 미션 진행도가 같은 요청에서 함께 돌면, 적립으로 받은 행동이 "완료됐지만 안 받은" 진행도로 남아 + * 새 앱에서 한 번 더 받을 수 있다(#267). 반대로 둘 다 막히면 그 행동으로는 XP 를 받을 길이 없다. + * + *

    그래서 요청 종류(헤더 없음, 구버전, 새 앱, HTTP 요청 밖) × 비상 스위치(켜짐, 꺼짐) 여덟 조합과 + * 적립을 함께 부르는 행동 네 가지를 전부 돌려 적립과 진행도 중 정확히 하나만 움직이는지 고정한다. + */ +@DisplayName("XP 경로는 하나뿐이다") +class MissionXpSinglePathTest extends MissionSystemTestSupport { + + @Autowired + private ProblemService problemService; + + @Autowired + private ProblemSolveService problemSolveService; + + @Autowired + private PracticeNoteService practiceNoteService; + + @Autowired + private LearningCalendarService learningCalendarService; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeNoteRepository practiceNoteRepository; + + private User user; + private Folder folder; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + folder = fixtures.createRootFolder(user.getId()); + } + + enum AppRequest { + NO_HEADER, LEGACY_APP, MISSION_CAPABLE_APP, OUTSIDE_HTTP_REQUEST + } + + enum Activity { + LOGIN, PROBLEM_REGISTER, SOLVE_CORRECT, PRACTICE_NOTE_COMPLETE + } + + enum XpPath { + LEGACY_ACCRUAL, MISSION_PROGRESS + } + + static Stream everyCombination() { + return Arrays.stream(AppRequest.values()) + .flatMap(app -> Stream.of(true, false) + .flatMap(flag -> Arrays.stream(Activity.values()) + .map(activity -> Arguments.of(app, flag, activity)))); + } + + @ParameterizedTest(name = "{0}, 비상 스위치 {1}, {2}") + @MethodSource("everyCombination") + @DisplayName("요청 종류와 비상 스위치가 무엇이든 적립과 진행도 중 정확히 하나만 움직인다") + void exactlyOneXpPath(AppRequest app, boolean legacyAccrualEnabled, Activity activity) { + setLegacyAccrual(legacyAccrualEnabled); + actAs(app); + + perform(activity); + + boolean accrued = accumulatedXp() > 0; + boolean progressed = progressRowCount() > 0; + assertThat(accrued ^ progressed) + .as("적립 %s, 진행도 %s. 둘 다면 이중 지급, 둘 다 아니면 그 행동으로는 XP 를 못 받는다", + accrued, progressed) + .isTrue(); + assertThat(accrued ? XpPath.LEGACY_ACCRUAL : XpPath.MISSION_PROGRESS) + .isEqualTo(expectedPath(app, legacyAccrualEnabled)); + } + + @Nested + @DisplayName("#267 재현: 구버전 앱으로 활동하고 새 앱으로 미션 화면을 열면") + class SwitchingApps { + + @Test + @DisplayName("적립으로 받은 활동은 받을 수 있는 미션으로 남지 않는다") + void legacyActivityLeavesNothingToClaim() { + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + problemService.registerProblem(problemDto(), user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + long accrued = accumulatedXp(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + + assertThat(accrued).as("구버전 앱은 지금처럼 적립을 받는다").isPositive(); + assertThat(completedUnclaimedCount()) + .as("같은 날 새 앱으로 다시 접속해도 이미 적립으로 받은 출석은 미션이 되지 않는다") + .isZero(); + assertThat(accumulatedXp()).isEqualTo(accrued); + } + + @Test + @DisplayName("새 앱에서 새로 한 활동만 미션으로 받는다") + void onlyNewAppActivityIsClaimable() { + requestFromApp(null); + problemService.registerProblem(problemDto(), user.getId()); + long accrued = accumulatedXp(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + problemService.registerProblem(problemDto(), user.getId()); + + assertThat(accumulatedXp()) + .as("새 앱 활동은 적립을 받지 않는다") + .isEqualTo(accrued); + MissionProgress progress = progressOf(user, DAILY_NOTE_WRITE); + assertThat(progress.getCurrentValue()) + .as("구버전 앱에서 쓴 오답노트는 세지 않는다") + .isEqualTo(1); + assertThat(progress.isCompleted()).isTrue(); + } + } + + @Nested + @DisplayName("진행도 증가를 직접 부르면") + class UpdaterGate { + + @Test + @DisplayName("적립을 함께 부르는 항목은 정확히 다섯 개다") + void coveredMetricsAreExactlyFive() { + // MOOD_LOGGED 는 기분 저장이 적립을 부르지 않아 빠진다. 새 항목을 넣을 때 이 목록을 함께 본다. + assertThat(Arrays.stream(MissionMetric.values()).filter(MissionMetric::isAlsoAccruedByLegacyPath)) + .containsExactlyInAnyOrder( + MissionMetric.LOGIN_DAY, + MissionMetric.PROBLEM_CREATED, + MissionMetric.SOLVE_RECORDED, + MissionMetric.SOLVE_CORRECT, + MissionMetric.PRACTICE_NOTE_COMPLETED); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(MissionMetric.class) + @DisplayName("적립이 도는 요청에서는 적립을 함께 부르는 항목만 막힌다") + void blocksOnlyCoveredMetricsWhenAccruing(MissionMetric metric) { + requestFromApp(LEGACY_APP_HEADER); + + missionProgressUpdater.increase(user.getId(), metric); + + assertThat(progressRowCount() > 0).isEqualTo(!metric.isAlsoAccruedByLegacyPath()); + } + + @ParameterizedTest(name = "{0}") + @EnumSource(MissionMetric.class) + @DisplayName("새 앱 요청에서는 모든 항목이 오른다") + void raisesEveryMetricForMissionCapableApp(MissionMetric metric) { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + missionProgressUpdater.increase(user.getId(), metric); + + assertThat(progressRowCount()).isPositive(); + } + + @Test + @DisplayName("기분 저장은 구버전 앱에서도 기분 미션이 오른다") + void moodStillCountsForLegacyApp() { + requestFromApp(null); + LocalDate today = MissionPeriodKey.today(); + Problem problem = saveProblem(); + jdbcTemplate.update("UPDATE problem SET created_at = ? WHERE id = ?", today.atTime(12, 0), problem.getId()); + + learningCalendarService.updateMood( + user.getId(), new LearningCalendarMoodRequestDto(today, "cool_sunglasses")); + + assertThat(currentOf(user.getId(), DAILY_MOOD)) + .as("기분 저장은 적립이 없어 막으면 그 미션으로는 XP 를 못 받는다") + .isEqualTo(1); + } + } + + private XpPath expectedPath(AppRequest app, boolean legacyAccrualEnabled) { + if (!legacyAccrualEnabled || app == AppRequest.MISSION_CAPABLE_APP) { + return XpPath.MISSION_PROGRESS; + } + return XpPath.LEGACY_ACCRUAL; + } + + private void actAs(AppRequest app) { + switch (app) { + case NO_HEADER -> requestFromApp(null); + case LEGACY_APP -> requestFromApp(LEGACY_APP_HEADER); + case MISSION_CAPABLE_APP -> requestFromApp(MISSION_CAPABLE_APP_HEADER); + case OUTSIDE_HTTP_REQUEST -> RequestContextHolder.resetRequestAttributes(); + } + } + + private void perform(Activity activity) { + switch (activity) { + case LOGIN -> missionLogService.registerLoginMission(user.getId()); + case PROBLEM_REGISTER -> problemService.registerProblem(problemDto(), user.getId()); + case SOLVE_CORRECT -> problemSolveService.createProblemSolve( + new ProblemSolveRegisterDto( + saveProblem().getId(), LocalDateTime.now(), AnswerStatus.CORRECT, "회고", null, 120, null), + user.getId()); + case PRACTICE_NOTE_COMPLETE -> practiceNoteService.addPracticeNoteCount( + user.getId(), savePracticeNote().getId()); + } + } + + private ProblemRegisterDto problemDto() { + return new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()); + } + + private Problem saveProblem() { + Problem problem = Problem.from(problemDto(), user.getId()); + problem.updateFolder(folder); + return problemRepository.saveAndFlush(problem); + } + + private PracticeNote savePracticeNote() { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + } + + /** 네 능력치에 지금까지 들어간 경험치 합. 어느 능력치로 들어가든 적립이 있었는지만 본다. */ + private long accumulatedXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getAttendanceLevel(), status.getAttendancePoint()) + + accumulatedPoints(status.getNoteWriteLevel(), status.getNoteWritePoint()) + + accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint()) + + accumulatedPoints(status.getNotePracticeLevel(), status.getNotePracticePoint()); + } + + private long progressRowCount() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mission_progress WHERE user_id = ? AND current_value > 0", + Long.class, user.getId()); + } + + private long completedUnclaimedCount() { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM mission_progress WHERE user_id = ? AND completed_at IS NOT NULL AND claimed_at IS NULL", + Long.class, user.getId()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java index 564f4e83..97eeb7ca 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionSystemTestSupport.java @@ -11,6 +11,8 @@ import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; import java.time.LocalDateTime; @@ -18,6 +20,11 @@ * 미션 시스템(정의/진행도/보상) 테스트의 공통 베이스. * *

    {@code @MockBean} 을 새로 선언하지 않아 스프링 컨텍스트는 다른 도메인 테스트와 그대로 공유된다. + * + *

    기본은 미션을 받을 수 있는 앱에서 온 요청이다. 자동 적립이 도는 요청(헤더 없음, 구버전 앱, + * HTTP 요청 밖)에서는 진행도가 오르지 않기 때문에, 진행도와 받기를 보는 테스트는 새 앱을 전제로 한다. + * 구버전 앱이나 요청 밖을 보려면 테스트에서 {@link #requestFromApp(String)} 이나 + * {@code RequestContextHolder.resetRequestAttributes()} 로 바꾼다. */ public abstract class MissionSystemTestSupport extends MissionTestSupport { @@ -56,6 +63,11 @@ void seedMissionDefinitions() { missionDefinitionSeeder.seed(); } + @BeforeEach + void requestFromMissionCapableAppByDefault() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + } + protected MissionDefinition definitionOf(String code) { return missionDefinitionRepository.findByCode(code).orElseThrow( () -> new IllegalStateException("시드에 없는 미션 코드다: " + code)); @@ -85,10 +97,21 @@ protected int currentOf(Long userId, String code) { return progress == null ? 0 : progress.getCurrentValue(); } - /** 미션을 목표까지 밀어 올린다. 받기 시나리오의 사전 준비용. */ + /** + * 미션을 목표까지 밀어 올린다. 받기 시나리오의 사전 준비용. + * + *

    준비 단계라 테스트가 어떤 요청을 흉내 내고 있든 새 앱에서 한 활동으로 채운다. + * 구버전 앱으로 적립을 받던 사용자가 새 앱으로 미션을 채우는 흐름도 이것으로 만든다. + */ protected MissionProgress completeMission(Long userId, String code) { MissionDefinition definition = definitionOf(code); - missionProgressUpdater.increase(userId, definition.getMetric(), definition.getTarget()); + RequestAttributes previous = RequestContextHolder.getRequestAttributes(); + try { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionProgressUpdater.increase(userId, definition.getMetric(), definition.getTarget()); + } finally { + RequestContextHolder.setRequestAttributes(previous, true); + } return progressOf(userId, code); } diff --git a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java index bf428d50..dea51740 100644 --- a/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/mission/support/MissionTestSupport.java @@ -1,10 +1,12 @@ package com.aisip.OnO.backend.mission.support; +import com.aisip.OnO.backend.common.web.AppVersionResolver; import com.aisip.OnO.backend.mission.dto.MissionRegisterDto; import com.aisip.OnO.backend.mission.entity.MissionLog; import com.aisip.OnO.backend.mission.entity.MissionType; import com.aisip.OnO.backend.mission.entity.UserMissionStatus; import com.aisip.OnO.backend.mission.repository.MissionLogRepository; +import com.aisip.OnO.backend.mission.service.LegacyAccrualPolicy; import com.aisip.OnO.backend.mission.service.MissionLogService; import com.aisip.OnO.backend.support.IntegrationTestSupport; import com.aisip.OnO.backend.user.entity.User; @@ -13,8 +15,10 @@ import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.AopTestUtils; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import java.time.LocalDate; import java.time.LocalDateTime; @@ -42,32 +46,37 @@ public abstract class MissionTestSupport extends IntegrationTestSupport { @Autowired protected MissionLogService missionLogService; + @Autowired + private LegacyAccrualPolicy legacyAccrualPolicy; + /** {@code ono.mission.legacy-accrual.enabled} 의 기본값. 설정을 안 건드리면 운영이 이 상태다. */ protected static final boolean LEGACY_ACCRUAL_DEFAULT = true; /** {@code ono.mission.mission-capable-version} 의 기본값. */ protected static final String MISSION_CAPABLE_VERSION_DEFAULT = "4.0.0"; + /** 미션을 받을 수 있는 앱이 보내는 헤더. 프론트가 보내는 형식 그대로다. */ + protected static final String MISSION_CAPABLE_APP_HEADER = "4.0.0+70"; + + /** 기준에 못 미치는 앱이 보내는 헤더. */ + protected static final String LEGACY_APP_HEADER = "3.9.9+69"; + /** * 자동 적립 플래그를 바꾼다. * - *

    {@code MissionLogService} 는 {@code @Transactional} 프록시라 프록시에 값을 넣으면 - * 정작 로직이 도는 대상 객체의 필드는 그대로다. 대상 객체를 꺼내서 넣어야 한다. + *

    판정은 {@link LegacyAccrualPolicy} 한 곳에 있어 자동 적립과 미션 진행도가 함께 바뀐다. + * 이 빈은 프록시가 아니라 필드에 바로 넣으면 된다. * *

    싱글턴 빈을 건드리는 것이라 테스트 사이에 값이 새면 엉뚱한 테스트가 깨진다. * 앞뒤로 기본값을 되돌려 이 클래스를 상속하지 않는 테스트까지 오염되지 않게 한다. */ protected void setLegacyAccrual(boolean enabled) { - // 대상 객체를 지역 변수로 받아 둔다. getTargetObject 의 반환형이 제네릭이라 그대로 넘기면 - // 컴파일러가 setField(Class, ...) 오버로드를 골라 실행 시점에 ClassCastException 이 난다. - MissionLogService target = AopTestUtils.getTargetObject(missionLogService); - ReflectionTestUtils.setField(target, "legacyAccrualEnabled", enabled); + ReflectionTestUtils.setField(legacyAccrualPolicy, "legacyAccrualEnabled", enabled); } - /** 자동 적립을 끄는 기준 앱 버전을 바꾼다. 플래그와 같은 이유로 대상 객체에 넣고 앞뒤로 되돌린다. */ + /** 자동 적립을 끄는 기준 앱 버전을 바꾼다. 플래그와 같은 이유로 앞뒤로 되돌린다. */ protected void setMissionCapableVersion(String version) { - MissionLogService target = AopTestUtils.getTargetObject(missionLogService); - ReflectionTestUtils.setField(target, "missionCapableVersion", version); + ReflectionTestUtils.setField(legacyAccrualPolicy, "missionCapableVersion", version); } @BeforeEach @@ -80,6 +89,22 @@ void restoreAccrualSettingsBeforeEachTest() { void restoreAccrualSettingsAfterEachTest() { setLegacyAccrual(LEGACY_ACCRUAL_DEFAULT); setMissionCapableVersion(MISSION_CAPABLE_VERSION_DEFAULT); + // 싱글턴이 아니라 스레드에 붙는 값이라 안 지우면 다음 테스트가 이 헤더를 그대로 물려받는다. + RequestContextHolder.resetRequestAttributes(); + } + + /** + * 지금 스레드를 "그 헤더를 단 요청을 처리하는 중" 으로 만든다. + * + *

    {@code null} 이면 헤더를 아예 붙이지 않는다. 헤더를 안 보내는 구버전 앱이다. + * 자식 스레드에도 물려준다. 동시성 테스트의 작업 스레드도 같은 앱에서 온 요청으로 봐야 하기 때문이다. + */ + protected void requestFromApp(String appVersionHeader) { + MockHttpServletRequest request = new MockHttpServletRequest(); + if (appVersionHeader != null) { + request.addHeader(AppVersionResolver.APP_VERSION_HEADER, appVersionHeader); + } + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request), true); } protected MissionLog saveMissionLog(User user, MissionType missionType, Long referenceId) { From 61c52f9318dd70632f2731417039d6c3480d19cb Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:17:32 +0900 Subject: [PATCH 111/130] =?UTF-8?q?[Fix]=20=EA=B0=99=EC=9D=80=20=EA=B8=B0?= =?UTF-8?q?=EA=B8=B0=EC=97=90=EC=84=9C=20=EA=B3=84=EC=A0=95=EC=9D=B4=20?= =?UTF-8?q?=EB=B0=94=EB=80=8C=EB=A9=B4=20=EC=9D=B4=EC=A0=84=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=9D=98=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=96=89=EC=9D=84=20=EC=A7=80=EC=9A=B4=EB=8B=A4=20-=20register?= =?UTF-8?q?Token=20=EC=97=90=EC=84=9C=20=EA=B0=99=EC=9D=80=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=B4=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=EC=97=90=EA=B2=8C=20=EB=AC=B6=EC=97=AC=20=EC=9E=88?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EA=B7=B8=20=ED=96=89=EC=9D=84=20id=20?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A8=EB=9D=BC=20=EC=A7=80=EC=9A=B4=20=EB=92=A4?= =?UTF-8?q?=20=EC=83=88=20=EC=82=AC=EC=9A=A9=EC=9E=90=EB=A1=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=ED=95=9C=EB=8B=A4=20=20=20token=20=EB=8B=A8=EB=8F=85?= =?UTF-8?q?=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=EA=B0=80=20=EC=97=86=EC=96=B4=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=20DELETE=20=EB=8A=94=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=A0=84=EC=B2=B4=EC=97=90=20=EC=9E=A0=EA=B8=88?= =?UTF-8?q?=EC=9D=84=20=EA=B1=B8=EA=B8=B0=20=EB=95=8C=EB=AC=B8=EC=97=90,?= =?UTF-8?q?=20=EC=9E=A0=EA=B8=88=20=EC=97=86=EB=8A=94=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A1=9C=20=EB=A8=BC=EC=A0=80=20=EA=B3=A0=EB=A5=B8=EB=8B=A4=20?= =?UTF-8?q?-=20=ED=9A=8C=EC=9B=90=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20?= =?UTF-8?q?=EA=B7=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20fcm=5Ftoken?= =?UTF-8?q?=20=ED=96=89=EC=9D=84=20bulk=20delete=20=EB=A1=9C=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B4=EB=8B=A4.=20=EC=98=81=EC=86=8D=EC=84=B1=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=8A=94=20=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20V43:=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=ED=95=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=ED=96=89=EC=9D=84=20=EC=A7=80=EC=9A=B0=EA=B3=A0,?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EB=A7=88=EB=8B=A4=20created=5Fat,=20id=20?= =?UTF-8?q?=EA=B0=80=20=EA=B0=80=EC=9E=A5=20=EC=B5=9C=EA=B7=BC=EC=9D=B8=20?= =?UTF-8?q?=ED=96=89=20=ED=95=98=EB=82=98=EB=A7=8C=20=EB=82=A8=EA=B8=B4?= =?UTF-8?q?=EB=8B=A4=20=20=20token=20=EB=8B=A8=EB=8F=85=20=EC=9C=A0?= =?UTF-8?q?=EB=8B=88=ED=81=AC=20=EC=A0=9C=EC=95=BD=EC=9D=80=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=20=EC=A4=91=20=EC=9D=B4=EC=A0=84=20=EB=B2=84=EC=A0=84?= =?UTF-8?q?=20=EC=95=B1=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B8=B0=EB=8F=99=EC=9D=B4=20=EB=A7=89?= =?UTF-8?q?=ED=9E=90=20=EC=88=98=20=EC=9E=88=EC=96=B4=20=EA=B1=B8=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EC=95=98=EB=8B=A4=20-=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=ED=99=98,=20=EA=B0=99=EC=9D=80=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=9E=AC=EB=93=B1=EB=A1=9D=20=EB=A9=B1=EB=93=B1,?= =?UTF-8?q?=20=ED=83=88=ED=87=B4=20=EC=8B=9C=20=EC=82=AD=EC=A0=9C,=20V43?= =?UTF-8?q?=20=EC=9D=84=20MySQL=208=20=EC=97=90=EC=84=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=ED=95=98=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #271 --- .../OnO/backend/user/service/UserService.java | 5 + .../fcm/repository/FcmTokenRepository.java | 15 ++ .../backend/util/fcm/service/FcmService.java | 19 +++ .../migration/V43__dedupe_fcm_token_owner.sql | 46 +++++ .../service/UserServiceIntegrationTest.java | 33 ++++ .../backend/user/service/UserServiceTest.java | 4 + .../migration/FcmTokenOwnerMigrationTest.java | 161 ++++++++++++++++++ .../util/fcm/service/FcmServiceTest.java | 23 ++- .../FcmTokenOwnerSwitchIntegrationTest.java | 105 ++++++++++++ 9 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenOwnerMigrationTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index ba67b4b9..2cefef0c 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -14,6 +14,7 @@ import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; @@ -78,6 +79,8 @@ public class UserService { private final DiscordWebhookNotificationService discordWebhookNotificationService; + private final FcmTokenRepository fcmTokenRepository; + private User findUserEntity(Long userId){ return userRepository.findById(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); @@ -256,6 +259,8 @@ public void deleteUserById(Long userId) { reminderService.cancelAllByUser(userId); problemService.deleteAllUserProblems(userId); folderService.deleteAllUserFolders(userId); + // 탈퇴 계정 앞으로 발송이 생기면 그 기기를 이어 쓰는 사람에게 알림이 뜬다. 토큰 행을 남기지 않는다. + fcmTokenRepository.deleteAllByUserId(userId); user.maskIdentifierForDeletion(makeDeletedIdentifier(userId)); userRepository.flush(); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java index 31e7007e..3acca5f5 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/repository/FcmTokenRepository.java @@ -2,6 +2,9 @@ import com.aisip.OnO.backend.util.fcm.entity.FcmToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -17,6 +20,18 @@ public interface FcmTokenRepository extends JpaRepository { List findAllByUserId(Long userId); + /** 같은 기기 토큰이 다른 사용자에게 묶여 있는 행. 계정 전환 시 지울 대상이다. */ + List findAllByTokenAndUserIdNot(String token, Long userId); + + /** + * 탈퇴한 사용자의 기기 토큰을 지운다. (user_id, token) 유니크 인덱스의 선두 컬럼을 탄다. + * 영속성 컨텍스트를 비우지 않는다. 탈퇴 경로가 아직 사용자 엔티티를 수정 중이라 비우면 그 변경이 사라진다. + */ + @Modifying + @Transactional + @Query("DELETE FROM FcmToken f WHERE f.userId = :userId") + int deleteAllByUserId(@Param("userId") Long userId); + @Transactional void deleteByToken(String token); } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index 02770ac4..bfd8fcf1 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -34,8 +34,27 @@ public class FcmService { private final FcmNotificationProducer fcmNotificationProducer; + /** + * 토큰은 기기 하나를 가리키므로 한 사용자에게만 묶여 있어야 한다. + * + *

    같은 기기에서 A 가 로그아웃하고 B 가 로그인하면 같은 토큰으로 등록이 들어온다. + * 이때 (A, 토큰) 행을 남겨 두면 A 앞으로 가는 알림(댓글 작성자 이름, 미리보기 포함)이 B 기기에 뜬다. + * 클라이언트가 로그아웃 때 토큰을 해제하지 않으므로, 서버가 등록 시점에 이전 소유자 행을 지운다. + * + *

    {@code token = ? AND user_id <> ?} 로 바로 DELETE 하지 않고 id 로 골라 지운다. + * token 단독 인덱스가 없어 조건 DELETE 는 테이블 전체 행에 잠금을 걸고, 그동안 다른 사용자 등록이 막힌다. + * 대부분의 등록은 지울 행이 없으니 잠금 없는 조회로 끝난다. + */ @Transactional public void registerToken(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { + List previousOwnerTokens = + fcmTokenRepository.findAllByTokenAndUserIdNot(fcmTokenRequestDto.token(), userId); + if (!previousOwnerTokens.isEmpty()) { + fcmTokenRepository.deleteAllInBatch(previousOwnerTokens); + log.info("FCM token moved to another user - userId: {}, removedPreviousOwnerRows: {}", + userId, previousOwnerTokens.size()); + } + if(!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())){ FcmToken fcmToken = FcmToken.From(fcmTokenRequestDto, userId); fcmTokenRepository.save(fcmToken); diff --git a/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql b/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql new file mode 100644 index 00000000..29e6f3d7 --- /dev/null +++ b/src/main/resources/db/migration/V43__dedupe_fcm_token_owner.sql @@ -0,0 +1,46 @@ +-- FCM 토큰 하나가 여러 사용자에게 묶인 행을 정리한다. (#271) +-- +-- 토큰은 기기 하나를 가리킨다. 그런데 등록이 (user_id, token) 쌍 단위라, 같은 기기에서 A 가 로그아웃하고 +-- B 가 로그인하면 (A, T) 와 (B, T) 가 함께 남았다. 그 뒤로 A 앞 알림(댓글 작성자 이름, 미리보기 포함)이 +-- B 가 쓰는 기기에 떴다. 등록 경로는 이번 변경에서 이전 소유자 행을 지우도록 고쳤고, +-- 이 파일은 그 전에 쌓인 행을 정리한다. +-- +-- 1) 탈퇴한 사용자의 토큰 행을 지운다. user 는 deleted_at 을 채우는 소프트 삭제라 행이 남아 있다. +-- 탈퇴 계정 행이 2) 에서 가장 최근 행으로 뽑히면 지금 그 기기를 쓰는 사용자의 행이 지워지므로 먼저 뺀다. +-- +-- 2) 토큰마다 가장 최근에 만들어진 행 하나만 남긴다. 순서는 created_at, 같으면 id 다. +-- - 등록은 이미 있는 쌍을 건드리지 않는다(FcmService.registerToken). 그래서 updated_at 은 created_at 과 같고, +-- "최근 행" 은 그 기기에 새 계정이 처음 등록된 시점이다. +-- - id 를 첫 기준으로 쓰지 않는다. FcmToken 은 테이블 기반 생성기(fcm_token_seq)로 id 를 미리 블록 단위로 +-- 받아 두기 때문에, 앱 인스턴스가 둘 이상이면 id 순서가 저장 순서와 어긋날 수 있다. +-- - 한계: 같은 기기에서 A → B → A 로 돌아온 경우 A 의 재등록은 새 행을 만들지 않아 B 행이 남는다. +-- 이 경우에도 새 코드에서는 그 기기에서 다음 로그인이나 토큰 갱신이 일어나는 순간 바로잡힌다. +-- - 행을 모두 지우는 쪽은 택하지 않았다. 앱은 로그인과 토큰 갱신 때만 토큰을 올리므로, +-- 지금 기기를 쓰는 사용자까지 다음 로그인 전까지 알림을 못 받게 된다. +-- +-- token 단독 유니크 제약은 걸지 않는다. Flyway 는 새 앱이 뜰 때 돌고 그동안 이전 버전 앱이 계속 등록을 받는다. +-- 정리와 제약 추가 사이에 중복 쌍이 한 번만 들어와도 ALTER 가 실패하고, MySQL DDL 은 롤백되지 않아 +-- 새 앱이 뜨지 못한 채 수동 복구가 필요해진다. 중복은 등록 경로에서 막고, 제약은 별도 작업으로 본다. +-- +-- 두 문장 모두 다시 돌려도 지울 행이 없어 안전하다. +-- 잠금: 두 DELETE 모두 fcm_token 전체를 한 번 읽으며, 읽는 행에 공유 잠금이 걸려 문장이 끝날 때까지 +-- 토큰 등록 INSERT 가 기다린다. 행 수에 비례해 짧게 끝나는 단일 문장이다. + +DELETE f +FROM fcm_token f +JOIN `user` u ON u.id = f.user_id +WHERE u.deleted_at IS NOT NULL; + +-- MySQL 은 DELETE 대상 테이블을 같은 문장의 서브쿼리에서 읽지 못한다(1093). +-- 윈도 함수가 들어간 파생 테이블은 병합되지 않고 임시 테이블로 먼저 만들어지므로 이 제약에 걸리지 않는다. +DELETE f +FROM fcm_token f +JOIN ( + SELECT ranked.id + FROM ( + SELECT id, + ROW_NUMBER() OVER (PARTITION BY token ORDER BY created_at DESC, id DESC) AS row_num + FROM fcm_token + ) ranked + WHERE ranked.row_num > 1 +) stale ON stale.id = f.id; diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceIntegrationTest.java index 3d713fe4..08510b42 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceIntegrationTest.java @@ -8,10 +8,14 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; import java.time.LocalDate; import java.util.List; @@ -37,6 +41,12 @@ class UserServiceIntegrationTest extends IntegrationTestSupport { @Autowired private FolderRepository folderRepository; + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + private static UserRegisterDto registerDto(String identifier) { return UserRegisterDto.builder() .identifier(identifier) @@ -183,6 +193,29 @@ void deletesOnlyOwnData() { .isNotEmpty(); } + @Test + @DisplayName("탈퇴하면 본인 기기 토큰 행이 지워지고 다른 사용자 토큰은 남는다") + void deletesOwnFcmTokens() { + User me = fixtures.createUser(); + User other = fixtures.createOtherUser(); + // 테이블 기반 id 생성기의 초기 행. FcmTokenRepositoryTest 와 같은 이유로 없으면 채운다. + Integer seqRows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM fcm_token_seq", Integer.class); + if (seqRows == null || seqRows == 0) { + jdbcTemplate.update("INSERT INTO fcm_token_seq (next_val) VALUES (1)"); + } + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("my-phone"), me.getId())); + fcmTokenRepository.save(FcmToken.From(new FcmTokenRequestDto("other-phone"), other.getId())); + + userService.deleteUserById(me.getId()); + + assertThat(fcmTokenRepository.findAllByUserId(me.getId())) + .as("탈퇴 계정 토큰이 남으면 그 기기를 이어 쓰는 사람에게 탈퇴 계정 알림이 뜰 수 있다") + .isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(other.getId())) + .extracting(FcmToken::getToken) + .containsExactly("other-phone"); + } + @Test @DisplayName("탈퇴한 사용자를 다시 탈퇴시키려 하면 3001 이 난다") void rejectsDoubleWithdrawal() { diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 35d03593..04c9eb81 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -14,6 +14,7 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import com.aisip.OnO.backend.util.fileupload.exception.FileUploadErrorCase; import com.aisip.OnO.backend.util.fileupload.service.FileUploadService; import com.aisip.OnO.backend.util.webhook.DiscordWebhookNotificationService; @@ -87,6 +88,8 @@ class UserServiceTest { private DiscordWebhookNotificationService discordWebhookNotificationService; @Mock private UserRegistrationWriter registrationWriter; + @Mock + private FcmTokenRepository fcmTokenRepository; @InjectMocks private UserService userService; @@ -485,6 +488,7 @@ void deletesUserWithAllRelatedData() { verify(reminderService).cancelAllByUser(USER_ID); verify(problemService).deleteAllUserProblems(USER_ID); verify(folderService).deleteAllUserFolders(USER_ID); + verify(fcmTokenRepository).deleteAllByUserId(USER_ID); verify(userRepository).deleteById(USER_ID); } diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenOwnerMigrationTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenOwnerMigrationTest.java new file mode 100644 index 00000000..d0bc84ce --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenOwnerMigrationTest.java @@ -0,0 +1,161 @@ +package com.aisip.OnO.backend.util.fcm.migration; + +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V43 이 실제 MySQL 8 에서 돌아가고 의도한 행만 남기는지 확인한다. + * + *

    테스트는 Flyway 를 끄고 Hibernate 가 만든 스키마를 쓴다({@code application-test.yml}). + * 그래서 Flyway 로 적용하는 대신 {@code AchievementMigrationTest} 처럼 파일 본문을 같은 연결에서 직접 실행한다. + * MySQL 1093(DELETE 대상 테이블을 서브쿼리에서 읽기) 같은 문법 문제는 여기서 드러난다. + */ +@DisplayName("FCM 토큰 중복 소유 정리 마이그레이션") +class FcmTokenOwnerMigrationTest extends IntegrationTestSupport { + + private static final String MIGRATION = "db/migration/V43__dedupe_fcm_token_owner.sql"; + private static final LocalDateTime BASE = LocalDateTime.of(2026, 9, 1, 12, 0); + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("토큰마다 가장 최근에 만들어진 행 하나만 남긴다") + void keepsLatestRowPerToken() { + long a = fixtures.createUser().getId(); + long b = fixtures.createOtherUser().getId(); + long c = fixtures.createUser("third").getId(); + + insert(10, a, "shared", BASE); + insert(11, b, "shared", BASE.plusDays(1)); + insert(12, a, "a-tablet", BASE); + insert(13, c, "c-phone", BASE); + + runMigration(); + + assertThat(rows()).containsExactlyInAnyOrder( + Map.entry(b, "shared"), + Map.entry(a, "a-tablet"), + Map.entry(c, "c-phone")); + } + + @Test + @DisplayName("생성 시각이 id 순서와 어긋나면 생성 시각을 따른다 - id 는 블록 단위로 미리 받는다") + void ordersByCreatedAtBeforeId() { + long a = fixtures.createUser().getId(); + long b = fixtures.createOtherUser().getId(); + + insert(100, a, "shared", BASE.plusDays(1)); + insert(51, b, "shared", BASE); + + runMigration(); + + assertThat(rows()).containsExactly(Map.entry(a, "shared")); + } + + @Test + @DisplayName("생성 시각이 같으면 id 가 큰 행을 남긴다") + void breaksTieById() { + long a = fixtures.createUser().getId(); + long b = fixtures.createOtherUser().getId(); + + insert(20, a, "shared", BASE); + insert(21, b, "shared", BASE); + + runMigration(); + + assertThat(rows()).containsExactly(Map.entry(b, "shared")); + } + + @Test + @DisplayName("탈퇴한 사용자 행은 더 최근이어도 지우고, 지금 사용자 행을 남긴다") + void removesWithdrawnUsersFirst() { + long active = fixtures.createUser().getId(); + User leaver = fixtures.createOtherUser(); + jdbcTemplate.update("UPDATE `user` SET deleted_at = NOW() WHERE id = ?", leaver.getId()); + + insert(30, active, "shared", BASE); + insert(31, leaver.getId(), "shared", BASE.plusDays(1)); + insert(32, leaver.getId(), "leaver-only", BASE); + + runMigration(); + + assertThat(rows()).containsExactly(Map.entry(active, "shared")); + } + + @Test + @DisplayName("다시 돌려도 터지지 않고 남은 행이 그대로다") + void rerunIsSafe() { + long a = fixtures.createUser().getId(); + long b = fixtures.createOtherUser().getId(); + insert(40, a, "shared", BASE); + insert(41, b, "shared", BASE.plusDays(1)); + + runMigration(); + runMigration(); + + assertThat(rows()).containsExactly(Map.entry(b, "shared")); + } + + private void insert(long id, long userId, String token, LocalDateTime createdAt) { + Timestamp at = Timestamp.valueOf(createdAt); + jdbcTemplate.update( + "INSERT INTO fcm_token (id, user_id, token, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + id, userId, token, at, at); + } + + private List> rows() { + return jdbcTemplate.query("SELECT user_id, token FROM fcm_token", + (rs, rowNum) -> Map.entry(rs.getLong("user_id"), rs.getString("token"))); + } + + private void runMigration() { + List statements = Arrays.stream(withoutComments().split(";")) + .map(String::trim) + .filter(statement -> !statement.isEmpty()) + .toList(); + jdbcTemplate.execute((ConnectionCallback) connection -> { + try (Statement statement = connection.createStatement()) { + for (String sql : statements) { + statement.execute(sql); + } + } + return null; + }); + } + + private String withoutComments() { + return Arrays.stream(read().split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + } + + private String read() { + try (InputStream inputStream = new ClassPathResource(MIGRATION).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java index d90f67e3..48b1ae39 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java @@ -105,19 +105,32 @@ void skipsDuplicatedToken() { } @Test - @DisplayName("같은 토큰이라도 사용자가 다르면 각자 저장한다 - 기기 공유 사용자") - void savesSameTokenForDifferentUser() { - when(fcmTokenRepository.existsByUserIdAndToken(1L, "shared-token")).thenReturn(true); + @DisplayName("같은 토큰이 다른 사용자에게 묶여 있으면 그 행을 지우고 새 사용자로 저장한다 - 기기 계정 전환") + void movesTokenFromPreviousOwner() { + FcmToken previousOwner = tokenOf(1L, "shared-token"); + when(fcmTokenRepository.findAllByTokenAndUserIdNot("shared-token", 2L)).thenReturn(List.of(previousOwner)); when(fcmTokenRepository.existsByUserIdAndToken(2L, "shared-token")).thenReturn(false); - fcmService.registerToken(new FcmTokenRequestDto("shared-token"), 1L); fcmService.registerToken(new FcmTokenRequestDto("shared-token"), 2L); + verify(fcmTokenRepository).deleteAllInBatch(List.of(previousOwner)); ArgumentCaptor saved = ArgumentCaptor.forClass(FcmToken.class); - verify(fcmTokenRepository, times(1)).save(saved.capture()); + verify(fcmTokenRepository).save(saved.capture()); assertThat(saved.getValue().getUserId()).isEqualTo(2L); } + @Test + @DisplayName("다른 사용자에게 묶인 행이 없으면 아무것도 지우지 않는다") + void deletesNothingWithoutPreviousOwner() { + when(fcmTokenRepository.findAllByTokenAndUserIdNot("token-1", 1L)).thenReturn(List.of()); + when(fcmTokenRepository.existsByUserIdAndToken(1L, "token-1")).thenReturn(true); + + fcmService.registerToken(new FcmTokenRequestDto("token-1"), 1L); + + verify(fcmTokenRepository, never()).deleteAllInBatch(any()); + verify(fcmTokenRepository, never()).save(any()); + } + @Test @DisplayName("토큰 등록만으로는 푸시를 보내지 않는다") void doesNotSendOnRegistration() { diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java new file mode 100644 index 00000000..912cc73b --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java @@ -0,0 +1,105 @@ +package com.aisip.OnO.backend.util.fcm.service; + +import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.google.firebase.messaging.FirebaseMessaging; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * 같은 기기에서 계정이 바뀔 때 토큰 행이 실제 MySQL 에서 어떻게 남는지 본다. (#271) + * + *

    통합 컨텍스트의 {@link FcmService} 는 발송 사고를 막으려고 목으로 바꿔 두었다. + * 그래서 저장소만 실제 빈을 쓰고 서비스는 여기서 직접 만든다. 발송 의존성은 전부 목이다. + */ +@DisplayName("FCM 토큰 계정 전환") +class FcmTokenOwnerSwitchIntegrationTest extends IntegrationTestSupport { + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private FirebaseMessaging firebaseMessaging; + private FcmService realFcmService; + + @BeforeEach + void setUp() { + // FcmTokenRepositoryTest 와 같은 이유로 테이블 기반 id 생성기의 초기 행을 되살린다. + Integer rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM fcm_token_seq", Integer.class); + if (rows == null || rows == 0) { + jdbcTemplate.update("INSERT INTO fcm_token_seq (next_val) VALUES (1)"); + } + firebaseMessaging = mock(FirebaseMessaging.class); + realFcmService = new FcmService( + fcmTokenRepository, firebaseMessaging, new SimpleMeterRegistry(), mock(FcmNotificationProducer.class)); + } + + @Test + @DisplayName("A 가 쓰던 기기에 B 가 로그인해 등록하면 A 의 토큰 행이 사라진다") + void removesPreviousOwnerOnAccountSwitch() { + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 2L); + + assertThat(fcmTokenRepository.findAllByUserId(1L)) + .as("A 행이 남으면 A 앞 알림이 B 가 쓰는 기기에 뜬다") + .isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(2L)) + .extracting(FcmToken::getToken) + .containsExactly("device-token"); + verifyNoInteractions(firebaseMessaging); + } + + @Test + @DisplayName("A 가 다시 로그인하면 B 행이 지워지고 A 로 돌아온다") + void switchesBackToOriginalOwner() { + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 2L); + + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + + assertThat(fcmTokenRepository.findAllByUserId(2L)).isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(1L)) + .extracting(FcmToken::getToken) + .containsExactly("device-token"); + } + + @Test + @DisplayName("같은 사용자가 같은 토큰을 여러 번 등록해도 행은 하나다") + void sameUserRegistrationIsIdempotent() { + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + + assertThat(fcmTokenRepository.findAllByUserId(1L)).hasSize(1); + } + + @Test + @DisplayName("같은 사용자의 다른 기기 토큰과 다른 사용자의 다른 토큰은 건드리지 않는다") + void keepsUnrelatedTokens() { + realFcmService.registerToken(new FcmTokenRequestDto("my-tablet"), 2L); + realFcmService.registerToken(new FcmTokenRequestDto("other-phone"), 3L); + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 1L); + + realFcmService.registerToken(new FcmTokenRequestDto("device-token"), 2L); + + assertThat(fcmTokenRepository.findAllByUserId(2L)) + .extracting(FcmToken::getToken) + .containsExactlyInAnyOrder("my-tablet", "device-token"); + assertThat(fcmTokenRepository.findAllByUserId(3L)) + .extracting(FcmToken::getToken) + .containsExactly("other-phone"); + } +} From ab8d9da8af0b5de5e0b4172b129a477840d8f49d Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:29:22 +0900 Subject: [PATCH 112/130] =?UTF-8?q?[Fix]=20ERROR=20=EB=A1=9C=20=EB=A9=88?= =?UTF-8?q?=EC=B6=98=20Quartz=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EB=A5=BC=205?= =?UTF-8?q?=EB=B6=84=EB=A7=88=EB=8B=A4=20=EC=A0=90=EA=B2=80=ED=95=B4=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4=20-=20blue-green=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=A4=91=20=EA=B5=AC=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=EA=B0=80=20=EC=9E=90=EA=B8=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=20=EC=97=86=EB=8A=94=20=EC=9E=A1(Pr?= =?UTF-8?q?oblemReviewReminderJob)=EC=9D=98=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=A7=91=EC=9C=BC=EB=A9=B4=20ClassNotFound=20?= =?UTF-8?q?=EB=A1=9C=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=EA=B0=80=20ERROR=20?= =?UTF-8?q?=EA=B0=80=20=EB=90=98=EA=B3=A0,=20Quartz=20=EB=8A=94=20ERROR=20?= =?UTF-8?q?=EB=A5=BC=20=EC=8A=A4=EC=8A=A4=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=95=84=20=EC=9E=AC=EA=B8=B0?= =?UTF-8?q?=EB=8F=99=20=EC=A0=84=EA=B9=8C=EC=A7=80=20=ED=8F=B4=EB=A7=81?= =?UTF-8?q?=EC=9D=B4=20=EB=A9=88=EC=B7=84=EB=8B=A4=20-=20util/quartz/Quart?= =?UTF-8?q?zErrorTriggerRecoverer=20=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=B4=20=EC=A4=80=EB=B9=84=20=EC=99=84=EB=A3=8C=201?= =?UTF-8?q?=EB=B6=84=20=EB=92=A4=EB=B6=80=ED=84=B0=205=EB=B6=84=20?= =?UTF-8?q?=EA=B0=84=EA=B2=A9=EC=9C=BC=EB=A1=9C=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EA=B7=B8=EB=A3=B9=EC=9D=84=20?= =?UTF-8?q?=ED=9B=91=EA=B3=A0=20ERROR=20=EB=A9=B4=20resetTriggerFromErrorS?= =?UTF-8?q?tate=20=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=9E=A1=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=A5=BC=20=EC=9D=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=EA=B0=80=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=AC=20=EC=88=98=20=EC=97=86=EC=9C=BC=EB=A9=B4=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=AC=EC=A7=80=20=EC=95=8A=EB=8A=94?= =?UTF-8?q?=EB=8B=A4.=20=EA=B5=AC=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EA=B0=80=20=EB=90=98=EC=82=B4=EB=A0=B8=EB=8B=A4=EA=B0=80=20?= =?UTF-8?q?=EB=B0=94=EB=A1=9C=20=EB=8B=A4=EC=8B=9C=20ERROR=20=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=8C=EB=93=9C=EB=8A=94=20=EA=B2=83=EC=9D=84=20=EB=A7=89?= =?UTF-8?q?=EB=8A=94=EB=8B=A4=20-=20reset=20=EC=9D=80=20UPDATE=20...=20WHE?= =?UTF-8?q?RE=20TRIGGER=5FSTATE=20=3D=20'ERROR'=20=EB=9D=BC=20blue=20?= =?UTF-8?q?=EC=99=80=20green=20=EC=9D=B4=20=EA=B0=99=EC=9D=B4=20=EB=8F=8C?= =?UTF-8?q?=EB=A0=A4=EB=8F=84=20=ED=95=9C=EC=AA=BD=EB=A7=8C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=EB=90=9C=EB=8B=A4=20-=20@EnableScheduling=20=EC=9D=84?= =?UTF-8?q?=20=EC=BC=9C=EB=A9=B4=20Boot=20=EC=9D=98=20ThreadPoolTaskSchedu?= =?UTF-8?q?ler=20=EA=B0=80=20@Async=20=EC=8B=A4=ED=96=89=EA=B8=B0=EB=A1=9C?= =?UTF-8?q?=20=EC=9E=A1=ED=9E=90=20=EC=88=98=20=EC=9E=88=EC=96=B4=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=20=EB=8D=B0=EB=AA=AC=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=93=B4=EB=8B=A4=20-=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=EC=99=80=20=EA=B1=B4=EB=84=88=EB=9C=80=EC=9D=80=20WAR?= =?UTF-8?q?N,=20=EC=A0=90=EA=B2=80=20=EC=8B=A4=ED=8C=A8=EB=8A=94=20ERROR?= =?UTF-8?q?=20=EB=A1=9C=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20-=20MySQL=20JDBC?= =?UTF-8?q?=20=EC=9E=A1=EC=8A=A4=ED=86=A0=EC=96=B4=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=9E=A1=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=A1=9C=20=EC=8B=A4=EC=A0=9C=20ERROR=20=EB=A5=BC=20=EB=A7=8C?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EB=90=98=EB=8F=8C=EC=95=84=EA=B0=80?= =?UTF-8?q?=EB=8A=94=EC=A7=80=20=ED=99=95=EC=9D=B8=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #273 --- .../quartz/QuartzErrorTriggerRecoverer.java | 155 +++++++++++++ .../QuartzErrorTriggerRecovererTest.java | 116 ++++++++++ .../QuartzErrorTriggerRecoveryJdbcTest.java | 206 ++++++++++++++++++ 3 files changed, 477 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecovererTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoveryJdbcTest.java diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java new file mode 100644 index 00000000..ae00badf --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoverer.java @@ -0,0 +1,155 @@ +package com.aisip.OnO.backend.util.quartz; + +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.quartz.JobDetail; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerKey; +import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * ERROR 상태로 멈춘 Quartz 트리거를 주기적으로 WAITING 으로 되돌린다. + * + *

    blue-green 배포 중에는 구 컨테이너가 수십 초 더 살아서 같은 QRTZ_ 테이블을 폴링한다. + * 구 이미지에 없는 잡 클래스의 트리거를 집으면 {@code ClassNotFoundException} 으로 트리거가 + * ERROR 가 되는데, Quartz 는 기동 시 복구({@code recoverJobs})에서도 트리거 획득에서도 + * ERROR 를 다시 보지 않는다. 그대로 두면 다음 재기동까지 그 잡이 한 번도 돌지 않는다. + * 기동 때 {@code rescheduleJob} 으로 트리거를 교체해도, 오류가 새 컨테이너 기동 뒤에 생기면 소용이 없다. + * + *

    되돌릴 때는 이 인스턴스가 잡 클래스를 실제로 불러올 수 있는지 먼저 확인한다. 구 컨테이너에서 + * 이 점검이 돌면 자기가 모르는 잡을 되살렸다가 곧바로 다시 ERROR 로 만들기 때문이다. + * + *

    blue 와 green 이 동시에 돌려도 안전하다. {@code resetTriggerFromErrorState} 는 + * {@code UPDATE ... WHERE TRIGGER_STATE = 'ERROR'} 한 문장이라, 먼저 바꾼 쪽만 반영되고 나머지는 + * 아무 일도 하지 않는다. + * + *

    {@code @Scheduled} 대신 전용 스레드를 둔다. {@code @EnableScheduling} 을 켜면 Spring Boot 가 + * {@code ThreadPoolTaskScheduler} 빈을 만드는데, 지금은 {@code s3UploadExecutor} 때문에 기본 + * {@code TaskExecutor} 빈({@code applicationTaskExecutor})이 만들어지지 않아 그 스케줄러가 + * {@code @Async} 실행기로 잡힐 수 있다. 점검 하나 때문에 다른 비동기 경로의 실행기를 바꾸지 않는다. + */ +@Slf4j +@Component +public class QuartzErrorTriggerRecoverer { + + /** 새 컨테이너가 준비된 뒤 구 컨테이너가 내려갈 때까지 대략 1분이 걸린다. 그 뒤에 첫 점검을 한다. */ + static final Duration INITIAL_DELAY = Duration.ofMinutes(1); + + /** + * 가장 촘촘한 폴링 잡(복습 리마인더)과 같은 5분이다. 되돌린 크론 트리거는 오발화 정책에 따라 + * 곧바로 한 번 실행되므로, 점검 간격만큼 늦어질 뿐 회차가 사라지지는 않는다. + */ + static final Duration CHECK_INTERVAL = Duration.ofMinutes(5); + + private final Scheduler scheduler; + private ScheduledExecutorService executor; + + @Autowired + public QuartzErrorTriggerRecoverer(Scheduler scheduler) { + this.scheduler = scheduler; + } + + @EventListener(ApplicationReadyEvent.class) + public synchronized void start() { + if (executor != null) { + return; + } + executor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "quartz-error-trigger-recoverer"); + thread.setDaemon(true); + return thread; + }); + executor.scheduleWithFixedDelay( + this::recoverSafely, + INITIAL_DELAY.toMillis(), + CHECK_INTERVAL.toMillis(), + TimeUnit.MILLISECONDS + ); + } + + @PreDestroy + public synchronized void stop() { + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + } + + /** 예외가 밖으로 새면 ScheduledExecutorService 가 이후 실행을 조용히 멈추므로 여기서 모두 막는다. */ + private void recoverSafely() { + try { + recoverErrorTriggers(); + } catch (Exception e) { + log.error("[QuartzRecovery] ERROR 트리거 점검 실패", e); + } + } + + /** + * ERROR 상태인 트리거를 찾아 되돌린다. + * + * @return 되돌린 트리거 수 + */ + public int recoverErrorTriggers() throws SchedulerException { + int recovered = 0; + for (String group : scheduler.getTriggerGroupNames()) { + for (TriggerKey triggerKey : scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(group))) { + if (recoverIfError(triggerKey)) { + recovered++; + } + } + } + return recovered; + } + + private boolean recoverIfError(TriggerKey triggerKey) { + try { + if (scheduler.getTriggerState(triggerKey) != Trigger.TriggerState.ERROR) { + return false; + } + + Trigger trigger = scheduler.getTrigger(triggerKey); + if (trigger == null) { + return false; + } + + if (!canLoadJob(trigger)) { + return false; + } + + scheduler.resetTriggerFromErrorState(triggerKey); + log.warn("[QuartzRecovery] ERROR 상태 트리거를 되돌렸습니다 - trigger: {}, job: {}", + triggerKey, trigger.getJobKey()); + return true; + } catch (SchedulerException e) { + log.error("[QuartzRecovery] 트리거 복구 실패 - trigger: {}", triggerKey, e); + return false; + } + } + + private boolean canLoadJob(Trigger trigger) { + try { + JobDetail jobDetail = scheduler.getJobDetail(trigger.getJobKey()); + if (jobDetail == null) { + log.warn("[QuartzRecovery] ERROR 트리거의 잡이 없어 되돌리지 않습니다 - trigger: {}, job: {}", + trigger.getKey(), trigger.getJobKey()); + return false; + } + return true; + } catch (SchedulerException e) { + log.warn("[QuartzRecovery] 이 인스턴스가 잡을 불러오지 못해 ERROR 트리거를 그대로 둡니다 - trigger: {}, job: {}, 원인: {}", + trigger.getKey(), trigger.getJobKey(), e.getMessage()); + return false; + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecovererTest.java b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecovererTest.java new file mode 100644 index 00000000..d21694fb --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecovererTest.java @@ -0,0 +1,116 @@ +package com.aisip.OnO.backend.util.quartz; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobKey; +import org.quartz.JobPersistenceException; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.quartz.impl.matchers.GroupMatcher; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; + +/** + * ERROR 트리거 복구의 분기 검증. + * + *

    실제 JDBC 잡스토어에서 상태가 되돌아가는지는 {@code QuartzErrorTriggerRecoveryJdbcTest} 가 확인한다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("QuartzErrorTriggerRecoverer") +class QuartzErrorTriggerRecovererTest { + + private static final JobKey JOB_KEY = JobKey.jobKey("problem-review-reminder", "reminder"); + private static final TriggerKey TRIGGER_KEY = TriggerKey.triggerKey("problem-review-reminder-trigger", "reminder"); + + @Mock + private Scheduler scheduler; + + private QuartzErrorTriggerRecoverer recoverer; + + @BeforeEach + void setUp() { + recoverer = new QuartzErrorTriggerRecoverer(scheduler); + } + + private void givenSingleTrigger(TriggerKey key) throws SchedulerException { + given(scheduler.getTriggerGroupNames()).willReturn(List.of(key.getGroup())); + given(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals(key.getGroup()))).willReturn(Set.of(key)); + } + + private Trigger triggerOf(TriggerKey key) { + return TriggerBuilder.newTrigger().withIdentity(key).forJob(JOB_KEY).build(); + } + + @Test + @DisplayName("ERROR 트리거의 잡을 불러올 수 있으면 되돌린다") + void resetsErrorTrigger() throws Exception { + givenSingleTrigger(TRIGGER_KEY); + given(scheduler.getTriggerState(TRIGGER_KEY)).willReturn(Trigger.TriggerState.ERROR); + given(scheduler.getTrigger(TRIGGER_KEY)).willReturn(triggerOf(TRIGGER_KEY)); + given(scheduler.getJobDetail(JOB_KEY)).willReturn(JobBuilder.newJob(Job.class).withIdentity(JOB_KEY).build()); + + assertThat(recoverer.recoverErrorTriggers()).isEqualTo(1); + + then(scheduler).should().resetTriggerFromErrorState(TRIGGER_KEY); + } + + @Test + @DisplayName("ERROR 가 아닌 트리거는 건드리지 않는다") + void ignoresHealthyTrigger() throws Exception { + givenSingleTrigger(TRIGGER_KEY); + given(scheduler.getTriggerState(TRIGGER_KEY)).willReturn(Trigger.TriggerState.NORMAL); + + assertThat(recoverer.recoverErrorTriggers()).isZero(); + + then(scheduler).should(never()).resetTriggerFromErrorState(any()); + } + + @Test + @DisplayName("이 인스턴스가 잡 클래스를 모르면 되돌리지 않는다 - 구 컨테이너가 되살렸다가 다시 ERROR 로 만드는 것을 막는다") + void skipsWhenJobClassCannotBeLoaded() throws Exception { + givenSingleTrigger(TRIGGER_KEY); + given(scheduler.getTriggerState(TRIGGER_KEY)).willReturn(Trigger.TriggerState.ERROR); + given(scheduler.getTrigger(TRIGGER_KEY)).willReturn(triggerOf(TRIGGER_KEY)); + willThrow(new JobPersistenceException("Couldn't retrieve job because a required class was not found")) + .given(scheduler).getJobDetail(JOB_KEY); + + assertThat(recoverer.recoverErrorTriggers()).isZero(); + + then(scheduler).should(never()).resetTriggerFromErrorState(any()); + } + + @Test + @DisplayName("한 트리거 복구가 실패해도 나머지 트리거는 계속 점검한다") + void continuesAfterFailure() throws Exception { + TriggerKey broken = TriggerKey.triggerKey("broken", "reminder"); + given(scheduler.getTriggerGroupNames()).willReturn(List.of("reminder")); + given(scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals("reminder"))) + .willReturn(new LinkedHashSet<>(List.of(broken, TRIGGER_KEY))); + willThrow(new SchedulerException("db down")).given(scheduler).getTriggerState(broken); + given(scheduler.getTriggerState(TRIGGER_KEY)).willReturn(Trigger.TriggerState.ERROR); + given(scheduler.getTrigger(TRIGGER_KEY)).willReturn(triggerOf(TRIGGER_KEY)); + given(scheduler.getJobDetail(JOB_KEY)).willReturn(JobBuilder.newJob(Job.class).withIdentity(JOB_KEY).build()); + + assertThat(recoverer.recoverErrorTriggers()).isEqualTo(1); + + then(scheduler).should().resetTriggerFromErrorState(TRIGGER_KEY); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoveryJdbcTest.java b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoveryJdbcTest.java new file mode 100644 index 00000000..48f61468 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzErrorTriggerRecoveryJdbcTest.java @@ -0,0 +1,206 @@ +package com.aisip.OnO.backend.util.quartz; + +import com.aisip.OnO.backend.support.TestContainers; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.quartz.CronScheduleBuilder; +import org.quartz.Job; +import org.quartz.JobBuilder; +import org.quartz.JobDetail; +import org.quartz.JobExecutionContext; +import org.quartz.JobKey; +import org.quartz.Scheduler; +import org.quartz.Trigger; +import org.quartz.TriggerBuilder; +import org.quartz.TriggerKey; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.testcontainers.containers.MySQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.Date; +import java.util.Properties; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 운영과 같은 JDBC 잡스토어(MySQL + StdJDBCDelegate)에서 ERROR 트리거가 실제로 되돌아가는지 확인한다. + * + *

    테스트 프로필은 메모리 잡스토어를 쓰므로 스프링 컨텍스트와 무관하게 스케줄러를 따로 띄운다. + * QRTZ_ 테이블은 Quartz 가 제공하는 MySQL 스크립트로 별도 스키마에 만들어, 다른 테스트가 쓰는 + * {@code ono_test} 스키마를 건드리지 않는다. 스크립트에 DROP TABLE 이 들어 있어 매번 새로 만든다. + * + *

    잡은 아무 일도 하지 않는 {@link NoOpJob} 이라 FCM 등 외부 발송 경로와 무관하다. + */ +@DisplayName("Quartz ERROR 트리거 복구 - JDBC 잡스토어") +class QuartzErrorTriggerRecoveryJdbcTest { + + private static final String SCHEMA = "quartz_trigger_recovery_test"; + private static final JobKey JOB_KEY = JobKey.jobKey("problem-review-reminder", "reminder"); + private static final TriggerKey TRIGGER_KEY = TriggerKey.triggerKey("problem-review-reminder-trigger", "reminder"); + private static final String MISSING_CLASS = "com.aisip.OnO.backend.problem.reminder.NotInThisImageJob"; + + private HikariDataSource dataSource; + private JdbcTemplate jdbcTemplate; + private SchedulerFactoryBean factoryBean; + private Scheduler scheduler; + private QuartzErrorTriggerRecoverer recoverer; + + public static class NoOpJob implements Job { + @Override + public void execute(JobExecutionContext context) { + } + } + + @BeforeEach + void setUp() throws Exception { + MySQLContainer mysql = TestContainers.mysql(); + String baseUrl = "jdbc:mysql://" + mysql.getHost() + ":" + mysql.getMappedPort(MySQLContainer.MYSQL_PORT); + + // 테스트 계정은 ono_test 에만 권한이 있어 스키마 생성은 root 로 한다. + // Testcontainers 는 root 비밀번호를 테스트 계정 비밀번호와 같게 둔다. + try (Connection conn = DriverManager.getConnection(baseUrl, "root", mysql.getPassword()); + Statement statement = conn.createStatement()) { + statement.execute("CREATE DATABASE IF NOT EXISTS " + SCHEMA); + } + + dataSource = new HikariDataSource(); + dataSource.setJdbcUrl(baseUrl + "/" + SCHEMA); + dataSource.setUsername("root"); + dataSource.setPassword(mysql.getPassword()); + dataSource.setMaximumPoolSize(4); + + ResourceDatabasePopulator populator = new ResourceDatabasePopulator( + new ClassPathResource("org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql")); + populator.setCommentPrefixes("#", "--"); + populator.execute(dataSource); + + jdbcTemplate = new JdbcTemplate(dataSource); + + Properties properties = new Properties(); + properties.setProperty("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.StdJDBCDelegate"); + properties.setProperty("org.quartz.jobStore.tablePrefix", "QRTZ_"); + properties.setProperty("org.quartz.jobStore.isClustered", "false"); + properties.setProperty("org.quartz.threadPool.threadCount", "1"); + + factoryBean = new SchedulerFactoryBean(); + factoryBean.setSchedulerName("quartz-error-trigger-recovery-test"); + factoryBean.setDataSource(dataSource); + factoryBean.setQuartzProperties(properties); + factoryBean.setAutoStartup(false); + factoryBean.afterPropertiesSet(); + scheduler = factoryBean.getObject(); + + recoverer = new QuartzErrorTriggerRecoverer(scheduler); + } + + @AfterEach + void tearDown() throws Exception { + if (factoryBean != null) { + factoryBean.destroy(); + } + if (dataSource != null) { + dataSource.close(); + } + } + + private void registerReminderJob(Trigger trigger) throws Exception { + JobDetail jobDetail = JobBuilder.newJob(NoOpJob.class) + .withIdentity(JOB_KEY) + .storeDurably(true) + .build(); + scheduler.addJob(jobDetail, true); + scheduler.scheduleJob(trigger); + } + + private Trigger cronTrigger() { + return TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_KEY) + .forJob(JOB_KEY) + .withSchedule(CronScheduleBuilder.cronSchedule("0 */5 * ? * *")) + .build(); + } + + private String storedState() { + return jdbcTemplate.queryForObject( + "SELECT TRIGGER_STATE FROM QRTZ_TRIGGERS WHERE TRIGGER_NAME = ? AND TRIGGER_GROUP = ?", + String.class, TRIGGER_KEY.getName(), TRIGGER_KEY.getGroup()); + } + + private void setJobClass(String className) { + jdbcTemplate.update("UPDATE QRTZ_JOB_DETAILS SET JOB_CLASS_NAME = ? WHERE JOB_NAME = ? AND JOB_GROUP = ?", + className, JOB_KEY.getName(), JOB_KEY.getGroup()); + } + + @Test + @DisplayName("ERROR 로 바뀐 트리거를 WAITING 으로 되돌린다") + void resetsErrorTriggerToWaiting() throws Exception { + registerReminderJob(cronTrigger()); + jdbcTemplate.update("UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = 'ERROR' WHERE TRIGGER_NAME = ?", TRIGGER_KEY.getName()); + assertThat(scheduler.getTriggerState(TRIGGER_KEY)).isEqualTo(Trigger.TriggerState.ERROR); + + assertThat(recoverer.recoverErrorTriggers()).isEqualTo(1); + + assertThat(storedState()).isEqualTo("WAITING"); + assertThat(scheduler.getTriggerState(TRIGGER_KEY)).isEqualTo(Trigger.TriggerState.NORMAL); + } + + @Test + @DisplayName("blue 와 green 이 연달아 점검해도 두 번째는 아무것도 바꾸지 않는다") + void secondRecoveryIsNoOp() throws Exception { + registerReminderJob(cronTrigger()); + jdbcTemplate.update("UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = 'ERROR' WHERE TRIGGER_NAME = ?", TRIGGER_KEY.getName()); + + QuartzErrorTriggerRecoverer otherInstance = new QuartzErrorTriggerRecoverer(scheduler); + + assertThat(recoverer.recoverErrorTriggers()).isEqualTo(1); + assertThat(otherInstance.recoverErrorTriggers()).isZero(); + assertThat(storedState()).isEqualTo("WAITING"); + } + + @Test + @DisplayName("구 이미지가 트리거를 집어 ERROR 가 된 상황을 재현하고, 잡 클래스를 아는 인스턴스만 되돌린다") + void reproducesClassNotFoundAndRecovers() throws Exception { + // 곧바로 발화할 트리거를 두고, 잡 클래스를 이 JVM 에 없는 이름으로 바꿔 구 컨테이너의 시야를 흉내 낸다. + registerReminderJob(TriggerBuilder.newTrigger() + .withIdentity(TRIGGER_KEY) + .forJob(JOB_KEY) + .startAt(new Date()) + .withSchedule(CronScheduleBuilder.cronSchedule("0 0 0 1 1 ? 2099")) + .build()); + jdbcTemplate.update("UPDATE QRTZ_TRIGGERS SET NEXT_FIRE_TIME = ? WHERE TRIGGER_NAME = ?", + System.currentTimeMillis() - 1_000, TRIGGER_KEY.getName()); + setJobClass(MISSING_CLASS); + + scheduler.start(); + waitUntilStored("ERROR"); + scheduler.standby(); + + // 잡 클래스를 모르는 인스턴스는 되돌리지 않는다. 되돌려 봐야 자기가 다시 ERROR 로 만든다. + assertThat(recoverer.recoverErrorTriggers()).isZero(); + assertThat(storedState()).isEqualTo("ERROR"); + + // 새 이미지(잡 클래스를 아는 인스턴스)의 시야로 돌아오면 되돌린다. + setJobClass(NoOpJob.class.getName()); + assertThat(recoverer.recoverErrorTriggers()).isEqualTo(1); + assertThat(storedState()).isEqualTo("WAITING"); + } + + private void waitUntilStored(String expected) throws InterruptedException { + long deadline = System.currentTimeMillis() + 15_000; + while (System.currentTimeMillis() < deadline) { + if (expected.equals(storedState())) { + return; + } + Thread.sleep(100); + } + assertThat(storedState()).as("스케줄러가 트리거를 집어 상태를 바꿔야 한다").isEqualTo(expected); + } +} From cdab1dadf27a7439f79223aeb7808a5c4dbd2224 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:29:24 +0900 Subject: [PATCH 113/130] =?UTF-8?q?[Fix]=20=ED=8F=B4=EB=8D=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=99=80=20=EB=AC=B8=EC=A0=9C=20=EB=93=B1=EB=A1=9D?= =?UTF-8?q?=EC=9D=B4=20=EA=B2=B9=EC=B3=90=EB=8F=84=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=EB=90=9C=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EA=B0=80=EB=A6=AC?= =?UTF-8?q?=ED=82=A4=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EA=B0=80=20=EB=82=A8?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4=20-=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=80=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=20=EC=97=86=EC=9D=B4=20=EC=9D=BD=EA=B3=A0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=8A=94=20=EB=AF=B8=EB=A6=AC=20=EB=9C=AC=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=88=8F=EC=9C=BC=EB=A1=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=EC=B0=BE=EC=95=84=EC=84=9C,=20=EC=82=AC?= =?UTF-8?q?=EC=9D=B4=EC=97=90=20=EB=93=A4=EC=96=B4=EC=98=A8=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EA=B0=80=20=EC=86=8C=ED=94=84=ED=8A=B8=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EB=90=9C=20=ED=8F=B4=EB=8D=94=EB=A5=BC=20=EA=B0=80?= =?UTF-8?q?=EB=A6=AC=ED=82=A8=20=EC=B1=84=20=EB=82=A8=EC=95=98=EB=8B=A4.?= =?UTF-8?q?=20=EC=96=B4=EB=8A=90=20=ED=8F=B4=EB=8D=94=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EC=95=88=20=EB=B3=B4=EC=9D=B4=EB=8A=94=EB=8D=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EA=B0=9C=EC=88=98=EC=97=90=EB=8A=94=20?= =?UTF-8?q?=EC=9E=A1=ED=98=94=EB=8B=A4=20-=20=EB=AC=B8=EC=A0=9C=EB=A5=BC?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=EC=97=90=20=EB=84=A3=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C(registerProblem,=20v2=20=EB=8B=A8=EA=B1=B4,=20v2=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84,=20=EB=AC=B8=EC=A0=9C=20=ED=8F=B4=EB=8D=94?= =?UTF-8?q?=20=EC=9D=B4=EB=8F=99)=EC=9D=98=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20FOR=20SHARE=20=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BF=A8=EB=8B=A4.=20=EB=93=B1=EB=A1=9D=EB=81=BC?= =?UTF-8?q?=EB=A6=AC=EB=8A=94=20=EC=84=9C=EB=A1=9C=20=EB=A7=89=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20=EC=82=AD=EC=A0=9C=EC=9D=98=20=EB=B0=B0?= =?UTF-8?q?=ED=83=80=20=EC=9E=A0=EA=B8=88=EA=B3=BC=EB=A7=8C=20=EC=B6=A9?= =?UTF-8?q?=EB=8F=8C=ED=95=9C=EB=8B=A4=20-=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=8A=94=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=EC=9D=98=20=EC=B2=AB=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8F=B4=EB=8D=94=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20FOR=20UPDATE=20=EB=A1=9C=20=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=EB=8B=A4.=20REPEATABLE=20READ=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=EC=88=8F=EC=9D=B4=20=EC=9E=A0=EA=B8=88=20=EB=92=A4=EC=97=90=20?= =?UTF-8?q?=EB=A7=8C=EB=93=A4=EC=96=B4=EC=A0=B8=EC=95=BC=20=EA=B8=B0?= =?UTF-8?q?=EB=8B=A4=EB=A6=AC=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EB=90=9C=20=EB=AC=B8=EC=A0=9C=EA=B9=8C=EC=A7=80=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=A7=80=EC=9A=B4=EB=8B=A4.=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=ED=8F=B4=EB=8D=94=EB=A1=9C=20=EB=93=A4=EC=96=B4?= =?UTF-8?q?=EC=98=A4=EB=8A=94=20=EB=93=B1=EB=A1=9D=EB=8F=84=20=EB=A7=89?= =?UTF-8?q?=EC=9C=BC=EB=A0=A4=EA=B3=A0=20=EC=82=AD=EC=A0=9C=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=EB=A7=8C=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=ED=8F=B4=EB=8D=94=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=EB=A5=BC=20=EC=9E=A1=EC=95=98=EB=8B=A4=20-=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EA=B0=80=20=EB=A8=BC=EC=A0=80=20=EC=9E=A1?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EB=93=B1=EB=A1=9D=EC=9D=80=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EA=B9=8C=EC=A7=80=20=EA=B8=B0=EB=8B=A4=EB=A0=B8?= =?UTF-8?q?=EB=8B=A4=EA=B0=80=20=EC=B5=9C=EC=8B=A0=20=ED=96=89=EC=9D=84=20?= =?UTF-8?q?=EC=9D=BD=EA=B3=A0=20=EA=B8=B0=EC=A1=B4=20FOLDER=5FNOT=5FFOUND?= =?UTF-8?q?=20=EB=A1=9C=20=EA=B1=B0=EC=A0=88=EB=90=9C=EB=8B=A4.=20?= =?UTF-8?q?=EC=83=88=20=EC=97=90=EB=9F=AC=20=EC=BD=94=EB=93=9C=EB=8A=94=20?= =?UTF-8?q?=EC=97=86=EB=8B=A4=20-=20=EB=91=90=20=ED=8A=B8=EB=9E=9C?= =?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=A4=91=20=ED=95=98=EB=82=98=EB=A5=BC=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20=EC=A7=81=EC=A0=84=EC=97=90=20=EC=84=B8?= =?UTF-8?q?=EC=9A=B0=EA=B3=A0=20=EB=8B=A4=EB=A5=B8=20=EC=AA=BD=EC=9D=B4=20?= =?UTF-8?q?folder=20=EC=9E=A0=EA=B8=88=20=EB=8C=80=EA=B8=B0=EC=97=90=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EA=B0=84=20=EA=B2=83=EC=9D=84=20performance?= =?UTF-8?q?=5Fschema=20=EB=A1=9C=20=ED=99=95=EC=9D=B8=ED=95=9C=20=EB=92=A4?= =?UTF-8?q?=20=EC=BB=A4=EB=B0=8B=EC=8B=9C=ED=82=A4=EB=8A=94=20=EA=B2=BD?= =?UTF-8?q?=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=205=EA=B0=9C=EB=A5=BC?= =?UTF-8?q?=20=EB=84=A3=EC=97=88=EB=8B=A4.=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=84=20=EC=BD=94=EB=93=9C=EC=97=90=EC=84=9C=EB=8A=94=205?= =?UTF-8?q?=EA=B0=9C=20=EB=AA=A8=EB=91=90=20=EC=8B=A4=ED=8C=A8=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #233 --- .../folder/repository/FolderRepository.java | 33 ++ .../backend/folder/service/FolderService.java | 3 + .../problem/service/ProblemService.java | 8 +- .../FolderDeleteProblemRegisterRaceTest.java | 299 ++++++++++++++++++ 4 files changed, 339 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 8371f6a4..07debf20 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -1,7 +1,9 @@ package com.aisip.OnO.backend.folder.repository; import com.aisip.OnO.backend.folder.entity.Folder; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,6 +18,37 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByUserId(Long userId); + /** + * 문제를 폴더에 넣는 경로(등록, 이동)에서 폴더를 공유 잠금(FOR SHARE)으로 읽는다. (#233) + * + *

    잠금 없이 읽으면 폴더 삭제가 아직 커밋되지 않은 폴더를 살아 있는 것으로 보고 문제를 넣어, + * 삭제된 폴더를 가리키는 고아 문제가 남았다. 공유 잠금은 삭제의 배타 잠금과만 충돌하므로 + * 같은 폴더로 들어오는 등록끼리는 서로 기다리지 않는다. 삭제가 먼저 잡았으면 커밋까지 기다린 뒤 + * 최신 행을 다시 읽고, 그때는 {@code deleted_at} 이 찍혀 있어 {@code @SQLRestriction} 에 걸러진다. + */ + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select f from Folder f where f.id = :folderId") + Optional findByIdForShare(@Param("folderId") Long folderId); + + /** {@link #findByIdForShare} 의 일괄 등록용. 잠금은 PK 순서로 잡힌다. */ + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select f from Folder f where f.id in :folderIds") + List findAllByIdInForShare(@Param("folderIds") Collection folderIds); + + /** + * 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) + * + *

    삭제할 폴더만이 아니라 사용자 폴더 전체를 잡는 이유는 하위 폴더 때문이다. 하위 폴더 목록은 + * 잠그기 전에는 알 수 없고, 하위 폴더로 들어오는 등록도 막아야 한다. + * + *

    반드시 트랜잭션의 첫 조회여야 한다. REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다. + * 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아 + * 문제 삭제에서 빠진다. 잠금 조회(FOR UPDATE)는 스냅숏을 만들지 않는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.userId = :userId") + List lockAllByUserId(@Param("userId") Long userId); + /** * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. * diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index f523cb17..929bce66 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -167,6 +167,9 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { } public void deleteFoldersWithProblems(Long userId, List folderIds) { + // 같은 폴더로 들어오는 문제 등록과 순서를 맞춘다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) + folderRepository.lockAllByUserId(userId); + // 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함) Set allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds); diff --git a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java index 085408b5..bd4b69e2 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/service/ProblemService.java @@ -223,7 +223,7 @@ public Long registerProblem(ProblemRegisterDto problemRegisterDto, Long userId) throw new ApplicationException(ProblemErrorCase.PROBLEM_FOLDER_ID_REQUIRED); } - Folder folder = folderRepository.findById(problemRegisterDto.folderId()) + Folder folder = folderRepository.findByIdForShare(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); validateFolderOwner(folder, userId); @@ -391,7 +391,7 @@ private Folder resolveRegisterFolder(Long folderId, Long userId) { return resolveRootFolder(userId); } - return folderRepository.findById(folderId) + return folderRepository.findByIdForShare(folderId) .map(folder -> { validateFolderOwner(folder, userId); return folder; @@ -414,7 +414,7 @@ private Map resolveRegisterFolders(List regi return Map.of(); } - Map foldersById = folderRepository.findAllById(folderIds).stream() + Map foldersById = folderRepository.findAllByIdInForShare(folderIds).stream() .collect(Collectors.toMap(Folder::getId, folder -> folder)); if (foldersById.size() != folderIds.size()) { throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); @@ -628,7 +628,7 @@ public void updateProblemFolder(ProblemRegisterDto problemRegisterDto, Long user Problem problem = findProblemEntity(problemRegisterDto.problemId(), userId); if (problemRegisterDto.folderId() != null) { - Folder folder = folderRepository.findById(problemRegisterDto.folderId()) + Folder folder = folderRepository.findByIdForShare(problemRegisterDto.folderId()) .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); validateFolderOwner(folder, userId); diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java new file mode 100644 index 00000000..59c36810 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java @@ -0,0 +1,299 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.exception.FolderErrorCase; +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2BatchDto; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problem.support.ProblemTestSupport; +import com.aisip.OnO.backend.support.TestContainers; +import com.aisip.OnO.backend.user.entity.User; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.assertj.core.api.Assertions.fail; + +/** + * 폴더 삭제와 문제 등록이 같은 폴더에서 겹칠 때 고아 문제가 남지 않는지 본다. (#233) + * + *

    예전에는 폴더는 소프트 삭제되는데 그 사이 등록된 문제가 삭제된 폴더를 가리킨 채 살아남았다. + * 어느 폴더에서도 보이지 않지만 문제 개수 집계에는 잡히는 상태다. + * + *

    스레드를 동시에 출발시키는 방식({@link ConcurrentRunner})은 경합이 매번 같은 순서로 일어난다는 + * 보장이 없다. 여기서는 순서를 직접 만든다. 한쪽 트랜잭션을 커밋 직전에 세워 두고, 다른 쪽이 + * {@code folder} 테이블 잠금에서 실제로 기다리기 시작한 것을 {@code performance_schema} 로 확인한 뒤에 + * 앞 트랜잭션을 커밋시킨다. 시간(sleep)으로 순서를 맞추지 않으므로 결과가 흔들리지 않는다. + */ +@DisplayName("동시성 - 폴더 삭제와 문제 등록 경합") +class FolderDeleteProblemRegisterRaceTest extends ProblemTestSupport { + + private static final long STEP_TIMEOUT_SECONDS = 20; + + @Autowired + private ProblemService problemService; + + @Autowired + private FolderService folderService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ExecutorService executor = Executors.newFixedThreadPool(2); + private final CountDownLatch release = new CountDownLatch(1); + + private User user; + private Folder rootFolder; + private Folder target; + + @BeforeEach + void setUpFolders() { + user = fixtures.createUser(); + rootFolder = fixtures.createRootFolder(user.getId()); + target = fixtures.createFolder(user.getId(), "삭제 대상", rootFolder); + saveProblem(user.getId(), target); + } + + @AfterEach + void releaseHeldTransaction() throws InterruptedException { + // 단언이 중간에 실패해도 세워 둔 트랜잭션이 커밋되고 스레드가 끝나야 다음 테스트의 DB 정리가 막히지 않는다. + release.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue(); + } + + @Nested + @DisplayName("등록이 폴더를 먼저 잡은 경우") + class RegistrationFirst { + + @Test + @DisplayName("삭제는 등록이 끝날 때까지 기다렸다가 새로 들어온 문제까지 함께 지운다") + void deleteWaitsAndRemovesProblemRegisteredMeanwhile() throws Exception { + Future registration = holdUntilReleased(() -> problemService.registerProblem( + new ProblemRegisterDto(null, "경합 메모", null, target.getId(), null), user.getId())); + + Future deletion = executor.submit( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + awaitFolderLockWaitOrDone(deletion); + release.countDown(); + + registration.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertThat(aliveProblemCountInFolder(target.getId())) + .as("등록된 문제가 삭제된 폴더를 가리킨 채 남으면 어느 폴더에서도 안 보이는 고아가 된다") + .isZero(); + assertNoOrphanProblems(); + } + + @Test + @DisplayName("하위 폴더에 v2 일괄 등록 중 상위 폴더를 지워도 하위 폴더 문제까지 함께 지워진다") + void deletingParentRemovesBatchRegisteredIntoSubFolder() throws Exception { + Folder child = fixtures.createFolder(user.getId(), "하위", target); + + Future registration = holdUntilReleased(() -> problemService.registerProblemsV2( + new ProblemRegisterV2BatchDto(List.of( + new ProblemRegisterV2Dto(null, "일괄1", null, child.getId(), null, null, null), + new ProblemRegisterV2Dto(null, "일괄2", null, child.getId(), null, null, null))), + user.getId())); + + Future deletion = executor.submit( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + awaitFolderLockWaitOrDone(deletion); + release.countDown(); + + registration.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertThat(aliveProblemCountInFolder(child.getId())).isZero(); + assertNoOrphanProblems(); + } + } + + @Nested + @DisplayName("삭제가 폴더를 먼저 잡은 경우") + class DeletionFirst { + + @Test + @DisplayName("등록은 삭제가 끝날 때까지 기다렸다가 FOLDER_NOT_FOUND 로 거절된다") + void registrationWaitsAndIsRejected() throws Exception { + Future deletion = holdUntilReleased( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblem( + new ProblemRegisterDto(null, "경합 메모", null, target.getId(), null), user.getId())); + awaitFolderLockWaitOrDone(registration); + release.countDown(); + + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertRejectedWithFolderNotFound(registration); + assertThat(aliveProblemCountInFolder(target.getId())).isZero(); + assertNoOrphanProblems(); + } + + @Test + @DisplayName("v2 단건 등록도 삭제된 하위 폴더로는 들어가지 않는다") + void v2RegistrationIntoDeletedSubFolderIsRejected() throws Exception { + Folder child = fixtures.createFolder(user.getId(), "하위", target); + + Future deletion = holdUntilReleased( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "경합 메모", null, child.getId(), null, null, null), user.getId())); + awaitFolderLockWaitOrDone(registration); + release.countDown(); + + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertRejectedWithFolderNotFound(registration); + assertNoOrphanProblems(); + } + + @Test + @DisplayName("삭제 중인 폴더로 문제를 옮기는 요청도 FOLDER_NOT_FOUND 로 거절되고 문제는 원래 폴더에 남는다") + void moveIntoDeletedFolderIsRejected() throws Exception { + Folder keep = fixtures.createFolder(user.getId(), "남는 폴더", rootFolder); + Problem moving = saveProblem(user.getId(), keep); + + Future deletion = holdUntilReleased( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + + Future move = executor.submit(() -> problemService.updateProblemFolder( + new ProblemRegisterDto(moving.getId(), null, null, target.getId(), null), user.getId())); + awaitFolderLockWaitOrDone(move); + release.countDown(); + + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertRejectedWithFolderNotFound(move); + assertThat(aliveProblemCountInFolder(keep.getId())).isEqualTo(1); + assertNoOrphanProblems(); + } + } + + /** 작업을 트랜잭션 안에서 실행하고, 커밋하기 직전에 {@link #release} 가 풀릴 때까지 붙잡아 둔다. */ + private Future holdUntilReleased(Runnable work) throws InterruptedException { + CountDownLatch workDone = new CountDownLatch(1); + Future future = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + work.run(); + workDone.countDown(); + awaitRelease(); + })); + + boolean reached = workDone.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!reached) { + release.countDown(); + rethrowIfFailed(future); + fail("먼저 잡아야 할 트랜잭션이 %d초 안에 작업을 끝내지 못했다", STEP_TIMEOUT_SECONDS); + } + return future; + } + + private void awaitRelease() { + try { + if (!release.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("release 신호를 받지 못했다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + /** + * 뒤에 들어온 요청이 {@code folder} 행 잠금에서 실제로 기다리기 시작할 때까지 기다린다. + * + *

    잠금 없이 그냥 끝나 버리는 경우(수정 전 코드의 일부 경로)도 있으므로 요청이 끝나면 바로 돌아간다. + * 이때는 뒤이은 상태 단언이 고아 데이터를 잡아낸다. + */ + private void awaitFolderLockWaitOrDone(Future waiter) throws Exception { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(STEP_TIMEOUT_SECONDS); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT COUNT(*) + FROM performance_schema.data_lock_waits w + JOIN performance_schema.data_locks l + ON l.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID + WHERE l.OBJECT_SCHEMA = DATABASE() AND l.OBJECT_NAME = 'folder' + """)) { + while (System.nanoTime() < deadline) { + if (waiter.isDone()) { + return; + } + try (ResultSet resultSet = statement.executeQuery()) { + resultSet.next(); + if (resultSet.getLong(1) > 0) { + return; + } + } + Thread.sleep(10); + } + } + fail("뒤에 들어온 요청이 %d초 안에 folder 잠금 대기에 들어가지도, 끝나지도 않았다", STEP_TIMEOUT_SECONDS); + } + + /** performance_schema 는 테스트 계정 권한으로 볼 수 없어 root 로 붙는다. */ + private Connection rootConnection() throws SQLException { + return DriverManager.getConnection( + TestContainers.mysql().getJdbcUrl(), "root", TestContainers.mysql().getPassword()); + } + + private void assertRejectedWithFolderNotFound(Future future) { + Throwable thrown = catchThrowable(() -> future.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)); + + assertThat(thrown) + .as("삭제가 커밋된 뒤의 등록은 성공하면 안 되고, 500 이 아니라 기존 404 계약으로 거절돼야 한다") + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(ApplicationException.class); + assertThat(((ApplicationException) thrown.getCause()).getErrorCase()) + .isEqualTo(FolderErrorCase.FOLDER_NOT_FOUND); + } + + private void rethrowIfFailed(Future future) { + try { + future.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (Exception e) { + throw new AssertionError("먼저 잡아야 할 트랜잭션이 실패했다", e); + } + } + + private long aliveProblemCountInFolder(Long folderId) { + return jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM problem WHERE folder_id = ? AND deleted_at IS NULL", Long.class, folderId); + } + + private void assertNoOrphanProblems() { + Long orphanCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM problem p + JOIN folder f ON f.id = p.folder_id + WHERE p.user_id = ? AND p.deleted_at IS NULL AND f.deleted_at IS NOT NULL + """, Long.class, user.getId()); + assertThat(orphanCount) + .as("삭제된 폴더를 가리키는 살아 있는 문제") + .isZero(); + } +} From 09b0560451e7cf75fc313addc52b592721c8eb9d Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Thu, 17 Sep 2026 13:19:24 +0900 Subject: [PATCH 114/130] =?UTF-8?q?[Fix]=20=EB=8D=98=EC=A7=80=EB=8A=94=20?= =?UTF-8?q?=EA=B3=B3=EC=9D=B4=20=EC=97=86=EB=8A=94=20StudyRoomErrorCase=20?= =?UTF-8?q?=EC=84=B8=20=EA=B0=9C=EB=A5=BC=20=EC=A7=80=EC=9A=B4=EB=8B=A4=20?= =?UTF-8?q?-=20SESSION=5FALREADY=5FACTIVE(10011),=20SESSION=5FNOT=5FFOUND(?= =?UTF-8?q?10012)=20=EB=8A=94=20=EA=B3=B5=EB=B6=80=20=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=9D=B4=20V13=20=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B9=A0=EC=A7=84=20=EB=92=A4=EB=A1=9C=20main=20=EA=B3=BC=20de?= =?UTF-8?q?velop=20=EC=96=B4=EB=94=94=EC=97=90=EC=84=9C=EB=8F=84=20?= =?UTF-8?q?=EB=8D=98=EC=A7=80=EC=A7=80=20=EC=95=8A=EC=95=98=EB=8B=A4=20-?= =?UTF-8?q?=20INVALID=5FREACTION=5FEMOJI(10015)=20=EB=8A=94=20=EB=A6=AC?= =?UTF-8?q?=EC=95=A1=EC=85=98=20=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=B4=20CustomEmojiValidator=20=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B2=A8=EA=B0=84=20=EB=92=A4=2011001=20=EC=9D=B4=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=20=EB=82=98=EA=B0=80=EA=B3=A0=20=EC=9E=88?= =?UTF-8?q?=EC=97=88=EB=8B=A4.=20=EB=A6=AC=EC=95=A1=EC=85=98=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=BD=94=EB=93=9C=EB=8A=94=2011001=20=EB=A1=9C=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=ED=95=9C=EB=8B=A4=20-=20=EA=B5=AC=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=95=B1=20ErrorMessageMapper=20=EA=B0=80=20?= =?UTF-8?q?=EC=84=B8=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=95=84=EC=A7=81=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=ED=95=98=EA=B3=A0=20=EC=9E=88=EC=96=B4=20enu?= =?UTF-8?q?m=20=EC=9E=90=EB=A6=AC=EC=97=90=20=EC=9E=AC=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=EB=8A=94=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=EC=9D=84=20=EB=82=A8=EA=B2=BC=EB=8B=A4=20-?= =?UTF-8?q?=20unreachableCasesAreKnown=20=EC=9D=84=20=EC=A7=80=EC=9A=B4=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EA=B0=80=20=EB=8B=A4=EC=8B=9C=20=EC=93=B0?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=EC=A7=80=20=EB=B3=B4?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BF=A8=EB=8B=A4.=20=EB=82=A8=EC=9D=80=2017=EA=B0=9C=EB=8A=94?= =?UTF-8?q?=20=EB=AA=A8=EB=91=90=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EC=9E=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #237 --- .../exception/StudyRoomErrorCase.java | 7 +++-- .../StudyRoomErrorCaseScenarioTest.java | 28 ++++++++----------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java index 3526e538..361f9816 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCase.java @@ -18,11 +18,12 @@ public enum StudyRoomErrorCase implements ErrorCase { ALREADY_MEMBER(409, 10008, "이미 참여 중인 스터디룸입니다."), CHALLENGE_NOT_FOUND(404, 10009, "챌린지를 찾을 수 없습니다."), CHALLENGE_LIMIT_EXCEEDED(409, 10010, "진행 중인 챌린지 수를 초과했습니다."), - SESSION_ALREADY_ACTIVE(409, 10011, "이미 진행 중인 공부 세션이 있습니다."), - SESSION_NOT_FOUND(404, 10012, "공부 세션을 찾을 수 없습니다."), + // 삭제된 번호 10011, 10012, 10015 는 다른 의미로 재사용하지 않는다. + // 운영 중인 4.0.0 앱이 이 번호를 옛 문구로 매핑하고 있다 (OnO_FRONT lib/Util/ErrorMessageMapper.dart:120-129). + // 10011, 10012 는 공부 세션 기능과 함께 지웠다. SHARED_PROBLEM_NOT_FOUND(404, 10013, "공유 문제를 찾을 수 없습니다."), REPORT_NOT_FOUND(404, 10014, "주간 리포트를 찾을 수 없습니다."), - INVALID_REACTION_EMOJI(400, 10015, "허용되지 않은 반응입니다."), + // 10015 는 리액션 이모지 검증이 CustomEmojiErrorCase.INVALID_EMOJI_KEY(11001) 로 옮겨가 지웠다. 재사용 금지. INVALID_STUDY_ROOM_REQUEST(400, 10016, "스터디룸 요청 값이 올바르지 않습니다."), SHARED_PROBLEM_COMMENT_NOT_FOUND(404, 10017, "공유 문제 댓글을 찾을 수 없습니다."), INVALID_SHARED_PROBLEM_COMMENT(400, 10018, "공유 문제 댓글 내용이 올바르지 않습니다."), diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCaseScenarioTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCaseScenarioTest.java index 86e7bdfd..a1d79963 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCaseScenarioTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/exception/StudyRoomErrorCaseScenarioTest.java @@ -35,31 +35,25 @@ class StudyRoomErrorCaseScenarioTest extends StudyRoomTestSupport { /** - * 프로덕션 코드에서 더 이상 던지지 않는 값들. + * 지운 값이 쓰던 errorCode. 구버전 앱이 아직 이 코드를 매핑하고 있어 다른 에러에 다시 붙이지 않는다. * *

      - *
    • {@code SESSION_ALREADY_ACTIVE}, {@code SESSION_NOT_FOUND} — 공부 세션 기능이 제거되면서 남은 값
    • - *
    • {@code INVALID_REACTION_EMOJI} — 이모지 검증이 공통 {@code CustomEmojiValidator} 로 옮겨가면서 + *
    • 10011 SESSION_ALREADY_ACTIVE, 10012 SESSION_NOT_FOUND — 공부 세션 기능이 제거되면서 지웠다
    • + *
    • 10015 INVALID_REACTION_EMOJI — 이모지 검증이 공통 {@code CustomEmojiValidator} 로 옮겨가 * 실제 응답은 11001(INVALID_EMOJI_KEY)로 나간다
    • *
    */ - private static final List UNREACHABLE = List.of( - StudyRoomErrorCase.SESSION_ALREADY_ACTIVE, - StudyRoomErrorCase.SESSION_NOT_FOUND, - StudyRoomErrorCase.INVALID_REACTION_EMOJI - ); + private static final List RETIRED_ERROR_CODES = List.of(10011, 10012, 10015); @Test - @DisplayName("현재 발생 경로가 없는 값은 세 개뿐이고 그 목록은 고정되어 있다") - void unreachableCasesAreKnown() { + @DisplayName("값은 17개로 모두 아래 시나리오가 있고, 지운 errorCode 는 다시 쓰지 않는다") + void retiredCodesAreNotReused() { assertThat(Arrays.stream(StudyRoomErrorCase.values()).toList()) - .as("전체 에러 케이스") - .hasSize(20) - .containsAll(UNREACHABLE); - assertThat(UNREACHABLE) - .as("발생 경로가 없는 값 — 새로 쓰기 시작하면 시나리오 테스트를 추가한다") - .extracting(StudyRoomErrorCase::getErrorCode) - .containsExactly(10011, 10012, 10015); + .as("전체 에러 케이스 — 값을 추가하면 시나리오 테스트도 추가한다") + .hasSize(17); + assertThat(Arrays.stream(StudyRoomErrorCase.values()).map(StudyRoomErrorCase::getErrorCode).toList()) + .as("지운 errorCode 를 재사용하면 구버전 앱이 엉뚱한 메시지를 띄운다") + .doesNotContainAnyElementsOf(RETIRED_ERROR_CODES); } @Nested From d59da222726d4a1a301497d7755299b8fab26144 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 16:24:25 +0900 Subject: [PATCH 115/130] =?UTF-8?q?[Fix]=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=B4=20=EB=8F=99=EC=8B=9C=EC=97=90=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EC=99=80=EB=8F=84=20500=20=EC=9D=B4=20?= =?UTF-8?q?=EB=90=98=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 등록을 FcmTokenWriter 의 독립 트랜잭션으로 옮기고, 중복 키 실패는 트랜잭션 밖에서 잡는다 트랜잭션 안에서 잡으면 rollback-only 로 표시돼 커밋 시점에 그대로 터진다 - 같은 사용자가 같은 토큰을 겹쳐 등록해도 결과 상태가 같으므로 성공으로 본다 롤백된 트랜잭션이 이전 소유자 행 삭제(#271)까지 되돌리므로 새 트랜잭션에서 한 번 더 등록한다 - V44: fcm_token.token 에 비유니크 보조 인덱스를 온라인 DDL 로 추가한다 유니크 인덱스 선두가 user_id 라, 등록 때마다 하던 token 단독 조회가 테이블을 전부 읽고 있었다 - 같은 사용자의 동시 등록과 계정 전환이 겹치는 경우를 Testcontainers MySQL 에서 확인하는 테스트를 추가했다 - 기존 FCM 테스트가 new FcmService(...) 로 만들어 트랜잭션 애노테이션이 적용되지 않던 것을 프록시로 감쌌다 --- .../OnO/backend/util/fcm/entity/FcmToken.java | 5 + .../backend/util/fcm/service/FcmService.java | 48 +++-- .../util/fcm/service/FcmTokenWriter.java | 65 ++++++ .../V44__add_fcm_token_token_index.sql | 19 ++ .../FcmTokenRegistrationConcurrencyTest.java | 189 ++++++++++++++++++ .../migration/FcmTokenIndexMigrationTest.java | 96 +++++++++ .../util/fcm/service/FcmServiceTest.java | 13 +- .../FcmTokenOwnerSwitchIntegrationTest.java | 22 +- .../util/fcm/support/BrokerOutageFcm.java | 2 + 9 files changed, 438 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java create mode 100644 src/main/resources/db/migration/V44__add_fcm_token_token_index.sql create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/FcmTokenRegistrationConcurrencyTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenIndexMigrationTest.java diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java b/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java index 5705212d..b66cc7e9 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/entity/FcmToken.java @@ -13,6 +13,11 @@ @Table(name = "fcm_token", uniqueConstraints = { @UniqueConstraint(name = "idx_fcm_token_user_token", columnNames = {"user_id", "token"}) + }, + // 유니크 인덱스는 선두가 user_id 라 token 단독 조회가 타지 못한다. + // 등록 때마다 이전 소유자 행을 token 으로 찾으므로 보조 인덱스를 따로 둔다. (운영 반영은 V44) + indexes = { + @Index(name = "idx_fcm_token_token", columnList = "token") } ) public class FcmToken extends BaseEntity { diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index bfd8fcf1..cccc6ef8 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -14,10 +14,12 @@ import com.google.firebase.messaging.Notification; import io.micrometer.core.instrument.MeterRegistry; import io.micrometer.core.instrument.Timer; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @@ -28,6 +30,7 @@ public class FcmService { private final FcmTokenRepository fcmTokenRepository; + private final FcmTokenWriter fcmTokenWriter; private final FirebaseMessaging firebaseMessaging; private final MeterRegistry meterRegistry; @@ -40,24 +43,41 @@ public class FcmService { *

    같은 기기에서 A 가 로그아웃하고 B 가 로그인하면 같은 토큰으로 등록이 들어온다. * 이때 (A, 토큰) 행을 남겨 두면 A 앞으로 가는 알림(댓글 작성자 이름, 미리보기 포함)이 B 기기에 뜬다. * 클라이언트가 로그아웃 때 토큰을 해제하지 않으므로, 서버가 등록 시점에 이전 소유자 행을 지운다. + * 실제 조회·삭제·삽입은 {@link FcmTokenWriter} 가 독립 트랜잭션으로 수행한다. * - *

    {@code token = ? AND user_id <> ?} 로 바로 DELETE 하지 않고 id 로 골라 지운다. - * token 단독 인덱스가 없어 조건 DELETE 는 테이블 전체 행에 잠금을 걸고, 그동안 다른 사용자 등록이 막힌다. - * 대부분의 등록은 지울 행이 없으니 잠금 없는 조회로 끝난다. + *

    여기서 트랜잭션을 열지 않는 이유가 있다. 삽입은 (user_id, token) 유니크 인덱스와 경쟁하는데, + * 실패한 트랜잭션 안에서는 재조회로 복구할 수 없다. 앱이 로그인 직후 등록과 토큰 갱신 등록을 겹쳐 보내면 + * 두 요청이 모두 "없음" 을 읽고 INSERT 해 뒤엣것이 Duplicate entry 로 떨어졌고, 운영에서 500 이 나갔다. + * (Sentry JAVA-SPRING-BOOT-5J, JAVA-SPRING-BOOT-5H) + * + *

    같은 토큰을 한 번 더 등록하는 것은 결과 상태가 같은 재시도다. 그래서 중복은 실패가 아니라 성공으로 본다. + * 다만 롤백된 트랜잭션은 이전 소유자 행 삭제까지 되돌리므로, 새 트랜잭션에서 한 번 더 등록을 돌려 맞춘다. */ - @Transactional + @Transactional(propagation = Propagation.NOT_SUPPORTED) public void registerToken(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { - List previousOwnerTokens = - fcmTokenRepository.findAllByTokenAndUserIdNot(fcmTokenRequestDto.token(), userId); - if (!previousOwnerTokens.isEmpty()) { - fcmTokenRepository.deleteAllInBatch(previousOwnerTokens); - log.info("FCM token moved to another user - userId: {}, removedPreviousOwnerRows: {}", - userId, previousOwnerTokens.size()); + try { + fcmTokenWriter.register(fcmTokenRequestDto, userId); + } catch (DataIntegrityViolationException e) { + log.info("FCM token insert lost the race, retrying - userId: {}", userId); + registerAfterLosingRace(fcmTokenRequestDto, userId); } + } - if(!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())){ - FcmToken fcmToken = FcmToken.From(fcmTokenRequestDto, userId); - fcmTokenRepository.save(fcmToken); + /** + * 중복으로 실패한 등록을 새 트랜잭션에서 한 번 더 시도한다. + * + *

    이미 행이 있으면 INSERT 없이 이전 소유자 정리만 확인하고 끝난다. + * 재시도까지 중복으로 끝났다면 그 짧은 사이에 또 다른 요청이 같은 행을 넣었다는 뜻이므로, + * 원하는 상태가 되었는지 확인하고 되었으면 성공으로 본다. 아니면 원래 예외를 그대로 올린다. + */ + private void registerAfterLosingRace(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { + try { + fcmTokenWriter.register(fcmTokenRequestDto, userId); + } catch (DataIntegrityViolationException retryFailure) { + if (!fcmTokenWriter.exists(userId, fcmTokenRequestDto.token())) { + throw retryFailure; + } + log.info("FCM token already registered by a concurrent request - userId: {}", userId); } } diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java new file mode 100644 index 00000000..9296936e --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenWriter.java @@ -0,0 +1,65 @@ +package com.aisip.OnO.backend.util.fcm.service; + +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 토큰 한 건의 등록을 독립된 트랜잭션으로 수행한다. + * + *

    등록은 (user_id, token) 유니크 인덱스와 경쟁하는 작업이라 + * "조회 → 없으면 삽입"이 한 트랜잭션 안에서는 안전하게 복구되지 않는다. + * 삽입이 Duplicate entry 로 실패하는 순간 그 트랜잭션은 롤백 대상이 되고, + * 그 안에서 예외를 잡아도 rollback-only 로 표시돼 커밋 시점에 터진다. + * MySQL REPEATABLE READ 에서는 같은 트랜잭션으로 다시 조회해도 + * 스냅샷 때문에 남이 커밋한 행이 보이지 않는다. + * + *

    그래서 중복 판단은 {@link FcmService} 가 트랜잭션 바깥에서 하고, + * 여기서는 매 호출마다 새 트랜잭션(= 새 스냅샷)을 연다. 태그 생성의 {@code TagWriter} 와 같은 구조다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FcmTokenWriter { + + private final FcmTokenRepository fcmTokenRepository; + + /** + * 이전 소유자 행을 지우고, 없으면 토큰 행을 새로 넣는다. + * + *

    유니크 인덱스에 걸리면 {@link org.springframework.dao.DataIntegrityViolationException} 이 + * 이 트랜잭션 밖으로 그대로 올라간다. 호출자가 새 트랜잭션에서 복구한다. + * 이때 이 트랜잭션이 통째로 롤백되므로 이전 소유자 행 삭제도 함께 되돌아가는데, + * 삭제를 되살리는 것은 호출자의 재시도 몫이다. + * + *

    {@code saveAndFlush} 로 삽입을 트랜잭션 안에서 앞당긴다. 커밋 시점 flush 까지 미루면 + * 실패 지점이 트랜잭션 경계와 겹쳐, 같은 메서드 안에서 실패 여부를 판단할 수 없다. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void register(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { + List previousOwnerTokens = + fcmTokenRepository.findAllByTokenAndUserIdNot(fcmTokenRequestDto.token(), userId); + if (!previousOwnerTokens.isEmpty()) { + fcmTokenRepository.deleteAllInBatch(previousOwnerTokens); + log.info("FCM token moved to another user - userId: {}, removedPreviousOwnerRows: {}", + userId, previousOwnerTokens.size()); + } + + if (!fcmTokenRepository.existsByUserIdAndToken(userId, fcmTokenRequestDto.token())) { + fcmTokenRepository.saveAndFlush(FcmToken.From(fcmTokenRequestDto, userId)); + } + } + + /** 새 트랜잭션에서 읽는다. 실패한 트랜잭션의 스냅샷으로는 남이 커밋한 행이 보이지 않기 때문이다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public boolean exists(Long userId, String token) { + return fcmTokenRepository.existsByUserIdAndToken(userId, token); + } +} diff --git a/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql b/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql new file mode 100644 index 00000000..de8f6d88 --- /dev/null +++ b/src/main/resources/db/migration/V44__add_fcm_token_token_index.sql @@ -0,0 +1,19 @@ +-- fcm_token.token 에 비유니크 보조 인덱스를 건다. +-- +-- 지금 이 테이블의 인덱스는 (user_id, token) 유니크 하나뿐이다. 선두 컬럼이 user_id 라 +-- token 단독 조회는 인덱스를 타지 못하고 테이블을 전부 읽는다. +-- 그 조회가 등록 경로 한복판에 있다. 등록은 로그인과 토큰 갱신 때마다 들어오고, +-- 매번 이전 소유자 행을 찾으려고 token 으로만 조회한다(FcmService -> FcmTokenWriter.register). +-- 중복 등록이 겹쳐 재시도까지 가면 같은 조회를 한 번 더 한다. +-- +-- 유니크가 아니라 보통 인덱스다. 한 토큰에 행이 둘 이상 남아 있어도 ALTER 가 실패하지 않는다. +-- (V43 주석에 적은 대로, 배포 중 이전 버전 앱이 중복 쌍을 넣으면 유니크 추가는 그대로 기동 실패가 된다.) +-- +-- ALGORITHM=INPLACE, LOCK=NONE 을 명시한다. MySQL 8.0 InnoDB 의 보조 인덱스 추가는 +-- 온라인 DDL 대상이라 진행 중에도 INSERT/DELETE 가 계속 처리된다. +-- 명시해 두면 어떤 이유로든 온라인으로 못 할 때 테이블을 오래 잠그는 대신 즉시 실패한다. +-- (실패하면 앱이 뜨지 않으므로 바로 알 수 있고, 잠금으로 서비스가 멈추는 쪽보다 낫다.) +ALTER TABLE fcm_token + ADD INDEX idx_fcm_token_token (token), + ALGORITHM = INPLACE, + LOCK = NONE; diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FcmTokenRegistrationConcurrencyTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FcmTokenRegistrationConcurrencyTest.java new file mode 100644 index 00000000..ec21dbbb --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FcmTokenRegistrationConcurrencyTest.java @@ -0,0 +1,189 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import com.aisip.OnO.backend.util.fcm.service.FcmTokenWriter; +import com.google.firebase.messaging.FirebaseMessaging; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * 같은 기기에서 토큰 등록 요청이 동시에 들어올 때. (Sentry JAVA-SPRING-BOOT-5J / 5H) + * + *

    운영에서 {@code POST /api/fcm/token} 이 500 으로 떨어졌다. + * {@code Duplicate entry '511-fqD5528...' for key 'fcm_token.idx_fcm_token_user_token'} 이었다. + * 등록은 "있는지 보고 없으면 저장" 인데 (user_id, token) 에 유니크 제약이 걸려 있어, + * 같은 사용자·같은 토큰 요청이 겹치면 둘 다 "없음" 을 읽고 INSERT 해 뒤엣것이 제약에 걸린다. + * + *

    같은 토큰을 한 번 더 등록하는 것은 사용자 입장에서 아무 의미가 없는 재시도다. + * 결과 상태(그 사용자에게 그 토큰 행이 하나)가 같으므로 둘 다 성공으로 끝나야 한다. + * + *

    유니크 제약은 실제 MySQL 에서만 걸리므로 Testcontainers 컨텍스트 위에서 돌린다. + * 통합 컨텍스트의 {@link FcmService} 는 실발송 사고를 막으려고 목이라, 여기서는 직접 만든다. + * 다만 {@code new} 로 만든 객체는 트랜잭션 애노테이션이 적용되지 않아 검증이 헐거워지므로, + * {@code BrokerOutageFcm} 과 같은 방식으로 트랜잭션 인터셉터를 씌운 프록시를 쓴다. + * {@code FcmTokenWriter} 는 컨텍스트의 프록시된 실제 빈이다. + */ +@DisplayName("동시성 - FCM 토큰 등록") +class FcmTokenRegistrationConcurrencyTest extends IntegrationTestSupport { + + private static final int THREAD_COUNT = 8; + private static final Long USER_A = 1L; + private static final Long USER_B = 2L; + private static final String DEVICE_TOKEN = "fqD5528-device-token"; + private static final int OBSERVE_COUNT = 300; + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** 등록 트랜잭션 경계는 여기에 있다. 프록시된 실제 빈을 써야 REQUIRES_NEW 가 걸린다. */ + @Autowired + private FcmTokenWriter fcmTokenWriter; + + @Autowired + private PlatformTransactionManager transactionManager; + + private FirebaseMessaging firebaseMessaging; + private FcmService realFcmService; + + /** + * FcmToken 은 테이블 기반 식별자 생성기({@code fcm_token_seq})를 쓴다. + * FcmTokenRepositoryTest 와 같은 이유로 초기 행을 되살린다. + */ + @BeforeEach + void setUp() { + Integer rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM fcm_token_seq", Integer.class); + if (rows == null || rows == 0) { + jdbcTemplate.update("INSERT INTO fcm_token_seq (next_val) VALUES (1)"); + } + firebaseMessaging = mock(FirebaseMessaging.class); + realFcmService = transactionalProxyOf(new FcmService( + fcmTokenRepository, fcmTokenWriter, firebaseMessaging, new SimpleMeterRegistry(), + mock(FcmNotificationProducer.class))); + } + + /** 운영과 같게 {@code @Transactional} 을 실제로 적용한 프록시로 감싼다. */ + private FcmService transactionalProxyOf(FcmService target) { + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice( + new TransactionInterceptor(transactionManager, new AnnotationTransactionAttributeSource())); + return (FcmService) proxyFactory.getProxy(); + } + + private void register(Long userId) { + realFcmService.registerToken(new FcmTokenRequestDto(DEVICE_TOKEN), userId); + } + + @Nested + @DisplayName("같은 사용자가 같은 토큰을 동시에 등록") + class SameUserSameToken { + + @RepeatedTest(3) + @DisplayName("등록이 겹쳐 들어와도 전부 200 이고 행은 하나만 남는다") + void keepsSingleRowWithoutServerError() { + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> register(USER_A)); + + assertThat(outcome.serverErrors()) + .as("유니크 제약 위반이 그대로 올라오면 토큰 등록이 500 으로 실패한다") + .isEmpty(); + assertThat(outcome.successCount()) + .as("같은 결과를 만드는 중복 요청이므로 모두 성공이어야 한다") + .isEqualTo(THREAD_COUNT); + assertThat(fcmTokenRepository.findAllByUserId(USER_A)) + .extracting(FcmToken::getToken) + .containsExactly(DEVICE_TOKEN); + verifyNoInteractions(firebaseMessaging); + } + + @Test + @DisplayName("이미 등록된 상태에서 겹쳐 들어와도 행이 늘지 않는다") + void staysIdempotentOnAlreadyRegisteredToken() { + register(USER_A); + + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> register(USER_A)); + + assertThat(outcome.serverErrors()).isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(USER_A)).hasSize(1); + } + } + + @Nested + @DisplayName("계정 전환과 동시 등록이 겹칠 때") + class OwnerSwitchUnderRace { + + @RepeatedTest(3) + @DisplayName("A 가 쓰던 기기에 B 의 등록이 겹쳐 들어와도 A 행은 사라지고 B 행만 하나 남는다") + void removesPreviousOwnerExactlyOnce() { + register(USER_A); + + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently( + THREAD_COUNT, () -> register(USER_B)); + + assertThat(outcome.serverErrors()) + .as("계정 전환 경로에서도 중복 등록이 500 이 되면 안 된다") + .isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(USER_A)) + .as("이전 소유자 행이 남으면 A 앞 알림이 B 가 쓰는 기기에 뜬다 (#271)") + .isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(USER_B)) + .extracting(FcmToken::getToken) + .containsExactly(DEVICE_TOKEN); + assertThat(fcmTokenRepository.findAll()) + .as("토큰 하나는 기기 하나이고 소유자는 한 명이어야 한다") + .hasSize(1); + } + + @RepeatedTest(3) + @DisplayName("전환이 진행되는 동안 그 기기 토큰 행이 사라지는 순간은 없다") + void neverExposesTokenlessWindow() { + register(USER_A); + AtomicBoolean sawNoToken = new AtomicBoolean(false); + + // 0번 스레드는 관찰자다. 이전 소유자 행 삭제와 새 행 삽입이 한 트랜잭션에 묶여 있지 않으면 + // 그 사이를 읽어 "이 기기로 갈 알림이 없는 상태" 를 잡아낸다. + ConcurrentRunner.Outcome outcome = ConcurrentRunner.runConcurrently(THREAD_COUNT, index -> { + if (index == 0) { + for (int attempt = 0; attempt < OBSERVE_COUNT; attempt++) { + if (!fcmTokenRepository.existsByToken(DEVICE_TOKEN)) { + sawNoToken.set(true); + return; + } + } + return; + } + register(USER_B); + }); + + assertThat(outcome.serverErrors()).isEmpty(); + assertThat(sawNoToken.get()) + .as("행이 잠깐이라도 사라지면 그 순간 발송 대상이 비어 알림이 유실된다") + .isFalse(); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenIndexMigrationTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenIndexMigrationTest.java new file mode 100644 index 00000000..fdae053d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/migration/FcmTokenIndexMigrationTest.java @@ -0,0 +1,96 @@ +package com.aisip.OnO.backend.util.fcm.migration; + +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V44 가 실제 MySQL 8 에서 돌아가는지 확인한다. + * + *

    온라인 DDL 절({@code ALGORITHM=INPLACE, LOCK=NONE})은 문법이 틀리거나 이 서버에서 못 쓰면 + * 그 자리에서 실패한다. Flyway 는 앱이 뜰 때 도니까, 그 실패는 곧 기동 실패다. + * 여기서 미리 실제 MySQL 8 에 적용해 본다. + * + *

    테스트 스키마는 Hibernate 가 엔티티에서 만든다({@code application-test.yml}). + * 엔티티에도 같은 인덱스를 선언해 두었으므로 이미 붙어 있고, 그대로 ALTER 하면 이름 충돌로 실패한다. + * 그래서 지웠다가 마이그레이션으로 다시 만든다. + */ +@DisplayName("FCM 토큰 인덱스 마이그레이션") +class FcmTokenIndexMigrationTest extends IntegrationTestSupport { + + private static final String MIGRATION = "db/migration/V44__add_fcm_token_token_index.sql"; + private static final String INDEX_NAME = "idx_fcm_token_token"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("token 단독 인덱스를 온라인 DDL 로 추가한다") + void addsTokenIndexOnline() { + dropIndexIfExists(); + assertThat(indexCount()).isZero(); + + jdbcTemplate.execute(withoutComments().trim().replaceAll(";$", "")); + + assertThat(indexCount()) + .as("token 단독 인덱스가 없으면 등록 때마다 fcm_token 을 전부 읽는다") + .isEqualTo(1); + assertThat(isUnique()) + .as("중복 쌍이 남아 있어도 ALTER 가 실패하지 않으려면 유니크가 아니어야 한다") + .isFalse(); + } + + private void dropIndexIfExists() { + if (indexCount() > 0) { + jdbcTemplate.execute("DROP INDEX " + INDEX_NAME + " ON fcm_token"); + } + } + + private Integer indexCount() { + return jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fcm_token' + AND index_name = ? + """, Integer.class, INDEX_NAME); + } + + private boolean isUnique() { + Integer nonUnique = jdbcTemplate.queryForObject(""" + SELECT non_unique + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'fcm_token' + AND index_name = ? + """, Integer.class, INDEX_NAME); + return nonUnique != null && nonUnique == 0; + } + + private String withoutComments() { + return Arrays.stream(read().split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + } + + private String read() { + try (InputStream inputStream = new ClassPathResource(MIGRATION).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java index 48b1ae39..3c1f5b20 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java @@ -61,7 +61,10 @@ void setUp() { firebaseMessaging = mock(FirebaseMessaging.class); fcmNotificationProducer = mock(FcmNotificationProducer.class); meterRegistry = new SimpleMeterRegistry(); - fcmService = new FcmService(fcmTokenRepository, firebaseMessaging, meterRegistry, fcmNotificationProducer); + // 등록 트랜잭션을 여는 라이터는 실제 구현을 쓴다. 프록시가 없으므로 트랜잭션은 열리지 않고 + // 저장소 호출 순서만 그대로 드러난다. + fcmService = new FcmService(fcmTokenRepository, new FcmTokenWriter(fcmTokenRepository), + firebaseMessaging, meterRegistry, fcmNotificationProducer); } @AfterEach @@ -89,7 +92,7 @@ void savesNewToken() { fcmService.registerToken(new FcmTokenRequestDto("token-1"), 1L); ArgumentCaptor saved = ArgumentCaptor.forClass(FcmToken.class); - verify(fcmTokenRepository).save(saved.capture()); + verify(fcmTokenRepository).saveAndFlush(saved.capture()); assertThat(saved.getValue().getUserId()).isEqualTo(1L); assertThat(saved.getValue().getToken()).isEqualTo("token-1"); } @@ -101,7 +104,7 @@ void skipsDuplicatedToken() { fcmService.registerToken(new FcmTokenRequestDto("token-1"), 1L); - verify(fcmTokenRepository, never()).save(any()); + verify(fcmTokenRepository, never()).saveAndFlush(any()); } @Test @@ -115,7 +118,7 @@ void movesTokenFromPreviousOwner() { verify(fcmTokenRepository).deleteAllInBatch(List.of(previousOwner)); ArgumentCaptor saved = ArgumentCaptor.forClass(FcmToken.class); - verify(fcmTokenRepository).save(saved.capture()); + verify(fcmTokenRepository).saveAndFlush(saved.capture()); assertThat(saved.getValue().getUserId()).isEqualTo(2L); } @@ -128,7 +131,7 @@ void deletesNothingWithoutPreviousOwner() { fcmService.registerToken(new FcmTokenRequestDto("token-1"), 1L); verify(fcmTokenRepository, never()).deleteAllInBatch(any()); - verify(fcmTokenRepository, never()).save(any()); + verify(fcmTokenRepository, never()).saveAndFlush(any()); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java index 912cc73b..eab6146d 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenOwnerSwitchIntegrationTest.java @@ -10,8 +10,12 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; @@ -22,6 +26,7 @@ * *

    통합 컨텍스트의 {@link FcmService} 는 발송 사고를 막으려고 목으로 바꿔 두었다. * 그래서 저장소만 실제 빈을 쓰고 서비스는 여기서 직접 만든다. 발송 의존성은 전부 목이다. + * {@code new} 로 만든 객체에는 트랜잭션 애노테이션이 걸리지 않으므로 트랜잭션 인터셉터를 씌운다. */ @DisplayName("FCM 토큰 계정 전환") class FcmTokenOwnerSwitchIntegrationTest extends IntegrationTestSupport { @@ -32,6 +37,13 @@ class FcmTokenOwnerSwitchIntegrationTest extends IntegrationTestSupport { @Autowired private JdbcTemplate jdbcTemplate; + /** 등록 트랜잭션 경계는 여기에 있다. 프록시된 실제 빈을 써야 REQUIRES_NEW 가 걸린다. */ + @Autowired + private FcmTokenWriter fcmTokenWriter; + + @Autowired + private PlatformTransactionManager transactionManager; + private FirebaseMessaging firebaseMessaging; private FcmService realFcmService; @@ -43,8 +55,14 @@ void setUp() { jdbcTemplate.update("INSERT INTO fcm_token_seq (next_val) VALUES (1)"); } firebaseMessaging = mock(FirebaseMessaging.class); - realFcmService = new FcmService( - fcmTokenRepository, firebaseMessaging, new SimpleMeterRegistry(), mock(FcmNotificationProducer.class)); + FcmService target = new FcmService( + fcmTokenRepository, fcmTokenWriter, firebaseMessaging, new SimpleMeterRegistry(), + mock(FcmNotificationProducer.class)); + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice( + new TransactionInterceptor(transactionManager, new AnnotationTransactionAttributeSource())); + realFcmService = (FcmService) proxyFactory.getProxy(); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java b/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java index 252dbbf9..141055ff 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/support/BrokerOutageFcm.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; import com.aisip.OnO.backend.util.fcm.service.FcmService; +import com.aisip.OnO.backend.util.fcm.service.FcmTokenWriter; import com.google.firebase.messaging.FirebaseMessaging; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.springframework.amqp.AmqpConnectException; @@ -63,6 +64,7 @@ public static BrokerOutageFcm create(TransactionManager transactionManager, int }); FcmService target = new FcmService( mock(FcmTokenRepository.class), + mock(FcmTokenWriter.class), mock(FirebaseMessaging.class), new SimpleMeterRegistry(), new FcmNotificationProducer(rabbitTemplate)); From 4a600d0906d4b222d6772d7da62e03637a40b273 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 16:26:43 +0900 Subject: [PATCH 116/130] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=20=EB=A6=AC?= =?UTF-8?q?=EB=A7=88=EC=9D=B8=EB=8D=94=20=ED=95=98=EB=A3=A8=201=EA=B1=B4?= =?UTF-8?q?=20=ED=8C=90=EC=A0=95=EC=97=90=20=EC=84=A0=EC=A0=90(SENDING)=20?= =?UTF-8?q?=ED=96=89=EB=8F=84=20=ED=8F=AC=ED=95=A8=EC=8B=9C=ED=82=A8?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - findDueReminders 의 "오늘 이미 처리한 사용자" 판정이 SENT 행만 보고 있어서, 다른 인스턴스가 선점해 SENDING 으로 커밋한 직후 폴링이 돌면 같은 사용자의 다른 예약이 그날 두 번째 푸시로 나갔다 - PR #281 이 선점을 별도 트랜잭션으로 즉시 커밋하게 바꾸면서 생긴 회귀다. 그 전에는 선점이 커밋 전이라 뒤늦게 온 폴링이 같은 행을 집고 tryUpdateStatus 에서 0건을 받아 그 사용자를 건너뛰었다 - 선점 시각은 tryUpdateStatus 가 넣는 updatedAt 이라 별도 컬럼 없이 같은 날 선점 여부를 판정할 수 있다. 10분 넘게 멈춘 SENDING 행은 기존 stuck 복구가 FAILED 로 넘기므로 영구히 막히지 않는다 - ProblemReviewReminderJob 에 @DisallowConcurrentExecution 을 붙여 한 인스턴스 안에서 폴링이 겹치는 것도 막는다 - continuesAfterBrokerFailure 가 LocalDateTime.now() 를 쓰고 있어 21시 이후에 CI 가 돌면 발송 창 밖이라 실패했다. 같은 클래스의 nowInSendWindow() 로 통일한다 --- .../reminder/ProblemReviewReminderJob.java | 10 ++++ .../ProblemReviewReminderRepository.java | 28 ++++++++--- .../ProblemReviewReminderService.java | 4 +- .../ProblemReviewReminderServiceTest.java | 48 +++++++++++++++++-- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java index 815d00be..9e3960ea 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderJob.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.problem.reminder; import lombok.extern.slf4j.Slf4j; +import org.quartz.DisallowConcurrentExecution; import org.quartz.JobExecutionContext; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.scheduling.quartz.QuartzJobBean; @@ -8,7 +9,16 @@ import java.time.LocalDateTime; import java.time.ZoneId; +/** + * 5분마다 due 복습 알림을 내보내는 폴링 잡. + * + *

    한 실행이 5분을 넘기면 다음 실행이 겹쳐 같은 사용자를 두 번 처리할 수 있어 중첩을 막는다. + * 다만 스케줄러가 클러스터 모드가 아니라(application-prod.yml 의 isClustered: false) blue/green + * 두 인스턴스 사이의 동시 폴링은 이것으로 막히지 않는다. 그건 findDueReminders 의 + * "오늘 이미 처리한 사용자" 판정이 막는다. + */ @Slf4j +@DisallowConcurrentExecution public class ProblemReviewReminderJob extends QuartzJobBean { @Autowired diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 374889ad..16177889 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -14,18 +14,33 @@ public interface ProblemReviewReminderRepository extends JpaRepositorySENT 만 보면 선점(SENDING)과 SENT 기록 사이에 창이 생긴다. 선점은 별도 트랜잭션으로 바로 + * 커밋되므로, 인스턴스 A 가 예약 X 를 SENDING 으로 커밋한 직후 인스턴스 B 가 폴링하면 X 는 + * SCHEDULED 가 아니라 후보에서 빠지고 그 사용자의 SENT 행도 아직 없어서, 같은 사용자의 다른 + * 예약 Y 가 하루 두 번째 푸시로 나갔다. 그래서 오늘 선점된 SENDING 행도 "이미 처리함" 으로 센다. + * + *

    선점 시각은 {@code tryUpdateStatus} 가 넣는 {@code updatedAt} 이다. SENDING 이 영영 막지는 + * 않는다 — 10분 넘게 멈춘 행은 stuck 복구가 FAILED 로 넘긴다. + */ @Query(""" SELECT r FROM ProblemReviewReminder r WHERE r.status = :status AND r.scheduledAt <= :now AND r.deletedAt IS NULL AND NOT EXISTS ( - SELECT 1 FROM ProblemReviewReminder sent - WHERE sent.userId = r.userId - AND sent.status = :sent - AND sent.sentAt >= :startOfDay - AND sent.sentAt < :endOfDay - AND sent.deletedAt IS NULL + SELECT 1 FROM ProblemReviewReminder handled + WHERE handled.userId = r.userId + AND handled.deletedAt IS NULL + AND ( + (handled.status = :sent + AND handled.sentAt >= :startOfDay AND handled.sentAt < :endOfDay) + OR + (handled.status = :sending + AND handled.updatedAt >= :startOfDay AND handled.updatedAt < :endOfDay) + ) ) AND EXISTS ( SELECT 1 FROM User u @@ -42,6 +57,7 @@ List findDueReminders( @Param("status") ProblemReviewReminderStatus status, @Param("now") LocalDateTime now, @Param("sent") ProblemReviewReminderStatus sent, + @Param("sending") ProblemReviewReminderStatus sending, @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay, Pageable pageable diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index 06a281b8..c0ce8340 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -119,8 +119,10 @@ public void sendDueReminders(LocalDateTime now) { LocalDateTime startOfDay = today.atStartOfDay(); LocalDateTime endOfDay = today.plusDays(1).atStartOfDay(); + // 오늘 SENT 뿐 아니라 오늘 선점된 SENDING 행이 있는 사용자도 후보에서 뺀다. + // 선점이 바로 커밋되므로, 다른 인스턴스가 선점한 직후 폴링이 돌면 SENT 만으로는 중복을 못 막는다. List dueRows = repository.findDueReminders( - SCHEDULED, now, SENT, startOfDay, endOfDay, PageRequest.of(0, DUE_REMINDER_BATCH_SIZE) + SCHEDULED, now, SENT, SENDING, startOfDay, endOfDay, PageRequest.of(0, DUE_REMINDER_BATCH_SIZE) ); if (dueRows.isEmpty()) return; diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java index c7f460f6..21e57c83 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderServiceTest.java @@ -460,6 +460,48 @@ void sendsAtMostOncePerDay() { assertThat(reminderRepository.findById(due.getId()).orElseThrow().getStatus()).isEqualTo(SCHEDULED); } + @Test + @DisplayName("같은 사용자의 다른 예약이 SENDING 이면 오늘의 두 번째 알림을 보내지 않는다") + void doesNotSendWhileAnotherReminderIsSending() { + giveFcmToken(userId, "token-1"); + ProblemReviewReminder claimed = + saveDueReminder(userId, 15001L, nowInSendWindow().minusMinutes(10)); + inTransaction(() -> + reminderRepository.tryUpdateStatus(claimed.getId(), SCHEDULED, SENDING, nowInSendWindow())); + ProblemReviewReminder second = + saveDueReminder(userId, 15002L, nowInSendWindow().minusMinutes(5)); + + service.sendDueReminders(nowInSendWindow()); + + verify(fcmService, never()).sendNotificationToAllUserDevice(any(), any()); + assertThat(reminderRepository.findById(second.getId()).orElseThrow().getStatus()) + .as("다른 인스턴스가 선점해 SENDING 으로 커밋한 직후 폴링이 돌면 같은 날 두 번째 푸시가 나간다") + .isEqualTo(SCHEDULED); + } + + @Test + @DisplayName("SENDING 에 멈춘 행이 stuck 복구로 FAILED 가 되면 같은 사용자의 다음 예약이 나간다") + void stuckSendingRowDoesNotBlockUserForever() { + giveFcmToken(userId, "token-1"); + ProblemReviewReminder stuck = + saveDueReminder(userId, 15101L, nowInSendWindow().minusMinutes(30)); + inTransaction(() -> + reminderRepository.tryUpdateStatus(stuck.getId(), SCHEDULED, SENDING, nowInSendWindow())); + inTransaction(() -> entityManager + .createNativeQuery("UPDATE problem_review_reminder SET updated_at = ? WHERE id = ?") + .setParameter(1, nowInSendWindow().minusMinutes(11)) + .setParameter(2, stuck.getId()) + .executeUpdate()); + ProblemReviewReminder next = + saveDueReminder(userId, 15102L, nowInSendWindow().minusMinutes(1)); + + service.sendDueReminders(nowInSendWindow()); + + assertThat(reminderRepository.findById(next.getId()).orElseThrow().getStatus()) + .as("SENDING 가드가 stuck 행 하나로 그 사용자를 영구히 막으면 안 된다") + .isEqualTo(SENT); + } + @Test @DisplayName("브로커 장애로 한 사용자의 적재가 실패해도 FAILED 로 남기고 다음 사용자를 계속 처리한다") void continuesAfterBrokerFailure() { @@ -468,10 +510,10 @@ void continuesAfterBrokerFailure() { User otherUser = fixtures.createUser(); giveFcmToken(userId, "token-1"); giveFcmToken(otherUser.getId(), "token-2"); - ProblemReviewReminder mine = saveDueReminder(userId, 13001L, LocalDateTime.now().minusMinutes(1)); - ProblemReviewReminder others = saveDueReminder(otherUser.getId(), 13002L, LocalDateTime.now().minusMinutes(1)); + ProblemReviewReminder mine = saveDueReminder(userId, 13001L, nowInSendWindow().minusMinutes(1)); + ProblemReviewReminder others = saveDueReminder(otherUser.getId(), 13002L, nowInSendWindow().minusMinutes(1)); - service.sendDueReminders(LocalDateTime.now()); + service.sendDueReminders(nowInSendWindow()); assertThat(brokerOutage.enqueueAttempts()) .as("첫 적재가 실패해도 두 번째 사용자까지 적재를 시도해야 한다") From 5b78aa0929f26484cecb91cdb49d19ffe75baaf4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 16:39:36 +0900 Subject: [PATCH 117/130] =?UTF-8?q?[Fix]=20=EC=A0=84=EC=B2=B4=20=ED=8F=B4?= =?UTF-8?q?=EB=8D=94=20=EC=82=AD=EC=A0=9C=EC=99=80=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1/=EC=88=98=EC=A0=95=EC=97=90=EB=8F=84=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=20=EC=9E=A0=EA=B8=88=EC=9D=84=20=EB=84=A3?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteAllUserFoldersWithProblems 가 트랜잭션 첫 조회로 사용자 폴더 전체를 FOR UPDATE 로 잡게 했다. 이 경로에는 #284 가 넣은 잠금이 없어서, 삭제 스냅숏 이후에 커밋된 등록이 삭제된 폴더를 가리킨 채 남았다 - createFolder 와 updateFolder 의 부모 폴더 조회를 findByIdForShare 로 바꿨다. 잠금 없이 읽으면 아직 커밋되지 않은 삭제를 못 보고, 삭제된 폴더를 부모로 가리키는 살아 있는 폴더가 남았다 - 새 에러 코드 없이 기존 FOLDER_NOT_FOUND 로 거절된다. 잠금 조회는 스냅숏이 아니라 최신 행을 읽어서 deleted_at 이 찍힌 폴더가 @SQLRestriction 에 걸러진다 - 경합 테스트 4개를 추가했다. 그중 3개는 수정 전 코드에서 실패하고, 나머지 1개는 생성이 부모를 먼저 잡는 순서에서 교착이 없는지를 본다 --- .../backend/folder/service/FolderService.java | 25 +++++- .../FolderDeleteProblemRegisterRaceTest.java | 83 +++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 929bce66..2f1645b5 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -87,6 +87,25 @@ public Folder findFolderEntity(Long folderId, Long userId) { return folder; } + /** + * 부모로 삼을 폴더를 공유 잠금(FOR SHARE)으로 읽는다. (#233) + * + *

    문제 등록이 폴더를 잠그고 읽는 것과 같은 이유다. 잠금 없이 읽으면 아직 커밋되지 않은 폴더 삭제를 + * 못 보고 그 폴더 아래에 새 폴더를 만들거나 기존 폴더를 옮긴다. 삭제가 커밋된 뒤에는 삭제된 폴더를 + * 부모로 가리키는 살아 있는 폴더가 남아, 앱에서 어느 폴더에도 보이지 않는다. + * + *

    잠금 조회는 스냅숏이 아니라 최신 행을 읽으므로, 삭제가 먼저 잡았으면 커밋까지 기다렸다가 + * {@code deleted_at} 이 찍힌 행을 보고 {@code @SQLRestriction} 에 걸려 기존 {@code FOLDER_NOT_FOUND} 가 된다. + * + *

    호출자의 트랜잭션 안에서 실행된다. + */ + private Folder findParentFolderForShare(Long folderId, Long userId) { + Folder parentFolder = folderRepository.findByIdForShare(folderId) + .orElseThrow(() -> new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND)); + validateFolderOwner(parentFolder, userId); + return parentFolder; + } + @Transactional(readOnly = true) public List findAllUserFolderThumbnails(Long userId) { List folderList = folderRepository.findAllByUserId(userId); @@ -138,7 +157,7 @@ public Long createFolder(FolderRegisterDto folderRegisterDto, Long userId) { } Folder folder = Folder.from(folderRegisterDto, userId); - Folder parentFolder = findFolderEntity(folderRegisterDto.parentFolderId(), userId); + Folder parentFolder = findParentFolderForShare(folderRegisterDto.parentFolderId(), userId); folder.updateParentFolder(parentFolder); folderRepository.save(folder); @@ -157,7 +176,7 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { folder.updateFolderInfo(folderRegisterDto); if (folderRegisterDto.parentFolderId() != null && folder.getParentFolder() != null) { - Folder newParentFolder = findFolderEntity(folderRegisterDto.parentFolderId(), userId); + Folder newParentFolder = findParentFolderForShare(folderRegisterDto.parentFolderId(), userId); validateNotCyclic(folder, newParentFolder); folder.updateParentFolder(newParentFolder); @@ -179,6 +198,8 @@ public void deleteFoldersWithProblems(Long userId, List folderIds) { } public void deleteAllUserFoldersWithProblems(Long userId) { + // 폴더 하나씩 지우는 경로와 같은 이유로 사용자 폴더 전체를 먼저 잡는다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) + folderRepository.lockAllByUserId(userId); problemService.deleteAllUserProblems(userId); diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java index 59c36810..e157eaad 100644 --- a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteProblemRegisterRaceTest.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.concurrency; import com.aisip.OnO.backend.common.exception.ApplicationException; +import com.aisip.OnO.backend.folder.dto.FolderRegisterDto; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.folder.exception.FolderErrorCase; import com.aisip.OnO.backend.folder.service.FolderService; @@ -131,6 +132,42 @@ void deletingParentRemovesBatchRegisteredIntoSubFolder() throws Exception { assertThat(aliveProblemCountInFolder(child.getId())).isZero(); assertNoOrphanProblems(); } + + @Test + @DisplayName("전체 폴더 삭제도 등록이 끝날 때까지 기다렸다가 새로 들어온 문제까지 함께 지운다") + void deleteAllWaitsAndRemovesProblemRegisteredMeanwhile() throws Exception { + Future registration = holdUntilReleased(() -> problemService.registerProblem( + new ProblemRegisterDto(null, "경합 메모", null, target.getId(), null), user.getId())); + + Future deletion = executor.submit(() -> folderService.deleteAllUserFoldersWithProblems(user.getId())); + awaitFolderLockWaitOrDone(deletion); + release.countDown(); + + registration.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertThat(aliveProblemCountInFolder(target.getId())) + .as("전체 삭제 경로에는 폴더 잠금이 없어서, 삭제 스냅숏 이후 커밋된 문제가 그대로 남았다") + .isZero(); + assertNoOrphanProblems(); + } + + @Test + @DisplayName("폴더 생성이 부모를 먼저 잡으면 삭제가 기다렸다가 새 폴더까지 함께 지운다") + void deleteWaitsAndRemovesFolderCreatedMeanwhile() throws Exception { + Future creation = holdUntilReleased(() -> folderService.createFolder( + new FolderRegisterDto("경합 폴더", null, target.getId()), user.getId())); + + Future deletion = executor.submit( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + awaitFolderLockWaitOrDone(deletion); + release.countDown(); + + creation.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + assertNoOrphanFolders(); + } } @Nested @@ -191,6 +228,40 @@ void moveIntoDeletedFolderIsRejected() throws Exception { assertThat(aliveProblemCountInFolder(keep.getId())).isEqualTo(1); assertNoOrphanProblems(); } + + @Test + @DisplayName("삭제 중인 폴더 아래에 새 폴더를 만드는 요청은 FOLDER_NOT_FOUND 로 거절된다") + void folderCreationUnderDeletedParentIsRejected() throws Exception { + Future deletion = holdUntilReleased( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + + Future creation = executor.submit(() -> folderService.createFolder( + new FolderRegisterDto("경합 폴더", null, target.getId()), user.getId())); + awaitFolderLockWaitOrDone(creation); + release.countDown(); + + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertRejectedWithFolderNotFound(creation); + assertNoOrphanFolders(); + } + + @Test + @DisplayName("삭제 중인 폴더 아래로 폴더를 옮기는 요청도 FOLDER_NOT_FOUND 로 거절된다") + void folderMoveUnderDeletedParentIsRejected() throws Exception { + Folder moving = fixtures.createFolder(user.getId(), "옮길 폴더", rootFolder); + + Future deletion = holdUntilReleased( + () -> folderService.deleteFoldersWithProblems(user.getId(), List.of(target.getId()))); + + Future move = executor.submit(() -> folderService.updateFolder( + new FolderRegisterDto("옮길 폴더", moving.getId(), target.getId()), user.getId())); + awaitFolderLockWaitOrDone(move); + release.countDown(); + + deletion.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertRejectedWithFolderNotFound(move); + assertNoOrphanFolders(); + } } /** 작업을 트랜잭션 안에서 실행하고, 커밋하기 직전에 {@link #release} 가 풀릴 때까지 붙잡아 둔다. */ @@ -296,4 +367,16 @@ SELECT COUNT(*) .as("삭제된 폴더를 가리키는 살아 있는 문제") .isZero(); } + + private void assertNoOrphanFolders() { + Long orphanCount = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM folder f + JOIN folder p ON p.id = f.parent_folder_id + WHERE f.user_id = ? AND f.deleted_at IS NULL AND p.deleted_at IS NOT NULL + """, Long.class, user.getId()); + assertThat(orphanCount) + .as("삭제된 폴더를 부모로 가리키는 살아 있는 폴더") + .isZero(); + } } From 5152d82364defcf6c2fd3f22eb24de3581a75902 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 17:14:14 +0900 Subject: [PATCH 118/130] =?UTF-8?q?[Fix]=20=EC=B1=8C=EB=A6=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=EC=9D=84=20=EC=95=84=EC=B9=A8=2009=EC=8B=9C?= =?UTF-8?q?=EB=82=98=20=EC=A0=80=EB=85=81=2018=EC=8B=9C=EC=97=90=EB=A7=8C?= =?UTF-8?q?=20=EB=B3=B4=EB=82=B8=EB=8B=A4=20-=20ChallengeNotificationSched?= =?UTF-8?q?uler=20=EA=B0=80=20=EA=B3=84=EC=82=B0=ED=95=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=8D=A8?= =?UTF-8?q?=EC=84=9C,=20=EC=95=B1=EC=9D=B4=20=EB=A7=88=EA=B0=90=EC=9D=84?= =?UTF-8?q?=20=EA=B7=B8=EB=82=A0=2023:59:59=20=EB=A1=9C=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EB=8A=94=20D-1=20=EC=95=8C=EB=A6=BC=EC=9D=80=20?= =?UTF-8?q?=EA=B1=B0=EC=9D=98=20=ED=95=AD=EC=83=81=20=EB=B0=A4=2023:59=20?= =?UTF-8?q?=EC=97=90=20=EB=82=98=EA=B0=94=EA=B3=A0,=20=EC=A4=91=EA=B0=84?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=EC=9D=80=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EB=93=A0=20=EC=8B=9C=EA=B0=81=EC=9D=84=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EA=B0=80=20=EC=83=88=EB=B2=BD=EC=97=90=20?= =?UTF-8?q?=EA=B1=B8=EB=A6=B4=20=EC=88=98=20=EC=9E=88=EC=97=88=EB=8B=A4.?= =?UTF-8?q?=20#276=20=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=20=EC=9E=A1=EC=9D=B4?= =?UTF-8?q?=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=B0=9C=ED=99=94=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=EB=90=98=EB=A9=B4=EC=84=9C=20=EC=8B=A4=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=ED=95=9C=EB=B0=A4?= =?UTF-8?q?=EC=A4=91=20=ED=91=B8=EC=8B=9C=EA=B0=80=20=EB=82=98=EA=B0=88=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8A=94=20=EC=83=81=ED=83=9C=EC=98=80?= =?UTF-8?q?=EB=8B=A4=20-=20ChallengeNotificationTimePolicy=20=EB=A5=BC=20?= =?UTF-8?q?=EB=91=90=EA=B3=A0=20=EA=B3=84=EC=82=B0=EB=90=9C=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EA=B0=99=EC=9D=80=20=EB=82=A0=2009:00=20?= =?UTF-8?q?=EA=B3=BC=2018:00=20=EC=A4=91=20=EA=B0=80=EA=B9=8C=EC=9A=B4=20?= =?UTF-8?q?=EC=AA=BD=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4.=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=20=EC=8B=9C=EA=B0=81=EC=9D=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=20=EC=A7=80=EB=82=AC=EC=9C=BC=EB=A9=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=B0=EC=A4=80=20=EC=8B=9C=EA=B0=81=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=84=98=EA=B8=B0=EA=B3=A0,=20=EA=B7=B8=EA=B2=83?= =?UTF-8?q?=EC=9D=B4=20=EB=A7=88=EA=B0=90=EC=9D=84=20=EB=84=98=EA=B8=B0?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EC=95=8C=EB=A6=BC=EC=9D=80=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=EC=9D=B4=20=ED=95=98=EB=A3=A8=20=EC=9D=B4?= =?UTF-8?q?=ED=95=98=EC=9D=B8=20=EC=B1=8C=EB=A6=B0=EC=A7=80=EB=8A=94=20D-1?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=EC=9D=B4=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EB=B3=B4=EB=8B=A4=EB=8F=84=20=EC=9D=BC=EB=9F=AC?= =?UTF-8?q?=EC=84=9C,=20=EC=96=B5=EC=A7=80=EB=A1=9C=20=EB=AF=B8=EB=A3=A8?= =?UTF-8?q?=EB=A9=B4=20=EC=A4=91=EA=B0=84=20=EC=95=8C=EB=A6=BC=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=8B=9C=EA=B0=81=EC=97=90=20=EB=91=90=20?= =?UTF-8?q?=EB=B2=88=20=EB=82=98=EA=B0=84=EB=8B=A4.=20=EA=B7=B8=EB=9E=98?= =?UTF-8?q?=EC=84=9C=20D-1=20=EC=9D=84=20=EB=B3=B4=EB=82=B4=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=EC=9D=98=20=EA=B8=B0=EC=A4=80=EC=9D=84=20Asi?= =?UTF-8?q?a/Seoul=20=EB=A1=9C=20=EB=A7=9E=EC=B6=98=EB=8B=A4.=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=EB=8A=94=20Asia/Seoul=20=EB=A1=9C=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=ED=95=98=EB=A9=B4=EC=84=9C=20=ED=98=84?= =?UTF-8?q?=EC=9E=AC=20=EC=8B=9C=EA=B0=81=EB=A7=8C=20JVM=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=8B=9C=EA=B0=84=EB=8C=80=EB=A1=9C=20=EC=9D=BD?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=88=EC=97=88=EA=B3=A0,=20PersistenceRulesTest?= =?UTF-8?q?=20=EC=9D=98=20=EC=98=88=EC=99=B8=20=EB=AA=A9=EB=A1=9D=EC=97=90?= =?UTF-8?q?=EC=84=9C=EB=8F=84=20=EB=BA=90=EB=8B=A4=20-=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EC=8B=9C=EA=B0=81=20=EA=B3=84=EC=82=B0=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=2011=EA=B0=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=96=88=EB=8B=A4.=20=EB=A7=88=EA=B0=90=EC=9D=B4=2023:59:59=20?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=A4=EC=A0=9C=20=EC=BC=80=EC=9D=B4=EC=8A=A4,?= =?UTF-8?q?=20=EC=83=88=EB=B2=BD=EC=97=90=20=EA=B1=B8=EB=A6=AC=EB=8A=94=20?= =?UTF-8?q?=EC=A4=91=EA=B0=84=20=EC=95=8C=EB=A6=BC,=20=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EC=9D=B4=20=ED=95=98=EB=A3=A8=20=EC=9D=B4=ED=95=98=EC=9D=B8=20?= =?UTF-8?q?=EC=B1=8C=EB=A6=B0=EC=A7=80,=20=EB=A7=88=EA=B0=90=EC=9D=B4=20?= =?UTF-8?q?=EC=9E=84=EB=B0=95=ED=95=B4=20=EB=B3=B4=EB=82=BC=20=EC=88=98=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EA=B2=BD=EC=9A=B0=EB=A5=BC=20=EB=8B=B4?= =?UTF-8?q?=EC=95=98=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #287 --- .../ChallengeNotificationScheduler.java | 16 +- .../ChallengeNotificationTimePolicy.java | 89 ++++++++ .../architecture/PersistenceRulesTest.java | 1 - .../ChallengeNotificationTimePolicyTest.java | 195 ++++++++++++++++++ 4 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java create mode 100644 src/test/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicyTest.java diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java index 5f3b15f5..c8c0b3ce 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationScheduler.java @@ -6,7 +6,6 @@ import org.quartz.*; import org.springframework.stereotype.Component; -import java.time.Duration; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; @@ -20,21 +19,18 @@ public class ChallengeNotificationScheduler { private static final ZoneId ZONE = ZoneId.of("Asia/Seoul"); private final Scheduler scheduler; + private final ChallengeNotificationTimePolicy timePolicy; public void scheduleNotifications(StudyRoomChallenge challenge) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = LocalDateTime.now(ZONE); LocalDateTime startAt = challenge.getStartAt(); LocalDateTime endAt = challenge.getEndAt(); - LocalDateTime halfwayAt = startAt.plus(Duration.between(startAt, endAt).dividedBy(2)); - if (halfwayAt.isAfter(now)) { - schedule(challenge, "HALFWAY", halfwayAt); - } + timePolicy.resolveHalfwayFireAt(startAt, endAt, now) + .ifPresent(fireAt -> schedule(challenge, "HALFWAY", fireAt)); - LocalDateTime oneDayBeforeAt = endAt.minusDays(1); - if (oneDayBeforeAt.isAfter(now)) { - schedule(challenge, "ONE_DAY_LEFT", oneDayBeforeAt); - } + timePolicy.resolveOneDayLeftFireAt(startAt, endAt, now) + .ifPresent(fireAt -> schedule(challenge, "ONE_DAY_LEFT", fireAt)); } public void cancelNotifications(Long challengeId) { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java new file mode 100644 index 00000000..ddb079f4 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicy.java @@ -0,0 +1,89 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Optional; + +/** + * 챌린지 알림을 언제 보낼지 정한다. + * + *

    계산된 시각을 그대로 쓰면 한밤중에 푸시가 나간다. 앱이 마감을 그날 23:59:59 로 보내서 + * D-1 알림은 거의 항상 밤 23:59 였고, 중간 알림은 챌린지를 만든 시각을 그대로 따라가 새벽에 걸렸다. + * 그래서 발송 시각은 아침 09:00 과 저녁 18:00 두 기준 시각으로만 맞춘다. + * + *

    복습 리마인더의 {@code ProblemReviewReminderPolicy} 는 09~21시 "구간" 안이면 그대로 두지만, + * 챌린지 알림은 방 멤버 전원에게 한 번에 나가는 알림이라 시각을 두 지점으로 못박는 편이 예측하기 쉽다. + */ +@Component +public class ChallengeNotificationTimePolicy { + + private static final LocalTime MORNING = LocalTime.of(9, 0); + private static final LocalTime EVENING = LocalTime.of(18, 0); + + /** + * 중간 알림 발송 시각. 기준은 기존과 같이 시작과 마감의 중간 지점이다. + */ + public Optional resolveHalfwayFireAt(LocalDateTime startAt, LocalDateTime endAt, LocalDateTime now) { + LocalDateTime halfwayAt = startAt.plus(Duration.between(startAt, endAt).dividedBy(2)); + return resolveFireAt(halfwayAt, now, endAt); + } + + /** + * D-1 알림 발송 시각. 기준은 기존과 같이 마감 하루 전이다. + * + *

    기간이 하루 이하인 챌린지는 마감 하루 전이 시작 시각보다도 이르다. 그 시점을 억지로 미루면 + * 중간 알림과 같은 시각에 두 번 나가므로 아예 보내지 않는다. + */ + public Optional resolveOneDayLeftFireAt(LocalDateTime startAt, LocalDateTime endAt, LocalDateTime now) { + LocalDateTime oneDayBeforeAt = endAt.minusDays(1); + if (oneDayBeforeAt.isBefore(startAt)) { + return Optional.empty(); + } + return resolveFireAt(oneDayBeforeAt, now, endAt); + } + + /** + * 계산된 시각을 기준 시각(09:00 / 18:00)으로 맞춘다. + * + *

    같은 날 두 기준 시각 중 가까운 쪽으로 옮기고(23:59 면 그날 18:00, 새벽 3시면 그날 09:00), + * 그 시각이 이미 지났으면 다음 기준 시각으로 넘긴다. 마감을 넘기면 보내지 않는다. + * 마감이 지난 뒤에 오는 "내일 마감" 알림은 의미가 없기 때문이다. + * + * @return 보낼 시각, 보낼 수 없으면 {@link Optional#empty()} + */ + private Optional resolveFireAt(LocalDateTime candidate, LocalDateTime now, LocalDateTime deadline) { + LocalDateTime fireAt = snapToNearestAnchor(candidate); + while (!fireAt.isAfter(now) && !fireAt.isAfter(deadline)) { + fireAt = nextAnchor(fireAt); + } + if (!fireAt.isAfter(now) || fireAt.isAfter(deadline)) { + return Optional.empty(); + } + return Optional.of(fireAt); + } + + /** + * 같은 날 09:00 과 18:00 중 가까운 쪽. 정확히 가운데(13:30)면 이른 쪽으로 보낸다. + */ + private LocalDateTime snapToNearestAnchor(LocalDateTime candidate) { + LocalDateTime morning = candidate.toLocalDate().atTime(MORNING); + LocalDateTime evening = candidate.toLocalDate().atTime(EVENING); + long toMorning = Math.abs(Duration.between(morning, candidate).toSeconds()); + long toEvening = Math.abs(Duration.between(evening, candidate).toSeconds()); + return toEvening < toMorning ? evening : morning; + } + + private LocalDateTime nextAnchor(LocalDateTime anchor) { + LocalTime time = anchor.toLocalTime(); + if (time.isBefore(MORNING)) { + return anchor.toLocalDate().atTime(MORNING); + } + if (time.isBefore(EVENING)) { + return anchor.toLocalDate().atTime(EVENING); + } + return anchor.toLocalDate().plusDays(1).atTime(MORNING); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java index 08558c11..7743d69e 100644 --- a/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java +++ b/src/test/java/com/aisip/OnO/backend/architecture/PersistenceRulesTest.java @@ -136,7 +136,6 @@ class TimeZone { "com.aisip.OnO.backend.feedback.service.FeedbackService", "com.aisip.OnO.backend.mission.repository.MissionLogRepositoryImpl", "com.aisip.OnO.backend.practicenote.entity.PracticeNote", - "com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationScheduler", "com.aisip.OnO.backend.studyroom.service.StudyRoomChallengeService", "com.aisip.OnO.backend.studyroom.service.StudyRoomInviteService", "com.aisip.OnO.backend.studyroom.service.StudyRoomWeeklyReportService", diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicyTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicyTest.java new file mode 100644 index 00000000..c4c696ae --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationTimePolicyTest.java @@ -0,0 +1,195 @@ +package com.aisip.OnO.backend.studyroom.quartz; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 챌린지 알림 발송 시각 검증. + * + *

    앱이 마감을 그날 23:59:59 로 보내기 때문에(OnO_FRONT ChallengeCreateSheet.dart) D-1 알림이 + * 밤 23:59 에 나가던 문제를 막는 계산이다. 실제 발송은 하지 않고 시각 계산만 확인한다. + */ +@DisplayName("챌린지 알림 발송 시각 정책") +class ChallengeNotificationTimePolicyTest { + + private ChallengeNotificationTimePolicy policy; + + @BeforeEach + void setUp() { + policy = new ChallengeNotificationTimePolicy(); + } + + @Nested + @DisplayName("D-1 알림") + class OneDayLeft { + + @Test + @DisplayName("마감이 23:59:59 면 전날 밤이 아니라 전날 18:00 에 보낸다") + void endOfDayDeadlineFiresAtEvening() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 10, 10, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 20, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 10, 10, 0); + + Optional fireAt = policy.resolveOneDayLeftFireAt(startAt, endAt, now); + + assertThat(fireAt).as("D-1 발송 시각") + .contains(LocalDateTime.of(2026, 9, 19, 18, 0)); + } + + @Test + @DisplayName("맞춘 시각이 이미 지났으면 다음 기준 시각인 다음 날 09:00 으로 넘긴다") + void passedAnchorMovesToNextAnchor() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 19, 8, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 20, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 19, 20, 0); + + Optional fireAt = policy.resolveOneDayLeftFireAt(startAt, endAt, now); + + assertThat(fireAt).as("18:00 이 지난 뒤의 D-1 발송 시각") + .contains(LocalDateTime.of(2026, 9, 20, 9, 0)); + } + + @Test + @DisplayName("마감이 임박해 다음 기준 시각이 마감을 넘기면 보내지 않는다") + void nextAnchorAfterDeadlineIsNotSent() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 9, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 19, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 19, 19, 0); + + Optional fireAt = policy.resolveOneDayLeftFireAt(startAt, endAt, now); + + assertThat(fireAt).as("마감 임박 D-1 발송 시각").isEmpty(); + } + + @Test + @DisplayName("기간이 하루 이하면 D-1 시점이 시작보다 이르므로 보내지 않는다") + void challengeShorterThanOneDaySendsNothing() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 10, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 18, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 10, 0); + + Optional fireAt = policy.resolveOneDayLeftFireAt(startAt, endAt, now); + + assertThat(fireAt).as("하루짜리 챌린지의 D-1 발송 시각").isEmpty(); + } + + @Test + @DisplayName("발송 시각은 절대 마감 이후로 밀리지 않는다") + void neverFiresAfterDeadline() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 9, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 20, 23, 59, 59); + + for (int hour = 0; hour < 24; hour++) { + LocalDateTime now = LocalDateTime.of(2026, 9, 19, hour, 30); + policy.resolveOneDayLeftFireAt(startAt, endAt, now) + .ifPresent(fireAt -> assertThat(fireAt) + .as("now=%s 일 때 D-1 발송 시각", now) + .isAfter(now) + .isBeforeOrEqualTo(endAt)); + } + } + } + + @Nested + @DisplayName("중간 알림") + class Halfway { + + @Test + @DisplayName("중간 지점이 새벽이면 같은 날 09:00 으로 맞춘다") + void dawnHalfwayMovesToMorning() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 22, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 19, 10, 0); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 22, 0); + + Optional fireAt = policy.resolveHalfwayFireAt(startAt, endAt, now); + + assertThat(fireAt).as("새벽 04:00 중간 지점의 발송 시각") + .contains(LocalDateTime.of(2026, 9, 19, 9, 0)); + } + + @Test + @DisplayName("중간 지점이 저녁에 가까우면 같은 날 18:00 으로 맞춘다") + void eveningHalfwayMovesToEvening() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 10, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 18, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 10, 0); + + Optional fireAt = policy.resolveHalfwayFireAt(startAt, endAt, now); + + assertThat(fireAt).as("하루짜리 챌린지의 중간 알림 발송 시각") + .contains(LocalDateTime.of(2026, 9, 18, 18, 0)); + } + + @Test + @DisplayName("중간 지점이 09:00 과 18:00 의 정확히 가운데면 이른 09:00 으로 맞춘다") + void middleOfTwoAnchorsPicksMorning() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 13, 30); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 20, 13, 30); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 13, 30); + + Optional fireAt = policy.resolveHalfwayFireAt(startAt, endAt, now); + + assertThat(fireAt).as("13:30 중간 지점의 발송 시각") + .contains(LocalDateTime.of(2026, 9, 19, 9, 0)); + } + + @Test + @DisplayName("맞춘 시각이 이미 지났으면 다음 기준 시각으로 넘긴다") + void passedHalfwayMovesToNextAnchor() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 14, 9, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 20, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 17, 19, 0); + + Optional fireAt = policy.resolveHalfwayFireAt(startAt, endAt, now); + + assertThat(fireAt).as("중간 지점(9/17 16:29)의 18:00 이 지난 뒤의 발송 시각") + .contains(LocalDateTime.of(2026, 9, 18, 9, 0)); + } + } + + @Nested + @DisplayName("공통") + class Common { + + @Test + @DisplayName("이미 끝난 챌린지는 두 알림 모두 보내지 않는다") + void finishedChallengeSendsNothing() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 1, 9, 0); + LocalDateTime endAt = LocalDateTime.of(2026, 9, 10, 23, 59, 59); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 9, 0); + + assertThat(policy.resolveHalfwayFireAt(startAt, endAt, now)).as("중간 알림").isEmpty(); + assertThat(policy.resolveOneDayLeftFireAt(startAt, endAt, now)).as("D-1 알림").isEmpty(); + } + + @Test + @DisplayName("어떤 마감 시각이 와도 발송 시각은 09:00 아니면 18:00 이다") + void fireTimeIsAlwaysMorningOrEvening() { + LocalDateTime startAt = LocalDateTime.of(2026, 9, 18, 0, 0); + LocalDateTime now = LocalDateTime.of(2026, 9, 18, 0, 0); + + for (int hour = 0; hour < 24; hour++) { + LocalDateTime endAt = LocalDateTime.of(2026, 9, 25, hour, 17, 31); + + policy.resolveHalfwayFireAt(startAt, endAt, now) + .ifPresent(fireAt -> assertThatIsAnchor(fireAt, "중간 알림", endAt)); + policy.resolveOneDayLeftFireAt(startAt, endAt, now) + .ifPresent(fireAt -> assertThatIsAnchor(fireAt, "D-1 알림", endAt)); + } + } + + private void assertThatIsAnchor(LocalDateTime fireAt, String label, LocalDateTime endAt) { + assertThat(fireAt.toLocalTime()) + .as("%s 발송 시각 (마감 %s)", label, endAt) + .isIn(LocalTime.of(9, 0), LocalTime.of(18, 0)); + } + } +} From 54b4a8e9e00a3114ba8d717e4140629d9e93c0fa Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 17:29:45 +0900 Subject: [PATCH 119/130] =?UTF-8?q?[Fix]=20Quartz=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B4=88=EA=B8=B0=ED=99=94=EA=B0=80=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20QRTZ=5F=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B0=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QuartzSchemaInitializer 를 QuartzConfig 에 직접 등록해 자동 구성의 초기화 빈을 대체했다. 자동 구성 빈은 @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class) 로 붙어 있어서, 같은 타입 빈이 있으면 물러난다 - 이 빈은 QRTZ_TRIGGERS 가 이미 있으면 초기화 스크립트를 건너뛴다. dev/prod 의 initialize-schema: always 가 기동할 때마다 DROP TABLE 11 줄로 시작하는 tables_mysql_innodb.sql 을 실행해서, 사용자가 등록한 복습노트 알림 트리거(trigger-{practiceId})와 챌린지 알림 트리거가 배포마다 사라졌다 - 테이블 존재 확인에 실패하면 "있다" 로 보고 스크립트를 실행하지 않는다. 상태를 모르는 채로 되돌릴 수 없는 DROP 을 도는 쪽이 건너뛰는 쪽보다 훨씬 위험하기 때문이다 - QRTZ_ 스키마를 V45__create_quartz_tables.sql 로 옮겼다. 전부 CREATE TABLE IF NOT EXISTS 라 테이블이 이미 있는 dev/prod 에서는 아무 일도 하지 않고, 없는 환경에서만 만든다. CREATE INDEX 는 MySQL 에 IF NOT EXISTS 가 없어서 같은 이름의 인덱스를 CREATE TABLE 안으로 옮겼다 - 초기화 빈과 마이그레이션 동작을 고정하는 JDBC 테스트 5개를 추가했다 Closes #288 --- .../OnO/backend/util/quartz/QuartzConfig.java | 20 ++ .../util/quartz/QuartzSchemaInitializer.java | 95 +++++++++ .../migration/V45__create_quartz_tables.sql | 167 +++++++++++++++ .../QuartzSchemaInitializerJdbcTest.java | 197 ++++++++++++++++++ 4 files changed, 479 insertions(+) create mode 100644 src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java create mode 100644 src/main/resources/db/migration/V45__create_quartz_tables.sql create mode 100644 src/test/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializerJdbcTest.java diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java index 275f9fbc..e7046c4d 100644 --- a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzConfig.java @@ -2,6 +2,7 @@ import org.quartz.spi.JobFactory; import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.quartz.JobStoreType; import org.springframework.boot.autoconfigure.quartz.QuartzProperties; import org.springframework.context.ApplicationContext; @@ -22,6 +23,25 @@ public JobFactory jobFactory(ApplicationContext applicationContext) { return jobFactory; } + /** + * Quartz 스키마 초기화를 직접 맡는다. 자동 구성의 초기화 빈은 + * {@code @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class)} 라 + * 같은 타입인 이 빈이 있으면 물러난다. + * + *

    자동 구성 그대로 두면 {@code initialize-schema: always} 인 dev/prod 에서 기동할 때마다 + * QRTZ_ 테이블을 지우고 다시 만들어, 사용자가 등록한 알림 트리거가 배포마다 사라졌다. + * 자세한 내용은 {@link QuartzSchemaInitializer} 주석에 적었다. + * + *

    자동 구성과 같은 조건({@code job-store-type: jdbc})에서만 만든다. 메모리 잡스토어를 쓰는 + * 테스트 프로필에는 QRTZ_ 테이블 자체가 없어야 하므로 초기화 빈도 있으면 안 된다. + */ + @Bean + @ConditionalOnProperty(prefix = "spring.quartz", name = "job-store-type", havingValue = "jdbc") + public QuartzSchemaInitializer quartzSchemaInitializer(DataSource dataSource, + QuartzProperties quartzProperties) { + return new QuartzSchemaInitializer(dataSource, quartzProperties); + } + /** * 스케줄러를 직접 구성한다. Spring Boot 의 자동 구성은 이 빈이 있으면 물러난다. * diff --git a/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java new file mode 100644 index 00000000..164b409b --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializer.java @@ -0,0 +1,95 @@ +package com.aisip.OnO.backend.util.quartz; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.quartz.QuartzDataSourceScriptDatabaseInitializer; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.util.StringUtils; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; + +/** + * QRTZ_ 테이블이 이미 있으면 Quartz 스키마 초기화 스크립트를 실행하지 않는다. + * + *

    Spring Boot 의 {@code spring.quartz.jdbc.initialize-schema: always} 는 기동할 때마다 + * Quartz 가 제공하는 {@code tables_mysql_innodb.sql} 을 그대로 실행한다. 그 스크립트는 + * {@code DROP TABLE IF EXISTS QRTZ_...} 11 줄로 시작한다. 그래서 배포할 때마다 QRTZ_ 테이블이 + * 통째로 다시 만들어졌고, 사용자가 등록한 복습노트 알림 트리거({@code trigger-{practiceId}})와 + * 챌린지 알림 트리거가 전부 사라졌다. 기동할 때 {@code @PostConstruct} 로 다시 등록하는 + * 고정 트리거 세 개만 살아남았다. + * + *

    운영에 쓰이는 {@code application-prod.yml} 은 레포에 없고 CI secret 으로 주입된다. + * 설정 파일만 고쳐서는 운영에 반영되지 않으므로, 설정이 {@code always} 인 채로 배포되더라도 + * 테이블이 지워지지 않게 코드에서 막는다. + * + *

    자동 구성의 초기화 빈은 {@code @ConditionalOnMissingBean(QuartzDataSourceScriptDatabaseInitializer.class)} + * 로 등록된다({@code QuartzAutoConfiguration.JdbcStoreTypeConfiguration}). 같은 타입인 이 빈을 + * 직접 정의하면 자동 구성이 물러난다. + * + *

    테이블이 없을 때는 상위 구현에 그대로 위임한다. QRTZ_ 스키마는 Flyway + * {@code V45__create_quartz_tables.sql} 이 만들고, Flyway 는 이 초기화 빈보다 먼저 돈다 + * ({@code DataSourceScriptDatabaseInitializerDetector} 의 순서가 Flyway 감지기보다 뒤라 + * 스크립트 초기화 빈이 Flyway 에 의존하도록 엮인다). 즉 정상 경로에서는 여기서 스크립트가 + * 실행될 일이 없고, Flyway 를 끈 환경을 위한 대비책으로만 남는다. + */ +public class QuartzSchemaInitializer extends QuartzDataSourceScriptDatabaseInitializer { + + private static final Logger log = LoggerFactory.getLogger(QuartzSchemaInitializer.class); + + private static final String TABLE_PREFIX_PROPERTY = "org.quartz.jobStore.tablePrefix"; + private static final String DEFAULT_TABLE_PREFIX = "QRTZ_"; + + private final DataSource dataSource; + private final String triggersTableName; + + public QuartzSchemaInitializer(DataSource dataSource, QuartzProperties properties) { + super(dataSource, properties); + this.dataSource = dataSource; + this.triggersTableName = resolveTablePrefix(properties) + "TRIGGERS"; + } + + @Override + public boolean initializeDatabase() { + if (quartzSchemaExists()) { + log.info("Quartz 스키마가 이미 있어 초기화 스크립트를 건너뛴다. (기준 테이블: {})", triggersTableName); + return false; + } + + log.info("Quartz 스키마가 없어 초기화 스크립트를 실행한다. (기준 테이블: {})", triggersTableName); + return super.initializeDatabase(); + } + + /** + * 트리거 테이블이 이미 있는지 본다. + * + *

    확인에 실패하면 "있다"로 답한다. 초기화 스크립트는 되돌릴 수 없는 DROP 으로 시작하므로, + * 상태를 모르는 채로 실행하는 쪽이 건너뛰는 쪽보다 훨씬 위험하다. 테이블이 정말 없었다면 + * 잠시 뒤 스케줄러가 기동하면서 명확한 오류로 드러난다. + */ + private boolean quartzSchemaExists() { + try (Connection connection = dataSource.getConnection()) { + DatabaseMetaData metaData = connection.getMetaData(); + try (ResultSet tables = metaData.getTables( + connection.getCatalog(), connection.getSchema(), null, new String[]{"TABLE"})) { + while (tables.next()) { + if (triggersTableName.equalsIgnoreCase(tables.getString("TABLE_NAME"))) { + return true; + } + } + } + return false; + } catch (SQLException e) { + log.warn("Quartz 스키마 존재 여부를 확인하지 못했다. 초기화 스크립트를 실행하지 않는다.", e); + return true; + } + } + + private static String resolveTablePrefix(QuartzProperties properties) { + String prefix = properties.getProperties().get(TABLE_PREFIX_PROPERTY); + return StringUtils.hasText(prefix) ? prefix : DEFAULT_TABLE_PREFIX; + } +} diff --git a/src/main/resources/db/migration/V45__create_quartz_tables.sql b/src/main/resources/db/migration/V45__create_quartz_tables.sql new file mode 100644 index 00000000..591e757f --- /dev/null +++ b/src/main/resources/db/migration/V45__create_quartz_tables.sql @@ -0,0 +1,167 @@ +-- QRTZ_ 스키마의 주인을 Quartz 초기화 스크립트에서 Flyway 로 옮긴다. +-- +-- 지금까지는 dev/prod 의 `spring.quartz.jdbc.initialize-schema: always` 가 기동할 때마다 +-- Quartz 기본 스크립트(tables_mysql_innodb.sql)를 실행했다. 그 스크립트는 DROP TABLE 11 줄로 +-- 시작해서, 배포할 때마다 사용자가 등록한 복습노트 알림 트리거와 챌린지 알림 트리거가 사라졌다. +-- +-- 이 마이그레이션은 전부 CREATE TABLE IF NOT EXISTS 다. 테이블이 이미 있는 dev/prod 에서는 +-- 아무 일도 하지 않고, QRTZ_ 테이블이 없는 환경에서만 만든다. 어느 쪽이든 기존 행은 건드리지 않는다. +-- +-- 정의는 quartz-2.3.2.jar 의 org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql 을 그대로 +-- 옮긴 것이다. 다만 CREATE INDEX 는 MySQL 에 IF NOT EXISTS 가 없어 재실행이 안전하지 않으므로, +-- 같은 이름/같은 컬럼의 인덱스를 CREATE TABLE 안으로 옮겼다. 결과 스키마는 동일하다. +-- 컬럼 타입도 운영에 이미 만들어져 있는 것과 맞추려고 원본 표기(BIGINT(13) 등)를 유지한다. + +CREATE TABLE IF NOT EXISTS QRTZ_JOB_DETAILS ( + SCHED_NAME VARCHAR(120) NOT NULL, + JOB_NAME VARCHAR(190) NOT NULL, + JOB_GROUP VARCHAR(190) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + JOB_CLASS_NAME VARCHAR(250) NOT NULL, + IS_DURABLE VARCHAR(1) NOT NULL, + IS_NONCONCURRENT VARCHAR(1) NOT NULL, + IS_UPDATE_DATA VARCHAR(1) NOT NULL, + REQUESTS_RECOVERY VARCHAR(1) NOT NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_J_REQ_RECOVERY (SCHED_NAME, REQUESTS_RECOVERY), + INDEX IDX_QRTZ_J_GRP (SCHED_NAME, JOB_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + JOB_NAME VARCHAR(190) NOT NULL, + JOB_GROUP VARCHAR(190) NOT NULL, + DESCRIPTION VARCHAR(250) NULL, + NEXT_FIRE_TIME BIGINT(13) NULL, + PREV_FIRE_TIME BIGINT(13) NULL, + PRIORITY INTEGER NULL, + TRIGGER_STATE VARCHAR(16) NOT NULL, + TRIGGER_TYPE VARCHAR(8) NOT NULL, + START_TIME BIGINT(13) NOT NULL, + END_TIME BIGINT(13) NULL, + CALENDAR_NAME VARCHAR(190) NULL, + MISFIRE_INSTR SMALLINT(2) NULL, + JOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_T_J (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_T_JG (SCHED_NAME, JOB_GROUP), + INDEX IDX_QRTZ_T_C (SCHED_NAME, CALENDAR_NAME), + INDEX IDX_QRTZ_T_G (SCHED_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_T_STATE (SCHED_NAME, TRIGGER_STATE), + INDEX IDX_QRTZ_T_N_STATE (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP, TRIGGER_STATE), + INDEX IDX_QRTZ_T_N_G_STATE (SCHED_NAME, TRIGGER_GROUP, TRIGGER_STATE), + INDEX IDX_QRTZ_T_NEXT_FIRE_TIME (SCHED_NAME, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_ST (SCHED_NAME, TRIGGER_STATE, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_MISFIRE (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME), + INDEX IDX_QRTZ_T_NFT_ST_MISFIRE (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_STATE), + INDEX IDX_QRTZ_T_NFT_ST_MISFIRE_GRP (SCHED_NAME, MISFIRE_INSTR, NEXT_FIRE_TIME, TRIGGER_GROUP, TRIGGER_STATE), + FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) + REFERENCES QRTZ_JOB_DETAILS (SCHED_NAME, JOB_NAME, JOB_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SIMPLE_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + REPEAT_COUNT BIGINT(7) NOT NULL, + REPEAT_INTERVAL BIGINT(12) NOT NULL, + TIMES_TRIGGERED BIGINT(10) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_CRON_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + CRON_EXPRESSION VARCHAR(120) NOT NULL, + TIME_ZONE_ID VARCHAR(80), + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SIMPROP_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + STR_PROP_1 VARCHAR(512) NULL, + STR_PROP_2 VARCHAR(512) NULL, + STR_PROP_3 VARCHAR(512) NULL, + INT_PROP_1 INT NULL, + INT_PROP_2 INT NULL, + LONG_PROP_1 BIGINT NULL, + LONG_PROP_2 BIGINT NULL, + DEC_PROP_1 NUMERIC(13, 4) NULL, + DEC_PROP_2 NUMERIC(13, 4) NULL, + BOOL_PROP_1 VARCHAR(1) NULL, + BOOL_PROP_2 VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_BLOB_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + BLOB_DATA BLOB NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + FOREIGN KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) + REFERENCES QRTZ_TRIGGERS (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_CALENDARS ( + SCHED_NAME VARCHAR(120) NOT NULL, + CALENDAR_NAME VARCHAR(190) NOT NULL, + CALENDAR BLOB NOT NULL, + PRIMARY KEY (SCHED_NAME, CALENDAR_NAME) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_PAUSED_TRIGGER_GRPS ( + SCHED_NAME VARCHAR(120) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + PRIMARY KEY (SCHED_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_FIRED_TRIGGERS ( + SCHED_NAME VARCHAR(120) NOT NULL, + ENTRY_ID VARCHAR(95) NOT NULL, + TRIGGER_NAME VARCHAR(190) NOT NULL, + TRIGGER_GROUP VARCHAR(190) NOT NULL, + INSTANCE_NAME VARCHAR(190) NOT NULL, + FIRED_TIME BIGINT(13) NOT NULL, + SCHED_TIME BIGINT(13) NOT NULL, + PRIORITY INTEGER NOT NULL, + STATE VARCHAR(16) NOT NULL, + JOB_NAME VARCHAR(190) NULL, + JOB_GROUP VARCHAR(190) NULL, + IS_NONCONCURRENT VARCHAR(1) NULL, + REQUESTS_RECOVERY VARCHAR(1) NULL, + PRIMARY KEY (SCHED_NAME, ENTRY_ID), + INDEX IDX_QRTZ_FT_TRIG_INST_NAME (SCHED_NAME, INSTANCE_NAME), + INDEX IDX_QRTZ_FT_INST_JOB_REQ_RCVRY (SCHED_NAME, INSTANCE_NAME, REQUESTS_RECOVERY), + INDEX IDX_QRTZ_FT_J_G (SCHED_NAME, JOB_NAME, JOB_GROUP), + INDEX IDX_QRTZ_FT_JG (SCHED_NAME, JOB_GROUP), + INDEX IDX_QRTZ_FT_T_G (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), + INDEX IDX_QRTZ_FT_TG (SCHED_NAME, TRIGGER_GROUP) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_SCHEDULER_STATE ( + SCHED_NAME VARCHAR(120) NOT NULL, + INSTANCE_NAME VARCHAR(190) NOT NULL, + LAST_CHECKIN_TIME BIGINT(13) NOT NULL, + CHECKIN_INTERVAL BIGINT(13) NOT NULL, + PRIMARY KEY (SCHED_NAME, INSTANCE_NAME) +) ENGINE = InnoDB; + +CREATE TABLE IF NOT EXISTS QRTZ_LOCKS ( + SCHED_NAME VARCHAR(120) NOT NULL, + LOCK_NAME VARCHAR(40) NOT NULL, + PRIMARY KEY (SCHED_NAME, LOCK_NAME) +) ENGINE = InnoDB; diff --git a/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializerJdbcTest.java b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializerJdbcTest.java new file mode 100644 index 00000000..848a0d86 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/quartz/QuartzSchemaInitializerJdbcTest.java @@ -0,0 +1,197 @@ +package com.aisip.OnO.backend.util.quartz; + +import com.aisip.OnO.backend.support.TestContainers; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.boot.sql.init.DatabaseInitializationMode; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.testcontainers.containers.MySQLContainer; + +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * {@code initialize-schema: always} 인 채로 기동해도 기존 QRTZ_ 테이블이 지워지지 않는지 본다. + * + *

    이슈 #288. 자동 구성의 초기화 빈은 매 기동마다 Quartz 기본 스크립트를 실행했고, 그 스크립트가 + * DROP TABLE 로 시작해서 사용자가 등록한 알림 트리거가 배포마다 사라졌다. + * + *

    스프링 컨텍스트 없이 초기화 빈만 직접 만들어 확인한다. 테스트 프로필은 메모리 잡스토어라 + * 컨텍스트로는 이 경로가 아예 타지 않는다. 다른 테스트가 쓰는 {@code ono_test} 스키마를 건드리지 + * 않도록 전용 스키마를 따로 만든다. + */ +@DisplayName("Quartz 스키마 초기화 - 기존 테이블 보존") +class QuartzSchemaInitializerJdbcTest { + + private static final String SCHEMA = "quartz_schema_initializer_test"; + + private static final String[] QUARTZ_TABLES = { + "QRTZ_FIRED_TRIGGERS", + "QRTZ_PAUSED_TRIGGER_GRPS", + "QRTZ_SCHEDULER_STATE", + "QRTZ_LOCKS", + "QRTZ_SIMPLE_TRIGGERS", + "QRTZ_SIMPROP_TRIGGERS", + "QRTZ_CRON_TRIGGERS", + "QRTZ_BLOB_TRIGGERS", + "QRTZ_TRIGGERS", + "QRTZ_JOB_DETAILS", + "QRTZ_CALENDARS" + }; + + private HikariDataSource dataSource; + private JdbcTemplate jdbcTemplate; + + @BeforeEach + void setUp() throws Exception { + MySQLContainer mysql = TestContainers.mysql(); + String baseUrl = "jdbc:mysql://" + mysql.getHost() + ":" + mysql.getMappedPort(MySQLContainer.MYSQL_PORT); + + // 테스트 계정은 ono_test 에만 권한이 있어 스키마 생성은 root 로 한다. + try (Connection conn = DriverManager.getConnection(baseUrl, "root", mysql.getPassword()); + Statement statement = conn.createStatement()) { + statement.execute("CREATE DATABASE IF NOT EXISTS " + SCHEMA); + } + + dataSource = new HikariDataSource(); + dataSource.setJdbcUrl(baseUrl + "/" + SCHEMA); + dataSource.setUsername("root"); + dataSource.setPassword(mysql.getPassword()); + dataSource.setMaximumPoolSize(2); + + jdbcTemplate = new JdbcTemplate(dataSource); + + // "QRTZ_ 테이블이 없는 환경" 을 재현해야 하므로 테이블만 비우고 시작한다. + // 스키마 자체를 지우지 않는 것은 컨테이너를 재사용하는 다른 테스트와 겹치지 않기 위해서다. + dropQuartzTables(); + } + + private void dropQuartzTables() { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 0"); + try { + for (String table : QUARTZ_TABLES) { + jdbcTemplate.execute("DROP TABLE IF EXISTS " + table); + } + } finally { + jdbcTemplate.execute("SET FOREIGN_KEY_CHECKS = 1"); + } + } + + @AfterEach + void tearDown() { + if (dataSource != null) { + dataSource.close(); + } + } + + @Test + @DisplayName("QRTZ_ 테이블이 이미 있으면 always 여도 스크립트를 실행하지 않아 기존 행이 남는다") + void keepsExistingTablesWhenSchemaAlreadyExists() { + createQuartzSchema(); + jdbcTemplate.update( + "INSERT INTO QRTZ_LOCKS (SCHED_NAME, LOCK_NAME) VALUES (?, ?)", + "quartzScheduler", "MARKER_288"); + + boolean applied = newInitializer(DatabaseInitializationMode.ALWAYS).initializeDatabase(); + + assertThat(applied).isFalse(); + assertThat(countMarkerRows()).isEqualTo(1); + } + + @Test + @DisplayName("QRTZ_ 테이블이 없으면 스크립트를 실행해 만든다") + void createsSchemaWhenMissing() { + assertThat(quartzTableCount()).isZero(); + + boolean applied = newInitializer(DatabaseInitializationMode.ALWAYS).initializeDatabase(); + + assertThat(applied).isTrue(); + assertThat(quartzTableCount()).isEqualTo(11); + } + + @Test + @DisplayName("스크립트를 한 번 실행한 뒤 다시 기동해도 그때 만든 행이 남는다") + void keepsRowsAcrossRestarts() { + newInitializer(DatabaseInitializationMode.ALWAYS).initializeDatabase(); + jdbcTemplate.update( + "INSERT INTO QRTZ_LOCKS (SCHED_NAME, LOCK_NAME) VALUES (?, ?)", + "quartzScheduler", "MARKER_288"); + + boolean applied = newInitializer(DatabaseInitializationMode.ALWAYS).initializeDatabase(); + + assertThat(applied).isFalse(); + assertThat(countMarkerRows()).isEqualTo(1); + } + + @Test + @DisplayName("V45 마이그레이션은 두 번 돌려도 스키마를 다시 만들지 않는다") + void migrationIsIdempotent() { + applyMigration(); + assertThat(quartzTableCount()).isEqualTo(11); + + jdbcTemplate.update( + "INSERT INTO QRTZ_LOCKS (SCHED_NAME, LOCK_NAME) VALUES (?, ?)", + "quartzScheduler", "MARKER_288"); + applyMigration(); + + assertThat(quartzTableCount()).isEqualTo(11); + assertThat(countMarkerRows()).isEqualTo(1); + } + + @Test + @DisplayName("V45 로 만든 스키마에서는 초기화 빈이 스크립트를 실행하지 않는다") + void initializerSkipsAfterMigration() { + applyMigration(); + + boolean applied = newInitializer(DatabaseInitializationMode.ALWAYS).initializeDatabase(); + + assertThat(applied).isFalse(); + } + + private void applyMigration() { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator( + new ClassPathResource("db/migration/V45__create_quartz_tables.sql")); + populator.execute(dataSource); + } + + private QuartzSchemaInitializer newInitializer(DatabaseInitializationMode mode) { + QuartzProperties properties = new QuartzProperties(); + properties.getJdbc().setInitializeSchema(mode); + properties.getProperties().put("org.quartz.jobStore.tablePrefix", "QRTZ_"); + + QuartzSchemaInitializer initializer = new QuartzSchemaInitializer(dataSource, properties); + initializer.setResourceLoader(new DefaultResourceLoader()); + return initializer; + } + + private void createQuartzSchema() { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator( + new ClassPathResource("org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql")); + populator.setCommentPrefixes("#", "--"); + populator.execute(dataSource); + } + + private int countMarkerRows() { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM QRTZ_LOCKS WHERE LOCK_NAME = ?", Integer.class, "MARKER_288"); + return count == null ? 0 : count; + } + + private int quartzTableCount() { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM information_schema.tables " + + "WHERE table_schema = ? AND table_name LIKE 'QRTZ\\_%'", + Integer.class, SCHEMA); + return count == null ? 0 : count; + } +} From 5426515aa9dd582883925f76b9a3f2dfa76af259 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Fri, 18 Sep 2026 17:44:48 +0900 Subject: [PATCH 120/130] =?UTF-8?q?[Fix]=20FCM=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=EC=9D=B4=20=EA=B5=90=EC=B0=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EA=B9=A8=EC=A0=B8=EB=8F=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=8B=9C=EB=8F=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 키를 동시에 INSERT 하면 InnoDB 가 중복 확인을 하려고 상대 레코드에 공유 잠금을 걸고 기다리다 교착이 난다 이때는 Duplicate entry 가 아니라 CannotAcquireLockException(PessimisticLockingFailureException)으로 올라와 중복 처리에 걸리지 않고 그대로 500 이 됐다 - registerToken 의 재시도를 중복 키와 잠금 실패 모두로 넓히고, 최대 3번까지만 시도하도록 횟수를 못박았다 그래도 실패하면 행이 이미 있는지 확인하고, 없으면 마지막 예외를 그대로 올린다 - 교착 예외를 한 번 던지는 라이터로 재시도 경로를 고정한 테스트를 추가했다 단위 5개와 실제 MySQL 통합 2개이고, 재시도 없이 돌리면 6개가 실패한다 --- .../backend/util/fcm/service/FcmService.java | 57 +++++--- .../util/fcm/service/FcmServiceTest.java | 97 +++++++++++++ .../FcmTokenRegisterRetryIntegrationTest.java | 132 ++++++++++++++++++ 3 files changed, 266 insertions(+), 20 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenRegisterRetryIntegrationTest.java diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java index cccc6ef8..8db9df48 100644 --- a/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/service/FcmService.java @@ -17,6 +17,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.dao.PessimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -37,6 +38,14 @@ public class FcmService { private final FcmNotificationProducer fcmNotificationProducer; + /** + * 토큰 등록을 최대 몇 번까지 시도하는가. + * + *

    중복 키든 교착이든 상대가 이미 끝났거나 되돌아간 뒤에 다시 하는 것이라 대개 두 번째에 끝난다. + * 세 번째는 바로 그 사이에 또 다른 요청이 끼어든 경우를 위한 여유 한 번이다. + */ + private static final int MAX_REGISTER_ATTEMPTS = 3; + /** * 토큰은 기기 하나를 가리키므로 한 사용자에게만 묶여 있어야 한다. * @@ -52,33 +61,41 @@ public class FcmService { * *

    같은 토큰을 한 번 더 등록하는 것은 결과 상태가 같은 재시도다. 그래서 중복은 실패가 아니라 성공으로 본다. * 다만 롤백된 트랜잭션은 이전 소유자 행 삭제까지 되돌리므로, 새 트랜잭션에서 한 번 더 등록을 돌려 맞춘다. + * + *

    경합이 중복 키로만 끝나는 것이 아니다. 같은 키를 동시에 INSERT 하면 InnoDB 가 중복 여부를 보려고 + * 상대가 쥔 레코드에 공유 잠금을 걸고 기다리는데, 그 상태에서 서로 삽입을 마치려 하면 교착이 된다. + * 이때는 {@code Duplicate entry} 가 아니라 {@link PessimisticLockingFailureException} + * (MySQL 교착과 잠금 대기 초과)으로 올라와 예전 코드의 중복 처리에 걸리지 않고 그대로 500 이 됐다. + * 전체 테스트를 돌릴 때 {@code FcmTokenRegistrationConcurrencyTest} 가 INSERT 교착으로 한 번 깨진 것이 그 경로다. + * (Sentry JAVA-SPRING-BOOT-5J 후속) + * + *

    둘 다 "다시 하면 되는" 실패라 같은 방식으로 다룬다. 등록은 결과가 같은 작업이라 여러 번 돌려도 안전하다. + * 대신 {@link #MAX_REGISTER_ATTEMPTS} 번까지만 시도한다. 무한 재시도는 DB 가 아픈 상황에서 + * 요청 스레드를 붙잡아 장애를 키운다. 그래도 안 되면 원하는 상태가 되었는지 확인하고, + * 아니면 마지막 예외를 그대로 올려 500 과 Sentry 로 드러나게 둔다. */ @Transactional(propagation = Propagation.NOT_SUPPORTED) public void registerToken(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { - try { - fcmTokenWriter.register(fcmTokenRequestDto, userId); - } catch (DataIntegrityViolationException e) { - log.info("FCM token insert lost the race, retrying - userId: {}", userId); - registerAfterLosingRace(fcmTokenRequestDto, userId); - } - } + RuntimeException lastFailure = null; - /** - * 중복으로 실패한 등록을 새 트랜잭션에서 한 번 더 시도한다. - * - *

    이미 행이 있으면 INSERT 없이 이전 소유자 정리만 확인하고 끝난다. - * 재시도까지 중복으로 끝났다면 그 짧은 사이에 또 다른 요청이 같은 행을 넣었다는 뜻이므로, - * 원하는 상태가 되었는지 확인하고 되었으면 성공으로 본다. 아니면 원래 예외를 그대로 올린다. - */ - private void registerAfterLosingRace(FcmTokenRequestDto fcmTokenRequestDto, Long userId) { - try { - fcmTokenWriter.register(fcmTokenRequestDto, userId); - } catch (DataIntegrityViolationException retryFailure) { - if (!fcmTokenWriter.exists(userId, fcmTokenRequestDto.token())) { - throw retryFailure; + for (int attempt = 1; attempt <= MAX_REGISTER_ATTEMPTS; attempt++) { + try { + fcmTokenWriter.register(fcmTokenRequestDto, userId); + return; + } catch (DataIntegrityViolationException | PessimisticLockingFailureException failure) { + lastFailure = failure; + log.info("FCM token registration lost the race - userId: {}, attempt: {}/{}, reason: {}", + userId, attempt, MAX_REGISTER_ATTEMPTS, failure.getClass().getSimpleName()); } + } + + // 마지막 시도까지 실패했어도 다른 요청이 같은 행을 넣었으면 원하는 상태다. 그때는 성공으로 본다. + if (fcmTokenWriter.exists(userId, fcmTokenRequestDto.token())) { log.info("FCM token already registered by a concurrent request - userId: {}", userId); + return; } + + throw lastFailure; } public FcmTokenResponseDto findToken(String token) { diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java index 3c1f5b20..a4ef9379 100644 --- a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmServiceTest.java @@ -20,11 +20,14 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; @@ -32,6 +35,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -145,6 +149,99 @@ void doesNotSendOnRegistration() { } } + @Nested + @DisplayName("토큰 등록 재시도") + class RegisterTokenRetry { + + private FcmTokenWriter fcmTokenWriter; + private FcmService serviceWithMockedWriter; + + @BeforeEach + void setUpMockedWriter() { + fcmTokenWriter = mock(FcmTokenWriter.class); + serviceWithMockedWriter = new FcmService(fcmTokenRepository, fcmTokenWriter, + firebaseMessaging, meterRegistry, fcmNotificationProducer); + } + + private CannotAcquireLockException deadlock() { + return new CannotAcquireLockException("Deadlock found when trying to get lock; try restarting transaction"); + } + + @Test + @DisplayName("교착으로 한 번 실패해도 다시 시도해 성공한다 - 예전에는 그대로 500 이 나갔다") + void retriesAfterDeadlock() { + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + if (calls.getAndIncrement() == 0) { + throw deadlock(); + } + return null; + }).when(fcmTokenWriter).register(any(), anyLong()); + + assertThatCode(() -> serviceWithMockedWriter.registerToken(new FcmTokenRequestDto("token-1"), 1L)) + .doesNotThrowAnyException(); + + verify(fcmTokenWriter, times(2)).register(any(), anyLong()); + } + + @Test + @DisplayName("중복 키로 한 번 실패해도 다시 시도해 성공한다") + void retriesAfterDuplicateKey() { + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + if (calls.getAndIncrement() == 0) { + throw new DataIntegrityViolationException("Duplicate entry '1-token-1' for key 'fcm_token.idx_fcm_token_user_token'"); + } + return null; + }).when(fcmTokenWriter).register(any(), anyLong()); + + assertThatCode(() -> serviceWithMockedWriter.registerToken(new FcmTokenRequestDto("token-1"), 1L)) + .doesNotThrowAnyException(); + + verify(fcmTokenWriter, times(2)).register(any(), anyLong()); + } + + @Test + @DisplayName("계속 실패해도 정해진 횟수까지만 시도한다 - 무한 재시도는 요청 스레드를 붙잡는다") + void stopsAfterMaxAttempts() { + doThrow(deadlock()).when(fcmTokenWriter).register(any(), anyLong()); + when(fcmTokenWriter.exists(anyLong(), anyString())).thenReturn(false); + + assertThatThrownBy(() -> serviceWithMockedWriter.registerToken(new FcmTokenRequestDto("token-1"), 1L)) + .isInstanceOf(CannotAcquireLockException.class); + + verify(fcmTokenWriter, times(3)).register(any(), anyLong()); + } + + @Test + @DisplayName("끝까지 실패해도 다른 요청이 같은 행을 넣었으면 성공으로 본다") + void succeedsWhenAnotherRequestRegisteredTheSameToken() { + doThrow(deadlock()).when(fcmTokenWriter).register(any(), anyLong()); + when(fcmTokenWriter.exists(1L, "token-1")).thenReturn(true); + + assertThatCode(() -> serviceWithMockedWriter.registerToken(new FcmTokenRequestDto("token-1"), 1L)) + .doesNotThrowAnyException(); + + verify(fcmTokenWriter, times(3)).register(any(), anyLong()); + } + + @Test + @DisplayName("재시도해도 푸시는 보내지 않는다") + void neverSendsWhileRetrying() { + AtomicInteger calls = new AtomicInteger(); + doAnswer(invocation -> { + if (calls.getAndIncrement() == 0) { + throw deadlock(); + } + return null; + }).when(fcmTokenWriter).register(any(), anyLong()); + + serviceWithMockedWriter.registerToken(new FcmTokenRequestDto("token-1"), 1L); + + verifyNoInteractions(firebaseMessaging); + } + } + @Nested @DisplayName("토큰 조회") class FindToken { diff --git a/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenRegisterRetryIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenRegisterRetryIntegrationTest.java new file mode 100644 index 00000000..788673f6 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/util/fcm/service/FcmTokenRegisterRetryIntegrationTest.java @@ -0,0 +1,132 @@ +package com.aisip.OnO.backend.util.fcm.service; + +import com.aisip.OnO.backend.config.rabbitmq.producer.FcmNotificationProducer; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.aisip.OnO.backend.util.fcm.entity.FcmToken; +import com.aisip.OnO.backend.util.fcm.repository.FcmTokenRepository; +import com.google.firebase.messaging.FirebaseMessaging; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.CannotAcquireLockException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 등록이 교착으로 한 번 깨진 뒤 실제 MySQL 에 무엇이 남는지 본다. (Sentry JAVA-SPRING-BOOT-5J 후속) + * + *

    교착은 타이밍에 달려 있어 실제로 나게 만들면 테스트가 들쭉날쭉해진다. + * 그래서 첫 호출만 {@link CannotAcquireLockException} 을 던지고 그다음부터는 진짜 라이터로 넘기는 + * 라이터를 끼워 재시도 경로를 고정한다. 저장은 실제 트랜잭션과 유니크 제약을 그대로 탄다. + */ +@DisplayName("FCM 토큰 등록 재시도") +class FcmTokenRegisterRetryIntegrationTest extends IntegrationTestSupport { + + private static final String DEVICE_TOKEN = "retry-device-token"; + private static final Long USER_A = 1L; + private static final Long USER_B = 2L; + + @Autowired + private FcmTokenRepository fcmTokenRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private FcmTokenWriter fcmTokenWriter; + + @Autowired + private PlatformTransactionManager transactionManager; + + private FirebaseMessaging firebaseMessaging; + private AtomicInteger registerCalls; + private FcmService fcmServiceWithFlakyWriter; + + @BeforeEach + void setUp() { + // FcmTokenRepositoryTest 와 같은 이유로 테이블 기반 id 생성기의 초기 행을 되살린다. + Integer rows = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM fcm_token_seq", Integer.class); + if (rows == null || rows == 0) { + jdbcTemplate.update("INSERT INTO fcm_token_seq (next_val) VALUES (1)"); + } + + firebaseMessaging = mock(FirebaseMessaging.class); + registerCalls = new AtomicInteger(); + fcmServiceWithFlakyWriter = transactionalProxyOf(new FcmService( + fcmTokenRepository, deadlockOnFirstCall(), firebaseMessaging, new SimpleMeterRegistry(), + mock(FcmNotificationProducer.class))); + } + + /** 첫 등록만 교착으로 떨어뜨리고, 이후 호출은 실제 라이터에 그대로 넘긴다. */ + private FcmTokenWriter deadlockOnFirstCall() { + FcmTokenWriter flakyWriter = mock(FcmTokenWriter.class); + doAnswer(invocation -> { + if (registerCalls.getAndIncrement() == 0) { + throw new CannotAcquireLockException( + "Deadlock found when trying to get lock; try restarting transaction"); + } + fcmTokenWriter.register(invocation.getArgument(0), invocation.getArgument(1)); + return null; + }).when(flakyWriter).register(any(), anyLong()); + when(flakyWriter.exists(anyLong(), anyString())) + .thenAnswer(invocation -> fcmTokenWriter.exists(invocation.getArgument(0), invocation.getArgument(1))); + return flakyWriter; + } + + private FcmService transactionalProxyOf(FcmService target) { + ProxyFactory proxyFactory = new ProxyFactory(target); + proxyFactory.setProxyTargetClass(true); + proxyFactory.addAdvice( + new TransactionInterceptor(transactionManager, new AnnotationTransactionAttributeSource())); + return (FcmService) proxyFactory.getProxy(); + } + + @Test + @DisplayName("교착으로 한 번 실패해도 등록은 성공하고 행은 하나만 남는다") + void registersOnceAfterDeadlock() { + assertThatCode(() -> fcmServiceWithFlakyWriter.registerToken(new FcmTokenRequestDto(DEVICE_TOKEN), USER_A)) + .as("교착은 다시 하면 되는 실패다. 그대로 올라가면 사용자에게 500 이 나간다") + .doesNotThrowAnyException(); + + assertThat(registerCalls.get()).isEqualTo(2); + assertThat(fcmTokenRepository.findAllByUserId(USER_A)) + .extracting(FcmToken::getToken) + .containsExactly(DEVICE_TOKEN); + assertThat(fcmTokenRepository.findAll()).hasSize(1); + verifyNoInteractions(firebaseMessaging); + } + + @Test + @DisplayName("계정 전환이 교착으로 한 번 깨져도 이전 소유자 행이 남지 않는다") + void keepsOwnerSwitchAfterDeadlock() { + fcmTokenWriter.register(new FcmTokenRequestDto(DEVICE_TOKEN), USER_A); + + fcmServiceWithFlakyWriter.registerToken(new FcmTokenRequestDto(DEVICE_TOKEN), USER_B); + + assertThat(fcmTokenRepository.findAllByUserId(USER_A)) + .as("교착으로 롤백되면 이전 소유자 행 삭제도 되돌아간다. 재시도가 그것까지 다시 맞춰야 한다") + .isEmpty(); + assertThat(fcmTokenRepository.findAllByUserId(USER_B)) + .extracting(FcmToken::getToken) + .containsExactly(DEVICE_TOKEN); + assertThat(fcmTokenRepository.findAll()).hasSize(1); + } +} From 5cb05d7c4fc14a4ac05f300efffdb114021a42b4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 14:02:02 +0900 Subject: [PATCH 121/130] =?UTF-8?q?[Fix]=20=EC=9D=B8=EC=A6=9D=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=EC=9D=B4=20200?= =?UTF-8?q?=20=EC=9C=BC=EB=A1=9C=20=EB=82=98=EA=B0=80=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomAuthenticationEntryPoint 가 공개 경로를 /api/auth 접두사로 판정해서, 보호 대상인 POST /api/auth/logout 까지 인증 실패 응답을 쓰지 않고 그냥 반환했다 SecurityConfig 는 이 경로에 GUEST, MEMBER, ADMIN 을 요구하는데 빈 200 이 나가서 앱은 로그아웃에 성공했다고 믿고, 서버는 세션도 블랙리스트도 건드리지 않았다 - 공개 경로 판정을 정확 매칭 집합과 / 로 끝나는 접두사 목록으로 바꿨다. AuthController 를 전수로 보면 /api/auth 아래에서 공개여야 하는 것은 아직 토큰이 없는 상태로 부르는 세 가지뿐이다 게스트 로그인(/api/auth/signup/guest), 소셜 로그인과 가입(/api/auth/signup/member), 토큰 갱신(/api/auth/refresh) - /login 과 /swagger-ui 도 접두사 판정이라 /loginx 같은 옆 경로가 공개로 샜다. 아래에 보호 경로가 없어서 위험하지는 않았지만 같은 방식으로 좁혔다 /grafana 와 /prometheus 는 이미 정확 매칭과 / 로 끝나는 접두사라 그대로 뒀다 - 인증 실패한 로그아웃은 이제 기존 에러 응답과 같은 형식(errorCode 포함)으로 나간다. 토큰 없음 1007, 쓰레기 토큰 1009, 만료 1005 로 사유가 살아 있어서 앱이 갱신할지 재로그인할지 그대로 구분한다 - 로그아웃 거절과 공개 경로 판정을 고정하는 테스트를 추가했다. 접두사 판정으로 되돌리면 6개가 실패한다 Closes #299 --- .../auth/CustomAuthenticationEntryPoint.java | 70 +++++++++++++++---- .../backend/auth/controller/AuthApiTest.java | 70 +++++++++++++------ .../AuthenticationFailureResponseTest.java | 44 +++++++++--- 3 files changed, 138 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java index 0832e53e..cdaa2df1 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/CustomAuthenticationEntryPoint.java @@ -12,32 +12,55 @@ import org.springframework.stereotype.Component; import java.io.IOException; +import java.util.List; +import java.util.Set; @Component @RequiredArgsConstructor public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + /** + * 인증 실패 응답을 쓰지 않을 공개 경로. 접두사가 아니라 정확 매칭이다. + * + *

    과거에는 {@code /api/auth} 접두사로 판정해서 보호 대상인 + * {@code POST /api/auth/logout} 까지 딸려 들어갔다. 그 결과 인증이 없어도 + * 401 이 아니라 빈 200 이 나가서, 앱은 로그아웃에 성공했다고 믿는데 + * 서버는 세션도 블랙리스트도 건드리지 않았다. + * + *

    {@code /api/auth} 아래에서 공개여야 하는 것은 아직 토큰이 없는 상태에서 + * 부르는 세 가지뿐이다. 게스트 로그인, 소셜 로그인과 가입, 토큰 갱신이다. + */ + private static final Set PUBLIC_PATHS = Set.of( + "/", + "/robots.txt", + "/home", + "/login", + "/grafana", + "/prometheus", + "/swagger-ui.html", + "/v3/api-docs", + "/api/auth/signup/guest", + "/api/auth/signup/member", + "/api/auth/refresh" + ); + + /** 하위 경로 전체가 공개인 것들. 반드시 {@code /} 로 끝내서 접두사가 옆 경로를 먹지 않게 한다. */ + private static final List PUBLIC_PATH_PREFIXES = List.of( + "/grafana/", + "/prometheus/", + "/swagger-ui/", + "/v3/api-docs/" + ); + private final ObjectMapper objectMapper; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { - String requestURI = request.getRequestURI(); - if(requestURI.contains("/actuator/") || - requestURI.equals("/grafana") || - requestURI.startsWith("/grafana/") || - requestURI.equals("/prometheus") || - requestURI.startsWith("/prometheus/") || - requestURI.startsWith("/api/auth") || - requestURI.equals("/") || - requestURI.equals("/robots.txt") || - requestURI.equals("/home") || - requestURI.startsWith("/login") || - requestURI.startsWith("/swagger-ui") || - requestURI.startsWith("/v3/api-docs") - ) { + if (isPublicPath(request.getRequestURI())) { return; } + ErrorCase errorCase = resolveErrorCase(request); response.setContentType("application/json;charset=UTF-8"); @@ -45,6 +68,25 @@ public void commence(HttpServletRequest request, HttpServletResponse response, A objectMapper.writeValue(response.getWriter(), CommonResponse.error(errorCase)); } + private boolean isPublicPath(String requestURI) { + if (requestURI == null) { + return false; + } + + // 서블릿 컨테이너의 getRequestURI() 에는 쿼리스트링이 없지만, + // 테스트나 다른 구현이 붙여 보내도 판정이 흔들리지 않게 잘라낸다. + int queryIndex = requestURI.indexOf('?'); + String path = queryIndex < 0 ? requestURI : requestURI.substring(0, queryIndex); + + if (path.contains("/actuator/")) { + return true; + } + if (PUBLIC_PATHS.contains(path)) { + return true; + } + return PUBLIC_PATH_PREFIXES.stream().anyMatch(path::startsWith); + } + private ErrorCase resolveErrorCase(HttpServletRequest request) { Object errorCase = request.getAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE); if (errorCase instanceof ErrorCase resolvedErrorCase) { diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java index d7d45ba0..20bf7601 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/AuthApiTest.java @@ -484,46 +484,72 @@ void cannotKillOtherUsersSession() throws Exception { refresh(victim.path("refreshToken").asText()).andExpect(status().isOk()); } - /** - * 계약 불일치(현재 동작을 그대로 고정한다). - * - *

    CustomAuthenticationEntryPoint 는 요청 URI 가 {@code /api/auth} 로 시작하면 - * 아무것도 쓰지 않고 반환한다. 그래서 인증 실패한 로그아웃은 401 + 1007 이 아니라 - * 200 + 빈 본문으로 나간다. 프론트는 errorCode 가 없는 응답을 받게 되고, - * 이것이 "errorCode: null 인 UnauthorizedException" 관측과 맞물린다. - * 수정 지점은 common/auth 패키지라 여기서는 손대지 않고 동작만 못 박는다. - */ @Test - @DisplayName("만료된 액세스 토큰으로 로그아웃하면 401 이 아니라 200 빈 본문이 나간다") - void expiredAccessTokenLogoutReturnsEmptyOk() throws Exception { + @DisplayName("만료된 액세스 토큰으로 로그아웃하면 401 + 1005 로 막고 세션은 남긴다") + void expiredAccessTokenLogoutIsRejected() throws Exception { JsonNode tokens = signUpGuest(); String expiredAccessToken = expiredTokenizer.createAccessToken( "1", Map.of("authority", Authority.ROLE_GUEST)); - MvcResult result = mockMvc.perform(post("/api/auth/logout") + mockMvc.perform(post("/api/auth/logout") .header("Authorization", expiredAccessToken) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString( new TokenRequestDto(expiredAccessToken, tokens.path("refreshToken").asText())))) - .andExpect(status().isOk()) - .andReturn(); + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode())); - assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8)) - .as("본문이 비어 있어 프론트는 errorCode 없이 이 응답을 해석해야 한다") - .isEmpty(); assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) .as("인증이 안 됐으므로 컨트롤러까지 가지 못해 세션은 그대로 남는다") .isPresent(); } @Test - @DisplayName("인증 없이 로그아웃해도 401 이 아니라 200 빈 본문이 나간다") - void anonymousLogoutReturnsEmptyOk() throws Exception { - MvcResult result = mockMvc.perform(post("/api/auth/logout")) + @DisplayName("인증 없이 로그아웃하면 401 + 1007 로 막는다") + void anonymousLogoutIsRejected() throws Exception { + mockMvc.perform(post("/api/auth/logout")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("쓰레기 토큰으로 로그아웃하면 401 + 1009 로 막고 세션은 남긴다") + void garbageAccessTokenLogoutIsRejected() throws Exception { + JsonNode tokens = signUpGuest(); + + mockMvc.perform(post("/api/auth/logout") + .header("Authorization", "Bearer not-a-real-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString( + new TokenRequestDto(null, tokens.path("refreshToken").asText())))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + + assertThat(refreshTokenRepository.findByRefreshToken(tokens.path("refreshToken").asText())) + .as("막지 못하면 토큰 없이 남의 세션을 지울 수 있게 된다") + .isPresent(); + } + + @Test + @DisplayName("Bearer 형식이 아닌 헤더로 로그아웃하면 401 + 1007 로 막는다") + void nonBearerHeaderLogoutIsRejected() throws Exception { + mockMvc.perform(post("/api/auth/logout").header("Authorization", "garbage")) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode())); + } + + @Test + @DisplayName("정상 토큰이면 200 과 함께 세션을 실제로 지운다") + void validTokenLogoutClearsSession() throws Exception { + JsonNode tokens = signUpGuest(); + String accessToken = tokens.path("accessToken").asText(); + String refreshToken = tokens.path("refreshToken").asText(); + + logout(accessToken, refreshToken) .andExpect(status().isOk()) - .andReturn(); + .andExpect(jsonPath("$.data").value("로그아웃 되었습니다.")); - assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).isEmpty(); + assertThat(refreshTokenRepository.findByRefreshToken(refreshToken)).isEmpty(); } } diff --git a/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java b/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java index 5256b712..83398972 100644 --- a/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/auth/AuthenticationFailureResponseTest.java @@ -54,7 +54,9 @@ class PublicPaths { "/grafana/d/board", "/prometheus", "/prometheus/metrics", - "/api/auth/login", + "/api/auth/signup/guest", + "/api/auth/signup/member", + "/api/auth/refresh", "/", "/robots.txt", "/home", @@ -149,20 +151,42 @@ void writesUtf8Json() throws Exception { .contains(AuthErrorCase.AUTHENTICATION_FAILED.getMessage()); } - @Test - @DisplayName("접두사만 비슷한 경로는 공개 경로로 보지 않는다") - void doesNotTreatLookalikePathAsPublic() throws Exception { + @ParameterizedTest(name = "{0}") + @ValueSource(strings = { + "/api/auth/logout", + "/api/authorization-test", + "/apis/auth", + "/grafanas", + "/prometheus-admin", + "/swagger-uix", + "/loginx" + }) + @DisplayName("공개 경로와 접두사만 비슷한 경로는 401 로 막는다") + void doesNotTreatLookalikePathAsPublic(String uri) throws Exception { MockHttpServletResponse response = new MockHttpServletResponse(); - entryPoint.commence(request("/api/authorization-test"), response, authenticationException); + entryPoint.commence(request(uri), response, authenticationException); assertThat(response.getStatus()) - .as("/api/auth 로 시작하는 경로 규칙은 현재 접두사 매칭이다") - .isEqualTo(200); + .as("%s 가 공개로 새면 인증 실패가 빈 200 으로 나간다", uri) + .isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .isEqualTo(AuthErrorCase.AUTHENTICATION_FAILED.getErrorCode()); + } - MockHttpServletResponse other = new MockHttpServletResponse(); - entryPoint.commence(request("/apis/auth"), other, authenticationException); - assertThat(other.getStatus()).isEqualTo(401); + @Test + @DisplayName("로그아웃은 실패 사유를 그대로 실어 401 로 응답한다") + void logoutCarriesFailureReason() throws Exception { + MockHttpServletRequest request = request("/api/auth/logout"); + request.setAttribute(JwtTokenFilter.AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.ACCESS_TOKEN_EXPIRED); + MockHttpServletResponse response = new MockHttpServletResponse(); + + entryPoint.commence(request, response, authenticationException); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(body(response).get("errorCode").asInt()) + .as("만료 사유가 살아야 앱이 갱신 후 다시 로그아웃을 시도한다") + .isEqualTo(AuthErrorCase.ACCESS_TOKEN_EXPIRED.getErrorCode()); } } From 88afa3ad289829a9a2052727828df16c43db8165 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 14:04:50 +0900 Subject: [PATCH 122/130] =?UTF-8?q?[Fix]=20=EB=B3=B5=EC=8A=B5=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=95=8C=EB=A6=BC=EC=97=90=20type=20=EC=9D=84=20?= =?UTF-8?q?=EB=84=A3=EA=B3=A0=20=EC=95=8C=EB=A6=BC=20type=20=ED=91=9C?= =?UTF-8?q?=EA=B8=B0=EB=A5=BC=20=EC=86=8C=EB=AC=B8=EC=9E=90=EB=A1=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PracticeNotificationJob 이 data 에 practiceId 만 담고 있었다. 앱은 data["type"] 하나로만 화면을 고르기 때문에, 사용자가 직접 켠 복습노트 알림을 눌러도 아무 화면이 열리지 않았다. practice_note_reminder 를 넣었다 - 알림 type 값이 발송하는 곳마다 문자열로 흩어져 있어서 NotificationType 상수 클래스 하나로 모았다. 앱과 맞춰 둔 계약이라 오타가 나도 컴파일이 통과하는 자리였다 - 표기를 소문자 스네이크로 통일했다. CHALLENGE_NOTIFICATION, CHALLENGE_COMPLETED, SHARED_PROBLEM, SHARED_PROBLEM_REACTION, SHARED_PROBLEM_COMMENT 다섯 개가 challenge_notification, challenge_completed, shared_problem, shared_problem_reaction, shared_problem_comment 로 바뀐다 - 운영 중인 4.0.0 앱은 review_due, reengagement, reengagement_monthly 세 개만 분기하고 나머지는 무시하므로 지금 앱에서 깨지는 곳은 없다 - roomId, sharedProblemId, practiceId 같은 다른 데이터 키는 프론트가 그대로 읽으므로 건드리지 않았다 - 알림 경로마다 정해진 type 이 실제로 담겨 나가는지 고정하는 테스트를 추가했다. 복습노트 알림 1개와 스터디룸 공유/반응/댓글 3개다 Closes #301 --- .../service/PracticeNotificationJob.java | 5 +- .../quartz/ReviewDueNotificationJob.java | 7 +- .../reminder/ProblemReviewReminderPolicy.java | 3 +- .../quartz/ChallengeNotificationJob.java | 3 +- .../service/StudyRoomChallengeService.java | 3 +- .../StudyRoomSharedProblemCommentService.java | 3 +- .../StudyRoomSharedProblemService.java | 5 +- .../backend/util/fcm/NotificationType.java | 46 ++++++++ .../service/PracticeNotificationJobTest.java | 68 ++++++++++++ .../StudyRoomNotificationTypeApiTest.java | 101 ++++++++++++++++++ .../quartz/StudyRoomQuartzJobTest.java | 2 +- ...StudyRoomChallengeCompletionEventTest.java | 2 +- 12 files changed, 236 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java create mode 100644 src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJobTest.java create mode 100644 src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationTypeApiTest.java diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java index 8dfea878..07edc098 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJob.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.practicenote.service; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.extern.slf4j.Slf4j; @@ -31,7 +32,9 @@ public void executeInternal(JobExecutionContext context) { "User Token", "오답노트를 복습할 시간이예요!", practiceTitle + " 복습 세트를 공부할 시간입니다!", - Map.of("practiceId", String.valueOf(practiceId)) + // type 이 없으면 앱이 분기하지 못해 알림을 눌러도 아무 화면이 열리지 않는다. + Map.of("type", NotificationType.PRACTICE_NOTE_REMINDER, + "practiceId", String.valueOf(practiceId)) ) ); } diff --git a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java index 5b38eff3..a369f78f 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/problem/quartz/ReviewDueNotificationJob.java @@ -4,6 +4,7 @@ import com.aisip.OnO.backend.problem.repository.ReviewDueSummary; import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.extern.slf4j.Slf4j; @@ -78,7 +79,7 @@ private void sendReviewNotifications(LocalDate today, LocalDateTime reengagement if (sendSafely(user.getId(), new NotificationRequestDto("", "오늘의 복습 알림", "오늘 복습할 문제가 " + dueCount + "개 있어요!", - Map.of("type", "review_due")))) { + Map.of("type", NotificationType.REVIEW_DUE)))) { notifiedUserIds.add(user.getId()); } } @@ -105,7 +106,7 @@ private void sendReengagementNotifications(LocalDate today, LocalDateTime reenga if (sendSafely(user.getId(), new NotificationRequestDto("", "오랜만이에요!", "오답노트를 펼칠 시간이에요. 복습하러 돌아와보세요!", - Map.of("type", "reengagement")))) { + Map.of("type", NotificationType.REENGAGEMENT)))) { notifiedUserIds.add(user.getId()); } } @@ -132,7 +133,7 @@ private void sendLongInactiveReengagementNotifications(LocalDate today, LocalDat if (sendSafely(user.getId(), new NotificationRequestDto("", "오답노트가 기다리고 있어요", "한동안 자리를 비우셨네요. 다시 시작하기 딱 좋은 날이에요!", - Map.of("type", "reengagement_monthly")))) { + Map.of("type", NotificationType.REENGAGEMENT_MONTHLY)))) { notifiedUserIds.add(user.getId()); } } diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java index b7a744a5..c97dc3a3 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderPolicy.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.problem.reminder; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import org.springframework.stereotype.Component; @@ -60,7 +61,7 @@ public NotificationRequestDto buildNotification(ProblemReviewReminder reminder) String body = hasContent ? NOTIFICATION_BODY_DEFAULT : NOTIFICATION_BODY_FALLBACK; Map data = Map.of( - "type", "problem_review_reminder", + "type", NotificationType.PROBLEM_REVIEW_REMINDER, "problemId", String.valueOf(reminder.getProblemId()), "sequence", String.valueOf(reminder.getSequence()), "intervalDays", String.valueOf(reminder.getIntervalDays()) diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java index 4eb761e7..3d0a033a 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/quartz/ChallengeNotificationJob.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.studyroom.quartz; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -37,7 +38,7 @@ public void execute(JobExecutionContext context) { } NotificationRequestDto dto = new NotificationRequestDto(null, title, body, - Map.of("type", "CHALLENGE_NOTIFICATION", "roomId", String.valueOf(roomId))); + Map.of("type", NotificationType.CHALLENGE_NOTIFICATION, "roomId", String.valueOf(roomId))); memberRepository.findAllWithUserByRoomId(roomId).forEach(member -> { try { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java index 75735562..9d3398ce 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java @@ -8,6 +8,7 @@ import com.aisip.OnO.backend.studyroom.quartz.ChallengeNotificationScheduler; import com.aisip.OnO.backend.studyroom.repository.StudyRoomChallengeRepository; import com.aisip.OnO.backend.studyroom.repository.StudyRoomMemberRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -343,7 +344,7 @@ private void notifyChallengeCompleted(StudyRoomChallenge challenge, List { try { fcmService.sendNotificationToAllUserDevice(member.getUser().getId(), dto); diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java index 93d43dcc..dd2fea65 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemCommentService.java @@ -16,6 +16,7 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -90,7 +91,7 @@ public SharedProblemCommentResponse createComment(Long roomId, Long sharedProble fcmService.sendNotificationToAllUserDevice(sharerId, new NotificationRequestDto(null, "공유 문제에 댓글이 달렸어요", user.getName() + ": " + preview, - Map.of("type", "SHARED_PROBLEM_COMMENT", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId)))); + Map.of("type", NotificationType.SHARED_PROBLEM_COMMENT, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId)))); } catch (Exception e) { log.warn("댓글 알림 발송 실패 - userId: {}", sharerId, e); } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java index 3b051404..0a5456aa 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomSharedProblemService.java @@ -15,6 +15,7 @@ import com.aisip.OnO.backend.user.entity.User; import com.aisip.OnO.backend.user.exception.UserErrorCase; import com.aisip.OnO.backend.user.repository.UserRepository; +import com.aisip.OnO.backend.util.fcm.NotificationType; import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; import com.aisip.OnO.backend.util.fcm.service.FcmService; import lombok.RequiredArgsConstructor; @@ -98,7 +99,7 @@ public SharedProblemResponse shareProblem(Long roomId, Long userId, SharedProble notifyRoomMembers(roomId, userId, user.getName() + "님이 문제를 공유했어요", referenceOrFallback(problem.getReference()), - Map.of("type", "SHARED_PROBLEM", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblem.getId()))); + Map.of("type", NotificationType.SHARED_PROBLEM, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblem.getId()))); return toResponse(sharedProblem, List.of(), 0L, userId); } @@ -138,7 +139,7 @@ public SharedProblemReactionToggleResponse toggleReaction(Long roomId, Long shar notifyUser(sharerId, "공유 문제에 반응이 달렸어요", user.getName() + "님이 반응을 추가했어요.", - Map.of("type", "SHARED_PROBLEM_REACTION", "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId))); + Map.of("type", NotificationType.SHARED_PROBLEM_REACTION, "roomId", String.valueOf(roomId), "sharedProblemId", String.valueOf(sharedProblemId))); } return new SharedProblemReactionToggleResponse(sharedProblemId, reactionService.summarizeSharedProblemReactions(reactionRepository.findAllBySharedProblemId(sharedProblemId), userId)); diff --git a/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java b/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java new file mode 100644 index 00000000..9ac82983 --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/util/fcm/NotificationType.java @@ -0,0 +1,46 @@ +package com.aisip.OnO.backend.util.fcm; + +/** + * 푸시 알림 데이터의 {@code type} 값. + * + *

    앱은 {@code data["type"]} 하나만 보고 어떤 화면을 열지 고른다. 값이 빠지거나 오타가 나면 + * 알림을 눌러도 아무 화면이 열리지 않으므로, 발송하는 쪽에서 문자열을 직접 쓰지 말고 여기 상수를 쓴다. + * + *

    표기는 소문자 스네이크로 통일한다. 값은 앱과 맞춰 둔 계약이라 + * 바꾸려면 프론트 분기도 같이 움직여야 한다. + */ +public final class NotificationType { + + /** 오늘 복습할 문제가 있는 사용자에게 보내는 일일 알림. */ + public static final String REVIEW_DUE = "review_due"; + + /** 며칠 접속하지 않은 사용자에게 보내는 재참여 알림. */ + public static final String REENGAGEMENT = "reengagement"; + + /** 30일 넘게 접속하지 않은 사용자에게 월 1회 보내는 재참여 알림. */ + public static final String REENGAGEMENT_MONTHLY = "reengagement_monthly"; + + /** 문제 단위 복습 리마인더. */ + public static final String PROBLEM_REVIEW_REMINDER = "problem_review_reminder"; + + /** 사용자가 복습노트에 직접 걸어 둔 반복 알림. */ + public static final String PRACTICE_NOTE_REMINDER = "practice_note_reminder"; + + /** 챌린지 중간/마감 하루 전 알림. */ + public static final String CHALLENGE_NOTIFICATION = "challenge_notification"; + + /** 챌린지 달성 알림. */ + public static final String CHALLENGE_COMPLETED = "challenge_completed"; + + /** 스터디룸에 문제가 새로 공유됐을 때. */ + public static final String SHARED_PROBLEM = "shared_problem"; + + /** 내가 공유한 문제에 반응이 달렸을 때. */ + public static final String SHARED_PROBLEM_REACTION = "shared_problem_reaction"; + + /** 내가 공유한 문제에 댓글이 달렸을 때. */ + public static final String SHARED_PROBLEM_COMMENT = "shared_problem_comment"; + + private NotificationType() { + } +} diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJobTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJobTest.java new file mode 100644 index 00000000..b21fdf39 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationJobTest.java @@ -0,0 +1,68 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import com.aisip.OnO.backend.util.fcm.service.FcmService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.quartz.JobDataMap; +import org.quartz.JobExecutionContext; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +/** + * 복습노트 반복 알림의 페이로드를 고정한다. + * + *

    앱은 {@code data["type"]} 만 보고 화면을 고른다. 예전에는 이 잡이 {@code practiceId} 만 담아 + * 사용자가 직접 켠 알림을 눌러도 아무 화면이 열리지 않았다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("PracticeNotificationJob") +class PracticeNotificationJobTest { + + private static final Long USER_ID = 7L; + private static final Long PRACTICE_ID = 42L; + + @Mock + private FcmService fcmService; + + @Mock + private JobExecutionContext context; + + private PracticeNotificationJob job; + + @BeforeEach + void setUp() { + job = new PracticeNotificationJob(); + ReflectionTestUtils.setField(job, "fcmService", fcmService); + + JobDataMap dataMap = new JobDataMap(); + dataMap.put("userId", USER_ID); + dataMap.put("practiceId", PRACTICE_ID); + dataMap.put("practiceTitle", "미적분 복습 세트"); + given(context.getMergedJobDataMap()).willReturn(dataMap); + } + + @Test + @DisplayName("알림 데이터에 type 과 practiceId 를 담아 보낸다") + void sendsNotificationTypeWithPracticeId() { + job.executeInternal(context); + + ArgumentCaptor dto = ArgumentCaptor.forClass(NotificationRequestDto.class); + verify(fcmService).sendNotificationToAllUserDevice(eq(USER_ID), dto.capture()); + + assertThat(dto.getValue().data()) + .as("알림 데이터") + .containsEntry("type", "practice_note_reminder") + .containsEntry("practiceId", String.valueOf(PRACTICE_ID)); + assertThat(dto.getValue().body()).as("알림 본문").contains("미적분 복습 세트"); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationTypeApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationTypeApiTest.java new file mode 100644 index 00000000..8d34b417 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomNotificationTypeApiTest.java @@ -0,0 +1,101 @@ +package com.aisip.OnO.backend.studyroom.integration; + +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.ReactionToggleRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.SharedProblemCommentRequest; +import com.aisip.OnO.backend.studyroom.dto.StudyRoomDtos.SharedProblemCreateRequest; +import com.aisip.OnO.backend.studyroom.entity.StudyRoomSharedProblem; +import com.aisip.OnO.backend.studyroom.support.StudyRoomTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.NotificationRequestDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.http.MediaType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 스터디룸 알림이 앱과 약속한 {@code type} 값을 담아 나가는지 고정한다. + * + *

    앱은 {@code data["type"]} 하나로 화면을 고른다. 값이 바뀌면 알림을 눌러도 아무 화면이 + * 열리지 않으므로, 리터럴을 그대로 적어 두고 상수 이름이 아니라 실제 문자열을 검증한다. + */ +@DisplayName("스터디룸 알림 type") +class StudyRoomNotificationTypeApiTest extends StudyRoomTestSupport { + + private RoomFixture fixture; + + @BeforeEach + void setUpRoom() { + fixture = createRoomWithMemberAndOutsider(); + } + + @Test + @DisplayName("문제 공유 알림은 shared_problem 을 담는다") + void shareProblemCarriesType() throws Exception { + Long problemId = saveProblem(fixture.member().getId()).getId(); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems", fixture.roomId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCreateRequest(problemId, "같이 봐요")))) + .andExpect(status().isCreated()); + + Long sharedProblemId = sharedProblemRepository.findAll().get(0).getId(); + assertThat(capturedNotification(fixture.host().getId()).data()) + .as("공유 알림 데이터") + .containsEntry("type", "shared_problem") + .containsEntry("roomId", String.valueOf(fixture.roomId())) + .containsEntry("sharedProblemId", String.valueOf(sharedProblemId)); + } + + @Test + @DisplayName("공유 문제 반응 알림은 shared_problem_reaction 을 담는다") + void reactionCarriesType() throws Exception { + StudyRoomSharedProblem shared = saveSharedProblem(fixture.room(), fixture.host(), + saveProblem(fixture.host().getId()), null); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems/{sharedProblemId}/reactions", + fixture.roomId(), shared.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReactionToggleRequest(EMOJI)))) + .andExpect(status().isOk()); + + assertThat(capturedNotification(fixture.host().getId()).data()) + .as("반응 알림 데이터") + .containsEntry("type", "shared_problem_reaction") + .containsEntry("roomId", String.valueOf(fixture.roomId())) + .containsEntry("sharedProblemId", String.valueOf(shared.getId())); + } + + @Test + @DisplayName("공유 문제 댓글 알림은 shared_problem_comment 를 담는다") + void commentCarriesType() throws Exception { + StudyRoomSharedProblem shared = saveSharedProblem(fixture.room(), fixture.host(), + saveProblem(fixture.host().getId()), null); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(post("/api/study-room/{roomId}/shared-problems/{sharedProblemId}/comments", + fixture.roomId(), shared.getId()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new SharedProblemCommentRequest("좋은 문제네요")))) + .andExpect(status().isCreated()); + + assertThat(capturedNotification(fixture.host().getId()).data()) + .as("댓글 알림 데이터") + .containsEntry("type", "shared_problem_comment") + .containsEntry("roomId", String.valueOf(fixture.roomId())) + .containsEntry("sharedProblemId", String.valueOf(shared.getId())); + } + + private NotificationRequestDto capturedNotification(Long userId) { + ArgumentCaptor captor = ArgumentCaptor.forClass(NotificationRequestDto.class); + verify(fcmService).sendNotificationToAllUserDevice(eq(userId), captor.capture()); + return captor.getValue(); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomQuartzJobTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomQuartzJobTest.java index a02ee493..8f9f2d68 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomQuartzJobTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/quartz/StudyRoomQuartzJobTest.java @@ -73,7 +73,7 @@ void halfwayNotificationGoesToEveryMember() { assertThat(dto.getValue().body()).as("알림 본문").contains("복습 챌린지").contains("절반"); assertThat(dto.getValue().data()) .as("알림 데이터") - .containsEntry("type", "CHALLENGE_NOTIFICATION") + .containsEntry("type", "challenge_notification") .containsEntry("roomId", String.valueOf(fixture.roomId())); } diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeCompletionEventTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeCompletionEventTest.java index f363c42d..fda1f05c 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeCompletionEventTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeCompletionEventTest.java @@ -79,7 +79,7 @@ void everyMemberIsNotifiedOnce() { assertThat(dto.getValue().body()).as("알림 본문").contains("다 같이 1문제"); assertThat(dto.getValue().data()) .as("알림 데이터") - .containsEntry("type", "CHALLENGE_COMPLETED") + .containsEntry("type", "challenge_completed") .containsEntry("roomId", String.valueOf(challenge.getRoom().getId())); } From 56e7d80fede98b65b6c10a16dd2b14451d42eba4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 14:47:37 +0900 Subject: [PATCH 123/130] =?UTF-8?q?[Fix]=20=ED=83=88=ED=87=B4=ED=95=A0=20?= =?UTF-8?q?=EB=95=8C=20=EA=B7=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90=EC=9D=98=20?= =?UTF-8?q?=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=96=89=EC=9D=84=20=EC=A7=80=EC=9A=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deleteUserById 가 FCM 토큰과 폴더, 문제, 복습노트는 지우면서 refresh_token 행은 남겨 둬서, 탈퇴한 뒤에도 남은 리프레시 토큰으로 POST /api/auth/refresh 가 200 으로 새 토큰을 내줬다 앱은 자동 로그인에 성공해 로그인 상태로 들어가는데 모든 API 가 404 라 화면이 비고 로그아웃 분기에도 닿지 못했다 - RefreshTokenRepository.deleteByUserId 는 선언만 있고 부르는 곳이 없었다. 벌크 DELETE 한 문장으로 바꾸고 탈퇴 경로에서 부른다 로그인할 때마다 세션 행이 쌓이는 구조라 기기 수만큼 있는 행을 한 번에 지운다 - 지우는 위치는 무거운 정리를 모두 끝낸 뒤, 사용자 소프트 삭제 직전이다. 갱신 요청이 같은 행을 다투기 때문에 앞에 두면 문제 삭제가 도는 동안 그 행 잠금을 계속 쥐고 있게 된다 - 액세스 토큰 블랙리스트는 건드리지 않았다. 탈퇴 경로에는 액세스 토큰이 들어오지 않고 관리자 삭제 경로에는 아예 없으며, 블랙리스트 키가 토큰 문자열이라 사용자 기준으로 지울 수단도 없다 탈퇴 계정의 액세스 토큰은 #300 범위로 뒀다 - 탈퇴 후 갱신이 1002 로 거절되는 것, 기기별 세션 행이 모두 사라지는 것, 다른 사용자 세션은 그대로 남는 것을 통합 테스트로 고정했다 Closes #298 --- .../repository/RefreshTokenRepository.java | 13 ++++- .../OnO/backend/user/service/UserService.java | 8 ++- .../UserAuthServiceIntegrationTest.java | 55 +++++++++++++++++++ .../backend/user/service/UserServiceTest.java | 19 +++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java b/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java index 8aaf829e..e4120a5b 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/com/aisip/OnO/backend/auth/repository/RefreshTokenRepository.java @@ -2,6 +2,10 @@ import com.aisip.OnO.backend.auth.entity.RefreshToken; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import java.util.Optional; import java.util.UUID; @@ -12,7 +16,14 @@ public interface RefreshTokenRepository extends JpaRepository findByRefreshToken(String refreshToken); - void deleteByUserId(Long userId); + /** + * 탈퇴한 사용자의 세션을 기기 수와 상관없이 한 번에 지운다. idx_refresh_token_user_id 를 탄다. + * 영속성 컨텍스트를 비우지 않는다. 탈퇴 경로가 아직 사용자 엔티티를 수정 중이라 비우면 그 변경이 사라진다. + */ + @Modifying + @Transactional + @Query("DELETE FROM RefreshToken r WHERE r.userId = :userId") + int deleteByUserId(@Param("userId") Long userId); void deleteByRefreshToken(String refreshToken); } diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 2cefef0c..7c8d7086 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.user.service; import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; @@ -81,6 +82,8 @@ public class UserService { private final FcmTokenRepository fcmTokenRepository; + private final RefreshTokenRepository refreshTokenRepository; + private User findUserEntity(Long userId){ return userRepository.findById(userId) .orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND)); @@ -261,6 +264,9 @@ public void deleteUserById(Long userId) { folderService.deleteAllUserFolders(userId); // 탈퇴 계정 앞으로 발송이 생기면 그 기기를 이어 쓰는 사람에게 알림이 뜬다. 토큰 행을 남기지 않는다. fcmTokenRepository.deleteAllByUserId(userId); + // 세션 행이 남으면 다른 기기가 갱신에 성공해 유령 로그인 상태가 된다. + // 갱신 요청과 같은 행을 다투므로 잠금 구간을 짧게 두려고 무거운 정리를 모두 끝낸 뒤에 지운다. + int deletedSessions = refreshTokenRepository.deleteByUserId(userId); user.maskIdentifierForDeletion(makeDeletedIdentifier(userId)); userRepository.flush(); @@ -268,7 +274,7 @@ public void deleteUserById(Long userId) { userRepository.deleteById(userId); userRepository.flush(); - log.info("userId: {} has deleted", userId); + log.info("userId: {} has deleted, refresh session rows removed: {}", userId, deletedSessions); } private String makeDeletedIdentifier(Long userId) { diff --git a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java index 2e93d767..c89c4031 100644 --- a/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/auth/service/UserAuthServiceIntegrationTest.java @@ -3,7 +3,9 @@ import com.aisip.OnO.backend.auth.dto.TokenRequestDto; import com.aisip.OnO.backend.auth.dto.TokenResponseDto; import com.aisip.OnO.backend.auth.entity.Authority; +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.support.IntegrationTestSupport; import com.aisip.OnO.backend.user.dto.UserRegisterDto; import com.aisip.OnO.backend.user.entity.User; @@ -22,6 +24,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; /** * 소셜 로그인 → 토큰 발급 → 저장 → 갱신까지 실제 MySQL 위에서 왕복시킨다. @@ -220,4 +223,56 @@ void deletesSessionOnLogout() { assertThat(refreshTokenRepository.findByRefreshToken(tokens.getRefreshToken())).isEmpty(); } } + + @Nested + @DisplayName("탈퇴 후 세션") + class WithdrawalSession { + + @Test + @DisplayName("탈퇴하면 남은 리프레시 토큰으로 갱신할 수 없다") + void rejectsRefreshAfterWithdrawal() { + TokenResponseDto tokens = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw", "GOOGLE")); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(tokens.getRefreshToken()); + + userService.deleteUserById(userId); + + assertThatThrownBy(() -> userAuthService.refreshAccessToken( + new TokenRequestDto(tokens.getAccessToken(), tokens.getRefreshToken()))) + .as("갱신이 성공하면 앱이 탈퇴한 계정으로 로그인 상태에 들어가 화면이 비어버린다") + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(AuthErrorCase.REFRESH_TOKEN_NOT_FOUND); + } + + @Test + @DisplayName("탈퇴하면 기기별로 쌓인 세션 행이 모두 사라진다") + void removesEveryDeviceSessionOnWithdrawal() { + TokenResponseDto phone = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-devices", "GOOGLE")); + TokenResponseDto tablet = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-devices", "GOOGLE")); + Long userId = jwtTokenizer.getUserIdFromRefreshToken(phone.getRefreshToken()); + assertThat(refreshTokenRepository.count()).isEqualTo(2); + + userService.deleteUserById(userId); + + assertThat(refreshTokenRepository.findByRefreshToken(phone.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.findByRefreshToken(tablet.getRefreshToken())).isEmpty(); + assertThat(refreshTokenRepository.count()) + .as("한 기기만 지우면 다른 기기가 계속 갱신에 성공한다") + .isZero(); + } + + @Test + @DisplayName("탈퇴는 다른 사용자의 세션을 건드리지 않는다") + void keepsOtherUserSession() { + TokenResponseDto leaver = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-me", "GOOGLE")); + TokenResponseDto stayer = userAuthService.signUpMemberUser(socialLogin("google-sub-withdraw-other", "GOOGLE")); + + userService.deleteUserById(jwtTokenizer.getUserIdFromRefreshToken(leaver.getRefreshToken())); + + assertThat(refreshTokenRepository.findByRefreshToken(stayer.getRefreshToken())).isPresent(); + assertThatCode(() -> userAuthService.refreshAccessToken( + new TokenRequestDto(stayer.getAccessToken(), stayer.getRefreshToken()))) + .doesNotThrowAnyException(); + } + } } diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 04c9eb81..905ad3ad 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -1,5 +1,6 @@ package com.aisip.OnO.backend.user.service; +import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.folder.service.FolderService; @@ -90,6 +91,8 @@ class UserServiceTest { private UserRegistrationWriter registrationWriter; @Mock private FcmTokenRepository fcmTokenRepository; + @Mock + private RefreshTokenRepository refreshTokenRepository; @InjectMocks private UserService userService; @@ -489,9 +492,24 @@ void deletesUserWithAllRelatedData() { verify(problemService).deleteAllUserProblems(USER_ID); verify(folderService).deleteAllUserFolders(USER_ID); verify(fcmTokenRepository).deleteAllByUserId(USER_ID); + verify(refreshTokenRepository).deleteByUserId(USER_ID); verify(userRepository).deleteById(USER_ID); } + @Test + @DisplayName("세션 행은 무거운 정리를 모두 끝낸 뒤 지운다") + void deletesRefreshTokenAfterHeavyCleanup() { + givenExistingUser(User.from(registerDto)); + + userService.deleteUserById(USER_ID); + + InOrder inOrder = inOrder(problemService, studyRoomService, refreshTokenRepository, userRepository); + inOrder.verify(studyRoomService).leaveAllRoomsForWithdrawal(USER_ID); + inOrder.verify(problemService).deleteAllUserProblems(USER_ID); + inOrder.verify(refreshTokenRepository).deleteByUserId(USER_ID); + inOrder.verify(userRepository).deleteById(USER_ID); + } + @Test @DisplayName("스터디룸 정리는 사용자를 소프트 삭제하기 전에 한다") void leavesStudyRoomsBeforeSoftDelete() { @@ -513,6 +531,7 @@ void rejectsUnknownUser() { .isInstanceOf(ApplicationException.class); verify(problemService, never()).deleteAllUserProblems(anyLong()); verify(studyRoomService, never()).leaveAllRoomsForWithdrawal(anyLong()); + verify(refreshTokenRepository, never()).deleteByUserId(anyLong()); verify(userRepository, never()).deleteById(anyLong()); } From 08e7ac63ecedb069cb299795039e9ebba660ffb4 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 14:09:45 +0900 Subject: [PATCH 124/130] =?UTF-8?q?[Fix]=20=ED=83=88=ED=87=B4=ED=95=9C=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=EC=9D=98=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=8D=94=20=EC=9D=B4=EC=83=81=20?= =?UTF-8?q?=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8A=94=EB=8B=A4=20-=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=ED=95=B4=EB=8F=84=20=EC=9D=B4=EB=AF=B8=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=EB=90=9C=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=80=20=EB=A7=8C=EB=A3=8C=20=EC=A0=84(?= =?UTF-8?q?=EC=B5=9C=EB=8C=80=2030=EB=B6=84)=EA=B9=8C=EC=A7=80=20=EC=82=B4?= =?UTF-8?q?=EC=95=84=20=EC=9E=88=EC=96=B4=EC=84=9C,=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=A1=B4=EC=9E=AC=EB=A5=BC=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EA=B0=80=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=99=EC=9E=91=ED=96=88=EB=8B=A4.=20POST=20/api?= =?UTF-8?q?/fcm/token=20=EC=9D=B4=20200=20=EC=9C=BC=EB=A1=9C=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=ED=95=B4=20#271=20=EB=A1=9C=20=EC=A7=80=EC=9A=B4=20fc?= =?UTF-8?q?m=5Ftoken=20=ED=96=89=EC=9D=B4=20=EB=90=98=EC=82=B4=EC=95=84?= =?UTF-8?q?=EB=82=AC=EA=B3=A0,=20=EA=B7=B8=20=EA=B8=B0=EA=B8=B0=EB=A5=BC?= =?UTF-8?q?=20=EC=9D=B4=EC=96=B4=20=EC=93=B0=EB=8A=94=20=EC=82=AC=EB=9E=8C?= =?UTF-8?q?=EC=97=90=EA=B2=8C=20=ED=83=88=ED=87=B4=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=95=9E=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=8A=94=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=EC=9D=B4=20=EB=9C=B0=20=EC=88=98=20=EC=9E=88=EC=97=88?= =?UTF-8?q?=EB=8B=A4.=20=ED=83=9C=EA=B7=B8=20=EC=83=9D=EC=84=B1,=20?= =?UTF-8?q?=EB=B3=B5=EC=8A=B5=EB=85=B8=ED=8A=B8=20=EB=93=B1=EB=A1=9D,=20?= =?UTF-8?q?=ED=9B=88=EC=9E=A5=20=EC=A1=B0=ED=9A=8C,=20=EA=B8=B0=EB=B6=84?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=EB=8F=84=20=EA=B0=99=EC=9D=80=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=98=80=EB=8B=A4=20-=20JwtTokenFilter=20=EA=B0=80=20?= =?UTF-8?q?=ED=81=B4=EB=A0=88=EC=9E=84=EC=9D=84=20=EC=9D=BD=EC=9D=80=20?= =?UTF-8?q?=EC=A7=81=ED=9B=84=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8(UBL:{userId})=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EA=B2=8C=20=ED=96=88=EB=8B=A4.=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=B8=94=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=EB=8A=94=20?= =?UTF-8?q?=ED=82=A4=EA=B0=80=20=ED=86=A0=ED=81=B0=20=EB=AC=B8=EC=9E=90?= =?UTF-8?q?=EC=97=B4=EC=9D=B4=EB=9D=BC=20=ED=83=88=ED=87=B4=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=97=90=20=EC=8B=A4=EB=A6=B0=20=EA=B7=B8=20=ED=95=9C?= =?UTF-8?q?=20=EC=9E=A5=EB=B0=96=EC=97=90=20=EB=AA=BB=20=EB=A7=89=EA=B3=A0?= =?UTF-8?q?,=20=EB=8B=A4=EB=A5=B8=20=EA=B8=B0=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EB=93=A4=EA=B3=A0=20=EC=9E=88=EB=8A=94=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EB=82=A8=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20-=20=EB=A7=A4=20=EC=9A=94=EC=B2=AD=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20DB=20=EB=A1=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=B0=A9=EC=8B=9D=20=EB=8C=80=EC=8B=A0=20=EC=9D=B4?= =?UTF-8?q?=EC=AA=BD=EC=9D=84=20=EA=B3=A8=EB=9E=90=EB=8B=A4.=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=EC=9D=B4=EB=AF=B8=20=EB=B8=94=EB=9E=99?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=EB=A1=9C=20Red?= =?UTF-8?q?is=20=EB=A5=BC=20=ED=83=80=EA=B3=A0=20=EC=9E=88=EC=96=B4?= =?UTF-8?q?=EC=84=9C=20DB=20=EC=A1=B0=ED=9A=8C=EB=8A=94=20=EB=8A=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0,=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EA=B0=80=20UserService.deleteUserById=20?= =?UTF-8?q?=ED=95=98=EB=82=98=EB=BF=90=EC=9D=B4=EB=9D=BC=20=EB=A7=89?= =?UTF-8?q?=EB=8A=94=20=EB=B2=94=EC=9C=84=EB=8F=84=20=EA=B0=99=EB=8B=A4=20?= =?UTF-8?q?-=20=ED=91=9C=EC=8B=9D=EC=9D=80=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=9D=B4=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=EB=90=9C=20=EB=92=A4=EC=97=90=20=EC=8B=AC=EB=8A=94?= =?UTF-8?q?=EB=8B=A4.=20=EB=A1=A4=EB=B0=B1=EB=90=90=EB=8A=94=EB=8D=B0=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=84=20=EB=A8=BC=EC=A0=80=20=EB=A7=89?= =?UTF-8?q?=EC=9C=BC=EB=A9=B4=20=EA=B3=84=EC=A0=95=EC=9D=80=20=EB=A9=80?= =?UTF-8?q?=EC=A9=A1=ED=95=9C=EB=8D=B0=20=EC=9E=AC=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=EB=8F=84=20=EB=AA=BB=20=ED=91=B8?= =?UTF-8?q?=EB=8A=94=20=EC=82=AC=EC=9A=A9=EC=9E=90=EA=B0=80=20=EC=83=9D?= =?UTF-8?q?=EA=B8=B4=EB=8B=A4=20-=20=ED=91=9C=EC=8B=9D=20=EC=88=98?= =?UTF-8?q?=EB=AA=85=EC=9D=80=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=2030=EB=B6=84=EC=9D=B4=20=EC=95=84=EB=8B=88=EB=9D=BC?= =?UTF-8?q?=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=20=EA=B8=B0=EA=B0=84=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B7=84=EB=8B=A4.=20=ED=83=88=ED=87=B4=ED=95=B4=EB=8F=84=20re?= =?UTF-8?q?fresh=5Ftoken=20=ED=96=89=EC=9D=B4=20=EB=82=A8=EC=95=84=20POST?= =?UTF-8?q?=20/api/auth/refresh=20=EB=A1=9C=20=EC=95=A1=EC=84=B8=EC=8A=A4?= =?UTF-8?q?=20=ED=86=A0=ED=81=B0=EC=9D=84=20=EA=B3=84=EC=86=8D=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EC=9D=84=20=EC=88=98=20=EC=9E=88=EC=96=B4?= =?UTF-8?q?=EC=84=9C,=2030=EB=B6=84=EC=A7=9C=EB=A6=AC=EB=A1=9C=20=EB=91=90?= =?UTF-8?q?=EB=A9=B4=20=EA=B7=B8=20=EB=92=A4=EC=97=90=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EB=9A=AB=EB=A6=B0=EB=8B=A4=20-=20=EC=8B=A4=EC=A0=9C=20Autho?= =?UTF-8?q?rization=20=ED=97=A4=EB=8D=94=EB=A1=9C=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=83=9C=EC=9A=B0=EB=8A=94=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=204=EA=B0=9C,=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=202?= =?UTF-8?q?=EA=B0=9C,=20=ED=83=88=ED=87=B4=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=202=EA=B0=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=96=88=EB=8B=A4.=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=BB=A8=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=84=B8=EC=9A=B0=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=8A=94=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=A5=BC=20=EC=A7=80=EB=82=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EC=95=84=20=ED=9A=8C=EA=B7=80=EB=A5=BC=20=EB=AA=BB=20=EC=9E=A1?= =?UTF-8?q?=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #300 --- .../backend/auth/service/JwtTokenService.java | 20 ++++ .../backend/common/auth/JwtTokenFilter.java | 15 ++- .../OnO/backend/user/service/UserService.java | 22 ++++ .../backend/util/redis/RedisTokenService.java | 27 +++++ .../DeletedUserAccessTokenApiTest.java | 113 ++++++++++++++++++ .../common/auth/JwtTokenFilterTest.java | 35 ++++++ .../backend/user/service/UserServiceTest.java | 27 +++++ 7 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java diff --git a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java index 2a4a66e4..0eb31b79 100644 --- a/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/auth/service/JwtTokenService.java @@ -10,6 +10,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.Map; @@ -87,4 +88,23 @@ public void logout(String accessToken, Long userId, String refreshToken) { log.info("userId: {} has logged out", userId); } + + /** + * ✅ 탈퇴한 사용자에게 이미 나간 액세스 토큰을 전부 막는다 + * + *

    로그아웃 블랙리스트는 토큰 문자열이 키라 탈퇴 요청에 실린 그 한 장밖에 막지 못한다. + * 다른 기기가 들고 있는 토큰은 남고, 그 토큰으로 사용자 존재를 확인하지 않는 엔드포인트 + * (예: {@code POST /api/fcm/token})가 최대 30분간 그대로 동작한다. 그래서 사용자 단위로 막는다. + * + *

    표식 수명은 액세스 토큰(30분)이 아니라 리프레시 토큰 유효 기간에 맞춘다. + * 탈퇴해도 {@code refresh_token} 행이 남아 있어 {@code POST /api/auth/refresh} 로 액세스 토큰을 + * 계속 새로 받을 수 있기 때문이다(별도 이슈). 30분짜리 표식은 그 시간이 지나면 다시 뚫린다. + * 표식은 탈퇴 한 건당 키 하나라 수명을 늘려도 비용이 거의 없다. + * + *

    DB 를 쓰지 않으므로 호출자의 트랜잭션에 얹히지 않는다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void blacklistUserAccessTokens(Long userId) { + redisTokenService.blacklistUser(userId, jwtTokenizer.getRefreshTokenExpirationSeconds()); + } } diff --git a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java index aa30a625..4489d0a7 100644 --- a/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java +++ b/src/main/java/com/aisip/OnO/backend/common/auth/JwtTokenFilter.java @@ -76,11 +76,24 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse return; } - // 3. 인증 정보 설정 + // 3. 클레임 해석 Claims claims = jwtTokenizer.getClaimsFromAccessToken(accessToken); Long userId = Long.valueOf(claims.getSubject()); Authority authority = Authority.valueOf(claims.get("authority", String.class)); + // 4. 탈퇴한 계정인지 확인 + // 액세스 토큰은 서명과 만료만으로 통과하므로, 탈퇴한 뒤에도 만료 전(최대 30분)까지는 + // 사용자 존재를 확인하지 않는 엔드포인트가 그대로 동작한다. 실제로 탈퇴 계정의 토큰으로 + // POST /api/fcm/token 이 200 이 나면서 지웠던 fcm_token 행이 되살아났다(#300). + // 매 요청마다 DB 로 사용자 존재를 확인하는 대신, 탈퇴 시점에 사용자 단위 블랙리스트를 + // 심어 두고 여기서 확인한다. 아래 블랙리스트 조회와 같은 Redis 경로라 DB 부하가 늘지 않는다. + if (redisTokenService.isUserBlacklisted(userId)) { + request.setAttribute(AUTH_ERROR_CASE_ATTRIBUTE, AuthErrorCase.INVALID_ACCESS_TOKEN); + filterChain.doFilter(request, response); + return; + } + + // 5. 인증 정보 설정 List authorities = List.of(new SimpleGrantedAuthority(authority.name())); UsernamePasswordAuthenticationToken authentication = diff --git a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java index 7c8d7086..aac4900b 100644 --- a/src/main/java/com/aisip/OnO/backend/user/service/UserService.java +++ b/src/main/java/com/aisip/OnO/backend/user/service/UserService.java @@ -2,6 +2,7 @@ import com.aisip.OnO.backend.admin.dto.AdminUserResponseDto; import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.auth.service.JwtTokenService; import com.aisip.OnO.backend.folder.service.FolderService; import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; import com.aisip.OnO.backend.problem.reminder.ProblemReviewReminderService; @@ -83,6 +84,7 @@ public class UserService { private final FcmTokenRepository fcmTokenRepository; private final RefreshTokenRepository refreshTokenRepository; + private final JwtTokenService jwtTokenService; private User findUserEntity(Long userId){ return userRepository.findById(userId) @@ -275,6 +277,26 @@ public void deleteUserById(Long userId) { userRepository.flush(); log.info("userId: {} has deleted, refresh session rows removed: {}", userId, deletedSessions); + // 탈퇴해도 이미 나간 액세스 토큰은 만료 전(최대 30분)까지 살아 있다. 그 사이에 FCM 토큰 등록처럼 + // 사용자 존재를 확인하지 않는 요청이 들어오면 방금 지운 fcm_token 행이 되살아난다. (#300) + blacklistAccessTokensAfterCommit(userId); + } + + /** + * 커밋 이후에 막는다. 삭제가 롤백됐는데 토큰을 먼저 막아 버리면 + * 계정은 멀쩡한데 재로그인으로도 못 푸는 사용자가 생긴다. + */ + private void blacklistAccessTokensAfterCommit(Long userId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + jwtTokenService.blacklistUserAccessTokens(userId); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + jwtTokenService.blacklistUserAccessTokens(userId); + } + }); } private String makeDeletedIdentifier(Long userId) { diff --git a/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java b/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java index 60e01826..893281f5 100644 --- a/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java +++ b/src/main/java/com/aisip/OnO/backend/util/redis/RedisTokenService.java @@ -20,6 +20,7 @@ public class RedisTokenService { private static final String REFRESH_TOKEN_PREFIX = "RT:"; // RefreshToken prefix private static final String BLACKLIST_PREFIX = "BL:"; // BlackList prefix + private static final String USER_BLACKLIST_PREFIX = "UBL:"; // 사용자 단위 BlackList prefix (탈퇴) /** * RefreshToken을 Redis에 저장 @@ -75,4 +76,30 @@ public boolean isBlacklisted(String accessToken) { String result = redisSingleDataService.getSingleData(key); return !result.isEmpty(); } + + /** + * 사용자 단위로 이미 발급된 액세스 토큰을 전부 막는다 (탈퇴 처리). + * + *

    로그아웃 블랙리스트는 토큰 문자열이 키라서, 탈퇴 시점에 서버가 손에 쥔 그 한 장만 막을 수 있다. + * 같은 계정이 다른 기기에서 받아 간 토큰은 그대로 남는다. 그래서 사용자 단위 키를 따로 둔다. + * + * @param userId 사용자 ID + * @param expiration 표식을 유지할 기간 (초). 그 계정 앞으로 유효한 자격증명이 남아 있을 수 있는 기간을 넣는다. + */ + public void blacklistUser(Long userId, long expiration) { + String key = USER_BLACKLIST_PREFIX + userId; + redisSingleDataService.setSingleData(key, "withdrawn", Duration.ofSeconds(expiration)); + log.info("Added userId: {} to user blacklist for {}s", userId, expiration); + } + + /** + * 사용자 단위 블랙리스트에 올라 있는지 확인 + * @param userId 사용자 ID + * @return 블랙리스트에 있으면 true + */ + public boolean isUserBlacklisted(Long userId) { + String key = USER_BLACKLIST_PREFIX + userId; + String result = redisSingleDataService.getSingleData(key); + return !result.isEmpty(); + } } diff --git a/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java b/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java new file mode 100644 index 00000000..7ff85f29 --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/auth/controller/DeletedUserAccessTokenApiTest.java @@ -0,0 +1,113 @@ +package com.aisip.OnO.backend.auth.controller; + +import com.aisip.OnO.backend.auth.exception.AuthErrorCase; +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import com.aisip.OnO.backend.util.fcm.dto.FcmTokenRequestDto; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.ResultActions; + +import java.nio.charset.StandardCharsets; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 탈퇴한 계정에게 이미 나간 액세스 토큰을 더 이상 받아주지 않는지 확인한다. + * + *

    액세스 토큰은 서명과 만료만으로 통과하므로, 탈퇴해도 만료 전(최대 30분)까지는 살아 있다. + * 사용자 존재를 확인하는 경로는 404 로 떨어지지만, 확인하지 않는 {@code POST /api/fcm/token} 은 + * 200 으로 성공해 탈퇴 때 지운 {@code fcm_token} 행이 되살아났다. 그러면 그 기기를 이어 쓰는 사람에게 + * 탈퇴한 계정 앞으로 가는 알림이 뜬다. (#300, #271 이 막으려던 상황과 같은 결과) + * + *

    여기서는 실제 {@code Authorization} 헤더로 요청을 보내 필터를 그대로 태운다. + * 인증 컨텍스트를 직접 세우는 테스트는 이 경로를 지나지 않아 회귀를 잡지 못한다. + */ +@DisplayName("탈퇴한 계정의 액세스 토큰") +class DeletedUserAccessTokenApiTest extends IntegrationTestSupport { + + private static final String FCM_TOKEN_API = "/api/fcm/token"; + + private JsonNode dataOf(MvcResult result) throws Exception { + return objectMapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); + } + + /** 게스트로 가입하고 "Bearer " 를 포함한 액세스 토큰을 돌려준다. */ + private String signUpGuestAndGetAccessToken() throws Exception { + MvcResult result = mockMvc.perform(post("/api/auth/signup/guest")) + .andExpect(status().isOk()) + .andReturn(); + return dataOf(result).path("accessToken").asText(); + } + + private ResultActions registerFcmToken(String authorizationHeader, String fcmToken) throws Exception { + return mockMvc.perform(post(FCM_TOKEN_API) + .header("Authorization", authorizationHeader) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new FcmTokenRequestDto(fcmToken)))); + } + + private void withdraw(String authorizationHeader) throws Exception { + mockMvc.perform(delete("/api/users").header("Authorization", authorizationHeader)) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("탈퇴 후 남은 토큰으로 FCM 토큰을 등록하면 401 + 1009 로 거절한다") + void rejectsFcmTokenRegistrationAfterWithdrawal() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + withdraw(accessToken); + + registerFcmToken(accessToken, "fcm-token-after-withdrawal") + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + + verify(fcmService, never()).registerToken(any(), any()); + } + + @Test + @DisplayName("탈퇴 후 남은 토큰은 사용자 조회도 401 로 막는다") + void rejectsUserLookupAfterWithdrawal() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + withdraw(accessToken); + + mockMvc.perform(get("/api/users").header("Authorization", accessToken)) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.errorCode").value(AuthErrorCase.INVALID_ACCESS_TOKEN.getErrorCode())); + } + + @Test + @DisplayName("탈퇴하지 않은 사용자는 FCM 토큰 등록이 그대로 된다") + void allowsFcmTokenRegistrationForLivingUser() throws Exception { + String accessToken = signUpGuestAndGetAccessToken(); + + registerFcmToken(accessToken, "fcm-token-living-user") + .andExpect(status().isOk()); + + verify(fcmService).registerToken(eq(new FcmTokenRequestDto("fcm-token-living-user")), any()); + } + + @Test + @DisplayName("남이 탈퇴해도 내 토큰은 영향을 받지 않는다") + void withdrawalOfAnotherUserDoesNotAffectMe() throws Exception { + String myAccessToken = signUpGuestAndGetAccessToken(); + String otherAccessToken = signUpGuestAndGetAccessToken(); + + withdraw(otherAccessToken); + + registerFcmToken(myAccessToken, "fcm-token-of-mine") + .andExpect(status().isOk()); + mockMvc.perform(get("/api/users").header("Authorization", myAccessToken)) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java b/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java index 4626fe0e..b87a74de 100644 --- a/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/auth/JwtTokenFilterTest.java @@ -275,6 +275,41 @@ void continuesChainForBlacklistedToken() throws Exception { assertThat(chain.getRequest()).isNotNull(); } + /** + * 탈퇴한 계정의 토큰은 서명도 만료도 멀쩡하다. 토큰 문자열 블랙리스트는 탈퇴 요청에 실린 + * 그 한 장밖에 못 막으므로, 사용자 단위로 한 번 더 본다. 이게 없으면 사용자 존재를 확인하지 + * 않는 엔드포인트(POST /api/fcm/token)가 최대 30분간 200 으로 동작한다. (#300) + */ + @Test + @DisplayName("탈퇴한 사용자의 토큰은 INVALID_ACCESS_TOKEN 이다") + void marksWithdrawnUserToken() throws Exception { + givenValidToken(9L, Authority.ROLE_MEMBER); + given(redisTokenService.isUserBlacklisted(9L)).willReturn(true); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + MockFilterChain chain = doFilter(request); + + assertThat(errorAttribute(request)).isEqualTo(AuthErrorCase.INVALID_ACCESS_TOKEN); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .as("탈퇴한 계정으로 인증되면 지운 데이터가 되살아난다") + .isNull(); + assertThat(chain.getRequest()).as("체인은 항상 이어져야 한다").isNotNull(); + } + + @Test + @DisplayName("탈퇴하지 않은 사용자는 그대로 인증된다") + void keepsAuthenticationForLivingUser() throws Exception { + givenValidToken(9L, Authority.ROLE_MEMBER); + given(redisTokenService.isUserBlacklisted(9L)).willReturn(false); + MockHttpServletRequest request = requestWith("Bearer " + VALID_TOKEN); + + doFilter(request); + + assertThat(errorAttribute(request)).isNull(); + assertThat(SecurityContextHolder.getContext().getAuthentication().getPrincipal()) + .isEqualTo(9L); + } + @Test @DisplayName("만료된 토큰은 ACCESS_TOKEN_EXPIRED 다 - 프론트의 갱신 트리거") void marksExpiredToken() throws Exception { diff --git a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java index 905ad3ad..6d0ee664 100644 --- a/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/user/service/UserServiceTest.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.user.service; import com.aisip.OnO.backend.auth.repository.RefreshTokenRepository; +import com.aisip.OnO.backend.auth.service.JwtTokenService; import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.config.rabbitmq.producer.S3DeleteProducer; import com.aisip.OnO.backend.folder.service.FolderService; @@ -93,6 +94,8 @@ class UserServiceTest { private FcmTokenRepository fcmTokenRepository; @Mock private RefreshTokenRepository refreshTokenRepository; + @Mock + private JwtTokenService jwtTokenService; @InjectMocks private UserService userService; @@ -546,6 +549,30 @@ void enqueuesProfileImageDeletion() { verify(s3DeleteProducer).sendDeleteMessage("https://cdn.test.ono/profile.png", USER_ID); } + + /** + * 이걸 빼면 탈퇴한 계정의 액세스 토큰이 만료 전까지 살아 있어서, 사용자 존재를 확인하지 않는 + * 엔드포인트(POST /api/fcm/token)가 방금 지운 fcm_token 행을 다시 넣는다. (#300) + */ + @Test + @DisplayName("탈퇴하면 그 사용자 앞으로 나간 액세스 토큰을 막는다") + void blacklistsIssuedAccessTokens() { + givenExistingUser(User.from(registerDto)); + + userService.deleteUserById(USER_ID); + + verify(jwtTokenService).blacklistUserAccessTokens(USER_ID); + } + + @Test + @DisplayName("없는 사용자면 토큰을 막지 않는다") + void doesNotBlacklistWhenUserIsMissing() { + given(userRepository.findById(404L)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> userService.deleteUserById(404L)) + .isInstanceOf(ApplicationException.class); + verify(jwtTokenService, never()).blacklistUserAccessTokens(anyLong()); + } } @Nested From 32d27bec9fb807bbed2b4c03a3e2e07c7245f25b Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 15:15:06 +0900 Subject: [PATCH 125/130] =?UTF-8?q?[Fix]=20=EC=9A=94=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EC=A7=80=20=EC=95=8A=EC=9D=80=20=EC=A3=BC?= =?UTF-8?q?=EA=B0=84=20=EB=B3=B5=EC=8A=B5=20=EC=95=8C=EB=A6=BC=EC=9D=84=20?= =?UTF-8?q?400=20=EC=9C=BC=EB=A1=9C=20=EA=B1=B0=EC=A0=88=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PracticeNotificationScheduler.convertDtoToCron 이 repeatType 이 weekly 인데 weekDays 가 비어 있으면 매일 크론("0 m H ? * *")으로 되돌렸다. 사용자는 특정 요일만 고른 줄 아는데 매일 알림을 받았고, 요청이 잘못됐다는 신호도 없었다 - PracticeNoteErrorCase 에 PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED(400, 6003) 를 추가했다. 6001/6002 다음 번호라 기존 코드와 겹치지 않는다 - 검증을 요청 진입부인 PracticeNoteService.registerPractice 와 updatePracticeInfo 에 뒀다. 스케줄러에만 두면 복습노트 저장과 Quartz 잡 삭제가 먼저 일어난다 - 스케줄러의 updateNotification 은 잡을 지운 뒤 다시 등록하는데, Quartz 삭제는 서비스 트랜잭션과 같이 롤백되지 않는다. 그래서 지우기 전에 검증한다 - daily 와 그 밖의 repeatType(null 포함)은 지금처럼 매일로 둔다. 구버전 앱 요청까지 막으면 쓰고 있던 알림이 통째로 끊긴다 - 스케줄러 단위 테스트 3개와 복습노트 통합 테스트 3개를 추가·수정했다 Closes #302 --- .../dto/PracticeNotificationRegisterDto.java | 12 ++++ .../exception/PracticeNoteErrorCase.java | 4 +- .../service/PracticeNoteService.java | 17 ++++++ .../PracticeNotificationScheduler.java | 26 +++++++- .../PracticeNoteIntegrationTest.java | 60 +++++++++++++++++++ .../PracticeNotificationSchedulerTest.java | 53 ++++++++++++++-- 6 files changed, 164 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java index 8bf7cb52..b2390956 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java @@ -9,4 +9,16 @@ public record PracticeNotificationRegisterDto( String repeatType, List weekDays ) { + + private static final String WEEKLY = "weekly"; + + /** + * 주간 반복인데 요일을 하나도 고르지 않은 상태인지 확인한다. + * + *

    예전에는 이 상태가 크론 변환에서 매일 발송으로 되돌아갔다. 사용자는 특정 요일만 + * 고른 줄 알면서 매일 알림을 받았고, 요청이 잘못됐다는 신호도 없었다. + */ + public boolean isWeeklyWithoutWeekDays() { + return WEEKLY.equalsIgnoreCase(repeatType) && (weekDays == null || weekDays.isEmpty()); + } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java index 9fe0d3ce..e60e2ed3 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/exception/PracticeNoteErrorCase.java @@ -10,7 +10,9 @@ public enum PracticeNoteErrorCase implements ErrorCase { PRACTICE_NOTE_NOT_FOUND(404, 6001, "복습 노트를 찾을 수 없습니다."), - PRACTICE_NOTE_USER_UNMATCHED(403, 6002, "복습 노트를 소유한 유저가 아닙니다."); + PRACTICE_NOTE_USER_UNMATCHED(403, 6002, "복습 노트를 소유한 유저가 아닙니다."), + + PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED(400, 6003, "주간 반복 알림은 요일을 최소 하나 선택해야 합니다."); private final Integer httpStatusCode; private final Integer errorCode; diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index ba300d1a..a01b2cc8 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -71,6 +71,8 @@ private PracticeNote getPracticeEntity(Long practiceId, Long userId){ public Long registerPractice(PracticeNoteRegisterDto practiceNoteRegisterDto, Long userId) { + validatePracticeNotification(practiceNoteRegisterDto.practiceNotification()); + PracticeNote practiceNote = PracticeNote.from(practiceNoteRegisterDto, userId); practiceNoteRepository.save(practiceNote); @@ -160,6 +162,8 @@ public void updatePracticeInfo(Long userId, PracticeNoteUpdateDto practiceNoteUp Long practiceId = practiceNoteUpdateDto.practiceNoteId(); PracticeNote practiceNote = getPracticeEntity(practiceId, userId); + validatePracticeNotification(practiceNoteUpdateDto.practiceNotification()); + practiceNote.updateTitle(practiceNoteUpdateDto.practiceTitle()); practiceNote.updateNotification(PracticeNotification.from(practiceNoteUpdateDto.practiceNotification())); @@ -209,6 +213,19 @@ private void deletePracticeWithoutOwnerCheck(Long practiceId) { log.info("practiceId: {} has deleted", practiceId); } + /** + * 주간 반복 알림에 요일이 하나도 없으면 400 으로 거절한다. + * + *

    스케줄러도 같은 검증을 하지만, 요청을 받은 자리에서 먼저 막아야 복습노트 저장이나 + * 기존 Quartz 잡 삭제가 아예 일어나지 않는다. Quartz 잡 삭제는 이 트랜잭션과 함께 + * 롤백되지 않기 때문이다. + */ + private void validatePracticeNotification(PracticeNotificationRegisterDto practiceNotification) { + if (practiceNotification != null && practiceNotification.isWeeklyWithoutWeekDays()) { + throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + } + } + private List nullSafe(List values) { return values == null ? List.of() : values; } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java index 54ec83dc..82822261 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java @@ -1,7 +1,9 @@ package com.aisip.OnO.backend.practicenote.service; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; +import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.quartz.*; @@ -14,6 +16,8 @@ public class PracticeNotificationScheduler { private final Scheduler scheduler; public void schedulePracticeNotification(Long userId, Long practiceId, String practiceTitle, PracticeNotificationRegisterDto dto) { + validateNotification(dto); + try { JobDetail jobDetail = JobBuilder.newJob(PracticeNotificationJob.class) .withIdentity("practice-" + practiceId, "practice-reminder") @@ -40,6 +44,10 @@ public void schedulePracticeNotification(Long userId, Long practiceId, String pr } public void updateNotification(Long userId, Long practiceId, String title, PracticeNotificationRegisterDto dto) { + // 잡을 지운 뒤에 검증에 걸리면 기존 알림만 사라진다. Quartz 잡 삭제는 서비스 트랜잭션과 + // 함께 롤백되지 않으므로, 지우기 전에 먼저 막는다. + validateNotification(dto); + deleteNotification(practiceId); schedulePracticeNotification(userId, practiceId, title, dto); } @@ -53,6 +61,18 @@ public void deleteNotification(Long practiceId) { } } + /** + * 주간 반복인데 요일이 비어 있으면 거절한다. + * + *

    예전에는 이 요청이 아래 크론 변환의 매일 폴백으로 흘러가, 사용자가 고르지도 않은 + * 매일 알림이 등록됐다. 잘못된 요청이라는 신호 없이 동작만 달라지는 쪽이 더 나쁘다. + */ + private void validateNotification(PracticeNotificationRegisterDto dto) { + if (dto.isWeeklyWithoutWeekDays()) { + throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + } + } + private String convertDtoToCron(PracticeNotificationRegisterDto dto) { int hour = dto.hour(); int minute = dto.minute(); @@ -60,8 +80,9 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { if ("daily".equalsIgnoreCase(dto.repeatType())) { // 매일 지정된 시각에 실행 return String.format("0 %d %d ? * *", minute, hour); - } else if ("weekly".equalsIgnoreCase(dto.repeatType()) && dto.weekDays() != null && !dto.weekDays().isEmpty()) { + } else if ("weekly".equalsIgnoreCase(dto.repeatType())) { // 선택한 요일에만 지정된 시각에 실행 (e.g. MON,WED,FRI) + // 요일이 비어 있는 경우는 validateNotification 이 이미 걸러 냈다. String dayString = dto.weekDays().stream() .map(this::convertDayToQuartz) .reduce((a, b) -> a + "," + b) @@ -70,7 +91,8 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { return String.format("0 %d %d ? * %s", minute, hour, dayString); } - // fallback (매일) + // daily/weekly 가 아닌 값(null 포함)은 지금처럼 매일로 둔다. + // 구버전 앱이 repeatType 을 비워 보내는 경우까지 여기서 막으면 기존 알림이 통째로 끊긴다. return String.format("0 %d %d ? * *", minute, hour); } diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java index bbced262..aff4d785 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java @@ -22,6 +22,8 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; @@ -177,6 +179,42 @@ void registersPracticeNote() throws Exception { userId, practiceNoteId.longValue(), "새 복습", dailyNotification()); } + @Test + @DisplayName("주간 반복은 고른 요일 그대로 스케줄을 건다") + void registersWeeklyNotification() throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "주간 복습", List.of(), weeklyNotification(List.of(1, 3, 5)))); + + String response = mockMvc.perform(post("/api/practiceNotes") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + Number practiceNoteId = JsonPath.read(response, "$.data"); + verify(practiceNotificationScheduler).schedulePracticeNotification( + userId, practiceNoteId.longValue(), "주간 복습", weeklyNotification(List.of(1, 3, 5))); + } + + @Test + @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 아무것도 저장하지 않는다") + void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(post("/api/practiceNotes") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + // 예전에는 매일 발송으로 되돌아가 복습노트까지 그대로 만들어졌다. + assertThat(practiceNoteRepository.findAllByUserId(userId)).isEmpty(); + verify(practiceNotificationScheduler, never()) + .schedulePracticeNotification(any(), any(), any(), any()); + } + @Test @DisplayName("다른 사용자의 문제로는 만들 수 없다") void rejectsOtherUserProblem() throws Exception { @@ -324,6 +362,28 @@ void updatesWithoutProblemLists() throws Exception { .isEqualTo("제목만 변경"); } + @Test + @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 기존 알림을 건드리지 않는다") + void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { + PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); + String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( + practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(patch("/api/practiceNotes") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + PracticeNote unchanged = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); + assertThat(unchanged.getTitle()).isEqualTo("복습"); + assertThat(unchanged.getPracticeNotification().getRepeatType()).isEqualTo("daily"); + // Quartz 잡 삭제는 트랜잭션과 함께 롤백되지 않는다. 아예 호출되면 안 된다. + verify(practiceNotificationScheduler, never()).updateNotification(any(), any(), any(), any()); + verify(practiceNotificationScheduler, never()).deleteNotification(any()); + } + @Test @DisplayName("다른 사용자의 복습노트는 수정할 수 없다") void rejectsOtherUserPracticeNote() throws Exception { diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java index 9331134b..c9a1b599 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java @@ -1,6 +1,8 @@ package com.aisip.OnO.backend.practicenote.service; +import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; +import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -25,6 +27,7 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; /** @@ -89,12 +92,35 @@ void weeklyCronForEveryWeekDay() throws Exception { } @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 매일로 되돌아간다") - void weeklyWithoutWeekDaysFallsBackToDaily() throws Exception { - String cron = scheduleAndCaptureCron( - new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of())); + @DisplayName("주간 반복인데 요일이 비어 있으면 400 으로 거절한다") + void weeklyWithoutWeekDaysIsRejected() throws Exception { + PracticeNotificationRegisterDto dto = + new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of()); + + assertThatThrownBy(() -> + notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); - assertThat(cron).isEqualTo("0 0 7 ? * *"); + // 매일 발송으로 되돌아가던 폴백이 없어야 한다. 잡 자체가 등록되면 안 된다. + verify(quartzScheduler, never()).addJob(any(), anyBoolean()); + verify(quartzScheduler, never()).scheduleJob(any(Trigger.class)); + } + + @Test + @DisplayName("주간 반복인데 요일이 null 이어도 400 으로 거절한다") + void weeklyWithNullWeekDaysIsRejected() throws Exception { + PracticeNotificationRegisterDto dto = + new PracticeNotificationRegisterDto(7, 7, 0, "WEEKLY", null); + + assertThatThrownBy(() -> + notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + + verify(quartzScheduler, never()).addJob(any(), anyBoolean()); } @Test @@ -168,6 +194,23 @@ void wrapsDeleteException() throws Exception { .hasMessage("알림 삭제 실패"); } + @Test + @DisplayName("갱신이 요일 없는 주간 반복이면 기존 잡을 지우기 전에 거절한다") + void updateNotificationRejectsBeforeDeleting() throws Exception { + PracticeNotificationRegisterDto dto = + new PracticeNotificationRegisterDto(7, 21, 0, "weekly", List.of()); + + assertThatThrownBy(() -> + notificationScheduler.updateNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) + .isInstanceOf(ApplicationException.class) + .extracting(e -> ((ApplicationException) e).getErrorCase()) + .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + + // Quartz 삭제는 서비스 트랜잭션과 함께 롤백되지 않는다. 지우고 나서 실패하면 + // 사용자가 쓰던 알림만 사라진다. + verify(quartzScheduler, never()).deleteJob(any()); + } + @Test @DisplayName("갱신은 기존 잡을 지운 뒤 새로 등록한다") void updateNotificationDeletesThenSchedules() throws Exception { From 7c344e5c87008fb2d0e756883ca9bb7c8d91a55c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 18:40:23 +0900 Subject: [PATCH 126/130] =?UTF-8?q?[Fix]=20=EC=B1=8C=EB=A6=B0=EC=A7=80?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=8C=EB=93=A0=20=EB=B3=B8=EC=9D=B8=EB=8F=84=20?= =?UTF-8?q?=EC=A7=80=EC=9A=B8=20=EC=88=98=20=EC=9E=88=EA=B2=8C=20=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 챌린지는 멤버 전원이 만들 수 있는데 삭제는 방장만 할 수 있어서, 일반 멤버가 만든 챌린지를 본인이 지우지 못했다 - study_room_challenge 에 작성자가 없어 V46 으로 created_by_user_id 를 추가한다. 기존 행은 방장으로 채워서 마이그레이션 전후로 누구도 권한을 새로 얻거나 잃지 않는다 - deleteChallenge 는 멤버 검증을 먼저 하고(비멤버에게 챌린지 존재를 알리지 않는다), 방장이거나 작성자일 때만 지운다. 거절은 기존 코드를 쓴다 - 삭제할 때 Quartz 알림 트리거를 함께 취소하는 기존 동작은 그대로다 - 응답에 createdByUserId 와 canDelete 를 더했다. 앱이 삭제 버튼을 언제 보여 줄지 정할 수 있어야 한다 - 작성자 본인, 방장, 다른 멤버, 멤버 아님 네 가지 권한 조합과 V46 적용을 테스트로 고정했다 Closes #310 --- .../backend/studyroom/dto/StudyRoomDtos.java | 3 +- .../studyroom/entity/StudyRoomChallenge.java | 19 +- .../service/StudyRoomChallengeService.java | 42 +++- ...6__add_study_room_challenge_created_by.sql | 29 +++ .../studyroom/entity/StudyRoomEntityTest.java | 10 +- .../StudyRoomChallengeApiTest.java | 109 ++++++++++- ...dyRoomChallengeCreatedByMigrationTest.java | 183 ++++++++++++++++++ .../StudyRoomChallengeServiceTest.java | 2 +- .../support/StudyRoomTestSupport.java | 20 +- 9 files changed, 393 insertions(+), 24 deletions(-) create mode 100644 src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql create mode 100644 src/test/java/com/aisip/OnO/backend/studyroom/migration/StudyRoomChallengeCreatedByMigrationTest.java diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java index 18739ecf..490fbd6e 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/dto/StudyRoomDtos.java @@ -83,7 +83,8 @@ public record ChallengeResponse(Long challengeId, String title, String type, Str Integer periodDays, Integer targetValue, LocalDateTime startAt, LocalDateTime endAt, String status, List memberProgress, - Integer groupCurrent) { + Integer groupCurrent, + Long createdByUserId, boolean canDelete) { } public record ChallengeMemberProgressResponse(Long userId, String name, String profileImageUrl, int current, boolean cleared) { diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java index da283ad4..68ad06a6 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomChallenge.java @@ -24,6 +24,16 @@ public class StudyRoomChallenge extends BaseEntity { @JoinColumn(name = "room_id", nullable = false) private StudyRoom room; + /** + * 챌린지를 만든 사용자. + * + *

    {@code StudyRoom.hostUserId} 와 같이 FK 없는 식별자로 둔다. 사용자 탈퇴는 소프트 삭제라 + * 행이 남지만, 방장 식별자와 같은 방식을 쓰는 편이 이 도메인 안에서 일관된다. + * 기존 행은 V46 마이그레이션에서 방장으로 채웠다(그때까지 방장만 지울 수 있었다). + */ + @Column(name = "created_by_user_id", nullable = false) + private Long createdByUserId; + @Column(nullable = false, length = 40) private String title; @@ -58,12 +68,14 @@ public class StudyRoomChallenge extends BaseEntity { @Column(name = "completed_at") private LocalDateTime completedAt; - public static StudyRoomChallenge create(StudyRoom room, String title, StudyRoomChallengeType type, + public static StudyRoomChallenge create(StudyRoom room, Long createdByUserId, String title, + StudyRoomChallengeType type, StudyRoomChallengeMetric metric, StudyRoomChallengePeriod period, Integer periodDays, Integer targetValue, LocalDateTime startAt, LocalDateTime endAt) { return StudyRoomChallenge.builder() .room(room) + .createdByUserId(createdByUserId) .title(title) .type(type) .metric(metric) @@ -76,6 +88,11 @@ public static StudyRoomChallenge create(StudyRoom room, String title, StudyRoomC .build(); } + /** 이 챌린지를 만든 사용자인지 확인한다. */ + public boolean isCreatedBy(Long userId) { + return userId != null && userId.equals(createdByUserId); + } + public void updateStatus(StudyRoomChallengeStatus status) { this.status = status; } diff --git a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java index 75735562..7af8aa7e 100644 --- a/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java +++ b/src/main/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeService.java @@ -44,8 +44,9 @@ public class StudyRoomChallengeService { public List getChallenges(Long roomId, Long userId) { accessService.validateMember(roomId, userId); List members = memberRepository.findAllWithUserByRoomId(roomId); + Viewer viewer = viewer(userId, members); return challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId).stream() - .map(challenge -> toResponse(challenge, members, true)) + .map(challenge -> toResponse(challenge, members, true, viewer)) .sorted(Comparator.comparing((ChallengeResponse r) -> "in_progress".equals(r.status()) ? 0 : 1) .thenComparing(Comparator.comparing(ChallengeResponse::endAt).reversed())) .toList(); @@ -66,6 +67,7 @@ public ChallengeResponse createChallenge(Long roomId, Long userId, ChallengeCrea StudyRoom room = accessService.getRoomOrThrow(roomId); StudyRoomChallenge challenge = challengeRepository.save(StudyRoomChallenge.create( room, + userId, request.title().trim(), parseEnum(request.type(), StudyRoomChallengeType.class), parseEnum(request.metric(), StudyRoomChallengeMetric.class), @@ -76,25 +78,51 @@ public ChallengeResponse createChallenge(Long roomId, Long userId, ChallengeCrea request.endAt() )); notificationScheduler.scheduleNotifications(challenge); - return toResponse(challenge, memberRepository.findAllWithUserByRoomId(roomId), true); + List members = memberRepository.findAllWithUserByRoomId(roomId); + return toResponse(challenge, members, true, viewer(userId, members)); } + /** + * 챌린지를 지운다. 방장은 방 안의 모든 챌린지를, 일반 멤버는 자기가 만든 챌린지만 지울 수 있다. + * + *

    비멤버는 챌린지가 있는지조차 알려 주지 않으려고 멤버 검증을 먼저 한다(403 10002). + * 그 다음 챌린지를 찾고(404 10009), 마지막에 방장이거나 작성자인지를 본다(403 10003). + */ @Transactional public void deleteChallenge(Long roomId, Long challengeId, Long userId) { - accessService.validateHost(roomId, userId); + StudyRoomMember member = accessService.getMemberOrThrow(roomId, userId); StudyRoomChallenge challenge = challengeRepository.findByIdAndRoomId(challengeId, roomId) .orElseThrow(() -> new ApplicationException(StudyRoomErrorCase.CHALLENGE_NOT_FOUND)); + if (!canDelete(challenge, member.getRole() == StudyRoomMemberRole.HOST, userId)) { + throw new ApplicationException(StudyRoomErrorCase.STUDY_ROOM_HOST_ONLY); + } notificationScheduler.cancelNotifications(challengeId); challengeRepository.delete(challenge); } + /** 이미 읽어 둔 멤버 목록에서 방장 여부를 뽑는다. 응답을 만들 때마다 멤버를 다시 조회하지 않는다. */ + private Viewer viewer(Long userId, List members) { + boolean isHost = members.stream() + .anyMatch(member -> member.getRole() == StudyRoomMemberRole.HOST + && userId.equals(member.getUser().getId())); + return new Viewer(userId, isHost); + } + + private boolean canDelete(StudyRoomChallenge challenge, boolean isHost, Long userId) { + return isHost || challenge.isCreatedBy(userId); + } + + /** 응답을 받아 보는 사용자. 삭제 버튼 노출 조건을 서버에서 계산하려고 들고 다닌다. */ + private record Viewer(Long userId, boolean isHost) {} + private void refreshRoomChallengeStatuses(Long roomId) { List members = memberRepository.findAllWithUserByRoomId(roomId); challengeRepository.findAllByRoomIdOrderByEndAtAsc(roomId) - .forEach(challenge -> toResponse(challenge, members, true)); + .forEach(challenge -> toResponse(challenge, members, true, null)); } - private ChallengeResponse toResponse(StudyRoomChallenge challenge, List members, boolean persistStatus) { + private ChallengeResponse toResponse(StudyRoomChallenge challenge, List members, + boolean persistStatus, Viewer viewer) { List userIds = members.stream().map(member -> member.getUser().getId()).toList(); AggregationRange range = resolveAggregationRange(challenge, LocalDateTime.now()); LocalDate streakBaseDate = min(LocalDate.now(), challenge.getEndAt().toLocalDate()); @@ -155,7 +183,9 @@ public void afterCommit() { challenge.getEndAt(), toApiValue(status.name()), memberProgress, - groupCurrent + groupCurrent, + challenge.getCreatedByUserId(), + viewer != null && canDelete(challenge, viewer.isHost(), viewer.userId()) ); } diff --git a/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql b/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql new file mode 100644 index 00000000..757bfbeb --- /dev/null +++ b/src/main/resources/db/migration/V46__add_study_room_challenge_created_by.sql @@ -0,0 +1,29 @@ +-- 챌린지에 작성자를 남긴다. (#310) +-- +-- 챌린지는 멤버 전원이 만들 수 있는데 삭제는 방장만 할 수 있었다. 그래서 일반 멤버는 자기가 만든 +-- 챌린지를 스스로 지우지 못했다. 작성자 본인에게도 삭제를 열어 주려면 누가 만들었는지를 알아야 하는데, +-- study_room_challenge 에는 그 정보가 아예 없어 컬럼을 새로 만든다. +-- +-- study_room.host_user_id 와 같이 FK 없는 식별자로 둔다. user 는 소프트 삭제라 행이 남지만, +-- 같은 도메인 안에서 사람을 가리키는 방식을 하나로 맞추는 편이 읽기 쉽다. +-- +-- 기존 행의 작성자는 방장으로 채운다. 지금까지 이 챌린지들을 지울 수 있던 사람이 방장뿐이었으므로, +-- 방장으로 채우면 마이그레이션 전후로 누구도 권한을 새로 얻거나 잃지 않는다. +-- 실제 작성자가 일반 멤버였더라도 이 값으로는 삭제 권한이 늘어나지 않는다(방장은 원래 다 지울 수 있다). +-- +-- NULL 을 허용한 채 채운 뒤 NOT NULL 로 조인다. study_room_challenge.room_id 는 NOT NULL 이고 +-- study_room.host_user_id 도 NOT NULL 이라 조인으로 못 채우는 행은 남지 않는다. +-- +-- 잠금: 세 문장 모두 study_room_challenge 전체를 훑는다. 진행 중 챌린지가 방당 최대 5개인 테이블이라 +-- 행 수가 적어 짧게 끝난다. + +ALTER TABLE study_room_challenge + ADD COLUMN created_by_user_id BIGINT NULL AFTER room_id; + +UPDATE study_room_challenge c +JOIN study_room r ON r.id = c.room_id +SET c.created_by_user_id = r.host_user_id +WHERE c.created_by_user_id IS NULL; + +ALTER TABLE study_room_challenge + MODIFY COLUMN created_by_user_id BIGINT NOT NULL; diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomEntityTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomEntityTest.java index 6c479ab9..a565bbff 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomEntityTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/entity/StudyRoomEntityTest.java @@ -221,7 +221,7 @@ class StudyRoomChallengeTest { @DisplayName("생성 직후 상태는 항상 IN_PROGRESS 이고 완료 시각은 비어 있다") void createStartsInProgress() { LocalDateTime startAt = LocalDateTime.now(); - StudyRoomChallenge challenge = StudyRoomChallenge.create(room, "챌린지", + StudyRoomChallenge challenge = StudyRoomChallenge.create(room, room.getHostUserId(), "챌린지", StudyRoomChallengeType.INDIVIDUAL, StudyRoomChallengeMetric.PROBLEM_COUNT, null, null, 5, startAt, startAt.plusDays(7)); @@ -256,10 +256,10 @@ void statusTransitionsAreSequential() { @DisplayName("period 를 쓰면 periodDays 없이, periodDays 를 쓰면 period 없이 저장된다") void periodAndPeriodDaysAreMutuallyExclusiveByConstruction() { LocalDateTime startAt = LocalDateTime.now(); - StudyRoomChallenge weekly = StudyRoomChallenge.create(room, "주간", StudyRoomChallengeType.GROUP, + StudyRoomChallenge weekly = StudyRoomChallenge.create(room, room.getHostUserId(), "주간", StudyRoomChallengeType.GROUP, StudyRoomChallengeMetric.PRACTICE_COUNT, StudyRoomChallengePeriod.WEEKLY, null, 3, startAt, startAt.plusDays(21)); - StudyRoomChallenge custom = StudyRoomChallenge.create(room, "3일 주기", StudyRoomChallengeType.GROUP, + StudyRoomChallenge custom = StudyRoomChallenge.create(room, room.getHostUserId(), "3일 주기", StudyRoomChallengeType.GROUP, StudyRoomChallengeMetric.PRACTICE_COUNT, null, 3, 3, startAt, startAt.plusDays(21)); @@ -273,7 +273,7 @@ void periodAndPeriodDaysAreMutuallyExclusiveByConstruction() { @DisplayName("기간이 0일인 챌린지(시작=종료)도 엔티티 수준에서는 만들어진다") void zeroLengthPeriodIsAcceptedByEntity() { LocalDateTime at = LocalDateTime.now(); - StudyRoomChallenge challenge = StudyRoomChallenge.create(room, "0일", StudyRoomChallengeType.INDIVIDUAL, + StudyRoomChallenge challenge = StudyRoomChallenge.create(room, room.getHostUserId(), "0일", StudyRoomChallengeType.INDIVIDUAL, StudyRoomChallengeMetric.PROBLEM_COUNT, null, null, 1, at, at); assertThat(challenge.getStartAt()) @@ -283,7 +283,7 @@ void zeroLengthPeriodIsAcceptedByEntity() { private StudyRoomChallenge inProgressChallenge() { LocalDateTime startAt = LocalDateTime.now(); - return StudyRoomChallenge.create(room, "챌린지", StudyRoomChallengeType.INDIVIDUAL, + return StudyRoomChallenge.create(room, room.getHostUserId(), "챌린지", StudyRoomChallengeType.INDIVIDUAL, StudyRoomChallengeMetric.PROBLEM_COUNT, null, null, 5, startAt, startAt.plusDays(7)); } } diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiTest.java index dc19d087..4c441b0b 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/integration/StudyRoomChallengeApiTest.java @@ -9,11 +9,18 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.quartz.Scheduler; +import org.quartz.SchedulerException; +import org.quartz.TriggerKey; +import org.quartz.impl.matchers.GroupMatcher; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.Set; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -27,6 +34,9 @@ class StudyRoomChallengeApiTest extends StudyRoomTestSupport { private static final LocalDateTime NOW = LocalDateTime.now().truncatedTo(ChronoUnit.SECONDS); + @Autowired + private Scheduler scheduler; + @Nested @DisplayName("생성") class CreateChallenge { @@ -44,9 +54,13 @@ void memberCanCreateChallenge() throws Exception { .andExpect(jsonPath("$.data.type").value("individual")) .andExpect(jsonPath("$.data.metric").value("problem_count")) .andExpect(jsonPath("$.data.status").value("in_progress")) - .andExpect(jsonPath("$.data.targetValue").value(10)); + .andExpect(jsonPath("$.data.targetValue").value(10)) + .andExpect(jsonPath("$.data.createdByUserId").value(fixture.member().getId())) + .andExpect(jsonPath("$.data.canDelete").value(true)); assertThat(challengeRepository.findAll()).as("저장된 챌린지").hasSize(1); + assertThat(challengeRepository.findAll().get(0).getCreatedByUserId()) + .as("저장된 작성자").isEqualTo(fixture.member().getId()); } @Test @@ -292,6 +306,29 @@ void memberSeesProgress() throws Exception { .andExpect(jsonPath("$.data[0].memberProgress[?(@.userId == " + fixture.member().getId() + ")].cleared").value(false)); } + @Test + @DisplayName("canDelete 는 작성자 본인과 방장에게만 true 다") + void canDeleteIsTrueOnlyForCreatorAndHost() throws Exception { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + User otherMember = fixtures.createUser("otherMember"); + addMember(fixture.room(), otherMember); + StudyRoomChallenge challenge = saveChallenge(fixture.room(), fixture.member().getId(), 5); + + authenticateAs(fixture.member().getId()); + mockMvc.perform(get("/api/study-room/{roomId}/challenges", fixture.roomId())) + .andExpect(jsonPath("$.data[0].createdByUserId").value(fixture.member().getId())) + .andExpect(jsonPath("$.data[0].canDelete").value(true)); + + authenticateAs(fixture.host().getId()); + mockMvc.perform(get("/api/study-room/{roomId}/challenges", fixture.roomId())) + .andExpect(jsonPath("$.data[0].canDelete").value(true)); + + authenticateAs(otherMember.getId()); + mockMvc.perform(get("/api/study-room/{roomId}/challenges", fixture.roomId())) + .andExpect(jsonPath("$.data[0].challengeId").value(challenge.getId())) + .andExpect(jsonPath("$.data[0].canDelete").value(false)); + } + @Test @DisplayName("아직 시작 전인 챌린지도 in_progress 로 조회된다") void notYetStartedChallengeIsInProgress() throws Exception { @@ -473,10 +510,10 @@ void unauthenticatedIsRejected() throws Exception { class DeleteChallenge { @Test - @DisplayName("방장은 챌린지를 삭제할 수 있다") - void hostCanDelete() throws Exception { + @DisplayName("방장은 다른 멤버가 만든 챌린지도 삭제할 수 있다") + void hostCanDeleteChallengeCreatedByMember() throws Exception { RoomFixture fixture = createRoomWithMemberAndOutsider(); - StudyRoomChallenge challenge = saveChallenge(fixture.room(), 5); + StudyRoomChallenge challenge = saveChallenge(fixture.room(), fixture.member().getId(), 5); authenticateAs(fixture.host().getId()); mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", @@ -487,12 +524,28 @@ void hostCanDelete() throws Exception { } @Test - @DisplayName("일반 멤버는 챌린지를 삭제할 수 없다") - void memberCannotDelete() throws Exception { + @DisplayName("일반 멤버도 자기가 만든 챌린지는 삭제할 수 있다") + void creatorCanDeleteOwnChallenge() throws Exception { RoomFixture fixture = createRoomWithMemberAndOutsider(); - StudyRoomChallenge challenge = saveChallenge(fixture.room(), 5); + StudyRoomChallenge challenge = saveChallenge(fixture.room(), fixture.member().getId(), 5); authenticateAs(fixture.member().getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", + fixture.roomId(), challenge.getId())) + .andExpect(status().isOk()); + + assertThat(challengeRepository.findById(challenge.getId())).as("작성자가 지운 챌린지").isEmpty(); + } + + @Test + @DisplayName("남이 만든 챌린지는 같은 방 멤버라도 삭제할 수 없다") + void otherMemberCannotDeleteChallenge() throws Exception { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + User otherMember = fixtures.createUser("otherMember"); + addMember(fixture.room(), otherMember); + StudyRoomChallenge challenge = saveChallenge(fixture.room(), fixture.member().getId(), 5); + authenticateAs(otherMember.getId()); + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", fixture.roomId(), challenge.getId())) .andExpect(status().isForbidden()) @@ -501,6 +554,41 @@ void memberCannotDelete() throws Exception { assertThat(challengeRepository.findById(challenge.getId())).as("남아 있는 챌린지").isPresent(); } + @Test + @DisplayName("방장이 만든 챌린지는 일반 멤버가 삭제할 수 없다") + void memberCannotDeleteHostChallenge() throws Exception { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + StudyRoomChallenge challenge = saveChallenge(fixture.room(), fixture.host().getId(), 5); + authenticateAs(fixture.member().getId()); + + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", + fixture.roomId(), challenge.getId())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.errorCode").value(10003)); + + assertThat(challengeRepository.findById(challenge.getId())).as("남아 있는 챌린지").isPresent(); + } + + @Test + @DisplayName("삭제하면 등록된 Quartz 알림 트리거도 함께 사라진다") + void deleteCancelsNotificationTriggers() throws Exception { + RoomFixture fixture = createRoomWithMemberAndOutsider(); + authenticateAs(fixture.member().getId()); + create(fixture.roomId(), request("알림 달린 챌린지", "individual", "problem_count", + null, null, 10, null, NOW.plusDays(7))) + .andExpect(status().isCreated()); + Long challengeId = challengeRepository.findAll().get(0).getId(); + assertThat(scheduledTriggerKeys(challengeId)) + .as("생성 직후 등록된 트리거") + .isNotEmpty(); + + mockMvc.perform(delete("/api/study-room/{roomId}/challenges/{challengeId}", + fixture.roomId(), challengeId)) + .andExpect(status().isOk()); + + assertThat(scheduledTriggerKeys(challengeId)).as("삭제 후 남은 트리거").isEmpty(); + } + @Test @DisplayName("비멤버는 챌린지를 삭제할 수 없다") void nonMemberCannotDelete() throws Exception { @@ -556,4 +644,11 @@ private ResultActions create(Long roomId, ChallengeCreateRequest request) throws .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); } + + /** 챌린지 하나에 걸린 알림 트리거 키. 스케줄러는 테스트 프로파일에서 메모리 저장소로 돈다. */ + private Set scheduledTriggerKeys(Long challengeId) throws SchedulerException { + return scheduler.getTriggerKeys(GroupMatcher.triggerGroupEquals("CHALLENGE_NOTIFICATION")).stream() + .filter(key -> key.getName().startsWith("challenge-" + challengeId + "-")) + .collect(Collectors.toSet()); + } } diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/migration/StudyRoomChallengeCreatedByMigrationTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/migration/StudyRoomChallengeCreatedByMigrationTest.java new file mode 100644 index 00000000..f262a1dd --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/studyroom/migration/StudyRoomChallengeCreatedByMigrationTest.java @@ -0,0 +1,183 @@ +package com.aisip.OnO.backend.studyroom.migration; + +import com.aisip.OnO.backend.support.IntegrationTestSupport; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StreamUtils; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * V46 이 실제 MySQL 8 에서 돌아가고 기존 챌린지의 작성자를 방장으로 채우는지 확인한다. + * + *

    테스트는 Flyway 를 끄고 Hibernate 가 만든 스키마를 쓴다({@code application-test.yml}). + * 이 마이그레이션은 {@code ALTER TABLE ... ADD COLUMN} 이라, 엔티티에 이미 컬럼이 있는 + * {@code ono_test} 스키마에 그대로 돌리면 중복 컬럼으로 실패한다. 컬럼을 지우고 돌리는 방법도 + * 있지만 같은 스키마를 쓰는 다른 테스트가 휩쓸린다. + * + *

    그래서 마이그레이션 전 모습만 담은 임시 스키마를 따로 만들고 그 안에서 파일 본문을 그대로 + * 실행한다. 스키마를 만들려면 권한이 있어야 해서 커넥션 풀 대신 root 로 직접 연결한다. + * 애플리케이션 계정은 {@code ono_test} 에만 권한이 있다. + */ +@DisplayName("챌린지 작성자 컬럼 마이그레이션") +class StudyRoomChallengeCreatedByMigrationTest extends IntegrationTestSupport { + + private static final String MIGRATION = "db/migration/V46__add_study_room_challenge_created_by.sql"; + + /** Testcontainers 의 MySQLContainer 는 root 비밀번호를 애플리케이션 계정 비밀번호와 같게 둔다. */ + @Value("${spring.datasource.url}") + private String jdbcUrl; + + @Value("${spring.datasource.password}") + private String rootPassword; + + @Test + @DisplayName("기존 챌린지의 작성자를 그 방의 방장으로 채우고 NOT NULL 로 조인다") + void backfillsCreatorWithRoomHost() { + inScratchSchema(statement -> { + createPreMigrationTables(statement); + statement.execute("INSERT INTO study_room VALUES (1, 100), (2, 200)"); + statement.execute("INSERT INTO study_room_challenge VALUES (10, 1, '가'), (11, 1, '나'), (12, 2, '다')"); + + runMigration(statement); + + assertThat(creatorByChallengeId(statement)) + .as("채워진 작성자") + .containsExactlyInAnyOrderEntriesOf(Map.of(10L, 100L, 11L, 100L, 12L, 200L)); + assertThat(isNullable(statement)) + .as("created_by_user_id 가 NULL 을 허용하는지") + .isFalse(); + }); + } + + @Test + @DisplayName("챌린지가 하나도 없어도 컬럼만 생기고 끝난다") + void runsOnEmptyTable() { + inScratchSchema(statement -> { + createPreMigrationTables(statement); + + runMigration(statement); + + assertThat(creatorByChallengeId(statement)).as("남은 행").isEmpty(); + assertThat(isNullable(statement)).as("created_by_user_id 가 NULL 을 허용하는지").isFalse(); + }); + } + + /** V46 직전의 두 테이블. 마이그레이션이 건드리는 컬럼만 남겼다. */ + private void createPreMigrationTables(Statement statement) throws Exception { + statement.execute(""" + CREATE TABLE study_room ( + id BIGINT NOT NULL PRIMARY KEY, + host_user_id BIGINT NOT NULL + )"""); + statement.execute(""" + CREATE TABLE study_room_challenge ( + id BIGINT NOT NULL PRIMARY KEY, + room_id BIGINT NOT NULL, + title VARCHAR(40) NOT NULL + )"""); + } + + private Map creatorByChallengeId(Statement statement) throws Exception { + Map creators = new LinkedHashMap<>(); + try (ResultSet rs = statement.executeQuery( + "SELECT id, created_by_user_id FROM study_room_challenge ORDER BY id")) { + while (rs.next()) { + creators.put(rs.getLong("id"), rs.getLong("created_by_user_id")); + } + } + return creators; + } + + private boolean isNullable(Statement statement) throws Exception { + try (ResultSet rs = statement.executeQuery(""" + SELECT is_nullable FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'study_room_challenge' + AND column_name = 'created_by_user_id'""")) { + assertThat(rs.next()).as("created_by_user_id 컬럼이 생겼는지").isTrue(); + return "YES".equals(rs.getString("is_nullable")); + } + } + + private void runMigration(Statement statement) throws Exception { + for (String sql : statements()) { + statement.execute(sql); + } + } + + private List statements() { + String body = Arrays.stream(read().split("\\R")) + .filter(line -> !line.trim().startsWith("--")) + .collect(Collectors.joining("\n")); + List statements = new ArrayList<>(); + for (String sql : body.split(";")) { + if (!sql.isBlank()) { + statements.add(sql.trim()); + } + } + return statements; + } + + private String read() { + try (InputStream inputStream = new ClassPathResource(MIGRATION).getInputStream()) { + return StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + /** + * 임시 스키마 하나를 만들어 그 안에서만 작업하고 끝나면 지운다. + * + *

    이름에 난수를 붙인다. 테스트가 한 번에 여러 개 돌아도 서로 부딪히지 않게 하기 위해서다. + */ + private void inScratchSchema(ScratchWork work) { + String schema = "v46_check_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + try (Connection connection = DriverManager.getConnection(jdbcUrl, "root", rootPassword); + Statement statement = connection.createStatement()) { + String original = currentDatabase(statement); + statement.execute("CREATE DATABASE " + schema); + try { + // setCatalog 는 드라이버 설정에 따라 세션을 바꾸지 않을 수 있어 USE 를 직접 보낸다. + statement.execute("USE " + schema); + work.run(statement); + } finally { + statement.execute("USE " + original); + statement.execute("DROP DATABASE " + schema); + } + } catch (Exception e) { + throw new IllegalStateException("V46 마이그레이션 검증 실패", e); + } + } + + private String currentDatabase(Statement statement) throws Exception { + try (ResultSet rs = statement.executeQuery("SELECT DATABASE()")) { + rs.next(); + return rs.getString(1); + } + } + + @FunctionalInterface + private interface ScratchWork { + void run(Statement statement) throws Exception; + } +} diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java index 2b3e36ae..23e6d423 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/service/StudyRoomChallengeServiceTest.java @@ -485,7 +485,7 @@ private StudyRoomChallenge challenge(StudyRoomChallengeType type, StudyRoomChall LocalDateTime startAt, LocalDateTime endAt) { StudyRoom room = StudyRoom.create("스터디룸", 1L); setField(room, "id", ROOM_ID); - StudyRoomChallenge challenge = StudyRoomChallenge.create(room, "챌린지", type, metric, + StudyRoomChallenge challenge = StudyRoomChallenge.create(room, room.getHostUserId(), "챌린지", type, metric, period, periodDays, targetValue, startAt, endAt); setField(challenge, "id", USER_SEQUENCE.incrementAndGet()); return challenge; diff --git a/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java b/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java index 2ece4925..4e9a4442 100644 --- a/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java +++ b/src/test/java/com/aisip/OnO/backend/studyroom/support/StudyRoomTestSupport.java @@ -172,9 +172,14 @@ protected StudyRoomFeedReaction saveFeedReaction(StudyRoomFeed feed, User user, // ─────────────────────────── 챌린지 ─────────────────────────── - /** 지금 시작해 7일 뒤 끝나는 개인 문제 등록 챌린지. */ + /** 지금 시작해 7일 뒤 끝나는 개인 문제 등록 챌린지. 작성자는 방장이다. */ protected StudyRoomChallenge saveChallenge(StudyRoom room, int targetValue) { - return saveChallenge(room, "챌린지", StudyRoomChallengeType.INDIVIDUAL, + return saveChallenge(room, room.getHostUserId(), targetValue); + } + + /** 작성자를 지정한 챌린지. 삭제 권한 조합 검증에 쓴다. */ + protected StudyRoomChallenge saveChallenge(StudyRoom room, Long createdByUserId, int targetValue) { + return saveChallenge(room, createdByUserId, "챌린지", StudyRoomChallengeType.INDIVIDUAL, StudyRoomChallengeMetric.PROBLEM_COUNT, null, null, targetValue, LocalDateTime.now().minusHours(1), LocalDateTime.now().plusDays(7)); } @@ -183,8 +188,17 @@ protected StudyRoomChallenge saveChallenge(StudyRoom room, String title, StudyRo StudyRoomChallengeMetric metric, StudyRoomChallengePeriod period, Integer periodDays, int targetValue, LocalDateTime startAt, LocalDateTime endAt) { + return saveChallenge(room, room.getHostUserId(), title, type, metric, period, periodDays, targetValue, + startAt, endAt); + } + + protected StudyRoomChallenge saveChallenge(StudyRoom room, Long createdByUserId, String title, + StudyRoomChallengeType type, + StudyRoomChallengeMetric metric, StudyRoomChallengePeriod period, + Integer periodDays, int targetValue, + LocalDateTime startAt, LocalDateTime endAt) { return challengeRepository.saveAndFlush(StudyRoomChallenge.create( - room, title, type, metric, period, periodDays, targetValue, startAt, endAt)); + room, createdByUserId, title, type, metric, period, periodDays, targetValue, startAt, endAt)); } // ─────────────────────────── 공유 문제 / 댓글 ─────────────────────────── From 2f5f7b4ac6539a3125c51b7698c525c2fa6fed2c Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 19:39:50 +0900 Subject: [PATCH 127/130] =?UTF-8?q?[Fix]=20=EC=8B=A0=EB=B2=84=EC=A0=84?= =?UTF-8?q?=EC=9D=B4=20=EB=82=A8=EA=B8=B4=20mission=5Flog=20=EA=B0=80=20?= =?UTF-8?q?=EA=B5=AC=EB=B2=84=EC=A0=84=20=EC=A0=81=EB=A6=BD=EC=9D=84=20?= =?UTF-8?q?=EB=A7=89=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 같은 계정을 신버전과 구버전 기기에서 번갈아 쓰면, 신버전 요청이 남긴 mission_log 행이 구버전 요청의 "이미 적립했다" 판정에 걸려 그 활동이 적립으로도 진행도로도 남지 않았다 - mission_log 행은 그대로 남긴다. DAU, 순 방문자, 일자별 방문 수, 활성 사용자 목록, 복습 로그 조회, 사용자별 기록, 훈장 '개근'이 전부 이 테이블만 본다. 행을 안 남기면 그 지표들이 통째로 0 이 된다 - 대신 MissionLog.point 가 "이 기록으로 자동 적립이 돌았는가"를 담는다. 적립이 돌면 정가, 돌지 않으면 0 이다. 상한에 걸려 깎이기 전 정가를 넣는 이유는 실지급액을 넣으면 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀌기 때문이다 - 중복 방지 판정에 accruedOnly 를 뒀다. 자동 적립이 도는 요청은 적립된 행만 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다. 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다 - 하루 200점 상한(getPointSumToday)은 point 합을 그대로 보므로, 지급되지도 않은 신버전 활동이 상한을 갉던 것도 함께 사라진다 - 관리자 복습 로그 화면의 점수는 저장값이 아니라 미션 타입의 정가를 쓴다. 화면의 뜻이 "이 행동의 값어치"라 적립 여부와 무관하고, 보이는 값은 지금과 같다 - 출석, 오답노트 등록, 복습 기록, 세트 완료 네 가지의 재현과 신버전 단독, 구버전 단독 사용자의 동작을 MissionCrossVersionAccrualTest 로 고정했다 Closes #318 --- .../dto/AdminPracticeLogResponseDto.java | 9 +- .../backend/mission/entity/MissionLog.java | 25 +- .../MissionLogRepositoryCustom.java | 27 ++ .../repository/MissionLogRepositoryImpl.java | 45 ++- .../mission/service/MissionLogService.java | 42 ++- .../MissionCrossVersionAccrualTest.java | 313 ++++++++++++++++++ 6 files changed, 447 insertions(+), 14 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java diff --git a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java index 03d2b5ac..2c31ca2d 100644 --- a/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java +++ b/src/main/java/com/aisip/OnO/backend/admin/dto/AdminPracticeLogResponseDto.java @@ -14,6 +14,13 @@ public record AdminPracticeLogResponseDto( Long point, LocalDateTime createdAt ) { + /** + * {@code point} 는 저장된 값이 아니라 미션 타입의 정가다. + * + *

    {@code mission_log.point} 는 "이 기록으로 자동 적립이 돌았는가"를 담게 되어, 미션을 받을 수 있는 + * 앱에서 온 요청의 행은 0 이다(#318). 이 화면이 보여 주는 값의 뜻은 "복습 세트 완료 하나의 값어치"라 + * 적립 여부와 무관하므로, 화면이 지금까지 보여 주던 값을 그대로 유지한다. + */ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNote practiceNote) { return new AdminPracticeLogResponseDto( missionLog.getId(), @@ -22,7 +29,7 @@ public static AdminPracticeLogResponseDto from(MissionLog missionLog, PracticeNo missionLog.getUser().getEmail(), missionLog.getReferenceId(), practiceNote != null ? practiceNote.getTitle() : "-", - missionLog.getPoint(), + missionLog.getMissionType().getPoint(), missionLog.getCreatedAt() ); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java index 4de17852..95ede576 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java +++ b/src/main/java/com/aisip/OnO/backend/mission/entity/MissionLog.java @@ -31,15 +31,38 @@ public class MissionLog extends BaseEntity { private MissionType missionType; + /** + * 이 기록으로 자동 적립 경로가 열렸는가. 열렸으면 그 행동의 정가, 아니면 0 이다. + * + *

    예전에는 지급 여부와 무관하게 언제나 정가가 들어갔다. 미션을 받을 수 있는 앱에서 온 요청은 + * 자동 적립을 돌리지 않는데도 정가가 들어가는 바람에, 하루 200점 상한({@code getPointSumToday})이 + * 받지도 않은 점수로 채워져 같은 계정의 구버전 기기 적립까지 막았다(#318). + * + *

    상한 계산은 이 값의 합만 본다. 그래서 실제로 적립이 돈 행만 상한을 갉는다. + * 적립이 돈 행에는 상한에 걸려 깎이기 전 정가를 그대로 넣는다. 깎인 실지급액을 넣으면 + * 상한 계산이 달라져 구버전만 쓰는 사용자의 동작이 바뀐다. + * + *

    관리자 복습 로그 화면이 보여 주는 점수는 이 값이 아니라 미션 타입의 정가다 + * ({@code AdminPracticeLogResponseDto}). 화면의 뜻은 "이 행동의 값어치"라 적립 여부와 무관하다. + */ private Long point; private Long referenceId; + /** 자동 적립이 도는 요청에서 남기는 기록. 정가가 그대로 들어간다. */ public static MissionLog from(MissionRegisterDto missionRegisterDto, User user) { + return from(missionRegisterDto, user, true); + } + + /** + * @param accrued 이 요청에서 자동 적립이 도는가. 돌지 않으면 {@link #point} 를 0 으로 남겨 + * 하루 상한과 중복 방지 판정이 이 행을 "적립된 행"으로 세지 않게 한다. + */ + public static MissionLog from(MissionRegisterDto missionRegisterDto, User user, boolean accrued) { return MissionLog.builder() .user(user) .missionType(missionRegisterDto.missionType()) - .point(missionRegisterDto.missionType().getPoint()) + .point(accrued ? missionRegisterDto.missionType().getPoint() : 0L) .referenceId(missionRegisterDto.referenceId()) .build(); } diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java index 7b432589..ceec1ddf 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryCustom.java @@ -6,17 +6,44 @@ import java.util.List; import java.util.Map; +/** + * 중복 방지 판정에 붙는 {@code accruedOnly} 는 "실제로 적립된 행만 셀 것인가"다. + * + *

    {@code mission_log} 행은 미션을 받을 수 있는 앱에서 온 요청도 그대로 남긴다. 관리자 통계와 + * 훈장이 이 테이블만 보기 때문이다. 그런데 그 행을 중복 방지에서도 그대로 세면, 같은 계정을 구버전 + * 기기에서도 쓰는 사용자는 구버전 활동이 "이미 적립했다"로 막혀 적립으로도 진행도로도 남지 않는다(#318). + * + *

    그래서 자동 적립이 도는 요청은 적립된 행만 세고({@code accruedOnly = true}), + * 진행도만 올리는 요청은 지금처럼 모든 행을 센다({@code accruedOnly = false}). + * 뒤쪽을 함께 풀면 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다. + */ public interface MissionLogRepositoryCustom { boolean alreadyWriteProblemsTodayMoreThan3(Long userId); + boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly); + long countProblemWritesToday(Long userId); + long countProblemWritesToday(Long userId, boolean accruedOnly); + boolean alreadyPracticeProblem(Long problemId); + boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly); + boolean alreadyPracticeNote(Long practiceNoteId); + boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly); + boolean alreadyLogin(Long userId); + boolean alreadyLogin(Long userId, boolean accruedOnly); + + /** + * 오늘 자동 적립으로 잡힌 점수 합. 하루 200점 상한이 이 값을 본다. + * + *

    적립이 돌지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. + * 조건을 따로 걸지 않는 이유는 그 0 이 이미 "이 행은 적립되지 않았다"를 뜻하기 때문이다. + */ Long getPointSumToday(Long userId); Map getDailyActiveUsersCount(int days); diff --git a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java index 260f5356..741c74b6 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java +++ b/src/main/java/com/aisip/OnO/backend/mission/repository/MissionLogRepositoryImpl.java @@ -29,17 +29,28 @@ public MissionLogRepositoryImpl(EntityManager entityManager) { @Override public boolean alreadyWriteProblemsTodayMoreThan3(Long userId) { - return countProblemWritesToday(userId) >= 3; + return alreadyWriteProblemsTodayMoreThan3(userId, false); + } + + @Override + public boolean alreadyWriteProblemsTodayMoreThan3(Long userId, boolean accruedOnly) { + return countProblemWritesToday(userId, accruedOnly) >= 3; } @Override public long countProblemWritesToday(Long userId) { + return countProblemWritesToday(userId, false); + } + + @Override + public long countProblemWritesToday(Long userId, boolean accruedOnly) { Long count = queryFactory .select(missionLog.count()) .from(missionLog) .where(missionLog.missionType.eq(MissionType.PROBLEM_WRITE) .and(missionLog.user.id.eq(userId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchOne(); @@ -48,6 +59,11 @@ public long countProblemWritesToday(Long userId) { @Override public boolean alreadyPracticeProblem(Long problemId){ + return alreadyPracticeProblem(problemId, false); + } + + @Override + public boolean alreadyPracticeProblem(Long problemId, boolean accruedOnly){ return queryFactory .selectOne() @@ -55,34 +71,61 @@ public boolean alreadyPracticeProblem(Long problemId){ .where(missionLog.missionType.eq(MissionType.PROBLEM_PRACTICE) .and(missionLog.referenceId.eq(problemId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyPracticeNote(Long practiceNoteId){ + return alreadyPracticeNote(practiceNoteId, false); + } + + @Override + public boolean alreadyPracticeNote(Long practiceNoteId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.NOTE_PRACTICE) .and(missionLog.referenceId.eq(practiceNoteId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } @Override public boolean alreadyLogin(Long userId){ + return alreadyLogin(userId, false); + } + + @Override + public boolean alreadyLogin(Long userId, boolean accruedOnly){ return queryFactory .selectOne() .from(missionLog) .where(missionLog.missionType.eq(MissionType.USER_LOGIN) .and(missionLog.user.id.eq(userId)) .and(createdToday()) + .and(accrued(accruedOnly)) ) .fetchFirst() != null; } + /** + * "실제로 적립된 행만" 조건. {@code accruedOnly} 가 아니면 조건을 붙이지 않는다. + * + *

    {@code null} 을 돌려주면 QueryDSL 이 그 항을 통째로 빼므로, 기존 판정의 쿼리가 그대로 남는다. + * 적립된 행은 {@code point} 에 정가가 들어 있고, 적립이 돌지 않은 요청의 행은 0 이다 + * ({@code MissionLog.point}). + * + *

    옛 행은 적립 여부와 무관하게 정가가 들어 있어 전부 "적립된 행"으로 잡힌다. + * 덜 주는 쪽이 아니라 지금과 같게 두는 쪽이라 안전하다. + */ + private BooleanExpression accrued(boolean accruedOnly) { + return accruedOnly ? missionLog.point.gt(0L) : null; + } + @Override public Long getPointSumToday(Long userId){ Long result = queryFactory diff --git a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java index 39d6a27e..1a6f8dcc 100644 --- a/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java +++ b/src/main/java/com/aisip/OnO/backend/mission/service/MissionLogService.java @@ -50,6 +50,19 @@ * 행이 사라지면 그 지표들이 통째로 0 이 된다. 중복 방지 판정도 설정과 무관하게 돈다. * 자동 적립이 도는 요청에서는 미션 진행도가 오르지 않는다. 같은 행동으로 적립과 미션 보상을 * 둘 다 받지 않게 하기 위해서다. 진행도를 막는 것은 {@link MissionProgressUpdater} 가 한다. + * + *

    중복 방지는 요청이 어느 경로인가에 따라 보는 범위가 다르다. 자동 적립이 도는 요청은 + * 실제로 적립된 행만 보고, 진행도를 올리는 요청은 지금처럼 모든 행을 본다 + * ({@code MissionLogRepositoryCustom} 의 {@code accruedOnly}). + * + *

    가르는 이유는 같은 계정을 두 기기에서 쓰는 사용자 때문이다(#318). 신버전 요청이 남긴 행을 + * 구버전 요청의 중복 방지가 그대로 세면, 그 활동은 적립으로도(행에 막혀서) 진행도로도 + * (구버전이라 막혀서) 남지 않아 어느 쪽으로도 XP 가 들어오지 않는다. 반대로 양쪽 판정을 함께 풀면 + * 신버전만 쓰는 사용자가 앱을 열 때마다 출석 진행도가 다시 오르므로, 푸는 것은 적립 쪽 하나뿐이다. + * + *

    대신 두 기기를 번갈아 쓰면 같은 대상에 대해 한 번은 적립, 한 번은 진행도가 따로 잡힌다. + * 기기마다 따로 한 활동이라 요청 하나가 두 경로로 받는 것은 아니다. 활동을 통째로 잃는 것보다 낫다고 보고 + * 받아들인 값이다. */ @Service @RequiredArgsConstructor @@ -92,7 +105,8 @@ private User lockUser(Long userId) { public void registerLoginMission(Long userId) { User user = lockUser(userId); - boolean alreadyLogin = missionLogRepository.alreadyLogin(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyLogin = missionLogRepository.alreadyLogin(userId, accrues); if(!alreadyLogin) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -101,7 +115,7 @@ public void registerLoginMission(Long userId) { .missionType(MissionType.USER_LOGIN) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -114,7 +128,9 @@ public void registerLoginMission(Long userId) { public void registerProblemWriteMission(Long userId) { User user = lockUser(userId); - boolean alreadyWriteMoreThanThreeProblems = missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyWriteMoreThanThreeProblems = + missionLogRepository.alreadyWriteProblemsTodayMoreThan3(userId, accrues); if(!alreadyWriteMoreThanThreeProblems) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -123,7 +139,7 @@ public void registerProblemWriteMission(Long userId) { .missionType(MissionType.PROBLEM_WRITE) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -132,7 +148,8 @@ public void registerProblemWriteMission(Long userId) { public void registerProblemWriteMissionBatch(Long userId, int count) { User user = lockUser(userId); - long todayCount = missionLogRepository.countProblemWritesToday(userId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + long todayCount = missionLogRepository.countProblemWritesToday(userId, accrues); int toCreate = (int) Math.min(count, Math.max(0, 3 - todayCount)); if (toCreate == 0) return; @@ -142,7 +159,7 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { .build(); for (int i = 0; i < toCreate; i++) { - MissionLog log = MissionLog.from(dto, user); + MissionLog log = MissionLog.from(dto, user, accrues); missionLogRepository.save(log); addPointToUser(user, log); } @@ -150,7 +167,8 @@ public void registerProblemWriteMissionBatch(Long userId, int count) { public void registerProblemPracticeMission(Long userId, Long problemId) { User user = lockUser(userId); - boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeProblem = missionLogRepository.alreadyPracticeProblem(problemId, accrues); if(!alreadyPracticeProblem) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -160,7 +178,7 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { .referenceId(problemId) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -169,7 +187,8 @@ public void registerProblemPracticeMission(Long userId, Long problemId) { public void registerNotePracticeMission(Long userId, Long practiceNoteId) { User user = lockUser(userId); - boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId); + boolean accrues = legacyAccrualPolicy.accruesForCurrentRequest(); + boolean alreadyPracticeNote = missionLogRepository.alreadyPracticeNote(practiceNoteId, accrues); if(!alreadyPracticeNote) { MissionRegisterDto missionRegisterDto = MissionRegisterDto @@ -179,7 +198,7 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { .referenceId(practiceNoteId) .build(); - MissionLog missionLog = MissionLog.from(missionRegisterDto, user); + MissionLog missionLog = MissionLog.from(missionRegisterDto, user, accrues); missionLogRepository.save(missionLog); addPointToUser(user, missionLog); @@ -197,7 +216,8 @@ public void registerNotePracticeMission(Long userId, Long practiceNoteId) { * 중복 방지 판정은 그대로 돈다. 진행도 증가는 반대로 적립하는 요청에서만 막힌다. * *

    하루 200점 상한은 이 경로에만 있는 규칙이다. 미션 보상은 {@link MissionRewardGranter} 가 - * 따로 지급하고 상한을 타지 않는다. + * 따로 지급하고 상한을 타지 않는다. 상한이 보는 {@code getPointSumToday} 는 적립이 돈 행만 센다. + * 적립하지 않은 요청의 행은 {@code point} 가 0 이라 저절로 빠진다. */ private Long addPointToUser(User user, MissionLog missionLog) { // 비상 스위치가 꺼졌거나 미션을 받을 수 있는 앱이면 보상 경로 하나만 남긴다. diff --git a/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java new file mode 100644 index 00000000..eeb0893d --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/mission/service/MissionCrossVersionAccrualTest.java @@ -0,0 +1,313 @@ +package com.aisip.OnO.backend.mission.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.mission.entity.MissionType; +import com.aisip.OnO.backend.mission.entity.UserMissionStatus; +import com.aisip.OnO.backend.mission.support.MissionSystemTestSupport; +import com.aisip.OnO.backend.practicenote.dto.PracticeNoteRegisterDto; +import com.aisip.OnO.backend.practicenote.entity.PracticeNote; +import com.aisip.OnO.backend.practicenote.repository.PracticeNoteRepository; +import com.aisip.OnO.backend.practicenote.service.PracticeNoteService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterDto; +import com.aisip.OnO.backend.problem.entity.Problem; +import com.aisip.OnO.backend.problem.repository.ProblemRepository; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problemsolve.dto.ProblemSolveRegisterDto; +import com.aisip.OnO.backend.problemsolve.entity.AnswerStatus; +import com.aisip.OnO.backend.problemsolve.service.ProblemSolveService; +import com.aisip.OnO.backend.user.entity.User; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +/** + * 같은 계정을 신버전과 구버전 기기에서 번갈아 쓸 때(#318). + * + *

    신버전 요청도 {@code mission_log} 행을 남긴다. 관리자 통계 일곱 곳과 훈장 '개근'이 이 테이블만 보기 때문에 + * 행을 안 남길 수는 없다({@code MissionLogRetentionTest}). 그런데 중복 방지 판정이 그 행을 그대로 세면, + * 뒤이어 온 구버전 요청이 "이미 적립했다"로 막힌다. 구버전 요청은 진행도도 올리지 않으므로 + * 그 활동은 적립으로도 진행도로도 남지 않는다. + * + *

    그래서 구버전 요청의 중복 방지는 실제로 적립된 행만 센다. 신버전 요청의 판정은 그대로 + * 모든 행을 세야 한다. 그러지 않으면 신버전 단독 사용자가 앱을 열 때마다 출석 진행도가 다시 오른다. + * 두 방향을 한자리에서 잠근다. + */ +@DisplayName("신·구버전 기기를 번갈아 써도") +class MissionCrossVersionAccrualTest extends MissionSystemTestSupport { + + @Autowired + private ProblemService problemService; + + @Autowired + private ProblemSolveService problemSolveService; + + @Autowired + private PracticeNoteService practiceNoteService; + + @Autowired + private ProblemRepository problemRepository; + + @Autowired + private PracticeNoteRepository practiceNoteRepository; + + private User user; + private Folder folder; + + @BeforeEach + void setUpUser() { + user = fixtures.createUser(); + folder = fixtures.createRootFolder(user.getId()); + } + + @Nested + @DisplayName("신버전으로 먼저 활동한 뒤 구버전으로 같은 활동을 하면") + class LegacyAfterMissionCapable { + + @Test + @DisplayName("출석 XP 를 받는다") + void loginStillAccrues() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendanceXp()) + .as("신버전이 남긴 출석 기록 때문에 구버전 출석이 통째로 사라지면 안 된다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("오답노트 등록 XP 를 받는다") + void problemWriteStillAccrues() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + for (int i = 0; i < 3; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + requestFromApp(null); + problemService.registerProblem(problemDto(), user.getId()); + + assertThat(noteWriteXp()) + .as("하루 3건 한도를 신버전 활동이 대신 소진하면 안 된다") + .isEqualTo(MissionType.PROBLEM_WRITE.getPoint()); + } + + @Test + @DisplayName("복습 기록 XP 를 받는다") + void problemPracticeStillAccrues() { + Problem problem = saveProblem(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + + requestFromApp(null); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + + assertThat(problemPracticeXp()) + .as("같은 문제를 신버전이 먼저 건드렸다고 구버전 복습이 사라지면 안 된다") + .isEqualTo(MissionType.PROBLEM_PRACTICE.getPoint()); + } + + @Test + @DisplayName("세트 완료 XP 를 받는다") + void notePracticeStillAccrues() { + PracticeNote note = savePracticeNote(); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + requestFromApp(null); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(notePracticeXp()) + .as("같은 세트를 신버전이 먼저 끝냈다고 구버전 완료가 사라지면 안 된다") + .isEqualTo(MissionType.NOTE_PRACTICE.getPoint()); + } + + @Test + @DisplayName("신버전 활동은 하루 200점 상한을 갉아먹지 않는다") + void missionCapableActivityDoesNotFillDailyLimit() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + // 정가로 세면 15 * 14 = 210 점이라 하루 상한 200 을 넘긴다. + for (int i = 0; i < 14; i++) { + practiceNoteService.addPracticeNoteCount(user.getId(), savePracticeNote().getId()); + } + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(attendanceXp()) + .as("지급되지 않은 신버전 활동이 상한을 채워 구버전 적립을 막으면 안 된다") + .isEqualTo(MissionType.USER_LOGIN.getPoint()); + } + + @Test + @DisplayName("신버전이 남긴 기록은 그대로 남는다") + void missionCapableLogRowsRemain() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + + requestFromApp(null); + missionLogService.registerLoginMission(user.getId()); + + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("관리자 통계와 훈장이 이 행만 본다. 신버전 행을 지우면 지표가 통째로 0 이 된다") + .isNotEmpty(); + } + } + + @Nested + @DisplayName("신버전만 쓰는 사용자는") + class MissionCapableOnly { + + @Test + @DisplayName("앱을 여러 번 열어도 출석 진행도가 한 번만 오른다") + void loginProgressRisesOnce() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)) + .as("같은 날 다시 접속한 것으로 주간 출석이 차면 안 된다") + .isEqualTo(1); + assertThat(attendanceXp()).as("신버전은 적립을 받지 않는다").isZero(); + } + + @Test + @DisplayName("같은 세트를 반복 완료해도 세트 진행도가 한 번만 오른다") + void practiceNoteProgressRisesOnce() { + PracticeNote note = savePracticeNote(); + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_SET_3)) + .as("같은 세트를 세 번 완료한 것으로 주간 세트 미션이 채워지면 안 된다") + .isEqualTo(1); + assertThat(notePracticeXp()).as("신버전은 적립을 받지 않는다").isZero(); + } + + @Test + @DisplayName("오답노트 기록은 하루 3건까지만 남는다") + void problemWriteLogsStopAtThree() { + requestFromApp(MISSION_CAPABLE_APP_HEADER); + + for (int i = 0; i < 5; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + assertThat(missionLogRepository.countProblemWritesToday(user.getId())) + .as("자동 적립 한도 판정은 신버전 요청에서 지금과 같아야 한다") + .isEqualTo(3L); + } + } + + @Nested + @DisplayName("구버전만 쓰는 사용자는") + class LegacyOnly { + + @Test + @DisplayName("같은 활동을 반복해도 지금처럼 한 번만 적립된다") + void accruesOnlyOnce() { + requestFromApp(null); + PracticeNote note = savePracticeNote(); + Problem problem = saveProblem(); + + missionLogService.registerLoginMission(user.getId()); + missionLogService.registerLoginMission(user.getId()); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + problemSolveService.createProblemSolve(solveDto(problem), user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + for (int i = 0; i < 5; i++) { + problemService.registerProblem(problemDto(), user.getId()); + } + + assertThat(attendanceXp()).isEqualTo(MissionType.USER_LOGIN.getPoint()); + assertThat(problemPracticeXp()).isEqualTo(MissionType.PROBLEM_PRACTICE.getPoint()); + assertThat(notePracticeXp()).isEqualTo(MissionType.NOTE_PRACTICE.getPoint()); + assertThat(noteWriteXp()) + .as("오답노트는 하루 3건까지만 적립된다") + .isEqualTo(3 * MissionType.PROBLEM_WRITE.getPoint()); + assertThat(missionLogRepository.findAllByUserId(user.getId())) + .as("출석 1 + 문제복습 1 + 세트 1 + 오답노트 3") + .hasSize(6); + } + } + + @Nested + @DisplayName("구버전으로 먼저 적립한 뒤 신버전으로 같은 활동을 하면") + class MissionCapableAfterLegacy { + + @Test + @DisplayName("진행도는 오르지 않는다") + void progressStaysBlocked() { + requestFromApp(null); + PracticeNote note = savePracticeNote(); + missionLogService.registerLoginMission(user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + requestFromApp(MISSION_CAPABLE_APP_HEADER); + missionLogService.registerLoginMission(user.getId()); + practiceNoteService.addPracticeNoteCount(user.getId(), note.getId()); + + assertThat(currentOf(user.getId(), WEEKLY_ATTEND_5)) + .as("이미 적립으로 받은 출석을 미션으로 한 번 더 받으면 안 된다") + .isZero(); + assertThat(currentOf(user.getId(), WEEKLY_SET_3)) + .as("이미 적립으로 받은 세트 완료를 미션으로 한 번 더 받으면 안 된다") + .isZero(); + } + } + + private long attendanceXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getAttendanceLevel(), status.getAttendancePoint()); + } + + private long noteWriteXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getNoteWriteLevel(), status.getNoteWritePoint()); + } + + private long problemPracticeXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getProblemPracticeLevel(), status.getProblemPracticePoint()); + } + + private long notePracticeXp() { + UserMissionStatus status = reload(user).getUserMissionStatus(); + return accumulatedPoints(status.getNotePracticeLevel(), status.getNotePracticePoint()); + } + + private ProblemRegisterDto problemDto() { + return new ProblemRegisterDto(null, "메모", "출처", folder.getId(), LocalDateTime.now()); + } + + private ProblemSolveRegisterDto solveDto(Problem problem) { + return new ProblemSolveRegisterDto( + problem.getId(), LocalDateTime.now(), AnswerStatus.CORRECT, "회고", null, 120, null); + } + + private Problem saveProblem() { + Problem problem = Problem.from(problemDto(), user.getId()); + problem.updateFolder(folder); + return problemRepository.saveAndFlush(problem); + } + + private PracticeNote savePracticeNote() { + return practiceNoteRepository.save(PracticeNote.from( + new PracticeNoteRegisterDto(null, "복습 세트", List.of(), null), user.getId())); + } +} From cf76def8e0f8ca35a0419134638f687f00862a8f Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 19:40:20 +0900 Subject: [PATCH 128/130] =?UTF-8?q?[Fix]=20=EC=9A=94=EC=9D=BC=20=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=EC=A3=BC=EA=B0=84=20=EB=B3=B5=EC=8A=B5=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=95=B1=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9C=BC=EB=A1=9C=20=EA=B0=80=EB=A5=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #302 로 들어간 검증이 스토어에 나가 있는 구버전 앱 사용자를 막았다. 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 요일이 빈 주간 알림 행을 가진 복습 세트는 제목만 바꿔도 계속 400 이 나 수정할 방법이 없었다 - PracticeNotificationWeekDayPolicy 를 새로 두고 X-App-Version 이 기준 버전(ono.practice-note.week-days-required-version, 기본 4.0.0) 이상인 요청만 400/6003 으로 막는다. 헤더가 없거나 읽을 수 없는 값이면 구버전으로 본다 - 미션의 LegacyAccrualPolicy 는 읽기만 하고 그대로 쓰지 않았다. 거기 붙은 비상 스위치(ono.mission.legacy-accrual.enabled)를 내리면 복습 알림 검증까지 같이 움직이기 때문이다. 공용으로 뺀 것은 AppVersionResolver.isAtLeast 의 버전 비교까지다 - PracticeNotificationScheduler.convertDtoToCron 의 매일 폴백을 되살렸다. 구버전 요청은 예전 서버와 같게 매일로 저장된다. 스케줄러에 있던 검증은 뺐다. 진입부인 PracticeNoteService 에서 이미 걸러지고, 거기서 막아야 Quartz 잡 삭제가 일어나지 않는다 - POST(registerPractice)와 PATCH(updatePracticeInfo) 두 경로 모두에 적용했다 Closes #317 --- .../common/web/AppVersionResolver.java | 17 ++ .../dto/PracticeNotificationRegisterDto.java | 5 +- .../service/PracticeNoteService.java | 22 ++- .../PracticeNotificationScheduler.java | 31 +--- .../PracticeNotificationWeekDayPolicy.java | 55 ++++++ .../common/web/AppVersionResolverTest.java | 41 +++++ .../PracticeNoteIntegrationTest.java | 174 +++++++++++++----- .../PracticeNotificationSchedulerTest.java | 57 ++---- ...PracticeNotificationWeekDayPolicyTest.java | 74 ++++++++ 9 files changed, 366 insertions(+), 110 deletions(-) create mode 100644 src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java create mode 100644 src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java diff --git a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java index 48da86c0..b9ab8d99 100644 --- a/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java +++ b/src/main/java/com/aisip/OnO/backend/common/web/AppVersionResolver.java @@ -44,4 +44,21 @@ public Optional resolve() { HttpServletRequest request = servletAttributes.getRequest(); return AppVersion.parse(request.getHeader(APP_VERSION_HEADER)); } + + /** + * 이번 요청이 {@code rawThreshold} 와 같거나 높은 버전의 앱에서 왔는가. + * + *

    모르면 아니라고 답한다. 헤더가 없거나, 읽을 수 없는 값이거나, 애초에 HTTP 요청이 아닌 + * 자리에서 불렸으면 전부 구버전으로 본다. 기준값을 읽지 못했을 때도 같다. 설정 오타 하나로 + * 모든 요청이 갑자기 신버전 취급을 받는 것보다, 아무도 신버전이 아닌 쪽이 되돌리기 쉽다. + * + *

    버전으로 동작을 가르는 곳이 늘어날 때 이 판정을 각자 들고 있으면 "모르면 구버전" 이라는 + * 규칙이 곳곳에서 조금씩 달라진다. 비교만 여기에 두고, 기준 버전과 그래서 무엇이 달라지는가는 + * 각 도메인이 정한다. 그래야 한 도메인의 설정이 다른 도메인의 동작을 끌고 가지 않는다. + */ + public boolean isAtLeast(String rawThreshold) { + return AppVersion.parse(rawThreshold) + .flatMap(threshold -> resolve().map(requested -> requested.isAtLeast(threshold))) + .orElse(false); + } } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java index b2390956..1a54c006 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/dto/PracticeNotificationRegisterDto.java @@ -15,8 +15,9 @@ public record PracticeNotificationRegisterDto( /** * 주간 반복인데 요일을 하나도 고르지 않은 상태인지 확인한다. * - *

    예전에는 이 상태가 크론 변환에서 매일 발송으로 되돌아갔다. 사용자는 특정 요일만 - * 고른 줄 알면서 매일 알림을 받았고, 요청이 잘못됐다는 신호도 없었다. + *

    이 상태는 크론 변환에서 매일 발송으로 되돌아간다. 사용자는 특정 요일만 고른 줄 알면서 + * 매일 알림을 받는다. 그래서 신버전 앱 요청은 진입부에서 400 으로 막는다. 요일을 고르라는 + * 검증이 없는 구버전 앱 요청은 예전 서버와 같게 매일로 저장한다. */ public boolean isWeeklyWithoutWeekDays() { return WEEKLY.equalsIgnoreCase(repeatType) && (weekDays == null || weekDays.isEmpty()); diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java index a01b2cc8..eed1b83c 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNoteService.java @@ -60,6 +60,8 @@ public class PracticeNoteService { private final CustomEmojiValidator customEmojiValidator; + private final PracticeNotificationWeekDayPolicy weekDayPolicy; + private PracticeNote getPracticeEntity(Long practiceId, Long userId){ PracticeNote practiceNote = practiceNoteRepository.findById(practiceId) @@ -214,16 +216,26 @@ private void deletePracticeWithoutOwnerCheck(Long practiceId) { } /** - * 주간 반복 알림에 요일이 하나도 없으면 400 으로 거절한다. + * 주간 반복 알림에 요일이 하나도 없으면 신버전 앱 요청만 400 으로 거절한다. + * + *

    이 검증이 요청 진입부에 있는 이유는 복습노트 저장이나 기존 Quartz 잡 삭제가 아예 일어나지 + * 않아야 하기 때문이다. Quartz 잡 삭제는 이 트랜잭션과 함께 롤백되지 않는다. * - *

    스케줄러도 같은 검증을 하지만, 요청을 받은 자리에서 먼저 막아야 복습노트 저장이나 - * 기존 Quartz 잡 삭제가 아예 일어나지 않는다. Quartz 잡 삭제는 이 트랜잭션과 함께 - * 롤백되지 않기 때문이다. + *

    구버전 요청은 예전처럼 통과시킨다. 요일이 빈 주간 반복은 스케줄러의 크론 변환에서 + * 매일 발송으로 저장된다. 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 + * 그 사용자는 복습 세트를 영영 수정할 수 없다. 판정 기준은 + * {@link PracticeNotificationWeekDayPolicy} 한 곳에 있다. */ private void validatePracticeNotification(PracticeNotificationRegisterDto practiceNotification) { - if (practiceNotification != null && practiceNotification.isWeeklyWithoutWeekDays()) { + if (practiceNotification == null || !practiceNotification.isWeeklyWithoutWeekDays()) { + return; + } + + if (weekDayPolicy.requiresWeekDays()) { throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); } + + log.info("요일 없는 주간 반복 알림을 구버전 앱 요청으로 보고 매일 발송으로 저장한다"); } private List nullSafe(List values) { diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java index 82822261..dcec1af0 100644 --- a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationScheduler.java @@ -1,9 +1,7 @@ package com.aisip.OnO.backend.practicenote.service; -import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; -import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.quartz.*; @@ -16,8 +14,6 @@ public class PracticeNotificationScheduler { private final Scheduler scheduler; public void schedulePracticeNotification(Long userId, Long practiceId, String practiceTitle, PracticeNotificationRegisterDto dto) { - validateNotification(dto); - try { JobDetail jobDetail = JobBuilder.newJob(PracticeNotificationJob.class) .withIdentity("practice-" + practiceId, "practice-reminder") @@ -44,10 +40,6 @@ public void schedulePracticeNotification(Long userId, Long practiceId, String pr } public void updateNotification(Long userId, Long practiceId, String title, PracticeNotificationRegisterDto dto) { - // 잡을 지운 뒤에 검증에 걸리면 기존 알림만 사라진다. Quartz 잡 삭제는 서비스 트랜잭션과 - // 함께 롤백되지 않으므로, 지우기 전에 먼저 막는다. - validateNotification(dto); - deleteNotification(practiceId); schedulePracticeNotification(userId, practiceId, title, dto); } @@ -61,18 +53,6 @@ public void deleteNotification(Long practiceId) { } } - /** - * 주간 반복인데 요일이 비어 있으면 거절한다. - * - *

    예전에는 이 요청이 아래 크론 변환의 매일 폴백으로 흘러가, 사용자가 고르지도 않은 - * 매일 알림이 등록됐다. 잘못된 요청이라는 신호 없이 동작만 달라지는 쪽이 더 나쁘다. - */ - private void validateNotification(PracticeNotificationRegisterDto dto) { - if (dto.isWeeklyWithoutWeekDays()) { - throw new ApplicationException(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); - } - } - private String convertDtoToCron(PracticeNotificationRegisterDto dto) { int hour = dto.hour(); int minute = dto.minute(); @@ -80,9 +60,8 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { if ("daily".equalsIgnoreCase(dto.repeatType())) { // 매일 지정된 시각에 실행 return String.format("0 %d %d ? * *", minute, hour); - } else if ("weekly".equalsIgnoreCase(dto.repeatType())) { + } else if ("weekly".equalsIgnoreCase(dto.repeatType()) && !dto.isWeeklyWithoutWeekDays()) { // 선택한 요일에만 지정된 시각에 실행 (e.g. MON,WED,FRI) - // 요일이 비어 있는 경우는 validateNotification 이 이미 걸러 냈다. String dayString = dto.weekDays().stream() .map(this::convertDayToQuartz) .reduce((a, b) -> a + "," + b) @@ -91,8 +70,12 @@ private String convertDtoToCron(PracticeNotificationRegisterDto dto) { return String.format("0 %d %d ? * %s", minute, hour, dayString); } - // daily/weekly 가 아닌 값(null 포함)은 지금처럼 매일로 둔다. - // 구버전 앱이 repeatType 을 비워 보내는 경우까지 여기서 막으면 기존 알림이 통째로 끊긴다. + // 매일 폴백. 여기로 오는 경우는 둘이다. + // 1) daily/weekly 가 아닌 값(null 포함). 구버전 앱이 repeatType 을 비워 보낸다. + // 2) 주간 반복인데 요일이 비어 있는 구버전 앱 요청. 신버전 요청은 진입부인 + // PracticeNoteService 에서 이미 400 으로 걸러지고 여기까지 오지 않는다. + // 구버전 앱에는 요일을 고르라는 검증이 없어서, 여기서 막으면 그 사용자는 + // 복습 세트를 저장할 수도 수정할 수도 없다. 예전 서버와 같게 매일로 저장한다. return String.format("0 %d %d ? * *", minute, hour); } diff --git a/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java new file mode 100644 index 00000000..a50c06dc --- /dev/null +++ b/src/main/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicy.java @@ -0,0 +1,55 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 주간 반복 알림에 요일을 강제할 요청인지 정하는 한 곳. + * + *

    요일 없는 주간 반복을 400 으로 막는 검증(#302)이 구버전 앱 사용자를 가뒀다. + * 구버전 앱에는 요일을 고르라는 클라이언트 검증이 없어서 "매주" 만 고른 저장이 그대로 올라오고, + * 예전 서버는 그 요청을 매일 크론으로 받아 줬다. 그래서 운영 DB 에 요일이 빈 주간 알림 행이 이미 있고, + * 그 복습 세트를 연 구버전 사용자는 제목만 바꿔도 계속 400 을 받는다. 앱을 올리기 전에는 빠져나갈 길이 없다. + * + *

    그래서 요청이 온 앱 버전으로 가른다. 기준 버전 이상에서 온 요청만 막고, 그 아래는 예전처럼 + * 매일 크론으로 저장한다. 프론트는 신버전에서 요일을 강제하므로 신버전 사용자는 이 조합을 만들 수 없다. + * + *

    미션의 {@code LegacyAccrualPolicy} 를 그대로 쓰지 않은 이유. 거기에는 자동 적립을 통째로 + * 멈추는 비상 스위치({@code ono.mission.legacy-accrual.enabled})가 붙어 있다. 그 스위치를 내렸을 때 + * 복습 알림 검증까지 같이 움직이면, XP 사고를 막으려고 끈 설정이 알림 저장 동작을 조용히 바꾼다. + * 공용으로 두는 것은 {@link AppVersionResolver#isAtLeast(String)} 의 버전 비교까지다. + */ +@Component +@RequiredArgsConstructor +public class PracticeNotificationWeekDayPolicy { + + private final AppVersionResolver appVersionResolver; + + /** + * 요일 없는 주간 반복을 거절하기 시작하는 첫 앱 버전. + * + *

    기본값이 {@code 4.0.0} 인 근거는 헤더 자체가 이 버전 라인에서 처음 붙는다 는 것이다 + * (AI-SIP/OnO_FRONT#214, 프론트 {@code pubspec.yaml} 이 {@code 4.0.0+70}). 헤더를 안 보내는 + * 스토어 빌드는 기준값이 무엇이든 구버전으로 떨어지므로, 이 값은 헤더를 보내는 앱 중 + * 어디까지를 새 앱으로 볼지만 가른다. 헤더를 붙인 커밋과 요일을 강제하는 클라이언트 검증은 + * 같은 미출시 빌드에 들어 있다. + * + *

    설정값으로 둔 이유는 요일 강제가 빠진 빌드가 뒤늦게 드러났을 때 배포 없이 기준을 올려 + * 되돌리기 위해서다. + */ + @Value("${ono.practice-note.week-days-required-version:4.0.0}") + private String weekDaysRequiredVersion; + + /** + * 이번 요청에서 주간 반복에 요일을 강제하는가. + * + *

    모르면 강제하지 않는다. 헤더가 없는 요청은 구버전이다. 잘못 보면 신버전 사용자가 + * 요일 없는 주간 알림을 하나 저장할 뿐이지만, 반대로 틀리면 구버전 사용자는 복습 세트를 + * 영영 수정할 수 없다. + */ + public boolean requiresWeekDays() { + return appVersionResolver.isAtLeast(weekDaysRequiredVersion); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java index d03dd724..b206434a 100644 --- a/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java +++ b/src/test/java/com/aisip/OnO/backend/common/web/AppVersionResolverTest.java @@ -79,6 +79,47 @@ void emptyOnThreadWithoutInheritedContext() throws Exception { } } + @Test + @DisplayName("기준 버전과 같거나 높으면 참이다") + void isAtLeastWhenHeaderMeetsThreshold() { + bindRequestWithHeader("4.0.0+70"); + + assertThat(resolver.isAtLeast("4.0.0")).isTrue(); + assertThat(resolver.isAtLeast("3.9.9")).isTrue(); + } + + @Test + @DisplayName("기준 버전보다 낮으면 거짓이다") + void isAtLeastFalseBelowThreshold() { + bindRequestWithHeader("3.6.0+67"); + + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + } + + @Test + @DisplayName("헤더가 없거나 읽을 수 없거나 HTTP 요청이 아니면 전부 거짓이다") + void isAtLeastFalseWhenVersionUnknown() { + // 버전으로 동작을 가르는 쪽의 규칙이 "모르면 구버전" 이라 세 경우가 같은 답이어야 한다. + bindRequestWithHeader(null); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + + bindRequestWithHeader("abc"); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + + RequestContextHolder.resetRequestAttributes(); + assertThat(resolver.isAtLeast("4.0.0")).isFalse(); + } + + @Test + @DisplayName("기준값을 읽을 수 없으면 아무도 신버전이 아니다") + void isAtLeastFalseWhenThresholdUnparsable() { + // 설정 오타 하나로 모든 요청이 갑자기 신버전 취급을 받으면 안 된다. + bindRequestWithHeader("9.9.9+999"); + + assertThat(resolver.isAtLeast("사.영.영")).isFalse(); + assertThat(resolver.isAtLeast("")).isFalse(); + } + private void bindRequestWithHeader(String appVersion) { MockHttpServletRequest request = new MockHttpServletRequest(); if (appVersion != null) { diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java index aff4d785..ddedde25 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/integration/PracticeNoteIntegrationTest.java @@ -1,6 +1,7 @@ package com.aisip.OnO.backend.practicenote.integration; import com.aisip.OnO.backend.common.emoji.CustomEmojiErrorCase; +import com.aisip.OnO.backend.common.web.AppVersionResolver; import com.aisip.OnO.backend.folder.entity.Folder; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteCompleteRequestDto; import com.aisip.OnO.backend.practicenote.dto.PracticeNoteDeleteRequestDto; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import java.util.List; @@ -35,6 +37,12 @@ @DisplayName("복습노트 API") class PracticeNoteIntegrationTest extends PracticeNoteTestSupport { + /** 요일을 고르게 강제하는 앱. 프론트가 보내는 형식 그대로다. */ + private static final String NEW_APP_VERSION = "4.0.0+70"; + + /** 그 검증이 없는 구버전 앱. */ + private static final String LEGACY_APP_VERSION = "3.6.0+67"; + private Long userId; private Long otherUserId; private List problems; @@ -196,25 +204,6 @@ void registersWeeklyNotification() throws Exception { userId, practiceNoteId.longValue(), "주간 복습", weeklyNotification(List.of(1, 3, 5))); } - @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 아무것도 저장하지 않는다") - void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { - String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( - null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); - - mockMvc.perform(post("/api/practiceNotes") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode") - .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); - - // 예전에는 매일 발송으로 되돌아가 복습노트까지 그대로 만들어졌다. - assertThat(practiceNoteRepository.findAllByUserId(userId)).isEmpty(); - verify(practiceNotificationScheduler, never()) - .schedulePracticeNotification(any(), any(), any(), any()); - } - @Test @DisplayName("다른 사용자의 문제로는 만들 수 없다") void rejectsOtherUserProblem() throws Exception { @@ -362,28 +351,6 @@ void updatesWithoutProblemLists() throws Exception { .isEqualTo("제목만 변경"); } - @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 이고 기존 알림을 건드리지 않는다") - void rejectsWeeklyNotificationWithoutWeekDays() throws Exception { - PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); - String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( - practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); - - mockMvc.perform(patch("/api/practiceNotes") - .contentType(MediaType.APPLICATION_JSON) - .content(body)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.errorCode") - .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); - - PracticeNote unchanged = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); - assertThat(unchanged.getTitle()).isEqualTo("복습"); - assertThat(unchanged.getPracticeNotification().getRepeatType()).isEqualTo("daily"); - // Quartz 잡 삭제는 트랜잭션과 함께 롤백되지 않는다. 아예 호출되면 안 된다. - verify(practiceNotificationScheduler, never()).updateNotification(any(), any(), any(), any()); - verify(practiceNotificationScheduler, never()).deleteNotification(any()); - } - @Test @DisplayName("다른 사용자의 복습노트는 수정할 수 없다") void rejectsOtherUserPracticeNote() throws Exception { @@ -489,4 +456,129 @@ void returns401WithoutAuthentication() throws Exception { .andExpect(status().isUnauthorized()); } } + + /** + * 요일 없는 주간 반복 알림을 앱 버전으로 가른다. + * + *

    신버전 앱은 요일을 고르게 강제하므로 이 조합을 만들 수 없다. 구버전 앱에는 그 검증이 없어서 + * 사용자가 "매주" 만 고른 저장이 그대로 올라오고, 예전 서버는 매일 발송으로 받아 줬다. 그래서 운영 DB 에 + * 요일이 빈 주간 알림 행이 이미 있고, 그 복습 세트를 연 구버전 사용자는 제목만 바꿔도 계속 400 을 받는다. + * 앱을 올리기 전에는 빠져나갈 길이 없다. + * + *

    헤더가 없는 요청은 구버전이다. 스토어에 나간 빌드가 헤더를 보내지 않는다. + * + *

    구버전 요청이 매일 크론으로 변환되는지는 {@code PracticeNotificationSchedulerTest} 가 본다. + * 여기서는 요청이 통과해 저장되고 스케줄러까지 그 설정 그대로 닿는지를 본다. + */ + @Nested + @DisplayName("요일 없는 주간 반복 알림은 앱 버전으로 가른다") + class WeekDaysRequiredByAppVersion { + + @Test + @DisplayName("신버전 앱의 등록 요청은 400 이고 아무것도 저장하지 않는다") + void rejectsRegisterFromNewApp() throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(post("/api/practiceNotes"), NEW_APP_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + assertThat(practiceNoteRepository.findAllByUserId(userId)).isEmpty(); + verify(practiceNotificationScheduler, never()) + .schedulePracticeNotification(any(), any(), any(), any()); + } + + @Test + @DisplayName("구버전 앱의 등록 요청은 예전처럼 받아 준다") + void acceptsRegisterFromLegacyApp() throws Exception { + registersWeeklyWithoutWeekDays(LEGACY_APP_VERSION); + } + + @Test + @DisplayName("헤더가 없는 등록 요청도 구버전으로 보고 받아 준다") + void acceptsRegisterWithoutAppVersionHeader() throws Exception { + registersWeeklyWithoutWeekDays(null); + } + + @Test + @DisplayName("신버전 앱의 수정 요청은 400 이고 기존 알림을 건드리지 않는다") + void rejectsUpdateFromNewApp() throws Exception { + PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); + String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( + practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(patch("/api/practiceNotes"), NEW_APP_VERSION) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.errorCode") + .value(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED.getErrorCode())); + + PracticeNote unchanged = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); + assertThat(unchanged.getTitle()).isEqualTo("복습"); + assertThat(unchanged.getPracticeNotification().getRepeatType()).isEqualTo("daily"); + // Quartz 잡 삭제는 트랜잭션과 함께 롤백되지 않는다. 아예 호출되면 안 된다. + verify(practiceNotificationScheduler, never()).updateNotification(any(), any(), any(), any()); + verify(practiceNotificationScheduler, never()).deleteNotification(any()); + } + + @Test + @DisplayName("구버전 앱의 수정 요청은 예전처럼 받아 준다") + void acceptsUpdateFromLegacyApp() throws Exception { + updatesWeeklyWithoutWeekDays(LEGACY_APP_VERSION); + } + + @Test + @DisplayName("헤더가 없는 수정 요청도 구버전으로 보고 받아 준다") + void acceptsUpdateWithoutAppVersionHeader() throws Exception { + updatesWeeklyWithoutWeekDays(null); + } + + private void registersWeeklyWithoutWeekDays(String appVersion) throws Exception { + String body = objectMapper.writeValueAsString(new PracticeNoteRegisterDto( + null, "요일 없는 주간 복습", List.of(), weeklyNotification(List.of()))); + + String response = mockMvc.perform(fromApp(post("/api/practiceNotes"), appVersion) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + Number practiceNoteId = JsonPath.read(response, "$.data"); + assertThat(practiceNoteRepository.findById(practiceNoteId.longValue())) + .isPresent() + .hasValueSatisfying(saved -> + assertThat(saved.getPracticeNotification().getRepeatType()).isEqualTo("weekly")); + verify(practiceNotificationScheduler).schedulePracticeNotification( + userId, practiceNoteId.longValue(), "요일 없는 주간 복습", weeklyNotification(List.of())); + } + + private void updatesWeeklyWithoutWeekDays(String appVersion) throws Exception { + PracticeNote practiceNote = savePracticeNote(userId, "복습", List.of(), dailyNotification()); + String body = objectMapper.writeValueAsString(new PracticeNoteUpdateDto( + practiceNote.getId(), "요일 없는 주간", List.of(), List.of(), weeklyNotification(List.of()))); + + mockMvc.perform(fromApp(patch("/api/practiceNotes"), appVersion) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()); + + PracticeNote updated = practiceNoteRepository.findById(practiceNote.getId()).orElseThrow(); + assertThat(updated.getTitle()).isEqualTo("요일 없는 주간"); + assertThat(updated.getPracticeNotification().getRepeatType()).isEqualTo("weekly"); + verify(practiceNotificationScheduler).updateNotification( + userId, practiceNote.getId(), "요일 없는 주간", weeklyNotification(List.of())); + } + } + + /** {@code null} 이면 헤더를 아예 붙이지 않는다. 헤더를 안 보내는 구버전 앱이다. */ + private MockHttpServletRequestBuilder fromApp(MockHttpServletRequestBuilder request, String appVersion) { + return appVersion == null + ? request + : request.header(AppVersionResolver.APP_VERSION_HEADER, appVersion); + } } diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java index c9a1b599..4359ba73 100644 --- a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationSchedulerTest.java @@ -1,8 +1,6 @@ package com.aisip.OnO.backend.practicenote.service; -import com.aisip.OnO.backend.common.exception.ApplicationException; import com.aisip.OnO.backend.practicenote.dto.PracticeNotificationRegisterDto; -import com.aisip.OnO.backend.practicenote.exception.PracticeNoteErrorCase; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -27,7 +25,6 @@ import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.inOrder; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; /** @@ -92,35 +89,23 @@ void weeklyCronForEveryWeekDay() throws Exception { } @Test - @DisplayName("주간 반복인데 요일이 비어 있으면 400 으로 거절한다") - void weeklyWithoutWeekDaysIsRejected() throws Exception { - PracticeNotificationRegisterDto dto = - new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of()); - - assertThatThrownBy(() -> - notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + @DisplayName("주간 반복인데 요일이 비어 있으면 매일 크론으로 되돌아간다") + void weeklyWithoutWeekDaysFallsBackToDaily() throws Exception { + // 신버전 요청은 진입부(PracticeNoteService)에서 이미 400 으로 걸러진다. 여기까지 오는 것은 + // 요일을 고르라는 검증이 없는 구버전 앱 요청이고, 예전 서버와 같게 매일로 저장돼야 한다. + String cron = scheduleAndCaptureCron( + new PracticeNotificationRegisterDto(7, 7, 0, "weekly", List.of())); - // 매일 발송으로 되돌아가던 폴백이 없어야 한다. 잡 자체가 등록되면 안 된다. - verify(quartzScheduler, never()).addJob(any(), anyBoolean()); - verify(quartzScheduler, never()).scheduleJob(any(Trigger.class)); + assertThat(cron).isEqualTo("0 0 7 ? * *"); } @Test - @DisplayName("주간 반복인데 요일이 null 이어도 400 으로 거절한다") - void weeklyWithNullWeekDaysIsRejected() throws Exception { - PracticeNotificationRegisterDto dto = - new PracticeNotificationRegisterDto(7, 7, 0, "WEEKLY", null); - - assertThatThrownBy(() -> - notificationScheduler.schedulePracticeNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); + @DisplayName("주간 반복인데 요일이 null 이어도 매일 크론으로 되돌아간다") + void weeklyWithNullWeekDaysFallsBackToDaily() throws Exception { + String cron = scheduleAndCaptureCron( + new PracticeNotificationRegisterDto(7, 7, 0, "WEEKLY", null)); - verify(quartzScheduler, never()).addJob(any(), anyBoolean()); + assertThat(cron).isEqualTo("0 0 7 ? * *"); } @Test @@ -195,20 +180,16 @@ void wrapsDeleteException() throws Exception { } @Test - @DisplayName("갱신이 요일 없는 주간 반복이면 기존 잡을 지우기 전에 거절한다") - void updateNotificationRejectsBeforeDeleting() throws Exception { + @DisplayName("갱신이 요일 없는 주간 반복이어도 매일 크론으로 다시 등록한다") + void updateNotificationWithoutWeekDaysFallsBackToDaily() throws Exception { PracticeNotificationRegisterDto dto = new PracticeNotificationRegisterDto(7, 21, 0, "weekly", List.of()); - assertThatThrownBy(() -> - notificationScheduler.updateNotification(USER_ID, PRACTICE_ID, "복습 세트", dto)) - .isInstanceOf(ApplicationException.class) - .extracting(e -> ((ApplicationException) e).getErrorCase()) - .isEqualTo(PracticeNoteErrorCase.PRACTICE_NOTIFICATION_WEEK_DAYS_REQUIRED); - - // Quartz 삭제는 서비스 트랜잭션과 함께 롤백되지 않는다. 지우고 나서 실패하면 - // 사용자가 쓰던 알림만 사라진다. - verify(quartzScheduler, never()).deleteJob(any()); + notificationScheduler.updateNotification(USER_ID, PRACTICE_ID, "복습 세트", dto); + + ArgumentCaptor triggerCaptor = ArgumentCaptor.forClass(Trigger.class); + verify(quartzScheduler).scheduleJob(triggerCaptor.capture()); + assertThat(((CronTrigger) triggerCaptor.getValue()).getCronExpression()).isEqualTo("0 0 21 ? * *"); } @Test diff --git a/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java new file mode 100644 index 00000000..89f74eab --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/practicenote/service/PracticeNotificationWeekDayPolicyTest.java @@ -0,0 +1,74 @@ +package com.aisip.OnO.backend.practicenote.service; + +import com.aisip.OnO.backend.common.web.AppVersionResolver; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.test.util.ReflectionTestUtils; + +import java.lang.reflect.Field; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * 요일 없는 주간 반복 알림을 어떤 요청에서 막을지 정하는 판정. + * + *

    버전 비교 자체는 {@code AppVersionResolverTest} 가 본다. 여기서는 이 도메인이 무엇을 기준으로 + * 삼는가와, 그 기준이 미션 도메인의 설정에 끌려가지 않는가를 본다. + */ +@ExtendWith(MockitoExtension.class) +@DisplayName("주간 알림 요일 강제 판정") +class PracticeNotificationWeekDayPolicyTest { + + @Mock + private AppVersionResolver appVersionResolver; + + @InjectMocks + private PracticeNotificationWeekDayPolicy policy; + + @Test + @DisplayName("기준 버전은 설정으로 바꿀 수 있고 기본값은 4.0.0 이다") + void thresholdIsConfigurable() throws NoSuchFieldException { + // 코드에 박으면 기준을 되돌릴 때마다 배포해야 한다. 값이 아니라 선언을 본다. + Field field = PracticeNotificationWeekDayPolicy.class.getDeclaredField("weekDaysRequiredVersion"); + + assertThat(field.getAnnotation(Value.class).value()) + .as("프로퍼티 이름과 기본값은 운영에서 기준을 옮길 때 쓰는 계약이다") + .isEqualTo("${ono.practice-note.week-days-required-version:4.0.0}"); + } + + @Test + @DisplayName("기준 버전 이상에서 온 요청이면 요일을 강제한다") + void requiresWeekDaysForNewApp() { + ReflectionTestUtils.setField(policy, "weekDaysRequiredVersion", "4.0.0"); + given(appVersionResolver.isAtLeast("4.0.0")).willReturn(true); + + assertThat(policy.requiresWeekDays()).isTrue(); + } + + @Test + @DisplayName("그 아래거나 버전을 모르면 강제하지 않는다") + void doesNotRequireWeekDaysForLegacyApp() { + // 헤더가 없는 요청도 여기로 떨어진다. 잘못 보면 구버전 사용자가 복습 세트를 영영 수정할 수 없다. + ReflectionTestUtils.setField(policy, "weekDaysRequiredVersion", "4.0.0"); + given(appVersionResolver.isAtLeast("4.0.0")).willReturn(false); + + assertThat(policy.requiresWeekDays()).isFalse(); + } + + @Test + @DisplayName("미션 도메인에 기대지 않는다") + void doesNotDependOnMissionDomain() { + // 미션의 LegacyAccrualPolicy 에는 자동 적립을 통째로 끄는 비상 스위치가 붙어 있다. + // 그 스위치를 내렸을 때 복습 알림 검증까지 같이 움직이면 안 된다. + assertThat(Arrays.stream(PracticeNotificationWeekDayPolicy.class.getDeclaredFields()) + .map(field -> field.getType().getName())) + .noneMatch(type -> type.startsWith("com.aisip.OnO.backend.mission")); + } +} From 277daef22918fa0ae71663d66da0f769e166b370 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sat, 19 Sep 2026 20:06:21 +0900 Subject: [PATCH 129/130] =?UTF-8?q?[Fix]=20=ED=8F=B4=EB=8D=94=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=9E=A0=EA=B8=88=EC=9D=B4=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=ED=8F=B4=EB=8D=94=EC=99=80=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=EB=B2=88=EC=A7=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #233 을 막으려고 넣은 lockAllByUserId 가 사용자 폴더 전체를 배타 잠금으로 잡아서, 삭제와 아무 상관 없는 폴더로 들어오는 문제 등록까지 삭제가 끝날 때까지 줄을 섰다. 기다리는 요청이 커넥션을 하나씩 물고 있어서 Hikari 풀(기본 10개)이 바닥나면 다른 사용자의 단순 조회까지 connection-timeout 30초를 채우고 실패했다 - deleteFoldersWithProblems 는 이제 삭제 대상 서브트리만 잡는다. lockAllByIdIn 으로 대상 폴더를 기본 키로 잠그고, lockAllByParentFolderIdIn 으로 한 단계씩 내려가며 하위 폴더를 잠근다. 내려가는 조회를 전부 잠금 조회로 둬야 REPEATABLE READ 스냅숏이 박히지 않아서, 잠금을 기다리다 커밋된 등록도 삭제 대상에 그대로 들어온다 - 그 사이에 새 폴더가 끼어들 자리는 없다. 어떤 폴더 아래에 폴더를 만들거나 옮기려면 findParentFolderForShare 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 기다렸다가 삭제된 부모를 보고 FOLDER_NOT_FOUND 로 거절된다 - 전체 폴더 삭제(deleteAllUserFoldersWithProblems)는 어차피 사용자 폴더 전부가 대상이라 lockAllByUserId 를 그대로 뒀다 - 복습 알림 취소가 문제마다 problem_id 범위 UPDATE 를 돌면서 uq_problem_review_reminder_seq 인덱스 끝의 갭까지 next-key lock 으로 잡고 있었다. problem_id 는 계속 커지므로 그 갭은 대개 supremum 이고, 폴더 삭제가 커밋될 때까지 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 사용자와 무관하게 막혔다. 취소 대상을 먼저 읽고 기본 키로 UPDATE 하도록 바꿨다 - performance_schema 로 잠금 범위를 고정하는 FolderDeleteLockBlastRadiusTest 를 추가했다. 삭제 트랜잭션이 잠그는 folder 행이 삭제 대상 서브트리뿐인지, idx_folder_user_id 에 잠금이 안 잡히는지, 무관한 폴더와 다른 계정의 등록이 안 막히는지를 본다 - 고아 데이터 쪽 계약은 FolderDeleteProblemRegisterRaceTest 9개가 그대로 통과한다 Closes #319 --- .../folder/repository/FolderRepository.java | 32 +- .../backend/folder/service/FolderService.java | 72 ++-- .../ProblemReviewReminderRepository.java | 31 +- .../ProblemReviewReminderService.java | 14 +- .../FolderDeleteLockBlastRadiusTest.java | 337 ++++++++++++++++++ .../ProblemReviewReminderSenderTest.java | 2 +- 6 files changed, 450 insertions(+), 38 deletions(-) create mode 100644 src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java diff --git a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java index 07debf20..389c6fb8 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/folder/repository/FolderRepository.java @@ -36,10 +36,14 @@ public interface FolderRepository extends JpaRepository, FolderRep List findAllByIdInForShare(@Param("folderIds") Collection folderIds); /** - * 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) + * 전체 폴더 삭제 전에 사용자의 폴더 전체를 배타 잠금(FOR UPDATE)으로 잡는다. (#233) * - *

    삭제할 폴더만이 아니라 사용자 폴더 전체를 잡는 이유는 하위 폴더 때문이다. 하위 폴더 목록은 - * 잠그기 전에는 알 수 없고, 하위 폴더로 들어오는 등록도 막아야 한다. + *

    폴더를 골라 지우는 경로는 이걸 쓰지 않는다. 사용자 폴더 전체를 잡으면 삭제와 아무 상관 없는 + * 폴더로 들어오는 등록까지 전부 줄을 서고, 기다리는 요청이 커넥션을 하나씩 물고 있어서 + * 커넥션 풀이 바닥난다. 그러면 다른 사용자의 요청까지 커넥션을 못 받고 죽는다. (#319) + * 그쪽은 {@link #lockAllByIdIn} 과 {@link #lockAllByParentFolderIdIn} 으로 삭제 대상 서브트리만 잡는다. + * + *

    여기는 어차피 사용자 폴더 전부가 삭제 대상이라 범위를 줄일 것이 없다. 계정 정리 때만 타는 드문 경로다. * *

    반드시 트랜잭션의 첫 조회여야 한다. REPEATABLE READ 는 첫 일반 조회 시점에 스냅숏을 만든다. * 잠금보다 먼저 일반 조회를 하면, 잠금을 기다리는 동안 커밋된 등록이 그 스냅숏에 보이지 않아 @@ -49,6 +53,28 @@ public interface FolderRepository extends JpaRepository, FolderRep @Query("select f from Folder f where f.userId = :userId") List lockAllByUserId(@Param("userId") Long userId); + /** + * 삭제 대상 폴더를 기본 키로 배타 잠금(FOR UPDATE)한다. (#319) + * + *

    {@link #lockAllByUserId} 와 달리 {@code idx_folder_user_id} 등치 스캔을 타지 않아 + * next-key lock 의 갭이 인접 사용자 구간까지 덮지 않는다. 기본 키 등치 조회는 {@code REC_NOT_GAP} 이다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.id in :folderIds") + List lockAllByIdIn(@Param("folderIds") Collection folderIds); + + /** + * 주어진 폴더들의 바로 아래 하위 폴더를 배타 잠금(FOR UPDATE)으로 읽는다. (#319) + * + *

    삭제 대상 서브트리를 한 단계씩 내려가며 잠그는 데 쓴다. 일반 조회로 내려가면 안 된다. + * 일반 조회는 그 시점의 스냅숏을 고정하므로, 아직 잠그지 못한 하위 폴더에 그 뒤로 커밋된 + * 문제나 폴더가 보이지 않아 #233 의 고아 데이터가 그대로 돌아온다. 잠금 조회는 스냅숏이 아니라 + * 최신 행을 읽는다. + */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select f from Folder f where f.parentFolder.id in :parentFolderIds") + List lockAllByParentFolderIdIn(@Param("parentFolderIds") Collection parentFolderIds); + /** * 훈장 '정리의 신' 판정용. 루트 폴더는 빼고 센다. * diff --git a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java index 2f1645b5..8e7a26f9 100644 --- a/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java +++ b/src/main/java/com/aisip/OnO/backend/folder/service/FolderService.java @@ -186,11 +186,8 @@ public void updateFolder(FolderRegisterDto folderRegisterDto, Long userId) { } public void deleteFoldersWithProblems(Long userId, List folderIds) { - // 같은 폴더로 들어오는 문제 등록과 순서를 맞춘다. 이 줄보다 앞에 조회를 두면 안 된다. (#233) - folderRepository.lockAllByUserId(userId); - - // 삭제할 모든 폴더의 ID 조회 (하위 폴더 포함) - Set allFolderIds = getAllFolderIdsIncludingSubFolders(userId, folderIds); + // 삭제 대상 서브트리를 잠그면서 하위 폴더까지 모은다. 이 줄보다 앞에 조회를 두면 안 된다. (#233, #319) + Set allFolderIds = lockSubtreeAndCollectFolderIds(userId, folderIds); problemService.deleteAllByFolderIds(userId, allFolderIds); @@ -206,40 +203,57 @@ public void deleteAllUserFoldersWithProblems(Long userId) { deleteAllUserFolders(userId); } - public Set getAllFolderIdsIncludingSubFolders(Long userId, List folderIds) { - Set allFolderIds = new HashSet<>(); + /** + * 삭제 대상 폴더와 그 하위 폴더 전부를 배타 잠금으로 잡으면서 ID 를 모은다. (#233, #319) + * + *

    호출자의 트랜잭션에서 가장 먼저 실행돼야 한다. 여기서 쓰는 조회는 전부 잠금 조회라 + * REPEATABLE READ 스냅숏을 고정하지 않는다. 그래서 잠금을 다 잡은 뒤에 일어나는 일반 조회가 + * "잠금을 잡은 시점 이후" 를 보게 되고, 잠금을 기다리다 커밋된 등록도 삭제 대상에 들어온다. + * 이 앞에 일반 조회를 한 줄이라도 두면 그 순간 스냅숏이 박혀 #233 의 고아 문제가 되살아난다. + * + *

    한 단계씩 내려가도 빠지는 폴더는 없다. 어떤 폴더 아래에 새 폴더를 만들거나 옮기려면 + * {@link #findParentFolderForShare} 로 그 부모를 공유 잠금해야 하는데, 우리가 배타 잠금을 쥔 뒤에는 + * 그쪽이 기다렸다가 삭제된 부모를 보고 거절된다. 아직 안 잠근 단계에서 먼저 들어온 생성은 + * 우리가 그 부모를 잠그려고 기다리는 동안 커밋되고, 그다음 잠금 조회가 최신 행을 읽어 잡아낸다. + * + *

    사용자 폴더 전체를 잡던 예전 방식({@code lockAllByUserId})은 삭제와 무관한 폴더로 들어오는 + * 등록까지 줄 세웠고, 기다리는 요청이 커넥션을 문 채로 풀을 바닥내 다른 사용자까지 죽였다. (#319) + */ + private Set lockSubtreeAndCollectFolderIds(Long userId, List folderIds) { + if (folderIds == null || folderIds.isEmpty()) { + return Set.of(); + } - for (Long folderId : folderIds) { - Folder folder = findFolderEntity(folderId, userId); + Map lockedTargets = folderRepository.lockAllByIdIn(new LinkedHashSet<>(folderIds)).stream() + .collect(Collectors.toMap(Folder::getId, folder -> folder)); + // 검증 순서는 예전과 같게 요청받은 순서대로 본다. 없음 → 소유자 불일치 → 루트 순이다. + for (Long folderId : folderIds) { + Folder folder = lockedTargets.get(folderId); + if (folder == null) { + throw new ApplicationException(FolderErrorCase.FOLDER_NOT_FOUND); + } + validateFolderOwner(folder, userId); if (folder.getParentFolder() == null) { throw new ApplicationException(FolderErrorCase.ROOT_FOLDER_CANNOT_REMOVE); } - allFolderIds.add(folder.getId()); - allFolderIds.addAll(getSubFolderIdsRecursive(folder)); } - return allFolderIds; - } - - private Set getSubFolderIdsRecursive(Folder folder) { - Set subFolderIds = new HashSet<>(); - collectSubFolderIds(folder, subFolderIds); - return subFolderIds; - } + // 이미 잠근 폴더는 다시 타고 들어가지 않는다. 부모-자식에 순환이 남아 있어도(과거 데이터) 한 번만 훑는다. + Set allFolderIds = new LinkedHashSet<>(lockedTargets.keySet()); + Collection currentLevel = new ArrayList<>(allFolderIds); - /** - * 이미 방문한 폴더는 다시 타고 들어가지 않는다. - * - *

    부모-자식 관계에 순환이 남아 있으면(과거 데이터 등) 단순 재귀는 StackOverflowError 로 - * 삭제 요청 전체를 500 으로 떨어뜨린다. 방문 집합으로 한 번만 훑는다. - */ - private void collectSubFolderIds(Folder folder, Set collectedIds) { - for (Folder subFolder : folder.getSubFolderList()) { - if (collectedIds.add(subFolder.getId())) { - collectSubFolderIds(subFolder, collectedIds); + while (!currentLevel.isEmpty()) { + List nextLevel = new ArrayList<>(); + for (Folder subFolder : folderRepository.lockAllByParentFolderIdIn(currentLevel)) { + if (allFolderIds.add(subFolder.getId())) { + nextLevel.add(subFolder.getId()); + } } + currentLevel = nextLevel; } + + return allFolderIds; } /** diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java index 16177889..cadc3a96 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderRepository.java @@ -8,6 +8,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.Collection; import java.util.List; public interface ProblemReviewReminderRepository extends JpaRepository { @@ -107,14 +108,36 @@ int recoverStuckRows( @Param("stuckBefore") LocalDateTime stuckBefore ); - @Modifying - @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") - int cancelByProblem( + /** + * 취소할 예약의 ID 만 먼저 읽는다. 잠금을 잡지 않는 일반 조회다. (#319) + * + * @see #cancelByIdIn + */ + @Query("SELECT r.id FROM ProblemReviewReminder r WHERE r.problemId = :problemId AND r.status IN :pendingStatuses AND r.deletedAt IS NULL") + List findPendingIdsByProblem( @Param("problemId") Long problemId, - @Param("canceled") ProblemReviewReminderStatus canceled, @Param("pendingStatuses") List pendingStatuses ); + /** + * 예약을 기본 키로 취소한다. (#319) + * + *

    예전에는 {@code WHERE problem_id = ?} 로 한 번에 UPDATE 했다. 그러면 REPEATABLE READ 에서 + * {@code uq_problem_review_reminder_seq(problem_id, sequence)} 를 범위로 훑으면서 next-key lock 이 + * 마지막 일치 항목 뒤의 갭까지 잡는다. {@code problem_id} 는 계속 커지므로 그 갭은 대개 + * supremum(인덱스 끝) 이고, 그러면 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 전부 막힌다. 문제 등록은 커밋 직후 예약을 넣기 때문에 + * ({@code scheduleForNewProblems}), 폴더 하나 지우는 동안 다른 계정의 등록까지 잠금 대기에 걸렸다. + * + *

    기본 키 등치 조회는 {@code REC_NOT_GAP} 이라 갭을 잡지 않는다. + */ + @Modifying + @Query("UPDATE ProblemReviewReminder r SET r.status = :canceled WHERE r.id IN :ids") + int cancelByIdIn( + @Param("ids") Collection ids, + @Param("canceled") ProblemReviewReminderStatus canceled + ); + @Modifying @Query("UPDATE ProblemReviewReminder r SET r.problemMemoSnapshot = :memo, r.problemReferenceSnapshot = :reference WHERE r.problemId = :problemId AND r.status = :scheduled AND r.deletedAt IS NULL") int refreshSnapshot( diff --git a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java index c0ce8340..c821e971 100644 --- a/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java +++ b/src/main/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderService.java @@ -74,9 +74,21 @@ public void scheduleForNewProblems(Long userId, List취소 대상을 먼저 읽고 기본 키로 UPDATE 한다. {@code problem_id} 조건으로 바로 UPDATE 하면 + * next-key lock 이 인덱스 끝의 갭까지 잡아서, 폴더 삭제가 커밋될 때까지 다른 계정의 문제 등록이 + * 전부 예약 INSERT 에서 막혔다. (#319, {@link ProblemReviewReminderRepository#cancelByIdIn}) + */ @Transactional public void cancelPendingByProblem(Long problemId) { - int count = repository.cancelByProblem(problemId, CANCELED, PENDING_STATUSES); + List pendingIds = repository.findPendingIdsByProblem(problemId, PENDING_STATUSES); + if (pendingIds.isEmpty()) { + return; + } + + int count = repository.cancelByIdIn(pendingIds, CANCELED); if (count > 0) { log.info("[ReviewReminder] 문제 삭제로 알림 취소 - problemId: {}, {}건", problemId, count); } diff --git a/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java new file mode 100644 index 00000000..f304145e --- /dev/null +++ b/src/test/java/com/aisip/OnO/backend/concurrency/FolderDeleteLockBlastRadiusTest.java @@ -0,0 +1,337 @@ +package com.aisip.OnO.backend.concurrency; + +import com.aisip.OnO.backend.folder.entity.Folder; +import com.aisip.OnO.backend.folder.service.FolderService; +import com.aisip.OnO.backend.problem.dto.ProblemRegisterV2Dto; +import com.aisip.OnO.backend.problem.service.ProblemService; +import com.aisip.OnO.backend.problem.support.ProblemTestSupport; +import com.aisip.OnO.backend.support.TestContainers; +import com.aisip.OnO.backend.user.entity.User; +import com.github.gavlyukovskiy.boot.jdbc.decorator.DecoratedDataSource; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import javax.sql.DataSource; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.fail; + +/** + * 폴더 삭제 잠금이 삭제 대상 밖으로 번지지 않는지 본다. (#319) + * + *

    폴더 하나를 지우는 동안 두 군데서 잠금이 새어 나갔다. + *

      + *
    • #233 을 막으려고 넣은 폴더 잠금이 삭제 대상 서브트리가 아니라 사용자 폴더 전체를 잡았다. + * 삭제와 아무 상관 없는 폴더로 들어오는 등록까지 삭제가 끝날 때까지 기다렸다.
    • + *
    • 문제마다 도는 복습 알림 취소가 {@code problem_id} 범위 UPDATE 라 + * {@code uq_problem_review_reminder_seq} 인덱스 끝의 갭까지 next-key lock 으로 잡았다. + * {@code problem_id} 는 계속 커지므로 그 뒤에 등록되는 모든 문제의 예약 INSERT 가 + * 사용자와 무관하게 막혔다.
    • + *
    + * + *

    기다리는 요청은 하나씩 커넥션을 물고 있다. 그래서 대기가 길어지면 Hikari 풀(기본 10개)이 바닥나고, + * 폴더와도 그 사용자와도 무관한 요청까지 {@code connection-timeout}(기본 30초) 뒤에 실패한다. + * dev 에서 잰 30.2초가 이 값이다. + * + *

    그래서 여기서는 고아 데이터가 아니라 영향 범위를 본다. + *

      + *
    1. 삭제 트랜잭션이 잠그는 {@code folder} 행이 삭제 대상 서브트리뿐인지 ({@code performance_schema.data_locks})
    2. + *
    3. 삭제와 무관한 폴더로 들어오는 등록이 막히지 않는지
    4. + *
    5. 다른 계정의 등록이 막히지 않는지
    6. + *
    7. 등록이 몰려 들어와도 전부 막히지 않는지
    8. + *
    + * + *

    고아 데이터 쪽 계약은 {@link FolderDeleteProblemRegisterRaceTest} 가 그대로 들고 있다. + * 잠금 범위를 줄이는 변경은 두 파일이 같이 통과해야 한다. + */ +@DisplayName("동시성 - 폴더 삭제 잠금의 영향 범위") +class FolderDeleteLockBlastRadiusTest extends ProblemTestSupport { + + private static final long STEP_TIMEOUT_SECONDS = 20; + + /** + * 잠금이 번지지 않으면 이 안에 끝난다. + * + *

    번지면 잠금 대기({@code innodb_lock_wait_timeout} 기본 50초)나 + * 커넥션 대기(Hikari {@code connection-timeout} 기본 30초)로 넘어가므로, 몇 초짜리 여유로 갈린다. + */ + private static final long NO_BLOCKING_TIMEOUT_SECONDS = 8; + + @Autowired + private ProblemService problemService; + + @Autowired + private FolderService folderService; + + @Autowired + private DataSource dataSource; + + private ExecutorService executor; + private final CountDownLatch release = new CountDownLatch(1); + + private User owner; + private Folder ownerRoot; + private Folder target; + private Folder targetChild; + private Folder untouched; + + private User other; + private Folder otherRoot; + + private int poolSize; + + @BeforeEach + void setUpFoldersAndPool() { + owner = fixtures.createUser(); + ownerRoot = fixtures.createRootFolder(owner.getId()); + target = fixtures.createFolder(owner.getId(), "삭제 대상", ownerRoot); + targetChild = fixtures.createFolder(owner.getId(), "삭제 대상 하위", target); + untouched = fixtures.createFolder(owner.getId(), "삭제와 무관한 폴더", ownerRoot); + saveProblem(owner.getId(), target); + + other = fixtures.createUser(); + otherRoot = fixtures.createRootFolder(other.getId()); + + poolSize = maximumPoolSize(); + executor = Executors.newFixedThreadPool(poolSize + 4); + } + + /** + * 한 번에 몰아 넣을 등록 수. 커넥션 풀의 절반까지만 쓴다. + * + *

    문제 등록은 커밋 직후 복습 알림 예약을 {@code AFTER_COMMIT} + {@code REQUIRES_NEW} 로 넣는다. + * 그동안 바깥 트랜잭션의 커넥션이 아직 반납되기 전이라, 요청 하나가 커넥션 두 개를 동시에 쥔다. + * 그래서 동시 등록이 풀 크기에 닿으면 폴더 삭제가 없어도 풀이 서로를 기다리며 멈추고, + * 전부 {@code connection-timeout} 을 채운다. 실제로 이 테스트에서 8건을 몰아 넣으면 + * "사용중 10, 유휴 0, 커넥션 대기중인 스레드 8" 로 멈춘다. + * + *

    그건 폴더 삭제 잠금과는 다른 원인이라 여기서 고정하지 않는다. 이 테스트가 보는 것은 + * "폴더 삭제가 무관한 등록을 붙잡고 있는가" 뿐이므로, 풀이 문제가 되지 않는 선까지만 몰아 넣는다. + */ + private int concurrentRegistrationCount() { + return Math.max(2, poolSize / 2); + } + + @AfterEach + void releaseHeldTransaction() throws InterruptedException { + // 단언이 중간에 실패해도 세워 둔 트랜잭션이 커밋되고 스레드가 끝나야 다음 테스트의 DB 정리가 막히지 않는다. + release.countDown(); + executor.shutdown(); + assertThat(executor.awaitTermination(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)).isTrue(); + } + + @Test + @DisplayName("삭제 트랜잭션은 삭제 대상 서브트리 밖의 폴더 행을 잠그지 않는다") + void deleteLocksOnlyTargetSubtree() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Set lockedFolderIds = lockedFolderRowIds(); + Set lockedIndexNames = lockedFolderIndexNames(); + + assertThat(lockedFolderIds) + .as("사용자 폴더 전체를 잡으면 루트와 무관한 폴더까지 잠겨서, 그 폴더로 들어오는 등록이 다 줄을 선다") + .containsExactlyInAnyOrder(target.getId(), targetChild.getId()); + assertThat(lockedIndexNames) + .as("idx_folder_user_id 등치 스캔은 next-key lock 이라 갭이 인접 사용자 구간까지 덮는다 (#291 댓글 실측)") + .doesNotContain("idx_folder_user_id"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 들어오는 등록은 삭제를 기다리지 않는다") + void registrationIntoUnrelatedFolderIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "무관한 폴더 등록", null, untouched.getId(), null, null, null), + owner.getId())); + + assertCompletesWithoutBlocking(registration, "삭제 대상이 아닌 폴더로 들어오는 등록"); + } + + @Test + @DisplayName("다른 계정의 문제 등록은 남의 폴더 삭제를 기다리지 않는다") + void registrationByAnotherUserIsNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + Future registration = executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, "다른 계정 등록", null, otherRoot.getId(), null, null, null), + other.getId())); + + assertCompletesWithoutBlocking(registration, "폴더를 지운 사용자와 아무 관계도 없는 다른 계정의 문제 등록"); + } + + @Test + @DisplayName("삭제와 무관한 폴더로 등록이 몰려도 전부 삭제를 기다리지 않는다") + void concurrentRegistrationsIntoUnrelatedFolderAreNotBlocked() throws Exception { + holdUntilReleased(() -> folderService.deleteFoldersWithProblems(owner.getId(), List.of(target.getId()))); + + List> registrations = new ArrayList<>(); + for (int i = 0; i < concurrentRegistrationCount(); i++) { + String memo = "동시 등록 " + i; + registrations.add(executor.submit(() -> problemService.registerProblemV2( + new ProblemRegisterV2Dto(null, memo, null, untouched.getId(), null, null, null), + owner.getId()))); + } + + for (int i = 0; i < registrations.size(); i++) { + assertCompletesWithoutBlocking(registrations.get(i), (i + 1) + "번째 동시 등록"); + } + } + + /** 작업을 트랜잭션 안에서 실행하고, 커밋하기 직전에 {@link #release} 가 풀릴 때까지 붙잡아 둔다. */ + private void holdUntilReleased(Runnable work) throws Exception { + CountDownLatch workDone = new CountDownLatch(1); + Future future = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + work.run(); + workDone.countDown(); + awaitRelease(); + })); + + if (!workDone.await(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + release.countDown(); + future.get(STEP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + fail("먼저 잡아야 할 삭제 트랜잭션이 %d초 안에 잠금을 잡지 못했다", STEP_TIMEOUT_SECONDS); + } + } + + private void awaitRelease() { + try { + if (!release.await(STEP_TIMEOUT_SECONDS * 4, TimeUnit.SECONDS)) { + throw new IllegalStateException("release 신호를 받지 못했다"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private void assertCompletesWithoutBlocking(Future future, String what) { + try { + future.get(NO_BLOCKING_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + fail("%s 가 폴더 삭제 때문에 %d초 안에 끝나지 않았다.%n커넥션 풀: %s%n대기 사슬:%n%s", + what, NO_BLOCKING_TIMEOUT_SECONDS, poolStats(), lockWaitChain()); + } catch (Exception e) { + throw new AssertionError(what + " 가 실패했다", e); + } + } + + /** 커넥션을 못 받아 막힌 것인지 바로 보이게 찍는다. */ + private String poolStats() { + var pool = hikariDataSource().getHikariPoolMXBean(); + return "최대 %d, 사용중 %d, 유휴 %d, 커넥션 대기중인 스레드 %d".formatted( + poolSize, pool.getActiveConnections(), pool.getIdleConnections(), + pool.getThreadsAwaitingConnection()); + } + + /** 왜 막혔는지 바로 보이도록 {@code performance_schema} 의 대기 사슬을 그대로 찍는다. */ + private String lockWaitChain() { + StringBuilder chain = new StringBuilder(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT r.OBJECT_NAME, r.INDEX_NAME, r.LOCK_TYPE, r.LOCK_MODE, r.LOCK_DATA, + b.LOCK_MODE, b.LOCK_DATA + FROM performance_schema.data_lock_waits w + JOIN performance_schema.data_locks r ON r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID + JOIN performance_schema.data_locks b ON b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + chain.append(" 요청 %s.%s %s %s [%s] <- 보유 %s [%s]%n".formatted( + resultSet.getString(1), resultSet.getString(2), resultSet.getString(3), + resultSet.getString(4), resultSet.getString(5), + resultSet.getString(6), resultSet.getString(7))); + } + } catch (SQLException e) { + chain.append(" (대기 사슬을 읽지 못했다: ").append(e.getMessage()).append(')'); + } + return chain.isEmpty() ? " (잠금 대기 없음 - 커넥션 풀 대기일 수 있다)" : chain.toString(); + } + + /** + * 지금 잠겨 있는 {@code folder} 행의 기본 키 집합. + * + *

    {@code X,GAP} 처럼 갭만 잡은 잠금은 뺀다. 갭 잠금은 그 행을 잡은 것이 아니라 앞의 빈 구간을 잡은 것이다. + * {@code X,REC_NOT_GAP} 도 'GAP' 으로 끝나므로 {@code LIKE '%GAP'} 로 거르면 전부 빠진다. + */ + private Set lockedFolderRowIds() throws Exception { + Set lockedIds = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT LOCK_DATA + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND INDEX_NAME = 'PRIMARY' + AND LOCK_TYPE = 'RECORD' + AND LOCK_MODE NOT LIKE '%,GAP' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + String lockData = resultSet.getString(1); + if (lockData != null && lockData.chars().allMatch(Character::isDigit)) { + lockedIds.add(Long.parseLong(lockData)); + } + } + } + return lockedIds; + } + + /** 지금 {@code folder} 테이블에서 잠금이 잡힌 인덱스 이름들. */ + private Set lockedFolderIndexNames() throws Exception { + Set indexNames = new LinkedHashSet<>(); + try (Connection root = rootConnection(); + PreparedStatement statement = root.prepareStatement(""" + SELECT DISTINCT INDEX_NAME + FROM performance_schema.data_locks + WHERE OBJECT_SCHEMA = DATABASE() + AND OBJECT_NAME = 'folder' + AND LOCK_TYPE = 'RECORD' + """); + ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + indexNames.add(resultSet.getString(1)); + } + } + return indexNames; + } + + /** performance_schema 는 테스트 계정 권한으로 볼 수 없어 root 로 붙는다. */ + private Connection rootConnection() throws SQLException { + return DriverManager.getConnection( + TestContainers.mysql().getJdbcUrl(), "root", TestContainers.mysql().getPassword()); + } + + private int maximumPoolSize() { + return hikariDataSource().getMaximumPoolSize(); + } + + /** p6spy 가 DataSource 를 감싸고 있어서 원본을 한 겹 벗겨야 Hikari 가 나온다. */ + private HikariDataSource hikariDataSource() { + DataSource candidate = dataSource instanceof DecoratedDataSource decorated + ? decorated.getRealDataSource() + : dataSource; + if (candidate instanceof HikariDataSource hikari) { + return hikari; + } + throw new IllegalStateException("HikariDataSource 를 꺼내지 못했다: " + dataSource.getClass()); + } +} diff --git a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java index a5d48856..09a0dcd6 100644 --- a/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java +++ b/src/test/java/com/aisip/OnO/backend/problem/reminder/ProblemReviewReminderSenderTest.java @@ -87,7 +87,7 @@ void doesNotSendWhenClaimFails() { @DisplayName("취소된 행도 선점되지 않는다") void doesNotSendCanceledRow() { ProblemReviewReminder reminder = saveReminder(LocalDateTime.now().minusHours(1)); - inTransaction(() -> reminderRepository.cancelByProblem(reminder.getProblemId(), CANCELED, List.of(SCHEDULED))); + inTransaction(() -> reminderRepository.cancelByIdIn(List.of(reminder.getId()), CANCELED)); sender.send(reminder, LocalDateTime.now()); From 1ccf15bd61adda38451bddab40e7ef887121cd23 Mon Sep 17 00:00:00 2001 From: KiSeungMin Date: Sun, 20 Sep 2026 00:06:37 +0900 Subject: [PATCH 130/130] =?UTF-8?q?[Chore]=20=EC=8A=A4=EB=AA=A8=ED=81=AC?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EA=B0=80=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?JWT=20=EC=84=9C=EB=AA=85=20=ED=82=A4=20=EC=8B=9C=ED=81=AC?= =?UTF-8?q?=EB=A6=BF=EC=9D=84=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=EC=93=B0?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SMOKE_PROD_ACCESS_TOKEN_SECRET, SMOKE_DEV_ACCESS_TOKEN_SECRET 으로 같은 값을 한 벌 더 두고 있었다. 배포 워크플로가 서버에 넘기는 키는 JWT_ACCESS_TOKEN_SECRET 하나이고 dev 와 prod 가 같은 값을 쓴다 - 값이 세 군데로 흩어지면 서명 키를 바꿀 때 스모크 쪽을 빠뜨리기 쉽다. 그러면 서버는 멀쩡한데 인증 확인만 실패해서 알림이 계속 울린다 - 스모크 워크플로가 JWT_ACCESS_TOKEN_SECRET 을 읽게 바꿨다. 환경마다 계정이 다른 SMOKE_PROD_USER_ID 와 SMOKE_DEV_USER_ID 는 그대로 둔다 - README 의 설정 순서와 키 교체 안내도 같이 고쳤다 --- .github/workflows/smoke-test.yml | 9 ++++++--- scripts/smoke/README.md | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 8ca1b7a4..41383f8d 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -15,7 +15,10 @@ name: Smoke Test # - 보낼 수 있는 요청과 요청 수 상한은 스크립트에 고정되어 있다. # # 필요한 Secrets (없으면 할 수 있는 단계까지만 한다) -# SMOKE_PROD_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_ACCESS_TOKEN_SECRET, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL +# JWT_ACCESS_TOKEN_SECRET, SMOKE_PROD_USER_ID, SMOKE_DEV_USER_ID, DISCORD_WEBHOOK_URL +# +# JWT_ACCESS_TOKEN_SECRET 은 배포 워크플로가 서버에 넘기는 그 키다(ci-dev.yml, ci-prod.yml). 스모크는 같은 키로 +# 토큰을 직접 만들어 쓰기 때문에 값이 같아야 하고, 따로 복사해 두면 키를 바꿀 때 한쪽만 남아 알림이 계속 울린다. # # workflow_run 과 schedule 은 이 파일이 기본 브랜치(main)에 있어야 동작한다. @@ -124,7 +127,7 @@ jobs: env: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} - SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_PROD_ACCESS_TOKEN_SECRET }} + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_PROD_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then @@ -140,7 +143,7 @@ jobs: env: SMOKE_BASE_URL: ${{ steps.plan.outputs.url }} SMOKE_LEVEL: ${{ steps.plan.outputs.level }} - SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.SMOKE_DEV_ACCESS_TOKEN_SECRET }} + SMOKE_ACCESS_TOKEN_SECRET: ${{ secrets.JWT_ACCESS_TOKEN_SECRET }} SMOKE_USER_ID: ${{ secrets.SMOKE_DEV_USER_ID }} run: | if [ "$SMOKE_LEVEL" = "bootstrap" ]; then diff --git a/scripts/smoke/README.md b/scripts/smoke/README.md index 46862dbe..a3d72acf 100644 --- a/scripts/smoke/README.md +++ b/scripts/smoke/README.md @@ -75,13 +75,14 @@ JWT 서명 키, DB 조회가 어긋나도 알 수 없고 배포와 상관없이 ## 처음 설정하는 순서 1. **이 파일이 `main` 에 들어가야 한다.** `workflow_run` 과 `schedule` 과 수동 실행 버튼 모두 기본 브랜치의 워크플로 파일로 돈다. -2. **`SMOKE_PROD_ACCESS_TOKEN_SECRET` 을 등록한다.** 서버의 `jwt.accessToken.secret` 과 같은 값이다. 이것만 있어도 매시 확인이 인증과 DB 조회까지 본다. +2. **토큰 서명 키는 이미 있는 `JWT_ACCESS_TOKEN_SECRET` 을 그대로 쓴다.** 배포 워크플로가 서버에 넘기는 그 키다(`ci-dev.yml`, `ci-prod.yml`). 스모크가 같은 키로 토큰을 만들기 때문에 따로 등록할 것이 없다. 이 키만 있어도 매시 확인이 인증과 DB 조회까지 본다. 3. **스모크 계정을 만든다.** Actions 에서 `Smoke Test` 를 `prod`, `bootstrap` 으로 한 번 실행한다. 게스트 가입이라 Discord 에 가입 알림이 한 번 온다. 4. **실행 결과 요약에 나온 계정 ID 를 `SMOKE_PROD_USER_ID` 에 등록한다.** 이때부터 `read` 와 `full` 이 스모크 계정으로 돈다. 5. dev 도 같은 방식으로 `SMOKE_DEV_*` 를 등록한다. dev 서버가 켜져 있을 때 한다. -JWT 서명 키를 바꾸면 `SMOKE_*_ACCESS_TOKEN_SECRET` 도 같이 바꿔야 한다. 계정 ID 는 그대로 쓴다. -이 시크릿은 서버 서명 키 그 자체라 관리자 토큰도 만들 수 있으니 `main` 에서만 쓸 수 있는 GitHub Environment 에 두는 편이 더 안전하다. +JWT 서명 키를 바꿔도 `JWT_ACCESS_TOKEN_SECRET` 한 곳만 고치면 된다. 스모크용으로 값을 복사해 두면 한쪽만 남아 서버는 멀쩡한데 알림만 계속 울린다. +계정 ID 는 키를 바꿔도 그대로 쓴다. +이 키는 서버 서명 키 그 자체라 관리자 토큰도 만들 수 있으니, `main` 에서만 쓸 수 있는 GitHub Environment 에 두는 편이 더 안전하다. ## 실패했을 때 읽는 법