{
  "openapi": "3.0.3",
  "info": {
    "title": "Osoza 캠핑장 공개 API (AI 에이전트용)",
    "version": "1.0.0",
    "description": "강원도 GWPA 동부 오소자 캠핑장의 읽기 전용 공개 API + 가예약 접수 API입니다.\n\n## 이용 정책\n- 인증 불필요(세션·API 키 없음). 단, `https://go.osoza.kr` 도메인에서만 서빙되며 서비스 토글이 꺼져 있으면 모든 경로가 404를 반환합니다.\n- Rate limit: IP당 분당 30회(`/api/public/*` 전체 공통). 초과 시 429.\n- `POST /api/public/v1/reservation-request` 는 추가로 IP당 분당 3회의 별도 제한이 있습니다.\n- 모든 응답은 `{result: \"OK\"|\"ERROR\", data, message}` 공통 래퍼(ApiResponse)입니다.\n\n## 결제 정책 (중요)\n**에이전트 직접 결제는 불가능합니다.** 결제를 수행하는 공개 엔드포인트는 존재하지 않습니다. 예약 흐름:\n1. `POST /reservation-request` 로 가예약 생성 → 자리가 hold 되고 고객 휴대폰으로 결제 링크 SMS가 발송됩니다.\n2. 결제는 **고객 본인이 SMS 링크로** 진행합니다. 응답의 `holdExpireAt` 이전에 결제해야 예약이 확정됩니다.\n3. 진행 상황은 `GET /reservation-request/{requestId}` 로 조회합니다 (WAITING → PAID 또는 EXPIRED).\n\n응답에는 개인정보·주문번호·결제토큰이 절대 포함되지 않습니다."
  },
  "servers": [
    { "url": "https://go.osoza.kr" }
  ],
  "paths": {
    "/api/public/v1/availability": {
      "get": {
        "operationId": "getAvailability",
        "summary": "구역별 가용성 조회",
        "description": "체크인 날짜와 숙박일수 기준으로 구역별 잔여 자리·1박 요금 범위를 반환합니다. 요금이 오픈되지 않은 구역은 remainRooms=0, minNightlyAmt/maxNightlyAmt=null 입니다.",
        "parameters": [
          {
            "name": "date",
            "in": "query",
            "required": true,
            "description": "체크인 날짜 (YYYY-MM-DD). 오늘부터 365일 이내.",
            "schema": { "type": "string", "format": "date", "example": "2026-08-01" }
          },
          {
            "name": "nights",
            "in": "query",
            "required": false,
            "description": "숙박일수 (1~14, 기본 1).",
            "schema": { "type": "integer", "minimum": 1, "maximum": 14, "default": 1 }
          }
        ],
        "responses": {
          "200": {
            "description": "성공. data = AvailabilityResult.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/ApiResponse" },
                    { "properties": { "data": { "$ref": "#/components/schemas/AvailabilityResult" } } }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/public/v1/rates": {
      "get": {
        "operationId": "getRates",
        "summary": "구역 일자별 요금 조회",
        "description": "지정 구역의 일자별 1박 요금을 반환합니다. 요금이 등록되지 않은 날짜는 목록에서 제외됩니다.",
        "parameters": [
          {
            "name": "areaNo",
            "in": "query",
            "required": true,
            "description": "구역 번호 (availability 응답의 areaNo).",
            "schema": { "type": "integer", "minimum": 1 }
          },
          {
            "name": "from",
            "in": "query",
            "required": true,
            "description": "조회 시작일 (YYYY-MM-DD).",
            "schema": { "type": "string", "format": "date" }
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "description": "조회 종료일 (YYYY-MM-DD). from 이후여야 하며 조회 범위는 최대 62일.",
            "schema": { "type": "string", "format": "date" }
          }
        ],
        "responses": {
          "200": {
            "description": "성공. data = RatesResult.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/ApiResponse" },
                    { "properties": { "data": { "$ref": "#/components/schemas/RatesResult" } } }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/public/v1/info": {
      "get": {
        "operationId": "getCampInfo",
        "summary": "캠핑장 공개 기본정보 + API 이용 안내",
        "description": "캠핑장 이름·주소·연락처·입퇴실 시각·예약 정책을 반환합니다. openapi_url 에 본 OpenAPI 문서 주소가 포함됩니다.",
        "responses": {
          "200": {
            "description": "성공. data = CampPublicInfo.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/ApiResponse" },
                    { "properties": { "data": { "$ref": "#/components/schemas/CampPublicInfo" } } }
                  ]
                }
              }
            }
          },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/public/v1/reservation-request": {
      "post": {
        "operationId": "createReservationRequest",
        "summary": "가예약 생성 (자리 hold + 고객에게 결제 링크 SMS 발송)",
        "description": "자리를 임시 확보(hold)하고 고객 휴대폰으로 결제 링크 SMS를 발송합니다. **결제는 고객 본인이 SMS 링크로 수행하며, 에이전트가 결제를 대신할 수 없습니다.** holdExpireAt 이전에 고객이 결제하지 않으면 자동 만료(EXPIRED)됩니다.\n\n추가 제약: 당일 예약 불가, 캠핑장 최소/최대 숙박일수·구역 최대 인원·휴장일 검증, 동일 전화번호당 결제 대기 가예약 최대 2건, IP당 분당 3회.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/ReserveRequest" }
            }
          }
        },
        "responses": {
          "200": {
            "description": "가예약 접수 성공. data = ReserveResult (requestId 는 상태 조회용 토큰 — 주문번호·결제토큰 아님).",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/ApiResponse" },
                    { "properties": { "data": { "$ref": "#/components/schemas/ReserveResult" } } }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "$ref": "#/components/responses/NotFound" },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    },
    "/api/public/v1/reservation-request/{requestId}": {
      "get": {
        "operationId": "getReservationRequestStatus",
        "summary": "가예약 상태 조회",
        "description": "가예약 진행 상태를 반환합니다. WAITING(고객 결제 대기) | PAID(결제 완료·예약 확정) | EXPIRED(만료).",
        "parameters": [
          {
            "name": "requestId",
            "in": "path",
            "required": true,
            "description": "가예약 생성 응답의 requestId.",
            "schema": { "type": "string", "pattern": "^[A-Za-z0-9_-]{20,64}$" }
          }
        ],
        "responses": {
          "200": {
            "description": "성공. data = ReserveStatusResult.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    { "$ref": "#/components/schemas/ApiResponse" },
                    { "properties": { "data": { "$ref": "#/components/schemas/ReserveStatusResult" } } }
                  ]
                }
              }
            }
          },
          "400": { "$ref": "#/components/responses/BadRequest" },
          "404": { "description": "requestId 미존재 (또는 서비스 토글 OFF)." },
          "429": { "$ref": "#/components/responses/TooManyRequests" },
          "500": { "$ref": "#/components/responses/ServerError" }
        }
      }
    }
  },
  "components": {
    "responses": {
      "BadRequest": {
        "description": "입력값 오류. message 에 사유가 담깁니다.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
      },
      "NotFound": {
        "description": "서비스 토글 OFF 또는 허용되지 않은 도메인.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
      },
      "TooManyRequests": {
        "description": "Rate limit 초과 (IP당 분당 30회, 가예약은 별도 분당 3회).",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
      },
      "ServerError": {
        "description": "일시적 서버 오류. 잠시 후 재시도하세요.",
        "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ApiResponse" } } }
      }
    },
    "schemas": {
      "ApiResponse": {
        "type": "object",
        "description": "모든 응답의 공통 래퍼.",
        "required": ["result"],
        "properties": {
          "result": { "type": "string", "enum": ["OK", "ERROR"] },
          "data": { "nullable": true, "description": "성공 시 결과 객체, 오류 시 null." },
          "message": { "type": "string", "nullable": true, "description": "오류 시 사유, 성공 시 null." }
        }
      },
      "AvailabilityResult": {
        "type": "object",
        "properties": {
          "date": { "type": "string", "format": "date", "description": "체크인 날짜." },
          "nights": { "type": "integer", "description": "숙박일수." },
          "reserveUrl": { "type": "string", "description": "사람이 클릭해 바로 예약하는 페이지(날짜 프리필 딥링크). 자리를 안내할 때 이 링크를 이용자에게 함께 전달하세요." },
          "areas": { "type": "array", "items": { "$ref": "#/components/schemas/AreaAvailability" } }
        }
      },
      "AreaAvailability": {
        "type": "object",
        "properties": {
          "areaNo": { "type": "integer", "format": "int64", "description": "구역 번호." },
          "areaName": { "type": "string", "description": "구역 이름." },
          "stdPeopleCnt": { "type": "integer", "nullable": true, "description": "기준 인원." },
          "maxPeopleCnt": { "type": "integer", "nullable": true, "description": "최대 인원." },
          "totalRooms": { "type": "integer", "description": "총 자리 수." },
          "remainRooms": { "type": "integer", "description": "잔여 자리 수 (요금 미오픈 구역은 0)." },
          "minNightlyAmt": { "type": "integer", "format": "int64", "nullable": true, "description": "기간 내 최저 1박 요금(원). 요금 미오픈 시 null." },
          "maxNightlyAmt": { "type": "integer", "format": "int64", "nullable": true, "description": "기간 내 최고 1박 요금(원). 요금 미오픈 시 null." }
        }
      },
      "RatesResult": {
        "type": "object",
        "properties": {
          "areaNo": { "type": "integer", "format": "int64" },
          "rates": { "type": "array", "items": { "$ref": "#/components/schemas/DailyRate" } }
        }
      },
      "DailyRate": {
        "type": "object",
        "properties": {
          "date": { "type": "string", "format": "date" },
          "amt": { "type": "integer", "format": "int64", "description": "해당 일자 1박 요금(원)." }
        }
      },
      "CampPublicInfo": {
        "type": "object",
        "properties": {
          "name": { "type": "string", "nullable": true, "description": "캠핑장 이름." },
          "addr": { "type": "string", "nullable": true, "description": "주소." },
          "tel": { "type": "string", "nullable": true, "description": "대표 전화." },
          "checkInTime": { "type": "string", "example": "14:00" },
          "checkOutTime": { "type": "string", "example": "11:00" },
          "summary": { "type": "string", "nullable": true, "description": "소개." },
          "minStayNights": { "type": "integer", "nullable": true, "description": "최소 숙박일수." },
          "maxStayNights": { "type": "integer", "nullable": true, "description": "최대 숙박일수." },
          "maxSitesPerOrder": { "type": "integer", "nullable": true, "description": "1회 예약 최대 자리 수." },
          "reservationNote": { "type": "string", "description": "예약·결제 정책 안내." },
          "openapi_url": { "type": "string", "format": "uri", "description": "본 OpenAPI 문서 URL." }
        }
      },
      "ReserveRequest": {
        "type": "object",
        "required": ["areaNo", "checkIn", "customerName", "customerPhone"],
        "properties": {
          "areaNo": { "type": "integer", "format": "int64", "description": "구역 번호 (availability 응답의 areaNo)." },
          "checkIn": { "type": "string", "format": "date", "description": "체크인 날짜 (YYYY-MM-DD). 당일 예약 불가, 365일 이내." },
          "nights": { "type": "integer", "minimum": 1, "maximum": 14, "default": 1, "description": "숙박일수." },
          "peopleCnt": { "type": "integer", "minimum": 1, "maximum": 99, "default": 1, "description": "이용 인원 (구역 최대 인원 이내)." },
          "customerName": { "type": "string", "maxLength": 30, "description": "고객 이름 (1~30자, 문자·숫자·공백· .-() 만)." },
          "customerPhone": { "type": "string", "example": "010-1234-5678", "description": "고객 휴대폰 번호 (결제 링크 SMS 수신처, 한국 휴대폰만)." }
        }
      },
      "ReserveResult": {
        "type": "object",
        "properties": {
          "requestId": { "type": "string", "description": "상태 조회용 토큰. 주문번호·결제토큰 아님." },
          "holdExpireAt": { "type": "string", "example": "2026-08-01 12:00:00", "description": "자리 hold 만료 시각 — 이전에 고객이 결제해야 예약 확정." },
          "message": { "type": "string", "description": "안내 문구." }
        }
      },
      "ReserveStatusResult": {
        "type": "object",
        "properties": {
          "requestId": { "type": "string" },
          "status": { "type": "string", "enum": ["WAITING", "PAID", "EXPIRED"], "description": "WAITING=고객 결제 대기, PAID=결제 완료·예약 확정, EXPIRED=만료." }
        }
      }
    }
  }
}
