This commit is contained in:
lunaticbum 2025-09-11 15:49:10 +09:00
parent 167822b9b5
commit 2bf6f8d232
5 changed files with 496 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@ -0,0 +1,45 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Kotlin ###
.kotlin
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

58
build.gradle.kts Normal file
View File

@ -0,0 +1,58 @@
// build.gradle.kts
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
plugins {
kotlin("jvm") version "2.0.0"
id("org.jetbrains.compose") version "1.8.0"
kotlin("plugin.serialization") version "2.0.0"
// ⬇️ 이 줄을 추가해 주세요!
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
}
group = "com.example"
version = "1.0.0"
repositories {
google()
mavenCentral()
maven("https://maven.pkg.jetbrains.space/public/p/compose/dev")
}
dependencies {
implementation(compose.desktop.currentOs)
implementation(compose.materialIconsExtended)
// JWT 라이브러리
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
implementation("io.jsonwebtoken:jjwt-impl:0.11.5")
implementation("io.jsonwebtoken:jjwt-jackson:0.11.5")
// Ktor HTTP 클라이언트
implementation("io.ktor:ktor-client-core:2.3.11")
implementation("io.ktor:ktor-client-cio:2.3.11")
implementation("io.ktor:ktor-client-logging:2.3.11")
// ⬇️ Ktor가 JSON을 처리할 수 있도록 도와주는 라이브러리 추가
implementation("io.ktor:ktor-client-content-negotiation:2.3.11")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.11")
// ⬇️ Kotlinx Serialization 라이브러리 추가
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.0")
}
kotlin {
jvmToolchain(21)
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
compose.desktop {
application {
mainClass = "MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "AnalyticsReportGenerator" // 앱 패키지 이름 변경
}
}
}

1
gradle.properties Normal file
View File

@ -0,0 +1 @@
kotlin.code.style=official

4
settings.gradle.kts Normal file
View File

@ -0,0 +1,4 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "untitled"

388
src/main/kotlin/Main.kt Normal file
View File

