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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

40
backend/.env.example Normal file
View File

@@ -0,0 +1,40 @@
# Database
USE_SQLITE=True
DB_HOST=192.168.0.201
DB_PORT=5432
DB_NAME=autonet
DB_USER=admin
DB_PASSWORD=
# Redis
REDIS_HOST=192.168.0.201
REDIS_PORT=6379
REDIS_PASSWORD=
# JWT
SECRET_KEY=your-secret-key-change-in-production
# Agent
AGENT_API_KEY=
# App
DEBUG=True
# Stripe Configuration
# Get your keys from https://dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
# Stripe Webhook Secret
# Get this from https://dashboard.stripe.com/webhooks when you create a webhook
STRIPE_WEBHOOK_SECRET=whsec_...
# Stripe redirect URLs (adjust for production)
STRIPE_SUCCESS_URL=http://localhost:3000/cc/success
STRIPE_CANCEL_URL=http://localhost:3000/cc
# Azure Translator API (Microsoft Azure)
# Get your keys from https://portal.azure.com -> Translator resource
# Free tier: 2 million characters/month
AZURE_TRANSLATOR_KEY=
AZURE_TRANSLATOR_REGION=koreacentral

23
backend/Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create uploads directory
RUN mkdir -p /app/uploads /app/logs
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -0,0 +1 @@
Error: Failed to fetch

View File

@@ -0,0 +1,36 @@
"""
Add exchange rate weight columns to system_settings table
"""
import sqlite3
def upgrade():
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Check existing columns
cursor.execute("PRAGMA table_info(system_settings)")
columns = [col[1] for col in cursor.fetchall()]
new_columns = [
('exchange_rate_weight_usd', 'FLOAT DEFAULT 0.0'),
('exchange_rate_weight_mnt', 'FLOAT DEFAULT 0.0'),
('exchange_rate_weight_rub', 'FLOAT DEFAULT 0.0'),
('exchange_rate_weight_cny', 'FLOAT DEFAULT 0.0'),
]
for col_name, col_type in new_columns:
if col_name not in columns:
try:
cursor.execute(f'ALTER TABLE system_settings ADD COLUMN {col_name} {col_type}')
print(f'Added column: {col_name}')
except Exception as e:
print(f'Error adding {col_name}: {e}')
else:
print(f'Column {col_name} already exists')
conn.commit()
conn.close()
print('Database migration complete!')
if __name__ == '__main__':
upgrade()

View File

@@ -0,0 +1,21 @@
"""Add pdf_path column to car_performance_checks table"""
import sqlite3
import os
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Check if column already exists
cursor.execute('PRAGMA table_info(car_performance_checks)')
columns = [col[1] for col in cursor.fetchall()]
if 'pdf_path' not in columns:
print("Adding pdf_path column...")
cursor.execute('ALTER TABLE car_performance_checks ADD COLUMN pdf_path VARCHAR(500)')
conn.commit()
print("pdf_path column added successfully!")
else:
print("pdf_path column already exists")
conn.close()

56
backend/analyze_page.py Normal file
View File

@@ -0,0 +1,56 @@
import asyncio, os, sys
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
from playwright.async_api import async_playwright
async def analyze():
url = "https://ck.carmodoo.com/carCheck/carmodooPrint.do?print=0&checkNum=7400044430"
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page(viewport={'width': 1400, 'height': 900})
await page.goto(url, wait_until='networkidle', timeout=60000)
await page.wait_for_timeout(3000)
# Get page structure
result = await page.evaluate("""() => {
const body = document.body;
const children = Array.from(body.children);
// Find elements that look like pages
const pageSelectors = ['.page', '[class*="page"]', 'table', '.print', '[class*="print"]'];
const found = {};
pageSelectors.forEach(sel => {
const elems = document.querySelectorAll(sel);
if (elems.length > 0) {
found[sel] = {
count: elems.length,
firstClass: elems[0].className,
firstTag: elems[0].tagName
};
}
});
// Get direct children of body
const bodyChildren = children.map(c => ({
tag: c.tagName,
class: c.className,
id: c.id,
height: c.offsetHeight
}));
return { found, bodyChildren: bodyChildren.slice(0, 20) };
}""")
print("=== Found elements ===")
for sel, info in result['found'].items():
print(f" {sel}: {info}")
print("\n=== Body children ===")
for child in result['bodyChildren']:
print(f" {child['tag']} class='{child['class']}' id='{child['id']}' height={child['height']}")
await browser.close()
asyncio.run(analyze())

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# AutonetSellCar Backend API

View File

@@ -0,0 +1 @@
# API Routes

