From 85e57e48cf7d4aede5b0f735389a3447043afd06 Mon Sep 17 00:00:00 2001 From: sususuj Date: Tue, 11 Aug 2026 16:02:40 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EA=B3=BC=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/server/post/domain/Post.java | 79 +++++++++++++++++++ .../com/server/post/domain/PostMedia.java | 55 +++++++++++++ .../db/migration/V7__create_post_tables.sql | 35 ++++++++ 3 files changed, 169 insertions(+) create mode 100644 src/main/java/com/server/post/domain/Post.java create mode 100644 src/main/java/com/server/post/domain/PostMedia.java create mode 100644 src/main/resources/db/migration/V7__create_post_tables.sql diff --git a/src/main/java/com/server/post/domain/Post.java b/src/main/java/com/server/post/domain/Post.java new file mode 100644 index 0000000..b7f5d95 --- /dev/null +++ b/src/main/java/com/server/post/domain/Post.java @@ -0,0 +1,79 @@ +package com.server.post.domain; + +import com.server.user.domain.User; +import jakarta.persistence.*; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "posts") +public class Post { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(name = "comment_count", nullable = false) + private int commentCount = 0; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + protected Post() { + } + + public Post(User user, String content) { + this.user = user; + this.content = content; + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + + public Long getId() { + return id; + } + + public User getUser() { + return user; + } + + public String getContent() { + return content; + } + + public int getLikeCount() { + return likeCount; + } + + public int getCommentCount() { + return commentCount; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } +} diff --git a/src/main/java/com/server/post/domain/PostMedia.java b/src/main/java/com/server/post/domain/PostMedia.java new file mode 100644 index 0000000..9bb0ca0 --- /dev/null +++ b/src/main/java/com/server/post/domain/PostMedia.java @@ -0,0 +1,55 @@ +package com.server.post.domain; + +import jakarta.persistence.*; + +@Entity +@Table(name = "post_media") +public class PostMedia { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @JoinColumn(name = "post_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @Column(name = "media_type", nullable = false) + private String mediaType; + + @Column(nullable = false, columnDefinition = "text") + private String url; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + protected PostMedia() { + } + + public PostMedia(Post post, String mediaType, String url, int sortOrder) { + this.post = post; + this.mediaType = mediaType; + this.url = url; + this.sortOrder = sortOrder; + } + + public Long getId() { + return id; + } + + public Post getPost() { + return post; + } + + public String getMediaType() { + return mediaType; + } + + public String getUrl() { + return url; + } + + public int getSortOrder() { + return sortOrder; + } +} + diff --git a/src/main/resources/db/migration/V7__create_post_tables.sql b/src/main/resources/db/migration/V7__create_post_tables.sql new file mode 100644 index 0000000..423a7bb --- /dev/null +++ b/src/main/resources/db/migration/V7__create_post_tables.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS posts ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id), + content text NOT NULL, + like_count integer NOT NULL DEFAULT 0, + comment_count integer NOT NULL DEFAULT 0, + created_at timestamp NOT NULL, + updated_at timestamp NOT NULL, + deleted_at timestamp +); + +CREATE TABLE IF NOT EXISTS post_media ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL REFERENCES posts(id), + media_type varchar(255) NOT NULL, + url text NOT NULL, + sort_order integer NOT NULL +); + +CREATE TABLE IF NOT EXISTS post_place_tags ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL REFERENCES posts(id), + place_id bigint NOT NULL REFERENCES places(id), + latitude numeric(12, 8), + longitude numeric(12, 8) +); + +CREATE INDEX IF NOT EXISTS idx_post_media_post_id ON post_media (post_id); +CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at); +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); +CREATE INDEX IF NOT EXISTS idx_post_place_tags_post_id ON post_place_tags (post_id); +CREATE INDEX IF NOT EXISTS idx_post_place_tags_place_id ON post_place_tags (place_id); + + + From cc9ae15f2a92785bd21f95961940813191ff0f07 Mon Sep 17 00:00:00 2001 From: sususuj Date: Tue, 11 Aug 2026 17:32:29 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EB=8C=93=EA=B8=80=C2=B7=EB=B0=98=EC=9D=91=C2=B7?= =?UTF-8?q?=EA=B4=80=EA=B3=84=C2=B7=EC=8B=A0=EA=B3=A0=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EA=B3=BC=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/server/post/domain/PostPlaceTag.java | 70 +++++++++++ .../com/server/post/domain/ReportStatus.java | 6 + .../post/repository/PostMediaRepository.java | 7 ++ .../repository/PostPlaceTagRepository.java | 7 ++ .../post/repository/PostRepository.java | 7 ++ .../migration/V7__create_community_tables.sql | 109 ++++++++++++++++++ .../db/migration/V7__create_post_tables.sql | 35 ------ 7 files changed, 206 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/server/post/domain/PostPlaceTag.java create mode 100644 src/main/java/com/server/post/domain/ReportStatus.java create mode 100644 src/main/java/com/server/post/repository/PostMediaRepository.java create mode 100644 src/main/java/com/server/post/repository/PostPlaceTagRepository.java create mode 100644 src/main/java/com/server/post/repository/PostRepository.java create mode 100644 src/main/resources/db/migration/V7__create_community_tables.sql delete mode 100644 src/main/resources/db/migration/V7__create_post_tables.sql diff --git a/src/main/java/com/server/post/domain/PostPlaceTag.java b/src/main/java/com/server/post/domain/PostPlaceTag.java new file mode 100644 index 0000000..1b61109 --- /dev/null +++ b/src/main/java/com/server/post/domain/PostPlaceTag.java @@ -0,0 +1,70 @@ +package com.server.post.domain; + +import com.server.place.domain.Place; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; + +@Entity +@Table(name = "post_place_tags") +public class PostPlaceTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + /** + * 사진이 실제로 촬영된 지점. 장소 대표 좌표는 {@link Place}에 있으므로 복사하지 않고, + * EXIF GPS가 없어 촬영 지점을 알 수 없으면 {@code null}로 둔다. + */ + @Column(precision = 12, scale = 8) + private BigDecimal latitude; + + @Column(precision = 12, scale = 8) + private BigDecimal longitude; + + protected PostPlaceTag() { + } + + public PostPlaceTag(Post post, Place place, BigDecimal latitude, BigDecimal longitude) { + this.post = post; + this.place = place; + this.latitude = latitude; + this.longitude = longitude; + } + + public Long getId() { + return id; + } + + public Post getPost() { + return post; + } + + public Place getPlace() { + return place; + } + + public BigDecimal getLatitude() { + return latitude; + } + + public BigDecimal getLongitude() { + return longitude; + } +} diff --git a/src/main/java/com/server/post/domain/ReportStatus.java b/src/main/java/com/server/post/domain/ReportStatus.java new file mode 100644 index 0000000..dc5db02 --- /dev/null +++ b/src/main/java/com/server/post/domain/ReportStatus.java @@ -0,0 +1,6 @@ +package com.server.post.domain; + +public enum ReportStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/server/post/repository/PostMediaRepository.java b/src/main/java/com/server/post/repository/PostMediaRepository.java new file mode 100644 index 0000000..0e9a391 --- /dev/null +++ b/src/main/java/com/server/post/repository/PostMediaRepository.java @@ -0,0 +1,7 @@ +package com.server.post.repository; + +import com.server.post.domain.PostMedia; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostMediaRepository extends JpaRepository { +} diff --git a/src/main/java/com/server/post/repository/PostPlaceTagRepository.java b/src/main/java/com/server/post/repository/PostPlaceTagRepository.java new file mode 100644 index 0000000..1e8d747 --- /dev/null +++ b/src/main/java/com/server/post/repository/PostPlaceTagRepository.java @@ -0,0 +1,7 @@ +package com.server.post.repository; + +import com.server.post.domain.PostPlaceTag; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostPlaceTagRepository extends JpaRepository { +} diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java new file mode 100644 index 0000000..464c9a9 --- /dev/null +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -0,0 +1,7 @@ +package com.server.post.repository; + +import com.server.post.domain.Post; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostRepository extends JpaRepository { +} diff --git a/src/main/resources/db/migration/V7__create_community_tables.sql b/src/main/resources/db/migration/V7__create_community_tables.sql new file mode 100644 index 0000000..4954fed --- /dev/null +++ b/src/main/resources/db/migration/V7__create_community_tables.sql @@ -0,0 +1,109 @@ +CREATE TABLE IF NOT EXISTS posts ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users(id), + content text NOT NULL, + like_count integer NOT NULL DEFAULT 0, + comment_count integer NOT NULL DEFAULT 0, + created_at timestamp NOT NULL, + updated_at timestamp NOT NULL, + deleted_at timestamp +); + +CREATE TABLE IF NOT EXISTS post_media ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL REFERENCES posts(id), + media_type varchar(255) NOT NULL, + url text NOT NULL, + sort_order integer NOT NULL +); + +CREATE TABLE IF NOT EXISTS post_place_tags ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL REFERENCES posts(id), + place_id bigint NOT NULL REFERENCES places(id), + latitude numeric(12, 8), + longitude numeric(12, 8) +); + +CREATE TABLE IF NOT EXISTS comments ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + post_id bigint NOT NULL REFERENCES posts(id), + user_id bigint NOT NULL REFERENCES users(id), + parent_id bigint REFERENCES comments(id), + content text NOT NULL, + like_count integer NOT NULL DEFAULT 0, + created_at timestamp NOT NULL, + updated_at timestamp NOT NULL, + deleted_at timestamp +); + +CREATE TABLE IF NOT EXISTS post_likes ( + post_id bigint NOT NULL REFERENCES posts(id), + user_id bigint NOT NULL REFERENCES users(id), + created_at timestamp NOT NULL, + PRIMARY KEY (post_id, user_id) +); + +CREATE TABLE IF NOT EXISTS comment_likes ( + comment_id bigint NOT NULL REFERENCES comments(id), + user_id bigint NOT NULL REFERENCES users(id), + created_at timestamp NOT NULL, + PRIMARY KEY (comment_id, user_id) +); + +CREATE TABLE IF NOT EXISTS bookmarks ( + user_id bigint NOT NULL REFERENCES users(id), + post_id bigint NOT NULL REFERENCES posts(id), + created_at timestamp NOT NULL, + PRIMARY KEY (user_id, post_id) +); + +CREATE TABLE IF NOT EXISTS follows ( + follower_id bigint NOT NULL REFERENCES users(id), + following_id bigint NOT NULL REFERENCES users(id), + created_at timestamp NOT NULL, + PRIMARY KEY (follower_id, following_id) +); + +CREATE TABLE IF NOT EXISTS blocks ( + blocker_id bigint NOT NULL REFERENCES users(id), + blocked_id bigint NOT NULL REFERENCES users(id), + created_at timestamp NOT NULL, + PRIMARY KEY (blocker_id, blocked_id) +); + +CREATE TABLE IF NOT EXISTS hashtags ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name varchar(255) NOT NULL, + post_count integer NOT NULL DEFAULT 0, + CONSTRAINT uk_hashtags_name UNIQUE (name) +); + +CREATE TABLE IF NOT EXISTS post_hashtags ( + post_id bigint NOT NULL REFERENCES posts(id), + hashtag_id bigint NOT NULL REFERENCES hashtags(id), + PRIMARY KEY (post_id, hashtag_id) +); + +CREATE TABLE IF NOT EXISTS reports ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + reporter_id bigint NOT NULL REFERENCES users(id), + target_type varchar(255) NOT NULL, + target_id bigint NOT NULL, + reason text NOT NULL, + status varchar(255) NOT NULL, + created_at timestamp NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_post_media_post_id ON post_media (post_id); +CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at); +CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); +CREATE INDEX IF NOT EXISTS idx_post_place_tags_post_id ON post_place_tags (post_id); +CREATE INDEX IF NOT EXISTS idx_post_place_tags_place_id ON post_place_tags (place_id); +CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments (post_id); +CREATE INDEX IF NOT EXISTS idx_comments_parent_id ON comments (parent_id); +CREATE INDEX IF NOT EXISTS idx_follows_following_id ON follows (following_id); +CREATE INDEX IF NOT EXISTS idx_post_hashtags_hashtag_id ON post_hashtags (hashtag_id); + + + diff --git a/src/main/resources/db/migration/V7__create_post_tables.sql b/src/main/resources/db/migration/V7__create_post_tables.sql deleted file mode 100644 index 423a7bb..0000000 --- a/src/main/resources/db/migration/V7__create_post_tables.sql +++ /dev/null @@ -1,35 +0,0 @@ -CREATE TABLE IF NOT EXISTS posts ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - user_id bigint NOT NULL REFERENCES users(id), - content text NOT NULL, - like_count integer NOT NULL DEFAULT 0, - comment_count integer NOT NULL DEFAULT 0, - created_at timestamp NOT NULL, - updated_at timestamp NOT NULL, - deleted_at timestamp -); - -CREATE TABLE IF NOT EXISTS post_media ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - post_id bigint NOT NULL REFERENCES posts(id), - media_type varchar(255) NOT NULL, - url text NOT NULL, - sort_order integer NOT NULL -); - -CREATE TABLE IF NOT EXISTS post_place_tags ( - id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - post_id bigint NOT NULL REFERENCES posts(id), - place_id bigint NOT NULL REFERENCES places(id), - latitude numeric(12, 8), - longitude numeric(12, 8) -); - -CREATE INDEX IF NOT EXISTS idx_post_media_post_id ON post_media (post_id); -CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts (created_at); -CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts (user_id); -CREATE INDEX IF NOT EXISTS idx_post_place_tags_post_id ON post_place_tags (post_id); -CREATE INDEX IF NOT EXISTS idx_post_place_tags_place_id ON post_place_tags (place_id); - - - From 25b21de99c84938838dc95d434e848bee6b14c93 Mon Sep 17 00:00:00 2001 From: sususuj Date: Tue, 11 Aug 2026 17:34:41 +0900 Subject: [PATCH 03/12] =?UTF-8?q?chore:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B2=88=ED=98=B8=EB=A5=BC=20V8=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...reate_community_tables.sql => V8__create_community_tables.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V7__create_community_tables.sql => V8__create_community_tables.sql} (100%) diff --git a/src/main/resources/db/migration/V7__create_community_tables.sql b/src/main/resources/db/migration/V8__create_community_tables.sql similarity index 100% rename from src/main/resources/db/migration/V7__create_community_tables.sql rename to src/main/resources/db/migration/V8__create_community_tables.sql From b47567f9153e756f4cdb2c8596af6c73a7e26483 Mon Sep 17 00:00:00 2001 From: sususuj Date: Tue, 11 Aug 2026 19:39:19 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=C2=B7=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/server/common/error/ErrorCode.java | 2 + .../post/controller/PostController.java | 73 +++++++++ .../server/post/dto/PostCreateRequest.java | 51 ++++++ .../server/post/dto/PostDetailResponse.java | 84 ++++++++++ .../com/server/post/dto/PostPlaceTagView.java | 29 ++++ .../post/dto/PostSummaryListResponse.java | 14 ++ .../server/post/dto/PostSummaryResponse.java | 60 +++++++ .../post/repository/PostMediaRepository.java | 7 + .../repository/PostPlaceTagRepository.java | 24 +++ .../post/repository/PostRepository.java | 14 ++ .../com/server/post/service/PostService.java | 148 ++++++++++++++++++ .../user/repository/UserRepository.java | 2 + 12 files changed, 508 insertions(+) create mode 100644 src/main/java/com/server/post/controller/PostController.java create mode 100644 src/main/java/com/server/post/dto/PostCreateRequest.java create mode 100644 src/main/java/com/server/post/dto/PostDetailResponse.java create mode 100644 src/main/java/com/server/post/dto/PostPlaceTagView.java create mode 100644 src/main/java/com/server/post/dto/PostSummaryListResponse.java create mode 100644 src/main/java/com/server/post/dto/PostSummaryResponse.java create mode 100644 src/main/java/com/server/post/service/PostService.java diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index edcb7ec..9b060ae 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -24,6 +24,8 @@ public enum ErrorCode { SCHEDULE_NOT_FOUND(404, "일정을 찾을 수 없습니다."), PLACE_NOT_FOUND(404, "장소를 찾을 수 없습니다."), SHARE_LINK_NOT_FOUND(404, "공유 링크를 찾을 수 없습니다."), + POST_NOT_FOUND(404, "게시물을 찾을 수 없습니다."), + USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."), TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), diff --git a/src/main/java/com/server/post/controller/PostController.java b/src/main/java/com/server/post/controller/PostController.java new file mode 100644 index 0000000..8a930ea --- /dev/null +++ b/src/main/java/com/server/post/controller/PostController.java @@ -0,0 +1,73 @@ +package com.server.post.controller; + +import com.server.post.dto.PostCreateRequest; +import com.server.post.dto.PostDetailResponse; +import com.server.post.dto.PostSummaryListResponse; +import com.server.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/posts") +@Tag(name = "커뮤니티 게시물", description = "여행 후기 게시물 작성과 조회") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "게시물 작성", + description = "미디어는 이미 업로드된 URL 을 전달한다. 장소 태그는 내부 places 에 등록된 장소만 지정할 수 있다." + ) + public PostDetailResponse create( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "작성자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody PostCreateRequest request + ) { + return postService.create(userId, request); + } + + @GetMapping + @Operation( + summary = "피드 조회", + description = "최신순으로 한 페이지를 반환한다. 응답의 nextCursor 를 다음 요청의 cursor 로 그대로 넘긴다. " + + "nextCursor 가 null 이면 더 가져올 게시물이 없다." + ) + public PostSummaryListResponse getFeed( + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") + @RequestParam(required = false) Long cursor, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return postService.getFeed(cursor, size); + } + + @GetMapping("/{postId}") + @Operation(summary = "게시물 상세", description = "첨부 미디어와 장소 태그를 모두 포함한다.") + public PostDetailResponse get( + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.get(postId); + } +} diff --git a/src/main/java/com/server/post/dto/PostCreateRequest.java b/src/main/java/com/server/post/dto/PostCreateRequest.java new file mode 100644 index 0000000..b8341e3 --- /dev/null +++ b/src/main/java/com/server/post/dto/PostCreateRequest.java @@ -0,0 +1,51 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.math.BigDecimal; +import java.util.List; + +@Schema(description = "커뮤니티 게시물 작성 요청. 작성자는 X-User-Id 헤더로 전달한다.") +public record PostCreateRequest( + @Schema(description = "본문", example = "광안리 야경 보러 갔는데 날씨가 좋았어요") + @NotBlank String content, + + @Schema(description = "첨부한 사진·영상. 표시 순서는 sortOrder를 따른다.") + @Valid List mediaList, + + @Schema(description = "게시물에 태그한 장소. 내부 places에 등록된 장소만 태그할 수 있다.") + @Valid List placeTags +) { + + @Schema(description = "첨부 미디어 한 건") + public record Media( + @Schema(description = "업로드된 파일 URL", + example = "https://example.com/media/gwangalli-night.jpg") + @NotBlank String url, + + @Schema(description = "미디어 종류", example = "IMAGE") + @NotBlank String mediaType, + + @Schema(description = "게시물 안에서의 표시 순서. 0부터 시작한다.", example = "0") + int sortOrder + ) { + } + + @Schema(description = "장소 태그 한 건") + public record PlaceTag( + @Schema(description = "내부 장소 ID", example = "42") + @NotNull Long placeId, + + @Schema(description = "사진이 실제로 촬영된 위도. EXIF GPS가 없으면 생략한다.", + example = "35.15320000") + BigDecimal latitude, + + @Schema(description = "사진이 실제로 촬영된 경도. EXIF GPS가 없으면 생략한다.", + example = "129.11860000") + BigDecimal longitude + ) { + } +} diff --git a/src/main/java/com/server/post/dto/PostDetailResponse.java b/src/main/java/com/server/post/dto/PostDetailResponse.java new file mode 100644 index 0000000..4c8e0c8 --- /dev/null +++ b/src/main/java/com/server/post/dto/PostDetailResponse.java @@ -0,0 +1,84 @@ +package com.server.post.dto; + +import com.server.post.domain.Post; +import com.server.post.domain.PostMedia; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +@Schema(description = "게시물 상세. 첨부 미디어와 장소 태그를 모두 포함한다.") +public record PostDetailResponse( + @Schema(example = "7") Long id, + Author author, + @Schema(example = "광안리 야경 보러 갔는데 날씨가 좋았어요") String content, + @Schema(description = "sortOrder 오름차순") List mediaList, + List placeTags, + @Schema(example = "12") int likeCount, + @Schema(example = "3") int commentCount, + @Schema(example = "2026-08-11T17:30:00") LocalDateTime createdAt, + @Schema(example = "2026-08-11T17:30:00") LocalDateTime updatedAt +) { + + @Schema(description = "작성자") + public record Author( + @Schema(example = "1") Long id, + @Schema(example = "감자") String nickname, + @Schema(example = "https://example.com/profile/1.jpg") String profileImageUrl + ) { + } + + @Schema(description = "첨부 미디어 한 건") + public record Media( + @Schema(example = "3") Long id, + @Schema(example = "https://example.com/media/gwangalli-night.jpg") String url, + @Schema(example = "IMAGE") String mediaType, + @Schema(example = "0") int sortOrder + ) { + } + + @Schema(description = "장소 태그 한 건") + public record PlaceTag( + @Schema(example = "42") Long placeId, + @Schema(example = "광안리해수욕장") String placeName, + @Schema(description = "사진이 촬영된 지점. 알 수 없으면 null이다.", example = "35.15320000") + BigDecimal latitude, + @Schema(example = "129.11860000") BigDecimal longitude + ) { + } + + public static PostDetailResponse from( + Post post, + List mediaList, + List placeTags + ) { + return new PostDetailResponse( + post.getId(), + new Author( + post.getUser().getId(), + post.getUser().getNickname(), + post.getUser().getProfileImageUrl()), + post.getContent(), + mediaList.stream() + .sorted(Comparator.comparingInt(PostMedia::getSortOrder)) + .map(media -> new Media( + media.getId(), + media.getUrl(), + media.getMediaType(), + media.getSortOrder())) + .toList(), + placeTags.stream() + .map(tag -> new PlaceTag( + tag.placeId(), + tag.placeName(), + tag.latitude(), + tag.longitude())) + .toList(), + post.getLikeCount(), + post.getCommentCount(), + post.getCreatedAt(), + post.getUpdatedAt()); + } +} diff --git a/src/main/java/com/server/post/dto/PostPlaceTagView.java b/src/main/java/com/server/post/dto/PostPlaceTagView.java new file mode 100644 index 0000000..75b27da --- /dev/null +++ b/src/main/java/com/server/post/dto/PostPlaceTagView.java @@ -0,0 +1,29 @@ +package com.server.post.dto; + +import com.server.post.domain.PostPlaceTag; +import java.math.BigDecimal; + +/** + * 응답에 필요한 장소 태그 값만 담는 조회 전용 모델. + * + *

{@code Place} 엔티티를 그대로 읽으면 {@code mappedBy} 로 연결된 상세·운영정보가 + * 장소마다 추가 질의를 발생시킨다. 커뮤니티는 장소명과 ID만 쓰므로 필요한 열만 가져온다. + */ +public record PostPlaceTagView( + Long postId, + Long placeId, + String placeName, + BigDecimal latitude, + BigDecimal longitude +) { + + /** 방금 저장해 이미 메모리에 있는 엔티티를 변환할 때 쓴다. */ + public static PostPlaceTagView from(PostPlaceTag tag) { + return new PostPlaceTagView( + tag.getPost().getId(), + tag.getPlace().getId(), + tag.getPlace().getName(), + tag.getLatitude(), + tag.getLongitude()); + } +} diff --git a/src/main/java/com/server/post/dto/PostSummaryListResponse.java b/src/main/java/com/server/post/dto/PostSummaryListResponse.java new file mode 100644 index 0000000..4eea6fa --- /dev/null +++ b/src/main/java/com/server/post/dto/PostSummaryListResponse.java @@ -0,0 +1,14 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "커서 기반 피드 목록. 다음 페이지는 nextCursor를 cursor 파라미터로 다시 보낸다.") +public record PostSummaryListResponse( + @Schema(description = "최신순 게시물") List items, + @Schema(description = "다음 요청에 사용할 커서. 더 가져올 게시물이 없으면 null이다.", + example = "81") + Long nextCursor +) { +} diff --git a/src/main/java/com/server/post/dto/PostSummaryResponse.java b/src/main/java/com/server/post/dto/PostSummaryResponse.java new file mode 100644 index 0000000..59127cd --- /dev/null +++ b/src/main/java/com/server/post/dto/PostSummaryResponse.java @@ -0,0 +1,60 @@ +package com.server.post.dto; + +import com.server.post.domain.Post; +import com.server.post.domain.PostMedia; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.Comparator; +import java.util.List; + +/** + * 피드 목록용 축약 응답. 미디어는 대표 한 장과 개수만 담는다. + * 전체 미디어와 장소 태그가 필요하면 {@code GET /api/v1/posts/{postId}}를 사용한다. + */ +@Schema(description = "피드 목록용 축약 게시물") +public record PostSummaryResponse( + @Schema(example = "7") Long id, + PostDetailResponse.Author author, + @Schema(example = "광안리 야경 보러 갔는데 날씨가 좋았어요") String content, + @Schema(description = "sortOrder가 가장 앞선 미디어. 없으면 null이다.", + example = "https://example.com/media/gwangalli-night.jpg") + String thumbnailUrl, + @Schema(description = "첨부 미디어 수. 2 이상이면 여러 장 표시를 띄운다.", example = "3") + int mediaCount, + @Schema(description = "대표 장소명. 태그가 없으면 null이다.", example = "광안리해수욕장") + String placeName, + @Schema(example = "12") int likeCount, + @Schema(example = "3") int commentCount, + @Schema(example = "2026-08-11T17:30:00") LocalDateTime createdAt +) { + + public static PostSummaryResponse from( + Post post, + List mediaList, + List placeTags + ) { + String thumbnailUrl = mediaList.stream() + .min(Comparator.comparingInt(PostMedia::getSortOrder)) + .map(PostMedia::getUrl) + .orElse(null); + String placeName = placeTags.stream() + .findFirst() + .map(PostPlaceTagView::placeName) + .orElse(null); + + return new PostSummaryResponse( + post.getId(), + new PostDetailResponse.Author( + post.getUser().getId(), + post.getUser().getNickname(), + post.getUser().getProfileImageUrl()), + post.getContent(), + thumbnailUrl, + mediaList.size(), + placeName, + post.getLikeCount(), + post.getCommentCount(), + post.getCreatedAt()); + } +} diff --git a/src/main/java/com/server/post/repository/PostMediaRepository.java b/src/main/java/com/server/post/repository/PostMediaRepository.java index 0e9a391..7aac1e4 100644 --- a/src/main/java/com/server/post/repository/PostMediaRepository.java +++ b/src/main/java/com/server/post/repository/PostMediaRepository.java @@ -1,7 +1,14 @@ package com.server.post.repository; import com.server.post.domain.PostMedia; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; public interface PostMediaRepository extends JpaRepository { + + List findByPostId(Long postId); + + /** 피드 한 페이지의 미디어를 한 번에 읽어 게시물 수만큼 질의가 늘어나지 않게 한다. */ + List findByPostIdIn(Collection postIds); } diff --git a/src/main/java/com/server/post/repository/PostPlaceTagRepository.java b/src/main/java/com/server/post/repository/PostPlaceTagRepository.java index 1e8d747..19090e2 100644 --- a/src/main/java/com/server/post/repository/PostPlaceTagRepository.java +++ b/src/main/java/com/server/post/repository/PostPlaceTagRepository.java @@ -1,7 +1,31 @@ package com.server.post.repository; import com.server.post.domain.PostPlaceTag; +import com.server.post.dto.PostPlaceTagView; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostPlaceTagRepository extends JpaRepository { + + @Query(""" + select new com.server.post.dto.PostPlaceTagView( + tag.post.id, place.id, place.name, tag.latitude, tag.longitude) + from PostPlaceTag tag + join tag.place place + where tag.post.id = :postId + """) + List findViewsByPostId(@Param("postId") Long postId); + + /** 피드 한 페이지의 장소 태그를 한 번에 읽는다. */ + @Query(""" + select new com.server.post.dto.PostPlaceTagView( + tag.post.id, place.id, place.name, tag.latitude, tag.longitude) + from PostPlaceTag tag + join tag.place place + where tag.post.id in :postIds + """) + List findViewsByPostIdIn(@Param("postIds") Collection postIds); } diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java index 464c9a9..9edf54f 100644 --- a/src/main/java/com/server/post/repository/PostRepository.java +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -1,7 +1,21 @@ package com.server.post.repository; import com.server.post.domain.Post; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; public interface PostRepository extends JpaRepository { + + @EntityGraph(attributePaths = "user") + Optional findByIdAndDeletedAtIsNull(Long id); + + /** + * 최신순 피드 한 페이지. 첫 페이지는 {@code Long.MAX_VALUE}를 커서로 넘긴다. + * 작성자는 응답에 항상 필요하므로 함께 조회한다. + */ + @EntityGraph(attributePaths = "user") + List findByDeletedAtIsNullAndIdLessThanOrderByIdDesc(Long cursor, Pageable pageable); } diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java new file mode 100644 index 0000000..b8f307b --- /dev/null +++ b/src/main/java/com/server/post/service/PostService.java @@ -0,0 +1,148 @@ +package com.server.post.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.place.domain.Place; +import com.server.place.repository.PlaceRepository; +import com.server.post.domain.Post; +import com.server.post.domain.PostMedia; +import com.server.post.domain.PostPlaceTag; +import com.server.post.dto.PostCreateRequest; +import com.server.post.dto.PostDetailResponse; +import com.server.post.dto.PostPlaceTagView; +import com.server.post.dto.PostSummaryListResponse; +import com.server.post.dto.PostSummaryResponse; +import com.server.post.repository.PostMediaRepository; +import com.server.post.repository.PostPlaceTagRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class PostService { + + private static final int DEFAULT_FEED_SIZE = 20; + private static final int MAX_FEED_SIZE = 50; + /** 첫 페이지는 커서가 없으므로 어떤 게시물 ID보다 큰 값에서 시작한다. */ + private static final long FIRST_PAGE_CURSOR = Long.MAX_VALUE; + + private final PostRepository postRepository; + private final PostMediaRepository postMediaRepository; + private final PostPlaceTagRepository postPlaceTagRepository; + private final UserRepository userRepository; + private final PlaceRepository placeRepository; + + public PostService( + PostRepository postRepository, + PostMediaRepository postMediaRepository, + PostPlaceTagRepository postPlaceTagRepository, + UserRepository userRepository, + PlaceRepository placeRepository + ) { + this.postRepository = postRepository; + this.postMediaRepository = postMediaRepository; + this.postPlaceTagRepository = postPlaceTagRepository; + this.userRepository = userRepository; + this.placeRepository = placeRepository; + } + + @Transactional + public PostDetailResponse create(Long userId, PostCreateRequest request) { + User author = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Post post = postRepository.save(new Post(author, request.content())); + List mediaList = saveMedia(post, request.mediaList()); + List placeTags = savePlaceTags(post, request.placeTags()); + + return PostDetailResponse.from( + post, + mediaList, + placeTags.stream().map(PostPlaceTagView::from).toList()); + } + + @Transactional(readOnly = true) + public PostDetailResponse get(Long postId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + + return PostDetailResponse.from( + post, + postMediaRepository.findByPostId(postId), + postPlaceTagRepository.findViewsByPostId(postId)); + } + + @Transactional(readOnly = true) + public PostSummaryListResponse getFeed(Long cursor, Integer size) { + int limit = resolveFeedSize(size); + List posts = postRepository.findByDeletedAtIsNullAndIdLessThanOrderByIdDesc( + cursor == null ? FIRST_PAGE_CURSOR : cursor, + PageRequest.of(0, limit)); + + if (posts.isEmpty()) { + return new PostSummaryListResponse(List.of(), null); + } + + List postIds = posts.stream().map(Post::getId).toList(); + Map> mediaByPost = postMediaRepository.findByPostIdIn(postIds).stream() + .collect(Collectors.groupingBy(media -> media.getPost().getId())); + Map> placeTagsByPost = postPlaceTagRepository.findViewsByPostIdIn(postIds).stream() + .collect(Collectors.groupingBy(PostPlaceTagView::postId)); + + List items = posts.stream() + .map(post -> PostSummaryResponse.from( + post, + mediaByPost.getOrDefault(post.getId(), List.of()), + placeTagsByPost.getOrDefault(post.getId(), List.of()))) + .toList(); + + // 요청한 개수를 못 채웠으면 더 가져올 게시물이 없다. + Long nextCursor = posts.size() < limit ? null : posts.get(posts.size() - 1).getId(); + return new PostSummaryListResponse(items, nextCursor); + } + + private List saveMedia(Post post, List requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + return postMediaRepository.saveAll(requests.stream() + .map(media -> new PostMedia(post, media.mediaType(), media.url(), media.sortOrder())) + .toList()); + } + + private List savePlaceTags(Post post, List requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + List placeIds = requests.stream() + .map(PostCreateRequest.PlaceTag::placeId) + .distinct() + .toList(); + Map places = placeRepository.findAllById(placeIds).stream() + .collect(Collectors.toMap(Place::getId, Function.identity())); + + return postPlaceTagRepository.saveAll(requests.stream() + .map(tag -> { + Place place = places.get(tag.placeId()); + if (place == null) { + throw new BusinessException(ErrorCode.PLACE_NOT_FOUND); + } + return new PostPlaceTag(post, place, tag.latitude(), tag.longitude()); + }) + .toList()); + } + + private int resolveFeedSize(Integer size) { + if (size == null || size <= 0) { + return DEFAULT_FEED_SIZE; + } + return Math.min(size, MAX_FEED_SIZE); + } +} diff --git a/src/main/java/com/server/user/repository/UserRepository.java b/src/main/java/com/server/user/repository/UserRepository.java index f4dd45b..00b5213 100644 --- a/src/main/java/com/server/user/repository/UserRepository.java +++ b/src/main/java/com/server/user/repository/UserRepository.java @@ -6,6 +6,8 @@ public interface UserRepository extends JpaRepository { + Optional findByIdAndDeletedAtIsNull(Long id); + Optional findByNicknameAndDeletedAtIsNull(String nickname); boolean existsByNicknameAndDeletedAtIsNull(String nickname); From b5b8265eed23f07848d7b5ba5150bec8ee3b668f Mon Sep 17 00:00:00 2001 From: sususuj Date: Tue, 18 Aug 2026 04:20:26 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=A2=8B=EC=95=84=EC=9A=94=C2=B7=EB=8C=93=EA=B8=80=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/server/common/error/ErrorCode.java | 2 + .../post/controller/CommentController.java | 67 ++++++++++ .../post/controller/PostController.java | 30 +++++ .../java/com/server/post/domain/Comment.java | 99 +++++++++++++++ .../java/com/server/post/domain/PostLike.java | 53 ++++++++ .../com/server/post/domain/PostLikeId.java | 38 ++++++ .../server/post/dto/CommentCreateRequest.java | 16 +++ .../server/post/dto/CommentListResponse.java | 14 +++ .../com/server/post/dto/CommentResponse.java | 36 ++++++ .../com/server/post/dto/PostLikeResponse.java | 11 ++ .../post/repository/CommentRepository.java | 26 ++++ .../post/repository/PostLikeRepository.java | 13 ++ .../post/repository/PostRepository.java | 25 ++++ .../server/post/service/CommentService.java | 116 ++++++++++++++++++ .../com/server/post/service/PostService.java | 33 +++++ 15 files changed, 579 insertions(+) create mode 100644 src/main/java/com/server/post/controller/CommentController.java create mode 100644 src/main/java/com/server/post/domain/Comment.java create mode 100644 src/main/java/com/server/post/domain/PostLike.java create mode 100644 src/main/java/com/server/post/domain/PostLikeId.java create mode 100644 src/main/java/com/server/post/dto/CommentCreateRequest.java create mode 100644 src/main/java/com/server/post/dto/CommentListResponse.java create mode 100644 src/main/java/com/server/post/dto/CommentResponse.java create mode 100644 src/main/java/com/server/post/dto/PostLikeResponse.java create mode 100644 src/main/java/com/server/post/repository/CommentRepository.java create mode 100644 src/main/java/com/server/post/repository/PostLikeRepository.java create mode 100644 src/main/java/com/server/post/service/CommentService.java diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index 9b060ae..39d4240 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -25,7 +25,9 @@ public enum ErrorCode { PLACE_NOT_FOUND(404, "장소를 찾을 수 없습니다."), SHARE_LINK_NOT_FOUND(404, "공유 링크를 찾을 수 없습니다."), POST_NOT_FOUND(404, "게시물을 찾을 수 없습니다."), + COMMENT_NOT_FOUND(404, "댓글을 찾을 수 없습니다."), USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."), + INVALID_COMMENT_REQUEST(400, "댓글 요청이 올바르지 않습니다."), TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), diff --git a/src/main/java/com/server/post/controller/CommentController.java b/src/main/java/com/server/post/controller/CommentController.java new file mode 100644 index 0000000..477b0fc --- /dev/null +++ b/src/main/java/com/server/post/controller/CommentController.java @@ -0,0 +1,67 @@ +package com.server.post.controller; + +import com.server.post.dto.CommentCreateRequest; +import com.server.post.dto.CommentListResponse; +import com.server.post.dto.CommentResponse; +import com.server.post.service.CommentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/posts/{postId}/comments") +@Tag(name = "커뮤니티 댓글", description = "게시물 댓글 작성과 조회") +public class CommentController { + + private final CommentService commentService; + + public CommentController(CommentService commentService) { + this.commentService = commentService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "댓글 작성", + description = "parentId 를 주면 답글이 된다. 답글에 다시 답글을 달면 400 을 반환한다." + ) + public CommentResponse create( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "작성자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "1") @PathVariable Long postId, + @Valid @RequestBody CommentCreateRequest request + ) { + return commentService.create(postId, userId, request); + } + + @GetMapping + @Operation( + summary = "댓글 목록", + description = "최상위 댓글을 오래된 순으로 반환하며 각 댓글의 답글을 함께 담는다. " + + "응답의 nextCursor 를 다음 요청의 cursor 로 그대로 넘긴다." + ) + public CommentListResponse getComments( + @Parameter(example = "1") @PathVariable Long postId, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "12") + @RequestParam(required = false) Long cursor, + @Parameter(description = "한 번에 가져올 댓글 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return commentService.getComments(postId, cursor, size); + } +} diff --git a/src/main/java/com/server/post/controller/PostController.java b/src/main/java/com/server/post/controller/PostController.java index 8a930ea..b56cb6c 100644 --- a/src/main/java/com/server/post/controller/PostController.java +++ b/src/main/java/com/server/post/controller/PostController.java @@ -2,6 +2,7 @@ import com.server.post.dto.PostCreateRequest; import com.server.post.dto.PostDetailResponse; +import com.server.post.dto.PostLikeResponse; import com.server.post.dto.PostSummaryListResponse; import com.server.post.service.PostService; import io.swagger.v3.oas.annotations.Operation; @@ -11,6 +12,7 @@ import jakarta.validation.constraints.Min; import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -70,4 +72,32 @@ public PostDetailResponse get( ) { return postService.get(postId); } + + @PostMapping("/{postId}/likes") + @Operation( + summary = "좋아요", + description = "이미 누른 상태에서 다시 요청해도 개수가 늘지 않는다." + ) + public PostLikeResponse like( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.like(postId, userId); + } + + @DeleteMapping("/{postId}/likes") + @Operation( + summary = "좋아요 취소", + description = "누른 적 없는 상태에서 요청해도 개수가 줄지 않는다." + ) + public PostLikeResponse unlike( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.unlike(postId, userId); + } } diff --git a/src/main/java/com/server/post/domain/Comment.java b/src/main/java/com/server/post/domain/Comment.java new file mode 100644 index 0000000..4a0d265 --- /dev/null +++ b/src/main/java/com/server/post/domain/Comment.java @@ -0,0 +1,99 @@ +package com.server.post.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comments") +public class Comment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** 대댓글이면 부모 댓글, 일반 댓글이면 null. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_id") + private Comment parent; + + @Column(nullable = false, columnDefinition = "text") + private String content; + + @Column(name = "like_count", nullable = false) + private int likeCount = 0; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + protected Comment() { + } + + public Comment(Post post, User user, Comment parent, String content) { + this.post = post; + this.user = user; + this.parent = parent; + this.content = content; + this.createdAt = LocalDateTime.now(); + this.updatedAt = this.createdAt; + } + + public Long getId() { + return id; + } + + public Post getPost() { + return post; + } + + public User getUser() { + return user; + } + + public Comment getParent() { + return parent; + } + + public String getContent() { + return content; + } + + public int getLikeCount() { + return likeCount; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } +} diff --git a/src/main/java/com/server/post/domain/PostLike.java b/src/main/java/com/server/post/domain/PostLike.java new file mode 100644 index 0000000..1194317 --- /dev/null +++ b/src/main/java/com/server/post/domain/PostLike.java @@ -0,0 +1,53 @@ +package com.server.post.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "post_likes") +@IdClass(PostLikeId.class) +public class PostLike { + + @Id + @JoinColumn(name = "post_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Post post; + + @Id + @JoinColumn(name = "user_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private User user; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected PostLike() { + } + + public PostLike(Post post, User user) { + this.post = post; + this.user = user; + this.createdAt = LocalDateTime.now(); + } + + public Post getPost() { + return post; + } + + public User getUser() { + return user; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/post/domain/PostLikeId.java b/src/main/java/com/server/post/domain/PostLikeId.java new file mode 100644 index 0000000..49b27f9 --- /dev/null +++ b/src/main/java/com/server/post/domain/PostLikeId.java @@ -0,0 +1,38 @@ +package com.server.post.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** + * {@link PostLike}의 복합 기본키. 필드 이름은 엔티티의 {@code @Id} 필드와 같아야 하고, + * 타입은 대상 엔티티의 기본키 타입을 쓴다. + */ +public class PostLikeId implements Serializable { + + private Long post; + private Long user; + + protected PostLikeId() { + } + + public PostLikeId(Long post, Long user) { + this.post = post; + this.user = user; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof PostLikeId that)) { + return false; + } + return Objects.equals(post, that.post) && Objects.equals(user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(post, user); + } +} diff --git a/src/main/java/com/server/post/dto/CommentCreateRequest.java b/src/main/java/com/server/post/dto/CommentCreateRequest.java new file mode 100644 index 0000000..1c73204 --- /dev/null +++ b/src/main/java/com/server/post/dto/CommentCreateRequest.java @@ -0,0 +1,16 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "댓글 작성 요청. 작성자는 X-User-Id 헤더로 전달한다.") +public record CommentCreateRequest( + @Schema(description = "댓글 내용", example = "저도 여기 가봤는데 좋았어요") + @NotBlank String content, + + @Schema(description = "답글을 달 부모 댓글 ID. 일반 댓글이면 생략한다. " + + "대댓글에는 다시 답글을 달 수 없다.", example = "3") + Long parentId +) { +} diff --git a/src/main/java/com/server/post/dto/CommentListResponse.java b/src/main/java/com/server/post/dto/CommentListResponse.java new file mode 100644 index 0000000..5e8cd4f --- /dev/null +++ b/src/main/java/com/server/post/dto/CommentListResponse.java @@ -0,0 +1,14 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "커서 기반 댓글 목록. 다음 페이지는 nextCursor 를 cursor 파라미터로 다시 보낸다.") +public record CommentListResponse( + @Schema(description = "오래된 순 최상위 댓글. 각 항목의 replies 에 답글이 들어 있다.") + List items, + @Schema(description = "다음 요청에 사용할 커서. 더 가져올 댓글이 없으면 null 이다.", example = "12") + Long nextCursor +) { +} diff --git a/src/main/java/com/server/post/dto/CommentResponse.java b/src/main/java/com/server/post/dto/CommentResponse.java new file mode 100644 index 0000000..369d87e --- /dev/null +++ b/src/main/java/com/server/post/dto/CommentResponse.java @@ -0,0 +1,36 @@ +package com.server.post.dto; + +import com.server.post.domain.Comment; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "댓글 한 건. 최상위 댓글이면 replies 에 답글이 담긴다.") +public record CommentResponse( + @Schema(example = "3") Long id, + PostDetailResponse.Author author, + @Schema(example = "저도 여기 가봤는데 좋았어요") String content, + @Schema(example = "2") int likeCount, + @Schema(example = "2026-08-18T04:02:00") LocalDateTime createdAt, + @Schema(description = "이 댓글에 달린 답글. 답글에는 다시 답글이 없으므로 항상 비어 있다.") + List replies +) { + + public static CommentResponse from(Comment comment, List replies) { + return new CommentResponse( + comment.getId(), + new PostDetailResponse.Author( + comment.getUser().getId(), + comment.getUser().getNickname(), + comment.getUser().getProfileImageUrl()), + comment.getContent(), + comment.getLikeCount(), + comment.getCreatedAt(), + replies); + } + + public static CommentResponse from(Comment comment) { + return from(comment, List.of()); + } +} diff --git a/src/main/java/com/server/post/dto/PostLikeResponse.java b/src/main/java/com/server/post/dto/PostLikeResponse.java new file mode 100644 index 0000000..1aec524 --- /dev/null +++ b/src/main/java/com/server/post/dto/PostLikeResponse.java @@ -0,0 +1,11 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "좋아요 처리 결과. 누른 사용자 목록은 노출하지 않는다.") +public record PostLikeResponse( + @Schema(description = "처리 후 좋아요 수", example = "13") int likeCount, + @Schema(description = "요청한 사용자가 이 게시물에 좋아요를 누른 상태인지", example = "true") + boolean liked +) { +} diff --git a/src/main/java/com/server/post/repository/CommentRepository.java b/src/main/java/com/server/post/repository/CommentRepository.java new file mode 100644 index 0000000..738ca1a --- /dev/null +++ b/src/main/java/com/server/post/repository/CommentRepository.java @@ -0,0 +1,26 @@ +package com.server.post.repository; + +import com.server.post.domain.Comment; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + Optional findByIdAndDeletedAtIsNull(Long id); + + /** + * 게시물의 최상위 댓글 한 페이지. 오래된 순으로 읽으므로 첫 페이지는 커서에 0을 넘긴다. + * 작성자는 응답에 항상 필요하므로 함께 조회한다. + */ + @EntityGraph(attributePaths = "user") + List findByPostIdAndParentIsNullAndDeletedAtIsNullAndIdGreaterThanOrderByIdAsc( + Long postId, Long cursor, Pageable pageable); + + /** 부모 댓글 여러 건의 대댓글을 한 번에 읽는다. */ + @EntityGraph(attributePaths = "user") + List findByParentIdInAndDeletedAtIsNullOrderByIdAsc(Collection parentIds); +} diff --git a/src/main/java/com/server/post/repository/PostLikeRepository.java b/src/main/java/com/server/post/repository/PostLikeRepository.java new file mode 100644 index 0000000..7e9fb77 --- /dev/null +++ b/src/main/java/com/server/post/repository/PostLikeRepository.java @@ -0,0 +1,13 @@ +package com.server.post.repository; + +import com.server.post.domain.PostLike; +import com.server.post.domain.PostLikeId; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PostLikeRepository extends JpaRepository { + + boolean existsByPostIdAndUserId(Long postId, Long userId); + + /** 취소된 건수를 돌려주므로 눌린 적 없는 좋아요를 취소해도 개수를 잘못 줄이지 않는다. */ + long deleteByPostIdAndUserId(Long postId, Long userId); +} diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java index 9edf54f..b77a34b 100644 --- a/src/main/java/com/server/post/repository/PostRepository.java +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -6,16 +6,41 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostRepository extends JpaRepository { @EntityGraph(attributePaths = "user") Optional findByIdAndDeletedAtIsNull(Long id); + boolean existsByIdAndDeletedAtIsNull(Long id); + /** * 최신순 피드 한 페이지. 첫 페이지는 {@code Long.MAX_VALUE}를 커서로 넘긴다. * 작성자는 응답에 항상 필요하므로 함께 조회한다. */ @EntityGraph(attributePaths = "user") List findByDeletedAtIsNullAndIdLessThanOrderByIdDesc(Long cursor, Pageable pageable); + + @Query("select post.likeCount from Post post where post.id = :postId") + int findLikeCountById(@Param("postId") Long postId); + + /** + * 좋아요 수를 DB에서 직접 증감시킨다. 엔티티를 읽어 고쳐 쓰면 동시에 들어온 요청이 + * 같은 값을 읽어 하나가 사라질 수 있다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Post post set post.likeCount = post.likeCount + 1 where post.id = :postId") + void increaseLikeCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Post post set post.likeCount = post.likeCount - 1 " + + "where post.id = :postId and post.likeCount > 0") + void decreaseLikeCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Post post set post.commentCount = post.commentCount + 1 where post.id = :postId") + void increaseCommentCount(@Param("postId") Long postId); } diff --git a/src/main/java/com/server/post/service/CommentService.java b/src/main/java/com/server/post/service/CommentService.java new file mode 100644 index 0000000..8ff9f7f --- /dev/null +++ b/src/main/java/com/server/post/service/CommentService.java @@ -0,0 +1,116 @@ +package com.server.post.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Comment; +import com.server.post.domain.Post; +import com.server.post.dto.CommentCreateRequest; +import com.server.post.dto.CommentListResponse; +import com.server.post.dto.CommentResponse; +import com.server.post.repository.CommentRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class CommentService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + /** 오래된 순으로 읽으므로 첫 페이지는 어떤 댓글 ID보다 작은 값에서 시작한다. */ + private static final long FIRST_PAGE_CURSOR = 0L; + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + + public CommentService( + CommentRepository commentRepository, + PostRepository postRepository, + UserRepository userRepository + ) { + this.commentRepository = commentRepository; + this.postRepository = postRepository; + this.userRepository = userRepository; + } + + @Transactional + public CommentResponse create(Long postId, Long userId, CommentCreateRequest request) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + User author = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + Comment parent = resolveParent(postId, request.parentId()); + Comment comment = commentRepository.save(new Comment(post, author, parent, request.content())); + postRepository.increaseCommentCount(postId); + + return CommentResponse.from(comment); + } + + @Transactional(readOnly = true) + public CommentListResponse getComments(Long postId, Long cursor, Integer size) { + if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { + throw new BusinessException(ErrorCode.POST_NOT_FOUND); + } + + int limit = resolvePageSize(size); + List parents = commentRepository + .findByPostIdAndParentIsNullAndDeletedAtIsNullAndIdGreaterThanOrderByIdAsc( + postId, + cursor == null ? FIRST_PAGE_CURSOR : cursor, + PageRequest.of(0, limit)); + + if (parents.isEmpty()) { + return new CommentListResponse(List.of(), null); + } + + List parentIds = parents.stream().map(Comment::getId).toList(); + Map> repliesByParent = commentRepository + .findByParentIdInAndDeletedAtIsNullOrderByIdAsc(parentIds).stream() + .collect(Collectors.groupingBy( + reply -> reply.getParent().getId(), + Collectors.mapping(CommentResponse::from, Collectors.toList()))); + + List items = parents.stream() + .map(parent -> CommentResponse.from( + parent, + repliesByParent.getOrDefault(parent.getId(), List.of()))) + .toList(); + + Long nextCursor = parents.size() < limit ? null : parents.get(parents.size() - 1).getId(); + return new CommentListResponse(items, nextCursor); + } + + /** + * 답글의 부모를 확인한다. 응답이 최상위 댓글과 답글 두 단계만 표현하므로 + * 답글에 다시 답글을 다는 요청은 거절한다. + */ + private Comment resolveParent(Long postId, Long parentId) { + if (parentId == null) { + return null; + } + Comment parent = commentRepository.findByIdAndDeletedAtIsNull(parentId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + if (!parent.getPost().getId().equals(postId)) { + throw new BusinessException(ErrorCode.INVALID_COMMENT_REQUEST); + } + if (parent.getParent() != null) { + throw new BusinessException(ErrorCode.INVALID_COMMENT_REQUEST); + } + return parent; + } + + private int resolvePageSize(Integer size) { + if (size == null || size <= 0) { + return DEFAULT_PAGE_SIZE; + } + return Math.min(size, MAX_PAGE_SIZE); + } +} diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java index b8f307b..4064949 100644 --- a/src/main/java/com/server/post/service/PostService.java +++ b/src/main/java/com/server/post/service/PostService.java @@ -5,13 +5,16 @@ import com.server.place.domain.Place; import com.server.place.repository.PlaceRepository; import com.server.post.domain.Post; +import com.server.post.domain.PostLike; import com.server.post.domain.PostMedia; import com.server.post.domain.PostPlaceTag; import com.server.post.dto.PostCreateRequest; import com.server.post.dto.PostDetailResponse; +import com.server.post.dto.PostLikeResponse; import com.server.post.dto.PostPlaceTagView; import com.server.post.dto.PostSummaryListResponse; import com.server.post.dto.PostSummaryResponse; +import com.server.post.repository.PostLikeRepository; import com.server.post.repository.PostMediaRepository; import com.server.post.repository.PostPlaceTagRepository; import com.server.post.repository.PostRepository; @@ -36,6 +39,7 @@ public class PostService { private final PostRepository postRepository; private final PostMediaRepository postMediaRepository; private final PostPlaceTagRepository postPlaceTagRepository; + private final PostLikeRepository postLikeRepository; private final UserRepository userRepository; private final PlaceRepository placeRepository; @@ -43,12 +47,14 @@ public PostService( PostRepository postRepository, PostMediaRepository postMediaRepository, PostPlaceTagRepository postPlaceTagRepository, + PostLikeRepository postLikeRepository, UserRepository userRepository, PlaceRepository placeRepository ) { this.postRepository = postRepository; this.postMediaRepository = postMediaRepository; this.postPlaceTagRepository = postPlaceTagRepository; + this.postLikeRepository = postLikeRepository; this.userRepository = userRepository; this.placeRepository = placeRepository; } @@ -108,6 +114,33 @@ public PostSummaryListResponse getFeed(Long cursor, Integer size) { return new PostSummaryListResponse(items, nextCursor); } + /** 이미 눌린 좋아요를 다시 눌러도 개수가 늘지 않는다. */ + @Transactional + public PostLikeResponse like(Long postId, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!postLikeRepository.existsByPostIdAndUserId(postId, userId)) { + postLikeRepository.save(new PostLike(post, user)); + postRepository.increaseLikeCount(postId); + } + return new PostLikeResponse(postRepository.findLikeCountById(postId), true); + } + + /** 누른 적 없는 좋아요를 취소해도 개수가 줄지 않는다. */ + @Transactional + public PostLikeResponse unlike(Long postId, Long userId) { + if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { + throw new BusinessException(ErrorCode.POST_NOT_FOUND); + } + if (postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0) { + postRepository.decreaseLikeCount(postId); + } + return new PostLikeResponse(postRepository.findLikeCountById(postId), false); + } + private List saveMedia(Post post, List requests) { if (requests == null || requests.isEmpty()) { return List.of(); From 5390de6ab03883fb3cf403a86036ffdc323d1df5 Mon Sep 17 00:00:00 2001 From: sususuj Date: Sun, 23 Aug 2026 20:21:59 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C,=20=ED=8C=94?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0,=20=ED=94=84=EB=A1=9C=ED=95=84,=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController.java | 76 +++++++++++++++++ .../com/server/bookmark/domain/Bookmark.java | 54 ++++++++++++ .../server/bookmark/domain/BookmarkId.java | 35 ++++++++ .../bookmark/dto/BookmarkListResponse.java | 13 +++ .../server/bookmark/dto/BookmarkResponse.java | 10 +++ .../repository/BookmarkRepository.java | 25 ++++++ .../bookmark/service/BookmarkService.java | 84 ++++++++++++++++++ .../com/server/common/error/ErrorCode.java | 2 + .../follow/controller/FollowController.java | 83 ++++++++++++++++++ .../java/com/server/follow/domain/Follow.java | 57 +++++++++++++ .../com/server/follow/domain/FollowId.java | 37 ++++++++ .../com/server/follow/dto/FollowResponse.java | 11 +++ .../follow/dto/FollowUserListResponse.java | 13 +++ .../server/follow/dto/FollowUserResponse.java | 16 ++++ .../follow/repository/FollowRepository.java | 32 +++++++ .../server/follow/service/FollowService.java | 85 +++++++++++++++++++ .../post/controller/PostController.java | 32 +++++++ .../java/com/server/post/domain/Post.java | 15 ++++ .../server/post/dto/PostUpdateRequest.java | 16 ++++ .../post/repository/PostRepository.java | 7 ++ .../com/server/post/service/PostService.java | 74 +++++++++++----- .../post/service/PostSummaryAssembler.java | 51 +++++++++++ .../user/controller/UserController.java | 61 +++++++++++++ .../server/user/dto/UserProfileResponse.java | 39 +++++++++ .../com/server/user/service/UserService.java | 51 +++++++++++ 25 files changed, 959 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/server/bookmark/controller/BookmarkController.java create mode 100644 src/main/java/com/server/bookmark/domain/Bookmark.java create mode 100644 src/main/java/com/server/bookmark/domain/BookmarkId.java create mode 100644 src/main/java/com/server/bookmark/dto/BookmarkListResponse.java create mode 100644 src/main/java/com/server/bookmark/dto/BookmarkResponse.java create mode 100644 src/main/java/com/server/bookmark/repository/BookmarkRepository.java create mode 100644 src/main/java/com/server/bookmark/service/BookmarkService.java create mode 100644 src/main/java/com/server/follow/controller/FollowController.java create mode 100644 src/main/java/com/server/follow/domain/Follow.java create mode 100644 src/main/java/com/server/follow/domain/FollowId.java create mode 100644 src/main/java/com/server/follow/dto/FollowResponse.java create mode 100644 src/main/java/com/server/follow/dto/FollowUserListResponse.java create mode 100644 src/main/java/com/server/follow/dto/FollowUserResponse.java create mode 100644 src/main/java/com/server/follow/repository/FollowRepository.java create mode 100644 src/main/java/com/server/follow/service/FollowService.java create mode 100644 src/main/java/com/server/post/dto/PostUpdateRequest.java create mode 100644 src/main/java/com/server/post/service/PostSummaryAssembler.java create mode 100644 src/main/java/com/server/user/controller/UserController.java create mode 100644 src/main/java/com/server/user/dto/UserProfileResponse.java create mode 100644 src/main/java/com/server/user/service/UserService.java diff --git a/src/main/java/com/server/bookmark/controller/BookmarkController.java b/src/main/java/com/server/bookmark/controller/BookmarkController.java new file mode 100644 index 0000000..4186569 --- /dev/null +++ b/src/main/java/com/server/bookmark/controller/BookmarkController.java @@ -0,0 +1,76 @@ +package com.server.bookmark.controller; + +import com.server.bookmark.dto.BookmarkListResponse; +import com.server.bookmark.dto.BookmarkResponse; +import com.server.bookmark.service.BookmarkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1") +@Tag(name = "커뮤니티 북마크", description = "게시물 저장과 내 저장 목록") +public class BookmarkController { + + private final BookmarkService bookmarkService; + + public BookmarkController(BookmarkService bookmarkService) { + this.bookmarkService = bookmarkService; + } + + @PostMapping("/posts/{postId}/bookmarks") + @Operation( + summary = "게시물 저장", + description = "이미 저장한 상태에서 다시 요청해도 중복 저장되지 않는다." + ) + public BookmarkResponse bookmark( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return bookmarkService.bookmark(postId, userId); + } + + @DeleteMapping("/posts/{postId}/bookmarks") + @Operation( + summary = "게시물 저장 해제", + description = "저장하지 않은 상태에서 요청해도 오류가 아니다." + ) + public BookmarkResponse removeBookmark( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return bookmarkService.removeBookmark(postId, userId); + } + + @GetMapping("/users/me/bookmarks") + @Operation( + summary = "내 북마크 목록", + description = "최근 저장한 순으로 반환한다. 저장한 뒤 삭제된 게시물은 제외한다." + ) + public BookmarkListResponse getMyBookmarks( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return bookmarkService.getMyBookmarks(userId, page, size); + } +} diff --git a/src/main/java/com/server/bookmark/domain/Bookmark.java b/src/main/java/com/server/bookmark/domain/Bookmark.java new file mode 100644 index 0000000..5bd1c62 --- /dev/null +++ b/src/main/java/com/server/bookmark/domain/Bookmark.java @@ -0,0 +1,54 @@ +package com.server.bookmark.domain; + +import com.server.post.domain.Post; +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "bookmarks") +@IdClass(BookmarkId.class) +public class Bookmark { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Bookmark() { + } + + public Bookmark(User user, Post post) { + this.user = user; + this.post = post; + this.createdAt = LocalDateTime.now(); + } + + public User getUser() { + return user; + } + + public Post getPost() { + return post; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/bookmark/domain/BookmarkId.java b/src/main/java/com/server/bookmark/domain/BookmarkId.java new file mode 100644 index 0000000..d3f3335 --- /dev/null +++ b/src/main/java/com/server/bookmark/domain/BookmarkId.java @@ -0,0 +1,35 @@ +package com.server.bookmark.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** {@link Bookmark}의 복합 기본키. 같은 게시물을 두 번 저장할 수 없다는 제약을 DB가 보장한다. */ +public class BookmarkId implements Serializable { + + private Long user; + private Long post; + + protected BookmarkId() { + } + + public BookmarkId(Long user, Long post) { + this.user = user; + this.post = post; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof BookmarkId that)) { + return false; + } + return Objects.equals(user, that.user) && Objects.equals(post, that.post); + } + + @Override + public int hashCode() { + return Objects.hash(user, post); + } +} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkListResponse.java b/src/main/java/com/server/bookmark/dto/BookmarkListResponse.java new file mode 100644 index 0000000..e4140a4 --- /dev/null +++ b/src/main/java/com/server/bookmark/dto/BookmarkListResponse.java @@ -0,0 +1,13 @@ +package com.server.bookmark.dto; + +import com.server.post.dto.PostSummaryResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "내 북마크 목록. 최근 저장한 순으로 반환한다.") +public record BookmarkListResponse( + @Schema(description = "저장한 게시물. 피드와 같은 축약 형태다.") + List items +) { +} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkResponse.java b/src/main/java/com/server/bookmark/dto/BookmarkResponse.java new file mode 100644 index 0000000..3ad49bf --- /dev/null +++ b/src/main/java/com/server/bookmark/dto/BookmarkResponse.java @@ -0,0 +1,10 @@ +package com.server.bookmark.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "북마크 처리 결과. 몇 명이 저장했는지는 공개하지 않는다.") +public record BookmarkResponse( + @Schema(description = "요청한 사용자가 이 게시물을 저장한 상태인지", example = "true") + boolean bookmarked +) { +} diff --git a/src/main/java/com/server/bookmark/repository/BookmarkRepository.java b/src/main/java/com/server/bookmark/repository/BookmarkRepository.java new file mode 100644 index 0000000..c64dd43 --- /dev/null +++ b/src/main/java/com/server/bookmark/repository/BookmarkRepository.java @@ -0,0 +1,25 @@ +package com.server.bookmark.repository; + +import com.server.bookmark.domain.Bookmark; +import com.server.bookmark.domain.BookmarkId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookmarkRepository extends JpaRepository { + + boolean existsByUserIdAndPostId(Long userId, Long postId); + + long deleteByUserIdAndPostId(Long userId, Long postId); + + /** + * 최근 저장한 순으로 읽는다. 저장 시각 기준 정렬이라 커서로 삼을 단조 증가 값이 없어 + * 오프셋 페이징을 쓴다. 이 목록은 본인만 바꾸므로 조회 중 목록이 밀릴 위험이 낮다. + * + *

삭제된 게시물은 응답에서 제외해야 하므로 게시물을 함께 조회한다. + */ + @EntityGraph(attributePaths = {"post", "post.user"}) + List findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc( + Long userId, Pageable pageable); +} diff --git a/src/main/java/com/server/bookmark/service/BookmarkService.java b/src/main/java/com/server/bookmark/service/BookmarkService.java new file mode 100644 index 0000000..35af753 --- /dev/null +++ b/src/main/java/com/server/bookmark/service/BookmarkService.java @@ -0,0 +1,84 @@ +package com.server.bookmark.service; + +import com.server.bookmark.domain.Bookmark; +import com.server.bookmark.dto.BookmarkListResponse; +import com.server.bookmark.dto.BookmarkResponse; +import com.server.bookmark.repository.BookmarkRepository; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Post; +import com.server.post.repository.PostRepository; +import com.server.post.service.PostSummaryAssembler; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BookmarkService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final BookmarkRepository bookmarkRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final PostSummaryAssembler postSummaryAssembler; + + public BookmarkService( + BookmarkRepository bookmarkRepository, + PostRepository postRepository, + UserRepository userRepository, + PostSummaryAssembler postSummaryAssembler + ) { + this.bookmarkRepository = bookmarkRepository; + this.postRepository = postRepository; + this.userRepository = userRepository; + this.postSummaryAssembler = postSummaryAssembler; + } + + /** 이미 저장한 게시물을 다시 저장해도 중복 생성되지 않는다. */ + @Transactional + public BookmarkResponse bookmark(Long postId, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!bookmarkRepository.existsByUserIdAndPostId(userId, postId)) { + bookmarkRepository.save(new Bookmark(user, post)); + } + return new BookmarkResponse(true); + } + + @Transactional + public BookmarkResponse removeBookmark(Long postId, Long userId) { + if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { + throw new BusinessException(ErrorCode.POST_NOT_FOUND); + } + bookmarkRepository.deleteByUserIdAndPostId(userId, postId); + return new BookmarkResponse(false); + } + + /** 저장한 뒤 삭제된 게시물은 목록에서 제외한다. */ + @Transactional(readOnly = true) + public BookmarkListResponse getMyBookmarks(Long userId, Integer page, Integer size) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + List posts = bookmarkRepository + .findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc(userId, pageRequest(page, size)) + .stream() + .map(Bookmark::getPost) + .toList(); + return new BookmarkListResponse(postSummaryAssembler.assemble(posts)); + } + + private PageRequest pageRequest(Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + return PageRequest.of(resolvedPage, resolvedSize); + } +} diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index 39d4240..a9e5118 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -28,6 +28,8 @@ public enum ErrorCode { COMMENT_NOT_FOUND(404, "댓글을 찾을 수 없습니다."), USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."), INVALID_COMMENT_REQUEST(400, "댓글 요청이 올바르지 않습니다."), + INVALID_FOLLOW_REQUEST(400, "팔로우 요청이 올바르지 않습니다."), + POST_ACCESS_DENIED(403, "본인의 게시물만 수정하거나 삭제할 수 있습니다."), TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), diff --git a/src/main/java/com/server/follow/controller/FollowController.java b/src/main/java/com/server/follow/controller/FollowController.java new file mode 100644 index 0000000..dc47e19 --- /dev/null +++ b/src/main/java/com/server/follow/controller/FollowController.java @@ -0,0 +1,83 @@ +package com.server.follow.controller; + +import com.server.follow.dto.FollowResponse; +import com.server.follow.dto.FollowUserListResponse; +import com.server.follow.service.FollowService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/users/{userId}") +@Tag(name = "커뮤니티 팔로우", description = "사용자 간 팔로우 관계") +public class FollowController { + + private final FollowService followService; + + public FollowController(FollowService followService) { + this.followService = followService; + } + + @PostMapping("/follows") + @Operation( + summary = "팔로우", + description = "이미 팔로우한 상태에서 다시 요청해도 팔로워 수가 늘지 않는다. 자기 자신은 팔로우할 수 없다." + ) + public FollowResponse follow( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "팔로우할 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return followService.follow(userId, requesterId); + } + + @DeleteMapping("/follows") + @Operation( + summary = "팔로우 취소", + description = "팔로우하지 않은 상태에서 요청해도 팔로워 수가 줄지 않는다." + ) + public FollowResponse unfollow( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "팔로우를 취소할 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return followService.unfollow(userId, requesterId); + } + + @GetMapping("/followers") + @Operation(summary = "팔로워 목록", description = "이 사용자를 팔로우하는 사람들을 최근 순으로 반환한다.") + public FollowUserListResponse getFollowers( + @Parameter(example = "1") @PathVariable Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return followService.getFollowers(userId, page, size); + } + + @GetMapping("/followings") + @Operation(summary = "팔로잉 목록", description = "이 사용자가 팔로우하는 사람들을 최근 순으로 반환한다.") + public FollowUserListResponse getFollowings( + @Parameter(example = "1") @PathVariable Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return followService.getFollowings(userId, page, size); + } +} diff --git a/src/main/java/com/server/follow/domain/Follow.java b/src/main/java/com/server/follow/domain/Follow.java new file mode 100644 index 0000000..4733e67 --- /dev/null +++ b/src/main/java/com/server/follow/domain/Follow.java @@ -0,0 +1,57 @@ +package com.server.follow.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +/** + * 팔로우 관계. {@code follower}가 {@code following}을 팔로우한다. 방향이 있으므로 + * 서로 팔로우하려면 두 행이 필요하다. + */ +@Entity +@Table(name = "follows") +@IdClass(FollowId.class) +public class Follow { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "follower_id", nullable = false) + private User follower; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "following_id", nullable = false) + private User following; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Follow() { + } + + public Follow(User follower, User following) { + this.follower = follower; + this.following = following; + this.createdAt = LocalDateTime.now(); + } + + public User getFollower() { + return follower; + } + + public User getFollowing() { + return following; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/follow/domain/FollowId.java b/src/main/java/com/server/follow/domain/FollowId.java new file mode 100644 index 0000000..f4fd642 --- /dev/null +++ b/src/main/java/com/server/follow/domain/FollowId.java @@ -0,0 +1,37 @@ +package com.server.follow.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** + * {@link Follow}의 복합 기본키. 같은 사람을 두 번 팔로우할 수 없다는 제약을 DB가 보장한다. + */ +public class FollowId implements Serializable { + + private Long follower; + private Long following; + + protected FollowId() { + } + + public FollowId(Long follower, Long following) { + this.follower = follower; + this.following = following; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FollowId that)) { + return false; + } + return Objects.equals(follower, that.follower) && Objects.equals(following, that.following); + } + + @Override + public int hashCode() { + return Objects.hash(follower, following); + } +} diff --git a/src/main/java/com/server/follow/dto/FollowResponse.java b/src/main/java/com/server/follow/dto/FollowResponse.java new file mode 100644 index 0000000..3fa42b2 --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowResponse.java @@ -0,0 +1,11 @@ +package com.server.follow.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "팔로우 처리 결과") +public record FollowResponse( + @Schema(description = "처리 후 대상 사용자의 팔로워 수", example = "152") long followerCount, + @Schema(description = "요청한 사용자가 대상을 팔로우한 상태인지", example = "true") + boolean following +) { +} diff --git a/src/main/java/com/server/follow/dto/FollowUserListResponse.java b/src/main/java/com/server/follow/dto/FollowUserListResponse.java new file mode 100644 index 0000000..5812ef1 --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowUserListResponse.java @@ -0,0 +1,13 @@ +package com.server.follow.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "팔로워 또는 팔로잉 목록. 최근 맺은 관계부터 반환한다.") +public record FollowUserListResponse( + List items, + @Schema(description = "전체 인원 수. 화면의 \"팔로워 152명\" 표시에 쓴다.", example = "152") + long totalCount +) { +} diff --git a/src/main/java/com/server/follow/dto/FollowUserResponse.java b/src/main/java/com/server/follow/dto/FollowUserResponse.java new file mode 100644 index 0000000..6e68b1e --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowUserResponse.java @@ -0,0 +1,16 @@ +package com.server.follow.dto; + +import com.server.user.domain.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "팔로워·팔로잉 목록에 표시할 사용자") +public record FollowUserResponse( + @Schema(example = "1") Long id, + @Schema(example = "감자") String nickname, + @Schema(example = "https://example.com/profile/1.jpg") String profileImageUrl +) { + + public static FollowUserResponse from(User user) { + return new FollowUserResponse(user.getId(), user.getNickname(), user.getProfileImageUrl()); + } +} diff --git a/src/main/java/com/server/follow/repository/FollowRepository.java b/src/main/java/com/server/follow/repository/FollowRepository.java new file mode 100644 index 0000000..35b5fd9 --- /dev/null +++ b/src/main/java/com/server/follow/repository/FollowRepository.java @@ -0,0 +1,32 @@ +package com.server.follow.repository; + +import com.server.follow.domain.Follow; +import com.server.follow.domain.FollowId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FollowRepository extends JpaRepository { + + boolean existsByFollowerIdAndFollowingId(Long followerId, Long followingId); + + long deleteByFollowerIdAndFollowingId(Long followerId, Long followingId); + + long countByFollowingId(Long followingId); + + long countByFollowerId(Long followerId); + + /** + * 대상을 팔로우하는 사람들. 목록에 닉네임을 담으므로 follower 를 함께 조회한다. + * + *

{@code follows}에는 대리키가 없어 커서 기준으로 삼을 단조 증가 값이 없다. + * 목록 정확도 요구가 피드만큼 높지 않아 오프셋 페이징을 쓴다. + */ + @EntityGraph(attributePaths = "follower") + List findByFollowingIdOrderByCreatedAtDesc(Long followingId, Pageable pageable); + + /** 대상이 팔로우하는 사람들. */ + @EntityGraph(attributePaths = "following") + List findByFollowerIdOrderByCreatedAtDesc(Long followerId, Pageable pageable); +} diff --git a/src/main/java/com/server/follow/service/FollowService.java b/src/main/java/com/server/follow/service/FollowService.java new file mode 100644 index 0000000..2c7bbe9 --- /dev/null +++ b/src/main/java/com/server/follow/service/FollowService.java @@ -0,0 +1,85 @@ +package com.server.follow.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.follow.domain.Follow; +import com.server.follow.dto.FollowResponse; +import com.server.follow.dto.FollowUserListResponse; +import com.server.follow.dto.FollowUserResponse; +import com.server.follow.repository.FollowRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class FollowService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final FollowRepository followRepository; + private final UserRepository userRepository; + + public FollowService(FollowRepository followRepository, UserRepository userRepository) { + this.followRepository = followRepository; + this.userRepository = userRepository; + } + + /** 이미 팔로우한 상대를 다시 팔로우해도 관계가 중복 생성되지 않는다. */ + @Transactional + public FollowResponse follow(Long targetUserId, Long userId) { + if (targetUserId.equals(userId)) { + throw new BusinessException(ErrorCode.INVALID_FOLLOW_REQUEST); + } + User target = findActiveUser(targetUserId); + User me = findActiveUser(userId); + + if (!followRepository.existsByFollowerIdAndFollowingId(userId, targetUserId)) { + followRepository.save(new Follow(me, target)); + } + return new FollowResponse(followRepository.countByFollowingId(targetUserId), true); + } + + @Transactional + public FollowResponse unfollow(Long targetUserId, Long userId) { + findActiveUser(targetUserId); + followRepository.deleteByFollowerIdAndFollowingId(userId, targetUserId); + return new FollowResponse(followRepository.countByFollowingId(targetUserId), false); + } + + /** 대상을 팔로우하는 사람들. */ + @Transactional(readOnly = true) + public FollowUserListResponse getFollowers(Long targetUserId, Integer page, Integer size) { + findActiveUser(targetUserId); + List items = followRepository + .findByFollowingIdOrderByCreatedAtDesc(targetUserId, pageRequest(page, size)).stream() + .map(follow -> FollowUserResponse.from(follow.getFollower())) + .toList(); + return new FollowUserListResponse(items, followRepository.countByFollowingId(targetUserId)); + } + + /** 대상이 팔로우하는 사람들. */ + @Transactional(readOnly = true) + public FollowUserListResponse getFollowings(Long targetUserId, Integer page, Integer size) { + findActiveUser(targetUserId); + List items = followRepository + .findByFollowerIdOrderByCreatedAtDesc(targetUserId, pageRequest(page, size)).stream() + .map(follow -> FollowUserResponse.from(follow.getFollowing())) + .toList(); + return new FollowUserListResponse(items, followRepository.countByFollowerId(targetUserId)); + } + + private User findActiveUser(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private PageRequest pageRequest(Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + return PageRequest.of(resolvedPage, resolvedSize); + } +} diff --git a/src/main/java/com/server/post/controller/PostController.java b/src/main/java/com/server/post/controller/PostController.java index b56cb6c..0b75ee8 100644 --- a/src/main/java/com/server/post/controller/PostController.java +++ b/src/main/java/com/server/post/controller/PostController.java @@ -4,6 +4,7 @@ import com.server.post.dto.PostDetailResponse; import com.server.post.dto.PostLikeResponse; import com.server.post.dto.PostSummaryListResponse; +import com.server.post.dto.PostUpdateRequest; import com.server.post.service.PostService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -14,6 +15,7 @@ import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -73,6 +75,36 @@ public PostDetailResponse get( return postService.get(postId); } + @PatchMapping("/{postId}") + @Operation( + summary = "게시물 수정", + description = "본문만 수정한다. 작성자 본인이 아니면 403 을 반환한다." + ) + public PostDetailResponse update( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId, + @Valid @RequestBody PostUpdateRequest request + ) { + return postService.update(postId, userId, request); + } + + @DeleteMapping("/{postId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "게시물 삭제", + description = "바로 지우지 않고 삭제 표시만 남긴다. 작성자 본인이 아니면 403 을 반환한다." + ) + public void delete( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + postService.delete(postId, userId); + } + @PostMapping("/{postId}/likes") @Operation( summary = "좋아요", diff --git a/src/main/java/com/server/post/domain/Post.java b/src/main/java/com/server/post/domain/Post.java index b7f5d95..6d7bab7 100644 --- a/src/main/java/com/server/post/domain/Post.java +++ b/src/main/java/com/server/post/domain/Post.java @@ -76,4 +76,19 @@ public LocalDateTime getUpdatedAt() { public LocalDateTime getDeletedAt() { return deletedAt; } + + public boolean isWrittenBy(Long userId) { + return user.getId().equals(userId); + } + + public void updateContent(String content) { + this.content = content; + this.updatedAt = LocalDateTime.now(); + } + + /** 물리 삭제하지 않는다. 기획상 삭제 후 30일간 복구할 수 있어야 한다. */ + public void delete() { + this.deletedAt = LocalDateTime.now(); + this.updatedAt = this.deletedAt; + } } diff --git a/src/main/java/com/server/post/dto/PostUpdateRequest.java b/src/main/java/com/server/post/dto/PostUpdateRequest.java new file mode 100644 index 0000000..233c6f8 --- /dev/null +++ b/src/main/java/com/server/post/dto/PostUpdateRequest.java @@ -0,0 +1,16 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +/** + * 본문만 수정한다. 첨부 미디어와 장소 태그 교체는 저장소에 올라간 파일 정리까지 함께 + * 정해야 하므로 후속 작업으로 미룬다. + */ +@Schema(description = "게시물 수정 요청. 작성자 본인만 수정할 수 있다.") +public record PostUpdateRequest( + @Schema(description = "수정할 본문", example = "광안리 야경 진짜 좋았어요") + @NotBlank String content +) { +} diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java index b77a34b..0c09cf9 100644 --- a/src/main/java/com/server/post/repository/PostRepository.java +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -17,6 +17,13 @@ public interface PostRepository extends JpaRepository { boolean existsByIdAndDeletedAtIsNull(Long id); + long countByUserIdAndDeletedAtIsNull(Long userId); + + /** 특정 사용자가 쓴 게시물 한 페이지. 피드와 같은 커서 방식을 쓴다. */ + @EntityGraph(attributePaths = "user") + List findByUserIdAndDeletedAtIsNullAndIdLessThanOrderByIdDesc( + Long userId, Long cursor, Pageable pageable); + /** * 최신순 피드 한 페이지. 첫 페이지는 {@code Long.MAX_VALUE}를 커서로 넘긴다. * 작성자는 응답에 항상 필요하므로 함께 조회한다. diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java index 4064949..facd0c6 100644 --- a/src/main/java/com/server/post/service/PostService.java +++ b/src/main/java/com/server/post/service/PostService.java @@ -14,6 +14,7 @@ import com.server.post.dto.PostPlaceTagView; import com.server.post.dto.PostSummaryListResponse; import com.server.post.dto.PostSummaryResponse; +import com.server.post.dto.PostUpdateRequest; import com.server.post.repository.PostLikeRepository; import com.server.post.repository.PostMediaRepository; import com.server.post.repository.PostPlaceTagRepository; @@ -42,6 +43,7 @@ public class PostService { private final PostLikeRepository postLikeRepository; private final UserRepository userRepository; private final PlaceRepository placeRepository; + private final PostSummaryAssembler postSummaryAssembler; public PostService( PostRepository postRepository, @@ -49,7 +51,8 @@ public PostService( PostPlaceTagRepository postPlaceTagRepository, PostLikeRepository postLikeRepository, UserRepository userRepository, - PlaceRepository placeRepository + PlaceRepository placeRepository, + PostSummaryAssembler postSummaryAssembler ) { this.postRepository = postRepository; this.postMediaRepository = postMediaRepository; @@ -57,6 +60,7 @@ public PostService( this.postLikeRepository = postLikeRepository; this.userRepository = userRepository; this.placeRepository = placeRepository; + this.postSummaryAssembler = postSummaryAssembler; } @Transactional @@ -88,30 +92,60 @@ public PostDetailResponse get(Long postId) { @Transactional(readOnly = true) public PostSummaryListResponse getFeed(Long cursor, Integer size) { int limit = resolveFeedSize(size); - List posts = postRepository.findByDeletedAtIsNullAndIdLessThanOrderByIdDesc( - cursor == null ? FIRST_PAGE_CURSOR : cursor, - PageRequest.of(0, limit)); + return toSummaryList( + postRepository.findByDeletedAtIsNullAndIdLessThanOrderByIdDesc( + cursor == null ? FIRST_PAGE_CURSOR : cursor, + PageRequest.of(0, limit)), + limit); + } - if (posts.isEmpty()) { - return new PostSummaryListResponse(List.of(), null); + @Transactional(readOnly = true) + public PostSummaryListResponse getUserPosts(Long userId, Long cursor, Integer size) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); } + int limit = resolveFeedSize(size); + return toSummaryList( + postRepository.findByUserIdAndDeletedAtIsNullAndIdLessThanOrderByIdDesc( + userId, + cursor == null ? FIRST_PAGE_CURSOR : cursor, + PageRequest.of(0, limit)), + limit); + } - List postIds = posts.stream().map(Post::getId).toList(); - Map> mediaByPost = postMediaRepository.findByPostIdIn(postIds).stream() - .collect(Collectors.groupingBy(media -> media.getPost().getId())); - Map> placeTagsByPost = postPlaceTagRepository.findViewsByPostIdIn(postIds).stream() - .collect(Collectors.groupingBy(PostPlaceTagView::postId)); - - List items = posts.stream() - .map(post -> PostSummaryResponse.from( - post, - mediaByPost.getOrDefault(post.getId(), List.of()), - placeTagsByPost.getOrDefault(post.getId(), List.of()))) - .toList(); - + private PostSummaryListResponse toSummaryList(List posts, int limit) { // 요청한 개수를 못 채웠으면 더 가져올 게시물이 없다. Long nextCursor = posts.size() < limit ? null : posts.get(posts.size() - 1).getId(); - return new PostSummaryListResponse(items, nextCursor); + return new PostSummaryListResponse(postSummaryAssembler.assemble(posts), nextCursor); + } + + @Transactional + public PostDetailResponse update(Long postId, Long userId, PostUpdateRequest request) { + Post post = findWritablePost(postId, userId); + post.updateContent(request.content()); + + return PostDetailResponse.from( + post, + postMediaRepository.findByPostId(postId), + postPlaceTagRepository.findViewsByPostId(postId)); + } + + /** + * 물리 삭제하지 않고 {@code deletedAt}만 남긴다. 조회 경로가 모두 + * {@code deletedAt IS NULL} 조건을 쓰므로 삭제된 게시물은 응답에 나오지 않는다. + */ + @Transactional + public void delete(Long postId, Long userId) { + findWritablePost(postId, userId).delete(); + } + + private Post findWritablePost(Long postId, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + if (!post.isWrittenBy(userId)) { + throw new BusinessException(ErrorCode.POST_ACCESS_DENIED); + } + return post; } /** 이미 눌린 좋아요를 다시 눌러도 개수가 늘지 않는다. */ diff --git a/src/main/java/com/server/post/service/PostSummaryAssembler.java b/src/main/java/com/server/post/service/PostSummaryAssembler.java new file mode 100644 index 0000000..89a344b --- /dev/null +++ b/src/main/java/com/server/post/service/PostSummaryAssembler.java @@ -0,0 +1,51 @@ +package com.server.post.service; + +import com.server.post.domain.Post; +import com.server.post.domain.PostMedia; +import com.server.post.dto.PostPlaceTagView; +import com.server.post.dto.PostSummaryResponse; +import com.server.post.repository.PostMediaRepository; +import com.server.post.repository.PostPlaceTagRepository; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * 게시물 목록에 미디어와 장소 태그를 붙여 축약 응답으로 만든다. + * 게시물 수와 무관하게 미디어·장소 태그를 각각 한 번씩만 읽는다. + */ +@Component +public class PostSummaryAssembler { + + private final PostMediaRepository postMediaRepository; + private final PostPlaceTagRepository postPlaceTagRepository; + + public PostSummaryAssembler( + PostMediaRepository postMediaRepository, + PostPlaceTagRepository postPlaceTagRepository + ) { + this.postMediaRepository = postMediaRepository; + this.postPlaceTagRepository = postPlaceTagRepository; + } + + public List assemble(List posts) { + if (posts.isEmpty()) { + return List.of(); + } + + List postIds = posts.stream().map(Post::getId).toList(); + Map> mediaByPost = postMediaRepository.findByPostIdIn(postIds).stream() + .collect(Collectors.groupingBy(media -> media.getPost().getId())); + Map> placeTagsByPost = + postPlaceTagRepository.findViewsByPostIdIn(postIds).stream() + .collect(Collectors.groupingBy(PostPlaceTagView::postId)); + + return posts.stream() + .map(post -> PostSummaryResponse.from( + post, + mediaByPost.getOrDefault(post.getId(), List.of()), + placeTagsByPost.getOrDefault(post.getId(), List.of()))) + .toList(); + } +} diff --git a/src/main/java/com/server/user/controller/UserController.java b/src/main/java/com/server/user/controller/UserController.java new file mode 100644 index 0000000..fff1c9d --- /dev/null +++ b/src/main/java/com/server/user/controller/UserController.java @@ -0,0 +1,61 @@ +package com.server.user.controller; + +import com.server.post.dto.PostSummaryListResponse; +import com.server.post.service.PostService; +import com.server.user.dto.UserProfileResponse; +import com.server.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/users/{userId}") +@Tag(name = "커뮤니티 프로필", description = "사용자 프로필과 작성 게시물") +public class UserController { + + private final UserService userService; + private final PostService postService; + + public UserController(UserService userService, PostService postService) { + this.userService = userService; + this.postService = postService; + } + + @GetMapping("/profile") + @Operation( + summary = "프로필 조회", + description = "게시물·팔로워·팔로잉 수와 요청자의 팔로우 여부를 함께 반환한다." + ) + public UserProfileResponse getProfile( + @Parameter(example = "1") @PathVariable Long userId, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 없으면 팔로우 여부를 판단하지 않는다.", example = "2") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId + ) { + return userService.getProfile(userId, requesterId); + } + + @GetMapping("/posts") + @Operation( + summary = "사용자 게시물 목록", + description = "이 사용자가 쓴 게시물을 최신순으로 반환한다. 피드와 같은 커서 방식을 쓴다." + ) + public PostSummaryListResponse getUserPosts( + @Parameter(example = "1") @PathVariable Long userId, + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") + @RequestParam(required = false) Long cursor, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return postService.getUserPosts(userId, cursor, size); + } +} diff --git a/src/main/java/com/server/user/dto/UserProfileResponse.java b/src/main/java/com/server/user/dto/UserProfileResponse.java new file mode 100644 index 0000000..1534f2e --- /dev/null +++ b/src/main/java/com/server/user/dto/UserProfileResponse.java @@ -0,0 +1,39 @@ +package com.server.user.dto; + +import com.server.user.domain.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "프로필 화면 상단 정보") +public record UserProfileResponse( + @Schema(example = "1") Long id, + @Schema(example = "감자") String nickname, + @Schema(example = "https://example.com/profile/1.jpg") String profileImageUrl, + @Schema(description = "삭제하지 않은 게시물 수", example = "12") long postCount, + @Schema(example = "152") long followerCount, + @Schema(example = "88") long followingCount, + @Schema(description = "요청자가 이 사용자를 팔로우한 상태인지. 요청자를 알 수 없으면 false 다.", + example = "true") + boolean following, + @Schema(description = "요청자 본인의 프로필인지. 본인이면 북마크 탭을 노출한다.", example = "false") + boolean me +) { + + public static UserProfileResponse of( + User user, + long postCount, + long followerCount, + long followingCount, + boolean following, + boolean me + ) { + return new UserProfileResponse( + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + postCount, + followerCount, + followingCount, + following, + me); + } +} diff --git a/src/main/java/com/server/user/service/UserService.java b/src/main/java/com/server/user/service/UserService.java new file mode 100644 index 0000000..ccf46f9 --- /dev/null +++ b/src/main/java/com/server/user/service/UserService.java @@ -0,0 +1,51 @@ +package com.server.user.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.follow.repository.FollowRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.dto.UserProfileResponse; +import com.server.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final FollowRepository followRepository; + + public UserService( + UserRepository userRepository, + PostRepository postRepository, + FollowRepository followRepository + ) { + this.userRepository = userRepository; + this.postRepository = postRepository; + this.followRepository = followRepository; + } + + /** + * @param requesterId 조회하는 사용자. 인증이 없어 전달되지 않을 수 있으며, + * 없으면 팔로우 여부를 판단하지 않는다. + */ + @Transactional(readOnly = true) + public UserProfileResponse getProfile(Long userId, Long requesterId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + boolean following = requesterId != null + && !requesterId.equals(userId) + && followRepository.existsByFollowerIdAndFollowingId(requesterId, userId); + + return UserProfileResponse.of( + user, + postRepository.countByUserIdAndDeletedAtIsNull(userId), + followRepository.countByFollowingId(userId), + followRepository.countByFollowerId(userId), + following, + userId.equals(requesterId)); + } +} From 653292f2002f6d7eb71baaac62227bb21aeb3b23 Mon Sep 17 00:00:00 2001 From: sususuj Date: Sun, 23 Aug 2026 20:28:20 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=AC=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=C2=B7=EC=82=AD=EC=A0=9C,=20=ED=8C=94?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0,=20=ED=94=84=EB=A1=9C=ED=95=84,=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController 2.java | 76 ++++++++++ .../server/bookmark/domain/Bookmark 2.java | 54 +++++++ .../server/bookmark/domain/BookmarkId 2.java | 35 +++++ .../bookmark/dto/BookmarkListResponse 2.java | 13 ++ .../bookmark/dto/BookmarkResponse 2.java | 10 ++ .../repository/BookmarkRepository 2.java | 25 ++++ .../bookmark/service/BookmarkService 2.java | 84 +++++++++++ .../follow/controller/FollowController 2.java | 83 +++++++++++ .../com/server/follow/domain/Follow 2.java | 57 ++++++++ .../com/server/follow/domain/FollowId 2.java | 37 +++++ .../server/follow/dto/FollowResponse 2.java | 11 ++ .../follow/dto/FollowUserListResponse 2.java | 13 ++ .../follow/dto/FollowUserResponse 2.java | 16 +++ .../post/controller/PostController 2.java | 135 ++++++++++++++++++ .../server/post/domain/PostPlaceTag 2.java | 70 +++++++++ .../server/post/domain/ReportStatus 2.java | 6 + .../server/post/dto/PostPlaceTagView 2.java | 29 ++++ .../server/user/service/UserService 2.java | 51 +++++++ 18 files changed, 805 insertions(+) create mode 100644 src/main/java/com/server/bookmark/controller/BookmarkController 2.java create mode 100644 src/main/java/com/server/bookmark/domain/Bookmark 2.java create mode 100644 src/main/java/com/server/bookmark/domain/BookmarkId 2.java create mode 100644 src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java create mode 100644 src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java create mode 100644 src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java create mode 100644 src/main/java/com/server/bookmark/service/BookmarkService 2.java create mode 100644 src/main/java/com/server/follow/controller/FollowController 2.java create mode 100644 src/main/java/com/server/follow/domain/Follow 2.java create mode 100644 src/main/java/com/server/follow/domain/FollowId 2.java create mode 100644 src/main/java/com/server/follow/dto/FollowResponse 2.java create mode 100644 src/main/java/com/server/follow/dto/FollowUserListResponse 2.java create mode 100644 src/main/java/com/server/follow/dto/FollowUserResponse 2.java create mode 100644 src/main/java/com/server/post/controller/PostController 2.java create mode 100644 src/main/java/com/server/post/domain/PostPlaceTag 2.java create mode 100644 src/main/java/com/server/post/domain/ReportStatus 2.java create mode 100644 src/main/java/com/server/post/dto/PostPlaceTagView 2.java create mode 100644 src/main/java/com/server/user/service/UserService 2.java diff --git a/src/main/java/com/server/bookmark/controller/BookmarkController 2.java b/src/main/java/com/server/bookmark/controller/BookmarkController 2.java new file mode 100644 index 0000000..4186569 --- /dev/null +++ b/src/main/java/com/server/bookmark/controller/BookmarkController 2.java @@ -0,0 +1,76 @@ +package com.server.bookmark.controller; + +import com.server.bookmark.dto.BookmarkListResponse; +import com.server.bookmark.dto.BookmarkResponse; +import com.server.bookmark.service.BookmarkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1") +@Tag(name = "커뮤니티 북마크", description = "게시물 저장과 내 저장 목록") +public class BookmarkController { + + private final BookmarkService bookmarkService; + + public BookmarkController(BookmarkService bookmarkService) { + this.bookmarkService = bookmarkService; + } + + @PostMapping("/posts/{postId}/bookmarks") + @Operation( + summary = "게시물 저장", + description = "이미 저장한 상태에서 다시 요청해도 중복 저장되지 않는다." + ) + public BookmarkResponse bookmark( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return bookmarkService.bookmark(postId, userId); + } + + @DeleteMapping("/posts/{postId}/bookmarks") + @Operation( + summary = "게시물 저장 해제", + description = "저장하지 않은 상태에서 요청해도 오류가 아니다." + ) + public BookmarkResponse removeBookmark( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return bookmarkService.removeBookmark(postId, userId); + } + + @GetMapping("/users/me/bookmarks") + @Operation( + summary = "내 북마크 목록", + description = "최근 저장한 순으로 반환한다. 저장한 뒤 삭제된 게시물은 제외한다." + ) + public BookmarkListResponse getMyBookmarks( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return bookmarkService.getMyBookmarks(userId, page, size); + } +} diff --git a/src/main/java/com/server/bookmark/domain/Bookmark 2.java b/src/main/java/com/server/bookmark/domain/Bookmark 2.java new file mode 100644 index 0000000..5bd1c62 --- /dev/null +++ b/src/main/java/com/server/bookmark/domain/Bookmark 2.java @@ -0,0 +1,54 @@ +package com.server.bookmark.domain; + +import com.server.post.domain.Post; +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "bookmarks") +@IdClass(BookmarkId.class) +public class Bookmark { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Bookmark() { + } + + public Bookmark(User user, Post post) { + this.user = user; + this.post = post; + this.createdAt = LocalDateTime.now(); + } + + public User getUser() { + return user; + } + + public Post getPost() { + return post; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/bookmark/domain/BookmarkId 2.java b/src/main/java/com/server/bookmark/domain/BookmarkId 2.java new file mode 100644 index 0000000..d3f3335 --- /dev/null +++ b/src/main/java/com/server/bookmark/domain/BookmarkId 2.java @@ -0,0 +1,35 @@ +package com.server.bookmark.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** {@link Bookmark}의 복합 기본키. 같은 게시물을 두 번 저장할 수 없다는 제약을 DB가 보장한다. */ +public class BookmarkId implements Serializable { + + private Long user; + private Long post; + + protected BookmarkId() { + } + + public BookmarkId(Long user, Long post) { + this.user = user; + this.post = post; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof BookmarkId that)) { + return false; + } + return Objects.equals(user, that.user) && Objects.equals(post, that.post); + } + + @Override + public int hashCode() { + return Objects.hash(user, post); + } +} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java b/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java new file mode 100644 index 0000000..e4140a4 --- /dev/null +++ b/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java @@ -0,0 +1,13 @@ +package com.server.bookmark.dto; + +import com.server.post.dto.PostSummaryResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "내 북마크 목록. 최근 저장한 순으로 반환한다.") +public record BookmarkListResponse( + @Schema(description = "저장한 게시물. 피드와 같은 축약 형태다.") + List items +) { +} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java b/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java new file mode 100644 index 0000000..3ad49bf --- /dev/null +++ b/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java @@ -0,0 +1,10 @@ +package com.server.bookmark.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "북마크 처리 결과. 몇 명이 저장했는지는 공개하지 않는다.") +public record BookmarkResponse( + @Schema(description = "요청한 사용자가 이 게시물을 저장한 상태인지", example = "true") + boolean bookmarked +) { +} diff --git a/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java b/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java new file mode 100644 index 0000000..c64dd43 --- /dev/null +++ b/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java @@ -0,0 +1,25 @@ +package com.server.bookmark.repository; + +import com.server.bookmark.domain.Bookmark; +import com.server.bookmark.domain.BookmarkId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookmarkRepository extends JpaRepository { + + boolean existsByUserIdAndPostId(Long userId, Long postId); + + long deleteByUserIdAndPostId(Long userId, Long postId); + + /** + * 최근 저장한 순으로 읽는다. 저장 시각 기준 정렬이라 커서로 삼을 단조 증가 값이 없어 + * 오프셋 페이징을 쓴다. 이 목록은 본인만 바꾸므로 조회 중 목록이 밀릴 위험이 낮다. + * + *

