diff --git a/Gathering_be/docs/1.md b/Gathering_be/docs/1.md new file mode 100644 index 00000000..82a780e5 --- /dev/null +++ b/Gathering_be/docs/1.md @@ -0,0 +1,214 @@ +# **'관심 프로젝트' 기능 백엔드 구현** + +## 📚 목차 + +1. [초기 문제 상황](#1_기능_개요) +2. [데이터 모델-entity](#2_데이터_모델_entity) +3. [데이터 접근 계층-repository](#3_데이터_접근_계층-repository) +4. [서비스 계층-business logic](#4_서비스_계층-business_logic) +5. [api 계층-controller](#5_api_계층-controller) +6. [다른 서비스와의 연동 및 활용](#6_다른_서비스와의_연동_및_활용) + +--- + +## **1. 기능 개요** + +'관심 프로젝트'는 사용자가 흥미로운 프로젝트를 저장(찜하기)하고, 나중에 쉽게 찾아볼 수 있도록 하는 기능이다. 핵심 기능은 다음과 같다. + +- **관심 등록/해제 (Toggle):** 사용자는 버튼 클릭 한 번으로 특정 프로젝트를 관심 목록에 추가하거나 제거할 수 있다. +- **관심 목록 조회:** 사용자는 자신이 관심 등록한 모든 프로젝트의 목록을 조회할 수 있다. +- **상태 표시:** 프로젝트 목록을 볼 때, 각 프로젝트에 대해 현재 사용자의 관심 등록 여부(`isInterested`)가 함께 표시된다. + +--- + +## **2. 데이터 모델 (Entity)** + +사용자(`Profile`)와 프로젝트(`Project`) 간의 **다대다(Many-to-Many)** 관계를 표현하기 위해, `InterestProject`라는 별도의 조인 테이블 엔티티를 설계했다. + +- **핵심 역할:** 어떤 `Profile`이 어떤 `Project`에 관심을 가졌는지 기록한다. +- **구조:** `profile_id`, `project_id`를 외래 키로 가지며, 두 ID의 조합이 하나의 관심 관계를 나타낸다. + +```java +// InterestProject.java +package com.Gathering_be.domain; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor +@Getter +public class InterestProject { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + private Project project; + + @Builder + public InterestProject(Long id, Profile profile, Project project) { + this.id = id; + this.profile = profile; + this.project = project; + } +} +``` + +--- + +## **3. 데이터 접근 계층 (Repository)** + +Spring Data JPA를 사용하여 데이터베이스와의 상호작용을 처리한다. `profileId`와 `projectId`를 조합하여 효율적으로 데이터를 조회, 확인, 삭제하는 쿼리 메서드를 정의했다. + +- **주요 메서드:** + - `existsByProfileIdAndProjectId(...)`: 특정 사용자가 특정 프로젝트에 이미 관심을 등록했는지 `boolean` 값으로 빠르게 확인한다. (토글 로직의 핵심) + - `deleteByProfileIdAndProjectId(...)`: 특정 관심 등록 기록을 효율적으로 삭제한다. + - `findAllByProfileId(...)`: 특정 사용자의 모든 관심 프로젝트 ID를 조회하여, 다른 서비스에서 `isInterested` 상태를 확인하는 데 사용된다. + +```java +// InterestProjectRepository.java +package com.Gathering_be.repository; + +import com.Gathering_be.domain.InterestProject; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface InterestProjectRepository extends JpaRepository { + List findByProfileNickname(String nickname); + boolean existsByProfileIdAndProjectId(Long profileId, Long projectId); + void deleteByProfileIdAndProjectId(Long profileId, Long projectId); + List findAllByProfileId(Long profileId); +} +``` + +--- + +## **4. 서비스 계층 (Business Logic)** + +핵심 비즈니스 로직은 `InterestProjectService`에서 처리한다. 모든 메서드는 요청을 보낸 사용자가 해당 리소스에 접근할 권한이 있는지 `validateMemberAccess`를 통해 철저히 검증한다. + +- **`toggleInterestProject` (관심 등록/해제):** + 1. `existsByProfileIdAndProjectId`를 통해 현재 관심 등록 상태를 확인한다. + 2. **만약 이미 존재한다면,** 새로운 `InterestProject` 엔티티를 생성하여 저장하고 `true`를 반환한다. + 3. 이 '토글(Toggle)' 방식을 통해 하나의 API로 두 가지 기능을 모두 처리하여 효율성을 높였다. +- **`getInterestProjects` (관심 목록 조회):** + + 1. `findByProfileNickname`을 통해 특정 사용자의 모든 관심 프로젝트 목록을 조회하여 DTO로 변환 후 반환한다. + + ```java + // InterestProjectService.java + package com.Gathering_be.service; + + import com.Gathering_be.domain.InterestProject; + // ... (기타 import) + + @Service + @RequiredArgsConstructor + public class InterestProjectService { + private final ProfileRepository profileRepository; + private final ProjectRepository projectRepository; + private final InterestProjectRepository interestProjectRepository; + + @Transactional + public boolean toggleInterestProject(InterestProjectRequest request) { + // ... (로그인한 사용자 정보 및 권한 확인) ... + + boolean exists = interestProjectRepository + .existsByProfileIdAndProjectId(profileId, projectId); + + if (exists) { + interestProjectRepository + .deleteByProfileIdAndProjectId(profileId, projectId); + return false; // 관심 해제됨 + } else { + InterestProject interestProject = InterestProject.builder() + .profile(profile) + .project(getProjectById(projectId)) + .build(); + interestProjectRepository.save(interestProject); + return true; // 관심 등록됨 + } + } + // ... (getInterestProjects 및 private 헬퍼 메서드) ... + } + + + ``` + +--- + +## **5. API 계층 (Controller)** + +`InterestProjectController`는 외부 요청을 받아 `InterestProjectService`의 로직을 호출하는 창구 역할을 한다. + +- **`POST /api/project/interest`:** `projectId`를 받아 `toggleInterestProject`서비스를 호출하고, 변경된 관심 상태(`true`/`false`)를 반환한다. +- **`GET /api/project/interest/{nickname}`:** `nickname`을 받아 `getInterestProjects` 서비스를 호출하고, 해당 사용자의 관심 프로젝트 목록을 반환한다. + +```java + // InterestProjectController.java +package com.Gathering_be.controller; + +import com.Gathering_be.dto.request.InterestProjectRequest; +// ... (기타 import) + +@RestController +@RequestMapping("/api/project/interest") +@RequiredArgsConstructor +public class InterestProjectController { + private final InterestProjectService interestProjectService; + + @PostMapping + public ResultResponse toggleInterestProject(@RequestBody InterestProjectRequest request) { + boolean isInterest = interestProjectService.toggleInterestProject(request); + return ResultResponse.of(ResultCode.INTEREST_PROJECT_TOGGLE_SUCCESS, isInterest); + } + + @GetMapping("/{nickname}") + public ResultResponse getInterestProjects(@PathVariable String nickname) { + List projects = interestProjectService.getInterestProjects(nickname); + return ResultResponse.of(ResultCode.INTEREST_PROJECT_GET_SUCCESS, projects); + } +} +``` + +--- + +## **6. 다른 서비스와의 연동 및 활용** + +`ProjectService`나 `ApplicationService`와 같이, 프로젝트 목록을 사용자에게 보여줘야 하는 모든 곳에서는 `isInterested` 필드를 채워주기 위해 `InterestProjectRepository`를 직접 활용한다. + +- **설계 결정:** 비즈니스 로직이 아닌 단순 조회를 위해 `InterestProjectService`를 거치지 않고, `InterestProjectRepository`를 직접 주입받아 사용함으로써 서비스 간의 불필요한 결합도를 낮추고 성능을 최적화했다. +- **구현 패턴:** + + 1. 현재 로그인한 사용자의 `Profile ID`를 가져온다. + 2. `interestProjectRepository.findAllByProfileId()`를 호출하여 해당 사용자가 관심 등록한 모든 프로젝트의 ID를 Set 형태로 미리 준비한다. + 3. 사용자에게 보여줄 프로젝틑 목록을 순회하면서, 각 프로젝트의 ID가 위에서 만든 `Set`에 포함되어 있는지 확인하여 `isInterested` 값을 `true` 또는 `false`로 설정한다. + + ```java + // ProjectService.java (활용 예시) + public Page searchProjectsWithFilters(...) { + // ... + // 1 & 2. 현재 사용자의 모든 관심 프로젝트 ID를 Set으로 미리 준비 + Set interestedProjectIds = interestProjectRepository.findAllByProfileId(...) + .stream() + .map(interest -> interest.getProject().getId()) + .collect(Collectors.toSet()); + + // 3. 프로젝트 목록을 DTO로 변환할 때, Set에 포함되어 있는지 확인하여 isInterested 값 설정 + return projectPage + .map(project -> ProjectSimpleResponse.from(project, interestedProjectIds.contains(project.getId()), null)); + } + + + ``` + +--- diff --git a/Gathering_be/docs/2.md b/Gathering_be/docs/2.md new file mode 100644 index 00000000..cac34cc5 --- /dev/null +++ b/Gathering_be/docs/2.md @@ -0,0 +1,152 @@ +# **포트폴리오 업로드 기능: S3 권한 문제 해결 과정** + +## 📚 목차 + +1. [초기 문제 상황: 403 Forbidden 에러 발생](#1_초기_문제_상황_403_Forbidden_에러_발생) +2. [원인 분석 및 해결 전략 선택](#2_원인_분석_및_해결_전략_선택) +3. [1차 해결 시도 (Pre-Signed URL 구현)](<#3_1차_해결_시도_(Pre-Signed_URL_구현)>) +4. [새로운 문제 발생: NoSuchKey 에러](#4_새로운_문제_발생_NoSuchKey_에러) +5. [NoSuchKey 최종 원인 분석 및 해결 (실제 코드 기반)](<#5_NoSuchKey_최종_원인_분석_및_해결_(실제_코드_기반)>) + +--- + +## **1. 초기 문제 상황: `403 Forbidden` 에러 발생** + +**구현 내용:** + +- 사용자의 프로필(`Profile`)에는 포트폴리오 정보를 담는 `Portfolio` 객체가 필드로 존재한다. +- 사용자가 포트폴리오 파일(PDF, 이미지 등)을 업로드하면, 파일은 AWS S3 버킷에 저장된다. +- S3에 저장된 파일의 URL과 원본 파일 이름은 `Portfolio` 객체에 정상적으로 저장된다. + +**문제점:** + +- 데이터베이스에는 URL이 올바르게 저장되었으나, `ProfileResponse`를 통해 전달받은 이 URL을 웹 브라우저에서 직접 클릭하여 접근하면, 파일이 보이는 대신 **`403 Forbidden` (접근 거부)** 에러가 발생했다. + +--- + +## **2. 원인 분석 및 해결 전략 선택** + +**원인 진단:** + +- S3 버킷과 그 안의 객체(파일)는 기본적으로 **비공개(Private)**로 설정된다. 따라서 외부에서 URL을 통해 직접 접근하려는 모든 시도는 보안 정책에 의해 차단된다. 이것이 `403 Forbidden` 에러의 직접적인 원인이다. + +**해결 전략:** + +- **'최소 권한 원칙'**에 따라, 보안성이 월등히 뛰어난 **'Pre-Signed URL 사용'** 방식을 채택하기로 결정했다. 이는 개인 정보가 포함될 수 있는 포트폴리오 파일을 안전하게 보호하기 위한 최선의 선택이다. + +--- + +## **3. 1차 해결 시도 (Pre-Signed URL 구현)** + +선택한 전략에 따라 아래와 같이 코드를 수정했다. + +1. **`S3Service` 수정:** 파일 키(key)를 받아 Pre-Signed URL을 생성하는 `getPresignedUrl(String fileKey)` 메서드를 새로 추가했다. +2. **`ProfileResponse` 수정:** `Portfolio` 객체의 정보를 DTO로 변환할 때, DB에 저장된 원본 S3 URL을 그대로 반환하는 대신, `S3Service.getPresignedUrl()`을 호출하여 **매번 새로운 Pre-Signed URL을 생성**하여 반환하도록 로직을 변경했다. + +--- + +## **4. 새로운 문제 발생: `NoSuchKey` 에러** + +1차 해결책을 적용하고 다시 테스트한 결과, `403 Forbidden` 에러는 성공적으로 해결되었다. 하지만 이제 Pre-Signed URL을 클릭하면, 브라우저에 아래와 같은 새로운 에러가 XML 형태로 표시되었다. + +```xml + +NoSuchKey +The specified key does not exist. +tmp.pdf +... +... + +``` + +**새로운 문제:** S3가 "요청한 URL은 유효하지만, 그 URL이 가리키는 파일(Key)이 버킷 안에 존재하지 않습니다" 라고 응답하고 있다. + +--- + +## **5. `NoSuchKey` 최종 원인 분석 및 해결 (실제 코드 기반)** + +이 문제의 진짜 원인을 파악하기 위해, `ProfileResponse` DTO가 생성되는 과정과 `S3Service`의 관련 메서드를 함께 분석했다. + +### **원인 진단: "잘못된 재료로 파일 키 요청"** + +`NoSuchKey` 에러의 근본적인 원인은, S3에 파일이 유니크한 이름으로 저장된다는 점을 간과하고, **원본 파일 이름**으로 Pre-Signed URL 생성을 시도했기 때문임이 밝혀졌다. + +1. **S3 저장 방식:** `S3Service`의 `uploadFile` 메서드는 `createUniqueFileName`을 통해 S3에 파일을 저장할 때 **`portfolio/uuid-원본파일이름.pdf`** 와 같이 유니크한 이름으로 저장한다. +2. **DB 저장 정보:** + - `portfolio.url`: 유니크한 이름이 포함된 **전체 URL**이 저장된다. + - `portfolio.fileName`: **원본 파일 이름**이 저장된다. +3. **초기 구현 실수:** `getPresignedUrl` 메서드는 초기에 아래와 같이 파일 키(`fileName`)를 직접 받아 URL을 생성하도록 구현되었다. + + ```java + // 초기 S3Service의 getPresignedUrl 메서드 + public String getPresignedUrl(String fileName) { + // Presigned URL의 만료 시간 설정 (예: 1시간) + Date expiration = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + + // Presigned URL 생성 + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucketName, fileName) // 파일 키를 직접 사용 + .withExpiration(expiration); + + URL url = s3Client.generatePresignedUrl(generatePresignedUrlRequest); + return url.toString(); + } + ``` + +4. **문제 발생 코드:** `ProfileResponse` DTO를 생성하는 과정에서, 위 메서드에 **`portfolio.getFileName()`**, 즉 **'원본 파일 이름'**을 전달했다. + + ```java + // 수정 전 ProfileResponse 생성 로직 + String presignedUrl = s3Service.getPresignedUrl(portfolio.getFileName()); // 잘못된 값을 전달! + ``` + +5. **결과:** `S3Service`는 `portfolio/원본파일이름.pdf` 라는 키로 Pre-Signed URL 생성을 시도했고, S3에는 해당 키를 가진 파일이 존재하지 않았기 때문에 `NoSuchKey` 에러가 발생한 것이다. + +### **최종 해결책: "올바른 재료로 파일 키 요청하기"** + +이 문제를 해결하기 위해, `ProfileResponse` DTO 생성 로직에서 `getPresignedUrl` 메서드에 **'원본 파일 이름'** 대신, **'전체 URL'**을 전달하도록 수정했다. 동시에, `S3Service`의 `getPresignedUrl` 메서드도 전체 URL을 받아 내부에서 파일 키를 추출하도록 변경했다. + +**✅ 최종 `ProfileResponse` 생성 로직:** + +```java +// 수정 후 ProfileResponse 생성 로직 + +if (isMyProfile) { + Portfolio portfolio = profile.getPortfolio(); + if (portfolio != null) { + // [핵심 해결책 1] + // S3Service가 전체 URL에서 올바른 파일 키를 추출할 수 있도록, + // portfolio.getFileName() 대신 portfolio.getUrl()을 전달한다. + String presignedUrl = s3Service.getPresignedUrl(portfolio.getUrl()); + builder.portfolio(new Portfolio(presignedUrl, portfolio.getFileName())); + } +} +``` + +**✅ 최종 `S3Service`의 `getPresignedUrl` 메서드:** + +```java +// S3Service.java + +public String getPresignedUrl(String fileUrl) { + // [핵심 해결책 2] + // "https://.../portfolio/..." 형태의 전체 URL에서, + // "portfolio/" 부터 시작하는 실제 파일 키(File Key)를 추출한다. + String fileKey = fileUrl.substring(fileUrl.indexOf("portfolio/")); + + // 1시간 동안 유효한 만료 시간을 설정한다. + Date expiration = new Date(System.currentTimeMillis() + 1000 * 60 * 60); + + // 추출된 fileKey를 사용하여 Pre-Signed URL 생성 요청을 만든다. + GeneratePresignedUrlRequest generatePresignedUrlRequest = + new GeneratePresignedUrlRequest(bucket, fileKey) + .withExpiration(expiration); + + URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); + return url.toString(); +} +``` + +이 수정을 통해, `S3Service`는 항상 올바른 전체 URL을 받아 정확한 파일 키를 추출할 수 있게 되었고, `NoSuchKey` 문제가 최종적으로 해결되었다. + +--- diff --git a/Gathering_be/docs/3.md b/Gathering_be/docs/3.md new file mode 100644 index 00000000..9599789d --- /dev/null +++ b/Gathering_be/docs/3.md @@ -0,0 +1,65 @@ +# **계정 로그인 문제** + +## 📚 목차 + +1. [로그인 방식 비대칭 동작 원인 분석](#1_로그인_방식_비대칭_동작_원인_분석) +2. [계정 통합(연결) 기능 구현](<#2_계정_통합(연결)_기능_구현>) + +--- + +## **1. 로그인 방식 비대칭 동작 원인 분석** + +### **시나리오 1: 자체 회원가입 → 구글 로그인 시도 (성공)** + +**비정상적 상황** + +- **동작 과정:** + 1. 사용자가 `cbk121779@gmail.com`으로 **자체 회원가입.** + 2. DB의 `member` 테이블에는 `email: "cbk121779@gmail.com"`, `provider: "BASIC"`인 사용자가 생성. + 3. 이후, 사용자가 **Google 로그인**을 시도. + 4. 프론트엔드는 Google로부터 받은 Access Token을 백엔드의 `AuthService.googleLogin()` 메서드로 전달. + 5. `googleLogin()` 메서드는 Access Token을 이용해 Google로부터 사용자 이메일(`cbk121779@gmail.com`)을 가져온다. + 6. **가장 중요한 부분:** `memberRepository.findByEmail("cbk121779@gmail.com")`를 호출합니다. + 7. 데이터베이스는 이메일이 일치하는 사용자를 성공적으로 찾아냄 (2번에서 만든 `BASIC` 사용자). + 8. `orElseGet(...)` (신규 사용자 생성 로직)은 실행되지 않고 건너뛴다. + 9. 코드는 찾아낸 기존 `Member` 객체를 사용하여 **JWT 토큰을 발급하고 로그인을 성공**시킨다. +- **핵심 원인:** `googleLogin` 메서드의 **애플리케이션 로직**이, 이메일로 사용자를 찾은 뒤 그 사용자의 기존 가입 방식(`provider`)이 `BASIC`인지 `GOOGLE`인지 **전혀 확인하지 않음**. 단순히 "이메일만 같으면 같은 사람"이라고 간주하고 로그인을 허용해 버린다. + +--- + +### **시나리오 2: 구글 로그인 → 자체 회원가입 (실패)** + +**정상적이고 올바른 동작** + +- **동작 과정:** + 1. 사용자가 `cbk121779@gmail.com`으로 **Google 로그인**. + 2. `AuthService.googleLogin()`의 `orElseGet(...)` 부분이 실행되어, DB의 `member` 테이블에는 `email: "cbk121779@gmail.com"`, `provider: "GOOGLE"`인 사용자가 생성. + 3. 이후, 사용자가 같은 이메일로 **자체 회원가입**을 시도. + 4. 백엔드의 `AuthService.signup()` (또는 유사한) 메서드는 새로운 `Member` 객체를 만들어 `memberRepository.save()`를 호출. + 5. **가장 중요한 부분:** 데이터베이스(MySQL)는 새로운 데이터를 `member` 테이블에 `INSERT`하려고 시도. + 6. 이때, `Member` 엔티티에 정의된 **`@Column(nullable = false, unique = true)`** 라는 + + **데이터베이스 제약 조건(Database Constraint)**이 작동. + + 7. 데이터베이스는 "이미 `email` 컬럼에 `cbk121779@gmail.com` 값이 존재하므로, `unique` 제약 조건을 위반합니다" 라고 판단하여 **`INSERT` 작업을 거부하고 에러를 발생**. + + (보통 `DataIntegrityViolationException`) + + 8. 이 에러로 인해 트랜잭션이 롤백되고, 최종적으로 "이미 가입된 이메일입니다" 라는 응답이 프론트엔드로 전달. +- **핵심 원인:** 이 시나리오는 애플리케이션 로직이 아니라, 더 근본적인 **데이터베이스의 규칙**에 의해 차단된다. `email`은 유일해야 한다는 DB의 제약 조건이 안전장치 역할을 수행한 것. + +--- + +## **2. 계정 통합(연결) 기능 구현** + +기존 `BASIC` 계정과 새로운 소셜 로그인 계정을 안전하게 연결하여, 계정 탈취 위험을 막고 사용자 경험을 향상시킨다. + +### **새로운 사용자 경험(UX) 흐름** + +1. 사용자가 `BASIC`으로 가입한 이메일과 동일한 Google 계정으로 로그인을 시도합니다. +2. 백엔드는 이 상황을 감지하고, 프론트엔드에 "이미 가입된 이메일입니다. Google 계정을 연결하시겠습니까?" 라는 특정 에러 응답을 보냅니다. (예: `ACCOUNT_NEEDS_LINKING` 에러 코드) +3. 프론트엔드는 이 응답을 받아 사용자에게 **계정 연결 동의 모달(Modal)**을 띄웁니다.x +4. 사용자가 '연결하기'를 누르면, **기존 계정의 비밀번호를 입력**하여 본인임을 최종 인증합니다. +5. 인증이 성공하면, 백엔드는 기존 `Member` 정보에 Google 로그인 방식을 추가하고, 최종적으로 로그인을 성공시킵니다. + +--- diff --git a/Gathering_be/src/main/java/com/Gathering_be/controller/AuthController.java b/Gathering_be/src/main/java/com/Gathering_be/controller/AuthController.java index 1aca835a..58f794d2 100644 --- a/Gathering_be/src/main/java/com/Gathering_be/controller/AuthController.java +++ b/Gathering_be/src/main/java/com/Gathering_be/controller/AuthController.java @@ -1,5 +1,6 @@ package com.Gathering_be.controller; +import com.Gathering_be.dto.request.LinkAccountRequest; import com.Gathering_be.dto.request.LoginRequest; import com.Gathering_be.dto.request.SignUpRequest; import com.Gathering_be.dto.response.TokenResponse; @@ -49,4 +50,9 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest req return ResponseEntity.ok(ResultResponse.of(ResultCode.LOGIN_SUCCESS, tokenResponse)); } + @PostMapping("/link/google") + public ResponseEntity linkGoogleAccount(@RequestBody LinkAccountRequest request) { + authService.linkGoogleAccount(request); + return ResponseEntity.ok(ResultResponse.of(ResultCode.ACCOUNT_LINK_SUCCESS)); + } } \ No newline at end of file diff --git a/Gathering_be/src/main/java/com/Gathering_be/domain/Member.java b/Gathering_be/src/main/java/com/Gathering_be/domain/Member.java index df4b575c..3d1e46bb 100644 --- a/Gathering_be/src/main/java/com/Gathering_be/domain/Member.java +++ b/Gathering_be/src/main/java/com/Gathering_be/domain/Member.java @@ -8,6 +8,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.util.HashSet; +import java.util.Set; + @Entity @Getter @NoArgsConstructor(access = lombok.AccessLevel.PROTECTED) @@ -26,7 +29,9 @@ public class Member extends BaseTimeEntity { private String password; @Enumerated(EnumType.STRING) - private OAuthProvider provider; + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "member_providers", joinColumns = @JoinColumn(name = "member_id")) + private Set providers = new HashSet<>(); @Enumerated(EnumType.STRING) private Role role; @@ -35,7 +40,7 @@ public class Member extends BaseTimeEntity { public Member(String email, String name, OAuthProvider provider) { this.email = email; this.name = name; - this.provider = provider; + this.providers.add(provider); this.role = Role.ROLE_USER; } @@ -44,10 +49,11 @@ public Member(String email, String name, String password) { this.email = email; this.name = name; this.password = password; - this.provider = OAuthProvider.BASIC; + this.providers.add(OAuthProvider.BASIC); this.role = Role.ROLE_USER; } + public void addProvider(OAuthProvider provider) { this.providers.add(provider); } public void updateRole(Role role) { this.role = role; } diff --git a/Gathering_be/src/main/java/com/Gathering_be/dto/request/LinkAccountRequest.java b/Gathering_be/src/main/java/com/Gathering_be/dto/request/LinkAccountRequest.java new file mode 100644 index 00000000..74d3a65d --- /dev/null +++ b/Gathering_be/src/main/java/com/Gathering_be/dto/request/LinkAccountRequest.java @@ -0,0 +1,11 @@ +package com.Gathering_be.dto.request; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class LinkAccountRequest { + private String email; + private String password; +} diff --git a/Gathering_be/src/main/java/com/Gathering_be/exception/AccountNeedsLinkingException.java b/Gathering_be/src/main/java/com/Gathering_be/exception/AccountNeedsLinkingException.java new file mode 100644 index 00000000..119e58b6 --- /dev/null +++ b/Gathering_be/src/main/java/com/Gathering_be/exception/AccountNeedsLinkingException.java @@ -0,0 +1,10 @@ +package com.Gathering_be.exception; + +import com.Gathering_be.global.exception.BusinessException; +import com.Gathering_be.global.exception.ErrorCode; + +public class AccountNeedsLinkingException extends BusinessException { + public AccountNeedsLinkingException() { + super(ErrorCode.ACCOUNT_NEEDS_LINKING); + } +} diff --git a/Gathering_be/src/main/java/com/Gathering_be/global/exception/ErrorCode.java b/Gathering_be/src/main/java/com/Gathering_be/global/exception/ErrorCode.java index e33789ac..91bbdb6c 100644 --- a/Gathering_be/src/main/java/com/Gathering_be/global/exception/ErrorCode.java +++ b/Gathering_be/src/main/java/com/Gathering_be/global/exception/ErrorCode.java @@ -28,6 +28,7 @@ public enum ErrorCode { INVALID_VERIFICATION_CODE(400, "AU008", "올바르지 않은 인증 코드입니다."), EMAIL_NOT_VERIFIED(400, "AU009", "이메일 인증이 완료되지 않았습니다."), INVALID_EMAIL(400, "AU009", "유효하지 않은 이메일 형식입니다."), + ACCOUNT_NEEDS_LINKING(400, "AU010", "이미 해당 이메일로 회원가입이 되어있어 구글 로그인과의 연동이 필요합니다."), // Member MEMBER_NOT_FOUND(404, "M001", "존재하지 않는 유저입니다."), diff --git a/Gathering_be/src/main/java/com/Gathering_be/global/response/ResultCode.java b/Gathering_be/src/main/java/com/Gathering_be/global/response/ResultCode.java index 49d042b0..e3a94604 100644 --- a/Gathering_be/src/main/java/com/Gathering_be/global/response/ResultCode.java +++ b/Gathering_be/src/main/java/com/Gathering_be/global/response/ResultCode.java @@ -15,6 +15,7 @@ public enum ResultCode { LOGOUT_SUCCESS(200, "AU006", "로그아웃에 성공하였습니다."), EMAIL_SENT_SUCCESS(200, "AU007", "이메일로 인증코드 전송을 성공하였습니다."), EMAIL_VERIFY_SUCCESS(200, "AU008", "이메일 인증코드 인증을 성공하였습니다."), + ACCOUNT_LINK_SUCCESS(200, "AU009", "기존 계정과 Google 계정이 성공적으로 연결되었습니다."), //Profile PROFILE_READ_SUCCESS(200, "P001", "프로필 조회에 성공하였습니다."), diff --git a/Gathering_be/src/main/java/com/Gathering_be/service/AuthService.java b/Gathering_be/src/main/java/com/Gathering_be/service/AuthService.java index 5660343a..f12a9a49 100644 --- a/Gathering_be/src/main/java/com/Gathering_be/service/AuthService.java +++ b/Gathering_be/src/main/java/com/Gathering_be/service/AuthService.java @@ -2,6 +2,7 @@ import com.Gathering_be.domain.Member; import com.Gathering_be.domain.Profile; +import com.Gathering_be.dto.request.LinkAccountRequest; import com.Gathering_be.dto.request.LoginRequest; import com.Gathering_be.dto.request.SignUpRequest; import com.Gathering_be.dto.response.TokenResponse; @@ -12,15 +13,17 @@ import com.Gathering_be.repository.ProfileRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.*; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.client.RestTemplate; -import java.util.ArrayList; -import java.util.HashSet; import java.util.Map; +import java.util.Optional; @Service @RequiredArgsConstructor @@ -61,18 +64,35 @@ public TokenResponse googleLogin(String accessToken) { String email = (String) userInfo.get("email"); String name = (String) userInfo.get("name"); - Member member = memberRepository.findByEmail(email) - .orElseGet(() -> { - Member newMember = Member.oAuthBuilder() - .email(email) - .name(name) - .provider(OAuthProvider.GOOGLE) - .build(); - Member savedMember = memberRepository.save(newMember); - createDefaultProfile(savedMember); - return savedMember; - }); + Optional existingMemberOpt = memberRepository.findByEmail(email); + + if (existingMemberOpt.isPresent()) { + // --- 이미 해당 이메일로 가입된 회원이 있을 경우 --- + Member member = existingMemberOpt.get(); + if (member.getProviders().contains(OAuthProvider.GOOGLE)) { + // [시나리오 1] 이미 Google 계정이 연결된 경우 -> 정상 로그인 처리 + return createAndSaveTokens(member); + } else { + // [시나리오 2] Google 계정이 연결되지 않은 경우 (예: BASIC 가입자) + // "계정 연결이 필요합니다" 라는 특정 예외를 발생시켜 프론트엔드에 알려줍니다. + throw new AccountNeedsLinkingException(); + } + } else { + // --- 신규 회원일 경우 --- + Member newMember = Member.oAuthBuilder() + .email(email) + .name(name) + .provider(OAuthProvider.GOOGLE) + .build(); + Member savedMember = memberRepository.save(newMember); + createDefaultProfile(savedMember); + return createAndSaveTokens(savedMember); + } + } + + // 토큰 생성 및 저장을 위한 private 헬퍼 메서드 + private TokenResponse createAndSaveTokens(Member member) { String jwtToken = jwtTokenProvider.createAccessToken(member.getId(), member.getRole()); String refreshToken = jwtTokenProvider.createRefreshToken(member.getId()); redisService.setValues(REFRESH_TOKEN_PREFIX + member.getId(), refreshToken); @@ -105,7 +125,7 @@ public TokenResponse login(LoginRequest request) { Member member = memberRepository.findByEmail(request.getEmail()) .orElseThrow(InvalidCredentialsException::new); - if (member.getProvider() != OAuthProvider.BASIC) { + if (!member.getProviders().contains(OAuthProvider.BASIC)) { throw new SocialMemberLoginException(); } @@ -121,8 +141,6 @@ public TokenResponse login(LoginRequest request) { return new TokenResponse(accessToken, refreshToken); } - - private Map getUserInfo(String accessToken) { HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(accessToken); @@ -177,4 +195,26 @@ private Member getMemberById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(MemberNotFoundException::new); } + + /** + * 사용자가 비밀번호를 입력하여 기존 계정에 Google 계정을 연결합니다. + * @param request 이메일과 비밀번호가 담긴 DTO + */ + @Transactional + public void linkGoogleAccount(LinkAccountRequest request) { + String email = request.getEmail(); + String password = request.getPassword(); + + // 1. 해당 이메일로 기존 회원을 찾습니다. + Member member = memberRepository.findByEmail(email) + .orElseThrow(MemberNotFoundException::new); + + // 2. 입력된 비밀번호가 맞는지 확인합니다. + if (!passwordEncoder.matches(password, member.getPassword())) { + throw new InvalidCredentialsException(); + } + + // 3. 비밀번호가 맞으면, 이 회원의 로그인 방식에 GOOGLE을 추가합니다. + member.addProvider(OAuthProvider.GOOGLE); + } } \ No newline at end of file diff --git a/Gathering_be/src/main/resources/templates/delete_notify_applicant.html b/Gathering_be/src/main/resources/templates/delete_notify_applicant.html index 0d9a902c..120c7625 100644 --- a/Gathering_be/src/main/resources/templates/delete_notify_applicant.html +++ b/Gathering_be/src/main/resources/templates/delete_notify_applicant.html @@ -41,9 +41,10 @@

