From ce64dec7fb750add5d8355719d6a60771dfdb059 Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Sat, 18 Jul 2026 01:07:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(looksets):=20=EC=A0=80=EC=9E=A5=20Look?= =?UTF-8?q?=20Set=20=EC=B9=B4=EB=93=9C=EC=97=90=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=EC=A1=B0=ED=95=A9=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브 피드백(#6): Profile "Look Sets" 탭이 포스트 썸네일만 보여줬는데, 저장의 본질은 룩이 아니라 그 룩을 이루는 **조합**이라 아이템도 함께 보여준다. backend (api-server): - LookSetItemDto 에 image_url·brand·product 추가(#[serde skip_if none] — 저장 입력·스냅샷은 sku 만, shape 오염 없음). - service::enrich_items_with_catalog: 목록 전 행의 sku 를 모아 assets 풀 affiliate_catalog_items 를 쿼리 1회로 배치 해석(N*M 쿼리 회피). 저장 스냅샷엔 sku 만 담고(카탈로그 이미지는 바뀜) 조회 시점에 fresh 하게 붙인다. 파싱 불가/비활성 sku 는 이미지 없이 두고 카드가 자기은폐한다. - list_my_look_sets handler 가 state.assets_db 로 enrich. 실패해도 목록을 실패시키지 않고 포스트 썸네일로 degrade. - 테스트: unparseable sku → DB 안 건드리고 Ok(degrade 안전성). 11 passed. frontend (web): - SavedLookSetItem 타입에 image_url/brand/product. - SavedLookSetsGrid 카드에 아이템 썸네일 스트립(최대 4 + "+N"), image_url 없는 아이템은 스킵, 전부 없으면 스트립 숨김. 외부 이미지는 image-proxy 경유. 검증(실제 브라우저 + 인증, localhost): - GET /users/me/look-sets → 아이템별 brand/product/image_url 해석 확인 (ANN DEMEULEMEESTER·ACNE·MM6, 실제 원격 카탈로그). - Profile Look Sets 탭: 포스트 썸네일 + 3개 아이템 조합 스트립 렌더, 콘솔 에러 없음(스크린샷 확인). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api-server/src/domains/look_sets/dto.rs | 16 +++++ .../src/domains/look_sets/handlers.rs | 5 +- .../src/domains/look_sets/service.rs | 67 +++++++++++++++++++ .../api-server/src/domains/look_sets/tests.rs | 34 ++++++++++ packages/web/lib/api/savedLookSets.ts | 6 ++ .../components/profile/SavedLookSetsGrid.tsx | 44 +++++++++++- 6 files changed, 170 insertions(+), 2 deletions(-) diff --git a/packages/api-server/src/domains/look_sets/dto.rs b/packages/api-server/src/domains/look_sets/dto.rs index 2621e59b..d459c546 100644 --- a/packages/api-server/src/domains/look_sets/dto.rs +++ b/packages/api-server/src/domains/look_sets/dto.rs @@ -12,6 +12,16 @@ pub struct LookSetItemDto { pub swapped: bool, #[serde(default)] pub affiliate_url: Option, + /// 카탈로그 파생 표시 필드 — 저장 스냅샷엔 없고(이미지 URL 은 시간이 지나면 + /// 바뀌므로 sku 만 저장한다) **목록 조회 시점에** assets 풀에서 fresh 하게 채운다 + /// (#6: Profile Look Sets 카드에 포스트 + 아이템 조합을 함께 표시). 저장 입력·저장 + /// 스냅샷에는 None → skip 되어 저장 shape 이 오염되지 않는다. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub image_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brand: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub product: Option, } #[derive(Debug, Clone, Deserialize, Validate, utoipa::ToSchema)] @@ -71,6 +81,9 @@ mod tests { sku: "sku-1".to_string(), swapped: false, affiliate_url: None, + image_url: None, + brand: None, + product: None, }], total_krw: Some(129_000), similarity: Some(0.82), @@ -106,6 +119,9 @@ mod tests { sku: "sku-1".to_string(), swapped: false, affiliate_url: None, + image_url: None, + brand: None, + product: None, }], total_krw: Some(129_000), similarity: Some(0.82), diff --git a/packages/api-server/src/domains/look_sets/handlers.rs b/packages/api-server/src/domains/look_sets/handlers.rs index 69b6602e..7bef6e6f 100644 --- a/packages/api-server/src/domains/look_sets/handlers.rs +++ b/packages/api-server/src/domains/look_sets/handlers.rs @@ -117,7 +117,10 @@ pub async fn list_my_look_sets( let offset = (page - 1) * per_page; let total = service::count_my_look_sets(state.db.as_ref(), user.id).await?; - let rows = service::list_my_look_sets(state.db.as_ref(), user.id, per_page, offset).await?; + let mut rows = service::list_my_look_sets(state.db.as_ref(), user.id, per_page, offset).await?; + // 아이템 sku → 카탈로그 이미지/브랜드/제목 (assets 풀, 쿼리 1회). 실패해도 카드는 + // 포스트 썸네일로 degrade 하므로 목록 자체를 실패시키지 않는다. + service::enrich_items_with_catalog(state.assets_db.as_ref(), &mut rows).await?; let data: Vec = rows.into_iter().map(list_row_to_response).collect(); Ok(Json(ListMyLookSetsResponse { diff --git a/packages/api-server/src/domains/look_sets/service.rs b/packages/api-server/src/domains/look_sets/service.rs index 259318fa..ee66c39e 100644 --- a/packages/api-server/src/domains/look_sets/service.rs +++ b/packages/api-server/src/domains/look_sets/service.rs @@ -38,6 +38,73 @@ pub fn items_from_model(model: &saved_look_sets::Model) -> Vec { items_from_json(&model.items) } +/// 저장된 look set item 의 sku(=`affiliate_catalog_items.id`)를 assets 풀에서 배치 +/// 해석해 image_url·brand·product 를 채운다 (#6: 카드에 포스트 + 아이템 조합 동시 +/// 표시). 저장 스냅샷은 sku 만 담으므로(카탈로그 이미지는 시간이 지나면 바뀐다) +/// 조회 시점에 fresh 하게 붙인다. 전 행의 sku 를 한 번에 모아 쿼리 1회 — +/// 카드 N개 × 아이템 M개를 N*M 쿼리로 만들지 않는다. 해석 실패 sku(파싱 불가 or +/// 비활성/삭제)는 이미지 없이 그대로 두고, 카드가 자기은폐한다(빈 썸네일 슬롯). +pub async fn enrich_items_with_catalog( + assets_db: &DatabaseConnection, + rows: &mut [LookSetListRow], +) -> AppResult<()> { + let mut ids: Vec = rows + .iter() + .flat_map(|r| items_from_json(&r.items)) + .filter_map(|it| Uuid::parse_str(&it.sku).ok()) + .collect(); + if ids.is_empty() { + return Ok(()); + } + ids.sort(); + ids.dedup(); + + let catalog = assets_db + .query_all(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, image_url, brand, title \ + FROM public.affiliate_catalog_items \ + WHERE id = ANY($1)", + [ids.into()], + )) + .await + .map_err(AppError::DatabaseError)?; + + let mut by_id: std::collections::HashMap< + Uuid, + (Option, Option, Option), + > = std::collections::HashMap::with_capacity(catalog.len()); + for r in &catalog { + let Ok(id) = r.try_get::("", "id") else { + continue; + }; + by_id.insert( + id, + ( + r.try_get::>("", "image_url").ok().flatten(), + r.try_get::>("", "brand").ok().flatten(), + r.try_get::>("", "title").ok().flatten(), + ), + ); + } + + for row in rows.iter_mut() { + let mut items = items_from_json(&row.items); + for it in items.iter_mut() { + let Ok(id) = Uuid::parse_str(&it.sku) else { + continue; + }; + if let Some((image_url, brand, product)) = by_id.get(&id) { + it.image_url = image_url.clone(); + it.brand = brand.clone(); + it.product = product.clone(); + } + } + row.items = items_to_json(&items)?; + } + Ok(()) +} + /// Look Set 저장 — 이미 같은 (user, post, variant) 조합이 있으면 스냅샷을 덮어쓴다 /// (재저장 = 갱신, unique index 로 중복 행 방지). pub async fn save_look_set( diff --git a/packages/api-server/src/domains/look_sets/tests.rs b/packages/api-server/src/domains/look_sets/tests.rs index e1a0c54b..08a52d86 100644 --- a/packages/api-server/src/domains/look_sets/tests.rs +++ b/packages/api-server/src/domains/look_sets/tests.rs @@ -13,6 +13,9 @@ mod mock_db_tests { sku: "sku-1".to_string(), swapped: false, affiliate_url: Some("https://example.com".to_string()), + image_url: None, + brand: None, + product: None, } } @@ -137,4 +140,35 @@ mod mock_db_tests { assert_eq!(items.len(), 1); assert_eq!(items[0].sku, "sku-1"); } + + fn list_row(items: Vec) -> service::LookSetListRow { + service::LookSetListRow { + id: fixtures::test_uuid(50), + post_id: fixtures::test_uuid(1), + variant: "mood".to_string(), + label: "Mood Match".to_string(), + items: serde_json::to_value(items).unwrap(), + total_krw: Some(129_000), + similarity: Some(0.82), + created_at: fixtures::test_timestamp().to_utc(), + post_thumbnail_url: Some("https://cdn/post.webp".to_string()), + post_title: None, + } + } + + // sku 가 UUID 로 파싱 안 되면(레거시/손상 스냅샷) DB 를 아예 건드리지 않고 Ok — + // 카드는 포스트 썸네일로 degrade 한다. enrichment 가 목록을 실패시키면 안 된다. + #[tokio::test] + async fn enrich_skips_unparseable_skus_without_querying() { + // 쿼리 결과를 하나도 append 하지 않은 MockDatabase — 만약 enrich 가 쿼리를 + // 실행하면 Mock 이 빈 결과를 주거나 패닉하지 않고, ids.is_empty() 단락으로 + // 아예 도달하지 않는다. + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); + let mut rows = vec![list_row(vec![item()])]; // sku="sku-1" → not a UUID + let result = service::enrich_items_with_catalog(&db, &mut rows).await; + assert!(result.is_ok()); + let enriched = service::items_from_json(&rows[0].items); + assert_eq!(enriched[0].sku, "sku-1"); + assert!(enriched[0].image_url.is_none()); // 채워지지 않음(파싱 실패) + } } diff --git a/packages/web/lib/api/savedLookSets.ts b/packages/web/lib/api/savedLookSets.ts index 4027fbee..ab315e5b 100644 --- a/packages/web/lib/api/savedLookSets.ts +++ b/packages/web/lib/api/savedLookSets.ts @@ -4,6 +4,12 @@ export interface SavedLookSetItem { sku: string; swapped: boolean; affiliate_url: string | null; + /** 목록 조회에서만 채워짐 — 카탈로그(assets)에서 해석한 아이템 표시 데이터. + * 저장 스냅샷엔 sku 만 있고 서버가 조회 시점에 fresh 하게 붙인다(#6). 해석 + * 실패 sku 는 undefined → 카드가 빈 슬롯으로 degrade. */ + image_url?: string | null; + brand?: string | null; + product?: string | null; } export interface SavedLookSet { diff --git a/packages/web/lib/components/profile/SavedLookSetsGrid.tsx b/packages/web/lib/components/profile/SavedLookSetsGrid.tsx index 8c8a11af..a21dc468 100644 --- a/packages/web/lib/components/profile/SavedLookSetsGrid.tsx +++ b/packages/web/lib/components/profile/SavedLookSetsGrid.tsx @@ -22,9 +22,24 @@ import { useImageDimensions } from "@/lib/hooks/useImageDimensions"; const PER_PAGE = 20; +// 카탈로그 아이템 이미지는 외부 리테일러 URL — same-origin 프록시 경유(R2 CORS 없음). +const proxyImg = (url: string) => + url.startsWith("http") + ? `/api/v1/image-proxy?url=${encodeURIComponent(url)}` + : url; + +// 조합 스트립에 최대 몇 칸 노출할지 — 넘치면 "+N". 4 는 카드 폭(2/3열 그리드)에서 +// 썸네일이 뭉개지지 않는 상한. +const MAX_STRIP = 4; + /** * 저장한 Look Set 카드 — Library "Look Sets" 탭 + Profile 탭 공용(#890 Saved Looks * post 타이틀 노출 회귀 재발 방지: post_title 은 alt 텍스트로만 쓰고 화면엔 미노출). + * + * 포스트 썸네일 + **아이템 조합 스트립**을 함께 보여준다(#6 raf 피드백: 룩만이 + * 아니라 그 룩을 이루는 조합이 저장의 본질). 아이템 이미지는 목록 응답에서 + * 서버가 카탈로그로 해석해 붙여준다(sku→image_url). 이미지 없는 아이템은 + * 스트립에서 빠지고, 전부 없으면 스트립 자체를 숨긴다(자기은폐). */ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) { const t = useTranslations("Library"); @@ -32,6 +47,10 @@ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) { const aspectRatio = width && height ? width / height : undefined; const queryClient = useQueryClient(); + const stripItems = item.items.filter((it) => !!it.image_url); + const shown = stripItems.slice(0, MAX_STRIP); + const overflow = stripItems.length - shown.length; + const unsaveMutation = useMutation({ mutationFn: () => unsaveLookSet(item.id), onSuccess: () => { @@ -61,7 +80,30 @@ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) { )} -
+
+ {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} From fd2e99c7c5e12f905032414cd956c2883c6d4caa Mon Sep 17 00:00:00 2001 From: cocoyoon Date: Sat, 18 Jul 2026 01:21:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?ci(invariants):=20look=5Fsets=20service/han?= =?UTF-8?q?dlers=20assets=5Fdb=20allowlist=20=EC=B6=94=EA=B0=80=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enrich_items_with_catalog 가 sku→affiliate_catalog_items(assets)를 read-only cross-pool 로 해석한다 — domains/posts/looksets.rs 와 동일 패턴. prod-only 부분 (saved_look_sets·posts JOIN)은 state.db 유지, 카탈로그 해석만 assets_db. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/api-server-invariants.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/api-server-invariants.yml b/.github/workflows/api-server-invariants.yml index e9583aac..6f5c3d7b 100644 --- a/.github/workflows/api-server-invariants.yml +++ b/.github/workflows/api-server-invariants.yml @@ -156,6 +156,13 @@ jobs: # solutions_service::list_solutions_by_brand_id 에 위임 — prod solutions 조회 + # assets affiliate_catalog_items/variants 로 market-aware affiliate URL·taxonomy # resolve (domains/solutions/ 와 동일 cross-pool 패턴). looks/artist 는 prod-only. + # + # domains/look_sets/service.rs · domains/look_sets/handlers.rs + # — GET /users/me/look-sets (#6, 저장 Look Set 카드에 아이템 조합 표시). + # enrich_items_with_catalog 가 저장 스냅샷의 sku(=affiliate_catalog_items.id)를 + # assets affiliate_catalog_items 로 배치 해석해 image_url/brand/title 을 붙인다. + # domains/posts/looksets.rs 와 동일한 cross-pool read-only 패턴 — 저장·조회의 + # prod-only 부분(saved_look_sets, posts JOIN)은 state.db, 카탈로그 해석만 assets. - name: Guard dual-DB pool selection — assets_db allowlist (#717) run: | set -euo pipefail @@ -197,6 +204,8 @@ jobs: | grep -v "^${SRC}/domains/posts/handlers\.rs:" \ | grep -v "^${SRC}/domains/posts/looksets\.rs:" \ | grep -v "^${SRC}/domains/posts/dto\.rs:" \ + | grep -v "^${SRC}/domains/look_sets/service\.rs:" \ + | grep -v "^${SRC}/domains/look_sets/handlers\.rs:" \ | grep -v "^${SRC}/domains/item_likes/" \ | grep -v "^${SRC}/entities/item_likes\.rs:" \ | grep -v "^${SRC}/batch/home_curation\.rs:" \