From b3d9c16780fdf225f7cde5750c12ca5325f8f023 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Mon, 1 Jun 2026 12:31:56 +0900 Subject: [PATCH] =?UTF-8?q?Clinical:=20labdb.medithings.net=20REST=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=86=B5=ED=95=A9=20(Phase=20A+B?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End Session 시 measurement.json을 https://labdb.medithings.net/api/v1/upload/json 에 자동 업로드. dataType="000" (VesiScan 6채널). cycle 1개 = record 1개. testId는 폴더명 그대로 사용 (40자 초과 시 prefix+hash로 축약). Files: - LabdbCredentials: apiKey/deviceId를 EncryptedSharedPreferences에 안전 저장 - LabdbClient: register/checkStatus/uploadJson + 5가지 에러 분기 (PendingApproval / Reregister / RateLimit / ApiError / Network) - LabdbUploader: measurement.json → payload 변환 + fire-and-forget 업로드 성공 시 labdb_upload.json, 실패 시 labdb_upload_error.json 마커 작성 ClinicalSessionStore.endMeasurement: cycle>0 + active 상태일 때만 자동 트리거. ClinicalHomeView: labdb 상태 카드(Register/Refresh) + 직전 측정 업로드 상태 표시 + 수동 Upload 재시도 버튼. 오프라인 큐는 아직 없음 (Phase C는 후속). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/ClinicalSessionStore.kt | 11 +- .../services/labdb/LabdbClient.kt | 167 ++ .../services/labdb/LabdbCredentials.kt | 46 + .../services/labdb/LabdbUploader.kt | 202 +++ .../ui/views/clinical/ClinicalHomeView.kt | 198 ++- labdb.md | 1536 +++++++++++++++++ 6 files changed, 2147 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/services/labdb/LabdbClient.kt create mode 100644 app/src/main/java/com/example/medilightv2android/services/labdb/LabdbCredentials.kt create mode 100644 app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt create mode 100644 labdb.md diff --git a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt index e182677..20cd4d3 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -139,16 +139,25 @@ object ClinicalSessionStore { File(dir, "meta.json").writeText(meta.toString(2)) } - /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null */ + /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → + * labdb 자동 업로드 (apiKey 있고 active 상태일 때만) → currentSession=null */ fun endMeasurement(context: Context): File? { val s = currentSession ?: return null s.endedAt = System.currentTimeMillis() writeMeta(context) writeMeasurementJson(context) + val capturedCount = cyclesBuffer.size val dir = currentLogDir() lastFinalizedFolder = dir currentSession = null cyclesBuffer.clear() + // 자동 업로드 — fire-and-forget. 실패해도 폴더는 그대로, 수동 재시도 가능. + if (dir != null && capturedCount > 0) { + val creds = com.example.medilightv2android.services.labdb.LabdbCredentials + if (creds.isRegistered && creds.lastStatus == "active") { + com.example.medilightv2android.services.labdb.LabdbUploader.uploadAsync(context, dir) + } + } return dir } diff --git a/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbClient.kt b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbClient.kt new file mode 100644 index 0000000..7a48b70 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbClient.kt @@ -0,0 +1,167 @@ +package com.example.medilightv2android.services.labdb + +import android.content.Context +import android.os.Build +import com.example.medilightv2android.ble.BleManager +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +sealed class LabdbException(message: String) : IOException(message) +class PendingApprovalException : LabdbException("Device is awaiting administrator approval") +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}") + +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?) +data class UploadResult( + val ok: Boolean, + val sessionId: String, + val created: Boolean, + val totalRecords: Int, + val inserted: Int, + val duplicates: Int, + val errors: Int, +) + +/** + * https://labdb.medithings.net REST 클라이언트. + * + * 사용 순서: + * 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회 + * 2) checkStatus() — 임상 화면 진입 시 매번 + * 3) uploadJson(payload) — End Session 후 measurement.json 업로드 + * + * 모든 메서드는 suspend, IO 디스패처에서 실행. + */ +object LabdbClient { + private const val BASE = "https://labdb.medithings.net/api/v1" + private const val APP_NAME = "VesiScan" + private val JSON = "application/json; charset=utf-8".toMediaType() + + private val client: OkHttpClient by lazy { + OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .build() + } + + /** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */ + suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) { + val ble = BleManager.getInstance(context) + val body = JSONObject().apply { + put("appName", APP_NAME) + put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}") + ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) } + ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) } + ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) } + put("appVersion", appVersion) + } + val req = Request.Builder() + .url("$BASE/devices/register") + .post(body.toString().toRequestBody(JSON)) + .build() + val (code, json) = exec(req) + when (code) { + 200, 201 -> { + val deviceId = json.optString("deviceId") + val apiKey = json.optString("apiKey") + val status = json.optString("status", "pending") + val message = json.optString("message", "") + if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey") + LabdbCredentials.apiKey = apiKey + LabdbCredentials.deviceId = deviceId + LabdbCredentials.lastStatus = status + RegisterResult(deviceId, apiKey, status, message) + } + else -> throw apiError(code, json) + } + } + + /** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */ + suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) { + val key = LabdbCredentials.apiKey + ?: return@withContext StatusResult(0, "not_registered", null, null) + val req = Request.Builder() + .url("$BASE/devices/status") + .header("X-API-Key", key) + .get() + .build() + val (code, json) = exec(req) + when (code) { + 200 -> { + val status = json.optString("status", "unknown") + LabdbCredentials.lastStatus = status + StatusResult( + httpStatus = 200, + status = status, + deviceName = json.optString("deviceName").ifBlank { null }, + sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null, + ) + } + 401, 404 -> { + LabdbCredentials.clear() + val err = json.optJSONObject("error") + val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED" + throw ReregisterRequiredException("$errCode: server requires re-registration") + } + else -> throw apiError(code, json) + } + } + + /** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */ + suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) { + val key = LabdbCredentials.apiKey + ?: throw ReregisterRequiredException("No apiKey — register first") + val req = Request.Builder() + .url("$BASE/upload/json") + .header("X-API-Key", key) + .post(payload.toString().toRequestBody(JSON)) + .build() + val (code, json) = exec(req) + when (code) { + 200, 201 -> UploadResult( + ok = json.optBoolean("ok", true), + sessionId = json.optString("sessionId"), + created = json.optBoolean("created", code == 201), + totalRecords = json.optInt("totalRecords"), + inserted = json.optInt("inserted"), + duplicates = json.optInt("duplicates"), + errors = json.optInt("errors"), + ) + 401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") } + 403 -> throw PendingApprovalException() + 404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") } + 429 -> throw RateLimitedException() + else -> throw apiError(code, json) + } + } + + private fun apiError(code: Int, json: JSONObject): LabdbApiError { + val err = json.optJSONObject("error") + val errCode = err?.optString("code") ?: "HTTP_$code" + val msg = err?.optString("message") ?: json.toString().take(200) + return LabdbApiError(code, errCode, msg) + } + + private fun exec(req: Request): Pair { + return try { + client.newCall(req).execute().use { res -> + val raw = res.body?.string().orEmpty() + val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() } + res.code to obj + } + } catch (t: IOException) { + throw LabdbNetworkError(t) + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbCredentials.kt b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbCredentials.kt new file mode 100644 index 0000000..d7bb686 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbCredentials.kt @@ -0,0 +1,46 @@ +package com.example.medilightv2android.services.labdb + +import com.example.medilightv2android.services.KeychainService + +/** + * labdb (https://labdb.medithings.net) 자격 증명 저장소. + * + * apiKey/deviceId는 KeychainService(EncryptedSharedPreferences)에 안전 저장. + * 등록은 앱 첫 임상 모드 진입 시 1회만 — 재등록 시 pending device 누적되므로 + * 401 INVALID_API_KEY / 404 DEVICE_DELETED 응답을 받았을 때만 clear() 후 재등록. + */ +object LabdbCredentials { + private const val KEY_API_KEY = "labdb.apiKey" + private const val KEY_DEVICE_ID = "labdb.deviceId" + private const val KEY_LAST_STATUS = "labdb.lastStatus" + + var apiKey: String? + get() = KeychainService.load(KEY_API_KEY) + set(value) { + if (value == null) KeychainService.delete(KEY_API_KEY) + else KeychainService.save(KEY_API_KEY, value) + } + + var deviceId: String? + get() = KeychainService.load(KEY_DEVICE_ID) + set(value) { + if (value == null) KeychainService.delete(KEY_DEVICE_ID) + else KeychainService.save(KEY_DEVICE_ID, value) + } + + /** 마지막으로 GET /devices/status 응답에서 받은 상태 ("active"/"pending"/"revoked") */ + var lastStatus: String? + get() = KeychainService.load(KEY_LAST_STATUS) + set(value) { + if (value == null) KeychainService.delete(KEY_LAST_STATUS) + else KeychainService.save(KEY_LAST_STATUS, value) + } + + val isRegistered: Boolean get() = !apiKey.isNullOrBlank() + + fun clear() { + apiKey = null + deviceId = null + lastStatus = null + } +} diff --git a/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt new file mode 100644 index 0000000..8964a98 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt @@ -0,0 +1,202 @@ +package com.example.medilightv2android.services.labdb + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.security.MessageDigest +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 측정 폴더(measurement.json 포함) → labdb /upload/json 자동 업로드. + * + * 정책: + * - End Session 시 ClinicalSessionStore가 uploadAsync(folder)를 호출 + * - 성공: folder/labdb_upload.json (응답 본문) + * - 실패: folder/labdb_upload_error.json (에러 본문) — 폴더는 그대로 유지되어 수동 재시도 가능 + * + * dataType = "000" (VesiScan 6채널). vesiscan_test 프로토콜과 동일. + * + * testId: + * - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에 + * 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지). + */ +object LabdbUploader { + private const val TAG = "LabdbUploader" + private const val DATA_TYPE = "000" + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val isoFmt: SimpleDateFormat + get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US) + + /** 비동기 업로드 — fire-and-forget. 결과는 폴더 내 마커 파일로 확인. */ + fun uploadAsync(context: Context, folder: File, onResult: ((Boolean, String) -> Unit)? = null) { + scope.launch { + val ok = runCatching { uploadBlocking(folder) } + .onFailure { Log.w(TAG, "upload failed: ${folder.name}", it) } + .getOrElse { e -> + writeError(folder, e) + onResult?.invoke(false, e.message ?: e.javaClass.simpleName) + return@launch + } + onResult?.invoke(true, "uploaded: ${ok.inserted}/${ok.totalRecords} (dup=${ok.duplicates})") + } + } + + /** 동기 업로드 — 재시도 버튼/자동 호출 양쪽 사용. */ + suspend fun uploadBlocking(folder: File): UploadResult { + val measurementFile = File(folder, "measurement.json") + if (!measurementFile.exists()) { + throw IllegalStateException("measurement.json not found in ${folder.name}") + } + val measurement = JSONObject(measurementFile.readText()) + val payload = buildPayload(folder.name, measurement) + val result = LabdbClient.uploadJson(payload) + writeSuccess(folder, result) + return result + } + + fun isUploaded(folder: File): Boolean = File(folder, "labdb_upload.json").exists() + fun lastError(folder: File): String? { + val f = File(folder, "labdb_upload_error.json") + return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null + } + + /** measurement.json → labdb payload. 각 cycle = 1 record. */ + private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject { + val meta = measurement.optJSONObject("meta") ?: JSONObject() + val cycles = measurement.optJSONArray("cycles") ?: JSONArray() + val testId = sanitizeTestId(folderName) + + val records = JSONArray() + for (i in 0 until cycles.length()) { + val cycle = cycles.optJSONObject(i) ?: continue + records.put(cycleToRecord(cycle, meta)) + } + + val params = JSONObject().apply { + // 측정 컨텍스트 — admin 콘솔에서 한눈에 보이도록 + put("posture", meta.optString("posture")) + put("step", meta.optString("step")) + put("is_alignment", meta.optBoolean("is_alignment")) + put("examiner", meta.optString("examiner")) + meta.optString("subject_id").takeIf { it.isNotBlank() }?.let { put("subject_id", it) } + if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml")) + if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm")) + meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) } + meta.optString("hardware_version").takeIf { it.isNotBlank() }?.let { put("hardware_version", it) } + meta.optString("serial_number").takeIf { it.isNotBlank() }?.let { put("serial_number", it) } + put("app_version", meta.optString("app_version_name")) + put("captured_cycles", cycles.length()) + } + + return JSONObject().apply { + put("testId", testId) + put("dataType", DATA_TYPE) + put("sessionName", meta.optString("label").ifBlank { folderName }) + put("memo", meta.optString("notes")) + meta.optLong("ended_at", 0L).takeIf { it > 0 }?.let { + put("savedAt", isoFmt.format(Date(it))) + } + put("params", params) + put("recordCount", records.length()) + put("records", records) + } + } + + private fun cycleToRecord(cycle: JSONObject, meta: JSONObject): JSONObject { + val rowIndex = cycle.optInt("cycle") + val tsMs = cycle.optLong("timestamp_ms") + val piezo = cycle.optJSONObject("piezo") ?: JSONObject() + val imuArr = cycle.optJSONArray("imu") ?: JSONArray() + + // labdb 표준 sensor.imu는 단일 샘플. 우리는 cycle당 15 샘플이라 + // 첫 샘플을 sensor.imu에 넣고 전체를 sensor.imu_samples에 free field로 보존. + val firstImu = if (imuArr.length() > 0) imuArr.optJSONObject(0) else null + val sensor = JSONObject().apply { + firstImu?.let { + put("imu", JSONObject().apply { + put("ax", it.optDouble("ax")) + put("ay", it.optDouble("ay")) + put("az", it.optDouble("az")) + put("gx", it.optDouble("gx")) + put("gy", it.optDouble("gy")) + put("gz", it.optDouble("gz")) + }) + } + put("imu_samples", imuArr) + put("imu_sample_count", imuArr.length()) + } + + val channelsArr = JSONArray() + for (ch in 0..5) { + val data = piezo.optJSONArray("CH$ch") ?: continue + var peak = 0 + var peakIdx = 0 + for (i in 0 until data.length()) { + val v = data.optInt(i) + if (v > peak) { peak = v; peakIdx = i } + } + channelsArr.put(JSONObject().apply { + put("ch", ch) + put("peak", peak) + put("peakIdx", peakIdx) + put("data", data) + }) + } + + return JSONObject().apply { + put("rowIndex", rowIndex) + put("datetime", isoFmt.format(Date(tsMs))) + put("commandType", "MTB") + put("sensor", sensor) + put("channels", channelsArr) + } + } + + /** ^[A-Za-z0-9_-]{1,40}$ 강제. 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix. */ + private fun sanitizeTestId(raw: String): String { + // - 와 _ 이외 특수문자 제거 + val cleaned = raw.replace(Regex("[^A-Za-z0-9_-]"), "_") + if (cleaned.length <= 40) return cleaned + val hash = MessageDigest.getInstance("SHA-1").digest(cleaned.toByteArray()) + .joinToString("") { "%02x".format(it) }.take(8) + return cleaned.take(31) + "_" + hash + } + + private fun writeSuccess(folder: File, r: UploadResult) { + val body = JSONObject().apply { + put("ok", r.ok) + put("sessionId", r.sessionId) + put("created", r.created) + put("totalRecords", r.totalRecords) + put("inserted", r.inserted) + put("duplicates", r.duplicates) + put("errors", r.errors) + put("uploadedAt", isoFmt.format(Date())) + } + File(folder, "labdb_upload.json").writeText(body.toString(2)) + // 이전 실패 마커가 있었다면 정리 + File(folder, "labdb_upload_error.json").delete() + } + + private fun writeError(folder: File, t: Throwable) { + val body = JSONObject().apply { + put("error", t.javaClass.simpleName) + put("message", t.message ?: "") + if (t is LabdbApiError) { + put("httpStatus", t.httpStatus) + put("code", t.code) + } + put("failedAt", isoFmt.format(Date())) + } + File(folder, "labdb_upload_error.json").writeText(body.toString(2)) + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt index e60ebd8..7dbf865 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -26,7 +26,11 @@ import com.example.medilightv2android.AppState import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.models.* import com.example.medilightv2android.services.ClinicalSessionStore +import com.example.medilightv2android.services.labdb.LabdbClient +import com.example.medilightv2android.services.labdb.LabdbCredentials +import com.example.medilightv2android.services.labdb.LabdbUploader import com.example.medilightv2android.ui.theme.* +import kotlinx.coroutines.launch /** * Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델. @@ -65,6 +69,38 @@ fun ClinicalHomeView(appState: AppState) { var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) } val lastFolder = ClinicalSessionStore.lastFinalizedFolder + val scope = rememberCoroutineScope() + + // labdb 상태 — 화면 진입 시 + 사용자 액션 후 refresh + var labdbApiKey by remember { mutableStateOf(LabdbCredentials.apiKey) } + var labdbStatus by remember { mutableStateOf(LabdbCredentials.lastStatus ?: "unknown") } + var labdbBusy by remember { mutableStateOf(false) } + var labdbMessage by remember { mutableStateOf(null) } + // 마지막 측정 업로드 상태 — Last saved 카드용 + var lastUploaded by remember(lastFolder) { + mutableStateOf(lastFolder?.let { LabdbUploader.isUploaded(it) } ?: false) + } + var lastUploadError by remember(lastFolder) { + mutableStateOf(lastFolder?.let { LabdbUploader.lastError(it) }) + } + var uploadBusy by remember { mutableStateOf(false) } + + // 진입 시 status 자동 확인 (apiKey 있을 때만) + LaunchedEffect(Unit) { + if (LabdbCredentials.isRegistered) { + labdbBusy = true + runCatching { LabdbClient.checkStatus() } + .onSuccess { + labdbStatus = it.status + labdbApiKey = LabdbCredentials.apiKey + } + .onFailure { + labdbMessage = it.message + labdbApiKey = LabdbCredentials.apiKey + } + labdbBusy = false + } + } Column( modifier = Modifier @@ -126,23 +162,161 @@ fun ClinicalHomeView(appState: AppState) { } } - // 직전 측정 결과 표시 + // labdb 서버 상태/등록 카드 + Spacer(modifier = Modifier.height(12.dp)) + val labdbBg = when { + !LabdbCredentials.isRegistered -> Color(0xFF9E9E9E).copy(alpha = 0.15f) + labdbStatus == "active" -> Color(0xFF2196F3).copy(alpha = 0.12f) + labdbStatus == "pending" -> Color(0xFFFF9800).copy(alpha = 0.15f) + else -> Color(0xFFE53935).copy(alpha = 0.12f) + } + Card(shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = labdbBg)) { + Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text("labdb.medithings.net", fontSize = 11.sp, color = MlSecondaryText) + Text( + when { + !LabdbCredentials.isRegistered -> "Not registered" + labdbBusy -> "Checking…" + else -> "Status: $labdbStatus" + }, + fontSize = 14.sp, fontWeight = FontWeight.Bold + ) + LabdbCredentials.deviceId?.let { + Text("deviceId: $it", fontSize = 10.sp, color = MlSecondaryText) + } + } + if (!LabdbCredentials.isRegistered) { + Button( + onClick = { + if (labdbBusy) return@Button + labdbBusy = true + labdbMessage = null + scope.launch { + runCatching { + LabdbClient.register( + context, + appVersion = try { + context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName ?: "unknown" + } catch (_: Exception) { "unknown" } + ) + }.onSuccess { + labdbApiKey = it.apiKey + labdbStatus = it.status + labdbMessage = it.message + Toast.makeText(context, + "Registered. Awaiting approval.", + Toast.LENGTH_LONG).show() + }.onFailure { + labdbMessage = it.message + } + labdbBusy = false + } + }, + enabled = !labdbBusy, + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) + ) { Text("Register", color = Color.White, fontSize = 12.sp) } + } else { + OutlinedButton( + onClick = { + if (labdbBusy) return@OutlinedButton + labdbBusy = true + labdbMessage = null + scope.launch { + runCatching { LabdbClient.checkStatus() } + .onSuccess { + labdbStatus = it.status + labdbApiKey = LabdbCredentials.apiKey + } + .onFailure { + labdbMessage = it.message + labdbApiKey = LabdbCredentials.apiKey + } + labdbBusy = false + } + }, + enabled = !labdbBusy + ) { Text("Refresh", fontSize = 12.sp) } + } + } + labdbMessage?.let { + Spacer(modifier = Modifier.height(4.dp)) + Text(it, fontSize = 11.sp, color = Color(0xFFE53935)) + } + if (LabdbCredentials.isRegistered && labdbStatus == "pending") { + Spacer(modifier = Modifier.height(4.dp)) + Text("admin@medithings.net 승인 후 자동 업로드됩니다.", + fontSize = 11.sp, color = MlSecondaryText) + } + } + } + + // 직전 측정 결과 + 업로드 상태/재시도 if (lastFolder != null) { Spacer(modifier = Modifier.height(12.dp)) + val savedBg = when { + lastUploaded -> Color(0xFF4CAF50).copy(alpha = 0.10f) + lastUploadError != null -> Color(0xFFE53935).copy(alpha = 0.10f) + else -> Color(0xFF4CAF50).copy(alpha = 0.06f) + } Card( shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.08f)) + colors = CardDefaults.cardColors(containerColor = savedBg) ) { - Row( - modifier = Modifier.padding(12.dp).fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.CheckCircle, null, - tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text("Last saved", fontSize = 11.sp, color = MlSecondaryText) - Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.CheckCircle, null, + tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("Last saved", fontSize = 11.sp, color = MlSecondaryText) + Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text( + when { + lastUploaded -> "✓ labdb 업로드 완료" + lastUploadError != null -> "✗ 업로드 실패: $lastUploadError" + uploadBusy -> "업로드 중…" + else -> "로컬 저장만 (서버 미업로드)" + }, + fontSize = 11.sp, + color = when { + lastUploaded -> Color(0xFF4CAF50) + lastUploadError != null -> Color(0xFFE53935) + else -> MlSecondaryText + } + ) + } + if (!lastUploaded && LabdbCredentials.isRegistered) { + Button( + onClick = { + if (uploadBusy) return@Button + uploadBusy = true + scope.launch { + runCatching { LabdbUploader.uploadBlocking(lastFolder) } + .onSuccess { + lastUploaded = true + lastUploadError = null + Toast.makeText(context, + "Uploaded: ${it.inserted}/${it.totalRecords}", + Toast.LENGTH_SHORT).show() + } + .onFailure { + lastUploadError = it.message ?: it.javaClass.simpleName + // status도 갱신 (401/403/404 케이스 반영) + labdbApiKey = LabdbCredentials.apiKey + labdbStatus = LabdbCredentials.lastStatus ?: "unknown" + } + uploadBusy = false + } + }, + enabled = !uploadBusy, + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) + ) { Text("Upload", color = Color.White, fontSize = 12.sp) } + } } } } diff --git a/labdb.md b/labdb.md new file mode 100644 index 0000000..41fcccd --- /dev/null +++ b/labdb.md @@ -0,0 +1,1536 @@ +\# labdb REST API Guide + + + +\*\*Server\*\*: `https://labdb.medithings.net` + +\*\*Base URL\*\*: `https://labdb.medithings.net/api/v1` + +\*\*Auth\*\*: `X-API-Key: {your\_api\_key}` (HTTP header) + +\*\*Content-Type\*\*: `application/json` (UTF-8) + +\*\*Version\*\*: 1.1 (2026-06-01) + + + +\--- + + + +\## 0. Quick Start (3 steps) + + + +``` + +1\) POST /devices/register → apiKey 발급 (1회) + +2\) Admin이 콘솔에서 Approve → status: active + +3\) POST /upload/json (dataType 포함) → 데이터 전송 + +``` + + + +가장 간단한 통합 방법은 \*\*`POST /upload/json` 한 번\*\*으로 전체 세션 + 모든 records를 보내는 것입니다 (Section 3 참조). + + + +\### 자료 구분 (필수 개념) + + + +모든 세션은 \*\*`dataType` 코드\*\*로 구분됩니다 (생략 시 `"000"`): + + + +| 코드 | 의미 | + +|------|------| + +| `"000"` | VesiScan 초음파 6채널 (기본) | + +| `"100"\~"899"` | 관리자 배정 (EMG/PPG/IMU 등) | + +| `"900"\~"999"` | 사용자 정의 자유 형식 | + + + +같은 디바이스가 여러 dataType의 세션을 무제한 보낼 수 있습니다. 자세한 내용은 \*\*Section 2-3\*\* 참조. + + + +\--- + + + +\## 1. 인증 (X-API-Key) + + + +모든 `/api/v1/\*` 요청에 `X-API-Key` 헤더 필요 (단, `/devices/register`만 예외). + + + +```http + +X-API-Key: xbk\_live\_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +``` + + + +\### 1-1. 디바이스 등록 (앱 첫 실행 1회) + + + +```http + +POST /api/v1/devices/register + +Content-Type: application/json + +``` + + + +```json + +{ + + "appName": "VesiScan", ← 필수: 앱 식별자 + + "deviceName": "Galaxy Tab S9", ← 필수: 표시명 + + "deviceAddress": "AA:BB:CC:DD:EE:FF", ← BLE MAC (선택) + + "hwNumber": "VB2024001", ← 선택 + + "serialNumber": "MT20260601", ← 선택 + + "fwVersion": "VBTFW0103", ← 선택 + + "appVersion": "1.0.0" ← 선택 + +} + +``` + + + +\*\*Response 201:\*\* + +```json + +{ + + "deviceId": "dev\_abc123", + + "apiKey": "xbk\_live\_...", ← 안전한 저장소(Keystore/Keychain)에 저장 + + "status": "pending", ← 관리자 승인 대기 + + "message": "Device registered. Awaiting administrator approval." + +} + +``` + + + +⚠️ \*\*재등록 금지\*\*: 매 실행마다 호출하면 pending device가 누적됩니다. 첫 실행에만 호출하고 `apiKey`를 저장하세요. + + + +\### 1-2. 디바이스 상태 확인 (앱 시작 시 매번 권장) + + + +```http + +GET /api/v1/devices/status + +X-API-Key: {apiKey} + +``` + + + +\*\*5가지 응답:\*\* + + + +| HTTP | status / code | 의미 | 권장 동작 | + +|:----:|---------------|------|----------| + +| 200 | `active` | 정상 사용 가능 | 데이터 업로드 가능 | + +| 200 | `pending` | 승인 대기 | 사용자에게 안내 메시지 | + +| 200 | `revoked` | 비활성화됨 | 관리자 문의 안내 | + +| 404 | `DEVICE\_DELETED` | 서버에서 삭제됨 | 로컬 키 삭제 후 재등록 | + +| 401 | `INVALID\_API\_KEY` | 키 형식 잘못됨 | 로컬 키 삭제 후 재등록 | + + + +\*\*200 응답 예 (active):\*\* + +```json + +{ + + "deviceId": "dev\_abc123", + + "status": "active", + + "deviceName": "Galaxy Tab S9", + + "appName": "VesiScan", + + "registeredAt": "2026-06-01T08:00:00Z", + + "approvedAt": "2026-06-01T09:15:00Z", + + "revokedAt": "", + + "lastSeenAt": "2026-06-01T10:30:00Z", + + "sessionCount": 12 + +} + +``` + + + +\--- + + + +\## 2. 데이터 구조 (핵심 개념) + + + +\### 2-1. 3단계 계층 + + + +``` + +Device (1) ── 여러 Session 보유 + + └─ Session (1) ── 여러 Record 보유 + + └─ Record (1) ── 6 Channel 측정값 + +``` + + + +\### 2-2. ID 규칙 + + + +| 종류 | 형식 | 발급 | + +|------|------|------| + +| `deviceId` | `dev\_{16hex}` | 서버 자동 | + +| `apiKey` | `xbk\_live\_{48hex}` | 서버 자동 | + +| `sessionId` (=`testId`) | `^\[A-Za-z0-9\_-]{1,40}$` | \*\*클라이언트 권장\*\* (idempotent) | + +| `rowIndex` | `0..N` 정수 | 클라이언트 | + + + +\*\*권장 sessionId\*\*: `LAB\_yyyymmdd\_hhmmss\[\_suffix]` (예: `LAB\_20260601\_103000`) + + + +\### 2-3. `dataType` — 자료 구분 (★ 핵심 개념) + + + +서로 다른 종류의 데이터(초음파 / EMG / PPG / IMU 등)를 \*\*하나의 서버에 보내되 명확히 구분\*\*하기 위해 모든 세션은 반드시 `dataType` 코드를 가집니다. + + + +\#### 왜 필요한가 + + + +\- \*\*같은 testId 공간을 공유\*\*: 여러 장치/앱이 다른 종류의 데이터를 동일 서버에 업로드 + +\- \*\*시각화 분기\*\*: 관리자 콘솔이 dataType별로 다른 차트/알고리즘 자동 적용 (예: `000`은 Wall Detection, `100`은 EMG 파형) + +\- \*\*분석 분리\*\*: dataType별 통계, 필터, 내보내기 + +\- \*\*레코드 구조 자유\*\*: 각 dataType마다 `records\[]` 내부 스키마가 완전히 달라도 됨 + + + +\#### dataType 레지스트리 + + + +| dataType | 의미 | 일반 records 스키마 | 분석/시각화 | + +|----------|------|---------------------|------------| + +| `"000"` (기본) | \*\*VesiScan 초음파 6채널\*\* | `{rowIndex, datetime, commandType, sensor:{batteryMv,tempC100,imu:{}}, channels:\[6×{ch,peak,peakIdx,data:\[\~100]}]}` | Wall Detection (Raw → Energy → Geometry) | + +| `"100"` | (예약 — EMG 등) | `{rowIndex, datetime, channels:\[N×{ch, data:\[sampleRate×duration]}]}` | 시계열 그래프 | + +| `"200"` | (예약 — PPG 등) | 자유 | 자유 | + +| `"900\~999"` | \*\*사용자 정의\*\* | 자유 | 기본 표시 (raw JSON) | + + + +> \*\*신규 dataType 발급\*\*: `admin@medithings.net`에 다음 정보 전달 → 등록 + +> - 코드 (`100`\~`899`) — 충돌 방지 위해 관리자가 배정 + +> - 데이터 의미 (장비/측정 종류) + +> - records\[] 스키마 (JSON Schema 권장) + +> - 시각화 요구사항 (있으면) + + + +\#### 명시 방법 + + + +업로드 시 `dataType` 필드를 포함: + +```json + +{ + + "testId": "LAB\_20260601\_103000", + + "dataType": "000", ← 이 필드로 자료 구분 + + "records": \[ ... ] + +} + +``` + + + +\- \*\*생략 시\*\* → `"000"` 자동 적용 (기존 호환) + +\- \*\*잘못 보낸 경우\*\* → admin 콘솔에서 수정 가능 (또는 같은 testId로 재업로드 시 dataType 갱신) + +\- \*\*형식 제한\*\* — 1\~10자 (`VARCHAR(10)`), 권장 패턴 `^\\d{3}$` + + + +\#### 같은 디바이스가 여러 유형을 보낼 때 + + + +같은 `apiKey`로 다른 dataType의 세션을 무제한 생성 가능: + +``` + +POST /upload/json { testId:"LAB\_20260601\_103000", dataType:"000", ... } ← 초음파 + +POST /upload/json { testId:"EMG\_20260601\_104500", dataType:"100", ... } ← EMG + +POST /upload/json { testId:"IMU\_20260601\_104530", dataType:"300", ... } ← IMU + +``` + + + +세션ID 접두어는 자유 — `dataType`이 진짜 구분자입니다. + + + +\#### 조회 시 dataType별 필터 + + + +```http + +GET /api/v1/sessions?dataType=100 # X-API-Key (앱) + +GET /api/admin/sessions?dataType=100 # 세션 인증 (관리자) + +``` + + + +응답의 모든 세션 객체는 `dataType` 필드를 포함하므로 클라이언트도 분기 처리 가능: + +```javascript + +sessions.forEach(s => { + + if (s.dataType === "000") renderUltrasound(s); + + else if (s.dataType === "100") renderEmg(s); + + else renderRaw(s); + +}); + +``` + + + +\#### 유형별 페이로드 예시 + + + +\*\*`dataType: "000"` — VesiScan 초음파 (6채널)\*\* + +```json + +{ + + "testId": "LAB\_20260601\_103000", "dataType": "000", + + "records": \[{ + + "rowIndex": 0, "datetime": "...", "commandType": "MBB", + + "sensor": { "batteryMv": 3920, "tempC100": 2530, "imu": {"ax":12,"ay":-3,"az":98,"gx":0,"gy":1,"gz":2} }, + + "channels": \[ + + { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ..., 100개] }, + + { "ch": 1, ... }, ..., { "ch": 5, ... } + + ] + + }] + +} + +``` + + + +\*\*`dataType: "100"` — EMG 단일 채널 시계열 (예시)\*\* + +```json + +{ + + "testId": "EMG\_20260601\_104500", "dataType": "100", + + "params": { "sampleRate": 1000, "channels": 1, "duration\_s": 5 }, + + "records": \[{ + + "rowIndex": 0, "datetime": "...", + + "channels": \[ + + { "ch": 0, "data": \[ 0.12, 0.15, -0.03, ..., 5000개 ] } + + ] + + }] + +} + +``` + + + +\*\*`dataType: "999"` — 사용자 정의 자유 형식 (예시)\*\* + +```json + +{ + + "testId": "CUSTOM\_001", "dataType": "999", + + "memo": "Experimental payload", + + "records": \[{ + + "rowIndex": 0, "datetime": "...", + + "myField1": "anything", + + "myField2": { "nested": \[1, 2, 3] } + + }] + +} + +``` + + + +서버는 `records\[]` 내부 구조를 검증하지 않고 그대로 PostgreSQL JSONB에 저장 → dataType별 핸들러가 해석. + + + +\--- + + + +\## 3. 단일 호출 업로드 — `POST /upload/json` (★ 권장) + + + +전체 세션 + 모든 records를 한 번에 보냅니다. \*\*재시도 안전 (idempotent)\*\*. + + + +```http + +POST /api/v1/upload/json + +X-API-Key: {apiKey} + +Content-Type: application/json + +``` + + + +```json + +{ + + "testId": "LAB\_20260601\_103000", ← 필수, sessionId와 동일 의미 + + "dataType": "000", ← 선택 (생략 시 "000") + + "sessionName": "Morning measurement", ← 선택 + + "memo": "물 500ml, 25°C", ← 선택 + + "savedAt": "2026-06-01T10:35:00Z", ← 선택 (있으면 status='completed') + + "params": { "frequency": 1, "cycles": 7 }, ← 선택 (JSON 자유) + + "recordCount": 5, + + "records": \[ + + { + + "rowIndex": 0, ← 필수 + + "datetime": "2026-06-01T10:30:00.629Z", + + "commandType": "MBB", + + "raaStatus": 0, + + "sensor": { + + "batteryMv": 3920, + + "batteryPct": 78, + + "tempC100": 2530, ← 25.30°C × 100 + + "imu": { "ax": 12, "ay": -3, "az": 98, "gx": 0, "gy": 1, "gz": 2 } + + }, + + "channels": \[ + + { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ...] }, + + { "ch": 1, "peak": 2150, "peakIdx": 2, "data": \[...] }, + + { "ch": 2, ... }, + + { "ch": 3, ... }, + + { "ch": 4, ... }, + + { "ch": 5, ... } + + ] + + }, + + { "rowIndex": 1, ... }, + + ... + + ] + +} + +``` + + + +\### 3-1. 응답 + + + +| HTTP | 상황 | + +|:----:|------| + +| 201 Created | 새 세션 생성 | + +| 200 OK | 기존 세션에 추가 / 재업로드 (idempotent) | + + + +```json + +{ + + "ok": true, + + "sessionId": "LAB\_20260601\_103000", + + "created": true, ← false면 기존 세션 사용 + + "totalRecords": 5, + + "inserted": 5, ← 실제 신규 삽입 + + "duplicates": 0, ← 이미 있어서 무시된 수 + + "errors": 0 + +} + +``` + + + +\### 3-2. 제한 + + + +| 항목 | 제한 | + +|------|:----:| + +| 최대 요청 본문 | \*\*20MB\*\* (한 세션 전체) | + +| Rate limit | \*\*10 req/min\*\* (디바이스별) | + +| testId 길이 | 1\~40자 (`^\[A-Za-z0-9\_-]+$`) | + + + +\### 3-3. 자유 형식 규칙 + + + +\- `records\[]`의 각 객체는 \*\*`rowIndex` 외에는 모두 선택\*\* — 내부 구조 자유 + +\- `sensor`, `channels`는 JSON 그대로 저장 → 나중에 dataType별 분석에서 해석 + +\- 동일 `testId + rowIndex` 재전송 → 자동 무시 (중복 안전) + +\- `dataType` 다르게 재업로드 → 세션의 dataType이 업데이트됨 + + + +\### 3-4. 에러 + + + +```json + +{ + + "error": { + + "code": "INVALID\_SESSION\_ID", + + "message": "testId must match ^\[A-Za-z0-9\_-]{1,40}$", + + "status": 400 + + } + +} + +``` + + + +| HTTP | code | 의미 | + +|:----:|------|------| + +| 400 | INVALID\_REQUEST | testId 누락 / 잘못된 JSON | + +| 400 | INVALID\_SESSION\_ID | testId 형식 위반 | + +| 401 | UNAUTHORIZED / INVALID\_API\_KEY | X-API-Key 누락/잘못됨 | + +| 403 | PENDING\_APPROVAL | 디바이스 승인 대기 | + +| 409 | SESSION\_ID\_CONFLICT | 다른 device의 testId와 충돌 | + +| 413 | PAYLOAD\_TOO\_LARGE | 20MB 초과 | + +| 429 | RATE\_LIMITED | 분당 10회 초과 | + +| 500 | INTERNAL\_ERROR | 서버 오류 | + + + +\--- + + + +\## 4. 분할 호출 방식 (대용량/스트리밍) + + + +업로드 한 번에 못 보낼 만큼 records가 많으면 분할: + + + +``` + +1\) POST /sessions → 세션 생성 (idempotent) + +2\) POST /sessions/{id}/records/batch × N ← 100건씩 반복 + +3\) PATCH /sessions/{id} → 종료 (endTime, recordCount) + +``` + + + +\### 4-1. 세션 생성 + + + +```http + +POST /api/v1/sessions + +X-API-Key: {apiKey} + +Content-Type: application/json + +``` + + + +```json + +{ + + "sessionId": "LAB\_20260601\_103000", ← 선택 (생략 시 서버가 ses\_xxx 발급) + + "dataType": "000", ← 선택 (기본 "000") + + "sessionName": "Morning", + + "startTime": "2026-06-01T10:30:00Z", ← 필수 + + "params": { ... }, ← 선택 (JSON 자유) + + "note": "..." + +} + +``` + + + +\*\*Response 201 (신규) / 200 (idempotent):\*\* + +```json + +{ "sessionId": "LAB\_20260601\_103000", "createdAt": "2026-06-01T10:30:01.123Z" } + +``` + + + +\### 4-2. Records 일괄 추가 + + + +```http + +POST /api/v1/sessions/{sessionId}/records/batch + +X-API-Key: {apiKey} + +``` + + + +```json + +{ + + "records": \[ + + { "rowIndex": 0, "timestamp": "...", "commandType": "MBB", "sensor": {...}, "channels": \[...] }, + + { "rowIndex": 1, ... }, + + ... + + ] + +} + +``` + + + +\*\*제한:\*\* 최대 100건/요청, 본문 2MB, 10 req/min + + + +\*\*Response 201:\*\* + +```json + +{ "inserted": 100, "firstRow": 0, "lastRow": 99 } + +``` + + + +\### 4-3. Records 1건 추가 (실시간 스트리밍용) + + + +```http + +POST /api/v1/sessions/{sessionId}/records + +X-API-Key: {apiKey} + +``` + + + +Body는 위의 `records\[]` 한 항목과 동일. + + + +\### 4-4. 세션 종료 + + + +```http + +PATCH /api/v1/sessions/{sessionId} + +X-API-Key: {apiKey} + +``` + + + +```json + +{ + + "endTime": "2026-06-01T11:30:00Z", + + "recordCount": 720, + + "note": "정상 완료" + +} + +``` + + + +\--- + + + +\## 5. 조회 API + + + +\### 5-1. 세션 목록 (해당 디바이스) + + + +```http + +GET /api/v1/sessions?from=2026-06-01\&to=2026-06-30\&limit=50\&offset=0 + +X-API-Key: {apiKey} + +``` + + + +\*\*Response:\*\* + +```json + +{ + + "total": 12, + + "sessions": \[ + + { + + "sessionId": "LAB\_20260601\_103000", + + "sessionName": "Morning", + + "deviceName": "Galaxy Tab S9", + + "dataType": "000", + + "startTime": "...", + + "endTime": "...", + + "recordCount": 720, + + "params": { ... }, + + "note": "..." + + } + + ] + +} + +``` + + + +\### 5-2. 세션 상세 + + + +```http + +GET /api/v1/sessions/{sessionId} + +X-API-Key: {apiKey} + +``` + + + +\### 5-3. Records 목록 + + + +```http + +GET /api/v1/sessions/{sessionId}/records?from=0\&to=99\&fields=summary + +X-API-Key: {apiKey} + +``` + + + +| `fields` | 반환 내용 | + +|---------|----------| + +| `summary` (기본) | rowIndex, timestamp, peak, peakIdx (가벼움) | + +| `full` | + sensor 전체 + channels의 ADC `data\[]` (큰 응답) | + + + +\### 5-4. Record 단건 (ADC 포함) + + + +```http + +GET /api/v1/sessions/{sessionId}/records/{rowIndex} + +X-API-Key: {apiKey} + +``` + + + +\### 5-5. 통계 + + + +```http + +GET /api/v1/sessions/{sessionId}/stats + +X-API-Key: {apiKey} + +``` + + + +6채널 peak 평균/최소/최대/표준편차 + 온도/배터리 집계. + + + +\### 5-6. CSV/JSON 내보내기 + + + +```http + +GET /api/v1/sessions/{sessionId}/export?format=csv + +GET /api/v1/sessions/{sessionId}/export?format=json + +``` + + + +CSV 형식: 1 record = 6 채널 rows (각 row에 sensor/IMU 컨텍스트 복제 + s0..s99). + + + +\--- + + + +\## 6. 삭제 + + + +```http + +DELETE /api/v1/sessions/{sessionId} + +X-API-Key: {apiKey} + +``` + + + +Cascade로 모든 records가 함께 삭제됩니다. (204 No Content) + + + +\--- + + + +\## 7. 에러 코드 전체 + + + +| HTTP | code | 권장 처리 | + +|:----:|------|----------| + +| 400 | INVALID\_REQUEST | 필드 누락/타입 오류 → 클라이언트 수정 | + +| 400 | INVALID\_SESSION\_ID | testId 형식 위반 → 재생성 | + +| 400 | BATCH\_TOO\_LARGE | records 100건 초과 → 청크 분할 | + +| 401 | UNAUTHORIZED | X-API-Key 누락 → 헤더 추가 | + +| 401 | INVALID\_API\_KEY | 키 형식 잘못됨 → 로컬 키 삭제 + 재등록 | + +| 403 | PENDING\_APPROVAL | 관리자 승인 대기 → UI 안내 | + +| 403 | FORBIDDEN | 권한 없음 → 관리자 문의 | + +| 404 | DEVICE\_DELETED | 디바이스 삭제됨 → 로컬 키 삭제 + 재등록 | + +| 404 | SESSION\_NOT\_FOUND | 세션 없음 → 세션 먼저 생성 | + +| 404 | RECORD\_NOT\_FOUND | rowIndex 확인 | + +| 409 | DUPLICATE\_ROW | 동일 rowIndex 중복 (자동 무시) | + +| 409 | SESSION\_ID\_CONFLICT | 다른 device가 같은 testId 사용 중 → 재생성 | + +| 413 | PAYLOAD\_TOO\_LARGE | 본문 크기 초과 | + +| 429 | RATE\_LIMITED | 분당 한도 초과 → 60초 대기 후 재시도 | + +| 500 | INTERNAL\_ERROR | 서버 오류 → 잠시 후 재시도 + 로그 보고 | + + + +\*\*에러 응답 형식 (공통):\*\* + +```json + +{ + + "error": { + + "code": "ERROR\_CODE", + + "message": "Human-readable description", + + "status": HTTP\_STATUS + + } + +} + +``` + + + +\--- + + + +\## 8. Rate Limit + + + +| 종류 | 한도 (디바이스별) | + +|------|:----:| + +| 일반 요청 (`/sessions`, `/records`, `/status` 등) | \*\*100 req/min\*\* | + +| 일괄 업로드 (`/records/batch`, `/upload/json`) | \*\*10 req/min\*\* | + +| Export (`/export?format=\*`) | \*\*5 req/min\*\* | + + + +\*\*응답 헤더로 잔여량 확인:\*\* + +``` + +X-RateLimit-Remaining: 87 + +X-RateLimit-Reset: 1780000000 + +``` + + + +\--- + + + +\## 9. Best Practices + + + +\### 9-1. API Key 보관 + + + +\- \*\*저장 위치\*\*: Android `EncryptedSharedPreferences` / iOS Keychain / Desktop OS credential store + +\- 평문 파일/SharedPreferences 사용 금지 + +\- 앱 삭제 시 함께 제거 (재설치 시 재등록 흐름) + + + +\### 9-2. 오프라인 우선 (IoT 권장) + + + +``` + +\[측정 중] + + 1) 로컬 DB에 records 저장 (네트워크 불필요) + + 2) sessionId는 측정 시작 시 로컬에서 생성: LAB\_yyyymmdd\_hhmmss + + + +\[네트워크 복구 시] + + 방식 A (권장 — 간단): + + POST /upload/json ← 세션 전체 한 번에 + + 방식 B (대용량): + + POST /sessions ← idempotent + + POST /records/batch × N ← 100건씩 + + PATCH /sessions/{id} ← 종료 + +``` + + + +\### 9-3. 재시도 안전 (idempotency) + + + +\- 동일 `testId` 재호출 → 기존 세션 그대로 사용 (덮어쓰기 안 함) + +\- 동일 `rowIndex` 재전송 → `ON CONFLICT DO NOTHING` → 자동 무시 + +\- 응답의 `inserted` 카운트로 실제 신규 삽입 수 확인 + + + +\### 9-4. 매 실행 흐름 + + + +``` + +앱 시작 + + ├─ 로컬 키 없음 → POST /devices/register → 키 저장 → "승인 대기" UI + + └─ 로컬 키 있음 → GET /devices/status + + ├─ active → 데이터 업로드 진행 + + ├─ pending → "승인 대기" UI + + ├─ revoked → "비활성화됨" UI + + ├─ 404 DEVICE\_DELETED → 로컬 키 삭제 → 재등록 흐름 + + └─ 401 INVALID\_KEY → 로컬 키 삭제 → 재등록 흐름 + +``` + + + +\### 9-5. dataType 활용 + + + +```javascript + +// 같은 앱이 여러 종류의 데이터를 보낼 때 + +sendUltrasoundData(records) { + + POST /upload/json { testId: "LAB\_xxx", dataType: "000", records }; + +} + +sendEmgData(records) { + + POST /upload/json { testId: "EMG\_xxx", dataType: "100", records }; + +} + +``` + + + +서버는 dataType별로 다른 시각화/분석 알고리즘을 자동 적용합니다. + + + +\--- + + + +\## 10. 클라이언트 예시 + + + +\### 10-1. cURL (가장 간단) + + + +```bash + +\# 1. 등록 (1회) + +curl -X POST https://labdb.medithings.net/api/v1/devices/register \\ + + -H "Content-Type: application/json" \\ + + -d '{"appName":"VesiScan","deviceName":"VB-001"}' + +\# → 응답에서 apiKey 저장 + + + +KEY="xbk\_live\_xxx..." + + + +\# 2. 상태 확인 + +curl -H "X-API-Key: $KEY" https://labdb.medithings.net/api/v1/devices/status + + + +\# 3. 전체 업로드 (권장) + +curl -X POST https://labdb.medithings.net/api/v1/upload/json \\ + + -H "X-API-Key: $KEY" -H "Content-Type: application/json" \\ + + --data-binary @session.json + +``` + + + +\### 10-2. Python + + + +```python + +import requests, json + +BASE = "https://labdb.medithings.net/api/v1" + + + +def register(): + + r = requests.post(f"{BASE}/devices/register", json={ + + "appName": "VesiScan", "deviceName": "VB-Test"}) + + r.raise\_for\_status() + + return r.json()\["apiKey"] + + + +def check\_status(key): + + r = requests.get(f"{BASE}/devices/status", headers={"X-API-Key": key}) + + return r.json() + + + +def upload(key, session\_dict): + + r = requests.post(f"{BASE}/upload/json", + + headers={"X-API-Key": key, "Content-Type": "application/json"}, + + data=json.dumps(session\_dict)) + + if r.status\_code == 403: + + raise Exception("Pending admin approval") + + r.raise\_for\_status() + + return r.json() + + + +\# 사용 + +key = register() # 첫 실행만 + +status = check\_status(key) + +if status\["status"] == "active": + + result = upload(key, { + + "testId": "LAB\_20260601\_103000", + + "dataType": "000", + + "memo": "Test", + + "records": \[...] + + }) + + print(f"Uploaded: {result\['inserted']} records") + +``` + + + +\### 10-3. Kotlin (Android) + + + +```kotlin + +suspend fun uploadSession(apiKey: String, session: JSONObject): JSONObject { + + val req = Request.Builder() + + .url("https://labdb.medithings.net/api/v1/upload/json") + + .header("X-API-Key", apiKey) + + .post(session.toString().toRequestBody("application/json".toMediaType())) + + .build() + + val res = client.newCall(req).execute() + + return when (res.code) { + + 200, 201 -> JSONObject(res.body!!.string()) + + 403 -> throw PendingApprovalException() + + 404 -> { secureStore.clear(); throw ReregisterRequiredException() } + + else -> throw IOException("HTTP ${res.code}") + + } + +} + +``` + + + +\### 10-4. JavaScript (Node.js) + + + +```javascript + +const fetch = require('node-fetch'); + +const BASE = 'https://labdb.medithings.net/api/v1'; + + + +async function upload(key, session) { + + const res = await fetch(`${BASE}/upload/json`, { + + method: 'POST', + + headers: { 'X-API-Key': key, 'Content-Type': 'application/json' }, + + body: JSON.stringify(session) + + }); + + if (res.status === 403) throw new Error('Pending approval'); + + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + return res.json(); + +} + +``` + + + +\--- + + + +\## 11. 엔드포인트 요약 + + + +| Method | Endpoint | 인증 | 설명 | + +|:------:|----------|:----:|------| + +| POST | `/api/v1/devices/register` | — | 디바이스 등록 | + +| GET | `/api/v1/devices/status` | X-API-Key | 상태 확인 (5가지 분기) | + +| \*\*POST\*\* | \*\*`/api/v1/upload/json`\*\* | \*\*X-API-Key\*\* | \*\*★ 전체 세션 업로드 (권장)\*\* | + +| POST | `/api/v1/sessions` | X-API-Key | 세션 생성 (idempotent) | + +| GET | `/api/v1/sessions?dataType=000` | X-API-Key | 세션 목록 (dataType 필터 가능) | + +| GET | `/api/v1/sessions/{id}` | X-API-Key | 세션 상세 | + +| PATCH | `/api/v1/sessions/{id}` | X-API-Key | 세션 종료/메모 | + +| DELETE | `/api/v1/sessions/{id}` | X-API-Key | 세션 삭제 | + +| POST | `/api/v1/sessions/{id}/records` | X-API-Key | Record 1건 추가 | + +| POST | `/api/v1/sessions/{id}/records/batch` | X-API-Key | Record 일괄 추가 (≤100) | + +| GET | `/api/v1/sessions/{id}/records` | X-API-Key | Record 목록 (summary/full) | + +| GET | `/api/v1/sessions/{id}/records/{row}` | X-API-Key | Record 단건 (ADC 포함) | + +| GET | `/api/v1/sessions/{id}/stats` | X-API-Key | 통계 | + +| GET | `/api/v1/sessions/{id}/export?format=` | X-API-Key | CSV/JSON 내보내기 | + + + +\--- + + + +\## 12. 변경 이력 + + + +| 버전 | 날짜 | 변경 | + +|:----:|------|------| + +| 1.0 | 2026-04-14 | 초안 (sessions/records/devices) | + +| 1.1 | 2026-06-01 | `POST /upload/json` 추가, `dataType` 필드 추가 | + +| 1.2 | 2026-06-01 | dataType 자료 구분 메커니즘 상세화 (레지스트리, 유형별 페이로드, 신규 코드 발급 절차) | + + + +\--- + + + +\## 13. 지원 + + + +\- \*\*서비스 상태\*\*: https://labdb.medithings.net/api/health + +\- \*\*관리자\*\*: admin@medithings.net + +\- \*\*상세 워크플로우\*\*: `docs/APP-WORKFLOW-RULES.md` 참조 + +\- \*\*디바이스 등록 가이드\*\*: `docs/DEVICE-REGISTRATION-GUIDE.md` 참조 + + + +\--- + + + +\*Copyright (c) 2026 Charles KWON OhJun / MEDiThings Inc.\* + + +