Initial commit: import from sinmb79/Gov-chat-bot

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
airkjw
2026-03-26 12:49:43 +09:00
commit a16c972dbb
104 changed files with 8063 additions and 0 deletions
View File
+65
View File
@@ -0,0 +1,65 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
# 보안
SECRET_KEY: str = "change-this-in-production-32chars"
JWT_EXPIRE_HOURS: int = 24
# 데이터베이스
DATABASE_URL: str = "postgresql+asyncpg://botuser:botpass@db:5432/smartbot"
REDIS_URL: str = "redis://redis:6379"
# 벡터DB
VECTOR_DB: str = "chromadb"
CHROMA_HOST: str = "chromadb"
CHROMA_PORT: int = 8000
# Provider 기본값 (테넌트별 TenantConfig로 오버라이드 가능)
LLM_PROVIDER: str = "none"
EMBEDDING_PROVIDER: str = "local"
EMBEDDING_MODEL: str = "jhgan/ko-sroberta-multitask"
# 개인정보
CHAT_LOG_RETENTION_DAYS: int = 30
# Idempotency
IDEMPOTENCY_TTL_SECONDS: int = 60
# Admin 초기값
ADMIN_DEFAULT_EMAIL: str = "admin@smartbot.kr"
ADMIN_DEFAULT_PASSWORD: str = "changeme123!"
# CORS
ALLOWED_ORIGINS: list[str] = ["http://localhost"]
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# 설정 우선순위:
# 1 (최고) TenantConfig DB 값 → 해당 테넌트에만 적용
# 2 환경변수 (.env) → 서버 전체 기본값
# 3 (최저) 코드 하드코딩 → 폴백
async def get_tenant_config(tenant_id: str, db) -> dict:
"""TenantConfig에서 테넌트별 설정 로드. 없으면 전역 settings 사용."""
from app.models.tenant import TenantConfig
from sqlalchemy import select
result = await db.execute(
select(TenantConfig).where(TenantConfig.tenant_id == tenant_id)
)
configs = result.scalars().all()
base = settings.model_dump()
if not configs:
return base
overrides = {cfg.key: cfg.value for cfg in configs}
return {**base, **overrides}
+28
View File
@@ -0,0 +1,28 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
# Phase 0B에서 settings로 교체 예정 — 현재는 하드코딩 허용
DATABASE_URL = "sqlite+aiosqlite:///:memory:"
class Base(DeclarativeBase):
pass
engine = create_async_engine(DATABASE_URL, echo=False)
AsyncSessionLocal = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
yield session
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def startup_hook():
"""Phase 0B startup 훅 — DB 초기화 + 추가 작업 예정."""
await init_db()
+52
View File
@@ -0,0 +1,52 @@
"""
FastAPI 공통 의존성.
JWT 인증, 역할 검증.
"""
from typing import Optional
from fastapi import Depends, HTTPException, Header, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.core.security import decode_token
from app.models.admin import AdminUser, AdminRole, SystemAdmin
async def get_current_admin(
authorization: Optional[str] = Header(default=None),
db: AsyncSession = Depends(get_db),
) -> AdminUser:
"""JWT Bearer 토큰에서 AdminUser 추출."""
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
token = authorization.removeprefix("Bearer ").strip()
payload = decode_token(token)
if not payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if payload.get("type") == "system_admin":
# SystemAdmin은 별도 처리 — 여기선 tenant-scoped API 접근 거부
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Use system admin endpoints")
user_id = payload.get("sub")
result = await db.execute(select(AdminUser).where(AdminUser.id == user_id, AdminUser.is_active.is_(True)))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def require_role(*roles: AdminRole):
"""역할 검증 의존성 팩토리."""
async def _check(user: AdminUser = Depends(get_current_admin)) -> AdminUser:
if user.role not in roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions")
return user
return _check
# 편의 의존성
require_editor = require_role(AdminRole.admin, AdminRole.editor)
require_admin = require_role(AdminRole.admin)
+49
View File
@@ -0,0 +1,49 @@
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from sqlalchemy import select
EXEMPT_PATHS = {"/health", "/ready", "/engine/query", "/api/docs", "/openapi.json", "/redoc"}
EXEMPT_PREFIXES = ("/skill/", "/api/admin/") # 채널 API + 관리자 API (JWT로 tenant 검증)
class TenantMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
if path in EXEMPT_PATHS or any(path.startswith(p) for p in EXEMPT_PREFIXES):
request.state.tenant_id = None
return await call_next(request)
tenant_id = await self._resolve_tenant(request)
if not tenant_id:
return JSONResponse({"error": "tenant_required"}, status_code=400)
request.state.tenant_id = tenant_id
return await call_next(request)
async def _resolve_tenant(self, request: Request):
# 현재는 X-Tenant-Slug 헤더만 처리 (Phase 0B에서 JWT 추가)
slug = request.headers.get("X-Tenant-Slug")
if slug:
return slug
return None
def tenanted_query(query, model, tenant_id):
"""
주의: model 파라미터가 두 번째 인자다. (v2.0 오류 수정)
tenant_id가 None 또는 빈 문자열이면 RuntimeError 발생.
사용 예: tenanted_query(select(FAQ), FAQ, request.state.tenant_id)
"""
if not tenant_id:
raise RuntimeError(
f"tenant_id is required for {model.__tablename__} queries. "
"Check TenantMiddleware is applied."
)
return query.where(model.tenant_id == tenant_id)
def system_query(query):
"""SystemAdmin 전용 쿼리. tenant 필터 없음. 일반 서비스에서 호출 금지."""
return query
+44
View File
@@ -0,0 +1,44 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import bcrypt
import jwt
from app.core.config import settings
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain: str, hashed: str) -> bool:
return bcrypt.checkpw(plain.encode(), hashed.encode())
def create_admin_token(user_id: str, tenant_id: str, role: str) -> str:
payload = {
"sub": user_id,
"tenant_id": tenant_id,
"role": role,
"type": "admin_user",
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def create_system_token(sys_admin_id: str) -> str:
payload = {
"sub": sys_admin_id,
"tenant_id": None,
"role": "system",
"type": "system_admin",
"exp": datetime.now(timezone.utc) + timedelta(hours=settings.JWT_EXPIRE_HOURS),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except Exception:
return None