diff --git a/build.gradle b/build.gradle index 610d6a6..ff8fb82 100644 --- a/build.gradle +++ b/build.gradle @@ -1,29 +1,29 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.6' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.5.6' + id 'io.spring.dependency-management' version '1.1.6' + id 'java' } - group = 'com.example' version = '0.0.1-SNAPSHOT' -description = 'Demo project for Spring Boot' - -java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } -} +java { sourceCompatibility = '21' } -repositories { - mavenCentral() -} +repositories { mavenCentral() } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-web' - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() + enabled = false } diff --git a/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java b/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..7ccb7c7 --- /dev/null +++ b/src/main/java/com/example/devSns/config/JwtAuthenticationFilter.java @@ -0,0 +1,46 @@ +package com.example.devSns.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { + this.jwtTokenProvider = jwtTokenProvider; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String header = request.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + return header.substring(7); + } + return null; + } +} diff --git a/src/main/java/com/example/devSns/config/JwtTokenProvider.java b/src/main/java/com/example/devSns/config/JwtTokenProvider.java new file mode 100644 index 0000000..e4ce22b --- /dev/null +++ b/src/main/java/com/example/devSns/config/JwtTokenProvider.java @@ -0,0 +1,80 @@ +package com.example.devSns.config; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Collections; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access-token-validity-in-seconds}") + private long validityInSeconds; + + private Key key; + + @PostConstruct + public void init() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + } + + public String createToken(String username, Long memberId) { + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInSeconds * 1000); + + return Jwts.builder() + .setSubject(username) + .claim("memberId", memberId) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public boolean validateToken(String token) { + try { + getClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + private Claims getClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } + + public Authentication getAuthentication(String token) { + Claims claims = getClaims(token); + String username = claims.getSubject(); + + User principal = new User(username, "", Collections.emptyList()); + return new UsernamePasswordAuthenticationToken( + principal, + token, + principal.getAuthorities() + ); + } + + public Long getMemberId(String token) { + Claims claims = getClaims(token); + return claims.get("memberId", Long.class); + } +} diff --git a/src/main/java/com/example/devSns/config/SecurityConfig.java b/src/main/java/com/example/devSns/config/SecurityConfig.java new file mode 100644 index 0000000..ccb0fd1 --- /dev/null +++ b/src/main/java/com/example/devSns/config/SecurityConfig.java @@ -0,0 +1,58 @@ +package com.example.devSns.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .httpBasic(basic -> basic.disable()) + .formLogin(form -> form.disable()) + .sessionManagement(sm -> + sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + // 로그인/회원가입, H2 콘솔은 누구나 허용 + .requestMatchers("/auth/**", "/h2-console/**").permitAll() + // 읽기(GET)은 일단 공개 + .requestMatchers(HttpMethod.GET, "/**").permitAll() + // 작성/수정/삭제는 인증 필요 + .requestMatchers(HttpMethod.POST, "/**").authenticated() + .requestMatchers(HttpMethod.PUT, "/**").authenticated() + .requestMatchers(HttpMethod.DELETE, "/**").authenticated() + .anyRequest().authenticated() + ); + + // H2 콘솔용 frame 옵션 + http.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); + + // JWT 필터 등록 + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/example/devSns/domain/Comment.java b/src/main/java/com/example/devSns/domain/Comment.java new file mode 100644 index 0000000..91d5674 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Comment.java @@ -0,0 +1,46 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Comment { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false) + private String content; + + /** 어느 Post 에 달린 댓글인지 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + /** 누가 쓴 댓글인지 */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member author; + + @Builder + private Comment(String content, Post post, Member author) { + this.content = content; + this.post = post; + this.author = author; + } + + public void update(String content) { + this.content = content; + } + + void setPostInternal(Post post) { + this.post = post; + } + + public void changeAuthor(Member author) { + this.author = author; + } +} diff --git a/src/main/java/com/example/devSns/domain/CommentRepository.java b/src/main/java/com/example/devSns/domain/CommentRepository.java new file mode 100644 index 0000000..a0f46e3 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/CommentRepository.java @@ -0,0 +1,14 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface CommentRepository extends JpaRepository { + + List findByPostIdOrderByIdAsc(Long postId); + Optional findByIdAndPostId(Long id, Long postId); + + List findByAuthorIdOrderByIdDesc(Long memberId); +} diff --git a/src/main/java/com/example/devSns/domain/Follow.java b/src/main/java/com/example/devSns/domain/Follow.java new file mode 100644 index 0000000..be57ab5 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Follow.java @@ -0,0 +1,33 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "follow", + uniqueConstraints = @UniqueConstraint(columnNames = {"follower_id", "following_id"}) +) +public class Follow { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 나를 기준으로: 내가 팔로우 하는 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "follower_id", nullable = false) + private Member follower; + + // 내가 팔로우 당하는 사람 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "following_id", nullable = false) + private Member following; + + @Builder + private Follow(Member follower, Member following) { + this.follower = follower; + this.following = following; + } +} diff --git a/src/main/java/com/example/devSns/domain/FollowRepository.java b/src/main/java/com/example/devSns/domain/FollowRepository.java new file mode 100644 index 0000000..3785305 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/FollowRepository.java @@ -0,0 +1,15 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface FollowRepository extends JpaRepository { + + boolean existsByFollowerIdAndFollowingId(Long followerId, Long followingId); + + void deleteByFollowerIdAndFollowingId(Long followerId, Long followingId); + + List findByFollowerId(Long followerId); // 내가 팔로우하는 사람들 + List findByFollowingId(Long followingId); // 나를 팔로우하는 사람들 +} diff --git a/src/main/java/com/example/devSns/domain/Member.java b/src/main/java/com/example/devSns/domain/Member.java new file mode 100644 index 0000000..7d821ea --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Member.java @@ -0,0 +1,45 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @NotBlank + @Column(nullable = false, unique = true, length = 50) + private String username; // 로그인 아이디 + + @NotBlank + @Column(nullable = false, length = 100) + private String password; // 인코딩된 비밀번호 + + @Column(length = 50) + private String nickname; // 화면용 이름 + + @Column(length = 255) + private String bio; // 자기소개 (옵션) + + @Builder + private Member(String username, String password, String nickname, String bio) { + this.username = username; + this.password = password; + this.nickname = nickname; + this.bio = bio; + } + + public void updateProfile(String nickname, String bio) { + this.nickname = nickname; + this.bio = bio; + } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} diff --git a/src/main/java/com/example/devSns/domain/MemberRepository.java b/src/main/java/com/example/devSns/domain/MemberRepository.java new file mode 100644 index 0000000..83f3260 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/MemberRepository.java @@ -0,0 +1,19 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + + // 멤버 검색용 (username 또는 nickname 기준) + List findByUsernameContainingIgnoreCaseOrNicknameContainingIgnoreCase( + String usernameKeyword, + String nicknameKeyword + ); + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/example/devSns/domain/Post.java b/src/main/java/com/example/devSns/domain/Post.java new file mode 100644 index 0000000..9a35159 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/Post.java @@ -0,0 +1,63 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Post { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // ★ 작성자(Member) 연결 (nullable=true 로 두어서 기존 기능 안 깨지게) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member author; + + @NotBlank + @Column(nullable = false, length = 100) + private String title; + + @Column(columnDefinition = "TEXT") + private String content; + + // 댓글 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private final List comments = new ArrayList<>(); + + // 좋아요 + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true) + private final List likes = new ArrayList<>(); + + @Builder + private Post(Member author, String title, String content) { + this.author = author; + this.title = title; + this.content = content; + } + + public void update(String title, String content) { + this.title = title; + this.content = content; + } + + public void changeAuthor(Member author) { + this.author = author; + } + + /** 양방향 편의 메서드 */ + void addComment(Comment c) { + comments.add(c); + c.setPostInternal(this); + } + + void removeComment(Comment c) { + comments.remove(c); + c.setPostInternal(null); + } +} diff --git a/src/main/java/com/example/devSns/domain/PostLike.java b/src/main/java/com/example/devSns/domain/PostLike.java new file mode 100644 index 0000000..a6e9cfb --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostLike.java @@ -0,0 +1,31 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "post_like", + uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "post_id"}) +) +public class PostLike { + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Builder + private PostLike(Member member, Post post) { + this.member = member; + this.post = post; + } +} diff --git a/src/main/java/com/example/devSns/domain/PostLikeRepository.java b/src/main/java/com/example/devSns/domain/PostLikeRepository.java new file mode 100644 index 0000000..c73c4e1 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostLikeRepository.java @@ -0,0 +1,16 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostLikeRepository extends JpaRepository { + + boolean existsByMemberIdAndPostId(Long memberId, Long postId); + + void deleteByMemberIdAndPostId(Long memberId, Long postId); + + long countByPostId(Long postId); + + List findByMemberIdOrderByIdDesc(Long memberId); +} diff --git a/src/main/java/com/example/devSns/domain/PostRepository.java b/src/main/java/com/example/devSns/domain/PostRepository.java new file mode 100644 index 0000000..3ca4fb7 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/PostRepository.java @@ -0,0 +1,10 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PostRepository extends JpaRepository { + + List findByAuthorIdOrderByIdDesc(Long memberId); +} diff --git a/src/main/java/com/example/devSns/domain/RefreshToken.java b/src/main/java/com/example/devSns/domain/RefreshToken.java new file mode 100644 index 0000000..1c26e6b --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshToken.java @@ -0,0 +1,41 @@ +package com.example.devSns.domain; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RefreshToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, unique = true, length = 200) + private String token; + + @Column(nullable = false) + private Instant expiryDate; + + @Builder + private RefreshToken(Member member, String token, Instant expiryDate) { + this.member = member; + this.token = token; + this.expiryDate = expiryDate; + } + + public void update(String token, Instant expiryDate) { + this.token = token; + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java b/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java new file mode 100644 index 0000000..15ecfd4 --- /dev/null +++ b/src/main/java/com/example/devSns/domain/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package com.example.devSns.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + void deleteByMember(Member member); +} diff --git a/src/main/java/com/example/devSns/service/CommentService.java b/src/main/java/com/example/devSns/service/CommentService.java new file mode 100644 index 0000000..d6a910f --- /dev/null +++ b/src/main/java/com/example/devSns/service/CommentService.java @@ -0,0 +1,78 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Comment; +import com.example.devSns.domain.CommentRepository; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +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; + +@Service +@Transactional +public class CommentService { + + private final CommentRepository commentRepo; + private final PostRepository postRepo; + private final MemberRepository memberRepo; + + public CommentService(CommentRepository commentRepo, + PostRepository postRepo, + MemberRepository memberRepo) { + this.commentRepo = commentRepo; + this.postRepo = postRepo; + this.memberRepo = memberRepo; + } + + /** CommentController.list 에서 부르는 메서드 */ + @Transactional(readOnly = true) + public List list(Long postId) { + ensurePostExists(postId); + return commentRepo.findByPostIdOrderByIdAsc(postId); + } + + @Transactional(readOnly = true) + public Comment get(Long postId, Long commentId) { + return commentRepo.findByIdAndPostId(commentId, postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Comment not found")); + } + + /** CommentController.create 에서 부르는 메서드 */ + public Comment create(Long postId, Long memberId, String content) { + Post post = postRepo.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + + Member author = memberRepo.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + Comment comment = Comment.builder() + .content(content) + .post(post) + .author(author) + .build(); + + return commentRepo.save(comment); + } + + public Comment update(Long postId, Long commentId, String content) { + Comment comment = get(postId, commentId); + comment.update(content); + return comment; + } + + public void delete(Long postId, Long commentId) { + Comment comment = get(postId, commentId); + commentRepo.delete(comment); + } + + private void ensurePostExists(Long postId) { + if (!postRepo.existsById(postId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found"); + } + } +} diff --git a/src/main/java/com/example/devSns/service/FollowService.java b/src/main/java/com/example/devSns/service/FollowService.java new file mode 100644 index 0000000..7465e99 --- /dev/null +++ b/src/main/java/com/example/devSns/service/FollowService.java @@ -0,0 +1,73 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Follow; +import com.example.devSns.domain.FollowRepository; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +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; + +@Service +@Transactional +public class FollowService { + + private final MemberRepository memberRepository; + private final FollowRepository followRepository; + + public FollowService(MemberRepository memberRepository, + FollowRepository followRepository) { + this.memberRepository = memberRepository; + this.followRepository = followRepository; + } + + public void follow(Long followerId, Long followingId) { + if (followerId.equals(followingId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "자기 자신은 팔로우할 수 없습니다."); + } + + Member follower = getMember(followerId); + Member following = getMember(followingId); + + if (followRepository.existsByFollowerIdAndFollowingId(followerId, followingId)) { + // 이미 팔로우 중이면 무시 + return; + } + + Follow follow = Follow.builder() + .follower(follower) + .following(following) + .build(); + followRepository.save(follow); + } + + public void unfollow(Long followerId, Long followingId) { + followRepository.deleteByFollowerIdAndFollowingId(followerId, followingId); + } + + @Transactional(readOnly = true) + public List getFollowers(Long memberId) { + getMember(memberId); // 존재 확인 + return followRepository.findByFollowingId(memberId) + .stream() + .map(Follow::getFollower) + .toList(); + } + + @Transactional(readOnly = true) + public List getFollowings(Long memberId) { + getMember(memberId); // 존재 확인 + return followRepository.findByFollowerId(memberId) + .stream() + .map(Follow::getFollowing) + .toList(); + } + + private Member getMember(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + } +} diff --git a/src/main/java/com/example/devSns/service/MemberService.java b/src/main/java/com/example/devSns/service/MemberService.java new file mode 100644 index 0000000..a8d5c99 --- /dev/null +++ b/src/main/java/com/example/devSns/service/MemberService.java @@ -0,0 +1,89 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.List; + +@Service +@Transactional +public class MemberService { + + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final PostLikeRepository postLikeRepository; + private final PasswordEncoder passwordEncoder; // ✅ 추가 + + public MemberService(MemberRepository memberRepository, + PostRepository postRepository, + CommentRepository commentRepository, + PostLikeRepository postLikeRepository, + PasswordEncoder passwordEncoder) { // ✅ 생성자 수정 + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.commentRepository = commentRepository; + this.postLikeRepository = postLikeRepository; + this.passwordEncoder = passwordEncoder; + } + + public Member create(String username, String rawPassword, String nickname, String bio) { + if (memberRepository.existsByUsername(username)) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 사용 중인 아이디입니다."); + } + + String encodedPassword = passwordEncoder.encode(rawPassword); + + Member member = Member.builder() + .username(username) + .password(encodedPassword) // ✅ 인코딩된 비밀번호 저장 + .nickname(nickname) + .bio(bio) + .build(); + + return memberRepository.save(member); + } + + @Transactional(readOnly = true) + public Member get(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + } + + @Transactional(readOnly = true) + public List search(String keyword) { + if (keyword == null || keyword.isBlank()) { + return memberRepository.findAll(); + } + return memberRepository + .findByUsernameContainingIgnoreCaseOrNicknameContainingIgnoreCase(keyword, keyword); + } + + @Transactional(readOnly = true) + public List getPosts(Long memberId) { + ensureMemberExists(memberId); + return postRepository.findByAuthorIdOrderByIdDesc(memberId); + } + + @Transactional(readOnly = true) + public List getComments(Long memberId) { + ensureMemberExists(memberId); + return commentRepository.findByAuthorIdOrderByIdDesc(memberId); + } + + @Transactional(readOnly = true) + public List getLikes(Long memberId) { + ensureMemberExists(memberId); + return postLikeRepository.findByMemberIdOrderByIdDesc(memberId); + } + + private void ensureMemberExists(Long memberId) { + if (!memberRepository.existsById(memberId)) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found"); + } + } +} diff --git a/src/main/java/com/example/devSns/service/PostLikeService.java b/src/main/java/com/example/devSns/service/PostLikeService.java new file mode 100644 index 0000000..9a6503d --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostLikeService.java @@ -0,0 +1,51 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.*; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +@Transactional +public class PostLikeService { + + private final MemberRepository memberRepository; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + + public PostLikeService(MemberRepository memberRepository, + PostRepository postRepository, + PostLikeRepository postLikeRepository) { + this.memberRepository = memberRepository; + this.postRepository = postRepository; + this.postLikeRepository = postLikeRepository; + } + + public void like(Long memberId, Long postId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + + if (postLikeRepository.existsByMemberIdAndPostId(memberId, postId)) { + return; // 이미 좋아요 누른 상태 + } + + PostLike like = PostLike.builder() + .member(member) + .post(post) + .build(); + postLikeRepository.save(like); + } + + public void unlike(Long memberId, Long postId) { + postLikeRepository.deleteByMemberIdAndPostId(memberId, postId); + } + + @Transactional(readOnly = true) + public long countLikes(Long postId) { + return postLikeRepository.countByPostId(postId); + } +} diff --git a/src/main/java/com/example/devSns/service/PostService.java b/src/main/java/com/example/devSns/service/PostService.java new file mode 100644 index 0000000..4c97614 --- /dev/null +++ b/src/main/java/com/example/devSns/service/PostService.java @@ -0,0 +1,64 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.PostRepository; +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; + +@Service +@Transactional +public class PostService { + + private final PostRepository postRepository; + private final MemberRepository memberRepository; + + public PostService(PostRepository postRepository, + MemberRepository memberRepository) { + this.postRepository = postRepository; + this.memberRepository = memberRepository; + } + + /** PostController.list 에서 쓰는 메서드 */ + @Transactional(readOnly = true) + public List findAll() { + return postRepository.findAll(); + } + + /** PostController.get 에서 쓰는 메서드 */ + @Transactional(readOnly = true) + public Post findById(Long id) { + return postRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Post not found")); + } + + /** PostController.create 에서 쓰는 메서드 */ + public Post create(Long memberId, String title, String content) { + Member author = memberRepository.findById(memberId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Member not found")); + + Post post = Post.builder() + .author(author) + .title(title) + .content(content) + .build(); + + return postRepository.save(post); + } + + public Post update(Long id, String title, String content) { + Post post = findById(id); + post.update(title, content); + return post; + } + + public void delete(Long id) { + Post post = findById(id); + postRepository.delete(post); + } +} diff --git a/src/main/java/com/example/devSns/service/RefreshTokenService.java b/src/main/java/com/example/devSns/service/RefreshTokenService.java new file mode 100644 index 0000000..2681fb1 --- /dev/null +++ b/src/main/java/com/example/devSns/service/RefreshTokenService.java @@ -0,0 +1,65 @@ +package com.example.devSns.service; + +import com.example.devSns.domain.Member; +import com.example.devSns.domain.RefreshToken; +import com.example.devSns.domain.RefreshTokenRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.time.Instant; +import java.util.UUID; + +@Service +@Transactional +public class RefreshTokenService { + + private final RefreshTokenRepository refreshTokenRepository; + + @Value("${jwt.refresh-token-validity-in-seconds}") + private long refreshValidityInSeconds; + + public RefreshTokenService(RefreshTokenRepository refreshTokenRepository) { + this.refreshTokenRepository = refreshTokenRepository; + } + + /** 새 리프레시 토큰 생성 + 저장 */ + public RefreshToken create(Member member) { + String token = UUID.randomUUID().toString(); + Instant expiryDate = Instant.now().plusSeconds(refreshValidityInSeconds); + + RefreshToken refreshToken = RefreshToken.builder() + .member(member) + .token(token) + .expiryDate(expiryDate) + .build(); + + return refreshTokenRepository.save(refreshToken); + } + + /** 토큰 문자열로 검증 (존재 + 만료 체크) */ + public RefreshToken validate(String token) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(token) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "유효하지 않은 리프레시 토큰입니다." + )); + + if (refreshToken.getExpiryDate().isBefore(Instant.now())) { + refreshTokenRepository.delete(refreshToken); + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, + "리프레시 토큰이 만료되었습니다. 다시 로그인 해주세요." + ); + } + + return refreshToken; + } + + /** 해당 회원의 모든 리프레시 토큰 제거 (로그아웃 등에서 사용 가능) */ + public void deleteByMember(Member member) { + refreshTokenRepository.deleteByMember(member); + } +} diff --git a/src/main/java/com/example/devSns/web/AuthController.java b/src/main/java/com/example/devSns/web/AuthController.java new file mode 100644 index 0000000..d8eb984 --- /dev/null +++ b/src/main/java/com/example/devSns/web/AuthController.java @@ -0,0 +1,86 @@ +package com.example.devSns.web; + +import com.example.devSns.config.JwtTokenProvider; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.MemberRepository; +import com.example.devSns.domain.RefreshToken; +import com.example.devSns.service.MemberService; +import com.example.devSns.service.RefreshTokenService; +import com.example.devSns.web.dto.LoginRequest; +import com.example.devSns.web.dto.LoginResponse; +import com.example.devSns.web.dto.MemberCreateRequest; +import com.example.devSns.web.dto.MemberResponse; +import com.example.devSns.web.dto.TokenRefreshRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final MemberService memberService; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; + private final PasswordEncoder passwordEncoder; + private final RefreshTokenService refreshTokenService; + + public AuthController(MemberService memberService, + MemberRepository memberRepository, + JwtTokenProvider jwtTokenProvider, + PasswordEncoder passwordEncoder, + RefreshTokenService refreshTokenService) { + this.memberService = memberService; + this.memberRepository = memberRepository; + this.jwtTokenProvider = jwtTokenProvider; + this.passwordEncoder = passwordEncoder; + this.refreshTokenService = refreshTokenService; + } + + /** 회원가입 */ + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + public MemberResponse signup(@Valid @RequestBody MemberCreateRequest req) { + Member member = memberService.create( + req.username(), + req.password(), + req.nickname(), + req.bio() + ); + return MemberResponse.from(member); + } + + /** 로그인 + AccessToken / RefreshToken 발급 */ + @PostMapping("/login") + public LoginResponse login(@Valid @RequestBody LoginRequest req) { + Member member = memberRepository.findByUsername(req.username()) + .orElseThrow(() -> new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 올바르지 않습니다.")); + + if (!passwordEncoder.matches(req.password(), member.getPassword())) { + throw new ResponseStatusException( + HttpStatus.UNAUTHORIZED, "아이디 혹은 비밀번호가 올바르지 않습니다."); + } + + String accessToken = jwtTokenProvider.createToken(member.getUsername(), member.getId()); + RefreshToken refreshToken = refreshTokenService.create(member); + + return LoginResponse.of(accessToken, refreshToken.getToken(), member); + } + + /** RefreshToken 으로 AccessToken 재발급 */ + @PostMapping("/refresh") + public LoginResponse refresh(@Valid @RequestBody TokenRefreshRequest req) { + // 1) 리프레시 토큰 검증 + RefreshToken refreshToken = refreshTokenService.validate(req.refreshToken()); + Member member = refreshToken.getMember(); + + // 2) 새 AccessToken 발급 + String newAccessToken = jwtTokenProvider.createToken(member.getUsername(), member.getId()); + + // 3) RefreshToken은 여기서는 재사용 (원하면 회전 로직 추가 가능) + return LoginResponse.of(newAccessToken, refreshToken.getToken(), member); + } +} diff --git a/src/main/java/com/example/devSns/web/CommentController.java b/src/main/java/com/example/devSns/web/CommentController.java new file mode 100644 index 0000000..dab1b46 --- /dev/null +++ b/src/main/java/com/example/devSns/web/CommentController.java @@ -0,0 +1,56 @@ +package com.example.devSns.web; + +import com.example.devSns.service.CommentService; +import com.example.devSns.web.dto.CommentCreateRequest; +import com.example.devSns.web.dto.CommentUpdateRequest; +import com.example.devSns.web.dto.CommentResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts/{postId}/comments") +public class CommentController { + private final CommentService svc; + + public CommentController(CommentService svc) { + this.svc = svc; + } + + @GetMapping + public List list(@PathVariable Long postId) { + return svc.list(postId).stream().map(CommentResponse::from).toList(); + } + + @GetMapping("/{commentId}") + public CommentResponse get(@PathVariable Long postId, @PathVariable Long commentId) { + return CommentResponse.from(svc.get(postId, commentId)); + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping + public CommentResponse create(@PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest req) { + return CommentResponse.from( + svc.create( + postId, + req.memberId(), + req.content() + ) + ); + } + + @PutMapping("/{commentId}") + public CommentResponse update(@PathVariable Long postId, @PathVariable Long commentId, + @Valid @RequestBody CommentUpdateRequest req) { + return CommentResponse.from(svc.update(postId, commentId, req.content())); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{commentId}") + public void delete(@PathVariable Long postId, @PathVariable Long commentId) { + svc.delete(postId, commentId); + } +} diff --git a/src/main/java/com/example/devSns/web/FollowController.java b/src/main/java/com/example/devSns/web/FollowController.java new file mode 100644 index 0000000..8e5bc84 --- /dev/null +++ b/src/main/java/com/example/devSns/web/FollowController.java @@ -0,0 +1,48 @@ +package com.example.devSns.web; + +import com.example.devSns.service.FollowService; +import com.example.devSns.web.dto.MemberResponse; +import com.example.devSns.domain.Member; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/members/{memberId}") +public class FollowController { + + private final FollowService followService; + + public FollowController(FollowService followService) { + this.followService = followService; + } + + /** memberId 가 targetId 를 팔로우 */ + @PostMapping("/follow/{targetId}") + @ResponseStatus(HttpStatus.CREATED) + public void follow(@PathVariable Long memberId, @PathVariable Long targetId) { + followService.follow(memberId, targetId); + } + + /** 언팔로우 */ + @DeleteMapping("/follow/{targetId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unfollow(@PathVariable Long memberId, @PathVariable Long targetId) { + followService.unfollow(memberId, targetId); + } + + /** 나를 팔로우하는 사람들 */ + @GetMapping("/followers") + public List followers(@PathVariable Long memberId) { + List list = followService.getFollowers(memberId); + return list.stream().map(MemberResponse::from).toList(); + } + + /** 내가 팔로우하는 사람들 */ + @GetMapping("/followings") + public List followings(@PathVariable Long memberId) { + List list = followService.getFollowings(memberId); + return list.stream().map(MemberResponse::from).toList(); + } +} diff --git a/src/main/java/com/example/devSns/web/MemberController.java b/src/main/java/com/example/devSns/web/MemberController.java new file mode 100644 index 0000000..389824c --- /dev/null +++ b/src/main/java/com/example/devSns/web/MemberController.java @@ -0,0 +1,77 @@ +package com.example.devSns.web; + +import com.example.devSns.domain.PostLike; +import com.example.devSns.service.MemberService; +import com.example.devSns.web.dto.*; +import com.example.devSns.domain.Member; +import com.example.devSns.domain.Post; +import com.example.devSns.domain.Comment; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/members") +public class MemberController { + + private final MemberService memberService; + + public MemberController(MemberService memberService) { + this.memberService = memberService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public MemberResponse create(@Valid @RequestBody MemberCreateRequest req) { + Member m = memberService.create( + req.username(), + req.password(), + req.nickname(), + req.bio() + ); + return MemberResponse.from(m); + } + + /** 멤버 검색 (q 없으면 전체) */ + @GetMapping + public List search(@RequestParam(name = "q", required = false) String keyword) { + return memberService.search(keyword).stream() + .map(MemberResponse::from) + .toList(); + } + + /** 멤버 단건 조회 */ + @GetMapping("/{memberId}") + public MemberResponse get(@PathVariable Long memberId) { + return MemberResponse.from(memberService.get(memberId)); + } + + /** 멤버의 게시글 보기 */ + @GetMapping("/{memberId}/posts") + public List posts(@PathVariable Long memberId) { + List posts = memberService.getPosts(memberId); + return posts.stream() + .map(PostResponse::from) + .toList(); + } + + /** 멤버의 댓글 보기 */ + @GetMapping("/{memberId}/comments") + public List comments(@PathVariable Long memberId) { + List comments = memberService.getComments(memberId); + return comments.stream() + .map(CommentResponse::from) + .toList(); + } + + /** 멤버가 좋아요 누른 게시글 보기 */ + @GetMapping("/{memberId}/likes") + public List likedPosts(@PathVariable Long memberId) { + List likes = memberService.getLikes(memberId); + return likes.stream() + .map(pl -> PostResponse.from(pl.getPost())) + .toList(); + } +} diff --git a/src/main/java/com/example/devSns/web/PostController.java b/src/main/java/com/example/devSns/web/PostController.java new file mode 100644 index 0000000..928290a --- /dev/null +++ b/src/main/java/com/example/devSns/web/PostController.java @@ -0,0 +1,58 @@ +package com.example.devSns.web; + +import com.example.devSns.domain.Post; +import com.example.devSns.service.PostService; +import com.example.devSns.web.dto.PostCreateRequest; +import com.example.devSns.web.dto.PostUpdateRequest; +import com.example.devSns.web.dto.PostResponse; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/posts") +public class PostController { + + private final PostService svc; + + public PostController(PostService svc) { + this.svc = svc; + } + + @GetMapping + public List list() { + return svc.findAll().stream().map(PostResponse::from).toList(); + } + + @GetMapping("/{id}") + public PostResponse get(@PathVariable Long id) { + Post p = svc.findById(id); + return PostResponse.from(p); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public PostResponse create(@Valid @RequestBody PostCreateRequest req) { + Post created = svc.create( + req.memberId(), + req.title(), + req.content() + ); + return PostResponse.from(created); + } + + + @PutMapping("/{id}") + public PostResponse update(@PathVariable Long id, @Valid @RequestBody PostUpdateRequest req) { + Post updated = svc.update(id, req.title(), req.content()); + return PostResponse.from(updated); + } + + @ResponseStatus(HttpStatus.NO_CONTENT) + @DeleteMapping("/{id}") + public void delete(@PathVariable Long id) { + svc.delete(id); + } +} diff --git a/src/main/java/com/example/devSns/web/PostLikeController.java b/src/main/java/com/example/devSns/web/PostLikeController.java new file mode 100644 index 0000000..e1781c6 --- /dev/null +++ b/src/main/java/com/example/devSns/web/PostLikeController.java @@ -0,0 +1,40 @@ +package com.example.devSns.web; + +import com.example.devSns.service.PostLikeService; +import com.example.devSns.web.dto.LikeRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/posts/{postId}/likes") +public class PostLikeController { + + private final PostLikeService postLikeService; + + public PostLikeController(PostLikeService postLikeService) { + this.postLikeService = postLikeService; + } + + /** 좋아요 누르기 */ + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void like(@PathVariable Long postId, + @Valid @RequestBody LikeRequest req) { + postLikeService.like(req.memberId(), postId); + } + + /** 좋아요 취소 */ + @DeleteMapping + @ResponseStatus(HttpStatus.NO_CONTENT) + public void unlike(@PathVariable Long postId, + @Valid @RequestBody LikeRequest req) { + postLikeService.unlike(req.memberId(), postId); + } + + /** 게시글 좋아요 개수 */ + @GetMapping("/count") + public long count(@PathVariable Long postId) { + return postLikeService.countLikes(postId); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java new file mode 100644 index 0000000..8a1b886 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentCreateRequest.java @@ -0,0 +1,9 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record CommentCreateRequest( + @NotNull Long memberId, + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/CommentResponse.java b/src/main/java/com/example/devSns/web/dto/CommentResponse.java new file mode 100644 index 0000000..19eb489 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentResponse.java @@ -0,0 +1,12 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Comment; + +public record CommentResponse( + Long id, + String content +) { + public static CommentResponse from(Comment c) { + return new CommentResponse(c.getId(), c.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..a80b258 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/CommentUpdateRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest( + @NotBlank String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/LikeRequest.java b/src/main/java/com/example/devSns/web/dto/LikeRequest.java new file mode 100644 index 0000000..04f4627 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LikeRequest.java @@ -0,0 +1,7 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotNull; + +public record LikeRequest( + @NotNull Long memberId +) {} diff --git a/src/main/java/com/example/devSns/web/dto/LoginRequest.java b/src/main/java/com/example/devSns/web/dto/LoginRequest.java new file mode 100644 index 0000000..0fc2484 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LoginRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank String username, + @NotBlank String password +) {} diff --git a/src/main/java/com/example/devSns/web/dto/LoginResponse.java b/src/main/java/com/example/devSns/web/dto/LoginResponse.java new file mode 100644 index 0000000..daf487c --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/LoginResponse.java @@ -0,0 +1,21 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Member; + +public record LoginResponse( + String accessToken, + String tokenType, + String refreshToken, + Long memberId, + String username +) { + public static LoginResponse of(String accessToken, String refreshToken, Member member) { + return new LoginResponse( + accessToken, + "Bearer", + refreshToken, + member.getId(), + member.getUsername() + ); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java new file mode 100644 index 0000000..ffaf0bb --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/MemberCreateRequest.java @@ -0,0 +1,10 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MemberCreateRequest( + @NotBlank String username, + @NotBlank String password, + String nickname, + String bio +) {} diff --git a/src/main/java/com/example/devSns/web/dto/MemberResponse.java b/src/main/java/com/example/devSns/web/dto/MemberResponse.java new file mode 100644 index 0000000..8584324 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/MemberResponse.java @@ -0,0 +1,19 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Member; + +public record MemberResponse( + Long id, + String username, + String nickname, + String bio +) { + public static MemberResponse from(Member m) { + return new MemberResponse( + m.getId(), + m.getUsername(), + m.getNickname(), + m.getBio() + ); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java new file mode 100644 index 0000000..ebfd6b5 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostCreateRequest.java @@ -0,0 +1,10 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record PostCreateRequest( + @NotNull Long memberId, + @NotBlank String title, + String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/PostResponse.java b/src/main/java/com/example/devSns/web/dto/PostResponse.java new file mode 100644 index 0000000..0130185 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostResponse.java @@ -0,0 +1,13 @@ +package com.example.devSns.web.dto; + +import com.example.devSns.domain.Post; + +public record PostResponse( + Long id, + String title, + String content +) { + public static PostResponse from(Post p) { + return new PostResponse(p.getId(), p.getTitle(), p.getContent()); + } +} diff --git a/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java new file mode 100644 index 0000000..1315608 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/PostUpdateRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostUpdateRequest( + @NotBlank String title, + String content +) {} diff --git a/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java b/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..6f134d3 --- /dev/null +++ b/src/main/java/com/example/devSns/web/dto/TokenRefreshRequest.java @@ -0,0 +1,8 @@ +package com.example.devSns.web.dto; + +import jakarta.validation.constraints.NotBlank; + +public record TokenRefreshRequest( + @NotBlank String refreshToken +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..c7a94b3 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,18 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;MODE=PostgreSQL + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: update + show-sql: true + h2: + console: + enabled: true + +jwt: + secret: zwKNCyglJK435Wj0ylqi/KF5xubUPnzYOQj8Jkl9QWxea4xj100mCRA9tNXjldjvDaQWo9neQCutSAjNd+N4uw== + access-token-validity-in-seconds: 3600 + refresh-token-validity-in-seconds: 1209600 diff --git a/src/test/java/com/example/devSns/web/CommentControllerTest.java b/src/test/java/com/example/devSns/web/CommentControllerTest.java new file mode 100644 index 0000000..caa6c46 --- /dev/null +++ b/src/test/java/com/example/devSns/web/CommentControllerTest.java @@ -0,0 +1,98 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional +class CommentControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + long postId; + + @BeforeEach + void setUp() throws Exception { + // 테스트용 게시글 하나 생성 + MvcResult res = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","댓글 테스트 글","content","본문"))) + ).andExpect(status().isCreated()) + .andReturn(); + + postId = om.readTree(res.getResponse().getContentAsString()).get("id").asLong(); + } + + @Test + void comment_CRUD_flow() throws Exception { + // CREATE + MvcResult create = mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","첫 댓글"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.content").value("첫 댓글")) + .andReturn(); + + long commentId = om.readTree(create.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts/{postId}/comments", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(commentId)); + + // DETAIL + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("첫 댓글")); + + // UPDATE + mockMvc.perform( + put("/posts/{postId}/comments/{commentId}", postId, commentId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","수정된 댓글"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.content").value("수정된 댓글")); + + // DELETE + mockMvc.perform(delete("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{postId}/comments/{commentId}", postId, commentId)) + .andExpect(status().isNotFound()); + } + + @Test + void comment_create_validation_fail_when_blank() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content",""))) + ).andExpect(status().isBadRequest()); + } + + @Test + void comment_404_when_post_not_found() throws Exception { + mockMvc.perform( + post("/posts/{postId}/comments", 999_999L) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("content","x"))) + ).andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/example/devSns/web/PostControllerTest.java b/src/test/java/com/example/devSns/web/PostControllerTest.java new file mode 100644 index 0000000..b4f7580 --- /dev/null +++ b/src/test/java/com/example/devSns/web/PostControllerTest.java @@ -0,0 +1,77 @@ +package com.example.devSns.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.*; + +import jakarta.transaction.Transactional; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Transactional // 각 테스트 후 롤백 +class PostControllerTest { + + @Autowired MockMvc mockMvc; + @Autowired ObjectMapper om; + + @Test + void post_CRUD_flow() throws Exception { + // CREATE + MvcResult createRes = mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","첫 글","content","내용입니다"))) + ).andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.title").value("첫 글")) + .andReturn(); + + long postId = om.readTree(createRes.getResponse().getContentAsString()).get("id").asLong(); + + // LIST + mockMvc.perform(get("/posts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").exists()); + + // DETAIL + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("첫 글")); + + // UPDATE + mockMvc.perform( + put("/posts/{id}", postId) + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","수정 제목","content","수정 내용"))) + ).andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("수정 제목")) + .andExpect(jsonPath("$.content").value("수정 내용")); + + // DELETE + mockMvc.perform(delete("/posts/{id}", postId)) + .andExpect(status().isNoContent()); + + // 404 확인 + mockMvc.perform(get("/posts/{id}", postId)) + .andExpect(status().isNotFound()); + } + + @Test + void post_create_validation_fail_when_title_blank() throws Exception { + mockMvc.perform( + post("/posts") + .contentType(MediaType.APPLICATION_JSON) + .content(om.writeValueAsString(Map.of("title","", "content","x"))) + ).andExpect(status().isBadRequest()); + } +}