diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index 4eac89d..278593b 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -3,8 +3,12 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.* @@ -28,361 +32,213 @@ 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.plugins.logging.* import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.ContentType import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.* -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay 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.io.ByteArrayInputStream 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.* import java.time.format.DateTimeFormatter -import java.time.temporal.TemporalAdjusters import java.util.* import java.util.prefs.Preferences +import java.util.zip.GZIPInputStream -// --- 상수 정의 --- +// --- 상수 및 Enum 정의 --- 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 사용 +private const val APP_ID_KEY = "APP_STORE_APP_ID" +private const val VENDOR_NUMBER_KEY = "APP_STORE_VENDOR_NUMBER" +enum class ReportType { SALES, ANALYTICS } -// --- 데이터 클래스 (JSON 파싱용) --- -@Serializable -data class AnalyticsMetric( - val data: List = 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(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(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 - ) - } - } -} +// --- 데이터 클래스 정의 --- +data class SalesReportRecord(val title: String, val units: Int) +data class AnalyticsResult(val installations: Long = 0, val redownloads: Long = 0, val updates: Long = 0) +@Serializable data class AnalyticsReportRequestResponse(val data: AnalyticsReportRequestData) +@Serializable data class AnalyticsReportRequestData(val id: String, val links: Links) +@Serializable data class Links(val self: String) +@Serializable data class AnalyticsReportResponse(val data: List = emptyList()) +@Serializable data class AnalyticsReportData(val attributes: ReportAttributes? = null) +@Serializable data class ReportAttributes(val downloadUrl: String? = null) +// --- 유틸리티 함수 --- 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 { 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 parseSalesReport(tsvData: String): List { + val records = mutableListOf() + val lines = tsvData.split("\n").drop(1) + lines.forEach { line -> if (line.isBlank()) return@forEach; val columns = line.split("\t"); try { records.add(SalesReportRecord(title = columns.getOrElse(4) { "N/A" }, units = columns.getOrElse(7) { "0" }.toIntOrNull() ?: 0)) } catch (e: Exception) { println("TSV 파싱 오류: $line, ${e.message}") } } + return records.groupBy { it.title }.map { (title, unitList) -> SalesReportRecord(title, unitList.sumOf { it.units }) }.sortedByDescending { it.units } +} +fun parseAnalyticsReport(csvData: String): AnalyticsResult { + var installs = 0L; var redownloads = 0L; var updates = 0L + val lines = csvData.split("\n").drop(1) + lines.forEach { line -> if (line.isBlank()) return@forEach; val columns = line.split(","); try { when (columns.getOrNull(0)?.trim()) { "INSTALLATIONS" -> installs = columns.getOrNull(1)?.toLongOrNull() ?: 0L; "REDOWNLOADS" -> redownloads = columns.getOrNull(1)?.toLongOrNull() ?: 0L; "UPDATES" -> updates = columns.getOrNull(1)?.toLongOrNull() ?: 0L } } catch (e: Exception) { println("Analytics CSV 파싱 오류: $line, ${e.message}") } } + return AnalyticsResult(installations = installs, redownloads = redownloads, updates = updates) +} + +// --- 메인 화면 및 컴포저블 --- +@Composable +fun App(onSaveTsvRequest: (period: String, tsvData: String) -> Unit, onSaveCsvRequest: (appId: String, date: LocalDate, result: AnalyticsResult) -> Unit) { + val prefs = remember { Preferences.userRoot().node("com.example.combinedreportgenerator") }; var selectedTab by remember { mutableStateOf(ReportType.SALES) }; var filePath by remember { mutableStateOf("") }; var issuerId by remember { mutableStateOf(prefs.get(ISSUER_ID_KEY, "")) }; var keyId by remember { mutableStateOf(prefs.get(KEY_ID_KEY, "")) }; var error by remember { mutableStateOf(null) }; val privateKey: PrivateKey? by remember(filePath) { derivedStateOf { if (filePath.isNotBlank()) { try { error = null; loadPrivateKey(filePath) } catch (e: Exception) { error = "개인키 파일 로드 실패: ${e.message}"; null } } else { null } } }; val client = remember { HttpClient(CIO) { install(Logging) { logger = object : Logger { override fun log(message: String) { println("KTOR LOG: $message") } }; level = LogLevel.ALL }; install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; isLenient = true; }) } } }; DisposableEffect(Unit) { onDispose { client.close() } } + Row(modifier = Modifier.fillMaxSize()) { + Column(modifier = Modifier.width(350.dp).fillMaxHeight().verticalScroll(rememberScrollState()).padding(16.dp)) { + Text("API Key 정보", style = MaterialTheme.typography.h6); Spacer(Modifier.height(8.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("개인키(.p8) 파일") + Row(verticalAlignment = Alignment.CenterVertically) { + TextField(value = filePath, onValueChange = { filePath = it }, 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) { filePath = dialog.directory + dialog.file } }) { Text("찾기") } + } + if (error != null) { Text(text = error ?: "", color = Color.Red, modifier = Modifier.padding(top = 8.dp)) } + Divider(modifier = Modifier.padding(vertical = 16.dp)); TabRow(selectedTabIndex = selectedTab.ordinal) { Tab(selected = selectedTab == ReportType.SALES, onClick = { selectedTab = ReportType.SALES }, text = { Text("판매 리포트") }); Tab(selected = selectedTab == ReportType.ANALYTICS, onClick = { selectedTab = ReportType.ANALYTICS }, text = { Text("앱 분석") }) }; Spacer(Modifier.height(16.dp)) + when (selectedTab) { + ReportType.SALES -> SalesReportSettings(prefs, privateKey, client, issuerId, keyId, onSaveTsvRequest) + ReportType.ANALYTICS -> AnalyticsReportSettings(prefs, privateKey, client, issuerId, keyId, onSaveCsvRequest) + } + } + Divider(modifier = Modifier.fillMaxHeight().width(1.dp)) + } +} + +// --- 판매 리포트 관련 컴포저블 --- +@Composable +fun SalesReportSettings(prefs: Preferences, privateKey: PrivateKey?, client: HttpClient, issuerId: String, keyId: String, onSaveTsvRequest: (period: String, tsvData: String) -> Unit) { + var vendorNumber by remember { mutableStateOf(prefs.get(VENDOR_NUMBER_KEY, "")) }; var statusMessage by remember { mutableStateOf("리포트 조회를 위한 정보를 입력하세요.") }; var salesResult by remember { mutableStateOf>(emptyList()) }; var rawTsvReport by remember { mutableStateOf("") }; val scope = rememberCoroutineScope(); val frequencies = listOf("DAILY", "WEEKLY", "MONTHLY", "YEARLY"); var selectedFrequency by remember { mutableStateOf(frequencies.first()) }; var reportPeriod by remember { mutableStateOf(LocalDate.now().minusDays(3).format(DateTimeFormatter.ISO_LOCAL_DATE)) } + Text("Vendor Number"); TextField(value = vendorNumber, onValueChange = {vendorNumber = it; prefs.put(VENDOR_NUMBER_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("리포트 빈도") + frequencies.forEach { freq -> Row(Modifier.fillMaxWidth().selectable(selected = (freq == selectedFrequency), onClick = { selectedFrequency = freq }).padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = (freq == selectedFrequency), onClick = null); Text(text = freq, modifier = Modifier.padding(start = 8.dp)) } } + Spacer(Modifier.height(8.dp)); Text("조회 기간"); OutlinedTextField(value = reportPeriod, onValueChange = { reportPeriod = it }, modifier = Modifier.fillMaxWidth()); Text("DAILY/WEEKLY: YYYY-MM-DD, MONTHLY: YYYY-MM, YEARLY: YYYY", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) + Button(onClick = { + scope.launch { + if(privateKey == null || vendorNumber.isBlank() || issuerId.isBlank() || keyId.isBlank()) { statusMessage = "오류: 모든 정보를 입력해주세요."; return@launch } + try { + statusMessage = "[$reportPeriod] 판매 리포트 로딩 중..."; salesResult = emptyList() + val token = createJWT(privateKey, issuerId, keyId, null) + val response: HttpResponse = client.get("https://api.appstoreconnect.apple.com/v1/salesReports") { + header(HttpHeaders.Accept, "application/a-gzip"); header(HttpHeaders.Authorization, "Bearer $token") + parameter("filter[reportType]", "SALES"); parameter("filter[reportSubType]", "SUMMARY"); parameter("filter[vendorNumber]", vendorNumber); parameter("filter[frequency]", selectedFrequency) + when (selectedFrequency) { "DAILY", "WEEKLY" -> parameter("filter[reportDate]", reportPeriod); "MONTHLY" -> parameter("filter[reportMonth]", reportPeriod); "YEARLY" -> parameter("filter[reportYear]", reportPeriod) } + } + if (!response.status.isSuccess()) throw Exception("API 오류 (${response.status}): ${response.bodyAsText()}") + rawTsvReport = GZIPInputStream(ByteArrayInputStream(response.body())).bufferedReader(Charsets.UTF_8).use { it.readText() } + salesResult = parseSalesReport(rawTsvReport); statusMessage = "[$reportPeriod] 리포트 조회 완료" + } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + }, modifier = Modifier.fillMaxWidth(), enabled = privateKey != null) { Text("판매 리포트 조회") } + Spacer(Modifier.height(16.dp)); Divider(); SalesResultView(statusMessage, salesResult) { onSaveTsvRequest(reportPeriod, rawTsvReport) } +} + +@Composable +fun SalesResultView(statusMessage: String, result: List, onSaveRequest: () -> Unit) { + Column(Modifier.fillMaxSize().padding(top = 16.dp)) { + Text(statusMessage, style = MaterialTheme.typography.subtitle2); Spacer(Modifier.height(8.dp)) + if (result.isNotEmpty()) { + Text("총 다운로드 (Units): ${NumberFormat.getInstance().format(result.sumOf { it.units })}", fontWeight = FontWeight.Bold); Spacer(Modifier.height(8.dp)); Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text("앱 이름", Modifier.weight(1f), fontWeight = FontWeight.Bold); Text("다운로드 수", Modifier.width(100.dp), fontWeight = FontWeight.Bold, textAlign = TextAlign.End) }; Divider() + LazyColumn(modifier = Modifier.weight(1f)) { items(result) { (title, units) -> Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { Text(title, Modifier.weight(1f)); Text(NumberFormat.getInstance().format(units), Modifier.width(100.dp), textAlign = TextAlign.End) }; Divider(color = Color.Gray.copy(alpha = 0.2f)) } } + Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("원본 TSV 다운로드") } + } + } +} + +// --- 앱 분석 관련 컴포저블 --- +@Composable +fun AnalyticsReportSettings(prefs: Preferences, privateKey: PrivateKey?, client: HttpClient, issuerId: String, keyId: String, onSaveCsvRequest: (appId: String, date: LocalDate, result: AnalyticsResult) -> Unit) { + var appId by remember { mutableStateOf(prefs.get(APP_ID_KEY, "")) }; var statusMessage by remember { mutableStateOf("분석할 앱의 정보를 입력하세요.") }; var analyticsResult by remember { mutableStateOf(null) }; var reportDate by remember { mutableStateOf(LocalDate.now().minusDays(3)) }; val scope = rememberCoroutineScope(); var showDatePicker by remember { mutableStateOf(false) } + Text("App ID"); TextField(value = appId, onValueChange = {appId = it; prefs.put(APP_ID_KEY, it)}, singleLine = true, modifier = Modifier.fillMaxWidth()); Spacer(Modifier.height(8.dp)); Text("조회 날짜") + Box(modifier = Modifier.clickable { showDatePicker = true }) { OutlinedTextField(value = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE), onValueChange = {}, readOnly = true, modifier = Modifier.fillMaxWidth(), trailingIcon = { Icon(Icons.Default.DateRange, "날짜 선택") }, enabled = false, colors = TextFieldDefaults.outlinedTextFieldColors(disabledTextColor = LocalContentColor.current.copy(LocalContentAlpha.current), disabledBorderColor = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.disabled))) } + if (showDatePicker) { SimpleDatePicker(initialDate = reportDate, onDateSelected = { reportDate = it; showDatePicker = false }, onDismissRequest = { showDatePicker = false }) } + Text("YYYY-MM-DD 형식. 3~4일 이전 날짜 권장", style = MaterialTheme.typography.caption); Spacer(Modifier.height(16.dp)) + Button(onClick = { + scope.launch { + if(privateKey == null || appId.isBlank() || issuerId.isBlank() || keyId.isBlank()) { statusMessage = "오류: 모든 정보를 입력해주세요."; return@launch } + try { + val dateString = reportDate.format(DateTimeFormatter.ISO_LOCAL_DATE) + statusMessage = "[$dateString] 분석 리포트 요청 중..."; analyticsResult = null + val token = createJWT(privateKey, issuerId, keyId, null) + + // ⬇️ 1. 리포트 생성 요청 (POST) - API 버전을 v1으로 수정 + val requestBody = """{"data":{"type":"analyticsReportRequests","attributes":{"accessType":"ONE_TIME_SNAPSHOT","stopped":true,"granularity":"DAILY","dimensionFilters":[{"dimensionKey":"app","optionKeys":["$appId"]}],"metricFilters":[{"metricType":"INSTALLATIONS"},{"metricType":"REDOWNLOADS"},{"metricType":"UPDATES"}]}}}""" + val requestResponse: HttpResponse = client.post("https://api.appstoreconnect.apple.com/v1/analyticsReportRequests") { + header(HttpHeaders.Authorization, "Bearer $token"); contentType(ContentType.Application.Json); setBody(requestBody) + } + if (!requestResponse.status.isSuccess()) throw Exception("리포트 요청 실패 (${requestResponse.status}): ${requestResponse.bodyAsText()}") + val reportRequestData: AnalyticsReportRequestResponse = requestResponse.body() + + // ⬇️ 2. 리포트 생성 대기 및 확인 + var downloadUrl: String? = null + repeat(10) { + statusMessage = "[$dateString] 리포트 생성 대기 중... (${it + 1}/10)"; delay(5000) + val reportListResponse: HttpResponse = client.get(reportRequestData.data.links.self + "/reports") { header(HttpHeaders.Authorization, "Bearer $token") } + if (reportListResponse.status.isSuccess()) { + val reports: AnalyticsReportResponse = reportListResponse.body() + downloadUrl = reports.data.firstOrNull()?.attributes?.downloadUrl + if (downloadUrl != null) { statusMessage = "[$dateString] 리포트 다운로드 중..."; return@repeat } + } + } + if (downloadUrl == null) throw Exception("리포트 생성 시간 초과") + + // ⬇️ 3. 생성된 리포트 다운로드 및 파싱 + val reportDataResponse: HttpResponse = client.get(downloadUrl!!) { header(HttpHeaders.Authorization, "Bearer $token") } + if (!reportDataResponse.status.isSuccess()) throw Exception("리포트 다운로드 실패: ${reportDataResponse.status}") + val reportCsv = GZIPInputStream(ByteArrayInputStream(reportDataResponse.body())).bufferedReader(Charsets.UTF_8).use { it.readText() } + analyticsResult = parseAnalyticsReport(reportCsv); statusMessage = "[$dateString] 분석 완료" + } catch (e: Exception) { e.printStackTrace(); statusMessage = "오류: ${e.message}" } + } + }, modifier = Modifier.fillMaxWidth(), enabled = privateKey != null) { Text("앱 분석 통계 조회") } + Spacer(Modifier.height(16.dp)); Divider(); AnalyticsResultView(statusMessage, analyticsResult) { analyticsResult?.let { onSaveCsvRequest(appId, reportDate, it) } } +} + +@Composable +fun AnalyticsResultView(statusMessage: String, result: AnalyticsResult?, onSaveRequest: () -> Unit) { + Column(Modifier.fillMaxSize().padding(top = 16.dp)) { + Text(statusMessage, style = MaterialTheme.typography.subtitle2); Spacer(Modifier.height(16.dp)) + if (result != null) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(16.dp)) { + AnalyticsCard("최초 설치", result.installations, MaterialTheme.colors.primary); AnalyticsCard("재다운로드", result.redownloads, Color(0xFF00897B)); AnalyticsCard("업데이트", result.updates, Color(0xFF5E35B1)) + } + Spacer(Modifier.height(16.dp)); Button(onClick = onSaveRequest, modifier = Modifier.fillMaxWidth()) { Text("CSV로 저장") } + } + } +} +@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(NumberFormat.getInstance().format(value), style = MaterialTheme.typography.h4.copy(fontWeight = FontWeight.Bold), color = color) } } } + +@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).dayOfWeek.value % 7 + LazyVerticalGrid(columns = GridCells.Fixed(7), contentPadding = PaddingValues(vertical = 4.dp)) { items(firstDayOfMonth) { 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) } } } + } } } +} fun main() = application { - var filePath by remember { mutableStateOf("") } - Window(onCloseRequest = ::exitApplication, title = "App Analytics 리포트 생성기") { + val snackbarHostState = remember { SnackbarHostState() }; val scope = rememberCoroutineScope() + Window(onCloseRequest = ::exitApplication, title = "App Store Connect 리포트") { MaterialTheme { - App( - filePath = filePath, - onFilePathChange = { filePath = it } - ) + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { paddingValues -> + Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) { + App( + onSaveTsvRequest = { period, tsvData -> val dialog = FileDialog(Frame(), "TSV 파일로 저장", FileDialog.SAVE).apply { file = "sales-report-$period.tsv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), tsvData); scope.launch { snackbarHostState.showSnackbar("TSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } }, + onSaveCsvRequest = { appId, date, result -> val csvContent = "Metric,Count\nInstallations,${result.installations}\nRedownloads,${result.redownloads}\nUpdates,${result.updates}"; val dialog = FileDialog(Frame(), "CSV 파일로 저장", FileDialog.SAVE).apply { file = "analytics-$appId-${date.format(DateTimeFormatter.ISO_LOCAL_DATE)}.csv"; isVisible = true }; if (dialog.directory != null && dialog.file != null) { try { Files.writeString(Paths.get(dialog.directory, dialog.file), csvContent); scope.launch { snackbarHostState.showSnackbar("CSV 파일 저장 완료") } } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar("파일 저장 실패: ${e.message}") } } } } + ) + } + } } } } \ No newline at end of file