fix(labdb): 미업로드를 전 폴더에서 올린다 · 업로드가 화면과 함께 죽지 않게
2026-09-10 병원에서 600 업로드 24건이 실패했고, 앱을 다시 켜자 **올릴 길이 없어져** PC 로
꺼내 올려야 했다(39건). 사유가 셋이었고 그중 하나는 우리 쪽 결함이다.
12건 DNS 해석 실패 망
6건 TLS 인증서 신뢰 실패 망 (LTE 에서는 정상 접속 — 병원 망의 검사 프록시로 보인다)
6건 화면 이탈로 업로드 취소 **앱 결함**
## ① 화면을 떠나면 업로드가 잘렸다
측정 루프의 `launch { upload(file) }` 가 `LaunchedEffect` 스코프였다. 조합이 끝나고 화면을
떠나면 전송이 취소된다 — `The coroutine scope left the composition`. 정렬(601)은 같은 이유로
이미 오브젝트 스코프로 옮겼는데(8b3b5c0) **프로토콜(600)만 남아 있었다.**
`uploadAsync()` 를 만들어 옮겼다.
## ② [지금 업로드] 의 사정권이 한 폴더뿐이었다
`lastRunDir` 은 **이번 실행**의 폴더이고 `remember` 다. 그래서:
· 앱을 다시 켜면 null → **카드 자체가 사라진다** ← 39건이 고립된 직접 원인
· 폴더 하나만 훑음 → 어제 것은 6개 폴더에 흩어져 있었다
`allRunDirs()` · `pendingAll()` · `uploadAllPending()` 을 만들고, 화면은 진입할 때 전 폴더를
훑는다. 버튼도 **[미업로드 전부 올리기]**, 문구도 "미업로드 N건 (지난 측정 포함)".
매니페스트는 폴더마다 다르므로 `manifestParamsFor(csv)` 가 그 CSV 와 같은 폴더의 run json
에서 찾는다. 못 찾으면 null — CSV 헤더만으로도 페이로드는 만들어진다.
## ③ 망 오류 문구를 사람 말로
Java 예외를 그대로 흘리고 있었다(`Unable to resolve host ...`,
`CertPathValidatorException: Trust anchor ...`). 조작자가 할 일이 원인마다 다른데 알 수
없었다. **둘은 망을 바꾸면 되는 것이었다** — LTE 에서는 정상 접속된다.
TLS 차단 "보안 연결이 차단되었습니다 — 이 네트워크가 통신을 검사하고 있을 수
있습니다. 다른 망(LTE·테더링)으로 바꾼 뒤 [미업로드 전부 올리기] 를…
데이터는 폰에 그대로 있습니다."
DNS 실패 "서버 주소를 찾을 수 없습니다 — 인터넷이 끊겼거나 이 망이 외부 접속을…"
시간 초과 "서버 응답이 없습니다 — 잠시 뒤 다시…"
**인증서 검증을 느슨하게 하는 선택은 하지 않았다.** 의료기기에서 그건 보안 후퇴다. 대신
무엇을 해야 하는지 말해 준다 — "측정은 병원에서, 업로드는 망이 되는 곳에서"가 성립하려면
②가 있어야 하고, 이제 있다.
테스트 143개 통과.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -303,6 +303,19 @@ object HospitalRunStore {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 **모든** 환자·날짜 폴더. 없으면 빈 목록.
|
||||
*
|
||||
* 업로드 재시도가 이걸 쓴다. 종전에는 "이번 실행의 폴더" 하나만 훑어서, 앱을 다시
|
||||
* 켜면 지난 측정이 영원히 고립됐다 — 2026-09-10 병원에서 39건이 그렇게 남았다
|
||||
* (망이 막혀 실패 → 앱 재시작 → 올릴 길 없음 → PC 로 꺼내 올려야 했다).
|
||||
*/
|
||||
fun allRunDirs(): List<File> =
|
||||
File(downloads(), ROOT).listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.sortedBy { it.name }
|
||||
?: emptyList()
|
||||
|
||||
/** 읽기 전용 조회 — [runDir] 과 달리 폴더를 만들지 않는다. */
|
||||
private fun runDirIfExists(patient: String, day: Date): File? =
|
||||
File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory }
|
||||
|
||||
@@ -177,6 +177,100 @@ object HospitalLabdbUploader {
|
||||
* 구분할 수 없고, 뒤의 멀쩡한 것까지 막으면 손해가 크다. 실패한 것은 마커가 남아
|
||||
* 다음 호출에서 다시 시도된다.
|
||||
*/
|
||||
/**
|
||||
* 저장된 **전 폴더**의 미업로드 CSV 를 센다. 지난 측정·지난 실행까지 포함한다.
|
||||
*
|
||||
* 화면이 "미업로드 N건"을 띄우는 근거. 종전에는 이번 실행의 폴더만 봤다.
|
||||
*/
|
||||
fun pendingAll(): List<File> =
|
||||
com.medithings.vesiscan.services.HospitalRunStore.allRunDirs().flatMap { pending(it) }
|
||||
|
||||
/**
|
||||
* 저장된 **전 폴더**의 미업로드 CSV 를 전부 올린다.
|
||||
*
|
||||
* ## 왜 이 함수가 필요한가
|
||||
* 종전의 [uploadPending] 은 넘겨받은 폴더 하나만 훑었고, 화면은 그 폴더를 `remember`
|
||||
* 로 들고 있었다. 그래서 **앱을 다시 켜면 버튼 자체가 사라졌다** — 2026-09-10 병원에서
|
||||
* 망이 막혀 39건이 실패한 뒤, 앱을 재시작하자 올릴 길이 없어져 PC 로 꺼내 올렸다.
|
||||
*
|
||||
* 병원 망이 labdb 를 막는 경우(TLS 검사 프록시·DNS 차단)가 실재하므로 "측정은 병원에서,
|
||||
* 업로드는 나중에"가 정상 경로다. 그 경로가 성립하려면 **지난 것까지 올릴 수 있어야**
|
||||
* 한다.
|
||||
*/
|
||||
suspend fun uploadAllPending(): String {
|
||||
if (running) return "이미 업로드 중입니다."
|
||||
val dirs = com.medithings.vesiscan.services.HospitalRunStore.allRunDirs()
|
||||
if (dirs.isEmpty()) {
|
||||
lastMessage.value = "올릴 것이 없습니다."
|
||||
return lastMessage.value!!
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
val targets = dirs.flatMap { pending(it) }
|
||||
pendingCount.intValue = targets.size
|
||||
if (targets.isEmpty()) {
|
||||
lastMessage.value = "올릴 것이 없습니다."
|
||||
return lastMessage.value!!
|
||||
}
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
targets.forEachIndexed { i, csv ->
|
||||
uploadingName.value = "${csv.parentFile?.name}/${csv.name}"
|
||||
// 매니페스트는 그 CSV 가 속한 폴더에서 찾는다 — 폴더마다 다르다.
|
||||
val (good, _) = upload(csv, manifestParamsFor(csv))
|
||||
if (good) ok++ else fail++
|
||||
pendingCount.intValue = (targets.size - (ok + fail)).coerceAtLeast(0)
|
||||
if (i < targets.lastIndex) delay(GAP_MS)
|
||||
}
|
||||
uploadingName.value = null
|
||||
lastMessage.value = "전체 업로드 — 성공 $ok · 실패 $fail"
|
||||
return lastMessage.value!!
|
||||
} finally {
|
||||
running = false
|
||||
uploadingName.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 그 CSV 와 같은 폴더의 run 매니페스트에서 파라미터를 찾는다.
|
||||
*
|
||||
* [uploadPending] 은 호출부가 매니페스트를 넘겨줬지만, 전 폴더를 도는 [uploadAllPending]
|
||||
* 은 폴더마다 다르므로 스스로 찾아야 한다. 못 찾으면 null — CSV 헤더만으로도 페이로드는
|
||||
* 만들어진다(avg·delay 같은 매니페스트 전용 필드만 빠진다).
|
||||
*/
|
||||
private fun manifestParamsFor(csv: File): JSONObject? {
|
||||
val dir = csv.parentFile ?: return null
|
||||
val runs = dir.listFiles { f ->
|
||||
f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json")
|
||||
} ?: return null
|
||||
for (f in runs.sortedByDescending { it.name }) {
|
||||
val o = runCatching { JSONObject(f.readText(Charsets.UTF_8)) }.getOrNull() ?: continue
|
||||
val combos = o.optJSONArray("combinations") ?: continue
|
||||
for (i in 0 until combos.length()) {
|
||||
if (combos.optJSONObject(i)?.optString("file") == csv.name) return o
|
||||
}
|
||||
}
|
||||
// 파일명을 못 맞췄으면 가장 최근 매니페스트라도 쓴다 — 같은 폴더면 조건이 같다.
|
||||
return runs.maxByOrNull { it.name }
|
||||
?.let { runCatching { JSONObject(it.readText(Charsets.UTF_8)) }.getOrNull() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 CSV 를 **화면 수명과 무관하게** 올린다.
|
||||
*
|
||||
* 측정 루프의 `launch` 는 `LaunchedEffect` 스코프라, 조합이 끝나고 화면을 떠나면
|
||||
* 전송이 잘린다 — 2026-09-10 실측에서 `The coroutine scope left the composition` 으로
|
||||
* 6건이 그렇게 실패했다. 정렬(601)은 같은 이유로 이미 오브젝트 스코프로 옮겼다.
|
||||
*/
|
||||
fun uploadAsync(csv: File, manifestParams: JSONObject? = null, onDone: ((String) -> Unit)? = null) {
|
||||
if (!LabdbCredentials.isRegistered) return
|
||||
scope.launch {
|
||||
val (_, m) = upload(csv, manifestParams)
|
||||
lastMessage.value = "${csv.name} — $m"
|
||||
onDone?.invoke(m)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun uploadPending(runDir: File, manifestParams: JSONObject? = null): String {
|
||||
if (running) return "이미 업로드 중입니다."
|
||||
running = true
|
||||
|
||||
@@ -20,7 +20,41 @@ class PendingApprovalException : LabdbException("Device is awaiting administrato
|
||||
class ReregisterRequiredException(message: String) : LabdbException(message)
|
||||
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
|
||||
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
|
||||
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
|
||||
/**
|
||||
* 망 문제로 못 보냈다. **원인을 사람 말로 구분한다.**
|
||||
*
|
||||
* 종전에는 Java 예외 문구를 그대로 흘렸다(`Unable to resolve host ...`,
|
||||
* `CertPathValidatorException: Trust anchor ...`). 조작자가 할 일이 원인마다 다른데 그걸
|
||||
* 알 수 없었다 — 2026-09-10 병원에서 24건이 실패했을 때 사유가 세 가지였고, 그중 둘은
|
||||
* **망을 바꾸면 되는 것**이었다(LTE 에서는 정상 접속된다).
|
||||
*
|
||||
* 검증을 느슨하게 하는 선택은 하지 않는다 — 의료기기에서 인증서 검증을 푸는 것은 보안
|
||||
* 후퇴다. 대신 **무엇을 해야 하는지** 말해 준다.
|
||||
*/
|
||||
class LabdbNetworkError(cause: Throwable) : LabdbException(describe(cause)) {
|
||||
companion object {
|
||||
private fun describe(c: Throwable): String {
|
||||
val m = c.message ?: ""
|
||||
return when {
|
||||
// 중간 검사 프록시가 서버 인증서를 바꿔치기한 경우가 대표적이다.
|
||||
c is javax.net.ssl.SSLHandshakeException ||
|
||||
m.contains("Trust anchor", true) ||
|
||||
m.contains("CertPath", true) ->
|
||||
"보안 연결이 차단되었습니다 — 이 네트워크가 통신을 검사하고 있을 수 있습니다. " +
|
||||
"다른 망(LTE·테더링)으로 바꾼 뒤 [미업로드 전부 올리기] 를 눌러 주세요. " +
|
||||
"데이터는 폰에 그대로 있습니다."
|
||||
m.contains("Unable to resolve host", true) ||
|
||||
c is java.net.UnknownHostException ->
|
||||
"서버 주소를 찾을 수 없습니다 — 인터넷이 끊겼거나 이 망이 외부 접속을 " +
|
||||
"막고 있습니다. 다른 망으로 바꾼 뒤 다시 올려 주세요. 데이터는 폰에 " +
|
||||
"그대로 있습니다."
|
||||
c is java.net.SocketTimeoutException || m.contains("timeout", true) ->
|
||||
"서버 응답이 없습니다 — 잠시 뒤 다시 올려 주세요. 데이터는 폰에 그대로 있습니다."
|
||||
else -> "Network error: $m"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
|
||||
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
|
||||
|
||||
@@ -130,6 +130,14 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 방금 끝난 실행 폴더 — 업로드 재시도가 이 폴더를 다시 훑는다.
|
||||
var lastRunDir by remember { mutableStateOf<java.io.File?>(null) }
|
||||
var pendingUploads by remember { mutableIntStateOf(0) }
|
||||
// 화면에 들어올 때 **전 폴더**를 훑는다. 종전에는 이번 실행의 폴더만 봤고 그 값을
|
||||
// `remember` 로 들고 있어서, 앱을 다시 켜면 카드 자체가 사라졌다 — 2026-09-10 병원에서
|
||||
// 망이 막혀 39건이 실패한 뒤 올릴 길이 없어진 직접 원인이다.
|
||||
LaunchedEffect(Unit) {
|
||||
pendingUploads = withContext(Dispatchers.IO) {
|
||||
runCatching { HospitalLabdbUploader.pendingAll().size }.getOrDefault(0)
|
||||
}
|
||||
}
|
||||
var uploadBusy by remember { mutableStateOf(false) }
|
||||
var uploadMsg by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
@@ -299,12 +307,15 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 조합이 끝나는 즉시 올린다. 한 바퀴(6조합)를 다 돌 때까지 기다리면
|
||||
// 중간에 앱이 죽거나 자리를 옮겼을 때 그때까지 잰 것이 통째로 남는다.
|
||||
// 실패해도 루프를 세우지 않는다 — 마커가 남아 나중에 버튼으로 올린다.
|
||||
if (LabdbCredentials.isRegistered) {
|
||||
launch {
|
||||
val (_, m) = HospitalLabdbUploader.upload(file)
|
||||
// **화면 수명과 무관한 스코프에서 올린다.**
|
||||
//
|
||||
// 종전에는 `launch { ... }` — 이 LaunchedEffect 의 스코프였다. 조합이 끝나고
|
||||
// 화면을 떠나면 전송이 잘렸다: 2026-09-10 병원 실측에서
|
||||
// `The coroutine scope left the composition` 으로 6건이 그렇게 실패했다.
|
||||
// 정렬(601)은 같은 이유로 이미 오브젝트 스코프로 옮겼는데 이쪽만 남아 있었다.
|
||||
HospitalLabdbUploader.uploadAsync(file) { m ->
|
||||
uploadMsg = "${file.name} — $m"
|
||||
lastRunDir?.let { d -> pendingUploads = HospitalLabdbUploader.pending(d).size }
|
||||
}
|
||||
pendingUploads = HospitalLabdbUploader.pendingAll().size
|
||||
}
|
||||
results.put(JSONObject().apply {
|
||||
put("freq_mhz", freq.label)
|
||||
@@ -353,7 +364,8 @@ fun HospitalModeView(appState: AppState) {
|
||||
put("anchor_basis", appState.anchorBasis?.wire ?: JSONObject.NULL)
|
||||
put("combinations", results)
|
||||
}, startedAt)
|
||||
pendingUploads = HospitalLabdbUploader.pending(dir).size
|
||||
// 전 폴더 기준으로 센다 — 지난 측정이 남아 있으면 그것도 보여야 한다.
|
||||
pendingUploads = HospitalLabdbUploader.pendingAll().size
|
||||
running = false
|
||||
}
|
||||
}
|
||||
@@ -597,10 +609,13 @@ fun HospitalModeView(appState: AppState) {
|
||||
// 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기
|
||||
// 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한
|
||||
// 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다.
|
||||
// isRegistered 로 감싸지 않는다. 미등록일 때도 **몇 건이 못 올라갔는지**는
|
||||
// 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로, 그 수를
|
||||
// 모르면 무엇을 기다리는지 알 수 없다.
|
||||
if (lastRunDir != null) {
|
||||
// **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember`
|
||||
// 라서 앱을 다시 켜면 null 이 된다 — 그러면 카드가 사라져 지난 측정을 올릴
|
||||
// 길이 없어진다(2026-09-10 병원 39건).
|
||||
//
|
||||
// isRegistered 로도 감싸지 않는다. 미등록일 때도 몇 건이 못 올라갔는지는
|
||||
// 보여야 한다 — 승인 뒤 한꺼번에 보내야 하므로.
|
||||
if (pendingUploads > 0 || lastRunDir != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Box(
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -612,7 +627,8 @@ fun HospitalModeView(appState: AppState) {
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
if (pendingUploads > 0) "labdb 미업로드 ${pendingUploads}건"
|
||||
if (pendingUploads > 0)
|
||||
"labdb 미업로드 ${pendingUploads}건 (지난 측정 포함)"
|
||||
else "labdb 업로드 완료",
|
||||
fontSize = 14.sp, fontWeight = FontWeight.Bold,
|
||||
color = if (pendingUploads > 0) Color(0xFFE65100) else MlSuccess,
|
||||
@@ -625,11 +641,12 @@ fun HospitalModeView(appState: AppState) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val d = lastRunDir ?: return@Button
|
||||
uploadBusy = true
|
||||
uploadScope.launch {
|
||||
uploadMsg = HospitalLabdbUploader.uploadPending(d)
|
||||
pendingUploads = HospitalLabdbUploader.pending(d).size
|
||||
// 전 폴더를 올린다 — 지난 측정까지.
|
||||
uploadMsg = HospitalLabdbUploader.uploadAllPending()
|
||||
pendingUploads =
|
||||
HospitalLabdbUploader.pendingAll().size
|
||||
uploadBusy = false
|
||||
}
|
||||
},
|
||||
@@ -640,7 +657,7 @@ fun HospitalModeView(appState: AppState) {
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(
|
||||
if (uploadBusy) "업로드 중…" else "지금 업로드",
|
||||
if (uploadBusy) "업로드 중…" else "미업로드 전부 올리기",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user