feat: Add bulletin board system

- Add BoardCategory and BoardPost models with multi-language support
- Add bulletin API endpoints (CRUD, notice toggle, pin toggle)
- Add board_enabled setting to control menu visibility
- Create frontend board pages (list, detail, write, edit)
- Create admin board management and category management pages
- Update Header.tsx with conditional Board menu between Inquiry and Contact Us
- Update admin settings with board_enabled toggle
- Add Board menu to admin sidebar

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
AutonetSellCar Deploy
2026-01-10 01:34:41 +09:00
parent 04bec0d2c7
commit e0c1f4540b
17 changed files with 2630 additions and 2 deletions

524
backend/app/api/bulletin.py Normal file
View File

@@ -0,0 +1,524 @@
"""
Bulletin Board API - 게시판 API
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, or_
from typing import Optional
import math
from ..database import get_db
from ..models.bulletin import BoardCategory, BoardPost
from ..models.user import User
from ..schemas.bulletin import (
BoardCategoryCreate, BoardCategoryUpdate, BoardCategoryResponse, BoardCategoryListResponse,
BoardPostCreate, BoardPostUpdate, BoardPostResponse, BoardPostListItem, BoardPostListResponse
)
from .auth import get_current_user, get_current_user_optional
router = APIRouter(prefix="/board", tags=["board"])
# ============ Category Endpoints ============
@router.get("/categories", response_model=BoardCategoryListResponse)
def get_categories(
include_inactive: bool = False,
db: Session = Depends(get_db)
):
"""카테고리 목록 조회 (공개)"""
query = db.query(BoardCategory)
if not include_inactive:
query = query.filter(BoardCategory.is_active == True)
categories = query.order_by(BoardCategory.sort_order, BoardCategory.id).all()
# 각 카테고리별 게시물 수 계산
result = []
for cat in categories:
post_count = db.query(func.count(BoardPost.id)).filter(
BoardPost.category_id == cat.id,
BoardPost.is_published == True
).scalar()
cat_dict = {
"id": cat.id,
"name": cat.name,
"name_en": cat.name_en,
"name_mn": cat.name_mn,
"name_ru": cat.name_ru,
"slug": cat.slug,
"description": cat.description,
"sort_order": cat.sort_order,
"is_active": cat.is_active,
"created_at": cat.created_at,
"updated_at": cat.updated_at,
"post_count": post_count
}
result.append(cat_dict)
return BoardCategoryListResponse(categories=result, total=len(result))
@router.post("/categories", response_model=BoardCategoryResponse)
def create_category(
category: BoardCategoryCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""카테고리 생성 (관리자만)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can create categories"
)
# slug 중복 체크
existing = db.query(BoardCategory).filter(BoardCategory.slug == category.slug).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category slug already exists"
)
db_category = BoardCategory(**category.model_dump())
db.add(db_category)
db.commit()
db.refresh(db_category)
return {**db_category.__dict__, "post_count": 0}
@router.put("/categories/{category_id}", response_model=BoardCategoryResponse)
def update_category(
category_id: int,
category_update: BoardCategoryUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""카테고리 수정 (관리자만)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can update categories"
)
db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first()
if not db_category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# slug 중복 체크 (다른 카테고리)
if category_update.slug:
existing = db.query(BoardCategory).filter(
BoardCategory.slug == category_update.slug,
BoardCategory.id != category_id
).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Category slug already exists"
)
update_data = category_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_category, key, value)
db.commit()
db.refresh(db_category)
post_count = db.query(func.count(BoardPost.id)).filter(
BoardPost.category_id == category_id,
BoardPost.is_published == True
).scalar()
return {**db_category.__dict__, "post_count": post_count}
@router.delete("/categories/{category_id}")
def delete_category(
category_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""카테고리 삭제 (관리자만)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can delete categories"
)
db_category = db.query(BoardCategory).filter(BoardCategory.id == category_id).first()
if not db_category:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Category not found"
)
# 해당 카테고리에 게시물이 있는지 확인
post_count = db.query(func.count(BoardPost.id)).filter(
BoardPost.category_id == category_id
).scalar()
if post_count > 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot delete category with {post_count} posts. Please delete or move posts first."
)
db.delete(db_category)
db.commit()
return {"message": "Category deleted successfully"}
# ============ Post Endpoints ============
@router.get("/posts", response_model=BoardPostListResponse)
def get_posts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id: Optional[int] = None,
search: Optional[str] = None,
db: Session = Depends(get_db)
):
"""게시물 목록 조회 (공개)"""
# 공지사항 (항상 상단에)
notices_query = db.query(BoardPost).filter(
BoardPost.is_published == True,
BoardPost.is_notice == True
).order_by(desc(BoardPost.is_pinned), desc(BoardPost.created_at))
if category_id:
notices_query = notices_query.filter(BoardPost.category_id == category_id)
notices = notices_query.all()
# 일반 게시물
query = db.query(BoardPost).filter(
BoardPost.is_published == True,
BoardPost.is_notice == False
)
if category_id:
query = query.filter(BoardPost.category_id == category_id)
if search:
query = query.filter(
or_(
BoardPost.title.ilike(f"%{search}%"),
BoardPost.content.ilike(f"%{search}%")
)
)
# Total count
total = query.count()
total_pages = math.ceil(total / page_size) if total > 0 else 1
# Pagination
posts = query.order_by(
desc(BoardPost.is_pinned),
desc(BoardPost.created_at)
).offset((page - 1) * page_size).limit(page_size).all()
# Format response
def format_post(post: BoardPost) -> dict:
return {
"id": post.id,
"title": post.title,
"category_id": post.category_id,
"category_name": post.category.name if post.category else None,
"author_id": post.author_id,
"author_name": post.author.name if post.author else None,
"is_notice": post.is_notice,
"is_pinned": post.is_pinned,
"view_count": post.view_count,
"created_at": post.created_at
}
return BoardPostListResponse(
posts=[format_post(p) for p in posts],
notices=[format_post(n) for n in notices],
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.get("/posts/{post_id}", response_model=BoardPostResponse)
def get_post(
post_id: int,
db: Session = Depends(get_db)
):
"""게시물 상세 조회 (공개)"""
post = db.query(BoardPost).filter(
BoardPost.id == post_id,
BoardPost.is_published == True
).first()
if not post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
# 조회수 증가
post.view_count += 1
db.commit()
db.refresh(post)
return post
@router.post("/posts", response_model=BoardPostResponse)
def create_post(
post_data: BoardPostCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""게시물 작성 (회원만)"""
# 카테고리 존재 확인
category = db.query(BoardCategory).filter(
BoardCategory.id == post_data.category_id,
BoardCategory.is_active == True
).first()
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category"
)
# 공지사항은 관리자만 작성 가능
is_notice = post_data.is_notice
if is_notice and not current_user.is_admin:
is_notice = False # 관리자가 아니면 공지사항 불가
db_post = BoardPost(
title=post_data.title,
content=post_data.content,
category_id=post_data.category_id,
author_id=current_user.id,
is_notice=is_notice,
is_pinned=is_notice # 공지사항은 자동으로 고정
)
db.add(db_post)
db.commit()
db.refresh(db_post)
return db_post
@router.put("/posts/{post_id}", response_model=BoardPostResponse)
def update_post(
post_id: int,
post_update: BoardPostUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""게시물 수정 (작성자 또는 관리자)"""
db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first()
if not db_post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
# 권한 체크: 작성자 또는 관리자만
if db_post.author_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to edit this post"
)
# 카테고리 변경 시 존재 확인
if post_update.category_id:
category = db.query(BoardCategory).filter(
BoardCategory.id == post_update.category_id,
BoardCategory.is_active == True
).first()
if not category:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid category"
)
update_data = post_update.model_dump(exclude_unset=True)
# 공지사항/고정은 관리자만 변경 가능
if not current_user.is_admin:
update_data.pop("is_notice", None)
update_data.pop("is_pinned", None)
update_data.pop("is_published", None)
for key, value in update_data.items():
setattr(db_post, key, value)
db.commit()
db.refresh(db_post)
return db_post
@router.delete("/posts/{post_id}")
def delete_post(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""게시물 삭제 (작성자 또는 관리자)"""
db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first()
if not db_post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
# 권한 체크: 작성자 또는 관리자만
if db_post.author_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have permission to delete this post"
)
db.delete(db_post)
db.commit()
return {"message": "Post deleted successfully"}
# ============ Admin Endpoints ============
@router.get("/admin/posts", response_model=BoardPostListResponse)
def admin_get_posts(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
category_id: Optional[int] = None,
is_notice: Optional[bool] = None,
is_published: Optional[bool] = None,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""관리자용 게시물 목록 (모든 게시물 포함)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
query = db.query(BoardPost)
if category_id:
query = query.filter(BoardPost.category_id == category_id)
if is_notice is not None:
query = query.filter(BoardPost.is_notice == is_notice)
if is_published is not None:
query = query.filter(BoardPost.is_published == is_published)
if search:
query = query.filter(
or_(
BoardPost.title.ilike(f"%{search}%"),
BoardPost.content.ilike(f"%{search}%")
)
)
total = query.count()
total_pages = math.ceil(total / page_size) if total > 0 else 1
posts = query.order_by(
desc(BoardPost.is_notice),
desc(BoardPost.is_pinned),
desc(BoardPost.created_at)
).offset((page - 1) * page_size).limit(page_size).all()
def format_post(post: BoardPost) -> dict:
return {
"id": post.id,
"title": post.title,
"category_id": post.category_id,
"category_name": post.category.name if post.category else None,
"author_id": post.author_id,
"author_name": post.author.name if post.author else None,
"is_notice": post.is_notice,
"is_pinned": post.is_pinned,
"view_count": post.view_count,
"created_at": post.created_at
}
# 공지사항 분리
notices = [p for p in posts if p.is_notice]
regular = [p for p in posts if not p.is_notice]
return BoardPostListResponse(
posts=[format_post(p) for p in regular],
notices=[format_post(n) for n in notices],
total=total,
page=page,
page_size=page_size,
total_pages=total_pages
)
@router.put("/admin/posts/{post_id}/toggle-notice")
def toggle_notice(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""공지사항 토글 (관리자만)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first()
if not db_post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
db_post.is_notice = not db_post.is_notice
db_post.is_pinned = db_post.is_notice # 공지사항이면 자동 고정
db.commit()
db.refresh(db_post)
return {"is_notice": db_post.is_notice, "is_pinned": db_post.is_pinned}
@router.put("/admin/posts/{post_id}/toggle-pin")
def toggle_pin(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""상단 고정 토글 (관리자만)"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required"
)
db_post = db.query(BoardPost).filter(BoardPost.id == post_id).first()
if not db_post:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Post not found"
)
db_post.is_pinned = not db_post.is_pinned
db.commit()
return {"is_pinned": db_post.is_pinned}

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
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 .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
@@ -238,6 +238,7 @@ app.include_router(exchange_rate.router)
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.get("/")

View File

@@ -18,6 +18,7 @@ from .car_specification import CarSpecification
from .exchange_rate import ExchangeRate, ExchangeRateHistory
from .cc_package import CCPackage, DEFAULT_CC_PACKAGES
from .visitor import VisitorLog, VisitorDailyStats, VisitorSession
from .bulletin import BoardCategory, BoardPost
__all__ = [
"CarMaker",
@@ -63,4 +64,6 @@ __all__ = [
"VisitorLog",
"VisitorDailyStats",
"VisitorSession",
"BoardCategory",
"BoardPost",
]

View File

@@ -0,0 +1,59 @@
"""
Bulletin Board Models - 게시판 모델
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from ..database import Base
class BoardCategory(Base):
"""게시판 카테고리"""
__tablename__ = "board_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False) # 카테고리명 (한국어)
name_en = Column(String(100), nullable=True) # 영어
name_mn = Column(String(100), nullable=True) # 몽골어
name_ru = Column(String(100), nullable=True) # 러시아어
slug = Column(String(50), unique=True, nullable=False) # URL용 슬러그
description = Column(String(255), nullable=True)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
posts = relationship("BoardPost", back_populates="category")
class BoardPost(Base):
"""게시판 글"""
__tablename__ = "board_posts"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
content = Column(Text, nullable=False)
# Category
category_id = Column(Integer, ForeignKey("board_categories.id"), nullable=False)
# Author
author_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Status
is_notice = Column(Boolean, default=False) # 공지사항 여부 (관리자만 설정 가능)
is_pinned = Column(Boolean, default=False) # 상단 고정 여부
is_published = Column(Boolean, default=True) # 게시 여부
# Stats
view_count = Column(Integer, default=0)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
category = relationship("BoardCategory", back_populates="posts")
author = relationship("User", foreign_keys=[author_id])

View File

@@ -63,6 +63,9 @@ class SystemSettings(Base):
withdrawal_enabled = Column(Boolean, default=True) # 출금 기능 활성화
min_withdrawal_usd = Column(Float, default=10.0) # 최소 출금 금액 (USD)
# 게시판 설정
board_enabled = Column(Boolean, default=True) # 게시판 상단 메뉴 표시 여부
# 차량 판매상태 검증 설정
car_availability_check_enabled = Column(Boolean, default=True) # 자동 검증 활성화
car_availability_check_hour = Column(Integer, default=6) # 검증 시간 (0-23, 기본 새벽 6시)

View File

@@ -0,0 +1,125 @@
"""
Bulletin Board Schemas - 게시판 스키마
"""
from datetime import datetime
from typing import Optional, List
from pydantic import BaseModel
# ============ Category Schemas ============
class BoardCategoryBase(BaseModel):
name: str
name_en: Optional[str] = None
name_mn: Optional[str] = None
name_ru: Optional[str] = None
slug: str
description: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class BoardCategoryCreate(BoardCategoryBase):
pass
class BoardCategoryUpdate(BaseModel):
name: Optional[str] = None
name_en: Optional[str] = None
name_mn: Optional[str] = None
name_ru: Optional[str] = None
slug: Optional[str] = None
description: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class BoardCategoryResponse(BoardCategoryBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
post_count: int = 0
class Config:
from_attributes = True
# ============ Post Schemas ============
class BoardPostBase(BaseModel):
title: str
content: str
category_id: int
class BoardPostCreate(BoardPostBase):
is_notice: bool = False # 관리자만 true 가능
class BoardPostUpdate(BaseModel):
title: Optional[str] = None
content: Optional[str] = None
category_id: Optional[int] = None
is_notice: Optional[bool] = None
is_pinned: Optional[bool] = None
is_published: Optional[bool] = None
class AuthorResponse(BaseModel):
id: int
name: Optional[str] = None
email: str
is_admin: bool = False
class Config:
from_attributes = True
class BoardPostResponse(BaseModel):
id: int
title: str
content: str
category_id: int
category: Optional[BoardCategoryResponse] = None
author_id: int
author: Optional[AuthorResponse] = None
is_notice: bool
is_pinned: bool
is_published: bool
view_count: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
class BoardPostListItem(BaseModel):
"""목록에서 보여줄 간략한 정보"""
id: int
title: str
category_id: int
category_name: Optional[str] = None
author_id: int
author_name: Optional[str] = None
is_notice: bool
is_pinned: bool
view_count: int
created_at: datetime
class Config:
from_attributes = True
class BoardPostListResponse(BaseModel):
posts: List[BoardPostListItem]
notices: List[BoardPostListItem] # 공지사항 (상단 고정)
total: int
page: int
page_size: int
total_pages: int
class BoardCategoryListResponse(BaseModel):
categories: List[BoardCategoryResponse]
total: int

View File

@@ -32,6 +32,9 @@ class SystemSettingsUpdate(BaseModel):
withdrawal_enabled: Optional[bool] = None
min_withdrawal_usd: Optional[float] = None
# 게시판 설정
board_enabled: Optional[bool] = None
# 차량 판매상태 검증 설정
car_availability_check_enabled: Optional[bool] = None
car_availability_check_hour: Optional[int] = None
@@ -67,6 +70,9 @@ class SystemSettingsResponse(BaseModel):
withdrawal_enabled: bool = True
min_withdrawal_usd: float = 10.0
# 게시판 설정
board_enabled: bool = True
# 차량 판매상태 검증 설정
car_availability_check_enabled: bool = True
car_availability_check_hour: int = 6