Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a74f084
feat: 커뮤니티 게시물 엔티티 작성
yooookm Sep 5, 2025
f49ade2
feat: photos vo 객체 팩토리 메서드 구현
yooookm Sep 5, 2025
f754aad
feat: 빈 문자열 체크 validator 생성
yooookm Sep 5, 2025
515daaf
feat: 게시물 생성/수정 도메인 로직 작성
yooookm Sep 5, 2025
a9a8d07
feat: 알림 설정은 생성/수정 필드에서 빼기
yooookm Sep 5, 2025
bdbfd6e
feat: 게시물 생성/수정 dto 작성
yooookm Sep 5, 2025
f5cc133
refactor: s3 업르도 서비스는 common 패키지로 이동
yooookm Sep 5, 2025
1c6a818
feat: 권한 관련 base exception 생성
yooookm Sep 5, 2025
bd68bb2
feat: id로 게시물 조회 repository 함수 구현
yooookm Sep 5, 2025
52c11a2
feat: multipartFile 이미지 s3 업로드 후 photos 객체로 변환하는 로직 작성
yooookm Sep 5, 2025
e386c0b
feat: s3 이미지 삭제 기능 구현
yooookm Sep 6, 2025
75baeae
feat: 이미지 업데이트 시 기존 이미지 삭제 후 새 이미지 업로드 로직 구현
yooookm Sep 6, 2025
91ca1b7
feat: 게시물 접근 권한 관련 커스텀 에러 작성
yooookm Sep 6, 2025
368dac8
feat: 게시물 생성, 수정, 삭제 api 구현
yooookm Sep 6, 2025
0d7d24a
feat: @size 어노테이션에 에러 메세지 추가
yooookm Sep 6, 2025
651dac7
feat: requestPart 유효성 어노테이션 에러 처리
yooookm Sep 6, 2025
800cca5
feat: 게시물 좋아요 엔티티 생성
yooookm Sep 6, 2025
fd7659f
feat: 게시물 좋아요 엔티티에 인덱스 추가
yooookm Sep 6, 2025
af8679e
feat: 게시물 좋아요 api 작성
yooookm Sep 6, 2025
bf0411c
feat: 게시물 댓글 수 증감 로직 작성
yooookm Sep 6, 2025
6c183ae
feat: 댓글 엔티티 작성
yooookm Sep 6, 2025
7e5bf0a
feat: 댓글 생성 response wkrtjd
yooookm Sep 6, 2025
428a9ec
feat: 댓글 수정 response wkrtjd
yooookm Sep 6, 2025
ec31b92
feat: 댓글 조회 repository 함수 작성
yooookm Sep 6, 2025
db06bb3
feat: 댓글 생성/조회/수정 api 작성
yooookm Sep 6, 2025
38f8b6f
feat: 게시물 삭제 시 게시물 사진도 s3에서 삭제
yooookm Sep 6, 2025
14ba795
refactor: command service는 command 패키지로 분리
yooookm Sep 6, 2025
766b7fc
feat: 게시글 조회수 레디스 저장 기능 구현
yooookm Sep 6, 2025
98fecb5
feat: 게시글 상세 조회 기능 구현
yooookm Sep 6, 2025
5abff6f
feat: 조회수 벌크 조회 구현
yooookm Sep 8, 2025
1b4fabb
feat: 사용자 좋아요 한 게시물 벌크 조회 구현
yooookm Sep 8, 2025
8542231
feat: 커뮤니티 홈 조회 구현
yooookm Sep 8, 2025
9bb9030
feat: 게시물 상세 조회 시 사진 필드에 사진 인덱스 포함
yooookm Sep 8, 2025
8dd3e93
feat: 게시물 수정 시 삭제한 사진 인덱스로 사진 삭제 구현
yooookm Sep 8, 2025
6309995
feat: 댓글 조회 api 구현
yooookm Sep 8, 2025
ef9aa53
feat: 게시물 검색 api 구현
yooookm Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import kr.tennispark.activity.admin.infrastructure.repository.ActivityImageRepository;
import kr.tennispark.activity.common.domain.ActivityImage;
import kr.tennispark.qr.application.S3UploadService;
import kr.tennispark.common.application.S3UploadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import kr.tennispark.activity.common.domain.ActivityCertification;
import kr.tennispark.activity.user.infrastructure.repository.ActivityCertificationRepository;
import kr.tennispark.common.application.S3UploadService;
import kr.tennispark.members.common.domain.entity.Member;
import kr.tennispark.qr.application.S3UploadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import kr.tennispark.advertisement.admin.presentation.dto.response.GetAdvertisementResponseDTO;
import kr.tennispark.advertisement.common.domain.entity.Advertisement;
import kr.tennispark.advertisement.common.domain.entity.enums.Position;
import kr.tennispark.qr.application.S3UploadService;
import kr.tennispark.common.application.S3UploadService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
package kr.tennispark.qr.application;
package kr.tennispark.common.application;

