diff --git a/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt b/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt new file mode 100644 index 000000000..e0b1d616a --- /dev/null +++ b/core/util/src/main/java/com/ku_stacks/ku_ring/util/DateTimeUtil.kt @@ -0,0 +1,17 @@ +package com.ku_stacks.ku_ring.util + +import java.time.Instant +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.YearMonth +import java.time.ZoneOffset + +val LocalDateTime.yearMonth: YearMonth + get() = YearMonth.of(this.year, this.monthValue) + +fun Long.toLocalDateTime(): LocalDateTime = + LocalDateTime.ofInstant(Instant.ofEpochSecond(this), ZoneOffset.systemDefault()) + +fun LocalDateTime.toEpochSecond() = atZone(ZoneOffset.systemDefault()).toEpochSecond() + +fun LocalDate.toEpochSecond() = atStartOfDay(ZoneOffset.systemDefault()).toEpochSecond() \ No newline at end of file diff --git a/data/ai/.gitignore b/data/ai/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/data/ai/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/ai/build.gradle.kts b/data/ai/build.gradle.kts new file mode 100644 index 000000000..766ecd076 --- /dev/null +++ b/data/ai/build.gradle.kts @@ -0,0 +1,27 @@ +import com.ku_stacks.ku_ring.buildlogic.dsl.setNameSpace + +plugins { + kuring("feature") + kuringPrimitive("room") + kuringPrimitive("ktor") + kuringPrimitive("test") +} + +android { + setNameSpace("ai") + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +dependencies { + implementation(projects.core.util) + implementation(projects.data.domain) + implementation(projects.data.remote) + implementation(projects.data.local) + + testImplementation(libs.kotlinx.coroutines.test) +} \ No newline at end of file diff --git a/data/ai/consumer-rules.pro b/data/ai/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/data/ai/src/main/AndroidManifest.xml b/data/ai/src/main/AndroidManifest.xml new file mode 100644 index 000000000..44008a433 --- /dev/null +++ b/data/ai/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt new file mode 100644 index 000000000..23da66c37 --- /dev/null +++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/di/RepositoryModule.kt @@ -0,0 +1,17 @@ +package com.ku_stacks.ku_ring.ai.di + +import com.ku_stacks.ku_ring.ai.repository.KuringBotRepository +import com.ku_stacks.ku_ring.ai.repository.KuringBotRepositoryImpl +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + @Binds + @Singleton + abstract fun bindKuringBotMessageRepository(repositoryImpl: KuringBotRepositoryImpl): KuringBotRepository +} \ No newline at end of file diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt new file mode 100644 index 000000000..e0e9c99af --- /dev/null +++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/DomainToEntityMapper.kt @@ -0,0 +1,14 @@ +package com.ku_stacks.ku_ring.ai.mapper + +import com.ku_stacks.ku_ring.domain.KuringBotMessage +import com.ku_stacks.ku_ring.local.entity.KuringBotMessageEntity +import com.ku_stacks.ku_ring.util.toEpochSecond + +internal fun KuringBotMessage.toEntity() = KuringBotMessageEntity( + id = id, + message = message, + postedEpochSeconds = postedDate.toEpochSecond(), + isQuery = isQuery, +) + +internal fun List.toEntity() = map { it.toEntity() } \ No newline at end of file diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt new file mode 100644 index 000000000..4e4a76d90 --- /dev/null +++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/mapper/EntityToDomainMapper.kt @@ -0,0 +1,14 @@ +package com.ku_stacks.ku_ring.ai.mapper + +import com.ku_stacks.ku_ring.domain.KuringBotMessage +import com.ku_stacks.ku_ring.local.entity.KuringBotMessageEntity +import com.ku_stacks.ku_ring.util.toLocalDateTime + +internal fun KuringBotMessageEntity.toDomain() = KuringBotMessage( + id = id, + message = message, + postedDate = postedEpochSeconds.toLocalDateTime(), + isQuery = isQuery, +) + +internal fun List.toDomain() = map { it.toDomain() } \ No newline at end of file diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt new file mode 100644 index 000000000..ca33651d4 --- /dev/null +++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepository.kt @@ -0,0 +1,51 @@ +package com.ku_stacks.ku_ring.ai.repository + +import com.ku_stacks.ku_ring.domain.KuringBotMessage +import java.time.LocalDate + +interface KuringBotRepository { + /** + * 쿠링봇 메시지 세션을 열고, 서버로부터 데이터가 주어졌을 때 작업을 실행한다. + * + * @param query 질문 내용 + * @param token 사용자 FCM 토큰 + * @param onReceived 데이터를 수신했을 때 실행할 작업 + */ + suspend fun openKuringBotSession( + query: String, + token: String, + onReceived: (String) -> Unit, + ) + + /** + * 로컬 DB에 저장된 쿠링봇 메시지를 모두 반환한다. + * + * @return 로컬에 저장된 모든 쿠링봇 메시지 + */ + suspend fun getAllMessages(): List + + /** + * 로컬 DB에 쿠링봇 메시지 여러 개를 저장한다. + * 이미 저장되어 있는 메시지는 중복으로 저장되지 않는다. + * + * @param messages 로컬에 저장할 메시지 + */ + suspend fun insertMessages(messages: List) + + /** + * 로컬 DB에 쿠링봇 메시지를 저장한다. + * 이미 저장되어 있는 메시지라면, 아무 일도 일어나지 않는다. + * + * @param message 로컬에 저장할 메시지 + */ + suspend fun insertMessage(message: KuringBotMessage) + + /** + * 지정된 기간 동안에 전송한 질문의 수를 반환한다. + * + * @param from 기간의 시작 + * @param to 기간의 끝 + * @return [from]부터 [to]까지 전송한 질문의 수 (inclusive) + */ + suspend fun getQueryCount(from: LocalDate, to: LocalDate): Int +} \ No newline at end of file diff --git a/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt new file mode 100644 index 000000000..d0efe6c90 --- /dev/null +++ b/data/ai/src/main/java/com/ku_stacks/ku_ring/ai/repository/KuringBotRepositoryImpl.kt @@ -0,0 +1,48 @@ +package com.ku_stacks.ku_ring.ai.repository + +import com.ku_stacks.ku_ring.ai.mapper.toDomain +import com.ku_stacks.ku_ring.ai.mapper.toEntity +import com.ku_stacks.ku_ring.domain.KuringBotMessage +import com.ku_stacks.ku_ring.local.room.KuringBotMessageDao +import com.ku_stacks.ku_ring.remote.kuringbot.KuringBotClient +import com.ku_stacks.ku_ring.util.toEpochSecond +import java.time.LocalDate +import javax.inject.Inject + +class KuringBotRepositoryImpl @Inject constructor( + private val kuringBotClient: KuringBotClient, + private val kuringBotMessageDao: KuringBotMessageDao, +) : KuringBotRepository { + + override suspend fun openKuringBotSession( + query: String, + token: String, + onReceived: (String) -> Unit, + ) { + try { + kuringBotClient.openKuringBotConnection(query, token, onReceived) + } catch (e: Exception) { + onReceived(UNKNOWN_ERROR) + } + } + + override suspend fun getAllMessages(): List { + return kuringBotMessageDao.getAllMessages().toDomain() + } + + override suspend fun insertMessages(messages: List) { + kuringBotMessageDao.insertMessages(messages.toEntity()) + } + + override suspend fun insertMessage(message: KuringBotMessage) { + kuringBotMessageDao.insertMessage(message.toEntity()) + } + + override suspend fun getQueryCount(from: LocalDate, to: LocalDate): Int { + return kuringBotMessageDao.getQueryCount(from.toEpochSecond(), to.toEpochSecond()) + } + + companion object { + internal const val UNKNOWN_ERROR = "예상치 못한 오류가 발생했어요. 다음에 다시 시도해 주세요." + } +} \ No newline at end of file diff --git a/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt b/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt new file mode 100644 index 000000000..44ff3ee77 --- /dev/null +++ b/data/ai/src/test/java/com/ku_stacks/ku_ring/ai/KuringBotRepositoryImplTest.kt @@ -0,0 +1,44 @@ +package com.ku_stacks.ku_ring.ai + +import com.ku_stacks.ku_ring.ai.repository.KuringBotRepository +import com.ku_stacks.ku_ring.ai.repository.KuringBotRepositoryImpl +import com.ku_stacks.ku_ring.local.room.KuringBotMessageDao +import com.ku_stacks.ku_ring.remote.kuringbot.KuringBotClient +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.mockito.Mockito +import org.mockito.exceptions.base.MockitoException + +class KuringBotRepositoryImplTest { + private val kuringBotClient = Mockito.mock(KuringBotClient::class.java) + private val dao = Mockito.mock(KuringBotMessageDao::class.java) + private lateinit var repository: KuringBotRepository + + private val query = "교내,외 장학금 및 학자금 대출 관련 전화번호들을 안내를 해줘" + + @Before + fun setup() { + repository = KuringBotRepositoryImpl(kuringBotClient, dao) + } + + @Test + fun `test when exception is thrown`() = runTest { + val tokens = mutableListOf() + val onReceived: (String) -> Unit = { data -> + tokens.add(data) + } + + // given + val token = System.currentTimeMillis().toString() + Mockito.`when`(kuringBotClient.openKuringBotConnection(query, token, onReceived)) + .thenThrow(MockitoException("test exception")) + + // when + repository.openKuringBotSession(query, token, onReceived) + + // then + assert(tokens.size == 1) + assert(tokens.contains(KuringBotRepositoryImpl.UNKNOWN_ERROR)) + } +} \ No newline at end of file diff --git a/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt new file mode 100644 index 000000000..4b7b1a5f4 --- /dev/null +++ b/data/domain/src/main/java/com/ku_stacks/ku_ring/domain/KuringBotMessage.kt @@ -0,0 +1,10 @@ +package com.ku_stacks.ku_ring.domain + +import java.time.LocalDateTime + +data class KuringBotMessage( + val id: Int, + val message: String, + val postedDate: LocalDateTime, + val isQuery: Boolean, +) \ No newline at end of file diff --git a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt index f54adf4ff..b9e63b3dd 100644 --- a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt +++ b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotClient.kt @@ -2,14 +2,13 @@ package com.ku_stacks.ku_ring.remote.kuringbot interface KuringBotClient { /** - * 쿠링봇 SSE 세션을 열고, 데이터를 문자열 형식으로 받아 처리한다. - * SSE 데이터를 받았을 때, [onReceived]에 접두어 `data:`를 떼지 않고 그대로 제공한다. + * 쿠링봇 연결을 만들고, 응답을 받아 문자열 형식으로 처리한다. * * @param query 질문 내용 * @param token 사용자 FCM 토큰 * @param onReceived 데이터를 수신했을 때 실행할 작업. */ - suspend fun openKuringBotSSESession( + suspend fun openKuringBotConnection( query: String, token: String, onReceived: (String) -> Unit, diff --git a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt index 5e431248b..aebce8d82 100644 --- a/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt +++ b/data/remote/src/main/java/com/ku_stacks/ku_ring/remote/kuringbot/KuringBotSSEClient.kt @@ -12,7 +12,7 @@ class KuringBotSSEClient @Inject constructor( private val client: HttpClient, ) : KuringBotClient { - override suspend fun openKuringBotSSESession( + override suspend fun openKuringBotConnection( query: String, token: String, onReceived: (String) -> Unit, diff --git a/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt b/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt index ba61c6ff5..de99352ff 100644 --- a/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt +++ b/data/remote/src/test/java/com/ku_stacks/ku_ring/remote/KuringBotSSEClientTest.kt @@ -28,7 +28,7 @@ class KuringBotSSEClientTest { // when val tokens = mutableListOf() - kuringBotClient.openKuringBotSSESession(query, testToken) { + kuringBotClient.openKuringBotConnection(query, testToken) { tokens.add(it) } @@ -43,7 +43,7 @@ class KuringBotSSEClientTest { // when val tokens = mutableListOf() - kuringBotClient.openKuringBotSSESession(wrongQuery, testToken) { + kuringBotClient.openKuringBotConnection(wrongQuery, testToken) { tokens.add(it) } @@ -57,11 +57,11 @@ class KuringBotSSEClientTest { val testToken = System.currentTimeMillis().toString() // when - kuringBotClient.openKuringBotSSESession(query, testToken) {} - kuringBotClient.openKuringBotSSESession(query, testToken) {} + kuringBotClient.openKuringBotConnection(query, testToken) {} + kuringBotClient.openKuringBotConnection(query, testToken) {} val tokens = mutableListOf() - kuringBotClient.openKuringBotSSESession(query, testToken) { + kuringBotClient.openKuringBotConnection(query, testToken) { tokens.add(it) } diff --git a/settings.gradle.kts b/settings.gradle.kts index fad854b4d..147e62f19 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,6 +27,7 @@ include( ":data:notice", ":data:notice:test", ":core:preferences", + ":data:ai", ":data:push", ":data:push:test", ":data:user",