Harden infra validation flow
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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/업로드/스냅샷 데이터가 유지되는지 확인합니다.
|
||||||
|
|||||||
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