import com.amazonaws.services.s3.AmazonS3;
import com.amazonaws.services.s3.model.DeleteObjectsRequest;
import com.amazonaws.services.s3.model.DeleteObjectsRequest.KeyVersion;
import com.amazonaws.services.s3.model.ObjectMetadata;
import com.amazonaws.services.s3.model.PutObjectRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import kr.tennispark.qr.application.exception.ImageUploadFailedException;
import kr.tennispark.common.application.exception.S3FailedException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Service
@RequiredArgsConstructor
public class S3UploadService {

private static final String DEFAULT_CONTENT_TYPE = "image/png";
private static final String DEFAULT_EXTENSION = ".png";
private static final String SLASH = "/";
private static final int KEY_START_INDEX = 1;

private final AmazonS3 amazonS3;

Expand All @@ -40,7 +50,7 @@ public String uploadImageFile(MultipartFile image, String keyPrefix) {
extension
);
} catch (IOException e) {
throw new ImageUploadFailedException();
throw new S3FailedException("이미지 업로드 실패");
}
}

Expand All @@ -66,4 +76,44 @@ private String extractExtension(String filename) {
}
return filename.substring(filename.lastIndexOf("."));
}

public void deleteFiles(Collection<String> fileUrls) {
if (fileUrls == null || fileUrls.isEmpty()) {
return;
}

List<KeyVersion> keys = fileUrls.stream()
.filter(Objects::nonNull)
.filter(s -> !s.isBlank())
.map(url -> {
try {
return new DeleteObjectsRequest.KeyVersion(extractKeyFromUrl(url));
} catch (S3FailedException e) {
log.warn("잘못된 S3 URL로 인해 삭제 대상에서 제외됨: {}", url, e);
return null;
}
})
.filter(Objects::nonNull)
.toList();

if (keys.isEmpty()) {
return;
}

DeleteObjectsRequest request = new DeleteObjectsRequest(bucket).withKeys(keys);
amazonS3.deleteObjects(request);
}

