diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java index 1eecd85..accec9d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java @@ -16,6 +16,7 @@ public class ApplicationListResponseDto { private Long applicationId; // 지원자 간략 정보 + private Long applicantId; private String applicantName; private String applicantNickname; private String applicantProfileImage; diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java index 0cab33f..bac4b7e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java @@ -22,6 +22,7 @@ public interface ApplicationMapper { ApplicationResponseDto toResponseDto(Application application); @Mapping(source = "id", target = "applicationId") + @Mapping(source = "applicant.id", target = "applicantId") @Mapping(source = "applicant.name", target = "applicantName") @Mapping(source = "applicant.nickname", target = "applicantNickname") @Mapping(source = "applicant.profileImage", target = "applicantProfileImage") diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java index 25e6a28..e08325a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java @@ -82,11 +82,12 @@ public ResponseEntity getRecruitment(@PathVariable */ @GetMapping("/recruitments") public ResponseEntity> searchRecruitments( + @RequestParam(required = false) String keyword, @RequestParam(required = false) RecruitmentStatus status, @RequestParam(required = false) List field, @PageableDefault(size = 10) Pageable pageable ) { - Page response = recruitmentService.searchRecruitments(status, field, pageable); + Page response = recruitmentService.searchRecruitments(keyword, status, field, pageable); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java index 3cb324d..7888b4e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -18,7 +18,9 @@ indexes = { @Index(name = "idx_recruitment_status", columnList = "status"), @Index(name = "idx_recruitment_deadline", columnList = "end_at"), - @Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id") + @Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id"), + @Index(name = "idx_recruitment_status_end_at", columnList = "status, end_at"), + @Index(name = "idx_recruitment_created_at", columnList = "created_at") }) @Getter @Setter diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java index 3d66253..28f80ad 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java @@ -10,6 +10,7 @@ public interface RecruitmentCustomRepository { Page searchRecruitments( + String keyword, RecruitmentStatus status, List fields, Pageable pageable diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java index 89b7995..2132c59 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java @@ -4,8 +4,8 @@ import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -18,6 +18,7 @@ public interface RecruitmentRepository extends JpaRepository, List findAllByStatusAndEndAtBefore(RecruitmentStatus status, LocalDateTime now); // 내 공고 목록 조회 + @EntityGraph(attributePaths = {"user"}) Page findByRecruiterId(Long recruiterId, Pageable pageable); // 상태 기반 조회(추천 시) diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java index 3dd0bf5..35752b3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java @@ -1,22 +1,30 @@ package com.teamEWSN.gitdeun.Recruitment.repository; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; +import java.util.ArrayList; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import static com.teamEWSN.gitdeun.Recruitment.entity.QRecruitment.recruitment; +@Slf4j @Repository @RequiredArgsConstructor public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository { @@ -25,37 +33,160 @@ public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository { @Override public Page searchRecruitments( - RecruitmentStatus status, List fields, Pageable pageable + String keyword, RecruitmentStatus status, List fields, Pageable pageable ) { - // 1. 데이터 조회 (엔티티 자체를 조회) - List content = queryFactory - .selectFrom(recruitment) - .distinct() // 중복 제거 - .leftJoin(recruitment.fieldTags).fetchJoin() // fetch join으로 N+1 문제 방지 - .leftJoin(recruitment.languageTags).fetchJoin() // fetch join - .where(statusEq(status), fieldIn(fields)) + // 키워드 전처리 후 Full-Text Search 활용 여부 확인 + String processed = preprocessKeyword(keyword); + + // 키워드 없으면: 키워드 조건 없이 status/fields만으로 페이지 조회 + boolean hasKeyword = (processed != null); + boolean useFullTextSearch = hasKeyword && isFullTextSearchAvailable(processed); + + BooleanExpression keywordExpr = null; + if (hasKeyword) { + keywordExpr = useFullTextSearch ? titleFullTextSearch(processed) + : fallbackContains(processed); + } + + // 엔티티 자체 데이터 조회 + // id 페이지닝 + List ids = queryFactory.select(recruitment.id).distinct() + .from(recruitment) + .where(keywordExpr, statusEq(status), fieldOrFilter(fields)) + .orderBy(useFullTextSearch ? scoreOrder(processed) : recruitment.id.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) - .orderBy(recruitment.id.desc()) .fetch(); - // 2. 전체 카운트 조회 (조건은 동일하게) - JPAQuery countQuery = queryFactory - .select(recruitment.count()) - .from(recruitment) - .where(statusEq(status), fieldIn(fields)); + if (ids.isEmpty()) return Page.empty(pageable); + + // 내용 로딩 + 순서 복원 + String idCsv = ids.stream().map(String::valueOf).collect(Collectors.joining(",")); + List content = queryFactory.selectFrom(recruitment) + .where(recruitment.id.in(ids)) + .orderBy(Expressions.numberTemplate(Integer.class, + "FIELD({0}, " + idCsv + ")", recruitment.id).asc()) + .fetch(); - Long total = countQuery.fetchOne(); + // 카운팅 + Long total = queryFactory.select(recruitment.id.countDistinct()) + .from(recruitment) + .where(keywordExpr, statusEq(status), fieldOrFilter(fields)) + .fetchOne(); return new PageImpl<>(content, pageable, total != null ? total : 0L); } + // 키워드 전처리 + private String preprocessKeyword(String keyword) { + if (!StringUtils.hasText(keyword)) return null; + + // 유니코드 정규화 + String s = java.text.Normalizer.normalize(keyword, java.text.Normalizer.Form.NFKC); + + // 제어문자 제거: NULL(0x00), Ctrl-Z(0x1A), 그 외 제어문자(탭/개행 제외) + s = s.replace("\u0000", "").replace("\u001A", ""); + s = s.replaceAll("[\\p{Cntrl}&&[^\\t\\n\\r]]", ""); + + // 공백 정규화 + s = s.trim().replaceAll("\\s+", " "); + + // 허용 문자만 남기기 (한글, 영문, 숫자, 공백, + - * " ( ) ) + s = s.replaceAll("[^가-힣A-Za-z0-9\\s\\+\\-\\*\\\"\\(\\)]", ""); + + // 따옴표 균형 보정 + int quoteCount = s.length() - s.replace("\"", "").length(); + if ((quoteCount % 2) != 0) { + int last = s.lastIndexOf('"'); + if (last >= 0) s = s.substring(0, last) + s.substring(last + 1); + } + + // 길이 제한 + if (s.length() < 2) return null; + if (s.length() > 30) s = s.substring(0, 30); + + return s; + } + + // Full Text Search 조건 (전처리 이후 동작) + private boolean isFullTextSearchAvailable(String keyword) { + if (!StringUtils.hasText(keyword)) { + return false; + } + return keyword.split("\\s+").length <= 5; + } + + /** + * MySQL Full-Text Search를 사용한 검색 + * MATCH ... AGAINST 구문 활용 + */ + private BooleanExpression titleFullTextSearch(String keyword) { + String booleanQuery = buildBooleanQuery(keyword); + return Expressions.booleanTemplate( + "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)", + recruitment.title, recruitment.content, booleanQuery + ); + } + + /** + * 기본 부분 문자열 검색 + */ + private BooleanExpression fallbackContains(String keyword) { + String k = keyword.trim(); + return recruitment.title.containsIgnoreCase(k) + .or(recruitment.content.containsIgnoreCase(k)); + } + + // BOOLEAN MODE 정렬식 + private OrderSpecifier scoreOrder(String keyword) { + String tpl = "MATCH({0}, {1}) AGAINST ({2} IN BOOLEAN MODE)"; + return Expressions.numberTemplate(Double.class, tpl, + recruitment.title, recruitment.content, buildBooleanQuery(keyword)) + .desc(); + } + + // 따옴표 보존 토크나이저 + 빌더 + private static final Pattern TOKEN_PATTERN = Pattern.compile("\"([^\"]+)\"|(\\S+)"); + private List tokenizeRespectingQuotes(String s) { + List tokens = new ArrayList<>(); + Matcher m = TOKEN_PATTERN.matcher(s); + while (m.find()) { + String phrase = m.group(1); + String single = m.group(2); + if (phrase != null) tokens.add("\"" + phrase + "\""); + else if (single != null) tokens.add(single); + } + return tokens; + } + private String buildBooleanQuery(String keyword) { + List parts = tokenizeRespectingQuotes(keyword.trim()); + StringBuilder sb = new StringBuilder(); + for (String p : parts) { + if (p.isBlank()) continue; + + // 와일드카드 과다 방지: 중간의 **** → * 로 축약, 토큰 중간 *는 허용 + String normalized = p.replaceAll("\\*{2,}", "*"); + + boolean hasOpPrefix = p.startsWith("+") || p.startsWith("-") || p.startsWith("("); + boolean isPhrase = normalized.startsWith("\"") && normalized.endsWith("\""); + + // 단독 "*" 같은 노이즈 토큰 차단 + if ("*".equals(normalized)) continue; + + if (hasOpPrefix || isPhrase) sb.append(normalized).append(' '); + else sb.append('+').append(normalized).append(' '); + } + return sb.toString().trim(); + } + private BooleanExpression statusEq(RecruitmentStatus status) { return status != null ? recruitment.status.eq(status) : null; } - private BooleanExpression fieldIn(List fields) { - return !CollectionUtils.isEmpty(fields) ? recruitment.fieldTags.any().in(fields) : null; + private BooleanExpression fieldOrFilter(List fields) { + return CollectionUtils.isEmpty(fields) + ? null + : recruitment.fieldTags.any().in(fields); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java index 02a4aef..4e306d4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java @@ -104,14 +104,16 @@ public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) { /** * 상태(status)와 모집 분야(field)를 기준으로 모집 공고를 필터링하여 검색합니다. - * @param status 검색할 모집 상태 (선택 사항) - * @param fields 검색할 모집 분야 목록 (선택 사항) + * @param keyword 제목 검색 키워드 (선택 사항) - 부분 문자열 매칭 + * @param status 검색할 모집 상태 (선택 사항) + * @param fields 검색할 모집 분야 목록 (선택 사항) * @param pageable 페이징 정보 * @return 페이징 처리된 검색 결과 목록 */ @Transactional(readOnly = true) - public Page searchRecruitments(RecruitmentStatus status, List fields, Pageable pageable) { - return recruitmentRepository.searchRecruitments(status, fields, pageable).map(recruitmentMapper::toListResponseDto); + public Page searchRecruitments(String keyword, RecruitmentStatus status, List fields, Pageable pageable) { + return recruitmentRepository.searchRecruitments(keyword, status, fields, pageable) + .map(recruitmentMapper::toListResponseDto); } /** diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index ca8aeb2..c91fe39 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -45,6 +45,10 @@ public enum ErrorCode { // 마인드맵 관련 MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + // 프롬프트 히스토리 관련 (신규 추가) + PROMPT_HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PROMPT-001", "요청한 프롬프트 히스토리를 찾을 수 없습니다."), + CANNOT_DELETE_APPLIED_PROMPT(HttpStatus.BAD_REQUEST, "PROMPT-002", "적용된 프롬프트 히스토리는 삭제할 수 없습니다."), + // 멤버 관련 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "해당 멤버를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-002", "이미 마인드맵에 등록된 멤버입니다."), diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index 37ba16a..63fd935 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -2,7 +2,6 @@ import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.beans.factory.annotation.Qualifier; @@ -38,10 +37,24 @@ public LocalDateTime getRepositoryLastCommitTime(String repoUrl, String authoriz } /** - * FastAPI 서버에 리포지토리 분석을 요청 (AI 기반) + * FastAPI 서버에 리포지토리 프롬프트 기반 마인드맵 분석 */ - public AnalysisResultDto analyzeWithAi(String repoUrl, String prompt, MindmapType type, String authorizationHeader) { - AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type); + public AnalysisResultDto analyzeWithPrompt(String repoUrl, String prompt, String authorizationHeader) { + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt); + return webClient.post() + .uri("/mindmap/analyze-prompt") + .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), AnalysisRequest.class) + .retrieve() + .bodyToMono(AnalysisResultDto.class) + .block(); + } + + /** + * 기본 마인드맵 분석 (프롬프트 X) + */ + public AnalysisResultDto analyzeDefault(String repoUrl, String authorizationHeader) { + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, null); return webClient.post() .uri("/mindmap/analyze-ai") .header("Authorization", authorizationHeader) @@ -113,8 +126,7 @@ public void deleteMindmapData(String repoUrl, String authorizationHeader) { @AllArgsConstructor private static class AnalysisRequest { private String repo_url; - private String prompt; - private MindmapType mode; // FastAPI 모델의 필드명(mode)과 일치 + private String prompt; // nullable } @Getter diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java index cb4c1e8..d476c50 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -1,11 +1,12 @@ package com.teamEWSN.gitdeun.common.fastapi.dto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; @Getter +@Builder public class AnalysisResultDto { // Repo 관련 정보 private String defaultBranch; @@ -13,8 +14,7 @@ public class AnalysisResultDto { // Mindmap 관련 정보 private String mapData; // JSON 형태의 마인드맵 데이터 - private String field; -// private MindmapType mode; -// private String prompt; + private String title; // 프롬프트 및 mindmap 정보 요약 + private String errorMessage; // 실패 시 전달될 에러메세지 // TODO: FastAPI 응답에 맞춰 필드 정의 } diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/dto/InvitationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/invitation/dto/InvitationResponseDto.java index 1568d5c..b9e1e27 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/dto/InvitationResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/dto/InvitationResponseDto.java @@ -12,7 +12,7 @@ public class InvitationResponseDto { private Long invitationId; - private String mindmapName; + private String mindmapTitle; private String inviteeName; private String inviteeEmail; private MindmapRole role; diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/mapper/InvitationMapper.java b/src/main/java/com/teamEWSN/gitdeun/invitation/mapper/InvitationMapper.java index 405974e..8d81661 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/mapper/InvitationMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/mapper/InvitationMapper.java @@ -10,7 +10,7 @@ public interface InvitationMapper { @Mapping(source = "id", target = "invitationId") - @Mapping(source = "mindmap.field", target = "mindmapName") + @Mapping(source = "mindmap.title", target = "mindmapTitle") @Mapping(source = "invitee.name", target = "inviteeName") @Mapping(source = "invitee.email", target = "inviteeEmail", defaultExpression = "java(\"링크 초대\")") InvitationResponseDto toResponseDto(Invitation invitation); diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java index 6a6d6e1..5ef2c43 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java @@ -77,7 +77,8 @@ public void inviteUserByEmail(Long mapId, InviteRequestDto requestDto, Long invi throw new GlobalException(ErrorCode.INVITATION_ALREADY_EXISTS); } - Mindmap mindmap = mindmapRepository.findById(mapId) + // 삭제된 마인드맵 제외 + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); User inviter = userRepository.findById(inviterId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); @@ -150,6 +151,11 @@ public void acceptInvitation(Long invitationId, Long userId) { Invitation invitation = invitationRepository.findById(invitationId) .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); + // 초대된 마인드맵이 삭제되었는지 확인 + if (invitation.getMindmap().isDeleted()) { + throw new GlobalException(ErrorCode.MINDMAP_NOT_FOUND); + } + // 초대 중복 여부 if (!invitation.getInvitee().getId().equals(userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); @@ -192,7 +198,8 @@ public LinkResponseDto createInvitationLink(Long mapId, Long inviterId) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - Mindmap mindmap = mindmapRepository.findById(mapId) + // 삭제된 마인드맵에는 초대할 수 없음 + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); User inviter = userRepository.findById(inviterId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); @@ -217,6 +224,7 @@ public void acceptInvitationByLink(String token, Long userId) { Invitation invitation = invitationRepository.findByToken(token) .orElseThrow(() -> new GlobalException(ErrorCode.INVITATION_NOT_FOUND)); + // 만료된 초대 여부 확인 if (invitation.getExpiresAt().isBefore(LocalDateTime.now())) { throw new GlobalException(ErrorCode.INVITATION_EXPIRED); } @@ -226,6 +234,11 @@ public void acceptInvitationByLink(String token, Long userId) { throw new GlobalException(ErrorCode.INVITATION_REJECTED_USER); } + // 초대된 마인드맵이 삭제되었는지 확인 + if (invitation.getMindmap().isDeleted()) { + throw new GlobalException(ErrorCode.MINDMAP_NOT_FOUND); + } + User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); @@ -266,7 +279,7 @@ public InvitationActionResponseDto approveLinkInvitation(Long invitationId, Long return new InvitationActionResponseDto("초대 요청이 승인되었습니다."); } - // Owner의 링크 초대 요청 거절 메서드 + // Owner의 링크 초대 요청 거절 @Transactional public InvitationActionResponseDto rejectLinkApproval(Long invitationId, Long ownerId) { Invitation invitation = invitationRepository.findById(invitationId) diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index 98b7527..c98da74 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,19 +1,25 @@ package com.teamEWSN.gitdeun.mindmap.controller; -import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; -import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.*; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.MindmapPromptAnalysisDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptApplyRequestDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; import com.teamEWSN.gitdeun.mindmap.service.MindmapService; +import com.teamEWSN.gitdeun.mindmap.service.PromptHistoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; + @Slf4j @RestController @RequestMapping("/api/mindmaps") @@ -21,6 +27,7 @@ public class MindmapController { private final MindmapService mindmapService; + private final PromptHistoryService promptHistoryService; // 마인드맵 생성 (FastAPI 분석 기반) @PostMapping @@ -29,7 +36,7 @@ public ResponseEntity createMindmap( @RequestBody MindmapCreateRequestDto request, @AuthenticationPrincipal CustomUserDetails userDetails ) { - MindmapResponseDto responseDto = mindmapService.createMindmapFromAnalysis( + MindmapResponseDto responseDto = mindmapService.createMindmap( request, userDetails.getId(), authorizationHeader @@ -38,7 +45,7 @@ public ResponseEntity createMindmap( return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } - // 마인드맵 상세 조회 (유저 인가 확인필요?) + // 마인드맵 상세 조회 @GetMapping("/{mapId}") public ResponseEntity getMindmap( @PathVariable Long mapId, @@ -49,7 +56,22 @@ public ResponseEntity getMindmap( return ResponseEntity.ok(responseDto); } - // 마인드맵 새로고침 + /** + * 마인드맵 제목 수정 + */ + @PatchMapping("/{mapId}/title") + public ResponseEntity updateMindmapTitle( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody MindmapTitleUpdateDto request + ) { + MindmapDetailResponseDto responseDto = mindmapService.updateMindmapTitle(mapId, userDetails.getId(), request); + return ResponseEntity.ok(responseDto); + } + + /** + * 마인드맵 새로고침 + */ @PostMapping("/{mapId}/refresh") public ResponseEntity refreshMindmap( @PathVariable Long mapId, @@ -60,7 +82,9 @@ public ResponseEntity refreshMindmap( return ResponseEntity.ok(responseDto); } - // 마인드맵 삭제 (owner만) + /** + * 마인드맵 삭제 (owner만) + */ @DeleteMapping("/{mapId}") public ResponseEntity deleteMindmap( @PathVariable Long mapId, @@ -68,8 +92,94 @@ public ResponseEntity deleteMindmap( @RequestHeader("Authorization") String authorizationHeader ) { mindmapService.deleteMindmap(mapId, userDetails.getId(), authorizationHeader); - return ResponseEntity.ok().build(); // 성공 시 200 OK와 빈 body 반환 + return ResponseEntity.ok().build(); + } + + + /** + * 프롬프트 분석 및 미리보기 생성 + */ + @PostMapping("/{mapId}/prompts") + public ResponseEntity analyzePromptPreview( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody MindmapPromptAnalysisDto request, + @RequestHeader("Authorization") String authorizationHeader + ) { + PromptPreviewResponseDto responseDto = promptHistoryService.createPromptPreview( + mapId, + userDetails.getId(), + request, + authorizationHeader + ); + return ResponseEntity.ok(responseDto); + } + + /** + * 프롬프트 히스토리 적용 + */ + @PostMapping("/{mapId}/prompts/apply") + public ResponseEntity applyPromptHistory( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody PromptApplyRequestDto request + ) { + promptHistoryService.applyPromptHistory(mapId, userDetails.getId(), request); + + // 적용 후 최신 마인드맵 정보 반환 + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), ""); + return ResponseEntity.ok(responseDto); } + /** + * 프롬프트 히스토리 목록 조회 (페이징) + */ + @GetMapping("/{mapId}/prompts/histories") + public ResponseEntity> getPromptHistories( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size + ) { + Pageable pageable = PageRequest.of(page, size, Sort.by("createdAt").descending()); + Page responseDto = promptHistoryService.getPromptHistories(mapId, userDetails.getId(), pageable); + return ResponseEntity.ok(responseDto); + } + /** + * 특정 프롬프트 히스토리 미리보기 조회 + */ + @GetMapping("/{mapId}/prompts/histories/{historyId}/preview") + public ResponseEntity getPromptHistoryPreview( + @PathVariable Long mapId, + @PathVariable Long historyId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + PromptPreviewResponseDto responseDto = promptHistoryService.getPromptHistoryPreview(mapId, historyId, userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + /** + * 프롬프트 히스토리 삭제 (적용되지 않은 것만) + */ + @DeleteMapping("/{mapId}/prompts/histories/{historyId}") + public ResponseEntity deletePromptHistory( + @PathVariable Long mapId, + @PathVariable Long historyId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + promptHistoryService.deletePromptHistory(mapId, historyId, userDetails.getId()); + return ResponseEntity.ok().build(); + } + + /** + * 현재 적용된 프롬프트 히스토리 조회 + */ + @GetMapping("/{mapId}/prompts/applied") + public ResponseEntity getAppliedPromptHistory( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(promptHistoryService.getAppliedPromptHistory(mapId, userDetails.getId())); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java index 1dcf335..f358078 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java @@ -1,24 +1,59 @@ package com.teamEWSN.gitdeun.mindmap.controller; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.service.MindmapSseService; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +@Slf4j @RestController -@RequestMapping("/api/mindmaps/{mapId}/sse") +@RequestMapping("/api/mindmaps") @RequiredArgsConstructor public class MindmapSseController { private final MindmapSseService mindmapSseService; + private final MindmapAuthService mindmapAuthService; - // 클라이언트의 특정 마인드맵의 업데이트를 구독 - @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter subscribeToMindmapUpdates(@PathVariable Long mapId) { - return mindmapSseService.subscribe(mapId); + /** + * 마인드맵 실시간 연결 (SSE) + */ + @GetMapping(value = "/{mapId}/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamMindmapUpdates( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + // VIEW 권한 확인 + if (!mindmapAuthService.hasView(mapId, userDetails.getId())) { + log.warn("SSE 연결 권한 없음 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userDetails.getId()); + SseEmitter emitter = new SseEmitter(0L); + try { + emitter.send(SseEmitter.event() + .name("error") + .data("접근 권한이 없습니다.")); + emitter.complete(); + } catch (Exception e) { + emitter.completeWithError(e); + } + return emitter; + } + + log.info("SSE 연결 요청 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userDetails.getId()); + return mindmapSseService.createConnection(mapId, userDetails.getId()); + } + + /** + * 현재 연결된 사용자 수 조회 + */ + @GetMapping("/{mapId}/connections/count") + public int getConnectionCount(@PathVariable Long mapId) { + return mindmapSseService.getConnectionCount(mapId); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java index 6b8349d..5d63d96 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java @@ -1,6 +1,5 @@ package com.teamEWSN.gitdeun.mindmap.dto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import lombok.Getter; import lombok.NoArgsConstructor; @@ -8,8 +7,5 @@ @NoArgsConstructor public class MindmapCreateRequestDto { private String repoUrl; - private String prompt; // Optional, 'DEV' 타입일 때 사용자가 입력하는 명령어 - private MindmapType type; - - private String field; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 + private String prompt; } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResultDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResultDto.java deleted file mode 100644 index f49075c..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreationResultDto.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.dto; - -import com.teamEWSN.gitdeun.repo.entity.Repo; -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public class MindmapCreationResultDto { - private final Repo repo; - private final String mapData; - private final String field; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java index 940e119..8251b1c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java @@ -1,22 +1,24 @@ package com.teamEWSN.gitdeun.mindmap.dto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import java.time.LocalDateTime; +import java.util.List; @Getter @Builder @AllArgsConstructor public class MindmapDetailResponseDto { private Long mindmapId; - private String field; // 제목 ("개발용", "확인용(n)" 등) - private MindmapType type; + private String title; private String branch; - private String prompt; private String mapData; // 핵심 데이터인 마인드맵 JSON private LocalDateTime createdAt; private LocalDateTime updatedAt; + + private List promptHistories; + private PromptHistoryResponseDto appliedPromptHistory; // 현재 적용된 프롬프트 } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java index c47e190..16dd583 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java @@ -1,6 +1,5 @@ package com.teamEWSN.gitdeun.mindmap.dto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -13,7 +12,7 @@ public class MindmapResponseDto { private Long mindmapId; private Long repoId; - private MindmapType type; - private String field; + private String title; + private String prompt; private LocalDateTime createdAt; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapTitleUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapTitleUpdateDto.java new file mode 100644 index 0000000..28c1a70 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapTitleUpdateDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MindmapTitleUpdateDto { + private String title; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/MindmapPromptAnalysisDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/MindmapPromptAnalysisDto.java new file mode 100644 index 0000000..7565632 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/MindmapPromptAnalysisDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.mindmap.dto.prompt; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MindmapPromptAnalysisDto { + private String prompt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java new file mode 100644 index 0000000..548064f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptApplyRequestDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.mindmap.dto.prompt; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PromptApplyRequestDto { + private Long historyId; // 적용할 히스토리 ID +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java new file mode 100644 index 0000000..fbe0698 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptHistoryResponseDto.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.mindmap.dto.prompt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class PromptHistoryResponseDto { + private Long historyId; + private String prompt; + private String title; + private Boolean applied; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java new file mode 100644 index 0000000..96e7de9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/prompt/PromptPreviewResponseDto.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.mindmap.dto.prompt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class PromptPreviewResponseDto { + private Long historyId; + private String prompt; + private String title; + private String previewMapData; // 미리보기용 맵 데이터 + private LocalDateTime createdAt; + private Boolean applied; // 현재 적용 상태 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index 581e031..838b657 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -8,12 +8,23 @@ import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + @Entity @Getter @Builder @NoArgsConstructor @AllArgsConstructor @Table(name = "mindmap") +@NamedEntityGraph( + name = "Mindmap.detail", + attributeNodes = { + @NamedAttributeNode("repo"), + @NamedAttributeNode("promptHistories") + } +) public class Mindmap extends AuditedEntity { @Id @@ -35,12 +46,8 @@ public class Mindmap extends AuditedEntity { @Column(length = 100, nullable = false) private String branch; - @Enumerated(EnumType.STRING) - @Column(name = "type", nullable = false) - private MindmapType type; - - @Column(name = "Field", length = 255, nullable = false) - private String field; + @Column(name = "title", length = 255, nullable = false) + private String title; @JdbcTypeCode(SqlTypes.JSON) @Column(name = "map_data", columnDefinition = "json", nullable = false) @@ -51,8 +58,47 @@ public class Mindmap extends AuditedEntity { @Column(name = "member_count", nullable = false) private Integer memberCount = 1; + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder.Default + @OneToMany(mappedBy = "mindmap", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private List promptHistories = new ArrayList<>(); + + public void updateMapData(String newMapData) { this.mapData = newMapData; } + public void updateTitle(String newTitle) { + this.title = newTitle; + } + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } + + public boolean isDeleted() { + return this.deletedAt != null; + } + + // 현재 적용된 프롬프트 히스토리 조회 + public PromptHistory getAppliedPromptHistory() { + return promptHistories.stream() + .filter(PromptHistory::getApplied) + .findFirst() + .orElse(null); + } + + // 특정 프롬프트 히스토리의 결과 적용 + public void applyPromptHistory(PromptHistory promptHistory) { + // 기존 적용 상태 해제 + promptHistories.forEach(PromptHistory::unapply); + + // 새 프롬프트 적용 + promptHistory.applyToMindmap(); + this.mapData = promptHistory.getMapData(); + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java deleted file mode 100644 index dfeefec..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.entity; - -public enum MindmapType { - DEV, - CHECK -} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java new file mode 100644 index 0000000..b1eec3f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/PromptHistory.java @@ -0,0 +1,47 @@ +package com.teamEWSN.gitdeun.mindmap.entity; + +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "prompt_history") +public class PromptHistory extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(columnDefinition = "TEXT", nullable = false) + private String prompt; + + @Column(length = 50) + private String title; // 분석 결과 요약 (기록 제목) + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "map_data", columnDefinition = "json", nullable = false) + private String mapData; // 해당 프롬프트의 분석 결과 데이터 + + @Builder.Default + @Column(name = "applied", nullable = false) + private Boolean applied = false; // 적용 확정 여부 + + public void applyToMindmap() { + this.applied = true; + } + + public void unapply() { + this.applied = false; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java index 1bc8429..45210fc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -2,17 +2,30 @@ import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.ReportingPolicy; -@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +import java.util.List; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE, uses = { PromptHistoryMapper.class }) public interface MindmapMapper { - @Mapping(source = "id", target = "mindmapId") + /** + * 마인드맵 기본 정보 매핑 + */ + @Mapping(source = "id", target = "mindmapId") + @Mapping(source = "repo.id", target = "repoId") MindmapResponseDto toResponseDto(Mindmap mindmap); - @Mapping(source = "id", target = "mindmapId") + /** + * 마인드맵 상세 정보 매핑 (프롬프트 히스토리 제외) + */ + @Mapping(source = "id", target = "mindmapId") + @Mapping(source = "promptHistories", target = "promptHistories") + @Mapping(source = "appliedPromptHistory", target = "appliedPromptHistory") MindmapDetailResponseDto toDetailResponseDto(Mindmap mindmap); + } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java new file mode 100644 index 0000000..a79d155 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/PromptHistoryMapper.java @@ -0,0 +1,33 @@ +package com.teamEWSN.gitdeun.mindmap.mapper; + +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface PromptHistoryMapper { + + /** + * PromptHistory 엔티티 → PromptHistoryResponseDto 변환 + */ + @Mapping(source = "id", target = "historyId") + PromptHistoryResponseDto toResponseDto(PromptHistory promptHistory); + + /** + * PromptHistory 엔티티 → PromptPreviewResponseDto 변환 + */ + @Mapping(source = "id", target = "historyId") + @Mapping(source = "mapData", target = "previewMapData") + PromptPreviewResponseDto toPreviewResponseDto(PromptHistory promptHistory); + + /** + * PromptHistory 리스트 → PromptHistoryResponseDto 리스트 변환 + */ + List toResponseDtoList(List promptHistories); + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java index 649338b..31ec7c5 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java @@ -2,21 +2,28 @@ import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; @Repository public interface MindmapRepository extends JpaRepository { - // 사용자가 생성한 확인용 마인드맵 중 가장 최근에 생성된 것(repo 무관) - @Query("SELECT m FROM Mindmap m " + - "WHERE m.user = :user AND m.type = 'CHECK' " + - "ORDER BY m.createdAt DESC LIMIT 1") - Optional findTopByUserAndTypeOrderByCreatedAtDesc( - @Param("user") User user - ); + /** + * 사용자의 삭제되지 않은 마인드맵 개수 조회 (제목 자동 생성용) + */ + @Query("SELECT COUNT(m) FROM Mindmap m WHERE m.user = :user AND m.deletedAt IS NULL") + long countByUserAndDeletedAtIsNull(@Param("user") User user); + + /** + * 삭제되지 않은 마인드맵 조회 + */ + @EntityGraph(value = "Mindmap.detail", type = EntityGraph.EntityGraphType.LOAD) + Optional findByIdAndDeletedAtIsNull(Long id); + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/PromptHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/PromptHistoryRepository.java new file mode 100644 index 0000000..7c3b6e1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/PromptHistoryRepository.java @@ -0,0 +1,32 @@ +package com.teamEWSN.gitdeun.mindmap.repository; + +import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PromptHistoryRepository extends JpaRepository { + + /** + * 특정 마인드맵의 프롬프트 히스토리를 최신순으로 조회 + */ + Page findByMindmapIdOrderByCreatedAtDesc(Long mindmapId, Pageable pageable); + + /** + * 현재 적용된 프롬프트 히스토리 조회 + */ + @Query("SELECT p FROM PromptHistory p WHERE p.mindmap.id = :mindmapId AND p.applied = true") + Optional findAppliedPromptByMindmapId(@Param("mindmapId") Long mindmapId); + + /** + * 마인드맵과 히스토리 ID로 조회 (권한 검증) + */ + @Query("SELECT p FROM PromptHistory p WHERE p.id = :historyId AND p.mindmap.id = :mindmapId") + Optional findByIdAndMindmapId(@Param("historyId") Long historyId, @Param("mindmapId") Long mindmapId); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 3bdd6e5..3404d68 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -7,21 +7,17 @@ import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreationResultDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; -import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.*; import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; +import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; -import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; import com.teamEWSN.gitdeun.repo.entity.Repo; import com.teamEWSN.gitdeun.repo.repository.RepoRepository; -import com.teamEWSN.gitdeun.repo.service.RepoService; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; @@ -34,8 +30,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Slf4j @Service @@ -45,58 +39,59 @@ public class MindmapService { private final VisitHistoryService visitHistoryService; private final MindmapSseService mindmapSseService; private final MindmapAuthService mindmapAuthService; + private final PromptHistoryService promptHistoryService; private final MindmapMapper mindmapMapper; private final MindmapRepository mindmapRepository; private final MindmapMemberRepository mindmapMemberRepository; private final RepoRepository repoRepository; private final UserRepository userRepository; private final FastApiClient fastApiClient; - private final ObjectMapper objectMapper; // JSON 직렬화 - + private final ObjectMapper objectMapper; + // 마인드맵 생성 @Transactional - public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, Long userId, String authorizationHeader) { + public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId, String authorizationHeader) { User user = userRepository.findByIdAndDeletedAtIsNull(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); - // 입력 검증 - if (req.getType() == MindmapType.DEV && !StringUtils.hasText(req.getPrompt())) { - throw new IllegalArgumentException("DEV 타입 마인드맵은 프롬프트가 필수입니다."); - } - String normalizedUrl = normalizeRepoUrl(req.getRepoUrl()); - // 타입별 처리 - MindmapCreationResultDto result; - if (req.getType() == MindmapType.DEV) { - result = createDevMindmap(normalizedUrl, req.getPrompt(), authorizationHeader); - } else { - result = createCheckMindmap(normalizedUrl, req.getField(), user, authorizationHeader); - } + // 1. Repository 처리 + Repo repo = processRepository(normalizedUrl, authorizationHeader); - // 마인드맵 엔티티 생성 + // 2. FastAPI를 통해 분석 수행 및 AI 생성 제목과 맵 데이터 획득 + AnalysisResultDto analysisResult = generateMapDataWithAnalysis(normalizedUrl, req.getPrompt(), authorizationHeader); + + // 3. AI가 생성한 제목 사용, 실패 시 기본 제목 + String title = determineAIGeneratedTitle(analysisResult, user); + + // 4. 마인드맵 엔티티 생성 Mindmap mindmap = Mindmap.builder() - .repo(result.getRepo()) + .repo(repo) .user(user) - .prompt(req.getType() == MindmapType.DEV ? req.getPrompt() : null) - .branch(result.getRepo().getDefaultBranch()) - .type(req.getType()) - .field(result.getField()) - .mapData(result.getMapData()) + .branch(repo.getDefaultBranch()) + .title(title) + .mapData(analysisResult.getMapData()) .build(); mindmapRepository.save(mindmap); - // 소유자 등록 및 방문 기록 + // 5. 초기 프롬프트 히스토리 생성 (프롬프트가 있는 경우) + if (StringUtils.hasText(req.getPrompt())) { + promptHistoryService.createInitialPromptHistory(mindmap, req.getPrompt(), analysisResult.getMapData(), + analysisResult.getTitle()); + } + + // 6. 소유자 등록 및 방문 기록 mindmapMemberRepository.save(MindmapMember.of(mindmap, user, MindmapRole.OWNER)); visitHistoryService.createVisitHistory(user, mindmap); - log.info("마인드맵 생성 완료 - ID: {}, Type: {}, Field: {}", mindmap.getId(), req.getType(), result.getField()); + log.info("마인드맵 생성 완료 - ID: {}, AI 생성 제목: {}", mindmap.getId(), title); return mindmapMapper.toResponseDto(mindmap); } /** - * 마인드맵 상세 정보 조회 - ArangoDB와 동기화된 최신 데이터 반환 + * 마인드맵 상세 정보 조회 */ @Transactional public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { @@ -104,65 +99,122 @@ public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String autho throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - Mindmap mindmap = mindmapRepository.findById(mapId) + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - // ArangoDB와 동기화하여 최신 데이터 반영 syncWithArangoDB(mindmap, authorizationHeader); return mindmapMapper.toDetailResponseDto(mindmap); } + /** + * 마인드맵 제목 수정 + */ + @Transactional + public MindmapDetailResponseDto updateMindmapTitle(Long mapId, Long userId, MindmapTitleUpdateDto req) { + + // EDIT 권한 필요 + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + mindmap.updateTitle(req.getTitle()); + + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + + // 제목 변경만 별도 브로드캐스트 + mindmapSseService.broadcastTitleChanged(mapId, req.getTitle()); + + log.info("마인드맵 제목 수정 완료 - ID: {}, 새 제목: {}", mapId, req.getTitle()); + return responseDto; + } + /** * 마인드맵 새로고침 */ @Transactional public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String authorizationHeader) { - Mindmap mindmap = mindmapRepository.findById(mapId) - .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - if (!mindmap.getUser().getId().equals(userId)) { + // 마인드맵 멤버 확인 + if (!mindmapAuthService.hasView(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + String repoUrl = mindmap.getRepo().getGithubRepoUrl(); - String newMapData; try { // 저장소 최신 설정 fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); fastApiClient.fetchRepo(repoUrl, authorizationHeader); - // 타입별 재분석 - if (mindmap.getType() == MindmapType.DEV) { - AnalysisResultDto analysisResult = fastApiClient.analyzeWithAi(repoUrl, mindmap.getPrompt(), MindmapType.DEV, authorizationHeader); - mindmap.getRepo().updateWithAnalysis(analysisResult); - newMapData = analysisResult.getMapData(); + // 현재 적용된 프롬프트 확인 + PromptHistory appliedPrompt = mindmap.getAppliedPromptHistory(); + AnalysisResultDto analysisResult; + + if (appliedPrompt != null && StringUtils.hasText(appliedPrompt.getPrompt())) { + analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, appliedPrompt.getPrompt(), authorizationHeader); } else { - AnalysisResultDto analysisResult = fastApiClient.analyzeWithAi(repoUrl, null, MindmapType.CHECK, authorizationHeader); - mindmap.getRepo().updateWithAnalysis(analysisResult); - newMapData = analysisResult.getMapData(); + analysisResult = fastApiClient.analyzeDefault(repoUrl, authorizationHeader); } - mindmap.updateMapData(newMapData); + mindmap.getRepo().updateWithAnalysis(analysisResult); + mindmap.updateMapData(analysisResult.getMapData()); + + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + mindmapSseService.broadcastUpdate(mapId, responseDto); + + return responseDto; } catch (Exception e) { log.error("마인드맵 새로고침 실패: {}", e.getMessage(), e); throw new RuntimeException("마인드맵 새로고침 중 오류가 발생했습니다: " + e.getMessage()); } + } - MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); - mindmapSseService.broadcastUpdate(mapId, responseDto); - return responseDto; + /** + * 마인드맵 소프트 삭제 + */ + @Transactional + public void deleteMindmap(Long mapId, Long userId, String authorizationHeader) { + + // Owner만 가능 + if (!mindmapAuthService.isOwner(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + try { + fastApiClient.deleteMindmapData(mindmap.getRepo().getGithubRepoUrl(), authorizationHeader); + log.info("ArangoDB 데이터 삭제 완료: {}", mindmap.getRepo().getGithubRepoUrl()); + } catch (Exception e) { + log.error("ArangoDB 데이터 삭제 실패, 마인드맵 소프트 삭제는 계속 진행: {}", e.getMessage()); + } + + // 소프트 삭제 수행 + mindmap.softDelete(); + log.info("마인드맵 소프트 삭제 완료: {}", mapId); } - // TODO: webhook을 통한 업데이트 + /** + * Webhook을 통한 마인드맵 업데이트 + */ @Transactional public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authorizationHeader) { Repo repo = repoRepository.findByGithubRepoUrl(dto.getRepoUrl()) .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_URL)); - List mindmapsToUpdate = repo.getMindmaps(); + // 삭제되지 않은 마인드맵만 업데이트 + List mindmapsToUpdate = repo.getMindmaps().stream() + .filter(mindmap -> !mindmap.isDeleted()) + .toList(); repo.updateWithWebhookData(dto); @@ -175,124 +227,80 @@ public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authorizationH } } - /** - * 마인드맵 삭제 - ArangoDB 데이터도 함께 삭제 - */ - @Transactional - public void deleteMindmap(Long mapId, Long userId, String authorizationHeader) { - Mindmap mindmap = mindmapRepository.findById(mapId) - .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - - if (!mindmap.getUser().getId().equals(userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); - } - - try { - fastApiClient.deleteMindmapData(mindmap.getRepo().getGithubRepoUrl(), authorizationHeader); - log.info("ArangoDB 데이터 삭제 완료: {}", mindmap.getRepo().getGithubRepoUrl()); - } catch (Exception e) { - log.error("ArangoDB 데이터 삭제 실패, MySQL 삭제는 계속 진행: {}", e.getMessage()); - } - - mindmapRepository.delete(mindmap); - log.info("마인드맵 삭제 완료: {}", mapId); - } - - // === Private Helper Methods === - /** - * DEV 타입 마인드맵 생성 - FastAPI에서 프롬프트 기반 제목도 생성 - */ - private MindmapCreationResultDto createDevMindmap(String repoUrl, String prompt, String authorizationHeader) { - log.info("DEV 타입 마인드맵 생성 시작 - repoUrl: {}, prompt: {}", repoUrl, prompt); - + private Repo processRepository(String repoUrl, String authHeader) { Optional existingRepo = repoRepository.findByGithubRepoUrl(repoUrl); Repo repo; if (existingRepo.isPresent()) { repo = existingRepo.get(); - log.info("기존 저장소 발견 (DEV): {}", repoUrl); + log.info("기존 저장소 발견: {}", repoUrl); - if (shouldUpdateRepo(repo, authorizationHeader)) { - log.info("저장소 업데이트 필요 (DEV): {}", repoUrl); - fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); - fastApiClient.fetchRepo(repoUrl, authorizationHeader); + if (shouldUpdateRepo(repo, authHeader)) { + log.info("저장소 업데이트 필요: {}", repoUrl); + fastApiClient.saveRepoInfo(repoUrl, authHeader); + fastApiClient.fetchRepo(repoUrl, authHeader); } } else { - log.info("새 저장소 (DEV): {}", repoUrl); + log.info("새 저장소: {}", repoUrl); repo = Repo.builder().githubRepoUrl(repoUrl).build(); - fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); - fastApiClient.fetchRepo(repoUrl, authorizationHeader); + fastApiClient.saveRepoInfo(repoUrl, authHeader); + fastApiClient.fetchRepo(repoUrl, authHeader); } - try { - // DEV 전용 분석 - FastAPI에서 프롬프트를 요약한 제목까지 반환받음 - AnalysisResultDto analysisResult = fastApiClient.analyzeWithAi(repoUrl, prompt, MindmapType.DEV, authorizationHeader); - repo.updateWithAnalysis(analysisResult); - repoRepository.save(repo); + return repo; + } - // FastAPI에서 생성한 제목 사용 - return new MindmapCreationResultDto(repo, analysisResult.getMapData(), analysisResult.getField()); + /** + * FastAPI를 통한 분석 수행 및 AI 생성 제목과 맵 데이터 획득 + */ + private AnalysisResultDto generateMapDataWithAnalysis(String repoUrl, String prompt, String authHeader) { + try { + AnalysisResultDto analysisResult; + if (StringUtils.hasText(prompt)) { + // 프롬프트가 있는 경우 - AI가 맞춤형 분석 및 제목 생성 + analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, prompt, authHeader); + log.info("프롬프트 기반 AI 분석 완료 - 생성된 제목: {}", analysisResult.getTitle()); + } else { + // 프롬프트가 없는 경우 - 기본 분석 (제목 생성 안됨) + analysisResult = fastApiClient.analyzeDefault(repoUrl, authHeader); + log.info("기본 분석 완료 - AI 제목 생성 없음"); + } + return analysisResult; } catch (Exception e) { - log.error("DEV 분석 실패: {}", e.getMessage(), e); - throw new RuntimeException("개발 방향성 분석 중 오류가 발생했습니다: " + e.getMessage()); + log.error("마인드맵 데이터 생성 실패: {}", e.getMessage(), e); + + // 분석 실패 시 예외를 다시 던져서 상위에서 처리하도록 함 + // 에러 메시지는 로그와 예외로만 관리 + throw new RuntimeException("FastAPI 분석 실패: " + e.getMessage(), e); } } /** - * CHECK 타입 마인드맵 생성 + * AI 생성 제목 결정 로직 + * 1. 프롬프트 있고 AI 제목 생성 성공 → AI 제목 사용 + * 2. 프롬프트 없거나 AI 제목 생성 실패 → 자동 번호 제목 */ - private MindmapCreationResultDto createCheckMindmap(String repoUrl, String userField, User user, String authorizationHeader) { - log.info("CHECK 타입 마인드맵 생성 시작 - repoUrl: {}, field: {}", repoUrl, userField); - - Optional existingRepo = repoRepository.findByGithubRepoUrl(repoUrl); - Repo repo; - String mapData; - - if (existingRepo.isPresent()) { - repo = existingRepo.get(); - log.info("기존 저장소 발견 (CHECK): {}", repoUrl); - - if (shouldUpdateRepo(repo, authorizationHeader)) { - log.info("저장소 업데이트 필요 (CHECK): {}", repoUrl); - fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); - fastApiClient.fetchRepo(repoUrl, authorizationHeader); - AnalysisResultDto analysisResult = fastApiClient.analyzeWithAi(repoUrl, null, MindmapType.CHECK, authorizationHeader); - repo.updateWithAnalysis(analysisResult); - mapData = analysisResult.getMapData(); - } else { - log.info("저장소가 최신 상태 (CHECK), ArangoDB에서 기존 데이터 조회: {}", repoUrl); - mapData = getMapDataFromArangoDB(repoUrl, authorizationHeader); - } - } else { - log.info("새 저장소 (CHECK): {}", repoUrl); - repo = Repo.builder().githubRepoUrl(repoUrl).build(); - fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); - fastApiClient.fetchRepo(repoUrl, authorizationHeader); - AnalysisResultDto analysisResult = fastApiClient.analyzeWithAi(repoUrl, null, MindmapType.CHECK, authorizationHeader); - repo.updateWithAnalysis(analysisResult); - mapData = analysisResult.getMapData(); + private String determineAIGeneratedTitle(AnalysisResultDto analysisResult, User user) { + // AI가 제목을 성공적으로 생성한 경우 + if (analysisResult != null && StringUtils.hasText(analysisResult.getTitle())) { + log.info("AI 생성 제목 사용: {}", analysisResult.getTitle()); + return analysisResult.getTitle(); } - repoRepository.save(repo); + // AI 제목 생성 실패 또는 프롬프트 없는 경우 → 자동 번호 제목 + long userMindmapCount = mindmapRepository.countByUserAndDeletedAtIsNull(user); + String defaultTitle = "마인드맵 " + (userMindmapCount + 1); - // CHECK 타입 제목 결정 - String field; - if (StringUtils.hasText(userField)) { - field = userField; - } else { - long nextSeq = findNextCheckSequence(user); - field = "확인용 (" + nextSeq + ")"; - } - - return new MindmapCreationResultDto(repo, mapData, field); + log.info("기본 제목 사용: {}", defaultTitle); + return defaultTitle; } - private boolean shouldUpdateRepo(Repo repo, String authorizationHeader) { + private boolean shouldUpdateRepo(Repo repo, String authHeader) { try { - LocalDateTime githubLastCommit = fastApiClient.getRepositoryLastCommitTime(repo.getGithubRepoUrl(), authorizationHeader); + LocalDateTime githubLastCommit = fastApiClient.getRepositoryLastCommitTime(repo.getGithubRepoUrl(), authHeader); if (repo.getGithubLastUpdatedAt() == null) { return true; @@ -305,9 +313,9 @@ private boolean shouldUpdateRepo(Repo repo, String authorizationHeader) { } } - private String getMapDataFromArangoDB(String repoUrl, String authorizationHeader) { + private String getMapDataFromArangoDB(String repoUrl, String authHeader) { try { - MindmapGraphDto graphData = fastApiClient.getMindmapGraph(repoUrl, authorizationHeader); + MindmapGraphDto graphData = fastApiClient.getMindmapGraph(repoUrl, authHeader); return graphData != null ? objectMapper.writeValueAsString(graphData) : "{}"; } catch (Exception e) { log.warn("ArangoDB 데이터 조회 실패: {}", e.getMessage()); @@ -338,21 +346,4 @@ private String normalizeRepoUrl(String url) { .replaceAll("\\.git$", ""); } - private long findNextCheckSequence(User user) { - Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); - - if (lastCheckMindmap.isEmpty()) { - return 1; - } - - Pattern pattern = Pattern.compile("\\((\\d+)\\)"); - Matcher matcher = pattern.matcher(lastCheckMindmap.get().getField()); - - if (matcher.find()) { - long lastSeq = Long.parseLong(matcher.group(1)); - return lastSeq + 1; - } - - return 1; - } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java index d3389c1..0640518 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java @@ -1,102 +1,169 @@ package com.teamEWSN.gitdeun.mindmap.service; +import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; @Slf4j @Service +@RequiredArgsConstructor public class MindmapSseService { - // 스레드 안전(thread-safe)한 자료구조 사용 + private final ObjectMapper objectMapper; + private final Map> emitters = new ConcurrentHashMap<>(); - // 1시간 타임아웃 설정 + + // 타임아웃 설정(1시간) private static final long TIMEOUT_MS = 60L * 60L * 1000L; /** - * 클라이언트가 마인드맵 업데이트 구독을 요청할 때 호출됩니다. + * 마인드맵 실시간 연결 생성 */ - public SseEmitter subscribe(Long mapId) { + public SseEmitter createConnection(Long mapId, Long userId) { SseEmitter emitter = new SseEmitter(TIMEOUT_MS); - // 스레드 안전하게 emitter 추가 emitters.computeIfAbsent(mapId, k -> new CopyOnWriteArrayList<>()).add(emitter); - // 연결 종료, 타임아웃, 오류 발생 시 emitter 제거 - emitter.onCompletion(() -> removeEmitter(mapId, emitter)); - emitter.onTimeout(() -> removeEmitter(mapId, emitter)); - emitter.onError(e -> removeEmitter(mapId, emitter)); + // 연결 종료 시 정리 (기존 로직 개선) + emitter.onCompletion(() -> removeEmitter(mapId, userId, emitter)); + emitter.onTimeout(() -> removeEmitter(mapId, userId, emitter)); + emitter.onError(throwable -> { + log.error("SSE 연결 오류 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userId, throwable); + removeEmitter(mapId, userId, emitter); + }); - // 연결 성공을 알리는 초기 이벤트 전송 - sendToEmitter(emitter, "connect", "Connected to mindmap updates for mapId: " + mapId); + // 연결 확인용 초기 메시지 + sendToEmitter(emitter, "마인드맵 " + mapId + " 실시간 연결 성공"); + log.info("SSE 연결 생성 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userId); return emitter; } /** - * 마인드맵 업데이트 시 모든 구독자에게 브로드캐스트합니다. + * 마인드맵 업데이트 브로드캐스트 + */ + public void broadcastUpdate(Long mapId, MindmapDetailResponseDto data) { + sendToMapSubscribers(mapId, "mindmap-update", data); + } + + /** + * 프롬프트 적용 브로드캐스트 + */ + public void broadcastPromptApplied(Long mapId, Long historyId) { + Map eventData = Map.of( + "type", "prompt_applied", + "historyId", historyId, + "message", "새로운 프롬프트가 적용되었습니다." + ); + sendToMapSubscribers(mapId, "prompt-applied", eventData); + } + + /** + * 제목 변경 브로드캐스트 */ - public void broadcastUpdate(Long mapId, MindmapDetailResponseDto updatedMindmap) { - sendToMapSubscribers(mapId, "mindmap-update", updatedMindmap); + public void broadcastTitleChanged(Long mapId, String newTitle) { + Map eventData = Map.of( + "type", "title_changed", + "newTitle", newTitle, + "message", "마인드맵 제목이 변경되었습니다." + ); + sendToMapSubscribers(mapId, "title-changed", eventData); } /** - * 특정 마인드맵의 모든 구독자에게 이벤트를 전송합니다. + * 특정 마인드맵의 모든 구독자에게 이벤트 전송 */ private void sendToMapSubscribers(Long mapId, String eventName, Object data) { - List mapEmitters = emitters.getOrDefault(mapId, Collections.emptyList()); - if (mapEmitters.isEmpty()) { + List mapEmitters = emitters.get(mapId); + if (mapEmitters == null || mapEmitters.isEmpty()) { + log.debug("마인드맵 ID {} 에 연결된 클라이언트가 없음", mapId); return; } - // 전송 실패한 emitter를 추적하기 위한 리스트 - List deadEmitters = new ArrayList<>(); + // JSON 직렬화 한 번만 수행 + String jsonData; + try { + jsonData = objectMapper.writeValueAsString(data); + } catch (Exception e) { + log.error("SSE 데이터 직렬화 실패 - 마인드맵 ID: {}", mapId, e); + return; + } + + // 전송 실패한 emitter 추적 + List deadEmitters = new CopyOnWriteArrayList<>(); + mapEmitters.forEach(emitter -> { try { - sendToEmitter(emitter, eventName, data); - } catch (Exception e) { + emitter.send(SseEmitter.event() + .name(eventName) + .data(jsonData)); + } catch (IOException e) { deadEmitters.add(emitter); - log.warn("Failed to send SSE to mapId={}: {}", mapId, e.toString()); + log.warn("SSE 전송 실패 - 마인드맵 ID: {}, 이벤트: {}", mapId, eventName, e); } }); // 실패한 emitter 정리 - deadEmitters.forEach(emitter -> removeEmitter(mapId, emitter)); + deadEmitters.forEach(emitter -> removeEmitterOnly(mapId, emitter)); + + log.debug("SSE 브로드캐스트 완료 - 마인드맵 ID: {}, 이벤트: {}, 성공: {}, 실패: {}", + mapId, eventName, mapEmitters.size() - deadEmitters.size(), deadEmitters.size()); } /** - * 개별 emitter에게 이벤트를 전송합니다. + * 개별 emitter에게 초기 메시지 전송 */ - private void sendToEmitter(SseEmitter emitter, String eventName, Object data) { + private void sendToEmitter(SseEmitter emitter, Object data) { try { - emitter.send(SseEmitter.event().name(eventName).data(data)); + String jsonData = objectMapper.writeValueAsString(data); + emitter.send(SseEmitter.event() + .name("connected") + .data(jsonData)); } catch (IOException e) { - // 전송 실패 시 런타임 예외 발생 - throw new RuntimeException("SSE data sending failed", e); + log.warn("초기 SSE 메시지 전송 실패", e); + // 초기 연결 실패는 런타임 예외로 처리하지 않음 } } /** - * 특정 마인드맵 구독 목록에서 emitter를 제거합니다. - * 리스트가 비면 맵에서도 해당 항목을 삭제합니다. + * 사용자별 emitter 제거 */ - private void removeEmitter(Long mapId, SseEmitter emitter) { + private void removeEmitter(Long mapId, Long userId, SseEmitter emitter) { + + // 마인드맵별 연결 제거 + removeEmitterOnly(mapId, emitter); + + log.info("SSE 연결 해제 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userId); + } + + /** + * emitter만 제거 + */ + private void removeEmitterOnly(Long mapId, SseEmitter emitter) { List mapEmitters = emitters.get(mapId); if (mapEmitters != null) { mapEmitters.remove(emitter); if (mapEmitters.isEmpty()) { emitters.remove(mapId); - log.info("No more subscribers for mapId={}, removing from map.", mapId); + log.info("마인드맵 ID {} 의 모든 구독자 연결 종료", mapId); } } } -} + + /** + * 연결 수 조회 + */ + public int getConnectionCount(Long mapId) { + List mapEmitters = emitters.get(mapId); + return mapEmitters != null ? mapEmitters.size() : 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java new file mode 100644 index 0000000..4597355 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/PromptHistoryService.java @@ -0,0 +1,215 @@ +package com.teamEWSN.gitdeun.mindmap.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.MindmapPromptAnalysisDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptApplyRequestDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptHistoryResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmap.entity.PromptHistory; +import com.teamEWSN.gitdeun.mindmap.mapper.PromptHistoryMapper; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.mindmap.repository.PromptHistoryRepository; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapAuthService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class PromptHistoryService { + + private final PromptHistoryRepository promptHistoryRepository; + private final MindmapRepository mindmapRepository; + private final MindmapAuthService mindmapAuthService; + private final FastApiClient fastApiClient; + private final MindmapSseService mindmapSseService; + private final PromptHistoryMapper promptHistoryMapper; + + /** + * 프롬프트 분석 및 미리보기 생성 + */ + public PromptPreviewResponseDto createPromptPreview(Long mapId, Long userId, MindmapPromptAnalysisDto req, String authorizationHeader) { + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + String repoUrl = mindmap.getRepo().getGithubRepoUrl(); + + try { + AnalysisResultDto analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, req.getPrompt(), authorizationHeader); + + // FastAPI로부터 받은 analysisSummary 사용 + String summary = analysisResult.getTitle(); + + // analysisSummary가 없거나 비어있는 경우 대체 로직 사용 + if (summary == null || summary.trim().isEmpty()) { + summary = generateFallbackSummary(req.getPrompt()); + } + + PromptHistory history = PromptHistory.builder() + .mindmap(mindmap) + .prompt(req.getPrompt()) + .title(summary) + .mapData(analysisResult.getMapData()) + .applied(false) + .build(); + + promptHistoryRepository.save(history); + + log.info("프롬프트 미리보기 생성 완료 - 마인드맵 ID: {}, 히스토리 ID: {}", mapId, history.getId()); + + // 매퍼를 활용한 변환 + return promptHistoryMapper.toPreviewResponseDto(history); + + } catch (Exception e) { + log.error("프롬프트 미리보기 생성 실패: {}", e.getMessage(), e); + throw new RuntimeException("프롬프트 분석 중 오류가 발생했습니다: " + e.getMessage()); + } + } + + /** + * 프롬프트 히스토리 목록 조회 + */ + @Transactional(readOnly = true) + public Page getPromptHistories(Long mapId, Long userId, Pageable pageable) { + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + Page historiesPage = promptHistoryRepository.findByMindmapIdOrderByCreatedAtDesc(mapId, pageable); + + return historiesPage.map(promptHistoryMapper::toResponseDto); + } + + /** + * 특정 프롬프트 히스토리의 상세 미리보기 조회 + */ + @Transactional(readOnly = true) + public PromptPreviewResponseDto getPromptHistoryPreview(Long mapId, Long historyId, Long userId) { + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + PromptHistory history = promptHistoryRepository.findByIdAndMindmapId(historyId, mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.PROMPT_HISTORY_NOT_FOUND)); + + // 매퍼 활용 + return promptHistoryMapper.toPreviewResponseDto(history); + } + + /** + * 현재 적용된 프롬프트 히스토리 조회 + */ + @Transactional(readOnly = true) + public PromptHistoryResponseDto getAppliedPromptHistory(Long mapId, Long userId) { + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + return promptHistoryRepository.findAppliedPromptByMindmapId(mapId) + .map(promptHistoryMapper::toResponseDto) + .orElse(null); + } + + /** + * 프롬프트 히스토리 적용 + */ + public void applyPromptHistory(Long mapId, Long userId, PromptApplyRequestDto req) { + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + PromptHistory historyToApply = promptHistoryRepository.findByIdAndMindmapId(req.getHistoryId(), mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.PROMPT_HISTORY_NOT_FOUND)); + + mindmap.applyPromptHistory(historyToApply); + + // 마인드맵 제목도 프롬프트 타이틀로 업데이트 + mindmap.updateTitle(historyToApply.getTitle()); + + log.info("프롬프트 히스토리 적용 완료 - 마인드맵 ID: {}, 히스토리 ID: {}, 제목 변경: {}", + mapId, req.getHistoryId(), historyToApply.getTitle()); + + // 제목 변경 브로드캐스트 추가 + mindmapSseService.broadcastTitleChanged(mapId, historyToApply.getTitle()); + + + mindmapSseService.broadcastPromptApplied(mapId, historyToApply.getId()); + } + + /** + * 프롬프트 히스토리 삭제 + */ + public void deletePromptHistory(Long mapId, Long historyId, Long userId) { + if (!mindmapAuthService.hasEdit(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + PromptHistory history = promptHistoryRepository.findByIdAndMindmapId(historyId, mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.PROMPT_HISTORY_NOT_FOUND)); + + if (history.getApplied()) { + throw new GlobalException(ErrorCode.CANNOT_DELETE_APPLIED_PROMPT); + } + + promptHistoryRepository.delete(history); + log.info("프롬프트 히스토리 삭제 완료 - 히스토리 ID: {}", historyId); + } + + /** + * 마인드맵 생성 시 초기 프롬프트 히스토리 생성 + */ + public void createInitialPromptHistory(Mindmap mindmap, String prompt, String mapData, String promptTitle) { + if (prompt != null && !prompt.trim().isEmpty()) { + PromptHistory history = PromptHistory.builder() + .mindmap(mindmap) + .prompt(prompt) + .title(promptTitle) + .mapData(mapData) + .applied(true) + .build(); + + promptHistoryRepository.save(history); + log.info("초기 프롬프트 히스토리 생성 완료 - 마인드맵 ID: {}", mindmap.getId()); + } + } + + /** + * 프롬프트 결과 대체 요약 생성 + */ + private String generateFallbackSummary(String prompt) { + if (prompt == null) { + return "기본 분석"; + } + + if (prompt.length() > 24) { + return prompt.substring(0, 24) + "..."; + } + return prompt; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java index 4aeb0bb..23abfce 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -3,6 +3,9 @@ import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; 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.stereotype.Repository; import java.util.Collection; @@ -12,17 +15,38 @@ public interface MindmapMemberRepository extends JpaRepository { /* OWNER/EDITOR/VIEWER 여부 */ - boolean existsByMindmapIdAndUserId(Long mindmapId, Long userId); - - boolean existsByMindmapIdAndUserIdAndRole(Long mindmapId, Long userId, MindmapRole role); - - boolean existsByMindmapIdAndUserIdAndRoleIn(Long mindmapId, Long userId, Collection roles); - - // 권한 변경 - Optional findByIdAndMindmapId(Long memberId, Long mindmapId); + // 삭제되지 않은 마인드맵의 멤버십만 확인 + @Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END " + + "FROM MindmapMember m WHERE m.mindmap.id = :mindmapId AND m.user.id = :userId " + + "AND m.mindmap.deletedAt IS NULL") + boolean existsByMindmapIdAndUserId(@Param("mindmapId") Long mindmapId, @Param("userId") Long userId); + + @Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END " + + "FROM MindmapMember m WHERE m.mindmap.id = :mindmapId AND m.user.id = :userId " + + "AND m.role = :role AND m.mindmap.deletedAt IS NULL") + boolean existsByMindmapIdAndUserIdAndRole(@Param("mindmapId") Long mindmapId, + @Param("userId") Long userId, + @Param("role") MindmapRole role); + + @Query("SELECT CASE WHEN COUNT(m) > 0 THEN true ELSE false END " + + "FROM MindmapMember m WHERE m.mindmap.id = :mindmapId AND m.user.id = :userId " + + "AND m.role IN :roles AND m.mindmap.deletedAt IS NULL") + boolean existsByMindmapIdAndUserIdAndRoleIn(@Param("mindmapId") Long mindmapId, + @Param("userId") Long userId, + @Param("roles") Collection roles); + + // 삭제되지 않은 마인드맵의 멤버만 조회(권한 변경) + @Query("SELECT m FROM MindmapMember m WHERE m.id = :memberId AND m.mindmap.id = :mindmapId " + + "AND m.mindmap.deletedAt IS NULL") + Optional findByIdAndMindmapId(@Param("memberId") Long memberId, @Param("mindmapId") Long mindmapId); // OWNER가 멤버 추방 - void deleteByIdAndMindmapId(Long memberId, Long mindmapId); - - Optional findByMindmapIdAndRole(Long mapId, MindmapRole mindmapRole); + @Modifying + @Query("DELETE FROM MindmapMember m WHERE m.id = :memberId AND m.mindmap.id = :mindmapId " + + "AND m.mindmap.deletedAt IS NULL") + void deleteByIdAndMindmapId(@Param("memberId") Long memberId, @Param("mindmapId") Long mindmapId); + + @Query("SELECT m FROM MindmapMember m WHERE m.mindmap.id = :mapId AND m.role = :role " + + "AND m.mindmap.deletedAt IS NULL") + Optional findByMindmapIdAndRole(@Param("mapId") Long mapId, @Param("role") MindmapRole role); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java index 25ba2a0..67a6d02 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java @@ -1,5 +1,6 @@ package com.teamEWSN.gitdeun.mindmapmember.service; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; import lombok.RequiredArgsConstructor; @@ -11,21 +12,25 @@ @RequiredArgsConstructor public class MindmapAuthService { - private final MindmapMemberRepository memberRepo; + private final MindmapMemberRepository memberRepository; + private final MindmapRepository mindmapRepository; - /** OWNER 확인 */ + /** OWNER 확인 - 삭제되지 않은 마인드맵만 */ public boolean isOwner(Long mapId, Long userId) { - return memberRepo.existsByMindmapIdAndUserIdAndRole(mapId, userId, MindmapRole.OWNER); + return mindmapRepository.findByIdAndDeletedAtIsNull(mapId).isPresent() && + memberRepository.existsByMindmapIdAndUserIdAndRole(mapId, userId, MindmapRole.OWNER); } - /** 수정 권한(OWNER, EDITOR) */ + /** 수정 권한(OWNER, EDITOR) - 삭제되지 않은 마인드맵만 */ public boolean hasEdit(Long mapId, Long userId) { - return memberRepo.existsByMindmapIdAndUserIdAndRoleIn( - mapId, userId, List.of(MindmapRole.OWNER, MindmapRole.EDITOR)); + return mindmapRepository.findByIdAndDeletedAtIsNull(mapId).isPresent() && + memberRepository.existsByMindmapIdAndUserIdAndRoleIn( + mapId, userId, List.of(MindmapRole.OWNER, MindmapRole.EDITOR)); } - /** 열람 권한(모든 멤버) */ + /** 열람 권한(모든 멤버) - 삭제되지 않은 마인드맵만 */ public boolean hasView(Long mapId, Long userId) { - return memberRepo.existsByMindmapIdAndUserId(mapId, userId); + return mindmapRepository.findByIdAndDeletedAtIsNull(mapId).isPresent() && + memberRepository.existsByMindmapIdAndUserId(mapId, userId); } } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java index 51903c3..e04f630 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -49,7 +49,7 @@ public void notifyInvitation(Invitation invitation) { User invitee = invitation.getInvitee(); String message = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", invitation.getInviter().getName(), - invitation.getMindmap().getField()); + invitation.getMindmap().getTitle()); createAndSendNotification(NotificationCreateDto.actionable( invitee, @@ -68,7 +68,7 @@ public void notifyAcceptance(Invitation invitation) { User inviter = invitation.getInviter(); String message = String.format("'%s'님이 '%s' 마인드맵 초대를 수락했습니다.", invitation.getInvitee().getName(), - invitation.getMindmap().getField()); + invitation.getMindmap().getTitle()); createAndSendNotification(NotificationCreateDto.actionable( inviter, @@ -87,7 +87,7 @@ public void notifyLinkApprovalRequest(Invitation invitation) { User owner = invitation.getMindmap().getUser(); String message = String.format("'%s'님이 링크를 통해 '%s' 마인드맵 참여를 요청했습니다.", invitation.getInvitee().getName(), - invitation.getMindmap().getField()); + invitation.getMindmap().getTitle()); createAndSendNotification(NotificationCreateDto.actionable( owner, @@ -148,7 +148,7 @@ public UnreadNotificationCountDto getUnreadNotificationCount(Long userId) { } /** - * 알림 읽음 처리 + * TODO: 알림 읽음 처리 */ @Transactional public void markAsRead(Long notificationId, Long userId) { diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java index d21df1d..2c5072a 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java @@ -9,16 +9,18 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.Map; + @Slf4j @RestController -@RequestMapping("/api/history/{historyId}/mindmaps/pinned") +@RequestMapping("/api/history") @RequiredArgsConstructor public class PinnedHistoryController { private final PinnedHistoryService pinnedHistoryService; // 핀 고정 - @PostMapping + @PostMapping("/{historyId}/pin") public ResponseEntity fixPinned( @PathVariable("historyId") Long historyId, @AuthenticationPrincipal CustomUserDetails customUserDetails @@ -28,7 +30,7 @@ public ResponseEntity fixPinned( } // 핀 해제 - @DeleteMapping + @DeleteMapping("/{historyId}/pin") public ResponseEntity removePinned( @PathVariable("historyId") Long historyId, @AuthenticationPrincipal CustomUserDetails customUserDetails diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java index 164625e..48f626b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java @@ -2,15 +2,18 @@ import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryBroadcastService; import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.List; @@ -22,6 +25,19 @@ public class VisitHistoryController { private final VisitHistoryService visitHistoryService; + private final VisitHistoryBroadcastService visitHistoryBroadcastService; + + + /** + * 방문 기록 실시간 연결 (SSE) + */ + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamVisitHistoryUpdates( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("방문기록 SSE 연결 요청 - 사용자 ID: {}", userDetails.getId()); + return visitHistoryBroadcastService.createVisitHistoryConnection(userDetails.getId()); + } // 핀 고정되지 않은 방문 기록 조회 @GetMapping("/visits") diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/PinnedHistoryUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/PinnedHistoryUpdateDto.java new file mode 100644 index 0000000..99d523e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/PinnedHistoryUpdateDto.java @@ -0,0 +1,27 @@ +package com.teamEWSN.gitdeun.visithistory.dto; + +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +public class PinnedHistoryUpdateDto { + + /** + * 액션 타입: PIN_ADDED, PIN_REMOVED, PIN_LIMIT_WARNING + */ + private String action; + + private Long historyId; + private Long mindmapId; + + private String mindmapTitle; + + private long currentPinCount; + private int maxPinCount; + + // 타임스탬프 - 클라이언트 동기화 + @Builder.Default + private long timestamp = System.currentTimeMillis(); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java index 33b7796..b8fea12 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java @@ -10,7 +10,7 @@ public class VisitHistoryResponseDto { private Long visitHistoryId; private Long mindmapId; - private String mindmapField; // 마인드맵 제목 + private String mindmapTitle; private String repoUrl; private LocalDateTime lastVisitedAt; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java index d784911..8bb1a7c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java @@ -11,7 +11,7 @@ public interface VisitHistoryMapper { @Mapping(source = "id", target = "visitHistoryId") @Mapping(source = "mindmap.id", target = "mindmapId") - @Mapping(source = "mindmap.field", target = "mindmapField") + @Mapping(source = "mindmap.title", target = "mindmapTitle") @Mapping(source = "mindmap.repo.githubRepoUrl", target = "repoUrl") VisitHistoryResponseDto toResponseDto(VisitHistory visitHistory); } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java index e105e87..4c1669d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java @@ -2,9 +2,10 @@ import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -13,12 +14,37 @@ @Repository public interface PinnedHistoryRepository extends JpaRepository { - boolean existsByUserIdAndVisitHistoryId(Long userId, Long historyId); + /** + * 삭제되지 않은 마인드맵의 핀 고정 기록만 조회 (최신순, 최대 8개) + * - UI 표시용 + * - 소프트 삭제된 마인드맵은 제외 + */ + @Query("SELECT p FROM PinnedHistory p " + + "JOIN p.visitHistory v " + + "JOIN v.mindmap m " + + "WHERE p.user = :user AND m.deletedAt IS NULL " + + "ORDER BY p.createdAt DESC") + List findTop8ByUserAndNotDeletedMindmapOrderByCreatedAtDesc(@Param("user") User user); - long countByUser(User user); + /** + * 삭제되지 않은 마인드맵의 핀 고정 개수 + */ + @Query("SELECT COUNT(p) FROM PinnedHistory p " + + "JOIN p.visitHistory v " + + "JOIN v.mindmap m " + + "WHERE p.user = :user AND m.deletedAt IS NULL") + long countByUserAndNotDeletedMindmap(@Param("user") User user); + /** + * 삭제되지 않은 마인드맵의 특정 핀 고정 기록 존재 여부 + */ + @Query("SELECT CASE WHEN COUNT(p) > 0 THEN true ELSE false END FROM PinnedHistory p " + + "JOIN p.visitHistory v " + + "JOIN v.mindmap m " + + "WHERE p.user.id = :userId AND v.id = :historyId AND m.deletedAt IS NULL") + boolean existsByUserIdAndVisitHistoryIdAndNotDeletedMindmap(@Param("userId") Long userId, @Param("historyId") Long historyId); + + @EntityGraph(attributePaths = {"mindmap", "mindmap.repo"}) Optional findByUserIdAndVisitHistoryId(Long userId, Long historyId); - // 사용자의 핀 고정 기록 최신순 조회 - List findTop8ByUserOrderByCreatedAtDesc(User user); } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java index 803814e..6cc2376 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java @@ -14,9 +14,9 @@ @Repository public interface VisitHistoryRepository extends JpaRepository { - // 사용자의 핀 고정되지 않은 방문 기록을 최신순으로 조회 + // 삭제되지 않은 마인드맵의 핀 고정되지 않은 방문 기록을 최신순으로 조회 @Query("SELECT v FROM VisitHistory v LEFT JOIN v.pinnedHistorys p " + - "WHERE v.user = :user AND p IS NULL " + + "WHERE v.user = :user AND p IS NULL AND v.mindmap.deletedAt IS NULL " + "ORDER BY v.lastVisitedAt DESC") - Page findUnpinnedHistoriesByUser(@Param("user") User user, Pageable pageable); + Page findUnpinnedHistoriesByUserAndNotDeletedMindmap(@Param("user") User user, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java index 5f0bc2f..0533354 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java @@ -1,8 +1,10 @@ package com.teamEWSN.gitdeun.visithistory.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.visithistory.dto.PinnedHistoryUpdateDto; import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; @@ -17,32 +19,38 @@ @Slf4j @Service -@Transactional @RequiredArgsConstructor public class PinnedHistoryService { private final PinnedHistoryRepository pinnedHistoryRepository; private final UserRepository userRepository; private final VisitHistoryRepository visitHistoryRepository; + private final VisitHistoryBroadcastService visitHistoryBroadcastService; + @Transactional public void fixPinned(Long historyId, Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GlobalException(USER_NOT_FOUND_FIX_PIN)); - // 현재 사용자의 핀 개수를 확인 - long currentPinCount = pinnedHistoryRepository.countByUser(user); - if (currentPinCount >= 8) { - throw new GlobalException(PINNED_HISTORY_LIMIT_EXCEEDED); - } - VisitHistory visitHistory = visitHistoryRepository.findById(historyId) .orElseThrow(() -> new GlobalException(HISTORY_NOT_FOUND)); + // 마인드맵이 삭제되었는지 확인 + if (visitHistory.getMindmap().isDeleted()) { + throw new GlobalException(ErrorCode.MINDMAP_NOT_FOUND); + } + // 이미 핀 고정이 있는지 확인 - if (pinnedHistoryRepository.existsByUserIdAndVisitHistoryId(userId, historyId)) { + if (pinnedHistoryRepository.existsByUserIdAndVisitHistoryIdAndNotDeletedMindmap(userId, historyId)) { throw new GlobalException(PINNEDHISTORY_ALREADY_EXISTS); } + // 현재 핀 개수 확인 (삭제되지 않은 마인드맵) + long currentPinCount = pinnedHistoryRepository.countByUserAndNotDeletedMindmap(user); + if (currentPinCount >= 8) { + throw new GlobalException(PINNED_HISTORY_LIMIT_EXCEEDED); + } + PinnedHistory pin = PinnedHistory.builder() .user(user) .visitHistory(visitHistory) @@ -50,6 +58,18 @@ public void fixPinned(Long historyId, Long userId) { pinnedHistoryRepository.save(pin); + // 실시간 브로드캐스트 - 핀 고정 추가 + PinnedHistoryUpdateDto updateDto = PinnedHistoryUpdateDto.builder() + .action("PIN_ADDED") + .historyId(historyId) + .mindmapId(visitHistory.getMindmap().getId()) + .mindmapTitle(visitHistory.getMindmap().getTitle()) + .currentPinCount(currentPinCount + 1) + .maxPinCount(8) + .build(); + + visitHistoryBroadcastService.broadcastPinUpdate(userId, updateDto); + } @Transactional @@ -57,7 +77,23 @@ public void removePinned(Long historyId, Long userId) { PinnedHistory pin = pinnedHistoryRepository.findByUserIdAndVisitHistoryId(userId, historyId) .orElseThrow(() -> new GlobalException(PINNEDHISTORY_NOT_FOUND)); + VisitHistory visitHistory = pin.getVisitHistory(); pinnedHistoryRepository.delete(pin); + // 현재 활성 핀 개수 계산 (삭제되지 않은 마인드맵만) + long currentPinCount = pinnedHistoryRepository.countByUserAndNotDeletedMindmap(pin.getUser()); + + // 실시간 브로드캐스트 - 핀 해제 + PinnedHistoryUpdateDto updateDto = PinnedHistoryUpdateDto.builder() + .action("PIN_REMOVED") + .historyId(historyId) + .mindmapId(visitHistory.getMindmap().getId()) + .mindmapTitle(visitHistory.getMindmap().getTitle()) + .currentPinCount(currentPinCount) + .maxPinCount(8) + .build(); + + visitHistoryBroadcastService.broadcastPinUpdate(userId, updateDto); + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryBroadcastService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryBroadcastService.java new file mode 100644 index 0000000..6ec17bf --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryBroadcastService.java @@ -0,0 +1,124 @@ +package com.teamEWSN.gitdeun.visithistory.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.visithistory.dto.PinnedHistoryUpdateDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * 방문 기록 핀 고정/해제에 대한 실시간 알림 서비스 + * - 사용자별 SSE 연결 관리 + * - 핀 상태 변경 시 실시간 브로드캐스트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VisitHistoryBroadcastService { + + private final ObjectMapper objectMapper; + + // 사용자별 SSE 연결 관리 (한 사용자당 여러 탭 가능) + private final Map> userConnections = new ConcurrentHashMap<>(); + + private static final long TIMEOUT_MS = 30L * 60L * 1000L; // 30분 + + /** + * 사용자의 방문 기록 페이지 SSE 연결 생성 + */ + public SseEmitter createVisitHistoryConnection(Long userId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + + // 사용자별 연결 목록에 추가 + userConnections.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter); + + // 연결 종료 시 정리 + emitter.onCompletion(() -> removeConnection(userId, emitter)); + emitter.onTimeout(() -> removeConnection(userId, emitter)); + emitter.onError(throwable -> { + log.error("방문기록 SSE 연결 오류 - 사용자 ID: {}", userId, throwable); + removeConnection(userId, emitter); + }); + + // 연결 확인 메시지 + sendToEmitter(emitter); + + log.info("방문기록 SSE 연결 생성 - 사용자 ID: {}", userId); + return emitter; + } + + /** + * 핀 고정/해제 상태 변경 브로드캐스트 + */ + public void broadcastPinUpdate(Long userId, PinnedHistoryUpdateDto updateDto) { + CopyOnWriteArrayList connections = userConnections.get(userId); + + if (connections == null || connections.isEmpty()) { + log.debug("사용자 ID {} 의 활성 연결이 없음", userId); + return; + } + + try { + String jsonData = objectMapper.writeValueAsString(updateDto); + + // 실패한 연결들 수집 + CopyOnWriteArrayList deadEmitters = new CopyOnWriteArrayList<>(); + + connections.forEach(emitter -> { + try { + emitter.send(SseEmitter.event() + .name("pin_update") + .data(jsonData)); + } catch (IOException e) { + deadEmitters.add(emitter); + log.warn("핀 업데이트 SSE 전송 실패 - 사용자 ID: {}", userId, e); + } + }); + + // 실패한 연결 제거 + deadEmitters.forEach(emitter -> removeConnection(userId, emitter)); + + log.info("핀 상태 변경 브로드캐스트 완료 - 사용자 ID: {}, 액션: {}, 성공: {}, 실패: {}", + userId, updateDto.getAction(), + connections.size() - deadEmitters.size(), deadEmitters.size()); + + } catch (Exception e) { + log.error("핀 업데이트 브로드캐스트 실패 - 사용자 ID: {}", userId, e); + } + } + + /** + * 개별 emitter에게 메시지 전송 + */ + private void sendToEmitter(SseEmitter emitter) { + try { + String jsonData = objectMapper.writeValueAsString("방문기록 실시간 연결 성공"); + emitter.send(SseEmitter.event() + .name("connected") + .data(jsonData)); + } catch (IOException e) { + log.warn("초기 SSE 메시지 전송 실패", e); + } + } + + /** + * 연결 제거 + */ + private void removeConnection(Long userId, SseEmitter emitter) { + CopyOnWriteArrayList connections = userConnections.get(userId); + if (connections != null) { + connections.remove(emitter); + if (connections.isEmpty()) { + userConnections.remove(userId); + log.info("사용자 ID {} 의 모든 방문기록 SSE 연결 종료", userId); + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index f56c69d..2a87a15 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -33,6 +33,11 @@ public class VisitHistoryService { // 마인드맵 생성 시 호출되어 방문 기록을 생성 @Transactional public void createVisitHistory(User user, Mindmap mindmap) { + // 삭제된 마인드맵에는 방문 기록을 생성하지 않음 + if (mindmap.isDeleted()) { + return; + } + VisitHistory visitHistory = VisitHistory.builder() .user(user) .mindmap(mindmap) @@ -45,7 +50,9 @@ public void createVisitHistory(User user, Mindmap mindmap) { @Transactional(readOnly = true) public Page getVisitHistories(Long userId, Pageable pageable) { User user = userService.findById(userId); - Page histories = visitHistoryRepository.findUnpinnedHistoriesByUser(user, pageable); + + // 삭제되지 않은 마인드맵 필터링 + Page histories = visitHistoryRepository.findUnpinnedHistoriesByUserAndNotDeletedMindmap(user, pageable); return histories.map(visitHistoryMapper::toResponseDto); } @@ -53,10 +60,9 @@ public Page getVisitHistories(Long userId, Pageable pag @Transactional(readOnly = true) public List getPinnedHistories(Long userId) { User user = userService.findById(userId); - // 핀 고정 횟수에 제한이 있지만, 명시적으로 상위 8개만 조회 - List pinnedHistories = pinnedHistoryRepository.findTop8ByUserOrderByCreatedAtDesc(user); - // List를 스트림으로 변환하여 매핑 + List pinnedHistories = pinnedHistoryRepository.findTop8ByUserAndNotDeletedMindmapOrderByCreatedAtDesc(user); + return pinnedHistories.stream() .map(pinned -> visitHistoryMapper.toResponseDto(pinned.getVisitHistory())) .collect(Collectors.toList());