This commit is contained in:
lunaticbum 2025-09-11 16:32:24 +09:00
parent 2bf6f8d232
commit 8f1aa71d52

View File

@ -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<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
)
}
}
}
// --- 데이터 클래스 정의 ---
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<AnalyticsReportData> = 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>?): 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<SalesReportRecord> {
val records = mutableListOf<SalesReportRecord>()
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<String?>(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<List<SalesReportRecord>>(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<SalesReportRecord>, 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<AnalyticsResult?>(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}") } } } }
)
}
}
}
}
}