Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Soundlog 배포 오버라이드 — echo 서버 전용
#
# 저장소의 docker-compose.yml은 그대로 두고 이 파일만 얹는다.
# `docker compose up -d` 가 두 파일을 자동 병합한다.
#
# ── 이 서버의 제약 (2026-08 확인) ──
# echo 서버는 여러 서비스가 공존하는 공용 서버다. 아래를 반드시 지킨다.
#
# 호스트 5432 : PostgreSQL 18이 이미 사용 중 → 컨테이너 db 포트를 노출하지 않는다
# 호스트 80/443: nginx가 이미 사용 중 → 지금은 사용 안 함 (나중에 프록시로 전환 가능)
# 호스트 8000 : ML(uvicorn)이 별도 프로세스로 운영 중 → 컨테이너에서 호스트로 호출
# 호스트 8005 : 비어 있음 → api가 사용 (ACG에서 열 것)
#
# 추천 모델 서비스는 이 compose가 관리하지 않는다.
# 호스트에서 uvicorn으로 따로 운영하며, api 컨테이너가 host-gateway를 통해 호출한다.
# ML을 다른 서버로 옮기면 .env에 ML_RECOMMENDATION_API_URL을 지정해 덮어쓰면 된다.

services:
db:
# 원본 compose의 "${POSTGRES_PORT:-5432}:5432" 노출을 취소한다.
# 호스트 PostgreSQL 18과 충돌하며, 컨테이너 네트워크 안에서만 쓰면 충분하다.
ports: !reset []

api:
# 호스트 8005를 외부에 공개한다. ACG에서 TCP 8005를 열어야 접속된다.
#
# 8005를 고른 이유: 이 서버에서 8000(ML), 8001(ci), 8002(echohub),
# 8004(blogstudio), 8006/8011(java)이 이미 사용 중이고 8005만 비어 있다.
#
# 나중에 nginx 뒤로 옮길 때는 아래 한 줄을 127.0.0.1 바인딩으로 바꾸면 된다.
# 그러면 HTTPS를 기존 nginx 인증서로 처리하고 ACG에서 8005를 닫을 수 있다.
# - "127.0.0.1:${API_PORT:-8005}:4000"
# docker-compose.yml 은 environment: 에 나열된 키만 컨테이너로 넘긴다.
# SoundLogServer 가 요구하는 MODERATION_*/SUPPORT_EMAIL/TRUST_PROXY_HOPS 등은
# 거기 없으므로 .env 를 통째로 주입한다.
env_file:
- .env
ports: !override
- "${API_PORT:-8005}:4000"
environment:
# 컨테이너에서 호스트의 추천 서비스(8000)를 호출한다.
# 리눅스에서는 host-gateway가 docker0 브리지 주소로 해석된다.
ML_RECOMMENDATION_API_URL: http://host.docker.internal:8000/recommend
# 관광공사 API가 느릴 때를 감안한 여유. Python 쪽 예산은 3.6초다.
ML_RECOMMENDATION_TIMEOUT_MS: ${ML_RECOMMENDATION_TIMEOUT_MS:-8000}
extra_hosts:
- "host.docker.internal:host-gateway"
96 changes: 96 additions & 0 deletions docs/recommendation-feedback-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Recommendation Feedback Contract

> Status: Canonical server contract
> Last updated: 2026-09-05

사용자가 음악 추천과 관광지 추천사진에 1~5점 별점과 선택적 의견(300자 이하)을 남긴다.
전송은 기존 분석 이벤트 경로를 그대로 쓰고, 서버가 이를 구조화된 행으로 다시 적재한다.

```text
앱 → POST /v1/recommendation-events (type=recommendation_feedback, value=JSON 문자열)
서버 → RecommendationEvent (원본 이벤트, 그대로 보존)
→ RecommendationFeedback (별점·의견을 컬럼으로 펼친 것)
```

## 왜 전용 엔드포인트를 만들지 않았나

