Files
VesiscanClinicalAndroid/labdb.md
T
dw.jang 60b630d3de docs: v7 (2026-07-10) 반영 — msp 제거, 6-stage alignment, mtb queue fix, labdb 자동 재시도
이번 세션 대량 변경 사항을 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>
2026-07-10 11:25:26 +09:00

1659 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
\# 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
{
&#x20; "appName": "VesiScan", ← 필수: 앱 식별자
&#x20; "deviceName": "Galaxy Tab S9", ← 필수: 표시명
&#x20; "deviceAddress": "AA:BB:CC:DD:EE:FF", ← BLE MAC (선택)
&#x20; "hwNumber": "VB2024001", ← 선택
&#x20; "serialNumber": "MT20260601", ← 선택
&#x20; "fwVersion": "VBTFW0103", ← 선택
&#x20; "appVersion": "1.0.0" ← 선택
}
```
\*\*Response 201:\*\*
```json
{
&#x20; "deviceId": "dev\_abc123",
&#x20; "apiKey": "xbk\_live\_...", ← 안전한 저장소(Keystore/Keychain)에 저장
&#x20; "status": "pending", ← 관리자 승인 대기
&#x20; "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
{
&#x20; "deviceId": "dev\_abc123",
&#x20; "status": "active",
&#x20; "deviceName": "Galaxy Tab S9",
&#x20; "appName": "VesiScan",
&#x20; "registeredAt": "2026-06-01T08:00:00Z",
&#x20; "approvedAt": "2026-06-01T09:15:00Z",
&#x20; "revokedAt": "",
&#x20; "lastSeenAt": "2026-06-01T10:30:00Z",
&#x20; "sessionCount": 12
}
```
\---
\## 2. 데이터 구조 (핵심 개념)
\### 2-1. 3단계 계층
```
Device (1) ── 여러 Session 보유
&#x20; └─ Session (1) ── 여러 Record 보유
&#x20; └─ 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
{
&#x20; "testId": "LAB\_20260601\_103000",
&#x20; "dataType": "000", ← 이 필드로 자료 구분
&#x20; "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 => {
&#x20; if (s.dataType === "000") renderUltrasound(s);
&#x20; else if (s.dataType === "100") renderEmg(s);
&#x20; else renderRaw(s);
});
```
\#### 유형별 페이로드 예시
\*\*`dataType: "000"` — VesiScan 초음파 (6채널)\*\*
```json
{
&#x20; "testId": "LAB\_20260601\_103000", "dataType": "000",
&#x20; "records": \[{
&#x20; "rowIndex": 0, "datetime": "...", "commandType": "MBB",
&#x20; "sensor": { "batteryMv": 3920, "tempC100": 2530, "imu": {"ax":12,"ay":-3,"az":98,"gx":0,"gy":1,"gz":2} },
&#x20; "channels": \[
&#x20; { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ..., 100개] },
&#x20; { "ch": 1, ... }, ..., { "ch": 5, ... }
&#x20; ]
&#x20; }]
}
```
\*\*`dataType: "100"` — EMG 단일 채널 시계열 (예시)\*\*
```json
{
&#x20; "testId": "EMG\_20260601\_104500", "dataType": "100",
&#x20; "params": { "sampleRate": 1000, "channels": 1, "duration\_s": 5 },
&#x20; "records": \[{
&#x20; "rowIndex": 0, "datetime": "...",
&#x20; "channels": \[
&#x20; { "ch": 0, "data": \[ 0.12, 0.15, -0.03, ..., 5000개 ] }
&#x20; ]
&#x20; }]
}
```
\*\*`dataType: "999"` — 사용자 정의 자유 형식 (예시)\*\*
```json
{
&#x20; "testId": "CUSTOM\_001", "dataType": "999",
&#x20; "memo": "Experimental payload",
&#x20; "records": \[{
&#x20; "rowIndex": 0, "datetime": "...",
&#x20; "myField1": "anything",
&#x20; "myField2": { "nested": \[1, 2, 3] }
&#x20; }]
}
```
서버는 `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
{
&#x20; "testId": "LAB\_20260601\_103000", ← 필수, sessionId와 동일 의미
&#x20; "dataType": "000", ← 선택 (생략 시 "000")
&#x20; "sessionName": "Morning measurement", ← 선택
&#x20; "memo": "물 500ml, 25°C", ← 선택
&#x20; "savedAt": "2026-06-01T10:35:00Z", ← 선택 (있으면 status='completed')
&#x20; "params": { "frequency": 1, "cycles": 7 }, ← 선택 (JSON 자유)
&#x20; "recordCount": 5,
&#x20; "records": \[
&#x20; {
&#x20; "rowIndex": 0, ← 필수
&#x20; "datetime": "2026-06-01T10:30:00.629Z",
&#x20; "commandType": "MBB",
&#x20; "raaStatus": 0,
&#x20; "sensor": {
&#x20; "batteryMv": 3920,
&#x20; "batteryPct": 78,
&#x20; "tempC100": 2530, ← 25.30°C × 100
&#x20; "imu": { "ax": 12, "ay": -3, "az": 98, "gx": 0, "gy": 1, "gz": 2 }
&#x20; },
&#x20; "channels": \[
&#x20; { "ch": 0, "peak": 2202, "peakIdx": 1, "data": \[2202, 2233, 2054, ...] },
&#x20; { "ch": 1, "peak": 2150, "peakIdx": 2, "data": \[...] },
&#x20; { "ch": 2, ... },
&#x20; { "ch": 3, ... },
&#x20; { "ch": 4, ... },
&#x20; { "ch": 5, ... }
&#x20; ]
&#x20; },
&#x20; { "rowIndex": 1, ... },
&#x20; ...
&#x20; ]
}
```
\### 3-1. 응답
| HTTP | 상황 |
|:----:|------|
| 201 Created | 새 세션 생성 |
| 200 OK | 기존 세션에 추가 / 재업로드 (idempotent) |
```json
{
&#x20; "ok": true,
&#x20; "sessionId": "LAB\_20260601\_103000",
&#x20; "created": true, ← false면 기존 세션 사용
&#x20; "totalRecords": 5,
&#x20; "inserted": 5, ← 실제 신규 삽입
&#x20; "duplicates": 0, ← 이미 있어서 무시된 수
&#x20; "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
{
&#x20; "error": {
&#x20; "code": "INVALID\_SESSION\_ID",
&#x20; "message": "testId must match ^\[A-Za-z0-9\_-]{1,40}$",
&#x20; "status": 400
&#x20; }
}
```
| 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
{
&#x20; "sessionId": "LAB\_20260601\_103000", ← 선택 (생략 시 서버가 ses\_xxx 발급)
&#x20; "dataType": "000", ← 선택 (기본 "000")
&#x20; "sessionName": "Morning",
&#x20; "startTime": "2026-06-01T10:30:00Z", ← 필수
&#x20; "params": { ... }, ← 선택 (JSON 자유)
&#x20; "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
{
&#x20; "records": \[
&#x20; { "rowIndex": 0, "timestamp": "...", "commandType": "MBB", "sensor": {...}, "channels": \[...] },
&#x20; { "rowIndex": 1, ... },
&#x20; ...
&#x20; ]
}
```
\*\*제한:\*\* 최대 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
{
&#x20; "endTime": "2026-06-01T11:30:00Z",
&#x20; "recordCount": 720,
&#x20; "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
{
&#x20; "total": 12,
&#x20; "sessions": \[
&#x20; {
&#x20; "sessionId": "LAB\_20260601\_103000",
&#x20; "sessionName": "Morning",
&#x20; "deviceName": "Galaxy Tab S9",
&#x20; "dataType": "000",
&#x20; "startTime": "...",
&#x20; "endTime": "...",
&#x20; "recordCount": 720,
&#x20; "params": { ... },
&#x20; "note": "..."
&#x20; }
&#x20; ]
}
```
\### 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
{
&#x20; "error": {
&#x20; "code": "ERROR\_CODE",
&#x20; "message": "Human-readable description",
&#x20; "status": HTTP\_STATUS
&#x20; }
}
```
\---
\## 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 권장)
```
\[측정 중]
&#x20; 1) 로컬 DB에 records 저장 (네트워크 불필요)
&#x20; 2) sessionId는 측정 시작 시 로컬에서 생성: LAB\_yyyymmdd\_hhmmss
\[네트워크 복구 시]
&#x20; 방식 A (권장 — 간단):
&#x20; POST /upload/json ← 세션 전체 한 번에
&#x20; 방식 B (대용량):
&#x20; POST /sessions ← idempotent
&#x20; POST /records/batch × N ← 100건씩
&#x20; PATCH /sessions/{id} ← 종료
```
\### 9-3. 재시도 안전 (idempotency)
\- 동일 `testId` 재호출 → 기존 세션 그대로 사용 (덮어쓰기 안 함)
\- 동일 `rowIndex` 재전송 → `ON CONFLICT DO NOTHING` → 자동 무시
\- 응답의 `inserted` 카운트로 실제 신규 삽입 수 확인
\### 9-4. 매 실행 흐름
```
앱 시작
&#x20; ├─ 로컬 키 없음 → POST /devices/register → 키 저장 → "승인 대기" UI
&#x20; └─ 로컬 키 있음 → GET /devices/status
&#x20; ├─ active → 데이터 업로드 진행
&#x20; ├─ pending → "승인 대기" UI
&#x20; ├─ revoked → "비활성화됨" UI
&#x20; ├─ 404 DEVICE\_DELETED → 로컬 키 삭제 → 재등록 흐름
&#x20; └─ 401 INVALID\_KEY → 로컬 키 삭제 → 재등록 흐름
```
\### 9-5. dataType 활용
```javascript
// 같은 앱이 여러 종류의 데이터를 보낼 때
sendUltrasoundData(records) {
&#x20; POST /upload/json { testId: "LAB\_xxx", dataType: "000", records };
}
sendEmgData(records) {
&#x20; 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 \\
&#x20; -H "Content-Type: application/json" \\
&#x20; -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 \\
&#x20; -H "X-API-Key: $KEY" -H "Content-Type: application/json" \\
&#x20; --data-binary @session.json
```
\### 10-2. Python
```python
import requests, json
BASE = "https://labdb.medithings.net/api/v1"
def register():
&#x20; r = requests.post(f"{BASE}/devices/register", json={
&#x20; "appName": "VesiScan", "deviceName": "VB-Test"})
&#x20; r.raise\_for\_status()
&#x20; return r.json()\["apiKey"]
def check\_status(key):
&#x20; r = requests.get(f"{BASE}/devices/status", headers={"X-API-Key": key})
&#x20; return r.json()
def upload(key, session\_dict):
&#x20; r = requests.post(f"{BASE}/upload/json",
&#x20; headers={"X-API-Key": key, "Content-Type": "application/json"},
&#x20; data=json.dumps(session\_dict))
&#x20; if r.status\_code == 403:
&#x20; raise Exception("Pending admin approval")
&#x20; r.raise\_for\_status()
&#x20; return r.json()
\# 사용
key = register() # 첫 실행만
status = check\_status(key)
if status\["status"] == "active":
&#x20; result = upload(key, {
&#x20; "testId": "LAB\_20260601\_103000",
&#x20; "dataType": "000",
&#x20; "memo": "Test",
&#x20; "records": \[...]
&#x20; })
&#x20; print(f"Uploaded: {result\['inserted']} records")
```
\### 10-3. Kotlin (Android)
```kotlin
suspend fun uploadSession(apiKey: String, session: JSONObject): JSONObject {
&#x20; val req = Request.Builder()
&#x20; .url("https://labdb.medithings.net/api/v1/upload/json")
&#x20; .header("X-API-Key", apiKey)
&#x20; .post(session.toString().toRequestBody("application/json".toMediaType()))
&#x20; .build()
&#x20; val res = client.newCall(req).execute()
&#x20; return when (res.code) {
&#x20; 200, 201 -> JSONObject(res.body!!.string())
&#x20; 403 -> throw PendingApprovalException()
&#x20; 404 -> { secureStore.clear(); throw ReregisterRequiredException() }
&#x20; else -> throw IOException("HTTP ${res.code}")
&#x20; }
}
```
\### 10-4. JavaScript (Node.js)
```javascript
const fetch = require('node-fetch');
const BASE = 'https://labdb.medithings.net/api/v1';
async function upload(key, session) {
&#x20; const res = await fetch(`${BASE}/upload/json`, {
&#x20; method: 'POST',
&#x20; headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
&#x20; body: JSON.stringify(session)
&#x20; });
&#x20; if (res.status === 403) throw new Error('Pending approval');
&#x20; if (!res.ok) throw new Error(`HTTP ${res.status}`);
&#x20; 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 · <folderName>"
\- 완료 (`lastResultMessage != null`):
초록/주황 배너 "N uploaded, M failed" + 확인 버튼
\### 12b.4 State 노출
```kotlin
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.\*