546
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,546 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from jose import JWTError, jwt
from pydantic import BaseModel
from typing import Optional
import bcrypt
from ..database import get_db
from ..models import User
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..config import get_settings
router = APIRouter(prefix="/auth", tags=["auth"])
settings = get_settings()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8'))
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.email == email).first()
if user is None:
raise credentials_exception
return user
# Optional 인증 scheme
oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
def get_current_user_optional(
token: str = Depends(oauth2_scheme_optional),
db: Session = Depends(get_db)
) -> User | None:
"""선택적 인증 - 토큰이 없거나 유효하지 않아도 None 반환"""
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
email: str = payload.get("sub")
if email is None:
return None
except JWTError:
return None
user = db.query(User).filter(User.email == email).first()
return user
def get_current_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""관리자 권한 확인"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
return current_user
@router.post("/register", response_model=UserResponse)
def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""회원가입"""
from ..models.user import VerificationCode
from datetime import datetime
existing = db.query(User).filter(User.email == user_data.email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# Check if email was verified (pre-registration verification)
email_verified = False
verification = db.query(VerificationCode).filter(
VerificationCode.email == user_data.email,
VerificationCode.code_type == "email",
VerificationCode.verified_at.isnot(None)
).order_by(VerificationCode.verified_at.desc()).first()
if verification:
email_verified = True
# Generate unique referral code
referral_code = generate_referral_code()
while db.query(User).filter(User.referral_code == referral_code).first():
referral_code = generate_referral_code()
user = User(
email=user_data.email,
password_hash=get_password_hash(user_data.password),
name=user_data.name,
phone=user_data.phone,
country=user_data.country,
referral_code=referral_code,
referred_by=getattr(user_data, 'referred_by', None),
email_verified=email_verified,
email_verified_at=datetime.utcnow() if email_verified else None,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""로그인"""
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": user.email})
return Token(access_token=access_token)
@router.get("/me", response_model=UserResponse)
def get_me(current_user: User = Depends(get_current_user)):
"""현재 사용자 정보"""
return current_user
@router.put("/me", response_model=UserResponse)
def update_me(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""사용자 정보 수정"""
if user_update.name is not None:
current_user.name = user_update.name
if user_update.phone is not None:
current_user.phone = user_update.phone
if user_update.country is not None:
current_user.country = user_update.country
db.commit()
db.refresh(current_user)
return current_user
# Admin User Management Endpoints
@router.get("/admin/users")
def admin_get_users(
page: int = 1,
page_size: int = 20,
search: str = None,
is_dealer: bool = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""모든 사용자 목록 조회 (관리자) - 삭제된 사용자 제외"""
query = db.query(User).filter(
User.is_admin == False,
User.deleted_at.is_(None) # 삭제되지 않은 사용자만
)
if search:
query = query.filter(
(User.email.ilike(f"%{search}%")) |
(User.name.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
if is_dealer is not None:
query = query.filter(User.is_dealer == is_dealer)
total = query.count()
users = query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/admin/users/{user_id}")
def admin_get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""특정 사용자 상세 정보 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_admin": user.is_admin,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.put("/admin/users/{user_id}")
def admin_update_user(
user_id: int,
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 정보 수정 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_update.name is not None:
user.name = user_update.name
if user_update.phone is not None:
user.phone = user_update.phone
if user_update.country is not None:
user.country = user_update.country
db.commit()
db.refresh(user)
return {
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
@router.put("/admin/users/{user_id}/cc")
def admin_adjust_cc(
user_id: int,
amount: float,
reason: str = "Admin adjustment",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 CC 잔액 조정 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.cc_balance = user.cc_balance + amount
if user.cc_balance < 0:
user.cc_balance = 0
db.commit()
db.refresh(user)
return {
"message": f"CC balance adjusted by {amount}",
"new_balance": user.cc_balance
}
# ===== 사용자 탈퇴 =====
class WithdrawalRequest(BaseModel):
"""탈퇴 요청"""
reason: Optional[str] = None
password: str # 본인 확인용
@router.post("/withdraw")
def request_withdrawal(
request: WithdrawalRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""사용자 탈퇴 요청"""
# 비밀번호 확인
if not verify_password(request.password, current_user.password_hash):
raise HTTPException(status_code=400, detail="Incorrect password")
# 이미 탈퇴 요청한 경우
if current_user.withdrawal_requested_at:
raise HTTPException(status_code=400, detail="Withdrawal already requested")
# 관리자는 탈퇴 불가
if current_user.is_admin:
raise HTTPException(status_code=400, detail="Admin cannot withdraw")
# 탈퇴 요청 기록
current_user.withdrawal_requested_at = datetime.utcnow()
current_user.withdrawal_reason = request.reason
current_user.is_active = False # 계정 비활성화
db.commit()
return {
"message": "Withdrawal request submitted. Your account has been deactivated.",
"withdrawal_requested_at": current_user.withdrawal_requested_at.isoformat()
}
@router.post("/withdraw/cancel")
def cancel_withdrawal(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""탈퇴 요청 취소 (아직 삭제되지 않은 경우)"""
if not current_user.withdrawal_requested_at:
raise HTTPException(status_code=400, detail="No withdrawal request found")
if current_user.deleted_at:
raise HTTPException(status_code=400, detail="Account already deleted")
# 탈퇴 요청 취소
current_user.withdrawal_requested_at = None
current_user.withdrawal_reason = None
current_user.is_active = True
db.commit()
return {"message": "Withdrawal request cancelled. Your account is active again."}
# ===== 관리자 사용자 삭제 =====
@router.delete("/admin/users/{user_id}")
def admin_delete_user(
user_id: int,
hard_delete: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""사용자 삭제 (관리자 전용)
- hard_delete=False: 소프트 삭제 (deleted_at 설정)
- hard_delete=True: 완전 삭제 (DB에서 제거)
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 관리자는 삭제 불가
if user.is_admin:
raise HTTPException(status_code=400, detail="Cannot delete admin user")
# 자기 자신은 삭제 불가
if user.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
user_email = user.email
if hard_delete:
# 완전 삭제 - 관련 데이터도 함께 삭제
from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification
db.query(CarView).filter(CarView.user_id == user_id).delete()
db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == user_id).delete()
db.query(ChargeHistory).filter(ChargeHistory.user_id == user_id).delete()
db.query(Inquiry).filter(Inquiry.user_id == user_id).delete()
db.query(Notification).filter(Notification.user_id == user_id).delete()
db.delete(user)
db.commit()
return {
"message": f"User {user_email} permanently deleted",
"deleted_user_id": user_id,
"hard_delete": True
}
else:
# 소프트 삭제
user.deleted_at = datetime.utcnow()
user.is_active = False
db.commit()
return {
"message": f"User {user_email} soft deleted",
"deleted_user_id": user_id,
"deleted_at": user.deleted_at.isoformat(),
"hard_delete": False
}
@router.get("/admin/users/withdrawn")
def admin_get_withdrawn_users(
page: int = 1,
page_size: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""탈퇴 요청한 사용자 목록 (관리자)"""
query = db.query(User).filter(
User.withdrawal_requested_at.isnot(None),
User.deleted_at.is_(None) # 아직 삭제되지 않은 사용자만
)
total = query.count()
users = query.order_by(User.withdrawal_requested_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"withdrawal_requested_at": user.withdrawal_requested_at.isoformat() if user.withdrawal_requested_at else None,
"withdrawal_reason": user.withdrawal_reason,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.get("/admin/users/deleted")
def admin_get_deleted_users(
page: int = 1,
page_size: int = 20,
search: str = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""삭제된 사용자 목록 (관리자)"""
query = db.query(User).filter(
User.deleted_at.isnot(None) # 삭제된 사용자만
)
if search:
query = query.filter(
(User.email.ilike(f"%{search}%")) |
(User.name.ilike(f"%{search}%")) |
(User.phone.ilike(f"%{search}%"))
)
total = query.count()
users = query.order_by(User.deleted_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return {
"users": [
{
"id": user.id,
"email": user.email,
"name": user.name,
"phone": user.phone,
"country": user.country,
"cc_balance": user.cc_balance,
"is_dealer": user.is_dealer,
"referral_code": user.referral_code,
"referred_by": user.referred_by,
"deleted_at": user.deleted_at.isoformat() if user.deleted_at else None,
"withdrawal_reason": user.withdrawal_reason,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
for user in users
],
"total": total,
"page": page,
"page_size": page_size
}
@router.post("/admin/users/{user_id}/restore")
def admin_restore_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""삭제된 사용자 복원 (관리자)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not user.deleted_at:
raise HTTPException(status_code=400, detail="User is not deleted")
user.deleted_at = None
user.is_active = True
user.withdrawal_requested_at = None
user.withdrawal_reason = None
db.commit()
return {
"message": f"User {user.email} restored successfully",
"user_id": user_id
}

2691
backend/app/api/carmodoo.py Normal file

File diff suppressed because it is too large Load Diff

340
backend/app/api/cars.py Normal file
View File

@@ -0,0 +1,340 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from typing import Optional, List
from ..database import get_db
from ..models import Car, CarMaker, CarModel, CarImage, CarOption
from ..schemas import (
CarCreate, CarUpdate, CarResponse, CarListResponse,
CarMakerCreate, CarMakerResponse,
CarModelCreate, CarModelResponse,
)
router = APIRouter(prefix="/cars", tags=["cars"])
def car_to_response(car: Car) -> dict:
"""Convert Car model to response dict with computed final prices"""
return {
"id": car.id,
"source": car.source,
"source_id": car.source_id,
"car_name": car.car_name,
"year": car.year,
"month": car.month,
"mileage": car.mileage,
"price_krw": car.price_krw,
"margin_krw": car.margin_krw or 0,
"margin_mn": car.margin_mn or 0,
"final_price_krw": (car.price_krw or 0) + (car.margin_krw or 0),
"final_price_mn": (car.price_krw or 0) + (car.margin_mn or 0),
"price_usd": car.price_usd,
"is_displayed": car.is_displayed or False,
"fuel": car.fuel,
"transmission": car.transmission,
"color": car.color,
"displacement": car.displacement,
"car_number": car.car_number,
"seize_count": car.seize_count or 0,
"collateral_count": car.collateral_count or 0,
"check_num": car.check_num,
"dealer_name": car.dealer_name,
"dealer_description": car.dealer_description,
"status": car.status,
"created_at": car.created_at,
"updated_at": car.updated_at,
"maker": car.maker,
"model": car.model,
"images": car.images,
"specification": car.specification,
}
@router.get("", response_model=CarListResponse)
def get_cars(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
maker_id: Optional[int] = None,
model_id: Optional[int] = 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,
status: Optional[str] = None,
is_displayed: Optional[bool] = None,
admin: bool = Query(False, description="Admin mode - show all cars"),
db: Session = Depends(get_db),
):
"""차량 목록 조회"""
# Base query for filtering (without eager loading for count)
base_query = db.query(Car)
# For non-admin (user-facing), only show displayed cars
if not admin:
base_query = base_query.filter(Car.is_displayed == True)
# status 필터 (None이면 전체 조회)
if status:
base_query = base_query.filter(Car.status == status)
# is_displayed 필터 (admin mode에서만 의미있음)
if is_displayed is not None and admin:
base_query = base_query.filter(Car.is_displayed == is_displayed)
if maker_id:
base_query = base_query.filter(Car.maker_id == maker_id)
if model_id:
base_query = base_query.filter(Car.model_id == model_id)
if year_min:
base_query = base_query.filter(Car.year >= year_min)
if year_max:
base_query = base_query.filter(Car.year <= year_max)
if price_min:
base_query = base_query.filter(Car.price_krw >= price_min)
if price_max:
base_query = base_query.filter(Car.price_krw <= price_max)
if mileage_max:
base_query = base_query.filter(Car.mileage <= mileage_max)
if fuel:
base_query = base_query.filter(Car.fuel == fuel)
total = base_query.count()
# Add eager loading for actual data fetch
cars = base_query.options(
joinedload(Car.maker),
joinedload(Car.model),
joinedload(Car.images)
).order_by(Car.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# Convert to response with computed fields
cars_response = [car_to_response(car) for car in cars]
return CarListResponse(
total=total,
page=page,
page_size=page_size,
cars=cars_response
)
@router.get("/{car_id}", response_model=CarResponse)
def get_car(car_id: int, admin: bool = Query(False), db: Session = Depends(get_db)):
"""차량 상세 조회"""
car = db.query(Car).options(
joinedload(Car.maker),
joinedload(Car.model),
joinedload(Car.images),
joinedload(Car.specification)
).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
# Non-admin can only see displayed cars
if not admin and not car.is_displayed:
raise HTTPException(status_code=404, detail="Car not found")
return car_to_response(car)
@router.post("", response_model=CarResponse)
def create_car(car_data: CarCreate, db: Session = Depends(get_db)):
"""차량 등록 (Agent용)"""
# Check if car already exists
existing = db.query(Car).filter(
Car.source == car_data.source,
Car.source_id == car_data.source_id
).first()
if existing:
raise HTTPException(status_code=400, detail="Car already exists")
# Get maker and model IDs
maker_id = None
model_id = None
if car_data.maker_code:
maker = db.query(CarMaker).filter(CarMaker.code == car_data.maker_code).first()
if maker:
maker_id = maker.id
if car_data.model_code and maker_id:
model = db.query(CarModel).filter(
CarModel.code == car_data.model_code,
CarModel.maker_id == maker_id
).first()
if model:
model_id = model.id
# Create car
car = Car(
source=car_data.source,
source_id=car_data.source_id,
source_key=car_data.source_key,
maker_id=maker_id,
model_id=model_id,
car_name=car_data.car_name,
year=car_data.year,
month=car_data.month,
mileage=car_data.mileage,
price_krw=car_data.price_krw,
price_usd=car_data.price_usd,
fuel=car_data.fuel,
transmission=car_data.transmission,
color=car_data.color,
displacement=car_data.displacement,
car_number=car_data.car_number,
seize_count=car_data.seize_count,
collateral_count=car_data.collateral_count,
check_num=car_data.check_num,
dealer_name=car_data.dealer_name,
dealer_phone=car_data.dealer_phone,
shop_name=car_data.shop_name,
memo=car_data.memo,
)
db.add(car)
db.flush()
# Add images
for i, img in enumerate(car_data.images):
car_image = CarImage(
car_id=car.id,
url=img.url,
local_path=img.local_path,
is_main=(i == 0),
sort_order=i
)
db.add(car_image)
# Add options
for opt in car_data.options:
car_option = CarOption(car_id=car.id, option_name=opt)
db.add(car_option)
db.commit()
db.refresh(car)
return car
@router.put("/{car_id}", response_model=CarResponse)
def update_car(car_id: int, car_data: CarUpdate, db: Session = Depends(get_db)):
"""차량 정보 수정"""
car = db.query(Car).options(
joinedload(Car.maker),
joinedload(Car.model),
joinedload(Car.images),
joinedload(Car.specification)
).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
for key, value in car_data.dict(exclude_unset=True).items():
setattr(car, key, value)
db.commit()
db.refresh(car)
return car_to_response(car)
@router.delete("/{car_id}")
def delete_car(car_id: int, db: Session = Depends(get_db)):
"""차량 삭제 (관련 데이터 포함)"""
print(f"[DELETE] Deleting car {car_id}")
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
print(f"[DELETE] Car {car_id} not found")
raise HTTPException(status_code=404, detail="Car not found")
try:
# 관련 테이블 데이터 삭제
from ..models.car import CarImage, CarOption
from ..models.performance_check import CarPerformanceCheck
from ..models.car_specification import CarSpecification
from ..models.hero_banner import HeroBanner
from ..models.user import CarView, PerformanceCheckView
from sqlalchemy import text
# 이미지 삭제
img_count = db.query(CarImage).filter(CarImage.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {img_count} images")
# 옵션 삭제
opt_count = db.query(CarOption).filter(CarOption.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {opt_count} options")
# 성능점검 삭제
pc_count = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {pc_count} performance checks")
# 사양 삭제
spec_count = db.query(CarSpecification).filter(CarSpecification.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {spec_count} specifications")
# 차량 조회 기록 삭제
cv_count = db.query(CarView).filter(CarView.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {cv_count} car views")
# 성능점검 조회 기록 삭제
pcv_count = db.query(PerformanceCheckView).filter(PerformanceCheckView.car_id == car_id).delete(synchronize_session=False)
print(f"[DELETE] Deleted {pcv_count} performance check views")
# 문의 기록에서 car_id 제거 (raw SQL로 실행하여 모델 스키마 검증 방지)
result = db.execute(text("UPDATE inquiries SET car_id = NULL WHERE car_id = :car_id"), {"car_id": car_id})
inq_count = result.rowcount
print(f"[DELETE] Unlinked {inq_count} inquiries")
# 배너에서 car_id 제거 (배너는 삭제하지 않고 연결만 해제)
banner_count = db.query(HeroBanner).filter(HeroBanner.car_id == car_id).update({"car_id": None}, synchronize_session=False)
print(f"[DELETE] Unlinked {banner_count} banners")
# 차량 삭제
db.delete(car)
db.commit()
print(f"[DELETE] Car {car_id} deleted successfully")
return {"message": "Car deleted"}
except Exception as e:
db.rollback()
import traceback
error_trace = traceback.format_exc()
print(f"[DELETE] Error deleting car {car_id}: {e}\n{error_trace}")
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
# Makers
@router.get("/makers/", response_model=List[CarMakerResponse])
def get_makers(db: Session = Depends(get_db)):
"""제조사 목록 조회"""
return db.query(CarMaker).all()
@router.post("/makers/", response_model=CarMakerResponse)
def create_maker(maker_data: CarMakerCreate, db: Session = Depends(get_db)):
"""제조사 등록"""
existing = db.query(CarMaker).filter(CarMaker.code == maker_data.code).first()
if existing:
return existing
maker = CarMaker(**maker_data.dict())
db.add(maker)
db.commit()
db.refresh(maker)
return maker
# Models
@router.get("/models/", response_model=List[CarModelResponse])
def get_models(maker_id: Optional[int] = None, db: Session = Depends(get_db)):
"""모델 목록 조회"""
query = db.query(CarModel)
if maker_id:
query = query.filter(CarModel.maker_id == maker_id)
return query.all()
@router.post("/models/", response_model=CarModelResponse)
def create_model(model_data: CarModelCreate, db: Session = Depends(get_db)):
"""모델 등록"""
existing = db.query(CarModel).filter(
CarModel.code == model_data.code,
CarModel.maker_id == model_data.maker_id
).first()
if existing:
return existing
model = CarModel(**model_data.dict())
db.add(model)
db.commit()
db.refresh(model)
return model

886
backend/app/api/cc.py Normal file
View File

@@ -0,0 +1,886 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Header
from sqlalchemy.orm import Session
from sqlalchemy import desc
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
import stripe
import logging
from ..database import get_db
from ..models import User, Car, CarView, PerformanceCheckView, ChargeHistory, CarPerformanceCheck, CCPackage, DEFAULT_CC_PACKAGES
from ..models.settings import SystemSettings
from ..models.user import PaymentSettings
from ..schemas import UserResponse, CarViewResponse, PurchaseViewRequest
from .auth import get_current_user, get_current_admin_user, get_current_user_optional
from .referral import create_referral_reward
from .carmodoo import CarmodooClient, capture_performance_check_pdf
from .notification import notify_system
from ..config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
# Configure Stripe
stripe.api_key = settings.STRIPE_SECRET_KEY
router = APIRouter(prefix="/cc", tags=["cc"])
class ChargeRequest(BaseModel):
amount: int
currency: str = "USD"
payment_method: str = "card"
transaction_id: Optional[str] = None # For crypto payments
wallet_address: Optional[str] = None # User's wallet for refunds
class USDCChargeRequest(BaseModel):
amount_usdc: int
transaction_hash: str
wallet_address: str
network: str = "Polygon"
class ChargeHistoryResponse(BaseModel):
id: int
amount_usd: int
cc_amount: int
payment_method: str
status: str
created_at: str
class Config:
from_attributes = True
@router.get("/balance")
def get_cc_balance(current_user: User = Depends(get_current_user)):
"""Get current user's CC balance"""
return {"cc_balance": current_user.cc_balance or 0}
@router.get("/views", response_model=List[CarViewResponse])
def get_purchased_views(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get list of cars the user has paid to view"""
views = db.query(CarView).filter(CarView.user_id == current_user.id).all()
return views
@router.get("/views/car-ids")
def get_purchased_car_ids(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get list of car IDs the user has paid to view (for quick lookup)"""
views = db.query(CarView.car_id).filter(CarView.user_id == current_user.id).all()
return {"car_ids": [v[0] for v in views]}
@router.post("/purchase-view")
def purchase_car_view(
request: PurchaseViewRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Purchase access to view full car details (costs 1 CC)"""
car_id = request.car_id
# Check if car exists
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
# Check if already purchased
existing_view = db.query(CarView).filter(
CarView.user_id == current_user.id,
CarView.car_id == car_id
).first()
if existing_view:
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
# Check if user has enough CC
if (current_user.cc_balance or 0) < 1:
raise HTTPException(
status_code=400,
detail="Insufficient CC balance. You need 1 CC to view full car details."
)
# Deduct CC and create view record
current_user.cc_balance = (current_user.cc_balance or 0) - 1
car_view = CarView(
user_id=current_user.id,
car_id=car_id,
cc_paid=1
)
db.add(car_view)
db.commit()
return {
"message": "Purchase successful",
"cc_balance": current_user.cc_balance,
"car_id": car_id
}
@router.get("/check-view/{car_id}")
def check_car_view(
car_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Check if user has purchased view access for a specific car"""
existing_view = db.query(CarView).filter(
CarView.user_id == current_user.id,
CarView.car_id == car_id
).first()
return {
"has_access": existing_view is not None,
"cc_balance": current_user.cc_balance or 0
}
PERFORMANCE_CHECK_COST = 0.1 # 0.1 CC for performance check view
@router.post("/purchase-performance-check")
async def purchase_performance_check_view(
request: PurchaseViewRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Purchase access to view performance check (costs 0.1 CC)"""
car_id = request.car_id
# Check if car exists
car = db.query(Car).filter(Car.id == car_id).first()
if not car:
raise HTTPException(status_code=404, detail="Car not found")
# Check if performance check record exists
perf_check = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.car_id == car_id
).first()
# If no performance check record, try to fetch from Carmodoo
if not perf_check:
try:
carmodoo_client = CarmodooClient()
check_num = car.check_num or ""
# Try to get check_num if not available
if not check_num:
check_num = await carmodoo_client.get_car_check_num(car.source_id, car.source_key or "")
if check_num:
# Fetch performance check data
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"]
# Create CarPerformanceCheck record
perf_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(perf_check)
db.flush()
# Capture PDF
try:
pdf_path = await capture_performance_check_pdf(perf_check.check_number, car.id)
if pdf_path:
perf_check.pdf_path = pdf_path
except Exception as pdf_error:
logger.warning(f"PDF capture failed: {pdf_error}")
db.commit()
db.refresh(perf_check)
except Exception as e:
logger.error(f"Failed to fetch performance check: {e}")
if not perf_check:
raise HTTPException(status_code=404, detail="Performance check not available for this car")
# Check if already purchased
existing_view = db.query(PerformanceCheckView).filter(
PerformanceCheckView.user_id == current_user.id,
PerformanceCheckView.car_id == car_id
).first()
if existing_view:
return {"message": "Already purchased", "cc_balance": current_user.cc_balance}
# Check if user has enough CC
if (current_user.cc_balance or 0) < PERFORMANCE_CHECK_COST:
raise HTTPException(
status_code=400,
detail=f"Insufficient CC balance. You need {PERFORMANCE_CHECK_COST} CC to view performance check."
)
# Deduct CC and create view record
current_user.cc_balance = (current_user.cc_balance or 0) - PERFORMANCE_CHECK_COST
perf_view = PerformanceCheckView(
user_id=current_user.id,
car_id=car_id,
cc_paid=PERFORMANCE_CHECK_COST
)
db.add(perf_view)
db.commit()
return {
"message": "Purchase successful",
"cc_balance": current_user.cc_balance,
"car_id": car_id
}
@router.get("/check-performance-check/{car_id}")
def check_performance_check_view(
car_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Check if user has purchased performance check view for a specific car"""
# Check if performance check exists for this car
perf_check = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.car_id == car_id
).first()
# 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 free
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)
return {
"has_access": has_access,
"has_performance_check": perf_check is not None,
"cc_balance": current_user.cc_balance or 0,
"cost": PERFORMANCE_CHECK_COST,
"included_in_car_view": existing_car_view is not None # True if already purchased car view
}
@router.get("/payment-info")
def get_payment_info():
"""Get payment information including USDC wallet address"""
return {
"usdc_wallet_address": PaymentSettings.USDC_WALLET_ADDRESS,
"usdc_network": PaymentSettings.USDC_NETWORK,
"min_charge_usd": PaymentSettings.MIN_CHARGE_USD,
"max_charge_usd": PaymentSettings.MAX_CHARGE_USD,
"supported_currencies": PaymentSettings.SUPPORTED_CURRENCIES,
"supported_methods": PaymentSettings.SUPPORTED_METHODS,
"rate": "1 USD = 1 CC",
}
@router.post("/charge")
def charge_cc(
request: ChargeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a charge request (for card or bank transfer - requires admin verification)"""
# Validate amount
if request.amount < PaymentSettings.MIN_CHARGE_USD:
raise HTTPException(status_code=400, detail=f"Minimum charge amount is ${PaymentSettings.MIN_CHARGE_USD}")
if request.amount > PaymentSettings.MAX_CHARGE_USD:
raise HTTPException(status_code=400, detail=f"Maximum charge amount is ${PaymentSettings.MAX_CHARGE_USD}")
# Calculate CC amount (1 USD = 1 CC)
cc_amount = request.amount
# Determine status based on payment method
# Card payments would go through a payment gateway (not implemented yet)
# USDC and bank transfers require manual verification
status = "pending" if request.payment_method in ["usdc", "bank_transfer"] else "pending"
# Create charge history record
charge_record = ChargeHistory(
user_id=current_user.id,
amount=request.amount,
amount_usd=request.amount, # Backwards compatibility
cc_amount=cc_amount,
currency=request.currency,
payment_method=request.payment_method,
transaction_id=request.transaction_id,
wallet_address=request.wallet_address,
status=status
)
db.add(charge_record)
db.commit()
db.refresh(charge_record)
return {
"message": "Charge request created" if status == "pending" else "Charge successful",
"charge_id": charge_record.id,
"amount": request.amount,
"currency": request.currency,
"cc_amount": cc_amount,
"status": status,
"payment_info": {
"usdc_wallet": PaymentSettings.USDC_WALLET_ADDRESS if request.payment_method == "usdc" else None,
"network": PaymentSettings.USDC_NETWORK if request.payment_method == "usdc" else None,
}
}
@router.post("/charge/usdc")
def charge_cc_usdc(
request: USDCChargeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create USDC charge request with transaction hash"""
# Validate amount
if request.amount_usdc < PaymentSettings.MIN_CHARGE_USD:
raise HTTPException(status_code=400, detail=f"Minimum charge amount is {PaymentSettings.MIN_CHARGE_USD} USDC")
if request.amount_usdc > PaymentSettings.MAX_CHARGE_USD:
raise HTTPException(status_code=400, detail=f"Maximum charge amount is {PaymentSettings.MAX_CHARGE_USD} USDC")
# Check for duplicate transaction
existing = db.query(ChargeHistory).filter(
ChargeHistory.transaction_id == request.transaction_hash
).first()
if existing:
raise HTTPException(status_code=400, detail="This transaction has already been submitted")
# Create pending charge record
charge_record = ChargeHistory(
user_id=current_user.id,
amount=request.amount_usdc,
amount_usd=request.amount_usdc,
cc_amount=request.amount_usdc,
currency="USDC",
payment_method="usdc",
transaction_id=request.transaction_hash,
wallet_address=request.wallet_address,
status="pending"
)
db.add(charge_record)
db.commit()
db.refresh(charge_record)
return {
"message": "USDC payment submitted for verification",
"charge_id": charge_record.id,
"amount_usdc": request.amount_usdc,
"cc_amount": request.amount_usdc,
"status": "pending",
"transaction_hash": request.transaction_hash
}
@router.get("/charge-history")
def get_charge_history(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's charge history"""
history = db.query(ChargeHistory).filter(
ChargeHistory.user_id == current_user.id
).order_by(desc(ChargeHistory.created_at)).limit(50).all()
return [
{
"id": h.id,
"amount": h.amount or h.amount_usd,
"amount_usd": h.amount_usd,
"currency": h.currency or "USD",
"cc_amount": h.cc_amount,
"payment_method": h.payment_method,
"transaction_id": h.transaction_id,
"status": h.status,
"created_at": h.created_at.isoformat() if h.created_at else None
}
for h in history
]
# Admin endpoints for payment verification
@router.get("/admin/pending")
def admin_get_pending_payments(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get all pending payment requests (Admin only)"""
pending = db.query(ChargeHistory).filter(
ChargeHistory.status == "pending"
).order_by(desc(ChargeHistory.created_at)).all()
return [
{
"id": h.id,
"user_id": h.user_id,
"user_email": h.user.email if h.user else None,
"user_name": h.user.name if h.user else None,
"amount": h.amount or h.amount_usd,
"currency": h.currency or "USD",
"cc_amount": h.cc_amount,
"payment_method": h.payment_method,
"transaction_id": h.transaction_id,
"wallet_address": h.wallet_address,
"status": h.status,
"created_at": h.created_at.isoformat() if h.created_at else None
}
for h in pending
]
@router.get("/admin/all")
def admin_get_all_payments(
status: str = None,
page: int = 1,
page_size: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get all payment records with optional status filter (Admin only)"""
query = db.query(ChargeHistory)
if status:
query = query.filter(ChargeHistory.status == status)
total = query.count()
payments = query.order_by(desc(ChargeHistory.created_at)).offset((page - 1) * page_size).limit(page_size).all()
return {
"payments": [
{
"id": h.id,
"user_id": h.user_id,
"user_email": h.user.email if h.user else None,
"user_name": h.user.name if h.user else None,
"amount": h.amount or h.amount_usd,
"currency": h.currency or "USD",
"cc_amount": h.cc_amount,
"payment_method": h.payment_method,
"transaction_id": h.transaction_id,
"wallet_address": h.wallet_address,
"admin_note": h.admin_note,
"status": h.status,
"verified_at": h.verified_at.isoformat() if h.verified_at else None,
"created_at": h.created_at.isoformat() if h.created_at else None
}
for h in payments
],
"total": total,
"page": page,
"page_size": page_size
}
@router.put("/admin/{charge_id}/verify")
def admin_verify_payment(
charge_id: int,
approved: bool,
admin_note: str = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Verify and approve/reject a pending payment (Admin only)"""
charge = db.query(ChargeHistory).filter(ChargeHistory.id == charge_id).first()
if not charge:
raise HTTPException(status_code=404, detail="Charge record not found")
if charge.status != "pending":
raise HTTPException(status_code=400, detail=f"Charge is already {charge.status}")
if approved:
charge.status = "completed"
charge.verified_at = datetime.utcnow()
charge.verified_by = current_user.id
charge.admin_note = admin_note
# Credit CC to user
user = db.query(User).filter(User.id == charge.user_id).first()
if user:
user.cc_balance = (user.cc_balance or 0) + charge.cc_amount
# Trigger referral reward if applicable
if user.referred_by:
referrer = db.query(User).filter(
User.referral_code == user.referred_by
).first()
if referrer:
create_referral_reward(
referrer_id=referrer.id,
referred_user_id=user.id,
payment_amount=charge.amount_usd or charge.amount,
db=db
)
# Send notification to user
notify_system(
db,
user.id,
"Payment Confirmed",
f"Your payment of {charge.amount} {charge.currency or 'USD'} has been confirmed. {charge.cc_amount} CC has been added to your balance.",
"/profile"
)
else:
charge.status = "rejected"
charge.verified_at = datetime.utcnow()
charge.verified_by = current_user.id
charge.admin_note = admin_note
# Send notification to user
user = db.query(User).filter(User.id == charge.user_id).first()
if user:
notify_system(
db,
user.id,
"Payment Rejected",
f"Your payment request for {charge.amount} {charge.currency or 'USD'} was rejected. Reason: {admin_note or 'No reason provided'}",
"/profile"
)
db.commit()
return {
"message": f"Payment {'approved' if approved else 'rejected'}",
"charge_id": charge_id,
"new_status": charge.status
}
# ============================================
# Stripe Payment Endpoints
# ============================================
class CreateCheckoutRequest(BaseModel):
package_id: int
@router.get("/stripe-key")
def get_stripe_publishable_key():
"""Get Stripe publishable key for frontend"""
return {"publishable_key": settings.STRIPE_PUBLISHABLE_KEY}
@router.get("/packages")
def get_cc_packages(db: Session = Depends(get_db)):
"""Get available CC packages"""
# Get system settings for cars_per_cc
system_settings = db.query(SystemSettings).first()
cars_per_cc = system_settings.cars_per_cc if system_settings and system_settings.cars_per_cc else 3
# First try to get from database
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
# If no packages in DB, initialize with defaults
if not packages:
for pkg_data in DEFAULT_CC_PACKAGES:
pkg = CCPackage(**pkg_data)
db.add(pkg)
db.commit()
packages = db.query(CCPackage).filter(CCPackage.is_active == True).order_by(CCPackage.sort_order).all()
return [
{
"id": pkg.id,
"name": pkg.name,
"price_usd": pkg.price_usd,
"cc_amount": pkg.cc_amount,
"bonus_cc": pkg.bonus_cc,
"total_cc": pkg.cc_amount + pkg.bonus_cc,
"discount_percent": pkg.discount_percent,
"recommendations": (pkg.cc_amount + pkg.bonus_cc) * cars_per_cc,
"cars_per_cc": cars_per_cc, # 프론트엔드에서 표시용
}
for pkg in packages
]
@router.post("/create-checkout-session")
def create_checkout_session(
request: CreateCheckoutRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create Stripe checkout session for CC purchase"""
if not settings.STRIPE_SECRET_KEY:
raise HTTPException(status_code=500, detail="Stripe is not configured")
# Get package
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
if not package:
raise HTTPException(status_code=404, detail="Package not found")
if not package.is_active:
raise HTTPException(status_code=400, detail="This package is no longer available")
try:
# Create Stripe Checkout Session
checkout_session = stripe.checkout.Session.create(
payment_method_types=["card"],
line_items=[
{
"price_data": {
"currency": "usd",
"unit_amount": package.price_usd * 100, # Stripe uses cents
"product_data": {
"name": f"AutonetSellCar CC - {package.name}",
"description": f"{package.cc_amount + package.bonus_cc} CC ({package.cc_amount} + {package.bonus_cc} bonus)",
},
},
"quantity": 1,
}
],
mode="payment",
success_url=f"{settings.STRIPE_SUCCESS_URL}?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=settings.STRIPE_CANCEL_URL,
client_reference_id=str(current_user.id),
metadata={
"user_id": str(current_user.id),
"package_id": str(package.id),
"cc_amount": str(package.cc_amount),
"bonus_cc": str(package.bonus_cc),
},
customer_email=current_user.email,
)
# Create pending charge record
charge_record = ChargeHistory(
user_id=current_user.id,
package_id=package.id,
amount=package.price_usd,
amount_usd=package.price_usd,
cc_amount=package.cc_amount,
bonus_cc=package.bonus_cc,
currency="USD",
payment_method="stripe",
stripe_session_id=checkout_session.id,
status="pending"
)
db.add(charge_record)
db.commit()
return {
"checkout_url": checkout_session.url,
"session_id": checkout_session.id
}
except stripe.error.StripeError as e:
logger.error(f"Stripe error: {e}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/webhook")
async def stripe_webhook(
request: Request,
db: Session = Depends(get_db)
):
"""Handle Stripe webhook events"""
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not settings.STRIPE_WEBHOOK_SECRET:
logger.warning("Stripe webhook secret not configured")
raise HTTPException(status_code=500, detail="Webhook not configured")
try:
event = stripe.Webhook.construct_event(
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
)
except ValueError as e:
logger.error(f"Invalid payload: {e}")
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.error.SignatureVerificationError as e:
logger.error(f"Invalid signature: {e}")
raise HTTPException(status_code=400, detail="Invalid signature")
# Handle the checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
# Get charge record by session ID
charge = db.query(ChargeHistory).filter(
ChargeHistory.stripe_session_id == session["id"]
).first()
if charge and charge.status == "pending":
# Update charge record
charge.status = "completed"
charge.stripe_payment_intent_id = session.get("payment_intent")
charge.verified_at = datetime.utcnow()
# Credit CC to user
user = db.query(User).filter(User.id == charge.user_id).first()
if user:
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
user.cc_balance = (user.cc_balance or 0) + total_cc
# Trigger referral reward if applicable
if user.referred_by:
referrer = db.query(User).filter(
User.referral_code == user.referred_by
).first()
if referrer:
create_referral_reward(
referrer_id=referrer.id,
referred_user_id=user.id,
payment_amount=charge.amount_usd or charge.amount,
db=db
)
# Send notification
notify_system(
db,
user.id,
"CC Purchase Successful",
f"Your purchase of {total_cc} CC has been completed. Your new balance is {user.cc_balance} CC.",
"/cc"
)
logger.info(f"CC credited: user={user.id}, amount={total_cc}")
db.commit()
elif event["type"] == "checkout.session.expired":
session = event["data"]["object"]
# Update charge record to cancelled
charge = db.query(ChargeHistory).filter(
ChargeHistory.stripe_session_id == session["id"]
).first()
if charge and charge.status == "pending":
charge.status = "cancelled"
db.commit()
return {"status": "success"}
@router.get("/checkout-success")
def checkout_success(
session_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Verify checkout session and return result"""
# Find charge record
charge = db.query(ChargeHistory).filter(
ChargeHistory.stripe_session_id == session_id,
ChargeHistory.user_id == current_user.id
).first()
if not charge:
raise HTTPException(status_code=404, detail="Payment record not found")
# If still pending, try to verify with Stripe
if charge.status == "pending":
try:
session = stripe.checkout.Session.retrieve(session_id)
if session.payment_status == "paid":
charge.status = "completed"
charge.stripe_payment_intent_id = session.payment_intent
charge.verified_at = datetime.utcnow()
# Credit CC
total_cc = charge.cc_amount + (charge.bonus_cc or 0)
current_user.cc_balance = (current_user.cc_balance or 0) + total_cc
db.commit()
except stripe.error.StripeError as e:
logger.error(f"Error verifying session: {e}")
return {
"status": charge.status,
"cc_amount": charge.cc_amount,
"bonus_cc": charge.bonus_cc or 0,
"total_cc": charge.cc_amount + (charge.bonus_cc or 0),
"cc_balance": current_user.cc_balance or 0
}
# Manual CC charge request (for Russian users via Mongolian partner)
class ManualChargeRequest(BaseModel):
package_id: int
payment_note: Optional[str] = None # e.g., "Paid via Mongolian partner bank"
@router.post("/manual-request")
def create_manual_charge_request(
request: ManualChargeRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create manual CC charge request (for Russian users)"""
# Get package
package = db.query(CCPackage).filter(CCPackage.id == request.package_id).first()
if not package:
raise HTTPException(status_code=404, detail="Package not found")
# Create pending charge record
charge_record = ChargeHistory(
user_id=current_user.id,
package_id=package.id,
amount=package.price_usd,
amount_usd=package.price_usd,
cc_amount=package.cc_amount,
bonus_cc=package.bonus_cc,
currency="USD",
payment_method="manual",
admin_note=request.payment_note,
status="pending"
)
db.add(charge_record)
db.commit()
db.refresh(charge_record)
# Notify admins
admins = db.query(User).filter(User.is_admin == True).all()
for admin in admins:
notify_system(
db,
admin.id,
"New Manual CC Request",
f"User {current_user.email} requested {package.cc_amount} CC (${package.price_usd}). Payment method: manual.",
"/admin/cc"
)
return {
"message": "Manual charge request created. An admin will verify your payment.",
"charge_id": charge_record.id,
"package": {
"name": package.name,
"price_usd": package.price_usd,
"cc_amount": package.cc_amount + package.bonus_cc
},
"status": "pending"
}

View File

@@ -0,0 +1,443 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, desc
from datetime import datetime, timedelta
from typing import List, Optional
from pydantic import BaseModel
from ..database import get_db
from ..models import (
User, Car, Inquiry, InquiryStatus,
VehicleRequest, RequestVehicle, PurchasedVehicle,
DealerApplication, DealerInfo,
VehicleShare, ShareReward,
WithdrawalRequest,
ReferralReward,
HeroBanner,
ChargeHistory,
)
from .auth import get_current_admin_user
router = APIRouter(prefix="/dashboard", tags=["Dashboard"])
class DashboardStats(BaseModel):
total_users: int
new_users_today: int
new_users_this_week: int
total_dealers: int
pending_dealer_applications: int
total_cars: int
total_vehicle_requests: int
pending_requests: int
total_purchased_vehicles: int
total_inquiries: int
pending_inquiries: int
total_shares: int
purchased_shares: int
total_withdrawals: int
pending_withdrawals: int
total_cc_charged: float
total_withdrawal_amount: float
class RevenueStats(BaseModel):
total_revenue: float
revenue_this_month: float
revenue_last_month: float
platform_commission: float
dealer_commission: float
class ChartData(BaseModel):
labels: List[str]
values: List[int]
class DailyStats(BaseModel):
date: str
users: int
requests: int
purchases: int
revenue: float
class RecentActivity(BaseModel):
type: str
title: str
description: str
time: str
icon: str
class TopDealer(BaseModel):
id: int
name: str
dealer_code: str
total_sales: int
total_commission: float
@router.get("/stats", response_model=DashboardStats)
def get_dashboard_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get comprehensive dashboard statistics"""
today = datetime.utcnow().date()
week_ago = today - timedelta(days=7)
# User stats
total_users = db.query(func.count(User.id)).filter(User.is_admin == False).scalar() or 0
new_users_today = db.query(func.count(User.id)).filter(
and_(
User.is_admin == False,
func.date(User.created_at) == today
)
).scalar() or 0
new_users_this_week = db.query(func.count(User.id)).filter(
and_(
User.is_admin == False,
func.date(User.created_at) >= week_ago
)
).scalar() or 0
# Dealer stats
total_dealers = db.query(func.count(DealerInfo.id)).filter(DealerInfo.is_active == True).scalar() or 0
pending_dealer_applications = db.query(func.count(DealerApplication.id)).filter(
DealerApplication.status == "pending"
).scalar() or 0
# Car stats
total_cars = db.query(func.count(Car.id)).scalar() or 0
# Vehicle request stats
total_vehicle_requests = db.query(func.count(VehicleRequest.id)).scalar() or 0
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
VehicleRequest.status == "pending"
).scalar() or 0
# Purchased vehicles
total_purchased_vehicles = db.query(func.count(PurchasedVehicle.id)).scalar() or 0
# Inquiry stats
total_inquiries = db.query(func.count(Inquiry.id)).scalar() or 0
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
Inquiry.status == InquiryStatus.PENDING
).scalar() or 0
# Share stats
total_shares = db.query(func.count(VehicleShare.id)).scalar() or 0
purchased_shares = db.query(func.count(VehicleShare.id)).filter(
VehicleShare.is_purchased == True
).scalar() or 0
# Withdrawal stats
total_withdrawals = db.query(func.count(WithdrawalRequest.id)).scalar() or 0
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
WithdrawalRequest.status == "pending"
).scalar() or 0
# CC stats
total_cc_charged = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
ChargeHistory.status == "completed"
).scalar() or 0
total_withdrawal_amount = db.query(func.coalesce(func.sum(WithdrawalRequest.amount), 0)).filter(
WithdrawalRequest.status == "completed"
).scalar() or 0
return DashboardStats(
total_users=total_users,
new_users_today=new_users_today,
new_users_this_week=new_users_this_week,
total_dealers=total_dealers,
pending_dealer_applications=pending_dealer_applications,
total_cars=total_cars,
total_vehicle_requests=total_vehicle_requests,
pending_requests=pending_requests,
total_purchased_vehicles=total_purchased_vehicles,
total_inquiries=total_inquiries,
pending_inquiries=pending_inquiries,
total_shares=total_shares,
purchased_shares=purchased_shares,
total_withdrawals=total_withdrawals,
pending_withdrawals=pending_withdrawals,
total_cc_charged=float(total_cc_charged),
total_withdrawal_amount=float(total_withdrawal_amount),
)
@router.get("/revenue", response_model=RevenueStats)
def get_revenue_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get revenue statistics"""
today = datetime.utcnow().date()
this_month_start = today.replace(day=1)
last_month_end = this_month_start - timedelta(days=1)
last_month_start = last_month_end.replace(day=1)
# Total CC charged as revenue
total_revenue = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
ChargeHistory.status == "completed"
).scalar() or 0
revenue_this_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
and_(
ChargeHistory.status == "completed",
func.date(ChargeHistory.created_at) >= this_month_start
)
).scalar() or 0
revenue_last_month = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
and_(
ChargeHistory.status == "completed",
func.date(ChargeHistory.created_at) >= last_month_start,
func.date(ChargeHistory.created_at) <= last_month_end
)
).scalar() or 0
# Commission stats from purchased vehicles
platform_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.platform_commission), 0)).scalar() or 0
dealer_commission = db.query(func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0)).scalar() or 0
return RevenueStats(
total_revenue=float(total_revenue),
revenue_this_month=float(revenue_this_month),
revenue_last_month=float(revenue_last_month),
platform_commission=float(platform_commission),
dealer_commission=float(dealer_commission),
)
@router.get("/chart/users", response_model=ChartData)
def get_user_chart_data(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get user registration chart data for last N days"""
today = datetime.utcnow().date()
labels = []
values = []
for i in range(days - 1, -1, -1):
date = today - timedelta(days=i)
count = db.query(func.count(User.id)).filter(
and_(
User.is_admin == False,
func.date(User.created_at) == date
)
).scalar() or 0
labels.append(date.strftime("%m/%d"))
values.append(count)
return ChartData(labels=labels, values=values)
@router.get("/chart/requests", response_model=ChartData)
def get_request_chart_data(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get vehicle request chart data for last N days"""
today = datetime.utcnow().date()
labels = []
values = []
for i in range(days - 1, -1, -1):
date = today - timedelta(days=i)
count = db.query(func.count(VehicleRequest.id)).filter(
func.date(VehicleRequest.created_at) == date
).scalar() or 0
labels.append(date.strftime("%m/%d"))
values.append(count)
return ChartData(labels=labels, values=values)
@router.get("/chart/revenue", response_model=ChartData)
def get_revenue_chart_data(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get daily revenue chart data for last N days"""
today = datetime.utcnow().date()
labels = []
values = []
for i in range(days - 1, -1, -1):
date = today - timedelta(days=i)
amount = db.query(func.coalesce(func.sum(ChargeHistory.amount), 0)).filter(
and_(
ChargeHistory.status == "completed",
func.date(ChargeHistory.created_at) == date
)
).scalar() or 0
labels.append(date.strftime("%m/%d"))
values.append(int(amount))
return ChartData(labels=labels, values=values)
@router.get("/recent-activities", response_model=List[RecentActivity])
def get_recent_activities(
limit: int = 10,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get recent activities across the platform"""
activities = []
# Recent user registrations
recent_users = db.query(User).filter(User.is_admin == False).order_by(
desc(User.created_at)
).limit(3).all()
for user in recent_users:
activities.append({
"type": "user",
"title": "New User Registration",
"description": f"{user.name or user.email} joined the platform",
"time": user.created_at.isoformat() if user.created_at else "",
"icon": "user"
})
# Recent vehicle requests
recent_requests = db.query(VehicleRequest).order_by(
desc(VehicleRequest.created_at)
).limit(3).all()
for req in recent_requests:
activities.append({
"type": "request",
"title": "Vehicle Request",
"description": f"Request #{req.id} - {req.status}",
"time": req.created_at.isoformat() if req.created_at else "",
"icon": "car"
})
# Recent inquiries
recent_inquiries = db.query(Inquiry).order_by(
desc(Inquiry.created_at)
).limit(3).all()
for inq in recent_inquiries:
activities.append({
"type": "inquiry",
"title": "New Inquiry",
"description": f"{inq.subject or 'General inquiry'} - {inq.status}",
"time": inq.created_at.isoformat() if inq.created_at else "",
"icon": "message"
})
# Recent dealer applications
recent_applications = db.query(DealerApplication).filter(
DealerApplication.status == "pending"
).order_by(desc(DealerApplication.applied_at)).limit(2).all()
for app in recent_applications:
activities.append({
"type": "dealer",
"title": "Dealer Application",
"description": f"{app.real_name} ({app.business_name}) applied",
"time": app.applied_at.isoformat() if app.applied_at else "",
"icon": "badge"
})
# Recent withdrawals
recent_withdrawals = db.query(WithdrawalRequest).filter(
WithdrawalRequest.status == "pending"
).order_by(desc(WithdrawalRequest.requested_at)).limit(2).all()
for wd in recent_withdrawals:
activities.append({
"type": "withdrawal",
"title": "Withdrawal Request",
"description": f"{wd.amount:,.0f} withdrawal requested",
"time": wd.requested_at.isoformat() if wd.requested_at else "",
"icon": "wallet"
})
# Sort by time
activities.sort(key=lambda x: x["time"], reverse=True)
return [RecentActivity(**a) for a in activities[:limit]]
@router.get("/top-dealers", response_model=List[TopDealer])
def get_top_dealers(
limit: int = 5,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get top performing dealers"""
# Get dealers with their stats
dealers = db.query(
DealerInfo,
User.name,
func.count(PurchasedVehicle.id).label("sales_count"),
func.coalesce(func.sum(PurchasedVehicle.dealer_commission), 0).label("total_commission")
).join(
User, DealerInfo.user_id == User.id
).outerjoin(
PurchasedVehicle, DealerInfo.user_id == PurchasedVehicle.selected_dealer_id
).filter(
DealerInfo.is_active == True
).group_by(
DealerInfo.id, User.name
).order_by(
desc("sales_count")
).limit(limit).all()
return [
TopDealer(
id=dealer.DealerInfo.id,
name=dealer.name or "Unknown",
dealer_code=dealer.DealerInfo.dealer_code,
total_sales=dealer.sales_count,
total_commission=float(dealer.total_commission)
)
for dealer in dealers
]
@router.get("/pending-actions")
def get_pending_actions(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get counts of pending items requiring admin action"""
pending_requests = db.query(func.count(VehicleRequest.id)).filter(
VehicleRequest.status == "pending"
).scalar() or 0
pending_inquiries = db.query(func.count(Inquiry.id)).filter(
Inquiry.status == InquiryStatus.PENDING
).scalar() or 0
pending_dealer_apps = db.query(func.count(DealerApplication.id)).filter(
DealerApplication.status == "pending"
).scalar() or 0
pending_withdrawals = db.query(func.count(WithdrawalRequest.id)).filter(
WithdrawalRequest.status == "pending"
).scalar() or 0
return {
"pending_requests": pending_requests,
"pending_inquiries": pending_inquiries,
"pending_dealer_applications": pending_dealer_apps,
"pending_withdrawals": pending_withdrawals,
"total_pending": pending_requests + pending_inquiries + pending_dealer_apps + pending_withdrawals
}

254
backend/app/api/dealer.py Normal file
View File

@@ -0,0 +1,254 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime
from typing import List
from ..database import get_db
from ..models import User, DealerApplication, DealerInfo
from ..models.dealer import generate_dealer_code
from ..schemas import (
DealerApplicationCreate, DealerApplicationResponse,
DealerApplicationReject, DealerInfoResponse, DealerPublicInfo,
)
from .auth import get_current_user
from .notification import notify_dealer_approved, notify_dealer_rejected
router = APIRouter(prefix="/dealer", tags=["dealer"])
@router.post("/apply", response_model=DealerApplicationResponse)
def apply_dealer(
application: DealerApplicationCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Submit a dealer application"""
# Check if user already has a pending or approved application
existing = db.query(DealerApplication).filter(
DealerApplication.user_id == current_user.id,
DealerApplication.status.in_(["pending", "approved"])
).first()
if existing:
if existing.status == "approved":
raise HTTPException(status_code=400, detail="You are already a dealer")
raise HTTPException(status_code=400, detail="You already have a pending application")
# Check if user is already a dealer
if current_user.is_dealer:
raise HTTPException(status_code=400, detail="You are already a dealer")
# Create new application
new_application = DealerApplication(
user_id=current_user.id,
business_name=application.business_name,
business_number=application.business_number,
real_name=application.real_name,
id_number_encrypted=application.id_number, # TODO: Encrypt this properly
phone=application.phone,
bank_name=application.bank_name,
bank_account=application.bank_account,
account_holder=application.account_holder,
photo_url=application.photo_url,
status="pending"
)
db.add(new_application)
db.commit()
db.refresh(new_application)
return new_application
@router.get("/my-application", response_model=DealerApplicationResponse)
def get_my_application(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's dealer application"""
application = db.query(DealerApplication).filter(
DealerApplication.user_id == current_user.id
).order_by(DealerApplication.applied_at.desc()).first()
if not application:
raise HTTPException(status_code=404, detail="No application found")
return application
@router.get("/my-info", response_model=DealerInfoResponse)
def get_my_dealer_info(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's dealer info (if approved)"""
if not current_user.is_dealer:
raise HTTPException(status_code=403, detail="You are not a dealer")
dealer_info = db.query(DealerInfo).filter(
DealerInfo.user_id == current_user.id
).first()
if not dealer_info:
raise HTTPException(status_code=404, detail="Dealer info not found")
return dealer_info
@router.get("/list", response_model=List[DealerPublicInfo])
def list_dealers(
db: Session = Depends(get_db)
):
"""Get list of active dealers (public info only)"""
dealers = db.query(DealerInfo).filter(
DealerInfo.is_active == True
).all()
return dealers
# Admin endpoints
@router.get("/admin/applications", response_model=List[DealerApplicationResponse])
def get_applications(
status_filter: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all dealer applications"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
query = db.query(DealerApplication)
if status_filter:
query = query.filter(DealerApplication.status == status_filter)
applications = query.order_by(DealerApplication.applied_at.desc()).all()
return applications
@router.put("/admin/applications/{application_id}/approve", response_model=DealerInfoResponse)
def approve_application(
application_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Approve a dealer application"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
application = db.query(DealerApplication).filter(
DealerApplication.id == application_id
).first()
if not application:
raise HTTPException(status_code=404, detail="Application not found")
if application.status != "pending":
raise HTTPException(status_code=400, detail="Application is not pending")
# Generate unique dealer code
dealer_code = generate_dealer_code()
while db.query(DealerInfo).filter(DealerInfo.dealer_code == dealer_code).first():
dealer_code = generate_dealer_code()
# Create dealer info
dealer_info = DealerInfo(
user_id=application.user_id,
dealer_code=dealer_code,
business_name=application.business_name,
real_name=application.real_name,
phone=application.phone,
photo_url=application.photo_url,
bank_name=application.bank_name,
bank_account=application.bank_account,
account_holder=application.account_holder,
)
# Update application status
application.status = "approved"
application.approved_at = datetime.utcnow()
# Update user is_dealer flag
user = db.query(User).filter(User.id == application.user_id).first()
user.is_dealer = True
db.add(dealer_info)
db.commit()
db.refresh(dealer_info)
# TODO: Generate dealer card image here
# dealer_info.dealer_card_url = generate_dealer_card(dealer_info)
# db.commit()
# Send notification to user about dealer approval
notify_dealer_approved(db, application.user_id, dealer_code)
return dealer_info
@router.put("/admin/applications/{application_id}/reject")
def reject_application(
application_id: int,
reject_data: DealerApplicationReject,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Reject a dealer application"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
application = db.query(DealerApplication).filter(
DealerApplication.id == application_id
).first()
if not application:
raise HTTPException(status_code=404, detail="Application not found")
if application.status != "pending":
raise HTTPException(status_code=400, detail="Application is not pending")
application.status = "rejected"
application.rejected_reason = reject_data.reason
db.commit()
# Send notification to user about dealer rejection
notify_dealer_rejected(db, application.user_id, reject_data.reason)
return {"message": "Application rejected", "reason": reject_data.reason}
@router.get("/admin/dealers", response_model=List[DealerInfoResponse])
def get_all_dealers(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all dealers with full info"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
dealers = db.query(DealerInfo).all()
return dealers
@router.put("/admin/dealers/{dealer_id}/toggle-active")
def toggle_dealer_active(
dealer_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Toggle dealer active status"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
dealer = db.query(DealerInfo).filter(DealerInfo.id == dealer_id).first()
if not dealer:
raise HTTPException(status_code=404, detail="Dealer not found")
dealer.is_active = not dealer.is_active
# Also update user's is_dealer status
user = db.query(User).filter(User.id == dealer.user_id).first()
if user:
user.is_dealer = dealer.is_active
db.commit()
return {"message": f"Dealer {'activated' if dealer.is_active else 'deactivated'}", "is_active": dealer.is_active}

View File

@@ -0,0 +1,247 @@
"""
Exchange Rate API - 환율 정보 조회 (한국수출입은행 API 연동)
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
from ..database import get_db
from ..models.exchange_rate import ExchangeRate, ExchangeRateHistory
from ..models.user import User
from .auth import get_current_admin_user
from ..services.exchange_rate_service import (
update_exchange_rates,
get_all_exchange_rates,
convert_krw_to_currency,
SUPPORTED_CURRENCIES
)
router = APIRouter(prefix="/api/exchange-rate", tags=["Exchange Rate"])
class ExchangeRateData(BaseModel):
currency_code: str
currency_name: str
symbol: str
deal_base_rate: float # 매매기준율 (1 USD = X KRW)
ttb_rate: float # 전신환 받을때
tts_rate: float # 전신환 보낼때
weight_percent: float # 가중치 (%)
adjusted_rate: float # 가중치 적용 환율
source_date: str
updated_at: str
class ExchangeRatesResponse(BaseModel):
base_currency: str
rates: List[ExchangeRateData]
source: str
last_updated: str
class ExchangeRateWeightUpdate(BaseModel):
currency_code: str
weight_percent: float
class ConvertRequest(BaseModel):
amount: float
from_currency: str = "KRW"
to_currency: str
class ConvertResponse(BaseModel):
original_amount: float
from_currency: str
converted_amount: float
to_currency: str
rate_used: float
@router.get("", response_model=ExchangeRatesResponse)
async def get_exchange_rates(db: Session = Depends(get_db)):
"""환율 정보 조회"""
rates = get_all_exchange_rates(db)
# DB에 데이터가 없으면 업데이트 시도
if not rates:
await update_exchange_rates(db)
rates = get_all_exchange_rates(db)
rate_list = []
for rate in rates:
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
rate_list.append(ExchangeRateData(
currency_code=rate.currency_code,
currency_name=rate.currency_name,
symbol=symbol,
deal_base_rate=rate.deal_base_rate,
ttb_rate=rate.ttb_rate or rate.deal_base_rate,
tts_rate=rate.tts_rate or rate.deal_base_rate,
weight_percent=rate.weight_percent or 0.0,
adjusted_rate=rate.adjusted_rate or rate.deal_base_rate,
source_date=rate.source_date or "",
updated_at=rate.updated_at.isoformat() if rate.updated_at else ""
))
last_updated = ""
if rates:
latest = max(rates, key=lambda r: r.updated_at if r.updated_at else datetime.min)
last_updated = latest.updated_at.isoformat() if latest.updated_at else ""
return ExchangeRatesResponse(
base_currency="KRW",
rates=rate_list,
source="koreaexim",
last_updated=last_updated
)
@router.get("/currency/{currency_code}")
async def get_single_rate(currency_code: str, db: Session = Depends(get_db)):
"""특정 통화 환율 조회"""
rate = db.query(ExchangeRate).filter(
ExchangeRate.currency_code == currency_code.upper(),
ExchangeRate.is_active == True
).first()
if not rate:
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
symbol = SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", "")
return {
"currency_code": rate.currency_code,
"currency_name": rate.currency_name,
"symbol": symbol,
"deal_base_rate": rate.deal_base_rate,
"adjusted_rate": rate.adjusted_rate,
"weight_percent": rate.weight_percent,
"source_date": rate.source_date,
"updated_at": rate.updated_at.isoformat() if rate.updated_at else None
}
@router.post("/convert", response_model=ConvertResponse)
async def convert_currency(
request: ConvertRequest,
db: Session = Depends(get_db)
):
"""통화 변환"""
if request.from_currency.upper() != "KRW":
raise HTTPException(status_code=400, detail="Currently only KRW conversion is supported")
converted = convert_krw_to_currency(db, request.amount, request.to_currency.upper())
if converted is None:
raise HTTPException(status_code=404, detail=f"Currency {request.to_currency} not found")
rate = db.query(ExchangeRate).filter(
ExchangeRate.currency_code == request.to_currency.upper()
).first()
return ConvertResponse(
original_amount=request.amount,
from_currency=request.from_currency.upper(),
converted_amount=round(converted, 2),
to_currency=request.to_currency.upper(),
rate_used=rate.adjusted_rate if rate else 0
)
@router.get("/weights")
async def get_exchange_rate_weights(db: Session = Depends(get_db)):
"""환율 가중치 설정 조회"""
rates = get_all_exchange_rates(db)
return {
rate.currency_code.lower(): rate.weight_percent or 0.0
for rate in rates
}
@router.put("/weights/{currency_code}")
async def update_exchange_rate_weight(
currency_code: str,
weight_percent: float,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""환율 가중치 수정 (관리자 전용)"""
rate = db.query(ExchangeRate).filter(
ExchangeRate.currency_code == currency_code.upper()
).first()
if not rate:
raise HTTPException(status_code=404, detail=f"Currency {currency_code} not found")
rate.weight_percent = weight_percent
rate.adjusted_rate = rate.deal_base_rate * (1 + weight_percent / 100)
db.commit()
return {
"message": "Weight updated successfully",
"currency_code": rate.currency_code,
"weight_percent": rate.weight_percent,
"adjusted_rate": rate.adjusted_rate
}
@router.post("/refresh")
async def refresh_exchange_rates(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""환율 강제 갱신 (관리자 전용)"""
result = await update_exchange_rates(db, force=True)
return result
@router.get("/history/{currency_code}")
async def get_exchange_rate_history(
currency_code: str,
limit: int = 30,
db: Session = Depends(get_db)
):
"""환율 변동 이력 조회"""
history = db.query(ExchangeRateHistory).filter(
ExchangeRateHistory.currency_code == currency_code.upper()
).order_by(ExchangeRateHistory.created_at.desc()).limit(limit).all()
return [
{
"currency_code": h.currency_code,
"deal_base_rate": h.deal_base_rate,
"source_date": h.source_date,
"created_at": h.created_at.isoformat() if h.created_at else None
}
for h in history
]
# 프론트엔드용 간단 API
@router.get("/simple")
async def get_simple_rates(db: Session = Depends(get_db)):
"""프론트엔드용 간단 환율 정보"""
rates = get_all_exchange_rates(db)
# DB에 데이터가 없으면 업데이트 시도
if not rates:
await update_exchange_rates(db)
rates = get_all_exchange_rates(db)
result = {}
for rate in rates:
result[rate.currency_code] = {
"rate": rate.adjusted_rate, # KRW per 1 unit (e.g., 1 USD = 1450 KRW)
"symbol": SUPPORTED_CURRENCIES.get(rate.currency_code, {}).get("symbol", ""),
"name": rate.currency_name
}
return result

View File

@@ -0,0 +1,265 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.orm import Session
from typing import List, Optional
import os
import uuid
import aiofiles
from ..database import get_db
from ..models.hero_banner import HeroBanner, HeroBannerSettings
from ..schemas.hero_banner import (
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
HeroBannerListResponse, HeroBannerLocalizedResponse,
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
)
from .auth import get_current_user
from ..models import User
from ..config import get_settings
router = APIRouter(prefix="/hero-banners", tags=["hero-banners"])
settings = get_settings()
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
def get_localized_field(obj, field: str, lang: str) -> Optional[str]:
"""Get localized field value with fallback to Korean then English"""
localized = getattr(obj, f"{field}_{lang}", None)
if localized:
return localized
# Fallback to Korean
ko_value = getattr(obj, f"{field}_ko", None)
if ko_value:
return ko_value
# Fallback to English
return getattr(obj, f"{field}_en", None)
# ==================== Public Endpoints ====================
@router.get("/", response_model=List[HeroBannerLocalizedResponse])
def get_hero_banners(
lang: str = Query("ko", regex="^(ko|en|mn)$"),
db: Session = Depends(get_db)
):
"""활성 히어로 배너 목록 조회 (Public)"""
banners = db.query(HeroBanner).filter(
HeroBanner.is_active == True
).order_by(HeroBanner.display_order.asc(), HeroBanner.id.desc()).all()
result = []
for b in banners:
result.append(HeroBannerLocalizedResponse(
id=b.id,
title=get_localized_field(b, "title", lang),
subtitle=get_localized_field(b, "subtitle", lang),
image_url=b.image_url,
link_url=b.link_url,
car_id=b.car_id,
))
return result
@router.get("/check-car/{car_id}")
def check_banner_car(car_id: int, db: Session = Depends(get_db)):
"""차량이 Hero Banner에 연결되어 있는지 확인 (Public)
Banner에 연결된 차량은 샘플로 모든 정보를 무료로 공개합니다.
"""
banner = db.query(HeroBanner).filter(
HeroBanner.car_id == car_id,
HeroBanner.is_active == True
).first()
return {
"car_id": car_id,
"is_banner_car": banner is not None,
"banner_id": banner.id if banner else None
}
@router.get("/settings", response_model=HeroBannerSettingsResponse)
def get_banner_settings(db: Session = Depends(get_db)):
"""배너 슬라이더 설정 조회 (Public)"""
settings_obj = db.query(HeroBannerSettings).first()
if not settings_obj:
# 기본 설정 생성
settings_obj = HeroBannerSettings(
slide_interval=3000,
animation_type="film-strip",
image_width=500,
image_height=300,
auto_play=True,
)
db.add(settings_obj)
db.commit()
db.refresh(settings_obj)
return settings_obj
# ==================== Admin Endpoints ====================
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
# TODO: 실제 관리자 역할 체크 추가
# if current_user.role != "admin":
# raise HTTPException(status_code=403, detail="Admin access required")
return current_user
@router.get("/admin/list", response_model=List[HeroBannerListResponse])
def admin_get_banners(
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""모든 히어로 배너 조회 (Admin)"""
banners = db.query(HeroBanner).order_by(
HeroBanner.display_order.asc(),
HeroBanner.id.desc()
).all()
return banners
@router.get("/admin/{banner_id}", response_model=HeroBannerResponse)
def admin_get_banner(
banner_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""히어로 배너 상세 조회 (Admin)"""
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
return banner
@router.post("/admin", response_model=HeroBannerResponse)
def create_banner(
banner_data: HeroBannerCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""히어로 배너 생성 (Admin)"""
banner = HeroBanner(**banner_data.model_dump())
db.add(banner)
db.commit()
db.refresh(banner)
return banner
@router.put("/admin/{banner_id}", response_model=HeroBannerResponse)
def update_banner(
banner_id: int,
banner_data: HeroBannerUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""히어로 배너 수정 (Admin)"""
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
update_data = banner_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(banner, field, value)
db.commit()
db.refresh(banner)
return banner
@router.delete("/admin/{banner_id}")
def delete_banner(
banner_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""히어로 배너 삭제 (Admin)"""
banner = db.query(HeroBanner).filter(HeroBanner.id == banner_id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
# 로컬 이미지 파일 삭제
if banner.image_url and banner.image_url.startswith("/uploads/"):
try:
filepath = os.path.join(settings.UPLOAD_DIR if hasattr(settings, 'UPLOAD_DIR') else "./uploads",
os.path.basename(banner.image_url))
if os.path.exists(filepath):
os.remove(filepath)
except Exception:
pass
db.delete(banner)
db.commit()
return {"message": "Banner deleted successfully"}
@router.put("/admin/settings", response_model=HeroBannerSettingsResponse)
def update_banner_settings(
settings_data: HeroBannerSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""배너 슬라이더 설정 수정 (Admin)"""
settings_obj = db.query(HeroBannerSettings).first()
if not settings_obj:
settings_obj = HeroBannerSettings()
db.add(settings_obj)
update_data = settings_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings_obj, field, value)
db.commit()
db.refresh(settings_obj)
return settings_obj
# ==================== Image Upload ====================
@router.post("/admin/upload-image")
async def upload_banner_image(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""배너 이미지 업로드 (Admin)"""
# 파일 확장자 검증
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type not allowed. Allowed: {ALLOWED_EXTENSIONS}"
)
# 파일 읽기 및 크기 검증
contents = await file.read()
max_size = 10 * 1024 * 1024 # 10MB
if len(contents) > max_size:
raise HTTPException(
status_code=400,
detail=f"File too large. Max size: {max_size / 1024 / 1024}MB"
)
# 업로드 디렉토리 생성
upload_dir = "./uploads/hero-banners"
os.makedirs(upload_dir, exist_ok=True)
# 고유 파일명 생성
filename = f"hero_{uuid.uuid4()}{ext}"
filepath = os.path.join(upload_dir, filename)
# 파일 저장
async with aiofiles.open(filepath, 'wb') as f:
await f.write(contents)
# 상대 URL 반환
image_url = f"/uploads/hero-banners/{filename}"
return {
"message": "Image uploaded successfully",
"image_url": image_url,
"filename": filename,
}

View File

@@ -0,0 +1,326 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime
from typing import List, Optional
from ..database import get_db
from ..models import User
from ..models.inquiry import Inquiry, InquiryMessage, InquiryStatus
from ..schemas.inquiry import (
InquiryCreate, InquiryResponse, InquiryListResponse,
InquiryMessageCreate, InquiryMessageResponse, InquiryWithMessages,
AdminInquiryRespond, AdminInquiryUpdateStatus
)
from .auth import get_current_user
from .notification import create_notification
router = APIRouter(prefix="/inquiries", tags=["inquiries"])
# =====================
# User Endpoints
# =====================
@router.get("", response_model=List[InquiryResponse])
def get_inquiries(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's inquiries (legacy endpoint)"""
return db.query(Inquiry).filter(Inquiry.user_id == current_user.id).order_by(desc(Inquiry.created_at)).all()
@router.post("", response_model=InquiryResponse)
def create_inquiry(
inquiry_data: InquiryCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new inquiry"""
inquiry = Inquiry(
user_id=current_user.id,
car_id=inquiry_data.car_id,
category=inquiry_data.category,
subject=inquiry_data.subject or f"{inquiry_data.category} 문의",
message=inquiry_data.message,
contact_email=inquiry_data.contact_email or current_user.email,
contact_phone=inquiry_data.contact_phone or current_user.phone,
status=InquiryStatus.PENDING
)
db.add(inquiry)
db.commit()
db.refresh(inquiry)
return inquiry
@router.get("/my-inquiries", response_model=InquiryListResponse)
def get_my_inquiries(
page: int = 1,
page_size: int = 10,
status: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's inquiries with pagination"""
query = db.query(Inquiry).filter(Inquiry.user_id == current_user.id)
if status:
query = query.filter(Inquiry.status == status)
total = query.count()
inquiries = query.order_by(desc(Inquiry.created_at)) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.all()
return InquiryListResponse(
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
total=total
)
@router.get("/my-inquiries/{inquiry_id}", response_model=InquiryWithMessages)
def get_my_inquiry_detail(
inquiry_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get details of a specific inquiry with messages"""
inquiry = db.query(Inquiry).filter(
Inquiry.id == inquiry_id,
Inquiry.user_id == current_user.id
).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
messages = db.query(InquiryMessage).filter(
InquiryMessage.inquiry_id == inquiry_id
).order_by(InquiryMessage.created_at).all()
return InquiryWithMessages(
inquiry=InquiryResponse.model_validate(inquiry),
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
)
@router.post("/my-inquiries/{inquiry_id}/message", response_model=InquiryMessageResponse)
def add_message_to_inquiry(
inquiry_id: int,
message_data: InquiryMessageCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add a message to an existing inquiry"""
inquiry = db.query(Inquiry).filter(
Inquiry.id == inquiry_id,
Inquiry.user_id == current_user.id
).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
if inquiry.status == InquiryStatus.CLOSED:
raise HTTPException(status_code=400, detail="Cannot add message to closed inquiry")
message = InquiryMessage(
inquiry_id=inquiry_id,
user_id=current_user.id,
message=message_data.message,
is_admin=False
)
# Update inquiry status if it was resolved
if inquiry.status == InquiryStatus.RESOLVED:
inquiry.status = InquiryStatus.IN_PROGRESS
db.add(message)
db.commit()
db.refresh(message)
return message
@router.get("/{inquiry_id}", response_model=InquiryResponse)
def get_inquiry(
inquiry_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get inquiry detail (legacy endpoint)"""
inquiry = db.query(Inquiry).filter(
Inquiry.id == inquiry_id,
Inquiry.user_id == current_user.id
).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
return inquiry
# =====================
# Admin Endpoints
# =====================
@router.get("/admin/list", response_model=InquiryListResponse)
def admin_get_all_inquiries(
page: int = 1,
page_size: int = 20,
status: Optional[str] = None,
category: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all inquiries"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
query = db.query(Inquiry)
if status:
query = query.filter(Inquiry.status == status)
if category:
query = query.filter(Inquiry.category == category)
total = query.count()
inquiries = query.order_by(desc(Inquiry.created_at)) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.all()
return InquiryListResponse(
inquiries=[InquiryResponse.model_validate(i) for i in inquiries],
total=total
)
@router.get("/admin/{inquiry_id}", response_model=InquiryWithMessages)
def admin_get_inquiry_detail(
inquiry_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get inquiry details with messages"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
messages = db.query(InquiryMessage).filter(
InquiryMessage.inquiry_id == inquiry_id
).order_by(InquiryMessage.created_at).all()
return InquiryWithMessages(
inquiry=InquiryResponse.model_validate(inquiry),
messages=[InquiryMessageResponse.model_validate(m) for m in messages]
)
@router.post("/admin/{inquiry_id}/respond", response_model=InquiryMessageResponse)
def admin_respond_to_inquiry(
inquiry_id: int,
response_data: AdminInquiryRespond,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Respond to an inquiry"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
# Create message
message = InquiryMessage(
inquiry_id=inquiry_id,
user_id=current_user.id,
message=response_data.message,
is_admin=True
)
# Update inquiry
inquiry.admin_response = response_data.message
inquiry.responded_at = datetime.utcnow()
inquiry.responded_by = current_user.id
if response_data.status:
inquiry.status = response_data.status
elif inquiry.status == InquiryStatus.PENDING:
inquiry.status = InquiryStatus.IN_PROGRESS
db.add(message)
db.commit()
db.refresh(message)
# Send notification to user
create_notification(
db=db,
user_id=inquiry.user_id,
notification_type="system",
title="문의 답변 도착",
message=f"'{inquiry.subject}' 문의에 답변이 등록되었습니다.",
link="/contact"
)
return message
@router.put("/admin/{inquiry_id}/status", response_model=InquiryResponse)
def admin_update_inquiry_status(
inquiry_id: int,
status_data: AdminInquiryUpdateStatus,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Update inquiry status"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
inquiry = db.query(Inquiry).filter(Inquiry.id == inquiry_id).first()
if not inquiry:
raise HTTPException(status_code=404, detail="Inquiry not found")
valid_statuses = [InquiryStatus.PENDING, InquiryStatus.IN_PROGRESS, InquiryStatus.RESOLVED, InquiryStatus.CLOSED]
if status_data.status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {valid_statuses}"
)
inquiry.status = status_data.status
db.commit()
db.refresh(inquiry)
return inquiry
@router.get("/admin/stats")
def admin_get_inquiry_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get inquiry statistics"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
total = db.query(Inquiry).count()
pending = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.PENDING).count()
in_progress = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.IN_PROGRESS).count()
resolved = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.RESOLVED).count()
closed = db.query(Inquiry).filter(Inquiry.status == InquiryStatus.CLOSED).count()
return {
"total": total,
"pending": pending,
"in_progress": in_progress,
"resolved": resolved,
"closed": closed
}

View File

@@ -0,0 +1,363 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import desc
from datetime import datetime
from typing import List, Optional
from ..database import get_db
from ..models import User, Notification
from ..schemas.notification import (
NotificationCreate, NotificationResponse,
NotificationListResponse, NotificationMarkRead
)
from .auth import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
# =====================
# Helper Functions
# =====================
def create_notification(
db: Session,
user_id: int,
notification_type: str,
title: str,
message: str,
link: Optional[str] = None,
related_id: Optional[int] = None,
related_type: Optional[str] = None
) -> Notification:
"""Create a new notification"""
notification = Notification(
user_id=user_id,
notification_type=notification_type,
title=title,
message=message,
link=link,
related_id=related_id,
related_type=related_type
)
db.add(notification)
db.commit()
db.refresh(notification)
return notification
def notify_vehicle_recommended(db: Session, user_id: int, request_id: int, vehicle_count: int):
"""Notify user when vehicles are recommended for their request"""
return create_notification(
db=db,
user_id=user_id,
notification_type="vehicle_recommended",
title="차량 추천 완료",
message=f"{vehicle_count}대의 차량이 추천되었습니다. 지금 확인해보세요!",
link=f"/my-request",
related_id=request_id,
related_type="vehicle_request"
)
def notify_shipping_update(db: Session, user_id: int, vehicle_id: int, status: int, car_name: str):
"""Notify user when shipping status changes"""
status_names = {
1: "구매완료",
2: "인천항 도착",
3: "텐진항 도착",
4: "자먼우드 도착",
5: "울란바토르 도착",
6: "통관 진행중",
7: "배송완료"
}
status_name = status_names.get(status, f"상태 {status}")
return create_notification(
db=db,
user_id=user_id,
notification_type="shipping_update",
title="배송 상태 업데이트",
message=f"{car_name}: {status_name}",
link=f"/find-my-car",
related_id=vehicle_id,
related_type="purchased_vehicle"
)
def notify_withdrawal_processed(db: Session, user_id: int, withdrawal_id: int, status: str, amount: float):
"""Notify user when withdrawal request is processed"""
status_messages = {
"approved": f"출금 신청이 승인되었습니다. {amount:,.0f}원이 곧 입금됩니다.",
"completed": f"출금 완료! {amount:,.0f}원이 입금되었습니다.",
"rejected": "출금 신청이 거부되었습니다. 관리자에게 문의해주세요."
}
return create_notification(
db=db,
user_id=user_id,
notification_type="withdrawal_processed",
title="출금 처리 알림",
message=status_messages.get(status, "출금 상태가 변경되었습니다."),
link="/withdrawal",
related_id=withdrawal_id,
related_type="withdrawal"
)
def notify_referral_reward(db: Session, user_id: int, reward_amount: float, referred_name: str):
"""Notify user when they receive referral reward"""
return create_notification(
db=db,
user_id=user_id,
notification_type="referral_reward",
title="레퍼럴 보상 적립",
message=f"{referred_name}님의 충전으로 {reward_amount:,.0f}원이 적립되었습니다!",
link="/withdrawal",
related_type="referral"
)
def notify_dealer_approved(db: Session, user_id: int, dealer_code: str):
"""Notify user when dealer application is approved"""
return create_notification(
db=db,
user_id=user_id,
notification_type="dealer_approved",
title="딜러 승인 완료",
message=f"딜러 승인이 완료되었습니다! 딜러 코드: {dealer_code}",
link="/dealer/my-card",
related_type="dealer"
)
def notify_dealer_rejected(db: Session, user_id: int, reason: str):
"""Notify user when dealer application is rejected"""
return create_notification(
db=db,
user_id=user_id,
notification_type="dealer_rejected",
title="딜러 신청 거부",
message=f"딜러 신청이 거부되었습니다. 사유: {reason}",
link="/dealer/apply",
related_type="dealer"
)
def notify_share_purchased(db: Session, user_id: int, share_id: int, reward_amount: float, car_name: str):
"""Notify user when their shared vehicle is purchased"""
return create_notification(
db=db,
user_id=user_id,
notification_type="share_purchased",
title="공유 차량 판매 완료",
message=f"{car_name} 판매 완료! 리워드 {reward_amount:,.0f}원이 적립되었습니다.",
link="/withdrawal",
related_id=share_id,
related_type="vehicle_share"
)
def notify_payment_confirmed(db: Session, user_id: int, charge_id: int, amount: float, cc_amount: int):
"""Notify user when payment is confirmed"""
return create_notification(
db=db,
user_id=user_id,
notification_type="payment_confirmed",
title="결제 확인 완료",
message=f"결제가 확인되었습니다! ${amount:.2f}{cc_amount} CC가 충전되었습니다.",
link="/charge",
related_id=charge_id,
related_type="charge"
)
def notify_inquiry_reply(db: Session, user_id: int, inquiry_id: int, subject: str = None):
"""Notify user when admin replies to their inquiry"""
return create_notification(
db=db,
user_id=user_id,
notification_type="inquiry_reply",
title="문의 답변 등록",
message=f"문의에 답변이 등록되었습니다." + (f" ({subject})" if subject else ""),
link=f"/my-inquiries/{inquiry_id}",
related_id=inquiry_id,
related_type="inquiry"
)
def notify_system(db: Session, user_id: int, title: str, message: str, link: Optional[str] = None):
"""Send a general system notification to a user"""
return create_notification(
db=db,
user_id=user_id,
notification_type="system",
title=title,
message=message,
link=link
)
# =====================
# User Endpoints
# =====================
@router.get("/", response_model=NotificationListResponse)
def get_notifications(
page: int = 1,
page_size: int = 20,
unread_only: bool = False,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's notifications"""
query = db.query(Notification).filter(Notification.user_id == current_user.id)
if unread_only:
query = query.filter(Notification.is_read == False)
total = query.count()
unread_count = db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False
).count()
notifications = query.order_by(desc(Notification.created_at)) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.all()
return NotificationListResponse(
notifications=[NotificationResponse.model_validate(n) for n in notifications],
unread_count=unread_count,
total=total
)
@router.get("/unread-count")
def get_unread_count(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get count of unread notifications"""
count = db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False
).count()
return {"unread_count": count}
@router.post("/mark-read")
def mark_as_read(
data: NotificationMarkRead,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Mark notifications as read"""
notifications = db.query(Notification).filter(
Notification.id.in_(data.notification_ids),
Notification.user_id == current_user.id
).all()
for notification in notifications:
notification.is_read = True
notification.read_at = datetime.utcnow()
db.commit()
return {"message": f"Marked {len(notifications)} notifications as read"}
@router.post("/mark-all-read")
def mark_all_as_read(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Mark all notifications as read"""
count = db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False
).update({
"is_read": True,
"read_at": datetime.utcnow()
})
db.commit()
return {"message": f"Marked {count} notifications as read"}
@router.delete("/{notification_id}")
def delete_notification(
notification_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a notification"""
notification = db.query(Notification).filter(
Notification.id == notification_id,
Notification.user_id == current_user.id
).first()
if not notification:
raise HTTPException(status_code=404, detail="Notification not found")
db.delete(notification)
db.commit()
return {"message": "Notification deleted"}
# =====================
# Admin Endpoints
# =====================
@router.post("/admin/send")
def admin_send_notification(
notification_data: NotificationCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Send notification to a user"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
notification = create_notification(
db=db,
user_id=notification_data.user_id,
notification_type=notification_data.notification_type,
title=notification_data.title,
message=notification_data.message,
link=notification_data.link,
related_id=notification_data.related_id,
related_type=notification_data.related_type
)
return NotificationResponse.model_validate(notification)
@router.post("/admin/send-all")
def admin_send_to_all(
title: str,
message: str,
link: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Send notification to all users"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
users = db.query(User).filter(User.is_active == True).all()
for user in users:
create_notification(
db=db,
user_id=user.id,
notification_type="system",
title=title,
message=message,
link=link
)
return {"message": f"Sent notification to {len(users)} users"}

276
backend/app/api/push.py Normal file
View File

@@ -0,0 +1,276 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from ..database import get_db
from ..models import User, PushSubscription, UserNotificationPreference
from .auth import get_current_user, get_current_admin_user
router = APIRouter(prefix="/push", tags=["Push Notifications"])
# VAPID keys for web push (in production, store these securely)
# Generate these using: npx web-push generate-vapid-keys
VAPID_PUBLIC_KEY = "BMjR7pDj6PUjFo8VkA4f1BYhOAzGhJPcVnT7mJ6Bq8jG9yYKvN8dZ5jT3pQ2sL9wR0xF4bM1nK3vH5uC7yX2aE0"
class PushSubscriptionCreate(BaseModel):
endpoint: str
p256dh_key: str
auth_key: str
device_info: Optional[str] = None
class NotificationPreferenceUpdate(BaseModel):
vehicle_recommended: Optional[bool] = None
shipping_update: Optional[bool] = None
payment_confirmed: Optional[bool] = None
withdrawal_processed: Optional[bool] = None
dealer_status: Optional[bool] = None
share_purchased: Optional[bool] = None
referral_reward: Optional[bool] = None
inquiry_reply: Optional[bool] = None
system_announcements: Optional[bool] = None
push_enabled: Optional[bool] = None
email_enabled: Optional[bool] = None
@router.get("/vapid-key")
def get_vapid_public_key():
"""Get VAPID public key for push subscription"""
return {"public_key": VAPID_PUBLIC_KEY}
@router.post("/subscribe")
def subscribe_push(
subscription: PushSubscriptionCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Subscribe to push notifications"""
# Check if subscription already exists
existing = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id,
PushSubscription.endpoint == subscription.endpoint
).first()
if existing:
# Update existing subscription
existing.p256dh_key = subscription.p256dh_key
existing.auth_key = subscription.auth_key
existing.device_info = subscription.device_info
existing.is_active = True
existing.last_used_at = datetime.utcnow()
else:
# Create new subscription
new_sub = PushSubscription(
user_id=current_user.id,
endpoint=subscription.endpoint,
p256dh_key=subscription.p256dh_key,
auth_key=subscription.auth_key,
device_info=subscription.device_info,
is_active=True
)
db.add(new_sub)
db.commit()
return {"message": "Push subscription saved successfully"}
@router.delete("/unsubscribe")
def unsubscribe_push(
endpoint: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Unsubscribe from push notifications"""
subscription = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id,
PushSubscription.endpoint == endpoint
).first()
if subscription:
subscription.is_active = False
db.commit()
return {"message": "Push subscription removed"}
@router.get("/subscriptions")
def get_my_subscriptions(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's active push subscriptions"""
subscriptions = db.query(PushSubscription).filter(
PushSubscription.user_id == current_user.id,
PushSubscription.is_active == True
).all()
return [
{
"id": sub.id,
"endpoint": sub.endpoint[:50] + "..." if len(sub.endpoint) > 50 else sub.endpoint,
"device_info": sub.device_info,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
"last_used_at": sub.last_used_at.isoformat() if sub.last_used_at else None
}
for sub in subscriptions
]
@router.get("/preferences")
def get_notification_preferences(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's notification preferences"""
prefs = db.query(UserNotificationPreference).filter(
UserNotificationPreference.user_id == current_user.id
).first()
if not prefs:
# Create default preferences
prefs = UserNotificationPreference(user_id=current_user.id)
db.add(prefs)
db.commit()
db.refresh(prefs)
return {
"vehicle_recommended": prefs.vehicle_recommended,
"shipping_update": prefs.shipping_update,
"payment_confirmed": prefs.payment_confirmed,
"withdrawal_processed": prefs.withdrawal_processed,
"dealer_status": prefs.dealer_status,
"share_purchased": prefs.share_purchased,
"referral_reward": prefs.referral_reward,
"inquiry_reply": prefs.inquiry_reply,
"system_announcements": prefs.system_announcements,
"push_enabled": prefs.push_enabled,
"email_enabled": prefs.email_enabled,
}
@router.put("/preferences")
def update_notification_preferences(
preferences: NotificationPreferenceUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update user's notification preferences"""
prefs = db.query(UserNotificationPreference).filter(
UserNotificationPreference.user_id == current_user.id
).first()
if not prefs:
prefs = UserNotificationPreference(user_id=current_user.id)
db.add(prefs)
# Update preferences
for field, value in preferences.dict(exclude_none=True).items():
setattr(prefs, field, value)
db.commit()
db.refresh(prefs)
return {"message": "Preferences updated successfully"}
# Admin endpoints
@router.get("/admin/stats")
def admin_get_push_stats(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get push notification statistics (Admin only)"""
total_subscriptions = db.query(PushSubscription).filter(
PushSubscription.is_active == True
).count()
users_with_push = db.query(PushSubscription.user_id).filter(
PushSubscription.is_active == True
).distinct().count()
return {
"total_subscriptions": total_subscriptions,
"users_with_push": users_with_push
}
# Helper function to send push notification (called from other modules)
def send_push_notification(
db: Session,
user_id: int,
title: str,
body: str,
url: str = None,
notification_type: str = "system"
):
"""
Send push notification to a user.
In production, this would use pywebpush to actually send the notification.
"""
# Check user preferences
prefs = db.query(UserNotificationPreference).filter(
UserNotificationPreference.user_id == user_id
).first()
if prefs and not prefs.push_enabled:
return False
# Check specific notification type preference
if prefs:
type_pref_map = {
"vehicle_recommended": prefs.vehicle_recommended,
"shipping_update": prefs.shipping_update,
"payment_confirmed": prefs.payment_confirmed,
"withdrawal_processed": prefs.withdrawal_processed,
"dealer_approved": prefs.dealer_status,
"dealer_rejected": prefs.dealer_status,
"share_purchased": prefs.share_purchased,
"referral_reward": prefs.referral_reward,
"inquiry_reply": prefs.inquiry_reply,
"system": prefs.system_announcements,
}
if notification_type in type_pref_map and not type_pref_map[notification_type]:
return False
# Get user's active subscriptions
subscriptions = db.query(PushSubscription).filter(
PushSubscription.user_id == user_id,
PushSubscription.is_active == True
).all()
if not subscriptions:
return False
# In production, use pywebpush to send notifications
# For now, we just log and return success
# Example with pywebpush:
# from pywebpush import webpush, WebPushException
# for sub in subscriptions:
# try:
# webpush(
# subscription_info={
# "endpoint": sub.endpoint,
# "keys": {
# "p256dh": sub.p256dh_key,
# "auth": sub.auth_key
# }
# },
# data=json.dumps({
# "title": title,
# "body": body,
# "url": url
# }),
# vapid_private_key=VAPID_PRIVATE_KEY,
# vapid_claims={"sub": "mailto:admin@autosellcar.com"}
# )
# sub.last_used_at = datetime.utcnow()
# except WebPushException as ex:
# if ex.response and ex.response.status_code == 410:
# sub.is_active = False
# db.commit()
return True

192
backend/app/api/referral.py Normal file
View File

@@ -0,0 +1,192 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from sqlalchemy import func as sql_func
from datetime import datetime
from typing import List
from ..database import get_db
from ..models import User, ReferralReward, SystemSettings
from ..schemas import (
ReferralRewardResponse, ReferralStats,
ReferralSettingsResponse, ReferralSettingsUpdate,
)
from .auth import get_current_user
from .notification import notify_referral_reward
router = APIRouter(prefix="/referral", tags=["referral"])
def get_referral_settings(db: Session) -> SystemSettings:
"""Get or create system settings"""
settings = db.query(SystemSettings).first()
if not settings:
settings = SystemSettings()
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def create_referral_reward(
referrer_id: int,
referred_user_id: int,
payment_amount: float,
db: Session
):
"""Create a referral reward when a referred user makes a payment"""
settings = get_referral_settings(db)
# Check if referral rewards are enabled
if not settings.referral_reward_enabled:
return None
# Check if this is a one_time reward and already exists
if settings.referral_reward_type == "one_time":
existing = db.query(ReferralReward).filter(
ReferralReward.referrer_id == referrer_id,
ReferralReward.referred_user_id == referred_user_id
).first()
if existing:
return None # Already gave reward for this referral
# Calculate reward amount
reward_amount = payment_amount * (settings.referral_reward_percent / 100)
# Create reward record
reward = ReferralReward(
referrer_id=referrer_id,
referred_user_id=referred_user_id,
payment_amount=payment_amount,
reward_amount=reward_amount,
status="credited", # Auto-credit for simplicity
credited_at=datetime.utcnow()
)
db.add(reward)
db.commit()
db.refresh(reward)
# Send notification to referrer
referred_user = db.query(User).filter(User.id == referred_user_id).first()
referred_name = referred_user.name or referred_user.email if referred_user else "회원"
notify_referral_reward(db, referrer_id, reward_amount, referred_name)
return reward
@router.get("/my-link")
def get_my_referral_link(current_user: User = Depends(get_current_user)):
"""Get current user's referral link/code"""
return {
"referral_code": current_user.referral_code,
"referral_link": f"/register?ref={current_user.referral_code}"
}
@router.get("/my-rewards", response_model=List[ReferralRewardResponse])
def get_my_rewards(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's referral rewards"""
rewards = db.query(ReferralReward).filter(
ReferralReward.referrer_id == current_user.id
).order_by(ReferralReward.created_at.desc()).all()
return rewards
@router.get("/stats", response_model=ReferralStats)
def get_referral_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get referral statistics for current user"""
# Get all rewards where user is the referrer
rewards = db.query(ReferralReward).filter(
ReferralReward.referrer_id == current_user.id
).all()
# Count unique referred users
referred_users = db.query(sql_func.count(sql_func.distinct(ReferralReward.referred_user_id))).filter(
ReferralReward.referrer_id == current_user.id
).scalar() or 0
total_rewards_earned = sum(r.reward_amount for r in rewards)
total_rewards_credited = sum(r.reward_amount for r in rewards if r.status == "credited")
total_rewards_pending = sum(r.reward_amount for r in rewards if r.status == "pending")
total_withdrawn = sum(r.reward_amount for r in rewards if r.status == "withdrawn")
return ReferralStats(
total_referrals=referred_users,
total_rewards_earned=total_rewards_earned,
total_rewards_credited=total_rewards_credited,
total_rewards_pending=total_rewards_pending,
available_for_withdrawal=total_rewards_credited - total_withdrawn
)
@router.get("/settings", response_model=ReferralSettingsResponse)
def get_settings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get referral settings (public endpoint)"""
settings = get_referral_settings(db)
return ReferralSettingsResponse(
referral_reward_enabled=settings.referral_reward_enabled,
referral_reward_percent=settings.referral_reward_percent,
referral_reward_type=settings.referral_reward_type
)
# Admin endpoints
@router.put("/admin/settings", response_model=ReferralSettingsResponse)
def update_settings(
update_data: ReferralSettingsUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Update referral settings"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
settings = get_referral_settings(db)
if update_data.referral_reward_enabled is not None:
settings.referral_reward_enabled = update_data.referral_reward_enabled
if update_data.referral_reward_percent is not None:
if update_data.referral_reward_percent < 0 or update_data.referral_reward_percent > 100:
raise HTTPException(status_code=400, detail="Reward percent must be between 0 and 100")
settings.referral_reward_percent = update_data.referral_reward_percent
if update_data.referral_reward_type is not None:
if update_data.referral_reward_type not in ["one_time", "recurring"]:
raise HTTPException(status_code=400, detail="Reward type must be 'one_time' or 'recurring'")
settings.referral_reward_type = update_data.referral_reward_type
db.commit()
db.refresh(settings)
return ReferralSettingsResponse(
referral_reward_enabled=settings.referral_reward_enabled,
referral_reward_percent=settings.referral_reward_percent,
referral_reward_type=settings.referral_reward_type
)
@router.get("/admin/all-rewards", response_model=List[ReferralRewardResponse])
def admin_get_all_rewards(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all referral rewards"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
rewards = db.query(ReferralReward).order_by(
ReferralReward.created_at.desc()
).limit(100).all()
return rewards

View File

@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.settings import SystemSettings
from ..schemas.settings import SystemSettingsUpdate, SystemSettingsResponse
from .auth import get_current_user
from ..models import User
router = APIRouter(prefix="/settings", tags=["settings"])
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
"""관리자 권한 확인 (임시: 모든 로그인 사용자 허용)"""
return current_user
def get_or_create_settings(db: Session) -> SystemSettings:
"""시스템 설정 조회 또는 기본값 생성"""
settings = db.query(SystemSettings).first()
if not settings:
settings = SystemSettings(
search_page_size=20,
korea_margin_percent=5.0,
mongolia_margin_percent=5.0,
cc_per_usdc=1, # 1 USD = 1 CC
cc_per_view=1, # 차량 상세 조회 시 1 CC
cars_per_cc=3, # 1 CC = 3 recommended vehicles per request
cc_signup_bonus=3, # 3 CC free on signup
cache_ttl_hours=2,
container_logistics_usd=3600,
shoring_cost_usd=300,
)
db.add(settings)
db.commit()
db.refresh(settings)
return settings
# ==================== Public Endpoints ====================
@router.get("/", response_model=SystemSettingsResponse)
def get_system_settings(db: Session = Depends(get_db)):
"""시스템 설정 조회 (Public)"""
return get_or_create_settings(db)
@router.get("/search-page-size")
def get_search_page_size(db: Session = Depends(get_db)):
"""검색 결과 페이지 크기 조회 (Public)"""
settings = get_or_create_settings(db)
return {"search_page_size": settings.search_page_size}
# ==================== Admin Endpoints ====================
@router.put("/", response_model=SystemSettingsResponse)
def update_system_settings(
settings_data: SystemSettingsUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_admin_user)
):
"""시스템 설정 수정 (Admin)"""
settings = get_or_create_settings(db)
update_data = settings_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(settings, field, value)
db.commit()
db.refresh(settings)
return settings

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timedelta
from ..database import get_db
from ..models import VehicleRequest, RequestVehicle, PurchasedVehicle, User, DealerInfo, SystemSettings
from ..schemas import (
VehicleRequestCreate, VehicleRequestResponse,
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
VehicleRequestWithVehicles,
)
from .auth import get_current_user
from .notification import notify_vehicle_recommended, notify_shipping_update
def get_system_settings(db: Session) -> SystemSettings:
"""Get or create system settings"""
settings = db.query(SystemSettings).first()
if not settings:
settings = SystemSettings()
db.add(settings)
db.commit()
db.refresh(settings)
return settings
def calculate_dealer_commission(vehicle_price_krw: int, db: Session) -> tuple:
"""Calculate dealer and platform commission based on Mongolia margin"""
settings = get_system_settings(db)
# Calculate Mongolia margin (vehicle price * margin percent)
mongolia_margin = vehicle_price_krw * (settings.mongolia_margin_percent / 100)
# 50/50 split between dealer and platform
dealer_commission = int(mongolia_margin * 0.5)
platform_commission = int(mongolia_margin * 0.5)
return dealer_commission, platform_commission
router = APIRouter(prefix="/vehicle-requests", tags=["vehicle-requests"])
# Development mode - skip 24 hour wait
DEV_MODE = True
# =====================
# User Endpoints
# =====================
QUOTE_REQUEST_COST = 1.0 # 1 CC for quote request submission
@router.post("/", response_model=VehicleRequestResponse)
def create_request(
request_data: VehicleRequestCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new vehicle search request (costs 1 CC)"""
# Check if user has enough CC
if (current_user.cc_balance or 0) < QUOTE_REQUEST_COST:
raise HTTPException(
status_code=400,
detail=f"Insufficient CC balance. You need {QUOTE_REQUEST_COST} CC to submit a vehicle request. Current balance: {current_user.cc_balance or 0}"
)
# Deduct CC from user's balance
current_user.cc_balance = (current_user.cc_balance or 0) - QUOTE_REQUEST_COST
# Create the request
request = VehicleRequest(
user_id=current_user.id,
cc_paid=QUOTE_REQUEST_COST,
**request_data.model_dump()
)
db.add(request)
db.commit()
db.refresh(request)
return request
@router.get("/my-requests", response_model=List[VehicleRequestWithVehicles])
def get_my_requests(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's vehicle requests with approved vehicles"""
requests = db.query(VehicleRequest).filter(
VehicleRequest.user_id == current_user.id
).order_by(VehicleRequest.created_at.desc()).all()
result = []
for req in requests:
# In dev mode, show all approved vehicles immediately
# In production, only show after 24 hours
if DEV_MODE or (req.created_at and datetime.utcnow() - req.created_at > timedelta(hours=24)):
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
else:
approved_vehicles = []
result.append(VehicleRequestWithVehicles(
request=VehicleRequestResponse.model_validate(req),
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in approved_vehicles]
))
return result
# =====================
# Purchased Vehicles (Find My Car)
# =====================
@router.get("/purchased", response_model=List[PurchasedVehicleResponse])
def get_purchased_vehicles(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's purchased vehicles with shipping status"""
vehicles = db.query(PurchasedVehicle).filter(
PurchasedVehicle.user_id == current_user.id
).order_by(PurchasedVehicle.purchased_at.desc()).all()
return vehicles
# =====================
# Admin Endpoints
# =====================
@router.get("/admin/list", response_model=List[VehicleRequestResponse])
def admin_get_all_requests(
status: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Get all vehicle requests"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
query = db.query(VehicleRequest)
if status:
query = query.filter(VehicleRequest.status == status)
requests = query.order_by(VehicleRequest.created_at.desc()).all()
return requests
@router.get("/admin/{request_id}", response_model=VehicleRequestWithVehicles)
def admin_get_request_detail(
request_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Get request detail with all recommended vehicles"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
if not request:
raise HTTPException(status_code=404, detail="Request not found")
return VehicleRequestWithVehicles(
request=VehicleRequestResponse.model_validate(request),
approved_vehicles=[RequestVehicleResponse.model_validate(v) for v in request.recommended_vehicles]
)
@router.post("/admin/{request_id}/vehicles", response_model=RequestVehicleResponse)
def admin_add_vehicle(
request_id: int,
vehicle_data: RequestVehicleCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Add a vehicle to a request"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
if not request:
raise HTTPException(status_code=404, detail="Request not found")
vehicle = RequestVehicle(
request_id=request_id,
car_data=vehicle_data.car_data,
is_approved=vehicle_data.is_approved,
approved_at=datetime.utcnow() if vehicle_data.is_approved else None
)
db.add(vehicle)
# Update request status
request.status = "reviewed"
request.admin_reviewed_at = datetime.utcnow()
db.commit()
db.refresh(vehicle)
return vehicle
@router.post("/admin/{request_id}/approve-vehicles")
def admin_approve_vehicles(
request_id: int,
approval: RequestVehicleApprove,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Approve multiple vehicles for a request"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
vehicles = db.query(RequestVehicle).filter(
RequestVehicle.request_id == request_id,
RequestVehicle.id.in_(approval.vehicle_ids)
).all()
for vehicle in vehicles:
vehicle.is_approved = True
vehicle.approved_at = datetime.utcnow()
# Update request status
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
if request:
request.status = "completed"
request.admin_reviewed_at = datetime.utcnow()
db.commit()
# Send notification to user
if request and len(vehicles) > 0:
notify_vehicle_recommended(db, request.user_id, request_id, len(vehicles))
return {"message": f"Approved {len(vehicles)} vehicles"}
@router.put("/admin/{request_id}/status")
def admin_update_request_status(
request_id: int,
new_status: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Update request status"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
request = db.query(VehicleRequest).filter(VehicleRequest.id == request_id).first()
if not request:
raise HTTPException(status_code=404, detail="Request not found")
request.status = new_status
request.admin_reviewed_at = datetime.utcnow()
db.commit()
return {"message": "Status updated"}
@router.delete("/admin/{request_id}/vehicles/{vehicle_id}")
def admin_delete_vehicle(
request_id: int,
vehicle_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Delete a recommended vehicle from a request"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
vehicle = db.query(RequestVehicle).filter(
RequestVehicle.id == vehicle_id,
RequestVehicle.request_id == request_id
).first()
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
db.delete(vehicle)
db.commit()
return {"message": "Vehicle deleted successfully"}
# =====================
# Admin: Purchased Vehicles Management
# =====================
@router.post("/admin/purchased", response_model=PurchasedVehicleResponse)
def admin_create_purchased(
vehicle_data: PurchasedVehicleCreate,
user_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Create a purchased vehicle record"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
# Calculate dealer commission if dealer is selected
dealer_commission_krw = 0
platform_commission_krw = 0
if vehicle_data.selected_dealer_id:
# Verify dealer exists and is active
dealer_info = db.query(DealerInfo).filter(
DealerInfo.id == vehicle_data.selected_dealer_id,
DealerInfo.is_active == True
).first()
if not dealer_info:
raise HTTPException(status_code=400, detail="Selected dealer not found or inactive")
# Calculate commissions
dealer_commission_krw, platform_commission_krw = calculate_dealer_commission(
vehicle_data.vehicle_price_krw, db
)
# Credit commission to dealer's account
dealer_info.total_commission_earned += dealer_commission_krw
vehicle = PurchasedVehicle(
user_id=user_id,
car_name=vehicle_data.car_name,
car_data=vehicle_data.car_data,
car_image=vehicle_data.car_image,
vehicle_price_krw=vehicle_data.vehicle_price_krw,
domestic_cost_krw=vehicle_data.domestic_cost_krw,
shipping_cost_usd=vehicle_data.shipping_cost_usd,
total_cost_krw=vehicle_data.total_cost_krw,
car_type=vehicle_data.car_type,
selected_dealer_id=vehicle_data.selected_dealer_id,
dealer_commission_krw=dealer_commission_krw,
platform_commission_krw=platform_commission_krw,
)
db.add(vehicle)
db.commit()
db.refresh(vehicle)
return vehicle
@router.get("/admin/purchased/all", response_model=List[PurchasedVehicleResponse])
def admin_get_all_purchased(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Get all purchased vehicles"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
vehicles = db.query(PurchasedVehicle).order_by(PurchasedVehicle.purchased_at.desc()).all()
return vehicles
@router.put("/admin/purchased/{vehicle_id}/status", response_model=PurchasedVehicleResponse)
def admin_update_shipping_status(
vehicle_id: int,
status_update: PurchasedVehicleUpdateStatus,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Admin: Update shipping status of a purchased vehicle"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
vehicle = db.query(PurchasedVehicle).filter(PurchasedVehicle.id == vehicle_id).first()
if not vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
vehicle.shipping_status = status_update.shipping_status
vehicle.status_updated_at = datetime.utcnow()
if status_update.current_location:
vehicle.current_location = status_update.current_location
if status_update.estimated_arrival:
vehicle.estimated_arrival = status_update.estimated_arrival
if status_update.shipping_status == 7: # Delivered (배송완료)
vehicle.delivered_at = datetime.utcnow()
db.commit()
db.refresh(vehicle)
# Send notification to user about shipping update
notify_shipping_update(db, vehicle.user_id, vehicle.id, status_update.shipping_status, vehicle.car_name)
return vehicle

View File

@@ -0,0 +1,286 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from datetime import datetime
from typing import List
from ..database import get_db
from ..models import User, VehicleShare, ShareReward, RequestVehicle
from ..models.vehicle_share import generate_share_code
from ..schemas import (
VehicleShareCreate, VehicleShareResponse,
ShareRewardResponse, ShareRewardSummary,
)
from .auth import get_current_user, get_current_user_optional
from .notification import notify_share_purchased
router = APIRouter(prefix="/share", tags=["vehicle-share"])
# Tax rate for rewards (3.3% withholding tax in Korea)
TAX_RATE = 0.033
# Reward percentage (90% of markup goes to sharer)
REWARD_RATE = 0.90
@router.post("/create", response_model=VehicleShareResponse)
def create_share(
share_data: VehicleShareCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a shareable link for a vehicle with optional price markup"""
# Get the request vehicle
request_vehicle = db.query(RequestVehicle).filter(
RequestVehicle.id == share_data.request_vehicle_id
).first()
if not request_vehicle:
raise HTTPException(status_code=404, detail="Vehicle not found")
# Check if user owns this request (through VehicleRequest)
if request_vehicle.vehicle_request.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only share vehicles from your own requests")
# Check if vehicle is approved
if not request_vehicle.is_approved:
raise HTTPException(status_code=400, detail="Only approved vehicles can be shared")
# Generate unique share code
share_code = generate_share_code()
while db.query(VehicleShare).filter(VehicleShare.share_code == share_code).first():
share_code = generate_share_code()
# Calculate prices
original_price = request_vehicle.price_krw or 0
markup = share_data.markup_amount_krw if share_data.markup_amount_krw > 0 else 0
shared_price = original_price + markup
# Create share
vehicle_share = VehicleShare(
user_id=current_user.id,
request_vehicle_id=share_data.request_vehicle_id,
share_code=share_code,
original_price_krw=original_price,
markup_amount_krw=markup,
shared_price_krw=shared_price,
)
db.add(vehicle_share)
db.commit()
db.refresh(vehicle_share)
return vehicle_share
@router.get("/my-shares", response_model=List[VehicleShareResponse])
def get_my_shares(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all vehicle shares created by current user"""
shares = db.query(VehicleShare).filter(
VehicleShare.user_id == current_user.id
).order_by(VehicleShare.created_at.desc()).all()
return shares
@router.get("/my-rewards", response_model=List[ShareRewardResponse])
def get_my_rewards(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all rewards earned from vehicle shares"""
rewards = db.query(ShareReward).filter(
ShareReward.user_id == current_user.id
).order_by(ShareReward.created_at.desc()).all()
return rewards
@router.get("/my-rewards/summary", response_model=ShareRewardSummary)
def get_rewards_summary(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get summary of share rewards"""
rewards = db.query(ShareReward).filter(
ShareReward.user_id == current_user.id
).all()
total_rewards = sum(r.net_amount for r in rewards)
total_withdrawn = sum(r.net_amount for r in rewards if r.status == "withdrawn")
pending = sum(r.net_amount for r in rewards if r.status == "pending")
approved = sum(r.net_amount for r in rewards if r.status == "approved")
return ShareRewardSummary(
total_rewards=total_rewards,
total_withdrawn=total_withdrawn,
pending_amount=pending,
available_for_withdrawal=approved,
reward_count=len(rewards)
)
@router.get("/{share_code}")
def get_shared_vehicle(
share_code: str,
current_user: User = Depends(get_current_user_optional),
db: Session = Depends(get_db)
):
"""Get shared vehicle details (public endpoint)"""
share = db.query(VehicleShare).filter(
VehicleShare.share_code == share_code
).first()
if not share:
raise HTTPException(status_code=404, detail="Shared vehicle not found")
# Increment view count
share.view_count += 1
db.commit()
# Get vehicle details
vehicle = share.request_vehicle
return {
"share": {
"id": share.id,
"share_code": share.share_code,
"shared_price_krw": share.shared_price_krw,
"original_price_krw": share.original_price_krw,
"markup_amount_krw": share.markup_amount_krw,
"view_count": share.view_count,
"is_purchased": share.is_purchased,
"created_at": share.created_at,
},
"vehicle": {
"id": vehicle.id,
"car_id": vehicle.car_id,
"maker": vehicle.maker,
"model": vehicle.model,
"year": vehicle.year,
"mileage": vehicle.mileage,
"fuel_type": vehicle.fuel_type,
"color": vehicle.color,
"grade": vehicle.grade,
"image_url": vehicle.image_url,
"performance_check_url": vehicle.performance_check_url,
"dealer_name": vehicle.dealer_name,
"dealer_phone": vehicle.dealer_phone,
},
"sharer": {
"name": share.user.name or "Anonymous",
}
}
@router.post("/{share_code}/purchase")
def purchase_shared_vehicle(
share_code: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Purchase a vehicle through a shared link"""
share = db.query(VehicleShare).filter(
VehicleShare.share_code == share_code
).first()
if not share:
raise HTTPException(status_code=404, detail="Shared vehicle not found")
if share.is_purchased:
raise HTTPException(status_code=400, detail="This vehicle has already been purchased")
if share.user_id == current_user.id:
raise HTTPException(status_code=400, detail="You cannot purchase your own shared vehicle")
# Mark as purchased
share.is_purchased = True
share.purchased_by_user_id = current_user.id
share.purchased_at = datetime.utcnow()
# Create reward for the sharer (if there's markup)
reward_net = 0
if share.markup_amount_krw > 0:
reward_amount = share.markup_amount_krw * REWARD_RATE # 90%
tax_amount = reward_amount * TAX_RATE # 3.3% tax
net_amount = reward_amount - tax_amount
reward_net = net_amount
reward = ShareReward(
user_id=share.user_id,
vehicle_share_id=share.id,
markup_amount=share.markup_amount_krw,
reward_amount=reward_amount,
tax_amount=tax_amount,
net_amount=net_amount,
status="pending" # Needs admin approval
)
db.add(reward)
db.commit()
# Send notification to sharer about the sale
vehicle = share.request_vehicle
car_name = f"{vehicle.maker} {vehicle.model}" if vehicle else "차량"
notify_share_purchased(db, share.user_id, share.id, reward_net, car_name)
return {
"message": "Vehicle purchase initiated",
"share_code": share_code,
"price": share.shared_price_krw
}
# Admin endpoints
@router.get("/admin/all", response_model=List[VehicleShareResponse])
def get_all_shares(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all vehicle shares"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
shares = db.query(VehicleShare).order_by(VehicleShare.created_at.desc()).all()
return shares
@router.get("/admin/rewards", response_model=List[ShareRewardResponse])
def get_all_rewards(
status_filter: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all share rewards"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
query = db.query(ShareReward)
if status_filter:
query = query.filter(ShareReward.status == status_filter)
rewards = query.order_by(ShareReward.created_at.desc()).all()
return rewards
@router.put("/admin/rewards/{reward_id}/approve")
def approve_reward(
reward_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Approve a share reward for withdrawal"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
reward = db.query(ShareReward).filter(ShareReward.id == reward_id).first()
if not reward:
raise HTTPException(status_code=404, detail="Reward not found")
if reward.status != "pending":
raise HTTPException(status_code=400, detail="Reward is not pending")
reward.status = "approved"
db.commit()
return {"message": "Reward approved", "reward_id": reward_id}

View File

@@ -0,0 +1,231 @@
"""
Verification API Endpoints
Handles email and phone verification for users
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
from ..database import get_db
from ..models import User
from ..services import verification_service
from .auth import get_current_user, get_current_user_optional
router = APIRouter(prefix="/verification", tags=["verification"])
# Request/Response schemas
class SendEmailCodeRequest(BaseModel):
email: EmailStr
language: str = "en"
class SendPhoneCodeRequest(BaseModel):
phone: str
language: str = "en"
class VerifyCodeRequest(BaseModel):
code: str
email: Optional[str] = None
phone: Optional[str] = None
class VerificationResponse(BaseModel):
success: bool
message: str
class VerificationStatusResponse(BaseModel):
email_verified: bool
phone_verified: bool
email: Optional[str] = None
phone: Optional[str] = None
# Email Verification Endpoints
@router.post("/email/send", response_model=VerificationResponse)
async def send_email_code(
request: SendEmailCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_optional)
):
"""Send email verification code"""
user_id = current_user.id if current_user else None
# If user is logged in, only allow sending to their email
if current_user and current_user.email != request.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You can only verify your own email address"
)
success, message = await verification_service.send_email_verification(
db=db,
email=request.email,
user_id=user_id,
language=request.language
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return VerificationResponse(success=True, message=message)
@router.post("/email/verify", response_model=VerificationResponse)
async def verify_email_code(
request: VerifyCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_optional)
):
"""Verify email code"""
email = request.email
if current_user:
email = current_user.email
if not email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
success, message = verification_service.verify_code(
db=db,
code=request.code,
code_type="email",
email=email
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
# If user is logged in, mark their email as verified
if current_user:
verification_service.mark_email_verified(db, current_user)
return VerificationResponse(success=True, message=message)
# Phone Verification Endpoints
@router.post("/phone/send", response_model=VerificationResponse)
async def send_phone_code(
request: SendPhoneCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) # Requires login
):
"""Send phone verification code (requires login)"""
success, message = await verification_service.send_sms_verification(
db=db,
phone=request.phone,
user_id=current_user.id,
language=request.language
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return VerificationResponse(success=True, message=message)
@router.post("/phone/verify", response_model=VerificationResponse)
async def verify_phone_code(
request: VerifyCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) # Requires login
):
"""Verify phone code (requires login)"""
if not request.phone:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Phone number is required"
)
# Normalize phone number
phone = request.phone.strip().replace(" ", "").replace("-", "")
if not phone.startswith("+"):
if phone.startswith("9") and len(phone) == 8:
phone = "+976" + phone
success, message = verification_service.verify_code(
db=db,
code=request.code,
code_type="phone",
phone=phone
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
# Mark phone as verified
verification_service.mark_phone_verified(db, current_user, phone)
return VerificationResponse(success=True, message=message)
# Status Endpoint
@router.get("/status", response_model=VerificationStatusResponse)
async def get_verification_status(
current_user: User = Depends(get_current_user),
):
"""Get current user's verification status"""
return VerificationStatusResponse(
email_verified=current_user.email_verified or False,
phone_verified=current_user.phone_verified or False,
email=current_user.email,
phone=current_user.phone
)
# Pre-registration email verification (for signup flow)
@router.post("/email/send-preregister", response_model=VerificationResponse)
async def send_preregister_email_code(
request: SendEmailCodeRequest,
db: Session = Depends(get_db)
):
"""Send email verification code for new registration (no login required)"""
# Check if email is already registered
existing = db.query(User).filter(User.email == request.email).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This email is already registered"
)
success, message = await verification_service.send_email_verification(
db=db,
email=request.email,
user_id=None,
language=request.language
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return VerificationResponse(success=True, message=message)
@router.post("/email/verify-preregister", response_model=VerificationResponse)
async def verify_preregister_email_code(
request: VerifyCodeRequest,
db: Session = Depends(get_db)
):
"""Verify email code for new registration (no login required)"""
if not request.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is required"
)
success, message = verification_service.verify_code(
db=db,
code=request.code,
code_type="email",
email=request.email
)
if not success:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
return VerificationResponse(success=True, message=message)

334
backend/app/api/visitor.py Normal file
View File

@@ -0,0 +1,334 @@
"""
Visitor Analytics API
"""
from fastapi import APIRouter, Depends, Request, BackgroundTasks
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, desc
from datetime import datetime, timedelta
from typing import Optional, List
from pydantic import BaseModel
from ..database import get_db
from ..models.visitor import VisitorLog, VisitorDailyStats
from ..models import User
from ..services.visitor_service import log_visit, aggregate_daily_stats
from .auth import get_current_admin_user, get_current_user_optional
router = APIRouter(prefix="/visitor", tags=["Visitor Analytics"])
# Pydantic schemas
class VisitLogRequest(BaseModel):
page_path: str
page_title: Optional[str] = None
referrer: Optional[str] = None
session_id: Optional[str] = None
utm_source: Optional[str] = None
utm_medium: Optional[str] = None
utm_campaign: Optional[str] = None
class VisitorStatsResponse(BaseModel):
total_visits: int
unique_visitors: int
device_breakdown: dict
browser_breakdown: dict
country_breakdown: dict
class ChartData(BaseModel):
labels: List[str]
values: List[int]
class TopPage(BaseModel):
path: str
views: int
title: Optional[str] = None
class TopReferrer(BaseModel):
domain: str
visits: int
# Background task wrapper for async log_visit
async def _log_visit_background(
db: Session,
ip: str,
user_agent: str,
page_path: str,
page_title: Optional[str],
referrer: Optional[str],
session_id: Optional[str],
user_id: Optional[int],
utm_source: Optional[str],
utm_medium: Optional[str],
utm_campaign: Optional[str],
):
"""Background wrapper for log_visit"""
try:
await log_visit(
db, ip, user_agent, page_path, page_title,
referrer, session_id, user_id,
utm_source, utm_medium, utm_campaign
)
except Exception as e:
print(f"[Visitor] Log visit failed: {e}")
# Public endpoint for logging visits
@router.post("/log")
async def log_page_visit(
visit_data: VisitLogRequest,
request: Request,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user_optional),
):
"""
Log a page visit (called from frontend)
"""
# Get client IP (handle proxies)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
ip = forwarded_for.split(",")[0].strip()
else:
ip = request.client.host if request.client else "unknown"
user_agent = request.headers.get("User-Agent", "")
user_id = current_user.id if current_user else None
# Log visit directly (async)
try:
await log_visit(
db,
ip,
user_agent,
visit_data.page_path,
visit_data.page_title,
visit_data.referrer,
visit_data.session_id,
user_id,
visit_data.utm_source,
visit_data.utm_medium,
visit_data.utm_campaign,
)
except Exception as e:
print(f"[Visitor] Log visit failed: {e}")
return {"status": "logged"}
# Admin endpoints
@router.get("/admin/overview", response_model=VisitorStatsResponse)
def get_visitor_overview(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get visitor statistics overview for last N days"""
start_date = datetime.utcnow() - timedelta(days=days)
# Total visits
total_visits = db.query(func.count(VisitorLog.id)).filter(
VisitorLog.visited_at >= start_date
).scalar() or 0
# Unique visitors
unique_visitors = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
VisitorLog.visited_at >= start_date
).scalar() or 0
# Device breakdown
device_query = db.query(
VisitorLog.device_type,
func.count(VisitorLog.id)
).filter(
VisitorLog.visited_at >= start_date
).group_by(VisitorLog.device_type).all()
device_breakdown = {d[0] or "unknown": d[1] for d in device_query}
# Browser breakdown
browser_query = db.query(
VisitorLog.browser,
func.count(VisitorLog.id)
).filter(
VisitorLog.visited_at >= start_date
).group_by(VisitorLog.browser).all()
browser_breakdown = {b[0] or "unknown": b[1] for b in browser_query}
# Country breakdown
country_query = db.query(
VisitorLog.country_code,
func.count(VisitorLog.id)
).filter(
VisitorLog.visited_at >= start_date
).group_by(VisitorLog.country_code).all()
country_breakdown = {c[0] or "unknown": c[1] for c in country_query}
return VisitorStatsResponse(
total_visits=total_visits,
unique_visitors=unique_visitors,
device_breakdown=device_breakdown,
browser_breakdown=browser_breakdown,
country_breakdown=country_breakdown,
)
@router.get("/admin/chart/visits", response_model=ChartData)
def get_visits_chart(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get daily visits chart data"""
today = datetime.utcnow().date()
labels = []
values = []
for i in range(days - 1, -1, -1):
date = today - timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
count = db.query(func.count(VisitorLog.id)).filter(
func.date(VisitorLog.visited_at) == date_str
).scalar() or 0
labels.append(date.strftime("%m/%d"))
values.append(count)
return ChartData(labels=labels, values=values)
@router.get("/admin/chart/unique-visitors", response_model=ChartData)
def get_unique_visitors_chart(
days: int = 30,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get daily unique visitors chart data"""
today = datetime.utcnow().date()
labels = []
values = []
for i in range(days - 1, -1, -1):
date = today - timedelta(days=i)
date_str = date.strftime("%Y-%m-%d")
count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
func.date(VisitorLog.visited_at) == date_str
).scalar() or 0
labels.append(date.strftime("%m/%d"))
values.append(count)
return ChartData(labels=labels, values=values)
@router.get("/admin/top-pages", response_model=List[TopPage])
def get_top_pages(
days: int = 30,
limit: int = 20,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get top visited pages"""
start_date = datetime.utcnow() - timedelta(days=days)
pages = db.query(
VisitorLog.page_path,
VisitorLog.page_title,
func.count(VisitorLog.id).label("views")
).filter(
VisitorLog.visited_at >= start_date
).group_by(
VisitorLog.page_path, VisitorLog.page_title
).order_by(
desc("views")
).limit(limit).all()
return [
TopPage(path=p[0], title=p[1], views=p[2])
for p in pages
]
@router.get("/admin/top-referrers", response_model=List[TopReferrer])
def get_top_referrers(
days: int = 30,
limit: int = 10,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get top referrer sources"""
start_date = datetime.utcnow() - timedelta(days=days)
referrers = db.query(
VisitorLog.referrer_domain,
func.count(VisitorLog.id).label("visits")
).filter(
and_(
VisitorLog.visited_at >= start_date,
VisitorLog.referrer_domain.isnot(None),
VisitorLog.referrer_domain != ""
)
).group_by(
VisitorLog.referrer_domain
).order_by(
desc("visits")
).limit(limit).all()
return [
TopReferrer(domain=r[0], visits=r[1])
for r in referrers
]
@router.get("/admin/realtime")
def get_realtime_visitors(
minutes: int = 5,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Get visitors in the last N minutes (real-time)"""
start_time = datetime.utcnow() - timedelta(minutes=minutes)
active_count = db.query(func.count(func.distinct(VisitorLog.visitor_hash))).filter(
VisitorLog.visited_at >= start_time
).scalar() or 0
# Recent pages
recent_pages = db.query(
VisitorLog.page_path,
func.count(VisitorLog.id).label("views")
).filter(
VisitorLog.visited_at >= start_time
).group_by(
VisitorLog.page_path
).order_by(
desc("views")
).limit(5).all()
return {
"active_visitors": active_count,
"minutes": minutes,
"recent_pages": [{"path": p[0], "views": p[1]} for p in recent_pages],
}
@router.post("/admin/aggregate/{date_str}")
def trigger_aggregation(
date_str: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user),
):
"""Manually trigger aggregation for a specific date (YYYY-MM-DD)"""
result = aggregate_daily_stats(db, date_str)
if result:
return {"status": "success", "date": date_str}
return {"status": "no_data", "date": date_str}

View File

@@ -0,0 +1,217 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func as sql_func
from datetime import datetime
from typing import List
from ..database import get_db
from ..models import User, WithdrawalRequest, DealerInfo, ShareReward, ReferralReward
from ..schemas import (
WithdrawalRequestCreate, WithdrawalRequestResponse,
WithdrawalProcess, WithdrawalBalance,
)
from .auth import get_current_user
from .notification import notify_withdrawal_processed
router = APIRouter(prefix="/withdrawal", tags=["withdrawal"])
# Tax rate (3.3% withholding)
TAX_RATE = 0.033
def calculate_user_balance(user: User, db: Session) -> WithdrawalBalance:
"""Calculate user's withdrawal balance from all sources"""
total_earned = 0.0
total_withdrawn = 0.0
pending_withdrawal = 0.0
# Get dealer earnings if user is a dealer
if user.is_dealer:
dealer_info = db.query(DealerInfo).filter(DealerInfo.user_id == user.id).first()
if dealer_info:
total_earned += dealer_info.total_commission_earned
total_withdrawn += dealer_info.total_withdrawn
# Get share rewards
share_rewards = db.query(ShareReward).filter(
ShareReward.user_id == user.id,
ShareReward.status.in_(["approved", "withdrawn"])
).all()
for reward in share_rewards:
total_earned += reward.net_amount
if reward.status == "withdrawn":
total_withdrawn += reward.net_amount
# Get referral rewards
referral_rewards = db.query(ReferralReward).filter(
ReferralReward.referrer_id == user.id,
ReferralReward.status.in_(["credited", "withdrawn"])
).all()
for reward in referral_rewards:
total_earned += reward.reward_amount
if reward.status == "withdrawn":
total_withdrawn += reward.reward_amount
# Get pending withdrawals
pending_requests = db.query(WithdrawalRequest).filter(
WithdrawalRequest.user_id == user.id,
WithdrawalRequest.status.in_(["pending", "approved"])
).all()
for req in pending_requests:
pending_withdrawal += req.net_amount
available_balance = total_earned - total_withdrawn - pending_withdrawal
return WithdrawalBalance(
total_earned=total_earned,
total_withdrawn=total_withdrawn,
pending_withdrawal=pending_withdrawal,
available_balance=max(0, available_balance)
)
@router.get("/balance", response_model=WithdrawalBalance)
def get_balance(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's withdrawal balance"""
return calculate_user_balance(current_user, db)
@router.post("/request", response_model=WithdrawalRequestResponse)
def create_withdrawal_request(
request_data: WithdrawalRequestCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new withdrawal request"""
# Check balance
balance = calculate_user_balance(current_user, db)
if request_data.amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
if request_data.amount > balance.available_balance:
raise HTTPException(
status_code=400,
detail=f"Insufficient balance. Available: {balance.available_balance}"
)
# Minimum withdrawal amount
MIN_WITHDRAWAL = 10 # 10 USD minimum
if request_data.amount < MIN_WITHDRAWAL:
raise HTTPException(
status_code=400,
detail=f"Minimum withdrawal amount is ${MIN_WITHDRAWAL} USD"
)
# Calculate tax and net amount
tax_amount = request_data.amount * TAX_RATE
net_amount = request_data.amount - tax_amount
# Create withdrawal request
withdrawal = WithdrawalRequest(
user_id=current_user.id,
amount=request_data.amount,
tax_withheld=tax_amount,
net_amount=net_amount,
bank_name=request_data.bank_name,
bank_account=request_data.bank_account,
account_holder=request_data.account_holder,
status="pending"
)
db.add(withdrawal)
db.commit()
db.refresh(withdrawal)
return withdrawal
@router.get("/my-requests", response_model=List[WithdrawalRequestResponse])
def get_my_requests(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user's withdrawal requests"""
requests = db.query(WithdrawalRequest).filter(
WithdrawalRequest.user_id == current_user.id
).order_by(WithdrawalRequest.requested_at.desc()).all()
return requests
# Admin endpoints
@router.get("/admin/list", response_model=List[WithdrawalRequestResponse])
def get_all_requests(
status_filter: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Get all withdrawal requests"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
query = db.query(WithdrawalRequest)
if status_filter:
query = query.filter(WithdrawalRequest.status == status_filter)
requests = query.order_by(WithdrawalRequest.requested_at.desc()).all()
return requests
@router.put("/admin/{request_id}/process", response_model=WithdrawalRequestResponse)
def process_withdrawal(
request_id: int,
process_data: WithdrawalProcess,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""[Admin] Process a withdrawal request"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
withdrawal = db.query(WithdrawalRequest).filter(
WithdrawalRequest.id == request_id
).first()
if not withdrawal:
raise HTTPException(status_code=404, detail="Request not found")
valid_statuses = ["approved", "completed", "rejected"]
if process_data.status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {valid_statuses}"
)
# Update status
withdrawal.status = process_data.status
withdrawal.admin_note = process_data.admin_note
withdrawal.processed_at = datetime.utcnow()
# If completed, update user's withdrawal totals
if process_data.status == "completed":
user = db.query(User).filter(User.id == withdrawal.user_id).first()
# Update dealer info if applicable
if user.is_dealer:
dealer_info = db.query(DealerInfo).filter(
DealerInfo.user_id == user.id
).first()
if dealer_info:
dealer_info.total_withdrawn += withdrawal.net_amount
# Mark related share rewards as withdrawn
# (This is a simplified version - in production you'd track which specific rewards were withdrawn)
db.commit()
db.refresh(withdrawal)
# Send notification to user about withdrawal status
notify_withdrawal_processed(db, withdrawal.user_id, withdrawal.id, process_data.status, withdrawal.net_amount)
return withdrawal

80
backend/app/config.py Normal file
View File

@@ -0,0 +1,80 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
import os
class Settings(BaseSettings):
# Database - Local SQLite or Remote PostgreSQL
USE_SQLITE: bool = True # Set to False for production PostgreSQL
DB_HOST: str = "192.168.0.201"
DB_PORT: int = 5432
DB_NAME: str = "autonet"
DB_USER: str = "admin"
DB_PASSWORD: str = ""
# Redis
REDIS_HOST: str = "192.168.0.201"
REDIS_PORT: int = 6379
REDIS_PASSWORD: str = ""
# JWT
SECRET_KEY: str = "your-secret-key-for-dev-123"
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 480 # 8 hours for development
# Agent
AGENT_API_KEY: str = ""
# App
DEBUG: bool = True
# Stripe
STRIPE_SECRET_KEY: str = "" # sk_test_... or sk_live_...
STRIPE_PUBLISHABLE_KEY: str = "" # pk_test_... or pk_live_...
STRIPE_WEBHOOK_SECRET: str = "" # whsec_...
STRIPE_SUCCESS_URL: str = "http://localhost:3000/cc/success"
STRIPE_CANCEL_URL: str = "http://localhost:3000/cc/purchase"
# Azure Translator
AZURE_TRANSLATOR_KEY: str = ""
AZURE_TRANSLATOR_REGION: str = "koreacentral"
# Email Settings (SMTP)
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = "" # App password for Gmail
SMTP_FROM_EMAIL: str = ""
SMTP_FROM_NAME: str = "AutonetSellCar"
# SMS Settings (Twilio)
TWILIO_ACCOUNT_SID: str = ""
TWILIO_AUTH_TOKEN: str = ""
TWILIO_PHONE_NUMBER: str = "" # Your Twilio phone number
# Verification Settings
VERIFICATION_CODE_EXPIRE_MINUTES: int = 10
EMAIL_VERIFICATION_REQUIRED: bool = True # Require email verification for signup
PHONE_VERIFICATION_REQUIRED_FOR_CC: bool = True # Require phone for CC charging
@property
def DATABASE_URL(self) -> str:
if self.USE_SQLITE:
# Get the backend directory path
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
db_path = os.path.join(base_dir, "autonet.db")
return f"sqlite:///{db_path}"
return f"postgresql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
@property
def REDIS_URL(self) -> str:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/0"
class Config:
env_file = ".env"
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings - updated with SMTP credentials"""
return Settings()

View File

@@ -0,0 +1,174 @@
{
"makers": [
{"code": "5", "name": "현대"},
{"code": "146", "name": "제네시스"},
{"code": "2", "name": "기아"},
{"code": "1", "name": "쉐보레(대우)"},
{"code": "3", "name": "르노(삼성)"},
{"code": "4", "name": "KG모빌리티(쌍용)"},
{"code": "76", "name": "닛산"},
{"code": "78", "name": "렉서스"},
{"code": "77", "name": "토요타"},
{"code": "125", "name": "혼다"}
],
"models": {
"5": [
{"code": "93", "name": "i30"},
{"code": "94", "name": "i40"},
{"code": "96", "name": "그랜저"},
{"code": "1185", "name": "넥쏘"},
{"code": "1243", "name": "베뉴"},
{"code": "106", "name": "벨로스터"},
{"code": "108", "name": "스타렉스"},
{"code": "1421", "name": "스타리아"},
{"code": "110", "name": "싼타페"},
{"code": "111", "name": "쏘나타"},
{"code": "112", "name": "아반떼"},
{"code": "114", "name": "아이오닉"},
{"code": "1594", "name": "아이오닉 5"},
{"code": "1595", "name": "아이오닉 6"},
{"code": "116", "name": "에쿠스"},
{"code": "1467", "name": "캐스퍼"},
{"code": "1167", "name": "코나"},
{"code": "124", "name": "투싼"},
{"code": "1207", "name": "팰리세이드"},
{"code": "129", "name": "포터"}
],
"146": [
{"code": "763", "name": "EQ900"},
{"code": "1172", "name": "G70"},
{"code": "1171", "name": "G80"},
{"code": "1205", "name": "G90"},
{"code": "1469", "name": "GV60"},
{"code": "1389", "name": "GV70"},
{"code": "1272", "name": "GV80"}
],
"2": [
{"code": "1755", "name": "EV3"},
{"code": "1455", "name": "EV6"},
{"code": "1701", "name": "EV9"},
{"code": "37", "name": "K3"},
{"code": "38", "name": "K5"},
{"code": "39", "name": "K7"},
{"code": "1420", "name": "K8"},
{"code": "40", "name": "K9"},
{"code": "41", "name": "니로"},
{"code": "42", "name": "레이"},
{"code": "46", "name": "모닝"},
{"code": "47", "name": "모하비"},
{"code": "49", "name": "봉고"},
{"code": "1244", "name": "셀토스"},
{"code": "1168", "name": "스토닉"},
{"code": "1160", "name": "스팅어"},
{"code": "54", "name": "스포티지"},
{"code": "56", "name": "쏘렌토"},
{"code": "57", "name": "쏘울"},
{"code": "64", "name": "카니발"},
{"code": "1380", "name": "텔루라이드"},
{"code": "71", "name": "포르테"}
],
"1": [
{"code": "4", "name": "다마스"},
{"code": "6", "name": "라보"},
{"code": "12", "name": "말리부"},
{"code": "1154", "name": "볼트"},
{"code": "18", "name": "스파크"},
{"code": "24", "name": "올란도"},
{"code": "1196", "name": "이쿼녹스"},
{"code": "28", "name": "카마로"},
{"code": "30", "name": "캡티바"},
{"code": "1249", "name": "콜로라도"},
{"code": "32", "name": "크루즈"},
{"code": "1251", "name": "트래버스"},
{"code": "34", "name": "트랙스"},
{"code": "1273", "name": "트레일블레이저"}
],
"3": [
{"code": "75", "name": "QM3"},
{"code": "76", "name": "QM5"},
{"code": "1137", "name": "QM6"},
{"code": "77", "name": "SM3"},
{"code": "79", "name": "SM5"},
{"code": "80", "name": "SM6"},
{"code": "81", "name": "SM7"},
{"code": "1279", "name": "XM3"},
{"code": "1765", "name": "그랑 콜레오스"},
{"code": "1204", "name": "마스터"},
{"code": "1748", "name": "아르카나"},
{"code": "1345", "name": "캡쳐"}
],
"4": [
{"code": "83", "name": "렉스턴"},
{"code": "84", "name": "로디우스"},
{"code": "88", "name": "체어맨"},
{"code": "90", "name": "코란도"},
{"code": "1573", "name": "토레스"},
{"code": "91", "name": "티볼리"}
],
"76": [
{"code": "271", "name": "GT-R"},
{"code": "1418", "name": "노트"},
{"code": "273", "name": "로그"},
{"code": "275", "name": "리프"},
{"code": "277", "name": "맥시마"},
{"code": "279", "name": "무라노"},
{"code": "289", "name": "알티마"},
{"code": "1216", "name": "엑스트레일"},
{"code": "293", "name": "쥬크"},
{"code": "294", "name": "캐시카이"},
{"code": "295", "name": "퀘스트"},
{"code": "297", "name": "티아나"},
{"code": "298", "name": "패스파인더"}
],
"78": [
{"code": "342", "name": "CT"},
{"code": "343", "name": "ES"},
{"code": "344", "name": "GS"},
{"code": "345", "name": "GX"},
{"code": "346", "name": "IS"},
{"code": "1161", "name": "LC"},
{"code": "347", "name": "LS"},
{"code": "348", "name": "LX"},
{"code": "349", "name": "NX"},
{"code": "350", "name": "RC"},
{"code": "351", "name": "RX"},
{"code": "1224", "name": "UX"}
],
"77": [
{"code": "302", "name": "86"},
{"code": "1195", "name": "C-HR"},
{"code": "304", "name": "FJ 크루져"},
{"code": "301", "name": "GR86"},
{"code": "308", "name": "라브4"},
{"code": "1326", "name": "랜드크루저"},
{"code": "324", "name": "아발론"},
{"code": "326", "name": "알파드"},
{"code": "327", "name": "에스티마"},
{"code": "317", "name": "세콰이어"},
{"code": "323", "name": "시에나"},
{"code": "1232", "name": "시엔타"},
{"code": "330", "name": "캠리"},
{"code": "331", "name": "코롤라"},
{"code": "332", "name": "크라운"},
{"code": "333", "name": "타코마"},
{"code": "334", "name": "툰드라"},
{"code": "338", "name": "프리우스"},
{"code": "339", "name": "하이랜더"},
{"code": "322", "name": "수프라"}
],
"125": [
{"code": "702", "name": "CR-V"},
{"code": "703", "name": "CR-Z"},
{"code": "1130", "name": "HR-V"},
{"code": "705", "name": "S2000"},
{"code": "1231", "name": "베젤"},
{"code": "1416", "name": "스텝웨건"},
{"code": "710", "name": "시빅"},
{"code": "711", "name": "어코드"},
{"code": "713", "name": "오딧세이"},
{"code": "714", "name": "인사이트"},
{"code": "719", "name": "파일럿"},
{"code": "722", "name": "피트"}
]
}
}

27
backend/app/database.py Normal file
View File

@@ -0,0 +1,27 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import get_settings
settings = get_settings()
# SQLite needs check_same_thread=False for FastAPI
if settings.USE_SQLITE:
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}
)
else:
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

168
backend/app/main.py Normal file
View File

@@ -0,0 +1,168 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import os
import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor
from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
from datetime import datetime, timedelta
app_settings = get_settings()
# Create tables
Base.metadata.create_all(bind=engine)
# APScheduler 설정
scheduler = AsyncIOScheduler()
async def scheduled_update_exchange_rates():
"""스케줄된 환율 업데이트 작업"""
print("[Scheduler] Starting daily exchange rate update...")
db = SessionLocal()
try:
result = await update_exchange_rates(db, force=True)
print(f"[Scheduler] Exchange rate update completed: {result}")
except Exception as e:
print(f"[Scheduler] Exchange rate update failed: {e}")
finally:
db.close()
async def scheduled_aggregate_visitor_stats():
"""Aggregate yesterday's visitor stats"""
print("[Scheduler] Aggregating visitor stats...")
db = SessionLocal()
try:
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
result = aggregate_daily_stats(db, yesterday)
if result:
print(f"[Scheduler] Visitor stats aggregated for {yesterday}")
else:
print(f"[Scheduler] No visitor data for {yesterday}")
except Exception as e:
print(f"[Scheduler] Visitor stats aggregation failed: {e}")
finally:
db.close()
async def scheduled_cleanup_old_visitor_logs():
"""Delete visitor logs older than 90 days"""
print("[Scheduler] Cleaning up old visitor logs...")
db = SessionLocal()
try:
deleted = cleanup_old_visitor_logs(db, days=90)
print(f"[Scheduler] Deleted {deleted} old visitor logs")
except Exception as e:
print(f"[Scheduler] Visitor log cleanup failed: {e}")
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행되는 lifespan 이벤트"""
# 시작 시
print("[Startup] Initializing scheduler...")
# 환율 업데이트 스케줄 등록 (매일 오전 11시 30분 - 수출입은행 11시경 업데이트)
scheduler.add_job(
scheduled_update_exchange_rates,
CronTrigger(hour=11, minute=30),
id="daily_exchange_rate_update",
name="Daily Exchange Rate Update",
replace_existing=True
)
# 방문자 통계 집계 (매일 새벽 2시)
scheduler.add_job(
scheduled_aggregate_visitor_stats,
CronTrigger(hour=2, minute=0),
id="daily_visitor_stats_aggregation",
name="Daily Visitor Stats Aggregation",
replace_existing=True
)
# 오래된 방문자 로그 정리 (매주 일요일 새벽 3시)
scheduler.add_job(
scheduled_cleanup_old_visitor_logs,
CronTrigger(day_of_week='sun', hour=3, minute=0),
id="weekly_visitor_log_cleanup",
name="Weekly Visitor Log Cleanup",
replace_existing=True
)
scheduler.start()
print("[Startup] Scheduler started - Exchange rates: 11:30 AM, Visitor stats: 2:00 AM, Log cleanup: Sunday 3:00 AM")
# 서버 시작 시 환율 데이터 초기화 (백그라운드에서)
asyncio.create_task(scheduled_update_exchange_rates())
yield
# 종료 시
print("[Shutdown] Stopping scheduler...")
scheduler.shutdown()
app = FastAPI(
title="AutonetSellCar API",
description="AutonetSellCar - Used Car Export Platform API",
version="1.0.0",
lifespan=lifespan
)
# CORS - credentials=True requires explicit origins (not "*")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:8000",
"http://192.168.0.202:3000", # Local network
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static files for uploads
os.makedirs("./uploads/hero-banners", exist_ok=True)
app.mount("/uploads", StaticFiles(directory="./uploads"), name="uploads")
# Routes
app.include_router(cars.router, prefix="/api")
app.include_router(auth.router, prefix="/api")
app.include_router(inquiries.router, prefix="/api")
app.include_router(hero_banners.router, prefix="/api")
app.include_router(carmodoo.router, prefix="/api")
app.include_router(translations.router, prefix="/api")
app.include_router(cc.router, prefix="/api")
app.include_router(settings.router, prefix="/api")
app.include_router(vehicle_requests.router, prefix="/api")
app.include_router(dealer.router, prefix="/api")
app.include_router(vehicle_share.router, prefix="/api")
app.include_router(withdrawal.router, prefix="/api")
app.include_router(referral.router, prefix="/api")
app.include_router(notification.router, prefix="/api")
app.include_router(dashboard.router, prefix="/api")
app.include_router(push.router, prefix="/api")
app.include_router(exchange_rate.router)
app.include_router(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api")
@app.get("/")
def root():
return {"message": "AutonetSellCar API", "version": "1.0.0"}
@app.get("/health")
def health():
return {"status": "healthy"}

View File

@@ -0,0 +1,64 @@
from .car import CarMaker, CarModel, Car, CarImage, CarOption
from .user import User, CarView, PerformanceCheckView, ChargeHistory, VerificationCode
from .inquiry import Inquiry, InquiryMessage, InquiryStatus, InquiryCategory
from .hero_banner import HeroBanner, HeroBannerSettings
from .translation import Translation
from .cache import CarCache, CarDetailCache, CacheRequestQueue
from .settings import SystemSettings
from .vehicle_request import VehicleRequest, RequestVehicle, PurchasedVehicle
from .dealer import DealerApplication, DealerInfo
from .vehicle_share import VehicleShare, ShareReward
from .withdrawal import WithdrawalRequest
from .referral import ReferralReward
from .notification import Notification
from .push_subscription import PushSubscription, UserNotificationPreference
from .performance_check import CarPerformanceCheck
from .car_specification import CarSpecification
from .exchange_rate import ExchangeRate, ExchangeRateHistory
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
__all__ = [
"CarMaker",
"CarModel",
"Car",
"CarImage",
"CarOption",
"CarPerformanceCheck",
"CarSpecification",
"User",
"CarView",
"PerformanceCheckView",
"ChargeHistory",
"VerificationCode",
"Inquiry",
"InquiryMessage",
"InquiryStatus",
"InquiryCategory",
"HeroBanner",
"HeroBannerSettings",
"Translation",
"CarCache",
"CarDetailCache",
"CacheRequestQueue",
"SystemSettings",
"VehicleRequest",
"RequestVehicle",
"PurchasedVehicle",
"DealerApplication",
"DealerInfo",
"VehicleShare",
"ShareReward",
"WithdrawalRequest",
"ReferralReward",
"Notification",
"PushSubscription",
"UserNotificationPreference",
"ExchangeRate",
"ExchangeRateHistory",
"CCPackage",
"DEFAULT_CC_PACKAGES",
"VisitorLog",
"VisitorDailyStats",
"VisitorSession",
]

View File

@@ -0,0 +1,75 @@
"""
캐시 모델 - 카모두 검색 결과 캐싱
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Index
from sqlalchemy.sql import func
from ..database import Base
class CarCache(Base):
"""
검색 결과 캐시 테이블 (Maker + Model 단위)
캐시 키: maker_code_model_code (예: "2_38" = 기아_K5)
"""
__tablename__ = "car_cache"
id = Column(Integer, primary_key=True, index=True)
cache_key = Column(String(50), unique=True, nullable=False, index=True)
maker_code = Column(String(10), nullable=False)
maker_name = Column(String(100), nullable=False)
model_code = Column(String(10), nullable=False)
model_name = Column(String(100), nullable=False)
total_count = Column(Integer, nullable=False, default=0)
cars_data = Column(Text, nullable=False) # JSON: 전체 차량 목록
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (
Index('idx_car_cache_expires', 'expires_at'),
Index('idx_car_cache_maker_model', 'maker_code', 'model_code'),
)
class CarDetailCache(Base):
"""
개별 차량 상세 정보 캐시 테이블
"""
__tablename__ = "car_detail_cache"
id = Column(Integer, primary_key=True, index=True)
car_id = Column(String(50), unique=True, nullable=False, index=True) # 카모두 차량 ID
detail_data = Column(Text, nullable=False) # JSON: 상세 정보
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
__table_args__ = (
Index('idx_car_detail_cache_expires', 'expires_at'),
)
class CacheRequestQueue(Base):
"""
캐시 요청 대기열 - 동일 조건 요청 병합용
"""
__tablename__ = "cache_request_queue"
id = Column(Integer, primary_key=True, index=True)
cache_key = Column(String(50), nullable=False, index=True)
status = Column(String(20), nullable=False, default='pending') # pending, processing, completed, failed
created_at = Column(DateTime(timezone=True), server_default=func.now())
started_at = Column(DateTime(timezone=True))
completed_at = Column(DateTime(timezone=True))
error_message = Column(Text)
__table_args__ = (
Index('idx_cache_request_status', 'status', 'cache_key'),
)

110
backend/app/models/car.py Normal file
View File

@@ -0,0 +1,110 @@
from sqlalchemy import Column, Integer, String, BigInteger, Boolean, ForeignKey, DateTime, Text, DECIMAL
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class CarMaker(Base):
__tablename__ = "car_makers"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(10), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
name_en = Column(String(100))
created_at = Column(DateTime(timezone=True), server_default=func.now())
models = relationship("CarModel", back_populates="maker")
cars = relationship("Car", back_populates="maker")
class CarModel(Base):
__tablename__ = "car_models"
id = Column(Integer, primary_key=True, index=True)
code = Column(String(10), nullable=False, index=True)
maker_id = Column(Integer, ForeignKey("car_makers.id"), nullable=False)
name = Column(String(100), nullable=False)
name_en = Column(String(100))
maker = relationship("CarMaker", back_populates="models")
cars = relationship("Car", back_populates="model")
class Car(Base):
__tablename__ = "cars"
id = Column(Integer, primary_key=True, index=True)
source = Column(String(50), nullable=False, default="carmodoo")
source_id = Column(String(50), nullable=False, index=True)
source_key = Column(Text)
maker_id = Column(Integer, ForeignKey("car_makers.id"))
model_id = Column(Integer, ForeignKey("car_models.id"))
car_name = Column(String(200))
year = Column(Integer, index=True)
month = Column(Integer)
mileage = Column(Integer)
price_krw = Column(BigInteger, index=True)
margin_krw = Column(BigInteger, default=0) # Korean margin amount in KRW
margin_mn = Column(BigInteger, default=0) # Mongolian margin amount in KRW
price_usd = Column(DECIMAL(12, 2))
is_displayed = Column(Boolean, default=False, index=True) # Show to users
fuel = Column(String(20))
transmission = Column(String(20))
color = Column(String(50))
displacement = Column(Integer)
car_number = Column(String(20))
seize_count = Column(Integer, default=0)
collateral_count = Column(Integer, default=0)
check_num = Column(String(50))
dealer_name = Column(String(100))
dealer_phone = Column(String(50))
shop_name = Column(String(100))
dealer_description = Column(Text) # 딜러가 작성한 차량 상세설명 (한국어 원문)
dealer_description_en = Column(Text) # 영어 번역
dealer_description_mn = Column(Text) # 몽골어 번역
dealer_description_ru = Column(Text) # 러시아어 번역
memo = Column(Text)
status = Column(String(20), default="active", index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
synced_at = Column(DateTime(timezone=True))
maker = relationship("CarMaker", back_populates="cars")
model = relationship("CarModel", back_populates="cars")
images = relationship("CarImage", back_populates="car", cascade="all, delete-orphan")
options = relationship("CarOption", back_populates="car", cascade="all, delete-orphan")
# inquiries relationship disabled due to schema mismatch - use raw SQL for inquiry operations
# inquiries = relationship("Inquiry", back_populates="car")
views = relationship("CarView", back_populates="car", cascade="all, delete-orphan")
performance_check = relationship("CarPerformanceCheck", back_populates="car", uselist=False, cascade="all, delete-orphan")
specification = relationship("CarSpecification", back_populates="car", uselist=False, cascade="all, delete-orphan")
class CarImage(Base):
__tablename__ = "car_images"
id = Column(Integer, primary_key=True, index=True)
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False)
url = Column(String(500))
local_path = Column(String(500))
is_main = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
car = relationship("Car", back_populates="images")
class CarOption(Base):
__tablename__ = "car_options"
id = Column(Integer, primary_key=True, index=True)
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False)
option_name = Column(String(100))
car = relationship("Car", back_populates="options")

View File

@@ -0,0 +1,59 @@
"""
차량 상세사양 (Car Specifications) 모델
카모두 상세사양조회 서비스에서 가져온 차량 스펙 정보를 저장
"""
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Text, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class CarSpecification(Base):
"""차량 상세사양"""
__tablename__ = "car_specifications"
id = Column(Integer, primary_key=True, index=True)
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, unique=True)
# 기본 정보
manufacturer = Column(String(50)) # 제조사
model_name = Column(String(100)) # 모델명
grade = Column(String(100)) # 등급/트림
model_year = Column(String(20)) # 연식
# 엔진/성능
displacement = Column(Integer) # 배기량 (cc)
fuel_type = Column(String(30)) # 연료 (가솔린/디젤/하이브리드/전기)
transmission = Column(String(30)) # 변속기 (자동/수동/CVT)
drive_type = Column(String(30)) # 구동방식 (전륜/후륜/4륜)
max_power = Column(String(50)) # 최고출력 (예: 180ps/6,000rpm)
max_torque = Column(String(50)) # 최대토크 (예: 23.5kg.m/4,200rpm)
fuel_efficiency = Column(String(50)) # 연비 (예: 12.5km/L)
# 차체
body_type = Column(String(30)) # 차체형태 (세단/SUV/해치백 등)
door_count = Column(Integer) # 도어수
seating_capacity = Column(Integer) # 승차정원
# 제원
length = Column(Integer) # 전장 (mm)
width = Column(Integer) # 전폭 (mm)
height = Column(Integer) # 전고 (mm)
wheelbase = Column(Integer) # 축거 (mm)
curb_weight = Column(Integer) # 공차중량 (kg)
# 옵션/편의장치 (JSON 배열)
safety_options = Column(JSON) # 안전옵션 ["에어백", "ABS", ...]
comfort_options = Column(JSON) # 편의옵션 ["썬루프", "열선시트", ...]
exterior_options = Column(JSON) # 외장옵션 ["LED헤드램프", ...]
interior_options = Column(JSON) # 내장옵션 ["가죽시트", ...]
# 원본 데이터
raw_data = Column(JSON) # 전체 원본 데이터 (파싱하지 못한 정보 포함)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationship
car = relationship("Car", back_populates="specification")

View File

@@ -0,0 +1,48 @@
from sqlalchemy import Column, Integer, String, Boolean, Float
from ..database import Base
class CCPackage(Base):
"""CC charging packages"""
__tablename__ = "cc_packages"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=False) # e.g., "Basic", "Standard", "Premium"
price_usd = Column(Integer, nullable=False) # Price in USD (10, 27, 40)
cc_amount = Column(Integer, nullable=False) # CC amount (10, 30, 50)
bonus_cc = Column(Integer, default=0) # Bonus CC (0, 3, 10)
discount_percent = Column(Integer, default=0) # Discount percentage (0, 10, 20)
is_active = Column(Boolean, default=True)
sort_order = Column(Integer, default=0)
# Stripe Price ID for recurring or one-time payments
stripe_price_id = Column(String(100), nullable=True)
# Default CC packages
DEFAULT_CC_PACKAGES = [
{
"name": "Basic",
"price_usd": 10,
"cc_amount": 10,
"bonus_cc": 0,
"discount_percent": 0,
"sort_order": 1,
},
{
"name": "Standard",
"price_usd": 27,
"cc_amount": 27,
"bonus_cc": 3,
"discount_percent": 10,
"sort_order": 2,
},
{
"name": "Premium",
"price_usd": 40,
"cc_amount": 40,
"bonus_cc": 10,
"discount_percent": 20,
"sort_order": 3,
},
]

View File

@@ -0,0 +1,85 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Float
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
import hashlib
from ..database import Base
def generate_dealer_code():
"""Generate a unique 6-character dealer code"""
unique_id = uuid.uuid4().hex
return "D" + hashlib.sha256(unique_id.encode()).hexdigest()[:5].upper()
class DealerApplication(Base):
"""Dealer application for users wanting to become dealers"""
__tablename__ = "dealer_applications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Business info
business_name = Column(String(100), nullable=False) # 상호명
business_number = Column(String(50), nullable=True) # 사업자번호 (선택)
# Personal info
real_name = Column(String(100), nullable=False) # 실명
id_number_encrypted = Column(String(255), nullable=True) # 주민번호/외국인번호 (암호화)
phone = Column(String(50), nullable=False) # 연락처
# Bank info for withdrawals
bank_name = Column(String(50), nullable=False) # 은행명
bank_account = Column(String(100), nullable=False) # 계좌번호
account_holder = Column(String(100), nullable=False) # 예금주명
# Photo
photo_url = Column(String(500), nullable=True) # 본인 사진 URL
# Application status
status = Column(String(20), default="pending") # pending, approved, rejected
rejected_reason = Column(Text, nullable=True) # 거부 사유
# Timestamps
applied_at = Column(DateTime(timezone=True), server_default=func.now())
approved_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
user = relationship("User", back_populates="dealer_application")
class DealerInfo(Base):
"""Approved dealer information"""
__tablename__ = "dealer_info"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), unique=True, nullable=False)
# Dealer identification
dealer_code = Column(String(10), unique=True, index=True, nullable=False) # 딜러 고유 코드 (D + 5자리)
dealer_card_url = Column(String(500), nullable=True) # 딜러증 이미지 URL
# Business info (from application)
business_name = Column(String(100), nullable=False)
real_name = Column(String(100), nullable=False)
phone = Column(String(50), nullable=False)
photo_url = Column(String(500), nullable=True)
# Bank info (from application)
bank_name = Column(String(50), nullable=False)
bank_account = Column(String(100), nullable=False)
account_holder = Column(String(100), nullable=False)
# Earnings
total_commission_earned = Column(Float, default=0.0) # 총 수수료 수익 (KRW)
total_withdrawn = Column(Float, default=0.0) # 총 출금액 (KRW)
pending_withdrawal = Column(Float, default=0.0) # 출금 대기 금액 (KRW)
# Status
is_active = Column(Boolean, default=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
user = relationship("User", back_populates="dealer_info")

View File

@@ -0,0 +1,46 @@
"""
Exchange Rate Model - 환율 정보 저장
"""
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean
from sqlalchemy.sql import func
from ..database import Base
class ExchangeRate(Base):
"""환율 정보 테이블"""
__tablename__ = "exchange_rates"
id = Column(Integer, primary_key=True, index=True)
# 통화 정보
currency_code = Column(String(10), unique=True, index=True) # USD, MNT, RUB, CNY
currency_name = Column(String(100)) # 미국 달러, 몽골 투그릭 등
# 환율 정보 (한국수출입은행 기준)
deal_base_rate = Column(Float) # 매매기준율 (1 USD = X KRW)
ttb_rate = Column(Float) # 전신환(송금) 받을때
tts_rate = Column(Float) # 전신환(송금) 보낼때
# 가중치 적용 환율
weight_percent = Column(Float, default=0.0) # 관리자 설정 가중치 (%)
adjusted_rate = Column(Float) # 가중치 적용된 환율
# 메타 정보
source_date = Column(String(20)) # 수출입은행 기준일 (예: 20241223)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ExchangeRateHistory(Base):
"""환율 변동 이력 테이블"""
__tablename__ = "exchange_rate_history"
id = Column(Integer, primary_key=True, index=True)
currency_code = Column(String(10), index=True)
deal_base_rate = Column(Float)
source_date = Column(String(20))
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,67 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class HeroBannerSettings(Base):
"""히어로 배너 슬라이더 설정"""
__tablename__ = "hero_banner_settings"
id = Column(Integer, primary_key=True, index=True)
# 슬라이드 전환 간격 (밀리초)
slide_interval = Column(Integer, default=3000) # 3초
# 애니메이션 타입: 'film-strip', 'fade', 'slide'
animation_type = Column(String(20), default="film-strip")
# 이미지 크기
image_width = Column(Integer, default=500)
image_height = Column(Integer, default=300)
# 자동 재생 여부
auto_play = Column(Boolean, default=True)
# 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class HeroBanner(Base):
"""히어로 배너 이미지"""
__tablename__ = "hero_banners"
id = Column(Integer, primary_key=True, index=True)
# 다국어 제목
title_ko = Column(String(100))
title_en = Column(String(100))
title_mn = Column(String(100)) # 몽골어
# 다국어 서브타이틀
subtitle_ko = Column(String(200))
subtitle_en = Column(String(200))
subtitle_mn = Column(String(200))
# 이미지 URL
image_url = Column(String(500), nullable=False)
# 클릭 시 이동 URL (선택)
link_url = Column(String(500))
# 연결된 차량 ID (선택 - 차량 상세 페이지로 연결)
car_id = Column(Integer, ForeignKey("cars.id", ondelete="SET NULL"), nullable=True)
# 활성화 여부
is_active = Column(Boolean, default=True)
# 표시 순서 (낮을수록 먼저)
display_order = Column(Integer, default=0)
# 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# 관계
car = relationship("Car", foreign_keys=[car_id])

View File

@@ -0,0 +1,79 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
import enum
from ..database import Base
class InquiryStatus:
PENDING = "pending"
IN_PROGRESS = "in_progress"
RESOLVED = "resolved"
CLOSED = "closed"
class InquiryCategory:
GENERAL = "general"
VEHICLE = "vehicle"
PAYMENT = "payment"
SHIPPING = "shipping"
DEALER = "dealer"
ACCOUNT = "account"
OTHER = "other"
class Inquiry(Base):
"""User inquiry/support ticket"""
__tablename__ = "inquiries"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Legacy field for backward compatibility
car_id = Column(Integer, ForeignKey("cars.id"), nullable=True)
# Inquiry details
category = Column(String(50), default=InquiryCategory.GENERAL)
subject = Column(String(200), nullable=True)
message = Column(Text, nullable=False)
# Contact info (can be different from user's profile)
contact_email = Column(String(255), nullable=True)
contact_phone = Column(String(50), nullable=True)
# Status
status = Column(String(20), default=InquiryStatus.PENDING)
# Admin response
admin_response = Column(Text, nullable=True)
responded_at = Column(DateTime(timezone=True), nullable=True)
responded_by = Column(Integer, ForeignKey("users.id"), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", foreign_keys=[user_id], back_populates="inquiries")
responder = relationship("User", foreign_keys=[responded_by])
# car relationship disabled due to schema mismatch - Car model doesn't have inquiries relationship
# car = relationship("Car", back_populates="inquiries")
class InquiryMessage(Base):
"""Messages within an inquiry thread"""
__tablename__ = "inquiry_messages"
id = Column(Integer, primary_key=True, index=True)
inquiry_id = Column(Integer, ForeignKey("inquiries.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
message = Column(Text, nullable=False)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
inquiry = relationship("Inquiry", backref="messages")
user = relationship("User")

View File

@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class Notification(Base):
"""User notifications"""
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Notification type: vehicle_recommended, shipping_update, withdrawal_processed,
# referral_reward, dealer_approved, share_purchased, system
notification_type = Column(String(50), nullable=False)
# Title and message (supports i18n keys or direct text)
title = Column(String(200), nullable=False)
message = Column(Text, nullable=False)
# Optional link to navigate when clicked
link = Column(String(500), nullable=True)
# Related entity (optional)
related_id = Column(Integer, nullable=True) # ID of related entity
related_type = Column(String(50), nullable=True) # Type: vehicle_request, purchased_vehicle, withdrawal, etc.
# Status
is_read = Column(Boolean, default=False)
read_at = Column(DateTime(timezone=True), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
user = relationship("User", backref="notifications")

View File

@@ -0,0 +1,119 @@
"""
성능점검표 (Performance Check Report) 모델
카모두에서 가져온 차량 성능점검 정보를 저장
"""
from sqlalchemy import Column, Integer, String, Boolean, ForeignKey, DateTime, Text, JSON
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class CarPerformanceCheck(Base):
"""차량 성능점검표"""
__tablename__ = "car_performance_checks"
id = Column(Integer, primary_key=True, index=True)
car_id = Column(Integer, ForeignKey("cars.id", ondelete="CASCADE"), nullable=False, unique=True)
# 성능점검 기본정보
check_number = Column(String(50)) # 성능점검번호
check_date = Column(String(20)) # 점검일자
valid_until = Column(String(20)) # 유효기간
inspector_name = Column(String(50)) # 점검자명
inspector_license = Column(String(50)) # 점검자 자격번호
# 차량 기본정보 (car_number는 cars 테이블에서 관리 - 원자성)
first_registration = Column(String(20)) # 최초등록일
model_year = Column(String(20)) # 연식
# 주행거리
mileage = Column(Integer) # 주행거리
mileage_status = Column(String(20)) # 주행거리 상태 (정상/조작의심/교환됨)
# 압류/저당 정보
seize_count = Column(Integer, default=0) # 압류 건수
collateral_count = Column(Integer, default=0) # 저당 건수
# 특별 이력 (침수/화재/전손)
is_flood_damaged = Column(Boolean, default=False) # 침수
is_fire_damaged = Column(Boolean, default=False) # 화재
is_total_loss = Column(Boolean, default=False) # 전손
# 용도이력
usage_history = Column(String(100)) # 자가용/영업용/관용 등
is_rental_used = Column(Boolean, default=False) # 렌트 이력
# 주요장치 상태 (JSON으로 상세정보 저장)
# 각 항목: 양호/주의/불량
engine_status = Column(String(20)) # 원동기
transmission_status = Column(String(20)) # 변속기
power_delivery_status = Column(String(20)) # 동력전달
steering_status = Column(String(20)) # 조향장치
brake_status = Column(String(20)) # 제동장치
electrical_status = Column(String(20)) # 전기장치
fuel_system_status = Column(String(20)) # 연료장치
# 타이어 상태
tire_front_left = Column(String(20)) # 전좌
tire_front_right = Column(String(20)) # 전우
tire_rear_left = Column(String(20)) # 후좌
tire_rear_right = Column(String(20)) # 후우
# 사고 이력 (외판/주요골격) - JSON으로 상세 저장
# 부위별: 없음/교환/판금용접/부식/손상
accident_history = Column(JSON) # {"hood": "교환", "front_fender_left": "판금", ...}
# 외판 부위
hood = Column(String(20)) # 후드
front_fender_left = Column(String(20)) # 프론트휀더(좌)
front_fender_right = Column(String(20)) # 프론트휀더(우)
front_door_left = Column(String(20)) # 프론트도어(좌)
front_door_right = Column(String(20)) # 프론트도어(우)
rear_door_left = Column(String(20)) # 리어도어(좌)
rear_door_right = Column(String(20)) # 리어도어(우)
trunk_lid = Column(String(20)) # 트렁크리드
radiator_support = Column(String(20)) # 라디에이터서포트
roof_panel = Column(String(20)) # 루프패널
quarter_panel_left = Column(String(20)) # 쿼터패널(좌)
quarter_panel_right = Column(String(20)) # 쿼터패널(우)
side_sill_left = Column(String(20)) # 사이드실패널(좌)
side_sill_right = Column(String(20)) # 사이드실패널(우)
# 주요골격 부위
front_panel = Column(String(20)) # 프론트패널
cross_member = Column(String(20)) # 크로스멤버
inside_panel_left = Column(String(20)) # 인사이드패널(좌)
inside_panel_right = Column(String(20)) # 인사이드패널(우)
side_member_left = Column(String(20)) # 사이드멤버(좌)
side_member_right = Column(String(20)) # 사이드멤버(우)
wheel_house_left = Column(String(20)) # 휠하우스(좌)
wheel_house_right = Column(String(20)) # 휠하우스(우)
dash_panel = Column(String(20)) # 대쉬패널
floor_panel = Column(String(20)) # 플로어패널
trunk_floor = Column(String(20)) # 트렁크플로어
rear_panel = Column(String(20)) # 리어패널
pillar_a_left = Column(String(20)) # 필러A(좌)
pillar_a_right = Column(String(20)) # 필러A(우)
pillar_b_left = Column(String(20)) # 필러B(좌)
pillar_b_right = Column(String(20)) # 필러B(우)
pillar_c_left = Column(String(20)) # 필러C(좌)
pillar_c_right = Column(String(20)) # 필러C(우)
package_tray = Column(String(20)) # 패키지트레이
# 원본 데이터 (파싱하지 못한 추가 정보)
raw_data = Column(JSON) # 전체 원본 데이터
raw_html = Column(Text) # 원본 HTML (디버깅용)
# 점검표 이미지 URL
report_image_url = Column(String(500)) # 성능점검표 이미지
report_image_local = Column(String(500)) # 로컬 저장 경로
# PDF 파일 경로 (Playwright로 캡처한 성능점검표)
pdf_path = Column(String(500)) # PDF 파일 상대경로 (/uploads/performance_checks/xxx.pdf)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
# Relationship
car = relationship("Car", back_populates="performance_check")

View File

@@ -0,0 +1,48 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class PushSubscription(Base):
"""Store user's push notification subscriptions"""
__tablename__ = "push_subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
endpoint = Column(Text, nullable=False) # Push service endpoint URL
p256dh_key = Column(String(255), nullable=False) # Public key for encryption
auth_key = Column(String(255), nullable=False) # Auth secret for encryption
device_info = Column(String(255), nullable=True) # Browser/device info
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_used_at = Column(DateTime(timezone=True), nullable=True)
user = relationship("User", backref="push_subscriptions")
class UserNotificationPreference(Base):
"""User preferences for different notification types"""
__tablename__ = "user_notification_preferences"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
# Notification type preferences (True = enabled)
vehicle_recommended = Column(Boolean, default=True)
shipping_update = Column(Boolean, default=True)
payment_confirmed = Column(Boolean, default=True)
withdrawal_processed = Column(Boolean, default=True)
dealer_status = Column(Boolean, default=True)
share_purchased = Column(Boolean, default=True)
referral_reward = Column(Boolean, default=True)
inquiry_reply = Column(Boolean, default=True)
system_announcements = Column(Boolean, default=True)
# Channel preferences
push_enabled = Column(Boolean, default=True)
email_enabled = Column(Boolean, default=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
user = relationship("User", backref="notification_preferences")

View File

@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class ReferralReward(Base):
"""레퍼럴 보상 모델"""
__tablename__ = "referral_rewards"
id = Column(Integer, primary_key=True, index=True)
# 추천인 (보상 받는 사람)
referrer_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# 피추천인 (추천받아 가입한 사람)
referred_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
# 결제 금액 (피추천인이 충전한 금액 USD)
payment_amount = Column(Float, nullable=False)
# 보상 금액 (결제 금액의 X%)
reward_amount = Column(Float, nullable=False)
# 보상 상태: pending(대기), credited(적립), withdrawn(출금)
status = Column(String(20), default="pending")
# 출금 요청 ID (출금 시 연결)
withdrawal_request_id = Column(Integer, ForeignKey("withdrawal_requests.id"), nullable=True)
# 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now())
credited_at = Column(DateTime(timezone=True), nullable=True) # 적립 시각
# Relationships
referrer = relationship("User", foreign_keys=[referrer_id], backref="referral_rewards_given")
referred_user = relationship("User", foreign_keys=[referred_user_id], backref="referral_rewards_received")

View File

@@ -0,0 +1,45 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, Boolean
from sqlalchemy.sql import func
from ..database import Base
class SystemSettings(Base):
"""시스템 설정"""
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
# 검색 결과 페이지 크기
search_page_size = Column(Integer, default=20)
# 마진 설정 (%)
korea_margin_percent = Column(Float, default=5.0)
mongolia_margin_percent = Column(Float, default=5.0)
# CC 코인 설정
cc_per_usdc = Column(Integer, default=10) # 1 USDC = 10 CC
cc_per_view = Column(Integer, default=1) # 차량 상세 조회 시 1 CC
cc_signup_bonus = Column(Integer, default=3) # 신규 가입 시 3 CC
cars_per_cc = Column(Integer, default=3) # 1 CC당 추천 차량 수 (기본 3대)
# 캐시 TTL (시간)
cache_ttl_hours = Column(Integer, default=2)
# 컨테이너 물류비 설정 (USD)
container_logistics_usd = Column(Integer, default=3600) # 컨테이너 물류비 $3,600
shoring_cost_usd = Column(Integer, default=300) # 쇼링비 (컨테이너 고정) $300
# 레퍼럴 보상 설정
referral_reward_enabled = Column(Boolean, default=True) # 레퍼럴 보상 활성화
referral_reward_percent = Column(Float, default=10.0) # 보상 비율 (기본 10%)
referral_reward_type = Column(String(20), default="one_time") # one_time / recurring
# 환율 가중치 설정 (%)
exchange_rate_weight_usd = Column(Float, default=0.0) # USD 가중치
exchange_rate_weight_mnt = Column(Float, default=0.0) # MNT (몽골 투그릭) 가중치
exchange_rate_weight_rub = Column(Float, default=0.0) # RUB (러시아 루블) 가중치
exchange_rate_weight_cny = Column(Float, default=0.0) # CNY (중국 위안) 가중치
# 타임스탬프
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())

View File

@@ -0,0 +1,28 @@
from sqlalchemy import Column, Integer, String, DateTime, Index
from sqlalchemy.sql import func
from ..database import Base
class Translation(Base):
"""Translation dictionary for car-related terms"""
__tablename__ = "translations"
id = Column(Integer, primary_key=True, index=True)
# Source text (Korean)
source_text = Column(String(500), nullable=False, index=True)
# Category: maker, model, fuel, transmission, color, car_name, etc.
category = Column(String(50), nullable=False, index=True)
# Translations
text_en = Column(String(500)) # English
text_mn = Column(String(500)) # Mongolian
text_ru = Column(String(500)) # Russian
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
__table_args__ = (
Index('ix_translations_source_category', 'source_text', 'category', unique=True),
)

138
backend/app/models/user.py Normal file
View File

@@ -0,0 +1,138 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
import hashlib
from ..database import Base
def generate_referral_code():
"""Generate a unique 8-character referral code"""
unique_id = uuid.uuid4().hex
return hashlib.sha256(unique_id.encode()).hexdigest()[:8].upper()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False)
name = Column(String(100))
phone = Column(String(50))
country = Column(String(50), default="Mongolia")
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
is_dealer = Column(Boolean, default=False) # Dealer status
cc_balance = Column(Float, default=3.0) # CC coin balance, 3 free on signup
referral_code = Column(String(8), unique=True, index=True) # Unique referral code for sharing
referred_by = Column(String(8), nullable=True) # Referral code of the user who referred this user
# Email verification
email_verified = Column(Boolean, default=False)
email_verified_at = Column(DateTime(timezone=True), nullable=True)
# Phone verification
phone_verified = Column(Boolean, default=False)
phone_verified_at = Column(DateTime(timezone=True), nullable=True)
# Account withdrawal/deletion
withdrawal_requested_at = Column(DateTime(timezone=True), nullable=True) # 탈퇴 요청 시각
withdrawal_reason = Column(String(500), nullable=True) # 탈퇴 사유
deleted_at = Column(DateTime(timezone=True), nullable=True) # 실제 삭제 시각 (soft delete)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Note: foreign_keys specified as string to avoid circular import
inquiries = relationship("Inquiry", back_populates="user", primaryjoin="User.id == Inquiry.user_id")
car_views = relationship("CarView", back_populates="user")
performance_check_views = relationship("PerformanceCheckView", back_populates="user")
charge_history = relationship("ChargeHistory", back_populates="user", primaryjoin="User.id == ChargeHistory.user_id")
dealer_application = relationship("DealerApplication", back_populates="user", uselist=False)
dealer_info = relationship("DealerInfo", back_populates="user", uselist=False)
class VerificationCode(Base):
"""Store temporary verification codes for email and phone"""
__tablename__ = "verification_codes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable for pre-registration
email = Column(String(255), nullable=True, index=True) # For email verification
phone = Column(String(50), nullable=True, index=True) # For phone verification
code = Column(String(10), nullable=False) # 6-digit code
code_type = Column(String(20), nullable=False) # 'email' or 'phone'
purpose = Column(String(50), default="verification") # 'verification', 'password_reset'
attempts = Column(Integer, default=0) # Failed verification attempts
max_attempts = Column(Integer, default=5)
expires_at = Column(DateTime(timezone=True), nullable=False)
verified_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class CarView(Base):
"""Track which cars a user has purchased (paid CC to view full details)"""
__tablename__ = "car_views"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
cc_paid = Column(Integer, default=1) # CC paid for this view
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="car_views")
car = relationship("Car", back_populates="views")
class PerformanceCheckView(Base):
"""Track which performance checks a user has purchased (paid 0.1 CC to view)"""
__tablename__ = "performance_check_views"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
car_id = Column(Integer, ForeignKey("cars.id"), nullable=False)
cc_paid = Column(Float, default=0.1) # CC paid for this view (0.1 CC)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="performance_check_views")
class ChargeHistory(Base):
"""Track CC charge history for users"""
__tablename__ = "charge_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
package_id = Column(Integer, ForeignKey("cc_packages.id"), nullable=True) # CC package purchased
amount = Column(Integer, nullable=False) # Amount in selected currency
amount_usd = Column(Integer, nullable=True) # Amount in USD (for backwards compatibility)
cc_amount = Column(Integer, nullable=False) # CC received
bonus_cc = Column(Integer, default=0) # Bonus CC received
currency = Column(String(10), default="USD") # USD, USDC, KRW
payment_method = Column(String(50), default="stripe") # stripe, manual, usdc, bank_transfer
# Stripe fields
stripe_session_id = Column(String(200), nullable=True) # Stripe Checkout Session ID
stripe_payment_intent_id = Column(String(200), nullable=True) # Stripe Payment Intent ID
# Legacy fields
transaction_id = Column(String(100), nullable=True) # External transaction ID (crypto tx hash)
wallet_address = Column(String(100), nullable=True) # User's wallet address for refunds
admin_note = Column(String(500), nullable=True) # Admin notes
status = Column(String(20), default="pending") # pending, completed, failed, cancelled
verified_at = Column(DateTime(timezone=True), nullable=True)
verified_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="charge_history", foreign_keys=[user_id])
# Payment settings constants
class PaymentSettings:
USDC_WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678" # Platform USDC receiving address
USDC_NETWORK = "Polygon" # Default network (Polygon for low fees)
MIN_CHARGE_USD = 10
MAX_CHARGE_USD = 10000
SUPPORTED_CURRENCIES = ["USD", "USDC", "KRW"]
SUPPORTED_METHODS = ["card", "usdc", "bank_transfer"]

View File

@@ -0,0 +1,106 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, JSON, Float
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class VehicleRequest(Base):
"""Track vehicle search requests from users"""
__tablename__ = "vehicle_requests"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Search criteria
maker_code = Column(String(50))
maker_name = Column(String(100))
model_code = Column(String(50))
model_name = Column(String(100))
grade_code = Column(String(50))
grade_name = Column(String(100))
year_from = Column(Integer)
year_to = Column(Integer)
mileage_min = Column(Integer)
mileage_max = Column(Integer)
fuel = Column(String(50)) # 연료 타입 (휘발유, 경유, 하이브리드, LPG, 전기)
displacement_min = Column(Integer) # 최소 배기량 (cc)
displacement_max = Column(Integer) # 최대 배기량 (cc)
# CC payment for request submission
cc_paid = Column(Float, default=1.0) # CC paid for this request (1 CC)
# Status: pending, reviewed, completed
status = Column(String(20), default="pending")
admin_reviewed_at = Column(DateTime(timezone=True))
admin_notes = Column(Text)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
user = relationship("User", backref="vehicle_requests")
recommended_vehicles = relationship("RequestVehicle", back_populates="request", cascade="all, delete-orphan")
class RequestVehicle(Base):
"""Vehicles recommended by admin for a user's request"""
__tablename__ = "request_vehicles"
id = Column(Integer, primary_key=True, index=True)
request_id = Column(Integer, ForeignKey("vehicle_requests.id"), nullable=False)
# Car data from Carmodoo (stored as JSON)
car_data = Column(JSON, nullable=False)
# Admin approval
is_approved = Column(Boolean, default=False)
approved_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
request = relationship("VehicleRequest", back_populates="recommended_vehicles")
class PurchasedVehicle(Base):
"""Track purchased vehicles and their shipping status"""
__tablename__ = "purchased_vehicles"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Vehicle info
car_name = Column(String(200))
car_data = Column(JSON) # Full car details
car_image = Column(String(500)) # Main image URL
# Price info
vehicle_price_krw = Column(Integer)
domestic_cost_krw = Column(Integer)
shipping_cost_usd = Column(Integer)
total_cost_krw = Column(Integer)
car_type = Column(String(20)) # small, compact
# Dealer selection and commission (50/50 split of Mongolia margin)
selected_dealer_id = Column(Integer, ForeignKey("dealer_info.id"), nullable=True)
dealer_commission_krw = Column(Integer, default=0) # 50% of Mongolia margin
platform_commission_krw = Column(Integer, default=0) # 50% of Mongolia margin
commission_paid = Column(Boolean, default=False) # Whether commission has been paid
commission_paid_at = Column(DateTime(timezone=True))
# Shipping status: 1-5
# 1: Purchased, 2: Incheon Port, 3: In Transit, 4: Customs, 5: Delivered
shipping_status = Column(Integer, default=1)
status_updated_at = Column(DateTime(timezone=True))
# Location info
current_location = Column(String(200))
estimated_arrival = Column(DateTime(timezone=True))
# Timestamps
purchased_at = Column(DateTime(timezone=True), server_default=func.now())
delivered_at = Column(DateTime(timezone=True))
# Relationships
user = relationship("User", backref="purchased_vehicles")
selected_dealer = relationship("DealerInfo", backref="purchased_vehicles")

View File

@@ -0,0 +1,75 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
import uuid
import hashlib
from ..database import Base
def generate_share_code():
"""Generate a unique 10-character share code"""
unique_id = uuid.uuid4().hex
return hashlib.sha256(unique_id.encode()).hexdigest()[:10].upper()
class VehicleShare(Base):
"""Track vehicle shares with price markup"""
__tablename__ = "vehicle_shares"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # User who shared
# Reference to the original vehicle
request_vehicle_id = Column(Integer, ForeignKey("request_vehicles.id"), nullable=False)
# Share code for the link
share_code = Column(String(10), unique=True, index=True, nullable=False)
# Pricing
original_price_krw = Column(Float, nullable=False) # Original vehicle price
markup_amount_krw = Column(Float, default=0) # Additional amount added by sharer
shared_price_krw = Column(Float, nullable=False) # Total shared price (original + markup)
# Statistics
view_count = Column(Integer, default=0)
is_purchased = Column(Boolean, default=False)
purchased_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration
purchased_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
user = relationship("User", foreign_keys=[user_id], backref="vehicle_shares")
purchased_by = relationship("User", foreign_keys=[purchased_by_user_id])
request_vehicle = relationship("RequestVehicle", backref="shares")
class ShareReward(Base):
"""Track rewards earned from vehicle shares"""
__tablename__ = "share_rewards"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False) # User who earned the reward
vehicle_share_id = Column(Integer, ForeignKey("vehicle_shares.id"), nullable=False)
# Amounts
markup_amount = Column(Float, nullable=False) # Original markup amount
reward_amount = Column(Float, nullable=False) # 90% of markup
tax_amount = Column(Float, nullable=False) # 3.3% tax withholding
net_amount = Column(Float, nullable=False) # Final amount after tax
# Status
status = Column(String(20), default="pending") # pending, approved, withdrawn
# Withdrawal tracking
withdrawal_request_id = Column(Integer, ForeignKey("withdrawal_requests.id"), nullable=True)
withdrawn_at = Column(DateTime(timezone=True), nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
user = relationship("User", backref="share_rewards")
vehicle_share = relationship("VehicleShare", backref="reward")

View File

@@ -0,0 +1,111 @@
"""
Visitor tracking models for analytics
"""
from sqlalchemy import Column, Integer, String, DateTime, Text, Index
from sqlalchemy.sql import func
from ..database import Base
class VisitorLog(Base):
"""
Raw visitor log - tracks every page visit
IP addresses are hashed for privacy
"""
__tablename__ = "visitor_logs"
id = Column(Integer, primary_key=True, index=True)
# Visitor identification (hashed for privacy)
visitor_hash = Column(String(64), nullable=False, index=True) # SHA256 hash of IP + User-Agent
ip_hash = Column(String(64), nullable=False) # SHA256 hash of IP only
# Session tracking
session_id = Column(String(64), nullable=True, index=True) # Cookie-based session ID
user_id = Column(Integer, nullable=True, index=True) # If logged in
# Page information
page_path = Column(String(500), nullable=False, index=True)
page_title = Column(String(200), nullable=True)
referrer = Column(String(1000), nullable=True)
referrer_domain = Column(String(200), nullable=True, index=True)
# Device information
device_type = Column(String(20), nullable=True, index=True) # mobile, desktop, tablet
browser = Column(String(50), nullable=True, index=True)
browser_version = Column(String(20), nullable=True)
os = Column(String(50), nullable=True)
os_version = Column(String(20), nullable=True)
# Geographic information (from IP geolocation)
country = Column(String(50), nullable=True, index=True)
country_code = Column(String(5), nullable=True)
city = Column(String(100), nullable=True)
region = Column(String(100), nullable=True)
# UTM parameters
utm_source = Column(String(100), nullable=True)
utm_medium = Column(String(100), nullable=True)
utm_campaign = Column(String(100), nullable=True)
# Timestamp
visited_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
class VisitorDailyStats(Base):
"""
Aggregated daily statistics for faster queries
Pre-computed by a scheduled task
"""
__tablename__ = "visitor_daily_stats"
id = Column(Integer, primary_key=True, index=True)
stat_date = Column(String(10), nullable=False, unique=True, index=True) # YYYY-MM-DD
# Visitor counts
total_visits = Column(Integer, default=0)
unique_visitors = Column(Integer, default=0)
# Device breakdown (JSON string)
device_breakdown = Column(Text) # {"mobile": 100, "desktop": 200, "tablet": 20}
# Browser breakdown (JSON string)
browser_breakdown = Column(Text) # {"Chrome": 150, "Safari": 100, ...}
# Country breakdown (JSON string)
country_breakdown = Column(Text) # {"MN": 200, "RU": 50, "KR": 30}
# Top pages (JSON string)
top_pages = Column(Text) # [{"path": "/", "views": 500}, ...]
# Top referrers (JSON string)
top_referrers = Column(Text) # [{"domain": "google.com", "visits": 100}, ...]
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class VisitorSession(Base):
"""
Track visitor sessions for better analytics
"""
__tablename__ = "visitor_sessions"
id = Column(Integer, primary_key=True, index=True)
session_id = Column(String(64), unique=True, nullable=False, index=True)
visitor_hash = Column(String(64), nullable=False, index=True)
user_id = Column(Integer, nullable=True)
# Session info
first_page = Column(String(500))
last_page = Column(String(500))
page_count = Column(Integer, default=1)
# Device/geo info (copied from first visit)
device_type = Column(String(20))
browser = Column(String(50))
country = Column(String(50))
# Timestamps
started_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
last_activity_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -0,0 +1,35 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Float, Text
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class WithdrawalRequest(Base):
"""Track withdrawal requests from users"""
__tablename__ = "withdrawal_requests"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Amount details
amount = Column(Float, nullable=False) # Requested withdrawal amount
tax_withheld = Column(Float, default=0) # Tax amount withheld (3.3%)
net_amount = Column(Float, nullable=False) # Net amount after tax
# Bank info (snapshot at time of request)
bank_name = Column(String(50), nullable=False)
bank_account = Column(String(100), nullable=False)
account_holder = Column(String(100), nullable=False)
# Status
status = Column(String(20), default="pending") # pending, approved, completed, rejected
# Admin notes
admin_note = Column(Text, nullable=True)
# Timestamps
requested_at = Column(DateTime(timezone=True), server_default=func.now())
processed_at = Column(DateTime(timezone=True), nullable=True)
# Relationships
user = relationship("User", backref="withdrawal_requests")

View File

@@ -0,0 +1,81 @@
from .car import (
CarMakerCreate, CarMakerResponse,
CarModelCreate, CarModelResponse,
CarCreate, CarUpdate, CarResponse, CarListResponse,
CarImageCreate, CarImageResponse,
)
from .user import UserCreate, UserUpdate, UserResponse, Token, CarViewResponse, PurchaseViewRequest
from .inquiry import (
InquiryCreate, InquiryResponse,
InquiryMessageCreate, InquiryMessageResponse,
InquiryWithMessages, InquiryListResponse,
AdminInquiryRespond, AdminInquiryUpdateStatus,
)
from .hero_banner import (
HeroBannerCreate, HeroBannerUpdate, HeroBannerResponse,
HeroBannerListResponse, HeroBannerLocalizedResponse,
HeroBannerSettingsUpdate, HeroBannerSettingsResponse,
)
from .translation import (
TranslationCreate, TranslationUpdate, TranslationResponse,
TranslationListResponse, TranslationBulkRequest, TranslationBulkResponse,
)
from .vehicle_request import (
VehicleRequestCreate, VehicleRequestResponse,
RequestVehicleCreate, RequestVehicleResponse, RequestVehicleApprove,
PurchasedVehicleCreate, PurchasedVehicleResponse, PurchasedVehicleUpdateStatus,
VehicleRequestWithVehicles,
)
from .dealer import (
DealerApplicationCreate, DealerApplicationResponse,
DealerApplicationApprove, DealerApplicationReject,
DealerInfoResponse, DealerPublicInfo,
)
from .vehicle_share import (
VehicleShareCreate, VehicleShareResponse, VehicleSharePublic,
ShareRewardResponse, ShareRewardSummary,
)
from .withdrawal import (
WithdrawalRequestCreate, WithdrawalRequestResponse,
WithdrawalProcess, WithdrawalBalance,
)
from .referral import (
ReferralRewardResponse, ReferralStats,
ReferralSettingsResponse, ReferralSettingsUpdate,
)
from .notification import (
NotificationCreate, NotificationResponse,
NotificationListResponse, NotificationMarkRead,
)
__all__ = [
"CarMakerCreate", "CarMakerResponse",
"CarModelCreate", "CarModelResponse",
"CarCreate", "CarUpdate", "CarResponse", "CarListResponse",
"CarImageCreate", "CarImageResponse",
"UserCreate", "UserUpdate", "UserResponse", "Token", "CarViewResponse", "PurchaseViewRequest",
"InquiryCreate", "InquiryResponse",
"InquiryMessageCreate", "InquiryMessageResponse",
"InquiryWithMessages", "InquiryListResponse",
"AdminInquiryRespond", "AdminInquiryUpdateStatus",
"HeroBannerCreate", "HeroBannerUpdate", "HeroBannerResponse",
"HeroBannerListResponse", "HeroBannerLocalizedResponse",
"HeroBannerSettingsUpdate", "HeroBannerSettingsResponse",
"TranslationCreate", "TranslationUpdate", "TranslationResponse",
"TranslationListResponse", "TranslationBulkRequest", "TranslationBulkResponse",
"VehicleRequestCreate", "VehicleRequestResponse",
"RequestVehicleCreate", "RequestVehicleResponse", "RequestVehicleApprove",
"PurchasedVehicleCreate", "PurchasedVehicleResponse", "PurchasedVehicleUpdateStatus",
"VehicleRequestWithVehicles",
"DealerApplicationCreate", "DealerApplicationResponse",
"DealerApplicationApprove", "DealerApplicationReject",
"DealerInfoResponse", "DealerPublicInfo",
"VehicleShareCreate", "VehicleShareResponse", "VehicleSharePublic",
"ShareRewardResponse", "ShareRewardSummary",
"WithdrawalRequestCreate", "WithdrawalRequestResponse",
"WithdrawalProcess", "WithdrawalBalance",
"ReferralRewardResponse", "ReferralStats",
"ReferralSettingsResponse", "ReferralSettingsUpdate",
"NotificationCreate", "NotificationResponse",
"NotificationListResponse", "NotificationMarkRead",
]

185
backend/app/schemas/car.py Normal file
View File

@@ -0,0 +1,185 @@
from pydantic import BaseModel
from typing import Optional, List, Any
from datetime import datetime
from decimal import Decimal
# CarSpecification Schema
class CarSpecificationResponse(BaseModel):
id: int
car_id: int
manufacturer: Optional[str] = None
model_name: Optional[str] = None
grade: Optional[str] = None
model_year: Optional[str] = None
displacement: Optional[int] = None
fuel_type: Optional[str] = None
transmission: Optional[str] = None
drive_type: Optional[str] = None
max_power: Optional[str] = None
max_torque: Optional[str] = None
fuel_efficiency: Optional[str] = None
body_type: Optional[str] = None
door_count: Optional[int] = None
seating_capacity: Optional[int] = None
length: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
wheelbase: Optional[int] = None
curb_weight: Optional[int] = None
safety_options: Optional[List[str]] = None
comfort_options: Optional[List[str]] = None
exterior_options: Optional[List[str]] = None
interior_options: Optional[List[str]] = None
raw_data: Optional[Any] = None
class Config:
from_attributes = True
# CarMaker Schemas
class CarMakerCreate(BaseModel):
code: str
name: str
name_en: Optional[str] = None
class CarMakerResponse(BaseModel):
id: int
code: str
name: str
name_en: Optional[str] = None
class Config:
from_attributes = True
# CarModel Schemas
class CarModelCreate(BaseModel):
code: str
maker_id: int
name: str
name_en: Optional[str] = None
class CarModelResponse(BaseModel):
id: int
code: str
maker_id: int
name: str
name_en: Optional[str] = None
class Config:
from_attributes = True
# CarImage Schemas
class CarImageCreate(BaseModel):
url: Optional[str] = None
local_path: Optional[str] = None
is_main: bool = False
sort_order: int = 0
class CarImageResponse(BaseModel):
id: int
url: Optional[str] = None
local_path: Optional[str] = None
is_main: bool
sort_order: int
class Config:
from_attributes = True
# Car Schemas
class CarCreate(BaseModel):
source: str = "carmodoo"
source_id: str
source_key: Optional[str] = None
maker_code: Optional[str] = None
model_code: Optional[str] = None
car_name: Optional[str] = None
year: Optional[int] = None
month: Optional[int] = None
mileage: Optional[int] = None
price_krw: Optional[int] = None
price_usd: Optional[Decimal] = None
fuel: Optional[str] = None
transmission: Optional[str] = None
color: Optional[str] = None
displacement: Optional[int] = None
car_number: Optional[str] = None
seize_count: int = 0
collateral_count: int = 0
check_num: Optional[str] = None
dealer_name: Optional[str] = None
dealer_phone: Optional[str] = None
shop_name: Optional[str] = None
memo: Optional[str] = None
images: List[CarImageCreate] = []
options: List[str] = []
class CarUpdate(BaseModel):
car_name: Optional[str] = None
year: Optional[int] = None
month: Optional[int] = None
mileage: Optional[int] = None
price_krw: Optional[int] = None
margin_krw: Optional[int] = None
margin_mn: Optional[int] = None
price_usd: Optional[Decimal] = None
fuel: Optional[str] = None
transmission: Optional[str] = None
color: Optional[str] = None
status: Optional[str] = None
is_displayed: Optional[bool] = None
class CarResponse(BaseModel):
id: int
source: str
source_id: str
car_name: Optional[str] = None
year: Optional[int] = None
month: Optional[int] = None
mileage: Optional[int] = None
price_krw: Optional[int] = None
margin_krw: Optional[int] = 0
margin_mn: Optional[int] = 0
final_price_krw: Optional[int] = None # Computed: price_krw + margin_krw (for Korean users)
final_price_mn: Optional[int] = None # Computed: price_krw + margin_mn (for Mongolian users)
price_usd: Optional[Decimal] = None
is_displayed: bool = False
fuel: Optional[str] = None
transmission: Optional[str] = None
color: Optional[str] = None
displacement: Optional[int] = None
car_number: Optional[str] = None
seize_count: int
collateral_count: int
check_num: Optional[str] = None
dealer_name: Optional[str] = None
dealer_description: Optional[str] = None
dealer_description_en: Optional[str] = None
dealer_description_mn: Optional[str] = None
dealer_description_ru: Optional[str] = None
status: str
created_at: datetime
updated_at: datetime
maker: Optional[CarMakerResponse] = None
model: Optional[CarModelResponse] = None
images: List[CarImageResponse] = []
specification: Optional[CarSpecificationResponse] = None
class Config:
from_attributes = True
class CarListResponse(BaseModel):
total: int
page: int
page_size: int
cars: List[CarResponse]

View File

@@ -0,0 +1,80 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class DealerApplicationCreate(BaseModel):
"""Schema for creating a dealer application"""
business_name: str
business_number: Optional[str] = None
real_name: str
id_number: Optional[str] = None # Will be encrypted before storage
phone: str
bank_name: str
bank_account: str
account_holder: str
photo_url: Optional[str] = None
class DealerApplicationResponse(BaseModel):
"""Schema for dealer application response"""
id: int
user_id: int
business_name: str
business_number: Optional[str] = None
real_name: str
phone: str
bank_name: str
bank_account: str
account_holder: str
photo_url: Optional[str] = None
status: str
rejected_reason: Optional[str] = None
applied_at: datetime
approved_at: Optional[datetime] = None
class Config:
from_attributes = True
class DealerApplicationApprove(BaseModel):
"""Schema for approving a dealer application"""
pass # No additional fields needed
class DealerApplicationReject(BaseModel):
"""Schema for rejecting a dealer application"""
reason: str
class DealerInfoResponse(BaseModel):
"""Schema for dealer info response"""
id: int
user_id: int
dealer_code: str
dealer_card_url: Optional[str] = None
business_name: str
real_name: str
phone: str
photo_url: Optional[str] = None
total_commission_earned: float
total_withdrawn: float
pending_withdrawal: float
is_active: bool
created_at: datetime
class Config:
from_attributes = True
class DealerPublicInfo(BaseModel):
"""Public dealer info for displaying in lists"""
id: int
dealer_code: str
business_name: str
real_name: str
photo_url: Optional[str] = None
is_active: bool
class Config:
from_attributes = True

View File

@@ -0,0 +1,101 @@
from pydantic import BaseModel, HttpUrl
from typing import Optional
from datetime import datetime
# ==================== Hero Banner Settings ====================
class HeroBannerSettingsBase(BaseModel):
slide_interval: int = 3000
animation_type: str = "film-strip"
image_width: int = 500
image_height: int = 300
auto_play: bool = True
class HeroBannerSettingsUpdate(BaseModel):
slide_interval: Optional[int] = None
animation_type: Optional[str] = None
image_width: Optional[int] = None
image_height: Optional[int] = None
auto_play: Optional[bool] = None
class HeroBannerSettingsResponse(HeroBannerSettingsBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# ==================== Hero Banner ====================
class HeroBannerBase(BaseModel):
title_ko: Optional[str] = None
title_en: Optional[str] = None
title_mn: Optional[str] = None
subtitle_ko: Optional[str] = None
subtitle_en: Optional[str] = None
subtitle_mn: Optional[str] = None
image_url: str
link_url: Optional[str] = None
car_id: Optional[int] = None
is_active: bool = True
display_order: int = 0
class HeroBannerCreate(HeroBannerBase):
pass
class HeroBannerUpdate(BaseModel):
title_ko: Optional[str] = None
title_en: Optional[str] = None
title_mn: Optional[str] = None
subtitle_ko: Optional[str] = None
subtitle_en: Optional[str] = None
subtitle_mn: Optional[str] = None
image_url: Optional[str] = None
link_url: Optional[str] = None
car_id: Optional[int] = None
is_active: Optional[bool] = None
display_order: Optional[int] = None
class HeroBannerResponse(HeroBannerBase):
id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class HeroBannerListResponse(BaseModel):
id: int
title_ko: Optional[str] = None
title_en: Optional[str] = None
image_url: str
link_url: Optional[str] = None
car_id: Optional[int] = None
is_active: bool
display_order: int
created_at: datetime
class Config:
from_attributes = True
# 다국어 지원 응답 (Public API용)
class HeroBannerLocalizedResponse(BaseModel):
id: int
title: Optional[str] = None
subtitle: Optional[str] = None
image_url: str
link_url: Optional[str] = None
car_id: Optional[int] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,67 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class InquiryCreate(BaseModel):
category: str = "general"
subject: Optional[str] = None
message: str
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
car_id: Optional[int] = None # For backward compatibility
class InquiryResponse(BaseModel):
id: int
user_id: Optional[int] = None
car_id: Optional[int] = None
category: Optional[str] = "general"
subject: Optional[str] = None
message: str
contact_email: Optional[str] = None
contact_phone: Optional[str] = None
status: str
admin_response: Optional[str] = None
responded_at: Optional[datetime] = None
responded_by: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class InquiryMessageCreate(BaseModel):
message: str
class InquiryMessageResponse(BaseModel):
id: int
inquiry_id: int
user_id: int
message: str
is_admin: bool
created_at: datetime
class Config:
from_attributes = True
class InquiryWithMessages(BaseModel):
inquiry: InquiryResponse
messages: List[InquiryMessageResponse]
class InquiryListResponse(BaseModel):
inquiries: List[InquiryResponse]
total: int
class AdminInquiryRespond(BaseModel):
message: str
status: Optional[str] = None # Can update status with response
class AdminInquiryUpdateStatus(BaseModel):
status: str

View File

@@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class NotificationCreate(BaseModel):
"""Create notification schema"""
user_id: int
notification_type: str
title: str
message: str
link: Optional[str] = None
related_id: Optional[int] = None
related_type: Optional[str] = None
class NotificationResponse(BaseModel):
"""Notification response schema"""
id: int
user_id: int
notification_type: str
title: str
message: str
link: Optional[str] = None
related_id: Optional[int] = None
related_type: Optional[str] = None
is_read: bool
read_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class NotificationListResponse(BaseModel):
"""Notification list with unread count"""
notifications: List[NotificationResponse]
unread_count: int
total: int
class NotificationMarkRead(BaseModel):
"""Mark notifications as read"""
notification_ids: List[int]

View File

@@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class ReferralRewardResponse(BaseModel):
"""레퍼럴 보상 응답 스키마"""
id: int
referrer_id: int
referred_user_id: int
payment_amount: float
reward_amount: float
status: str
created_at: datetime
credited_at: Optional[datetime] = None
class Config:
from_attributes = True
class ReferralStats(BaseModel):
"""레퍼럴 통계 스키마"""
total_referrals: int # 총 추천한 회원 수
total_rewards_earned: float # 총 보상 금액
total_rewards_credited: float # 적립된 보상 금액
total_rewards_pending: float # 대기 중인 보상 금액
available_for_withdrawal: float # 출금 가능 금액
class ReferralSettingsResponse(BaseModel):
"""레퍼럴 설정 응답 스키마"""
referral_reward_enabled: bool
referral_reward_percent: float
referral_reward_type: str # one_time / recurring
class ReferralSettingsUpdate(BaseModel):
"""레퍼럴 설정 업데이트 스키마"""
referral_reward_enabled: Optional[bool] = None
referral_reward_percent: Optional[float] = None
referral_reward_type: Optional[str] = None

View File

@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class SystemSettingsUpdate(BaseModel):
"""시스템 설정 수정용 스키마"""
search_page_size: Optional[int] = None
korea_margin_percent: Optional[float] = None
mongolia_margin_percent: Optional[float] = None
cc_per_usdc: Optional[int] = None
cc_per_view: Optional[int] = None
cc_signup_bonus: Optional[int] = None
cars_per_cc: Optional[int] = None
cache_ttl_hours: Optional[int] = None
container_logistics_usd: Optional[int] = None
shoring_cost_usd: Optional[int] = None
class SystemSettingsResponse(BaseModel):
"""시스템 설정 응답 스키마"""
id: int
search_page_size: int
korea_margin_percent: float
mongolia_margin_percent: float
cc_per_usdc: int
cc_per_view: int
cc_signup_bonus: int
cars_per_cc: int
cache_ttl_hours: int
container_logistics_usd: int
shoring_cost_usd: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,52 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class TranslationCreate(BaseModel):
source_text: str
category: str
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
class TranslationUpdate(BaseModel):
source_text: Optional[str] = None
category: Optional[str] = None
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
class TranslationResponse(BaseModel):
id: int
source_text: str
category: str
text_en: Optional[str] = None
text_mn: Optional[str] = None
text_ru: Optional[str] = None
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class TranslationListResponse(BaseModel):
total: int
page: int
page_size: int
translations: List[TranslationResponse]
class TranslationBulkRequest(BaseModel):
"""Bulk translation lookup request"""
texts: List[str]
category: Optional[str] = None
lang: str = "en"
class TranslationBulkResponse(BaseModel):
"""Returns a dictionary mapping source text to translated text"""
translations: dict # {source_text: translated_text}

View File

@@ -0,0 +1,62 @@
from pydantic import BaseModel, EmailStr
from typing import Optional
from datetime import datetime
class UserCreate(BaseModel):
email: EmailStr
password: str
name: Optional[str] = None
phone: Optional[str] = None
country: str = "Mongolia"
referred_by: Optional[str] = None # Referral code of the user who referred
class UserUpdate(BaseModel):
"""Schema for updating user profile"""
name: Optional[str] = None
phone: Optional[str] = None
country: Optional[str] = None
class UserResponse(BaseModel):
id: int
email: str
name: Optional[str] = None
phone: Optional[str] = None
country: str
is_active: bool
is_admin: bool = False
is_dealer: bool = False
cc_balance: float = 0.0 # Float to support fractional CC (e.g., 0.1 CC)
referral_code: Optional[str] = None # User's unique referral code
email_verified: bool = False
phone_verified: bool = False
created_at: datetime
class Config:
from_attributes = True
class CarViewResponse(BaseModel):
id: int
user_id: int
car_id: int
cc_paid: int
created_at: datetime
class Config:
from_attributes = True
class PurchaseViewRequest(BaseModel):
car_id: int
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
email: Optional[str] = None

View File

@@ -0,0 +1,122 @@
from pydantic import BaseModel
from typing import Optional, List, Any
from datetime import datetime
# Vehicle Request Schemas
class VehicleRequestCreate(BaseModel):
maker_code: str
maker_name: Optional[str] = None
model_code: str
model_name: Optional[str] = None
grade_code: Optional[str] = None
grade_name: Optional[str] = None
year_from: Optional[int] = None
year_to: Optional[int] = None
mileage_min: Optional[int] = None
mileage_max: Optional[int] = None
fuel: Optional[str] = None
displacement_min: Optional[int] = None
displacement_max: Optional[int] = None
class VehicleRequestResponse(BaseModel):
id: int
user_id: int
maker_code: Optional[str]
maker_name: Optional[str]
model_code: Optional[str]
model_name: Optional[str]
grade_code: Optional[str]
grade_name: Optional[str]
year_from: Optional[int]
year_to: Optional[int]
mileage_min: Optional[int]
mileage_max: Optional[int]
fuel: Optional[str]
displacement_min: Optional[int]
displacement_max: Optional[int]
status: str
admin_reviewed_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
# Request Vehicle (Admin recommended) Schemas
class RequestVehicleCreate(BaseModel):
request_id: int
car_data: dict
is_approved: bool = False
class RequestVehicleResponse(BaseModel):
id: int
request_id: int
car_data: dict
is_approved: bool
approved_at: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
class RequestVehicleApprove(BaseModel):
vehicle_ids: List[int]
# Purchased Vehicle Schemas
class PurchasedVehicleCreate(BaseModel):
car_name: str
car_data: Optional[dict] = None
car_image: Optional[str] = None
vehicle_price_krw: int
domestic_cost_krw: int
shipping_cost_usd: int
total_cost_krw: int
car_type: str # small, compact
selected_dealer_id: Optional[int] = None # Selected dealer for commission split
class PurchasedVehicleResponse(BaseModel):
id: int
user_id: int
car_name: Optional[str]
car_data: Optional[dict]
car_image: Optional[str]
vehicle_price_krw: Optional[int]
domestic_cost_krw: Optional[int]
shipping_cost_usd: Optional[int]
total_cost_krw: Optional[int]
car_type: Optional[str]
selected_dealer_id: Optional[int] = None
dealer_commission_krw: Optional[int] = 0
platform_commission_krw: Optional[int] = 0
commission_paid: bool = False
commission_paid_at: Optional[datetime] = None
shipping_status: int
status_updated_at: Optional[datetime]
current_location: Optional[str]
estimated_arrival: Optional[datetime]
purchased_at: datetime
delivered_at: Optional[datetime]
class Config:
from_attributes = True
class PurchasedVehicleUpdateStatus(BaseModel):
shipping_status: int # 1-7: 구매완료, 인천항, 텐진항, 자먼우드, 울란바토르, 통관, 배송완료
current_location: Optional[str] = None
estimated_arrival: Optional[datetime] = None
# List response with request and approved vehicles
class VehicleRequestWithVehicles(BaseModel):
request: VehicleRequestResponse
approved_vehicles: List[RequestVehicleResponse]
class Config:
from_attributes = True

View File

@@ -0,0 +1,69 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class VehicleShareCreate(BaseModel):
"""Schema for creating a vehicle share"""
request_vehicle_id: int
markup_amount_krw: float = 0
class VehicleShareResponse(BaseModel):
"""Schema for vehicle share response"""
id: int
user_id: int
request_vehicle_id: int
share_code: str
original_price_krw: float
markup_amount_krw: float
shared_price_krw: float
view_count: int
is_purchased: bool
purchased_by_user_id: Optional[int] = None
created_at: datetime
expires_at: Optional[datetime] = None
purchased_at: Optional[datetime] = None
class Config:
from_attributes = True
class VehicleSharePublic(BaseModel):
"""Public schema for shared vehicle (for viewing shared link)"""
id: int
share_code: str
shared_price_krw: float
view_count: int
is_purchased: bool
created_at: datetime
# Vehicle info will be added separately
class Config:
from_attributes = True
class ShareRewardResponse(BaseModel):
"""Schema for share reward response"""
id: int
user_id: int
vehicle_share_id: int
markup_amount: float
reward_amount: float
tax_amount: float
net_amount: float
status: str
withdrawn_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class ShareRewardSummary(BaseModel):
"""Summary of user's share rewards"""
total_rewards: float
total_withdrawn: float
pending_amount: float
available_for_withdrawal: float
reward_count: int

View File

@@ -0,0 +1,44 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class WithdrawalRequestCreate(BaseModel):
"""Schema for creating a withdrawal request"""
amount: float
bank_name: str
bank_account: str
account_holder: str
class WithdrawalRequestResponse(BaseModel):
"""Schema for withdrawal request response"""
id: int
user_id: int
amount: float
tax_withheld: float
net_amount: float
bank_name: str
bank_account: str
account_holder: str
status: str
admin_note: Optional[str] = None
requested_at: datetime
processed_at: Optional[datetime] = None
class Config:
from_attributes = True
class WithdrawalProcess(BaseModel):
"""Schema for processing a withdrawal (admin)"""
status: str # approved, completed, rejected
admin_note: Optional[str] = None
class WithdrawalBalance(BaseModel):
"""Schema for user's withdrawal balance"""
total_earned: float # Total earnings (dealer commission + share rewards)
total_withdrawn: float # Total already withdrawn
pending_withdrawal: float # Currently pending withdrawal requests
available_balance: float # Available for withdrawal

View 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)

View 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

View 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

View 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

View 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,
}

View 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)

View 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

View 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

View File

@@ -0,0 +1,11 @@
import urllib.request
import json
with urllib.request.urlopen("http://localhost:8000/openapi.json") as response:
data = json.loads(response.read().decode())
props = data['components']['schemas']['CarmodooSearchResultItem']['properties']
print("Properties in CarmodooSearchResultItem:")
for p in props.keys():
print(f" - {p}")
print(f"\ncheck_num present: {'check_num' in props}")

View File

@@ -0,0 +1,42 @@
"""Check banner cars and their source_id for fetching performance checks"""
import sqlite3
import os
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# 먼저 cars 테이블 구조 확인
cursor.execute('PRAGMA table_info(cars)')
columns = cursor.fetchall()
print('=== cars Table Columns ===')
col_names = [col[1] for col in columns]
print(col_names)
print()
# 배너 차량들의 source_id 확인
cursor.execute('''
SELECT h.car_id, c.source_id,
(SELECT COUNT(*) FROM car_performance_checks WHERE car_id = h.car_id) as has_perf
FROM hero_banners h
JOIN cars c ON h.car_id = c.id
WHERE h.is_active = 1
''')
banner_cars = cursor.fetchall()
print('=== Banner Cars Status ===')
print()
cars_without_perf = []
for car in banner_cars:
car_id, source_id, has_perf = car
status = 'O' if has_perf > 0 else 'X'
print(f'Car ID: {car_id}, Source ID: {source_id}, Perf Check: {status}')
if has_perf == 0 and source_id:
cars_without_perf.append((car_id, source_id))
print()
print('=== Cars that need performance check fetch ===')
for car_id, source_id in cars_without_perf:
print(f' Car ID: {car_id}, Source ID: {source_id}')
conn.close()

8
backend/check_config.py Normal file
View File

@@ -0,0 +1,8 @@
import sys
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
from app.config import get_settings
settings = get_settings()
print(f"USE_SQLITE: {settings.USE_SQLITE}")
print(f"DATABASE_URL: {settings.DATABASE_URL}")

47
backend/check_data.py Normal file
View File

@@ -0,0 +1,47 @@
import sqlite3
import os
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# 활성 배너 차량 확인
cursor.execute('SELECT id, car_id, is_active FROM hero_banners WHERE is_active = 1')
banners = cursor.fetchall()
print('=== Active Banners ===')
for b in banners:
print(f' Banner ID: {b[0]}, Car ID: {b[1]}')
print()
# 성능점검표 테이블 구조 확인
cursor.execute('PRAGMA table_info(car_performance_checks)')
columns = cursor.fetchall()
print('=== car_performance_checks Table Structure ===')
for col in columns:
print(f' {col[1]} ({col[2]})')
print()
# 배너 차량들의 성능점검표 확인
if banners:
car_ids = [b[1] for b in banners if b[1]]
if car_ids:
placeholders = ','.join('?' * len(car_ids))
cursor.execute(f'SELECT car_id, check_number FROM car_performance_checks WHERE car_id IN ({placeholders})', car_ids)
perf_checks = cursor.fetchall()
print('=== Performance Checks for Banner Cars ===')
if perf_checks:
for pc in perf_checks:
print(f' Car ID: {pc[0]}, Check #: {pc[1]}')
else:
print(' No performance checks found for banner cars!')
print()
# 전체 성능점검표 개수
cursor.execute('SELECT COUNT(*) FROM car_performance_checks')
count = cursor.fetchone()[0]
print(f'=== Total Performance Checks: {count} ===')
conn.close()

81
backend/check_db.py Normal file
View File

@@ -0,0 +1,81 @@
import sqlite3
conn = sqlite3.connect('D:/Workspace/claudeCode/AutonetSellCar.com/backend/car_platform.db')
cursor = conn.cursor()
# List all tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print('Tables in database:')
for t in tables:
print(f' - {t[0]}')
# Check if inquiries table exists
if ('inquiries',) not in tables:
print('\nCreating inquiries table...')
cursor.execute('''
CREATE TABLE inquiries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
car_id INTEGER,
category VARCHAR(50) DEFAULT "general",
subject VARCHAR(200),
message TEXT NOT NULL,
contact_email VARCHAR(255),
contact_phone VARCHAR(50),
status VARCHAR(20) DEFAULT "pending",
admin_response TEXT,
responded_at DATETIME,
responded_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (car_id) REFERENCES cars (id)
)
''')
print('inquiries table created!')
else:
print('\nChecking columns in inquiries table...')
cursor.execute('PRAGMA table_info(inquiries)')
columns = cursor.fetchall()
existing_cols = [col[1] for col in columns]
print('Existing columns:', existing_cols)
# Add new columns if they don't exist
new_columns = [
('category', 'VARCHAR(50) DEFAULT "general"'),
('subject', 'VARCHAR(200)'),
('contact_email', 'VARCHAR(255)'),
('contact_phone', 'VARCHAR(50)'),
('admin_response', 'TEXT'),
('responded_at', 'DATETIME'),
('responded_by', 'INTEGER'),
('updated_at', 'DATETIME'),
]
for col_name, col_type in new_columns:
if col_name not in existing_cols:
try:
cursor.execute(f'ALTER TABLE inquiries ADD COLUMN {col_name} {col_type}')
print(f'Added column: {col_name}')
except Exception as e:
print(f'Error adding {col_name}: {e}')
# Create inquiry_messages table if not exists
cursor.execute('''
CREATE TABLE IF NOT EXISTS inquiry_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inquiry_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
message TEXT NOT NULL,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (inquiry_id) REFERENCES inquiries (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
print('\ninquiry_messages table created/verified')
conn.commit()
conn.close()
print('\nDatabase update complete!')

View File

@@ -0,0 +1,46 @@
import sqlite3
import os
db_path = os.path.join(os.path.dirname(__file__), 'car_platform.db')
print(f"Database path: {db_path}")
print(f"Database exists: {os.path.exists(db_path)}")
print(f"Database size: {os.path.getsize(db_path) if os.path.exists(db_path) else 0} bytes")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
print(f"\nTables: {[t[0] for t in tables]}")
# Check counts for each important table
table_counts = [
'car_makers',
'car_models',
'cars',
'hero_banners',
'users',
'translations'
]
print("\nTable counts:")
for table in table_counts:
try:
cursor.execute(f"SELECT COUNT(*) FROM {table}")
count = cursor.fetchone()[0]
print(f" {table}: {count}")
except Exception as e:
print(f" {table}: Error - {e}")
# Check if there are any car_makers
cursor.execute("SELECT * FROM car_makers LIMIT 5")
makers = cursor.fetchall()
print(f"\nSample car_makers: {makers}")
# Check if there are any hero_banners
cursor.execute("SELECT * FROM hero_banners LIMIT 5")
banners = cursor.fetchall()
print(f"\nSample hero_banners: {banners}")
conn.close()

49
backend/check_dbs.py Normal file
View File

@@ -0,0 +1,49 @@
import os
from pathlib import Path
import sqlite3
base_path = Path(r'D:\Workspace\claudeCode\AutonetSellCar.com')
# Find all .db files
db_files = []
for p in base_path.rglob('*.db'):
if 'node_modules' not in str(p) and 'venv' not in str(p):
db_files.append(p)
print("=" * 60)
print("발견된 DB 파일들:")
print("=" * 60)
for db_path in sorted(db_files):
size = db_path.stat().st_size / 1024 # KB
mtime = db_path.stat().st_mtime
from datetime import datetime
mtime_str = datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M:%S')
print(f"\n{db_path}")
print(f" 크기: {size:.1f} KB")
print(f" 수정: {mtime_str}")
# Check tables
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = [t[0] for t in cursor.fetchall()]
print(f" 테이블: {', '.join(tables[:10])}")
# Check if car_performance_checks table exists
if 'car_performance_checks' in tables:
cursor.execute("SELECT COUNT(*), COUNT(pdf_path) FROM car_performance_checks WHERE pdf_path IS NOT NULL AND pdf_path != ''")
total, with_pdf = cursor.fetchone()
print(f" 성능점검: 총 {total}개, PDF있음 {with_pdf}")
# Show recent records
cursor.execute("SELECT car_id, check_number, pdf_path FROM car_performance_checks ORDER BY id DESC LIMIT 3")
for row in cursor.fetchall():
print(f" car_id={row[0]}, check_num={row[1]}, pdf={row[2]}")
conn.close()
except Exception as e:
print(f" 오류: {e}")
print("\n" + "=" * 60)

View File

@@ -0,0 +1,40 @@
"""Check PDF migration status"""
import sqlite3
import os
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Check total records
cursor.execute('SELECT COUNT(*) FROM car_performance_checks')
total = cursor.fetchone()[0]
# Check records with check_number
cursor.execute("SELECT COUNT(*) FROM car_performance_checks WHERE check_number IS NOT NULL AND check_number != ''")
with_check_num = cursor.fetchone()[0]
# Check records with pdf_path
cursor.execute("SELECT COUNT(*) FROM car_performance_checks WHERE pdf_path IS NOT NULL AND pdf_path != ''")
with_pdf = cursor.fetchone()[0]
print("=== Performance Check PDF Status ===")
print(f"Total records: {total}")
print(f"With check_number: {with_check_num}")
print(f"With PDF: {with_pdf}")
print(f"Pending PDF generation: {with_check_num - with_pdf}")
print()
# Show details
cursor.execute("SELECT car_id, check_number, pdf_path FROM car_performance_checks")
records = cursor.fetchall()
print("=== Details ===")
for r in records:
car_id, check_num, pdf_path = r
status = "O" if pdf_path else "X"
print(f"Car ID: {car_id}, Check#: {check_num}, PDF: {status}")
if pdf_path:
print(f" -> {pdf_path}")
conn.close()

View File

@@ -0,0 +1,91 @@
"""성능점검표 가져오기 스크립트"""
import asyncio
import sys
import os
os.chdir(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, '.')
from app.api.carmodoo import carmodoo_client
from app.database import SessionLocal
from app.models import Car, CarPerformanceCheck
async def fetch_and_save():
db = SessionLocal()
try:
# 1. 최근 차량 중 성능점검표 없는 것 확인
cars = db.query(Car).order_by(Car.id.desc()).limit(5).all()
print("Recent cars:")
for c in cars:
perf = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == c.id).first()
print(f" ID {c.id}: {c.source_id} - has_perf: {perf is not None}")
# K5 차량 (ID 6) 처리
car = db.query(Car).filter(Car.id == 6).first()
if not car:
print("Car ID 4 not found")
return
print(f"Car: {car.car_name}, source_id: {car.source_id}")
# 2. 기존 성능점검표 삭제
existing = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == 4).first()
if existing:
db.delete(existing)
db.commit()
print("Deleted existing performance check")
# 3. 카모두에서 성능점검표 가져오기
print(f"Fetching performance check for car_no: {car.source_id}")
result = await carmodoo_client.get_performance_check(car.source_id)
print(f"Result found: {result.get('found')}")
print(f"Check number: {result.get('check_num')}")
if result.get('found') and result.get('data'):
perf_data = result['data']
print(f"Performance check data keys: {perf_data.keys()}")
# 4. DB에 저장
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"),
raw_data=perf_data,
raw_html=result.get("raw_html", ""),
)
db.add(performance_check)
db.commit()
print(f"Saved performance check: {perf_data.get('check_number')}")
else:
print("No performance check data found from Carmodoo")
print(f"Full result: {result}")
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
if __name__ == "__main__":
asyncio.run(fetch_and_save())

View File

@@ -0,0 +1,97 @@
"""성능점검표 직접 가져오기 (checkNum 직접 입력)"""
import asyncio
import sys
import os
import httpx
from lxml import html as lxml_html
os.chdir(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, '.')
from app.database import SessionLocal
from app.models import Car, CarPerformanceCheck
async def fetch_performance_check_direct(check_num: str):
"""checkNum으로 직접 성능점검표 가져오기"""
print(f"Fetching performance check: {check_num}")
async with httpx.AsyncClient(timeout=30.0) as client:
perf_url = "https://ck.carmodoo.com/carCheck/carmodooPrint.do"
params = {"print": "0", "checkNum": check_num}
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36',
'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:
html = response.content.decode('utf-8')
print(f"Got HTML response: {len(html)} bytes")
# 간단한 파싱
tree = lxml_html.fromstring(html)
# 차명 추출
car_name = tree.xpath('//th[contains(text(), "차명")]/following-sibling::td/text()')
if car_name:
print(f"Car name: {car_name[0].strip()}")
# 등록번호 추출
car_number = tree.xpath('//th[contains(text(), "자동차등록번호")]/following-sibling::td/text()')
if car_number:
print(f"Car number: {car_number[0].strip()}")
# 주행거리 추출
mileage_text = tree.xpath('//*[contains(text(), "현재 주행거리")]/text()')
print(f"Mileage raw: {mileage_text}")
return html
else:
print(f"Error: {response.status_code}")
return None
async def save_to_db(car_id: int, check_num: str, html: str):
"""성능점검표 DB에 저장"""
db = SessionLocal()
try:
# 기존 데이터 삭제
existing = db.query(CarPerformanceCheck).filter(CarPerformanceCheck.car_id == car_id).first()
if existing:
db.delete(existing)
db.commit()
print(f"Deleted existing performance check for car {car_id}")
# 새 데이터 저장
perf_check = CarPerformanceCheck(
car_id=car_id,
check_number=check_num,
raw_html=html,
)
db.add(perf_check)
db.commit()
print(f"Saved performance check {check_num} for car {car_id}")
finally:
db.close()
async def main():
# 성능점검번호 (사용자가 제공한 K5 차량)
check_num = "7400044430"
# 성능점검표 가져오기
html = await fetch_performance_check_direct(check_num)
if html:
# 가장 최근 차량(K5)에 저장 - ID 6
car_id = 6
await save_to_db(car_id, check_num, html)
print("Done!")
else:
print("Failed to fetch performance check")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,107 @@
"""
기존 차량의 성능점검표를 가져오는 스크립트
"""
import asyncio
import sys
sys.path.insert(0, '.')
from sqlalchemy.orm import Session
from app.database import SessionLocal, engine
from app.models import Car, CarPerformanceCheck
from app.api.carmodoo import carmodoo_client
async def fetch_and_save_performance_check(car_id: int, car_no: str):
"""특정 차량의 성능점검표를 가져와서 저장"""
db = SessionLocal()
try:
# 이미 있는지 확인
existing = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.car_id == car_id
).first()
if existing:
print(f"Car {car_id} already has performance check")
return False
# 카모두에서 성능점검표 가져오기
print(f"Fetching performance check for car_no: {car_no}")
result = await carmodoo_client.get_performance_check(car_no)
# raw_html 저장 (디버깅용)
if result.get("raw_html"):
with open(f"./debug_perf_check_{car_no}.html", "w", encoding="utf-8") as f:
f.write(result["raw_html"])
print(f" Raw HTML saved to debug_perf_check_{car_no}.html (length: {len(result['raw_html'])})")
if not result.get("found"):
print(f"Performance check not found for car_no: {car_no}")
return False
perf_data = result["data"]
print(f"Performance check found: {perf_data.get('check_number', 'N/A')}")
# 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"),
raw_data=perf_data,
raw_html=result.get("raw_html", "")[:50000],
report_image_url=perf_data.get("report_image_url"),
)
db.add(performance_check)
db.commit()
print(f"Performance check saved for car {car_id}")
return True
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
db.rollback()
return False
finally:
db.close()
async def main():
# 모든 차량 조회
db = SessionLocal()
cars = db.query(Car).filter(
Car.source == "carmodoo",
Car.source_id.isnot(None)
).all()
db.close()
print(f"Found {len(cars)} cars from carmodoo")
for car in cars:
print(f"\n--- Processing Car ID: {car.id}, Source ID: {car.source_id} ---")
await fetch_and_save_performance_check(car.id, car.source_id)
await asyncio.sleep(1) # 서버 부하 방지
if __name__ == "__main__":
asyncio.run(main())

48
backend/fix_pdf.py Normal file
View File

@@ -0,0 +1,48 @@
import asyncio
import sqlite3
import os
import sys
from pathlib import Path
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
from app.services.pdf_service import capture_performance_check_pdf
async def fix_missing_pdfs():
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Find cars with check_number but no pdf_path
cursor.execute("""
SELECT car_id, check_number, pdf_path
FROM car_performance_checks
WHERE check_number IS NOT NULL AND check_number != ''
AND (pdf_path IS NULL OR pdf_path = '')
""")
missing = cursor.fetchall()
print(f"PDF 없는 성능점검 레코드: {len(missing)}")
for car_id, check_num, pdf_path in missing:
print(f"\n차량 {car_id}: check_num={check_num}")
print(f" PDF 생성 중...")
try:
pdf = await capture_performance_check_pdf(check_num, car_id)
if pdf:
cursor.execute(
"UPDATE car_performance_checks SET pdf_path = ? WHERE car_id = ?",
(pdf, car_id)
)
conn.commit()
print(f" [OK] PDF: {pdf}")
else:
print(f" [FAIL] PDF generation failed")
except Exception as e:
print(f" [ERROR] {e}")
conn.close()
print("\n완료!")
asyncio.run(fix_missing_pdfs())

145
backend/frame_0_after.html Normal file
View File

@@ -0,0 +1,145 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
<title>카모두 - 딜러매물 공유시스템</title>
<link href="/css/layout.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub_search.css?ver=20241018" rel="stylesheet" type="text/css">
<script type="text/javascript" async="" src="https://www.google-analytics.com/analytics.js"></script><script type="text/javascript" async="" src="https://www.googletagmanager.com/gtag/js?id=G-KQMHL5VC6S&amp;cx=c&amp;gtm=4e5ca1"></script><script src="/common/default.js?ver=20241018" type="text/javascript"></script>
<script src="/common/jquery.js?ver=20241018" type="text/javascript"></script>
<script src="/js/jsapi.js?ver=20241018" type="text/javascript"></script>
<script src="/common/carSearch.js?ver=20241018" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
// container 실제 높이 설정
$('.container').css('height',realContainerHeight()+'px');
//$("#iframeLayer").css('height',realContainerHeight()+'px');
setIframeUrl();
});
function setIframeUrl() {
//console.log($('.container').height());
var h = $('.container').height() - 0;
//h = realContainerHeight() - 50;
$("#iframeLayer").html("<iframe name='dataIframe' id='dataIframe' src='https://api.autobegins.com/cp/?k=edc921b19788b782f6760a1fc0a6588c0553a4aa2cfbfb03be8c0433165960b4' style='margin:0px;padding:0px;border:0px;width:100%; height:" + h + "px;'></iframe>");
}
function realContainerHeight(){
var scrH = $(window).height();
var footerH = 0;
if($('.header_n').length != 0){
footerH = $('.header_n').outerHeight(true);
}
return scrH - footerH;
}
</script>
<!-- favicon set -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
<link rel="manifest" href="/images/favicon/site.webmanifest">
<link rel="mask-icon" href="/images/favicon/safari-pinned-tab.svg" color="#3fcdff">
<meta name="msapplication-TileColor" content="#3fcdff">
<meta name="theme-color" content="#3fcdff">
<!--// favicon set 예외 아이디 입력(구글애널리틱스 동작안하게 처리) -->
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-141060518-2"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-141060518-2');
</script>
<script type="text/javascript" src="//wcs.naver.net/wcslog.js"></script><script type="text/javascript" async="" src="https://ssl.pstatic.net/melona/libs/gfp-nac-module/synchronizer.js"></script>
<script type="text/javascript">
if(!wcs_add) var wcs_add = {};
wcs_add["wa"] = "a47b8ec8c031b8";
if(window.wcs) {
wcs_do();
}
</script>
</head>
<body>
<div id="temptempLayer"></div>
<script src="/js/checkbox.js?ver=20241018" type="text/javascript"></script>
<div class="header_wrap_n">
<iframe name="sessionCatch_iframe" src="/common/sessionCatch.html" width="0" height="0" frameborder="0" scrolling="NO"></iframe>
<div class="header_n">
<div class="cont">
<div class="logo" onclick="location.href='/main.html'"></div>
<div class="gnb_n">
<ul>
<li><a href="/car/carListPhoto.html"><span class="m2">매물조회</span></a>
<ul class="sub1">
<li><a href="/car/carListPhoto.html">매물조회</a></li>
<li><a href="/car/carListSoldNew.html">팔린매물</a></li>
</ul>
</li>
<!--<li ><a href="/car/speedList.html"><span class="m4">급구차량</span></a></li>-->
<li><span class="m5">중고차상담</span>
<ul class="sub5">
<li><a href="/consult/list.html">판매상담</a></li>
<li><a href="/consult/list.html?code=buy">구매상담</a></li>
<li><a href="/consult/sms.html">문자상담내역</a></li>
</ul>
</li>
<li class="on"><a href="/info/totalSearch.html"><span class="m6">조회서비스</span></a>
<ul class="sub6">
<li><a href="/info/search_ab.html">상세사양조회</a></li>
<!-- <li><a href="/info/totalSearch.html">사양/원부조회</a></li>
<li><a href="/info/carinfoSearch.html">사양조회</a></li> -->
<li><a href="/info/wonbuSearch.html">간편원부조회</a></li>
<li><a href="/info/newcarIndex.html">신차정보</a></li>
<li><a href="/info/price.html">중고차시세</a></li>
<li><a href="/info/dealerSearch.html">종사원확인</a></li>
<li><a href="/info/companySearch.html">상사확인</a></li>
<li><a href="/info/carCheck.html">성능점검인쇄</a></li>
</ul>
</li>
<li id="m6"><a href="/board/board.html?code=notice"><span class="m7">게시판</span></a>
<ul class="sub7">
<li><a href="/board/board.html?code=notice">공지사항</a></li>
<li><a href="/board/board.html?code=qna">건의함</a></li>
</ul>
</li>
<li><a href="/car/sangsaList.html"><span class="m3">상사매물</span></a></li>
<li id="m7"><a href="/mypage/mycarList.html?pStatus=1"><span class="m8">내차관리</span></a>
<ul class="sub8">
<li><a href="/mypage/mycarList.html?pStatus=1">나의매물</a></li>
<li><a href="/mypage/mycarList.html?pStatus=3">계약중</a></li>
<li><a href="/car/interestListNew.html">관심차량</a></li>
<!-- <li><a href="/car/searchListNew.html">최근검색</a></li> -->
<li><a href="/mypage/settingLogin.html">환경설정</a></li>
<li><a href="/mypage/regModify.html">나의 정보관리</a></li>
<!-- <li><a href="/mypage/carRegist.html">매물등록</a></li> -->
<!-- <li><span>광고관리</span>
<ul>
<li><a href="/mypage/adcarList.html">광고차량관리</a></li>
<li><a href="/mypage/adCompany.html">광고업체설정</a></li>
</ul>
</li> -->
</ul>
</li>
</ul>
</div>
<div class="header_info">
<div class="customer">
<span>시스템 문의전화</span>
031-242-8940
</div>
<a href="/member/logout.html" class="btn_logout">로그아웃</a>
</div>
<!--<strong>홍사성</strong>님-->
<a href="javascript:window.history.back()" class="btn_back">뒤로가기</a>
</div>
</div><!--// header-->
</div>
<div class="container" id="iframeLayer" style="height: 670px;"><iframe name="dataIframe" id="dataIframe" src="https://api.autobegins.com/cp/?k=edc921b19788b782f6760a1fc0a6588c0553a4aa2cfbfb03be8c0433165960b4" style="margin:0px;padding:0px;border:0px;width:100%; height:670px;"></iframe></div>
</body></html>

View File

@@ -0,0 +1,62 @@
<html><head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
<script src="/js/jquery-1.8.3.min.js?ver=20241018" type="text/javascript"></script>
<script language="JavaScript" src="/common/ajax/ajax.js"></script>
<script language="JavaScript">
<!--
function _errorFunc() {
// alert("네트워크 상태가 원할하지 않습니다. 잠시 뒤 다시 검색하세요.");
}
function startSession() {
$.ajax({
type:'POST',
url: "/common/ajax/sessionHold.html",
dataType:'xml',
cache:false,
async:false,
success:xmlGetOn, // 형태를 결정한다.
error:_errorFunc,
beforeSend:function(x) {
if(x && x.overrideMimeType) {
x.overrideMimeType("application/xml;charset=euc-kr");
}
}
});
//var aObj = new AjaxObject;
//var fLink = "/common/ajax/sessionHold.html";
//aObj.getHttpRequest(fLink, "xmlGet");
}
function xmlGet(view) {
if(view['item'][0]['status'] == "N") {
var time = view['item'][0]['time'];
var ip = view['item'][0]['ip'];
var m_id = view['item'][0]['m_id'];
alert("해당 계정은 " + ip + " 에서 " + time + " 에 접속해서 로그인이 해제 됩니다.");
parent.location.href = "/member/logout.html";
}
setTimeout("startSession()", 60000);
}
function xmlGetOn(xml) {
$(xml).find("item").each(function() {
var status = $(this).find("status").text();
var ip = $(this).find("ip").text();
var m_id = $(this).find("m_id").text();
var time = $(this).find("time").text();
if(status == "N") {
alert("해당 계정은 " + ip + " 에서 " + time + " 에 접속해서 로그인이 해제 됩니다.");
parent.location.href = "/member/logout.html";
}
});
//console.log(view);
setTimeout("startSession()", 60000);
}
setTimeout("startSession()", 60000);
//-->
</script>
</head>
<body></body></html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html><html lang="ko"><head>
<meta charset="euc-kr">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>사양조회</title>
<link rel="stylesheet" type="text/css" href="/cp/css/layout.css">
<link rel="stylesheet" type="text/css" href="/cp/css/scrollbar.css">
<script type="text/javascript" src="/cp/js/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/cp/js/jquery.scrollbar.js"></script>
<script type="text/javascript" src="/cp/js/common.js"></script>
<script type="text/javascript" src="/cp/js/emeye.js"></script>
<script type="text/javascript" src="/cp/js/SearchCarNum.js?v=1"></script>
<style>
body, html {height:100%;background:#fff;}
.wrapper {height:100% !important;}
.page_wrap {height:calc(100% - 109px);text-align:center;}
</style>
</head>
<body>
<input type="hidden" name="otp" id="otp" value="edc921b19788b782f6760a1fc0a6588c0553a4aa2cfbfb03be8c0433165960b4" style="">
<input type="hidden" name="nextOtp" id="nextOtp" value="c1e03980a2a7e8e734c4f186a0865973d6ce4eef2dfc6db6acfe3f881daf5698" style="">
<input type="hidden" name="importKey" id="importKey" value="" style="">
<div class="wrapper print">
<!-- header -->
<div class="header">
<div class="header_info">
<span class="search_box">
<input type="text" name="mainCarNum" id="mainCarNum" placeholder="차량번호" maxlength="9" class="search" style="" onkeydown="return deleteSpaceKey(event);">
<input type="submit" value="조회" onclick="searchCarNum('main');" style="">
</span>
</div>
</div>
<!--// header -->
<div class="page_wrap">
<iframe name="searchIFrame" id="searchIFrame" src="search_guide.html" frameborder="0" width="100%" height="100%"></iframe>
</div><!--// page -->
</div>
</body></html>

View File

@@ -0,0 +1,23 @@
<!DOCTYPE html><html lang="ko" class="iframe"><head>
<meta charset="euc-kr">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>사양조회</title>
<link rel="stylesheet" type="text/css" href="/cp/css/layout.css">
<script type="text/javascript" src="/cp/js/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/cp/js/common.js"></script>
<script type="text/javascript" src="/cp/js/emeye.js"></script>
<script type="text/javascript" src="/cp/js/SearchCarNum.js?v=1"></script>
<style>
body {background:#fff;}
.wrapper {min-width:auto;height:auto;overflow:auto;}
</style>
</head>
<body>
<div class="wrapper">
<div class="search_result">
<div class="guide">본 서비스는 중고차 매매업에 종사하는 허가받은 종사원들의 업무의 편의를 위하여 제공하는 서비스입니다.<br>차량번호를 입력하시면 차량정보와 정확한 세부 등급정보로 신차출고가격 및 옵션정보를 제공합니다.</div>
<img src="/cp/images/sample.png">
</div>
</div>
</body></html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
<title>카모두 - 딜러매물 공유시스템</title>
<link href="/css/layout.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub_search.css?ver=20241018" rel="stylesheet" type="text/css">
<script type="text/javascript" async="" src="https://www.google-analytics.com/analytics.js"></script><script type="text/javascript" async="" src="https://www.googletagmanager.com/gtag/js?id=G-KQMHL5VC6S&amp;cx=c&amp;gtm=4e5ca1"></script><script src="/common/default.js?ver=20241018" type="text/javascript"></script>
<script src="/common/jquery.js?ver=20241018" type="text/javascript"></script>
<script src="/js/jsapi.js?ver=20241018" type="text/javascript"></script>
<script src="/common/carSearch.js?ver=20241018" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
// container 실제 높이 설정
$('.container').css('height',realContainerHeight()+'px');
//$("#iframeLayer").css('height',realContainerHeight()+'px');
setIframeUrl();
});
function setIframeUrl() {
//console.log($('.container').height());
var h = $('.container').height() - 0;
//h = realContainerHeight() - 50;
$("#iframeLayer").html("<iframe name='dataIframe' id='dataIframe' src='https://api.autobegins.com/cp/?k=0e4bc71e238c7aa18429d84168d6291e3ec05ebc9e33231109f4ab08b2ff12f2' style='margin:0px;padding:0px;border:0px;width:100%; height:" + h + "px;'></iframe>");
}
function realContainerHeight(){
var scrH = $(window).height();
var footerH = 0;
if($('.header_n').length != 0){
footerH = $('.header_n').outerHeight(true);
}
return scrH - footerH;
}
</script>
<!-- favicon set -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
<link rel="manifest" href="/images/favicon/site.webmanifest">
<link rel="mask-icon" href="/images/favicon/safari-pinned-tab.svg" color="#3fcdff">
<meta name="msapplication-TileColor" content="#3fcdff">
<meta name="theme-color" content="#3fcdff">
<!--// favicon set 예외 아이디 입력(구글애널리틱스 동작안하게 처리) -->
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-141060518-2"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-141060518-2');
</script>
<script type="text/javascript" src="//wcs.naver.net/wcslog.js"></script><script type="text/javascript" async="" src="https://ssl.pstatic.net/melona/libs/gfp-nac-module/synchronizer.js"></script>
<script type="text/javascript">
if(!wcs_add) var wcs_add = {};
wcs_add["wa"] = "a47b8ec8c031b8";
if(window.wcs) {
wcs_do();
}
</script>
</head>
<body>
<div id="temptempLayer"></div>
<script src="/js/checkbox.js?ver=20241018" type="text/javascript"></script>
<div class="header_wrap_n">
<iframe name="sessionCatch_iframe" src="/common/sessionCatch.html" width="0" height="0" frameborder="0" scrolling="NO"></iframe>
<div class="header_n">
<div class="cont">
<div class="logo" onclick="location.href='/main.html'"></div>
<div class="gnb_n">
<ul>
<li><a href="/car/carListPhoto.html"><span class="m2">매물조회</span></a>
<ul class="sub1">
<li><a href="/car/carListPhoto.html">매물조회</a></li>
<li><a href="/car/carListSoldNew.html">팔린매물</a></li>
</ul>
</li>
<!--<li ><a href="/car/speedList.html"><span class="m4">급구차량</span></a></li>-->
<li><span class="m5">중고차상담</span>
<ul class="sub5">
<li><a href="/consult/list.html">판매상담</a></li>
<li><a href="/consult/list.html?code=buy">구매상담</a></li>
<li><a href="/consult/sms.html">문자상담내역</a></li>
</ul>
</li>
<li class="on"><a href="/info/totalSearch.html"><span class="m6">조회서비스</span></a>
<ul class="sub6">
<li><a href="/info/search_ab.html">상세사양조회</a></li>
<!-- <li><a href="/info/totalSearch.html">사양/원부조회</a></li>
<li><a href="/info/carinfoSearch.html">사양조회</a></li> -->
<li><a href="/info/wonbuSearch.html">간편원부조회</a></li>
<li><a href="/info/newcarIndex.html">신차정보</a></li>
<li><a href="/info/price.html">중고차시세</a></li>
<li><a href="/info/dealerSearch.html">종사원확인</a></li>
<li><a href="/info/companySearch.html">상사확인</a></li>
<li><a href="/info/carCheck.html">성능점검인쇄</a></li>
</ul>
</li>
<li id="m6"><a href="/board/board.html?code=notice"><span class="m7">게시판</span></a>
<ul class="sub7">
<li><a href="/board/board.html?code=notice">공지사항</a></li>
<li><a href="/board/board.html?code=qna">건의함</a></li>
</ul>
</li>
<li><a href="/car/sangsaList.html"><span class="m3">상사매물</span></a></li>
<li id="m7"><a href="/mypage/mycarList.html?pStatus=1"><span class="m8">내차관리</span></a>
<ul class="sub8">
<li><a href="/mypage/mycarList.html?pStatus=1">나의매물</a></li>
<li><a href="/mypage/mycarList.html?pStatus=3">계약중</a></li>
<li><a href="/car/interestListNew.html">관심차량</a></li>
<!-- <li><a href="/car/searchListNew.html">최근검색</a></li> -->
<li><a href="/mypage/settingLogin.html">환경설정</a></li>
<li><a href="/mypage/regModify.html">나의 정보관리</a></li>
<!-- <li><a href="/mypage/carRegist.html">매물등록</a></li> -->
<!-- <li><span>광고관리</span>
<ul>
<li><a href="/mypage/adcarList.html">광고차량관리</a></li>
<li><a href="/mypage/adCompany.html">광고업체설정</a></li>
</ul>
</li> -->
</ul>
</li>
</ul>
</div>
<div class="header_info">
<div class="customer">
<span>시스템 문의전화</span>
031-242-8940
</div>
<a href="/member/logout.html" class="btn_logout">로그아웃</a>
</div>
<!--<strong>홍사성</strong>님-->
<a href="javascript:window.history.back()" class="btn_back">뒤로가기</a>
</div>
</div><!--// header-->
</div>
<div class="container" id="iframeLayer" style="height: 670px;"><iframe name="dataIframe" id="dataIframe" src="https://api.autobegins.com/cp/?k=0e4bc71e238c7aa18429d84168d6291e3ec05ebc9e33231109f4ab08b2ff12f2" style="margin:0px;padding:0px;border:0px;width:100%; height:670px;"></iframe></div>
</body></html>

43
backend/generate_pdf.py Normal file
View File

@@ -0,0 +1,43 @@
"""Generate PDF for performance checks"""
import asyncio
import sqlite3
import os
import sys
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
from app.services.pdf_service import capture_performance_check_pdf
async def generate_pdfs():
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Get records without PDF
cursor.execute("""
SELECT car_id, check_number FROM car_performance_checks
WHERE check_number IS NOT NULL AND check_number != ''
AND (pdf_path IS NULL OR pdf_path = '')
""")
records = cursor.fetchall()
print(f"Found {len(records)} records to generate PDF")
for car_id, check_number in records:
print(f"\nGenerating PDF for Car ID: {car_id}, Check#: {check_number}")
pdf_path = await capture_performance_check_pdf(check_number, car_id)
if pdf_path:
print(f" SUCCESS: {pdf_path}")
# Update database
cursor.execute("UPDATE car_performance_checks SET pdf_path = ? WHERE car_id = ?", (pdf_path, car_id))
conn.commit()
else:
print(f" FAILED")
conn.close()
print("\nDone!")
if __name__ == "__main__":
asyncio.run(generate_pdfs())

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">
<title>카모두 - 딜러매물 공유시스템</title>
<link href="/css/layout.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub.css?ver=20241018" rel="stylesheet" type="text/css">
<link href="/css/sub_search.css?ver=20241018" rel="stylesheet" type="text/css">
<script type="text/javascript" async="" src="https://www.google-analytics.com/analytics.js"></script><script type="text/javascript" async="" src="https://www.googletagmanager.com/gtag/js?id=G-KQMHL5VC6S&amp;cx=c&amp;gtm=4e5ca1"></script><script src="/common/default.js?ver=20241018" type="text/javascript"></script>
<script src="/common/jquery.js?ver=20241018" type="text/javascript"></script>
<script src="/js/jsapi.js?ver=20241018" type="text/javascript"></script>
<script src="/common/carSearch.js?ver=20241018" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
// container 실제 높이 설정
$('.container').css('height',realContainerHeight()+'px');
//$("#iframeLayer").css('height',realContainerHeight()+'px');
setIframeUrl();
});
function setIframeUrl() {
//console.log($('.container').height());
var h = $('.container').height() - 0;
//h = realContainerHeight() - 50;
$("#iframeLayer").html("<iframe name='dataIframe' id='dataIframe' src='https://api.autobegins.com/cp/?k=319398dbc50565ed288ec25ec056df130a476d575089e02fd8598d7addcde4ec' style='margin:0px;padding:0px;border:0px;width:100%; height:" + h + "px;'></iframe>");
}
function realContainerHeight(){
var scrH = $(window).height();
var footerH = 0;
if($('.header_n').length != 0){
footerH = $('.header_n').outerHeight(true);
}
return scrH - footerH;
}
</script>
<!-- favicon set -->
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
<link rel="manifest" href="/images/favicon/site.webmanifest">
<link rel="mask-icon" href="/images/favicon/safari-pinned-tab.svg" color="#3fcdff">
<meta name="msapplication-TileColor" content="#3fcdff">
<meta name="theme-color" content="#3fcdff">
<!--// favicon set 예외 아이디 입력(구글애널리틱스 동작안하게 처리) -->
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async="" src="https://www.googletagmanager.com/gtag/js?id=UA-141060518-2"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-141060518-2');
</script>
<script type="text/javascript" src="//wcs.naver.net/wcslog.js"></script><script type="text/javascript" async="" src="https://ssl.pstatic.net/melona/libs/gfp-nac-module/synchronizer.js"></script>
<script type="text/javascript">
if(!wcs_add) var wcs_add = {};
wcs_add["wa"] = "a47b8ec8c031b8";
if(window.wcs) {
wcs_do();
}
</script>
</head>
<body>
<div id="temptempLayer"></div>
<script src="/js/checkbox.js?ver=20241018" type="text/javascript"></script>
<div class="header_wrap_n">
<iframe name="sessionCatch_iframe" src="/common/sessionCatch.html" width="0" height="0" frameborder="0" scrolling="NO"></iframe>
<div class="header_n">
<div class="cont">
<div class="logo" onclick="location.href='/main.html'"></div>
<div class="gnb_n">
<ul>
<li><a href="/car/carListPhoto.html"><span class="m2">매물조회</span></a>
<ul class="sub1">
<li><a href="/car/carListPhoto.html">매물조회</a></li>
<li><a href="/car/carListSoldNew.html">팔린매물</a></li>
</ul>
</li>
<!--<li ><a href="/car/speedList.html"><span class="m4">급구차량</span></a></li>-->
<li><span class="m5">중고차상담</span>
<ul class="sub5">
<li><a href="/consult/list.html">판매상담</a></li>
<li><a href="/consult/list.html?code=buy">구매상담</a></li>
<li><a href="/consult/sms.html">문자상담내역</a></li>
</ul>
</li>
<li class="on"><a href="/info/totalSearch.html"><span class="m6">조회서비스</span></a>
<ul class="sub6">
<li><a href="/info/search_ab.html">상세사양조회</a></li>
<!-- <li><a href="/info/totalSearch.html">사양/원부조회</a></li>
<li><a href="/info/carinfoSearch.html">사양조회</a></li> -->
<li><a href="/info/wonbuSearch.html">간편원부조회</a></li>
<li><a href="/info/newcarIndex.html">신차정보</a></li>
<li><a href="/info/price.html">중고차시세</a></li>
<li><a href="/info/dealerSearch.html">종사원확인</a></li>
<li><a href="/info/companySearch.html">상사확인</a></li>
<li><a href="/info/carCheck.html">성능점검인쇄</a></li>
</ul>
</li>
<li id="m6"><a href="/board/board.html?code=notice"><span class="m7">게시판</span></a>
<ul class="sub7">
<li><a href="/board/board.html?code=notice">공지사항</a></li>
<li><a href="/board/board.html?code=qna">건의함</a></li>
</ul>
</li>
<li><a href="/car/sangsaList.html"><span class="m3">상사매물</span></a></li>
<li id="m7"><a href="/mypage/mycarList.html?pStatus=1"><span class="m8">내차관리</span></a>
<ul class="sub8">
<li><a href="/mypage/mycarList.html?pStatus=1">나의매물</a></li>
<li><a href="/mypage/mycarList.html?pStatus=3">계약중</a></li>
<li><a href="/car/interestListNew.html">관심차량</a></li>
<!-- <li><a href="/car/searchListNew.html">최근검색</a></li> -->
<li><a href="/mypage/settingLogin.html">환경설정</a></li>
<li><a href="/mypage/regModify.html">나의 정보관리</a></li>
<!-- <li><a href="/mypage/carRegist.html">매물등록</a></li> -->
<!-- <li><span>광고관리</span>
<ul>
<li><a href="/mypage/adcarList.html">광고차량관리</a></li>
<li><a href="/mypage/adCompany.html">광고업체설정</a></li>
</ul>
</li> -->
</ul>
</li>
</ul>
</div>
<div class="header_info">
<div class="customer">
<span>시스템 문의전화</span>
031-242-8940
</div>
<a href="/member/logout.html" class="btn_logout">로그아웃</a>
</div>
<!--<strong>홍사성</strong>님-->
<a href="javascript:window.history.back()" class="btn_back">뒤로가기</a>
</div>
</div><!--// header-->
</div>
<div class="container" id="iframeLayer" style="height: 670px;"><iframe name="dataIframe" id="dataIframe" src="https://api.autobegins.com/cp/?k=319398dbc50565ed288ec25ec056df130a476d575089e02fd8598d7addcde4ec" style="margin:0px;padding:0px;border:0px;width:100%; height:670px;"></iframe></div>
</body></html>

View File

@@ -0,0 +1,40 @@
"""
Migration script to add dealer_description translation columns to cars table
Run: python migrate_translations.py
"""
import sqlite3
import os
# DB path
DB_PATH = os.path.join(os.path.dirname(__file__), 'autonet.db')
def migrate():
print(f"Connecting to: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check existing columns
cursor.execute('PRAGMA table_info(cars)')
columns = [col[1] for col in cursor.fetchall()]
print(f"Existing columns: {len(columns)} columns")
# New columns to add
new_columns = [
('dealer_description_en', 'TEXT'),
('dealer_description_mn', 'TEXT'),
('dealer_description_ru', 'TEXT')
]
for col_name, col_type in new_columns:
if col_name not in columns:
cursor.execute(f'ALTER TABLE cars ADD COLUMN {col_name} {col_type}')
print(f'Added column: {col_name}')
else:
print(f'Column already exists: {col_name}')
conn.commit()
conn.close()
print('Migration complete!')
if __name__ == '__main__':
migrate()

49
backend/regenerate_pdf.py Normal file
View File

@@ -0,0 +1,49 @@
"""Regenerate PDF for performance checks with new settings"""
import asyncio
import sqlite3
import os
import sys
from pathlib import Path
sys.path.insert(0, r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
os.chdir(r'D:\Workspace\claudeCode\AutonetSellCar.com\backend')
from app.services.pdf_service import capture_performance_check_pdf, PDF_STORAGE_DIR
async def regenerate_pdfs():
conn = sqlite3.connect('autonet.db')
cursor = conn.cursor()
# Delete old PDFs
print("=== Deleting old PDFs ===")
for pdf_file in PDF_STORAGE_DIR.glob("*.pdf"):
print(f" Deleting: {pdf_file.name}")
pdf_file.unlink()
# Get all records with check_number
cursor.execute("""
SELECT car_id, check_number FROM car_performance_checks
WHERE check_number IS NOT NULL AND check_number != ''
""")
records = cursor.fetchall()
print(f"\n=== Regenerating PDFs for {len(records)} records ===")
for car_id, check_number in records:
print(f"\nGenerating PDF for Car ID: {car_id}, Check#: {check_number}")
pdf_path = await capture_performance_check_pdf(check_number, car_id)
if pdf_path:
print(f" SUCCESS: {pdf_path}")
# Update database
cursor.execute("UPDATE car_performance_checks SET pdf_path = ? WHERE car_id = ?", (pdf_path, car_id))
conn.commit()
else:
print(f" FAILED")
conn.close()
print("\nDone!")
if __name__ == "__main__":
asyncio.run(regenerate_pdfs())

17
backend/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi
uvicorn[standard]
sqlalchemy
# psycopg2-binary # Uncomment for PostgreSQL production
redis
python-dotenv
pydantic
pydantic-settings
python-jose[cryptography]
passlib[bcrypt]
python-multipart
aiofiles
httpx
alembic
email-validator
playwright # PDF capture for performance check reports
apscheduler # Scheduled tasks (exchange rate updates)

55
backend/reset_password.py Normal file
View File

@@ -0,0 +1,55 @@
"""
Password Reset Script
Usage: python reset_password.py <email> <new_password>
"""
import sqlite3
import os
import sys
import bcrypt
db_path = os.path.join(os.path.dirname(__file__), 'autonet.db')
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def reset_password(email: str, new_password: str):
"""Reset user password"""
if not os.path.exists(db_path):
print(f"Database not found: {db_path}")
return False
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# Check if user exists
cursor.execute("SELECT id, email FROM users WHERE email = ?", (email,))
user = cursor.fetchone()
if not user:
print(f"User not found: {email}")
conn.close()
return False
# Update password
password_hash = get_password_hash(new_password)
cursor.execute("UPDATE users SET password_hash = ? WHERE email = ?", (password_hash, email))
conn.commit()
print(f"Password reset successful for: {email}")
print(f" New password: {new_password}")
conn.close()
return True
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python reset_password.py <email> <new_password>")
print("Example: python reset_password.py test@test.com password123")
sys.exit(1)
email = sys.argv[1]
password = sys.argv[2]
reset_password(email, password)

View File

@@ -0,0 +1,202 @@
<!DOCTYPE html><html lang="ko"><head>
<meta charset="euc-kr">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>사양조회</title>
<link rel="stylesheet" type="text/css" href="/cp/css/layout.css">
<script type="text/javascript" src="/cp/js/jquery-1.9.1.min.js"></script>
<script type="text/javascript" src="/cp/js/common.js"></script>
<script type="text/javascript" src="/cp/js/emeye.js"></script>
<script type="text/javascript" src="/cp/js/SearchCarNum.js?v=1"></script>
<style>
body, html {height:auto;overflow:auto;background:#fff;}
@media print {
.reloadinfo, .fuc_print {display:none;}
.page {width:700px !important;}
.digit_area_mini span, .digit_area span {background:transparent !important;}
.print .digit_area_mini span {width:17px;}
.digit_area_mini span.hide, .digit_area span.hide {position:static;font-size:26px;font-weight:bold;text-indent:0;color:#111;line-height:1;}
.print .digit_area span {width:19px;}
.digit_area.red span.hide {color:#f94b34;font-size:30px;}
}
</style>
<script type="text/javascript">
function clipboardCopyVin() {
var i = document.getElementById('copyVin');
i.select();
i.setSelectionRange(0, 9999);
document.execCommand('copy');
}
</script>
</head>
<body>
<div class="wrapper print">
<div class="page">
<div style="text-align:left;padding-top:2rem;"><button type="button" class="btn fuc_print" onclick="print();" style="padding:.5rem 3rem;background:#999;color:#fff">인쇄하기</button>
</div>
<div class="print_header">
<div class="car_num"><span>차량번호</span> 117더3590</div>
<div class="ab"><img src="/cp/images/logo_print.png"></div>
</div>
<div class="carinfo_header">
<div class="info_header_wrap">
<div class="info_header">
<ul class="model">
<li><img src="https://code2.car2b.com/data/makerLogo/20210119/60062c177ed85.png" class="logo"> 기아<br>모닝 어반 프레스티지</li>
</ul>
<div class="car_img"><img src="https://code2.car2b.com/data/_NewCarDB/FrontImage/20250912/68c3abd73b5ba_1.png"></div>
</div>
</div>
</div>
<div class="table_wrap">
<table border="0" class="info_table2">
<colgroup>
<col>
<col style="width:25%">
<col>
<col style="width:20%">
<col>
<col>
</colgroup>
<tbody><tr>
<th>년형</th>
<td>2022년</td>
<th>최초등록일</th>
<td>2022.05.23</td>
<th>외형</th>
<td>경차</td>
</tr>
<tr>
<th>미션</th>
<td>오토</td>
<th>연료</th>
<td>가솔린</td>
<th>배기량</th>
<td>998cc</td>
</tr>
<tr>
<th>색상</th>
<td>흰색</td>
<th>주행거리</th>
<td>9,692km</td>
<th>용도</th>
<td>일반</td>
</tr>
<tr>
<th>차대번호</th>
<td>
<input type="text" id="copyVin" onclick="clipboardCopyVin();" value="KNAB2518BNT952352" style="font-size:12px; border:0px; padding:5px; background:white;">
<button type="button" value="복사" onclick="clipboardCopyVin();" class="btn fuc_print" style="padding:5px;background:#999;color:#fff">복사</button>
<!-- <div id="targetVin" onclick="clipboardCopyVin();">KNAB2518BNT952352</div> -->
</td>
<th>검사유효기간</th>
<td colspan="3">2025년 05월 23일 ~ 2026년 05월 22일</td>
</tr>
</tbody></table>
</div>
<div class="table_wrap">
<h4>가격정보</h4>
<table border="0" class="price_table">
<tbody><tr>
<td class="wid30">출고가 <span class="ex">(VAT, 선택옵션포함)</span><br>
<span class="digit_area red">
<span class="count count1"><span class="hide">1</span></span><span class="count comma"><span class="hide">,</span></span><span class="count count4"><span class="hide">4</span></span><span class="count count5"><span class="hide">5</span></span><span class="count count0"><span class="hide">0</span></span> </span><span class="text">만원</span></td>
<td class="wid30">기본가<br>
<span class="digit_area_mini black">
<span class="count count1"><span class="hide">1</span></span><span class="count comma"><span class="hide">,</span></span><span class="count count3"><span class="hide">3</span></span><span class="count count5"><span class="hide">5</span></span><span class="count count5"><span class="hide">5</span></span> </span><span class="text">만원</span></td>
<td>출고시 옵션가<br>
<span class="digit_area_mini black">
<span class="count hyphen"><span class="hide">-</span></span> </span><span class="text">만원</span></td>
</tr>
</tbody></table>
<table border="0" class="info_table">
<colgroup>
<col style="width:50%;">
<col>
</colgroup>
<tbody><tr>
<th>출고시 옵션</th>
</tr>
<tr>
<td class="opt_cont">
<ul class="opt_first">
<li>드라이브와이즈2(드라이브와이즈1 포함) <span>-</span></li> </ul>
</td>
</tr>
</tbody></table>
<table border="0" class="info_table">
<colgroup>
<col style="width:50%;">
<col>
</colgroup>
<tbody><tr>
<th>기본품목</th>
<th>선택품목</th>
</tr>
<tr>
<td style="padding:5px 0;">
<ul class="opt_base">
<li><img src="/cp/images/cateIcon_2_3_10.png?ver="><span>전동접이</span></li>
<li><img src="/cp/images/cateIcon_2_6_68.png?ver="><span>알루미늄휠</span></li>
<li><img src="/cp/images/cateIcon_3_8_22.png?ver="><span>열선시트(앞)</span></li>
<li><img src="/cp/images/cateIcon_3_8_26.png?ver="><span>통풍시트(운전석)</span></li>
<li><img src="/cp/images/cateIcon_4_14_54.png?ver="><span>후방감지센서</span></li>
<li><img src="/cp/images/cateIcon_5_35_.png?ver="><span>버튼시동</span></li>
<li><img src="/cp/images/cateIcon_5_37_.png?ver="><span>스마트키</span></li>
</ul></td>
<td>
<ul>
<li><span>드라이브 와이즈 </span> <strong>550,000원</strong></li>
<li><span>드라이브 와이즈 Ⅱ(드라이브 와이즈Ⅰ 적용 시)</span> <strong>400,000원</strong></li>
<li><span>엣지</span> <strong>200,000원</strong></li>
<li><span>스타일</span> <strong>500,000원</strong></li>
<li><span>컨비니언스</span> <strong>200,000원</strong></li>
<li><span>멀티미디어 패키지(하이패스 자동결제 시스템 동시 선택 불가)</span> <strong>1,400,000원</strong></li>
<li><span>하이패스 자동결제 시스템(멀티미디어 패키지 동시 선택 불가)</span> <strong>200,000원</strong></li>
</ul>
</td>
</tr>
</tbody></table>
<div class="finance_info">
<span class="title_big">저당</span> <strong id="juCntDiv" class="txt_red">1</strong> / <span class="title_big">압류</span> <strong id="apCntDiv" class="txt_red">0</strong>
</div>
<table class="info_table type3">
<colgroup>
<col width="80">
<col width="120">
<col width="200">
<col>
</colgroup>
<thead>
<tr>
<th>구분</th>
<th>등록일자</th>
<th>관리번호</th>
<th>내용</th>
</tr>
</thead>
<tbody id="searchList">
<tr>
<th>저당</th>
<td>2025-09-18</td>
<td>482a2025192104</td>
<td class="left">우리금융캐피탈 주식회사 / / 채권가액:1,000,000WON</td>
</tr>
</tbody>
</table>
</div>
<div class="detailinfo">제공되는 정보는 매매시 참고용으로만 활용하세요. 실 차량매입시 정확한 등급을 확인하시길 바랍니다.</div>
</div>
</div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View File

@@ -0,0 +1,63 @@
"""
Database migration: Add pdf_path column to car_performance_checks table
Usage:
cd backend
python scripts/add_pdf_path_column.py
"""
import os
import sys
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.exc import OperationalError
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./autonetsellcar.db")
def add_pdf_path_column():
"""Add pdf_path column to car_performance_checks table"""
engine = create_engine(DATABASE_URL)
try:
with engine.connect() as conn:
# Check if column already exists
if "sqlite" in DATABASE_URL:
result = conn.execute(text("PRAGMA table_info(car_performance_checks)"))
columns = [row[1] for row in result.fetchall()]
if "pdf_path" in columns:
print("Column 'pdf_path' already exists. Nothing to do.")
return
else:
# PostgreSQL
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'car_performance_checks'
AND column_name = 'pdf_path'
"""))
if result.fetchone():
print("Column 'pdf_path' already exists. Nothing to do.")
return
# Add the column
print("Adding 'pdf_path' column to car_performance_checks table...")
conn.execute(text(
"ALTER TABLE car_performance_checks ADD COLUMN pdf_path VARCHAR(500)"
))
conn.commit()
print("Column 'pdf_path' added successfully!")
except OperationalError as e:
if "duplicate column" in str(e).lower() or "already exists" in str(e).lower():
print("Column 'pdf_path' already exists. Nothing to do.")
else:
raise
if __name__ == "__main__":
add_pdf_path_column()

View File

@@ -0,0 +1,154 @@
"""
Migration script: Generate PDFs for existing performance check records
This script:
1. Finds all CarPerformanceCheck records that have check_number but no pdf_path
2. Generates PDF for each using Playwright
3. Updates the pdf_path in the database
Usage:
cd backend
python scripts/migrate_performance_check_to_pdf.py
Requirements:
pip install playwright
playwright install chromium
"""
import asyncio
import sys
import os
# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.database import Base
from app.models import CarPerformanceCheck
from app.services.pdf_service import capture_performance_check_pdf, PLAYWRIGHT_AVAILABLE
# Database connection
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./autonetsellcar.db")
async def migrate_performance_checks():
"""Migrate existing performance checks to PDF format"""
if not PLAYWRIGHT_AVAILABLE:
print("ERROR: Playwright is not installed. Please run:")
print(" pip install playwright")
print(" playwright install chromium")
return
# Create database session
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
# Find all performance checks without PDF
records = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.check_number.isnot(None),
CarPerformanceCheck.check_number != "",
(CarPerformanceCheck.pdf_path.is_(None)) | (CarPerformanceCheck.pdf_path == "")
).all()
total = len(records)
print(f"Found {total} performance check records to migrate")
if total == 0:
print("No records to migrate. Done.")
return
success_count = 0
error_count = 0
for i, record in enumerate(records, 1):
print(f"\n[{i}/{total}] Processing car_id={record.car_id}, check_number={record.check_number}")
try:
pdf_path = await capture_performance_check_pdf(
record.check_number,
record.car_id
)
if pdf_path:
record.pdf_path = pdf_path
db.commit()
print(f" SUCCESS: {pdf_path}")
success_count += 1
else:
print(f" FAILED: PDF generation returned None")
error_count += 1
except Exception as e:
print(f" ERROR: {e}")
error_count += 1
db.rollback()
# Small delay to avoid overwhelming the server
await asyncio.sleep(1)
print(f"\n{'='*50}")
print(f"Migration completed!")
print(f" Total: {total}")
print(f" Success: {success_count}")
print(f" Errors: {error_count}")
finally:
db.close()
async def check_status():
"""Check current migration status"""
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
total = db.query(CarPerformanceCheck).count()
with_check_num = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.check_number.isnot(None),
CarPerformanceCheck.check_number != ""
).count()
with_pdf = db.query(CarPerformanceCheck).filter(
CarPerformanceCheck.pdf_path.isnot(None),
CarPerformanceCheck.pdf_path != ""
).count()
print(f"Performance Check Status:")
print(f" Total records: {total}")
print(f" With check_number: {with_check_num}")
print(f" With PDF: {with_pdf}")
print(f" Pending migration: {with_check_num - with_pdf}")
finally:
db.close()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Migrate performance checks to PDF")
parser.add_argument("--status", action="store_true", help="Show current status only")
parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes")
args = parser.parse_args()
if args.status:
asyncio.run(check_status())
else:
print("Starting performance check PDF migration...")
print("This will generate PDFs for all existing performance check records.")
print()
if args.dry_run:
print("DRY RUN - No changes will be made")
asyncio.run(check_status())
else:
confirm = input("Continue? (y/n): ")
if confirm.lower() == 'y':
asyncio.run(migrate_performance_checks())
else:
print("Cancelled.")

View File

@@ -0,0 +1,70 @@
function searchCarNum(flag) {
if(searchDupFlag) {
alert("검색중입니다.");
return false;
}
var carNum = "";
var inputObj = null;
if (flag == 'main') {
inputObj = $("#mainCarNum");
} else {
inputObj = $("#headerCarNum");
}
carNum = trim(inputObj.val());
if (carNum == "") return false;
if (carNum == "") {
alert("차량번호를 입력하세요.");
inputObj.focus();
return false;
}
var rightNum = carNum.substr(carNum.length - 4);
if (!checkDigitOnly(rightNum)) {
alert("차량정보를 찾을 수 없습니다.\n정확한 차량번호를 확인하신 후 다시 시도해 주시기 바랍니다.");
inputObj.focus();
return false;
}
if (carNum.length < 7 || carNum.length > 9) {
alert("차량 번호를 정확하게 입력해주세요.");
inputObj.focus();
return false;
}
inputObj.blur();
// 로딩 스타트

Some files were not shown because too many files have changed in this diff Show More