초기 커밋

This commit is contained in:
2026-03-01 07:44:19 +09:00
commit 09359f30be
146 changed files with 6120 additions and 0 deletions

0
app/core/__init__.py Normal file
View File

93
app/core/config.py Normal file
View File

@@ -0,0 +1,93 @@
from __future__ import annotations
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# ── Application ──────────────────────────────────
APP_NAME: str = "core-api"
APP_ENV: str = "development"
DEBUG: bool = True
SECRET_KEY: str = "change-me-to-a-random-secret-key"
API_V1_PREFIX: str = "/api/v1"
# ── MariaDB ──────────────────────────────────────
MARIADB_HOST: str = "127.0.0.1"
MARIADB_PORT: int = 3306
MARIADB_USER: str = "root"
MARIADB_PASSWORD: str = "changeme"
MARIADB_DATABASE: str = "core_api"
@property
def MARIADB_DSN(self) -> str:
return (
f"mysql+aiomysql://{self.MARIADB_USER}:{self.MARIADB_PASSWORD}"
f"@{self.MARIADB_HOST}:{self.MARIADB_PORT}/{self.MARIADB_DATABASE}"
)
@property
def MARIADB_DSN_SYNC(self) -> str:
return (
f"mysql+pymysql://{self.MARIADB_USER}:{self.MARIADB_PASSWORD}"
f"@{self.MARIADB_HOST}:{self.MARIADB_PORT}/{self.MARIADB_DATABASE}"
)
# ── MongoDB ──────────────────────────────────────
MONGODB_URL: str = "mongodb://127.0.0.1:27017"
MONGODB_DATABASE: str = "core_api"
# ── Redis ────────────────────────────────────────
REDIS_URL: str = "redis://127.0.0.1:6379/0"
# ── JWT ──────────────────────────────────────────
JWT_SECRET_KEY: str = "change-me-jwt-secret"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# ── MQTT ─────────────────────────────────────────
MQTT_HOST: str = "127.0.0.1"
MQTT_PORT: int = 1883
MQTT_USERNAME: str = ""
MQTT_PASSWORD: str = ""
# ── Celery ───────────────────────────────────────
CELERY_BROKER_URL: str = "redis://127.0.0.1:6379/1"
CELERY_RESULT_BACKEND: str = "redis://127.0.0.1:6379/2"
# ── CORS ─────────────────────────────────────────
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:8080"]
@field_validator("CORS_ORIGINS", mode="before")
@classmethod
def assemble_cors_origins(cls, v: str | list[str]) -> list[str]:
if isinstance(v, str):
return [i.strip() for i in v.strip("[]").split(",") if i.strip()]
return v
# ── OAuth ────────────────────────────────────────
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
KAKAO_CLIENT_ID: str = ""
KAKAO_CLIENT_SECRET: str = ""
NAVER_CLIENT_ID: str = ""
NAVER_CLIENT_SECRET: str = ""
# ── SMTP ─────────────────────────────────────────
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USERNAME: str = ""
SMTP_PASSWORD: str = ""
# ── Logging ──────────────────────────────────────
LOG_LEVEL: str = "DEBUG"
settings = Settings()

31
app/core/constants.py Normal file
View File

@@ -0,0 +1,31 @@
from __future__ import annotations
class Role:
SUPERADMIN = "superadmin"
ADMIN = "admin"
MANAGER = "manager"
USER = "user"
DEVICE = "device"
ALL = [SUPERADMIN, ADMIN, MANAGER, USER, DEVICE]
ADMIN_ROLES = [SUPERADMIN, ADMIN]
MANAGEMENT_ROLES = [SUPERADMIN, ADMIN, MANAGER]
class DeviceStatus:
ONLINE = "online"
OFFLINE = "offline"
ERROR = "error"
MAINTENANCE = "maintenance"
class AlertSeverity:
CRITICAL = "critical"
WARNING = "warning"
INFO = "info"
class TokenType:
ACCESS = "access"
REFRESH = "refresh"

36
app/core/dependencies.py Normal file
View File

@@ -0,0 +1,36 @@
from __future__ import annotations
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.constants import TokenType
from app.core.exceptions import ForbiddenException, UnauthorizedException
from app.core.security import decode_token
bearer_scheme = HTTPBearer()
async def get_current_user_payload(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> dict:
payload = decode_token(credentials.credentials)
if payload is None:
raise UnauthorizedException("Invalid or expired token")
if payload.get("type") != TokenType.ACCESS:
raise UnauthorizedException("Invalid token type")
return payload
async def get_current_user_id(
payload: dict = Depends(get_current_user_payload),
) -> int:
return int(payload["sub"])
def require_role(*allowed_roles: str):
async def _check(payload: dict = Depends(get_current_user_payload)) -> dict:
if payload.get("role") not in allowed_roles:
raise ForbiddenException("Insufficient permissions")
return payload
return _check

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.core.exceptions import AppException
def register_error_handlers(app: FastAPI) -> None:
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"detail": exc.detail},
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)

32
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,32 @@
from __future__ import annotations
class AppException(Exception):
def __init__(self, status_code: int, detail: str):
self.status_code = status_code
self.detail = detail
class NotFoundException(AppException):
def __init__(self, detail: str = "Resource not found"):
super().__init__(status_code=404, detail=detail)
class UnauthorizedException(AppException):
def __init__(self, detail: str = "Not authenticated"):
super().__init__(status_code=401, detail=detail)
class ForbiddenException(AppException):
def __init__(self, detail: str = "Permission denied"):
super().__init__(status_code=403, detail=detail)
class ConflictException(AppException):
def __init__(self, detail: str = "Resource already exists"):
super().__init__(status_code=409, detail=detail)
class ValidationException(AppException):
def __init__(self, detail: str = "Validation error"):
super().__init__(status_code=422, detail=detail)

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import logging
import sys
import structlog
from app.core.config import settings
def setup_logging() -> None:
log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer()
if settings.DEBUG
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=log_level,
)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
return structlog.get_logger(name)

16
app/core/permissions.py Normal file
View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from app.core.constants import Role
def is_admin(role: str) -> bool:
return role in Role.ADMIN_ROLES
def is_management(role: str) -> bool:
return role in Role.MANAGEMENT_ROLES
def can_manage_user(actor_role: str, target_role: str) -> bool:
hierarchy = {Role.SUPERADMIN: 4, Role.ADMIN: 3, Role.MANAGER: 2, Role.USER: 1, Role.DEVICE: 0}
return hierarchy.get(actor_role, 0) > hierarchy.get(target_role, 0)

47
app/core/security.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
from app.core.constants import TokenType
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: int | str, role: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {
"sub": str(subject),
"role": role,
"type": TokenType.ACCESS,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(subject: int | str) -> str:
expire = datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(subject),
"type": TokenType.REFRESH,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> dict | None:
try:
return jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except JWTError:
return None