캐릭터 챗봇 #338
| @@ -0,0 +1,125 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.image | ||||||
|  |  | ||||||
|  | import com.amazonaws.services.s3.model.ObjectMetadata | ||||||
|  | import com.fasterxml.jackson.databind.ObjectMapper | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.image.dto.AdminCharacterImageResponse | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.image.dto.RegisterCharacterImageRequest | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageOrdersRequest | ||||||
|  | import kr.co.vividnext.sodalive.admin.chat.character.image.dto.UpdateCharacterImageTriggersRequest | ||||||
|  | import kr.co.vividnext.sodalive.aws.cloudfront.ImageContentCloudFront | ||||||
|  | import kr.co.vividnext.sodalive.aws.s3.S3Uploader | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.image.CharacterImageService | ||||||
|  | import kr.co.vividnext.sodalive.common.ApiResponse | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import kr.co.vividnext.sodalive.utils.generateFileName | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | import org.springframework.security.access.prepost.PreAuthorize | ||||||
|  | import org.springframework.web.bind.annotation.DeleteMapping | ||||||
|  | import org.springframework.web.bind.annotation.GetMapping | ||||||
|  | import org.springframework.web.bind.annotation.PathVariable | ||||||
|  | import org.springframework.web.bind.annotation.PostMapping | ||||||
|  | import org.springframework.web.bind.annotation.PutMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestBody | ||||||
|  | import org.springframework.web.bind.annotation.RequestMapping | ||||||
|  | import org.springframework.web.bind.annotation.RequestParam | ||||||
|  | import org.springframework.web.bind.annotation.RequestPart | ||||||
|  | import org.springframework.web.bind.annotation.RestController | ||||||
|  | import org.springframework.web.multipart.MultipartFile | ||||||
|  |  | ||||||
|  | @RestController | ||||||
|  | @RequestMapping("/admin/chat/character/image") | ||||||
|  | @PreAuthorize("hasRole('ADMIN')") | ||||||
|  | class AdminCharacterImageController( | ||||||
|  |     private val imageService: CharacterImageService, | ||||||
|  |     private val s3Uploader: S3Uploader, | ||||||
|  |     private val imageCloudFront: ImageContentCloudFront, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.s3.content-bucket}") | ||||||
|  |     private val s3Bucket: String | ||||||
|  | ) { | ||||||
|  |  | ||||||
|  |     @GetMapping("/list") | ||||||
|  |     fun list(@RequestParam characterId: Long) = run { | ||||||
|  |         val expiration = 5L * 60L * 1000L // 5분 | ||||||
|  |         val list = imageService.listActiveByCharacter(characterId) | ||||||
|  |             .map { img -> | ||||||
|  |                 val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) | ||||||
|  |                 AdminCharacterImageResponse.fromWithUrl(img, signedUrl) | ||||||
|  |             } | ||||||
|  |         ApiResponse.ok(list) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @GetMapping("/{imageId}") | ||||||
|  |     fun detail(@PathVariable imageId: Long) = run { | ||||||
|  |         val img = imageService.getById(imageId) | ||||||
|  |         val expiration = 5L * 60L * 1000L // 5분 | ||||||
|  |         val signedUrl = imageCloudFront.generateSignedURL(img.imagePath, expiration) | ||||||
|  |         ApiResponse.ok(AdminCharacterImageResponse.fromWithUrl(img, signedUrl)) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PostMapping("/register") | ||||||
|  |     fun register( | ||||||
|  |         @RequestPart("image") image: MultipartFile, | ||||||
|  |         @RequestPart("request") requestString: String | ||||||
|  |     ) = run { | ||||||
|  |         val objectMapper = ObjectMapper() | ||||||
|  |         val request = objectMapper.readValue(requestString, RegisterCharacterImageRequest::class.java) | ||||||
|  |  | ||||||
|  |         // 1) 임시 경로로 엔티티 생성하기 전에 파일 업로드 경로 계산 | ||||||
|  |         val tempKey = buildS3Key(characterId = request.characterId) | ||||||
|  |         val imagePath = saveImage(tempKey, image) | ||||||
|  |  | ||||||
|  |         imageService.registerImage( | ||||||
|  |             characterId = request.characterId, | ||||||
|  |             imagePath = imagePath, | ||||||
|  |             price = request.price, | ||||||
|  |             isAdult = request.isAdult, | ||||||
|  |             triggers = request.triggers ?: emptyList() | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(null) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PutMapping("/{imageId}/triggers") | ||||||
|  |     fun updateTriggers( | ||||||
|  |         @PathVariable imageId: Long, | ||||||
|  |         @RequestBody request: UpdateCharacterImageTriggersRequest | ||||||
|  |     ) = run { | ||||||
|  |         imageService.updateTriggers(imageId, request.triggers ?: emptyList()) | ||||||
|  |  | ||||||
|  |         ApiResponse.ok(null) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @DeleteMapping("/{imageId}") | ||||||
|  |     fun delete(@PathVariable imageId: Long) = run { | ||||||
|  |         imageService.deleteImage(imageId) | ||||||
|  |         ApiResponse.ok(null, "이미지가 삭제되었습니다.") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @PutMapping("/orders") | ||||||
|  |     fun updateOrders(@RequestBody request: UpdateCharacterImageOrdersRequest) = run { | ||||||
|  |         if (request.characterId == null) throw SodaException("characterId는 필수입니다") | ||||||
|  |         imageService.updateOrders(request.characterId, request.ids) | ||||||
|  |         ApiResponse.ok(null, "정렬 순서가 변경되었습니다.") | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun buildS3Key(characterId: Long): String { | ||||||
|  |         val fileName = generateFileName("character-image") | ||||||
|  |         return "characters/$characterId/images/$fileName" | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun saveImage(filePath: String, image: MultipartFile): String { | ||||||
|  |         try { | ||||||
|  |             val metadata = ObjectMetadata() | ||||||
|  |             metadata.contentLength = image.size | ||||||
|  |             return s3Uploader.upload( | ||||||
|  |                 inputStream = image.inputStream, | ||||||
|  |                 bucket = s3Bucket, | ||||||
|  |                 filePath = filePath, | ||||||
|  |                 metadata = metadata | ||||||
|  |             ) | ||||||
|  |         } catch (e: Exception) { | ||||||
|  |             throw SodaException("이미지 저장에 실패했습니다: ${e.message}") | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,54 @@ | |||||||
|  | package kr.co.vividnext.sodalive.admin.chat.character.image.dto | ||||||
|  |  | ||||||
|  | import com.fasterxml.jackson.annotation.JsonProperty | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.image.CharacterImage | ||||||
|  |  | ||||||
|  | // 요청 DTOs | ||||||
|  |  | ||||||
|  | data class RegisterCharacterImageRequest( | ||||||
|  |     @JsonProperty("characterId") val characterId: Long, | ||||||
|  |     @JsonProperty("price") val price: Long, | ||||||
|  |     @JsonProperty("isAdult") val isAdult: Boolean = false, | ||||||
|  |     @JsonProperty("triggers") val triggers: List<String>? = null | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class UpdateCharacterImageTriggersRequest( | ||||||
|  |     @JsonProperty("triggers") val triggers: List<String>? = null | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | data class UpdateCharacterImageOrdersRequest( | ||||||
|  |     @JsonProperty("characterId") val characterId: Long?, | ||||||
|  |     @JsonProperty("ids") val ids: List<Long> | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | // 응답 DTOs | ||||||
|  |  | ||||||
|  | data class AdminCharacterImageResponse( | ||||||
|  |     val id: Long, | ||||||
|  |     val characterId: Long, | ||||||
|  |     val price: Long, | ||||||
|  |     val isAdult: Boolean, | ||||||
|  |     val sortOrder: Int, | ||||||
|  |     val active: Boolean, | ||||||
|  |     val imageUrl: String, | ||||||
|  |     val triggers: List<String> | ||||||
|  | ) { | ||||||
|  |     companion object { | ||||||
|  |         fun fromWithUrl(entity: CharacterImage, signedUrl: String): AdminCharacterImageResponse { | ||||||
|  |             return base(entity, signedUrl) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         private fun base(entity: CharacterImage, url: String): AdminCharacterImageResponse { | ||||||
|  |             return AdminCharacterImageResponse( | ||||||
|  |                 id = entity.id!!, | ||||||
|  |                 characterId = entity.chatCharacter.id!!, | ||||||
|  |                 price = entity.price, | ||||||
|  |                 isAdult = entity.isAdult, | ||||||
|  |                 sortOrder = entity.sortOrder, | ||||||
|  |                 active = entity.isActive, | ||||||
|  |                 imageUrl = url, | ||||||
|  |                 triggers = entity.triggerMappings.map { it.tag.word } | ||||||
|  |             ) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,48 @@ | |||||||
|  | package kr.co.vividnext.sodalive.aws.cloudfront | ||||||
|  |  | ||||||
|  | import com.amazonaws.services.cloudfront.CloudFrontUrlSigner | ||||||
|  | import org.springframework.beans.factory.annotation.Value | ||||||
|  | import org.springframework.stereotype.Component | ||||||
|  | import java.nio.file.Files | ||||||
|  | import java.nio.file.Paths | ||||||
|  | import java.security.KeyFactory | ||||||
|  | import java.security.PrivateKey | ||||||
|  | import java.security.spec.PKCS8EncodedKeySpec | ||||||
|  | import java.util.Date | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 이미지(CloudFront) 서명 URL 생성기 | ||||||
|  |  * - cloud.aws.cloud-front.* 설정을 사용 | ||||||
|  |  */ | ||||||
|  | @Component | ||||||
|  | class ImageContentCloudFront( | ||||||
|  |     @Value("\${cloud.aws.cloud-front.host}") | ||||||
|  |     private val cloudfrontDomain: String, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.cloud-front.private-key-file-path}") | ||||||
|  |     private val privateKeyFilePath: String, | ||||||
|  |  | ||||||
|  |     @Value("\${cloud.aws.cloud-front.key-pair-id}") | ||||||
|  |     private val keyPairId: String | ||||||
|  | ) { | ||||||
|  |     fun generateSignedURL( | ||||||
|  |         resourcePath: String, | ||||||
|  |         expirationTimeMillis: Long | ||||||
|  |     ): String { | ||||||
|  |         val privateKey = loadPrivateKey(privateKeyFilePath) | ||||||
|  |         return CloudFrontUrlSigner.getSignedURLWithCannedPolicy( | ||||||
|  |             "$cloudfrontDomain/$resourcePath", | ||||||
|  |             keyPairId, | ||||||
|  |             privateKey, | ||||||
|  |             Date(System.currentTimeMillis() + expirationTimeMillis) | ||||||
|  |         ) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun loadPrivateKey(resourceName: String): PrivateKey { | ||||||
|  |         val path = Paths.get(resourceName) | ||||||
|  |         val bytes = Files.readAllBytes(path) | ||||||
|  |         val keySpec = PKCS8EncodedKeySpec(bytes) | ||||||
|  |         val keyFactory = KeyFactory.getInstance("RSA") | ||||||
|  |         return keyFactory.generatePrivate(keySpec) | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,35 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.ChatCharacter | ||||||
|  | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import javax.persistence.CascadeType | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.JoinColumn | ||||||
|  | import javax.persistence.ManyToOne | ||||||
|  | import javax.persistence.OneToMany | ||||||
|  |  | ||||||
|  | @Entity | ||||||
|  | class CharacterImage( | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "character_id") | ||||||
|  |     var chatCharacter: ChatCharacter, | ||||||
|  |  | ||||||
|  |     // 이미지 경로 (S3 key) | ||||||
|  |     var imagePath: String, | ||||||
|  |  | ||||||
|  |     // 가격 (메시지/이미지 통합 단일가 - 요구사항 범위) | ||||||
|  |     var price: Long = 0L, | ||||||
|  |  | ||||||
|  |     // 성인 이미지 여부 (본인인증 필요) | ||||||
|  |     var isAdult: Boolean = false, | ||||||
|  |  | ||||||
|  |     // 갤러리/관리자 노출 순서 (낮을수록 먼저) | ||||||
|  |     var sortOrder: Int = 0, | ||||||
|  |  | ||||||
|  |     // 활성화 여부 (소프트 삭제) | ||||||
|  |     var isActive: Boolean = true | ||||||
|  | ) : BaseEntity() { | ||||||
|  |     @OneToMany(mappedBy = "characterImage", cascade = [CascadeType.ALL], orphanRemoval = true) | ||||||
|  |     var triggerMappings: MutableList<CharacterImageTriggerMapping> = mutableListOf() | ||||||
|  | } | ||||||
| @@ -0,0 +1,16 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.data.jpa.repository.Query | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | interface CharacterImageRepository : JpaRepository<CharacterImage, Long> { | ||||||
|  |     fun findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId: Long): List<CharacterImage> | ||||||
|  |  | ||||||
|  |     @Query( | ||||||
|  |         "SELECT COALESCE(MAX(ci.sortOrder), 0) FROM CharacterImage ci " + | ||||||
|  |             "WHERE ci.chatCharacter.id = :characterId AND ci.isActive = true" | ||||||
|  |     ) | ||||||
|  |     fun findMaxSortOrderByCharacterId(characterId: Long): Int | ||||||
|  | } | ||||||
| @@ -0,0 +1,99 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.chat.character.repository.ChatCharacterRepository | ||||||
|  | import kr.co.vividnext.sodalive.common.SodaException | ||||||
|  | import org.springframework.stereotype.Service | ||||||
|  | import org.springframework.transaction.annotation.Transactional | ||||||
|  |  | ||||||
|  | @Service | ||||||
|  | class CharacterImageService( | ||||||
|  |     private val characterRepository: ChatCharacterRepository, | ||||||
|  |     private val imageRepository: CharacterImageRepository, | ||||||
|  |     private val triggerTagRepository: CharacterImageTriggerRepository | ||||||
|  | ) { | ||||||
|  |  | ||||||
|  |     fun listActiveByCharacter(characterId: Long): List<CharacterImage> { | ||||||
|  |         return imageRepository.findByChatCharacterIdAndIsActiveTrueOrderBySortOrderAsc(characterId) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     fun getById(id: Long): CharacterImage = | ||||||
|  |         imageRepository.findById(id).orElseThrow { SodaException("캐릭터 이미지를 찾을 수 없습니다: $id") } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun registerImage( | ||||||
|  |         characterId: Long, | ||||||
|  |         imagePath: String, | ||||||
|  |         price: Long, | ||||||
|  |         isAdult: Boolean, | ||||||
|  |         triggers: List<String> | ||||||
|  |     ): CharacterImage { | ||||||
|  |         val character = characterRepository.findById(characterId) | ||||||
|  |             .orElseThrow { SodaException("캐릭터를 찾을 수 없습니다: $characterId") } | ||||||
|  |  | ||||||
|  |         if (!character.isActive) throw SodaException("비활성화된 캐릭터에는 이미지를 등록할 수 없습니다: $characterId") | ||||||
|  |  | ||||||
|  |         val nextOrder = (imageRepository.findMaxSortOrderByCharacterId(characterId)) + 1 | ||||||
|  |         val entity = CharacterImage( | ||||||
|  |             chatCharacter = character, | ||||||
|  |             imagePath = imagePath, | ||||||
|  |             price = price, | ||||||
|  |             isAdult = isAdult, | ||||||
|  |             sortOrder = nextOrder, | ||||||
|  |             isActive = true | ||||||
|  |         ) | ||||||
|  |         val saved = imageRepository.save(entity) | ||||||
|  |         applyTriggers(saved, triggers) | ||||||
|  |         return saved | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /** | ||||||
|  |      * 수정은 트리거만 가능 | ||||||
|  |      */ | ||||||
|  |     @Transactional | ||||||
|  |     fun updateTriggers(imageId: Long, triggers: List<String>): CharacterImage { | ||||||
|  |         val image = getById(imageId) | ||||||
|  |         if (!image.isActive) throw SodaException("비활성화된 이미지는 수정할 수 없습니다: $imageId") | ||||||
|  |         applyTriggers(image, triggers) | ||||||
|  |         return image | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun applyTriggers(image: CharacterImage, triggers: List<String>) { | ||||||
|  |         // 입력 트리거 정규화 | ||||||
|  |         val newWords = triggers.mapNotNull { it.trim().lowercase().takeIf { s -> s.isNotBlank() } }.distinct().toSet() | ||||||
|  |  | ||||||
|  |         // 현재 매핑 단어 집합 | ||||||
|  |         val currentMappings = image.triggerMappings | ||||||
|  |         val currentWords = currentMappings.map { it.tag.word }.toSet() | ||||||
|  |  | ||||||
|  |         // 제거되어야 할 매핑(현재는 있지만 새 입력에는 없는 단어) | ||||||
|  |         val toRemove = currentMappings.filter { it.tag.word !in newWords } | ||||||
|  |         currentMappings.removeAll(toRemove) | ||||||
|  |  | ||||||
|  |         // 추가되어야 할 단어(새 입력에는 있지만 현재는 없는 단어) | ||||||
|  |         val toAdd = newWords.minus(currentWords) | ||||||
|  |         toAdd.forEach { w -> | ||||||
|  |             val tag = triggerTagRepository.findByWord(w) ?: triggerTagRepository.save(CharacterImageTrigger(word = w)) | ||||||
|  |             currentMappings.add(CharacterImageTriggerMapping(characterImage = image, tag = tag)) | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun deleteImage(imageId: Long) { | ||||||
|  |         val image = getById(imageId) | ||||||
|  |         image.isActive = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     @Transactional | ||||||
|  |     fun updateOrders(characterId: Long, ids: List<Long>): List<CharacterImage> { | ||||||
|  |         // 동일 캐릭터 소속 검증 및 순서 재지정 | ||||||
|  |         val updated = mutableListOf<CharacterImage>() | ||||||
|  |         ids.forEachIndexed { idx, id -> | ||||||
|  |             val img = getById(id) | ||||||
|  |             if (img.chatCharacter.id != characterId) throw SodaException("다른 캐릭터의 이미지가 포함되어 있습니다: $id") | ||||||
|  |             if (!img.isActive) throw SodaException("비활성화된 이미지는 순서를 변경할 수 없습니다: $id") | ||||||
|  |             img.sortOrder = idx + 1 | ||||||
|  |             updated.add(img) | ||||||
|  |         } | ||||||
|  |         return updated | ||||||
|  |     } | ||||||
|  | } | ||||||
| @@ -0,0 +1,24 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import javax.persistence.Column | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.OneToMany | ||||||
|  | import javax.persistence.Table | ||||||
|  | import javax.persistence.UniqueConstraint | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * 캐릭터 이미지 트리거 "태그" 엔티티 | ||||||
|  |  * - word를 전역 고유로 관리하여 중복 단어 저장을 방지한다. | ||||||
|  |  * - 이미지와의 연결은 CharacterImageTriggerMapping을 사용한다. | ||||||
|  |  */ | ||||||
|  | @Entity | ||||||
|  | @Table(uniqueConstraints = [UniqueConstraint(columnNames = ["word"])]) | ||||||
|  | class CharacterImageTrigger( | ||||||
|  |     @Column(nullable = false) | ||||||
|  |     var word: String | ||||||
|  | ) : BaseEntity() { | ||||||
|  |     @OneToMany(mappedBy = "tag", fetch = FetchType.LAZY) | ||||||
|  |     var mappings: MutableList<CharacterImageTriggerMapping> = mutableListOf() | ||||||
|  | } | ||||||
| @@ -0,0 +1,21 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import kr.co.vividnext.sodalive.common.BaseEntity | ||||||
|  | import javax.persistence.Entity | ||||||
|  | import javax.persistence.FetchType | ||||||
|  | import javax.persistence.JoinColumn | ||||||
|  | import javax.persistence.ManyToOne | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * CharacterImage 와 CharacterImageTrigger(태그) 사이의 매핑 엔티티 | ||||||
|  |  */ | ||||||
|  | @Entity | ||||||
|  | class CharacterImageTriggerMapping( | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "character_image_id") | ||||||
|  |     var characterImage: CharacterImage, | ||||||
|  |  | ||||||
|  |     @ManyToOne(fetch = FetchType.LAZY) | ||||||
|  |     @JoinColumn(name = "tag_id") | ||||||
|  |     var tag: CharacterImageTrigger | ||||||
|  | ) : BaseEntity() | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | package kr.co.vividnext.sodalive.chat.character.image | ||||||
|  |  | ||||||
|  | import org.springframework.data.jpa.repository.JpaRepository | ||||||
|  | import org.springframework.stereotype.Repository | ||||||
|  |  | ||||||
|  | @Repository | ||||||
|  | interface CharacterImageTriggerRepository : JpaRepository<CharacterImageTrigger, Long> { | ||||||
|  |     fun findByWord(word: String): CharacterImageTrigger? | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user