Harden infra validation flow
This commit is contained in:
@@ -4,6 +4,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
import csv
|
||||
from io import BytesIO, StringIO
|
||||
import re
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
@@ -57,20 +58,41 @@ class MemberBulkPayload(BaseModel):
|
||||
|
||||
LEGACY_HEADER_MAP = {
|
||||
"이름": "name",
|
||||
"name": "name",
|
||||
"소속회사": "company",
|
||||
"co": "company",
|
||||
"company": "company",
|
||||
"직급": "rank",
|
||||
"rank": "rank",
|
||||
"직책": "role",
|
||||
"pos": "role",
|
||||
"role": "role",
|
||||
"부서": "department",
|
||||
"part": "department",
|
||||
"department": "department",
|
||||
"그룹": "grp",
|
||||
"gr": "grp",
|
||||
"grp": "grp",
|
||||
"디비전": "division",
|
||||
"div": "division",
|
||||
"division": "division",
|
||||
"팀": "team",
|
||||
"team": "team",
|
||||
"teal": "team",
|
||||
"셀": "cell",
|
||||
"cell": "cell",
|
||||
"근무상태": "work_status",
|
||||
"work_status": "work_status",
|
||||
"근무시간": "work_time",
|
||||
"work_time": "work_time",
|
||||
"전화번호": "phone",
|
||||
"phone": "phone",
|
||||
"이메일": "email",
|
||||
"email": "email",
|
||||
"자리위치": "seat_label",
|
||||
"seat_label": "seat_label",
|
||||
"사진": "photo_url",
|
||||
"photo_url": "photo_url",
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +132,20 @@ def fetch_members() -> list[dict[str, object]]:
|
||||
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]]:
|
||||
with get_conn() as conn:
|
||||
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 normalize_header(value: object) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
header_idx = next(
|
||||
(
|
||||
idx
|
||||
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,
|
||||
)
|
||||
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] = []
|
||||
|
||||
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")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
def health() -> dict[str, object]:
|
||||
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")
|
||||
@@ -311,11 +369,18 @@ def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("
|
||||
|
||||
@app.post("/api/snapshots/monthly")
|
||||
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"
|
||||
target = SNAPSHOT_DIR / filename
|
||||
|
||||
with get_conn() as conn:
|
||||
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(
|
||||
"""
|
||||
SELECT name, company, rank, role, department, grp, division, team, cell,
|
||||
|
||||
@@ -2,17 +2,33 @@ services:
|
||||
proxy:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
backend:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./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:
|
||||
build:
|
||||
context: .
|
||||
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:
|
||||
build:
|
||||
@@ -21,10 +37,18 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
- db
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploads_data:/data/uploads
|
||||
- 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:
|
||||
image: postgres:16-alpine
|
||||
@@ -34,9 +58,15 @@ services:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- 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:
|
||||
postgres_data:
|
||||
uploads_data:
|
||||
snapshots_data:
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
3. `docker compose build`를 실행합니다.
|
||||
4. `docker compose up -d`를 실행합니다.
|
||||
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. 현재 단계의 데이터 및 백업 정책
|
||||
- 데이터베이스: PostgreSQL 볼륨 `postgres_data`
|
||||
@@ -60,17 +62,25 @@
|
||||
|
||||
## 8. 현재 구조의 한계
|
||||
- 로그인은 화면상 동작만 구현되어 있고, 아직 백엔드 보호 기능은 없습니다.
|
||||
- 레거시 조직도 화면은 iframe으로 연결만 해둔 상태이며, DB 기반 API와 완전히 연동되지는 않았습니다.
|
||||
- 레거시 조직도 화면은 현재 DB 기반 API를 사용하도록 전환했지만, 운영 환경에서 전체 업로드/재기동/스냅샷 흐름 검증이 추가로 필요합니다.
|
||||
- 레거시 화면은 CDN 자산을 사용합니다. 사내망이 외부 인터넷 접속을 막는 환경이라면 추후 로컬 자산으로 바꿔야 합니다.
|
||||
|
||||
## 9. 다음 구현 권장 순서
|
||||
1. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
|
||||
2. 구성원 데이터를 브라우저 메모리 방식에서 PostgreSQL 기반 API 저장 방식으로 전환합니다.
|
||||
3. 사무실 자리배치 좌표 저장 기능을 추가합니다.
|
||||
4. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다.
|
||||
1. Docker Compose 기준 운영 검증과 스냅샷 검증을 완료합니다.
|
||||
2. 4개 기능 통합 대시보드 프레임과 공통 헤더를 준비합니다.
|
||||
3. 프로필 사진 업로드 UI를 `/api/uploads/profile-photo` 와 연결합니다.
|
||||
4. 사무실 자리배치 좌표 저장 기능을 추가합니다.
|
||||
5. 인증 정책이 정해지면 화면상 로그인 대신 실제 로그인으로 교체합니다.
|
||||
|
||||
## 10. 현재 로컬 테스트 접속 정보
|
||||
- 접속 주소: `http://localhost:8080`
|
||||
- 상태 확인 API: `http://localhost:8080/api/health`
|
||||
- WSL 내부 실행 경로:
|
||||
- `/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/업로드/스냅샷 데이터가 유지되는지 확인합니다.
|
||||
|
||||
50
docs/INFRA_VALIDATION_CHECKLIST.md
Normal file
50
docs/INFRA_VALIDATION_CHECKLIST.md
Normal 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 데이터 유지
|
||||
- 업로드 파일 유지
|
||||
- 스냅샷 파일 유지
|
||||
Reference in New Issue
Block a user