""" Carmodoo API - 실제 카모두 API 연동 """ import asyncio from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse from sqlalchemy.orm import Session from typing import Optional, List from pydantic import BaseModel import httpx import json import re import os from pathlib import Path from lxml import etree from lxml import html as lxml_html from ..database import get_db from ..models import Car, CarMaker, CarModel, CarImage, CarOption, CarPerformanceCheck, CarSpecification, PerformanceCheckView, CarView, User, VehicleRequest, RequestVehicle from ..models.settings import SystemSettings from ..api.auth import get_current_user, get_current_user_optional, get_current_admin_user from ..services.cache_service import CacheService from ..services.pdf_service import capture_performance_check_pdf, get_pdf_full_path, get_pdf_failures, clear_pdf_failure from ..services.spec_service import get_specifications_from_carmodoo, spec_to_dict from ..services.sensitive_filter import detect_sensitive_info, mask_sensitive_info, highlight_sensitive_info, has_sensitive_info, get_sensitivity_summary from ..services.translation_service import translate_dealer_description, get_translation_service router = APIRouter(prefix="/carmodoo", tags=["carmodoo"]) # Carmodoo 설정 CARMODOO_BASE_URL = "https://dealer.carmodoo.com" CARMODOO_USER_ID = os.getenv("CARMODOO_USER_ID", "01033315258") CARMODOO_PASSWORD = os.getenv("CARMODOO_PASSWORD", "alskfl@1122") # JSON 데이터 로드 DATA_PATH = Path(__file__).parent.parent / "data" / "carmodoo_makers_models.json" def load_makers_models(): """JSON 파일에서 제조사/모델 데이터 로드""" try: with open(DATA_PATH, 'r', encoding='utf-8') as f: return json.load(f) except Exception as e: print(f"Error loading makers/models: {e}") return {"makers": [], "models": {}} CARMODOO_DATA = load_makers_models() # Response schemas class CarmodooMaker(BaseModel): code: str name: str class CarmodooModel(BaseModel): code: str name: str class CarmodooSearchResultItem(BaseModel): id: str car_name: str maker_name: Optional[str] = None model_name: Optional[str] = None year: Optional[int] = None mileage: Optional[int] = None original_price: Optional[int] = None korea_margin: Optional[int] = None mongolia_margin: Optional[int] = None final_price: Optional[int] = None fuel: Optional[str] = None transmission: Optional[str] = None color: Optional[str] = None displacement: Optional[int] = None main_image: Optional[str] = None image_count: int = 20 check_num: Optional[str] = None # 성능점검번호 (Performance check number) car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용) class VehicleRequestSearchResponse(BaseModel): total: int cars: List[CarmodooSearchResultItem] class CarmodooClient: """카모두 API 클라이언트""" def __init__(self): self.cookies = {} self.is_logged_in = False self.headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', 'Accept-Language': 'ko-KR,ko;q=0.9', } def _decode_response(self, content: bytes) -> str: """EUC-KR 응답 디코딩""" try: return content.decode('euc-kr') except UnicodeDecodeError: try: return content.decode('utf-8') except UnicodeDecodeError: return content.decode('latin-1') def _clean_xml_bytes(self, content: bytes) -> bytes: """XML 정리""" try: text = content.decode('euc-kr') except UnicodeDecodeError: try: text = content.decode('utf-8') except UnicodeDecodeError: text = content.decode('latin-1') text = re.sub(r'^[0-9a-fA-F]+\r?\n', '', text, flags=re.MULTILINE) text = text.strip() if not text.startswith(' 0: text = text[xml_start:] text = re.sub(r'encoding=["\'][^"\']*["\']', 'encoding="UTF-8"', text) return text.encode('utf-8') async def login(self) -> bool: """카모두 로그인""" url = f"{CARMODOO_BASE_URL}/member/login_ok.html" data = { 'prevURL': '', 'id': CARMODOO_USER_ID, 'passwd': CARMODOO_PASSWORD, 'idSave': 'Y', 'button': 'LOGIN' } headers = { **self.headers, 'Content-Type': 'application/x-www-form-urlencoded', 'Origin': CARMODOO_BASE_URL, 'Referer': f'{CARMODOO_BASE_URL}/member/login_v2.html', } try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.post( url, data=data, headers=headers, follow_redirects=False ) text = self._decode_response(response.content) if 'goMain' in text or 'PHPSESSID' in str(response.cookies): self.cookies = dict(response.cookies) self.is_logged_in = True print("Carmodoo login successful") return True else: print("Carmodoo login failed") return False except Exception as e: print(f"Carmodoo login error: {e}") return False async def search_cars( self, maker_code: Optional[str] = None, model_code: Optional[str] = None, 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, page: int = 1, page_size: int = 50 ) -> List[dict]: """차량 검색 (POST 방식 AJAX API 호출)""" print(f"[search_cars] Called with maker={maker_code}, model={model_code}, is_logged_in={self.is_logged_in}") if not self.is_logged_in: print("[search_cars] Not logged in, attempting login...") login_result = await self.login() print(f"[search_cars] Login result: {login_result}") try: async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: # AJAX 엔드포인트 - POST 방식으로 호출 ajax_url = f"{CARMODOO_BASE_URL}/car/_inc_carListPhoto.html" # POST form data form_data = { 'sf_page': str(page - 1), # 0-based 'sf_pageSize': str(page_size), 'cho': '', # 국산/수입 구분 (빈값이면 전체 - 수입차 검색을 위해 필수) } if maker_code: form_data['c_bmNo'] = maker_code # 제조사 (Brand/Maker) if model_code: form_data['c_boInitNo'] = model_code # 모델 이니셜 (Model Init - c_boInitNo가 정확한 파라미터) if year_min: form_data['c_year1'] = str(year_min) if year_max: form_data['c_year2'] = str(year_max) if mileage_min: form_data['c_mileage1'] = str(mileage_min) if mileage_max: form_data['c_mileage2'] = str(mileage_max) if price_min: form_data['c_price1'] = str(price_min) if price_max: form_data['c_price2'] = str(price_max) if fuel: # 연료 타입 매핑 (프론트엔드 값 -> 카모두 API 값) # 카모두 API는 dFuel[] 파라미터 (체크박스 배열)를 사용함 fuel_map = { '가솔린': '휘발유', '디젤': '경유', 'LPG': 'LPG', '하이브리드': '하이브리드', '전기': '전기', 'CNG': 'CNG', } if fuel in fuel_map: form_data['dFuel[]'] = fuel_map[fuel] if transmission: # 변속기 타입 매핑 (프론트엔드 값 -> 카모두 API 값) # 카모두 API는 dGA 파라미터에 한글 텍스트 값을 사용함 trans_map = { '자동': '오토', '수동': '수동', '세미오토': '세미오토', 'CVT': 'CVT', } if transmission in trans_map: form_data['dGA'] = trans_map[transmission] ajax_headers = { **self.headers, 'Accept': 'text/html, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', 'Content-Type': 'application/x-www-form-urlencoded; charset=EUC-KR', 'Origin': CARMODOO_BASE_URL, 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', } # EUC-KR 인코딩으로 form data 생성 (한글 필터 지원) import urllib.parse encoded_parts = [] for key, value in form_data.items(): # 한글 값을 EUC-KR로 인코딩 후 URL 인코딩 if isinstance(value, str): try: encoded_value = urllib.parse.quote(value.encode('euc-kr'), safe='') except UnicodeEncodeError: encoded_value = urllib.parse.quote(value, safe='') else: encoded_value = str(value) encoded_parts.append(f"{key}={encoded_value}") encoded_body = '&'.join(encoded_parts).encode('ascii') response = await client.post( ajax_url, content=encoded_body, headers=ajax_headers, ) print(f"[search_cars] Response status: {response.status_code}, content length: {len(response.content)}") if response.status_code == 200: html = self._decode_response(response.content) print(f"[search_cars] Decoded HTML length: {len(html)}") # 전체 개수 파싱 (totalCntUpdate(N) 패턴) total_count = 0 total_match = re.search(r'totalCntUpdate\((\d+)\)', html) if total_match: total_count = int(total_match.group(1)) print(f"[search_cars] Total count from API: {total_count}") cars = self._parse_car_list_html(html) print(f"[search_cars] Parsed {len(cars)} cars from HTML") # 전체 개수를 메타데이터로 저장 if cars: cars[0]["_total_count"] = total_count # check_num이 없는 차량에 대해 상세 페이지에서 재시도 cars_without_check = [c for c in cars if not c.get("check_num")] if cars_without_check: print(f"[Carmodoo] {len(cars_without_check)}개 차량의 성능점검번호를 상세 페이지에서 가져옵니다...") retry_count = len(cars_without_check) retry_success = 0 for car in cars_without_check: try: await asyncio.sleep(0.3) # 서버 부하 방지 check_num = await self.get_car_check_num(car["car_no"], car.get("car_key", "")) if check_num: car["check_num"] = check_num retry_success += 1 print(f" - {car['car_no']}: check_num={check_num} (재시도 성공)") else: print(f" - {car['car_no']}: check_num 없음 (재시도 실패)") except Exception as e: print(f" - {car['car_no']}: 재시도 오류 - {e}") # 재시도 통계를 첫 번째 차량에 포함 if cars: cars[0]["_retry_count"] = retry_count cars[0]["_retry_success"] = retry_success return cars except Exception as e: print(f"Search error: {e}") return [] async def search_cars_by_year_segment( self, maker_code: str, model_code: str, year_start: int = 2010, year_end: int = None ) -> List[dict]: """연도별 분할 검색으로 더 많은 차량 수집 카모두 API는 한 번에 최대 50대만 반환하므로, 연도별로 나누어 검색하여 더 많은 차량을 수집합니다. """ from datetime import datetime if not self.is_logged_in: await self.login() if year_end is None: year_end = datetime.now().year + 1 all_cars = [] seen_car_ids = set() for year in range(year_end, year_start - 1, -1): try: cars = await self.search_cars( maker_code=maker_code, model_code=model_code, year_min=year, year_max=year, page=1, page_size=50 ) # 중복 제거 for car in cars: car_id = car.get("car_no") if car_id and car_id not in seen_car_ids: seen_car_ids.add(car_id) all_cars.append(car) # 카모두 서버 부하 방지 await asyncio.sleep(0.3) except Exception as e: print(f"Error fetching year {year}: {e}") continue return all_cars def _parse_car_list_html(self, html: str) -> List[dict]: """HTML에서 차량 목록 파싱""" cars = [] try: tree = lxml_html.fromstring(html) # 각 차량 행 찾기 (tr id="trCtl_XXXXXXX") car_rows = tree.xpath('//tr[starts-with(@id, "trCtl_")]') for row in car_rows: try: # 차량 번호 추출 tr_id = row.get('id', '') car_no = tr_id.replace('trCtl_', '') # 차량번호판 car_plate = "" plate_elem = row.xpath('.//td[@class="center"]/strong/text()') if plate_elem: car_plate = plate_elem[0].strip() # 제조사/모델명 maker_name = "" model_name = "" car_name_full = "" maker_elem = row.xpath('.//span[@class="maker"]/text()') if maker_elem: maker_name = maker_elem[0].strip().strip('[]') model_elem = row.xpath('.//span[@class="model"]/text()') if model_elem: model_name = model_elem[0].strip() # 전체 차량명 (링크 텍스트) car_name_elem = row.xpath('.//td[@class="carName"]/a/text()') if car_name_elem: car_name_parts = [t.strip() for t in car_name_elem if t.strip()] if car_name_parts: car_name_full = ' '.join(car_name_parts) if not car_name_full and maker_name: car_name_full = f"{maker_name} {model_name}".strip() # 미션 transmission = "" td_centers = row.xpath('.//td[@class="center"]/text()') for t in td_centers: t = t.strip() if t in ['오토', '수동', '자동']: transmission = t break # 연식 year = None year_elem = row.xpath('.//span[@class="year_2"]/text()') if year_elem: try: year = int(year_elem[0].strip()) except: pass # 연료 fuel = "" for t in td_centers: t = t.strip() if t in ['휘발유', '경유', 'LPG', '전기', '하이브리드', '가솔린', '디젤']: fuel = t break # 주행거리 mileage = None mileage_elem = row.xpath('.//td[@class="right"]/text()') if mileage_elem: mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '') try: mileage = int(mileage_str) except: pass # 색상 color = "" # 색상은 마지막 center 셀 중 하나 for t in td_centers: t = t.strip() if t in ['흰색', '검정', '은색', '회색', '파랑', '빨강', '갈색', '녹색', '기타', '노랑', '주황']: color = t # 배기량 (cc) - td_centers에서 숫자만 있는 값 확인 displacement = None for t in td_centers: t = t.strip() # 배기량은 보통 숫자로만 이루어져 있음 (1999, 2497 등) if t.isdigit() and 500 <= int(t) <= 10000: displacement = int(t) break # 가격 (만원) price = None price_elem = row.xpath('.//td[@class="price"]/text()') if price_elem: try: price_str = price_elem[0].strip().replace(',', '') price = int(price_str) * 10000 # 만원 -> 원 except: pass # 이미지 URL 생성 car_no_padded = car_no.zfill(9) main_image = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_0.jpg" # 성능점검번호 및 key 추출 시도 check_num = "" car_key = "" # 전체 row HTML에서 dealerCarviewPopup('...') 패턴으로 key와 checkNum 추출 row_html = etree.tostring(row, encoding='unicode') key_match = re.search(r"dealerCarviewPopup\s*\(\s*['\"]([^'\"]+)['\"]", row_html) if key_match: car_key = key_match.group(1) # checkNum 추출 (주석 포함) check_match = re.search(r'checkNum=(\d+)', row_html) if check_match: check_num = check_match.group(1) # data 속성에서 checkNum 추출 (대안) if not check_num: check_num_attr = row.get('data-checknum', '') if not check_num_attr: check_elem = row.xpath('.//*[@data-checknum]/@data-checknum') if check_elem: check_num_attr = check_elem[0] check_num = check_num_attr # 성능점검 링크에서 checkNum 추출 (대안) if not check_num: check_link = row.xpath('.//a[contains(@href, "checkNum")]/@href') if check_link: check_match = re.search(r'checkNum=(\d+)', check_link[0]) if check_match: check_num = check_match.group(1) if car_no: cars.append({ "car_no": car_no, "car_plate": car_plate, "car_name": car_name_full, "maker_name": maker_name, "model_name": model_name, "year": year, "mileage": mileage, "price": price, "fuel": fuel, "transmission": transmission, "color": color, "displacement": displacement, "main_image": main_image, "car_key": car_key, "check_num": check_num, }) except Exception as e: print(f"Error parsing car row: {e}") continue except Exception as e: print(f"Error parsing HTML: {e}") return cars async def get_grades(self, maker_code: str, model_code: str) -> List[dict]: """등급 목록 가져오기 (카모두 API에서 c_boNo)""" if not self.is_logged_in: await self.login() try: async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: # 카모두 API - 등급 목록 조회 # mode=getCarModel: 제조사(company)와 모델이니셜(c_nameInit)을 받아서 등급 목록 반환 url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDBCode.html" params = { 'mode': 'getCarModel', 'ctl': 'car', 'cho': '', # 국산/수입 구분 (빈값이면 전체) 'company': maker_code, 'c_nameInit': model_code, 'selected': '' } headers = { **self.headers, 'Accept': 'application/xml, text/xml, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', } response = await client.get(url, params=params, headers=headers) if response.status_code == 200: xml_content = self._clean_xml_bytes(response.content) return self._parse_grades_xml(xml_content) except Exception as e: print(f"Get grades error: {e}") return [] def _parse_grades_xml(self, xml_content: bytes) -> List[dict]: """등급 XML 파싱""" grades = [] try: root = etree.fromstring(xml_content) items = root.findall('.//item') for item in items: key_elem = item.find('key') name_elem = item.find('name') if key_elem is not None and name_elem is not None: grades.append({ 'code': key_elem.text.strip() if key_elem.text else '', 'name': name_elem.text.strip() if name_elem.text else '' }) except Exception as e: print(f"Parse grades XML error: {e}") return grades async def get_car_check_num(self, car_no: str, car_key: str = "") -> str: """차량 상세 정보에서 성능점검번호(checkNum) 가져오기""" if not self.is_logged_in: await self.login() try: async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: url = f"{CARMODOO_BASE_URL}/common/ajax/AutoDB.html" headers = { **self.headers, 'Accept': 'application/xml, text/xml, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', 'Referer': f'{CARMODOO_BASE_URL}/car/carListPhoto.html', } # key가 있으면 key로 조회 if car_key: params = {"mode": "view", "key": car_key} response = await client.get(url, params=params, headers=headers) print(f" AutoDB with key: status={response.status_code}, length={len(response.content)}") if response.status_code == 200 and len(response.content) > 100: try: xml_content = self._clean_xml_bytes(response.content) root = etree.fromstring(xml_content) check_num_elem = root.find('.//c_checkNum') if check_num_elem is not None and check_num_elem.text: return check_num_elem.text.strip() except Exception as e: print(f" XML parse error with key: {e}") # key가 없으면 여러 모드 시도 modes_to_try = [ {"mode": "view", "c_carNo": car_no}, {"mode": "viewNo", "no": car_no}, ] for params in modes_to_try: response = await client.get(url, params=params, headers=headers) if response.status_code == 200 and len(response.content) > 100: try: xml_content = self._clean_xml_bytes(response.content) root = etree.fromstring(xml_content) check_num_elem = root.find('.//c_checkNum') if check_num_elem is not None and check_num_elem.text: return check_num_elem.text.strip() except Exception as e: pass except Exception as e: print(f"Get car check num error: {e}") return "" async def get_car_detail(self, car_no: str, car_key: str = "") -> dict: """차량 상세 정보 가져오기 (딜러 설명 포함) Args: car_no: 차량 번호 car_key: 암호화된 차량 키 (dealerCarviewPopup용) Returns: dict with dealer_description and other details """ if not self.is_logged_in: await self.login() result = { "car_no": car_no, "dealer_description": "", "found": False } try: async with httpx.AsyncClient(timeout=30.0, cookies=self.cookies) as client: # car_key가 있으면 dealerCarView.html 사용 if car_key: url = f"{CARMODOO_BASE_URL}/car/dealerCarView.html" params = {"key": car_key, "tabStart": "1"} response = await client.get(url, params=params, headers=self.headers) if response.status_code == 200: # EUC-KR로 디코딩 try: html = response.content.decode('euc-kr') except: html = response.content.decode('utf-8', errors='replace') # 상세설명 추출 - carViewMemoWrap 내의 memo div #