@ -0,0 +1,388 @@
// Main.kt
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.*
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.awt.FileDialog
import java.awt.Frame
import java.io.File
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.text.NumberFormat
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDate
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters
import java.util.*
import java.util.prefs.Preferences
// --- 상수 정의 ---
private const val ISSUER_ID_KEY = "APP_STORE_ISSUER_ID"
private const val KEY_ID_KEY = "APP_STORE_KEY_ID"
private const val APP_ID_KEY = "APP_STORE_APP_ID" // Vendor Number 대신 App ID 사용
// --- 데이터 클래스 (JSON 파싱용) ---
@Serializable
data class AnalyticsMetric(
val data: List<MetricData> = emptyList()
)
@Serializable
data class MetricData(
val dataPoints: DataPoints
)
@Serializable
data class DataPoints(
val totals: Totals? = null
)
@Serializable
data class Totals(
val count: Long? = null
)
// --- 화면에 표시할 최종 분석 결과 데이터 클래스 ---
data class AnalyticsResult(
val installations: Long = 0,
val redownloads: Long = 0,
val updates: Long = 0
)
// --- API 클라이언트 ---
suspend fun fetchAnalyticsMetric(
client: HttpClient,
token: String,
appId: String,
metricType: String,
date: String
): Long {
try {
val response: AnalyticsMetric = client.get("https://api.appstoreconnect.apple.com/v1/apps/$appId/metrics") {
header(HttpHeaders.Authorization, "Bearer $token")
parameter("granularity", "DAY")
parameter("filter[metricType]", metricType)
parameter("filter[days]", date)
}.body()
return response.data.firstOrNull()?.dataPoints?.totals?.count ?: 0L
} catch (e: Exception) {
println("Error fetching metric $metricType: ${e.message}")
throw e // 오류를 상위로 전파하여 처리
}
}
@Composable
fun SimpleDatePicker(
initialDate: LocalDate,
onDateSelected: (LocalDate) -> Unit,
onDismissRequest: () -> Unit
) {
var displayedYearMonth by remember { mutableStateOf(YearMonth.from(initialDate)) }
Dialog(onDismissRequest = onDismissRequest) {
Surface(
shape = MaterialTheme.shapes.medium,
modifier = Modifier.padding(16.dp).widthIn(max = 320.dp),
elevation = 8.dp
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Button(onClick = { displayedYearMonth = displayedYearMonth.minusMonths(1) }) { Text("<") }
Text(
text = displayedYearMonth.format(DateTimeFormatter.ofPattern("yyyy년 MM월")),
style = MaterialTheme.typography.h6
)
Button(onClick = { displayedYearMonth = displayedYearMonth.plusMonths(1) }) { Text(">") }
}
Spacer(Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
listOf("", "", "", "", "", "", "").forEach { day ->
Text(
text = day,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontWeight = FontWeight.Bold
)
}
}
Spacer(Modifier.height(8.dp))
val daysInMonth = displayedYearMonth.lengthOfMonth()
val firstDayOfMonth = displayedYearMonth.atDay(1)
val firstDayOfWeek = firstDayOfMonth.dayOfWeek.value % 7
LazyVerticalGrid(
columns = androidx.compose.foundation.lazy.grid.GridCells.Fixed(7),
contentPadding = PaddingValues(vertical = 4.dp)
) {
items(firstDayOfWeek) { Box(Modifier.size(40.dp)) }
items(daysInMonth) { day ->
val date = displayedYearMonth.atDay(day + 1)
val isSelected = date == initialDate
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (isSelected) MaterialTheme.colors.primary else Color.Transparent)
.clickable { onDateSelected(date) },
contentAlignment = Alignment.Center
) {
Text(
text = "${day + 1}",
color = if (isSelected) MaterialTheme.colors.onPrimary else MaterialTheme.colors.onSurface
)
}
}
}
}
}
}
}
@Composable
fun App(
filePath: String,
onFilePathChange: (String) -> Unit
) {
var error by remember { mutableStateOf<String?>(null) }
val prefs = remember { Preferences.userRoot().node("com.example.analyticsreportgenerator") }
var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }
var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }
var appId by remember { mutableStateOf(prefs.get(APP_ID_KEY, "")) } // Vendor Number 대신 App ID
var statusMessage by remember { mutableStateOf("분석할 앱의 정보를 입력하고 날짜를 선택하세요.") }
var analyticsResult by remember { mutableStateOf<AnalyticsResult?>(null) }
val scope = rememberCoroutineScope()
val dailyFormat = DateTimeFormatter.ISO_LOCAL_DATE
var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(2)) }
var showDatePicker by remember { mutableStateOf(false) }
val client = remember {
HttpClient(CIO) {
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println("===== KTOR API LOG =====\n$message\n========================")
}
}
level = LogLevel.ALL
}
// JSON 파싱을 위한 ContentNegotiation 플러그인 설치
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // API 응답에 모르는 필드가 있어도 무시
})
}
}
}
DisposableEffect(Unit) { onDispose { client.close() } }
val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } }
fun isReady(): Boolean {
if (privateKey == null) { error = "개인키 파일(.p8)을 선택하세요."; return false }
if (issuerId.isBlank() || keyId.isBlank()) { error = "Issuer ID와 Key ID를 입력하세요."; return false }
if (appId.isBlank()) { error = "분석을 위해 App ID를 입력하세요."; return false }
error = null; return true
}
if (showDatePicker) {
SimpleDatePicker(
initialDate = reportDate,
onDismissRequest = { showDatePicker = false },
onDateSelected = { date ->
reportDate = date
showDatePicker = false
}
)
}
Row(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.width(350.dp).fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) {
Text("Issuer ID"); TextField(value = issuerId, onValueChange = {issuerId = it; prefs.put(ISSUER_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
Text("Key ID"); TextField(value = keyId, onValueChange = {keyId = it; prefs.put(KEY_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
Text("App ID (앱 고유 ID)"); TextField(value = appId, onValueChange = {appId = it; prefs.put(APP_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp))
Text("개인키(.p8) 파일 (버튼을 클릭하여 파일 선택)")
Row(verticalAlignment = Alignment.CenterVertically) {
TextField(value = filePath, onValueChange = onFilePathChange, singleLine = true, modifier = Modifier.weight(1f), readOnly = true)
Spacer(Modifier.width(8.dp))
Button(onClick = {
val dialog = FileDialog(Frame(), "개인키(.p8) 파일을 선택하세요", FileDialog.LOAD).apply {
setFile("*.p8")
isVisible = true
}
if (dialog.directory != null && dialog.file != null) {
onFilePathChange(dialog.directory + dialog.file)
}
}) { Text("찾기...") }
}
Divider(modifier = Modifier.padding(vertical = 16.dp))
Text("조회 날짜 (YYYY-MM-DD)")
Box(modifier = Modifier.clickable { showDatePicker = true }) {
OutlinedTextField(
value = reportDate.format(dailyFormat), onValueChange = { }, readOnly = true, modifier = Modifier.fillMaxWidth(),
trailingIcon = { Icon(imageVector = Icons.Default.DateRange, contentDescription = "날짜 선택") },
enabled = false,
colors = TextFieldDefaults.outlinedTextFieldColors(
disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current),
disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled),
disabledLabelColor = MaterialTheme.colors.onSurface.copy(ContentAlpha.medium),
disabledTrailingIconColor = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.IconOpacity)
)
)
}
Spacer(Modifier.height(16.dp))
Button(
onClick = {
if (!isReady()) return@Button
scope.launch {
try {
analyticsResult = null
val dateString = reportDate.format(dailyFormat)
statusMessage = "[$dateString] 앱 분석 데이터 로딩 중..."
error = null
val reportToken = createJWT(privateKey!!, issuerId, keyId, listOf("GET:/v1/apps/$appId/metrics"))
// 3가지 메트릭을 동시에 요청 (병렬 처리)
coroutineScope {
val installsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "INSTALLATIONS", dateString) }
val redownloadsDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "REDOWNLOADS", dateString) }
val updatesDeferred = async { fetchAnalyticsMetric(client, reportToken, appId, "UPDATES", dateString) }
analyticsResult = AnalyticsResult(
installations = installsDeferred.await(),
redownloads = redownloadsDeferred.await(),
updates = updatesDeferred.await()
)
}
statusMessage = "[$dateString] 분석 완료"
} catch (e: Exception) {
println("===== 작업 실패 ====="); e.printStackTrace(); error = "작업 오류: ${e.message}"; statusMessage = "오류: ${e.message}"
}
}
},
modifier = Modifier.fillMaxWidth(), enabled = privateKey != null
) { Text("앱 분석 통계 조회") }
Spacer(Modifier.height(16.dp))
if (error != null) { Text(text = error ?: "", color = Color.Red, modifier = Modifier.padding(top = 8.dp)) }
}
Divider(modifier = Modifier.fillMaxHeight().width(1.dp))
ReportView(
modifier = Modifier.weight(1f).fillMaxHeight(),
result = analyticsResult,
statusMessage = statusMessage
)
}
}
@Composable
fun ReportView(
modifier: Modifier = Modifier,
result: AnalyticsResult?,
statusMessage: String
) {
Column(modifier.padding(16.dp)) {
Text(
text = statusMessage,
style = MaterialTheme.typography.subtitle1,
color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f),
modifier = Modifier.padding(bottom = 16.dp)
)
if (result != null) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
AnalyticsCard("최초 설치 (Installations)", result.installations, MaterialTheme.colors.primary)
AnalyticsCard("재다운로드 (Re-downloads)", result.redownloads, Color(0xFF00897B))
AnalyticsCard("업데이트 (Updates)", result.updates, Color(0xFF5E35B1))
}
} else {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("표시할 데이터가 없습니다.", color = Color.Gray)
}
}
}
}
@Composable
fun AnalyticsCard(title: String, value: Long, color: Color) {
Card(elevation = 4.dp, modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.h6)
Spacer(Modifier.height(8.dp))
Text(
text = NumberFormat.getInstance().format(value),
style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold),
color = color
)
}
}
}
fun loadPrivateKey(path: String): PrivateKey {
val keyBytes = Files.readAllBytes(Paths.get(path)); val keyPem = String(keyBytes).replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").replace("\\s".toRegex(), ""); val decoded = Base64.getDecoder().decode(keyPem); val keySpec = PKCS8EncodedKeySpec(decoded); val kf = KeyFactory.getInstance("EC"); return kf.generatePrivate(keySpec)
}
fun createJWT(privateKey: PrivateKey, issuerId: String, keyId: String, scope: List<String>?): String {
val now = Instant.now(); val expire = now.plusSeconds(20 * 60); val builder = Jwts.builder().setHeaderParam("alg", "ES256").setHeaderParam("kid", keyId).setHeaderParam("typ", "JWT").setIssuer(issuerId).setAudience("appstoreconnect-v1").setIssuedAt(Date.from(now)).setExpiration(Date.from(expire)); if (!scope.isNullOrEmpty()) { builder.claim("scope", scope) }; return builder.signWith(privateKey, SignatureAlgorithm.ES256).compact()
}
fun main() = application {
var filePath by remember { mutableStateOf("") }
Window(onCloseRequest = ::exitApplication, title = "App Analytics 리포트 생성기") {
MaterialTheme {
App(
filePath = filePath,
onFilePathChange = { filePath = it }
)
}
}
}