diff --git a/src/main/java/com/numberone/backend/config/SwaggerConfig.java b/src/main/java/com/numberone/backend/config/SwaggerConfig.java index 86d6d2b2..a1f07ee7 100644 --- a/src/main/java/com/numberone/backend/config/SwaggerConfig.java +++ b/src/main/java/com/numberone/backend/config/SwaggerConfig.java @@ -6,6 +6,7 @@ import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -27,10 +28,14 @@ ) @Configuration public class SwaggerConfig { + @Bean public OpenAPI customOpenAPI() { return new OpenAPI() - .components(new Components().addSecuritySchemes("JWT", bearerAuth())); + .components( + new Components() + .addSecuritySchemes("JWT", bearerAuth())) + .addSecurityItem(new SecurityRequirement().addList("JWT")); } public SecurityScheme bearerAuth() { @@ -41,4 +46,5 @@ public SecurityScheme bearerAuth() { .in(SecurityScheme.In.HEADER) .name(HttpHeaders.AUTHORIZATION); } + } diff --git a/src/main/java/com/numberone/backend/domain/admin/service/AdminService.java b/src/main/java/com/numberone/backend/domain/admin/service/AdminService.java index 11604792..5424cb8c 100644 --- a/src/main/java/com/numberone/backend/domain/admin/service/AdminService.java +++ b/src/main/java/com/numberone/backend/domain/admin/service/AdminService.java @@ -4,7 +4,7 @@ import com.numberone.backend.domain.admin.dto.response.GetAddressResponse; import com.numberone.backend.domain.shelter.dto.response.GetAllSheltersResponse; import com.numberone.backend.domain.shelter.repository.ShelterRepository; -import com.numberone.backend.support.S3Provider; +import com.numberone.backend.support.s3.S3Provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/numberone/backend/domain/article/controller/ArticleController.java b/src/main/java/com/numberone/backend/domain/article/controller/ArticleController.java new file mode 100644 index 00000000..9bcb9151 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/controller/ArticleController.java @@ -0,0 +1,126 @@ +package com.numberone.backend.domain.article.controller; + +import com.numberone.backend.domain.article.dto.request.ModifyArticleRequest; +import com.numberone.backend.domain.article.dto.request.UploadArticleRequest; +import com.numberone.backend.domain.article.dto.response.*; +import com.numberone.backend.domain.article.service.ArticleService; +import com.numberone.backend.domain.comment.dto.request.CreateCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateCommentResponse; +import com.numberone.backend.support.redis.like.service.RedisLockLikeFacade; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@Slf4j +@RequestMapping("/api/articles") +@RequiredArgsConstructor +@RestController +public class ArticleController { + + private final ArticleService articleService; + private final RedisLockLikeFacade redisLockLikeFacade; + + @Operation(summary = "게시글 작성 API", description = """ + + 동네생활 게시글 등록 api 입니다. + 반드시 access token 을 헤더에 담아서 요청해주세요. + + 1. title 은 글 제목 입니다 (not null) + 2. content 는 글 내용 입니다 (not null) + 3. articleTag 는 게시글 태그 입니다. LIFE(일상), FRAUD(사기), SAFETY(안전), REPORT(제보) + 4. imageList 는 이미지 (MultiPart) 리스트 입니다. + 5. thumbNailImageIdx 는 썸네일 이미지의 인덱스 입니다. (0,1,2, ... + imageList 에 이미지를 담아서 보내는 경우, + idx 에 따라서 썸네일 이미지를 결정합니다. + + """) + + @PostMapping + public ResponseEntity uploadArticle(@RequestBody @Valid UploadArticleRequest request) { + return ResponseEntity.created(URI.create("/api/articles")) + .body(articleService.uploadArticle(request)); + } + + @Operation(summary = "게시글을 삭제하는 API 입니다.", description = """ + 게시글 id 를 PathVariable 으로 넘겨주세요. + 해당 게시글을 삭제 상태로 변경합니다. + """) + @PatchMapping("{article-id}/delete") + public ResponseEntity deleteArticle(@PathVariable("article-id") Long articleId) { + return ResponseEntity.ok(articleService.deleteArticle(articleId)); + } + + @Operation(summary = "게시글 상세 조회 API 입니다.", description = """ + 게시글 id 를 PathVariable 으로 넘겨주세요. + """) + @GetMapping("{article-id}") + public ResponseEntity getArticleDetails(@PathVariable("article-id") Long articleId) { + return ResponseEntity.ok(articleService.getArticleDetail(articleId)); + } + + + @Operation(summary = "게시글 리스트 조회 no offset Paging API 입니다.", description = """ + + 요청 예시 url 은 다음과 같습니다. + `/api/articles?size=5` + size 는 페이지의 사이즈를 의미하고, default 는 20 입니다. + + 정렬 순서는 articleId 순입니다. ( = 생성 시간 순 ) + + ModelAttribute 로 lastArticleId 와 tag 를 넘겨주세요 ( 둘 다 nullable ) + + tag 가 null 이면, tag 상관 없이 전체 조회를 수행합니다. + tag 가 null 이 아니면, 해당 tag 에 해당하는 게시글만 조회합니다. + + lastArticleId 는 직전에 조회한 게시글 중 가장 먀지막(작은) articleId 를 의미합니다. + - 첫 페이지를 요청할 경우에는 lastArticleId 를 null 로 보내야합니다. + - 첫 페이지 이후에 대한 요청은, 직전 페이지 요청에서 얻어온 lastArticleId 를 넣어서 보내면 그 다음 페이지를 호출합니다. + + """) + @GetMapping + public ResponseEntity> getArticlePages( + Pageable pageable, + @ModelAttribute ArticleSearchParameter param) { // todo: 해당 유저가 좋아요를 눌렀는지 여부까지 표시되도록 수정 + return ResponseEntity.ok(articleService.getArticleListPaging(param, pageable)); + } + + @Operation(summary = "게시글에 댓글 작성하기", description = """ + 게시글에 댓글을 작성하는 API 입니다. + + 게시글 아이디는 Path variable 으로 넘겨주세요 (article-id) + + """) + @PostMapping("comments/{article-id}") + public ResponseEntity createComment( + @PathVariable("article-id") Long articleId, + @RequestBody @Valid CreateCommentRequest request) { + return ResponseEntity.created( + URI.create(String.format("/api/articles/comment/%s", articleId))) + .body(articleService.createComment(articleId, request)); + + } + + @Operation(summary = "게시글 수정하기", description = """ + 게시글 내용을 수정하는 API 입니다. + + 반드시 access token 을 헤더에 포함해서 요청해주세요. + + article-id 는 path variable 으로 넘겨주세요. + """) + @PutMapping("{article-id}/modify") + public ResponseEntity modifyArticle( + @PathVariable("article-id") Long articleId, + @RequestBody @Valid ModifyArticleRequest request ){ + return ResponseEntity.ok(articleService.modifyArticle(articleId, request)); + } + + // todo: 게시글 신고 기능 + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/request/ModifyArticleRequest.java b/src/main/java/com/numberone/backend/domain/article/dto/request/ModifyArticleRequest.java new file mode 100644 index 00000000..3348de84 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/request/ModifyArticleRequest.java @@ -0,0 +1,38 @@ +package com.numberone.backend.domain.article.dto.request; + +import com.numberone.backend.domain.article.entity.ArticleTag; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ModifyArticleRequest { + + // 글 관련 + @NotNull(message = "글 제목은 null 일 수 없습니다.") + private String title; // 제목 + + @NotNull(message = "내용은 null 일 수 없습니다.") + private String content; // 내용 + + @NotNull(message = """ + 게시글의 태그를 하나 선택해주세요. + + LIFE(일상), FRAUD(사기), SAFETY(안전), REPORT(제보) + """) + private ArticleTag articleTag; // 게시글 태그 + + // 이미지 관련 + private List imageList; // 이미지 리스트 + private Long thumbNailImageIdx; // 썸네일 이미지의 순서 (0,1,2,...) + + private Double longitude; + private Double latitude; + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/request/UploadArticleRequest.java b/src/main/java/com/numberone/backend/domain/article/dto/request/UploadArticleRequest.java new file mode 100644 index 00000000..04b76643 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/request/UploadArticleRequest.java @@ -0,0 +1,38 @@ +package com.numberone.backend.domain.article.dto.request; + +import com.numberone.backend.domain.article.entity.ArticleTag; +import jakarta.validation.constraints.NotNull; +import lombok.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UploadArticleRequest { + + // 글 관련 + @NotNull(message = "글 제목은 null 일 수 없습니다.") + private String title; // 제목 + + @NotNull(message = "내용은 null 일 수 없습니다.") + private String content; // 내용 + + @NotNull(message = """ + 게시글의 태그를 하나 선택해주세요. + + LIFE(일상), FRAUD(사기), SAFETY(안전), REPORT(제보) + """) + private ArticleTag articleTag; // 게시글 태그 + + // 이미지 관련 + private List imageList; // 이미지 리스트 + private Long thumbNailImageIdx; // 썸네일 이미지의 순서 (0,1,2,...) + + private Double longitude; + private Double latitude; + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/ArticleSearchParameter.java b/src/main/java/com/numberone/backend/domain/article/dto/response/ArticleSearchParameter.java new file mode 100644 index 00000000..5eb8b6dd --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/ArticleSearchParameter.java @@ -0,0 +1,16 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.numberone.backend.domain.article.entity.ArticleTag; +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ArticleSearchParameter { + private ArticleTag tag; + private Long lastArticleId; + private Double longitude; + private Double latitude; +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/DeleteArticleResponse.java b/src/main/java/com/numberone/backend/domain/article/dto/response/DeleteArticleResponse.java new file mode 100644 index 00000000..ae7e249a --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/DeleteArticleResponse.java @@ -0,0 +1,24 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.numberone.backend.domain.article.entity.Article; +import lombok.*; + +import java.time.LocalDateTime; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class DeleteArticleResponse { + + private Long id; + private LocalDateTime deletedAt; + + public static DeleteArticleResponse of(Article article){ + return DeleteArticleResponse.builder() + .id(article.getId()) + .deletedAt(article.getModifiedAt()) + .build(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleDetailResponse.java b/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleDetailResponse.java new file mode 100644 index 00000000..cc356667 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleDetailResponse.java @@ -0,0 +1,57 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.member.entity.Member; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GetArticleDetailResponse { + + // 게시글 관련 + private Long articleId; + private Integer likeCount; + private LocalDateTime createdAt; + private LocalDateTime modifiedAt; + private String title; + private String content; + + // 작성자 관련 + private String memberName; + private String memberNickName; + private String address; // todo: 더미 데이터 + private Long ownerMemberId; + + // 이미지 관련 + private List imageUrls; + private String thumbNailImageUrl; + + public static GetArticleDetailResponse of(Article article, List imageUrls, String thumbNailImageUrl, Member member){ + return GetArticleDetailResponse.builder() + .articleId(article.getId()) + .title(article.getTitle()) + .content(article.getContent()) + .likeCount( + Optional.ofNullable( + article.getLikeCount() + ).orElse(0) + ) + .createdAt(article.getCreatedAt()) + .modifiedAt(article.getModifiedAt()) + .ownerMemberId(member.getId()) + .memberName(member.getRealName()) + .memberNickName(member.getNickName()) + .imageUrls(imageUrls) + .thumbNailImageUrl(thumbNailImageUrl) + .address("서울시 광진구 자양동") // 교체 + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleListResponse.java b/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleListResponse.java new file mode 100644 index 00000000..6b88af6d --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/GetArticleListResponse.java @@ -0,0 +1,72 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.entity.ArticleStatus; +import com.numberone.backend.domain.article.entity.ArticleTag; +import com.numberone.backend.domain.articleimage.entity.ArticleImage; +import com.numberone.backend.domain.member.entity.Member; +import com.querydsl.core.annotations.QueryProjection; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.Optional; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GetArticleListResponse { + + private ArticleTag tag; + private Long id; + private String title; + private String content; + private String address; + private String ownerNickName; + private Long ownerId; + private LocalDateTime createdAt; + private ArticleStatus articleStatus; + private String thumbNailImageUrl; + private Long thumbNailImageId; + + private Integer articleLikeCount; + private Integer commentCount; + + + @QueryProjection + public GetArticleListResponse(Article article, Long ownerId, Long thumbNailImageId) { + this.tag = article.getArticleTag(); + this.id = article.getId(); + this.title = article.getTitle(); + this.content = article.getContent(); + this.address = article.getAddress(); + this.ownerId = ownerId; + this.createdAt = article.getCreatedAt(); + this.articleStatus = article.getArticleStatus(); + this.thumbNailImageId = thumbNailImageId; + this.articleLikeCount = article.getLikeCount(); + this.commentCount = article.getCommentCount(); + } + + public void setOwnerNickName(String nickName){ + this.ownerNickName = nickName; + } + + public void setThumbNailImageUrl(String thumbNailImageUrl){ + this.thumbNailImageUrl = thumbNailImageUrl; + } + + public void updateInfo(Optional owner, Optional articleImage){ + owner.ifPresentOrElse( + o -> setOwnerNickName(o.getNickName()), + () -> setOwnerNickName("알 수 없는 사용자") + ); + articleImage.ifPresentOrElse( + image -> setThumbNailImageUrl(image.getImageUrl()), + () -> setThumbNailImageUrl("") + ); + } + + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/ModifyArticleResponse.java b/src/main/java/com/numberone/backend/domain/article/dto/response/ModifyArticleResponse.java new file mode 100644 index 00000000..88a57536 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/ModifyArticleResponse.java @@ -0,0 +1,41 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.numberone.backend.domain.article.entity.Article; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ModifyArticleResponse { + + private Long articleId; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime createdAt; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime modifiedAt; + + // 이미지 관련 + private List imageUrls; + private String thumbNailImageUrl; + + // 작성자 주소 + private String address; // todo: 더미 데이터 + + public static ModifyArticleResponse of(Article article, List imageUrls, String thumbNailImageUrl){ + return ModifyArticleResponse.builder() + .articleId(article.getId()) + .createdAt(article.getCreatedAt()) + .modifiedAt(article.getModifiedAt()) + .imageUrls(imageUrls) + .thumbNailImageUrl(thumbNailImageUrl) + .address("서울시 광진구 자양동") + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/article/dto/response/UploadArticleResponse.java b/src/main/java/com/numberone/backend/domain/article/dto/response/UploadArticleResponse.java new file mode 100644 index 00000000..fef61aa5 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/dto/response/UploadArticleResponse.java @@ -0,0 +1,36 @@ +package com.numberone.backend.domain.article.dto.response; + +import com.numberone.backend.domain.article.entity.Article; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.List; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UploadArticleResponse { + + private Long articleId; + private LocalDateTime createdAt; + + // 이미지 관련 + private List imageUrls; + private String thumbNailImageUrl; + + // 작성자 주소 + private String address; // todo: 더미 데이터 + + public static UploadArticleResponse of(Article article, List imageUrls, String thumbNailImageUrl){ + return UploadArticleResponse.builder() + .articleId(article.getId()) + .createdAt(article.getCreatedAt()) + .imageUrls(imageUrls) + .thumbNailImageUrl(thumbNailImageUrl) + .address(article.getAddress()) + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/article/entity/Article.java b/src/main/java/com/numberone/backend/domain/article/entity/Article.java new file mode 100644 index 00000000..c0bea662 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/entity/Article.java @@ -0,0 +1,107 @@ +package com.numberone.backend.domain.article.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.articleimage.entity.ArticleImage; +import com.numberone.backend.domain.articleparticipant.entity.ArticleParticipant; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.Comment; + +import java.util.ArrayList; +import java.util.List; + +@Comment("동네생활 게시글 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "ARTICLE") +public class Article extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "article_id") + private Long id; + + @OneToMany(mappedBy = "article", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) + private List comments = new ArrayList<>(); + + @OneToMany(mappedBy = "article", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) + private List articleParticipants = new ArrayList<>(); + + @OneToMany(mappedBy = "article", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) + private List articleImages = new ArrayList<>(); + + @Comment("썸네일 이미지 url ID") + private Long thumbNailImageUrlId; + + @Comment("게시글 제목") + private String title; + + @Comment("게시글 내용") + private String content; + + @Comment("게시글 태그 (일상:LIFE, 사기:FRAUD, 치안:SAFETY, 제보:REPORT)") + @Enumerated(EnumType.STRING) + private ArticleTag articleTag; + + @Comment("게시글 상태 (ACTIVATED, DELETED)") + @Enumerated(EnumType.STRING) + private ArticleStatus articleStatus; + + @Comment("게시글 작성 당시 주소") + private String address; + + @ColumnDefault("0") + @Comment("게시글 좋아요 개수") + private Integer likeCount; // todo: 동시성 처리 + + @ColumnDefault("0") + @Comment("게시글에 달린 댓글 개수") + private Integer commentCount; + + @Comment("작성자 ID") + private Long articleOwnerId; + + public Article(String title, String content, Long articleOwnerId, ArticleTag tag) { + this.title = title; + this.content = content; + this.articleOwnerId = articleOwnerId; + this.articleTag = tag; + this.articleStatus = ArticleStatus.ACTIVATED; + this.commentCount = 0; + this.likeCount = 0; + } + + public void updateArticleImage(List images, Long thumbNailImageUrlId) { + this.articleImages = images; + this.thumbNailImageUrlId = thumbNailImageUrlId; + } + + public void updateArticleStatus(ArticleStatus status) { + this.articleStatus = status; + } + + public void modifyArticle(String title, String content, ArticleTag tag) { + this.title = title; + this.content = content; + this.articleTag = tag; + } + + public void updateAddress(String address) { + this.address = address; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + +} diff --git a/src/main/java/com/numberone/backend/domain/article/entity/ArticleStatus.java b/src/main/java/com/numberone/backend/domain/article/entity/ArticleStatus.java new file mode 100644 index 00000000..97e66db9 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/entity/ArticleStatus.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.article.entity; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ArticleStatus { + ACTIVATED, + DELETED; + private String value; +} diff --git a/src/main/java/com/numberone/backend/domain/article/entity/ArticleTag.java b/src/main/java/com/numberone/backend/domain/article/entity/ArticleTag.java new file mode 100644 index 00000000..1ebf82a4 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/entity/ArticleTag.java @@ -0,0 +1,12 @@ +package com.numberone.backend.domain.article.entity; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum ArticleTag { + LIFE, // 일상 + FRAUD, // 사기 + SAFETY, // 치안 + REPORT; // 제보 + private String value; +} diff --git a/src/main/java/com/numberone/backend/domain/article/repository/ArticleRepository.java b/src/main/java/com/numberone/backend/domain/article/repository/ArticleRepository.java new file mode 100644 index 00000000..b0a9d891 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/repository/ArticleRepository.java @@ -0,0 +1,8 @@ +package com.numberone.backend.domain.article.repository; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.repository.custom.ArticleRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleRepository extends JpaRepository, ArticleRepositoryCustom { +} diff --git a/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustom.java b/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustom.java new file mode 100644 index 00000000..7b65f173 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.article.repository.custom; + +import com.numberone.backend.domain.article.dto.response.ArticleSearchParameter; +import com.numberone.backend.domain.article.dto.response.GetArticleListResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +public interface ArticleRepositoryCustom { + Slice getArticlesNoOffSetPaging(ArticleSearchParameter param, Pageable pageable); +} diff --git a/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustomImpl.java b/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustomImpl.java new file mode 100644 index 00000000..f4160bad --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/repository/custom/ArticleRepositoryCustomImpl.java @@ -0,0 +1,71 @@ +package com.numberone.backend.domain.article.repository.custom; + +import com.numberone.backend.domain.article.dto.response.ArticleSearchParameter; +import com.numberone.backend.domain.article.dto.response.GetArticleListResponse; +import com.numberone.backend.domain.article.dto.response.QGetArticleListResponse; +import com.numberone.backend.domain.article.entity.ArticleStatus; +import com.numberone.backend.domain.article.entity.ArticleTag; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.List; +import java.util.Objects; + +import static com.numberone.backend.domain.article.entity.QArticle.article; + +public class ArticleRepositoryCustomImpl implements ArticleRepositoryCustom { + private final JPAQueryFactory queryFactory; + + public ArticleRepositoryCustomImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public Slice getArticlesNoOffSetPaging(ArticleSearchParameter param, Pageable pageable) { + List result = queryFactory.select(new QGetArticleListResponse( + article, + article.articleOwnerId, + article.thumbNailImageUrlId + )) + .from(article) + .where( + ltArticleId(param.getLastArticleId()), + checkTagCondition(param.getTag()), + article.articleStatus.eq(ArticleStatus.ACTIVATED) + ) + .orderBy(article.id.desc()) + .limit(pageable.getPageSize() + 1) + .fetch(); + return checkLastPage(pageable, result); + } + + private BooleanExpression checkTagCondition(ArticleTag tag) { + if (Objects.isNull(tag)) { + return null; + } + return article.articleTag.eq(tag); + } + + private BooleanExpression ltArticleId(Long articleId) { + if (Objects.isNull(articleId)) { + return null; + } + return article.id.lt(articleId); + } + + private Slice checkLastPage(Pageable pageable, List result) { + boolean hasNext = false; + + if (result.size() > pageable.getPageSize()) { + hasNext = true; + result.remove(pageable.getPageSize()); + } + + return new SliceImpl<>(result, pageable, hasNext); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java b/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java new file mode 100644 index 00000000..c2c5d312 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/article/service/ArticleService.java @@ -0,0 +1,220 @@ +package com.numberone.backend.domain.article.service; + +import com.numberone.backend.domain.article.dto.request.ModifyArticleRequest; +import com.numberone.backend.domain.article.dto.request.UploadArticleRequest; +import com.numberone.backend.domain.article.dto.response.*; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.entity.ArticleStatus; +import com.numberone.backend.domain.article.repository.ArticleRepository; +import com.numberone.backend.domain.articleimage.entity.ArticleImage; +import com.numberone.backend.domain.articleimage.repository.ArticleImageRepository; +import com.numberone.backend.domain.articleparticipant.entity.ArticleParticipant; +import com.numberone.backend.domain.articleparticipant.repository.ArticleParticipantRepository; +import com.numberone.backend.domain.comment.dto.request.CreateCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateCommentResponse; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.CommentRepository; +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.notification.entity.NotificationTag; +import com.numberone.backend.domain.token.util.SecurityContextProvider; +import com.numberone.backend.exception.notfound.NotFoundArticleException; +import com.numberone.backend.exception.notfound.NotFoundMemberException; +import com.numberone.backend.support.fcm.service.FcmMessageProvider; +import com.numberone.backend.support.s3.S3Provider; +import com.numberone.backend.util.LocationProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import static com.numberone.backend.support.notification.NotificationMessage.ARTICLE_COMMENT_FCM_ALARM; + +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Service +public class ArticleService { + + private final ArticleRepository articleRepository; + private final MemberRepository memberRepository; + private final ArticleParticipantRepository articleParticipantRepository; + private final ArticleImageRepository articleImageRepository; + private final CommentRepository commentRepository; + private final S3Provider s3Provider; + private final LocationProvider locationProvider; + private final FcmMessageProvider fcmMessageProvider; + + @Transactional + public UploadArticleResponse uploadArticle(UploadArticleRequest request) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member owner = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + + // 1. 게시글 생성 ( 제목, 내용, 작성자 아이디, 태그) + Article article = articleRepository.save( + new Article( + request.getTitle(), + request.getContent(), + owner.getId(), + request.getArticleTag()) + ); + articleParticipantRepository.save( + new ArticleParticipant(article, owner) + ); + + // 2. 이미지 업로드 + List articleImages = new ArrayList<>(); + List imageUrls = new ArrayList<>(); + String thumbNailImageUrl = ""; + Long thumbNailImageId = 1L; + if (!Objects.isNull(request.getImageList())) { + // todo: refactoring + List imageList = request.getImageList(); + + for (int i = 0; i < imageList.size(); i++) { + String imageUrl = s3Provider.uploadImage(imageList.get(i)); + imageUrls.add(imageUrl); + + ArticleImage savedArticleImage = articleImageRepository.save( + new ArticleImage(article, imageUrl) + ); + articleImages.add(savedArticleImage); + if (Objects.equals(i, request.getThumbNailImageIdx())) { + thumbNailImageUrl = imageUrl; + thumbNailImageId = savedArticleImage.getId(); + } + + } + } + + // 3. 게시글 - 이미지 연관 관계 설정 + article.updateArticleImage(articleImages, thumbNailImageId); + + // 4. 작성자 주소 설정 + Double latitude = request.getLatitude(); + Double longitude = request.getLongitude(); + if (latitude != null && longitude != null) { + String address = locationProvider.pos2address(request.getLatitude(), request.getLongitude()); + article.updateAddress(address); + } + + return UploadArticleResponse.of(article, imageUrls, thumbNailImageUrl); + } + + + @Transactional + public DeleteArticleResponse deleteArticle(Long articleId) { + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + article.updateArticleStatus(ArticleStatus.DELETED); + return DeleteArticleResponse.of(article); + } + + public GetArticleDetailResponse getArticleDetail(Long articleId) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member owner = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + + List imageUrls = articleImageRepository.findByArticle(article) + .stream() + .map(ArticleImage::getImageUrl) + .toList(); + + + Optional thumbNailImage = articleImageRepository.findById(article.getThumbNailImageUrlId()); + + String thumbNailImageUrl = ""; + if (thumbNailImage.isPresent()) { + thumbNailImageUrl = thumbNailImage.get().getImageUrl(); + } + + return GetArticleDetailResponse.of(article, imageUrls, thumbNailImageUrl, owner); + } + + public Slice getArticleListPaging(ArticleSearchParameter param, Pageable pageable) { + return new SliceImpl<>( + articleRepository.getArticlesNoOffSetPaging(param, pageable) + .stream() + .peek(this::updateArticleInfo) + .toList() + ); + } + + public void updateArticleInfo(GetArticleListResponse articleInfo) { + Long ownerId = articleInfo.getOwnerId(); + Long thumbNailImageUrlId = articleInfo.getThumbNailImageId(); + + Optional owner = memberRepository.findById(ownerId); + Optional articleImage = articleImageRepository.findById(thumbNailImageUrlId); + + articleInfo.updateInfo(owner, articleImage); + } + + @Transactional + public CreateCommentResponse createComment(Long articleId, CreateCommentRequest request) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + CommentEntity savedComment = commentRepository.save( + new CommentEntity(request.getContent(), article, member) + ); + + articleParticipantRepository.save(new ArticleParticipant(article, member)); + fcmMessageProvider.sendFcm(member, ARTICLE_COMMENT_FCM_ALARM, NotificationTag.COMMUNITY); + + return CreateCommentResponse.of(savedComment); + } + + @Transactional + public ModifyArticleResponse modifyArticle(Long articleId, ModifyArticleRequest request) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + + article.modifyArticle(request.getTitle(), request.getContent(), request.getArticleTag()); + + + List articleImages = new ArrayList<>(); + List imageUrls = new ArrayList<>(); + String thumbNailImageUrl = ""; + Long thumbNailImageId = 1L; + if (!Objects.isNull(request.getImageList())) { + // todo: refactoring + List imageList = request.getImageList(); + + for (int i = 0; i < imageList.size(); i++) { + String imageUrl = s3Provider.uploadImage(imageList.get(i)); + imageUrls.add(imageUrl); + + ArticleImage savedArticleImage = articleImageRepository.save( + new ArticleImage(article, imageUrl) + ); + articleImages.add(savedArticleImage); + if (Objects.equals(i, request.getThumbNailImageIdx())) { + thumbNailImageUrl = imageUrl; + thumbNailImageId = savedArticleImage.getId(); + } + + } + article.updateArticleImage(articleImages, thumbNailImageId); + } + + return ModifyArticleResponse.of(article, imageUrls, thumbNailImageUrl); + } +} diff --git a/src/main/java/com/numberone/backend/domain/articleimage/entity/ArticleImage.java b/src/main/java/com/numberone/backend/domain/articleimage/entity/ArticleImage.java new file mode 100644 index 00000000..2fdae5f5 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/articleimage/entity/ArticleImage.java @@ -0,0 +1,33 @@ +package com.numberone.backend.domain.articleimage.entity; + +import com.numberone.backend.domain.article.entity.Article; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("동네생활 게시글 이미지 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "ARTICLE_IMAGE") +public class ArticleImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "article_id") + private Article article; + + @Comment("동네생활 게시글 이미지 URL") + private String imageUrl; + + + public ArticleImage(Article article, String imageUrl){ + this.article = article; + this.imageUrl = imageUrl; + } +} diff --git a/src/main/java/com/numberone/backend/domain/articleimage/repository/ArticleImageRepository.java b/src/main/java/com/numberone/backend/domain/articleimage/repository/ArticleImageRepository.java new file mode 100644 index 00000000..08bf579b --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/articleimage/repository/ArticleImageRepository.java @@ -0,0 +1,12 @@ +package com.numberone.backend.domain.articleimage.repository; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.articleimage.entity.ArticleImage; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ArticleImageRepository extends JpaRepository { + + List findByArticle(Article article); +} diff --git a/src/main/java/com/numberone/backend/domain/articleparticipant/entity/ArticleParticipant.java b/src/main/java/com/numberone/backend/domain/articleparticipant/entity/ArticleParticipant.java new file mode 100644 index 00000000..0e3e9452 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/articleparticipant/entity/ArticleParticipant.java @@ -0,0 +1,36 @@ +package com.numberone.backend.domain.articleparticipant.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("동네생활 게시글 참여자") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "ARTICLE_PARTICIPANT") +public class ArticleParticipant extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "article_participant_id") + private Long id; + + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "article_id") + private Article article; + + @Comment("회원 id") + private Long memberId; + + public ArticleParticipant(Article article, Member member){ + this.article = article; + this.memberId= member.getId(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/articleparticipant/repository/ArticleParticipantRepository.java b/src/main/java/com/numberone/backend/domain/articleparticipant/repository/ArticleParticipantRepository.java new file mode 100644 index 00000000..6e57bde9 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/articleparticipant/repository/ArticleParticipantRepository.java @@ -0,0 +1,7 @@ +package com.numberone.backend.domain.articleparticipant.repository; + +import com.numberone.backend.domain.articleparticipant.entity.ArticleParticipant; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ArticleParticipantRepository extends JpaRepository { +} diff --git a/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java b/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java new file mode 100644 index 00000000..6ccf4158 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/controller/CommentController.java @@ -0,0 +1,65 @@ +package com.numberone.backend.domain.comment.controller; + +import com.numberone.backend.domain.comment.dto.request.CreateChildCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateChildCommentResponse; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.List; + +@Slf4j +@RequestMapping("api/comments") +@RequiredArgsConstructor +@RestController +public class CommentController { + + private final CommentService commentService; + + @Operation(summary = "대댓글 작성 API", description = """ + comment-id 는 부모 댓글의 id 입니다. + article-id 와 comment-id 는 모두 path variable 으로 보내주세요! + """) + @PostMapping("{article-id}/{comment-id}") + public ResponseEntity createChildComment( + @PathVariable("article-id") Long articleId, + @PathVariable("comment-id") Long commentId, + @RequestBody @Valid CreateChildCommentRequest request) { + CreateChildCommentResponse response = commentService.createChildComment(articleId, commentId, request); + return ResponseEntity.created( + URI.create(String.format("/comments/%s/%s", articleId, commentId))) + .body(response); + } + + @Operation(summary = "해당 게시물에 달린 댓긂을 모두 조회하는 API 입니다.", description = """ + 해당 게시물에 달린 댓글을 계층 형태로 조회합니다. + + - Long commentId : 댓글 아이디 + - Long parentCommentId : 부모 댓글의 야이디 (nullable) + - List childComments = new ArrayList<>() : 대댓글 리스트 + - Integer likeCount : 해당 댓글의 좋아요 개수 + - LocalDateTime createdAt : 해당 댓글의 생성 시각 + - LocalDateTime modifiedAt : 해당 댓글의 마지막 수정 시각 + - String content : 해당 댓글의 내용 + - Long authorId : 해당 댓글의 작성자 아이디 + - String authorNickName : 해당 댓글의 작성자 닉네임 + - String authorProfileImageUrl : 해당 댓글 작성자의 프로필 사진 url + + 댓글 작성자가 추후에 탈퇴하는 경우를 고려했는데, + authorNickName 이 "알수없는 사용자" 로 변경되어 내려갑니다..! + """) + @GetMapping("{article-id}") + public ResponseEntity> getCommentsByArticle(@PathVariable("article-id") Long articleId){ + List response = commentService.getCommentsByArticle(articleId); // todo: 해당 유저가 좋아요를 눌렀는지 여부까지 표시되도록 수정 + return ResponseEntity.ok(response); + } + + // todo: 댓글 삭제, 가장 많은 좋아요 상단 고정, 대댓글 달리면 푸시 알람 전송, 상단 고정된 작성자에게 푸시알람 전송, 댓글 신고 기능 + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java new file mode 100644 index 00000000..5b765e43 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateChildCommentRequest.java @@ -0,0 +1,15 @@ +package com.numberone.backend.domain.comment.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateChildCommentRequest { + @NotNull + private String content; + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateCommentRequest.java b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateCommentRequest.java new file mode 100644 index 00000000..057e6a72 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/request/CreateCommentRequest.java @@ -0,0 +1,20 @@ +package com.numberone.backend.domain.comment.dto.request; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import jakarta.validation.constraints.NotNull; +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateCommentRequest { + + @NotNull + private String content; + + private Double longitude; + private Double latitude; + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java new file mode 100644 index 00000000..80d65519 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateChildCommentResponse.java @@ -0,0 +1,25 @@ +package com.numberone.backend.domain.comment.dto.response; + +import com.numberone.backend.domain.comment.entity.CommentEntity; +import lombok.*; + +import java.time.LocalDateTime; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateChildCommentResponse { + + private LocalDateTime createdAt; + private Long commentId; + + public static CreateChildCommentResponse of (CommentEntity comment){ + return CreateChildCommentResponse.builder() + .createdAt(comment.getCreatedAt()) + .commentId(comment.getId()) + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java new file mode 100644 index 00000000..d1cbf278 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/CreateCommentResponse.java @@ -0,0 +1,24 @@ +package com.numberone.backend.domain.comment.dto.response; + +import com.numberone.backend.domain.comment.entity.CommentEntity; +import lombok.*; + +import java.time.LocalDateTime; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CreateCommentResponse { + private LocalDateTime createdAt; + private Long commentId; + + public static CreateCommentResponse of (CommentEntity comment){ + return CreateCommentResponse.builder() + .createdAt(comment.getCreatedAt()) + .commentId(comment.getId()) + .build(); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java b/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java new file mode 100644 index 00000000..4fe99af3 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/dto/response/GetCommentDto.java @@ -0,0 +1,62 @@ +package com.numberone.backend.domain.comment.dto.response; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.member.entity.Member; +import com.querydsl.core.annotations.QueryProjection; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GetCommentDto { + + private Long commentId; + private Long parentCommentId; + private List childComments = new ArrayList<>(); + private Integer likeCount; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime createdAt; + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd hh:mm:ss a", timezone = "Asia/Seoul") + private LocalDateTime modifiedAt; + private String content; + private Long authorId; + private String authorNickName; + private String authorProfileImageUrl; + + + @QueryProjection + public GetCommentDto(CommentEntity comment){ + if(!Objects.isNull(comment.getParent())){ + this.parentCommentId = comment.getParent().getId(); + } + this.commentId = comment.getId(); + this.createdAt = comment.getCreatedAt(); + this.modifiedAt = comment.getModifiedAt(); + this.content = comment.getContent(); + this.authorId = comment.getAuthorId(); + this.likeCount = comment.getLikeCount(); + } + + public void updateCommentInfo(Optional author){ + author.ifPresentOrElse( + a -> { + this.authorNickName = a.getNickName(); + this.authorProfileImageUrl = a.getProfileImageUrl(); + }, + () -> { + this.authorNickName = "알 수 없는 사용자"; + } + ); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java b/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java new file mode 100644 index 00000000..91fb1299 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/entity/CommentEntity.java @@ -0,0 +1,71 @@ +package com.numberone.backend.domain.comment.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +import java.util.ArrayList; +import java.util.List; + +@Comment("동네생활 댓글 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "COMMENT") +public class CommentEntity extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "comment_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "article_id") + private Article article; + + @Comment("댓글 depth {0: 댓글, 1: 대댓글}") + private Integer depth; + + @Comment("댓글 좋아요 개수") + private Integer likeCount; // todo: 동시성 처리 + + @Comment("댓글 내용") + private String content; + + @Comment("작성자 아이디") + private Long authorId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private CommentEntity parent; + + @OneToMany(mappedBy = "parent", orphanRemoval = true) + private List childs = new ArrayList<>(); + + public CommentEntity(String content, Article article, Member author) { + this.depth = 0; + this.content = content; + this.article = article; + this.authorId = author.getId(); + this.likeCount = 0; + } + + public void updateParent(CommentEntity parent) { + this.parent = parent; + } + + public void increaseLikeCount() { + this.likeCount++; + } + + public void decreaseLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java b/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java new file mode 100644 index 00000000..6ffcf463 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/repository/CommentRepository.java @@ -0,0 +1,8 @@ +package com.numberone.backend.domain.comment.repository; + +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.custom.CommentRepositoryCustom; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository, CommentRepositoryCustom { +} diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java new file mode 100644 index 00000000..fe326c39 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.comment.repository.custom; + +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; + +import java.util.List; + +public interface CommentRepositoryCustom { + public List findAllByArticle(Long articleId); + +} diff --git a/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java new file mode 100644 index 00000000..69331895 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/repository/custom/CommentRepositoryCustomImpl.java @@ -0,0 +1,34 @@ +package com.numberone.backend.domain.comment.repository.custom; + +import com.numberone.backend.domain.article.entity.QArticle; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.dto.response.QGetCommentDto; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; + +import java.util.List; + +import static com.numberone.backend.domain.article.entity.QArticle.article; +import static com.numberone.backend.domain.comment.entity.QCommentEntity.commentEntity; + +public class CommentRepositoryCustomImpl implements CommentRepositoryCustom { + private final JPAQueryFactory queryFactory; + + public CommentRepositoryCustomImpl(EntityManager em) { + this.queryFactory = new JPAQueryFactory(em); + } + + @Override + public List findAllByArticle(Long articleId) { + return queryFactory.select(new QGetCommentDto(commentEntity)) + .from(commentEntity) + .leftJoin(commentEntity.parent) + .fetchJoin() + .where(commentEntity.article.id.eq(articleId)) + .orderBy( + commentEntity.parent.id.asc().nullsFirst(), + commentEntity.createdAt.asc() + ) + .fetch(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java b/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java new file mode 100644 index 00000000..ed8b83d7 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/comment/service/CommentService.java @@ -0,0 +1,88 @@ +package com.numberone.backend.domain.comment.service; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.repository.ArticleRepository; +import com.numberone.backend.domain.articleparticipant.entity.ArticleParticipant; +import com.numberone.backend.domain.articleparticipant.repository.ArticleParticipantRepository; +import com.numberone.backend.domain.comment.dto.request.CreateChildCommentRequest; +import com.numberone.backend.domain.comment.dto.response.CreateChildCommentResponse; +import com.numberone.backend.domain.comment.dto.response.GetCommentDto; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.CommentRepository; +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.token.util.SecurityContextProvider; +import com.numberone.backend.exception.notfound.NotFoundArticleException; +import com.numberone.backend.exception.notfound.NotFoundCommentException; +import com.numberone.backend.exception.notfound.NotFoundMemberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +@Slf4j +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class CommentService { + + private final CommentRepository commentRepository; + private final ArticleRepository articleRepository; + private final ArticleParticipantRepository articleParticipantRepository; + private final MemberRepository memberRepository; + + @Transactional + public CreateChildCommentResponse createChildComment( + Long articleId, + Long parentCommentId, + CreateChildCommentRequest request) { + + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + CommentEntity parentComment = commentRepository.findById(parentCommentId) + .orElseThrow(NotFoundCommentException::new); + + CommentEntity childComment = commentRepository.save(new CommentEntity(request.getContent(), article, member)); + childComment.updateParent(parentComment); + + articleParticipantRepository.save(new ArticleParticipant(article, member)); + + return CreateChildCommentResponse.of(childComment); + } + + public List getCommentsByArticle(Long articleId) { + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundArticleException::new); + List comments = commentRepository.findAllByArticle(article.getId()); + + // 계층 구조로 변환 (추후 리팩토링 필요) + List result = new ArrayList<>(); + Map map = new HashMap<>(); + comments.forEach( + comment -> { + CommentEntity commentEntity = commentRepository.findById(comment.getCommentId()) + .orElseThrow(NotFoundCommentException::new); + Optional author = memberRepository.findById(commentEntity.getAuthorId()); + comment.updateCommentInfo(author); + + map.put(comment.getCommentId(), comment); + + if (comment.getParentCommentId() != null){ + GetCommentDto parentComment = map.get(comment.getParentCommentId()); + List childComments = parentComment.getChildComments(); + childComments.add(comment); + } else { + result.add(comment); + } + } + ); + + return result; + } + +} diff --git a/src/main/java/com/numberone/backend/domain/like/controller/LikeController.java b/src/main/java/com/numberone/backend/domain/like/controller/LikeController.java new file mode 100644 index 00000000..7a2ba262 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/controller/LikeController.java @@ -0,0 +1,48 @@ +package com.numberone.backend.domain.like.controller; + +import com.numberone.backend.domain.like.dto.response.response.ArticleLikeResponse; +import com.numberone.backend.domain.like.dto.response.response.CommentLikeResponse; +import com.numberone.backend.support.redis.like.service.RedisLockLikeFacade; +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/likes") +public class LikeController { + private final RedisLockLikeFacade redisLockLikeFacade; + + @Operation(summary = "게시글 좋아요 추가") + @PutMapping("articles/{article-id}/add") + public ResponseEntity addArticleLike( + @PathVariable("article-id") Long articleId) throws InterruptedException { + return ResponseEntity.ok(redisLockLikeFacade.increaseArticleLike(articleId)); + } + + @Operation(summary = "게시글 좋아요 취소") + @PutMapping("articles/{article-id}/cancel") + public ResponseEntity cancelArticleLike( + @PathVariable("article-id") Long articleId) throws InterruptedException { + return ResponseEntity.ok(redisLockLikeFacade.decreaseArticleLike(articleId)); + } + + @Operation(summary = "댓글 좋아요 추가") + @PutMapping("comments/{comment-id}/add") + public ResponseEntity addCommentLike( + @PathVariable("comment-id") Long commentId) throws InterruptedException { + return ResponseEntity.ok(redisLockLikeFacade.increaseCommentLike(commentId)); + } + + @Operation(summary = "댓글 좋아요 취소") + @PutMapping("comments/{comment-id}/cancel") + public ResponseEntity cancelCommentLike( + @PathVariable("comment-id") Long commentId) throws InterruptedException { + return ResponseEntity.ok(redisLockLikeFacade.decreaseCommentLike(commentId)); + } + + +} diff --git a/src/main/java/com/numberone/backend/domain/like/dto/response/response/ArticleLikeResponse.java b/src/main/java/com/numberone/backend/domain/like/dto/response/response/ArticleLikeResponse.java new file mode 100644 index 00000000..6e66a9e2 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/dto/response/response/ArticleLikeResponse.java @@ -0,0 +1,13 @@ +package com.numberone.backend.domain.like.dto.response.response; + +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ArticleLikeResponse { + private Long articleId; + private Integer currentLikeCount; +} diff --git a/src/main/java/com/numberone/backend/domain/like/dto/response/response/CommentLikeResponse.java b/src/main/java/com/numberone/backend/domain/like/dto/response/response/CommentLikeResponse.java new file mode 100644 index 00000000..2c41c8d2 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/dto/response/response/CommentLikeResponse.java @@ -0,0 +1,13 @@ +package com.numberone.backend.domain.like.dto.response.response; + +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class CommentLikeResponse { + private Long commentId; + private Integer currentLikeCount; +} diff --git a/src/main/java/com/numberone/backend/domain/like/entity/ArticleLike.java b/src/main/java/com/numberone/backend/domain/like/entity/ArticleLike.java new file mode 100644 index 00000000..53f0dbe9 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/entity/ArticleLike.java @@ -0,0 +1,33 @@ +package com.numberone.backend.domain.like.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("게시글 좋아요 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "ARTICLE_LIKE") +public class ArticleLike extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "member_id") + private Member member; + + private Long articleId; + + public ArticleLike(Member member, Article article){ + this.member = member; + this.articleId = article.getId(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/like/entity/CommentLike.java b/src/main/java/com/numberone/backend/domain/like/entity/CommentLike.java new file mode 100644 index 00000000..36759115 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/entity/CommentLike.java @@ -0,0 +1,33 @@ +package com.numberone.backend.domain.like.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("댓글 좋아요 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "COMMENT_LIKE") +public class CommentLike extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST) + @JoinColumn(name = "member_id") + private Member member; + + private Long commentId; + + public CommentLike(Member member, CommentEntity comment) { + this.member = member; + this.commentId = comment.getId(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/like/repository/ArticleLikeRepository.java b/src/main/java/com/numberone/backend/domain/like/repository/ArticleLikeRepository.java new file mode 100644 index 00000000..390e5ecf --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/repository/ArticleLikeRepository.java @@ -0,0 +1,13 @@ +package com.numberone.backend.domain.like.repository; + +import com.numberone.backend.domain.like.entity.ArticleLike; +import com.numberone.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface ArticleLikeRepository extends JpaRepository { + + List findByMember(Member member); + +} diff --git a/src/main/java/com/numberone/backend/domain/like/repository/CommentLikeRepository.java b/src/main/java/com/numberone/backend/domain/like/repository/CommentLikeRepository.java new file mode 100644 index 00000000..c6ee3d8f --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/repository/CommentLikeRepository.java @@ -0,0 +1,11 @@ +package com.numberone.backend.domain.like.repository; + +import com.numberone.backend.domain.like.entity.CommentLike; +import com.numberone.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CommentLikeRepository extends JpaRepository { + List findByMember(Member member); +} diff --git a/src/main/java/com/numberone/backend/domain/like/service/LikeService.java b/src/main/java/com/numberone/backend/domain/like/service/LikeService.java new file mode 100644 index 00000000..c43c90d7 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/like/service/LikeService.java @@ -0,0 +1,122 @@ +package com.numberone.backend.domain.like.service; + +import com.numberone.backend.domain.article.entity.Article; +import com.numberone.backend.domain.article.repository.ArticleRepository; +import com.numberone.backend.domain.comment.entity.CommentEntity; +import com.numberone.backend.domain.comment.repository.CommentRepository; +import com.numberone.backend.domain.like.entity.ArticleLike; +import com.numberone.backend.domain.like.entity.CommentLike; +import com.numberone.backend.domain.like.repository.ArticleLikeRepository; +import com.numberone.backend.domain.like.repository.CommentLikeRepository; +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.token.util.SecurityContextProvider; +import com.numberone.backend.exception.conflict.AlreadyLikedException; +import com.numberone.backend.exception.conflict.AlreadyUnLikedException; +import com.numberone.backend.exception.notfound.NotFoundApiException; +import com.numberone.backend.exception.notfound.NotFoundCommentException; +import com.numberone.backend.exception.notfound.NotFoundMemberException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeService { + + private final ArticleLikeRepository articleLikeRepository; + private final ArticleRepository articleRepository; + private final CommentLikeRepository commentLikeRepository; + private final CommentRepository commentRepository; + private final MemberRepository memberRepository; + + + @Transactional + public Integer increaseArticleLike(Long articleId) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundApiException::new); + if (isAlreadyLikedArticle(member, articleId)) { + // 이미 좋아요를 누른 게시글입니다. + throw new AlreadyLikedException(); + } + article.increaseLikeCount(); + articleLikeRepository.save(new ArticleLike(member, article)); + + return article.getLikeCount(); + } + + @Transactional + public Integer decreaseArticleLike(Long articleId) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + Article article = articleRepository.findById(articleId) + .orElseThrow(NotFoundApiException::new); + if (!isAlreadyLikedArticle(member, articleId)) { + // 좋아요를 누르지 않은 게시글이라 취소할 수 없습니다. + throw new AlreadyUnLikedException(); + } + article.decreaseLikeCount(); + + // 사용자의 게시글 좋아요 목록에서 제거 + List articleLikeList = articleLikeRepository.findByMember(member); + articleLikeList.removeIf(articleLike -> articleLike.getArticleId().equals(articleId)); + + return article.getLikeCount(); + } + + @Transactional + public Integer increaseCommentLike(Long commentId) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + CommentEntity commentEntity = commentRepository.findById(commentId) + .orElseThrow(NotFoundCommentException::new); + if (isAlreadyLikedComment(member, commentId)) { + // 이미 좋아요를 누른 댓글입니다. + throw new AlreadyLikedException(); + } + commentEntity.increaseLikeCount(); + commentLikeRepository.save(new CommentLike(member, commentEntity)); + + return commentEntity.getLikeCount(); + } + + @Transactional + public Integer decreaseCommentLike(Long commentId) { + String principal = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(principal) + .orElseThrow(NotFoundMemberException::new); + CommentEntity commentEntity = commentRepository.findById(commentId) + .orElseThrow(NotFoundCommentException::new); + if (!isAlreadyLikedComment(member, commentId)){ + // 좋아요를 누르지 않은 댓글이라 좋아요를 취소할 수 없습니다. + throw new AlreadyUnLikedException(); + } + commentEntity.decreaseLikeCount(); + // 사용자의 댓글 좋아요 목록에서 제거 + List commentLikeList = commentLikeRepository.findByMember(member); + commentLikeList.removeIf(commentLike -> commentLike.getCommentId().equals(commentId)); + + return commentEntity.getLikeCount(); + } + + private boolean isAlreadyLikedArticle(Member member, Long articleId) { + return articleLikeRepository.findByMember(member).stream() + .anyMatch(articleLike -> articleLike.getArticleId().equals(articleId)); + } + + private boolean isAlreadyLikedComment(Member member, Long commentId) { + return commentLikeRepository.findByMember(member).stream() + .anyMatch(commentLike -> commentLike.getCommentId().equals(commentId)); + } + +} diff --git a/src/main/java/com/numberone/backend/domain/member/controller/MemberController.java b/src/main/java/com/numberone/backend/domain/member/controller/MemberController.java index c8609d97..64e7cea8 100644 --- a/src/main/java/com/numberone/backend/domain/member/controller/MemberController.java +++ b/src/main/java/com/numberone/backend/domain/member/controller/MemberController.java @@ -3,11 +3,14 @@ import com.numberone.backend.domain.member.dto.request.OnboardingRequest; import com.numberone.backend.domain.member.dto.request.BuyHeartRequest; import com.numberone.backend.domain.member.dto.response.HeartCntResponse; +import com.numberone.backend.domain.member.dto.response.UploadProfileImageResponse; import com.numberone.backend.domain.member.service.MemberService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -18,14 +21,29 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URI; +@Slf4j @Tag(name = "members", description = "사용자 관련 API") @RestController @RequiredArgsConstructor -@RequestMapping("/api/members") public class MemberController { private final MemberService memberService; + @Operation(summary = "회원 프로필 사진 업로드 API", + description = """ + 1. 반드시 access token 을 헤더에 포함하여 호출해주세요. (유저를 식별하기 위함입니다.) + + 2. 프로필 사진은 MultipartFile 으로 반드시 image 라는 이름으로 보내주세요 + """) + @PostMapping("/profile-image") + public ResponseEntity uploadMemberProfileImage(@RequestPart("image") MultipartFile image) { + return ResponseEntity.created(URI.create("/api/members/profile-image")) + .body(memberService.uploadProfileImage(image)); + } + @PostMapping("/heart") @Operation(summary = "마음 구입하기", description = """ 구입한 마음 갯수를 body에 담아 전달해주세요. diff --git a/src/main/java/com/numberone/backend/domain/member/dto/response/UploadProfileImageResponse.java b/src/main/java/com/numberone/backend/domain/member/dto/response/UploadProfileImageResponse.java new file mode 100644 index 00000000..31992a5b --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/member/dto/response/UploadProfileImageResponse.java @@ -0,0 +1,19 @@ +package com.numberone.backend.domain.member.dto.response; + +import lombok.*; + +@ToString +@Builder +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UploadProfileImageResponse { + + private String imageUrl; + + public static UploadProfileImageResponse of(String imageUrl) { + return UploadProfileImageResponse.builder() + .imageUrl(imageUrl) + .build(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/member/entity/Member.java b/src/main/java/com/numberone/backend/domain/member/entity/Member.java index adfe9936..873322c0 100644 --- a/src/main/java/com/numberone/backend/domain/member/entity/Member.java +++ b/src/main/java/com/numberone/backend/domain/member/entity/Member.java @@ -1,9 +1,16 @@ package com.numberone.backend.domain.member.entity; +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.like.entity.ArticleLike; +import com.numberone.backend.domain.like.entity.CommentLike; +import jakarta.persistence.*; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; import com.numberone.backend.domain.notificationdisaster.entity.NotificationDisaster; import com.numberone.backend.domain.notificationregion.entity.NotificationRegion; import com.numberone.backend.domain.support.entity.Support; -import jakarta.persistence.*; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -13,10 +20,12 @@ import java.util.ArrayList; import java.util.List; +@Comment("회원 정보") @Entity @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter -public class Member { +@Table(name = "MEMBER") +public class Member extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -36,6 +45,18 @@ public class Member { @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) private List supports = new ArrayList<>(); + @OneToMany(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) + private List commentLikes = new ArrayList<>(); + + @OneToMany(mappedBy = "member", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, orphanRemoval = true) + private List articleLikes = new ArrayList<>(); + + @Comment("회원 프로필 사진 URL") + private String profileImageUrl; + + @Comment("FCM 토큰") + private String fcmToken; + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) private List notificationDisasters = new ArrayList<>(); @@ -58,8 +79,12 @@ public static Member of(String email, String realName) { .build(); } - public void updateNickname(String nickname) { - this.nickName = nickname; + public void updateProfileImageUrl(String imageUrl) { + this.profileImageUrl = imageUrl; + } + + public void updateNickName(String nickName) { + this.nickName = nickName; } public void plusHeart(int heart) { diff --git a/src/main/java/com/numberone/backend/domain/member/service/MemberService.java b/src/main/java/com/numberone/backend/domain/member/service/MemberService.java index 4f222276..bca50289 100644 --- a/src/main/java/com/numberone/backend/domain/member/service/MemberService.java +++ b/src/main/java/com/numberone/backend/domain/member/service/MemberService.java @@ -1,5 +1,6 @@ package com.numberone.backend.domain.member.service; +import com.numberone.backend.domain.member.dto.response.UploadProfileImageResponse; import com.numberone.backend.domain.disaster.util.DisasterType; import com.numberone.backend.domain.member.dto.request.OnboardingAddress; import com.numberone.backend.domain.member.dto.request.OnboardingDisasterType; @@ -8,20 +9,26 @@ import com.numberone.backend.domain.member.dto.response.HeartCntResponse; import com.numberone.backend.domain.member.entity.Member; import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.token.util.SecurityContextProvider; import com.numberone.backend.domain.notificationdisaster.entity.NotificationDisaster; import com.numberone.backend.domain.notificationdisaster.repository.NotificationDisasterRepository; import com.numberone.backend.domain.notificationregion.entity.NotificationRegion; import com.numberone.backend.domain.notificationregion.repository.NotificationRegionRepository; import com.numberone.backend.exception.notfound.NotFoundMemberException; +import com.numberone.backend.support.s3.S3Provider; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @RequiredArgsConstructor @Service +@Slf4j @Transactional(readOnly = true) public class MemberService { private final MemberRepository memberRepository; + private final S3Provider s3Provider; private final NotificationDisasterRepository notificationDisasterRepository; private final NotificationRegionRepository notificationRegionRepository; @@ -31,15 +38,15 @@ public Member findByEmail(String email) { } @Transactional - public void create(String email, String realname) { - memberRepository.save(Member.of(email, realname)); + public void create(String email, String realName) { + memberRepository.save(Member.of(email, realName)); } @Transactional public void initMemberData(String email, OnboardingRequest onboardingRequest) { Member member = memberRepository.findByEmail(email) .orElseThrow(NotFoundMemberException::new); - member.updateNickname(onboardingRequest.getNickname()); + member.updateNickName(onboardingRequest.getNickname()); notificationDisasterRepository.deleteAllByMemberId(member.getId()); notificationRegionRepository.deleteAllByMemberId(member.getId()); for (OnboardingAddress address : onboardingRequest.getAddresses()) { @@ -71,4 +78,19 @@ public HeartCntResponse getHeart(String email) { .orElseThrow(NotFoundMemberException::new); return HeartCntResponse.of(member); } + + @Transactional + public UploadProfileImageResponse uploadProfileImage(MultipartFile image){ + String email = SecurityContextProvider.getAuthenticatedUserEmail(); + Member member = memberRepository.findByEmail(email) + .orElseThrow(NotFoundMemberException::new); + String imageUrl = s3Provider.uploadImage(image); + + log.info("[회원의 프로필 이미지를 업로드하였습니다.] id:{} url:{}", member.getId(), imageUrl); + + member.updateProfileImageUrl(imageUrl); + + return UploadProfileImageResponse.of(imageUrl); + } + } diff --git a/src/main/java/com/numberone/backend/domain/notification/controller/NotificationController.java b/src/main/java/com/numberone/backend/domain/notification/controller/NotificationController.java deleted file mode 100644 index 0078b16c..00000000 --- a/src/main/java/com/numberone/backend/domain/notification/controller/NotificationController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.numberone.backend.domain.notification.controller; - -import com.numberone.backend.domain.notification.dto.SendFcmRequest; -import com.numberone.backend.domain.notification.dto.SendFcmResponse; -import com.numberone.backend.domain.notification.service.NotificationService; -import io.swagger.v3.oas.annotations.Operation; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -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; - -@Slf4j -@RequestMapping("/notification") -@RestController -@RequiredArgsConstructor -public class NotificationController { - private final NotificationService notificationService; - - @Operation(summary = "fcm 푸시알람 테스트 용 API 입니다.", - description = " 테스트 해본 뒤, 성공 여부를 알려주세요. 🥲") - @PostMapping("/send-fcm") - public ResponseEntity sendFcmNotification(@RequestBody SendFcmRequest request) { - /* FCM 푸시알람 API 테스트 용 서비스 로직입니다. */ - notificationService.sendFcm(request); - return ResponseEntity.ok(new SendFcmResponse("메세지 전송 완료, 성공 여부를 백엔드 팀에게 알려주세요.")); - } - -} diff --git a/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmRequest.java b/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmRequest.java deleted file mode 100644 index 9dfbf107..00000000 --- a/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmRequest.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.numberone.backend.domain.notification.dto; - -import jakarta.validation.constraints.NotNull; -import lombok.*; - -@ToString -@Builder -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SendFcmRequest { - @NotNull(message = "fcmToken 은 null 일 수 없습니다.") - private String fcmToken; - - private String body; - private String title; - private String imageUrl; - -} diff --git a/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmResponse.java b/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmResponse.java deleted file mode 100644 index fc640142..00000000 --- a/src/main/java/com/numberone/backend/domain/notification/dto/SendFcmResponse.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.numberone.backend.domain.notification.dto; - -import lombok.*; - -@ToString -@Builder -@Getter -@AllArgsConstructor -@NoArgsConstructor(access = AccessLevel.PROTECTED) -public class SendFcmResponse { - private String msg; -} diff --git a/src/main/java/com/numberone/backend/domain/notification/entity/NotificationEntity.java b/src/main/java/com/numberone/backend/domain/notification/entity/NotificationEntity.java new file mode 100644 index 00000000..67d000aa --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/notification/entity/NotificationEntity.java @@ -0,0 +1,54 @@ +package com.numberone.backend.domain.notification.entity; + +import com.numberone.backend.config.basetime.BaseTimeEntity; +import com.numberone.backend.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Comment("알림 정보") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +@Table(name = "NOTIFICATION") +public class NotificationEntity extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Comment("알림 대상 회원의 ID") + private Long receivedMemberId; + + @Comment("알림 대상 회원의 닉네임") + private String nickName; + + @Comment("알림 종류") + @Enumerated(EnumType.STRING) + private NotificationTag notificationTag; + + @Comment("알림 제목") + private String title; + + @Comment("알림 내용") + private String body; + + @Comment("알림 정상 전송 여부") + private Boolean isSent; + + @Comment("확인한 알림인지 여부") + private Boolean isRead; + + public NotificationEntity(Member member, NotificationTag tag, String title, String body, Boolean isSent) { + this.receivedMemberId = member.getId(); + this.nickName = member.getNickName(); + this.notificationTag = tag; + this.title = title; + this.body = body; + this.isSent = isSent; + isRead = false; + } + +} diff --git a/src/main/java/com/numberone/backend/domain/notification/entity/NotificationTag.java b/src/main/java/com/numberone/backend/domain/notification/entity/NotificationTag.java new file mode 100644 index 00000000..64614cdd --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/notification/entity/NotificationTag.java @@ -0,0 +1,12 @@ +package com.numberone.backend.domain.notification.entity; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum NotificationTag { + COMMUNITY, // 커뮤니티 + SUPPORT, // 후원 + FAMILY, // 가족 + ; + private String value; +} diff --git a/src/main/java/com/numberone/backend/domain/notification/repository/NotificationRepository.java b/src/main/java/com/numberone/backend/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..1ac16808 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,7 @@ +package com.numberone.backend.domain.notification.repository; + +import com.numberone.backend.domain.notification.entity.NotificationEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface NotificationRepository extends JpaRepository { +} diff --git a/src/main/java/com/numberone/backend/domain/notification/service/NotificationService.java b/src/main/java/com/numberone/backend/domain/notification/service/NotificationService.java deleted file mode 100644 index e052044b..00000000 --- a/src/main/java/com/numberone/backend/domain/notification/service/NotificationService.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.numberone.backend.domain.notification.service; - -import com.numberone.backend.domain.notification.dto.SendFcmRequest; -import com.numberone.backend.support.fcm.dto.FcmNotificationDto; -import com.numberone.backend.support.fcm.service.FcmMessageProvider; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.List; - -@Slf4j -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class NotificationService { - private final FcmMessageProvider fcmMessageProvider; - - public void sendFcm(SendFcmRequest request) { - /* FCM 푸시알람 API 테스트 용 서비스 로직입니다. */ - String token = request.getFcmToken(); - List tokens = Arrays.asList(request.getFcmToken()); - fcmMessageProvider.sendFcmToMembers( - tokens, - FcmNotificationDto.of(request.getTitle(), request.getBody(), request.getImageUrl()) - ); - } -} diff --git a/src/main/java/com/numberone/backend/domain/shelter/service/ShelterService.java b/src/main/java/com/numberone/backend/domain/shelter/service/ShelterService.java index 21e1afd7..d8b96dfc 100644 --- a/src/main/java/com/numberone/backend/domain/shelter/service/ShelterService.java +++ b/src/main/java/com/numberone/backend/domain/shelter/service/ShelterService.java @@ -5,7 +5,7 @@ import com.numberone.backend.domain.shelter.repository.ShelterRepository; import com.numberone.backend.domain.shelter.util.ShelterType; import com.numberone.backend.exception.notfound.NotFoundShelterException; -import com.numberone.backend.support.S3Provider; +import com.numberone.backend.support.s3.S3Provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; diff --git a/src/main/java/com/numberone/backend/domain/token/util/SecurityContextProvider.java b/src/main/java/com/numberone/backend/domain/token/util/SecurityContextProvider.java new file mode 100644 index 00000000..42a23e75 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/util/SecurityContextProvider.java @@ -0,0 +1,19 @@ +package com.numberone.backend.domain.token.util; + +import com.numberone.backend.exception.badrequest.BadUserAuthenticationException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Objects; + +public class SecurityContextProvider { + + public static String getAuthenticatedUserEmail(){ + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + Object principal = authentication.getPrincipal(); + if (Objects.isNull(principal)){ + throw new BadUserAuthenticationException(); + } + return (String) principal; + } +} diff --git a/src/main/java/com/numberone/backend/exception/badrequest/BadUserAuthenticationException.java b/src/main/java/com/numberone/backend/exception/badrequest/BadUserAuthenticationException.java new file mode 100644 index 00000000..b11d1719 --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/badrequest/BadUserAuthenticationException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.badrequest; + +import static com.numberone.backend.exception.context.CustomExceptionContext.BAD_USER_AUTHENTICATION; + +public class BadUserAuthenticationException extends BadRequestException { + public BadUserAuthenticationException(){ + super(BAD_USER_AUTHENTICATION); + } +} diff --git a/src/main/java/com/numberone/backend/exception/conflict/AlreadyLikedException.java b/src/main/java/com/numberone/backend/exception/conflict/AlreadyLikedException.java new file mode 100644 index 00000000..ece8d2d3 --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/conflict/AlreadyLikedException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.conflict; + +import static com.numberone.backend.exception.context.CustomExceptionContext.ALREADY_LIKED_ERROR; + +public class AlreadyLikedException extends ConflictException { + public AlreadyLikedException(){ + super(ALREADY_LIKED_ERROR); + } +} diff --git a/src/main/java/com/numberone/backend/exception/conflict/AlreadyUnLikedException.java b/src/main/java/com/numberone/backend/exception/conflict/AlreadyUnLikedException.java new file mode 100644 index 00000000..c0b3584a --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/conflict/AlreadyUnLikedException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.conflict; + +import static com.numberone.backend.exception.context.CustomExceptionContext.ALREADY_UNLIKED_ERROR; + +public class AlreadyUnLikedException extends ConflictException { + public AlreadyUnLikedException() { + super(ALREADY_UNLIKED_ERROR); + } +} diff --git a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java index e77d699d..13eca97d 100644 --- a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java +++ b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java @@ -14,6 +14,7 @@ public enum CustomExceptionContext implements ExceptionContext { EXPIRED_ACCESS_TOKEN("만료된 액세스 토큰입니다. 리프레쉬 토큰을 이용하여 갱신해주세요.", 2002), WRONG_REFRESH_TOKEN("존재하지 않거나 만료된 리프레쉬 토큰입니다. 다시 리프레쉬 토큰을 발급받아주세요.", 2003), BAD_REQUEST_SOCIAL_TOKEN("요청하신 네이버 또는 카카오 소셜 토큰이 유효하지 않습니다.", 2004), + BAD_USER_AUTHENTICATION("해당 토큰의 인증 정보가 유효하지 않습니다.", 2005), // SHELTER 관련 예외 NOT_FOUND_SHELTER("주변에 가까운 대피소가 존재하지 않습니다.", 3000), @@ -35,7 +36,20 @@ public enum CustomExceptionContext implements ExceptionContext { //후원 페이지 관련 예외 NOT_FOUND_SUPPORT("존재하지 않는 후원 관계입니다.", 7000), NOT_FOUND_SPONSOR("존재하지 않는 후원입니다.", 7001), - BAD_REQUEST_HEART("후원을 하기에는 사용자의 마음 갯수가 부족합니다.",7002) + BAD_REQUEST_HEART("후원을 하기에는 사용자의 마음 갯수가 부족합니다.",7002), + + // article 관련 예외 + NOT_FOUND_ARTICLE("해당 게시글을 찾을 수 없습니다.", 8000), + + // article image 관련 예외 + NOT_FOUND_ARTICLE_IMAGE("해당 이미지를 찾을 수 없습니다.", 9000), + + // comment 관련 예외 + NOT_FOUND_COMMENT("해당 댓글을 찾을 수 없습니다.", 10000), + + // like 관련 예외 + ALREADY_LIKED_ERROR("이미 좋아요 처리된 엔티티입니다.", 11000), + ALREADY_UNLIKED_ERROR("이미 좋아요 해제 처리된 엔티티입니다.", 11001), ; private final String message; diff --git a/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleException.java b/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleException.java new file mode 100644 index 00000000..47ac9824 --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.notfound; + +import static com.numberone.backend.exception.context.CustomExceptionContext.NOT_FOUND_ARTICLE; + +public class NotFoundArticleException extends NotFoundException { + public NotFoundArticleException() { + super(NOT_FOUND_ARTICLE); + } +} diff --git a/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleImageException.java b/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleImageException.java new file mode 100644 index 00000000..cb031b3b --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/notfound/NotFoundArticleImageException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.notfound; + +import static com.numberone.backend.exception.context.CustomExceptionContext.NOT_FOUND_ARTICLE_IMAGE; + +public class NotFoundArticleImageException extends NotFoundException { + public NotFoundArticleImageException() { + super(NOT_FOUND_ARTICLE_IMAGE); + } +} diff --git a/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java b/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java new file mode 100644 index 00000000..c5f0e8db --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/notfound/NotFoundCommentException.java @@ -0,0 +1,9 @@ +package com.numberone.backend.exception.notfound; + +import static com.numberone.backend.exception.context.CustomExceptionContext.NOT_FOUND_COMMENT; + +public class NotFoundCommentException extends NotFoundException { + public NotFoundCommentException(){ + super(NOT_FOUND_COMMENT); + } +} diff --git a/src/main/java/com/numberone/backend/support/fcm/service/FcmMessageProvider.java b/src/main/java/com/numberone/backend/support/fcm/service/FcmMessageProvider.java index 1284b7c4..5bfff921 100644 --- a/src/main/java/com/numberone/backend/support/fcm/service/FcmMessageProvider.java +++ b/src/main/java/com/numberone/backend/support/fcm/service/FcmMessageProvider.java @@ -1,19 +1,57 @@ package com.numberone.backend.support.fcm.service; import com.google.firebase.messaging.*; +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.notification.entity.NotificationEntity; +import com.numberone.backend.domain.notification.entity.NotificationTag; +import com.numberone.backend.domain.notification.repository.NotificationRepository; import com.numberone.backend.exception.conflict.FirebaseMessageSendException; import com.numberone.backend.support.fcm.dto.FcmNotificationDto; +import com.numberone.backend.support.notification.NotificationMessage; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.stream.IntStream; @Slf4j @Component +@RequiredArgsConstructor public class FcmMessageProvider { + private final NotificationRepository notificationRepository; + public void sendFcm(Member member, NotificationMessage notificationMessage, NotificationTag tag){ + String token = member.getFcmToken(); + if (Objects.isNull(token)){ + log.error("해당 회원의 fcm 토큰이 존재하지 않아, 푸시알람을 전송할 수 없습니다."); + return; + } + + String title = notificationMessage.getTitle(); + String body = member.getNickName() + "님, " + notificationMessage.getBody(); + + Message message = Message.builder() + .putData("time", LocalDateTime.now().toString()) + .setNotification( + Notification.builder() + .setTitle(title) + .setBody(body) + .build() + ) + .setToken(token) + .build(); + try { + String response = FirebaseMessaging.getInstance().send(message); + notificationRepository.save(new NotificationEntity(member, tag, title, body, true)); + log.info("Fcm 푸시 알람을 성공적으로 전송하였습니다."); + } catch (Exception e){ + notificationRepository.save(new NotificationEntity(member, tag, title, body, false)); + log.error("Fcm 푸시 알람을 전송하는 도중에 에러가 발생했습니다. {}", e.getMessage()); + } + } public void sendFcmToMembers(List tokens, FcmNotificationDto fcmDto) { List messages = tokens.stream().map( @@ -50,4 +88,5 @@ public void sendFcmToMembers(List tokens, FcmNotificationDto fcmDto) { throw new FirebaseMessageSendException(); } } + } diff --git a/src/main/java/com/numberone/backend/support/notification/NotificationMessage.java b/src/main/java/com/numberone/backend/support/notification/NotificationMessage.java new file mode 100644 index 00000000..8fdf8316 --- /dev/null +++ b/src/main/java/com/numberone/backend/support/notification/NotificationMessage.java @@ -0,0 +1,16 @@ +package com.numberone.backend.support.notification; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum NotificationMessage implements NotificationMessageSpec { + + ARTICLE_COMMENT_FCM_ALARM("[대피로 알림]", "게시글에 댓글이 달렸어요!", null); + + private final String title; + private final String body; + private final String imageUrl; + +} diff --git a/src/main/java/com/numberone/backend/support/notification/NotificationMessageSpec.java b/src/main/java/com/numberone/backend/support/notification/NotificationMessageSpec.java new file mode 100644 index 00000000..e9083410 --- /dev/null +++ b/src/main/java/com/numberone/backend/support/notification/NotificationMessageSpec.java @@ -0,0 +1,7 @@ +package com.numberone.backend.support.notification; + +public interface NotificationMessageSpec { + String getTitle(); + String getBody(); + String getImageUrl(); +} diff --git a/src/main/java/com/numberone/backend/support/redis/like/repository/RedisLockRepository.java b/src/main/java/com/numberone/backend/support/redis/like/repository/RedisLockRepository.java new file mode 100644 index 00000000..8344e4ce --- /dev/null +++ b/src/main/java/com/numberone/backend/support/redis/like/repository/RedisLockRepository.java @@ -0,0 +1,23 @@ +package com.numberone.backend.support.redis.like.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +public class RedisLockRepository { + private final RedisTemplate redisTemplate; + + public Boolean lock(Long key){ + return redisTemplate.opsForValue() + .setIfAbsent(key.toString(), "lock", Duration.ofMillis(3_000)); + } + + public Boolean unlock(Long key){ + return redisTemplate.delete(key.toString()); + } + +} diff --git a/src/main/java/com/numberone/backend/support/redis/like/service/RedisLockLikeFacade.java b/src/main/java/com/numberone/backend/support/redis/like/service/RedisLockLikeFacade.java new file mode 100644 index 00000000..13c82a57 --- /dev/null +++ b/src/main/java/com/numberone/backend/support/redis/like/service/RedisLockLikeFacade.java @@ -0,0 +1,77 @@ +package com.numberone.backend.support.redis.like.service; + +import com.numberone.backend.domain.like.dto.response.response.ArticleLikeResponse; +import com.numberone.backend.domain.like.dto.response.response.CommentLikeResponse; +import com.numberone.backend.domain.like.service.LikeService; +import com.numberone.backend.support.redis.like.repository.RedisLockRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RedisLockLikeFacade { + private final RedisLockRepository redisLockRepository; + private final LikeService likeService; + + public ArticleLikeResponse increaseArticleLike(Long articleId) throws InterruptedException { + while (!redisLockRepository.lock(articleId)) { + Thread.sleep(100); + } + try { + Integer likeCount = likeService.increaseArticleLike(articleId); + return ArticleLikeResponse.builder() + .currentLikeCount(likeCount) + .articleId(articleId) + .build(); + } finally { + redisLockRepository.unlock(articleId); + } + } + + public ArticleLikeResponse decreaseArticleLike(Long articleId) throws InterruptedException { + while (!redisLockRepository.lock(articleId)) { + Thread.sleep(100); + } + try { + Integer likeCount = likeService.decreaseArticleLike(articleId); + return ArticleLikeResponse.builder() + .currentLikeCount(likeCount) + .articleId(articleId) + .build(); + } finally { + redisLockRepository.unlock(articleId); + } + } + + public CommentLikeResponse increaseCommentLike(Long commentId) throws InterruptedException { + while (!redisLockRepository.lock(commentId)) { + Thread.sleep(100); + } + try { + Integer likeCount = likeService.increaseCommentLike(commentId); + return CommentLikeResponse.builder() + .currentLikeCount(likeCount) + .commentId(commentId) + .build(); + } finally { + redisLockRepository.unlock(commentId); + } + } + + public CommentLikeResponse decreaseCommentLike(Long commentId) throws InterruptedException { + while (!redisLockRepository.lock(commentId)) { + Thread.sleep(100); + } + try { + Integer likeCount = likeService.decreaseCommentLike(commentId); + return CommentLikeResponse.builder() + .currentLikeCount(likeCount) + .commentId(commentId) + .build(); + } finally { + redisLockRepository.unlock(commentId); + } + } + + +} diff --git a/src/main/java/com/numberone/backend/support/S3Provider.java b/src/main/java/com/numberone/backend/support/s3/S3Provider.java similarity index 98% rename from src/main/java/com/numberone/backend/support/S3Provider.java rename to src/main/java/com/numberone/backend/support/s3/S3Provider.java index 128054f5..c76c82f2 100644 --- a/src/main/java/com/numberone/backend/support/S3Provider.java +++ b/src/main/java/com/numberone/backend/support/s3/S3Provider.java @@ -1,4 +1,4 @@ -package com.numberone.backend.support; +package com.numberone.backend.support.s3; import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3Client; diff --git a/src/main/java/com/numberone/backend/util/LocationProvider.java b/src/main/java/com/numberone/backend/util/LocationProvider.java index 806f412b..ba0c2efb 100644 --- a/src/main/java/com/numberone/backend/util/LocationProvider.java +++ b/src/main/java/com/numberone/backend/util/LocationProvider.java @@ -16,22 +16,26 @@ @Component @RequiredArgsConstructor @Slf4j -//위치(주소나 GPS) 관련 기능들 작성할 util함수 public class LocationProvider { private final KakaoProperties kakaoProperties; private final RestTemplate restTemplate; public String pos2address(double latitude, double longitude) { - HttpHeaders headers = new HttpHeaders(); - headers.add("Authorization", "KakaoAK " + kakaoProperties.getClient_id()); - URI uri = UriComponentsBuilder - .fromUriString(kakaoProperties.getMapApiUrl()) - .queryParam("x", longitude) - .queryParam("y", latitude) - .build() - .toUri(); - MapApiResponse mapApiResponse = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(null, headers), MapApiResponse.class) - .getBody(); - return mapApiResponse.getDocuments().get(0).getAddress(); + try { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", "KakaoAK " + kakaoProperties.getClient_id()); + URI uri = UriComponentsBuilder + .fromUriString(kakaoProperties.getMapApiUrl()) + .queryParam("x", longitude) + .queryParam("y", latitude) + .build() + .toUri(); + MapApiResponse mapApiResponse = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(null, headers), MapApiResponse.class) + .getBody(); + return mapApiResponse.getDocuments().get(0).getAddress(); + } catch (Exception e) { + log.error("Location Provider occurs error! {}", e.getMessage()); + return ""; + } } }