diff --git "a/docs/api/\353\246\254\353\267\260_\354\203\235\354\204\261_API.md" "b/docs/api/\353\246\254\353\267\260_\354\203\235\354\204\261_API.md" index 2e76b2e..e05b8d2 100644 --- "a/docs/api/\353\246\254\353\267\260_\354\203\235\354\204\261_API.md" +++ "b/docs/api/\353\246\254\353\267\260_\354\203\235\354\204\261_API.md" @@ -24,10 +24,3 @@ ### Response #### `Response Status 200 OK` - -```json -{ - "id": "123456789", - "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다." -} -``` diff --git "a/docs/api/\353\246\254\353\267\260_\354\236\254\354\203\235\354\204\261_API.md" "b/docs/api/\353\246\254\353\267\260_\354\236\254\354\203\235\354\204\261_API.md" index 1dbdbd7..384c97e 100644 --- "a/docs/api/\353\246\254\353\267\260_\354\236\254\354\203\235\354\204\261_API.md" +++ "b/docs/api/\353\246\254\353\267\260_\354\236\254\354\203\235\354\204\261_API.md" @@ -14,10 +14,3 @@ ### Response #### `Response Status 200 OK` - -```json -{ - "id": "123456789", - "review": "가득합니다....." -} -``` diff --git "a/docs/api/\353\246\254\353\267\260_\354\241\260\355\232\214_API.md" "b/docs/api/\353\246\254\353\267\260_\354\241\260\355\232\214_API.md" new file mode 100644 index 0000000..2483db8 --- /dev/null +++ "b/docs/api/\353\246\254\353\267\260_\354\241\260\355\232\214_API.md" @@ -0,0 +1,24 @@ +# 리뷰 생성 + +리뷰를 생성합니다. + +## Request + +### HTTP METHOD : `GET` + +### url : `https://api.misik.me/reviews/{id}` +### Http Headers +- device-id: 식별할 수 있는 값 + `미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지` + +### Response + +#### `Response Status 200 OK` + +```json +{ + "isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true + "id": "123456789", + "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다." +} +``` diff --git a/gradle/jetbrains.gradle b/gradle/jetbrains.gradle index 69bd274..3306533 100644 --- a/gradle/jetbrains.gradle +++ b/gradle/jetbrains.gradle @@ -8,6 +8,7 @@ compileTestKotlin { dependencies { implementation "org.jetbrains.kotlin:kotlin-reflect" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-reactor" testImplementation "org.jetbrains.kotlin:kotlin-test" } diff --git a/gradle/spring.gradle b/gradle/spring.gradle index 6221e95..f617a16 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -5,6 +5,7 @@ jar { dependencies { implementation "org.springframework.boot:spring-boot-starter" implementation "org.springframework.boot:spring-boot-starter-web" + implementation "org.springframework.boot:spring-boot-starter-webflux" implementation "org.springframework.boot:spring-boot-starter-data-jpa" implementation 'org.springframework.retry:spring-retry' diff --git a/src/main/kotlin/me/misik/api/api/.gitkeep b/src/main/kotlin/me/misik/api/api/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/me/misik/api/api/ReviewController.kt b/src/main/kotlin/me/misik/api/api/ReviewController.kt new file mode 100644 index 0000000..c754a3e --- /dev/null +++ b/src/main/kotlin/me/misik/api/api/ReviewController.kt @@ -0,0 +1,17 @@ +package me.misik.api.api + +import me.misik.api.app.ReCreateReviewFacade +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +class ReviewController( + private val reCreateReviewFacade: ReCreateReviewFacade, +) { + + @PostMapping("reviews/{id}/re-create") + fun reCreateReview( + @PathVariable("id") id: Long, + ) = reCreateReviewFacade.reCreateReviewInBackground(id) +} diff --git a/src/main/kotlin/me/misik/api/app/.gitkeep b/src/main/kotlin/me/misik/api/app/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/me/misik/api/app/ReCreateReviewFacade.kt b/src/main/kotlin/me/misik/api/app/ReCreateReviewFacade.kt new file mode 100644 index 0000000..ef4a116 --- /dev/null +++ b/src/main/kotlin/me/misik/api/app/ReCreateReviewFacade.kt @@ -0,0 +1,52 @@ +package me.misik.api.app + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.launch +import me.misik.api.core.Chatbot +import me.misik.api.core.GracefulShutdownDispatcher +import me.misik.api.domain.ReviewService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +@Service +class ReCreateReviewFacade( + private val chatbot: Chatbot, + private val reviewService: ReviewService, +) { + + private val logger = LoggerFactory.getLogger(this::class.simpleName) + + fun reCreateReviewInBackground(id: Long) { + reviewService.clearReview(id) + + reCreateReviewWithRetry(id, retryCount = 0) + } + + private fun reCreateReviewWithRetry(id: Long, retryCount: Int) { + CoroutineScope(GracefulShutdownDispatcher.dispatcher).launch { + val review = reviewService.getById(id) + + chatbot.createReviewWithModelName(Chatbot.Request.from(review)) + .filterNot { it.stopReason == ALREADY_COMPLETED } + .collect { + reviewService.updateReview(id, it.message?.content ?: "") + } + }.invokeOnCompletion { + if (it == null) { + return@invokeOnCompletion reviewService.completeReview(id) + } + if (retryCount == MAX_RETRY_COUNT) { + logger.error("Failed to create review.", it) + throw it + } + logger.warn("Failed to create review. retrying... retryCount: \"${retryCount + 1}\"", it) + reCreateReviewWithRetry(id, retryCount + 1) + } + } + + private companion object { + private const val MAX_RETRY_COUNT = 3 + private const val ALREADY_COMPLETED = "stop_before" + } +} diff --git a/src/main/kotlin/me/misik/api/core/Chatbot.kt b/src/main/kotlin/me/misik/api/core/Chatbot.kt new file mode 100644 index 0000000..852eefa --- /dev/null +++ b/src/main/kotlin/me/misik/api/core/Chatbot.kt @@ -0,0 +1,65 @@ +package me.misik.api.core + +import kotlinx.coroutines.flow.Flow +import me.misik.api.domain.Review +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.service.annotation.PostExchange + +fun interface Chatbot { + + @PostExchange("/testapp/v1/chat-completions/HCX-003") + fun createReviewWithModelName(@RequestBody request: Request): Flow + + data class Request( + val messages: List, + val includeAiFilters: Boolean = true, + ) { + data class Message( + val role: String, + val content: String, + ) { + + companion object { + + fun createSystem(content: String) = Message( + role = "system", + content = content, + ) + + fun createUser(content: String) = Message( + role = "user", + content = content, + ) + } + } + + companion object { + private val cachedSystemMessage = Message.createSystem( + """ + 너는 지금부터 음식에 대한 리뷰를 하는 고독한 미식가야. + 답변에는 리뷰에 대한 내용만 포함해. + 또한, 응답 메시지는 공백을 포함해서 300자가 넘으면 절대로 안돼. + """ + ) + + fun from(review: Review): Request { + return Request( + messages = listOf( + cachedSystemMessage, + Message.createUser(review.requestPrompt.text) + ) + ) + } + } + } + + data class Response( + val stopReason: String?, + val message: Message?, + ) { + data class Message( + val role: String, + val content: String, + ) + } +} diff --git a/src/main/kotlin/me/misik/api/core/CoroutineScope.kt b/src/main/kotlin/me/misik/api/core/CoroutineScope.kt new file mode 100644 index 0000000..db16f6e --- /dev/null +++ b/src/main/kotlin/me/misik/api/core/CoroutineScope.kt @@ -0,0 +1,45 @@ +package me.misik.api.core + +import jakarta.annotation.PreDestroy +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.asCoroutineDispatcher +import me.misik.api.core.GracefulShutdownDispatcher.executorService +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +object GracefulShutdownDispatcher { + + val executorService = Executors.newFixedThreadPool(10) { runnable -> + Thread(runnable, "misik-gracefulshutdown").apply { isDaemon = false } + } + + val dispatcher: CoroutineDispatcher = executorService.asCoroutineDispatcher() +} + +@Component +class GracefulShutdownHook { + + private val logger = LoggerFactory.getLogger(this::class.simpleName) + + @PreDestroy + fun tryGracefulShutdown() { + logger.info("Shutting down dispatcher...") + executorService.shutdown() + runCatching { + if (!executorService.awaitTermination(60, TimeUnit.SECONDS)) { + logger.warn("Forcing shutdown...") + executorService.shutdownNow() + } else { + logger.info("Shutdown completed gracefully.") + } + }.onFailure { + if (it is InterruptedException) { + logger.warn("Shutdown interrupted. Forcing shutdown...") + executorService.shutdownNow() + Thread.currentThread().interrupt() + } + } + } +} diff --git a/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt b/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt index c338ad5..d124b55 100644 --- a/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt +++ b/src/main/kotlin/me/misik/api/domain/RequestPrompt.kt @@ -9,7 +9,7 @@ class RequestPrompt( @Column(name = "style", nullable = false, columnDefinition = "VARCHAR(20)") val style: ReviewStyle, - @Column(name = "text", columnDefinition = "TEXT", nullable = false) + @Column(name = "prompt_text", columnDefinition = "TEXT", nullable = false) val text: String, @Column(name = "hash_tags", columnDefinition = "TEXT", nullable = false) diff --git a/src/main/kotlin/me/misik/api/domain/Review.kt b/src/main/kotlin/me/misik/api/domain/Review.kt index d808d51..6df958d 100644 --- a/src/main/kotlin/me/misik/api/domain/Review.kt +++ b/src/main/kotlin/me/misik/api/domain/Review.kt @@ -1,10 +1,6 @@ package me.misik.api.domain -import jakarta.persistence.Column -import jakarta.persistence.Embedded -import jakarta.persistence.Entity -import jakarta.persistence.Id -import jakarta.persistence.Table +import jakarta.persistence.* import me.misik.api.core.AggregateRoot @@ -16,15 +12,15 @@ class Review( @Column(name = "id") val id: Long, - @Column(name = "text", length = 300, columnDefinition = "VARCHAR(300)", nullable = false) - val text: String, + @Column(name = "is_completed") + var isCompleted: Boolean, + + @Column(name = "text", length = 1000, columnDefinition = "VARCHAR(1000)", nullable = false) + var text: String, @Column(name = "device_id", nullable = false, columnDefinition = "VARCHAR(100)") val deviceId: String, @Embedded val requestPrompt: RequestPrompt, -): AbstractTime() { - - -} +) : AbstractTime() diff --git a/src/main/kotlin/me/misik/api/domain/ReviewRepository.kt b/src/main/kotlin/me/misik/api/domain/ReviewRepository.kt index aabb286..617a5fa 100644 --- a/src/main/kotlin/me/misik/api/domain/ReviewRepository.kt +++ b/src/main/kotlin/me/misik/api/domain/ReviewRepository.kt @@ -1,5 +1,23 @@ package me.misik.api.domain +import jakarta.persistence.LockModeType import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param -interface ReviewRepository : JpaRepository +interface ReviewRepository : JpaRepository { + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update review as r set r.text = concat(r.text, :text) where r.id = :id") + fun addText(@Param("id") id: Long, @Param("text") text: String) + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update review as r set r.isCompleted = :completedStatus where r.id = :id") + fun setReviewCompletedStatus(@Param("id") id: Long, @Param("completedStatus") completedStatus: Boolean) + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query("select r from review as r where id = :id") + fun getReviewWithReadLock(@Param("id") id: Long): Review +} diff --git a/src/main/kotlin/me/misik/api/domain/ReviewService.kt b/src/main/kotlin/me/misik/api/domain/ReviewService.kt index 657a0a0..5d59d75 100644 --- a/src/main/kotlin/me/misik/api/domain/ReviewService.kt +++ b/src/main/kotlin/me/misik/api/domain/ReviewService.kt @@ -1,5 +1,6 @@ package me.misik.api.domain +import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -8,4 +9,20 @@ import org.springframework.transaction.annotation.Transactional class ReviewService( private val reviewRepository: ReviewRepository, ) { + + @Transactional + fun clearReview(id: Long) { + val review = reviewRepository.getReviewWithReadLock(id) + review.text = "" + review.isCompleted = false + } + + @Transactional + fun completeReview(id: Long) = reviewRepository.setReviewCompletedStatus(id, true) + + @Transactional + fun updateReview(id: Long, text: String) = reviewRepository.addText(id, text) + + fun getById(id: Long): Review = reviewRepository.findByIdOrNull(id) + ?: throw IllegalArgumentException("Cannot find review by id \"$id\"") } diff --git a/src/main/kotlin/me/misik/api/infra/.gitkeep b/src/main/kotlin/me/misik/api/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/me/misik/api/infra/ClovaChatbotConfiguration.kt b/src/main/kotlin/me/misik/api/infra/ClovaChatbotConfiguration.kt new file mode 100644 index 0000000..f845e83 --- /dev/null +++ b/src/main/kotlin/me/misik/api/infra/ClovaChatbotConfiguration.kt @@ -0,0 +1,36 @@ +package me.misik.api.infra + +import me.misik.api.core.Chatbot +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.http.MediaType +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.support.WebClientAdapter +import org.springframework.web.service.invoker.HttpServiceProxyFactory + +@Configuration +class ClovaChatbotConfiguration( + @Value("\${me.misik.chatbot.clova.url:https://clovastudio.stream.ntruss.com/}") private val chatbotUrl: String, + @Value("\${me.misik.chatbot.clova.authorization}") private val authorization: String, +) { + + @Bean + fun clovaChatbot(): Chatbot { + val webClient = WebClient.builder() + .baseUrl(chatbotUrl) + .defaultHeaders { headers -> + headers.add(HttpHeaders.AUTHORIZATION, "Bearer $authorization") + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + headers.add(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE) + } + .build() + + val httpServiceProxyFactory = HttpServiceProxyFactory + .builderFor(WebClientAdapter.create(webClient)) + .build() + + return httpServiceProxyFactory.createClient(Chatbot::class.java) + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..caa4d0b --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,27 @@ +spring.profiles.active=prod + +server.name=misik-api +server.port=8080 + +spring.datasource.url=jdbc:mysql://localhost:3306/misikapi +spring.datasource.username=root +spring.datasource.password=0000 +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.open-in-view=false + +netx.mode=redis +netx.host=localhost +netx.port=6379 +netx.group=render +netx.node-id=1 +netx.node-name=render-1 +netx.recovery-milli=1000 +netx.orphan-milli=60000 +netx.backpressure=40 +netx.logging.level=info + +internal.secret=a + +logging.level.root=INFO diff --git a/src/test/kotlin/me/misik/api/domain/Fixture.kt b/src/test/kotlin/me/misik/api/domain/Fixture.kt new file mode 100644 index 0000000..ff9ea79 --- /dev/null +++ b/src/test/kotlin/me/misik/api/domain/Fixture.kt @@ -0,0 +1,25 @@ +package me.misik.api.domain + +fun review( + id: Long = 0L, + isCompleted: Boolean = false, + text: String = "", + deviceId: String = "", + requestPrompt: RequestPrompt = requestPrompt() +): Review = Review( + id = id, + isCompleted = isCompleted, + text = text, + deviceId = deviceId, + requestPrompt = requestPrompt, +) + +fun requestPrompt( + style: ReviewStyle = ReviewStyle.DUMMY, + text: String = "", + hashTags: List = listOf(), +): RequestPrompt = RequestPrompt( + style = style, + text = text, + hashTags = hashTags, +) diff --git a/src/test/kotlin/me/misik/api/domain/ReviewServiceTest.kt b/src/test/kotlin/me/misik/api/domain/ReviewServiceTest.kt new file mode 100644 index 0000000..dc34e24 --- /dev/null +++ b/src/test/kotlin/me/misik/api/domain/ReviewServiceTest.kt @@ -0,0 +1,60 @@ +package me.misik.api.domain + +import io.kotest.common.runBlocking +import io.kotest.core.annotation.DisplayName +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.launch +import org.springframework.boot.autoconfigure.domain.EntityScan +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.data.jpa.repository.config.EnableJpaRepositories +import org.springframework.test.context.ContextConfiguration + +@DataJpaTest +@ContextConfiguration(classes = [ReviewService::class]) +@EntityScan(basePackages = ["me.misik.api.domain"]) +@EnableJpaRepositories(basePackages = ["me.misik.api.domain"]) +@DisplayName("ReviewService 클래스의") +class ReviewServiceTest( + private val reviewService: ReviewService, + private val reviewRepository: ReviewRepository, +) : DescribeSpec({ + afterEach { + reviewRepository.deleteAllInBatch() + } + + describe("completeReview 메소드는") { + + context("id를 입력받으면,") { + val review = reviewRepository.save(review(isCompleted = false)) + + it("review의 isCompleted 상태를 true로 변경한다") { + reviewService.completeReview(review.id) + + reviewRepository.getReferenceById(review.id).isCompleted shouldBe true + } + } + } + + describe("updateReview 메소드는") { + context("id와 text를 입력받으면,") { + val review = reviewRepository.save(review(text = "A")) + it("기존 text에 새로운 text를 순차적으로 추가한다") { + runBlocking { + launch { + reviewService.updateReview(review.id, "B") + } + launch { + reviewService.updateReview(review.id, "C") + } + launch { + reviewService.updateReview(review.id, "D") + } + }.invokeOnCompletion { + reviewRepository.getReferenceById(review.id).text shouldBe "ABCD" + } + + } + } + } +})