diff --git a/packages/api-server/src/domains/look_sets/dto.rs b/packages/api-server/src/domains/look_sets/dto.rs index d459c546..f60421e5 100644 --- a/packages/api-server/src/domains/look_sets/dto.rs +++ b/packages/api-server/src/domains/look_sets/dto.rs @@ -26,8 +26,11 @@ pub struct LookSetItemDto { #[derive(Debug, Clone, Deserialize, Validate, utoipa::ToSchema)] pub struct CreateLookSetDto { - pub post_id: Uuid, - /// mood | alternative | essential + /// 재현 대상 룩. Style Me 조합처럼 특정 룩 기반이 아니면 None(#4) — 그 경우 + /// (user, variant) 부분 유니크가 유저당 1개를 보장한다. + #[serde(default)] + pub post_id: Option, + /// mood | alternative | essential | style-me #[validate(length(min = 1, max = 32))] pub variant: String, #[validate(length(min = 1, max = 120))] @@ -41,7 +44,9 @@ pub struct CreateLookSetDto { #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct LookSetResponse { pub id: Uuid, - pub post_id: Uuid, + /// None = post 없는 저장(Style Me 조합, #4). 프론트는 이때 post 링크를 걸지 + /// 않고 아이템 조합 스트립(#6)만으로 카드를 그린다. + pub post_id: Option, pub variant: String, pub label: String, pub items: Vec, @@ -74,7 +79,7 @@ mod tests { let dt = chrono::DateTime::from_timestamp(1_700_000_000, 0).expect("valid ts"); let row = LookSetResponse { id: Uuid::nil(), - post_id: Uuid::nil(), + post_id: Some(Uuid::nil()), variant: "mood".to_string(), label: "Mood Match".to_string(), items: vec![LookSetItemDto { @@ -99,7 +104,7 @@ mod tests { #[test] fn create_look_set_dto_rejects_empty_items() { let dto = CreateLookSetDto { - post_id: Uuid::nil(), + post_id: Some(Uuid::nil()), variant: "mood".to_string(), label: "Mood Match".to_string(), items: vec![], @@ -112,7 +117,7 @@ mod tests { #[test] fn create_look_set_dto_accepts_valid_payload() { let dto = CreateLookSetDto { - post_id: Uuid::nil(), + post_id: Some(Uuid::nil()), variant: "mood".to_string(), label: "Mood Match".to_string(), items: vec![LookSetItemDto { diff --git a/packages/api-server/src/domains/look_sets/service.rs b/packages/api-server/src/domains/look_sets/service.rs index ee66c39e..c9af7dab 100644 --- a/packages/api-server/src/domains/look_sets/service.rs +++ b/packages/api-server/src/domains/look_sets/service.rs @@ -14,7 +14,8 @@ use super::dto::{CreateLookSetDto, LookSetItemDto}; /// 않는다(raf 피드백: Saved Looks 카드에 post 타이틀 노출 금지, #890 회귀 재발 방지). pub struct LookSetListRow { pub id: Uuid, - pub post_id: Uuid, + /// None = post 없는 저장(Style Me, #4). + pub post_id: Option, pub variant: String, pub label: String, pub items: sea_orm::JsonValue, @@ -112,19 +113,29 @@ pub async fn save_look_set( user_id: Uuid, dto: CreateLookSetDto, ) -> AppResult { - posts::Entity::find_by_id(dto.post_id) - .one(db) - .await? - .ok_or_else(|| AppError::not_found("Post를 찾을 수 없습니다"))?; + // post 기반 저장만 대상 룩 존재를 검증한다. Style Me 조합(post_id=None, #4)은 + // 참조할 룩이 없으므로 이 검증을 건너뛴다 — dedup 은 아래 (user, variant) + // is_null 조회 + 부분 유니크 인덱스가 담당한다. + if let Some(post_id) = dto.post_id { + posts::Entity::find_by_id(post_id) + .one(db) + .await? + .ok_or_else(|| AppError::not_found("Post를 찾을 수 없습니다"))?; + } let items_json = items_to_json(&dto.items)?; - let existing = saved_look_sets::Entity::find() - .filter(saved_look_sets::Column::PostId.eq(dto.post_id)) + // 재저장 = 갱신. post 있으면 (user, post, variant), post 없으면(Style Me) + // (user, variant) is_null 로 기존 행을 찾는다 — NULL 은 eq 로 매칭되지 않으므로 + // 분기해야 한다. + let mut existing_query = saved_look_sets::Entity::find() .filter(saved_look_sets::Column::UserId.eq(user_id)) - .filter(saved_look_sets::Column::Variant.eq(dto.variant.clone())) - .one(db) - .await?; + .filter(saved_look_sets::Column::Variant.eq(dto.variant.clone())); + existing_query = match dto.post_id { + Some(post_id) => existing_query.filter(saved_look_sets::Column::PostId.eq(post_id)), + None => existing_query.filter(saved_look_sets::Column::PostId.is_null()), + }; + let existing = existing_query.one(db).await?; if let Some(existing) = existing { let mut active: saved_look_sets::ActiveModel = existing.into(); @@ -193,11 +204,13 @@ pub async fn list_my_look_sets( let rows = db .query_all(Statement::from_sql_and_values( DatabaseBackend::Postgres, + // LEFT JOIN — post 없는 저장(Style Me, #4)도 목록에 나와야 한다. + // INNER JOIN 이면 post_id NULL 행이 통째로 빠진다. "SELECT sls.id, sls.post_id, sls.variant, sls.label, sls.items, \ sls.total_krw, sls.similarity, sls.created_at, \ p.image_url AS post_thumbnail_url, p.title AS post_title \ FROM public.saved_look_sets sls \ - JOIN public.posts p ON sls.post_id = p.id \ + LEFT JOIN public.posts p ON sls.post_id = p.id \ WHERE sls.user_id = $1 \ ORDER BY sls.created_at DESC \ LIMIT $2 OFFSET $3", @@ -215,7 +228,7 @@ pub async fn list_my_look_sets( .filter_map(|r| { Some(LookSetListRow { id: r.try_get::("", "id").ok()?, - post_id: r.try_get::("", "post_id").ok()?, + post_id: r.try_get::>("", "post_id").ok().flatten(), variant: r.try_get::("", "variant").ok()?, label: r.try_get::("", "label").ok()?, items: r.try_get::("", "items").ok()?, diff --git a/packages/api-server/src/domains/look_sets/tests.rs b/packages/api-server/src/domains/look_sets/tests.rs index 08a52d86..7aebc326 100644 --- a/packages/api-server/src/domains/look_sets/tests.rs +++ b/packages/api-server/src/domains/look_sets/tests.rs @@ -22,7 +22,7 @@ mod mock_db_tests { fn saved_look_set_model() -> crate::entities::saved_look_sets::Model { crate::entities::saved_look_sets::Model { id: fixtures::test_uuid(50), - post_id: fixtures::test_uuid(1), + post_id: Some(fixtures::test_uuid(1)), user_id: fixtures::test_uuid(10), variant: "mood".to_string(), label: "Mood Match".to_string(), @@ -39,7 +39,7 @@ mod mock_db_tests { .append_query_results([Vec::::new()]) .into_connection(); let dto = CreateLookSetDto { - post_id: fixtures::test_uuid(99), + post_id: Some(fixtures::test_uuid(99)), variant: "mood".to_string(), label: "Mood Match".to_string(), items: vec![item()], @@ -62,7 +62,7 @@ mod mock_db_tests { }]) .into_connection(); let dto = CreateLookSetDto { - post_id: fixtures::test_uuid(1), + post_id: Some(fixtures::test_uuid(1)), variant: "mood".to_string(), label: "Mood Match".to_string(), items: vec![item()], @@ -86,7 +86,7 @@ mod mock_db_tests { }]) .into_connection(); let dto = CreateLookSetDto { - post_id: fixtures::test_uuid(1), + post_id: Some(fixtures::test_uuid(1)), variant: "mood".to_string(), label: "Mood Match (updated)".to_string(), items: vec![item()], @@ -97,6 +97,37 @@ mod mock_db_tests { assert!(result.is_ok()); } + // Style Me 저장(#4): post_id=None 이면 post 존재 검증을 **건너뛴다**. 아래 + // MockDatabase 는 post 조회 결과를 하나도 append 하지 않으므로, 만약 서비스가 + // post 를 조회하면 mock 시퀀스가 어긋나 insert 결과를 못 받고 실패한다 — + // 이 테스트가 통과하면 post 조회가 실제로 스킵됐다는 뜻. + #[tokio::test] + async fn save_look_set_style_me_skips_post_check() { + let mut style_me = saved_look_set_model(); + style_me.post_id = None; + style_me.variant = "style-me".to_string(); + let db = MockDatabase::new(DatabaseBackend::Postgres) + // (post 조회 없음) → 기존행 조회(is_null) empty → insert 결과 + .append_query_results([Vec::::new()]) + .append_query_results([[style_me]]) + .append_exec_results([MockExecResult { + last_insert_id: 0, + rows_affected: 1, + }]) + .into_connection(); + let dto = CreateLookSetDto { + post_id: None, + variant: "style-me".to_string(), + label: "Style Me".to_string(), + items: vec![item()], + total_krw: None, + similarity: None, + }; + let result = service::save_look_set(&db, fixtures::test_uuid(10), dto).await; + assert!(result.is_ok(), "post 없는 저장이 실패: {result:?}"); + assert_eq!(result.unwrap().post_id, None); + } + #[tokio::test] async fn unsave_look_set_not_found() { let db = MockDatabase::new(DatabaseBackend::Postgres) @@ -144,7 +175,7 @@ mod mock_db_tests { fn list_row(items: Vec) -> service::LookSetListRow { service::LookSetListRow { id: fixtures::test_uuid(50), - post_id: fixtures::test_uuid(1), + post_id: Some(fixtures::test_uuid(1)), variant: "mood".to_string(), label: "Mood Match".to_string(), items: serde_json::to_value(items).unwrap(), diff --git a/packages/api-server/src/entities/saved_look_sets.rs b/packages/api-server/src/entities/saved_look_sets.rs index efa1d08b..d9b8fa9a 100644 --- a/packages/api-server/src/entities/saved_look_sets.rs +++ b/packages/api-server/src/entities/saved_look_sets.rs @@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize}; pub struct Model { #[sea_orm(primary_key, auto_increment = false)] pub id: Uuid, - pub post_id: Uuid, + // NULL 허용(#4) — Style Me 처럼 특정 룩(post) 기반이 아닌 저장은 post_id 없음. + pub post_id: Option, pub user_id: Uuid, pub variant: String, pub label: String, diff --git a/packages/web/lib/api/savedLookSets.ts b/packages/web/lib/api/savedLookSets.ts index ab315e5b..2121e47b 100644 --- a/packages/web/lib/api/savedLookSets.ts +++ b/packages/web/lib/api/savedLookSets.ts @@ -14,7 +14,9 @@ export interface SavedLookSetItem { export interface SavedLookSet { id: string; - post_id: string; + /** null = post 없는 저장(Style Me 조합, #4). 카드가 post 링크 없이 아이템 + * 조합 스트립만으로 렌더된다. */ + post_id: string | null; variant: string; label: string; items: SavedLookSetItem[]; @@ -33,7 +35,9 @@ export interface SavedLookSetStatus { } export interface CreateSavedLookSetInput { - post_id: string; + /** null = Style Me 처럼 특정 룩 기반이 아닌 저장(#4). 서버가 (user, variant) + * 부분 유니크로 유저당 1개를 upsert 한다. */ + post_id: string | null; variant: string; label: string; items: SavedLookSetItem[]; diff --git a/packages/web/lib/components/home/StyleMeSection.tsx b/packages/web/lib/components/home/StyleMeSection.tsx index c6747d19..97d3a793 100644 --- a/packages/web/lib/components/home/StyleMeSection.tsx +++ b/packages/web/lib/components/home/StyleMeSection.tsx @@ -3,13 +3,19 @@ /** * "Style me"(#786 후속 Phase 4) — 특정 룩이 아니라 내 취향(좋아요한 아이템 ∪ * Style DNA 시드)으로 처음부터 조합한 코어 아웃핏. RecreateLookSets 와 같은 조합 - * 엔진(composeLookSets)을 재사용하되, 특정 룩 재현이 아니라서 VTON 시도(원본 - * 사진 없음)와 저가/무드/핵심템 탭 구분(백엔드가 모든 슬롯을 이미 importance="high" - * 로 주므로 essential 은 mood 와 동일해 무의미)은 뺀 단일 카드. + * 엔진(composeLookSets)을 재사용한다. 저가/무드/핵심템 탭 구분은 뺐다(백엔드가 + * 모든 슬롯을 이미 importance="high" 로 주므로 essential 은 mood 와 동일해 무의미). + * + * VTON 시도 + 조합 저장은 Recreate 와 동일하게 제공한다(#4 raf 피드백). 예전엔 + * "원본 사진 없음"을 이유로 VTON 을 뺐지만, 이제 유저당 저장 사진(user_vton_photos) + * 이 있어 특정 룩이 아니어도 그 조합을 입어볼 수 있다. 저장은 post 없는 Look Set + * (post_id=NULL, variant='style-me') 으로 — Profile Look Sets 탭에 아이템 조합 + * 스트립(#6)으로 나타난다. */ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslations } from "next-intl"; +import { Bookmark } from "lucide-react"; import { useAuthStore, selectIsLoggedIn } from "@/lib/stores/authStore"; import { fetchStyleMe } from "@/lib/api/itemLikes"; import { looksetsToCodyMatch } from "@/lib/api/adapters/looksets-to-cody-match"; @@ -18,11 +24,16 @@ import { type LookSet, } from "@/lib/utils/composition-variants"; import { DEFAULT_SHOPPER_PREFERENCE } from "@/lib/api/adapters/cody-match"; +import { useVtonStore, type VtonPreloadItem } from "@/lib/stores/vtonStore"; +import { useStyleMeSaved, STYLE_ME_VARIANT } from "@/lib/hooks/useSavedLookSet"; +import type { CreateSavedLookSetInput } from "@/lib/api/savedLookSets"; +import { ScanBracketGlyph } from "@/lib/design-system"; import { SimilarCatalogItemsPanel, platformLabel, } from "../decoded/RecreateLookSets"; import { ItemLikeButton } from "../decoded/ItemLikeButton"; +import { cn } from "@/lib/utils"; const proxyImg = (url: string) => url.startsWith("http") @@ -35,10 +46,40 @@ const STYLE_ME_PREF = { preferred_retailers: [], }; +// 조합 아이템 → VTON preload 형태 (RecreateLookSets.toVtonItems 와 동일 매핑). +function toVtonItems(look: LookSet): VtonPreloadItem[] { + return look.items.map((it) => ({ + id: it.sku.sku_signature_hash, + title: it.sku.product, + thumbnail_url: it.sku.primary_image_url, + description: null, + keywords: null, + })); +} + function StyleMeCard({ look }: { look: LookSet }) { const t = useTranslations("Home"); const [expandedId, setExpandedId] = useState(null); + // 이 조합 그대로 입어보기 — post 가 없으므로 sourcePostId 는 빈 문자열(널이 아닌 + // falsy 라 결과 공유 URL 이 /posts 로 잘못 링크되지 않고 폴백된다), snapshot 없음. + const openWithItems = useVtonStore((s) => s.openWithItems); + const vtonItems = useMemo(() => toVtonItems(look), [look]); + + const { isSaved, toggle, isPending } = useStyleMeSaved(); + const buildSaveInput = (): CreateSavedLookSetInput => ({ + post_id: null, + variant: STYLE_ME_VARIANT, + label: t("styleMe.heading"), + items: look.items.map((it) => ({ + sku: it.sku.sku_signature_hash, + swapped: it.swapped, + affiliate_url: it.affiliateUrl, + })), + total_krw: look.totalKrw, + similarity: look.similarity, + }); + return (
    @@ -107,6 +148,38 @@ function StyleMeCard({ look }: { look: LookSet }) { ); })}
