diff --git a/build.gradle b/build.gradle index a899501..089e5f4 100644 --- a/build.gradle +++ b/build.gradle @@ -81,13 +81,17 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // 파일의 매직 바이트 검사용 Apache Tika 라이브러리 + implementation 'org.apache.tika:tika-core:3.2.2' + // queryDsl implementation "com.querydsl:querydsl-core:${queryDslVersion}" implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" annotationProcessor ( "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta", + 'jakarta.annotation:jakarta.annotation-api', // JPA 메타모델 생성용 - "jakarta.persistence:jakarta.persistence-api:3.1.0" + 'jakarta.persistence:jakarta.persistence-api' ) // db @@ -117,12 +121,18 @@ dependencies { /** Q 클래스 생성 경로 지정 **/ def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile +// gradle clean 시에 QClass 디렉토리 삭제 +clean { + delete file(querydslDir) +} + +// 🔧 소스셋에 generated 디렉토리 추가 sourceSets { - main { java.srcDirs += querydslDir } + main.java.srcDirs += [ querydslDir ] } tasks.withType(JavaCompile).configureEach { - options.annotationProcessorGeneratedSourcesDirectory = querydslDir + options.generatedSourceOutputDirectory.set(querydslDir) } tasks.named('test') { diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java new file mode 100644 index 0000000..f71d246 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/controller/ApplicationController.java @@ -0,0 +1,129 @@ +package com.teamEWSN.gitdeun.Application.controller; + +import com.teamEWSN.gitdeun.Application.dto.*; +import com.teamEWSN.gitdeun.Application.service.ApplicationService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +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") +@RequiredArgsConstructor +@Tag(name = "Application", description = "모집 공고 지원 관련 API") +public class ApplicationController { + + private final ApplicationService applicationService; + + /** + * 모집 공고에 지원하기 + */ + @PostMapping("/recruitments/{recruitmentId}/applications") + public ResponseEntity createApplication( + @PathVariable Long recruitmentId, + @Valid @RequestBody ApplicationCreateRequestDto requestDto, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Creating application for recruitment: {} by user: {}", recruitmentId, userDetails.getId()); + ApplicationResponseDto response = applicationService.createApplication( + recruitmentId, requestDto, userDetails.getId() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 내 지원 목록 조회 + */ + @GetMapping("/users/me/applications") + public ResponseEntity> getMyApplications( + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + log.info("Getting applications for user: {}", userDetails.getId()); + Page applications = applicationService.getMyApplications( + userDetails.getId(), pageable + ); + return ResponseEntity.ok(applications); + } + + /** + * 특정 공고의 지원자 목록 조회 (모집자만 가능) + */ + @GetMapping("/recruitments/{recruitmentId}/applications") + public ResponseEntity> getRecruitmentApplications( + @PathVariable Long recruitmentId, + @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Page applications = applicationService.getRecruitmentApplications( + recruitmentId, userDetails.getId(), pageable + ); + return ResponseEntity.ok(applications); + } + + /** + * 지원 상세 조회 + */ + @GetMapping("/applications/{applicationId}") + public ResponseEntity getApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ApplicationResponseDto application = applicationService.getApplication( + applicationId, userDetails.getId() + ); + return ResponseEntity.ok(application); + } + + /** + * 지원 철회 (지원자만 가능) + */ + @PatchMapping("/applications/{applicationId}/withdraw") + public ResponseEntity withdrawApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + applicationService.withdrawApplication(applicationId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + /** + * 지원 수락 (모집자만 가능) + */ + @PatchMapping("/applications/{applicationId}/accept") + public ResponseEntity acceptApplication( + @PathVariable Long applicationId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ApplicationResponseDto response = applicationService.acceptApplication( + applicationId, userDetails.getId() + ); + return ResponseEntity.ok(response); + } + + /** + * 지원 거절 (모집자만 가능) + */ + @PatchMapping("/applications/{applicationId}/reject") + public ResponseEntity rejectApplication( + @PathVariable Long applicationId, + @Valid @RequestBody ApplicationStatusUpdateDto updateDto, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ApplicationResponseDto response = applicationService.rejectApplication( + applicationId, userDetails.getId(), updateDto + ); + return ResponseEntity.ok(response); + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java new file mode 100644 index 0000000..c3570f3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationCreateRequestDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationCreateRequestDto { + + @NotNull(message = "지원 분야를 선택해주세요.") + private RecruitmentField appliedField; + + @Size(max = 1000, message = "지원 메시지는 1000자 이내로 작성해주세요.") + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java new file mode 100644 index 0000000..accec9d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationListResponseDto.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationListResponseDto { + + private Long applicationId; + + // 지원자 간략 정보 + private Long applicantId; + private String applicantName; + private String applicantNickname; + private String applicantProfileImage; + + // 공고 간략 정보 + private String recruitmentTitle; + + // 지원 정보 + private RecruitmentField appliedField; + private ApplicationStatus status; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java new file mode 100644 index 0000000..45b5750 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationResponseDto.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationResponseDto { + + private Long applicationId; + + // 지원자 정보 + private Long applicantId; + private String applicantName; + private String applicantEmail; + private String applicantNickname; + private String applicantProfileImage; + + // 공고 정보 + private Long recruitmentId; + private String recruitmentTitle; + private String recruiterName; + + // 지원 정보 + private RecruitmentField appliedField; + private String message; + private ApplicationStatus status; + private String rejectReason; + private boolean active; + + // 날짜 정보 + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java new file mode 100644 index 0000000..347ccb1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/dto/ApplicationStatusUpdateDto.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.Application.dto; + +import jakarta.validation.constraints.Size; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ApplicationStatusUpdateDto { + + @Size(max = 500, message = "거절 사유는 500자 이내로 작성해주세요.") + private String rejectReason; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java index 48ae467..52c6084 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java +++ b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java @@ -11,8 +11,8 @@ @Table(name = "application", uniqueConstraints = { // 동일 사용자·공고에 대해 "활성" 신청 중복 방지용 (WITHDRAWN 제외) - @UniqueConstraint(name = "uk_active_application", - columnNames = {"recruitment_id", "applicant_id", "active"}) + @UniqueConstraint(name = "uk_application_recruitment_applicant", + columnNames = {"recruitment_id", "applicant_id"}) }, indexes = { @Index(name = "idx_application_recruitment", columnList = "recruitment_id"), diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java new file mode 100644 index 0000000..bac4b7e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/mapper/ApplicationMapper.java @@ -0,0 +1,32 @@ +package com.teamEWSN.gitdeun.Application.mapper; + +import com.teamEWSN.gitdeun.Application.dto.ApplicationListResponseDto; +import com.teamEWSN.gitdeun.Application.dto.ApplicationResponseDto; +import com.teamEWSN.gitdeun.Application.entity.Application; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface ApplicationMapper { + + @Mapping(source = "id", target = "applicationId") + @Mapping(source = "applicant.id", target = "applicantId") + @Mapping(source = "applicant.name", target = "applicantName") + @Mapping(source = "applicant.email", target = "applicantEmail") + @Mapping(source = "applicant.nickname", target = "applicantNickname") + @Mapping(source = "applicant.profileImage", target = "applicantProfileImage") + @Mapping(source = "recruitment.id", target = "recruitmentId") + @Mapping(source = "recruitment.title", target = "recruitmentTitle") + @Mapping(source = "recruitment.recruiter.name", target = "recruiterName") + 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") + @Mapping(source = "recruitment.title", target = "recruitmentTitle") + ApplicationListResponseDto toListResponseDto(Application application); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java new file mode 100644 index 0000000..3933cd6 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/repository/ApplicationRepository.java @@ -0,0 +1,34 @@ +package com.teamEWSN.gitdeun.Application.repository; + +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.user.entity.User; +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 ApplicationRepository extends JpaRepository { + + // 특정 사용자의 활성 지원 내역만 조회 + Page findByApplicantAndActiveTrueOrderByCreatedAtDesc(User applicant, Pageable pageable); + + // 특정 공고의 활성 지원자만 조회 + Page findByRecruitmentAndActiveTrueOrderByCreatedAtDesc(Recruitment recruitment, Pageable pageable); + + // 사용자가 특정 공고에 이미 지원했는지 확인 (활성 지원만) + boolean existsByRecruitmentAndApplicantAndActiveTrue(Recruitment recruitment, User applicant); + + // 특정 지원 조회 (지원자 본인 확인용) + Optional findByIdAndApplicant(Long id, User applicant); + + // 특정 지원 조회 (공고 작성자 확인용) + @Query("SELECT a FROM Application a WHERE a.id = :id AND a.recruitment.recruiter = :recruiter") + Optional findByIdAndRecruiter(@Param("id") Long id, @Param("recruiter") User recruiter); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java new file mode 100644 index 0000000..5cc9248 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/service/ApplicationService.java @@ -0,0 +1,306 @@ +package com.teamEWSN.gitdeun.Application.service; + +import com.teamEWSN.gitdeun.Application.dto.*; +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.Application.entity.ApplicationStatus; +import com.teamEWSN.gitdeun.Application.mapper.ApplicationMapper; +import com.teamEWSN.gitdeun.Application.repository.ApplicationRepository; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.notification.service.NotificationService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +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; + +import java.time.LocalDateTime; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ApplicationService { + + private final ApplicationRepository applicationRepository; + private final RecruitmentRepository recruitmentRepository; + private final UserRepository userRepository; + private final ApplicationMapper applicationMapper; + private final NotificationService notificationService; + + /** + * 모집 공고에 지원하기 + */ + @Transactional + public ApplicationResponseDto createApplication(Long recruitmentId, ApplicationCreateRequestDto requestDto, Long userId) { + // 지원자 정보 조회 + User applicant = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 모집 공고 정보 조회 + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> { + log.error("Recruitment not found with id: {}", recruitmentId); + return new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND); + }); + + // 모집 상태 확인 + if (recruitment.getStatus() != RecruitmentStatus.RECRUITING) { + log.warn("Application attempt to non-recruiting post: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_NOT_RECRUITING); + } + + // 모집 마감일 확인 + if (recruitment.getEndAt().isBefore(LocalDateTime.now())) { + log.warn("Application attempt to expired recruitment: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_EXPIRED); + } + + // 본인 공고 지원 불가 + if (recruitment.getRecruiter().getId().equals(userId)) { + log.warn("Self-application attempt by user: {}", userId); + throw new GlobalException(ErrorCode.CANNOT_APPLY_OWN_RECRUITMENT); + } + + // 중복 지원 확인 + if (applicationRepository.existsByRecruitmentAndApplicantAndActiveTrue(recruitment, applicant)) { + log.warn("Duplicate application attempt by user: {} to recruitment: {}", userId, recruitmentId); + throw new GlobalException(ErrorCode.ALREADY_APPLIED); + } + + // 모집 인원 확인 + if (recruitment.getRecruitQuota() <= 0) { + log.warn("Application to full recruitment: {}", recruitmentId); + throw new GlobalException(ErrorCode.RECRUITMENT_FULL); + } + + // 지원서 생성 + Application application = Application.builder() + .recruitment(recruitment) + .applicant(applicant) + .appliedField(requestDto.getAppliedField()) + .message(requestDto.getMessage()) + .status(ApplicationStatus.PENDING) + .active(true) + .build(); + + Application savedApplication = applicationRepository.save(application); + + + // 모집자에게 알림 전송 + String notificationMessage = String.format( + "'%s'님이 '%s' 공고에 지원했습니다.", + applicant.getName(), + recruitment.getTitle() + ); + + notificationService.createAndSendNotification( + NotificationCreateDto.actionable( + recruitment.getRecruiter(), + NotificationType.APPLICATION_RECEIVED, + notificationMessage, + savedApplication.getId(), + null + ) + ); + + return applicationMapper.toResponseDto(savedApplication); + } + + /** + * 내 지원 목록 조회 + */ + @Transactional(readOnly = true) + public Page getMyApplications(Long userId, Pageable pageable) { + User applicant = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Page applications = applicationRepository + .findByApplicantAndActiveTrueOrderByCreatedAtDesc(applicant, pageable); + + return applications.map(applicationMapper::toListResponseDto); + } + + /** + * 특정 공고의 지원자 목록 조회 (모집자만 가능) + */ + @Transactional(readOnly = true) + public Page getRecruitmentApplications( + Long recruitmentId, Long userId, Pageable pageable) { + + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + // 모집자 본인 확인 + if (!recruitment.getRecruiter().getId().equals(userId)) { + log.warn("Unauthorized access to applications by user: {} for recruitment: {}", userId, recruitmentId); + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + Page applications = applicationRepository + .findByRecruitmentAndActiveTrueOrderByCreatedAtDesc(recruitment, pageable); + + return applications.map(applicationMapper::toListResponseDto); + } + + /** + * 지원 상세 조회 + */ + @Transactional(readOnly = true) + public ApplicationResponseDto getApplication(Long applicationId, Long userId) { + Application application = applicationRepository.findById(applicationId) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 지원자 본인 또는 모집자만 조회 가능 + boolean isApplicant = application.getApplicant().getId().equals(userId); + boolean isRecruiter = application.getRecruitment().getRecruiter().getId().equals(userId); + + if (!isApplicant && !isRecruiter) { + log.warn("Unauthorized access to application: {} by user: {}", applicationId, userId); + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + return applicationMapper.toResponseDto(application); + } + + /** + * 지원 철회 (지원자만 가능) + */ + @Transactional + public void withdrawApplication(Long applicationId, Long userId) { + Application application = applicationRepository.findByIdAndApplicant(applicationId, + userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID))) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 이미 철회된 지원인지 확인 + if (!application.isActive()) { + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_WITHDRAWN); + } + + // 만약 '수락'된 상태의 지원을 철회하는 경우 + boolean wasAccepted = application.getStatus() == ApplicationStatus.ACCEPTED; + if (wasAccepted) { + Recruitment recruitment = application.getRecruitment(); + recruitment.increaseQuota(); + + // 철회 알림 + String notificationMessage = String.format( + "'%s'님이 '%s' 공고의 수락을 철회했습니다.", + application.getApplicant().getName(), + application.getRecruitment().getTitle() + ); + + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + application.getRecruitment().getRecruiter(), + NotificationType.APPLICATION_WITHDRAWN_AFTER_ACCEPTANCE, + notificationMessage + ) + ); + } + + // 지원 철회 처리 + application.withdraw(); + + } + + /** + * 지원 수락 (모집자만 가능) + */ + @Transactional + public ApplicationResponseDto acceptApplication(Long applicationId, Long userId) { + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Application application = applicationRepository.findByIdAndRecruiter(applicationId, recruiter) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 활성 상태 확인 + if (!application.isActive()) { + throw new GlobalException(ErrorCode.APPLICATION_NOT_ACTIVE); + } + + // 이미 처리된 지원인지 확인 + if (application.getStatus() != ApplicationStatus.PENDING) { + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_PROCESSED); + } + + // 수락 처리 + application.accept(); + + // 모집 인원 감소 + Recruitment recruitment = application.getRecruitment(); + recruitment.decreaseQuota(); + + // 지원자에게 알림 전송 + String notificationMessage = String.format( + "'%s' 공고 지원이 수락되었습니다!", + application.getRecruitment().getTitle() + ); + + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + application.getApplicant(), + NotificationType.APPLICATION_ACCEPTED, + notificationMessage + ) + ); + + return applicationMapper.toResponseDto(application); + } + + /** + * 지원 거절 (모집자만 가능) + */ + @Transactional + public ApplicationResponseDto rejectApplication( + Long applicationId, Long userId, ApplicationStatusUpdateDto updateDto) { + + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + Application application = applicationRepository.findByIdAndRecruiter(applicationId, recruiter) + .orElseThrow(() -> new GlobalException(ErrorCode.APPLICATION_NOT_FOUND)); + + // 활성 상태 확인 + if (!application.isActive()) { + throw new GlobalException(ErrorCode.APPLICATION_NOT_ACTIVE); + } + + // 이미 처리된 지원인지 확인 + if (application.getStatus() != ApplicationStatus.PENDING) { + throw new GlobalException(ErrorCode.APPLICATION_ALREADY_PROCESSED); + } + + // 거절 처리 + application.reject(updateDto.getRejectReason()); + + // 지원자에게 알림 전송 + String notificationMessage = String.format( + "'%s' 공고 지원이 거절되었습니다.", + application.getRecruitment().getTitle() + ); + + if (updateDto.getRejectReason() != null && !updateDto.getRejectReason().isEmpty()) { + notificationMessage += " 사유: " + updateDto.getRejectReason(); + } + + notificationService.createAndSendNotification( + NotificationCreateDto.simple( + application.getApplicant(), + NotificationType.APPLICATION_REJECTED, + notificationMessage + ) + ); + + return applicationMapper.toResponseDto(application); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java new file mode 100644 index 0000000..e08325a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/controller/RecruitmentController.java @@ -0,0 +1,145 @@ +package com.teamEWSN.gitdeun.Recruitment.controller; + +import com.teamEWSN.gitdeun.Recruitment.dto.*; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.service.RecruitmentService; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +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.multipart.MultipartFile; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class RecruitmentController { + + private final RecruitmentService recruitmentService; + + /** + * 신규 모집 공고 생성 API + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param requestDto 생성할 공고 정보 (JSON) + * @param images 업로드할 이미지 파일 목록 + * @return 생성된 공고의 상세 정보 + */ + @PostMapping(value = "/recruitments", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity createRecruitment( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestPart("requestDto") RecruitmentCreateRequestDto requestDto, + @RequestPart(value = "images", required = false) List images + ) { + RecruitmentDetailResponseDto responseDto = recruitmentService.createRecruitment(userDetails.getId(), requestDto, images); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + + /** + * 내가 작성한 모집 공고 목록 조회 API + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param pageable 페이징 정보 + * @return 내 모집 공고 목록 + */ + @GetMapping("/users/me/recruitments") + public ResponseEntity> getMyRecruitments( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10) Pageable pageable + ) { + Page response = recruitmentService.getMyRecruitments(userDetails.getId(), pageable); + return ResponseEntity.ok(response); + } + + /** + * 특정 모집 공고 상세 조회 API + * + * @param recruitmentId 조회할 공고 ID + * @return 공고 상세 정보 + */ + @GetMapping("/recruitments/{id}") + public ResponseEntity getRecruitment(@PathVariable("id") Long recruitmentId) { + RecruitmentDetailResponseDto responseDto = recruitmentService.getRecruitment(recruitmentId); + return ResponseEntity.ok(responseDto); + } + + /** + * 모집 공고 목록 필터링 조회 API + * + * @param status 모집 상태 (FORTHCOMING, RECRUITING, CLOSED, COMPLETED) + * @param field 모집 분야 (BACKEND, FRONTEND 등) + * @param pageable 페이징 정보 + * @return 필터링된 공고 목록 + */ + @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(keyword, status, field, pageable); + return ResponseEntity.ok(response); + } + + /** + * 모집 공고 수정 API (작성자만 가능) + * + * @param recruitmentId 수정할 공고 ID + * @param userDetails 현재 로그인한 사용자 정보 + * @param requestDto 수정할 공고 정보 (JSON) + * @param newImages 새로 추가할 이미지 파일 목록 + * @return 수정된 공고 상세 정보 + */ + @PutMapping(value = "/recruitments/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity updateRecruitment( + @PathVariable("id") Long recruitmentId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestPart("requestDto") RecruitmentUpdateRequestDto requestDto, + @RequestPart(value = "newImages", required = false) List newImages + ) { + RecruitmentDetailResponseDto responseDto = recruitmentService.updateRecruitment(recruitmentId, userDetails.getId(), requestDto, newImages); + return ResponseEntity.ok(responseDto); + } + + /** + * 모집 공고 삭제 API (작성자만 가능) + * + * @param recruitmentId 삭제할 공고 ID + * @param userDetails 현재 로그인한 사용자 정보 + * @return 응답 없음 (204 No Content) + */ + @DeleteMapping("/recruitments/{id}") + public ResponseEntity deleteRecruitment( + @PathVariable("id") Long recruitmentId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + recruitmentService.deleteRecruitment(recruitmentId, userDetails.getId()); + return ResponseEntity.noContent().build(); + } + + /** + * 사용자 맞춤 추천 공고 목록 조회 API + * + * @param userDetails 현재 로그인한 사용자 정보 + * @param pageable 페이징 정보 + * @return 추천 공고 목록 + */ + @GetMapping("/recruitments/recommendations") + public ResponseEntity> getRecommendedRecruitments( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 10) Pageable pageable + ) { + Page response = recruitmentService.getRecommendedRecruitments(userDetails.getId(), pageable); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java new file mode 100644 index 0000000..2266d6a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentCreateRequestDto.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.validation.constraints.*; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Set; + +@Getter +public class RecruitmentCreateRequestDto { + + @NotBlank(message = "제목을 입력해주세요.") + @Size(max = 120, message = "제목은 120자를 넘을 수 없습니다.") + private String title; + + @NotBlank(message = "내용을 입력해주세요.") + private String content; + + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String contactEmail; + + @NotNull(message = "모집 시작일을 입력해주세요.") + private LocalDateTime startAt; + + @NotNull(message = "모집 마감일을 입력해주세요.") + @Future(message = "마감일은 현재보다 미래여야 합니다.") + private LocalDateTime endAt; + + @NotNull(message = "총 팀원 수를 입력해주세요.") + @Min(value = 1, message = "총 팀원 수는 1명 이상이어야 합니다.") + private Integer teamSizeTotal; + + @NotNull(message = "모집 인원을 입력해주세요.") + @Min(value = 1, message = "모집 인원은 1명 이상이어야 합니다.") + private Integer recruitQuota; + + @Size(min = 1, message = "모집 분야를 하나 이상 선택해주세요.") + private Set fieldTags; + + private Set languageTags; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java new file mode 100644 index 0000000..35e30a7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentDetailResponseDto.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +@Builder +public class RecruitmentDetailResponseDto { + private Long id; + private String title; + private String content; + private String contactEmail; + private RecruitmentStatus status; + + private LocalDateTime startAt; + private LocalDateTime endAt; + + private int teamSizeTotal; + private int recruitQuota; + private int viewCount; + + private String recruiterNickname; + private String recruiterProfileImage; + + private Set fieldTags; + private Set languageTags; + + private List images; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java new file mode 100644 index 0000000..3cd1875 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentImageDto.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class RecruitmentImageDto { + private Long imageId; + private String imageUrl; +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java new file mode 100644 index 0000000..7d5ebf8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentListResponseDto.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.Set; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class RecruitmentListResponseDto { + private Long id; + private String title; + + @With + private String thumbnailUrl; // 썸네일 이미지(이미지 리스트 중 맨 앞) + private RecruitmentStatus status; + + private Set languageTags; // 개발 기술 및 지원 분야 태그 + private Set fieldTags; + + private LocalDateTime startAt; // 모집 기간 + private LocalDateTime endAt; + + private Integer viewCount; + private Integer recruitQuota; + + // private String recruiterNickname; + + @With + private Double matchScore; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java new file mode 100644 index 0000000..b32308b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/dto/RecruitmentUpdateRequestDto.java @@ -0,0 +1,39 @@ +package com.teamEWSN.gitdeun.Recruitment.dto; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +import jakarta.validation.constraints.*; +import lombok.Getter; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Getter +public class RecruitmentUpdateRequestDto { + @Size(max = 120, message = "제목은 120자를 넘을 수 없습니다.") + private String title; + + private String content; + + @Email(message = "유효한 이메일 형식이 아닙니다.") + private String contactEmail; + + @Future(message = "마감일은 현재보다 미래여야 합니다.") + private LocalDateTime endAt; + + @Min(value = 1, message = "총 팀원 수는 1명 이상이어야 합니다.") + private Integer teamSizeTotal; + + @Min(value = 1, message = "모집 인원은 1명 이상이어야 합니다.") + private Integer recruitQuota; + + @Size(min = 1, message = "모집 분야를 하나 이상 선택해주세요.") + private Set fieldTags; + + private Set languageTags; + + private List keepImageIds; // 유지할 기존 이미지 ID +} + 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 95a2498..7888b4e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -1,5 +1,7 @@ package com.teamEWSN.gitdeun.Recruitment.entity; +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import jakarta.persistence.*; @@ -16,14 +18,16 @@ 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 @NoArgsConstructor @AllArgsConstructor @Builder -public class Recruitment { +public class Recruitment extends AuditedEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -67,7 +71,7 @@ public class Recruitment { // 개발 분야 태그 (선택 필요) - BACKEND/FRONTEND/AI 등 @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "recruitment_field_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @CollectionTable(name = "recruitment_field_tags") @Column(name = "field", nullable = false, length = 32) @Enumerated(EnumType.STRING) @Builder.Default @@ -75,7 +79,7 @@ public class Recruitment { // 개발 언어 태그 (선택 필요) - 화면 필터/표시용 @ElementCollection(fetch = FetchType.LAZY) - @CollectionTable(name = "recruitment_language_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @CollectionTable(name = "recruitment_language_tags") @Column(name = "language", nullable = false, length = 64) @Enumerated(EnumType.STRING) @Builder.Default @@ -87,26 +91,46 @@ public class Recruitment { private RecruitmentStatus status; // 조회수 - @Column(name = "views", nullable = false) + @Column(name = "view_count", nullable = false) @Builder.Default - private int views = 0; + private int viewCount = 0; // 모집 공고 이미지 (선택) @Builder.Default @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) private List recruitmentImages = new ArrayList<>(); - /*// 지원 신청 리스트 + // 지원 신청 리스트 @OneToMany(mappedBy = "recruitment", fetch = FetchType.LAZY) @Builder.Default - private List applications = new ArrayList<>();*/ + private List applications = new ArrayList<>(); // 추천 가중치용 요구 기술(언어/프레임워크/툴 등, 선택) - @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) @Builder.Default private Set requiredSkills = new HashSet<>(); - - - public void increaseView() { this.views++; } + /** + * 모집 인원을 1 감소시키고, 인원이 0이 되면 상태를 COMPLETED로 변경 + */ + public void decreaseQuota() { + if (this.recruitQuota > 0) { + this.recruitQuota--; + if (this.recruitQuota == 0) { + this.status = RecruitmentStatus.COMPLETED; + } + } + } + + /** + * 모집 인원을 1 증가시키고, 상태가 COMPLETED였다면 RECRUITING으로 변경 + */ + public void increaseQuota() { + this.recruitQuota++; + if (this.status == RecruitmentStatus.COMPLETED && LocalDateTime.now().isBefore(this.endAt)) { + this.status = RecruitmentStatus.RECRUITING; + } + } + + public void increaseView() { this.viewCount++; } } diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java index 4903203..784bf2d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java @@ -32,7 +32,7 @@ public class RecruitmentRequiredSkill { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) - private SkillCategory category; // 추천은 LANGUAGE만 사용 + private SkillCategory category; // 추천은 현재 LANGUAGE만 사용 @Column(nullable = false) @Builder.Default diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java index fbccbe8..f4fd110 100644 --- a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java @@ -1,7 +1,8 @@ package com.teamEWSN.gitdeun.Recruitment.entity; public enum RecruitmentStatus { - Forthcoming, // 모집 예정 - RECRUITING, // 모집 중 - CLOSED, // 마감 + FORTHCOMING, // 모집 예정 + RECRUITING, // 모집 중 + CLOSED, // 마감 (기간 종료) + COMPLETED // 완료 (인원 충족) } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java new file mode 100644 index 0000000..c394bbf --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/mapper/RecruitmentMapper.java @@ -0,0 +1,63 @@ +package com.teamEWSN.gitdeun.Recruitment.mapper; + +import com.teamEWSN.gitdeun.Recruitment.dto.*; +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; +import org.mapstruct.*; + +import java.util.List; +import java.util.stream.Collectors; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface RecruitmentMapper { + + @Mapping(source = "recruitmentImages", target = "thumbnailUrl", qualifiedByName = "mapThumbnailUrl") + RecruitmentListResponseDto toListResponseDto(Recruitment recruitment); + + @Mapping(source = "recruiter.nickname", target = "recruiterNickname") + @Mapping(source = "recruiter.profileImage", target = "recruiterProfileImage") + @Mapping(source = "recruitmentImages", target = "images", qualifiedByName = "mapImages") + RecruitmentDetailResponseDto toDetailResponseDto(Recruitment recruitment); + + Recruitment toEntity(RecruitmentCreateRequestDto createDto); + + @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE) + void updateRecruitmentFromDto(RecruitmentUpdateRequestDto updateDto, @MappingTarget Recruitment recruitment); + + + /** + * 썸네일 URL 매핑 (이미지 목록의 첫 번째 이미지) + */ + @Named("mapThumbnailUrl") + default String mapThumbnailUrl(List recruitmentImages) { + if (recruitmentImages == null || recruitmentImages.isEmpty()) { + return null; + } + + return recruitmentImages.stream() + .filter(image -> image.getDeletedAt() == null) + .findFirst() + .map(RecruitmentImage::getImageUrl) + .orElse(null); + } + + /** + * 이미지 리스트 매핑 + */ + @Named("mapImages") + default List mapImages(List recruitmentImages) { + if (recruitmentImages == null || recruitmentImages.isEmpty()) { + return null; + } + return recruitmentImages.stream() + .filter(image -> image.getDeletedAt() == null) + .map(this::toRecruitmentImageDto) + .collect(Collectors.toList()); + } + + /** + * 이미지 DTO 변환 + */ + @Mapping(source = "id", target = "imageId") + RecruitmentImageDto toRecruitmentImageDto(RecruitmentImage recruitmentImage); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java new file mode 100644 index 0000000..28f80ad --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentCustomRepository.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface RecruitmentCustomRepository { + Page searchRecruitments( + String keyword, + RecruitmentStatus status, + List fields, + Pageable pageable + ); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java new file mode 100644 index 0000000..a03112e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentImageRepository.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface RecruitmentImageRepository extends JpaRepository { + List findByRecruitmentAndDeletedAtIsNull(Recruitment recruitment); + + List findByRecruitmentIdAndDeletedAtIsNull(Long recruitmentId); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java new file mode 100644 index 0000000..58990d7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepository.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +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.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; + +@Repository +public interface RecruitmentRepository extends JpaRepository, RecruitmentCustomRepository{ + // 스케줄링을 위한 조회 + List findAllByStatusAndStartAtBefore(RecruitmentStatus status, LocalDateTime now); + List findAllByStatusAndEndAtBefore(RecruitmentStatus status, LocalDateTime now); + + // 내 공고 목록 조회 + @EntityGraph(attributePaths = {"recruiter"}) + Page findByRecruiterId(Long recruiterId, Pageable pageable); + + // 상태 기반 조회(추천 시) + List findAllByStatusIn(List statuses); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java new file mode 100644 index 0000000..35752b3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRepositoryImpl.java @@ -0,0 +1,192 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +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 { + + private final JPAQueryFactory queryFactory; + + @Override + public Page searchRecruitments( + String keyword, RecruitmentStatus status, List fields, Pageable pageable + ) { + // 키워드 전처리 후 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()) + .fetch(); + + 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 = 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 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/repository/RecruitmentRequiredSkillRepository.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java new file mode 100644 index 0000000..0e9e301 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/repository/RecruitmentRequiredSkillRepository.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.Recruitment.repository; + +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentRequiredSkill; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RecruitmentRequiredSkillRepository extends JpaRepository { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java new file mode 100644 index 0000000..f2d87f9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentSchedulingService.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.Recruitment.service; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecruitmentSchedulingService { + + private final RecruitmentRepository recruitmentRepository; + + @Transactional + @Scheduled(cron = "0 0 0 * * *") // 매일 자정 실행 + public void updateRecruitmentStatus() { + log.info("모집 공고 상태 업데이트 스케줄러 시작"); + LocalDateTime now = LocalDateTime.now(); + + // 모집 예정 -> 모집 중 + List startingRecruitments = recruitmentRepository + .findAllByStatusAndStartAtBefore(RecruitmentStatus.FORTHCOMING, now); + startingRecruitments.forEach(r -> r.setStatus(RecruitmentStatus.RECRUITING)); + log.info("{}개의 공고를 '모집 중'으로 변경했습니다.", startingRecruitments.size()); + + // 모집 중 -> 모집 마감 + List closingRecruitments = recruitmentRepository + .findAllByStatusAndEndAtBefore(RecruitmentStatus.RECRUITING, now); + closingRecruitments.forEach(r -> r.setStatus(RecruitmentStatus.CLOSED)); + log.info("{}개의 공고를 '모집 마감'으로 변경했습니다.", closingRecruitments.size()); + log.info("모집 공고 상태 업데이트 스케줄러 종료"); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java new file mode 100644 index 0000000..16a0c12 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/RecruitmentService.java @@ -0,0 +1,340 @@ +package com.teamEWSN.gitdeun.Recruitment.service; + +import com.teamEWSN.gitdeun.Recruitment.dto.*; +import com.teamEWSN.gitdeun.Recruitment.entity.*; +import com.teamEWSN.gitdeun.Recruitment.mapper.RecruitmentMapper; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentImageRepository; +import com.teamEWSN.gitdeun.Recruitment.repository.RecruitmentRepository; +import com.teamEWSN.gitdeun.Recruitment.service.util.RecommendationScoreCalculator; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; +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.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecruitmentService { + private final RecruitmentRepository recruitmentRepository; + private final RecruitmentImageRepository recruitmentImageRepository; + private final UserRepository userRepository; + private final RecruitmentMapper recruitmentMapper; + private final S3BucketService s3BucketService; + + /** + * 새로운 모집 공고를 생성합니다. + * @param userId 모집 공고를 생성하는 사용자 ID + * @param requestDto 생성할 공고의 정보가 담긴 DTO + * @return 생성된 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto createRecruitment(Long userId, RecruitmentCreateRequestDto requestDto, List images) { + User recruiter = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + validateRecruitmentDates(requestDto.getStartAt(), requestDto.getEndAt()); + + Recruitment recruitment = recruitmentMapper.toEntity(requestDto); + recruitment.setRecruiter(recruiter); + + RecruitmentStatus initialStatus = requestDto.getStartAt().isAfter(LocalDateTime.now()) ? + RecruitmentStatus.FORTHCOMING : RecruitmentStatus.RECRUITING; + recruitment.setStatus(initialStatus); + + Recruitment savedRecruitment = recruitmentRepository.save(recruitment); + + if (!CollectionUtils.isEmpty(images)) { + List savedImages = uploadAndSaveImages(savedRecruitment, images); + savedRecruitment.setRecruitmentImages(savedImages); + } + + return recruitmentMapper.toDetailResponseDto(savedRecruitment); + } + + /** + * 현재 로그인한 사용자가 작성한 모든 모집 공고 목록을 조회합니다. + * @param userId 현재 사용자 ID + * @param pageable 페이징 정보 + * @return 페이징 처리된 내 모집 공고 목록 + */ + @Transactional(readOnly = true) + public Page getMyRecruitments(Long userId, Pageable pageable) { + Page recruitmentPage = recruitmentRepository.findByRecruiterId(userId, pageable); + + List content = recruitmentPage.getContent().stream() + .map(recruitment -> { + RecruitmentListResponseDto dto = recruitmentMapper.toListResponseDto(recruitment); + // 썸네일 URL 설정 + return addThumbnailUrl(dto, recruitment.getRecruitmentImages()); + }) + .collect(Collectors.toList()); + + return new PageImpl<>(content, pageable, recruitmentPage.getTotalElements()); + } + + /** + * 특정 모집 공고의 상세 정보를 조회합니다. + * 이 메서드가 호출될 때마다 해당 공고의 조회수가 1 증가합니다. + * @param recruitmentId 조회할 공고의 ID + * @return 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + recruitment.increaseView(); + return recruitmentMapper.toDetailResponseDto(recruitment); + } + + /** + * 상태(status)와 모집 분야(field)를 기준으로 모집 공고를 필터링하여 검색합니다. + * @param keyword 제목 검색 키워드 (선택 사항) - 부분 문자열 매칭 + * @param status 검색할 모집 상태 (선택 사항) + * @param fields 검색할 모집 분야 목록 (선택 사항) + * @param pageable 페이징 정보 + * @return 페이징 처리된 검색 결과 목록 + */ + @Transactional(readOnly = true) + public Page searchRecruitments(String keyword, RecruitmentStatus status, List fields, Pageable pageable) { + return recruitmentRepository.searchRecruitments(keyword, status, fields, pageable) + .map(recruitmentMapper::toListResponseDto); + } + + /** + * 특정 모집 공고를 수정합니다. + * 공고 작성자만 수정할 수 있습니다. + * @param recruitmentId 수정할 공고의 ID + * @param userId 요청한 사용자의 ID + * @param requestDto 수정할 공고의 정보가 담긴 DTO + * @param newImages 새로 추가할 이미지 파일 목록 + * @return 수정된 공고의 상세 정보 DTO + */ + @Transactional + public RecruitmentDetailResponseDto updateRecruitment(Long recruitmentId, Long userId, RecruitmentUpdateRequestDto requestDto, List newImages) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + if (!recruitment.getRecruiter().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // DTO에 endAt이 제공된 경우에만 날짜 유효성 검사 + if (requestDto.getEndAt() != null) { + validateRecruitmentDates(recruitment.getStartAt(), requestDto.getEndAt()); + } + + recruitmentMapper.updateRecruitmentFromDto(requestDto, recruitment); + + // 이미지 업데이트 - 삭제 후 새 이미지 추가 + deleteUnusedImages(recruitment, requestDto.getKeepImageIds()); + + if (!CollectionUtils.isEmpty(newImages)) { + uploadAndSaveImages(recruitment, newImages); + } + + return recruitmentMapper.toDetailResponseDto(recruitment); + } + + /** + * 특정 모집 공고를 삭제합니다. + * 공고 작성자만 삭제할 수 있습니다. + * @param recruitmentId 삭제할 공고의 ID + * @param userId 요청한 사용자의 ID + */ + @Transactional + public void deleteRecruitment(Long recruitmentId, Long userId) { + Recruitment recruitment = recruitmentRepository.findById(recruitmentId) + .orElseThrow(() -> new GlobalException(ErrorCode.RECRUITMENT_NOT_FOUND)); + + if (!recruitment.getRecruiter().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + recruitmentRepository.delete(recruitment); + } + + /** + * 사용자의 기술 스택을 기반으로 맞춤 모집 공고를 추천합니다. + * 사용자가 보유한 기술과 공고의 요구 기술이 많이 일치할수록 상단에 노출됩니다. + * @param userId 추천을 받을 사용자의 ID + * @param pageable 페이징 정보 + * @return 페이징 처리된 추천 공고 목록 + */ + @Transactional(readOnly = true) + public Page getRecommendedRecruitments(Long userId, Pageable pageable) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 사용자 기술 스택 조회 + Set userSkills = user.getSkills().stream() + .map(userSkill -> DeveloperSkill.valueOf(userSkill.getSkill().toUpperCase())) + .collect(Collectors.toSet()); + + // RECRUITING과 FORTHCOMING 상태인 공고만 조회 + List targetStatuses = List.of( + RecruitmentStatus.RECRUITING, + RecruitmentStatus.FORTHCOMING + ); + + List recruitments = recruitmentRepository.findAllByStatusIn(targetStatuses); + + // 매칭 점수 계산 및 정렬 + List matchedRecruitments = recruitments.stream() + .map(recruitment -> { + // 기본 DTO 생성 + RecruitmentListResponseDto dto = recruitmentMapper.toListResponseDto(recruitment); + + // 썸네일 URL 설정 + dto = addThumbnailUrl(dto, recruitment.getRecruitmentImages()); + + // 매칭 점수 계산 및 설정 + double matchScore = RecommendationScoreCalculator.calculate(recruitment, userSkills); + dto = withMatchScore(dto, matchScore); + + return dto; + }) + .filter(dto -> dto.getMatchScore() > 0.0) // 매칭 점수가 0인 경우 제외 + .sorted((a, b) -> Double.compare(b.getMatchScore(), a.getMatchScore())) // 점수 높은 순 정렬 + .collect(Collectors.toList()); + + // 페이징 적용 + int start = (int) pageable.getOffset(); + int end = Math.min(start + pageable.getPageSize(), matchedRecruitments.size()); + List pagedContent = start < matchedRecruitments.size() ? + matchedRecruitments.subList(start, end) : new ArrayList<>(); + + return new PageImpl<>(pagedContent, pageable, matchedRecruitments.size()); + } + + // =============== Private Helper Methods =============== + + /** + * DTO에 썸네일 URL을 추가합니다. + */ + private RecruitmentListResponseDto addThumbnailUrl(RecruitmentListResponseDto dto, List images) { + if (dto.getThumbnailUrl() != null) { + return dto; + } + + String thumbnailUrl = images.stream() + .filter(image -> image.getDeletedAt() == null) + .findFirst() + .map(RecruitmentImage::getImageUrl) + .orElse(null); + + return withThumbnailUrl(dto, thumbnailUrl); + } + + /** + * DTO에 매칭 점수를 추가합니다. + */ + private RecruitmentListResponseDto withMatchScore(RecruitmentListResponseDto dto, Double matchScore) { + return RecruitmentListResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .thumbnailUrl(dto.getThumbnailUrl()) + .status(dto.getStatus()) + .languageTags(dto.getLanguageTags()) + .fieldTags(dto.getFieldTags()) + .startAt(dto.getStartAt()) + .endAt(dto.getEndAt()) + .recruitQuota(dto.getRecruitQuota()) + .viewCount(dto.getViewCount()) + .matchScore(matchScore) + .build(); + } + + /** + * DTO에 썸네일 URL을 추가합니다. + */ + private RecruitmentListResponseDto withThumbnailUrl(RecruitmentListResponseDto dto, String thumbnailUrl) { + return RecruitmentListResponseDto.builder() + .id(dto.getId()) + .title(dto.getTitle()) + .thumbnailUrl(thumbnailUrl) + .status(dto.getStatus()) + .languageTags(dto.getLanguageTags()) + .fieldTags(dto.getFieldTags()) + .startAt(dto.getStartAt()) + .endAt(dto.getEndAt()) + .recruitQuota(dto.getRecruitQuota()) + .viewCount(dto.getViewCount()) + .matchScore(dto.getMatchScore()) + .build(); + } + + /** + * 이미지 파일들을 S3에 업로드하고 DB에 저장합니다. + * @param recruitment 이미지를 연결할 공고 엔티티 + * @param imageFiles 업로드할 이미지 파일들 + * @return 저장된 이미지 엔티티 목록 + */ + private List uploadAndSaveImages(Recruitment recruitment, List imageFiles) { + if (CollectionUtils.isEmpty(imageFiles)) { + return Collections.emptyList(); + } + + // S3에 이미지 업로드 + String s3Path = "recruitments/" + recruitment.getId(); + List uploadedUrls = s3BucketService.upload(imageFiles, s3Path); + + // 이미지 엔티티 생성 + List images = uploadedUrls.stream() + .map(url -> RecruitmentImage.builder() + .imageUrl(url) + .recruitment(recruitment) + .build()) + .collect(Collectors.toList()); + + // DB에 저장 + return recruitmentImageRepository.saveAll(images); + } + + /** + * 기존 이미지 중 유지할 ID 목록에 없는 이미지를 소프트 삭제합니다. + * + * @param recruitment 공고 엔티티 + * @param keepImageIds 유지할 이미지 ID 목록 + */ + private void deleteUnusedImages(Recruitment recruitment, List keepImageIds) { + List existingImages = recruitmentImageRepository.findByRecruitmentAndDeletedAtIsNull(recruitment); + List finalKeepIds = keepImageIds == null ? Collections.emptyList() : keepImageIds; + + List imagesToDelete = existingImages.stream() + .filter(img -> !finalKeepIds.contains(img.getId())) + .collect(Collectors.toList()); + + if (!imagesToDelete.isEmpty()) { + imagesToDelete.forEach(RecruitmentImage::softDelete); + recruitmentImageRepository.saveAll(imagesToDelete); + } + + } + + /** + * 모집 공고의 날짜 유효성을 검사합니다. + */ + private void validateRecruitmentDates(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt)) { + throw new GlobalException(ErrorCode.INVALID_DATE_RANGE); + } + if (endAt.isBefore(LocalDateTime.now())) { + throw new GlobalException(ErrorCode.END_DATE_IN_PAST); + } + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java new file mode 100644 index 0000000..d8a7861 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/service/util/RecommendationScoreCalculator.java @@ -0,0 +1,91 @@ +package com.teamEWSN.gitdeun.Recruitment.service.util; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Set; + +public class RecommendationScoreCalculator { + + /** + * 모집 공고와 사용자 기술 간의 매칭 점수를 계산 + * @param recruitment 모집 공고 엔티티 + * @param userSkills 사용자 기술 스택 + * @return 0.0 ~ 1.0 사이의 매칭 점수 + */ + public static double calculate(Recruitment recruitment, Set userSkills) { + double score = 0.0; + + // 1. 기본 매칭 점수 계산 (단순 매칭만 사용) + score = calculateSimpleScore(recruitment.getLanguageTags(), userSkills); + + // 2. 상태별 점수 조정 + score = applyStatusAdjustment(score, recruitment); + + // 3. 날짜별 점수 조정 + score = applyDateAdjustment(score, recruitment); + + return score; + } + + /** + * 단순 매칭 점수 계산 (모든 기술의 중요도가 동일) + */ + private static double calculateSimpleScore(Set languageTags, Set userSkills) { + if (languageTags.isEmpty()) return 0.0; + + long matchCount = languageTags.stream() + .mapToLong(tag -> userSkills.contains(tag) ? 1 : 0) + .sum(); + + return (double) matchCount / languageTags.size(); + } + + /** + * 공고 상태에 따른 점수 조정 + */ + private static double applyStatusAdjustment(double score, Recruitment recruitment) { + // 모집 중인 공고가 모집 예정 공고보다 우선순위 높음 + if (recruitment.getStatus() == RecruitmentStatus.RECRUITING) { + score += 0.05; + } + + return Math.min(score, 1.0); + } + + /** + * 공고 등록일/마감일에 따른 점수 조정 + */ + private static double applyDateAdjustment(double score, Recruitment recruitment) { + // 최근 등록된 공고에 가산점 부여 + if (recruitment.getCreatedAt().isAfter(LocalDateTime.now().minusDays(7))) { + score += 0.05; + } + + // 마감이 임박한 경우 가산점 + if (recruitment.getStatus() == RecruitmentStatus.RECRUITING) { + long daysUntilEnd = ChronoUnit.DAYS.between(LocalDateTime.now(), recruitment.getEndAt()); + if (daysUntilEnd <= 2) { + score += 0.03; + } + } + + // 모집 예정인 공고는 시작일이 가까울수록 점수 상승 + if (recruitment.getStatus() == RecruitmentStatus.FORTHCOMING) { + long daysUntilStart = ChronoUnit.DAYS.between(LocalDateTime.now(), recruitment.getStartAt()); + + if (daysUntilStart <= 3) { + // 3일 이내에 시작하는 공고는 가산점 + score += 0.02; + } else if (daysUntilStart > 14) { + // 2주 이상 먼 공고는 감점 + score -= 0.05; + } + } + + return Math.min(Math.max(score, 0.0), 1.0); // 0~1 범위로 제한 + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java index 430d07a..2ef3ce7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java @@ -3,6 +3,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.web.reactive.function.client.WebClient; @Configuration @@ -16,6 +18,10 @@ public class FastApiConfig { public WebClient webClient() { return WebClient.builder() .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(10 * 1024 * 1024)) // 10MB 제한 .build(); } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index feaf6eb..24d0f9d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -24,8 +24,11 @@ public class SecurityPath { "/api/history/**", "/api/invitations/**", "/api/notifications/**", + "/api/skills/**", + "/api/recruitments/**", + "/api/applications/**", "/api/s3/bucket/**", - "/api/proxy/**" + "/api/webhook/**" }; // hasRole("ADMIN") 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 8ad6020..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", "이미 마인드맵에 등록된 멤버입니다."), @@ -61,6 +65,7 @@ public enum ErrorCode { NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION-001", "알림을 찾을 수 없습니다."), CANNOT_ACCESS_NOTIFICATION(HttpStatus.FORBIDDEN, "NOTIFICATION-002", "해당 알림에 접근할 권한이 없습니다."), + // 방문기록 관련 HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), @@ -70,6 +75,25 @@ public enum ErrorCode { PINNEDHISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PINNEDHISTORY-003", "핀 고정 기록을 찾을 수 없습니다."), PINNED_HISTORY_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "PINNEDHISTORY-004", "최대 8개까지만 핀으로 고정할 수 있습니다."), + // 모집 공고 관련 + RECRUITMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "RECRUIT-001", "모집 공고를 찾을 수 없습니다."), + RECRUITMENT_NOT_RECRUITING(HttpStatus.BAD_REQUEST, "RECRUIT-002", "모집 중인 공고가 아닙니다."), + RECRUITMENT_EXPIRED(HttpStatus.BAD_REQUEST, "RECRUIT-003", "모집 기간이 마감된 공고입니다."), + RECRUITMENT_FULL(HttpStatus.BAD_REQUEST, "RECRUIT-004", "모집 인원이 마감되었습니다."), + INVALID_DATE_RANGE(HttpStatus.BAD_REQUEST, "RECRUIT-005", "시작일은 종료일보다 이전이어야 합니다."), + END_DATE_IN_PAST(HttpStatus.BAD_REQUEST, "RECRUIT-006", "종료일은 현재보다 이후여야 합니다."), + + // 지원 관련 + APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "APPLICATION-001", "요청한 지원 정보를 찾을 수 없습니다."), + ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION-002", "이미 지원한 공고입니다."), + CANNOT_APPLY_OWN_RECRUITMENT(HttpStatus.BAD_REQUEST, "APPLICATION-003", "본인의 공고에는 지원할 수 없습니다."), + INVALID_APPLICATION_FIELD(HttpStatus.BAD_REQUEST, "APPLICATION-004", "해당 공고에서 모집하지 않는 분야입니다."), + APPLICATION_ALREADY_WITHDRAWN(HttpStatus.BAD_REQUEST, "APPLICATION-005", "이미 철회된 지원입니다."), + CANNOT_WITHDRAW_ACCEPTED(HttpStatus.BAD_REQUEST, "APPLICATION-006", "수락된 지원은 철회할 수 없습니다."), + APPLICATION_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "APPLICATION-007", "활성 상태가 아닌 지원입니다."), + APPLICATION_ALREADY_PROCESSED(HttpStatus.BAD_REQUEST, "APPLICATION-008", "이미 처리된 지원입니다."), + + // S3 파일 관련 // Client Errors (4xx) FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), 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 faa88d1..63fd935 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,8 +1,7 @@ package com.teamEWSN.gitdeun.common.fastapi; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; -import com.teamEWSN.gitdeun.common.fastapi.dto.ArangoDataDto; -import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import com.teamEWSN.gitdeun.common.fastapi.dto.MindmapGraphDto; import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.beans.factory.annotation.Qualifier; @@ -15,97 +14,131 @@ @Component public class FastApiClient { - private final WebClient webClient; // FastApiConfig에서 생성한 Bean을 주입받음 + private final WebClient webClient; public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { this.webClient = webClient; } - // FastAPI 서버에 리포지토리 분석을 요청 - public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type, String authorizationHeader) { - AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type); - // FastAPI 요청 본문을 위한 내부 DTO + /** + * GitHub 저장소의 최신 커밋 시간 조회 + */ + public LocalDateTime getRepositoryLastCommitTime(String repoUrl, String authorizationHeader) { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/github/repos/last-commit") + .queryParam("repo_url", repoUrl) + .build()) + .header("Authorization", authorizationHeader) + .retrieve() + .bodyToMono(RepositoryCommitInfo.class) + .map(RepositoryCommitInfo::getLastCommitTime) + .block(); + } + /** + * FastAPI 서버에 리포지토리 프롬프트 기반 마인드맵 분석 + */ + public AnalysisResultDto analyzeWithPrompt(String repoUrl, String prompt, String authorizationHeader) { + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt); return webClient.post() - .uri("/analyze") // FastAPI에 정의된 분석 엔드포인트 + .uri("/mindmap/analyze-prompt") .header("Authorization", authorizationHeader) .body(Mono.just(requestBody), AnalysisRequest.class) - .retrieve() // 응답을 받아옴 - .bodyToMono(AnalysisResultDto.class) // 응답 본문을 DTO로 변환 - .block(); // 비동기 처리를 동기적으로 대기 + .retrieve() + .bodyToMono(AnalysisResultDto.class) + .block(); } - // ArangoDB에서 마인드맵 데이터를 조회 - public ArangoDataDto getArangoData(String arangodbKey, String authorizationHeader) { - return webClient.get() - .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 조회 엔드포인트 + /** + * 기본 마인드맵 분석 (프롬프트 X) + */ + public AnalysisResultDto analyzeDefault(String repoUrl, String authorizationHeader) { + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, null); + return webClient.post() + .uri("/mindmap/analyze-ai") .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), AnalysisRequest.class) .retrieve() - .bodyToMono(ArangoDataDto.class) + .bodyToMono(AnalysisResultDto.class) .block(); } - // ArangoDB에 마인드맵 데이터를 저장하고 키를 반환 - public String saveArangoData(String repoUrl, String mapData, String authorizationHeader) { - ArangoSaveRequest requestBody = new ArangoSaveRequest(repoUrl, mapData); - - return webClient.post() - .uri("/arango/save") // ArangoDB 데이터 저장 엔드포인트 + /** + * ArangoDB에서 마인드맵 그래프 데이터를 조회 + */ + public MindmapGraphDto getMindmapGraph(String repoUrl, String authorizationHeader) { + return webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/mindmap/graph") + .queryParam("repo_url", repoUrl) + .build()) .header("Authorization", authorizationHeader) - .body(Mono.just(requestBody), ArangoSaveRequest.class) .retrieve() - .bodyToMono(ArangoSaveResponse.class) - .map(ArangoSaveResponse::getArangodbKey) + .bodyToMono(MindmapGraphDto.class) .block(); } - // ArangoDB에서 마인드맵 데이터를 업데이트 - public ArangoDataDto updateArangoData(String arangodbKey, String mapData, String authorizationHeader) { - ArangoUpdateRequest requestBody = new ArangoUpdateRequest(mapData); + /** + * GitHub 저장소 정보 저장을 요청 + */ + public void saveRepoInfo(String repoUrl, String authorizationHeader) { + webClient.post() + .uri("/repo/github/repo-info") + .header("Authorization", authorizationHeader) + .body(Mono.just(new GitRepoRequest(repoUrl)), GitRepoRequest.class) + .retrieve() + .bodyToMono(Void.class) + .block(); + } - return webClient.put() - .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 업데이트 엔드포인트 + /** + * GitHub ZIP을 ArangoDB에 저장을 요청 + */ + public void fetchRepo(String repoUrl, String authorizationHeader) { + webClient.post() + .uri("/github/repos/fetch") .header("Authorization", authorizationHeader) - .body(Mono.just(requestBody), ArangoUpdateRequest.class) + .body(Mono.just(new GitRepoRequest(repoUrl)), GitRepoRequest.class) .retrieve() - .bodyToMono(ArangoDataDto.class) + .bodyToMono(Void.class) .block(); } - // ArangoDB에서 마인드맵 데이터를 삭제 - public void deleteAnalysisData(String arangodbKey) { + /** + * ArangoDB에서 repo_url 기반으로 마인드맵 데이터를 삭제 + */ + public void deleteMindmapData(String repoUrl, String authorizationHeader) { webClient.delete() - .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 삭제 엔드포인트 + .uri(uriBuilder -> uriBuilder + .path("/mindmap/repo") + .queryParam("repo_url", repoUrl) + .build()) + .header("Authorization", authorizationHeader) .retrieve() .bodyToMono(Void.class) .block(); } - @Getter - @AllArgsConstructor - private static class AnalysisRequest { - private String url; - private String prompt; - private MindmapType type; - } + // === Inner Classes === @Getter @AllArgsConstructor - private static class ArangoSaveRequest { - private String repoUrl; - private String mapData; + private static class AnalysisRequest { + private String repo_url; + private String prompt; // nullable } @Getter @AllArgsConstructor - private static class ArangoUpdateRequest { - private String mapData; + private static class GitRepoRequest { + private String repo_url; } @Getter - @AllArgsConstructor - private static class ArangoSaveResponse { - private String arangodbKey; - private String status; + public static class RepositoryCommitInfo { + private LocalDateTime lastCommitTime; + private String lastCommitSha; + private String defaultBranch; } -} +} \ No newline at end of file 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 9ca695a..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,20 +1,20 @@ 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 language; private String defaultBranch; private LocalDateTime githubLastUpdatedAt; // Mindmap 관련 정보 private String mapData; // JSON 형태의 마인드맵 데이터 - private MindmapType type; - private String prompt; + private String title; // 프롬프트 및 mindmap 정보 요약 + private String errorMessage; // 실패 시 전달될 에러메세지 // TODO: FastAPI 응답에 맞춰 필드 정의 } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java deleted file mode 100644 index 34acea2..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.teamEWSN.gitdeun.common.fastapi.dto; - -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -public class ArangoDataDto { - private String arangodbKey; - private String mapData; - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java new file mode 100644 index 0000000..e38af6d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/EdgeDto.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import lombok.Getter; + +@Getter +public class EdgeDto { + private String from; + private String to; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java new file mode 100644 index 0000000..398f2ec --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/MindmapGraphDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import lombok.Getter; +import java.util.List; + +@Getter +public class MindmapGraphDto { + private int count; + private List nodes; + private List edges; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java new file mode 100644 index 0000000..c8fd6cb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/NodeDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import lombok.Getter; +import java.util.List; + +@Getter +public class NodeDto { + private String key; + private String label; + private List related_files; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java index de56ba0..e062ac2 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java @@ -1,7 +1,5 @@ package com.teamEWSN.gitdeun.common.s3.controller; -import com.teamEWSN.gitdeun.common.exception.ErrorCode; -import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; import lombok.RequiredArgsConstructor; import org.springframework.core.io.Resource; @@ -21,18 +19,12 @@ public class S3BucketController { private final S3BucketService s3BucketService; - private static final int MAX_FILE_COUNT = 10; @PostMapping("/upload") public ResponseEntity> uploadFiles( @RequestParam("files") List files, @RequestParam("path") String path ) { - // FILE-005: 업로드 가능한 파일 개수를 초과했습니다. - if (files.size() > MAX_FILE_COUNT) { - throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); - } - List fileUrls = s3BucketService.upload(files, path); return ResponseEntity.ok(fileUrls); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java index 6ed3ec5..fe99060 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java @@ -5,6 +5,7 @@ import io.awspring.cloud.s3.S3Resource; import io.awspring.cloud.s3.S3Template; import lombok.RequiredArgsConstructor; +import org.apache.tika.Tika; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; @@ -12,8 +13,11 @@ import software.amazon.awssdk.core.exception.SdkException; import java.io.IOException; +import java.io.InputStream; +import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.UUID; @Service @@ -21,32 +25,41 @@ public class S3BucketService { private final S3Template s3Template; + private final Tika tika = new Tika(); - @Value("${cloud.aws.s3.bucket.name}") + @Value("${s3.bucket}") private String bucketName; + // 이미지 및 파일 업로드 제약 조건 + private static final long MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB + private static final int MAX_FILE_COUNT = 5; + private static final Set ALLOWED_IMAGE_TYPES = Set.of("image/jpeg", "image/png", "image/gif"); + private static final Set ALLOWED_DOCUMENT_TYPES = Set.of("application/pdf", "application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + public List upload(List files, String path) { - // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + // 1. 파일 목록이 비어있는지 확인 if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) { throw new GlobalException(ErrorCode.INVALID_FILE_LIST); } + // 2. 파일 개수 제한 확인 + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + List uploadedUrls = new ArrayList<>(); for (MultipartFile file : files) { - if (file.isEmpty()) continue; + try (InputStream inputStream = file.getInputStream()) { + // 3. 각 파일에 대해 크기 및 실제 타입(매직 바이트) 검증 + validateSingleFile(file.getSize(), inputStream); - if (!isValidFileType(file.getOriginalFilename())) { - // FILE-004: 지원하지 않는 파일 형식입니다. - throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); - } + String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); - String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); - - try { + // 4. S3에 업로드 (주의: 검증 시 사용한 inputStream을 재사용할 수 없으므로, S3Template이 내부적으로 새 스트림을 열도록 file을 전달) S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream()); uploadedUrls.add(s3Resource.getURL().toString()); + } catch (IOException | SdkException e) { - // FILE-501: 파일 업로드 중 서버 오류가 발생했습니다. throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); } } @@ -92,10 +105,10 @@ public S3Resource download(String url) { private String extractKeyFromUrl(String url) { try { - String urlPrefix = "https://" + bucketName + ".s3."; - int startIndex = url.indexOf(urlPrefix); - int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length()); - return url.substring(keyStartIndex + 1); + URI uri = new URI(url); + String path = uri.getPath(); + // 맨 앞의 '/'를 제거하여 순수한 객체 키만 반환 + return path.substring(1); } catch (Exception e) { // FILE-007: S3 URL 형식이 올바르지 않습니다. throw new GlobalException(ErrorCode.INVALID_S3_URL); @@ -106,10 +119,15 @@ private String generateValidPath(String path) { if (path == null || path.trim().isEmpty()) { return ""; } - if (path.contains("..")) { + // 경로에 허용된 문자(영문, 숫자, 슬래시, 하이픈, 언더스코어) 외에 다른 문자가 있는지 확인 + if (!path.matches("^[a-zA-Z0-9/_-]+$")) { // FILE-003: 파일 경로나 이름이 유효하지 않습니다. throw new GlobalException(ErrorCode.INVALID_FILE_PATH); } + // ".." 문자열 체크는 유지 + if (path.contains("..")) { + throw new GlobalException(ErrorCode.INVALID_FILE_PATH); + } return path.replaceAll("^/+|/+$", "") + "/"; } @@ -118,10 +136,41 @@ private String createUniqueFileName(String originalFileName) { return UUID.randomUUID() + "." + extension; } - private boolean isValidFileType(String filename) { - if (filename == null) return false; - String extension = StringUtils.getFilenameExtension(filename.toLowerCase()); - List allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // 허용 확장자 - return allowedExtensions.contains(extension); + private void validateSingleFile(long fileSize, InputStream inputStream) { + // 파일 크기 검증 + if (fileSize > MAX_FILE_SIZE) { + throw new GlobalException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + try { + // Tika를 사용하여 InputStream에서 실제 MIME 타입 감지 + String actualMimeType = tika.detect(inputStream); + + // 허용된 이미지 또는 문서 타입이 아니면 예외 발생 + if (actualMimeType == null || (!ALLOWED_IMAGE_TYPES.contains(actualMimeType) && !ALLOWED_DOCUMENT_TYPES.contains(actualMimeType))) { + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + } catch (IOException e) { + // 스트림 읽기 중 오류 발생 시 + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); + } } + + /*private void validateFiles(List files) { + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + + for (MultipartFile file : files) { + if (file.getSize() > MAX_FILE_SIZE) { + throw new GlobalException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + String contentType = file.getContentType(); + // 이미지 또는 문서 타입에 해당하지 않으면 예외 발생 + if (contentType == null || (!ALLOWED_IMAGE_TYPES.contains(contentType) && !ALLOWED_DOCUMENT_TYPES.contains(contentType))) { + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + } + }*/ } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java index 9005c39..b1b7ba9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java @@ -11,7 +11,6 @@ public class WebhookUpdateDto { // FastAPI 콜백 페이로드와 동일 private String repoUrl; private String mapData; - private String language; private String defaultBranch; private LocalDateTime githubLastUpdatedAt; } 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 a04dd33..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,26 +1,33 @@ 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("/api/mindmaps") +@RestController +@RequestMapping("/api/mindmaps") @RequiredArgsConstructor public class MindmapController { private final MindmapService mindmapService; - private final FastApiClient fastApiClient; + private final PromptHistoryService promptHistoryService; // 마인드맵 생성 (FastAPI 분석 기반) @PostMapping @@ -29,18 +36,8 @@ public ResponseEntity createMindmap( @RequestBody MindmapCreateRequestDto request, @AuthenticationPrincipal CustomUserDetails userDetails ) { - // 1. FastAPI로 분석 요청 - AnalysisResultDto analysisResult = fastApiClient.analyze( - request.getRepoUrl(), - request.getPrompt(), - request.getType(), - authorizationHeader - ); - - // 2. 분석 결과로 마인드맵 생성 - MindmapResponseDto responseDto = mindmapService.createMindmapFromAnalysis( + MindmapResponseDto responseDto = mindmapService.createMindmap( request, - analysisResult, userDetails.getId(), authorizationHeader ); @@ -48,7 +45,7 @@ public ResponseEntity createMindmap( return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); } - // 마인드맵 상세 조회 (유저 인가 확인필요?) + // 마인드맵 상세 조회 @GetMapping("/{mapId}") public ResponseEntity getMindmap( @PathVariable Long mapId, @@ -59,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, @@ -70,15 +82,104 @@ public ResponseEntity refreshMindmap( return ResponseEntity.ok(responseDto); } - // 마인드맵 삭제 (owner만) + /** + * 마인드맵 삭제 (owner만) + */ @DeleteMapping("/{mapId}") public ResponseEntity deleteMindmap( @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader + ) { + mindmapService.deleteMindmap(mapId, userDetails.getId(), authorizationHeader); + 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 ) { - mindmapService.deleteMindmap(mapId, userDetails.getId()); - return ResponseEntity.ok().build(); // 성공 시 200 OK와 빈 body 반환 + 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/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 02d346e..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,14 +58,47 @@ public class Mindmap extends AuditedEntity { @Column(name = "member_count", nullable = false) private Integer memberCount = 1; - // TODO: Graph RAG 조회 및 데이터 연결 - @Column(name = "arangodb_key", length = 255) - private String arangodbKey; + @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 updateArangodbKey(String arangodbKey) { this.arangodbKey = arangodbKey; } + 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 4ccb176..3404d68 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -1,24 +1,23 @@ package com.teamEWSN.gitdeun.mindmap.service; +import com.fasterxml.jackson.databind.ObjectMapper; 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.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.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; @@ -26,11 +25,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Slf4j @Service @@ -38,267 +37,189 @@ public class MindmapService { private final VisitHistoryService visitHistoryService; - private final RepoService repoService; 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; + // 마인드맵 생성 @Transactional - public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, AnalysisResultDto dto, 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)); - Repo repo = repoService.createOrUpdate(req.getRepoUrl(), dto); - repoRepository.save(repo); + String normalizedUrl = normalizeRepoUrl(req.getRepoUrl()); - String field = determineField(req, user); + // 1. Repository 처리 + Repo repo = processRepository(normalizedUrl, authorizationHeader); - // 1. ArangoDB에 초기 마인드맵 데이터를 저장하고 키를 받아옴 - String arangodbKey = null; - String finalMapData = dto.getMapData(); + // 2. FastAPI를 통해 분석 수행 및 AI 생성 제목과 맵 데이터 획득 + AnalysisResultDto analysisResult = generateMapDataWithAnalysis(normalizedUrl, req.getPrompt(), authorizationHeader); - try { - // FastAPI를 통해 ArangoDB에 데이터 저장 - arangodbKey = fastApiClient.saveArangoData( - req.getRepoUrl(), - dto.getMapData(), - authorizationHeader - ); - - // ArangoDB에서 저장된 데이터 조회하여 최종 mapData 확정 - var arangoData = fastApiClient.getArangoData(arangodbKey, authorizationHeader); - if (arangoData != null && arangoData.getMapData() != null) { - finalMapData = arangoData.getMapData(); - } - - } catch (Exception e) { - log.warn("ArangoDB 저장 중 오류 발생, 기본 데이터로 진행: {}", e.getMessage()); - // ArangoDB 저장 실패 시에도 마인드맵은 생성하되, arangodbKey는 null로 유지 - } + // 3. AI가 생성한 제목 사용, 실패 시 기본 제목 + String title = determineAIGeneratedTitle(analysisResult, user); - // 2. MySQL에 마인드맵 엔티티 저장 + // 4. 마인드맵 엔티티 생성 Mindmap mindmap = Mindmap.builder() .repo(repo) .user(user) - .prompt(req.getPrompt()) - .branch(dto.getDefaultBranch()) - .type(req.getType()) - .field(field) - .mapData(finalMapData) // ArangoDB에서 가져온 최종 데이터 - .arangodbKey(arangodbKey) // ArangoDB 키 저장 + .branch(repo.getDefaultBranch()) + .title(title) + .mapData(analysisResult.getMapData()) .build(); mindmapRepository.save(mindmap); - // 3. 마인드맵 소유자 등록 - mindmapMemberRepository.save( - MindmapMember.of(mindmap, user, MindmapRole.OWNER) - ); + // 5. 초기 프롬프트 히스토리 생성 (프롬프트가 있는 경우) + if (StringUtils.hasText(req.getPrompt())) { + promptHistoryService.createInitialPromptHistory(mindmap, req.getPrompt(), analysisResult.getMapData(), + analysisResult.getTitle()); + } - // 4. 방문 기록 생성 + // 6. 소유자 등록 및 방문 기록 + mindmapMemberRepository.save(MindmapMember.of(mindmap, user, MindmapRole.OWNER)); visitHistoryService.createVisitHistory(user, mindmap); + log.info("마인드맵 생성 완료 - ID: {}, AI 생성 제목: {}", mindmap.getId(), title); return mindmapMapper.toResponseDto(mindmap); } - private String determineField(MindmapCreateRequestDto req, User user) { - if (req.getType() == MindmapType.DEV) { - return "개발용"; - } else { - if (req.getField() != null && !req.getField().isEmpty()) { - return req.getField(); - } else { - long nextSeq = findNextCheckSequence(user); - return "확인용 (" + nextSeq + ")"; - } - } - } - /** - * 특정 사용자의 "확인용 (n)" 다음 시퀀스 번호를 찾습니다. + * 마인드맵 상세 정보 조회 */ - private long findNextCheckSequence(User user) { - Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); - - if (lastCheckMindmap.isEmpty()) { - return 1; + @Transactional + public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - Pattern pattern = Pattern.compile("\\((\\d+)\\)"); - Matcher matcher = pattern.matcher(lastCheckMindmap.get().getField()); + Mindmap mindmap = mindmapRepository.findByIdAndDeletedAtIsNull(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); - if (matcher.find()) { - long lastSeq = Long.parseLong(matcher.group(1)); - return lastSeq + 1; - } + syncWithArangoDB(mindmap, authorizationHeader); - return 1; + return mindmapMapper.toDetailResponseDto(mindmap); } /** - * 마인드맵 상세 정보 조회 - ArangoDB와 동기화된 최신 데이터 반환 + * 마인드맵 제목 수정 */ @Transactional - public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { - if (!mindmapAuthService.hasView(mapId, userId)) { + public MindmapDetailResponseDto updateMindmapTitle(Long mapId, Long userId, MindmapTitleUpdateDto req) { + + // EDIT 권한 필요 + if (!mindmapAuthService.hasEdit(mapId, userId)) { 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); + mindmap.updateTitle(req.getTitle()); - return mindmapMapper.toDetailResponseDto(mindmap); + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + + // 제목 변경만 별도 브로드캐스트 + mindmapSseService.broadcastTitleChanged(mapId, req.getTitle()); + + log.info("마인드맵 제목 수정 완료 - ID: {}, 새 제목: {}", mapId, req.getTitle()); + return responseDto; } /** - * 마인드맵 새로고침 - ArangoDB와 완전 동기화 + * 마인드맵 새로고침 */ @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(); + try { - // 1. FastAPI를 통해 리포지토리 재분석 - AnalysisResultDto dto = fastApiClient.analyze( - mindmap.getRepo().getGithubRepoUrl(), - mindmap.getPrompt(), - mindmap.getType(), - authorizationHeader - ); - - // 2. 리포지토리 정보 업데이트 - mindmap.getRepo().updateWithAnalysis(dto); - - // 3. ArangoDB 데이터 업데이트 또는 신규 생성 - String finalMapData = dto.getMapData(); - - if (mindmap.getArangodbKey() != null) { - // 기존 ArangoDB 데이터 업데이트 - var arangoData = fastApiClient.updateArangoData( - mindmap.getArangodbKey(), - dto.getMapData(), - authorizationHeader - ); - if (arangoData != null && arangoData.getMapData() != null) { - finalMapData = arangoData.getMapData(); - } + // 저장소 최신 설정 + fastApiClient.saveRepoInfo(repoUrl, authorizationHeader); + fastApiClient.fetchRepo(repoUrl, authorizationHeader); + + // 현재 적용된 프롬프트 확인 + PromptHistory appliedPrompt = mindmap.getAppliedPromptHistory(); + AnalysisResultDto analysisResult; + + if (appliedPrompt != null && StringUtils.hasText(appliedPrompt.getPrompt())) { + analysisResult = fastApiClient.analyzeWithPrompt(repoUrl, appliedPrompt.getPrompt(), authorizationHeader); } else { - // ArangoDB 키가 없다면 신규 생성 - String newArangodbKey = fastApiClient.saveArangoData( - mindmap.getRepo().getGithubRepoUrl(), - dto.getMapData(), - authorizationHeader - ); - mindmap.updateArangodbKey(newArangodbKey); // Mindmap 엔티티에 이 메소드 추가 필요 - - // 저장된 데이터 조회 - var arangoData = fastApiClient.getArangoData(newArangodbKey, authorizationHeader); - if (arangoData != null && arangoData.getMapData() != null) { - finalMapData = arangoData.getMapData(); - } + analysisResult = fastApiClient.analyzeDefault(repoUrl, authorizationHeader); } - // 4. MySQL 마인드맵 데이터 업데이트 - mindmap.updateMapData(finalMapData); + mindmap.getRepo().updateWithAnalysis(analysisResult); + mindmap.updateMapData(analysisResult.getMapData()); - } catch (Exception e) { - log.error("새로고침 중 ArangoDB 연동 실패: {}", e.getMessage()); - // ArangoDB 연동 실패 시에도 기본 FastAPI 결과로 업데이트 - AnalysisResultDto dto = fastApiClient.analyze( - mindmap.getRepo().getGithubRepoUrl(), - mindmap.getPrompt(), - mindmap.getType(), - authorizationHeader - ); - mindmap.getRepo().updateWithAnalysis(dto); - mindmap.updateMapData(dto.getMapData()); - } - - MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + mindmapSseService.broadcastUpdate(mapId, responseDto); - // 업데이트된 마인드맵 정보를 모든 구독자에게 방송 - mindmapSseService.broadcastUpdate(mapId, responseDto); + return responseDto; - return responseDto; + } catch (Exception e) { + log.error("마인드맵 새로고침 실패: {}", e.getMessage(), e); + throw new RuntimeException("마인드맵 새로고침 중 오류가 발생했습니다: " + e.getMessage()); + } } /** - * ArangoDB와 동기화하여 최신 마인드맵 데이터를 가져옴 + * 마인드맵 소프트 삭제 */ - private void syncWithArangoDB(Mindmap mindmap, String authorizationHeader) { - if (mindmap.getArangodbKey() == null) { - return; // ArangoDB 키가 없으면 동기화 불가 + @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 { - var arangoData = fastApiClient.getArangoData( - mindmap.getArangodbKey(), - authorizationHeader - ); - - if (arangoData != null && arangoData.getMapData() != null) { - // ArangoDB 데이터가 MySQL 데이터와 다르면 업데이트 - if (!arangoData.getMapData().equals(mindmap.getMapData())) { - mindmap.updateMapData(arangoData.getMapData()); - log.info("마인드맵 ID {}의 데이터가 ArangoDB와 동기화되었습니다", mindmap.getId()); - } - } + fastApiClient.deleteMindmapData(mindmap.getRepo().getGithubRepoUrl(), authorizationHeader); + log.info("ArangoDB 데이터 삭제 완료: {}", mindmap.getRepo().getGithubRepoUrl()); } catch (Exception e) { - log.warn("ArangoDB 동기화 실패, 기존 데이터 유지: {}", e.getMessage()); + log.error("ArangoDB 데이터 삭제 실패, 마인드맵 소프트 삭제는 계속 진행: {}", e.getMessage()); } + + // 소프트 삭제 수행 + mindmap.softDelete(); + log.info("마인드맵 소프트 삭제 완료: {}", mapId); } - // 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 정보 업데이트 repo.updateWithWebhookData(dto); - // 각 마인드맵의 ArangoDB 데이터와 MySQL 데이터 동기화 for (Mindmap mindmap : mindmapsToUpdate) { - try { - // Webhook 데이터를 ArangoDB에 업데이트 - if (mindmap.getArangodbKey() != null) { - var arangoData = fastApiClient.updateArangoData( - mindmap.getArangodbKey(), - dto.getMapData(), - authorizationHeader - ); - - // ArangoDB에서 반환된 데이터로 MySQL 업데이트 - if (arangoData != null && arangoData.getMapData() != null) { - mindmap.updateMapData(arangoData.getMapData()); - } else { - mindmap.updateMapData(dto.getMapData()); - } - } else { - // ArangoDB 키가 없으면 직접 업데이트 - mindmap.updateMapData(dto.getMapData()); - } - } catch (Exception e) { - log.warn("Webhook 처리 중 ArangoDB 연동 실패, 직접 업데이트: {}", e.getMessage()); - mindmap.updateMapData(dto.getMapData()); - } - + mindmap.updateMapData(dto.getMapData()); MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); @@ -306,32 +227,123 @@ public void updateMindmapFromWebhook(WebhookUpdateDto dto, String authorizationH } } +// === Private Helper Methods === + + private Repo processRepository(String repoUrl, String authHeader) { + Optional existingRepo = repoRepository.findByGithubRepoUrl(repoUrl); + Repo repo; + + if (existingRepo.isPresent()) { + repo = existingRepo.get(); + log.info("기존 저장소 발견: {}", repoUrl); + + if (shouldUpdateRepo(repo, authHeader)) { + log.info("저장소 업데이트 필요: {}", repoUrl); + fastApiClient.saveRepoInfo(repoUrl, authHeader); + fastApiClient.fetchRepo(repoUrl, authHeader); + } + } else { + log.info("새 저장소: {}", repoUrl); + repo = Repo.builder().githubRepoUrl(repoUrl).build(); + fastApiClient.saveRepoInfo(repoUrl, authHeader); + fastApiClient.fetchRepo(repoUrl, authHeader); + } + + return repo; + } + /** - * 마인드맵 삭제 - ArangoDB 데이터도 함께 삭제 + * FastAPI를 통한 분석 수행 및 AI 생성 제목과 맵 데이터 획득 */ - @Transactional - public void deleteMindmap(Long mapId, Long userId) { - Mindmap mindmap = mindmapRepository.findById(mapId) - .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + 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 제목 생성 없음"); + } - // 마인드맵 생성자만 삭제 가능 - if (!mindmap.getUser().getId().equals(userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + return analysisResult; + } catch (Exception e) { + log.error("마인드맵 데이터 생성 실패: {}", e.getMessage(), e); + + // 분석 실패 시 예외를 다시 던져서 상위에서 처리하도록 함 + // 에러 메시지는 로그와 예외로만 관리 + throw new RuntimeException("FastAPI 분석 실패: " + e.getMessage(), e); } + } - // ArangoDB 데이터 삭제 - if (mindmap.getArangodbKey() != null) { - try { - fastApiClient.deleteAnalysisData(mindmap.getArangodbKey()); - log.info("ArangoDB에서 마인드맵 데이터 삭제 완료: {}", mindmap.getArangodbKey()); - } catch (Exception e) { - log.error("ArangoDB 데이터 삭제 실패하지만 MySQL 삭제는 진행: {}", e.getMessage()); + /** + * AI 생성 제목 결정 로직 + * 1. 프롬프트 있고 AI 제목 생성 성공 → AI 제목 사용 + * 2. 프롬프트 없거나 AI 제목 생성 실패 → 자동 번호 제목 + */ + private String determineAIGeneratedTitle(AnalysisResultDto analysisResult, User user) { + // AI가 제목을 성공적으로 생성한 경우 + if (analysisResult != null && StringUtils.hasText(analysisResult.getTitle())) { + log.info("AI 생성 제목 사용: {}", analysisResult.getTitle()); + return analysisResult.getTitle(); + } + + // AI 제목 생성 실패 또는 프롬프트 없는 경우 → 자동 번호 제목 + long userMindmapCount = mindmapRepository.countByUserAndDeletedAtIsNull(user); + String defaultTitle = "마인드맵 " + (userMindmapCount + 1); + + log.info("기본 제목 사용: {}", defaultTitle); + return defaultTitle; + } + + private boolean shouldUpdateRepo(Repo repo, String authHeader) { + try { + LocalDateTime githubLastCommit = fastApiClient.getRepositoryLastCommitTime(repo.getGithubRepoUrl(), authHeader); + + if (repo.getGithubLastUpdatedAt() == null) { + return true; } + + return githubLastCommit.isAfter(repo.getGithubLastUpdatedAt()); + } catch (Exception e) { + log.warn("저장소 업데이트 확인 실패: {}", e.getMessage()); + return false; + } + } + + private String getMapDataFromArangoDB(String repoUrl, String authHeader) { + try { + MindmapGraphDto graphData = fastApiClient.getMindmapGraph(repoUrl, authHeader); + return graphData != null ? objectMapper.writeValueAsString(graphData) : "{}"; + } catch (Exception e) { + log.warn("ArangoDB 데이터 조회 실패: {}", e.getMessage()); + return "{}"; + } + } + + private void syncWithArangoDB(Mindmap mindmap, String authHeader) { + try { + String latestMapData = getMapDataFromArangoDB(mindmap.getRepo().getGithubRepoUrl(), authHeader); + if (!latestMapData.equals(mindmap.getMapData())) { + mindmap.updateMapData(latestMapData); + log.info("마인드맵 동기화 완료: {}", mindmap.getId()); + } + } catch (Exception e) { + log.warn("ArangoDB 동기화 실패: {}", e.getMessage()); + } + } + + private String normalizeRepoUrl(String url) { + if (url == null || url.trim().isEmpty()) { + throw new IllegalArgumentException("Repository URL cannot be null or empty"); } - // MySQL에서 마인드맵 삭제 - mindmapRepository.delete(mindmap); - log.info("마인드맵 ID {} 삭제 완료", mapId); + return url.trim() + .toLowerCase() + .replaceAll("/$", "") + .replaceAll("\\.git$", ""); } } \ 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 8e756de..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,58 +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.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<>(); - // 클라이언트가 구독을 요청할 때 호출 - public SseEmitter subscribe(Long mapId) { - SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃을 매우 길게 설정 - // 특정 마인드맵 ID에 해당하는 Emitter 리스트에 추가 - this.emitters.computeIfAbsent(mapId, k -> new CopyOnWriteArrayList<>()).add(emitter); + // 타임아웃 설정(1시간) + private static final long TIMEOUT_MS = 60L * 60L * 1000L; - emitter.onCompletion(() -> this.emitters.get(mapId).remove(emitter)); - emitter.onTimeout(() -> this.emitters.get(mapId).remove(emitter)); + /** + * 마인드맵 실시간 연결 생성 + */ + public SseEmitter createConnection(Long mapId, Long userId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); - // 연결 성공을 알리는 더미 이벤트 전송 - try { - emitter.send(SseEmitter.event().name("connect").data("Connected!")); - } catch (IOException e) { - log.error("SSE 연결 중 오류 발생", e); - } + emitters.computeIfAbsent(mapId, k -> new CopyOnWriteArrayList<>()).add(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, "마인드맵 " + mapId + " 실시간 연결 성공"); + + log.info("SSE 연결 생성 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userId); return emitter; } - // 마인드맵이 업데이트되면 이 메서드를 호출하여 모든 구독자에게 방송 - public void broadcastUpdate(Long mapId, MindmapDetailResponseDto updatedMindmap) { - List mapEmitters = this.emitters.get(mapId); - if (mapEmitters == null) { + /** + * 마인드맵 업데이트 브로드캐스트 + */ + 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 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.get(mapId); + if (mapEmitters == null || mapEmitters.isEmpty()) { + log.debug("마인드맵 ID {} 에 연결된 클라이언트가 없음", mapId); + return; + } + + // 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 { emitter.send(SseEmitter.event() - .name("mindmap-update") // 이벤트 이름 지정 - .data(updatedMindmap)); // 업데이트된 마인드맵 데이터 전송 + .name(eventName) + .data(jsonData)); } catch (IOException e) { - log.error("SSE 데이터 전송 중 오류 발생, emitter 제거", e); - mapEmitters.remove(emitter); + deadEmitters.add(emitter); + log.warn("SSE 전송 실패 - 마인드맵 ID: {}, 이벤트: {}", mapId, eventName, e); } }); + + // 실패한 emitter 정리 + deadEmitters.forEach(emitter -> removeEmitterOnly(mapId, emitter)); + + log.debug("SSE 브로드캐스트 완료 - 마인드맵 ID: {}, 이벤트: {}, 성공: {}, 실패: {}", + mapId, eventName, mapEmitters.size() - deadEmitters.size(), deadEmitters.size()); + } + + /** + * 개별 emitter에게 초기 메시지 전송 + */ + private void sendToEmitter(SseEmitter emitter, Object data) { + try { + String jsonData = objectMapper.writeValueAsString(data); + emitter.send(SseEmitter.event() + .name("connected") + .data(jsonData)); + } catch (IOException e) { + log.warn("초기 SSE 메시지 전송 실패", e); + // 초기 연결 실패는 런타임 예외로 처리하지 않음 + } + } + + /** + * 사용자별 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("마인드맵 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/entity/Notification.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java index c184bf8..96e5086 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java @@ -37,7 +37,7 @@ public class Notification extends CreatedEntity { private boolean read; // 읽음 여부 (기본값: false) @Enumerated(EnumType.STRING) - @Column(name = "notification_type", nullable = false) + @Column(name = "notification_type", nullable = false, length = 64) private NotificationType notificationType; // 알림 종류 @Column(name = "reference_id") diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java index bf5bbc9..21a8b4f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/NotificationType.java @@ -3,5 +3,9 @@ public enum NotificationType { INVITE_MINDMAP, // 마인드맵 초대 MENTION_COMMENT, // 댓글에서 맨션 - SYSTEM_UPDATE; // 시스템 업데이트(webhook) + APPLICATION_RECEIVED, // 지원 신청 + APPLICATION_ACCEPTED, // 지원 수락 + APPLICATION_REJECTED, // 지원 거절 + APPLICATION_WITHDRAWN_AFTER_ACCEPTANCE, // 지원 수락 철회 + SYSTEM_UPDATE // 시스템 업데이트(webhook) } 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 1ca41cc..e04f630 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -12,12 +12,14 @@ import com.teamEWSN.gitdeun.notification.repository.NotificationRepository; import com.teamEWSN.gitdeun.user.entity.User; import com.teamEWSN.gitdeun.user.repository.UserRepository; +import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +35,12 @@ public class NotificationService { private final NotificationMapper notificationMapper; private final JavaMailSender mailSender; + @Value("${spring.mail.username}") + private String fromEmail; + + @Value("${spring.mail.properties.from.name:Gitdeun}") + private String fromName; + /** * 이메일 초대 알림 */ @@ -41,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, @@ -60,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, @@ -79,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, @@ -140,7 +148,7 @@ public UnreadNotificationCountDto getUnreadNotificationCount(Long userId) { } /** - * 알림 읽음 처리 + * TODO: 알림 읽음 처리 */ @Transactional public void markAsRead(Long notificationId, Long userId) { @@ -188,12 +196,16 @@ private User getUserById(Long userId) { @Async public void sendEmailNotification(String to, String subject, String text) { try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setTo(to); - message.setSubject(subject); - message.setText(text); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(text, false); // HTML이면 true + helper.setFrom(fromEmail, fromName); // 이름과 이메일 분리 설정 + mailSender.send(message); - log.info("Email sent to {}", to); + log.info("Email sent to {} from {}", to, fromName); } catch (Exception e) { log.error("Failed to send email to {}", to, e); } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java index 7803030..4f1abd9 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -28,9 +28,6 @@ public class Repo { // @Column(name="file_name", length = 256, nullable = false) // private String fileName; - @Column(length = 100) - private String language; - @Column(name = "default_branch", length = 100) private String defaultBranch; // 기본 브랜치 @@ -41,21 +38,18 @@ public class Repo { private List mindmaps = new ArrayList<>(); @Builder - public Repo(String githubRepoUrl, String language, String defaultBranch, LocalDateTime githubLastUpdatedAt) { + public Repo(String githubRepoUrl, String defaultBranch, LocalDateTime githubLastUpdatedAt) { this.githubRepoUrl = githubRepoUrl; - this.language = language; this.defaultBranch = defaultBranch; this.githubLastUpdatedAt = githubLastUpdatedAt; } public void updateWithAnalysis(AnalysisResultDto result) { - this.language = result.getLanguage(); this.defaultBranch = result.getDefaultBranch(); this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); } public void updateWithWebhookData(WebhookUpdateDto dto) { - this.language = dto.getLanguage(); this.defaultBranch = dto.getDefaultBranch(); this.githubLastUpdatedAt = dto.getGithubLastUpdatedAt(); } diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java index edb07f2..573bf38 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -22,7 +22,6 @@ public class RepoService { private final RepoRepository repoRepository; private final RepoMapper repoMapper; - private final FastApiClient fastApiClient; // 레포지토리 ID로 정보 조회 public RepoResponseDto findRepoById(Long repoId) { diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index c9e9dde..5811d2d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -2,12 +2,14 @@ import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; import jakarta.persistence.*; import lombok.*; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Set; @Entity @Getter @@ -38,6 +40,10 @@ public class User extends AuditedEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List socialConnections = new ArrayList<>(); + // 사용자 기술 + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private Set skills; + @Column(name = "deleted_at") private LocalDateTime deletedAt; 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()); diff --git a/src/main/resources/application-s3Bucket.yml b/src/main/resources/application-s3Bucket.yml index 86a1525..52e0849 100644 --- a/src/main/resources/application-s3Bucket.yml +++ b/src/main/resources/application-s3Bucket.yml @@ -1,17 +1,14 @@ -cloud: - aws: - credentials: - access-key: ${S3_ACCESS_KEY} - secret-key: ${S3_SECRET_KEY} - region: - static: ap-northeast-2 - stack: - auto: false # CloudFormation 스택 자동 생성을 비활성화 - s3: - bucket: - name: gitdeun - spring: config: activate: on-profile: s3Bucket + cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ap-northeast-2 + +s3: + bucket: gitdeun \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f76ad83..d69a7dd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,8 +46,22 @@ spring: auth: true starttls: enable: true + # 발신자 정보 추가 + from: + name: "Gitdeun" + email: ${GMAIL_USERNAME} profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 + servlet: + multipart: + max-file-size: 10MB # 개별 파일의 최대 크기 + max-request-size: 50MB # 요청 전체(모든 파일 + 폼 데이터)의 최대 크기 + +server: + tomcat: + max-parameter-count: 15000 # 파라미터 개수 + max-part-count: 200 # 파트(파일+필드) 개수 상향 + max-part-header-size: 4096 # 파일명이 길거나 커스텀 헤더 많을 때 db: crypto-key: ${CRYPTO_KEY}