Files
AutonetSellCar Deploy 7c97d9aecb feat: Add Promo Interest column to admin users page
- Backend: Include promo_preferred_maker, promo_preferred_model, promo_email_enabled in admin/users API
- Frontend: Add AdminUser interface fields
- Admin UI: Display Promo Interest column with maker/model and email alert status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:51:55 +09:00

673 lines
22 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, SystemSettings, ReferralReward
from ..models.user import generate_referral_code
from ..schemas import UserCreate, UserUpdate, UserResponse, Token
from ..schemas.user import PromoPreferenceUpdate, PromoPreferenceResponse
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,
User.deleted_at.is_(None) # 삭제되지 않은 사용자만
).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
# 삭제된 사용자가 있다면 완전히 제거 (동일 이메일 재가입 허용)
deleted_user = db.query(User).filter(
User.email == user_data.email,
User.deleted_at.isnot(None)
).first()
if deleted_user:
# 관련 데이터 삭제
from ..models import CarView, PerformanceCheckView, ChargeHistory, Inquiry, Notification
db.query(CarView).filter(CarView.user_id == deleted_user.id).delete()
db.query(PerformanceCheckView).filter(PerformanceCheckView.user_id == deleted_user.id).delete()
db.query(ChargeHistory).filter(ChargeHistory.user_id == deleted_user.id).delete()
db.query(Inquiry).filter(Inquiry.user_id == deleted_user.id).delete()
db.query(Notification).filter(Notification.user_id == deleted_user.id).delete()
db.delete(deleted_user)
db.commit()
# 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)
# 레퍼럴 가입 보상 처리
if user.referred_by:
# 추천인 찾기
referrer = db.query(User).filter(User.referral_code == user.referred_by).first()
if referrer:
# 설정에서 보상 CC 가져오기
sys_settings = db.query(SystemSettings).first()
reward_cc = sys_settings.referral_signup_reward_cc if sys_settings else 10.0
# 추천인에게 CC 지급 (프로모션 CC - 출금 불가)
referrer.cc_balance += reward_cc
# 레퍼럴 보상 기록 생성
referral_reward = ReferralReward(
referrer_id=referrer.id,
referred_user_id=user.id,
reward_type="signup",
payment_amount=0.0,
reward_amount=reward_cc,
status="credited",
credited_at=datetime.utcnow(),
)
db.add(referral_reward)
db.commit()
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 user:
# 비밀번호 재설정 필요 여부 체크
if getattr(user, 'password_reset_required', False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password reset required. Too many failed login attempts.",
headers={"WWW-Authenticate": "Bearer"},
)
# 비밀번호 검증
if not verify_password(form_data.password, user.password_hash):
# 실패 횟수 증가
failed_attempts = getattr(user, 'failed_login_attempts', 0) or 0
user.failed_login_attempts = failed_attempts + 1
# 20회 이상 실패 시 비밀번호 재설정 필요
if user.failed_login_attempts >= 20:
user.password_reset_required = True
db.commit()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Account locked. Password reset required due to too many failed attempts.",
headers={"WWW-Authenticate": "Bearer"},
)
db.commit()
remaining = 20 - user.failed_login_attempts
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Incorrect email or password. {remaining} attempts remaining before account lock.",
headers={"WWW-Authenticate": "Bearer"},
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# 삭제된 사용자 체크
if user.deleted_at:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This account has been deleted",
headers={"WWW-Authenticate": "Bearer"},
)
# 비활성화된 사용자 체크
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This account has been deactivated",
headers={"WWW-Authenticate": "Bearer"},
)
# 로그인 성공 - 실패 횟수 초기화
user.failed_login_attempts = 0
db.commit()
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
@router.get("/me/promo-preference", response_model=PromoPreferenceResponse)
def get_promo_preference(current_user: User = Depends(get_current_user)):
"""프로모션 선호 차종 조회"""
return current_user
@router.put("/me/promo-preference", response_model=PromoPreferenceResponse)
def update_promo_preference(
promo_update: PromoPreferenceUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""프로모션 선호 차종 설정"""
current_user.promo_preferred_maker = promo_update.promo_preferred_maker
current_user.promo_preferred_model = promo_update.promo_preferred_model
current_user.promo_email_enabled = promo_update.promo_email_enabled
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,
"promo_preferred_maker": user.promo_preferred_maker,
"promo_preferred_model": user.promo_preferred_model,
"promo_email_enabled": user.promo_email_enabled,
"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
}