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
17 changes: 11 additions & 6 deletions packages/api-server/src/domains/look_sets/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid>,
/// mood | alternative | essential | style-me
#[validate(length(min = 1, max = 32))]
pub variant: String,
#[validate(length(min = 1, max = 120))]
Expand All @@ -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<Uuid>,
pub variant: String,
pub label: String,
pub items: Vec<LookSetItemDto>,
Expand Down Expand Up @@ -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 {
Expand All @@ -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![],
Expand All @@ -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 {
Expand Down
37 changes: 25 additions & 12 deletions packages/api-server/src/domains/look_sets/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid>,
pub variant: String,
pub label: String,
pub items: sea_orm::JsonValue,
Expand Down Expand Up @@ -112,19 +113,29 @@ pub async fn save_look_set(
user_id: Uuid,
dto: CreateLookSetDto,
) -> AppResult<saved_look_sets::Model> {
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();
Expand Down Expand Up @@ -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",
Expand All @@ -215,7 +228,7 @@ pub async fn list_my_look_sets(
.filter_map(|r| {
Some(LookSetListRow {
id: r.try_get::<Uuid>("", "id").ok()?,
post_id: r.try_get::<Uuid>("", "post_id").ok()?,
post_id: r.try_get::<Option<Uuid>>("", "post_id").ok().flatten(),
variant: r.try_get::<String>("", "variant").ok()?,
label: r.try_get::<String>("", "label").ok()?,
items: r.try_get::<sea_orm::JsonValue>("", "items").ok()?,
Expand Down
41 changes: 36 additions & 5 deletions packages/api-server/src/domains/look_sets/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -39,7 +39,7 @@ mod mock_db_tests {
.append_query_results([Vec::<crate::entities::posts::Model>::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()],
Expand All @@ -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()],
Expand All @@ -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()],
Expand All @@ -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::<crate::entities::saved_look_sets::Model>::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)
Expand Down Expand Up @@ -144,7 +175,7 @@ mod mock_db_tests {
fn list_row(items: Vec<LookSetItemDto>) -> 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(),
Expand Down
3 changes: 2 additions & 1 deletion packages/api-server/src/entities/saved_look_sets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uuid>,
pub user_id: Uuid,
pub variant: String,
pub label: String,
Expand Down
8 changes: 6 additions & 2 deletions packages/web/lib/api/savedLookSets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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[];
Expand Down
79 changes: 76 additions & 3 deletions packages/web/lib/components/home/StyleMeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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")
Expand All @@ -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<string | null>(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 (
<article className="flex flex-col gap-3 rounded-xl border border-border bg-card p-4">
<ul className="flex flex-col gap-2.5">
Expand Down Expand Up @@ -107,6 +148,38 @@ function StyleMeCard({ look }: { look: LookSet }) {
);
})}
</ul>

{/* 이 조합 입어보기 + 저장 (#4) — Recreate 카드와 동일한 두 액션. */}
<div className="flex gap-2">
{vtonItems.length > 0 && (
<button
type="button"
onClick={() => openWithItems("", vtonItems, null)}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-lg bg-foreground px-4 py-2.5 text-sm font-semibold text-background transition-opacity hover:opacity-90"
>
<ScanBracketGlyph size={16} />
{t("styleMe.tryOn")}
</button>
)}
<button
type="button"
aria-label={
isSaved ? t("styleMe.unsaveSetAria") : t("styleMe.saveSetAria")
}
aria-pressed={isSaved}
disabled={isPending}
onClick={() => toggle(buildSaveInput)}
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors disabled:opacity-50",
isSaved
? "border-primary bg-primary/5 text-foreground"
: "border-border text-muted-foreground hover:bg-accent"
)}
>
<Bookmark className={cn("size-4", isSaved && "fill-current")} />
{isSaved ? t("styleMe.savedSet") : t("styleMe.saveSet")}
</button>
</div>
</article>
);
}
Expand Down
Loading
Loading