상세설명

...
memo_match = re.search( r']*class=["\'][^"\']*carViewMemoWrap[^"\']*["\'][^>]*>.*?' r'

\s*상세설명\s*

\s*]*class=["\'][^"\']*memo[^"\']*["\'][^>]*>(.*?)', html, re.DOTALL | re.IGNORECASE ) if memo_match: desc_html = memo_match.group(1) # HTML 태그 제거 (br 태그는 줄바꿈으로) desc = re.sub(r'', '\n', desc_html) desc = re.sub(r'<[^>]+>', '', desc) desc = desc.strip() if desc and len(desc) > 5: result["dealer_description"] = desc result["found"] = True # raw_html 저장 (디버깅용, 크기 제한) result["raw_html"] = html[:10000] if len(html) > 10000 else html except Exception as e: print(f"Get car detail error: {e}") import traceback traceback.print_exc() return result async def get_performance_check(self, car_no: str, car_key: str = "", check_num: str = "") -> dict: """성능점검표 가져오기 - ck.carmodoo.com에서 조회 Args: car_no: 차량 번호 car_key: 암호화된 key (검색 결과에서 추출) check_num: 성능점검번호 (이미 알고 있는 경우) """ if not self.is_logged_in: await self.login() result = { "car_no": car_no, "found": False, "data": {}, "raw_html": "", "check_num": "" } try: # 1. checkNum이 없으면 차량 상세에서 가져오기 if not check_num: check_num = await self.get_car_check_num(car_no, car_key) if not check_num: print(f"No checkNum found for car_no: {car_no}") return result result["check_num"] = check_num print(f"Found checkNum: {check_num}") # 2. 성능점검표 페이지 가져오기 async with httpx.AsyncClient(timeout=30.0) as client: perf_url = f"https://ck.carmodoo.com/carCheck/carmodooPrint.do" params = {"print": "0", "checkNum": check_num} headers = { 'User-Agent': self.headers['User-Agent'], 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'ko-KR,ko;q=0.9', } response = await client.get(perf_url, params=params, headers=headers) if response.status_code == 200: # UTF-8로 디코딩 (ck.carmodoo.com은 UTF-8 사용) try: html = response.content.decode('utf-8') except: html = response.content.decode('euc-kr', errors='ignore') result["raw_html"] = html result["data"] = self._parse_performance_check_html(html) result["data"]["check_number"] = check_num # checkNum 저장 result["found"] = bool(result["data"]) except Exception as e: print(f"Get performance check error: {e}") import traceback traceback.print_exc() return result def _parse_performance_check_html(self, html: str) -> dict: """성능점검표 HTML 파싱""" data = {} try: tree = lxml_html.fromstring(html) # 성능점검번호 check_num_elem = tree.xpath('//th[contains(text(), "점검번호")]/following-sibling::td/text()') if check_num_elem: data["check_number"] = check_num_elem[0].strip() # 점검일자 check_date_elem = tree.xpath('//th[contains(text(), "점검일")]/following-sibling::td/text()') if check_date_elem: data["check_date"] = check_date_elem[0].strip() # 유효기간 valid_until_elem = tree.xpath('//th[contains(text(), "유효기간")]/following-sibling::td/text()') if valid_until_elem: data["valid_until"] = valid_until_elem[0].strip() # 차량번호 (자동차등록번호로 표시됨) car_num_elem = tree.xpath('//th[contains(text(), "자동차등록번호")]/following-sibling::td/text()') if not car_num_elem: # Fallback to old pattern car_num_elem = tree.xpath('//th[contains(text(), "차량번호")]/following-sibling::td/text()') if car_num_elem: data["car_number"] = car_num_elem[0].strip() # 최초등록일 first_reg_elem = tree.xpath('//th[contains(text(), "최초등록")]/following-sibling::td/text()') if first_reg_elem: data["first_registration"] = first_reg_elem[0].strip() # 주행거리 mileage_elem = tree.xpath('//th[contains(text(), "주행거리")]/following-sibling::td/text()') if mileage_elem: mileage_str = mileage_elem[0].strip().replace(',', '').replace('km', '').replace(' ', '') try: data["mileage"] = int(mileage_str) except: data["mileage_text"] = mileage_elem[0].strip() # 주행거리 상태 (정상/조작의심/교환됨) mileage_status_elem = tree.xpath('//th[contains(text(), "계기상태")]/following-sibling::td/text()') if mileage_status_elem: data["mileage_status"] = mileage_status_elem[0].strip() # 압류/저당 seize_elem = tree.xpath('//th[contains(text(), "압류")]/following-sibling::td/text()') if seize_elem: seize_str = seize_elem[0].strip() try: data["seize_count"] = int(re.search(r'(\d+)', seize_str).group(1)) if re.search(r'(\d+)', seize_str) else 0 except: data["seize_count"] = 0 collateral_elem = tree.xpath('//th[contains(text(), "저당")]/following-sibling::td/text()') if collateral_elem: collateral_str = collateral_elem[0].strip() try: data["collateral_count"] = int(re.search(r'(\d+)', collateral_str).group(1)) if re.search(r'(\d+)', collateral_str) else 0 except: data["collateral_count"] = 0 # 침수/화재/전손 - 체크박스 상태로 확인 # bc_41_1 = 침수, bc_41_2 = 화재 flood_checkbox = tree.xpath('//input[@id="bc_41_1"]/@checked') fire_checkbox = tree.xpath('//input[@id="bc_41_2"]/@checked') # 전손은 특별이력 섹션의 "있음" 체크박스와 함께 확인 special_history_yes = tree.xpath('//input[@id="bc_4_2"]/@checked') data["is_flood_damaged"] = len(flood_checkbox) > 0 data["is_fire_damaged"] = len(fire_checkbox) > 0 # 전손은 별도 체크박스가 없으므로 텍스트로 확인 (fallback) total_loss_text = tree.xpath('//th[contains(text(), "특별이력")]/following-sibling::td//text()') if total_loss_text: history_text = ' '.join([t.strip() for t in total_loss_text]) data["is_total_loss"] = "전손" in history_text and ("유" in history_text or "있음" in history_text) else: data["is_total_loss"] = False # 용도이력 usage_elem = tree.xpath('//th[contains(text(), "용도이력")]/following-sibling::td/text()') if usage_elem: data["usage_history"] = usage_elem[0].strip() # 렌트이력 rental_elem = tree.xpath('//th[contains(text(), "렌트")]/following-sibling::td//text()') if rental_elem: rental_text = ' '.join([t.strip() for t in rental_elem]).lower() data["is_rental_used"] = "유" in rental_text or "있음" in rental_text # 주요장치 상태 device_status_map = { "원동기": "engine_status", "변속기": "transmission_status", "동력전달": "power_delivery_status", "조향장치": "steering_status", "제동장치": "brake_status", "전기장치": "electrical_status", "연료장치": "fuel_system_status", } for korean, english in device_status_map.items(): elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()') if elem: data[english] = elem[0].strip() # 외판 부위 상태 (사고이력) body_parts_map = { "후드": "hood", "프론트휀더(좌)": "front_fender_left", "프론트휀더(우)": "front_fender_right", "프론트도어(좌)": "front_door_left", "프론트도어(우)": "front_door_right", "리어도어(좌)": "rear_door_left", "리어도어(우)": "rear_door_right", "트렁크리드": "trunk_lid", "라디에이터서포트": "radiator_support", "루프패널": "roof_panel", "쿼터패널(좌)": "quarter_panel_left", "쿼터패널(우)": "quarter_panel_right", "사이드실패널(좌)": "side_sill_left", "사이드실패널(우)": "side_sill_right", } # 주요골격 부위 frame_parts_map = { "프론트패널": "front_panel", "크로스멤버": "cross_member", "인사이드패널(좌)": "inside_panel_left", "인사이드패널(우)": "inside_panel_right", "사이드멤버(좌)": "side_member_left", "사이드멤버(우)": "side_member_right", "휠하우스(좌)": "wheel_house_left", "휠하우스(우)": "wheel_house_right", "대쉬패널": "dash_panel", "플로어패널": "floor_panel", "트렁크플로어": "trunk_floor", "리어패널": "rear_panel", "필러A(좌)": "pillar_a_left", "필러A(우)": "pillar_a_right", "필러B(좌)": "pillar_b_left", "필러B(우)": "pillar_b_right", "필러C(좌)": "pillar_c_left", "필러C(우)": "pillar_c_right", "패키지트레이": "package_tray", } # 모든 부위 파싱 all_parts = {**body_parts_map, **frame_parts_map} accident_history = {} for korean, english in all_parts.items(): elem = tree.xpath(f'//th[contains(text(), "{korean}")]/following-sibling::td/text()') if elem: status = elem[0].strip() data[english] = status if status and status not in ["없음", "무", "-", ""]: accident_history[english] = status if accident_history: data["accident_history"] = accident_history # 타이어 상태 tire_map = { "전좌": "tire_front_left", "전우": "tire_front_right", "후좌": "tire_rear_left", "후우": "tire_rear_right", } for korean, english in tire_map.items(): elem = tree.xpath(f'//th[contains(text(), "타이어")]/following-sibling::td//text()[contains(., "{korean}")]') if elem: # 타이어 상태 추출 로직 data[english] = "확인필요" # 성능점검표 이미지 URL img_elem = tree.xpath('//img[contains(@src, "prfcheck") or contains(@src, "performance")]/@src') if img_elem: img_url = img_elem[0] if not img_url.startswith('http'): img_url = f"{CARMODOO_BASE_URL}{img_url}" data["report_image_url"] = img_url except Exception as e: print(f"Parse performance check HTML error: {e}") import traceback traceback.print_exc() return data # 싱글톤 클라이언트 carmodoo_client = CarmodooClient() @router.get("/makers") def get_makers(): """제조사 목록 (실제 카모두 데이터)""" return [CarmodooMaker(**m) for m in CARMODOO_DATA.get("makers", [])] @router.get("/models/{maker_code}") def get_models(maker_code: str): """모델 목록 (실제 카모두 데이터)""" models = CARMODOO_DATA.get("models", {}).get(maker_code, []) return [CarmodooModel(**m) for m in models] @router.get("/grades/{maker_code}/{model_code}") async def get_grades(maker_code: str, model_code: str): """등급 목록 (카모두에서 동적 조회)""" grades = await carmodoo_client.get_grades(maker_code, model_code) if not grades: # 빈 목록 반환 (등급 선택 없이 검색 가능) return [] return grades class AdminSearchResultItem(BaseModel): id: str car_name: str maker_code: Optional[str] = None maker_name: Optional[str] = None model_code: Optional[str] = None model_name: Optional[str] = None car_type: Optional[str] = None car_type_name: Optional[str] = None grade: Optional[str] = None grade_name: Optional[str] = None year: Optional[int] = None month: Optional[int] = None mileage: Optional[int] = None price: Optional[int] = None fuel: Optional[str] = None transmission: Optional[str] = None color: Optional[str] = None displacement: Optional[int] = None car_number: Optional[str] = None main_image: Optional[str] = None images: Optional[List[str]] = None options: Optional[List[str]] = None dealer_name: Optional[str] = None shop_name: Optional[str] = None seize_count: Optional[int] = None collateral_count: Optional[int] = None check_num: Optional[str] = None # 성능점검번호 car_key: Optional[str] = None # 암호화된 차량 키 class AdminSearchResponse(BaseModel): total: int cars: List[AdminSearchResultItem] check_num_retried: int = 0 # 성능점검번호 재시도한 차량 수 check_num_retry_success: int = 0 # 재시도 성공 수 @router.get("/search", response_model=AdminSearchResponse) async def admin_carmodoo_search( maker_code: Optional[str] = None, model_code: Optional[str] = None, car_type: Optional[str] = None, grade: Optional[str] = None, year_min: Optional[int] = None, year_max: Optional[int] = None, price_min: Optional[int] = None, price_max: Optional[int] = None, mileage_max: Optional[int] = None, fuel: Optional[str] = None, displacement_min: Optional[int] = None, displacement_max: Optional[int] = None, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=50), current_user = Depends(get_current_admin_user), ): """ 관리자용 카모두 차량 검색 - 직접 카모두 API를 호출하여 검색 - 배기량 필터는 결과에서 클라이언트 사이드 필터링 """ # Debug logging print(f"[Admin Search] Starting search with maker={maker_code}, model={model_code}") print(f"[Admin Search] Client state: is_logged_in={carmodoo_client.is_logged_in}, cookies={bool(carmodoo_client.cookies)}") # 카모두 API 호출 cars = await carmodoo_client.search_cars( maker_code=maker_code, model_code=model_code, year_min=year_min, year_max=year_max, mileage_max=mileage_max, price_min=price_min, price_max=price_max, fuel=fuel, page=page, page_size=page_size ) # Debug logging print(f"[Admin Search] Search returned {len(cars)} cars") print(f"[Admin Search] Client state after: is_logged_in={carmodoo_client.is_logged_in}") # 배기량 필터링 (카모두 API에서 지원하지 않는 경우) if displacement_min or displacement_max: filtered_cars = [] for car in cars: car_displacement = car.get("displacement") or 0 if displacement_min and car_displacement < displacement_min: continue if displacement_max and car_displacement > displacement_max: continue filtered_cars.append(car) cars = filtered_cars # 제조사/모델 이름 조회 maker_name = "" model_name = "" if maker_code: for m in CARMODOO_DATA.get("makers", []): if m["code"] == maker_code: maker_name = m["name"] break if maker_code and model_code: for m in CARMODOO_DATA.get("models", {}).get(maker_code, []): if m["code"] == model_code: model_name = m["name"] break # 결과 변환 result_cars = [] for car in cars: result_cars.append(AdminSearchResultItem( id=car.get("car_no", ""), car_name=car.get("car_name", ""), maker_code=maker_code, maker_name=car.get("maker_name", maker_name), model_code=model_code, model_name=car.get("model_name", model_name), year=car.get("year"), month=car.get("month"), mileage=car.get("mileage"), price=car.get("price") or car.get("original_price"), fuel=car.get("fuel"), transmission=car.get("transmission"), color=car.get("color"), displacement=car.get("displacement"), car_number=car.get("car_number"), main_image=car.get("main_image"), seize_count=car.get("seize_count", 0), collateral_count=car.get("collateral_count", 0), check_num=car.get("check_num"), # 성능점검번호 car_key=car.get("car_key"), # 암호화된 차량 키 )) # 메타데이터 추출 total_count = cars[0].get("_total_count", len(result_cars)) if cars else len(result_cars) retry_count = cars[0].get("_retry_count", 0) if cars else 0 retry_success = cars[0].get("_retry_success", 0) if cars else 0 print(f"[Admin Search] Returning total={total_count}, cars in page={len(result_cars)}") return AdminSearchResponse( total=total_count, cars=result_cars, check_num_retried=retry_count, check_num_retry_success=retry_success ) @router.get("/request-search", response_model=VehicleRequestSearchResponse) async def vehicle_request_search( maker_code: str = Query(..., description="제조사 코드 (필수)"), model_code: str = Query(..., description="모델 코드 (필수)"), year_min: int = Query(..., description="시작 연도 (필수)"), year_max: int = Query(..., description="종료 연도 (필수)"), grade: Optional[str] = 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, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: Session = Depends(get_db), current_user = Depends(get_current_user_optional), ): """ 차량 요청 검색 - 캐시 우선 조회 1. 캐시에서 maker+model 전체 데이터 조회 2. 캐시 MISS 시 카모두에서 전체 수집 후 캐시 저장 3. 로컬에서 year, mileage, price, fuel, transmission 필터링 4. 마진 계산 후 페이징하여 반환 """ # 마진율 (관리자 설정에서 가져오기) system_settings = db.query(SystemSettings).first() if system_settings: KOREA_MARGIN_RATE = system_settings.korea_margin_percent / 100 MONGOLIA_MARGIN_RATE = system_settings.mongolia_margin_percent / 100 else: # 기본값 (설정이 없는 경우) KOREA_MARGIN_RATE = 0.05 MONGOLIA_MARGIN_RATE = 0.05 # 제조사/모델 이름 조회 maker_name = "" model_name = "" for m in CARMODOO_DATA.get("makers", []): if m["code"] == maker_code: maker_name = m["name"] break for m in CARMODOO_DATA.get("models", {}).get(maker_code, []): if m["code"] == model_code: model_name = m["name"] break # 캐시 서비스 생성 cache_service = CacheService(db, carmodoo_client) cache_key = cache_service.get_cache_key(maker_code, model_code) # 1. 캐시 조회 cache = cache_service.get_cache(cache_key) if cache: # 캐시 HIT - 로컬에서 필터링 cars = cache_service.get_cars_from_cache(cache) else: # 캐시 MISS - 카모두에서 전체 수집 cars = await cache_service.fetch_all_cars_for_cache( maker_code=maker_code, model_code=model_code, maker_name=maker_name, model_name=model_name ) # 캐시 저장 if cars: cache_service.save_cache( cache_key=cache_key, maker_code=maker_code, maker_name=maker_name, model_code=model_code, model_name=model_name, cars=cars ) # 2. 로컬 필터링 filtered_cars = cache_service.filter_cars( cars=cars, year_min=year_min, year_max=year_max, mileage_min=mileage_min, mileage_max=mileage_max, price_min=price_min * 10000 if price_min else None, # 만원 -> 원 price_max=price_max * 10000 if price_max else None, fuel=fuel, transmission=transmission, displacement_min=displacement_min, displacement_max=displacement_max ) # 3. 페이징 paginated_cars, total = cache_service.paginate_cars( filtered_cars, page=page, page_size=page_size ) # 4. 마진 계산 및 응답 생성 result_cars = [] for car in paginated_cars: original_price = car.get("price") or car.get("original_price") or 0 korea_margin = int(original_price * KOREA_MARGIN_RATE) mongolia_margin = int(original_price * MONGOLIA_MARGIN_RATE) final_price = original_price + korea_margin + mongolia_margin result_cars.append(CarmodooSearchResultItem( id=car.get("car_no", car.get("id", "")), car_name=car.get("car_name", ""), maker_name=car.get("maker_name", maker_name), model_name=car.get("model_name", model_name), year=car.get("year"), mileage=car.get("mileage"), original_price=original_price, korea_margin=korea_margin, mongolia_margin=mongolia_margin, final_price=final_price, fuel=car.get("fuel"), transmission=car.get("transmission"), color=car.get("color"), displacement=car.get("displacement"), main_image=car.get("main_image"), image_count=car.get("image_count", 20), check_num=car.get("check_num"), # 성능점검번호 car_key=car.get("car_key"), # 암호화된 차량 키 )) return VehicleRequestSearchResponse( total=total, cars=result_cars ) class ImportCarRequest(BaseModel): car_no: str car_name: str maker_name: Optional[str] = None model_name: Optional[str] = None year: Optional[int] = None mileage: Optional[int] = None price: Optional[int] = None # 원가 (KRW) fuel: Optional[str] = None transmission: Optional[str] = None color: Optional[str] = None displacement: Optional[int] = None main_image: Optional[str] = None check_num: Optional[str] = None # 성능점검번호 car_key: Optional[str] = None # 암호화된 차량 키 (dealerCarviewPopup용) dealer_description: Optional[str] = None # 편집된 딜러 설명 (민감정보 제거됨) class ImportCarsRequest(BaseModel): cars: List[ImportCarRequest] async def download_image(url: str, save_path: str) -> bool: """이미지를 다운로드해서 로컬에 저장""" try: async with httpx.AsyncClient(timeout=30.0) as client: response = await client.get(url) if response.status_code == 200: os.makedirs(os.path.dirname(save_path), exist_ok=True) with open(save_path, 'wb') as f: f.write(response.content) return True except Exception as e: print(f"Image download failed: {url} - {e}") return False @router.post("/import") async def import_cars_from_carmodoo( request: ImportCarsRequest, db: Session = Depends(get_db), current_user = Depends(get_current_user), ): """카모두에서 선택한 차량들을 로컬 DB로 가져오기 (이미지 포함)""" imported = [] skipped = [] errors = [] for car_data in request.cars: try: # 이미 존재하는지 확인 existing = db.query(Car).filter( Car.source == "carmodoo", Car.source_id == car_data.car_no ).first() if existing: skipped.append({ "car_no": car_data.car_no, "car_id": existing.id, "reason": "already exists" }) continue # 제조사 찾기/생성 maker = None maker_code = None if car_data.maker_name: # 제조사 코드 찾기 for m in CARMODOO_DATA.get("makers", []): if m["name"] == car_data.maker_name: maker_code = m["code"] break if maker_code: maker = db.query(CarMaker).filter(CarMaker.code == maker_code).first() if not maker: maker = CarMaker( code=maker_code, name=car_data.maker_name, name_en=car_data.maker_name ) db.add(maker) db.flush() # 모델 찾기/생성 model = None if maker and car_data.model_name and maker_code: models_list = CARMODOO_DATA.get("models", {}).get(maker_code, []) model_code = None for m in models_list: if m["name"] == car_data.model_name: model_code = m["code"] break if model_code: model = db.query(CarModel).filter( CarModel.code == model_code, CarModel.maker_id == maker.id ).first() if not model: model = CarModel( code=model_code, maker_id=maker.id, name=car_data.model_name, name_en=car_data.model_name ) db.add(model) db.flush() # 차량 생성 car = Car( source="carmodoo", source_id=car_data.car_no, maker_id=maker.id if maker else None, model_id=model.id if model else None, car_name=car_data.car_name, year=car_data.year, mileage=car_data.mileage, price_krw=car_data.price, fuel=car_data.fuel, transmission=car_data.transmission, color=car_data.color, displacement=car_data.displacement, is_displayed=True, # Banner용은 바로 표시 status="active" ) db.add(car) db.flush() # 이미지 다운로드 (20장) car_no_padded = car_data.car_no.zfill(9) image_base_dir = f"./uploads/cars/{car.id}" os.makedirs(image_base_dir, exist_ok=True) downloaded_images = [] for i in range(20): image_url = f"{CARMODOO_BASE_URL}/data/__carPhoto/{car_no_padded[:3]}/{car_no_padded[3:6]}/{car_no_padded[6:]}/cmcar_{i}.jpg" local_filename = f"image_{i}.jpg" local_path = f"{image_base_dir}/{local_filename}" if await download_image(image_url, local_path): car_image = CarImage( car_id=car.id, url=f"/uploads/cars/{car.id}/{local_filename}", local_path=local_path, is_main=(i == 0), sort_order=i ) db.add(car_image) downloaded_images.append(local_filename) else: # 이미지 없으면 중단 (보통 20장 미만인 경우) if i > 0: break # 성능점검표 가져오기 (check_num이 있으면 직접 사용) perf_check_result = await carmodoo_client.get_performance_check( car_data.car_no, check_num=car_data.check_num or "" ) performance_check_saved = False if perf_check_result.get("found") and perf_check_result.get("data"): perf_data = perf_check_result["data"] # 차량번호를 cars 테이블에 저장 (원자성) if perf_data.get("car_number"): car.car_number = perf_data.get("car_number") # 차량 상세정보 가져오기 (딜러 설명) # 관리자가 편집한 dealer_description이 있으면 사용, 없으면 원본 가져와서 자동 마스킹 if car_data.dealer_description: # 관리자가 편집한 설명 사용 car.dealer_description = car_data.dealer_description else: # 원본 가져와서 자동 마스킹 car_detail = await carmodoo_client.get_car_detail(car_data.car_no, car_data.car_key or "") if car_detail.get("found") and car_detail.get("dealer_description"): original_desc = car_detail.get("dealer_description") # 민감정보 자동 마스킹 적용 car.dealer_description = mask_sensitive_info(original_desc) # 딜러 설명 번역 (한국어 → 영어/몽골어/러시아어) if car.dealer_description: try: translations = await translate_dealer_description(car.dealer_description) car.dealer_description_en = translations.get('en') car.dealer_description_mn = translations.get('mn') car.dealer_description_ru = translations.get('ru') print(f"[Translation] Dealer description translated for car {car.id}") except Exception as trans_error: print(f"[Translation] Failed for car {car.id}: {trans_error}") # 번역 실패 시 원문 유지 (번역 필드는 None 상태) # CarPerformanceCheck 생성 (성능점검 데이터가 있을 때만) pdf_status = {"success": False, "attempts": 0, "error": None, "check_num": None} if perf_check_result.get("found") and perf_check_result.get("data"): performance_check = CarPerformanceCheck( car_id=car.id, check_number=perf_data.get("check_number"), check_date=perf_data.get("check_date"), valid_until=perf_data.get("valid_until"), first_registration=perf_data.get("first_registration"), mileage=perf_data.get("mileage"), mileage_status=perf_data.get("mileage_status"), seize_count=perf_data.get("seize_count", 0), collateral_count=perf_data.get("collateral_count", 0), is_flood_damaged=perf_data.get("is_flood_damaged", False), is_fire_damaged=perf_data.get("is_fire_damaged", False), is_total_loss=perf_data.get("is_total_loss", False), usage_history=perf_data.get("usage_history"), is_rental_used=perf_data.get("is_rental_used", False), engine_status=perf_data.get("engine_status"), transmission_status=perf_data.get("transmission_status"), power_delivery_status=perf_data.get("power_delivery_status"), steering_status=perf_data.get("steering_status"), brake_status=perf_data.get("brake_status"), electrical_status=perf_data.get("electrical_status"), fuel_system_status=perf_data.get("fuel_system_status"), tire_front_left=perf_data.get("tire_front_left"), tire_front_right=perf_data.get("tire_front_right"), tire_rear_left=perf_data.get("tire_rear_left"), tire_rear_right=perf_data.get("tire_rear_right"), hood=perf_data.get("hood"), front_fender_left=perf_data.get("front_fender_left"), front_fender_right=perf_data.get("front_fender_right"), front_door_left=perf_data.get("front_door_left"), front_door_right=perf_data.get("front_door_right"), rear_door_left=perf_data.get("rear_door_left"), rear_door_right=perf_data.get("rear_door_right"), trunk_lid=perf_data.get("trunk_lid"), radiator_support=perf_data.get("radiator_support"), roof_panel=perf_data.get("roof_panel"), quarter_panel_left=perf_data.get("quarter_panel_left"), quarter_panel_right=perf_data.get("quarter_panel_right"), side_sill_left=perf_data.get("side_sill_left"), side_sill_right=perf_data.get("side_sill_right"), front_panel=perf_data.get("front_panel"), cross_member=perf_data.get("cross_member"), inside_panel_left=perf_data.get("inside_panel_left"), inside_panel_right=perf_data.get("inside_panel_right"), side_member_left=perf_data.get("side_member_left"), side_member_right=perf_data.get("side_member_right"), wheel_house_left=perf_data.get("wheel_house_left"), wheel_house_right=perf_data.get("wheel_house_right"), dash_panel=perf_data.get("dash_panel"), floor_panel=perf_data.get("floor_panel"), trunk_floor=perf_data.get("trunk_floor"), rear_panel=perf_data.get("rear_panel"), pillar_a_left=perf_data.get("pillar_a_left"), pillar_a_right=perf_data.get("pillar_a_right"), pillar_b_left=perf_data.get("pillar_b_left"), pillar_b_right=perf_data.get("pillar_b_right"), pillar_c_left=perf_data.get("pillar_c_left"), pillar_c_right=perf_data.get("pillar_c_right"), package_tray=perf_data.get("package_tray"), accident_history=perf_data.get("accident_history"), raw_data=perf_data, raw_html=perf_check_result.get("raw_html", "")[:50000], # 50KB 제한 report_image_url=perf_data.get("report_image_url"), ) db.add(performance_check) db.flush() # Get ID for PDF filename # PDF 캡처 (성능점검표) - capture_performance_check_pdf가 내부적으로 3회 재시도 check_num = perf_data.get("check_number") pdf_status["check_num"] = check_num if check_num: try: print(f"[PDF] Starting capture for car {car.id}, check_num={check_num}") # 함수 내부에서 3회 재시도 수행 pdf_path = await capture_performance_check_pdf(check_num, car.id) if pdf_path: performance_check.pdf_path = pdf_path pdf_status["success"] = True pdf_status["path"] = pdf_path pdf_status["attempts"] = 1 print(f"[PDF] Success: {pdf_path}") else: pdf_status["error"] = "No path returned after 3 retries" pdf_status["attempts"] = 3 print(f"[PDF] Failed after internal retries: No path returned") except Exception as pdf_error: pdf_status["error"] = str(pdf_error) pdf_status["attempts"] = 3 print(f"[PDF] Exception: {pdf_error}") performance_check_saved = True # 상세사양 조회 및 저장 (차량번호가 있는 경우만) spec_saved = False if car.car_number: try: print(f"Fetching specifications for car_number: {car.car_number}") spec_data = await get_specifications_from_carmodoo(car.car_number) if spec_data: spec_dict = spec_to_dict(spec_data) car_spec = CarSpecification( car_id=car.id, manufacturer=spec_dict.get("manufacturer"), model_name=spec_dict.get("model_name"), grade=spec_dict.get("grade"), model_year=spec_dict.get("model_year"), displacement=spec_dict.get("displacement"), fuel_type=spec_dict.get("fuel_type"), transmission=spec_dict.get("transmission"), drive_type=spec_dict.get("drive_type"), max_power=spec_dict.get("max_power"), max_torque=spec_dict.get("max_torque"), fuel_efficiency=spec_dict.get("fuel_efficiency"), body_type=spec_dict.get("body_type"), door_count=spec_dict.get("door_count"), seating_capacity=spec_dict.get("seating_capacity"), length=spec_dict.get("length"), width=spec_dict.get("width"), height=spec_dict.get("height"), wheelbase=spec_dict.get("wheelbase"), curb_weight=spec_dict.get("curb_weight"), safety_options=spec_dict.get("safety_options"), comfort_options=spec_dict.get("comfort_options"), exterior_options=spec_dict.get("exterior_options"), interior_options=spec_dict.get("interior_options"), raw_data=spec_dict.get("raw_data"), ) db.add(car_spec) spec_saved = True print(f"Specifications saved for car {car.id}") except Exception as spec_error: print(f"Specification fetch failed for car {car.id}: {spec_error}") db.commit() imported.append({ "car_no": car_data.car_no, "car_id": car.id, "car_name": car_data.car_name, "images_downloaded": len(downloaded_images), "performance_check_saved": performance_check_saved, "specification_saved": spec_saved, "pdf_status": pdf_status }) except Exception as e: db.rollback() errors.append({ "car_no": car_data.car_no, "error": str(e) }) # PDF 상태 요약 pdf_success_count = sum(1 for i in imported if i.get("pdf_status", {}).get("success")) pdf_failed_count = len(imported) - pdf_success_count return { "imported": imported, "skipped": skipped, "errors": errors, "summary": { "imported_count": len(imported), "skipped_count": len(skipped), "error_count": len(errors), "pdf_success_count": pdf_success_count, "pdf_failed_count": pdf_failed_count } } @router.get("/performance-check/{car_no}") async def get_performance_check( car_no: str, current_user = Depends(get_current_user), ): """카모두에서 성능점검표 조회 (미리보기용)""" result = await carmodoo_client.get_performance_check(car_no) return result @router.get("/car/{car_id}/performance-check") async def get_car_performance_check( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_user_optional), ): """저장된 차량의 성능점검표 조회 (0.1 CC 결제 필요)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") performance_check = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id == car_id ).first() if not performance_check: return {"car_id": car_id, "found": False, "has_access": False, "data": None} # Check if user has paid for performance check has_access = False if current_user: # Admin always has access if current_user.is_admin: has_access = True else: # Check 1: User purchased this performance check (0.1 CC) existing_perf_view = db.query(PerformanceCheckView).filter( PerformanceCheckView.user_id == current_user.id, PerformanceCheckView.car_id == car_id ).first() # Check 2: User purchased full car view (1 CC) -> performance check included existing_car_view = db.query(CarView).filter( CarView.user_id == current_user.id, CarView.car_id == car_id ).first() # Check 3: This car was recommended to the user (paid 1 CC for recommendation) recommended_vehicle = db.query(RequestVehicle).join(VehicleRequest).filter( VehicleRequest.user_id == current_user.id, RequestVehicle.car_id == car_id, RequestVehicle.is_approved == True ).first() has_access = (existing_perf_view is not None) or (existing_car_view is not None) or (recommended_vehicle is not None) # If no access, return only basic info (that performance check exists) if not has_access: return { "car_id": car_id, "found": True, "has_access": False, "preview": { "check_number": performance_check.check_number, "check_date": performance_check.check_date, "mileage": performance_check.mileage, }, "data": None } # 응답 데이터 구성 data = { "id": performance_check.id, "car_id": performance_check.car_id, "check_number": performance_check.check_number, "check_date": performance_check.check_date, "valid_until": performance_check.valid_until, "car_number": car.car_number, # car_number is in Car model, not PerformanceCheck "first_registration": performance_check.first_registration, "mileage": performance_check.mileage, "mileage_status": performance_check.mileage_status, "seize_count": performance_check.seize_count, "collateral_count": performance_check.collateral_count, "is_flood_damaged": performance_check.is_flood_damaged, "is_fire_damaged": performance_check.is_fire_damaged, "is_total_loss": performance_check.is_total_loss, "usage_history": performance_check.usage_history, "is_rental_used": performance_check.is_rental_used, "device_status": { "engine": performance_check.engine_status, "transmission": performance_check.transmission_status, "power_delivery": performance_check.power_delivery_status, "steering": performance_check.steering_status, "brake": performance_check.brake_status, "electrical": performance_check.electrical_status, "fuel_system": performance_check.fuel_system_status, }, "tire_status": { "front_left": performance_check.tire_front_left, "front_right": performance_check.tire_front_right, "rear_left": performance_check.tire_rear_left, "rear_right": performance_check.tire_rear_right, }, "body_parts": { "hood": performance_check.hood, "front_fender_left": performance_check.front_fender_left, "front_fender_right": performance_check.front_fender_right, "front_door_left": performance_check.front_door_left, "front_door_right": performance_check.front_door_right, "rear_door_left": performance_check.rear_door_left, "rear_door_right": performance_check.rear_door_right, "trunk_lid": performance_check.trunk_lid, "radiator_support": performance_check.radiator_support, "roof_panel": performance_check.roof_panel, "quarter_panel_left": performance_check.quarter_panel_left, "quarter_panel_right": performance_check.quarter_panel_right, "side_sill_left": performance_check.side_sill_left, "side_sill_right": performance_check.side_sill_right, }, "frame_parts": { "front_panel": performance_check.front_panel, "cross_member": performance_check.cross_member, "inside_panel_left": performance_check.inside_panel_left, "inside_panel_right": performance_check.inside_panel_right, "side_member_left": performance_check.side_member_left, "side_member_right": performance_check.side_member_right, "wheel_house_left": performance_check.wheel_house_left, "wheel_house_right": performance_check.wheel_house_right, "dash_panel": performance_check.dash_panel, "floor_panel": performance_check.floor_panel, "trunk_floor": performance_check.trunk_floor, "rear_panel": performance_check.rear_panel, "pillar_a_left": performance_check.pillar_a_left, "pillar_a_right": performance_check.pillar_a_right, "pillar_b_left": performance_check.pillar_b_left, "pillar_b_right": performance_check.pillar_b_right, "pillar_c_left": performance_check.pillar_c_left, "pillar_c_right": performance_check.pillar_c_right, "package_tray": performance_check.package_tray, }, "accident_history": performance_check.accident_history, "report_image_url": performance_check.report_image_url, "created_at": performance_check.created_at.isoformat() if performance_check.created_at else None, "pdf_path": performance_check.pdf_path, # PDF 파일 경로 추가 } return {"car_id": car_id, "found": True, "has_access": True, "data": data} @router.get("/car/{car_id}/performance-check/pdf") async def get_car_performance_check_pdf( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_user), ): """성능점검표 PDF 다운로드 (0.1 CC 결제 필요)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") performance_check = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id == car_id ).first() if not performance_check: raise HTTPException(status_code=404, detail="Performance check not found") if not performance_check.pdf_path: raise HTTPException(status_code=404, detail="PDF not available for this car") # Check access (admin, purchased performance check, or purchased car view) has_access = False if current_user.is_admin: has_access = True else: # Check 1: Purchased performance check (0.1 CC) existing_perf_view = db.query(PerformanceCheckView).filter( PerformanceCheckView.user_id == current_user.id, PerformanceCheckView.car_id == car_id ).first() # Check 2: Purchased full car view (1 CC) -> performance check included existing_car_view = db.query(CarView).filter( CarView.user_id == current_user.id, CarView.car_id == car_id ).first() has_access = (existing_perf_view is not None) or (existing_car_view is not None) if not has_access: raise HTTPException( status_code=403, detail="Access denied. Please purchase access with 0.1 CC or 1 CC for full car view." ) # Get full path and return file pdf_full_path = get_pdf_full_path(performance_check.pdf_path) if not pdf_full_path or not pdf_full_path.exists(): raise HTTPException(status_code=404, detail="PDF file not found on server") return FileResponse( path=str(pdf_full_path), media_type="application/pdf", filename=f"performance_check_{car_id}.pdf" ) @router.post("/car/{car_id}/fetch-performance-check") async def fetch_performance_check_for_car( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """기존 차량의 성능점검표 데이터 가져오기 (관리자 전용) 차량이 import될 때 성능점검표가 없었거나, 나중에 추가된 경우 사용 """ car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") if not car.source_id: raise HTTPException(status_code=400, detail="Car has no source_id (car_no)") # 기존 성능점검 데이터가 있는지 확인 existing_check = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id == car_id ).first() if existing_check: return { "message": "Performance check already exists", "car_id": car_id, "check_number": existing_check.check_number, "has_pdf": bool(existing_check.pdf_path) } # Carmodoo에서 성능점검 데이터 가져오기 perf_check_result = await carmodoo_client.get_performance_check(car.source_id) if not perf_check_result.get("found") or not perf_check_result.get("data"): raise HTTPException(status_code=404, detail="Performance check not found on Carmodoo") perf_data = perf_check_result["data"] # CarPerformanceCheck 생성 performance_check = CarPerformanceCheck( car_id=car.id, check_number=perf_data.get("check_number"), check_date=perf_data.get("check_date"), valid_until=perf_data.get("valid_until"), car_number=perf_data.get("car_number"), first_registration=perf_data.get("first_registration"), mileage=perf_data.get("mileage"), mileage_status=perf_data.get("mileage_status"), seize_count=perf_data.get("seize_count", 0), collateral_count=perf_data.get("collateral_count", 0), is_flood_damaged=perf_data.get("is_flood_damaged", False), is_fire_damaged=perf_data.get("is_fire_damaged", False), is_total_loss=perf_data.get("is_total_loss", False), usage_history=perf_data.get("usage_history"), is_rental_used=perf_data.get("is_rental_used", False), engine_status=perf_data.get("engine_status"), transmission_status=perf_data.get("transmission_status"), power_delivery_status=perf_data.get("power_delivery_status"), steering_status=perf_data.get("steering_status"), brake_status=perf_data.get("brake_status"), electrical_status=perf_data.get("electrical_status"), fuel_system_status=perf_data.get("fuel_system_status"), tire_front_left=perf_data.get("tire_front_left"), tire_front_right=perf_data.get("tire_front_right"), tire_rear_left=perf_data.get("tire_rear_left"), tire_rear_right=perf_data.get("tire_rear_right"), hood=perf_data.get("hood"), front_fender_left=perf_data.get("front_fender_left"), front_fender_right=perf_data.get("front_fender_right"), front_door_left=perf_data.get("front_door_left"), front_door_right=perf_data.get("front_door_right"), rear_door_left=perf_data.get("rear_door_left"), rear_door_right=perf_data.get("rear_door_right"), trunk_lid=perf_data.get("trunk_lid"), radiator_support=perf_data.get("radiator_support"), roof_panel=perf_data.get("roof_panel"), quarter_panel_left=perf_data.get("quarter_panel_left"), quarter_panel_right=perf_data.get("quarter_panel_right"), side_sill_left=perf_data.get("side_sill_left"), side_sill_right=perf_data.get("side_sill_right"), front_panel=perf_data.get("front_panel"), cross_member=perf_data.get("cross_member"), inside_panel_left=perf_data.get("inside_panel_left"), inside_panel_right=perf_data.get("inside_panel_right"), side_member_left=perf_data.get("side_member_left"), side_member_right=perf_data.get("side_member_right"), wheel_house_left=perf_data.get("wheel_house_left"), wheel_house_right=perf_data.get("wheel_house_right"), dash_panel=perf_data.get("dash_panel"), floor_panel=perf_data.get("floor_panel"), trunk_floor=perf_data.get("trunk_floor"), rear_panel=perf_data.get("rear_panel"), pillar_a_left=perf_data.get("pillar_a_left"), pillar_a_right=perf_data.get("pillar_a_right"), pillar_b_left=perf_data.get("pillar_b_left"), pillar_b_right=perf_data.get("pillar_b_right"), pillar_c_left=perf_data.get("pillar_c_left"), pillar_c_right=perf_data.get("pillar_c_right"), package_tray=perf_data.get("package_tray"), accident_history=perf_data.get("accident_history"), report_image_url=perf_data.get("report_image_url"), ) db.add(performance_check) db.flush() # PDF 캡처 pdf_path = None if performance_check.check_number: try: pdf_path = await capture_performance_check_pdf( performance_check.check_number, car.id ) if pdf_path: performance_check.pdf_path = pdf_path except Exception as e: print(f"PDF capture failed for car {car.id}: {e}") db.commit() return { "message": "Performance check fetched successfully", "car_id": car_id, "check_number": performance_check.check_number, "has_pdf": bool(pdf_path) } @router.post("/car/{car_id}/regenerate-pdf") async def regenerate_performance_check_pdf( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """성능점검표 PDF 재생성 (관리자 전용)""" performance_check = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id == car_id ).first() if not performance_check: raise HTTPException(status_code=404, detail="Performance check not found") if not performance_check.check_number: raise HTTPException(status_code=400, detail="No check number available") try: pdf_path = await capture_performance_check_pdf( performance_check.check_number, car_id ) if pdf_path: performance_check.pdf_path = pdf_path db.commit() return {"message": "PDF regenerated successfully", "pdf_path": pdf_path} else: raise HTTPException(status_code=500, detail="Failed to generate PDF") except Exception as e: raise HTTPException(status_code=500, detail=f"PDF generation error: {str(e)}") @router.post("/car/{car_id}/fetch-check-num") async def fetch_car_check_num( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """차량의 성능점검번호를 카모두에서 가져와서 저장 (관리자 전용)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") if not car.car_number: raise HTTPException(status_code=400, detail="Car number not available") # 기존 성능점검 레코드 확인 performance_check = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id == car_id ).first() # 이미 check_number가 있으면 바로 PDF 생성 시도 (최대 3회 재시도) if performance_check and performance_check.check_number: pdf_error = None pdf_path = None max_retries = 3 for attempt in range(1, max_retries + 1): try: print(f"[PDF API] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={performance_check.check_number}") pdf_path = await capture_performance_check_pdf( performance_check.check_number, car_id ) print(f"[PDF API] Attempt {attempt} result: pdf_path={pdf_path}") if pdf_path: performance_check.pdf_path = pdf_path db.commit() return { "message": f"PDF generated successfully (attempt {attempt})", "check_number": performance_check.check_number, "pdf_path": pdf_path } else: pdf_error = f"Attempt {attempt}: PDF generation returned None" except Exception as e: import traceback pdf_error = f"Attempt {attempt}: {str(e)}" print(f"[PDF API] Exception on attempt {attempt}: {pdf_error}") # 재시도 전 대기 if attempt < max_retries: await asyncio.sleep(2) return { "message": f"Check number exists but PDF generation failed after {max_retries} attempts: {pdf_error}", "check_number": performance_check.check_number, "pdf_path": None } # 카모두에서 check_num 가져오기 try: check_num = await carmodoo_client.get_car_check_num(car.car_number, "") if not check_num: raise HTTPException(status_code=404, detail="Could not find check number from Carmodoo") # 성능점검 레코드 생성 또는 업데이트 if not performance_check: performance_check = CarPerformanceCheck( car_id=car_id, check_number=check_num ) db.add(performance_check) else: performance_check.check_number = check_num # PDF 생성 시도 (최대 3회 재시도) pdf_path = None pdf_error = None max_retries = 3 for attempt in range(1, max_retries + 1): try: print(f"[PDF API New] Attempt {attempt}/{max_retries} for car_id={car_id}, check_num={check_num}") pdf_path = await capture_performance_check_pdf(check_num, car_id) if pdf_path: performance_check.pdf_path = pdf_path print(f"[PDF API New] Success on attempt {attempt}: {pdf_path}") break else: pdf_error = f"Attempt {attempt}: returned None" except Exception as e: pdf_error = f"Attempt {attempt}: {str(e)}" print(f"[PDF API New] Exception on attempt {attempt}: {pdf_error}") if attempt < max_retries: await asyncio.sleep(2) db.commit() return { "message": "Check number fetched successfully" + (f" (PDF: {pdf_error})" if not pdf_path else ""), "check_number": check_num, "pdf_path": pdf_path } except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error fetching check number: {str(e)}") @router.get("/admin/pdf-failures") async def get_pdf_generation_failures( current_user: User = Depends(get_current_admin_user) ): """Get list of recent PDF generation failures (Admin only)""" failures = get_pdf_failures() return { "failures": failures, "count": len(failures) } @router.post("/admin/retry-all-failed-pdfs") async def retry_all_failed_pdfs( current_user: User = Depends(get_current_admin_user), db: Session = Depends(get_db) ): """Retry PDF generation for all cars with missing PDFs (Admin only)""" results = [] carmodoo_client = CarmodooClient() # 1. 배너 차량 중 car_performance_checks 레코드가 없는 차량 찾기 cars_without_check = db.query(Car).outerjoin( CarPerformanceCheck, Car.id == CarPerformanceCheck.car_id ).filter( Car.is_displayed == True, CarPerformanceCheck.id.is_(None) ).all() for car in cars_without_check: try: # 차량번호로 성능점검 데이터 조회 check_num = car.check_num or "" if not check_num and car.car_number: # check_num이 없으면 조회 시도 check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "") if not check_num: results.append({ "car_id": car.id, "car_name": car.car_name, "status": "skipped", "reason": "No check_num available" }) continue # 성능점검 데이터 가져오기 perf_result = await carmodoo_client.get_performance_check(car.source_id, car.source_key or "", check_num) if perf_result.get("found") and perf_result.get("data"): perf_data = perf_result["data"] # CarPerformanceCheck 레코드 생성 performance_check = CarPerformanceCheck( car_id=car.id, check_number=perf_data.get("check_number") or check_num, check_date=perf_data.get("check_date"), valid_until=perf_data.get("valid_until"), first_registration=perf_data.get("first_registration"), mileage=perf_data.get("mileage"), mileage_status=perf_data.get("mileage_status"), seize_count=perf_data.get("seize_count", 0), collateral_count=perf_data.get("collateral_count", 0), is_flood_damaged=perf_data.get("is_flood_damaged", False), is_fire_damaged=perf_data.get("is_fire_damaged", False), is_total_loss=perf_data.get("is_total_loss", False), engine_status=perf_data.get("engine_status"), transmission_status=perf_data.get("transmission_status"), power_delivery_status=perf_data.get("power_delivery_status"), raw_data=perf_data, raw_html=perf_result.get("raw_html", "")[:50000], ) db.add(performance_check) db.flush() # PDF 캡처 pdf_path = await capture_performance_check_pdf(performance_check.check_number, car.id) if pdf_path: performance_check.pdf_path = pdf_path db.commit() results.append({ "car_id": car.id, "car_name": car.car_name, "check_number": performance_check.check_number, "status": "success", "pdf_path": pdf_path, "action": "created_and_captured" }) else: db.commit() results.append({ "car_id": car.id, "car_name": car.car_name, "check_number": performance_check.check_number, "status": "partial", "reason": "Record created but PDF capture failed" }) else: results.append({ "car_id": car.id, "car_name": car.car_name, "status": "failed", "reason": "Could not fetch performance check data" }) except Exception as e: results.append({ "car_id": car.id, "car_name": car.car_name, "status": "error", "error": str(e) }) # 2. 기존 로직: check_number가 있지만 pdf_path가 없는 레코드 missing_pdfs = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.check_number.isnot(None), CarPerformanceCheck.check_number != '', (CarPerformanceCheck.pdf_path.is_(None)) | (CarPerformanceCheck.pdf_path == '') ).all() for check in missing_pdfs: try: pdf_path = await capture_performance_check_pdf(check.check_number, check.car_id) if pdf_path: check.pdf_path = pdf_path db.commit() results.append({ "car_id": check.car_id, "check_number": check.check_number, "status": "success", "pdf_path": pdf_path, "action": "captured" }) else: results.append({ "car_id": check.car_id, "check_number": check.check_number, "status": "failed", "pdf_path": None }) except Exception as e: results.append({ "car_id": check.car_id, "check_number": check.check_number, "status": "error", "error": str(e) }) success_count = sum(1 for r in results if r["status"] == "success") return { "total": len(results), "success": success_count, "failed": len(results) - success_count, "results": results } @router.post("/pdf-status") async def get_pdf_status_batch( car_ids: List[int], db: Session = Depends(get_db), ): """여러 차량의 PDF 상태를 한번에 조회""" if not car_ids: return {} checks = db.query(CarPerformanceCheck).filter( CarPerformanceCheck.car_id.in_(car_ids) ).all() # Create a map of car_id -> has_pdf result = {} for check in checks: result[check.car_id] = bool(check.pdf_path) # Fill in missing car_ids with False for car_id in car_ids: if car_id not in result: result[car_id] = False return result @router.get("/specifications/{car_number}") async def get_car_specifications( car_number: str, current_user = Depends(get_current_admin_user), ): """차량번호로 상세사양 조회 (관리자 전용) AUTOBEGINS 서비스를 통해 차량의 상세사양을 조회합니다. - 제조사, 모델명, 년형, 연료, 배기량 - 출고가, 기본가, 옵션가 - 기본 옵션, 선택 옵션 """ try: spec_data = await get_specifications_from_carmodoo(car_number) if spec_data: return { "found": True, "car_number": car_number, "specifications": spec_to_dict(spec_data), "raw": { "manufacturer": spec_data.manufacturer, "model_name": spec_data.model_name, "model_year": spec_data.model_year, "fuel_type": spec_data.fuel_type, "displacement": spec_data.displacement, "transmission": spec_data.transmission, "body_type": spec_data.body_type, "color": spec_data.color, "mileage": spec_data.mileage, "vin": spec_data.vin, "release_price": spec_data.release_price, "base_price": spec_data.base_price, } } else: return { "found": False, "car_number": car_number, "message": "No specifications found for this car number" } except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch specifications: {str(e)}" ) @router.get("/car/{car_id}/specifications") async def get_car_specifications_by_id( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_user), ): """저장된 차량의 상세사양 조회""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") # Check if specifications exist in database spec = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).first() if spec: return { "found": True, "car_id": car_id, "from_db": True, "specifications": { "manufacturer": spec.manufacturer, "model_name": spec.model_name, "grade": spec.grade, "model_year": spec.model_year, "fuel_type": spec.fuel_type, "displacement": spec.displacement, "transmission": spec.transmission, "drive_type": spec.drive_type, "body_type": spec.body_type, "max_power": spec.max_power, "max_torque": spec.max_torque, "fuel_efficiency": spec.fuel_efficiency, "seating_capacity": spec.seating_capacity, "comfort_options": spec.comfort_options, "interior_options": spec.interior_options, "raw_data": spec.raw_data, } } # If not in DB but car has car_number, offer to fetch (admin only) if current_user.is_admin and car.car_number: return { "found": False, "car_id": car_id, "car_number": car.car_number, "message": "Specifications not saved. Use /specifications/{car_number} to fetch." } return { "found": False, "car_id": car_id, "message": "No specifications available for this car" } # ==================== Sensitive Info Detection ==================== class SensitiveInfoRequest(BaseModel): text: str class SensitiveInfoResponse(BaseModel): has_sensitive: bool summary: dict highlighted_html: str masked_text: str @router.post("/check-sensitive-info", response_model=SensitiveInfoResponse) async def check_sensitive_info( request: SensitiveInfoRequest, current_user = Depends(get_current_admin_user), ): """ Check text for sensitive information (phone numbers, addresses, etc.) Returns highlighted HTML for preview and masked text for storage. Admin only. """ text = request.text or "" return SensitiveInfoResponse( has_sensitive=has_sensitive_info(text), summary=get_sensitivity_summary(text), highlighted_html=highlight_sensitive_info(text), masked_text=mask_sensitive_info(text) ) @router.post("/preview-dealer-description/{car_no}") async def preview_dealer_description( car_no: str, car_key: Optional[str] = None, current_user = Depends(get_current_admin_user), ): """ Fetch dealer description from Carmodoo and return with sensitivity analysis. Admin only. """ car_detail = await carmodoo_client.get_car_detail(car_no, car_key or "") if not car_detail.get("found") or not car_detail.get("dealer_description"): return { "found": False, "message": "No dealer description found" } original = car_detail.get("dealer_description", "") return { "found": True, "original": original, "has_sensitive": has_sensitive_info(original), "summary": get_sensitivity_summary(original), "highlighted_html": highlight_sensitive_info(original), "masked_text": mask_sensitive_info(original) } # ===== 딜러 설명 번역 관리 API ===== class TranslationUpdateRequest(BaseModel): """번역 수정 요청""" dealer_description: Optional[str] = None # 한국어 원문 dealer_description_en: Optional[str] = None dealer_description_mn: Optional[str] = None dealer_description_ru: Optional[str] = None @router.get("/car/{car_id}/translations") async def get_car_translations( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """차량의 딜러 설명 번역 정보 조회 (관리자용)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") translation_service = get_translation_service() return { "car_id": car_id, "car_name": car.car_name, "dealer_description": car.dealer_description, "translations": { "en": car.dealer_description_en, "mn": car.dealer_description_mn, "ru": car.dealer_description_ru }, "has_translations": bool( car.dealer_description_en or car.dealer_description_mn or car.dealer_description_ru ), "papago_configured": translation_service.is_configured } @router.put("/car/{car_id}/translations") async def update_car_translations( car_id: int, request: TranslationUpdateRequest, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """차량의 딜러 설명 번역 수정 (관리자용)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") # 한국어 원문 업데이트 if request.dealer_description is not None: car.dealer_description = request.dealer_description # 번역 업데이트 if request.dealer_description_en is not None: car.dealer_description_en = request.dealer_description_en if request.dealer_description_mn is not None: car.dealer_description_mn = request.dealer_description_mn if request.dealer_description_ru is not None: car.dealer_description_ru = request.dealer_description_ru db.commit() return { "message": "Translations updated successfully", "car_id": car_id, "dealer_description": car.dealer_description, "translations": { "en": car.dealer_description_en, "mn": car.dealer_description_mn, "ru": car.dealer_description_ru } } @router.post("/car/{car_id}/translations/regenerate") async def regenerate_car_translations( car_id: int, db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """차량의 딜러 설명 번역 재생성 (관리자용)""" car = db.query(Car).filter(Car.id == car_id).first() if not car: raise HTTPException(status_code=404, detail="Car not found") if not car.dealer_description: raise HTTPException(status_code=400, detail="No dealer description to translate") translation_service = get_translation_service() if not translation_service.is_configured: raise HTTPException( status_code=503, detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET." ) try: translations = await translate_dealer_description(car.dealer_description) car.dealer_description_en = translations.get('en') car.dealer_description_mn = translations.get('mn') car.dealer_description_ru = translations.get('ru') db.commit() return { "message": "Translations regenerated successfully", "car_id": car_id, "translations": { "en": car.dealer_description_en, "mn": car.dealer_description_mn, "ru": car.dealer_description_ru } } except Exception as e: raise HTTPException(status_code=500, detail=f"Translation failed: {str(e)}") @router.get("/admin/untranslated-cars") async def get_untranslated_cars( db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), limit: int = Query(default=50, le=100), ): """번역이 없는 차량 목록 조회 (관리자용)""" cars = db.query(Car).filter( Car.dealer_description.isnot(None), Car.dealer_description != '', ( (Car.dealer_description_en.is_(None)) | (Car.dealer_description_mn.is_(None)) | (Car.dealer_description_ru.is_(None)) ) ).limit(limit).all() return { "count": len(cars), "cars": [ { "id": car.id, "car_name": car.car_name, "dealer_description": car.dealer_description[:100] + "..." if car.dealer_description and len(car.dealer_description) > 100 else car.dealer_description, "has_en": bool(car.dealer_description_en), "has_mn": bool(car.dealer_description_mn), "has_ru": bool(car.dealer_description_ru) } for car in cars ] } @router.post("/admin/translate-all-pending") async def translate_all_pending( db: Session = Depends(get_db), current_user = Depends(get_current_admin_user), ): """번역이 없는 모든 차량에 대해 번역 수행 (관리자용)""" translation_service = get_translation_service() if not translation_service.is_configured: raise HTTPException( status_code=503, detail="Translation service not configured. Please set PAPAGO_CLIENT_ID and PAPAGO_CLIENT_SECRET." ) cars = db.query(Car).filter( Car.dealer_description.isnot(None), Car.dealer_description != '', ( (Car.dealer_description_en.is_(None)) & (Car.dealer_description_mn.is_(None)) & (Car.dealer_description_ru.is_(None)) ) ).all() results = [] for car in cars: try: translations = await translate_dealer_description(car.dealer_description) car.dealer_description_en = translations.get('en') car.dealer_description_mn = translations.get('mn') car.dealer_description_ru = translations.get('ru') results.append({ "car_id": car.id, "status": "success" }) except Exception as e: results.append({ "car_id": car.id, "status": "failed", "error": str(e) }) db.commit() success_count = len([r for r in results if r["status"] == "success"]) return { "message": f"Translated {success_count}/{len(results)} cars", "total": len(results), "success": success_count, "failed": len(results) - success_count, "results": results }