From a5e7cff160001964f2455a24333467447e15866a Mon Sep 17 00:00:00 2001 From: CHOSOOGEUN <241898164+CHOSOOGEUN@users.noreply.github.com> Date: Sat, 23 May 2026 11:11:45 +0900 Subject: [PATCH] =?UTF-8?q?feat(events,cameras):=20camera=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20+=20station=20=ED=95=84=ED=84=B0=20+=20cameras=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 팀원 피드백 3종 일괄 반영. - (#5) POST /api/events/ 가 camera_id 검증 없이 행을 만들었음 → camera 미존재 404, 비활성(is_active=false) 400 으로 거부 - (#6) GET /api/cameras/ 가 정렬 없어 토글/새로고침마다 순서가 흔들림 → order_by(Camera.id) 추가 - (#3) GET /api/events/ 에 station 명시 파라미터 추가. 통합 search 로 station_name 부분일치 가능했지만 명시 필터가 더 깔끔. station 단독으로 들어오면 Camera 조인 후 station_name ILIKE 매칭. search 와는 AND. 같은 Camera 조인이 search 와 station 양쪽에서 발생하지 않게 한 번만 적용하도록 정리. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/app/api/cameras.py | 3 ++- backend/app/api/events.py | 33 +++++++++++++++++++++++++-------- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/backend/app/api/cameras.py b/backend/app/api/cameras.py index feff533..ffeab71 100644 --- a/backend/app/api/cameras.py +++ b/backend/app/api/cameras.py @@ -19,7 +19,8 @@ async def list_cameras( """ [통합 v1] 등록된 모든 카메라의 상태를 조회합니다. (보안 인증 필수) """ - result = await db.execute(select(Camera)) + # 토글/재조회 시 순서 흔들림 방지 — 항상 id 오름차순 + result = await db.execute(select(Camera).order_by(Camera.id)) return result.scalars().all() diff --git a/backend/app/api/events.py b/backend/app/api/events.py index b5813a8..18dc117 100644 --- a/backend/app/api/events.py +++ b/backend/app/api/events.py @@ -26,6 +26,7 @@ async def list_events( camera_id: Optional[int] = None, status: Optional[str] = None, type: Optional[str] = None, + station: Optional[str] = None, date_from: Optional[datetime] = None, date_to: Optional[datetime] = None, search: Optional[str] = None, @@ -64,25 +65,34 @@ async def list_events( if date_to: query = query.where(Event.timestamp <= date_to) + # Camera 조인은 station 필터나 search 가 들어올 때만 (중복 join 방지) + station_q = station.strip() if station else "" + search_q = search.strip() if search else "" + if station_q or search_q: + query = query.join(Camera, Event.camera_id == Camera.id) + + # 역 이름 명시 필터 + if station_q: + query = query.where(Camera.station_name.ilike(f"%{station_q}%")) + # 서버사이드 통합 검색: EV-번호 / CAM-번호 / 역이름 / 게이트(위치) / event_type / reason - if search and search.strip(): - s = search.strip() - pattern = f"%{s}%" + if search_q: + pattern = f"%{search_q}%" conds = [ Camera.station_name.ilike(pattern), Camera.location.ilike(pattern), Event.event_type.ilike(pattern), Event.reason.ilike(pattern), ] - m = re.search(r"EV[-_]?(\d+)", s, re.IGNORECASE) + m = re.search(r"EV[-_]?(\d+)", search_q, re.IGNORECASE) if m: conds.append(Event.id == int(m.group(1))) - m = re.search(r"CAM[-_]?(\d+)", s, re.IGNORECASE) + m = re.search(r"CAM[-_]?(\d+)", search_q, re.IGNORECASE) if m: conds.append(Event.camera_id == int(m.group(1))) - if s.isdigit(): - conds.append(Event.id == int(s)) - query = query.join(Camera, Event.camera_id == Camera.id).where(or_(*conds)) + if search_q.isdigit(): + conds.append(Event.id == int(search_q)) + query = query.where(or_(*conds)) query = query.order_by(Event.timestamp.desc()).offset(offset).limit(limit) @@ -145,6 +155,13 @@ async def create_event( [GateGuard] AI 실시간 추론 결과로부터 무임승차 이벤트를 기록하고 관제 대시보드에 즉시 브로드캐스트합니다. - AI 추론 엔진(inference.py)에서 호출됩니다. """ + # 0. 카메라 검증 — 존재해야 하고 활성 상태여야 이벤트를 받음 + camera = await db.scalar(select(Camera).where(Camera.id == body.camera_id)) + if not camera: + raise HTTPException(status_code=404, detail=f"카메라 #{body.camera_id} 가 존재하지 않습니다.") + if not camera.is_active: + raise HTTPException(status_code=400, detail=f"카메라 #{body.camera_id} 가 비활성 상태입니다.") + # 1. DB에 사건 기록 (Persistence) # exclude_none: 비전송 필드는 모델 default 가 적용되도록 (event_type → 'unknown') event = Event(**body.model_dump(exclude_none=True))