diff --git a/build.gradle b/build.gradle index da46c99..a899501 100644 --- a/build.gradle +++ b/build.gradle @@ -106,6 +106,9 @@ dependencies { // Email implementation 'org.springframework.boot:spring-boot-starter-mail' + // Github Webhook + implementation 'org.kohsuke:github-api:1.327' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java new file mode 100644 index 0000000..5f1eb7d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/entity/Application.java @@ -0,0 +1,68 @@ +package com.teamEWSN.gitdeun.Application.entity; + +import com.teamEWSN.gitdeun.Recruitment.entity.Recruitment; +import com.teamEWSN.gitdeun.Recruitment.entity.RecruitmentField; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "application", + uniqueConstraints = { + // 동일 사용자·공고에 대해 "활성" 신청 중복 방지용 (WITHDRAWN 제외) + @UniqueConstraint(name = "uk_active_application", + columnNames = {"recruitment_id", "applicant_id", "active"}) + }, + indexes = { + @Index(name = "idx_application_recruitment", columnList = "recruitment_id"), + @Index(name = "idx_application_applicant", columnList = "applicant_id"), + @Index(name = "idx_application_status", columnList = "status") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Application extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 모집공고 */ + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recruitment_id", nullable = false) + private Recruitment recruitment; + + /** 신청 사용자 (User.id) */ + @Column(name = "applicant_id", nullable = false) + private Long applicantId; + + /** 지원 분야 (작성자의 모집 분야 중에서 선택) */ + @Enumerated(EnumType.STRING) + @Column(name = "applied_field", nullable = false, length = 32) + private RecruitmentField appliedField; + + /** 지원 메세지 */ + @Column(name = "message", length = 1000) + private String message; + + /** 신청 거절 사유(거절 시 선택) */ + @Column(name = "reject_reason", length = 500) + private String rejectReason; + + /** 신청 상태 (검토 중 / 수락 / 거절 / 철회) */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private ApplicationStatus status; + + /** 활성 신청 여부 (WITHDRAWN 면 false) */ + @Column(nullable = false) + private boolean active; + + + // 상태 전이 + public void accept() { this.status = ApplicationStatus.ACCEPTED; } + public void reject(String reason) { this.status = ApplicationStatus.REJECTED; this.rejectReason = reason; } + public void withdraw() { this.status = ApplicationStatus.WITHDRAWN; this.active = false; } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java b/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java new file mode 100644 index 0000000..ff2e38c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Application/entity/ApplicationStatus.java @@ -0,0 +1,8 @@ +package com.teamEWSN.gitdeun.Application.entity; + +public enum ApplicationStatus { + PENDING, // 검토중 + ACCEPTED, // 수락 + REJECTED, // 거절 + WITHDRAWN // 철회 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java new file mode 100644 index 0000000..4c31ff5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/Recruitment.java @@ -0,0 +1,110 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import com.teamEWSN.gitdeun.Application.entity.Application; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkillEnum; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Entity +@Table(name = "recruitment", + indexes = { + @Index(name = "idx_recruitment_status", columnList = "status"), + @Index(name = "idx_recruitment_deadline", columnList = "end_at"), + @Index(name = "idx_recruitment_recruiter", columnList = "recruiter_id") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Recruitment { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 모집자 (자동 선택, User.id) */ + @Column(name = "Recruiter_id", nullable = false) + private Long RecruiterId; + + /** 모집 공고 제목 (입력 필요) */ + @Column(nullable = false, length = 120) + private String title; + + /** 연락망: 이메일 주소 (입력 필요) */ + @Column(length = 120) + private String contactEmail; + + /** 모집일 (입력 필요) */ + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + // 모집 마감일 (입력 필요) + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + /** 모집 내용 (입력 필요) */ + @Lob + @Column(nullable = false) + private String content; + + /** 팀 규모(총인원) (입력 필요)*/ + @Column(name = "team_size_total", nullable = false) + private Integer teamSizeTotal; + + /** 남은 모집 인원(입력 필요) – 신청 시 1 감소, 철회/거절 시 1 증가(복원) */ + @Column(name = "recruit_quota", nullable = false) + private Integer recruitQuota; + + /** 개발 언어 태그 (선택 필요) - 화면 필터/표시용 */ + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "recruitment_language_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @Column(name = "language", nullable = false, length = 64) + @Enumerated(EnumType.STRING) + @Builder.Default + private Set languageTags = new HashSet<>(); + + /** 개발 분야 태그 (선택 필요) - BACKEND/FRONTEND/AI 등 */ + @ElementCollection(fetch = FetchType.LAZY) + @CollectionTable(name = "recruitment_field_tags", joinColumns = @JoinColumn(name = "recruitment_id")) + @Column(name = "field", nullable = false, length = 32) + @Enumerated(EnumType.STRING) + @Builder.Default + private Set fieldTags = new HashSet<>(); + + /** 모집 상태 (모집 예정 / 모집 중 / 모집 마감) */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 24) + private RecruitmentStatus status; + + /** 조회수 */ + @Column(name = "view_count", nullable = false) + @Builder.Default + private Long viewCount = 0L; + + /** 모집 공고 이미지 (선택) */ + @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<>(); + + /** 추천 가중치용 요구 기술(언어/프레임워크/툴 등, 선택) */ + @OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default + private Set requiredSkills = new HashSet<>(); + + + + public void increaseView() { this.viewCount++; } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java new file mode 100644 index 0000000..2b237da --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentField.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum RecruitmentField { + FRONTEND, // 프론트엔드 + BACKEND, // 백엔드 + FULLSTACK, // 풀스택 + ANDROID, // 안드로이드 + IOS, // iOS + DATA, // 데이터 분석 + DEVOPS, // DevOps + AI, // 인공지능 + EMBEDDED, // 임베디드 + GAME, // 게임 + SECURITY, // 보안 + ETC; // 기타 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java new file mode 100644 index 0000000..d26e638 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentImage.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@Builder +@Table(name = "recruitment_image", indexes = { + @Index(name = "idx_recruitment_image_recruitment_id_deleted_at", columnList = "recruitment_id, deleted_at") +}) +@NoArgsConstructor +@AllArgsConstructor +public class RecruitmentImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "recruitment_id") + private Recruitment recruitment; + + @Column(name = "image_url", length = 255) + private String imageUrl; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + // Soft delete 처리 메소드 + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java new file mode 100644 index 0000000..3651d12 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentRequiredSkill.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkillEnum; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "recruitment_required_skill", + uniqueConstraints = @UniqueConstraint(name = "uk_recruit_skill", columnNames = {"recruitment_id","skill"}), + indexes = { + @Index(name = "idx_recruit_skill_recruitment", columnList = "recruitment_id"), + @Index(name = "idx_recruit_skill_category", columnList = "category"), + @Index(name = "idx_rrs_cat_weight_skill", columnList = "category, weight, skill") + }) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class RecruitmentRequiredSkill { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "recruitment_id", nullable = false) + private Recruitment recruitment; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 64) + private DeveloperSkillEnum skill; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private SkillCategory category; // 추천은 LANGUAGE만 사용 + + @Column(nullable = false) + @Builder.Default + private double weight = 1.0; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java new file mode 100644 index 0000000..fbccbe8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/RecruitmentStatus.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum RecruitmentStatus { + Forthcoming, // 모집 예정 + RECRUITING, // 모집 중 + CLOSED, // 마감 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java new file mode 100644 index 0000000..a7b0c36 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/Recruitment/entity/SkillCategory.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.Recruitment.entity; + +public enum SkillCategory { + LANGUAGE, + FRAMEWORK, + DATABASE, + TOOLS, + ETC +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index ba5ffc3..f7db26b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -67,6 +67,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http .authorizeHttpRequests((auth) -> auth + // 내부 webhook 통신 API + .requestMatchers("/api/webhook/**").permitAll() + // 외부 공개 API(클라이언트 - JWT) .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() 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 9b7ec43..feaf6eb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -22,6 +22,8 @@ public class SecurityPath { "/api/repos/**", "/api/mindmaps/**", "/api/history/**", + "/api/invitations/**", + "/api/notifications/**", "/api/s3/bucket/**", "/api/proxy/**" }; 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 8a775c9..8ad6020 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -25,6 +25,10 @@ public enum ErrorCode { SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), USER_SETTING_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-005", "해당 아이디의 설정을 찾을 수 없습니다."), + // 개발 기술 관련 + SKILL_LIMIT_EXCEEDED(HttpStatus.BAD_REQUEST, "SKILL-001", "최대 10개까지만 선택 가능합니다."), + INVALID_SKILL(HttpStatus.BAD_REQUEST, "SKILL-002", "유효하지 않은 기술입니다."), + // 소셜 로그인 관련 OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH-002", "지원하지 않는 소셜 로그인 제공자입니다."), @@ -44,6 +48,7 @@ public enum ErrorCode { // 멤버 관련 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "해당 멤버를 찾을 수 없습니다."), MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER-002", "이미 마인드맵에 등록된 멤버입니다."), + OWNER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-003", "해당 소유자를 찾을 수 없습니다."), // 초대 관련 INVITATION_NOT_FOUND(HttpStatus.NOT_FOUND, "INVITE-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 815a7d0..faa88d1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -1,6 +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 lombok.AllArgsConstructor; import lombok.Getter; @@ -34,7 +35,51 @@ public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type .block(); // 비동기 처리를 동기적으로 대기 } + // ArangoDB에서 마인드맵 데이터를 조회 + public ArangoDataDto getArangoData(String arangodbKey, String authorizationHeader) { + return webClient.get() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 조회 엔드포인트 + .header("Authorization", authorizationHeader) + .retrieve() + .bodyToMono(ArangoDataDto.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 데이터 저장 엔드포인트 + .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), ArangoSaveRequest.class) + .retrieve() + .bodyToMono(ArangoSaveResponse.class) + .map(ArangoSaveResponse::getArangodbKey) + .block(); + } + + // ArangoDB에서 마인드맵 데이터를 업데이트 + public ArangoDataDto updateArangoData(String arangodbKey, String mapData, String authorizationHeader) { + ArangoUpdateRequest requestBody = new ArangoUpdateRequest(mapData); + + return webClient.put() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 업데이트 엔드포인트 + .header("Authorization", authorizationHeader) + .body(Mono.just(requestBody), ArangoUpdateRequest.class) + .retrieve() + .bodyToMono(ArangoDataDto.class) + .block(); + } + // ArangoDB에서 마인드맵 데이터를 삭제 + public void deleteAnalysisData(String arangodbKey) { + webClient.delete() + .uri("/arango/data/{key}", arangodbKey) // ArangoDB 데이터 삭제 엔드포인트 + .retrieve() + .bodyToMono(Void.class) + .block(); + } @Getter @AllArgsConstructor @@ -43,4 +88,24 @@ private static class AnalysisRequest { private String prompt; private MindmapType type; } + + @Getter + @AllArgsConstructor + private static class ArangoSaveRequest { + private String repoUrl; + private String mapData; + } + + @Getter + @AllArgsConstructor + private static class ArangoUpdateRequest { + private String mapData; + } + + @Getter + @AllArgsConstructor + private static class ArangoSaveResponse { + private String arangodbKey; + private String status; + } } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/controller/FastApiController.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/controller/FastApiController.java deleted file mode 100644 index 3706a26..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/controller/FastApiController.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.teamEWSN.gitdeun.common.fastapi.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.MindmapResponseDto; -import com.teamEWSN.gitdeun.mindmap.service.MindmapService; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api/proxy") -@RequiredArgsConstructor -public class FastApiController { - - private final FastApiClient fastApiClient; - private final MindmapService mindmapService; - - /** - * 클라이언트의 마인드맵 생성 요청을 받아 FastAPI로 전달하고, - * 그 결과를 사용하여 마인드맵을 생성하는 프록시 엔드포인트 - */ - @PostMapping("/mindmaps") - public ResponseEntity createMindmapViaProxy( - @RequestHeader("Authorization") String authorizationHeader, - @RequestBody MindmapCreateRequestDto request, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - // 1. Spring Boot가 FastAPI 서버에 분석을 요청 - AnalysisResultDto analysisResult = fastApiClient.analyze( - request.getRepoUrl(), - request.getPrompt(), - request.getType(), - authorizationHeader - ); - - // 2. FastAPI로부터 받은 분석 결과를 MindmapService로 전달하여 마인드맵 생성 - MindmapResponseDto responseDto = mindmapService.createMindmapFromAnalysis( - request, - analysisResult, - userDetails.getId() - ); - - return ResponseEntity.ok(responseDto); - } -} 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 new file mode 100644 index 0000000..34acea2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/ArangoDataDto.java @@ -0,0 +1,12 @@ +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/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java index 3530db8..122740c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -6,7 +6,8 @@ @Getter @RequiredArgsConstructor public enum CacheType { - SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200); // 자동완성 캐시 + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // 자동완성 캐시 + USER_SKILLS("userSkills", 1, 500); // 사용자 개발기술 캐시 private final String cacheName; private final int expiredAfterWrite; // 시간(hour) 단위 diff --git a/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java new file mode 100644 index 0000000..052bd88 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/controller/WebhookController.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.common.webhook.controller; + +import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; +import com.teamEWSN.gitdeun.mindmap.service.MindmapService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/webhook/mindmaps") +@RequiredArgsConstructor +public class WebhookController { + + private final MindmapService mindmapService; + + @PostMapping("/update") + public ResponseEntity updateMindmapFromWebhook(@RequestHeader("Authorization") String authorizationHeader, + @RequestBody WebhookUpdateDto updateDto) { + mindmapService.updateMindmapFromWebhook(updateDto, authorizationHeader); + return ResponseEntity.ok().build(); + } +} \ 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 new file mode 100644 index 0000000..9005c39 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/dto/WebhookUpdateDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.webhook.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +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/common/webhook/service/WebhookService.java b/src/main/java/com/teamEWSN/gitdeun/common/webhook/service/WebhookService.java new file mode 100644 index 0000000..202c8d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/webhook/service/WebhookService.java @@ -0,0 +1,8 @@ +package com.teamEWSN.gitdeun.common.webhook.service; + + +public class WebhookService { + + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java index 7142cfd..cd70662 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/controller/InvitationController.java @@ -15,6 +15,8 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequiredArgsConstructor @@ -35,7 +37,7 @@ public ResponseEntity inviteByEmail( // 특정 마인드맵의 전체 초대 목록 조회 (페이지네이션 적용) @GetMapping("/mindmaps/{mapId}") - public ResponseEntity> getInvitations( + public ResponseEntity> getInvitations( @PathVariable Long mapId, @AuthenticationPrincipal CustomUserDetails userDetails, @PageableDefault(size = 10, sort = "createdAt,desc") Pageable pageable @@ -43,6 +45,24 @@ public ResponseEntity> getInvitations( return ResponseEntity.ok(invitationService.getInvitationsByMindmap(mapId, userDetails.getId(), pageable)); } + // ACCEPTED 상태의 초대 목록 조회 + @GetMapping("/mindmaps/{mapId}/accepted") + public ResponseEntity> getAcceptedInvitations( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(invitationService.getAcceptedInvitationsByMindmap(mapId, userDetails.getId())); + } + + // PENDING 상태의 초대 목록 조회 + @GetMapping("/mindmaps/{mapId}/pending") + public ResponseEntity> getPendingInvitations( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(invitationService.getPendingInvitationsByMindmap(mapId, userDetails.getId())); + } + // 초대 수락 @PostMapping("/{invitationId}/accept") public ResponseEntity acceptInvitation( diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java b/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java index fd05ee8..eae433d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/repository/InvitationRepository.java @@ -17,14 +17,14 @@ public interface InvitationRepository extends JpaRepository { // 특정 마인드맵의 모든 초대 목록을 페이징하여 조회 Page findByMindmapId(Long mindmapId, Pageable pageable); + // 특정 마인드맵의 특정 상태 초대 목록 조회 + List findByMindmapIdAndStatus(Long mindmapId, InvitationStatus status); + // 특정 마인드맵에 특정 유저가 이미 초대 대기중인지 확인 boolean existsByMindmapIdAndInviteeIdAndStatusAndExpiresAtAfter(Long mindmapId, Long inviteeId, InvitationStatus status, LocalDateTime now); boolean existsByMindmapIdAndInviteeIdAndStatus(Long mindmapId, Long inviteeId, InvitationStatus status); - // 사용자가 받은 모든 초대 목록 조회 - List findByInviteeIdAndStatus(Long inviteeId, InvitationStatus status); - // 고유 토큰으로 초대 정보 조회 Optional findByToken(String token); 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 b2e9d46..6a6d6e1 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/service/InvitationService.java @@ -26,7 +26,9 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -40,6 +42,7 @@ public class InvitationService { private final NotificationService notificationService; private final InvitationMapper invitationMapper; + // TODO: 배포 시 이메일 주소 변경 private static final String INVITATION_BASE_URL = "http://localhost:8080/invitations/"; // private static final String INVITATION_BASE_URL = "https://gitdeun.site/invitations/"; @@ -106,6 +109,41 @@ public Page getInvitationsByMindmap(Long mapId, Long user return invitations.map(invitationMapper::toResponseDto); } + // 초대 수락 목록 조회 + @Transactional(readOnly = true) + public List getAcceptedInvitationsByMindmap(Long mapId, Long userId) { + // 권한 검증 - 최소 조회 권한이 있어야 함 + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // ACCEPTED 상태의 초대 목록 조회 + List acceptedInvitations = invitationRepository.findByMindmapIdAndStatus( + mapId, InvitationStatus.ACCEPTED); + + // 엔티티를 DTO로 변환하여 반환 + return acceptedInvitations.stream() + .map(invitationMapper::toResponseDto) + .collect(Collectors.toList()); + } + + // 초대 보류 목록 조회 + @Transactional(readOnly = true) + public List getPendingInvitationsByMindmap(Long mapId, Long userId) { + // 권한 검증 - 최소 조회 권한이 있어야 함 + if (!mindmapAuthService.hasView(mapId, userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + List pendingInvitations = invitationRepository.findByMindmapIdAndStatus( + mapId, InvitationStatus.PENDING); + + // 엔티티를 DTO로 변환하여 반환 + return pendingInvitations.stream() + .map(invitationMapper::toResponseDto) + .collect(Collectors.toList()); + } + // 초대 수락 @Transactional public void acceptInvitation(Long invitationId, Long userId) { 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 2e00819..a04dd33 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,5 +1,7 @@ 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; @@ -18,25 +20,42 @@ public class MindmapController { private final MindmapService mindmapService; + private final FastApiClient fastApiClient; - // FastApiController로 생성 - /*// 마인드맵 생성 (마인드맵에 한해서 owner 권한 얻음) + // 마인드맵 생성 (FastAPI 분석 기반) @PostMapping public ResponseEntity createMindmap( + @RequestHeader("Authorization") String authorizationHeader, @RequestBody MindmapCreateRequestDto request, @AuthenticationPrincipal CustomUserDetails userDetails ) { - MindmapResponseDto responseDto = mindmapService.createMindmap(request, userDetails.getId()); + // 1. FastAPI로 분석 요청 + AnalysisResultDto analysisResult = fastApiClient.analyze( + request.getRepoUrl(), + request.getPrompt(), + request.getType(), + authorizationHeader + ); + + // 2. 분석 결과로 마인드맵 생성 + MindmapResponseDto responseDto = mindmapService.createMindmapFromAnalysis( + request, + analysisResult, + userDetails.getId(), + authorizationHeader + ); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); - }*/ + } // 마인드맵 상세 조회 (유저 인가 확인필요?) @GetMapping("/{mapId}") public ResponseEntity getMindmap( @PathVariable Long mapId, - @AuthenticationPrincipal CustomUserDetails userDetails + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestHeader("Authorization") String authorizationHeader ) { - MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId()); + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId, userDetails.getId(), authorizationHeader); return ResponseEntity.ok(responseDto); } 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 98ac218..1dcf335 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapSseController.java @@ -5,17 +5,19 @@ import org.springframework.http.MediaType; 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; @RestController +@RequestMapping("/api/mindmaps/{mapId}/sse") @RequiredArgsConstructor public class MindmapSseController { private final MindmapSseService mindmapSseService; // 클라이언트의 특정 마인드맵의 업데이트를 구독 - @GetMapping(value = "/api/mindmaps/{mapId}/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribeToMindmapUpdates(@PathVariable Long mapId) { return mindmapSseService.subscribe(mapId); } 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 31eb2a8..02d346e 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -46,7 +46,19 @@ public class Mindmap extends AuditedEntity { @Column(name = "map_data", columnDefinition = "json", nullable = false) private String mapData; + // TODO: 멤버수 제한 기능 (유료?) + @Builder.Default + @Column(name = "member_count", nullable = false) + private Integer memberCount = 1; + + // TODO: Graph RAG 조회 및 데이터 연결 + @Column(name = "arangodb_key", length = 255) + private String arangodbKey; + public void updateMapData(String newMapData) { this.mapData = newMapData; } + + public void updateArangodbKey(String arangodbKey) { this.arangodbKey = arangodbKey; } + } \ No newline at end of file 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 400da15..4ccb176 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -4,6 +4,7 @@ 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.webhook.dto.WebhookUpdateDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; @@ -26,6 +27,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -47,26 +49,39 @@ public class MindmapService { private final FastApiClient fastApiClient; @Transactional - public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, AnalysisResultDto dto, Long userId) { + public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, AnalysisResultDto dto, 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 field; - if (req.getType() == MindmapType.DEV) { - field = "개발용"; - } else { - if (req.getField() != null && !req.getField().isEmpty()) { - field = req.getField(); - } else { - // findNextCheckSequence 호출 시 repo 정보 제거 - long nextSeq = findNextCheckSequence(user); - field = "확인용 (" + nextSeq + ")"; + String field = determineField(req, user); + + // 1. ArangoDB에 초기 마인드맵 데이터를 저장하고 키를 받아옴 + String arangodbKey = null; + String finalMapData = dto.getMapData(); + + 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로 유지 } + // 2. MySQL에 마인드맵 엔티티 저장 Mindmap mindmap = Mindmap.builder() .repo(repo) .user(user) @@ -74,30 +89,40 @@ public MindmapResponseDto createMindmapFromAnalysis(MindmapCreateRequestDto req, .branch(dto.getDefaultBranch()) .type(req.getType()) .field(field) - .mapData(dto.getMapData()) + .mapData(finalMapData) // ArangoDB에서 가져온 최종 데이터 + .arangodbKey(arangodbKey) // ArangoDB 키 저장 .build(); mindmapRepository.save(mindmap); - // 마인드맵 소유자 등록 + // 3. 마인드맵 소유자 등록 mindmapMemberRepository.save( MindmapMember.of(mindmap, user, MindmapRole.OWNER) ); - // 방문 기록 생성 + // 4. 방문 기록 생성 visitHistoryService.createVisitHistory(user, mindmap); 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)" 다음 시퀀스 번호를 찾습니다. - * @param user 대상 사용자 - * @return 다음 시퀀스 번호 */ private long findNextCheckSequence(User user) { - // repo 조건이 제거된 리포지토리 메서드 호출 Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); if (lastCheckMindmap.isEmpty()) { @@ -115,26 +140,26 @@ private long findNextCheckSequence(User user) { return 1; } - /** - * 마인드맵 상세 정보 조회 - * 저장소 업데이트는 Fast API Webhook 알림 - * 마인드맵 변경이나 리뷰 생성 시 SSE 적용을 통한 실시간 업데이트 (새로고침 x) + * 마인드맵 상세 정보 조회 - ArangoDB와 동기화된 최신 데이터 반환 */ @Transactional - public MindmapDetailResponseDto getMindmap(Long mapId, Long userId) { + public MindmapDetailResponseDto getMindmap(Long mapId, Long userId, String authorizationHeader) { if (!mindmapAuthService.hasView(mapId, userId)) { - throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); // 멤버 권한 확인 + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } Mindmap mindmap = mindmapRepository.findById(mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + // ArangoDB에서 최신 데이터 동기화 + syncWithArangoDB(mindmap, authorizationHeader); + return mindmapMapper.toDetailResponseDto(mindmap); } /** - * 마인드맵 새로고침 + * 마인드맵 새로고침 - ArangoDB와 완전 동기화 */ @Transactional public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String authorizationHeader) { @@ -146,17 +171,62 @@ public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String a throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 기존 정보로 FastAPI 재호출 - AnalysisResultDto dto = fastApiClient.analyze( - mindmap.getRepo().getGithubRepoUrl(), - mindmap.getPrompt(), - mindmap.getType(), - authorizationHeader - ); + 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(); + } + } 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(); + } + } - // 데이터 최신화 - mindmap.getRepo().updateWithAnalysis(dto); - mindmap.updateMapData(dto.getMapData()); + // 4. MySQL 마인드맵 데이터 업데이트 + mindmap.updateMapData(finalMapData); + + } 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); @@ -167,7 +237,77 @@ public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId, String a } /** - * 마인드맵 삭제 + * ArangoDB와 동기화하여 최신 마인드맵 데이터를 가져옴 + */ + private void syncWithArangoDB(Mindmap mindmap, String authorizationHeader) { + if (mindmap.getArangodbKey() == null) { + return; // ArangoDB 키가 없으면 동기화 불가 + } + + 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()); + } + } + } catch (Exception e) { + log.warn("ArangoDB 동기화 실패, 기존 데이터 유지: {}", e.getMessage()); + } + } + + // 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(); + + // 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()); + } + + MindmapDetailResponseDto responseDto = mindmapMapper.toDetailResponseDto(mindmap); + mindmapSseService.broadcastUpdate(mindmap.getId(), responseDto); + + log.info("Webhook으로 마인드맵 ID {} 업데이트 및 SSE 전송 완료", mindmap.getId()); + } + } + + /** + * 마인드맵 삭제 - ArangoDB 데이터도 함께 삭제 */ @Transactional public void deleteMindmap(Long mapId, Long userId) { @@ -179,7 +319,19 @@ public void deleteMindmap(Long mapId, Long userId) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } + // ArangoDB 데이터 삭제 + if (mindmap.getArangodbKey() != null) { + try { + fastApiClient.deleteAnalysisData(mindmap.getArangodbKey()); + log.info("ArangoDB에서 마인드맵 데이터 삭제 완료: {}", mindmap.getArangodbKey()); + } catch (Exception e) { + log.error("ArangoDB 데이터 삭제 실패하지만 MySQL 삭제는 진행: {}", e.getMessage()); + } + } + + // MySQL에서 마인드맵 삭제 mindmapRepository.delete(mindmap); + log.info("마인드맵 ID {} 삭제 완료", mapId); } } \ No newline at end of file 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 2542a19..4aeb0bb 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -23,4 +23,6 @@ public interface MindmapMemberRepository extends JpaRepository findByMindmapIdAndRole(Long mapId, MindmapRole mindmapRole); } diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java index eab5710..b3cc907 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java @@ -16,6 +16,7 @@ public class MindmapMemberService { private final MindmapAuthService auth; private final MindmapMemberRepository memberRepository; + // 멤버 권한 변경 @Transactional public void changeRole(Long mapId, Long memberId, @@ -26,10 +27,28 @@ public void changeRole(Long mapId, Long memberId, throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 대상 멤버 조회 후 role 변경 - MindmapMember member = memberRepository.findByIdAndMindmapId(memberId, mapId) + // 대상 멤버 조회 + MindmapMember targetMember = memberRepository.findByIdAndMindmapId(memberId, mapId) .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); - member.updateRole(newRole); + + // 소유자 변경 특수 케이스 처리 + if (newRole == MindmapRole.OWNER) { + // 현재 OWNER 조회 + MindmapMember currentOwner = memberRepository + .findByMindmapIdAndRole(mapId, MindmapRole.OWNER) + .orElseThrow(() -> new GlobalException(ErrorCode.OWNER_NOT_FOUND)); + + // 자기 자신에게 OWNER 재할당은 무시 + if (currentOwner.getId().equals(memberId)) { + return; + } + + // 현재 OWNER를 EDITOR로 변경 + currentOwner.updateRole(MindmapRole.EDITOR); + } + + // 대상 멤버 역할 변경 + targetMember.updateRole(newRole); } // 멤버 추방 @@ -40,4 +59,5 @@ public void removeMember(Long mapId, Long memberId, Long requesterId) { } memberRepository.deleteByIdAndMindmapId(memberId, mapId); } + } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java index e0aaa2c..a80a4c8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/controller/NotificationSseController.java @@ -11,14 +11,14 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @RestController -@RequestMapping("/api/notifications") +@RequestMapping("/api/notifications/sse") @RequiredArgsConstructor public class NotificationSseController { private final NotificationSseService notificationSseService; // 클라이언트의 알림 구독 - @GetMapping(value = "/subscribe", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter subscribe(@AuthenticationPrincipal CustomUserDetails userDetails) { return notificationSseService.subscribe(userDetails.getId()); } diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java new file mode 100644 index 0000000..732f176 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationCreateDto.java @@ -0,0 +1,50 @@ +package com.teamEWSN.gitdeun.notification.dto; + +import com.teamEWSN.gitdeun.notification.entity.NotificationType; +import com.teamEWSN.gitdeun.user.entity.User; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class NotificationCreateDto { + private User user; + private NotificationType type; + private String message; + private Long referenceId; + private LocalDateTime expiresAt; + + // 알림 유형별 기본 만료 기간 설정 + private static LocalDateTime getDefaultExpiryDate(NotificationType type) { + LocalDateTime now = LocalDateTime.now(); + return switch (type) { + case MENTION_COMMENT -> now.plusDays(30); // 댓글 알림은 30일 + default -> now.plusDays(30); // 기본 30일 + }; + } + + // 기본 만료 기간이 적용된 알림 생성 + public static NotificationCreateDto simple(User user, NotificationType type, String message) { + return NotificationCreateDto.builder() + .user(user) + .type(type) + .message(message) + .expiresAt(getDefaultExpiryDate(type)) + .build(); + } + + // 만료 기간과 참조 ID가 지정된 알림 생성 + public static NotificationCreateDto actionable(User user, NotificationType type, + String message, Long referenceId, + LocalDateTime expiresAt) { + return NotificationCreateDto.builder() + .user(user) + .type(type) + .message(message) + .referenceId(referenceId) + .expiresAt(expiresAt != null ? expiresAt : getDefaultExpiryDate(type)) + .build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java index c447c83..04cb467 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/dto/NotificationResponseDto.java @@ -14,4 +14,11 @@ public class NotificationResponseDto { private boolean read; private NotificationType notificationType; private LocalDateTime createdAt; + + private Long referenceId; + private LocalDateTime expiresAt; + + public boolean isActionAvailable() { + return expiresAt != null && expiresAt.isAfter(LocalDateTime.now()); + } } 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 bf92f71..c184bf8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/entity/Notification.java @@ -9,6 +9,8 @@ import lombok.NoArgsConstructor; import org.hibernate.annotations.ColumnDefault; +import java.time.LocalDateTime; + @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -38,11 +40,21 @@ public class Notification extends CreatedEntity { @Column(name = "notification_type", nullable = false) private NotificationType notificationType; // 알림 종류 + @Column(name = "reference_id") + private Long referenceId; // Invitation ID, Comment ID, Mindmap ID 등 + + @Column(name = "expires_at") + private LocalDateTime expiresAt; // 액션 가능 만료 시간 + @Builder - public Notification(User user, String message, NotificationType notificationType) { + public Notification(User user, NotificationType notificationType, + String message, Long referenceId, + LocalDateTime expiresAt) { this.user = user; this.message = message; this.notificationType = notificationType; + this.referenceId = referenceId; + this.expiresAt = expiresAt; this.read = false; } 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 3253907..1ca41cc 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationService.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.common.exception.ErrorCode; import com.teamEWSN.gitdeun.common.exception.GlobalException; import com.teamEWSN.gitdeun.invitation.entity.Invitation; +import com.teamEWSN.gitdeun.notification.dto.NotificationCreateDto; import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; import com.teamEWSN.gitdeun.notification.entity.Notification; @@ -41,7 +42,14 @@ public void notifyInvitation(Invitation invitation) { String message = String.format("'%s'님이 '%s' 마인드맵으로 초대했습니다.", invitation.getInviter().getName(), invitation.getMindmap().getField()); - createAndSendNotification(invitee, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + invitee, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** @@ -53,7 +61,14 @@ public void notifyAcceptance(Invitation invitation) { String message = String.format("'%s'님이 '%s' 마인드맵 초대를 수락했습니다.", invitation.getInvitee().getName(), invitation.getMindmap().getField()); - createAndSendNotification(inviter, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + inviter, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** @@ -65,19 +80,31 @@ public void notifyLinkApprovalRequest(Invitation invitation) { String message = String.format("'%s'님이 링크를 통해 '%s' 마인드맵 참여를 요청했습니다.", invitation.getInvitee().getName(), invitation.getMindmap().getField()); - createAndSendNotification(owner, NotificationType.INVITE_MINDMAP, message); + + createAndSendNotification(NotificationCreateDto.actionable( + owner, + NotificationType.INVITE_MINDMAP, + message, + invitation.getId(), + invitation.getExpiresAt() + )); } /** - * 알림 생성 및 발송 (다른 서비스에서 호출) + * 알림 생성 및 발송 (공통 호출) */ @Transactional - public void createAndSendNotification(User user, NotificationType type, String message) { + public void createAndSendNotification(NotificationCreateDto dto) { + User user = dto.getUser(); + String message = dto.getMessage(); + Notification notification = Notification.builder() .user(user) - .notificationType(type) + .notificationType(dto.getType()) .message(message) + .referenceId(dto.getReferenceId()) + .expiresAt(dto.getExpiresAt()) .build(); notificationRepository.save(notification); @@ -86,6 +113,10 @@ public void createAndSendNotification(User user, NotificationType type, String m int unreadCount = notificationRepository.countByUserAndReadFalse(user); notificationSseService.sendUnreadCount(user.getId(), unreadCount); + + // 새 알림 전송 + notificationSseService.sendNewNotification(dto.getUser().getId(), + notificationMapper.toResponseDto(notification)); } /** @@ -147,7 +178,7 @@ public void deleteNotification(Long notificationId, Long userId) { } } - // 사용자 조회 편의 메서드 + // 사용자 조회 - 편의 메서드 private User getUserById(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); diff --git a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java index 937d2a9..d45ea5c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/notification/service/NotificationSseService.java @@ -1,55 +1,86 @@ package com.teamEWSN.gitdeun.notification.service; -import com.teamEWSN.gitdeun.notification.dto.UnreadNotificationCountDto; +import com.teamEWSN.gitdeun.notification.dto.NotificationResponseDto; +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.*; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; @Slf4j @Service +@RequiredArgsConstructor public class NotificationSseService { - // 스레드 안전한 자료구조를 사용하여 사용자별 Emitter를 관리 (Key: userId, Value: SseEmitter) - private final Map emitters = new ConcurrentHashMap<>(); + // 한 사용자에 대해 여러 탭/기기의 연결을 허용 + private final Map> emitters = new ConcurrentHashMap<>(); - // 클라이언트가 구독을 요청할 때 호출 - public SseEmitter subscribe(Long userId) { - SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); // 타임아웃을 매우 길게 설정 - emitters.put(userId, emitter); + // 1시간 + private static final long TIMEOUT_MS = 60L * 60L * 1000L; - // 연결 종료 시 Emitter 제거 - emitter.onCompletion(() -> emitters.remove(userId)); - emitter.onTimeout(() -> emitters.remove(userId)); - emitter.onError(e -> emitters.remove(userId)); + /** 클라이언트 구독 */ + public SseEmitter subscribe(Long userId) { + SseEmitter emitter = new SseEmitter(TIMEOUT_MS); + // 사용자별 리스트에 emitter 추가 (동시성 안전) + emitters.computeIfAbsent(userId, k -> new CopyOnWriteArrayList<>()).add(emitter); - // 연결 성공을 알리는 더미 이벤트 전송 - try { - emitter.send(SseEmitter.event().name("connect").data("Connected to notification stream.")); - } catch (IOException e) { - log.error("SSE 연결 중 오류 발생, userId={}", userId, e); - } + // 연결 종료/에러 시 정리 + emitter.onCompletion(() -> removeEmitter(userId, emitter)); + emitter.onTimeout(() -> removeEmitter(userId, emitter)); + emitter.onError(e -> removeEmitter(userId, emitter)); + // 헬스체크/연결 확인 + sendToEmitter(emitter, "connect", "connected"); return emitter; } - // 특정 사용자에게 읽지 않은 알림 개수 전송 + /** 읽지 않은 알림 개수 전송 */ public void sendUnreadCount(Long userId, int count) { - SseEmitter emitter = emitters.get(userId); - if (emitter != null) { + sendToUser(userId, "unreadCount", count); + } + + /** 새 알림 전송 */ + public void sendNewNotification(Long userId, NotificationResponseDto notification) { + sendToUser(userId, "newNotification", notification); + } + + /** 사용자에게 이벤트 전송 */ + private void sendToUser(Long userId, String eventName, Object data) { + List userEmitters = emitters.getOrDefault(userId, Collections.emptyList()); + if (userEmitters.isEmpty()) return; + + List dead = new ArrayList<>(); + for (SseEmitter emitter : userEmitters) { try { - emitter.send(SseEmitter.event() - .name("unread-count") // 이벤트 이름 지정 - .data(new UnreadNotificationCountDto(count))); // 데이터 전송 - } catch (IOException e) { - log.error("SSE 데이터 전송 중 오류 발생, userId={}", userId, e); - // 오류 발생 시 해당 Emitter 제거 - emitters.remove(userId); + sendToEmitter(emitter, eventName, data); + } catch (Exception e) { + dead.add(emitter); + log.warn("Failed to send SSE (userId={}): {}", userId, e.toString()); } } + // 죽은 emitter 정리 + dead.forEach(em -> removeEmitter(userId, em)); + } + + /** 개별 emitter에게 전송 */ + private void sendToEmitter(SseEmitter emitter, String eventName, Object data) { + try { + emitter.send(SseEmitter.event().name(eventName).data(data)); + } catch (IOException e) { + throw new RuntimeException("SSE 전송 실패", e); + } + } + + /** emitter 제거(리스트가 비면 맵에서도 제거) */ + private void removeEmitter(Long userId, SseEmitter emitter) { + List list = emitters.get(userId); + if (list == null) return; + list.remove(emitter); + if (list.isEmpty()) emitters.remove(userId); } -} \ No newline at end of file +} 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 9b9b257..7803030 100644 --- a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -1,6 +1,8 @@ package com.teamEWSN.gitdeun.repo.entity; import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.common.webhook.dto.WebhookUpdateDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; @@ -8,6 +10,8 @@ import lombok.NoArgsConstructor; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; @Entity @Getter @@ -33,6 +37,8 @@ public class Repo { @Column(name = "github_last_updated_at") private LocalDateTime githubLastUpdatedAt; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) + @OneToMany(mappedBy = "repo", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private List mindmaps = new ArrayList<>(); @Builder public Repo(String githubRepoUrl, String language, String defaultBranch, LocalDateTime githubLastUpdatedAt) { @@ -47,4 +53,10 @@ public void updateWithAnalysis(AnalysisResultDto result) { 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(); + } } \ No newline at end of file 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 07a5ff8..c9e9dde 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -34,6 +34,7 @@ public class User extends AuditedEntity { @Column(nullable = false) private Role role; + // 소셜 연동 @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) private List socialConnections = new ArrayList<>(); diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index 3f4a861..7ce92f3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -1,9 +1,13 @@ package com.teamEWSN.gitdeun.user.repository; 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 @@ -15,5 +19,6 @@ public interface UserRepository extends JpaRepository { // user email로 검색 Optional findByEmailAndDeletedAtIsNull(String email); - + // 닉네임으로 사용자 찾기 + Optional findByNicknameAndDeletedAtIsNull(String nickname); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java b/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java new file mode 100644 index 0000000..2b37b0e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/controller/UserSkillController.java @@ -0,0 +1,57 @@ +package com.teamEWSN.gitdeun.userskill.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.userskill.dto.CategorizedSkillsResponseDto; +import com.teamEWSN.gitdeun.userskill.dto.CategorizedSkillsWithSelectionDto; +import com.teamEWSN.gitdeun.userskill.dto.SkillSelectionRequestDto; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkillEnum; +import com.teamEWSN.gitdeun.userskill.service.UserSkillService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/skills") +@RequiredArgsConstructor +public class UserSkillController { + + private final UserSkillService userSkillService; + + // 선택할 개발 기술 목록 조회 + @GetMapping + public ResponseEntity getAvailableInterests() { + // Enum에서 카테고리별 displayName 값 추출 + Map> categorizedInterests = Arrays.stream(DeveloperSkillEnum.values()) + .collect(Collectors.groupingBy( + DeveloperSkillEnum::getCategory, + Collectors.mapping(DeveloperSkillEnum::getDisplayName, Collectors.toList()) + )); + return ResponseEntity.ok(new CategorizedSkillsResponseDto(categorizedInterests)); + } + + // 선택한 개발 기술 목록 조회 + @GetMapping("/me") + public ResponseEntity getMySkillsWithSelection( + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + return ResponseEntity.ok(userSkillService.getUserSkillsWithSelection(userId)); + } + + // 선택한 기술 저장 + @PostMapping("/me") + public ResponseEntity saveSkills( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody SkillSelectionRequestDto requestDto + ) { + Long userId = userDetails.getId(); + userSkillService.saveUserSkills(userId, requestDto.getAllSkills()); + return ResponseEntity.status(HttpStatus.OK).build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java new file mode 100644 index 0000000..2cef3ae --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsResponseDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedSkillsResponseDto { + private Map> categorizedSkills; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java new file mode 100644 index 0000000..0a72b73 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/CategorizedSkillsWithSelectionDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; +import java.util.Map; + +@Getter +@AllArgsConstructor +public class CategorizedSkillsWithSelectionDto { + private Map> categorizedSkills; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java new file mode 100644 index 0000000..54ef3f7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/DeveloperSkillDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class DeveloperSkillDto { + private String name; // 관심사 이름 + private boolean selected; // 선택 여부 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java new file mode 100644 index 0000000..57c03ae --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/dto/SkillSelectionRequestDto.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.userskill.dto; + +import lombok.Getter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Getter +public class SkillSelectionRequestDto { + private Map> categorizedSkills; + + public List getAllSkills() { + if (categorizedSkills == null) { + return new ArrayList<>(); + } + List allSkills = new ArrayList<>(); + categorizedSkills.values().forEach(allSkills::addAll); + return allSkills; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkillEnum.java b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkillEnum.java new file mode 100644 index 0000000..90dca9d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/DeveloperSkillEnum.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.userskill.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum DeveloperSkillEnum { + // 프로그래밍 언어 + JAVASCRIPT("JavaScript", "LANGUAGE"), + TYPESCRIPT("TypeScript", "LANGUAGE"), + PYTHON("Python", "LANGUAGE"), + JAVA("Java", "LANGUAGE"), + KOTLIN("Kotlin", "LANGUAGE"), + GO("Go", "LANGUAGE"), + RUST("Rust", "LANGUAGE"), + CPP("C++", "LANGUAGE"), + CSHARP("C#", "LANGUAGE"), + SWIFT("Swift", "LANGUAGE"), + DART("Dart", "LANGUAGE"), + PHP("PHP", "LANGUAGE"), + RUBY("Ruby", "LANGUAGE"), + R("R", "LANGUAGE"), + OTHER("기타", "LANGUAGE"); + +// // 프레임워크/라이브러리 +// SPRING("Spring", "FRAMEWORK"), +// REACT("React", "FRAMEWORK"), +// VUE("Vue.js", "FRAMEWORK"), +// ANGULAR("Angular", "FRAMEWORK"), +// NEXTJS("Next.js", "FRAMEWORK"), +// EXPRESS("Express.js", "FRAMEWORK"), +// NESTJS("NestJS", "FRAMEWORK"), +// DJANGO("Django", "FRAMEWORK"), +// FLASK("Flask", "FRAMEWORK"), +// FASTAPI("FastAPI", "FRAMEWORK"), +// FLUTTER("Flutter", "FRAMEWORK"), +// SINATRA("sinatra", "FRAMEWORK"), +// RAILS("Rails", "FRAMEWORK"), +// SWIFTUI("SwiftUI", "FRAMEWORK"), +// LARAVEL("Laravel", "FRAMEWORK"), +// GIN("Gin", "FRAMEWORK"), +// OTHER("기타", "FRAMEWORK"); + + private final String displayName; + private final String category; + + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java new file mode 100644 index 0000000..ad25211 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/entity/UserSkill.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.userskill.entity; + +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +@Table(name = "user_skill", indexes = { + @Index(name = "idx_user_id", columnList = "user_id"), + @Index(name = "idx_skill", columnList = "skill") +}) +public class UserSkill { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(columnDefinition = "INT UNSIGNED") + private Long id; + + @ManyToOne + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false) + private String skill; + + @Override + public String toString() { + return "{user_id: " + user.getId() + ", keyword: " + skill + "}"; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java b/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java new file mode 100644 index 0000000..7065597 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/repository/UserSkillRepository.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.userskill.repository; + +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; +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.List; + +@Repository +public interface UserSkillRepository extends JpaRepository { + + // 사용자 ID에 해당하는 모든 기술 항목을 조회 + List findByUserId(Long userId); + + // 사용자 ID로 기술 삭제 + @Modifying + @Query("DELETE FROM UserSkill us WHERE us.user.id = :userId") + void deleteByUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java b/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java new file mode 100644 index 0000000..b0ea386 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/userskill/service/UserSkillService.java @@ -0,0 +1,131 @@ +package com.teamEWSN.gitdeun.userskill.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.userskill.dto.CategorizedSkillsWithSelectionDto; +import com.teamEWSN.gitdeun.userskill.dto.DeveloperSkillDto; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkillEnum; +import com.teamEWSN.gitdeun.userskill.entity.UserSkill; +import com.teamEWSN.gitdeun.userskill.repository.UserSkillRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserSkillService { + + private final UserRepository userRepository; + private final UserSkillRepository userSkillRepository; + + // 기술 최대 선택 개수 상수화 + private static final int MAX_SKILL_COUNT = 15; + + /** + * 사용자의 기술 목록과 선택 여부를 함께 조회 + * + * @param userId 사용자 ID + * @return 카테고리별로 분류된 기술 목록 (선택 여부 포함) + */ + @Cacheable(value = "userSkills", key = "#userId") + @Transactional(readOnly = true) + public CategorizedSkillsWithSelectionDto getUserSkillsWithSelection(Long userId) { + + // 사용자가 선택한 기술 목록을 Set으로 변환 (검색 성능 향상) + Set selectedSkills = userSkillRepository.findByUserId(userId).stream() + .map(UserSkill::getSkill) + .collect(Collectors.toSet()); + + // 카테고리별로 모든 기술을 분류하고 선택 여부 표시 + Map> categorizedSkills = + Arrays.stream(DeveloperSkillEnum.values()) + .collect(Collectors.groupingBy( + DeveloperSkillEnum::getCategory, + LinkedHashMap::new, // 순서 보장 + Collectors.mapping( + skillEnum -> new DeveloperSkillDto( + skillEnum.getDisplayName(), + selectedSkills.contains(skillEnum.getDisplayName()) + ), + Collectors.toList() + ) + )); + + log.debug("사용자 기술 조회 완료 - userId: {}, 선택된 기술 수: {}", userId, selectedSkills.size()); + + return new CategorizedSkillsWithSelectionDto(categorizedSkills); + } + + /** + * 사용자가 선택한 기술 목록을 저장 + * + * @param userId 사용자 ID + * @param selectedSkills 선택한 기술 목록 + */ + @CacheEvict(value = "userSkills", key = "#userId") + @Transactional + public void saveUserSkills(Long userId, List selectedSkills) { + // null 체크 및 빈 리스트 처리 + if (selectedSkills == null) { + selectedSkills = Collections.emptyList(); + } + + // 비즈니스 규칙 검증 + validateSkillSelection(selectedSkills); + + // 사용자 존재 여부 확인 + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // 기존 기술 목록 삭제 + List existingSkills = userSkillRepository.findByUserId(userId); + userSkillRepository.deleteAll(existingSkills); + + // 새로운 기술 목록 저장 + List newSkills = selectedSkills.stream() + .map(skill -> UserSkill.builder() + .user(user) + .skill(skill) + .build()) + .collect(Collectors.toList()); + + userSkillRepository.saveAll(newSkills); + + log.info("사용자 기술 갱신 완료 - userId: {}, 기술 수: {} -> {}", + userId, existingSkills.size(), selectedSkills.size()); + } + + /** + * 기술 선택 유효성 검증 + */ + private void validateSkillSelection(List selectedSkills) { + // 최대 선택 개수 검증 + if (selectedSkills.size() > MAX_SKILL_COUNT) { + throw new GlobalException(ErrorCode.SKILL_LIMIT_EXCEEDED); + } + + // 유효한 기술인지 검증 (Set으로 변환하여 검색 성능 향상) + Set validSkills = Arrays.stream(DeveloperSkillEnum.values()) + .map(DeveloperSkillEnum::getDisplayName) + .collect(Collectors.toSet()); + + // 유효하지 않은 기술 리스트 확인 + List invalidSkills = selectedSkills.stream() + .filter(skill -> !validSkills.contains(skill)) + .collect(Collectors.toList()); + + if (!invalidSkills.isEmpty()) { + log.warn("유효하지 않은 기술 선택 시도 - invalidSkills: {}", invalidSkills); + throw new GlobalException(ErrorCode.INVALID_SKILL); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ff771a6..f76ad83 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -34,7 +34,7 @@ spring: client-secret: ${GOOGLE_CLIENT_SECRET} scope: openid, email, profile github: - scope: user:email, repo + scope: user:email, repo, admin:repo_hook mail: host: smtp.gmail.com port: 587