From 49f5d271f9cf7ac636d1b77c5cd83e292ed7542a Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 18:14:47 +0900 Subject: [PATCH 01/18] =?UTF-8?q?[BOOK-480]=20refactor:=20apis=20-=20emoti?= =?UTF-8?q?onTags=EB=8A=94=20=EB=B9=88=20=EB=A6=AC=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=8F=84=20=ED=97=88=EC=9A=A9=EC=9D=B4=20=EB=90=98=EA=B8=B0=20?= =?UTF-8?q?=EB=95=8C=EB=AC=B8=EC=97=90=20valid=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../readingrecord/dto/request/CreateReadingRecordRequest.kt | 2 -- .../org/yapp/apis/readingrecord/service/ReadingRecordService.kt | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt index 9f58eaf6..1f4a7ddf 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequest.kt @@ -43,6 +43,4 @@ data class CreateReadingRecordRequest private constructor( fun validQuote(): String = requireNotNull(quote) { "quote는 null일 수 없습니다." } - - fun validEmotionTags(): List = emotionTags } diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt index 06c37a84..5814308e 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordService.kt @@ -26,7 +26,7 @@ class ReadingRecordService( pageNumber = request.validPageNumber(), quote = request.validQuote(), review = request.review, - emotionTags = request.validEmotionTags() + emotionTags = request.emotionTags ) // Update user's lastActivity when a reading record is created From b3c30f29626a4415cc8f837b36f6273fa74497a8 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:03:44 +0900 Subject: [PATCH 02/18] =?UTF-8?q?[BOOK-480]=20feat:=20infra=20-=20?= =?UTF-8?q?=EA=B0=90=EC=A0=95=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20DB=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...1__add_primary_emotion_and_detail_tags.sql | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql diff --git a/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql b/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql new file mode 100644 index 00000000..cd0caccf --- /dev/null +++ b/infra/src/main/resources/db/migration/mysql/V20251224_001__add_primary_emotion_and_detail_tags.sql @@ -0,0 +1,85 @@ +-- 1. reading_records 테이블 수정 +-- page_number를 nullable로 변경 +ALTER TABLE reading_records MODIFY COLUMN page_number INT NULL; + +-- primary_emotion 컬럼 추가 +ALTER TABLE reading_records ADD COLUMN primary_emotion VARCHAR(20) NULL; + +-- 기존 데이터 마이그레이션 (tags 기반으로 primary_emotion 설정) +UPDATE reading_records rr +SET primary_emotion = COALESCE( + (SELECT CASE t.name + WHEN '따뜻함' THEN 'WARMTH' + WHEN '즐거움' THEN 'JOY' + WHEN '슬픔' THEN 'SADNESS' + WHEN '깨달음' THEN 'INSIGHT' + ELSE 'OTHER' + END + FROM reading_record_tags rrt + JOIN tags t ON rrt.tag_id = t.id + WHERE rrt.reading_record_id = rr.id AND rrt.deleted_at IS NULL + LIMIT 1), + 'OTHER' +); + +-- NOT NULL 제약 추가 +ALTER TABLE reading_records MODIFY COLUMN primary_emotion VARCHAR(20) NOT NULL; + +-- 2. detail_tags 테이블 생성 +CREATE TABLE detail_tags ( + id VARCHAR(36) NOT NULL, + created_at datetime(6) NOT NULL, + updated_at datetime(6) NOT NULL, + primary_emotion VARCHAR(20) NOT NULL, + name VARCHAR(20) NOT NULL, + display_order INT NOT NULL DEFAULT 0, + CONSTRAINT pk_detail_tags PRIMARY KEY (id), + CONSTRAINT uq_detail_tags_emotion_name UNIQUE (primary_emotion, name) +); + +-- 3. reading_record_detail_tags 테이블 생성 +CREATE TABLE reading_record_detail_tags ( + id VARCHAR(36) NOT NULL, + created_at datetime(6) NOT NULL, + updated_at datetime(6) NOT NULL, + deleted_at datetime(6) NULL, + reading_record_id VARCHAR(36) NOT NULL, + detail_tag_id VARCHAR(36) NOT NULL, + CONSTRAINT pk_reading_record_detail_tags PRIMARY KEY (id), + CONSTRAINT uq_record_detail_tag UNIQUE (reading_record_id, detail_tag_id), + CONSTRAINT fk_rrdt_reading_record FOREIGN KEY (reading_record_id) REFERENCES reading_records(id) ON DELETE CASCADE, + CONSTRAINT fk_rrdt_detail_tag FOREIGN KEY (detail_tag_id) REFERENCES detail_tags(id) +); + +CREATE INDEX idx_rrdt_reading_record_id ON reading_record_detail_tags(reading_record_id); +CREATE INDEX idx_rrdt_detail_tag_id ON reading_record_detail_tags(detail_tag_id); + +-- 4. 세부감정 초기 데이터 삽입 +INSERT INTO detail_tags (id, created_at, updated_at, primary_emotion, name, display_order) VALUES +-- 즐거움 +(UUID(), NOW(), NOW(), 'JOY', '설레는', 1), +(UUID(), NOW(), NOW(), 'JOY', '뿌듯한', 2), +(UUID(), NOW(), NOW(), 'JOY', '유쾌한', 3), +(UUID(), NOW(), NOW(), 'JOY', '기쁜', 4), +(UUID(), NOW(), NOW(), 'JOY', '흥미진진한', 5), +-- 따뜻함 +(UUID(), NOW(), NOW(), 'WARMTH', '위로받은', 1), +(UUID(), NOW(), NOW(), 'WARMTH', '포근한', 2), +(UUID(), NOW(), NOW(), 'WARMTH', '다정한', 3), +(UUID(), NOW(), NOW(), 'WARMTH', '고마운', 4), +(UUID(), NOW(), NOW(), 'WARMTH', '마음이 놓이는', 5), +(UUID(), NOW(), NOW(), 'WARMTH', '편안한', 6), +-- 슬픔 +(UUID(), NOW(), NOW(), 'SADNESS', '허무한', 1), +(UUID(), NOW(), NOW(), 'SADNESS', '외로운', 2), +(UUID(), NOW(), NOW(), 'SADNESS', '아쉬운', 3), +(UUID(), NOW(), NOW(), 'SADNESS', '먹먹한', 4), +(UUID(), NOW(), NOW(), 'SADNESS', '애틋한', 5), +(UUID(), NOW(), NOW(), 'SADNESS', '안타까운', 6), +(UUID(), NOW(), NOW(), 'SADNESS', '그리운', 7), +-- 깨달음 +(UUID(), NOW(), NOW(), 'INSIGHT', '감탄한', 1), +(UUID(), NOW(), NOW(), 'INSIGHT', '통찰력을 얻은', 2), +(UUID(), NOW(), NOW(), 'INSIGHT', '영감을 받은', 3), +(UUID(), NOW(), NOW(), 'INSIGHT', '생각이 깊어진', 4), +(UUID(), NOW(), NOW(), 'INSIGHT', '새롭게 이해한', 5); From 3a85de71a34cc1c03915ca3999adbdd358e68de0 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:03:52 +0900 Subject: [PATCH 03/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20Primary?= =?UTF-8?q?Emotion=20=EB=8C=80=EB=B6=84=EB=A5=98=20=EA=B0=90=EC=A0=95=20en?= =?UTF-8?q?um=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/domain/readingrecord/PrimaryEmotion.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt new file mode 100644 index 00000000..75205668 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/PrimaryEmotion.kt @@ -0,0 +1,17 @@ +package org.yapp.domain.readingrecord + +enum class PrimaryEmotion(val displayName: String) { + WARMTH("따뜻함"), + JOY("즐거움"), + SADNESS("슬픔"), + INSIGHT("깨달음"), + OTHER("기타"); + + companion object { + fun fromDisplayName(name: String): PrimaryEmotion = + entries.find { it.displayName == name } ?: OTHER + + fun fromCode(code: String): PrimaryEmotion = + entries.find { it.name == code } ?: OTHER + } +} From 3b2914b78e8cc2159dd4e97a1e43fa6571736f9a Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:04 +0900 Subject: [PATCH 04/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20DetailT?= =?UTF-8?q?ag=20=EC=84=B8=EB=B6=80=EA=B0=90=EC=A0=95=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20Repository=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/yapp/domain/detailtag/DetailTag.kt | 58 +++++++++++++++++++ .../domain/detailtag/DetailTagRepository.kt | 13 +++++ 2 files changed, 71 insertions(+) create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt new file mode 100644 index 00000000..ead3b81d --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTag.kt @@ -0,0 +1,58 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.globalutils.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + +data class DetailTag private constructor( + val id: Id, + val primaryEmotion: PrimaryEmotion, + val name: String, + val displayOrder: Int, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null +) { + companion object { + fun create( + primaryEmotion: PrimaryEmotion, + name: String, + displayOrder: Int + ): DetailTag { + require(name.isNotBlank()) { "Detail tag name cannot be blank" } + require(displayOrder >= 0) { "Display order must be non-negative" } + + return DetailTag( + id = Id.newInstance(UuidGenerator.create()), + primaryEmotion = primaryEmotion, + name = name, + displayOrder = displayOrder + ) + } + + fun reconstruct( + id: Id, + primaryEmotion: PrimaryEmotion, + name: String, + displayOrder: Int, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null + ): DetailTag { + return DetailTag( + id = id, + primaryEmotion = primaryEmotion, + name = name, + displayOrder = displayOrder, + createdAt = createdAt, + updatedAt = updatedAt + ) + } + } + + @JvmInline + value class Id(val value: UUID) { + companion object { + fun newInstance(value: UUID) = Id(value) + } + } +} diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt new file mode 100644 index 00000000..c65346d2 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagRepository.kt @@ -0,0 +1,13 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.* + +interface DetailTagRepository { + fun findById(id: UUID): DetailTag? + fun findAllById(ids: List): List + fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List + fun findAll(): List + fun save(detailTag: DetailTag): DetailTag + fun saveAll(detailTags: List): List +} From 84b94c8b315ad0b4dab365a9a29780eeb4dbffc0 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:13 +0900 Subject: [PATCH 05/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20Reading?= =?UTF-8?q?RecordDetailTag=20=EC=97=B0=EA=B2=B0=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=8F=20Repository=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReadingRecordDetailTag.kt | 55 +++++++++++++++++++ .../ReadingRecordDetailTagRepository.kt | 11 ++++ 2 files changed, 66 insertions(+) create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt new file mode 100644 index 00000000..f594c576 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTag.kt @@ -0,0 +1,55 @@ +package org.yapp.domain.readingrecorddetailtag + +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.globalutils.util.UuidGenerator +import java.time.LocalDateTime +import java.util.* + +data class ReadingRecordDetailTag private constructor( + val id: Id, + val readingRecordId: ReadingRecord.Id, + val detailTagId: DetailTag.Id, + val createdAt: LocalDateTime? = null, + val updatedAt: LocalDateTime? = null, + val deletedAt: LocalDateTime? = null +) { + companion object { + fun create( + readingRecordId: UUID, + detailTagId: UUID + ): ReadingRecordDetailTag { + return ReadingRecordDetailTag( + id = Id.newInstance(UuidGenerator.create()), + readingRecordId = ReadingRecord.Id.newInstance(readingRecordId), + detailTagId = DetailTag.Id.newInstance(detailTagId) + ) + } + + fun reconstruct( + id: Id, + readingRecordId: ReadingRecord.Id, + detailTagId: DetailTag.Id, + createdAt: LocalDateTime? = null, + updatedAt: LocalDateTime? = null, + deletedAt: LocalDateTime? = null + ): ReadingRecordDetailTag { + return ReadingRecordDetailTag( + id = id, + readingRecordId = readingRecordId, + detailTagId = detailTagId, + createdAt = createdAt, + updatedAt = updatedAt, + deletedAt = deletedAt + ) + } + } + + @JvmInline + value class Id(val value: UUID) { + companion object { + fun newInstance(value: UUID) = Id(value) + } + } +} + diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt new file mode 100644 index 00000000..1c2c83df --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagRepository.kt @@ -0,0 +1,11 @@ +package org.yapp.domain.readingrecorddetailtag + +import java.util.* + +interface ReadingRecordDetailTagRepository { + fun findByReadingRecordId(readingRecordId: UUID): List + fun findByReadingRecordIdIn(readingRecordIds: List): List + fun save(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTag + fun saveAll(readingRecordDetailTags: List): List + fun deleteAllByReadingRecordId(readingRecordId: UUID) +} From 4045ca8f032ba8a35b4f68a0333cde789b6dbd78 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:25 +0900 Subject: [PATCH 06/18] =?UTF-8?q?[BOOK-480]=20feat:=20infra=20-=20DetailTa?= =?UTF-8?q?g=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Repository=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infra/detailtag/entity/DetailTagEntity.kt | 73 +++++++++++++++++++ .../repository/DetailTagRepositoryImpl.kt | 45 ++++++++++++ .../repository/JpaDetailTagRepository.kt | 12 +++ 3 files changed, 130 insertions(+) create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt new file mode 100644 index 00000000..6198e330 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/entity/DetailTagEntity.kt @@ -0,0 +1,73 @@ +package org.yapp.infra.detailtag.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.common.BaseTimeEntity +import java.sql.Types +import java.util.* + +@Entity +@Table( + name = "detail_tags", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_detail_tags_emotion_name", + columnNames = ["primary_emotion", "name"] + ) + ] +) +class DetailTagEntity( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 36, updatable = false, nullable = false) + val id: UUID, + + @Enumerated(EnumType.STRING) + @Column(name = "primary_emotion", nullable = false, length = 20) + val primaryEmotion: PrimaryEmotion, + + name: String, + displayOrder: Int = 0 +) : BaseTimeEntity() { + + @Column(nullable = false, length = 20) + var name: String = name + protected set + + @Column(name = "display_order", nullable = false) + var displayOrder: Int = displayOrder + protected set + + fun toDomain(): DetailTag { + return DetailTag.reconstruct( + id = DetailTag.Id.newInstance(this.id), + primaryEmotion = this.primaryEmotion, + name = this.name, + displayOrder = this.displayOrder, + createdAt = this.createdAt, + updatedAt = this.updatedAt + ) + } + + companion object { + fun fromDomain(detailTag: DetailTag): DetailTagEntity { + return DetailTagEntity( + id = detailTag.id.value, + primaryEmotion = detailTag.primaryEmotion, + name = detailTag.name, + displayOrder = detailTag.displayOrder + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DetailTagEntity) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() +} + diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt new file mode 100644 index 00000000..b1d13493 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/DetailTagRepositoryImpl.kt @@ -0,0 +1,45 @@ +package org.yapp.infra.detailtag.repository + +import org.springframework.stereotype.Repository +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.detailtag.DetailTagRepository +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.detailtag.entity.DetailTagEntity +import java.util.* + +@Repository +class DetailTagRepositoryImpl( + private val jpaDetailTagRepository: JpaDetailTagRepository +) : DetailTagRepository { + + override fun findById(id: UUID): DetailTag? { + return jpaDetailTagRepository.findById(id) + .map { it.toDomain() } + .orElse(null) + } + + override fun findAllById(ids: List): List { + return jpaDetailTagRepository.findAllById(ids) + .map { it.toDomain() } + } + + override fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List { + return jpaDetailTagRepository.findByPrimaryEmotionOrderByDisplayOrderAsc(primaryEmotion) + .map { it.toDomain() } + } + + override fun findAll(): List { + return jpaDetailTagRepository.findAllByOrderByPrimaryEmotionAscDisplayOrderAsc() + .map { it.toDomain() } + } + + override fun save(detailTag: DetailTag): DetailTag { + val entity = DetailTagEntity.fromDomain(detailTag) + return jpaDetailTagRepository.save(entity).toDomain() + } + + override fun saveAll(detailTags: List): List { + val entities = detailTags.map { DetailTagEntity.fromDomain(it) } + return jpaDetailTagRepository.saveAll(entities).map { it.toDomain() } + } +} diff --git a/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt new file mode 100644 index 00000000..c1c65540 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/detailtag/repository/JpaDetailTagRepository.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.detailtag.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.infra.detailtag.entity.DetailTagEntity +import java.util.* + +interface JpaDetailTagRepository : JpaRepository { + fun findByPrimaryEmotionOrderByDisplayOrderAsc(primaryEmotion: PrimaryEmotion): List + fun findAllByOrderByPrimaryEmotionAscDisplayOrderAsc(): List +} + From aa4cc4ae9c65ffe277c9796527f6d13c3c230aa6 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:34 +0900 Subject: [PATCH 07/18] =?UTF-8?q?[BOOK-480]=20feat:=20infra=20-=20ReadingR?= =?UTF-8?q?ecordDetailTag=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EB=B0=8F=20Repo?= =?UTF-8?q?sitory=20=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/ReadingRecordDetailTagEntity.kt | 73 +++++++++++++++++++ .../JpaReadingRecordDetailTagRepository.kt | 12 +++ .../ReadingRecordDetailTagRepositoryImpl.kt | 39 ++++++++++ 3 files changed, 124 insertions(+) create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt create mode 100644 infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt new file mode 100644 index 00000000..4f5ff199 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/entity/ReadingRecordDetailTagEntity.kt @@ -0,0 +1,73 @@ +package org.yapp.infra.readingrecorddetailtag.entity + +import jakarta.persistence.* +import org.hibernate.annotations.JdbcTypeCode +import org.hibernate.annotations.SQLDelete +import org.hibernate.annotations.SQLRestriction +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTag +import org.yapp.infra.common.BaseTimeEntity +import java.sql.Types +import java.util.* + +@Entity +@Table( + name = "reading_record_detail_tags", + uniqueConstraints = [ + UniqueConstraint( + name = "uq_record_detail_tag", + columnNames = ["reading_record_id", "detail_tag_id"] + ) + ], + indexes = [ + Index(name = "idx_rrdt_reading_record_id", columnList = "reading_record_id"), + Index(name = "idx_rrdt_detail_tag_id", columnList = "detail_tag_id") + ] +) +@SQLDelete(sql = "UPDATE reading_record_detail_tags SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +class ReadingRecordDetailTagEntity( + @Id + @JdbcTypeCode(Types.VARCHAR) + @Column(length = 36, updatable = false, nullable = false) + val id: UUID, + + @Column(name = "reading_record_id", nullable = false, length = 36) + @JdbcTypeCode(Types.VARCHAR) + val readingRecordId: UUID, + + @Column(name = "detail_tag_id", nullable = false, length = 36) + @JdbcTypeCode(Types.VARCHAR) + val detailTagId: UUID +) : BaseTimeEntity() { + + fun toDomain(): ReadingRecordDetailTag { + return ReadingRecordDetailTag.reconstruct( + id = ReadingRecordDetailTag.Id.newInstance(this.id), + readingRecordId = ReadingRecord.Id.newInstance(this.readingRecordId), + detailTagId = DetailTag.Id.newInstance(this.detailTagId), + createdAt = this.createdAt, + updatedAt = this.updatedAt, + deletedAt = this.deletedAt + ) + } + + companion object { + fun fromDomain(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTagEntity { + return ReadingRecordDetailTagEntity( + id = readingRecordDetailTag.id.value, + readingRecordId = readingRecordDetailTag.readingRecordId.value, + detailTagId = readingRecordDetailTag.detailTagId.value + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ReadingRecordDetailTagEntity) return false + return id == other.id + } + + override fun hashCode(): Int = id.hashCode() +} diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt new file mode 100644 index 00000000..0826f7e1 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/JpaReadingRecordDetailTagRepository.kt @@ -0,0 +1,12 @@ +package org.yapp.infra.readingrecorddetailtag.repository + +import org.springframework.data.jpa.repository.JpaRepository +import org.yapp.infra.readingrecorddetailtag.entity.ReadingRecordDetailTagEntity +import java.util.* + +interface JpaReadingRecordDetailTagRepository : JpaRepository { + fun findByReadingRecordId(readingRecordId: UUID): List + fun findByReadingRecordIdIn(readingRecordIds: List): List + fun deleteAllByReadingRecordId(readingRecordId: UUID) +} + diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt new file mode 100644 index 00000000..00929543 --- /dev/null +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecorddetailtag/repository/ReadingRecordDetailTagRepositoryImpl.kt @@ -0,0 +1,39 @@ +package org.yapp.infra.readingrecorddetailtag.repository + +import org.springframework.stereotype.Repository +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTag +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagRepository +import org.yapp.infra.readingrecorddetailtag.entity.ReadingRecordDetailTagEntity +import java.util.* + +@Repository +class ReadingRecordDetailTagRepositoryImpl( + private val jpaReadingRecordDetailTagRepository: JpaReadingRecordDetailTagRepository +) : ReadingRecordDetailTagRepository { + + override fun findByReadingRecordId(readingRecordId: UUID): List { + return jpaReadingRecordDetailTagRepository.findByReadingRecordId(readingRecordId) + .map { it.toDomain() } + } + + override fun findByReadingRecordIdIn(readingRecordIds: List): List { + if (readingRecordIds.isEmpty()) return emptyList() + return jpaReadingRecordDetailTagRepository.findByReadingRecordIdIn(readingRecordIds) + .map { it.toDomain() } + } + + override fun save(readingRecordDetailTag: ReadingRecordDetailTag): ReadingRecordDetailTag { + val entity = ReadingRecordDetailTagEntity.fromDomain(readingRecordDetailTag) + return jpaReadingRecordDetailTagRepository.save(entity).toDomain() + } + + override fun saveAll(readingRecordDetailTags: List): List { + if (readingRecordDetailTags.isEmpty()) return emptyList() + val entities = readingRecordDetailTags.map { ReadingRecordDetailTagEntity.fromDomain(it) } + return jpaReadingRecordDetailTagRepository.saveAll(entities).map { it.toDomain() } + } + + override fun deleteAllByReadingRecordId(readingRecordId: UUID) { + jpaReadingRecordDetailTagRepository.deleteAllByReadingRecordId(readingRecordId) + } +} From ee429a8ca8d8badf829efabd30a423ada1ff5123 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:45 +0900 Subject: [PATCH 08/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain,=20infra=20-?= =?UTF-8?q?=20ReadingRecord=EC=97=90=20primaryEmotion=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/readingrecord/ReadingRecord.kt | 15 ++++++++---- .../entity/ReadingRecordEntity.kt | 23 ++++++++++++------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt index f7cf1cab..bac0c904 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecord.kt @@ -8,9 +8,10 @@ import java.util.* data class ReadingRecord private constructor( val id: Id, val userBookId: UserBook.Id, - val pageNumber: PageNumber, + val pageNumber: PageNumber?, val quote: Quote, val review: Review?, + val primaryEmotion: PrimaryEmotion, val emotionTags: List = emptyList(), val createdAt: LocalDateTime? = null, val updatedAt: LocalDateTime? = null, @@ -19,17 +20,19 @@ data class ReadingRecord private constructor( companion object { fun create( userBookId: UUID, - pageNumber: Int, + pageNumber: Int?, quote: String, review: String?, + primaryEmotion: PrimaryEmotion, emotionTags: List = emptyList() ): ReadingRecord { return ReadingRecord( id = Id.newInstance(UuidGenerator.create()), userBookId = UserBook.Id.newInstance(userBookId), - pageNumber = PageNumber.newInstance(pageNumber), + pageNumber = pageNumber?.let { PageNumber.newInstance(it) }, quote = Quote.newInstance(quote), review = Review.newInstance(review), + primaryEmotion = primaryEmotion, emotionTags = emotionTags.map { EmotionTag.newInstance(it) } ) } @@ -37,9 +40,10 @@ data class ReadingRecord private constructor( fun reconstruct( id: Id, userBookId: UserBook.Id, - pageNumber: PageNumber, + pageNumber: PageNumber?, quote: Quote, review: Review?, + primaryEmotion: PrimaryEmotion, emotionTags: List = emptyList(), createdAt: LocalDateTime? = null, updatedAt: LocalDateTime? = null, @@ -51,6 +55,7 @@ data class ReadingRecord private constructor( pageNumber = pageNumber, quote = quote, review = review, + primaryEmotion = primaryEmotion, emotionTags = emotionTags, createdAt = createdAt, updatedAt = updatedAt, @@ -63,12 +68,14 @@ data class ReadingRecord private constructor( pageNumber: Int?, quote: String?, review: String?, + primaryEmotion: PrimaryEmotion?, emotionTags: List? ): ReadingRecord { return this.copy( pageNumber = pageNumber?.let { PageNumber.newInstance(it) } ?: this.pageNumber, quote = quote?.let { Quote.newInstance(it) } ?: this.quote, review = if (review != null) Review.newInstance(review) else this.review, + primaryEmotion = primaryEmotion ?: this.primaryEmotion, emotionTags = emotionTags?.map { EmotionTag.newInstance(it) } ?: this.emotionTags, updatedAt = LocalDateTime.now() ) diff --git a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt index 1b754912..4cf0c3b4 100644 --- a/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt +++ b/infra/src/main/kotlin/org/yapp/infra/readingrecord/entity/ReadingRecordEntity.kt @@ -4,6 +4,7 @@ import jakarta.persistence.* import org.hibernate.annotations.JdbcTypeCode import org.hibernate.annotations.SQLDelete import org.hibernate.annotations.SQLRestriction +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.userbook.UserBook import org.yapp.infra.common.BaseTimeEntity @@ -24,15 +25,14 @@ class ReadingRecordEntity( @JdbcTypeCode(Types.VARCHAR) val userBookId: UUID, - pageNumber: Int, + pageNumber: Int?, quote: String, review: String?, - - + primaryEmotion: PrimaryEmotion ) : BaseTimeEntity() { - @Column(name = "page_number", nullable = false) - var pageNumber: Int = pageNumber + @Column(name = "page_number", nullable = true) + var pageNumber: Int? = pageNumber protected set @Column(name = "quote", nullable = false, length = 1000) @@ -43,13 +43,19 @@ class ReadingRecordEntity( var review: String? = review protected set + @Enumerated(EnumType.STRING) + @Column(name = "primary_emotion", nullable = false, length = 20) + var primaryEmotion: PrimaryEmotion = primaryEmotion + protected set + fun toDomain(): ReadingRecord { return ReadingRecord.reconstruct( id = ReadingRecord.Id.newInstance(this.id), userBookId = UserBook.Id.newInstance(this.userBookId), - pageNumber = ReadingRecord.PageNumber.newInstance(this.pageNumber), + pageNumber = this.pageNumber?.let { ReadingRecord.PageNumber.newInstance(it) }, quote = ReadingRecord.Quote.newInstance(this.quote), review = ReadingRecord.Review.newInstance(this.review), + primaryEmotion = this.primaryEmotion, emotionTags = emptyList(), createdAt = this.createdAt, updatedAt = this.updatedAt, @@ -62,9 +68,10 @@ class ReadingRecordEntity( return ReadingRecordEntity( id = readingRecord.id.value, userBookId = readingRecord.userBookId.value, - pageNumber = readingRecord.pageNumber.value, + pageNumber = readingRecord.pageNumber?.value, quote = readingRecord.quote.value, - review = readingRecord.review?.value + review = readingRecord.review?.value, + primaryEmotion = readingRecord.primaryEmotion ) } } From 1b0caa43fefdda5c51ccb847b3675e96cbf71bce Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:04:54 +0900 Subject: [PATCH 09/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20Reading?= =?UTF-8?q?RecordInfoVO=EC=97=90=20V2=EC=9A=A9=20detailEmotions=20?= =?UTF-8?q?=ED=95=84=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 --- .../readingrecord/vo/ReadingRecordInfoVO.kt | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt index 95126d28..291ec17c 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/vo/ReadingRecordInfoVO.kt @@ -1,16 +1,20 @@ package org.yapp.domain.readingrecord.vo +import org.yapp.domain.readingrecord.PrimaryEmotion import org.yapp.domain.readingrecord.ReadingRecord import org.yapp.domain.userbook.UserBook import java.time.LocalDateTime +import java.util.UUID data class ReadingRecordInfoVO private constructor( val id: ReadingRecord.Id, val userBookId: UserBook.Id, - val pageNumber: ReadingRecord.PageNumber, + val pageNumber: ReadingRecord.PageNumber?, val quote: ReadingRecord.Quote, val review: ReadingRecord.Review?, + val primaryEmotion: PrimaryEmotion, val emotionTags: List, + val detailEmotions: List, val createdAt: LocalDateTime, val updatedAt: LocalDateTime, val bookTitle: String? = null, @@ -19,7 +23,6 @@ data class ReadingRecordInfoVO private constructor( val author: String? = null ) { init { - require(emotionTags.size <= 3) { "Maximum 3 emotion tags are allowed" } require(!createdAt.isAfter(updatedAt)) { "생성일(createdAt)은 수정일(updatedAt)보다 이후일 수 없습니다." } @@ -28,7 +31,8 @@ data class ReadingRecordInfoVO private constructor( companion object { fun newInstance( readingRecord: ReadingRecord, - emotionTags: List, + emotionTags: List = emptyList(), + detailEmotions: List = emptyList(), bookTitle: String? = null, bookPublisher: String? = null, bookCoverImageUrl: String? = null, @@ -40,7 +44,9 @@ data class ReadingRecordInfoVO private constructor( pageNumber = readingRecord.pageNumber, quote = readingRecord.quote, review = readingRecord.review, + primaryEmotion = readingRecord.primaryEmotion, emotionTags = emotionTags, + detailEmotions = detailEmotions, createdAt = readingRecord.createdAt ?: throw IllegalStateException("createdAt은 null일 수 없습니다."), updatedAt = readingRecord.updatedAt ?: throw IllegalStateException("updatedAt은 null일 수 없습니다."), bookTitle = bookTitle, @@ -50,4 +56,9 @@ data class ReadingRecordInfoVO private constructor( ) } } + + data class DetailEmotionInfo( + val id: UUID, + val name: String + ) } From 900feed59f55d534000fe70e7bdf7336c4cc44eb Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:05:04 +0900 Subject: [PATCH 10/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20DetailT?= =?UTF-8?q?agDomainService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../detailtag/DetailTagDomainService.kt | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt diff --git a/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt new file mode 100644 index 00000000..d06f8e1a --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/detailtag/DetailTagDomainService.kt @@ -0,0 +1,27 @@ +package org.yapp.domain.detailtag + +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.globalutils.annotation.DomainService +import java.util.* + +@DomainService +class DetailTagDomainService( + private val detailTagRepository: DetailTagRepository +) { + fun findById(id: UUID): DetailTag? { + return detailTagRepository.findById(id) + } + + fun findAllById(ids: List): List { + if (ids.isEmpty()) return emptyList() + return detailTagRepository.findAllById(ids) + } + + fun findByPrimaryEmotion(primaryEmotion: PrimaryEmotion): List { + return detailTagRepository.findByPrimaryEmotion(primaryEmotion) + } + + fun findAll(): List { + return detailTagRepository.findAll() + } +} From 28c7d8264f00aafab3759bf7a456d45361bd0887 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:05:29 +0900 Subject: [PATCH 11/18] =?UTF-8?q?[BOOK-480]=20feat:=20domain=20-=20Reading?= =?UTF-8?q?RecordDetailTagDomainService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReadingRecordDetailTagDomainService.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt new file mode 100644 index 00000000..9ae6b9d6 --- /dev/null +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecorddetailtag/ReadingRecordDetailTagDomainService.kt @@ -0,0 +1,34 @@ +package org.yapp.domain.readingrecorddetailtag + +import org.yapp.globalutils.annotation.DomainService +import java.util.* + +@DomainService +class ReadingRecordDetailTagDomainService( + private val readingRecordDetailTagRepository: ReadingRecordDetailTagRepository +) { + fun findByReadingRecordId(readingRecordId: UUID): List { + return readingRecordDetailTagRepository.findByReadingRecordId(readingRecordId) + } + + fun findByReadingRecordIdIn(readingRecordIds: List): List { + if (readingRecordIds.isEmpty()) return emptyList() + return readingRecordDetailTagRepository.findByReadingRecordIdIn(readingRecordIds) + } + + fun deleteAllByReadingRecordId(readingRecordId: UUID) { + readingRecordDetailTagRepository.deleteAllByReadingRecordId(readingRecordId) + } + + fun createAndSaveAll(readingRecordId: UUID, detailTagIds: List): List { + if (detailTagIds.isEmpty()) return emptyList() + val readingRecordDetailTags = detailTagIds.map { detailTagId -> + ReadingRecordDetailTag.create( + readingRecordId = readingRecordId, + detailTagId = detailTagId + ) + } + return readingRecordDetailTagRepository.saveAll(readingRecordDetailTags) + } +} + From ce821c6f1a3ed40fcc0063843b2903ba0c07d6de Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:05:41 +0900 Subject: [PATCH 12/18] =?UTF-8?q?[BOOK-480]=20refactor:=20domain=20-=20Rea?= =?UTF-8?q?dingRecordDomainService=20V2=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReadingRecordDomainService.kt | 105 +++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt index ae1e2a80..3075f213 100644 --- a/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt +++ b/domain/src/main/kotlin/org/yapp/domain/readingrecord/ReadingRecordDomainService.kt @@ -24,6 +24,94 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 private val userBookRepository: UserBookRepository ) { + // ===================== V2 API (Simple CRUD) ===================== + + fun createReadingRecordV2( + userBookId: UUID, + pageNumber: Int?, + quote: String, + review: String?, + primaryEmotion: PrimaryEmotion + ): ReadingRecord { + val userBook = userBookRepository.findById(userBookId) + ?: throw UserBookNotFoundException( + UserBookErrorCode.USER_BOOK_NOT_FOUND, + "User book not found with id: $userBookId" + ) + + val readingRecord = ReadingRecord.create( + userBookId = userBookId, + pageNumber = pageNumber, + quote = quote, + review = review, + primaryEmotion = primaryEmotion + ) + + val savedReadingRecord = readingRecordRepository.save(readingRecord) + userBookRepository.save(userBook.increaseReadingRecordCount()) + + return savedReadingRecord + } + + fun modifyReadingRecordV2( + readingRecordId: UUID, + pageNumber: Int?, + quote: String?, + review: String?, + primaryEmotion: PrimaryEmotion? + ): ReadingRecord { + val readingRecord = readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + + val updatedReadingRecord = readingRecord.update( + pageNumber = pageNumber, + quote = quote, + review = review, + primaryEmotion = primaryEmotion, + emotionTags = null + ) + + return readingRecordRepository.save(updatedReadingRecord) + } + + fun findById(readingRecordId: UUID): ReadingRecord { + return readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + } + + fun findByDynamicCondition( + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + return readingRecordRepository.findReadingRecordsByDynamicCondition(userBookId, sort, pageable) + } + + fun deleteReadingRecordV2(readingRecordId: UUID) { + val readingRecord = readingRecordRepository.findById(readingRecordId) + ?: throw ReadingRecordNotFoundException( + ReadingRecordErrorCode.READING_RECORD_NOT_FOUND, + "Reading record not found with id: $readingRecordId" + ) + + val userBook = userBookRepository.findById(readingRecord.userBookId.value) + ?: throw UserBookNotFoundException( + UserBookErrorCode.USER_BOOK_NOT_FOUND, + "User book not found with id: ${readingRecord.userBookId.value}" + ) + + readingRecordRepository.deleteById(readingRecordId) + userBookRepository.save(userBook.decreaseReadingRecordCount()) + } + + // ===================== V1 API (Legacy) ===================== + fun createReadingRecord( userBookId: UUID, pageNumber: Int, @@ -37,15 +125,22 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 "User book not found with id: $userBookId" ) + // Convert emotion tag to primary emotion + val primaryEmotion = emotionTags.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) + } ?: PrimaryEmotion.OTHER + val readingRecord = ReadingRecord.create( userBookId = userBookId, pageNumber = pageNumber, quote = quote, - review = review + review = review, + primaryEmotion = primaryEmotion ) val savedReadingRecord = readingRecordRepository.save(readingRecord) + // Also save to legacy reading_record_tags for backward compatibility val tags = emotionTags.map { tagName -> tagRepository.findByName(tagName) ?: tagRepository.save(Tag.create(tagName)) } @@ -70,7 +165,6 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 ) } - fun findReadingRecordById(readingRecordId: UUID): ReadingRecordInfoVO { val readingRecord = readingRecordRepository.findById(readingRecordId) ?: throw ReadingRecordNotFoundException( @@ -147,10 +241,16 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 "Reading record not found with id: $readingRecordId" ) + // Convert emotion tag to primary emotion if provided + val primaryEmotion = emotionTags?.firstOrNull()?.let { + PrimaryEmotion.fromDisplayName(it) + } + val updatedReadingRecord = readingRecord.update( pageNumber = pageNumber, quote = quote, review = review, + primaryEmotion = primaryEmotion, emotionTags = emotionTags ) @@ -177,6 +277,7 @@ class ReadingRecordDomainService( // TODO: readingRecordRepository만 남기고 fun deleteAllByUserBookId(userBookId: UUID) { readingRecordRepository.deleteAllByUserBookId(userBookId) } + fun deleteReadingRecord(readingRecordId: UUID) { val readingRecord = readingRecordRepository.findById(readingRecordId) ?: throw ReadingRecordNotFoundException( From 8af880678f067512f6f141001c05ec4b94b16e2e Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:05:51 +0900 Subject: [PATCH 13/18] =?UTF-8?q?[BOOK-480]=20feat:=20apis=20-=20V2=20Requ?= =?UTF-8?q?est/Response=20DTO=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/CreateReadingRecordRequestV2.kt | 51 ++++++++++ .../request/UpdateReadingRecordRequestV2.kt | 34 +++++++ .../dto/response/ReadingRecordResponseV2.kt | 96 +++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt new file mode 100644 index 00000000..185c28bd --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/CreateReadingRecordRequestV2.kt @@ -0,0 +1,51 @@ +package org.yapp.apis.readingrecord.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.Size +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema( + name = "CreateReadingRecordRequestV2", + description = "독서 기록 생성 요청 (V2)", + example = """ + { + "pageNumber": 42, + "quote": "이것은 기억에 남는 문장입니다.", + "review": "이 책은 매우 인상적이었습니다.", + "primaryEmotion": "JOY", + "detailEmotionTagIds": ["uuid-1", "uuid-2"] + } + """ +) +data class CreateReadingRecordRequestV2 private constructor( + + @field:Min(1, message = "페이지 번호는 1 이상이어야 합니다.") + @field:Max(9999, message = "페이지 번호는 9999 이하여야 합니다.") + @field:Schema(description = "현재 읽은 페이지 번호", example = "42", required = false) + val pageNumber: Int? = null, + + @field:NotBlank(message = "기억에 남는 문장은 필수입니다.") + @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.", required = true) + val quote: String? = null, + + @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.", required = false) + val review: String? = null, + + @field:Schema(description = "대분류 감정", example = "JOY", required = true) + val primaryEmotion: PrimaryEmotion? = null, + + @field:Schema(description = "세부 감정 태그 ID 목록 (선택, 다중 선택 가능)", example = "[\"uuid-1\", \"uuid-2\"]") + val detailEmotionTagIds: List = emptyList() +) { + fun validQuote(): String = + requireNotNull(quote) { "quote는 null일 수 없습니다." } + + fun validPrimaryEmotion(): PrimaryEmotion = + requireNotNull(primaryEmotion) { "primaryEmotion은 null일 수 없습니다." } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt new file mode 100644 index 00000000..f2b48b50 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/request/UpdateReadingRecordRequestV2.kt @@ -0,0 +1,34 @@ +package org.yapp.apis.readingrecord.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.Max +import jakarta.validation.constraints.Min +import jakarta.validation.constraints.Size +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema( + name = "UpdateReadingRecordRequestV2", + description = "독서 기록 수정 요청 (V2)" +) +data class UpdateReadingRecordRequestV2( + + @field:Min(1, message = "페이지 번호는 1 이상이어야 합니다.") + @field:Max(9999, message = "페이지 번호는 9999 이하여야 합니다.") + @field:Schema(description = "현재 읽은 페이지 번호", example = "42") + val pageNumber: Int? = null, + + @field:Size(max = 1000, message = "기억에 남는 문장은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") + val quote: String? = null, + + @field:Size(max = 1000, message = "감상평은 1000자를 초과할 수 없습니다.") + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.") + val review: String? = null, + + @field:Schema(description = "대분류 감정", example = "JOY") + val primaryEmotion: PrimaryEmotion? = null, + + @field:Schema(description = "세부 감정 태그 ID 목록 (null이면 변경하지 않음)") + val detailEmotionTagIds: List? = null +) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt new file mode 100644 index 00000000..278e6818 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponseV2.kt @@ -0,0 +1,96 @@ +package org.yapp.apis.readingrecord.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Schema( + name = "ReadingRecordResponseV2", + description = "독서 기록 응답 (V2)" +) +data class ReadingRecordResponseV2 private constructor( + @field:Schema(description = "독서 기록 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "사용자 책 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val userBookId: UUID, + + @field:Schema(description = "현재 읽은 페이지 번호 (선택)", example = "42") + val pageNumber: Int?, + + @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") + val quote: String, + + @field:Schema(description = "감상평", example = "이 책은 매우 인상적이었습니다.") + val review: String?, + + @field:Schema(description = "대분류 감정") + val primaryEmotion: PrimaryEmotionDto, + + @field:Schema(description = "세부 감정 목록") + val detailEmotions: List, + + @field:Schema(description = "생성 일시", example = "2023-01-01T12:00:00") + val createdAt: String, + + @field:Schema(description = "수정 일시", example = "2023-01-01T12:00:00") + val updatedAt: String, + + @field:Schema(description = "도서 제목", example = "클린 코드") + val bookTitle: String?, + + @field:Schema(description = "출판사", example = "인사이트") + val bookPublisher: String?, + + @field:Schema(description = "도서 썸네일 URL", example = "https://example.com/book-cover.jpg") + val bookCoverImageUrl: String?, + + @field:Schema(description = "저자", example = "로버트 C. 마틴") + val author: String? +) { + companion object { + private val dateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + + fun from(readingRecordInfoVO: ReadingRecordInfoVO): ReadingRecordResponseV2 { + return ReadingRecordResponseV2( + id = readingRecordInfoVO.id.value, + userBookId = readingRecordInfoVO.userBookId.value, + pageNumber = readingRecordInfoVO.pageNumber?.value, + quote = readingRecordInfoVO.quote.value, + review = readingRecordInfoVO.review?.value, + primaryEmotion = PrimaryEmotionDto( + code = readingRecordInfoVO.primaryEmotion.name, + displayName = readingRecordInfoVO.primaryEmotion.displayName + ), + detailEmotions = readingRecordInfoVO.detailEmotions.map { + DetailEmotionDto(id = it.id, name = it.name) + }, + createdAt = readingRecordInfoVO.createdAt.format(dateTimeFormatter), + updatedAt = readingRecordInfoVO.updatedAt.format(dateTimeFormatter), + bookTitle = readingRecordInfoVO.bookTitle, + bookPublisher = readingRecordInfoVO.bookPublisher, + bookCoverImageUrl = readingRecordInfoVO.bookCoverImageUrl, + author = readingRecordInfoVO.author + ) + } + } +} + +@Schema(name = "PrimaryEmotionDto", description = "대분류 감정") +data class PrimaryEmotionDto( + @field:Schema(description = "감정 코드", example = "JOY") + val code: String, + + @field:Schema(description = "감정 표시 이름", example = "즐거움") + val displayName: String +) + +@Schema(name = "DetailEmotionDto", description = "세부 감정") +data class DetailEmotionDto( + @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "세부 감정 이름", example = "설레는") + val name: String +) From ff5b0b659136f8d3c03ce9b3fece88992142771d Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:06:01 +0900 Subject: [PATCH 14/18] =?UTF-8?q?[BOOK-480]=20feat:=20apis=20-=20ReadingRe?= =?UTF-8?q?cordServiceV2=20ApplicationService=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ReadingRecordServiceV2.kt | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt new file mode 100644 index 00000000..8a12ebc0 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/service/ReadingRecordServiceV2.kt @@ -0,0 +1,180 @@ +package org.yapp.apis.readingrecord.service + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.domain.detailtag.DetailTagDomainService +import org.yapp.domain.readingrecord.PrimaryEmotion +import org.yapp.domain.readingrecord.ReadingRecord +import org.yapp.domain.readingrecord.ReadingRecordDomainService +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.domain.readingrecord.vo.ReadingRecordInfoVO +import org.yapp.domain.readingrecorddetailtag.ReadingRecordDetailTagDomainService +import org.yapp.domain.user.UserDomainService +import org.yapp.globalutils.annotation.ApplicationService +import java.util.* + +@ApplicationService +class ReadingRecordServiceV2( + private val readingRecordDomainService: ReadingRecordDomainService, + private val detailTagDomainService: DetailTagDomainService, + private val readingRecordDetailTagDomainService: ReadingRecordDetailTagDomainService, + private val userDomainService: UserDomainService +) { + fun createReadingRecord( + userId: UUID, + userBookId: UUID, + request: CreateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + val primaryEmotion = request.validPrimaryEmotion() + val detailEmotionTagIds = request.detailEmotionTagIds + + // Validate detail emotion tags belong to the selected primary emotion + validateDetailEmotionTags(detailEmotionTagIds, primaryEmotion) + + // Create reading record + val savedReadingRecord = readingRecordDomainService.createReadingRecordV2( + userBookId = userBookId, + pageNumber = request.pageNumber, + quote = request.validQuote(), + review = request.review, + primaryEmotion = primaryEmotion + ) + + // Save detail emotion tags + readingRecordDetailTagDomainService.createAndSaveAll( + readingRecordId = savedReadingRecord.id.value, + detailTagIds = detailEmotionTagIds + ) + + // Update user's lastActivity + userDomainService.updateLastActivity(userId) + + return buildResponse(savedReadingRecord, detailEmotionTagIds) + } + + fun getReadingRecordDetail( + readingRecordId: UUID + ): ReadingRecordResponseV2 { + val readingRecord = readingRecordDomainService.findById(readingRecordId) + val detailTagIds = readingRecordDetailTagDomainService.findByReadingRecordId(readingRecordId) + .map { it.detailTagId.value } + + return buildResponse(readingRecord, detailTagIds) + } + + fun getReadingRecordsByDynamicCondition( + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + val readingRecordPage = readingRecordDomainService.findByDynamicCondition(userBookId, sort, pageable) + if (readingRecordPage.isEmpty) { + return Page.empty(pageable) + } + + val readingRecords = readingRecordPage.content + val readingRecordIds = readingRecords.map { it.id.value } + + // Fetch detail tags + val readingRecordDetailTags = readingRecordDetailTagDomainService.findByReadingRecordIdIn(readingRecordIds) + val detailTagIds = readingRecordDetailTags.map { it.detailTagId.value }.distinct() + val detailTagsById = detailTagDomainService.findAllById(detailTagIds).associateBy { it.id.value } + + val detailTagsByReadingRecordId = readingRecordDetailTags + .groupBy { it.readingRecordId.value } + .mapValues { (_, tags) -> + tags.mapNotNull { detailTagsById[it.detailTagId.value] } + .map { ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) } + } + + return readingRecordPage.map { readingRecord -> + val detailEmotions = detailTagsByReadingRecordId[readingRecord.id.value] ?: emptyList() + ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = readingRecord, + detailEmotions = detailEmotions + ) + ) + } + } + + fun updateReadingRecord( + userId: UUID, + readingRecordId: UUID, + request: UpdateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + val existingRecord = readingRecordDomainService.findById(readingRecordId) + val newPrimaryEmotion = request.primaryEmotion ?: existingRecord.primaryEmotion + + // Validate detail emotion tags if provided + if (!request.detailEmotionTagIds.isNullOrEmpty()) { + validateDetailEmotionTags(request.detailEmotionTagIds, newPrimaryEmotion) + } + + // Update reading record + val savedReadingRecord = readingRecordDomainService.modifyReadingRecordV2( + readingRecordId = readingRecordId, + pageNumber = request.pageNumber, + quote = request.quote, + review = request.review, + primaryEmotion = request.primaryEmotion + ) + + // Update detail emotion tags if provided + if (request.detailEmotionTagIds != null) { + readingRecordDetailTagDomainService.deleteAllByReadingRecordId(readingRecordId) + readingRecordDetailTagDomainService.createAndSaveAll( + readingRecordId = savedReadingRecord.id.value, + detailTagIds = request.detailEmotionTagIds + ) + } + + val finalDetailTagIds = request.detailEmotionTagIds + ?: readingRecordDetailTagDomainService.findByReadingRecordId(readingRecordId).map { it.detailTagId.value } + + // Update user's lastActivity + userDomainService.updateLastActivity(userId) + + return buildResponse(savedReadingRecord, finalDetailTagIds) + } + + fun deleteReadingRecord(readingRecordId: UUID) { + readingRecordDetailTagDomainService.deleteAllByReadingRecordId(readingRecordId) + readingRecordDomainService.deleteReadingRecordV2(readingRecordId) + } + + private fun validateDetailEmotionTags(detailEmotionTagIds: List, primaryEmotion: PrimaryEmotion) { + if (detailEmotionTagIds.isEmpty()) return + + val detailTags = detailTagDomainService.findAllById(detailEmotionTagIds) + require(detailTags.size == detailEmotionTagIds.size) { + "Some detail emotion tag IDs are invalid" + } + require(detailTags.all { it.primaryEmotion == primaryEmotion }) { + "All detail emotions must belong to the selected primary emotion" + } + } + + private fun buildResponse( + readingRecord: ReadingRecord, + detailTagIds: List + ): ReadingRecordResponseV2 { + val detailEmotions = if (detailTagIds.isNotEmpty()) { + detailTagDomainService.findAllById(detailTagIds).map { + ReadingRecordInfoVO.DetailEmotionInfo(it.id.value, it.name) + } + } else { + emptyList() + } + + return ReadingRecordResponseV2.from( + ReadingRecordInfoVO.newInstance( + readingRecord = readingRecord, + detailEmotions = detailEmotions + ) + ) + } +} From 0cdf8d9d5194f72b2003ca68bb28c6db173b39f2 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:06:10 +0900 Subject: [PATCH 15/18] =?UTF-8?q?[BOOK-480]=20feat:=20apis=20-=20ReadingRe?= =?UTF-8?q?cordUseCaseV2=20=EC=9C=A0=EC=8A=A4=EC=BC=80=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/ReadingRecordUseCaseV2.kt | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt new file mode 100644 index 00000000..048973b7 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/usecase/ReadingRecordUseCaseV2.kt @@ -0,0 +1,89 @@ +package org.yapp.apis.readingrecord.usecase + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.transaction.annotation.Transactional +import org.yapp.apis.book.service.UserBookService +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.service.ReadingRecordServiceV2 +import org.yapp.apis.user.service.UserService +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.globalutils.annotation.UseCase +import java.util.* + +@UseCase +@Transactional(readOnly = true) +class ReadingRecordUseCaseV2( + private val readingRecordServiceV2: ReadingRecordServiceV2, + private val userService: UserService, + private val userBookService: UserBookService, +) { + @Transactional + fun createReadingRecord( + userId: UUID, + userBookId: UUID, + request: CreateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.createReadingRecord( + userId = userId, + userBookId = userBookId, + request = request + ) + } + + fun getReadingRecordDetail( + userId: UUID, + readingRecordId: UUID + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + + return readingRecordServiceV2.getReadingRecordDetail( + readingRecordId = readingRecordId + ) + } + + fun getReadingRecordsByUserBookId( + userId: UUID, + userBookId: UUID, + sort: ReadingRecordSortType?, + pageable: Pageable + ): Page { + userService.validateUserExists(userId) + userBookService.validateUserBookExists(userBookId, userId) + + return readingRecordServiceV2.getReadingRecordsByDynamicCondition( + userBookId = userBookId, + sort = sort, + pageable = pageable + ) + } + + @Transactional + fun updateReadingRecord( + userId: UUID, + readingRecordId: UUID, + request: UpdateReadingRecordRequestV2 + ): ReadingRecordResponseV2 { + userService.validateUserExists(userId) + + return readingRecordServiceV2.updateReadingRecord( + userId = userId, + readingRecordId = readingRecordId, + request = request + ) + } + + @Transactional + fun deleteReadingRecord( + userId: UUID, + readingRecordId: UUID + ) { + userService.validateUserExists(userId) + readingRecordServiceV2.deleteReadingRecord(readingRecordId) + } +} From 31d58a3ccbe1dd8d3682699007d72306f61b9273 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:06:20 +0900 Subject: [PATCH 16/18] =?UTF-8?q?[BOOK-480]=20feat:=20apis=20-=20ReadingRe?= =?UTF-8?q?cordControllerV2=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=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 --- .../ReadingRecordControllerApiV2.kt | 153 ++++++++++++++++++ .../controller/ReadingRecordControllerV2.kt | 90 +++++++++++ 2 files changed, 243 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt new file mode 100644 index 00000000..b62b129b --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerApiV2.kt @@ -0,0 +1,153 @@ +package org.yapp.apis.readingrecord.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.domain.readingrecord.ReadingRecordSortType +import org.yapp.globalutils.exception.ErrorResponse +import java.util.* + +@Tag(name = "Reading Records V2", description = "독서 기록 관련 API (V2)") +@RequestMapping("/api/v2/reading-records") +interface ReadingRecordControllerApiV2 { + + @Operation( + summary = "독서 기록 생성 (V2)", + description = "사용자의 책에 대한 독서 기록을 생성합니다. 대분류 감정은 필수, 세부 감정은 선택입니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "201", + description = "독서 기록 생성 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 책을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @PostMapping("/{userBookId}") + fun createReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "독서 기록을 생성할 사용자 책 ID") userBookId: UUID, + @Valid @RequestBody @Parameter(description = "독서 기록 생성 요청 객체") request: CreateReadingRecordRequestV2 + ): ResponseEntity + + @Operation( + summary = "독서 기록 상세 조회 (V2)", + description = "독서 기록 ID로 독서 기록 상세 정보를 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 상세 조회 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/detail/{readingRecordId}") + fun getReadingRecordDetail( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "조회할 독서 기록 ID") readingRecordId: UUID + ): ResponseEntity + + @Operation( + summary = "독서 기록 목록 조회 (V2)", + description = "사용자의 책에 대한 독서 기록을 페이징하여 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 목록 조회 성공" + ), + ApiResponse( + responseCode = "404", + description = "사용자 또는 책을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @GetMapping("/{userBookId}") + fun getReadingRecords( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "독서 기록을 조회할 사용자 책 ID") userBookId: UUID, + @RequestParam(required = false) @Parameter(description = "정렬 타입") sort: ReadingRecordSortType?, + @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) + @Parameter(description = "페이징 정보") pageable: Pageable + ): ResponseEntity> + + @Operation( + summary = "독서 기록 수정 (V2)", + description = "독서 기록을 수정합니다. 대분류 감정과 세부 감정을 변경할 수 있습니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "독서 기록 수정 성공", + content = [Content(schema = Schema(implementation = ReadingRecordResponseV2::class))] + ), + ApiResponse( + responseCode = "404", + description = "독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @PutMapping("/{readingRecordId}") + fun updateReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "수정할 독서 기록 ID") readingRecordId: UUID, + @Valid @RequestBody @Parameter(description = "독서 기록 수정 요청 객체") request: UpdateReadingRecordRequestV2 + ): ResponseEntity + + @Operation( + summary = "독서 기록 삭제", + description = "독서 기록을 삭제합니다." + ) + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "독서 기록 삭제 성공"), + ApiResponse( + responseCode = "404", + description = "독서 기록을 찾을 수 없음", + content = [Content(schema = Schema(implementation = ErrorResponse::class))] + ) + ] + ) + @DeleteMapping("/{readingRecordId}") + fun deleteReadingRecord( + @AuthenticationPrincipal @Parameter(description = "인증된 사용자 ID") userId: UUID, + @PathVariable @Parameter(description = "삭제할 독서 기록 ID") readingRecordId: UUID + ): ResponseEntity +} diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt new file mode 100644 index 00000000..6e1a1e19 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/controller/ReadingRecordControllerV2.kt @@ -0,0 +1,90 @@ +package org.yapp.apis.readingrecord.controller + +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.data.web.PageableDefault +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* +import org.yapp.apis.readingrecord.dto.request.CreateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.request.UpdateReadingRecordRequestV2 +import org.yapp.apis.readingrecord.dto.response.ReadingRecordResponseV2 +import org.yapp.apis.readingrecord.usecase.ReadingRecordUseCaseV2 +import org.yapp.domain.readingrecord.ReadingRecordSortType +import java.util.UUID + +@RestController +@RequestMapping("/api/v2/reading-records") +class ReadingRecordControllerV2( + private val readingRecordUseCaseV2: ReadingRecordUseCaseV2 +) : ReadingRecordControllerApiV2 { + + @PostMapping("/{userBookId}") + override fun createReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable userBookId: UUID, + @Valid @RequestBody request: CreateReadingRecordRequestV2 + ): ResponseEntity { + val response = readingRecordUseCaseV2.createReadingRecord( + userId = userId, + userBookId = userBookId, + request = request + ) + return ResponseEntity.status(HttpStatus.CREATED).body(response) + } + + @GetMapping("/detail/{readingRecordId}") + override fun getReadingRecordDetail( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID + ): ResponseEntity { + val response = readingRecordUseCaseV2.getReadingRecordDetail( + userId = userId, + readingRecordId = readingRecordId + ) + return ResponseEntity.ok(response) + } + + @GetMapping("/{userBookId}") + override fun getReadingRecords( + @AuthenticationPrincipal userId: UUID, + @PathVariable userBookId: UUID, + @RequestParam(required = false) sort: ReadingRecordSortType?, + @PageableDefault(size = 10, sort = ["createdAt"], direction = Sort.Direction.DESC) + pageable: Pageable + ): ResponseEntity> { + val response = readingRecordUseCaseV2.getReadingRecordsByUserBookId( + userId = userId, + userBookId = userBookId, + sort = sort, + pageable = pageable + ) + return ResponseEntity.ok(response) + } + + @PutMapping("/{readingRecordId}") + override fun updateReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID, + @Valid @RequestBody request: UpdateReadingRecordRequestV2 + ): ResponseEntity { + val response = readingRecordUseCaseV2.updateReadingRecord( + userId = userId, + readingRecordId = readingRecordId, + request = request + ) + return ResponseEntity.ok(response) + } + + @DeleteMapping("/{readingRecordId}") + override fun deleteReadingRecord( + @AuthenticationPrincipal userId: UUID, + @PathVariable readingRecordId: UUID + ): ResponseEntity { + readingRecordUseCaseV2.deleteReadingRecord(userId, readingRecordId) + return ResponseEntity.noContent().build() + } +} From 87b8b6c47a0af52d00e2e10c02e80c629d91b09b Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:06:28 +0900 Subject: [PATCH 17/18] =?UTF-8?q?[BOOK-480]=20feat:=20apis=20-=20EmotionCo?= =?UTF-8?q?ntroller=20=EA=B0=90=EC=A0=95=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../emotion/controller/EmotionController.kt | 21 ++++++++ .../controller/EmotionControllerApi.kt | 33 ++++++++++++ .../dto/response/EmotionListResponse.kt | 52 +++++++++++++++++++ .../apis/emotion/service/EmotionService.kt | 16 ++++++ 4 files changed, 122 insertions(+) create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt create mode 100644 apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt new file mode 100644 index 00000000..42cb80e3 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionController.kt @@ -0,0 +1,21 @@ +package org.yapp.apis.emotion.controller + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import org.yapp.apis.emotion.dto.response.EmotionListResponse +import org.yapp.apis.emotion.service.EmotionService + +@RestController +@RequestMapping("/api/v2/emotions") +class EmotionController( + private val emotionService: EmotionService +) : EmotionControllerApi { + + @GetMapping + override fun getEmotions(): ResponseEntity { + val response = emotionService.getEmotionList() + return ResponseEntity.ok(response) + } +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt new file mode 100644 index 00000000..4651d31d --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/controller/EmotionControllerApi.kt @@ -0,0 +1,33 @@ +package org.yapp.apis.emotion.controller + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.media.Content +import io.swagger.v3.oas.annotations.media.Schema +import io.swagger.v3.oas.annotations.responses.ApiResponse +import io.swagger.v3.oas.annotations.responses.ApiResponses +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.yapp.apis.emotion.dto.response.EmotionListResponse + +@Tag(name = "Emotions", description = "감정 관련 API") +@RequestMapping("/api/v2/emotions") +interface EmotionControllerApi { + + @Operation( + summary = "감정 목록 조회", + description = "대분류 감정과 세부 감정 목록을 조회합니다." + ) + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "감정 목록 조회 성공", + content = [Content(schema = Schema(implementation = EmotionListResponse::class))] + ) + ] + ) + @GetMapping + fun getEmotions(): ResponseEntity +} diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt new file mode 100644 index 00000000..99f870c9 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/dto/response/EmotionListResponse.kt @@ -0,0 +1,52 @@ +package org.yapp.apis.emotion.dto.response + +import io.swagger.v3.oas.annotations.media.Schema +import org.yapp.domain.detailtag.DetailTag +import org.yapp.domain.readingrecord.PrimaryEmotion +import java.util.UUID + +@Schema(name = "EmotionListResponse", description = "감정 목록 응답") +data class EmotionListResponse( + @field:Schema(description = "감정 그룹 목록") + val emotions: List +) { + companion object { + fun from(detailTags: List): EmotionListResponse { + val grouped = detailTags.groupBy { it.primaryEmotion } + + val emotions = PrimaryEmotion.entries.map { primary -> + EmotionGroupDto( + code = primary.name, + displayName = primary.displayName, + detailEmotions = grouped[primary] + ?.sortedBy { it.displayOrder } + ?.map { EmotionDetailDto(id = it.id.value, name = it.name) } + ?: emptyList() + ) + } + + return EmotionListResponse(emotions = emotions) + } + } +} + +@Schema(name = "EmotionGroupDto", description = "감정 그룹 (대분류 + 세부감정)") +data class EmotionGroupDto( + @field:Schema(description = "대분류 코드", example = "JOY") + val code: String, + + @field:Schema(description = "대분류 표시 이름", example = "즐거움") + val displayName: String, + + @field:Schema(description = "세부 감정 목록") + val detailEmotions: List +) + +@Schema(name = "EmotionDetailDto", description = "세부 감정") +data class EmotionDetailDto( + @field:Schema(description = "세부 감정 ID", example = "123e4567-e89b-12d3-a456-426614174000") + val id: UUID, + + @field:Schema(description = "세부 감정 이름", example = "설레는") + val name: String +) diff --git a/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt b/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt new file mode 100644 index 00000000..41c485d2 --- /dev/null +++ b/apis/src/main/kotlin/org/yapp/apis/emotion/service/EmotionService.kt @@ -0,0 +1,16 @@ +package org.yapp.apis.emotion.service + +import org.yapp.apis.emotion.dto.response.EmotionListResponse +import org.yapp.domain.detailtag.DetailTagDomainService +import org.yapp.globalutils.annotation.ApplicationService + +@ApplicationService +class EmotionService( + private val detailTagDomainService: DetailTagDomainService +) { + fun getEmotionList(): EmotionListResponse { + val detailTags = detailTagDomainService.findAll() + return EmotionListResponse.from(detailTags) + } +} + From ad44b1d4930bf08988508ad950b351fb8e5e74a1 Mon Sep 17 00:00:00 2001 From: move-hoon Date: Wed, 24 Dec 2025 19:06:40 +0900 Subject: [PATCH 18/18] =?UTF-8?q?[BOOK-480]=20fix:=20apis=20-=20V1=20Readi?= =?UTF-8?q?ngRecordResponse=20pageNumber=20nullable=20=ED=98=B8=ED=99=98?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apis/readingrecord/dto/response/ReadingRecordResponse.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt index 6107182a..6407f096 100644 --- a/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt +++ b/apis/src/main/kotlin/org/yapp/apis/readingrecord/dto/response/ReadingRecordResponse.kt @@ -18,7 +18,7 @@ data class ReadingRecordResponse private constructor( val userBookId: UUID, @field:Schema(description = "현재 읽은 페이지 번호", example = "42") - val pageNumber: Int, + val pageNumber: Int?, @field:Schema(description = "기억에 남는 문장", example = "이것은 기억에 남는 문장입니다.") val quote: String, @@ -54,7 +54,7 @@ data class ReadingRecordResponse private constructor( return ReadingRecordResponse( id = readingRecordInfoVO.id.value, userBookId = readingRecordInfoVO.userBookId.value, - pageNumber = readingRecordInfoVO.pageNumber.value, + pageNumber = readingRecordInfoVO.pageNumber?.value, quote = readingRecordInfoVO.quote.value, review = readingRecordInfoVO.review?.value, emotionTags = readingRecordInfoVO.emotionTags, @@ -68,3 +68,4 @@ data class ReadingRecordResponse private constructor( } } } +