Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
310
backend/app/services/cache_service.py
Normal file
310
backend/app/services/cache_service.py
Normal file
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
캐시 서비스 - 카모두 검색 결과 캐싱 및 필터링
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Optional, Dict, Any, Tuple, TYPE_CHECKING
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from ..models.cache import CarCache, CarDetailCache, CacheRequestQueue
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..api.carmodoo import CarmodooClient
|
||||
|
||||
# 캐시 TTL 설정 (시간 단위)
|
||||
CACHE_TTL_HOURS = 2
|
||||
|
||||
# 요청 큐 락
|
||||
_request_lock = asyncio.Lock()
|
||||
_pending_requests: Dict[str, asyncio.Event] = {}
|
||||
|
||||
|
||||
class CacheService:
|
||||
def __init__(self, db: Session, carmodoo_client: "CarmodooClient" = None):
|
||||
self.db = db
|
||||
self.carmodoo_client = carmodoo_client
|
||||
|
||||
def get_cache_key(self, maker_code: str, model_code: str) -> str:
|
||||
"""캐시 키 생성"""
|
||||
return f"{maker_code}_{model_code}"
|
||||
|
||||
def get_cache(self, cache_key: str) -> Optional[CarCache]:
|
||||
"""캐시 조회 (만료 확인)"""
|
||||
cache = self.db.query(CarCache).filter(
|
||||
CarCache.cache_key == cache_key
|
||||
).first()
|
||||
|
||||
if cache:
|
||||
# 만료 확인
|
||||
if cache.expires_at < datetime.utcnow():
|
||||
# 만료된 캐시 삭제
|
||||
self.db.delete(cache)
|
||||
self.db.commit()
|
||||
return None
|
||||
return cache
|
||||
return None
|
||||
|
||||
def save_cache(
|
||||
self,
|
||||
cache_key: str,
|
||||
maker_code: str,
|
||||
maker_name: str,
|
||||
model_code: str,
|
||||
model_name: str,
|
||||
cars: List[Dict[str, Any]]
|
||||
) -> CarCache:
|
||||
"""캐시 저장"""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
|
||||
|
||||
# 기존 캐시 삭제
|
||||
existing = self.db.query(CarCache).filter(
|
||||
CarCache.cache_key == cache_key
|
||||
).first()
|
||||
if existing:
|
||||
self.db.delete(existing)
|
||||
self.db.commit()
|
||||
|
||||
# 새 캐시 저장
|
||||
cache = CarCache(
|
||||
cache_key=cache_key,
|
||||
maker_code=maker_code,
|
||||
maker_name=maker_name,
|
||||
model_code=model_code,
|
||||
model_name=model_name,
|
||||
total_count=len(cars),
|
||||
cars_data=json.dumps(cars, ensure_ascii=False),
|
||||
expires_at=expires_at
|
||||
)
|
||||
self.db.add(cache)
|
||||
self.db.commit()
|
||||
self.db.refresh(cache)
|
||||
return cache
|
||||
|
||||
def get_cars_from_cache(self, cache: CarCache) -> List[Dict[str, Any]]:
|
||||
"""캐시에서 차량 목록 가져오기"""
|
||||
return json.loads(cache.cars_data)
|
||||
|
||||
def filter_cars(
|
||||
self,
|
||||
cars: List[Dict[str, Any]],
|
||||
year_min: Optional[int] = None,
|
||||
year_max: Optional[int] = None,
|
||||
mileage_min: Optional[int] = None,
|
||||
mileage_max: Optional[int] = None,
|
||||
price_min: Optional[int] = None,
|
||||
price_max: Optional[int] = None,
|
||||
fuel: Optional[str] = None,
|
||||
transmission: Optional[str] = None,
|
||||
displacement_min: Optional[int] = None,
|
||||
displacement_max: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""캐시된 데이터에서 필터링"""
|
||||
filtered = cars
|
||||
|
||||
if year_min:
|
||||
filtered = [c for c in filtered if c.get('year') and c['year'] >= year_min]
|
||||
if year_max:
|
||||
filtered = [c for c in filtered if c.get('year') and c['year'] <= year_max]
|
||||
if mileage_min:
|
||||
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] >= mileage_min]
|
||||
if mileage_max:
|
||||
filtered = [c for c in filtered if c.get('mileage') and c['mileage'] <= mileage_max]
|
||||
if price_min:
|
||||
# 'price' 또는 'original_price' 키 둘 다 체크 (카모두 파싱 결과는 'price', 변환 후에는 'original_price')
|
||||
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) >= price_min]
|
||||
if price_max:
|
||||
filtered = [c for c in filtered if (c.get('price') or c.get('original_price')) and (c.get('price') or c.get('original_price', 0)) <= price_max]
|
||||
if fuel:
|
||||
# 연료 타입 매핑 (프론트엔드 값 -> 카모두 값)
|
||||
fuel_map = {
|
||||
'가솔린': ['휘발유', '가솔린'],
|
||||
'디젤': ['경유', '디젤'],
|
||||
'LPG': ['LPG'],
|
||||
'하이브리드': ['하이브리드'],
|
||||
'전기': ['전기'],
|
||||
'휘발유': ['휘발유', '가솔린'],
|
||||
'경유': ['경유', '디젤'],
|
||||
}
|
||||
allowed_fuels = fuel_map.get(fuel, [fuel])
|
||||
filtered = [c for c in filtered if c.get('fuel') in allowed_fuels]
|
||||
if transmission:
|
||||
# 변속기 타입 매핑
|
||||
trans_map = {
|
||||
'자동': ['오토', '자동'],
|
||||
'수동': ['수동'],
|
||||
'세미오토': ['세미오토'],
|
||||
'CVT': ['CVT'],
|
||||
}
|
||||
allowed_trans = trans_map.get(transmission, [transmission])
|
||||
filtered = [c for c in filtered if c.get('transmission') in allowed_trans]
|
||||
if displacement_min:
|
||||
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] >= displacement_min]
|
||||
if displacement_max:
|
||||
filtered = [c for c in filtered if c.get('displacement') and c['displacement'] <= displacement_max]
|
||||
|
||||
return filtered
|
||||
|
||||
def paginate_cars(
|
||||
self,
|
||||
cars: List[Dict[str, Any]],
|
||||
page: int = 1,
|
||||
page_size: int = 20
|
||||
) -> Tuple[List[Dict[str, Any]], int]:
|
||||
"""페이징 처리"""
|
||||
total = len(cars)
|
||||
start = (page - 1) * page_size
|
||||
end = start + page_size
|
||||
return cars[start:end], total
|
||||
|
||||
async def fetch_all_cars_for_cache(
|
||||
self,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str = "",
|
||||
model_name: str = ""
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""캐시용 전체 데이터 수집 (연도별 분할 검색)
|
||||
|
||||
카모두 API는 페이징이 제대로 동작하지 않아 한 번에 최대 50대만 반환합니다.
|
||||
연도별로 나누어 검색하여 더 많은 차량을 수집합니다.
|
||||
"""
|
||||
if not self.carmodoo_client:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 연도별 분할 검색 사용 (최근 15년간)
|
||||
all_cars = await self.carmodoo_client.search_cars_by_year_segment(
|
||||
maker_code=maker_code,
|
||||
model_code=model_code,
|
||||
year_start=2010, # 2010년부터
|
||||
year_end=None # 현재 연도까지
|
||||
)
|
||||
return all_cars
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error fetching cars for cache: {e}")
|
||||
return []
|
||||
|
||||
async def get_or_fetch_cache(
|
||||
self,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str = "",
|
||||
model_name: str = ""
|
||||
) -> Optional[CarCache]:
|
||||
"""캐시 조회 또는 새로 가져오기 (요청 병합 포함)"""
|
||||
cache_key = self.get_cache_key(maker_code, model_code)
|
||||
|
||||
# 1. 캐시 확인
|
||||
cache = self.get_cache(cache_key)
|
||||
if cache:
|
||||
return cache
|
||||
|
||||
# 2. 요청 락으로 동시 요청 병합
|
||||
async with _request_lock:
|
||||
# 다른 요청이 이미 처리 중인지 확인
|
||||
if cache_key in _pending_requests:
|
||||
event = _pending_requests[cache_key]
|
||||
else:
|
||||
# 새 이벤트 생성
|
||||
event = asyncio.Event()
|
||||
_pending_requests[cache_key] = event
|
||||
|
||||
# 백그라운드에서 데이터 가져오기
|
||||
asyncio.create_task(
|
||||
self._fetch_and_cache(cache_key, maker_code, model_code, maker_name, model_name, event)
|
||||
)
|
||||
|
||||
# 3. 완료 대기
|
||||
await event.wait()
|
||||
|
||||
# 4. 캐시 반환
|
||||
return self.get_cache(cache_key)
|
||||
|
||||
async def _fetch_and_cache(
|
||||
self,
|
||||
cache_key: str,
|
||||
maker_code: str,
|
||||
model_code: str,
|
||||
maker_name: str,
|
||||
model_name: str,
|
||||
event: asyncio.Event
|
||||
):
|
||||
"""데이터 가져와서 캐시에 저장"""
|
||||
try:
|
||||
cars = await self.fetch_all_cars_for_cache(
|
||||
maker_code, model_code, maker_name, model_name
|
||||
)
|
||||
|
||||
if cars:
|
||||
self.save_cache(
|
||||
cache_key=cache_key,
|
||||
maker_code=maker_code,
|
||||
maker_name=maker_name,
|
||||
model_code=model_code,
|
||||
model_name=model_name,
|
||||
cars=cars
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Error caching {cache_key}: {e}")
|
||||
finally:
|
||||
# 완료 시그널
|
||||
event.set()
|
||||
# 대기열에서 제거
|
||||
if cache_key in _pending_requests:
|
||||
del _pending_requests[cache_key]
|
||||
|
||||
def cleanup_expired_cache(self):
|
||||
"""만료된 캐시 정리"""
|
||||
expired = self.db.query(CarCache).filter(
|
||||
CarCache.expires_at < datetime.utcnow()
|
||||
).all()
|
||||
|
||||
for cache in expired:
|
||||
self.db.delete(cache)
|
||||
|
||||
self.db.commit()
|
||||
|
||||
return len(expired)
|
||||
|
||||
# 상세 정보 캐시 관련
|
||||
def get_detail_cache(self, car_id: str) -> Optional[CarDetailCache]:
|
||||
"""상세 정보 캐시 조회"""
|
||||
cache = self.db.query(CarDetailCache).filter(
|
||||
CarDetailCache.car_id == car_id
|
||||
).first()
|
||||
|
||||
if cache:
|
||||
if cache.expires_at < datetime.utcnow():
|
||||
self.db.delete(cache)
|
||||
self.db.commit()
|
||||
return None
|
||||
return cache
|
||||
return None
|
||||
|
||||
def save_detail_cache(self, car_id: str, detail_data: Dict[str, Any]) -> CarDetailCache:
|
||||
"""상세 정보 캐시 저장"""
|
||||
expires_at = datetime.utcnow() + timedelta(hours=CACHE_TTL_HOURS)
|
||||
|
||||
existing = self.db.query(CarDetailCache).filter(
|
||||
CarDetailCache.car_id == car_id
|
||||
).first()
|
||||
if existing:
|
||||
self.db.delete(existing)
|
||||
self.db.commit()
|
||||
|
||||
cache = CarDetailCache(
|
||||
car_id=car_id,
|
||||
detail_data=json.dumps(detail_data, ensure_ascii=False),
|
||||
expires_at=expires_at
|
||||
)
|
||||
self.db.add(cache)
|
||||
self.db.commit()
|
||||
self.db.refresh(cache)
|
||||
return cache
|
||||
|
||||
def get_detail_from_cache(self, cache: CarDetailCache) -> Dict[str, Any]:
|
||||
"""상세 정보 캐시에서 데이터 가져오기"""
|
||||
return json.loads(cache.detail_data)
|
||||
305
backend/app/services/exchange_rate_service.py
Normal file
305
backend/app/services/exchange_rate_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Exchange Rate Service - 한국수출입은행 API 연동
|
||||
API 문서: https://www.koreaexim.go.kr/ir/HPHKIR020M01?apino=2&viewtype=C
|
||||
"""
|
||||
import httpx
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, List
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
|
||||
|
||||
|
||||
# 한국수출입은행 API 설정
|
||||
KOREAEXIM_API_URL = "https://oapi.koreaexim.go.kr/site/program/financial/exchangeJSON"
|
||||
KOREAEXIM_API_KEY = os.getenv("KOREAEXIM_API_KEY", "rOzKaATDEinF9luHla1wVTosjWribjKL")
|
||||
|
||||
# 지원 통화 목록
|
||||
SUPPORTED_CURRENCIES = {
|
||||
"USD": {"name_ko": "미국 달러", "name_en": "US Dollar", "symbol": "$"},
|
||||
"MNT": {"name_ko": "몽골 투그릭", "name_en": "Mongolian Tugrik", "symbol": "₮"},
|
||||
"RUB": {"name_ko": "러시아 루블", "name_en": "Russian Ruble", "symbol": "₽"},
|
||||
"CNY": {"name_ko": "중국 위안", "name_en": "Chinese Yuan", "symbol": "¥"},
|
||||
"JPY": {"name_ko": "일본 엔", "name_en": "Japanese Yen", "symbol": "¥"},
|
||||
"EUR": {"name_ko": "유로", "name_en": "Euro", "symbol": "€"},
|
||||
}
|
||||
|
||||
# 기본 환율 (API 실패 시 사용, 2024년 12월 기준)
|
||||
DEFAULT_RATES = {
|
||||
"USD": 1450.0,
|
||||
"MNT": 0.42, # 1 MNT = 0.42 KRW
|
||||
"RUB": 14.0,
|
||||
"CNY": 198.0,
|
||||
"JPY": 9.5, # 100엔 기준이면 950
|
||||
"EUR": 1510.0,
|
||||
}
|
||||
|
||||
|
||||
async def fetch_rates_from_koreaexim(search_date: Optional[str] = None) -> Optional[List[Dict]]:
|
||||
"""
|
||||
한국수출입은행 API에서 환율 정보 조회
|
||||
|
||||
Args:
|
||||
search_date: 조회일자 (YYYYMMDD 형식), 없으면 오늘
|
||||
|
||||
Returns:
|
||||
환율 데이터 리스트 또는 None
|
||||
"""
|
||||
if not KOREAEXIM_API_KEY:
|
||||
print("Warning: KOREAEXIM_API_KEY not set, using fallback rates")
|
||||
return None
|
||||
|
||||
if not search_date:
|
||||
search_date = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
KOREAEXIM_API_URL,
|
||||
params={
|
||||
"authkey": KOREAEXIM_API_KEY,
|
||||
"searchdate": search_date,
|
||||
"data": "AP01" # 환율 데이터
|
||||
},
|
||||
timeout=15.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
|
||||
# API 결과 코드 확인
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
return data
|
||||
else:
|
||||
print(f"Korea Exim API returned empty data for date {search_date}")
|
||||
# 주말/공휴일이면 이전 영업일 데이터 조회
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to fetch from Korea Exim API: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def parse_koreaexim_response(data: List[Dict]) -> Dict[str, Dict]:
|
||||
"""
|
||||
한국수출입은행 API 응답 파싱
|
||||
|
||||
Response format:
|
||||
{
|
||||
"result": 1,
|
||||
"cur_unit": "USD",
|
||||
"cur_nm": "미국 달러",
|
||||
"ttb": "1,438.71", # 전신환(송금) 받을때
|
||||
"tts": "1,467.28", # 전신환(송금) 보낼때
|
||||
"deal_bas_r": "1,452.99", # 매매 기준율
|
||||
"bkpr": "1,452", # 장부가격
|
||||
...
|
||||
}
|
||||
"""
|
||||
parsed = {}
|
||||
|
||||
for item in data:
|
||||
try:
|
||||
cur_unit = item.get("cur_unit", "").replace("(100)", "").strip()
|
||||
|
||||
if cur_unit not in SUPPORTED_CURRENCIES:
|
||||
continue
|
||||
|
||||
# 쉼표 제거 후 숫자 변환
|
||||
deal_base_rate = float(item.get("deal_bas_r", "0").replace(",", ""))
|
||||
ttb_rate = float(item.get("ttb", "0").replace(",", ""))
|
||||
tts_rate = float(item.get("tts", "0").replace(",", ""))
|
||||
|
||||
# 100엔 단위인 경우 (JPY(100))
|
||||
if "(100)" in item.get("cur_unit", ""):
|
||||
deal_base_rate /= 100
|
||||
ttb_rate /= 100
|
||||
tts_rate /= 100
|
||||
|
||||
parsed[cur_unit] = {
|
||||
"currency_code": cur_unit,
|
||||
"currency_name": item.get("cur_nm", SUPPORTED_CURRENCIES[cur_unit]["name_ko"]),
|
||||
"deal_base_rate": deal_base_rate,
|
||||
"ttb_rate": ttb_rate,
|
||||
"tts_rate": tts_rate,
|
||||
}
|
||||
|
||||
except (ValueError, KeyError) as e:
|
||||
print(f"Error parsing currency {item.get('cur_unit')}: {e}")
|
||||
continue
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
async def update_exchange_rates(db: Session, force: bool = False) -> Dict:
|
||||
"""
|
||||
환율 정보 업데이트
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
force: 강제 업데이트 여부
|
||||
|
||||
Returns:
|
||||
업데이트 결과
|
||||
"""
|
||||
today = datetime.now().strftime("%Y%m%d")
|
||||
|
||||
# 오늘 이미 업데이트했는지 확인 (force가 아닌 경우)
|
||||
if not force:
|
||||
existing = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.source_date == today
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return {
|
||||
"status": "skipped",
|
||||
"message": f"Already updated for {today}",
|
||||
"source_date": today
|
||||
}
|
||||
|
||||
# API 호출 (오늘 데이터 시도)
|
||||
api_data = await fetch_rates_from_koreaexim(today)
|
||||
source_date = today
|
||||
|
||||
# 오늘 데이터 없으면 어제 시도 (주말/공휴일 대응)
|
||||
if not api_data:
|
||||
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d")
|
||||
api_data = await fetch_rates_from_koreaexim(yesterday)
|
||||
source_date = yesterday
|
||||
|
||||
# 그래도 없으면 기본값 사용
|
||||
if not api_data:
|
||||
print("Using fallback rates")
|
||||
rates_data = {
|
||||
code: {
|
||||
"currency_code": code,
|
||||
"currency_name": info["name_ko"],
|
||||
"deal_base_rate": DEFAULT_RATES.get(code, 1.0),
|
||||
"ttb_rate": DEFAULT_RATES.get(code, 1.0) * 0.98,
|
||||
"tts_rate": DEFAULT_RATES.get(code, 1.0) * 1.02,
|
||||
}
|
||||
for code, info in SUPPORTED_CURRENCIES.items()
|
||||
}
|
||||
source = "fallback"
|
||||
else:
|
||||
rates_data = parse_koreaexim_response(api_data)
|
||||
source = "koreaexim"
|
||||
|
||||
# DB에 저장/업데이트
|
||||
updated_currencies = []
|
||||
for code, rate_info in rates_data.items():
|
||||
existing = db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == code
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 기존 데이터 업데이트
|
||||
old_rate = existing.deal_base_rate
|
||||
existing.currency_name = rate_info["currency_name"]
|
||||
existing.deal_base_rate = rate_info["deal_base_rate"]
|
||||
existing.ttb_rate = rate_info["ttb_rate"]
|
||||
existing.tts_rate = rate_info["tts_rate"]
|
||||
existing.adjusted_rate = rate_info["deal_base_rate"] * (1 + existing.weight_percent / 100)
|
||||
existing.source_date = source_date
|
||||
|
||||
# 변동이 있으면 히스토리 저장
|
||||
if old_rate != rate_info["deal_base_rate"]:
|
||||
history = ExchangeRateHistory(
|
||||
currency_code=code,
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date
|
||||
)
|
||||
db.add(history)
|
||||
else:
|
||||
# 신규 데이터 추가
|
||||
new_rate = ExchangeRate(
|
||||
currency_code=code,
|
||||
currency_name=rate_info["currency_name"],
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
ttb_rate=rate_info["ttb_rate"],
|
||||
tts_rate=rate_info["tts_rate"],
|
||||
weight_percent=0.0,
|
||||
adjusted_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date,
|
||||
is_active=True
|
||||
)
|
||||
db.add(new_rate)
|
||||
|
||||
# 히스토리 저장
|
||||
history = ExchangeRateHistory(
|
||||
currency_code=code,
|
||||
deal_base_rate=rate_info["deal_base_rate"],
|
||||
source_date=source_date
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
updated_currencies.append(code)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Updated {len(updated_currencies)} currencies",
|
||||
"currencies": updated_currencies,
|
||||
"source": source,
|
||||
"source_date": source_date
|
||||
}
|
||||
|
||||
|
||||
def get_exchange_rate(db: Session, currency_code: str) -> Optional[ExchangeRate]:
|
||||
"""특정 통화 환율 조회"""
|
||||
return db.query(ExchangeRate).filter(
|
||||
ExchangeRate.currency_code == currency_code,
|
||||
ExchangeRate.is_active == True
|
||||
).first()
|
||||
|
||||
|
||||
def get_all_exchange_rates(db: Session) -> List[ExchangeRate]:
|
||||
"""모든 환율 조회"""
|
||||
return db.query(ExchangeRate).filter(
|
||||
ExchangeRate.is_active == True
|
||||
).all()
|
||||
|
||||
|
||||
def convert_krw_to_currency(db: Session, krw_amount: float, currency_code: str) -> Optional[float]:
|
||||
"""
|
||||
KRW를 다른 통화로 변환
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
krw_amount: 원화 금액
|
||||
currency_code: 대상 통화 코드 (USD, MNT, RUB, CNY)
|
||||
|
||||
Returns:
|
||||
변환된 금액 또는 None
|
||||
"""
|
||||
rate = get_exchange_rate(db, currency_code)
|
||||
|
||||
if not rate or rate.adjusted_rate <= 0:
|
||||
return None
|
||||
|
||||
# KRW / 환율 = 외화
|
||||
return krw_amount / rate.adjusted_rate
|
||||
|
||||
|
||||
def convert_currency_to_krw(db: Session, amount: float, currency_code: str) -> Optional[float]:
|
||||
"""
|
||||
다른 통화를 KRW로 변환
|
||||
|
||||
Args:
|
||||
db: DB 세션
|
||||
amount: 외화 금액
|
||||
currency_code: 원화 통화 코드
|
||||
|
||||
Returns:
|
||||
KRW 금액 또는 None
|
||||
"""
|
||||
rate = get_exchange_rate(db, currency_code)
|
||||
|
||||
if not rate:
|
||||
return None
|
||||
|
||||
# 외화 * 환율 = KRW
|
||||
return amount * rate.adjusted_rate
|
||||
356
backend/app/services/pdf_service.py
Normal file
356
backend/app/services/pdf_service.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""
|
||||
PDF Service for capturing web pages as PDF using Playwright
|
||||
Used for capturing Korean vehicle performance check reports (성능점검기록부)
|
||||
"""
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Tuple
|
||||
from datetime import datetime
|
||||
import tempfile
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PDF generation failure log
|
||||
PDF_FAILURES: List[dict] = [] # In-memory log of recent failures
|
||||
|
||||
# Playwright imports
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Browser, Page
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
print("Warning: Playwright not installed. PDF capture will not work.")
|
||||
|
||||
# Image to PDF imports
|
||||
try:
|
||||
import img2pdf
|
||||
from PIL import Image
|
||||
IMG2PDF_AVAILABLE = True
|
||||
except ImportError:
|
||||
IMG2PDF_AVAILABLE = False
|
||||
print("Warning: img2pdf/pillow not installed. Image-based PDF will not work.")
|
||||
|
||||
# PDF storage directory
|
||||
PDF_STORAGE_DIR = Path(__file__).parent.parent.parent / "uploads" / "performance_checks"
|
||||
|
||||
|
||||
def ensure_pdf_directory():
|
||||
"""Ensure PDF storage directory exists"""
|
||||
PDF_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def log_pdf_failure(car_id: int, check_num: str, error: str):
|
||||
"""Log PDF generation failure"""
|
||||
global PDF_FAILURES
|
||||
failure = {
|
||||
"car_id": car_id,
|
||||
"check_num": check_num,
|
||||
"error": str(error),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"retried": False
|
||||
}
|
||||
PDF_FAILURES.append(failure)
|
||||
# Keep only last 100 failures
|
||||
if len(PDF_FAILURES) > 100:
|
||||
PDF_FAILURES = PDF_FAILURES[-100:]
|
||||
logger.error(f"PDF generation failed - car_id={car_id}, check_num={check_num}: {error}")
|
||||
|
||||
|
||||
def get_pdf_failures() -> List[dict]:
|
||||
"""Get list of recent PDF generation failures"""
|
||||
return PDF_FAILURES.copy()
|
||||
|
||||
|
||||
def clear_pdf_failure(car_id: int):
|
||||
"""Clear failure record for a car after successful retry"""
|
||||
global PDF_FAILURES
|
||||
PDF_FAILURES = [f for f in PDF_FAILURES if f["car_id"] != car_id]
|
||||
|
||||
|
||||
async def capture_performance_check_pdf(
|
||||
check_num: str,
|
||||
car_id: int,
|
||||
timeout: int = 60000,
|
||||
max_retries: int = 3,
|
||||
retry_delay: int = 2
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Capture Korean vehicle performance check report as PDF
|
||||
Uses screenshot-based approach for accurate rendering
|
||||
Includes automatic retry on failure
|
||||
|
||||
Args:
|
||||
check_num: Performance check number (성능점검번호)
|
||||
car_id: Car ID for naming the PDF file
|
||||
timeout: Page load timeout in milliseconds
|
||||
max_retries: Maximum number of retry attempts (default: 3)
|
||||
retry_delay: Delay between retries in seconds (default: 2)
|
||||
|
||||
Returns:
|
||||
PDF file path (relative) if successful, None if failed
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
error_msg = "Playwright not available. Cannot capture PDF."
|
||||
logger.error(error_msg)
|
||||
log_pdf_failure(car_id, check_num, error_msg)
|
||||
return None
|
||||
|
||||
if not IMG2PDF_AVAILABLE:
|
||||
error_msg = "img2pdf/pillow not available. Cannot create PDF from screenshots."
|
||||
logger.error(error_msg)
|
||||
log_pdf_failure(car_id, check_num, error_msg)
|
||||
return None
|
||||
|
||||
ensure_pdf_directory()
|
||||
|
||||
last_error = None
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
# 별도 스레드에서 새 이벤트 루프로 실행하여 uvicorn과의 충돌 방지
|
||||
try:
|
||||
result = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
_capture_pdf_in_new_loop,
|
||||
check_num, car_id, timeout, attempt
|
||||
)
|
||||
if result:
|
||||
# Success - clear any previous failure record
|
||||
clear_pdf_failure(car_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"PDF capture attempt {attempt} failed: {e}")
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"PDF capture attempt {attempt}/{max_retries} failed for car_id={car_id}, retrying in {retry_delay}s...")
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
# All retries failed
|
||||
log_pdf_failure(car_id, check_num, f"Failed after {max_retries} attempts")
|
||||
return None
|
||||
|
||||
|
||||
def _capture_pdf_in_new_loop(check_num: str, car_id: int, timeout: int, attempt: int) -> Optional[str]:
|
||||
"""별도 이벤트 루프에서 PDF 캡처 실행"""
|
||||
import asyncio
|
||||
|
||||
# 새 이벤트 루프 생성
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(_capture_pdf_single_attempt(check_num, car_id, timeout, attempt))
|
||||
return result
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _capture_pdf_single_attempt(
|
||||
check_num: str,
|
||||
car_id: int,
|
||||
timeout: int,
|
||||
attempt: int
|
||||
) -> Optional[str]:
|
||||
"""Single attempt to capture PDF"""
|
||||
print(f"[PDF] _capture_pdf_single_attempt: car_id={car_id}, check_num={check_num}, attempt={attempt}")
|
||||
ensure_pdf_directory()
|
||||
|
||||
# Performance check URL from carmodoo
|
||||
url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do?print=0&checkNum={check_num}"
|
||||
print(f"[PDF] URL: {url}")
|
||||
|
||||
# PDF filename: car_id_timestamp.pdf
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
pdf_filename = f"{car_id}_{timestamp}.pdf"
|
||||
pdf_path = PDF_STORAGE_DIR / pdf_filename
|
||||
relative_path = f"/uploads/performance_checks/{pdf_filename}"
|
||||
print(f"[PDF] Output path: {pdf_path}")
|
||||
|
||||
temp_images: List[Path] = []
|
||||
browser = None
|
||||
|
||||
try:
|
||||
print(f"[PDF] Launching playwright...")
|
||||
async with async_playwright() as p:
|
||||
# Launch browser (headless mode) with extended timeout
|
||||
print(f"[PDF] Launching chromium...")
|
||||
browser: Browser = await p.chromium.launch(
|
||||
headless=True,
|
||||
timeout=30000, # 30 second browser launch timeout
|
||||
args=[
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-extensions',
|
||||
'--disable-background-networking',
|
||||
'--single-process' # Use single process for stability
|
||||
]
|
||||
)
|
||||
print(f"[PDF] Browser launched")
|
||||
|
||||
# Create new page - narrower viewport for larger content
|
||||
context = await browser.new_context(
|
||||
locale='ko-KR',
|
||||
viewport={'width': 900, 'height': 800},
|
||||
device_scale_factor=2 # High DPI for better quality
|
||||
)
|
||||
page: Page = await context.new_page()
|
||||
print(f"[PDF] Page created, navigating to URL...")
|
||||
|
||||
# Navigate to performance check page
|
||||
await page.goto(url, wait_until='networkidle', timeout=timeout)
|
||||
print(f"[PDF] Navigation complete")
|
||||
|
||||
# Wait for content to fully load
|
||||
await page.wait_for_timeout(3000)
|
||||
print(f"[PDF] Content loaded, taking screenshot...")
|
||||
|
||||
# Get full page dimensions
|
||||
page_height = await page.evaluate("document.documentElement.scrollHeight")
|
||||
page_width = await page.evaluate("document.documentElement.scrollWidth")
|
||||
|
||||
print(f"Page size: {page_width}x{page_height}")
|
||||
|
||||
# Take single full-page screenshot (no page splits)
|
||||
screenshot_path = PDF_STORAGE_DIR / f"temp_{car_id}_full.png"
|
||||
await page.screenshot(
|
||||
path=str(screenshot_path),
|
||||
full_page=True
|
||||
)
|
||||
temp_images.append(screenshot_path)
|
||||
print(f"Captured full page screenshot")
|
||||
|
||||
await browser.close()
|
||||
|
||||
# Convert screenshots to PDF
|
||||
if temp_images:
|
||||
print(f"Converting {len(temp_images)} images to PDF...")
|
||||
|
||||
# Process images for A4 size
|
||||
processed_images = []
|
||||
for img_path in temp_images:
|
||||
# Open and convert to RGB (required for PDF)
|
||||
with Image.open(img_path) as img:
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
|
||||
# Save as temporary JPEG for better compression
|
||||
temp_jpg = img_path.with_suffix('.jpg')
|
||||
img.save(temp_jpg, 'JPEG', quality=95)
|
||||
processed_images.append(temp_jpg)
|
||||
|
||||
# Create PDF with margins (25mm left/right, 30mm top/bottom)
|
||||
margin_lr_mm = 25 # left/right margin
|
||||
margin_tb_mm = 30 # top/bottom margin
|
||||
|
||||
# Get image dimensions to calculate page size
|
||||
with Image.open(processed_images[0]) as img:
|
||||
img_width_px, img_height_px = img.size
|
||||
|
||||
# Convert image pixels to points (assuming 150 DPI for reasonable size)
|
||||
dpi = 150
|
||||
img_width_pt = img_width_px * 72 / dpi
|
||||
img_height_pt = img_height_px * 72 / dpi
|
||||
|
||||
# Page size = image size + margins
|
||||
page_width_pt = img_width_pt + 2 * img2pdf.mm_to_pt(margin_lr_mm)
|
||||
page_height_pt = img_height_pt + 2 * img2pdf.mm_to_pt(margin_tb_mm)
|
||||
|
||||
with open(pdf_path, 'wb') as f:
|
||||
pdf_bytes = img2pdf.convert(
|
||||
[str(img) for img in processed_images],
|
||||
layout_fun=img2pdf.get_layout_fun(
|
||||
pagesize=(page_width_pt, page_height_pt),
|
||||
border=(img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm),
|
||||
img2pdf.mm_to_pt(margin_lr_mm), img2pdf.mm_to_pt(margin_tb_mm)),
|
||||
fit=img2pdf.FitMode.into
|
||||
)
|
||||
)
|
||||
f.write(pdf_bytes)
|
||||
|
||||
# Cleanup temporary files
|
||||
for img_path in temp_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
for img_path in processed_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
|
||||
# Verify PDF was created
|
||||
if pdf_path.exists() and pdf_path.stat().st_size > 0:
|
||||
logger.info(f"PDF captured successfully (attempt {attempt}): {pdf_path}")
|
||||
return relative_path
|
||||
else:
|
||||
logger.warning(f"PDF file not created or empty: {pdf_path}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_trace = traceback.format_exc()
|
||||
logger.error(f"Error capturing PDF for check_num={check_num} (attempt {attempt}): {e}\n{error_trace}")
|
||||
print(f"[PDF] ERROR: {e}\n{error_trace}")
|
||||
# Cleanup on error
|
||||
for img_path in temp_images:
|
||||
if img_path.exists():
|
||||
img_path.unlink()
|
||||
return None
|
||||
|
||||
|
||||
def capture_performance_check_pdf_sync(check_num: str, car_id: int) -> Optional[str]:
|
||||
"""
|
||||
Synchronous wrapper for capture_performance_check_pdf
|
||||
For use in non-async contexts
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
return loop.run_until_complete(capture_performance_check_pdf(check_num, car_id))
|
||||
|
||||
|
||||
def get_pdf_path(car_id: int) -> Optional[str]:
|
||||
"""
|
||||
Get existing PDF path for a car if it exists
|
||||
Returns the most recent PDF for the car
|
||||
"""
|
||||
ensure_pdf_directory()
|
||||
|
||||
# Find all PDFs for this car
|
||||
pattern = f"{car_id}_*.pdf"
|
||||
pdf_files = list(PDF_STORAGE_DIR.glob(pattern))
|
||||
|
||||
if not pdf_files:
|
||||
return None
|
||||
|
||||
# Return the most recent one
|
||||
latest_pdf = max(pdf_files, key=lambda p: p.stat().st_mtime)
|
||||
return f"/uploads/performance_checks/{latest_pdf.name}"
|
||||
|
||||
|
||||
def delete_pdf(relative_path: str) -> bool:
|
||||
"""Delete a PDF file"""
|
||||
try:
|
||||
filename = Path(relative_path).name
|
||||
full_path = PDF_STORAGE_DIR / filename
|
||||
if full_path.exists():
|
||||
full_path.unlink()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Error deleting PDF: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_pdf_full_path(relative_path: str) -> Optional[Path]:
|
||||
"""Get full filesystem path from relative path"""
|
||||
if not relative_path:
|
||||
return None
|
||||
filename = Path(relative_path).name
|
||||
full_path = PDF_STORAGE_DIR / filename
|
||||
if full_path.exists():
|
||||
return full_path
|
||||
return None
|
||||
181
backend/app/services/sensitive_filter.py
Normal file
181
backend/app/services/sensitive_filter.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Sensitive Information Detection and Masking Service
|
||||
|
||||
Detects and masks Korean phone numbers, addresses, and other PII in dealer descriptions.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import List, Tuple, Dict
|
||||
|
||||
|
||||
# Korean phone number patterns
|
||||
PHONE_PATTERNS = [
|
||||
r'01[0-9]-?\d{3,4}-?\d{4}', # Mobile: 010-1234-5678, 0101234567
|
||||
r'02-?\d{3,4}-?\d{4}', # Seoul: 02-123-4567
|
||||
r'0[3-6][0-9]-?\d{3,4}-?\d{4}', # Regional: 031-123-4567
|
||||
r'070-?\d{3,4}-?\d{4}', # Internet phone: 070-1234-5678
|
||||
r'1[0-9]{2,3}-?\d{4}', # Service numbers: 1588-1234
|
||||
r'\d{2,4}[-.)]\s*\d{3,4}[-.)]\s*\d{4}', # Various formats with separators
|
||||
]
|
||||
|
||||
# Korean address patterns
|
||||
ADDRESS_PATTERNS = [
|
||||
r'[가-힣]+시\s+[가-힣]+구', # 서울시 강남구
|
||||
r'[가-힣]+도\s+[가-힣]+시', # 경기도 성남시
|
||||
r'[가-힣]+시\s+[가-힣]+동', # 서울시 역삼동
|
||||
r'[가-힣]+구\s+[가-힣]+동', # 강남구 역삼동
|
||||
r'[가-힣]+로\s*\d+', # 테헤란로 123
|
||||
r'[가-힣]+길\s*\d+', # 역삼길 45
|
||||
r'\d+번지', # 123번지
|
||||
r'[가-힣]+빌딩', # XX빌딩
|
||||
r'[가-힣]+타워', # XX타워
|
||||
r'[가-힣]+센터', # XX센터
|
||||
]
|
||||
|
||||
# Other sensitive patterns
|
||||
OTHER_PATTERNS = [
|
||||
r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', # Email
|
||||
r'카[카톡|톡]\s*:?\s*[a-zA-Z0-9가-힣]+', # 카톡 ID
|
||||
r'카카오톡?\s*:?\s*[a-zA-Z0-9가-힣]+', # 카카오톡 ID
|
||||
]
|
||||
|
||||
|
||||
def detect_sensitive_info(text: str) -> Dict[str, List[Tuple[int, int, str]]]:
|
||||
"""
|
||||
Detect sensitive information in text.
|
||||
|
||||
Returns:
|
||||
Dict with categories as keys and list of (start, end, matched_text) tuples as values.
|
||||
"""
|
||||
if not text:
|
||||
return {"phones": [], "addresses": [], "others": []}
|
||||
|
||||
result = {
|
||||
"phones": [],
|
||||
"addresses": [],
|
||||
"others": []
|
||||
}
|
||||
|
||||
# Detect phone numbers
|
||||
for pattern in PHONE_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["phones"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Detect addresses
|
||||
for pattern in ADDRESS_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["addresses"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Detect other sensitive info
|
||||
for pattern in OTHER_PATTERNS:
|
||||
for match in re.finditer(pattern, text):
|
||||
result["others"].append((match.start(), match.end(), match.group()))
|
||||
|
||||
# Remove duplicates and sort by position
|
||||
for category in result:
|
||||
result[category] = sorted(set(result[category]), key=lambda x: x[0])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def mask_sensitive_info(text: str, mask_char: str = "*") -> str:
|
||||
"""
|
||||
Mask all detected sensitive information in text.
|
||||
|
||||
Args:
|
||||
text: Original text
|
||||
mask_char: Character to use for masking (default: *)
|
||||
|
||||
Returns:
|
||||
Text with sensitive info masked
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
|
||||
# Collect all ranges to mask
|
||||
ranges = []
|
||||
for category in detected.values():
|
||||
for start, end, _ in category:
|
||||
ranges.append((start, end))
|
||||
|
||||
# Merge overlapping ranges
|
||||
ranges = sorted(ranges)
|
||||
merged = []
|
||||
for start, end in ranges:
|
||||
if merged and start <= merged[-1][1]:
|
||||
merged[-1] = (merged[-1][0], max(merged[-1][1], end))
|
||||
else:
|
||||
merged.append((start, end))
|
||||
|
||||
# Apply masking (reverse order to preserve positions)
|
||||
result = text
|
||||
for start, end in reversed(merged):
|
||||
original = result[start:end]
|
||||
# Keep first and last char, mask the middle
|
||||
if len(original) > 4:
|
||||
masked = original[:2] + mask_char * (len(original) - 4) + original[-2:]
|
||||
else:
|
||||
masked = mask_char * len(original)
|
||||
result = result[:start] + masked + result[end:]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def highlight_sensitive_info(text: str) -> str:
|
||||
"""
|
||||
Add HTML highlighting to detected sensitive information.
|
||||
Used for admin preview.
|
||||
|
||||
Returns:
|
||||
HTML string with sensitive info wrapped in <mark> tags
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
|
||||
# Collect all ranges with their categories
|
||||
ranges = []
|
||||
for category, items in detected.items():
|
||||
for start, end, matched in items:
|
||||
ranges.append((start, end, matched, category))
|
||||
|
||||
# Sort by position (reverse for replacement)
|
||||
ranges = sorted(ranges, key=lambda x: x[0], reverse=True)
|
||||
|
||||
result = text
|
||||
for start, end, matched, category in ranges:
|
||||
color = {
|
||||
"phones": "#fee2e2", # red-100
|
||||
"addresses": "#fef3c7", # amber-100
|
||||
"others": "#dbeafe" # blue-100
|
||||
}.get(category, "#e5e7eb")
|
||||
|
||||
result = (
|
||||
result[:start] +
|
||||
f'<mark style="background-color: {color}; padding: 0 2px;">{matched}</mark>' +
|
||||
result[end:]
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def has_sensitive_info(text: str) -> bool:
|
||||
"""Check if text contains any sensitive information."""
|
||||
if not text:
|
||||
return False
|
||||
detected = detect_sensitive_info(text)
|
||||
return any(len(items) > 0 for items in detected.values())
|
||||
|
||||
|
||||
def get_sensitivity_summary(text: str) -> Dict[str, int]:
|
||||
"""Get count of each type of sensitive info detected."""
|
||||
if not text:
|
||||
return {"phones": 0, "addresses": 0, "others": 0, "total": 0}
|
||||
|
||||
detected = detect_sensitive_info(text)
|
||||
counts = {k: len(v) for k, v in detected.items()}
|
||||
counts["total"] = sum(counts.values())
|
||||
return counts
|
||||
364
backend/app/services/spec_service.py
Normal file
364
backend/app/services/spec_service.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""
|
||||
Specification Service for fetching vehicle specifications from AUTOBEGINS via Carmodoo
|
||||
Uses Playwright to interact with the dealer portal and call AUTOBEGINS API
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import asyncio
|
||||
import logging
|
||||
import json
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Playwright imports
|
||||
try:
|
||||
from playwright.async_api import async_playwright, Browser, Page
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
logger.warning("Playwright not installed. Specification lookup will not work.")
|
||||
|
||||
# Carmodoo credentials
|
||||
CARMODOO_BASE_URL = "https://dealer.carmodoo.com"
|
||||
CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258")
|
||||
CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CarSpecification:
|
||||
"""Vehicle specification data from AUTOBEGINS"""
|
||||
car_number: str = ""
|
||||
manufacturer: str = ""
|
||||
model_name: str = ""
|
||||
grade: str = ""
|
||||
model_year: str = ""
|
||||
first_registration: str = ""
|
||||
body_type: str = ""
|
||||
transmission: str = ""
|
||||
fuel_type: str = ""
|
||||
displacement: int = 0
|
||||
color: str = ""
|
||||
mileage: int = 0
|
||||
usage: str = ""
|
||||
vin: str = ""
|
||||
inspection_validity: str = ""
|
||||
|
||||
# Price info (in 만원)
|
||||
release_price: int = 0
|
||||
base_price: int = 0
|
||||
option_price: int = 0
|
||||
|
||||
# Mortgage/Seizure
|
||||
mortgage_count: int = 0
|
||||
seizure_count: int = 0
|
||||
|
||||
# Options
|
||||
standard_options: list = field(default_factory=list)
|
||||
selected_options: list = field(default_factory=list)
|
||||
|
||||
# Raw data
|
||||
raw_data: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def _parse_spec_html(html: str, car_number: str) -> CarSpecification:
|
||||
"""Parse HTML content from AUTOBEGINS search.html to extract specification data"""
|
||||
spec = CarSpecification(car_number=car_number)
|
||||
spec.raw_data = {"html_length": len(html)}
|
||||
|
||||
try:
|
||||
# Manufacturer and Model (from logo and text)
|
||||
model_match = re.search(r'<ul class="model">\s*<li>.*?([^>]+)<br>([^<]+)</li>', html, re.DOTALL)
|
||||
if model_match:
|
||||
# Extract manufacturer from text before <br>
|
||||
maker_text = model_match.group(1).strip()
|
||||
maker_clean = re.sub(r'<[^>]+>', '', maker_text).strip()
|
||||
spec.manufacturer = maker_clean
|
||||
|
||||
# Model name after <br>
|
||||
spec.model_name = model_match.group(2).strip()
|
||||
|
||||
# Alternative manufacturer detection
|
||||
if not spec.manufacturer:
|
||||
maker_patterns = ['기아', '현대', 'KG모빌리티', '쌍용', '르노', '쉐보레', 'BMW', '벤츠', '아우디', '볼보', '렉서스', '토요타']
|
||||
for maker in maker_patterns:
|
||||
if maker in html:
|
||||
spec.manufacturer = maker
|
||||
break
|
||||
|
||||
# Year (년형)
|
||||
year_match = re.search(r'<th>년형</th>\s*<td>(\d{4})년</td>', html)
|
||||
if year_match:
|
||||
spec.model_year = year_match.group(1)
|
||||
|
||||
# First registration (최초등록일)
|
||||
reg_match = re.search(r'<th>최초등록일</th>\s*<td>(\d{4}\.\d{2}\.\d{2})</td>', html)
|
||||
if reg_match:
|
||||
spec.first_registration = reg_match.group(1)
|
||||
|
||||
# Body type (외형)
|
||||
body_match = re.search(r'<th>외형</th>\s*<td>([^<]+)</td>', html)
|
||||
if body_match:
|
||||
spec.body_type = body_match.group(1).strip()
|
||||
|
||||
# Transmission (미션)
|
||||
trans_match = re.search(r'<th>미션</th>\s*<td>([^<]+)</td>', html)
|
||||
if trans_match:
|
||||
spec.transmission = trans_match.group(1).strip()
|
||||
|
||||
# Fuel type (연료)
|
||||
fuel_match = re.search(r'<th>연료</th>\s*<td>([^<]+)</td>', html)
|
||||
if fuel_match:
|
||||
spec.fuel_type = fuel_match.group(1).strip()
|
||||
|
||||
# Displacement (배기량)
|
||||
disp_match = re.search(r'<th>배기량</th>\s*<td>(\d+)cc</td>', html)
|
||||
if disp_match:
|
||||
spec.displacement = int(disp_match.group(1))
|
||||
|
||||
# Color (색상)
|
||||
color_match = re.search(r'<th>색상</th>\s*<td>([^<]+)</td>', html)
|
||||
if color_match:
|
||||
spec.color = color_match.group(1).strip()
|
||||
|
||||
# Mileage (주행거리)
|
||||
mileage_match = re.search(r'<th>주행거리</th>\s*<td>([\d,]+)km</td>', html)
|
||||
if mileage_match:
|
||||
spec.mileage = int(mileage_match.group(1).replace(',', ''))
|
||||
|
||||
# Usage (용도)
|
||||
usage_match = re.search(r'<th>용도</th>\s*<td>([^<]+)</td>', html)
|
||||
if usage_match:
|
||||
spec.usage = usage_match.group(1).strip()
|
||||
|
||||
# VIN (차대번호)
|
||||
vin_match = re.search(r'value="([A-Z0-9]{17})"', html)
|
||||
if vin_match:
|
||||
spec.vin = vin_match.group(1)
|
||||
|
||||
# Inspection validity (검사유효기간)
|
||||
insp_match = re.search(r'<th>검사유효기간</th>\s*<td[^>]*>([^<]+)</td>', html)
|
||||
if insp_match:
|
||||
spec.inspection_validity = insp_match.group(1).strip()
|
||||
|
||||
# Price extraction - digit_area contains nested spans with hidden digits
|
||||
# Format: <span class="digit_area red">...<span class="hide">1</span>...<span class="hide">4</span>...</span>
|
||||
def extract_price_from_section(section_html):
|
||||
"""Extract price from a section of HTML containing digit_area spans"""
|
||||
digits = re.findall(r'<span class="hide">([0-9])</span>', section_html)
|
||||
if digits:
|
||||
try:
|
||||
return int(''.join(digits))
|
||||
except:
|
||||
pass
|
||||
return 0
|
||||
|
||||
# Release price (출고가) - find the whole price_table row
|
||||
release_section = re.search(r'출고가.*?</td>', html, re.DOTALL)
|
||||
if release_section:
|
||||
spec.release_price = extract_price_from_section(release_section.group(0))
|
||||
|
||||
# Base price (기본가)
|
||||
base_section = re.search(r'>기본가<.*?</td>', html, re.DOTALL)
|
||||
if base_section:
|
||||
spec.base_price = extract_price_from_section(base_section.group(0))
|
||||
|
||||
# Option price (출고시 옵션가)
|
||||
option_section = re.search(r'출고시 옵션가.*?</td>', html, re.DOTALL)
|
||||
if option_section:
|
||||
spec.option_price = extract_price_from_section(option_section.group(0))
|
||||
|
||||
# Mortgage/Seizure (저당/압류)
|
||||
mortgage_match = re.search(r'<span class="title_big">저당</span>\s*<strong[^>]*>(\d+)</strong>', html)
|
||||
if mortgage_match:
|
||||
spec.mortgage_count = int(mortgage_match.group(1))
|
||||
|
||||
seizure_match = re.search(r'<span class="title_big">압류</span>\s*<strong[^>]*>(\d+)</strong>', html)
|
||||
if seizure_match:
|
||||
spec.seizure_count = int(seizure_match.group(1))
|
||||
|
||||
# Standard options (기본품목)
|
||||
std_opts = re.findall(r'<ul class="opt_base">.*?</ul>', html, re.DOTALL)
|
||||
if std_opts:
|
||||
spec.standard_options = re.findall(r'<span>([^<]+)</span>', std_opts[0])
|
||||
|
||||
# Selected options (선택품목)
|
||||
sel_opts = re.findall(r'<li><span>([^<]+)</span>\s*<strong>([^<]+)</strong></li>', html)
|
||||
spec.selected_options = [f"{name} ({price})" for name, price in sel_opts]
|
||||
|
||||
logger.info(f"Parsed spec for {car_number}: {spec.manufacturer} {spec.model_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing spec HTML: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
return spec
|
||||
|
||||
|
||||
async def get_specifications_from_carmodoo(car_number: str, timeout: int = 60000) -> Optional[CarSpecification]:
|
||||
"""
|
||||
Fetch vehicle specifications from AUTOBEGINS via Carmodoo dealer portal
|
||||
|
||||
Args:
|
||||
car_number: Korean license plate number (e.g., "117더3590")
|
||||
timeout: Maximum wait time in milliseconds
|
||||
|
||||
Returns:
|
||||
CarSpecification object or None if not found
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.error("Playwright not available for specification lookup")
|
||||
return None
|
||||
|
||||
if not car_number or len(car_number) < 7:
|
||||
logger.error(f"Invalid car number: {car_number}")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(headless=True)
|
||||
page = await browser.new_page()
|
||||
|
||||
try:
|
||||
# Login to Carmodoo
|
||||
logger.info("Logging in to Carmodoo...")
|
||||
await page.goto(f"{CARMODOO_BASE_URL}/member/login_v2.html", timeout=timeout)
|
||||
await page.fill('input[name="id"]', CARMODOO_USER_ID)
|
||||
await page.fill('input[name="passwd"]', CARMODOO_PASSWORD)
|
||||
await page.click('input[value="LOGIN"]')
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Navigate to spec search page
|
||||
logger.info("Navigating to spec search...")
|
||||
await page.goto(f"{CARMODOO_BASE_URL}/info/search_ab.html", timeout=timeout)
|
||||
await page.wait_for_timeout(3000)
|
||||
|
||||
# Find the AUTOBEGINS iframe
|
||||
target_frame = None
|
||||
for frame in page.frames:
|
||||
if 'autobegins.com/cp/?k=' in frame.url:
|
||||
target_frame = frame
|
||||
break
|
||||
|
||||
if not target_frame:
|
||||
logger.error("Could not find AUTOBEGINS frame")
|
||||
return None
|
||||
|
||||
# Get OTP values
|
||||
otp = await target_frame.evaluate("document.getElementById('otp').value")
|
||||
next_otp = await target_frame.evaluate("document.getElementById('nextOtp').value")
|
||||
|
||||
logger.info(f"Calling AUTOBEGINS API for: {car_number}")
|
||||
|
||||
# Call the API directly
|
||||
api_result = await target_frame.evaluate("""
|
||||
async (params) => {
|
||||
const { carNum, otp, nextOtp } = params;
|
||||
try {
|
||||
const response = await fetch('/ext/gg1_ab.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `mode=search&carNum=${encodeURIComponent(carNum)}&otp=${otp}&nextOtp=${nextOtp}`
|
||||
});
|
||||
const text = await response.text();
|
||||
return { success: true, data: text };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
""", {"carNum": car_number, "otp": otp, "nextOtp": next_otp})
|
||||
|
||||
if not api_result.get('success'):
|
||||
logger.error(f"API call failed: {api_result.get('error')}")
|
||||
return None
|
||||
|
||||
# Parse API response
|
||||
try:
|
||||
data = json.loads(api_result['data'])
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Failed to parse API response")
|
||||
return None
|
||||
|
||||
rst_code = data.get('rst_code')
|
||||
if rst_code != 1:
|
||||
rst_msg = data.get('rst_msg', 'Unknown error')
|
||||
logger.warning(f"AUTOBEGINS API returned: {rst_msg} (code: {rst_code})")
|
||||
return None
|
||||
|
||||
# Get search result
|
||||
sd_key = data.get('sdKey')
|
||||
sd_type = data.get('sdType')
|
||||
|
||||
if sd_type not in [2, 3]:
|
||||
logger.warning(f"Unexpected sdType: {sd_type}")
|
||||
return None
|
||||
|
||||
# Navigate to result page
|
||||
page_name = 'search_yet.html' if sd_type == 2 else 'search.html'
|
||||
result_url = f'/cp/{page_name}?otp={otp}&nextOtp={next_otp}&S_SDDATA={sd_key}'
|
||||
|
||||
await target_frame.evaluate(f"""
|
||||
document.getElementById('searchIFrame').src = '{result_url}';
|
||||
""")
|
||||
|
||||
logger.info("Waiting for result page to load...")
|
||||
await page.wait_for_timeout(8000)
|
||||
|
||||
# Find and read result frame
|
||||
result_content = None
|
||||
for frame in page.frames:
|
||||
if page_name in frame.url and 'S_SDDATA' in frame.url:
|
||||
result_content = await frame.content()
|
||||
break
|
||||
|
||||
if not result_content:
|
||||
logger.error("Could not find result frame content")
|
||||
return None
|
||||
|
||||
# Parse the HTML
|
||||
spec = _parse_spec_html(result_content, car_number)
|
||||
logger.info(f"Successfully retrieved specs for {car_number}")
|
||||
return spec
|
||||
|
||||
finally:
|
||||
await browser.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching specifications for {car_number}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return None
|
||||
|
||||
|
||||
def spec_to_dict(spec: CarSpecification) -> dict:
|
||||
"""Convert CarSpecification to dictionary for database storage"""
|
||||
return {
|
||||
"car_number": spec.car_number,
|
||||
"manufacturer": spec.manufacturer,
|
||||
"model_name": spec.model_name,
|
||||
"grade": spec.grade,
|
||||
"model_year": spec.model_year,
|
||||
"first_registration": spec.first_registration,
|
||||
"body_type": spec.body_type,
|
||||
"transmission": spec.transmission,
|
||||
"fuel_type": spec.fuel_type,
|
||||
"displacement": spec.displacement,
|
||||
"color": spec.color,
|
||||
"mileage": spec.mileage,
|
||||
"usage": spec.usage,
|
||||
"vin": spec.vin,
|
||||
"inspection_validity": spec.inspection_validity,
|
||||
"release_price": spec.release_price,
|
||||
"base_price": spec.base_price,
|
||||
"option_price": spec.option_price,
|
||||
"mortgage_count": spec.mortgage_count,
|
||||
"seizure_count": spec.seizure_count,
|
||||
"standard_options": spec.standard_options,
|
||||
"selected_options": spec.selected_options,
|
||||
"raw_data": spec.raw_data,
|
||||
}
|
||||
174
backend/app/services/translation_service.py
Normal file
174
backend/app/services/translation_service.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Azure Translator Service for dealer descriptions
|
||||
Supports Korean → English, Mongolian, Russian direct translation
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
from typing import Optional, Dict
|
||||
import json
|
||||
|
||||
|
||||
class AzureTranslationService:
|
||||
"""Microsoft Azure Translator API Service"""
|
||||
|
||||
AZURE_ENDPOINT = "https://api.cognitive.microsofttranslator.com"
|
||||
API_VERSION = "3.0"
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.getenv("AZURE_TRANSLATOR_KEY", "")
|
||||
self.region = os.getenv("AZURE_TRANSLATOR_REGION", "koreacentral")
|
||||
self._is_configured = bool(self.api_key)
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""Check if Azure Translator API is configured"""
|
||||
return self._is_configured
|
||||
|
||||
async def translate(self, text: str, target_lang: str, source_lang: str = "ko") -> Optional[str]:
|
||||
"""
|
||||
Translate text from source language to target language
|
||||
|
||||
Args:
|
||||
text: Text to translate (Korean)
|
||||
target_lang: Target language code (en, mn, ru)
|
||||
source_lang: Source language code (default: ko)
|
||||
|
||||
Returns:
|
||||
Translated text or None if failed
|
||||
"""
|
||||
if not self._is_configured:
|
||||
print("[Translation] Azure Translator API not configured")
|
||||
return None
|
||||
|
||||
if not text or not text.strip():
|
||||
return ""
|
||||
|
||||
try:
|
||||
url = f"{self.AZURE_ENDPOINT}/translate"
|
||||
params = {
|
||||
"api-version": self.API_VERSION,
|
||||
"from": source_lang,
|
||||
"to": target_lang
|
||||
}
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": self.api_key,
|
||||
"Ocp-Apim-Subscription-Region": self.region,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
body = [{"text": text}]
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result and len(result) > 0 and "translations" in result[0]:
|
||||
translated = result[0]["translations"][0]["text"]
|
||||
print(f"[Translation] Success: {source_lang} -> {target_lang}")
|
||||
return translated
|
||||
else:
|
||||
error_msg = response.text
|
||||
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Translation] Exception: {e}")
|
||||
return None
|
||||
|
||||
async def translate_all_languages(self, text: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Translate text to all supported languages (en, mn, ru) in a single API call
|
||||
|
||||
Args:
|
||||
text: Korean text to translate
|
||||
|
||||
Returns:
|
||||
Dictionary with translations: {
|
||||
'en': '...',
|
||||
'mn': '...',
|
||||
'ru': '...'
|
||||
}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {'en': '', 'mn': '', 'ru': ''}
|
||||
|
||||
if not self._is_configured:
|
||||
print("[Translation] Azure Translator API not configured")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
try:
|
||||
# Azure supports multiple target languages in a single call
|
||||
url = f"{self.AZURE_ENDPOINT}/translate"
|
||||
params = {
|
||||
"api-version": self.API_VERSION,
|
||||
"from": "ko",
|
||||
"to": ["en", "mn", "ru"] # All three languages at once
|
||||
}
|
||||
headers = {
|
||||
"Ocp-Apim-Subscription-Key": self.api_key,
|
||||
"Ocp-Apim-Subscription-Region": self.region,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
body = [{"text": text}]
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
json=body
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
if result and len(result) > 0 and "translations" in result[0]:
|
||||
translations = result[0]["translations"]
|
||||
result_dict = {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
for trans in translations:
|
||||
lang = trans.get("to")
|
||||
text_translated = trans.get("text")
|
||||
if lang in result_dict:
|
||||
result_dict[lang] = text_translated
|
||||
|
||||
print(f"[Translation] Success: ko -> en, mn, ru (batch)")
|
||||
return result_dict
|
||||
else:
|
||||
error_msg = response.text
|
||||
print(f"[Translation] API Error ({response.status_code}): {error_msg}")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Translation] Exception: {e}")
|
||||
return {'en': None, 'mn': None, 'ru': None}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_translation_service: Optional[AzureTranslationService] = None
|
||||
|
||||
|
||||
def get_translation_service() -> AzureTranslationService:
|
||||
"""Get or create the translation service singleton"""
|
||||
global _translation_service
|
||||
if _translation_service is None:
|
||||
_translation_service = AzureTranslationService()
|
||||
return _translation_service
|
||||
|
||||
|
||||
async def translate_dealer_description(text: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Convenience function to translate dealer description to all languages
|
||||
|
||||
Args:
|
||||
text: Korean dealer description
|
||||
|
||||
Returns:
|
||||
Dictionary with translations for en, mn, ru
|
||||
"""
|
||||
service = get_translation_service()
|
||||
return await service.translate_all_languages(text)
|
||||
313
backend/app/services/verification_service.py
Normal file
313
backend/app/services/verification_service.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Verification Service for Email and SMS
|
||||
Handles sending and verifying codes for user authentication
|
||||
"""
|
||||
import random
|
||||
import string
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Tuple
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.user import User, VerificationCode
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def generate_code(length: int = 6) -> str:
|
||||
"""Generate a random numeric code"""
|
||||
return ''.join(random.choices(string.digits, k=length))
|
||||
|
||||
|
||||
def create_verification_code(
|
||||
db: Session,
|
||||
code_type: str, # 'email' or 'phone'
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
purpose: str = "verification"
|
||||
) -> VerificationCode:
|
||||
"""Create a new verification code"""
|
||||
# Invalidate any existing codes for this email/phone
|
||||
if email:
|
||||
db.query(VerificationCode).filter(
|
||||
VerificationCode.email == email,
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
).delete()
|
||||
if phone:
|
||||
db.query(VerificationCode).filter(
|
||||
VerificationCode.phone == phone,
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
).delete()
|
||||
|
||||
# Create new code
|
||||
code = VerificationCode(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
phone=phone,
|
||||
code=generate_code(),
|
||||
code_type=code_type,
|
||||
purpose=purpose,
|
||||
expires_at=datetime.utcnow() + timedelta(minutes=settings.VERIFICATION_CODE_EXPIRE_MINUTES)
|
||||
)
|
||||
db.add(code)
|
||||
db.commit()
|
||||
db.refresh(code)
|
||||
return code
|
||||
|
||||
|
||||
def verify_code(
|
||||
db: Session,
|
||||
code: str,
|
||||
code_type: str,
|
||||
email: Optional[str] = None,
|
||||
phone: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Verify a code and return (success, message)
|
||||
"""
|
||||
query = db.query(VerificationCode).filter(
|
||||
VerificationCode.code_type == code_type,
|
||||
VerificationCode.verified_at.is_(None)
|
||||
)
|
||||
|
||||
if email:
|
||||
query = query.filter(VerificationCode.email == email)
|
||||
if phone:
|
||||
query = query.filter(VerificationCode.phone == phone)
|
||||
|
||||
verification = query.order_by(VerificationCode.created_at.desc()).first()
|
||||
|
||||
if not verification:
|
||||
return False, "No verification code found. Please request a new one."
|
||||
|
||||
# Check if expired
|
||||
if datetime.utcnow() > verification.expires_at.replace(tzinfo=None):
|
||||
return False, "Verification code has expired. Please request a new one."
|
||||
|
||||
# Check attempts
|
||||
if verification.attempts >= verification.max_attempts:
|
||||
return False, "Too many failed attempts. Please request a new code."
|
||||
|
||||
# Check code
|
||||
if verification.code != code:
|
||||
verification.attempts += 1
|
||||
db.commit()
|
||||
remaining = verification.max_attempts - verification.attempts
|
||||
return False, f"Invalid code. {remaining} attempts remaining."
|
||||
|
||||
# Success
|
||||
verification.verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return True, "Verification successful"
|
||||
|
||||
|
||||
async def send_email_verification(
|
||||
db: Session,
|
||||
email: str,
|
||||
user_id: Optional[int] = None,
|
||||
language: str = "en"
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send email verification code"""
|
||||
# Check rate limit (1 email per minute)
|
||||
recent = db.query(VerificationCode).filter(
|
||||
VerificationCode.email == email,
|
||||
VerificationCode.code_type == "email",
|
||||
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
|
||||
).first()
|
||||
|
||||
if recent:
|
||||
return False, "Please wait 1 minute before requesting another code."
|
||||
|
||||
# Create verification code
|
||||
verification = create_verification_code(
|
||||
db=db,
|
||||
code_type="email",
|
||||
email=email,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# Send email
|
||||
try:
|
||||
# Email templates by language
|
||||
subjects = {
|
||||
"en": "AutonetSellCar - Email Verification Code",
|
||||
"ko": "AutonetSellCar - 이메일 인증 코드",
|
||||
"mn": "AutonetSellCar - Имэйл баталгаажуулах код",
|
||||
"ru": "AutonetSellCar - Код подтверждения email"
|
||||
}
|
||||
|
||||
bodies = {
|
||||
"en": f"""
|
||||
Hello,
|
||||
|
||||
Your verification code is: {verification.code}
|
||||
|
||||
This code will expire in {settings.VERIFICATION_CODE_EXPIRE_MINUTES} minutes.
|
||||
|
||||
If you didn't request this code, please ignore this email.
|
||||
|
||||
Best regards,
|
||||
AutonetSellCar Team
|
||||
""",
|
||||
"ko": f"""
|
||||
안녕하세요,
|
||||
|
||||
인증 코드: {verification.code}
|
||||
|
||||
이 코드는 {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분 후에 만료됩니다.
|
||||
|
||||
요청하지 않은 경우 이 이메일을 무시하세요.
|
||||
|
||||
감사합니다,
|
||||
AutonetSellCar 팀
|
||||
""",
|
||||
"mn": f"""
|
||||
Сайн байна уу,
|
||||
|
||||
Таны баталгаажуулах код: {verification.code}
|
||||
|
||||
Энэ код {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минутын дараа хүчингүй болно.
|
||||
|
||||
Хэрэв та энэ кодыг хүсээгүй бол энэ имэйлийг үл тоомсорлоно уу.
|
||||
|
||||
Хүндэтгэсэн,
|
||||
AutonetSellCar баг
|
||||
""",
|
||||
"ru": f"""
|
||||
Здравствуйте,
|
||||
|
||||
Ваш код подтверждения: {verification.code}
|
||||
|
||||
Этот код истечет через {settings.VERIFICATION_CODE_EXPIRE_MINUTES} минут.
|
||||
|
||||
Если вы не запрашивали этот код, проигнорируйте это письмо.
|
||||
|
||||
С уважением,
|
||||
Команда AutonetSellCar
|
||||
"""
|
||||
}
|
||||
|
||||
subject = subjects.get(language, subjects["en"])
|
||||
body = bodies.get(language, bodies["en"])
|
||||
|
||||
# Check if SMTP is configured
|
||||
if not settings.SMTP_USER or not settings.SMTP_PASSWORD:
|
||||
# Development mode - just log the code
|
||||
print(f"[DEV] Email verification code for {email}: {verification.code}")
|
||||
return True, "Verification code sent (dev mode)"
|
||||
|
||||
# Send actual email
|
||||
msg = MIMEMultipart()
|
||||
msg['From'] = f"{settings.SMTP_FROM_NAME} <{settings.SMTP_FROM_EMAIL or settings.SMTP_USER}>"
|
||||
msg['To'] = email
|
||||
msg['Subject'] = subject
|
||||
msg.attach(MIMEText(body, 'plain', 'utf-8'))
|
||||
|
||||
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as server:
|
||||
server.starttls()
|
||||
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||
server.send_message(msg)
|
||||
|
||||
return True, "Verification code sent to your email"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to send email: {e}")
|
||||
return False, f"Failed to send email: {str(e)}"
|
||||
|
||||
|
||||
async def send_sms_verification(
|
||||
db: Session,
|
||||
phone: str,
|
||||
user_id: Optional[int] = None,
|
||||
language: str = "en"
|
||||
) -> Tuple[bool, str]:
|
||||
"""Send SMS verification code"""
|
||||
# Normalize phone number
|
||||
phone = phone.strip().replace(" ", "").replace("-", "")
|
||||
if not phone.startswith("+"):
|
||||
# Assume Mongolia if no country code
|
||||
if phone.startswith("9") and len(phone) == 8:
|
||||
phone = "+976" + phone
|
||||
|
||||
# Check rate limit (1 SMS per minute)
|
||||
recent = db.query(VerificationCode).filter(
|
||||
VerificationCode.phone == phone,
|
||||
VerificationCode.code_type == "phone",
|
||||
VerificationCode.created_at > datetime.utcnow() - timedelta(minutes=1)
|
||||
).first()
|
||||
|
||||
if recent:
|
||||
return False, "Please wait 1 minute before requesting another code."
|
||||
|
||||
# Create verification code
|
||||
verification = create_verification_code(
|
||||
db=db,
|
||||
code_type="phone",
|
||||
phone=phone,
|
||||
user_id=user_id
|
||||
)
|
||||
|
||||
# SMS messages by language
|
||||
messages = {
|
||||
"en": f"AutonetSellCar verification code: {verification.code}. Valid for {settings.VERIFICATION_CODE_EXPIRE_MINUTES} min.",
|
||||
"ko": f"AutonetSellCar 인증 코드: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES}분간 유효.",
|
||||
"mn": f"AutonetSellCar баталгаажуулах код: {verification.code}. {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин хүчинтэй.",
|
||||
"ru": f"Код подтверждения AutonetSellCar: {verification.code}. Действителен {settings.VERIFICATION_CODE_EXPIRE_MINUTES} мин."
|
||||
}
|
||||
|
||||
message = messages.get(language, messages["en"])
|
||||
|
||||
try:
|
||||
# Check if Twilio is configured
|
||||
if not settings.TWILIO_ACCOUNT_SID or not settings.TWILIO_AUTH_TOKEN:
|
||||
# Development mode - just log the code
|
||||
print(f"[DEV] SMS verification code for {phone}: {verification.code}")
|
||||
return True, "Verification code sent (dev mode)"
|
||||
|
||||
# Send actual SMS via Twilio
|
||||
from twilio.rest import Client
|
||||
|
||||
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
|
||||
client.messages.create(
|
||||
body=message,
|
||||
from_=settings.TWILIO_PHONE_NUMBER,
|
||||
to=phone
|
||||
)
|
||||
|
||||
return True, "Verification code sent to your phone"
|
||||
|
||||
except Exception as e:
|
||||
print(f"[ERROR] Failed to send SMS: {e}")
|
||||
return False, f"Failed to send SMS: {str(e)}"
|
||||
|
||||
|
||||
def mark_email_verified(db: Session, user: User) -> None:
|
||||
"""Mark user's email as verified"""
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_phone_verified(db: Session, user: User, phone: str) -> None:
|
||||
"""Mark user's phone as verified and update phone number"""
|
||||
user.phone = phone
|
||||
user.phone_verified = True
|
||||
user.phone_verified_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
|
||||
def is_email_verified(user: User) -> bool:
|
||||
"""Check if user's email is verified"""
|
||||
return user.email_verified
|
||||
|
||||
|
||||
def is_phone_verified(user: User) -> bool:
|
||||
"""Check if user's phone is verified"""
|
||||
return user.phone_verified
|
||||
299
backend/app/services/visitor_service.py
Normal file
299
backend/app/services/visitor_service.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Visitor Tracking Service
|
||||
- Tracks page visits with privacy-preserving IP hashing
|
||||
- Parses user agent for device/browser info
|
||||
- Geolocation using free ip-api.com service
|
||||
"""
|
||||
import hashlib
|
||||
import httpx
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
|
||||
from ..models.visitor import VisitorLog, VisitorDailyStats, VisitorSession
|
||||
|
||||
# IP Geolocation service (free, 45 req/min limit)
|
||||
IP_API_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,regionName,city"
|
||||
|
||||
# Cache for IP geolocation results (in-memory, simple)
|
||||
_geo_cache: Dict[str, Dict] = {}
|
||||
_geo_cache_expiry: Dict[str, datetime] = {}
|
||||
GEO_CACHE_TTL = timedelta(hours=24)
|
||||
|
||||
|
||||
def hash_ip(ip: str) -> str:
|
||||
"""Hash IP address for privacy"""
|
||||
return hashlib.sha256(ip.encode()).hexdigest()
|
||||
|
||||
|
||||
def hash_visitor(ip: str, user_agent: str) -> str:
|
||||
"""Create unique visitor hash from IP + User-Agent"""
|
||||
combined = f"{ip}:{user_agent}"
|
||||
return hashlib.sha256(combined.encode()).hexdigest()
|
||||
|
||||
|
||||
def parse_device_info(user_agent_string: str) -> Dict:
|
||||
"""Parse user agent string for device/browser info"""
|
||||
try:
|
||||
from user_agents import parse as parse_user_agent
|
||||
ua = parse_user_agent(user_agent_string)
|
||||
|
||||
# Determine device type
|
||||
if ua.is_mobile:
|
||||
device_type = "mobile"
|
||||
elif ua.is_tablet:
|
||||
device_type = "tablet"
|
||||
else:
|
||||
device_type = "desktop"
|
||||
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"browser": ua.browser.family,
|
||||
"browser_version": ua.browser.version_string,
|
||||
"os": ua.os.family,
|
||||
"os_version": ua.os.version_string,
|
||||
}
|
||||
except ImportError:
|
||||
# Fallback if user-agents not installed
|
||||
return {
|
||||
"device_type": "unknown",
|
||||
"browser": "unknown",
|
||||
"browser_version": "",
|
||||
"os": "unknown",
|
||||
"os_version": "",
|
||||
}
|
||||
|
||||
|
||||
async def get_geo_info(ip: str) -> Optional[Dict]:
|
||||
"""Get geographic info from IP address using free ip-api.com"""
|
||||
# Check cache first
|
||||
if ip in _geo_cache:
|
||||
if datetime.now() < _geo_cache_expiry.get(ip, datetime.min):
|
||||
return _geo_cache[ip]
|
||||
|
||||
# Skip private/local IPs
|
||||
if ip.startswith(('127.', '192.168.', '10.', '172.16.', '172.17.', '172.18.', '172.19.',
|
||||
'172.20.', '172.21.', '172.22.', '172.23.', '172.24.', '172.25.',
|
||||
'172.26.', '172.27.', '172.28.', '172.29.', '172.30.', '172.31.', 'localhost', '::1')):
|
||||
return {"country": "Local", "country_code": "LO", "region": "", "city": ""}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
IP_API_URL.format(ip=ip),
|
||||
timeout=5.0
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get("status") == "success":
|
||||
result = {
|
||||
"country": data.get("country", "Unknown"),
|
||||
"country_code": data.get("countryCode", ""),
|
||||
"region": data.get("regionName", ""),
|
||||
"city": data.get("city", ""),
|
||||
}
|
||||
# Cache the result
|
||||
_geo_cache[ip] = result
|
||||
_geo_cache_expiry[ip] = datetime.now() + GEO_CACHE_TTL
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"Geo lookup failed for {ip}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_referrer_domain(referrer: str) -> Optional[str]:
|
||||
"""Extract domain from referrer URL"""
|
||||
if not referrer:
|
||||
return None
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(referrer)
|
||||
return parsed.netloc or None
|
||||
except:
|
||||
return None
|
||||
|
||||
|
||||
async def log_visit(
|
||||
db: Session,
|
||||
ip: str,
|
||||
user_agent: str,
|
||||
page_path: str,
|
||||
page_title: Optional[str] = None,
|
||||
referrer: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[int] = None,
|
||||
utm_source: Optional[str] = None,
|
||||
utm_medium: Optional[str] = None,
|
||||
utm_campaign: Optional[str] = None,
|
||||
) -> VisitorLog:
|
||||
"""
|
||||
Log a page visit
|
||||
"""
|
||||
# Hash IP for privacy
|
||||
ip_hash = hash_ip(ip)
|
||||
visitor_hash = hash_visitor(ip, user_agent)
|
||||
|
||||
# Parse device info
|
||||
device_info = parse_device_info(user_agent)
|
||||
|
||||
# Get geo info (async)
|
||||
geo_info = await get_geo_info(ip) or {}
|
||||
|
||||
# Extract referrer domain
|
||||
referrer_domain = extract_referrer_domain(referrer)
|
||||
|
||||
# Create log entry
|
||||
log = VisitorLog(
|
||||
visitor_hash=visitor_hash,
|
||||
ip_hash=ip_hash,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
page_path=page_path,
|
||||
page_title=page_title,
|
||||
referrer=referrer,
|
||||
referrer_domain=referrer_domain,
|
||||
device_type=device_info["device_type"],
|
||||
browser=device_info["browser"],
|
||||
browser_version=device_info["browser_version"],
|
||||
os=device_info["os"],
|
||||
os_version=device_info["os_version"],
|
||||
country=geo_info.get("country"),
|
||||
country_code=geo_info.get("country_code"),
|
||||
city=geo_info.get("city"),
|
||||
region=geo_info.get("region"),
|
||||
utm_source=utm_source,
|
||||
utm_medium=utm_medium,
|
||||
utm_campaign=utm_campaign,
|
||||
)
|
||||
|
||||
db.add(log)
|
||||
|
||||
# Update or create session
|
||||
if session_id:
|
||||
session = db.query(VisitorSession).filter(
|
||||
VisitorSession.session_id == session_id
|
||||
).first()
|
||||
|
||||
if session:
|
||||
session.last_page = page_path
|
||||
session.page_count += 1
|
||||
session.last_activity_at = datetime.utcnow()
|
||||
if user_id and not session.user_id:
|
||||
session.user_id = user_id
|
||||
else:
|
||||
session = VisitorSession(
|
||||
session_id=session_id,
|
||||
visitor_hash=visitor_hash,
|
||||
user_id=user_id,
|
||||
first_page=page_path,
|
||||
last_page=page_path,
|
||||
device_type=device_info["device_type"],
|
||||
browser=device_info["browser"],
|
||||
country=geo_info.get("country"),
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
|
||||
return log
|
||||
|
||||
|
||||
def aggregate_daily_stats(db: Session, date_str: str) -> Optional[VisitorDailyStats]:
|
||||
"""
|
||||
Aggregate visitor stats for a given date (YYYY-MM-DD)
|
||||
Called by scheduled task
|
||||
"""
|
||||
# Query all visits for the date
|
||||
visits = db.query(VisitorLog).filter(
|
||||
func.date(VisitorLog.visited_at) == date_str
|
||||
).all()
|
||||
|
||||
if not visits:
|
||||
return None
|
||||
|
||||
total_visits = len(visits)
|
||||
unique_visitors = len(set(v.visitor_hash for v in visits))
|
||||
|
||||
# Device breakdown
|
||||
device_counts = {}
|
||||
for v in visits:
|
||||
device = v.device_type or "unknown"
|
||||
device_counts[device] = device_counts.get(device, 0) + 1
|
||||
|
||||
# Browser breakdown
|
||||
browser_counts = {}
|
||||
for v in visits:
|
||||
browser = v.browser or "unknown"
|
||||
browser_counts[browser] = browser_counts.get(browser, 0) + 1
|
||||
|
||||
# Country breakdown
|
||||
country_counts = {}
|
||||
for v in visits:
|
||||
country = v.country_code or "unknown"
|
||||
country_counts[country] = country_counts.get(country, 0) + 1
|
||||
|
||||
# Top pages
|
||||
page_counts = {}
|
||||
for v in visits:
|
||||
page_counts[v.page_path] = page_counts.get(v.page_path, 0) + 1
|
||||
top_pages = sorted(
|
||||
[{"path": k, "views": v} for k, v in page_counts.items()],
|
||||
key=lambda x: x["views"],
|
||||
reverse=True
|
||||
)[:20]
|
||||
|
||||
# Top referrers
|
||||
referrer_counts = {}
|
||||
for v in visits:
|
||||
if v.referrer_domain:
|
||||
referrer_counts[v.referrer_domain] = referrer_counts.get(v.referrer_domain, 0) + 1
|
||||
top_referrers = sorted(
|
||||
[{"domain": k, "visits": v} for k, v in referrer_counts.items()],
|
||||
key=lambda x: x["visits"],
|
||||
reverse=True
|
||||
)[:10]
|
||||
|
||||
# Create or update daily stats
|
||||
existing = db.query(VisitorDailyStats).filter(
|
||||
VisitorDailyStats.stat_date == date_str
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.total_visits = total_visits
|
||||
existing.unique_visitors = unique_visitors
|
||||
existing.device_breakdown = json.dumps(device_counts)
|
||||
existing.browser_breakdown = json.dumps(browser_counts)
|
||||
existing.country_breakdown = json.dumps(country_counts)
|
||||
existing.top_pages = json.dumps(top_pages)
|
||||
existing.top_referrers = json.dumps(top_referrers)
|
||||
stats = existing
|
||||
else:
|
||||
stats = VisitorDailyStats(
|
||||
stat_date=date_str,
|
||||
total_visits=total_visits,
|
||||
unique_visitors=unique_visitors,
|
||||
device_breakdown=json.dumps(device_counts),
|
||||
browser_breakdown=json.dumps(browser_counts),
|
||||
country_breakdown=json.dumps(country_counts),
|
||||
top_pages=json.dumps(top_pages),
|
||||
top_referrers=json.dumps(top_referrers),
|
||||
)
|
||||
db.add(stats)
|
||||
|
||||
db.commit()
|
||||
return stats
|
||||
|
||||
|
||||
def cleanup_old_visitor_logs(db: Session, days: int = 90) -> int:
|
||||
"""Delete visitor logs older than specified days"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
deleted = db.query(VisitorLog).filter(
|
||||
VisitorLog.visited_at < cutoff
|
||||
).delete()
|
||||
db.commit()
|
||||
return deleted
|
||||
Reference in New Issue
Block a user