-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 상품 상세·후기 API 구현 (productDetail·productReviews·리뷰 댓글/좋아요 해제) #167
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5f7b073
fb153c6
5d61ab3
00aee95
51bb39e
999c3f2
5e9e201
24cdde7
d264073
654f438
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| -- AlterTable | ||
| ALTER TABLE `product_option_group` ADD COLUMN `description` VARCHAR(1000) NULL; | ||
|
|
||
| -- CreateTable | ||
| CREATE TABLE `review_comment` ( | ||
| `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, | ||
| `review_id` BIGINT UNSIGNED NOT NULL, | ||
| `account_id` BIGINT UNSIGNED NOT NULL, | ||
| `content` VARCHAR(500) NOT NULL, | ||
| `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), | ||
| `updated_at` DATETIME(3) NOT NULL, | ||
| `deleted_at` DATETIME(3) NULL, | ||
|
|
||
| INDEX `idx_review_comment_review`(`review_id`, `created_at`), | ||
| INDEX `idx_review_comment_account`(`account_id`), | ||
| INDEX `idx_review_comment_deleted_at`(`deleted_at`), | ||
| PRIMARY KEY (`id`) | ||
| ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE `review_comment` ADD CONSTRAINT `review_comment_review_id_fkey` FOREIGN KEY (`review_id`) REFERENCES `review`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; | ||
|
|
||
| -- AddForeignKey | ||
| ALTER TABLE `review_comment` ADD CONSTRAINT `review_comment_account_id_fkey` FOREIGN KEY (`account_id`) REFERENCES `account`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| -- 커서 쿼리(review_id = ? AND id > ? ORDER BY id) 정합 인덱스로 교체. | ||
| -- FK(review_id)가 인덱스를 요구하므로 DROP/ADD를 단일 ALTER로 원자 처리한다. | ||
| ALTER TABLE `review_comment` | ||
| DROP INDEX `idx_review_comment_review`, | ||
| ADD INDEX `idx_review_comment_review` (`review_id`, `id`); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -156,6 +156,7 @@ model Account { | |
|
|
||
| store_wishlist_items StoreWishlistItem[] | ||
| review_likes ReviewLike[] | ||
| review_comments ReviewComment[] | ||
| store_conversations StoreConversation[] | ||
| sent_conversation_messages StoreConversationMessage[] @relation("ConversationMessageSender") | ||
| store Store? @relation("StoreSellerAccount") | ||
|
|
@@ -594,6 +595,8 @@ model ProductOptionGroup { | |
| product_id BigInt @db.UnsignedBigInt | ||
|
|
||
| name String @db.VarChar(120) | ||
| // 그룹 수준 안내 문구(상품 상세 옵션 섹션 인트로. 예: 맛 옵션 크림 설명) | ||
| description String? @db.VarChar(1000) | ||
| is_required Boolean @default(true) @db.TinyInt | ||
| min_select Int @default(1) @db.UnsignedSmallInt | ||
| max_select Int @default(1) @db.UnsignedSmallInt | ||
|
|
@@ -1082,6 +1085,7 @@ model Review { | |
| media ReviewMedia[] | ||
|
|
||
| likes ReviewLike[] | ||
| comments ReviewComment[] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Adding comments to reviews without extending Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 반영: softDeleteReview 트랜잭션에서 댓글도 soft-delete (5e9e201). 재작성 복원 시 옛 댓글 미노출 회귀 테스트 추가. |
||
| notifications Notification[] | ||
|
|
||
| @@index([store_id, created_at], map: "idx_review_store") | ||
|
|
@@ -1111,6 +1115,27 @@ model ReviewMedia { | |
| @@map("review_media") | ||
| } | ||
|
|
||
| model ReviewComment { | ||
| id BigInt @id @default(autoincrement()) @db.UnsignedBigInt | ||
| review_id BigInt @db.UnsignedBigInt | ||
| account_id BigInt @db.UnsignedBigInt | ||
|
|
||
| content String @db.VarChar(500) | ||
|
|
||
| created_at DateTime @default(now()) @db.DateTime(3) | ||
| updated_at DateTime @updatedAt @db.DateTime(3) | ||
| deleted_at DateTime? @db.DateTime(3) | ||
|
|
||
| review Review @relation(fields: [review_id], references: [id]) | ||
| account Account @relation(fields: [account_id], references: [id]) | ||
|
|
||
| // 커서 쿼리(review_id = ? AND id > ? ORDER BY id) 정합 인덱스 | ||
| @@index([review_id, id], map: "idx_review_comment_review") | ||
| @@index([account_id], map: "idx_review_comment_account") | ||
| @@index([deleted_at], map: "idx_review_comment_deleted_at") | ||
| @@map("review_comment") | ||
| } | ||
|
|
||
| /** | ||
| * ========================= | ||
| * 10) Notification | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /** 상품 상세 조회 에러 메시지. */ | ||
| export const PRODUCT_DETAIL_ERRORS = { | ||
| PRODUCT_NOT_FOUND: '상품을 찾을 수 없습니다.', | ||
| } as const; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| /** 상품 리뷰 조회 에러 메시지. */ | ||
| export const PRODUCT_REVIEW_ERRORS = { | ||
| REVIEW_NOT_FOUND: '리뷰를 찾을 수 없습니다.', | ||
| INVALID_LIKES_CURSOR: '좋아요순 커서 형식이 올바르지 않습니다.', | ||
| } as const; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| /** 상품 리뷰 목록 기본 페이지 크기. */ | ||
| export const DEFAULT_PRODUCT_REVIEWS_LIMIT = 20; | ||
|
|
||
| /** 리뷰 댓글 목록 기본 페이지 크기. */ | ||
| export const DEFAULT_REVIEW_COMMENTS_LIMIT = 20; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { | ||
| IsBoolean, | ||
| IsIn, | ||
| IsInt, | ||
| IsNotEmpty, | ||
| IsOptional, | ||
| IsString, | ||
| Max, | ||
| Min, | ||
| } from 'class-validator'; | ||
|
|
||
| export const PRODUCT_REVIEW_SORTS = ['LATEST', 'LIKES'] as const; | ||
| export type ProductReviewSort = (typeof PRODUCT_REVIEW_SORTS)[number]; | ||
|
|
||
| export class ProductReviewsInput { | ||
| @IsString() | ||
| @IsNotEmpty() | ||
| productId!: string; | ||
|
|
||
| @IsOptional() | ||
| @IsBoolean() | ||
| photoOnly?: boolean; | ||
|
|
||
| @IsOptional() | ||
| @IsIn(PRODUCT_REVIEW_SORTS) | ||
| sort?: ProductReviewSort; | ||
|
|
||
| @IsOptional() | ||
| @IsString() | ||
| @IsNotEmpty() | ||
| cursor?: string; | ||
|
|
||
| @IsOptional() | ||
| @IsInt() | ||
| @Min(1) | ||
| @Max(100) | ||
| limit?: number; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { | ||
| IsInt, | ||
| IsNotEmpty, | ||
| IsOptional, | ||
| IsString, | ||
| Max, | ||
| Min, | ||
| } from 'class-validator'; | ||
|
|
||
| export class ReviewCommentsInput { | ||
| @IsString() | ||
| @IsNotEmpty() | ||
| reviewId!: string; | ||
|
|
||
| @IsOptional() | ||
| @IsString() | ||
| @IsNotEmpty() | ||
| cursor?: string; | ||
|
|
||
| @IsOptional() | ||
| @IsInt() | ||
| @Min(1) | ||
| @Max(100) | ||
| limit?: number; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| extend type Query { | ||
| """상품 상세. 없거나 비활성/삭제 상품(또는 매장)이면 NOT_FOUND. 비로그인 접근 가능.""" | ||
| productDetail(productId: ID!): ProductDetail! | ||
| } | ||
|
|
||
| """상품 상세(구매자용).""" | ||
| type ProductDetail { | ||
| id: ID! | ||
| """소속 매장 ID(주문/매장 이동용).""" | ||
| storeId: ID! | ||
| name: String! | ||
| description: String | ||
| """구매 전 필독사항.""" | ||
| purchaseNotice: String | ||
| """상품 이미지(캐러셀·썸네일, sort_order asc).""" | ||
| images: [String!]! | ||
| regularPrice: Int! | ||
| salePrice: Int | ||
| """할인율(0~100). salePrice 없으면 0.""" | ||
| discountRate: Int! | ||
| currency: String! | ||
| """후기 탭 카운트.""" | ||
| reviewCount: Int! | ||
| """로그인 사용자의 찜 여부(비로그인 시 false).""" | ||
| isWishlisted: Boolean! | ||
| """옵션 섹션(케이크 사이즈/맛 등, sort_order asc). 활성 그룹만.""" | ||
| optionGroups: [ProductDetailOptionGroup!]! | ||
| } | ||
|
|
||
| """상품 상세 옵션 섹션(그룹).""" | ||
| type ProductDetailOptionGroup { | ||
| id: ID! | ||
| name: String! | ||
| """그룹 안내 문구(섹션 인트로).""" | ||
| description: String | ||
| isRequired: Boolean! | ||
| minSelect: Int! | ||
| maxSelect: Int! | ||
| sortOrder: Int! | ||
| items: [ProductDetailOptionItem!]! | ||
| } | ||
|
|
||
| """상품 상세 옵션 항목.""" | ||
| type ProductDetailOptionItem { | ||
| id: ID! | ||
| title: String! | ||
| description: String | ||
| imageUrl: String | ||
| """옵션 선택 시 가격 증감(원).""" | ||
| priceDelta: Int! | ||
| sortOrder: Int! | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| extend type Query { | ||
| """상품 공개 리뷰 목록(커서). 사진 필터·정렬 지원. 비로그인 접근 가능.""" | ||
| productReviews(input: ProductReviewsInput!): ProductReviewConnection! | ||
|
|
||
| """리뷰 상세(후기 상세 화면). 없거나 삭제된 리뷰면 NOT_FOUND. 비로그인 접근 가능.""" | ||
| reviewDetail(reviewId: ID!): ReviewDetail! | ||
|
|
||
| """리뷰 댓글 목록(등록순, 커서). 비로그인 접근 가능.""" | ||
| reviewComments(input: ReviewCommentsInput!): ReviewCommentConnection! | ||
| } | ||
|
|
||
| input ProductReviewsInput { | ||
| productId: ID! | ||
| """true면 사진(미디어) 있는 리뷰만(사진후기 그리드/사진후기 상세).""" | ||
| photoOnly: Boolean = false | ||
| sort: ProductReviewSort = LATEST | ||
| """이전 페이지의 nextCursor 값(불투명 토큰). 동일 sort에서만 유효.""" | ||
| cursor: ID | ||
| limit: Int = 20 | ||
| } | ||
|
|
||
| """상품 리뷰 정렬.""" | ||
| enum ProductReviewSort { | ||
| """최신순.""" | ||
| LATEST | ||
| """좋아요순(동률이면 최신순).""" | ||
| LIKES | ||
| } | ||
|
|
||
| """상품 리뷰 목록(커서 기반).""" | ||
| type ProductReviewConnection { | ||
| items: [ProductReview!]! | ||
| """전체 리뷰 수(후기 탭 카운트). 0이면 빈 상태.""" | ||
| totalCount: Int! | ||
| """사진 리뷰 수(사진후기 카운트).""" | ||
| photoTotalCount: Int! | ||
| hasMore: Boolean! | ||
| nextCursor: ID | ||
| } | ||
|
|
||
| """상품 공개 리뷰.""" | ||
| type ProductReview { | ||
| id: ID! | ||
| """평점(0.0~5.0).""" | ||
| rating: Float! | ||
| content: String | ||
| media: [ProductReviewMedia!]! | ||
| likeCount: Int! | ||
| """로그인 사용자의 좋아요 여부(비로그인 시 false).""" | ||
| isLiked: Boolean! | ||
| """댓글 수.""" | ||
| commentCount: Int! | ||
| """작성자 닉네임(탈퇴 시 null).""" | ||
| authorNickname: String | ||
| """작성자 프로필 이미지(탈퇴/미설정 시 null).""" | ||
| authorProfileImageUrl: String | ||
| """주문 시 선택 옵션 스냅샷(커스텀 정보: 모양/크기/맛 등).""" | ||
| customOptions: [ReviewCustomOption!]! | ||
| createdAt: DateTime! | ||
| } | ||
|
|
||
| """상품 리뷰 첨부 미디어.""" | ||
| type ProductReviewMedia { | ||
| mediaType: ReviewMediaType! | ||
| mediaUrl: String! | ||
| thumbnailUrl: String | ||
| sortOrder: Int! | ||
| } | ||
|
|
||
| """리뷰의 주문 옵션 스냅샷(커스텀 정보 행).""" | ||
| type ReviewCustomOption { | ||
| """옵션 그룹명 스냅샷(예: 모양/크기/맛).""" | ||
| groupName: String! | ||
| """선택 옵션명 스냅샷(예: (기본) 동그라미).""" | ||
| optionTitle: String! | ||
| } | ||
|
|
||
| """리뷰 상세(후기 상세 화면).""" | ||
| type ReviewDetail { | ||
| """리뷰 본문(목록 카드와 동일 구조, content 전문).""" | ||
| review: ProductReview! | ||
| """판매 케이크 정보(현재 상품 기준).""" | ||
| product: ReviewDetailProduct! | ||
| } | ||
|
|
||
| """리뷰 상세 상단 판매 케이크 정보.""" | ||
| type ReviewDetailProduct { | ||
| productId: ID! | ||
| name: String! | ||
| """대표 이미지(sort_order 최소). 없으면 null.""" | ||
| thumbnailUrl: String | ||
| storeName: String! | ||
| """매장 위치 표기(예: 인천 청라동).""" | ||
| regionLabel: String | ||
| regularPrice: Int! | ||
| salePrice: Int | ||
| """할인율(0~100). salePrice 없으면 0.""" | ||
| discountRate: Int! | ||
| } | ||
|
|
||
| input ReviewCommentsInput { | ||
| reviewId: ID! | ||
| """이전 페이지 마지막 댓글 id(이후부터 조회).""" | ||
| cursor: ID | ||
| limit: Int = 20 | ||
| } | ||
|
|
||
| """리뷰 댓글 목록(등록순, 커서 기반).""" | ||
| type ReviewCommentConnection { | ||
| items: [ReviewCommentItem!]! | ||
| totalCount: Int! | ||
| hasMore: Boolean! | ||
| nextCursor: ID | ||
| } | ||
|
|
||
| """리뷰 댓글.""" | ||
| type ReviewCommentItem { | ||
| id: ID! | ||
| content: String! | ||
| """작성자 닉네임(탈퇴 시 null).""" | ||
| authorNickname: String | ||
| """작성자 프로필 이미지(탈퇴/미설정 시 null).""" | ||
| authorProfileImageUrl: String | ||
| """로그인 사용자 본인 댓글 여부(삭제 버튼 노출용, 비로그인 시 false).""" | ||
| isMine: Boolean! | ||
| createdAt: DateTime! | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For reviews with many comments, this index cannot efficiently serve
listReviewComments, whose predicate isreview_id = ... AND id > ...and whose ordering isid ASC. MySQL must either sort the review's matching rows or scan the primary-key range while filtering other reviews on every page; using(review_id, id)aligns the index with the cursor query and keeps page retrieval proportional to the requested limit.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
반영: 인덱스를 (review_id, id)로 교체하는 마이그레이션 추가. FK가 인덱스를 요구하므로 DROP/ADD 단일 ALTER로 원자 처리. created_at 정렬 사용처 없음 확인.