앱이 이미 이 경로로 이벤트를 보내고 있었다. 전송 계약을 바꾸면 앱 배포와 서버 배포가
묶인다. 대신 서버가 받아서 옮겨 담는 쪽을 택했다. 원본 이벤트는 남으므로, 나중에
해석을 바꾸고 싶으면 `RecommendationEvent`에서 다시 만들 수 있다.

## Server invariants

1. `type`이 `recommendation_feedback`인 이벤트는 `value`에 피드백 JSON을 직렬화한 **문자열**로 담는다.
2. `value.version`은 현재 `1`만 지원한다. 그 밖의 값은 나머지 필드를 해석하지 않고 거부한다.
3. `value.subject`는 `music` 또는 `photo`다.
4. `value.rating`은 1 이상 5 이하의 **정수**다. 0, 6, 소수점은 거부한다.
5. `value.opinion`은 선택이다. 앞뒤 공백을 제거한 뒤 1자 이상 300자 이하여야 한다.
6. `subject`가 `music`이면 `playlistId`가 반드시 있어야 한다. 무엇을 평가했는지 없으면 분석에 쓸 수 없다.
7. 사진 피드백에는 이미지 URL, 기기 파일 URI, GPS 좌표를 담지 않는다. 장소 식별자와 장소명만 전달한다.
8. `RecommendationFeedback.id`는 이벤트 `id`를 그대로 쓴다. 같은 이벤트가 다시 와도 한 행만 남는다.
9. 이벤트 적재와 피드백 적재는 같은 트랜잭션에서 일어난다. 한쪽만 남는 상태를 만들지 않는다.
10. 배치 안의 이벤트가 하나라도 잘못되면 배치 전체를 400으로 거부한다. 부분 수용은 하지 않는다.
11. 의견 원문은 운영 로그와 검증 오류 메시지 어디에도 남기지 않는다.

## 10번을 그렇게 정한 이유

앱의 `syncRecommendationEvent`가 이벤트를 한 건씩 보낸다. 부분 수용을 구현해도 지금은
쓰이지 않는 경로가 되고, 응답 형태만 복잡해진다. 배치 전송이 실제로 필요해지면 그때
`acceptedEventIds` / `rejectedEvents` 확장 응답을 도입한다.

## 거부 사유 코드

검증 실패는 `400`이며 `error.details.issues[].params.feedbackCode`에 사유가 담긴다.
같은 위치의 `params.eventId`로 배치 안 어느 이벤트인지 식별한다.

| 코드 | 의미 |
| --- | --- |
| `INVALID_FEEDBACK_VALUE` | `value`가 JSON이 아니거나 rating·subject·opinion이 규칙을 벗어남 |
| `UNSUPPORTED_FEEDBACK_VERSION` | 지원하지 않는 `version` |
| `MISSING_FEEDBACK_TARGET` | `subject=music`인데 `playlistId`가 없음 |

## context.source

`context.source`는 추천이 어느 경로로 나왔는지를 담는다.

- 음악: `ml-recommendation` · `seed-fallback` · `server-contextual`
- 사진: `recommended-photo:<관광 데이터 출처>` (예: `recommended-photo:tour-api`)

사진 쪽 출처는 계속 늘어나므로 OpenAPI에서 enum으로 고정하지 않는다.

> **2026-09-05 이전 데이터 주의**
> 그 전까지 `recommendationContextSchema`에 `source`가 선언돼 있지 않았다.
> zod의 `z.object()`는 모르는 키를 오류 없이 버리고, `validate` 미들웨어는 `req.body`를
> 파싱 결과로 통째 교체한다. 그래서 앱이 보낸 `source`는 400도 없이 사라졌다.
> 이 날짜 이전 이벤트에는 `source`가 없다.

## 스키마

`prisma/models/analytics.prisma`의 `RecommendationFeedback`.

| 컬럼 | 비고 |
| --- | --- |
| `id` | 이벤트 id. 재전송 멱등성의 근거 |
| `subject` `rating` `opinion` | `value`를 펼친 것 |
| `playlistId` `placeId` `placeName` `source` | 조인·집계용으로 뽑아둔 것 |
| `context` | 이벤트 context 원본 JSON. 위 컬럼으로 안 뽑은 값이 여기 남는다 |
| `version` | 재해석이 필요할 때 세대를 가르는 값 |