private String extractKeyFromUrl(String url) {
try {
URI uri = new URI(url);
String path = uri.getPath();
if (path == null || path.length() <= KEY_START_INDEX) {
throw new S3FailedException("Invalid S3 URL: " + url);
}
return path.substring(KEY_START_INDEX);
} catch (URISyntaxException e) {
throw new S3FailedException("Invalid S3 URL: " + url + "\n" + e);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
package kr.tennispark.qr.application.exception;
package kr.tennispark.common.application.exception;

import kr.tennispark.common.utils.ApiUtils;
import org.springframework.http.HttpStatus;

public class ImageUploadFailedException extends RuntimeException {
public class S3FailedException extends RuntimeException {

private static final String MESSAGE = "이미지 업로드 실패";
private static final String MESSAGE = "S3 작업 실패";

public ImageUploadFailedException (final String message) {
public S3FailedException(final String message) {
super(message);
}

public ImageUploadFailedException() {
public S3FailedException() {
this(MESSAGE);
}

Expand Down
14 changes: 14 additions & 0 deletions src/main/java/kr/tennispark/common/domain/DomainValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package kr.tennispark.common.domain;

import org.springframework.util.StringUtils;

public class DomainValidator {

public static String requireNonBlank(String value) {
if (!StringUtils.hasText(value)) {
throw new IllegalArgumentException("빈 문자열을 넣을 수 없습니다.");
}
return value;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import kr.tennispark.auth.common.application.exception.ExpiredTokenException;
import kr.tennispark.common.exception.base.DuplicateException;
import kr.tennispark.common.exception.base.InvalidException;
import kr.tennispark.common.exception.base.NotAuthorizedException;
import kr.tennispark.common.exception.base.NotFoundException;
import kr.tennispark.common.exception.base.UnsupportedTypeException;
import kr.tennispark.common.utils.ApiUtils;
Expand All @@ -26,6 +27,7 @@
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;

Expand Down Expand Up @@ -54,6 +56,10 @@ public ResponseEntity<?> handleDuplicateException(DuplicateException e) {
return ResponseEntity.status(e.status()).body(ApiUtils.error(e.status(), e.getMessage()));
}

@ExceptionHandler(NotAuthorizedException.class)
public ResponseEntity<?> handleNotAuthorizedException(NotAuthorizedException e) {
return ResponseEntity.status(e.status()).body(ApiUtils.error(e.status(), e.getMessage()));
}

@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgumentException(IllegalArgumentException e) {
Expand Down Expand Up @@ -108,6 +114,15 @@ public ApiUtils.ApiResult<Void> handleMissingServletRequestParameterException(
return ApiUtils.error(HttpStatus.BAD_REQUEST, message);
}

@ExceptionHandler(HandlerMethodValidationException.class)
public ApiUtils.ApiResult<Void> handleHandlerMethodValidation(HandlerMethodValidationException e) {
String message = e.getAllErrors().stream()
.findFirst()
.map(err -> err.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return ApiUtils.error(HttpStatus.BAD_REQUEST, message);
}

@ExceptionHandler(JwtException.class)
public ResponseEntity<?> handleJwtException(JwtException ex) {
return ResponseEntity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.tennispark.common.exception.base;

import kr.tennispark.common.utils.ApiUtils;
import org.springframework.http.HttpStatus;

public class NotAuthorizedException extends RuntimeException {

private static final String MESSAGE = "해당 리소스에 대한 접근 권한이 없습니다.";

public NotAuthorizedException(final String message) {
super(message);
}

public NotAuthorizedException() {
this(MESSAGE);
}

public ApiUtils.ApiResult<?> body() {
return ApiUtils.error(HttpStatus.FORBIDDEN, getMessage());
}

public HttpStatus status() {
return HttpStatus.FORBIDDEN;
}
}

64 changes: 64 additions & 0 deletions src/main/java/kr/tennispark/post/common/domain/entity/Comment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package kr.tennispark.post.common.domain.entity;

import static kr.tennispark.common.domain.DomainValidator.requireNonBlank;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import kr.tennispark.common.domain.BaseEntity;
import kr.tennispark.members.common.domain.entity.Member;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "post_comment")
@SQLRestriction("status = true")
@SQLDelete(sql = "UPDATE post_comment SET status = false WHERE id = ?")
public class Comment extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "post_id", nullable = false)
private Post post;

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@Column(nullable = false, length = 2000)
private String content;

@Column(name = "photo_url", length = 1000)
private String photoUrl;

private Comment(Post post, Member member, String content, String photoUrl) {
this.post = post;
this.member = member;
this.content = requireNonBlank(content);
this.photoUrl = photoUrl;
this.post.increaseComment();
}

public static Comment create(Post post, Member member, String content, String photoUrl) {
return new Comment(post, member, content, photoUrl);
}

public void update(String newContent, String photoUrl) {
this.content = requireNonBlank(newContent);
this.photoUrl = photoUrl;
}

public void remove() {
if (!isDeleted()) {
this.post.decreaseComment();
delete();
}
}
}
95 changes: 95 additions & 0 deletions src/main/java/kr/tennispark/post/common/domain/entity/Post.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package kr.tennispark.post.common.domain.entity;


import static kr.tennispark.common.domain.DomainValidator.requireNonBlank;

import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import kr.tennispark.common.domain.BaseEntity;
import kr.tennispark.members.common.domain.entity.Member;
import kr.tennispark.post.common.domain.entity.vo.Photos;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "community_post")
@SQLDelete(sql = "UPDATE community_post SET status = false WHERE id = ?")
@SQLRestriction("status = true")
public class Post extends BaseEntity {

@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", nullable = false)
private Member member;

@Column(nullable = false, length = 100)
private String title;

@Column(nullable = false, length = 3000)
private String content;

@Embedded
private Photos photos;

@Column(nullable = false)
@ColumnDefault("true")
private boolean notificationEnabled = true;

@Column(nullable = false)
@ColumnDefault("0")
private Integer commentCount = 0;

@Column(nullable = false)
@ColumnDefault("0")
private Integer likeCount = 0;

public static Post create(Member member, String title, String content,
Photos photos) {
Post p = new Post();
p.member = member;
p.title = requireNonBlank(title);
p.content = requireNonBlank(content);
p.photos = photos != null ? photos : Photos.of(null);
return p;
}

public void update(String title, String content,
Photos photos) {
this.title = requireNonBlank(title);
this.content = requireNonBlank(content);
if (photos != null) {
this.photos = photos;
}
}

public void increaseLike() {
this.likeCount++;
}

public void decreaseLike() {
if (this.likeCount > 0) {
this.likeCount--;
}
}

public void increaseComment() {
this.commentCount++;
}

public void decreaseComment() {
if (this.commentCount > 0) {
this.commentCount--;
}
}
}

Loading