diff --git a/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md b/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md new file mode 100644 index 00000000..2010e926 --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-profile-tries-detail-design.md @@ -0,0 +1,266 @@ +--- +title: "Profile Tries Detail Modal Design" +owner: human +status: draft +updated: 2026-05-07 +tags: [api, ui] +--- + +# Profile Tries Detail Modal Design + +- Date: 2026-05-07 +- Status: Draft (review pending) +- Related URL: `http://localhost:3000/profile` -> `Tries` tab +- Scope: profile tries history, VTON save contract, try-on detail modal + +## 1. Background / Problem + +The profile page already has a `Tries` tab that lists the current user's saved +try-on results. Each grid item currently shows only the generated result image +and saved date. + +The VTON save flow already sends partial provenance: + +- `source_post_id` +- `selected_item_ids` + +The server stores those values under `user_tryon_history.style_combination`. +However, the profile tries list API currently returns only: + +- `id` +- `image_url` +- `created_at` + +This means the profile UI cannot explain which post, which items, or which +original images produced a try-on result. The desired behavior is that clicking +a try-on photo opens a detail view similar to the current VTON modal, showing +the result together with the original person image, source post image, selected +items, and source post context. + +## 2. Goals + +1. Make every try-on result in `/profile` -> `Tries` clickable. +2. Show a detail modal that resembles the existing VTON modal rather than + navigating away from the profile page. +3. Persist enough save-time snapshot data to render old combinations even when + the source post or solution records later change. +4. Support all three original references requested for the detail modal: + uploaded person image, source post image, and the overall input combination. +5. Keep existing historical records usable with quiet fallback states. + +## 3. Non-Goals + +- No database schema migration in the first pass. +- No public profile exposure of private try-on history. +- No automatic re-run of a try-on from the detail modal in the first pass. +- No broad redesign of the VTON creation modal. + +## 4. Product Design + +When a user opens `/profile`, selects the `Tries` tab, and clicks a try-on +image, the app opens a modal in-place. + +The modal has two main areas: + +1. Image area + - Generated try-on result image. + - Uploaded person original image, when saved. + - Source post original image, when saved. + - A simple before/result comparison mode can use the same image area pattern + as the current VTON modal. + +2. Context area + - Source post summary: image, title, artist/group/context, and a `View post` + action when `source_post_id` exists. + - Selected item list: thumbnail, title, description, and keywords. + - Saved date. + - Fallback labels for missing historical metadata: + - `Original image unavailable` + - `Source post unavailable` + - `Items unavailable` + +The modal opens for every try-on record. Missing metadata never disables the +card; it only changes the detail content. + +## 5. Save Contract + +The VTON save request should expand from ID-only provenance to ID plus snapshot +provenance. + +```ts +type SaveTryOnBody = { + result_image: string; + person_original_image?: string | null; + source_post_id?: string | null; + selected_item_ids?: string[]; + source_post_snapshot?: { + id: string; + title: string | null; + image_url: string | null; + artist_name?: string | null; + group_name?: string | null; + context?: string | null; + } | null; + selected_items_snapshot?: Array<{ + id: string; + title: string; + thumbnail_url: string; + description?: string | null; + keywords?: string[] | null; + }>; +}; +``` + +The server stores this under `user_tryon_history.style_combination`. + +```ts +type TryOnStyleCombination = { + source_post_id: string | null; + selected_item_ids: string[]; + person_original_image: string | null; + source_post_snapshot: SaveTryOnBody["source_post_snapshot"]; + selected_items_snapshot: SaveTryOnBody["selected_items_snapshot"]; +}; +``` + +For backward compatibility, the API must also tolerate older rows where +`style_combination` is null or only has `source_post_id` and +`selected_item_ids`. + +## 6. Read Contract + +`GET /api/v1/users/me/tries` should return each try-on result with detail-ready +metadata. + +```ts +type TryItem = { + id: string; + image_url: string; + created_at: string; + source_post_id: string | null; + selected_item_ids: string[]; + person_original_image: string | null; + source_post: { + id: string; + title: string | null; + image_url: string | null; + artist_name?: string | null; + group_name?: string | null; + context?: string | null; + } | null; + selected_items: Array<{ + id: string; + title: string; + thumbnail_url: string; + description?: string | null; + keywords?: string[] | null; + }>; +}; +``` + +The first pass should prefer saved snapshots because they represent the exact +state at save time. If a historical record only has IDs, the API may best-effort +resolve the current post and solution rows. If that lookup fails, it returns the +fallback fields as null or empty arrays. + +## 7. Image Storage Decision + +The current implementation can store generated result images as data URLs in +`user_tryon_history.image_url`. Extending that pattern to result image, person +original image, and source post image makes rows large. + +Decision: + +- First pass: keep the current table shape and store snapshot metadata in + `style_combination`. +- Avoid adding a migration in this task. +- Prefer URL references for source post and item images. +- For uploaded person original image, use the current available client value in + the save payload only if the existing request size limit accepts it. +- Follow-up: move try-on result and person original images to Supabase Storage, + then store only Storage URLs in `user_tryon_history`. + +## 8. Architecture + +```text +VTON modal selection + -> selected source post + selected item snapshots + person original image + -> POST /api/v1/tries + -> user_tryon_history.image_url + style_combination snapshot + -> GET /api/v1/users/me/tries + -> TriesGrid cards + -> TryDetailModal +``` + +Responsibilities: + +- `useVtonTryOn`: builds the save request from current VTON modal state. +- `/api/v1/tries`: validates and persists snapshot-compatible + `style_combination`. +- `/api/v1/users/me/tries`: maps stored JSON into a stable `TryItem` response + with fallback handling. +- `TriesGrid`: owns selection state and opens the detail modal. +- `TryDetailModal`: presentational detail view for result, originals, post, and + selected items. + +## 9. File Scope + +Expected implementation files: + +- `packages/web/lib/hooks/useVtonTryOn.ts` +- `packages/web/lib/components/vton/VtonModal.tsx` +- `packages/web/app/api/v1/tries/route.ts` +- `packages/web/app/api/v1/users/me/tries/route.ts` +- `packages/web/app/api/v1/tries/__tests__/route.test.ts` +- `packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts` +- `packages/web/lib/components/profile/TriesGrid.tsx` +- `packages/web/lib/components/profile/TryDetailModal.tsx` + +Generated API models under `packages/web/lib/api/generated/` should not be +edited manually. If the implementation needs the generated `TryItem` model to +match the expanded contract, regenerate it from the OpenAPI source rather than +hand editing generated files. + +## 10. Error Handling + +- Unauthorized save or list requests keep returning 401. +- Invalid image payloads keep returning 400 or 413. +- Malformed `style_combination` JSON should not break the profile page; the + route returns the result image with null/empty detail fields. +- Failed best-effort lookup of current post or items should degrade to saved + snapshots or fallback labels. +- Detail modal image failures should show the existing browser broken-image + behavior plus textual fallback context. + +## 11. Testing + +API tests: + +- `POST /api/v1/tries` stores snapshot fields in `style_combination`. +- `POST /api/v1/tries` still accepts legacy payloads with only IDs. +- `GET /api/v1/users/me/tries` returns expanded fields for snapshot rows. +- `GET /api/v1/users/me/tries` returns safe fallbacks for legacy/null + `style_combination`. + +UI tests: + +- `TriesGrid` opens the detail modal when a try-on card is clicked. +- The modal shows result image, person original, source post, and selected + items when present. +- The modal opens with fallback text when metadata is absent. + +Manual QA: + +- Save a new try-on from a source post. +- Open `/profile`, switch to `Tries`, click the saved result. +- Confirm the modal shows the result, uploaded original, source post image, and + selected items. +- Confirm old seeded try-on records still open. + +## 12. Open Follow-Ups + +1. Move generated result and uploaded person original images to Supabase Storage. +2. Add a `Try again with this combination` action after the read-only detail + modal is stable. +3. Decide whether users can delete individual try-on history entries from the + detail modal. diff --git a/packages/ai-server/scripts/cost_probe.py b/packages/ai-server/scripts/cost_probe.py new file mode 100644 index 00000000..577541d7 --- /dev/null +++ b/packages/ai-server/scripts/cost_probe.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +# pyright: reportMissingImports=false +"""Single raw_post 의 전체 Gemini 파이프라인 비용을 실측한다. + +목적: cost-tracking 시스템 (DB pricing + per-call recorder) 을 만들기 전에 +"한 raw_post 처리에 실제로 token / image / grounding query 가 얼마나 +드는지" 를 ad-hoc 으로 측정. 결과로 plan 의 비용 추정치를 보강. + +원리: + - prod assets DB 에서 image_url 있는 raw_post 1건 random pick. + - prod 파이프라인이 만드는 8가지 호출 타입을 1회씩 (item 단위는 N=3 cap): + 1. hero_reframe gemini-2.5-flash-image image out + 2. subject gemini-2.5-flash image+text + 3. items gemini-2.5-pro image+text → N items + 4. spots gemini-2.5-flash hero+items + 5. thumbnail × N gemini-2.5-flash-image image out + 6. url_grounded × N gemini-2.5-flash + googleSearch + 7. url_filter × N gemini-2.5-flash + image + - 각 호출의 usage_metadata 캡처 (prompt_token_count / candidates_token_count / + cached_content_token_count, image 모델은 candidates_token_count 가 image + bytes 분 ≈ 1290). + - 끝에 합계 + Gemini 공시 단가 (스크립트 상단 상수) 로 USD 환산. + +단가 상수는 *프로브 한정* (production code 는 plan 대로 DB SOT). +출처는 `https://ai.google.dev/pricing` (2026-05 기준 manual 입력). + +Usage: + cd packages/ai-server + uv run python scripts/cost_probe.py + uv run python scripts/cost_probe.py --id +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import logging +import os +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Optional + +import asyncpg +import httpx +from google import genai +from google.genai import types as genai_types + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services.raw_posts.processors.prompts import ( # noqa: E402 + HERO_REFRAME_PROMPT, + ITEM_THUMBNAIL_PROMPT, + ITEMS_PROMPT, + SPOTS_PROMPT, + SUBJECT_PROMPT, +) +from src.services.raw_posts.processors.items_parser import _ItemsResponse # noqa: E402 +from src.services.raw_posts.processors.spots_parser import _SpotsResponse # noqa: E402 +from src.services.raw_posts.processors.subject_parser import _SubjectDraft # noqa: E402 + + +logger = logging.getLogger("cost-probe") + + +# === Gemini 단가 (USD / unit) — 2026-05 ai.google.dev/pricing 기준 manual === +# 프로브 스크립트 한정. 운영 시스템은 DB pricing 테이블 SOT. +PRICING = { + "gemini-2.5-pro": { + "input_token": 1.25 / 1_000_000, # ≤200k context + "output_token": 10.0 / 1_000_000, + "cached_input_token": 0.3125 / 1_000_000, + }, + "gemini-2.5-flash": { + "input_token": 0.30 / 1_000_000, + "output_token": 2.50 / 1_000_000, + "cached_input_token": 0.075 / 1_000_000, + }, + "gemini-2.5-flash-image": { + "image_output": 0.039, # per image (1024x1024) + "input_token": 0.30 / 1_000_000, # image input still tokenized + }, + "grounding_query": 35.0 / 1_000, # $35 / 1000 google search queries +} + +ITEM_CAP = 3 # 실제 prod 평균 ≈ 5, probe 는 3 으로 cap (비용 ↓) + + +@dataclass +class CallLog: + step: str + model: str + ok: bool + prompt_tokens: int = 0 + completion_tokens: int = 0 + cached_tokens: int = 0 + image_output: int = 0 + grounding_queries: int = 0 + latency_ms: int = 0 + err: Optional[str] = None + est_cost_usd: float = 0.0 + + +calls: list[CallLog] = [] + + +def _extract_usage(resp: Any) -> tuple[int, int, int]: + um = getattr(resp, "usage_metadata", None) + if not um: + return (0, 0, 0) + return ( + getattr(um, "prompt_token_count", 0) or 0, + getattr(um, "candidates_token_count", 0) or 0, + getattr(um, "cached_content_token_count", 0) or 0, + ) + + +def _price_text(model: str, prompt: int, completion: int, cached: int) -> float: + p = PRICING.get(model, {}) + cost = 0.0 + cost += (prompt - (cached or 0)) * p.get("input_token", 0) + cost += completion * p.get("output_token", 0) + cost += (cached or 0) * p.get("cached_input_token", p.get("input_token", 0)) + return cost + + +async def _download(url: str) -> tuple[bytes, str]: + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as c: + r = await c.get(url) + r.raise_for_status() + ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + return r.content, ct + + +async def _pick_sample(db_url: str, raw_id: Optional[str]) -> dict: + conn = await asyncpg.connect(db_url) + try: + if raw_id: + row = await conn.fetchrow( + "SELECT id::text AS id, image_url, caption, parse_result FROM public.raw_posts WHERE id=$1::uuid", + raw_id, + ) + else: + row = await conn.fetchrow( + """ + SELECT id::text AS id, image_url, caption, parse_result + FROM public.raw_posts + WHERE image_url IS NOT NULL AND image_url != '' + AND status = 'COMPLETED' + AND parse_result IS NOT NULL + AND jsonb_typeof(parse_result -> 'items') = 'array' + AND jsonb_array_length(parse_result -> 'items') >= 3 + ORDER BY random() LIMIT 1 + """ + ) + if not row: + raise SystemExit("no eligible raw_post found") + return dict(row) + finally: + await conn.close() + + +async def _call_text( + client: genai.Client, + *, + step: str, + model: str, + contents: list, + response_schema: Optional[Any] = None, +) -> Any: + cfg = genai_types.GenerateContentConfig(temperature=0.1) + if response_schema is not None: + cfg = genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=response_schema, + temperature=0.1, + ) + t0 = time.monotonic() + log = CallLog(step=step, model=model, ok=False) + try: + resp = await client.aio.models.generate_content(model=model, contents=contents, config=cfg) + log.ok = True + p, c, ca = _extract_usage(resp) + log.prompt_tokens, log.completion_tokens, log.cached_tokens = p, c, ca + log.est_cost_usd = _price_text(model, p, c, ca) + return resp + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def _call_image( + client: genai.Client, + *, + step: str, + image_bytes: bytes, + content_type: str, + prompt: str, + aspect_ratio: str, + image_size: str, +) -> Any: + cfg = genai_types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=genai_types.ImageConfig(aspect_ratio=aspect_ratio, image_size=image_size), + ) + t0 = time.monotonic() + log = CallLog(step=step, model="gemini-2.5-flash-image", ok=False) + try: + resp = await client.aio.models.generate_content( + model="gemini-2.5-flash-image", + contents=[ + genai_types.Part.from_bytes(data=image_bytes, mime_type=content_type), + prompt, + ], + config=cfg, + ) + log.ok = True + p, c, ca = _extract_usage(resp) + log.prompt_tokens = p + log.image_output = 1 + log.est_cost_usd = ( + PRICING["gemini-2.5-flash-image"]["image_output"] + + p * PRICING["gemini-2.5-flash-image"]["input_token"] + ) + return resp + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def _call_grounded( + api_key: str, *, step: str, brand: str, title: str, model: str +) -> dict: + prompt = f"Find official PDP URLs for fashion item: brand={brand!r}, product={title!r}. Return top 5 URLs." + payload = { + "contents": [{"role": "user", "parts": [{"text": prompt}]}], + "tools": [{"googleSearch": {}}], + "generationConfig": {"temperature": 0.2}, + } + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent" + t0 = time.monotonic() + log = CallLog(step=step, model=model, ok=False, grounding_queries=1) + try: + async with httpx.AsyncClient(timeout=60.0) as c: + r = await c.post(url, params={"key": api_key}, json=payload) + r.raise_for_status() + data = r.json() + log.ok = True + um = (data or {}).get("usageMetadata") or {} + p = um.get("promptTokenCount", 0) or 0 + cc = um.get("candidatesTokenCount", 0) or 0 + log.prompt_tokens, log.completion_tokens = p, cc + log.est_cost_usd = ( + _price_text(model, p, cc, 0) + PRICING["grounding_query"] * 1 + ) + return data + except Exception as exc: # noqa: BLE001 + log.err = f"{type(exc).__name__}: {exc}" + raise + finally: + log.latency_ms = int((time.monotonic() - t0) * 1000) + calls.append(log) + + +async def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s") + parser = argparse.ArgumentParser() + parser.add_argument("--id", type=str, default=None) + args = parser.parse_args() + + db_url = os.environ.get("ASSETS_DATABASE_URL") + api_key = os.environ.get("GEMINI_API_KEY") + if not db_url or not api_key: + raise SystemExit("ASSETS_DATABASE_URL + GEMINI_API_KEY required (source .env.backend.prod)") + + row = await _pick_sample(db_url, args.id) + logger.info("sample raw_post: id=%s image_url=%s", row["id"], row["image_url"]) + image_bytes, content_type = await _download(row["image_url"]) + logger.info("image: %d bytes, ct=%s", len(image_bytes), content_type) + + client = genai.Client(api_key=api_key) + img_part = genai_types.Part.from_bytes(data=image_bytes, mime_type=content_type) + + # === 1. hero_reframe (flash-image) === + logger.info("[1/7] hero_reframe …") + await _call_image( + client, step="hero_reframe", image_bytes=image_bytes, content_type=content_type, + prompt=HERO_REFRAME_PROMPT, aspect_ratio="4:5", image_size="2K", + ) + + # === 2. subject (flash) === + logger.info("[2/7] subject …") + caption_text = (row.get("caption") or "").strip() + subj_contents = [img_part, SUBJECT_PROMPT + (f"\n\nCaption: {caption_text[:300]}" if caption_text else "")] + await _call_text(client, step="subject", model="gemini-2.5-flash", + contents=subj_contents, response_schema=_SubjectDraft) + + # === 3. items (pro) === + logger.info("[3/7] items …") + items_resp = await _call_text(client, step="items", model="gemini-2.5-pro", + contents=[img_part, ITEMS_PROMPT], + response_schema=_ItemsResponse) + # raw image_url 은 합성 전이라 items parser 가 0 으로 돌아오는 게 정상. + # 실제 N 은 prod parse_result.items 에서 가져와 per-item 호출 시뮬레이션. + cached: list[dict] = [] + pr = row.get("parse_result") + if pr: + try: + pr_obj = pr if isinstance(pr, dict) else json.loads(pr) + cached = (pr_obj or {}).get("items", []) or [] + except Exception: # noqa: BLE001 + pass + n_items = min(len(cached), ITEM_CAP) + logger.info("items in cached parse_result: %d (probing %d)", len(cached), n_items) + items = cached + + # === 4. spots (flash) — image + items text === + logger.info("[4/7] spots …") + items_text = json.dumps([{"brand": (it or {}).get("brand"), "product": (it or {}).get("product")} for it in items[:n_items]]) + await _call_text(client, step="spots", model="gemini-2.5-flash", + contents=[img_part, SPOTS_PROMPT + "\n\nitems=" + items_text], + response_schema=_SpotsResponse) + + # === 5. thumbnail × N (flash-image) === + for i in range(n_items): + logger.info("[5/7] thumbnail %d/%d …", i + 1, n_items) + await _call_image( + client, step=f"thumbnail#{i+1}", image_bytes=image_bytes, content_type=content_type, + prompt=ITEM_THUMBNAIL_PROMPT, aspect_ratio="1:1", image_size="1K", + ) + + # === 6. url_search.grounded × N (flash + googleSearch) === + for i in range(n_items): + it = items[i] or {} + brand = (it.get("brand") or "unknown brand")[:80] + product = (it.get("product") or "unknown product")[:120] + logger.info("[6/7] url_grounded %d/%d (%s · %s) …", i + 1, n_items, brand, product) + try: + await _call_grounded(api_key, step=f"url_grounded#{i+1}", brand=brand, title=product, + model="gemini-2.5-flash") + except Exception as exc: # noqa: BLE001 + logger.warning("url_grounded failed: %s", exc) + + # === 7. url_search.filter × N (flash + thumbnail image) === + for i in range(n_items): + logger.info("[7/7] url_filter %d/%d …", i + 1, n_items) + await _call_text( + client, step=f"url_filter#{i+1}", model="gemini-2.5-flash", + contents=[img_part, "Evaluate top product URL candidates. Return JSON with best_url, confidence, domain_class."], + ) + + # === 결과 출력 === + print() + print(f"{'step':<22}{'model':<26}{'ok':<4}{'in tok':>8}{'out tok':>9}{'img':>5}{'grnd':>6}{'lat ms':>9}{'$':>10}") + print("-" * 99) + total = {"in": 0, "out": 0, "img": 0, "grnd": 0, "ms": 0, "usd": 0.0} + for c in calls: + print(f"{c.step:<22}{c.model:<26}{('✓' if c.ok else '✗'):<4}" + f"{c.prompt_tokens:>8}{c.completion_tokens:>9}{c.image_output:>5}" + f"{c.grounding_queries:>6}{c.latency_ms:>9}{c.est_cost_usd:>10.5f}") + total["in"] += c.prompt_tokens; total["out"] += c.completion_tokens + total["img"] += c.image_output; total["grnd"] += c.grounding_queries + total["ms"] += c.latency_ms; total["usd"] += c.est_cost_usd + print("-" * 99) + print(f"{'TOTAL':<22}{'':<26}{'':<4}{total['in']:>8}{total['out']:>9}{total['img']:>5}" + f"{total['grnd']:>6}{total['ms']:>9}{total['usd']:>10.5f}") + print() + print(f"raw_post id = {row['id']}") + print(f"items detected (prod 평균 ≈ 5) = {len(items)}, probe 사용 = {n_items}") + print(f"실측 ${total['usd']:.4f} / 1 raw_post (ITEM_CAP={ITEM_CAP})") + if n_items > 0: + per_item = total['usd'] - sum(c.est_cost_usd for c in calls if not c.step.startswith(('thumbnail', 'url_'))) + prod_est = (total['usd'] - per_item) + per_item / n_items * 5 + print(f"prod 추정 (5 items): ${prod_est:.4f}") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/scripts/cost_tracking_e2e_test.py b/packages/ai-server/scripts/cost_tracking_e2e_test.py new file mode 100644 index 00000000..a744b8ec --- /dev/null +++ b/packages/ai-server/scripts/cost_tracking_e2e_test.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""cost_tracking 모듈 end-to-end 검증. + +local DB (port 54322) 에 마이그레이션 적용 후 실행: + ASSETS_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \\ + uv run python scripts/cost_tracking_e2e_test.py + +검증: + 1. PricingCache 가 DB seed 단가 로드 + 2. track_call 이 mock Gemini 호출 (usage_metadata 포함) 을 wrap + 3. estimate 가 단가 × token 으로 cost 계산 + 4. recorder 가 fire-and-forget INSERT + 5. 실패 path 도 row 적립 (ok=false, error_class) +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import uuid +from dataclasses import dataclass +from pathlib import Path + +import asyncpg + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services import cost_tracking # noqa: E402 + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("cost-tracking-test") + + +# --- mock Gemini response objects --- + +@dataclass +class _MockUsage: + prompt_token_count: int = 0 + candidates_token_count: int = 0 + cached_content_token_count: int = 0 + + +@dataclass +class _MockResp: + usage_metadata: _MockUsage + + +async def _fake_call(usage: _MockUsage): + """Mock coroutine — returns response with usage_metadata.""" + await asyncio.sleep(0.01) + return _MockResp(usage_metadata=usage) + + +async def _fake_failure(): + """Mock coroutine that raises.""" + await asyncio.sleep(0.005) + raise RuntimeError("safety_block: simulated") + + +async def main() -> int: + dsn = os.environ.get("ASSETS_DATABASE_URL") + if not dsn: + print("ASSETS_DATABASE_URL required", file=sys.stderr) + return 2 + + raw_post_id = str(uuid.uuid4()) + cost_tracking.set_context(raw_post_id=raw_post_id, pipeline="raw_post") + logger.info("context: raw_post_id=%s pipeline=raw_post", raw_post_id) + + # === 1. happy path — Pro text call === + logger.info("[1] items_parser (Pro) — mock 1000 in / 500 out") + await cost_tracking.track_call( + "items_parser", + "gemini-2.5-pro", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=1000, candidates_token_count=500)), + ) + + # === 2. happy path — Flash text call === + logger.info("[2] subject_parser (Flash) — mock 800 in / 100 out") + await cost_tracking.track_call( + "subject_parser", + "gemini-2.5-flash", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=800, candidates_token_count=100)), + ) + + # === 3. happy path — Flash text with cache === + logger.info("[3] subject_parser cached — mock 1000 in (400 cached) / 100 out") + await cost_tracking.track_call( + "subject_parser", + "gemini-2.5-flash", + cost_tracking.extract_text_usage, + _fake_call( + _MockUsage( + prompt_token_count=1000, + candidates_token_count=100, + cached_content_token_count=400, + ) + ), + ) + + # === 4. image output (flash-image) === + logger.info("[4] hero_reframe (flash-image)") + await cost_tracking.track_call( + "hero_reframe", + "gemini-2.5-flash-image", + cost_tracking.extract_image_usage, + _fake_call(_MockUsage(prompt_token_count=540, candidates_token_count=0)), + ) + + # === 5. failure path === + logger.info("[5] items_parser failure (safety_block)") + try: + await cost_tracking.track_call( + "items_parser", + "gemini-2.5-pro", + cost_tracking.extract_text_usage, + _fake_failure(), + ) + except RuntimeError as exc: + logger.info(" propagated as expected: %s", exc) + + # === 6. no-pricing model === + logger.info("[6] unknown model (no pricing)") + await cost_tracking.track_call( + "experimental_step", + "gemini-999-future", + cost_tracking.extract_text_usage, + _fake_call(_MockUsage(prompt_token_count=100, candidates_token_count=50)), + ) + + # === 모든 fire-and-forget task 끝날 때까지 대기 === + logger.info("waiting for fire-and-forget INSERTs to drain …") + await asyncio.sleep(2.0) + # 추가로 명시 shutdown — 풀의 모든 pending INSERT 가 끝남 + pending = [t for t in asyncio.all_tasks() if not t.done() and t is not asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + # === DB 확인 === + conn = await asyncpg.connect(dsn) + try: + rows = await conn.fetch( + """ + SELECT step, model, ok, prompt_tokens, completion_tokens, cached_tokens, + image_output_count, est_cost_usd::float8 AS cost, + error_class, pricing_snapshot::text AS snap, latency_ms + FROM public.gemini_usage_events + WHERE raw_post_id = $1::uuid + ORDER BY id + """, + raw_post_id, + ) + finally: + await conn.close() + + print() + print(f"{'step':<24}{'model':<26}{'ok':<4}{'in':>6}{'out':>5}{'cache':>6}" + f"{'img':>4}{'cost $':>12}{'err':<16}") + print("-" * 110) + for r in rows: + print(f"{r['step']:<24}{r['model']:<26}{('✓' if r['ok'] else '✗'):<4}" + f"{(r['prompt_tokens'] or 0):>6}{(r['completion_tokens'] or 0):>5}" + f"{(r['cached_tokens'] or 0):>6}{(r['image_output_count'] or 0):>4}" + f"{r['cost']:>12.6f} {r['error_class'] or '':<14}") + print() + print(f"{len(rows)} rows inserted (expected 6)") + await cost_tracking.shutdown() + return 0 if len(rows) == 6 else 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/scripts/cost_tracking_parser_verify.py b/packages/ai-server/scripts/cost_tracking_parser_verify.py new file mode 100644 index 00000000..19d9e807 --- /dev/null +++ b/packages/ai-server/scripts/cost_tracking_parser_verify.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""실제 parser wrap 검증 — `SubjectParser` 가 cost_tracking 경유해 DB 적립하는지. + +이전 `cost_tracking_e2e_test.py` 는 mock coroutine + `track_call` 직접 호출. +이 스크립트는 *진짜 parser 클래스* 를 import + 호출 → 그 안에 박힌 wrap +코드 라인이 작동해서 `gemini_usage_events` 에 row 가 적립되는지 검증. + + ASSETS_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres \\ + GEMINI_API_KEY=$(grep '^GEMINI_API_KEY=' ../../.env.backend.prod | cut -d= -f2-) \\ + uv run python scripts/cost_tracking_parser_verify.py + +비용: 약 \$0.0005 (Subject parser 1 회). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +import uuid +from pathlib import Path + +import asyncpg +import httpx + + +_THIS = Path(__file__).resolve() +_AI_SERVER_ROOT = _THIS.parent.parent +sys.path.insert(0, str(_AI_SERVER_ROOT)) + +from src.services import cost_tracking # noqa: E402 +from src.services.raw_posts.processors.subject_parser import SubjectParser # noqa: E402 + + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("cost-tracking-parser-verify") + + +# 테스트 이미지 — 운영 환경의 한 raw_post (Pinterest, 셀럽 사진). +TEST_IMAGE_URL = "https://pub-64ff29549cdf47ee94d338bca8d04819.r2.dev/pinterest/75/75646468738215900.png" + + +async def main() -> int: + dsn = os.environ.get("ASSETS_DATABASE_URL") + api_key = os.environ.get("GEMINI_API_KEY") + if not dsn or not api_key: + print("ASSETS_DATABASE_URL + GEMINI_API_KEY required", file=sys.stderr) + return 2 + + # 가짜 raw_post_id 로 context 설정 — DB 적립 시 이 ID 로 row 검증. + fake_raw_post_id = str(uuid.uuid4()) + cost_tracking.set_context(raw_post_id=fake_raw_post_id, pipeline="raw_post") + logger.info("context: raw_post_id=%s pipeline=raw_post", fake_raw_post_id) + + # 이미지 fetch + logger.info("fetching test image …") + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as c: + r = await c.get(TEST_IMAGE_URL) + r.raise_for_status() + img_bytes = r.content + ct = (r.headers.get("content-type") or "image/jpeg").split(";")[0].strip() + logger.info("image: %d bytes, ct=%s", len(img_bytes), ct) + + # 실제 SubjectParser 호출 — 내부 `await cost_tracking.track_call(...)` 발동. + logger.info("calling SubjectParser.parse() — wrap 경유 …") + parser = SubjectParser(api_key=api_key, model="gemini-2.5-flash") + subject = await parser.parse( + composite_bytes=img_bytes, + content_type=ct, + caption=None, + ) + logger.info("subject result: %s", subject) + + # fire-and-forget INSERT drain + logger.info("waiting for fire-and-forget INSERT …") + await asyncio.sleep(2.0) + pending = [t for t in asyncio.all_tasks() if not t.done() and t is not asyncio.current_task()] + if pending: + await asyncio.gather(*pending, return_exceptions=True) + + # DB 검증 + conn = await asyncpg.connect(dsn) + try: + rows = await conn.fetch( + """ + SELECT step, model, ok, prompt_tokens, completion_tokens, + est_cost_usd::float8 AS cost, latency_ms, pipeline, + pricing_snapshot::text AS snap, error_class + FROM public.gemini_usage_events + WHERE raw_post_id = $1::uuid + ORDER BY id + """, + fake_raw_post_id, + ) + finally: + await conn.close() + + print() + if not rows: + print(f"❌ FAIL — no row found for raw_post_id={fake_raw_post_id}") + await cost_tracking.shutdown() + return 1 + + print(f"✅ {len(rows)} row(s) inserted for raw_post_id={fake_raw_post_id}") + print() + for r in rows: + print(f" step {r['step']}") + print(f" model {r['model']}") + print(f" pipeline {r['pipeline']}") + print(f" ok {r['ok']}") + print(f" prompt_tokens {r['prompt_tokens']}") + print(f" completion_tok {r['completion_tokens']}") + print(f" cost_usd ${r['cost']:.6f}") + print(f" latency_ms {r['latency_ms']}") + print(f" pricing_snapshot {r['snap']}") + print(f" error_class {r['error_class']}") + print() + + # 핵심 invariant 검증 + fail = False + row = rows[0] + if row["step"] != "subject_parser": + print(f"❌ step mismatch: {row['step']!r} != 'subject_parser'") + fail = True + if row["model"] != "gemini-2.5-flash": + print(f"❌ model mismatch: {row['model']!r} != 'gemini-2.5-flash'") + fail = True + if row["pipeline"] != "raw_post": + print(f"❌ pipeline mismatch: {row['pipeline']!r} != 'raw_post'") + fail = True + if not row["ok"]: + print(f"❌ ok=false (error_class={row['error_class']})") + fail = True + if (row["prompt_tokens"] or 0) <= 0: + print(f"❌ prompt_tokens missing: {row['prompt_tokens']}") + fail = True + if row["cost"] <= 0: + print(f"❌ cost_usd <= 0: {row['cost']}") + fail = True + + await cost_tracking.shutdown() + if fail: + print("❌ invariant check failed") + return 1 + print("✅ all invariants passed — wrap 코드 경로가 실제로 작동함") + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/packages/ai-server/src/managers/llm/adapters/nano_banana.py b/packages/ai-server/src/managers/llm/adapters/nano_banana.py index 2be2e48e..891bc8ae 100644 --- a/packages/ai-server/src/managers/llm/adapters/nano_banana.py +++ b/packages/ai-server/src/managers/llm/adapters/nano_banana.py @@ -17,6 +17,8 @@ from google import genai from google.genai import types as genai_types +from src.services import cost_tracking + logger = logging.getLogger(__name__) @@ -50,24 +52,33 @@ async def reframe( prompt: str, aspect_ratio: str = "4:5", image_size: str = "2K", + step: str = "nano_banana", ) -> bytes: """Call Nano Banana with an input image + prompt, return output bytes. Raises NanoBananaError when the API errors or returns no inline_data. + + `step` 은 cost_tracking 의 step 라벨 — 호출자 (hero_reframe / + items_thumbnail) 가 own 라벨 전달 시 cost 대시보드에서 분리 가능. """ try: - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=image_bytes, mime_type=image_mime_type - ), - prompt, - ], - config=genai_types.GenerateContentConfig( - response_modalities=["IMAGE"], - image_config=genai_types.ImageConfig( - aspect_ratio=aspect_ratio, image_size=image_size + resp = await cost_tracking.track_call( + step, + self._model, + cost_tracking.extract_image_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=image_bytes, mime_type=image_mime_type + ), + prompt, + ], + config=genai_types.GenerateContentConfig( + response_modalities=["IMAGE"], + image_config=genai_types.ImageConfig( + aspect_ratio=aspect_ratio, image_size=image_size + ), ), ), ) diff --git a/packages/ai-server/src/post_editorial/nodes/celeb_search.py b/packages/ai-server/src/post_editorial/nodes/celeb_search.py index 25beb73f..0e13ebb7 100644 --- a/packages/ai-server/src/post_editorial/nodes/celeb_search.py +++ b/packages/ai-server/src/post_editorial/nodes/celeb_search.py @@ -12,6 +12,7 @@ from pydantic import BaseModel from src.managers.database import DatabaseManager +from src.services import cost_tracking from ..state import PostEditorialState from ..config import get_settings @@ -189,13 +190,18 @@ def _build_celeb_ranking_prompt( async def _rank_celebs( client: genai.Client, prompt: str, model: str ) -> CelebRankingOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=CelebRankingOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "celeb_search", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=CelebRankingOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/design_spec.py b/packages/ai-server/src/post_editorial/nodes/design_spec.py index b3a6cd0e..22454e39 100644 --- a/packages/ai-server/src/post_editorial/nodes/design_spec.py +++ b/packages/ai-server/src/post_editorial/nodes/design_spec.py @@ -7,6 +7,8 @@ from google import genai from google.genai import types +from src.services import cost_tracking + from ..models import DesignSpec, default_design_spec from ..state import PostEditorialState from ..config import get_settings @@ -54,13 +56,18 @@ def _get_genai_client() -> genai.Client: async def _generate_spec(client: genai.Client, prompt: str, model: str) -> DesignSpec: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=DesignSpec, - temperature=0.7, + response = await cost_tracking.track_call( + "design_spec", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=DesignSpec, + temperature=0.7, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/editorial.py b/packages/ai-server/src/post_editorial/nodes/editorial.py index 2db9ff10..c039dcf7 100644 --- a/packages/ai-server/src/post_editorial/nodes/editorial.py +++ b/packages/ai-server/src/post_editorial/nodes/editorial.py @@ -8,6 +8,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -163,13 +165,18 @@ def _get_genai_client() -> genai.Client: async def _generate_editorial(client: genai.Client, prompt: str, model: str) -> EditorialOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=EditorialOutput, - temperature=0.7, + response = await cost_tracking.track_call( + "editorial", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=EditorialOutput, + temperature=0.7, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/image_analysis.py b/packages/ai-server/src/post_editorial/nodes/image_analysis.py index fcbf0ea4..24e52a97 100644 --- a/packages/ai-server/src/post_editorial/nodes/image_analysis.py +++ b/packages/ai-server/src/post_editorial/nodes/image_analysis.py @@ -9,6 +9,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -49,16 +51,21 @@ async def _download_image(url: str) -> tuple[bytes, str]: async def _analyze_image( client: genai.Client, model: str, prompt: str, image_bytes: bytes, mime_type: str ) -> ImageAnalysisOutput: - response = await client.aio.models.generate_content( - model=model, - contents=[ - prompt, - types.Part.from_bytes(data=image_bytes, mime_type=mime_type), - ], - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=ImageAnalysisOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "image_analysis", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=[ + prompt, + types.Part.from_bytes(data=image_bytes, mime_type=mime_type), + ], + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ImageAnalysisOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/item_search.py b/packages/ai-server/src/post_editorial/nodes/item_search.py index 3c45124f..32f22347 100644 --- a/packages/ai-server/src/post_editorial/nodes/item_search.py +++ b/packages/ai-server/src/post_editorial/nodes/item_search.py @@ -13,6 +13,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from src.managers.database import DatabaseManager from ..state import PostEditorialState @@ -253,13 +255,18 @@ def _build_ranking_prompt( async def _rank_items( client: genai.Client, prompt: str, model: str ) -> ItemRankingOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=ItemRankingOutput, - temperature=0.3, + response = await cost_tracking.track_call( + "item_search", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=ItemRankingOutput, + temperature=0.3, + ), ), ) raw_text = response.text or "{}" diff --git a/packages/ai-server/src/post_editorial/nodes/news_research.py b/packages/ai-server/src/post_editorial/nodes/news_research.py index 3fb40f6e..f83434c9 100644 --- a/packages/ai-server/src/post_editorial/nodes/news_research.py +++ b/packages/ai-server/src/post_editorial/nodes/news_research.py @@ -15,6 +15,8 @@ from google.genai import types from pydantic import BaseModel +from src.services import cost_tracking + from ..state import PostEditorialState from ..config import get_settings from ..gemini_retry import call_gemini_with_fallback @@ -243,13 +245,18 @@ async def _filter_with_gemini( client = genai.Client(api_key=settings.gemini_api_key) async def _generate(model: str) -> NewsFilterOutput: - response = await client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=NewsFilterOutput, - temperature=0.1, + response = await cost_tracking.track_call( + "news_research", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=NewsFilterOutput, + temperature=0.1, + ), ), ) return NewsFilterOutput.model_validate_json(response.text or '{"articles":[]}') diff --git a/packages/ai-server/src/post_editorial/nodes/review.py b/packages/ai-server/src/post_editorial/nodes/review.py index e0d86c66..d566bb0e 100644 --- a/packages/ai-server/src/post_editorial/nodes/review.py +++ b/packages/ai-server/src/post_editorial/nodes/review.py @@ -11,6 +11,8 @@ from google.genai import types from pydantic import ValidationError +from src.services import cost_tracking + from ..models import PostMagazineLayout, ReviewResult, CriterionResult from ..state import PostEditorialState from ..config import get_settings @@ -147,11 +149,16 @@ async def review_node(state: PostEditorialState) -> dict: async def _call_review(model: str): return await asyncio.wait_for( - client.aio.models.generate_content( - model=model, - contents=prompt, - config=types.GenerateContentConfig( - response_mime_type="application/json", temperature=0.0 + cost_tracking.track_call( + "review", + model, + cost_tracking.extract_text_usage, + client.aio.models.generate_content( + model=model, + contents=prompt, + config=types.GenerateContentConfig( + response_mime_type="application/json", temperature=0.0 + ), ), ), timeout=_REVIEW_TIMEOUT, diff --git a/packages/ai-server/src/services/cost_tracking/__init__.py b/packages/ai-server/src/services/cost_tracking/__init__.py new file mode 100644 index 00000000..953110a6 --- /dev/null +++ b/packages/ai-server/src/services/cost_tracking/__init__.py @@ -0,0 +1,43 @@ +"""Gemini API per-call 비용 추적 (#cost-tracking). + +ai-server 모든 Gemini 호출의 사용량 / 비용을 *호출 시점에* DB +(`gemini_usage_events`) 에 적립한다. 단가는 DB SOT (`gemini_pricing`, +SCD-2) 에서 동적 lookup — 코드 하드코딩 없음. + +설계 원칙: + - **fire-and-forget**: 추적이 본 파이프라인 latency / 실패에 영향 없음. + - **실패도 row 적립**: ok=false, error_class 로 분류 — 가시화. + - **lazy init**: ASSETS_DATABASE_URL 미설정이면 silent no-op. + - **단가 cache**: 5 분 TTL 메모리 캐시, 어드민 단가 변경 시 자동 반영. + +호출 사이트 (parser 별): + resp = await track_call( + "items_parser", model, extract_text_usage, + client.aio.models.generate_content(model=..., contents=..., config=...), + ) + +Pipeline runner 가 진입 시 한 번: + cost_tracking.set_context(raw_post_id=..., pipeline="raw_post") +""" + +from ._impl import ( + extract_grounded_response, + extract_grounded_usage, + extract_image_usage, + extract_rest_text_usage, + extract_text_usage, + set_context, + shutdown, + track_call, +) + +__all__ = [ + "extract_grounded_response", + "extract_grounded_usage", + "extract_image_usage", + "extract_rest_text_usage", + "extract_text_usage", + "set_context", + "shutdown", + "track_call", +] diff --git a/packages/ai-server/src/services/cost_tracking/_impl.py b/packages/ai-server/src/services/cost_tracking/_impl.py new file mode 100644 index 00000000..01490a1a --- /dev/null +++ b/packages/ai-server/src/services/cost_tracking/_impl.py @@ -0,0 +1,397 @@ +"""cost_tracking 내부 구현 — pool · pricing cache · recorder · estimator · context. + +본 모듈은 `__init__.py` 가 re-export 하는 5개 심볼만 외부에 노출: + track_call, set_context, extract_{text,image,grounded}_usage, shutdown +""" + +from __future__ import annotations + +import asyncio +import contextvars +import json +import logging +import os +import time +from typing import Any, Awaitable, Callable, Optional + +import asyncpg + + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# pool — 자체 asyncpg pool. DatabaseManager 와 분리 (DI 변경 없이 동작). +# --------------------------------------------------------------------------- + +_pool: Optional[asyncpg.Pool] = None +_pool_lock = asyncio.Lock() +_pool_failed = False # init 실패 후 재시도 안 함 (조용한 no-op) + + +async def _get_pool() -> Optional[asyncpg.Pool]: + """ASSETS_DATABASE_URL 로 lazy init. 미설정 / 실패 시 None — no-op.""" + global _pool, _pool_failed + if _pool is not None: + return _pool + if _pool_failed: + return None + async with _pool_lock: + if _pool is not None: + return _pool + if _pool_failed: + return None + dsn = ( + os.environ.get("ASSETS_DATABASE_URL") + or os.environ.get("OPERATION_DATABASE_URL") + ) + if not dsn: + logger.info("cost_tracking: no DSN — disabled (no-op)") + _pool_failed = True + return None + try: + _pool = await asyncpg.create_pool( + dsn=dsn, min_size=1, max_size=3, command_timeout=10 + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: pool init failed (%s) — disabled", exc) + _pool_failed = True + return None + logger.info("cost_tracking: pool initialized") + return _pool + + +async def shutdown() -> None: + """선택적 종료 — 테스트 / app shutdown 용.""" + global _pool + if _pool is not None: + await _pool.close() + _pool = None + + +# --------------------------------------------------------------------------- +# pricing cache — DB SOT 의 5분 TTL 메모리 캐시 +# --------------------------------------------------------------------------- + +_PRICE_TTL_SEC = 300.0 +_price_cache: dict[tuple[str, str, str], float] = {} +_price_loaded_at = 0.0 +_price_lock = asyncio.Lock() + + +async def _refresh_pricing_if_stale() -> None: + global _price_cache, _price_loaded_at + now = time.monotonic() + if now - _price_loaded_at < _PRICE_TTL_SEC: + return + async with _price_lock: + if now - _price_loaded_at < _PRICE_TTL_SEC: + return + pool = await _get_pool() + if pool is None: + return + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT model, unit_kind, tier, usd_per_unit::float8 AS rate + FROM public.gemini_pricing + WHERE effective_to IS NULL + """ + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: pricing reload failed: %s", exc) + return + _price_cache = {(r["model"], r["unit_kind"], r["tier"]): r["rate"] for r in rows} + _price_loaded_at = time.monotonic() + logger.debug("cost_tracking: pricing cache reloaded (%d rows)", len(_price_cache)) + + +async def _price_lookup(model: str, unit_kind: str, tier: str = "default") -> Optional[float]: + await _refresh_pricing_if_stale() + return _price_cache.get((model, unit_kind, tier)) + + +async def _model_snapshot(model: str, tier: str = "default") -> dict[str, float]: + """현재 model 의 모든 unit_kind 단가 dict — pricing_snapshot 빌드용.""" + await _refresh_pricing_if_stale() + return { + uk: rate + for (m, uk, t), rate in _price_cache.items() + if m == model and t == tier + } + + +# --------------------------------------------------------------------------- +# estimator — usage dict + model → (cost, snapshot, error_class) +# --------------------------------------------------------------------------- + + +async def _estimate(model: str, usage: dict[str, Any]) -> tuple[float, dict[str, float], Optional[str]]: + snap = await _model_snapshot(model) + cost = 0.0 + used_snap: dict[str, float] = {} + err: Optional[str] = None + + prompt = int(usage.get("prompt_tokens") or 0) + completion = int(usage.get("completion_tokens") or 0) + cached = int(usage.get("cached_tokens") or 0) + image_n = int(usage.get("image_output_count") or 0) + grounding_n = int(usage.get("grounding_queries") or 0) + + has_any_token = (prompt + completion + cached) > 0 + if has_any_token and "input_token" not in snap and "output_token" not in snap: + err = "no_pricing" + + if "input_token" in snap: + rate = snap["input_token"] + used_snap["input_token"] = rate + cost += max(0, prompt - cached) * rate + if "output_token" in snap and completion: + rate = snap["output_token"] + used_snap["output_token"] = rate + cost += completion * rate + if cached and "cached_input_token" in snap: + rate = snap["cached_input_token"] + used_snap["cached_input_token"] = rate + cost += cached * rate + if image_n and "image_output" in snap: + rate = snap["image_output"] + used_snap["image_output"] = rate + cost += image_n * rate + if grounding_n: + grate = await _price_lookup("grounding", "grounding_query") + if grate is not None: + used_snap["grounding_query"] = grate + cost += grounding_n * grate + else: + err = err or "no_pricing" + + return (cost, used_snap, err) + + +# --------------------------------------------------------------------------- +# context — contextvars (raw_post_id / post_id / pipeline 자동 전파) +# --------------------------------------------------------------------------- + +_raw_post_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "ct_raw_post_id", default=None +) +_post_id_var: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( + "ct_post_id", default=None +) +_pipeline_var: contextvars.ContextVar[str] = contextvars.ContextVar( + "ct_pipeline", default="ad_hoc" +) + + +def set_context( + *, + raw_post_id: Optional[str] = None, + post_id: Optional[str] = None, + pipeline: str = "ad_hoc", +) -> None: + """파이프라인 진입부에서 한 번 호출. 이후 같은 async task 안의 모든 + `track_call` 이 자동으로 read.""" + _raw_post_id_var.set(raw_post_id) + _post_id_var.set(post_id) + _pipeline_var.set(pipeline) + + +# --------------------------------------------------------------------------- +# recorder — fire-and-forget INSERT +# --------------------------------------------------------------------------- + + +def _classify(exc: BaseException) -> str: + msg = str(exc).lower() + if "safety" in msg or "blocked" in msg or "block_reason" in msg: + return "safety_block" + if "quota" in msg or "rate limit" in msg or "resource_exhausted" in msg: + return "quota" + if "timeout" in msg or "deadline" in msg: + return "timeout" + if "parse" in msg or "json" in msg: + return "parse_error" + if any(c in msg for c in ("500", "502", "503", "504")): + return "http_5xx" + return "error" + + +def _record( # fire-and-forget + *, + step: str, + model: str, + ok: bool, + usage: dict[str, Any], + cost: float, + snapshot: dict[str, float], + error_class: Optional[str], + latency_ms: int, +) -> None: + raw_post_id = _raw_post_id_var.get() + post_id = _post_id_var.get() + pipeline = _pipeline_var.get() + + async def _insert() -> None: + pool = await _get_pool() + if pool is None: + return + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO public.gemini_usage_events( + raw_post_id, post_id, step, pipeline, model, ok, + prompt_tokens, completion_tokens, cached_tokens, + image_output_count, grounding_queries, + est_cost_usd, pricing_snapshot, error_class, latency_ms + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13::jsonb,$14,$15) + """, + raw_post_id, + post_id, + step, + pipeline, + model, + ok, + usage.get("prompt_tokens"), + usage.get("completion_tokens"), + usage.get("cached_tokens"), + usage.get("image_output_count"), + usage.get("grounding_queries"), + cost, + json.dumps(snapshot) if snapshot else None, + error_class, + latency_ms, + ) + except Exception as exc: # noqa: BLE001 + logger.warning("cost_tracking: insert failed (step=%s): %s", step, exc) + + try: + asyncio.create_task(_insert()) + except RuntimeError: + # event loop 없는 컨텍스트 — silent skip (테스트 등) + pass + + +# --------------------------------------------------------------------------- +# usage extractors — SDK response → 공통 dict +# --------------------------------------------------------------------------- + + +def extract_text_usage(resp: Any) -> dict[str, Any]: + """google.genai 의 resp.usage_metadata → 공통 dict.""" + um = getattr(resp, "usage_metadata", None) + if not um: + return {} + return { + "prompt_tokens": getattr(um, "prompt_token_count", 0) or 0, + "completion_tokens": getattr(um, "candidates_token_count", 0) or 0, + "cached_tokens": getattr(um, "cached_content_token_count", 0) or 0, + } + + +def extract_image_usage(resp: Any) -> dict[str, Any]: + """flash-image: 1 image output + input image 토큰.""" + base = extract_text_usage(resp) + base["image_output_count"] = 1 + return base + + +def extract_grounded_usage(resp_dict: dict) -> dict[str, Any]: + """url_search.grounded_search — httpx response.json() (camelCase 키).""" + um = (resp_dict or {}).get("usageMetadata") or {} + return { + "prompt_tokens": um.get("promptTokenCount", 0) or 0, + "completion_tokens": um.get("candidatesTokenCount", 0) or 0, + "cached_tokens": um.get("cachedContentTokenCount", 0) or 0, + "grounding_queries": 1, + } + + +def extract_grounded_response(resp: Any) -> dict[str, Any]: + """httpx.Response → grounded usage. .json() 결과를 cache (httpx 가 알아서).""" + try: + data = resp.json() + except Exception: # noqa: BLE001 + return {"grounding_queries": 1} + return extract_grounded_usage(data) + + +def extract_rest_text_usage(resp: Any) -> dict[str, Any]: + """httpx.Response → text usage (camelCase 키, grounding 미사용).""" + try: + data = resp.json() + except Exception: # noqa: BLE001 + return {} + um = (data or {}).get("usageMetadata") or {} + return { + "prompt_tokens": um.get("promptTokenCount", 0) or 0, + "completion_tokens": um.get("candidatesTokenCount", 0) or 0, + "cached_tokens": um.get("cachedContentTokenCount", 0) or 0, + } + + +# --------------------------------------------------------------------------- +# track_call — public wrapper (어떤 Gemini 호출이든 통합) +# --------------------------------------------------------------------------- + + +async def track_call( + step: str, + model: str, + extract: Callable[[Any], dict[str, Any]], + coro: Awaitable[Any], +) -> Any: + """Gemini SDK 호출 coroutine 을 감싸 usage / cost 적립. + + `coro` 는 *이미 생성된* coroutine (e.g. `client.aio.models.generate_content(...)`). + 이 함수가 await + 결과 반환. 호출자 인터페이스는 변하지 않음. + + 실패 시 record_failure + re-raise. 본 파이프라인은 정상 에러 흐름 유지. + """ + t0 = time.monotonic() + try: + resp = await coro + except BaseException as exc: + latency = int((time.monotonic() - t0) * 1000) + _record( + step=step, + model=model, + ok=False, + usage={}, + cost=0.0, + snapshot={}, + error_class=_classify(exc), + latency_ms=latency, + ) + raise + latency = int((time.monotonic() - t0) * 1000) + try: + usage = extract(resp) or {} + cost, snap, err = await _estimate(model, usage) + except Exception as exc: # noqa: BLE001 + # extract / pricing 실패 — 본 결과는 반환, ok=true row 만 cost=0 + logger.warning("cost_tracking: usage/estimate failed (step=%s): %s", step, exc) + _record( + step=step, + model=model, + ok=True, + usage={}, + cost=0.0, + snapshot={}, + error_class="extract_error", + latency_ms=latency, + ) + return resp + _record( + step=step, + model=model, + ok=True, + usage=usage, + cost=cost, + snapshot=snap, + error_class=err, + latency_ms=latency, + ) + return resp diff --git a/packages/ai-server/src/services/post_editorial/post_editorial_service.py b/packages/ai-server/src/services/post_editorial/post_editorial_service.py index 25e3b543..eaadd170 100644 --- a/packages/ai-server/src/services/post_editorial/post_editorial_service.py +++ b/packages/ai-server/src/services/post_editorial/post_editorial_service.py @@ -67,6 +67,15 @@ async def post_editorial_job( metadata_extract_service = ctx.get("metadata_extract_service") from src.post_editorial.graph import create_post_editorial_graph + from src.services import cost_tracking + + # cost_tracking 컨텍스트 — 이후 모든 LangGraph 노드의 Gemini 호출이 + # post_id + pipeline 라벨로 자동 적립. + post_id = (post_data or {}).get("id") if isinstance(post_data, dict) else None + cost_tracking.set_context( + post_id=str(post_id) if post_id else None, + pipeline="post_editorial", + ) graph = create_post_editorial_graph() diff --git a/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py b/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py index 8c5d1078..d08fda27 100644 --- a/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py +++ b/packages/ai-server/src/services/raw_posts/processors/hero_reframe.py @@ -57,6 +57,7 @@ async def reframe( image_mime_type=content_type or "image/jpeg", prompt=prompt, aspect_ratio="4:5", + step="hero_reframe", ) except NanoBananaError as exc: raise HeroReframeError(str(exc)) from exc diff --git a/packages/ai-server/src/services/raw_posts/processors/items_parser.py b/packages/ai-server/src/services/raw_posts/processors/items_parser.py index aed555f0..01dfc91a 100644 --- a/packages/ai-server/src/services/raw_posts/processors/items_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/items_parser.py @@ -15,6 +15,8 @@ from google.genai import types as genai_types from pydantic import BaseModel +from src.services import cost_tracking + from .prompts import ITEMS_PROMPT from .schemas import ParsedItem, SpotBox @@ -85,19 +87,24 @@ async def parse( if caption: text += f"\n\nThe pin caption is: {caption[:200]}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=composite_bytes, - mime_type=content_type or "image/jpeg", + resp = await cost_tracking.track_call( + "items_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=composite_bytes, + mime_type=content_type or "image/jpeg", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_ItemsResponse, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_ItemsResponse, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py b/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py index b50eb036..0751d7e6 100644 --- a/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py +++ b/packages/ai-server/src/services/raw_posts/processors/items_thumbnail.py @@ -162,6 +162,7 @@ async def _maybe_generate_one( prompt=prompt, aspect_ratio="1:1", image_size="1K", + step="items_thumbnail", ) except NanoBananaError as exc: logger.warning( @@ -272,6 +273,7 @@ async def _maybe_download_one( prompt=prompt, aspect_ratio="1:1", image_size="1K", + step="items_thumbnail_refine", ) except NanoBananaError as exc: logger.warning( diff --git a/packages/ai-server/src/services/raw_posts/processors/spots_parser.py b/packages/ai-server/src/services/raw_posts/processors/spots_parser.py index 02094fbc..07755266 100644 --- a/packages/ai-server/src/services/raw_posts/processors/spots_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/spots_parser.py @@ -20,6 +20,8 @@ from google.genai import types as genai_types from pydantic import BaseModel, Field +from src.services import cost_tracking + from .prompts import SPOTS_PROMPT from .schemas import ParsedItem, SpotBox @@ -85,19 +87,24 @@ async def detect( ) text = f"{self._prompt}\n\nItems:\n{listing}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=hero_bytes, - mime_type=hero_content_type or "image/png", + resp = await cost_tracking.track_call( + "spots_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=hero_bytes, + mime_type=hero_content_type or "image/png", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SpotsResponse, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_SpotsResponse, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/subject_parser.py b/packages/ai-server/src/services/raw_posts/processors/subject_parser.py index 456b75a8..5ff013cf 100644 --- a/packages/ai-server/src/services/raw_posts/processors/subject_parser.py +++ b/packages/ai-server/src/services/raw_posts/processors/subject_parser.py @@ -29,6 +29,8 @@ from google.genai import types as genai_types from pydantic import BaseModel +from src.services import cost_tracking + from .prompts import SUBJECT_PROMPT from .schemas import CONTEXT_ENUM, Subject @@ -80,19 +82,24 @@ async def parse( if caption: text += f"\n\nThe pin caption is: {caption[:300]}" - resp = await self._client.aio.models.generate_content( - model=self._model, - contents=[ - genai_types.Part.from_bytes( - data=composite_bytes, - mime_type=content_type or "image/jpeg", + resp = await cost_tracking.track_call( + "subject_parser", + self._model, + cost_tracking.extract_text_usage, + self._client.aio.models.generate_content( + model=self._model, + contents=[ + genai_types.Part.from_bytes( + data=composite_bytes, + mime_type=content_type or "image/jpeg", + ), + text, + ], + config=genai_types.GenerateContentConfig( + response_mime_type="application/json", + response_schema=_SubjectDraft, + temperature=0.1, ), - text, - ], - config=genai_types.GenerateContentConfig( - response_mime_type="application/json", - response_schema=_SubjectDraft, - temperature=0.1, ), ) diff --git a/packages/ai-server/src/services/raw_posts/processors/url_search.py b/packages/ai-server/src/services/raw_posts/processors/url_search.py index 30fc1cd7..4ba60755 100644 --- a/packages/ai-server/src/services/raw_posts/processors/url_search.py +++ b/packages/ai-server/src/services/raw_posts/processors/url_search.py @@ -33,6 +33,8 @@ import httpx +from src.services import cost_tracking + from .schemas import ParsedItem @@ -211,10 +213,19 @@ async def _grounded_search( } url = f"{_GEMINI_BASE}/{self._gemini_model}:generateContent" try: - resp = await client.post( - url, params={"key": self._gemini_key}, json=payload, timeout=60 + async def _do_post(): + r = await client.post( + url, params={"key": self._gemini_key}, json=payload, timeout=60 + ) + r.raise_for_status() + return r + + resp = await cost_tracking.track_call( + "url_grounded_search", + self._gemini_model, + cost_tracking.extract_grounded_response, + _do_post(), ) - resp.raise_for_status() data = resp.json() except Exception as exc: logger.warning("grounded_search HTTP error: %s", exc) @@ -287,10 +298,19 @@ async def _filter( } url = f"{_GEMINI_BASE}/{self._gemini_model}:generateContent" try: - resp = await client.post( - url, params={"key": self._gemini_key}, json=payload, timeout=60 + async def _do_post(): + r = await client.post( + url, params={"key": self._gemini_key}, json=payload, timeout=60 + ) + r.raise_for_status() + return r + + resp = await cost_tracking.track_call( + "url_filter", + self._gemini_model, + cost_tracking.extract_rest_text_usage, + _do_post(), ) - resp.raise_for_status() data = resp.json() except Exception as exc: logger.warning("filter HTTP error: %s", exc) diff --git a/packages/ai-server/src/services/raw_posts/repository.py b/packages/ai-server/src/services/raw_posts/repository.py index c6fae840..7f291ab0 100644 --- a/packages/ai-server/src/services/raw_posts/repository.py +++ b/packages/ai-server/src/services/raw_posts/repository.py @@ -1082,6 +1082,7 @@ async def insert_vision_filter_log( platform: str, external_id: str, source_identifier: Optional[str], + image_url: Optional[str], verdict: str, confidence: Optional[float], reason: Optional[str], @@ -1097,8 +1098,8 @@ async def insert_vision_filter_log( """ INSERT INTO public.vision_filter_log (platform, external_id, source_identifier, verdict, - confidence, reason, model) - VALUES ($1, $2, $3, $4, $5, $6, $7) + confidence, reason, model, image_url) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) """, platform, external_id, @@ -1107,6 +1108,7 @@ async def insert_vision_filter_log( confidence, (reason or None), model, + image_url, ) # ---- #368 cycle running indicator ----------------------------------- diff --git a/packages/ai-server/src/services/raw_posts/scheduler.py b/packages/ai-server/src/services/raw_posts/scheduler.py index 113ebbe5..1e05520f 100644 --- a/packages/ai-server/src/services/raw_posts/scheduler.py +++ b/packages/ai-server/src/services/raw_posts/scheduler.py @@ -31,6 +31,7 @@ from apscheduler.triggers.interval import IntervalTrigger from src.managers.storage.r2_client import R2Client +from src.services import cost_tracking from .discovery.composite_filter import CompositeFilter, CompositeFilterError from .models import FetchRequest @@ -572,6 +573,7 @@ async def _vision_filter_cycle(self) -> None: platform=row.platform, external_id=row.external_id, source_identifier=row.external_id, # pin 일 경우 동일 + image_url=row.image_url, verdict=verdict, confidence=judgment.confidence, reason=judgment.reason, @@ -967,6 +969,13 @@ async def step_cb(step: str, ok: bool, note: Optional[str]) -> None: # 가 items / url_search / subject 단계 우회. prelabeled = from_platform_metadata(target.platform_metadata) + # cost_tracking 컨텍스트 — 이후 처리 안 Gemini 호출이 raw_post_id 와 + # pipeline 라벨을 자동 적립. + cost_tracking.set_context( + raw_post_id=str(target.raw_post_id), + pipeline="raw_post", + ) + try: processed = await self._processor.process( composite_bytes=composite_bytes, diff --git a/packages/api-server/openapi.json b/packages/api-server/openapi.json index a29f92d1..af671ca5 100644 --- a/packages/api-server/openapi.json +++ b/packages/api-server/openapi.json @@ -8388,7 +8388,9 @@ "required": [ "id", "image_url", - "created_at" + "created_at", + "selected_item_ids", + "selected_items" ], "properties": { "created_at": { @@ -8401,6 +8403,42 @@ }, "image_url": { "type": "string" + }, + "person_original_image": { + "type": [ + "string", + "null" + ] + }, + "selected_item_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "selected_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrySelectedItem" + } + }, + "source_post": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TrySourcePost" + } + ] + }, + "source_post_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" } } } @@ -10972,7 +11010,9 @@ "required": [ "id", "image_url", - "created_at" + "created_at", + "selected_item_ids", + "selected_items" ], "properties": { "created_at": { @@ -10985,6 +11025,42 @@ }, "image_url": { "type": "string" + }, + "person_original_image": { + "type": [ + "string", + "null" + ] + }, + "selected_item_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "selected_items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrySelectedItem" + } + }, + "source_post": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/TrySourcePost" + } + ] + }, + "source_post_id": { + "type": [ + "string", + "null" + ], + "format": "uuid" } } }, @@ -11073,6 +11149,85 @@ } } }, + "TrySelectedItem": { + "type": "object", + "description": "VTON 히스토리 선택 아이템 스냅샷", + "required": [ + "id", + "title", + "thumbnail_url" + ], + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "keywords": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "thumbnail_url": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "TrySourcePost": { + "type": "object", + "description": "VTON 히스토리 원본 포스트 스냅샷", + "required": [ + "id" + ], + "properties": { + "artist_name": { + "type": [ + "string", + "null" + ] + }, + "context": { + "type": [ + "string", + "null" + ] + }, + "group_name": { + "type": [ + "string", + "null" + ] + }, + "id": { + "type": "string", + "format": "uuid" + }, + "image_url": { + "type": [ + "string", + "null" + ] + }, + "title": { + "type": [ + "string", + "null" + ] + } + } + }, "UpdateBadgeDto": { "type": "object", "description": "뱃지 수정 요청", diff --git a/packages/api-server/src/domains/admin/gemini_cost.rs b/packages/api-server/src/domains/admin/gemini_cost.rs new file mode 100644 index 00000000..5e8e022e --- /dev/null +++ b/packages/api-server/src/domains/admin/gemini_cost.rs @@ -0,0 +1,526 @@ +//! Admin — Gemini API cost tracking dashboard endpoints. +//! +//! 데이터 SOT: `public.gemini_usage_events` + `public.gemini_spend_daily` (view). +//! 단가 SOT: `public.gemini_pricing` (SCD-2). ai-server 의 cost_tracking 이 +//! 호출 시점에 적립한다. + +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + routing::{delete, get}, + Json, Router, +}; +use sea_orm::{ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, TransactionTrait}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + config::{AppConfig, AppState}, + error::{AppError, AppResult}, + middleware::auth::User, +}; + +// === Query / Response types === + +#[derive(Debug, Deserialize)] +pub struct DaysQuery { + /// 조회 기간 (일). 1..=90. 기본 7. + #[serde(default)] + pub days: Option, +} + +#[derive(Debug, Deserialize)] +pub struct TopRawPostsQuery { + #[serde(default)] + pub days: Option, + #[serde(default)] + pub limit: Option, +} + +#[derive(Debug, Serialize)] +pub struct DailySpendRow { + pub day: String, // YYYY-MM-DD + pub spend_usd: f64, + pub ok_calls: i64, + pub failed_calls: i64, + pub prompt_tokens: i64, + pub completion_tokens: i64, + pub images: i64, + pub groundings: i64, +} + +#[derive(Debug, Serialize)] +pub struct DailySpendResponse { + pub days: Vec, + pub total_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct GroupSpendRow { + pub key: String, + pub spend_usd: f64, + pub ok_calls: i64, + pub failed_calls: i64, +} + +#[derive(Debug, Serialize)] +pub struct GroupSpendResponse { + pub rows: Vec, + pub total_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct TodaySpendResponse { + pub today_usd: f64, + pub today_calls: i64, + pub today_failed: i64, + pub yesterday_usd: f64, + pub last_7d_usd: f64, + pub last_30d_usd: f64, +} + +#[derive(Debug, Serialize)] +pub struct TopRawPostRow { + pub raw_post_id: String, + pub spend_usd: f64, + pub call_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct TopRawPostsResponse { + pub rows: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PricingRow { + pub id: String, + pub model: String, + pub unit_kind: String, + pub tier: String, + pub usd_per_unit: f64, + pub effective_from: String, + pub effective_to: Option, + pub source: String, + pub notes: Option, +} + +#[derive(Debug, Serialize)] +pub struct PricingListResponse { + pub active: Vec, + pub history: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct UpsertPricingRequest { + pub model: String, + pub unit_kind: String, + pub usd_per_unit: f64, + #[serde(default)] + pub tier: Option, + #[serde(default)] + pub notes: Option, +} + +// === Handlers === + +fn clamp_days(days: Option, default: i64) -> i64 { + days.unwrap_or(default).clamp(1, 90) +} + +pub async fn get_daily_spend( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT date_trunc('day', occurred_at)::date::text AS day, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) FILTER (WHERE ok) AS ok_calls, + COUNT(*) FILTER (WHERE NOT ok) AS failed_calls, + COALESCE(SUM(prompt_tokens), 0) AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0) AS completion_tokens, + COALESCE(SUM(image_output_count), 0) AS images, + COALESCE(SUM(grounding_queries), 0) AS groundings + FROM public.gemini_usage_events + WHERE occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY 1 + ORDER BY 1 DESC", + vec![(days as i32).into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut days_out = Vec::with_capacity(rows.len()); + let mut total = 0.0f64; + for r in rows { + let row = DailySpendRow { + day: r.try_get("", "day").map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + ok_calls: r.try_get("", "ok_calls").map_err(AppError::DatabaseError)?, + failed_calls: r + .try_get("", "failed_calls") + .map_err(AppError::DatabaseError)?, + prompt_tokens: r + .try_get("", "prompt_tokens") + .map_err(AppError::DatabaseError)?, + completion_tokens: r + .try_get("", "completion_tokens") + .map_err(AppError::DatabaseError)?, + images: r.try_get("", "images").map_err(AppError::DatabaseError)?, + groundings: r + .try_get("", "groundings") + .map_err(AppError::DatabaseError)?, + }; + total += row.spend_usd; + days_out.push(row); + } + Ok(Json(DailySpendResponse { + days: days_out, + total_usd: total, + })) +} + +async fn group_spend( + db: &DatabaseConnection, + group_col: &str, + days: i64, +) -> AppResult { + // group_col 은 whitelist 만 — SQL injection 회피. + let sql = format!( + "SELECT {gc}::text AS key, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) FILTER (WHERE ok) AS ok_calls, + COUNT(*) FILTER (WHERE NOT ok) AS failed_calls + FROM public.gemini_usage_events + WHERE occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY 1 + ORDER BY spend_usd DESC", + gc = group_col, + ); + let stmt = + Statement::from_sql_and_values(DatabaseBackend::Postgres, &sql, vec![(days as i32).into()]); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + let mut total = 0.0f64; + for r in rows { + let row = GroupSpendRow { + key: r.try_get("", "key").map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + ok_calls: r.try_get("", "ok_calls").map_err(AppError::DatabaseError)?, + failed_calls: r + .try_get("", "failed_calls") + .map_err(AppError::DatabaseError)?, + }; + total += row.spend_usd; + out.push(row); + } + Ok(GroupSpendResponse { + rows: out, + total_usd: total, + }) +} + +pub async fn get_spend_by_step( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "step", days).await?, + )) +} + +pub async fn get_spend_by_model( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "model", days).await?, + )) +} + +pub async fn get_spend_by_pipeline( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + Ok(Json( + group_spend(state.assets_db.as_ref(), "pipeline", days).await?, + )) +} + +pub async fn get_today_spend( + State(state): State, + _user: axum::Extension, +) -> AppResult> { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= date_trunc('day', now())), 0)::float8 AS today_usd, + COUNT(*) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) AND ok) AS today_calls, + COUNT(*) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) AND NOT ok) AS today_failed, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= date_trunc('day', now()) - interval '1 day' + AND occurred_at < date_trunc('day', now())), 0)::float8 AS yesterday_usd, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= now() - interval '7 days'), 0)::float8 AS last_7d_usd, + COALESCE(SUM(est_cost_usd) FILTER ( + WHERE occurred_at >= now() - interval '30 days'), 0)::float8 AS last_30d_usd + FROM public.gemini_usage_events", + Vec::::new(), + ); + let row = db + .query_one(stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("today spend row missing"))?; + Ok(Json(TodaySpendResponse { + today_usd: row + .try_get("", "today_usd") + .map_err(AppError::DatabaseError)?, + today_calls: row + .try_get("", "today_calls") + .map_err(AppError::DatabaseError)?, + today_failed: row + .try_get("", "today_failed") + .map_err(AppError::DatabaseError)?, + yesterday_usd: row + .try_get("", "yesterday_usd") + .map_err(AppError::DatabaseError)?, + last_7d_usd: row + .try_get("", "last_7d_usd") + .map_err(AppError::DatabaseError)?, + last_30d_usd: row + .try_get("", "last_30d_usd") + .map_err(AppError::DatabaseError)?, + })) +} + +pub async fn get_top_raw_posts( + State(state): State, + _user: axum::Extension, + Query(q): Query, +) -> AppResult> { + let days = clamp_days(q.days, 7); + let limit = q.limit.unwrap_or(10).clamp(1, 100); + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT raw_post_id::text AS raw_post_id, + COALESCE(SUM(est_cost_usd), 0)::float8 AS spend_usd, + COUNT(*) AS call_count + FROM public.gemini_usage_events + WHERE raw_post_id IS NOT NULL + AND occurred_at >= now() - ($1::int * interval '1 day') + GROUP BY raw_post_id + ORDER BY spend_usd DESC + LIMIT $2", + vec![(days as i32).into(), (limit as i64).into()], + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut out = Vec::with_capacity(rows.len()); + for r in rows { + out.push(TopRawPostRow { + raw_post_id: r + .try_get("", "raw_post_id") + .map_err(AppError::DatabaseError)?, + spend_usd: r + .try_get("", "spend_usd") + .map_err(AppError::DatabaseError)?, + call_count: r + .try_get("", "call_count") + .map_err(AppError::DatabaseError)?, + }); + } + Ok(Json(TopRawPostsResponse { rows: out })) +} + +// === Pricing CRUD === + +fn row_to_pricing(r: sea_orm::QueryResult) -> AppResult { + let id: Uuid = r.try_get("", "id").map_err(AppError::DatabaseError)?; + let effective_from: chrono::DateTime = r + .try_get("", "effective_from") + .map_err(AppError::DatabaseError)?; + let effective_to: Option> = r + .try_get("", "effective_to") + .map_err(AppError::DatabaseError)?; + Ok(PricingRow { + id: id.to_string(), + model: r.try_get("", "model").map_err(AppError::DatabaseError)?, + unit_kind: r + .try_get("", "unit_kind") + .map_err(AppError::DatabaseError)?, + tier: r.try_get("", "tier").map_err(AppError::DatabaseError)?, + usd_per_unit: r + .try_get("", "usd_per_unit") + .map_err(AppError::DatabaseError)?, + effective_from: effective_from.to_rfc3339(), + effective_to: effective_to.map(|t| t.to_rfc3339()), + source: r.try_get("", "source").map_err(AppError::DatabaseError)?, + notes: r.try_get("", "notes").map_err(AppError::DatabaseError)?, + }) +} + +pub async fn list_pricing( + State(state): State, + _user: axum::Extension, +) -> AppResult> { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes + FROM public.gemini_pricing + ORDER BY model, unit_kind, tier, effective_from DESC", + Vec::::new(), + ); + let rows = db.query_all(stmt).await.map_err(AppError::DatabaseError)?; + let mut active = Vec::new(); + let mut history = Vec::new(); + for r in rows { + let p = row_to_pricing(r)?; + if p.effective_to.is_none() { + active.push(p); + } else { + history.push(p); + } + } + Ok(Json(PricingListResponse { active, history })) +} + +pub async fn upsert_pricing( + State(state): State, + _user: axum::Extension, + Json(body): Json, +) -> AppResult> { + if body.usd_per_unit < 0.0 { + return Err(AppError::bad_request("usd_per_unit must be >= 0")); + } + let allowed_units = [ + "input_token", + "output_token", + "cached_input_token", + "image_output", + "grounding_query", + ]; + if !allowed_units.contains(&body.unit_kind.as_str()) { + return Err(AppError::bad_request( + "unit_kind must be one of: input_token, output_token, cached_input_token, image_output, grounding_query", + )); + } + let tier = body.tier.unwrap_or_else(|| "default".to_string()); + let notes = body.notes; + + let db = state.assets_db.as_ref(); + // Two-step: close existing active row, then insert new active row. + // SeaORM `query_one` can run inside a Statement — but for transaction we + // use raw SQL fenced by BEGIN/COMMIT via `db.execute_unprepared`. + let txn_sql = " + BEGIN; + UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE model = $1 AND unit_kind = $2 AND tier = $3 AND effective_to IS NULL; + INSERT INTO public.gemini_pricing(model, unit_kind, tier, usd_per_unit, source, notes) + VALUES ($1, $2, $3, $4, 'manual', $5) + RETURNING id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes; + COMMIT; + "; + // sea_orm 의 Statement 는 single-statement 라 트랜잭션을 묶기 어렵다 — SQL 분할 실행. + let txn = db.begin().await.map_err(AppError::DatabaseError)?; + let close_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE model = $1 AND unit_kind = $2 AND tier = $3 AND effective_to IS NULL", + vec![ + body.model.clone().into(), + body.unit_kind.clone().into(), + tier.clone().into(), + ], + ); + txn.execute(close_stmt) + .await + .map_err(AppError::DatabaseError)?; + + let insert_stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO public.gemini_pricing(model, unit_kind, tier, usd_per_unit, source, notes) + VALUES ($1, $2, $3, $4, 'manual', $5) + RETURNING id, model, unit_kind, tier, usd_per_unit::float8 AS usd_per_unit, + effective_from, effective_to, source, notes", + vec![ + body.model.into(), + body.unit_kind.into(), + tier.into(), + body.usd_per_unit.into(), + notes + .clone() + .map(|n| n.into()) + .unwrap_or(sea_orm::Value::String(None)), + ], + ); + let new_row = txn + .query_one(insert_stmt) + .await + .map_err(AppError::DatabaseError)? + .ok_or_else(|| AppError::internal("pricing insert returned no row"))?; + let pricing = row_to_pricing(new_row)?; + txn.commit().await.map_err(AppError::DatabaseError)?; + + let _ = txn_sql; // doc reference + Ok(Json(pricing)) +} + +pub async fn retire_pricing( + State(state): State, + _user: axum::Extension, + Path(id): Path, +) -> AppResult { + let db = state.assets_db.as_ref(); + let stmt = Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE public.gemini_pricing + SET effective_to = now(), updated_at = now() + WHERE id = $1 AND effective_to IS NULL", + vec![id.into()], + ); + db.execute(stmt).await.map_err(AppError::DatabaseError)?; + Ok(StatusCode::NO_CONTENT) +} + +// === Router === + +pub fn router(state: AppState, app_config: AppConfig) -> Router { + Router::new() + .route("/spend/daily", get(get_daily_spend)) + .route("/spend/by-step", get(get_spend_by_step)) + .route("/spend/by-model", get(get_spend_by_model)) + .route("/spend/by-pipeline", get(get_spend_by_pipeline)) + .route("/spend/today", get(get_today_spend)) + .route("/spend/top-raw-posts", get(get_top_raw_posts)) + .route("/pricing", get(list_pricing).post(upsert_pricing)) + .route("/pricing/{id}", delete(retire_pricing)) + .layer(axum::middleware::from_fn_with_state( + state, + crate::middleware::admin_db_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + app_config, + crate::middleware::auth_middleware, + )) +} diff --git a/packages/api-server/src/domains/admin/handlers.rs b/packages/api-server/src/domains/admin/handlers.rs index 50a4e9fb..e1eecb84 100644 --- a/packages/api-server/src/domains/admin/handlers.rs +++ b/packages/api-server/src/domains/admin/handlers.rs @@ -9,7 +9,8 @@ use crate::{app_state::AppState, config::AppConfig}; use super::{ badges, categories, curations, dashboard, editorial_article_chat, editorial_articles, editorial_candidates, editorial_discovery_settings, editorial_pipeline_settings, - editorial_recommendations, magazine_sessions, monitoring, posts, solutions, spots, synonyms, + editorial_recommendations, gemini_cost, magazine_sessions, monitoring, posts, solutions, spots, + synonyms, }; use crate::domains::reports; @@ -53,6 +54,10 @@ pub fn router(state: AppState, app_config: AppConfig) -> Router { "/dashboard", dashboard::router(state.clone(), app_config.clone()), ) + .nest( + "/gemini-cost", + gemini_cost::router(state.clone(), app_config.clone()), + ) .nest("/badges", badges::router(app_config.clone())) .nest("/reports", reports::admin_router(app_config.clone())) .nest( diff --git a/packages/api-server/src/domains/admin/mod.rs b/packages/api-server/src/domains/admin/mod.rs index f79112df..4b124107 100644 --- a/packages/api-server/src/domains/admin/mod.rs +++ b/packages/api-server/src/domains/admin/mod.rs @@ -13,6 +13,7 @@ pub mod editorial_candidates; pub mod editorial_discovery_settings; pub mod editorial_pipeline_settings; pub mod editorial_recommendations; +pub mod gemini_cost; pub mod magazine_sessions; pub mod monitoring; pub mod posts; diff --git a/packages/api-server/src/domains/posts/service.rs b/packages/api-server/src/domains/posts/service.rs index 55ddfbf5..fdf3014f 100644 --- a/packages/api-server/src/domains/posts/service.rs +++ b/packages/api-server/src/domains/posts/service.rs @@ -2272,6 +2272,8 @@ pub async fn list_tries_by_spot( /// 까지 한 트랜잭션으로 묶을 수 있도록 변경 (#350). pub async fn create_post_from_raw( db: &C, + post_id: Uuid, + image_url: String, admin_id: Uuid, raw_post: &crate::entities::assets_raw_posts::Model, dto: crate::domains::raw_posts::dto::VerifyRawPostDto, @@ -2282,12 +2284,6 @@ pub async fn create_post_from_raw( subject_style_tags: Option<&[String]>, ) -> AppResult { let now = chrono::Utc::now().fixed_offset(); - let image_url = raw_post.image_url.clone().ok_or_else(|| { - AppError::BadRequest(format!( - "raw_post {} has no image_url — cannot create post", - raw_post.id - )) - })?; // title 우선순위: admin 입력 (or compose_title 결과 호출자가 미리 주입) → // pin caption (보통 비영어/길어 마지막 fallback). subject_parser.title_en 은 @@ -2331,7 +2327,7 @@ pub async fn create_post_from_raw( }; let post = ActiveModel { - id: Set(Uuid::new_v4()), + id: Set(post_id), user_id: Set(admin_id), image_url: Set(image_url), media_type: Set("image".to_string()), diff --git a/packages/api-server/src/domains/raw_posts/dto.rs b/packages/api-server/src/domains/raw_posts/dto.rs index 8044ba77..8dcf45f4 100644 --- a/packages/api-server/src/domains/raw_posts/dto.rs +++ b/packages/api-server/src/domains/raw_posts/dto.rs @@ -317,6 +317,7 @@ pub struct VisionFilterLogEntry { pub platform: String, pub external_id: String, pub source_identifier: Option, + pub image_url: Option, pub verdict: String, pub confidence: Option, pub reason: Option, diff --git a/packages/api-server/src/domains/raw_posts/mod.rs b/packages/api-server/src/domains/raw_posts/mod.rs index 96fae581..27e83f7a 100644 --- a/packages/api-server/src/domains/raw_posts/mod.rs +++ b/packages/api-server/src/domains/raw_posts/mod.rs @@ -8,6 +8,7 @@ pub mod dto; pub mod handlers; +pub(crate) mod relocate; pub mod service; pub use handlers::router; diff --git a/packages/api-server/src/domains/raw_posts/relocate.rs b/packages/api-server/src/domains/raw_posts/relocate.rs new file mode 100644 index 00000000..e9ddfcfc --- /dev/null +++ b/packages/api-server/src/domains/raw_posts/relocate.rs @@ -0,0 +1,229 @@ +//! verify-time R2 relocation (#466 follow-up). +//! +//! Verify 시점에 raw bucket (assets storage) 의 hero / item thumbnail R2 객체를 +//! operation bucket 으로 복사한다. CopyObject 권한 이슈 회피 위해 +//! `download → upload` 두 단계로 처리 — bytes 가 우리 머신을 잠시 통과하지만 +//! 평균 1MB 이미지라 비용 무시. +//! +//! caller 책임: +//! - relocate 성공 후 raw 객체 best-effort delete (단일 책임 분리) +//! - new_key 의 사전 생성 (e.g. `posts/{post_id}`, `items/{solution_id}`) + +use crate::error::{AppError, AppResult}; +use crate::services::storage::StorageClient; + +use super::service::extract_assets_r2_key; + +/// raw bucket 의 객체를 operation bucket 의 새 키로 복사한다. +/// +/// `raw_url` 이 `raw_public_url` prefix 가 아니면 `BadRequest` — verify 흐름은 +/// raw bucket URL 만 가정 (외부 hotlink 은 별도 PR). +/// +/// 반환: operation public URL (`{op_public_url}/{new_key}`). +pub(crate) async fn relocate_raw_to_operation( + raw_storage: &dyn StorageClient, + op_storage: &dyn StorageClient, + raw_url: &str, + raw_public_url: &str, + op_public_url: &str, + new_key: &str, + fallback_content_type: &str, +) -> AppResult { + let raw_key = extract_assets_r2_key(raw_url, raw_public_url).ok_or_else(|| { + AppError::BadRequest(format!( + "non-raw URL cannot be relocated: {raw_url:?} (raw_public_url={raw_public_url:?})" + )) + })?; + + let (bytes, ct) = raw_storage.download(&raw_key).await?; + let content_type = ct.as_deref().unwrap_or(fallback_content_type); + + op_storage + .upload(new_key, bytes, content_type) + .await + .map_err(|e| { + AppError::ExternalService(format!( + "relocate: upload to operation failed (key={new_key}): {e}" + )) + })?; + + Ok(format!( + "{}/{}", + op_public_url.trim_end_matches('/'), + new_key + )) +} + +#[cfg(test)] +#[allow(clippy::disallowed_methods)] +mod tests { + use super::*; + + use async_trait::async_trait; + use std::collections::HashMap; + use std::sync::Mutex; + + /// 테스트용 in-memory StorageClient — download 가 받은 bytes 를 upload 가 + /// 받았는지 검증할 수 있도록 호출 기록 보존. + struct InMemoryStorage { + objects: Mutex, Option)>>, + upload_log: Mutex, String)>>, + public_url: String, + } + + impl InMemoryStorage { + fn new(public_url: &str) -> Self { + Self { + objects: Mutex::new(HashMap::new()), + upload_log: Mutex::new(Vec::new()), + public_url: public_url.to_string(), + } + } + + fn put(&self, key: &str, bytes: Vec, ct: Option) { + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (bytes, ct)); + } + + fn upload_count(&self) -> usize { + self.upload_log.lock().unwrap().len() + } + } + + #[async_trait] + impl StorageClient for InMemoryStorage { + async fn upload( + &self, + key: &str, + data: Vec, + content_type: &str, + ) -> Result { + self.upload_log.lock().unwrap().push(( + key.to_string(), + data.clone(), + content_type.to_string(), + )); + self.objects + .lock() + .unwrap() + .insert(key.to_string(), (data, Some(content_type.to_string()))); + Ok(self.get_url(key)) + } + + async fn delete(&self, key: &str) -> Result<(), AppError> { + self.objects.lock().unwrap().remove(key); + Ok(()) + } + + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + self.objects + .lock() + .unwrap() + .get(key) + .cloned() + .ok_or_else(|| AppError::NotFound(format!("not found: {key}"))) + } + + fn get_url(&self, key: &str) -> String { + format!("{}/{}", self.public_url.trim_end_matches('/'), key) + } + } + + #[tokio::test] + async fn relocate_happy_path_passes_bytes_and_content_type() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + raw.put( + "starstyle/92/926700.jpg", + vec![0xff, 0xd8, 0xff, 0xe0], + Some("image/jpeg".to_string()), + ); + + let new_url = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/92/926700.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc-123", + "image/jpeg", + ) + .await + .unwrap(); + + assert_eq!(new_url, "https://r2.decoded.style/posts/abc-123"); + let log = op.upload_log.lock().unwrap(); + assert_eq!(log.len(), 1); + assert_eq!(log[0].0, "posts/abc-123"); + assert_eq!(log[0].1, vec![0xff, 0xd8, 0xff, 0xe0]); + assert_eq!(log[0].2, "image/jpeg"); + } + + #[tokio::test] + async fn relocate_uses_fallback_content_type_when_missing() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + raw.put("starstyle/x.bin", vec![0u8; 8], None); + + relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/starstyle/x.bin", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/png", + ) + .await + .unwrap(); + + let log = op.upload_log.lock().unwrap(); + assert_eq!(log[0].2, "image/png"); + } + + #[tokio::test] + async fn relocate_rejects_non_raw_url() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://i.pinimg.com/anything/abc.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::BadRequest(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } + + #[tokio::test] + async fn relocate_propagates_download_not_found() { + let raw = InMemoryStorage::new("https://pub-x.r2.dev"); + let op = InMemoryStorage::new("https://r2.decoded.style"); + // raw.put 안 함 — download 시 NotFound + + let err = relocate_raw_to_operation( + &raw, + &op, + "https://pub-x.r2.dev/missing.jpg", + "https://pub-x.r2.dev", + "https://r2.decoded.style", + "posts/abc", + "image/jpeg", + ) + .await + .unwrap_err(); + + assert!(matches!(err, AppError::NotFound(_)), "got {err:?}"); + assert_eq!(op.upload_count(), 0); + } +} diff --git a/packages/api-server/src/domains/raw_posts/service.rs b/packages/api-server/src/domains/raw_posts/service.rs index 33c19f62..72be7d4d 100644 --- a/packages/api-server/src/domains/raw_posts/service.rs +++ b/packages/api-server/src/domains/raw_posts/service.rs @@ -671,7 +671,7 @@ pub async fn delete_item( /// → Some("starstyle/92/926700.jpg") /// /// 다른 도메인 / 빈 public_url / 키가 비면 None — caller 가 cleanup skip. -fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { +pub(crate) fn extract_assets_r2_key(url: &str, public_url: &str) -> Option { let prefix = public_url.trim_end_matches('/'); if prefix.is_empty() { return None; @@ -1057,7 +1057,7 @@ pub async fn list_vision_filter_log( Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, "SELECT id, platform, external_id, source_identifier, verdict, \ - confidence, reason, model, judged_at \ + image_url, confidence, reason, model, judged_at \ FROM public.vision_filter_log \ WHERE verdict = $1 \ ORDER BY judged_at DESC \ @@ -1068,7 +1068,7 @@ pub async fn list_vision_filter_log( Statement::from_sql_and_values( sea_orm::DatabaseBackend::Postgres, "SELECT id, platform, external_id, source_identifier, verdict, \ - confidence, reason, model, judged_at \ + image_url, confidence, reason, model, judged_at \ FROM public.vision_filter_log \ ORDER BY judged_at DESC \ LIMIT $1", @@ -1090,6 +1090,9 @@ pub async fn list_vision_filter_log( source_identifier: row .try_get("", "source_identifier") .map_err(AppError::DatabaseError)?, + image_url: row + .try_get("", "image_url") + .map_err(AppError::DatabaseError)?, verdict: row .try_get("", "verdict") .map_err(AppError::DatabaseError)?, @@ -1281,9 +1284,63 @@ pub async fn verify_raw_post( } } + // 3. R2 relocate (raw → operation) — prod_txn 시작 전 (#466 follow-up). + // raw bucket 의 hero / item thumbnail 객체를 operation bucket 의 정규 + // 키 (`posts/{post_id}`, `items/{solution_id}`) 로 복사하고, 새 URL 만 + // DB 에 들어가도록 한다. copy 도중 실패하면 verify 거부 (DB 부작용 0). + let raw_url = raw_post.image_url.clone().ok_or_else(|| { + AppError::BadRequest(format!("raw_post {id} has no image_url — cannot verify")) + })?; + let assets_pub = state.config.assets_storage.public_url.clone(); + let op_pub = state.config.storage.public_url.clone(); + let post_id = Uuid::new_v4(); + let new_post_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + &raw_url, + &assets_pub, + &op_pub, + &format!("posts/{post_id}"), + "image/jpeg", + ) + .await?; + + // 각 visible item 에 대해 solution_id 사전 생성 + thumbnail copy. None 인 + // thumbnail 은 fallback path (raw 객체 자체가 없음) 라 그대로 None. + let mut prepared_items: Vec<(usize, Uuid, Option, Option)> = Vec::new(); + if let Some(payload) = &parse_payload { + for (idx, item) in payload.items.iter().enumerate().filter(|(_, i)| i.visible) { + let solution_id = Uuid::new_v4(); + let new_thumb = match item.thumbnail_url.as_deref() { + Some(url) if !url.trim().is_empty() => { + let new_url = super::relocate::relocate_raw_to_operation( + state.assets_storage.as_ref(), + state.operation_storage.as_ref(), + url, + &assets_pub, + &op_pub, + &format!("items/{solution_id}"), + "image/jpeg", + ) + .await?; + Some(new_url) + } + _ => None, + }; + // raw thumbnail key 는 두 트랜잭션 commit 후 best-effort delete 용으로 보관. + let raw_thumb_key = item + .thumbnail_url + .as_deref() + .and_then(|u| extract_assets_r2_key(u, &assets_pub)); + prepared_items.push((idx, solution_id, new_thumb, raw_thumb_key)); + } + } + let prod_txn = state.db.begin().await.map_err(AppError::DatabaseError)?; let post = crate::domains::posts::service::create_post_from_raw( &prod_txn, + post_id, + new_post_url.clone(), admin_id, &raw_post, dto, @@ -1365,13 +1422,22 @@ pub async fn verify_raw_post( Some(t.to_string()) } }); + // prepared_items[idx] 에서 사전 생성한 solution_id + 새 operation + // thumbnail_url 가져오기. 정상 흐름이면 매칭되지만, payload.items + // 와 prepared_items 의 idx 가 어긋나면 fallback (현재 동작 유지). + let prepared = prepared_items.iter().find(|(i, _, _, _)| *i == idx); + let (solution_id, thumb_for_db) = match prepared { + Some((_, sid, new_thumb, _)) => (*sid, new_thumb.clone()), + None => (Uuid::new_v4(), item.thumbnail_url.clone()), + }; crate::domains::solutions::service::create_solution_for_verify( &prod_txn, + solution_id, spot.id, admin_id, title, Some(serde_json::Value::Object(metadata)), - item.thumbnail_url.clone(), + thumb_for_db, original_url, ) .await?; @@ -1395,6 +1461,9 @@ pub async fn verify_raw_post( active.verified_at = Set(Some(now)); active.verified_by = Set(Some(admin_id)); active.updated_at = Set(now); + // raw_post.image_url 도 operation URL 로 갱신 (#466 옵션 C). raw bucket + // 객체는 곧 삭제 예정이라 stale URL 이 되지 않게 단일 진실 소스로 통일. + active.image_url = Set(Some(new_post_url.clone())); active.update(&txn).await.map_err(|e| { tracing::error!(raw_post_id=%id, post_id=%post.id, error=%e, "assets status=VERIFIED write FAILED after prod INSERT succeeded — \ @@ -1413,6 +1482,24 @@ pub async fn verify_raw_post( .await?; txn.commit().await.map_err(AppError::DatabaseError)?; + + // 4. raw 객체 best-effort delete (#466 follow-up). 두 트랜잭션 commit 완료 + // 후라 rollback 위험 없음. 실패는 warn 로깅만 — orphan 은 lifecycle rule + // 또는 별도 GC 잡 (out of scope). + if let Some(raw_hero_key) = extract_assets_r2_key(&raw_url, &assets_pub) { + if let Err(e) = state.assets_storage.delete(&raw_hero_key).await { + tracing::warn!(raw_post_id=%id, key=%raw_hero_key, error=%e, + "raw hero delete failed (post-verify cleanup) — orphan"); + } + } + for (_, _, _, raw_thumb_key) in &prepared_items { + if let Some(key) = raw_thumb_key { + if let Err(e) = state.assets_storage.delete(key).await { + tracing::warn!(raw_post_id=%id, key=%key, error=%e, + "raw thumbnail delete failed (post-verify cleanup) — orphan"); + } + } + } Ok(post) } diff --git a/packages/api-server/src/domains/solutions/service.rs b/packages/api-server/src/domains/solutions/service.rs index 22210122..019bd849 100644 --- a/packages/api-server/src/domains/solutions/service.rs +++ b/packages/api-server/src/domains/solutions/service.rs @@ -39,6 +39,7 @@ use super::dto::{ /// 반환값은 row id (#350). pub async fn create_solution_for_verify( db: &C, + solution_id: Uuid, spot_id: Uuid, user_id: Uuid, title: String, @@ -46,7 +47,7 @@ pub async fn create_solution_for_verify( thumbnail_url: Option, original_url: Option, ) -> AppResult { - let id = Uuid::new_v4(); + let id = solution_id; let now = chrono::Utc::now().fixed_offset(); let model = ActiveModel { id: Set(id), diff --git a/packages/api-server/src/domains/users/dto.rs b/packages/api-server/src/domains/users/dto.rs index 483433bf..aa4390bc 100644 --- a/packages/api-server/src/domains/users/dto.rs +++ b/packages/api-server/src/domains/users/dto.rs @@ -166,12 +166,48 @@ pub struct UserSolutionItem { pub created_at: DateTime, } +/// VTON 히스토리 원본 포스트 스냅샷 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrySourcePost { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub artist_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context: Option, +} + +/// VTON 히스토리 선택 아이템 스냅샷 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct TrySelectedItem { + pub id: Uuid, + pub title: String, + pub thumbnail_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub keywords: Option>, +} + /// VTON 히스토리 아이템 #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TryItem { pub id: Uuid, pub image_url: String, pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_post_id: Option, + pub selected_item_ids: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub person_original_image: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_post: Option, + pub selected_items: Vec, } /// 저장된 포스트 아이템 diff --git a/packages/api-server/src/domains/users/service.rs b/packages/api-server/src/domains/users/service.rs index b36fb2d0..e36baa6d 100644 --- a/packages/api-server/src/domains/users/service.rs +++ b/packages/api-server/src/domains/users/service.rs @@ -454,6 +454,11 @@ pub async fn list_my_tries( id: r.id, image_url: r.image_url, created_at: r.created_at.into(), + source_post_id: None, + selected_item_ids: Vec::new(), + person_original_image: None, + source_post: None, + selected_items: Vec::new(), }) .collect(); diff --git a/packages/api-server/src/services/storage/client.rs b/packages/api-server/src/services/storage/client.rs index b8422898..3aceaa71 100644 --- a/packages/api-server/src/services/storage/client.rs +++ b/packages/api-server/src/services/storage/client.rs @@ -33,6 +33,16 @@ pub trait StorageClient: Send + Sync { /// * `key` - 삭제할 파일 키 async fn delete(&self, key: &str) -> Result<(), AppError>; + /// 파일 다운로드 (#466 verify-time relocate) + /// + /// # Arguments + /// * `key` - 다운로드할 파일 키 + /// + /// # Returns + /// `(bytes, content_type)`. content_type 은 R2 의 Content-Type 헤더 — + /// 없으면 None (caller 가 fallback). + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError>; + /// 공개 URL 생성 /// /// # Arguments @@ -78,6 +88,10 @@ impl StorageClient for DummyStorageClient { Ok(()) } + async fn download(&self, _key: &str) -> Result<(Vec, Option), AppError> { + Ok((vec![1, 2, 3], Some("image/jpeg".to_string()))) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.base_url, key) } diff --git a/packages/api-server/src/services/storage/r2.rs b/packages/api-server/src/services/storage/r2.rs index d0a7d40f..9af9c9d4 100644 --- a/packages/api-server/src/services/storage/r2.rs +++ b/packages/api-server/src/services/storage/r2.rs @@ -95,6 +95,32 @@ impl StorageClient for CloudflareR2Client { Ok(()) } + async fn download(&self, key: &str) -> Result<(Vec, Option), AppError> { + let resp = self + .client + .get_object() + .bucket(&self.bucket_name) + .key(key) + .send() + .await + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("NotFound") || msg.contains("NoSuchKey") || msg.contains("404") { + AppError::NotFound(format!("R2 object not found: {}", key)) + } else { + AppError::ExternalService(format!("Failed to download from R2: {}", e)) + } + })?; + + let content_type = resp.content_type().map(String::from); + let body = resp + .body + .collect() + .await + .map_err(|e| AppError::ExternalService(format!("Failed to read R2 body: {}", e)))?; + Ok((body.into_bytes().to_vec(), content_type)) + } + fn get_url(&self, key: &str) -> String { format!("{}/{}", self.public_url, key) } diff --git a/packages/web/app/admin/gemini-cost/page.tsx b/packages/web/app/admin/gemini-cost/page.tsx new file mode 100644 index 00000000..c9283f1d --- /dev/null +++ b/packages/web/app/admin/gemini-cost/page.tsx @@ -0,0 +1,475 @@ +"use client"; + +/** + * /admin/gemini-cost — Gemini API 비용 대시보드 (#cost-tracking). + * + * 데이터 SOT: `public.gemini_usage_events` + `public.gemini_pricing` (assets DB). + * ai-server cost_tracking 모듈이 호출 시점에 fire-and-forget 으로 적립. + */ + +import { useState } from "react"; +import { + ResponsiveContainer, + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, +} from "recharts"; + +import { + useGeminiDailySpend, + useGeminiSpendByStep, + useGeminiSpendByModel, + useGeminiSpendByPipeline, + useGeminiTodaySpend, + useGeminiTopRawPosts, + useGeminiPricing, + useUpsertGeminiPricing, + useRetireGeminiPricing, + type PricingRow, +} from "@/lib/hooks/admin/useGeminiCost"; + +const PERIOD_OPTIONS = [ + { label: "7D", value: 7 }, + { label: "14D", value: 14 }, + { label: "30D", value: 30 }, +]; + +const UNIT_KINDS = [ + "input_token", + "output_token", + "cached_input_token", + "image_output", + "grounding_query", +]; + +function fmtUsd(v: number | undefined): string { + if (v == null) return "—"; + if (v < 0.01) return `$${v.toFixed(5)}`; + if (v < 10) return `$${v.toFixed(3)}`; + return `$${v.toFixed(2)}`; +} + +function fmtInt(v: number | undefined): string { + if (v == null) return "—"; + return v.toLocaleString(); +} + +// ─── KPI Row ────────────────────────────────────────────────────────────────── + +function KpiRow() { + const { data } = useGeminiTodaySpend(); + const items = [ + { + label: "Today", + value: fmtUsd(data?.today_usd), + sub: `${fmtInt(data?.today_calls)} calls / ${fmtInt(data?.today_failed)} failed`, + }, + { label: "Yesterday", value: fmtUsd(data?.yesterday_usd), sub: "" }, + { label: "Last 7d", value: fmtUsd(data?.last_7d_usd), sub: "" }, + { label: "Last 30d", value: fmtUsd(data?.last_30d_usd), sub: "" }, + ]; + return ( +
+ {items.map((it) => ( +
+
+ {it.label} +
+
+ {it.value} +
+ {it.sub && ( +
{it.sub}
+ )} +
+ ))} +
+ ); +} + +// ─── Daily Spend Chart ──────────────────────────────────────────────────────── + +function DailyChart({ days }: { days: number }) { + const { data, isLoading } = useGeminiDailySpend(days); + const rows = (data?.days ?? []).slice().reverse(); // oldest → newest + + return ( +
+
+