삭제된 게시물은 응답에서 제외해야 하므로 게시물을 함께 조회한다. + */ + @EntityGraph(attributePaths = {"post", "post.user"}) + List findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc( + Long userId, Pageable pageable); +} diff --git a/src/main/java/com/server/bookmark/service/BookmarkService 2.java b/src/main/java/com/server/bookmark/service/BookmarkService 2.java new file mode 100644 index 0000000..35af753 --- /dev/null +++ b/src/main/java/com/server/bookmark/service/BookmarkService 2.java @@ -0,0 +1,84 @@ +package com.server.bookmark.service; + +import com.server.bookmark.domain.Bookmark; +import com.server.bookmark.dto.BookmarkListResponse; +import com.server.bookmark.dto.BookmarkResponse; +import com.server.bookmark.repository.BookmarkRepository; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Post; +import com.server.post.repository.PostRepository; +import com.server.post.service.PostSummaryAssembler; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BookmarkService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final BookmarkRepository bookmarkRepository; + private final PostRepository postRepository; + private final UserRepository userRepository; + private final PostSummaryAssembler postSummaryAssembler; + + public BookmarkService( + BookmarkRepository bookmarkRepository, + PostRepository postRepository, + UserRepository userRepository, + PostSummaryAssembler postSummaryAssembler + ) { + this.bookmarkRepository = bookmarkRepository; + this.postRepository = postRepository; + this.userRepository = userRepository; + this.postSummaryAssembler = postSummaryAssembler; + } + + /** 이미 저장한 게시물을 다시 저장해도 중복 생성되지 않는다. */ + @Transactional + public BookmarkResponse bookmark(Long postId, Long userId) { + Post post = postRepository.findByIdAndDeletedAtIsNull(postId) + .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!bookmarkRepository.existsByUserIdAndPostId(userId, postId)) { + bookmarkRepository.save(new Bookmark(user, post)); + } + return new BookmarkResponse(true); + } + + @Transactional + public BookmarkResponse removeBookmark(Long postId, Long userId) { + if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { + throw new BusinessException(ErrorCode.POST_NOT_FOUND); + } + bookmarkRepository.deleteByUserIdAndPostId(userId, postId); + return new BookmarkResponse(false); + } + + /** 저장한 뒤 삭제된 게시물은 목록에서 제외한다. */ + @Transactional(readOnly = true) + public BookmarkListResponse getMyBookmarks(Long userId, Integer page, Integer size) { + if (!userRepository.existsById(userId)) { + throw new BusinessException(ErrorCode.USER_NOT_FOUND); + } + List posts = bookmarkRepository + .findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc(userId, pageRequest(page, size)) + .stream() + .map(Bookmark::getPost) + .toList(); + return new BookmarkListResponse(postSummaryAssembler.assemble(posts)); + } + + private PageRequest pageRequest(Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + return PageRequest.of(resolvedPage, resolvedSize); + } +} diff --git a/src/main/java/com/server/follow/controller/FollowController 2.java b/src/main/java/com/server/follow/controller/FollowController 2.java new file mode 100644 index 0000000..dc47e19 --- /dev/null +++ b/src/main/java/com/server/follow/controller/FollowController 2.java @@ -0,0 +1,83 @@ +package com.server.follow.controller; + +import com.server.follow.dto.FollowResponse; +import com.server.follow.dto.FollowUserListResponse; +import com.server.follow.service.FollowService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/users/{userId}") +@Tag(name = "커뮤니티 팔로우", description = "사용자 간 팔로우 관계") +public class FollowController { + + private final FollowService followService; + + public FollowController(FollowService followService) { + this.followService = followService; + } + + @PostMapping("/follows") + @Operation( + summary = "팔로우", + description = "이미 팔로우한 상태에서 다시 요청해도 팔로워 수가 늘지 않는다. 자기 자신은 팔로우할 수 없다." + ) + public FollowResponse follow( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "팔로우할 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return followService.follow(userId, requesterId); + } + + @DeleteMapping("/follows") + @Operation( + summary = "팔로우 취소", + description = "팔로우하지 않은 상태에서 요청해도 팔로워 수가 줄지 않는다." + ) + public FollowResponse unfollow( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "팔로우를 취소할 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return followService.unfollow(userId, requesterId); + } + + @GetMapping("/followers") + @Operation(summary = "팔로워 목록", description = "이 사용자를 팔로우하는 사람들을 최근 순으로 반환한다.") + public FollowUserListResponse getFollowers( + @Parameter(example = "1") @PathVariable Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return followService.getFollowers(userId, page, size); + } + + @GetMapping("/followings") + @Operation(summary = "팔로잉 목록", description = "이 사용자가 팔로우하는 사람들을 최근 순으로 반환한다.") + public FollowUserListResponse getFollowings( + @Parameter(example = "1") @PathVariable Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return followService.getFollowings(userId, page, size); + } +} diff --git a/src/main/java/com/server/follow/domain/Follow 2.java b/src/main/java/com/server/follow/domain/Follow 2.java new file mode 100644 index 0000000..4733e67 --- /dev/null +++ b/src/main/java/com/server/follow/domain/Follow 2.java @@ -0,0 +1,57 @@ +package com.server.follow.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +/** + * 팔로우 관계. {@code follower}가 {@code following}을 팔로우한다. 방향이 있으므로 + * 서로 팔로우하려면 두 행이 필요하다. + */ +@Entity +@Table(name = "follows") +@IdClass(FollowId.class) +public class Follow { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "follower_id", nullable = false) + private User follower; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "following_id", nullable = false) + private User following; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Follow() { + } + + public Follow(User follower, User following) { + this.follower = follower; + this.following = following; + this.createdAt = LocalDateTime.now(); + } + + public User getFollower() { + return follower; + } + + public User getFollowing() { + return following; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/follow/domain/FollowId 2.java b/src/main/java/com/server/follow/domain/FollowId 2.java new file mode 100644 index 0000000..f4fd642 --- /dev/null +++ b/src/main/java/com/server/follow/domain/FollowId 2.java @@ -0,0 +1,37 @@ +package com.server.follow.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** + * {@link Follow}의 복합 기본키. 같은 사람을 두 번 팔로우할 수 없다는 제약을 DB가 보장한다. + */ +public class FollowId implements Serializable { + + private Long follower; + private Long following; + + protected FollowId() { + } + + public FollowId(Long follower, Long following) { + this.follower = follower; + this.following = following; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof FollowId that)) { + return false; + } + return Objects.equals(follower, that.follower) && Objects.equals(following, that.following); + } + + @Override + public int hashCode() { + return Objects.hash(follower, following); + } +} diff --git a/src/main/java/com/server/follow/dto/FollowResponse 2.java b/src/main/java/com/server/follow/dto/FollowResponse 2.java new file mode 100644 index 0000000..3fa42b2 --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowResponse 2.java @@ -0,0 +1,11 @@ +package com.server.follow.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "팔로우 처리 결과") +public record FollowResponse( + @Schema(description = "처리 후 대상 사용자의 팔로워 수", example = "152") long followerCount, + @Schema(description = "요청한 사용자가 대상을 팔로우한 상태인지", example = "true") + boolean following +) { +} diff --git a/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java b/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java new file mode 100644 index 0000000..5812ef1 --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java @@ -0,0 +1,13 @@ +package com.server.follow.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "팔로워 또는 팔로잉 목록. 최근 맺은 관계부터 반환한다.") +public record FollowUserListResponse( + List items, + @Schema(description = "전체 인원 수. 화면의 \"팔로워 152명\" 표시에 쓴다.", example = "152") + long totalCount +) { +} diff --git a/src/main/java/com/server/follow/dto/FollowUserResponse 2.java b/src/main/java/com/server/follow/dto/FollowUserResponse 2.java new file mode 100644 index 0000000..6e68b1e --- /dev/null +++ b/src/main/java/com/server/follow/dto/FollowUserResponse 2.java @@ -0,0 +1,16 @@ +package com.server.follow.dto; + +import com.server.user.domain.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "팔로워·팔로잉 목록에 표시할 사용자") +public record FollowUserResponse( + @Schema(example = "1") Long id, + @Schema(example = "감자") String nickname, + @Schema(example = "https://example.com/profile/1.jpg") String profileImageUrl +) { + + public static FollowUserResponse from(User user) { + return new FollowUserResponse(user.getId(), user.getNickname(), user.getProfileImageUrl()); + } +} diff --git a/src/main/java/com/server/post/controller/PostController 2.java b/src/main/java/com/server/post/controller/PostController 2.java new file mode 100644 index 0000000..0b75ee8 --- /dev/null +++ b/src/main/java/com/server/post/controller/PostController 2.java @@ -0,0 +1,135 @@ +package com.server.post.controller; + +import com.server.post.dto.PostCreateRequest; +import com.server.post.dto.PostDetailResponse; +import com.server.post.dto.PostLikeResponse; +import com.server.post.dto.PostSummaryListResponse; +import com.server.post.dto.PostUpdateRequest; +import com.server.post.service.PostService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.http.HttpStatus; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/posts") +@Tag(name = "커뮤니티 게시물", description = "여행 후기 게시물 작성과 조회") +public class PostController { + + private final PostService postService; + + public PostController(PostService postService) { + this.postService = postService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "게시물 작성", + description = "미디어는 이미 업로드된 URL 을 전달한다. 장소 태그는 내부 places 에 등록된 장소만 지정할 수 있다." + ) + public PostDetailResponse create( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "작성자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody PostCreateRequest request + ) { + return postService.create(userId, request); + } + + @GetMapping + @Operation( + summary = "피드 조회", + description = "최신순으로 한 페이지를 반환한다. 응답의 nextCursor 를 다음 요청의 cursor 로 그대로 넘긴다. " + + "nextCursor 가 null 이면 더 가져올 게시물이 없다." + ) + public PostSummaryListResponse getFeed( + @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") + @RequestParam(required = false) Long cursor, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return postService.getFeed(cursor, size); + } + + @GetMapping("/{postId}") + @Operation(summary = "게시물 상세", description = "첨부 미디어와 장소 태그를 모두 포함한다.") + public PostDetailResponse get( + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.get(postId); + } + + @PatchMapping("/{postId}") + @Operation( + summary = "게시물 수정", + description = "본문만 수정한다. 작성자 본인이 아니면 403 을 반환한다." + ) + public PostDetailResponse update( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId, + @Valid @RequestBody PostUpdateRequest request + ) { + return postService.update(postId, userId, request); + } + + @DeleteMapping("/{postId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "게시물 삭제", + description = "바로 지우지 않고 삭제 표시만 남긴다. 작성자 본인이 아니면 403 을 반환한다." + ) + public void delete( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + postService.delete(postId, userId); + } + + @PostMapping("/{postId}/likes") + @Operation( + summary = "좋아요", + description = "이미 누른 상태에서 다시 요청해도 개수가 늘지 않는다." + ) + public PostLikeResponse like( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.like(postId, userId); + } + + @DeleteMapping("/{postId}/likes") + @Operation( + summary = "좋아요 취소", + description = "누른 적 없는 상태에서 요청해도 개수가 줄지 않는다." + ) + public PostLikeResponse unlike( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "7") @PathVariable Long postId + ) { + return postService.unlike(postId, userId); + } +} diff --git a/src/main/java/com/server/post/domain/PostPlaceTag 2.java b/src/main/java/com/server/post/domain/PostPlaceTag 2.java new file mode 100644 index 0000000..1b61109 --- /dev/null +++ b/src/main/java/com/server/post/domain/PostPlaceTag 2.java @@ -0,0 +1,70 @@ +package com.server.post.domain; + +import com.server.place.domain.Place; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; + +@Entity +@Table(name = "post_place_tags") +public class PostPlaceTag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "place_id", nullable = false) + private Place place; + + /** + * 사진이 실제로 촬영된 지점. 장소 대표 좌표는 {@link Place}에 있으므로 복사하지 않고, + * EXIF GPS가 없어 촬영 지점을 알 수 없으면 {@code null}로 둔다. + */ + @Column(precision = 12, scale = 8) + private BigDecimal latitude; + + @Column(precision = 12, scale = 8) + private BigDecimal longitude; + + protected PostPlaceTag() { + } + + public PostPlaceTag(Post post, Place place, BigDecimal latitude, BigDecimal longitude) { + this.post = post; + this.place = place; + this.latitude = latitude; + this.longitude = longitude; + } + + public Long getId() { + return id; + } + + public Post getPost() { + return post; + } + + public Place getPlace() { + return place; + } + + public BigDecimal getLatitude() { + return latitude; + } + + public BigDecimal getLongitude() { + return longitude; + } +} diff --git a/src/main/java/com/server/post/domain/ReportStatus 2.java b/src/main/java/com/server/post/domain/ReportStatus 2.java new file mode 100644 index 0000000..dc5db02 --- /dev/null +++ b/src/main/java/com/server/post/domain/ReportStatus 2.java @@ -0,0 +1,6 @@ +package com.server.post.domain; + +public enum ReportStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/server/post/dto/PostPlaceTagView 2.java b/src/main/java/com/server/post/dto/PostPlaceTagView 2.java new file mode 100644 index 0000000..75b27da --- /dev/null +++ b/src/main/java/com/server/post/dto/PostPlaceTagView 2.java @@ -0,0 +1,29 @@ +package com.server.post.dto; + +import com.server.post.domain.PostPlaceTag; +import java.math.BigDecimal; + +/** + * 응답에 필요한 장소 태그 값만 담는 조회 전용 모델. + * + *

{@code Place} 엔티티를 그대로 읽으면 {@code mappedBy} 로 연결된 상세·운영정보가 + * 장소마다 추가 질의를 발생시킨다. 커뮤니티는 장소명과 ID만 쓰므로 필요한 열만 가져온다. + */ +public record PostPlaceTagView( + Long postId, + Long placeId, + String placeName, + BigDecimal latitude, + BigDecimal longitude +) { + + /** 방금 저장해 이미 메모리에 있는 엔티티를 변환할 때 쓴다. */ + public static PostPlaceTagView from(PostPlaceTag tag) { + return new PostPlaceTagView( + tag.getPost().getId(), + tag.getPlace().getId(), + tag.getPlace().getName(), + tag.getLatitude(), + tag.getLongitude()); + } +} diff --git a/src/main/java/com/server/user/service/UserService 2.java b/src/main/java/com/server/user/service/UserService 2.java new file mode 100644 index 0000000..ccf46f9 --- /dev/null +++ b/src/main/java/com/server/user/service/UserService 2.java @@ -0,0 +1,51 @@ +package com.server.user.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.follow.repository.FollowRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.dto.UserProfileResponse; +import com.server.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class UserService { + + private final UserRepository userRepository; + private final PostRepository postRepository; + private final FollowRepository followRepository; + + public UserService( + UserRepository userRepository, + PostRepository postRepository, + FollowRepository followRepository + ) { + this.userRepository = userRepository; + this.postRepository = postRepository; + this.followRepository = followRepository; + } + + /** + * @param requesterId 조회하는 사용자. 인증이 없어 전달되지 않을 수 있으며, + * 없으면 팔로우 여부를 판단하지 않는다. + */ + @Transactional(readOnly = true) + public UserProfileResponse getProfile(Long userId, Long requesterId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + boolean following = requesterId != null + && !requesterId.equals(userId) + && followRepository.existsByFollowerIdAndFollowingId(requesterId, userId); + + return UserProfileResponse.of( + user, + postRepository.countByUserIdAndDeletedAtIsNull(userId), + followRepository.countByFollowingId(userId), + followRepository.countByFollowerId(userId), + following, + userId.equals(requesterId)); + } +} From 1e27a77c37db5f73c0e09ace0cbd8ac43c7bc0a8 Mon Sep 17 00:00:00 2001 From: sususuj Date: Mon, 24 Aug 2026 00:01:58 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=C2=B7?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=97=AC=EB=B6=80=20=ED=91=9C=EC=8B=9C,?= =?UTF-8?q?=20=EB=8C=93=EA=B8=80=20=EC=82=AD=EC=A0=9C,=20=EB=8B=89?= =?UTF-8?q?=EB=84=A4=EC=9E=84=20=EB=B3=80=EA=B2=BD,=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EA=B2=80=EC=83=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BookmarkController 2.java | 76 ---------- .../server/bookmark/domain/Bookmark 2.java | 54 ------- .../server/bookmark/domain/BookmarkId 2.java | 35 ----- .../bookmark/dto/BookmarkListResponse 2.java | 13 -- .../bookmark/dto/BookmarkResponse 2.java | 10 -- .../repository/BookmarkRepository 2.java | 25 ---- .../repository/BookmarkRepository.java | 11 ++ .../bookmark/service/BookmarkService 2.java | 84 ----------- .../bookmark/service/BookmarkService.java | 2 +- .../com/server/common/error/ErrorCode.java | 3 + .../follow/controller/FollowController 2.java | 83 ----------- .../com/server/follow/domain/Follow 2.java | 57 -------- .../com/server/follow/domain/FollowId 2.java | 37 ----- .../server/follow/dto/FollowResponse 2.java | 11 -- .../follow/dto/FollowUserListResponse 2.java | 13 -- .../follow/dto/FollowUserResponse 2.java | 16 --- .../post/controller/CommentController.java | 18 +++ .../post/controller/PostController 2.java | 135 ------------------ .../post/controller/PostController.java | 28 +++- .../java/com/server/post/domain/Comment.java | 10 ++ .../server/post/domain/PostPlaceTag 2.java | 70 --------- .../server/post/domain/ReportStatus 2.java | 6 - .../com/server/post/dto/CommentResponse.java | 13 +- .../server/post/dto/PostCreateRequest.java | 5 +- .../server/post/dto/PostDetailResponse.java | 11 +- .../server/post/dto/PostPlaceTagView 2.java | 29 ---- .../server/post/dto/PostSummaryResponse.java | 11 +- .../post/repository/CommentRepository.java | 19 ++- .../post/repository/PostLikeRepository.java | 11 ++ .../post/repository/PostRepository.java | 26 +++- .../server/post/service/CommentService.java | 28 +++- .../com/server/post/service/PostService.java | 53 +++++-- .../post/service/PostSummaryAssembler.java | 41 +++++- .../user/controller/MyProfileController.java | 45 ++++++ .../user/controller/UserController.java | 7 +- .../user/controller/UserSearchController.java | 46 ++++++ .../java/com/server/user/domain/User.java | 3 +- .../user/dto/NicknameUpdateRequest.java | 17 +++ .../user/dto/UserSearchListResponse.java | 12 ++ .../user/repository/UserRepository.java | 6 + .../server/user/service/UserService 2.java | 51 ------- .../com/server/user/service/UserService.java | 44 ++++++ 42 files changed, 430 insertions(+), 845 deletions(-) delete mode 100644 src/main/java/com/server/bookmark/controller/BookmarkController 2.java delete mode 100644 src/main/java/com/server/bookmark/domain/Bookmark 2.java delete mode 100644 src/main/java/com/server/bookmark/domain/BookmarkId 2.java delete mode 100644 src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java delete mode 100644 src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java delete mode 100644 src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java delete mode 100644 src/main/java/com/server/bookmark/service/BookmarkService 2.java delete mode 100644 src/main/java/com/server/follow/controller/FollowController 2.java delete mode 100644 src/main/java/com/server/follow/domain/Follow 2.java delete mode 100644 src/main/java/com/server/follow/domain/FollowId 2.java delete mode 100644 src/main/java/com/server/follow/dto/FollowResponse 2.java delete mode 100644 src/main/java/com/server/follow/dto/FollowUserListResponse 2.java delete mode 100644 src/main/java/com/server/follow/dto/FollowUserResponse 2.java delete mode 100644 src/main/java/com/server/post/controller/PostController 2.java delete mode 100644 src/main/java/com/server/post/domain/PostPlaceTag 2.java delete mode 100644 src/main/java/com/server/post/domain/ReportStatus 2.java delete mode 100644 src/main/java/com/server/post/dto/PostPlaceTagView 2.java create mode 100644 src/main/java/com/server/user/controller/MyProfileController.java create mode 100644 src/main/java/com/server/user/controller/UserSearchController.java create mode 100644 src/main/java/com/server/user/dto/NicknameUpdateRequest.java create mode 100644 src/main/java/com/server/user/dto/UserSearchListResponse.java delete mode 100644 src/main/java/com/server/user/service/UserService 2.java diff --git a/src/main/java/com/server/bookmark/controller/BookmarkController 2.java b/src/main/java/com/server/bookmark/controller/BookmarkController 2.java deleted file mode 100644 index 4186569..0000000 --- a/src/main/java/com/server/bookmark/controller/BookmarkController 2.java +++ /dev/null @@ -1,76 +0,0 @@ -package com.server.bookmark.controller; - -import com.server.bookmark.dto.BookmarkListResponse; -import com.server.bookmark.dto.BookmarkResponse; -import com.server.bookmark.service.BookmarkService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Min; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/v1") -@Tag(name = "커뮤니티 북마크", description = "게시물 저장과 내 저장 목록") -public class BookmarkController { - - private final BookmarkService bookmarkService; - - public BookmarkController(BookmarkService bookmarkService) { - this.bookmarkService = bookmarkService; - } - - @PostMapping("/posts/{postId}/bookmarks") - @Operation( - summary = "게시물 저장", - description = "이미 저장한 상태에서 다시 요청해도 중복 저장되지 않는다." - ) - public BookmarkResponse bookmark( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId - ) { - return bookmarkService.bookmark(postId, userId); - } - - @DeleteMapping("/posts/{postId}/bookmarks") - @Operation( - summary = "게시물 저장 해제", - description = "저장하지 않은 상태에서 요청해도 오류가 아니다." - ) - public BookmarkResponse removeBookmark( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId - ) { - return bookmarkService.removeBookmark(postId, userId); - } - - @GetMapping("/users/me/bookmarks") - @Operation( - summary = "내 북마크 목록", - description = "최근 저장한 순으로 반환한다. 저장한 뒤 삭제된 게시물은 제외한다." - ) - public BookmarkListResponse getMyBookmarks( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") - @RequestParam(defaultValue = "0") Integer page, - @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size - ) { - return bookmarkService.getMyBookmarks(userId, page, size); - } -} diff --git a/src/main/java/com/server/bookmark/domain/Bookmark 2.java b/src/main/java/com/server/bookmark/domain/Bookmark 2.java deleted file mode 100644 index 5bd1c62..0000000 --- a/src/main/java/com/server/bookmark/domain/Bookmark 2.java +++ /dev/null @@ -1,54 +0,0 @@ -package com.server.bookmark.domain; - -import com.server.post.domain.Post; -import com.server.user.domain.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.IdClass; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; - -import java.time.LocalDateTime; - -@Entity -@Table(name = "bookmarks") -@IdClass(BookmarkId.class) -public class Bookmark { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id", nullable = false) - private Post post; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - protected Bookmark() { - } - - public Bookmark(User user, Post post) { - this.user = user; - this.post = post; - this.createdAt = LocalDateTime.now(); - } - - public User getUser() { - return user; - } - - public Post getPost() { - return post; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } -} diff --git a/src/main/java/com/server/bookmark/domain/BookmarkId 2.java b/src/main/java/com/server/bookmark/domain/BookmarkId 2.java deleted file mode 100644 index d3f3335..0000000 --- a/src/main/java/com/server/bookmark/domain/BookmarkId 2.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.server.bookmark.domain; - -import java.io.Serializable; -import java.util.Objects; - -/** {@link Bookmark}의 복합 기본키. 같은 게시물을 두 번 저장할 수 없다는 제약을 DB가 보장한다. */ -public class BookmarkId implements Serializable { - - private Long user; - private Long post; - - protected BookmarkId() { - } - - public BookmarkId(Long user, Long post) { - this.user = user; - this.post = post; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof BookmarkId that)) { - return false; - } - return Objects.equals(user, that.user) && Objects.equals(post, that.post); - } - - @Override - public int hashCode() { - return Objects.hash(user, post); - } -} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java b/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java deleted file mode 100644 index e4140a4..0000000 --- a/src/main/java/com/server/bookmark/dto/BookmarkListResponse 2.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.server.bookmark.dto; - -import com.server.post.dto.PostSummaryResponse; -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -@Schema(description = "내 북마크 목록. 최근 저장한 순으로 반환한다.") -public record BookmarkListResponse( - @Schema(description = "저장한 게시물. 피드와 같은 축약 형태다.") - List items -) { -} diff --git a/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java b/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java deleted file mode 100644 index 3ad49bf..0000000 --- a/src/main/java/com/server/bookmark/dto/BookmarkResponse 2.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.server.bookmark.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "북마크 처리 결과. 몇 명이 저장했는지는 공개하지 않는다.") -public record BookmarkResponse( - @Schema(description = "요청한 사용자가 이 게시물을 저장한 상태인지", example = "true") - boolean bookmarked -) { -} diff --git a/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java b/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java deleted file mode 100644 index c64dd43..0000000 --- a/src/main/java/com/server/bookmark/repository/BookmarkRepository 2.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.server.bookmark.repository; - -import com.server.bookmark.domain.Bookmark; -import com.server.bookmark.domain.BookmarkId; -import java.util.List; -import org.springframework.data.domain.Pageable; -import org.springframework.data.jpa.repository.EntityGraph; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface BookmarkRepository extends JpaRepository { - - boolean existsByUserIdAndPostId(Long userId, Long postId); - - long deleteByUserIdAndPostId(Long userId, Long postId); - - /** - * 최근 저장한 순으로 읽는다. 저장 시각 기준 정렬이라 커서로 삼을 단조 증가 값이 없어 - * 오프셋 페이징을 쓴다. 이 목록은 본인만 바꾸므로 조회 중 목록이 밀릴 위험이 낮다. - * - *

삭제된 게시물은 응답에서 제외해야 하므로 게시물을 함께 조회한다. - */ - @EntityGraph(attributePaths = {"post", "post.user"}) - List findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc( - Long userId, Pageable pageable); -} diff --git a/src/main/java/com/server/bookmark/repository/BookmarkRepository.java b/src/main/java/com/server/bookmark/repository/BookmarkRepository.java index c64dd43..aa8f90b 100644 --- a/src/main/java/com/server/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/com/server/bookmark/repository/BookmarkRepository.java @@ -2,15 +2,26 @@ import com.server.bookmark.domain.Bookmark; import com.server.bookmark.domain.BookmarkId; +import java.util.Collection; import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface BookmarkRepository extends JpaRepository { boolean existsByUserIdAndPostId(Long userId, Long postId); + /** 목록에 저장 여부를 표시할 때, 게시물 수와 무관하게 한 번만 조회한다. */ + @Query(""" + select bookmark.post.id from Bookmark bookmark + where bookmark.user.id = :userId and bookmark.post.id in :postIds + """) + List findBookmarkedPostIds( + @Param("userId") Long userId, @Param("postIds") Collection postIds); + long deleteByUserIdAndPostId(Long userId, Long postId); /** diff --git a/src/main/java/com/server/bookmark/service/BookmarkService 2.java b/src/main/java/com/server/bookmark/service/BookmarkService 2.java deleted file mode 100644 index 35af753..0000000 --- a/src/main/java/com/server/bookmark/service/BookmarkService 2.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.server.bookmark.service; - -import com.server.bookmark.domain.Bookmark; -import com.server.bookmark.dto.BookmarkListResponse; -import com.server.bookmark.dto.BookmarkResponse; -import com.server.bookmark.repository.BookmarkRepository; -import com.server.common.error.BusinessException; -import com.server.common.error.ErrorCode; -import com.server.post.domain.Post; -import com.server.post.repository.PostRepository; -import com.server.post.service.PostSummaryAssembler; -import com.server.user.domain.User; -import com.server.user.repository.UserRepository; -import java.util.List; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class BookmarkService { - - private static final int DEFAULT_PAGE_SIZE = 20; - private static final int MAX_PAGE_SIZE = 50; - - private final BookmarkRepository bookmarkRepository; - private final PostRepository postRepository; - private final UserRepository userRepository; - private final PostSummaryAssembler postSummaryAssembler; - - public BookmarkService( - BookmarkRepository bookmarkRepository, - PostRepository postRepository, - UserRepository userRepository, - PostSummaryAssembler postSummaryAssembler - ) { - this.bookmarkRepository = bookmarkRepository; - this.postRepository = postRepository; - this.userRepository = userRepository; - this.postSummaryAssembler = postSummaryAssembler; - } - - /** 이미 저장한 게시물을 다시 저장해도 중복 생성되지 않는다. */ - @Transactional - public BookmarkResponse bookmark(Long postId, Long userId) { - Post post = postRepository.findByIdAndDeletedAtIsNull(postId) - .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - if (!bookmarkRepository.existsByUserIdAndPostId(userId, postId)) { - bookmarkRepository.save(new Bookmark(user, post)); - } - return new BookmarkResponse(true); - } - - @Transactional - public BookmarkResponse removeBookmark(Long postId, Long userId) { - if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { - throw new BusinessException(ErrorCode.POST_NOT_FOUND); - } - bookmarkRepository.deleteByUserIdAndPostId(userId, postId); - return new BookmarkResponse(false); - } - - /** 저장한 뒤 삭제된 게시물은 목록에서 제외한다. */ - @Transactional(readOnly = true) - public BookmarkListResponse getMyBookmarks(Long userId, Integer page, Integer size) { - if (!userRepository.existsById(userId)) { - throw new BusinessException(ErrorCode.USER_NOT_FOUND); - } - List posts = bookmarkRepository - .findByUserIdAndPostDeletedAtIsNullOrderByCreatedAtDesc(userId, pageRequest(page, size)) - .stream() - .map(Bookmark::getPost) - .toList(); - return new BookmarkListResponse(postSummaryAssembler.assemble(posts)); - } - - private PageRequest pageRequest(Integer page, Integer size) { - int resolvedPage = page == null || page < 0 ? 0 : page; - int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); - return PageRequest.of(resolvedPage, resolvedSize); - } -} diff --git a/src/main/java/com/server/bookmark/service/BookmarkService.java b/src/main/java/com/server/bookmark/service/BookmarkService.java index 35af753..435a723 100644 --- a/src/main/java/com/server/bookmark/service/BookmarkService.java +++ b/src/main/java/com/server/bookmark/service/BookmarkService.java @@ -73,7 +73,7 @@ public BookmarkListResponse getMyBookmarks(Long userId, Integer page, Integer si .stream() .map(Bookmark::getPost) .toList(); - return new BookmarkListResponse(postSummaryAssembler.assemble(posts)); + return new BookmarkListResponse(postSummaryAssembler.assemble(posts, userId)); } private PageRequest pageRequest(Integer page, Integer size) { diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index a9e5118..e51518b 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -29,7 +29,10 @@ public enum ErrorCode { USER_NOT_FOUND(404, "사용자를 찾을 수 없습니다."), INVALID_COMMENT_REQUEST(400, "댓글 요청이 올바르지 않습니다."), INVALID_FOLLOW_REQUEST(400, "팔로우 요청이 올바르지 않습니다."), + INVALID_FEED_REQUEST(400, "피드 조회 조건이 올바르지 않습니다."), POST_ACCESS_DENIED(403, "본인의 게시물만 수정하거나 삭제할 수 있습니다."), + COMMENT_ACCESS_DENIED(403, "본인의 댓글만 삭제할 수 있습니다."), + NICKNAME_ALREADY_USED(409, "이미 사용 중인 닉네임입니다."), TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), diff --git a/src/main/java/com/server/follow/controller/FollowController 2.java b/src/main/java/com/server/follow/controller/FollowController 2.java deleted file mode 100644 index dc47e19..0000000 --- a/src/main/java/com/server/follow/controller/FollowController 2.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.server.follow.controller; - -import com.server.follow.dto.FollowResponse; -import com.server.follow.dto.FollowUserListResponse; -import com.server.follow.service.FollowService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.constraints.Min; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/v1/users/{userId}") -@Tag(name = "커뮤니티 팔로우", description = "사용자 간 팔로우 관계") -public class FollowController { - - private final FollowService followService; - - public FollowController(FollowService followService) { - this.followService = followService; - } - - @PostMapping("/follows") - @Operation( - summary = "팔로우", - description = "이미 팔로우한 상태에서 다시 요청해도 팔로워 수가 늘지 않는다. 자기 자신은 팔로우할 수 없다." - ) - public FollowResponse follow( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long requesterId, - @Parameter(description = "팔로우할 대상 사용자 ID", example = "2") @PathVariable Long userId - ) { - return followService.follow(userId, requesterId); - } - - @DeleteMapping("/follows") - @Operation( - summary = "팔로우 취소", - description = "팔로우하지 않은 상태에서 요청해도 팔로워 수가 줄지 않는다." - ) - public FollowResponse unfollow( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long requesterId, - @Parameter(description = "팔로우를 취소할 대상 사용자 ID", example = "2") @PathVariable Long userId - ) { - return followService.unfollow(userId, requesterId); - } - - @GetMapping("/followers") - @Operation(summary = "팔로워 목록", description = "이 사용자를 팔로우하는 사람들을 최근 순으로 반환한다.") - public FollowUserListResponse getFollowers( - @Parameter(example = "1") @PathVariable Long userId, - @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") - @RequestParam(defaultValue = "0") Integer page, - @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size - ) { - return followService.getFollowers(userId, page, size); - } - - @GetMapping("/followings") - @Operation(summary = "팔로잉 목록", description = "이 사용자가 팔로우하는 사람들을 최근 순으로 반환한다.") - public FollowUserListResponse getFollowings( - @Parameter(example = "1") @PathVariable Long userId, - @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") - @RequestParam(defaultValue = "0") Integer page, - @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size - ) { - return followService.getFollowings(userId, page, size); - } -} diff --git a/src/main/java/com/server/follow/domain/Follow 2.java b/src/main/java/com/server/follow/domain/Follow 2.java deleted file mode 100644 index 4733e67..0000000 --- a/src/main/java/com/server/follow/domain/Follow 2.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.server.follow.domain; - -import com.server.user.domain.User; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.Id; -import jakarta.persistence.IdClass; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; - -import java.time.LocalDateTime; - -/** - * 팔로우 관계. {@code follower}가 {@code following}을 팔로우한다. 방향이 있으므로 - * 서로 팔로우하려면 두 행이 필요하다. - */ -@Entity -@Table(name = "follows") -@IdClass(FollowId.class) -public class Follow { - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "follower_id", nullable = false) - private User follower; - - @Id - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "following_id", nullable = false) - private User following; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - protected Follow() { - } - - public Follow(User follower, User following) { - this.follower = follower; - this.following = following; - this.createdAt = LocalDateTime.now(); - } - - public User getFollower() { - return follower; - } - - public User getFollowing() { - return following; - } - - public LocalDateTime getCreatedAt() { - return createdAt; - } -} diff --git a/src/main/java/com/server/follow/domain/FollowId 2.java b/src/main/java/com/server/follow/domain/FollowId 2.java deleted file mode 100644 index f4fd642..0000000 --- a/src/main/java/com/server/follow/domain/FollowId 2.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.server.follow.domain; - -import java.io.Serializable; -import java.util.Objects; - -/** - * {@link Follow}의 복합 기본키. 같은 사람을 두 번 팔로우할 수 없다는 제약을 DB가 보장한다. - */ -public class FollowId implements Serializable { - - private Long follower; - private Long following; - - protected FollowId() { - } - - public FollowId(Long follower, Long following) { - this.follower = follower; - this.following = following; - } - - @Override - public boolean equals(Object other) { - if (this == other) { - return true; - } - if (!(other instanceof FollowId that)) { - return false; - } - return Objects.equals(follower, that.follower) && Objects.equals(following, that.following); - } - - @Override - public int hashCode() { - return Objects.hash(follower, following); - } -} diff --git a/src/main/java/com/server/follow/dto/FollowResponse 2.java b/src/main/java/com/server/follow/dto/FollowResponse 2.java deleted file mode 100644 index 3fa42b2..0000000 --- a/src/main/java/com/server/follow/dto/FollowResponse 2.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.server.follow.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "팔로우 처리 결과") -public record FollowResponse( - @Schema(description = "처리 후 대상 사용자의 팔로워 수", example = "152") long followerCount, - @Schema(description = "요청한 사용자가 대상을 팔로우한 상태인지", example = "true") - boolean following -) { -} diff --git a/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java b/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java deleted file mode 100644 index 5812ef1..0000000 --- a/src/main/java/com/server/follow/dto/FollowUserListResponse 2.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.server.follow.dto; - -import io.swagger.v3.oas.annotations.media.Schema; - -import java.util.List; - -@Schema(description = "팔로워 또는 팔로잉 목록. 최근 맺은 관계부터 반환한다.") -public record FollowUserListResponse( - List items, - @Schema(description = "전체 인원 수. 화면의 \"팔로워 152명\" 표시에 쓴다.", example = "152") - long totalCount -) { -} diff --git a/src/main/java/com/server/follow/dto/FollowUserResponse 2.java b/src/main/java/com/server/follow/dto/FollowUserResponse 2.java deleted file mode 100644 index 6e68b1e..0000000 --- a/src/main/java/com/server/follow/dto/FollowUserResponse 2.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.server.follow.dto; - -import com.server.user.domain.User; -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(description = "팔로워·팔로잉 목록에 표시할 사용자") -public record FollowUserResponse( - @Schema(example = "1") Long id, - @Schema(example = "감자") String nickname, - @Schema(example = "https://example.com/profile/1.jpg") String profileImageUrl -) { - - public static FollowUserResponse from(User user) { - return new FollowUserResponse(user.getId(), user.getNickname(), user.getProfileImageUrl()); - } -} diff --git a/src/main/java/com/server/post/controller/CommentController.java b/src/main/java/com/server/post/controller/CommentController.java index 477b0fc..e3910a7 100644 --- a/src/main/java/com/server/post/controller/CommentController.java +++ b/src/main/java/com/server/post/controller/CommentController.java @@ -11,6 +11,7 @@ import jakarta.validation.constraints.Min; import org.springframework.http.HttpStatus; import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -49,6 +50,23 @@ public CommentResponse create( return commentService.create(postId, userId, request); } + @DeleteMapping("/{commentId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Operation( + summary = "댓글 삭제", + description = "바로 지우지 않고 삭제 표시만 남긴다. 답글이 달려 있으면 답글은 그대로 보이고 " + + "이 댓글은 작성자와 내용이 감춰진 자리로 남는다. 작성자 본인이 아니면 403 을 반환한다." + ) + public void delete( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "1") @PathVariable Long postId, + @Parameter(example = "3") @PathVariable Long commentId + ) { + commentService.delete(postId, commentId, userId); + } + @GetMapping @Operation( summary = "댓글 목록", diff --git a/src/main/java/com/server/post/controller/PostController 2.java b/src/main/java/com/server/post/controller/PostController 2.java deleted file mode 100644 index 0b75ee8..0000000 --- a/src/main/java/com/server/post/controller/PostController 2.java +++ /dev/null @@ -1,135 +0,0 @@ -package com.server.post.controller; - -import com.server.post.dto.PostCreateRequest; -import com.server.post.dto.PostDetailResponse; -import com.server.post.dto.PostLikeResponse; -import com.server.post.dto.PostSummaryListResponse; -import com.server.post.dto.PostUpdateRequest; -import com.server.post.service.PostService; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; -import org.springframework.http.HttpStatus; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PatchMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestHeader; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -@Validated -@RestController -@RequestMapping("/api/v1/posts") -@Tag(name = "커뮤니티 게시물", description = "여행 후기 게시물 작성과 조회") -public class PostController { - - private final PostService postService; - - public PostController(PostService postService) { - this.postService = postService; - } - - @PostMapping - @ResponseStatus(HttpStatus.CREATED) - @Operation( - summary = "게시물 작성", - description = "미디어는 이미 업로드된 URL 을 전달한다. 장소 태그는 내부 places 에 등록된 장소만 지정할 수 있다." - ) - public PostDetailResponse create( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "작성자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Valid @RequestBody PostCreateRequest request - ) { - return postService.create(userId, request); - } - - @GetMapping - @Operation( - summary = "피드 조회", - description = "최신순으로 한 페이지를 반환한다. 응답의 nextCursor 를 다음 요청의 cursor 로 그대로 넘긴다. " - + "nextCursor 가 null 이면 더 가져올 게시물이 없다." - ) - public PostSummaryListResponse getFeed( - @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") - @RequestParam(required = false) Long cursor, - @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size - ) { - return postService.getFeed(cursor, size); - } - - @GetMapping("/{postId}") - @Operation(summary = "게시물 상세", description = "첨부 미디어와 장소 태그를 모두 포함한다.") - public PostDetailResponse get( - @Parameter(example = "7") @PathVariable Long postId - ) { - return postService.get(postId); - } - - @PatchMapping("/{postId}") - @Operation( - summary = "게시물 수정", - description = "본문만 수정한다. 작성자 본인이 아니면 403 을 반환한다." - ) - public PostDetailResponse update( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId, - @Valid @RequestBody PostUpdateRequest request - ) { - return postService.update(postId, userId, request); - } - - @DeleteMapping("/{postId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - @Operation( - summary = "게시물 삭제", - description = "바로 지우지 않고 삭제 표시만 남긴다. 작성자 본인이 아니면 403 을 반환한다." - ) - public void delete( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId - ) { - postService.delete(postId, userId); - } - - @PostMapping("/{postId}/likes") - @Operation( - summary = "좋아요", - description = "이미 누른 상태에서 다시 요청해도 개수가 늘지 않는다." - ) - public PostLikeResponse like( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId - ) { - return postService.like(postId, userId); - } - - @DeleteMapping("/{postId}/likes") - @Operation( - summary = "좋아요 취소", - description = "누른 적 없는 상태에서 요청해도 개수가 줄지 않는다." - ) - public PostLikeResponse unlike( - // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. - @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") - @RequestHeader("X-User-Id") Long userId, - @Parameter(example = "7") @PathVariable Long postId - ) { - return postService.unlike(postId, userId); - } -} diff --git a/src/main/java/com/server/post/controller/PostController.java b/src/main/java/com/server/post/controller/PostController.java index 0b75ee8..0f2c061 100644 --- a/src/main/java/com/server/post/controller/PostController.java +++ b/src/main/java/com/server/post/controller/PostController.java @@ -31,6 +31,9 @@ @Tag(name = "커뮤니티 게시물", description = "여행 후기 게시물 작성과 조회") public class PostController { + /** 팔로우한 사람들의 게시물만 볼 때 feed 파라미터에 넣는 값. */ + private static final String FOLLOWING_FEED = "following"; + private final PostService postService; public PostController(PostService postService) { @@ -41,7 +44,8 @@ public PostController(PostService postService) { @ResponseStatus(HttpStatus.CREATED) @Operation( summary = "게시물 작성", - description = "미디어는 이미 업로드된 URL 을 전달한다. 장소 태그는 내부 places 에 등록된 장소만 지정할 수 있다." + description = "사진 또는 영상을 최소 한 건 첨부해야 한다. 미디어는 이미 업로드된 URL 을 전달한다. " + + "장소 태그는 내부 places 에 등록된 장소만 지정할 수 있다." ) public PostDetailResponse create( // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. @@ -56,23 +60,35 @@ public PostDetailResponse create( @Operation( summary = "피드 조회", description = "최신순으로 한 페이지를 반환한다. 응답의 nextCursor 를 다음 요청의 cursor 로 그대로 넘긴다. " - + "nextCursor 가 null 이면 더 가져올 게시물이 없다." + + "nextCursor 가 null 이면 더 가져올 게시물이 없다. " + + "feed 와 placeId 는 함께 쓸 수 있다." ) public PostSummaryListResponse getFeed( @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") @RequestParam(required = false) Long cursor, @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size + @RequestParam(defaultValue = "20") @Min(1) Integer size, + @Parameter(description = "following 이면 팔로우한 사람들의 게시물만 반환한다. " + + "이때 X-User-Id 가 필요하다.", example = "following") + @RequestParam(required = false) String feed, + @Parameter(description = "이 장소를 태그한 게시물만 반환한다.", example = "1") + @RequestParam(required = false) Long placeId, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 팔로잉 피드에만 필요하다.", example = "1") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId ) { - return postService.getFeed(cursor, size); + return postService.getFeed(cursor, size, FOLLOWING_FEED.equals(feed), placeId, requesterId); } @GetMapping("/{postId}") @Operation(summary = "게시물 상세", description = "첨부 미디어와 장소 태그를 모두 포함한다.") public PostDetailResponse get( - @Parameter(example = "7") @PathVariable Long postId + @Parameter(example = "7") @PathVariable Long postId, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 없으면 좋아요·저장 여부가 false 로 나간다.", example = "1") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId ) { - return postService.get(postId); + return postService.get(postId, requesterId); } @PatchMapping("/{postId}") diff --git a/src/main/java/com/server/post/domain/Comment.java b/src/main/java/com/server/post/domain/Comment.java index 4a0d265..514c514 100644 --- a/src/main/java/com/server/post/domain/Comment.java +++ b/src/main/java/com/server/post/domain/Comment.java @@ -96,4 +96,14 @@ public LocalDateTime getUpdatedAt() { public LocalDateTime getDeletedAt() { return deletedAt; } + + public boolean isWrittenBy(Long userId) { + return user.getId().equals(userId); + } + + /** 게시물과 마찬가지로 물리 삭제하지 않는다. */ + public void delete() { + this.deletedAt = LocalDateTime.now(); + this.updatedAt = this.deletedAt; + } } diff --git a/src/main/java/com/server/post/domain/PostPlaceTag 2.java b/src/main/java/com/server/post/domain/PostPlaceTag 2.java deleted file mode 100644 index 1b61109..0000000 --- a/src/main/java/com/server/post/domain/PostPlaceTag 2.java +++ /dev/null @@ -1,70 +0,0 @@ -package com.server.post.domain; - -import com.server.place.domain.Place; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import java.math.BigDecimal; - -@Entity -@Table(name = "post_place_tags") -public class PostPlaceTag { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "post_id", nullable = false) - private Post post; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "place_id", nullable = false) - private Place place; - - /** - * 사진이 실제로 촬영된 지점. 장소 대표 좌표는 {@link Place}에 있으므로 복사하지 않고, - * EXIF GPS가 없어 촬영 지점을 알 수 없으면 {@code null}로 둔다. - */ - @Column(precision = 12, scale = 8) - private BigDecimal latitude; - - @Column(precision = 12, scale = 8) - private BigDecimal longitude; - - protected PostPlaceTag() { - } - - public PostPlaceTag(Post post, Place place, BigDecimal latitude, BigDecimal longitude) { - this.post = post; - this.place = place; - this.latitude = latitude; - this.longitude = longitude; - } - - public Long getId() { - return id; - } - - public Post getPost() { - return post; - } - - public Place getPlace() { - return place; - } - - public BigDecimal getLatitude() { - return latitude; - } - - public BigDecimal getLongitude() { - return longitude; - } -} diff --git a/src/main/java/com/server/post/domain/ReportStatus 2.java b/src/main/java/com/server/post/domain/ReportStatus 2.java deleted file mode 100644 index dc5db02..0000000 --- a/src/main/java/com/server/post/domain/ReportStatus 2.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.server.post.domain; - -public enum ReportStatus { - PENDING, - RESOLVED -} diff --git a/src/main/java/com/server/post/dto/CommentResponse.java b/src/main/java/com/server/post/dto/CommentResponse.java index 369d87e..888ec0e 100644 --- a/src/main/java/com/server/post/dto/CommentResponse.java +++ b/src/main/java/com/server/post/dto/CommentResponse.java @@ -9,15 +9,25 @@ @Schema(description = "댓글 한 건. 최상위 댓글이면 replies 에 답글이 담긴다.") public record CommentResponse( @Schema(example = "3") Long id, + @Schema(description = "삭제된 댓글이면 null 이다.") PostDetailResponse.Author author, - @Schema(example = "저도 여기 가봤는데 좋았어요") String content, + @Schema(description = "삭제된 댓글이면 null 이다. 화면 문구는 클라이언트가 정한다.", + example = "저도 여기 가봤는데 좋았어요") + String content, @Schema(example = "2") int likeCount, @Schema(example = "2026-08-18T04:02:00") LocalDateTime createdAt, + @Schema(description = "삭제된 댓글인지. 답글이 남아 있어 자리만 유지하는 경우 true 다.", + example = "false") + boolean deleted, @Schema(description = "이 댓글에 달린 답글. 답글에는 다시 답글이 없으므로 항상 비어 있다.") List replies ) { public static CommentResponse from(Comment comment, List replies) { + if (comment.getDeletedAt() != null) { + // 삭제된 댓글은 답글을 매달 자리만 남기고 작성자와 내용을 감춘다. + return new CommentResponse(comment.getId(), null, null, 0, comment.getCreatedAt(), true, replies); + } return new CommentResponse( comment.getId(), new PostDetailResponse.Author( @@ -27,6 +37,7 @@ public static CommentResponse from(Comment comment, List replie comment.getContent(), comment.getLikeCount(), comment.getCreatedAt(), + false, replies); } diff --git a/src/main/java/com/server/post/dto/PostCreateRequest.java b/src/main/java/com/server/post/dto/PostCreateRequest.java index b8341e3..8ab535f 100644 --- a/src/main/java/com/server/post/dto/PostCreateRequest.java +++ b/src/main/java/com/server/post/dto/PostCreateRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; import java.util.List; @@ -13,8 +14,8 @@ public record PostCreateRequest( @Schema(description = "본문", example = "광안리 야경 보러 갔는데 날씨가 좋았어요") @NotBlank String content, - @Schema(description = "첨부한 사진·영상. 표시 순서는 sortOrder를 따른다.") - @Valid List mediaList, + @Schema(description = "첨부한 사진·영상. 최소 한 건이 필요하다. 표시 순서는 sortOrder를 따른다.") + @NotEmpty @Valid List mediaList, @Schema(description = "게시물에 태그한 장소. 내부 places에 등록된 장소만 태그할 수 있다.") @Valid List placeTags diff --git a/src/main/java/com/server/post/dto/PostDetailResponse.java b/src/main/java/com/server/post/dto/PostDetailResponse.java index 4c8e0c8..138a599 100644 --- a/src/main/java/com/server/post/dto/PostDetailResponse.java +++ b/src/main/java/com/server/post/dto/PostDetailResponse.java @@ -18,6 +18,11 @@ public record PostDetailResponse( List placeTags, @Schema(example = "12") int likeCount, @Schema(example = "3") int commentCount, + @Schema(description = "요청자가 좋아요를 누른 상태인지. 요청자를 알 수 없으면 false 다.", + example = "true") + boolean liked, + @Schema(description = "요청자가 저장한 상태인지. 요청자를 알 수 없으면 false 다.", example = "false") + boolean bookmarked, @Schema(example = "2026-08-11T17:30:00") LocalDateTime createdAt, @Schema(example = "2026-08-11T17:30:00") LocalDateTime updatedAt ) { @@ -52,7 +57,9 @@ public record PlaceTag( public static PostDetailResponse from( Post post, List mediaList, - List placeTags + List placeTags, + boolean liked, + boolean bookmarked ) { return new PostDetailResponse( post.getId(), @@ -78,6 +85,8 @@ public static PostDetailResponse from( .toList(), post.getLikeCount(), post.getCommentCount(), + liked, + bookmarked, post.getCreatedAt(), post.getUpdatedAt()); } diff --git a/src/main/java/com/server/post/dto/PostPlaceTagView 2.java b/src/main/java/com/server/post/dto/PostPlaceTagView 2.java deleted file mode 100644 index 75b27da..0000000 --- a/src/main/java/com/server/post/dto/PostPlaceTagView 2.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.server.post.dto; - -import com.server.post.domain.PostPlaceTag; -import java.math.BigDecimal; - -/** - * 응답에 필요한 장소 태그 값만 담는 조회 전용 모델. - * - *

{@code Place} 엔티티를 그대로 읽으면 {@code mappedBy} 로 연결된 상세·운영정보가 - * 장소마다 추가 질의를 발생시킨다. 커뮤니티는 장소명과 ID만 쓰므로 필요한 열만 가져온다. - */ -public record PostPlaceTagView( - Long postId, - Long placeId, - String placeName, - BigDecimal latitude, - BigDecimal longitude -) { - - /** 방금 저장해 이미 메모리에 있는 엔티티를 변환할 때 쓴다. */ - public static PostPlaceTagView from(PostPlaceTag tag) { - return new PostPlaceTagView( - tag.getPost().getId(), - tag.getPlace().getId(), - tag.getPlace().getName(), - tag.getLatitude(), - tag.getLongitude()); - } -} diff --git a/src/main/java/com/server/post/dto/PostSummaryResponse.java b/src/main/java/com/server/post/dto/PostSummaryResponse.java index 59127cd..488efe8 100644 --- a/src/main/java/com/server/post/dto/PostSummaryResponse.java +++ b/src/main/java/com/server/post/dto/PostSummaryResponse.java @@ -26,13 +26,20 @@ public record PostSummaryResponse( String placeName, @Schema(example = "12") int likeCount, @Schema(example = "3") int commentCount, + @Schema(description = "요청자가 좋아요를 누른 상태인지. 요청자를 알 수 없으면 false 다.", + example = "true") + boolean liked, + @Schema(description = "요청자가 저장한 상태인지. 요청자를 알 수 없으면 false 다.", example = "false") + boolean bookmarked, @Schema(example = "2026-08-11T17:30:00") LocalDateTime createdAt ) { public static PostSummaryResponse from( Post post, List mediaList, - List placeTags + List placeTags, + boolean liked, + boolean bookmarked ) { String thumbnailUrl = mediaList.stream() .min(Comparator.comparingInt(PostMedia::getSortOrder)) @@ -55,6 +62,8 @@ public static PostSummaryResponse from( placeName, post.getLikeCount(), post.getCommentCount(), + liked, + bookmarked, post.getCreatedAt()); } } diff --git a/src/main/java/com/server/post/repository/CommentRepository.java b/src/main/java/com/server/post/repository/CommentRepository.java index 738ca1a..a3fec34 100644 --- a/src/main/java/com/server/post/repository/CommentRepository.java +++ b/src/main/java/com/server/post/repository/CommentRepository.java @@ -7,6 +7,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface CommentRepository extends JpaRepository { @@ -15,10 +17,23 @@ public interface CommentRepository extends JpaRepository { /** * 게시물의 최상위 댓글 한 페이지. 오래된 순으로 읽으므로 첫 페이지는 커서에 0을 넘긴다. * 작성자는 응답에 항상 필요하므로 함께 조회한다. + * + *

삭제된 댓글이라도 살아 있는 답글이 있으면 자리를 남긴다. 그렇지 않으면 답글이 + * 부모를 잃고 화면에서 사라진다. */ @EntityGraph(attributePaths = "user") - List findByPostIdAndParentIsNullAndDeletedAtIsNullAndIdGreaterThanOrderByIdAsc( - Long postId, Long cursor, Pageable pageable); + @Query(""" + select comment from Comment comment + where comment.post.id = :postId + and comment.parent is null + and comment.id > :cursor + and (comment.deletedAt is null + or exists (select 1 from Comment reply + where reply.parent = comment and reply.deletedAt is null)) + order by comment.id asc + """) + List findTopLevelComments( + @Param("postId") Long postId, @Param("cursor") Long cursor, Pageable pageable); /** 부모 댓글 여러 건의 대댓글을 한 번에 읽는다. */ @EntityGraph(attributePaths = "user") diff --git a/src/main/java/com/server/post/repository/PostLikeRepository.java b/src/main/java/com/server/post/repository/PostLikeRepository.java index 7e9fb77..dfa5d97 100644 --- a/src/main/java/com/server/post/repository/PostLikeRepository.java +++ b/src/main/java/com/server/post/repository/PostLikeRepository.java @@ -2,12 +2,23 @@ import com.server.post.domain.PostLike; import com.server.post.domain.PostLikeId; +import java.util.Collection; +import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface PostLikeRepository extends JpaRepository { boolean existsByPostIdAndUserId(Long postId, Long userId); + /** 목록에 좋아요 여부를 표시할 때, 게시물 수와 무관하게 한 번만 조회한다. */ + @Query(""" + select postLike.post.id from PostLike postLike + where postLike.user.id = :userId and postLike.post.id in :postIds + """) + List findLikedPostIds(@Param("userId") Long userId, @Param("postIds") Collection postIds); + /** 취소된 건수를 돌려주므로 눌린 적 없는 좋아요를 취소해도 개수를 잘못 줄이지 않는다. */ long deleteByPostIdAndUserId(Long postId, Long userId); } diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java index 0c09cf9..a7d2e68 100644 --- a/src/main/java/com/server/post/repository/PostRepository.java +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -27,9 +27,28 @@ List findByUserIdAndDeletedAtIsNullAndIdLessThanOrderByIdDesc( /** * 최신순 피드 한 페이지. 첫 페이지는 {@code Long.MAX_VALUE}를 커서로 넘긴다. * 작성자는 응답에 항상 필요하므로 함께 조회한다. + * + * @param followerId 값이 있으면 이 사용자가 팔로우한 사람들의 게시물만 남긴다. + * @param placeId 값이 있으면 이 장소를 태그한 게시물만 남긴다. */ @EntityGraph(attributePaths = "user") - List findByDeletedAtIsNullAndIdLessThanOrderByIdDesc(Long cursor, Pageable pageable); + @Query(""" + select post from Post post + where post.deletedAt is null + and post.id < :cursor + and (:followerId is null or post.user.id in ( + select follow.following.id from Follow follow + where follow.follower.id = :followerId)) + and (:placeId is null or exists ( + select 1 from PostPlaceTag tag + where tag.post = post and tag.place.id = :placeId)) + order by post.id desc + """) + List findFeed( + @Param("followerId") Long followerId, + @Param("placeId") Long placeId, + @Param("cursor") Long cursor, + Pageable pageable); @Query("select post.likeCount from Post post where post.id = :postId") int findLikeCountById(@Param("postId") Long postId); @@ -50,4 +69,9 @@ List findByUserIdAndDeletedAtIsNullAndIdLessThanOrderByIdDesc( @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update Post post set post.commentCount = post.commentCount + 1 where post.id = :postId") void increaseCommentCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Post post set post.commentCount = post.commentCount - 1 " + + "where post.id = :postId and post.commentCount > 0") + void decreaseCommentCount(@Param("postId") Long postId); } diff --git a/src/main/java/com/server/post/service/CommentService.java b/src/main/java/com/server/post/service/CommentService.java index 8ff9f7f..8c8fe55 100644 --- a/src/main/java/com/server/post/service/CommentService.java +++ b/src/main/java/com/server/post/service/CommentService.java @@ -61,11 +61,10 @@ public CommentListResponse getComments(Long postId, Long cursor, Integer size) { } int limit = resolvePageSize(size); - List parents = commentRepository - .findByPostIdAndParentIsNullAndDeletedAtIsNullAndIdGreaterThanOrderByIdAsc( - postId, - cursor == null ? FIRST_PAGE_CURSOR : cursor, - PageRequest.of(0, limit)); + List parents = commentRepository.findTopLevelComments( + postId, + cursor == null ? FIRST_PAGE_CURSOR : cursor, + PageRequest.of(0, limit)); if (parents.isEmpty()) { return new CommentListResponse(List.of(), null); @@ -88,6 +87,25 @@ public CommentListResponse getComments(Long postId, Long cursor, Integer size) { return new CommentListResponse(items, nextCursor); } + /** + * 물리 삭제하지 않는다. 답글이 달린 댓글이어도 답글은 그대로 두고, + * 목록에서는 작성자와 내용을 감춘 자리만 남는다. + */ + @Transactional + public void delete(Long postId, Long commentId, Long userId) { + Comment comment = commentRepository.findByIdAndDeletedAtIsNull(commentId) + .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); + if (!comment.getPost().getId().equals(postId)) { + throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND); + } + if (!comment.isWrittenBy(userId)) { + throw new BusinessException(ErrorCode.COMMENT_ACCESS_DENIED); + } + + comment.delete(); + postRepository.decreaseCommentCount(postId); + } + /** * 답글의 부모를 확인한다. 응답이 최상위 댓글과 답글 두 단계만 표현하므로 * 답글에 다시 답글을 다는 요청은 거절한다. diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java index facd0c6..8e255b9 100644 --- a/src/main/java/com/server/post/service/PostService.java +++ b/src/main/java/com/server/post/service/PostService.java @@ -2,6 +2,7 @@ import com.server.common.error.BusinessException; import com.server.common.error.ErrorCode; +import com.server.common.error.FieldViolation; import com.server.place.domain.Place; import com.server.place.repository.PlaceRepository; import com.server.post.domain.Post; @@ -72,35 +73,60 @@ public PostDetailResponse create(Long userId, PostCreateRequest request) { List mediaList = saveMedia(post, request.mediaList()); List placeTags = savePlaceTags(post, request.placeTags()); + // 방금 만든 게시물이라 좋아요·저장이 있을 수 없다. return PostDetailResponse.from( post, mediaList, - placeTags.stream().map(PostPlaceTagView::from).toList()); + placeTags.stream().map(PostPlaceTagView::from).toList(), + false, + false); } @Transactional(readOnly = true) - public PostDetailResponse get(Long postId) { + public PostDetailResponse get(Long postId, Long requesterId) { Post post = postRepository.findByIdAndDeletedAtIsNull(postId) .orElseThrow(() -> new BusinessException(ErrorCode.POST_NOT_FOUND)); + List postIds = List.of(postId); return PostDetailResponse.from( post, postMediaRepository.findByPostId(postId), - postPlaceTagRepository.findViewsByPostId(postId)); + postPlaceTagRepository.findViewsByPostId(postId), + !postSummaryAssembler.likedPostIds(requesterId, postIds).isEmpty(), + !postSummaryAssembler.bookmarkedPostIds(requesterId, postIds).isEmpty()); } + /** + * @param following true 면 요청자가 팔로우한 사람들의 게시물만 반환한다. + * @param placeId 값이 있으면 이 장소를 태그한 게시물만 반환한다. + * @param requesterId 팔로잉 피드에만 필요하다. + */ @Transactional(readOnly = true) - public PostSummaryListResponse getFeed(Long cursor, Integer size) { + public PostSummaryListResponse getFeed( + Long cursor, + Integer size, + boolean following, + Long placeId, + Long requesterId + ) { + if (following && requesterId == null) { + throw new BusinessException(ErrorCode.INVALID_FEED_REQUEST, List.of(new FieldViolation( + "X-User-Id", "팔로잉 피드를 보려면 요청자를 알 수 있어야 합니다."))); + } int limit = resolveFeedSize(size); return toSummaryList( - postRepository.findByDeletedAtIsNullAndIdLessThanOrderByIdDesc( + postRepository.findFeed( + following ? requesterId : null, + placeId, cursor == null ? FIRST_PAGE_CURSOR : cursor, PageRequest.of(0, limit)), - limit); + limit, + requesterId); } @Transactional(readOnly = true) - public PostSummaryListResponse getUserPosts(Long userId, Long cursor, Integer size) { + public PostSummaryListResponse getUserPosts( + Long userId, Long cursor, Integer size, Long requesterId) { if (!userRepository.existsById(userId)) { throw new BusinessException(ErrorCode.USER_NOT_FOUND); } @@ -110,13 +136,15 @@ public PostSummaryListResponse getUserPosts(Long userId, Long cursor, Integer si userId, cursor == null ? FIRST_PAGE_CURSOR : cursor, PageRequest.of(0, limit)), - limit); + limit, + requesterId); } - private PostSummaryListResponse toSummaryList(List posts, int limit) { + private PostSummaryListResponse toSummaryList(List posts, int limit, Long requesterId) { // 요청한 개수를 못 채웠으면 더 가져올 게시물이 없다. Long nextCursor = posts.size() < limit ? null : posts.get(posts.size() - 1).getId(); - return new PostSummaryListResponse(postSummaryAssembler.assemble(posts), nextCursor); + return new PostSummaryListResponse( + postSummaryAssembler.assemble(posts, requesterId), nextCursor); } @Transactional @@ -124,10 +152,13 @@ public PostDetailResponse update(Long postId, Long userId, PostUpdateRequest req Post post = findWritablePost(postId, userId); post.updateContent(request.content()); + List postIds = List.of(postId); return PostDetailResponse.from( post, postMediaRepository.findByPostId(postId), - postPlaceTagRepository.findViewsByPostId(postId)); + postPlaceTagRepository.findViewsByPostId(postId), + !postSummaryAssembler.likedPostIds(userId, postIds).isEmpty(), + !postSummaryAssembler.bookmarkedPostIds(userId, postIds).isEmpty()); } /** diff --git a/src/main/java/com/server/post/service/PostSummaryAssembler.java b/src/main/java/com/server/post/service/PostSummaryAssembler.java index 89a344b..477477e 100644 --- a/src/main/java/com/server/post/service/PostSummaryAssembler.java +++ b/src/main/java/com/server/post/service/PostSummaryAssembler.java @@ -1,35 +1,48 @@ package com.server.post.service; +import com.server.bookmark.repository.BookmarkRepository; import com.server.post.domain.Post; import com.server.post.domain.PostMedia; import com.server.post.dto.PostPlaceTagView; import com.server.post.dto.PostSummaryResponse; +import com.server.post.repository.PostLikeRepository; import com.server.post.repository.PostMediaRepository; import com.server.post.repository.PostPlaceTagRepository; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.stereotype.Component; /** - * 게시물 목록에 미디어와 장소 태그를 붙여 축약 응답으로 만든다. - * 게시물 수와 무관하게 미디어·장소 태그를 각각 한 번씩만 읽는다. + * 게시물 목록에 미디어·장소 태그와 요청자의 좋아요·저장 여부를 붙여 축약 응답으로 만든다. + * 게시물 수와 무관하게 각 정보를 한 번씩만 읽는다. */ @Component public class PostSummaryAssembler { private final PostMediaRepository postMediaRepository; private final PostPlaceTagRepository postPlaceTagRepository; + private final PostLikeRepository postLikeRepository; + private final BookmarkRepository bookmarkRepository; public PostSummaryAssembler( PostMediaRepository postMediaRepository, - PostPlaceTagRepository postPlaceTagRepository + PostPlaceTagRepository postPlaceTagRepository, + PostLikeRepository postLikeRepository, + BookmarkRepository bookmarkRepository ) { this.postMediaRepository = postMediaRepository; this.postPlaceTagRepository = postPlaceTagRepository; + this.postLikeRepository = postLikeRepository; + this.bookmarkRepository = bookmarkRepository; } - public List assemble(List posts) { + /** + * @param requesterId 조회하는 사용자. 인증이 없어 전달되지 않을 수 있으며, + * 없으면 좋아요·저장 여부를 모두 false 로 둔다. + */ + public List assemble(List posts, Long requesterId) { if (posts.isEmpty()) { return List.of(); } @@ -40,12 +53,30 @@ public List assemble(List posts) { Map> placeTagsByPost = postPlaceTagRepository.findViewsByPostIdIn(postIds).stream() .collect(Collectors.groupingBy(PostPlaceTagView::postId)); + Set likedPostIds = likedPostIds(requesterId, postIds); + Set bookmarkedPostIds = bookmarkedPostIds(requesterId, postIds); return posts.stream() .map(post -> PostSummaryResponse.from( post, mediaByPost.getOrDefault(post.getId(), List.of()), - placeTagsByPost.getOrDefault(post.getId(), List.of()))) + placeTagsByPost.getOrDefault(post.getId(), List.of()), + likedPostIds.contains(post.getId()), + bookmarkedPostIds.contains(post.getId()))) .toList(); } + + public Set likedPostIds(Long requesterId, List postIds) { + if (requesterId == null || postIds.isEmpty()) { + return Set.of(); + } + return Set.copyOf(postLikeRepository.findLikedPostIds(requesterId, postIds)); + } + + public Set bookmarkedPostIds(Long requesterId, List postIds) { + if (requesterId == null || postIds.isEmpty()) { + return Set.of(); + } + return Set.copyOf(bookmarkRepository.findBookmarkedPostIds(requesterId, postIds)); + } } diff --git a/src/main/java/com/server/user/controller/MyProfileController.java b/src/main/java/com/server/user/controller/MyProfileController.java new file mode 100644 index 0000000..b52a165 --- /dev/null +++ b/src/main/java/com/server/user/controller/MyProfileController.java @@ -0,0 +1,45 @@ +package com.server.user.controller; + +import com.server.user.dto.NicknameUpdateRequest; +import com.server.user.dto.UserProfileResponse; +import com.server.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 요청자 본인을 대상으로 하는 API. 경로 변수를 쓰는 {@link UserController}와 섞이지 않도록 + * {@code /users/me} 를 별도로 매핑한다. + */ +@RestController +@RequestMapping("/api/v1/users/me") +@Tag(name = "커뮤니티 프로필", description = "사용자 프로필과 작성 게시물") +public class MyProfileController { + + private final UserService userService; + + public MyProfileController(UserService userService) { + this.userService = userService; + } + + @PatchMapping("/nickname") + @Operation( + summary = "닉네임 변경", + description = "최대 10자다. 다른 사람이 쓰는 닉네임이면 409 를 반환한다. " + + "프로필 사진 변경은 별도 API 로 분리할 예정이다." + ) + public UserProfileResponse changeNickname( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody NicknameUpdateRequest request + ) { + return userService.changeNickname(userId, request); + } +} diff --git a/src/main/java/com/server/user/controller/UserController.java b/src/main/java/com/server/user/controller/UserController.java index fff1c9d..672ec32 100644 --- a/src/main/java/com/server/user/controller/UserController.java +++ b/src/main/java/com/server/user/controller/UserController.java @@ -54,8 +54,11 @@ public PostSummaryListResponse getUserPosts( @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "81") @RequestParam(required = false) Long cursor, @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size + @RequestParam(defaultValue = "20") @Min(1) Integer size, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 없으면 좋아요·저장 여부가 false 로 나간다.", example = "1") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId ) { - return postService.getUserPosts(userId, cursor, size); + return postService.getUserPosts(userId, cursor, size, requesterId); } } diff --git a/src/main/java/com/server/user/controller/UserSearchController.java b/src/main/java/com/server/user/controller/UserSearchController.java new file mode 100644 index 0000000..f5e4999 --- /dev/null +++ b/src/main/java/com/server/user/controller/UserSearchController.java @@ -0,0 +1,46 @@ +package com.server.user.controller; + +import com.server.user.dto.UserSearchListResponse; +import com.server.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 경로 변수를 쓰는 {@link UserController}와 섞이지 않도록 {@code /users/search} 를 별도로 매핑한다. + */ +@Validated +@RestController +@RequestMapping("/api/v1/users/search") +@Tag(name = "커뮤니티 프로필", description = "사용자 프로필과 작성 게시물") +public class UserSearchController { + + private final UserService userService; + + public UserSearchController(UserService userService) { + this.userService = userService; + } + + @GetMapping + @Operation( + summary = "사용자 검색", + description = "닉네임에 검색어가 포함된 사용자를 가나다순으로 반환한다. " + + "검색어가 비어 있으면 빈 목록을 준다." + ) + public UserSearchListResponse search( + @Parameter(description = "닉네임 검색어", example = "감자") + @RequestParam(required = false) String keyword, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return userService.search(keyword, page, size); + } +} diff --git a/src/main/java/com/server/user/domain/User.java b/src/main/java/com/server/user/domain/User.java index a48f129..9e01edb 100644 --- a/src/main/java/com/server/user/domain/User.java +++ b/src/main/java/com/server/user/domain/User.java @@ -61,9 +61,8 @@ public LocalDateTime getDeletedAt() { return deletedAt; } - public void updateProfile(String nickname, String profileImageUrl) { + public void changeNickname(String nickname) { this.nickname = nickname; - this.profileImageUrl = profileImageUrl; } public void delete(LocalDateTime deletedAt) { diff --git a/src/main/java/com/server/user/dto/NicknameUpdateRequest.java b/src/main/java/com/server/user/dto/NicknameUpdateRequest.java new file mode 100644 index 0000000..418adfe --- /dev/null +++ b/src/main/java/com/server/user/dto/NicknameUpdateRequest.java @@ -0,0 +1,17 @@ +package com.server.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 프로필 사진 변경은 저장소에 올린 파일을 다루는 작업이라 별도 API 로 분리한다. + */ +@Schema(description = "닉네임 변경 요청") +public record NicknameUpdateRequest( + @Schema(description = "바꿀 닉네임. 최대 10자이며 이미 쓰는 사람이 있으면 409 를 반환한다.", + example = "감자") + @NotBlank @Size(max = 10) String nickname +) { +} diff --git a/src/main/java/com/server/user/dto/UserSearchListResponse.java b/src/main/java/com/server/user/dto/UserSearchListResponse.java new file mode 100644 index 0000000..065f366 --- /dev/null +++ b/src/main/java/com/server/user/dto/UserSearchListResponse.java @@ -0,0 +1,12 @@ +package com.server.user.dto; + +import com.server.follow.dto.FollowUserResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "닉네임 검색 결과. 팔로워·팔로잉 목록과 같은 형태다.") +public record UserSearchListResponse( + List items +) { +} diff --git a/src/main/java/com/server/user/repository/UserRepository.java b/src/main/java/com/server/user/repository/UserRepository.java index 00b5213..bb1e076 100644 --- a/src/main/java/com/server/user/repository/UserRepository.java +++ b/src/main/java/com/server/user/repository/UserRepository.java @@ -1,7 +1,9 @@ package com.server.user.repository; import com.server.user.domain.User; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface UserRepository extends JpaRepository { @@ -11,4 +13,8 @@ public interface UserRepository extends JpaRepository { Optional findByNicknameAndDeletedAtIsNull(String nickname); boolean existsByNicknameAndDeletedAtIsNull(String nickname); + + /** 닉네임 부분 일치 검색. 탈퇴한 사용자는 제외한다. */ + List findByNicknameContainingIgnoreCaseAndDeletedAtIsNullOrderByNicknameAsc( + String keyword, Pageable pageable); } diff --git a/src/main/java/com/server/user/service/UserService 2.java b/src/main/java/com/server/user/service/UserService 2.java deleted file mode 100644 index ccf46f9..0000000 --- a/src/main/java/com/server/user/service/UserService 2.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.server.user.service; - -import com.server.common.error.BusinessException; -import com.server.common.error.ErrorCode; -import com.server.follow.repository.FollowRepository; -import com.server.post.repository.PostRepository; -import com.server.user.domain.User; -import com.server.user.dto.UserProfileResponse; -import com.server.user.repository.UserRepository; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -public class UserService { - - private final UserRepository userRepository; - private final PostRepository postRepository; - private final FollowRepository followRepository; - - public UserService( - UserRepository userRepository, - PostRepository postRepository, - FollowRepository followRepository - ) { - this.userRepository = userRepository; - this.postRepository = postRepository; - this.followRepository = followRepository; - } - - /** - * @param requesterId 조회하는 사용자. 인증이 없어 전달되지 않을 수 있으며, - * 없으면 팔로우 여부를 판단하지 않는다. - */ - @Transactional(readOnly = true) - public UserProfileResponse getProfile(Long userId, Long requesterId) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); - - boolean following = requesterId != null - && !requesterId.equals(userId) - && followRepository.existsByFollowerIdAndFollowingId(requesterId, userId); - - return UserProfileResponse.of( - user, - postRepository.countByUserIdAndDeletedAtIsNull(userId), - followRepository.countByFollowingId(userId), - followRepository.countByFollowerId(userId), - following, - userId.equals(requesterId)); - } -} diff --git a/src/main/java/com/server/user/service/UserService.java b/src/main/java/com/server/user/service/UserService.java index ccf46f9..4e4afdc 100644 --- a/src/main/java/com/server/user/service/UserService.java +++ b/src/main/java/com/server/user/service/UserService.java @@ -2,17 +2,25 @@ import com.server.common.error.BusinessException; import com.server.common.error.ErrorCode; +import com.server.follow.dto.FollowUserResponse; import com.server.follow.repository.FollowRepository; import com.server.post.repository.PostRepository; import com.server.user.domain.User; +import com.server.user.dto.NicknameUpdateRequest; import com.server.user.dto.UserProfileResponse; +import com.server.user.dto.UserSearchListResponse; import com.server.user.repository.UserRepository; +import java.util.List; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class UserService { + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + private final UserRepository userRepository; private final PostRepository postRepository; private final FollowRepository followRepository; @@ -27,6 +35,36 @@ public UserService( this.followRepository = followRepository; } + /** 닉네임에 검색어가 포함된 사용자를 찾는다. 검색어가 비어 있으면 빈 목록을 준다. */ + @Transactional(readOnly = true) + public UserSearchListResponse search(String keyword, Integer page, Integer size) { + if (keyword == null || keyword.isBlank()) { + return new UserSearchListResponse(List.of()); + } + List items = userRepository + .findByNicknameContainingIgnoreCaseAndDeletedAtIsNullOrderByNicknameAsc( + keyword.trim(), pageRequest(page, size)) + .stream() + .map(FollowUserResponse::from) + .toList(); + return new UserSearchListResponse(items); + } + + /** 이미 쓰는 사람이 있는 닉네임이면 거절한다. 자기 닉네임을 그대로 보내는 건 허용한다. */ + @Transactional + public UserProfileResponse changeNickname(Long userId, NicknameUpdateRequest request) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!user.getNickname().equals(request.nickname()) + && userRepository.existsByNicknameAndDeletedAtIsNull(request.nickname())) { + throw new BusinessException(ErrorCode.NICKNAME_ALREADY_USED); + } + user.changeNickname(request.nickname()); + + return getProfile(userId, userId); + } + /** * @param requesterId 조회하는 사용자. 인증이 없어 전달되지 않을 수 있으며, * 없으면 팔로우 여부를 판단하지 않는다. @@ -48,4 +86,10 @@ public UserProfileResponse getProfile(Long userId, Long requesterId) { following, userId.equals(requesterId)); } + + private PageRequest pageRequest(Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + return PageRequest.of(resolvedPage, resolvedSize); + } } From 03767e92912aded060ffb0afd9944d3912184fba Mon Sep 17 00:00:00 2001 From: sususuj Date: Mon, 24 Aug 2026 04:05:13 +0900 Subject: [PATCH 09/12] =?UTF-8?q?feat:=20=ED=95=B4=EC=8B=9C=ED=83=9C?= =?UTF-8?q?=EA=B7=B8,=20=EC=8B=A0=EA=B3=A0,=20=EC=B0=A8=EB=8B=A8,=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=ED=94=BC=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../block/controller/BlockController.java | 74 +++++++++++ .../java/com/server/block/domain/Block.java | 57 ++++++++ .../java/com/server/block/domain/BlockId.java | 35 +++++ .../com/server/block/dto/BlockResponse.java | 10 ++ .../block/dto/BlockUserListResponse.java | 12 ++ .../block/repository/BlockRepository.java | 18 +++ .../server/block/service/BlockService.java | 87 +++++++++++++ .../com/server/common/error/ErrorCode.java | 2 + .../config/HashtagSeedInitializer.java | 45 +++++++ .../hashtag/controller/HashtagController.java | 42 ++++++ .../com/server/hashtag/domain/Hashtag.java | 48 +++++++ .../server/hashtag/domain/PostHashtag.java | 42 ++++++ .../server/hashtag/domain/PostHashtagId.java | 35 +++++ .../dto/HashtagSuggestionListResponse.java | 11 ++ .../dto/HashtagSuggestionResponse.java | 15 +++ .../hashtag/repository/HashtagRepository.java | 35 +++++ .../repository/PostHashtagRepository.java | 25 ++++ .../hashtag/service/HashtagExtractor.java | 44 +++++++ .../hashtag/service/HashtagService.java | 123 ++++++++++++++++++ .../post/controller/PostController.java | 24 +++- .../post/repository/PostRepository.java | 30 +++++ .../com/server/post/service/PostService.java | 33 ++++- .../report/controller/ReportController.java | 44 +++++++ .../java/com/server/report/domain/Report.java | 93 +++++++++++++ .../report/domain/ReportTargetType.java | 7 + .../report/dto/ReportCreateRequest.java | 21 +++ .../com/server/report/dto/ReportResponse.java | 27 ++++ .../report/repository/ReportRepository.java | 11 ++ .../server/report/service/ReportService.java | 68 ++++++++++ 29 files changed, 1115 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/server/block/controller/BlockController.java create mode 100644 src/main/java/com/server/block/domain/Block.java create mode 100644 src/main/java/com/server/block/domain/BlockId.java create mode 100644 src/main/java/com/server/block/dto/BlockResponse.java create mode 100644 src/main/java/com/server/block/dto/BlockUserListResponse.java create mode 100644 src/main/java/com/server/block/repository/BlockRepository.java create mode 100644 src/main/java/com/server/block/service/BlockService.java create mode 100644 src/main/java/com/server/hashtag/config/HashtagSeedInitializer.java create mode 100644 src/main/java/com/server/hashtag/controller/HashtagController.java create mode 100644 src/main/java/com/server/hashtag/domain/Hashtag.java create mode 100644 src/main/java/com/server/hashtag/domain/PostHashtag.java create mode 100644 src/main/java/com/server/hashtag/domain/PostHashtagId.java create mode 100644 src/main/java/com/server/hashtag/dto/HashtagSuggestionListResponse.java create mode 100644 src/main/java/com/server/hashtag/dto/HashtagSuggestionResponse.java create mode 100644 src/main/java/com/server/hashtag/repository/HashtagRepository.java create mode 100644 src/main/java/com/server/hashtag/repository/PostHashtagRepository.java create mode 100644 src/main/java/com/server/hashtag/service/HashtagExtractor.java create mode 100644 src/main/java/com/server/hashtag/service/HashtagService.java create mode 100644 src/main/java/com/server/report/controller/ReportController.java create mode 100644 src/main/java/com/server/report/domain/Report.java create mode 100644 src/main/java/com/server/report/domain/ReportTargetType.java create mode 100644 src/main/java/com/server/report/dto/ReportCreateRequest.java create mode 100644 src/main/java/com/server/report/dto/ReportResponse.java create mode 100644 src/main/java/com/server/report/repository/ReportRepository.java create mode 100644 src/main/java/com/server/report/service/ReportService.java diff --git a/src/main/java/com/server/block/controller/BlockController.java b/src/main/java/com/server/block/controller/BlockController.java new file mode 100644 index 0000000..20903c8 --- /dev/null +++ b/src/main/java/com/server/block/controller/BlockController.java @@ -0,0 +1,74 @@ +package com.server.block.controller; + +import com.server.block.dto.BlockResponse; +import com.server.block.dto.BlockUserListResponse; +import com.server.block.service.BlockService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1") +@Tag(name = "커뮤니티 차단", description = "사용자 차단과 차단 목록") +public class BlockController { + + private final BlockService blockService; + + public BlockController(BlockService blockService) { + this.blockService = blockService; + } + + @PostMapping("/users/{userId}/blocks") + @Operation( + summary = "차단", + description = "차단하면 서로의 팔로우가 끊기고 이 사용자의 게시물이 내 피드에서 사라진다. " + + "상대가 내 게시물을 보는 것은 막지 않는다." + ) + public BlockResponse block( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "차단할 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return blockService.block(userId, requesterId); + } + + @DeleteMapping("/users/{userId}/blocks") + @Operation( + summary = "차단 해제", + description = "끊긴 팔로우는 되살아나지 않는다. 필요하면 다시 팔로우해야 한다." + ) + public BlockResponse unblock( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long requesterId, + @Parameter(description = "차단을 풀 대상 사용자 ID", example = "2") @PathVariable Long userId + ) { + return blockService.unblock(userId, requesterId); + } + + @GetMapping("/users/me/blocks") + @Operation(summary = "내가 차단한 사용자 목록", description = "최근 차단한 순으로 반환한다.") + public BlockUserListResponse getMyBlocks( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 인원 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size + ) { + return blockService.getMyBlocks(userId, page, size); + } +} diff --git a/src/main/java/com/server/block/domain/Block.java b/src/main/java/com/server/block/domain/Block.java new file mode 100644 index 0000000..13eb549 --- /dev/null +++ b/src/main/java/com/server/block/domain/Block.java @@ -0,0 +1,57 @@ +package com.server.block.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +/** + * 차단 관계. {@code blocker}가 {@code blocked}를 차단한다. 방향이 있으며, + * 차단한 사람의 피드에서 상대 게시물이 사라진다. + */ +@Entity +@Table(name = "blocks") +@IdClass(BlockId.class) +public class Block { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocker_id", nullable = false) + private User blocker; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "blocked_id", nullable = false) + private User blocked; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Block() { + } + + public Block(User blocker, User blocked) { + this.blocker = blocker; + this.blocked = blocked; + this.createdAt = LocalDateTime.now(); + } + + public User getBlocker() { + return blocker; + } + + public User getBlocked() { + return blocked; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/block/domain/BlockId.java b/src/main/java/com/server/block/domain/BlockId.java new file mode 100644 index 0000000..710450e --- /dev/null +++ b/src/main/java/com/server/block/domain/BlockId.java @@ -0,0 +1,35 @@ +package com.server.block.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** {@link Block}의 복합 기본키. 같은 사람을 두 번 차단할 수 없다. */ +public class BlockId implements Serializable { + + private Long blocker; + private Long blocked; + + protected BlockId() { + } + + public BlockId(Long blocker, Long blocked) { + this.blocker = blocker; + this.blocked = blocked; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof BlockId that)) { + return false; + } + return Objects.equals(blocker, that.blocker) && Objects.equals(blocked, that.blocked); + } + + @Override + public int hashCode() { + return Objects.hash(blocker, blocked); + } +} diff --git a/src/main/java/com/server/block/dto/BlockResponse.java b/src/main/java/com/server/block/dto/BlockResponse.java new file mode 100644 index 0000000..6f4425b --- /dev/null +++ b/src/main/java/com/server/block/dto/BlockResponse.java @@ -0,0 +1,10 @@ +package com.server.block.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "차단 처리 결과") +public record BlockResponse( + @Schema(description = "요청한 사용자가 대상을 차단한 상태인지", example = "true") + boolean blocked +) { +} diff --git a/src/main/java/com/server/block/dto/BlockUserListResponse.java b/src/main/java/com/server/block/dto/BlockUserListResponse.java new file mode 100644 index 0000000..b0773be --- /dev/null +++ b/src/main/java/com/server/block/dto/BlockUserListResponse.java @@ -0,0 +1,12 @@ +package com.server.block.dto; + +import com.server.follow.dto.FollowUserResponse; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "내가 차단한 사용자 목록. 최근 차단한 순으로 반환한다.") +public record BlockUserListResponse( + List items +) { +} diff --git a/src/main/java/com/server/block/repository/BlockRepository.java b/src/main/java/com/server/block/repository/BlockRepository.java new file mode 100644 index 0000000..3ddd61a --- /dev/null +++ b/src/main/java/com/server/block/repository/BlockRepository.java @@ -0,0 +1,18 @@ +package com.server.block.repository; + +import com.server.block.domain.Block; +import com.server.block.domain.BlockId; +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BlockRepository extends JpaRepository { + + boolean existsByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + long deleteByBlockerIdAndBlockedId(Long blockerId, Long blockedId); + + @EntityGraph(attributePaths = "blocked") + List findByBlockerIdOrderByCreatedAtDesc(Long blockerId, Pageable pageable); +} diff --git a/src/main/java/com/server/block/service/BlockService.java b/src/main/java/com/server/block/service/BlockService.java new file mode 100644 index 0000000..a96b210 --- /dev/null +++ b/src/main/java/com/server/block/service/BlockService.java @@ -0,0 +1,87 @@ +package com.server.block.service; + +import com.server.block.domain.Block; +import com.server.block.dto.BlockResponse; +import com.server.block.dto.BlockUserListResponse; +import com.server.block.repository.BlockRepository; +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.follow.dto.FollowUserResponse; +import com.server.follow.repository.FollowRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class BlockService { + + private static final int DEFAULT_PAGE_SIZE = 20; + private static final int MAX_PAGE_SIZE = 50; + + private final BlockRepository blockRepository; + private final FollowRepository followRepository; + private final UserRepository userRepository; + + public BlockService( + BlockRepository blockRepository, + FollowRepository followRepository, + UserRepository userRepository + ) { + this.blockRepository = blockRepository; + this.followRepository = followRepository; + this.userRepository = userRepository; + } + + /** + * 차단하면 서로의 팔로우를 끊는다. 차단한 상대의 소식을 계속 받는 것은 앞뒤가 맞지 않는다. + * 이미 차단한 상대를 다시 차단해도 관계가 중복 생성되지 않는다. + */ + @Transactional + public BlockResponse block(Long targetUserId, Long userId) { + if (targetUserId.equals(userId)) { + throw new BusinessException(ErrorCode.INVALID_BLOCK_REQUEST); + } + User target = findActiveUser(targetUserId); + User me = findActiveUser(userId); + + if (!blockRepository.existsByBlockerIdAndBlockedId(userId, targetUserId)) { + blockRepository.save(new Block(me, target)); + } + followRepository.deleteByFollowerIdAndFollowingId(userId, targetUserId); + followRepository.deleteByFollowerIdAndFollowingId(targetUserId, userId); + + return new BlockResponse(true); + } + + /** 차단을 풀어도 끊긴 팔로우는 되살리지 않는다. */ + @Transactional + public BlockResponse unblock(Long targetUserId, Long userId) { + findActiveUser(targetUserId); + blockRepository.deleteByBlockerIdAndBlockedId(userId, targetUserId); + return new BlockResponse(false); + } + + @Transactional(readOnly = true) + public BlockUserListResponse getMyBlocks(Long userId, Integer page, Integer size) { + findActiveUser(userId); + List items = blockRepository + .findByBlockerIdOrderByCreatedAtDesc(userId, pageRequest(page, size)).stream() + .map(block -> FollowUserResponse.from(block.getBlocked())) + .toList(); + return new BlockUserListResponse(items); + } + + private User findActiveUser(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private PageRequest pageRequest(Integer page, Integer size) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + return PageRequest.of(resolvedPage, resolvedSize); + } +} diff --git a/src/main/java/com/server/common/error/ErrorCode.java b/src/main/java/com/server/common/error/ErrorCode.java index e51518b..b049ce7 100644 --- a/src/main/java/com/server/common/error/ErrorCode.java +++ b/src/main/java/com/server/common/error/ErrorCode.java @@ -33,6 +33,8 @@ public enum ErrorCode { POST_ACCESS_DENIED(403, "본인의 게시물만 수정하거나 삭제할 수 있습니다."), COMMENT_ACCESS_DENIED(403, "본인의 댓글만 삭제할 수 있습니다."), NICKNAME_ALREADY_USED(409, "이미 사용 중인 닉네임입니다."), + ALREADY_REPORTED(409, "이미 신고한 대상입니다."), + INVALID_BLOCK_REQUEST(400, "차단 요청이 올바르지 않습니다."), TRANSIT_ROUTE_NOT_FOUND(422, "장소 사이 대중교통 경로를 찾지 못했습니다."), FACILITY_TYPE_NOT_SUPPORTED(501, "지원하지 않는 편의시설 유형입니다."), EXTERNAL_PROVIDER_UNAVAILABLE(503, "외부 서비스가 응답하지 않습니다."), diff --git a/src/main/java/com/server/hashtag/config/HashtagSeedInitializer.java b/src/main/java/com/server/hashtag/config/HashtagSeedInitializer.java new file mode 100644 index 0000000..649a45a --- /dev/null +++ b/src/main/java/com/server/hashtag/config/HashtagSeedInitializer.java @@ -0,0 +1,45 @@ +package com.server.hashtag.config; + +import java.util.List; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * 자주 쓰일 해시태그를 미리 만들어 둔다. + * + *

같은 태그를 처음 쓰는 요청이 동시에 들어오면 이름 고유 제약에 걸려 하나가 실패할 수 있다. + * 미리 만들어 두면 그 순간에는 이미 존재하므로 충돌하지 않는다. 서비스 초기에 자동완성이 + * 비어 보이는 것도 함께 막는다. + * + *

{@code post_count}는 0에서 시작한다. 실제로 쓰인 횟수만 세야 자동완성 순서가 뒤틀리지 않는다. + */ +@Configuration +public class HashtagSeedInitializer { + + private static final List POPULAR_HASHTAGS = List.of( + // 지역·장소 + "부산", "해운대", "광안리", "감천문화마을", "자갈치시장", "태종대", + "송도", "서면", "남포동", "영도", "다대포", "기장", + // 여행 성격 + "부산여행", "뚜벅이여행", "당일치기", "데이트", "가족여행", "혼자여행", + // 주제 + "맛집", "카페", "야경", "바다", "사진스팟", "축제", + // 지역과 주제를 붙여 쓰는 형태. 태그에는 띄어쓰기를 넣을 수 없어 자주 쓰인다. + "부산맛집", "부산카페", "부산야경", "부산바다", "부산데이트", "부산가볼만한곳", + "해운대맛집", "해운대카페", "광안리맛집", "광안리카페", "서면맛집", "남포동맛집" + ); + + @Bean + ApplicationRunner seedPopularHashtags(JdbcTemplate jdbcTemplate) { + return args -> POPULAR_HASHTAGS.forEach(name -> { + Integer count = jdbcTemplate.queryForObject( + "select count(*) from hashtags where name = ?", Integer.class, name); + if (count == null || count == 0) { + jdbcTemplate.update( + "insert into hashtags(name, post_count) values (?, 0)", name); + } + }); + } +} diff --git a/src/main/java/com/server/hashtag/controller/HashtagController.java b/src/main/java/com/server/hashtag/controller/HashtagController.java new file mode 100644 index 0000000..f0420ad --- /dev/null +++ b/src/main/java/com/server/hashtag/controller/HashtagController.java @@ -0,0 +1,42 @@ +package com.server.hashtag.controller; + +import com.server.hashtag.dto.HashtagSuggestionListResponse; +import com.server.hashtag.service.HashtagService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/v1/hashtags") +@Tag(name = "커뮤니티 해시태그", description = "해시태그 자동완성") +public class HashtagController { + + private final HashtagService hashtagService; + + public HashtagController(HashtagService hashtagService) { + this.hashtagService = hashtagService; + } + + @GetMapping("/search") + @Operation( + summary = "해시태그 자동완성", + description = "입력한 앞글자로 시작하는 태그를 많이 쓰인 순으로 반환한다. " + + "본문에 여러 단어를 담으려면 붙여 써야 하므로, 사용자가 직접 입력하는 대신 " + + "여기서 골라 넣도록 하는 것이 낫다." + ) + public HashtagSuggestionListResponse suggest( + @Parameter(description = "태그 앞글자. # 은 빼고 보낸다.", example = "광") + @RequestParam(required = false) String keyword, + @Parameter(description = "최대 후보 수. 1 이상 30 이하", example = "10") + @RequestParam(defaultValue = "10") @Min(1) Integer size + ) { + return hashtagService.suggest(keyword, size); + } +} diff --git a/src/main/java/com/server/hashtag/domain/Hashtag.java b/src/main/java/com/server/hashtag/domain/Hashtag.java new file mode 100644 index 0000000..8f70dcb --- /dev/null +++ b/src/main/java/com/server/hashtag/domain/Hashtag.java @@ -0,0 +1,48 @@ +package com.server.hashtag.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + +@Entity +@Table( + name = "hashtags", + uniqueConstraints = @UniqueConstraint(name = "uk_hashtags_name", columnNames = {"name"}) +) +public class Hashtag { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 소문자로 정규화해 저장한다. #Busan 과 #busan 은 같은 태그다. */ + @Column(nullable = false) + private String name; + + /** 자동완성 정렬 기준. 게시물이 추가·삭제될 때 함께 증감시킨다. */ + @Column(name = "post_count", nullable = false) + private int postCount = 0; + + protected Hashtag() { + } + + public Hashtag(String name) { + this.name = name; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public int getPostCount() { + return postCount; + } +} diff --git a/src/main/java/com/server/hashtag/domain/PostHashtag.java b/src/main/java/com/server/hashtag/domain/PostHashtag.java new file mode 100644 index 0000000..360f6f7 --- /dev/null +++ b/src/main/java/com/server/hashtag/domain/PostHashtag.java @@ -0,0 +1,42 @@ +package com.server.hashtag.domain; + +import com.server.post.domain.Post; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +@Entity +@Table(name = "post_hashtags") +@IdClass(PostHashtagId.class) +public class PostHashtag { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "hashtag_id", nullable = false) + private Hashtag hashtag; + + protected PostHashtag() { + } + + public PostHashtag(Post post, Hashtag hashtag) { + this.post = post; + this.hashtag = hashtag; + } + + public Post getPost() { + return post; + } + + public Hashtag getHashtag() { + return hashtag; + } +} diff --git a/src/main/java/com/server/hashtag/domain/PostHashtagId.java b/src/main/java/com/server/hashtag/domain/PostHashtagId.java new file mode 100644 index 0000000..a3cd12e --- /dev/null +++ b/src/main/java/com/server/hashtag/domain/PostHashtagId.java @@ -0,0 +1,35 @@ +package com.server.hashtag.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** {@link PostHashtag}의 복합 기본키. 같은 게시물에 같은 태그가 두 번 붙지 않는다. */ +public class PostHashtagId implements Serializable { + + private Long post; + private Long hashtag; + + protected PostHashtagId() { + } + + public PostHashtagId(Long post, Long hashtag) { + this.post = post; + this.hashtag = hashtag; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof PostHashtagId that)) { + return false; + } + return Objects.equals(post, that.post) && Objects.equals(hashtag, that.hashtag); + } + + @Override + public int hashCode() { + return Objects.hash(post, hashtag); + } +} diff --git a/src/main/java/com/server/hashtag/dto/HashtagSuggestionListResponse.java b/src/main/java/com/server/hashtag/dto/HashtagSuggestionListResponse.java new file mode 100644 index 0000000..b7b7be6 --- /dev/null +++ b/src/main/java/com/server/hashtag/dto/HashtagSuggestionListResponse.java @@ -0,0 +1,11 @@ +package com.server.hashtag.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "해시태그 자동완성 결과. 많이 쓰인 태그를 먼저 반환한다.") +public record HashtagSuggestionListResponse( + List items +) { +} diff --git a/src/main/java/com/server/hashtag/dto/HashtagSuggestionResponse.java b/src/main/java/com/server/hashtag/dto/HashtagSuggestionResponse.java new file mode 100644 index 0000000..50a905c --- /dev/null +++ b/src/main/java/com/server/hashtag/dto/HashtagSuggestionResponse.java @@ -0,0 +1,15 @@ +package com.server.hashtag.dto; + +import com.server.hashtag.domain.Hashtag; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "해시태그 자동완성 후보") +public record HashtagSuggestionResponse( + @Schema(description = "# 을 뺀 태그 이름", example = "광안리맛집") String name, + @Schema(description = "이 태그가 달린 게시물 수", example = "1203") int postCount +) { + + public static HashtagSuggestionResponse from(Hashtag hashtag) { + return new HashtagSuggestionResponse(hashtag.getName(), hashtag.getPostCount()); + } +} diff --git a/src/main/java/com/server/hashtag/repository/HashtagRepository.java b/src/main/java/com/server/hashtag/repository/HashtagRepository.java new file mode 100644 index 0000000..d844fc3 --- /dev/null +++ b/src/main/java/com/server/hashtag/repository/HashtagRepository.java @@ -0,0 +1,35 @@ +package com.server.hashtag.repository; + +import com.server.hashtag.domain.Hashtag; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface HashtagRepository extends JpaRepository { + + Optional findByName(String name); + + List findByNameIn(Collection names); + + /** 자동완성. 많이 쓰인 태그를 먼저 보여준다. */ + List findByNameStartingWithOrderByPostCountDescNameAsc(String prefix, Pageable pageable); + + /** + * 사용 수를 DB에서 직접 증감시킨다. 엔티티를 읽어 고쳐 쓰면 동시에 같은 태그를 쓴 + * 요청끼리 값이 어긋날 수 있다. + */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Hashtag hashtag set hashtag.postCount = hashtag.postCount + 1 " + + "where hashtag.id in :ids") + void increasePostCount(@Param("ids") Collection ids); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Hashtag hashtag set hashtag.postCount = hashtag.postCount - 1 " + + "where hashtag.id in :ids and hashtag.postCount > 0") + void decreasePostCount(@Param("ids") Collection ids); +} diff --git a/src/main/java/com/server/hashtag/repository/PostHashtagRepository.java b/src/main/java/com/server/hashtag/repository/PostHashtagRepository.java new file mode 100644 index 0000000..96a19ec --- /dev/null +++ b/src/main/java/com/server/hashtag/repository/PostHashtagRepository.java @@ -0,0 +1,25 @@ +package com.server.hashtag.repository; + +import com.server.hashtag.domain.PostHashtag; +import com.server.hashtag.domain.PostHashtagId; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PostHashtagRepository extends JpaRepository { + + @Query("select link.hashtag.id from PostHashtag link where link.post.id = :postId") + List findHashtagIdsByPostId(@Param("postId") Long postId); + + long deleteByPostId(Long postId); + + /** 게시물 상세·목록에 태그 이름을 담을 때 쓴다. */ + @Query(""" + select link.post.id, link.hashtag.name from PostHashtag link + where link.post.id in :postIds + order by link.hashtag.name asc + """) + List findPostIdAndNamePairs(@Param("postIds") Collection postIds); +} diff --git a/src/main/java/com/server/hashtag/service/HashtagExtractor.java b/src/main/java/com/server/hashtag/service/HashtagExtractor.java new file mode 100644 index 0000000..871d6ab --- /dev/null +++ b/src/main/java/com/server/hashtag/service/HashtagExtractor.java @@ -0,0 +1,44 @@ +package com.server.hashtag.service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 본문에서 해시태그를 뽑아낸다. + * + *

{@code #} 뒤의 한글·영문·숫자·밑줄만 태그로 인정하며 공백이나 그 밖의 문자에서 끊는다. + * 여러 단어를 담고 싶으면 붙여 써야 한다({@code #광안리맛집}). 띄어쓰기를 허용하면 + * 어디까지가 태그인지 정할 수 없기 때문이다. + */ +@Component +public class HashtagExtractor { + + /** 게시물 하나에 담을 수 있는 태그 수. */ + static final int MAX_HASHTAGS_PER_POST = 20; + /** 태그 하나의 최대 길이. */ + static final int MAX_HASHTAG_LENGTH = 50; + + private static final Pattern HASHTAG = Pattern.compile("#([0-9A-Za-z_가-힣ㄱ-ㅎㅏ-ㅣ]+)"); + + /** + * @return 등장 순서를 유지한 소문자 태그 목록. 중복은 제거하고 최대 개수까지만 남긴다. + */ + public List extract(String content) { + if (content == null || content.isBlank()) { + return List.of(); + } + + LinkedHashSet names = new LinkedHashSet<>(); + Matcher matcher = HASHTAG.matcher(content); + while (matcher.find() && names.size() < MAX_HASHTAGS_PER_POST) { + String name = matcher.group(1).toLowerCase(); + if (name.length() <= MAX_HASHTAG_LENGTH) { + names.add(name); + } + } + return List.copyOf(names); + } +} diff --git a/src/main/java/com/server/hashtag/service/HashtagService.java b/src/main/java/com/server/hashtag/service/HashtagService.java new file mode 100644 index 0000000..892e67f --- /dev/null +++ b/src/main/java/com/server/hashtag/service/HashtagService.java @@ -0,0 +1,123 @@ +package com.server.hashtag.service; + +import com.server.hashtag.domain.Hashtag; +import com.server.hashtag.domain.PostHashtag; +import com.server.hashtag.dto.HashtagSuggestionListResponse; +import com.server.hashtag.dto.HashtagSuggestionResponse; +import com.server.hashtag.repository.HashtagRepository; +import com.server.hashtag.repository.PostHashtagRepository; +import com.server.post.domain.Post; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class HashtagService { + + private static final int DEFAULT_SUGGESTION_SIZE = 10; + private static final int MAX_SUGGESTION_SIZE = 30; + + private final HashtagRepository hashtagRepository; + private final PostHashtagRepository postHashtagRepository; + private final HashtagExtractor hashtagExtractor; + + public HashtagService( + HashtagRepository hashtagRepository, + PostHashtagRepository postHashtagRepository, + HashtagExtractor hashtagExtractor + ) { + this.hashtagRepository = hashtagRepository; + this.postHashtagRepository = postHashtagRepository; + this.hashtagExtractor = hashtagExtractor; + } + + /** 본문에서 태그를 뽑아 게시물에 연결하고 사용 수를 올린다. */ + @Transactional + public void attachFromContent(Post post, String content) { + List names = hashtagExtractor.extract(content); + if (names.isEmpty()) { + return; + } + + List hashtags = findOrCreate(names); + postHashtagRepository.saveAll(hashtags.stream() + .map(hashtag -> new PostHashtag(post, hashtag)) + .toList()); + hashtagRepository.increasePostCount(hashtags.stream().map(Hashtag::getId).toList()); + } + + /** 게시물이 삭제되면 연결을 끊고 사용 수를 되돌린다. */ + @Transactional + public void detachFromPost(Long postId) { + List hashtagIds = postHashtagRepository.findHashtagIdsByPostId(postId); + if (hashtagIds.isEmpty()) { + return; + } + postHashtagRepository.deleteByPostId(postId); + hashtagRepository.decreasePostCount(hashtagIds); + } + + /** 게시물 본문이 바뀌면 태그를 다시 계산한다. */ + @Transactional + public void reattachFromContent(Post post, String content) { + detachFromPost(post.getId()); + attachFromContent(post, content); + } + + /** 앞글자로 시작하는 태그를 많이 쓰인 순으로 추천한다. */ + @Transactional(readOnly = true) + public HashtagSuggestionListResponse suggest(String keyword, Integer size) { + if (keyword == null || keyword.isBlank()) { + return new HashtagSuggestionListResponse(List.of()); + } + int limit = size == null || size <= 0 + ? DEFAULT_SUGGESTION_SIZE + : Math.min(size, MAX_SUGGESTION_SIZE); + + List items = hashtagRepository + .findByNameStartingWithOrderByPostCountDescNameAsc( + keyword.trim().toLowerCase(), PageRequest.of(0, limit)) + .stream() + .map(HashtagSuggestionResponse::from) + .toList(); + return new HashtagSuggestionListResponse(items); + } + + /** 게시물별 태그 이름. 목록 응답에 붙일 때 게시물 수와 무관하게 한 번만 조회한다. */ + @Transactional(readOnly = true) + public Map> findNamesByPostIds(Collection postIds) { + if (postIds.isEmpty()) { + return Map.of(); + } + return postHashtagRepository.findPostIdAndNamePairs(postIds).stream() + .collect(Collectors.groupingBy( + pair -> (Long) pair[0], + Collectors.mapping(pair -> (String) pair[1], Collectors.toList()))); + } + + /** + * 이미 있는 태그는 그대로 쓰고 없는 것만 만든다. 같은 태그를 두 사람이 동시에 처음 + * 쓰면 이름 고유 제약에 걸릴 수 있으며, 그 경우 요청 하나가 실패한다. + */ + private List findOrCreate(List names) { + Map existing = hashtagRepository.findByNameIn(names).stream() + .collect(Collectors.toMap(Hashtag::getName, Function.identity())); + + List created = new ArrayList<>(); + for (String name : names) { + if (!existing.containsKey(name)) { + created.add(new Hashtag(name)); + } + } + if (!created.isEmpty()) { + hashtagRepository.saveAll(created).forEach(hashtag -> existing.put(hashtag.getName(), hashtag)); + } + return names.stream().map(existing::get).toList(); + } +} diff --git a/src/main/java/com/server/post/controller/PostController.java b/src/main/java/com/server/post/controller/PostController.java index 0f2c061..68d9033 100644 --- a/src/main/java/com/server/post/controller/PostController.java +++ b/src/main/java/com/server/post/controller/PostController.java @@ -73,11 +73,33 @@ public PostSummaryListResponse getFeed( @RequestParam(required = false) String feed, @Parameter(description = "이 장소를 태그한 게시물만 반환한다.", example = "1") @RequestParam(required = false) Long placeId, + @Parameter(description = "이 해시태그가 달린 게시물만 반환한다. # 은 빼고 보낸다.", + example = "광안리맛집") + @RequestParam(required = false) String hashtag, // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. @Parameter(description = "요청자 ID. 팔로잉 피드에만 필요하다.", example = "1") @RequestHeader(value = "X-User-Id", required = false) Long requesterId ) { - return postService.getFeed(cursor, size, FOLLOWING_FEED.equals(feed), placeId, requesterId); + return postService.getFeed( + cursor, size, FOLLOWING_FEED.equals(feed), placeId, hashtag, requesterId); + } + + @GetMapping("/popular") + @Operation( + summary = "인기 피드", + description = "탐색 탭용이다. 최근 7일 게시물을 좋아요 + 댓글×2 점수 순으로 반환한다. " + + "점수 기준 정렬이라 커서를 쓸 수 없어 page 로 넘긴다." + ) + public PostSummaryListResponse getPopularFeed( + @Parameter(description = "0부터 시작하는 페이지 번호", example = "0") + @RequestParam(defaultValue = "0") Integer page, + @Parameter(description = "한 번에 가져올 게시물 수. 1 이상 50 이하", example = "20") + @RequestParam(defaultValue = "20") @Min(1) Integer size, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 차단한 사용자를 걸러내는 데 쓴다.", example = "1") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId + ) { + return postService.getPopularFeed(page, size, requesterId); } @GetMapping("/{postId}") diff --git a/src/main/java/com/server/post/repository/PostRepository.java b/src/main/java/com/server/post/repository/PostRepository.java index a7d2e68..33dd173 100644 --- a/src/main/java/com/server/post/repository/PostRepository.java +++ b/src/main/java/com/server/post/repository/PostRepository.java @@ -1,6 +1,7 @@ package com.server.post.repository; import com.server.post.domain.Post; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import org.springframework.data.domain.Pageable; @@ -42,14 +43,43 @@ List findByUserIdAndDeletedAtIsNullAndIdLessThanOrderByIdDesc( and (:placeId is null or exists ( select 1 from PostPlaceTag tag where tag.post = post and tag.place.id = :placeId)) + and (:hashtag is null or exists ( + select 1 from PostHashtag link + where link.post = post and link.hashtag.name = :hashtag)) + and (:blockerId is null or not exists ( + select 1 from Block block + where block.blocker.id = :blockerId and block.blocked = post.user)) order by post.id desc """) List findFeed( @Param("followerId") Long followerId, @Param("placeId") Long placeId, + @Param("hashtag") String hashtag, + @Param("blockerId") Long blockerId, @Param("cursor") Long cursor, Pageable pageable); + /** + * 탐색 탭용 인기 게시물. 점수는 좋아요 + 댓글×2 이며, 오래된 인기글이 상단을 계속 + * 차지하지 않도록 최근 게시물만 대상으로 한다. + * + *

점수 기준 정렬이라 커서로 삼을 단조 증가 값이 없어 오프셋 페이징을 쓴다. + */ + @EntityGraph(attributePaths = "user") + @Query(""" + select post from Post post + where post.deletedAt is null + and post.createdAt >= :since + and (:blockerId is null or not exists ( + select 1 from Block block + where block.blocker.id = :blockerId and block.blocked = post.user)) + order by (post.likeCount + post.commentCount * 2) desc, post.id desc + """) + List findPopularFeed( + @Param("since") LocalDateTime since, + @Param("blockerId") Long blockerId, + Pageable pageable); + @Query("select post.likeCount from Post post where post.id = :postId") int findLikeCountById(@Param("postId") Long postId); diff --git a/src/main/java/com/server/post/service/PostService.java b/src/main/java/com/server/post/service/PostService.java index 8e255b9..4d0aed8 100644 --- a/src/main/java/com/server/post/service/PostService.java +++ b/src/main/java/com/server/post/service/PostService.java @@ -3,6 +3,7 @@ import com.server.common.error.BusinessException; import com.server.common.error.ErrorCode; import com.server.common.error.FieldViolation; +import com.server.hashtag.service.HashtagService; import com.server.place.domain.Place; import com.server.place.repository.PlaceRepository; import com.server.post.domain.Post; @@ -22,6 +23,7 @@ import com.server.post.repository.PostRepository; import com.server.user.domain.User; import com.server.user.repository.UserRepository; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.function.Function; @@ -37,6 +39,8 @@ public class PostService { private static final int MAX_FEED_SIZE = 50; /** 첫 페이지는 커서가 없으므로 어떤 게시물 ID보다 큰 값에서 시작한다. */ private static final long FIRST_PAGE_CURSOR = Long.MAX_VALUE; + /** 인기 피드가 다루는 기간. 지나치게 넓으면 예전 인기글이 상단을 계속 차지한다. */ + private static final int POPULAR_FEED_DAYS = 7; private final PostRepository postRepository; private final PostMediaRepository postMediaRepository; @@ -45,6 +49,7 @@ public class PostService { private final UserRepository userRepository; private final PlaceRepository placeRepository; private final PostSummaryAssembler postSummaryAssembler; + private final HashtagService hashtagService; public PostService( PostRepository postRepository, @@ -53,7 +58,8 @@ public PostService( PostLikeRepository postLikeRepository, UserRepository userRepository, PlaceRepository placeRepository, - PostSummaryAssembler postSummaryAssembler + PostSummaryAssembler postSummaryAssembler, + HashtagService hashtagService ) { this.postRepository = postRepository; this.postMediaRepository = postMediaRepository; @@ -62,6 +68,7 @@ public PostService( this.userRepository = userRepository; this.placeRepository = placeRepository; this.postSummaryAssembler = postSummaryAssembler; + this.hashtagService = hashtagService; } @Transactional @@ -72,6 +79,7 @@ public PostDetailResponse create(Long userId, PostCreateRequest request) { Post post = postRepository.save(new Post(author, request.content())); List mediaList = saveMedia(post, request.mediaList()); List placeTags = savePlaceTags(post, request.placeTags()); + hashtagService.attachFromContent(post, request.content()); // 방금 만든 게시물이라 좋아요·저장이 있을 수 없다. return PostDetailResponse.from( @@ -99,7 +107,8 @@ public PostDetailResponse get(Long postId, Long requesterId) { /** * @param following true 면 요청자가 팔로우한 사람들의 게시물만 반환한다. * @param placeId 값이 있으면 이 장소를 태그한 게시물만 반환한다. - * @param requesterId 팔로잉 피드에만 필요하다. + * @param hashtag 값이 있으면 이 해시태그가 달린 게시물만 반환한다. + * @param requesterId 팔로잉 피드에 필요하며, 차단한 사용자를 걸러내는 데도 쓴다. */ @Transactional(readOnly = true) public PostSummaryListResponse getFeed( @@ -107,6 +116,7 @@ public PostSummaryListResponse getFeed( Integer size, boolean following, Long placeId, + String hashtag, Long requesterId ) { if (following && requesterId == null) { @@ -118,12 +128,29 @@ public PostSummaryListResponse getFeed( postRepository.findFeed( following ? requesterId : null, placeId, + hashtag == null || hashtag.isBlank() ? null : hashtag.trim().toLowerCase(), + requesterId, cursor == null ? FIRST_PAGE_CURSOR : cursor, PageRequest.of(0, limit)), limit, requesterId); } + /** 탐색 탭. 최근 {@value #POPULAR_FEED_DAYS}일 게시물을 좋아요·댓글 기준으로 정렬한다. */ + @Transactional(readOnly = true) + public PostSummaryListResponse getPopularFeed(Integer page, Integer size, Long requesterId) { + int resolvedPage = page == null || page < 0 ? 0 : page; + int limit = resolveFeedSize(size); + List posts = postRepository.findPopularFeed( + LocalDateTime.now().minusDays(POPULAR_FEED_DAYS), + requesterId, + PageRequest.of(resolvedPage, limit)); + + // 점수 기준 정렬이라 이어받을 커서가 없다. 다음 페이지는 page 를 올려 요청한다. + return new PostSummaryListResponse( + postSummaryAssembler.assemble(posts, requesterId), null); + } + @Transactional(readOnly = true) public PostSummaryListResponse getUserPosts( Long userId, Long cursor, Integer size, Long requesterId) { @@ -151,6 +178,7 @@ private PostSummaryListResponse toSummaryList(List posts, int limit, Long public PostDetailResponse update(Long postId, Long userId, PostUpdateRequest request) { Post post = findWritablePost(postId, userId); post.updateContent(request.content()); + hashtagService.reattachFromContent(post, request.content()); List postIds = List.of(postId); return PostDetailResponse.from( @@ -168,6 +196,7 @@ public PostDetailResponse update(Long postId, Long userId, PostUpdateRequest req @Transactional public void delete(Long postId, Long userId) { findWritablePost(postId, userId).delete(); + hashtagService.detachFromPost(postId); } private Post findWritablePost(Long postId, Long userId) { diff --git a/src/main/java/com/server/report/controller/ReportController.java b/src/main/java/com/server/report/controller/ReportController.java new file mode 100644 index 0000000..cba425b --- /dev/null +++ b/src/main/java/com/server/report/controller/ReportController.java @@ -0,0 +1,44 @@ +package com.server.report.controller; + +import com.server.report.dto.ReportCreateRequest; +import com.server.report.dto.ReportResponse; +import com.server.report.service.ReportService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/reports") +@Tag(name = "커뮤니티 신고", description = "게시물·댓글·사용자 신고 접수") +public class ReportController { + + private final ReportService reportService; + + public ReportController(ReportService reportService) { + this.reportService = reportService; + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + @Operation( + summary = "신고", + description = "접수만 한다. 처리 상태를 바꾸는 관리자 기능은 아직 없다. " + + "같은 대상을 다시 신고하면 409 를 반환한다." + ) + public ReportResponse report( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "신고자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody ReportCreateRequest request + ) { + return reportService.report(userId, request); + } +} diff --git a/src/main/java/com/server/report/domain/Report.java b/src/main/java/com/server/report/domain/Report.java new file mode 100644 index 0000000..28053c1 --- /dev/null +++ b/src/main/java/com/server/report/domain/Report.java @@ -0,0 +1,93 @@ +package com.server.report.domain; + +import com.server.post.domain.ReportStatus; +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +/** + * 신고 한 건. + * + *

{@code targetId}에는 외래키가 없다. 대상이 게시물·댓글·사용자로 달라져 한 테이블을 + * 가리킬 수 없기 때문이다. 대상이 실제로 있는지는 서비스에서 확인한다. + */ +@Entity +@Table(name = "reports") +public class Report { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private ReportTargetType targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(nullable = false, columnDefinition = "text") + private String reason; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportStatus status; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected Report() { + } + + public Report(User reporter, ReportTargetType targetType, Long targetId, String reason) { + this.reporter = reporter; + this.targetType = targetType; + this.targetId = targetId; + this.reason = reason; + this.status = ReportStatus.PENDING; + this.createdAt = LocalDateTime.now(); + } + + public Long getId() { + return id; + } + + public User getReporter() { + return reporter; + } + + public ReportTargetType getTargetType() { + return targetType; + } + + public Long getTargetId() { + return targetId; + } + + public String getReason() { + return reason; + } + + public ReportStatus getStatus() { + return status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/report/domain/ReportTargetType.java b/src/main/java/com/server/report/domain/ReportTargetType.java new file mode 100644 index 0000000..d589152 --- /dev/null +++ b/src/main/java/com/server/report/domain/ReportTargetType.java @@ -0,0 +1,7 @@ +package com.server.report.domain; + +public enum ReportTargetType { + POST, + COMMENT, + USER +} diff --git a/src/main/java/com/server/report/dto/ReportCreateRequest.java b/src/main/java/com/server/report/dto/ReportCreateRequest.java new file mode 100644 index 0000000..543d3b8 --- /dev/null +++ b/src/main/java/com/server/report/dto/ReportCreateRequest.java @@ -0,0 +1,21 @@ +package com.server.report.dto; + +import com.server.report.domain.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +@Schema(description = "신고 요청. 같은 대상은 한 번만 신고할 수 있다.") +public record ReportCreateRequest( + @Schema(description = "신고 대상 종류", example = "POST") + @NotNull ReportTargetType targetType, + + @Schema(description = "신고 대상 ID", example = "7") + @NotNull Long targetId, + + @Schema(description = "신고 사유", example = "광고성 게시물입니다") + @NotBlank @Size(max = 500) String reason +) { +} diff --git a/src/main/java/com/server/report/dto/ReportResponse.java b/src/main/java/com/server/report/dto/ReportResponse.java new file mode 100644 index 0000000..7cb3901 --- /dev/null +++ b/src/main/java/com/server/report/dto/ReportResponse.java @@ -0,0 +1,27 @@ +package com.server.report.dto; + +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "접수된 신고") +public record ReportResponse( + @Schema(example = "1") Long id, + @Schema(example = "POST") ReportTargetType targetType, + @Schema(example = "7") Long targetId, + @Schema(description = "처리 상태. 접수 시점에는 PENDING 이다.", example = "PENDING") + String status, + @Schema(example = "2026-08-24T03:40:00") LocalDateTime createdAt +) { + + public static ReportResponse from(Report report) { + return new ReportResponse( + report.getId(), + report.getTargetType(), + report.getTargetId(), + report.getStatus().name(), + report.getCreatedAt()); + } +} diff --git a/src/main/java/com/server/report/repository/ReportRepository.java b/src/main/java/com/server/report/repository/ReportRepository.java new file mode 100644 index 0000000..4f53550 --- /dev/null +++ b/src/main/java/com/server/report/repository/ReportRepository.java @@ -0,0 +1,11 @@ +package com.server.report.repository; + +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId( + Long reporterId, ReportTargetType targetType, Long targetId); +} diff --git a/src/main/java/com/server/report/service/ReportService.java b/src/main/java/com/server/report/service/ReportService.java new file mode 100644 index 0000000..d29ca6a --- /dev/null +++ b/src/main/java/com/server/report/service/ReportService.java @@ -0,0 +1,68 @@ +package com.server.report.service; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.repository.CommentRepository; +import com.server.post.repository.PostRepository; +import com.server.report.domain.Report; +import com.server.report.domain.ReportTargetType; +import com.server.report.dto.ReportCreateRequest; +import com.server.report.dto.ReportResponse; +import com.server.report.repository.ReportRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class ReportService { + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + + public ReportService( + ReportRepository reportRepository, + PostRepository postRepository, + CommentRepository commentRepository, + UserRepository userRepository + ) { + this.reportRepository = reportRepository; + this.postRepository = postRepository; + this.commentRepository = commentRepository; + this.userRepository = userRepository; + } + + @Transactional + public ReportResponse report(Long reporterId, ReportCreateRequest request) { + User reporter = userRepository.findByIdAndDeletedAtIsNull(reporterId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + requireTargetExists(request.targetType(), request.targetId()); + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId( + reporterId, request.targetType(), request.targetId())) { + throw new BusinessException(ErrorCode.ALREADY_REPORTED); + } + + Report report = reportRepository.save(new Report( + reporter, request.targetType(), request.targetId(), request.reason())); + return ReportResponse.from(report); + } + + /** {@code target_id}에 외래키가 없으므로 대상이 실제로 있는지 여기서 확인한다. */ + private void requireTargetExists(ReportTargetType targetType, Long targetId) { + boolean exists = switch (targetType) { + case POST -> postRepository.existsByIdAndDeletedAtIsNull(targetId); + case COMMENT -> commentRepository.findByIdAndDeletedAtIsNull(targetId).isPresent(); + case USER -> userRepository.findByIdAndDeletedAtIsNull(targetId).isPresent(); + }; + if (!exists) { + throw new BusinessException(switch (targetType) { + case POST -> ErrorCode.POST_NOT_FOUND; + case COMMENT -> ErrorCode.COMMENT_NOT_FOUND; + case USER -> ErrorCode.USER_NOT_FOUND; + }); + } + } +} From 0435805d266fedb6c701f46cebf365cd396191cc Mon Sep 17 00:00:00 2001 From: sususuj Date: Mon, 24 Aug 2026 18:33:26 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat:=20=EB=8C=93=EA=B8=80=20=EC=A2=8B?= =?UTF-8?q?=EC=95=84=EC=9A=94=EC=99=80=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=82=AC=EC=A7=84=20=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/CommentController.java | 32 +++++++- .../com/server/post/domain/CommentLike.java | 53 +++++++++++++ .../com/server/post/domain/CommentLikeId.java | 35 +++++++++ .../server/post/dto/CommentLikeResponse.java | 11 +++ .../com/server/post/dto/CommentResponse.java | 13 +++- .../repository/CommentLikeRepository.java | 24 ++++++ .../post/repository/CommentRepository.java | 15 ++++ .../server/post/service/CommentService.java | 75 ++++++++++++++++--- .../user/controller/MyProfileController.java | 26 +++++++ .../java/com/server/user/domain/User.java | 8 ++ .../user/dto/ProfileImageUpdateRequest.java | 16 ++++ .../com/server/user/service/UserService.java | 21 +++++- 12 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/server/post/domain/CommentLike.java create mode 100644 src/main/java/com/server/post/domain/CommentLikeId.java create mode 100644 src/main/java/com/server/post/dto/CommentLikeResponse.java create mode 100644 src/main/java/com/server/post/repository/CommentLikeRepository.java create mode 100644 src/main/java/com/server/user/dto/ProfileImageUpdateRequest.java diff --git a/src/main/java/com/server/post/controller/CommentController.java b/src/main/java/com/server/post/controller/CommentController.java index e3910a7..0bb8b2e 100644 --- a/src/main/java/com/server/post/controller/CommentController.java +++ b/src/main/java/com/server/post/controller/CommentController.java @@ -1,6 +1,7 @@ package com.server.post.controller; import com.server.post.dto.CommentCreateRequest; +import com.server.post.dto.CommentLikeResponse; import com.server.post.dto.CommentListResponse; import com.server.post.dto.CommentResponse; import com.server.post.service.CommentService; @@ -78,8 +79,35 @@ public CommentListResponse getComments( @Parameter(description = "이전 응답의 nextCursor. 첫 페이지는 생략한다.", example = "12") @RequestParam(required = false) Long cursor, @Parameter(description = "한 번에 가져올 댓글 수. 1 이상 50 이하", example = "20") - @RequestParam(defaultValue = "20") @Min(1) Integer size + @RequestParam(defaultValue = "20") @Min(1) Integer size, + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 없으면 좋아요 여부가 false 로 나간다.", example = "1") + @RequestHeader(value = "X-User-Id", required = false) Long requesterId + ) { + return commentService.getComments(postId, cursor, size, requesterId); + } + + @PostMapping("/{commentId}/likes") + @Operation(summary = "댓글 좋아요", description = "이미 누른 상태에서 다시 요청해도 개수가 늘지 않는다.") + public CommentLikeResponse like( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "1") @PathVariable Long postId, + @Parameter(example = "3") @PathVariable Long commentId + ) { + return commentService.like(postId, commentId, userId); + } + + @DeleteMapping("/{commentId}/likes") + @Operation(summary = "댓글 좋아요 취소", description = "누른 적 없는 상태에서 요청해도 개수가 줄지 않는다.") + public CommentLikeResponse unlike( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Parameter(example = "1") @PathVariable Long postId, + @Parameter(example = "3") @PathVariable Long commentId ) { - return commentService.getComments(postId, cursor, size); + return commentService.unlike(postId, commentId, userId); } } diff --git a/src/main/java/com/server/post/domain/CommentLike.java b/src/main/java/com/server/post/domain/CommentLike.java new file mode 100644 index 0000000..2881273 --- /dev/null +++ b/src/main/java/com/server/post/domain/CommentLike.java @@ -0,0 +1,53 @@ +package com.server.post.domain; + +import com.server.user.domain.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "comment_likes") +@IdClass(CommentLikeId.class) +public class CommentLike { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private Comment comment; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + protected CommentLike() { + } + + public CommentLike(Comment comment, User user) { + this.comment = comment; + this.user = user; + this.createdAt = LocalDateTime.now(); + } + + public Comment getComment() { + return comment; + } + + public User getUser() { + return user; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } +} diff --git a/src/main/java/com/server/post/domain/CommentLikeId.java b/src/main/java/com/server/post/domain/CommentLikeId.java new file mode 100644 index 0000000..2c3a8f6 --- /dev/null +++ b/src/main/java/com/server/post/domain/CommentLikeId.java @@ -0,0 +1,35 @@ +package com.server.post.domain; + +import java.io.Serializable; +import java.util.Objects; + +/** {@link CommentLike}의 복합 기본키. 같은 댓글에 두 번 좋아요를 누를 수 없다. */ +public class CommentLikeId implements Serializable { + + private Long comment; + private Long user; + + protected CommentLikeId() { + } + + public CommentLikeId(Long comment, Long user) { + this.comment = comment; + this.user = user; + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + if (!(other instanceof CommentLikeId that)) { + return false; + } + return Objects.equals(comment, that.comment) && Objects.equals(user, that.user); + } + + @Override + public int hashCode() { + return Objects.hash(comment, user); + } +} diff --git a/src/main/java/com/server/post/dto/CommentLikeResponse.java b/src/main/java/com/server/post/dto/CommentLikeResponse.java new file mode 100644 index 0000000..3497f9e --- /dev/null +++ b/src/main/java/com/server/post/dto/CommentLikeResponse.java @@ -0,0 +1,11 @@ +package com.server.post.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "댓글 좋아요 처리 결과") +public record CommentLikeResponse( + @Schema(description = "처리 후 좋아요 수", example = "3") int likeCount, + @Schema(description = "요청한 사용자가 이 댓글에 좋아요를 누른 상태인지", example = "true") + boolean liked +) { +} diff --git a/src/main/java/com/server/post/dto/CommentResponse.java b/src/main/java/com/server/post/dto/CommentResponse.java index 888ec0e..5218fed 100644 --- a/src/main/java/com/server/post/dto/CommentResponse.java +++ b/src/main/java/com/server/post/dto/CommentResponse.java @@ -15,6 +15,9 @@ public record CommentResponse( example = "저도 여기 가봤는데 좋았어요") String content, @Schema(example = "2") int likeCount, + @Schema(description = "요청자가 좋아요를 누른 상태인지. 요청자를 알 수 없으면 false 다.", + example = "false") + boolean liked, @Schema(example = "2026-08-18T04:02:00") LocalDateTime createdAt, @Schema(description = "삭제된 댓글인지. 답글이 남아 있어 자리만 유지하는 경우 true 다.", example = "false") @@ -23,10 +26,11 @@ public record CommentResponse( List replies ) { - public static CommentResponse from(Comment comment, List replies) { + public static CommentResponse from(Comment comment, boolean liked, List replies) { if (comment.getDeletedAt() != null) { // 삭제된 댓글은 답글을 매달 자리만 남기고 작성자와 내용을 감춘다. - return new CommentResponse(comment.getId(), null, null, 0, comment.getCreatedAt(), true, replies); + return new CommentResponse( + comment.getId(), null, null, 0, false, comment.getCreatedAt(), true, replies); } return new CommentResponse( comment.getId(), @@ -36,12 +40,13 @@ public static CommentResponse from(Comment comment, List replie comment.getUser().getProfileImageUrl()), comment.getContent(), comment.getLikeCount(), + liked, comment.getCreatedAt(), false, replies); } - public static CommentResponse from(Comment comment) { - return from(comment, List.of()); + public static CommentResponse from(Comment comment, boolean liked) { + return from(comment, liked, List.of()); } } diff --git a/src/main/java/com/server/post/repository/CommentLikeRepository.java b/src/main/java/com/server/post/repository/CommentLikeRepository.java new file mode 100644 index 0000000..e5efb2d --- /dev/null +++ b/src/main/java/com/server/post/repository/CommentLikeRepository.java @@ -0,0 +1,24 @@ +package com.server.post.repository; + +import com.server.post.domain.CommentLike; +import com.server.post.domain.CommentLikeId; +import java.util.Collection; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface CommentLikeRepository extends JpaRepository { + + boolean existsByCommentIdAndUserId(Long commentId, Long userId); + + long deleteByCommentIdAndUserId(Long commentId, Long userId); + + /** 댓글 목록에 좋아요 여부를 표시할 때, 댓글 수와 무관하게 한 번만 조회한다. */ + @Query(""" + select commentLike.comment.id from CommentLike commentLike + where commentLike.user.id = :userId and commentLike.comment.id in :commentIds + """) + List findLikedCommentIds( + @Param("userId") Long userId, @Param("commentIds") Collection commentIds); +} diff --git a/src/main/java/com/server/post/repository/CommentRepository.java b/src/main/java/com/server/post/repository/CommentRepository.java index a3fec34..fc08564 100644 --- a/src/main/java/com/server/post/repository/CommentRepository.java +++ b/src/main/java/com/server/post/repository/CommentRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -38,4 +39,18 @@ List findTopLevelComments( /** 부모 댓글 여러 건의 대댓글을 한 번에 읽는다. */ @EntityGraph(attributePaths = "user") List findByParentIdInAndDeletedAtIsNullOrderByIdAsc(Collection parentIds); + + /** 게시물 좋아요와 같은 이유로 DB 에서 직접 증감시킨다. */ + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Comment comment set comment.likeCount = comment.likeCount + 1 " + + "where comment.id = :commentId") + void increaseLikeCount(@Param("commentId") Long commentId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("update Comment comment set comment.likeCount = comment.likeCount - 1 " + + "where comment.id = :commentId and comment.likeCount > 0") + void decreaseLikeCount(@Param("commentId") Long commentId); + + @Query("select comment.likeCount from Comment comment where comment.id = :commentId") + int findLikeCountById(@Param("commentId") Long commentId); } diff --git a/src/main/java/com/server/post/service/CommentService.java b/src/main/java/com/server/post/service/CommentService.java index 8c8fe55..500b8ab 100644 --- a/src/main/java/com/server/post/service/CommentService.java +++ b/src/main/java/com/server/post/service/CommentService.java @@ -3,16 +3,21 @@ import com.server.common.error.BusinessException; import com.server.common.error.ErrorCode; import com.server.post.domain.Comment; +import com.server.post.domain.CommentLike; import com.server.post.domain.Post; import com.server.post.dto.CommentCreateRequest; +import com.server.post.dto.CommentLikeResponse; import com.server.post.dto.CommentListResponse; import com.server.post.dto.CommentResponse; +import com.server.post.repository.CommentLikeRepository; import com.server.post.repository.CommentRepository; import com.server.post.repository.PostRepository; import com.server.user.domain.User; import com.server.user.repository.UserRepository; +import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -27,15 +32,18 @@ public class CommentService { private static final long FIRST_PAGE_CURSOR = 0L; private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; private final PostRepository postRepository; private final UserRepository userRepository; public CommentService( CommentRepository commentRepository, + CommentLikeRepository commentLikeRepository, PostRepository postRepository, UserRepository userRepository ) { this.commentRepository = commentRepository; + this.commentLikeRepository = commentLikeRepository; this.postRepository = postRepository; this.userRepository = userRepository; } @@ -51,11 +59,12 @@ public CommentResponse create(Long postId, Long userId, CommentCreateRequest req Comment comment = commentRepository.save(new Comment(post, author, parent, request.content())); postRepository.increaseCommentCount(postId); - return CommentResponse.from(comment); + return CommentResponse.from(comment, false); } @Transactional(readOnly = true) - public CommentListResponse getComments(Long postId, Long cursor, Integer size) { + public CommentListResponse getComments( + Long postId, Long cursor, Integer size, Long requesterId) { if (!postRepository.existsByIdAndDeletedAtIsNull(postId)) { throw new BusinessException(ErrorCode.POST_NOT_FOUND); } @@ -71,15 +80,25 @@ public CommentListResponse getComments(Long postId, Long cursor, Integer size) { } List parentIds = parents.stream().map(Comment::getId).toList(); - Map> repliesByParent = commentRepository - .findByParentIdInAndDeletedAtIsNullOrderByIdAsc(parentIds).stream() + List replies = commentRepository + .findByParentIdInAndDeletedAtIsNullOrderByIdAsc(parentIds); + + // 부모와 답글의 좋아요 여부를 한 번에 읽는다. + List allIds = new ArrayList<>(parentIds); + replies.forEach(reply -> allIds.add(reply.getId())); + Set likedIds = likedCommentIds(requesterId, allIds); + + Map> repliesByParent = replies.stream() .collect(Collectors.groupingBy( reply -> reply.getParent().getId(), - Collectors.mapping(CommentResponse::from, Collectors.toList()))); + Collectors.mapping( + reply -> CommentResponse.from(reply, likedIds.contains(reply.getId())), + Collectors.toList()))); List items = parents.stream() .map(parent -> CommentResponse.from( parent, + likedIds.contains(parent.getId()), repliesByParent.getOrDefault(parent.getId(), List.of()))) .toList(); @@ -87,17 +106,53 @@ public CommentListResponse getComments(Long postId, Long cursor, Integer size) { return new CommentListResponse(items, nextCursor); } - /** - * 물리 삭제하지 않는다. 답글이 달린 댓글이어도 답글은 그대로 두고, - * 목록에서는 작성자와 내용을 감춘 자리만 남는다. - */ + /** 이미 눌린 좋아요를 다시 눌러도 개수가 늘지 않는다. */ @Transactional - public void delete(Long postId, Long commentId, Long userId) { + public CommentLikeResponse like(Long postId, Long commentId, Long userId) { + Comment comment = findComment(postId, commentId); + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + + if (!commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)) { + commentLikeRepository.save(new CommentLike(comment, user)); + commentRepository.increaseLikeCount(commentId); + } + return new CommentLikeResponse(commentRepository.findLikeCountById(commentId), true); + } + + /** 누른 적 없는 좋아요를 취소해도 개수가 줄지 않는다. */ + @Transactional + public CommentLikeResponse unlike(Long postId, Long commentId, Long userId) { + findComment(postId, commentId); + if (commentLikeRepository.deleteByCommentIdAndUserId(commentId, userId) > 0) { + commentRepository.decreaseLikeCount(commentId); + } + return new CommentLikeResponse(commentRepository.findLikeCountById(commentId), false); + } + + private Comment findComment(Long postId, Long commentId) { Comment comment = commentRepository.findByIdAndDeletedAtIsNull(commentId) .orElseThrow(() -> new BusinessException(ErrorCode.COMMENT_NOT_FOUND)); if (!comment.getPost().getId().equals(postId)) { throw new BusinessException(ErrorCode.COMMENT_NOT_FOUND); } + return comment; + } + + private Set likedCommentIds(Long requesterId, List commentIds) { + if (requesterId == null || commentIds.isEmpty()) { + return Set.of(); + } + return Set.copyOf(commentLikeRepository.findLikedCommentIds(requesterId, commentIds)); + } + + /** + * 물리 삭제하지 않는다. 답글이 달린 댓글이어도 답글은 그대로 두고, + * 목록에서는 작성자와 내용을 감춘 자리만 남는다. + */ + @Transactional + public void delete(Long postId, Long commentId, Long userId) { + Comment comment = findComment(postId, commentId); if (!comment.isWrittenBy(userId)) { throw new BusinessException(ErrorCode.COMMENT_ACCESS_DENIED); } diff --git a/src/main/java/com/server/user/controller/MyProfileController.java b/src/main/java/com/server/user/controller/MyProfileController.java index b52a165..e133648 100644 --- a/src/main/java/com/server/user/controller/MyProfileController.java +++ b/src/main/java/com/server/user/controller/MyProfileController.java @@ -1,12 +1,14 @@ package com.server.user.controller; import com.server.user.dto.NicknameUpdateRequest; +import com.server.user.dto.ProfileImageUpdateRequest; import com.server.user.dto.UserProfileResponse; import com.server.user.service.UserService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -42,4 +44,28 @@ public UserProfileResponse changeNickname( ) { return userService.changeNickname(userId, request); } + + @PatchMapping("/profile-image") + @Operation( + summary = "프로필 사진 변경", + description = "이미 업로드된 URL 을 전달한다. 사진을 지우려면 DELETE 를 쓴다." + ) + public UserProfileResponse changeProfileImage( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId, + @Valid @RequestBody ProfileImageUpdateRequest request + ) { + return userService.changeProfileImage(userId, request); + } + + @DeleteMapping("/profile-image") + @Operation(summary = "프로필 사진 제거", description = "기본 이미지 상태로 되돌린다.") + public UserProfileResponse removeProfileImage( + // TODO: 인증 도입 시 제거하고 인증 주체에서 사용자 ID 를 받는다. 임시 식별 수단이다. + @Parameter(description = "요청자 ID. 인증 도입 전까지 쓰는 임시 헤더다.", example = "1") + @RequestHeader("X-User-Id") Long userId + ) { + return userService.removeProfileImage(userId); + } } diff --git a/src/main/java/com/server/user/domain/User.java b/src/main/java/com/server/user/domain/User.java index 9e01edb..6c1882e 100644 --- a/src/main/java/com/server/user/domain/User.java +++ b/src/main/java/com/server/user/domain/User.java @@ -65,6 +65,14 @@ public void changeNickname(String nickname) { this.nickname = nickname; } + public void changeProfileImage(String profileImageUrl) { + this.profileImageUrl = profileImageUrl; + } + + public void removeProfileImage() { + this.profileImageUrl = null; + } + public void delete(LocalDateTime deletedAt) { this.deletedAt = deletedAt; } diff --git a/src/main/java/com/server/user/dto/ProfileImageUpdateRequest.java b/src/main/java/com/server/user/dto/ProfileImageUpdateRequest.java new file mode 100644 index 0000000..6df6d9e --- /dev/null +++ b/src/main/java/com/server/user/dto/ProfileImageUpdateRequest.java @@ -0,0 +1,16 @@ +package com.server.user.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +import jakarta.validation.constraints.NotBlank; + +/** + * 사진을 지우는 것과 그대로 두는 것을 한 요청으로 구분할 수 없어, 제거는 별도 DELETE 로 나눈다. + */ +@Schema(description = "프로필 사진 변경 요청. 이미 업로드된 URL 을 전달한다.") +public record ProfileImageUpdateRequest( + @Schema(description = "바꿀 프로필 사진 URL", + example = "https://example.com/profile/1.jpg") + @NotBlank String profileImageUrl +) { +} diff --git a/src/main/java/com/server/user/service/UserService.java b/src/main/java/com/server/user/service/UserService.java index 4e4afdc..93d1082 100644 --- a/src/main/java/com/server/user/service/UserService.java +++ b/src/main/java/com/server/user/service/UserService.java @@ -7,6 +7,7 @@ import com.server.post.repository.PostRepository; import com.server.user.domain.User; import com.server.user.dto.NicknameUpdateRequest; +import com.server.user.dto.ProfileImageUpdateRequest; import com.server.user.dto.UserProfileResponse; import com.server.user.dto.UserSearchListResponse; import com.server.user.repository.UserRepository; @@ -50,11 +51,22 @@ public UserSearchListResponse search(String keyword, Integer page, Integer size) return new UserSearchListResponse(items); } + @Transactional + public UserProfileResponse changeProfileImage(Long userId, ProfileImageUpdateRequest request) { + findActiveUser(userId).changeProfileImage(request.profileImageUrl()); + return getProfile(userId, userId); + } + + @Transactional + public UserProfileResponse removeProfileImage(Long userId) { + findActiveUser(userId).removeProfileImage(); + return getProfile(userId, userId); + } + /** 이미 쓰는 사람이 있는 닉네임이면 거절한다. 자기 닉네임을 그대로 보내는 건 허용한다. */ @Transactional public UserProfileResponse changeNickname(Long userId, NicknameUpdateRequest request) { - User user = userRepository.findByIdAndDeletedAtIsNull(userId) - .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + User user = findActiveUser(userId); if (!user.getNickname().equals(request.nickname()) && userRepository.existsByNicknameAndDeletedAtIsNull(request.nickname())) { @@ -87,6 +99,11 @@ public UserProfileResponse getProfile(Long userId, Long requesterId) { userId.equals(requesterId)); } + private User findActiveUser(Long userId) { + return userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + private PageRequest pageRequest(Integer page, Integer size) { int resolvedPage = page == null || page < 0 ? 0 : page; int resolvedSize = size == null || size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); From 9e1327c8d61cc5775d8f4e25056dc5286d82d6f4 Mon Sep 17 00:00:00 2001 From: sususuj Date: Mon, 24 Aug 2026 18:33:40 +0900 Subject: [PATCH 11/12] =?UTF-8?q?docs:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20ERD=EC=99=80=20API=20=EB=AA=85=EC=84=B8=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/API_SPEC.md | 443 ++++++++++++++++++++++++++++++++++++++++- docs/ERD.md | 241 +++++++++++++++++++++- docs/api-change-log.md | 44 ++++ 3 files changed, 726 insertions(+), 2 deletions(-) diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md index b277d22..b20e981 100644 --- a/docs/API_SPEC.md +++ b/docs/API_SPEC.md @@ -12,10 +12,14 @@ | --- | --- | | Base URL | `/api/v1` | | Content Type | `application/json; charset=utf-8` | -| 인증 | 1차 스프린트에서는 인증 없음 | +| 인증 | 아직 없음. 커뮤니티 API는 `X-User-Id` 헤더로 요청자를 임시 식별한다 | | 좌표 | WGS84, `longitude`는 경도, `latitude`는 위도 | | 향후 계획 | 사용자 도메인 도입 후 JWT와 사용자별 일정 조회 적용 | +> **`X-User-Id`는 임시 조치다.** 헤더 값을 바꾸면 다른 사용자를 사칭할 수 있으므로 인증 도입 시 +> 반드시 제거해야 한다. 요청 본문이 아니라 헤더로 받는 이유는, 조회 API에는 본문이 없어 방식이 +> 갈라지고, 나중에 `Authorization` 헤더로 교체할 때 요청 DTO를 건드리지 않아도 되기 때문이다. + ## 엔드포인트 | 기능 | Method | URI | 성공 상태 | @@ -45,6 +49,45 @@ | Preview 기반 일정 생성 | POST | `/schedules` | `201 Created` | 구현 | | 일정 단건 조회 | GET | `/schedules/{scheduleId}` | `200 OK` | 구현 | +### 커뮤니티 엔드포인트 + +계약 상세는 `커뮤니티 계약`을 본다. `요청자` 열의 `필수`는 `X-User-Id` 헤더가 없으면 요청이 +실패한다는 뜻이고, `선택`은 없어도 동작하되 좋아요·저장 여부가 `false`로 나간다는 뜻이다. + +| 기능 | Method | URI | 성공 상태 | 요청자 | +| --- | --- | --- | --- | --- | +| 게시물 작성 | POST | `/posts` | `201 Created` | 필수 | +| 피드 조회 | GET | `/posts` | `200 OK` | 선택 | +| 인기 피드 | GET | `/posts/popular` | `200 OK` | 선택 | +| 게시물 상세 | GET | `/posts/{postId}` | `200 OK` | 선택 | +| 게시물 수정 | PATCH | `/posts/{postId}` | `200 OK` | 필수 | +| 게시물 삭제 | DELETE | `/posts/{postId}` | `204 No Content` | 필수 | +| 좋아요 | POST | `/posts/{postId}/likes` | `200 OK` | 필수 | +| 좋아요 취소 | DELETE | `/posts/{postId}/likes` | `200 OK` | 필수 | +| 저장 | POST | `/posts/{postId}/bookmarks` | `200 OK` | 필수 | +| 저장 해제 | DELETE | `/posts/{postId}/bookmarks` | `200 OK` | 필수 | +| 내 저장 목록 | GET | `/users/me/bookmarks` | `200 OK` | 필수 | +| 댓글 작성 | POST | `/posts/{postId}/comments` | `201 Created` | 필수 | +| 댓글 목록 | GET | `/posts/{postId}/comments` | `200 OK` | 선택 | +| 댓글 삭제 | DELETE | `/posts/{postId}/comments/{commentId}` | `204 No Content` | 필수 | +| 댓글 좋아요 | POST | `/posts/{postId}/comments/{commentId}/likes` | `200 OK` | 필수 | +| 댓글 좋아요 취소 | DELETE | `/posts/{postId}/comments/{commentId}/likes` | `200 OK` | 필수 | +| 팔로우 | POST | `/users/{userId}/follows` | `200 OK` | 필수 | +| 팔로우 취소 | DELETE | `/users/{userId}/follows` | `200 OK` | 필수 | +| 팔로워 목록 | GET | `/users/{userId}/followers` | `200 OK` | 불필요 | +| 팔로잉 목록 | GET | `/users/{userId}/followings` | `200 OK` | 불필요 | +| 차단 | POST | `/users/{userId}/blocks` | `200 OK` | 필수 | +| 차단 해제 | DELETE | `/users/{userId}/blocks` | `200 OK` | 필수 | +| 내 차단 목록 | GET | `/users/me/blocks` | `200 OK` | 필수 | +| 프로필 조회 | GET | `/users/{userId}/profile` | `200 OK` | 선택 | +| 사용자 게시물 | GET | `/users/{userId}/posts` | `200 OK` | 선택 | +| 닉네임 변경 | PATCH | `/users/me/nickname` | `200 OK` | 필수 | +| 프로필 사진 변경 | PATCH | `/users/me/profile-image` | `200 OK` | 필수 | +| 프로필 사진 제거 | DELETE | `/users/me/profile-image` | `200 OK` | 필수 | +| 사용자 검색 | GET | `/users/search` | `200 OK` | 불필요 | +| 해시태그 자동완성 | GET | `/hashtags/search` | `200 OK` | 불필요 | +| 신고 | POST | `/reports` | `201 Created` | 필수 | + ## 1. 사전 질문 조회 `GET /api/v1/trip-questions` @@ -1231,6 +1274,404 @@ Provider 응답의 `distanceMeters`가 누락되거나 0 이하이면 서버는 별도 백엔드 API를 만들지 않는다. 프론트엔드에서 브라우저 인쇄, `jsPDF`, `html2canvas` 등을 사용해 생성한다. +## 커뮤니티 계약 + +### C-1. 공통 규칙 + +**요청자 식별** + +인증 도입 전까지 `X-User-Id` 헤더로 요청자를 전달한다. 위 `공통`의 경고를 함께 본다. + +**페이징이 두 가지다** + +| 방식 | 쓰는 곳 | 파라미터 | 응답 | +| --- | --- | --- | --- | +| 커서 | 피드, 사용자 게시물, 댓글 | `cursor`, `size` | `nextCursor` | +| 오프셋 | 인기 피드, 북마크, 팔로워·팔로잉, 차단, 검색 | `page`, `size` | 없음 | + +커서 방식은 응답의 `nextCursor`를 다음 요청의 `cursor`로 그대로 넘긴다. `null`이면 더 없다. +목록이 밀리지 않아 무한 스크롤에 적합하다. + +오프셋 방식은 정렬 기준이 증가하는 식별자가 아니어서 커서를 쓸 수 없는 곳에만 쓴다. 인기 피드는 +점수 기준, 나머지는 관계를 맺은 시각 기준이며 해당 테이블에 대리키가 없다. + +`size`는 1 이상 50 이하이며 기본값은 20이다. 해시태그 자동완성만 기본 10, 최대 30이다. + +**정렬 방향** + +| 대상 | 정렬 | +| --- | --- | +| 피드·사용자 게시물 | 최신순 (`id` 내림차순) | +| 댓글 | 오래된 순 (`id` 오름차순). 대화 흐름을 따라 읽기 위함 | +| 인기 피드 | 점수 내림차순 | +| 팔로워·팔로잉·차단·북마크 | 최근 순 | +| 사용자 검색 | 닉네임 가나다순 | + +**삭제 정책** + +게시물과 댓글은 `deleted_at`만 남기고 조회에서 제외한다. 삭제된 댓글에 살아 있는 답글이 있으면 +목록에서 자리를 유지하되 `author`와 `content`를 `null`로, `deleted`를 `true`로 반환한다. +화면 문구는 클라이언트가 정한다. + +### C-2. 게시물 작성 + +`POST /api/v1/posts` + +```json +{ + "content": "광안리 야경 보러 갔는데 날씨가 좋았어요 #광안리맛집 #부산", + "mediaList": [ + { "url": "https://example.com/media/1.jpg", "mediaType": "IMAGE", "sortOrder": 0 }, + { "url": "https://example.com/media/2.jpg", "mediaType": "IMAGE", "sortOrder": 1 } + ], + "placeTags": [ + { "placeId": 42, "latitude": 35.15320000, "longitude": 129.11860000 } + ] +} +``` + +| 필드 | 필수 | 설명 | +| --- | --- | --- | +| `content` | O | 본문. 해시태그를 본문 안에 함께 적는다 | +| `mediaList` | O | **최소 한 건.** 사진·영상 기반 서비스라 빈 게시물을 허용하지 않는다 | +| `mediaList[].mediaType` | O | `IMAGE`, `VIDEO` | +| `mediaList[].sortOrder` | O | 표시 순서. 0부터 | +| `placeTags` | X | 내부 `places`에 등록된 장소만 태그할 수 있다 | +| `placeTags[].latitude`·`longitude` | X | 사진 EXIF의 촬영 지점. 없으면 생략한다 | + +**파일 업로드는 서버가 하지 않는다.** 클라이언트가 저장소에 올린 뒤 URL만 전달한다. +**저장소와 업로드 API는 아직 정해지지 않았다.** + +해시태그는 본문에서 뽑는다. `#` 뒤의 한글·영문·숫자·밑줄만 인정하고 공백이나 그 밖의 문자에서 +끊는다. 여러 단어를 담으려면 붙여 쓴다(`#광안리맛집`). 소문자로 정규화하며 게시물당 20개까지다. + +응답은 `C-4`의 게시물 상세와 같은 형태다. + +### C-3. 피드 조회 + +`GET /api/v1/posts` + +| 파라미터 | 설명 | +| --- | --- | +| `cursor` | 이전 응답의 `nextCursor`. 첫 페이지는 생략 | +| `size` | 1~50, 기본 20 | +| `feed` | `following`이면 팔로우한 사람의 게시물만. 이때 `X-User-Id`가 필요하다 | +| `placeId` | 이 장소를 태그한 게시물만 | +| `hashtag` | 이 해시태그가 달린 게시물만. `#`은 빼고 보낸다 | + +`feed`, `placeId`, `hashtag`는 함께 쓸 수 있으며 모두 만족하는 게시물만 반환한다. +`X-User-Id`가 있으면 요청자가 차단한 사용자의 게시물을 제외한다. + +```json +{ + "items": [ + { + "id": 7, + "author": { "id": 1, "nickname": "감자", "profileImageUrl": "https://example.com/p/1.jpg" }, + "content": "광안리 야경 보러 갔는데 날씨가 좋았어요", + "thumbnailUrl": "https://example.com/media/1.jpg", + "mediaCount": 3, + "placeName": "광안리해수욕장", + "likeCount": 12, + "commentCount": 3, + "liked": true, + "bookmarked": false, + "createdAt": "2026-08-24T18:00:00" + } + ], + "nextCursor": 7 +} +``` + +목록은 축약 응답이다. 미디어는 `sortOrder`가 가장 앞선 한 건만 `thumbnailUrl`로 주고 나머지는 +개수만 준다. 장소는 대표 하나의 이름만 준다. 전체가 필요하면 상세를 조회한다. + +`liked`·`bookmarked`는 요청자 기준이다. `X-User-Id`가 없으면 둘 다 `false`다. `likeCount`는 +모두에게 같고 `liked`만 사람마다 다르다. + +### C-4. 게시물 상세 + +`GET /api/v1/posts/{postId}` + +```json +{ + "id": 7, + "author": { "id": 1, "nickname": "감자", "profileImageUrl": "https://example.com/p/1.jpg" }, + "content": "광안리 야경 보러 갔는데 날씨가 좋았어요", + "mediaList": [ + { "id": 3, "url": "https://example.com/media/1.jpg", "mediaType": "IMAGE", "sortOrder": 0 } + ], + "placeTags": [ + { + "placeId": 42, + "placeName": "광안리해수욕장", + "latitude": 35.15320000, + "longitude": 129.11860000 + } + ], + "likeCount": 12, + "commentCount": 3, + "liked": true, + "bookmarked": false, + "createdAt": "2026-08-24T18:00:00", + "updatedAt": "2026-08-24T18:00:00" +} +``` + +`mediaList`는 `sortOrder` 오름차순이다. `placeTags[].latitude`는 촬영 지점이며 알 수 없으면 +`null`이다. 지도에 표시할 좌표가 없으면 `placeId`로 장소 상세를 조회한다. + +### C-5. 게시물 수정·삭제 + +`PATCH /api/v1/posts/{postId}` + +```json +{ "content": "광안리 야경 진짜 좋았어요 #부산" } +``` + +**본문만 수정한다.** 첨부 미디어와 장소 태그 교체는 저장소에 올라간 파일 정리까지 함께 정해야 +하므로 후속 작업이다. 본문이 바뀌면 해시태그를 다시 계산한다. + +`DELETE /api/v1/posts/{postId}` — `204 No Content` + +바로 지우지 않고 삭제 표시만 남긴다. 기획상 30일간 복구할 수 있어야 한다. +**만료된 게시물을 정리하는 배치는 아직 없다.** + +둘 다 작성자 본인이 아니면 `403 POST_ACCESS_DENIED`를 반환한다. + +### C-6. 인기 피드 + +`GET /api/v1/posts/popular?page=0&size=20` + +최근 7일 게시물을 `좋아요 + 댓글×2` 점수 내림차순으로 반환한다. 응답 형태는 피드와 같으나 +`nextCursor`는 항상 `null`이다. 다음 페이지는 `page`를 올려 요청한다. + +기간을 두는 이유는 예전 인기글이 상단을 계속 차지하는 것을 막기 위함이다. **점수 계산식과 기간은 +확정된 정책이 아니다.** + +### C-7. 좋아요·저장 + +| 동작 | 요청 | +| --- | --- | +| 좋아요 | `POST /api/v1/posts/{postId}/likes` | +| 좋아요 취소 | `DELETE /api/v1/posts/{postId}/likes` | +| 저장 | `POST /api/v1/posts/{postId}/bookmarks` | +| 저장 해제 | `DELETE /api/v1/posts/{postId}/bookmarks` | + +```json +{ "likeCount": 13, "liked": true } +``` + +```json +{ "bookmarked": true } +``` + +**멱등하다.** 이미 누른 상태에서 다시 요청해도 개수가 늘지 않고, 누른 적 없는 상태에서 취소해도 +줄지 않는다. 클라이언트가 중복 요청을 보내도 안전하다. + +저장은 몇 명이 저장했는지 공개하지 않는다. 좋아요를 누른 사용자 목록도 제공하지 않는다. + +`GET /api/v1/users/me/bookmarks?page=0&size=20` — 저장한 게시물을 최근 저장 순으로 반환한다. +응답 항목은 피드와 같은 축약 형태다. 저장한 뒤 삭제된 게시물은 제외한다. + +### C-8. 댓글 + +`POST /api/v1/posts/{postId}/comments` + +```json +{ "content": "저도 여기 가봤는데 좋았어요", "parentId": 3 } +``` + +`parentId`를 주면 답글이 된다. **답글에 다시 답글을 달면 `400 INVALID_COMMENT_REQUEST`다.** +응답이 두 단계만 표현하기 때문이다. + +`GET /api/v1/posts/{postId}/comments?cursor=&size=20` + +```json +{ + "items": [ + { + "id": 3, + "author": { "id": 1, "nickname": "감자", "profileImageUrl": null }, + "content": "저도 여기 가봤는데 좋았어요", + "likeCount": 2, + "liked": false, + "createdAt": "2026-08-24T18:02:00", + "deleted": false, + "replies": [ + { + "id": 4, + "author": { "id": 2, "nickname": "고구마", "profileImageUrl": null }, + "content": "언제 가셨어요?", + "likeCount": 0, + "liked": false, + "createdAt": "2026-08-24T18:03:00", + "deleted": false, + "replies": [] + } + ] + } + ], + "nextCursor": null +} +``` + +최상위 댓글만 페이징하고 답글은 각 댓글에 모두 담는다. 답글의 `replies`는 항상 비어 있다. + +`DELETE /api/v1/posts/{postId}/comments/{commentId}` — `204 No Content`. +작성자 본인이 아니면 `403 COMMENT_ACCESS_DENIED`다. 경로의 `postId`와 댓글의 소속이 다르면 +`404 COMMENT_NOT_FOUND`를 반환한다. + +댓글 좋아요는 게시물 좋아요와 같은 계약이다. + +### C-9. 팔로우·차단 + +| 동작 | 요청 | +| --- | --- | +| 팔로우 | `POST /api/v1/users/{userId}/follows` | +| 팔로우 취소 | `DELETE /api/v1/users/{userId}/follows` | +| 차단 | `POST /api/v1/users/{userId}/blocks` | +| 차단 해제 | `DELETE /api/v1/users/{userId}/blocks` | + +```json +{ "followerCount": 152, "following": true } +``` + +```json +{ "blocked": true } +``` + +자기 자신을 팔로우하거나 차단하면 `400`이다. 팔로우도 멱등하다. + +**차단하면 서로의 팔로우가 끊긴다.** 차단을 풀어도 되살아나지 않으므로 필요하면 다시 팔로우한다. +차단한 사용자의 게시물은 모든 피드에서 제외된다. **상대가 내 게시물을 보는 것은 막지 않는다.** + +목록 조회는 오프셋 페이징이다. + +| 요청 | 설명 | +| --- | --- | +| `GET /users/{userId}/followers` | 이 사용자를 팔로우하는 사람들 | +| `GET /users/{userId}/followings` | 이 사용자가 팔로우하는 사람들 | +| `GET /users/me/blocks` | 내가 차단한 사람들 | + +```json +{ + "items": [ { "id": 1, "nickname": "감자", "profileImageUrl": null } ], + "totalCount": 152 +} +``` + +차단 목록에는 `totalCount`가 없다. + +### C-10. 프로필 + +`GET /api/v1/users/{userId}/profile` + +```json +{ + "id": 1, + "nickname": "감자", + "profileImageUrl": "https://example.com/p/1.jpg", + "postCount": 12, + "followerCount": 152, + "followingCount": 88, + "following": true, + "me": false +} +``` + +`following`은 요청자가 이 사용자를 팔로우 중인지, `me`는 본인 프로필인지다. `X-User-Id`가 없으면 +둘 다 `false`다. `me`가 `true`면 북마크 탭을 노출한다. + +`GET /api/v1/users/{userId}/posts` — 이 사용자의 게시물. 피드와 같은 커서 방식이다. + +| 요청 | 본문 | 설명 | +| --- | --- | --- | +| `PATCH /users/me/nickname` | `{ "nickname": "감자" }` | 최대 10자. 중복이면 `409` | +| `PATCH /users/me/profile-image` | `{ "profileImageUrl": "https://..." }` | 이미 업로드된 URL | +| `DELETE /users/me/profile-image` | — | 사진 제거 | + +사진 변경과 제거를 나눈 이유는, 한 요청으로는 "사진을 지운다"와 "사진은 그대로 두고 다른 값만 +바꾼다"를 구분할 수 없기 때문이다. 세 요청 모두 갱신된 프로필을 반환한다. + +### C-11. 검색 + +`GET /api/v1/users/search?keyword=감자&page=0&size=20` + +닉네임에 검색어가 포함된 사용자를 가나다순으로 반환한다. 대소문자를 구분하지 않는다. +**검색어가 비거나 공백뿐이면 빈 목록을 준다.** 전체 사용자 명단이 노출되지 않게 하기 위함이다. + +```json +{ "items": [ { "id": 1, "nickname": "감자", "profileImageUrl": null } ] } +``` + +`GET /api/v1/hashtags/search?keyword=광&size=10` + +앞글자로 시작하는 태그를 사용 수 내림차순으로 반환한다. `#`은 빼고 보낸다. + +```json +{ "items": [ { "name": "광안리맛집", "postCount": 1203 } ] } +``` + +태그에는 띄어쓰기를 넣을 수 없으므로, 사용자가 직접 입력하는 대신 여기서 골라 넣도록 하면 +표기가 갈라지지 않는다. 자주 쓰일 태그는 서버가 미리 등록해 둔다. + +### C-12. 신고 + +`POST /api/v1/reports` + +```json +{ "targetType": "POST", "targetId": 7, "reason": "광고성 게시물입니다" } +``` + +`targetType`은 `POST`, `COMMENT`, `USER`다. 대상이 없으면 각각 +`POST_NOT_FOUND`, `COMMENT_NOT_FOUND`, `USER_NOT_FOUND`를 반환한다. +같은 대상을 다시 신고하면 `409 ALREADY_REPORTED`다. + +```json +{ + "id": 1, + "targetType": "POST", + "targetId": 7, + "status": "PENDING", + "createdAt": "2026-08-24T18:10:00" +} +``` + +**접수만 한다. 신고를 확인하거나 처리 상태를 바꾸는 관리자 API는 아직 없다.** + +### C-13. 커뮤니티 오류 코드 + +| 코드 | 상태 | 발생 상황 | +| --- | --- | --- | +| `POST_NOT_FOUND` | 404 | 게시물이 없거나 삭제됨 | +| `COMMENT_NOT_FOUND` | 404 | 댓글이 없거나 삭제됨. 경로의 게시물과 소속이 다른 경우 포함 | +| `USER_NOT_FOUND` | 404 | 사용자가 없거나 탈퇴함 | +| `POST_ACCESS_DENIED` | 403 | 남의 게시물을 수정·삭제하려 함 | +| `COMMENT_ACCESS_DENIED` | 403 | 남의 댓글을 삭제하려 함 | +| `INVALID_COMMENT_REQUEST` | 400 | 답글에 답글을 달거나, 부모 댓글이 다른 게시물의 것 | +| `INVALID_FOLLOW_REQUEST` | 400 | 자기 자신을 팔로우 | +| `INVALID_BLOCK_REQUEST` | 400 | 자기 자신을 차단 | +| `INVALID_FEED_REQUEST` | 400 | 팔로잉 피드인데 `X-User-Id`가 없음 | +| `NICKNAME_ALREADY_USED` | 409 | 다른 사용자가 쓰는 닉네임 | +| `ALREADY_REPORTED` | 409 | 같은 대상을 다시 신고 | + +> **알려진 문제.** 요청 본문 검증에 실패하면 `GlobalExceptionHandler`가 URI로 오류 코드를 +> 고르는데 커뮤니티 경로가 없어 `INVALID_SCHEDULE_CONDITION`("일정 조건이 올바르지 않습니다")이 +> 나간다. `fieldErrors`는 정확하므로 클라이언트는 그쪽을 쓴다. 핸들러에 커뮤니티 분기를 추가하면 +> 해결된다. + +### C-14. 미구현 + +| 항목 | 상태 | +| --- | --- | +| 이미지·영상 업로드 | 저장소와 API 미정. 현재는 클라이언트가 올린 URL만 받는다 | +| 알림 | 테이블부터 없다. 기획서 필수 기능이나 데이터 모델이 정의되지 않았다 | +| 사진 EXIF 좌표 추출 | 클라이언트가 좌표를 직접 넘긴다 | +| 게시물 30일 후 완전 삭제 배치 | 없다 | +| 신고 처리 관리자 기능 | 없다 | +| 게시물 미디어·장소 태그 수정 | 본문만 수정 가능 | + ## 공통 오류 응답 ```json diff --git a/docs/ERD.md b/docs/ERD.md index 4bdbca3..653e8b3 100644 --- a/docs/ERD.md +++ b/docs/ERD.md @@ -2,8 +2,9 @@ > 구현 상태 안내 > -> - 1~13번 테이블과 `일정 생성 V2 변경`은 현재 구현 기준이다. +> - 1~26번 테이블과 `일정 생성 V2 변경`은 현재 구현 기준이다. > - V2 컬럼과 테이블은 `V4__schedule_generation_v2.sql`, 질문 화면 단계는 `V5__add_question_ui_step.sql`에서 추가한다. +> - 사용자(14번)는 `V6__create_users_table.sql`, 커뮤니티(15~26번)는 `V8__create_community_tables.sql`에서 추가한다. ## 표기 @@ -303,6 +304,231 @@ TourAPI의 일일 요청 제한을 서버 재시작과 중복 실행 이후에 | `revoked_at` | datetime | X | 폐기시각 | | `created_at` | datetime | O | 생성시각 | +## 14. users + +서비스 사용자다. 인증 도입 전이므로 로그인에 필요한 컬럼은 아직 없다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 서비스 내부 사용자 ID | +| `nickname` | varchar | O | 표시용 닉네임. 최대 10자는 요청 DTO에서 검증한다 | +| `profile_image_url` | text | X | 프로필 사진 URL | +| `created_at` | datetime | O | 가입시각 | +| `deleted_at` | datetime | X | 탈퇴 처리시각 | + +닉네임 고유 조건은 일반 unique 제약이 아니라 부분 고유 인덱스 +`uk_users_nickname_active(nickname) WHERE deleted_at IS NULL`이다. 탈퇴한 사용자가 쓰던 닉네임을 +다른 사용자가 다시 쓸 수 있어야 하기 때문이다. 부분 인덱스는 JPA로 표현할 수 없어 엔티티에는 +제약을 두지 않고 migration에서만 관리한다. + +탈퇴해도 행을 지우지 않는다. 댓글을 남기고 작성자만 감추는 정책이라 참조가 남아 있어야 한다. +인증 도입 시 추가될 개인정보 컬럼은 탈퇴 시 비우는 처리가 필요하다. + +## 15. posts + +커뮤니티 게시물이다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 게시물 ID | +| `user_id` | bigint | FK, O | 작성자 `users.id` | +| `content` | text | O | 본문. 해시태그를 본문 안에 함께 적는다 | +| `like_count` | integer | O | 좋아요 수. 조회 성능용 집계 컬럼 | +| `comment_count` | integer | O | 댓글 수. 조회 성능용 집계 컬럼 | +| `created_at` | datetime | O | 작성시각 | +| `updated_at` | datetime | O | 마지막 수정시각 | +| `deleted_at` | datetime | X | 삭제 표시시각 | + +`id`는 증가하는 정수다. 피드는 `id`를 커서로 쓰는 방식이라 순서가 있는 식별자가 필요하다. + +집계 컬럼은 엔티티를 읽어 고쳐 쓰지 않고 `UPDATE ... SET like_count = like_count + 1`처럼 +DB에서 직접 증감시킨다. 동시에 들어온 요청이 같은 값을 읽어 하나가 사라지는 것을 막기 위함이다. + +삭제는 `deleted_at`만 남긴다. 기획상 삭제 후 30일간 복구할 수 있어야 하므로 조회에서 제외하되 +행은 보존한다. **만료된 게시물을 정리하는 배치는 아직 없다.** + +인덱스: `idx_posts_created_at`, `idx_posts_user_id` + +## 16. post_media + +게시물에 첨부한 사진·영상이다. 게시물당 최소 한 건이 필요하며 요청 DTO에서 검증한다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 미디어 ID | +| `post_id` | bigint | FK, O | 소속 `posts.id` | +| `media_type` | varchar | O | `IMAGE`, `VIDEO` | +| `url` | text | O | 업로드된 파일 URL | +| `sort_order` | integer | O | 게시물 안의 표시 순서 | + +파일 업로드는 서버가 하지 않는다. 클라이언트가 저장소에 올린 뒤 URL만 전달한다. +**저장소 선정과 업로드 API는 미정이다.** + +인덱스: `idx_post_media_post_id` + +## 17. post_place_tags + +게시물에 태그한 장소다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 태그 ID | +| `post_id` | bigint | FK, O | 소속 `posts.id` | +| `place_id` | bigint | FK, O | 태그한 `places.id` | +| `latitude` | decimal | X | 사진이 실제로 촬영된 위도 | +| `longitude` | decimal | X | 사진이 실제로 촬영된 경도 | + +좌표는 장소 대표 좌표가 아니라 **사진 EXIF에서 얻은 촬영 지점**이다. `places`의 좌표를 복사하지 +않으며, EXIF가 없으면 `null`로 둔다. 값의 유무로 촬영 위치를 아는지가 구분된다. 화면에 표시할 +좌표가 없으면 `place_id`로 `places`에서 읽는다. + +인덱스: `idx_post_place_tags_post_id`, `idx_post_place_tags_place_id` + +## 18. comments + +게시물 댓글이다. 최상위 댓글과 답글 두 단계만 표현한다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 댓글 ID | +| `post_id` | bigint | FK, O | 소속 `posts.id` | +| `user_id` | bigint | FK, O | 작성자 `users.id` | +| `parent_id` | bigint | FK, X | 답글이면 부모 `comments.id`, 일반 댓글이면 `null` | +| `content` | text | O | 내용 | +| `like_count` | integer | O | 좋아요 수. 조회 성능용 집계 컬럼 | +| `created_at` | datetime | O | 작성시각 | +| `updated_at` | datetime | O | 마지막 수정시각 | +| `deleted_at` | datetime | X | 삭제 표시시각 | + +`parent_id`는 같은 테이블을 참조한다. 답글에 다시 답글을 다는 요청은 거절한다. + +삭제된 댓글에 살아 있는 답글이 있으면 목록에서 자리를 유지하고 작성자와 내용만 감춘다. +부모가 사라지면 답글이 화면에서 함께 없어지기 때문이다. 답글이 없으면 목록에서 제외한다. + +인덱스: `idx_comments_post_id`, `idx_comments_parent_id` + +## 19. post_likes + +게시물 좋아요다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `post_id` | bigint | PK 일부, FK, O | `posts.id` | +| `user_id` | bigint | PK 일부, FK, O | `users.id` | +| `created_at` | datetime | O | 누른 시각 | + +대리키 없이 `(post_id, user_id)`가 기본키다. 같은 사람이 같은 게시물에 두 번 누르는 것을 DB가 +막는다. 애플리케이션에서 확인하는 방식은 동시 요청에서 뚫린다. + +키 순서는 게시물 기준 조회가 많아 `post_id`가 앞이다. + +## 20. comment_likes + +댓글 좋아요다. 구조와 이유는 `post_likes`와 같다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `comment_id` | bigint | PK 일부, FK, O | `comments.id` | +| `user_id` | bigint | PK 일부, FK, O | `users.id` | +| `created_at` | datetime | O | 누른 시각 | + +## 21. bookmarks + +게시물 저장이다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `user_id` | bigint | PK 일부, FK, O | `users.id` | +| `post_id` | bigint | PK 일부, FK, O | `posts.id` | +| `created_at` | datetime | O | 저장한 시각 | + +`post_likes`와 달리 `user_id`가 키 앞에 온다. 주 용도가 "내 북마크 목록" 조회라 사용자 기준으로 +읽는 일이 많기 때문이다. 이 순서 덕분에 별도 인덱스가 필요 없다. + +몇 명이 저장했는지는 응답에 담지 않는다. + +## 22. follows + +팔로우 관계다. 방향이 있어 서로 팔로우하려면 두 행이 필요하다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `follower_id` | bigint | PK 일부, FK, O | 팔로우하는 `users.id` | +| `following_id` | bigint | PK 일부, FK, O | 팔로우당하는 `users.id` | +| `created_at` | datetime | O | 맺은 시각 | + +한 테이블이 `users`를 두 번 참조한다. 팔로워 목록과 팔로잉 목록이 모두 필요해 역방향 인덱스를 +둔다. 대리키가 없어 커서로 삼을 값이 없으므로 목록은 오프셋 페이징을 쓴다. + +팔로워 수는 집계 컬럼 없이 매번 센다. 현재 규모에서는 충분하며, 느려지면 `users`에 컬럼을 추가한다. + +인덱스: `idx_follows_following_id` + +## 23. blocks + +차단 관계다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `blocker_id` | bigint | PK 일부, FK, O | 차단한 `users.id` | +| `blocked_id` | bigint | PK 일부, FK, O | 차단당한 `users.id` | +| `created_at` | datetime | O | 차단한 시각 | + +차단하면 모든 피드에서 상대 게시물을 제외하고 서로의 팔로우를 끊는다. 차단을 풀어도 팔로우는 +되살리지 않는다. + +**상대가 내 게시물을 보는 것은 막지 않는다.** 역방향까지 막으려면 모든 조회에서 "상대가 나를 +차단했는지"를 확인해야 해 비용이 크다. + +## 24. hashtags + +해시태그다. 본문에서 뽑아 저장한다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 태그 ID | +| `name` | varchar | UK, O | `#`을 뺀 이름. 소문자로 정규화해 저장한다 | +| `post_count` | integer | O | 이 태그가 달린 게시물 수. 자동완성 정렬 기준 | + +`#` 뒤의 한글·영문·숫자·밑줄만 태그로 인정하고 공백이나 그 밖의 문자에서 끊는다. 여러 단어를 +담으려면 붙여 써야 한다. 게시물당 20개, 태그당 50자를 넘지 않는다. + +`post_count`는 게시물 작성·수정·삭제에 맞춰 증감시킨다. 자주 쓰일 태그는 +`HashtagSeedInitializer`가 미리 만들어 둔다. 처음 쓰는 태그를 동시에 저장하면 이름 고유 제약에 +걸려 요청 하나가 실패할 수 있는데, 미리 만들어 두면 그 상황을 줄일 수 있다. + +## 25. post_hashtags + +게시물과 해시태그의 연결이다. + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `post_id` | bigint | PK 일부, FK, O | `posts.id` | +| `hashtag_id` | bigint | PK 일부, FK, O | `hashtags.id` | + +게시물의 태그 조회와 태그별 게시물 조회가 모두 필요해 역방향 인덱스를 둔다. + +인덱스: `idx_post_hashtags_hashtag_id` + +## 26. reports + +신고다. 접수만 하며 **처리 상태를 바꾸는 관리자 기능은 아직 없다.** + +| 컬럼 | 자료형 | 키·필수 | 의미 | +| --- | --- | --- | --- | +| `id` | bigint | PK, O | 신고 ID | +| `reporter_id` | bigint | FK, O | 신고자 `users.id` | +| `target_type` | varchar | O | `POST`, `COMMENT`, `USER` | +| `target_id` | bigint | O | 신고 대상 ID | +| `reason` | text | O | 신고 사유 | +| `status` | varchar | O | `PENDING`, `RESOLVED` | +| `created_at` | datetime | O | 접수시각 | + +**`target_id`에는 외래키가 없다.** 대상이 게시물·댓글·사용자로 달라져 한 테이블을 가리킬 수 +없기 때문이다. 대상이 실제로 있는지는 애플리케이션에서 확인한다. + +같은 사용자가 같은 대상을 다시 신고하면 거절한다. + ## 일정 생성 V2 변경 ### V2-1. 기존 테이블 변경 @@ -475,6 +701,19 @@ Preview의 고정 행사 제약이 실제 일정의 방문지로 배치된 결 | `schedule_previews` 1 : N `schedule_creation_requests` | 한 Preview의 생성 시도와 멱등성 상태를 기록한다 | | `schedules` 1 : N `schedule_fixed_events` | 일정은 여러 고정 행사를 포함할 수 있다 | | `schedule_stops` 1 : 0..1 `schedule_fixed_events` | 방문 계획은 고정 행사와 최대 하나 연결된다 | +| `users` 1 : N `posts` | 사용자는 여러 게시물을 쓴다 | +| `posts` 1 : N `post_media` | 게시물은 사진·영상을 여러 개 가진다 | +| `posts` 1 : N `post_place_tags` | 게시물은 장소를 여러 개 태그할 수 있다 | +| `places` 1 : N `post_place_tags` | 장소는 여러 게시물에 태그된다 | +| `posts` 1 : N `comments` | 게시물은 여러 댓글을 가진다 | +| `comments` 1 : N `comments` | 댓글은 답글을 여러 개 가진다. 답글에는 답글이 없다 | +| `users` N : M `posts` (`post_likes`) | 좋아요. 같은 조합은 한 번만 | +| `users` N : M `comments` (`comment_likes`) | 댓글 좋아요. 같은 조합은 한 번만 | +| `users` N : M `posts` (`bookmarks`) | 저장. 같은 조합은 한 번만 | +| `users` N : M `users` (`follows`) | 팔로우. 방향이 있다 | +| `users` N : M `users` (`blocks`) | 차단. 방향이 있다 | +| `posts` N : M `hashtags` (`post_hashtags`) | 게시물의 해시태그 | +| `users` 1 : N `reports` | 사용자는 여러 건을 신고할 수 있다. 신고 대상은 FK로 연결하지 않는다 | ## DB에 저장하지 않는 데이터 diff --git a/docs/api-change-log.md b/docs/api-change-log.md index 74aeb7f..c673b89 100644 --- a/docs/api-change-log.md +++ b/docs/api-change-log.md @@ -2,6 +2,50 @@ API 계약이 변경될 때마다 최신 항목을 위에 추가한다. +## 2026-08-24 (커뮤니티 API 신설) + +- API: `/api/v1/posts`, `/api/v1/users`, `/api/v1/hashtags`, `/api/v1/reports` 아래 31개 엔드포인트 +- 구분: 추가 +- 이전: 커뮤니티 기능이 없었다. 사용자 도메인도 `users` 테이블만 있고 API가 없었다. +- 이후: 게시물 작성·조회·수정·삭제, 좋아요, 저장, 댓글과 답글, 팔로우, 차단, 프로필, 사용자·해시태그 검색, 신고를 추가한다. 계약은 `docs/API_SPEC.md`의 `커뮤니티 계약`에 있다. +- 이유: 여행 후기를 공유하는 커뮤니티 기능을 제공하기 위함 +- 호환성 파괴: 아니오. 모두 신규 경로이며 기존 API의 요청·응답은 그대로다. +- DB/ERD: `V6__create_users_table.sql`로 `users`, `V8__create_community_tables.sql`로 커뮤니티 테이블 12개와 인덱스 9개를 추가한다. `docs/ERD.md` 14~26번에 반영했다. +- 관련 PR 또는 이슈: 없음 + +### 요청자 식별은 임시 조치다 + +인증이 아직 없어 `X-User-Id` 헤더로 요청자를 전달한다. **헤더 값을 바꾸면 다른 사용자를 사칭할 수 있으므로 인증 도입 시 반드시 제거해야 한다.** 컨트롤러마다 TODO 주석을 남겼다. + +요청 본문이 아니라 헤더로 받은 이유는, 조회 API에는 본문이 없어 방식이 갈라지고, `Authorization` 헤더로 교체할 때 요청 DTO를 건드리지 않아도 되기 때문이다. + +### 계약에 영향을 주는 결정 + +- **페이징이 두 가지다.** 피드·사용자 게시물·댓글은 `cursor`, 인기 피드·북마크·팔로워·팔로잉·차단·검색은 `page`를 쓴다. 후자는 정렬 기준이 증가하는 식별자가 아니거나 대상 테이블에 대리키가 없어 커서를 만들 수 없다. +- **댓글은 오래된 순, 피드는 최신순이다.** 댓글은 대화 흐름을 따라 읽어야 해 커서 방향이 반대다. +- **답글에 답글을 달 수 없다.** 응답이 두 단계만 표현하므로 `400 INVALID_COMMENT_REQUEST`를 반환한다. +- **삭제된 댓글이 목록에 남을 수 있다.** 살아 있는 답글이 있으면 자리를 유지하고 `author`·`content`를 `null`, `deleted`를 `true`로 반환한다. 화면 문구는 클라이언트가 정한다. +- **게시물에는 사진 또는 영상이 최소 한 건 필요하다.** 사진 기반 서비스라 빈 게시물을 허용하지 않는다. +- **좋아요·저장·팔로우·차단은 멱등하다.** 중복 요청에도 개수가 어긋나지 않는다. +- **차단은 단방향이다.** 차단한 사람의 피드에서 상대 게시물이 사라지고 서로의 팔로우가 끊기지만, 상대가 내 게시물을 보는 것은 막지 않는다. 역방향까지 막으려면 모든 조회에서 역방향 확인이 필요해 비용이 크다. +- **해시태그는 본문에서 뽑는다.** `#` 뒤의 한글·영문·숫자·밑줄만 인정하고 공백에서 끊는다. 소문자로 정규화하며 게시물당 20개까지다. +- **`liked`·`bookmarked`는 요청자 기준이다.** `likeCount`는 모두에게 같고 `liked`만 사람마다 다르다. `X-User-Id`가 없으면 `false`다. + +### 신설 오류 코드 + +`POST_NOT_FOUND`, `COMMENT_NOT_FOUND`, `USER_NOT_FOUND`, `POST_ACCESS_DENIED`, `COMMENT_ACCESS_DENIED`, `INVALID_COMMENT_REQUEST`, `INVALID_FOLLOW_REQUEST`, `INVALID_BLOCK_REQUEST`, `INVALID_FEED_REQUEST`, `NICKNAME_ALREADY_USED`, `ALREADY_REPORTED` + +### 알려진 문제 + +요청 본문 검증에 실패하면 `GlobalExceptionHandler.validationErrorCode()`가 URI로 오류 코드를 고르는데 커뮤니티 경로 분기가 없어 `INVALID_SCHEDULE_CONDITION`("일정 조건이 올바르지 않습니다")이 나간다. `fieldErrors`는 정확하므로 클라이언트는 그쪽을 쓴다. 핸들러에 `/api/v1/posts`, `/api/v1/users` 분기를 추가하면 해결된다. + +### 미구현 + +- 이미지·영상 업로드: 저장소와 API가 정해지지 않았다. 현재는 클라이언트가 올린 URL만 받는다. +- 알림: 기획서 필수 기능이나 테이블부터 없다. +- 사진 EXIF 좌표 추출: 클라이언트가 좌표를 직접 넘긴다. +- 게시물 30일 후 완전 삭제 배치, 신고 처리 관리자 기능, 게시물 미디어·장소 태그 수정. + ## 2026-08-11 (장소 상세에 출처와 외부 링크 추가) - API: `GET /api/v1/places/{placeId}` From 07de078315a43cfe3a617f8ef4c37fdc99ef12f5 Mon Sep 17 00:00:00 2001 From: sususuj Date: Mon, 24 Aug 2026 19:03:09 +0900 Subject: [PATCH 12/12] =?UTF-8?q?test:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=95=B5=EC=8B=AC=20=EA=B7=9C=EC=B9=99=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hashtag/service/HashtagExtractorTest.java | 96 ++++++++++ .../post/service/CommentServiceTest.java | 175 ++++++++++++++++++ .../server/post/service/PostServiceTest.java | 170 +++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 src/test/java/com/server/hashtag/service/HashtagExtractorTest.java create mode 100644 src/test/java/com/server/post/service/CommentServiceTest.java create mode 100644 src/test/java/com/server/post/service/PostServiceTest.java diff --git a/src/test/java/com/server/hashtag/service/HashtagExtractorTest.java b/src/test/java/com/server/hashtag/service/HashtagExtractorTest.java new file mode 100644 index 0000000..f3fc765 --- /dev/null +++ b/src/test/java/com/server/hashtag/service/HashtagExtractorTest.java @@ -0,0 +1,96 @@ +package com.server.hashtag.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("해시태그 추출") +class HashtagExtractorTest { + + private final HashtagExtractor extractor = new HashtagExtractor(); + + @Test + @DisplayName("본문에서 # 으로 시작하는 태그를 뽑는다") + void extractsHashtags() { + assertThat(extractor.extract("광안리 야경 #광안리맛집 #부산 최고")) + .containsExactly("광안리맛집", "부산"); + } + + @Test + @DisplayName("띄어쓰기에서 태그가 끝난다") + void stopsAtWhitespace() { + // "#광안리 맛집" 은 "광안리" 하나다. 띄어쓰기를 허용하면 어디까지가 태그인지 정할 수 없다. + assertThat(extractor.extract("#광안리 맛집 갔다왔는데 #해운대 좋아")) + .containsExactly("광안리", "해운대"); + } + + @Test + @DisplayName("쉼표 같은 문자에서도 태그가 끝난다") + void stopsAtPunctuation() { + assertThat(extractor.extract("#광안리맛집, #부산! 그리고 #해운대.")) + .containsExactly("광안리맛집", "부산", "해운대"); + } + + @Test + @DisplayName("영문은 소문자로 정규화해 같은 태그로 본다") + void normalizesToLowerCase() { + assertThat(extractor.extract("#Busan #BUSAN #busan")) + .containsExactly("busan"); + } + + @Test + @DisplayName("같은 태그가 여러 번 나와도 한 번만 담는다") + void removesDuplicates() { + assertThat(extractor.extract("#부산 여행 #부산 최고 #부산")) + .containsExactly("부산"); + } + + @Test + @DisplayName("등장 순서를 유지한다") + void keepsAppearanceOrder() { + assertThat(extractor.extract("#다 #가 #나")) + .containsExactly("다", "가", "나"); + } + + @Test + @DisplayName("숫자와 밑줄을 태그에 포함한다") + void allowsDigitsAndUnderscore() { + assertThat(extractor.extract("#부산_여행 #2026여행")) + .containsExactly("부산_여행", "2026여행"); + } + + @Test + @DisplayName("게시물당 20개까지만 담는다") + void limitsCountPerPost() { + String content = IntStream.rangeClosed(1, 25) + .mapToObj(number -> "#태그" + number) + .reduce("", (left, right) -> left + " " + right); + + assertThat(extractor.extract(content)).hasSize(20).endsWith("태그20"); + } + + @Test + @DisplayName("50자를 넘는 태그는 버린다") + void skipsTooLongHashtag() { + String tooLong = "가".repeat(51); + + assertThat(extractor.extract("#" + tooLong + " #부산")) + .containsExactly("부산"); + } + + @Test + @DisplayName("# 뒤에 쓸 수 있는 문자가 없으면 태그가 아니다") + void ignoresBareHash() { + assertThat(extractor.extract("# 부산 #, ###")).isEmpty(); + } + + @Test + @DisplayName("본문이 없거나 비어 있으면 빈 목록이다") + void handlesEmptyContent() { + assertThat(extractor.extract(null)).isEmpty(); + assertThat(extractor.extract(" ")).isEmpty(); + assertThat(extractor.extract("태그 없는 본문")).isEmpty(); + } +} diff --git a/src/test/java/com/server/post/service/CommentServiceTest.java b/src/test/java/com/server/post/service/CommentServiceTest.java new file mode 100644 index 0000000..47ef7a7 --- /dev/null +++ b/src/test/java/com/server/post/service/CommentServiceTest.java @@ -0,0 +1,175 @@ +package com.server.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.post.domain.Comment; +import com.server.post.domain.Post; +import com.server.post.dto.CommentCreateRequest; +import com.server.post.dto.CommentResponse; +import com.server.post.repository.CommentLikeRepository; +import com.server.post.repository.CommentRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("댓글 서비스") +class CommentServiceTest { + + private static final long POST_ID = 7L; + private static final long OTHER_POST_ID = 8L; + private static final long AUTHOR_ID = 1L; + private static final long OTHER_USER_ID = 2L; + + private final CommentRepository commentRepository = Mockito.mock(CommentRepository.class); + private final CommentLikeRepository commentLikeRepository = + Mockito.mock(CommentLikeRepository.class); + private final PostRepository postRepository = Mockito.mock(PostRepository.class); + private final UserRepository userRepository = Mockito.mock(UserRepository.class); + + private final CommentService commentService = new CommentService( + commentRepository, commentLikeRepository, postRepository, userRepository); + + @Test + @DisplayName("답글에 다시 답글을 달 수 없다") + void rejectsReplyToReply() { + Post post = givenPost(POST_ID); + givenActiveUser(AUTHOR_ID); + Comment parent = comment(10L, post, null); + Comment reply = comment(11L, post, parent); + when(commentRepository.findByIdAndDeletedAtIsNull(11L)).thenReturn(Optional.of(reply)); + + assertThatThrownBy(() -> commentService.create( + POST_ID, AUTHOR_ID, new CommentCreateRequest("답글의 답글", 11L))) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_COMMENT_REQUEST)); + } + + @Test + @DisplayName("다른 게시물의 댓글에는 답글을 달 수 없다") + void rejectsParentFromAnotherPost() { + givenPost(POST_ID); + givenActiveUser(AUTHOR_ID); + Comment parent = comment(10L, givenPost(OTHER_POST_ID), null); + when(commentRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(parent)); + + assertThatThrownBy(() -> commentService.create( + POST_ID, AUTHOR_ID, new CommentCreateRequest("남의 글 댓글에 답글", 10L))) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.INVALID_COMMENT_REQUEST)); + } + + @Test + @DisplayName("남의 댓글은 삭제할 수 없다") + void deleteRejectsOtherUsersComment() { + Comment comment = comment(10L, givenPost(POST_ID), null); + when(commentRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.delete(POST_ID, 10L, OTHER_USER_ID)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()) + .isEqualTo(ErrorCode.COMMENT_ACCESS_DENIED)); + assertThat(comment.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("경로의 게시물과 댓글 소속이 다르면 찾지 못한 것으로 본다") + void deleteRejectsCommentFromAnotherPost() { + Comment comment = comment(10L, givenPost(OTHER_POST_ID), null); + when(commentRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(comment)); + + assertThatThrownBy(() -> commentService.delete(POST_ID, 10L, AUTHOR_ID)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.COMMENT_NOT_FOUND)); + } + + @Test + @DisplayName("삭제는 행을 지우지 않고 삭제 시각만 남기며 댓글 수를 줄인다") + void deleteKeepsRow() { + Comment comment = comment(10L, givenPost(POST_ID), null); + when(commentRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(comment)); + + commentService.delete(POST_ID, 10L, AUTHOR_ID); + + assertThat(comment.getDeletedAt()).isNotNull(); + verify(commentRepository, never()).delete(any(Comment.class)); + verify(postRepository).decreaseCommentCount(POST_ID); + } + + @Test + @DisplayName("삭제된 댓글도 답글이 남아 있으면 자리를 유지하고 작성자와 내용을 감춘다") + void keepsDeletedParentAsPlaceholder() { + Post post = givenPost(POST_ID); + Comment parent = comment(10L, post, null); + parent.delete(); + Comment reply = comment(11L, post, parent); + + when(postRepository.existsByIdAndDeletedAtIsNull(POST_ID)).thenReturn(true); + when(commentRepository.findTopLevelComments(anyLong(), anyLong(), any())) + .thenReturn(List.of(parent)); + when(commentRepository.findByParentIdInAndDeletedAtIsNullOrderByIdAsc(List.of(10L))) + .thenReturn(List.of(reply)); + + CommentResponse item = commentService.getComments(POST_ID, null, 20, null).items().get(0); + + assertThat(item.deleted()).isTrue(); + assertThat(item.author()).isNull(); + assertThat(item.content()).isNull(); + assertThat(item.replies()).singleElement() + .satisfies(child -> assertThat(child.content()).isEqualTo("내용")); + } + + @Test + @DisplayName("이미 누른 댓글 좋아요를 다시 눌러도 개수를 올리지 않는다") + void commentLikeIsIdempotent() { + Comment comment = comment(10L, givenPost(POST_ID), null); + when(commentRepository.findByIdAndDeletedAtIsNull(10L)).thenReturn(Optional.of(comment)); + givenActiveUser(OTHER_USER_ID); + when(commentLikeRepository.existsByCommentIdAndUserId(10L, OTHER_USER_ID)).thenReturn(true); + when(commentRepository.findLikeCountById(10L)).thenReturn(1); + + assertThat(commentService.like(POST_ID, 10L, OTHER_USER_ID).likeCount()).isEqualTo(1); + + verify(commentRepository, never()).increaseLikeCount(anyLong()); + } + + private Post givenPost(long postId) { + Post post = new Post(user(AUTHOR_ID), "본문"); + ReflectionTestUtils.setField(post, "id", postId); + when(postRepository.findByIdAndDeletedAtIsNull(postId)).thenReturn(Optional.of(post)); + return post; + } + + private Comment comment(long commentId, Post post, Comment parent) { + Comment comment = new Comment(post, user(AUTHOR_ID), parent, "내용"); + ReflectionTestUtils.setField(comment, "id", commentId); + return comment; + } + + private User givenActiveUser(long userId) { + User user = user(userId); + when(userRepository.findByIdAndDeletedAtIsNull(userId)).thenReturn(Optional.of(user)); + return user; + } + + private User user(long userId) { + User user = new User("사용자" + userId, null); + ReflectionTestUtils.setField(user, "id", userId); + return user; + } +} diff --git a/src/test/java/com/server/post/service/PostServiceTest.java b/src/test/java/com/server/post/service/PostServiceTest.java new file mode 100644 index 0000000..e690723 --- /dev/null +++ b/src/test/java/com/server/post/service/PostServiceTest.java @@ -0,0 +1,170 @@ +package com.server.post.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.server.common.error.BusinessException; +import com.server.common.error.ErrorCode; +import com.server.hashtag.service.HashtagService; +import com.server.place.repository.PlaceRepository; +import com.server.post.domain.Post; +import com.server.post.domain.PostLike; +import com.server.post.dto.PostUpdateRequest; +import com.server.post.repository.PostLikeRepository; +import com.server.post.repository.PostMediaRepository; +import com.server.post.repository.PostPlaceTagRepository; +import com.server.post.repository.PostRepository; +import com.server.user.domain.User; +import com.server.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; + +@DisplayName("게시물 서비스") +class PostServiceTest { + + private static final long AUTHOR_ID = 1L; + private static final long OTHER_USER_ID = 2L; + private static final long POST_ID = 7L; + + private final PostRepository postRepository = Mockito.mock(PostRepository.class); + private final PostMediaRepository postMediaRepository = Mockito.mock(PostMediaRepository.class); + private final PostPlaceTagRepository postPlaceTagRepository = + Mockito.mock(PostPlaceTagRepository.class); + private final PostLikeRepository postLikeRepository = Mockito.mock(PostLikeRepository.class); + private final UserRepository userRepository = Mockito.mock(UserRepository.class); + private final PlaceRepository placeRepository = Mockito.mock(PlaceRepository.class); + private final PostSummaryAssembler postSummaryAssembler = + Mockito.mock(PostSummaryAssembler.class); + private final HashtagService hashtagService = Mockito.mock(HashtagService.class); + + private final PostService postService = new PostService( + postRepository, + postMediaRepository, + postPlaceTagRepository, + postLikeRepository, + userRepository, + placeRepository, + postSummaryAssembler, + hashtagService); + + @Test + @DisplayName("이미 누른 좋아요를 다시 눌러도 개수를 올리지 않는다") + void likeIsIdempotent() { + givenPostWrittenBy(AUTHOR_ID); + givenActiveUser(OTHER_USER_ID); + when(postLikeRepository.existsByPostIdAndUserId(POST_ID, OTHER_USER_ID)).thenReturn(true); + when(postRepository.findLikeCountById(POST_ID)).thenReturn(1); + + assertThat(postService.like(POST_ID, OTHER_USER_ID).likeCount()).isEqualTo(1); + + verify(postLikeRepository, never()).save(any(PostLike.class)); + verify(postRepository, never()).increaseLikeCount(anyLong()); + } + + @Test + @DisplayName("누른 적 없는 좋아요를 취소해도 개수를 내리지 않는다") + void unlikeIsIdempotent() { + when(postRepository.existsByIdAndDeletedAtIsNull(POST_ID)).thenReturn(true); + when(postLikeRepository.deleteByPostIdAndUserId(POST_ID, OTHER_USER_ID)).thenReturn(0L); + when(postRepository.findLikeCountById(POST_ID)).thenReturn(0); + + assertThat(postService.unlike(POST_ID, OTHER_USER_ID).liked()).isFalse(); + + verify(postRepository, never()).decreaseLikeCount(anyLong()); + } + + @Test + @DisplayName("남의 게시물은 수정할 수 없다") + void updateRejectsOtherUsersPost() { + givenPostWrittenBy(AUTHOR_ID); + + assertThatThrownBy(() -> + postService.update(POST_ID, OTHER_USER_ID, new PostUpdateRequest("고쳐볼까"))) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.POST_ACCESS_DENIED)); + } + + @Test + @DisplayName("남의 게시물은 삭제할 수 없다") + void deleteRejectsOtherUsersPost() { + Post post = givenPostWrittenBy(AUTHOR_ID); + + assertThatThrownBy(() -> postService.delete(POST_ID, OTHER_USER_ID)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.POST_ACCESS_DENIED)); + assertThat(post.getDeletedAt()).isNull(); + } + + @Test + @DisplayName("삭제는 행을 지우지 않고 삭제 시각만 남긴다") + void deleteKeepsRowForRecovery() { + Post post = givenPostWrittenBy(AUTHOR_ID); + + postService.delete(POST_ID, AUTHOR_ID); + + assertThat(post.getDeletedAt()).isNotNull(); + verify(postRepository, never()).delete(any(Post.class)); + verify(hashtagService).detachFromPost(POST_ID); + } + + @Test + @DisplayName("본문을 수정하면 해시태그를 다시 계산한다") + void updateRecalculatesHashtags() { + Post post = givenPostWrittenBy(AUTHOR_ID); + when(postMediaRepository.findByPostId(POST_ID)).thenReturn(List.of()); + when(postPlaceTagRepository.findViewsByPostId(POST_ID)).thenReturn(List.of()); + + postService.update(POST_ID, AUTHOR_ID, new PostUpdateRequest("고친 본문 #부산")); + + assertThat(post.getContent()).isEqualTo("고친 본문 #부산"); + verify(hashtagService).reattachFromContent(post, "고친 본문 #부산"); + } + + @Test + @DisplayName("팔로잉 피드는 요청자를 알 수 없으면 거절한다") + void followingFeedRequiresRequester() { + assertThatThrownBy(() -> postService.getFeed(null, 20, true, null, null, null)) + .isInstanceOfSatisfying(BusinessException.class, exception -> { + assertThat(exception.getErrorCode()).isEqualTo(ErrorCode.INVALID_FEED_REQUEST); + assertThat(exception.getFieldViolations()) + .singleElement() + .satisfies(violation -> + assertThat(violation.field()).isEqualTo("X-User-Id")); + }); + } + + @Test + @DisplayName("요청자를 몰라도 전체 피드는 볼 수 있다") + void anonymousCanReadFeed() { + when(postRepository.findFeed(any(), any(), any(), any(), anyLong(), any())) + .thenReturn(List.of()); + when(postSummaryAssembler.assemble(List.of(), null)).thenReturn(List.of()); + + assertThatCode(() -> postService.getFeed(null, 20, false, null, null, null)) + .doesNotThrowAnyException(); + } + + private Post givenPostWrittenBy(long userId) { + Post post = new Post(givenActiveUser(userId), "본문"); + ReflectionTestUtils.setField(post, "id", POST_ID); + when(postRepository.findByIdAndDeletedAtIsNull(POST_ID)).thenReturn(Optional.of(post)); + return post; + } + + private User givenActiveUser(long userId) { + User user = new User("사용자" + userId, null); + ReflectionTestUtils.setField(user, "id", userId); + when(userRepository.findByIdAndDeletedAtIsNull(userId)).thenReturn(Optional.of(user)); + return user; + } +}