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

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