Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ entity "classes" as classes {
owner_participates : BOOLEAN
status : VARCHAR(20)
deleted_at : TIMESTAMP <<nullable>>
purged_at : TIMESTAMP <<nullable>>
created_at : TIMESTAMP
updated_at : TIMESTAMP
}
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -69,4 +70,17 @@ and not exists (select 1 from ClassReport r
order by c.id asc
""")
List<Classroom> 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<Long> findPurgeTargetIds(@Param("cutoff") LocalDateTime cutoff, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -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<Long> failed = new HashSet<>();
int purged = 0;
for (int batch = 0; batch < MAX_BATCHES; batch++) {
List<Long> 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());
}
}
Original file line number Diff line number Diff line change
@@ -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 보관과 삭제).
* <ul>
* <li>파기: 클래스 의견·댓글과 그 좋아요·신고, AI 토론 세션·메시지, 클래스 리포트(스냅샷·개인 피드백·인사이트)</li>
* <li>유지: 원문이 없는 투표·진행 단계·멤버·클래스 행, ai_jobs(토큰·오류만 기록)</li>
* <li>클래스마다 한 트랜잭션. 끝나면 purged_at 을 찍어 다시 처리하지 않는다</li>
* </ul>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ClassDataPurgeService {

private final ClassroomRepository classroomRepository;
private final EntityManager entityManager;

@Transactional(readOnly = true)
public List<Long> 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();
}
}
4 changes: 4 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- #565 이용 종료 후 30일 지난 클래스 데이터 파기 (PRD §18)
-- 멱등하게 작성되어 재실행해도 안전하다.
ALTER TABLE classes ADD COLUMN IF NOT EXISTS purged_at TIMESTAMP;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Rollback for V20261022_01__classes_purged_at.sql
-- 애플리케이션 코드를 #565 이전으로 되돌린 뒤 실행할 것. 이미 파기된 데이터는 복구되지 않는다.
ALTER TABLE classes DROP COLUMN IF EXISTS purged_at;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -113,6 +118,10 @@ class ClassBattleApiIntegrationTest {
private PerspectiveReportRepository perspectiveReportRepository;
@Autowired
private CommentReportRepository commentReportRepository;
@Autowired
private ClassReportRepository classReportRepository;
@Autowired
private ClassDataPurgeService purgeService;

private final List<Long> createdBattleIds = new ArrayList<>();
private final List<Long> createdUserIds = new ArrayList<>();
Expand Down Expand Up @@ -150,6 +159,7 @@ void tearDown() {
}

private void cleanUp() {
classReportRepository.deleteAll();
commentReportRepository.deleteAll();
perspectiveReportRepository.deleteAll();
commentLikeRepository.deleteAll();
Expand Down Expand Up @@ -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 {
Expand Down
Loading