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:
2026-09-11 10:41:00 +09:00
parent 14e4b33072
commit a7d7cf469f
4 changed files with 175 additions and 17 deletions
@@ -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?)