diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..f8cd6a2 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,27 @@ +name: Java CI with Gradle (build only/skip tests) + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + - name: Setup Gradle + uses: gradle/actions/setup-gradle@af1da67850ed9a4cedd57bfd976089dd991e2582 # v4.0.0 + + - name: Build with Gradle Wrapper + run: ./gradlew build -x test diff --git a/build.gradle b/build.gradle index eeeb325..b10e515 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(17) } } @@ -26,7 +26,19 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.security:spring-security-crypto' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + implementation 'org.bouncycastle:bcprov-jdk18on:1.80' + implementation 'io.jsonwebtoken:jjwt-api:0.12.6' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/apptive/devlog/DevlogApplication.java b/src/main/java/apptive/devlog/DevlogApplication.java index fa7035e..7d5a440 100644 --- a/src/main/java/apptive/devlog/DevlogApplication.java +++ b/src/main/java/apptive/devlog/DevlogApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class DevlogApplication { public static void main(String[] args) { diff --git a/src/main/java/apptive/devlog/SwaggerConfig.java b/src/main/java/apptive/devlog/SwaggerConfig.java new file mode 100644 index 0000000..c0c99c6 --- /dev/null +++ b/src/main/java/apptive/devlog/SwaggerConfig.java @@ -0,0 +1,41 @@ +package apptive.devlog; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.List; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ); + Server server = new Server(); + server.setUrl("https://devlog.jun0.dev"); + + return new OpenAPI() + .components(new Components()) + .info(apiInfo()) + .servers(List.of(server)) + .addSecurityItem(securityRequirement) + .components(components); + } + private Info apiInfo() { + return new Info() + .title("Devlog API Documentation") + .version("0.0.1"); + } +} diff --git a/src/main/java/apptive/devlog/async/AsyncConfig.java b/src/main/java/apptive/devlog/async/AsyncConfig.java new file mode 100644 index 0000000..6c12f9d --- /dev/null +++ b/src/main/java/apptive/devlog/async/AsyncConfig.java @@ -0,0 +1,23 @@ +package apptive.devlog.async; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + @Bean("mailExecutor") + public Executor mailExecutor() { + ThreadPoolTaskExecutor exec = new ThreadPoolTaskExecutor(); + exec.setCorePoolSize(4); + exec.setMaxPoolSize(8); + exec.setQueueCapacity(100); + exec.setThreadNamePrefix("mail-"); + exec.initialize(); + return exec; + } +} diff --git a/src/main/java/apptive/devlog/auth/AuthController.java b/src/main/java/apptive/devlog/auth/AuthController.java new file mode 100644 index 0000000..b3c0ac4 --- /dev/null +++ b/src/main/java/apptive/devlog/auth/AuthController.java @@ -0,0 +1,65 @@ +package apptive.devlog.auth; + +import apptive.devlog.auth.dto.*; +import apptive.devlog.common.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + private final AuthService authService; + + @PostMapping("/register") + @Operation(summary = "회원 가입", description = "회원으로 가입합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "가입 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity register(@RequestBody @Valid RegisterRequest request) { + authService.register(request); + return CommonResponse.buildResponseEntity(HttpStatus.CREATED, "성공적으로 가입되었습니다."); + } + + @PostMapping("/login") + @Operation(summary = "로그인", description = "정보 일치 시 AccessToken과 RefreshToken을 발급합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity login(@RequestBody @Valid LoginRequest request) { + LoginResult loginResult = authService.login(request); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그인 되었습니다.", loginResult); + } + + @PostMapping("/logout") + @Operation(summary = "로그아웃", description = "RefreshToken을 Revoke합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity logout(@AuthenticationPrincipal UserDetails userDetails) { + authService.logout(userDetails.getUsername()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "로그아웃 되었습니다."); + } + + @PostMapping("/refresh") + @Operation(summary = "AccessToken 재발급", description = "AccessToken을 재발급합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "재발급 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity refresh(@RequestBody @Valid RefreshRequest request) { + String newAccessToken = authService.refresh(request.refreshToken()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "AccessToken 재발급 되었습니다.", + Map.of("accessToken", newAccessToken)); + } +} diff --git a/src/main/java/apptive/devlog/auth/AuthService.java b/src/main/java/apptive/devlog/auth/AuthService.java new file mode 100644 index 0000000..3304d57 --- /dev/null +++ b/src/main/java/apptive/devlog/auth/AuthService.java @@ -0,0 +1,96 @@ +package apptive.devlog.auth; + +import apptive.devlog.auth.token.RefreshToken; +import apptive.devlog.auth.token.RefreshTokenRepository; +import apptive.devlog.auth.token.TokenProvider; +import apptive.devlog.exception.EmailAlreadyExistsException; +import apptive.devlog.exception.InvalidPasswordException; +import apptive.devlog.exception.NicknameAlreadyExistsException; +import apptive.devlog.user.User; +import apptive.devlog.user.UserRepository; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import apptive.devlog.auth.dto.*; +import lombok.RequiredArgsConstructor; + +import java.time.LocalDateTime; +import java.util.regex.Pattern; + +@Service +@RequiredArgsConstructor +@Transactional +public class AuthService { + private final AuthenticationManager authenticationManager; + private final TokenProvider tokenProvider; + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + + public void register(RegisterRequest request) { + if (userRepository.existsByEmail(request.email())) { + throw new EmailAlreadyExistsException("이미 사용중인 이메일입니다."); + } + if (userRepository.existsByNickname(request.nickname())) { + throw new NicknameAlreadyExistsException("이미 사용중인 닉네임입니다."); + } + if (!validatePassword(request.password())) { + throw new InvalidPasswordException("비밀번호는 대소문자, 특수문자 포함 10자 이상이어야 합니다."); + } + + User user = User.builder() + .email(request.email()) + .password(passwordEncoder.encode(request.password())) + .name(request.name()) + .nickname(request.nickname()) + .birth(request.birth()) + .gender(request.gender()) + .build(); + userRepository.save(user); + } + + public LoginResult login(LoginRequest request) { + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.email(), request.password())); + + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new BadCredentialsException("사용자 없음")); + + String accessTokenString = tokenProvider.generateAccessToken(user.getEmail()); + String refreshTokenString = tokenProvider.generateRefreshToken(); + RefreshToken refreshToken = RefreshToken.builder() + .token(refreshTokenString) + .userEmail(request.email()) + .expiry(LocalDateTime.now().plusDays(TokenProvider.REFRESH_TOKEN_TTL)) + .build(); + refreshTokenRepository.deleteByUserEmail(request.email()); + refreshTokenRepository.save(refreshToken); + + return new LoginResult(accessTokenString, refreshTokenString); + } catch (BadCredentialsException exception) { + throw new BadCredentialsException("이메일 또는 비밀번호가 잘못되었습니다."); + } + } + + public void logout(String email) { + refreshTokenRepository.deleteByUserEmail(email); + } + + public String refresh(String refreshTokenString) { + RefreshToken refreshToken = refreshTokenRepository.findByToken(refreshTokenString); + if(refreshToken == null || refreshToken.getExpiry().isBefore(LocalDateTime.now())) + throw new BadCredentialsException("RefreshToken이 바르지 않습니다."); + + return tokenProvider.generateAccessToken(refreshToken.getUserEmail()); + } + + private boolean validatePassword(String password) { + Pattern pattern = Pattern.compile("^(?=.*[A-Z])(?=.*[a-z])(?=.*\\d)(?=.*[^a-zA-Z0-9]).{10,}$"); + return pattern.matcher(password).matches(); + } +} diff --git a/src/main/java/apptive/devlog/auth/dto/LoginRequest.java b/src/main/java/apptive/devlog/auth/dto/LoginRequest.java new file mode 100644 index 0000000..921614c --- /dev/null +++ b/src/main/java/apptive/devlog/auth/dto/LoginRequest.java @@ -0,0 +1,13 @@ +package apptive.devlog.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + String password +) { } diff --git a/src/main/java/apptive/devlog/auth/dto/LoginResult.java b/src/main/java/apptive/devlog/auth/dto/LoginResult.java new file mode 100644 index 0000000..10d6ddf --- /dev/null +++ b/src/main/java/apptive/devlog/auth/dto/LoginResult.java @@ -0,0 +1,7 @@ +package apptive.devlog.auth.dto; + +public record LoginResult( + String accessToken, + String refreshToken +) { +} diff --git a/src/main/java/apptive/devlog/auth/dto/RefreshRequest.java b/src/main/java/apptive/devlog/auth/dto/RefreshRequest.java new file mode 100644 index 0000000..8c71bd5 --- /dev/null +++ b/src/main/java/apptive/devlog/auth/dto/RefreshRequest.java @@ -0,0 +1,8 @@ +package apptive.devlog.auth.dto; + +import jakarta.validation.constraints.NotBlank; + +public record RefreshRequest ( + @NotBlank String refreshToken +) +{ } diff --git a/src/main/java/apptive/devlog/auth/dto/RegisterRequest.java b/src/main/java/apptive/devlog/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..679228d --- /dev/null +++ b/src/main/java/apptive/devlog/auth/dto/RegisterRequest.java @@ -0,0 +1,29 @@ +package apptive.devlog.auth.dto; + +import apptive.devlog.user.User; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDate; + +public record RegisterRequest( + @NotBlank(message = "이메일은 필수 입력값입니다.") + @Email(message = "이메일 형식이 아닙니다.") + String email, + + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + String password, + + @NotBlank(message = "이름은 필수 입력값입니다.") + String name, + + @NotBlank(message = "닉네임은 필수 입력값입니다.") + String nickname, + + @NotNull(message = "생년월일은 필수 입력값입니다.") + LocalDate birth, + + @NotNull(message = "성별은 필수 입력값입니다.") + User.Gender gender +){} diff --git a/src/main/java/apptive/devlog/auth/token/AuthenticationFilter.java b/src/main/java/apptive/devlog/auth/token/AuthenticationFilter.java new file mode 100644 index 0000000..07df1a2 --- /dev/null +++ b/src/main/java/apptive/devlog/auth/token/AuthenticationFilter.java @@ -0,0 +1,33 @@ +package apptive.devlog.auth.token; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@RequiredArgsConstructor +public class AuthenticationFilter extends OncePerRequestFilter { + private final TokenProvider tokenProvider; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + String token = tokenProvider.resolveToken(request); + + if (token != null && tokenProvider.validateToken(token)) { + Authentication auth = tokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } +} + diff --git a/src/main/java/apptive/devlog/auth/token/RefreshToken.java b/src/main/java/apptive/devlog/auth/token/RefreshToken.java new file mode 100644 index 0000000..742cbee --- /dev/null +++ b/src/main/java/apptive/devlog/auth/token/RefreshToken.java @@ -0,0 +1,31 @@ +package apptive.devlog.auth.token; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Builder +@Entity +@EntityListeners(AuditingEntityListener.class) +public class RefreshToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 32) + private String token; + + @Column(nullable = false) + private String userEmail; + + @Column(nullable = false) + private LocalDateTime expiry; +} diff --git a/src/main/java/apptive/devlog/auth/token/RefreshTokenRepository.java b/src/main/java/apptive/devlog/auth/token/RefreshTokenRepository.java new file mode 100644 index 0000000..e2f8e7c --- /dev/null +++ b/src/main/java/apptive/devlog/auth/token/RefreshTokenRepository.java @@ -0,0 +1,8 @@ +package apptive.devlog.auth.token; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { + RefreshToken findByToken(String token); + void deleteByUserEmail(String userEmail); +} diff --git a/src/main/java/apptive/devlog/auth/token/TokenProvider.java b/src/main/java/apptive/devlog/auth/token/TokenProvider.java new file mode 100644 index 0000000..9d1fc11 --- /dev/null +++ b/src/main/java/apptive/devlog/auth/token/TokenProvider.java @@ -0,0 +1,92 @@ +package apptive.devlog.auth.token; + +import apptive.devlog.security.CustomUserDetails; +import apptive.devlog.security.CustomUserDetailsService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.beans.factory.annotation.Value; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.UUID; + +@RequiredArgsConstructor +@Component +public class TokenProvider { + private final CustomUserDetailsService userDetailsService; + + @Value("${jwt.secret}") + private String secretKey; + + public static final long ACCESS_TOKEN_TTL_MS = 1000L * 60 * 15; //15분 + public static final long REFRESH_TOKEN_TTL = 365L; //365일 + + private SecretKey getSigningKey() { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + return Keys.hmacShaKeyFor(keyBytes); + } + + public String generateAccessToken(String userEmail) { + return createToken(userEmail, ACCESS_TOKEN_TTL_MS); + } + + public String generateRefreshToken() { + return createUuid(); + } + + private String createUuid() { + return UUID.randomUUID().toString().replace("-", ""); + } + + private String createToken(String email, long validityMs) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + validityMs); + Key key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + + return Jwts.builder() + .subject(email) + .issuedAt(now) + .expiration(expiry) + .signWith(key) + .compact(); + } + + public String getEmailFromAccessToken(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getSubject(); + } + + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) + return bearerToken.substring(7); + return null; + } + + public boolean validateToken(String token) { + Date expirationDate = Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload() + .getExpiration(); + return !expirationDate.before(new Date()); + } + + public Authentication getAuthentication(String token) { + CustomUserDetails userDetails = userDetailsService.loadUserByUsername(getEmailFromAccessToken(token)); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } +} + diff --git a/src/main/java/apptive/devlog/common/CommonResponse.java b/src/main/java/apptive/devlog/common/CommonResponse.java new file mode 100644 index 0000000..29635f4 --- /dev/null +++ b/src/main/java/apptive/devlog/common/CommonResponse.java @@ -0,0 +1,21 @@ +package apptive.devlog.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +public record CommonResponse( + @Schema(example = "200") + int statusCode, + String message, + T data +) { + public static ResponseEntity> buildResponseEntity(HttpStatus httpStatus, String message) { + return buildResponseEntity(httpStatus, message, null); + } + + public static ResponseEntity> buildResponseEntity(HttpStatus httpStatus, String message, T data) { + return ResponseEntity.status(httpStatus) + .body(new CommonResponse<>(httpStatus.value(), message, data)); + } +} diff --git a/src/main/java/apptive/devlog/exception/EmailAlreadyExistsException.java b/src/main/java/apptive/devlog/exception/EmailAlreadyExistsException.java new file mode 100644 index 0000000..db18aac --- /dev/null +++ b/src/main/java/apptive/devlog/exception/EmailAlreadyExistsException.java @@ -0,0 +1,8 @@ +package apptive.devlog.exception; + +public class EmailAlreadyExistsException extends RuntimeException { + public EmailAlreadyExistsException(String message) { + super(message); + } +} + diff --git a/src/main/java/apptive/devlog/exception/GlobalExceptionHandler.java b/src/main/java/apptive/devlog/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..66ac26f --- /dev/null +++ b/src/main/java/apptive/devlog/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package apptive.devlog.exception; + + +import apptive.devlog.common.CommonResponse; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.*; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(EmailAlreadyExistsException.class) + public ResponseEntity> handleEmailAlreadyExists(EmailAlreadyExistsException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.CONFLICT, exception.getMessage()); + } + + @ExceptionHandler(NicknameAlreadyExistsException.class) + public ResponseEntity> handleNicknameAlreadyExists(NicknameAlreadyExistsException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.CONFLICT, exception.getMessage()); + } + + @ExceptionHandler(InvalidPasswordException.class) + public ResponseEntity> handleInvalidPassword(InvalidPasswordException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.BAD_REQUEST, exception.getMessage()); + } + + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationError(MethodArgumentNotValidException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.BAD_REQUEST, + exception.getBindingResult().getFieldErrors().stream().findFirst().orElse(null).getDefaultMessage()); + } + + @ExceptionHandler(BadCredentialsException.class) + public ResponseEntity> handleBadCredentials(BadCredentialsException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.UNAUTHORIZED, "인증 정보가 잘못되었습니다."); + } + + @ExceptionHandler(DisabledException.class) + public ResponseEntity> handleUserDisabled(DisabledException exception) { + return CommonResponse.buildResponseEntity(HttpStatus.UNAUTHORIZED, "인증 정보가 잘못되었습니다."); + } + + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAll(Exception exception) { + return CommonResponse.buildResponseEntity(HttpStatus.INTERNAL_SERVER_ERROR, exception.getMessage()); + } +} + diff --git a/src/main/java/apptive/devlog/exception/InvalidPasswordException.java b/src/main/java/apptive/devlog/exception/InvalidPasswordException.java new file mode 100644 index 0000000..45a9ca9 --- /dev/null +++ b/src/main/java/apptive/devlog/exception/InvalidPasswordException.java @@ -0,0 +1,7 @@ +package apptive.devlog.exception; + +public class InvalidPasswordException extends RuntimeException { + public InvalidPasswordException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/exception/NicknameAlreadyExistsException.java b/src/main/java/apptive/devlog/exception/NicknameAlreadyExistsException.java new file mode 100644 index 0000000..17e013c --- /dev/null +++ b/src/main/java/apptive/devlog/exception/NicknameAlreadyExistsException.java @@ -0,0 +1,7 @@ +package apptive.devlog.exception; + +public class NicknameAlreadyExistsException extends IllegalArgumentException { + public NicknameAlreadyExistsException(String message) { + super(message); + } +} diff --git a/src/main/java/apptive/devlog/notification/NotificationService.java b/src/main/java/apptive/devlog/notification/NotificationService.java new file mode 100644 index 0000000..d6e83dd --- /dev/null +++ b/src/main/java/apptive/devlog/notification/NotificationService.java @@ -0,0 +1,69 @@ +package apptive.devlog.notification; + +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; + +@Service +@RequiredArgsConstructor +public class NotificationService { + private final JavaMailSender mailSender; + + @Value("${spring.mail.username}") + private String fromEmail; + + public void notifyComment(String receiverEmail, String postTitle, String commentAuthor, String commentContent) { + String abbreviatedPostTitle = StringUtils.abbreviate(postTitle, 15); + + String subject = "[Devlog 댓글 알림] 작성하신 글 '" + abbreviatedPostTitle + "'에 새로운 댓글이 달렸습니다"; + String text = String.format( + "

