Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/api-server-invariants.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:" \
Expand Down
16 changes: 16 additions & 0 deletions packages/api-server/src/domains/look_sets/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ pub struct LookSetItemDto {
pub swapped: bool,
#[serde(default)]
pub affiliate_url: Option<String>,
/// 카탈로그 파생 표시 필드 — 저장 스냅샷엔 없고(이미지 URL 은 시간이 지나면
/// 바뀌므로 sku 만 저장한다) **목록 조회 시점에** assets 풀에서 fresh 하게 채운다
/// (#6: Profile Look Sets 카드에 포스트 + 아이템 조합을 함께 표시). 저장 입력·저장
/// 스냅샷에는 None → skip 되어 저장 shape 이 오염되지 않는다.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub brand: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub product: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Validate, utoipa::ToSchema)]
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
5 changes: 4 additions & 1 deletion packages/api-server/src/domains/look_sets/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<LookSetResponse> = rows.into_iter().map(list_row_to_response).collect();

Ok(Json(ListMyLookSetsResponse {
Expand Down
67 changes: 67 additions & 0 deletions packages/api-server/src/domains/look_sets/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,73 @@ pub fn items_from_model(model: &saved_look_sets::Model) -> Vec<LookSetItemDto> {
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<Uuid> = 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<String>, Option<String>, Option<String>),
> = std::collections::HashMap::with_capacity(catalog.len());
for r in &catalog {
let Ok(id) = r.try_get::<Uuid>("", "id") else {
continue;
};
by_id.insert(
id,
(
r.try_get::<Option<String>>("", "image_url").ok().flatten(),
r.try_get::<Option<String>>("", "brand").ok().flatten(),
r.try_get::<Option<String>>("", "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(
Expand Down
34 changes: 34 additions & 0 deletions packages/api-server/src/domains/look_sets/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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<LookSetItemDto>) -> 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()); // 채워지지 않음(파싱 실패)
}
}
6 changes: 6 additions & 0 deletions packages/web/lib/api/savedLookSets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 43 additions & 1 deletion packages/web/lib/components/profile/SavedLookSetsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,35 @@ 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");
const { width, height } = useImageDimensions(item.post_thumbnail_url ?? null);
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: () => {
Expand Down Expand Up @@ -61,7 +80,30 @@ function SavedLookSetItemCard({ item }: { item: SavedLookSet }) {
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/60 to-transparent p-2">
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2">
{shown.length > 0 && (
<div className="mb-1.5 flex items-center gap-1">
{shown.map((it, i) => (
<span
key={`${it.sku}-${i}`}
className="relative size-9 shrink-0 overflow-hidden rounded-md border border-white/20 bg-black/30"
>
{/* eslint-disable-next-line @next/next/no-img-element -- proxied external catalog image */}
<img
src={proxyImg(it.image_url as string)}
alt={it.product ?? it.brand ?? ""}
className="h-full w-full object-cover"
loading="lazy"
/>
</span>
))}
{overflow > 0 && (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-white/20 bg-black/40 text-[10px] font-medium text-white/80">
+{overflow}
</span>
)}
</div>
)}
<div className="flex items-end justify-between gap-1">
<span className="text-[11px] text-white/90 font-medium truncate">
{item.label}
Expand Down
Loading