이번 세션 대량 변경 사항을 5개 문서에 반영. 각 문서마다 stale 이던
섹션을 갱신하거나 신규 섹션 추가.
USER_GUIDE.md
- Sensor Alignment 를 V1 (3-stage) / V2 (6-stage) 로 재구성
- V2 6-stage 표 + relaxed mode / soft hint 설명
- GREEN 진입 5초 hold + 10-strike 리셋 완화 명시
- "최적의 위치입니다!" 문구 반영
docs/BLE_PROTOCOL_REFERENCE.md
- msp 명령 취소선 처리 + mim 신규 명령 문서화
- Watchdog timeout 25초 연장 명시 (2.5)
- §2.6 신규: firmware VBTFW0121 mls mode 0 freeze 취약점 +
앱 측 3-layer 회피 (isMtbBusy / mtb 3초 timeout / 자동 재연결)
VesiScan_Android_Pipeline_Summary.md
- v7 (2026-07-10) 섹션 신규 추가 — BLE / Alignment / UI / labdb /
tools 5개 카테고리로 변경 사항 정리
- 권장 펌웨어 표기 VBTFW0116 → VBTFW0120+ 로 갱신
labdb.md
- §12b 신규: 앱 측 Auto Retry Policy — endMeasurement 자동 업로드
조건 완화, LabdbAutoRetry object, UI 배너, 재시도 안전성
tools/README.md
- labdb_upload.py 섹션 신규 — 사용법 / 폴더 구조 / 재실행 안전성 /
buildPayload 로직 / 활용 예 정리
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
27 KiB
# 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만 예외).
X-API-Key: xbk\_live\_xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
### 1-1. 디바이스 등록 (앱 첫 실행 1회)
POST /api/v1/devices/register
Content-Type: application/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:**
{
  "deviceId": "dev\_abc123",
  "apiKey": "xbk\_live\_...", ← 안전한 저장소(Keystore/Keychain)에 저장
  "status": "pending", ← 관리자 승인 대기
  "message": "Device registered. Awaiting administrator approval."
}
⚠️ **재등록 금지**: 매 실행마다 호출하면 pending device가 누적됩니다. 첫 실행에만 호출하고 apiKey를 저장하세요.
### 1-2. 디바이스 상태 확인 (앱 시작 시 매번 권장)
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):**
{
  "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 필드를 포함:
{
  "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별 필터
GET /api/v1/sessions?dataType=100 # X-API-Key (앱)
GET /api/admin/sessions?dataType=100 # 세션 인증 (관리자)
응답의 모든 세션 객체는 dataType 필드를 포함하므로 클라이언트도 분기 처리 가능:
sessions.forEach(s => {
  if (s.dataType === "000") renderUltrasound(s);
  else if (s.dataType === "100") renderEmg(s);
  else renderRaw(s);
});
#### 유형별 페이로드 예시
**dataType: "000" — VesiScan 초음파 (6채널)**
{
  "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 단일 채널 시계열 (예시)**
{
  "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" — 사용자 정의 자유 형식 (예시)**
{
  "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)**.
POST /api/v1/upload/json
X-API-Key: {apiKey}
Content-Type: application/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) |
{
  "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. 에러
{
  "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. 세션 생성
POST /api/v1/sessions
X-API-Key: {apiKey}
Content-Type: application/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):**
{ "sessionId": "LAB\_20260601\_103000", "createdAt": "2026-06-01T10:30:01.123Z" }
### 4-2. Records 일괄 추가
POST /api/v1/sessions/{sessionId}/records/batch
X-API-Key: {apiKey}
{
  "records": \[
  { "rowIndex": 0, "timestamp": "...", "commandType": "MBB", "sensor": {...}, "channels": \[...] },
  { "rowIndex": 1, ... },
  ...
  ]
}
**제한:** 최대 100건/요청, 본문 2MB, 10 req/min
**Response 201:**
{ "inserted": 100, "firstRow": 0, "lastRow": 99 }
### 4-3. Records 1건 추가 (실시간 스트리밍용)
POST /api/v1/sessions/{sessionId}/records
X-API-Key: {apiKey}
Body는 위의 records\[] 한 항목과 동일.
### 4-4. 세션 종료
PATCH /api/v1/sessions/{sessionId}
X-API-Key: {apiKey}
{
  "endTime": "2026-06-01T11:30:00Z",
  "recordCount": 720,
  "note": "정상 완료"
}
---
## 5. 조회 API
### 5-1. 세션 목록 (해당 디바이스)
GET /api/v1/sessions?from=2026-06-01\&to=2026-06-30\&limit=50\&offset=0
X-API-Key: {apiKey}
**Response:**
{
  "total": 12,
  "sessions": \[
  {
  "sessionId": "LAB\_20260601\_103000",
  "sessionName": "Morning",
  "deviceName": "Galaxy Tab S9",
  "dataType": "000",
  "startTime": "...",
  "endTime": "...",
  "recordCount": 720,
  "params": { ... },
  "note": "..."
  }
  ]
}
### 5-2. 세션 상세
GET /api/v1/sessions/{sessionId}
X-API-Key: {apiKey}
### 5-3. Records 목록
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 포함)
GET /api/v1/sessions/{sessionId}/records/{rowIndex}
X-API-Key: {apiKey}
### 5-5. 통계
GET /api/v1/sessions/{sessionId}/stats
X-API-Key: {apiKey}
6채널 peak 평균/최소/최대/표준편차 + 온도/배터리 집계.
### 5-6. CSV/JSON 내보내기
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. 삭제
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 | 서버 오류 → 잠시 후 재시도 + 로그 보고 |
**에러 응답 형식 (공통):**
{
  "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 활용
// 같은 앱이 여러 종류의 데이터를 보낼 때
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 (가장 간단)
\# 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
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)
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)
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 자료 구분 메커니즘 상세화 (레지스트리, 유형별 페이로드, 신규 코드 발급 절차) |
---
## 12b. 앱 측 Auto Retry Policy (Android app, 2026-07-09)
Android 앱 (VesiScan-Basic demo-final / tab-navigation / cloud-mvp) 은 매
session 마다 사용자가 Upload 버튼을 누르지 않아도 되도록 **자동 업로드 +
재시도 큐** 를 내장합니다.
### 12b.1 endMeasurement 자동 업로드
Clinical 세션 종료 시 (ClinicalSessionStore.endMeasurement()) apiKey 가
등록되어 있으면 (LabdbCredentials.isRegistered) 무조건 `LabdbUploader.
uploadAsync(context, folder)` 를 호출. fire-and-forget 이므로 응답 대기
없이 다음 화면으로 진행.
- 이전 정책: lastStatus == "active" 조건 필요 → 상태 갱신 안 된 첫 진입 시
자동 업로드 skip 되던 문제.
- 2026-07-09 이후: isRegistered 만 체크. 서버가 401/403 을 반환하면 상위
로직에서 status 재조회.
### 12b.2 LabdbAutoRetry object (app/src/main/java/.../labdb/LabdbAutoRetry.kt)
Clinical Home 진입 시 (LaunchedEffect(Unit)) retryPending(context) 자동
호출. 로직:
1. LabdbCredentials.isRegistered 확인 → 없으면 no-op
2. ClinicalSessionStore.recentFinalizedFolders (tab-nav/cloud-mvp) 또는
listOfNotNull(lastFinalizedFolder) (demo-final) 스캔
3. labdb_upload.json 마커 없는 폴더만 pending
4. 순차 (병렬 X) LabdbUploader.uploadBlocking 실행
5. 결과: labdb_upload.json (성공) / labdb_upload_error.json (실패)
### 12b.3 UI 배너
Clinical Home 상단:
- 진행 중 (pendingCount > 0 또는 uploadingName != null):
파란 배너 "자동 업로드 진행 중 · 남은 세션 N · "
- 완료 (lastResultMessage != null):
초록/주황 배너 "N uploaded, M failed" + 확인 버튼
### 12b.4 State 노출
object LabdbAutoRetry {
val pendingCount: MutableIntState // UI 배너 카운트
val uploadingName: MutableState<String?> // 현재 업로드 중 폴더
val lastResultMessage: MutableState<String?> // 완료 후 결과 요약
fun retryPending(context: Context) // 트리거
fun dismissResult() // 배너 확인
}
### 12b.5 재시도 안전성
- 이미 업로드된 폴더 (labdb_upload.json 있음) 는 skip
- 실패 폴더는 labdb_upload_error.json 만 남기고 다음 진입 시 재시도
- 네트워크 순간 이슈 → 다음 진입에서 자동 복구
- 앱 종료 후 재실행 → ensureHistoryLoaded 로 recent 폴더 복원 → 재시도
---
## 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.*