작성하신 글 '%s'에 새로운 댓글이 달렸습니다.


댓글 내용: [%s] %s


", + abbreviatedPostTitle, commentAuthor, commentContent); + + sendEmail(receiverEmail, subject, text); + } + + public void notifyReplyToComment(String receiverEmail, String postTitle, String commentContent, String replyAuthor, String replyContent) { + String abbreviatedPostTitle = StringUtils.abbreviate(postTitle, 15); + String abbreviatedCommentContent = StringUtils.abbreviate(commentContent, 15); + + String subject = "[Devlog 대댓글 알림] 작성하신 댓글 '" + abbreviatedCommentContent + "'에 새로운 댓글이 달렸습니다"; + String text = String.format( + "

'%s' 게시글에 작성하신 댓글 '%s'에 새로운 대댓글이 달렸습니다.

대댓글 내용: [%s] %s


", + abbreviatedPostTitle, abbreviatedCommentContent, replyAuthor, replyContent); + + sendEmail(receiverEmail, subject, text); + } + + public void notifyNewPostToFollowers(String receiverEmail, String postTitle, String author) { + String abbreviatedPostTitle = StringUtils.abbreviate(postTitle, 15); + + String subject = String.format("[Devlog 새 게시글 알림] %s님이 새 게시글을 작성했습니다 : '%s'", author, abbreviatedPostTitle); + String text = String.format("