+ + {/* 이 조합 입어보기 + 저장 (#4) — Recreate 카드와 동일한 두 액션. */} +
+ {vtonItems.length > 0 && ( + + )} + +
); } diff --git a/packages/web/lib/components/profile/SavedLookSetsGrid.tsx b/packages/web/lib/components/profile/SavedLookSetsGrid.tsx index a21dc468..8b4efbf1 100644 --- a/packages/web/lib/components/profile/SavedLookSetsGrid.tsx +++ b/packages/web/lib/components/profile/SavedLookSetsGrid.tsx @@ -55,65 +55,86 @@ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) { mutationFn: () => unsaveLookSet(item.id), onSuccess: () => { queryClient.invalidateQueries({ queryKey: savedLookSetKeys.myList() }); - queryClient.invalidateQueries({ - queryKey: savedLookSetKeys.status(item.post_id), - }); + // post 있는 저장은 post 단위 status 쿼리를, post 없는 저장(Style Me, #4)은 + // 전용 styleMeStatus 를 무효화한다 — 그래야 룩 상세·Style Me 섹션의 저장 + // 토글이 삭제를 즉시 반영한다. + if (item.post_id) { + queryClient.invalidateQueries({ + queryKey: savedLookSetKeys.status(item.post_id), + }); + } else { + queryClient.invalidateQueries({ + queryKey: savedLookSetKeys.styleMeStatus(), + }); + } }, onError: () => toast.error(t("lookSets.removeError")), }); - return ( -
- -
- {item.post_thumbnail_url ? ( - {item.post_title - ) : ( -
- -
- )} -
-
- {shown.length > 0 && ( -
- {shown.map((it, i) => ( - - {/* eslint-disable-next-line @next/next/no-img-element -- proxied external catalog image */} - {it.product - - ))} - {overflow > 0 && ( - - +{overflow} - - )} -
- )} -
- - {item.label} - - - {t("lookSets.itemCount", { count: item.items.length })} - + // post 없는 저장(Style Me, #4)은 이동할 룩 상세가 없으므로 Link 로 감싸지 + // 않는다 — 아이템 조합 스트립(#6)이 카드의 시각적 정체성이 된다. + const cardBody = ( + <> +
+ {item.post_thumbnail_url ? ( + {item.post_title + ) : ( +
+ +
+ )} +
+
+ {shown.length > 0 && ( +
+ {shown.map((it, i) => ( + + {/* eslint-disable-next-line @next/next/no-img-element -- proxied external catalog image */} + {it.product + + ))} + {overflow > 0 && ( + + +{overflow} + + )}
+ )} +
+ + {item.label} + + + {t("lookSets.itemCount", { count: item.items.length })} +
- +
+ + ); + + return ( +
+ {item.post_id ? ( + + {cardBody} + + ) : ( +
{cardBody}
+ )}