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/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()