Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

변경사항이 많은 경우 배치 동기화 하지 않고 슬랙 채널에 알림 #213

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ class SugangSnuLectureCompareResult(
val createdLectureList: List<Lecture>,
val deletedLectureList: List<Lecture>,
val updatedLectureList: List<UpdatedLecture>,
)
) {
fun needsConfirmOnProduction(): Boolean {
return createdLectureList.size > 50 || deletedLectureList.size > 10 || updatedLectureList.size > 50
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ package com.wafflestudio.snu4t.sugangsnu.job.sync.service

import com.wafflestudio.snu4t.bookmark.repository.BookmarkRepository
import com.wafflestudio.snu4t.common.cache.Cache
import com.wafflestudio.snu4t.common.cache.CacheKey
import com.wafflestudio.snu4t.common.cache.get
import com.wafflestudio.snu4t.common.slack.CONFIRM_DONE_EMOJI
import com.wafflestudio.snu4t.common.slack.CONFIRM_ONGOING_EMOJI
import com.wafflestudio.snu4t.common.slack.SlackMessageBlock
import com.wafflestudio.snu4t.common.slack.SlackMessageRequest
import com.wafflestudio.snu4t.common.slack.SlackMessageService
import com.wafflestudio.snu4t.config.Phase
import com.wafflestudio.snu4t.config.SNUTT_MENTION
import com.wafflestudio.snu4t.coursebook.data.Coursebook
import com.wafflestudio.snu4t.coursebook.repository.CoursebookRepository
import com.wafflestudio.snu4t.lectures.data.Lecture
Expand Down Expand Up @@ -49,7 +58,10 @@ class SugangSnuSyncServiceImpl(
private val bookmarkRepository: BookmarkRepository,
private val tagListRepository: TagListRepository,
private val cache: Cache,
private val phase: Phase,
private val slackMessageService: SlackMessageService
) : SugangSnuSyncService {

override suspend fun updateCoursebook(coursebook: Coursebook): List<UserLectureSyncResult> {
val newLectures = sugangSnuFetchService.getSugangSnuLectures(coursebook.year, coursebook.semester)
val oldLectures =
Expand Down Expand Up @@ -140,9 +152,66 @@ class SugangSnuSyncServiceImpl(
diff.newData.apply { id = diff.oldData.id }
}

lectureService.upsertLectures(compareResult.createdLectureList)
lectureService.upsertLectures(updatedLectures)
lectureService.deleteLectures(compareResult.deletedLectureList)
val ongoingConfirmThread = cache.get<String>(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build())
val hasOngoingConfirmProcessOnProd = phase.isProd && ongoingConfirmThread != null
val currentResultRequiresConfirm = phase.isProd && compareResult.needsConfirmOnProduction()

when {
currentResultRequiresConfirm -> {
// 이전 검토 요청 존재 여부와 별개로 새로운 검토 요청
val newThreadTs = slackMessageService.postMessage(
message =
SlackMessageRequest(
SlackMessageBlock.Section("*$SNUTT_MENTION 수강스누 업데이트 검토가 필요합니다.*"),
SlackMessageBlock.Section("추가된 강좌 수: ${compareResult.createdLectureList.size}"),
SlackMessageBlock.Section("업데이트된 강좌 수: ${compareResult.updatedLectureList.size}"),
SlackMessageBlock.Section("삭제된 강좌 수: ${compareResult.deletedLectureList.size}"),
SlackMessageBlock.Section("개발환경에서 검토 후 아래 버튼 통해 라이브 배포해주세요."),
SlackMessageBlock.Action.SUGANG_SNU_CONFIRM
)
).threadTs

// 기존 결과가 검토 대기 중이었으면 내림
if (hasOngoingConfirmProcessOnProd) {
slackMessageService.deleteEmoji(threadTs = ongoingConfirmThread!!, emoji = CONFIRM_ONGOING_EMOJI)
slackMessageService.addEmoji(threadTs = ongoingConfirmThread, emoji = CONFIRM_DONE_EMOJI)
slackMessageService.postMessageToThread(
threadTs = ongoingConfirmThread,
message = SlackMessageRequest(
SlackMessageBlock.Header("새로운 동기화 결과가 업데이트 되어 검토 요청을 자동 종료합니다."),
SlackMessageBlock.Section("새로운 동기화 결과가 자동 배포 기준을 충족하지 않아 검토가 필요합니다."),
)
)
}

slackMessageService.addEmoji(threadTs = newThreadTs, emoji = CONFIRM_ONGOING_EMOJI)
cache.set(CacheKey.LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED.build(), newThreadTs)
}

// 이전 검토 요청이 있었고, 새로운 결과가 검토 대기 중이 아니면 검토 종료, 새로운 결과 반영
!currentResultRequiresConfirm && hasOngoingConfirmProcessOnProd -> {
slackMessageService.deleteEmoji(threadTs = ongoingConfirmThread!!, emoji = CONFIRM_ONGOING_EMOJI)
slackMessageService.addEmoji(threadTs = ongoingConfirmThread, emoji = CONFIRM_DONE_EMOJI)
slackMessageService.postMessageToThread(
threadTs = ongoingConfirmThread,
message = SlackMessageRequest(
SlackMessageBlock.Header("동기화 결과가 업데이트 되어 검토 요청을 자동 종료합니다."),
SlackMessageBlock.Section("새로운 동기화 결과가 자동 배포 기준을 충족하여 검토가 필요하지 않습니다."),
SlackMessageBlock.Section("검토 요청을 종료합니다."),
)
)

lectureService.upsertLectures(compareResult.createdLectureList)
lectureService.upsertLectures(updatedLectures)
lectureService.deleteLectures(compareResult.deletedLectureList)
}

else -> {
lectureService.upsertLectures(compareResult.createdLectureList)
lectureService.upsertLectures(updatedLectures)
lectureService.deleteLectures(compareResult.deletedLectureList)
}
}
}

private suspend fun syncSavedUserLectures(compareResult: SugangSnuLectureCompareResult): List<UserLectureSyncResult> =
Expand Down Expand Up @@ -278,7 +347,10 @@ class SugangSnuSyncServiceImpl(
updatedLecture.updatedField.contains(Lecture::classPlaceAndTimes) &&
timetable.lectures.any {
it.lectureId != updatedLecture.oldData.id &&
ClassTimeUtils.timesOverlap(it.classPlaceAndTimes, updatedLecture.newData.classPlaceAndTimes)
ClassTimeUtils.timesOverlap(
it.classPlaceAndTimes,
updatedLecture.newData.classPlaceAndTimes
)
}

private fun deleteBookmarkLectures(deletedLecture: Lecture): Flow<BookmarkLectureDeleteResult> =
Expand Down
1 change: 1 addition & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ subprojects {
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("io.projectreactor.kotlin:reactor-kotlin-extensions")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8")
implementation("org.springframework.boot:spring-boot-starter-data-mongodb-reactive")

implementation("org.springframework.boot:spring-boot-starter-data-redis")
Expand Down
3 changes: 3 additions & 0 deletions core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ dependencies {
implementation("software.amazon.awssdk:secretsmanager:2.17.276")
implementation("software.amazon.awssdk:sts:2.17.276")
implementation("com.google.firebase:firebase-admin:9.1.1")
implementation("com.slack.api:bolt-jetty:1.37.0")
implementation("com.slack.api:slack-api-model-kotlin-extension:1.21.0")
implementation("com.slack.api:slack-api-client-kotlin-extension:1.21.0")

testFixturesImplementation("org.testcontainers:mongodb:1.19.0")
testFixturesImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
Expand Down
1 change: 1 addition & 0 deletions core/src/main/kotlin/common/cache/CacheKey.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ enum class CacheKey(
CLIENT_CONFIGS("client_configs:%s_%s", Duration.ofMinutes(5)), // osType, appVersion
LOCK_REGISTER_LOCAL("lock:register_local:%s", Duration.ofMinutes(1)), // localId
LOCK_ADD_FCM_REGISTRATION_ID("lock:add_registration_id:%s_%s", Duration.ofMinutes(1)), // userId, registrationId
LOCK_LIVE_SUGANG_SNU_SYNC_UNTIL_CONFIRMED("lock:live_sugang_snu_sync_until_confirmed", Duration.ofDays(14)),
;

fun build(vararg args: Any?): BuiltCacheKey {
Expand Down
30 changes: 30 additions & 0 deletions core/src/main/kotlin/common/slack/SlackMessageRequest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package com.wafflestudio.snu4t.common.slack

data class SlackMessageRequest(
val text: String = "",
val blocks: List<SlackMessageBlock>,
) {
constructor(vararg blocks: SlackMessageBlock, text: String = "") : this(text, blocks.toList())
}

interface SlackMessageBlock {

data class Header(val text: String) : SlackMessageBlock

data class Section(val text: String) : SlackMessageBlock

data class Button(val text: String, val url: String? = null) : SlackMessageBlock

enum class Action(
val actionId: String,
val value: String,
val text: String,
) : SlackMessageBlock {
SUGANG_SNU_CONFIRM("sugang_snu_confirm", "ok", "확인 완료"),
}
}

data class SlackMessageResponse(
val channel: String,
val threadTs: String
)
127 changes: 127 additions & 0 deletions core/src/main/kotlin/common/slack/SlackMessageService.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package com.wafflestudio.snu4t.common.slack

import com.slack.api.Slack
import com.slack.api.methods.kotlin_extension.request.chat.blocks
import com.slack.api.model.block.composition.PlainTextObject
import com.slack.api.model.block.element.BlockElements.button
import com.slack.api.model.kotlin_extension.block.dsl.LayoutBlockDsl
import kotlinx.coroutines.future.await
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component

private const val SNUTT_CHANNEL_ID = "C0PAVPS5T"
const val CONFIRM_ONGOING_EMOJI = "acongablob"
const val CONFIRM_DONE_EMOJI = "done"

interface SlackMessageService {

suspend fun postMessage(channel: String = SNUTT_CHANNEL_ID, message: SlackMessageRequest): SlackMessageResponse

suspend fun postMessageToThread(channel: String = SNUTT_CHANNEL_ID, threadTs: String, message: SlackMessageRequest): SlackMessageResponse

suspend fun addEmoji(channel: String = SNUTT_CHANNEL_ID, threadTs: String, emoji: String)

suspend fun deleteEmoji(channel: String = SNUTT_CHANNEL_ID, threadTs: String, emoji: String)
}

@Component
internal class Impl(
@Value("\${slack.bot.token}") secretKey: String,
) : SlackMessageService {

private val client = Slack.getInstance()
.methodsAsync(secretKey)

override suspend fun postMessage(channel: String, message: SlackMessageRequest): SlackMessageResponse {
return client.chatPostMessage { builder ->
builder.channel(channel)
.text(message.text)
.blocks { apply(message.blocks) }
}
.await()
.let {
if (!it.isOk) {
log.error("Failed to post message to slack: ${it.error}")
}

SlackMessageResponse(it.channel, it.ts)
}
}

override suspend fun postMessageToThread(channel: String, threadTs: String, message: SlackMessageRequest): SlackMessageResponse {
return client.chatPostMessage { builder ->
builder
.channel(channel)
.threadTs(threadTs)
.text(message.text)
.blocks { apply(message.blocks) }
}
.await()
.let {
if (!it.isOk) {
log.error("Failed to post message to slack: ${it.error}")
}

SlackMessageResponse(it.channel, it.ts)
}
}

override suspend fun addEmoji(channel: String, threadTs: String, emoji: String) {
client.reactionsAdd { builder ->
builder
.channel(channel)
.timestamp(threadTs)
.name(emoji)
}
.await()
.let {
if (!it.isOk) {
log.error("Failed to add emoji to slack: ${it.error}")
}
}
}

override suspend fun deleteEmoji(channel: String, threadTs: String, emoji: String) {
client.reactionsRemove { builder ->
builder
.channel(channel)
.timestamp(threadTs)
.name(emoji)
}
.await()
.let {
if (!it.isOk) {
log.error("Failed to delete emoji to slack: ${it.error}")
}
}
}

private fun LayoutBlockDsl.apply(blocks: List<SlackMessageBlock>) {
blocks.forEach { block ->
when (block) {
is SlackMessageBlock.Header -> header {
text(block.text, emoji = false)
}
is SlackMessageBlock.Section -> section {
markdownText(block.text, verbatim = false)
}
is SlackMessageBlock.Button -> button {
it.text(PlainTextObject(block.text, false))
block.url?.let { url -> it.url(url) }
}
is SlackMessageBlock.Action -> actions {
elements {
button {
actionId(block.actionId)
value(block.value)
text(block.text)
}
}
}
}
}
}

private val log = LoggerFactory.getLogger(javaClass)
}
Loading
Loading