diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 00000000..c78e69b3 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,49 @@ +name: CI/CD to EC2 + +on: + push: + branches: [ "조은성/main", "조은성/6주차" ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Docker login + run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and Push Docker Image + run: | + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t ${{ secrets.DOCKER_USERNAME }}/blog-app:latest \ + --push . + + - name: Decode PEM key + run: | + echo "${{ secrets.EC2_KEY }}" | base64 -d > ec2-key.pem + chmod 600 ec2-key.pem + + - name: Deploy to EC2 + run: | + ssh -o StrictHostKeyChecking=no -i ec2-key.pem ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }} << 'EOF' + docker pull ${{ secrets.DOCKER_USERNAME }}/blog-app:latest + + docker stop blog-app || true + docker rm blog-app || true + + cd ~ + echo "KAKAO_CLIENT_ID=abc123" > .env + echo "KAKAO_CLIENT_SECRET=xyz456" >> .env + + docker run -d -p 8080:8080 \ + --name blog-app \ + --env-file .env \ + ${{ secrets.DOCKER_USERNAME }}/blog-app:latest + EOF diff --git a/.gitignore b/.gitignore index 42c1ad51..7fb273ee 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,6 @@ out/ # 환경 설정 및 민감 파일 .env -application.properties +docker/.env *.key *.pem diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..772e1f72 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM gradle:8.4.0-jdk21 as builder +WORKDIR /app +COPY . . +RUN ./gradlew bootJar --no-daemon + +FROM eclipse-temurin:21-jdk +WORKDIR /app +COPY --from=builder /app/build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index 40e33870..7acf0f96 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ 스크린샷 2025-03-28 오전 10 24 35 -## 프로젝트 구조 +## 프로젝트 구조 ``` ├── README.md ├── build.gradle @@ -38,11 +38,9 @@ │   │   │   │   └── service │   │   └── global │   │   ├── auth - │   │   ├── common │   │   │   └── request │   │   │   └── response - │   │   ├── config - │   │   └── exception + │   │   └── config │   └── resources │   └── application.properties └── test diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..d5ed70a0 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,11 @@ +version: '3.8' + +services: + blog: + image: choes0101/blog-app:latest + container_name: blog-app + ports: + - "8080:8080" + env_file: + - .env + restart: always diff --git a/src/main/java/com/blog/domain/comment/repository/CommentRepository.java b/src/main/java/com/blog/domain/comment/repository/CommentRepository.java index 2cb1c151..fcebe34d 100644 --- a/src/main/java/com/blog/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/blog/domain/comment/repository/CommentRepository.java @@ -66,4 +66,10 @@ private RowMapper commentRowMapper() { rs.getTimestamp("updated_at").toLocalDateTime() ); } + + public List findByAuthorId(UUID authorId) { + String sql = "SELECT * FROM comment WHERE author_id = ?"; + return jdbcTemplate.query(sql, commentRowMapper(), authorId.toString()); + } + } diff --git a/src/main/java/com/blog/domain/comment/service/CommentService.java b/src/main/java/com/blog/domain/comment/service/CommentService.java index eca0ebbd..f4de874a 100644 --- a/src/main/java/com/blog/domain/comment/service/CommentService.java +++ b/src/main/java/com/blog/domain/comment/service/CommentService.java @@ -54,4 +54,12 @@ public void updateComment(UUID id, CommentRequest request) { public void deleteComment(UUID id) { commentRepository.deleteById(id); } + + public List getCommentsByAuthor(UUID authorId) { + return commentRepository.findByAuthorId(authorId) + .stream() + .map(CommentResponse::from) + .collect(Collectors.toList()); + } + } diff --git a/src/main/java/com/blog/domain/image/Image.java b/src/main/java/com/blog/domain/image/Image.java deleted file mode 100644 index 0da3b162..00000000 --- a/src/main/java/com/blog/domain/image/Image.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.blog.domain.image; - -public class Image { - -} diff --git a/src/main/java/com/blog/domain/image/controller/ImageController.java b/src/main/java/com/blog/domain/image/controller/ImageController.java new file mode 100644 index 00000000..ada6b212 --- /dev/null +++ b/src/main/java/com/blog/domain/image/controller/ImageController.java @@ -0,0 +1,23 @@ +package com.blog.domain.image.controller; + +import com.blog.domain.image.dto.ImageResponse; +import com.blog.domain.image.service.ImageService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequestMapping("/images") +public class ImageController { + private final ImageService imageService; + + public ImageController(ImageService imageService) { + this.imageService = imageService; + } + + @PostMapping("/upload") + public ResponseEntity upload(@RequestParam("file") MultipartFile file) { + ImageResponse response = imageService.upload(file); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/blog/domain/image/domain/Image.java b/src/main/java/com/blog/domain/image/domain/Image.java new file mode 100644 index 00000000..edcf2aa7 --- /dev/null +++ b/src/main/java/com/blog/domain/image/domain/Image.java @@ -0,0 +1,40 @@ +package com.blog.domain.image.domain; + +import java.time.LocalDateTime; +import java.util.UUID; + +public class Image { + private UUID id; + private String originalName; + private String storedName; + private String url; + private LocalDateTime createdAt; + + public Image(UUID id, String originalName, String storedName, String url, LocalDateTime createdAt) { + this.id = id; + this.originalName = originalName; + this.storedName = storedName; + this.url = url; + this.createdAt = createdAt; + } + + public UUID getId() { + return id; + } + + public String getOriginalName() { + return originalName; + } + + public String getStoredName() { + return storedName; + } + + public String getUrl() { + return url; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/blog/domain/image/dto/ImageResponse.java b/src/main/java/com/blog/domain/image/dto/ImageResponse.java new file mode 100644 index 00000000..f4566e9c --- /dev/null +++ b/src/main/java/com/blog/domain/image/dto/ImageResponse.java @@ -0,0 +1,27 @@ +package com.blog.domain.image.dto; + +import com.blog.domain.image.domain.Image; + +import java.util.UUID; + +public class ImageResponse { + private UUID id; + private String url; + + public ImageResponse(UUID id, String url) { + this.id = id; + this.url = url; + } + + public static ImageResponse from(Image image) { + return new ImageResponse(image.getId(), image.getUrl()); + } + + public UUID getId() { + return id; + } + + public String getUrl() { + return url; + } +} diff --git a/src/main/java/com/blog/domain/image/repository/ImageRepository.java b/src/main/java/com/blog/domain/image/repository/ImageRepository.java new file mode 100644 index 00000000..ba133b9d --- /dev/null +++ b/src/main/java/com/blog/domain/image/repository/ImageRepository.java @@ -0,0 +1,45 @@ +package com.blog.domain.image.repository; + +import com.blog.domain.image.domain.Image; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Timestamp; +import java.util.Optional; +import java.util.UUID; + +@Repository +public class ImageRepository { + private final JdbcTemplate jdbcTemplate; + + public ImageRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void save(Image image) { + String sql = "INSERT INTO image (id, original_name, stored_name, url, created_at) VALUES (?, ?, ?, ?, ?)"; + jdbcTemplate.update(sql, + image.getId().toString(), + image.getOriginalName(), + image.getStoredName(), + image.getUrl(), + Timestamp.valueOf(image.getCreatedAt())); + } + + public Optional findById(UUID id) { + String sql = "SELECT * FROM image WHERE id = ?"; + return jdbcTemplate.query(sql, rowMapper(), id.toString()) + .stream().findFirst(); + } + + private RowMapper rowMapper() { + return (rs, rowNum) -> new Image( + UUID.fromString(rs.getString("id")), + rs.getString("original_name"), + rs.getString("stored_name"), + rs.getString("url"), + rs.getTimestamp("created_at").toLocalDateTime() + ); + } +} diff --git a/src/main/java/com/blog/domain/image/service/ImageService.java b/src/main/java/com/blog/domain/image/service/ImageService.java new file mode 100644 index 00000000..f98fbea7 --- /dev/null +++ b/src/main/java/com/blog/domain/image/service/ImageService.java @@ -0,0 +1,49 @@ +package com.blog.domain.image.service; + +import com.blog.domain.image.domain.Image; +import com.blog.domain.image.dto.ImageResponse; +import com.blog.domain.image.repository.ImageRepository; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class ImageService { + private static final String UPLOAD_DIR = System.getProperty("user.dir") + "/uploads/"; + private final ImageRepository imageRepository; + + public ImageService(ImageRepository imageRepository) { + this.imageRepository = imageRepository; + } + public ImageResponse upload(MultipartFile file) { + try { + String originalName = file.getOriginalFilename(); + String ext = originalName.substring(originalName.lastIndexOf(".")); + String storedName = UUID.randomUUID() + ext; + Path storedPath = Path.of(UPLOAD_DIR + storedName); + + Files.createDirectories(storedPath.getParent()); // 디렉토리 없으면 생성 + file.transferTo(storedPath.toFile()); + + String url = "/images/" + storedName; + + Image image = new Image( + UUID.randomUUID(), + originalName, + storedName, + url, + LocalDateTime.now() + ); + + imageRepository.save(image); + return ImageResponse.from(image); + } catch (Exception e) { + throw new RuntimeException("이미지 저장 실패", e); + } + } +} diff --git a/src/main/java/com/blog/domain/post/repository/PostRepository.java b/src/main/java/com/blog/domain/post/repository/PostRepository.java index f3f90bc3..eaae4eda 100644 --- a/src/main/java/com/blog/domain/post/repository/PostRepository.java +++ b/src/main/java/com/blog/domain/post/repository/PostRepository.java @@ -67,4 +67,10 @@ private RowMapper postRowMapper() { rs.getTimestamp("updated_at").toLocalDateTime() ); } + + public List findByAuthorId(String authorId) { + String sql = "SELECT * FROM post WHERE author_id = ?"; + return jdbcTemplate.query(sql, postRowMapper(), authorId); + } + } diff --git a/src/main/java/com/blog/domain/post/service/PostService.java b/src/main/java/com/blog/domain/post/service/PostService.java index 044d8313..eef4b079 100644 --- a/src/main/java/com/blog/domain/post/service/PostService.java +++ b/src/main/java/com/blog/domain/post/service/PostService.java @@ -64,4 +64,12 @@ public void updatePost(UUID id, PostRequest request) { public void deletePost(UUID id) { postRepository.deleteById(id); } + + public List getPostsByAuthor(UUID authorId) { + return postRepository.findByAuthorId(authorId.toString()) + .stream() + .map(PostResponse::from) + .collect(Collectors.toList()); + } + } diff --git a/src/main/java/com/blog/domain/user/controller/UserController.java b/src/main/java/com/blog/domain/user/controller/UserController.java index 52eefa15..c1d43d76 100644 --- a/src/main/java/com/blog/domain/user/controller/UserController.java +++ b/src/main/java/com/blog/domain/user/controller/UserController.java @@ -1,18 +1,33 @@ package com.blog.domain.user.controller; +import com.blog.domain.comment.dto.CommentResponse; +import com.blog.domain.comment.service.CommentService; +import com.blog.domain.post.dto.PostResponse; +import com.blog.domain.post.service.PostService; import com.blog.domain.user.domain.User; +import com.blog.domain.user.dto.UpdateProfileImageRequest; +import com.blog.domain.user.dto.UserProfileResponse; +import com.blog.domain.user.service.UserService; import com.blog.global.auth.service.AuthService; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @RestController public class UserController { private final AuthService authService; + private final UserService userService; + private final PostService postService; + private final CommentService commentService; - public UserController(AuthService authService) { + public UserController(AuthService authService, UserService userService , PostService postService , CommentService commentService) { this.authService = authService; + this.userService = userService; + this.postService = postService; + this.commentService = commentService; } @GetMapping("/protected-resource") @@ -21,5 +36,58 @@ public ResponseEntity getProtectedResource( User user = authService.getUserInfoFromToken(token); return ResponseEntity.ok(user); } -} + @GetMapping("/me") + public ResponseEntity getMyInfo( + @RequestHeader("Authorization") String token) { + User user = getUserFromToken(token); + return ResponseEntity.ok(UserProfileResponse.from(user)); + } + + @DeleteMapping("/me") + public ResponseEntity> deleteMyAccount( + @RequestHeader("Authorization") String token) { + User user = getUserFromToken(token); + userService.deleteByEmail(user.getEmail()); + + Map response = Map.of("message", "회원 탈퇴가 완료되었습니다."); + return ResponseEntity.ok(response); + } + + @PatchMapping("/me/profile-image") + public ResponseEntity> updateProfileImage( + @RequestHeader("Authorization") String token, + @RequestBody UpdateProfileImageRequest request) { + User user = getUserFromToken(token); + userService.updateProfileImage(user.getEmail(), request.getProfileImageUrl()); + + Map response = new HashMap<>(); + response.put("message", "프로필 이미지가 성공적으로 변경되었습니다."); + response.put("profileImageUrl", request.getProfileImageUrl()); + + return ResponseEntity.ok(response); + } + + private User getUserFromToken(String token) { + User decoded = authService.getUserInfoFromToken(token); + return userService.findUserByEmail(decoded.getEmail()) + .orElseThrow(() -> new RuntimeException("User not found")); + } + + @GetMapping("/me/posts") + public ResponseEntity> getMyPosts( + @RequestHeader("Authorization") String token) { + User user = getUserFromToken(token); + List posts = postService.getPostsByAuthor(user.getId()); + return ResponseEntity.ok(posts); + } + + @GetMapping("/me/comments") + public ResponseEntity> getMyComments( + @RequestHeader("Authorization") String token) { + User user = getUserFromToken(token); + List comments = commentService.getCommentsByAuthor(user.getId()); + return ResponseEntity.ok(comments); + } + +} diff --git a/src/main/java/com/blog/domain/user/dto/UpdateProfileImageRequest.java b/src/main/java/com/blog/domain/user/dto/UpdateProfileImageRequest.java new file mode 100644 index 00000000..c8f1aeb7 --- /dev/null +++ b/src/main/java/com/blog/domain/user/dto/UpdateProfileImageRequest.java @@ -0,0 +1,9 @@ +package com.blog.domain.user.dto; + +public class UpdateProfileImageRequest { + private String profileImageUrl; + + public String getProfileImageUrl() { + return profileImageUrl; + } +} diff --git a/src/main/java/com/blog/domain/user/dto/UserProfileResponse.java b/src/main/java/com/blog/domain/user/dto/UserProfileResponse.java new file mode 100644 index 00000000..962a6362 --- /dev/null +++ b/src/main/java/com/blog/domain/user/dto/UserProfileResponse.java @@ -0,0 +1,51 @@ +package com.blog.domain.user.dto; + +import com.blog.domain.user.domain.User; +import java.time.LocalDateTime; +import java.util.UUID; + +public class UserProfileResponse { + private UUID id; + private String nickname; + private String email; + private String profileImageUrl; + private LocalDateTime createdAt; + + public UserProfileResponse(UUID id, String nickname, String email, String profileImageUrl, LocalDateTime createdAt) { + this.id = id; + this.nickname = nickname; + this.email = email; + this.profileImageUrl = profileImageUrl; + this.createdAt = createdAt; + } + + public static UserProfileResponse from(User user) { + return new UserProfileResponse( + user.getId(), + user.getNickname(), + user.getEmail(), + user.getProfileImageUrl(), + user.getCreatedAt() + ); + } + + public UUID getId() { + return id; + } + + public String getNickname() { + return nickname; + } + + public String getEmail() { + return email; + } + + public String getProfileImageUrl() { + return profileImageUrl; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/blog/domain/user/repository/UserRepository.java b/src/main/java/com/blog/domain/user/repository/UserRepository.java index 40f372fb..ccc8486f 100644 --- a/src/main/java/com/blog/domain/user/repository/UserRepository.java +++ b/src/main/java/com/blog/domain/user/repository/UserRepository.java @@ -45,6 +45,12 @@ public Optional findById(String id) { return users.stream().findFirst(); } + public void deleteByEmail(String email) { + String sql = "DELETE FROM user WHERE email = ?"; + jdbcTemplate.update(sql, email); + } + + private RowMapper userRowMapper() { return (rs, rowNum) -> new User( UUID.fromString(rs.getString("id")), diff --git a/src/main/java/com/blog/domain/user/service/UserService.java b/src/main/java/com/blog/domain/user/service/UserService.java index 73de8675..1e86e5dd 100644 --- a/src/main/java/com/blog/domain/user/service/UserService.java +++ b/src/main/java/com/blog/domain/user/service/UserService.java @@ -11,15 +11,18 @@ import java.util.Base64; import java.util.Optional; import java.util.UUID; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; @Service public class UserService { private final UserRepository userRepository; + private final JdbcTemplate jdbcTemplate; private static final String SALT = "random_salt_value"; - public UserService(UserRepository userRepository) { + public UserService(UserRepository userRepository, JdbcTemplate jdbcTemplate) { this.userRepository = userRepository; + this.jdbcTemplate = jdbcTemplate; } public void registerUser(String nickname, String password, String email, String profileImageUrl) { @@ -42,4 +45,14 @@ private String hashPassword(String password) { public Optional findUserByEmail(String email) { return userRepository.findByEmail(email); } + + public void deleteByEmail(String email) { + userRepository.deleteByEmail(email); + } + + public void updateProfileImage(String email, String imageUrl) { + String sql = "UPDATE user SET profile_image_url = ?, updated_at = ? WHERE email = ?"; + jdbcTemplate.update(sql, imageUrl, LocalDateTime.now(), email); + } + } diff --git a/src/main/java/com/blog/global/common/Common.java b/src/main/java/com/blog/global/common/Common.java deleted file mode 100644 index eda2b68a..00000000 --- a/src/main/java/com/blog/global/common/Common.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.blog.global.common; - -public class Common { - -} diff --git a/src/main/java/com/blog/global/exception/Exception.java b/src/main/java/com/blog/global/exception/Exception.java deleted file mode 100644 index 18a99e94..00000000 --- a/src/main/java/com/blog/global/exception/Exception.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.blog.global.exception; - -public class Exception { - -} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..4fa1de49 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,15 @@ +spring.application.name=blog +spring.datasource.url=${DB_URL} +spring.datasource.username=${DB_USERNAME} +spring.datasource.password=${DB_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.web.resources.static-locations=classpath:/static/,file:uploads/ + +logging.level.org.springframework.jdbc.core=DEBUG +logging.level.org.springframework.jdbc.datasource=DEBUG + +kakao.token-url=https://kauth.kakao.com/oauth/token +kakao.user-url=https://kapi.kakao.com/v2/user/me +kakao.client-id=${KAKAO_CLIENT_ID} +kakao.redirect-uri=http://localhost:8080/auth/kakao/callback