diff --git a/README.md b/README.md index 295c9f3..340ec16 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ SoundLog React Native/Expo 앱과 연동되는 Express + TypeScript API 서버 API 구현 기준은 `openapi/soundlog-api.yaml`이며, 현재 Express와 OpenAPI에 동기화된 62개 HTTP 연산을 제공합니다. +위치와 무드 기반 추천 모델의 구현 기준은 +[`SoundLogTeam/soundlog-ml`](https://github.com/SoundLogTeam/soundlog-ml)입니다. +과거 개인 API 스냅샷이 아니라 이 저장소와 공식 ML 저장소를 함께 변경합니다. + +팀에서 활성 관리하는 세 저장소와 과거 이력 보존 위치는 +[저장소 구조 문서](docs/repository-structure.md)에서 확인할 수 있습니다. + 리캡, 여행 로그, 여행 세션, GPS 경로를 변경할 때는 [Recap / Log 서버 도메인 계약](docs/recap-log-domain-contract.md)을 먼저 확인합니다. ## Stack diff --git a/docs/production-deployment.md b/docs/production-deployment.md index df7c23c..6fe5a07 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -45,6 +45,11 @@ ALLOW_DEV_AUTH_FALLBACK=false `ML_RECOMMENDATION_API_URL`은 API 서버가 내부적으로 호출할 HTTPS 추천 엔드포인트입니다. 앱은 이 내부 주소를 직접 호출하지 않고 `https://api.soundlog.p-e.kr/v1/recommendations/playlists`만 호출합니다. 배포 후 공개 계약 검사는 이 경로가 폴백이 아닌 `ml-recommendation` 결과와 HTTPS 커버 이미지를 반환하는지 확인합니다. +추천 서비스 코드와 `/recommend` 응답 계약은 +[`SoundLogTeam/soundlog-ml`](https://github.com/SoundLogTeam/soundlog-ml)을 기준으로 +관리합니다. `backgroundImageUrl` 계약을 변경할 때는 ML 저장소의 `API.md`와 이 +서버의 응답 변환 테스트를 같은 작업에서 확인합니다. + ## 배포 중 보호 절차 워크플로는 새 compose 파일을 복사하기 전에 기존 `.env`와 `docker-compose.prod.yml`을 `.deploy-backups/`에 보관합니다. 새 API가 시작되지 않거나 내부 계약 검사가 실패하면 이전 파일로 되돌리고 기존 컨테이너를 다시 시작합니다. diff --git a/docs/repository-structure.md b/docs/repository-structure.md new file mode 100644 index 0000000..0a9cb4e --- /dev/null +++ b/docs/repository-structure.md @@ -0,0 +1,31 @@ +# SoundLog 저장소 구조 + +SoundLogTeam은 다음 세 저장소만 활성 개발 대상으로 관리합니다. + +| 저장소 | 책임 | +| --- | --- | +| `SoundLogTeam/SoundLogApp` | React Native와 Expo 기반 모바일 앱 | +| `SoundLogTeam/SoundLogServer` | Node.js API와 데이터베이스와 배포 | +| `SoundLogTeam/soundlog-ml` | 위치와 무드 기반 음악 및 장소 이미지 추천 | + +OpenAPI의 구현 기준은 이 저장소의 `openapi/soundlog-api.yaml`입니다. API 문서만 +별도 저장소에서 수정하지 않습니다. 추천 서비스의 `/recommend` 계약은 +`SoundLogTeam/soundlog-ml`의 `API.md`를 함께 확인합니다. + +## 과거 이력 보존 + +기존 `SoundLogTeam/api-docs`와 `KimJaegeol1/soundlog-api`의 Git 이력은 삭제하지 +않고 이 저장소의 다음 태그로 보존했습니다. + +- `archive/api-docs-final-2026-08-26` +- `archive/soundlog-api-personal-main-2026-08-26` + +태그의 파일을 확인하려면 새 작업 폴더에서 다음 명령을 사용합니다. + +```bash +git worktree add ../soundlog-api-docs-archive archive/api-docs-final-2026-08-26 +git worktree add ../soundlog-api-snapshot-archive archive/soundlog-api-personal-main-2026-08-26 +``` + +두 태그는 과거 이력 확인용입니다. 새 브랜치를 만들거나 운영 코드를 수정할 +때의 기준으로 사용하지 않습니다. diff --git a/src/services/soundlog.service.ts b/src/services/soundlog.service.ts index b08adcc..26f22c7 100644 --- a/src/services/soundlog.service.ts +++ b/src/services/soundlog.service.ts @@ -30,6 +30,7 @@ import { getLimit, paginateByCursor } from '../utils/pagination.js'; import { createPublicId } from '../utils/tokens.js'; import { badRequest, forbidden, notFound } from '../utils/http-error.js'; import { findRegionalPlaylistId } from '../utils/regional-playlist.js'; +import { createMlRecommendationArtwork } from '../utils/ml-recommendation-artwork.js'; import { reverseGeocodeLocation } from './reverse-geocoding.service.js'; import { assertUserTextAllowed, @@ -98,6 +99,7 @@ type MlMood = '잔잔한' | '신나는' | '시원한' | '설레는' | '감성적 const RECAP_DISCOVERY_RADIUS_METERS = 300; type MlRecommendationResponse = { + backgroundImageUrl?: unknown; tracks?: unknown; }; @@ -1013,8 +1015,7 @@ async function fetchMlRecommendationPlaylist( regionName: state, placeName: input.placeId, reason: `${state} 중인 지금, ${mood} 무드에 맞춰 추천했어요`, - coverImageUrl: undefined, - backgroundImageUrl: undefined, + ...createMlRecommendationArtwork(data?.backgroundImageUrl), trackCount: tracks.length, durationText: `${tracks.length * 4}:00분`, context: { diff --git a/src/utils/ml-recommendation-artwork.ts b/src/utils/ml-recommendation-artwork.ts new file mode 100644 index 0000000..484891a --- /dev/null +++ b/src/utils/ml-recommendation-artwork.ts @@ -0,0 +1,21 @@ +type MlRecommendationArtwork = { + backgroundImageUrl?: string; + coverImageUrl?: string; +}; + +export function createMlRecommendationArtwork(value: unknown): MlRecommendationArtwork { + if (typeof value !== 'string') { + return {}; + } + + const imageUrl = value.trim(); + + if (!imageUrl) { + return {}; + } + + return { + backgroundImageUrl: imageUrl, + coverImageUrl: imageUrl, + }; +} diff --git a/tests/ml-recommendation-artwork.test.ts b/tests/ml-recommendation-artwork.test.ts new file mode 100644 index 0000000..871a8d5 --- /dev/null +++ b/tests/ml-recommendation-artwork.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { createMlRecommendationArtwork } from '../src/utils/ml-recommendation-artwork.js'; + +describe('createMlRecommendationArtwork', () => { + it('uses the ML background image for both playlist artwork fields', () => { + expect( + createMlRecommendationArtwork( + ' http://tong.visitkorea.or.kr/cms/resource/photo.jpg ', + ), + ).toEqual({ + backgroundImageUrl: 'http://tong.visitkorea.or.kr/cms/resource/photo.jpg', + coverImageUrl: 'http://tong.visitkorea.or.kr/cms/resource/photo.jpg', + }); + }); + + it.each([null, undefined, '', ' ', 123, {}])( + 'omits artwork for an unusable ML value: %p', + (value) => { + expect(createMlRecommendationArtwork(value)).toEqual({}); + }, + ); +});