Daily Spend

+
+ Total {days}d:{" "} + {fmtUsd(data?.total_usd)} +
+
+
+ {isLoading ? ( +
+ Loading… +
+ ) : rows.length === 0 ? ( +
+ No data yet +
+ ) : ( + + + + + + fmtUsd(typeof v === "number" ? v : Number(v))} + /> + + + + )} +
+
+ ); +} + +// ─── Breakdown bar charts ───────────────────────────────────────────────────── + +function BreakdownCard({ + title, + data, +}: { + title: string; + data: + | { + key: string; + spend_usd: number; + ok_calls: number; + failed_calls: number; + }[] + | undefined; +}) { + const rows = (data ?? []).slice(0, 10); + return ( +
+

{title}

+ {rows.length === 0 ? ( +
+ No data +
+ ) : ( +
+ + + + + + fmtUsd(typeof v === "number" ? v : Number(v))} + /> + + + +
+ )} +
+ ); +} + +// ─── Top raw_posts ──────────────────────────────────────────────────────────── + +function TopRawPostsCard({ days }: { days: number }) { + const { data } = useGeminiTopRawPosts(days, 10); + const rows = data?.rows ?? []; + return ( +
+

+ Top expensive raw_posts ({days}d) +

+ {rows.length === 0 ? ( +
No data
+ ) : ( + + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + +
raw_post_idCallsSpend
+ + {r.raw_post_id.slice(0, 8)}… + + + {fmtInt(r.call_count)} + + {fmtUsd(r.spend_usd)} +
+ )} +
+ ); +} + +// ─── Pricing Editor ─────────────────────────────────────────────────────────── + +function PricingEditor() { + const { data } = useGeminiPricing(); + const upsert = useUpsertGeminiPricing(); + const retire = useRetireGeminiPricing(); + const [showHistory, setShowHistory] = useState(false); + + // 신규 row 추가 입력 상태 + const [model, setModel] = useState(""); + const [unitKind, setUnitKind] = useState("input_token"); + const [rate, setRate] = useState(""); + const [notes, setNotes] = useState(""); + + const handleAdd = async () => { + const value = parseFloat(rate); + if (!model.trim() || !Number.isFinite(value)) { + return; + } + await upsert.mutateAsync({ + model: model.trim(), + unit_kind: unitKind, + usd_per_unit: value, + notes: notes.trim() || null, + }); + setModel(""); + setRate(""); + setNotes(""); + }; + + const renderRow = (p: PricingRow, isActive: boolean) => ( + + {p.model} + {p.unit_kind} + + {p.usd_per_unit < 0.001 + ? `$${p.usd_per_unit.toExponential(3)}` + : `$${p.usd_per_unit.toFixed(6)}`} + + + {new Date(p.effective_from).toLocaleDateString()} →{" "} + {p.effective_to + ? new Date(p.effective_to).toLocaleDateString() + : "active"} + + {p.source} + + {isActive && ( + + )} + + + ); + + return ( +
+
+

