- 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>
547 lines
17 KiB
Python
547 lines
17 KiB
Python
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
|
|
}
|