AI와 보따리앱 제작자는 이 요약을 먼저 읽고, 상세 스키마는 아래 원문을 확인합니다.
BotariSDK 우선, 앱 안 상대 /wp-json/botari/v1/* 호출 금지, wpApiSettings nonce 의존 금지window.BOTARI_BUILT_APP 컨텍스트가 자동 주입되고 SDK/Relay 요청에 제품 ID, 버전, 실행 모드가 함께 전달됩니다.BotariSDK.setStorage(), getStorage(), listStorage(), deleteStorage()BotariSDK.sendSmsCode(), verifySmsCode()BotariSDK.generateQR()BotariSDK.generateBarcode()BotariSDK.generateExcel(), createExcel(), generatePdf(), createPdf(), generateVectorPdf(), createVectorPdf()BotariSDK.extractTextFromImage(), createOcrJob(), getOcrJob(). 기본은 Gemini 우선이며 실패 시 PaddleOCR로 fallback합니다.extractTextFromImage({normalize_label_ocr:true})는 Gemini가 이미지 OCR과 항목별 JSON 정리를 한 번에 시도하고, fallback 결과에는 normalizeLabelOcr()를 이어서 사용합니다.BotariSDK.trackEvent(name, props)로 QR 생성, 다운로드, OCR 실패 같은 앱 고유 행동을 선택 기록합니다.BotariSDK.requestLocation({purpose})는 앱 실행 중 사용자에게 별도 확인을 받은 뒤 현재 브라우저 위치만 반환합니다. 보따리 계정에 저장된 위치정보는 개별 앱에 자동 제공하지 않습니다.BotariSDK.getNearbyCallTaxis(), getCallTaxiCoverage()로 동의받은 좌표 또는 행정구역의 일반 호출번호와 수집 상태를 조회합니다.addSharedItem(), listSharedItems(), updateSharedItem(), deleteSharedItem()으로 같은 앱의 모집·방명록·예약 현황을 관리합니다. 수정과 삭제는 본인 항목만 가능합니다.trackEvent()는 실행 세션 토큰과 제품 ID가 일치할 때만 저장합니다. 이벤트 이름은 짧은 영문/숫자/밑줄 형태를 사용하고, props에는 상태·형식·개수 같은 짧은 값만 넣습니다.
서버는 속성 키 최대 10개, 값 120자 이하만 저장합니다. 이름, 이메일, 전화번호, 주소, 위치, OCR 원문, 본문, 프롬프트, 이미지/base64, 파일 내용, 토큰, 비밀번호, 쿠키처럼 민감한 키나 값은 [blocked]로 대체합니다. 같은 이벤트의 반복 전송에는 rate limit이 적용됩니다.
보따리 자체 서비스가 보유한 회원 위치정보는 보따리 서비스 내부 목적에만 사용됩니다. 개별 보따리앱은 저장된 회원 위치를 조회할 수 없고, 위치가 필요하면 BotariSDK.requestLocation()으로 앱 화면에서 별도 목적 고지와 사용자 동의를 받아 현재 위치만 사용할 수 있습니다.
OCR은 로그인 사용자만 사용할 수 있고, jpg/png/webp 3MB 이하 이미지를 서버 큐에 등록해 Gemini 우선으로 처리하며 실패 시 PaddleOCR로 fallback합니다.
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: "image/png",
product_id: 123,
normalize_label_ocr: true
});
console.log(result.fields);
POST /wp-json/botari/v1/ocr/image
GET /wp-json/botari/v1/ocr/jobs/{job_id}
POST /wp-json/botari/v1/ocr/normalize-label
# Botari API 가이드
Botari 앱/게임 연동용 REST API와 연동 규약 정리입니다.
## 0. OCR / 식품 라벨 OCR 빠른 확인
AI 앱 제작자가 OCR을 찾을 때는 이 블록을 우선 기준으로 사용하세요.
공식 SDK:
- `BotariSDK.extractTextFromImage(payload)`
- `BotariSDK.createOcrJob(payload)`
- `BotariSDK.getOcrJob(jobId)`
- `BotariSDK.normalizeLabelOcr({ text, lines, product_id? })`
OCR REST:
- `POST /wp-json/botari/v1/ocr/image`: 이미지 OCR 작업 등록, Gemini 우선 처리 후 실패 시 PaddleOCR fallback, `202`와 `job_id` 반환
- `GET /wp-json/botari/v1/ocr/jobs/{job_id}`: OCR 작업 상태/결과 조회
- `POST /wp-json/botari/v1/ocr/normalize-label`: fallback 또는 기존 OCR `text`/`lines`를 Gemini가 식품 라벨용 `clean_text`, `fields`, `corrections`, `confidence` JSON으로 정리
식품 라벨 OCR 후처리 예시:
```js
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: 'image/jpeg',
timeout_ms: 120000,
poll_interval_ms: 1500,
normalize_label_ocr: true
});
console.log(result.clean_text);
console.log(result.fields.ingredients);
```
별도 후처리:
```js
const normalized = await BotariSDK.normalizeLabelOcr({
text: ocr.text,
lines: ocr.lines
});
```
짧은 OCR 전용 문서:
- `https://botari.net/docs/ocr-api.md`
- `https://api.botari.net/docs/ocr-api.md`
문서에서 정리한 기준은 내부 구현 기준입니다. 운영 규칙은 정책 문서와 함께
확인하세요.
## 1. 공통
### REST 루트
- 기본: `/wp-json/`
- 운영 서비스 API 호스트: `https://api.botari.net/wp-json/`
- 보따리 라우트: `/wp-json/botari/v1/...`
### 인증
- 로그인 사용자: `X-WP-Nonce` (WordPress 비로그인 제한 경로 포함)
- 일부 공개 API만 비로그인 사용 가능
### 실행/격리 도메인
- 상품/마이페이지: `https://{user}.botari.net`
- 앱/게임 실행: 보따리 실행 도메인에서 열립니다.
- 서비스 API: `https://api.botari.net/wp-json/botari/v1/...`
앱 내부에서는 직접 WordPress 쿠키나 실행 경로에 의존하지 말고 `BotariSDK`를 사용하세요.
SDK가 실행 중인 앱의 권한과 컨텍스트를 처리합니다.
### 공개 지역 콜택시 API
보따리앱에서는 `BotariSDK.getNearbyCallTaxis()`와 `getCallTaxiCoverage()`를 우선 사용합니다. 앱은 사용자가 위치 사용을 승인한 뒤 좌표를 보내거나 이미 알고 있는 행정구역명을 보냅니다.
```js
const location = await BotariSDK.requestLocation({
purpose: '주변에서 전화로 부를 수 있는 택시를 찾기 위해 현재 위치가 필요합니다.'
});
const nearby = await BotariSDK.getNearbyCallTaxis({
latitude: location.latitude,
longitude: location.longitude
});
const seoulCoverage = await BotariSDK.getCallTaxiCoverage({ sido: '서울특별시' });
```
보따리앱 밖의 서버·외부 클라이언트는 인증 없이 아래 절대 REST URL을 사용할 수 있습니다.
```http
GET https://api.botari.net/wp-json/botari/v1/map/call-taxis/nearby?lat=37.5665&lon=126.9780
GET https://api.botari.net/wp-json/botari/v1/map/call-taxis/nearby?sido=서울특별시&sigungu=중구
GET https://api.botari.net/wp-json/botari/v1/map/call-taxis/coverage
POST https://api.botari.net/wp-json/botari/v1/map/call-taxis/report
```
`nearby` 핵심 응답:
```json
{
"available": true,
"coverage_status": "available",
"data_status": "ready",
"region": {
"sido": "서울특별시",
"sigungu": "중구",
"road_address": "서울특별시 중구 한강대로 405",
"lot_address": "서울특별시 중구 봉래동2가 122-21"
},
"items": [
{ "id": 123, "name": "호출 서비스명", "phone": "0000-0000", "tel_url": "tel:00000000" }
],
"count": 1,
"pickup_context": {
"available": true,
"address": "서울특별시 중구 한강대로 405",
"landmark_status": "ready",
"primary_landmark": null,
"landmarks": [
{ "name": "서울역", "category_label": "역·터미널", "distance_m": 61, "direction": "북쪽" }
],
"suggested_phrase": "서울특별시 중구 한강대로 405입니다."
}
}
```
- `available`: 지금 사용자에게 제공할 수 있는 활성 일반 호출번호가 있는지 여부
- `coverage_status`: `available`, `unavailable`, `collecting`, `unverified`
- `data_status`: `ready`, `collecting`, `missing`
- `unavailable`은 공식 출처에서 일반 전화 호출 서비스가 없음을 확인한 지역에만 사용합니다. 미수집 지역은 `unverified`입니다.
- 교통약자·장애인 전용 호출번호는 일반 결과에 섞지 않습니다.
- 지정 마을 주민만 이용하는 행복·희망·마을택시 역시 일반 결과에 섞지 않습니다. 번호가 없는 지역에 검색엔진이나 민간 지도에서 찾은 번호를 임의 보충하지 마세요.
- API는 사용자 좌표와 실제 통화 여부를 저장하지 않습니다.
- 좌표를 보낸 경우 `pickup_context`에 도로명·지번 주소와 주변의 역, 터미널, 병원, 공공시설, 대형 상업시설 후보를 거리순으로 최대 10개 제공합니다.
- 서버는 대표 시설을 지정하지 않습니다. `primary_landmark`는 하위 호환을 위해 `null`로 유지하며 `suggested_phrase`에는 주소만 담습니다. 앱이 주소·지번·주변 후보 중 표시하거나 복사할 내용을 선택하세요.
- `landmark_status=missing`이어도 전화번호와 주소 조회는 정상입니다. 장소 보강 실패를 호출 기능 전체 실패로 처리하지 마세요.
- 행정구역명만 보낸 요청은 정밀 좌표가 없으므로 랜드마크를 만들지 않으며 `landmark_status=coordinates_required`를 반환합니다.
- 번호 오류 신고는 인증 없이 가능하며 JSON body에 `taxi_id`, `reason`, 선택 `details`를 보냅니다. `reason`은 `wrong_number`, `disconnected`, `service_ended`, `wrong_area`, `other` 중 하나입니다.
- 신고는 사용자당 하루 5건, 같은 번호의 같은 사용자 미처리 신고는 24시간에 1건으로 제한됩니다. 앱이 자동 신고하거나 화면 진입 때 신고하지 말고, 사용자가 통화 결과를 확인한 뒤 직접 선택하게 하세요.
### AI 앱 제작 필수 규칙
AI로 보따리앱을 만들 때는 아래 규칙을 먼저 적용합니다.
- 보따리 서버 기능은 공식 `BotariSDK` 메서드를 먼저 사용합니다.
- 앱 iframe 안에서 `/wp-json/botari/v1/...` 같은 상대 REST 경로를 직접 호출하지 않습니다. 실행 도메인이 `play.botari.net`이므로 잘못된 호스트로 요청될 수 있습니다.
- 앱 안에서 `wpApiSettings`, WordPress nonce, WordPress 쿠키에 의존하지 않습니다.
- 앱 ZIP 안에 가짜/noop `BotariSDK`를 넣지 않습니다. 로컬 개발 fallback은 `window.BotariSDK`가 없을 때만 제한적으로 둡니다.
- QR/바코드/OCR/문서 생성/SMS/스토리지는 공개 문서에 있는 `BotariSDK` 메서드명을 기준으로 구현합니다.
- 새 앱은 서버 저장소, 문서 생성, 위치, 공유 목록 같은 기능을 직접 REST 탐색 없이 SDK 경로로 만듭니다.
권장 예시:
```js
const qr = await BotariSDK.generateQR({
data: 'https://botari.net',
type: 'png',
size: 256
});
```
금지 예시:
```js
fetch('/wp-json/botari/v1/qr');
const nonce = window.wpApiSettings?.nonce;
```
## 2. 앱/게임 API
### 2.0 권장 연동 방식
보따리앱/게임은 ZIP 안에 `assets/js/botari-sdk.js`를 포함하거나 플랫폼이 제공하는 SDK를
로드한 뒤 아래 메서드를 사용합니다.
```js
await BotariSDK.recordPlay();
await BotariSDK.submitScore(1200, { level: 3 });
const rankings = await BotariSDK.getRankings(10);
await BotariSDK.setStorage('menu', { categories: [] });
const menu = await BotariSDK.getStorage('menu');
await BotariSDK.addSharedItem('together', { message: '함께해요' });
const together = await BotariSDK.listSharedItems('together', { limit: 20 });
const match = await BotariSDK.findMatch('ranked-duel');
const me = await BotariSDK.getCurrentUser();
```
SDK가 실행 중인 앱의 권한과 컨텍스트를 자동 처리합니다. 앱 제작자는 내부 요청 경로를 직접 호출하지 마세요.
### 2.0.1 보따리앱 PWA 모드 / 홈 화면 추가 지원
보따리는 별도 PWA 앱 타입 대신 보따리앱의 PWA 모드를 지원합니다. 설치형 앱도 기본은
보따리앱 ZIP으로 등록하며, ZIP 루트에 `index.html`을 둡니다. `manifest.json`,
`service-worker.js`, 아이콘 파일이 있으면 제작자 파일을 우선 사용합니다.
사용자가 홈 화면에 추가하면 설치 대상은 보따리 전체가 아니라 해당 보따리앱 실행 URL입니다.
실행 페이지가 앱별 manifest를 제공하므로 설치된 아이콘은 `/bottariapp/{product_id}`를
standalone 화면으로 엽니다.
설치된 앱이 standalone 모드로 실행될 때는 보따리 실행 상단 바가 숨겨지고 앱 화면만 표시됩니다.
권장 구조:
```text
my-pwa/
├── index.html
├── app.js
├── style.css
├── manifest.json
├── service-worker.js
├── icon-192.png
└── icon-512.png
```
`manifest.json` 예시:
```json
{
"name": "My Botari PWA",
"short_name": "BotariPWA",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#f8384b",
"icons": [
{
"src": "./icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "./icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
```
구현 기준:
- `index.html`은 ZIP 루트의 진입 파일입니다.
- `manifest.json`은 앱 이름, standalone 표시 방식, 테마 색상, 192x192/512x512 앱 고유 아이콘을 포함합니다.
- 앱 이름, 홈 화면 이름, 테마 색상, 배경 색상은 ZIP 안의 실제 PWA `manifest.json` 값을 우선 사용합니다.
- 등록 화면의 PWA 색상 설정은 선택적 덮어쓰기/폴백입니다. 일반 제작자는 앱 ZIP의 `manifest.json`에 색상을 넣으면 됩니다.
- PWA 원본 아이콘에는 보따리 로고를 직접 넣지 않습니다. 플랫폼이 홈 화면용 아이콘을 만들 때 우하단에 작은 보따리 배지를 자동 합성합니다.
- `icons[].src`는 ZIP 내부 상대 경로를 사용합니다. 예: `./icon-192.png`, `./icon-512.png`. 빌드 후 `assets/icon-512.{hash}.png`처럼 파일명이 바뀐 경우에도 플랫폼이 실제 PWA manifest 위치를 기준으로 아이콘을 찾아 연결합니다.
- `manifest.json`은 앱 설치용 Web App Manifest여야 하며, 빌드 메타데이터나 번들 manifest를 대신 사용하지 않습니다.
- `service-worker.js`는 정적 자원 캐시와 오프라인 안내 화면에 사용합니다.
- 서버 기능은 일반 보따리앱과 동일하게 `BotariSDK`를 우선 사용합니다.
- 앱은 모바일 320px 이상에서 동작해야 합니다.
- 앱 안에서 설치 안내 모달을 열려면 `BotariSDK.openInstallGuide()`를 호출합니다.
- PWA 모드가 켜진 보따리앱에서 `manifest.json` 또는 `service-worker.js`가 없으면 보따리가 앱 이름, 아이콘, 선택적 등록 색상 또는 기본 색상을 바탕으로 기본 파일을 생성합니다. 직접 포함한 파일이 있으면 제작자 파일을 우선합니다.
서비스워커 주의:
- HTML/CSS/JS/이미지 같은 ZIP 내부 정적 자원 중심으로 캐시합니다.
- `/wp-json/botari/v1/*`, OCR, SMS, 결제, 저장소, 랭킹 같은 서버 API 응답을 영구 캐시하지 않습니다.
- 실행 도메인은 보따리에서 관리하므로, 앱 파일 참조는 가능한 한 ZIP 내부 상대 경로를 사용합니다.
- 새 버전을 배포할 때 캐시 이름을 바꿔 이전 파일이 남지 않게 합니다.
앱 안 설치 안내 버튼 예시:
```html
<button id="install-help">홈 화면에 설치하기</button>
<script>
document.getElementById('install-help').addEventListener('click', async () => {
if (!window.BotariSDK || !BotariSDK.openInstallGuide) {
window.open('https://botari.net/pwa-install-guide/', '_blank', 'noopener');
return;
}
await BotariSDK.openInstallGuide();
});
</script>
```
사용자용 상세 안내 페이지:
- `https://botari.net/pwa-install-guide/`
### 2.0.2 외부 라이브러리 정책
Three.js 같은 범용 프론트엔드 라이브러리는 보따리 공식 API로 제공하지 않습니다. 보따리
플랫폼은 `BotariSDK`, 저장소 API, 권한/결제/랭킹처럼 플랫폼과 직접 연결되는
기능만 제공합니다.
앱 제작자는 필요한 외부 라이브러리를 ZIP 내부에 포함하거나, 허용된 신뢰 CDN에서 직접
로드할 수 있습니다.
권장 방식:
- 보안/안정성이 중요하면 라이브러리 파일을 ZIP 안에 포함하고 상대 경로로 로드합니다.
- CDN을 사용할 경우 `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `unpkg.com`처럼 보따리 허용리스트에 있는 HTTPS 신뢰 CDN만 사용합니다. 그 외 외부 script는 업로드 정적 검사에서 차단됩니다.
- 라이브러리 버전은 고정합니다. 예: `three@0.160.0`
Three.js 예시:
```html
<!-- CDN 방식 -->
<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"></script>
<!-- ZIP 포함 방식 -->
<script src="./libs/three.min.js"></script>
```
### 2.0.3 SDK 우선 원칙
보따리앱 안에서는 런타임 객체나 REST 경로를 자동 탐색하지 말고 공식 `BotariSDK` 메서드를 먼저 사용합니다.
공식 SDK 메서드가 있는 기능:
- JSON 스토리지: `listStorage()`, `getStorage()`, `setStorage()`, `deleteStorage()`
- 휴대폰 소유자 인증: `sendSmsCode()`, `verifySmsCode()`
- QR/바코드 생성: `generateQR()`, `generateBarcode()`
- 문서 생성: `generateExcel()`, `createExcel()`, `generatePdf()`, `createPdf()`, `generateVectorPdf()`, `createVectorPdf()`
- OCR 이미지 텍스트 추출: `extractTextFromImage()`, `createOcrJob()`, `getOcrJob()`, `normalizeLabelOcr()`
- 현재 브라우저 위치 요청: `requestLocation()`
- 보따리 지도에서 현재 좌표 보정: `openLocationPicker()`
- 일반 콜택시 번호/수집 상태: `getNearbyCallTaxis()`, `getCallTaxiCoverage()`
- 게임/앱 이벤트: `recordPlay()`, `submitScore()`, `getRankings()`, `trackEvent()`
- PWA 설치 안내: `openInstallGuide()`
- 사용자 설정 읽기: `getSettings()`
- 현재 사용자 공개 표시 정보: `getCurrentUser()`
직접 REST 호출은 관리 화면, 서버 사이드 코드, SDK가 없는 레거시 앱에서만 사용합니다. 앱 실행 화면에서는 SDK 사용을 기본값으로 둡니다.
#### 로컬 개발용 stub 주의
앱 ZIP 안에 `BotariSDK`를 직접 구현하거나 noop(빈 동작) stub을 포함하지 마세요. 보따리 실행 환경에서는 플랫폼이 공식 `BotariSDK`를 자동 주입합니다.
로컬 개발 편의를 위해 fallback을 둘 수는 있지만, 반드시 `window.BotariSDK`가 없을 때만 임시 객체를 만들고 서버 저장이 성공한 것처럼 처리하지 마세요.
권장 패턴:
```js
const Botari = window.BotariSDK || {
async setStorage() { throw new Error('BotariSDK is not available in local preview'); },
async getStorage() { throw new Error('BotariSDK is not available in local preview'); },
async listStorage() { return { success: true, items: [], used_bytes: 0, quota_bytes: 0 }; },
async deleteStorage() { throw new Error('BotariSDK is not available in local preview'); }
};
```
AI로 앱을 만들 때도 `botari-ready/assets/js/botari-sdk.js` 같은 로컬 템플릿 stub을 공식 SDK로 판단하지 말고, 공개 문서의 `BotariSDK` 메서드를 기준으로 구현하세요.
### 2.0.4 보따리앱 사용자 설정
보따리앱 ZIP 안에 `settings.json`이 있으면 보따리가 내 보따리에서 `내 설정` 버튼과 설정 폼을 자동으로 제공합니다. 제작자는 앱 안에 별도 설정 페이지를 만들 필요가 없습니다.
앱은 실행 중 아래처럼 사용자별 설정값만 읽습니다.
```js
const settings = await BotariSDK.getSettings();
document.title = settings.title || '나의 앱';
```
`settings.json` 예시:
```json
{
"version": 1,
"groups": [
{
"key": "basic",
"label": "기본",
"fields": [
{ "key": "title", "label": "앱 제목", "type": "text", "default": "나의 앱" },
{ "key": "themeColor", "label": "테마 색상", "type": "color", "default": "#f8384b" }
]
}
]
}
```
지원 타입은 `text`, `textarea`, `number`, `boolean`, `color`, `select`, `image`, `url`입니다. 설정 파일이 없으면 설정 UI는 표시되지 않고 `getSettings()`는 빈 객체를 반환합니다.
### 2.0.5 브라우저 권한 요청 안내
카메라, 마이크, 클립보드, 푸시 알림은 앱이 실제로 해당 기능을 쓰기 직전에 사용자 클릭 흐름에서 요청합니다. 앱 시작 직후 한꺼번에 권한창을 띄우지 마세요.
위치는 개별 보따리앱에서 보따리 계정에 저장된 위치정보를 직접 조회하지 않습니다. 앱이 현재 브라우저 위치가 필요하면 `BotariSDK.requestLocation({ purpose })`를 사용하고, 실행 부모 페이지가 앱 이름과 사용 목적을 다시 안내한 뒤 브라우저 위치 권한을 요청합니다.
권장 UI 흐름:
- 기능 버튼을 누르기 전 짧게 안내합니다. 예: `QR 스캔을 위해 카메라 권한이 필요합니다.`
- 사용자가 `허용하기`를 누른 뒤 브라우저 권한 API를 호출합니다.
- 거부되면 브라우저 사이트 설정에서 다시 허용해야 한다고 안내합니다.
- 권한이 없어도 가능한 대체 흐름을 제공합니다. 예: 카메라 스캔 대신 이미지 업로드.
기본 예시:
```js
async function requestCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
stream.getTracks().forEach((track) => track.stop());
return true;
} catch (error) {
alert('카메라 권한이 필요합니다. 브라우저 사이트 설정에서 허용해 주세요.');
return false;
}
}
async function requestLocation() {
try {
const location = await BotariSDK.requestLocation({
purpose: '내 주변 장소를 찾기 위해 현재 위치가 필요합니다.',
enableHighAccuracy: true,
timeoutMs: 15000
});
return location;
} catch (error) {
alert('위치 권한이 필요합니다. 브라우저 사이트 설정에서 허용해 주세요.');
return null;
}
}
```
보따리 계정 또는 권한 센터에 저장된 위치정보는 개별 보따리앱에 자동 제공되지 않습니다. 개별 앱은 `requestLocation()`으로 별도 목적 고지와 사용자 동의를 받은 현재 브라우저 위치만 받을 수 있습니다.
현재 위치가 건물 반대편이나 도로 건너편으로 잡히면 지도 선택기로 좌표만 보정할 수 있습니다.
```js
try {
const corrected = await BotariSDK.openLocationPicker({
latitude: location.latitude,
longitude: location.longitude,
purpose: '택시를 부를 위치를 조정합니다.'
});
// corrected 좌표로 기존 nearby API를 다시 호출합니다.
} catch (error) {
if (error && error.code !== 'location_picker_cancelled') throw error;
}
```
선택 좌표는 서버에 저장되지 않고 현재 앱 실행 중에만 사용합니다. 지도 공급자와 렌더러는 보따리가 관리하므로 앱은 지도 URL이나 VWorld API를 직접 호출하지 않습니다.
보따리 실행 iframe은 카메라/마이크/클립보드 권한을 사용할 수 있도록 허용 속성을 제공합니다. 위치는 SDK bridge를 통해 부모 페이지가 요청합니다. 브라우저별 지원 차이가 있으므로 Safari/iOS에서는 대체 흐름을 준비하세요.
#### 푸시 알림 권한 모달 기준
보따리 알림은 기본적으로 사이트 내부 알림을 먼저 생성합니다. PWA 또는 브라우저 푸시 권한이 있는 사용자는 같은 알림을 기기 푸시로도 받을 수 있습니다.
푸시 권한 안내 모달은 다음 조건을 모두 만족할 때만 띄웁니다.
- 로그인한 사용자입니다.
- 읽지 않은 내부 알림이 있거나, 사용자가 담은/사용한 보따리템에서 제작자 알림이 발송되었습니다.
- 브라우저 `Notification.permission`이 `granted`가 아니거나 Push Subscription이 저장되어 있지 않습니다.
- 사용자가 최근에 `나중에`를 선택한 상태가 아닙니다. 기본 재노출 간격은 7일을 권장합니다.
- iOS에서는 홈 화면에 추가된 standalone PWA 상태이거나, 먼저 홈 화면 추가 안내를 보여줄 수 있는 상태입니다.
권한 모달 문구는 짧게 유지합니다. 예: `알림을 받으시려면 알림 권한을 허용해 주세요.` 버튼은 `알림 권한 설정`, `나중에`를 기본으로 둡니다.
거부 상태(`Notification.permission === "denied"`)에서는 브라우저 권한 요청창을 다시 띄울 수 없으므로 모달에서 브라우저/기기 설정에서 직접 허용해야 한다고 안내합니다. 권한이 없는 사용자에게도 내부 알림은 계속 남기며, 푸시가 없다는 이유로 알림 발송을 실패 처리하지 않습니다.
알림 읽음 처리는 내부 알림과 푸시 알림을 하나의 알림 ID로 묶습니다. 사용자가 사이트 알림함에서 읽거나, 푸시 알림을 눌러 해당 URL로 들어오면 같은 알림 ID를 읽음 처리합니다. 한쪽에서 읽으면 다른 쪽도 읽은 것으로 간주합니다.
### 2.0.5 앱 행동 이벤트 기록
플랫폼 기본 실행 통계는 서버가 자동으로 기록합니다. 앱 제작자는 실제 기능 사용량이나 실패 지점을 알고 싶을 때 `BotariSDK.trackEvent()`를 선택적으로 호출합니다.
사용 예시:
```js
await BotariSDK.trackEvent('qr_generated', {
format: 'png',
size: 256
});
await BotariSDK.trackEvent('png_downloaded', {
source: 'result_panel'
});
await BotariSDK.trackEvent('storage_save_failed', {
code: error.code || 'unknown'
});
```
운영 기준:
- 이벤트 이름은 `qr_generated`, `png_downloaded`처럼 짧은 영문/숫자/밑줄 형태를 권장합니다.
- `props`에는 개인정보, 전화번호, 이메일, 원문 이미지, 긴 본문을 넣지 않습니다.
- 서버는 세션 토큰과 제품 ID가 일치할 때만 저장합니다.
- 서버는 속성 키 최대 10개만 저장하고, 값은 120자 이하로 자릅니다.
- 민감 키 또는 값은 `[blocked]`로 대체됩니다. 차단 예: `name`, `email`, `phone`, `address`, `lat`, `lng`, `location`, `ocr_text`, `raw_text`, `content`, `image`, `base64`, `file`, `token`, `password`, `cookie`, `session`.
- base64, 긴 문자열, 이메일/전화번호처럼 보이는 값도 저장하지 않습니다.
- 같은 앱/세션/이벤트명 조합은 짧은 시간 안에 반복 저장되지 않도록 rate limit이 적용됩니다.
- 기록은 앱 로그의 `app_track_event` 이벤트로 저장되며 관리자와 앱 소유자 통계에 활용할 수 있습니다.
- 실패/오류는 `*_failed` 형태로 남기면 운영자가 빠르게 원인을 찾기 쉽습니다.
권장 이벤트 예시:
- `qr_generated`
- `png_downloaded`
- `svg_requested`
- `barcode_created`
- `ocr_started`
- `ocr_failed`
- `storage_saved`
- `storage_save_failed`
### 2.0.6 현재 사용자 표시
방명록, 롤링페이퍼, 예약, 투표, 내 랭킹 강조처럼 현재 사용자의 공개 표시 정보가 필요할 때 사용합니다.
```js
const me = await BotariSDK.getCurrentUser();
if (me.logged_in) {
console.log(me.user.id);
console.log(me.user.display_name);
console.log(me.user.avatar_url);
console.log(me.user.profile_url);
}
```
로그인 사용자는 내부 DB ID가 아닌 안정적인 공개 해시, 표시 이름, 아바타 URL, 좌판 URL만 받습니다. 비로그인 공개 실행에서는 `logged_in: false`, `user: null`을 반환합니다. 이메일, 전화번호, raw `user_login`, 역할·권한 정보는 반환하지 않습니다.
### 2.1 게임 점수 제출
**Endpoint**
- `POST /wp-json/botari/v1/game/{product_id}/score`
**요청**
- `score` (number) 점수
- `metadata` (object, optional) 부가 데이터
**예시**
```js
fetch(`${botariData.root}botari/v1/game/123/score`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': botariData.nonce
},
body: JSON.stringify({
score: 1200,
metadata: { level: 3, cleared: true }
})
});
```
### 2.2 게임 플레이 기록
**Endpoint**
- `POST /wp-json/botari/v1/games/{product_id}/play`
**비고**
- 게임 플레이 횟수/진입 추적용
- 공식 정책: 게임은 로그인 사용자만 실행 가능
- 게임 비구매자: 하루 1회 무료 실행
- 게임 구매자: 무제한 실행
- 일반 앱: 무료/구매/작성자 권한 기준으로 웹에서 바로 실행
### 2.3 게임 랭킹 조회
**Endpoint**
- `GET /wp-json/botari/v1/game/{product_id}/rankings?limit=10`
- `GET /wp-json/botari/v1/game/{product_id}/my-best-score`
## 3. 보호된 앱 컨텍스트
앱 내부 이벤트, 저장소, 점수, 공유 목록처럼 실행 권한이 필요한 기능은 `BotariSDK`가 보호된 앱 컨텍스트로 처리합니다. 앱 제작자는 내부 중계 경로와 액션명을 직접 호출하지 말고 공개 SDK 메서드만 사용하세요.
## 4. 보따리앱 JSON 스토리지 API
보따리앱이 메뉴판, 설정, 진행상태처럼 사용자별 앱 데이터를 저장할 때 사용하는 공식
저장소입니다. 서버 DB를 앱에 직접 열지 않고, 검증된 JSON API만 제공합니다.
### 4.1 저장 경계
데이터는 사용자, 앱, 저장 key 기준으로 격리됩니다.
예를 들어 메뉴판 앱에서 key를 `menu`로 쓰면, A 사장님의 메뉴판 데이터와 B
사장님의 메뉴판 데이터는 같은 앱이라도 서로 접근할 수 없습니다.
### 4.2 제한
- 저장 형식: JSON only
- key 길이: 1~100자
- key 허용 문자: 영문, 숫자, `.`, `_`, `:`, `-`
- 단일 value 크기: 최대 64KB
- 사용자-앱별 총 용량: 최대 5MB
- 권한: 제품 작성자, 관리자, 구매/내보따리 추가 사용자
- 금지: 크레딧, 구매권한, 결제상태, 승인상태 같은 민감 상태를 앱 스토리지 값으로 결정하면 안 됩니다.
### 4.3 SDK 사용
앱 실행 중에는 직접 REST 호출보다 `BotariSDK` 사용을 권장합니다. SDK가 실행 중인 앱의 권한과 컨텍스트를 처리합니다.
```js
const menuData = {
storeName: '조슈아 카페',
categories: [
{
name: '커피',
items: [
{ name: '아메리카노', price: 4500, soldOut: false }
]
}
]
};
await BotariSDK.setStorage('menu', menuData);
const saved = await BotariSDK.getStorage('menu');
console.log(saved.value);
const items = await BotariSDK.listStorage();
console.log(items.used_bytes, items.quota_bytes);
await BotariSDK.deleteStorage('menu');
```
저장소 읽기는 앱 화면 렌더링을 막지 않아야 합니다. 앱은 기본 UI를 먼저 그리고,
저장소 복원은 뒤에서 처리하세요. `listStorage()`와 `getStorage()`는 일시적인
네트워크/릴레이 장애일 때 `unavailable: true`와 함께 빈 목록 또는 `value: null`을
반환할 수 있습니다. 이 값은 “저장된 데이터 없음”처럼 안전하게 처리하고, 저장 버튼을
누르는 시점의 `setStorage()` 실패만 사용자에게 안내하세요.
```js
renderDefaultUI();
async function restoreDraft() {
const saved = await BotariSDK.getStorage('draft');
if (saved.value) {
applyDraft(saved.value);
}
if (saved.unavailable) {
console.warn('Storage restore skipped:', saved.code);
}
}
restoreDraft();
```
### 4.3.1 저장소 사용 조건
서버 JSON 저장소는 보따리 서버 자원을 사용하는 기능입니다. 앱 실행 자체와 분리해서 권한을 확인합니다.
- 비로그인 공개 실행: 저장소 사용 불가
- 로그인 사용자: 해당 보따리앱을 구매했거나 내 보따리에 추가한 경우 사용 가능
- 제품 작성자/관리자: 관리와 테스트를 위해 사용 가능
- 현재는 무료 정책이지만, 나중에 유료 저장소 플랜이 붙을 수 있도록 같은 정책 함수에서 검사합니다.
앱에서는 아래 에러 코드를 기준으로 안내하면 됩니다.
| code | 의미 | 권장 안내 |
| --- | --- | --- |
| `login_required` | 로그인이 필요함 | 로그인 후 다시 시도 |
| `library_required` | 내 보따리에 추가/구매 필요 | 앱을 내 보따리에 추가 후 사용 |
| `storage_quota_exceeded` | 앱별 저장 용량 초과 | 오래된 데이터를 삭제하거나 용량을 줄임 |
| `storage_plan_required` | 향후 유료 저장소 플랜 필요 | 저장소 플랜 안내 |
### 4.3.2 SDK 시그니처와 응답 스키마
앱 제작자는 아래 SDK 함수만 사용하면 됩니다. 앱 안에서 `window.botariAPI`, `botariData`, REST root, nonce를 직접 탐색하지 마세요.
```js
const list = await BotariSDK.listStorage();
const item = await BotariSDK.getStorage('menu');
const saved = await BotariSDK.setStorage('menu', { categories: [] });
const deleted = await BotariSDK.deleteStorage('menu');
```
초기 화면은 저장소 응답을 기다리지 말고 먼저 렌더링하세요. 저장소 읽기 장애는
앱 실행 실패가 아니며, SDK는 복원 실패 시 `unavailable: true`를 붙여 안전한
빈 응답으로 돌려줄 수 있습니다. 저장 실패(`setStorage`)와 삭제 실패(`deleteStorage`)는
사용자에게 알려야 하므로 예외를 직접 처리하세요.
**listStorage 응답**
```json
{
"success": true,
"items": [
{
"key": "menu",
"version": 1,
"bytes": 123,
"updated_at": "2026-05-16 16:28:00"
}
],
"used_bytes": 123,
"quota_bytes": 5242880
}
```
## 4.4 Shared Board/List API
현재 사용 가능한 SDK입니다. 예정 기능이 아닙니다.
같은 앱을 실행 중인 로그인 사용자끼리 짧은 목록을 공유할 때 사용합니다. “함께해요” 모집,
방명록, 예약 현황, 투표 옵션, 대기열, 공동 위시리스트처럼 앱 전체에 보이는 항목에 적합합니다.
앱 실행 중에는 직접 REST 호출보다 `BotariSDK`를 사용하세요. 내부 요청 경로나 액션명은 앱에서 직접 다루지 않습니다.
```js
await BotariSDK.addSharedItem('together', {
message: '함께해요',
roomId: 'room-1'
}, { expiresIn: 1800 });
const response = await BotariSDK.listSharedItems('together', { limit: 50 });
response.items.forEach((entry) => {
console.log(entry.id, entry.user.display_name, entry.item.message, entry.mine);
});
await BotariSDK.updateSharedItem(response.items[0].id, {
message: '모집이 마감되었습니다',
roomId: 'room-1',
status: 'closed'
}, { expiresIn: 600 });
await BotariSDK.deleteSharedItem(response.items[0].id);
```
정책:
- `boardKey`는 영문/숫자/`.`, `_`, `:`, `-` 1~64자입니다.
- 항목은 같은 앱과 같은 `boardKey` 안에서만 조회됩니다.
- 로그인된 앱 실행 세션에서만 사용할 수 있고, 비로그인 공개 실행에서는 차단됩니다.
- 항목은 기본 30분 후 만료되며, `expiresIn`은 최대 24시간입니다.
- 목록 조회는 최신순이며 `limit`은 최대 100입니다.
- 수정과 삭제는 본인이 등록한 활성 항목만 가능합니다. 수정으로 앱, `boardKey`, 작성자는 바꿀 수 없습니다.
- 응답 사용자 정보는 표시 이름과 avatar URL 같은 공개 표시 정보만 포함합니다.
- 실시간 이벤트가 필요하면 `joinRoom()`을 함께 쓰고, 방 발견/모집 목록은 `listSharedItems()`로 처리하세요.
사용자가 상대를 직접 고르는 모집형 앱 권장 패턴:
```js
await BotariSDK.addSharedItem('lobby', {
message: '대국 상대 찾는 중',
matchRoomId: 'baduk-' + Date.now()
}, { expiresIn: 600 });
const lobby = await BotariSDK.listSharedItems('lobby', { limit: 20 });
// 사용자가 상대를 고르면 해당 matchRoomId로 입장합니다.
const room = await BotariSDK.joinRoom(lobby.items[0].item.matchRoomId, { create: true });
```
## 4.5 Automatic Matchmaking API
자동 온라인 대전은 Shared Board/List에서 사용자가 상대를 고르게 하지 않고 서버가 대기자 한 명을 원자적으로 배정합니다.
```js
const match = await BotariSDK.findMatch('ranked-duel', {
timeout: 30000,
pollInterval: 1500,
expiresIn: 60
});
if (match.status === 'matched') {
const room = await BotariSDK.joinRoom(match.room_id, { create: true });
startGame(room, match.player_number, match.opponent);
}
// 사용자가 찾기 취소를 눌렀을 때
await BotariSDK.cancelMatch('ranked-duel');
```
응답 상태는 `waiting` 또는 `matched`입니다. 매칭되면 양쪽에 같은 `match_id`/`room_id`가 전달되고, 먼저 기다린 사용자는 `player_number: 1`, 뒤에 매칭된 사용자는 `player_number: 2`를 받습니다. 테스트모드/공개 실행 및 서로 다른 앱 버전은 같은 큐 키를 써도 섞이지 않습니다.
`findMatch()`는 기본 30초 동안 자동 확인합니다. 직접 폴링 UI를 만들 때는 `requestMatch()`, 대기를 끝낼 때는 `cancelMatch()`를 사용합니다. 자유 대전/랭크 대전처럼 규칙이 다르면 `queueKey`를 따로 사용하세요.
**getStorage 응답**
```json
{
"success": true,
"product_id": 123,
"key": "menu",
"value": { "categories": [] },
"version": 1,
"hash": "sha256...",
"updated_at": "2026-05-16 16:28:00"
}
```
**setStorage 응답**
```json
{
"success": true,
"product_id": 123,
"key": "menu",
"version": 1,
"hash": "sha256...",
"bytes": 123,
"updated_at": "2026-05-16 16:28:00"
}
```
**deleteStorage 응답**
```json
{
"success": true,
"key": "menu",
"deleted": true
}
```
### 4.4 REST 엔드포인트
REST는 로그인 사용자와 `X-WP-Nonce`가 있는 환경에서 사용할 수 있습니다. 비로그인/권한 없음 상태에서는 `login_required` 또는 `library_required` 같은 보따리 저장소 에러 코드가 반환됩니다. 보따리앱 iframe 안에서는 REST를 직접 탐색하지 말고 `BotariSDK`를 사용하세요.
**목록 조회**
- `GET /wp-json/botari/v1/app-storage/{product_id}`
**값 조회**
- `GET /wp-json/botari/v1/app-storage/{product_id}/{key}`
**값 저장**
- `POST /wp-json/botari/v1/app-storage/{product_id}/{key}`
- `PUT /wp-json/botari/v1/app-storage/{product_id}/{key}`
**값 삭제**
- `DELETE /wp-json/botari/v1/app-storage/{product_id}/{key}`
**저장 요청 예시**
```js
await fetch('https://api.botari.net/wp-json/botari/v1/app-storage/123/menu', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': botariData.nonce
},
body: JSON.stringify({
value: {
storeName: '조슈아 카페',
categories: []
}
})
});
```
**저장 응답 예시**
```json
{
"success": true,
"product_id": 123,
"key": "menu",
"version": 1,
"hash": "sha256...",
"bytes": 62,
"updated_at": "2026-05-16 16:28:00"
}
```
**조회 응답 예시**
```json
{
"success": true,
"product_id": 123,
"key": "menu",
"value": {
"storeName": "조슈아 카페",
"categories": []
},
"version": 1,
"hash": "sha256...",
"updated_at": "2026-05-16 16:28:00"
}
```
## 5. QR 생성 API
### 5.1 QR SVG/PNG 생성
**Endpoint**
- `GET /wp-json/botari/v1/qr`
**필수 파라미터**
- `data` (string): QR 내용
**선택 파라미터**
- `size` (64~1024, default: 256)
- `margin` (0~10, default: 2)
- `color` (HEX without #, default: 000000)
- `bg` (HEX without #, default: ffffff)
- `format` (`svg` or `png`, default: `svg`)
**응답 규칙**
- `format=svg` (기본): PHP 내부 생성기로 `svg` 문자열 반환
- `format=png`: 기존 서버 QR 런타임이 있는 경우 `png`에 `data:image/png;base64,...` 문자열 반환
- SVG 내부 생성은 현재 약 230바이트 이하 데이터에 최적화되어 있습니다. 긴 텍스트는 QR보다 짧은 공유 URL을 넣는 방식을 권장합니다.
**SDK 예시**
```js
try {
const qr = await BotariSDK.generateQR({
data: 'https://botari.net/app/123',
size: 256,
format: 'svg'
});
document.querySelector('#qr').innerHTML = qr.svg;
} catch (err) {
if (err.code === 'login_required') {
showNotice('SVG QR 코드는 보따리 계정으로 로그인하면 만들 수 있어요.');
}
}
```
**REST 예시**
```js
const params = new URLSearchParams({
data: 'https://botari.net/app/123',
size: '256',
margin: '2',
color: '000000',
bg: 'ffffff',
format: 'svg' // 또는 'png'
});
const res = await fetch(`${botariData.root}botari/v1/qr?${params.toString()}`, {
method: 'GET',
headers: { 'X-WP-Nonce': botariData.nonce },
});
const json = await res.json();
// format=svg: json.svg (string), 예: '<svg ...>'
// format=png: json.png (string), 예: 'data:image/png;base64,...'
```
**응답 예시**
```json
{
"format": "svg",
"svg": "<svg ...></svg>",
"data": "https://botari.net/app/123",
"size": 256,
"margin": 2,
"color": "#000000",
"bg": "#ffffff",
"cached": false
}
```
```json
{
"format": "png",
"png": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgA...",
"data": "https://botari.net/app/123",
"size": 256,
"margin": 2,
"color": "#000000",
"bg": "#ffffff",
"cached": false
}
```
## 5.2 바코드 생성 API
외부 보따리앱에서 바코드 생성 앱을 만들 때 사용합니다. SVG는 서버에서 바로 생성하며
별도 외부 라이브러리나 node 런타임에 의존하지 않습니다.
**Endpoint**
- `GET /wp-json/botari/v1/barcode`
**필수 파라미터**
- `value` (string): 바코드에 넣을 값
**선택 파라미터**
- `type` (`code128` or `ean13`, default: `code128`)
- `format` (`svg`, default: `svg`)
- `height` (40~400, default: 120)
- `scale` (1~8, default: 2)
- `margin` (0~80, default: 10)
- `color` (HEX without #, default: 111111)
- `bg` (HEX without #, default: ffffff)
- `text` (`1` or `0`, default: `1`)
**SDK 예시**
```js
const barcode = await BotariSDK.generateBarcode({
type: 'code128',
value: 'BOTARI-12345',
height: 120,
scale: 2
});
document.querySelector('#barcode').innerHTML = barcode.svg;
```
**REST 예시**
```js
const params = new URLSearchParams({
type: 'ean13',
value: '8801234567893',
format: 'svg'
});
const res = await fetch(`/wp-json/botari/v1/barcode?${params.toString()}`);
const json = await res.json();
```
**응답 예시**
```json
{
"format": "svg",
"type": "code128",
"value": "BOTARI-12345",
"svg": "<svg ...></svg>",
"width": 332,
"height": 120
}
```
## 5.3 휴대폰 소유자 인증 API
조문, 방명록, 예약, 문의처럼 보따리앱 안에서 특정 행동 전에 휴대폰 번호를 실제로 수신 가능한지 확인할 때 사용합니다. 이 기능은 휴대폰 소유자 인증 또는 문자 인증이며, 실명/명의/성인 본인인증이 아닙니다. UI와 문서에서도 본인인증, 실명인증, 성인인증, 명의확인이라고 표현하면 안 됩니다.
**Endpoint**
- `POST /wp-json/botari/v1/sms/send-code`
- `POST /wp-json/botari/v1/sms/verify-code`
**AI/앱 제작자 주의**
- 보따리앱 안에서는 REST 후보를 자동 탐색하지 말고 `BotariSDK.sendSmsCode()`와 `BotariSDK.verifySmsCode()`를 사용합니다.
- 이 API는 보따리 회원가입을 요구하지 않습니다. 허용된 보따리앱에서 휴대폰 소유자 인증만 통과하면 됩니다.
- `verified` 결과는 해당 앱의 특정 행동 허용에만 사용하고, 실명/성인/명의 판단에는 사용하지 않습니다.
**관리 위치**
- 관리자: `보따리 관리 > 시스템 > 문자 인증`
- 허용 제품 ID와 발송 제한은 관리자 화면에서 관리
- Solapi 키/시크릿/발신번호는 서버 환경변수로만 관리
**사용 조건**
- 서버에 `SOLAPI_API_KEY`, `SOLAPI_API_SECRET`, `SMS_FROM` 설정 필요
- `BOTARI_SMS_ALLOWED_PRODUCTS` 또는 `botari_sms_allowed_products` 옵션으로 허용된 앱만 발송 가능
- 기본 제한: 같은 번호 하루 5회, 같은 IP 하루 10회, 앱별 하루 100회, 재발송 60초 제한
**SDK 시그니처**
```js
await BotariSDK.sendSmsCode({
phone: '01012345678',
purpose: 'condolence_message', // optional, default: 'default'
productId: 123 // optional, 없으면 현재 실행 앱 ID 자동 감지
});
await BotariSDK.verifySmsCode({
phone: '01012345678',
code: '123456',
purpose: 'condolence_message',
productId: 123
});
```
**sendSmsCode 응답**
```json
{
"success": true,
"expires_in": 300,
"retry_after": 60,
"phone_last4": "5678"
}
```
**verifySmsCode 응답**
```json
{
"success": true,
"verified": true,
"verified_for": 1800,
"phone_last4": "5678"
}
```
**SDK 예시**
```js
await BotariSDK.sendSmsCode({
phone: '01012345678',
purpose: 'condolence_message'
});
const result = await BotariSDK.verifySmsCode({
phone: '01012345678',
code: '123456',
purpose: 'condolence_message'
});
if (result.verified) {
// 조문 등록 진행
}
```
## 6. 문서 생성 API
보따리앱에서 간단한 엑셀 파일과 PDF 파일을 서버에서 생성합니다. 브라우저 안에서 무거운 문서 생성 라이브러리를 직접 돌리지 않아도 되도록 제공하는 기능입니다.
문서 생성은 로그인 사용자만 사용할 수 있습니다. 앱 실행 환경에서는 `BotariSDK.generateExcel()`, `BotariSDK.generatePdf()`, `BotariSDK.generateVectorPdf()`를 우선 사용하세요. SDK는 실행 중인 보따리템의 `product_id`를 자동으로 붙입니다.
`generatePdf()`는 한국어 문서를 안정적으로 보이게 하기 위해 페이지를 이미지로 렌더링한 PDF입니다. 일러스트레이터 같은 편집 프로그램에서 텍스트와 도형을 다시 수정해야 하는 경우에는 `generateVectorPdf()`를 사용하세요.
### 6.1 제한과 보안
- 로그인 사용자만 사용 가능
- `product_id`가 있으면 해당 보따리템 접근 권한을 확인
- 사용자 기준 분당 20회 요청 제한
- Excel 시트 최대 5개
- Excel 시트당 컬럼 최대 50개, 행 최대 2000개
- 생성된 Excel 파일 최대 2MB
- `generatePdf()`는 제목, 부제목, 문단, 섹션, 행 데이터를 이미지형 PDF로 렌더링
- `generateVectorPdf()`는 텍스트, 사각형, 선을 PDF 객체로 생성
- 벡터 PDF는 서버의 Noto Sans CJK 계열 폰트가 있으면 해당 폰트를, 없으면 한국어 대체 폰트를 임베딩해 한글 텍스트도 PDF 객체로 생성합니다. Illustrator 호환을 위해 출력은 `.ai`가 아니라 편집 가능한 PDF로 제공합니다.
- 벡터 PDF는 사용된 글자만 폰트 서브셋으로 임베딩하며 최대 10MB까지 허용합니다.
- 서버 응답은 파일을 직접 저장하지 않고 `base64` 문자열로 반환
### 6.2 Excel SDK 사용법
가장 단순한 형태는 `columns`와 `rows`를 넘기는 방식입니다.
```js
const excel = await BotariSDK.generateExcel({
filename: 'guest-list.xlsx',
columns: ['name', 'phone', 'memo'],
rows: [
{ name: '홍길동', phone: '01012345678', memo: '참석' },
{ name: '김보따리', phone: '01098765432', memo: '미정' }
]
});
downloadBase64File(excel.base64, excel.mime, excel.filename);
```
여러 시트를 만들 때는 `sheets` 배열을 사용합니다.
```js
const excel = await BotariSDK.generateExcel({
filename: 'report.xlsx',
sheets: [
{
name: '참석자',
columns: ['name', 'phone'],
rows: [{ name: '홍길동', phone: '01012345678' }]
},
{
name: '정산',
columns: ['item', 'price'],
rows: [{ item: '꽃', price: 50000 }]
}
]
});
```
### 6.3 PDF SDK 사용법
```js
const pdf = await BotariSDK.generatePdf({
filename: 'notice.pdf',
title: '안내문',
subtitle: '보따리 문서 생성 예시',
paragraphs: [
'첫 번째 문단입니다.',
'두 번째 문단입니다.'
],
sections: [
{
heading: '세부 내용',
lines: ['항목 1', '항목 2']
}
],
rows: [
{ 이름: '홍길동', 상태: '확인' }
]
});
downloadBase64File(pdf.base64, pdf.mime, pdf.filename);
```
### 6.4 편집 가능한 벡터 PDF SDK 사용법
일러스트레이터, 피그마 변환 도구, PDF 편집기에서 텍스트/도형을 다시 잡아 수정해야 하는 문서는 `generateVectorPdf()`를 사용합니다. 좌표는 앱 개발자가 다루기 쉽게 좌상단 기준입니다.
```js
const vectorPdf = await BotariSDK.generateVectorPdf({
filename: 'label-design.pdf',
width: 595,
height: 842,
elements: [
{
type: 'rect',
x: 56,
y: 56,
width: 240,
height: 80,
fill: '#ffffff',
stroke: '#f8384b',
strokeWidth: 1
},
{
type: 'text',
text: 'Editable Label',
x: 76,
y: 88,
size: 18,
bold: true,
color: '#f8384b'
},
{
type: 'line',
x1: 76,
y1: 124,
x2: 260,
y2: 124,
stroke: '#111111',
strokeWidth: 0.5
}
]
});
if (vectorPdf.warnings?.includes('vector_pdf_font_unavailable')) {
console.warn('서버에서 한글 임베딩 폰트를 찾지 못했습니다.');
}
downloadBase64File(vectorPdf.base64, vectorPdf.mime, vectorPdf.filename);
```
지원 요소:
- `text`: `text`, `x`, `y`, `size`, `color`, `bold`
- `rect`: `x`, `y`, `width`, `height`, `fill`, `stroke`, `strokeWidth`
- `line`: `x1`, `y1`, `x2`, `y2`, `stroke`, `strokeWidth`
### 6.5 base64 파일 다운로드 예시
```js
function downloadBase64File(base64, mime, filename) {
const byteCharacters = atob(base64);
const byteNumbers = Array.from(byteCharacters, (char) => char.charCodeAt(0));
const blob = new Blob([new Uint8Array(byteNumbers)], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
```
### 6.6 REST API
SDK 사용을 권장합니다. REST는 서버 사이드 코드나 레거시 앱 호환용입니다.
**Excel 생성**
`POST /wp-json/botari/v1/document/excel`
```json
{
"filename": "report.xlsx",
"columns": ["name", "phone"],
"rows": [
{ "name": "홍길동", "phone": "01012345678" }
],
"product_id": 123
}
```
응답:
```json
{
"success": true,
"format": "xlsx",
"filename": "report.xlsx",
"mime": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"bytes": 4321,
"base64": "..."
}
```
**PDF 생성**
`POST /wp-json/botari/v1/document/pdf`
```json
{
"filename": "notice.pdf",
"title": "안내문",
"subtitle": "부제목",
"paragraphs": ["본문 문단"],
"sections": [
{ "heading": "섹션", "lines": ["내용 1", "내용 2"] }
],
"rows": [
{ "name": "홍길동", "status": "확인" }
],
"product_id": 123
}
```
응답:
```json
{
"success": true,
"format": "pdf",
"filename": "notice.pdf",
"mime": "application/pdf",
"bytes": 12345,
"base64": "..."
}
```
**편집 가능한 벡터 PDF 생성**
`POST /wp-json/botari/v1/document/vector-pdf`
```json
{
"filename": "label-design.pdf",
"width": 595,
"height": 842,
"elements": [
{ "type": "rect", "x": 56, "y": 56, "width": 240, "height": 80, "fill": "#ffffff", "stroke": "#f8384b" },
{ "type": "text", "text": "Editable Label", "x": 76, "y": 88, "size": 18, "bold": true, "color": "#f8384b" },
{ "type": "line", "x1": 76, "y1": 124, "x2": 260, "y2": 124, "stroke": "#111111" }
],
"product_id": 123
}
```
응답:
```json
{
"success": true,
"format": "pdf",
"kind": "vector",
"editable": true,
"filename": "label-design.pdf",
"mime": "application/pdf",
"bytes": 2345,
"base64": "...",
"warnings": []
}
```
### 6.7 문서 생성 에러 코드
- `document_forbidden`: 해당 보따리템에서 문서를 생성할 권한 없음
- `invalid_json`: JSON 본문이 아님
- `missing_sheets`: Excel 생성에 필요한 `sheets` 또는 `columns/rows` 누락
- `xlsx_unavailable`: 서버 Excel 생성 환경을 사용할 수 없음
- `document_too_large`: 생성된 Excel 파일이 2MB 초과
- `pdf_unavailable`: 서버 PDF 생성 환경을 사용할 수 없음
- `font_unavailable`: 한국어 PDF 렌더링 폰트를 찾을 수 없음
- `missing_content`: PDF에 넣을 내용이 없음
- `pdf_failed`: PDF 생성 실패
- `vector_pdf_font_unavailable`: 벡터 PDF 한글 임베딩 폰트를 찾지 못함
- `vector_pdf_font_parse_failed`: 벡터 PDF 한글 임베딩 폰트를 PDF용으로 해석하지 못함
- `vector_pdf_font_subset_unavailable`: 서버에 폰트 서브셋 도구가 없음
- `vector_pdf_font_subset_failed`: 사용 글자 기준 폰트 서브셋 생성 실패
## 7. OCR 이미지 텍스트 추출 API
이미지 안의 글자를 서버에서 추출합니다. 기본 엔진은 Gemini 3.1 Flash Lite이며, Gemini API 장애·쿼터·키 문제·타임아웃 시 내부 PaddleOCR로 fallback합니다. 앱 안에 OCR 모델을 넣지 말고 공식 `BotariSDK`를 사용하세요. 서버 부하를 줄이기 위해 OCR은 바로 실행하지 않고 큐에 등록한 뒤 순차 처리합니다.
### 7.1 언제 쓰나
- 영수증, 라벨, 문서 이미지에서 텍스트 가져오기
- 사용자가 올린 사진 속 문구를 앱 데이터로 변환하기
- 이미지 기반 신청서, 방명록, 명단 입력 보조
OCR은 100% 정확하지 않습니다. 앱에서는 사용자가 추출 결과를 확인하고 수정할 수 있게 만들어야 합니다.
### 7.2 제한과 보안
- 로그인 사용자만 사용 가능
- `jpg`, `png`, `webp`만 지원
- 이미지 최대 크기: 3MB
- 사용자가 선택한 원본 사진은 업로드 전 브라우저에서 리사이징해야 함
- OCR 권장 입력 크기: 긴 변 1600~2400px, JPEG/WebP 품질 0.8 전후
- 사용자당 대기/실행 OCR 작업 최대 5개
- 서버는 큐에서 한 번에 1개 작업만 처리
- 원본 이미지 base64는 처리 완료/실패 후 서버에서 삭제
- 완료/실패 결과는 24시간 뒤 자동 정리
- OCR 서비스는 Gemini 우선, PaddleOCR fallback 모두 외부에 공개하지 않고 보따리 서버 내부 Docker 네트워크에서만 호출
### 7.3 가장 쉬운 SDK 사용법
`extractTextFromImage()`는 큐 등록, 상태 조회, 완료 대기를 내부에서 처리합니다.
```js
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: 'image/png'
});
console.log(result.text);
console.log(result.lines);
console.log(result.quality);
if (result.quality && !result.quality.ok) {
showNotice(result.quality.hints.join('\n'));
}
```
OCR 실패는 앱에서 반드시 감지해야 합니다. `extractTextFromImage()`는 실패 시
`code`, `message`, `user_message`, `retryable`, `stage`, `job_id`가 포함된
에러를 던집니다. 화면에는 `user_message`를 보여주고, `retryable`이 `true`일 때만
재시도 버튼을 노출하세요.
```js
try {
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: 'image/jpeg',
normalize_label_ocr: true
});
showOcrResult(result);
} catch (error) {
showError(error.user_message || 'OCR에 실패했습니다.');
if (error.retryable) {
showRetryButton();
}
await BotariSDK.trackEvent('ocr_failed', {
code: error.code,
stage: error.stage,
retryable: !!error.retryable
});
}
```
타임아웃과 조회 간격을 조정할 수 있습니다.
```js
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: 'image/jpeg',
timeout_ms: 60000,
poll_interval_ms: 1500
});
```
### 7.4 큐를 직접 관리하는 SDK 사용법
OCR 진행 상태를 직접 UI에 보여주고 싶다면 작업 등록과 조회를 분리해서 사용합니다.
```js
const job = await BotariSDK.createOcrJob({
image_base64: imageBase64,
mime: 'image/webp'
});
showStatus('OCR 대기 중...');
const status = await BotariSDK.getOcrJob(job.job_id);
if (status.status === 'completed') {
showText(status.text);
} else if (status.status === 'failed') {
showError(status.message || 'OCR에 실패했습니다.');
}
```
### 7.5 식품 라벨 OCR Gemini 후처리
식품 라벨처럼 항목명이 많고 한글/영문/숫자/바코드가 섞인 이미지는 `normalize_label_ocr: true`를 사용하세요. 이 경우 Gemini가 이미지 OCR과 항목별 JSON 정리를 한 번에 시도하고, 실패해 PaddleOCR로 fallback된 경우에만 기존 Gemini 후처리를 추가 호출합니다.
가장 쉬운 방식은 `extractTextFromImage()`에 `normalize_label_ocr: true`를 함께 전달하는 것입니다. 정상 Gemini 경로에서는 응답에 `label_ocr`, `clean_text`, `fields`가 함께 포함됩니다.
```js
const result = await BotariSDK.extractTextFromImage({
image_base64: imageBase64,
mime: 'image/jpeg',
timeout_ms: 120000,
poll_interval_ms: 1500,
normalize_label_ocr: true
});
console.log(result.clean_text);
console.log(result.fields);
```
OCR 결과가 이미 있다면 `normalizeLabelOcr()`만 별도로 호출할 수 있습니다.
```js
const normalized = await BotariSDK.normalizeLabelOcr({
text: ocr.text,
lines: ocr.lines
});
console.log(normalized.fields.ingredients);
console.log(normalized.fields.storage);
```
REST 경로:
- `POST /wp-json/botari/v1/ocr/normalize-label`
응답 예시:
```json
{
"success": true,
"clean_text": "제품명: 제주 푸른콩 고추장\n식품유형: 고추장",
"fields": {
"product_name": "제주 푸른콩 고추장",
"food_type": "고추장",
"ingredients": "정제수, 고춧가루(국내산), 찹쌀(국내산)",
"storage": "개봉 전 서늘하고 건조한 곳, 개봉 후 냉장 보관"
},
"corrections": [
{ "from": "잡쌀", "to": "찹쌀", "reason": "식품 원재료 문맥" }
],
"confidence": 0.82
}
```
주의:
- Gemini OCR/라벨 정리는 이미지에서 보이는 텍스트만 추출·보정/병합하도록 제한하며, 이미지에 없는 정보를 새로 만들도록 설계하지 않았습니다.
- 식품 라벨 자동 입력에는 유용하지만 최종 표기 전 사용자가 반드시 확인해야 합니다.
- 이 기능은 로그인 사용자와 접근 가능한 보따리템 기준으로 제한됩니다.
### 7.6 이미지 업로드 전 리사이징 필수
삼성/아이폰 기본 사진은 보통 12MP(`4000x3000` 전후)라서 그대로 OCR에 보내면 3MB 제한을 넘거나 처리 비용이 커질 수 있습니다. 앱은 파일 선택 직후 브라우저에서 이미지를 줄인 뒤 OCR API에 전달해야 합니다.
권장값:
- 긴 변 기준 `1600~2400px`
- JPEG/WebP 품질 `0.8` 전후
- 리사이징 후에도 3MB를 넘으면 사용자에게 더 작은 이미지 선택을 안내
```js
function resizeImageForUpload(file, options = {}) {
const maxSide = options.maxSide || 2000;
const quality = options.quality || 0.82;
const mime = options.mime || 'image/jpeg';
return new Promise((resolve, reject) => {
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(url);
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
const width = Math.round(img.width * scale);
const height = Math.round(img.height * scale);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, width, height);
const image = canvas.toDataURL(mime, quality);
resolve({ image, mime, width, height });
};
img.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('이미지를 읽지 못했습니다.'));
};
img.src = url;
});
}
function dataUrlBytes(dataUrl) {
const base64 = dataUrl.split(',')[1] || '';
return Math.floor((base64.length * 3) / 4);
}
async function runOcrFromFile(file) {
const resized = await resizeImageForUpload(file, {
maxSide: 2000,
quality: 0.82,
mime: 'image/jpeg'
});
if (dataUrlBytes(resized.image) > 3 * 1024 * 1024) {
alert('이미지가 아직 큽니다. 더 작은 이미지로 다시 시도해 주세요.');
return;
}
const result = await BotariSDK.extractTextFromImage({
image: resized.image,
mime: resized.mime
});
document.querySelector('#ocrText').value = result.text;
}
```
### 7.6 OCR 품질 진단과 사용자 안내
OCR 성공 응답에도 `quality.ok`가 `false`일 수 있습니다. 이 경우 앱은 결과를 버리지 말고 사용자가 확인/수정할 수 있게 하면서, `quality.hints`를 안내문으로 보여줍니다.
대표 안내 흐름:
- `line_count`가 0이면: 글자가 화면에 크게 보이도록 다시 촬영 안내
- `avg_confidence`가 낮으면: 문서를 수평으로 맞추고 흔들림 없이 촬영 안내
- `brightness`가 낮거나 높으면: 밝은 곳 또는 반사광이 적은 곳에서 촬영 안내
- `contrast`가 낮으면: 글자와 배경 대비가 잘 보이게 촬영 안내
- `blur_score`가 낮으면: 초점을 맞추고 흔들림 없이 촬영 안내
```js
const result = await BotariSDK.extractTextFromImage({
image: resized.image,
mime: resized.mime
});
if (result.quality && !result.quality.ok) {
showNotice(result.quality.hints.join('\n'));
}
showEditableText(result.text);
```
`quality.hints`는 사용자에게 그대로 보여줄 수 있는 한국어 문장 배열입니다. 앱 제작 AI는 별도 규칙을 추측하지 말고 이 값을 우선 사용하세요.
### 7.7 REST API
SDK 사용을 권장합니다. REST는 서버 사이드 코드나 레거시 앱 호환용입니다.
**작업 등록**
`POST /wp-json/botari/v1/ocr/image`
```json
{
"image_base64": "...",
"mime": "image/png",
"product_id": 123
}
```
응답은 `202 Accepted`입니다.
```json
{
"success": true,
"queued": true,
"job_id": 12,
"status": "queued",
"status_url": "https://botari.net/wp-json/botari/v1/ocr/jobs/12"
}
```
**작업 조회**
`GET /wp-json/botari/v1/ocr/jobs/{job_id}`
완료 응답:
```json
{
"success": true,
"job_id": 12,
"status": "completed",
"engine": "paddleocr",
"lang": "korean",
"text": "추출된 전체 텍스트",
"lines": [
{ "text": "줄 단위 텍스트", "confidence": 0.98, "box": [] }
],
"quality": {
"ok": true,
"line_count": 1,
"text_length": 8,
"avg_confidence": 0.98,
"image_width": 2000,
"image_height": 1500,
"brightness": 128.4,
"contrast": 42.1,
"blur_score": 12.5,
"hints": []
}
}
```
실패 응답:
```json
{
"success": true,
"job_id": 12,
"status": "failed",
"code": "ocr_failed",
"message": "OCR 처리에 실패했습니다."
}
```
### 7.8 OCR 에러 코드
- `ocr_image_too_large`: 이미지가 3MB 초과
- `ocr_invalid_mime`: jpg/png/webp가 아님
- `ocr_missing_image`: 이미지 값 누락
- `ocr_queue_limit`: 대기/실행 중인 OCR 작업이 너무 많음
- `ocr_job_not_found`: 작업 ID가 없음
- `ocr_job_forbidden`: 다른 사용자의 OCR 작업 조회 시도
- `ocr_timeout`: SDK가 정한 시간 안에 작업이 끝나지 않음. `job_id`로 나중에 다시 조회 가능
- `ocr_service_unavailable`: 내부 OCR 서비스 일시 장애
## 7.9 내 보따리 외부 연동 API
내 보따리에 담긴 보따리템의 공개 정보는 외부 웹사이트, 메뉴판, POS 같은 서비스에서 서버 간 API로 읽을 수 있습니다.
### 연동 키 만들기
독립 `내 라이브러리` 화면을 마이페이지로 통합하는 동안 신규 연동 키를 발급하는 사용자 UI는 일시적으로 제공하지 않습니다. 기존에 발급된 `btex_...` 키와 외부 연동 API는 그대로 유지됩니다.
키는 외부 서비스의 서버 비밀값으로 보관합니다.
키를 브라우저 JavaScript, HTML, 공개 저장소, 보따리앱 ZIP에 넣지 마세요. 이 API는 외부 서비스의 백엔드에서 호출하는 용도이며 브라우저 CORS 사용을 지원하지 않습니다.
### 내 보따리 목록 조회
`GET https://api.botari.net/wp-json/botari/v1/external/library?page=1&per_page=50`
```http
Authorization: Bearer btex_your_token
```
응답 예시:
```json
{
"success": true,
"items": [
{
"id": 123,
"title": "보따리템 이름",
"summary": "공개 설명",
"detail_url": "https://botari.net/item/example/",
"thumbnail_url": "https://...",
"creator": {
"display_name": "제작자",
"profile_url": "https://creator.botari.net",
"avatar_url": "https://..."
},
"rights": {
"third_party_copyright": "none",
"light": "green",
"external_file_delivery": false,
"notice": "이 API는 공개 메타데이터만 제공합니다. 외부 파일 사용권은 상품 조건과 별도 허락을 확인해야 합니다."
}
}
],
"pagination": { "page": 1, "per_page": 50, "total": 1 }
}
```
- 반환 범위는 현재 로그인 사용자의 `내 보따리`에 있고 공개 상태인 보따리템입니다.
- 제목, 설명, 상세 URL, 공개 썸네일, 제작자 공개 프로필만 제공합니다.
- 원본 다운로드 URL, 구매자 개인정보, 내부 사용자 ID는 제공하지 않습니다.
- `rights.light`가 `red`인 상품은 외부 사용처를 등록할 수 없습니다.
- `green`도 외부 사용권을 자동으로 뜻하지 않습니다. 상품 조건과 제작자 허락을 별도로 확인해야 합니다.
### 사용처 신고
외부 서비스가 실제로 보따리 정보를 표시하면 해당 URL을 신고해야 합니다.
`POST https://api.botari.net/wp-json/botari/v1/external/placements`
```http
Authorization: Bearer btex_your_token
Content-Type: application/json
```
```json
{
"product_id": 123,
"target_type": "website",
"target_url": "https://shop.example.com/menu",
"purpose": "매장 메뉴판 상품 소개"
}
```
- `target_type`: `website`, `signage`, `pos`, `print`, `social`, `other`
- `target_url`: 실제 확인 가능한 `https` 주소
- 같은 키·상품·유형·URL로 다시 보내면 새 행을 늘리지 않고 최종 확인 시각을 갱신합니다.
- 사용처 신고는 자체 신고 기록이며 Botari의 검증 또는 사용권 부여를 뜻하지 않습니다.
신고 목록은 `GET /wp-json/botari/v1/external/placements`, 신고 해제는 `DELETE /wp-json/botari/v1/external/placements/{id}`로 처리합니다. 외부 키는 자신이 만든 신고만 조회하고 해제할 수 있습니다.
키를 폐기하면 그 키로 등록한 사용처는 연결 해제 상태가 되며 이후 모든 API 호출이 거부됩니다. 키와 신고 사용처의 사용자 관리 화면은 마이페이지 통합 후 다시 제공합니다.
### 제한과 오류
- 목록 조회: 키당 분당 120회
- 사용처 쓰기: 키당 분당 60회
- 활성 키: 사용자당 최대 5개, 발급 후 1년
- `external_token_required`, `external_token_invalid`: 키 누락·만료·폐기
- `external_server_only`: 브라우저 `Origin`이 포함된 호출
- `external_rate_limited`: 호출량 초과
- `library_item_required`: 내 보따리에 없는 상품
- `external_use_blocked`: 제3자 저작권 포함 상품
- `invalid_target_url`: `https`가 아닌 사용처
## 8. 에러 처리
- `rest_not_logged_in` / 권한 오류: 로그인 필요
- `login_required`: SDK에서 QR/로그인 필요 응답을 표준화한 오류
- `not_allowed`: 권한/정책 위반
- `storage_forbidden`: 앱 스토리지 접근 권한 없음
- `invalid_storage_key`: 앱 스토리지 key 형식 오류
- `missing_value`: 앱 스토리지 저장 요청에 `value` 누락
- `storage_value_too_large`: 단일 저장값 64KB 초과
- `storage_quota_exceeded`: 사용자-앱별 총 5MB 초과
- `missing_data`: QR data 누락
- `invalid_format`: `format` 값이 지원 범위 밖인 경우
- `qr_unavailable`, `qr_failed`, `qr_png_unsupported`: QR 생성 실패
- `qr_data_too_long`: 내부 QR SVG 생성 범위를 초과한 데이터
- `invalid_barcode_type`: 바코드 type이 지원 범위 밖인 경우
- `invalid_barcode_value`, `invalid_barcode_checksum`, `barcode_value_too_long`: 바코드 값 검증 실패
- `sms_not_configured`, `sms_not_allowed`, `sms_resend_wait`, `sms_code_expired`, `invalid_sms_code`: 휴대폰 소유자 인증/SMS 문자 인증 오류
- `document_forbidden`, `missing_sheets`, `document_too_large`, `pdf_unavailable`, `font_unavailable`: 문서 생성 오류
- `ocr_image_too_large`, `ocr_invalid_mime`, `ocr_queue_limit`, `ocr_timeout`, `ocr_service_unavailable`: OCR 오류
문서가 계속 비어 보이면 운영 페이지 `/docs` 에서 `botari_doc` 값이
`api-guide`인지 확인해주세요.