vivamini's picture
Add client error reporting endpoint and admin log viewer
5d011b8
Raw
History Blame Contribute Delete
39.4 kB
import os
from dotenv import load_dotenv
load_dotenv() # .env ํŒŒ์ผ ์ž๋™ ๋กœ๋“œ
import re
import json
import time
import base64
import io
import secrets
import hashlib
from datetime import datetime, timedelta, timezone
from pathlib import Path
import httpx
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from json_repair import repair_json
from fastapi import FastAPI, HTTPException, Header
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
app = FastAPI(title="Dalibaba API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["POST", "GET"],
allow_headers=["*"],
)
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
GROQ_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
DEEPL_API_KEY = os.environ.get("DEEPL_API_KEY", "")
DEEPL_URL = "https://api-free.deepl.com/v2/translate"
KST = timezone(timedelta(hours=9))
DATA_DIR = Path(__file__).resolve().parent / "data"
STORE_PATH = DATA_DIR / "store.json"
FREE_DAILY_AI_LIMIT = 3
PAID_DAILY_AI_LIMIT = 100
PLANS = [
{"id": "pass_1d", "kind": "pass", "days": 1, "label": "1์ผ๊ถŒ", "priceUsd": 1.99},
{"id": "pass_3d", "kind": "pass", "days": 3, "label": "3์ผ๊ถŒ", "priceUsd": 3.99},
{"id": "pass_7d", "kind": "pass", "days": 7, "label": "7์ผ๊ถŒ", "priceUsd": 7.99},
{"id": "sub_month", "kind": "subscription", "days": 30, "label": "์›” ๊ตฌ๋…", "priceUsd": 8.99},
{"id": "sub_year", "kind": "subscription", "days": 365, "label": "์—ฐ ๊ตฌ๋…", "priceUsd": 59.99},
]
def _now() -> datetime:
return datetime.now(KST)
def _today_key() -> str:
return _now().strftime("%Y-%m-%d")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
_EMPTY_STORE = {"users": {}, "sessions": {}, "usage": {}}
if DATABASE_URL:
import psycopg
from psycopg.types.json import Json
def _db_conn():
return psycopg.connect(DATABASE_URL)
def _ensure_table() -> None:
try:
with _db_conn() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS app_store (id INT PRIMARY KEY, data JSONB NOT NULL)")
conn.execute(
"INSERT INTO app_store (id, data) VALUES (1, %s) ON CONFLICT (id) DO NOTHING",
(Json(_EMPTY_STORE),),
)
conn.commit()
except Exception as e:
print(f"[DB] ์ดˆ๊ธฐํ™” ์‹คํŒจ: {e}")
_ensure_table()
def _load_store() -> dict:
try:
with _db_conn() as conn:
row = conn.execute("SELECT data FROM app_store WHERE id = 1").fetchone()
if row and isinstance(row[0], dict):
data = row[0]
return {
"users": data.get("users", {}),
"sessions": data.get("sessions", {}),
"usage": data.get("usage", {}),
}
except Exception as e:
print(f"[DB] ์กฐํšŒ ์‹คํŒจ: {e}")
return {"users": {}, "sessions": {}, "usage": {}}
def _save_store(store: dict) -> None:
try:
with _db_conn() as conn:
conn.execute(
"UPDATE app_store SET data = %s WHERE id = 1",
(Json(store),),
)
conn.commit()
except Exception as e:
print(f"[DB] ์ €์žฅ ์‹คํŒจ: {e}")
else:
def _load_store() -> dict:
if not STORE_PATH.exists():
return {"users": {}, "sessions": {}, "usage": {}}
try:
return json.loads(STORE_PATH.read_text(encoding="utf-8"))
except Exception:
return {"users": {}, "sessions": {}, "usage": {}}
def _save_store(store: dict) -> None:
DATA_DIR.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8")
def _hash_password(password: str, salt: str | None = None) -> tuple[str, str]:
salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120000)
return salt, digest.hex()
def _active_entitlement(user: dict | None) -> dict | None:
if not user:
return None
entitlement = user.get("entitlement")
if not entitlement:
return None
expires_at = entitlement.get("expiresAt")
if not expires_at:
return None
try:
if datetime.fromisoformat(expires_at) > _now():
return entitlement
except ValueError:
return None
return None
def _public_user(user: dict | None) -> dict | None:
if not user:
return None
entitlement = _active_entitlement(user)
return {
"email": user.get("email"),
"createdAt": user.get("createdAt"),
"entitlement": entitlement,
}
def _get_user_by_token(authorization: str | None) -> tuple[str | None, dict | None, dict]:
store = _load_store()
if not authorization or not authorization.startswith("Bearer "):
return None, None, store
token = authorization.replace("Bearer ", "", 1).strip()
email = store.get("sessions", {}).get(token)
user = store.get("users", {}).get(email or "")
return email, user, store
def _usage_identity(user_email: str | None, guest_id: str | None) -> str:
if user_email:
return f"user:{user_email.lower()}"
guest = (guest_id or "").strip()[:80]
return f"guest:{guest or 'anonymous'}"
def _usage_status(store: dict, identity: str, user: dict | None) -> dict:
entitlement = _active_entitlement(user)
limit = PAID_DAILY_AI_LIMIT if entitlement else FREE_DAILY_AI_LIMIT
date_key = _today_key()
bucket = store.setdefault("usage", {}).setdefault(identity, {})
used = int(bucket.get(date_key, 0))
return {
"date": date_key,
"used": used,
"limit": limit,
"remaining": max(0, limit - used),
"plan": entitlement,
}
# โ”€โ”€ ํฐํŠธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
FONT_PATHS = [
"/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
"/usr/share/fonts/nanum/NanumGothic.ttf",
"C:/Windows/Fonts/malgun.ttf",
"C:/Windows/Fonts/NanumGothic.ttf",
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
]
def get_font(size: int):
for path in FONT_PATHS:
if os.path.exists(path):
try:
return ImageFont.truetype(path, max(10, size))
except Exception:
pass
return ImageFont.load_default()
# โ”€โ”€ ํ”„๋กฌํ”„ํŠธ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
PRICE_PROMPT = """์ด ์ด๋ฏธ์ง€๋ฅผ ๋ถ„์„ํ•˜๊ณ  menu, receipt, other ์ค‘ ํ•˜๋‚˜๋กœ ๋ถ„๋ฅ˜ํ•˜์„ธ์š”.
๋จผ์ € ์ด๋ฏธ์ง€ ์ข…๋ฅ˜๋ฅผ ํŒ๋‹จํ•˜์„ธ์š”:
- ๋ฉ”๋‰ดํŒ์ด๋ฉด documentType = "menu"
- ๊ฒฐ์ œ ์˜์ˆ˜์ฆ/์ฃผ๋ฌธ ์˜์ˆ˜์ฆ์ด๋ฉด documentType = "receipt"
- ๊ทธ ์™ธ ๋ชจ๋“  ์ด๋ฏธ์ง€(์˜๋ฅ˜ ํƒ, ์•ˆ๋‚ด๋ฌธ, ํฌ์žฅ์ง€, ํ‘œ์ง€ํŒ, ๋ฌธ์„œ ๋“ฑ)๋Š” documentType = "other"
other ๋ถ„์„ ๊ทœ์น™:
- ์ด๋ฏธ์ง€์—์„œ ์ฝ์„ ์ˆ˜ ์žˆ๋Š” ์˜๋ฏธ ์žˆ๋Š” ํ…์ŠคํŠธ๋ฅผ ์œ„์—์„œ ์•„๋ž˜, ์™ผ์ชฝ์—์„œ ์˜ค๋ฅธ์ชฝ ์ˆœ์„œ๋กœ ์ถ”์ถœํ•  ๊ฒƒ
- originalText์—๋Š” ์ด๋ฏธ์ง€์— ์ ํžŒ ํ…์ŠคํŠธ๋ฅผ ๊ธ€์ž์™€ ์ˆซ์ž ๊ทธ๋Œ€๋กœ ๋„ฃ์„ ๊ฒƒ
- translatedText์—๋Š” originalText๋ฅผ ์ž์—ฐ์Šค๋Ÿฌ์šด ํ•œ๊ตญ์–ด๋กœ ๋ฒˆ์—ญํ•  ๊ฒƒ
- ๋ธŒ๋žœ๋“œ๋ช…, ๋ชจ๋ธ๋ช…, ์ œํ’ˆ ์ฝ”๋“œ์ฒ˜๋Ÿผ ๋ฒˆ์—ญํ•˜๋ฉด ์•ˆ ๋˜๋Š” ๊ณ ์œ ๋ช…์‚ฌ๋Š” ์›๋ฌธ์„ ์œ ์ง€ํ•  ๊ฒƒ
- ๊ฐ™์€ ๋ฌธ๊ตฌ๋ฅผ ์ค‘๋ณตํ•ด์„œ ๋„ฃ์ง€ ๋ง๊ณ , ์ฝ์„ ์ˆ˜ ์—†๋Š” ๋‚ด์šฉ์€ ์ถ”์ธกํ•˜์ง€ ๋ง ๊ฒƒ
- other์ธ ๊ฒฝ์šฐ prices๋Š” ๋นˆ ๋ฐฐ์—ด๋กœ ๋ฐ˜ํ™˜ํ•  ๊ฒƒ
โ˜… ๊ฐ€์žฅ ์ค‘์š”ํ•œ ๊ทœ์น™ โ˜…
๊ฐ ๋ฉ”๋‰ด ํ•ญ๋ชฉ๋งˆ๋‹ค ๋‘ ๊ฐ€์ง€ ์ด๋ฆ„์„ ๋”ฐ๋กœ ์ ์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค:
1. originalText = ์ด๋ฏธ์ง€์— ์‹ค์ œ๋กœ ์ธ์‡„๋œ ๊ธ€์ž ๊ทธ๋Œ€๋กœ (์ผ๋ณธ์–ด๋ฉด ์ผ๋ณธ์–ด, ์˜์–ด๋ฉด ์˜์–ด, ์ ˆ๋Œ€ ๋ฒˆ์—ญ ๊ธˆ์ง€)
2. context = ์œ„ ๋ฉ”๋‰ด๋ฅผ ํ•œ๊ตญ์–ด๋กœ ๋ฒˆ์—ญํ•œ ์ด๋ฆ„
์˜ˆ์‹œ (์ผ๋ณธ์–ด ๋ฉ”๋‰ดํŒ):
- originalText: "ๅ”ๆšใ’ๅฎš้ฃŸ" โ†’ context: "๋‹ญ ํŠ€๊น€ ์ •์‹"
- originalText: "็”Ÿใƒ“ใƒผใƒซ" โ†’ context: "์ƒ๋งฅ์ฃผ"
- originalText: "ๆตท่€ๅคฉไธผ" โ†’ context: "์ƒˆ์šฐํŠ€๊น€ ๋ฎ๋ฐฅ"
โ˜… ์‚ฌ์ด์ฆˆ/์˜ต์…˜ ๋ณ€ํ˜• ์ฒ˜๋ฆฌ ๊ทœ์น™ โ˜…
ํ•˜๋‚˜์˜ ๋ฉ”๋‰ด์— ๅฐ/ไธญ/ๅคง, S/M/L, ํ•˜ํ”„/ํ’€ ๋“ฑ ํฌ๊ธฐ๋‚˜ ์˜ต์…˜๋ณ„๋กœ ๊ฐ€๊ฒฉ์ด ๋‹ค๋ฅธ ๊ฒฝ์šฐ:
โ†’ ๊ฐ๊ฐ์„ ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ๋ถ„๋ฆฌํ•  ๊ฒƒ
์˜ˆ์‹œ:
- originalText: "็†Ÿๆˆใ‚ซใƒซใƒ“ ๅฐ" โ†’ context: "์ˆ™์„ฑ ๊ฐˆ๋น„ (์†Œ)" / amount: 1500
- originalText: "็†Ÿๆˆใ‚ซใƒซใƒ“ ไธญ" โ†’ context: "์ˆ™์„ฑ ๊ฐˆ๋น„ (์ค‘)" / amount: 2500
์ฃผ์˜์‚ฌํ•ญ:
- ์ด๋ฏธ์ง€๋ฅผ ์œ„์—์„œ ์•„๋ž˜, ์™ผ์ชฝ์—์„œ ์˜ค๋ฅธ์ชฝ์œผ๋กœ ๋น ์ง์—†์ด ์Šค์บ”ํ•  ๊ฒƒ
- ๊ฐ€๊ฒฉ์ด ์žˆ๋Š” ํ•ญ๋ชฉ์€ ๋ชจ๋‘ ํฌํ•จํ•  ๊ฒƒ (์‚ฌ์ด์ฆˆ ๋ณ€ํ˜• ํฌํ•จ)
- ๋งˆ์ง€๋ง‰์— ์ „์ฒด ํ•ญ๋ชฉ ์ˆ˜๋ฅผ ๋‹ค์‹œ ์„ธ์–ด ๋ˆ„๋ฝ์ด ์—†๋Š”์ง€ ํ™•์ธํ•  ๊ฒƒ
์˜์ˆ˜์ฆ ๋ถ„์„ ๊ทœ์น™:
- ์˜์ˆ˜์ฆ์ด๋ฉด ์ฃผ๋ฌธ ํ•ญ๋ชฉ๋งŒ prices์— ๋„ฃ๊ณ  ์„ธ๊ธˆ, ํ•ฉ๊ณ„, ํ• ์ธ, ์นด๋“œ ์Šน์ธ๋ฒˆํ˜ธ, ์‚ฌ์—…์ž๋ฒˆํ˜ธ, ๋‚ ์งœ๋Š” ์ œ์™ธํ•  ๊ฒƒ
- ์ˆ˜๋Ÿ‰์ด ๋ณด์ด๋ฉด qty์— ์ˆซ์ž๋กœ ๋„ฃ์„ ๊ฒƒ. ์ˆ˜๋Ÿ‰์ด ์—†์œผ๋ฉด qty: 1
- ํ•ญ๋ชฉ๋ณ„ ๊ธˆ์•ก์ด ์ด์•ก์ด๊ณ  ์ˆ˜๋Ÿ‰์ด 2 ์ด์ƒ์ด๋ฉด amount๋Š” 1๊ฐœ๋‹น ๋‹จ๊ฐ€๋กœ ๊ณ„์‚ฐํ•  ๊ฒƒ
- ๋‹จ๊ฐ€๋ฅผ ์•Œ ์ˆ˜ ์—†๊ณ  ์ค„ ์ด์•ก๋งŒ ์žˆ์œผ๋ฉด amount๋Š” ์ค„ ์ด์•ก, qty๋Š” 1๋กœ ์ฒ˜๋ฆฌํ•  ๊ฒƒ
- ์˜์ˆ˜์ฆ์—์„œ๋Š” ์ด๋ฏธ ์ฃผ๋ฌธ๋œ ํ•ญ๋ชฉ์ด๋ฏ€๋กœ ํ”„๋ก ํŠธ๊ฐ€ ๋ฐ”๋กœ ์ฃผ๋ฌธ์„œ๋กœ ์ €์žฅํ•  ์ˆ˜ ์žˆ๊ฒŒ qty๋ฅผ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๊ฒƒ
๋ฐ˜๋“œ์‹œ ๋‹ค์Œ JSON ํ˜•์‹์œผ๋กœ๋งŒ ์‘๋‹ตํ•˜์„ธ์š” (JSON ์™ธ ๋‹ค๋ฅธ ํ…์ŠคํŠธ ์—†์Œ):
{
"documentType": "menu ๋˜๋Š” receipt ๋˜๋Š” other",
"detectedLanguage": "๊ฐ์ง€๋œ ์–ธ์–ด ์ด๋ฆ„ (์˜ˆ: ์ผ๋ณธ์–ด, ์˜์–ด, ํƒœ๊ตญ์–ด)",
"detectedCurrency": "์ฃผ์š” ํ†ตํ™” ISO ์ฝ”๋“œ (USD/EUR/JPY/CNY/THB/VND/MYR/IDR/PHP/SGD/HKD/AUD/GBP/CAD ๋˜๋Š” null)",
"textBlocks": [
{
"originalText": "์ด๋ฏธ์ง€์— ์ ํžŒ ์›๋ฌธ ๊ทธ๋Œ€๋กœ",
"translatedText": "์ž์—ฐ์Šค๋Ÿฌ์šด ํ•œ๊ตญ์–ด ๋ฒˆ์—ญ"
}
],
"prices": [
{
"amount": ์ˆซ์ž๊ฐ’๋งŒ,
"currency": "ISO ํ†ตํ™” ์ฝ”๋“œ",
"qty": ์ˆ˜๋Ÿ‰ ์ˆซ์ž๊ฐ’,
"originalText": "์ด๋ฏธ์ง€์— ์ธ์‡„๋œ ์›๋ณธ ํ…์ŠคํŠธ (๋ฒˆ์—ญ ์ ˆ๋Œ€ ๊ธˆ์ง€, ์›๋ณธ ์–ธ์–ด ๊ทธ๋Œ€๋กœ)",
"context": "ํ•œ๊ตญ์–ด ๋ฒˆ์—ญ๋ช…",
"category": "food/drink/alcohol/dessert/side/other ์ค‘ ํ•˜๋‚˜",
"x": ํ…์ŠคํŠธ_์ค‘์‹ฌ_๊ฐ€๋กœ์œ„์น˜_ํผ์„ผํŠธ(0~100),
"y": ํ…์ŠคํŠธ_์ค‘์‹ฌ_์„ธ๋กœ์œ„์น˜_ํผ์„ผํŠธ(0~100)
}
]
}
menu ๋˜๋Š” receipt์ธ ๊ฒฝ์šฐ textBlocks๋Š” ๋นˆ ๋ฐฐ์—ด๋กœ ๋ฐ˜ํ™˜ํ•˜์„ธ์š”.
context ๋ฒˆ์—ญ ๊ทœ์น™:
- ์›์–ด ๋ฐœ์Œ์„ ํ•œ๊ธ€๋กœ ์Œ์ฐจ ๊ธˆ์ง€: โŒ "์นด๋ผ์•„๊ฒŒ" โœ… "๋‹ญ ํŠ€๊น€"
- ใใฎใ“โ†’๋ฒ„์„ฏ, ๅต/ใŸใพใ”โ†’๋‹ฌ๊ฑ€, ้ถโ†’๋‹ญ, ๆตท่€/ใˆใณโ†’์ƒˆ์šฐ, ่ฑšโ†’๋ผ์ง€, ็‰›โ†’์†Œ
- ๅ”ๆšใ’โ†’๋‹ญ ํŠ€๊น€, ๅคฉใทใ‚‰โ†’ํŠ€๊น€, ๅˆบ่บซโ†’ํšŒ, ใƒใƒฃใƒผใƒใƒณโ†’๋ณถ์Œ๋ฐฅ
- ํ•œ๊ตญ์—์„œ ์“ฐ๋Š” ์™ธ๋ž˜์–ด๋Š” ๊ทธ๋Œ€๋กœ (ํ”ผ์ž, ํŒŒ์Šคํƒ€, ์นด๋ ˆ, ์Šคํ…Œ์ดํฌ, ๋ผ๋ฉ˜)
- ๊ฐ€๊ฒฉ ์—†์œผ๋ฉด prices: []"""
OCR_PROMPT = """์ด ์ด๋ฏธ์ง€์—์„œ ํ…์ŠคํŠธ๋ฅผ ๋ชจ๋‘ ์ฐพ์•„์ฃผ์„ธ์š”. ๋ฒˆ์—ญํ•˜์ง€ ๋ง๊ณ  ์›๋ณธ ๊ทธ๋Œ€๋กœ ์ถ”์ถœํ•˜์„ธ์š”.
๋ฐ˜๋“œ์‹œ ์•„๋ž˜ JSON ํ˜•์‹์œผ๋กœ๋งŒ ์‘๋‹ต (JSON ์™ธ ํ…์ŠคํŠธ ์ ˆ๋Œ€ ์—†์Œ):
{
"blocks": [
{
"text": "์›๋ณธ ํ…์ŠคํŠธ",
"x1": 10, "y1": 5, "x2": 60, "y2": 15,
"dark_bg": false
}
]
}
๊ทœ์น™:
- x1,y1: ํ…์ŠคํŠธ ๋ธ”๋ก ์ขŒ์ƒ๋‹จ / x2,y2: ์šฐํ•˜๋‹จ (์ด๋ฏธ์ง€ ๊ธฐ์ค€ 0~100 ํผ์„ผํŠธ)
- ๊ฐ ํ…์ŠคํŠธ ์ค„์„ ๊ฐœ๋ณ„ ๋ธ”๋ก์œผ๋กœ ์ž‘์„ฑ
- dark_bg: ๋ฐฐ๊ฒฝ์ด ์–ด๋‘์šฐ๋ฉด true, ๋ฐ์œผ๋ฉด false
- ๊ฐ€๊ฒฉยท์ˆซ์ž๋Š” ๊ทธ๋Œ€๋กœ ์œ ์ง€
- ์žฅ์‹์šฉ ๊ธฐํ˜ธยทํ…Œ๋‘๋ฆฌ ๋“ฑ ์˜๋ฏธ ์—†๋Š” ์š”์†Œ๋Š” ์ œ์™ธ
- blocks ์ตœ๋Œ€ 40๊ฐœ"""
async def translate_with_deepl(texts: list) -> list:
"""DeepL๋กœ ํ…์ŠคํŠธ ๋ชฉ๋ก์„ ํ•œ๊ตญ์–ด๋กœ ๋ฒˆ์—ญ"""
if not texts or not DEEPL_API_KEY:
return texts
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
DEEPL_URL,
headers={"Authorization": f"DeepL-Auth-Key {DEEPL_API_KEY}",
"Content-Type": "application/json"},
json={"text": texts, "target_lang": "KO"},
)
if resp.is_success:
return [t["text"] for t in resp.json().get("translations", [])]
print(f"[DeepL] ์˜ค๋ฅ˜ {resp.status_code}: {resp.text[:200]}")
except Exception as e:
print(f"[DeepL] ์˜ˆ์™ธ: {e}")
return texts
class AnalyzeRequest(BaseModel):
image_base64: str
image_type: str = "image/jpeg"
class AuthRequest(BaseModel):
email: str
password: str
class PurchaseRequest(BaseModel):
planId: str
@app.get("/plans")
async def plans():
return {"plans": PLANS}
@app.post("/auth/register")
async def register(req: AuthRequest):
email = req.email.strip().lower()
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(400, "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
if len(req.password) < 6:
raise HTTPException(400, "๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” 6์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
store = _load_store()
if email in store["users"]:
raise HTTPException(409, "์ด๋ฏธ ๊ฐ€์ž…๋œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค.")
salt, password_hash = _hash_password(req.password)
store["users"][email] = {
"email": email,
"salt": salt,
"passwordHash": password_hash,
"createdAt": _now().isoformat(),
"entitlement": None,
}
token = secrets.token_urlsafe(32)
store["sessions"][token] = email
_save_store(store)
return {"token": token, "user": _public_user(store["users"][email])}
@app.post("/auth/login")
async def login(req: AuthRequest):
email = req.email.strip().lower()
store = _load_store()
user = store["users"].get(email)
if not user:
raise HTTPException(401, "์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
_, password_hash = _hash_password(req.password, user["salt"])
if password_hash != user["passwordHash"]:
raise HTTPException(401, "์ด๋ฉ”์ผ ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.")
token = secrets.token_urlsafe(32)
store["sessions"][token] = email
_save_store(store)
return {"token": token, "user": _public_user(user)}
@app.get("/auth/social/{provider}")
async def social_login(provider: str):
provider = provider.lower()
if provider not in {"google", "kakao", "naver"}:
raise HTTPException(400, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ๋กœ๊ทธ์ธ ์ œ๊ณต์ž์ž…๋‹ˆ๋‹ค.")
raise HTTPException(
501,
f"{provider} ๋กœ๊ทธ์ธ์€ ๊ฐœ๋ฐœ์ž ์ฝ˜์†”์˜ client_id, client_secret, redirect URL ์„ค์ • ํ›„ ์—ฐ๊ฒฐํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.",
)
@app.get("/me")
async def me(
authorization: str | None = Header(default=None),
x_guest_id: str | None = Header(default=None),
):
email, user, store = _get_user_by_token(authorization)
identity = _usage_identity(email, x_guest_id)
return {
"user": _public_user(user),
"usage": _usage_status(store, identity, user),
"plans": PLANS,
}
@app.delete("/me")
async def delete_account(authorization: str | None = Header(default=None)):
email, user, store = _get_user_by_token(authorization)
if not email or not user:
raise HTTPException(401, "๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
store["users"].pop(email, None)
store["sessions"] = {tok: em for tok, em in store.get("sessions", {}).items() if em != email}
store.get("usage", {}).pop(f"user:{email.lower()}", None)
_save_store(store)
return {"ok": True}
# โ”€โ”€ ์•ฑ์Šคํ† ์–ด/ํ”Œ๋ ˆ์ด์Šคํ† ์–ด ์˜์ˆ˜์ฆ ๊ฒ€์ฆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
APPLE_SHARED_SECRET = os.environ.get("APPLE_SHARED_SECRET", "")
APPLE_VERIFY_URL_PROD = "https://buy.itunes.apple.com/verifyReceipt"
APPLE_VERIFY_URL_SANDBOX = "https://sandbox.itunes.apple.com/verifyReceipt"
GOOGLE_PACKAGE_NAME = os.environ.get("GOOGLE_PACKAGE_NAME", "")
GOOGLE_SERVICE_ACCOUNT_JSON = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "")
# productId(์Šคํ† ์–ด) โ†’ PLANS์˜ id ๋งคํ•‘
STORE_PRODUCT_TO_PLAN = {
"dalibaba_pass_1d": "pass_1d",
"dalibaba_pass_3d": "pass_3d",
"dalibaba_pass_7d": "pass_7d",
"dalibaba_sub_month": "sub_month",
"dalibaba_sub_year": "sub_year",
}
def _grant_entitlement(user: dict, plan: dict) -> dict:
current = _active_entitlement(user)
start = _now()
if current:
try:
current_expiry = datetime.fromisoformat(current["expiresAt"])
if current_expiry > start:
start = current_expiry
except ValueError:
pass
expires_at = start + timedelta(days=int(plan["days"]))
user["entitlement"] = {
"planId": plan["id"],
"label": plan["label"],
"kind": plan["kind"],
"expiresAt": expires_at.isoformat(),
}
return user["entitlement"]
async def _verify_apple_receipt(receipt_data: str) -> dict:
if not APPLE_SHARED_SECRET:
raise HTTPException(500, "APPLE_SHARED_SECRET์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
payload = {"receipt-data": receipt_data, "password": APPLE_SHARED_SECRET, "exclude-old-transactions": True}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(APPLE_VERIFY_URL_PROD, json=payload)
body = resp.json()
if body.get("status") == 21007: # ์ƒŒ๋“œ๋ฐ•์Šค ์˜์ˆ˜์ฆ์„ ์šด์˜ ์„œ๋ฒ„๋กœ ๋ณด๋‚ธ ๊ฒฝ์šฐ
resp = await client.post(APPLE_VERIFY_URL_SANDBOX, json=payload)
body = resp.json()
if body.get("status") != 0:
raise HTTPException(400, f"์• ํ”Œ ์˜์ˆ˜์ฆ ๊ฒ€์ฆ ์‹คํŒจ (status={body.get('status')})")
latest = body.get("latest_receipt_info") or body.get("receipt", {}).get("in_app", [])
if not latest:
raise HTTPException(400, "์˜์ˆ˜์ฆ์—์„œ ๊ตฌ๋งค ๋‚ด์—ญ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
latest.sort(key=lambda x: int(x.get("purchase_date_ms", 0)), reverse=True)
product_id = latest[0].get("product_id")
return {"productId": product_id, "transactionId": latest[0].get("transaction_id")}
async def _verify_google_receipt(product_id: str, purchase_token: str) -> dict:
if not GOOGLE_SERVICE_ACCOUNT_JSON or not GOOGLE_PACKAGE_NAME:
raise HTTPException(500, "GOOGLE_SERVICE_ACCOUNT_JSON / GOOGLE_PACKAGE_NAME์ด ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
try:
from google.oauth2 import service_account
from google.auth.transport.requests import Request as GoogleAuthRequest
except ImportError:
raise HTTPException(500, "google-auth ํŒจํ‚ค์ง€๊ฐ€ ์„ค์น˜๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
creds_info = json.loads(GOOGLE_SERVICE_ACCOUNT_JSON)
credentials = service_account.Credentials.from_service_account_info(
creds_info, scopes=["https://www.googleapis.com/auth/androidpublisher"],
)
credentials.refresh(GoogleAuthRequest())
is_subscription = product_id.startswith("dalibaba_sub_")
kind = "subscriptions" if is_subscription else "products"
url = (
f"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/"
f"{GOOGLE_PACKAGE_NAME}/purchases/{kind}/{product_id}/tokens/{purchase_token}"
)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(url, headers={"Authorization": f"Bearer {credentials.token}"})
if not resp.is_success:
raise HTTPException(400, f"๊ตฌ๊ธ€ ์˜์ˆ˜์ฆ ๊ฒ€์ฆ ์‹คํŒจ: {resp.text[:200]}")
body = resp.json()
if is_subscription:
if body.get("paymentState") not in (1, 2):
raise HTTPException(400, "๊ตฌ๋… ๊ฒฐ์ œ๊ฐ€ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
else:
if body.get("purchaseState") != 0:
raise HTTPException(400, "๊ตฌ๋งค๊ฐ€ ์™„๋ฃŒ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
return {"productId": product_id}
class VerifyPurchaseRequest(BaseModel):
platform: str # "ios" | "android"
receiptData: str = "" # iOS: base64 ์˜์ˆ˜์ฆ
productId: str = "" # Android: ์ƒํ’ˆ ID
purchaseToken: str = "" # Android: ๊ตฌ๋งค ํ† ํฐ
@app.post("/purchase/verify")
async def verify_purchase(req: VerifyPurchaseRequest, authorization: str | None = Header(default=None)):
"""์•ฑ์Šคํ† ์–ด/ํ”Œ๋ ˆ์ด์Šคํ† ์–ด ์ธ์•ฑ๊ฒฐ์ œ ์˜์ˆ˜์ฆ์„ ๊ฒ€์ฆํ•˜๊ณ  ์ด์šฉ๊ถŒ์„ ์ง€๊ธ‰ํ•ฉ๋‹ˆ๋‹ค."""
email, user, store = _get_user_by_token(authorization)
if not email or not user:
raise HTTPException(401, "๊ตฌ๋งค๋ฅผ ์ ์šฉํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
if req.platform == "ios":
info = await _verify_apple_receipt(req.receiptData)
elif req.platform == "android":
info = await _verify_google_receipt(req.productId, req.purchaseToken)
else:
raise HTTPException(400, "platform์€ ios ๋˜๋Š” android์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.")
plan_id = STORE_PRODUCT_TO_PLAN.get(info["productId"])
plan = next((item for item in PLANS if item["id"] == plan_id), None)
if not plan:
raise HTTPException(400, f"์•Œ ์ˆ˜ ์—†๋Š” ์ƒํ’ˆ ID์ž…๋‹ˆ๋‹ค: {info['productId']}")
_grant_entitlement(user, plan)
store["users"][email] = user
_save_store(store)
return {"user": _public_user(user), "plans": PLANS}
@app.post("/purchase")
async def purchase(req: PurchaseRequest, authorization: str | None = Header(default=None)):
"""๊ฐœ๋ฐœ/ํ…Œ์ŠคํŠธ์šฉ: ์‹ค์ œ ๊ฒฐ์ œ ์—†์ด ์ด์šฉ๊ถŒ์„ ์ฆ‰์‹œ ์ง€๊ธ‰. ์šด์˜ ๋นŒ๋“œ์—์„œ๋Š” /purchase/verify๋ฅผ ์‚ฌ์šฉํ•  ๊ฒƒ."""
email, user, store = _get_user_by_token(authorization)
if not email or not user:
raise HTTPException(401, "๊ตฌ๋งคํ•˜๋ ค๋ฉด ๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.")
plan = next((item for item in PLANS if item["id"] == req.planId), None)
if not plan:
raise HTTPException(400, "์•Œ ์ˆ˜ ์—†๋Š” ์š”๊ธˆ์ œ์ž…๋‹ˆ๋‹ค.")
current = _active_entitlement(user)
start = _now()
if current:
try:
current_expiry = datetime.fromisoformat(current["expiresAt"])
if current_expiry > start:
start = current_expiry
except ValueError:
pass
expires_at = start + timedelta(days=int(plan["days"]))
user["entitlement"] = {
"planId": plan["id"],
"label": plan["label"],
"kind": plan["kind"],
"expiresAt": expires_at.isoformat(),
}
store["users"][email] = user
_save_store(store)
return {"user": _public_user(user), "plans": PLANS}
# โ”€โ”€ ํ™˜์œจ ์บ์‹œ (1์‹œ๊ฐ„) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
_rate_cache: dict = {"rates": {}, "ts": 0}
async def get_usd_rates() -> dict:
if time.time() - _rate_cache["ts"] < 3600 and _rate_cache["rates"]:
return _rate_cache["rates"]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
res = await client.get("https://open.er-api.com/v6/latest/USD")
rates = res.json().get("rates", {})
if rates:
_rate_cache["rates"] = rates
_rate_cache["ts"] = time.time()
print(f"[ํ™˜์œจ] ๊ฐฑ์‹  ์™„๋ฃŒ โ€” USD/KRW={rates.get('KRW')}")
return rates
except Exception as e:
print(f"[ํ™˜์œจ] ์กฐํšŒ ์‹คํŒจ: {e}")
return _rate_cache["rates"]
async def fetch_krw_rate(currency: str) -> float | None:
if currency == "KRW":
return 1.0
rates = await get_usd_rates()
usd_to_krw = rates.get("KRW")
usd_to_cur = rates.get(currency)
if usd_to_krw and usd_to_cur:
return usd_to_krw / usd_to_cur
return None
# โ”€โ”€ ์ด๋ฏธ์ง€ ๋ Œ๋”๋ง ํ—ฌํผ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
def render_blocks(img_pil: Image.Image, blocks: list) -> Image.Image:
"""์ธํŽ˜์ธํŒ…๋œ ์ด๋ฏธ์ง€ ์œ„์— ๋ฒˆ์—ญ๋ฌธ์„ ๋ฐ˜ํˆฌ๋ช… ๋ฐ•์Šค๋กœ ๋ Œ๋”๋ง"""
iw, ih = img_pil.size
img_arr = np.array(img_pil)
img_rgba = img_pil.convert('RGBA')
overlay = Image.new('RGBA', img_pil.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
for b in blocks:
x1, y1, x2, y2 = b["px"]
bw, bh = x2 - x1, y2 - y1
text = b["translated"]
# ๋ฐฐ๊ฒฝ ๋ฐ๊ธฐ ์ธก์ • โ†’ ๊ธ€์ž์ƒ‰ ์ž๋™ ๊ฒฐ์ •
region = img_arr[max(0,y1):min(ih,y2), max(0,x1):min(iw,x2)]
if region.size > 0:
avg = region.mean(axis=(0, 1))
brightness = 0.299*avg[0] + 0.587*avg[1] + 0.114*avg[2]
dark_bg = brightness < 128
else:
dark_bg = b.get("dark_bg", False)
text_fill = (255, 255, 255, 255) if dark_bg else (15, 23, 42, 255)
bg_fill = (10, 10, 20, 215) if dark_bg else (255, 255, 255, 215)
# ํฐํŠธ ํฌ๊ธฐ = ๋ฐ•์Šค ๋†’์ด, ๋„ˆ๋ฌด ๋„“์œผ๋ฉด ์ž๋™ ์ถ•์†Œ
font_size = max(10, bh - 2)
font = get_font(font_size)
while font_size > 10:
try:
tb = draw.textbbox((0, 0), text, font=font)
if (tb[2] - tb[0]) <= bw:
break
except Exception:
break
font_size -= 1
font = get_font(font_size)
draw.rectangle([x1, y1, x2, y2], fill=bg_fill)
try:
tb = draw.textbbox((0, 0), text, font=font)
tw, th = tb[2] - tb[0], tb[3] - tb[1]
tx = x1 + max(0, (bw - tw) // 2)
ty = y1 + max(0, (bh - th) // 2)
except Exception:
tx, ty = x1 + 2, y1 + 2
draw.text((tx, ty), text, fill=text_fill, font=font)
return Image.alpha_composite(img_rgba, overlay).convert('RGB')
class ChatMessage(BaseModel):
role: str # "user" | "assistant"
content: str
class ChatRequest(BaseModel):
message: str
history: list[ChatMessage] = []
menuContext: str = "" # ๋ฉ”๋‰ด ๋ชฉ๋ก ์š”์•ฝ ํ…์ŠคํŠธ
@app.post("/chat")
async def chat(
req: ChatRequest,
authorization: str | None = Header(default=None),
x_guest_id: str | None = Header(default=None),
):
if not GROQ_API_KEY:
raise HTTPException(500, "์„œ๋ฒ„์— GROQ_API_KEY๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
email, user, store = _get_user_by_token(authorization)
identity = _usage_identity(email, x_guest_id)
usage = _usage_status(store, identity, user)
if usage["remaining"] <= 0:
raise HTTPException(
429,
{
"message": "์˜ค๋Š˜ ๋ฌด๋ฃŒ AI ์งˆ๋ฌธ 3ํšŒ๋ฅผ ๋ชจ๋‘ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.",
"usage": usage,
},
)
system_prompt = f"""๋‹น์‹ ์€ ํ•ด์™ธ ์—ฌํ–‰ ์ค‘์ธ ํ•œ๊ตญ์ธ์„ ๋•๋Š” ์นœ์ ˆํ•œ ์Œ์‹ ์ „๋ฌธ๊ฐ€ AI์˜ˆ์š”.
ํ˜„์žฌ ๋ฉ”๋‰ดํŒ์—์„œ ๋ถ„์„๋œ ๋ฉ”๋‰ด ๋ชฉ๋ก์€ ๋‹ค์Œ๊ณผ ๊ฐ™์•„์š”:
{req.menuContext if req.menuContext else '(๋ฉ”๋‰ด ์ •๋ณด ์—†์Œ)'}
์œ„ ๋ฉ”๋‰ด๋ฅผ ์ฐธ๊ณ ํ•ด์„œ ์‚ฌ์šฉ์ž ์งˆ๋ฌธ์— ํ•œ๊ตญ์–ด๋กœ ์นœ์ ˆํ•˜๊ณ  ๊ฐ„๊ฒฐํ•˜๊ฒŒ ๋‹ตํ•ด์ฃผ์„ธ์š”.
์žฌ๋ฃŒ, ๋ง›, ์กฐ๋ฆฌ๋ฒ•, ํ•œ๊ตญ ์Œ์‹๊ณผ์˜ ๋น„๊ต, ์ฃผ๋ฌธ ํŒ ๋“ฑ์„ ์ž์—ฐ์Šค๋Ÿฝ๊ฒŒ ์„ค๋ช…ํ•ด์ฃผ์„ธ์š”.
๋ชจ๋ฅด๋Š” ๋‚ด์šฉ์€ ์†”์งํ•˜๊ฒŒ ๋ชจ๋ฅธ๋‹ค๊ณ  ๋งํ•˜์„ธ์š”. ๋‹ต๋ณ€์€ 3~5๋ฌธ์žฅ ์ด๋‚ด๋กœ."""
messages = [{"role": "system", "content": system_prompt}]
for m in req.history[-6:]: # ์ตœ๊ทผ 6๊ฐœ๋งŒ ์œ ์ง€
messages.append({"role": m.role, "content": m.content})
messages.append({"role": "user", "content": req.message})
payload = {
"model": GROQ_MODEL,
"messages": messages,
"max_tokens": 500,
"temperature": 0.7,
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
GROQ_URL,
headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
json=payload,
)
if not resp.is_success:
raise HTTPException(resp.status_code, resp.text[:200])
answer = resp.json()["choices"][0]["message"]["content"].strip()
date_key = _today_key()
bucket = store.setdefault("usage", {}).setdefault(identity, {})
bucket[date_key] = int(bucket.get(date_key, 0)) + 1
_save_store(store)
return {"answer": answer, "usage": _usage_status(store, identity, user)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
class TranslateItemsRequest(BaseModel):
items: list[str] # ํ•œ๊ตญ์–ด ๋ฉ”๋‰ด๋ช… ๋ชฉ๋ก
target_lang: str = "ja" # ๋ฒˆ์—ญ ๋ชฉํ‘œ ์–ธ์–ด (ISO ์ฝ”๋“œ)
lang_name: str = "์ผ๋ณธ์–ด" # ์–ธ์–ด ์ด๋ฆ„ (ํ”„๋กฌํ”„ํŠธ์šฉ)
@app.post("/translate-items")
async def translate_items(req: TranslateItemsRequest):
if not GROQ_API_KEY:
raise HTTPException(500, "GROQ_API_KEY๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
items_text = "\n".join(f"{i+1}. {name}" for i, name in enumerate(req.items))
prompt = f"""์•„๋ž˜ ํ•œ๊ตญ์–ด ์Œ์‹ ๋ฉ”๋‰ด ์ด๋ฆ„๋“ค์„ {req.lang_name}๋กœ ๋ฒˆ์—ญํ•ด์ฃผ์„ธ์š”.
๋ฒˆ์—ญ ์‹œ ์‹ค์ œ {req.lang_name} ์‹๋‹น์—์„œ ์“ฐ๋Š” ์ž์—ฐ์Šค๋Ÿฌ์šด ํ‘œํ˜„์„ ์‚ฌ์šฉํ•˜์„ธ์š”.
๋ฐ˜๋“œ์‹œ ์•„๋ž˜ JSON ํ˜•์‹์œผ๋กœ๋งŒ ์‘๋‹ตํ•˜์„ธ์š” (JSON ์™ธ ํ…์ŠคํŠธ ์—†์Œ):
{{"translations": ["๋ฒˆ์—ญ1", "๋ฒˆ์—ญ2", ...]}}
๋ฉ”๋‰ด ๋ชฉ๋ก:
{items_text}"""
payload = {
"model": GROQ_MODEL,
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 1000,
"temperature": 0.2,
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(
GROQ_URL,
headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
json=payload,
)
if not resp.is_success:
raise HTTPException(resp.status_code, resp.text[:200])
content = resp.json()["choices"][0]["message"]["content"]
match = re.search(r"\{[\s\S]*\}", content)
if not match:
raise HTTPException(500, "๋ฒˆ์—ญ ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
result = json.loads(repair_json(match.group()))
translations = result.get("translations", req.items)
return {"translations": translations}
except HTTPException:
raise
except Exception as e:
raise HTTPException(500, str(e))
class ClientErrorRequest(BaseModel):
message: str
stack: str = ""
url: str = ""
userAgent: str = ""
MAX_CLIENT_ERRORS = 200
@app.post("/client-error")
async def report_client_error(
req: ClientErrorRequest,
x_guest_id: str | None = Header(default=None),
):
"""ํ”„๋ก ํŠธ์—”๋“œ ๋Ÿฐํƒ€์ž„ ์˜ค๋ฅ˜๋ฅผ ์ˆ˜์ง‘ํ•ด ์„œ๋ฒ„ store์— ๋ณด๊ด€ (์ตœ๊ทผ 200๊ฑด)"""
store = _load_store()
errors = store.setdefault("clientErrors", [])
errors.append({
"at": _now().isoformat(),
"message": req.message[:500],
"stack": req.stack[:2000],
"url": req.url[:300],
"userAgent": req.userAgent[:300],
"guestId": (x_guest_id or "")[:80],
})
del errors[:-MAX_CLIENT_ERRORS]
_save_store(store)
return {"ok": True}
@app.get("/client-error")
async def list_client_errors(authorization: str | None = Header(default=None)):
"""์ˆ˜์ง‘๋œ ์˜ค๋ฅ˜ ๋กœ๊ทธ ์กฐํšŒ (๊ด€๋ฆฌ์ž ํ† ํฐ ํ•„์š”: ADMIN_TOKEN env)"""
admin_token = os.environ.get("ADMIN_TOKEN", "")
token = (authorization or "").replace("Bearer ", "", 1).strip()
if not admin_token or token != admin_token:
raise HTTPException(403, "๊ถŒํ•œ์ด ์—†์Šต๋‹ˆ๋‹ค.")
store = _load_store()
return {"errors": store.get("clientErrors", [])}
@app.get("/health")
async def health():
return {"status": "ok", "model": GROQ_MODEL}
@app.get("/rates")
async def get_rates():
rates = await get_usd_rates()
if not rates:
raise HTTPException(503, "ํ™˜์œจ ์ •๋ณด๋ฅผ ๊ฐ€์ ธ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.")
return {"base": "USD", "rates": rates}
@app.post("/analyze")
async def analyze(req: AnalyzeRequest):
if not GROQ_API_KEY:
raise HTTPException(500, "์„œ๋ฒ„์— GROQ_API_KEY๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
image_url = f"data:{req.image_type};base64,{req.image_base64}"
payload = {
"model": GROQ_MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": PRICE_PROMPT},
],
}],
"max_tokens": 4000,
"temperature": 0.1,
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
GROQ_URL,
headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
json=payload,
)
print(f"[Groq/analyze] status={resp.status_code}")
if not resp.is_success:
detail = resp.json().get("error", {}).get("message", resp.text[:200])
raise HTTPException(resp.status_code, detail)
content = resp.json()["choices"][0]["message"]["content"]
match = re.search(r"\{[\s\S]*\}", content)
if not match:
raise HTTPException(500, f"๋ชจ๋ธ์ด JSON์„ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค: {content[:200]}")
result = json.loads(repair_json(match.group()))
document_type = str(result.get("documentType") or "other").lower()
if document_type not in {"menu", "receipt", "other"}:
document_type = "other"
result["documentType"] = document_type
result["prices"] = result.get("prices") if isinstance(result.get("prices"), list) else []
result["textBlocks"] = result.get("textBlocks") if isinstance(result.get("textBlocks"), list) else []
if document_type == "other":
result["prices"] = []
result["detectedCurrency"] = None
result["textBlocks"] = [
{
"originalText": str(block.get("originalText") or "").strip(),
"translatedText": str(block.get("translatedText") or "").strip(),
}
for block in result["textBlocks"]
if isinstance(block, dict) and str(block.get("originalText") or "").strip()
]
if result.get("prices"):
currencies = list({p.get("currency") for p in result["prices"] if p.get("currency")})
rates: dict[str, float | None] = {}
async with httpx.AsyncClient(timeout=5.0) as client:
for cur in currencies:
rates[cur] = await fetch_krw_rate(cur)
for price in result["prices"]:
price["qty"] = max(1, int(float(price.get("qty") or 1)))
rate = rates.get(price.get("currency"))
price["krwAmount"] = round(float(price.get("amount") or 0) * rate) if rate else None
return result
except HTTPException:
raise
except Exception as e:
print(f"[ERROR/analyze] {e}")
raise HTTPException(500, str(e))
@app.post("/translate-image")
async def translate_image(req: AnalyzeRequest):
if not GROQ_API_KEY:
raise HTTPException(500, "์„œ๋ฒ„์— GROQ_API_KEY๊ฐ€ ์„ค์ •๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.")
try:
# 1. Groq ๋น„์ „ ํ˜ธ์ถœ โ†’ OCR๋งŒ (ํ…์ŠคํŠธ ์œ„์น˜ ๊ฐ์ง€)
image_url = f"data:{req.image_type};base64,{req.image_base64}"
payload = {
"model": GROQ_MODEL,
"messages": [{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": image_url}},
{"type": "text", "text": OCR_PROMPT},
],
}],
"max_tokens": 2000,
"temperature": 0.1,
}
async with httpx.AsyncClient(timeout=60.0) as client:
resp = await client.post(
GROQ_URL,
headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
json=payload,
)
print(f"[Groq/OCR] status={resp.status_code}")
if not resp.is_success:
raise HTTPException(resp.status_code, resp.text[:200])
content = resp.json()["choices"][0]["message"]["content"]
print(f"[Groq/OCR] {content[:300]}")
match = re.search(r"\{[\s\S]*\}", content)
if not match:
raise HTTPException(500, f"๋ชจ๋ธ์ด JSON์„ ๋ฐ˜ํ™˜ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค: {content[:200]}")
blocks = json.loads(repair_json(match.group())).get("blocks", [])
if not blocks:
return {"translated_image": None, "message": "๋ฒˆ์—ญํ•  ํ…์ŠคํŠธ๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."}
# 1-2. DeepL๋กœ ์ถ”์ถœ๋œ ํ…์ŠคํŠธ ๋ฒˆ์—ญ (๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ)
original_texts = [b.get("text", "") for b in blocks]
translated_texts = await translate_with_deepl(original_texts)
for b, t in zip(blocks, translated_texts):
b["translated"] = t
# 2. ์ด๋ฏธ์ง€ ๋””์ฝ”๋”ฉ + ํผ์„ผํŠธ ์ขŒํ‘œ โ†’ ํ”ฝ์…€ ๋ณ€ํ™˜
img_bytes = base64.b64decode(req.image_base64)
img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB")
iw, ih = img_pil.size
px_blocks = []
for b in blocks:
x1 = int(b.get("x1", 0) / 100 * iw)
y1 = int(b.get("y1", 0) / 100 * ih)
x2 = int(b.get("x2", 0) / 100 * iw)
y2 = int(b.get("y2", 0) / 100 * ih)
if x2 - x1 < 4 or y2 - y1 < 4:
continue
translated = b.get("translated") or b.get("text", "")
px_blocks.append({
"px": (x1, y1, x2, y2),
"translated": translated,
"dark_bg": b.get("dark_bg", False),
})
if not px_blocks:
return {"translated_image": None, "message": "์œ ํšจํ•œ ํ…์ŠคํŠธ ์˜์—ญ์„ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค."}
# 3. OpenCV ์ธํŽ˜์ธํŒ…์œผ๋กœ ์›๋ณธ ํ…์ŠคํŠธ ์ œ๊ฑฐ
pad = 6
img_arr = np.ascontiguousarray(np.array(img_pil), dtype=np.uint8)
img_cv = cv2.cvtColor(img_arr, cv2.COLOR_RGB2BGR)
mask = np.zeros((ih, iw), dtype=np.uint8)
for b in px_blocks:
x1, y1, x2, y2 = b["px"]
cv2.rectangle(
mask,
(max(0, x1 - pad), max(0, y1 - pad)),
(min(iw - 1, x2 + pad), min(ih - 1, y2 + pad)),
255, -1,
)
try:
inpainted = cv2.inpaint(img_cv, mask, 7, cv2.INPAINT_TELEA)
img_pil = Image.fromarray(cv2.cvtColor(inpainted, cv2.COLOR_BGR2RGB))
except Exception as cv_err:
print(f"[WARN/inpaint] OpenCV ์‹คํŒจ, ์›๋ณธ ์‚ฌ์šฉ: {cv_err}")
img_pil = Image.fromarray(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB))
# 4. ๋ฒˆ์—ญ๋ฌธ ๋ Œ๋”๋ง
img_pil = render_blocks(img_pil, px_blocks)
# 5. JPEG base64 ๋ฐ˜ํ™˜
out_buf = io.BytesIO()
img_pil.save(out_buf, format="JPEG", quality=85)
out_b64 = base64.b64encode(out_buf.getvalue()).decode()
return {"translated_image": out_b64}
except HTTPException:
raise
except Exception as e:
print(f"[ERROR/translate] {e}")
raise HTTPException(500, str(e))
if os.path.isdir("dist"):
app.mount("/", StaticFiles(directory="dist", html=True), name="frontend")