b3d9c16780
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) <noreply@anthropic.com>
1537 lines
25 KiB
Markdown
1537 lines
25 KiB
Markdown
\# 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.\*
|
||
|
||
|
||
|