EV 충전 플랫폼 초기 백업

This commit is contained in:
root
2026-04-18 05:59:31 +09:00
commit 4558ac10c0
40 changed files with 6246 additions and 0 deletions

0
app/__init__.py Normal file
View File

70
app/config.py Normal file
View File

@@ -0,0 +1,70 @@
"""설정 관리 — 환경변수 기반"""
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# DB
POSTGRES_DB: str = "ev_charging"
POSTGRES_USER: str = "evuser"
POSTGRES_PASSWORD: str = "evpass1234"
POSTGRES_HOST: str = "postgres"
POSTGRES_PORT: int = 5432
# Redis
REDIS_HOST: str = "redis"
REDIS_PORT: int = 6379
# Steve
STEVE_BASE_URL: str = "https://s1.byunc.com/steve"
STEVE_API_USER: str = "admin"
STEVE_API_PASSWORD: str = "changeme"
# 토스페이먼츠
TOSS_CLIENT_KEY: str = ""
TOSS_SECRET_KEY: str = ""
# 요금 (원/kWh)
ELECTRICITY_RATE: int = 120
SERVICE_MARGIN: int = 50
# JWT
JWT_SECRET: str = "change-me"
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 1440
# 서버
DEBUG: bool = True
@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def database_url_sync(self) -> str:
return (
f"postgresql://{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
)
@property
def redis_url(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/0"
@property
def total_rate(self) -> int:
"""총 충전 단가 (원/kWh)"""
return self.ELECTRICITY_RATE + self.SERVICE_MARGIN
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
@lru_cache
def get_settings() -> Settings:
return Settings()

47
app/database.py Normal file
View File

@@ -0,0 +1,47 @@
"""비동기 DB 엔진 + 세션 팩토리"""
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(
settings.database_url,
echo=settings.DEBUG,
pool_size=10,
max_overflow=20,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
"""FastAPI Depends용 DB 세션 제너레이터"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db():
"""테이블 생성 (개발용 — 프로덕션에서는 Alembic 사용)"""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

142
app/main.py Normal file
View File

@@ -0,0 +1,142 @@
"""EV 충전 플랫폼 FastAPI 백엔드
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
충전 흐름:
QR 스캔 → 세션 생성 → 결제 → 충전 시작 → 충전 중 → 종료 → 정산
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from app.config import get_settings
from app.database import init_db, AsyncSessionLocal
from app.routers import chargers, sessions, payments, ocpp_callbacks, dashboard, qr, auth
settings = get_settings()
# 로깅
logging.basicConfig(
level=logging.DEBUG if settings.DEBUG else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""앱 시작/종료 시 실행"""
logger.info("=" * 50)
logger.info("EV 충전 백엔드 시작")
logger.info(f" Steve: {settings.STEVE_BASE_URL}")
logger.info(f" 요금: {settings.total_rate}원/kWh")
logger.info(f" (전기 {settings.ELECTRICITY_RATE} + 서비스 {settings.SERVICE_MARGIN})")
logger.info("=" * 50)
# DB 테이블 생성 (개발용)
await init_db()
logger.info("DB 테이블 초기화 완료")
# 초기 관리자 계정 생성
await _ensure_admin()
yield
logger.info("EV 충전 백엔드 종료")
# ── FastAPI 앱 ──
app = FastAPI(
title="EV 충전 플랫폼 API",
description="CPO 없는 전기차 충전 플랫폼 — OCPP 1.6J + 토스페이먼츠",
version="0.1.0",
lifespan=lifespan,
docs_url="/docs",
redoc_url="/redoc",
)
# CORS (모바일 웹 결제 페이지 허용)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://s1.byunc.com",
"http://localhost:3000",
"http://localhost:5173",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── 라우터 등록 ──
app.include_router(auth.router, prefix="/api/v1")
app.include_router(chargers.router, prefix="/api/v1")
app.include_router(sessions.router, prefix="/api/v1")
app.include_router(payments.router, prefix="/api/v1")
app.include_router(ocpp_callbacks.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(qr.router, prefix="/api/v1")
# ── 초기 관리자 생성 ──
async def _ensure_admin():
"""admin 계정이 없으면 자동 생성"""
from sqlalchemy import select
from app.models import User, UserRole
from app.services.auth import hash_password
async with AsyncSessionLocal() as db:
result = await db.execute(
select(User).where(User.role == UserRole.ADMIN)
)
if result.scalar_one_or_none():
return
admin = User(
username="admin",
hashed_password=hash_password("admin1234"),
display_name="관리자",
role=UserRole.ADMIN,
)
db.add(admin)
await db.commit()
logger.info("초기 관리자 계정 생성: admin / admin1234")
# ── 헬스체크 ──
@app.get("/health")
async def health():
return {
"status": "ok",
"service": "ev-charging-api",
"rate": f"{settings.total_rate}원/kWh",
}
@app.get("/")
async def root():
return {
"message": "EV 충전 플랫폼 API",
"docs": "/docs",
"health": "/health",
}
@app.get("/dashboard")
async def dashboard_page():
"""관리자 대시보드 HTML 서빙"""
return FileResponse("/code/dashboard.html", media_type="text/html")
@app.get("/simulator")
async def simulator_page():
"""충전 시뮬레이터 GUI 서빙"""
return FileResponse("/code/simulator.html", media_type="text/html")

180
app/models/__init__.py Normal file
View File

@@ -0,0 +1,180 @@
"""DB 모델 정의"""
from datetime import datetime
from enum import Enum as PyEnum
from sqlalchemy import (
Column, Integer, BigInteger, String, Float, DateTime,
Enum, ForeignKey, Text, Boolean, Index,
)
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
# ── 상태 Enum ──
class ChargerStatus(str, PyEnum):
AVAILABLE = "Available"
CHARGING = "Charging"
FAULTED = "Faulted"
UNAVAILABLE = "Unavailable"
RESERVED = "Reserved"
class SessionStatus(str, PyEnum):
PENDING = "pending" # 결제 대기
AUTHORIZED = "authorized" # 결제 승인, 충전 시작 대기
CHARGING = "charging" # 충전 중
COMPLETED = "completed" # 정상 종료
FAILED = "failed" # 실패
CANCELLED = "cancelled" # 취소
class PaymentStatus(str, PyEnum):
PENDING = "pending"
CONFIRMED = "confirmed"
FAILED = "failed"
REFUNDED = "refunded"
PARTIAL_REFUND = "partial_refund"
# ── 충전기 ──
class Charger(Base):
__tablename__ = "chargers"
id = Column(Integer, primary_key=True, autoincrement=True)
charge_box_id = Column(String(64), unique=True, nullable=False, index=True,
comment="OCPP chargeBoxId (Steve 등록명)")
name = Column(String(128), comment="표시 이름 (예: A동 주차장 1번)")
location = Column(String(256), comment="설치 위치")
connector_count = Column(Integer, default=1)
power_kw = Column(Float, default=7.0, comment="충전 용량 kW")
status = Column(Enum(ChargerStatus), default=ChargerStatus.UNAVAILABLE)
last_heartbeat = Column(DateTime(timezone=True))
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
sessions = relationship("ChargingSession", back_populates="charger")
def __repr__(self):
return f"<Charger {self.charge_box_id} [{self.status}]>"
# ── 충전 세션 ──
class ChargingSession(Base):
__tablename__ = "charging_sessions"
__table_args__ = (
Index("ix_session_charger_status", "charger_id", "status"),
Index("ix_session_created", "created_at"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
session_uid = Column(String(64), unique=True, nullable=False, index=True,
comment="고유 세션 ID (UUID)")
charger_id = Column(Integer, ForeignKey("chargers.id"), nullable=False)
connector_id = Column(Integer, default=1)
# OCPP 트랜잭션 정보
ocpp_transaction_id = Column(Integer, comment="Steve에서 발급한 transactionId")
id_tag = Column(String(64), comment="OCPP 인증 태그")
# 전력량 (Wh 단위)
meter_start = Column(BigInteger, comment="시작 전력량 Wh")
meter_stop = Column(BigInteger, comment="종료 전력량 Wh")
charged_wh = Column(BigInteger, default=0, comment="충전된 전력량 Wh")
last_meter_value = Column(BigInteger, comment="마지막 MeterValues Wh")
# 요금
electricity_cost = Column(Integer, default=0, comment="전기 원가 (원)")
service_fee = Column(Integer, default=0, comment="서비스 수수료 (원)")
total_bill = Column(Integer, default=0, comment="총 요금 (원)")
# 시간
status = Column(Enum(SessionStatus), default=SessionStatus.PENDING)
started_at = Column(DateTime(timezone=True))
stopped_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
charger = relationship("Charger", back_populates="sessions")
payment = relationship("Payment", back_populates="session", uselist=False)
def __repr__(self):
return f"<Session {self.session_uid} [{self.status}]>"
# ── 결제 ──
class Payment(Base):
__tablename__ = "payments"
__table_args__ = (
Index("ix_payment_order", "order_id"),
)
id = Column(Integer, primary_key=True, autoincrement=True)
session_id = Column(Integer, ForeignKey("charging_sessions.id"), nullable=False)
# 토스페이먼츠
order_id = Column(String(128), unique=True, nullable=False,
comment="토스 주문 ID")
payment_key = Column(String(256), comment="토스 paymentKey")
amount = Column(Integer, nullable=False, comment="결제 요청 금액 (원)")
actual_amount = Column(Integer, comment="실제 충전 후 정산 금액")
status = Column(Enum(PaymentStatus), default=PaymentStatus.PENDING)
method = Column(String(32), comment="결제 수단 (카드, 간편결제 등)")
card_company = Column(String(32), comment="카드사 이름")
confirmed_at = Column(DateTime(timezone=True))
refunded_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
session = relationship("ChargingSession", back_populates="payment")
def __repr__(self):
return f"<Payment {self.order_id} [{self.status}]>"
# ── 충전기 MeterValue 로그 ──
class MeterValueLog(Base):
"""실시간 MeterValues 로그 (디버깅/분석용)"""
__tablename__ = "meter_value_logs"
id = Column(BigInteger, primary_key=True, autoincrement=True)
charger_id = Column(Integer, ForeignKey("chargers.id"), nullable=False)
transaction_id = Column(Integer)
connector_id = Column(Integer, default=1)
measurand = Column(String(64), default="Energy.Active.Import.Register")
value = Column(Float, nullable=False, comment="측정값")
unit = Column(String(16), default="Wh")
timestamp = Column(DateTime(timezone=True), server_default=func.now())
# ── 사용자 ──
class UserRole(str, PyEnum):
ADMIN = "admin"
OPERATOR = "operator"
VIEWER = "viewer"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String(64), unique=True, nullable=False, index=True)
hashed_password = Column(String(256), nullable=False)
display_name = Column(String(128), comment="표시 이름")
role = Column(Enum(UserRole), default=UserRole.VIEWER)
is_active = Column(Boolean, default=True)
last_login = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<User {self.username} [{self.role}]>"

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

219
app/routers/auth.py Normal file
View File

@@ -0,0 +1,219 @@
"""인증 + 사용자 관리 API
로그인 → JWT 토큰 발급 → 대시보드 접근
관리자만 사용자 생성/수정/삭제 가능
"""
import logging
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import User, UserRole
from app.services.auth import (
hash_password, verify_password, create_token,
get_current_user, require_admin,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["인증"])
# ── 스키마 ──
class LoginRequest(BaseModel):
username: str
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
user: dict
class UserCreate(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
password: str = Field(..., min_length=4)
display_name: str = ""
role: str = "viewer"
class UserUpdate(BaseModel):
display_name: Optional[str] = None
role: Optional[str] = None
is_active: Optional[bool] = None
password: Optional[str] = None
class UserOut(BaseModel):
id: int
username: str
display_name: Optional[str]
role: str
is_active: bool
last_login: Optional[datetime]
created_at: datetime
class Config:
from_attributes = True
# ── 로그인 ──
@router.post("/login", response_model=LoginResponse)
async def login(
data: LoginRequest,
db: AsyncSession = Depends(get_db),
):
"""로그인 → JWT 토큰 발급"""
result = await db.execute(
select(User).where(User.username == data.username)
)
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(401, "아이디 또는 비밀번호가 올바르지 않습니다")
if not user.is_active:
raise HTTPException(403, "비활성화된 계정입니다")
# 마지막 로그인 시간 갱신
user.last_login = datetime.now(timezone.utc)
token = create_token(user.id, user.username, user.role)
logger.info(f"로그인: {user.username} ({user.role})")
return LoginResponse(
access_token=token,
user={
"id": user.id,
"username": user.username,
"display_name": user.display_name or user.username,
"role": user.role,
},
)
# ── 내 정보 ──
@router.get("/me", response_model=UserOut)
async def get_me(user: User = Depends(get_current_user)):
"""현재 로그인 사용자 정보"""
return user
@router.put("/me/password")
async def change_my_password(
old_password: str,
new_password: str,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""비밀번호 변경"""
if not verify_password(old_password, user.hashed_password):
raise HTTPException(400, "현재 비밀번호가 틀립니다")
user.hashed_password = hash_password(new_password)
return {"message": "비밀번호 변경 완료"}
# ── 사용자 관리 (관리자 전용) ──
@router.get("/users", response_model=List[UserOut])
async def list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 목록 (관리자 전용)"""
result = await db.execute(
select(User).order_by(User.created_at)
)
return result.scalars().all()
@router.post("/users", response_model=UserOut, status_code=201)
async def create_user(
data: UserCreate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 생성 (관리자 전용)"""
# 중복 확인
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(409, f"이미 존재하는 아이디: {data.username}")
# 역할 검증
valid_roles = [r.value for r in UserRole]
if data.role not in valid_roles:
raise HTTPException(400, f"유효하지 않은 역할: {data.role} (가능: {valid_roles})")
user = User(
username=data.username,
hashed_password=hash_password(data.password),
display_name=data.display_name or data.username,
role=data.role,
)
db.add(user)
await db.flush()
await db.refresh(user)
logger.info(f"사용자 생성: {user.username} ({user.role}) by {admin.username}")
return user
@router.put("/users/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
data: UserUpdate,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 수정 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "사용자 없음")
if data.display_name is not None:
user.display_name = data.display_name
if data.role is not None:
user.role = data.role
if data.is_active is not None:
user.is_active = data.is_active
if data.password is not None:
user.hashed_password = hash_password(data.password)
logger.info(f"사용자 수정: {user.username} by {admin.username}")
await db.flush()
await db.refresh(user)
return user
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""사용자 삭제 (관리자 전용)"""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(404, "사용자 없음")
if user.id == admin.id:
raise HTTPException(400, "자기 자신은 삭제할 수 없습니다")
await db.delete(user)
logger.info(f"사용자 삭제: {user.username} by {admin.username}")
return {"message": f"{user.username} 삭제 완료"}

92
app/routers/chargers.py Normal file
View File

@@ -0,0 +1,92 @@
"""충전기 관리 API"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger
from app.schemas import ChargerCreate, ChargerOut
from app.services.steve_client import steve_client
router = APIRouter(prefix="/chargers", tags=["충전기"])
@router.get("/", response_model=List[ChargerOut])
async def list_chargers(
active_only: bool = True,
db: AsyncSession = Depends(get_db),
):
"""등록된 충전기 목록"""
stmt = select(Charger)
if active_only:
stmt = stmt.where(Charger.is_active == True)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("/", response_model=ChargerOut, status_code=201)
async def register_charger(
data: ChargerCreate,
db: AsyncSession = Depends(get_db),
):
"""새 충전기 등록
FastAPI DB에 등록. Steve에도 이미 chargeBoxId가
등록되어 있어야 OCPP 통신 가능.
"""
# 중복 확인
existing = await db.execute(
select(Charger).where(Charger.charge_box_id == data.charge_box_id)
)
if existing.scalar_one_or_none():
raise HTTPException(409, f"이미 등록된 충전기: {data.charge_box_id}")
charger = Charger(**data.model_dump())
db.add(charger)
await db.flush()
await db.refresh(charger)
return charger
@router.get("/{charge_box_id}", response_model=ChargerOut)
async def get_charger(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""충전기 상세 정보"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, "충전기를 찾을 수 없습니다")
return charger
@router.get("/{charge_box_id}/steve-status")
async def get_steve_status(charge_box_id: str):
"""Steve 서버에서 충전기 실시간 상태 조회"""
data = await steve_client.get_charge_point(charge_box_id)
if not data:
raise HTTPException(502, "Steve 서버 응답 없음")
return data
@router.delete("/{charge_box_id}")
async def deactivate_charger(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""충전기 비활성화 (soft delete)"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, "충전기를 찾을 수 없습니다")
charger.is_active = False
return {"message": f"{charge_box_id} 비활성화 완료"}

131
app/routers/dashboard.py Normal file
View File

@@ -0,0 +1,131 @@
"""대시보드 API — 관리자 모니터링"""
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy import select, func, and_, extract
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger, ChargingSession, ChargerStatus, SessionStatus
from app.schemas import DashboardSummary
router = APIRouter(prefix="/dashboard", tags=["대시보드"])
@router.get("/summary", response_model=DashboardSummary)
async def get_summary(db: AsyncSession = Depends(get_db)):
"""대시보드 요약 — 오늘/이번달 충전 현황"""
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
# 충전기 통계
chargers = await db.execute(select(Charger).where(Charger.is_active == True))
charger_list = chargers.scalars().all()
total = len(charger_list)
active = sum(1 for c in charger_list if c.status != ChargerStatus.UNAVAILABLE)
charging = sum(1 for c in charger_list if c.status == ChargerStatus.CHARGING)
# 오늘 세션
today_q = await db.execute(
select(
func.count(ChargingSession.id),
func.coalesce(func.sum(ChargingSession.total_bill), 0),
func.coalesce(func.sum(ChargingSession.charged_wh), 0),
).where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= today_start,
)
)
)
today_row = today_q.one()
# 이번달 세션
month_q = await db.execute(
select(
func.coalesce(func.sum(ChargingSession.total_bill), 0),
func.coalesce(func.sum(ChargingSession.charged_wh), 0),
).where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= month_start,
)
)
)
month_row = month_q.one()
return DashboardSummary(
total_chargers=total,
active_chargers=active,
charging_now=charging,
today_sessions=today_row[0],
today_revenue=today_row[1],
today_kwh=round(today_row[2] / 1000, 2),
month_revenue=month_row[0],
month_kwh=round(month_row[1] / 1000, 2),
)
@router.get("/recent-sessions")
async def recent_sessions(
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
"""최근 충전 세션 목록"""
result = await db.execute(
select(ChargingSession)
.order_by(ChargingSession.created_at.desc())
.limit(limit)
)
sessions = result.scalars().all()
return [
{
"session_uid": s.session_uid,
"charger_id": s.charger_id,
"status": s.status,
"charged_kwh": round(s.charged_wh / 1000, 2) if s.charged_wh else 0,
"total_bill": s.total_bill,
"started_at": s.started_at,
"stopped_at": s.stopped_at,
}
for s in sessions
]
@router.get("/daily-stats")
async def daily_stats(
days: int = 30,
db: AsyncSession = Depends(get_db),
):
"""일별 충전 통계 (최근 N일)"""
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(
func.date(ChargingSession.created_at).label("date"),
func.count(ChargingSession.id).label("sessions"),
func.coalesce(func.sum(ChargingSession.total_bill), 0).label("revenue"),
func.coalesce(func.sum(ChargingSession.charged_wh), 0).label("wh"),
)
.where(
and_(
ChargingSession.status == SessionStatus.COMPLETED,
ChargingSession.created_at >= cutoff,
)
)
.group_by(func.date(ChargingSession.created_at))
.order_by(func.date(ChargingSession.created_at))
)
return [
{
"date": str(row.date),
"sessions": row.sessions,
"revenue": row.revenue,
"kwh": round(row.wh / 1000, 2),
}
for row in result.all()
]

View File

@@ -0,0 +1,258 @@
"""OCPP 이벤트 콜백 API
Steve OCPP 서버에서 충전기 이벤트 수신.
Steve 설정에서 webhook URL을 이 엔드포인트로 지정하거나,
주기적으로 Steve API를 폴링하여 이벤트 동기화.
※ Steve 버전에 따라 webhook 지원 여부가 다름.
지원하지 않는 경우 /ocpp/sync 엔드포인트로 수동 동기화.
"""
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import (
Charger, ChargingSession, MeterValueLog,
ChargerStatus, SessionStatus,
)
from app.schemas import (
OcppStatusNotification,
OcppStartTransaction,
OcppStopTransaction,
OcppMeterValues,
)
from app.services.billing import calculate_bill
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ocpp", tags=["OCPP 콜백"])
# ── StatusNotification ──
@router.post("/status")
async def handle_status_notification(
data: OcppStatusNotification,
db: AsyncSession = Depends(get_db),
):
"""충전기 상태 변경 수신
Available / Charging / Faulted / Unavailable
"""
charger = await _get_charger(db, data.charge_box_id)
if not charger:
logger.warning(f"미등록 충전기 상태 수신: {data.charge_box_id}")
return {"status": "ignored", "reason": "unregistered"}
# 상태 매핑
status_map = {
"Available": ChargerStatus.AVAILABLE,
"Charging": ChargerStatus.CHARGING,
"Faulted": ChargerStatus.FAULTED,
"Unavailable": ChargerStatus.UNAVAILABLE,
"Reserved": ChargerStatus.RESERVED,
}
new_status = status_map.get(data.status, ChargerStatus.UNAVAILABLE)
charger.status = new_status
charger.last_heartbeat = data.timestamp or datetime.now(timezone.utc)
logger.info(f"충전기 상태: {data.charge_box_id}{new_status}")
return {"status": "ok", "charger_status": new_status}
# ── StartTransaction ──
@router.post("/start-transaction")
async def handle_start_transaction(
data: OcppStartTransaction,
db: AsyncSession = Depends(get_db),
):
"""충전 시작 이벤트 수신
충전기가 StartTransaction 보내면 transactionId 기록.
meterStart 값으로 시작 전력량 저장.
"""
# id_tag로 세션 찾기
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.id_tag == data.id_tag,
ChargingSession.status.in_([
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
).order_by(ChargingSession.created_at.desc())
)
session = result.scalar_one_or_none()
if not session:
logger.warning(
f"매칭 세션 없음: tag={data.id_tag} "
f"charger={data.charge_box_id}"
)
return {"status": "no_session"}
session.ocpp_transaction_id = data.transaction_id
session.meter_start = data.meter_start
session.status = SessionStatus.CHARGING
session.started_at = data.timestamp or datetime.now(timezone.utc)
logger.info(
f"충전 시작: session={session.session_uid} "
f"txn={data.transaction_id} meter={data.meter_start}Wh"
)
return {
"status": "ok",
"session_uid": session.session_uid,
"transaction_id": data.transaction_id,
}
# ── StopTransaction ──
@router.post("/stop-transaction")
async def handle_stop_transaction(
data: OcppStopTransaction,
db: AsyncSession = Depends(get_db),
):
"""충전 종료 이벤트 수신 + 자동 정산
충전기가 StopTransaction 보내면 meterStop 기록 후 요금 정산.
"""
# transactionId로 충전 중인 세션 찾기
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.ocpp_transaction_id == data.transaction_id,
ChargingSession.status == SessionStatus.CHARGING,
)
).order_by(ChargingSession.created_at.desc())
)
session = result.scalars().first()
if not session:
logger.warning(f"매칭 세션 없음: txn={data.transaction_id}")
return {"status": "no_session"}
# 전력량 기록 및 정산
session.meter_stop = data.meter_stop
session.charged_wh = max(0, data.meter_stop - (session.meter_start or 0))
session.stopped_at = data.timestamp or datetime.now(timezone.utc)
session.status = SessionStatus.COMPLETED
# 요금 계산
bill = calculate_bill(session.meter_start or 0, data.meter_stop)
session.electricity_cost = bill["electricity_cost"]
session.service_fee = bill["service_fee"]
session.total_bill = bill["total_bill"]
logger.info(
f"충전 완료: session={session.session_uid} "
f"charged={bill['charged_kwh']}kWh "
f"bill={bill['total_bill']}"
)
return {
"status": "ok",
"session_uid": session.session_uid,
"billing": bill,
}
# ── MeterValues ──
@router.post("/meter-values")
async def handle_meter_values(
data: OcppMeterValues,
db: AsyncSession = Depends(get_db),
):
"""실시간 전력량 수신
충전 중 주기적으로 수신되는 MeterValues를 기록.
실시간 충전량/요금 계산에 사용.
"""
charger = await _get_charger(db, data.charge_box_id)
# 로그 저장
log = MeterValueLog(
charger_id=charger.id if charger else 0,
transaction_id=data.transaction_id,
connector_id=data.connector_id,
measurand=data.measurand,
value=data.value,
unit="Wh",
timestamp=data.timestamp or datetime.now(timezone.utc),
)
db.add(log)
# 진행 중 세션에 마지막 meter 값 업데이트
if data.transaction_id:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.ocpp_transaction_id == data.transaction_id,
ChargingSession.status == SessionStatus.CHARGING,
)
)
)
session = result.scalars().first()
if session:
session.last_meter_value = int(data.value)
session.charged_wh = max(
0, int(data.value) - (session.meter_start or 0)
)
return {"status": "ok", "value": data.value}
# ── Steve 데이터 동기화 (폴링 방식) ──
@router.post("/sync")
async def sync_from_steve(
db: AsyncSession = Depends(get_db),
):
"""Steve 서버에서 최신 트랜잭션 데이터 동기화
Steve에 webhook이 없는 경우 이 엔드포인트를
cron 또는 APScheduler로 주기적 호출.
"""
from app.services.steve_client import steve_client
transactions = await steve_client.get_transactions()
if not transactions:
return {"status": "no_data"}
synced = 0
for txn in transactions:
txn_id = txn.get("transactionId") or txn.get("id")
if not txn_id:
continue
# 이미 기록된 트랜잭션인지 확인
result = await db.execute(
select(ChargingSession).where(
ChargingSession.ocpp_transaction_id == txn_id
)
)
session = result.scalar_one_or_none()
if session and session.status == SessionStatus.COMPLETED:
continue
# TODO: 트랜잭션 데이터를 세션에 반영
synced += 1
return {"status": "ok", "synced": synced}
# ── 유틸 ──
async def _get_charger(db: AsyncSession, charge_box_id: str):
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
return result.scalar_one_or_none()

