feat: Add Review (사용후기) board system with CC reward

- Backend: Review model, full API (public/user/admin endpoints)
- Frontend: list, detail, write/edit pages, admin management
- 1 CC reward for writing a review on completed vehicle requests
- Navigation updates (Header + admin sidebar)
- "Write Review" button on my-request page for completed requests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-02-18 08:42:35 +09:00
parent 30888c1434
commit 8e230c537c
12 changed files with 1674 additions and 3 deletions

474
backend/app/api/reviews.py Normal file
View File

@@ -0,0 +1,474 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from ..database import get_db
from ..models import User, VehicleRequest, Review, RequestVehicle
from .auth import get_current_user
from .notification import notify_system
router = APIRouter(prefix="/reviews", tags=["reviews"])
# =====================
# Pydantic Schemas
# =====================
class ReviewCreate(BaseModel):
request_id: int
rating: int = Field(..., ge=1, le=5)
title: str = Field(..., max_length=255)
content: str
class ReviewUpdate(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5)
title: Optional[str] = Field(None, max_length=255)
content: Optional[str] = None
class VehicleInfo(BaseModel):
car_id: Optional[int] = None
car_name: Optional[str] = None
main_image: Optional[str] = None
year: Optional[int] = None
mileage: Optional[int] = None
fuel: Optional[str] = None
final_price: Optional[int] = None
class ReviewListItem(BaseModel):
id: int
rating: int
title: str
content_preview: str
author_name: Optional[str] = None
main_image: Optional[str] = None
view_count: int
created_at: datetime
class Config:
from_attributes = True
class ReviewDetail(BaseModel):
id: int
request_id: int
rating: int
title: str
content: str
author_id: int
author_name: Optional[str] = None
author_email: Optional[str] = None
cc_rewarded: bool
is_published: bool
view_count: int
vehicles: List[VehicleInfo] = []
created_at: datetime
updated_at: Optional[datetime] = None
class ReviewListResponse(BaseModel):
reviews: List[ReviewListItem]
total: int
page: int
page_size: int
total_pages: int
class ReviewableRequestItem(BaseModel):
request_id: int
maker_name: Optional[str] = None
model_name: Optional[str] = None
grade_name: Optional[str] = None
status: str
completed_at: Optional[datetime] = None
vehicle_count: int = 0
main_image: Optional[str] = None
class AdminReviewListItem(BaseModel):
id: int
rating: int
title: str
author_name: Optional[str] = None
author_email: Optional[str] = None
is_published: bool
cc_rewarded: bool
view_count: int
created_at: datetime
# =====================
# Helper Functions
# =====================
def _extract_vehicles(request: VehicleRequest) -> List[VehicleInfo]:
"""Extract vehicle info from a request's recommended vehicles"""
vehicles = []
for rv in request.recommended_vehicles:
if not rv.is_approved:
continue
car_data = rv.car_data or {}
vehicles.append(VehicleInfo(
car_id=rv.car_id or car_data.get("local_car_id"),
car_name=car_data.get("car_name"),
main_image=car_data.get("main_image"),
year=car_data.get("year"),
mileage=car_data.get("mileage"),
fuel=car_data.get("fuel"),
final_price=car_data.get("final_price"),
))
return vehicles
def _get_main_image(request: VehicleRequest) -> Optional[str]:
"""Get the first approved vehicle's main image"""
for rv in request.recommended_vehicles:
if rv.is_approved and rv.car_data:
img = rv.car_data.get("main_image")
if img:
return img
return None
# =====================
# Public Endpoints
# =====================
@router.get("/", response_model=ReviewListResponse)
def get_reviews(
page: int = Query(1, ge=1),
page_size: int = Query(12, ge=1, le=50),
rating: Optional[int] = Query(None, ge=1, le=5),
db: Session = Depends(get_db),
):
"""Get published reviews list"""
query = db.query(Review).filter(Review.is_published == True)
if rating:
query = query.filter(Review.rating == rating)
total = query.count()
total_pages = (total + page_size - 1) // page_size
reviews = query.order_by(desc(Review.created_at)) \
.offset((page - 1) * page_size) \
.limit(page_size) \
.all()
items = []
for r in reviews:
main_image = _get_main_image(r.request) if r.request else None
items.append(ReviewListItem(
id=r.id,
rating=r.rating,
title=r.title,
content_preview=r.content[:150] + ("..." if len(r.content) > 150 else ""),
author_name=r.author.name if r.author else None,
main_image=main_image,
view_count=r.view_count,
created_at=r.created_at,
))
return ReviewListResponse(
reviews=items,
total=total,
page=page,
page_size=page_size,
total_pages=total_pages,
)
@router.get("/my/reviewable-requests", response_model=List[ReviewableRequestItem])
def get_reviewable_requests(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Get user's completed requests that don't have a review yet"""
requests = db.query(VehicleRequest).filter(
VehicleRequest.user_id == current_user.id,
VehicleRequest.status == "completed",
).all()
# Filter out requests that already have a review
existing_review_request_ids = set(
r.request_id for r in db.query(Review.request_id).filter(
Review.author_id == current_user.id
).all()
)
items = []
for req in requests:
if req.id in existing_review_request_ids:
continue
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
main_image = None
for v in approved_vehicles:
if v.car_data and v.car_data.get("main_image"):
main_image = v.car_data["main_image"]
break
items.append(ReviewableRequestItem(
request_id=req.id,
maker_name=req.maker_name,
model_name=req.model_name,
grade_name=req.grade_name,
status=req.status,
completed_at=req.admin_reviewed_at,
vehicle_count=len(approved_vehicles),
main_image=main_image,
))
return items
@router.get("/admin/list", response_model=List[AdminReviewListItem])
def admin_get_reviews(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Get all reviews including unpublished"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
reviews = db.query(Review).order_by(desc(Review.created_at)).all()
return [
AdminReviewListItem(
id=r.id,
rating=r.rating,
title=r.title,
author_name=r.author.name if r.author else None,
author_email=r.author.email if r.author else None,
is_published=r.is_published,
cc_rewarded=r.cc_rewarded,
view_count=r.view_count,
created_at=r.created_at,
)
for r in reviews
]
@router.put("/admin/{review_id}/toggle-publish")
def admin_toggle_publish(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Toggle review published status"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
review.is_published = not review.is_published
db.commit()
return {"message": f"Review {'published' if review.is_published else 'unpublished'}", "is_published": review.is_published}
@router.delete("/admin/{review_id}")
def admin_delete_review(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Admin: Delete a review"""
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Admin access required")
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
db.delete(review)
db.commit()
return {"message": "Review deleted"}
@router.get("/{review_id}", response_model=ReviewDetail)
def get_review(
review_id: int,
db: Session = Depends(get_db),
):
"""Get review detail (increments view count)"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if not review.is_published:
raise HTTPException(status_code=404, detail="Review not found")
# Increment view count
review.view_count = (review.view_count or 0) + 1
db.commit()
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
# =====================
# User Endpoints (CRUD)
# =====================
@router.post("/", response_model=ReviewDetail)
def create_review(
review_data: ReviewCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Create a review for a completed vehicle request (rewards 1 CC)"""
# Verify the request exists and belongs to user
request = db.query(VehicleRequest).filter(
VehicleRequest.id == review_data.request_id
).first()
if not request:
raise HTTPException(status_code=404, detail="Vehicle request not found")
if request.user_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only review your own requests")
if request.status != "completed":
raise HTTPException(status_code=400, detail="Can only review completed requests")
# Check for existing review
existing = db.query(Review).filter(Review.request_id == review_data.request_id).first()
if existing:
raise HTTPException(status_code=400, detail="You have already written a review for this request")
# Create review
review = Review(
request_id=review_data.request_id,
author_id=current_user.id,
rating=review_data.rating,
title=review_data.title,
content=review_data.content,
cc_rewarded=True,
is_published=True,
)
db.add(review)
# Reward 1 CC
current_user.cc_balance = (current_user.cc_balance or 0) + 1.0
db.commit()
db.refresh(review)
# Send notification about CC reward
try:
notify_system(
db,
current_user.id,
"Review Reward",
"You earned 1 CC for writing a review! Thank you for your feedback.",
link="/reviews",
)
except Exception:
pass # Don't fail review creation if notification fails
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
@router.put("/{review_id}", response_model=ReviewDetail)
def update_review(
review_id: int,
review_data: ReviewUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Update own review"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if review.author_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only edit your own reviews")
if review_data.rating is not None:
review.rating = review_data.rating
if review_data.title is not None:
review.title = review_data.title
if review_data.content is not None:
review.content = review_data.content
review.updated_at = datetime.utcnow()
db.commit()
db.refresh(review)
vehicles = _extract_vehicles(review.request) if review.request else []
return ReviewDetail(
id=review.id,
request_id=review.request_id,
rating=review.rating,
title=review.title,
content=review.content,
author_id=review.author_id,
author_name=review.author.name if review.author else None,
author_email=review.author.email if review.author else None,
cc_rewarded=review.cc_rewarded,
is_published=review.is_published,
view_count=review.view_count,
vehicles=vehicles,
created_at=review.created_at,
updated_at=review.updated_at,
)
@router.delete("/{review_id}")
def delete_review(
review_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""Delete own review"""
review = db.query(Review).filter(Review.id == review_id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
if review.author_id != current_user.id:
raise HTTPException(status_code=403, detail="You can only delete your own reviews")
db.delete(review)
db.commit()
return {"message": "Review deleted"}

View File

@@ -9,7 +9,7 @@ import asyncio
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from .database import engine, Base, SessionLocal
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin
from .api import cars, auth, inquiries, hero_banners, carmodoo, translations, cc, settings, vehicle_requests, dealer, vehicle_share, withdrawal, referral, notification, dashboard, push, exchange_rate, verification, visitor, sns_share, bulletin, reviews
from .config import get_settings
from .services.exchange_rate_service import update_exchange_rates
from .services.visitor_service import aggregate_daily_stats, cleanup_old_visitor_logs
@@ -239,6 +239,7 @@ app.include_router(verification.router, prefix="/api")
app.include_router(visitor.router, prefix="/api")
app.include_router(sns_share.router, prefix="/api")
app.include_router(bulletin.router, prefix="/api")
app.include_router(reviews.router, prefix="/api")
@app.get("/")

View File

@@ -19,6 +19,7 @@ from .exchange_rate import ExchangeRate, ExchangeRateHistory
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
from .bulletin import BoardCategory, BoardPost
from .review import Review
__all__ = [
"CarMaker",
@@ -66,4 +67,5 @@ __all__ = [
"VisitorSession",
"BoardCategory",
"BoardPost",
"Review",
]

View File

@@ -0,0 +1,29 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, CheckConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class Review(Base):
"""User reviews for completed vehicle requests"""
__tablename__ = "reviews"
__table_args__ = (
CheckConstraint('rating >= 1 AND rating <= 5', name='check_rating_range'),
)
id = Column(Integer, primary_key=True, index=True)
request_id = Column(Integer, ForeignKey("vehicle_requests.id"), nullable=False, unique=True)
author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
rating = Column(Integer, nullable=False)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
cc_rewarded = Column(Boolean, default=False)
is_published = Column(Boolean, default=True, index=True)
view_count = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
request = relationship("VehicleRequest", backref="review")
author = relationship("User", backref="reviews")