## 아직 없는 것

별점만으로는 **왜** 나쁜지 알 수 없다. ML 응답의 `meta`(`photo.gate`, `photo.source`,
`place.place_applied`, `poiSource`)가 `Playlist.context`에 저장되면,
`RecommendationFeedback.playlistId` → `Playlist.context` 조인만으로
"낮은 별점이 어느 게이트에 몰리는가"를 볼 수 있다. 그때 이 스키마는 바꿀 필요가 없다.

## 관련 파일

```text
prisma/models/analytics.prisma RecommendationFeedback
prisma/migrations/20260905000000_recommendation_feedback/
src/validators/api.validators.ts parseRecommendationFeedbackValue, superRefine
src/services/soundlog.service.ts buildRecommendationFeedbackRows
src/services/mock-soundlog.service.ts mock 모드 동등 구현 (빠지면 두 경로가 갈라진다)
tests/recommendation-feedback.test.ts 수용 시나리오 12건
openapi/soundlog-api.yaml RecommendationFeedbackValue
```
134 changes: 129 additions & 5 deletions openapi/soundlog-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1181,6 +1181,31 @@ paths:
"401":
$ref: "#/components/responses/Unauthorized"

/v1/recaps/background-suggestion:
post:
tags:
- Recaps
summary: 리캡 배경 사진 추천
description: 좌표(와 선택한 여행 모드·무드)로 ML 서비스의 관광공사 사진 추천을 받습니다. 리캡 생성 화면에서 사용자 사진이 없을 때 배경으로 제안합니다. ML 서비스가 응답하지 않으면 500 대신 backgroundImageUrl이 null인 응답을 돌려줍니다.
operationId: getRecapBackgroundSuggestion
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RecapBackgroundSuggestionRequest"
responses:
"200":
description: 추천 배경 (없으면 null)
content:
application/json:
schema:
$ref: "#/components/schemas/RecapBackgroundSuggestionResponse"
"400":
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"

/v1/recaps/{recapId}/share:
get:
tags:
Expand Down Expand Up @@ -2803,11 +2828,15 @@ components:
example: 광안리해수욕장
source:
type: string
description: 추천 산출 경로입니다. ML 추천이 실패했거나 위치가 없으면 seed-fallback으로 내려갑니다.
enum:
- ml-recommendation
- seed-fallback
- server-contextual
description: |
추천 산출 경로입니다.

음악은 `ml-recommendation` / `seed-fallback` / `server-contextual` 중 하나이며,
ML 추천이 실패했거나 위치가 없으면 seed-fallback으로 내려갑니다.

추천사진 피드백은 `recommended-photo:<관광 데이터 출처>` 형식을 씁니다
(예: `recommended-photo:tour-api`). 출처가 계속 늘어나므로 enum으로 고정하지 않습니다.
maxLength: 120
example: ml-recommendation
travelMode:
$ref: "#/components/schemas/TravelMode"
Expand Down Expand Up @@ -3090,6 +3119,7 @@ components:
- recommendation_mode_change
- top_filter_change
- recap_representative_track_select
- recommendation_feedback

RecommendationEvent:
type: object
Expand Down Expand Up @@ -3122,6 +3152,44 @@ components:
type: string
format: date-time

RecommendationFeedbackValue:
type: object
description: |
추천 피드백 이벤트의 `value` 문자열을 파싱한 결과입니다. 전송할 때는
이 객체를 JSON으로 직렬화해 `value`에 문자열로 넣습니다.

의견 원문은 운영 로그와 검증 오류 메시지에 남기지 않습니다.
required:
- version
- subject
- rating
properties:
version:
type: integer
description: 현재 지원 버전은 1입니다. 그 밖의 값은 해석하지 않고 거부합니다.
enum:
- 1
example: 1
subject:
type: string
description: 평가 대상. `music`이면 playlistId가 함께 필요합니다.
enum:
- music
- photo
example: music
rating:
type: integer
minimum: 1
maximum: 5
description: 1부터 5까지의 정수. 소수점은 거부합니다.
example: 5
opinion:
type: string
minLength: 1
maxLength: 300
description: 선택 항목. 앞뒤 공백을 제거한 뒤 1자 이상 300자 이하여야 합니다.
example: 산책할 때 잘 어울렸어요

