diff --git a/.gitignore b/.gitignore index c2065bc..97adfa9 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +/src/main/resources/properties/env.properties +/src/main/java/com/example/feeda/config/PropertyConfig.java diff --git a/README.md b/README.md index 283b096..7814902 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,102 @@ -# spring-feeda -모두가 모여 서로의 스터디 정보와 노하우를 나누는 커뮤니티 SNS입니다. +# 피다(Feeda) +개발자, 학생, 그리고 자기 계발에 관심 있는 모두가 모여 서로의 스터디 정보와 노하우를 나누는 커뮤니티 SNS입니다. + +- 스터디 모집 및 경험 공유 +- 질문과 답변을 통한 지식 나눔 +- 성장과 동기부여를 위한 소통 공간 +- 함께 배우고, 함께 성장하는 즐거움을 경험 시켜 드릴수가 있을 것 같습니다. + +
+ +## 👨‍💻 Team +- 팀명: 6pring (식스프링) +- 소개: 4명의 아기자기한 팀 +- 팀원 및 역할 분담 + +| 이름 | 역할 | 주요 담당 업무 | +|-----|----|------------------------------------------------------------------------------------------| +| 최경진 | 팀장 | - 발표 ✨
- 프로필 관련 API 개발
- 게시글 댓글 관련 API 개발 | +| 김나경 | 팀원 | - ERD 작성 및 DB 설계
- JWT 인증/인가 관련 기능 구현
- 회원 관리 관련 API 개발
- 게시글 댓글 좋아요 관련 API 개발 | +| 안요한 | 팀원 | - 와이어 프레임 작성
- 게시글 관련 API 개발
- 게시글 좋아요 관련 API 개발 | +| 이의현 | 팀원 | - API 명세서 작성
- 팔로우(친구 관리) 관련 API 개발
- 전역 예외 처리 핸들러 개발
- 테스트 코드 작성 | + + +
+ +## 🛠 사용 기술 +- Java 17 +- Gradle 8.5 +- Spring Boot 3.5.0 +- Spring Data JPA (Hibernate 6.6.13.Final) +- Spring Security +- MySQL 8.0 이상 +- Redis Cloud + +
+ +## 💻 개발 도구 +- IntelliJ IDEA +- Redis Insight +- Git +- Postman + +
+ +## 📃 프로젝트 설계 +
+API 명세서 + +Postman: [document](https://documenter.getpostman.com/view/44635744/2sB2qgeyJ7) + +Notion +- [필수기능 명세서](https://www.notion.so/2002dc3ef5148050b741cdfba818f530?pvs=21) +- [도전기능 명세서](https://www.notion.so/2022dc3ef51481939541e86c62aa7864?pvs=21) +
+ +
+와이어 프레임 + +![와이어프레임](./images/wireframe.png) +
+ +
+DB 설계 + +- 개념적 설계: + - ![er](./images/er.png) +- 논리적 설계(ERD): + - ![erd](./images/erd.png) +- 물리적 설계(SQL): + - [newsFeed2.sql](./newsFeed2.sql) + - ![erdE](./images/erdE.png) +
+ +
+ +## 📁 폴더 구조 +```bash +src +├──── main.java.com.example.feeda +│ ├──── config # 설정 관련 +│ ├──── domain # 도메인별 기능 분류 +│ │ ├──── account +│ │ ├──── comment +│ │ ├──── follow +│ │ ├──── post +│ │ └──── profile +│ ├──── exception # 예외 클래스 및 처리 +│ ├──── filter # 인증 필터 +│ ├──── security # 보안 관련 (PasswordEncoder, JWT) +│ └──── FeedaApplication.java +└──── test # 테스트 코드 +``` + +
+ +## 🔍 새로운 지식 + + +## 🧰 문제 해결 (트러블 슈팅) + + + diff --git a/build.gradle b/build.gradle index 25f6d6e..6a3fa01 100644 --- a/build.gradle +++ b/build.gradle @@ -1,46 +1,50 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.0' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.0' + id 'io.spring.dependency-management' version '1.1.7' } group = 'com.example' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } + compileOnly { + extendsFrom annotationProcessor + } } repositories { - mavenCentral() + mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - runtimeOnly 'com.mysql:mysql-connector-j' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - // jwt - compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' - runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' - runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + implementation 'mysql:mysql-connector-java:8.0.33' // 추가 + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + runtimeOnly 'com.mysql:mysql-connector-j' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // jwt + compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + + testImplementation 'com.h2database:h2' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } diff --git a/images/er.png b/images/er.png new file mode 100644 index 0000000..816fc2b Binary files /dev/null and b/images/er.png differ diff --git a/images/erd.png b/images/erd.png new file mode 100644 index 0000000..cf35035 Binary files /dev/null and b/images/erd.png differ diff --git a/images/erdE.png b/images/erdE.png new file mode 100644 index 0000000..d2c6fb4 Binary files /dev/null and b/images/erdE.png differ diff --git a/images/wireframe.png b/images/wireframe.png new file mode 100644 index 0000000..c8ec456 Binary files /dev/null and b/images/wireframe.png differ diff --git a/newsFeed.sql b/newsFeed.sql new file mode 100644 index 0000000..c0cd7ba --- /dev/null +++ b/newsFeed.sql @@ -0,0 +1,49 @@ +DROP DATABASE IF EXISTS newsFeed; +CREATE DATABASE IF NOT EXISTS newsFeed; +USE newsFeed; + +-- 사용자 인증(accounts) 테이블 생성 +CREATE TABLE accounts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 인증 ID (PK)', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', + password VARCHAR(255) NOT NULL COMMENT '비밀번호', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일' +) COMMENT = '사용자 인증 Table'; + +-- 사용자 정보(profile) 테이블 생성 +CREATE TABLE profiles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 정보 ID (PK)', + account_id BIGINT COMMENT '사용자 인증 ID (FK)', + nickname VARCHAR(50) NOT NULL UNIQUE COMMENT '닉네임', + birth DATE COMMENT '생년월일', + bio TEXT COMMENT '자기소개', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일', + + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) COMMENT = '사용자 정보 Table'; + +-- 게시글(posts) 테이블 생성 +CREATE TABLE posts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '게시글 ID (PK)', + profile_id BIGINT NOT NULL COMMENT '작성자 ID (FK)', + title VARCHAR(100) NOT NULL COMMENT '제목', + content TEXT NOT NULL COMMENT '내용', + category VARCHAR(50) COMMENT '카테고리', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일', + + FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '게시글 Table'; + +-- 팔로우 목록(follows) 테이블 생성 +CREATE TABLE follows ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '팔로우 ID (PK)', + follower_id BIGINT NOT NULL COMMENT '팔로우한 사람 ID (PK)', + following_id BIGINT NOT NULL COMMENT '팔로잉된 사람 ID (FK)', + created_at DATETIME COMMENT '생성일', + + FOREIGN KEY (follower_id) REFERENCES profiles(id) ON DELETE CASCADE, + FOREIGN KEY (following_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '팔로우 목록 Table' \ No newline at end of file diff --git a/newsFeed2.sql b/newsFeed2.sql new file mode 100644 index 0000000..e5baf2a --- /dev/null +++ b/newsFeed2.sql @@ -0,0 +1,84 @@ +DROP DATABASE IF EXISTS newsFeed; +CREATE DATABASE IF NOT EXISTS newsFeed; +USE newsFeed; + +-- 사용자 인증(accounts) 테이블 생성 +CREATE TABLE accounts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 인증 ID (PK)', + email VARCHAR(100) NOT NULL UNIQUE COMMENT '이메일', + password VARCHAR(255) NOT NULL COMMENT '비밀번호', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일' +) COMMENT = '사용자 인증 Table'; + +-- 사용자 정보(profile) 테이블 생성 +CREATE TABLE profiles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '사용자 정보 ID (PK)', + account_id BIGINT COMMENT '사용자 인증 ID (FK)', + nickname VARCHAR(50) NOT NULL UNIQUE COMMENT '닉네임', + birth DATE COMMENT '생년월일', + bio TEXT COMMENT '자기소개', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일', + + FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE +) COMMENT = '사용자 정보 Table'; + +-- 팔로우 목록(follows) 테이블 생성 +CREATE TABLE follows ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '팔로우 ID (PK)', + follower_id BIGINT NOT NULL COMMENT '팔로우한 사람 ID (PK)', + following_id BIGINT NOT NULL COMMENT '팔로잉된 사람 ID (FK)', + created_at DATETIME COMMENT '생성일', + + FOREIGN KEY (follower_id) REFERENCES profiles(id) ON DELETE CASCADE, + FOREIGN KEY (following_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '팔로우 목록 Table'; + +-- 게시글(posts) 테이블 생성 +CREATE TABLE posts ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '게시글 ID (PK)', + profile_id BIGINT NOT NULL COMMENT '작성자 ID (FK)', + title VARCHAR(100) NOT NULL COMMENT '제목', + content TEXT NOT NULL COMMENT '내용', + category VARCHAR(50) COMMENT '카테고리', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일', + + FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '게시글 Table'; + +-- 게시글 댓글(post_comments) 테이블 생성 +CREATE TABLE post_comments ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 ID (PK)', + post_id BIGINT NOT NULL COMMENT '게시글 ID (FK)', + profile_id BIGINT NOT NULL COMMENT '작성자 ID (FK)', + content TEXT NOT NULL COMMENT '내용', + created_at DATETIME COMMENT '생성일', + updated_at DATETIME COMMENT '수정일', + + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '게시글 댓글 Table'; + +-- 게시글 좋아요(post_likes) 테이블 생성 +CREATE TABLE post_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 ID (PK)', + post_id BIGINT NOT NULL COMMENT '게시글 ID (FK)', + profile_id BIGINT NOT NULL COMMENT '사용자 ID (FK)', + created_at DATETIME COMMENT '생성일', + + FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE, + FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '게시글 좋아요 Table'; + +-- 게시글 댓글 좋아요(post_comment_likes) 테이블 생성 +CREATE TABLE post_comment_likes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '댓글 ID (PK)', + post_comment_id BIGINT NOT NULL COMMENT '게시글 ID (FK)', + profile_id BIGINT NOT NULL COMMENT '사용자 ID (FK)', + created_at DATETIME COMMENT '생성일', + + FOREIGN KEY (post_comment_id) REFERENCES post_comments(id) ON DELETE CASCADE, + FOREIGN KEY (profile_id) REFERENCES profiles(id) ON DELETE CASCADE +) COMMENT = '게시글 댓글 좋아요 Table'; \ No newline at end of file diff --git a/src/main/java/com/example/feeda/config/.gitkeep b/src/main/java/com/example/feeda/config/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/feeda/config/RedisConfig.java b/src/main/java/com/example/feeda/config/RedisConfig.java new file mode 100644 index 0000000..65c3c4e --- /dev/null +++ b/src/main/java/com/example/feeda/config/RedisConfig.java @@ -0,0 +1,34 @@ +package com.example.feeda.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; + + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Value("${spring.data.redis.password:}") + private String password; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(); + config.setHostName(host); + config.setPort(port); + config.setPassword(password); + + return new LettuceConnectionFactory(config); + } +} diff --git a/src/main/java/com/example/feeda/config/SecurityConfig.java b/src/main/java/com/example/feeda/config/SecurityConfig.java index e6e37dc..80ac914 100644 --- a/src/main/java/com/example/feeda/config/SecurityConfig.java +++ b/src/main/java/com/example/feeda/config/SecurityConfig.java @@ -1,8 +1,10 @@ package com.example.feeda.config; import com.example.feeda.filter.JwtFilter; +import com.example.feeda.security.handler.CustomAccessDeniedHandler; +import com.example.feeda.security.handler.CustomAuthenticationEntryPoint; +import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtUtil; -import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +21,10 @@ @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; @Bean public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception { @@ -41,32 +46,16 @@ public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Excepti .requestMatchers("/error").permitAll() .requestMatchers("/api/**").authenticated() - // 비로그인 시 GET 만 허용 -// .requestMatchers(HttpMethod.GET, "/api/**").permitAll() -// .requestMatchers("/api/**").permitAll() - .anyRequest().denyAll() ) // 필터 등록 - .addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new JwtFilter(jwtBlacklistService, jwtUtil), UsernamePasswordAuthenticationFilter.class) .exceptionHandling(configurer -> configurer - .authenticationEntryPoint((request, response, authException) -> { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"인증 실패: " + authException.getMessage() + "\"}"; - response.getWriter().write(message); - }) - .accessDeniedHandler((request, response, accessDeniedException) -> { - response.setStatus(HttpServletResponse.SC_FORBIDDEN); - response.setContentType("application/json;charset=UTF-8"); - - String message = "{\"error\": \"접근 거부: " + accessDeniedException.getMessage() + "\"}"; - response.getWriter().write(message); - }) + .authenticationEntryPoint(customAuthenticationEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler) ) .build(); diff --git a/src/main/java/com/example/feeda/domain/account/AccountController.java b/src/main/java/com/example/feeda/domain/account/AccountController.java deleted file mode 100644 index 2776ada..0000000 --- a/src/main/java/com/example/feeda/domain/account/AccountController.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.example.feeda.domain.account; - -import com.example.feeda.domain.account.dto.*; -import com.example.feeda.security.jwt.JwtPayload; -import com.example.feeda.security.jwt.JwtUtil; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/api") -@RequiredArgsConstructor -public class AccountController { - private final AccountService accountService; - private final JwtUtil jwtUtil; - - @PostMapping("/accounts") - public ResponseEntity signup(@RequestBody SignUpRequestDTO requestDTO) { - return new ResponseEntity<>(accountService.signup(requestDTO), HttpStatus.CREATED); - } - - @DeleteMapping("/accounts/me") - public ResponseEntity deleteAccount( - @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody DeleteAccountRequestDTO requestDTO - ) { - accountService.deleteAccount(jwtPayload.getUserId(), requestDTO.getPassword()); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } - - @PatchMapping("/accounts/password") - public ResponseEntity updatePassword( - @AuthenticationPrincipal JwtPayload jwtPayload, - @RequestBody UpdatePasswordRequestDTO requestDTO - ) { - return new ResponseEntity<>(accountService.updatePassword(jwtPayload.getUserId(), requestDTO), HttpStatus.OK); - } - - @PostMapping("/accounts/login") - public ResponseEntity login( - @RequestBody LogInRequestDTO requestDTO - ) { - UserResponseDTO responseDTO = accountService.login(requestDTO); - - String jwt = jwtUtil.createToken(responseDTO.getId(), "gaji", responseDTO.getEmail()); - - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", "Bearer " + jwt); - - return new ResponseEntity<>(responseDTO, headers, HttpStatus.OK); - } - -} diff --git a/src/main/java/com/example/feeda/domain/account/controller/AccountController.java b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java new file mode 100644 index 0000000..2773b65 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/account/controller/AccountController.java @@ -0,0 +1,85 @@ +package com.example.feeda.domain.account.controller; + +import com.example.feeda.domain.account.sevice.AccountServiceImpl; +import com.example.feeda.domain.account.dto.*; +import com.example.feeda.security.jwt.JwtBlacklistService; +import com.example.feeda.security.jwt.JwtPayload; +import com.example.feeda.security.jwt.JwtUtil; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AccountController { + private final AccountServiceImpl accountService; + private final JwtBlacklistService jwtBlacklistService; + private final JwtUtil jwtUtil; + + @PostMapping("/accounts") + public ResponseEntity signup(@RequestBody @Valid SignUpRequestDTO requestDTO) { + return new ResponseEntity<>(accountService.signup(requestDTO), HttpStatus.CREATED); + } + + @DeleteMapping("/accounts/me") + public ResponseEntity deleteAccount( + @RequestHeader("Authorization") String bearerToken, + @AuthenticationPrincipal JwtPayload jwtPayload, + @RequestBody @Valid DeleteAccountRequestDTO requestDTO + ) { + accountService.deleteAccount(jwtPayload.getAccountId(), requestDTO.getPassword()); + + // 토큰 무효화 + invalidateToken(bearerToken); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @PatchMapping("/accounts/password") + public ResponseEntity updatePassword( + @AuthenticationPrincipal JwtPayload jwtPayload, + @RequestBody @Valid UpdatePasswordRequestDTO requestDTO + ) { + return new ResponseEntity<>(accountService.updatePassword(jwtPayload.getAccountId(), requestDTO), HttpStatus.OK); + } + + @PostMapping("/accounts/login") + public ResponseEntity login( + @RequestBody @Valid LogInRequestDTO requestDTO + ) { + UserResponseDTO responseDTO = accountService.login(requestDTO); + + JwtPayload payload = new JwtPayload( + responseDTO.getAccountId(), + responseDTO.getProfileId(), + responseDTO.getEmail(), + responseDTO.getNickName() + ); + + String jwt = jwtUtil.createToken(payload); + + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "Bearer " + jwt); + + return new ResponseEntity<>(responseDTO, headers, HttpStatus.OK); + } + + @PostMapping("/accounts/logout") + public ResponseEntity logout(@RequestHeader("Authorization") String bearerToken) { + // 토큰 무효화 + invalidateToken(bearerToken); + + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + + private void invalidateToken(String bearerToken) { + String token = jwtUtil.extractToken(bearerToken); + jwtBlacklistService.addBlacklist(token); + } +} diff --git a/src/main/java/com/example/feeda/domain/account/dto/UserResponseDTO.java b/src/main/java/com/example/feeda/domain/account/dto/UserResponseDTO.java index b09d362..70f23a6 100644 --- a/src/main/java/com/example/feeda/domain/account/dto/UserResponseDTO.java +++ b/src/main/java/com/example/feeda/domain/account/dto/UserResponseDTO.java @@ -9,17 +9,18 @@ @Getter @AllArgsConstructor public class UserResponseDTO { - private final Long id; + private final Long accountId; + private final Long profileId; private final String email; private String nickName; private final LocalDateTime createdAt; private final LocalDateTime updatedAt; - // TODO: Profile 값 도 추가 - public UserResponseDTO(Account account) { - this.id = account.getId(); + this.accountId = account.getId(); + this.profileId = account.getProfile().getId(); this.email = account.getEmail(); + this.nickName = account.getProfile().getNickname(); this.createdAt = account.getCreatedAt(); this.updatedAt = account.getUpdatedAt(); } diff --git a/src/main/java/com/example/feeda/domain/account/entity/Account.java b/src/main/java/com/example/feeda/domain/account/entity/Account.java index 872c2d1..725e2eb 100644 --- a/src/main/java/com/example/feeda/domain/account/entity/Account.java +++ b/src/main/java/com/example/feeda/domain/account/entity/Account.java @@ -35,6 +35,7 @@ public class Account { @Column(name = "updated_at") private LocalDateTime updatedAt; + @Setter @OneToOne(mappedBy = "account", cascade = CascadeType.ALL, optional = false) private Profile profile; diff --git a/src/main/java/com/example/feeda/domain/account/AccountRepository.java b/src/main/java/com/example/feeda/domain/account/repository/AccountRepository.java similarity index 83% rename from src/main/java/com/example/feeda/domain/account/AccountRepository.java rename to src/main/java/com/example/feeda/domain/account/repository/AccountRepository.java index dad37bf..8e3cfb2 100644 --- a/src/main/java/com/example/feeda/domain/account/AccountRepository.java +++ b/src/main/java/com/example/feeda/domain/account/repository/AccountRepository.java @@ -1,4 +1,4 @@ -package com.example.feeda.domain.account; +package com.example.feeda.domain.account.repository; import com.example.feeda.domain.account.entity.Account; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java new file mode 100644 index 0000000..9c18c4a --- /dev/null +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountService.java @@ -0,0 +1,16 @@ +package com.example.feeda.domain.account.sevice; + +import com.example.feeda.domain.account.dto.LogInRequestDTO; +import com.example.feeda.domain.account.dto.SignUpRequestDTO; +import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; +import com.example.feeda.domain.account.dto.UserResponseDTO; + +public interface AccountService { + UserResponseDTO signup(SignUpRequestDTO requestDTO); + + void deleteAccount(Long id, String password); + + UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO); + + UserResponseDTO login(LogInRequestDTO requestDTO); +} diff --git a/src/main/java/com/example/feeda/domain/account/AccountService.java b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java similarity index 59% rename from src/main/java/com/example/feeda/domain/account/AccountService.java rename to src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java index 1d9aecd..fb2c7aa 100644 --- a/src/main/java/com/example/feeda/domain/account/AccountService.java +++ b/src/main/java/com/example/feeda/domain/account/sevice/AccountServiceImpl.java @@ -1,57 +1,72 @@ -package com.example.feeda.domain.account; +package com.example.feeda.domain.account.sevice; import com.example.feeda.domain.account.dto.LogInRequestDTO; import com.example.feeda.domain.account.dto.UpdatePasswordRequestDTO; import com.example.feeda.domain.account.dto.UserResponseDTO; import com.example.feeda.domain.account.dto.SignUpRequestDTO; import com.example.feeda.domain.account.entity.Account; +import com.example.feeda.domain.account.repository.AccountRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; import com.example.feeda.security.PasswordEncoder; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - @Service @RequiredArgsConstructor -public class AccountService { +public class AccountServiceImpl implements AccountService { private final AccountRepository accountRepository; private final PasswordEncoder passwordEncoder; + private final ProfileRepository profileRepository; + @Override + @Transactional public UserResponseDTO signup(SignUpRequestDTO requestDTO) { if(accountRepository.findByEmail(requestDTO.getEmail()).isPresent()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다. : " + requestDTO.getEmail()); + throw new CustomResponseException(ResponseError.EMAIL_ALREADY_EXISTS); + } + + if(profileRepository.findByNickname(requestDTO.getNickName()).isPresent()) { + throw new CustomResponseException(ResponseError.NICKNAME_ALREADY_EXISTS); } Account account = new Account(requestDTO.getEmail(), requestDTO.getPassword()); account.setPassword(passwordEncoder.encode(account.getPassword())); - Account saveAccount = accountRepository.save(account); + Profile profile = new Profile(requestDTO.getNickName(), requestDTO.getBirth(), requestDTO.getBio()); - // Todo: Profile 생성 로직 추가 + // 양방향 연결 + account.setProfile(profile); + profile.setAccount(account); - return new UserResponseDTO(saveAccount); + Account saveProfile = accountRepository.save(account); + + return new UserResponseDTO(saveProfile); } + @Override @Transactional public void deleteAccount(Long id, String password) { Account account = getAccountById(id); if(!passwordEncoder.matches(password, account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); } accountRepository.delete(account); } + @Override @Transactional public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestDTO) { Account account = getAccountById(id); if(!passwordEncoder.matches(requestDTO.getOldPassword(), account.getPassword())) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."); + throw new CustomResponseException(ResponseError.INVALID_PASSWORD); } account.setPassword(passwordEncoder.encode(requestDTO.getNewPassword())); @@ -62,21 +77,20 @@ public UserResponseDTO updatePassword(Long id, UpdatePasswordRequestDTO requestD return new UserResponseDTO(account); } - + @Override public UserResponseDTO login(LogInRequestDTO requestDTO) { return new UserResponseDTO(accountRepository.findByEmail(requestDTO.getEmail()) .filter(findAccount -> passwordEncoder.matches(requestDTO.getPassword(), findAccount.getPassword())) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다.")) + .orElseThrow(() -> new CustomResponseException(ResponseError.INVALID_EMAIL_OR_PASSWORD)) ); } - /* 유틸(?): 서비스 내에서만 사용 */ public Account getAccountById(Long id) { return accountRepository.findById(id).orElseThrow(() -> - new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id 의 유저가 존재하지 않습니다. : " + id) + new CustomResponseException(ResponseError.ACCOUNT_NOT_FOUND) ); } } diff --git a/src/main/java/com/example/feeda/domain/comment/controller/CommentController.java b/src/main/java/com/example/feeda/domain/comment/controller/CommentController.java new file mode 100644 index 0000000..b00ac8f --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/controller/CommentController.java @@ -0,0 +1,104 @@ +package com.example.feeda.domain.comment.controller; + +import com.example.feeda.domain.comment.dto.CommentResponse; +import com.example.feeda.domain.comment.dto.CreateCommentRequest; +import com.example.feeda.domain.comment.dto.UpdateCommentRequest; +import com.example.feeda.domain.comment.service.CommentService; +import com.example.feeda.domain.comment.dto.LikeCommentResponseDTO; +import com.example.feeda.domain.comment.service.CommentLikeService; +import com.example.feeda.security.jwt.JwtPayload; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +@Validated +public class CommentController { + + private final CommentLikeService commentLikeService; + private final CommentService commentService; + + // 댓글 작성 + @PostMapping("/comments/post/{postId}") + public ResponseEntity createComment( + @PathVariable Long postId, + @AuthenticationPrincipal JwtPayload jwtPayload, + @RequestBody @Valid CreateCommentRequest request + ) { + Long profileId = jwtPayload.getProfileId(); + CommentResponse response = commentService.createComment(postId, profileId, request); + return ResponseEntity.ok(response); + } + + // 댓글 전체 조회 (게시글 기준, 정렬/필터 가능) + @GetMapping("/comments/post/{postId}") + public ResponseEntity> getCommentsByPostId( + @PathVariable Long postId, + @RequestParam(defaultValue = "latest") String sort // latest 또는 oldest + ) { + List comments = commentService.getCommentsByPostId(postId, sort); + return ResponseEntity.ok(comments); + } + + // 댓글 단건 조회 + @GetMapping("/comments/{commentId}") + public ResponseEntity getCommentById(@PathVariable Long commentId) { + CommentResponse response = commentService.getCommentById(commentId); + return ResponseEntity.ok(response); + } + + + // 댓글 수정 + @PutMapping("/comments/{commentId}") + public ResponseEntity updateComment( + @PathVariable Long commentId, + @AuthenticationPrincipal JwtPayload jwtPayload, + @Valid @RequestBody UpdateCommentRequest request + ) { + Long profileId = jwtPayload.getProfileId(); + CommentResponse response = commentService.updateComment(commentId, profileId, request); + return ResponseEntity.ok(response); + } + + // 댓글 삭제 + @DeleteMapping("/comments/{commentId}") + public ResponseEntity deleteComment( + @PathVariable Long commentId, + @AuthenticationPrincipal JwtPayload jwtPayload + ) { + Long profileId = jwtPayload.getProfileId(); + commentService.deleteComment(commentId, profileId); + return ResponseEntity.noContent().build(); + } + + // 댓글 좋아요 + @PostMapping("/comments/{id}/likes") + public ResponseEntity likeComment( + @PathVariable Long id, + @AuthenticationPrincipal JwtPayload jwtPayload + ) { + Long profileId = jwtPayload.getProfileId(); + return new ResponseEntity<>( + commentLikeService.likeComment(id, profileId), + HttpStatus.OK + ); + } + + // 댓글 좋아요 취소 + @DeleteMapping("/comments/{id}/likes") + public ResponseEntity unlikeComment( + @PathVariable Long id, + @AuthenticationPrincipal JwtPayload jwtPayload + ) { + commentLikeService.unlikeComment(id, jwtPayload.getProfileId()); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/dto/CommentResponse.java b/src/main/java/com/example/feeda/domain/comment/dto/CommentResponse.java new file mode 100644 index 0000000..ae28b6c --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/dto/CommentResponse.java @@ -0,0 +1,25 @@ +package com.example.feeda.domain.comment.dto; + +import com.example.feeda.domain.comment.entity.Comment; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(staticName = "of") +public class CommentResponse { + private final Long id; + private final String content; + private final String profileName; + private final LocalDateTime createdAt; + + public static CommentResponse from(Comment comment) { + return CommentResponse.of( + comment.getId(), + comment.getContent(), + comment.getProfile().getNickname(), + comment.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/dto/CreateCommentRequest.java b/src/main/java/com/example/feeda/domain/comment/dto/CreateCommentRequest.java new file mode 100644 index 0000000..f41596e --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/dto/CreateCommentRequest.java @@ -0,0 +1,12 @@ +package com.example.feeda.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor() +public class CreateCommentRequest { + @NotBlank(message = "내용을 입력해주세요.") + private String content; +} diff --git a/src/main/java/com/example/feeda/domain/comment/dto/LikeCommentResponseDTO.java b/src/main/java/com/example/feeda/domain/comment/dto/LikeCommentResponseDTO.java new file mode 100644 index 0000000..48a1535 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/dto/LikeCommentResponseDTO.java @@ -0,0 +1,23 @@ +package com.example.feeda.domain.comment.dto; + +import com.example.feeda.domain.comment.entity.CommentLike; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +public class LikeCommentResponseDTO { + private final Long id; + private final Long commentId; + private final Long profileId; + private final LocalDateTime createdAt; + + public LikeCommentResponseDTO(CommentLike commentLike) { + this.id = commentLike.getId(); + this.commentId = commentLike.getComment().getId(); + this.profileId = commentLike.getProfile().getId(); + this.createdAt = commentLike.getCreatedAt(); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/dto/UpdateCommentRequest.java b/src/main/java/com/example/feeda/domain/comment/dto/UpdateCommentRequest.java new file mode 100644 index 0000000..0379e0c --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/dto/UpdateCommentRequest.java @@ -0,0 +1,12 @@ +package com.example.feeda.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class UpdateCommentRequest { + @NotBlank(message = "내용을 입력해주세요.") + private String content; +} diff --git a/src/main/java/com/example/feeda/domain/comment/entity/Comment.java b/src/main/java/com/example/feeda/domain/comment/entity/Comment.java new file mode 100644 index 0000000..4f2d2e2 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/entity/Comment.java @@ -0,0 +1,54 @@ +package com.example.feeda.domain.comment.entity; + +import com.example.feeda.domain.post.entity.Post; +import com.example.feeda.domain.profile.entity.Profile; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_comments") +@Getter +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +public class Comment { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @Column(nullable = false) + private String content; + + @CreatedDate + @Column(updatable = false, name = "created_at") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public Comment(Post post, Profile profile, String content) { + this.post = post; + this.profile = profile; + this.content = content; + } + + public void updateContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/entity/CommentLike.java b/src/main/java/com/example/feeda/domain/comment/entity/CommentLike.java new file mode 100644 index 0000000..6cfb13a --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/entity/CommentLike.java @@ -0,0 +1,40 @@ +package com.example.feeda.domain.comment.entity; + +import com.example.feeda.domain.profile.entity.Profile; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_comment_likes") +@Getter +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +public class CommentLike { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "post_comment_id", nullable = false) + private Comment comment; + + @ManyToOne + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @CreatedDate + @Column(updatable = false, name = "created_at") + private LocalDateTime createdAt; + + public CommentLike(Comment comment, Profile profile) { + this.comment = comment; + this.profile = profile; + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/repository/CommentLikeRepository.java b/src/main/java/com/example/feeda/domain/comment/repository/CommentLikeRepository.java new file mode 100644 index 0000000..e8a85c5 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/repository/CommentLikeRepository.java @@ -0,0 +1,10 @@ +package com.example.feeda.domain.comment.repository; + +import com.example.feeda.domain.comment.entity.CommentLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CommentLikeRepository extends JpaRepository { + Optional findByComment_IdAndProfile_Id(Long commentId, Long profileId); +} diff --git a/src/main/java/com/example/feeda/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/feeda/domain/comment/repository/CommentRepository.java new file mode 100644 index 0000000..61835b4 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/repository/CommentRepository.java @@ -0,0 +1,15 @@ +package com.example.feeda.domain.comment.repository; + +import com.example.feeda.domain.comment.entity.Comment; +import com.example.feeda.domain.post.entity.Post; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + List findByPostIdOrderByCreatedAtDesc(Long postId); // 최신순 + + List findByPostIdOrderByCreatedAtAsc(Long postId); + + Long countByPost(Post findPost); +} diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java new file mode 100644 index 0000000..0746e92 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentLikeService.java @@ -0,0 +1,55 @@ +package com.example.feeda.domain.comment.service; + +import com.example.feeda.domain.comment.dto.LikeCommentResponseDTO; +import com.example.feeda.domain.comment.entity.Comment; +import com.example.feeda.domain.comment.entity.CommentLike; +import com.example.feeda.domain.comment.repository.CommentLikeRepository; +import com.example.feeda.domain.comment.repository.CommentRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CommentLikeService { + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final ProfileRepository profileRepository; + + + public LikeCommentResponseDTO likeComment(Long commentId, Long profileId) { + Optional findCommentLike = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); + if(findCommentLike.isPresent()) { + throw new CustomResponseException(ResponseError.ALREADY_LIKED_COMMENT); + } + + Comment findComment = commentRepository.findById(commentId).orElseThrow(() -> + new CustomResponseException(ResponseError.COMMENT_NOT_FOUND) + ); + + Profile findProfile = profileRepository.findById(profileId).orElseThrow(() -> + new CustomResponseException(ResponseError.PROFILE_NOT_FOUND) + ); + + CommentLike commentLike = new CommentLike(findComment, findProfile); + commentLikeRepository.save(commentLike); + + return new LikeCommentResponseDTO(commentLike); + } + + public void unlikeComment(Long commentId, Long profileId) { + Optional findCommentLikeOptional = commentLikeRepository.findByComment_IdAndProfile_Id(commentId, profileId); + if(findCommentLikeOptional.isEmpty()) { + throw new CustomResponseException(ResponseError.NOT_YET_LIKED_COMMENT); + } + + CommentLike commentLike = findCommentLikeOptional.get(); + + commentLikeRepository.delete(commentLike); + } +} diff --git a/src/main/java/com/example/feeda/domain/comment/service/CommentService.java b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java new file mode 100644 index 0000000..765d8b5 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/comment/service/CommentService.java @@ -0,0 +1,87 @@ +package com.example.feeda.domain.comment.service; + +import com.example.feeda.domain.comment.dto.CommentResponse; +import com.example.feeda.domain.comment.dto.CreateCommentRequest; +import com.example.feeda.domain.comment.dto.UpdateCommentRequest; +import com.example.feeda.domain.comment.entity.Comment; +import com.example.feeda.domain.comment.repository.CommentRepository; +import com.example.feeda.domain.post.entity.Post; +import com.example.feeda.domain.post.repository.PostRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final ProfileRepository profileRepository; + + @Transactional + public CommentResponse createComment(Long postId, Long profileId, CreateCommentRequest request) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + Profile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + Comment comment = new Comment(post, profile, request.getContent()); + commentRepository.save(comment); + return CommentResponse.from(comment); + } + + public List getCommentsByPostId(Long postId, String sort) { + List comments; + + if (sort.equalsIgnoreCase("oldest")) { + comments = commentRepository.findByPostIdOrderByCreatedAtAsc(postId); + } else { + comments = commentRepository.findByPostIdOrderByCreatedAtDesc(postId); + } + + return comments.stream() + .map(CommentResponse::from) + .toList(); + } + + public CommentResponse getCommentById(Long commentId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); + return CommentResponse.from(comment); + } + + @Transactional + public CommentResponse updateComment(Long commentId, Long requesterProfileId, UpdateCommentRequest request) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); + + if (!comment.getProfile().getId().equals(requesterProfileId)) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); + } + + comment.updateContent(request.getContent()); + return CommentResponse.from(comment); + } + + @Transactional + public void deleteComment(Long commentId, Long requesterProfileId) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CustomResponseException(ResponseError.COMMENT_NOT_FOUND)); + + Long authorId = comment.getProfile().getId(); + Long postOwnerId = comment.getPost().getProfile().getId(); + + if (!authorId.equals(requesterProfileId) && !postOwnerId.equals(requesterProfileId)) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); + } + + commentRepository.delete(comment); + } +} diff --git a/src/main/java/com/example/feeda/domain/follow/.gitkeep b/src/main/java/com/example/feeda/domain/follow/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/feeda/domain/follow/controller/FollowsController.java b/src/main/java/com/example/feeda/domain/follow/controller/FollowsController.java index 82f3bff..f3a04c8 100644 --- a/src/main/java/com/example/feeda/domain/follow/controller/FollowsController.java +++ b/src/main/java/com/example/feeda/domain/follow/controller/FollowsController.java @@ -1,19 +1,26 @@ package com.example.feeda.domain.follow.controller; import com.example.feeda.domain.follow.dto.FollowsResponseDto; -import com.example.feeda.domain.follow.dto.ProfilesResponseDto; import com.example.feeda.domain.follow.service.FollowsService; -import java.util.List; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.security.jwt.JwtPayload; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort.Direction; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Validated @RestController @RequiredArgsConstructor @RequestMapping("/api/follows") @@ -23,41 +30,65 @@ public class FollowsController { @PostMapping("/{profileId}") public FollowsResponseDto follow(@PathVariable Long profileId, - Authentication auth) { + @AuthenticationPrincipal JwtPayload jwtPayload) { - return followsService.follow(auth, profileId); + return followsService.follow(jwtPayload, profileId); } @DeleteMapping("/{followingId}") public ResponseEntity unfollow(@PathVariable Long followingId, - Authentication auth) { + @AuthenticationPrincipal JwtPayload jwtPayload) { - followsService.unfollow(auth, followingId); + followsService.unfollow(jwtPayload, followingId); return ResponseEntity.ok().build(); } @GetMapping("/{profileId}/followings") - public List getFollowings(@PathVariable Long profileId) { + public ProfileListResponseDto getFollowings(@PathVariable Long profileId, + @RequestParam(defaultValue = "1") @Min(1) Integer page, + @RequestParam(defaultValue = "10") @Min(5) Integer size, + @AuthenticationPrincipal JwtPayload jwtPayload) { - return followsService.findFollowings(profileId); + Pageable pageable = PageRequest + .of(page - 1, size, Direction.DESC, "createdAt"); + + return followsService.findFollowingsPage(profileId, jwtPayload, pageable); } @GetMapping("/{profileId}/followers") - public List getFollowers(@PathVariable Long profileId) { + public ProfileListResponseDto getFollowers(@PathVariable Long profileId, + @RequestParam(defaultValue = "1") @Min(1) Integer page, + @RequestParam(defaultValue = "10") @Min(5) Integer size, + @AuthenticationPrincipal JwtPayload jwtPayload) { + + Pageable pageable = PageRequest + .of(page - 1, size, Direction.DESC, "createdAt"); - return followsService.findFollowers(profileId); + return followsService.findFollowersPage(profileId, jwtPayload, pageable); } @GetMapping("/followings") - public List getMyFollowings(Authentication auth) { + public ProfileListResponseDto getMyFollowings( + @AuthenticationPrincipal JwtPayload jwtPayload, + @RequestParam(defaultValue = "1") @Min(1) Integer page, + @RequestParam(defaultValue = "10") @Min(5) Integer size) { - return followsService.findFollowings(Long.parseLong(auth.getName())); + Pageable pageable = PageRequest + .of(page - 1, size, Direction.DESC, "createdAt"); + + return followsService.findFollowingsPage(jwtPayload.getProfileId(), jwtPayload, pageable); } @GetMapping("/followers") - public List getMyFollowers(Authentication auth) { + public ProfileListResponseDto getMyFollowers( + @AuthenticationPrincipal JwtPayload jwtPayload, + @RequestParam(defaultValue = "1") @Min(1) Integer page, + @RequestParam(defaultValue = "10") @Min(5) Integer size) { + + Pageable pageable = PageRequest + .of(page - 1, size, Direction.DESC, "createdAt"); - return followsService.findFollowers(Long.parseLong(auth.getName())); + return followsService.findFollowersPage(jwtPayload.getProfileId(), jwtPayload, pageable); } } diff --git a/src/main/java/com/example/feeda/domain/follow/dto/ProfilesResponseDto.java b/src/main/java/com/example/feeda/domain/follow/dto/ProfilesResponseDto.java deleted file mode 100644 index 4b16f9e..0000000 --- a/src/main/java/com/example/feeda/domain/follow/dto/ProfilesResponseDto.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.feeda.domain.follow.dto; - -import com.example.feeda.domain.follow.entity.Profiles; -import java.time.LocalDateTime; -import lombok.Getter; - -/** - * 프로필 응답 Response Dto. 추후 프로필 개발 병합 이후 삭제 예정. - */ - -@Getter -public class ProfilesResponseDto { - - private Long id; - private String nickname; - private String birth; - private String bio; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; - - public static ProfilesResponseDto of(Profiles profiles) { - final ProfilesResponseDto res = new ProfilesResponseDto(); - - res.id = profiles.getId(); - res.nickname = profiles.getNickname(); - res.birth = profiles.getBirth(); - res.bio = profiles.getBio(); - res.createdAt = profiles.getCreatedAt(); - res.updatedAt = profiles.getUpdatedAt(); - - return res; - } -} diff --git a/src/main/java/com/example/feeda/domain/follow/entity/Follows.java b/src/main/java/com/example/feeda/domain/follow/entity/Follows.java index b4fccd1..f5676c2 100644 --- a/src/main/java/com/example/feeda/domain/follow/entity/Follows.java +++ b/src/main/java/com/example/feeda/domain/follow/entity/Follows.java @@ -1,7 +1,9 @@ package com.example.feeda.domain.follow.entity; +import com.example.feeda.domain.profile.entity.Profile; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -16,11 +18,13 @@ import lombok.Getter; import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; @Entity @Getter @Builder @AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "follows") public class Follows { @@ -31,11 +35,11 @@ public class Follows { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "follower_id", nullable = false) - private Profiles followers; + private Profile followers; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "following_id", nullable = false) - private Profiles followings; + private Profile followings; @CreatedDate @Column(updatable = false, name = "created_at") diff --git a/src/main/java/com/example/feeda/domain/follow/entity/Profiles.java b/src/main/java/com/example/feeda/domain/follow/entity/Profiles.java deleted file mode 100644 index 942749d..0000000 --- a/src/main/java/com/example/feeda/domain/follow/entity/Profiles.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.example.feeda.domain.follow.entity; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import lombok.Getter; - -/** - * 프로필 Entity. 추후 프로필 개발 병합 이후 삭제 예정. - */ - -@Entity -@Getter -@Table(name = "profiles") -public class Profiles { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - /* - @OneToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "account_id", nullable = false) - private Accounts accounts; - */ - - @JsonIgnore - @OneToMany(mappedBy = "followers", cascade = CascadeType.ALL, orphanRemoval = true) - private List followers = new ArrayList<>(); - - @JsonIgnore - @OneToMany(mappedBy = "followings", cascade = CascadeType.ALL, orphanRemoval = true) - private List followings = new ArrayList<>(); - - @Column(nullable = false) - private String nickname; - - @Column(nullable = false) - private String birth; - - @Column(nullable = false) - private String bio; - - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/example/feeda/domain/follow/repository/FollowsRepository.java b/src/main/java/com/example/feeda/domain/follow/repository/FollowsRepository.java index 00fdcff..cc9f6f0 100644 --- a/src/main/java/com/example/feeda/domain/follow/repository/FollowsRepository.java +++ b/src/main/java/com/example/feeda/domain/follow/repository/FollowsRepository.java @@ -1,18 +1,25 @@ package com.example.feeda.domain.follow.repository; import com.example.feeda.domain.follow.entity.Follows; -import com.example.feeda.domain.follow.entity.Profiles; -import java.util.List; +import com.example.feeda.domain.profile.entity.Profile; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository public interface FollowsRepository extends JpaRepository { - Optional findByFollowersAndFollowings(Profiles followers, Profiles followings); + Optional findByFollowersAndFollowings(Profile followers, Profile followings); - List findAllByFollowers_Id(Long followerId); + Page findAllByFollowings_Id(Long followingsId, Pageable pageable); - List findAllByFollowings_Id(Long followingId); + Page findAllByFollowers_Id(Long followersId, Pageable pageable); + + // 팔로워 목록 + Long countByFollowings_Id(Long followingsId); + + // 팔로잉 목록 + Long countByFollowers_Id(Long followersId); } diff --git a/src/main/java/com/example/feeda/domain/follow/repository/ProfilesRepository.java b/src/main/java/com/example/feeda/domain/follow/repository/ProfilesRepository.java deleted file mode 100644 index c366a35..0000000 --- a/src/main/java/com/example/feeda/domain/follow/repository/ProfilesRepository.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.example.feeda.domain.follow.repository; - -import com.example.feeda.domain.follow.entity.Profiles; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -@Repository -public interface ProfilesRepository extends JpaRepository { - -} diff --git a/src/main/java/com/example/feeda/domain/follow/service/FollowsService.java b/src/main/java/com/example/feeda/domain/follow/service/FollowsService.java index eb925c9..4148c92 100644 --- a/src/main/java/com/example/feeda/domain/follow/service/FollowsService.java +++ b/src/main/java/com/example/feeda/domain/follow/service/FollowsService.java @@ -1,17 +1,19 @@ package com.example.feeda.domain.follow.service; import com.example.feeda.domain.follow.dto.FollowsResponseDto; -import com.example.feeda.domain.follow.dto.ProfilesResponseDto; -import java.util.List; -import org.springframework.security.core.Authentication; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.security.jwt.JwtPayload; +import org.springframework.data.domain.Pageable; public interface FollowsService { - FollowsResponseDto follow(Authentication auth, Long profileId); + FollowsResponseDto follow(JwtPayload jwtPayload, Long profileId); - void unfollow(Authentication auth, Long followingId); + void unfollow(JwtPayload jwtPayload, Long followingId); - List findFollowings(Long profileId); + ProfileListResponseDto findFollowingsPage(Long profileId, JwtPayload jwtPayload, + Pageable pageable); - List findFollowers(Long profileId); + ProfileListResponseDto findFollowersPage(Long profileId, JwtPayload jwtPayload, + Pageable pageable); } diff --git a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java index 0cf9435..ea37010 100644 --- a/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java +++ b/src/main/java/com/example/feeda/domain/follow/service/FollowsServiceImpl.java @@ -1,16 +1,21 @@ package com.example.feeda.domain.follow.service; import com.example.feeda.domain.follow.dto.FollowsResponseDto; -import com.example.feeda.domain.follow.dto.ProfilesResponseDto; import com.example.feeda.domain.follow.entity.Follows; -import com.example.feeda.domain.follow.entity.Profiles; import com.example.feeda.domain.follow.repository.FollowsRepository; -import com.example.feeda.domain.follow.repository.ProfilesRepository; +import com.example.feeda.domain.profile.dto.GetProfileResponseDto; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import com.example.feeda.security.jwt.JwtPayload; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.Authentication; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,100 +25,119 @@ public class FollowsServiceImpl implements FollowsService { private final FollowsRepository followsRepository; - private final ProfilesRepository profilesRepository; + private final ProfileRepository profileRepository; @Override @Transactional - public FollowsResponseDto follow(Authentication auth, Long profileId) { + public FollowsResponseDto follow(JwtPayload jwtPayload, Long profileId) { - Long myProfileId = Long.parseLong(auth.getName()); + Profile myProfile = getProfileOrThrow(jwtPayload.getProfileId()); + validateNotSelf(myProfile, profileId); + Profile followingProfile = getProfileOrThrow(profileId); - Optional myProfile = - profilesRepository.findById(myProfileId); - if (myProfile.isEmpty()) { - log.warn("존재하지 않는 유저 정보입니다."); - return null; + Optional follow = + followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); + if (follow.isPresent()) { + throw new CustomResponseException(ResponseError.ALREADY_FOLLOWED); } - Optional followingProfile = profilesRepository.findById(profileId); - if (followingProfile.isEmpty()) { - log.warn("존재하지 않는 유저 정보입니다."); - return null; - } - - if (myProfileId.equals(profileId)) { - log.warn("본인 계정은 팔로우/언팔로우 할 수 없습니다."); - return null; - } - - Follows follows = Follows.builder() - .followers(myProfile.get()) - .followings(followingProfile.get()) + Follows newFollow = Follows.builder() + .followers(myProfile) + .followings(followingProfile) .build(); - followsRepository.save(follows); + followsRepository.save(newFollow); - return FollowsResponseDto.of(follows); + return FollowsResponseDto.of(newFollow); } - @Override @Transactional - public void unfollow(Authentication auth, Long followingId) { - - Long myProfileId = Long.parseLong(auth.getName()); + public void unfollow(JwtPayload jwtPayload, Long followingId) { - Optional myProfile = - profilesRepository.findById(myProfileId); - if (myProfile.isEmpty()) { - log.warn("존재하지 않는 유저 정보입니다."); - return; - } - - Optional followingProfile = profilesRepository.findById(followingId); - if (followingProfile.isEmpty()) { - log.warn("존재하지 않는 유저 정보입니다."); - return; - } - - if (myProfileId.equals(followingId)) { - log.warn("본인 계정은 팔로우/언팔로우 할 수 없습니다."); - return; - } + Profile myProfile = getProfileOrThrow(jwtPayload.getProfileId()); + Profile followingProfile = getProfileOrThrow(followingId); + validateNotSelf(myProfile, followingId); Optional follows = - followsRepository.findByFollowersAndFollowings(myProfile.get(), followingProfile.get()); + followsRepository.findByFollowersAndFollowings(myProfile, followingProfile); if (follows.isEmpty()) { - log.warn("존재하지 않는 유저 정보입니다."); - return; + throw new CustomResponseException(ResponseError.FOLLOW_NOT_FOUND); } followsRepository.delete(follows.get()); } @Override - public List findFollowings(Long profileId) { - - List followings = followsRepository.findAllByFollowers_Id(profileId); - List profiles = followings.stream() - .map(Follows::getFollowings) + public ProfileListResponseDto findFollowingsPage( + Long profileId, + JwtPayload jwtPayload, + Pageable pageable) { + + Page profiles = followsRepository.findAllByFollowers_Id(profileId, pageable).map( + Follows::getFollowings); + + List responseDtoList = profiles.stream() + .map(profile -> GetProfileResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + profile.getCreatedAt(), + profile.getUpdatedAt() + )) .toList(); - return profiles.stream() - .map(ProfilesResponseDto::of) - .toList(); + return ProfileListResponseDto.of( + responseDtoList, + profiles.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 + profiles.getTotalPages(), + profiles.getTotalElements() + ); } @Override - public List findFollowers(Long profileId) { - - List followers = followsRepository.findAllByFollowings_Id(profileId); - List profiles = followers.stream() - .map(Follows::getFollowers) + public ProfileListResponseDto findFollowersPage( + Long profileId, + JwtPayload jwtPayload, + Pageable pageable) { + + Page profiles = followsRepository.findAllByFollowings_Id(profileId, pageable) + .map(Follows::getFollowers); + + List responseDtoList = profiles.stream() + .map(profile -> GetProfileResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + profile.getCreatedAt(), + profile.getUpdatedAt() + )) .toList(); - return profiles.stream() - .map(ProfilesResponseDto::of) - .toList(); + return ProfileListResponseDto.of( + responseDtoList, + profiles.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 + profiles.getTotalPages(), + profiles.getTotalElements() + ); + } + + private Profile getProfileOrThrow(Long profileId) { + Optional optionalProfile = + profileRepository.findById(profileId); + + if (optionalProfile.isEmpty()) { + throw new CustomResponseException(ResponseError.PROFILE_NOT_FOUND); + } + + return optionalProfile.get(); + } + + private void validateNotSelf(Profile me, Long profileId) { + if (me.getId().equals(profileId)) { + throw new CustomResponseException(ResponseError.CANNOT_FOLLOW_SELF); + } } } diff --git a/src/main/java/com/example/feeda/domain/post/.gitkeep b/src/main/java/com/example/feeda/domain/post/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/example/feeda/domain/post/controller/PostController.java b/src/main/java/com/example/feeda/domain/post/controller/PostController.java new file mode 100644 index 0000000..9182ae4 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/controller/PostController.java @@ -0,0 +1,117 @@ +package com.example.feeda.domain.post.controller; + +import com.example.feeda.domain.post.dto.PostLikeResponseDTO; +import com.example.feeda.domain.post.dto.PostRequestDto; +import com.example.feeda.domain.post.dto.PostResponseDto; +import com.example.feeda.domain.post.service.PostService; +import com.example.feeda.security.jwt.JwtPayload; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + + +@RestController +@Validated +@RequestMapping("/api/posts") +@RequiredArgsConstructor +public class PostController { + + private final PostService postService; + + @PostMapping + public ResponseEntity createPost(@RequestBody @Valid PostRequestDto requestDto, + @AuthenticationPrincipal JwtPayload jwtPayload) { + + PostResponseDto post = postService.createPost(requestDto, jwtPayload); + + return new ResponseEntity<>(post, HttpStatus.CREATED); + } + + @PostMapping("/{id}/likes") + public ResponseEntity makeLikes( + @PathVariable Long id, + @AuthenticationPrincipal JwtPayload jwtPayload) { + + return new ResponseEntity<>(postService.makeLikes(id, jwtPayload), HttpStatus.OK); + } + + @DeleteMapping("/{id}/likes") + public ResponseEntity deleteLikes( + @PathVariable Long id, + @AuthenticationPrincipal JwtPayload jwtPayload + ) { + Long profileId = jwtPayload.getProfileId(); + postService.deleteLikes(id, profileId); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/{id}") + public ResponseEntity findPostById(@PathVariable @NotNull Long id) { + return new ResponseEntity<>(postService.findPostById(id), HttpStatus.OK); + } + + @GetMapping + public ResponseEntity> findAllPost( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) int size, + @RequestParam(defaultValue = "") String keyword, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate startUpdatedAt, + @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate endUpdatedAt + ) { + + Pageable pageable = PageRequest.of(page - 1, size, Sort.Direction.DESC, "updatedAt"); + + return new ResponseEntity<>( + postService.findAll(pageable, keyword, startUpdatedAt, endUpdatedAt), HttpStatus.OK); + } + + @GetMapping("/followings") + public ResponseEntity> findFollowingAllPost( + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "10") @Min(1) int size, + @AuthenticationPrincipal JwtPayload jwtPayload + ) { + Pageable pageable = PageRequest.of(page - 1, size); + + return new ResponseEntity<>(postService.findFollowingAllPost(pageable, jwtPayload), + HttpStatus.OK); + } + + @PutMapping("/{id}") + public ResponseEntity updatePost( + @PathVariable @NotNull Long id, + @RequestBody PostRequestDto requestDto, @AuthenticationPrincipal JwtPayload jwtPayload) { + + PostResponseDto post = postService.updatePost(id, requestDto, jwtPayload); + return new ResponseEntity<>(post, HttpStatus.OK); + } + + @DeleteMapping("/{id}") + public ResponseEntity deletePost(@PathVariable @NotNull Long id, + @AuthenticationPrincipal JwtPayload jwtPayload) { + + postService.deletePost(id, jwtPayload); + return new ResponseEntity<>(HttpStatus.OK); + } +} diff --git a/src/main/java/com/example/feeda/domain/post/dto/PostLikeResponseDTO.java b/src/main/java/com/example/feeda/domain/post/dto/PostLikeResponseDTO.java new file mode 100644 index 0000000..adba5b0 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/dto/PostLikeResponseDTO.java @@ -0,0 +1,22 @@ +package com.example.feeda.domain.post.dto; + +import com.example.feeda.domain.post.entity.PostLike; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class PostLikeResponseDTO { + + private final Long id; + private final Long postId; + private final Long profileId; + private final LocalDateTime createdAt; + + public PostLikeResponseDTO(PostLike postLike) { + this.id = postLike.getId(); + this.postId = postLike.getPost().getId(); + this.profileId = postLike.getProfile().getId(); + this.createdAt = postLike.getCreatedAt(); + } +} diff --git a/src/main/java/com/example/feeda/domain/post/dto/PostRequestDto.java b/src/main/java/com/example/feeda/domain/post/dto/PostRequestDto.java new file mode 100644 index 0000000..17824e0 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/dto/PostRequestDto.java @@ -0,0 +1,25 @@ +package com.example.feeda.domain.post.dto; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class PostRequestDto { + + @NotNull(message = "제목은 필수 항목입니다.") + private final String title; + + @NotNull(message = "내용은 필수 항목입니다.") + private final String content; + + @Size(max = 50, message = "카테고리는 50자 이하로 입력하세요.") + private final String category; + + public PostRequestDto(String title, String content, String category) { + this.title = title; + this.content = content; + this.category = category; + } + +} diff --git a/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java new file mode 100644 index 0000000..fdcfa93 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/dto/PostResponseDto.java @@ -0,0 +1,37 @@ +package com.example.feeda.domain.post.dto; + +import com.example.feeda.domain.post.entity.Post; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class PostResponseDto { + + private final Long id; + + private final String title; + + private final String content; + + private final String category; + + private final Long likes; + + private final Long comments; + + private final LocalDateTime createdAt; + + private final LocalDateTime updatedAt; + + public PostResponseDto(Post post, Long likes, Long comments) { + this.id = post.getId(); + this.title = post.getTitle(); + this.content = post.getContent(); + this.category = post.getCategory(); + this.likes = likes; + this.comments = comments; + this.createdAt = post.getCreatedAt(); + this.updatedAt = post.getUpdatedAt(); + } +} diff --git a/src/main/java/com/example/feeda/domain/post/entity/BaseEntity.java b/src/main/java/com/example/feeda/domain/post/entity/BaseEntity.java new file mode 100644 index 0000000..d787554 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/entity/BaseEntity.java @@ -0,0 +1,25 @@ +package com.example.feeda.domain.post.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/example/feeda/domain/post/entity/Post.java b/src/main/java/com/example/feeda/domain/post/entity/Post.java new file mode 100644 index 0000000..71592e2 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/entity/Post.java @@ -0,0 +1,53 @@ +package com.example.feeda.domain.post.entity; + +import com.example.feeda.domain.profile.entity.Profile; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; + + +@Getter +@Entity +@Table(name = "posts") +public class Post extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 100, nullable = false) + private String title; + + @Column(columnDefinition = "longtext", nullable = false) + private String content; + + @Column(length = 50) + private String category; + + @ManyToOne + @JoinColumn(name = "profile_id") + private Profile profile; + + public Post(String title, String content, String category, Profile profile) { + this.title = title; + this.content = content; + this.category = category; + this.profile = profile; + } + + protected Post() { + super(); + } + + public void update(String title, String content, String category) { + this.title = title; + this.content = content; + this.category = category; + } +} diff --git a/src/main/java/com/example/feeda/domain/post/entity/PostLike.java b/src/main/java/com/example/feeda/domain/post/entity/PostLike.java new file mode 100644 index 0000000..b708d80 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/entity/PostLike.java @@ -0,0 +1,40 @@ +package com.example.feeda.domain.post.entity; + +import com.example.feeda.domain.profile.entity.Profile; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_likes") +@Getter +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +public class PostLike { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne + @JoinColumn(name = "profile_id", nullable = false) + private Profile profile; + + @CreatedDate + @Column(updatable = false, name = "created_at") + private LocalDateTime createdAt; + + public PostLike(Post post, Profile profile) { + this.post = post; + this.profile = profile; + } +} diff --git a/src/main/java/com/example/feeda/domain/post/repository/PostLikeRepository.java b/src/main/java/com/example/feeda/domain/post/repository/PostLikeRepository.java new file mode 100644 index 0000000..799142e --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/repository/PostLikeRepository.java @@ -0,0 +1,13 @@ +package com.example.feeda.domain.post.repository; + +import com.example.feeda.domain.post.entity.Post; +import com.example.feeda.domain.post.entity.PostLike; +import com.example.feeda.domain.profile.entity.Profile; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PostLikeRepository extends JpaRepository { + Optional findByPostAndProfile(Post post, Profile profile); + long countByPost(Post post); // PostLike 갯수 +} diff --git a/src/main/java/com/example/feeda/domain/post/repository/PostRepository.java b/src/main/java/com/example/feeda/domain/post/repository/PostRepository.java new file mode 100644 index 0000000..ff944dc --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/repository/PostRepository.java @@ -0,0 +1,19 @@ +package com.example.feeda.domain.post.repository; + +import com.example.feeda.domain.post.entity.Post; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { + + Page findAllByTitleContaining(String title, Pageable pageable); + + Page findAllByProfile_IdIn(List followingProfileIds, Pageable pageable); + + Page findAllByTitleContainingAndUpdatedAtBetween(String title, + LocalDateTime startUpdatedAt, LocalDateTime endUpdatedAt, + Pageable pageable); +} diff --git a/src/main/java/com/example/feeda/domain/post/service/PostService.java b/src/main/java/com/example/feeda/domain/post/service/PostService.java new file mode 100644 index 0000000..463d697 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/service/PostService.java @@ -0,0 +1,31 @@ +package com.example.feeda.domain.post.service; + +import com.example.feeda.domain.post.dto.PostLikeResponseDTO; +import com.example.feeda.domain.post.dto.PostRequestDto; +import com.example.feeda.domain.post.dto.PostResponseDto; +import com.example.feeda.security.jwt.JwtPayload; +import java.time.LocalDate; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface PostService { + + PostResponseDto createPost(PostRequestDto postRequestDto, + JwtPayload jwtPayload); + + PostResponseDto findPostById(Long id); + + Page findAll(Pageable pageable, String keyword, LocalDate startUpdatedAt, + LocalDate endUpdatedAt); + + Page findFollowingAllPost(Pageable pageable, JwtPayload jwtPayload); + + PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload jwtPayload); + + void deletePost(Long id, JwtPayload jwtPayload); + + PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload); + + void deleteLikes(Long id, Long profileId); + +} \ No newline at end of file diff --git a/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java new file mode 100644 index 0000000..f30cf02 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/post/service/PostServiceImpl.java @@ -0,0 +1,184 @@ +package com.example.feeda.domain.post.service; + +import com.example.feeda.domain.comment.repository.CommentRepository; +import com.example.feeda.domain.follow.entity.Follows; +import com.example.feeda.domain.follow.repository.FollowsRepository; +import com.example.feeda.domain.post.dto.PostLikeResponseDTO; +import com.example.feeda.domain.post.dto.PostRequestDto; +import com.example.feeda.domain.post.dto.PostResponseDto; +import com.example.feeda.domain.post.entity.Post; +import com.example.feeda.domain.post.entity.PostLike; +import com.example.feeda.domain.post.repository.PostLikeRepository; +import com.example.feeda.domain.post.repository.PostRepository; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import com.example.feeda.security.jwt.JwtPayload; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PostServiceImpl implements PostService { + + private final PostRepository postRepository; + private final ProfileRepository profileRepository; // 현재 로그인 사용자 정보를 찾기 위해 필요 + private final FollowsRepository followsRepository; + private final PostLikeRepository postLikeRepository; + private final CommentRepository commentRepository; + + @Override + public PostResponseDto createPost(PostRequestDto postRequestDto, JwtPayload jwtPayload) { + + Profile profile = profileRepository.findById(jwtPayload.getProfileId()) + .orElseThrow( + () -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + Post post = new Post(postRequestDto.getTitle(), postRequestDto.getContent(), + postRequestDto.getCategory(), profile); + + Post savedPost = postRepository.save(post); + + return new PostResponseDto(savedPost, 0L, 0L); + } + + @Override + @Transactional + public PostLikeResponseDTO makeLikes(Long id, JwtPayload jwtPayload) { + Post post = postRepository.findById(id).orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + Profile profile = profileRepository.findById(jwtPayload.getProfileId()).orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + // 중복 좋아요 방지 + postLikeRepository.findByPostAndProfile(post, profile).ifPresent(like -> { + throw new CustomResponseException(ResponseError.ALREADY_LIKED_POST); + }); + + PostLike savePost = postLikeRepository.save(new PostLike(post, profile)); + + return new PostLikeResponseDTO(savePost); + } + + @Override + public void deleteLikes(Long id, Long profileId) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + + Profile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + PostLike postLike = postLikeRepository.findByPostAndProfile(post, profile) + .orElseThrow(() -> new CustomResponseException(ResponseError.NOT_YET_LIKED_POST)); + + postLikeRepository.delete(postLike); + } + + @Override + @Transactional(readOnly = true) + public PostResponseDto findPostById(Long id) { + Optional optionalPost = postRepository.findById(id); + + if (optionalPost.isEmpty()) { + throw new CustomResponseException(ResponseError.POST_NOT_FOUND); + } + + Post findPost = optionalPost.get(); + + Long likeCount = postLikeRepository.countByPost(findPost); + Long commentCount = commentRepository.countByPost(findPost); + + return new PostResponseDto(findPost, likeCount, commentCount); + } + + @Override + @Transactional(readOnly = true) + public Page findAll(Pageable pageable, String keyword, + LocalDate startUpdatedAt, LocalDate endUpdatedAt) { + + if ((startUpdatedAt == null && endUpdatedAt != null) || (startUpdatedAt != null + && endUpdatedAt == null)) { + throw new CustomResponseException(ResponseError.INVALID_DATE_PARAMETERS); + } + + if (startUpdatedAt != null) { + return postRepository.findAllByTitleContainingAndUpdatedAtBetween( + keyword, startUpdatedAt.atStartOfDay(), endUpdatedAt.atTime(23, 59, 59), pageable) + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); + } + + return postRepository.findAllByTitleContaining(keyword, pageable) + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); + } + + @Override + @Transactional(readOnly = true) + public Page findFollowingAllPost(Pageable pageable, JwtPayload jwtPayload) { + + Page followings = followsRepository.findAllByFollowers_Id( + jwtPayload.getProfileId(), pageable); + + List followingProfileIds = followings.stream() + .map(following -> following.getFollowings().getId()) + .toList(); + + Pageable newPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.Direction.DESC, "updatedAt"); + + return postRepository.findAllByProfile_IdIn(followingProfileIds, newPageable) + .map(post -> new PostResponseDto( + post, + postLikeRepository.countByPost(post), + commentRepository.countByPost(post) + )); + } + + @Override + @Transactional + public PostResponseDto updatePost(Long id, PostRequestDto requestDto, JwtPayload jwtPayload) { + + Post findPost = postRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + + if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); + } + + findPost.update(requestDto.getTitle(), requestDto.getContent(), requestDto.getCategory()); + Post savedPost = postRepository.save(findPost); + + Long likeCount = postLikeRepository.countByPost(findPost); + Long commentCount = commentRepository.countByPost(findPost); + + return new PostResponseDto(savedPost, likeCount, commentCount); + } + + @Override + @Transactional + public void deletePost(Long id, JwtPayload jwtPayload) { + + Post findPost = postRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.POST_NOT_FOUND)); + + if (!findPost.getProfile().getId().equals(jwtPayload.getProfileId())) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_DELETE); + } + + postRepository.delete(findPost); + } +} diff --git a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java index 758ba6e..d67d55e 100644 --- a/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java +++ b/src/main/java/com/example/feeda/domain/profile/controller/ProfileController.java @@ -1,10 +1,7 @@ package com.example.feeda.domain.profile.controller; -import com.example.feeda.domain.profile.dto.GetProfileResponseDto; -import com.example.feeda.domain.profile.dto.ProfileListResponseDto; -import com.example.feeda.domain.profile.dto.UpdateProfileRequestDto; -import com.example.feeda.domain.profile.dto.UpdateProfileResponseDto; -import com.example.feeda.domain.profile.service.ProfileService; +import com.example.feeda.domain.profile.dto.*; +import com.example.feeda.domain.profile.service.ProfileServiceImpl; import com.example.feeda.security.jwt.JwtPayload; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; @@ -16,9 +13,9 @@ @RequestMapping("/api") public class ProfileController { - private final ProfileService profileService; + private final ProfileServiceImpl profileService; - public ProfileController(ProfileService profileService) { + public ProfileController(ProfileServiceImpl profileService) { this.profileService = profileService; } @@ -27,8 +24,8 @@ public ProfileController(ProfileService profileService) { */ @GetMapping("/profiles/{id}") - public ResponseEntity getProfile(@PathVariable Long id) { - GetProfileResponseDto responseDto = profileService.getProfile(id); + public ResponseEntity getProfile(@PathVariable Long id) { + GetProfileWithFollowCountResponseDto responseDto = profileService.getProfile(id); return new ResponseEntity<>(responseDto, HttpStatus.OK); } @@ -39,7 +36,7 @@ public ResponseEntity getProfile(@PathVariable Long id) { @GetMapping("/profiles") public ResponseEntity getProfiles( @RequestParam(required = false) String keyword, - @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size) { ProfileListResponseDto response = profileService.getProfiles(keyword, page, size); @@ -56,8 +53,8 @@ public ResponseEntity updateProfile( @PathVariable Long id, @Valid @RequestBody UpdateProfileRequestDto requestDto) { - Long userId = jwtPayload.getUserId(); - UpdateProfileResponseDto responseDto = profileService.updateProfile(userId, id, requestDto); + Long accountId = jwtPayload.getAccountId(); + UpdateProfileResponseDto responseDto = profileService.updateProfile(accountId, id, requestDto); return new ResponseEntity<>(responseDto, HttpStatus.OK); } diff --git a/src/main/java/com/example/feeda/domain/profile/dto/GetProfileWithFollowCountResponseDto.java b/src/main/java/com/example/feeda/domain/profile/dto/GetProfileWithFollowCountResponseDto.java new file mode 100644 index 0000000..c3f9228 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/profile/dto/GetProfileWithFollowCountResponseDto.java @@ -0,0 +1,38 @@ +package com.example.feeda.domain.profile.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Date; + + +@AllArgsConstructor(staticName = "of") +@Getter +public class GetProfileWithFollowCountResponseDto { + + // 계정 ID + private final Long id; + + // 닉네임 + private final String nickname; + + // 생일 + private final Date birth; + + // 자기소개 + private final String bio; + + // 팔로워 수 + private final Long followerCount; + + // 팔로잉 수 + private final Long followingCount; + + //생성 시간 + private final LocalDateTime createdAt; + + //마지막 수정 시간 + private final LocalDateTime updatedAt; +} + diff --git a/src/main/java/com/example/feeda/domain/profile/entity/Profile.java b/src/main/java/com/example/feeda/domain/profile/entity/Profile.java index 0efc944..6456941 100644 --- a/src/main/java/com/example/feeda/domain/profile/entity/Profile.java +++ b/src/main/java/com/example/feeda/domain/profile/entity/Profile.java @@ -2,9 +2,7 @@ import com.example.feeda.domain.account.entity.Account; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.time.LocalDateTime; import java.util.Date; @@ -13,6 +11,7 @@ @Table(name = "profiles") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor public class Profile extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,13 +24,20 @@ public class Profile extends BaseEntity { private String bio; + @Setter @OneToOne - @JoinColumn(name = "account_id",unique = true) + @JoinColumn(name = "account_id", unique = true) private Account account; //프로필 생성해줄때 사용 public static Profile create(String nickname, Date birth, String bio, Account account) { - return new Profile(null, nickname, birth, bio, null, null,account); + return new Profile(null, nickname, birth, bio, null, null, account); + } + + public Profile(String nickname, Date birth, String bio) { + this.nickname = nickname; + this.birth = birth; + this.bio = bio; } public Profile(Long id, String nickname, Date birth, String bio, LocalDateTime createdAt, LocalDateTime updatedAt, Account account) { @@ -41,18 +47,11 @@ public Profile(Long id, String nickname, Date birth, String bio, LocalDateTime c this.birth = birth; this.bio = bio; this.account = account; - } public void updateProfile(String nickname, Date birth, String bio) { - if (nickname != null) { this.nickname = nickname; - } - if (birth != null) { this.birth = birth; - } - if (bio != null) { this.bio = bio; } } -} diff --git a/src/main/java/com/example/feeda/domain/profile/repository/ProfileRepository.java b/src/main/java/com/example/feeda/domain/profile/repository/ProfileRepository.java index d1da3e4..1ae5472 100644 --- a/src/main/java/com/example/feeda/domain/profile/repository/ProfileRepository.java +++ b/src/main/java/com/example/feeda/domain/profile/repository/ProfileRepository.java @@ -5,6 +5,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface ProfileRepository extends JpaRepository { /** @@ -13,4 +15,5 @@ public interface ProfileRepository extends JpaRepository { Page findByNicknameContaining(String nickname, Pageable pageable); + Optional findByNickname(String nickname); } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java index 980367b..098637e 100644 --- a/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileService.java @@ -1,117 +1,14 @@ package com.example.feeda.domain.profile.service; -import com.example.feeda.domain.profile.dto.GetProfileResponseDto; +import com.example.feeda.domain.profile.dto.GetProfileWithFollowCountResponseDto; import com.example.feeda.domain.profile.dto.ProfileListResponseDto; import com.example.feeda.domain.profile.dto.UpdateProfileRequestDto; import com.example.feeda.domain.profile.dto.UpdateProfileResponseDto; -import com.example.feeda.domain.profile.entity.Profile; -import com.example.feeda.domain.profile.repository.ProfileRepository; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import java.util.List; +public interface ProfileService { + GetProfileWithFollowCountResponseDto getProfile(Long id); -@Service -public class ProfileService { + ProfileListResponseDto getProfiles(String keyword, int page, int size); - private final ProfileRepository profileRepository; - - public ProfileService(ProfileRepository profileRepository) { - this.profileRepository = profileRepository; - } - - /** - * 프로필 단건 조회 기능 - */ - - @Transactional(readOnly = true) - public GetProfileResponseDto getProfile(Long id) { - - Profile profile = profileRepository.findById(id) - .orElseThrow(() -> new ResponseStatusException( - HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다." - )); - - return GetProfileResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - profile.getCreatedAt(), - profile.getUpdatedAt() - ); - } - - /** - * 프로필 다건 조회 기능(검색,페이징) - */ - - @Transactional(readOnly = true) - public ProfileListResponseDto getProfiles(String keyword, int page, int size) { - - if (page < 1 || size < 1) { - throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, - "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다." - ); - } - - Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); - - Page profilePage; - if (keyword == null || keyword.trim().isEmpty()) { - profilePage = profileRepository.findAll(pageable); - } else { - profilePage = profileRepository.findByNicknameContaining(keyword, pageable); - } - - List responseDtoList = profilePage.stream() - .map(profile -> GetProfileResponseDto.of( - profile.getId(), - profile.getNickname(), - profile.getBirth(), - profile.getBio(), - profile.getCreatedAt(), - profile.getUpdatedAt() - )) - .toList(); - - return ProfileListResponseDto.of( - responseDtoList, - profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 - profilePage.getTotalPages(), - profilePage.getTotalElements() - ); - } - - - /** - * 프로필 수정 기능 - */ - @Transactional - public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { - - Profile profile = profileRepository.findById(profileId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 없음.")); - - if (!profile.getAccount().getId().equals(userId)) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."); - } - - profile.updateProfile( - requestDto.getNickname(), - requestDto.getBirth(), - requestDto.getBio() - ); - - profileRepository.save(profile); - - return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); - } + UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto); } diff --git a/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java new file mode 100644 index 0000000..0941ac1 --- /dev/null +++ b/src/main/java/com/example/feeda/domain/profile/service/ProfileServiceImpl.java @@ -0,0 +1,116 @@ +package com.example.feeda.domain.profile.service; + +import com.example.feeda.domain.follow.repository.FollowsRepository; +import com.example.feeda.domain.profile.dto.*; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.exception.CustomResponseException; +import com.example.feeda.exception.enums.ResponseError; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class ProfileServiceImpl implements ProfileService { + private final ProfileRepository profileRepository; + private final FollowsRepository followsRepository; + + /** + * 프로필 단건 조회 기능 + */ + @Override + @Transactional(readOnly = true) + public GetProfileWithFollowCountResponseDto getProfile(Long id) { + + Profile profile = profileRepository.findById(id) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + Long followerCount = followsRepository.countByFollowings_Id(id); + Long followingCount = followsRepository.countByFollowers_Id(id); + + return GetProfileWithFollowCountResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + followerCount, + followingCount, + profile.getCreatedAt(), + profile.getUpdatedAt() + ); + } + + /** + * 프로필 다건 조회 기능(검색,페이징) + */ + @Override + @Transactional(readOnly = true) + public ProfileListResponseDto getProfiles(String keyword, int page, int size) { + + if (page < 1 || size < 1) { + throw new CustomResponseException(ResponseError.INVALID_PAGINATION_PARAMETERS); + } + + Pageable pageable = PageRequest.of(page - 1, size, Sort.by("id").ascending()); + + Page profilePage; + if (keyword == null || keyword.trim().isEmpty()) { + profilePage = profileRepository.findAll(pageable); + } else { + profilePage = profileRepository.findByNicknameContaining(keyword, pageable); + } + + List responseDtoList = profilePage.stream() + .map(profile -> GetProfileResponseDto.of( + profile.getId(), + profile.getNickname(), + profile.getBirth(), + profile.getBio(), + profile.getCreatedAt(), + profile.getUpdatedAt() + )) + .toList(); + + return ProfileListResponseDto.of( + responseDtoList, + profilePage.getNumber() + 1, // 다시 1부터 시작하는 번호로 반환 + profilePage.getTotalPages(), + profilePage.getTotalElements() + ); + } + + + /** + * 프로필 수정 기능 + */ + @Override + @Transactional + public UpdateProfileResponseDto updateProfile(Long userId, Long profileId, UpdateProfileRequestDto requestDto) { + + Profile profile = profileRepository.findById(profileId) + .orElseThrow(() -> new CustomResponseException(ResponseError.PROFILE_NOT_FOUND)); + + if (!profile.getAccount().getId().equals(userId)) { + throw new CustomResponseException(ResponseError.NO_PERMISSION_TO_EDIT); + } + + if (requestDto.getNickname() != null || requestDto.getBirth() != null || requestDto.getBio() != null) { + profile.updateProfile( + requestDto.getNickname(), + requestDto.getBirth(), + requestDto.getBio() + ); + } + + profileRepository.save(profile); + + return UpdateProfileResponseDto.from("프로필이 성공적으로 수정되었습니다."); + } +} diff --git a/src/main/java/com/example/feeda/exception/CustomResponseException.java b/src/main/java/com/example/feeda/exception/CustomResponseException.java new file mode 100644 index 0000000..efcf233 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/CustomResponseException.java @@ -0,0 +1,17 @@ +package com.example.feeda.exception; + +import com.example.feeda.exception.enums.ResponseError; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class CustomResponseException extends RuntimeException { + + private final HttpStatus httpStatus; + private final String errorMessage; + + public CustomResponseException(ResponseError responseError) { + this.httpStatus = responseError.getHttpStatus(); + this.errorMessage = responseError.getMessage(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..35314fd --- /dev/null +++ b/src/main/java/com/example/feeda/exception/GlobalExceptionHandler.java @@ -0,0 +1,67 @@ +package com.example.feeda.exception; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler({ + MethodArgumentNotValidException.class, + BindException.class, + ConstraintViolationException.class + }) + public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException ex, HttpServletRequest request) { + String message = ex.getBindingResult().getFieldErrors() + .stream() + .map(DefaultMessageSourceResolvable::getDefaultMessage) + .collect(Collectors.joining(", ")); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.BAD_REQUEST.value()); + body.put("error", HttpStatus.BAD_REQUEST.getReasonPhrase()); + body.put("message", message); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(CustomResponseException.class) + public ResponseEntity> handleCustomResponseException(CustomResponseException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", ex.getHttpStatus().value()); + body.put("error", ex.getHttpStatus().getReasonPhrase()); + body.put("message", ex.getErrorMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, ex.getHttpStatus()); + } + + @ExceptionHandler(TokenNotFoundException.class) + public ResponseEntity> handleTokenNotFoundException(TokenNotFoundException ex, HttpServletRequest request) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now()); + body.put("status", HttpStatus.UNAUTHORIZED.value()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ex.getMessage()); + body.put("path", request.getRequestURI()); + + return new ResponseEntity<>(body, HttpStatus.UNAUTHORIZED); + } +} + + diff --git a/src/main/java/com/example/feeda/exception/JwtValidationException.java b/src/main/java/com/example/feeda/exception/JwtValidationException.java deleted file mode 100644 index 9912176..0000000 --- a/src/main/java/com/example/feeda/exception/JwtValidationException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.example.feeda.exception; - -import lombok.Getter; - -@Getter -public class JwtValidationException extends RuntimeException { - private final int statusCode; - - public JwtValidationException(String message, int statusCode) { - super(message); - this.statusCode = statusCode; - } -} diff --git a/src/main/java/com/example/feeda/exception/enums/ResponseError.java b/src/main/java/com/example/feeda/exception/enums/ResponseError.java new file mode 100644 index 0000000..a442fad --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ResponseError.java @@ -0,0 +1,46 @@ +package com.example.feeda.exception.enums; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum ResponseError { + // 회원 관리 관련 오류 + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 이메일 입니다."), + INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다."), + INVALID_EMAIL_OR_PASSWORD(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호가 일치하지 않습니다."), + ACCOUNT_NOT_FOUND(HttpStatus.NOT_FOUND, "계정이 존재하지 않습니다."), + + // 프로필 관련 오류 + NICKNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 존재하는 닉네임 입니다."), + PROFILE_NOT_FOUND(HttpStatus.NOT_FOUND, "프로필이 존재하지 않습니다."), + + // 팔로우 관련 오류 + ALREADY_FOLLOWED(HttpStatus.BAD_REQUEST, "이미 팔로우한 계정입니다."), + FOLLOW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 팔로우입니다."), + CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, "본인 프로필은 팔로우/언팔로우 할 수 없습니다"), + + // 게시글 관련 오류 + POST_NOT_FOUND(HttpStatus.NOT_FOUND, "게시글이 존재하지 않습니다"), + COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "댓글이 존재하지 않습니다"), + ALREADY_LIKED_POST(HttpStatus.BAD_REQUEST, "이미 좋아요한 게시글 입니다."), + NOT_YET_LIKED_POST(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 게시글 입니다."), + ALREADY_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "이미 좋아요한 댓글입니다."), + NOT_YET_LIKED_COMMENT(HttpStatus.BAD_REQUEST, "아직 좋아요 하지 않은 댓글 입니다."), + + // 페이징 & 검색 관련 오류 + INVALID_PAGINATION_PARAMETERS(HttpStatus.BAD_REQUEST, "페이지 번호는 1 이상, 페이지 크기는 1 이상이어야 합니다."), + INVALID_DATE_PARAMETERS(HttpStatus.BAD_REQUEST, "startUpdatedAt과 endUpdatedAt은 둘 다 있어야 하거나 둘 다 없어야 합니다."), + + // 권한 관련 오류 + NO_PERMISSION_TO_EDIT(HttpStatus.FORBIDDEN, "수정 권한이 없습니다."), + NO_PERMISSION_TO_DELETE(HttpStatus.FORBIDDEN, "삭제 권한이 없습니다."); + + private final HttpStatus httpStatus; + private final String message; + + ResponseError(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java new file mode 100644 index 0000000..57a16a7 --- /dev/null +++ b/src/main/java/com/example/feeda/exception/enums/ServletResponseError.java @@ -0,0 +1,28 @@ +package com.example.feeda.exception.enums; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.Getter; + +@Getter +public enum ServletResponseError { + // JWT 관련 오류 + INVALID_JWT_SIGNATURE(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다."), + EXPIRED_JWT_TOKEN(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다."), + UNSUPPORTED_JWT(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다."), + INVALID_JWT(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."), + + // Security 관련 오류 + UNAUTHORIZED(HttpServletResponse.SC_UNAUTHORIZED, "인증이 필요합니다."), + ACCESS_DENIED(HttpServletResponse.SC_FORBIDDEN, "접근이 거부되었습니다."), + + // 내부 서버 오류 + INTERNAL_SERVER_ERROR(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "내부 서버 오류입니다."); + + private final int httpStatus; + private final String message; + + ServletResponseError(int httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } +} diff --git a/src/main/java/com/example/feeda/filter/JwtFilter.java b/src/main/java/com/example/feeda/filter/JwtFilter.java index d8355c2..33d4cd6 100644 --- a/src/main/java/com/example/feeda/filter/JwtFilter.java +++ b/src/main/java/com/example/feeda/filter/JwtFilter.java @@ -1,6 +1,8 @@ package com.example.feeda.filter; -import com.example.feeda.exception.JwtValidationException; +import com.example.feeda.exception.enums.ServletResponseError; +import com.example.feeda.exception.TokenNotFoundException; +import com.example.feeda.security.jwt.JwtBlacklistService; import com.example.feeda.security.jwt.JwtPayload; import com.example.feeda.security.jwt.JwtUtil; import io.jsonwebtoken.Claims; @@ -23,6 +25,7 @@ @RequiredArgsConstructor public class JwtFilter extends OncePerRequestFilter { + private final JwtBlacklistService jwtBlacklistService; private final JwtUtil jwtUtil; @Override @@ -34,7 +37,7 @@ protected void doFilterInternal( String bearerJwt = request.getHeader("Authorization"); - if (bearerJwt == null) { + if (bearerJwt == null || !bearerJwt.matches("^Bearer\\s+[A-Za-z0-9-_.]+$")) { chain.doFilter(request, response); return; } @@ -45,14 +48,21 @@ protected void doFilterInternal( // JWT 유효성 검사와 claims 추출 Claims claims = jwtUtil.extractClaims(jwt); if (claims == null) { - response.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다."); + response.sendError(ServletResponseError.INVALID_JWT.getHttpStatus(), ServletResponseError.INVALID_JWT.getMessage()); return; } - Long userId = jwtUtil.getUserId(jwt); + // 블랙리스트 검증 + if (jwtBlacklistService.isBlacklisted(jwt)) { + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); + return; + } + + Long accountId = jwtUtil.getAccountId(jwt); + Long profileId = jwtUtil.getProfileId(jwt); String nickName = jwtUtil.getNickName(jwt); String email = jwtUtil.getEmail(jwt); - JwtPayload payload = new JwtPayload(userId, email, nickName); + JwtPayload payload = new JwtPayload(accountId, profileId, email, nickName); // 인증 객체 생성: 사용자 정보(payload), 패스워드(""), 권한 목록(empty) UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( @@ -64,14 +74,14 @@ protected void doFilterInternal( chain.doFilter(request, response); - } catch (SecurityException | MalformedJwtException e) { - throw new JwtValidationException("유효하지 않는 JWT 서명입니다.", HttpServletResponse.SC_UNAUTHORIZED); + } catch (SecurityException | MalformedJwtException | TokenNotFoundException e) { + response.sendError(ServletResponseError.INVALID_JWT_SIGNATURE.getHttpStatus(), ServletResponseError.INVALID_JWT_SIGNATURE.getMessage()); } catch (ExpiredJwtException e) { - throw new JwtValidationException("만료된 JWT 토큰입니다.", HttpServletResponse.SC_UNAUTHORIZED); + response.sendError(ServletResponseError.EXPIRED_JWT_TOKEN.getHttpStatus(), ServletResponseError.EXPIRED_JWT_TOKEN.getMessage()); } catch (UnsupportedJwtException e) { - throw new JwtValidationException("지원되지 않는 JWT 토큰입니다.", HttpServletResponse.SC_BAD_REQUEST); + response.sendError(ServletResponseError.UNSUPPORTED_JWT.getHttpStatus(), ServletResponseError.UNSUPPORTED_JWT.getMessage()); } catch (Exception e) { - throw new JwtValidationException("내부 서버 오류", HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + response.sendError(ServletResponseError.INTERNAL_SERVER_ERROR.getHttpStatus(), ServletResponseError.INTERNAL_SERVER_ERROR.getMessage()); } } } diff --git a/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..a7f77ab --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAccessDeniedHandler.java @@ -0,0 +1,38 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.setStatus(ServletResponseError.ACCESS_DENIED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.ACCESS_DENIED.getHttpStatus()); + body.put("error", HttpStatus.FORBIDDEN.getReasonPhrase()); + body.put("message", ServletResponseError.ACCESS_DENIED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..5bf4a37 --- /dev/null +++ b/src/main/java/com/example/feeda/security/handler/CustomAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package com.example.feeda.security.handler; + +import com.example.feeda.exception.enums.ServletResponseError; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.setStatus(ServletResponseError.UNAUTHORIZED.getHttpStatus()); + response.setContentType("application/json;charset=UTF-8"); + + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", ServletResponseError.UNAUTHORIZED.getHttpStatus()); + body.put("error", HttpStatus.UNAUTHORIZED.getReasonPhrase()); + body.put("message", ServletResponseError.UNAUTHORIZED.getMessage()); + body.put("path", request.getRequestURI()); + + String jsonBody = objectMapper.writeValueAsString(body); + + response.getWriter().write(jsonBody); + } +} diff --git a/src/main/java/com/example/feeda/security/jwt/JwtBlacklistService.java b/src/main/java/com/example/feeda/security/jwt/JwtBlacklistService.java new file mode 100644 index 0000000..61caeed --- /dev/null +++ b/src/main/java/com/example/feeda/security/jwt/JwtBlacklistService.java @@ -0,0 +1,34 @@ +package com.example.feeda.security.jwt; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class JwtBlacklistService { + private static final String BLACKLIST_PREFIX = "BL:"; + + private final RedisTemplate redisTemplate; + private final JwtUtil jwtUtil; + + + // 토큰을 블랙리스트에 저장 + public void addBlacklist(String token) { + long expirationMillis = jwtUtil.getRemainingExpiration(token); + + redisTemplate.opsForValue() + .set(getKey(token), "true", expirationMillis, TimeUnit.MILLISECONDS); + } + + // 요청 시 토큰이 블랙리스트인지 확인 + public boolean isBlacklisted(String token) { + return Boolean.TRUE.equals(redisTemplate.hasKey(getKey(token))); + } + + private String getKey(String token) { + return BLACKLIST_PREFIX + token; + } +} diff --git a/src/main/java/com/example/feeda/security/jwt/JwtPayload.java b/src/main/java/com/example/feeda/security/jwt/JwtPayload.java index fb119a7..ae6a166 100644 --- a/src/main/java/com/example/feeda/security/jwt/JwtPayload.java +++ b/src/main/java/com/example/feeda/security/jwt/JwtPayload.java @@ -6,7 +6,8 @@ @Getter @AllArgsConstructor public class JwtPayload { - private final Long userId; + private final Long accountId; + private final Long profileId; private final String email; private final String nickName; } diff --git a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java index 9613de2..ba73898 100644 --- a/src/main/java/com/example/feeda/security/jwt/JwtUtil.java +++ b/src/main/java/com/example/feeda/security/jwt/JwtUtil.java @@ -30,13 +30,14 @@ public void init() { key = Keys.hmacShaKeyFor(bytes); } - public String createToken(Long userId, String nickName, String email) { + public String createToken(JwtPayload payload) { Date date = new Date(); return Jwts.builder() - .setSubject(String.valueOf(userId)) - .claim("nickName", nickName) - .claim("email", email) + .setSubject(String.valueOf(payload.getAccountId())) + .claim("profileId", payload.getProfileId()) + .claim("nickName", payload.getNickName()) + .claim("email", payload.getEmail()) .setExpiration(new Date(date.getTime() + TOKEN_TIME)) .setIssuedAt(date) // 발급일 .signWith(key, signatureAlgorithm) // 암호화 알고리즘 @@ -48,7 +49,7 @@ public String extractToken(String tokenValue) { return tokenValue.substring(BEARER_PREFIX.length()); // "Bearer " 제거 후 반환 } - throw new TokenNotFoundException("Not Found Token"); + throw new TokenNotFoundException("토큰을 찾을 수 없습니다."); } public Claims extractClaims(String token) { @@ -59,10 +60,19 @@ public Claims extractClaims(String token) { .getBody(); } - public Long getUserId(String token) { + public long getRemainingExpiration(String token) { + Date expiration = extractClaims(token).getExpiration(); + return expiration.getTime() - System.currentTimeMillis(); + } + + public Long getAccountId(String token) { return Long.parseLong(extractClaims(token).getSubject()); } + public Long getProfileId(String token) { + return extractClaims(token).get("profileId", Long.class); + } + public String getNickName(String token) { return extractClaims(token).get("nickName", String.class); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3df66af..3f45db5 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,10 +7,11 @@ spring: username: ${DB_USERNAME} password: ${DB_PASSWORD} - jpa: - hibernate: - ddl-auto: create - show-sql: true + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + password: ${REDIS_PASSWORD} server: error: @@ -19,3 +20,10 @@ server: jwt: secret: key: ${SECRET_KEY} + + +#logging: +# level: +# org: +# springframework: +# security: trace \ No newline at end of file diff --git a/src/test/java/com/example/feeda/unit/service/FollowServiceTest.java b/src/test/java/com/example/feeda/unit/service/FollowServiceTest.java new file mode 100644 index 0000000..7815fa2 --- /dev/null +++ b/src/test/java/com/example/feeda/unit/service/FollowServiceTest.java @@ -0,0 +1,219 @@ +package com.example.feeda.unit.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.example.feeda.domain.account.entity.Account; +import com.example.feeda.domain.follow.dto.FollowsResponseDto; +import com.example.feeda.domain.follow.entity.Follows; +import com.example.feeda.domain.follow.repository.FollowsRepository; +import com.example.feeda.domain.follow.service.FollowsServiceImpl; +import com.example.feeda.domain.profile.dto.ProfileListResponseDto; +import com.example.feeda.domain.profile.entity.Profile; +import com.example.feeda.domain.profile.repository.ProfileRepository; +import com.example.feeda.security.jwt.JwtPayload; +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +@ExtendWith(MockitoExtension.class) +public class FollowServiceTest { + + @InjectMocks + FollowsServiceImpl followsServiceimpl; + + @Mock + FollowsRepository followsRepository; + + @Mock + ProfileRepository profileRepository; + + @Test + void 계정_팔로우_단위_테스트() { + + //given + Long myAccountId = 1L; + Long myProfileId = 1L; + Long targetProfileId = 2L; + LocalDateTime now = LocalDateTime.now(); + + JwtPayload jwtPayload = new JwtPayload(myAccountId, myProfileId, "test@naver.com", "test"); + + Account myAccount = new Account(); + Profile myProfile = new Profile(myProfileId, "test", new Date(), "intro myself", now, now, + myAccount); + + Account targetAccount = new Account(); + Profile targetProfile = new Profile(targetProfileId, "target", new Date(), "intro target", + now, now, targetAccount); + + when(profileRepository.findById(myProfileId)).thenReturn(Optional.of(myProfile)); + when(profileRepository.findById(targetProfileId)).thenReturn(Optional.of(targetProfile)); + when(followsRepository.findByFollowersAndFollowings(myProfile, targetProfile)) + .thenReturn(Optional.empty()); + + Follows newFollow = Follows.builder() + .followers(myProfile) + .followings(targetProfile) + .build(); + + when(followsRepository.save(any(Follows.class))).thenReturn(newFollow); + + //when + FollowsResponseDto followsResponseDto = followsServiceimpl.follow(jwtPayload, + targetProfileId); + + //then + assertThat(followsResponseDto.getFollowerId()).isEqualTo(myProfileId); + assertThat(followsResponseDto.getFollowingId()).isEqualTo(targetProfileId); + } + + @Test + void 계정_언팔로우_단위_테스트() { + + //given + Long myAccountId = 1L; + Long myProfileId = 1L; + Long followingProfileId = 2L; + LocalDateTime now = LocalDateTime.now(); + + JwtPayload jwtPayload = new JwtPayload(myAccountId, myProfileId, "test@naver.com", "test"); + + Account myAccount = new Account(); + Profile myProfile = new Profile(myProfileId, "test", new Date(), "intro myself", now, now, + myAccount); + + Account targetAccount = new Account(); + Profile targetProfile = new Profile(followingProfileId, "target", new Date(), + "intro target", + now, now, targetAccount); + + Follows follow = Follows.builder() + .followers(myProfile) + .followings(targetProfile) + .build(); + + when(profileRepository.findById(myProfileId)).thenReturn(Optional.of(myProfile)); + when(profileRepository.findById(followingProfileId)).thenReturn(Optional.of(targetProfile)); + when(followsRepository.findByFollowersAndFollowings(myProfile, targetProfile)) + .thenReturn(Optional.of(follow)); + doNothing().when(followsRepository).delete(follow); + + //when + followsServiceimpl.unfollow(jwtPayload, + followingProfileId); + + // then + verify(followsRepository, times(1)).delete(follow); + } + + @Test + void 팔로워_목록_조회_단위_테스트() { + Long myAccountId = 1L; + Long firstProfileId = 1L; + Long secondProfileId = 2L; + Long followingProfileId = 3L; + LocalDateTime now = LocalDateTime.now(); + Pageable pageable = PageRequest.of(0, 10); + + JwtPayload jwtPayload = new JwtPayload(myAccountId, firstProfileId, "test@naver.com", + "firstNickname"); + + Account myAccount = new Account(); + Profile firstProfile = new Profile(firstProfileId, "firstNickname", new Date(), + "intro myself", now, + now, + myAccount); + + Account secondAccount = new Account(); + Profile secondProfile = new Profile(secondProfileId, "secondNickname", new Date(), + "intro myself", now, + now, + secondAccount); + + Account targetAccount = new Account(); + Profile targetProfile = new Profile(followingProfileId, "targetNickname", new Date(), + "intro target", + now, now, targetAccount); + + Follows firstFollow = Follows.builder().followers(firstProfile).followings(targetProfile) + .build(); + Follows secondFollow = Follows.builder().followers(secondProfile).followings(targetProfile) + .build(); + + Page followsPage = new PageImpl<>(List.of(firstFollow, secondFollow), pageable, 2); + when(followsRepository.findAllByFollowings_Id(followingProfileId, pageable)).thenReturn( + followsPage); + + // when + ProfileListResponseDto result = followsServiceimpl.findFollowersPage(followingProfileId, + jwtPayload, pageable); + + // then + assertThat(result.getProfiles().get(0).getNickname()).isEqualTo("firstNickname"); + assertThat(result.getProfiles().get(1).getNickname()).isEqualTo("secondNickname"); + assertThat(result.getCurrentPage()).isEqualTo(1); + } + + @Test + void 팔로잉_목록_조회_단위_테스트() { + Long myAccountId = 1L; + Long firstProfileId = 1L; + Long secondProfileId = 2L; + Long followingProfileId = 3L; + LocalDateTime now = LocalDateTime.now(); + Pageable pageable = PageRequest.of(0, 10); + + JwtPayload jwtPayload = new JwtPayload(myAccountId, firstProfileId, "test@naver.com", + "firstNickname"); + + Account myAccount = new Account(); + Profile firstProfile = new Profile(firstProfileId, "firstNickname", new Date(), + "intro myself", now, + now, + myAccount); + + Account secondAccount = new Account(); + Profile secondProfile = new Profile(secondProfileId, "secondNickname", new Date(), + "intro myself", now, + now, + secondAccount); + + Account targetAccount = new Account(); + Profile targetProfile = new Profile(followingProfileId, "targetNickname", new Date(), + "intro target", + now, now, targetAccount); + + Follows firstFollow = Follows.builder().followers(firstProfile).followings(secondProfile) + .build(); + Follows secondFollow = Follows.builder().followers(firstProfile).followings(targetProfile) + .build(); + + Page followsPage = new PageImpl<>(List.of(firstFollow, secondFollow), pageable, 2); + when(followsRepository.findAllByFollowers_Id(firstProfileId, pageable)).thenReturn( + followsPage); + + // when + ProfileListResponseDto result = followsServiceimpl.findFollowingsPage(firstProfileId, + jwtPayload, pageable); + + // then + assertThat(result.getProfiles().get(0).getNickname()).isEqualTo("secondNickname"); + assertThat(result.getProfiles().get(1).getNickname()).isEqualTo("targetNickname"); + assertThat(result.getCurrentPage()).isEqualTo(1); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..412d302 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,11 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false + driver-class-name: org.h2.Driver + username: test + password: + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect