From c9d8a9c13db7260c54ba3311cbf07cf9ebe93e03 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:45:19 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Discord=20/stats=20=EC=8A=AC?= =?UTF-8?q?=EB=9E=98=EC=8B=9C=EC=BB=A4=EB=A7=A8=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EC=A7=80=ED=91=9C=20?= =?UTF-8?q?=EB=8C=80=ED=99=94=ED=98=95=20=EC=A1=B0=ED=9A=8C=20(#664)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DiscordAccessController를 공통 게이트(서명·PING·채널·allowlist) + data.name 라우팅으로 분해 - piki-admin 로직을 AdminGrantCommandHandler로 이동, StatsCommandHandler 신규 추가 - /stats period·metric 옵션 → MetricsService.snapshot 재사용 → ephemeral embed (LLM 없음, 개발진 제외 기본) - 순수 단위 TDD: StatsEmbed·StatsMetric·StatsPeriod·DiscordInteractions --- .../admin/access/AdminGrantCommandHandler.kt | 31 +++++ .../admin/access/DiscordAccessController.kt | 106 ++++------------ .../piki/admin/access/DiscordInteractions.kt | 73 +++++++++++ .../piki/admin/access/StatsCommandHandler.kt | 26 ++++ .../depromeet/piki/admin/access/StatsEmbed.kt | 98 +++++++++++++++ .../piki/admin/access/StatsPeriod.kt | 19 +++ .../admin/access/DiscordInteractionsTest.kt | 37 ++++++ .../piki/admin/access/StatsEmbedTest.kt | 113 ++++++++++++++++++ .../piki/admin/access/StatsOptionTest.kt | 34 ++++++ 9 files changed, 455 insertions(+), 82 deletions(-) create mode 100644 src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt create mode 100644 src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt create mode 100644 src/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.kt create mode 100644 src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt create mode 100644 src/main/kotlin/com/depromeet/piki/admin/access/StatsPeriod.kt create mode 100644 src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/admin/access/StatsEmbedTest.kt create mode 100644 src/test/kotlin/com/depromeet/piki/admin/access/StatsOptionTest.kt diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt b/src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt new file mode 100644 index 00000000..095c8cca --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/access/AdminGrantCommandHandler.kt @@ -0,0 +1,31 @@ +package com.depromeet.piki.admin.access + +import com.depromeet.piki.admin.config.AdminProperties +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import org.springframework.stereotype.Component + +// `/piki-admin env:` — 선택한 환경의 원타임 grant 링크를 발급한다(#654). +// host 는 grantHosts 맵에서(요청 host 가 아니라) — 인터랙션 엔드포인트와 대상 env 가 다를 수 있어서다. +// 토큰은 그 env 에 바인딩 서명돼(GrantTokenCodec), 대상 env 만 소비할 수 있다(cross-env). +@Component +@ConditionalOnAdminEnabled +class AdminGrantCommandHandler( + private val allowlistService: AdminAllowlistService, + private val adminProperties: AdminProperties, +) : DiscordCommandHandler { + override val commandName = "piki-admin" + + override fun handle(interaction: DiscordInteraction): Map { + val env = DiscordInteractions.optionValue(interaction.root, "env") + val host = + adminProperties.grantHosts[env] + ?: return DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 알 수 없는 환경", "지원하지 않는 환경입니다: `$env`") + val token = allowlistService.issueGrantToken(interaction.userId, interaction.userName, env) + val link = "$host/admin-access/grant?token=$token" + return DiscordInteractions.embed( + DiscordInteractions.COLOR_GREEN, + "✅ 관리자 인증됨 — ${interaction.userName}", + "**$env** 접속: 이 기기에서 3분 내 아래 링크를 여세요 (그 기기 IP 가 등록됩니다).\n$link", + ) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt index 2c5d89ea..a1a3728b 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt @@ -15,16 +15,16 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.ResponseBody import org.springframework.web.bind.annotation.RestController -import tools.jackson.databind.JsonNode import tools.jackson.databind.ObjectMapper import java.nio.charset.StandardCharsets -// 백오피스 접근의 유일한 공개 표면 — Discord 슬래시커맨드(인터랙션) + grant 링크. 두 게이트 필터(EnvironmentAccessFilter· +// 백오피스·운영 조회의 유일한 공개 표면 — Discord 슬래시커맨드(인터랙션) + grant 링크. 두 게이트 필터(EnvironmentAccessFilter· // AdminAccessFilter)는 이 경로(/admin-access/**)를 항상 통과시킨다(여기서 IP 를 등록해야 게이트를 열 수 있으므로). // -// Slack(HMAC)에서 Discord(Ed25519 인터랙션)로 이관(#654). `/piki-admin env:` 로 선택한 환경의 원타임 -// 링크를 발급한다. 인터랙션은 한 엔드포인트로 오지만(Discord 앱당 URL 1개), 토큰을 HMAC 서명(GrantTokenCodec)해 -// 대상 env 가 검증·소비하므로 cross-env 로 동작한다. 링크 클릭·IP 캡처·세션은 진입 표면과 무관해 그대로다. +// Slack(HMAC)에서 Discord(Ed25519 인터랙션)로 이관(#654). Discord 앱당 인터랙션 URL 은 1개라, 이 컨트롤러가 공통 +// 게이트(서명 검증 → PING → 채널 → allowlist)를 처리한 뒤 data.name 으로 DiscordCommandHandler 에 라우팅한다(#664): +// piki-admin → AdminGrantCommandHandler (원타임 grant 링크) +// stats → StatsCommandHandler (대시보드 지표 조회) @Hidden @RestController @ConditionalOnAdminEnabled @@ -35,10 +35,13 @@ class DiscordAccessController( private val auditService: AdminAuditService, private val adminProperties: AdminProperties, private val objectMapper: ObjectMapper, + handlers: List, ) { + private val handlersByName: Map = handlers.associateBy { it.commandName } + // Discord 인터랙션 수신(application/json). Ed25519 서명 검증 후 분기: // PING(type 1) → PONG (Discord 가 엔드포인트 등록 시 이걸로 살아있음을 확인) - // 커맨드(type 2) → 채널 게이트 → allowlist 게이트 → 선택한 env(옵션)의 원타임 grant 링크를 ephemeral embed 로 + // 커맨드(type 2) → 채널 게이트 → allowlist 게이트 → data.name 라우팅 → 해당 핸들러 응답(ephemeral) @PostMapping("/discord", produces = [MediaType.APPLICATION_JSON_VALUE]) @ResponseBody fun discord( @@ -60,30 +63,31 @@ class DiscordAccessController( } val root = objectMapper.readTree(rawBody) - if ((root.path("type").takeIf { it.canConvertToInt() }?.asInt() ?: 0) == TYPE_PING) return pong() + if ((root.path("type").takeIf { it.canConvertToInt() }?.asInt() ?: 0) == DiscordInteractions.TYPE_PING) { + return DiscordInteractions.pong() + } - // admin 전용 채널에서만 — 원타임 링크·대화형 대시보드 조회 등 admin 인터랙션을 한 채널로 국한한다(봇이 여러 - // 채널에 있어도). 채널 미설정(blank)이면 fail-closed 로 전부 거부(설정 실수로 아무 채널에서나 열리는 것 방지). + // admin 전용 채널에서만 — 원타임 링크·대화형 조회 등 운영 인터랙션을 한 채널로 국한한다(봇이 여러 채널에 있어도). + // 채널 미설정(blank)이면 fail-closed 로 전부 거부(설정 실수로 아무 채널에서나 열리는 것 방지). val channelId = root.path("channel_id").takeIf { it.isString }?.asString() ?: "" if (adminProperties.discordAdminChannelId.isBlank() || channelId != adminProperties.discordAdminChannelId) { - return embed(COLOR_RED, "❌ 사용 불가", "이 명령은 지정된 admin 채널에서만 사용할 수 있습니다.") + return DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 사용 불가", "이 명령은 지정된 admin 채널에서만 사용할 수 있습니다.") } - val userId = root.path("member").path("user").path("id").takeIf { it.isString }?.asString() ?: "" - // 로그·감사 actor 이름 — 서버 별명(nick) 우선, 없으면 표시이름(global_name), 없으면 고유 핸들(username). - val userName = - root.path("member").path("nick").takeIf { it.isString }?.asString() - ?: root.path("member").path("user").path("global_name").takeIf { it.isString }?.asString() - ?: root.path("member").path("user").path("username").takeIf { it.isString }?.asString() - ?: "unknown" + val userId = DiscordInteractions.userId(root) + val userName = DiscordInteractions.userName(root) - // allowlist 게이트 — 허용된 Discord userId 만 링크를 받는다. 아니면 발급 없이 거부 UI(응답이 ephemeral 이라 본인만 봄). + // allowlist 게이트 — 허용된 Discord userId 만 통과. 아니면 처리 없이 거부 UI(응답이 ephemeral 이라 본인만 봄). if (userId !in adminProperties.discordAdminUserIds) { auditService.record(userName, AdminAuditAction.ACCESS_DENIED, "미허용 Discord 계정의 admin 커맨드 시도", ClientIp.of(request)) - return embed(COLOR_RED, "❌ 접근 불가", "이 Discord 계정은 관리자 목록에 없습니다.") + return DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 접근 불가", "이 Discord 계정은 관리자 목록에 없습니다.") } - return issueGrantLink(optionValue(root, "env"), userId, userName) + // data.name 라우팅 — 등록되지 않은 커맨드는 거부(운영 실수·미배포 커맨드 방지). + val handler = + handlersByName[DiscordInteractions.commandName(root)] + ?: return DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 알 수 없는 명령", "지원하지 않는 명령입니다.") + return handler.handle(DiscordInteraction(root, userId, userName, ClientIp.of(request))) } // grant 링크 클릭 — 토큰 검증(서명·만료·env·one-time) 후 접속자 IP 를 캡처해 등록 + 세션 발급(신원·IP 바인딩) → /admin. @@ -109,66 +113,4 @@ class DiscordAccessController( auditService.record(identity.name, AdminAuditAction.ACCESS_GRANTED, "원타임 링크로 접근 허용(IP 캡처)", ip) response.sendRedirect("/admin") } - - // 선택한 env 의 원타임 링크를 만든다. host 는 grantHosts 맵에서(요청 host 가 아니라) — 인터랙션 엔드포인트와 대상 env 가 - // 다를 수 있기 때문. 토큰은 그 env 에 바인딩 서명돼, 대상 env 만 소비할 수 있다. - private fun issueGrantLink( - env: String, - userId: String, - name: String, - ): Map { - val host = - adminProperties.grantHosts[env] - ?: return embed(COLOR_RED, "❌ 알 수 없는 환경", "지원하지 않는 환경입니다: `$env`") - val token = allowlistService.issueGrantToken(userId, name, env) - val link = "$host/admin-access/grant?token=$token" - return embed( - COLOR_GREEN, - "✅ 관리자 인증됨 — $name", - "**$env** 접속: 이 기기에서 3분 내 아래 링크를 여세요 (그 기기 IP 가 등록됩니다).\n$link", - ) - } - - // 최상위 커맨드 옵션 값(data.options[name==?].value). 서브커맨드 없이 env 옵션 하나라 인덱스 순회로 찾는다(Jackson 3). - private fun optionValue( - root: JsonNode, - name: String, - ): String { - val opts = root.path("data").path("options") - if (!opts.isArray) return "" - for (i in 0 until opts.size()) { - val o = opts.get(i) - if (o.path("name").takeIf { it.isString }?.asString() == name) { - return o.path("value").takeIf { it.isString }?.asString() ?: "" - } - } - return "" - } - - // Discord 인터랙션 응답: PONG(type 1). - private fun pong(): Map = mapOf("type" to TYPE_PONG) - - // ephemeral(본인만 보임, flags 64) embed 응답(type 4). 링크가 채널에 새지 않게 항상 ephemeral. - private fun embed( - color: Int, - title: String, - description: String, - ): Map = - mapOf( - "type" to TYPE_CHANNEL_MESSAGE, - "data" to - mapOf( - "embeds" to listOf(mapOf("title" to title, "description" to description, "color" to color)), - "flags" to FLAG_EPHEMERAL, - ), - ) - - companion object { - private const val TYPE_PING = 1 - private const val TYPE_PONG = 1 - private const val TYPE_CHANNEL_MESSAGE = 4 - private const val FLAG_EPHEMERAL = 64 - private const val COLOR_GREEN = 0x2ECC71 - private const val COLOR_RED = 0xE74C3C - } } diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt new file mode 100644 index 00000000..59549c13 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordInteractions.kt @@ -0,0 +1,73 @@ +package com.depromeet.piki.admin.access + +import tools.jackson.databind.JsonNode + +// Discord 인터랙션 페이로드 파싱·응답 조립 공통 헬퍼(순수). 여러 커맨드 핸들러가 공유한다. +object DiscordInteractions { + const val TYPE_PING = 1 + const val TYPE_PONG = 1 + const val TYPE_CHANNEL_MESSAGE = 4 + const val FLAG_EPHEMERAL = 64 + const val COLOR_GREEN = 0x2ECC71 + const val COLOR_RED = 0xE74C3C + + // data.name — 어느 슬래시커맨드인가(라우팅 키). + fun commandName(root: JsonNode): String = root.path("data").path("name").takeIf { it.isString }?.asString() ?: "" + + // 최상위 커맨드 옵션 값(data.options[name==?].value). 서브커맨드 없이 인덱스 순회로 찾는다(Jackson 3). + fun optionValue( + root: JsonNode, + name: String, + ): String { + val opts = root.path("data").path("options") + if (!opts.isArray) return "" + for (i in 0 until opts.size()) { + val o = opts.get(i) + if (o.path("name").takeIf { it.isString }?.asString() == name) { + return o.path("value").takeIf { it.isString }?.asString() ?: "" + } + } + return "" + } + + fun userId(root: JsonNode): String = root.path("member").path("user").path("id").takeIf { it.isString }?.asString() ?: "" + + // 로그·감사 actor 이름 — 서버 별명(nick) 우선, 없으면 표시이름(global_name), 없으면 고유 핸들(username). + fun userName(root: JsonNode): String = + root.path("member").path("nick").takeIf { it.isString }?.asString() + ?: root.path("member").path("user").path("global_name").takeIf { it.isString }?.asString() + ?: root.path("member").path("user").path("username").takeIf { it.isString }?.asString() + ?: "unknown" + + fun pong(): Map = mapOf("type" to TYPE_PONG) + + // ephemeral(본인만 보임, flags 64) 단일 embed(제목+설명). 링크·거부 UI 가 채널에 새지 않게 항상 ephemeral. + fun embed( + color: Int, + title: String, + description: String, + ): Map = + mapOf( + "type" to TYPE_CHANNEL_MESSAGE, + "data" to + mapOf( + "embeds" to listOf(mapOf("title" to title, "description" to description, "color" to color)), + "flags" to FLAG_EPHEMERAL, + ), + ) +} + +// 게이트(서명·채널·allowlist)를 통과한 인터랙션 컨텍스트. 핸들러는 이 값만 받아 커맨드를 처리한다. +data class DiscordInteraction( + val root: JsonNode, + val userId: String, + val userName: String, + val clientIp: String, +) + +// data.name 별 커맨드 처리기. 게이트는 컨트롤러가 공통으로 하고, 핸들러는 자기 커맨드 로직만 담는다. +interface DiscordCommandHandler { + val commandName: String + + fun handle(interaction: DiscordInteraction): Map +} diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.kt b/src/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.kt new file mode 100644 index 00000000..e3c72738 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/access/StatsCommandHandler.kt @@ -0,0 +1,26 @@ +package com.depromeet.piki.admin.access + +import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled +import com.depromeet.piki.metrics.dashboard.MetricsService +import org.springframework.stereotype.Component + +// `/stats period:<오늘|어제|7일|30일> metric:<요약|가입|위시|토너먼트|푸시>` — 대시보드 지표를 Discord 에서 대화형 조회(#664). +// 집계는 MetricsService 를 그대로 재사용하고(중복 0), 여기선 옵션 파싱 + 섹션 embed 표현만 한다. LLM 없음. +// 개발진(developers 명단) 활동은 대시보드 기본과 동일하게 제외한다. +@Component +@ConditionalOnAdminEnabled +class StatsCommandHandler( + private val metricsService: MetricsService, +) : DiscordCommandHandler { + override val commandName = "stats" + + override fun handle(interaction: DiscordInteraction): Map { + val period = StatsPeriod.from(DiscordInteractions.optionValue(interaction.root, "period")) + val metric = StatsMetric.from(DiscordInteractions.optionValue(interaction.root, "metric")) + + val range = metricsService.resolveRange(period.preset, null, null) + val snapshot = metricsService.snapshot(range.from, range.to, excludeInternal = true) + + return StatsEmbed.build(metric, snapshot, period.label) + } +} diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt b/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt new file mode 100644 index 00000000..5c86664a --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/access/StatsEmbed.kt @@ -0,0 +1,98 @@ +package com.depromeet.piki.admin.access + +import com.depromeet.piki.metrics.dashboard.MetricsSnapshot + +// /stats 가 조회할 지표 섹션. 슬래시커맨드 metric 옵션(choices)과 1:1. +enum class StatsMetric( + val label: String, +) { + SUMMARY("요약"), + SIGNUP("가입"), + WISH("위시"), + TOURNAMENT("토너먼트"), + PUSH("푸시"), + ; + + companion object { + // 옵션값(enum 이름 소문자, /stats 커맨드 choices) → enum. 누락·미지원은 요약으로. + fun from(value: String?): StatsMetric = entries.firstOrNull { it.name.equals(value, ignoreCase = true) } ?: SUMMARY + } +} + +// MetricsSnapshot → Discord 인터랙션 embed 응답(type 4, ephemeral) 순수 변환. +// 집계는 MetricsService 가 하고 여기선 표현만 — DB·Spring 의존이 없어 단위로 검증된다. +object StatsEmbed { + fun build( + metric: StatsMetric, + snapshot: MetricsSnapshot, + periodLabel: String, + ): Map { + val fields = + when (metric) { + StatsMetric.SUMMARY -> + listOf( + field("신규 가입", snapshot.signup.within), + field("위시 담기", snapshot.wish.total), + field("토너먼트 생성", snapshot.tournament.created), + field("푸시 발송", snapshot.push.notificationsTotal), + ) + StatsMetric.SIGNUP -> + listOf( + field("신규 가입", snapshot.signup.within), + field("회원", snapshot.signup.withinMembers), + field("게스트", snapshot.signup.withinGuests), + field("게스트→회원 전환", snapshot.signup.guestToMemberConversions), + ) + StatsMetric.WISH -> + listOf( + field("위시 담기", snapshot.wish.total), + field("URL", snapshot.wish.fromUrl), + field("이미지", snapshot.wish.fromImage), + field("파싱 성공률", "${snapshot.wish.parseSuccessRate}%"), + ) + StatsMetric.TOURNAMENT -> + listOf( + field("생성", snapshot.tournament.created), + field("참가자", snapshot.tournament.participants), + field("완료", snapshot.tournament.completed), + field("플레이", snapshot.tournament.plays), + field("평균 참가", snapshot.tournament.avgParticipants), + ) + StatsMetric.PUSH -> + listOf( + field("총 발송", snapshot.push.notificationsTotal), + field("성공", snapshot.push.deliverySuccess), + field("실패", snapshot.push.deliveryFailure), + field("근사 CTR", "${snapshot.push.ctrApproxPct}%"), + ) + } + return response("📊 ${metric.label} · $periodLabel", fields) + } + + private fun field( + name: String, + value: Long, + ): Map = field(name, value.toString()) + + private fun field( + name: String, + value: String, + ): Map = mapOf("name" to name, "value" to value, "inline" to true) + + private fun response( + title: String, + fields: List>, + ): Map = + mapOf( + "type" to TYPE_CHANNEL_MESSAGE, + "data" to + mapOf( + "embeds" to listOf(mapOf("title" to title, "color" to COLOR_BLURPLE, "fields" to fields)), + "flags" to FLAG_EPHEMERAL, + ), + ) + + private const val TYPE_CHANNEL_MESSAGE = 4 + private const val FLAG_EPHEMERAL = 64 + private const val COLOR_BLURPLE = 0x5865F2 +} diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/StatsPeriod.kt b/src/main/kotlin/com/depromeet/piki/admin/access/StatsPeriod.kt new file mode 100644 index 00000000..475c8dc3 --- /dev/null +++ b/src/main/kotlin/com/depromeet/piki/admin/access/StatsPeriod.kt @@ -0,0 +1,19 @@ +package com.depromeet.piki.admin.access + +// /stats period 옵션. preset 은 MetricsService.resolveRange 가 받는 값과 동일하게 두어(오늘/어제/7d/30d) +// 집계 로직을 그대로 재사용한다. label 은 embed 제목용 한국어. +enum class StatsPeriod( + val preset: String, + val label: String, +) { + TODAY("today", "오늘"), + YESTERDAY("yesterday", "어제"), + LAST_7D("7d", "최근 7일"), + LAST_30D("30d", "최근 30일"), + ; + + companion object { + // 옵션값(preset 문자열, /stats choices) → enum. 누락·미지원은 오늘로(가장 좁은 안전 기본). + fun from(value: String?): StatsPeriod = entries.firstOrNull { it.preset == value } ?: TODAY + } +} diff --git a/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt b/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt new file mode 100644 index 00000000..a5d7e992 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt @@ -0,0 +1,37 @@ +package com.depromeet.piki.admin.access + +import org.junit.jupiter.api.Test +import tools.jackson.databind.ObjectMapper +import kotlin.test.assertEquals + +// 인터랙션 페이로드 파싱 헬퍼(순수). 옵션·커맨드명·유저 추출을 단위로 고정한다. +class DiscordInteractionsTest { + private val mapper = ObjectMapper() + + @Test + fun `data options 에서 이름으로 옵션 값을 뽑고 없으면 빈 문자열이다`() { + val root = mapper.readTree("""{"data":{"name":"stats","options":[{"name":"period","value":"7d"},{"name":"metric","value":"wish"}]}}""") + + assertEquals("7d", DiscordInteractions.optionValue(root, "period")) + assertEquals("wish", DiscordInteractions.optionValue(root, "metric")) + assertEquals("", DiscordInteractions.optionValue(root, "absent")) + } + + @Test + fun `커맨드 이름을 뽑는다`() { + val root = mapper.readTree("""{"data":{"name":"piki-admin"}}""") + assertEquals("piki-admin", DiscordInteractions.commandName(root)) + } + + @Test + fun `유저 표시이름은 nick 우선, 없으면 global_name, 없으면 username 이다`() { + val nick = mapper.readTree("""{"member":{"nick":"별명","user":{"id":"1","global_name":"글로벌","username":"핸들"}}}""") + val global = mapper.readTree("""{"member":{"user":{"id":"1","global_name":"글로벌","username":"핸들"}}}""") + val handle = mapper.readTree("""{"member":{"user":{"id":"1","username":"핸들"}}}""") + + assertEquals("별명", DiscordInteractions.userName(nick)) + assertEquals("글로벌", DiscordInteractions.userName(global)) + assertEquals("핸들", DiscordInteractions.userName(handle)) + assertEquals("1", DiscordInteractions.userId(nick)) + } +} diff --git a/src/test/kotlin/com/depromeet/piki/admin/access/StatsEmbedTest.kt b/src/test/kotlin/com/depromeet/piki/admin/access/StatsEmbedTest.kt new file mode 100644 index 00000000..14a039e5 --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/admin/access/StatsEmbedTest.kt @@ -0,0 +1,113 @@ +package com.depromeet.piki.admin.access + +import com.depromeet.piki.metrics.dashboard.MetricsSnapshot +import org.junit.jupiter.api.Test +import java.time.LocalDateTime +import kotlin.test.assertEquals + +// /stats 슬래시커맨드의 embed 조립은 DB·Spring 없이 순수 변환이라 단위로 망라한다. +// (집계 자체는 MetricsDashboardIntegrationTest 가 통합으로 검증하므로 여기선 "스냅샷 → embed 필드" 매핑만 본다.) +class StatsEmbedTest { + private fun snapshot() = + MetricsSnapshot( + from = LocalDateTime.of(2026, 2, 1, 0, 0), + to = LocalDateTime.of(2026, 2, 28, 0, 0), + signup = + MetricsSnapshot.Signup( + before = 1000, + within = 120, + withinMembers = 80, + withinGuests = 40, + byProvider = mapOf("kakao" to 70L, "apple" to 10L), + guestToMemberConversions = 15, + ), + wish = MetricsSnapshot.Wish(total = 300, fromUrl = 200, fromImage = 100, parsedReady = 270, parsedFailed = 30), + tournament = MetricsSnapshot.Tournament(created = 50, participants = 180, itemsAdded = 600, completed = 40, plays = 220), + pushReachableUsers = 900, + retention = MetricsSnapshot.Retention(cohortSignups = 120, d1Returned = 48, dau = emptyList()), + push = + MetricsSnapshot.Push( + byType = mapOf("ALERT" to 100L), + deliverySuccess = 95, + deliveryFailure = 5, + deliverySkipped = 0, + notificationsTotal = 100, + readApprox = 40, + ), + hourlySignups = emptyList(), + ) + + @Test + fun `가입 metric 은 신규 가입·회원·게스트·전환 수를 embed 필드로 담는다`() { + val fields = fieldsOf(StatsEmbed.build(StatsMetric.SIGNUP, snapshot(), "최근 7일")) + + assertEquals("120", fields["신규 가입"]) + assertEquals("80", fields["회원"]) + assertEquals("40", fields["게스트"]) + assertEquals("15", fields["게스트→회원 전환"]) + } + + @Test + fun `위시 metric 은 총 담기·출처·파싱 성공률을 담는다`() { + val fields = fieldsOf(StatsEmbed.build(StatsMetric.WISH, snapshot(), "최근 7일")) + + assertEquals("300", fields["위시 담기"]) + assertEquals("200", fields["URL"]) + assertEquals("100", fields["이미지"]) + assertEquals("90%", fields["파싱 성공률"]) + } + + @Test + fun `토너먼트 metric 은 생성·참가·완료·플레이·평균 참가를 담는다`() { + val fields = fieldsOf(StatsEmbed.build(StatsMetric.TOURNAMENT, snapshot(), "최근 7일")) + + assertEquals("50", fields["생성"]) + assertEquals("180", fields["참가자"]) + assertEquals("40", fields["완료"]) + assertEquals("220", fields["플레이"]) + assertEquals("3.6", fields["평균 참가"]) + } + + @Test + fun `푸시 metric 은 총 발송·성공·실패·근사 CTR 을 담는다`() { + val fields = fieldsOf(StatsEmbed.build(StatsMetric.PUSH, snapshot(), "최근 7일")) + + assertEquals("100", fields["총 발송"]) + assertEquals("95", fields["성공"]) + assertEquals("5", fields["실패"]) + assertEquals("40%", fields["근사 CTR"]) + } + + @Test + fun `요약 metric 은 가입·위시·토너먼트·푸시 대표값을 한 embed 로 담는다`() { + val fields = fieldsOf(StatsEmbed.build(StatsMetric.SUMMARY, snapshot(), "최근 7일")) + + assertEquals("120", fields["신규 가입"]) + assertEquals("300", fields["위시 담기"]) + assertEquals("50", fields["토너먼트 생성"]) + assertEquals("100", fields["푸시 발송"]) + } + + @Test + fun `embed title 은 metric 이름과 기간 라벨을 함께 표시한다`() { + assertEquals("📊 요약 · 최근 7일", titleOf(StatsEmbed.build(StatsMetric.SUMMARY, snapshot(), "최근 7일"))) + assertEquals("📊 가입 · 오늘", titleOf(StatsEmbed.build(StatsMetric.SIGNUP, snapshot(), "오늘"))) + assertEquals("📊 위시 · 어제", titleOf(StatsEmbed.build(StatsMetric.WISH, snapshot(), "어제"))) + } + + // Discord 인터랙션 응답(type 4)의 data.embeds[0].fields 를 name→value 맵으로 뽑는다. + @Suppress("UNCHECKED_CAST") + private fun fieldsOf(response: Map): Map { + val fields = embed(response)["fields"] as List> + return fields.associate { (it["name"] as String) to (it["value"] as String) } + } + + private fun titleOf(response: Map): String = embed(response)["title"] as String + + @Suppress("UNCHECKED_CAST") + private fun embed(response: Map): Map { + val data = response["data"] as Map + val embeds = data["embeds"] as List> + return embeds.first() + } +} diff --git a/src/test/kotlin/com/depromeet/piki/admin/access/StatsOptionTest.kt b/src/test/kotlin/com/depromeet/piki/admin/access/StatsOptionTest.kt new file mode 100644 index 00000000..32c44f5c --- /dev/null +++ b/src/test/kotlin/com/depromeet/piki/admin/access/StatsOptionTest.kt @@ -0,0 +1,34 @@ +package com.depromeet.piki.admin.access + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals + +// 슬래시커맨드 옵션(문자열) → enum 파싱. 잘못된/누락 값은 안전한 기본값으로 떨어진다(공개 인터랙션이라 방어적). +class StatsOptionTest { + @Test + fun `metric 옵션값을 enum 으로 파싱하고 없거나 모르면 요약이 기본이다`() { + assertEquals(StatsMetric.SIGNUP, StatsMetric.from("signup")) + assertEquals(StatsMetric.WISH, StatsMetric.from("wish")) + assertEquals(StatsMetric.TOURNAMENT, StatsMetric.from("tournament")) + assertEquals(StatsMetric.PUSH, StatsMetric.from("push")) + assertEquals(StatsMetric.SUMMARY, StatsMetric.from("summary")) + assertEquals(StatsMetric.SUMMARY, StatsMetric.from(null)) + assertEquals(StatsMetric.SUMMARY, StatsMetric.from("unknown")) + } + + @Test + fun `period 옵션값을 MetricsService preset·한국어 라벨로 매핑한다`() { + assertEquals("today", StatsPeriod.from("today").preset) + assertEquals("오늘", StatsPeriod.from("today").label) + assertEquals("어제", StatsPeriod.from("yesterday").label) + assertEquals("7d", StatsPeriod.from("7d").preset) + assertEquals("최근 7일", StatsPeriod.from("7d").label) + assertEquals("최근 30일", StatsPeriod.from("30d").label) + } + + @Test + fun `period 가 없거나 모르는 값이면 오늘이 기본이다`() { + assertEquals(StatsPeriod.TODAY, StatsPeriod.from(null)) + assertEquals(StatsPeriod.TODAY, StatsPeriod.from("last-year")) + } +} From 4c47d0fcbe46429fe2a8632f80cf1a0effc8d6f5 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:14:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?infra:=20Discord=20=EC=8A=AC=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C=20(piki-admin=C2=B7stats)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow_dispatch 로 Actions 버튼 실행, secrets.DISCORD_BOT_TOKEN 사용(로컬 curl 불필요) - 길드 스코프 POST upsert, command 입력으로 all/piki-admin/stats 선택 - HTTP 200/201 확인 + 실패 시 응답 본문과 함께 에러 --- .../workflows/discord-register-commands.yml | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/discord-register-commands.yml diff --git a/.github/workflows/discord-register-commands.yml b/.github/workflows/discord-register-commands.yml new file mode 100644 index 00000000..5f1934d1 --- /dev/null +++ b/.github/workflows/discord-register-commands.yml @@ -0,0 +1,60 @@ +name: Discord 슬래시커맨드 등록 + +# Discord admin 봇의 길드 슬래시커맨드(/piki-admin·/stats)를 등록/갱신한다. +# 봇 토큰은 secrets.DISCORD_BOT_TOKEN 을 러너 안에서만 꺼내 써서(로컬 curl 불필요) 노출되지 않는다. +# 길드 스코프라 즉시 반영. POST 는 개별 upsert 라 서로를 덮어쓰지 않는다. +# Actions 탭 → 이 워크플로 → Run workflow(초록 버튼) → command 선택 → 실행. +on: + workflow_dispatch: + inputs: + command: + description: "등록할 커맨드" + type: choice + required: true + default: all + options: + - all + - piki-admin + - stats + +env: + APP_ID: "1522386661067980921" + GUILD_ID: "1520952901944475778" + +jobs: + register: + runs-on: ubuntu-latest + steps: + - name: 슬래시커맨드 등록 + env: + BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }} + COMMAND: ${{ inputs.command }} + run: | + set -euo pipefail + if [ -z "$BOT_TOKEN" ]; then echo "::error::secrets.DISCORD_BOT_TOKEN 이 비어 있음"; exit 1; fi + + register() { + # $1: 표시용 이름, $2: 커맨드 JSON + local code + code=$(curl -s -o /tmp/resp.json -w '%{http_code}' \ + -X POST "https://discord.com/api/v10/applications/$APP_ID/guilds/$GUILD_ID/commands" \ + -H "Authorization: Bot $BOT_TOKEN" \ + -H "Content-Type: application/json" \ + -d "$2") + if [ "$code" = "200" ] || [ "$code" = "201" ]; then + echo "::notice::$1 등록 완료 (HTTP $code)" + else + echo "::error::$1 등록 실패 (HTTP $code) — $(cat /tmp/resp.json)" + exit 1 + fi + } + + PIKI_ADMIN='{"name":"piki-admin","description":"백오피스 접근 링크 발급","options":[{"type":3,"name":"env","description":"접속 환경","required":true,"choices":[{"name":"dev","value":"dev"},{"name":"staging","value":"staging"},{"name":"prod","value":"prod"}]}]}' + STATS='{"name":"stats","description":"운영 지표 조회 (개발진 제외 기본)","options":[{"type":3,"name":"period","description":"조회 기간","required":true,"choices":[{"name":"오늘","value":"today"},{"name":"어제","value":"yesterday"},{"name":"최근 7일","value":"7d"},{"name":"최근 30일","value":"30d"}]},{"type":3,"name":"metric","description":"지표 섹션 (기본: 요약)","required":false,"choices":[{"name":"요약","value":"summary"},{"name":"가입","value":"signup"},{"name":"위시","value":"wish"},{"name":"토너먼트","value":"tournament"},{"name":"푸시","value":"push"}]}]}' + + case "$COMMAND" in + piki-admin) register "piki-admin" "$PIKI_ADMIN" ;; + stats) register "stats" "$STATS" ;; + all) register "piki-admin" "$PIKI_ADMIN"; register "stats" "$STATS" ;; + *) echo "::error::알 수 없는 command=$COMMAND"; exit 1 ;; + esac From fbe17e4efe266ccedb1ab27cc3ee90ad94395a63 Mon Sep 17 00:00:00 2001 From: sevineleven <117634128+sevineleven@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:08:06 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20Discord=20=EC=9D=B8=ED=84=B0?= =?UTF-8?q?=EB=9E=99=EC=85=98=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EA=B2=A9=EB=A6=AC=20+=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=EB=AA=85=20=EC=A4=91=EB=B3=B5=20=EB=B6=80=ED=8C=85=20?= =?UTF-8?q?=EA=B0=80=EB=93=9C=20(#664)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handler.handle 예외를 runCatching 으로 감싸 에러 embed 로 응답 (인터랙션 3초 타임아웃·상호작용 실패 방지) - 커맨드명 중복 시 init require 로 부팅 실패 (associateBy 의 조용한 덮어쓰기 차단) - DiscordInteractions userId/userName 폴백·embed·pong 응답 구조 단위테스트 추가 --- .../admin/access/DiscordAccessController.kt | 20 +++++++++++++++++-- .../admin/access/DiscordInteractionsTest.kt | 20 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt index a1a3728b..c7947bf8 100644 --- a/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt +++ b/src/main/kotlin/com/depromeet/piki/admin/access/DiscordAccessController.kt @@ -8,6 +8,7 @@ import com.depromeet.piki.admin.config.ConditionalOnAdminEnabled import io.swagger.v3.oas.annotations.Hidden import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory import org.springframework.http.MediaType import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.PostMapping @@ -37,8 +38,16 @@ class DiscordAccessController( private val objectMapper: ObjectMapper, handlers: List, ) { + private val log = LoggerFactory.getLogger(javaClass) private val handlersByName: Map = handlers.associateBy { it.commandName } + init { + // 커맨드명이 겹치면 associateBy 가 조용히 하나를 덮어써 그 라우팅이 사라진다. 부팅 시점에 깨 fail-fast. + require(handlers.size == handlersByName.size) { + "Discord 커맨드명이 중복됐다: ${handlers.map { it.commandName }}" + } + } + // Discord 인터랙션 수신(application/json). Ed25519 서명 검증 후 분기: // PING(type 1) → PONG (Discord 가 엔드포인트 등록 시 이걸로 살아있음을 확인) // 커맨드(type 2) → 채널 게이트 → allowlist 게이트 → data.name 라우팅 → 해당 핸들러 응답(ephemeral) @@ -84,10 +93,17 @@ class DiscordAccessController( } // data.name 라우팅 — 등록되지 않은 커맨드는 거부(운영 실수·미배포 커맨드 방지). + val commandName = DiscordInteractions.commandName(root) val handler = - handlersByName[DiscordInteractions.commandName(root)] + handlersByName[commandName] ?: return DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 알 수 없는 명령", "지원하지 않는 명령입니다.") - return handler.handle(DiscordInteraction(root, userId, userName, ClientIp.of(request))) + // 핸들러 예외(DB 조회 실패 등)가 인터랙션 응답을 통째로 깨지 않게 감싼다. + // Discord 는 3초 안에 응답이 없으면 사용자에게 "상호작용 실패" 를 띄우므로, 예외도 사용자 대면 embed 로 되돌린다. + return runCatching { handler.handle(DiscordInteraction(root, userId, userName, ClientIp.of(request))) } + .getOrElse { e -> + log.error("Discord 커맨드 처리 실패: command={}", commandName, e) + DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "❌ 처리 실패", "요청 처리 중 오류가 발생했어요. 잠시 후 다시 시도해주세요.") + } } // grant 링크 클릭 — 토큰 검증(서명·만료·env·one-time) 후 접속자 IP 를 캡처해 등록 + 세션 발급(신원·IP 바인딩) → /admin. diff --git a/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt b/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt index a5d7e992..7355d02f 100644 --- a/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt +++ b/src/test/kotlin/com/depromeet/piki/admin/access/DiscordInteractionsTest.kt @@ -34,4 +34,24 @@ class DiscordInteractionsTest { assertEquals("핸들", DiscordInteractions.userName(handle)) assertEquals("1", DiscordInteractions.userId(nick)) } + + @Test + fun `member user 정보가 없으면 userId 는 빈 문자열, userName 은 unknown 이다`() { + // allowlist 게이트가 userId 로 판정하므로, 예상 밖 payload(member 없음)의 fail-safe 폴백을 고정한다. + val root = mapper.readTree("""{"data":{"name":"stats"}}""") + assertEquals("", DiscordInteractions.userId(root)) + assertEquals("unknown", DiscordInteractions.userName(root)) + } + + @Test + fun `embed 는 type 4 와 ephemeral flag 를, pong 은 type 1 을 담는다`() { + val embed = DiscordInteractions.embed(DiscordInteractions.COLOR_RED, "t", "d") + assertEquals(DiscordInteractions.TYPE_CHANNEL_MESSAGE, embed["type"]) + + @Suppress("UNCHECKED_CAST") + val data = embed["data"] as Map + assertEquals(DiscordInteractions.FLAG_EPHEMERAL, data["flags"]) + + assertEquals(DiscordInteractions.TYPE_PONG, DiscordInteractions.pong()["type"]) + } }