From 51485c23f263cc8224bb5598c8b7b2bbd2f9907d Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Sun, 19 Jul 2026 00:20:15 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(style-me):=20Try-on=20+=20=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=A0=80=EC=9E=A5=20=EB=B2=84=ED=8A=BC=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브 피드백(#4): Style Me 섹션에 Recreate 처럼 Try-on 과 조합 저장을 단다. 예전엔 "원본 사진 없음"으로 VTON 을 뺐지만 이제 유저당 저장 사진 (user_vton_photos)이 있어 특정 룩이 아니어도 그 조합을 입어볼 수 있다. Try-on (post 불필요): - StyleMeCard 에 "입어보기" 버튼 — 조합 아이템을 VtonPreloadItem 으로 매핑해 openWithItems("", items, null). VTON 잡은 person_image(저장 사진) + item_urls 만 필요하고 post 를 요구하지 않는다. sourcePostId 는 빈 문자열(falsy 라 결과 공유 URL 이 /posts 로 오링크되지 않고 폴백). Look Set 저장 (post-less 지원): - saved_look_sets.post_id 를 NULL 허용으로 완화(마이그레이션). Style Me 는 참조할 룩이 없다. 기존 유니크 (user, post, variant) 는 NULL 을 서로 다른 값으로 취급해 중복을 못 막으므로 (user, variant) WHERE post_id IS NULL 부분 유니크를 추가 — 유저당 style-me 세트 1개 보장(재저장=upsert). - 백엔드: CreateLookSetDto/LookSetResponse/LookSetListRow/엔티티 post_id 를 Option 화. save_look_set 은 None 이면 post 존재 검증을 스킵하고 is_null 로 dedup. list_my_look_sets 는 LEFT JOIN(INNER 면 post-less 행이 통째로 빠진다). - 프론트: useStyleMeSaved(post 없는 status — 내 목록에서 style-me 항목 탐색, Profile 그리드의 myList infinite 쿼리와 키 충돌 방지 위해 전용 키). Style Me 저장은 variant='style-me', post_id=null. - SavedLookSetsGrid: post_id 없으면 post 링크로 감싸지 않는다 — 아이템 조합 스트립(#6)이 카드의 시각적 정체성. 테스트: save_look_set_style_me_skips_post_check (post 조회 스킵 검증). 12 passed. 검증(실제 브라우저 + 인증, localhost): - POST /look-sets post_id=null → 생성 OK, 재저장은 upsert(중복 아님, 개수 1). - GET /users/me/look-sets → style-me(post_id null, LEFT JOIN 유지) + 기존 post 기반 세트 둘 다, 아이템 이미지 해석됨. - Style Me 섹션: Try-on + Save 버튼, Save 클릭 → "Saved" 전환. - Profile Look Sets 탭: "Your outfit" post-less 카드(아이템 스트립, 깨진 /posts/null 링크 0개) + 기존 카드 공존, 콘솔 에러 없음. 주의: 마이그레이션은 로컬만 적용. prod 는 명시 승인 사안이라 미적용. #6(#1026) 위에 스택 — 카드 아이템 스트립·enrich 를 공유한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api-server/src/domains/look_sets/dto.rs | 17 ++- .../src/domains/look_sets/service.rs | 37 ++++-- .../api-server/src/domains/look_sets/tests.rs | 41 ++++++- .../src/entities/saved_look_sets.rs | 3 +- packages/web/lib/api/savedLookSets.ts | 8 +- .../lib/components/home/StyleMeSection.tsx | 79 +++++++++++- .../components/profile/SavedLookSetsGrid.tsx | 112 ++++++++++-------- packages/web/lib/hooks/useSavedLookSet.ts | 70 +++++++++++ packages/web/messages/en.json | 7 +- packages/web/messages/ko.json | 7 +- ...0260719120000_saved_look_sets_postless.sql | 23 ++++ 11 files changed, 323 insertions(+), 81 deletions(-) create mode 100644 supabase/migrations/20260719120000_saved_look_sets_postless.sql 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..4a1a6904 100644 --- a/packages/web/lib/components/profile/SavedLookSetsGrid.tsx +++ b/packages/web/lib/components/profile/SavedLookSetsGrid.tsx @@ -62,58 +62,70 @@ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) { 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}
+ )}