Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,14 @@ jobs:
- name: Test
run: pytest --cov=app --cov-report=term-missing -v
env:
TEST_DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/test_db
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
REDIS_HOST: localhost
REDIS_PORT: 6379
TEST_REDIS_HOST: localhost
TEST_REDIS_PORT: 6379
ADMIN_KEY: ci-admin-key
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# RoboSense API

**배포 주소**: http://3.39.87.132

## 부제
```
High-Performance Sensor Data Processing API for Robotics
Expand Down Expand Up @@ -96,9 +98,12 @@ RobosenseAPI/
│ ├── main.py # FastAPI 앱 (랜딩페이지 서빙 포함)
│ ├── database.py # DB 연결 및 세션 관리
│ ├── redis_client.py # Redis 클라이언트
│ ├── context.py # 컨텍스트 관리
│ ├── middleware.py # 미들웨어
│ ├── logging_config.py # 로깅 설정
│ ├── auth.py # API Key 생성 및 검증
│ ├── context.py # ContextVar 기반 request_id 관리
│ ├── exceptions.py # 커스텀 예외 클래스
│ ├── metrics.py # 요청 지표 수집
│ ├── middleware.py # Request ID 주입, 응답시간 측정
│ ├── logging_config.py # request_id 로그 필터
│ ├── models/
│ │ ├── __init__.py
│ │ ├── db_models.py # SQLAlchemy ORM 모델
Expand All @@ -107,9 +112,11 @@ RobosenseAPI/
│ │ └── enum.py # Enum 타입 정의
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── sensor_routes.py # 센서 데이터 API
│ │ ├── sensor_routes.py # 센서 데이터 API + 배치 워커
│ │ ├── robot_routes.py # 로봇 관리 API
│ │ └── stats_routes.py # 통계 API
│ │ ├── stats_routes.py # 통계 API
│ │ ├── admin_routes.py # API Key 발급/폐기 (관리자 전용)
│ │ └── demo_routes.py # 랜딩페이지 공개 조회 (인증 불필요)
│ └── utils/
│ └── retry.py # 재시도 유틸리티
├── cpp_modules/ # C++ 연산 모듈
Expand Down
10 changes: 8 additions & 2 deletions app/routes/admin_routes.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional

from app.database import get_db
from app.models.db_models import ApiKey
Expand All @@ -9,14 +11,18 @@
router = APIRouter(prefix="/admin", tags=["관리"])


class ApiKeyCreate(BaseModel):
robot_id: Optional[int] = None


@router.post("/api-keys", status_code=status.HTTP_201_CREATED)
async def issue_api_key(
body: dict = None,
body: ApiKeyCreate = ApiKeyCreate(),
admin_key: str = Depends(verify_admin_key),
db: AsyncSession = Depends(get_db),
):
"""API Key 발급. 평문 키는 이 응답에서만 확인 가능."""
robot_id = body.get("robot_id") if body else None
robot_id = body.robot_id
plain_key, salt, key_hash = generate_api_key()

api_key = ApiKey(key_hash=key_hash, salt=salt, robot_id=robot_id)
Expand Down
17 changes: 10 additions & 7 deletions app/routes/sensor_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from app.auth import verify_api_key
from app.exceptions import SensorNotFoundError
import logging
import time
import asyncio
import json
import math
Expand All @@ -19,14 +18,12 @@
router = APIRouter()
logger = logging.getLogger(__name__)

last_invalidation = {}
sensor_queue = asyncio.Queue()


@router.post('/api/sensors', status_code=status.HTTP_201_CREATED)
async def collect_sensor_data(data: SensorDataCreate, _key=Depends(verify_api_key)):
redis = get_redis()
now = time.time()

sensors_to_add = [
SensorData(
Expand All @@ -52,13 +49,13 @@ async def collect_sensor_data(data: SensorDataCreate, _key=Depends(verify_api_ke
await redis.ltrim(key, 0, 4)
await redis.expire(key, 10)

if now - last_invalidation.get(data.robot_id, 0) > 10:
# 무효화 스로틀은 Redis로 공유. 프로세스 로컬 dict는 replica마다 따로 놀아
# "10초에 1번"이 프로세스 수만큼 중복 무효화됨. SETNX로 윈도우를 선점한 1개만 삭제.
if await redis.set(f"invalidate:robot:{data.robot_id}:detail", "1", nx=True, ex=10):
await redis.delete(f"robot:{data.robot_id}:detail")
last_invalidation[data.robot_id] = now

if now - last_invalidation.get('stats', 0) > 60:
if await redis.set("invalidate:stats", "1", nx=True, ex=60):
await redis.delete("stats:recent")
last_invalidation['stats'] = now

return {
"status": "success",
Expand All @@ -79,6 +76,12 @@ async def check_filter_data(
_key=Depends(verify_api_key)):
query = select(SensorData).order_by(SensorData.id.desc())

# tz 없는 입력은 UTC로 간주 — TIMESTAMPTZ 컬럼과 비교 시 타임존 어긋남 방지
if start_time and start_time.tzinfo is None:
start_time = start_time.replace(tzinfo=timezone.utc)
if end_time and end_time.tzinfo is None:
end_time = end_time.replace(tzinfo=timezone.utc)

if robot_id:
query = query.where(SensorData.robot_id == robot_id)
if sensor_type:
Expand Down
6 changes: 3 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -292,13 +292,13 @@ <h5 style="margin-top:1.25rem;">Registered Robots</h5>
function checkServer(){var d=document.getElementById('statusDot'),t=document.getElementById('statusText');fetch(B+'/health_check').then(function(r){if(r.ok){d.className='status-dot online';t.textContent='Server Online';}else{d.className='status-dot offline';t.textContent='Unhealthy';}}).catch(function(){d.className='status-dot offline';t.textContent='Server Offline';});}
checkServer();
function toggle(h){var b=h.nextElementSibling,t=h.querySelector('.endpoint-toggle');b.classList.toggle('open');t.style.transform=b.classList.contains('open')?'rotate(180deg)':'';}
function showResp(id,s,d,m){var b=document.getElementById(id),ok=s>=200&&s<300;b.className='response-box visible';b.innerHTML='<div class="response-header '+(ok?'success':'error')+'"><span class="status-code">'+s+'</span><span>'+(ok?'Success':'Error')+'</span><span class="response-time">'+m+'ms</span></div><div class="response-body-output">'+(typeof d==='string'?d:JSON.stringify(d,null,2))+'</div>';}
function showResp(id,s,d,m){var b=document.getElementById(id),ok=s>=200&&s<300;b.className='response-box visible';b.innerHTML='<div class="response-header '+(ok?'success':'error')+'"><span class="status-code"></span><span>'+(ok?'Success':'Error')+'</span><span class="response-time"></span></div><div class="response-body-output"></div>';b.querySelector('.status-code').textContent=s;b.querySelector('.response-time').textContent=m+'ms';b.querySelector('.response-body-output').textContent=(typeof d==='string'?d:JSON.stringify(d,null,2));}
function showLoad(id){var b=document.getElementById(id);b.className='response-box visible';b.innerHTML='<div class="response-header loading"><span>Sending...</span></div>';}
function showErr(id,m){var b=document.getElementById(id);b.className='response-box visible';b.innerHTML='<div class="response-header error"><span>Error</span></div><div class="response-body-output">'+m+'</div>';}
function showErr(id,m){var b=document.getElementById(id);b.className='response-box visible';b.innerHTML='<div class="response-header error"><span>Error</span></div><div class="response-body-output"></div>';b.querySelector('.response-body-output').textContent=m;}
function api(method,path,body){var s=performance.now(),o={method:method,headers:{'Content-Type':'application/json'}};if(body)o.body=JSON.stringify(body);return fetch(B+path,o).then(function(r){var m=Math.round(performance.now()-s);return r.json().then(function(d){return{status:r.status,data:d,ms:m};}).catch(function(){return r.text().then(function(t){return{status:r.status,data:t,ms:m};});});});}
function unlock(){['lock-post-sensor','lock-get-sensor','lock-filter-sensor','lock-stats'].forEach(function(id){document.getElementById(id).classList.add('unlocked');});}
function lock(){['lock-post-sensor','lock-get-sensor','lock-filter-sensor','lock-stats'].forEach(function(id){document.getElementById(id).classList.remove('unlocked');});}
function updateRL(){var el=document.getElementById('registeredRobots');if(!R.length){el.innerHTML='<span style="font-size:.8rem;color:var(--text-dim)">아직 등록된 로봇이 없습니다.</span>';return;}el.innerHTML=R.map(function(r){return'<div class="robot-chip"><span class="chip-id">#'+r.id+'</span><span class="chip-name">'+r.name+'</span></div>';}).join('');}
function updateRL(){var el=document.getElementById('registeredRobots');if(!R.length){el.innerHTML='<span style="font-size:.8rem;color:var(--text-dim)">아직 등록된 로봇이 없습니다.</span>';return;}el.innerHTML='';R.forEach(function(r){var chip=document.createElement('div');chip.className='robot-chip';var idSpan=document.createElement('span');idSpan.className='chip-id';idSpan.textContent='#'+r.id;var nameSpan=document.createElement('span');nameSpan.className='chip-name';nameSpan.textContent=r.name;chip.appendChild(idSpan);chip.appendChild(nameSpan);el.appendChild(chip);});}
var MODELS=['Warehouse-Bot-v2','Patrol-Bot-X1','Cargo-Mover-3K','Scout-Mini','HeavyLift-900','SpeedRunner-S','Inspector-Pro','CleanBot-Z','Picker-Alpha','Navigator-V5'];
var autoTimer=null,autoSentN=0,autoErrN=0,autoTotalMs=0;

Expand Down
Loading
Loading