게더링 모집글 삭제 diff --git a/Gathering_be/src/main/resources/templates/delete_notify_author.html b/Gathering_be/src/main/resources/templates/delete_notify_author.html index af26a51e..0faa89bc 100644 --- a/Gathering_be/src/main/resources/templates/delete_notify_author.html +++ b/Gathering_be/src/main/resources/templates/delete_notify_author.html @@ -41,9 +41,10 @@

게더링 모집글 삭제 diff --git a/Gathering_fe/public/banner1.png b/Gathering_fe/public/banner1.png new file mode 100644 index 00000000..cf360334 Binary files /dev/null and b/Gathering_fe/public/banner1.png differ diff --git a/Gathering_fe/public/banner2.png b/Gathering_fe/public/banner2.png new file mode 100644 index 00000000..3c72c4eb Binary files /dev/null and b/Gathering_fe/public/banner2.png differ diff --git a/Gathering_fe/src/components/GoogleRedirectHandler.tsx b/Gathering_fe/src/components/GoogleRedirectHandler.tsx index db95c23f..faf9f3c6 100644 --- a/Gathering_fe/src/components/GoogleRedirectHandler.tsx +++ b/Gathering_fe/src/components/GoogleRedirectHandler.tsx @@ -15,6 +15,7 @@ const GoogleRedirectHandler: React.FC = () => { if (code) { try { const result = await googleLogin(code); + if (result?.success) { // showToast('로그인 되었습니다.', true); // setTimeout(() => { diff --git a/Gathering_fe/src/components/Header.tsx b/Gathering_fe/src/components/Header.tsx index 621721a0..a2a70146 100644 --- a/Gathering_fe/src/components/Header.tsx +++ b/Gathering_fe/src/components/Header.tsx @@ -24,8 +24,10 @@ const Header: React.FC = () => { const onLogoClick = () => { if (window.location.pathname === '/') { window.location.reload(); + window.scrollTo(0, 0); } else { nav('/'); + window.scrollTo(0, 0); } }; diff --git a/Gathering_fe/src/pages/PostHome.tsx b/Gathering_fe/src/pages/PostHome.tsx index bd12f503..3aefdf87 100644 --- a/Gathering_fe/src/pages/PostHome.tsx +++ b/Gathering_fe/src/pages/PostHome.tsx @@ -19,6 +19,8 @@ import Pagination from '@/components/Pagination'; import OptionalDropdown from '@/components/OptionalDropdown'; import { useToast } from '@/contexts/ToastContext'; import EmblaCarouselComponent from '@/components/BannerCarousel'; +import banner1 from '/banner1.png'; +import banner2 from '/banner2.png'; interface DropdownDispatchContextType { setSelectedStack: (value: string[]) => void; @@ -54,14 +56,39 @@ const PostHome: React.FC = () => { }); const slides = [ -
- 🌟 새로운 프로젝트를 시작해보세요! 🌟 -
, -
- 💡 아이디어를 공유하고 팀원을 찾아보세요! 💡 +
+ window.open( + 'https://diligent-cloudberry-302.notion.site/Gathering-25545ef4e6838014a912cd391552ab99?source=copy_link', + '_blank' + ) + } + className="relative cursor-pointer h-48 sm:h-64 rounded-xl overflow-hidden shadow-lg group transform transition-transform duration-300 hover:scale-[1.005] will-change-transform" + > + 사용 설명서 배너 +
+

+ 사용 방법을 배우면 프로젝트의 매칭 확률이 올라가요! +

+

+ 게더링 서비스의 사용설명서 보러가기 >> +

+
, -
- 🚀 당신의 기술 스택을 뽐내보세요! 🚀 + +
window.open('https://github.com/Gathering-Organization/Gathering', '_blank')} + className="relative cursor-pointer h-48 sm:h-64 rounded-xl overflow-hidden shadow-lg group transform transition-transform duration-300 hover:scale-[1.005] will-change-transform" + > + 깃허브 배너 +
+

+ IT 초심자를 위한 팀원 모집 웹서비스, 게더링 +

+

+ 게더링 서비스의 오픈소스 보러가기 >> +

+
]; diff --git "a/assets/\353\252\250\354\247\221\352\270\200 \354\236\221\354\204\261 \353\260\251\353\262\225.gif" "b/assets/\353\252\250\354\247\221\352\270\200 \354\236\221\354\204\261 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..55459c59 Binary files /dev/null and "b/assets/\353\252\250\354\247\221\352\270\200 \354\236\221\354\204\261 \353\260\251\353\262\225.gif" differ diff --git "a/assets/\353\252\250\354\247\221\352\270\200 \354\247\200\354\233\220 \353\260\251\353\262\225.gif" "b/assets/\353\252\250\354\247\221\352\270\200 \354\247\200\354\233\220 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..0e52e40a Binary files /dev/null and "b/assets/\353\252\250\354\247\221\352\270\200 \354\247\200\354\233\220 \353\260\251\353\262\225.gif" differ diff --git "a/assets/\353\252\250\354\247\221\354\236\220 \355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 \353\260\251\353\262\225.gif" "b/assets/\353\252\250\354\247\221\354\236\220 \355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..3c9348b3 Binary files /dev/null and "b/assets/\353\252\250\354\247\221\354\236\220 \355\224\204\353\241\234\355\225\204 \354\241\260\355\232\214 \353\260\251\353\262\225.gif" differ diff --git "a/assets/\354\247\200\354\233\220\354\236\220 \354\241\260\355\232\214 \353\260\217 \354\212\271\354\235\270 \353\260\251\353\262\225.gif" "b/assets/\354\247\200\354\233\220\354\236\220 \354\241\260\355\232\214 \353\260\217 \354\212\271\354\235\270 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..f4bbdb32 Binary files /dev/null and "b/assets/\354\247\200\354\233\220\354\236\220 \354\241\260\355\232\214 \353\260\217 \354\212\271\354\235\270 \353\260\251\353\262\225.gif" differ diff --git "a/assets/\355\224\204\353\241\234\355\225\204 \354\240\225\353\263\264 \353\223\261\353\241\235 \353\260\251\353\262\225.gif" "b/assets/\355\224\204\353\241\234\355\225\204 \354\240\225\353\263\264 \353\223\261\353\241\235 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..2afd24e1 Binary files /dev/null and "b/assets/\355\224\204\353\241\234\355\225\204 \354\240\225\353\263\264 \353\223\261\353\241\235 \353\260\251\353\262\225.gif" differ diff --git "a/assets/\355\225\251\352\262\251 \354\213\234 \354\261\204\355\214\205 \353\260\251\353\262\225.gif" "b/assets/\355\225\251\352\262\251 \354\213\234 \354\261\204\355\214\205 \353\260\251\353\262\225.gif" new file mode 100644 index 00000000..0c2b5e1d Binary files /dev/null and "b/assets/\355\225\251\352\262\251 \354\213\234 \354\261\204\355\214\205 \353\260\251\353\262\225.gif" differ