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)
+
+
+
+와이어 프레임
+
+
+
+
+
+DB 설계
+
+- 개념적 설계:
+ - 
+- 논리적 설계(ERD):
+ - 
+- 물리적 설계(SQL):
+ - [newsFeed2.sql](./newsFeed2.sql)
+ - 
+
+
+
+
+## 📁 폴더 구조
+```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