diff --git a/docs/erd/class.puml b/docs/erd/class.puml index eea6b236..4dea31be 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -139,6 +139,7 @@ entity "classes" as classes { owner_participates : BOOLEAN status : VARCHAR(20) deleted_at : TIMESTAMP <> + purged_at : TIMESTAMP <> created_at : TIMESTAMP updated_at : TIMESTAMP } @@ -514,4 +515,11 @@ note bottom of battles 추천을 켜려면 difficulty·category 필수, 커스텀 배틀(creator_type USER)은 설정 불가 end note +note bottom of classes + 데이터 파기 (#565, PRD §18): 이용권 expires_at + 30일(읽기 유예)이 지난 클래스 (삭제된 클래스 포함) + 파기: perspectives·perspective_comments(class_id) 와 그 좋아요·신고, class_debate_sessions·messages, class_reports + 유지: votes·user_battles·class_members·classes 행, ai_jobs (원문 없음) + 클래스 단위 트랜잭션, 끝나면 purged_at 기록. 스케줄러 매일 04:00 KST, picke.class-retention.purge-enabled=true 일 때만 +end note + @enduml diff --git a/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java b/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java index 4fa18e86..3d1b35cc 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java +++ b/src/main/java/com/swyp/picke/domain/classroom/entity/Classroom.java @@ -96,6 +96,10 @@ public class Classroom extends BaseEntity { @Column(name = "deleted_at") private LocalDateTime deletedAt; + /** 읽기 유예가 끝나 참여 원문·AI 대화·리포트를 파기한 시각 (PRD §18). null 이면 아직 파기 전 */ + @Column(name = "purged_at") + private LocalDateTime purgedAt; + @Builder private Classroom(String name, String inviteCode, User owner, ClassTicket ticket, Battle battle, ClassBattleType battleType, ClassTopicVersion topicVersion, int capacity, LocalDateTime deadline, @@ -128,6 +132,10 @@ public void delete(LocalDateTime now) { this.deletedAt = now; } + public void markPurged(LocalDateTime now) { + this.purgedAt = now; + } + public LocalDateTime getEffectiveCloseAt() { LocalDateTime ticketEnd = ticketEnd(); if (deadline == null) { diff --git a/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java b/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java index dedc0f9f..6f8d8259 100644 --- a/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java +++ b/src/main/java/com/swyp/picke/domain/classroom/repository/ClassroomRepository.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Optional; import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; @@ -69,4 +70,17 @@ and not exists (select 1 from ClassReport r order by c.id asc """) List findClosedWithoutReports(@Param("now") LocalDateTime now); + + /** + * 파기 대상: 이용권 종료 + 읽기 유예(30일)가 지났고 아직 파기하지 않은 클래스. 삭제된 클래스도 포함한다 (PRD §18). + * cutoff = now - 30일 이므로 expires_at <= cutoff 이면 유예가 끝난 것이다. + */ + @Query(""" + select c.id from Classroom c + where c.purgedAt is null + and c.ticket.expiresAt is not null + and c.ticket.expiresAt <= :cutoff + order by c.id + """) + List findPurgeTargetIds(@Param("cutoff") LocalDateTime cutoff, Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/domain/classroom/scheduler/ClassDataPurgeScheduler.java b/src/main/java/com/swyp/picke/domain/classroom/scheduler/ClassDataPurgeScheduler.java new file mode 100644 index 00000000..01037bad --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/scheduler/ClassDataPurgeScheduler.java @@ -0,0 +1,57 @@ +package com.swyp.picke.domain.classroom.scheduler; + +import com.swyp.picke.domain.classroom.service.retention.ClassDataPurgeService; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * 읽기 유예가 끝난 클래스 데이터를 하루 한 번 파기한다 (기본 매일 04:00 KST, PRD §18 "30일 이내 삭제 큐로 파기"). + * 데이터를 지우는 작업이라 picke.class-retention.purge-enabled=true 일 때만 켜진다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "picke.class-retention", name = "purge-enabled", havingValue = "true") +public class ClassDataPurgeScheduler { + + static final int BATCH_SIZE = 50; + static final int MAX_BATCHES = 20; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final ClassDataPurgeService purgeService; + + @Scheduled(cron = "${picke.class-retention.purge-cron:0 0 4 * * *}", zone = "Asia/Seoul") + public void run() { + LocalDateTime now = LocalDateTime.now(KST); + Set failed = new HashSet<>(); + int purged = 0; + for (int batch = 0; batch < MAX_BATCHES; batch++) { + List targets = purgeService.findTargets(now, BATCH_SIZE + failed.size()).stream() + .filter(id -> !failed.contains(id)) + .limit(BATCH_SIZE) + .toList(); + if (targets.isEmpty()) { + break; + } + for (Long classId : targets) { + try { + if (purgeService.purge(classId, now)) { + purged++; + } + } catch (Exception e) { + failed.add(classId); + log.error("[ClassDataPurge] classId={} failed", classId, e); + } + } + } + log.info("[ClassDataPurge] done purged={} failed={}", purged, failed.size()); + } +} diff --git a/src/main/java/com/swyp/picke/domain/classroom/service/retention/ClassDataPurgeService.java b/src/main/java/com/swyp/picke/domain/classroom/service/retention/ClassDataPurgeService.java new file mode 100644 index 00000000..86987fea --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/classroom/service/retention/ClassDataPurgeService.java @@ -0,0 +1,72 @@ +package com.swyp.picke.domain.classroom.service.retention; + +import com.swyp.picke.domain.classroom.entity.Classroom; +import com.swyp.picke.domain.classroom.repository.ClassroomRepository; +import com.swyp.picke.domain.ticket.entity.ClassTicket; +import jakarta.persistence.EntityManager; +import java.time.LocalDateTime; +import java.util.List; +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; + +/** + * 읽기 유예(이용권 종료 + 30일)가 끝난 클래스의 참여 원문·AI 대화·리포트를 파기한다 (PRD §18 보관과 삭제). + *
    + *
  • 파기: 클래스 의견·댓글과 그 좋아요·신고, AI 토론 세션·메시지, 클래스 리포트(스냅샷·개인 피드백·인사이트)
  • + *
  • 유지: 원문이 없는 투표·진행 단계·멤버·클래스 행, ai_jobs(토큰·오류만 기록)
  • + *
  • 클래스마다 한 트랜잭션. 끝나면 purged_at 을 찍어 다시 처리하지 않는다
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ClassDataPurgeService { + + private final ClassroomRepository classroomRepository; + private final EntityManager entityManager; + + @Transactional(readOnly = true) + public List findTargets(LocalDateTime now, int limit) { + LocalDateTime cutoff = now.minusDays(ClassTicket.READ_GRACE_DAYS); + return classroomRepository.findPurgeTargetIds(cutoff, PageRequest.of(0, limit)); + } + + /** 자식 행부터 지운다. 이미 파기됐거나 아직 유예 중이면 아무것도 하지 않는다 */ + @Transactional + public boolean purge(Long classId, LocalDateTime now) { + Classroom classroom = classroomRepository.findById(classId).orElse(null); + if (classroom == null || classroom.getPurgedAt() != null) { + return false; + } + LocalDateTime readUntil = classroom.getTicket().getReadGraceEndsAt(); + if (readUntil == null || now.isBefore(readUntil)) { + return false; + } + + String classComments = "select c.id from PerspectiveComment c where c.classroom.id = :classId or c.perspective.classroom.id = :classId"; + String classPerspectives = "select p.id from Perspective p where p.classroom.id = :classId"; + int commentLikes = delete("delete from CommentLike l where l.comment.id in (" + classComments + ")", classId); + int commentReports = delete("delete from CommentReport r where r.comment.id in (" + classComments + ")", classId); + int comments = delete("delete from PerspectiveComment c where c.id in (" + classComments + ")", classId); + int perspectiveLikes = delete("delete from PerspectiveLike l where l.perspective.id in (" + classPerspectives + ")", classId); + int perspectiveReports = delete("delete from PerspectiveReport r where r.perspective.id in (" + classPerspectives + ")", classId); + int perspectives = delete("delete from Perspective p where p.classroom.id = :classId", classId); + int messages = delete("delete from ClassDebateMessage m where m.session.id in " + + "(select s.id from ClassDebateSession s where s.classroom.id = :classId)", classId); + int sessions = delete("delete from ClassDebateSession s where s.classroom.id = :classId", classId); + int reports = delete("delete from ClassReport r where r.classroom.id = :classId", classId); + + classroom.markPurged(now); + log.info("[ClassDataPurge] classId={} perspectives={} comments={} likes={} reports={} debateSessions={} debateMessages={} classReports={}", + classId, perspectives, comments, perspectiveLikes + commentLikes, perspectiveReports + commentReports, + sessions, messages, reports); + return true; + } + + private int delete(String jpql, Long classId) { + return entityManager.createQuery(jpql).setParameter("classId", classId).executeUpdate(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 90107c08..9e0692ad 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -165,6 +165,10 @@ picke: # 클래스 마감 감지·AI 리포트 생성 스케줄러. 테스트에서는 끈다. scheduler-enabled: ${CLASS_REPORT_SCHEDULER_ENABLED:true} scheduler-delay-ms: ${CLASS_REPORT_SCHEDULER_DELAY_MS:60000} + class-retention: + # 이용권 종료 + 30일 지난 클래스의 의견·댓글·AI 토론·리포트 파기 (PRD §18). 데이터를 지우므로 명시적으로 켜야 한다. + purge-enabled: ${CLASS_PURGE_ENABLED:false} + purge-cron: ${CLASS_PURGE_CRON:0 0 4 * * *} class: # 커스텀 배틀 클래스의 고정 썸네일 1종 (기획 Q&A). 비어 있으면 썸네일 없이 만든다. custom-battle-thumbnail-url: ${CLASS_CUSTOM_BATTLE_THUMBNAIL_URL:} diff --git a/src/main/resources/db/migration/V20261022_01__classes_purged_at.sql b/src/main/resources/db/migration/V20261022_01__classes_purged_at.sql new file mode 100644 index 00000000..c262c62f --- /dev/null +++ b/src/main/resources/db/migration/V20261022_01__classes_purged_at.sql @@ -0,0 +1,3 @@ +-- #565 이용 종료 후 30일 지난 클래스 데이터 파기 (PRD §18) +-- 멱등하게 작성되어 재실행해도 안전하다. +ALTER TABLE classes ADD COLUMN IF NOT EXISTS purged_at TIMESTAMP; diff --git a/src/main/resources/db/migration/V20261022_rollback__classes_purged_at.sql b/src/main/resources/db/migration/V20261022_rollback__classes_purged_at.sql new file mode 100644 index 00000000..b0535ce5 --- /dev/null +++ b/src/main/resources/db/migration/V20261022_rollback__classes_purged_at.sql @@ -0,0 +1,3 @@ +-- Rollback for V20261022_01__classes_purged_at.sql +-- 애플리케이션 코드를 #565 이전으로 되돌린 뒤 실행할 것. 이미 파기된 데이터는 복구되지 않는다. +ALTER TABLE classes DROP COLUMN IF EXISTS purged_at; diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java index 47c350cc..3a5fb966 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java @@ -12,7 +12,12 @@ import com.swyp.picke.domain.classroom.entity.Classroom; import com.swyp.picke.domain.classroom.enums.ClassMemberStatus; import com.swyp.picke.domain.classroom.repository.ClassMemberRepository; +import com.swyp.picke.domain.classroom.repository.ClassReportRepository; import com.swyp.picke.domain.classroom.repository.ClassroomRepository; +import com.swyp.picke.domain.classroom.entity.ClassReport; +import com.swyp.picke.domain.classroom.enums.ClassReportType; +import com.swyp.picke.domain.classroom.service.retention.ClassDataPurgeService; +import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.oauth.jwt.JwtProvider; import com.swyp.picke.domain.perspective.repository.CommentLikeRepository; import com.swyp.picke.domain.perspective.repository.CommentReportRepository; @@ -113,6 +118,10 @@ class ClassBattleApiIntegrationTest { private PerspectiveReportRepository perspectiveReportRepository; @Autowired private CommentReportRepository commentReportRepository; + @Autowired + private ClassReportRepository classReportRepository; + @Autowired + private ClassDataPurgeService purgeService; private final List createdBattleIds = new ArrayList<>(); private final List createdUserIds = new ArrayList<>(); @@ -150,6 +159,7 @@ void tearDown() { } private void cleanUp() { + classReportRepository.deleteAll(); commentReportRepository.deleteAll(); perspectiveReportRepository.deleteAll(); commentLikeRepository.deleteAll(); @@ -478,6 +488,69 @@ void reports_requireClassReadAccess() throws Exception { assertThat(commentReportRepository.count()).isEqualTo(1); } + @Test + @DisplayName("데이터 파기: 읽기 유예 중에는 그대로, 이용권 종료 + 30일이 지나면 의견·댓글·좋아요·신고·리포트를 지우고 투표·멤버는 남긴다") + void purge_afterReadGrace() throws Exception { + ClassInfo cls = createClass(false); + User writer = newUser("학생A"); + String writerToken = tokenOf(writer); + User reader = newUser("학생B"); + String readerToken = tokenOf(reader); + join(writerToken, cls.inviteCode, "학생A").andExpect(status().isOk()); + join(readerToken, cls.inviteCode, "학생B").andExpect(status().isOk()); + + vote(writerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + vote(writerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); + long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") + .header("Authorization", "Bearer " + writerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("content", "파기될 클래스 의견입니다.")))) + .andExpect(status().isOk()) + .andReturn()).get("perspectiveId").asLong(); + long commentId = dataOf(mockMvc.perform(post("/api/v1/perspectives/" + perspectiveId + "/comments") + .header("Authorization", "Bearer " + readerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("content", "파기될 클래스 댓글입니다.")))) + .andExpect(status().isOk()) + .andReturn()).get("commentId").asLong(); + mockMvc.perform(post("/api/v1/perspectives/" + perspectiveId + "/likes").header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/comments/" + commentId + "/likes").header("Authorization", "Bearer " + writerToken)) + .andExpect(status().isOk()); + mockMvc.perform(post("/api/v1/perspectives/" + perspectiveId + "/reports").header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + Classroom classroom = classroomRepository.findById(cls.classId).orElseThrow(); + classReportRepository.save(ClassReport.builder().classroom(classroom).type(ClassReportType.CLASS_SUMMARY).build()); + + // 이용권 사용 중: 대상 아님 + LocalDateTime now = LocalDateTime.now(); + assertThat(purgeService.findTargets(now, 10)).doesNotContain(cls.classId); + assertThat(purgeService.purge(cls.classId, now)).isFalse(); + assertThat(perspectiveRepository.count()).isEqualTo(1); + + // 이용권 종료 + 30일 경과 + ClassTicket ticket = classTicketRepository.findById(classroom.getTicket().getId()).orElseThrow(); + ReflectionTestUtils.setField(ticket, "expiresAt", now.minusDays(ClassTicket.READ_GRACE_DAYS).minusMinutes(1)); + classTicketRepository.save(ticket); + + assertThat(purgeService.findTargets(now, 10)).contains(cls.classId); + assertThat(purgeService.purge(cls.classId, now)).isTrue(); + + assertThat(perspectiveRepository.count()).isZero(); + assertThat(perspectiveCommentRepository.count()).isZero(); + assertThat(perspectiveLikeRepository.count()).isZero(); + assertThat(commentLikeRepository.count()).isZero(); + assertThat(perspectiveReportRepository.count()).isZero(); + assertThat(classReportRepository.count()).isZero(); + assertThat(battleVoteRepository.count()).isEqualTo(1); + assertThat(classMemberRepository.findByClassroomIdAndUserId(cls.classId, writer.getId())).isPresent(); + assertThat(classroomRepository.findById(cls.classId).orElseThrow().getPurgedAt()).isNotNull(); + + // 이미 파기한 클래스는 다시 대상이 되지 않는다 + assertThat(purgeService.findTargets(now, 10)).doesNotContain(cls.classId); + assertThat(purgeService.purge(cls.classId, now)).isFalse(); + } + @Test @DisplayName("투표한 뒤 나가면 좌석은 유지(F15)되고, 재참여하면 진행 상태가 이어진다(F07)") void leaveAfterActivity_keepsSeat_andRejoinKeepsProgress() throws Exception {