+ Pricing (DB SOT) +

+ +
+ + + + + + + + + + + + + {(data?.active ?? []).map((p) => renderRow(p, true))} + {showHistory && (data?.history ?? []).map((p) => renderRow(p, false))} + +
ModelUnitUSD / unitEffectiveSource +
+ +
+ setModel(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100" + /> + + setRate(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs font-mono text-neutral-100" + /> + setNotes(e.target.value)} + className="rounded border border-neutral-700 bg-neutral-900 px-2 py-1 text-xs text-neutral-100" + /> + +
+ {upsert.isError && ( +
+ {upsert.error?.message ?? "Failed"} +
+ )} +
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function GeminiCostPage() { + const [days, setDays] = useState(7); + const byStep = useGeminiSpendByStep(days); + const byModel = useGeminiSpendByModel(days); + const byPipeline = useGeminiSpendByPipeline(days); + + return ( +
+
+
+

+ Gemini Cost +

+

+ Per-call cost tracking. Pricing SOT is the DB table below — no + hardcoding in code. +

+
+
+ {PERIOD_OPTIONS.map((opt) => ( + + ))} +
+
+ + + + + +
+ + + +
+ + + + +
+ ); +} diff --git a/packages/web/app/admin/page.tsx b/packages/web/app/admin/page.tsx index 359cdee9..8f340341 100644 --- a/packages/web/app/admin/page.tsx +++ b/packages/web/app/admin/page.tsx @@ -19,6 +19,7 @@ import { TodaySummarySkeleton, } from "@/lib/components/admin/dashboard/TodaySummary"; import { PipelineHealthCard } from "@/lib/components/admin/dashboard/PipelineHealthCard"; +import { GeminiCostMini } from "@/lib/components/admin/dashboard/GeminiCostMini"; /** * Admin Dashboard Page @@ -56,6 +57,9 @@ export default function AdminDashboardPage() { {/* Pipeline 헬스 — IN_PROGRESS > 0 시 라이브 폴링 (#361) */} + {/* Gemini API cost mini (#cost-tracking) */} + + {/* Traffic Chart */} {chartQuery.isLoading ? ( diff --git a/packages/web/app/api/admin/gemini-cost/[...path]/route.ts b/packages/web/app/api/admin/gemini-cost/[...path]/route.ts new file mode 100644 index 00000000..ccd85cf8 --- /dev/null +++ b/packages/web/app/api/admin/gemini-cost/[...path]/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import { checkIsAdmin } from "@/lib/supabase/admin"; +import { API_BASE_URL } from "@/lib/server-env"; + +/** + * Catch-all proxy: /api/v1/admin/gemini-cost/* → api-server. + * + * Supports GET / POST / DELETE. Auth check + bearer forward. + */ +async function proxy( + request: NextRequest, + context: { params: Promise<{ path: string[] }> } +): Promise { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const isAdmin = await checkIsAdmin(supabase, user.id); + if (!isAdmin) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const { + data: { session }, + } = await supabase.auth.getSession(); + if (!session?.access_token) { + return NextResponse.json({ error: "No session" }, { status: 401 }); + } + + const { path } = await context.params; + const subpath = (path || []).join("/"); + const qs = request.nextUrl.searchParams.toString(); + const url = `${API_BASE_URL}/api/v1/admin/gemini-cost/${subpath}${ + qs ? `?${qs}` : "" + }`; + + const init: RequestInit = { + method: request.method, + headers: { + Authorization: `Bearer ${session.access_token}`, + }, + }; + if (request.method === "POST" || request.method === "PATCH") { + const body = await request.text(); + init.body = body; + (init.headers as Record)["Content-Type"] = + request.headers.get("content-type") || "application/json"; + } + + try { + const response = await fetch(url, init); + const text = await response.text(); + if (!text) { + return new NextResponse(null, { status: response.status }); + } + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = { message: text }; + } + return NextResponse.json(data, { status: response.status }); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("gemini-cost proxy error:", error); + } + return NextResponse.json( + { message: error instanceof Error ? error.message : "Proxy error" }, + { status: 502 } + ); + } +} + +export const GET = proxy; +export const POST = proxy; +export const DELETE = proxy; diff --git a/packages/web/app/api/v1/posts/__tests__/route.test.ts b/packages/web/app/api/v1/posts/__tests__/route.test.ts index e5cc6589..23f0bf29 100644 --- a/packages/web/app/api/v1/posts/__tests__/route.test.ts +++ b/packages/web/app/api/v1/posts/__tests__/route.test.ts @@ -1,9 +1,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const createPostInSupabaseMock = vi.fn(); +const createSupabaseServerClientMock = vi.fn(); vi.mock("@/lib/server-env", () => ({ API_BASE_URL: "", + IS_LOCAL_DATABASE: false, + USE_SUPABASE_FALLBACK: false, +})); + +vi.mock("@/lib/supabase/server", () => ({ + createSupabaseServerClient: createSupabaseServerClientMock, })); vi.mock("../local-create", () => ({ @@ -24,6 +31,68 @@ function makeJsonRequest(body: unknown, authHeader = "Bearer token") { beforeEach(() => { vi.resetModules(); createPostInSupabaseMock.mockReset(); + createSupabaseServerClientMock.mockReset(); +}); + +function makeGetRequest( + url = "http://localhost/api/v1/posts?page=1&per_page=3" +) { + return new Request(url) as unknown as import("next/server").NextRequest; +} + +function createSupabaseQueryMock(response: unknown) { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + not: vi.fn(() => query), + ilike: vi.fn(() => query), + order: vi.fn(() => query), + range: vi.fn(() => query), + then: (resolve: (value: unknown) => unknown) => + Promise.resolve(response).then(resolve), + }; + return query; +} + +describe("GET /api/v1/posts", () => { + it("uses Supabase fallback when backend API base URL is not configured", async () => { + const query = createSupabaseQueryMock({ + data: [ + { + id: "post-1", + image_url: "https://cdn.example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Street", + created_at: "2026-05-07T00:00:00Z", + view_count: 12, + media_type: "image", + title: "Seeded Post", + post_magazines: null, + users: { + id: "user-1", + username: "seed_user", + avatar_url: null, + rank: "member", + }, + }, + ], + count: 1, + error: null, + }); + createSupabaseServerClientMock.mockResolvedValue({ + from: vi.fn(() => query), + }); + + const { GET } = await import("../route"); + const res = await GET(makeGetRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Source")).toBe("supabase-fallback"); + expect(json.data).toHaveLength(1); + expect(json.data[0].title).toBe("Seeded Post"); + }); }); describe("POST /api/v1/posts", () => { diff --git a/packages/web/app/api/v1/posts/route.ts b/packages/web/app/api/v1/posts/route.ts index e22dda98..eff94da6 100644 --- a/packages/web/app/api/v1/posts/route.ts +++ b/packages/web/app/api/v1/posts/route.ts @@ -11,12 +11,16 @@ // the createPostUnified branching helper. import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL, USE_SUPABASE_FALLBACK } from "@/lib/server-env"; +import { + API_BASE_URL, + IS_LOCAL_DATABASE, + USE_SUPABASE_FALLBACK, +} from "@/lib/server-env"; import { createSupabaseServerClient } from "@/lib/supabase/server"; import { createPostInSupabase } from "./local-create"; /** - * Build a 502 response for when the backend is unreachable and the + * Build a 502 response for when a configured backend is unreachable and the * Supabase fallback is disabled. Avoids silently masking backend errors * in dev/staging — opt in via USE_SUPABASE_FALLBACK=true. (#383) */ @@ -37,9 +41,17 @@ function backendUnavailableResponse(detail: string) { * Fetch posts list with optional query parameters */ export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + + const shouldUseSupabaseFallback = + !API_BASE_URL || USE_SUPABASE_FALLBACK || IS_LOCAL_DATABASE; + + if (!API_BASE_URL) { + return supabaseFallback(searchParams); + } + try { // Forward query parameters - const { searchParams } = new URL(request.url); const queryString = searchParams.toString(); const url = queryString ? `${API_BASE_URL}/api/v1/posts?${queryString}` @@ -72,7 +84,7 @@ export async function GET(request: NextRequest) { } // Backend failed → fall back to Supabase only when explicitly enabled (#383) - if (!USE_SUPABASE_FALLBACK) { + if (!shouldUseSupabaseFallback) { return NextResponse.json(data, { status: response.status }); } console.warn( @@ -84,12 +96,11 @@ export async function GET(request: NextRequest) { if (process.env.NODE_ENV === "development") { console.warn("Posts GET proxy error:", error); } - if (!USE_SUPABASE_FALLBACK) { + if (!shouldUseSupabaseFallback) { return backendUnavailableResponse( error instanceof Error ? error.message : String(error) ); } - const { searchParams } = new URL(request.url); return supabaseFallback(searchParams); } } @@ -170,7 +181,7 @@ async function supabaseFallback(searchParams: URLSearchParams) { view_count: post.view_count ?? 0, spot_count: 0, comment_count: 0, - title: post.post_magazines?.title ?? null, + title: post.title ?? post.post_magazines?.title ?? null, post_magazine_title: post.post_magazines?.title ?? null, media_source: { type: post.media_type ?? "unknown", description: null }, user: post.users diff --git a/packages/web/app/api/v1/tries/__tests__/route.test.ts b/packages/web/app/api/v1/tries/__tests__/route.test.ts index 2f5810a4..3c5b5b2e 100644 --- a/packages/web/app/api/v1/tries/__tests__/route.test.ts +++ b/packages/web/app/api/v1/tries/__tests__/route.test.ts @@ -1,39 +1,79 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const getUserMock = vi.fn(); +const headerGetUserMock = vi.fn(); const singleMock = vi.fn(); +const headerSingleMock = vi.fn(); const insertMock = vi.fn(); +const createClientMock = vi.fn(); +const uploadMock = vi.fn(); +const getPublicUrlMock = vi.fn(); + +function createStorageMock() { + return { + from: () => ({ + upload: uploadMock, + getPublicUrl: getPublicUrlMock, + }), + }; +} + +function createSupabaseChain(table: string, single: typeof singleMock) { + const chain = { + insert: (payload: unknown) => { + insertMock(table, payload); + return chain; + }, + select: () => chain, + single, + }; + return chain; +} + +vi.mock("@supabase/supabase-js", () => ({ + createClient: createClientMock, +})); + +vi.mock("@/lib/supabase/env", () => ({ + getEnv: (key: string) => `mock-${key}`, +})); vi.mock("@/lib/supabase/server", () => ({ createSupabaseServerClient: async () => ({ auth: { getUser: getUserMock, }, - from: (table: string) => { - const chain = { - insert: (payload: unknown) => { - insertMock(table, payload); - return chain; - }, - select: () => chain, - single: singleMock, - }; - return chain; - }, + storage: createStorageMock(), + from: (table: string) => createSupabaseChain(table, singleMock), }), })); -function makeRequest(body: unknown) { +function makeRequest(body: unknown, headers?: HeadersInit) { return new Request("http://localhost/api/v1/tries", { method: "POST", + headers, body: JSON.stringify(body), }) as unknown as import("next/server").NextRequest; } beforeEach(() => { getUserMock.mockReset(); + headerGetUserMock.mockReset(); singleMock.mockReset(); + headerSingleMock.mockReset(); insertMock.mockReset(); + createClientMock.mockReset(); + uploadMock.mockReset(); + getPublicUrlMock.mockReset(); + uploadMock.mockResolvedValue({ error: null }); + getPublicUrlMock.mockReturnValue({ + data: { publicUrl: "https://storage.example.com/original.png" }, + }); + createClientMock.mockReturnValue({ + auth: { getUser: headerGetUserMock }, + storage: createStorageMock(), + from: (table: string) => createSupabaseChain(table, headerSingleMock), + }); }); describe("POST /api/v1/tries", () => { @@ -76,6 +116,132 @@ describe("POST /api/v1/tries", () => { style_combination: { source_post_id: "post-1", selected_item_ids: ["item-1", "item-2"], + person_original_image: null, + source_post_snapshot: null, + selected_items_snapshot: [], + }, + }); + }); + + it("uploads the original image and stores detail snapshots in style_combination", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + singleMock.mockResolvedValue({ + data: { + id: "try-1", + image_url: "data:image/png;base64,result", + created_at: "2026-05-07T00:00:00Z", + }, + error: null, + }); + + const { POST } = await import("../route"); + const res = await POST( + makeRequest({ + result_image: "data:image/png;base64,result", + person_original_image: "data:image/png;base64,person", + source_post_id: "post-1", + selected_item_ids: ["item-1", "item-2"], + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Airport look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.jpg", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }) + ); + + expect(res.status).toBe(201); + expect(uploadMock).toHaveBeenCalledWith( + expect.stringMatching(/^user-1\/originals\/.+\.png$/), + expect.any(Uint8Array), + { contentType: "image/png", upsert: false } + ); + expect(insertMock).toHaveBeenCalledWith("user_tryon_history", { + user_id: "user-1", + image_url: "data:image/png;base64,result", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1", "item-2"], + person_original_image: "https://storage.example.com/original.png", + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.jpg", + artist_name: "Artist", + group_name: null, + context: "Airport look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.jpg", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }, + }); + }); + + it("saves with a bearer token when server cookies are unavailable", async () => { + headerGetUserMock.mockResolvedValue({ + data: { user: { id: "bearer-user-1" } }, + }); + headerSingleMock.mockResolvedValue({ + data: { + id: "try-bearer-1", + image_url: "data:image/png;base64,abc", + created_at: "2026-05-07T00:00:00Z", + }, + error: null, + }); + + const { POST } = await import("../route"); + const res = await POST( + makeRequest( + { + result_image: "data:image/png;base64,abc", + selected_item_ids: ["item-1"], + }, + { Authorization: "Bearer access-token-1" } + ) + ); + const json = await res.json(); + + expect(res.status).toBe(201); + expect(json.data.id).toBe("try-bearer-1"); + expect(getUserMock).not.toHaveBeenCalled(); + expect(headerGetUserMock).toHaveBeenCalledWith("access-token-1"); + expect(createClientMock).toHaveBeenCalledWith( + "mock-NEXT_PUBLIC_DATABASE_API_URL", + "mock-NEXT_PUBLIC_DATABASE_ANON_KEY", + expect.objectContaining({ + global: { + headers: { Authorization: "Bearer access-token-1" }, + }, + }) + ); + expect(insertMock).toHaveBeenCalledWith("user_tryon_history", { + user_id: "bearer-user-1", + image_url: "data:image/png;base64,abc", + style_combination: { + source_post_id: null, + selected_item_ids: ["item-1"], + person_original_image: null, + source_post_snapshot: null, + selected_items_snapshot: [], }, }); }); diff --git a/packages/web/app/api/v1/tries/route.ts b/packages/web/app/api/v1/tries/route.ts index f813954d..e86a9eaf 100644 --- a/packages/web/app/api/v1/tries/route.ts +++ b/packages/web/app/api/v1/tries/route.ts @@ -1,20 +1,198 @@ import { NextRequest, NextResponse } from "next/server"; +import { randomUUID } from "crypto"; +import { createClient, type User } from "@supabase/supabase-js"; +import { getEnv } from "@/lib/supabase/env"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin-server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Database, Json } from "@/lib/supabase/types"; interface SaveTryOnBody { result_image?: unknown; image_url?: unknown; + person_original_image?: unknown; source_post_id?: unknown; selected_item_ids?: unknown; + source_post_snapshot?: unknown; + selected_items_snapshot?: unknown; } const MAX_IMAGE_URL_LENGTH = 8_000_000; +const MAX_ORIGINAL_IMAGE_BYTES = 10 * 1024 * 1024; +const SUPPORTED_IMAGE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); +const STORAGE_BUCKETS = ["tryon-images", "decoded-images", "profile"]; + +type JsonRecord = { [key: string]: Json | undefined }; function toStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((item): item is string => typeof item === "string"); } +function optionalString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function nullableString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function toSourcePostSnapshot(value: unknown): JsonRecord | null { + if (!value || typeof value !== "object") return null; + const input = value as Record; + const id = optionalString(input.id); + if (!id) return null; + return { + id, + title: nullableString(input.title), + image_url: nullableString(input.image_url), + artist_name: nullableString(input.artist_name), + group_name: nullableString(input.group_name), + context: nullableString(input.context), + }; +} + +function toSelectedItemSnapshots(value: unknown): JsonRecord[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const input = entry as Record; + const id = optionalString(input.id); + const title = optionalString(input.title); + const thumbnailUrl = optionalString(input.thumbnail_url); + if (!id || !title || !thumbnailUrl) return []; + return [ + { + id, + title, + thumbnail_url: thumbnailUrl, + description: nullableString(input.description), + keywords: Array.isArray(input.keywords) + ? input.keywords.filter( + (keyword): keyword is string => typeof keyword === "string" + ) + : null, + }, + ]; + }); +} + +function tokenFromAuthHeader(authHeader: string): string { + return authHeader.replace(/^Bearer\s+/i, "").trim(); +} + +function extensionForMimeType(type: string): string { + if (type === "image/jpeg") return "jpg"; + if (type === "image/png") return "png"; + if (type === "image/webp") return "webp"; + return "bin"; +} + +function createOptionalAdminClient() { + try { + return createAdminSupabaseClient(); + } catch { + return null; + } +} + +function parseDataUrlImage(value: string): { bytes: Uint8Array; mime: string } { + const match = value.match(/^data:([^;,]+);base64,(.+)$/); + if (!match) throw new Error("invalid_data_url"); + + const mime = match[1]; + if (!SUPPORTED_IMAGE_TYPES.has(mime)) throw new Error("unsupported_image"); + + const buffer = Buffer.from(match[2], "base64"); + if (buffer.byteLength > MAX_ORIGINAL_IMAGE_BYTES) { + throw new Error("image_too_large"); + } + + return { bytes: new Uint8Array(buffer), mime }; +} + +async function persistOriginalImage( + supabase: Awaited>, + userId: string, + value: string | null +): Promise { + if (!value) return null; + if (!value.startsWith("data:")) return value; + + const { bytes, mime } = parseDataUrlImage(value); + const adminSupabase = createOptionalAdminClient(); + const storageSupabase = adminSupabase ?? supabase; + const objectPath = `${userId}/originals/${randomUUID()}.${extensionForMimeType( + mime + )}`; + const bucketCandidates = [ + process.env.NEXT_PUBLIC_UPLOAD_STORAGE_BUCKET, + process.env.UPLOAD_STORAGE_BUCKET, + ...STORAGE_BUCKETS, + ].filter((bucket): bucket is string => Boolean(bucket)); + + let lastError: string | undefined; + for (const bucket of bucketCandidates) { + if (adminSupabase) { + await adminSupabase.storage.createBucket(bucket, { + public: true, + allowedMimeTypes: Array.from(SUPPORTED_IMAGE_TYPES), + fileSizeLimit: MAX_ORIGINAL_IMAGE_BYTES, + }); + } + + const { error } = await storageSupabase.storage + .from(bucket) + .upload(objectPath, bytes, { + contentType: mime, + upsert: false, + }); + + if (!error) { + const { data } = supabase.storage.from(bucket).getPublicUrl(objectPath); + return data.publicUrl; + } + + lastError = error.message; + } + + throw new Error(lastError || "original_upload_failed"); +} + +async function getAuthenticatedSupabase(request: NextRequest): Promise<{ + supabase: Awaited>; + user: User | null; +}> { + const authHeader = request.headers.get("Authorization"); + + if (authHeader) { + const token = tokenFromAuthHeader(authHeader); + const supabase = createClient( + getEnv("NEXT_PUBLIC_DATABASE_API_URL"), + getEnv("NEXT_PUBLIC_DATABASE_ANON_KEY"), + { + auth: { persistSession: false, autoRefreshToken: false }, + global: { headers: { Authorization: `Bearer ${token}` } }, + } + ); + const { + data: { user }, + } = await supabase.auth.getUser(token || undefined); + + return { supabase, user }; + } + + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return { supabase, user }; +} + export async function POST(request: NextRequest) { let body: SaveTryOnBody; @@ -36,25 +214,45 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "image_too_large" }, { status: 413 }); } - const supabase = await createSupabaseServerClient(); - const { - data: { user }, - } = await supabase.auth.getUser(); + const { supabase, user } = await getAuthenticatedSupabase(request); if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } + let personOriginalImage: string | null; + try { + personOriginalImage = await persistOriginalImage( + supabase, + user.id, + optionalString(body.person_original_image) + ); + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error("[POST /api/v1/tries] original upload error:", error); + } + const message = error instanceof Error ? error.message : "upload_failed"; + const status = message === "image_too_large" ? 413 : 400; + return NextResponse.json({ error: message }, { status }); + } + + const styleCombination: Json = { + source_post_id: + typeof body.source_post_id === "string" ? body.source_post_id : null, + selected_item_ids: toStringArray(body.selected_item_ids), + person_original_image: personOriginalImage, + source_post_snapshot: toSourcePostSnapshot(body.source_post_snapshot), + selected_items_snapshot: toSelectedItemSnapshots( + body.selected_items_snapshot + ), + }; + const { data, error } = await supabase .from("user_tryon_history") .insert({ user_id: user.id, image_url: imageUrl, - style_combination: { - source_post_id: - typeof body.source_post_id === "string" ? body.source_post_id : null, - selected_item_ids: toStringArray(body.selected_item_ids), - }, + style_combination: styleCombination, }) .select("id, image_url, created_at") .single(); diff --git a/packages/web/app/api/v1/users/me/route.ts b/packages/web/app/api/v1/users/me/route.ts index 038cc60c..1852b83b 100644 --- a/packages/web/app/api/v1/users/me/route.ts +++ b/packages/web/app/api/v1/users/me/route.ts @@ -7,7 +7,69 @@ */ import { NextRequest, NextResponse } from "next/server"; -import { API_BASE_URL } from "@/lib/server-env"; +import { + API_BASE_URL, + IS_LOCAL_DATABASE, + USE_SUPABASE_FALLBACK, +} from "@/lib/server-env"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { UserRow } from "@/lib/supabase/types"; + +function tokenFromAuthHeader(authHeader: string): string { + return authHeader.replace(/^Bearer\s+/i, "").trim(); +} + +function userResponseFromRow(row: UserRow) { + return { + id: row.id, + email: row.email, + username: row.username, + display_name: row.display_name, + avatar_url: row.avatar_url, + bio: row.bio, + rank: row.rank, + total_points: row.total_points, + ink_credits: row.ink_credits, + is_admin: row.is_admin, + style_dna: row.style_dna, + followers_count: 0, + following_count: 0, + }; +} + +async function fetchSupabaseProfile(authHeader: string) { + const supabase = await createSupabaseServerClient(); + const token = tokenFromAuthHeader(authHeader); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(token || undefined); + + if (authError || !user) { + return NextResponse.json( + { message: "Authentication required" }, + { status: 401 } + ); + } + + const { data, error } = await supabase + .from("users") + .select( + "id,email,username,display_name,avatar_url,bio,rank,total_points,ink_credits,is_admin,style_dna" + ) + .eq("id", user.id) + .maybeSingle(); + + if (error) { + throw new Error(error.message); + } + + if (!data) { + return NextResponse.json({ message: "User not found" }, { status: 404 }); + } + + return NextResponse.json(userResponseFromRow(data as UserRow)); +} /** * GET /api/v1/users/me @@ -22,6 +84,10 @@ export async function GET(request: NextRequest) { ); } + if (!API_BASE_URL) { + return fetchSupabaseProfile(authHeader); + } + try { const response = await fetch(`${API_BASE_URL}/api/v1/users/me`, { method: "GET", @@ -42,6 +108,10 @@ export async function GET(request: NextRequest) { } return NextResponse.json(data, { status: response.status }); } catch (error) { + if (USE_SUPABASE_FALLBACK || IS_LOCAL_DATABASE) { + return fetchSupabaseProfile(authHeader); + } + if (process.env.NODE_ENV === "development") { console.error("Users/me GET proxy error:", error); } diff --git a/packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts b/packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts index 033d62f8..a16c1380 100644 --- a/packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts +++ b/packages/web/app/api/v1/users/me/tries/__tests__/route.test.ts @@ -5,6 +5,8 @@ const rangeMock = vi.fn(); const selectMock = vi.fn(); const eqMock = vi.fn(); const orderMock = vi.fn(); +const postsInMock = vi.fn(); +const solutionsInMock = vi.fn(); vi.mock("@/lib/supabase/server", () => ({ createSupabaseServerClient: async () => ({ @@ -26,6 +28,11 @@ vi.mock("@/lib/supabase/server", () => ({ return chain; }, range: rangeMock, + in: (column: string, values: unknown[]) => { + if (table === "posts") return postsInMock(column, values); + if (table === "solutions") return solutionsInMock(column, values); + throw new Error(`Unexpected in() call for ${table}`); + }, }; return chain; }, @@ -44,6 +51,10 @@ beforeEach(() => { selectMock.mockReset(); eqMock.mockReset(); orderMock.mockReset(); + postsInMock.mockReset(); + solutionsInMock.mockReset(); + postsInMock.mockResolvedValue({ data: [], error: null }); + solutionsInMock.mockResolvedValue({ data: [], error: null }); }); describe("GET /api/v1/users/me/tries", () => { @@ -85,7 +96,7 @@ describe("GET /api/v1/users/me/tries", () => { expect(res.status).toBe(200); expect(selectMock).toHaveBeenCalledWith( "user_tryon_history", - "id, image_url, created_at", + "id, image_url, created_at, style_combination", { count: "exact" } ); expect(eqMock).toHaveBeenCalledWith("user_id", "user-1"); @@ -99,22 +110,416 @@ describe("GET /api/v1/users/me/tries", () => { id: "try-2", image_url: "https://example.com/new.png", created_at: "2026-05-07T01:00:00Z", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], }, { id: "try-1", image_url: "https://example.com/old.png", created_at: "2026-05-07T00:00:00Z", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], }, ], pagination: { current_page: 2, per_page: 2, - total: 22, + total_items: 22, total_pages: 11, }, }); }); + it("returns expanded fields from saved snapshots", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post_snapshot: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items_snapshot: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }, + }, + ], + error: null, + count: 1, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(selectMock).toHaveBeenCalledWith( + "user_tryon_history", + "id, image_url, created_at, style_combination", + { count: "exact" } + ); + expect(postsInMock).not.toHaveBeenCalled(); + expect(solutionsInMock).not.toHaveBeenCalled(); + expect(json.data[0]).toEqual({ + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], + }); + }); + + it("returns fallback detail fields for null style_combination", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-legacy", + image_url: "https://example.com/legacy.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: null, + }, + ], + error: null, + count: 1, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(postsInMock).not.toHaveBeenCalled(); + expect(solutionsInMock).not.toHaveBeenCalled(); + expect(json.data[0]).toMatchObject({ + id: "try-legacy", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], + }); + }); + + it("looks up current rows for legacy IDs without snapshots", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-legacy", + image_url: "https://example.com/legacy.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1", "missing-item"], + }, + }, + ], + error: null, + count: 1, + }); + postsInMock.mockResolvedValue({ + data: [ + { + id: "post-1", + title: "Current Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: "Group", + context: "Current context", + }, + ], + error: null, + }); + solutionsInMock.mockResolvedValue({ + data: [ + { + id: "item-1", + title: "Current Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Current description", + keywords: ["outerwear", 123], + }, + ], + error: null, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(selectMock).toHaveBeenCalledWith( + "posts", + "id, title, image_url, artist_name, group_name, context", + undefined + ); + expect(selectMock).toHaveBeenCalledWith( + "solutions", + "id, title, thumbnail_url, description, keywords", + undefined + ); + expect(postsInMock).toHaveBeenCalledWith("id", ["post-1"]); + expect(solutionsInMock).toHaveBeenCalledWith("id", [ + "item-1", + "missing-item", + ]); + expect(json.data[0]).toMatchObject({ + id: "try-legacy", + source_post_id: "post-1", + selected_item_ids: ["item-1", "missing-item"], + person_original_image: null, + source_post: { + id: "post-1", + title: "Current Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: "Group", + context: "Current context", + }, + selected_items: [ + { + id: "item-1", + title: "Current Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Current description", + keywords: ["outerwear"], + }, + ], + }); + }); + + it("merges partial selected item snapshots with current rows", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-partial", + image_url: "https://example.com/partial.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + selected_item_ids: ["item-1", "item-2"], + selected_items_snapshot: [ + { + id: "item-1", + title: "Saved Jacket", + thumbnail_url: "https://example.com/saved-jacket.png", + }, + ], + }, + }, + ], + error: null, + count: 1, + }); + solutionsInMock.mockResolvedValue({ + data: [ + { + id: "item-2", + title: "Current Pants", + thumbnail_url: "https://example.com/pants.png", + description: null, + keywords: null, + }, + ], + error: null, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(solutionsInMock).toHaveBeenCalledWith("id", ["item-2"]); + expect(json.data[0].selected_items).toEqual([ + { + id: "item-1", + title: "Saved Jacket", + thumbnail_url: "https://example.com/saved-jacket.png", + description: null, + keywords: null, + }, + { + id: "item-2", + title: "Current Pants", + thumbnail_url: "https://example.com/pants.png", + description: null, + keywords: null, + }, + ]); + }); + + it("falls back safely when legacy detail lookup fails", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-legacy", + image_url: "https://example.com/legacy.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + source_post_id: "post-1", + selected_item_ids: ["item-1"], + }, + }, + ], + error: null, + count: 1, + }); + postsInMock.mockResolvedValue({ + data: null, + error: { message: "RLS denied" }, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.data[0]).toMatchObject({ + id: "try-legacy", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + source_post: null, + selected_items: [], + }); + }); + + it("falls back to current post when source snapshot id does not match", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-mismatch", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + source_post_id: "post-1", + selected_item_ids: [], + source_post_snapshot: { + id: "post-2", + title: "Wrong Look", + image_url: "https://example.com/wrong.png", + }, + }, + }, + ], + error: null, + count: 1, + }); + postsInMock.mockResolvedValue({ + data: [ + { + id: "post-1", + title: "Current Look", + image_url: "https://example.com/post.png", + artist_name: null, + group_name: null, + context: null, + }, + ], + error: null, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(postsInMock).toHaveBeenCalledWith("id", ["post-1"]); + expect(json.data[0].source_post).toEqual({ + id: "post-1", + title: "Current Look", + image_url: "https://example.com/post.png", + artist_name: null, + group_name: null, + context: null, + }); + }); + + it("returns saved selected item snapshots when selected IDs are malformed", async () => { + getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); + rangeMock.mockResolvedValue({ + data: [ + { + id: "try-snapshot-only", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + style_combination: { + selected_item_ids: null, + selected_items_snapshot: [ + { + id: "item-1", + title: "Saved Jacket", + thumbnail_url: "https://example.com/saved-jacket.png", + }, + ], + }, + }, + ], + error: null, + count: 1, + }); + + const { GET } = await import("../route"); + const res = await GET(makeRequest()); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(solutionsInMock).not.toHaveBeenCalled(); + expect(json.data[0].selected_items).toEqual([ + { + id: "item-1", + title: "Saved Jacket", + thumbnail_url: "https://example.com/saved-jacket.png", + description: null, + keywords: null, + }, + ]); + }); + it("returns an empty page with valid pagination", async () => { getUserMock.mockResolvedValue({ data: { user: { id: "user-1" } } }); rangeMock.mockResolvedValue({ @@ -134,7 +539,7 @@ describe("GET /api/v1/users/me/tries", () => { pagination: { current_page: 1, per_page: 20, - total: 0, + total_items: 0, total_pages: 0, }, }); diff --git a/packages/web/app/api/v1/users/me/tries/route.ts b/packages/web/app/api/v1/users/me/tries/route.ts index 48c4606b..d399b6eb 100644 --- a/packages/web/app/api/v1/users/me/tries/route.ts +++ b/packages/web/app/api/v1/users/me/tries/route.ts @@ -5,6 +5,31 @@ const DEFAULT_PAGE = 1; const DEFAULT_PER_PAGE = 20; const MAX_PER_PAGE = 50; +type TrySourcePost = { + id: string; + title: string | null; + image_url: string | null; + artist_name?: string | null; + group_name?: string | null; + context?: string | null; +}; + +type TrySelectedItem = { + id: string; + title: string; + thumbnail_url: string; + description?: string | null; + keywords?: string[] | null; +}; + +type StyleCombination = { + source_post_id: string | null; + selected_item_ids: string[]; + person_original_image: string | null; + source_post_snapshot: TrySourcePost | null; + selected_items_snapshot: TrySelectedItem[]; +}; + function parsePositiveInteger(value: string | null, fallback: number): number { if (!value) return fallback; @@ -14,6 +39,78 @@ function parsePositiveInteger(value: string | null, fallback: number): number { return parsed; } +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function stringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.filter((item): item is string => typeof item === "string"); +} + +function normalizeSourcePost(value: unknown): TrySourcePost | null { + if (!value || typeof value !== "object") return null; + const input = value as Record; + const id = stringOrNull(input.id); + if (!id) return null; + return { + id, + title: stringOrNull(input.title), + image_url: stringOrNull(input.image_url), + artist_name: stringOrNull(input.artist_name), + group_name: stringOrNull(input.group_name), + context: stringOrNull(input.context), + }; +} + +function normalizeSelectedItems(value: unknown): TrySelectedItem[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const input = entry as Record; + const id = stringOrNull(input.id); + const title = stringOrNull(input.title); + const thumbnailUrl = stringOrNull(input.thumbnail_url); + if (!id || !title || !thumbnailUrl) return []; + return [ + { + id, + title, + thumbnail_url: thumbnailUrl, + description: stringOrNull(input.description), + keywords: Array.isArray(input.keywords) + ? input.keywords.filter( + (keyword): keyword is string => typeof keyword === "string" + ) + : null, + }, + ]; + }); +} + +function normalizeStyleCombination(value: unknown): StyleCombination { + if (!value || typeof value !== "object") { + return { + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post_snapshot: null, + selected_items_snapshot: [], + }; + } + + const input = value as Record; + return { + source_post_id: stringOrNull(input.source_post_id), + selected_item_ids: stringArray(input.selected_item_ids), + person_original_image: stringOrNull(input.person_original_image), + source_post_snapshot: normalizeSourcePost(input.source_post_snapshot), + selected_items_snapshot: normalizeSelectedItems( + input.selected_items_snapshot + ), + }; +} + export async function GET(request: NextRequest) { const supabase = await createSupabaseServerClient(); const { @@ -35,7 +132,7 @@ export async function GET(request: NextRequest) { const { data, error, count } = await supabase .from("user_tryon_history") - .select("id, image_url, created_at", { count: "exact" }) + .select("id, image_url, created_at, style_combination", { count: "exact" }) .eq("user_id", user.id) .order("created_at", { ascending: false }) .range(from, to); @@ -47,15 +144,106 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "internal_error" }, { status: 500 }); } + const normalizedRows = (data ?? []).map((row) => ({ + row, + style: normalizeStyleCombination( + (row as { style_combination?: unknown }).style_combination + ), + })); + const sourcePostIds = [ + ...new Set( + normalizedRows.flatMap(({ style }) => + style.source_post_id && + style.source_post_snapshot?.id !== style.source_post_id + ? [style.source_post_id] + : [] + ) + ), + ]; + const selectedItemIds = [ + ...new Set( + normalizedRows.flatMap(({ style }) => { + if (style.selected_item_ids.length === 0) return []; + const snapshotIds = new Set( + style.selected_items_snapshot.map((item) => item.id) + ); + return style.selected_item_ids.filter((id) => !snapshotIds.has(id)); + }) + ), + ]; + + const [postResult, solutionResult] = await Promise.all([ + sourcePostIds.length > 0 + ? supabase + .from("posts") + .select("id, title, image_url, artist_name, group_name, context") + .in("id", sourcePostIds) + : Promise.resolve({ data: [], error: null }), + selectedItemIds.length > 0 + ? supabase + .from("solutions") + .select("id, title, thumbnail_url, description, keywords") + .in("id", selectedItemIds) + : Promise.resolve({ data: [], error: null }), + ]); + if ( + (postResult.error || solutionResult.error) && + process.env.NODE_ENV === "development" + ) { + console.error("[GET /api/v1/users/me/tries] detail lookup error:", { + posts: postResult.error, + solutions: solutionResult.error, + }); + } + const postRows = postResult.error ? [] : (postResult.data ?? []); + const solutionRows = solutionResult.error ? [] : (solutionResult.data ?? []); + const postById = new Map( + postRows.flatMap((row) => { + const post = normalizeSourcePost(row); + return post ? [[post.id, post]] : []; + }) + ); + const solutionById = new Map( + normalizeSelectedItems(solutionRows).map((item) => [item.id, item]) + ); + const tries = normalizedRows.map(({ row, style }) => { + const sourcePost = + style.source_post_snapshot?.id === style.source_post_id + ? style.source_post_snapshot + : style.source_post_id + ? (postById.get(style.source_post_id) ?? null) + : null; + const selectedItems = + style.selected_item_ids.length > 0 + ? style.selected_item_ids.flatMap((id) => { + const item = + style.selected_items_snapshot.find( + (snapshot) => snapshot.id === id + ) ?? solutionById.get(id); + return item ? [item] : []; + }) + : style.selected_items_snapshot; + + return { + id: row.id, + image_url: row.image_url, + created_at: row.created_at, + source_post_id: style.source_post_id, + selected_item_ids: style.selected_item_ids, + person_original_image: style.person_original_image, + source_post: sourcePost, + selected_items: selectedItems, + }; + }); const total = count ?? 0; const totalPages = total === 0 ? 0 : Math.ceil(total / perPage); return NextResponse.json({ - data: data ?? [], + data: tries, pagination: { current_page: page, per_page: perPage, - total, + total_items: total, total_pages: totalPages, }, }); diff --git a/packages/web/app/api/v1/vton/items/route.ts b/packages/web/app/api/v1/vton/items/route.ts index 7f979aa2..4400ffda 100644 --- a/packages/web/app/api/v1/vton/items/route.ts +++ b/packages/web/app/api/v1/vton/items/route.ts @@ -1,14 +1,64 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Database, Json } from "@/lib/supabase/types"; -// Subcategory IDs for VTON-eligible fashion categories -const SUBCATEGORY_MAP: Record = { - tops: "230d533e-57b1-4fd9-ae7d-1e4e14fdb091", - bottoms: "d4367fd1-55bd-4ea6-b085-6fed80a65b99", +const VTON_CATEGORY_CODES = ["tops", "bottoms"] as const; +type VtonCategoryCode = (typeof VTON_CATEGORY_CODES)[number]; + +type SupabaseClient = Awaited>; +type SolutionRow = Database["public"]["Tables"]["solutions"]["Row"] & { + spots?: { + subcategory_id: string | null; + posts?: { image_url: string | null } | null; + } | null; }; -// For "all" query, include both -const ALL_SUBCATEGORY_IDS = Object.values(SUBCATEGORY_MAP); +function isVtonCategoryCode(value: string): value is VtonCategoryCode { + return VTON_CATEGORY_CODES.includes(value as VtonCategoryCode); +} + +function toKeywords(value: Json | null): string[] | null { + if (!Array.isArray(value)) return null; + const keywords = value.filter( + (entry): entry is string => typeof entry === "string" + ); + return keywords.length > 0 ? keywords : null; +} + +async function getVtonSubcategoryIds( + supabase: SupabaseClient, + category: string +) { + const codes = isVtonCategoryCode(category) ? [category] : VTON_CATEGORY_CODES; + const { data, error } = await supabase + .from("subcategories") + .select("id") + .in("code", codes); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error( + "Error fetching vton subcategories:", + JSON.stringify(error) + ); + } + return []; + } + + return (data ?? []).map((row) => row.id); +} + +function mapItems(data: SolutionRow[] | null) { + return (data || []) + .map((item) => ({ + id: item.id, + title: item.title || "Untitled item", + thumbnail_url: item.thumbnail_url || item.spots?.posts?.image_url || "", + description: item.description, + keywords: toKeywords(item.keywords), + })) + .filter((item) => item.thumbnail_url); +} export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); @@ -18,28 +68,44 @@ export async function GET(request: NextRequest) { try { const supabase = await createSupabaseServerClient(); + const subcategoryIds = await getVtonSubcategoryIds(supabase, category); - const subcategoryIds = - category && SUBCATEGORY_MAP[category] - ? [SUBCATEGORY_MAP[category]] - : ALL_SUBCATEGORY_IDS; - - let dbQuery = supabase - .from("solutions") - .select( - "id, title, thumbnail_url, description, keywords, spots!inner(subcategory_id)" - ) - .eq("status", "active") - .not("thumbnail_url", "is", null) - .in("spots.subcategory_id", subcategoryIds) - .order("accurate_count", { ascending: false }) - .limit(limit); + const buildQuery = () => + supabase + .from("solutions") + .select( + "id, title, thumbnail_url, description, keywords, spots!inner(subcategory_id, posts(image_url))" + ) + .order("accurate_count", { ascending: false }) + .limit(limit); + let dbQuery = buildQuery(); + if (subcategoryIds.length > 0) { + dbQuery = dbQuery.in("spots.subcategory_id", subcategoryIds); + } if (query) { dbQuery = dbQuery.ilike("title", `%${query}%`); } - const { data, error } = await dbQuery; + const result = await dbQuery; + let data = result.data as SolutionRow[] | null; + let error = result.error; + + if (!error && (!data || data.length === 0) && subcategoryIds.length > 0) { + let fallbackQuery = supabase + .from("solutions") + .select( + "id, title, thumbnail_url, description, keywords, spots(subcategory_id, posts(image_url))" + ) + .order("accurate_count", { ascending: false }) + .limit(limit); + if (query) { + fallbackQuery = fallbackQuery.ilike("title", `%${query}%`); + } + const fallback = await fallbackQuery; + data = fallback.data as SolutionRow[] | null; + error = fallback.error; + } if (error) { if (process.env.NODE_ENV === "development") { @@ -51,15 +117,7 @@ export async function GET(request: NextRequest) { ); } - const items = (data || []).map((item) => ({ - id: item.id, - title: item.title, - thumbnail_url: item.thumbnail_url, - description: item.description, - keywords: item.keywords, - })); - - return NextResponse.json({ items }); + return NextResponse.json({ items: mapItems(data as SolutionRow[] | null) }); } catch (err) { if (process.env.NODE_ENV === "development") { console.error("VTON items error:", err); diff --git a/packages/web/app/api/v1/vton/placeholder/route.ts b/packages/web/app/api/v1/vton/placeholder/route.ts new file mode 100644 index 00000000..90afbbc9 --- /dev/null +++ b/packages/web/app/api/v1/vton/placeholder/route.ts @@ -0,0 +1,61 @@ +import { NextRequest } from "next/server"; + +function escapeXml(value: string) { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function colorFromSeed(seed: string) { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) % 360; + } + return { + primary: `hsl(${hash}, 72%, 54%)`, + secondary: `hsl(${(hash + 52) % 360}, 62%, 36%)`, + }; +} + +export function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const title = searchParams.get("title") || "Decoded"; + const kind = searchParams.get("kind") || "item"; + const width = Number(searchParams.get("w")) || 600; + const height = Number(searchParams.get("h")) || 750; + const { primary, secondary } = colorFromSeed(`${kind}:${title}`); + const label = escapeXml(title); + const eyebrow = escapeXml(kind.toUpperCase()); + + const svg = ` + + + + + + + + + + + + + + + + + ${eyebrow} + +
${label}
+
+
`; + + return new Response(svg, { + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=31536000, immutable", + }, + }); +} diff --git a/packages/web/app/api/v1/vton/posts/route.ts b/packages/web/app/api/v1/vton/posts/route.ts new file mode 100644 index 00000000..a7c6ad07 --- /dev/null +++ b/packages/web/app/api/v1/vton/posts/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createSupabaseServerClient } from "@/lib/supabase/server"; +import type { Json } from "@/lib/supabase/types"; + +const VTON_CATEGORY_CODES = ["tops", "bottoms"] as const; +type SupabaseClient = Awaited>; + +type SolutionRow = { + id: string; + title: string | null; + thumbnail_url: string | null; + description: string | null; + keywords: Json | null; + status: string | null; + accurate_count: number | null; +}; + +type SpotRow = { + subcategory_id: string | null; + solutions?: SolutionRow[] | null; +}; + +type PostRow = { + id: string; + title: string | null; + context: string | null; + artist_name: string | null; + group_name: string | null; + image_url: string | null; + spots?: SpotRow[] | null; +}; + +function toKeywords(value: Json | null): string[] | null { + if (!Array.isArray(value)) return null; + const keywords = value.filter( + (entry): entry is string => typeof entry === "string" + ); + return keywords.length > 0 ? keywords : null; +} + +async function getVtonSubcategoryIds(supabase: SupabaseClient) { + const { data, error } = await supabase + .from("subcategories") + .select("id") + .in("code", VTON_CATEGORY_CODES); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error( + "Error fetching vton subcategories:", + JSON.stringify(error) + ); + } + return new Set(); + } + + return new Set((data ?? []).map((row) => row.id)); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const limit = Math.min(Number(searchParams.get("limit")) || 24, 50); + + try { + const supabase = await createSupabaseServerClient(); + const subcategoryIds = await getVtonSubcategoryIds(supabase); + const { data, error } = await supabase + .from("posts") + .select( + "id, title, context, artist_name, group_name, image_url, spots(subcategory_id, solutions(id, title, thumbnail_url, description, keywords, status, accurate_count))" + ) + .eq("status", "active") + .not("image_url", "is", null) + .order("created_at", { ascending: false }) + .limit(limit * 3); + + if (error) { + if (process.env.NODE_ENV === "development") { + console.error("Error fetching vton posts:", JSON.stringify(error)); + } + return NextResponse.json( + { posts: [], error: error.message }, + { status: 500 } + ); + } + + const posts = ((data ?? []) as PostRow[]) + .map((post) => { + const eligibleSpots = (post.spots ?? []).filter((spot) => + subcategoryIds.size > 0 && spot.subcategory_id + ? subcategoryIds.has(spot.subcategory_id) + : true + ); + const items = eligibleSpots + .flatMap((spot) => spot.solutions ?? []) + .filter( + (solution) => + solution.title && + (solution.thumbnail_url || post.image_url) && + (!solution.status || solution.status === "active") + ) + .sort((a, b) => (b.accurate_count ?? 0) - (a.accurate_count ?? 0)) + .map((solution) => ({ + id: solution.id, + title: solution.title || "Untitled item", + thumbnail_url: solution.thumbnail_url || post.image_url || "", + description: solution.description, + keywords: toKeywords(solution.keywords), + })); + + return { + id: post.id, + title: + post.title || + post.context || + post.artist_name || + post.group_name || + "Untitled post", + image_url: post.image_url || "", + artist_name: post.artist_name, + group_name: post.group_name, + context: post.context, + items, + }; + }) + .filter((post) => post.image_url && post.items.length > 0) + .slice(0, limit); + + return NextResponse.json({ posts }); + } catch (err) { + if (process.env.NODE_ENV === "development") { + console.error("VTON posts error:", err); + } + return NextResponse.json( + { posts: [], error: err instanceof Error ? err.message : "Proxy error" }, + { status: 502 } + ); + } +} diff --git a/packages/web/lib/api/admin/raw-post-sources.ts b/packages/web/lib/api/admin/raw-post-sources.ts index c7c5edcf..0b6e4783 100644 --- a/packages/web/lib/api/admin/raw-post-sources.ts +++ b/packages/web/lib/api/admin/raw-post-sources.ts @@ -206,6 +206,7 @@ export interface VisionFilterLogEntry { platform: string; external_id: string; source_identifier: string | null; + image_url: string | null; verdict: "pass" | "skip"; confidence: number | null; reason: string | null; diff --git a/packages/web/lib/components/MobileNavBar.tsx b/packages/web/lib/components/MobileNavBar.tsx index 9ac840e3..2074a00f 100644 --- a/packages/web/lib/components/MobileNavBar.tsx +++ b/packages/web/lib/components/MobileNavBar.tsx @@ -2,7 +2,7 @@ import { memo } from "react"; import { usePathname } from "next/navigation"; -import { Home, Search, Compass, Upload } from "lucide-react"; +import { Home, Search, Compass, Sparkles } from "lucide-react"; import { NavBar, NavItem } from "@/lib/design-system"; interface NavItemConfig { @@ -11,17 +11,15 @@ interface NavItemConfig { icon: React.ComponentType<{ className?: string }>; label: string; disabled?: boolean; - isAction?: boolean; } /** - * Navigation items for 1차 릴리즈: Home, Search, Explore (3 items) - * Upload/Profile은 추후 추가 예정 + * Navigation items for 1차 릴리즈. */ const navItems: NavItemConfig[] = [ { id: "home", href: "/", icon: Home, label: "Home" }, { id: "search", href: "/search", icon: Search, label: "Search" }, - { id: "upload", href: "/request/upload", icon: Upload, label: "Upload" }, + { id: "tryon", href: "/lab/vton", icon: Sparkles, label: "Try On" }, { id: "explore", href: "/explore", icon: Compass, label: "Explore" }, ]; @@ -30,7 +28,7 @@ const navItems: NavItemConfig[] = [ * * Design spec: * - Height: 64px - * - 5 items: Home, Search, Request, Feed, Profile + * - 4 items: Home, Search, Try On, Explore * - Each item: icon (22px) + label (10px, font-medium) * - Background: card color * - Padding: 8px 24px @@ -40,7 +38,7 @@ const navItems: NavItemConfig[] = [ * - Hidden on desktop (md:hidden) * - Active state with primary color * - Safe area support for iPhone notch - * - Request opens modal instead of page navigation + * - Try On routes through the VTON lab entry point */ export const MobileNavBar = memo(() => { const pathname = usePathname(); diff --git a/packages/web/lib/components/admin/AdminSidebar.tsx b/packages/web/lib/components/admin/AdminSidebar.tsx index 66876b76..de7b8c4b 100644 --- a/packages/web/lib/components/admin/AdminSidebar.tsx +++ b/packages/web/lib/components/admin/AdminSidebar.tsx @@ -15,6 +15,7 @@ import { Tag, UsersRound, Link2, + DollarSign, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAuthStore } from "@/lib/stores/authStore"; @@ -62,6 +63,12 @@ const SIDEBAR_ENTRIES: SidebarEntry[] = [ }, ], }, + { + label: "Observability", + items: [ + { href: "/admin/gemini-cost", label: "Gemini Cost", icon: DollarSign }, + ], + }, ]; interface AdminSidebarProps { diff --git a/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx b/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx new file mode 100644 index 00000000..25caeb4a --- /dev/null +++ b/packages/web/lib/components/admin/dashboard/GeminiCostMini.tsx @@ -0,0 +1,70 @@ +"use client"; + +/** + * Compact today-Gemini-cost card for the /admin home (#cost-tracking). + * Full breakdown lives at /admin/gemini-cost. + */ + +import Link from "next/link"; +import { useGeminiTodaySpend } from "@/lib/hooks/admin/useGeminiCost"; + +function fmtUsd(v: number | undefined): string { + if (v == null) return "—"; + if (v < 0.01) return `$${v.toFixed(5)}`; + if (v < 10) return `$${v.toFixed(3)}`; + return `$${v.toFixed(2)}`; +} + +export function GeminiCostMini() { + const { data, isLoading } = useGeminiTodaySpend(); + + return ( + +
+

Gemini Cost

+ view → +
+ {isLoading ? ( +
+ ) : ( +
+
+
+ Today +
+
+ {fmtUsd(data?.today_usd)} +
+
+
+
+ Yesterday +
+
+ {fmtUsd(data?.yesterday_usd)} +
+
+
+
+ 7d +
+
+ {fmtUsd(data?.last_7d_usd)} +
+
+
+
+ 30d +
+
+ {fmtUsd(data?.last_30d_usd)} +
+
+
+ )} + + ); +} diff --git a/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx b/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx index 8d00c050..8fe74c13 100644 --- a/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx +++ b/packages/web/lib/components/admin/raw-post-sources/VisionFilterLog.tsx @@ -42,6 +42,9 @@ function postUrl(platform: string, externalId: string): string | null { } function previewSrc(entry: VisionFilterLogEntry): string | null { + if (entry.platform === "instagram" && entry.image_url) { + return entry.image_url; + } if (entry.platform === "pinterest" && /^\d+$/.test(entry.external_id)) { return `/api/admin/raw-post-sources/preview?platform=pinterest&pin=${encodeURIComponent( entry.external_id diff --git a/packages/web/lib/components/main-renewal/SmartNav.tsx b/packages/web/lib/components/main-renewal/SmartNav.tsx index a48ea1c1..5ace7b41 100644 --- a/packages/web/lib/components/main-renewal/SmartNav.tsx +++ b/packages/web/lib/components/main-renewal/SmartNav.tsx @@ -14,6 +14,8 @@ import { selectProfile, selectLogout, } from "@/lib/stores/authStore"; +import { useVtonStore } from "@/lib/stores/vtonStore"; +import { Sparkles } from "lucide-react"; const DecodedLogo = dynamic(() => import("@/lib/components/DecodedLogo"), { ssr: false, loading: () => ( @@ -50,6 +52,7 @@ export function SmartNav({ className }: SmartNavProps) { const user = useAuthStore(selectUser); const profile = useAuthStore(selectProfile); const logout = useAuthStore(selectLogout); + const openVton = useVtonStore((state) => state.open); const [isProfileOpen, setIsProfileOpen] = useState(false); const profileRef = useRef(null); @@ -172,12 +175,14 @@ export function SmartNav({ className }: SmartNavProps) { ); })} - {/* Upload — intercept route handles modal overlay vs full page */} + {/* Try On */} {/* Auth: Login / Profile Dropdown */} @@ -225,11 +230,11 @@ export function SmartNav({ className }: SmartNavProps) { + ))} + + {/* Sentinel for infinite scroll trigger */} +
+ + {/* Loading more indicator */} + {isFetchingNextPage && ( +
+ {Array.from({ length: 2 }).map((_, i) => ( +
+ ))}
- - ))} - - {/* Sentinel for infinite scroll trigger */} -
- - {/* Loading more indicator */} - {isFetchingNextPage && ( -
- {Array.from({ length: 2 }).map((_, i) => ( -
- ))} -
- )} -
+ )} +
+ {selectedTry ? ( + setSelectedTry(null)} + /> + ) : null} + ); } diff --git a/packages/web/lib/components/profile/TryDetailModal.tsx b/packages/web/lib/components/profile/TryDetailModal.tsx new file mode 100644 index 00000000..e431c69d --- /dev/null +++ b/packages/web/lib/components/profile/TryDetailModal.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { useEffect } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { X } from "lucide-react"; +import type { TryItem } from "@/lib/api/generated/models"; +import { BeforeAfterSlider } from "@/lib/components/vton/BeforeAfterSlider"; + +interface TryDetailModalProps { + tryItem: TryItem; + onClose: () => void; +} + +export function TryDetailModal({ tryItem, onClose }: TryDetailModalProps) { + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + const savedDate = new Date(tryItem.created_at).toLocaleDateString("ko-KR", { + year: "numeric", + month: "short", + day: "numeric", + }); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+ + +
+
+
+ {tryItem.person_original_image ? ( + + ) : ( + Try-on result + )} + {tryItem.person_original_image ? ( +
+ + drag to compare + +
+ ) : null} +
+
+ {tryItem.person_original_image ? "Original / Result" : "Result"} +
+
+ + {!tryItem.person_original_image ? ( +
+ Original image unavailable +
+ ) : null} + + {tryItem.source_post?.image_url ? ( +
+
+ Source post +
+
+ Source post +
+
+ ) : ( +
+ Source post unavailable +
+ )} +
+ + +
+
+ ); +} diff --git a/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx b/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx new file mode 100644 index 00000000..ca5e7b8b --- /dev/null +++ b/packages/web/lib/components/profile/__tests__/TriesGrid.test.tsx @@ -0,0 +1,64 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TriesGrid } from "../TriesGrid"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +vi.mock("@/lib/api/generated/users/users", () => ({ + getMyTries: vi.fn(async () => ({ + data: [ + { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: null, + selected_item_ids: [], + person_original_image: null, + source_post: null, + selected_items: [], + }, + ], + pagination: { + current_page: 1, + per_page: 20, + total: 1, + total_pages: 1, + }, + })), +})); + +function renderGrid() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + + + + ); +} + +describe("TriesGrid", () => { + it("opens the detail modal when a try card is clicked", async () => { + renderGrid(); + + await waitFor(() => + expect(screen.getByAltText("Try-on result")).toBeInTheDocument() + ); + fireEvent.click(screen.getByRole("button", { name: /open try-on/i })); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText("Try-on details")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx b/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx new file mode 100644 index 00000000..fafd71e5 --- /dev/null +++ b/packages/web/lib/components/profile/__tests__/TryDetailModal.test.tsx @@ -0,0 +1,89 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable @next/next/no-img-element */ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { TryDetailModal } from "../TryDetailModal"; +import type { TryItem } from "@/lib/api/generated/models"; + +vi.mock("next/image", () => ({ + default: (props: React.ImgHTMLAttributes) => ( + + ), +})); + +const fullTry: TryItem = { + id: "try-1", + image_url: "https://example.com/result.png", + created_at: "2026-05-07T00:00:00Z", + source_post_id: "post-1", + selected_item_ids: ["item-1"], + person_original_image: "https://example.com/person.png", + source_post: { + id: "post-1", + title: "Source Look", + image_url: "https://example.com/post.png", + artist_name: "Artist", + group_name: null, + context: "Stage look", + }, + selected_items: [ + { + id: "item-1", + title: "Jacket", + thumbnail_url: "https://example.com/jacket.png", + description: "Black jacket", + keywords: ["outerwear"], + }, + ], +}; + +describe("TryDetailModal", () => { + it("renders comparison slider, source post, and selected items", () => { + render( {}} />); + + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByAltText("Result")).toBeInTheDocument(); + expect(screen.getByAltText("Original")).toBeInTheDocument(); + expect(screen.getByText("drag to compare")).toBeInTheDocument(); + expect(screen.getByText("Source Look")).toBeInTheDocument(); + expect(screen.getByText("Jacket")).toBeInTheDocument(); + expect(screen.getByText("outerwear")).toBeInTheDocument(); + }); + + it("calls onClose from the close button", () => { + const onClose = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /close/i })); + + expect(onClose).toHaveBeenCalled(); + }); + + it("renders fallback text when metadata is missing", () => { + render( + {}} + /> + ); + + expect(screen.getByText("Original image unavailable")).toBeInTheDocument(); + expect( + screen.getAllByText("Source post unavailable").length + ).toBeGreaterThan(0); + expect(screen.getByText("Items unavailable")).toBeInTheDocument(); + }); +}); diff --git a/packages/web/lib/components/vton/VtonItemPanel.tsx b/packages/web/lib/components/vton/VtonItemPanel.tsx index 4be3040d..1b6ad207 100644 --- a/packages/web/lib/components/vton/VtonItemPanel.tsx +++ b/packages/web/lib/components/vton/VtonItemPanel.tsx @@ -6,8 +6,10 @@ import { type ItemData, type Category, } from "@/lib/hooks/useVtonItemFetch"; +import type { VtonPostData } from "@/lib/hooks/useVtonPostFetch"; interface VtonItemPanelProps { + sourceMode: "items" | "posts"; isPostMode: boolean; hasActiveJob: boolean; activeCategory: Category; @@ -15,21 +17,48 @@ interface VtonItemPanelProps { selectedPostItemIds: Set; jobSelectedItems: ItemData[]; items: ItemData[]; + posts: VtonPostData[]; + sourcePostId: string | null; selectedList: ItemData[]; selectedCount: number; personImage: string | null; isProcessing: boolean; + isLoadingPosts: boolean; searchQuery: string; error: string | null; bgError: { error: string | null } | null; + onSourceModeChange: (mode: "items" | "posts") => void; onCategoryChange: (category: Category) => void; onSearchChange: (query: string) => void; onSelectItem: (item: ItemData) => void; + onSelectPost: (postId: string, items: ItemData[]) => void; onDeselect: (item: ItemData) => void; onTryOn: () => void; } +function fallbackImageUrl(title: string, kind: "item" | "post") { + const params = new URLSearchParams({ + title, + kind, + w: kind === "post" ? "600" : "500", + h: kind === "post" ? "750" : "500", + }); + return `/api/v1/vton/placeholder?${params}`; +} + +function handleImageError( + event: React.SyntheticEvent, + title: string, + kind: "item" | "post" +) { + const img = event.currentTarget; + const fallback = fallbackImageUrl(title, kind); + if (img.src.endsWith(fallback)) return; + img.src = fallback; +} + export function VtonItemPanel({ + sourceMode, isPostMode, hasActiveJob, activeCategory, @@ -37,20 +66,26 @@ export function VtonItemPanel({ selectedPostItemIds, jobSelectedItems, items, + posts, + sourcePostId, selectedList, selectedCount, personImage, isProcessing, + isLoadingPosts, searchQuery, error, bgError, + onSourceModeChange, onCategoryChange, onSearchChange, onSelectItem, + onSelectPost, onDeselect, onTryOn, }: VtonItemPanelProps) { const currentSelected = selectedItems[activeCategory]; + const showPostGrid = sourceMode === "posts" && !isPostMode; return (
+
+ {[ + { key: "items" as const, label: "Items" }, + { key: "posts" as const, label: "Posts" }, + ].map((source) => { + const isActive = sourceMode === source.key; + return ( + + ); + })} +
+ {/* Category tabs — hidden in post mode */} - {!isPostMode && ( + {sourceMode === "items" && !isPostMode && (
{CATEGORIES.map((cat) => { const isActive = activeCategory === cat.key; @@ -100,13 +158,13 @@ export function VtonItemPanel({ {isPostMode && (

- 이 포스트의 아이템 — 여러 개 선택 가능 + Selected post items — choose one or more

)} {/* Search — hidden in post mode */} - {!isPostMode && ( + {sourceMode === "items" && !isPostMode && (
-

- {items.length} item{items.length !== 1 ? "s" : ""} -

-
- {items.map((item) => { - const isSelected = hasActiveJob - ? jobSelectedItems.some((j) => j.id === item.id) - : isPostMode - ? selectedPostItemIds.has(item.id) - : currentSelected?.id === item.id; - return ( - - ); - })} -
+
+

+ {post.title} +

+

+ {post.items.length} item + {post.items.length !== 1 ? "s" : ""} +

+
+ + ))} +
+ {!isLoadingPosts && posts.length === 0 && ( +

+ No try-on ready posts yet. +

+ )} + + ) : ( + <> +

+ {items.length} item{items.length !== 1 ? "s" : ""} +

+
+ {items.map((item) => { + const isSelected = hasActiveJob + ? jobSelectedItems.some((j) => j.id === item.id) + : isPostMode + ? selectedPostItemIds.has(item.id) + : currentSelected?.id === item.id; + return ( + + ); + })} +
+ + )}
{/* Selected summary + Try On */} @@ -186,6 +303,9 @@ export function VtonItemPanel({ {item.title} + handleImageError(event, item.title, "item") + } className="h-12 w-12 rounded-lg border border-[#eafd67]/50 object-cover" />