Harden infra validation flow

This commit is contained in:
hyunho
2026-03-25 10:43:39 +09:00
parent d9023abed6
commit 3b4169d301
4 changed files with 169 additions and 14 deletions

View File

@@ -4,6 +4,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
import csv import csv
from io import BytesIO, StringIO from io import BytesIO, StringIO
import re
import shutil import shutil
import uuid import uuid
@@ -57,20 +58,41 @@ class MemberBulkPayload(BaseModel):
LEGACY_HEADER_MAP = { LEGACY_HEADER_MAP = {
"이름": "name", "이름": "name",
"name": "name",
"소속회사": "company", "소속회사": "company",
"co": "company",
"company": "company",
"직급": "rank", "직급": "rank",
"rank": "rank",
"직책": "role", "직책": "role",
"pos": "role",
"role": "role",
"부서": "department", "부서": "department",
"part": "department",
"department": "department",
"그룹": "grp", "그룹": "grp",
"gr": "grp",
"grp": "grp",
"디비전": "division", "디비전": "division",
"div": "division",
"division": "division",
"": "team", "": "team",
"team": "team",
"teal": "team",
"": "cell", "": "cell",
"cell": "cell",
"근무상태": "work_status", "근무상태": "work_status",
"work_status": "work_status",
"근무시간": "work_time", "근무시간": "work_time",
"work_time": "work_time",
"전화번호": "phone", "전화번호": "phone",
"phone": "phone",
"이메일": "email", "이메일": "email",
"email": "email",
"자리위치": "seat_label", "자리위치": "seat_label",
"seat_label": "seat_label",
"사진": "photo_url", "사진": "photo_url",
"photo_url": "photo_url",
} }
@@ -110,6 +132,20 @@ def fetch_members() -> list[dict[str, object]]:
return cur.fetchall() return cur.fetchall()
def get_member_count() -> int:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) AS count FROM members")
return int(cur.fetchone()["count"])
def ensure_snapshot_month(snapshot_month: str) -> str:
value = snapshot_month.strip()
if not re.fullmatch(r"\d{4}-\d{2}", value):
raise HTTPException(status_code=400, detail="snapshot_month 형식은 YYYY-MM 이어야 합니다.")
return value
def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]: def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -130,18 +166,22 @@ def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]: def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]:
def normalize_header(value: object) -> str:
return str(value or "").strip().lower()
header_idx = next( header_idx = next(
( (
idx idx
for idx, row in enumerate(rows) for idx, row in enumerate(rows)
if "이름" in row and "부서" in row if {"이름", "부서"}.issubset({str(value).strip() for value in row})
or {"name", "part"}.issubset({normalize_header(value) for value in row})
), ),
-1, -1,
) )
if header_idx < 0: if header_idx < 0:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. '이름''부서' 헤더를 찾지 못했습니다.") raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. 필수 헤더(이름/부서 또는 name/part)를 찾지 못했습니다.")
headers = [str(value).strip() for value in rows[header_idx]] headers = [normalize_header(value) for value in rows[header_idx]]
payloads: list[MemberPayload] = [] payloads: list[MemberPayload] = []
for row in rows[header_idx + 1 :]: for row in rows[header_idx + 1 :]:
@@ -185,8 +225,26 @@ app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=F
@app.get("/api/health") @app.get("/api/health")
def health() -> dict[str, str]: def health() -> dict[str, object]:
return {"status": "ok"} checks = {
"upload_dir": UPLOAD_DIR.exists(),
"snapshot_dir": SNAPSHOT_DIR.exists(),
}
try:
member_count = get_member_count()
checks["database"] = True
except Exception:
member_count = None
checks["database"] = False
status = "ok" if all(checks.values()) else "degraded"
return {
"status": status,
"checks": checks,
"member_count": member_count,
"timestamp": datetime.utcnow().isoformat() + "Z",
}
@app.post("/api/mock-login") @app.post("/api/mock-login")
@@ -311,11 +369,18 @@ def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("
@app.post("/api/snapshots/monthly") @app.post("/api/snapshots/monthly")
def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]: def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]:
snapshot_month = ensure_snapshot_month(snapshot_month)
filename = f"organization-snapshot-{snapshot_month}.csv" filename = f"organization-snapshot-{snapshot_month}.csv"
target = SNAPSHOT_DIR / filename target = SNAPSHOT_DIR / filename
with get_conn() as conn: with get_conn() as conn:
with conn.cursor() as cur: with conn.cursor() as cur:
cur.execute(
"SELECT id FROM snapshots WHERE snapshot_month = %s ORDER BY created_at DESC LIMIT 1",
(snapshot_month,),
)
if cur.fetchone() is not None:
raise HTTPException(status_code=409, detail="해당 월의 스냅샷이 이미 존재합니다.")
cur.execute( cur.execute(
""" """
SELECT name, company, rank, role, department, grp, division, team, cell, SELECT name, company, rank, role, department, grp, division, team, cell,

View File

@@ -2,17 +2,33 @@ services:
proxy: proxy:
image: nginx:1.27-alpine image: nginx:1.27-alpine
depends_on: depends_on:
- frontend frontend:
- backend condition: service_healthy
backend:
condition: service_healthy
ports: ports:
- "8080:80" - "8080:80"
volumes: volumes:
- ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./proxy/nginx.conf:/etc/nginx/conf.d/default.conf:ro
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
frontend: frontend:
build: build:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 10s
backend: backend:
build: build:
@@ -21,10 +37,18 @@ services:
env_file: env_file:
- .env - .env
depends_on: depends_on:
- db db:
condition: service_healthy
volumes: volumes:
- uploads_data:/data/uploads - uploads_data:/data/uploads
- snapshots_data:/data/snapshots - snapshots_data:/data/snapshots
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')\" || exit 1"]
interval: 15s
timeout: 5s
retries: 8
start_period: 20s
db: db:
image: postgres:16-alpine image: postgres:16-alpine
@@ -34,9 +58,15 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
volumes: volumes:
postgres_data: postgres_data:
uploads_data: uploads_data:
snapshots_data: snapshots_data:

View File

@@ -50,6 +50,8 @@
3. `docker compose build`를 실행합니다. 3. `docker compose build`를 실행합니다.
4. `docker compose up -d`를 실행합니다. 4. `docker compose up -d`를 실행합니다.
5. 브라우저에서 `http://SERVER_IP:8080` 으로 접속합니다. 5. 브라우저에서 `http://SERVER_IP:8080` 으로 접속합니다.
6. `docker compose ps` 에서 `backend`, `frontend`, `proxy`, `db` 가 모두 `healthy` 인지 확인합니다.
7. `http://SERVER_IP:8080/api/health` 응답에서 `status`, `checks`, `member_count` 를 확인합니다.
## 7. 현재 단계의 데이터 및 백업 정책 ## 7. 현재 단계의 데이터 및 백업 정책
- 데이터베이스: PostgreSQL 볼륨 `postgres_data` - 데이터베이스: PostgreSQL 볼륨 `postgres_data`
@@ -60,17 +62,25 @@
## 8. 현재 구조의 한계 ## 8. 현재 구조의 한계
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다. - 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
- 레거시 조직도 화면은 iframe으로 연결만 해둔 상태이며, DB 기반 API와 완전히 연동되지는 않았습니다. - 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다.
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다. - 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
## 9. 다음 구현 권장 순서 ## 9. 다음 구현 권장 순서
1. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다. 1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다.
2. 구성원 데이터를 브라우저 메모리 방식에서 PostgreSQL 기반 API 저장 방식으로 전환합니다. 2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
3. 사무실 자리배치 좌표 저장 기능을 추가합니다. 3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
4. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다. 4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
5. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다.
## 10. 현재 로컬 테스트 접속 정보 ## 10. 현재 로컬 테스트 접속 정보
- 접속 주소: `http://localhost:8080` - 접속 주소: `http://localhost:8080`
- 상태 확인 API: `http://localhost:8080/api/health` - 상태 확인 API: `http://localhost:8080/api/health`
- WSL 내부 실행 경로: - WSL 내부 실행 경로:
- `/home/hyunho/projects/mh-dashboard-organization` - `/home/hyunho/projects/mh-dashboard-organization`
## 11. 운영 검증 체크포인트
- 엑셀 또는 CSV 업로드 후 `GET /api/members` 에서 데이터가 조회되는지 확인합니다.
- `docker compose restart backend proxy` 이후에도 데이터가 유지되는지 확인합니다.
- `POST /api/snapshots/monthly` 호출 시 `YYYY-MM` 형식만 허용되는지 확인합니다.
- 같은 월에 대해 중복 스냅샷 생성 시 409 에러가 반환되는지 확인합니다.
- `docker compose down` 후 다시 `up -d` 했을 때 DB/업로드/스냅샷 데이터가 유지되는지 확인합니다.

View File

@@ -0,0 +1,50 @@
# 인프라 검증 체크리스트
## 1. 컨테이너 기동
- `docker compose build`
- `docker compose up -d`
- `docker compose ps`
- 확인 기준: `proxy`, `frontend`, `backend`, `db` 모두 `healthy`
## 2. API 상태 확인
- `curl http://localhost:8080/api/health`
- 확인 기준:
- `status``ok`
- `checks.database``true`
- `checks.upload_dir``true`
- `checks.snapshot_dir``true`
## 3. 초기 데이터 업로드
- 조직도 화면에서 `.xlsx` 또는 `.csv` 업로드
- `curl http://localhost:8080/api/members`
- 확인 기준:
- `items` 배열이 비어 있지 않음
- 화면 렌더링이 정상 동작함
## 4. 영속성 확인
- `docker compose restart backend proxy`
- 다시 `curl http://localhost:8080/api/members`
- 확인 기준:
- 업로드했던 데이터가 그대로 유지됨
## 5. 스냅샷 검증
- `curl -X POST -F snapshot_month=2026-03 http://localhost:8080/api/snapshots/monthly`
- 확인 기준:
- CSV 파일 경로가 반환됨
- `/snapshots/...` 다운로드 가능
## 6. 중복/형식 오류 검증
- 같은 월로 다시 스냅샷 생성
- 확인 기준:
- 409 에러 반환
- 잘못된 형식으로 스냅샷 생성 예: `202603`
- 확인 기준:
- 400 에러 반환
## 7. 종료 후 재기동 확인
- `docker compose down`
- `docker compose up -d`
- 확인 기준:
- DB 데이터 유지
- 업로드 파일 유지
- 스냅샷 파일 유지