From 76bf3d93eb822c0fbfdd92dea147d430b881c72b Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:03:00 +0900 Subject: [PATCH 01/27] =?UTF-8?q?fix:=20=EC=84=9C=EB=B2=84=20API=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=EB=B0=8F=20=EC=9D=8C=EC=95=85=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.config.js | 5 - app/(tabs)/index.tsx | 10 +- app/(tabs)/my.tsx | 88 ----- app/_layout.tsx | 1 - app/spotify-auth.tsx | 16 - ...er-api-and-streaming-removal-audit-plan.md | 66 ++++ package-lock.json | 48 --- package.json | 2 - scripts/check-store-release.js | 18 - src/api/authApi.ts | 120 +++--- src/api/homeApi.ts | 11 +- src/api/meApi.ts | 15 - src/api/mockServerClient.ts | 9 + src/api/playlistApi.ts | 7 +- src/api/recapApi.ts | 14 +- src/api/tourApi.ts | 3 +- src/components/MiniPlayer.tsx | 170 +++------ src/components/auth/AppEntryGate.tsx | 5 +- src/components/dev/DevTestManager.tsx | 2 +- src/components/library/LibraryScreen.tsx | 16 +- src/components/library/LibraryTrackRow.tsx | 2 +- .../my/MusicPlatformSettingsCard.tsx | 136 ------- .../playlist/PlaylistCurationScreen.tsx | 22 +- src/components/playlist/PlaylistHeroInfo.tsx | 4 +- src/components/playlist/TrackActionMenu.tsx | 6 +- src/components/playlist/TrackRow.tsx | 2 +- src/components/travel/RecapCard.tsx | 4 +- src/components/travel/TravelReportModal.tsx | 20 +- src/components/travel/TravelStatusCard.tsx | 4 +- src/components/travel/travelData.ts | 6 +- src/mocks/playlistMocks.ts | 20 - src/spotify/spotifyAuth.ts | 198 ---------- src/spotify/spotifyPlayback.ts | 344 ------------------ src/store/devToolsStore.ts | 8 +- src/store/musicPlatformStore.ts | 38 -- src/store/playerStore.ts | 14 - src/store/recommendationEventStore.ts | 4 - src/store/spotifyAuthStore.ts | 63 ---- src/types/domain.ts | 4 +- src/utils/musicPlatformLinks.ts | 171 +-------- 40 files changed, 291 insertions(+), 1405 deletions(-) delete mode 100644 app/spotify-auth.tsx create mode 100644 docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md create mode 100644 src/api/mockServerClient.ts delete mode 100644 src/components/my/MusicPlatformSettingsCard.tsx delete mode 100644 src/spotify/spotifyAuth.ts delete mode 100644 src/spotify/spotifyPlayback.ts delete mode 100644 src/store/musicPlatformStore.ts delete mode 100644 src/store/spotifyAuthStore.ts diff --git a/app.config.js b/app.config.js index fd8f10e..fe6e751 100644 --- a/app.config.js +++ b/app.config.js @@ -9,7 +9,6 @@ const baseConfig = { ios: { infoPlist: { CFBundleAllowMixedLocalizations: true, - LSApplicationQueriesSchemes: ['spotify'], NSCameraUsageDescription: 'Soundlog가 여행 순간을 사진으로 기록하기 위해 카메라 권한이 필요합니다.', NSFaceIDUsageDescription: @@ -80,7 +79,6 @@ const baseConfig = { ], 'expo-secure-store', 'expo-image', - 'expo-web-browser', ], experiments: { typedRoutes: true, @@ -91,9 +89,6 @@ const baseConfig = { supportEmail: process.env.EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL ?? 'support@soundlog.app', termsUrl: process.env.EXPO_PUBLIC_SOUNDLOG_TERMS_URL, }, - spotify: { - clientId: process.env.EXPO_PUBLIC_SPOTIFY_CLIENT_ID, - }, eas: { projectId: '4b07627b-36bf-463d-a15e-b4839022ecbb', }, diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e8a7ecd..e554ea1 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -37,12 +37,12 @@ import { import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { queryClient } from '@/providers/queryClient'; -import { playSelectedSpotifyOrFallback } from '@/spotify/spotifyPlayback'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; import { FeaturedPlaylist, MoodRecommendation, MusicLogItem, TravelMode } from '@/types/domain'; import { requestForegroundLocationWithStatus } from '@/utils/location'; import { getMoodTagsFromFilter } from '@/utils/moodTags'; +import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; const moodFilterToMlMood: Record = { @@ -170,14 +170,16 @@ function HomeContent() { return; } + const externalLink = getTrackExternalLink(item.track); + setTrack(item.track); - void playSelectedSpotifyOrFallback(item.track); + void openMusicPlatformUrl(externalLink).catch(() => undefined); syncRecommendationEvent( addRecommendationEvent({ context: createRecommendationEventContext(), trackId: item.track.id, - type: 'track_play', - value: item.id, + type: 'track_external_open', + value: externalLink.platformId, }), ); }; diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index ef55409..f79650c 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -8,22 +8,13 @@ import { useNearbyPlacesQuery } from '@/api/tourQueries'; import { AppText } from '@/components/AppText'; import { LocationContextCard } from '@/components/home/LocationContextCard'; import { AuthAccountCard } from '@/components/my/AuthAccountCard'; -import { MusicPlatformSettingsCard } from '@/components/my/MusicPlatformSettingsCard'; import { PermissionSettingsCard } from '@/components/my/PermissionSettingsCard'; import { Screen } from '@/components/Screen'; import { useNativePermissionSettings } from '@/hooks/useNativePermissionSettings'; -import { useMusicPlatformStore } from '@/store/musicPlatformStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { useSpotifyAuthStore } from '@/store/spotifyAuthStore'; import { useTravelSessionStore } from '@/store/travelSessionStore'; import { useUserProfileStore } from '@/store/userProfileStore'; -import { - connectSpotifyAccount, - getSpotifyAuthErrorMessage, - isSpotifyConfigured, -} from '@/spotify/spotifyAuth'; import { requestForegroundLocationWithStatus } from '@/utils/location'; -import { MusicPlatformId } from '@/types/domain'; type MyMenuItem = { description?: string; @@ -50,16 +41,6 @@ export default function MyScreen() { const { profile, resetOnboarding, updateProfile } = useUserProfileStore(); const { clearEvents, events, isHydrated } = useRecommendationEventStore(); const permissionSettings = useNativePermissionSettings(); - const { selectedPlatformId, setSelectedPlatform } = useMusicPlatformStore(); - const { - clearSession: clearSpotifySession, - errorMessage: spotifyErrorMessage, - isConnecting: isSpotifyConnecting, - session: spotifySession, - setConnecting: setSpotifyConnecting, - setError: setSpotifyError, - setSession: setSpotifySession, - } = useSpotifyAuthStore(); const { currentLocation, currentPlace, @@ -105,63 +86,6 @@ export default function MyScreen() { void meApi.updateProfile(nextProfile).catch(() => undefined); }, [profile, updateProfile]); - const handleSelectMusicPlatform = useCallback( - (platformId: MusicPlatformId) => { - setSelectedPlatform(platformId); - void meApi - .updateMusicPlatform({ - connected: platformId === 'spotify' ? Boolean(spotifySession) : platformId !== 'none', - selectedPlatformId: platformId, - }) - .catch(() => undefined); - }, - [setSelectedPlatform, spotifySession], - ); - - const handleConnectSpotify = useCallback(async () => { - if (isSpotifyConnecting) { - return; - } - - setSelectedPlatform('spotify'); - setSpotifyConnecting(true); - setSpotifyError(undefined); - - try { - const session = await connectSpotifyAccount(); - - setSpotifySession(session); - void meApi - .updateMusicPlatform({ - connected: true, - providerUserId: session.userId, - selectedPlatformId: 'spotify', - }) - .catch(() => undefined); - } catch (error) { - setSpotifyError(getSpotifyAuthErrorMessage(error)); - } finally { - setSpotifyConnecting(false); - } - }, [ - isSpotifyConnecting, - setSelectedPlatform, - setSpotifyConnecting, - setSpotifyError, - setSpotifySession, - ]); - - const handleDisconnectSpotify = useCallback(() => { - clearSpotifySession(); - setSelectedPlatform('none'); - void meApi - .updateMusicPlatform({ - connected: false, - selectedPlatformId: 'none', - }) - .catch(() => undefined); - }, [clearSpotifySession, setSelectedPlatform]); - const handleRefreshLocation = useCallback(async () => { if (locationStatus === 'loading') { return; @@ -274,18 +198,6 @@ export default function MyScreen() { /> - - {menuItems.map((item) => ( - diff --git a/app/spotify-auth.tsx b/app/spotify-auth.tsx deleted file mode 100644 index 9737de8..0000000 --- a/app/spotify-auth.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { View } from 'react-native'; - -import { AppText } from '@/components/AppText'; -import { Screen } from '@/components/Screen'; - -export default function SpotifyAuthCallbackScreen() { - return ( - - - - Spotify 연결을 확인하고 있어요. - - - - ); -} diff --git a/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md new file mode 100644 index 0000000..c99adfd --- /dev/null +++ b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md @@ -0,0 +1,66 @@ +# Server API and streaming removal audit plan + +## Goal + +Make the app behavior honest and testable: + +- production/web server mode must call the real Soundlog API, not the in-app mock server +- mock data must remain an explicit local/dev fallback only +- music recommendation can stay, but in-app music streaming and Spotify playback control must be removed +- any music action shown to users must either open an external music link/search or clearly behave as local selection only + +## Evidence gathered + +- Live `https://sound-log-app.vercel.app/api/soundlog/v1/health` returns the EC2 server health payload. +- The current live web bundle contains `getApiBaseUrl() => "/api/soundlog"` and initializes `apiSource: "server"`. +- Live home endpoints respond through the Vercel proxy: + - `/api/soundlog/v1/home/mood-recommendations` + - `/api/soundlog/v1/playlists/{id}` +- The app still bundles `mockServer` modules because API facades statically import them for local fallback. +- Main server-backed read APIs use server mode, but authenticated APIs intentionally return empty/no-op values when the user is a guest or has no token. +- Current playback UI is misleading: `playerStore.setTrack()` sets `isPlaying: true` even when no preview or stream exists. +- Spotify auth/playback code remains in the app even though streaming will be removed. + +## Scope + +### App changes + +1. Server/mock audit hardening + - Keep mock server available for explicit local/dev mode. + - Add a small runtime helper that can report whether the app is using server or mock mode. + - Add documentation/verification notes explaining why mock code may exist in the JS bundle but should not be selected at runtime in server mode. + +2. Remove in-app streaming and Spotify playback + - Remove Spotify OAuth callback route and Spotify auth/playback modules. + - Remove Spotify/music platform connection UI from My page. + - Remove production release requirement for `EXPO_PUBLIC_SPOTIFY_CLIENT_ID`. + - Remove `expo-auth-session`, `expo-web-browser`, Spotify app query scheme, and Spotify extra config if unused. + +3. Make music actions honest + - Replace `playSelectedSpotifyOrFallback` usage with a simple external-link action. + - Change playlist, library, home recommendation, and mini-player actions to: + - select the track in local player context + - open an external music URL/search when available + - record `track_external_open`, not fake `track_play` + - Stop setting `isPlaying: true` when a track is selected. + - Replace play/pause copy/icons that imply in-app streaming with external/open/select wording. + +4. Verification + - `npm run typecheck` + - `npm run check:store-release` without Spotify client id + - web export with server proxy env + - search for removed Spotify playback/auth imports + - search built bundle for `/api/soundlog` and runtime `apiSource: "server"` + +## Non-goals for this first loop + +- Implement actual audio preview playback. +- Implement Spotify/Melon SDK streaming. +- Remove all track metadata links from seed data. +- Remove server-side music platform endpoints, because they may remain backward-compatible and are not user-facing after this app change. + +## Risks + +- Removing `expo-web-browser` is safe only if no other active route imports it. +- Some users may still expect a persistent mini-player; it should become a selected-track/external-open panel rather than a streaming control. +- The server seed data intentionally resembles mock data, so visual similarity alone does not prove mock usage. diff --git a/package-lock.json b/package-lock.json index 30cc540..0320baa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", "expo": "~56.0.12", - "expo-auth-session": "~56.0.14", "expo-build-properties": "~56.0.19", "expo-camera": "~56.0.8", "expo-constants": "~56.0.15", @@ -28,7 +27,6 @@ "expo-secure-store": "~56.0.4", "expo-sharing": "~56.0.18", "expo-status-bar": "~56.0.4", - "expo-web-browser": "~56.0.5", "nativewind": "^4.2.4", "react": "19.2.3", "react-dom": "19.2.3", @@ -4698,42 +4696,6 @@ "react-native": "*" } }, - "node_modules/expo-auth-session": { - "version": "56.0.14", - "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-56.0.14.tgz", - "integrity": "sha512-b6URDBKXVWBjHwypnbCPW6A3PrwYyFqzLXtTrrpTGpmDlsxk7xuz6wIA77sBNziz3hMxt11Nu70iY0fJZYT4jA==", - "license": "MIT", - "dependencies": { - "expo-application": "~56.0.3", - "expo-constants": "~56.0.18", - "expo-crypto": "~56.0.4", - "expo-linking": "~56.0.14", - "expo-web-browser": "~56.0.5", - "invariant": "^2.2.4" - }, - "peerDependencies": { - "react": "*", - "react-native": "*" - } - }, - "node_modules/expo-auth-session/node_modules/expo-application": { - "version": "56.0.3", - "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-56.0.3.tgz", - "integrity": "sha512-DdGGPlMuM6cSTeKhbvh6OeLr2O/+EI5BHKYrD+Do8sJPYgLwzGrgESELfyjJCpEhFzT+TgKIdmLmWXhNUQnHiw==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, - "node_modules/expo-auth-session/node_modules/expo-crypto": { - "version": "56.0.4", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz", - "integrity": "sha512-fRNEhoXRXgAWBpe3/hq5X+KXTit3OZqdiAGts1YvNEUHQb+H5591mpPac0Yw+sZg9pXcrjRnzo5AxvZaENpc7g==", - "license": "MIT", - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-build-properties": { "version": "56.0.19", "resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz", @@ -5179,16 +5141,6 @@ "expo": "*" } }, - "node_modules/expo-web-browser": { - "version": "56.0.5", - "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-56.0.5.tgz", - "integrity": "sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-keep-awake": { "version": "56.0.3", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", diff --git a/package.json b/package.json index 6d90912..d21b66c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,6 @@ "@react-native-async-storage/async-storage": "2.2.0", "@tanstack/react-query": "^5.100.14", "expo": "~56.0.12", - "expo-auth-session": "~56.0.14", "expo-build-properties": "~56.0.19", "expo-camera": "~56.0.8", "expo-constants": "~56.0.15", @@ -23,7 +22,6 @@ "expo-secure-store": "~56.0.4", "expo-sharing": "~56.0.18", "expo-status-bar": "~56.0.4", - "expo-web-browser": "~56.0.5", "nativewind": "^4.2.4", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/scripts/check-store-release.js b/scripts/check-store-release.js index f84b7a0..124801a 100644 --- a/scripts/check-store-release.js +++ b/scripts/check-store-release.js @@ -84,16 +84,11 @@ function assertProductionEnv(productionEnv) { const privacyUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL; const termsUrl = productionEnv.EXPO_PUBLIC_SOUNDLOG_TERMS_URL; const supportEmail = productionEnv.EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL; - const spotifyClientId = productionEnv.EXPO_PUBLIC_SPOTIFY_CLIENT_ID; if (!apiBaseUrl?.startsWith('https://')) { addError('EAS production env must set EXPO_PUBLIC_SOUNDLOG_API_BASE_URL to an HTTPS URL.'); } - if (!spotifyClientId) { - addError('EAS production env must set EXPO_PUBLIC_SPOTIFY_CLIENT_ID for Spotify playback.'); - } - if (!privacyUrl?.startsWith('https://')) { addError('EAS production env must set EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL to an HTTPS URL.'); } @@ -168,18 +163,6 @@ function assertTransportSecurity(config) { } } -function assertSpotifyConfig(config) { - const querySchemes = new Set(config.ios?.infoPlist?.LSApplicationQueriesSchemes ?? []); - - if (!querySchemes.has('spotify')) { - addError('iOS config must include spotify in LSApplicationQueriesSchemes.'); - } - - if (!hasPlugin(config, 'expo-web-browser')) { - addError('Expo config must include expo-web-browser plugin for Spotify OAuth.'); - } -} - function assertNativeIosPlist() { const plistPath = path.join(projectRoot, 'ios/Soundlog/Info.plist'); const entitlementsPath = path.join(projectRoot, 'ios/Soundlog/Soundlog.entitlements'); @@ -234,7 +217,6 @@ function main() { assertAppIcon(config); assertAndroidPermissions(config); assertTransportSecurity(config); - assertSpotifyConfig(config); } assertNativeIosPlist(); diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 7163966..7438f6d 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -1,5 +1,5 @@ import { createIdempotencyKey, requestApi, shouldUseServerApi } from '@/api/client'; -import { mockServer } from '@/mock-server'; +import { getMockServer } from '@/api/mockServerClient'; import { AuthMe, AuthSession, @@ -10,51 +10,75 @@ import { } from '@/types/auth'; export const authApi = { - getMe: () => - shouldUseServerApi() - ? requestApi('/v1/me') - : mockServer.auth.getMe(), - login: (request: LoginRequest) => - shouldUseServerApi() - ? requestApi('/v1/auth/login', { - auth: false, - body: request, - method: 'POST', - retryOnUnauthorized: false, - }) - : mockServer.auth.login(request), - logout: (refreshToken?: string) => - shouldUseServerApi() - ? requestApi<{ accepted: boolean }>('/v1/auth/logout', { - auth: false, - body: { refreshToken }, - method: 'POST', - }) - : mockServer.auth.logout(), - migrateLocalData: (payload: LocalDataMigrationPayload) => - shouldUseServerApi() - ? requestApi('/v1/me/migrate-local-data', { - body: payload, - idempotencyKey: payload.idempotencyKey ?? createIdempotencyKey('migration'), - method: 'POST', - }) - : mockServer.auth.migrateLocalData(payload), - refresh: (refreshToken?: string) => - shouldUseServerApi() - ? requestApi('/v1/auth/refresh', { - auth: false, - body: { refreshToken }, - method: 'POST', - retryOnUnauthorized: false, - }) - : mockServer.auth.refresh(refreshToken), - register: (request: RegisterRequest) => - shouldUseServerApi() - ? requestApi('/v1/auth/register', { - auth: false, - body: request, - method: 'POST', - retryOnUnauthorized: false, - }) - : mockServer.auth.register(request), + getMe: async () => { + if (shouldUseServerApi()) { + return requestApi('/v1/me'); + } + + const mockServer = await getMockServer(); + return mockServer.auth.getMe(); + }, + login: async (request: LoginRequest) => { + if (shouldUseServerApi()) { + return requestApi('/v1/auth/login', { + auth: false, + body: request, + method: 'POST', + retryOnUnauthorized: false, + }); + } + + const mockServer = await getMockServer(); + return mockServer.auth.login(request); + }, + logout: async (refreshToken?: string) => { + if (shouldUseServerApi()) { + return requestApi<{ accepted: boolean }>('/v1/auth/logout', { + auth: false, + body: { refreshToken }, + method: 'POST', + }); + } + + const mockServer = await getMockServer(); + return mockServer.auth.logout(); + }, + migrateLocalData: async (payload: LocalDataMigrationPayload) => { + if (shouldUseServerApi()) { + return requestApi('/v1/me/migrate-local-data', { + body: payload, + idempotencyKey: payload.idempotencyKey ?? createIdempotencyKey('migration'), + method: 'POST', + }); + } + + const mockServer = await getMockServer(); + return mockServer.auth.migrateLocalData(payload); + }, + refresh: async (refreshToken?: string) => { + if (shouldUseServerApi()) { + return requestApi('/v1/auth/refresh', { + auth: false, + body: { refreshToken }, + method: 'POST', + retryOnUnauthorized: false, + }); + } + + const mockServer = await getMockServer(); + return mockServer.auth.refresh(refreshToken); + }, + register: async (request: RegisterRequest) => { + if (shouldUseServerApi()) { + return requestApi('/v1/auth/register', { + auth: false, + body: request, + method: 'POST', + retryOnUnauthorized: false, + }); + } + + const mockServer = await getMockServer(); + return mockServer.auth.register(request); + }, }; diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index 61538a3..4e80e7e 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -1,5 +1,5 @@ import { canUseAuthenticatedApi, requestApi, shouldUseServerApi } from '@/api/client'; -import { mockServer } from '@/mock-server'; +import { getMockServer } from '@/api/mockServerClient'; import type { FeaturedPlaylistMockParams, MoodRecommendationMockParams, @@ -11,8 +11,9 @@ import type { } from '@/types/domain'; export const homeApi = { - getFeaturedPlaylists: (params?: FeaturedPlaylistMockParams) => { + getFeaturedPlaylists: async (params?: FeaturedPlaylistMockParams) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.home.getFeaturedPlaylists(params); } @@ -27,8 +28,9 @@ export const homeApi = { }, }); }, - getMoodRecommendations: (params?: MoodRecommendationMockParams) => { + getMoodRecommendations: async (params?: MoodRecommendationMockParams) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.home.getMoodRecommendations(params); } @@ -44,8 +46,9 @@ export const homeApi = { }, }); }, - getRecentMusicLogs: () => { + getRecentMusicLogs: async () => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.home.getRecentMusicLogs(); } diff --git a/src/api/meApi.ts b/src/api/meApi.ts index 24460c4..878ec82 100644 --- a/src/api/meApi.ts +++ b/src/api/meApi.ts @@ -1,5 +1,4 @@ import { canUseAuthenticatedApi, requestApi } from '@/api/client'; -import { MusicPlatformId } from '@/types/domain'; import { UserProfile, UserProfileInput } from '@/store/userProfileStore'; type ServerCompanionType = 'couple' | 'family' | 'friends' | 'solo'; @@ -34,20 +33,6 @@ function toProfileBody(input: UserProfileInput) { } export const meApi = { - updateMusicPlatform: (input: { - connected?: boolean; - providerUserId?: string; - selectedPlatformId: MusicPlatformId; - }) => { - if (!canUseAuthenticatedApi()) { - return Promise.resolve(undefined); - } - - return requestApi('/v1/me/music-platform', { - body: input, - method: 'PUT', - }); - }, updateProfile: (input: UserProfileInput) => { if (!canUseAuthenticatedApi()) { return Promise.resolve(undefined); diff --git a/src/api/mockServerClient.ts b/src/api/mockServerClient.ts new file mode 100644 index 0000000..f2aff63 --- /dev/null +++ b/src/api/mockServerClient.ts @@ -0,0 +1,9 @@ +import type { MockServer } from '@/mock-server/types'; + +let mockServerPromise: Promise | undefined; + +export function getMockServer() { + mockServerPromise ??= import('@/mock-server').then((module) => module.mockServer); + + return mockServerPromise; +} diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 735268b..0a879ed 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -4,7 +4,7 @@ import { requestApi, shouldUseServerApi, } from '@/api/client'; -import { mockServer } from '@/mock-server'; +import { getMockServer } from '@/api/mockServerClient'; import type { GeoPoint, MoodTag, PlaylistCuration, TravelMode } from '@/types/domain'; export type PlaylistMlMood = '감성적인' | '설레는' | '시원한' | '신나는' | '잔잔한'; @@ -21,8 +21,9 @@ export type ContextualPlaylistInput = { }; export const playlistApi = { - getPlaylist: (id?: string) => { + getPlaylist: async (id?: string) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.playlist.getPlaylist(id); } @@ -30,7 +31,7 @@ export const playlistApi = { `/v1/playlists/${encodeURIComponent(id ?? 'fallback')}`, ); }, - createContextualPlaylist: ( + createContextualPlaylist: async ( input: ContextualPlaylistInput, fallbackPlaylistId?: string, ) => { diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 6dc53c3..a267a34 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -4,7 +4,7 @@ import { requestApi, shouldUseServerApi, } from '@/api/client'; -import { mockServer } from '@/mock-server'; +import { getMockServer } from '@/api/mockServerClient'; import type { RecapItem, RecapShare, RecapTemplateId } from '@/types/domain'; type CreateRecapInput = { @@ -18,8 +18,9 @@ type CreateRecapInput = { type RecapShareEventType = 'os_share' | 'save_image'; export const recapApi = { - createShareEvent: (recapId: string, type: RecapShareEventType) => { + createShareEvent: async (recapId: string, type: RecapShareEventType) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.recap.createShareEvent(recapId, type); } @@ -39,8 +40,9 @@ export const recapApi = { }, ); }, - createRecap: (input: CreateRecapInput) => { + createRecap: async (input: CreateRecapInput) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.recap.createRecap(input); } @@ -54,8 +56,9 @@ export const recapApi = { method: 'POST', }); }, - getRecapList: () => { + getRecapList: async () => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.recap.getRecapList(); } @@ -67,8 +70,9 @@ export const recapApi = { query: { limit: 20 }, }); }, - getRecapShare: (id?: string) => { + getRecapShare: async (id?: string) => { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.recap.getRecapShare(id); } diff --git a/src/api/tourApi.ts b/src/api/tourApi.ts index 2a4ed25..79513c6 100644 --- a/src/api/tourApi.ts +++ b/src/api/tourApi.ts @@ -1,5 +1,5 @@ import { requestApi, shouldUseServerApi } from '@/api/client'; -import { mockServer } from '@/mock-server'; +import { getMockServer } from '@/api/mockServerClient'; import type { GeoPoint, PlaceContext } from '@/types/domain'; type NearbyPlacesParams = { @@ -12,6 +12,7 @@ const DEFAULT_RADIUS_METERS = 2000; export const tourApi = { async getNearbyPlaces(params: NearbyPlacesParams): Promise { if (!shouldUseServerApi()) { + const mockServer = await getMockServer(); return mockServer.tour.getNearbyPlaces(params); } diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 9df07fa..d9b1ebc 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -11,15 +11,8 @@ import { AppText } from '@/components/AppText'; import { TrackActionMenu } from '@/components/playlist/TrackActionMenu'; import { getMiniPlayerBottom } from '@/constants/layout'; import { useLibraryStore } from '@/store/libraryStore'; -import { useMusicPlatformStore } from '@/store/musicPlatformStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { useSpotifyAuthStore } from '@/store/spotifyAuthStore'; -import { - getSpotifyPlaybackFailureMessage, - pauseSpotifyPlayback, - playSpotifyTrack, -} from '@/spotify/spotifyPlayback'; import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; import { getTrackKeyColor, hexToRgba } from '@/utils/trackVisuals'; @@ -33,17 +26,13 @@ export function MiniPlayer() { const insets = useSafeAreaInsets(); const { currentTrack, - isPlaying, playNext, playPrevious, playlistId, queue, - toggle, } = usePlayerStore(); const { isLiked, isSaved, setLikeState, setSaveState } = useLibraryStore(); - const selectedPlatformId = useMusicPlatformStore((state) => state.selectedPlatformId); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); - const spotifySession = useSpotifyAuthStore((state) => state.session); const [actionMessage, setActionMessage] = useState(); const [externalMessage, setExternalMessage] = useState(); const [isActionMenuVisible, setIsActionMenuVisible] = useState(false); @@ -60,17 +49,7 @@ export function MiniPlayer() { const playerGlow = hexToRgba(keyColor, 0.72); const playerSoftGlow = hexToRgba(keyColor, 0.24); const canSkip = queue.length > 1; - const externalLink = getTrackExternalLink(currentTrack, selectedPlatformId); - const shouldControlSpotify = selectedPlatformId === 'spotify' && Boolean(spotifySession); - const openSpotifyFallback = async (track = currentTrack) => { - const spotifyLink = getTrackExternalLink(track, 'spotify'); - - if (!spotifyLink.url) { - return; - } - - await openMusicPlatformUrl(spotifyLink); - }; + const externalLink = getTrackExternalLink(currentTrack); const getAdjacentTrack = (direction: 'next' | 'previous') => { if (!currentTrack || queue.length < 2) { return undefined; @@ -110,34 +89,33 @@ export function MiniPlayer() { }), ); }; - const handleTogglePlayback = async () => { - setExternalMessage(undefined); - - if (shouldControlSpotify) { - const spotifyResult = isPlaying - ? await pauseSpotifyPlayback() - : await playSpotifyTrack(currentTrack); + const handleOpenTrackExternal = async (track = currentTrack) => { + const link = getTrackExternalLink(track); - if (!spotifyResult.ok) { - setExternalMessage(getSpotifyPlaybackFailureMessage(spotifyResult.code)); + if (!link.url || isOpeningExternal) { + setExternalMessage('이 곡을 열 수 있는 링크를 만들지 못했어요.'); + return; + } - if (!isPlaying) { - await openSpotifyFallback().catch(() => undefined); - } + setIsOpeningExternal(true); + setExternalMessage(undefined); - return; - } + try { + await openMusicPlatformUrl(link); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId, + trackId: track.id, + type: 'track_external_open', + value: link.platformId, + }), + ); + } catch { + setExternalMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); + } finally { + setIsOpeningExternal(false); } - - toggle(); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId, - trackId: currentTrack.id, - type: isPlaying ? 'track_pause' : 'track_resume', - }), - ); }; const handleToggleSave = () => { const context = createRecommendationEventContext(); @@ -170,25 +148,12 @@ export function MiniPlayer() { const nextTrack = getAdjacentTrack('next'); - if (shouldControlSpotify && nextTrack) { - setExternalMessage(undefined); - const spotifyResult = await playSpotifyTrack(nextTrack); - - if (!spotifyResult.ok) { - setExternalMessage(getSpotifyPlaybackFailureMessage(spotifyResult.code)); - await openSpotifyFallback(nextTrack).catch(() => undefined); - } + if (!nextTrack) { + return; } - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId, - trackId: currentTrack.id, - type: 'track_skip', - }), - ); playNext(); + await handleOpenTrackExternal(nextTrack); }; const handlePlayPrevious = async () => { if (!canSkip) { @@ -197,51 +162,12 @@ export function MiniPlayer() { const previousTrack = getAdjacentTrack('previous'); - if (shouldControlSpotify && previousTrack) { - setExternalMessage(undefined); - const spotifyResult = await playSpotifyTrack(previousTrack); - - if (!spotifyResult.ok) { - setExternalMessage(getSpotifyPlaybackFailureMessage(spotifyResult.code)); - await openSpotifyFallback(previousTrack).catch(() => undefined); - } - } - - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId, - trackId: currentTrack.id, - type: 'track_skip', - }), - ); - playPrevious(); - }; - const handleOpenExternal = async () => { - if (!externalLink.url || isOpeningExternal) { - setExternalMessage('이 곡을 열 수 있는 링크를 만들지 못했어요.'); + if (!previousTrack) { return; } - setIsOpeningExternal(true); - setExternalMessage(undefined); - - try { - await openMusicPlatformUrl(externalLink); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId, - trackId: currentTrack.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); - } catch { - setExternalMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - } finally { - setIsOpeningExternal(false); - } + playPrevious(); + await handleOpenTrackExternal(previousTrack); }; const renderCover = (sizeClassName: string, radiusClassName: string) => ( @@ -374,13 +300,18 @@ export function MiniPlayer() { void handleOpenTrackExternal()} style={{ backgroundColor: playerGlow }} > - + {isOpeningExternal ? ( + + ) : ( + + )} - - {isPlaying ? '컨텍스트 재생 중' : '대기 중'} - - - {shouldControlSpotify ? 'Spotify 제어 중' : '외부 앱 전체 재생'} - + 선택한 음악 + 외부 링크 @@ -497,13 +424,18 @@ export function MiniPlayer() { void handleOpenTrackExternal()} style={{ backgroundColor: playerGlow }} > - + {isOpeningExternal ? ( + + ) : ( + + )} void handleOpenExternal()} + onPress={() => void handleOpenTrackExternal()} style={{ opacity: isOpeningExternal ? 0.72 : 1 }} > {isOpeningExternal ? ( diff --git a/src/components/auth/AppEntryGate.tsx b/src/components/auth/AppEntryGate.tsx index de4babc..7fc1b4c 100644 --- a/src/components/auth/AppEntryGate.tsx +++ b/src/components/auth/AppEntryGate.tsx @@ -15,10 +15,9 @@ export function AppEntryGate() { const isAuthRoute = pathname.startsWith('/auth'); const isLegalRoute = pathname.startsWith('/legal'); const isOnboardingRoute = pathname.startsWith('/onboarding'); - const isSpotifyAuthRoute = pathname.startsWith('/spotify-auth'); const hasAppSession = status === 'authenticated' || status === 'guest'; - if (!hasAppSession && !isAuthRoute && !isLegalRoute && !isSpotifyAuthRoute) { + if (!hasAppSession && !isAuthRoute && !isLegalRoute) { return ; } @@ -26,7 +25,7 @@ export function AppEntryGate() { return ; } - if (hasAppSession && !profile.completedOnboarding && !isOnboardingRoute && !isSpotifyAuthRoute) { + if (hasAppSession && !profile.completedOnboarding && !isOnboardingRoute) { return ; } diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index f6a4cb5..6df45ec 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -591,7 +591,7 @@ function DevTestManagerContent() { setTrack(getSampleTrack(0), samplePlaylist.id)} /> diff --git a/src/components/library/LibraryScreen.tsx b/src/components/library/LibraryScreen.tsx index 5972bc4..dae3d20 100644 --- a/src/components/library/LibraryScreen.tsx +++ b/src/components/library/LibraryScreen.tsx @@ -12,10 +12,7 @@ import { Screen } from '@/components/Screen'; import { LibraryTrackRecord, useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { - getSpotifyPlaybackFailureMessage, - playSelectedSpotifyOrFallback, -} from '@/spotify/spotifyPlayback'; +import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type LibraryTab = 'liked' | 'playlists' | 'saved'; @@ -107,19 +104,20 @@ export function LibraryScreen() { closeMenu(); }; const playRecord = (record: LibraryTrackRecord) => { + const externalLink = getTrackExternalLink(record.track); + setActionMessage(undefined); setTrack(record.track, record.playlistId); - void playSelectedSpotifyOrFallback(record.track).then((spotifyResult) => { - if (!spotifyResult.ok) { - setActionMessage(getSpotifyPlaybackFailureMessage(spotifyResult.code)); - } + void openMusicPlatformUrl(externalLink).catch(() => { + setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); }); syncRecommendationEvent( addRecommendationEvent({ context: createRecommendationEventContext(), playlistId: record.playlistId, trackId: record.track.id, - type: 'track_play', + type: 'track_external_open', + value: externalLink.platformId, }), ); }; diff --git a/src/components/library/LibraryTrackRow.tsx b/src/components/library/LibraryTrackRow.tsx index 8252278..4b4be34 100644 --- a/src/components/library/LibraryTrackRow.tsx +++ b/src/components/library/LibraryTrackRow.tsx @@ -21,7 +21,7 @@ export function LibraryTrackRow({ return ( void; - onDisconnectSpotify?: () => void; - onSelectPlatform: (id: MusicPlatformId) => void; - selectedPlatformId: MusicPlatformId; - spotifyDisplayName?: string; - spotifyErrorMessage?: string; -}; - -export function MusicPlatformSettingsCard({ - isSpotifyConfigured = false, - isSpotifyConnected = false, - isSpotifyConnecting = false, - onConnectSpotify, - onDisconnectSpotify, - onSelectPlatform, - selectedPlatformId, - spotifyDisplayName, - spotifyErrorMessage, -}: MusicPlatformSettingsCardProps) { - const selectedPlatform = getMusicPlatformOption(selectedPlatformId); - const showSpotifyConnection = selectedPlatformId === 'spotify'; - - return ( - - - - - - - 음악 플랫폼 - - {selectedPlatform.label} - - - Spotify 연결 시 재생 제어를 먼저 시도하고, 실패하면 외부 앱으로 열어요. - - - - - - {musicPlatformOptions.map((option) => { - const selected = option.id === selectedPlatformId; - - return ( - onSelectPlatform(option.id)} - > - {option.shortLabel} - - ); - })} - - - - {selectedPlatform.description} - - - {showSpotifyConnection ? ( - - - - - {isSpotifyConnected ? 'Spotify 연결됨' : 'Spotify 계정 연결'} - - - {isSpotifyConnected - ? `${spotifyDisplayName ?? '내 Spotify'} 계정으로 재생 제어를 시도해요.` - : isSpotifyConfigured - ? '연결하면 재생, 일시정지, 이전/다음을 Spotify에 요청할 수 있어요.' - : 'EXPO_PUBLIC_SPOTIFY_CLIENT_ID 설정이 필요해요.'} - - - - {isSpotifyConnecting ? ( - - ) : ( - - {isSpotifyConnected ? '해제' : '연결'} - - )} - - - - {spotifyErrorMessage ? ( - - - {spotifyErrorMessage} - - - ) : null} - - ) : null} - - ); -} diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index 86bc96b..d02d5f4 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -21,11 +21,8 @@ import { getCurationListBottomPadding, getMiniPlayerBottom } from '@/constants/l import { useLibraryStore } from '@/store/libraryStore'; import { usePlayerStore } from '@/store/playerStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; -import { - getSpotifyPlaybackFailureMessage, - playSelectedSpotifyOrFallback, -} from '@/spotify/spotifyPlayback'; import { Track } from '@/types/domain'; +import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; import { createRecommendationEventContext } from '@/utils/recommendationEventContext'; type PlaylistCurationScreenProps = { @@ -86,28 +83,25 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); - const requestSpotifyPlayback = async (track: Track) => { - const spotifyResult = await playSelectedSpotifyOrFallback(track); - - if (!spotifyResult.ok) { - setActionMessage(getSpotifyPlaybackFailureMessage(spotifyResult.code)); - } - }; - const playTrack = (track: Track) => { if (!playlist) { return; } + const externalLink = getTrackExternalLink(track); + setActionMessage(undefined); setTrack(track, playlist.id, playlist.tracks); - void requestSpotifyPlayback(track); + void openMusicPlatformUrl(externalLink).catch(() => { + setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); + }); syncRecommendationEvent( addRecommendationEvent({ context: createRecommendationEventContext(), playlistId: playlist.id, trackId: track.id, - type: 'track_play', + type: 'track_external_open', + value: externalLink.platformId, }), ); }; diff --git a/src/components/playlist/PlaylistHeroInfo.tsx b/src/components/playlist/PlaylistHeroInfo.tsx index ea7e53c..8e22106 100644 --- a/src/components/playlist/PlaylistHeroInfo.tsx +++ b/src/components/playlist/PlaylistHeroInfo.tsx @@ -38,14 +38,14 @@ export function PlaylistHeroInfo({ disabled = false, onPlay, playlist }: Playlis - + diff --git a/src/components/playlist/TrackActionMenu.tsx b/src/components/playlist/TrackActionMenu.tsx index 2a98fdd..de18dcf 100644 --- a/src/components/playlist/TrackActionMenu.tsx +++ b/src/components/playlist/TrackActionMenu.tsx @@ -5,7 +5,6 @@ import { ActivityIndicator, Modal, Pressable, View } from 'react-native'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; import { AppText } from '@/components/AppText'; -import { useMusicPlatformStore } from '@/store/musicPlatformStore'; import { useRecommendationEventStore } from '@/store/recommendationEventStore'; import { Track } from '@/types/domain'; import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; @@ -59,13 +58,12 @@ export function TrackActionMenu({ track, visible, }: TrackActionMenuProps) { - const selectedPlatformId = useMusicPlatformStore((state) => state.selectedPlatformId); const addRecommendationEvent = useRecommendationEventStore((state) => state.addEvent); const [externalMessage, setExternalMessage] = useState(); const [isOpeningExternal, setIsOpeningExternal] = useState(false); const externalLink = useMemo( - () => (track ? getTrackExternalLink(track, selectedPlatformId) : undefined), - [selectedPlatformId, track], + () => (track ? getTrackExternalLink(track) : undefined), + [track], ); const canOpenExternal = Boolean(externalLink?.url); diff --git a/src/components/playlist/TrackRow.tsx b/src/components/playlist/TrackRow.tsx index f6e0868..8755d3f 100644 --- a/src/components/playlist/TrackRow.tsx +++ b/src/components/playlist/TrackRow.tsx @@ -48,7 +48,7 @@ export function TrackRow({ isActive, isLiked, isSaved, onMore, onPress, track }: onPress(track)} diff --git a/src/components/travel/RecapCard.tsx b/src/components/travel/RecapCard.tsx index d73c8a6..1a1142c 100644 --- a/src/components/travel/RecapCard.tsx +++ b/src/components/travel/RecapCard.tsx @@ -32,13 +32,13 @@ export function RecapCard({ item, onPress }: RecapCardProps) { - {item.durationText} · {item.playTimeText.replace('총 음악 재생 ', '')} + {item.durationText} · {item.playTimeText.replace('음악 기록 ', '')} - {item.playCount}회 + {item.playCount}회 기록 diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index 512b73c..8ca200c 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -304,24 +304,24 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP - Your listening time + Music records - {item.playTimeText.replace('총 음악 재생 ', '')} + {item.playTimeText.replace('음악 기록 ', '')} - 음악으로 채워진{'\n'}{item.durationText} + 음악으로 남긴{'\n'}{item.durationText} - Total plays + Saved music {item.playCount} - 회 재생 + 회 기록 @@ -344,9 +344,9 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP node: ( - Most played + Most recorded - 이 여행에서{'\n'}가장 많이 들은 노래 + 이 여행에서{'\n'}가장 많이 기록한 노래 @@ -365,7 +365,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP {mostPlayed.title} - {mostPlayed.artist} · {mostPlayed.playCount}회 + {mostPlayed.artist} · {mostPlayed.playCount}회 기록 @@ -382,7 +382,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP Top songs - 많이 들은 순위 + 많이 기록한 순위 @@ -472,7 +472,7 @@ export function TravelReportModal({ item, onClose, visible }: TravelReportModalP - Plays + Records {item.playCount} diff --git a/src/components/travel/TravelStatusCard.tsx b/src/components/travel/TravelStatusCard.tsx index c984177..62e98b6 100644 --- a/src/components/travel/TravelStatusCard.tsx +++ b/src/components/travel/TravelStatusCard.tsx @@ -67,7 +67,7 @@ export function TravelStatusCard({ const placeLabel = currentPlace?.title ?? '위치 확인 전'; const currentTrackLabel = currentTrack ? `${currentTrack.artist} - ${currentTrack.title}` - : '재생 중인 음악 없음'; + : '선택한 음악 없음'; if (status === 'active') { return ( @@ -99,7 +99,7 @@ export function TravelStatusCard({ - 현재 재생 중 + 선택한 음악 {currentTrackLabel} diff --git a/src/components/travel/travelData.ts b/src/components/travel/travelData.ts index 0ae197a..3f9a142 100644 --- a/src/components/travel/travelData.ts +++ b/src/components/travel/travelData.ts @@ -119,7 +119,7 @@ export const sampleRecaps: TravelRecap[] = [ momentCount: 12, periodText: '2026.06.06 13:02 - 15:16', playCount: 48, - playTimeText: '총 음악 재생 1시간 52분', + playTimeText: '음악 기록 31곡', representativeTrack: 'IU - Love wins all', topTracks: [ { artist: 'IU', playCount: 9, title: 'Love wins all' }, @@ -140,7 +140,7 @@ export const sampleRecaps: TravelRecap[] = [ momentCount: 8, periodText: '2026.05.25 16:41 - 18:29', playCount: 36, - playTimeText: '총 음악 재생 1시간 20분', + playTimeText: '음악 기록 24곡', representativeTrack: 'NewJeans - Ditto', topTracks: [ { artist: 'NewJeans', playCount: 8, title: 'Ditto' }, @@ -161,7 +161,7 @@ export const sampleRecaps: TravelRecap[] = [ momentCount: 15, periodText: '2026.05.11 19:08 - 22:10', playCount: 64, - playTimeText: '총 음악 재생 2시간 36분', + playTimeText: '음악 기록 42곡', representativeTrack: 'JENNIE - Seoul City', topTracks: [ { artist: 'JENNIE', playCount: 11, title: 'Seoul City' }, diff --git a/src/mocks/playlistMocks.ts b/src/mocks/playlistMocks.ts index 929238f..690c06d 100644 --- a/src/mocks/playlistMocks.ts +++ b/src/mocks/playlistMocks.ts @@ -8,7 +8,6 @@ const tracks: Track[] = [ id: 'seoul-city', isLiked: true, platformUrls: { - spotify: 'https://open.spotify.com/search/JENNIE%20Seoul%20City', youtubeMusic: 'https://music.youtube.com/search?q=JENNIE%20Seoul%20City', }, title: 'Seoul City', @@ -42,7 +41,6 @@ const geojeTracks: Track[] = [ platformUrls: { melon: 'https://www.melon.com/search/total/index.htm?q=AKMU%20Dinosaur', - spotify: 'https://open.spotify.com/search/AKMU%20Dinosaur', youtubeMusic: 'https://music.youtube.com/search?q=AKMU%20Dinosaur', }, title: 'Dinosaur', @@ -54,8 +52,6 @@ const geojeTracks: Track[] = [ platformUrls: { melon: 'https://www.melon.com/search/total/index.htm?q=%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89', - spotify: - 'https://open.spotify.com/search/%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89', youtubeMusic: 'https://music.youtube.com/search?q=%EB%B3%BC%EB%B9%A8%EA%B0%84%EC%82%AC%EC%B6%98%EA%B8%B0%20%EC%97%AC%ED%96%89', }, @@ -66,8 +62,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#D29B42', id: 'geoje-summer', platformUrls: { - spotify: - 'https://open.spotify.com/search/%EC%9E%94%EB%82%98%EB%B9%84%20%EB%9C%A8%EA%B1%B0%EC%9A%B4%20%EC%97%AC%EB%A6%84%EB%B0%A4%EC%9D%80%20%EA%B0%80%EA%B3%A0%20%EB%82%A8%EC%9D%80%20%EA%B1%B4%20%EB%B3%BC%ED%92%88%EC%97%86%EC%A7%80%EB%A7%8C', youtubeMusic: 'https://music.youtube.com/search?q=%EC%9E%94%EB%82%98%EB%B9%84%20%EB%9C%A8%EA%B1%B0%EC%9A%B4%20%EC%97%AC%EB%A6%84%EB%B0%A4%EC%9D%80%20%EA%B0%80%EA%B3%A0%20%EB%82%A8%EC%9D%80%20%EA%B1%B4%20%EB%B3%BC%ED%92%88%EC%97%86%EC%A7%80%EB%A7%8C', }, @@ -78,7 +72,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#2D6A72', id: 'geoje-seasons', platformUrls: { - spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons', youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons', }, title: 'seasons', @@ -88,7 +81,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#45536B', id: 'geoje-wi-ing', platformUrls: { - spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing', youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing', }, title: '위잉위잉', @@ -98,7 +90,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#6C7F99', id: 'geoje-down', platformUrls: { - spotify: 'https://open.spotify.com/search/O3ohn%20Down', youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down', }, title: 'Down', @@ -108,8 +99,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#334D3F', id: 'geoje-tree', platformUrls: { - spotify: - 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', youtubeMusic: 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', }, @@ -120,7 +109,6 @@ const geojeTracks: Track[] = [ fallbackColor: '#1F2937', id: 'geoje-everything', platformUrls: { - spotify: 'https://open.spotify.com/search/The%20Black%20Skirts%20Everything', youtubeMusic: 'https://music.youtube.com/search?q=The%20Black%20Skirts%20Everything', }, @@ -134,7 +122,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#2B176C', id: 'calm-walk-seoul-city', platformUrls: { - spotify: 'https://open.spotify.com/search/JENNIE%20Seoul%20City', youtubeMusic: 'https://music.youtube.com/search?q=JENNIE%20Seoul%20City', }, title: 'Seoul City', @@ -144,8 +131,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#6E4FD3', id: 'calm-walk-night-letter', platformUrls: { - spotify: - 'https://open.spotify.com/search/%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', youtubeMusic: 'https://music.youtube.com/search?q=%EC%95%84%EC%9D%B4%EC%9C%A0%20%EB%B0%A4%ED%8E%B8%EC%A7%80', }, @@ -156,7 +141,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#2D6A72', id: 'calm-walk-seasons', platformUrls: { - spotify: 'https://open.spotify.com/search/wave%20to%20earth%20seasons', youtubeMusic: 'https://music.youtube.com/search?q=wave%20to%20earth%20seasons', }, title: 'seasons', @@ -166,8 +150,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#334D3F', id: 'calm-walk-tree', platformUrls: { - spotify: - 'https://open.spotify.com/search/%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', youtubeMusic: 'https://music.youtube.com/search?q=%EC%B9%B4%EB%8D%94%EA%B0%80%EB%93%A0%20%EB%82%98%EB%AC%B4', }, @@ -178,7 +160,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#45536B', id: 'calm-walk-wi-ing', platformUrls: { - spotify: 'https://open.spotify.com/search/hyukoh%20wi%20ing%20wi%20ing', youtubeMusic: 'https://music.youtube.com/search?q=hyukoh%20wi%20ing%20wi%20ing', }, title: '위잉위잉', @@ -188,7 +169,6 @@ const calmWalkTracks: Track[] = [ fallbackColor: '#6C7F99', id: 'calm-walk-down', platformUrls: { - spotify: 'https://open.spotify.com/search/O3ohn%20Down', youtubeMusic: 'https://music.youtube.com/search?q=O3ohn%20Down', }, title: 'Down', diff --git a/src/spotify/spotifyAuth.ts b/src/spotify/spotifyAuth.ts deleted file mode 100644 index 9cda1ff..0000000 --- a/src/spotify/spotifyAuth.ts +++ /dev/null @@ -1,198 +0,0 @@ -import * as AuthSession from 'expo-auth-session'; -import * as WebBrowser from 'expo-web-browser'; -import Constants from 'expo-constants'; -import { Platform } from 'react-native'; - -import { SpotifyAuthSession } from '@/store/spotifyAuthStore'; - -WebBrowser.maybeCompleteAuthSession(); - -const SPOTIFY_ACCOUNTS_URL = 'https://accounts.spotify.com'; -const SPOTIFY_API_URL = 'https://api.spotify.com/v1'; -const TOKEN_EXPIRY_BUFFER_MS = 60_000; - -const discovery = { - authorizationEndpoint: `${SPOTIFY_ACCOUNTS_URL}/authorize`, - tokenEndpoint: `${SPOTIFY_ACCOUNTS_URL}/api/token`, -}; - -const SPOTIFY_SCOPES = [ - 'app-remote-control', - 'user-modify-playback-state', - 'user-read-currently-playing', - 'user-read-playback-state', -]; - -type SpotifyProfileResponse = { - display_name?: string; - id?: string; -}; - -export type SpotifyAuthErrorCode = - | 'cancelled' - | 'configuration_missing' - | 'token_exchange_failed' - | 'unsupported_web'; - -export class SpotifyAuthError extends Error { - code: SpotifyAuthErrorCode; - - constructor(code: SpotifyAuthErrorCode, message: string) { - super(message); - this.code = code; - } -} - -type SpotifyExtraConfig = { - spotify?: { - clientId?: string; - }; -}; - -export function getSpotifyClientId() { - const extra = Constants.expoConfig?.extra as SpotifyExtraConfig | undefined; - - return extra?.spotify?.clientId ?? process.env.EXPO_PUBLIC_SPOTIFY_CLIENT_ID; -} - -export function getSpotifyRedirectUri() { - return AuthSession.makeRedirectUri({ - path: 'spotify-auth', - scheme: 'soundlog', - }); -} - -export function isSpotifyConfigured() { - return Boolean(getSpotifyClientId()); -} - -export function isSpotifySessionFresh(session?: SpotifyAuthSession) { - if (!session) { - return false; - } - - return new Date(session.expiresAt).getTime() - TOKEN_EXPIRY_BUFFER_MS > Date.now(); -} - -function createExpiresAt(expiresIn?: number) { - return new Date(Date.now() + (expiresIn ?? 3600) * 1000).toISOString(); -} - -async function fetchSpotifyProfile(accessToken: string) { - const response = await fetch(`${SPOTIFY_API_URL}/me`, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - if (!response.ok) { - return undefined; - } - - return (await response.json()) as SpotifyProfileResponse; -} - -export function getSpotifyAuthErrorMessage(error: unknown) { - if (error instanceof SpotifyAuthError) { - if (error.code === 'configuration_missing') { - return 'Spotify Client ID가 아직 설정되지 않았어요.'; - } - - if (error.code === 'unsupported_web') { - return 'Spotify 연결은 모바일 앱에서 확인해주세요.'; - } - - if (error.code === 'cancelled') { - return 'Spotify 연결이 취소됐어요.'; - } - } - - return 'Spotify 연결에 실패했어요. 잠시 후 다시 시도해주세요.'; -} - -export async function connectSpotifyAccount(): Promise { - const clientId = getSpotifyClientId(); - const redirectUri = getSpotifyRedirectUri(); - - if (!clientId) { - throw new SpotifyAuthError('configuration_missing', 'Spotify Client ID is missing.'); - } - - if (Platform.OS === 'web') { - throw new SpotifyAuthError('unsupported_web', 'Spotify auth is only enabled for mobile.'); - } - - const request = new AuthSession.AuthRequest({ - clientId, - codeChallengeMethod: AuthSession.CodeChallengeMethod.S256, - redirectUri, - responseType: AuthSession.ResponseType.Code, - scopes: SPOTIFY_SCOPES, - usePKCE: true, - }); - const result = await request.promptAsync(discovery); - - if (result.type !== 'success' || !result.params.code) { - throw new SpotifyAuthError('cancelled', 'Spotify auth was not completed.'); - } - - const token = await AuthSession.exchangeCodeAsync( - { - clientId, - code: result.params.code, - extraParams: { - code_verifier: request.codeVerifier ?? '', - }, - redirectUri, - }, - discovery, - ); - - if (!token.accessToken) { - throw new SpotifyAuthError('token_exchange_failed', 'Spotify token exchange failed.'); - } - - const profile = await fetchSpotifyProfile(token.accessToken).catch(() => undefined); - - return { - accessToken: token.accessToken, - connectedAt: new Date().toISOString(), - displayName: profile?.display_name, - expiresAt: createExpiresAt(token.expiresIn), - refreshToken: token.refreshToken, - scope: token.scope, - tokenType: token.tokenType, - userId: profile?.id, - }; -} - -export async function refreshSpotifyAccount( - session: SpotifyAuthSession, -): Promise { - const clientId = getSpotifyClientId(); - - if (!clientId) { - throw new SpotifyAuthError('configuration_missing', 'Spotify Client ID is missing.'); - } - - if (!session.refreshToken) { - throw new SpotifyAuthError('token_exchange_failed', 'Spotify refresh token is missing.'); - } - - const token = await AuthSession.refreshAsync( - { - clientId, - refreshToken: session.refreshToken, - }, - discovery, - ); - - return { - ...session, - accessToken: token.accessToken, - expiresAt: createExpiresAt(token.expiresIn), - refreshToken: token.refreshToken ?? session.refreshToken, - scope: token.scope ?? session.scope, - tokenType: token.tokenType ?? session.tokenType, - }; -} diff --git a/src/spotify/spotifyPlayback.ts b/src/spotify/spotifyPlayback.ts deleted file mode 100644 index a46a9eb..0000000 --- a/src/spotify/spotifyPlayback.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { useMusicPlatformStore } from '@/store/musicPlatformStore'; -import { useSpotifyAuthStore } from '@/store/spotifyAuthStore'; -import { Track } from '@/types/domain'; -import { getTrackExternalLink, openMusicPlatformUrl } from '@/utils/musicPlatformLinks'; -import { - getSpotifyClientId, - isSpotifyConfigured, - isSpotifySessionFresh, - refreshSpotifyAccount, -} from '@/spotify/spotifyAuth'; - -const SPOTIFY_API_URL = 'https://api.spotify.com/v1'; - -export type SpotifyPlaybackFailureCode = - | 'configuration_missing' - | 'network_error' - | 'no_active_device' - | 'not_connected' - | 'not_found' - | 'premium_required' - | 'rate_limited' - | 'unauthorized' - | 'unknown'; - -export type SpotifyPlaybackResult = - | { - ok: true; - } - | { - code: SpotifyPlaybackFailureCode; - detail?: string; - ok: false; - }; - -type SpotifyErrorResponse = { - error?: { - message?: string; - reason?: string; - status?: number; - }; -}; - -type SpotifySearchResponse = { - tracks?: { - items?: Array<{ - uri?: string; - }>; - }; -}; - -function createTrackQuery(track: Track) { - const title = track.title.trim(); - const artist = track.artist.trim(); - - return artist ? `${artist} ${title}` : title; -} - -function getSpotifyTrackUriFromUrl(url?: string) { - if (!url) { - return undefined; - } - - if (url.startsWith('spotify:track:')) { - return url; - } - - try { - const parsedUrl = new URL(url); - - if (parsedUrl.hostname !== 'open.spotify.com') { - return undefined; - } - - const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); - const trackIndex = pathSegments.findIndex((segment) => segment === 'track'); - const trackId = pathSegments[trackIndex + 1]; - - return trackIndex >= 0 && trackId ? `spotify:track:${trackId}` : undefined; - } catch { - return undefined; - } -} - -function getSpotifyTrackUri(track: Track) { - return ( - getSpotifyTrackUriFromUrl(track.platformUrls?.spotify) ?? - getSpotifyTrackUriFromUrl(track.externalUrl) - ); -} - -async function readSpotifyError(response: Response): Promise { - try { - return (await response.json()) as SpotifyErrorResponse; - } catch { - return undefined; - } -} - -function normalizeSpotifyFailure(response: Response, body?: SpotifyErrorResponse) { - const reason = body?.error?.reason; - const message = body?.error?.message; - - if (response.status === 401) { - return { code: 'unauthorized', detail: message, ok: false } satisfies SpotifyPlaybackResult; - } - - if (response.status === 403) { - return { - code: 'premium_required', - detail: message, - ok: false, - } satisfies SpotifyPlaybackResult; - } - - if (response.status === 404 && reason === 'NO_ACTIVE_DEVICE') { - return { - code: 'no_active_device', - detail: message, - ok: false, - } satisfies SpotifyPlaybackResult; - } - - if (response.status === 404) { - return { code: 'not_found', detail: message, ok: false } satisfies SpotifyPlaybackResult; - } - - if (response.status === 429) { - return { code: 'rate_limited', detail: message, ok: false } satisfies SpotifyPlaybackResult; - } - - return { code: 'unknown', detail: message, ok: false } satisfies SpotifyPlaybackResult; -} - -export function getSpotifyPlaybackFailureMessage(code: SpotifyPlaybackFailureCode) { - if (code === 'configuration_missing') { - return 'Spotify Client ID가 설정되지 않았어요.'; - } - - if (code === 'not_connected' || code === 'unauthorized') { - return 'Spotify 계정을 다시 연결하면 앱에서 바로 제어할 수 있어요.'; - } - - if (code === 'premium_required') { - return 'Spotify 전체 재생 제어는 Premium 계정에서 사용할 수 있어요.'; - } - - if (code === 'no_active_device') { - return 'Spotify 앱을 한 번 열어 활성 기기를 만든 뒤 다시 시도해주세요.'; - } - - if (code === 'not_found') { - return 'Spotify에서 이 곡을 정확히 찾지 못했어요.'; - } - - if (code === 'rate_limited') { - return 'Spotify 요청이 잠시 많아요. 조금 뒤 다시 시도해주세요.'; - } - - return 'Spotify 재생 요청에 실패했어요. Spotify 앱으로 이어서 열게요.'; -} - -export function isSpotifyPlaybackReady() { - const { session } = useSpotifyAuthStore.getState(); - - return isSpotifyConfigured() && Boolean(session); -} - -export async function ensureSpotifyAccessToken(): Promise< - | { - accessToken: string; - ok: true; - } - | { - code: SpotifyPlaybackFailureCode; - ok: false; - } -> { - const clientId = getSpotifyClientId(); - const { clearSession, session, setSession } = useSpotifyAuthStore.getState(); - - if (!clientId) { - return { code: 'configuration_missing', ok: false }; - } - - if (!session) { - return { code: 'not_connected', ok: false }; - } - - if (isSpotifySessionFresh(session)) { - return { accessToken: session.accessToken, ok: true }; - } - - try { - const refreshedSession = await refreshSpotifyAccount(session); - - setSession(refreshedSession); - return { accessToken: refreshedSession.accessToken, ok: true }; - } catch { - clearSession(); - return { code: 'unauthorized', ok: false }; - } -} - -async function spotifyRequest( - path: string, - options: { - body?: unknown; - method?: 'GET' | 'POST' | 'PUT'; - } = {}, -): Promise { - const token = await ensureSpotifyAccessToken(); - - if (!token.ok) { - return token; - } - - try { - const response = await fetch(`${SPOTIFY_API_URL}${path}`, { - body: options.body ? JSON.stringify(options.body) : undefined, - headers: { - Authorization: `Bearer ${token.accessToken}`, - ...(options.body ? { 'Content-Type': 'application/json' } : {}), - }, - method: options.method ?? 'GET', - }); - - if (response.ok || response.status === 204) { - return { ok: true }; - } - - return normalizeSpotifyFailure(response, await readSpotifyError(response)); - } catch { - return { code: 'network_error', ok: false }; - } -} - -async function searchSpotifyTrackUri(track: Track, accessToken: string) { - const query = createTrackQuery(track); - - if (!query) { - return undefined; - } - - const response = await fetch( - `${SPOTIFY_API_URL}/search?${new URLSearchParams({ - limit: '1', - q: query, - type: 'track', - }).toString()}`, - { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }, - ); - - if (!response.ok) { - return undefined; - } - - const data = (await response.json()) as SpotifySearchResponse; - - return data.tracks?.items?.[0]?.uri; -} - -export async function resolveSpotifyTrackUri(track: Track) { - const directUri = getSpotifyTrackUri(track); - - if (directUri) { - return { ok: true, uri: directUri } as const; - } - - const token = await ensureSpotifyAccessToken(); - - if (!token.ok) { - return token; - } - - const searchedUri = await searchSpotifyTrackUri(track, token.accessToken).catch(() => undefined); - - return searchedUri - ? ({ ok: true, uri: searchedUri } as const) - : ({ code: 'not_found', ok: false } as const); -} - -export async function playSpotifyTrack(track: Track): Promise { - const resolvedUri = await resolveSpotifyTrackUri(track); - - if (!resolvedUri.ok) { - return resolvedUri; - } - - return spotifyRequest('/me/player/play', { - body: { - uris: [resolvedUri.uri], - }, - method: 'PUT', - }); -} - -async function openSpotifyFallback(track: Track) { - const spotifyLink = getTrackExternalLink(track, 'spotify'); - - if (!spotifyLink.url) { - return; - } - - await openMusicPlatformUrl(spotifyLink); -} - -export async function playSelectedSpotifyOrFallback( - track: Track, -): Promise { - const selectedPlatformId = useMusicPlatformStore.getState().selectedPlatformId; - - if (selectedPlatformId !== 'spotify') { - return { ok: true }; - } - - if (!useSpotifyAuthStore.getState().session) { - await openSpotifyFallback(track).catch(() => undefined); - return { code: 'not_connected', ok: false }; - } - - const spotifyResult = await playSpotifyTrack(track); - - if (!spotifyResult.ok) { - await openSpotifyFallback(track).catch(() => undefined); - } - - return spotifyResult; -} - -export function pauseSpotifyPlayback() { - return spotifyRequest('/me/player/pause', { method: 'PUT' }); -} - -export function nextSpotifyPlayback() { - return spotifyRequest('/me/player/next', { method: 'POST' }); -} - -export function previousSpotifyPlayback() { - return spotifyRequest('/me/player/previous', { method: 'POST' }); -} diff --git a/src/store/devToolsStore.ts b/src/store/devToolsStore.ts index 18ffef1..21b180d 100644 --- a/src/store/devToolsStore.ts +++ b/src/store/devToolsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { MockEndpointId } from '@/mock-server/types'; +import type { MockEndpointId } from '@/mock-server/types'; export type ApiSource = 'mock' | 'server'; @@ -34,7 +34,7 @@ type DevToolsState = { function getInitialApiSource(): ApiSource { if (process.env.EXPO_PUBLIC_SOUNDLOG_API_SOURCE === 'mock') { - return 'mock'; + return __DEV__ ? 'mock' : 'server'; } if ( @@ -44,6 +44,10 @@ function getInitialApiSource(): ApiSource { return 'server'; } + if (!__DEV__) { + return 'server'; + } + return 'mock'; } diff --git a/src/store/musicPlatformStore.ts b/src/store/musicPlatformStore.ts deleted file mode 100644 index 333765c..0000000 --- a/src/store/musicPlatformStore.ts +++ /dev/null @@ -1,38 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { MusicPlatformId } from '@/types/domain'; - -type MusicPlatformState = { - resetPlatform: () => void; - selectedPlatformId: MusicPlatformId; - setSelectedPlatform: (id: MusicPlatformId) => void; - updatedAt?: string; -}; - -export const useMusicPlatformStore = create()( - persist( - (set) => ({ - selectedPlatformId: 'none', - resetPlatform: () => - set({ - selectedPlatformId: 'none', - updatedAt: new Date().toISOString(), - }), - setSelectedPlatform: (selectedPlatformId) => - set({ - selectedPlatformId, - updatedAt: new Date().toISOString(), - }), - }), - { - name: 'soundlog-music-platform', - partialize: (state) => ({ - selectedPlatformId: state.selectedPlatformId, - updatedAt: state.updatedAt, - }), - storage: createJSONStorage(() => AsyncStorage), - }, - ), -); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 169aa71..baa0201 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -2,25 +2,18 @@ import { create } from 'zustand'; import { Track } from '@/types/domain'; -type PlayerSource = 'none' | 'preview' | 'external'; - type PlayerState = { currentTrack?: Track; - isPlaying: boolean; playlistId?: string; queue: Track[]; - source: PlayerSource; playNext: () => void; playPrevious: () => void; clearTrack: () => void; setTrack: (track: Track, playlistId?: string, queue?: Track[]) => void; - toggle: () => void; }; export const usePlayerStore = create((set) => ({ - isPlaying: false, queue: [], - source: 'none', playNext: () => set((state) => { if (!state.currentTrack || state.queue.length < 2) { @@ -32,7 +25,6 @@ export const usePlayerStore = create((set) => ({ return { currentTrack: state.queue[nextIndex], - isPlaying: true, }; }), playPrevious: () => @@ -47,24 +39,18 @@ export const usePlayerStore = create((set) => ({ return { currentTrack: state.queue[previousIndex], - isPlaying: true, }; }), clearTrack: () => set({ currentTrack: undefined, - isPlaying: false, playlistId: undefined, queue: [], - source: 'none', }), setTrack: (track, playlistId, queue) => set({ currentTrack: track, - isPlaying: true, playlistId, queue: queue?.length ? queue : [track], - source: track.previewUrl ? 'preview' : 'external', }), - toggle: () => set((state) => ({ isPlaying: !state.isPlaying })), })); diff --git a/src/store/recommendationEventStore.ts b/src/store/recommendationEventStore.ts index 18e4948..adbd815 100644 --- a/src/store/recommendationEventStore.ts +++ b/src/store/recommendationEventStore.ts @@ -5,15 +5,11 @@ import { createJSONStorage, persist } from 'zustand/middleware'; import { MusicRecommendationMode, TravelMode } from '@/types/domain'; export type RecommendationEventType = - | 'track_play' - | 'track_pause' - | 'track_resume' | 'track_external_open' | 'track_like' | 'track_unlike' | 'track_save' | 'track_unsave' - | 'track_skip' | 'playlist_open' | 'mood_filter_change' | 'recommendation_mode_change' diff --git a/src/store/spotifyAuthStore.ts b/src/store/spotifyAuthStore.ts deleted file mode 100644 index f7805ef..0000000 --- a/src/store/spotifyAuthStore.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { create } from 'zustand'; -import { createJSONStorage, persist } from 'zustand/middleware'; - -import { createAuthStorage } from '@/store/authStorage'; - -export type SpotifyAuthSession = { - accessToken: string; - connectedAt: string; - displayName?: string; - expiresAt: string; - refreshToken?: string; - scope?: string; - tokenType?: string; - userId?: string; -}; - -type SpotifyAuthState = { - clearSession: () => void; - errorMessage?: string; - isConnecting: boolean; - isHydrated: boolean; - session?: SpotifyAuthSession; - setConnecting: (isConnecting: boolean) => void; - setError: (message?: string) => void; - setHydrated: (isHydrated: boolean) => void; - setSession: (session: SpotifyAuthSession) => void; -}; - -export const useSpotifyAuthStore = create()( - persist( - (set) => ({ - clearSession: () => - set({ - errorMessage: undefined, - isConnecting: false, - session: undefined, - }), - errorMessage: undefined, - isConnecting: false, - isHydrated: false, - session: undefined, - setConnecting: (isConnecting) => set({ isConnecting }), - setError: (errorMessage) => set({ errorMessage }), - setHydrated: (isHydrated) => set({ isHydrated }), - setSession: (session) => - set({ - errorMessage: undefined, - isConnecting: false, - session, - }), - }), - { - name: 'soundlog-spotify-auth', - onRehydrateStorage: () => (state) => { - state?.setHydrated(true); - }, - partialize: (state) => ({ - session: state.session, - }), - storage: createJSONStorage(createAuthStorage), - }, - ), -); diff --git a/src/types/domain.ts b/src/types/domain.ts index 722e846..93afd24 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -22,9 +22,9 @@ export type MusicRecommendationMode = 'everyday' | 'travel'; export type MoodTag = 'calm' | 'fresh' | 'emotional' | 'active' | 'local'; -export type MusicPlatformId = 'melon' | 'none' | 'spotify' | 'youtubeMusic'; +export type MusicPlatformId = 'none' | 'youtubeMusic'; -export type ExternalMusicPlatformId = Exclude; +export type ExternalMusicPlatformId = 'melon' | 'youtubeMusic'; export type Track = { id: string; diff --git a/src/utils/musicPlatformLinks.ts b/src/utils/musicPlatformLinks.ts index eb13788..80110b6 100644 --- a/src/utils/musicPlatformLinks.ts +++ b/src/utils/musicPlatformLinks.ts @@ -2,13 +2,6 @@ import { Linking } from 'react-native'; import { MusicPlatformId, Track } from '@/types/domain'; -export type MusicPlatformOption = { - description: string; - id: MusicPlatformId; - label: string; - shortLabel: string; -}; - export type TrackExternalLinkResult = { fallbackUrl?: string; label: string; @@ -17,37 +10,6 @@ export type TrackExternalLinkResult = { usedFallback: boolean; }; -export const musicPlatformOptions: MusicPlatformOption[] = [ - { - description: '플랫폼을 정하지 않고 기본 링크나 YouTube Music 검색으로 열어요.', - id: 'none', - label: '미설정', - shortLabel: '미설정', - }, - { - description: 'Spotify 앱으로 먼저 열고, 실패하면 웹 링크로 열어요.', - id: 'spotify', - label: 'Spotify', - shortLabel: 'Spotify', - }, - { - description: 'Melon 검색 링크로 열어요.', - id: 'melon', - label: 'Melon', - shortLabel: 'Melon', - }, - { - description: 'YouTube Music 검색으로 열어요.', - id: 'youtubeMusic', - label: 'YouTube Music', - shortLabel: 'YouTube', - }, -]; - -export function getMusicPlatformOption(id: MusicPlatformId) { - return musicPlatformOptions.find((option) => option.id === id) ?? musicPlatformOptions[0]; -} - function createTrackQuery(track: Track) { const title = track.title.trim(); @@ -60,111 +22,25 @@ function createTrackQuery(track: Track) { return artist ? `${artist} ${title}` : title; } -function createSearchUrl(platformId: MusicPlatformId, query: string) { - const encodedQuery = encodeURIComponent(query); - - if (platformId === 'spotify') { - return `https://open.spotify.com/search/${encodedQuery}`; - } - - if (platformId === 'melon') { - return `https://www.melon.com/search/total/index.htm?q=${encodedQuery}`; - } - - return `https://music.youtube.com/search?q=${encodedQuery}`; -} - -function createSpotifySearchAppUrl(query: string) { - return `spotify:search:${encodeURIComponent(query)}`; -} - -function createSpotifyAppUrlFromWebUrl(url: string) { - if (url.startsWith('spotify:')) { - return url; - } - - try { - const parsedUrl = new URL(url); - - if (parsedUrl.hostname !== 'open.spotify.com') { - return undefined; - } - - const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); - const contentTypes = new Set(['album', 'artist', 'episode', 'playlist', 'show', 'track']); - const contentIndex = pathSegments.findIndex((segment) => contentTypes.has(segment)); - - if (contentIndex >= 0) { - const contentType = pathSegments[contentIndex]; - const contentId = pathSegments[contentIndex + 1]; - - return contentId ? `spotify:${contentType}:${contentId}` : undefined; - } - - const searchIndex = pathSegments.findIndex((segment) => segment === 'search'); - const encodedSearchQuery = pathSegments[searchIndex + 1]; - - if (searchIndex >= 0 && encodedSearchQuery) { - return createSpotifySearchAppUrl(decodeURIComponent(encodedSearchQuery)); - } - } catch { - return undefined; - } - - return undefined; -} - -function createSpotifyOpenTarget(platformUrl?: string, query?: string) { - const webUrl = - platformUrl && !platformUrl.startsWith('spotify:') - ? platformUrl - : query - ? createSearchUrl('spotify', query) - : undefined; - const appUrl = - (platformUrl ? createSpotifyAppUrlFromWebUrl(platformUrl) : undefined) ?? - (query ? createSpotifySearchAppUrl(query) : undefined); - - return { - fallbackUrl: appUrl && webUrl && appUrl !== webUrl ? webUrl : undefined, - url: appUrl ?? webUrl, - }; +function createYoutubeMusicSearchUrl(query: string) { + return `https://music.youtube.com/search?q=${encodeURIComponent(query)}`; } -export function getTrackExternalLink( - track: Track, - selectedPlatformId: MusicPlatformId, -): TrackExternalLinkResult { - const platform = getMusicPlatformOption(selectedPlatformId); - const platformUrl = - selectedPlatformId === 'none' ? undefined : track.platformUrls?.[selectedPlatformId]; - - if (platformUrl) { - if (selectedPlatformId === 'spotify') { - const spotifyTarget = createSpotifyOpenTarget(platformUrl, createTrackQuery(track)); - - return { - fallbackUrl: spotifyTarget.fallbackUrl, - label: 'Spotify 앱에서 열기', - platformId: selectedPlatformId, - url: spotifyTarget.url, - usedFallback: false, - }; - } - +export function getTrackExternalLink(track: Track): TrackExternalLinkResult { + if (track.externalUrl) { return { - label: `${platform.shortLabel}에서 열기`, - platformId: selectedPlatformId, - url: platformUrl, + label: '음악 링크 열기', + platformId: 'none', + url: track.externalUrl, usedFallback: false, }; } - if (selectedPlatformId === 'none' && track.externalUrl) { + if (track.platformUrls?.youtubeMusic) { return { - label: '외부 음악 앱에서 열기', - platformId: selectedPlatformId, - url: track.externalUrl, + label: 'YouTube Music에서 열기', + platformId: 'youtubeMusic', + url: track.platformUrls.youtubeMusic, usedFallback: false, }; } @@ -173,31 +49,16 @@ export function getTrackExternalLink( if (!query) { return { - label: `${platform.shortLabel}에서 열기`, - platformId: selectedPlatformId, - usedFallback: true, - }; - } - - if (selectedPlatformId === 'spotify') { - const spotifyTarget = createSpotifyOpenTarget(undefined, query); - - return { - fallbackUrl: spotifyTarget.fallbackUrl, - label: 'Spotify 앱에서 검색', - platformId: selectedPlatformId, - url: spotifyTarget.url, + label: '음악 링크 열기', + platformId: 'none', usedFallback: true, }; } return { - label: - selectedPlatformId === 'none' - ? 'YouTube Music에서 검색' - : `${platform.shortLabel}에서 검색`, - platformId: selectedPlatformId, - url: createSearchUrl(selectedPlatformId, query), + label: 'YouTube Music에서 검색', + platformId: 'youtubeMusic', + url: createYoutubeMusicSearchUrl(query), usedFallback: true, }; } From 5107f7432b3ec94e217df96099ea37290c77c54a Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:08:05 +0900 Subject: [PATCH 02/27] =?UTF-8?q?fix:=20=EC=99=B8=EB=B6=80=20=EC=9D=8C?= =?UTF-8?q?=EC=95=85=20=EB=A7=81=ED=81=AC=20=EC=84=B1=EA=B3=B5=20=ED=9B=84?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 35 +++++++++++++------ src/components/library/LibraryScreen.tsx | 27 +++++++------- .../playlist/PlaylistCurationScreen.tsx | 27 +++++++------- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index e554ea1..ef10b71 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -12,6 +12,7 @@ import { meApi } from '@/api/meApi'; import { playlistApi, PlaylistMlMood, PlaylistMlState } from '@/api/playlistApi'; import { playlistQueryKeys } from '@/api/playlistQueries'; import { syncRecommendationEvent } from '@/api/recommendationEventApi'; +import { AppText } from '@/components/AppText'; import { useNearbyPlacesQuery } from '@/api/tourQueries'; import { MiniPlayer } from '@/components/MiniPlayer'; import { FeaturedPlaylistSection } from '@/components/home/FeaturedPlaylistSection'; @@ -78,6 +79,7 @@ function resolvePlaylistState(mode?: TravelMode): PlaylistMlState { function HomeContent() { const insets = useSafeAreaInsets(); + const [actionMessage, setActionMessage] = useState(); const [creatingPlaylistId, setCreatingPlaylistId] = useState(); const { selectedMoodFilter, @@ -156,7 +158,9 @@ function HomeContent() { } }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); - const handleSelectRecommendation = (item: MoodRecommendation) => { + const handleSelectRecommendation = async (item: MoodRecommendation) => { + setActionMessage(undefined); + if (item.playlistId) { router.push(`/playlist/${item.playlistId}`); syncRecommendationEvent( @@ -173,15 +177,20 @@ function HomeContent() { const externalLink = getTrackExternalLink(item.track); setTrack(item.track); - void openMusicPlatformUrl(externalLink).catch(() => undefined); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - trackId: item.track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); + + try { + await openMusicPlatformUrl(externalLink); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + trackId: item.track.id, + type: 'track_external_open', + value: externalLink.platformId, + }), + ); + } catch { + setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); + } }; const handleSelectFeaturedPlaylist = useCallback( async (playlist: FeaturedPlaylist) => { @@ -399,6 +408,12 @@ function HomeContent() { /> + {actionMessage ? ( + + {actionMessage} + + ) : null} + { + const playRecord = async (record: LibraryTrackRecord) => { const externalLink = getTrackExternalLink(record.track); setActionMessage(undefined); setTrack(record.track, record.playlistId); - void openMusicPlatformUrl(externalLink).catch(() => { + + try { + await openMusicPlatformUrl(externalLink); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: record.playlistId, + trackId: record.track.id, + type: 'track_external_open', + value: externalLink.platformId, + }), + ); + } catch { setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - }); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: record.playlistId, - trackId: record.track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); + } }; const toggleSelectedLike = () => { if (!selectedRecord) { diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index d02d5f4..8b6086e 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -83,7 +83,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); - const playTrack = (track: Track) => { + const playTrack = async (track: Track) => { if (!playlist) { return; } @@ -92,18 +92,21 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro setActionMessage(undefined); setTrack(track, playlist.id, playlist.tracks); - void openMusicPlatformUrl(externalLink).catch(() => { + + try { + await openMusicPlatformUrl(externalLink); + syncRecommendationEvent( + addRecommendationEvent({ + context: createRecommendationEventContext(), + playlistId: playlist.id, + trackId: track.id, + type: 'track_external_open', + value: externalLink.platformId, + }), + ); + } catch { setActionMessage('음악 링크를 열지 못했어요. 다시 시도해주세요.'); - }); - syncRecommendationEvent( - addRecommendationEvent({ - context: createRecommendationEventContext(), - playlistId: playlist.id, - trackId: track.id, - type: 'track_external_open', - value: externalLink.platformId, - }), - ); + } }; const playFirstTrack = () => { From 7dc0365ec685e75aa15806c82088d938108fa827 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:10:24 +0900 Subject: [PATCH 03/27] =?UTF-8?q?fix:=20=EB=A7=9E=EC=B6=A4=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=88=A8=EA=B9=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 3 +++ src/api/playlistApi.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index ef10b71..1e66abd 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -198,6 +198,7 @@ function HomeContent() { return; } + setActionMessage(undefined); setCreatingPlaylistId(playlist.id); try { @@ -233,6 +234,8 @@ function HomeContent() { }), ); router.push(`/playlist/${nextPlaylistId}`); + } catch { + setActionMessage('맞춤 플레이리스트를 만들지 못했어요. 잠시 후 다시 시도해주세요.'); } finally { setCreatingPlaylistId(undefined); } diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 0a879ed..1049f04 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -43,6 +43,6 @@ export const playlistApi = { body: input, idempotencyKey: createIdempotencyKey('playlist-contextual'), method: 'POST', - }).catch(() => playlistApi.getPlaylist(fallbackPlaylistId)); + }); }, }; From 1a9e7aaa7f40509b2af9c42c374270cb1d899a56 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:15:56 +0900 Subject: [PATCH 04/27] =?UTF-8?q?fix:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=88=A8=EA=B9=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/client.ts | 8 +++----- src/api/homeApi.ts | 4 ++-- src/api/libraryApi.ts | 6 +++--- src/api/meApi.ts | 4 ++-- src/api/momentLogApi.ts | 4 ++-- src/api/playlistApi.ts | 4 ++-- src/api/recapApi.ts | 10 +++++----- src/api/recommendationEventApi.ts | 4 ++-- 8 files changed, 21 insertions(+), 23 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 7ff78e6..5a475f4 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -45,14 +45,12 @@ export function shouldUseServerApi() { return isServerApiSource(); } -export function canUseAuthenticatedApi() { - const { accessToken, refreshToken, status } = useAuthStore.getState(); +export function shouldAttemptAuthenticatedApi() { + const { status } = useAuthStore.getState(); return ( shouldUseServerApi() && - isRealApiEnabled() && - status === 'authenticated' && - Boolean(accessToken || refreshToken) + status === 'authenticated' ); } diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index 4e80e7e..f2f38ed 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -1,4 +1,4 @@ -import { canUseAuthenticatedApi, requestApi, shouldUseServerApi } from '@/api/client'; +import { requestApi, shouldAttemptAuthenticatedApi, shouldUseServerApi } from '@/api/client'; import { getMockServer } from '@/api/mockServerClient'; import type { FeaturedPlaylistMockParams, @@ -52,7 +52,7 @@ export const homeApi = { return mockServer.home.getRecentMusicLogs(); } - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } diff --git a/src/api/libraryApi.ts b/src/api/libraryApi.ts index 60533b7..cf467c1 100644 --- a/src/api/libraryApi.ts +++ b/src/api/libraryApi.ts @@ -1,7 +1,7 @@ import { - canUseAuthenticatedApi, createIdempotencyKey, requestApi, + shouldAttemptAuthenticatedApi, } from '@/api/client'; import { RecommendationEventContext } from '@/store/recommendationEventStore'; import { Track } from '@/types/domain'; @@ -25,7 +25,7 @@ export type RemoteLibraryTrackRecord = { export const libraryApi = { getTracks: (kind: 'all' | 'liked' | 'saved' = 'all') => { - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } @@ -41,7 +41,7 @@ export const libraryApi = { playlistId?: string; }, ) => { - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } diff --git a/src/api/meApi.ts b/src/api/meApi.ts index 878ec82..958c2c6 100644 --- a/src/api/meApi.ts +++ b/src/api/meApi.ts @@ -1,4 +1,4 @@ -import { canUseAuthenticatedApi, requestApi } from '@/api/client'; +import { requestApi, shouldAttemptAuthenticatedApi } from '@/api/client'; import { UserProfile, UserProfileInput } from '@/store/userProfileStore'; type ServerCompanionType = 'couple' | 'family' | 'friends' | 'solo'; @@ -34,7 +34,7 @@ function toProfileBody(input: UserProfileInput) { export const meApi = { updateProfile: (input: UserProfileInput) => { - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } diff --git a/src/api/momentLogApi.ts b/src/api/momentLogApi.ts index c677d46..6f321cf 100644 --- a/src/api/momentLogApi.ts +++ b/src/api/momentLogApi.ts @@ -1,7 +1,7 @@ import { - canUseAuthenticatedApi, createIdempotencyKey, requestApi, + shouldAttemptAuthenticatedApi, } from '@/api/client'; import { GeoPoint, MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; @@ -58,7 +58,7 @@ function toFormData(input: CreateMomentLogInput) { export const momentLogApi = { createMomentLog: (input: CreateMomentLogInput) => { - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 1049f04..1063433 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -1,7 +1,7 @@ import { - canUseAuthenticatedApi, createIdempotencyKey, requestApi, + shouldAttemptAuthenticatedApi, shouldUseServerApi, } from '@/api/client'; import { getMockServer } from '@/api/mockServerClient'; @@ -35,7 +35,7 @@ export const playlistApi = { input: ContextualPlaylistInput, fallbackPlaylistId?: string, ) => { - if (!shouldUseServerApi() || !canUseAuthenticatedApi()) { + if (!shouldUseServerApi() || !shouldAttemptAuthenticatedApi()) { return playlistApi.getPlaylist(fallbackPlaylistId); } diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index a267a34..58f7a3c 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -1,7 +1,7 @@ import { - canUseAuthenticatedApi, createIdempotencyKey, requestApi, + shouldAttemptAuthenticatedApi, shouldUseServerApi, } from '@/api/client'; import { getMockServer } from '@/api/mockServerClient'; @@ -24,7 +24,7 @@ export const recapApi = { return mockServer.recap.createShareEvent(recapId, type); } - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve({ accepted: false }); } @@ -46,7 +46,7 @@ export const recapApi = { return mockServer.recap.createRecap(input); } - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } @@ -62,7 +62,7 @@ export const recapApi = { return mockServer.recap.getRecapList(); } - if (!canUseAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } @@ -76,7 +76,7 @@ export const recapApi = { return mockServer.recap.getRecapShare(id); } - if (!id || !canUseAuthenticatedApi()) { + if (!id || !shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } diff --git a/src/api/recommendationEventApi.ts b/src/api/recommendationEventApi.ts index 9046fc6..c91daa6 100644 --- a/src/api/recommendationEventApi.ts +++ b/src/api/recommendationEventApi.ts @@ -1,9 +1,9 @@ -import { canUseAuthenticatedApi, createIdempotencyKey, requestApi } from '@/api/client'; +import { createIdempotencyKey, requestApi, shouldAttemptAuthenticatedApi } from '@/api/client'; import { RecommendationEvent } from '@/store/recommendationEventStore'; export const recommendationEventApi = { createEvents: (events: RecommendationEvent[]) => { - if (!canUseAuthenticatedApi() || events.length === 0) { + if (!shouldAttemptAuthenticatedApi() || events.length === 0) { return Promise.resolve({ accepted: false }); } From 900b5778bbc3d3ed809ecf36dd21b2e2df57aa13 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:20:40 +0900 Subject: [PATCH 05/27] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=EC=97=90=EA=B2=8C=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 24 +++-- app/(tabs)/my.tsx | 20 +++- .../onboarding/OnboardingScreen.tsx | 92 ++++++++++++++----- 3 files changed, 102 insertions(+), 34 deletions(-) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 1e66abd..957efaf 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -304,7 +304,7 @@ function HomeContent() { }, [addRecommendationEvent, recommendationMode, setRecommendationMode], ); - const handleEnableLocationRecommendation = () => { + const handleEnableLocationRecommendation = useCallback(async () => { const nextProfile = { companionType: profile.companionType, locationRecommendationEnabled: true, @@ -313,9 +313,17 @@ function HomeContent() { travelStyles: profile.travelStyles, }; - updateProfile(nextProfile); - void meApi.updateProfile(nextProfile).catch(() => undefined); - }; + setActionMessage(undefined); + + try { + await meApi.updateProfile(nextProfile); + updateProfile(nextProfile); + return true; + } catch { + setActionMessage('위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); + return false; + } + }, [profile, updateProfile]); const handleRefreshLocation = useCallback(async () => { if (locationStatus === 'loading') { return; @@ -336,9 +344,13 @@ function HomeContent() { setLocationStatus('unavailable'); } }, [locationStatus, setLocation, setLocationStatus]); - const handleSetCurrentLocation = useCallback(() => { + const handleSetCurrentLocation = useCallback(async () => { if (!profile.locationRecommendationEnabled) { - handleEnableLocationRecommendation(); + const didEnable = await handleEnableLocationRecommendation(); + + if (!didEnable) { + return; + } } void handleRefreshLocation(); diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index f79650c..e2ed83a 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -1,6 +1,6 @@ import { Feather } from '@expo/vector-icons'; import { router } from 'expo-router'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Pressable, ScrollView, View } from 'react-native'; import { meApi } from '@/api/meApi'; @@ -39,6 +39,7 @@ function formatEventTime(value?: string) { export default function MyScreen() { const { profile, resetOnboarding, updateProfile } = useUserProfileStore(); + const [profileMessage, setProfileMessage] = useState(); const { clearEvents, events, isHydrated } = useRecommendationEventStore(); const permissionSettings = useNativePermissionSettings(); const { @@ -73,7 +74,7 @@ export default function MyScreen() { } }, [currentPlace?.id, nearbyPlacesQuery.data, setPlace]); - const handleEnableLocationRecommendation = useCallback(() => { + const handleEnableLocationRecommendation = useCallback(async () => { const nextProfile = { companionType: profile.companionType, locationRecommendationEnabled: true, @@ -82,8 +83,14 @@ export default function MyScreen() { travelStyles: profile.travelStyles, }; - updateProfile(nextProfile); - void meApi.updateProfile(nextProfile).catch(() => undefined); + setProfileMessage(undefined); + + try { + await meApi.updateProfile(nextProfile); + updateProfile(nextProfile); + } catch { + setProfileMessage('위치 추천 설정을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); + } }, [profile, updateProfile]); const handleRefreshLocation = useCallback(async () => { @@ -196,6 +203,11 @@ export default function MyScreen() { status={locationStatus} updatedAt={locationUpdatedAt} /> + {profileMessage ? ( + + {profileMessage} + + ) : null} diff --git a/src/components/onboarding/OnboardingScreen.tsx b/src/components/onboarding/OnboardingScreen.tsx index c6bd17f..f6e55a8 100644 --- a/src/components/onboarding/OnboardingScreen.tsx +++ b/src/components/onboarding/OnboardingScreen.tsx @@ -116,6 +116,8 @@ export function OnboardingScreen() { const [draft, setDraft] = useState(() => getInitialDraft(profile), ); + const [isSaving, setIsSaving] = useState(false); + const [saveErrorMessage, setSaveErrorMessage] = useState(); const currentStep = steps[currentStepIndex]; const isLastStep = currentStepIndex === steps.length - 1; @@ -151,23 +153,42 @@ export function OnboardingScreen() { setSelectedMoodFilter(input.preferredMoods[0] ?? '전체'); }; - const finish = (input: UserProfileInput) => { + const saveProfile = async (input: UserProfileInput) => { + setIsSaving(true); + setSaveErrorMessage(undefined); + + try { + await meApi.updateProfile(input); + return true; + } catch { + setSaveErrorMessage('프로필을 서버에 저장하지 못했어요. 잠시 후 다시 시도해주세요.'); + return false; + } finally { + setIsSaving(false); + } + }; + + const finish = async (input: UserProfileInput) => { + const didSave = await saveProfile(input); + + if (!didSave) { + return; + } + if (isEditMode) { updateProfile(input); applyHomeFilters(input); - void meApi.updateProfile(input).catch(() => undefined); router.replace('/my' as never); return; } completeOnboarding(input); applyHomeFilters(input); - void meApi.updateProfile(input).catch(() => undefined); router.replace('/'); }; const handlePrimaryPress = () => { - if (!canProceed) { + if (!canProceed || isSaving) { return; } @@ -176,25 +197,33 @@ export function OnboardingScreen() { return; } - finish(draft); + void finish(draft); }; - const handleSkip = () => { + const handleSkip = async () => { + if (isSaving) { + return; + } + if (isEditMode) { router.replace('/my' as never); return; } + const skippedProfile = { + companionType: undefined, + locationRecommendationEnabled: true, + preferredGenres: [], + preferredMoods: [], + travelStyles: [], + }; + const didSave = await saveProfile(skippedProfile); + + if (!didSave) { + return; + } + skipOnboarding(); - void meApi - .updateProfile({ - companionType: undefined, - locationRecommendationEnabled: true, - preferredGenres: [], - preferredMoods: [], - travelStyles: [], - }) - .catch(() => undefined); setSelectedTopFilter('전체'); setSelectedMoodFilter('전체'); router.replace('/'); @@ -306,7 +335,12 @@ export function OnboardingScreen() { > - + void handleSkip()} + style={{ opacity: isSaving ? 0.5 : 1 }} + > {isEditMode ? '변경 없이 나가기' : '나중에 하기'} @@ -407,23 +441,33 @@ export function OnboardingScreen() { + {saveErrorMessage ? ( + + + {saveErrorMessage} + + + ) : null} + - {isLastStep + {isSaving + ? '저장 중...' + : isLastStep ? isEditMode ? '수정 완료' : '완료하고 시작하기' @@ -433,13 +477,13 @@ export function OnboardingScreen() { setCurrentStepIndex((prev) => prev - 1)} > Date: Fri, 3 Jul 2026 14:22:56 +0900 Subject: [PATCH 06/27] =?UTF-8?q?fix:=20=EB=AF=B8=EB=8B=88=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=96=B4=20=EA=B0=80=EC=A7=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=20=ED=91=9C=EC=8B=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MiniPlayer.tsx | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index d9b1ebc..4d8210c 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -280,12 +280,9 @@ export function MiniPlayer() { {currentTrack.artist} - - - + + {externalLink.label} + @@ -396,19 +393,9 @@ export function MiniPlayer() { {currentTrack.artist} - - - - - - - - 선택한 음악 - 외부 링크 - + + {externalLink.label} + From 4e89539f961a31f2926063828ba03a146a5f5d3e Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:30:25 +0900 Subject: [PATCH 07/27] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=9C=EB=8D=95?= =?UTF-8?q?=EC=85=98=20=EB=B0=B0=ED=8F=AC=20=EC=84=A4=EC=A0=95=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=86=B5=EA=B3=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- eas.json | 6 +++++- vercel.json | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 60fa777..b6fd83e 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Vercel web 배포는 HTTPS 페이지에서 HTTP EC2 API를 직접 호출하지 `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`와 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. -네이티브 실배포 빌드는 HTTPS API를 사용해야 합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. +네이티브 실배포 빌드는 `https://sound-log-app.vercel.app/api/soundlog` HTTPS 프록시를 사용합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. ## 테스트 설치 빌드 diff --git a/eas.json b/eas.json index 47414e1..485fc7e 100644 --- a/eas.json +++ b/eas.json @@ -22,7 +22,11 @@ "production": { "autoIncrement": true, "env": { - "EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL": "support@soundlog.app" + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://sound-log-app.vercel.app/api/soundlog", + "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server", + "EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL": "https://sound-log-app.vercel.app/legal/privacy", + "EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL": "support@soundlog.app", + "EXPO_PUBLIC_SOUNDLOG_TERMS_URL": "https://sound-log-app.vercel.app/legal/terms" } } }, diff --git a/vercel.json b/vercel.json index af1b93b..b62895d 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,10 @@ { "source": "/api/soundlog/:path*", "destination": "http://52.79.185.121:4000/:path*" + }, + { + "source": "/:path*", + "destination": "/index.html" } ] } From e9f86d2d75f4195bfd6188df29d0a0bbc99fc45c Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:35:24 +0900 Subject: [PATCH 08/27] =?UTF-8?q?fix:=20=EB=A7=9E=EC=B6=A4=20=ED=94=8C?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9E=A5?= =?UTF-8?q?=EB=A5=B4=20=EC=84=A0=ED=98=B8=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 2 ++ src/api/playlistApi.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 957efaf..165fb1b 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -208,6 +208,7 @@ function HomeContent() { mood: resolvePlaylistMood(selectedMoodFilter, profile.preferredMoods), moodTags: getMoodTagsFromFilter(selectedMoodFilter), placeId: currentPlace?.id, + preferredGenres: profile.preferredGenres, preferredMoods: profile.preferredMoods, state: resolvePlaylistState(selectedMode), travelMode: selectedMode, @@ -245,6 +246,7 @@ function HomeContent() { creatingPlaylistId, currentLocation, currentPlace, + profile.preferredGenres, profile.preferredMoods, selectedMode, selectedMoodFilter, diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 1063433..c9d4673 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -15,6 +15,7 @@ export type ContextualPlaylistInput = { mood?: PlaylistMlMood; moodTags?: MoodTag[]; placeId?: string; + preferredGenres?: string[]; preferredMoods?: string[]; state?: PlaylistMlState; travelMode?: TravelMode; From 14101d220ff5440176a31127e8d52a37227e6ddf Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:37:17 +0900 Subject: [PATCH 09/27] =?UTF-8?q?fix:=20=EC=9E=90=EC=B2=B4=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EA=B8=B0=EC=A4=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EB=AC=B8=EA=B5=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/legal/privacy.tsx | 2 +- app/legal/terms.tsx | 3 +-- src/components/my/AuthAccountCard.tsx | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/legal/privacy.tsx b/app/legal/privacy.tsx index d21ec32..d1244f1 100644 --- a/app/legal/privacy.tsx +++ b/app/legal/privacy.tsx @@ -4,7 +4,7 @@ import { SOUNDLOG_SUPPORT_EMAIL } from '@/constants/legal'; const privacySections = [ { title: '수집하는 정보', - body: 'Soundlog는 계정 연동 시 소셜 로그인 제공자가 전달하는 식별자, 이름, 이메일을 저장할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 여행 순간 기록, Recap 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.', + body: 'Soundlog는 자체 계정 가입과 로그인을 위해 사용자가 입력한 이름, 이메일, 비밀번호 인증 정보를 처리할 수 있습니다. 사용자가 입력한 음악 취향, 여행 스타일, 동행 유형, 좋아요와 저장한 음악, 여행 순간 기록, Recap 생성에 필요한 사진, 위치, 시간, 장소, 음악 정보를 저장할 수 있습니다.', }, { title: '위치와 사진 권한', diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx index f189f50..c6efba3 100644 --- a/app/legal/terms.tsx +++ b/app/legal/terms.tsx @@ -16,7 +16,7 @@ const termsSections = [ }, { title: '외부 서비스', - body: '외부 음악 앱, 소셜 로그인 제공자, 관광 데이터 제공자의 서비스로 이동하거나 연동되는 경우 해당 서비스의 약관과 정책이 함께 적용될 수 있습니다.', + body: '외부 음악 앱이나 관광 데이터 제공자의 서비스로 이동하거나 연동되는 경우 해당 서비스의 약관과 정책이 함께 적용될 수 있습니다.', }, { title: '제한 사항', @@ -38,4 +38,3 @@ export default function TermsScreen() { /> ); } - diff --git a/src/components/my/AuthAccountCard.tsx b/src/components/my/AuthAccountCard.tsx index 969e6fd..dd473a3 100644 --- a/src/components/my/AuthAccountCard.tsx +++ b/src/components/my/AuthAccountCard.tsx @@ -191,7 +191,7 @@ export function AuthAccountCard() { onPress={handleLoginPress} > - 소셜 로그인 연결하기 + Soundlog 계정으로 로그인 From 1e40892fdb47b4353af27d8f22038b9970ad1a74 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 14:45:28 +0900 Subject: [PATCH 10/27] =?UTF-8?q?fix:=20=EC=9B=90=EA=B2=A9=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=99=20=ED=94=8C=EB=9E=AB=ED=8F=BC=20URL=20=EC=A0=95?= =?UTF-8?q?=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/homeApi.ts | 5 ++- src/api/libraryApi.ts | 10 ++++- src/api/momentLogApi.ts | 6 ++- src/api/playlistApi.ts | 9 ++++- src/api/recapApi.ts | 9 ++++- src/utils/trackSanitizer.ts | 81 +++++++++++++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 src/utils/trackSanitizer.ts diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index f2f38ed..c731466 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -9,6 +9,7 @@ import type { MoodRecommendation, MusicLogItem, } from '@/types/domain'; +import { sanitizeMoodRecommendation } from '@/utils/trackSanitizer'; export const homeApi = { getFeaturedPlaylists: async (params?: FeaturedPlaylistMockParams) => { @@ -34,7 +35,7 @@ export const homeApi = { return mockServer.home.getMoodRecommendations(params); } - return requestApi('/v1/home/mood-recommendations', { + const recommendations = await requestApi('/v1/home/mood-recommendations', { query: { limit: 10, moodFilter: params?.moodFilter ?? '전체', @@ -45,6 +46,8 @@ export const homeApi = { travelStyles: params?.travelStyles, }, }); + + return recommendations.map(sanitizeMoodRecommendation); }, getRecentMusicLogs: async () => { if (!shouldUseServerApi()) { diff --git a/src/api/libraryApi.ts b/src/api/libraryApi.ts index cf467c1..774a541 100644 --- a/src/api/libraryApi.ts +++ b/src/api/libraryApi.ts @@ -5,6 +5,7 @@ import { } from '@/api/client'; import { RecommendationEventContext } from '@/store/recommendationEventStore'; import { Track } from '@/types/domain'; +import { sanitizeTrack } from '@/utils/trackSanitizer'; type LibraryTrackAction = 'like' | 'save' | 'unlike' | 'unsave'; @@ -24,14 +25,19 @@ export type RemoteLibraryTrackRecord = { }; export const libraryApi = { - getTracks: (kind: 'all' | 'liked' | 'saved' = 'all') => { + getTracks: async (kind: 'all' | 'liked' | 'saved' = 'all') => { if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } - return requestApi('/v1/library/tracks', { + const records = await requestApi('/v1/library/tracks', { query: { kind, limit: 50 }, }); + + return records.map((record) => ({ + ...record, + track: sanitizeTrack(record.track), + })); }, updateTrackState: ( trackId: string, diff --git a/src/api/momentLogApi.ts b/src/api/momentLogApi.ts index 6f321cf..bc0bfb0 100644 --- a/src/api/momentLogApi.ts +++ b/src/api/momentLogApi.ts @@ -4,6 +4,7 @@ import { shouldAttemptAuthenticatedApi, } from '@/api/client'; import { GeoPoint, MomentLog, MoodTag, Track, TravelMode } from '@/types/domain'; +import { sanitizeTrack } from '@/utils/trackSanitizer'; type CreateMomentLogInput = { createdAt: string; @@ -66,6 +67,9 @@ export const momentLogApi = { body: toFormData(input), idempotencyKey: input.idempotencyKey ?? createIdempotencyKey('moment-log'), method: 'POST', - }); + }).then((log) => ({ + ...log, + track: log.track ? sanitizeTrack(log.track) : undefined, + })); }, }; diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index c9d4673..6f07b72 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -6,6 +6,7 @@ import { } from '@/api/client'; import { getMockServer } from '@/api/mockServerClient'; import type { GeoPoint, MoodTag, PlaylistCuration, TravelMode } from '@/types/domain'; +import { sanitizePlaylistCuration } from '@/utils/trackSanitizer'; export type PlaylistMlMood = '감성적인' | '설레는' | '시원한' | '신나는' | '잔잔한'; export type PlaylistMlState = '바다' | '드라이브' | '산책' | '카페' | '야경'; @@ -28,9 +29,11 @@ export const playlistApi = { return mockServer.playlist.getPlaylist(id); } - return requestApi( + const playlist = await requestApi( `/v1/playlists/${encodeURIComponent(id ?? 'fallback')}`, ); + + return sanitizePlaylistCuration(playlist); }, createContextualPlaylist: async ( input: ContextualPlaylistInput, @@ -40,10 +43,12 @@ export const playlistApi = { return playlistApi.getPlaylist(fallbackPlaylistId); } - return requestApi('/v1/playlists/contextual', { + const playlist = await requestApi('/v1/playlists/contextual', { body: input, idempotencyKey: createIdempotencyKey('playlist-contextual'), method: 'POST', }); + + return sanitizePlaylistCuration(playlist); }, }; diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 58f7a3c..5f2e038 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -6,6 +6,7 @@ import { } from '@/api/client'; import { getMockServer } from '@/api/mockServerClient'; import type { RecapItem, RecapShare, RecapTemplateId } from '@/types/domain'; +import { sanitizeRecapItem } from '@/utils/trackSanitizer'; type CreateRecapInput = { momentLogIds?: string[]; @@ -50,11 +51,13 @@ export const recapApi = { return Promise.resolve(undefined); } - return requestApi('/v1/recaps', { + const recap = await requestApi('/v1/recaps', { body: input, idempotencyKey: createIdempotencyKey(`recap-${input.sessionId ?? 'session'}`), method: 'POST', }); + + return sanitizeRecapItem(recap); }, getRecapList: async () => { if (!shouldUseServerApi()) { @@ -66,9 +69,11 @@ export const recapApi = { return Promise.resolve([]); } - return requestApi('/v1/recaps', { + const recaps = await requestApi('/v1/recaps', { query: { limit: 20 }, }); + + return recaps.map(sanitizeRecapItem); }, getRecapShare: async (id?: string) => { if (!shouldUseServerApi()) { diff --git a/src/utils/trackSanitizer.ts b/src/utils/trackSanitizer.ts new file mode 100644 index 0000000..7253541 --- /dev/null +++ b/src/utils/trackSanitizer.ts @@ -0,0 +1,81 @@ +import { MoodRecommendation, PlaylistCuration, RecapItem, Track } from '@/types/domain'; + +const SUPPORTED_PLATFORM_IDS = ['melon', 'youtubeMusic'] as const; +const SUPPORTED_EXTERNAL_HOSTS = ['music.youtube.com']; + +function sanitizePlatformUrls(platformUrls: Track['platformUrls']) { + if (!platformUrls) { + return undefined; + } + + const nextPlatformUrls: Track['platformUrls'] = {}; + + SUPPORTED_PLATFORM_IDS.forEach((platformId) => { + const url = platformUrls[platformId]; + + if (url) { + nextPlatformUrls[platformId] = url; + } + }); + + return Object.keys(nextPlatformUrls).length > 0 ? nextPlatformUrls : undefined; +} + +function isSupportedExternalUrl(url?: string) { + if (!url) { + return false; + } + + try { + const host = new URL(url).hostname.toLowerCase(); + + return ( + SUPPORTED_EXTERNAL_HOSTS.includes(host) || + host === 'melon.com' || + host.endsWith('.melon.com') + ); + } catch { + return false; + } +} + +export function sanitizeTrack(track: Track): Track { + const platformUrls = sanitizePlatformUrls(track.platformUrls); + const externalUrl = isSupportedExternalUrl(track.externalUrl) ? track.externalUrl : undefined; + const nextTrack = { ...track }; + + if (platformUrls) { + nextTrack.platformUrls = platformUrls; + } else { + delete nextTrack.platformUrls; + } + + if (externalUrl) { + nextTrack.externalUrl = externalUrl; + } else { + delete nextTrack.externalUrl; + } + + return nextTrack; +} + +export function sanitizeMoodRecommendation(item: MoodRecommendation): MoodRecommendation { + return { + ...item, + track: sanitizeTrack(item.track), + }; +} + +export function sanitizePlaylistCuration(playlist: PlaylistCuration): PlaylistCuration { + return { + ...playlist, + tracks: playlist.tracks.map(sanitizeTrack), + }; +} + +export function sanitizeRecapItem(item: RecapItem): RecapItem { + return { + ...item, + representativeTrack: sanitizeTrack(item.representativeTrack), + }; +} From 403543073dd9261e6697ce459c00dff1bfb91484 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:00:31 +0900 Subject: [PATCH 11/27] =?UTF-8?q?docs:=20=EC=9D=8C=EC=95=85=20=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=A0=9C=EA=B1=B0=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md | 32 +++++----- .../MUSIC_CURATION_PAGE_RN_BUILD_DOC.md | 28 ++++----- docs/frontend/RN_FRONTEND_PLANNING_POINTS.md | 57 +++++++++-------- docs/product/SOUNDLOG_APP_PLANNING.md | 61 +++++++++---------- 4 files changed, 88 insertions(+), 90 deletions(-) diff --git a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md index fb11e71..7572bc1 100644 --- a/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MAIN_PAGE_RN_BUILD_DOC.md @@ -11,10 +11,10 @@ Figma 기준 화면은 `390 x 844` 모바일 화면에 맞춰 설계되어 있 - `Music Playlist` 대표 플레이리스트 캐러셀 - `나의 무드에 맞는 음악 추천` 섹션 - `Music Log` 섹션 -- 하단 미니 플레이어 +- 하단 외부 링크용 미니 플레이어 - 하단 탭바 및 중앙 카메라 버튼 -React Native 구현 시 Figma의 절대 좌표를 그대로 옮기기보다, 모바일 앱에서 안정적으로 동작하도록 **세로 스크롤 콘텐츠 + 가로 스크롤 리스트 + 하단 고정 플레이어/탭바** 구조로 재설계한다. +React Native 구현 시 Figma의 절대 좌표를 그대로 옮기기보다, 모바일 앱에서 안정적으로 동작하도록 **세로 스크롤 콘텐츠 + 가로 스크롤 리스트 + 하단 고정 미니 링크 패널/탭바** 구조로 재설계한다. --- @@ -26,7 +26,7 @@ React Native 구현 시 Figma의 절대 좌표를 그대로 옮기기보다, 모 1. 현재 위치 기반 추천 플레이리스트를 빠르게 보여준다. 2. 사용자의 상황/무드 필터를 통해 추천 방향을 조정한다. -3. 현재 재생 중인 음악을 항상 접근 가능하게 유지한다. +3. 현재 선택한 음악과 외부 음악 앱 링크를 항상 접근 가능하게 유지한다. 4. 여행 순간 저장 액션을 화면 어디서든 명확하게 제공한다. 5. Music Log를 통해 여행 중 쌓이는 음악 기록을 보여준다. @@ -345,7 +345,7 @@ CTA: ### 역할 -현재 재생 중인 음악을 화면 하단에서 항상 제어할 수 있게 한다. +현재 선택한 음악과 외부 음악 앱 링크를 화면 하단에서 항상 확인할 수 있게 한다. ### Figma 기준 요소 @@ -354,7 +354,7 @@ CTA: - 배경: `#45343D` - 좌측 앨범 썸네일 - 중앙 곡 제목/가수 -- 우측 이전/일시정지/다음 버튼 +- 우측 이전/외부 링크/다음 버튼 ### RN 구현 권장 @@ -365,22 +365,22 @@ MiniPlayer는 스크롤 콘텐츠 안에 넣지 않고 `absolute` 또는 레이 ```ts type PlayerState = { currentTrack?: Track; - isPlaying: boolean; - source: 'preview' | 'spotify' | 'melon' | 'none'; + queue: Track[]; + source: 'external-link' | 'none'; }; ``` ### 빈 상태 -재생 중인 곡이 없을 경우 다음 중 하나를 선택한다. +선택한 곡이 없을 경우 다음 중 하나를 선택한다. - 미니 플레이어 숨김 -- “추천 음악을 재생해보세요” 상태로 축소 표시 +- “추천 음악을 열어보세요” 상태로 축소 표시 권장안: ```txt -재생 중인 음악이 없으면 MiniPlayer를 숨긴다. +선택한 음악이 없으면 MiniPlayer를 숨긴다. ``` 이유는 메인페이지 하단 공간이 부족하기 때문이다. @@ -390,9 +390,9 @@ type PlayerState = { | 액션 | 동작 | | ---------------- | --------------------------------------- | | 미니 플레이어 탭 | 풀 플레이어 화면 또는 bottom sheet 열기 | -| 이전 | 이전 곡 재생 | -| 재생/일시정지 | 현재 곡 상태 변경 | -| 다음 | 다음 곡 재생 및 skip 이벤트 기록 | +| 이전 | 이전 곡으로 이동하고 외부 링크 열기 | +| 외부 링크 | 현재 곡의 외부 음악 앱 검색 링크 열기 | +| 다음 | 다음 곡으로 이동하고 피드백 이벤트 기록 | --- @@ -645,7 +645,7 @@ Figma는 390px 기준이므로 작은 기기와 큰 기기를 모두 고려해 - 칩 터치 영역은 최소 44px 높이를 권장한다. - 카메라 버튼은 명확한 accessibility label을 가진다. -- 미니 플레이어 버튼은 `이전 곡`, `재생`, `일시정지`, `다음 곡` label을 제공한다. +- 미니 플레이어 버튼은 `이전 곡`, `음악 링크 열기`, `다음 곡` label을 제공한다. - 색상만으로 선택 상태를 구분하지 않고, 선택 칩에는 텍스트/테두리 차이를 함께 준다. 예시: @@ -684,7 +684,7 @@ accessibilityLabel = '순간 저장 카메라 열기'; 9. 위치 권한 상태 UI 연결 10. 추천 API 연결 11. Music Log API 연결 -12. 실제 플레이어/외부 음악 앱 연동 +12. 외부 음악 앱 링크 연결 --- @@ -703,7 +703,7 @@ accessibilityLabel = '순간 저장 카메라 열기'; - 칩 선택 상태가 즉시 반영된다. - 위치 권한 거부 상태가 별도로 보인다. - 추천 로딩/실패/빈 상태가 모두 존재한다. -- 현재 재생 중인 곡이 없을 때 MiniPlayer 정책이 적용된다. +- 현재 선택한 곡이 없을 때 MiniPlayer 정책이 적용된다. ### 데이터 diff --git a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md index 0f54868..328f08a 100644 --- a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md @@ -4,7 +4,7 @@ 이 문서는 Figma의 `Playlist` 화면 중 음악 큐레이션 페이지를 React Native 기반 Soundlog 앱에서 구현하기 위한 제작 기준서이다. -Figma 기준 화면은 사용자의 현재 위치를 기반으로 추천된 플레이리스트를 보여주는 화면이다. 상단에는 장소 이미지가 크게 깔리고, 하단에는 유리 질감의 바텀시트 형태로 추천 지역, 재생 버튼, 트랙 리스트, 미니 플레이어가 배치된다. +Figma 기준 화면은 사용자의 현재 위치를 기반으로 추천된 플레이리스트를 보여주는 화면이다. 상단에는 장소 이미지가 크게 깔리고, 하단에는 유리 질감의 바텀시트 형태로 추천 지역, 외부 링크 버튼, 트랙 리스트, 미니 플레이어가 배치된다. --- @@ -16,9 +16,9 @@ Figma 기준 화면은 사용자의 현재 위치를 기반으로 추천된 플 1. 현재 위치 또는 선택 장소 기반 플레이리스트를 보여준다. 2. 왜 이 플레이리스트가 추천되었는지 장소 맥락을 전달한다. -3. 곡 리스트를 탐색하고 바로 재생할 수 있게 한다. +3. 곡 리스트를 탐색하고 외부 음악 앱 검색 링크를 바로 열 수 있게 한다. 4. 곡별 더보기, 저장, 좋아요 같은 음악 액션의 진입점을 제공한다. -5. 현재 재생 중인 음악은 미니 플레이어로 유지한다. +5. 현재 선택한 음악은 미니 플레이어로 유지한다. --- @@ -28,9 +28,9 @@ Figma 기준 화면은 사용자의 현재 위치를 기반으로 추천된 플 | --- | --- | | 배경 이미지 | 서울 야경/도시 이미지가 상단과 하단에 반복 배치 | | 상단 상태바 | iPhone 상태바 | -| 하단 시트 | `Seoul`, `Based on your location`, 재생 버튼, 곡 리스트 | +| 하단 시트 | `Seoul`, `Based on your location`, 외부 링크 버튼, 곡 리스트 | | 트랙 리스트 | `Seoul City`, `서울의 달`, `한강에서`, `밤편지`, `홍대와 건대사이` 등 | -| 미니 플레이어 | 현재 재생 곡 `Seoul City / JENNIE` | +| 미니 플레이어 | 현재 선택 곡 `Seoul City / JENNIE` | | 하단 탭바 | 홈, 위치, 카메라, 좋아요, 마이 | --- @@ -231,7 +231,7 @@ MVP에서는 `좋아요`, `저장하기`, `외부 음악 앱에서 열기`만 ### 역할 -현재 재생 중인 곡을 하단에서 제어한다. +현재 선택한 곡과 외부 음악 앱 링크를 하단에서 확인한다. ### Figma 기준 @@ -239,7 +239,7 @@ MVP에서는 `좋아요`, `저장하기`, `외부 음악 앱에서 열기`만 - 배경 `#45343D` - 좌측 앨범 썸네일 - 제목/가수 -- 이전/일시정지/다음 +- 이전/외부 링크/다음 ### RN 구현 위치 @@ -256,7 +256,7 @@ absolute left-5 right-5 bottom: tabBarHeight + safeAreaBottom + 12 ```ts type PlayerState = { currentTrack?: CuratedTrack; - isPlaying: boolean; + queue: CuratedTrack[]; playlistId?: string; }; ``` @@ -269,7 +269,7 @@ type PlayerState = { 이 화면에서 탭바는 하단 고정이며, 중앙 카메라 버튼은 항상 노출한다. -카메라 버튼을 누르면 현재 재생 중인 곡과 현재 위치 정보를 카메라 화면으로 전달한다. +카메라 버튼을 누르면 현재 선택한 곡과 현재 위치 정보를 카메라 화면으로 전달한다. ```ts type CameraEntryParams = { @@ -424,8 +424,8 @@ absolute left-5 right-5 h-[67px] rounded-[20px] bg-[#45343D] | 플레이리스트 로딩 | 배경 이미지 스켈레톤 + 트랙 리스트 스켈레톤 | | 플레이리스트 없음 | “이 위치에 맞는 음악을 찾는 중이에요” fallback | | 이미지 없음 | 기본 도시/지역 그라데이션 배경 | -| 곡 재생 실패 | 토스트 + 외부 음악 앱 열기 옵션 | -| 음악 플랫폼 미연동 | “음악 앱을 연결하면 바로 재생할 수 있어요” 안내 | +| 외부 링크 열기 실패 | 토스트 + 다시 시도 옵션 | +| 외부 링크 없음 | 곡 제목/아티스트 기반 검색 링크 생성 | | 좋아요 실패 | optimistic update 롤백 | | 네트워크 끊김 | 캐시된 플레이리스트 표시 | @@ -453,13 +453,13 @@ absolute left-5 right-5 h-[67px] rounded-[20px] bg-[#45343D] - 더보기 버튼 터치 영역이 충분한가? - 곡 제목이 길 때 한 줄 말줄임 처리되는가? - 배경 이미지 로딩 실패 시 화면이 깨지지 않는가? -- 음악 플랫폼 미연동 상태에서 재생 액션이 어색하지 않은가? +- 외부 링크가 없거나 열리지 않을 때 안내가 어색하지 않은가? - 카메라 버튼 진입 시 현재 곡/장소 컨텍스트가 유지되는가? --- ## 13. 최종 구현 방향 -음악 큐레이션 페이지는 Soundlog의 “추천 결과를 실제 음악 감상으로 전환하는 화면”이다. 따라서 디자인의 감성보다 더 중요한 것은 사용자가 추천을 신뢰하고 곡을 바로 재생할 수 있는 흐름이다. +음악 큐레이션 페이지는 Soundlog의 “추천 결과를 실제 음악 감상 앱으로 이어주는 화면”이다. 따라서 디자인의 감성보다 더 중요한 것은 사용자가 추천을 신뢰하고 외부 음악 앱 검색 링크를 빠르게 열 수 있는 흐름이다. -MVP에서는 드래그 가능한 고급 바텀시트보다 안정적인 고정형 시트로 시작하고, 이후 곡 리스트 확장/축소, blur 강도 조절, 재생 중 row 하이라이트, 외부 음악 앱 연동을 단계적으로 고도화한다. +MVP에서는 드래그 가능한 고급 바텀시트보다 안정적인 고정형 시트로 시작하고, 이후 곡 리스트 확장/축소, blur 강도 조절, 선택 중 row 하이라이트, 외부 음악 앱 링크 품질을 단계적으로 고도화한다. diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index b0358d7..639b525 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -30,8 +30,8 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 - 현재 위치 - 사용자 선택 관광 모드 - 사용자 선택 무드 태그 -- 현재 재생 중인 음악 -- 좋아요, 저장, 스킵, 무드 조정 피드백 +- 현재 선택한 음악 +- 좋아요, 저장, 다음/이전 이동, 외부 링크 열기, 무드 조정 피드백 - 순간 저장 시점의 위치, 시간, 음악, 사진 이 데이터가 끊기면 추천과 Recap 품질이 함께 떨어지므로, 입력 상태와 실패 상태를 화면에서 자연스럽게 처리해야 한다. @@ -48,7 +48,7 @@ Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔 | 언어 | TypeScript | 위치/음악/로그 데이터 타입 안정성 확보 | | 네비게이션 | React Navigation | 탭, 스택, 모달 흐름 구성에 적합 | | 서버 상태 | TanStack Query | 관광 API, 추천 API, Recap API 캐싱 및 실패 처리 | -| 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 플레이어 상태 관리 | +| 클라이언트 상태 | Zustand 또는 Jotai | 현재 여행 세션, 선택 태그, 선택 곡 상태 관리 | | 로컬 저장소 | MMKV 또는 AsyncStorage | 온보딩 상태, 최근 여행 세션, 임시 로그 저장 | | 카메라 | expo-camera 또는 react-native-vision-camera | MVP는 expo-camera, 고성능 촬영/프레임 처리는 vision-camera 검토 | | 위치 | expo-location 또는 react-native-geolocation-service | 백그라운드 위치 필요 여부에 따라 선택 | @@ -60,8 +60,8 @@ MVP 단계에서는 Expo가 적합하다. 온보딩, 위치, 카메라, 이미 다만 다음 요구가 커지면 bare RN 또는 custom dev client 전환을 검토한다. -- 음악 앱 수준의 정교한 백그라운드 재생 제어 -- Spotify/Melon SDK의 네이티브 연동 +- 직접 음원 재생 수준의 정교한 백그라운드 제어 +- Melon/YouTube Music 등 외부 음악 앱의 네이티브 SDK 연동 - 잠금화면 미디어 컨트롤 - 백그라운드 위치 추적 - 고성능 영상 Recap 생성 @@ -79,7 +79,7 @@ MVP 기준 탭은 4개로 단순화한다. | 홈 | 현재 여행과 추천의 중심 | 현재 위치, 태그, 추천 PL, 미니 플레이어 | | Recap | 지난 여행 기록 확인 | Recap 리스트, Recap 상세 | | 보관함 | 좋아요/저장 음악 확인 | 좋아요 음악, 저장 플레이리스트 | -| 마이 | 계정/연동/권한 관리 | 음악 플랫폼 연동, 취향 수정, 설정 | +| 마이 | 계정/권한 관리 | 취향 수정, 위치/카메라 권한, 설정 | 중앙 카메라 버튼은 탭과 별도로 Floating Action Button 형태로 둔다. 이 버튼은 홈뿐 아니라 Recap/보관함에서도 접근 가능하게 할지, 여행 세션 중에만 노출할지 정책이 필요하다. @@ -95,10 +95,10 @@ MVP 기준 탭은 4개로 단순화한다. | --- | --- | --- | | P-01 | 온보딩 | 서비스 가치 전달 | | P-02 | 취향 설문 | 음악/여행 취향 수집 | -| P-03 | 권한/연동 | 위치, 카메라, 음악 플랫폼 연결 | +| P-03 | 권한/취향 | 위치, 카메라, 취향 설정 | | P-04 | 홈 / 현재 여행 | 추천과 재생의 메인 허브 | | P-05 | 플레이리스트 추천 상세 | 추천 이유, 곡 목록, 무드 조정 | -| P-06 | 미니/풀 플레이어 | 음악 재생 제어 | +| P-06 | 미니/풀 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | | P-07 | 순간 저장 카메라 | 사진 촬영 및 로그 생성 | | P-08 | 순간 저장 확인 | 위치, 음악, 무드 확인 후 저장 | | P-09 | Recap 리스트 | 여행별 기록 확인 | @@ -141,14 +141,14 @@ MVP 기준 탭은 4개로 단순화한다. ### 5.3 권한/연동 -Soundlog는 위치, 카메라, 사진 라이브러리, 음악 플랫폼 연동이 필요하다. 권한 요청 순서를 잘못 설계하면 초기 이탈이 커진다. +Soundlog는 위치, 카메라, 사진 라이브러리 권한이 필요하다. 권한 요청 순서를 잘못 설계하면 초기 이탈이 커진다. 권장 순서는 다음과 같다. 1. 위치 권한: 현재 장소 기반 추천 설명 후 요청 2. 카메라 권한: 순간 저장 기능 진입 시 요청 3. 사진 권한: Recap 이미지 저장 또는 업로드 시 요청 -4. 음악 플랫폼 연동: 첫 재생 시점 또는 마이페이지에서 요청 +4. 외부 음악 앱 이동: 곡을 선택한 순간 검색 링크를 열고, 별도 계정 연동은 MVP 범위에서 제외 프론트 고려사항은 다음과 같다. @@ -190,17 +190,17 @@ Soundlog는 위치, 카메라, 사진 라이브러리, 음악 플랫폼 연동 - 외부 음악 앱으로 이동하는 경우 앱 복귀 플로우를 고려한다. - 음악 미리듣기와 전체 재생 가능 여부를 구분해 보여준다. -### 5.6 플레이어 +### 5.6 미니/풀 링크 패널 -MVP에서 플레이어는 복잡한 음악 앱 수준으로 만들기보다 Soundlog의 여행 맥락을 보여주는 데 집중한다. +MVP에서 미니/풀 링크 패널은 복잡한 음악 앱 수준의 재생 제어가 아니라 Soundlog의 여행 맥락과 현재 선택 곡을 보여주는 데 집중한다. 프론트 고려사항은 다음과 같다. -- 현재 곡, 아티스트, 커버 이미지, 현재 장소를 함께 보여준다. -- 재생/일시정지/다음/이전/좋아요를 우선 제공한다. -- 스킵 이벤트는 추천 피드백으로 수집한다. -- 백그라운드 재생 지원 범위를 명확히 정한다. -- 실제 음악 스트리밍이 불가능한 단계라면 “외부 플랫폼에서 재생”으로 범위를 줄인다. +- 현재 선택 곡, 아티스트, 커버 이미지, 현재 장소를 함께 보여준다. +- 외부 음악 앱 검색 링크, 다음/이전 곡 이동, 좋아요/저장을 우선 제공한다. +- 다음/이전 이동과 외부 링크 열기 이벤트는 추천 피드백으로 수집한다. +- 백그라운드 음원 제어, 인앱 음원 제공, 외부 플랫폼 상태 제어는 MVP 범위에서 제외한다. +- 실제 음악 감상은 YouTube Music 또는 Melon 검색 링크로 넘긴다. ### 5.7 순간 저장 카메라 @@ -252,7 +252,7 @@ Recap 상세는 공유 가능한 결과물을 보는 화면이다. 화면 비율 프론트 고려사항은 다음과 같다. -- 음악 플랫폼 연동 상태를 명확히 보여준다. +- 계정, 권한, 취향 정보 상태를 명확히 보여준다. - 위치/카메라/사진 권한 상태를 확인하고 설정으로 이동할 수 있어야 한다. - 온보딩에서 입력한 취향을 수정할 수 있어야 한다. - 데이터 삭제, 로그아웃, 계정 탈퇴 같은 민감 액션은 확인 모달을 둔다. @@ -369,7 +369,7 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. - 선택된 관광 모드 - 선택된 무드 태그 - 현재 위치 -- 현재 재생 중인 곡 +- 현재 선택한 곡 - 미니 플레이어 열림/닫힘 - 임시 MomentLog @@ -442,16 +442,15 @@ POST /v1/music-platforms/connect | 권한 거부 | 설정 이동 안내, 사진 없는 로그 저장 옵션 제공 | | 촬영 실패 | 재촬영, 임시 로그 저장 | -### 9.3 음악 플랫폼 연동 실패 +### 9.3 외부 음악 링크 실패 -음악 플랫폼 연동은 외부 서비스 의존성이 높으므로 fallback이 필요하다. +외부 음악 앱 링크는 기기와 서비스 상태에 따라 열리지 않을 수 있으므로 fallback이 필요하다. | 상태 | UI 대응 | | --- | --- | -| 미연동 | 미리듣기 또는 추천 목록만 제공 | -| 연동 성공 | 앱 또는 외부 플랫폼 재생 | -| 토큰 만료 | 재연동 요청 | -| 해당 곡 재생 불가 | 대체 곡 또는 외부 검색 링크 제공 | +| 링크 없음 | 곡 제목/아티스트 기반 검색 링크 생성 | +| 외부 앱 열기 실패 | 안내 메시지와 다시 시도 제공 | +| 해당 곡 검색 불가 | 대체 곡 또는 추천 목록 유지 | --- @@ -619,13 +618,13 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 | 리스크 | 설명 | 대응 | | --- | --- | --- | -| 권한 이탈 | 위치/카메라/음악 연동 요청이 많아 초기 이탈 가능 | 기능 맥락에서 순차 요청 | -| 음악 재생 범위 불명확 | Spotify/Melon SDK 연동 가능 범위가 제한될 수 있음 | MVP는 외부 플랫폼 딥링크 또는 미리듣기 중심으로 설계 | +| 권한 이탈 | 위치/카메라 요청이 초기 이탈을 만들 수 있음 | 기능 맥락에서 순차 요청 | +| 음악 감상 범위 불명확 | 외부 음악 앱 SDK나 직접 음원 제공 범위를 잘못 약속할 수 있음 | MVP는 외부 음악 앱 검색 링크 중심으로 설계 | | 위치 정확도 문제 | 실내, 지하, 이동 중 위치가 부정확할 수 있음 | 주변 장소 후보 선택 UI 제공 | | 네트워크 불안정 | 여행지에서 API 요청 실패 가능 | 최근 추천/임시 로그 로컬 캐싱 | | 이미지 품질 편차 | 관광공사 이미지가 없거나 비율이 다를 수 있음 | fallback 이미지 및 crop 정책 정의 | | Recap 생성 비용 | 영상형 Recap은 프론트/서버 모두 비용이 큼 | MVP는 이미지형 Recap 우선 | -| 상태 복잡도 증가 | 위치, 재생, 여행 세션, 로그가 얽힘 | 서버 상태와 클라이언트 상태 분리 | +| 상태 복잡도 증가 | 위치, 선택 곡, 여행 세션, 로그가 얽힘 | 서버 상태와 클라이언트 상태 분리 | --- @@ -633,4 +632,4 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 Soundlog의 React Native 프론트엔드는 단순히 화면을 예쁘게 구현하는 것보다, 여행 중 끊기지 않는 경험을 만드는 것이 중요하다. 핵심은 사용자가 현재 위치와 상황을 직접 복잡하게 설명하지 않아도 앱이 추천 가능한 상태를 만들고, 사용자가 인상적인 순간을 놓치지 않도록 사진·장소·음악·시간을 안정적으로 묶어 저장하는 것이다. -따라서 MVP 프론트 개발은 홈, 위치 권한, 관광 모드/무드 태그, 추천 카드, 미니 플레이어, 순간 저장, Recap 리스트를 중심으로 시작하는 것이 적절하다. 이후 실제 추천 API, 음악 플랫폼 연동, 백그라운드 동작, 영상 Recap을 단계적으로 확장한다. +따라서 MVP 프론트 개발은 홈, 위치 권한, 관광 모드/무드 태그, 추천 카드, 외부 링크용 미니 플레이어, 순간 저장, Recap 리스트를 중심으로 시작하는 것이 적절하다. 이후 실제 추천 API, 외부 음악 앱 링크 품질, 영상 Recap을 단계적으로 확장한다. diff --git a/docs/product/SOUNDLOG_APP_PLANNING.md b/docs/product/SOUNDLOG_APP_PLANNING.md index 3211deb..7959c96 100644 --- a/docs/product/SOUNDLOG_APP_PLANNING.md +++ b/docs/product/SOUNDLOG_APP_PLANNING.md @@ -29,7 +29,7 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. 둘째, 음악 탐색 피로를 줄인다. 사용자는 여행지에서 매번 “지금 이 장소에 어울리는 음악”을 검색하지 않아도 된다. Soundlog는 현재 위치, 관광 모드, 무드 태그, 사용자 취향을 결합해 플레이리스트를 제안하고, 추천이 맞지 않을 때는 “더 신나게”, “더 잔잔하게” 같은 간단한 조정만으로 방향을 바꿀 수 있게 한다. -셋째, 파편화된 여행 기록을 하나로 묶는다. 기존에는 사진은 갤러리에, 음악은 스트리밍 앱에, 방문 장소는 지도 앱에 따로 남았다. Soundlog는 사진·장소·음악·시간·무드 데이터를 하나의 여행 순간으로 저장하고, 여행 종료 후 Recap 콘텐츠로 자동 재구성한다. +셋째, 파편화된 여행 기록을 하나로 묶는다. 기존에는 사진은 갤러리에, 음악은 음악 앱에, 방문 장소는 지도 앱에 따로 남았다. Soundlog는 사진·장소·음악·시간·무드 데이터를 하나의 여행 순간으로 저장하고, 여행 종료 후 Recap 콘텐츠로 자동 재구성한다. --- @@ -39,7 +39,7 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. | --- | --- | --- | --- | | 기록에 진심인 여행자 | 사진, 음악, 기록을 통해 여행을 하나의 작품처럼 남기고 싶어 한다. | 사진만으로는 당시의 분위기와 감정을 충분히 복기하기 어렵다. | 사진, 장소, 음악, 시간을 함께 저장하고 Recap으로 재구성한다. | | 선곡이 귀찮은 여행자 | 장소에 어울리는 음악을 듣고 싶지만 직접 찾는 과정을 번거롭게 느낀다. | 음악 검색이 여행 흐름을 끊고 피로감을 만든다. | 위치와 관광 맥락 기반으로 플레이리스트를 자동 추천한다. | -| 현장 몰입을 중시하는 여행자 | 여행 중에는 휴대폰보다 풍경과 분위기에 집중하고 싶어 한다. | 정보 중심 앱은 계속 화면 조작을 요구한다. | 미니 플레이어, 무드 조정, 원클릭 저장 중심의 Eyes-free UX를 제공한다. | +| 현장 몰입을 중시하는 여행자 | 여행 중에는 휴대폰보다 풍경과 분위기에 집중하고 싶어 한다. | 정보 중심 앱은 계속 화면 조작을 요구한다. | 외부 음악 링크, 무드 조정, 원클릭 저장 중심의 Eyes-free UX를 제공한다. | --- @@ -53,21 +53,21 @@ Soundlog는 이 문제를 다음 세 가지 관점에서 해결한다. ### 4.2 Eyes-free 여행 몰입 UX -Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 내부 또는 연동된 음악 플랫폼에서 바로 재생할 수 있고, 사용자는 하단 미니 플레이어를 통해 재생, 일시정지, 이전 곡, 다음 곡 이동을 간단히 제어한다. +Soundlog는 여행 중 화면 조작을 최소화하는 방향으로 설계된다. 추천된 플레이리스트는 앱 안에서 장소와 곡 맥락을 보여주고, 사용자는 하단 미니 플레이어에서 현재 선택한 곡을 확인한 뒤 외부 음악 앱 검색 링크로 이동한다. Soundlog MVP는 음원을 직접 스트리밍하거나 외부 플랫폼 재생 상태를 제어하지 않는다. 추천이 현재 분위기와 맞지 않을 경우 복잡한 검색을 다시 하지 않아도 된다. 사용자는 “더 신나게”, “더 잔잔하게”, “더 감성적으로”, “더 로컬하게” 같은 무드 조정 옵션을 선택해 추천 방향을 즉시 바꿀 수 있다. ### 4.3 여행 순간 저장 -사용자는 여행 중 인상적인 장면을 발견했을 때 중앙 카메라 버튼으로 순간을 저장한다. 이때 사진만 저장되는 것이 아니라 사진 촬영 위치, 시간, 주변 관광지 정보, 당시 재생 중이던 음악, 선택한 무드 정보가 함께 기록된다. +사용자는 여행 중 인상적인 장면을 발견했을 때 중앙 카메라 버튼으로 순간을 저장한다. 이때 사진만 저장되는 것이 아니라 사진 촬영 위치, 시간, 주변 관광지 정보, 당시 선택해 둔 음악, 선택한 무드 정보가 함께 기록된다. 이를 통해 하나의 사진은 단순 이미지가 아니라 “어디에서, 언제, 어떤 음악을 들으며, 어떤 분위기 속에서 남긴 장면인지”를 포함한 여행 순간이 된다. ### 4.4 Music Log와 Recap 생성 -Music Log는 여행 중 들었던 음악과 저장한 음악을 장소와 함께 보여주는 기록 영역이다. 사용자는 “그 여행지에서 들었던 음악”, “그 장소에서 저장했던 곡”을 다시 확인할 수 있고, 음악을 통해 여행 당시의 장면을 복기할 수 있다. +Music Log는 여행 중 선택하거나 저장한 음악을 장소와 함께 보여주는 기록 영역이다. 사용자는 “그 여행지에서 저장했던 곡”, “그 장소에서 함께 남긴 음악”을 다시 확인할 수 있고, 음악을 통해 여행 당시의 장면을 복기할 수 있다. -여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 재생 음악, 시간, 무드 데이터를 기반으로 Recap을 생성한다. Recap은 앨범 커버형, LP형, 필름형, 영상형 콘텐츠로 제공되며, 대표 장소, 대표 사진, 대표 음악, 아티스트, 기록 시간이 함께 표시된다. +여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 선택 음악, 시간, 무드 데이터를 기반으로 Recap을 생성한다. Recap은 앨범 커버형, LP형, 필름형, 영상형 콘텐츠로 제공되며, 대표 장소, 대표 사진, 대표 음악, 아티스트, 기록 시간이 함께 표시된다. --- @@ -75,7 +75,7 @@ Music Log는 여행 중 들었던 음악과 저장한 음악을 장소와 함께 ### 5.1 온보딩 및 초기 취향 수집 -온보딩은 추천 품질을 높이기 위한 초기 데이터 수집 단계이다. 사용자는 간편 로그인 후 위치 접근 권한과 음악 플랫폼 연동을 설정하고, 음악 취향과 여행 성향을 입력한다. +온보딩은 추천 품질을 높이기 위한 초기 데이터 수집 단계이다. 사용자는 자체 계정 로그인 또는 둘러보기 후 위치 기반 추천 여부를 설정하고, 음악 취향과 여행 성향을 입력한다. 수집 항목은 다음과 같다. @@ -91,7 +91,7 @@ Music Log는 여행 중 들었던 음악과 저장한 음악을 장소와 함께 ### 5.2 홈 / 현재 여행 -홈 화면은 현재 여행 세션의 중심 화면이다. 현재 위치, 주변 관광지, 여행 모드, 무드 태그, 추천 플레이리스트, 미니 플레이어, 순간 저장 버튼을 제공한다. +홈 화면은 현재 여행 세션의 중심 화면이다. 현재 위치, 주변 관광지, 여행 모드, 무드 태그, 추천 플레이리스트, 외부 링크용 미니 플레이어, 순간 저장 버튼을 제공한다. 주요 구성은 다음과 같다. @@ -108,7 +108,7 @@ Music Log는 여행 중 들었던 음악과 저장한 음악을 장소와 함께 주요 기능은 다음과 같다. - 추천 사유 제공: “광안리 해변 산책과 어울리는 청량한 플레이리스트”처럼 추천 맥락을 짧게 설명한다. -- 음악 플랫폼 연동: Spotify, Melon 등 외부 음악 플랫폼과 연결해 재생한다. +- 외부 음악 링크: 앱 안에서 직접 재생하지 않고 YouTube Music 또는 Melon 검색 링크로 이동한다. - 무드 조정: 더 신나게, 더 잔잔하게, 더 감성적으로, 더 로컬하게 옵션을 제공한다. - 좋아요/저장: 마음에 드는 곡과 플레이리스트를 저장한다. @@ -120,7 +120,7 @@ Music Log는 여행 중 들었던 음악과 저장한 음악을 장소와 함께 - 현재 위치 - 주변 관광지 ID 및 장소명 - 촬영 시간 -- 당시 재생 중인 음악 +- 당시 선택한 음악 - 사용자가 선택한 관광 모드 - 사용자가 선택한 무드 태그 @@ -137,9 +137,8 @@ Recap 유형은 다음과 같다. ### 5.6 마이페이지 -마이페이지는 계정과 연동, 저장 데이터를 관리하는 화면이다. +마이페이지는 계정, 저장 데이터, 권한, 취향 정보를 관리하는 화면이다. -- 음악 플랫폼 연동 관리 - 좋아요한 음악 확인 - 저장한 플레이리스트 확인 - 위치 권한 및 개인정보 설정 @@ -152,10 +151,10 @@ Recap 유형은 다음과 같다. | 영역 | 화면 | 목적 | | --- | --- | --- | | 시작 | 온보딩 | 서비스 가치 전달 및 취향 데이터 수집 | -| 시작 | 권한/연동 | 위치 권한, 음악 플랫폼 연동 설정 | +| 시작 | 권한/취향 | 위치 추천 여부와 음악·여행 취향 설정 | | 여행 | 홈 / 현재 여행 | 현재 위치, 관광 모드, 추천 플레이리스트 확인 | | 여행 | 음악 큐레이션 | 추천 플레이리스트 상세 및 무드 조정 | -| 여행 | 음악 플레이어 | 재생 제어 및 현재 장소 정보 확인 | +| 여행 | 음악 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | | 기록 | 순간 저장 | 사진, 장소, 음악, 시간 기반 로그 생성 | | 회고 | Recap 리스트 | 생성된 여행 Recap 확인 | | 회고 | Recap 상세 | 앨범 커버, LP, 필름, 영상 결과물 확인 및 공유 | @@ -167,12 +166,12 @@ Recap 유형은 다음과 같다. 1. 사용자가 앱에 진입한다. 2. 간편 로그인 후 음악 취향, 여행 스타일, 동행 유형 등 온보딩 설문을 완료한다. -3. 위치 접근 권한과 음악 플랫폼 연동을 설정한다. +3. 위치 기반 추천 여부와 음악·여행 취향을 설정한다. 4. 홈 화면에서 현재 위치 기반 추천을 확인한다. 5. 현재 상황 태그와 무드 태그를 선택해 추천 방향을 조정한다. -6. 추천된 플레이리스트를 재생한다. +6. 추천된 플레이리스트에서 곡을 선택하고 외부 음악 앱 검색 링크를 연다. 7. 여행 중 인상적인 순간에 카메라 버튼을 눌러 여행 로그를 생성한다. -8. 여행 중 들었던 음악과 저장한 곡은 Music Log에 누적된다. +8. 여행 중 선택하거나 저장한 곡은 Music Log에 누적된다. 9. 여행 종료 후 Soundlog가 사진, 장소, 음악, 시간 데이터를 바탕으로 Recap을 생성한다. 10. 사용자는 Recap을 저장하거나 SNS에 공유한다. @@ -209,8 +208,8 @@ Soundlog에서 한국관광공사 데이터는 부가 정보가 아니라 서비 | 이벤트 | 수집 데이터 | 활용 | | --- | --- | --- | -| 재생 | track_id, 재생 시간, 위치, 관광 모드 | 개인화 추천 학습 | -| 스킵 | track_id, 스킵 시점, 직전 재생 시간 | 부적합 음악 감지 | +| 외부 링크 열기 | track_id, 플랫폼, 위치, 관광 모드 | 개인화 추천 학습 | +| 다음/이전 이동 | track_id, 이동 방향, 위치, 관광 모드 | 부적합 음악 감지 | | 좋아요/저장 | track_id, 추천 컨텍스트, 시각 | 선호 음악 및 지역 트렌드 집계 | | 무드 조정 | 조정 방향, 직전 추천 결과 | 추천 랭킹 보정 | | 위치 변동 | GPS, 주변 POI ID, 타임스탬프 | 여행 경로 및 Recap 생성 | @@ -230,7 +229,7 @@ Soundlog의 추천 시스템은 사용자의 위치만으로 곡을 추천하는 4. Melon Playlist Dataset, 외부 음악 API, 사전 수집 플레이리스트 풀에서 후보곡을 수집한다. 5. 장소 맥락, 음악 태그, 사용자 취향, 지역 트렌드, 행동 로그를 기준으로 랭킹한다. 6. 최종 플레이리스트를 제공한다. -7. 재생, 스킵, 좋아요, 저장, 무드 조정 데이터를 다시 추천 모델에 반영한다. +7. 외부 링크 열기, 다음/이전 이동, 좋아요, 저장, 무드 조정 데이터를 다시 추천 모델에 반영한다. ### 9.2 LLM의 역할 @@ -272,7 +271,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### MVP에 포함 - 온보딩 설문 -- 위치 권한 및 음악 플랫폼 연동 흐름 +- 위치 권한 및 외부 음악 링크 흐름 - 현재 위치 기반 주변 관광지 조회 - 관광 모드 및 무드 태그 선택 - 장소 맥락 기반 플레이리스트 추천 @@ -281,7 +280,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 - 카메라 버튼 기반 여행 로그 생성 - Recap 리스트 및 상세 화면 - 좋아요한 음악/플레이리스트 확인 -- 마이페이지의 음악 플랫폼 연동 관리 +- 마이페이지의 계정, 권한, 취향 관리 ### MVP에서 제외하거나 후순위 @@ -302,19 +301,19 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### P-02 취향 설문 / 권한 연동 -음악 장르, 여행 스타일, 동행 유형, 위치 접근, 음악 플랫폼 연동을 설정한다. +음악 장르, 여행 스타일, 동행 유형, 위치 기반 추천 여부를 설정한다. ### P-03 홈 / 현재 여행 -현재 위치, 여행 모드, 상황 태그, 무드 태그, 추천 플레이리스트, 미니 플레이어, 카메라 버튼을 제공한다. +현재 위치, 여행 모드, 상황 태그, 무드 태그, 추천 플레이리스트, 외부 링크용 미니 플레이어, 카메라 버튼을 제공한다. ### P-04 음악 큐레이션 추천 플레이리스트의 커버 이미지, 추천 사유, 곡 목록, 무드 조정, 좋아요/저장을 제공한다. -### P-05 음악 플레이어 +### P-05 음악 링크 패널 -현재 재생 중인 곡, 현재 장소, 재생 컨트롤, 순간 저장 진입을 제공한다. +현재 선택한 곡, 현재 장소, 외부 음악 앱 링크, 순간 저장 진입을 제공한다. ### P-06 순간 저장 @@ -330,7 +329,7 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 ### P-09 마이페이지 -음악 플랫폼 연동, 좋아요한 음악, 저장한 플레이리스트, 취향 정보, 권한 설정을 관리한다. +좋아요한 음악, 저장한 플레이리스트, 취향 정보, 권한 설정을 관리한다. --- @@ -389,12 +388,12 @@ LLM은 곡을 직접 추천하는 역할이 아니라 사용자의 문장형 입 첫 번째 핵심 경험은 현재 장소에 어울리는 음악 추천입니다. 사용자가 앱에 진입하면 Soundlog는 현재 위치를 기준으로 주변 관광지와 장소 맥락을 파악합니다. 한국관광공사 OpenAPI를 통해 관광지명, 주소, 좌표, 카테고리, 장소 설명, 대표 이미지, 행사 및 축제 정보 등을 활용하고, 이를 바탕으로 사용자가 지금 어떤 관광 환경에 있는지 해석합니다. 단순히 “서울에 있다”, “부산에 있다”와 같은 위치 정보에 그치지 않고, 바다 근처 산책, 야경 명소 체류, 카페거리 휴식, 축제 현장 방문과 같은 장소의 성격을 함께 반영합니다. -사용자는 현재 자신의 여행 상황에 맞게 관광 모드를 선택할 수 있습니다. 산책, 드라이브, 카페 투어, 바다 보기, 축제, 야경 감상과 같은 모드를 설정하고, 잔잔한, 신나는, 청량한, 감성적인 등의 무드 태그를 더해 추천 방향을 조정합니다. 이 정보는 사용자의 음악 취향, 이전 재생 이력, 좋아요, 저장, 스킵 피드백과 함께 분석되어 현재 상황에 맞는 플레이리스트 추천에 활용됩니다. 예를 들어 해안가 산책 중에는 청량하고 잔잔한 음악을, 야경 명소에서는 차분하고 감성적인 음악을, 축제 현장에서는 활기찬 음악을 추천할 수 있습니다. +사용자는 현재 자신의 여행 상황에 맞게 관광 모드를 선택할 수 있습니다. 산책, 드라이브, 카페 투어, 바다 보기, 축제, 야경 감상과 같은 모드를 설정하고, 잔잔한, 신나는, 청량한, 감성적인 등의 무드 태그를 더해 추천 방향을 조정합니다. 이 정보는 사용자의 음악 취향, 외부 링크 열기, 좋아요, 저장, 다음/이전 이동 피드백과 함께 분석되어 현재 상황에 맞는 플레이리스트 추천에 활용됩니다. 예를 들어 해안가 산책 중에는 청량하고 잔잔한 음악을, 야경 명소에서는 차분하고 감성적인 음악을, 축제 현장에서는 활기찬 음악을 추천할 수 있습니다. -두 번째 핵심 경험은 Eyes-free 여행 몰입 UX입니다. 여행 중 사용자가 스마트폰 화면을 오래 바라보는 순간 실제 풍경과 장소에 대한 몰입은 줄어들 수 있습니다. Soundlog는 추천 플레이리스트를 바로 재생하고, 하단 미니 플레이어와 간단한 무드 조정 기능을 통해 화면 조작을 최소화합니다. 사용자는 복잡한 검색 없이 “더 신나게”, “더 잔잔하게”, “더 감성적으로” 같은 선택만으로 음악 분위기를 바꿀 수 있습니다. +두 번째 핵심 경험은 Eyes-free 여행 몰입 UX입니다. 여행 중 사용자가 스마트폰 화면을 오래 바라보는 순간 실제 풍경과 장소에 대한 몰입은 줄어들 수 있습니다. Soundlog는 추천 플레이리스트와 외부 음악 앱 검색 링크를 빠르게 제공하고, 하단 미니 플레이어와 간단한 무드 조정 기능을 통해 화면 조작을 최소화합니다. 사용자는 복잡한 검색 없이 “더 신나게”, “더 잔잔하게”, “더 감성적으로” 같은 선택만으로 음악 분위기를 바꿀 수 있습니다. -세 번째 핵심 경험은 여행 순간 저장과 Recap 생성입니다. 사용자가 여행 중 인상적인 장면을 발견했을 때 카메라 버튼을 누르면 사진뿐 아니라 촬영 위치, 시간, 주변 관광지 정보, 당시 재생 중이던 음악, 선택한 무드 정보가 함께 저장됩니다. 여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 재생 음악, 시간, 무드 데이터를 기반으로 앨범 커버형, LP형, 필름형, 영상형 Recap을 생성합니다. 사용자는 자신의 여행을 하나의 음악 앨범처럼 다시 감상하고, 완성된 Recap을 저장하거나 SNS에 공유할 수 있습니다. +세 번째 핵심 경험은 여행 순간 저장과 Recap 생성입니다. 사용자가 여행 중 인상적인 장면을 발견했을 때 카메라 버튼을 누르면 사진뿐 아니라 촬영 위치, 시간, 주변 관광지 정보, 당시 선택해 둔 음악, 선택한 무드 정보가 함께 저장됩니다. 여행이 종료되면 Soundlog는 저장된 사진, 방문 장소, 선택 음악, 시간, 무드 데이터를 기반으로 앨범 커버형, LP형, 필름형, 영상형 Recap을 생성합니다. 사용자는 자신의 여행을 하나의 음악 앨범처럼 다시 감상하고, 완성된 Recap을 저장하거나 SNS에 공유할 수 있습니다. -Soundlog의 기술적 핵심은 한국관광공사 OpenAPI 기반 장소 데이터와 음악 데이터를 연결하는 추천 파이프라인입니다. 현재 위치를 기준으로 관광지명, 좌표, 카테고리, 장소 설명, 대표 이미지, 행사·축제 정보 등을 수집하고, 이를 단순 위치 정보가 아닌 장소의 분위기와 관광 맥락으로 변환합니다. 이후 장소 설명, 카테고리, 관광 모드, 무드 태그를 음악 데이터와 매칭해 사용자 로그가 부족한 초기 상황에서도 장소에 어울리는 음악을 추천할 수 있습니다. 서비스 이용이 누적되면 재생, 스킵, 좋아요, 저장, 무드 조정 데이터를 반영해 개인화 추천 비중을 점진적으로 높입니다. +Soundlog의 기술적 핵심은 한국관광공사 OpenAPI 기반 장소 데이터와 음악 데이터를 연결하는 추천 파이프라인입니다. 현재 위치를 기준으로 관광지명, 좌표, 카테고리, 장소 설명, 대표 이미지, 행사·축제 정보 등을 수집하고, 이를 단순 위치 정보가 아닌 장소의 분위기와 관광 맥락으로 변환합니다. 이후 장소 설명, 카테고리, 관광 모드, 무드 태그를 음악 데이터와 매칭해 사용자 로그가 부족한 초기 상황에서도 장소에 어울리는 음악을 추천할 수 있습니다. 서비스 이용이 누적되면 외부 링크 열기, 다음/이전 이동, 좋아요, 저장, 무드 조정 데이터를 반영해 개인화 추천 비중을 점진적으로 높입니다. 결과적으로 Soundlog는 관광 데이터를 단순히 보여주는 서비스가 아니라, 관광 데이터를 기반으로 사용자의 여행 분위기를 설계하고, 여행 중 몰입을 돕고, 여행 후 기억을 콘텐츠로 남기는 서비스입니다. 관광 정보, 음악 추천, 여행 기록, 감성 콘텐츠 생성을 하나로 연결함으로써 관광데이터의 활용 가치를 실제 사용자 경험으로 확장하는 새로운 형태의 관광 서비스가 되고자 합니다. From 24f7654172f1e544f19b0ed8a635b7ba1b00f504 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:04:57 +0900 Subject: [PATCH 12/27] =?UTF-8?q?ci:=20=EC=84=9C=EB=B2=84=20=EC=9B=B9=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 3 + .../2026-07-03-server-web-export-ci-plan.md | 44 ++++++ package.json | 1 + scripts/check-server-web-export.js | 125 ++++++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 docs/implementation/2026-07-03-server-web-export-ci-plan.md create mode 100644 scripts/check-server-web-export.js diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 00fdb78..cddaffa 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -25,3 +25,6 @@ jobs: - name: Run typecheck run: npm run typecheck + + - name: Check server web export + run: npm run check:server-web-export diff --git a/docs/implementation/2026-07-03-server-web-export-ci-plan.md b/docs/implementation/2026-07-03-server-web-export-ci-plan.md new file mode 100644 index 0000000..5b343a8 --- /dev/null +++ b/docs/implementation/2026-07-03-server-web-export-ci-plan.md @@ -0,0 +1,44 @@ +# 2026-07-03 Server Web Export CI Plan + +## Goal + +Keep the frontend from regressing to mock API mode or reintroducing removed music streaming/Spotify routes after PR review and future changes. + +## User Outcome + +- The Vercel/web build is compiled with `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`. +- The compiled web bundle points API requests at `/api/soundlog`. +- The app no longer ships Spotify auth/playback route markers in the web bundle. +- PR checks fail before merge if these deployment-critical assumptions are broken. + +## Scope + +- Add a Node script that runs a server-mode Expo web export into a temporary directory. +- Inspect the generated JS bundles for server API markers and removed streaming markers. +- Add an npm script for local/CI use. +- Run the new check in the GitHub PR workflow after typecheck. + +## Non-goals + +- Do not add browser E2E tests in this slice. +- Do not merge PRs. +- Do not change Vercel domain/DNS settings from code. +- Do not require live EC2 API access in CI; this check validates the compiled app contract. + +## Files + +- `scripts/check-server-web-export.js` +- `package.json` +- `.github/workflows/pr-check.yml` + +## Verification + +- `npm run check:server-web-export` +- `npm run typecheck` +- `git diff --check` +- PR #14 checks pass after push. + +## Risks + +- Expo export can add CI time, but it is the closest automated proof that the deployed web bundle is server-mode. +- Bundle text is minified, so the script should check robust markers such as `/api/soundlog`, `apiSource:'server'` or equivalent, and absence of `spotify-auth`. diff --git a/package.json b/package.json index d21b66c..86a4e8a 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "build:production": "eas build --profile production --platform all", "typecheck": "tsc --noEmit", "doctor": "npx expo-doctor", + "check:server-web-export": "node scripts/check-server-web-export.js", "check:store-release": "node scripts/check-store-release.js", "check": "npm run typecheck && npm run doctor", "prepare": "sh scripts/install-git-hooks.sh" diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js new file mode 100644 index 0000000..9ffcefa --- /dev/null +++ b/scripts/check-server-web-export.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node + +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const projectRoot = path.resolve(__dirname, '..'); +const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), 'soundlog-web-export-')); +const errors = []; + +function addError(message) { + errors.push(message); +} + +function listFiles(dir) { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const nextPath = path.join(dir, entry.name); + + if (entry.isDirectory()) { + return listFiles(nextPath); + } + + return nextPath; + }); +} + +function readBundleText() { + const jsFiles = listFiles(outputDir).filter((filePath) => filePath.endsWith('.js')); + + if (jsFiles.length === 0) { + addError('Server web export did not produce any JavaScript bundles.'); + return ''; + } + + return jsFiles.map((filePath) => fs.readFileSync(filePath, 'utf8')).join('\n'); +} + +function assertIncludes(bundleText, pattern, message) { + if (typeof pattern === 'string' ? !bundleText.includes(pattern) : !pattern.test(bundleText)) { + addError(message); + } +} + +function assertExcludes(bundleText, pattern, message) { + if (typeof pattern === 'string' ? bundleText.includes(pattern) : pattern.test(bundleText)) { + addError(message); + } +} + +function runExport() { + const result = spawnSync( + process.platform === 'win32' ? 'npx.cmd' : 'npx', + ['expo', 'export', '--platform', 'web', '--output-dir', outputDir], + { + cwd: projectRoot, + env: { + ...process.env, + EXPO_PUBLIC_SOUNDLOG_API_BASE_URL: '/api/soundlog', + EXPO_PUBLIC_SOUNDLOG_API_SOURCE: 'server', + }, + stdio: 'inherit', + }, + ); + + if (result.status !== 0) { + addError(`Server web export failed with exit code ${result.status ?? 'unknown'}.`); + } +} + +function verifyBundle(bundleText) { + assertIncludes( + bundleText, + '/api/soundlog', + 'Server web export must inline EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog.', + ); + assertIncludes( + bundleText, + /apiSource\s*:\s*['"]server['"]/, + 'Server web export must initialize apiSource as server.', + ); + assertExcludes( + bundleText, + 'http://52.79.185.121:4000', + 'Server web export must not inline the direct EC2 HTTP API URL.', + ); + assertExcludes( + bundleText, + 'spotify-auth', + 'Server web export must not include the removed Spotify auth route.', + ); + assertExcludes( + bundleText, + 'soundlog-spotify-auth', + 'Server web export must not include the removed Spotify auth store.', + ); + assertExcludes( + bundleText, + 'playSelectedSpotifyOrFallback', + 'Server web export must not include removed Spotify playback helpers.', + ); + assertExcludes( + bundleText, + 'open.spotify.com', + 'Server web export must not include Spotify external search URLs.', + ); +} + +try { + runExport(); + + if (errors.length === 0) { + verifyBundle(readBundleText()); + } +} finally { + fs.rmSync(outputDir, { force: true, recursive: true }); +} + +if (errors.length > 0) { + console.error('Server web export check failed:'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); +} + +console.log('Server web export check passed.'); From 20ffecd30509aa3c0dbad101086d56bc6ebf26c0 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:19:42 +0900 Subject: [PATCH 13/27] =?UTF-8?q?fix:=20=ED=94=84=EB=A1=A0=ED=8A=B8=20mock?= =?UTF-8?q?=20API=20=EB=9F=B0=ED=83=80=EC=9E=84=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 6 +- docs/codex/CODEX_PROMPTS.md | 6 +- docs/codex/TEST_MANAGER.md | 20 +--- ...er-api-and-streaming-removal-audit-plan.md | 16 ++-- .../2026-07-03-server-web-export-ci-plan.md | 8 +- scripts/check-server-web-export.js | 15 ++- src/api/apiSource.ts | 9 -- src/api/authApi.ts | 91 ++++++------------- src/api/client.ts | 4 +- src/api/homeApi.ts | 46 +++++----- src/api/mockServerClient.ts | 9 -- src/api/playlistApi.ts | 9 +- src/api/recapApi.ts | 22 ----- src/api/tourApi.ts | 8 +- src/components/dev/DevTestManager.tsx | 91 +------------------ src/store/devToolsStore.ts | 25 ----- 16 files changed, 98 insertions(+), 287 deletions(-) delete mode 100644 src/api/apiSource.ts delete mode 100644 src/api/mockServerClient.ts diff --git a/docs/README.md b/docs/README.md index bfed5ad..4e61c79 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,9 +18,9 @@ Soundlog 문서는 목적별로 관리합니다. 공모전 기획, RN 프론트 - [비개발자용 Codex 개발 가이드](codex/NON_DEVELOPER_CODEX_GUIDE.md): Codex에게 개발을 맡기는 방식 - [Codex 요청 프롬프트 모음](codex/CODEX_PROMPTS.md): 바로 복사해서 쓸 수 있는 요청문 - [UI 피드백 루프 운영 문서](codex/UI_FEEDBACK_LOOP.md): 자연어 UI 수정 요청을 계획-리뷰-구현-리뷰 루프로 처리하는 방식 -- [개발용 테스트 매니저](codex/TEST_MANAGER.md): 페이지 이동, mock API, seed 데이터 검수를 위한 개발 도구 +- [개발용 테스트 매니저](codex/TEST_MANAGER.md): 페이지 이동, 조건/seed 데이터 검수를 위한 개발 도구 - [PR 전용 개발 흐름](codex/PR_ONLY_WORKFLOW.md): `main` 직접 push를 막고 PR로만 병합하는 방식 -## Mock API +## Legacy Mock API -- [Mock Server 안내](../src/mock-server/README.md): 서버가 없는 상태에서 앱 기능을 PoC로 테스트하는 방법 +- [Mock Server 안내](../src/mock-server/README.md): 서버가 없던 초기 PoC용 자료입니다. 현재 앱 API facade는 서버 API를 기본으로 사용합니다. diff --git a/docs/codex/CODEX_PROMPTS.md b/docs/codex/CODEX_PROMPTS.md index eeea614..61ff2b2 100644 --- a/docs/codex/CODEX_PROMPTS.md +++ b/docs/codex/CODEX_PROMPTS.md @@ -39,12 +39,12 @@ Soundlog에서 [기능명]을 구현하고 싶어. 수정 후 같은 종류의 문제가 다른 파일에도 있는지 확인하고 타입체크까지 해줘. ``` -## Mock API 추가 +## Legacy Mock API 추가 ```text -아직 서버가 없는 [기능명]을 테스트할 수 있게 mock-server에 API를 추가해줘. +명시적으로 로컬 PoC가 필요한 [기능명]만 mock-server에 API를 추가해줘. 성공/로딩/실패 상태를 모두 테스트할 수 있게 만들고, -React Query 훅 또는 API facade까지 연결해줘. +현재 배포 앱 API facade에는 연결하지 말고 별도 검증 방법까지 정리해줘. ``` ## 문서 정리 diff --git a/docs/codex/TEST_MANAGER.md b/docs/codex/TEST_MANAGER.md index 80859d8..57e2225 100644 --- a/docs/codex/TEST_MANAGER.md +++ b/docs/codex/TEST_MANAGER.md @@ -9,7 +9,7 @@ - 온보딩 완료 후 홈 필터 기본값이 뒤집혀 있다. - 현재: `preferredMoods[0]` -> 상단 필터, `travelStyles[0]` -> 무드 필터 - 기대: 상단 필터는 추천 범위이므로 `전체`, 무드 필터는 `preferredMoods[0]` -- Mock API 실패/지연은 환경변수로만 설정되어 앱 실행 중 상태 전환 테스트가 어렵다. +- 서버 API가 붙은 뒤에는 앱 실행 중 mock/server 전환을 제공하지 않는다. - Moment Log, Recap, Library, Player, Session 상태를 한 화면에서 빠르게 seed/clear할 방법이 없다. - 화면 이동 테스트가 탭/카드 탐색에 의존해 QA 속도가 느리다. @@ -27,23 +27,13 @@ - 위치/세션: 서울/부산 위치 seed, 위치 거부/불가 상태, 여행 시작/종료/리셋 - 데이터 seed: 샘플 Moment Log, 보관함 좋아요/저장, 현재 재생곡 - 데이터 clear: Moment Log, 보관함, 추천 이벤트, 플레이어 - - Mock API: 정상, 느림, 전체 실패, endpoint별 실패 - -3. Runtime Mock API 상태 - - `src/store/devToolsStore.ts`에 mock delay/fail 상태를 저장한다. - - `src/mock-server/delay.ts`가 환경변수보다 runtime 설정을 우선 참고한다. - - Query cache를 invalidate해서 변경 상태가 즉시 반영되게 한다. - - 실패/지연 설정은 개발 세션용 상태로 두고 영속 저장하지 않는다. - -4. 미완성/기획 불일치 보정 +3. 미완성/기획 불일치 보정 - 온보딩 완료 시 상단 필터는 `전체`, 무드 필터는 첫 선호 무드로 설정한다. - 홈 필터와 무드 필터 개념을 계속 분리한다. ## 주요 파일 - `src/components/dev/DevTestManager.tsx` -- `src/store/devToolsStore.ts` -- `src/mock-server/delay.ts` - `src/providers/AppProviders.tsx` - `src/store/playerStore.ts` - `src/store/libraryStore.ts` @@ -55,18 +45,16 @@ - TypeScript: `tsc --noEmit` - diff whitespace: `git diff --check` -- Runtime hook: mock API 정상/느림/실패 전환 - UI: 웹에서 플로팅 버튼이 이동 가능하고 패널이 열린다. ## 위험과 대응 - 테스트 매니저가 실제 배포 UI에 노출될 위험: `__DEV__` guard로 제한한다. - 패널이 화면을 가릴 위험: Modal 패널로 열고 닫기 버튼을 제공한다. -- Mock 실패 상태가 남아 검수를 방해할 위험: `정상` 버튼으로 모든 runtime mock 설정을 초기화한다. - Store 경계를 깨뜨릴 위험: seed/clear는 store action을 통해 수행하고 컴포넌트에서 배열을 직접 변형하지 않는다. -- Query 결과가 이전 상태로 남을 위험: mock 상태 변경 후 React Query cache를 invalidate한다. +- Query 결과가 이전 상태로 남을 위험: 위치/조건 변경 후 React Query cache를 invalidate한다. ## 계획 리뷰 반영 - Claude CLI 호출은 응답 없이 장시간 대기해 중단했고, `soundlog-ui-reviewer` 체크리스트로 자체 리뷰를 수행했다. -- 리뷰 결과 runtime mock 변경 후 cache invalidation, `__DEV__` guard, store action 경계, 온보딩 필터 불일치 수정이 필요하다고 판단했다. +- 리뷰 결과 조건 변경 후 cache invalidation, `__DEV__` guard, store action 경계, 온보딩 필터 불일치 수정이 필요하다고 판단했다. diff --git a/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md index c99adfd..5846dc0 100644 --- a/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md +++ b/docs/implementation/2026-07-03-server-api-and-streaming-removal-audit-plan.md @@ -5,18 +5,18 @@ Make the app behavior honest and testable: - production/web server mode must call the real Soundlog API, not the in-app mock server -- mock data must remain an explicit local/dev fallback only +- mock data must not be reachable through the app API facades - music recommendation can stay, but in-app music streaming and Spotify playback control must be removed - any music action shown to users must either open an external music link/search or clearly behave as local selection only ## Evidence gathered - Live `https://sound-log-app.vercel.app/api/soundlog/v1/health` returns the EC2 server health payload. -- The current live web bundle contains `getApiBaseUrl() => "/api/soundlog"` and initializes `apiSource: "server"`. +- The checked web export contains `getApiBaseUrl() => "/api/soundlog"` and compiles `shouldUseServerApi()` to always true. - Live home endpoints respond through the Vercel proxy: - `/api/soundlog/v1/home/mood-recommendations` - `/api/soundlog/v1/playlists/{id}` -- The app still bundles `mockServer` modules because API facades statically import them for local fallback. +- The app no longer imports `mockServer` from API facades; the web export check fails if mock handlers are bundled again. - Main server-backed read APIs use server mode, but authenticated APIs intentionally return empty/no-op values when the user is a guest or has no token. - Current playback UI is misleading: `playerStore.setTrack()` sets `isPlaying: true` even when no preview or stream exists. - Spotify auth/playback code remains in the app even though streaming will be removed. @@ -26,9 +26,9 @@ Make the app behavior honest and testable: ### App changes 1. Server/mock audit hardening - - Keep mock server available for explicit local/dev mode. - - Add a small runtime helper that can report whether the app is using server or mock mode. - - Add documentation/verification notes explaining why mock code may exist in the JS bundle but should not be selected at runtime in server mode. + - Remove mock server fallback branches from API facades. + - Compile `shouldUseServerApi()` as server-only. + - Add documentation/verification notes explaining that source-level legacy mocks are not part of the app API runtime. 2. Remove in-app streaming and Spotify playback - Remove Spotify OAuth callback route and Spotify auth/playback modules. @@ -50,7 +50,7 @@ Make the app behavior honest and testable: - `npm run check:store-release` without Spotify client id - web export with server proxy env - search for removed Spotify playback/auth imports - - search built bundle for `/api/soundlog` and runtime `apiSource: "server"` + - search built bundle for `/api/soundlog`, always-server mode, and absence of mock handlers ## Non-goals for this first loop @@ -63,4 +63,4 @@ Make the app behavior honest and testable: - Removing `expo-web-browser` is safe only if no other active route imports it. - Some users may still expect a persistent mini-player; it should become a selected-track/external-open panel rather than a streaming control. -- The server seed data intentionally resembles mock data, so visual similarity alone does not prove mock usage. +- The server seed data intentionally resembles old mock data, so visual similarity alone does not prove mock usage; network and bundle evidence are required. diff --git a/docs/implementation/2026-07-03-server-web-export-ci-plan.md b/docs/implementation/2026-07-03-server-web-export-ci-plan.md index 5b343a8..bbaa18a 100644 --- a/docs/implementation/2026-07-03-server-web-export-ci-plan.md +++ b/docs/implementation/2026-07-03-server-web-export-ci-plan.md @@ -8,6 +8,7 @@ Keep the frontend from regressing to mock API mode or reintroducing removed musi - The Vercel/web build is compiled with `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`. - The compiled web bundle points API requests at `/api/soundlog`. +- The compiled web bundle does not ship the development mock server handlers or seeded mock DB data. - The app no longer ships Spotify auth/playback route markers in the web bundle. - PR checks fail before merge if these deployment-critical assumptions are broken. @@ -15,6 +16,8 @@ Keep the frontend from regressing to mock API mode or reintroducing removed musi - Add a Node script that runs a server-mode Expo web export into a temporary directory. - Inspect the generated JS bundles for server API markers and removed streaming markers. +- Prevent the mock server dynamic import from being bundled into production web exports. +- Add bundle assertions for mock handler names and seeded mock route/data markers. - Add an npm script for local/CI use. - Run the new check in the GitHub PR workflow after typecheck. @@ -27,6 +30,8 @@ Keep the frontend from regressing to mock API mode or reintroducing removed musi ## Files +- `src/api/*Api.ts` +- `src/api/client.ts` - `scripts/check-server-web-export.js` - `package.json` - `.github/workflows/pr-check.yml` @@ -41,4 +46,5 @@ Keep the frontend from regressing to mock API mode or reintroducing removed musi ## Risks - Expo export can add CI time, but it is the closest automated proof that the deployed web bundle is server-mode. -- Bundle text is minified, so the script should check robust markers such as `/api/soundlog`, `apiSource:'server'` or equivalent, and absence of `spotify-auth`. +- Bundle text is minified, so the script should check robust markers such as `/api/soundlog`, `shouldUseServerApi=function(){return!0}`, and absence of `spotify-auth`, `homeMockHandlers`, and `authMockHandlers`. +- Legacy mock source files may remain in the repo for reference, but app API facades must not import them. diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index 9ffcefa..e85f8fa 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -76,14 +76,25 @@ function verifyBundle(bundleText) { ); assertIncludes( bundleText, - /apiSource\s*:\s*['"]server['"]/, - 'Server web export must initialize apiSource as server.', + 'shouldUseServerApi=function(){return!0}', + 'Server web export must compile shouldUseServerApi as always true.', ); assertExcludes( bundleText, 'http://52.79.185.121:4000', 'Server web export must not inline the direct EC2 HTTP API URL.', ); + [ + ['authMockHandlers', 'Server web export must not include auth mock handlers.'], + ['homeMockHandlers', 'Server web export must not include home mock handlers.'], + ['playlistMockHandlers', 'Server web export must not include playlist mock handlers.'], + ['recapMockHandlers', 'Server web export must not include recap mock handlers.'], + ['tourMockHandlers', 'Server web export must not include tour mock handlers.'], + ['mockServerDelay', 'Server web export must not include mock server delay helpers.'], + ['mock-user-email', 'Server web export must not include seeded mock auth data.'], + ].forEach(([pattern, message]) => { + assertExcludes(bundleText, pattern, message); + }); assertExcludes( bundleText, 'spotify-auth', diff --git a/src/api/apiSource.ts b/src/api/apiSource.ts deleted file mode 100644 index 42e1a22..0000000 --- a/src/api/apiSource.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ApiSource, useDevToolsStore } from '@/store/devToolsStore'; - -export function getApiSource(): ApiSource { - return useDevToolsStore.getState().apiSource; -} - -export function isServerApiSource() { - return getApiSource() === 'server'; -} diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 7438f6d..a9f84d7 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -1,5 +1,4 @@ -import { createIdempotencyKey, requestApi, shouldUseServerApi } from '@/api/client'; -import { getMockServer } from '@/api/mockServerClient'; +import { createIdempotencyKey, requestApi } from '@/api/client'; import { AuthMe, AuthSession, @@ -11,74 +10,44 @@ import { export const authApi = { getMe: async () => { - if (shouldUseServerApi()) { - return requestApi('/v1/me'); - } - - const mockServer = await getMockServer(); - return mockServer.auth.getMe(); + return requestApi('/v1/me'); }, login: async (request: LoginRequest) => { - if (shouldUseServerApi()) { - return requestApi('/v1/auth/login', { - auth: false, - body: request, - method: 'POST', - retryOnUnauthorized: false, - }); - } - - const mockServer = await getMockServer(); - return mockServer.auth.login(request); + return requestApi('/v1/auth/login', { + auth: false, + body: request, + method: 'POST', + retryOnUnauthorized: false, + }); }, logout: async (refreshToken?: string) => { - if (shouldUseServerApi()) { - return requestApi<{ accepted: boolean }>('/v1/auth/logout', { - auth: false, - body: { refreshToken }, - method: 'POST', - }); - } - - const mockServer = await getMockServer(); - return mockServer.auth.logout(); + return requestApi<{ accepted: boolean }>('/v1/auth/logout', { + auth: false, + body: { refreshToken }, + method: 'POST', + }); }, migrateLocalData: async (payload: LocalDataMigrationPayload) => { - if (shouldUseServerApi()) { - return requestApi('/v1/me/migrate-local-data', { - body: payload, - idempotencyKey: payload.idempotencyKey ?? createIdempotencyKey('migration'), - method: 'POST', - }); - } - - const mockServer = await getMockServer(); - return mockServer.auth.migrateLocalData(payload); + return requestApi('/v1/me/migrate-local-data', { + body: payload, + idempotencyKey: payload.idempotencyKey ?? createIdempotencyKey('migration'), + method: 'POST', + }); }, refresh: async (refreshToken?: string) => { - if (shouldUseServerApi()) { - return requestApi('/v1/auth/refresh', { - auth: false, - body: { refreshToken }, - method: 'POST', - retryOnUnauthorized: false, - }); - } - - const mockServer = await getMockServer(); - return mockServer.auth.refresh(refreshToken); + return requestApi('/v1/auth/refresh', { + auth: false, + body: { refreshToken }, + method: 'POST', + retryOnUnauthorized: false, + }); }, register: async (request: RegisterRequest) => { - if (shouldUseServerApi()) { - return requestApi('/v1/auth/register', { - auth: false, - body: request, - method: 'POST', - retryOnUnauthorized: false, - }); - } - - const mockServer = await getMockServer(); - return mockServer.auth.register(request); + return requestApi('/v1/auth/register', { + auth: false, + body: request, + method: 'POST', + retryOnUnauthorized: false, + }); }, }; diff --git a/src/api/client.ts b/src/api/client.ts index 5a475f4..98176cc 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,6 +1,5 @@ import { useAuthStore } from '@/store/authStore'; import { AuthSession } from '@/types/auth'; -import { isServerApiSource } from '@/api/apiSource'; type QueryValue = boolean | number | string | Array | null | undefined; @@ -42,14 +41,13 @@ export function isRealApiEnabled() { } export function shouldUseServerApi() { - return isServerApiSource(); + return true; } export function shouldAttemptAuthenticatedApi() { const { status } = useAuthStore.getState(); return ( - shouldUseServerApi() && status === 'authenticated' ); } diff --git a/src/api/homeApi.ts b/src/api/homeApi.ts index c731466..b05d80d 100644 --- a/src/api/homeApi.ts +++ b/src/api/homeApi.ts @@ -1,23 +1,33 @@ -import { requestApi, shouldAttemptAuthenticatedApi, shouldUseServerApi } from '@/api/client'; -import { getMockServer } from '@/api/mockServerClient'; -import type { - FeaturedPlaylistMockParams, - MoodRecommendationMockParams, -} from '@/mock-server/types'; +import { requestApi, shouldAttemptAuthenticatedApi } from '@/api/client'; import type { FeaturedPlaylist, + GeoPoint, MoodRecommendation, MusicLogItem, + MusicRecommendationMode, + PlaceContext, } from '@/types/domain'; import { sanitizeMoodRecommendation } from '@/utils/trackSanitizer'; -export const homeApi = { - getFeaturedPlaylists: async (params?: FeaturedPlaylistMockParams) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.home.getFeaturedPlaylists(params); - } +type FeaturedPlaylistParams = { + location?: GeoPoint; + locationRecommendationEnabled?: boolean; + recommendationMode?: MusicRecommendationMode; + place?: PlaceContext; +}; + +type MoodRecommendationParams = { + currentPlace?: PlaceContext; + moodFilter?: string; + recommendationMode?: MusicRecommendationMode; + preferredGenres?: string[]; + preferredMoods?: string[]; + topFilter?: string; + travelStyles?: string[]; +}; +export const homeApi = { + getFeaturedPlaylists: async (params?: FeaturedPlaylistParams) => { return requestApi('/v1/home/featured-playlists', { query: { lat: params?.location?.lat, @@ -29,12 +39,7 @@ export const homeApi = { }, }); }, - getMoodRecommendations: async (params?: MoodRecommendationMockParams) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.home.getMoodRecommendations(params); - } - + getMoodRecommendations: async (params?: MoodRecommendationParams) => { const recommendations = await requestApi('/v1/home/mood-recommendations', { query: { limit: 10, @@ -50,11 +55,6 @@ export const homeApi = { return recommendations.map(sanitizeMoodRecommendation); }, getRecentMusicLogs: async () => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.home.getRecentMusicLogs(); - } - if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } diff --git a/src/api/mockServerClient.ts b/src/api/mockServerClient.ts deleted file mode 100644 index f2aff63..0000000 --- a/src/api/mockServerClient.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { MockServer } from '@/mock-server/types'; - -let mockServerPromise: Promise | undefined; - -export function getMockServer() { - mockServerPromise ??= import('@/mock-server').then((module) => module.mockServer); - - return mockServerPromise; -} diff --git a/src/api/playlistApi.ts b/src/api/playlistApi.ts index 6f07b72..191a91c 100644 --- a/src/api/playlistApi.ts +++ b/src/api/playlistApi.ts @@ -2,9 +2,7 @@ import { createIdempotencyKey, requestApi, shouldAttemptAuthenticatedApi, - shouldUseServerApi, } from '@/api/client'; -import { getMockServer } from '@/api/mockServerClient'; import type { GeoPoint, MoodTag, PlaylistCuration, TravelMode } from '@/types/domain'; import { sanitizePlaylistCuration } from '@/utils/trackSanitizer'; @@ -24,11 +22,6 @@ export type ContextualPlaylistInput = { export const playlistApi = { getPlaylist: async (id?: string) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.playlist.getPlaylist(id); - } - const playlist = await requestApi( `/v1/playlists/${encodeURIComponent(id ?? 'fallback')}`, ); @@ -39,7 +32,7 @@ export const playlistApi = { input: ContextualPlaylistInput, fallbackPlaylistId?: string, ) => { - if (!shouldUseServerApi() || !shouldAttemptAuthenticatedApi()) { + if (!shouldAttemptAuthenticatedApi()) { return playlistApi.getPlaylist(fallbackPlaylistId); } diff --git a/src/api/recapApi.ts b/src/api/recapApi.ts index 5f2e038..0aaf368 100644 --- a/src/api/recapApi.ts +++ b/src/api/recapApi.ts @@ -2,9 +2,7 @@ import { createIdempotencyKey, requestApi, shouldAttemptAuthenticatedApi, - shouldUseServerApi, } from '@/api/client'; -import { getMockServer } from '@/api/mockServerClient'; import type { RecapItem, RecapShare, RecapTemplateId } from '@/types/domain'; import { sanitizeRecapItem } from '@/utils/trackSanitizer'; @@ -20,11 +18,6 @@ type RecapShareEventType = 'os_share' | 'save_image'; export const recapApi = { createShareEvent: async (recapId: string, type: RecapShareEventType) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.recap.createShareEvent(recapId, type); - } - if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve({ accepted: false }); } @@ -42,11 +35,6 @@ export const recapApi = { ); }, createRecap: async (input: CreateRecapInput) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.recap.createRecap(input); - } - if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } @@ -60,11 +48,6 @@ export const recapApi = { return sanitizeRecapItem(recap); }, getRecapList: async () => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.recap.getRecapList(); - } - if (!shouldAttemptAuthenticatedApi()) { return Promise.resolve([]); } @@ -76,11 +59,6 @@ export const recapApi = { return recaps.map(sanitizeRecapItem); }, getRecapShare: async (id?: string) => { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.recap.getRecapShare(id); - } - if (!id || !shouldAttemptAuthenticatedApi()) { return Promise.resolve(undefined); } diff --git a/src/api/tourApi.ts b/src/api/tourApi.ts index 79513c6..aa70c4d 100644 --- a/src/api/tourApi.ts +++ b/src/api/tourApi.ts @@ -1,5 +1,4 @@ -import { requestApi, shouldUseServerApi } from '@/api/client'; -import { getMockServer } from '@/api/mockServerClient'; +import { requestApi } from '@/api/client'; import type { GeoPoint, PlaceContext } from '@/types/domain'; type NearbyPlacesParams = { @@ -11,11 +10,6 @@ const DEFAULT_RADIUS_METERS = 2000; export const tourApi = { async getNearbyPlaces(params: NearbyPlacesParams): Promise { - if (!shouldUseServerApi()) { - const mockServer = await getMockServer(); - return mockServer.tour.getNearbyPlaces(params); - } - return requestApi('/v1/tour/nearby-places', { auth: false, query: { diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 6df45ec..6303baa 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -16,7 +16,6 @@ import { queryClient } from '@/providers/queryClient'; import { AppText } from '@/components/AppText'; import { playlistCurationById } from '@/mocks/playlistMocks'; import { useAuthStore } from '@/store/authStore'; -import { mockEndpointIds, useDevToolsStore } from '@/store/devToolsStore'; import { useHomeFilterStore } from '@/store/homeFilterStore'; import { useLibraryStore } from '@/store/libraryStore'; import { useMomentLogStore } from '@/store/momentLogStore'; @@ -28,8 +27,6 @@ import { AuthProvider, AuthSession } from '@/types/auth'; import { GeoPoint, MomentLog, PlaceContext, Track, TravelMode } from '@/types/domain'; const BUTTON_SIZE = 58; -const DEFAULT_DELAY_MS = 300; -const SLOW_DELAY_MS = 1600; const samplePlaylist = playlistCurationById['busan-ocean']; const sampleTracks = samplePlaylist.tracks; @@ -100,7 +97,7 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -function invalidateMockQueries() { +function invalidateRuntimeQueries() { void queryClient.invalidateQueries(); } @@ -222,17 +219,6 @@ function DevTestManagerContent() { const setMode = useTravelSessionStore((state) => state.setMode); const setPlace = useTravelSessionStore((state) => state.setPlace); const startSession = useTravelSessionStore((state) => state.startSession); - const { - apiSource, - failedEndpointIds, - failAllEndpoints, - mockDelayMs, - resetMockRuntime, - setApiSource, - setFailAllEndpoints, - setMockDelayMs, - toggleFailedEndpoint, - } = useDevToolsStore(); const { completeOnboarding, resetOnboarding } = useUserProfileStore(); const { continueAsGuest, finishLogin, logoutLocal, status: authStatus, user: authUser } = useAuthStore(); @@ -296,12 +282,12 @@ function DevTestManagerContent() { const applyPlacePreset = (preset: (typeof placePresets)[number]) => { setLocation(preset.location); setPlace(preset.place); - invalidateMockQueries(); + invalidateRuntimeQueries(); }; const setLocationState = (status: 'denied' | 'idle' | 'unavailable') => { clearLocation(); setLocationStatus(status); - invalidateMockQueries(); + invalidateRuntimeQueries(); }; const addSampleMomentLogs = () => { const baseTime = Date.now(); @@ -376,28 +362,6 @@ function DevTestManagerContent() { likedTracks.forEach((record) => removeLikedTrack(record.track.id)); savedTracks.forEach((record) => removeSavedTrack(record.track.id)); }; - const resetMockState = () => { - resetMockRuntime(); - invalidateMockQueries(); - }; - const setSlowMockState = () => { - setMockDelayMs(SLOW_DELAY_MS); - setFailAllEndpoints(false); - invalidateMockQueries(); - }; - const setFailAllMockState = () => { - setFailAllEndpoints(true); - invalidateMockQueries(); - }; - const toggleEndpoint = (endpointId: (typeof mockEndpointIds)[number]) => { - toggleFailedEndpoint(endpointId); - invalidateMockQueries(); - }; - const selectApiSource = (nextApiSource: typeof apiSource) => { - setApiSource(nextApiSource); - invalidateMockQueries(); - }; - return ( <> - 페이지 이동, 조건문, mock 실패/지연, 로컬 데이터를 빠르게 검수해요. + 페이지 이동, 조건문, 로컬 데이터를 빠르게 검수해요. - - - - - - selectApiSource('mock')} - /> - selectApiSource('server')} - /> @@ -598,33 +542,6 @@ function DevTestManagerContent() { - - - - - {mockEndpointIds.map((endpointId) => ( - toggleEndpoint(endpointId)} - /> - ))} - diff --git a/src/store/devToolsStore.ts b/src/store/devToolsStore.ts index 21b180d..dfc07bc 100644 --- a/src/store/devToolsStore.ts +++ b/src/store/devToolsStore.ts @@ -2,8 +2,6 @@ import { create } from 'zustand'; import type { MockEndpointId } from '@/mock-server/types'; -export type ApiSource = 'mock' | 'server'; - export const mockEndpointIds: MockEndpointId[] = [ 'auth.login', 'auth.register', @@ -21,38 +19,16 @@ export const mockEndpointIds: MockEndpointId[] = [ ]; type DevToolsState = { - apiSource: ApiSource; failedEndpointIds: MockEndpointId[]; failAllEndpoints: boolean; mockDelayMs?: number; resetMockRuntime: () => void; - setApiSource: (apiSource: ApiSource) => void; setFailAllEndpoints: (failAllEndpoints: boolean) => void; setMockDelayMs: (mockDelayMs?: number) => void; toggleFailedEndpoint: (endpointId: MockEndpointId) => void; }; -function getInitialApiSource(): ApiSource { - if (process.env.EXPO_PUBLIC_SOUNDLOG_API_SOURCE === 'mock') { - return __DEV__ ? 'mock' : 'server'; - } - - if ( - process.env.EXPO_PUBLIC_SOUNDLOG_API_SOURCE === 'server' || - process.env.EXPO_PUBLIC_SOUNDLOG_API_BASE_URL - ) { - return 'server'; - } - - if (!__DEV__) { - return 'server'; - } - - return 'mock'; -} - export const useDevToolsStore = create((set) => ({ - apiSource: getInitialApiSource(), failedEndpointIds: [], failAllEndpoints: false, mockDelayMs: undefined, @@ -62,7 +38,6 @@ export const useDevToolsStore = create((set) => ({ failAllEndpoints: false, mockDelayMs: undefined, }), - setApiSource: (apiSource) => set({ apiSource }), setFailAllEndpoints: (failAllEndpoints) => set({ failAllEndpoints }), setMockDelayMs: (mockDelayMs) => set({ mockDelayMs }), toggleFailedEndpoint: (endpointId) => From 5da1e4767a61b0f6b12b3ea578849fdbcd7d7c09 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:37:48 +0900 Subject: [PATCH 14/27] =?UTF-8?q?test:=20=EC=84=9C=EB=B2=84=20=EC=9B=B9=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20mock=20=EA=B2=80=EC=A6=9D=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/my.tsx | 2 +- scripts/check-server-web-export.js | 46 ++++++++++++++++++++++++++++++ src/api/mockDelay.ts | 38 ------------------------ 3 files changed, 47 insertions(+), 39 deletions(-) delete mode 100644 src/api/mockDelay.ts diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index e2ed83a..18bd97a 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -197,7 +197,7 @@ export default function MyScreen() { placeCount={nearbyPlacesQuery.data?.length ?? 0} placeInfoMessage={ currentPlace?.source === 'mock' - ? '주변 장소 정보를 임시 데이터로 보여주고 있어요.' + ? '주변 장소 정보를 기본 장소 데이터로 보여주고 있어요.' : undefined } status={locationStatus} diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index e85f8fa..a3fbcf9 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -36,6 +36,15 @@ function readBundleText() { return jsFiles.map((filePath) => fs.readFileSync(filePath, 'utf8')).join('\n'); } +function readTextFiles(dir) { + return listFiles(dir) + .filter((filePath) => /\.(ts|tsx|js|jsx)$/.test(filePath)) + .map((filePath) => ({ + filePath, + text: fs.readFileSync(filePath, 'utf8'), + })); +} + function assertIncludes(bundleText, pattern, message) { if (typeof pattern === 'string' ? !bundleText.includes(pattern) : !pattern.test(bundleText)) { addError(message); @@ -48,6 +57,27 @@ function assertExcludes(bundleText, pattern, message) { } } +function verifyApiSourceFiles() { + const apiDir = path.join(projectRoot, 'src/api'); + const blockedApiImports = [ + '@/mock-server', + '@/mocks', + '@/api/mockDelay', + '@/api/apiSource', + '@/api/mockServerClient', + ]; + + readTextFiles(apiDir).forEach(({ filePath, text }) => { + blockedApiImports.forEach((blockedImport) => { + if (text.includes(blockedImport)) { + addError( + `Server API facade must not import ${blockedImport}: ${path.relative(projectRoot, filePath)}`, + ); + } + }); + }); +} + function runExport() { const result = spawnSync( process.platform === 'win32' ? 'npx.cmd' : 'npx', @@ -92,6 +122,21 @@ function verifyBundle(bundleText) { ['tourMockHandlers', 'Server web export must not include tour mock handlers.'], ['mockServerDelay', 'Server web export must not include mock server delay helpers.'], ['mock-user-email', 'Server web export must not include seeded mock auth data.'], + ['DevTestManager', 'Server web export must not include the native development test manager.'], + ['dev-access-', 'Server web export must not include development auth tokens.'], + ['dev-refresh-', 'Server web export must not include development refresh tokens.'], + ['Soundlog 테스트 유저', 'Server web export must not include development test users.'], + ['playlistCurationById', 'Server web export must not include seeded mock playlist maps.'], + ['mock-namsan', 'Server web export must not include seeded mock nearby places.'], + ['mock-gwangalli', 'Server web export must not include seeded mock nearby places.'], + [ + 'EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS', + 'Server web export must not include mock API failure controls.', + ], + [ + 'EXPO_PUBLIC_MOCK_API_DELAY_MS', + 'Server web export must not include mock API delay controls.', + ], ].forEach(([pattern, message]) => { assertExcludes(bundleText, pattern, message); }); @@ -118,6 +163,7 @@ function verifyBundle(bundleText) { } try { + verifyApiSourceFiles(); runExport(); if (errors.length === 0) { diff --git a/src/api/mockDelay.ts b/src/api/mockDelay.ts deleted file mode 100644 index 50d4210..0000000 --- a/src/api/mockDelay.ts +++ /dev/null @@ -1,38 +0,0 @@ -type MockDelayOptions = { - delayMs?: number; - shouldFail?: boolean; -}; - -const DEFAULT_DELAY_MS = 300; - -function getFallbackDelayMs(delayMs?: number) { - if (delayMs !== undefined) { - return delayMs; - } - - const rawValue = process.env.EXPO_PUBLIC_MOCK_API_DELAY_MS; - const parsedValue = rawValue ? Number(rawValue) : DEFAULT_DELAY_MS; - - return Number.isFinite(parsedValue) && parsedValue >= 0 - ? parsedValue - : DEFAULT_DELAY_MS; -} - -export function mockDelay( - data: T, - options: MockDelayOptions = {}, -): Promise { - const { shouldFail = false } = options; - const delayMs = getFallbackDelayMs(options.delayMs); - - return new Promise((resolve, reject) => { - setTimeout(() => { - if (shouldFail) { - reject(new Error('Mock request failed')); - return; - } - - resolve(data); - }, delayMs); - }); -} From 300a28870ce48d4c634013ce5406a00d19e4aa39 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:41:03 +0900 Subject: [PATCH 15/27] =?UTF-8?q?ci:=20=EC=95=B1=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=A6=88=20=EC=84=A4=EC=A0=95=20=EA=B2=80=EC=82=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index cddaffa..aabc535 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -28,3 +28,6 @@ jobs: - name: Check server web export run: npm run check:server-web-export + + - name: Check store release config + run: npm run check:store-release From 94037b4ac3d65e807ba44f45bf18a0582df20027 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 15:54:17 +0900 Subject: [PATCH 16/27] =?UTF-8?q?refactor:=20=EC=9D=8C=EC=95=85=20?= =?UTF-8?q?=ED=94=8C=EB=9E=AB=ED=8F=BC=20=EC=9D=91=EB=8B=B5=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/mock-server/authHandlers.ts | 5 ----- src/types/auth.ts | 6 ------ 2 files changed, 11 deletions(-) diff --git a/src/mock-server/authHandlers.ts b/src/mock-server/authHandlers.ts index 4a05088..2b5c078 100644 --- a/src/mock-server/authHandlers.ts +++ b/src/mock-server/authHandlers.ts @@ -120,11 +120,6 @@ export const authMockHandlers = { } return mockServerDelay('auth.me', { - musicPlatform: { - connected: false, - selectedPlatformId: 'none', - updatedAt: new Date().toISOString(), - }, profile: activeSession.profile, user: activeSession.user, }); diff --git a/src/types/auth.ts b/src/types/auth.ts index 315cfe6..e405344 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -35,12 +35,6 @@ export type AuthSession = { }; export type AuthMe = { - musicPlatform?: { - connected: boolean; - providerUserId?: string; - selectedPlatformId: string; - updatedAt?: string; - }; profile?: UserProfile; user: AuthUser; }; From ee60e7100df3db1a7343d71da8320c2c19228825 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 16:02:35 +0900 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20=EA=B0=80=EC=A7=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=83=9D=20=ED=91=9C=EB=A9=B4=20=EC=B6=94=EA=B0=80=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/codex/NON_DEVELOPER_CODEX_GUIDE.md | 14 ++++---- .../MUSIC_CURATION_PAGE_RN_BUILD_DOC.md | 34 ++++++++++--------- docs/frontend/RN_FRONTEND_PLANNING_POINTS.md | 15 ++++---- scripts/check-server-web-export.js | 28 +++++++++++++++ src/components/travel/TravelReportModal.tsx | 16 --------- src/mock-server/README.md | 10 +++--- 6 files changed, 65 insertions(+), 52 deletions(-) diff --git a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md index 2a636de..d69bcdb 100644 --- a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md +++ b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md @@ -55,21 +55,19 @@ Codex에게 한 번에 “다 만들어줘”라고 하기보다 아래 순서 계획 -> 계획 리뷰 -> 반영 -> 구현 -> 코드리뷰 -> 코드리뷰 반영 순서로 진행해줘. ``` -## 서버가 아직 없을 때 +## 서버 API 확인이 필요할 때 -Soundlog는 `src/mock-server`로 서버 기능을 흉내낼 수 있습니다. 서버 개발 전에도 홈, 플레이리스트, Recap, 주변 관광지 기능을 테스트할 수 있습니다. +현재 Soundlog 앱의 API facade는 서버 API만 호출합니다. `src/mock-server`는 초기 PoC용 레거시 자료로 남아 있으며, 메인 홈/플레이리스트/Recap 런타임 검증 기준으로 쓰지 않습니다. -로딩 상태 확인: +서버 API 연결 여부 확인: ```bash -EXPO_PUBLIC_MOCK_API_DELAY_MS=1500 npm run web +npm run check:server-web-export ``` -실패 상태 확인: +이 검사는 `src/api`가 `src/mock-server`나 `src/mocks`를 import하거나, 프로덕션 웹 export에 mock handler/seed 데이터가 포함되면 실패합니다. -```bash -EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=recap.share npm run web -``` +API 실패 상태를 보고 싶다면 실제 dev 서버나 Vercel preview의 `/api/soundlog/v1/...` 요청을 기준으로 재현합니다. ## 검증을 요청할 때 diff --git a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md index 328f08a..f38cdbc 100644 --- a/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md +++ b/docs/frontend/MUSIC_CURATION_PAGE_RN_BUILD_DOC.md @@ -110,7 +110,7 @@ bg-black/40 overlay - 지역명: `Seoul` - 추천 근거: `Based on your location` -- 재생 버튼 +- 대표곡 외부 음악 링크 버튼 ### RN 컴포넌트 구조 @@ -118,7 +118,7 @@ bg-black/40 overlay PlaylistHeroInfo Text regionName Text recommendationReason - PlayButton + ExternalLinkButton ``` ### 데이터 타입 @@ -209,7 +209,7 @@ type CuratedTrack = { | 액션 | 동작 | | --- | --- | -| Row 탭 | 해당 곡 재생 | +| Row 탭 | 현재 선택 곡으로 지정하고 외부 음악 링크 열기 | | 더보기 탭 | BottomSheet 메뉴 열기 | | 길게 누르기 | 좋아요/저장 quick action, 후순위 | @@ -305,8 +305,7 @@ type PlaylistCurationRouteParams = { usePlaylistCurationQuery useTrackLikeMutation useTrackSaveMutation -usePlayEventMutation -useSkipEventMutation +syncRecommendationEvent ``` ### 7.3 클라이언트 상태 @@ -340,22 +339,25 @@ type PlaylistCurationResponse = { }; ``` -### 8.2 곡 재생 이벤트 +### 8.2 외부 음악 링크 열기 이벤트 ```txt -POST /v1/events/play +POST /v1/recommendation-events ``` ```ts -type PlayEventRequest = { - playlistId: string; - trackId: string; - placeId?: string; - context: { - source: 'playlist_curation'; - mood?: string; - mode?: string; - }; +type RecommendationEventsRequest = { + events: Array<{ + type: 'track_external_open'; + playlistId?: string; + trackId: string; + value?: 'youtubeMusic' | 'melon' | 'none'; + context: { + source?: 'playlist_curation'; + mood?: string; + mode?: string; + }; + }>; }; ``` diff --git a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md index 639b525..e20f9b5 100644 --- a/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md +++ b/docs/frontend/RN_FRONTEND_PLANNING_POINTS.md @@ -12,11 +12,11 @@ Soundlog는 위치, 음악, 카메라, 여행 로그, Recap을 함께 다루는 ### 2.1 사용자가 여행 중 화면을 오래 보지 않아도 되는 앱 -Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔드는 많은 정보를 보여주는 것보다, 사용자가 최소 조작으로 음악을 재생하고 순간을 저장할 수 있도록 설계해야 한다. +Soundlog의 핵심 UX는 Eyes-free 여행 몰입이다. 따라서 프론트엔드는 많은 정보를 보여주는 것보다, 사용자가 최소 조작으로 외부 음악 링크를 열고 순간을 저장할 수 있도록 설계해야 한다. 핵심 기준은 다음과 같다. -- 홈에서 현재 장소, 추천 플레이리스트, 재생 상태, 순간 저장 버튼이 즉시 보여야 한다. +- 홈에서 현재 장소, 추천 플레이리스트, 선택한 음악 링크 상태, 순간 저장 버튼이 즉시 보여야 한다. - 여행 중 주요 액션은 1~2탭 안에 끝나야 한다. - 음악 추천 결과가 마음에 들지 않을 때 검색 화면으로 보내지 않고 무드 조정 버튼으로 해결해야 한다. - 카메라 버튼은 항상 접근 가능한 중심 액션으로 유지한다. @@ -96,7 +96,7 @@ MVP 기준 탭은 4개로 단순화한다. | P-01 | 온보딩 | 서비스 가치 전달 | | P-02 | 취향 설문 | 음악/여행 취향 수집 | | P-03 | 권한/취향 | 위치, 카메라, 취향 설정 | -| P-04 | 홈 / 현재 여행 | 추천과 재생의 메인 허브 | +| P-04 | 홈 / 현재 여행 | 추천과 외부 음악 링크의 메인 허브 | | P-05 | 플레이리스트 추천 상세 | 추천 이유, 곡 목록, 무드 조정 | | P-06 | 미니/풀 링크 패널 | 현재 선택 곡과 외부 음악 링크 확인 | | P-07 | 순간 저장 카메라 | 사진 촬영 및 로그 생성 | @@ -407,13 +407,12 @@ Zustand 또는 Jotai로 관리할 데이터는 다음과 같다. ```txt GET /v1/places/nearby?lat=&lng=&radius= GET /v1/recommendations/playlists?lat=&lng=&mode=&moods= -POST /v1/events/play -POST /v1/events/skip -POST /v1/events/like +POST /v1/recommendation-events +POST /v1/tracks/:trackId/like +POST /v1/tracks/:trackId/save POST /v1/moment-logs GET /v1/recaps GET /v1/recaps/:id -POST /v1/music-platforms/connect ``` --- @@ -601,7 +600,7 @@ Soundlog는 음악 앱과 여행 앱의 중간 성격을 가진다. 너무 정 - 네트워크가 끊겨도 최근 추천을 볼 수 있는가? - 순간 저장 업로드 실패 시 로컬에 남는가? - 관광 이미지 로딩 실패 시 화면이 깨지지 않는가? -- 음악 플랫폼 토큰 만료 시 재연동 안내가 나오는가? +- 외부 음악 링크를 열지 못했을 때 다시 시도 안내가 나오는가? - 위치 조회 실패 시 수동 지역 선택이 가능한가? ### 15.3 모바일 UX diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index a3fbcf9..3c68fea 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -78,6 +78,33 @@ function verifyApiSourceFiles() { }); } +function verifyHonestMusicActionSourceFiles() { + const sourceDirs = [ + path.join(projectRoot, 'app'), + path.join(projectRoot, 'src'), + ].filter((dir) => fs.existsSync(dir)); + const blockedSourceMarkers = [ + ['NOW PLAYING', 'User-facing source must not imply in-app streaming playback.'], + ['track_play', 'Frontend source must record track_external_open, not fake play events.'], + ['track_pause', 'Frontend source must not include fake pause events.'], + ['track_resume', 'Frontend source must not include fake resume events.'], + ['track_skip', 'Frontend source must not include fake skip playback events.'], + ['spotify-auth', 'Frontend source must not include the removed Spotify auth route.'], + ['open.spotify.com', 'Frontend source must not include Spotify external search URLs.'], + ['playSelectedSpotifyOrFallback', 'Frontend source must not include Spotify playback helpers.'], + ]; + + sourceDirs + .flatMap((dir) => readTextFiles(dir)) + .forEach(({ filePath, text }) => { + blockedSourceMarkers.forEach(([marker, message]) => { + if (text.includes(marker)) { + addError(`${message}: ${path.relative(projectRoot, filePath)}`); + } + }); + }); +} + function runExport() { const result = spawnSync( process.platform === 'win32' ? 'npx.cmd' : 'npx', @@ -164,6 +191,7 @@ function verifyBundle(bundleText) { try { verifyApiSourceFiles(); + verifyHonestMusicActionSourceFiles(); runExport(); if (errors.length === 0) { diff --git a/src/components/travel/TravelReportModal.tsx b/src/components/travel/TravelReportModal.tsx index 8ca200c..2c42982 100644 --- a/src/components/travel/TravelReportModal.tsx +++ b/src/components/travel/TravelReportModal.tsx @@ -195,22 +195,6 @@ function SmallCaps({ children }: { children: React.ReactNode }) { ); } -function PlayerBar({ title }: { title: string }) { - return ( - - - - NOW PLAYING - - {title} - - - - - - ); -} - export function TravelReportModal({ item, onClose, visible }: TravelReportModalProps) { const insets = useSafeAreaInsets(); const [pageIndex, setPageIndex] = useState(0); diff --git a/src/mock-server/README.md b/src/mock-server/README.md index 5747558..0eb1055 100644 --- a/src/mock-server/README.md +++ b/src/mock-server/README.md @@ -1,10 +1,12 @@ # Soundlog Mock Server -실제 서버가 아직 없는 기능을 PoC로 테스트하기 위한 앱 내부 mock server입니다. +실제 서버가 없던 초기 PoC 단계에서 사용하던 앱 내부 mock server입니다. + +현재 앱 API facade(`src/api`)는 서버 API만 호출합니다. 이 디렉터리는 레거시 PoC/개발 참고용으로 남아 있으며, 프로덕션 웹 export와 앱 API runtime에 포함되면 안 됩니다. `npm run check:server-web-export`는 `src/api`가 이 디렉터리나 `src/mocks`를 import하면 실패합니다. ## 구조 -- `index.ts`: 앱 API 레이어가 가져다 쓰는 `mockServer` 진입점 +- `index.ts`: 레거시 PoC용 `mockServer` 진입점 - `types.ts`: mock endpoint id, request params, 반환 계약 - `delay.ts`: 공통 지연/실패 시뮬레이션 - `homeHandlers.ts`: 홈 추천, 무드 추천, Music Log @@ -13,7 +15,7 @@ - `tourHandlers.ts`: TourAPI 실패 또는 미설정 시 주변 관광지 fallback - `authHandlers.ts`: 로그인, 토큰 갱신, 로그아웃, 로컬 데이터 이관 mock -## 실패 상태 테스트 +## 레거시 실패 상태 테스트 ```bash EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=playlist.detail npm run web @@ -31,7 +33,7 @@ EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=home.featuredPlaylists,recap.share npm run w EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS=* npm run web ``` -## 로딩 상태 테스트 +## 레거시 로딩 상태 테스트 ```bash EXPO_PUBLIC_MOCK_API_DELAY_MS=1500 npm run web From 0f5fd0ee100b475a6ce52d131cd1a9e35f262c77 Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 16:09:54 +0900 Subject: [PATCH 18/27] =?UTF-8?q?test:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=B9=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/codex/NON_DEVELOPER_CODEX_GUIDE.md | 14 +- package.json | 1 + scripts/check-deployed-web.js | 229 ++++++++++++++++++++++++ 3 files changed, 243 insertions(+), 1 deletion(-) create mode 100644 scripts/check-deployed-web.js diff --git a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md index d69bcdb..2bbc01a 100644 --- a/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md +++ b/docs/codex/NON_DEVELOPER_CODEX_GUIDE.md @@ -67,7 +67,19 @@ npm run check:server-web-export 이 검사는 `src/api`가 `src/mock-server`나 `src/mocks`를 import하거나, 프로덕션 웹 export에 mock handler/seed 데이터가 포함되면 실패합니다. -API 실패 상태를 보고 싶다면 실제 dev 서버나 Vercel preview의 `/api/soundlog/v1/...` 요청을 기준으로 재현합니다. +배포된 URL에서 실제 API proxy와 번들을 함께 확인: + +```bash +npm run check:deployed-web -- https://sound-log-app.vercel.app +``` + +Vercel preview가 Deployment Protection으로 보호되어 있으면 `/api/soundlog/v1/...` 요청이 JSON이 아니라 Vercel SSO로 리다이렉트됩니다. 이 경우 Vercel 프로젝트의 Protection Bypass for Automation secret을 받아 아래처럼 실행합니다. + +```bash +SOUNDLOG_VERCEL_PROTECTION_BYPASS=... npm run check:deployed-web -- https://preview-url.vercel.app +``` + +API 실패 상태를 보고 싶다면 실제 dev 서버, 보호가 해제된 Vercel preview, 또는 bypass secret을 적용한 preview의 `/api/soundlog/v1/...` 요청을 기준으로 재현합니다. ## 검증을 요청할 때 diff --git a/package.json b/package.json index 86a4e8a..c74e1f0 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "build:production": "eas build --profile production --platform all", "typecheck": "tsc --noEmit", "doctor": "npx expo-doctor", + "check:deployed-web": "node scripts/check-deployed-web.js", "check:server-web-export": "node scripts/check-server-web-export.js", "check:store-release": "node scripts/check-store-release.js", "check": "npm run typecheck && npm run doctor", diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js new file mode 100644 index 0000000..f10ebd8 --- /dev/null +++ b/scripts/check-deployed-web.js @@ -0,0 +1,229 @@ +#!/usr/bin/env node + +const errors = []; + +const baseUrl = (process.argv[2] || process.env.SOUNDLOG_WEB_BASE_URL || '').replace(/\/+$/, ''); +const bypassSecret = + process.env.SOUNDLOG_VERCEL_PROTECTION_BYPASS || process.env.VERCEL_PROTECTION_BYPASS; + +if (!baseUrl) { + console.error( + 'Usage: npm run check:deployed-web -- https://your-soundlog-deployment.vercel.app', + ); + process.exit(1); +} + +function addError(message) { + errors.push(message); +} + +function withBase(path) { + return `${baseUrl}${path.startsWith('/') ? path : `/${path}`}`; +} + +function getHeaders() { + return bypassSecret ? { 'x-vercel-protection-bypass': bypassSecret } : {}; +} + +function getLocation(response) { + return response.headers.get('location') ?? ''; +} + +function isVercelProtectionRedirect(response) { + return response.status >= 300 && response.status < 400 && getLocation(response).includes('/sso-api'); +} + +function protectionMessage(url) { + return [ + `${url} is behind Vercel Deployment Protection.`, + 'Disable Vercel Authentication for the preview environment or run this check with', + 'SOUNDLOG_VERCEL_PROTECTION_BYPASS set to the project bypass secret.', + ].join(' '); +} + +async function fetchText(url) { + const response = await fetch(url, { + headers: getHeaders(), + redirect: 'manual', + }); + + if (isVercelProtectionRedirect(response)) { + throw new Error(protectionMessage(url)); + } + + if (response.status >= 300 && response.status < 400) { + throw new Error(`${url} redirected to ${getLocation(response) || '(missing location)'}.`); + } + + return { + contentType: response.headers.get('content-type') ?? '', + response, + text: await response.text(), + }; +} + +async function fetchJson(path) { + const url = withBase(path); + const { contentType, response, text } = await fetchText(url); + + if (!response.ok) { + throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 180)}`); + } + + if (!contentType.includes('application/json') && !text.trim().startsWith('{')) { + throw new Error( + `${url} did not return JSON. content-type=${contentType}; sample=${text + .slice(0, 180) + .replace(/\s+/g, ' ')}`, + ); + } + + return JSON.parse(text); +} + +async function verifyApiProxy() { + const endpoints = [ + '/api/soundlog/v1/health', + '/api/soundlog/v1/home/featured-playlists?limit=3&locationRecommendationEnabled=false&recommendationMode=everyday', + '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + '/api/soundlog/v1/playlists/geoje-ocean', + ]; + + for (const endpoint of endpoints) { + try { + const payload = await fetchJson(endpoint); + const data = payload.data; + + if (endpoint.includes('/health')) { + if (data?.status !== 'ok') { + addError(`${endpoint} returned unexpected health status: ${JSON.stringify(data)}`); + } + } else if (endpoint.includes('/home/')) { + if (!Array.isArray(data)) { + addError(`${endpoint} did not return an array data payload.`); + } + } else if (endpoint.includes('/playlists/')) { + if (!data?.id || !Array.isArray(data.tracks)) { + addError(`${endpoint} did not return a playlist with tracks.`); + } + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + } +} + +function getScriptUrls(html) { + return [...html.matchAll(/]+src=["']([^"']+\.js)["']/g)] + .map((match) => new URL(match[1], `${baseUrl}/`).href) + .filter((url) => url.includes('/_expo/static/js/')); +} + +async function verifyBundle() { + let html; + + try { + const result = await fetchText(withBase('/')); + html = result.text; + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + return; + } + + const scriptUrls = getScriptUrls(html); + + if (scriptUrls.length === 0) { + addError( + `Deployment root does not look like the Expo web app. This may be a Vercel protection page. sample=${html + .slice(0, 180) + .replace(/\s+/g, ' ')}`, + ); + return; + } + + const bundleParts = []; + + for (const scriptUrl of scriptUrls) { + try { + const { response, text } = await fetchText(scriptUrl); + + if (!response.ok) { + addError(`${scriptUrl} returned HTTP ${response.status}.`); + } else { + bundleParts.push(text); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + } + + const bundleText = bundleParts.join('\n'); + const requiredMarkers = [ + ['/api/soundlog', 'Deployed bundle must call the Vercel API proxy.'], + [ + 'shouldUseServerApi=function(){return!0}', + 'Deployed bundle must compile shouldUseServerApi as always true.', + ], + ]; + const blockedMarkers = [ + ['authMockHandlers', 'Deployed bundle must not include auth mock handlers.'], + ['homeMockHandlers', 'Deployed bundle must not include home mock handlers.'], + ['playlistMockHandlers', 'Deployed bundle must not include playlist mock handlers.'], + ['recapMockHandlers', 'Deployed bundle must not include recap mock handlers.'], + ['tourMockHandlers', 'Deployed bundle must not include tour mock handlers.'], + ['mockServerDelay', 'Deployed bundle must not include mock server delay helpers.'], + ['mock-user-email', 'Deployed bundle must not include seeded mock auth data.'], + ['playlistCurationById', 'Deployed bundle must not include seeded mock playlist maps.'], + ['mock-namsan', 'Deployed bundle must not include seeded mock nearby places.'], + ['mock-gwangalli', 'Deployed bundle must not include seeded mock nearby places.'], + [ + 'EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS', + 'Deployed bundle must not include mock API failure controls.', + ], + [ + 'EXPO_PUBLIC_MOCK_API_DELAY_MS', + 'Deployed bundle must not include mock API delay controls.', + ], + ['NOW PLAYING', 'Deployed bundle must not imply in-app streaming playback.'], + ['track_play', 'Deployed bundle must not include fake play events.'], + ['track_pause', 'Deployed bundle must not include fake pause events.'], + ['track_resume', 'Deployed bundle must not include fake resume events.'], + ['track_skip', 'Deployed bundle must not include fake skip playback events.'], + ['spotify-auth', 'Deployed bundle must not include the removed Spotify auth route.'], + ['open.spotify.com', 'Deployed bundle must not include Spotify external search URLs.'], + [ + 'playSelectedSpotifyOrFallback', + 'Deployed bundle must not include removed Spotify playback helpers.', + ], + ]; + + requiredMarkers.forEach(([marker, message]) => { + if (!bundleText.includes(marker)) { + addError(message); + } + }); + + blockedMarkers.forEach(([marker, message]) => { + if (bundleText.includes(marker)) { + addError(message); + } + }); +} + +async function main() { + await verifyApiProxy(); + await verifyBundle(); + + if (errors.length > 0) { + console.error('Deployed web check failed:'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); + } + + console.log(`Deployed web check passed: ${baseUrl}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); From b9eae9aec9ac93b416dddbf9fe481ea1ebda374a Mon Sep 17 00:00:00 2001 From: manNomi Date: Fri, 3 Jul 2026 17:22:32 +0900 Subject: [PATCH 19/27] =?UTF-8?q?fix:=20=EC=9E=A5=EC=86=8C=20seed=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/my.tsx | 2 +- ...26-07-01-server-api-integration-audit-plan.md | 2 +- scripts/check-deployed-web.js | 4 ++-- scripts/check-server-web-export.js | 4 ++-- src/components/dev/DevTestManager.tsx | 4 ++-- src/mocks/tourMocks.ts | 16 ++++++++-------- src/types/domain.ts | 2 +- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/(tabs)/my.tsx b/app/(tabs)/my.tsx index 18bd97a..d82b6d7 100644 --- a/app/(tabs)/my.tsx +++ b/app/(tabs)/my.tsx @@ -196,7 +196,7 @@ export default function MyScreen() { place={currentPlace} placeCount={nearbyPlacesQuery.data?.length ?? 0} placeInfoMessage={ - currentPlace?.source === 'mock' + currentPlace?.source === 'seed' ? '주변 장소 정보를 기본 장소 데이터로 보여주고 있어요.' : undefined } diff --git a/docs/implementation/2026-07-01-server-api-integration-audit-plan.md b/docs/implementation/2026-07-01-server-api-integration-audit-plan.md index de0c8b5..d96c167 100644 --- a/docs/implementation/2026-07-01-server-api-integration-audit-plan.md +++ b/docs/implementation/2026-07-01-server-api-integration-audit-plan.md @@ -66,7 +66,7 @@ Make the Soundlog frontend clearly use the deployed server when configured for s - Backed up the server `.env`, changed both values to `false`, ran `pnpm db:deploy`, and restarted `soundlog-api.service`. - Public health now returns `{"database":"ok"}` instead of `{"mode":"mock-db"}`. - Fake provider-token login is now rejected, so dev/preview builds must not enable `EXPO_PUBLIC_ENABLE_DEV_AUTH_FALLBACK`. -- Nearby places can still contain `source: "mock"` because the real DB seed data uses that source value when Tour API returns no items. That is Postgres seed/fallback data, not the app's in-memory mock server. +- Nearby places should expose `source: "seed"` when Tour API returns no items. That is Postgres seed/fallback data, not the app's in-memory mock server. ## 2026-07-02 Vercel web audit notes diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index f10ebd8..51539a1 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -174,8 +174,8 @@ async function verifyBundle() { ['mockServerDelay', 'Deployed bundle must not include mock server delay helpers.'], ['mock-user-email', 'Deployed bundle must not include seeded mock auth data.'], ['playlistCurationById', 'Deployed bundle must not include seeded mock playlist maps.'], - ['mock-namsan', 'Deployed bundle must not include seeded mock nearby places.'], - ['mock-gwangalli', 'Deployed bundle must not include seeded mock nearby places.'], + ['seed-namsan', 'Deployed bundle must not include seeded nearby places.'], + ['seed-gwangalli', 'Deployed bundle must not include seeded nearby places.'], [ 'EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS', 'Deployed bundle must not include mock API failure controls.', diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index 3c68fea..12dee0c 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -154,8 +154,8 @@ function verifyBundle(bundleText) { ['dev-refresh-', 'Server web export must not include development refresh tokens.'], ['Soundlog 테스트 유저', 'Server web export must not include development test users.'], ['playlistCurationById', 'Server web export must not include seeded mock playlist maps.'], - ['mock-namsan', 'Server web export must not include seeded mock nearby places.'], - ['mock-gwangalli', 'Server web export must not include seeded mock nearby places.'], + ['seed-namsan', 'Server web export must not include seeded nearby places.'], + ['seed-gwangalli', 'Server web export must not include seeded nearby places.'], [ 'EXPO_PUBLIC_MOCK_API_FAIL_ENDPOINTS', 'Server web export must not include mock API failure controls.', diff --git a/src/components/dev/DevTestManager.tsx b/src/components/dev/DevTestManager.tsx index 6303baa..199ff59 100644 --- a/src/components/dev/DevTestManager.tsx +++ b/src/components/dev/DevTestManager.tsx @@ -45,7 +45,7 @@ const placePresets: Array<{ imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', location: { lat: 35.1532, lng: 129.1186 }, overview: '바다와 야경, 산책 맥락을 테스트하는 개발용 장소입니다.', - source: 'mock', + source: 'seed', title: '광안리 해수욕장', }, }, @@ -59,7 +59,7 @@ const placePresets: Array<{ imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', location: { lat: 37.5512, lng: 126.9882 }, overview: '도시 야경과 감성 음악 추천을 테스트하는 개발용 장소입니다.', - source: 'mock', + source: 'seed', title: '남산서울타워', }, }, diff --git a/src/mocks/tourMocks.ts b/src/mocks/tourMocks.ts index 1910ff0..697afca 100644 --- a/src/mocks/tourMocks.ts +++ b/src/mocks/tourMocks.ts @@ -6,11 +6,11 @@ const seoulPlaces: PlaceContext[] = [ category: '야경 명소', contentType: '관광지', distanceMeters: 620, - id: 'mock-namsan', + id: 'seed-namsan', imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/96/4033396_image2_1.jpg', location: { lat: 37.5512, lng: 126.9882 }, overview: '서울의 야경과 도심 산책을 함께 즐길 수 있는 대표 관광지입니다.', - source: 'mock', + source: 'seed', title: '남산서울타워', }, { @@ -18,11 +18,11 @@ const seoulPlaces: PlaceContext[] = [ category: '문화시설', contentType: '관광지', distanceMeters: 940, - id: 'mock-gwanghwamun', + id: 'seed-gwanghwamun', imageUrl: 'https://tong.visitkorea.or.kr/cms/resource_photo/97/4033397_image2_1.jpg', location: { lat: 37.5759, lng: 126.9768 }, overview: '도시 산책과 역사 관광 맥락을 함께 제공하는 서울 중심 관광지입니다.', - source: 'mock', + source: 'seed', title: '광화문광장', }, ]; @@ -33,11 +33,11 @@ const busanPlaces: PlaceContext[] = [ category: '해변', contentType: '관광지', distanceMeters: 360, - id: 'mock-gwangalli', + id: 'seed-gwangalli', imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/76/2012176.jpg', location: { lat: 35.1532, lng: 129.1186 }, overview: '바다 산책과 야경을 함께 즐길 수 있는 부산 대표 해변입니다.', - source: 'mock', + source: 'seed', title: '광안리해수욕장', }, { @@ -45,11 +45,11 @@ const busanPlaces: PlaceContext[] = [ category: '바다', contentType: '관광지', distanceMeters: 1280, - id: 'mock-haeundae', + id: 'seed-haeundae', imageUrl: 'https://tong.visitkorea.or.kr/cms2/website/75/2012175.jpg', location: { lat: 35.1587, lng: 129.1604 }, overview: '해변과 드라이브 맥락에 어울리는 부산 대표 관광지입니다.', - source: 'mock', + source: 'seed', title: '해운대해수욕장', }, ]; diff --git a/src/types/domain.ts b/src/types/domain.ts index 93afd24..027c55f 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -12,7 +12,7 @@ export type PlaceContext = { imageUrl?: string; location?: GeoPoint; overview?: string; - source: 'mock' | 'tour-api'; + source: 'seed' | 'tour-api'; title: string; }; From f6fd0c29446b69687f839c8d789d14dab4b40a41 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 01:19:10 +0900 Subject: [PATCH 20/27] =?UTF-8?q?fix:=20soundlog.shop=20API=20=ED=94=84?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +++--- docs/deployment/SOUNDLOG_SHOP_DOMAIN.md | 36 +++++++------------------ eas.json | 12 ++++----- 3 files changed, 19 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 7c95620..18d7ace 100644 --- a/README.md +++ b/README.md @@ -39,18 +39,18 @@ EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/so ``` Vercel web 배포는 브라우저 API 호출을 같은 origin에서 처리하도록 -`/api/soundlog` rewrite proxy를 사용합니다. 현재 rewrite 대상은 EC2 API `http://52.79.185.121:4000`입니다. `api.soundlog.shop` DNS/HTTPS가 준비되면 rewrite 대상만 `https://api.soundlog.shop`으로 전환합니다. `vercel.json`의 build command가 +`https://soundlog.shop/api/soundlog` rewrite proxy를 사용합니다. 현재 rewrite 대상은 EC2 API `http://52.79.185.121:4000`입니다. `vercel.json`의 build command가 `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`와 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. -네이티브 실배포 빌드는 `https://sound-log-app.vercel.app/api/soundlog` HTTPS 프록시를 사용합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. +네이티브 실배포 빌드는 `https://soundlog.shop/api/soundlog` HTTPS 프록시를 사용합니다. 로그인은 Soundlog 자체 이메일/비밀번호 계정으로 처리합니다. ## 테스트 설치 빌드 `development`, `preview` EAS profile은 HTTPS Vercel API proxy를 바라보도록 설정되어 있습니다. - Web: `https://soundlog.shop` -- API: `https://sound-log-app.vercel.app/api/soundlog` +- API: `https://soundlog.shop/api/soundlog` - API source: `server` - auth: Soundlog 자체 이메일/비밀번호 로그인 - iOS/Android: HTTPS API만 사용 @@ -63,7 +63,7 @@ Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성 npx eas build --profile preview --platform android ``` -iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 현재는 `https://sound-log-app.vercel.app/api/soundlog`을 사용합니다. +iOS는 TestFlight 또는 ad hoc 기기 등록이 필요합니다. App Store/TestFlight에 올릴 production profile도 현재는 `https://soundlog.shop/api/soundlog`을 사용합니다. ## 문서 diff --git a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md index 2bc007c..559688e 100644 --- a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md +++ b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md @@ -6,7 +6,7 @@ Soundlog 운영/테스트 도메인은 아래 구조로 사용합니다. | --- | --- | --- | | `soundlog.shop` | Vercel | Expo web frontend | | `www.soundlog.shop` | Vercel | Frontend alias | -| `api.soundlog.shop` | EC2 reverse proxy | Express API, planned after DNS/HTTPS setup | +| `soundlog.shop/api/soundlog` | Vercel rewrite | Express API proxy | ## Gabia DNS @@ -16,9 +16,8 @@ Gabia DNS 관리툴에서 아래 레코드를 설정합니다. | --- | --- | --- | | `A` | `@` | `76.76.21.21` | | `CNAME` | `www` | `cname.vercel-dns.com.` | -| `A` | `api` | EC2 Elastic IP | -DNS에는 port를 넣을 수 없습니다. API 컨테이너는 EC2 내부 `4000` 포트로 두고, `api.soundlog.shop`의 HTTPS 요청은 EC2 reverse proxy가 `127.0.0.1:4000`으로 전달합니다. +별도 `api` 서브도메인은 사용하지 않습니다. API 컨테이너는 EC2 `4000` 포트에서 동작하고, Vercel이 `https://soundlog.shop/api/soundlog/:path*` 요청을 EC2 API로 rewrite합니다. ## Vercel @@ -27,37 +26,23 @@ Vercel project domain에 아래 도메인을 추가합니다. - `soundlog.shop` - `www.soundlog.shop` -Web build는 `vercel.json`에서 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. 현재 `api.soundlog.shop` DNS/HTTPS가 준비되기 전까지 `/api/soundlog/:path*` 요청은 Vercel이 서버 사이드에서 EC2 API `http://52.79.185.121:4000/:path*`로 rewrite합니다. - -`api.soundlog.shop` DNS, reverse proxy, HTTPS 인증서가 모두 준비되면 rewrite destination을 `https://api.soundlog.shop/:path*`로 전환합니다. - -## EC2 HTTPS reverse proxy - -EC2 보안 그룹은 외부에서 `80`, `443`, SSH 포트만 열고, API `4000` 포트는 직접 공개하지 않는 구성을 권장합니다. - -Caddy를 사용할 경우 예시 설정은 아래와 같습니다. - -```caddyfile -api.soundlog.shop { - reverse_proxy 127.0.0.1:4000 -} -``` +Web build는 `vercel.json`에서 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. `/api/soundlog/:path*` 요청은 Vercel이 서버 사이드에서 EC2 API `http://52.79.185.121:4000/:path*`로 rewrite합니다. ## EAS app env -`development`, `preview`, `production` profile은 현재 HTTPS Vercel proxy를 사용합니다. +`development`, `preview`, `production` profile은 `soundlog.shop` HTTPS Vercel proxy를 사용합니다. ```dotenv EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server -EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://sound-log-app.vercel.app/api/soundlog +EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=https://soundlog.shop/api/soundlog ``` -Production profile은 추가로 약관/개인정보 URL과 지원 메일을 설정합니다. `soundlog.shop` legal route가 배포/검증되면 아래 값을 custom domain 기준으로 전환합니다. +Production profile은 추가로 약관/개인정보 URL과 지원 메일을 설정합니다. ```dotenv -EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL=https://sound-log-app.vercel.app/legal/privacy -EXPO_PUBLIC_SOUNDLOG_TERMS_URL=https://sound-log-app.vercel.app/legal/terms -EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL=support@soundlog.app +EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL=https://soundlog.shop/legal/privacy +EXPO_PUBLIC_SOUNDLOG_TERMS_URL=https://soundlog.shop/legal/terms +EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL=support@soundlog.shop ``` ## Verification @@ -67,9 +52,6 @@ DNS와 HTTPS 설정이 끝나면 아래 명령으로 확인합니다. ```bash dig +short soundlog.shop A dig +short www.soundlog.shop CNAME -dig +short api.soundlog.shop A curl -I https://soundlog.shop curl https://soundlog.shop/api/soundlog/v1/health -curl https://sound-log-app.vercel.app/api/soundlog/v1/health -curl https://api.soundlog.shop/v1/health ``` diff --git a/eas.json b/eas.json index f46b2d2..52657f3 100644 --- a/eas.json +++ b/eas.json @@ -8,25 +8,25 @@ "developmentClient": true, "distribution": "internal", "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://sound-log-app.vercel.app/api/soundlog", + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server" } }, "preview": { "distribution": "internal", "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://sound-log-app.vercel.app/api/soundlog", + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server" } }, "production": { "autoIncrement": true, "env": { - "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://sound-log-app.vercel.app/api/soundlog", + "EXPO_PUBLIC_SOUNDLOG_API_BASE_URL": "https://soundlog.shop/api/soundlog", "EXPO_PUBLIC_SOUNDLOG_API_SOURCE": "server", - "EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL": "https://sound-log-app.vercel.app/legal/privacy", - "EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL": "support@soundlog.app", - "EXPO_PUBLIC_SOUNDLOG_TERMS_URL": "https://sound-log-app.vercel.app/legal/terms" + "EXPO_PUBLIC_SOUNDLOG_PRIVACY_URL": "https://soundlog.shop/legal/privacy", + "EXPO_PUBLIC_SOUNDLOG_SUPPORT_EMAIL": "support@soundlog.shop", + "EXPO_PUBLIC_SOUNDLOG_TERMS_URL": "https://soundlog.shop/legal/terms" } } }, From 3fa20c2c27977a27376132634c6822b058262c5e Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 01:33:26 +0900 Subject: [PATCH 21/27] =?UTF-8?q?fix:=20=ED=99=88=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=A7=84=EC=9E=85=EC=A0=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(tabs)/index.tsx | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx index 165fb1b..fe4b248 100644 --- a/app/(tabs)/index.tsx +++ b/app/(tabs)/index.tsx @@ -22,6 +22,7 @@ import { HomeTopFilterBar, isHomeTopFilter, } from '@/components/home/HomeHeader'; +import { LocationContextCard } from '@/components/home/LocationContextCard'; import { MoodRecommendationSection, isMoodRecommendationFilter, @@ -133,6 +134,15 @@ function HomeContent() { ...momentLogs.slice(0, 6).map(momentLogToMusicLogItem), ...(recentMusicLogsQuery.data ?? []), ].slice(0, 10); + const placeInfoMessage = + profile.locationRecommendationEnabled && + currentLocation && + !nearbyPlacesQuery.isFetching && + (nearbyPlacesQuery.data?.length ?? 0) === 0 + ? '주변 관광지 결과가 없어도 기본 추천은 계속 사용할 수 있어요.' + : nearbyPlacesQuery.isError + ? '주변 관광지를 불러오지 못했지만 기본 추천은 계속 사용할 수 있어요.' + : undefined; useEffect(() => { if (!isMoodRecommendationFilter(selectedMoodFilter)) { @@ -384,6 +394,20 @@ function HomeContent() { recommendationMode={recommendationMode} /> + + {recommendationMode === 'travel' ? ( Date: Sun, 5 Jul 2026 01:54:26 +0900 Subject: [PATCH 22/27] =?UTF-8?q?fix:=20API=20rewrite=20origin=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-check.yml | 3 + README.md | 9 +- docs/deployment/SOUNDLOG_SHOP_DOMAIN.md | 17 ++- package.json | 2 + scripts/check-api-origin.js | 140 ++++++++++++++++++++++++ scripts/check-deployed-web.js | 68 ++++++++++++ scripts/check-server-web-export.js | 4 +- scripts/check-vercel-config.js | 74 +++++++++++++ vercel.json | 14 --- vercel.mjs | 33 ++++++ 10 files changed, 346 insertions(+), 18 deletions(-) create mode 100644 scripts/check-api-origin.js create mode 100644 scripts/check-vercel-config.js delete mode 100644 vercel.json create mode 100644 vercel.mjs diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index aabc535..ddbc744 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -29,5 +29,8 @@ jobs: - name: Check server web export run: npm run check:server-web-export + - name: Check Vercel config + run: npm run check:vercel-config + - name: Check store release config run: npm run check:store-release diff --git a/README.md b/README.md index 18d7ace..41ac1e4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/so ``` Vercel web 배포는 브라우저 API 호출을 같은 origin에서 처리하도록 -`https://soundlog.shop/api/soundlog` rewrite proxy를 사용합니다. 현재 rewrite 대상은 EC2 API `http://52.79.185.121:4000`입니다. `vercel.json`의 build command가 +`https://soundlog.shop/api/soundlog` rewrite proxy를 사용합니다. 브라우저와 네이티브 앱은 별도 `api` 서브도메인을 쓰지 않습니다. Vercel 내부 rewrite 대상은 +`SOUNDLOG_API_ORIGIN` 환경변수로 관리하며, 이 값은 최신 SoundLogServer origin이어야 합니다. `vercel.mjs`의 build command가 `EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server`와 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. @@ -57,6 +58,12 @@ Vercel web 배포는 브라우저 API 호출을 같은 origin에서 처리하도 Mock API로 되돌리는 런타임 경로는 제거했습니다. 화면 상태 테스트가 필요하면 서버 fixture 또는 `src/mock-server` 레거시 자료를 별도 개발 도구에서만 사용합니다. +API origin이 최신 서버인지 확인하려면 아래처럼 실행합니다. + +```bash +SOUNDLOG_API_ORIGIN=http://:4000 npm run check:api-origin +``` + Android 지인 테스트용 내부 배포 빌드는 아래 명령으로 생성합니다. ```bash diff --git a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md index 559688e..4504ff8 100644 --- a/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md +++ b/docs/deployment/SOUNDLOG_SHOP_DOMAIN.md @@ -26,7 +26,15 @@ Vercel project domain에 아래 도메인을 추가합니다. - `soundlog.shop` - `www.soundlog.shop` -Web build는 `vercel.json`에서 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. `/api/soundlog/:path*` 요청은 Vercel이 서버 사이드에서 EC2 API `http://52.79.185.121:4000/:path*`로 rewrite합니다. +Web build는 `vercel.mjs`에서 `EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog`를 주입합니다. `/api/soundlog/:path*` 요청은 Vercel이 서버 사이드에서 `SOUNDLOG_API_ORIGIN`으로 rewrite합니다. + +Vercel 환경변수에 아래 값을 설정합니다. + +```dotenv +SOUNDLOG_API_ORIGIN=http://:4000 +``` + +브라우저와 앱이 호출하는 공개 API URL은 계속 `https://soundlog.shop/api/soundlog`입니다. `SOUNDLOG_API_ORIGIN`은 Vercel 서버 사이드 rewrite에서만 쓰이는 내부 origin입니다. ## EAS app env @@ -54,4 +62,11 @@ dig +short soundlog.shop A dig +short www.soundlog.shop CNAME curl -I https://soundlog.shop curl https://soundlog.shop/api/soundlog/v1/health +npm run check:deployed-web -- https://soundlog.shop +``` + +EC2 origin을 직접 검증하려면 아래 명령을 실행합니다. 이 검사는 `/openapi.yaml`, fallback 장소 source, Spotify 메타데이터 제거 여부까지 확인하므로 예전 백엔드로 잘못 붙은 경우 실패합니다. + +```bash +SOUNDLOG_API_ORIGIN=http://:4000 npm run check:api-origin ``` diff --git a/package.json b/package.json index c74e1f0..fba84d2 100644 --- a/package.json +++ b/package.json @@ -56,9 +56,11 @@ "build:production": "eas build --profile production --platform all", "typecheck": "tsc --noEmit", "doctor": "npx expo-doctor", + "check:api-origin": "node scripts/check-api-origin.js", "check:deployed-web": "node scripts/check-deployed-web.js", "check:server-web-export": "node scripts/check-server-web-export.js", "check:store-release": "node scripts/check-store-release.js", + "check:vercel-config": "node scripts/check-vercel-config.js", "check": "npm run typecheck && npm run doctor", "prepare": "sh scripts/install-git-hooks.sh" }, diff --git a/scripts/check-api-origin.js b/scripts/check-api-origin.js new file mode 100644 index 0000000..39d5547 --- /dev/null +++ b/scripts/check-api-origin.js @@ -0,0 +1,140 @@ +#!/usr/bin/env node + +const errors = []; +const apiOrigin = (process.argv[2] || process.env.SOUNDLOG_API_ORIGIN || '').replace(/\/+$/, ''); + +if (!apiOrigin) { + console.error('Usage: npm run check:api-origin -- http://:4000'); + process.exit(1); +} + +function addError(message) { + errors.push(message); +} + +function withOrigin(path) { + return `${apiOrigin}${path.startsWith('/') ? path : `/${path}`}`; +} + +async function fetchText(path) { + const url = withOrigin(path); + const response = await fetch(url, { redirect: 'manual' }); + const text = await response.text(); + + return { response, text, url }; +} + +async function fetchJson(path) { + const { response, text, url } = await fetchText(path); + + if (!response.ok) { + throw new Error(`${url} returned HTTP ${response.status}: ${text.slice(0, 180)}`); + } + + return JSON.parse(text); +} + +async function verifyHealth() { + try { + const payload = await fetchJson('/v1/health'); + + if (payload?.data?.status !== 'ok') { + addError(`/v1/health returned unexpected payload: ${JSON.stringify(payload)}`); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function verifyOpenApi() { + try { + const { response, text, url } = await fetchText('/openapi.yaml'); + + if (!response.ok) { + addError(`${url} returned HTTP ${response.status}.`); + } + + if (!text.includes('openapi: 3.1.0') || !text.includes('/v1/auth/register')) { + addError('/openapi.yaml does not look like the current SoundLogServer contract.'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function verifyNearbyPlaces() { + try { + const payload = await fetchJson( + '/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', + ); + const place = payload?.data?.[0]; + + if (!place) { + addError('/v1/tour/nearby-places returned an empty payload.'); + return; + } + + if (typeof place.id === 'string' && place.id.startsWith('mock-')) { + addError('/v1/tour/nearby-places returned a legacy mock-* place id.'); + } + + if (place.source === 'mock') { + addError('/v1/tour/nearby-places returned legacy source="mock".'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function verifyMusicMetadata() { + try { + const payload = await fetchJson( + '/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + ); + const serialized = JSON.stringify(payload?.data ?? {}); + + if (serialized.includes('open.spotify.com') || /"spotify"\s*:/.test(serialized)) { + addError('/v1/home/mood-recommendations still exposes Spotify metadata.'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function verifyLegacyMusicPlatformRouteRemoved() { + try { + const { response, text, url } = await fetchText('/v1/me/music-platform'); + + if (response.status !== 404) { + addError( + `${url} returned HTTP ${response.status}; expected 404 for removed music platform API. sample=${text.slice( + 0, + 120, + )}`, + ); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + +async function main() { + await verifyHealth(); + await verifyOpenApi(); + await verifyNearbyPlaces(); + await verifyMusicMetadata(); + await verifyLegacyMusicPlatformRouteRemoved(); + + if (errors.length > 0) { + console.error('SoundLog API origin check failed:'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); + } + + console.log(`SoundLog API origin check passed: ${apiOrigin}`); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index 51539a1..03d4ae6 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -113,6 +113,73 @@ async function verifyApiProxy() { } } +async function verifyServerContract() { + try { + const url = withBase('/api/soundlog/openapi.yaml'); + const { response, text } = await fetchText(url); + + if (!response.ok) { + addError(`${url} returned HTTP ${response.status}.`); + } + + if (!text.includes('openapi: 3.1.0') || !text.includes('/v1/auth/register')) { + addError('/api/soundlog/openapi.yaml does not look like the current SoundLogServer contract.'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + + try { + const payload = await fetchJson( + '/api/soundlog/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', + ); + const place = payload?.data?.[0]; + + if (!place) { + addError('/api/soundlog/v1/tour/nearby-places returned an empty payload.'); + } else { + if (typeof place.id === 'string' && place.id.startsWith('mock-')) { + addError('/api/soundlog/v1/tour/nearby-places returned a legacy mock-* place id.'); + } + + if (place.source === 'mock') { + addError('/api/soundlog/v1/tour/nearby-places returned legacy source="mock".'); + } + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + + try { + const payload = await fetchJson( + '/api/soundlog/v1/home/mood-recommendations?limit=3&moodFilter=%EC%A0%84%EC%B2%B4&recommendationMode=everyday&topFilter=%EC%A0%84%EC%B2%B4', + ); + const serialized = JSON.stringify(payload?.data ?? {}); + + if (serialized.includes('open.spotify.com') || /"spotify"\s*:/.test(serialized)) { + addError('/api/soundlog/v1/home/mood-recommendations still exposes Spotify metadata.'); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } + + try { + const url = withBase('/api/soundlog/v1/me/music-platform'); + const { response, text } = await fetchText(url); + + if (response.status !== 404) { + addError( + `${url} returned HTTP ${response.status}; expected 404 for removed music platform API. sample=${text.slice( + 0, + 120, + )}`, + ); + } + } catch (error) { + addError(error instanceof Error ? error.message : String(error)); + } +} + function getScriptUrls(html) { return [...html.matchAll(/]+src=["']([^"']+\.js)["']/g)] .map((match) => new URL(match[1], `${baseUrl}/`).href) @@ -212,6 +279,7 @@ async function verifyBundle() { async function main() { await verifyApiProxy(); + await verifyServerContract(); await verifyBundle(); if (errors.length > 0) { diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index 12dee0c..fcfa49e 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -138,8 +138,8 @@ function verifyBundle(bundleText) { ); assertExcludes( bundleText, - 'http://52.79.185.121:4000', - 'Server web export must not inline the direct EC2 HTTP API URL.', + /http:\/\/\d+\.\d+\.\d+\.\d+:4000/, + 'Server web export must not inline a direct EC2 HTTP API URL.', ); [ ['authMockHandlers', 'Server web export must not include auth mock handlers.'], diff --git a/scripts/check-vercel-config.js b/scripts/check-vercel-config.js new file mode 100644 index 0000000..0e23892 --- /dev/null +++ b/scripts/check-vercel-config.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const { pathToFileURL } = require('url'); + +const projectRoot = path.resolve(__dirname, '..'); +const errors = []; + +function addError(message) { + errors.push(message); +} + +function readIfExists(filePath) { + return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''; +} + +async function main() { + const vercelJsonPath = path.join(projectRoot, 'vercel.json'); + const vercelConfigPath = path.join(projectRoot, 'vercel.mjs'); + + if (fs.existsSync(vercelJsonPath)) { + addError('Use only vercel.mjs. vercel.json must not exist alongside it.'); + } + + const configText = readIfExists(vercelConfigPath); + + if (!configText) { + addError('Missing vercel.mjs.'); + } + + [ + '52.79.185.121', + '54.226.62.131', + 'api.soundlog.shop', + ].forEach((marker) => { + if (configText.includes(marker)) { + addError(`vercel.mjs must not hard-code stale API origin ${marker}.`); + } + }); + + process.env.SOUNDLOG_API_ORIGIN = 'http://soundlog-api-origin.test:4000'; + + const imported = await import(`${pathToFileURL(vercelConfigPath).href}?check=${Date.now()}`); + const config = imported.config; + const apiRewrite = config?.rewrites?.find( + (rewrite) => rewrite.source === '/api/soundlog/:path*', + ); + + if (config?.buildCommand?.includes('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog') !== true) { + addError('Vercel build must inline EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog.'); + } + + if (config?.outputDirectory !== 'dist') { + addError('Vercel outputDirectory must be dist.'); + } + + if (apiRewrite?.destination !== 'http://soundlog-api-origin.test:4000/:path*') { + addError('Vercel API rewrite must use SOUNDLOG_API_ORIGIN as its destination.'); + } + + if (errors.length > 0) { + console.error('Vercel config check failed:'); + errors.forEach((error) => console.error(`- ${error}`)); + process.exit(1); + } + + console.log('Vercel config check passed.'); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); diff --git a/vercel.json b/vercel.json deleted file mode 100644 index b62895d..0000000 --- a/vercel.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "buildCommand": "EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npx expo export --platform web", - "outputDirectory": "dist", - "rewrites": [ - { - "source": "/api/soundlog/:path*", - "destination": "http://52.79.185.121:4000/:path*" - }, - { - "source": "/:path*", - "destination": "/index.html" - } - ] -} diff --git a/vercel.mjs b/vercel.mjs new file mode 100644 index 0000000..16cbe16 --- /dev/null +++ b/vercel.mjs @@ -0,0 +1,33 @@ +function getRequiredApiOrigin() { + const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); + + if (!apiOrigin) { + throw new Error( + 'SOUNDLOG_API_ORIGIN is required. Set it to the SoundLogServer origin, for example http://:4000.', + ); + } + + if (!/^https?:\/\//.test(apiOrigin)) { + throw new Error('SOUNDLOG_API_ORIGIN must start with http:// or https://.'); + } + + return apiOrigin; +} + +const apiOrigin = getRequiredApiOrigin(); + +export const config = { + buildCommand: + 'EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npx expo export --platform web', + outputDirectory: 'dist', + rewrites: [ + { + source: '/api/soundlog/:path*', + destination: `${apiOrigin}/:path*`, + }, + { + source: '/:path*', + destination: '/index.html', + }, + ], +}; From 1707db3f600ee22df49d9d2467aba6eae4da2972 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 01:58:15 +0900 Subject: [PATCH 23/27] =?UTF-8?q?fix:=20Vercel=20API=20origin=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B0=A9=EC=8B=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check-vercel-config.js | 23 ++++++++++++++++++----- scripts/require-vercel-api-origin.js | 27 +++++++++++++++++++++++++++ vercel.mjs | 18 +++++++----------- 3 files changed, 52 insertions(+), 16 deletions(-) create mode 100644 scripts/require-vercel-api-origin.js diff --git a/scripts/check-vercel-config.js b/scripts/check-vercel-config.js index 0e23892..d3b9407 100644 --- a/scripts/check-vercel-config.js +++ b/scripts/check-vercel-config.js @@ -29,16 +29,29 @@ async function main() { addError('Missing vercel.mjs.'); } - [ - '52.79.185.121', - '54.226.62.131', - 'api.soundlog.shop', - ].forEach((marker) => { + ['52.79.185.121', '54.226.62.131', 'api.soundlog.shop'].forEach((marker) => { if (configText.includes(marker)) { addError(`vercel.mjs must not hard-code stale API origin ${marker}.`); } }); + delete process.env.SOUNDLOG_API_ORIGIN; + + const missingEnvImport = await import( + `${pathToFileURL(vercelConfigPath).href}?check=missing-${Date.now()}` + ); + const missingEnvRewrite = missingEnvImport.config?.rewrites?.find( + (rewrite) => rewrite.source === '/api/soundlog/:path*', + ); + + if (missingEnvRewrite?.destination !== 'https://soundlog-api-origin-missing.invalid/:path*') { + addError('Vercel config must keep a schema-valid placeholder when SOUNDLOG_API_ORIGIN is missing.'); + } + + if (!missingEnvImport.config?.buildCommand?.startsWith('node scripts/require-vercel-api-origin.js && ')) { + addError('Vercel build must validate SOUNDLOG_API_ORIGIN before exporting web assets.'); + } + process.env.SOUNDLOG_API_ORIGIN = 'http://soundlog-api-origin.test:4000'; const imported = await import(`${pathToFileURL(vercelConfigPath).href}?check=${Date.now()}`); diff --git a/scripts/require-vercel-api-origin.js b/scripts/require-vercel-api-origin.js new file mode 100644 index 0000000..686a79a --- /dev/null +++ b/scripts/require-vercel-api-origin.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); +const staleOrigins = new Set([ + 'http://52.79.185.121:4000', + 'http://54.226.62.131:4000', + 'https://api.soundlog.shop', +]); + +if (!apiOrigin) { + console.error( + 'SOUNDLOG_API_ORIGIN is required for Vercel builds. Set it to the current SoundLogServer origin, for example http://:4000.', + ); + process.exit(1); +} + +if (!/^https?:\/\//.test(apiOrigin)) { + console.error('SOUNDLOG_API_ORIGIN must start with http:// or https://.'); + process.exit(1); +} + +if (staleOrigins.has(apiOrigin)) { + console.error( + `${apiOrigin} is a stale SoundLog API origin. Set SOUNDLOG_API_ORIGIN to the current SoundLogServer origin.`, + ); + process.exit(1); +} diff --git a/vercel.mjs b/vercel.mjs index 16cbe16..8793adb 100644 --- a/vercel.mjs +++ b/vercel.mjs @@ -1,24 +1,20 @@ -function getRequiredApiOrigin() { - const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); +const missingApiOrigin = 'https://soundlog-api-origin-missing.invalid'; - if (!apiOrigin) { - throw new Error( - 'SOUNDLOG_API_ORIGIN is required. Set it to the SoundLogServer origin, for example http://:4000.', - ); - } +function getApiOriginForConfig() { + const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); - if (!/^https?:\/\//.test(apiOrigin)) { - throw new Error('SOUNDLOG_API_ORIGIN must start with http:// or https://.'); + if (!apiOrigin || !/^https?:\/\//.test(apiOrigin)) { + return missingApiOrigin; } return apiOrigin; } -const apiOrigin = getRequiredApiOrigin(); +const apiOrigin = getApiOriginForConfig(); export const config = { buildCommand: - 'EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npx expo export --platform web', + 'node scripts/require-vercel-api-origin.js && EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npx expo export --platform web', outputDirectory: 'dist', rewrites: [ { From 4b62e8c99423962850373fe3c698da31a5de674e Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 02:01:18 +0900 Subject: [PATCH 24/27] =?UTF-8?q?fix:=20Vercel=20rewrite=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=98=95=EC=8B=9D=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 124 +++++++++++++++++++++++++++++++++ package.json | 1 + scripts/check-vercel-config.js | 8 +-- vercel.mjs | 12 ++-- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0320baa..4ad32dc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "@vercel/config": "^0.5.5", "babel-plugin-module-resolver": "^5.0.3", "babel-preset-expo": "^56.0.12", "prettier-plugin-tailwindcss": "^0.5.14", @@ -3355,6 +3356,35 @@ "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", "license": "ISC" }, + "node_modules/@vercel/config": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@vercel/config/-/config-0.5.5.tgz", + "integrity": "sha512-U0QX7p08vgk8D47HI74wYyRuDJ2IYHbFQyfVdwO81Xchjp5CsV58/xhkV4EAXXj4NuLuELBwdPDa6oOdCYvEhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vercel/routing-utils": "6.4.0", + "pretty-cache-header": "^1.0.0", + "zod": "^3.22.0" + }, + "bin": { + "config": "dist/cli.js" + } + }, + "node_modules/@vercel/routing-utils": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/@vercel/routing-utils/-/routing-utils-6.4.0.tgz", + "integrity": "sha512-SnL/449ftNIDt1cB/oDqFmdZWywckNA6Q/S3WuzzsxBdE9DxmNn498qMKRtgx0W6EW3Mm/wVmg7aNJrfElMBTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "path-to-regexp": "6.1.0", + "path-to-regexp-updated": "npm:path-to-regexp@6.3.0" + }, + "optionalDependencies": { + "ajv": "^6.12.3" + } + }, "node_modules/@xmldom/xmldom": { "version": "0.8.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", @@ -3410,6 +3440,24 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/anser": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", @@ -5191,6 +5239,14 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5914,6 +5970,14 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -7244,6 +7308,21 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.1.0.tgz", + "integrity": "sha512-h9DqehX3zZZDCEm+xbfU0ZmwCGFCAAraPJWMXJ4+v32NjZJilVg3k1TcKsRgIb8IQ/izZSaydDc1OhJCZvs2Dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp-updated": { + "name": "path-to-regexp", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7573,6 +7652,19 @@ } } }, + "node_modules/pretty-cache-header": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/pretty-cache-header/-/pretty-cache-header-1.0.0.tgz", + "integrity": "sha512-xtXazslu25CdnGnUkByU1RoOjK55TqwatJkjjJLg5ZAdz2Lngko/mmaUgeET36P2GMlNwh3fdM7FWBO717pNcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "timestring": "^6.0.0" + }, + "engines": { + "node": ">=12.13" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -7639,6 +7731,17 @@ "node": ">= 6" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/query-string": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", @@ -9110,6 +9213,16 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/timestring": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/timestring/-/timestring-6.0.0.tgz", + "integrity": "sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -9340,6 +9453,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", diff --git a/package.json b/package.json index fba84d2..3fe0569 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ }, "devDependencies": { "@types/react": "~19.2.2", + "@vercel/config": "^0.5.5", "babel-plugin-module-resolver": "^5.0.3", "babel-preset-expo": "^56.0.12", "prettier-plugin-tailwindcss": "^0.5.14", diff --git a/scripts/check-vercel-config.js b/scripts/check-vercel-config.js index d3b9407..8535398 100644 --- a/scripts/check-vercel-config.js +++ b/scripts/check-vercel-config.js @@ -41,10 +41,10 @@ async function main() { `${pathToFileURL(vercelConfigPath).href}?check=missing-${Date.now()}` ); const missingEnvRewrite = missingEnvImport.config?.rewrites?.find( - (rewrite) => rewrite.source === '/api/soundlog/:path*', + (rewrite) => rewrite.source === '/api/soundlog/(.*)', ); - if (missingEnvRewrite?.destination !== 'https://soundlog-api-origin-missing.invalid/:path*') { + if (missingEnvRewrite?.destination !== 'https://soundlog-api-origin-missing.invalid/$1') { addError('Vercel config must keep a schema-valid placeholder when SOUNDLOG_API_ORIGIN is missing.'); } @@ -57,7 +57,7 @@ async function main() { const imported = await import(`${pathToFileURL(vercelConfigPath).href}?check=${Date.now()}`); const config = imported.config; const apiRewrite = config?.rewrites?.find( - (rewrite) => rewrite.source === '/api/soundlog/:path*', + (rewrite) => rewrite.source === '/api/soundlog/(.*)', ); if (config?.buildCommand?.includes('EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog') !== true) { @@ -68,7 +68,7 @@ async function main() { addError('Vercel outputDirectory must be dist.'); } - if (apiRewrite?.destination !== 'http://soundlog-api-origin.test:4000/:path*') { + if (apiRewrite?.destination !== 'http://soundlog-api-origin.test:4000/$1') { addError('Vercel API rewrite must use SOUNDLOG_API_ORIGIN as its destination.'); } diff --git a/vercel.mjs b/vercel.mjs index 8793adb..0002fdd 100644 --- a/vercel.mjs +++ b/vercel.mjs @@ -1,3 +1,5 @@ +import { routes } from '@vercel/config/v1'; + const missingApiOrigin = 'https://soundlog-api-origin-missing.invalid'; function getApiOriginForConfig() { @@ -17,13 +19,7 @@ export const config = { 'node scripts/require-vercel-api-origin.js && EXPO_PUBLIC_SOUNDLOG_API_SOURCE=server EXPO_PUBLIC_SOUNDLOG_API_BASE_URL=/api/soundlog npx expo export --platform web', outputDirectory: 'dist', rewrites: [ - { - source: '/api/soundlog/:path*', - destination: `${apiOrigin}/:path*`, - }, - { - source: '/:path*', - destination: '/index.html', - }, + routes.rewrite('/api/soundlog/(.*)', `${apiOrigin}/$1`), + routes.rewrite('/(.*)', '/index.html'), ], }; From facbb49b16900f63abf2027f2abe41a28ef1157c Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 02:19:15 +0900 Subject: [PATCH 25/27] =?UTF-8?q?fix:=20=EC=9D=8C=EC=95=85=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20UX=EC=99=80=20mock=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check-api-origin.js | 4 ++ scripts/check-deployed-web.js | 4 ++ scripts/check-server-web-export.js | 57 ++++++++++++++++++ src/components/MiniPlayer.tsx | 59 +++++-------------- .../playlist/PlaylistCurationScreen.tsx | 12 ++-- src/components/playlist/PlaylistHeroInfo.tsx | 10 +++- src/mock-server/types.ts | 17 +----- src/store/devToolsStore.ts | 9 +-- src/types/domain.ts | 1 - 9 files changed, 101 insertions(+), 72 deletions(-) diff --git a/scripts/check-api-origin.js b/scripts/check-api-origin.js index 39d5547..8c31bff 100644 --- a/scripts/check-api-origin.js +++ b/scripts/check-api-origin.js @@ -96,6 +96,10 @@ async function verifyMusicMetadata() { if (serialized.includes('open.spotify.com') || /"spotify"\s*:/.test(serialized)) { addError('/v1/home/mood-recommendations still exposes Spotify metadata.'); } + + if (/"previewUrl"\s*:/.test(serialized)) { + addError('/v1/home/mood-recommendations still exposes streaming preview URLs.'); + } } catch (error) { addError(error instanceof Error ? error.message : String(error)); } diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index 03d4ae6..15a26dd 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -159,6 +159,10 @@ async function verifyServerContract() { if (serialized.includes('open.spotify.com') || /"spotify"\s*:/.test(serialized)) { addError('/api/soundlog/v1/home/mood-recommendations still exposes Spotify metadata.'); } + + if (/"previewUrl"\s*:/.test(serialized)) { + addError('/api/soundlog/v1/home/mood-recommendations still exposes streaming preview URLs.'); + } } catch (error) { addError(error instanceof Error ? error.message : String(error)); } diff --git a/scripts/check-server-web-export.js b/scripts/check-server-web-export.js index fcfa49e..d3b09fa 100644 --- a/scripts/check-server-web-export.js +++ b/scripts/check-server-web-export.js @@ -105,6 +105,61 @@ function verifyHonestMusicActionSourceFiles() { }); } +function isAllowedMockReferencePath(filePath) { + const relativePath = path.relative(projectRoot, filePath).split(path.sep).join('/'); + + return ( + relativePath.startsWith('src/mock-server/') || + relativePath.startsWith('src/mocks/') || + relativePath.startsWith('src/components/dev/') + ); +} + +function verifyRuntimeSourceDoesNotImportMocks() { + const sourceDirs = [ + path.join(projectRoot, 'app'), + path.join(projectRoot, 'src'), + ].filter((dir) => fs.existsSync(dir)); + const blockedRuntimeImports = [ + '@/mock-server', + '@/mocks', + ]; + + sourceDirs + .flatMap((dir) => readTextFiles(dir)) + .filter(({ filePath }) => !isAllowedMockReferencePath(filePath)) + .forEach(({ filePath, text }) => { + blockedRuntimeImports.forEach((blockedImport) => { + if (text.includes(blockedImport)) { + addError( + `Runtime source must not import ${blockedImport}: ${path.relative(projectRoot, filePath)}`, + ); + } + }); + }); +} + +function verifyHomeScreenUsesServerQueries() { + const homeRoutePath = path.join(projectRoot, 'app/(tabs)/index.tsx'); + const text = fs.readFileSync(homeRoutePath, 'utf8'); + + [ + ['useFeaturedPlaylistsQuery', 'Home screen must request featured playlists through the API query.'], + ['useMoodRecommendationsQuery', 'Home screen must request mood recommendations through the API query.'], + ['useNearbyPlacesQuery', 'Home screen must request nearby places through the API query.'], + ].forEach(([marker, message]) => { + if (!text.includes(marker)) { + addError(message); + } + }); + + ['@/mock-server', '@/mocks', 'playlistCurationById'].forEach((marker) => { + if (text.includes(marker)) { + addError(`Home screen must not use mock data marker: ${marker}`); + } + }); +} + function runExport() { const result = spawnSync( process.platform === 'win32' ? 'npx.cmd' : 'npx', @@ -192,6 +247,8 @@ function verifyBundle(bundleText) { try { verifyApiSourceFiles(); verifyHonestMusicActionSourceFiles(); + verifyRuntimeSourceDoesNotImportMocks(); + verifyHomeScreenUsesServerQueries(); runExport(); if (errors.length === 0) { diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 4d8210c..71eed69 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -50,21 +50,6 @@ export function MiniPlayer() { const playerSoftGlow = hexToRgba(keyColor, 0.24); const canSkip = queue.length > 1; const externalLink = getTrackExternalLink(currentTrack); - const getAdjacentTrack = (direction: 'next' | 'previous') => { - if (!currentTrack || queue.length < 2) { - return undefined; - } - - const currentIndex = queue.findIndex((track) => track.id === currentTrack.id); - - if (direction === 'next') { - const nextIndex = currentIndex >= 0 ? (currentIndex + 1) % queue.length : 0; - return queue[nextIndex]; - } - - const previousIndex = currentIndex > 0 ? currentIndex - 1 : Math.max(queue.length - 1, 0); - return queue[previousIndex]; - }; const handleToggleLike = () => { const context = createRecommendationEventContext(); @@ -141,33 +126,21 @@ export function MiniPlayer() { }), ); }; - const handlePlayNext = async () => { + const handleSelectNextTrack = () => { if (!canSkip) { return; } - const nextTrack = getAdjacentTrack('next'); - - if (!nextTrack) { - return; - } - + setExternalMessage(undefined); playNext(); - await handleOpenTrackExternal(nextTrack); }; - const handlePlayPrevious = async () => { + const handleSelectPreviousTrack = () => { if (!canSkip) { return; } - const previousTrack = getAdjacentTrack('previous'); - - if (!previousTrack) { - return; - } - + setExternalMessage(undefined); playPrevious(); - await handleOpenTrackExternal(previousTrack); }; const renderCover = (sizeClassName: string, radiusClassName: string) => ( - + - + @@ -400,14 +373,14 @@ export function MiniPlayer() { - + - + diff --git a/src/components/playlist/PlaylistCurationScreen.tsx b/src/components/playlist/PlaylistCurationScreen.tsx index 8b6086e..f620e68 100644 --- a/src/components/playlist/PlaylistCurationScreen.tsx +++ b/src/components/playlist/PlaylistCurationScreen.tsx @@ -83,7 +83,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro const listBottomPadding = getCurationListBottomPadding(insets.bottom, hasMiniPlayer); const usesPlainMoodPage = playlistId === 'calm-walk' || Boolean(playlist?.accentColor); - const playTrack = async (track: Track) => { + const openTrackExternal = async (track: Track) => { if (!playlist) { return; } @@ -109,14 +109,14 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro } }; - const playFirstTrack = () => { + const openFirstTrackExternal = () => { const firstTrack = playlist?.tracks[0]; if (!firstTrack) { return; } - playTrack(firstTrack); + openTrackExternal(firstTrack); }; const toggleLiked = () => { @@ -193,7 +193,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro currentTrackId={currentTrack?.id} likedTrackIds={likedTrackIds} onOpenMenu={(track) => setSelectedTrackId(track.id)} - onSelectTrack={playTrack} + onSelectTrack={openTrackExternal} savedTrackIds={savedTrackIds} tracks={playlist.tracks} /> @@ -213,7 +213,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro {playlist ? ( ) : null} @@ -225,7 +225,7 @@ export function PlaylistCurationScreen({ playlistId }: PlaylistCurationScreenPro playlist ? ( ) : undefined diff --git a/src/components/playlist/PlaylistHeroInfo.tsx b/src/components/playlist/PlaylistHeroInfo.tsx index 8e22106..3796bf4 100644 --- a/src/components/playlist/PlaylistHeroInfo.tsx +++ b/src/components/playlist/PlaylistHeroInfo.tsx @@ -7,11 +7,15 @@ import { PlaylistCuration } from '@/types/domain'; type PlaylistHeroInfoProps = { disabled?: boolean; - onPlay: () => void; + onOpenFirstTrack: () => void; playlist: PlaylistCuration; }; -export function PlaylistHeroInfo({ disabled = false, onPlay, playlist }: PlaylistHeroInfoProps) { +export function PlaylistHeroInfo({ + disabled = false, + onOpenFirstTrack, + playlist, +}: PlaylistHeroInfoProps) { return ( @@ -42,7 +46,7 @@ export function PlaylistHeroInfo({ disabled = false, onPlay, playlist }: Playlis accessibilityRole="button" className="h-[54px] w-[54px] items-center justify-center rounded-full bg-[#20146F]" disabled={disabled} - onPress={onPlay} + onPress={onOpenFirstTrack} style={{ opacity: disabled ? 0.45 : 1 }} > diff --git a/src/mock-server/types.ts b/src/mock-server/types.ts index 7d8729a..d15aea6 100644 --- a/src/mock-server/types.ts +++ b/src/mock-server/types.ts @@ -18,22 +18,9 @@ import { RecapShare, RecapTemplateId, } from '@/types/domain'; +import type { MockEndpointId } from '@/store/devToolsStore'; -export type MockEndpointId = - | 'auth.login' - | 'auth.logout' - | 'auth.me' - | 'auth.migrateLocalData' - | 'auth.refresh' - | 'auth.register' - | 'home.featuredPlaylists' - | 'home.moodRecommendations' - | 'home.recentMusicLogs' - | 'playlist.detail' - | 'recap.create' - | 'recap.list' - | 'recap.share' - | 'tour.nearbyPlaces'; +export type { MockEndpointId } from '@/store/devToolsStore'; export type MockDelayOptions = { delayMs?: number; diff --git a/src/store/devToolsStore.ts b/src/store/devToolsStore.ts index dfc07bc..4782d97 100644 --- a/src/store/devToolsStore.ts +++ b/src/store/devToolsStore.ts @@ -1,8 +1,6 @@ import { create } from 'zustand'; -import type { MockEndpointId } from '@/mock-server/types'; - -export const mockEndpointIds: MockEndpointId[] = [ +export const mockEndpointIds = [ 'auth.login', 'auth.register', 'auth.refresh', @@ -13,10 +11,13 @@ export const mockEndpointIds: MockEndpointId[] = [ 'home.moodRecommendations', 'home.recentMusicLogs', 'playlist.detail', + 'recap.create', 'recap.list', 'recap.share', 'tour.nearbyPlaces', -]; +] as const; + +export type MockEndpointId = (typeof mockEndpointIds)[number]; type DevToolsState = { failedEndpointIds: MockEndpointId[]; diff --git a/src/types/domain.ts b/src/types/domain.ts index 027c55f..0629188 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -32,7 +32,6 @@ export type Track = { artist: string; fallbackColor?: string; albumImageUrl?: string; - previewUrl?: string; externalUrl?: string; platformUrls?: Partial>; isLiked?: boolean; From 8aeb6ab644eaa808a98cccde7faad154e8babfb1 Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 03:01:57 +0900 Subject: [PATCH 26/27] =?UTF-8?q?test:=20=EB=B0=B0=ED=8F=AC=20=EC=9B=B9=20?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=20=EA=B2=80=EC=A6=9D=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/check-deployed-web.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/scripts/check-deployed-web.js b/scripts/check-deployed-web.js index 15a26dd..0145cb7 100644 --- a/scripts/check-deployed-web.js +++ b/scripts/check-deployed-web.js @@ -133,17 +133,20 @@ async function verifyServerContract() { const payload = await fetchJson( '/api/soundlog/v1/tour/nearby-places?lat=35.1595&lng=129.1604&radiusMeters=2000&limit=1', ); - const place = payload?.data?.[0]; - if (!place) { - addError('/api/soundlog/v1/tour/nearby-places returned an empty payload.'); + if (!Array.isArray(payload?.data)) { + addError('/api/soundlog/v1/tour/nearby-places returned a non-array data payload.'); } else { - if (typeof place.id === 'string' && place.id.startsWith('mock-')) { - addError('/api/soundlog/v1/tour/nearby-places returned a legacy mock-* place id.'); - } + const place = payload.data[0]; + + if (place) { + if (typeof place.id === 'string' && place.id.startsWith('mock-')) { + addError('/api/soundlog/v1/tour/nearby-places returned a legacy mock-* place id.'); + } - if (place.source === 'mock') { - addError('/api/soundlog/v1/tour/nearby-places returned legacy source="mock".'); + if (place.source === 'mock') { + addError('/api/soundlog/v1/tour/nearby-places returned legacy source="mock".'); + } } } } catch (error) { From 43499a5b485ad1d91bff4005cfe110762ac5a6ae Mon Sep 17 00:00:00 2001 From: manNomi Date: Sun, 5 Jul 2026 03:06:28 +0900 Subject: [PATCH 27/27] =?UTF-8?q?ci:=20=ED=98=84=EC=9E=AC=20API=20origin?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/require-vercel-api-origin.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/require-vercel-api-origin.js b/scripts/require-vercel-api-origin.js index 686a79a..13b1024 100644 --- a/scripts/require-vercel-api-origin.js +++ b/scripts/require-vercel-api-origin.js @@ -3,7 +3,6 @@ const apiOrigin = process.env.SOUNDLOG_API_ORIGIN?.replace(/\/+$/, ''); const staleOrigins = new Set([ 'http://52.79.185.121:4000', - 'http://54.226.62.131:4000', 'https://api.soundlog.shop', ]);