179
app/routers/payments.py Normal file
View File

@@ -0,0 +1,179 @@
"""결제 API — 토스페이먼츠 연동
결제 흐름:
1. POST /payments/prepare → orderId 발급 + Redis에 임시 저장
2. 프론트에서 토스 결제 UI 호출 → 사용자 카드 입력
3. POST /payments/confirm → 백엔드에서 토스 confirm API 호출
4. 승인 성공 → 세션 상태 AUTHORIZED → 충전 시작 가능
"""
import uuid
import json
import logging
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import ChargingSession, Payment, SessionStatus, PaymentStatus
from app.schemas import PaymentRequest, PaymentConfirm, PaymentOut
from app.services.payment import toss_service
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
router = APIRouter(prefix="/payments", tags=["결제"])
@router.post("/prepare")
async def prepare_payment(
data: PaymentRequest,
db: AsyncSession = Depends(get_db),
):
"""결제 준비 — orderId 발급
프론트에서 토스 결제 UI를 호출하기 전에 호출.
orderId와 클라이언트 키를 반환.
"""
# 세션 확인
result = await db.execute(
select(ChargingSession).where(
ChargingSession.session_uid == data.session_uid
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(404, "세션 없음")
if session.status != SessionStatus.PENDING:
raise HTTPException(400, f"결제 불가 상태: {session.status}")
# 주문 ID 생성
order_id = f"EV-{uuid.uuid4().hex[:16].upper()}"
# Payment 레코드 생성
payment = Payment(
session_id=session.id,
order_id=order_id,
amount=data.amount,
status=PaymentStatus.PENDING,
)
db.add(payment)
await db.flush()
return {
"order_id": order_id,
"amount": data.amount,
"session_uid": data.session_uid,
"client_key": settings.TOSS_CLIENT_KEY,
"customer_name": "EV 충전",
"order_name": f"전기차 충전 ({data.amount:,}원)",
}
@router.post("/confirm", response_model=PaymentOut)
async def confirm_payment(
data: PaymentConfirm,
db: AsyncSession = Depends(get_db),
):
"""결제 최종 승인
프론트에서 토스 결제 완료 후 paymentKey, orderId, amount 전달.
백엔드에서 토스 confirm API 호출 → 실제 청구 확정.
성공 시 세션 상태를 AUTHORIZED로 변경.
"""
# Payment 조회
result = await db.execute(
select(Payment).where(Payment.order_id == data.order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, f"주문 없음: {data.order_id}")
# 금액 검증
if payment.amount != data.amount:
raise HTTPException(400, "결제 금액 불일치")
# 토스페이먼츠 최종 승인
toss_result = await toss_service.confirm_payment(
payment_key=data.payment_key,
order_id=data.order_id,
amount=data.amount,
)
if not toss_result["success"]:
payment.status = PaymentStatus.FAILED
raise HTTPException(400, toss_result.get("message", "결제 승인 실패"))
# 결제 성공 처리
toss_data = toss_result["data"]
payment.payment_key = data.payment_key
payment.status = PaymentStatus.CONFIRMED
payment.method = toss_data.get("method", "")
payment.card_company = (
toss_data.get("card", {}).get("company", "") if toss_data.get("card") else ""
)
payment.confirmed_at = datetime.now(timezone.utc)
# 세션 상태 → AUTHORIZED (충전 시작 가능)
session = await db.get(ChargingSession, payment.session_id)
session.status = SessionStatus.AUTHORIZED
logger.info(
f"결제 승인 완료: {data.order_id} / {data.amount}원 → "
f"세션 {session.session_uid} AUTHORIZED"
)
await db.flush()
await db.refresh(payment)
return payment
@router.post("/{order_id}/cancel")
async def cancel_payment(
order_id: str,
cancel_reason: str = "사용자 취소",
db: AsyncSession = Depends(get_db),
):
"""결제 취소"""
result = await db.execute(
select(Payment).where(Payment.order_id == order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, "주문 없음")
if not payment.payment_key:
raise HTTPException(400, "승인된 결제가 아닙니다")
cancel_result = await toss_service.cancel_payment(
payment_key=payment.payment_key,
cancel_reason=cancel_reason,
)
if not cancel_result["success"]:
raise HTTPException(400, cancel_result.get("message", "취소 실패"))
payment.status = PaymentStatus.REFUNDED
payment.refunded_at = datetime.now(timezone.utc)
# 세션 취소
session = await db.get(ChargingSession, payment.session_id)
session.status = SessionStatus.CANCELLED
return {"message": "결제 취소 완료", "order_id": order_id}
@router.get("/{order_id}", response_model=PaymentOut)
async def get_payment(
order_id: str,
db: AsyncSession = Depends(get_db),
):
"""결제 상세 조회"""
result = await db.execute(
select(Payment).where(Payment.order_id == order_id)
)
payment = result.scalar_one_or_none()
if not payment:
raise HTTPException(404, "주문 없음")
return payment

38
app/routers/qr.py Normal file
View File

@@ -0,0 +1,38 @@
"""QR 코드 생성 API"""
import io
import qrcode
from fastapi import APIRouter, Response
from app.config import get_settings
settings = get_settings()
router = APIRouter(prefix="/qr", tags=["QR 코드"])
@router.get("/{charge_box_id}")
async def generate_qr(charge_box_id: str):
"""충전기용 QR 코드 이미지 생성
QR 내용: 충전 시작 페이지 URL (charge_box_id 포함)
사용자가 스캔하면 모바일 웹 결제 페이지로 이동.
"""
# 충전 시작 URL (프론트 배포 후 수정)
charge_url = f"https://s1.byunc.com/charge/{charge_box_id}"
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(charge_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
buf.seek(0)
return Response(
content=buf.getvalue(),
media_type="image/png",
headers={
"Content-Disposition": f'inline; filename="qr_{charge_box_id}.png"'
},
)

276
app/routers/sessions.py Normal file
View File

@@ -0,0 +1,276 @@
"""충전 세션 API — 핵심 충전 흐름 관리
흐름:
QR 스캔 → POST /sessions (세션 생성)
→ 결제 완료 → POST /sessions/{uid}/start (RemoteStart)
→ 충전 중 MeterValues 수신
→ POST /sessions/{uid}/stop (RemoteStop)
→ 자동 정산
"""
import uuid
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Charger, ChargingSession, SessionStatus, ChargerStatus
from app.schemas import SessionCreate, SessionOut, SessionBilling
from app.services.steve_client import steve_client
from app.services.billing import calculate_bill
router = APIRouter(prefix="/sessions", tags=["충전 세션"])
# ── 세션 생성 (QR 스캔 시) ──
@router.post("/", response_model=SessionOut, status_code=201)
async def create_session(
data: SessionCreate,
db: AsyncSession = Depends(get_db),
):
"""충전 세션 생성
사용자가 QR 스캔 시 호출. 충전기 상태 확인 후 세션 생성.
이후 결제 → 충전 시작 순서로 진행.
"""
# 충전기 확인
result = await db.execute(
select(Charger).where(Charger.charge_box_id == data.charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
raise HTTPException(404, f"충전기 미등록: {data.charge_box_id}")
if not charger.is_active:
raise HTTPException(400, "비활성화된 충전기입니다")
# 해당 충전기에 진행 중인 세션 확인
active = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.charger_id == charger.id,
ChargingSession.status.in_([
SessionStatus.PENDING,
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
)
)
if active.scalar_one_or_none():
raise HTTPException(409, "이미 사용 중인 충전기입니다")
# 세션 생성
session_uid = f"SES-{uuid.uuid4().hex[:12].upper()}"
id_tag = f"APP-{uuid.uuid4().hex[:8].upper()}"
session = ChargingSession(
session_uid=session_uid,
charger_id=charger.id,
connector_id=data.connector_id,
id_tag=id_tag,
status=SessionStatus.PENDING,
)
db.add(session)
await db.flush()
await db.refresh(session)
return session
# ── 충전 시작 (결제 승인 후) ──
@router.post("/{session_uid}/start")
async def start_charging(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""충전 시작 — 결제 승인 후 호출
Steve 서버에 RemoteStartTransaction 전송.
충전기가 수락하면 StartTransaction 응답이 옴.
"""
session = await _get_session(db, session_uid)
if session.status != SessionStatus.AUTHORIZED:
raise HTTPException(400, f"시작 불가 상태: {session.status}")
charger = await db.get(Charger, session.charger_id)
# Steve에 원격 시작 명령
result = await steve_client.remote_start_transaction(
charge_box_id=charger.charge_box_id,
id_tag=session.id_tag,
connector_id=session.connector_id,
)
if not result:
raise HTTPException(502, "Steve 서버 통신 실패")
session.status = SessionStatus.CHARGING
session.started_at = datetime.now(timezone.utc)
return {
"message": "충전 시작 명령 전송 완료",
"session_uid": session_uid,
"steve_response": result,
}
# ── 충전 종료 ──
@router.post("/{session_uid}/stop")
async def stop_charging(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""충전 종료 — 사용자 요청 또는 자동 종료"""
session = await _get_session(db, session_uid)
if session.status != SessionStatus.CHARGING:
raise HTTPException(400, f"종료 불가 상태: {session.status}")
if not session.ocpp_transaction_id:
raise HTTPException(400, "OCPP 트랜잭션 ID 없음 — 아직 시작되지 않음")
charger = await db.get(Charger, session.charger_id)
# Steve에 원격 종료 명령
result = await steve_client.remote_stop_transaction(
charge_box_id=charger.charge_box_id,
transaction_id=session.ocpp_transaction_id,
)
if not result:
raise HTTPException(502, "Steve 서버 통신 실패")
return {
"message": "충전 종료 명령 전송 완료",
"session_uid": session_uid,
}
# ── 세션 상태 조회 ──
@router.get("/{session_uid}", response_model=SessionOut)
async def get_session(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""세션 상태 조회 (충전 중 폴링용)"""
return await _get_session(db, session_uid)
@router.get("/{session_uid}/billing", response_model=SessionBilling)
async def get_session_billing(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""세션 정산 내역"""
session = await _get_session(db, session_uid)
if session.status not in (SessionStatus.COMPLETED, SessionStatus.CHARGING):
raise HTTPException(400, "정산 정보 없음")
start_wh = session.meter_start or 0
end_wh = session.meter_stop or session.last_meter_value or start_wh
bill = calculate_bill(start_wh, end_wh)
return SessionBilling(
session_uid=session_uid,
charged_kwh=bill["charged_kwh"],
electricity_cost=bill["electricity_cost"],
service_fee=bill["service_fee"],
total_bill=bill["total_bill"],
saved_vs_cpo=bill["saved_vs_cpo"],
)
# ── 세션 목록 ──
@router.get("/", response_model=List[SessionOut])
async def list_sessions(
status: Optional[str] = None,
charge_box_id: Optional[str] = None,
limit: int = Query(50, le=200),
offset: int = 0,
db: AsyncSession = Depends(get_db),
):
"""충전 세션 목록 (관리자 대시보드용)"""
stmt = select(ChargingSession).order_by(
ChargingSession.created_at.desc()
)
if status:
stmt = stmt.where(ChargingSession.status == status)
if charge_box_id:
stmt = stmt.join(Charger).where(
Charger.charge_box_id == charge_box_id
)
stmt = stmt.offset(offset).limit(limit)
result = await db.execute(stmt)
return result.scalars().all()
# ── 테스트용 (프로덕션에서 제거) ──
@router.post("/reset/{charge_box_id}")
async def reset_charger_sessions(
charge_box_id: str,
db: AsyncSession = Depends(get_db),
):
"""[테스트 전용] 충전기의 미완료 세션 전부 취소"""
result = await db.execute(
select(Charger).where(Charger.charge_box_id == charge_box_id)
)
charger = result.scalar_one_or_none()
if not charger:
return {"message": "충전기 없음", "cancelled": 0}
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.charger_id == charger.id,
ChargingSession.status.in_([
SessionStatus.PENDING,
SessionStatus.AUTHORIZED,
SessionStatus.CHARGING,
]),
)
)
)
sessions = result.scalars().all()
for s in sessions:
s.status = SessionStatus.CANCELLED
return {"message": f"{len(sessions)}건 세션 취소", "cancelled": len(sessions)}
@router.post("/{session_uid}/force-authorize")
async def force_authorize(
session_uid: str,
db: AsyncSession = Depends(get_db),
):
"""[테스트 전용] 결제 없이 세션을 AUTHORIZED로 강제 변경"""
session = await _get_session(db, session_uid)
session.status = SessionStatus.AUTHORIZED
return {"message": "세션 AUTHORIZED로 변경", "session_uid": session_uid}
# ── 유틸 ──
async def _get_session(
db: AsyncSession, session_uid: str
) -> ChargingSession:
result = await db.execute(
select(ChargingSession).where(
ChargingSession.session_uid == session_uid
)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(404, f"세션 없음: {session_uid}")
return session

156
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1,156 @@
"""Pydantic 스키마 (요청/응답 직렬화)"""
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
# ━━━━━━━━ 충전기 ━━━━━━━━
class ChargerCreate(BaseModel):
charge_box_id: str = Field(..., example="CHARGER_001")
name: str = Field("", example="A동 주차장 1번")
location: str = Field("", example="수원시 영통구 아파트 지하1층")
connector_count: int = 1
power_kw: float = 7.0
class ChargerOut(BaseModel):
id: int
charge_box_id: str
name: str
location: str
connector_count: int
power_kw: float
status: str
last_heartbeat: Optional[datetime] = None
is_active: bool
class Config:
from_attributes = True
class ChargerStatusUpdate(BaseModel):
status: str
timestamp: Optional[datetime] = None
# ━━━━━━━━ 충전 세션 ━━━━━━━━
class SessionCreate(BaseModel):
"""QR 스캔 → 세션 생성 요청"""
charge_box_id: str = Field(..., example="CHARGER_001")
connector_id: int = 1
class SessionOut(BaseModel):
id: int
session_uid: str
charger_id: int
id_tag: Optional[str] = None
status: str
meter_start: Optional[int] = None
meter_stop: Optional[int] = None
charged_wh: int = 0
total_bill: int = 0
started_at: Optional[datetime] = None
stopped_at: Optional[datetime] = None
created_at: datetime
class Config:
from_attributes = True
class SessionBilling(BaseModel):
"""정산 결과"""
session_uid: str
charged_kwh: float
electricity_cost: int
service_fee: int
total_bill: int
saved_vs_cpo: int
# ━━━━━━━━ 결제 ━━━━━━━━
class PaymentRequest(BaseModel):
"""결제 시작 요청 (모바일 웹에서 호출)"""
session_uid: str
amount: int = Field(..., ge=100, example=10000, description="선결제 금액 (원)")
class PaymentConfirm(BaseModel):
"""토스페이먼츠 결제 승인 요청"""
payment_key: str
order_id: str
amount: int
class PaymentOut(BaseModel):
order_id: str
payment_key: Optional[str] = None
amount: int
actual_amount: Optional[int] = None
status: str
method: Optional[str] = None
confirmed_at: Optional[datetime] = None
class Config:
from_attributes = True
# ━━━━━━━━ Steve OCPP 콜백 ━━━━━━━━
class OcppBootNotification(BaseModel):
charge_box_id: str
charge_point_vendor: Optional[str] = None
charge_point_model: Optional[str] = None
firmware_version: Optional[str] = None
class OcppStatusNotification(BaseModel):
charge_box_id: str
connector_id: int = 1
status: str # Available, Charging, Faulted ...
error_code: str = "NoError"
timestamp: Optional[datetime] = None
class OcppStartTransaction(BaseModel):
charge_box_id: str
connector_id: int = 1
id_tag: str
meter_start: int # Wh
transaction_id: Optional[int] = None
timestamp: Optional[datetime] = None
class OcppStopTransaction(BaseModel):
charge_box_id: str
transaction_id: int
id_tag: Optional[str] = None
meter_stop: int # Wh
reason: str = "Local"
timestamp: Optional[datetime] = None
class OcppMeterValues(BaseModel):
charge_box_id: str
connector_id: int = 1
transaction_id: Optional[int] = None
value: float # Wh
measurand: str = "Energy.Active.Import.Register"
timestamp: Optional[datetime] = None
# ━━━━━━━━ 대시보드 ━━━━━━━━
class DashboardSummary(BaseModel):
total_chargers: int
active_chargers: int
charging_now: int
today_sessions: int
today_revenue: int
today_kwh: float
month_revenue: int
month_kwh: float

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

113
app/services/auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""인증 서비스 — JWT 토큰 + 비밀번호 해싱"""
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_db
from app.models import User, UserRole
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer(auto_error=False)
# ── 비밀번호 ──
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
# ── JWT 토큰 ──
def create_token(user_id: int, username: str, role: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.JWT_EXPIRE_MINUTES
)
payload = {
"sub": str(user_id),
"username": username,
"role": role,
"exp": expire,
}
return jwt.encode(payload, settings.JWT_SECRET, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(
token, settings.JWT_SECRET, algorithms=[settings.JWT_ALGORITHM]
)
return payload
except JWTError:
return None
# ── FastAPI 인증 의존성 ──
async def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> User:
"""현재 로그인된 사용자 반환 — 인증 필수 엔드포인트에 사용"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_token(credentials.credentials)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않은 토큰입니다",
)
user_id = int(payload.get("sub", 0))
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="비활성화된 계정입니다",
)
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""관리자 권한 필수"""
if user.role != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="관리자 권한이 필요합니다",
)
return user
async def optional_auth(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: AsyncSession = Depends(get_db),
) -> Optional[User]:
"""인증 선택 — 토큰 있으면 사용자 반환, 없으면 None"""
if not credentials:
return None
payload = decode_token(credentials.credentials)
if not payload:
return None
user_id = int(payload.get("sub", 0))
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()

51
app/services/billing.py Normal file
View File

@@ -0,0 +1,51 @@
"""전력량 기반 요금 정산 서비스
OCPP MeterValues의 Energy.Active.Import.Register (Wh 단위)를
세션 시작/종료 시점에 기록하여 실제 충전량 기반 요금 계산.
"""
from app.config import get_settings
settings = get_settings()
def calculate_bill(start_wh: int, end_wh: int) -> dict:
"""충전 요금 계산
Args:
start_wh: 충전 시작 시 meter 값 (Wh)
end_wh: 충전 종료 시 meter 값 (Wh)
Returns:
dict: 정산 내역
"""
charged_wh = max(0, end_wh - start_wh)
charged_kwh = charged_wh / 1000
electricity_cost = round(charged_kwh * settings.ELECTRICITY_RATE)
service_fee = round(charged_kwh * settings.SERVICE_MARGIN)
total_bill = round(charged_kwh * settings.total_rate)
# CPO 방식(350원/kWh) 대비 절감액
cpo_rate = 350
saved_vs_cpo = round(charged_kwh * (cpo_rate - settings.total_rate))
return {
"charged_wh": charged_wh,
"charged_kwh": round(charged_kwh, 3),
"electricity_cost": electricity_cost,
"service_fee": service_fee,
"total_bill": total_bill,
"saved_vs_cpo": saved_vs_cpo,
}
def estimate_charge_cost(kwh: float) -> dict:
"""예상 충전 요금 계산 (QR 결제 화면용)"""
return {
"estimated_kwh": kwh,
"rate_per_kwh": settings.total_rate,
"estimated_cost": round(kwh * settings.total_rate),
"cpo_comparison": round(kwh * 350),
"savings": round(kwh * (350 - settings.total_rate)),
}

115
app/services/payment.py Normal file
View File

@@ -0,0 +1,115 @@
"""토스페이먼츠 결제 서비스
결제 흐름:
1. 프론트에서 토스 결제 UI 호출 (클라이언트 키 사용)
2. 사용자 카드 입력 → paymentKey 발급
3. 백엔드에서 confirm API 호출 → 실제 청구 확정 (시크릿 키 사용)
4. 승인 확인 후 OCPP RemoteStartTransaction 전송
"""
import base64
import logging
from typing import Optional
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
TOSS_API_BASE = "https://api.tosspayments.com/v1"
class TossPaymentService:
def __init__(self):
# 시크릿 키를 Base64 인코딩 (Basic Auth)
secret = settings.TOSS_SECRET_KEY
encoded = base64.b64encode(f"{secret}:".encode()).decode()
self.headers = {
"Authorization": f"Basic {encoded}",
"Content-Type": "application/json",
}
async def confirm_payment(
self, payment_key: str, order_id: str, amount: int
) -> dict:
"""결제 최종 승인 (confirm)
프론트에서 결제 완료 후 paymentKey, orderId, amount를 전달받아
토스 서버에 최종 승인 요청.
Returns:
토스 응답 dict (성공 시 status="DONE")
"""
payload = {
"paymentKey": payment_key,
"orderId": order_id,
"amount": amount,
}
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{TOSS_API_BASE}/payments/confirm",
headers=self.headers,
json=payload,
)
data = resp.json()
if resp.status_code == 200:
logger.info(f"결제 승인 성공: {order_id} / {amount}")
return {"success": True, "data": data}
else:
logger.error(f"결제 승인 실패: {data}")
return {
"success": False,
"error_code": data.get("code", "UNKNOWN"),
"message": data.get("message", "결제 승인 실패"),
}
async def get_payment(self, payment_key: str) -> Optional[dict]:
"""결제 상세 조회"""
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(
f"{TOSS_API_BASE}/payments/{payment_key}",
headers=self.headers,
)
if resp.status_code == 200:
return resp.json()
return None
async def cancel_payment(
self,
payment_key: str,
cancel_reason: str = "사용자 취소",
cancel_amount: Optional[int] = None,
) -> dict:
"""결제 취소 / 부분 환불
Args:
payment_key: 토스 paymentKey
cancel_reason: 취소 사유
cancel_amount: 부분 취소 금액 (None이면 전액 취소)
"""
payload = {"cancelReason": cancel_reason}
if cancel_amount is not None:
payload["cancelAmount"] = cancel_amount
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
f"{TOSS_API_BASE}/payments/{payment_key}/cancel",
headers=self.headers,
json=payload,
)
data = resp.json()
if resp.status_code == 200:
logger.info(f"결제 취소 성공: {payment_key}")
return {"success": True, "data": data}
else:
logger.error(f"결제 취소 실패: {data}")
return {"success": False, "message": data.get("message")}
toss_service = TossPaymentService()

94
app/services/scheduler.py Normal file
View File

@@ -0,0 +1,94 @@
"""백그라운드 태스크 — Steve 폴링 + 세션 정리
Steve 서버에 webhook이 없는 경우,
APScheduler로 주기적으로 트랜잭션 데이터 동기화.
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import AsyncSessionLocal
from app.models import Charger, ChargingSession, SessionStatus
from app.services.steve_client import steve_client
logger = logging.getLogger(__name__)
async def poll_steve_heartbeats():
"""Steve에서 충전기 상태 폴링 (60초 간격 권장)"""
try:
charge_points = await steve_client.get_charge_points()
if not charge_points:
return
async with AsyncSessionLocal() as db:
for cp in charge_points:
cp_id = cp.get("chargeBoxId") or cp.get("chargePointId")
if not cp_id:
continue
result = await db.execute(
select(Charger).where(Charger.charge_box_id == cp_id)
)
charger = result.scalar_one_or_none()
if charger:
charger.last_heartbeat = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
logger.error(f"Steve 폴링 실패: {e}")
async def cleanup_stale_sessions():
"""오래된 PENDING 세션 정리 (10분 초과 시 취소)"""
try:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=10)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.status == SessionStatus.PENDING,
ChargingSession.created_at < cutoff,
)
)
)
stale = result.scalars().all()
for session in stale:
session.status = SessionStatus.CANCELLED
logger.info(f"만료 세션 취소: {session.session_uid}")
if stale:
await db.commit()
logger.info(f"만료 세션 {len(stale)}건 정리 완료")
except Exception as e:
logger.error(f"세션 정리 실패: {e}")
async def check_long_charging_sessions():
"""12시간 이상 충전 중인 세션 경고"""
try:
cutoff = datetime.now(timezone.utc) - timedelta(hours=12)
async with AsyncSessionLocal() as db:
result = await db.execute(
select(ChargingSession).where(
and_(
ChargingSession.status == SessionStatus.CHARGING,
ChargingSession.started_at < cutoff,
)
)
)
long_sessions = result.scalars().all()
for session in long_sessions:
logger.warning(
f"장시간 충전: {session.session_uid} "
f"시작: {session.started_at}"
)
except Exception as e:
logger.error(f"장시간 충전 체크 실패: {e}")

View File

@@ -0,0 +1,132 @@
"""Steve OCPP 서버 REST API 클라이언트
Steve 대시보드 API를 통해 충전기 원격 제어 수행.
- RemoteStartTransaction: 결제 승인 후 충전 시작
- RemoteStopTransaction: 충전 원격 종료
"""
import logging
from typing import Optional
import httpx
from app.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
class SteveClient:
"""Steve OCPP 서버와 HTTP 통신"""
def __init__(self):
self.base_url = settings.STEVE_BASE_URL.rstrip("/")
self.auth = (settings.STEVE_API_USER, settings.STEVE_API_PASSWORD)
async def _request(
self, method: str, path: str, **kwargs
) -> Optional[dict]:
url = f"{self.base_url}{path}"
try:
async with httpx.AsyncClient(verify=True, timeout=10.0) as client:
resp = await client.request(
method, url, auth=self.auth, **kwargs
)
resp.raise_for_status()
if resp.headers.get("content-type", "").startswith("application/json"):
return resp.json()
return {"status": "ok", "code": resp.status_code}
except httpx.HTTPStatusError as e:
logger.error(f"Steve API HTTP 에러: {e.response.status_code} {url}")
return None
except Exception as e:
logger.error(f"Steve API 연결 실패: {e}")
return None
# ── 충전기 관리 ──
async def get_charge_points(self) -> Optional[list]:
"""등록된 충전기 목록 조회"""
return await self._request("GET", "/api/v1/chargepoints")
async def get_charge_point(self, charge_box_id: str) -> Optional[dict]:
"""특정 충전기 상세 정보"""
return await self._request("GET", f"/api/v1/chargepoints/{charge_box_id}")
# ── 원격 제어 (핵심) ──
async def remote_start_transaction(
self,
charge_box_id: str,
id_tag: str,
connector_id: int = 1,
) -> Optional[dict]:
"""충전기에 원격 충전 시작 명령 전송
결제 승인 완료 후 호출.
Steve → 충전기 WebSocket으로 RemoteStartTransaction 전송.
"""
payload = {
"chargeBoxId": charge_box_id,
"connectorId": connector_id,
"idTag": id_tag,
}
logger.info(f"RemoteStart 요청: {charge_box_id} tag={id_tag}")
result = await self._request(
"POST",
"/api/v1/operations/RemoteStartTransaction",
json=payload,
)
if result:
logger.info(f"RemoteStart 응답: {result}")
return result
async def remote_stop_transaction(
self,
charge_box_id: str,
transaction_id: int,
) -> Optional[dict]:
"""충전기에 원격 충전 종료 명령 전송"""
payload = {
"chargeBoxId": charge_box_id,
"transactionId": transaction_id,
}
logger.info(f"RemoteStop 요청: {charge_box_id} txn={transaction_id}")
result = await self._request(
"POST",
"/api/v1/operations/RemoteStopTransaction",
json=payload,
)
return result
# ── 트랜잭션 조회 ──
async def get_transactions(
self, charge_box_id: Optional[str] = None
) -> Optional[list]:
"""트랜잭션(충전 기록) 조회"""
params = {}
if charge_box_id:
params["chargeBoxId"] = charge_box_id
return await self._request(
"GET", "/api/v1/transactions", params=params
)
async def get_transaction(self, transaction_id: int) -> Optional[dict]:
"""특정 트랜잭션 상세"""
return await self._request(
"GET", f"/api/v1/transactions/{transaction_id}"
)
# ── ID 태그 관리 ──
async def add_id_tag(self, id_tag: str) -> Optional[dict]:
"""OCPP 인증용 ID 태그 등록"""
payload = {"idTag": id_tag}
return await self._request("POST", "/api/v1/idtags", json=payload)
async def get_id_tags(self) -> Optional[list]:
"""등록된 ID 태그 목록"""
return await self._request("GET", "/api/v1/idtags")
# 싱글턴
steve_client = SteveClient()

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