From 67186bba55c47dcbba74273af55a686ee405d2be Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 18 Sep 2025 18:04:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(original):=20=EC=9B=90=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원천 원작, 원천 원작 링크, 글/그림 작가, 제작사, 태그 추가 --- .../chat/original/dto/OriginalWorkDtos.kt | 24 +++++++- .../service/AdminOriginalWorkService.kt | 56 ++++++++++++++++++- .../sodalive/chat/original/OriginalWork.kt | 26 ++++++++- .../chat/original/OriginalWorkLink.kt | 22 ++++++++ .../sodalive/chat/original/OriginalWorkTag.kt | 21 +++++++ .../chat/original/OriginalWorkTagMapping.kt | 21 +++++++ .../chat/original/dto/OriginalWorkAppDtos.kt | 10 ++++ .../repository/OriginalWorkTagRepository.kt | 10 ++++ 8 files changed, 184 insertions(+), 6 deletions(-) create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt create mode 100644 src/main/kotlin/kr/co/vividnext/sodalive/chat/original/repository/OriginalWorkTagRepository.kt diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt index 263582c..55eda2b 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/dto/OriginalWorkDtos.kt @@ -12,7 +12,12 @@ data class OriginalWorkRegisterRequest( @JsonProperty("category") val category: String, @JsonProperty("isAdult") val isAdult: Boolean = false, @JsonProperty("description") val description: String = "", - @JsonProperty("originalLink") val originalLink: String? = null + @JsonProperty("originalWork") val originalWork: String? = null, + @JsonProperty("originalLink") val originalLink: String? = null, + @JsonProperty("writer") val writer: String? = null, + @JsonProperty("studio") val studio: String? = null, + @JsonProperty("originalLinks") val originalLinks: List? = null, + @JsonProperty("tags") val tags: List? = null ) /** @@ -25,7 +30,12 @@ data class OriginalWorkUpdateRequest( @JsonProperty("category") val category: String? = null, @JsonProperty("isAdult") val isAdult: Boolean? = null, @JsonProperty("description") val description: String? = null, - @JsonProperty("originalLink") val originalLink: String? = null + @JsonProperty("originalWork") val originalWork: String? = null, + @JsonProperty("originalLink") val originalLink: String? = null, + @JsonProperty("writer") val writer: String? = null, + @JsonProperty("studio") val studio: String? = null, + @JsonProperty("originalLinks") val originalLinks: List? = null, + @JsonProperty("tags") val tags: List? = null ) /** @@ -38,7 +48,12 @@ data class OriginalWorkResponse( val category: String, val isAdult: Boolean, val description: String, + val originalWork: String?, val originalLink: String?, + val writer: String?, + val studio: String?, + val originalLinks: List, + val tags: List, val imageUrl: String? ) { companion object { @@ -55,7 +70,12 @@ data class OriginalWorkResponse( category = entity.category, isAdult = entity.isAdult, description = entity.description, + originalWork = entity.originalWork, originalLink = entity.originalLink, + writer = entity.writer, + studio = entity.studio, + originalLinks = entity.originalLinks.map { it.url }, + tags = entity.tagMappings.map { it.tag.tag }, imageUrl = fullImagePath ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt index e855f91..e02e632 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/original/service/AdminOriginalWorkService.kt @@ -6,6 +6,9 @@ import kr.co.vividnext.sodalive.chat.character.ChatCharacter import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository import kr.co.vividnext.sodalive.chat.original.OriginalWork import kr.co.vividnext.sodalive.chat.original.OriginalWorkRepository +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTagMapping +import kr.co.vividnext.sodalive.chat.original.repository.OriginalWorkTagRepository import kr.co.vividnext.sodalive.common.SodaException import org.springframework.data.domain.Page import org.springframework.data.domain.PageRequest @@ -20,7 +23,8 @@ import org.springframework.transaction.annotation.Transactional @Service class AdminOriginalWorkService( private val originalWorkRepository: OriginalWorkRepository, - private val chatCharacterRepository: ChatCharacterRepository + private val chatCharacterRepository: ChatCharacterRepository, + private val originalWorkTagRepository: OriginalWorkTagRepository ) { /** 원작 등록 (중복 제목 방지 포함) */ @@ -35,8 +39,23 @@ class AdminOriginalWorkService( category = request.category, isAdult = request.isAdult, description = request.description, - originalLink = request.originalLink + originalWork = request.originalWork, + originalLink = request.originalLink, + writer = request.writer, + studio = request.studio ) + // 링크 리스트 생성 + request.originalLinks?.filter { it.isNotBlank() }?.forEach { link -> + entity.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = entity)) + } + // 태그 매핑 생성 (기존 태그 재사용) + request.tags?.let { tags -> + val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet() + normalized.forEach { t -> + val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t)) + entity.tagMappings.add(OriginalWorkTagMapping(originalWork = entity, tag = tagEntity)) + } + } return originalWorkRepository.save(entity) } @@ -51,7 +70,40 @@ class AdminOriginalWorkService( request.category?.let { ow.category = it } request.isAdult?.let { ow.isAdult = it } request.description?.let { ow.description = it } + request.originalWork?.let { ow.originalWork = it } request.originalLink?.let { ow.originalLink = it } + request.writer?.let { ow.writer = it } + request.studio?.let { ow.studio = it } + // 링크 리스트가 전달되면 기존 것을 교체 + request.originalLinks?.let { links -> + ow.originalLinks.clear() + links.filter { it.isNotBlank() }.forEach { link -> + ow.originalLinks.add(kr.co.vividnext.sodalive.chat.original.OriginalWorkLink(url = link, originalWork = ow)) + } + } + // 태그 변경사항만 반영 (요청이 null이면 변경 없음) + request.tags?.let { tags -> + val normalized = tags.map { it.trim() }.filter { it.isNotBlank() }.toSet() + val current = ow.tagMappings.map { it.tag.tag }.toSet() + val toAdd = normalized.minus(current) + val toRemove = current.minus(normalized) + + if (toRemove.isNotEmpty()) { + val itr = ow.tagMappings.iterator() + while (itr.hasNext()) { + val m = itr.next() + if (toRemove.contains(m.tag.tag)) { + itr.remove() // orphanRemoval=true로 매핑 삭제 + } + } + } + if (toAdd.isNotEmpty()) { + toAdd.forEach { t -> + val tagEntity = originalWorkTagRepository.findByTag(t) ?: originalWorkTagRepository.save(OriginalWorkTag(t)) + ow.tagMappings.add(OriginalWorkTagMapping(originalWork = ow, tag = tagEntity)) + } + } + } if (imagePath != null) { ow.imagePath = imagePath } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt index 35dc383..543c635 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWork.kt @@ -1,8 +1,10 @@ package kr.co.vividnext.sodalive.chat.original import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.CascadeType import javax.persistence.Column import javax.persistence.Entity +import javax.persistence.OneToMany /** * 원작(오리지널 작품) 엔티티 @@ -31,13 +33,33 @@ class OriginalWork( @Column(columnDefinition = "TEXT") var description: String = "", - /** 원작 링크 */ + /** 원천 원작 */ @Column(nullable = true) - var originalLink: String? = null + var originalWork: String? = null, + + /** 원천 원작 링크(단일) */ + @Column(nullable = true) + var originalLink: String? = null, + + /** 작가 */ + @Column(nullable = true) + var writer: String? = null, + + /** 제작사 */ + @Column(nullable = true) + var studio: String? = null ) : BaseEntity() { /** 원작 대표 이미지 S3 경로 */ var imagePath: String? = null /** 소프트 삭제 여부 (true면 삭제된 것으로 간주) */ var isDeleted: Boolean = false + + /** 원작 링크들 (1:N) */ + @OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true) + var originalLinks: MutableList = mutableListOf() + + /** 원작 태그 매핑들 (1:N) */ + @OneToMany(mappedBy = "originalWork", cascade = [CascadeType.ALL], orphanRemoval = true) + var tagMappings: MutableList = mutableListOf() } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt new file mode 100644 index 0000000..1e5cab6 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkLink.kt @@ -0,0 +1,22 @@ +package kr.co.vividnext.sodalive.chat.original + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * 원작 원본 링크 엔티티 + * - 하나의 원작(OriginalWork)에 여러 개의 링크가 연결될 수 있음 (1:N) + */ +@Entity +class OriginalWorkLink( + @Column(nullable = false) + var url: String, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "original_work_id") + var originalWork: OriginalWork? = null +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt new file mode 100644 index 0000000..764c330 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTag.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.original + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.OneToMany +import javax.persistence.Table +import javax.persistence.UniqueConstraint + +/** + * 원작 태그 엔티티 (작품/시리즈 태그와 분리) + */ +@Entity +@Table(uniqueConstraints = [UniqueConstraint(columnNames = ["tag"])]) +class OriginalWorkTag( + @Column(nullable = false) + val tag: String +) : BaseEntity() { + @OneToMany(mappedBy = "tag") + var tagMappings: MutableList = mutableListOf() +} diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt new file mode 100644 index 0000000..7e53d48 --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/OriginalWorkTagMapping.kt @@ -0,0 +1,21 @@ +package kr.co.vividnext.sodalive.chat.original + +import kr.co.vividnext.sodalive.common.BaseEntity +import javax.persistence.Entity +import javax.persistence.FetchType +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne + +/** + * OriginalWork 와 OriginalWorkTag 매핑 엔티티 + */ +@Entity +class OriginalWorkTagMapping( + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "original_work_id") + val originalWork: OriginalWork, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tag_id") + val tag: OriginalWorkTag +) : BaseEntity() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt index 7520eca..d1a652d 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/dto/OriginalWorkAppDtos.kt @@ -48,7 +48,12 @@ data class OriginalWorkDetailResponse( @JsonProperty("category") val category: String, @JsonProperty("isAdult") val isAdult: Boolean, @JsonProperty("description") val description: String, + @JsonProperty("originalWork") val originalWork: String?, @JsonProperty("originalLink") val originalLink: String?, + @JsonProperty("writer") val writer: String?, + @JsonProperty("studio") val studio: String?, + @JsonProperty("originalLinks") val originalLinks: List, + @JsonProperty("tags") val tags: List, @JsonProperty("characters") val characters: List ) { companion object { @@ -69,7 +74,12 @@ data class OriginalWorkDetailResponse( category = entity.category, isAdult = entity.isAdult, description = entity.description, + originalWork = entity.originalWork, originalLink = entity.originalLink, + writer = entity.writer, + studio = entity.studio, + originalLinks = entity.originalLinks.map { it.url }, + tags = entity.tagMappings.map { it.tag.tag }, characters = characters ) } diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/repository/OriginalWorkTagRepository.kt b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/repository/OriginalWorkTagRepository.kt new file mode 100644 index 0000000..fe4241a --- /dev/null +++ b/src/main/kotlin/kr/co/vividnext/sodalive/chat/original/repository/OriginalWorkTagRepository.kt @@ -0,0 +1,10 @@ +package kr.co.vividnext.sodalive.chat.original.repository + +import kr.co.vividnext.sodalive.chat.original.OriginalWorkTag +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface OriginalWorkTagRepository : JpaRepository { + fun findByTag(tag: String): OriginalWorkTag? +}