diff --git a/.github/workflows/api-server-invariants.yml b/.github/workflows/api-server-invariants.yml index a79725f2e..99fdf6efb 100644 --- a/.github/workflows/api-server-invariants.yml +++ b/.github/workflows/api-server-invariants.yml @@ -144,6 +144,12 @@ jobs: # 인스타 트렌드 불릿, assets 프로젝트 테이블)를 참고 신호로 read-only 조회. # 엔티티 파일은 정의 + "assets_db 로만 조회" 주석. operations 아닌 assets 에 # 두는 결정(외부 파이프라인 산출물 = raw_posts 와 동일 성격). + # + # domains/catalog_entities/ + # — GET /brands/{id}/solutions (#886). list_brand_solutions 가 + # 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. - name: Guard dual-DB pool selection — assets_db allowlist (#717) run: | set -euo pipefail @@ -178,6 +184,7 @@ jobs: | grep -v "^${SRC}/domains/admin/solutions\.rs:" \ | grep -v "^${SRC}/domains/catalog/service\.rs:" \ | grep -v "^${SRC}/domains/catalog/handlers\.rs:" \ + | grep -v "^${SRC}/domains/catalog_entities/" \ | grep -v "^${SRC}/domains/posts/service\.rs:" \ | grep -v "^${SRC}/domains/posts/handlers\.rs:" \ | grep -v "^${SRC}/domains/posts/looksets\.rs:" \ diff --git a/packages/ai-server/scripts/backfill_artist_profile_image_url.py b/packages/ai-server/scripts/backfill_artist_profile_image_url.py new file mode 100644 index 000000000..6e8a89da6 --- /dev/null +++ b/packages/ai-server/scripts/backfill_artist_profile_image_url.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# pyright: reportMissingImports=false +"""Copy instagram_accounts.profile_image_url → artists.profile_image_url (1회성). + +artists.profile_image_url 이 비어 있고, 연결된 Instagram 계정에만 이미지가 있을 때 +primary_instagram_account_id 우선, 없으면 artist_id 링크 중 최신 row 를 사용한다. + +Usage: + cd packages/ai-server + + uv run python scripts/backfill_artist_profile_image_url.py \\ + --db-env ../../.env.backend.prod --dry-run + + uv run python scripts/backfill_artist_profile_image_url.py \\ + --db-env ../../.env.backend.prod --apply +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import sys +from pathlib import Path +from typing import Any, Optional + +logger = logging.getLogger("backfill-artist-profile-image") + + +def _parse_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + raise FileNotFoundError(f"env file not found: {path}") + out: dict[str, str] = {} + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + if not key: + continue + for marker in (" #", "\t#", " #"): + idx = val.find(marker) + if idx != -1: + val = val[:idx] + break + out[key] = val.strip().strip('"').strip("'") + return out + + +def _resolve_env(path: Optional[str], keys: list[str]) -> dict[str, str]: + if path: + parsed = _parse_env_file(Path(path).expanduser().resolve()) + return {k: parsed[k] for k in keys if k in parsed} + return {k: os.environ[k] for k in keys if k in os.environ} + + +def _mask_host(db_url: str) -> str: + try: + return db_url.split("@", 1)[1] + except IndexError: + return "" + + +def _is_local(db_url: str) -> bool: + return _mask_host(db_url).startswith(("127.0.0.1", "localhost", "::1")) + + +def _is_supabase_pooler(db_url: str) -> bool: + return "pooler.supabase.com" in db_url + + +def _prefer_transaction_pooler(db_url: str, *, session_pooler: bool) -> str: + if session_pooler or not _is_supabase_pooler(db_url): + return db_url + if ":5432/" in db_url: + return db_url.replace(":5432/", ":6543/", 1) + return db_url + + +async def _connect( + db_url: str, + *, + session_pooler: bool = False, + connect_retries: int = 4, +): + import asyncpg + + resolved = _prefer_transaction_pooler(db_url, session_pooler=session_pooler) + ssl = None if _is_local(resolved) else "require" + kwargs: dict[str, Any] = {} + if _is_supabase_pooler(resolved): + kwargs["statement_cache_size"] = 0 + + delay = 1.0 + last_exc: Optional[Exception] = None + for attempt in range(connect_retries + 1): + try: + conn = await asyncpg.connect(resolved, ssl=ssl, **kwargs) + if resolved != db_url: + logger.info( + "using transaction pooler %s (session :5432 → :6543)", + _mask_host(resolved), + ) + return conn + except Exception as exc: # noqa: BLE001 + last_exc = exc + msg = str(exc).lower() + pool_full = "max clients" in msg or "too many clients" in msg + if attempt < connect_retries and (pool_full or "timeout" in msg): + logger.warning("connect failed (attempt %s): %s", attempt + 1, exc) + await asyncio.sleep(delay) + delay = min(delay * 2, 8.0) + continue + if pool_full: + logger.error( + "Supabase session pool exhausted. Stop `just local-be` or use --db-url." + ) + raise + raise last_exc # type: ignore[misc] + + +_COUNT_SQL = """ +SELECT COUNT(*)::bigint + FROM public.artists a + LEFT JOIN public.instagram_accounts primary_ia + ON primary_ia.id = a.primary_instagram_account_id + WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '') + AND COALESCE( + NULLIF(btrim(primary_ia.profile_image_url), ''), + ( + SELECT NULLIF(btrim(ia2.profile_image_url), '') + FROM public.instagram_accounts ia2 + WHERE ia2.artist_id = a.id + AND ia2.profile_image_url IS NOT NULL + AND btrim(ia2.profile_image_url) <> '' + ORDER BY ia2.updated_at DESC + LIMIT 1 + ) + ) IS NOT NULL +""" + +_PREVIEW_SQL = """ +SELECT + a.id, + COALESCE(a.name_ko, a.name_en, a.name ->> 'ko', a.name ->> 'en') AS name, + COALESCE( + NULLIF(btrim(primary_ia.profile_image_url), ''), + ( + SELECT NULLIF(btrim(ia2.profile_image_url), '') + FROM public.instagram_accounts ia2 + WHERE ia2.artist_id = a.id + AND ia2.profile_image_url IS NOT NULL + AND btrim(ia2.profile_image_url) <> '' + ORDER BY ia2.updated_at DESC + LIMIT 1 + ) + ) AS source_url, + CASE + WHEN primary_ia.profile_image_url IS NOT NULL + AND btrim(primary_ia.profile_image_url) <> '' + THEN 'primary_instagram_account_id' + ELSE 'artist_id_link' + END AS source +FROM public.artists a +LEFT JOIN public.instagram_accounts primary_ia + ON primary_ia.id = a.primary_instagram_account_id +WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '') + AND COALESCE( + NULLIF(btrim(primary_ia.profile_image_url), ''), + ( + SELECT NULLIF(btrim(ia2.profile_image_url), '') + FROM public.instagram_accounts ia2 + WHERE ia2.artist_id = a.id + AND ia2.profile_image_url IS NOT NULL + AND btrim(ia2.profile_image_url) <> '' + ORDER BY ia2.updated_at DESC + LIMIT 1 + ) + ) IS NOT NULL +ORDER BY a.updated_at DESC +LIMIT $1 +""" + +_UPDATE_SQL = """ +UPDATE public.artists a + SET profile_image_url = src.url, + updated_at = NOW() + FROM ( + SELECT + a.id, + COALESCE( + NULLIF(btrim(primary_ia.profile_image_url), ''), + ( + SELECT NULLIF(btrim(ia2.profile_image_url), '') + FROM public.instagram_accounts ia2 + WHERE ia2.artist_id = a.id + AND ia2.profile_image_url IS NOT NULL + AND btrim(ia2.profile_image_url) <> '' + ORDER BY ia2.updated_at DESC + LIMIT 1 + ) + ) AS url + FROM public.artists a + LEFT JOIN public.instagram_accounts primary_ia + ON primary_ia.id = a.primary_instagram_account_id + WHERE (a.profile_image_url IS NULL OR btrim(a.profile_image_url) = '') + ) src + WHERE a.id = src.id + AND src.url IS NOT NULL +""" + + +async def run(args: argparse.Namespace) -> int: + db_cfg = _resolve_env(args.db_env, ["OPERATION_DATABASE_URL"]) + db_url = ( + args.db_url + or db_cfg.get("OPERATION_DATABASE_URL", "") + or os.environ.get("OPERATION_DATABASE_URL", "") + ) + if not db_url: + logger.error("OPERATION_DATABASE_URL missing — pass --db-env or --db-url") + return 2 + + if not args.apply and not args.dry_run: + logger.error("Specify --dry-run or --apply") + return 2 + + logger.info("db host: %s", _mask_host(db_url)) + conn = await _connect(db_url, session_pooler=args.session_pooler) + try: + total = await conn.fetchval(_COUNT_SQL) + logger.info("artists to backfill: %d", total) + if total == 0: + return 0 + + preview_rows = await conn.fetch(_PREVIEW_SQL, args.preview_limit) + for row in preview_rows: + logger.info( + "%s (%s) ← %s | %s", + row["name"] or row["id"], + str(row["id"])[:8], + row["source"], + (row["source_url"] or "")[:80], + ) + if total > len(preview_rows): + logger.info("... and %d more", total - len(preview_rows)) + + if args.dry_run: + logger.info("dry-run complete — no rows updated") + return 0 + + result = await conn.execute(_UPDATE_SQL) + logger.info("apply complete: %s", result) + remaining = await conn.fetchval(_COUNT_SQL) + logger.info("remaining artists needing backfill: %d", remaining) + return 0 + finally: + await conn.close() + + +def main() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db-env", default="", help="Env file with OPERATION_DATABASE_URL") + parser.add_argument("--db-url", default="", help="OPERATION_DATABASE_URL override") + parser.add_argument( + "--session-pooler", + action="store_true", + help="Use Supabase session pooler (:5432); default is transaction pooler (:6543)", + ) + parser.add_argument("--preview-limit", type=int, default=10, help="Sample rows in logs") + parser.add_argument("--dry-run", action="store_true", help="Preview only") + parser.add_argument("--apply", action="store_true", help="Write to OPERATION_DATABASE_URL") + raise SystemExit(asyncio.run(run(parser.parse_args()))) + + +if __name__ == "__main__": + main() diff --git a/packages/ai-server/scripts/backfill_solution_categories.py b/packages/ai-server/scripts/backfill_solution_categories.py new file mode 100644 index 000000000..ff677cbdc --- /dev/null +++ b/packages/ai-server/scripts/backfill_solution_categories.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +# pyright: reportMissingImports=false +"""Backfill missing solution categories from each linked Look's Cody analysis. + +This is a one-off, schema-free metadata backfill. It only updates solutions +whose metadata.category is absent, merges new keys into metadata JSONB, and +never overwrites title, URL, price, brand, or an existing category. + +A category is applied only when: +1. the solution title has a high-confidence category keyword, and +2. the linked post's cody_analysis contains the same category. + +Usage: + cd packages/ai-server + uv run python scripts/backfill_solution_categories.py \ + --db-env ../../.env.backend.prod --brand-id --dry-run + + uv run python scripts/backfill_solution_categories.py \ + --db-env ../../.env.backend.prod --brand-id --apply +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import re +from collections.abc import Iterable +from pathlib import Path +from typing import Any + +logger = logging.getLogger("backfill-solution-categories") + +_CATEGORY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( + ( + "footwear", + re.compile( + r"\b(sneaker|sneakers|shoe|shoes|boot|boots|loafer|loafer|sandal|sandals|heel|heels|slide|slides)\b", + re.IGNORECASE, + ), + ), + ( + "bag", + re.compile( + r"\b(bag|tote|pouch|clutch|backpack|shoulder bag|crossbody|satchel)\b", + re.IGNORECASE, + ), + ), + ( + "outerwear", + re.compile( + r"\b(jacket|coat|blazer|parka|cardigan|windbreaker|bomber|vest)\b", + re.IGNORECASE, + ), + ), + ( + "dress", + re.compile(r"\b(dress|gown|jumpsuit|romper)\b", re.IGNORECASE), + ), + ( + "bottom", + re.compile( + r"\b(pant|pants|trouser|trousers|jeans|denim|shorts|skirt|bermuda|leggings)\b", + re.IGNORECASE, + ), + ), + ( + "top", + re.compile( + r"\b(t-?shirt|tee|shirt|top|sweater|hoodie|sweatshirt|knit|tank|polo|blouse)\b", + re.IGNORECASE, + ), + ), + ( + "accessory", + re.compile( + r"\b(belt|hat|cap|scarf|glove|glasses|sunglasses|watch|ring|necklace|bracelet|earring)\b", + re.IGNORECASE, + ), + ), +) + +_LOOK_CATEGORY_ALIASES = { + "fashion-top": "top", + "fashion-bottom": "bottom", + "footwear": "footwear", + "bag": "bag", + "accessory": "accessory", + "accessory-jewelry": "accessory", + "accessory-watch": "accessory", + "outerwear": "outerwear", + "dress": "dress", +} + +_CANDIDATES_SQL = """ +SELECT + s.id, + s.title, + s.metadata, + p.id AS post_id, + p.cody_analysis +FROM public.solutions s +JOIN public.spots sp ON sp.id = s.spot_id +JOIN public.posts p ON p.id = sp.post_id +WHERE s.status = 'active' + AND COALESCE(NULLIF(btrim(s.metadata ->> 'category'), ''), '') = '' + AND ($1::uuid IS NULL OR s.brand_id = $1) +ORDER BY s.created_at DESC +""" + +_UPDATE_SQL = """ +UPDATE public.solutions +SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() +WHERE id = $1 + AND COALESCE(NULLIF(btrim(metadata ->> 'category'), ''), '') = '' +""" + + +def parse_env_file(path: Path) -> dict[str, str]: + if not path.exists(): + raise FileNotFoundError(f"env file not found: {path}") + values: dict[str, str] = {} + for raw in path.read_text().splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + values[key.strip()] = value.strip().strip('"').strip("'") + return values + + +def infer_title_category(title: str) -> str | None: + for category, pattern in _CATEGORY_PATTERNS: + if pattern.search(title): + return category + return None + + +def look_categories(cody_analysis: Any) -> set[str]: + if isinstance(cody_analysis, str): + try: + cody_analysis = json.loads(cody_analysis) + except json.JSONDecodeError: + return set() + if not isinstance(cody_analysis, dict): + return set() + items = cody_analysis.get("items") + if not isinstance(items, list): + return set() + + categories: set[str] = set() + for item in items: + if not isinstance(item, dict): + continue + category = item.get("category") + if isinstance(category, str): + normalized = _LOOK_CATEGORY_ALIASES.get(category) + if normalized: + categories.add(normalized) + + path = item.get("category_path") + if isinstance(path, dict): + item_type = path.get("type") + if isinstance(item_type, str) and item_type in _LOOK_CATEGORY_ALIASES: + categories.add(_LOOK_CATEGORY_ALIASES[item_type]) + elif isinstance(item_type, str) and item_type in { + "top", + "bottom", + "footwear", + "bag", + "accessory", + "outerwear", + "dress", + }: + categories.add(item_type) + return categories + + +def classify(rows: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + changes: list[dict[str, Any]] = [] + for row in rows: + title = str(row["title"]) + category = infer_title_category(title) + categories = look_categories(row.get("cody_analysis")) + if not category or category not in categories: + continue + changes.append( + { + "id": str(row["id"]), + "title": title, + "post_id": str(row["post_id"]), + "category": category, + "look_categories": sorted(categories), + "metadata_patch": { + "category": category, + "category_source": "look_analysis_title_backfill", + "category_confidence": 0.95, + }, + } + ) + return changes + + +async def run(args: argparse.Namespace) -> int: + import asyncpg + + db_url = args.db_url + if not db_url and args.db_env: + db_url = parse_env_file(Path(args.db_env).expanduser())[ + "OPERATION_DATABASE_URL" + ] + if not db_url: + db_url = os.environ.get("OPERATION_DATABASE_URL", "") + if not db_url: + raise ValueError("Provide --db-url, --db-env, or OPERATION_DATABASE_URL") + + resolved_url = db_url.replace(":5432/", ":6543/", 1) + conn = await asyncpg.connect( + resolved_url, + ssl=None if "localhost" in resolved_url or "127.0.0.1" in resolved_url else "require", + statement_cache_size=0 if "pooler.supabase.com" in resolved_url else 100, + ) + try: + rows = await conn.fetch(_CANDIDATES_SQL, args.brand_id) + changes = classify([dict(row) for row in rows]) + logger.info( + "examined %s uncategorized active solutions; %s high-confidence updates", + len(rows), + len(changes), + ) + for change in changes[: args.preview_limit]: + logger.info( + "%s → %s (%s)", + change["title"], + change["category"], + change["post_id"], + ) + + if args.audit_file: + audit_path = Path(args.audit_file).expanduser() + audit_path.parent.mkdir(parents=True, exist_ok=True) + audit_path.write_text(json.dumps(changes, ensure_ascii=False, indent=2)) + logger.info("wrote audit preview to %s", audit_path) + + if not args.apply: + logger.info("dry-run complete; rerun with --apply to persist changes") + return 0 + + async with conn.transaction(): + updated = 0 + for change in changes: + result = await conn.execute( + _UPDATE_SQL, + change["id"], + json.dumps(change["metadata_patch"]), + ) + updated += int(result.rsplit(" ", 1)[-1]) + logger.info("updated %s solutions", updated) + return 0 + finally: + await conn.close() + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--db-url") + parser.add_argument("--db-env") + parser.add_argument("--brand-id", help="Optional brand UUID to scope the backfill") + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes only (the default unless --apply is provided)", + ) + parser.add_argument("--apply", action="store_true") + parser.add_argument("--preview-limit", type=int, default=25) + parser.add_argument( + "--audit-file", + default="tmp/solution-category-backfill-preview.json", + ) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") + return asyncio.run(run(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json index e7bde3951..7ee2ad2cf 100644 --- a/packages/api-server/openapi.json +++ b/packages/api-server/openapi.json @@ -1766,6 +1766,52 @@ ] } }, + "/api/v1/artists/{id}": { + "get": { + "tags": [ + "catalog-entities" + ], + "operationId": "get_artist", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Artist ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "locale", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Artist detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArtistDetail" + } + } + } + }, + "404": { + "description": "Artist not found" + } + } + } + }, "/api/v1/auth/password/reset": { "post": { "tags": [ @@ -1991,6 +2037,184 @@ } } }, + "/api/v1/brands/{id}": { + "get": { + "tags": [ + "catalog-entities" + ], + "operationId": "get_brand", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Brand ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "locale", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Brand detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BrandDetail" + } + } + } + }, + "404": { + "description": "Brand not found" + } + } + } + }, + "/api/v1/brands/{id}/looks": { + "get": { + "tags": [ + "catalog-entities" + ], + "operationId": "list_brand_looks", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Brand ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "locale", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Brand looks", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_PostListItem" + } + } + } + }, + "404": { + "description": "Brand not found" + } + } + } + }, + "/api/v1/brands/{id}/solutions": { + "get": { + "tags": [ + "catalog-entities" + ], + "operationId": "list_brand_solutions", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Brand ID", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + }, + { + "name": "locale", + "in": "query", + "required": false, + "schema": { + "type": [ + "string", + "null" + ] + } + } + ], + "responses": { + "200": { + "description": "Brand solutions", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse" + } + } + } + }, + "404": { + "description": "Brand not found" + } + } + } + }, "/api/v1/catalog/items/{item_id}": { "get": { "tags": [ @@ -2749,6 +2973,16 @@ "format": "uuid" } }, + { + "name": "brand_id", + "in": "query", + "description": "브랜드 ID 필터 (active primary solution FK)", + "required": false, + "schema": { + "type": "string", + "format": "uuid" + } + }, { "name": "sort", "in": "query", @@ -7116,6 +7350,72 @@ } } }, + "ArtistDetail": { + "type": "object", + "required": [ + "id", + "name", + "look_count", + "decoded_count", + "created_at", + "updated_at" + ], + "properties": { + "created_at": { + "type": "string", + "format": "date-time" + }, + "decoded_count": { + "type": "integer", + "format": "int64" + }, + "group_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "id": { + "type": "string", + "format": "uuid" + }, + "instagram_username": { + "type": [ + "string", + "null" + ] + }, + "look_count": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "name_en": { + "type": [ + "string", + "null" + ] + }, + "name_ko": { + "type": [ + "string", + "null" + ] + }, + "profile_image_url": { + "type": [ + "string", + "null" + ] + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "AuthTokenResponse": { "type": "object", "required": [ @@ -7352,6 +7652,99 @@ "shopper" ] }, + "BrandContentQuery": { + "type": "object", + "properties": { + "locale": { + "type": [ + "string", + "null" + ] + }, + "page": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "per_page": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "BrandDetail": { + "type": "object", + "required": [ + "id", + "name", + "product_count", + "look_count", + "created_at", + "updated_at" + ], + "properties": { + "country_of_origin": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "instagram_username": { + "type": [ + "string", + "null" + ] + }, + "logo_image_url": { + "type": [ + "string", + "null" + ] + }, + "look_count": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "name_en": { + "type": [ + "string", + "null" + ] + }, + "name_ko": { + "type": [ + "string", + "null" + ] + }, + "product_count": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + }, "BuildStyleDnaDto": { "type": "object", "description": "Style DNA 빌드 요청 — 유저가 picks 단계에서 고른 룩(post) id 들.\n최소 5개 (콜드스타트 온보딩 풀 픽 + 본인 룩 공통). cody_analysis 없는 post 는\n서버가 거른다(가짜 데이터 금지).", @@ -7424,6 +7817,17 @@ } } }, + "CatalogEntityQuery": { + "type": "object", + "properties": { + "locale": { + "type": [ + "string", + "null" + ] + } + } + }, "CatalogItemDetail": { "type": "object", "description": "카탈로그 아이템 단건 상세 — 아이템 detail 페이지(#786 후속: 아이템 좋아요 →\n개인화) 용. 목록/유사도 카드(CatalogSimItem)보다 필드가 조금 더 많다\n(affiliate_url 별도 노출 — product_url 이 없을 때 FE 가 대체 링크로 쓸 수 있게).", @@ -9262,11 +9666,33 @@ "type": "string", "description": "엔티티 타입 (artist | brand | group)" }, + "group_names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "연결된 그룹 표시명 목록" + }, "id": { "type": "string", "format": "uuid", "description": "엔티티 UUID" }, + "logo_image_url": { + "type": [ + "string", + "null" + ], + "description": "브랜드 로고 이미지 URL" + }, + "look_count": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "관련 look 수" + }, "name": { "type": "string", "description": "표시명 (locale 기준)" @@ -9284,6 +9710,21 @@ "null" ], "description": "한국어 이름" + }, + "product_count": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "관련 product/solution 수" + }, + "profile_image_url": { + "type": [ + "string", + "null" + ], + "description": "아티스트 프로필 이미지 URL" } } }, @@ -10406,6 +10847,24 @@ } } }, + "PaginatedResponse": { + "type": "object", + "required": [ + "data", + "pagination" + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SolutionListItem" + } + }, + "pagination": { + "$ref": "#/components/schemas/PaginationMeta" + } + } + }, "PaginatedResponse_AdminSpotListItem": { "type": "object", "required": [ @@ -10807,6 +11266,20 @@ "format": "uuid", "description": "브랜드 ID (warehouse FK, nullable)" }, + "category": { + "type": [ + "string", + "null" + ], + "description": "상품 카테고리 (catalog 우선, legacy metadata fallback)" + }, + "category_path": { + "type": [ + "string", + "null" + ], + "description": "카테고리 전체 경로 (e.g. \"Clothing > Pants\")" + }, "created_at": { "type": "string", "format": "date-time", @@ -10849,6 +11322,13 @@ ], "description": "원본 상품 URL (Shop the Look 링크용)" }, + "product_type": { + "type": [ + "string", + "null" + ], + "description": "상품 세부 유형 (e.g. \"Jeans\")" + }, "served_market": { "type": [ "string", @@ -10856,6 +11336,13 @@ ], "description": "서빙된 market (catalog variant 픽 결과; non-catalog → null)" }, + "subcategory": { + "type": [ + "string", + "null" + ], + "description": "Look spot에서 지정한 상품 세부 카테고리" + }, "thumbnail_url": { "type": [ "string", @@ -13111,6 +13598,20 @@ "format": "uuid", "description": "브랜드 ID (warehouse FK, nullable)" }, + "category": { + "type": [ + "string", + "null" + ], + "description": "상품 카테고리 (catalog 우선, legacy metadata fallback)" + }, + "category_path": { + "type": [ + "string", + "null" + ], + "description": "카테고리 전체 경로 (e.g. \"Clothing > Pants\")" + }, "created_at": { "type": "string", "format": "date-time", @@ -13153,6 +13654,13 @@ ], "description": "원본 상품 URL (Shop the Look 링크용)" }, + "product_type": { + "type": [ + "string", + "null" + ], + "description": "상품 세부 유형 (e.g. \"Jeans\")" + }, "served_market": { "type": [ "string", @@ -13160,6 +13668,13 @@ ], "description": "서빙된 market (catalog variant 픽 결과; non-catalog → null)" }, + "subcategory": { + "type": [ + "string", + "null" + ], + "description": "Look spot에서 지정한 상품 세부 카테고리" + }, "thumbnail_url": { "type": [ "string", @@ -15817,6 +16332,10 @@ "name": "search", "description": "Search and feed" }, + { + "name": "catalog-entities", + "description": "Catalog artist and brand discovery" + }, { "name": "rankings", "description": "Rankings" diff --git a/packages/api-server/src/batch/entity_reindex.rs b/packages/api-server/src/batch/entity_reindex.rs index e078d5692..d9b51fa3f 100644 --- a/packages/api-server/src/batch/entity_reindex.rs +++ b/packages/api-server/src/batch/entity_reindex.rs @@ -21,13 +21,15 @@ use tracing::{error, info, warn}; use uuid::Uuid; // json! 매크로 전개에 unwrap 포함 — entity_to_document는 정적 필드만 직렬화 -#[allow(clippy::disallowed_methods)] +#[allow(clippy::disallowed_methods, clippy::too_many_arguments)] fn entity_to_document( kind: EntityKind, id: Uuid, name_json: Option<&Value>, name_en: Option, name_ko: Option, + profile_image_url: Option, + logo_image_url: Option, aliases: &[String], ) -> Value { let (en, ko) = entity_names(name_json, name_en, name_ko); @@ -66,6 +68,8 @@ fn entity_to_document( "name_ko": ko, "name_en": en, "name_ja": ja, + "profile_image_url": profile_image_url, + "logo_image_url": logo_image_url, "aliases": aliases, }) } @@ -99,17 +103,50 @@ pub async fn upsert_entity_search_doc( EntityKind::Artist => entities::Artists::find_by_id(entity_id) .one(db) .await - .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))), + .map(|o| { + o.map(|m| { + ( + m.name, + m.name_en, + m.name_ko, + m.profile_image_url, + None, + m.aliases, + ) + }) + }), EntityKind::Brand => entities::Brands::find_by_id(entity_id) .one(db) .await - .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))), + .map(|o| { + o.map(|m| { + ( + m.name, + m.name_en, + m.name_ko, + None, + m.logo_image_url, + m.aliases, + ) + }) + }), EntityKind::Group => entities::Groups::find_by_id(entity_id) .one(db) .await - .map(|o| o.map(|m| (m.name, m.name_en, m.name_ko, m.aliases))), + .map(|o| { + o.map(|m| { + ( + m.name, + m.name_en, + m.name_ko, + m.profile_image_url, + None, + m.aliases, + ) + }) + }), }; - let (name, name_en, name_ko, aliases) = match row { + let (name, name_en, name_ko, profile_image_url, logo_image_url, aliases) = match row { Ok(Some(t)) => t, Ok(None) => { warn!(kind = kind.as_str(), entity_id = %entity_id, "Entity not found for search doc upsert"); @@ -129,6 +166,8 @@ pub async fn upsert_entity_search_doc( name.as_ref().map(|v| v as &Value), name_en, name_ko, + profile_image_url, + logo_image_url, &aliases, ); let pk = format!("{}_{}", kind.as_str(), entity_id); @@ -157,6 +196,8 @@ pub async fn run(state: Arc) -> Result<(), Box) -> Result<(), Box) -> Result<(), Box PostListQuery { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "popular".to_string(), page: 1, per_page, diff --git a/packages/api-server/src/domains/catalog_entities/dto.rs b/packages/api-server/src/domains/catalog_entities/dto.rs new file mode 100644 index 000000000..294b260a3 --- /dev/null +++ b/packages/api-server/src/domains/catalog_entities/dto.rs @@ -0,0 +1,74 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::{domains::solutions::dto::SolutionListItem, utils::pagination::PaginatedResponse}; + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ArtistDetail { + pub id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_ko: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_en: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instagram_username: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_names: Vec, + pub look_count: i64, + pub decoded_count: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct BrandDetail { + pub id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_ko: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name_en: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instagram_username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub country_of_origin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub product_count: i64, + pub look_count: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] +pub struct CatalogEntityQuery { + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] +pub struct BrandContentQuery { + #[serde(default = "default_page")] + pub page: u64, + #[serde(default = "default_per_page")] + pub per_page: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +fn default_page() -> u64 { + 1 +} + +fn default_per_page() -> u64 { + 20 +} + +pub type BrandSolutionsResponse = PaginatedResponse; diff --git a/packages/api-server/src/domains/catalog_entities/handlers.rs b/packages/api-server/src/domains/catalog_entities/handlers.rs new file mode 100644 index 000000000..5b5045f70 --- /dev/null +++ b/packages/api-server/src/domains/catalog_entities/handlers.rs @@ -0,0 +1,134 @@ +use axum::{ + extract::{Path, Query, State}, + routing::get, + Json, Router, +}; +use uuid::Uuid; + +use crate::{ + config::AppState, error::AppResult, market::RequestMarket, utils::pagination::PaginatedResponse, +}; + +use super::{ + dto::{ + ArtistDetail, BrandContentQuery, BrandDetail, BrandSolutionsResponse, CatalogEntityQuery, + }, + service, +}; + +#[utoipa::path( + get, + path = "/api/v1/artists/{id}", + tag = "catalog-entities", + params( + ("id" = Uuid, Path, description = "Artist ID"), + CatalogEntityQuery + ), + responses( + (status = 200, description = "Artist detail", body = ArtistDetail), + (status = 404, description = "Artist not found") + ) +)] +pub async fn get_artist( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> AppResult> { + Ok(Json( + service::get_artist_detail(state.db.as_ref(), id, query.locale.as_deref()).await?, + )) +} + +#[utoipa::path( + get, + path = "/api/v1/brands/{id}", + tag = "catalog-entities", + params( + ("id" = Uuid, Path, description = "Brand ID"), + CatalogEntityQuery + ), + responses( + (status = 200, description = "Brand detail", body = BrandDetail), + (status = 404, description = "Brand not found") + ) +)] +pub async fn get_brand( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> AppResult> { + Ok(Json( + service::get_brand_detail(state.db.as_ref(), id, query.locale.as_deref()).await?, + )) +} + +#[utoipa::path( + get, + path = "/api/v1/brands/{id}/solutions", + tag = "catalog-entities", + params( + ("id" = Uuid, Path, description = "Brand ID"), + BrandContentQuery + ), + responses( + (status = 200, description = "Brand solutions", body = BrandSolutionsResponse), + (status = 404, description = "Brand not found") + ) +)] +pub async fn list_brand_solutions( + State(state): State, + RequestMarket(market): RequestMarket, + Path(id): Path, + Query(query): Query, +) -> AppResult> { + Ok(Json( + service::list_brand_solutions( + state.db.as_ref(), + state.assets_db.as_ref(), + id, + query, + &market, + ) + .await?, + )) +} + +#[utoipa::path( + get, + path = "/api/v1/brands/{id}/looks", + tag = "catalog-entities", + params( + ("id" = Uuid, Path, description = "Brand ID"), + BrandContentQuery + ), + responses( + (status = 200, description = "Brand looks", body = PaginatedResponse), + (status = 404, description = "Brand not found") + ) +)] +pub async fn list_brand_looks( + State(state): State, + Path(id): Path, + Query(query): Query, +) -> AppResult>> { + Ok(Json( + service::list_brand_looks(state.db.as_ref(), id, query).await?, + )) +} + +pub fn artists_router() -> Router { + Router::new().route("/{id}", get(get_artist)) +} + +pub fn brands_router() -> Router { + Router::new() + .route("/{id}", get(get_brand)) + .route("/{id}/solutions", get(list_brand_solutions)) + .route("/{id}/looks", get(list_brand_looks)) +} + +pub fn router() -> Router { + Router::new() + .nest("/artists", artists_router()) + .nest("/brands", brands_router()) +} diff --git a/packages/api-server/src/domains/catalog_entities/mod.rs b/packages/api-server/src/domains/catalog_entities/mod.rs new file mode 100644 index 000000000..530e6ac8b --- /dev/null +++ b/packages/api-server/src/domains/catalog_entities/mod.rs @@ -0,0 +1,5 @@ +pub mod dto; +pub mod handlers; +pub mod service; + +pub use handlers::router; diff --git a/packages/api-server/src/domains/catalog_entities/service.rs b/packages/api-server/src/domains/catalog_entities/service.rs new file mode 100644 index 000000000..265ceea04 --- /dev/null +++ b/packages/api-server/src/domains/catalog_entities/service.rs @@ -0,0 +1,251 @@ +use sea_orm::{ + ColumnTrait, DatabaseBackend, DatabaseConnection, EntityTrait, FromQueryResult, PaginatorTrait, + QueryFilter, Statement, +}; +use uuid::Uuid; + +use crate::{ + domains::{ + posts::{dto::PostListQuery, service as posts_service}, + solutions::service as solutions_service, + }, + entities, + error::{AppError, AppResult}, + utils::pagination::{PaginatedResponse, Pagination}, +}; + +use super::dto::{ArtistDetail, BrandContentQuery, BrandDetail, BrandSolutionsResponse}; + +#[derive(Debug, FromQueryResult)] +struct ArtistDetailRow { + id: Uuid, + name: String, + name_ko: Option, + name_en: Option, + profile_image_url: Option, + instagram_username: Option, + group_names: Vec, + look_count: i64, + decoded_count: i64, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +#[derive(Debug, FromQueryResult)] +struct BrandDetailRow { + id: Uuid, + name: String, + name_ko: Option, + name_en: Option, + logo_image_url: Option, + instagram_username: Option, + country_of_origin: Option, + description: Option, + product_count: i64, + look_count: i64, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} + +pub async fn get_artist_detail( + db: &DatabaseConnection, + id: Uuid, + locale: Option<&str>, +) -> AppResult { + let locale = normalize_locale(locale); + let sql = r#" + SELECT + a.id, + COALESCE(a.name ->> $2, a.name_ko, a.name_en, a.name ->> 'ko', a.name ->> 'en') AS name, + a.name_ko, + a.name_en, + a.profile_image_url, + ia.username AS instagram_username, + COALESCE( + array_remove(array_agg(DISTINCT COALESCE(g.name ->> $2, g.name_ko, g.name_en)), NULL), + ARRAY[]::text[] + ) AS group_names, + COUNT(DISTINCT p.id) FILTER ( + WHERE p.status = 'active' + AND (p.post_type IS NULL OR p.post_type <> 'try') + AND (p.post_type IS NULL OR p.post_type <> 'decode') + )::bigint AS look_count, + COUNT(DISTINCT p.id) FILTER ( + WHERE p.status = 'active' + AND p.post_type = 'decode' + )::bigint AS decoded_count, + a.created_at, + a.updated_at + FROM public.artists a + LEFT JOIN public.instagram_accounts ia ON ia.id = a.primary_instagram_account_id + LEFT JOIN public.group_members gm ON gm.artist_id = a.id AND COALESCE(gm.is_active, true) + LEFT JOIN public.groups g ON g.id = gm.group_id + LEFT JOIN public.posts p ON p.artist_id = a.id + WHERE a.id = $1 + GROUP BY a.id, ia.username + "#; + + let row = ArtistDetailRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + sql, + vec![id.into(), locale.into()], + )) + .one(db) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("Artist not found: {}", id)))?; + + Ok(ArtistDetail { + id: row.id, + name: row.name, + name_ko: row.name_ko, + name_en: row.name_en, + profile_image_url: row.profile_image_url, + instagram_username: row.instagram_username, + group_names: row.group_names, + look_count: row.look_count, + decoded_count: row.decoded_count, + created_at: row.created_at.with_timezone(&chrono::Utc), + updated_at: row.updated_at.with_timezone(&chrono::Utc), + }) +} + +pub async fn get_brand_detail( + db: &DatabaseConnection, + id: Uuid, + locale: Option<&str>, +) -> AppResult { + let locale = normalize_locale(locale); + let sql = r#" + SELECT + b.id, + COALESCE(b.name ->> $2, b.name_ko, b.name_en, b.name ->> 'ko', b.name ->> 'en') AS name, + b.name_ko, + b.name_en, + b.logo_image_url, + ia.username AS instagram_username, + COALESCE( + b.metadata ->> 'country_of_origin', + b.metadata ->> 'country', + b.metadata ->> 'country_code' + ) AS country_of_origin, + COALESCE( + NULLIF(btrim(b.metadata -> 'brand_identity' -> 'summary' ->> $2), ''), + NULLIF(btrim(b.metadata ->> 'description'), '') + ) AS description, + COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'active')::bigint AS product_count, + COUNT(DISTINCT p.id) FILTER ( + WHERE p.status = 'active' + AND (p.post_type IS NULL OR p.post_type <> 'try') + AND (p.post_type IS NULL OR p.post_type <> 'decode') + )::bigint AS look_count, + b.created_at, + b.updated_at + FROM public.brands b + LEFT JOIN public.instagram_accounts ia ON ia.id = b.primary_instagram_account_id + LEFT JOIN public.solutions s ON s.brand_id = b.id + LEFT JOIN public.spots sp ON sp.id = s.spot_id + LEFT JOIN public.posts p ON p.id = sp.post_id + WHERE b.id = $1 + GROUP BY b.id, ia.username + "#; + + let row = BrandDetailRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + sql, + vec![id.into(), locale.into()], + )) + .one(db) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::NotFound(format!("Brand not found: {}", id)))?; + + Ok(BrandDetail { + id: row.id, + name: row.name, + name_ko: row.name_ko, + name_en: row.name_en, + logo_image_url: row.logo_image_url, + instagram_username: row.instagram_username, + country_of_origin: row.country_of_origin, + description: row.description, + product_count: row.product_count, + look_count: row.look_count, + created_at: row.created_at.with_timezone(&chrono::Utc), + updated_at: row.updated_at.with_timezone(&chrono::Utc), + }) +} + +pub async fn list_brand_solutions( + db: &DatabaseConnection, + assets_db: &DatabaseConnection, + brand_id: Uuid, + query: BrandContentQuery, + market: &str, +) -> AppResult { + ensure_brand_exists(db, brand_id).await?; + let pagination = Pagination::new(query.page, query.per_page); + solutions_service::list_solutions_by_brand_id( + db, + assets_db, + brand_id, + pagination, + market, + query.locale.as_deref(), + ) + .await +} + +pub async fn list_brand_looks( + db: &DatabaseConnection, + brand_id: Uuid, + query: BrandContentQuery, +) -> AppResult> { + ensure_brand_exists(db, brand_id).await?; + posts_service::list_posts( + db, + PostListQuery { + artist_name: None, + group_name: None, + context: None, + mood: None, + category: None, + user_id: None, + artist_id: None, + group_id: None, + brand_id: Some(brand_id), + sort: "recent".to_string(), + page: query.page, + per_page: query.per_page, + has_solutions: None, + has_magazine: None, + color: None, + season: None, + min_solutions: None, + complete_outfit: None, + include_magazine_items: None, + viewer_id: None, + balance_gender: None, + }, + ) + .await +} + +async fn ensure_brand_exists(db: &DatabaseConnection, brand_id: Uuid) -> AppResult<()> { + let exists = entities::Brands::find() + .filter(entities::brands::Column::Id.eq(brand_id)) + .count(db) + .await + .map_err(AppError::DatabaseError)?; + if exists == 0 { + return Err(AppError::NotFound(format!("Brand not found: {}", brand_id))); + } + Ok(()) +} + +fn normalize_locale(locale: Option<&str>) -> &'static str { + match locale { + Some("en") => "en", + _ => "ko", + } +} diff --git a/packages/api-server/src/domains/mod.rs b/packages/api-server/src/domains/mod.rs index 00b94df99..91e18d2ca 100644 --- a/packages/api-server/src/domains/mod.rs +++ b/packages/api-server/src/domains/mod.rs @@ -6,6 +6,7 @@ pub mod admin; pub mod auth; pub mod badges; pub mod catalog; +pub mod catalog_entities; pub mod categories; pub mod comments; pub mod content_studio; diff --git a/packages/api-server/src/domains/posts/dto.rs b/packages/api-server/src/domains/posts/dto.rs index 621c85124..9e825a6fc 100644 --- a/packages/api-server/src/domains/posts/dto.rs +++ b/packages/api-server/src/domains/posts/dto.rs @@ -244,6 +244,10 @@ pub struct PostListQuery { #[serde(skip_serializing_if = "Option::is_none")] pub group_id: Option, + /// 브랜드 ID 필터 (warehouse FK, active solution 기준) + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_id: Option, + /// 정렬 방식 #[serde(default = "default_sort")] pub sort: String, // 'recent' | 'popular' | 'trending' diff --git a/packages/api-server/src/domains/posts/handlers.rs b/packages/api-server/src/domains/posts/handlers.rs index 3eac9deca..a8c358767 100644 --- a/packages/api-server/src/domains/posts/handlers.rs +++ b/packages/api-server/src/domains/posts/handlers.rs @@ -213,6 +213,7 @@ pub async fn create_post_with_solutions( ("user_id" = Option, Query, description = "사용자 ID 필터"), ("artist_id" = Option, Query, description = "아티스트 ID 필터 (warehouse FK)"), ("group_id" = Option, Query, description = "그룹 ID 필터 (warehouse FK)"), + ("brand_id" = Option, Query, description = "브랜드 ID 필터 (active primary solution FK)"), ("sort" = Option, Query, description = "정렬: recent | popular | trending"), ("page" = Option, Query, description = "페이지 번호"), ("per_page" = Option, Query, description = "페이지당 개수"), diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 0df3fc458..c7b78e1f3 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -1706,6 +1706,29 @@ fn build_post_list_select( select = select.filter(Column::GroupId.eq(group_id)); } + if let Some(brand_id) = query.brand_id { + use sea_orm::sea_query::{Alias, Query as SeaQuery}; + let subquery = SeaQuery::select() + .distinct() + .column((Alias::new("spots"), Alias::new("post_id"))) + .from(Alias::new("spots")) + .inner_join( + Alias::new("solutions"), + sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("spot_id"))) + .equals((Alias::new("spots"), Alias::new("id"))), + ) + .and_where( + sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("status"))) + .eq(crate::constants::solution_status::ACTIVE), + ) + .and_where( + sea_orm::sea_query::Expr::col((Alias::new("solutions"), Alias::new("brand_id"))) + .eq(brand_id), + ) + .to_owned(); + select = select.filter(Column::Id.in_subquery(subquery)); + } + if let Some(ref context) = query.context { select = select.filter(Column::Context.eq(context)); } @@ -5395,6 +5418,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -5442,6 +5466,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "popular".to_string(), page: 1, per_page: 20, @@ -5485,6 +5510,7 @@ mod tests { user_id: Some(Uuid::new_v4()), artist_id: Some(Uuid::new_v4()), group_id: Some(Uuid::new_v4()), + brand_id: None, sort: "popular".to_string(), page: 1, per_page: 20, @@ -5526,6 +5552,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "trending".to_string(), page: 1, per_page: 20, @@ -5572,6 +5599,7 @@ mod tests { user_id: Some(Uuid::new_v4()), artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -5618,6 +5646,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "popular".to_string(), page: 1, per_page: 20, @@ -5663,6 +5692,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "trending".to_string(), page: 1, per_page: 20, @@ -5907,6 +5937,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -5951,6 +5982,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -6015,6 +6047,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -6145,6 +6178,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, diff --git a/packages/api-server/src/domains/posts/tests.rs b/packages/api-server/src/domains/posts/tests.rs index 56c087b62..9278bc5d7 100644 --- a/packages/api-server/src/domains/posts/tests.rs +++ b/packages/api-server/src/domains/posts/tests.rs @@ -409,6 +409,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -700,6 +701,7 @@ mod tests { user_id: Some(test_uuid(10)), artist_id: Some(test_uuid(20)), group_id: Some(test_uuid(30)), + brand_id: None, sort: "popular".to_string(), page: 1, per_page: 20, @@ -746,6 +748,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "trending".to_string(), page: 1, per_page: 20, @@ -1252,6 +1255,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, @@ -1474,6 +1478,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "xxx-unknown".to_string(), page: 1, per_page: 20, @@ -1703,6 +1708,7 @@ mod tests { user_id: None, artist_id: None, group_id: None, + brand_id: None, sort: "recent".to_string(), page: 1, per_page: 20, diff --git a/packages/api-server/src/domains/search/dto.rs b/packages/api-server/src/domains/search/dto.rs index 64afd3057..dfb5b5932 100644 --- a/packages/api-server/src/domains/search/dto.rs +++ b/packages/api-server/src/domains/search/dto.rs @@ -277,6 +277,21 @@ pub struct EntitySearchHit { /// 영어 이름 #[serde(skip_serializing_if = "Option::is_none")] pub name_en: Option, + /// 아티스트 프로필 이미지 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_image_url: Option, + /// 브랜드 로고 이미지 URL + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_image_url: Option, + /// 연결된 그룹 표시명 목록 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub group_names: Vec, + /// 관련 look 수 + #[serde(skip_serializing_if = "Option::is_none")] + pub look_count: Option, + /// 관련 product/solution 수 + #[serde(skip_serializing_if = "Option::is_none")] + pub product_count: Option, } /// 엔티티 검색 응답 diff --git a/packages/api-server/src/domains/search/service.rs b/packages/api-server/src/domains/search/service.rs index 8d039fe02..f35b66f29 100644 --- a/packages/api-server/src/domains/search/service.rs +++ b/packages/api-server/src/domains/search/service.rs @@ -3,8 +3,8 @@ //! 검색 비즈니스 로직 use sea_orm::{ - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, FromQueryResult, QueryFilter, - QueryOrder, QuerySelect, Set, Statement, + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, + FromQueryResult, QueryFilter, QueryOrder, QuerySelect, Set, Statement, }; use uuid::Uuid; @@ -383,6 +383,19 @@ impl SearchService { name, name_ko, name_en, + profile_image_url: doc["profile_image_url"].as_str().map(str::to_string), + logo_image_url: doc["logo_image_url"].as_str().map(str::to_string), + group_names: doc["group_names"] + .as_array() + .map(|values| { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(), + look_count: doc["look_count"].as_i64(), + product_count: doc["product_count"].as_i64(), }) } @@ -404,10 +417,11 @@ impl SearchService { let empty = vec![]; let hits = results["hits"].as_array().unwrap_or(&empty); - let data: Vec = hits + let mut data: Vec = hits .iter() .filter_map(|h| Self::map_entity_hit(h, locale)) .collect(); + Self::enrich_entity_hits(state.db.as_ref(), &mut data, locale).await?; Ok(EntitySearchResponse { data, @@ -415,6 +429,147 @@ impl SearchService { took_ms: start.elapsed().as_millis() as u64, }) } + + async fn enrich_entity_hits( + db: &DatabaseConnection, + hits: &mut [EntitySearchHit], + locale: &str, + ) -> AppResult<()> { + let artist_ids: Vec = hits + .iter() + .filter(|hit| hit.entity_type == "artist") + .map(|hit| hit.id) + .collect(); + let brand_ids: Vec = hits + .iter() + .filter(|hit| hit.entity_type == "brand") + .map(|hit| hit.id) + .collect(); + + let mut artists = HashMap::new(); + if !artist_ids.is_empty() { + let artist_placeholders: Vec = + (1..=artist_ids.len()).map(|i| format!("${}", i)).collect(); + let locale_placeholder = artist_ids.len() + 1; + let mut values: Vec = artist_ids.into_iter().map(Into::into).collect(); + values.push(locale.into()); + let rows = db + .query_all(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + format!( + r#" + SELECT + a.id, + a.profile_image_url, + COALESCE( + array_remove(array_agg(DISTINCT COALESCE(g.name ->> ${}, g.name_ko, g.name_en)), NULL), + ARRAY[]::text[] + ) AS group_names, + COUNT(DISTINCT p.id) FILTER ( + WHERE p.status = 'active' + AND (p.post_type IS NULL OR p.post_type <> 'try') + AND (p.post_type IS NULL OR p.post_type <> 'decode') + )::bigint AS look_count + FROM public.artists a + LEFT JOIN public.group_members gm ON gm.artist_id = a.id AND COALESCE(gm.is_active, true) + LEFT JOIN public.groups g ON g.id = gm.group_id + LEFT JOIN public.posts p ON p.artist_id = a.id + WHERE a.id IN ({}) + GROUP BY a.id + "#, + locale_placeholder, + artist_placeholders.join(", ") + ), + values, + )) + .await + .map_err(AppError::DatabaseError)?; + for row in rows { + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let profile_image_url: Option = row + .try_get("", "profile_image_url") + .map_err(AppError::DatabaseError)?; + let group_names: Vec = row + .try_get("", "group_names") + .map_err(AppError::DatabaseError)?; + let look_count: i64 = row + .try_get("", "look_count") + .map_err(AppError::DatabaseError)?; + artists.insert(id, (profile_image_url, group_names, look_count)); + } + } + + let mut brands = HashMap::new(); + if !brand_ids.is_empty() { + let brand_placeholders: Vec = + (1..=brand_ids.len()).map(|i| format!("${}", i)).collect(); + let values: Vec = brand_ids.into_iter().map(Into::into).collect(); + let rows = db + .query_all(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + format!( + r#" + SELECT + b.id, + b.logo_image_url, + COUNT(DISTINCT s.id) FILTER (WHERE s.status = 'active')::bigint AS product_count, + COUNT(DISTINCT p.id) FILTER ( + WHERE p.status = 'active' + AND (p.post_type IS NULL OR p.post_type <> 'try') + AND (p.post_type IS NULL OR p.post_type <> 'decode') + )::bigint AS look_count + FROM public.brands b + LEFT JOIN public.solutions s ON s.brand_id = b.id + LEFT JOIN public.spots sp ON sp.id = s.spot_id + LEFT JOIN public.posts p ON p.id = sp.post_id + WHERE b.id IN ({}) + GROUP BY b.id + "#, + brand_placeholders.join(", ") + ), + values, + )) + .await + .map_err(AppError::DatabaseError)?; + for row in rows { + let id: Uuid = row.try_get("", "id").map_err(AppError::DatabaseError)?; + let logo_image_url: Option = row + .try_get("", "logo_image_url") + .map_err(AppError::DatabaseError)?; + let product_count: i64 = row + .try_get("", "product_count") + .map_err(AppError::DatabaseError)?; + let look_count: i64 = row + .try_get("", "look_count") + .map_err(AppError::DatabaseError)?; + brands.insert(id, (logo_image_url, product_count, look_count)); + } + } + + for hit in hits { + match hit.entity_type.as_str() { + "artist" => { + if let Some((profile_image_url, group_names, look_count)) = artists.get(&hit.id) + { + hit.profile_image_url = + hit.profile_image_url.clone().or(profile_image_url.clone()); + hit.group_names = group_names.clone(); + hit.look_count = Some(*look_count); + } + } + "brand" => { + if let Some((logo_image_url, product_count, look_count)) = brands.get(&hit.id) { + hit.logo_image_url = hit.logo_image_url.clone().or(logo_image_url.clone()); + hit.product_count = Some(*product_count); + hit.look_count = Some(*look_count); + } + } + _ => {} + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/packages/api-server/src/domains/solutions/affiliate_resolve.rs b/packages/api-server/src/domains/solutions/affiliate_resolve.rs index a48a3f740..de93579f1 100644 --- a/packages/api-server/src/domains/solutions/affiliate_resolve.rs +++ b/packages/api-server/src/domains/solutions/affiliate_resolve.rs @@ -8,9 +8,19 @@ use std::collections::HashMap; use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; use uuid::Uuid; -use crate::entities::affiliate_catalog_item_variants as variants; +use crate::entities::{ + affiliate_catalog_item_variants as variants, affiliate_catalog_items as catalog_items, +}; use crate::market::{pick_variant_for_market, ResolvedAffiliate, VariantOffer, FALLBACK_MARKET}; +/// Public taxonomy fields from an affiliate catalog item. +#[derive(Debug, Clone, Default)] +pub struct CatalogTaxonomy { + pub category: Option, + pub category_path: Option, + pub product_type: Option, +} + /// catalog_item_id 들에 대해 user/INTL variant offer 를 한 번에 로드. /// 반환: catalog_item_id(Uuid) → 해당 item 의 후보 offers. async fn load_offers( @@ -51,6 +61,38 @@ async fn load_offers( map } +/// Catalog taxonomy for the given IDs, loaded in one query per result page. +pub async fn load_taxonomies( + assets_db: &DatabaseConnection, + catalog_item_ids: &[Uuid], +) -> HashMap { + if catalog_item_ids.is_empty() { + return HashMap::new(); + } + + let rows = catalog_items::Entity::find() + .filter(catalog_items::Column::Id.is_in(catalog_item_ids.iter().copied())) + .all(assets_db) + .await + .unwrap_or_else(|error| { + tracing::warn!(%error, "assets catalog taxonomy query failed"); + Vec::new() + }); + + rows.into_iter() + .map(|item| { + ( + item.id, + CatalogTaxonomy { + category: item.category, + category_path: item.category_path, + product_type: item.product_type, + }, + ) + }) + .collect() +} + /// 단건 해석. pub async fn resolve_one( assets_db: &DatabaseConnection, diff --git a/packages/api-server/src/domains/solutions/dto.rs b/packages/api-server/src/domains/solutions/dto.rs index 8beeedf10..16afd819b 100644 --- a/packages/api-server/src/domains/solutions/dto.rs +++ b/packages/api-server/src/domains/solutions/dto.rs @@ -232,6 +232,22 @@ pub struct SolutionListItem { #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, + /// 상품 카테고리 (catalog 우선, legacy metadata fallback) + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + + /// Look spot에서 지정한 상품 세부 카테고리 + #[serde(skip_serializing_if = "Option::is_none")] + pub subcategory: Option, + + /// 카테고리 전체 경로 (e.g. "Clothing > Pants") + #[serde(skip_serializing_if = "Option::is_none")] + pub category_path: Option, + + /// 상품 세부 유형 (e.g. "Jeans") + #[serde(skip_serializing_if = "Option::is_none")] + pub product_type: Option, + /// 썸네일 이미지 URL #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_url: Option, diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs index f12567d6d..3c8c91c89 100644 --- a/packages/api-server/src/domains/solutions/service.rs +++ b/packages/api-server/src/domains/solutions/service.rs @@ -9,7 +9,7 @@ use sea_orm::{ use serde_json::Value as JsonValue; use uuid::Uuid; -use crate::entities::users; +use crate::entities::{spots, subcategories, users}; use crate::{ domains::users::service::get_user_by_id, entities::solutions::{ActiveModel, Column, Entity as Solutions}, @@ -20,6 +20,91 @@ use super::dto::{ CreateSolutionDto, SolutionListItem, SolutionResponse, UpdateSolutionDto, VoteStatsDto, }; +fn metadata_text(metadata: Option<&JsonValue>, key: &str) -> Option { + metadata? + .get(key)? + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn taxonomy_fields( + metadata: Option<&JsonValue>, + taxonomy: Option<&super::affiliate_resolve::CatalogTaxonomy>, +) -> (Option, Option, Option) { + ( + taxonomy + .and_then(|value| value.category.clone()) + .or_else(|| metadata_text(metadata, "category")), + taxonomy + .and_then(|value| value.category_path.clone()) + .or_else(|| metadata_text(metadata, "category_path")), + taxonomy + .and_then(|value| value.product_type.clone()) + .or_else(|| metadata_text(metadata, "product_type")) + .or_else(|| metadata_text(metadata, "sub_category")), + ) +} + +fn parse_catalog_item_id(value: &str) -> Option { + Uuid::parse_str(value).ok() +} + +fn localized_name(name: &JsonValue, locale: Option<&str>) -> Option { + let primary_key = if locale == Some("en") { "en" } else { "ko" }; + name.get(primary_key) + .and_then(JsonValue::as_str) + .or_else(|| name.get("en").and_then(JsonValue::as_str)) + .or_else(|| name.get("ko").and_then(JsonValue::as_str)) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +async fn load_spot_subcategories( + db: &DatabaseConnection, + spot_ids: &[Uuid], + locale: Option<&str>, +) -> AppResult> { + if spot_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let spot_rows = spots::Entity::find() + .filter(spots::Column::Id.is_in(spot_ids.iter().copied())) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + let subcategory_ids = spot_rows + .iter() + .filter_map(|spot| spot.subcategory_id) + .collect::>(); + if subcategory_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let names = subcategories::Entity::find() + .filter(subcategories::Column::Id.is_in(subcategory_ids)) + .all(db) + .await + .map_err(AppError::DatabaseError)? + .into_iter() + .filter_map(|subcategory| { + localized_name(&subcategory.name, locale).map(|name| (subcategory.id, name)) + }) + .collect::>(); + + Ok(spot_rows + .into_iter() + .filter_map(|spot| { + spot.subcategory_id + .and_then(|id| names.get(&id).cloned()) + .map(|name| (spot.id, name)) + }) + .collect()) +} + /// Solution 생성 (verify 흐름 전용, 트랜잭션 호환). /// /// raw_posts.parse_result.items[] 의 brand/product/price/thumbnail_url 을 spot @@ -131,6 +216,11 @@ pub async fn list_solutions_by_spot_id( }) .collect(); let resolved = super::affiliate_resolve::resolve_many(assets_db, &rows, user_market).await; + let taxonomy_ids = rows + .iter() + .filter_map(|(_, value, _)| value.as_deref().and_then(parse_catalog_item_id)) + .collect::>(); + let taxonomies = super::affiliate_resolve::load_taxonomies(assets_db, &taxonomy_ids).await; let mut items = Vec::new(); for (solution, user) in solutions { @@ -141,6 +231,13 @@ pub async fn list_solutions_by_spot_id( }; let aff = resolved.get(&solution.id); + let taxonomy = solution + .catalog_item_id + .as_deref() + .and_then(parse_catalog_item_id) + .and_then(|id| taxonomies.get(&id)); + let (category, category_path, product_type) = + taxonomy_fields(solution.metadata.as_ref(), taxonomy); items.push(SolutionListItem { id: solution.id, brand_id: solution.brand_id, @@ -149,6 +246,10 @@ pub async fn list_solutions_by_spot_id( link_type: solution.link_type, title: solution.title, metadata: solution.metadata, + category, + subcategory: None, + category_path, + product_type, thumbnail_url: solution.thumbnail_url, original_url: solution.original_url, affiliate_url: aff.and_then(|r| r.affiliate_url.clone()), @@ -166,6 +267,103 @@ pub async fn list_solutions_by_spot_id( Ok(items) } +/// 브랜드 ID로 공개 Solution 목록 조회 (채택 > 검증 > 최신순) +pub async fn list_solutions_by_brand_id( + db: &DatabaseConnection, + assets_db: &sea_orm::DatabaseConnection, + brand_id: Uuid, + pagination: crate::utils::pagination::Pagination, + user_market: &str, + locale: Option<&str>, +) -> AppResult> { + let select = Solutions::find() + .filter(Column::BrandId.eq(brand_id)) + .filter(Column::Status.eq(crate::constants::solution_status::ACTIVE)) + .order_by(Column::IsAdopted, Order::Desc) + .order_by(Column::IsVerified, Order::Desc) + .order_by_desc(Column::CreatedAt); + + let total = select + .clone() + .count(db) + .await + .map_err(AppError::DatabaseError)?; + + let solutions = select + .offset(pagination.offset()) + .limit(pagination.limit()) + .find_also_related(users::Entity) + .all(db) + .await + .map_err(AppError::DatabaseError)?; + + let rows: Vec<(Uuid, Option, Option)> = solutions + .iter() + .map(|(sol, _)| { + ( + sol.id, + sol.catalog_item_id.clone(), + sol.affiliate_url.clone(), + ) + }) + .collect(); + let resolved = super::affiliate_resolve::resolve_many(assets_db, &rows, user_market).await; + let taxonomy_ids = rows + .iter() + .filter_map(|(_, value, _)| value.as_deref().and_then(parse_catalog_item_id)) + .collect::>(); + let taxonomies = super::affiliate_resolve::load_taxonomies(assets_db, &taxonomy_ids).await; + let spot_ids = solutions + .iter() + .map(|(solution, _)| solution.spot_id) + .collect::>(); + let spot_subcategories = load_spot_subcategories(db, &spot_ids, locale).await?; + + let mut items = Vec::new(); + for (solution, user) in solutions { + let Some(user) = user else { + tracing::warn!("Solution {} has no associated user", solution.id); + continue; + }; + let aff = resolved.get(&solution.id); + let taxonomy = solution + .catalog_item_id + .as_deref() + .and_then(parse_catalog_item_id) + .and_then(|id| taxonomies.get(&id)); + let (category, category_path, product_type) = + taxonomy_fields(solution.metadata.as_ref(), taxonomy); + items.push(SolutionListItem { + id: solution.id, + brand_id: solution.brand_id, + user: user.into(), + match_type: solution.match_type, + link_type: solution.link_type, + title: solution.title, + metadata: solution.metadata, + category, + subcategory: spot_subcategories.get(&solution.spot_id).cloned(), + category_path, + product_type, + thumbnail_url: solution.thumbnail_url, + original_url: solution.original_url, + affiliate_url: aff.and_then(|r| r.affiliate_url.clone()), + vote_stats: VoteStatsDto { + accurate: solution.accurate_count, + different: solution.different_count, + }, + is_verified: solution.is_verified, + is_adopted: solution.is_adopted, + created_at: solution.created_at.with_timezone(&chrono::Utc), + served_market: aff.and_then(|r| r.served_market.clone()), + }); + } + + Ok(crate::utils::pagination::PaginatedResponse::new( + items, pagination, total, + )) +} + /// Solution ID로 조회 pub async fn get_solution_by_id( db: &DatabaseConnection, @@ -369,6 +567,8 @@ pub async fn admin_list_solutions( tracing::warn!("Solution {} has no associated user", solution.id); continue; }; + let (category, category_path, product_type) = + taxonomy_fields(solution.metadata.as_ref(), None); items.push(SolutionListItem { id: solution.id, brand_id: solution.brand_id, @@ -377,6 +577,10 @@ pub async fn admin_list_solutions( link_type: solution.link_type, title: solution.title, metadata: solution.metadata, + category, + subcategory: None, + category_path, + product_type, thumbnail_url: solution.thumbnail_url, original_url: solution.original_url, affiliate_url: solution.affiliate_url, diff --git a/packages/api-server/src/entities/affiliate_catalog_items.rs b/packages/api-server/src/entities/affiliate_catalog_items.rs index f35c4a593..e475295f8 100644 --- a/packages/api-server/src/entities/affiliate_catalog_items.rs +++ b/packages/api-server/src/entities/affiliate_catalog_items.rs @@ -49,6 +49,10 @@ pub struct Model { #[sea_orm(nullable)] pub currency: Option, + /// 공급자 원본 카테고리 (e.g. "Clothing"). + #[sea_orm(nullable)] + pub category: Option, + /// "Clothing > Pants" 형태 text (jsonb 아님 — catalog taxonomy). #[sea_orm(nullable)] pub category_path: Option, diff --git a/packages/api-server/src/openapi.rs b/packages/api-server/src/openapi.rs index c6599c55d..4418ce93e 100644 --- a/packages/api-server/src/openapi.rs +++ b/packages/api-server/src/openapi.rs @@ -51,6 +51,10 @@ use utoipa::OpenApi; crate::domains::catalog::handlers::similar_items, // 아이템 detail 페이지(#786 후속: 아이템 좋아요 → 개인화). crate::domains::catalog::handlers::get_item, + crate::domains::catalog_entities::handlers::get_artist, + crate::domains::catalog_entities::handlers::get_brand, + crate::domains::catalog_entities::handlers::list_brand_solutions, + crate::domains::catalog_entities::handlers::list_brand_looks, crate::domains::posts::handlers::get_post, crate::domains::posts::handlers::update_post, crate::domains::posts::handlers::delete_post, @@ -182,6 +186,7 @@ use utoipa::OpenApi; (name = "feed", description = "Home feed and curations"), (name = "subcategories", description = "Subcategories"), (name = "search", description = "Search and feed"), + (name = "catalog-entities", description = "Catalog artist and brand discovery"), (name = "rankings", description = "Rankings"), (name = "badges", description = "Badges"), (name = "earnings", description = "Earnings and settlements"), @@ -312,6 +317,11 @@ use utoipa::OpenApi; crate::domains::search::dto::EntitySearchQuery, crate::domains::search::dto::EntitySearchResponse, crate::domains::search::dto::EntitySearchHit, + crate::domains::catalog_entities::dto::ArtistDetail, + crate::domains::catalog_entities::dto::BrandDetail, + crate::domains::catalog_entities::dto::CatalogEntityQuery, + crate::domains::catalog_entities::dto::BrandContentQuery, + crate::domains::catalog_entities::dto::BrandSolutionsResponse, // Rankings 도메인 DTO crate::domains::rankings::dto::RankingPeriodQuery, crate::domains::rankings::dto::RankingListResponse, diff --git a/packages/api-server/src/router.rs b/packages/api-server/src/router.rs index ba78bcba9..a07ace977 100644 --- a/packages/api-server/src/router.rs +++ b/packages/api-server/src/router.rs @@ -20,6 +20,7 @@ pub fn build_api_router(state: AppState) -> Router { .nest("/tries", domains::vton::tries_router(config.clone())) .nest("/posts", domains::posts::router(config.clone())) .nest("/catalog", domains::catalog::router()) + .merge(domains::catalog_entities::router()) .nest("/spots", domains::spots::router(config.clone())) .nest("/search", domains::search::router(config.clone())) .nest("/feed", domains::feed::router(config.clone())) diff --git a/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx b/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx new file mode 100644 index 000000000..4bccc2841 --- /dev/null +++ b/packages/web/app/[locale]/(shell)/artists/[id]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { ArtistDetailClient } from "@/lib/components/artists/ArtistDetailClient"; + +type Props = { + params: Promise<{ id: string; locale: string }>; +}; + +export const metadata: Metadata = { + title: "Artist", +}; + +export default async function ArtistDetailPage({ params }: Props) { + const { id } = await params; + return ; +} diff --git a/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx b/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx new file mode 100644 index 000000000..566058782 --- /dev/null +++ b/packages/web/app/[locale]/(shell)/brands/[id]/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { BrandDetailClient } from "@/lib/components/brands/BrandDetailClient"; + +type Props = { + params: Promise<{ id: string; locale: string }>; +}; + +export const metadata: Metadata = { + title: "Brand", +}; + +export default async function BrandDetailPage({ params }: Props) { + const { id } = await params; + return ; +} diff --git a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx index 6b33bd403..8f9952eb2 100644 --- a/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx +++ b/packages/web/app/[locale]/(shell)/explore/ExploreClient.tsx @@ -1,46 +1,22 @@ "use client"; import { useState, useEffect, useMemo, useRef, useCallback } from "react"; -import { useTranslations } from "next-intl"; -import { Search, X, ChevronDown } from "lucide-react"; +import { Search, X } from "lucide-react"; +import { useRouter } from "@/i18n/navigation"; import { useDebounce, useRecentSearchesStore } from "@decoded/shared"; import { useExploreData } from "@/lib/hooks/useExploreData"; import { useSearchStore } from "@/lib/stores/searchStore"; -import { FilterChip } from "@/lib/components/explore/FilterChip"; import { ExploreCardCell, ExploreSkeletonCell } from "@/lib/components/explore"; -import { - EntityTabBar, - type ExploreEntity, -} from "@/lib/components/explore/EntityTabBar"; import { FilterChipRow, type StyleTag, } from "@/lib/components/explore/FilterChipRow"; import { SortDropdown } from "@/lib/components/explore/SortDropdown"; -import { CategoryFilter } from "@/lib/components/explore/CategoryFilter"; -import { - sortOptionsForMode, - coerceSortForMode, - activeStyleForQuery, - activeCategoryForQuery, - queryForCategory, - type ExploreCategoryId, -} from "@/lib/components/explore/exploreFilters"; -import { ComingSoonState } from "@/lib/components/explore/ComingSoonState"; +import { UnifiedSearchResults } from "@/lib/components/explore/UnifiedSearchResults"; import { LoadingSpinner } from "@/lib/design-system/loading-spinner"; import { SearchSuggestions } from "@/lib/components/search/SearchSuggestions"; import type { GridItem } from "@/lib/components/ThiingsGrid"; import type { PostGridItem } from "@/lib/hooks/useImages"; -import { cn } from "@/lib/utils"; - -const POPULAR_SEARCH_TAGS = [ - "BLACKPINK", - "NewJeans", - "Lisa", - "Jennie", - "Minji", - "Hanni", -] as const; type Props = { initialPosts?: PostGridItem[]; @@ -65,22 +41,17 @@ export function ExploreClient({ initialPage, initialTotalPages, }: Props) { - const t = useTranslations("Explore"); const debouncedQuery = useSearchStore((state) => state.debouncedQuery); const query = useSearchStore((state) => state.query); const setQuery = useSearchStore((state) => state.setQuery); const setDebouncedQuery = useSearchStore((state) => state.setDebouncedQuery); + const router = useRouter(); const inputRef = useRef(null); const containerRef = useRef(null); const sentinelRef = useRef(null); - const [entity, setEntity] = useState("looks"); - // Editorial style + category highlights are DERIVED from the query (single - // source of truth) — a chip can never stay lit while the applied query moves - // on. Both are canned-query palettes; only one query is active at a time. - const activeStyle: StyleTag = activeStyleForQuery(query); - const activeCategory: ExploreCategoryId = activeCategoryForQuery(query); + const [activeStyle, setActiveStyle] = useState("All"); const debouncedValue = useDebounce(query, 300); useEffect(() => { @@ -123,14 +94,27 @@ export function ExploreClient({ }, [showSuggestions]); const handleSuggestionSelect = useCallback( - (selectedQuery: string) => { + ( + selectedQuery: string, + options?: { entityType?: string; entityId?: string } + ) => { + if ( + options?.entityId && + (options.entityType === "artist" || options.entityType === "brand") + ) { + router.push(`/${options.entityType}s/${options.entityId}`); + setShowSuggestions(false); + inputRef.current?.blur(); + return; + } + setQuery(selectedQuery); setDebouncedQuery(selectedQuery); setShowSuggestions(false); addRecentSearch(selectedQuery); inputRef.current?.blur(); }, - [setQuery, setDebouncedQuery, addRecentSearch] + [router, setQuery, setDebouncedQuery, addRecentSearch] ); // 비동기 엔티티 히트 도착으로 서제스천 구성이 바뀌면 하이라이트 리셋 @@ -158,33 +142,27 @@ export function ExploreClient({ setQuery(""); setDebouncedQuery(""); setShowSuggestions(false); + setActiveStyle("All"); inputRef.current?.focus(); }, [setQuery, setDebouncedQuery]); - // Canned-query palette: a chip injects a real search query (empty "" clears). - // The chip's highlight is derived from the query, so no separate state to desync. - const applyCannedQuery = useCallback( - (value: string) => { - setQuery(value); - setDebouncedQuery(value); - if (value) addRecentSearch(value); + // Style chips inject a real search query (no fake facet filtering). + const handleStyleChange = useCallback( + (tag: StyleTag) => { + setActiveStyle(tag); + if (tag === "All") { + setQuery(""); + setDebouncedQuery(""); + } else { + setQuery(tag); + setDebouncedQuery(tag); + addRecentSearch(tag); + } setShowSuggestions(false); }, [setQuery, setDebouncedQuery, addRecentSearch] ); - // Editorial style chips inject the tag label as a canned search query. - const handleStyleChange = useCallback( - (tag: StyleTag) => applyCannedQuery(tag === "All" ? "" : tag), - [applyCannedQuery] - ); - - // Category chips inject their canned query ("All" → "" clears). - const handleCategoryChange = useCallback( - (id: ExploreCategoryId) => applyCannedQuery(queryForCategory(id)), - [applyCannedQuery] - ); - const { items, isLoading, @@ -195,13 +173,6 @@ export function ExploreClient({ hasNextPage, isFetchingNextPage, mode, - artistFacets, - contextFacets, - selectedArtists, - toggleArtist, - clearArtistFilters, - activeContext, - setContext, activeSort, setSort, } = useExploreData({ @@ -214,7 +185,7 @@ export function ExploreClient({ // Infinite scroll via sentinel (replaces ThiingsGrid onReachEnd) useEffect(() => { const sentinel = sentinelRef.current; - if (!sentinel || entity !== "looks") return; + if (!sentinel) return; const observer = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) { @@ -225,7 +196,7 @@ export function ExploreClient({ ); observer.observe(sentinel); return () => observer.disconnect(); - }, [entity, hasNextPage, isFetchingNextPage, fetchNextPage]); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); const gridItems: GridItem[] = useMemo(() => { return items @@ -246,36 +217,13 @@ export function ExploreClient({ })); }, [items]); - const contextOptions = useMemo(() => { - return Object.entries(contextFacets) - .sort(([, a], [, b]) => b - a) - .slice(0, 10); - }, [contextFacets]); - - const artistBadges = useMemo(() => { - return Object.entries(artistFacets) - .sort(([, a], [, b]) => b - a) - .slice(0, 8); - }, [artistFacets]); - - // Crit 1: only surface sorts the active mode can honor. `solution_count` is a - // search-only backend sort, so browse hides it; `displaySort` coerces the value - // so the - setContext( - e.target.value === "" ? null : e.target.value - ) - } - className={cn( - "cursor-pointer appearance-none rounded-full border bg-transparent py-1 pl-3 pr-7 text-xs font-medium transition-colors", - activeContext - ? "border-primary/30 bg-primary/10 text-foreground" - : "border-border text-muted-foreground hover:bg-accent" - )} - > - - {contextOptions.map(([value, count]) => ( - - ))} - - - - )} - {artistBadges.map(([name, count]) => ( - toggleArtist(name)} - onRemove={() => toggleArtist(name)} - /> - ))} - {hasActiveFilters && ( - - )} - - )} - - {/* Loading skeleton */} {isLoading && items.length === 0 && (
)} - {/* Error state — browse mode */} - {isError && mode !== "search" && ( + {isError && (
⚠️

- {t("loadError.title")} + Failed to load posts

- {error instanceof Error ? error.message : t("loadError.body")} + {error instanceof Error + ? error.message + : "Something went wrong while loading posts."}

)} - {/* Error state — search mode (backend/Meilisearch outage) */} - {isError && mode === "search" && ( -
-
🛠️
-

- {t("searchError.title")} -

-

- {(() => { - const msg = - error instanceof Error - ? error.message - : t("searchError.body"); - return msg.length > 140 ? `${msg.slice(0, 140)}…` : msg; - })()} -

-
- - -
-
- )} - - {/* Empty state with search suggestions */} {!isError && !isLoading && items.length === 0 && (
-
- {debouncedQuery.trim().length > 0 ? "🔍" : "📷"} -
+
📷

- {debouncedQuery.trim().length > 0 - ? t("empty.noResults", { query: debouncedQuery }) - : t("empty.noPosts")} + No posts found yet.

- {debouncedQuery.trim().length > 0 - ? t("empty.noResultsHint") - : t("empty.noPostsHint")} + Check back later.

- {debouncedQuery.trim().length > 0 && ( - <> -
- {POPULAR_SEARCH_TAGS.map((tag) => ( - - ))} -
- - - )}
)} @@ -541,7 +357,7 @@ export function ExploreClient({