EV 충전 플랫폼 초기 백업
This commit is contained in:
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
219
app/routers/auth.py
Normal file
219
app/routers/auth.py
Normal 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
92
app/routers/chargers.py
Normal 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
131
app/routers/dashboard.py
Normal 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()
|
||||
]
|
||||
258
app/routers/ocpp_callbacks.py
Normal file
258
app/routers/ocpp_callbacks.py
Normal 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
179
app/routers/payments.py
Normal 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
38
app/routers/qr.py
Normal 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
276
app/routers/sessions.py
Normal 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
|
||||
Reference in New Issue
Block a user