\# 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 자료 구분 메커니즘 상세화 (레지스트리, 유형별 페이로드, 신규 코드 발급 절차) | \--- \## 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 노출 ```kotlin object LabdbAutoRetry { val pendingCount: MutableIntState // UI 배너 카운트 val uploadingName: MutableState // 현재 업로드 중 폴더 val lastResultMessage: MutableState // 완료 후 결과 요약 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.\*