diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..230df3d --- /dev/null +++ b/docker-compose.override.yml @@ -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" diff --git a/docs/recommendation-feedback-contract.md b/docs/recommendation-feedback-contract.md new file mode 100644 index 0000000..74e87c0 --- /dev/null +++ b/docs/recommendation-feedback-contract.md @@ -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 +``` diff --git a/openapi/soundlog-api.yaml b/openapi/soundlog-api.yaml index d496bce..1bdf180 100644 --- a/openapi/soundlog-api.yaml +++ b/openapi/soundlog-api.yaml @@ -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: @@ -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" @@ -3090,6 +3119,7 @@ components: - recommendation_mode_change - top_filter_change - recap_representative_track_select + - recommendation_feedback RecommendationEvent: type: object @@ -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: @@ -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 diff --git a/prisma/migrations/20260905000000_recommendation_feedback/migration.sql b/prisma/migrations/20260905000000_recommendation_feedback/migration.sql new file mode 100644 index 0000000..e099ba1 --- /dev/null +++ b/prisma/migrations/20260905000000_recommendation_feedback/migration.sql @@ -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; diff --git a/prisma/models/analytics.prisma b/prisma/models/analytics.prisma index 58c531f..abf840e 100644 --- a/prisma/models/analytics.prisma +++ b/prisma/models/analytics.prisma @@ -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 diff --git a/prisma/models/auth.prisma b/prisma/models/auth.prisma index 719068d..43dc04d 100644 --- a/prisma/models/auth.prisma +++ b/prisma/models/auth.prisma @@ -17,6 +17,7 @@ model User { libraryTrackStates LibraryTrackState[] momentLogs MomentLog[] recommendationEvents RecommendationEvent[] + recommendationFeedbacks RecommendationFeedback[] recaps Recap[] travelSessions TravelSession[] recapShareEvents RecapShareEvent[] diff --git a/src/config/env.ts b/src/config/env.ts index 1740b42..e47a42f 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -55,17 +55,71 @@ const envSchema = z.object({ const parsedEnv = envSchema.parse(process.env); -const mlRecommendationApiUrl = - parsedEnv.NODE_ENV === 'production' && - parsedEnv.ML_RECOMMENDATION_API_URL && - new URL(parsedEnv.ML_RECOMMENDATION_API_URL).protocol !== 'https:' - ? undefined - : parsedEnv.ML_RECOMMENDATION_API_URL; +/** + * 평문 ML 주소를 받아들일지 판단한다. + * + * 추천 요청에는 정확한 위경도와 무드가 실린다. 공개망을 평문으로 지나가면 + * 그대로 노출되므로 production에서는 https를 요구한다. + * + * 다만 ML이 같은 호스트에 있으면 이야기가 다르다. echo 배포에서 api 컨테이너는 + * host.docker.internal:8000으로 docker0 브리지를 통해 ML을 부른다. 이 트래픽은 + * 호스트 밖으로 나가지 않으므로 평문이어도 노출 경로가 없고, 여기에 https를 + * 요구하면 내부 호출에 인증서를 붙여야 하는 실익 없는 작업이 생긴다. + * + * 그래서 "공개망 평문만" 막는다. 이 판정이 틀리면 ML 주소가 undefined가 되어 + * 음악 추천과 리캡 배경이 조용히 폴백으로 내려간다 — 에러도 로그도 없이. + */ +function isPrivateHost(hostname: string): boolean { + if ( + hostname === 'localhost' || + hostname === 'host.docker.internal' || + hostname.endsWith('.local') || + hostname.endsWith('.internal') + ) { + return true; + } + + const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname); + + if (!ipv4) { + return hostname === '::1'; + } + + const [a, b] = ipv4.slice(1).map(Number); + + // 127/8 루프백, 10/8, 172.16/12, 192.168/16 사설 대역 + return a === 127 || a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +function resolveMlApiUrl(rawUrl: string | undefined, nodeEnv: string): string | undefined { + if (!rawUrl) { + return undefined; + } + + const url = new URL(rawUrl); + + if (url.protocol === 'https:' || nodeEnv !== 'production') { + return rawUrl; + } + + if (isPrivateHost(url.hostname)) { + return rawUrl; + } + + console.warn( + `[env] ML_RECOMMENDATION_API_URL(${url.hostname})이 공개망 평문이라 무시한다. https를 쓰거나 내부 주소로 바꿀 것.`, + ); + + return undefined; +} + +const mlRecommendationApiUrl = resolveMlApiUrl( + parsedEnv.ML_RECOMMENDATION_API_URL, + parsedEnv.NODE_ENV, +); export const env = { ...parsedEnv, - // Production recommendation requests can include precise location and mood. - // Drop a legacy plaintext endpoint so callers use their local fallback instead. ML_RECOMMENDATION_API_URL: mlRecommendationApiUrl, // Defaults to disabled under NODE_ENV=test so existing tests that call // auth endpoints repeatedly are not destabilized. Set diff --git a/src/controllers/recap.controller.ts b/src/controllers/recap.controller.ts index aeea076..e8972c7 100644 --- a/src/controllers/recap.controller.ts +++ b/src/controllers/recap.controller.ts @@ -28,6 +28,11 @@ export const recapController = { ); }, + async getRecapBackgroundSuggestion(req: Request, res: Response) { + requireUser(req); + res.json(dataResponse(await apiService.getRecapBackgroundSuggestion(req.body))); + }, + async getRecapShare(req: Request, res: Response) { const user = requireUser(req); res.json(dataResponse(await apiService.getRecapShare(user.id, String(req.params.recapId)))); diff --git a/src/mock/mock-db.ts b/src/mock/mock-db.ts index 08164f3..40e23d3 100644 --- a/src/mock/mock-db.ts +++ b/src/mock/mock-db.ts @@ -279,6 +279,21 @@ function createMockDb() { userId: string; value?: string; }>, + recommendationFeedbacks: [] as Array<{ + context: Record; + createdAt: Date; + id: string; + opinion: string | null; + placeId: string | null; + placeName: string | null; + playlistId: string | null; + rating: number; + sessionId: string; + source: string | null; + subject: string; + userId: string; + version: number; + }>, recaps: recaps.map((recap) => { const routePoints = 'routePoints' in recap ? recap.routePoints.map((point) => ({ ...point })) diff --git a/src/routes/index.ts b/src/routes/index.ts index 4971b3a..2f33e43 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -283,6 +283,12 @@ export function createApiRouter() { validate({ body: recapValidators.createBody }), asyncHandler(recapController.createRecap), ); + router.post( + '/v1/recaps/background-suggestion', + authMiddleware, + validate({ body: recapValidators.backgroundSuggestionBody }), + asyncHandler(recapController.getRecapBackgroundSuggestion), + ); router.get( '/v1/recaps/:recapId/share', authMiddleware, diff --git a/src/services/mock-soundlog.service.ts b/src/services/mock-soundlog.service.ts index 192ffe9..29c87e1 100644 --- a/src/services/mock-soundlog.service.ts +++ b/src/services/mock-soundlog.service.ts @@ -5,6 +5,10 @@ import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { createPublicId } from '../utils/tokens.js'; +import { + RECOMMENDATION_FEEDBACK_TYPE, + parseRecommendationFeedbackValue, +} from '../validators/api.validators.js'; import { assertUserTextAllowed, type ModerationTargetType } from './content-moderation.service.js'; type TrackDto = { @@ -1709,6 +1713,38 @@ export const mockSoundlogService = { userId, createdAt: new Date(event.createdAt), }); + + if (event.type !== RECOMMENDATION_FEEDBACK_TYPE) { + return; + } + + const parsed = parseRecommendationFeedbackValue(event.value); + + if (!parsed.ok) { + return; + } + + const context = event.context ?? {}; + const readString = (key: string) => + typeof context[key] === 'string' && context[key] !== '' + ? (context[key] as string) + : null; + + mockDb.recommendationFeedbacks.push({ + id: event.id, + userId, + sessionId: event.sessionId, + version: parsed.value.version, + subject: parsed.value.subject, + rating: parsed.value.rating, + opinion: parsed.value.opinion ?? null, + playlistId: event.playlistId ?? null, + placeId: readString('placeId'), + placeName: readString('placeName'), + source: readString('source'), + context, + createdAt: new Date(event.createdAt), + }); }); }, @@ -2547,6 +2583,7 @@ export const mockSoundlogService = { }, async createRecap(userId: string, input: { + backgroundImageUrl?: string; momentLogIds?: string[]; representativeTrackId?: string; routePoints?: RoutePointDto[]; @@ -2709,7 +2746,7 @@ export const mockSoundlogService = { momentCount: moments.length, sessionId: input.sessionId, travelSessionId: input.sessionId, - backgroundImageUrl: thumbnailMoment.photoUrl, + backgroundImageUrl: thumbnailMoment.photoUrl ?? input.backgroundImageUrl, discImageUrl: representativeMoment.photoUrl, lat: recapLocation?.lat, lng: recapLocation?.lng, @@ -2748,6 +2785,18 @@ export const mockSoundlogService = { ); }, + // mock은 네트워크를 타지 않는다 — 시드 플레이리스트의 배경을 그대로 돌려준다 + async getRecapBackgroundSuggestion(input: { location: { lat: number; lng: number } }) { + void input; + const seed = mockDb.playlists.find((playlist) => playlist.backgroundImageUrl); + return { + backgroundImageUrl: seed?.backgroundImageUrl ?? null, + placeName: seed?.placeName ?? null, + placeType: null, + source: seed?.backgroundImageUrl ? ('poi_image' as const) : null, + }; + }, + async getRecapShare(userId: string, recapId: string) { const recap = mockDb.recaps.find( (item) => diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index b08adcc..629f0ec 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -29,6 +29,10 @@ import { UPLOAD_FILE_ID_PATTERN } from '../middlewares/upload.middleware.js'; import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; +import { + RECOMMENDATION_FEEDBACK_TYPE, + parseRecommendationFeedbackValue, +} from '../validators/api.validators.js'; import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; import { reverseGeocodeLocation } from './reverse-geocoding.service.js'; import { @@ -98,6 +102,7 @@ type MlMood = '잔잔한' | '신나는' | '시원한' | '설레는' | '감성적 const RECAP_DISCOVERY_RADIUS_METERS = 300; type MlRecommendationResponse = { + backgroundImageUrl?: string | null; tracks?: unknown; }; @@ -129,6 +134,28 @@ type ContextualPlaylistInput = { travelMode?: string; }; +type RecapBackgroundSuggestionInput = { + location: { lat: number; lng: number }; + mood?: MlMood; + moodTags?: string[]; + state?: MlTravelState; + travelMode?: string; +}; + +// ML `POST /photo` 응답 — /recommend와 같은 계약(backgroundImageUrl 키 항상 존재, null 가능) +type MlPhotoResponse = { + backgroundImageUrl?: string | null; + meta?: { photo?: { source?: string | null } | null } | null; + poi?: { title?: string | null; type?: string | null } | null; +}; + +type RecapBackgroundSuggestionDto = { + backgroundImageUrl: string | null; + placeName: string | null; + placeType: string | null; + source: 'gallery' | 'poi_image' | null; +}; + type MomentLogUpdateInput = { artistName?: string; createdAt?: string; @@ -960,6 +987,73 @@ function normalizeMlTracks(rawTracks: unknown): TrackDto[] { }); } +// ML `/photo`는 /recommend의 앞부분(POI 확보 → 사진 선택)만 타는 엔드포인트다. +// 주소는 별도 env 없이 /recommend 주소에서 파생한다 — override의 +// host.docker.internal 경로를 그대로 따라가므로 배포 설정을 건드리지 않는다. +// env.ts가 프로덕션에서 https가 아닌 ML 주소를 undefined로 떨어뜨리므로, +// 그때는 fetchMlRecommendationPlaylist와 똑같이 호출하지 않는다. +function mlPhotoApiUrl(): string | undefined { + return env.ML_RECOMMENDATION_API_URL?.replace(/\/recommend\/?$/, '/photo'); +} + +const EMPTY_BACKGROUND_SUGGESTION: RecapBackgroundSuggestionDto = { + backgroundImageUrl: null, + placeName: null, + placeType: null, + source: null, +}; + +// 리캡 배경 추천. ML이 죽거나 늦으면 500 대신 null 응답 — 앱은 사용자 사진/그라데이션으로 간다. +async function fetchMlBackgroundSuggestion( + input: RecapBackgroundSuggestionInput, +): Promise { + const url = mlPhotoApiUrl(); + + if (!url) { + return EMPTY_BACKGROUND_SUGGESTION; + } + + const state = resolveMlTravelState(input); + const mood = resolveMlMood(input); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), env.ML_RECOMMENDATION_TIMEOUT_MS); + + try { + const response = await fetch(url, { + body: JSON.stringify({ + mood, + state, + x: input.location.lng, + y: input.location.lat, + }), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + signal: controller.signal, + }); + + if (!response.ok) { + return EMPTY_BACKGROUND_SUGGESTION; + } + + const data = (await response.json().catch(() => undefined)) as MlPhotoResponse | undefined; + const source = data?.meta?.photo?.source; + + return { + backgroundImageUrl: data?.backgroundImageUrl ?? null, + placeName: data?.poi?.title ?? null, + placeType: data?.poi?.type ?? null, + source: source === 'poi_image' || source === 'gallery' ? source : null, + }; + } catch { + return EMPTY_BACKGROUND_SUGGESTION; + } finally { + clearTimeout(timeout); + } +} + async function fetchMlRecommendationPlaylist( input: ContextualPlaylistInput, ): Promise { @@ -1013,8 +1107,8 @@ async function fetchMlRecommendationPlaylist( regionName: state, placeName: input.placeId, reason: `${state} 중인 지금, ${mood} 무드에 맞춰 추천했어요`, - coverImageUrl: undefined, - backgroundImageUrl: undefined, + coverImageUrl: data?.backgroundImageUrl ?? undefined, + backgroundImageUrl: data?.backgroundImageUrl ?? undefined, trackCount: tracks.length, durationText: `${tracks.length * 4}:00분`, context: { @@ -1790,6 +1884,62 @@ async function findDefaultPlaylist(params?: { lat?: number; lng?: number; placeI }); } +/** + * 이벤트 배치에서 피드백 이벤트만 골라 구조화된 행으로 옮긴다. + * + * 검증은 validate 미들웨어에서 이미 끝났다 — 여기 도달한 피드백 이벤트는 + * 형식이 성하다. 그래도 같은 파서를 다시 쓰는 이유는, 검증과 저장이 서로 다른 + * 해석을 하기 시작하면 그게 제일 찾기 어려운 종류의 버그라서다. + */ +function buildRecommendationFeedbackRows( + userId: string, + events: Array<{ + context: RecommendationContext; + createdAt: string; + id: string; + playlistId?: string; + sessionId: string; + type: string; + value?: string; + }>, +) { + return events.flatMap((event) => { + if (event.type !== RECOMMENDATION_FEEDBACK_TYPE) { + return []; + } + + const parsed = parseRecommendationFeedbackValue(event.value); + + if (!parsed.ok) { + return []; + } + + const context = (event.context ?? {}) as Record; + const readString = (key: string) => + typeof context[key] === 'string' && context[key] !== '' + ? (context[key] as string) + : null; + + return [ + { + id: event.id, + userId, + sessionId: event.sessionId, + version: parsed.value.version, + subject: parsed.value.subject, + rating: parsed.value.rating, + opinion: parsed.value.opinion ?? null, + playlistId: event.playlistId ?? null, + placeId: readString('placeId'), + placeName: readString('placeName'), + source: readString('source'), + context: event.context as Prisma.InputJsonValue, + createdAt: new Date(event.createdAt), + }, + ]; + }); +} + export const soundlogService = { async getHealth() { let database: 'ok' | 'unavailable' = 'ok'; @@ -2760,19 +2910,31 @@ export const soundlogService = { await withIdempotency( { idempotencyKey, scope: 'recommendation-events.create', userId }, async () => { - await prisma.recommendationEvent.createMany({ - data: input.events.map((event) => ({ - id: event.id, - userId, - sessionId: event.sessionId, - type: event.type, - trackId: event.trackId, - playlistId: event.playlistId, - value: event.value, - context: event.context as Prisma.InputJsonValue, - createdAt: new Date(event.createdAt), - })), - skipDuplicates: true, + const feedbackRows = buildRecommendationFeedbackRows(userId, input.events); + + await prisma.$transaction(async (transaction) => { + await transaction.recommendationEvent.createMany({ + data: input.events.map((event) => ({ + id: event.id, + userId, + sessionId: event.sessionId, + type: event.type, + trackId: event.trackId, + playlistId: event.playlistId, + value: event.value, + context: event.context as Prisma.InputJsonValue, + createdAt: new Date(event.createdAt), + })), + skipDuplicates: true, + }); + + if (feedbackRows.length > 0) { + // id가 이벤트 id라 재전송돼도 한 행만 남는다. + await transaction.recommendationFeedback.createMany({ + data: feedbackRows, + skipDuplicates: true, + }); + } }); return { accepted: true }; @@ -3798,6 +3960,7 @@ export const soundlogService = { async createRecap( userId: string, input: { + backgroundImageUrl?: string; momentLogIds?: string[]; representativeTrackId?: string; routePoints?: RoutePointDto[]; @@ -3986,7 +4149,7 @@ export const soundlogService = { momentCount: moments.length, sessionId: input.sessionId, travelSessionId: input.sessionId, - backgroundImageUrl: thumbnailMoment.photoUrl, + backgroundImageUrl: thumbnailMoment.photoUrl ?? input.backgroundImageUrl, discImageUrl: representativeMoment.photoUrl, recordedAt: representativeMoment.createdAt, moments: moments.map(momentLogToRecapShareMoment) as Prisma.JsonArray, @@ -4026,6 +4189,10 @@ export const soundlogService = { ); }, + async getRecapBackgroundSuggestion(input: RecapBackgroundSuggestionInput) { + return fetchMlBackgroundSuggestion(input); + }, + async getRecapShare(userId: string, recapId: string) { const hiddenUserIds = await getCommunityHiddenUserIds(userId); const recap = await prisma.recap.findFirst({ diff --git a/src/validators/api.validators.ts b/src/validators/api.validators.ts index 3ebcab3..1e4b1cd 100644 --- a/src/validators/api.validators.ts +++ b/src/validators/api.validators.ts @@ -107,10 +107,106 @@ const recommendationContextSchema = z placeId: z.string().optional(), placeName: z.string().optional(), recommendationMode: recommendationModeSchema.optional(), + // 음악 추천 출처 또는 'recommended-photo:<관광 데이터 출처>'. + // z.object()는 모르는 키를 조용히 버리므로 여기에 선언돼 있어야 서비스까지 넘어간다. + source: z.string().trim().min(1).max(120).optional(), travelMode: travelModeSchema.optional(), }) .default({}); +export const RECOMMENDATION_FEEDBACK_TYPE = 'recommendation_feedback'; +export const SUPPORTED_RECOMMENDATION_FEEDBACK_VERSIONS = [1] as const; + +/** + * 이벤트 value에 담겨 오는 피드백 본문. + * + * 검증 실패 메시지에 opinion 원문을 절대 넣지 않는다 — error 미들웨어가 + * details.issues를 그대로 응답에 실어 보내기 때문에 의견이 밖으로 샌다. + */ +export const recommendationFeedbackValueSchema = z.object({ + opinion: z.string().trim().min(1).max(300).optional(), + rating: z.number().int().min(1).max(5), + subject: z.enum(['music', 'photo']), + version: z.literal(SUPPORTED_RECOMMENDATION_FEEDBACK_VERSIONS), +}); + +export type RecommendationFeedbackValue = z.infer< + typeof recommendationFeedbackValueSchema +>; + +type FeedbackRejectionCode = + | 'INVALID_FEEDBACK_VALUE' + | 'MISSING_FEEDBACK_TARGET' + | 'UNSUPPORTED_FEEDBACK_VERSION'; + +/** + * value 문자열을 파싱해 피드백 본문을 얻는다. 형식이 어긋나면 사유 코드를 돌려준다. + * 서비스 계층에서도 같은 함수를 써서 검증과 저장이 갈라지지 않게 한다. + */ +export function parseRecommendationFeedbackValue(raw?: string): + | { code: FeedbackRejectionCode; message: string; ok: false } + | { ok: true; value: RecommendationFeedbackValue } { + if (typeof raw !== 'string' || raw.trim() === '') { + return { + code: 'INVALID_FEEDBACK_VALUE', + message: 'value에 피드백 JSON 문자열이 필요합니다.', + ok: false, + }; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + return { + code: 'INVALID_FEEDBACK_VALUE', + message: 'value를 JSON으로 해석할 수 없습니다.', + ok: false, + }; + } + + // 지원하지 않는 version은 나머지 필드를 해석하지 않고 먼저 끊는다. + const version = (parsed as { version?: unknown } | null)?.version; + + if ( + typeof version !== 'number' || + !SUPPORTED_RECOMMENDATION_FEEDBACK_VERSIONS.includes( + version as (typeof SUPPORTED_RECOMMENDATION_FEEDBACK_VERSIONS)[number], + ) + ) { + return { + code: 'UNSUPPORTED_FEEDBACK_VERSION', + message: `지원하는 피드백 version은 ${SUPPORTED_RECOMMENDATION_FEEDBACK_VERSIONS.join(', ')} 입니다.`, + ok: false, + }; + } + + const result = recommendationFeedbackValueSchema.safeParse(parsed); + + if (!result.success) { + const hasRatingIssue = result.error.issues.some( + (issue) => issue.path[0] === 'rating', + ); + const hasOpinionIssue = result.error.issues.some( + (issue) => issue.path[0] === 'opinion', + ); + + return { + code: 'INVALID_FEEDBACK_VALUE', + message: hasRatingIssue + ? 'rating은 1부터 5까지의 정수여야 합니다.' + : hasOpinionIssue + ? 'opinion은 공백을 제외하고 1자 이상 300자 이하여야 합니다.' + : 'subject는 music 또는 photo여야 합니다.', + ok: false, + }; + } + + return { ok: true, value: result.data }; +} + + export const authValidators = { loginBody: z.object({ email: z.string().trim().email(), @@ -339,8 +435,40 @@ export const recommendationEventValidators = { 'recommendation_mode_change', 'top_filter_change', 'recap_representative_track_select', + RECOMMENDATION_FEEDBACK_TYPE, ]), value: z.string().optional(), + }) + .superRefine((event, ctx) => { + if (event.type !== RECOMMENDATION_FEEDBACK_TYPE) { + return; + } + + const parsed = parseRecommendationFeedbackValue(event.value); + + if (!parsed.ok) { + ctx.addIssue({ + code: 'custom', + message: parsed.message, + params: { eventId: event.id, feedbackCode: parsed.code }, + path: ['value'], + }); + + return; + } + + // 음악은 어떤 플레이리스트를 평가했는지 없으면 분석에 못 쓴다. + if (parsed.value.subject === 'music' && !event.playlistId) { + ctx.addIssue({ + code: 'custom', + message: '음악 피드백에는 playlistId가 필요합니다.', + params: { + eventId: event.id, + feedbackCode: 'MISSING_FEEDBACK_TARGET', + }, + path: ['playlistId'], + }); + } }), ) .min(1) @@ -361,6 +489,9 @@ export const recapValidators = { scope: z.enum(['all', 'mine', 'others']).optional().default('mine'), }), createBody: z.object({ + // ML 배경 추천(/v1/recaps/background-suggestion) 결과를 저장할 때 보낸다. + // 대표 캡처에 사진이 없을 때만 배경으로 쓰인다 — 사용자 사진이 항상 우선. + backgroundImageUrl: z.string().url().optional(), momentLogIds: z.array(z.string()).optional(), representativeTrackId: z.string().optional(), routePoints: routePointsSchema.optional(), @@ -369,6 +500,14 @@ export const recapValidators = { title: z.string().optional(), visibility: recapVisibilitySchema.optional().default('private'), }), + // 리캡 배경 추천 — contextual 플레이리스트와 같은 어휘(travelMode/moodTags)를 받는다. + backgroundSuggestionBody: z.object({ + location: geoPointSchema, + mood: mlMoodSchema.optional(), + moodTags: z.array(moodTagSchema).optional(), + state: mlTravelStateSchema.optional(), + travelMode: travelModeSchema.optional(), + }), recapParams: z.object({ recapId: z.string().min(1), }), diff --git a/tests/recommendation-feedback.test.ts b/tests/recommendation-feedback.test.ts new file mode 100644 index 0000000..f90e02f --- /dev/null +++ b/tests/recommendation-feedback.test.ts @@ -0,0 +1,239 @@ +import request from 'supertest'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import { CURRENT_TERMS_VERSION } from '../src/constants/legal.constants.js'; +import { createApp } from '../src/app.js'; +import { mockDb } from '../src/mock/mock-db.js'; + +/** + * RECOMMENDATION_FEEDBACK_SERVER_SPEC.md 8절 수용 시나리오. + * + * mock 모드에서 돈다 — Postgres 없이 계약만 검증한다. + * USE_MOCK_DB=true npx vitest run tests/recommendation-feedback.test.ts + */ +const app = createApp(); + +let accessToken: string; + +async function getToken() { + const email = `feedback-${Date.now()}-${Math.random().toString(36).slice(2)}@soundlog.test`; + const password = 'soundlog-password'; + + // 이 서버는 약관 동의 없이는 가입이 막힌다 (앱 심사 대응). + await request(app).post('/v1/auth/register').send({ + displayName: '피드백 테스트', + email, + password, + termsAccepted: true, + termsVersion: CURRENT_TERMS_VERSION, + }); + + const login = await request(app).post('/v1/auth/login').send({ email, password }); + + expect(login.status).toBe(200); + + return login.body.data.accessToken as string; +} + +let counter = 0; + +function feedbackEvent(overrides: { + context?: Record; + id?: string; + playlistId?: string | null; + value?: unknown; +}) { + counter += 1; + + const event: Record = { + context: overrides.context ?? { source: 'ml-recommendation' }, + createdAt: new Date().toISOString(), + id: overrides.id ?? `event-test-${Date.now()}-${counter}`, + sessionId: `session-test-${Date.now()}`, + type: 'recommendation_feedback', + value: + typeof overrides.value === 'string' + ? overrides.value + : JSON.stringify( + overrides.value ?? { + rating: 5, + subject: 'music', + version: 1, + }, + ), + }; + + if (overrides.playlistId !== null) { + event.playlistId = overrides.playlistId ?? 'playlist-seoul-night'; + } + + return event; +} + +function post(events: Array>) { + return request(app) + .post('/v1/recommendation-events') + .set('Authorization', `Bearer ${accessToken}`) + .set('Idempotency-Key', String(events[0]?.id ?? 'batch')) + .send({ events }); +} + +function storedById(id: string) { + return mockDb.recommendationFeedbacks.find((item) => item.id === id); +} + +describe('추천 피드백', () => { + beforeAll(async () => { + accessToken = await getToken(); + }); + + it('음악에 별점만 보낸 이벤트를 저장한다', async () => { + const event = feedbackEvent({ value: { rating: 5, subject: 'music', version: 1 } }); + const response = await post([event]); + + expect(response.status).toBe(202); + expect(response.body.data.accepted).toBe(true); + + const stored = storedById(event.id as string); + + expect(stored?.rating).toBe(5); + expect(stored?.subject).toBe('music'); + expect(stored?.opinion).toBeNull(); + expect(stored?.playlistId).toBe('playlist-seoul-night'); + }); + + it('음악에 별점과 의견을 함께 저장한다', async () => { + const event = feedbackEvent({ + value: { + opinion: '산책할 때 잘 어울렸어요', + rating: 3, + subject: 'music', + version: 1, + }, + }); + + expect((await post([event])).status).toBe(202); + expect(storedById(event.id as string)?.opinion).toBe('산책할 때 잘 어울렸어요'); + }); + + it('추천사진 피드백을 장소와 연결해 저장한다', async () => { + const event = feedbackEvent({ + context: { + placeId: 'tour-126508', + placeName: '서울숲', + source: 'recommended-photo:tour-api', + }, + playlistId: null, + value: { rating: 4, subject: 'photo', version: 1 }, + }); + + expect((await post([event])).status).toBe(202); + + const stored = storedById(event.id as string); + + expect(stored?.subject).toBe('photo'); + expect(stored?.placeId).toBe('tour-126508'); + expect(stored?.placeName).toBe('서울숲'); + // context.source가 zod 스키마에 없으면 여기서 null이 된다. + expect(stored?.source).toBe('recommended-photo:tour-api'); + }); + + it('공백만 있는 의견은 거부한다', async () => { + const response = await post([ + feedbackEvent({ + value: { opinion: ' ', rating: 4, subject: 'music', version: 1 }, + }), + ]); + + expect(response.status).toBe(400); + }); + + it('0점과 6점과 소수점 별점을 거부한다', async () => { + for (const rating of [0, 6, 3.5]) { + const response = await post([ + feedbackEvent({ value: { rating, subject: 'music', version: 1 } }), + ]); + + expect(response.status).toBe(400); + } + }); + + it('301자 의견을 거부한다', async () => { + const response = await post([ + feedbackEvent({ + value: { + opinion: '가'.repeat(301), + rating: 4, + subject: 'music', + version: 1, + }, + }), + ]); + + expect(response.status).toBe(400); + }); + + it('지원하지 않는 version을 거부한다', async () => { + const response = await post([ + feedbackEvent({ value: { rating: 4, subject: 'music', version: 2 } }), + ]); + + expect(response.status).toBe(400); + expect( + response.body.error.details.issues.some( + (issue: { params?: { feedbackCode?: string } }) => + issue.params?.feedbackCode === 'UNSUPPORTED_FEEDBACK_VERSION', + ), + ).toBe(true); + }); + + it('음악 피드백에 playlistId가 없으면 거부한다', async () => { + const response = await post([ + feedbackEvent({ + playlistId: null, + value: { rating: 4, subject: 'music', version: 1 }, + }), + ]); + + expect(response.status).toBe(400); + }); + + it('JSON이 아닌 value를 거부한다', async () => { + const response = await post([feedbackEvent({ value: 'not-json' })]); + + expect(response.status).toBe(400); + }); + + it('같은 이벤트를 두 번 보내도 하나만 저장한다', async () => { + const event = feedbackEvent({ id: `event-dup-${Date.now()}` }); + + expect((await post([event])).status).toBe(202); + expect((await post([event])).status).toBe(202); + + const matches = mockDb.recommendationFeedbacks.filter( + (item) => item.id === event.id, + ); + + expect(matches).toHaveLength(1); + }); + + it('저장 데이터에 사진 URI가 없다', async () => { + const serialized = JSON.stringify(mockDb.recommendationFeedbacks); + + expect(serialized).not.toMatch(/https?:\/\//); + expect(serialized).not.toMatch(/file:\/\//); + }); + + it('한 이벤트가 잘못되면 배치 전체를 거부한다', async () => { + const good = feedbackEvent({ id: `event-batch-good-${Date.now()}` }); + const bad = feedbackEvent({ + id: `event-batch-bad-${Date.now()}`, + value: { rating: 9, subject: 'music', version: 1 }, + }); + + const response = await post([good, bad]); + + expect(response.status).toBe(400); + expect(storedById(good.id as string)).toBeUndefined(); + }); +});