팔로우 중인 %s님이 새 게시글을 작성했습니다 :

%s

", author, postTitle); + + sendEmail(receiverEmail, subject, text); + } + + private void sendEmail(String to, String subject, String text) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8"); + + helper.setTo(to); + helper.setSubject(subject); + helper.setText(text, true); + + helper.setFrom(new InternetAddress(fromEmail, "Devlog", "UTF-8")); + + mailSender.send(message); + } catch (Exception e) { + throw new RuntimeException("메일 전송 실패", e); + } + } +} + diff --git a/src/main/java/apptive/devlog/post/Post.java b/src/main/java/apptive/devlog/post/Post.java new file mode 100644 index 0000000..a42b850 --- /dev/null +++ b/src/main/java/apptive/devlog/post/Post.java @@ -0,0 +1,51 @@ +package apptive.devlog.post; + +import apptive.devlog.post.comment.Comment; +import apptive.devlog.user.User; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +@Entity +@EntityListeners(AuditingEntityListener.class) +public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String title; + + @Lob + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_uuid", nullable = false) + private User author; + + @Builder.Default + @Column(nullable = false) + private Boolean isDeleted = false; + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + @OneToMany(mappedBy = "post", cascade = CascadeType.ALL) + private List comments; +} diff --git a/src/main/java/apptive/devlog/post/PostController.java b/src/main/java/apptive/devlog/post/PostController.java new file mode 100644 index 0000000..457b909 --- /dev/null +++ b/src/main/java/apptive/devlog/post/PostController.java @@ -0,0 +1,82 @@ +package apptive.devlog.post; + +import apptive.devlog.common.CommonResponse; +import apptive.devlog.post.dto.PostCreateRequest; +import apptive.devlog.post.dto.PostData; +import apptive.devlog.post.dto.PostListItem; +import apptive.devlog.post.dto.PostUpdateRequest; +import apptive.devlog.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + + +@RestController +@RequestMapping("/post") +@RequiredArgsConstructor +public class PostController { + private final PostService postService; + + @PostMapping + @Operation(summary = "게시글 업로드", description = "게시글 업로드") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업로드 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity createPost( + @RequestBody @Valid PostCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.createPost(request, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "게시글이 업로드 되었습니다."); + } + + @GetMapping("/{id}") + @Operation(summary = "게시글 가져오기", description = "게시글 가져오기") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity getPost(@PathVariable Long id, @AuthenticationPrincipal CustomUserDetails userDetails) { + PostData postData = postService.getPost(id, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "성공", postData); + } + + @GetMapping + @Operation(summary = "게시글 목록 가져오기", description = "게시글 목록 가져오기") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity getPosts() { + List posts = postService.getPosts(); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "성공", posts); + } + + + @PatchMapping("/{id}") + @Operation(summary = "게시글 수정", description = "게시글 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity updatePost( + @PathVariable Long id, + @RequestBody @Valid PostUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.updatePost(id, request, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "게시글이 수정되었습니다."); + } + + @DeleteMapping("/{postId}") + @Operation(summary = "게시글 삭제", description = "게시글 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity deletePost( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + postService.deletePost(postId, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "게시글이 삭제되었습니다."); + } +} diff --git a/src/main/java/apptive/devlog/post/PostRepository.java b/src/main/java/apptive/devlog/post/PostRepository.java new file mode 100644 index 0000000..663f476 --- /dev/null +++ b/src/main/java/apptive/devlog/post/PostRepository.java @@ -0,0 +1,11 @@ +package apptive.devlog.post; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PostRepository extends JpaRepository { + Optional findByIdAndIsDeletedFalse(Long id); + List findByIsDeletedFalseOrderByCreatedAtAsc(); +} diff --git a/src/main/java/apptive/devlog/post/PostService.java b/src/main/java/apptive/devlog/post/PostService.java new file mode 100644 index 0000000..1f7c257 --- /dev/null +++ b/src/main/java/apptive/devlog/post/PostService.java @@ -0,0 +1,74 @@ +package apptive.devlog.post; + +import apptive.devlog.post.dto.PostCreateRequest; +import apptive.devlog.post.dto.PostData; +import apptive.devlog.post.dto.PostListItem; +import apptive.devlog.post.dto.PostUpdateRequest; +import apptive.devlog.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class PostService { + private final PostRepository postRepository; + + public void createPost(PostCreateRequest request, User user) { + postRepository.save( + Post.builder() + .title(request.title()) + .content(request.content()) + .author(user) + .build()); + } + + public PostData getPost(Long id, User user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다.")); + + return PostData.builder() + .title(post.getTitle()).content(post.getContent()) + .authorNickname(post.getAuthor().getNickname()) + .createdAt(post.getCreatedAt()).updatedAt(post.getUpdatedAt()) + .isPostOwner(post.getAuthor().equals(user)) + .build(); + } + + public List getPosts() { + return postRepository.findByIsDeletedFalseOrderByCreatedAtAsc().stream() + .map(post -> PostListItem.builder() + .id(post.getId()) + .title(post.getTitle()) + .authorNickname(post.getAuthor().getNickname()) + .createdAt(post.getCreatedAt()) + .build()) + .toList(); + } + + public void updatePost(Long id, PostUpdateRequest request, User user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다.")); + if (!post.getAuthor().equals(user)) + throw new IllegalArgumentException("게시글 작성자가 아닙니다."); + + if (request.title() != null) + post.setTitle(request.title()); + if (request.content() != null) + post.setContent(request.content()); + postRepository.save(post); + } + + public void deletePost(Long id, User user) { + Post post = postRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("게시글이 존재하지 않습니다.")); + if (!post.getAuthor().equals(user)) + throw new IllegalArgumentException("게시글 작성자가 아닙니다."); + + post.setIsDeleted(true); + postRepository.save(post); + } +} diff --git a/src/main/java/apptive/devlog/post/comment/Comment.java b/src/main/java/apptive/devlog/post/comment/Comment.java new file mode 100644 index 0000000..4e635fd --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/Comment.java @@ -0,0 +1,58 @@ +package apptive.devlog.post.comment; + +import apptive.devlog.post.Post; +import apptive.devlog.user.User; +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +@Entity +@EntityListeners(AuditingEntityListener.class) +public class Comment { + @Id + @GeneratedValue + @Column(nullable = false, updatable = false) + private UUID uuid; + + @Lob + @Column(nullable = false) + private String content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_uuid", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_uuid") + private Comment parent; + + @OneToMany(mappedBy = "parent", cascade = CascadeType.ALL) + private List replies; + + @Builder.Default + @Column(nullable = false) + private boolean isDeleted = false; + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/apptive/devlog/post/comment/CommentController.java b/src/main/java/apptive/devlog/post/comment/CommentController.java new file mode 100644 index 0000000..9940d30 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/CommentController.java @@ -0,0 +1,78 @@ +package apptive.devlog.post.comment; + +import apptive.devlog.common.CommonResponse; +import apptive.devlog.post.comment.dto.CommentCreateRequest; +import apptive.devlog.post.comment.dto.CommentData; +import apptive.devlog.post.comment.dto.CommentUpdateRequest; +import apptive.devlog.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/post/{postId}/comment") +@RequiredArgsConstructor +public class CommentController { + private final CommentService commentService; + + @Operation(summary = "댓글 업로드", description = "댓글 업로드") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "업로드 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + @PostMapping + public ResponseEntity createComment( + @PathVariable Long postId, + @RequestBody @Valid CommentCreateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + commentService.createComment(postId, request, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "댓글이 등록되었습니다."); + } + + @Operation(summary = "댓글 목록 조회", description = "댓글 목록 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + @GetMapping + public ResponseEntity getComments( + @PathVariable Long postId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + List data = commentService.getComments(postId, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "성공", data); + } + + @Operation(summary = "댓글 수정", description = "댓글 수정") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + @PatchMapping("/{commentId}") + public ResponseEntity updateComment( + @PathVariable UUID commentId, + @RequestBody @Valid CommentUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + commentService.updateComment(commentId, request, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "댓글이 수정되었습니다."); + } + + @Operation(summary = "댓글 삭제", description = "댓글 삭제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + @DeleteMapping("/{commentId}") + public ResponseEntity deleteComment( + @PathVariable UUID commentId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + commentService.deleteComment(commentId, userDetails.getUser()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "댓글이 삭제되었습니다."); + } +} diff --git a/src/main/java/apptive/devlog/post/comment/CommentRepository.java b/src/main/java/apptive/devlog/post/comment/CommentRepository.java new file mode 100644 index 0000000..d778009 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/CommentRepository.java @@ -0,0 +1,14 @@ +package apptive.devlog.post.comment; + +import apptive.devlog.post.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface CommentRepository extends JpaRepository { + Optional findByUuidAndIsDeleted(UUID uuid, boolean deleted); + List findByPostOrderByCreatedAtAsc(Post post); +} + diff --git a/src/main/java/apptive/devlog/post/comment/CommentService.java b/src/main/java/apptive/devlog/post/comment/CommentService.java new file mode 100644 index 0000000..2ecb6e4 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/CommentService.java @@ -0,0 +1,111 @@ +package apptive.devlog.post.comment; + +import apptive.devlog.post.Post; +import apptive.devlog.post.PostRepository; +import apptive.devlog.post.comment.dto.CommentCreateRequest; +import apptive.devlog.post.comment.dto.CommentData; +import apptive.devlog.post.comment.dto.CommentUpdateRequest; +import apptive.devlog.post.comment.event.CommentCreatedEvent; +import apptive.devlog.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +public class CommentService { + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final ApplicationEventPublisher applicationEventPublisher; + + public void createComment(Long postId, CommentCreateRequest request, User user) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); + + Comment parent = (request.parentId() != null) + ? commentRepository.findByUuidAndIsDeleted(request.parentId(), false) + .orElseThrow(() -> new IllegalArgumentException("상위 댓글이 없습니다.")) + : null; + + Comment comment = Comment.builder() + .content(request.content()) + .post(post) + .parent(parent) + .author(user) + .build(); + + commentRepository.save(comment); + applicationEventPublisher.publishEvent( + new CommentCreatedEvent( + parent == null, + + post.getTitle(), + post.getAuthor().getEmail(), + post.getAuthor().getUuid(), + + parent != null ? parent.getAuthor().getUuid() : null, + parent != null ? parent.getAuthor().getEmail() : null, + parent != null ? parent.getContent() : null, + + comment.getAuthor().getUuid(), + comment.getAuthor().getNickname(), + comment.getContent())); + } + + @Transactional(readOnly = true) + public List getComments(Long postId, User user) { + Post post = postRepository.findByIdAndIsDeletedFalse(postId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 게시글입니다.")); + + return commentRepository.findByPostOrderByCreatedAtAsc(post).stream() + .filter(comment -> comment.getParent() == null && (!comment.isDeleted() || !comment.getReplies().isEmpty())) + .map(comment -> toDto(comment, user)) + .toList(); + } + + public void updateComment(UUID commentId, + CommentUpdateRequest req, + User user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("댓글이 없습니다.")); + + if (!comment.getAuthor().equals(user)) + throw new IllegalArgumentException("권한이 없습니다."); + + comment.setContent(req.content()); + } + + public void deleteComment(UUID commentId, User user) { + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 댓글입니다.")); + + if (!comment.getAuthor().equals(user)) + throw new IllegalArgumentException("권한이 없습니다."); + + comment.setDeleted(true); + } + + private CommentData toDto(Comment comment, User user) { + CommentData.CommentDataBuilder commentDataBuilder = CommentData.builder() + .uuid(comment.getUuid()) + .content(comment.isDeleted() ? "(삭제된 댓글)" : comment.getContent()) + .isDeleted(comment.isDeleted()) + .isCommentOwner(comment.getAuthor().equals(user)) + .replies(comment.getReplies().stream() + .filter(reply -> !reply.isDeleted() || !reply.getReplies().isEmpty()) + .map(reply -> toDto(reply, user)) + .toList()); + + if (!comment.isDeleted()) { + commentDataBuilder = commentDataBuilder.authorNickname(comment.getAuthor().getNickname()) + .createdAt(comment.getCreatedAt()); + } + + return commentDataBuilder.build(); + } +} diff --git a/src/main/java/apptive/devlog/post/comment/dto/CommentCreateRequest.java b/src/main/java/apptive/devlog/post/comment/dto/CommentCreateRequest.java new file mode 100644 index 0000000..90373d0 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/dto/CommentCreateRequest.java @@ -0,0 +1,11 @@ +package apptive.devlog.post.comment.dto; + +import jakarta.validation.constraints.NotBlank; + +import java.util.UUID; + +public record CommentCreateRequest( + @NotBlank(message = "내용은 필수 입력값입니다.") + String content, + UUID parentId +) {} diff --git a/src/main/java/apptive/devlog/post/comment/dto/CommentData.java b/src/main/java/apptive/devlog/post/comment/dto/CommentData.java new file mode 100644 index 0000000..5e3124c --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/dto/CommentData.java @@ -0,0 +1,18 @@ +package apptive.devlog.post.comment.dto; + +import lombok.Builder; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Builder +public record CommentData( + UUID uuid, + String content, + String authorNickname, + Boolean isDeleted, + Boolean isCommentOwner, + LocalDateTime createdAt, + List replies +) {} diff --git a/src/main/java/apptive/devlog/post/comment/dto/CommentUpdateRequest.java b/src/main/java/apptive/devlog/post/comment/dto/CommentUpdateRequest.java new file mode 100644 index 0000000..bf67551 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/dto/CommentUpdateRequest.java @@ -0,0 +1,8 @@ +package apptive.devlog.post.comment.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest( + @NotBlank(message = "내용은 필수 입력값입니다.") + String content +) {} diff --git a/src/main/java/apptive/devlog/post/comment/event/CommentCreatedEvent.java b/src/main/java/apptive/devlog/post/comment/event/CommentCreatedEvent.java new file mode 100644 index 0000000..2d8848d --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/event/CommentCreatedEvent.java @@ -0,0 +1,20 @@ +package apptive.devlog.post.comment.event; + +import java.util.UUID; + +public record CommentCreatedEvent( + boolean isTopLevel, + + String postTitle, + String postAuthorEmail, + UUID postAuthorUuid, + + UUID parentCommentAuthorUuid, + String parentCommentAuthorEmail, + String parentCommentContent, + + UUID commentAuthorUuid, + String commentAuthorNickname, + String commentContent +) {} + diff --git a/src/main/java/apptive/devlog/post/comment/listener/CommentNotificationListener.java b/src/main/java/apptive/devlog/post/comment/listener/CommentNotificationListener.java new file mode 100644 index 0000000..256bfd6 --- /dev/null +++ b/src/main/java/apptive/devlog/post/comment/listener/CommentNotificationListener.java @@ -0,0 +1,34 @@ +package apptive.devlog.post.comment.listener; + + +import apptive.devlog.notification.NotificationService; +import apptive.devlog.post.comment.event.CommentCreatedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Service +@RequiredArgsConstructor +public class CommentNotificationListener { + private final NotificationService notificationService; + + @Async("mailExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(CommentCreatedEvent event) { + + if (event.isTopLevel()) { + if (!event.postAuthorUuid().equals(event.commentAuthorUuid())) { + notificationService.notifyComment(event.postAuthorEmail(), event.postTitle(), + event.commentAuthorNickname(), event.commentContent()); + } + return; + } + + if (!event.parentCommentAuthorUuid().equals(event.commentAuthorUuid())) { + notificationService.notifyReplyToComment(event.parentCommentAuthorEmail(), event.postTitle(), + event.parentCommentContent(), event.commentAuthorNickname(), event.commentContent()); + } + } +} diff --git a/src/main/java/apptive/devlog/post/dto/PostCreateRequest.java b/src/main/java/apptive/devlog/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..f32a56b --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostCreateRequest.java @@ -0,0 +1,11 @@ +package apptive.devlog.post.dto; + +import jakarta.validation.constraints.NotBlank; + +public record PostCreateRequest( + @NotBlank(message = "제목은 필수 입력값입니다.") + String title, + + @NotBlank(message = "내용은 필수 입력값입니다.") + String content +) { } diff --git a/src/main/java/apptive/devlog/post/dto/PostData.java b/src/main/java/apptive/devlog/post/dto/PostData.java new file mode 100644 index 0000000..c38d75d --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostData.java @@ -0,0 +1,16 @@ +package apptive.devlog.post.dto; + +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record PostData( + String title, + String content, + String authorNickname, + Boolean isPostOwner, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/apptive/devlog/post/dto/PostListItem.java b/src/main/java/apptive/devlog/post/dto/PostListItem.java new file mode 100644 index 0000000..cc19318 --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostListItem.java @@ -0,0 +1,14 @@ +package apptive.devlog.post.dto; + +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record PostListItem( + Long id, + String title, + String authorNickname, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/apptive/devlog/post/dto/PostUpdateRequest.java b/src/main/java/apptive/devlog/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..13ba27c --- /dev/null +++ b/src/main/java/apptive/devlog/post/dto/PostUpdateRequest.java @@ -0,0 +1,6 @@ +package apptive.devlog.post.dto; + +public record PostUpdateRequest( + String title, + String content +) { } diff --git a/src/main/java/apptive/devlog/security/CustomUserDetails.java b/src/main/java/apptive/devlog/security/CustomUserDetails.java new file mode 100644 index 0000000..33559c1 --- /dev/null +++ b/src/main/java/apptive/devlog/security/CustomUserDetails.java @@ -0,0 +1,39 @@ +package apptive.devlog.security; + +import apptive.devlog.user.User; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + private final User user; + + @Override + public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority("ROLE_USER")); + } + + @Override + public String getUsername() { + return user.getEmail(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + public User getUser() { + return user; + } + + @Override + public boolean isEnabled() { + return user.isActive(); + } +} diff --git a/src/main/java/apptive/devlog/security/CustomUserDetailsService.java b/src/main/java/apptive/devlog/security/CustomUserDetailsService.java new file mode 100644 index 0000000..28009ee --- /dev/null +++ b/src/main/java/apptive/devlog/security/CustomUserDetailsService.java @@ -0,0 +1,23 @@ +package apptive.devlog.security; + +import apptive.devlog.user.User; +import apptive.devlog.user.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("이메일 오류")); + + return new CustomUserDetails(user); + } +} \ No newline at end of file diff --git a/src/main/java/apptive/devlog/security/SecurityConfig.java b/src/main/java/apptive/devlog/security/SecurityConfig.java new file mode 100644 index 0000000..8e4bd61 --- /dev/null +++ b/src/main/java/apptive/devlog/security/SecurityConfig.java @@ -0,0 +1,55 @@ +package apptive.devlog.security; + +import apptive.devlog.auth.token.AuthenticationFilter; +import apptive.devlog.auth.token.TokenProvider; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@RequiredArgsConstructor +@EnableWebSecurity +public class SecurityConfig { + private final TokenProvider tokenProvider; + private final CustomUserDetailsService customUserDetailsService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth.requestMatchers("/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .anyRequest().authenticated() + ).addFilterBefore(new AuthenticationFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public DaoAuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(customUserDetailsService); + provider.setPasswordEncoder(passwordEncoder()); + return provider; + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { + return configuration.getAuthenticationManager(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + } +} + diff --git a/src/main/java/apptive/devlog/user/User.java b/src/main/java/apptive/devlog/user/User.java new file mode 100644 index 0000000..9f84916 --- /dev/null +++ b/src/main/java/apptive/devlog/user/User.java @@ -0,0 +1,63 @@ +package apptive.devlog.user; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@Builder +@Entity +@EntityListeners(AuditingEntityListener.class) +@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) +public class User { + @Id + @GeneratedValue + @EqualsAndHashCode.Include + @Column(nullable = false, updatable = false) + private UUID uuid; + + @Column(nullable = false, unique = true, updatable = false) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String nickname; + + @Column(nullable = false) + private LocalDate birth; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Gender gender; + + @Builder.Default + @Column(nullable = false) + private boolean isActive = true; + + @CreatedDate + @Column(updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + public enum Gender { + MALE, FEMALE, OTHER + } +} + diff --git a/src/main/java/apptive/devlog/user/UserController.java b/src/main/java/apptive/devlog/user/UserController.java new file mode 100644 index 0000000..cca6c18 --- /dev/null +++ b/src/main/java/apptive/devlog/user/UserController.java @@ -0,0 +1,47 @@ +package apptive.devlog.user; + + +import apptive.devlog.common.CommonResponse; +import apptive.devlog.security.CustomUserDetails; +import apptive.devlog.user.dto.UserDeleteRequest; +import apptive.devlog.user.dto.UserUpdateRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +public class UserController { + private final UserService userService; + + @PatchMapping("/me") + @Operation(summary = "회원정보 수정", description = "현재 로그인된 사용자의 정보를 수정합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정보 수정 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity updateUser( + @RequestBody @Valid UserUpdateRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.updateUser(userDetails.getUser(), request); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "회원정보가 수정되었습니다."); + } + + @DeleteMapping("/me") + @Operation(summary = "회원 탈퇴", description = "현재 로그인된 사용자를 탈퇴시킵니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "탈퇴 성공", content = @Content(schema = @Schema(implementation = CommonResponse.class)))}) + public ResponseEntity deleteUser( + @RequestBody @Valid UserDeleteRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.deactivateUser(userDetails.getUser(), request.password()); + return CommonResponse.buildResponseEntity(HttpStatus.OK, "정상적으로 탈퇴되었습니다."); + } +} diff --git a/src/main/java/apptive/devlog/user/UserRepository.java b/src/main/java/apptive/devlog/user/UserRepository.java new file mode 100644 index 0000000..2d6ddcd --- /dev/null +++ b/src/main/java/apptive/devlog/user/UserRepository.java @@ -0,0 +1,11 @@ +package apptive.devlog.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + boolean existsByEmail(String email); + boolean existsByNickname(String nickname); + Optional findByEmail(String email); +} diff --git a/src/main/java/apptive/devlog/user/UserService.java b/src/main/java/apptive/devlog/user/UserService.java new file mode 100644 index 0000000..13ba3a5 --- /dev/null +++ b/src/main/java/apptive/devlog/user/UserService.java @@ -0,0 +1,46 @@ +package apptive.devlog.user; + +import apptive.devlog.auth.token.RefreshTokenRepository; +import apptive.devlog.user.dto.UserUpdateRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserService { + private final UserRepository userRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + + public void updateUser(User user, UserUpdateRequest request) { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(user.getEmail(), request.oldPassword())); + + if (request.birth() != null) + user.setBirth(request.birth()); + if (request.gender() != null) + user.setGender(request.gender()); + if (request.name() != null) + user.setName(request.name()); + if (request.newPassword() != null) + user.setPassword(passwordEncoder.encode(request.oldPassword())); + if (request.nickname() != null) + user.setNickname(request.nickname()); + userRepository.save(user); + } + + public void deactivateUser(User user, String password) { + authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(user.getEmail(), password)); + + user.setActive(false); + userRepository.save(user); + + refreshTokenRepository.deleteByUserEmail(user.getEmail()); + } +} diff --git a/src/main/java/apptive/devlog/user/dto/UserDeleteRequest.java b/src/main/java/apptive/devlog/user/dto/UserDeleteRequest.java new file mode 100644 index 0000000..50663b5 --- /dev/null +++ b/src/main/java/apptive/devlog/user/dto/UserDeleteRequest.java @@ -0,0 +1,8 @@ +package apptive.devlog.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record UserDeleteRequest( + @NotBlank(message = "비밀번호는 필수 입력값입니다.") + String password +) { } diff --git a/src/main/java/apptive/devlog/user/dto/UserUpdateRequest.java b/src/main/java/apptive/devlog/user/dto/UserUpdateRequest.java new file mode 100644 index 0000000..08e2ef5 --- /dev/null +++ b/src/main/java/apptive/devlog/user/dto/UserUpdateRequest.java @@ -0,0 +1,22 @@ +package apptive.devlog.user.dto; + +import apptive.devlog.user.User; +import jakarta.validation.constraints.NotBlank; + +import java.time.LocalDate; + +public record UserUpdateRequest( + @NotBlank(message = "현재 비밀번호는 필수 입력값입니다.") + String oldPassword, + + String newPassword, + + String name, + + String nickname, + + LocalDate birth, + + User.Gender gender +) { +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 95619f3..ded7b2b 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,31 @@ spring.application.name=devlog + +spring.datasource.url=jdbc:mysql://localhost:3306/devlog_db +spring.datasource.username=devlog_eg09ej3n +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +#validate +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.open-in-view=false + +spring.datasource.hikari.maximum-pool-size=20 +spring.datasource.hikari.minimum-idle=5 +spring.datasource.hikari.idle-timeout=30000 +spring.datasource.hikari.connection-timeout=30000 +spring.datasource.hikari.max-lifetime=1800000 + +server.port=8080 +jwt.secret=${JWT_SECRET} + +spring.mail.host=jun0.dev +spring.mail.port=587 +spring.mail.username=y@jun0.dev +spring.mail.password=${SMTP_PASSWORD} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true + +springdoc.default-produces-media-type=application/json +springdoc.api-docs.enabled=true +springdoc.swagger-ui.enabled=true \ No newline at end of file