openapi: 3.1.0 info: title: Aster.duck API description: | Aster.duck 플랫폼 API — CoffeeDuck & WineDuck 테이스팅 노트 서비스. 커피와 와인을 검색하고, 테이스팅 노트를 기록·관리할 수 있습니다. ## 인증 - `/api/auth/login`으로 JWT 토큰 발급 (24시간 유효) - 인증이 필요한 API는 `Authorization: Bearer {token}` 헤더 사용 - 검색/조회 API는 대부분 인증 불필요 ## 다국어 - `Accept-Language` 헤더로 응답 언어 변경: `ko`, `ja`, `en` version: 1.0.0 contact: name: Aster.duck url: https://asterduck.koalstudio.com license: name: MIT servers: - url: https://coffeeduckbe-production.up.railway.app/api description: Production tags: - name: Auth description: 공용 인증 (로그인, 회원가입, 토큰) - name: CoffeeDuck - Search description: 커피 검색 & 탐색 - name: CoffeeDuck - Tasting description: 커피 테이스팅 노트 CRUD - name: CoffeeDuck - Coffee description: 커피 등록 - name: WineDuck - Search description: 와인 검색 & 지역 탐색 - name: WineDuck - Tasting description: 와인 테이스팅 노트 CRUD - name: WineDuck - Wine description: 와인 등록 - name: WineDuck - Cellar description: 와인 셀러(보유 와인) 관리 - name: WineDuck - Discovery description: 커뮤니티 팔레트 집계 / 사용자 취향 기반 추천 # ────────────────────────────────────────── # Auth # ────────────────────────────────────────── paths: /auth/login: post: tags: [Auth] summary: 로그인 (JWT 토큰 발급) operationId: login requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: type: string example: "myuser" password: type: string example: "mypassword" responses: "200": description: 로그인 성공 content: application/json: schema: type: object properties: success: type: boolean example: true token: type: string description: JWT Bearer Token (24시간 유효) user: $ref: "#/components/schemas/UserInfo" /auth/register: post: tags: [Auth] summary: 회원가입 operationId: register requestBody: required: true content: application/json: schema: type: object required: [username, password, name] properties: username: type: string password: type: string name: type: string description: 표시 이름 responses: "200": description: 가입 성공 /auth/verify: get: tags: [Auth] summary: 토큰 검증 operationId: verifyToken security: - bearerAuth: [] responses: "200": description: 토큰 유효 content: application/json: schema: type: object properties: success: type: boolean user: $ref: "#/components/schemas/UserInfo" /auth/change-password: post: tags: [Auth] summary: 비밀번호 변경 operationId: changePassword security: - bearerAuth: [] requestBody: required: true content: application/json: schema: type: object required: [current_password, new_password] properties: current_password: type: string new_password: type: string responses: "200": description: 변경 성공 # ────────────────────────────────────────── # CoffeeDuck - Search # ────────────────────────────────────────── /coffee: get: tags: [CoffeeDuck - Search] summary: 커피 목록 (검색 + 페이지네이션) operationId: listCoffees parameters: - name: search in: query schema: type: string description: 검색 키워드 - name: search_type in: query schema: type: string enum: [all, name, supplier, country, varietal, flavor_notes, flavor_note_ids] default: all - name: flavor_note_ids in: query schema: type: string description: 플레이버 노트 ID (쉼표 구분, search_type=flavor_note_ids와 함께 사용) - name: sort_by in: query schema: type: string enum: [created_at, name, avg_rating] default: created_at - name: sort_dir in: query schema: type: string enum: [asc, desc] default: desc - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 10 responses: "200": description: 검색 결과 content: application/json: schema: type: object properties: success: type: boolean coffees: type: array items: $ref: "#/components/schemas/CoffeeListItem" pagination: $ref: "#/components/schemas/Pagination" post: tags: [CoffeeDuck - Coffee] summary: 커피 등록 operationId: createCoffee security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoffeeCreateRequest" responses: "200": description: 등록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string coffee_id: type: integer /coffee/{coffee_id}: get: tags: [CoffeeDuck - Search] summary: 커피 상세 조회 operationId: getCoffee parameters: - name: coffee_id in: path required: true schema: type: integer responses: "200": description: 커피 상세 content: application/json: schema: type: object properties: success: type: boolean coffee: $ref: "#/components/schemas/CoffeeDetail" /coffee/search-by-name: get: tags: [CoffeeDuck - Search] summary: 커피 이름 검색 (정확 매칭 + 유사 결과) description: 중복 확인에 활용. 정확 매칭이 있으면 coffee에 반환, 없으면 null. 유사 결과는 similar에 최대 5개. operationId: searchCoffeeByName parameters: - name: name in: query required: true schema: type: string responses: "200": description: 검색 결과 content: application/json: schema: type: object properties: success: type: boolean coffee: nullable: true description: 정확 매칭 결과 (없으면 null) similar: type: array items: type: object search_name: type: string /flavor-notes: get: tags: [CoffeeDuck - Search] summary: 플레이버 노트 전체 목록 description: | 계층 구조: Level 1 (대분류) → Level 2 (중분류) → Level 3 (세부 향). 테이스팅 노트에서는 세부 향의 ID를 사용. operationId: listFlavorNotes parameters: - name: Accept-Language in: header schema: type: string enum: [ko, ja, en] responses: "200": description: 플레이버 노트 목록 content: application/json: schema: type: object properties: success: type: boolean flavor_notes: type: array items: $ref: "#/components/schemas/FlavorNote" /coffeeduck/flavor-notes/popular: get: tags: [CoffeeDuck - Search] summary: 인기 플레이버 노트 (Top 12) operationId: popularFlavorNotes responses: "200": description: 인기 플레이버 노트 /coffee/{coffee_id}/user-flavor-notes: get: tags: [CoffeeDuck - Search] summary: 커피별 사용자 향미 통계 operationId: coffeeUserFlavorNotes parameters: - name: coffee_id in: path required: true schema: type: integer responses: "200": description: 향미 통계 /varieties: get: tags: [CoffeeDuck - Search] summary: 커피 품종 전체 목록 operationId: listVarieties responses: "200": description: 품종 목록 content: application/json: schema: type: object properties: success: type: boolean varieties: type: array items: type: string # ────────────────────────────────────────── # CoffeeDuck - Tasting # ────────────────────────────────────────── /coffeeduck/tasting/coffees/{coffee_id}/notes: post: tags: [CoffeeDuck - Tasting] summary: 커피 테이스팅 노트 등록 operationId: createCoffeeTasting security: - bearerAuth: [] parameters: - name: coffee_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoffeeTastingCreateRequest" responses: "200": description: 등록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string tasting_note_id: type: integer get: tags: [CoffeeDuck - Tasting] summary: 특정 커피의 테이스팅 목록 operationId: listCoffeeTastings security: - bearerAuth: [] parameters: - name: coffee_id in: path required: true schema: type: integer - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 20 responses: "200": description: 테이스팅 목록 /coffeeduck/tasting/notes/{note_id}: get: tags: [CoffeeDuck - Tasting] summary: 테이스팅 노트 상세 조회 operationId: getCoffeeTasting security: - bearerAuth: [] parameters: - name: note_id in: path required: true schema: type: integer responses: "200": description: 테이스팅 상세 put: tags: [CoffeeDuck - Tasting] summary: 테이스팅 노트 수정 (본인만) operationId: updateCoffeeTasting security: - bearerAuth: [] parameters: - name: note_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CoffeeTastingUpdateRequest" responses: "200": description: 수정 성공 delete: tags: [CoffeeDuck - Tasting] summary: 테이스팅 노트 삭제 (본인만) operationId: deleteCoffeeTasting security: - bearerAuth: [] parameters: - name: note_id in: path required: true schema: type: integer responses: "200": description: 삭제 성공 /coffeeduck/tasting/coffees/{coffee_id}/stats: get: tags: [CoffeeDuck - Tasting] summary: 커피 커뮤니티 통계 (공개) description: 인증 불필요. 해당 커피의 평균 팔레트, 평균 평점, 총 테이스팅 수. operationId: coffeeTastingStats parameters: - name: coffee_id in: path required: true schema: type: integer responses: "200": description: 통계 content: application/json: schema: type: object properties: success: type: boolean stats: type: object properties: total_tastings: type: integer avg_rating: type: number avg_palate: type: object properties: avg_acidity: type: number avg_body: type: number avg_sweetness: type: number avg_bitterness: type: number avg_finish: type: number /coffeeduck/tasting/users/{user_id}/stats: get: tags: [CoffeeDuck - Tasting] summary: 내 테이스팅 통계 (본인만) operationId: myTastingStats security: - bearerAuth: [] parameters: - name: user_id in: path required: true schema: type: integer responses: "200": description: 사용자 통계 # ────────────────────────────────────────── # WineDuck - Search # ────────────────────────────────────────── /wineduck/wines/search: get: tags: [WineDuck - Search] summary: 와인 이름 검색 operationId: searchWines parameters: - name: name in: query required: true schema: type: string description: 검색어 (부분 매칭) - name: vintage in: query schema: type: integer description: 빈티지 연도 필터 responses: "200": description: 검색 결과 content: application/json: schema: type: object properties: success: type: boolean wines: type: array items: $ref: "#/components/schemas/WineSearchResult" /wineduck/wines/autocomplete: get: tags: [WineDuck - Search] summary: 와인 자동완성 검색 description: 2글자 이상 입력 시 prefix → contains 2단계 매칭 operationId: autocompleteWines parameters: - name: q in: query required: true schema: type: string minLength: 2 responses: "200": description: 자동완성 결과 /wineduck/countries: get: tags: [WineDuck - Search] summary: 와인 생산국 목록 operationId: listWineCountries responses: "200": description: 국가 목록 /wineduck/countries/{country_id}/regions: get: tags: [WineDuck - Search] summary: 특정 국가의 와인 지역 목록 operationId: listWineRegions parameters: - name: country_id in: path required: true schema: type: integer responses: "200": description: 지역 목록 (하위 지역 포함) /wineduck/regions/{region_id}/appellations: get: tags: [WineDuck - Search] summary: 특정 지역의 아펠라시옹 목록 operationId: listAppellations parameters: - name: region_id in: path required: true schema: type: integer - name: include_sub in: query schema: type: boolean default: false description: 하위 지역의 아펠라시옹도 포함 responses: "200": description: 아펠라시옹 목록 /wineduck/wines/{wine_id}: get: tags: [WineDuck - Search] summary: 와인 상세 조회 operationId: getWine parameters: - name: wine_id in: path required: true schema: type: integer responses: "200": description: 와인 상세 /wineduck/wines: get: tags: [WineDuck - Search] summary: 와인 목록 (필터링) operationId: listWines parameters: - name: country_id in: query schema: type: integer - name: region_id in: query schema: type: integer - name: appellation_id in: query schema: type: integer - name: wine_type in: query schema: type: string enum: [red, rose, white_sparkling] - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 20 responses: "200": description: 와인 목록 post: tags: [WineDuck - Wine] summary: 와인 등록 operationId: createWine security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WineCreateRequest" responses: "200": description: 등록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string wine_id: type: integer # ────────────────────────────────────────── # WineDuck - Tasting # ────────────────────────────────────────── /wineduck/wines/{wine_id}/tastings: post: tags: [WineDuck - Tasting] summary: 와인 테이스팅 노트 등록 operationId: createWineTasting security: - bearerAuth: [] parameters: - name: wine_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WineTastingCreateRequest" responses: "200": description: 등록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string tasting_id: type: integer get: tags: [WineDuck - Tasting] summary: 특정 와인의 테이스팅 목록 operationId: listWineTastings parameters: - name: wine_id in: path required: true schema: type: integer responses: "200": description: 테이스팅 목록 /wineduck/tastings/{tasting_id}: get: tags: [WineDuck - Tasting] summary: 테이스팅 노트 상세 조회 operationId: getWineTasting parameters: - name: tasting_id in: path required: true schema: type: integer responses: "200": description: 테이스팅 상세 put: tags: [WineDuck - Tasting] summary: 테이스팅 노트 수정 (본인만) operationId: updateWineTasting security: - bearerAuth: [] parameters: - name: tasting_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/WineTastingUpdateRequest" responses: "200": description: 수정 성공 delete: tags: [WineDuck - Tasting] summary: 테이스팅 노트 삭제 (본인만) operationId: deleteWineTasting security: - bearerAuth: [] parameters: - name: tasting_id in: path required: true schema: type: integer responses: "200": description: 삭제 성공 /wineduck/users/{user_id}/tastings: get: tags: [WineDuck - Tasting] summary: 내 와인 테이스팅 목록 (본인만) operationId: myWineTastings security: - bearerAuth: [] parameters: - name: user_id in: path required: true schema: type: integer responses: "200": description: 테이스팅 목록 # ────────────────────────────────────────── # WineDuck - Discovery (community aggregates / personalized recommendations) # ────────────────────────────────────────── /wineduck/wines/aggregates/palate-avg: get: tags: [WineDuck - Discovery] summary: 커뮤니티 평균 팔레트 집계 description: | 전체(또는 wine_type별) 테이스팅 노트의 5축 팔레트(sweetness/acidity/body/tannin/finish) 평균. FE 레이더 차트 오버레이 / 비교 기준선 용도. 인증 불필요. operationId: getCommunityPalateAvg parameters: - name: wine_type in: query description: 타입 필터 (없으면 전체 평균) schema: type: string enum: [red, rose, white_sparkling] responses: "200": description: 평균 팔레트 + 표본 수 content: application/json: schema: type: object properties: success: { type: boolean } avg_palate: type: object description: 5축 평균 (각 축 0~5, 표본 없으면 null) properties: sweetness: { type: number, nullable: true } acidity: { type: number, nullable: true } body: { type: number, nullable: true } tannin: { type: number, nullable: true } finish: { type: number, nullable: true } sample_count: { type: integer, description: 집계에 포함된 테이스팅 노트 수 } wine_type: { type: string, description: 적용된 필터 (`all` if none) } /wineduck/users/{user_id}/recommendations: get: tags: [WineDuck - Discovery] summary: 사용자 취향 기반 와인 추천 TOP N description: | 팔레트 유사도(주 신호) + 선호 타입/국가/품종 보너스로 점수화된 추천. 본인만 조회 가능 (path `user_id` 와 토큰 user_id 일치 검증). 테이스팅 노트가 `min_count_required`(기본 3) 미만이면 `need_more: true` + `wines: []` 반환. operationId: getUserRecommendations security: - bearerAuth: [] parameters: - name: user_id in: path required: true schema: type: integer - name: limit in: query description: 반환 개수 (기본 8, 최대 20) schema: type: integer default: 8 minimum: 1 maximum: 20 responses: "200": description: 추천 결과 (게이팅 미달 시 빈 배열 + need_more=true) content: application/json: schema: type: object properties: success: { type: boolean } wines: type: array description: 점수 DESC 정렬된 추천 리스트 items: type: object properties: wine: type: object description: WineListItem 호환 페이로드 (id, canonical_name, producer, wine_type, vintage_year, country/region/appellation, community_palate 등) score: { type: number, description: 종합 점수 } palate_similarity: { type: number, description: 0~1 정규화 유사도 } reasons: type: array description: i18n 적용된 매치 사유 태그 (`#비슷한 팔레트`, `#레드`, `#프랑스`, `#Nebbiolo` 등) items: { type: string } need_more: { type: boolean, description: 게이팅 미달 시 true } min_count_required: { type: integer, description: 최소 테이스팅 수 (기본 3) } user_tasting_count: { type: integer } user_palate_count: { type: integer, description: 평점 4점 이상 와인 수 (팔레트 학습 시그널) } candidate_pool_size: { type: integer, description: 스코어링에 들어간 후보 와인 수 } "403": description: 본인이 아닌 다른 user_id 조회 시도 # ────────────────────────────────────────── # WineDuck - Cellar # ────────────────────────────────────────── /cellar: get: tags: [WineDuck - Cellar] summary: 셀러 목록 조회 (페이지네이션 + 필터) operationId: listCellarEntries security: - bearerAuth: [] parameters: - name: status in: query description: | 기본 `in_cellar`. 단일 값 또는 쉼표로 다중 값 지정 가능. 허용 값: `in_cellar` / `consumed` / `gifted` / `received`. 예: `in_cellar,received` (보유 + 선물받음). schema: type: string default: in_cellar example: in_cellar,received - name: wine_type in: query schema: type: string enum: [red, rose, white_sparkling] - name: sort in: query description: | 정렬 옵션. - `newest` / `oldest`: 등록일 기준 - `purchase_date`: 구매일 DESC - `drink_soon`: `drink_until_year` ASC (와인 음용적기 임박순) - `consumed_recent`: `consumed_at` DESC (`status=consumed` 조합 권장) schema: type: string enum: [newest, oldest, purchase_date, drink_soon, consumed_recent] default: newest - name: q in: query description: canonical_name 또는 producer 부분 매칭 schema: type: string - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 20 maximum: 100 responses: "200": description: 셀러 목록 content: application/json: schema: type: object properties: success: type: boolean entries: type: array items: $ref: "#/components/schemas/CellarEntry" pagination: $ref: "#/components/schemas/CellarPagination" post: tags: [WineDuck - Cellar] summary: 셀러에 와인 추가 operationId: createCellarEntry security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CellarCreateRequest" responses: "201": description: 셀러 등록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string entry_id: type: integer /cellar/stats: get: tags: [WineDuck - Cellar] summary: 셀러 통계 (총 병 수, 총 가치, 국가별 분포) operationId: cellarStats security: - bearerAuth: [] responses: "200": description: 셀러 통계 content: application/json: schema: type: object properties: success: type: boolean total_bottles_in_cellar: type: integer total_value: type: number country_breakdown: type: array items: type: object properties: country_name: type: string country_name_ko: type: string iso_code: type: string bottle_count: type: integer /cellar/expiring: get: tags: [WineDuck - Cellar] summary: 음용 적기 임박 와인 (와인 음용적기 종료연도(drink_until_year) ≤ 올해) description: | `status='in_cellar' AND w.drink_until_year IS NOT NULL AND w.drink_until_year <= YEAR(CURDATE())` 조건의 보유 와인. `w.drink_until_year ASC` 정렬. 홈 화면 "곧 마셔야 할 와인" 카드용. operationId: listExpiringCellarEntries security: - bearerAuth: [] parameters: - name: n in: query description: 반환 개수 (기본 10, 최대 50) schema: type: integer default: 10 minimum: 1 maximum: 50 responses: "200": description: 임박 와인 목록 content: application/json: schema: type: object properties: success: { type: boolean } entries: type: array items: $ref: "#/components/schemas/CellarEntry" count: { type: integer } /cellar/{entry_id}: get: tags: [WineDuck - Cellar] summary: 셀러 엔트리 상세 operationId: getCellarEntry security: - bearerAuth: [] parameters: - name: entry_id in: path required: true schema: type: integer responses: "200": description: 셀러 엔트리 content: application/json: schema: type: object properties: success: type: boolean entry: $ref: "#/components/schemas/CellarEntry" put: tags: [WineDuck - Cellar] summary: 셀러 엔트리 부분 수정 operationId: updateCellarEntry security: - bearerAuth: [] parameters: - name: entry_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: $ref: "#/components/schemas/CellarUpdateRequest" responses: "200": description: 수정 성공 delete: tags: [WineDuck - Cellar] summary: 셀러 엔트리 소프트 삭제 operationId: deleteCellarEntry security: - bearerAuth: [] parameters: - name: entry_id in: path required: true schema: type: integer responses: "200": description: 삭제 성공 /cellar/{entry_id}/consume: post: tags: [WineDuck - Cellar] summary: 셀러 와인 소비 처리 (오픈한 병 기록) operationId: consumeCellarEntry description: | `SELECT FOR UPDATE`로 수량 정합성 보장. - 전량 소비 시 `status` / `consumed_at` / `consumed_quantity` / `tasting_id` 필드 원자적 기록. - 부분 소비 시 `quantity`만 차감, 상태는 유지. security: - bearerAuth: [] parameters: - name: entry_id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: type: object required: [quantity, status] properties: quantity: type: integer minimum: 1 status: type: string enum: [consumed, gifted] tasting_id: type: integer description: 연결할 테이스팅 노트 ID (옵션) nullable: true responses: "200": description: 소비 기록 성공 content: application/json: schema: type: object properties: success: type: boolean message: type: string remaining_quantity: type: integer # ────────────────────────────────────────── # Components # ────────────────────────────────────────── components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT description: JWT 토큰 (24시간 유효). /api/auth/login으로 발급. schemas: UserInfo: type: object properties: id: type: integer username: type: string name: type: string role: type: string enum: [user, super-admin] Pagination: type: object properties: current_page: type: integer per_page: type: integer total_count: type: integer total_pages: type: integer has_next: type: boolean has_prev: type: boolean CoffeeListItem: type: object properties: id: type: integer name: type: string supplier: type: string description: 로스터리/공급처 created_at: type: string avg_rating: type: number comment_count: type: integer flavor_notes: type: array items: type: object properties: name: type: string color: type: string CoffeeDetail: type: object properties: id: type: integer name: type: string supplier: type: string harvest_year: type: integer nullable: true origin: type: object properties: country: type: string region: type: string farm_name: type: string producer: type: string nullable: true altitude_meters: type: integer nullable: true processing: type: object properties: method: type: string fermentation: type: string nullable: true varietal: type: array items: type: string flavor_notes: type: array items: $ref: "#/components/schemas/FlavorNote" FlavorNote: type: object properties: id: type: integer name: type: string name_ko: type: string name_ja: type: string color: type: string nullable: true CoffeeCreateRequest: type: object required: [name, roastery] properties: name: type: string description: "커피 이름 (예: Ethiopia Yirgacheffe Konga)" roastery: type: string description: 로스터리/공급처 coffee_type: type: string enum: [single_origin, blend] default: single_origin origin: type: object properties: country: type: string description: 산지 국가 (싱글 오리진 시 필수) region: type: string farm_name: type: string producer: type: string altitude_meters: type: integer processing: type: string description: "가공법 (Washed, Natural, Honey, Anaerobic 등)" fermentation: type: string harvest_year: type: integer variety: type: array items: type: string description: "품종 (예: [\"Heirloom\", \"SL28\"])" flavor_notes: type: array items: type: string description: "대표 향미 (예: [\"blueberry\", \"jasmine\"])" detail: type: string CoffeeTastingCreateRequest: type: object required: [rating] properties: tasted_at: type: string format: date description: "시음 날짜 (YYYY-MM-DD, 기본: 오늘)" acidity: type: integer minimum: 0 maximum: 100 description: 산도 (0-100) body: type: integer minimum: 0 maximum: 100 description: 바디 (0-100) sweetness: type: integer minimum: 0 maximum: 100 description: 당도 (0-100) bitterness: type: integer minimum: 0 maximum: 100 description: 쓴맛 (0-100) finish: type: integer minimum: 0 maximum: 100 description: 여운 (0-100) roasting_level: type: string enum: [light, medium_light, medium, medium_dark, dark] brew_method: type: string description: "추출 방법 (pour over, espresso, aeropress 등)" rating: type: number minimum: 0 maximum: 5 description: 총점 (0.0-5.0, 필수) one_liner: type: string maxLength: 280 description: 한줄평 flavor_note_ids: type: array items: type: integer description: 플레이버 노트 ID 배열 (/api/flavor-notes에서 조회) CoffeeTastingUpdateRequest: type: object properties: tasted_at: type: string format: date acidity: type: integer body: type: integer sweetness: type: integer bitterness: type: integer finish: type: integer roasting_level: type: string brew_method: type: string rating: type: number one_liner: type: string flavor_note_ids: type: array items: type: integer WineSearchResult: type: object properties: id: type: integer canonical_name: type: string producer: type: string wine_type: type: string enum: [red, rose, white_sparkling] vintage_year: type: integer nullable: true country_code: type: string WineCreateRequest: type: object required: [canonical_name, wine_type, country_id, region_id] properties: canonical_name: type: string description: | 와인 이름. 타입별 규칙: - Type A (구세계): 아펠라시옹 + 크뤼/특수표기. 생산자 포함 금지. (예: Gevrey-Chambertin Vieilles Vignes) - Type B (신세계): 생산자 + 품종. (예: Far Niente Cabernet Sauvignon) - Type C (브랜드): 브랜드명. (예: Opus One) wine_type: type: string enum: [red, rose, white_sparkling] description: "⚠️ white 단독 사용 불가, white_sparkling 사용" producer: type: string description: 생산자 (도멘/네고시앙/와이너리) vintage_year: type: integer nullable: true description: 빈티지 연도 (NV는 null) grapes_text: type: string description: "품종 (쉼표 구분, 예: Pinot Noir)" drink_from_year: type: integer nullable: true description: "음용 적기 시작 연도 (예: 2026). 와인 자체의 권장 음용 시기(셀러 병 단위와 별개)." drink_until_year: type: integer nullable: true description: "음용 적기 종료 연도 (예: 2034)." peak_year: type: integer nullable: true description: "음용 피크 연도 (예: 2030). 정합성: drink_from_year ≤ peak_year ≤ drink_until_year (연도 1900~2100)." country_id: type: integer description: 국가 ID (/api/wineduck/countries에서 조회) region_id: type: integer description: 지역 ID (/api/wineduck/countries/{id}/regions에서 조회) appellation_id: type: integer description: 아펠라시옹 ID (/api/wineduck/regions/{id}/appellations에서 조회) WineTastingCreateRequest: type: object properties: wine_type: type: string enum: [red, rose, white_sparkling] description: 와인 타입 (필수) tasted_at: type: string format: date description: "시음 날짜 (YYYY-MM-DD, 기본: 오늘)" sweetness: type: integer minimum: 1 maximum: 5 description: 당도 (1-5) acidity: type: integer minimum: 1 maximum: 5 description: 산도 (1-5) body: type: integer minimum: 1 maximum: 5 description: 바디 (1-5) tannin: type: integer minimum: 1 maximum: 5 description: 타닌 (1-5) finish: type: integer minimum: 1 maximum: 5 description: 여운 (1-5) aroma_intensity: type: integer minimum: 1 maximum: 6 description: 향 강도 (1-6) rating: type: number minimum: 0 maximum: 5 description: 총점 (0.0-5.0) one_liner: type: string description: 한줄평 repurchase: type: boolean description: 재구매 의향 pairing_food: type: string description: 페어링 음식 color: type: string description: "색상 (ruby, garnet, purple 등)" price_amount: type: number description: 가격 currency_code: type: string enum: [KRW, USD, EUR] default: KRW aromas: type: array items: type: object properties: source: type: string enum: [custom, taxonomy] custom_label: type: string description: source=custom일 때 향 이름 node_id: type: integer description: source=taxonomy일 때 아로마 DB ID description: 향 목록 (커스텀 또는 택소노미) WineTastingUpdateRequest: type: object properties: sweetness: type: integer acidity: type: integer body: type: integer tannin: type: integer finish: type: integer aroma_intensity: type: integer rating: type: number one_liner: type: string repurchase: type: boolean pairing_food: type: string color: type: string aromas: type: array items: type: object CellarEntry: type: object description: 셀러 엔트리 (와인/국가/지역/아펠라시옹 JOIN 포함) properties: id: type: integer user_id: type: integer wine_id: type: integer quantity: type: integer description: 보유 병 수 purchase_date: type: string format: date nullable: true purchase_price: type: number nullable: true currency: type: string example: KRW storage_location: type: string nullable: true status: type: string enum: [in_cellar, consumed, gifted, received] drink_from_year: type: integer nullable: true description: 와인 음용적기(연도) — 와인 카탈로그에서 가져옴 drink_until_year: type: integer nullable: true description: 와인 음용적기(연도) — 와인 카탈로그에서 가져옴. `sort=drink_soon` / `/cellar/expiring` 기준 peak_year: type: integer nullable: true description: 와인 음용적기(연도) — 와인 카탈로그에서 가져옴 consumed_at: type: string format: date-time nullable: true consumed_quantity: type: integer nullable: true tasting_id: type: integer nullable: true description: 연결된 테이스팅 노트 ID note: type: string nullable: true created_at: type: string format: date-time updated_at: type: string format: date-time canonical_name: type: string description: 와인 정식 이름 (JOIN) producer: type: string nullable: true wine_type: type: string enum: [red, rose, white_sparkling] vintage_year: type: integer nullable: true grapes_text: type: string nullable: true country_id: type: integer nullable: true region_id: type: integer nullable: true appellation_id: type: integer nullable: true country_name: type: string nullable: true country_name_ko: type: string nullable: true country_iso_code: type: string nullable: true region_name: type: string nullable: true region_name_ko: type: string nullable: true appellation_name: type: string nullable: true appellation_name_ko: type: string nullable: true appellation_classification: type: string nullable: true description: AOC / DOCG / Grand Cru / 1er Cru 등 CellarCreateRequest: type: object required: [wine_id] properties: wine_id: type: integer description: 기존 와인 ID (wineduck-search / wineduck-wine으로 확보) quantity: type: integer minimum: 1 default: 1 status: type: string enum: [in_cellar, consumed, gifted, received] default: in_cellar purchase_date: type: string format: date purchase_price: type: number currency: type: string default: KRW storage_location: type: string note: type: string CellarUpdateRequest: type: object description: 부분 수정 (하나 이상 필수). consumed_at / consumed_quantity / tasting_id는 소비 API 전용. properties: quantity: type: integer minimum: 1 purchase_date: type: string format: date purchase_price: type: number currency: type: string storage_location: type: string status: type: string enum: [in_cellar, consumed, gifted, received] note: type: string CellarPagination: type: object properties: page: type: integer per_page: type: integer total: type: integer total_pages: type: integer