diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json index 71369329e..7f112d445 100644 --- a/packages/api-server/openapi.json +++ b/packages/api-server/openapi.json @@ -10680,6 +10680,13 @@ "$ref": "#/components/schemas/PostUserInfo", "description": "사용자 정보 (간소화)" }, + "user_has_saved": { + "type": [ + "boolean", + "null" + ], + "description": "뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실.\n카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움\n(게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱." + }, "view_count": { "type": "integer", "format": "int32", @@ -11754,6 +11761,13 @@ "$ref": "#/components/schemas/PostUserInfo", "description": "사용자 정보 (간소화)" }, + "user_has_saved": { + "type": [ + "boolean", + "null" + ], + "description": "뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실.\n카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움\n(게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱." + }, "view_count": { "type": "integer", "format": "int32", diff --git a/packages/api-server/src/batch/home_curation.rs b/packages/api-server/src/batch/home_curation.rs index 2ef1bb34a..43436a7f2 100644 --- a/packages/api-server/src/batch/home_curation.rs +++ b/packages/api-server/src/batch/home_curation.rs @@ -616,6 +616,7 @@ mod tests { group_profile_image_url: None, style_tags: None, item_thumbnails: None, + user_has_saved: None, } } diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index f51374b2c..1d717158e 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -477,6 +477,12 @@ pub struct PostListItem { /// "+N" 오버플로우는 web 에서 spot_count 로 산출. #[serde(skip_serializing_if = "Option::is_none")] pub item_thumbnails: Option>, + + /// 뷰어가 이 룩을 저장(북마크)했는지. null=미인증/미계산(게스트), true/false=서버 진실. + /// 카드 저장 토글의 초기 상태 정합용(#840). list_posts 는 viewer_id 있을 때만 채움 + /// (게스트/admin 목록은 생략). PostDetailResponse.user_has_saved 와 동일 시맨틱. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_has_saved: Option, } /// Post에 포함된 사용자 정보 (간소화) diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 4075f07d3..fc3f83948 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -1795,6 +1795,16 @@ pub async fn list_posts( }; let (artist_display_map, group_display_map) = load_warehouse_display_maps(db, &posts).await?; + // 저장 상태 배치 조회(#840): viewer 가 있을 때만 1회 IN 쿼리로 조회(N+1 회피). + // 게스트(viewer_id None)는 쿼리 자체를 생략하고 user_has_saved 를 None 으로 둔다. + let saved_post_id_set = match query.viewer_id { + Some(viewer) => { + let ids: Vec = posts.iter().map(|p| p.id).collect(); + crate::domains::saved_posts::service::saved_post_ids_for_user(db, &ids, viewer).await? + } + None => std::collections::HashSet::new(), + }; + // PostListItem으로 변환 (배치 로딩 데이터 사용) let items: Vec = posts .into_iter() @@ -1854,6 +1864,9 @@ pub async fn list_posts( group_profile_image_url, style_tags: post.style_tags.clone(), item_thumbnails: item_thumb_map.get(&post.id).cloned(), + user_has_saved: query + .viewer_id + .map(|_| saved_post_id_set.contains(&post.id)), } }) .collect(); @@ -2168,6 +2181,8 @@ pub async fn admin_list_posts( group_profile_image_url, style_tags: post.style_tags.clone(), item_thumbnails: None, + // admin 목록은 저장 어피던스 없음 → 계산 생략(#840). + user_has_saved: None, } }) .collect(); @@ -5497,6 +5512,134 @@ mod tests { ); } + // ── #840 user_has_saved on list cards ── + + #[tokio::test] + async fn list_posts_user_has_saved_true_when_viewer_saved() { + use crate::tests::fixtures; + use sea_orm::{DatabaseBackend, MockDatabase}; + + // viewer_id 가 있으면 saved_posts 배치 조회가 발사되고, 저장한 카드는 + // user_has_saved == Some(true) 로 채워져야 한다(카드 초기 상태 정합). + let mut post = fixtures::post_model(); + post.post_magazine_id = Some(fixtures::test_uuid(80)); + + let magazine = crate::entities::post_magazines::Model { + id: fixtures::test_uuid(80), + title: "Mag Title".to_string(), + subtitle: None, + keyword: None, + layout_json: None, + status: "published".to_string(), + review_summary: None, + error_log: None, + created_at: fixtures::test_timestamp(), + updated_at: fixtures::test_timestamp(), + published_at: Some(fixtures::test_timestamp()), + }; + + let saved_row = crate::entities::saved_posts::Model { + id: fixtures::test_uuid(90), + post_id: post.id, + user_id: fixtures::test_uuid(10), + created_at: fixtures::test_timestamp(), + }; + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![fixtures::count_row(1)]]) // count + .append_query_results([vec![post]]) // posts + .append_query_results([vec![fixtures::user_model()]]) // users + .append_query_results([ + Vec::>::new(), + ]) // spot_counts + .append_query_results([ + Vec::>::new(), + ]) // comment_counts + .append_query_results([ + Vec::>::new(), + ]) // item_thumbnails + .append_query_results([vec![magazine]]) // magazine titles + .append_query_results([vec![saved_row]]) // saved_posts batch (#840) + .into_connection(); + let query = PostListQuery { + viewer_id: Some(fixtures::test_uuid(10)), + mood: None, + artist_name: None, + group_name: None, + context: None, + category: None, + user_id: None, + artist_id: None, + group_id: None, + sort: "recent".to_string(), + page: 1, + per_page: 20, + has_solutions: None, + has_magazine: None, + color: None, + season: None, + min_solutions: None, + complete_outfit: None, + include_magazine_items: None, + }; + let result = list_posts(&db, query).await; + assert!(result.is_ok(), "unexpected err: {:?}", result.err()); + let resp = result.unwrap(); + assert_eq!(resp.data.len(), 1); + assert_eq!(resp.data[0].user_has_saved, Some(true)); + } + + #[tokio::test] + async fn list_posts_user_has_saved_none_for_guest() { + use crate::tests::fixtures; + use sea_orm::{DatabaseBackend, MockDatabase}; + + // 게스트(viewer_id None)는 저장 배치 조회를 발사하지 않고 user_has_saved == None. + // magazine 없음 → magazine 쿼리 스킵, saved 쿼리 스킵 → 총 6 쿼리. + let post = fixtures::post_model(); + + let db = MockDatabase::new(DatabaseBackend::Postgres) + .append_query_results([vec![fixtures::count_row(1)]]) // count + .append_query_results([vec![post]]) // posts + .append_query_results([vec![fixtures::user_model()]]) // users + .append_query_results([ + Vec::>::new(), + ]) // spot_counts + .append_query_results([ + Vec::>::new(), + ]) // comment_counts + .append_query_results([ + Vec::>::new(), + ]) // item_thumbnails + .into_connection(); + let query = PostListQuery { + viewer_id: None, + mood: None, + artist_name: None, + group_name: None, + context: None, + category: None, + user_id: None, + artist_id: None, + group_id: None, + sort: "recent".to_string(), + page: 1, + per_page: 20, + has_solutions: None, + has_magazine: None, + color: None, + season: None, + min_solutions: None, + complete_outfit: None, + include_magazine_items: None, + }; + let result = list_posts(&db, query).await; + assert!(result.is_ok(), "unexpected err: {:?}", result.err()); + let resp = result.unwrap(); + assert_eq!(resp.data.len(), 1); + assert_eq!(resp.data[0].user_has_saved, None); + } + // ── list_tries populated ── #[tokio::test] diff --git a/packages/api-server/src/domains/saved_posts/service.rs b/packages/api-server/src/domains/saved_posts/service.rs index 98ab34826..0a35d47ed 100644 --- a/packages/api-server/src/domains/saved_posts/service.rs +++ b/packages/api-server/src/domains/saved_posts/service.rs @@ -73,3 +73,23 @@ pub async fn count_saves_by_post_id(db: &DatabaseConnection, post_id: Uuid) -> A Ok(count) } + +/// 주어진 post_ids 중 user 가 저장한 post_id 집합. 카드 목록의 저장상태 배치 조회용(#840). +/// 단건 `user_has_saved` 를 카드마다 호출하는 N+1 을 피하려 한 번의 IN 쿼리로 조회한다. +pub async fn saved_post_ids_for_user( + db: &DatabaseConnection, + post_ids: &[Uuid], + user_id: Uuid, +) -> AppResult> { + if post_ids.is_empty() { + return Ok(std::collections::HashSet::new()); + } + + let rows = saved_posts::Entity::find() + .filter(saved_posts::Column::UserId.eq(user_id)) + .filter(saved_posts::Column::PostId.is_in(post_ids.iter().copied())) + .all(db) + .await?; + + Ok(rows.into_iter().map(|r| r.post_id).collect()) +} diff --git a/packages/web/app/[locale]/(shell)/page.tsx b/packages/web/app/[locale]/(shell)/page.tsx index 4cacf9e92..2328df0d3 100644 --- a/packages/web/app/[locale]/(shell)/page.tsx +++ b/packages/web/app/[locale]/(shell)/page.tsx @@ -182,6 +182,9 @@ export default async function Home({ artistName: artistName || undefined, // List model carries no detected-item count yet — omit (no fake counts). match: getPostMatch(post), // null until #646 lands + // Saved-state when the list carries it (#840). null on the cached public + // feed (no viewer_id) → undefined → card falls back to unsaved. + userHasSaved: post.user_has_saved ?? undefined, }; } diff --git a/packages/web/lib/components/home/LookCards.tsx b/packages/web/lib/components/home/LookCards.tsx index 072bae9d7..458cbe195 100644 --- a/packages/web/lib/components/home/LookCards.tsx +++ b/packages/web/lib/components/home/LookCards.tsx @@ -43,7 +43,11 @@ export function RecommendedLookCard({ )} - + ); diff --git a/packages/web/lib/components/home/SaveLookButton.tsx b/packages/web/lib/components/home/SaveLookButton.tsx index 3a0938f2e..5251c09f9 100644 --- a/packages/web/lib/components/home/SaveLookButton.tsx +++ b/packages/web/lib/components/home/SaveLookButton.tsx @@ -8,19 +8,24 @@ import { cn } from "@/lib/utils"; /** * Card-overlay save button (top-right of look cards, per design). * - * The list model doesn't carry user_has_saved yet, so the initial state is - * unfilled and the fill is local-optimistic after a click. The underlying - * toggle is real (auth-gated via useSavedPost). + * `initialSaved` seeds the filled state from the list model's `user_has_saved` + * when present (authed/uncached lists — #840). On the cached public home feed + * that field is absent, so it falls back to unsaved and fills optimistically + * after a click; reflecting real saved-state on the public feed needs + * client-hydration (#840 follow-up). The underlying toggle is real (auth-gated + * via useSavedPost). */ export function SaveLookButton({ postId, + initialSaved = false, className, }: { postId: string; + initialSaved?: boolean; className?: string; }) { const { toggle, isPending } = useSavedPost(postId); - const [saved, setSaved] = useState(false); + const [saved, setSaved] = useState(initialSaved); return (