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