diff --git a/build.gradle b/build.gradle index 6afc82e..c7e8077 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,14 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.mapstruct:mapstruct:1.5.1.Final' annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'com.google.code.gson:gson' + + 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' } tasks.named('javadoc') { diff --git a/src/main/java/com/springboot/advice/GlobalExceptionAdvice.java b/src/main/java/com/springboot/advice/GlobalExceptionAdvice.java new file mode 100644 index 0000000..bd3a0fb --- /dev/null +++ b/src/main/java/com/springboot/advice/GlobalExceptionAdvice.java @@ -0,0 +1,90 @@ +package com.springboot.advice; + +import com.springboot.exception.BusinessLogicException; +import com.springboot.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import javax.validation.ConstraintViolationException; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionAdvice { + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMethodArgumentNotValidException( + MethodArgumentNotValidException e) { + final ErrorResponse response = ErrorResponse.of(e.getBindingResult()); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleConstraintViolationException( + ConstraintViolationException e) { + final ErrorResponse response = ErrorResponse.of(e.getConstraintViolations()); + + return response; + } + + @ExceptionHandler + public ResponseEntity handleBusinessLogicException(BusinessLogicException e) { + final ErrorResponse response = ErrorResponse.of(e.getExceptionCode()); + + return new ResponseEntity<>(response, HttpStatus.valueOf(e.getExceptionCode() + .getStatus())); + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED) + public ErrorResponse handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.METHOD_NOT_ALLOWED); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleHttpMessageNotReadableException( + HttpMessageNotReadableException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.BAD_REQUEST, + "Required request body is missing"); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + + final ErrorResponse response = ErrorResponse.of(HttpStatus.BAD_REQUEST, + e.getMessage()); + + return response; + } + + @ExceptionHandler + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleException(Exception e) { + log.error("# handle Exception", e); + // TODO 애플리케이션의 에러는 에러 로그를 로그에 기록하고, 관리자에게 이메일이나 카카오 톡, + // 슬랙 등으로 알려주는 로직이 있는게 좋습니다. + + final ErrorResponse response = ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR); + + return response; + } +} diff --git a/src/main/java/com/springboot/audit/Auditable.java b/src/main/java/com/springboot/audit/Auditable.java new file mode 100644 index 0000000..7ca7797 --- /dev/null +++ b/src/main/java/com/springboot/audit/Auditable.java @@ -0,0 +1,28 @@ +package com.springboot.audit; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import javax.persistence.Column; +import javax.persistence.EntityListeners; +import javax.persistence.MappedSuperclass; +import java.time.LocalDateTime; + +@Getter +@Setter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class Auditable { + + @CreatedDate + @Column(name ="created_at", updatable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @LastModifiedDate + @Column(name = "LAST_MODIFIED_AT") + private LocalDateTime modifiedAt = LocalDateTime.now(); +} diff --git a/src/main/java/com/springboot/auth/dto/LoginDto.java b/src/main/java/com/springboot/auth/dto/LoginDto.java new file mode 100644 index 0000000..03c3c2c --- /dev/null +++ b/src/main/java/com/springboot/auth/dto/LoginDto.java @@ -0,0 +1,9 @@ +package com.springboot.auth.dto; + +import lombok.Getter; + +@Getter +public class LoginDto { + private String username; + private String password; +} diff --git a/src/main/java/com/springboot/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/springboot/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..812651b --- /dev/null +++ b/src/main/java/com/springboot/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,71 @@ +package com.springboot.auth.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.springboot.auth.dto.LoginDto; +import com.springboot.auth.jwt.JwtTokenizer; +import com.springboot.member.entity.Member; +import lombok.SneakyThrows; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + private final AuthenticationManager authenticationManager; + private final JwtTokenizer jwtTokenizer; + + + public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) { + this.authenticationManager = authenticationManager; + this.jwtTokenizer = jwtTokenizer; + } + + @Override + @SneakyThrows + public Authentication attemptAuthentication (HttpServletRequest request, HttpServletResponse response) { + ObjectMapper objectMapper = new ObjectMapper(); + LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class); + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(loginDto.getUsername(),loginDto.getPassword()); + return authenticationManager.authenticate(authenticationToken); + } + + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { + Member member = (Member) authResult.getPrincipal(); + String accessToken = delegateAccessToken(member); + String refreshToken = delegateRefreshToken(member); + response.setHeader("Authorization", "Bearer " + accessToken); + response.setHeader("Refresh", refreshToken); +// this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult); + } + + protected String delegateAccessToken (Member member) { + Map claims = new HashMap<>(); + claims.put("username", member.getEmail()); + claims.put("roles", member.getRoles()); + String subject = member.getEmail(); + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes()); + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String accessToken = jwtTokenizer.generateAccessToken(claims, subject,expiration,base64EncodedSecretKey); + return accessToken; + } + protected String delegateRefreshToken (Member member) { + String subject = member.getEmail(); + Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes()); + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + + String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey); + return refreshToken; + } +} diff --git a/src/main/java/com/springboot/auth/filter/JwtVerificationFilter.java b/src/main/java/com/springboot/auth/filter/JwtVerificationFilter.java new file mode 100644 index 0000000..f473e60 --- /dev/null +++ b/src/main/java/com/springboot/auth/filter/JwtVerificationFilter.java @@ -0,0 +1,64 @@ +package com.springboot.auth.filter; + +import com.springboot.auth.jwt.JwtTokenizer; +import com.springboot.auth.utils.JwtAuthorityUtils; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +public class JwtVerificationFilter extends OncePerRequestFilter { + private final JwtTokenizer jwtTokenizer; + private final JwtAuthorityUtils jwtAuthorityUtils; + + public JwtVerificationFilter(JwtTokenizer jwtTokenizer, JwtAuthorityUtils jwtAuthorityUtils) { + this.jwtTokenizer = jwtTokenizer; + this.jwtAuthorityUtils = jwtAuthorityUtils; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + // 검증된 claims 를 데리고 와서 + Map claims = verifyJws(request); + // claims 를 넣고 Context 를 만들어줌. + setAuthenticationToContext(claims); + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter (HttpServletRequest request) throws ServletException { + String authorization = request.getHeader("Authorization"); + return authorization == null || !authorization.startsWith("Bearer"); + } + + // 요청 헤더에서 Jws 를 데리고 옴. + // getClaims 로 검증을 함. + private Map verifyJws (HttpServletRequest request) { + String jws = request.getHeader("Authorization").replace("Bearer ", ""); + String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey()); + // 검증된 claims 를 받음. claims 에 있는 body 를 데리고 오기 때문에 getBody() + Map claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody(); + return claims; + } + + private void setAuthenticationToContext (Map claims) { + String username = (String) claims.get("username"); + // claims 에 있는 Roles 정보를 데리고 와서, Authorities 를 만든다. + List authorities = jwtAuthorityUtils.createAuthorities((List)claims.get("roles")); + // 이걸 Authentication 객체로 만듦. + Authentication authentication = new UsernamePasswordAuthenticationToken( + username, null, authorities); + //Security Context 에 만든 authentication 을 넣음. + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/src/main/java/com/springboot/auth/handler/MemberAuthenticationFailureHandler.java b/src/main/java/com/springboot/auth/handler/MemberAuthenticationFailureHandler.java new file mode 100644 index 0000000..8e97442 --- /dev/null +++ b/src/main/java/com/springboot/auth/handler/MemberAuthenticationFailureHandler.java @@ -0,0 +1,32 @@ +package com.springboot.auth.handler; + +import com.google.gson.Gson; +import com.springboot.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class MemberAuthenticationFailureHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + log.info("Authenticated failed"); + log.error("Authenticated failed", exception.getMessage()); + sendErrorResponse(response); + } + + private void sendErrorResponse(HttpServletResponse response) throws IOException { + Gson gson = new Gson(); + ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.getWriter().write(gson.toJson(errorResponse, ErrorResponse.class)); + } +} diff --git a/src/main/java/com/springboot/auth/handler/MemberAuthenticationSuccessHandler.java b/src/main/java/com/springboot/auth/handler/MemberAuthenticationSuccessHandler.java new file mode 100644 index 0000000..012834f --- /dev/null +++ b/src/main/java/com/springboot/auth/handler/MemberAuthenticationSuccessHandler.java @@ -0,0 +1,18 @@ +package com.springboot.auth.handler; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +@Slf4j +public class MemberAuthenticationSuccessHandler implements AuthenticationSuccessHandler { + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.info("Authenticated Success"); + } +} diff --git a/src/main/java/com/springboot/auth/jwt/JwtTokenizer.java b/src/main/java/com/springboot/auth/jwt/JwtTokenizer.java new file mode 100644 index 0000000..c6cfcc0 --- /dev/null +++ b/src/main/java/com/springboot/auth/jwt/JwtTokenizer.java @@ -0,0 +1,94 @@ +package com.springboot.auth.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.Encoders; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Calendar; +import java.util.Date; +import java.util.Map; + +@Component +public class JwtTokenizer { + @Getter + @Value("${jwt.key}") + private String secretKey; + + @Getter + @Value("${jwt.access-token-expiration-minutes}") + private int accessTokenExpirationMinutes; + + @Getter + @Value("${jwt.refresh-token-expiration-minutes}") + private int refreshTokenExpirationMinutes; + + public String encodeBase64SecretKey (String secretKey) { + // secretKey를 암호화. + return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8)); + } + // 해시함수로 암호화한 키. + public Key getKeyFromBase64EncodeKey (String base64EncodedSecretKey) { + // 디코딩 한 후에 + byte[] keyBytes = Decoders.BASE64URL.decode(base64EncodedSecretKey); + // 다시 해시 함수로 암호화. + Key key = Keys.hmacShaKeyFor(keyBytes); + return key; + } + //AccessToken 받기. + public String generateAccessToken (Map claims, + String subject, + Date expiration, String base64EncodedSecretKey) { + Key key = getKeyFromBase64EncodeKey(base64EncodedSecretKey); + return Jwts.builder() + .setClaims(claims) + .setSubject(subject) + .setIssuedAt(Calendar.getInstance().getTime()) + .setExpiration(expiration) + .signWith(key) + .compact(); + } + public String generateRefreshToken(String subject, + Date expiration, String base64EncodedSecretKey) { + Key key = getKeyFromBase64EncodeKey(base64EncodedSecretKey); + return Jwts.builder() + .setSubject(subject) + .setIssuedAt(Calendar.getInstance().getTime()) + .setExpiration(expiration) + .signWith(key) + .compact(); + } + + //검증된 jwt만 받아짐. + public Jws getClaims (String jws, String baseEncodedSecretKey) { + Key key = getKeyFromBase64EncodeKey(baseEncodedSecretKey); + Jws claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(jws); + + return claims; + } + + public void verifySignature (String jws, String base64EncodedSecretKey) { + Key key = getKeyFromBase64EncodeKey(base64EncodedSecretKey); + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(jws); + } + + public Date getTokenExpiration (int expirationMinutes) { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.MINUTE, expirationMinutes); + Date expiration = calendar.getTime(); + return expiration; + } +} diff --git a/src/main/java/com/springboot/auth/userDetails/MemberDetailsService.java b/src/main/java/com/springboot/auth/userDetails/MemberDetailsService.java new file mode 100644 index 0000000..b4d17bd --- /dev/null +++ b/src/main/java/com/springboot/auth/userDetails/MemberDetailsService.java @@ -0,0 +1,73 @@ +package com.springboot.auth.userDetails; + +import com.springboot.auth.utils.JwtAuthorityUtils; +import com.springboot.exception.BusinessLogicException; +import com.springboot.exception.ExceptionCode; +import com.springboot.member.entity.Member; +import com.springboot.member.repository.MemberRepository; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import java.util.Collection; +import java.util.Optional; + + +@Component +public class MemberDetailsService implements UserDetailsService { + private final MemberRepository memberRepository; + private final JwtAuthorityUtils authorityUtils; + + public MemberDetailsService(MemberRepository memberRepository, JwtAuthorityUtils authorityUtils) { + this.memberRepository = memberRepository; + this.authorityUtils = authorityUtils; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional optionalMember = memberRepository.findByEmail(username); + Member findMember = optionalMember.orElseThrow( () -> + new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return new MemberDetails(findMember); + } + private final class MemberDetails extends Member implements UserDetails { + public MemberDetails(Member member) { + setMemberId(member.getMemberId()); + setEmail(member.getEmail()); + setPassword(member.getPassword()); + setRoles(member.getRoles()); + } + + @Override + public Collection getAuthorities() { + return authorityUtils.createAuthorities(this.getRoles()); + } + + @Override + public String getUsername() { + return getEmail(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } + } +} diff --git a/src/main/java/com/springboot/auth/utils/JwtAuthorityUtils.java b/src/main/java/com/springboot/auth/utils/JwtAuthorityUtils.java new file mode 100644 index 0000000..28be4b0 --- /dev/null +++ b/src/main/java/com/springboot/auth/utils/JwtAuthorityUtils.java @@ -0,0 +1,37 @@ +package com.springboot.auth.utils; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +public class JwtAuthorityUtils { + @Value("${mail.address.admin}") + private String adminMailAddress; + + private final List ADMIN_ROLES = + AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_USER"); + private final List USER_ROLES = + AuthorityUtils.createAuthorityList("ROLE_USER"); + private final List ADMIN_ROLES_STRING = + List.of("ADMIN", "USER"); + private final List USER_ROLES_STRING = + List.of("USER"); + + public List createAuthorities (List roles) { + return roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_"+ role)) + .collect(Collectors.toList()); + } + public List createRoles (String email) { + if(email.equals(adminMailAddress)) { + return ADMIN_ROLES_STRING; + } + return USER_ROLES_STRING; + } + } diff --git a/src/main/java/com/springboot/board/controller/BoardController.java b/src/main/java/com/springboot/board/controller/BoardController.java new file mode 100644 index 0000000..c25a2b1 --- /dev/null +++ b/src/main/java/com/springboot/board/controller/BoardController.java @@ -0,0 +1,115 @@ +package com.springboot.board.controller; + +import com.springboot.board.dto.*; +import com.springboot.board.entity.Board; +import com.springboot.board.entity.Like; +import com.springboot.board.mapper.BoardMapper; +import com.springboot.board.service.BoardService; +import com.springboot.dto.MultiResponseDto; +import com.springboot.dto.SingleResponseDto; +import com.springboot.member.entity.Member; +import com.springboot.member.service.MemberService; +import com.springboot.utils.UriCreator; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.Positive; +import java.net.URI; +import java.util.List; + +@RestController +@RequestMapping("/v2/boards") +@Validated +public class BoardController { + private final static String BOARD_DEFAULT_URL = "/v2/boards"; + + private final BoardService boardService; + private final MemberService memberService; + private final BoardMapper mapper; + + public BoardController(BoardService boardService, MemberService memberService, BoardMapper mapper) { + this.boardService = boardService; + this.memberService = memberService; + this.mapper = mapper; + } + + @PostMapping + public ResponseEntity postBoard (@Valid @RequestBody BoardPostDto boardPostDto, + Authentication authentication) { + // 메퍼로 먼저 감싼 다음에, 서비스에 적용. + Board board = mapper.boardPostDtoToBoard(boardPostDto); + + Board createdBoard = boardService.createBoard(board,authentication); +// Member member = new Member(); +// member.setMemberId(board.getMember().getMemberId()); +// Board createdBoard = boardService.createBoard(board, authentication); +// board.setMember(member); + + URI location = UriCreator.createUri(BOARD_DEFAULT_URL, createdBoard.getBoardId()); + return ResponseEntity.created(location).build(); + } + + @PostMapping("/{board-id}/like") + public ResponseEntity postLike (@PathVariable("board-id") @Positive long boardId, + Authentication authentication) { + + boardService.createLike(boardId, authentication); + + return new ResponseEntity(HttpStatus.OK); + } + + + @PatchMapping("/{board-id}") + public ResponseEntity patchBoard (@PathVariable("board-id") @Positive long boardId, + @Valid @RequestBody BoardPatchDto boardPatchDto, + Authentication authentication) { + boardPatchDto.setBoardId(boardId); + // 질문을 등록한 회원만 수정. + Board board = boardService.updateBoard(mapper.boardPatchDtoTOBoard(boardPatchDto), authentication); + BoardResponseDto boardResponseDto = mapper.boardToBoardResponseDto(board); + + return new ResponseEntity<>( + new SingleResponseDto<>(boardResponseDto), HttpStatus.OK); + } + + @GetMapping("/{board-id}") + public ResponseEntity getBoard ( @PathVariable("board-id") @Positive long boardId, + Authentication authentication) { + String email = (String) authentication.getPrincipal(); + Board board = boardService.findBoard(boardId,authentication); + BoardResponseDto boardResponseDto = mapper.boardToBoardResponseDto(board); + return new ResponseEntity<>( + new SingleResponseDto<>(boardResponseDto), HttpStatus.OK); + } + + @GetMapping + public ResponseEntity getBoards (@Positive @RequestParam int page, + @Positive @RequestParam int size, + // desc, asc + @RequestParam String sort) { + Page pageBoards = boardService.findBoards(page, size, sort); + List boards = pageBoards.getContent(); + List responseDtos = mapper.boardsToBoardResponseDtos(boards); + + return new ResponseEntity<>( + new MultiResponseDto<>(responseDtos,pageBoards), HttpStatus.OK ); + } + + + // 질문 삭제 상태. + // 회원 탈퇴시 질문 비활성화. >> memberService + @DeleteMapping("{member-id}") + public ResponseEntity deleteBoard ( @PathVariable("member-id") @Positive long boardId, + Authentication authentication) { + boardService.deleteBoard(boardId, authentication); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + } + + +} diff --git a/src/main/java/com/springboot/board/dto/BoardGetDto.java b/src/main/java/com/springboot/board/dto/BoardGetDto.java new file mode 100644 index 0000000..9cdc5a2 --- /dev/null +++ b/src/main/java/com/springboot/board/dto/BoardGetDto.java @@ -0,0 +1,10 @@ +package com.springboot.board.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BoardGetDto { + private Long memberId; +} diff --git a/src/main/java/com/springboot/board/dto/BoardPatchDto.java b/src/main/java/com/springboot/board/dto/BoardPatchDto.java new file mode 100644 index 0000000..fabce6e --- /dev/null +++ b/src/main/java/com/springboot/board/dto/BoardPatchDto.java @@ -0,0 +1,19 @@ +package com.springboot.board.dto; + +import com.springboot.board.entity.Board; +import lombok.Getter; + +import javax.validation.constraints.NotBlank; + +@Getter +public class BoardPatchDto { + private long boardId; + + private String title; + private String content; + private Board.VisibilityStatus visibilityStatus; + + public void setBoardId(long boardId) { + this.boardId = boardId; + } +} diff --git a/src/main/java/com/springboot/board/dto/BoardPostDto.java b/src/main/java/com/springboot/board/dto/BoardPostDto.java new file mode 100644 index 0000000..2836d4a --- /dev/null +++ b/src/main/java/com/springboot/board/dto/BoardPostDto.java @@ -0,0 +1,22 @@ +package com.springboot.board.dto; + +import com.springboot.board.entity.Board; +import lombok.Getter; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Positive; + +@Getter +public class BoardPostDto { +// @Positive +// private long memberId; + + @NotBlank + private String title; + + @NotBlank + private String content; + + private Board.VisibilityStatus visibilityStatus = Board.VisibilityStatus.PUBLIC; + +} diff --git a/src/main/java/com/springboot/board/dto/BoardResponseDto.java b/src/main/java/com/springboot/board/dto/BoardResponseDto.java new file mode 100644 index 0000000..3d86ea5 --- /dev/null +++ b/src/main/java/com/springboot/board/dto/BoardResponseDto.java @@ -0,0 +1,28 @@ +package com.springboot.board.dto; + +import com.springboot.board.entity.Board; +import com.springboot.comment.dto.CommentResponseDto; +import lombok.*; + +import javax.persistence.Column; +import javax.validation.constraints.NotBlank; +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor +@Setter +@Getter +public class BoardResponseDto { + private long boardId; + private long memberId; + private String title; + private String content; + private int likeCount ; + private int viewCount ; + private Board.QuestionStatus questionStatus; + private Board.VisibilityStatus visibilityStatus; + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + private CommentResponseDto boardComment; + +} diff --git a/src/main/java/com/springboot/board/dto/LikePostDto.java b/src/main/java/com/springboot/board/dto/LikePostDto.java new file mode 100644 index 0000000..344a251 --- /dev/null +++ b/src/main/java/com/springboot/board/dto/LikePostDto.java @@ -0,0 +1,12 @@ +package com.springboot.board.dto; + +import lombok.Getter; +import lombok.Setter; + +import javax.validation.constraints.NotBlank; + +@Getter +@Setter +public class LikePostDto { + +} diff --git a/src/main/java/com/springboot/board/entity/Board.java b/src/main/java/com/springboot/board/entity/Board.java new file mode 100644 index 0000000..47a1528 --- /dev/null +++ b/src/main/java/com/springboot/board/entity/Board.java @@ -0,0 +1,130 @@ +package com.springboot.board.entity; + +import com.springboot.audit.Auditable; +import com.springboot.comment.entity.Comment; +import com.springboot.member.entity.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@Entity +public class Board extends Auditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long boardId; + + @Column(length = 100, nullable = false) + private String title; + + @Column(nullable = false) + private String content; + + @Column + private int likeCount = 0; + + @Column + private int viewCount = 0; + + @ManyToOne + @JoinColumn(name = "MEMBER_ID") + private Member member; + public void setMember(Member member) { + this.member = member; + if (!member.getBoards().contains(this)) { + member.setBoards(this); + } + } + + @OneToOne(mappedBy = "board") + private Comment comment; + public void setComment(Comment comment) { + this.comment = comment; + if (comment.getBoard() != this) { + comment.setBoard(this); + } + } + public void deleteComment (Comment comment) { + this.comment = null; + if (comment.getBoard() == this) { + comment.deleteBoard(this); + } + } + + @OneToOne(cascade = CascadeType.ALL) + private Like like; + public void setLike(Like like) { + this.like = like; + if (like.getBoard() != this) { + like.setBoard(this); + } + } + public void deleteLike(Like like) { + this.like = null; + if (like.getBoard() == this) { + like.deleteBoard(this); + } + } + + @OneToOne + private View view; + public void setView (View view) { + this.view = view; + if(view.getBoard() != this) { + view.setBoard(this); + } + } + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private QuestionStatus questionStatus = QuestionStatus.QUESTION_REGISTERED; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private VisibilityStatus visibilityStatus = VisibilityStatus.PUBLIC; + + public enum QuestionStatus { + QUESTION_REGISTERED(1, "질문 등록"), + QUESTION_ANSWERED(2, "답변 완료"), + QUESTION_DELETED(3, "질문 삭제"), + // 탈퇴했을 때. + QUESTION_DEACTIVED(4, "질문 비활성화"); + + @Getter + private int QuestionNumber; + + @Getter + private String Status; + + QuestionStatus(int QuestionNumber, String questionStatus) { + this.QuestionNumber = QuestionNumber; + this.Status = Status; + } + } + + public enum VisibilityStatus { + PUBLIC("공개글"), + SECRET("비밀글"); + + @Getter + private String visibilityStatus; + + VisibilityStatus(String visibilityStatus) { + this.visibilityStatus = visibilityStatus; + } + } + + public void increasedCount(){ + this.likeCount++; + } + + public void decreaseCount(){ + this.likeCount--; + } + +} \ No newline at end of file diff --git a/src/main/java/com/springboot/board/entity/Like.java b/src/main/java/com/springboot/board/entity/Like.java new file mode 100644 index 0000000..e084351 --- /dev/null +++ b/src/main/java/com/springboot/board/entity/Like.java @@ -0,0 +1,55 @@ +package com.springboot.board.entity; + +import com.springboot.audit.Auditable; +import com.springboot.comment.entity.Comment; +import com.springboot.member.entity.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.mapstruct.Mapping; + +import javax.persistence.*; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "likes") +public class Like { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long likeId; + + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "BOARD_ID") + private Board board; + public void setBoard (Board board) { + this.board = board; + if(board.getLike() != this) { + board.setLike(this); + } + } + public void deleteBoard (Board board) { + this.board = null; + if(board.getLike() == this) { + board.deleteLike(this); + } + } + + @ManyToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "MEMBER_ID") + private Member member; + public void setMember (Member member) { + this.member = member; + if(!member.getLikes().contains(this)) { + member.setLikes(this); + } + } + + public void deleteMember (Member member) { + this.member = null; + if(member.getLikes().contains(this)) { + member.deleteLikes(this); + } + } +} diff --git a/src/main/java/com/springboot/board/entity/View.java b/src/main/java/com/springboot/board/entity/View.java new file mode 100644 index 0000000..006652d --- /dev/null +++ b/src/main/java/com/springboot/board/entity/View.java @@ -0,0 +1,40 @@ +package com.springboot.board.entity; + +import com.springboot.audit.Auditable; +import com.springboot.member.entity.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; + +@Getter +@Setter +@NoArgsConstructor +@Entity +public class View{ + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long viewId; + + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "BOARD_ID") + private Board board; + public void setBoard (Board board) { + this.board = board; + if(board.getView() != this) { + board.setView(this); + } + } + + @OneToOne(cascade = CascadeType.PERSIST) + @JoinColumn(name = "MEMBER_ID") + private Member member; + public void setMember (Member member) { + this.member = member; + if(!member.getViews().contains(this)) { + member.setViews(this); + } + } +} diff --git a/src/main/java/com/springboot/board/mapper/BoardMapper.java b/src/main/java/com/springboot/board/mapper/BoardMapper.java new file mode 100644 index 0000000..695fa9e --- /dev/null +++ b/src/main/java/com/springboot/board/mapper/BoardMapper.java @@ -0,0 +1,26 @@ +package com.springboot.board.mapper; + +import com.springboot.board.dto.*; +import com.springboot.board.entity.Board; +import com.springboot.board.entity.Like; +import com.springboot.board.entity.View; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface BoardMapper { + Board boardPostDtoToBoard (BoardPostDto boardPostDto); + Board boardPatchDtoTOBoard (BoardPatchDto boardPatchDto); + + @Mapping(source = "member.memberId", target = "memberId") + @Mapping(source = "comment.content", target = "boardComment.content") + @Mapping(source = "comment.member.memberId", target = "boardComment.memberId") + BoardResponseDto boardToBoardResponseDto (Board board); + List boardsToBoardResponseDtos (List boards); + + @Mapping(source = "memberId", target = "member.memberId") + Board BoardGetDtoToBoard (BoardGetDto viewGetDto); + +} diff --git a/src/main/java/com/springboot/board/repository/BoardRepository.java b/src/main/java/com/springboot/board/repository/BoardRepository.java new file mode 100644 index 0000000..3d2482c --- /dev/null +++ b/src/main/java/com/springboot/board/repository/BoardRepository.java @@ -0,0 +1,14 @@ +package com.springboot.board.repository; + +import com.springboot.board.entity.Board; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface BoardRepository extends JpaRepository { + Board findByQuestionStatusNotAndQuestionStatusNot(Board.QuestionStatus questionStatus1, Board.QuestionStatus QuestionStatus2); + Page findByQuestionStatusNotAndQuestionStatusNot(Board.QuestionStatus questionStatus1, Board.QuestionStatus QuestionStatus2, Pageable pageable); + +} diff --git a/src/main/java/com/springboot/board/repository/LikeRepository.java b/src/main/java/com/springboot/board/repository/LikeRepository.java new file mode 100644 index 0000000..81b3c46 --- /dev/null +++ b/src/main/java/com/springboot/board/repository/LikeRepository.java @@ -0,0 +1,12 @@ +package com.springboot.board.repository; + +import com.springboot.board.entity.Board; +import com.springboot.board.entity.Like; +import com.springboot.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface LikeRepository extends JpaRepository { + Optional findByMemberAndBoard(Member member, Board board); +} diff --git a/src/main/java/com/springboot/board/repository/ViewRepository.java b/src/main/java/com/springboot/board/repository/ViewRepository.java new file mode 100644 index 0000000..4b0faf8 --- /dev/null +++ b/src/main/java/com/springboot/board/repository/ViewRepository.java @@ -0,0 +1,12 @@ +package com.springboot.board.repository; + +import com.springboot.board.entity.Board; +import com.springboot.board.entity.View; +import com.springboot.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ViewRepository extends JpaRepository { + Optional findByMemberAndBoard(Member member, Board board); +} diff --git a/src/main/java/com/springboot/board/service/BoardService.java b/src/main/java/com/springboot/board/service/BoardService.java new file mode 100644 index 0000000..55c98bc --- /dev/null +++ b/src/main/java/com/springboot/board/service/BoardService.java @@ -0,0 +1,260 @@ +package com.springboot.board.service; + +import com.springboot.board.entity.Board; +import com.springboot.board.entity.Like; +import com.springboot.board.entity.View; +import com.springboot.board.repository.BoardRepository; +import com.springboot.board.repository.LikeRepository; +import com.springboot.board.repository.ViewRepository; +import com.springboot.comment.service.CommentService; +import com.springboot.exception.BusinessLogicException; +import com.springboot.exception.ExceptionCode; +import com.springboot.member.entity.Member; +import com.springboot.member.repository.MemberRepository; +import com.springboot.member.service.MemberService; +import com.springboot.page.SortType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static com.springboot.board.entity.Board.QuestionStatus.*; +import static com.springboot.page.SortType.*; + +@Service +public class BoardService { + private final BoardRepository boardRepository; + private final MemberService memberService; + private final CommentService commentService; + private final LikeRepository likeRepository; + private final MemberRepository memberRepository; + private final ViewRepository viewRepository; + + public BoardService(BoardRepository boardRepository, MemberService memberService, CommentService commentService, LikeRepository likeRepository, MemberRepository memberRepository, ViewRepository viewRepository) { + this.boardRepository = boardRepository; + this.memberService = memberService; + this.commentService = commentService; + this.likeRepository = likeRepository; + this.memberRepository = memberRepository; + this.viewRepository = viewRepository; + } + + public Board createBoard (Board board, Authentication authentication) { + if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))){ + throw new BusinessLogicException(ExceptionCode.NO_AUTHORITY); + } else { + String email = (String) authentication.getPrincipal(); + Member member = memberService.findVerifiedMember(email); + board.setMember(member); + + return boardRepository.save(board); + } + } + public void createLike (long boardId, Authentication authentication) { + + // 어떤 게 null 값이 나왔을 때 어떤 exception 코드를 전해야 하는가? + // board 에 있는 like 를 확인해야 해서 그런가? + // 받아온 like 에 board 를 가지고 옴. + // jpa 가 무조건. + Optional board = boardRepository.findById(boardId); + Board findBoard = board.orElseThrow(() -> new BusinessLogicException(ExceptionCode.BOARD_NOT_FOUND)); + Optional member = memberRepository.findByEmail((String) authentication.getPrincipal()); + Member findMember = member.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + + Optional findLike = likeRepository.findByMemberAndBoard(findMember, findBoard); + + if( findLike.isPresent()) { + // delete 를 해라. + Like deleteLike = findLike.orElseThrow (() -> new BusinessLogicException(ExceptionCode.LIKE_EXISTS)); + findBoard.deleteLike(deleteLike); + findMember.deleteLikes(deleteLike); + findBoard.decreaseCount(); + boardRepository.save(findBoard); + likeRepository.delete(deleteLike); + } else { + Like addlike= new Like(); + addlike.setBoard(findBoard); + addlike.setMember(findMember); + findBoard.increasedCount(); + likeRepository.save(addlike); + } + } + + public Board updateBoard (Board board, Authentication authentication) { + // 질문을 등록한 회원만 수정할 수 있음. + String email = (String) authentication.getPrincipal(); + Member member = memberService.findVerifiedMember(email); + Board findBoard = findVerifiedBoard(board.getBoardId()); + + if(findBoard.getMember() == member ) { + // 질문 수정 시 수정 날짜 업데이트. + findBoard.setModifiedAt(LocalDateTime.now()); + // 비밀글로 변경할 경우, 상태 변경 수정. + Optional.ofNullable(board.getVisibilityStatus()) + .ifPresent(visibilityStatus -> findBoard.setVisibilityStatus(visibilityStatus)); + Optional.ofNullable(board.getTitle()) + .ifPresent(title -> findBoard.setTitle(title)); + Optional.ofNullable(board.getContent()) + .ifPresent(content -> findBoard.setContent(content)); + } else { + new BusinessLogicException(ExceptionCode.DIFFERENT_USER); + } + // 답변 완료된 질문은 수정할 수 없음. + int questionNumber = findBoard.getQuestionStatus().getQuestionNumber(); + if (questionNumber == 2) { + throw new BusinessLogicException(ExceptionCode.CANNOT_CHANGE_BOARD); + } + + return boardRepository.save(findBoard); + + } + + public Board findBoard (long boardId, Authentication authentication) { + // 비밀글이면 질문을 등록한 회원과 관리자만 조회 가능. + // 답변이 존재하면 답변도 함께 조회해야 함. + Board findBoard = findVerifiedBoard(boardId); + // 비밀글을 제외한 다른 글들은 회원과 관리자 모두가 봐야 함. + + // 비밀글이면 + if(findBoard.getVisibilityStatus().equals(Board.VisibilityStatus.SECRET)) { + // 관리자와 질문을 등록한 회원은 볼 수 있음. 게시글의 작성자와 principal 이 같은지 확인. + if(isCheckBoardOwnerAndAdmin(authentication, findBoard.getMember())) { + createdView(authentication,findBoard); + return findBoard; + } else { + throw new BusinessLogicException(ExceptionCode.NO_AUTHORITY); + } + } else { + // 공개 일 때에는 다른 회원의 글도 볼 수 있어야 함....... + // 그냥 조회하면 되는 건가? + // 이거는 뷰에 대한 로직. + createdView (authentication, findBoard); + // 질문삭제, 비활성화 된 글은 보이도록 하지 않음. + // 이미 삭제 상태인 질문은 조회할 수 없다. + int questionNumber = findBoard.getQuestionStatus().getQuestionNumber(); + if (questionNumber >= 3) { + throw new BusinessLogicException(ExceptionCode.BOARD_NOT_FOUND); + } + return findBoard; + } + } + + public Page findBoards (int page, int size, String sort) { + // 여러 건의 질문 목록은 모두 조회 가능. + // 삭제 상태가 아닌 질문만 조회 가능. + // 답변도 함께 조회 해야. mapper responsedto . 를 변경. comment mapper 에서 + + // 페이지 네이션 처리. + // 최근 순, + // 오래 된 순. + // 정렬 조회. + SortType sortType = SortType.valueOf(sort); + Pageable pageable; + + switch (sortType) { + case TIME_ASC : + pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").ascending()); + break; + case TIME_DESC : + pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending()); + break; + case LIKE_ASC : + pageable = PageRequest.of(page - 1, size, Sort.by("likeCount").ascending()); + break; + case LIKE_DESC : + pageable = PageRequest.of(page - 1, size, Sort.by("likeCount").descending()); + break; + case VIEW_ASC : + pageable = PageRequest.of(page - 1, size, Sort.by("viewCount").ascending()); + break; + case VIEW_DESC : + pageable = PageRequest.of(page - 1, size, Sort.by("viewCount").descending()); + break; + default: + pageable = PageRequest.of(page -1, size, Sort.by("boardId").descending()); + } + return boardRepository + .findByQuestionStatusNotAndQuestionStatusNot(QUESTION_DELETED, QUESTION_DEACTIVED, pageable); + } + + public void deleteBoard (long boardId, Authentication authentication) { + // 질문 등록한 회원만 가능. + Board findBoard = findVerifiedBoard(boardId); + if(isCheckBoardOwner(authentication, findBoard.getMember())){ + int questionNumber = findBoard.getQuestionStatus().getQuestionNumber(); + if( questionNumber == 1) { + findBoard.setQuestionStatus(QUESTION_DELETED); + boardRepository.save(findBoard); + } + else if( questionNumber == 2) { + // 질문 삭제하면 질문 상태만 변경. + findBoard.setQuestionStatus(QUESTION_DELETED); + // comment 는 지워져야 함. + findBoard.deleteComment(findBoard.getComment()); + boardRepository.save(findBoard); + } + // 이미 삭제상태인 질문은 삭제 불가. + else if (questionNumber == 3) { + throw new BusinessLogicException(ExceptionCode.CANNOT_DELETE_BOARD); + } + }else { + throw new BusinessLogicException(ExceptionCode.DIFFERENT_USER); + } + + } + + // board 에 memberId 가 있으면, 수정, 조회, 삭제가 가능하도록 함. + // 회원인지 아닌지를 조회를 하는 것. + private void verifyBoardMember (Board board) { + + memberService.findVerifedMember(board.getMember().getMemberId()); + } + + // board 가 있는지 확인. + public Board findVerifiedBoard (long boardId) { + Optional optionalBoard = boardRepository.findById(boardId); + Board findBoard = optionalBoard.orElseThrow(()-> + new BusinessLogicException(ExceptionCode.BOARD_NOT_FOUND)); + return findBoard; + } + + private boolean isCheckBoardOwnerAndAdmin(Authentication authentication, Member member) { + return member.getEmail().equals(authentication.getPrincipal()) + || authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN")); + } + + public boolean isCheckBoardOwner(Authentication authentication, Member member) { + return member.getEmail().equals(authentication.getPrincipal()); + } + + + + private void createdView (Authentication authentication, Board board) { + String email = (String) authentication.getPrincipal(); + Optional optionalMember = memberRepository.findByEmail(email); + Member findMember = optionalMember.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + Optional view = viewRepository.findByMemberAndBoard(findMember, board); + + if(view.isPresent()) { + view.orElseThrow(() -> new BusinessLogicException(ExceptionCode.VIEW_NOT_FOUND)); + } else { + View addView = new View(); + addView.setBoard(board); + addView.setMember(findMember); + board.setViewCount(board.getViewCount() + 1); + viewRepository.save(addView); + } + + } + +} diff --git a/src/main/java/com/springboot/comment/controller/CommentController.java b/src/main/java/com/springboot/comment/controller/CommentController.java new file mode 100644 index 0000000..2e51d83 --- /dev/null +++ b/src/main/java/com/springboot/comment/controller/CommentController.java @@ -0,0 +1,85 @@ +package com.springboot.comment.controller; + +import com.springboot.board.entity.Board; +import com.springboot.board.repository.BoardRepository; +import com.springboot.board.service.BoardService; +import com.springboot.comment.dto.CommentPatchDto; +import com.springboot.comment.dto.CommentPostDto; +import com.springboot.comment.dto.CommentResponseDto; +import com.springboot.comment.entity.Comment; +import com.springboot.comment.mapper.CommentMapper; +import com.springboot.comment.repository.CommentRepository; +import com.springboot.comment.service.CommentService; +import com.springboot.dto.SingleResponseDto; +import com.springboot.member.entity.Member; +import com.springboot.member.service.MemberService; +import com.springboot.utils.UriCreator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.Positive; +import java.net.URI; + +@RestController +@RequestMapping("/v2/boards/{board-id}/comments") +// v2/{board-id}/comments/1 +@Validated +public class CommentController { + private final static String COMMENT_DEFAULT_URL = "/v2/boards/{board-id}/comments"; + + private final CommentService commentService; + private final CommentMapper mapper; + + public CommentController(CommentService commentService, CommentMapper mapper) { + this.commentService = commentService; + this.mapper = mapper; + } + + @PostMapping + public ResponseEntity postComment (@PathVariable("board-id") @Positive long boardId, + @Valid @RequestBody CommentPostDto commentPostDto, + Authentication authentication) { + commentPostDto.setBoardId(boardId); + Comment comment = mapper.commentPostDtoToComment(commentPostDto); + Comment createdComment = commentService.createComment(comment, authentication); + URI location = UriCreator.createUri(COMMENT_DEFAULT_URL, createdComment.getCommentId()); + return ResponseEntity.created(location).build(); + + } + + @PatchMapping("/{comment-id}") + public ResponseEntity patchComment (@PathVariable("board-id") @Positive long boardId, + @PathVariable("comment-id") @Positive long commentId, + @Valid @RequestBody CommentPatchDto commentPatchDto, + Authentication authentication) { + commentPatchDto.setCommentId(commentId); + commentPatchDto.setBoardId(boardId); + Comment comment = commentService.updateComment(mapper.commentPatchDtoToComment(commentPatchDto), authentication); + CommentResponseDto commentResponseDto = mapper.commentToCommentResponseDto(comment); + return new ResponseEntity<>( + new SingleResponseDto<>(commentResponseDto), HttpStatus.OK + ); + } + @GetMapping("{comment-id}") + public ResponseEntity getComment (@PathVariable("board-id") @Positive long boardId, + @PathVariable("comment-id") @Positive long commentId) { + Comment comment = commentService.findComment(commentId); + CommentResponseDto commentResponseDto = mapper.commentToCommentResponseDto(comment); + + return new ResponseEntity<>( + new SingleResponseDto<>(commentResponseDto), HttpStatus.OK + ); + } + + @DeleteMapping("{comment-id}") + public ResponseEntity deleteComment (@PathVariable("board-id") @Positive long boardId, @PathVariable("comment-id") @Positive long commentId, Authentication authentication) { + + commentService.deleteComment(commentId, authentication); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + + } +} \ No newline at end of file diff --git a/src/main/java/com/springboot/comment/dto/CommentPatchDto.java b/src/main/java/com/springboot/comment/dto/CommentPatchDto.java new file mode 100644 index 0000000..c3be54a --- /dev/null +++ b/src/main/java/com/springboot/comment/dto/CommentPatchDto.java @@ -0,0 +1,20 @@ +package com.springboot.comment.dto; + +import lombok.Getter; + + +@Getter +public class CommentPatchDto { + private long commentId; + private long boardId; + + private String content; + + public void setBoardId(long boardId) { + this.boardId = boardId; + } + + public void setCommentId(long commentId) { + this.commentId = commentId; + } +} diff --git a/src/main/java/com/springboot/comment/dto/CommentPostDto.java b/src/main/java/com/springboot/comment/dto/CommentPostDto.java new file mode 100644 index 0000000..c5b9387 --- /dev/null +++ b/src/main/java/com/springboot/comment/dto/CommentPostDto.java @@ -0,0 +1,19 @@ +package com.springboot.comment.dto; + +import com.springboot.board.entity.Board; +import lombok.Getter; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Positive; + +@Getter +public class CommentPostDto { + private long boardId; + + @NotBlank + private String content; + + public void setBoardId(long boardId) { + this.boardId = boardId; + } +} diff --git a/src/main/java/com/springboot/comment/dto/CommentResponseDto.java b/src/main/java/com/springboot/comment/dto/CommentResponseDto.java new file mode 100644 index 0000000..be41a70 --- /dev/null +++ b/src/main/java/com/springboot/comment/dto/CommentResponseDto.java @@ -0,0 +1,19 @@ +package com.springboot.comment.dto; + +import com.springboot.comment.entity.Comment; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Builder +@Getter +@Setter +public class CommentResponseDto { + private Long memberId; + private String content; + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + +} diff --git a/src/main/java/com/springboot/comment/entity/Comment.java b/src/main/java/com/springboot/comment/entity/Comment.java new file mode 100644 index 0000000..de0a96a --- /dev/null +++ b/src/main/java/com/springboot/comment/entity/Comment.java @@ -0,0 +1,66 @@ +package com.springboot.comment.entity; + +import com.springboot.audit.Auditable; +import com.springboot.board.entity.Board; +import com.springboot.member.entity.Member; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; + +@Getter +@Setter +@NoArgsConstructor +@Entity +public class Comment extends Auditable { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long commentId; + + @Column(nullable = false) + private String content; + + @ManyToOne(cascade = CascadeType.MERGE) + @JoinColumn(name = "MEMBER_ID") + private Member member; + public void setMember(Member member) { + this.member = member; + if (!member.getComments().contains(this)) { + member.setComments(this); + } + } + + @OneToOne(cascade = CascadeType.MERGE) + //persist : 처음에 만들때 씀. + @JoinColumn(name = "BOARD_ID") + private Board board; + public void setBoard (Board board) { + this.board = board; + if(board.getComment() != this) { + board.setComment(this); + } + } + public void deleteBoard(Board board) { + this.board = null; + if(board.getComment() == this) { + board.deleteComment(this); + } + } + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private VisibilityStatus visibilityStatus = VisibilityStatus.PUBLIC; + + public enum VisibilityStatus { + PUBLIC("공개글"), + SECRET("비밀글"); + + @Getter + private String visibilityStatus; + + VisibilityStatus(String visibilityStatus) { + this.visibilityStatus = visibilityStatus; + } + } +} diff --git a/src/main/java/com/springboot/comment/mapper/CommentMapper.java b/src/main/java/com/springboot/comment/mapper/CommentMapper.java new file mode 100644 index 0000000..bb00708 --- /dev/null +++ b/src/main/java/com/springboot/comment/mapper/CommentMapper.java @@ -0,0 +1,20 @@ +package com.springboot.comment.mapper; + +import com.springboot.comment.dto.CommentPatchDto; +import com.springboot.comment.dto.CommentPostDto; +import com.springboot.comment.dto.CommentResponseDto; +import com.springboot.comment.entity.Comment; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface CommentMapper { + + @Mapping(source = "boardId",target = "board.boardId") + Comment commentPostDtoToComment (CommentPostDto commentPostDto); + Comment commentPatchDtoToComment (CommentPatchDto commentPatchDto); + + + @Mapping(source = "member.memberId", target = "memberId") + CommentResponseDto commentToCommentResponseDto (Comment comment); +} diff --git a/src/main/java/com/springboot/comment/repository/CommentRepository.java b/src/main/java/com/springboot/comment/repository/CommentRepository.java new file mode 100644 index 0000000..f48e6b6 --- /dev/null +++ b/src/main/java/com/springboot/comment/repository/CommentRepository.java @@ -0,0 +1,7 @@ +package com.springboot.comment.repository; + +import com.springboot.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { +} diff --git a/src/main/java/com/springboot/comment/service/CommentService.java b/src/main/java/com/springboot/comment/service/CommentService.java new file mode 100644 index 0000000..5f0a2f8 --- /dev/null +++ b/src/main/java/com/springboot/comment/service/CommentService.java @@ -0,0 +1,117 @@ +package com.springboot.comment.service; + +import com.springboot.board.entity.Board; +import com.springboot.board.repository.BoardRepository; +import com.springboot.board.service.BoardService; +import com.springboot.comment.entity.Comment; +import com.springboot.comment.repository.CommentRepository; +import com.springboot.exception.BusinessLogicException; +import com.springboot.exception.ExceptionCode; +import com.springboot.member.entity.Member; +import com.springboot.member.service.MemberService; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Optional; + +@Service +public class CommentService { + private final CommentRepository commentRepository; + private final BoardRepository boardRepository; + private final MemberService memberService; + + public CommentService(CommentRepository commentRepository, BoardRepository boardRepository, MemberService memberService) { + this.commentRepository = commentRepository; + this.boardRepository = boardRepository; + this.memberService = memberService; + } + + public Comment createComment (Comment comment, Authentication authentication) { + Optional findBoard = boardRepository.findById(comment.getBoard().getBoardId()); + verifyComment(findBoard.get().getBoardId()); + Board board = findBoard.orElseThrow(()->new BusinessLogicException(ExceptionCode.BOARD_NOT_FOUND)); + board.setQuestionStatus(Board.QuestionStatus.QUESTION_ANSWERED); + // 객체 간의 entity 를 영속성을 넣을 때에는 무조건. set을 해서 상태를 넣어주어야 함. + String email = (String) authentication.getPrincipal(); + Member member = memberService.findVerifiedMember(email); + comment.setMember(member); + comment.setBoard(board); + + // 답변 등록 시 등록 날짜 생성. (이미 엔티티에 초기화 되어 있음.) + // 질문이 비밀글이면 답변 비밀글.... 같이 가야 함. + // 비공개 일때만 + if (board.getVisibilityStatus().equals(Board.VisibilityStatus.SECRET)) { + comment.setVisibilityStatus(Comment.VisibilityStatus.SECRET); + } + return commentRepository.save(comment); + + } + + public Comment updateComment (Comment comment, Authentication authentication) { + // + String email = (String) authentication.getPrincipal(); + Member member = memberService.findVerifiedMember(email); + comment.setMember(member); + Comment findComment = findVerifiedComment(comment.getCommentId()); + // comment 를 쓴 사람이면. + if( isCheckCommentOwner(authentication, findComment)) { + // 있는 코멘트 데리고 옴. +// verifyComment(comment.getBoard().getBoardId()); + Optional.ofNullable(comment.getContent()) + .ifPresent(content -> findComment.setContent(content)); + // 답변 수정 시 수정 날짜 생성. + findComment.setModifiedAt(LocalDateTime.now()); + + return commentRepository.save(findComment); + + } else { + throw new BusinessLogicException(ExceptionCode.DIFFERENT_USER); + } + } + + + public Comment findComment (long commentId) { + Comment findComment = findVerifiedComment(commentId); + return findComment; + } + + public void deleteComment (long commentId,Authentication authentication) { + Comment findComment = findVerifiedComment(commentId); + // 삭제 시 테이블에서 Row 가 완전히 삭제될 수 있도록 함. + commentRepository.delete(findComment); + } + + public void verifyComment (long boardId) { + Optional optionalBoard = boardRepository.findById(boardId); + Board board = optionalBoard.orElseThrow(() -> + new BusinessLogicException(ExceptionCode.BOARD_NOT_FOUND)); + if(board.getComment() != null) { + throw new BusinessLogicException(ExceptionCode.COMMENT_EXISTS); + } + } + + // comment의 id 가 있는 Id인지 확인. update 할 때 필요. + public Comment findVerifiedComment (long commentId) { + Optional optionalComment = commentRepository.findById(commentId); + Comment findComment = optionalComment.orElseThrow( + () -> new BusinessLogicException(ExceptionCode.COMMENT_NOT_FOUND) + ); + return findComment; + } + + public boolean verifyAdmin (Authentication authentication) { + boolean isAdmin = authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN")); + if (!isAdmin) { + throw new BusinessLogicException(ExceptionCode.NO_AUTHORITY); + } + return isAdmin; + } + + public boolean isCheckCommentOwner (Authentication authentication, Comment comment) { + return comment.getMember().getEmail().equals(authentication.getPrincipal()); + } + +} diff --git a/src/main/java/com/springboot/config/SecurityConfiguration.java b/src/main/java/com/springboot/config/SecurityConfiguration.java new file mode 100644 index 0000000..a12e9ac --- /dev/null +++ b/src/main/java/com/springboot/config/SecurityConfiguration.java @@ -0,0 +1,111 @@ +package com.springboot.config; + +import com.springboot.auth.filter.JwtAuthenticationFilter; +import com.springboot.auth.filter.JwtVerificationFilter; +import com.springboot.auth.handler.MemberAuthenticationFailureHandler; +import com.springboot.auth.handler.MemberAuthenticationSuccessHandler; +import com.springboot.auth.jwt.JwtTokenizer; +import com.springboot.auth.utils.JwtAuthorityUtils; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +public class SecurityConfiguration { + private final JwtTokenizer jwtTokenizer; + private final JwtAuthorityUtils jwtAuthorityUtils; + + public SecurityConfiguration(JwtTokenizer jwtTokenizer, JwtAuthorityUtils jwtAuthorityUtils) { + this.jwtTokenizer = jwtTokenizer; + this.jwtAuthorityUtils = jwtAuthorityUtils; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + // h2 웹 콘솔 화면 자체가 내부적으로 을 사용하고 있음. 이를 정상적으로 수행하도록 함. + // 동일 출처로부터 들어오는 요청만 페이지 렌더링을 허용. + http + .headers().frameOptions().sameOrigin() + .and() + .csrf().disable() + .cors(withDefaults()) + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .and() + .formLogin().disable() + .httpBasic().disable() + .apply(new CustomFilterConfigurer()) + .and() + .authorizeHttpRequests(authorize -> authorize + // User 가 회원가입을 해서 로그인을 하면. + // boards post, patch, 한 건의 get, Delete + // Get 의 경우에 모든 글은 다른 회원도 볼 수 있음. 근데, + // 비밀글 상태인 질문은 질문을 등록한 회원(고객)과 관리자만 조회할 수 있다. + // 비밀글이면 로그인 한 상태에서 MemberId 가 같은지 다른지 조건을 달아주어야 하나. + // 회원으로 등록한 회원만 해당 게시판 기능 이용. + .antMatchers(HttpMethod.POST, "/*/boards").hasRole("USER") + // 질문을 등록한 회원만 수정. + .antMatchers(HttpMethod.PATCH, "/*/boards/**").hasRole("USER") + .antMatchers(HttpMethod.GET, "/*/boards").hasAnyRole("USER", "ADMIN") + // 1건의 특정 질문은 질문을 등록한 회원과 관리지가 조회할 수 있음. + .antMatchers(HttpMethod.GET, "/*/boards/*").hasAnyRole("USER", "ADMIN") + // 회원만 삭제 가능. 질문을 등록한 회원만 삭제. + .antMatchers(HttpMethod.DELETE, "/*/boards/*").hasRole("USER") + // 답변 관리자만 등록 가능. 한 건만 등록 가능. + .antMatchers(HttpMethod.POST, "/*/boards/*/comments").hasRole("ADMIN") + // 답변을 등록한 관리자만 수정 가능. + .antMatchers(HttpMethod.PATCH, "/*/boards/*/comments/*").hasRole("ADMIN") + // 답변을 등록한 관리자만 삭제 가능. + .antMatchers(HttpMethod.DELETE, "/*/boards/*/comments/*").hasRole("ADMIN") + + .anyRequest().permitAll()); + return http.build(); + } + + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PATCH", "DELETE")); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + public class CustomFilterConfigurer extends AbstractHttpConfigurer { + @Override + public void configure (HttpSecurity builder) { + AuthenticationManager authenticationManager = + builder.getSharedObject(AuthenticationManager.class); + JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); + jwtAuthenticationFilter.setFilterProcessesUrl("/v2/auth/login"); + jwtAuthenticationFilter.setAuthenticationSuccessHandler(new MemberAuthenticationSuccessHandler()); + jwtAuthenticationFilter.setAuthenticationFailureHandler(new MemberAuthenticationFailureHandler()); + JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, jwtAuthorityUtils); + + builder.addFilter(jwtAuthenticationFilter) + // Authentication 다음에 Verification 필터를 실행해라 + .addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class); + } + } + +} diff --git a/src/main/java/com/springboot/dto/MultiResponseDto.java b/src/main/java/com/springboot/dto/MultiResponseDto.java new file mode 100644 index 0000000..2b59680 --- /dev/null +++ b/src/main/java/com/springboot/dto/MultiResponseDto.java @@ -0,0 +1,22 @@ +package com.springboot.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@AllArgsConstructor +@Getter +public class MultiResponseDto { + // data 랑 pageInfo 를 받는데, 이 pageInfo 는 number, size, totalElement, totalPage 를 받음. + private List data; + private PageInfo pageInfo; + + public MultiResponseDto(List data, Page page) { + this.data = data; + // page 는 인덱스처럼 0부터 시작함. pageInfo 로 response 에 담기 위해서는 + 1 이 필요함. + this.pageInfo = new PageInfo(page.getNumber() + 1, page.getSize(), + page.getTotalElements(),page.getTotalPages(), page.getSort().toString()); + } +} diff --git a/src/main/java/com/springboot/dto/PageInfo.java b/src/main/java/com/springboot/dto/PageInfo.java new file mode 100644 index 0000000..b8b1be8 --- /dev/null +++ b/src/main/java/com/springboot/dto/PageInfo.java @@ -0,0 +1,16 @@ +package com.springboot.dto; + +import com.springboot.page.SortType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.domain.Sort; + +@AllArgsConstructor +@Getter +public class PageInfo { + private int page; + private int size; + private long totalElement; + private int totalPages; + private String sortType; +} diff --git a/src/main/java/com/springboot/dto/SingleResponseDto.java b/src/main/java/com/springboot/dto/SingleResponseDto.java new file mode 100644 index 0000000..770cf86 --- /dev/null +++ b/src/main/java/com/springboot/dto/SingleResponseDto.java @@ -0,0 +1,10 @@ +package com.springboot.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class SingleResponseDto { + private T data; +} diff --git a/src/main/java/com/springboot/exception/BusinessLogicException.java b/src/main/java/com/springboot/exception/BusinessLogicException.java new file mode 100644 index 0000000..770d9c7 --- /dev/null +++ b/src/main/java/com/springboot/exception/BusinessLogicException.java @@ -0,0 +1,13 @@ +package com.springboot.exception; + +import lombok.Getter; + +public class BusinessLogicException extends RuntimeException{ + @Getter + private ExceptionCode exceptionCode; + // + public BusinessLogicException(ExceptionCode exceptionCode) { + super(exceptionCode.getMessage()); + this.exceptionCode = exceptionCode; + } +} diff --git a/src/main/java/com/springboot/exception/ExceptionCode.java b/src/main/java/com/springboot/exception/ExceptionCode.java new file mode 100644 index 0000000..17ab8d5 --- /dev/null +++ b/src/main/java/com/springboot/exception/ExceptionCode.java @@ -0,0 +1,34 @@ +package com.springboot.exception; + +import lombok.Getter; + +public enum ExceptionCode { + MEMBER_NOT_FOUND(404, "Member not found"), + MEMBER_EXISTS(409, "Member exists"), + COMMENT_NOT_FOUND(404, "Comment not found"), + COMMENT_EXISTS(409, "Comment exists"), + VIEW_NOT_FOUND(404, "View not found"), + NO_AUTHORITY (403, "No Authority"), + DO_NOT_HAVE_POST_PERMISSION (403, "Do not have permission to create this post"), + DIFFERENT_USER (403, "Different User"), + DO_NOT_HAVE_GET_PERMISSION (403, "Do not have permission to look up this post"), + DO_NOT_HAVE_DELETE_PERMISSION (403, "Do not have permission to delete this post"), + NOT_IMPLEMENTATION(501, "Not Implementation"), + BOARD_NOT_FOUND(404, "Board not found"), + CANNOT_CHANGE_BOARD(403, "Board can not change"), + CANNOT_DELETE_BOARD(403, "Board can not delete"), + LIKE_EXISTS(404, "like exists"); + // enum 만들 때. + // 필요한 필드를 주입을 해서 + // 생성자를 만들어 주어야 함. + + @Getter + private int status; + + @Getter + private String message; + ExceptionCode(int code, String message) { + this.status = code; + this.message = message; + } +} diff --git a/src/main/java/com/springboot/member/controller/MemberController.java b/src/main/java/com/springboot/member/controller/MemberController.java new file mode 100644 index 0000000..b1a3a66 --- /dev/null +++ b/src/main/java/com/springboot/member/controller/MemberController.java @@ -0,0 +1,111 @@ +package com.springboot.member.controller; + +import com.springboot.dto.MultiResponseDto; +import com.springboot.dto.SingleResponseDto; +import com.springboot.member.dto.MemberPatchDto; +import com.springboot.member.dto.MemberPostDto; +import com.springboot.member.entity.Member; +import com.springboot.member.mapper.MemberMapper; +import com.springboot.member.service.MemberService; +import com.springboot.utils.UriCreator; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.validation.constraints.Positive; +import java.net.URI; +import java.util.List; + +//@ResponseBody 자바를 제이슨 형태로 전환. @Controller 로 컨트롤러 지정. +@RestController +//특정 url로 요청을 보내면 controller에서 어떠한 방식으로 처리할지 정의. +//이때 들어온 요청을 특정 메서드와 매핑하기 위해 사용되는 어노테이션 +@RequestMapping("/v2/members") +//유효성 검증 클래스에 붇임.. 메서드에는 @Valid +@Validated +public class MemberController { + private final static String MEMBER_DEFAULT_URL = "/v2/members"; + + //의존성 주입. + private final MemberService memberService; + private final MemberMapper mapper; + + public MemberController(MemberService memberService, MemberMapper mapper) { + this.memberService = memberService; + this.mapper = mapper; + } + + + @PostMapping + // @RequestBody : 제이슨을 자바로 전환 + public ResponseEntity postMember (@Valid @RequestBody MemberPostDto memberPostDto) { + // postDto 를 Member 객체로 바꾼다. + Member member = mapper.memberPostDtoToMember(memberPostDto); + // 여기에 stamp 객체를 새로 만들어준다. + // 새롭게 만든 member 를 create 한다. + Member createdMember = memberService.createMember(member); + URI location = UriCreator.createUri(MEMBER_DEFAULT_URL, createdMember.getMemberId()); + + return ResponseEntity.created(location).build(); + + } + + // patch 할 때에 member id 를 받음. + // @Validated : 객체의 검증 부분. + // @PathVariable : 경로 변수를 표시하기 위함. + // @Positive : 값이 0이 아닌 양수인지 확인. + @PatchMapping("/{member-id}") + public ResponseEntity patchMember (@PathVariable("member-id") @Positive long memberId, + @Valid @RequestBody MemberPatchDto memberPatchDto) { + + //memberId 로 원하는 멤버를 찾은 뒤에 + memberPatchDto.setMemberId(memberId); + // memberPatchDto 를 Member 로 바꿔준다. + Member updateMember = mapper.memberPatchDtoToMember(memberPatchDto); + + // 바꾼 Member 엔티티로 update 를 한다. + Member member = memberService.updateMember(updateMember); + + // 응답은 ResponseDto 로 함. OK. + return new ResponseEntity<>( + new SingleResponseDto<>(mapper.memberToMemberResponseDto(member)), HttpStatus.OK); + + } + + // get 할 때에 member id 를 받음. + @GetMapping("/{member-id}") + public ResponseEntity getMember (@PathVariable("member-id") @Positive long memberId) { + // memberId 로 member 를 찾아라. + Member member = memberService.findMember(memberId); + + // 응답은 ResponseDto 로 함. OK. + return new ResponseEntity<>( + new SingleResponseDto<>(mapper.memberToMemberResponseDto(member)), HttpStatus.OK); + + } + + // 페이지네이션 구현. + @GetMapping + public ResponseEntity getMembers (@Positive @RequestParam int page, @Positive @RequestParam int size) { + // 페이지를 먼저 구현함. 받은 페이지는 -1 을 해서 인덱스 0을 만들어 주어야 함. + Page pageMembers = memberService.findMembers(page -1, size); + // 페이지에 있는 content 만 데려와서 list를 만들어라. 이거는 밑에 있는 ResponseDto 를 만드는데 쓰일 것임. + List members = pageMembers.getContent(); + + return new ResponseEntity<>( + // public MultiResponseDto(List data, Page page) + new MultiResponseDto<>(mapper.membersToMemberResponseDtos(members), pageMembers), HttpStatus.OK + ); + } + + @DeleteMapping("/{member-id}") + public ResponseEntity deleteMember (@PathVariable("member-id") @Positive long memberId) { + // deleteMember 로직 사용. + memberService.deleteMember(memberId); + // 반환할 내용은 없고, 상태만 변환시키면 됨. + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } +} diff --git a/src/main/java/com/springboot/member/dto/MemberPatchDto.java b/src/main/java/com/springboot/member/dto/MemberPatchDto.java new file mode 100644 index 0000000..669d15e --- /dev/null +++ b/src/main/java/com/springboot/member/dto/MemberPatchDto.java @@ -0,0 +1,27 @@ +package com.springboot.member.dto; + +import com.springboot.member.entity.Member; +import com.springboot.validator.NotSpace; +import lombok.Getter; + +import javax.validation.constraints.Pattern; + +@Getter +public class MemberPatchDto { + private long memberId; + + @NotSpace(message = "회원 이름은 공백이 아니어야 합니다.") + private String name; + + @NotSpace(message = "휴대폰 번호는 공백이 아니어야 합니다.") + @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$", + message = "휴대폰 번호는 010 으로 시작하는 11자리 숫자와 '-'로 구성되엉 햡나디ㅏ.") + + private String phone; + + private Member.MemberStatus memberStatus; + + public void setMemberId(long memberId) { + this.memberId = memberId; + } +} diff --git a/src/main/java/com/springboot/member/dto/MemberPostDto.java b/src/main/java/com/springboot/member/dto/MemberPostDto.java new file mode 100644 index 0000000..15f5950 --- /dev/null +++ b/src/main/java/com/springboot/member/dto/MemberPostDto.java @@ -0,0 +1,25 @@ +package com.springboot.member.dto; + +import lombok.Getter; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +@Getter +public class MemberPostDto { + + @NotBlank + @Email + private String email; + + @NotBlank + private String password; + + @NotBlank(message = "이름은 공백이 아니어야 합니다.") + private String name; + + @Pattern(regexp = "^010-\\d{3,4}-\\d{4}$", + message = "휴대폰 번호는 010으로 시작하는 11자리 숫자와 '-'로 구성되어야 합니다.") + private String phone; +} diff --git a/src/main/java/com/springboot/member/dto/MemberResponseDto.java b/src/main/java/com/springboot/member/dto/MemberResponseDto.java new file mode 100644 index 0000000..3c5f15c --- /dev/null +++ b/src/main/java/com/springboot/member/dto/MemberResponseDto.java @@ -0,0 +1,13 @@ +package com.springboot.member.dto; + +import lombok.Builder; +import lombok.Getter; + +@Builder +@Getter +public class MemberResponseDto { + private Long memberId; + private String email; + private String name; + private String phone; +} diff --git a/src/main/java/com/springboot/member/entity/Member.java b/src/main/java/com/springboot/member/entity/Member.java new file mode 100644 index 0000000..0cdda29 --- /dev/null +++ b/src/main/java/com/springboot/member/entity/Member.java @@ -0,0 +1,101 @@ +package com.springboot.member.entity; + +import com.springboot.audit.Auditable; +import com.springboot.board.entity.Like; +import com.springboot.board.entity.View; +import com.springboot.comment.entity.Comment; +import com.springboot.board.entity.Board; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import javax.persistence.*; +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@Entity +public class Member extends Auditable { + @Id + @GeneratedValue (strategy = GenerationType.IDENTITY) + private Long memberId; + + @Column(length = 100, nullable = false) + private String email; + + @Column(length = 100, nullable = false) + private String name; + + @Column(length = 100, nullable = false) + private String password; + + @Column(length = 100, nullable = false) + private String phone; + + // 사용자 권한 등록 위한 테이블 사용. + @ElementCollection(fetch = FetchType.EAGER) + private List roles = new ArrayList<>(); + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private MemberStatus memberStatus = MemberStatus.MEMBER_ACTIVE; + + @OneToMany (mappedBy = "member") + private List boards = new ArrayList<>(); + public void setBoards (Board board) { + this.boards.add(board); + if(board.getMember() != this) { + board.setMember(this); + } + } + + @OneToMany (mappedBy = "member") + private List comments = new ArrayList<>(); + public void setComments (Comment comment) { + this.comments.add(comment); + if(comment.getMember() != this) { + comment.setMember(this); + } + } + + @OneToMany (mappedBy = "member", cascade = CascadeType.ALL) + private List likes = new ArrayList<>(); + public void setLikes(Like like) { + this.likes.add(like); + if(like.getMember() != this) { + like.setMember(this); + } + } + public void deleteLikes(Like like) { + this.likes.remove(like); + if(like.getMember() == this) { + like.deleteMember(this); + } + } + + @OneToMany (mappedBy = "member") + private List views = new ArrayList<>(); + public void setViews (View view) { + this.views.add(view); + if(view.getMember() == this) { + view.setMember(this); + } + } + + public enum MemberStatus { + MEMBER_ACTIVE("활동중"), + MEMBER_SLEEP("휴면 상태"), + MEMBER_QUIT("탈퇴 상태"); + + @Getter + private String status; + + MemberStatus(String status) { + this.status = status; + } + } + + +} diff --git a/src/main/java/com/springboot/member/mapper/MemberMapper.java b/src/main/java/com/springboot/member/mapper/MemberMapper.java new file mode 100644 index 0000000..1ba64c7 --- /dev/null +++ b/src/main/java/com/springboot/member/mapper/MemberMapper.java @@ -0,0 +1,17 @@ +package com.springboot.member.mapper; + +import com.springboot.member.dto.MemberPatchDto; +import com.springboot.member.dto.MemberPostDto; +import com.springboot.member.dto.MemberResponseDto; +import com.springboot.member.entity.Member; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface MemberMapper { + Member memberPostDtoToMember (MemberPostDto memberPostDto); + Member memberPatchDtoToMember (MemberPatchDto memberPatchDto); + MemberResponseDto memberToMemberResponseDto (Member member); + List membersToMemberResponseDtos (List members); +} diff --git a/src/main/java/com/springboot/member/repository/MemberRepository.java b/src/main/java/com/springboot/member/repository/MemberRepository.java new file mode 100644 index 0000000..e504b30 --- /dev/null +++ b/src/main/java/com/springboot/member/repository/MemberRepository.java @@ -0,0 +1,10 @@ +package com.springboot.member.repository; + +import com.springboot.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail (String email); +} diff --git a/src/main/java/com/springboot/member/service/MemberService.java b/src/main/java/com/springboot/member/service/MemberService.java new file mode 100644 index 0000000..58a0f28 --- /dev/null +++ b/src/main/java/com/springboot/member/service/MemberService.java @@ -0,0 +1,115 @@ +package com.springboot.member.service; + +import com.springboot.auth.utils.JwtAuthorityUtils; +import com.springboot.board.entity.Board; +import com.springboot.exception.BusinessLogicException; +import com.springboot.exception.ExceptionCode; +import com.springboot.member.entity.Member; +import com.springboot.member.repository.MemberRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Slf4j +// 트랜잭션은 Spring AOP 를 통해 구현되어 있다. 선언적 트랜잭션 처리를 지원. +// 클래스, 매서드에 선언되면 해당 클래스에 트랜잭션이 적용된 프록시 객체 생성. +@Transactional +@Service +public class MemberService { + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtAuthorityUtils authorityUtils; + + public MemberService(MemberRepository memberRepository, PasswordEncoder passwordEncoder, JwtAuthorityUtils authorityUtils) { + this.memberRepository = memberRepository; + this.passwordEncoder = passwordEncoder; + this.authorityUtils = authorityUtils; + } + + // controller의 Post + public Member createMember (Member member) { + // 이메일 검증을 해야 함. 있는 이메일인지 없는 이메일인지. + verifyExistsEmail(member.getEmail()); + // password 암호화 + String encryptedPassword = passwordEncoder.encode(member.getPassword()); + member.setPassword(encryptedPassword); + + // db에 userRole 저장 + List roles = authorityUtils.createRoles(member.getEmail()); + member.setRoles(roles); + return memberRepository.save(member); + } + + // controller의 Patch + public Member updateMember (Member member) { + // 멤버 아이디를 보고, 있는 멤버인지 아닌지 확인함. + Member findMember = findVerifedMember(member.getMemberId()); + // 일반 객체를 optional 로 해서 null 값 을 받아주어야 update 가 가능함. + Optional.ofNullable(member.getName()) + // 만약 존재한다면, set을 사용해서 name 을 수정해라. + .ifPresent(name -> findMember.setName(name)); + Optional.ofNullable(member.getPhone()) + .ifPresent(phone -> findMember.setPhone(phone)); + Optional.ofNullable(member.getMemberStatus()) + .ifPresent(status -> findMember.setMemberStatus(status)); + findMember.setModifiedAt(LocalDateTime.now()); + + return memberRepository.save(findMember); + } + + // controller 의 Get 은 memberId 를 받아주어야 함. + public Member findMember (long memberId) { + // 있는 memberId 인지 확인하는 메서드. + return findVerifedMember(memberId); + } + + // 전체조회를 하는 것이기 때문에 페이지 네이션이 필요함. + public Page findMembers (int page, int size) { + // Repository 에서 다 찾아오는데, 페이지네이션을 해야 함. + // 입력받은 page 와 size 를 기준으로, sort.by 를 해야 함. 그리고 내림차순으로. + return memberRepository.findAll(PageRequest.of(page, size, Sort.by("memberId").descending())); + } + + // delete 는 memberId 조회를 해서 없애야 함. + public void deleteMember (long memberId) { + // 멤버 아이디로 멤버를 찾고, + Member findMember = findVerifedMember(memberId); + // member의 상태를 전환시킨다. + findMember.setMemberStatus(Member.MemberStatus.MEMBER_QUIT); + // 질문을 비활성화 한다. 질문은 boards 라서 list 임. + List boards = findMember.getBoards(); + for (Board board : boards) { + board.setQuestionStatus(Board.QuestionStatus.QUESTION_DEACTIVED); + } + memberRepository.save(findMember); + } + + public void verifyExistsEmail (String email) { + Optional member = memberRepository.findByEmail(email); + if (member.isPresent()) { + throw new BusinessLogicException(ExceptionCode.MEMBER_EXISTS); + } + } + public Member findVerifiedMember (String email) { + Optional member = memberRepository.findByEmail(email); + Member findMember = member.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return findMember; + } + + public Member findVerifedMember (long memberId) { + Optional member = memberRepository.findById(memberId); + // optional 일 때에는 Null 예외처리를 해 주어야 함. + // 찾은 멤버가 있으면 반환, 없으면 businessLogicException 던지기. + Member findMember = member.orElseThrow(() -> new BusinessLogicException(ExceptionCode.MEMBER_NOT_FOUND)); + return findMember; + } + +} diff --git a/src/main/java/com/springboot/page/SortType.java b/src/main/java/com/springboot/page/SortType.java new file mode 100644 index 0000000..4da8f59 --- /dev/null +++ b/src/main/java/com/springboot/page/SortType.java @@ -0,0 +1,19 @@ +package com.springboot.page; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum SortType { + TIME_ASC("오래된 글 순", "TIME_ASC"), + TIME_DESC("최신글 순", "TIME_DESC"), + LIKE_ASC("좋아요 적은 순", "LIKE_ASC"), + LIKE_DESC("좋아요 많은 순", "LIKE_DESC"), + VIEW_ASC("조회수 적은 순", "VIEW_ASC"), + VIEW_DESC("좋아요 많은 순", "VIEW_DESC"); + + @Getter + public final String korDes; + public final String engDes; +} diff --git a/src/main/java/com/springboot/response/ErrorResponse.java b/src/main/java/com/springboot/response/ErrorResponse.java new file mode 100644 index 0000000..6a77265 --- /dev/null +++ b/src/main/java/com/springboot/response/ErrorResponse.java @@ -0,0 +1,99 @@ +package com.springboot.response; + +import com.springboot.exception.ExceptionCode; +import lombok.Getter; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindingResult; + +import javax.validation.ConstraintViolation; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Getter +public class ErrorResponse { + private int status; + private String message; + private List fieldErrors; + private List violationErrors; + + private ErrorResponse(int status, String message) { + this.status = status; + this.message = message; + } + + private ErrorResponse(final List fieldErrors, + final List violationErrors) { + this.fieldErrors = fieldErrors; + this.violationErrors = violationErrors; + } + + public static ErrorResponse of(BindingResult bindingResult) { + return new ErrorResponse(FieldError.of(bindingResult), null); + } + + public static ErrorResponse of(Set> violations) { + return new ErrorResponse(null, ConstraintViolationError.of(violations)); + } + + public static ErrorResponse of(ExceptionCode exceptionCode) { + return new ErrorResponse(exceptionCode.getStatus(), exceptionCode.getMessage()); + } + + public static ErrorResponse of(HttpStatus httpStatus) { + return new ErrorResponse(httpStatus.value(), httpStatus.getReasonPhrase()); + } + + public static ErrorResponse of(HttpStatus httpStatus, String message) { + return new ErrorResponse(httpStatus.value(), message); + } + + @Getter + public static class FieldError { + private String field; + private Object rejectedValue; + private String reason; + + private FieldError(String field, Object rejectedValue, String reason) { + this.field = field; + this.rejectedValue = rejectedValue; + this.reason = reason; + } + + public static List of(BindingResult bindingResult) { + final List fieldErrors = + bindingResult.getFieldErrors(); + return fieldErrors.stream() + .map(error -> new FieldError( + error.getField(), + error.getRejectedValue() == null ? + "" : error.getRejectedValue().toString(), + error.getDefaultMessage())) + .collect(Collectors.toList()); + } + } + + @Getter + public static class ConstraintViolationError { + private String propertyPath; + private Object rejectedValue; + private String reason; + + private ConstraintViolationError(String propertyPath, Object rejectedValue, + String reason) { + this.propertyPath = propertyPath; + this.rejectedValue = rejectedValue; + this.reason = reason; + } + + public static List of( + Set> constraintViolations) { + return constraintViolations.stream() + .map(constraintViolation -> new ConstraintViolationError( + constraintViolation.getPropertyPath().toString(), + constraintViolation.getInvalidValue().toString(), + constraintViolation.getMessage() + )).collect(Collectors.toList()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/springboot/utils/UriCreator.java b/src/main/java/com/springboot/utils/UriCreator.java new file mode 100644 index 0000000..3df0164 --- /dev/null +++ b/src/main/java/com/springboot/utils/UriCreator.java @@ -0,0 +1,12 @@ +package com.springboot.utils; + +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +public class UriCreator { + public static URI createUri (String defaultUrl, long resourceId) { + return UriComponentsBuilder.newInstance().path(defaultUrl +"{/resource-id}") + .buildAndExpand(resourceId).toUri(); + } +} diff --git a/src/main/java/com/springboot/validator/NotSpace.java b/src/main/java/com/springboot/validator/NotSpace.java new file mode 100644 index 0000000..b592bd4 --- /dev/null +++ b/src/main/java/com/springboot/validator/NotSpace.java @@ -0,0 +1,17 @@ +package com.springboot.validator; + +import javax.validation.Constraint; +import javax.validation.Payload; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {NotSpaceValidator.class}) +public @interface NotSpace { + String message() default "공백이 아니어야 합니다"; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/com/springboot/validator/NotSpaceValidator.java b/src/main/java/com/springboot/validator/NotSpaceValidator.java new file mode 100644 index 0000000..b194a6d --- /dev/null +++ b/src/main/java/com/springboot/validator/NotSpaceValidator.java @@ -0,0 +1,19 @@ +package com.springboot.validator; + +import org.springframework.util.StringUtils; + +import javax.validation.ConstraintValidator; +import javax.validation.ConstraintValidatorContext; + +public class NotSpaceValidator implements ConstraintValidator { + @Override + public void initialize(NotSpace constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + return value == null || StringUtils.hasText(value); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 39a4a19..1b31404 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,3 +9,26 @@ spring: hibernate: ddl-auto: create # (1) 스키마 자동 생성 show-sql: true # (2) SQL 쿼리 출력 + properties: + hibernate: + format_sql: true # (3) SQL pretty print + sql: + init: + data-locations: classpath*:db/h2/data.sql +logging: + level: + org: + springframework: + orm: + jpa: DEBUG +server: + servlet: + encoding: + force-response: true +mail: + address: + admin: admin@gmail.com +jwt: + key: ${JWT_SECRET_KEY} + access-token-expiration-minutes: 30 + refresh-token-expiration-minutes: 420 \ No newline at end of file