...
This commit is contained in:
parent
2bf6f8d232
commit
8f1aa71d52
@ -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}") } } } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user