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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.stackup.stackup.common.storage;

import java.util.List;

/**
* 스토리지 객체 즉시 파기 요청.
*
* <p>자료를 지우면 DB 행은 soft delete 로 남지만(세션 컨텍스트가 FK 로 참조한다) 내용물은
* 남길 이유가 없다. 이력서 원본 PDF 에는 이름·연락처·주소가 들어 있고
* (`docs/security.md §5.2`), 분석 마크다운은 그걸 재구성한 문서다.
*
* <p>발행은 도메인 트랜잭션 안에서, 실제 삭제는 {@link ObjectPurgeListener} 가 커밋 이후에
* 한다 — 롤백된 삭제로 객체를 날리면 복구할 방법이 없다.
*/
public record ObjectPurgeEvent(List<String> keys) {

public ObjectPurgeEvent {
keys = keys == null ? List.of() : keys.stream().filter(k -> k != null && !k.isBlank()).toList();
}

public boolean isEmpty() {
return keys.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.stackup.stackup.common.storage;

import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* {@link ObjectPurgeEvent} 를 받아 스토리지 객체를 실제로 지운다.
*
* <p>AFTER_COMMIT 인 이유: 삭제 트랜잭션이 롤백됐는데 객체를 먼저 지우면 DB 는 살아 있고
* 내용물만 사라진 상태가 된다 — 되돌릴 수 없다.
*
* <p>실패해도 사용자의 삭제 요청을 실패시키지 않는다. 이 시점엔 이미 커밋돼 행이 soft delete
* 상태라 어차피 도달 불가이고, 여기서 예외를 던져봐야 삭제를 되돌릴 수도 없다. 대신 키를
* ERROR 로 남겨 수동 회수가 가능하게 한다 — 조용히 삼키면 파기됐다고 착각하게 된다.
*/
@Component
@RequiredArgsConstructor
public class ObjectPurgeListener {

private static final Logger log = LoggerFactory.getLogger(ObjectPurgeListener.class);

private final ObjectStorageClient storage;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(ObjectPurgeEvent event) {
for (String key : event.keys()) {
try {
storage.delete(key);
log.info("storage object purged. key={}", key);
} catch (Exception e) {
// 키를 남긴다 — 이 로그가 없으면 어떤 객체가 남았는지 알 방법이 없다.
log.error("storage object purge failed — 수동 회수 필요. key={}", key, e);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.common.storage.ObjectPurgeEvent;
import com.stackup.stackup.coverletter.application.event.CoverLetterDeletedEvent;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.document.domain.DocumentEmbeddingRepository;
import com.stackup.stackup.github.application.event.RepositoryDeletedEvent;
import com.stackup.stackup.resume.application.event.ResumeDeletedEvent;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

// Resume / GithubRepository soft delete 발생 시 관련 AnalyzedDocument 도 cascade soft delete.
// Resume / GithubRepository / CoverLetter soft delete 발생 시 관련 AnalyzedDocument 도
// cascade soft delete + **분석 내용물 즉시 파기**(분석 마크다운 객체 + 임베딩 청크).
// 도메인 cycle 회피 — resume/github 가 document 도메인을 직접 import 하지 않고 이벤트로 위임.
@Component
@RequiredArgsConstructor
Expand All @@ -22,17 +26,17 @@ public class AnalyzedDocumentCascadeListener {
private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentCascadeListener.class);

private final AnalyzedDocumentRepository analyzedDocumentRepository;
private final DocumentEmbeddingRepository embeddingRepository;
private final ApplicationEventPublisher events;

@EventListener
@Transactional
public void on(ResumeDeletedEvent event) {
List<AnalyzedDocument> docs = analyzedDocumentRepository
.findActiveByResumeIdAndOwner(event.resumeId(), event.userId());
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
purge(docs);
if (!docs.isEmpty()) {
log.info("AnalyzedDocument cascade soft delete (resume). userId={}, resumeId={}, count={}",
log.info("AnalyzedDocument cascade soft delete + purge (resume). userId={}, resumeId={}, count={}",
event.userId(), event.resumeId(), docs.size());
}
}
Expand All @@ -42,11 +46,9 @@ public void on(ResumeDeletedEvent event) {
public void on(RepositoryDeletedEvent event) {
List<AnalyzedDocument> docs = analyzedDocumentRepository
.findActiveByRepositoryIdAndOwner(event.repositoryId(), event.userId());
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
purge(docs);
if (!docs.isEmpty()) {
log.info("AnalyzedDocument cascade soft delete (repository). userId={}, repositoryId={}, count={}",
log.info("AnalyzedDocument cascade soft delete + purge (repository). userId={}, repositoryId={}, count={}",
event.userId(), event.repositoryId(), docs.size());
}
}
Expand All @@ -56,12 +58,34 @@ public void on(RepositoryDeletedEvent event) {
public void on(CoverLetterDeletedEvent event) {
List<AnalyzedDocument> docs = analyzedDocumentRepository
.findActiveByCoverLetterIdAndOwner(event.coverLetterId(), event.userId());
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
purge(docs);
if (!docs.isEmpty()) {
log.info("AnalyzedDocument cascade soft delete (cover letter). userId={}, coverLetterId={}, count={}",
log.info("AnalyzedDocument cascade soft delete + purge (cover letter). userId={}, coverLetterId={}, count={}",
event.userId(), event.coverLetterId(), docs.size());
}
}

/**
* 문서를 soft delete 하고 **내용물은 즉시 파기**한다.
*
* <p>행을 지우지 않는 이유: session_contexts 가 analyzed_documents 를 FK 로 참조한다.
* 하지만 남길 이유가 있는 건 참조 무결성뿐이고, 분석 마크다운(이력서를 재구성한 문서)과
* 임베딩 청크(원문 조각)는 남길 이유가 없다.
*
* <p>임베딩은 같은 트랜잭션에서 지운다(DB). 스토리지 객체는 커밋 이후에 지운다 —
* 롤백된 삭제로 객체를 날리면 되돌릴 수 없다.
*/
private void purge(List<AnalyzedDocument> docs) {
if (docs.isEmpty()) {
return;
}
List<Long> ids = docs.stream().map(AnalyzedDocument::getId).toList();
List<String> paths = docs.stream().map(AnalyzedDocument::getDocumentPath).toList();
for (AnalyzedDocument doc : docs) {
doc.markDeleted();
}
int removed = embeddingRepository.deleteByDocumentIds(ids);
log.info("document embeddings purged. documentIds={}, chunks={}", ids, removed);
events.publishEvent(new ObjectPurgeEvent(paths));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ public interface DocumentEmbeddingRepository {

int countByDocumentId(long documentId);

// 자료 삭제 시 청크 원문을 즉시 파기한다. 청크에는 이력서 본문이 그대로 들어 있어
// 행이 남아 있는 한 "지웠다"고 할 수 없다(검색에서 빼는 것과는 다른 문제다).
int deleteByDocumentIds(List<Long> documentIds);

// 임베딩 검색. queryText 가 주어지면 벡터 + full-text(BM25) 를 RRF 로 융합한
// 하이브리드 검색, 없으면(null/blank) pgvector cosine 단독 검색.
// documentIds 가 비어 있으면 전체 대상.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ ON CONFLICT (document_id, chunk_index)
private static final String COUNT_SQL =
"SELECT count(*) FROM document_embeddings WHERE document_id = ?";

private static final String DELETE_BY_DOCS_SQL =
"DELETE FROM document_embeddings WHERE document_id IN (:documentIds)";

// 삭제된 문서의 청크는 검색에서 제외한다.
//
// 세션 생성 뒤 사용자가 워크스페이스에서 자료를 지워도 session_contexts 에는 그 문서 id 가
Expand Down Expand Up @@ -78,6 +81,14 @@ public int countByDocumentId(long documentId) {
return n == null ? 0 : n;
}

@Override
public int deleteByDocumentIds(List<Long> documentIds) {
if (documentIds == null || documentIds.isEmpty()) {
return 0;
}
return namedJdbc.update(DELETE_BY_DOCS_SQL, Map.of("documentIds", documentIds));
}

@Override
public List<SearchHit> search(
float[] queryEmbedding, String queryText, List<Long> documentIds, int topK) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectPurgeEvent;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.resume.application.dto.ResumeResult;
import com.stackup.stackup.resume.application.dto.ResumeUploadCommand;
Expand Down Expand Up @@ -104,8 +105,14 @@ public ResumeResult get(Long userId, Long resumeId) {
public void delete(Long userId, Long resumeId) {
Resume resume = loadOwned(userId, resumeId);
resume.markDeleted();
// 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete.
// 직접 의존 회피 (ArchUnit 도메인 cycle 방지).
// 업로드 원본 즉시 파기. 이력서 PDF 에는 이름·연락처·주소가 들어 있어
// (docs/security.md §5.2) 행만 soft delete 하고 객체를 남기면 "지웠다"고 할 수 없다.
// 웹 이력서(URL)는 업로드 원본이 없어 filePath 가 null 이다.
// 실제 삭제는 ObjectPurgeListener 가 커밋 이후에 한다.
events.publishEvent(new ObjectPurgeEvent(List.of(
resume.getFilePath() == null ? "" : resume.getFilePath())));
// 분석 결과 cascade — document 도메인 listener 가 ResumeDeletedEvent 받아 AnalyzedDocument soft delete
// + 분석 마크다운·임베딩 파기. 직접 의존 회피 (ArchUnit 도메인 cycle 방지).
events.publishEvent(new ResumeDeletedEvent(userId, resumeId));
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,76 +1,95 @@
package com.stackup.stackup.document.application;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult;
import com.stackup.stackup.document.domain.AnalysisStatus;
import com.stackup.stackup.common.storage.ObjectPurgeEvent;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import com.stackup.stackup.document.domain.DocumentStatus;
import com.stackup.stackup.github.application.event.RepositoryDeletedEvent;
import com.stackup.stackup.github.domain.GithubRepository;
import com.stackup.stackup.document.domain.DocumentEmbeddingRepository;
import com.stackup.stackup.resume.application.event.ResumeDeletedEvent;
import com.stackup.stackup.resume.domain.Resume;
import com.stackup.stackup.resume.domain.ResumeFileType;
import com.stackup.stackup.user.domain.User;
import java.util.List;
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.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;

/**
* 자료를 지우면 분석 내용물도 즉시 파기한다 — 분석 마크다운 객체 + 임베딩 청크.
*
* <p>행은 soft delete 로 남는다(session_contexts 가 FK 로 참조). 남길 이유가 있는 건
* 참조 무결성뿐이고 내용물은 아니다.
*/
@ExtendWith(MockitoExtension.class)
class AnalyzedDocumentCascadeListenerTest {

@Mock AnalyzedDocumentRepository documentRepository;
@Mock ObjectStorageClient storage;
@Mock DocumentEmbeddingRepository embeddingRepository;
@Mock ApplicationEventPublisher events;
@InjectMocks AnalyzedDocumentCascadeListener listener;

@Test
void resumeDeleted_softDeletesRelatedAnalyzedDocuments() {
AnalyzedDocument first = AnalyzedDocument.forResume(mock(Resume.class));
AnalyzedDocument second = AnalyzedDocument.forResume(mock(Resume.class));
when(documentRepository.findActiveByResumeIdAndOwner(10L, 1L)).thenReturn(List.of(first, second));
void resumeDeleted_softDeletesDocsAndPurgesContent() {
AnalyzedDocument doc = analyzedDocument(11L, "analyzed/resume/11/summary.md");
when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of(doc));

listener.on(new ResumeDeletedEvent(1L, 10L));
listener.on(new ResumeDeletedEvent(1L, 5L));

assertThat(first.isDeleted()).isTrue();
assertThat(second.isDeleted()).isTrue();
assertThat(doc.isDeleted()).isTrue();
// 임베딩은 같은 트랜잭션에서 바로 지운다 — 청크에 이력서 본문이 그대로 들어 있다.
verify(embeddingRepository).deleteByDocumentIds(List.of(11L));

// 스토리지 객체는 커밋 이후 파기 — 이벤트로 넘긴다.
ArgumentCaptor<Object> published = ArgumentCaptor.forClass(Object.class);
verify(events).publishEvent(published.capture());
assertThat(published.getValue())
.isInstanceOfSatisfying(ObjectPurgeEvent.class, e ->
assertThat(e.keys()).containsExactly("analyzed/resume/11/summary.md"));
}

// 분석 전에 지운 자료는 documentPath 가 없다 — 빈 키로 파기 이벤트를 내지 않는다.
@Test
void repositoryDeleted_softDeletesRelatedAnalyzedDocuments() {
AnalyzedDocument document = AnalyzedDocument.forRepository(mock(GithubRepository.class));
when(documentRepository.findActiveByRepositoryIdAndOwner(20L, 1L)).thenReturn(List.of(document));
void resumeDeleted_skipsPurgeEventWhenNoDocumentPath() {
AnalyzedDocument doc = analyzedDocument(12L, null);
when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of(doc));

listener.on(new RepositoryDeletedEvent(1L, 20L));
listener.on(new ResumeDeletedEvent(1L, 5L));

assertThat(document.isDeleted()).isTrue();
verify(embeddingRepository).deleteByDocumentIds(List.of(12L));
ArgumentCaptor<Object> published = ArgumentCaptor.forClass(Object.class);
verify(events).publishEvent(published.capture());
assertThat(published.getValue())
.isInstanceOfSatisfying(ObjectPurgeEvent.class, e -> assertThat(e.isEmpty()).isTrue());
}

@Test
void listForUser_usesActiveDocumentQuerySoDeletedDocumentsAreExcluded() {
AnalyzedDocumentQueryService queryService = new AnalyzedDocumentQueryService(documentRepository, storage);
AnalyzedDocument active = mockDocument();
when(documentRepository.findActiveByOwner(1L)).thenReturn(List.of(active));
void resumeDeleted_isNoopWhenNoAnalyzedDocuments() {
when(documentRepository.findActiveByResumeIdAndOwner(5L, 1L)).thenReturn(List.of());

List<AnalyzedDocumentResult> results = queryService.listForUser(1L, null, null);
listener.on(new ResumeDeletedEvent(1L, 5L));

assertThat(results).hasSize(1);
verify(documentRepository).findActiveByOwner(1L);
verify(documentRepository, never()).findByResume_User_IdOrRepository_User_Id(1L, 1L);
verify(embeddingRepository, never()).deleteByDocumentIds(anyList());
verify(events, never()).publishEvent(any());
}

private AnalyzedDocument mockDocument() {
AnalyzedDocument document = mock(AnalyzedDocument.class);
when(document.getTechStack()).thenReturn("[\"Java\"]");
when(document.getEmbeddingChunkCount()).thenReturn(1);
when(document.getAnalysisStatus()).thenReturn(AnalysisStatus.ANALYZED);
when(document.getStatus()).thenReturn(DocumentStatus.ACTIVE);
return document;
private AnalyzedDocument analyzedDocument(Long id, String documentPath) {
User user = User.createGithubUser(1L, "u", null, null, "t");
ReflectionTestUtils.setField(user, "id", 1L);
Resume resume = Resume.create(user, "r.pdf", "resumes/raw/1/r.pdf", ResumeFileType.PDF, 10L);
AnalyzedDocument doc = AnalyzedDocument.forResume(resume);
ReflectionTestUtils.setField(doc, "id", id);
ReflectionTestUtils.setField(doc, "documentPath", documentPath);
return doc;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,31 @@ void serviceReturnsEmptyWhenUserOwnsNothing() {
.isEmpty();
}

// 자료를 지우면 청크 원문도 즉시 파기한다. 검색에서 빼는 것(#216)과는 다른 문제다 —
// 행이 남아 있는 한 이력서 본문이 DB 에 그대로 있다.
@Test
void deleteByDocumentIdsRemovesChunks() {
AnalyzedDocument kept = document(97009L, "kept-purge");
AnalyzedDocument removed = document(97010L, "removed-purge");
embeddingRepository.upsertAll(kept.getId(), "test-model",
List.of(new EmbeddingChunk(0, "남아야 하는 청크", vector(0.4f))));
embeddingRepository.upsertAll(removed.getId(), "test-model",
List.of(new EmbeddingChunk(0, "파기 대상", vector(0.4f)),
new EmbeddingChunk(1, "파기 대상 2", vector(0.4f))));
em.flush();

assertThat(embeddingRepository.deleteByDocumentIds(List.of(removed.getId()))).isEqualTo(2);

assertThat(embeddingRepository.countByDocumentId(removed.getId())).isZero();
// 다른 문서의 청크까지 쓸어가면 안 된다.
assertThat(embeddingRepository.countByDocumentId(kept.getId())).isEqualTo(1);
}

@Test
void deleteByDocumentIdsIsNoopForEmptyInput() {
assertThat(embeddingRepository.deleteByDocumentIds(List.of())).isZero();
}

private List<SearchHit> search(List<Long> documentIds) {
return embeddingRepository.search(vector(0.9f), null, documentIds, 10);
}
Expand Down
Loading
Loading