RecommendationEventsRequest:
type: object
required:
Expand Down Expand Up @@ -3221,6 +3289,62 @@ components:
$ref: "#/components/schemas/RoutePoint"
visibility:
$ref: "#/components/schemas/RecapVisibility"
backgroundImageUrl:
type: string
format: uri
description: 리캡 배경 추천(/v1/recaps/background-suggestion) 결과를 저장할 때 보냅니다. 대표 캡처에 사진이 없을 때만 배경으로 쓰이며, 사용자 사진이 항상 우선합니다.

RecapBackgroundSuggestionRequest:
type: object
required:
- location
properties:
location:
$ref: "#/components/schemas/GeoPoint"
travelMode:
$ref: "#/components/schemas/TravelMode"
moodTags:
type: array
items:
$ref: "#/components/schemas/MoodTag"
state:
$ref: "#/components/schemas/MlTravelState"
mood:
$ref: "#/components/schemas/MlMood"

RecapBackgroundSuggestion:
type: object
required:
- backgroundImageUrl
- placeName
- placeType
- source
properties:
backgroundImageUrl:
type: string
format: uri
nullable: true
description: 관광공사 사진 URL. 근처에 관광지가 없거나 ML 서비스가 응답하지 않으면 null.
placeName:
type: string
nullable: true
description: "사진의 기준이 된 관광지 이름 (예: 광안리해수욕장)"
placeType:
type: string
nullable: true
source:
type: string
nullable: true
enum: [poi_image, gallery]
description: poi_image는 관광지 대표 사진, gallery는 관광사진갤러리 매칭 결과

RecapBackgroundSuggestionResponse:
type: object
required:
- data
properties:
data:
$ref: "#/components/schemas/RecapBackgroundSuggestion"

RecapVisibilityUpdateRequest:
type: object
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "RecommendationFeedback" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"subject" TEXT NOT NULL,
"rating" INTEGER NOT NULL,
"opinion" TEXT,
"playlistId" TEXT,
"placeId" TEXT,
"placeName" TEXT,
"source" TEXT,
"context" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "RecommendationFeedback_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "RecommendationFeedback_subject_rating_idx" ON "RecommendationFeedback"("subject", "rating");
CREATE INDEX "RecommendationFeedback_playlistId_idx" ON "RecommendationFeedback"("playlistId");
CREATE INDEX "RecommendationFeedback_placeId_idx" ON "RecommendationFeedback"("placeId");
CREATE INDEX "RecommendationFeedback_createdAt_idx" ON "RecommendationFeedback"("createdAt");

-- AddForeignKey
ALTER TABLE "RecommendationFeedback" ADD CONSTRAINT "RecommendationFeedback_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
25 changes: 25 additions & 0 deletions prisma/models/analytics.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,31 @@ model RecommendationEvent {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

/// 추천 피드백. RecommendationEvent(type=recommendation_feedback)의 value JSON을
/// 구조화해 옮겨 담은 것으로, 원본 이벤트는 그대로 남는다.
/// id는 이벤트 id를 그대로 쓴다 — 같은 이벤트가 두 번 와도 한 행만 남는다.
model RecommendationFeedback {
id String @id
userId String
sessionId String
version Int @default(1)
subject String
rating Int
opinion String?
playlistId String?
placeId String?
placeName String?
source String?
context Json
createdAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([subject, rating])
@@index([playlistId])
@@index([placeId])
@@index([createdAt])
}

model RegionSoundTrend {
id String @id @default(cuid())
regionCode String
Expand Down
1 change: 1 addition & 0 deletions prisma/models/auth.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ model User {
libraryTrackStates LibraryTrackState[]
momentLogs MomentLog[]
recommendationEvents RecommendationEvent[]
recommendationFeedbacks RecommendationFeedback[]
recaps Recap[]
travelSessions TravelSession[]
recapShareEvents RecapShareEvent[]
Expand Down
Loading
Loading