From b4085a0e0fef79fa41daf1a281466971770d0bce Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 16 Sep 2026 16:25:02 +0900 Subject: [PATCH] =?UTF-8?q?[fix]=20solution:=20=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=C2=B7=ED=81=AC=EB=A1=A4=EB=A7=81=20?= =?UTF-8?q?=EC=A7=84=EB=8B=A8=C2=B7=EC=9E=A5=EC=95=A0=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EB=AC=B6=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것, 크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버): 사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이 429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일 이유가 없었다. - solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은 dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡 진행(useGenerationJob) 그대로 - solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속. db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN. worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를 Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가. collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다. - postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션 검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인. --- .env.example | 12 +- AGENTS.md | 8 + docker-compose.yml | 5 +- docs/ALERTS.md | 61 ++++++ docs/DEVLOG.md | 77 +++++++ nginx/Dockerfile | 11 +- postgres-init/init-data/init.sql | 26 +++ .../migrations/0015_users_token_version.sql | 18 ++ .../migrations/0016_alert_outbox.sql | 35 +++ .../backend/common/collect_diagnostics.py | 59 +++++ .../common/database/db_session_manager.py | 5 +- .../backend/common/database/model/models.py | 29 +++ solution/backend/common/enums.py | 9 + solution/backend/common/models/gmodel.py | 6 +- solution/backend/crud/alert_crud.py | 74 +++++++ solution/backend/crud/job_crud.py | 14 +- solution/backend/router/router.py | 27 ++- solution/backend/scheduler/__init__.py | 14 +- solution/backend/scheduler/jobs.py | 51 ++++- solution/backend/services/alert_service.py | 162 ++++++++++++++ solution/backend/services/auth_service.py | 36 ++- solution/backend/services/build_service.py | 23 ++ solution/backend/services/collect_service.py | 32 ++- solution/backend/services/copy_service.py | 13 +- solution/backend/services/rollback_service.py | 12 +- solution/backend/services/teams_webhook.py | 69 ++++++ solution/backend/tests/test_alert_service.py | 206 ++++++++++++++++++ solution/backend/tests/test_auth.py | 59 +++++ solution/backend/tests/test_build_publish.py | 10 + solution/backend/tests/test_healthz.py | 9 + solution/backend/tests/test_job_queue.py | 22 +- .../tests/test_search_console_service.py | 13 +- solution/backend/worker/runner.py | 49 ++++- .../features/onboarding/Step5Generating.tsx | 134 +++++++++--- .../features/onboarding/generationLabels.ts | 9 +- solution/frontend/src/lib/autoSession.ts | 5 + 36 files changed, 1320 insertions(+), 84 deletions(-) create mode 100644 docs/ALERTS.md create mode 100644 postgres-init/migrations/0015_users_token_version.sql create mode 100644 postgres-init/migrations/0016_alert_outbox.sql create mode 100644 solution/backend/common/collect_diagnostics.py create mode 100644 solution/backend/crud/alert_crud.py create mode 100644 solution/backend/services/alert_service.py create mode 100644 solution/backend/services/teams_webhook.py create mode 100644 solution/backend/tests/test_alert_service.py diff --git a/.env.example b/.env.example index 8f35202..98c9092 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,14 @@ GSC_CREDENTIALS_FILE= GSC_CREDENTIALS_HOST_FILE= GSC_ALERT_DAYS=7 GSC_ALERT_WEBHOOK_URL= + +# 장애 알림(잡 dead-letter·발행 업무 실패·부분 실패·잡 큐 정체) — Teams Workflows 수신 webhook. +# GSC_ALERT_WEBHOOK_URL 과 다른 값이다(그건 색인 감시 전용) — docs/ALERTS.md. +# 비우면 알림은 DB(alert_outbox)에 쌓이기만 하고 안 나간다. 서버 동작에는 영향 없다. +TEAMS_WEBHOOK_URL= +# 재시도마다 중복 스팸을 막는 창(분). 기본 60분 — 같은 사유가 이 시간 안에 또 터지면 다시 안 보낸다. +ALERT_DEDUPE_WINDOW_MIN=60 + # 비우면 로컬 발행만 한다 AZURE_STORAGE_CONNECTION_STRING= AZURE_STORAGE_CONTAINER= @@ -103,6 +111,8 @@ AZURE_STORAGE_PREFIX= # 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다. # ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 — # 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts). -# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site +# ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site, +# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는 +# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다). AUTO_LOGIN_ID= AUTO_LOGIN_PW= diff --git a/AGENTS.md b/AGENTS.md index 2518695..97ad53c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | | **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) | +| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) | --- @@ -115,6 +116,13 @@ (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다). +- **★ `VITE_AUTO_LOGIN_ID`·`PW` 는 운영 진입점(`solution-site`, nginx/Dockerfile)에 절대 + 넘기지 않는다.** 예전엔 `docker-compose.yml` 의 `solution-site` build args 에 이 값이 + 실제로 흘러가고 있었다 — `.env` 에 채운 채로 배포하면 자동 로그인 계정이 사장님이 여는 + 운영 번들에 그대로 구워졌다(누구나 JS 에서 읽을 수 있다). 지금은 그 build arg 자체가 + 없다. `lib/autoSession.ts` 의 `import.meta.env.DEV` 가드가 둘째 안전판이다 — 실수로 + 값이 다시 넘어와도 운영 빌드(`vite build`)에서는 죽은 코드로 접혀 번들에서 빠진다. + 자동 로그인이 필요하면 `solution-frontend`(`--profile dev`, `vite dev`)만 쓴다. - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 CDN 을 앞에 세워야 한다. 아니면 비워라. diff --git a/docker-compose.yml b/docker-compose.yml index 77b5dc4..c91ec96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -248,11 +248,10 @@ services: VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost} VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost} - # ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다. - VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-} - VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-} # 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다. VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + # ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다. + # 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다. image: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site volumes: diff --git a/docs/ALERTS.md b/docs/ALERTS.md new file mode 100644 index 0000000..dc25ba0 --- /dev/null +++ b/docs/ALERTS.md @@ -0,0 +1,61 @@ +# 장애 알림 (2026-09-15) + +구현: `services/alert_service.py`(적재·재시도·중복 억제) · `services/teams_webhook.py`(전송) · +`worker/runner.py` · `services/build_service.py` · `services/rollback_service.py`(발생 지점) · +`scheduler/jobs.py`(발송·큐 정체 스윕). 전용 컨테이너 없음 — 기존 API·워커 프로세스가 한다. + +## 무엇을 알리나 + +| kind | 언제 | dedupe_key | +|---|---|---| +| `job_dead` | 잡이 재시도를 소진해 DEAD | `job_dead:{JobType}:{place_id 또는 job_id}` | +| `build_failed` | BUILD·ROLLBACK 이 **게이트 반려가 아닌** 렌더·인프라 실패로 끝남 | `build_failed:{place_id}` | +| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` | +| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` | +| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) | + +★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건 +운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`. + +## 중복 억제·재시도 + +`send_alert(kind, title, detail, dedupe_key)` — 같은 dedupe_key 로 "안 풀린"(resolved_at +NULL) 알림이 이미 있으면 새로 만들지 않는다. `resolve_alert(dedupe_key, ...)` 가 그 알림을 +풀고 복구 알림을 한 번 보낸다. 실제 전송은 `scheduler.jobs.sweep_alert_outbox`(1분마다) — +실패하면 `crud/job_crud.compute_backoff` 와 같은 백오프로 최대 5회 재시도 후 `FAILED`(소진)로 +멈춘다. `TEAMS_WEBHOOK_URL` 이 비어 있으면 적재만 되고 전송은 안 나간다(서버 동작엔 영향 없음). + +`detail` 은 저장 **전에** `alert_service._scrub` 이 쿼리스트링 키·Bearer 토큰·`password=` 류· +이메일을 마스킹한다 — 외부 API 예외 메시지가 URL 에 키를 실어 보내는 경우가 있다. + +## 설정 + +``` +TEAMS_WEBHOOK_URL= # Teams Workflows 수신 webhook. 비우면 알림이 DB(alert_outbox)에 + # 쌓이기만 하고 안 나간다 — 서버는 그대로 뜬다. +ALERT_DEDUPE_WINDOW_MIN=60 +``` + +`GSC_ALERT_WEBHOOK_URL`(search_console_alerts.py)과는 **다른 값**이다 — 색인 감시 전용과 +이 잡 큐·발행 알림은 목적이 달라 의도적으로 분리했다(services/teams_webhook.py 머리주석). + +## 서버·DB 전체 장애 — 이 알림 체계로는 못 잡는다 + +`alert_service`·`scheduler`가 도는 프로세스 자체가 죽으면(서버 다운·DB 완전 단절) 이 체계는 +자기 장애를 자기가 못 알린다. **외부 감시가 필요하다** — uptime 모니터 등에서 주기적으로 +`GET /readyz` 를 찌른다(`router/router.py`). `/healthz` 와 다르다: `/healthz` 는 프로세스 +생존만(항상 200), `/readyz` 는 **DB 에 실제로 `SELECT 1` 을 던져** 200/503 을 가른다. + +절차: +1. 외부 모니터가 `https:///readyz` 를 1~5분 간격으로 확인한다. +2. 2xx 가 아니거나 타임아웃이면 **그 모니터 자신의 채널**로 알린다 — 이 레포의 + `TEAMS_WEBHOOK_URL` 로 보내면 안 된다(webhook 이 죽은 서버 안에 있을 수 있다). +3. 이 모니터의 실제 설정(어느 서비스·어느 채널)은 이 세션에서 만들지 않았다 — 운영 계정· + 외부 서비스 연결은 사용자 승인 후 진행한다. + +## 아직 안 한 것 — 운영 미적용 + +- 실제 Teams Workflows webhook 생성·채널 지정 — mock 테스트만 했다(tests/test_alert_service.py). +- 외부 uptime 모니터 실제 연결(2절 3번). +- 마이그레이션(`0016_alert_outbox.sql`) 서버 적용. +- `alert_outbox` 오래된 SENT/FAILED 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다). diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index fc195e7..fb8f230 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,5 +1,82 @@ # 개발 일지 +## 2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다 + +`common/collect_diagnostics.py`(신규) + `collect_service.py` 채널별 실패 10곳 연결. +전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다. + +**검증** — `python3 ast` 파싱, 수동 실행 확인. + +## 2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게 + +**한 일** +- `services/copy_service.py` — 소개문·FAQ 생성(`generate` 단계)에서 `GeminiError` 가 나면 + 잡을 실패시키지 않고 `generate` 를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다. + 프론트 사유 라벨: `generationLabels.ts` `SKIP_REASONS.generation_failed`. +- `common/database/db_session_manager.py` — 유니크 제약 충돌(`IntegrityError`) 로그를 + ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라 + `services/collect_service.py` `_add_link` 가 이미 "이미 있으면 그만" 으로 처리한다. + +**왜** +API 키가 아예 없을 때는 이미 `generate` 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데 +**호출이 실패할 때만** 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠 +0건으로 막지 않고(`publish_gate.check_unique_content` — "얇은 콘텐츠로 발행을 막지 않기로 +했다"), 다른 곁들이 콘텐츠(자작곡 등, `build_service.py`)도 실패하면 로그만 남기고 계속 +진행한다 — 이 갈래만 예외였다. + +실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를 +쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다. +화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데 +잡을 죽일 이유가 없었다. + +유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속 +오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그). + +**남은 것** — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 · +잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데, +그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다. + +## 2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz + +- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회, + job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이 + 기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다. +- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 **게이트 반려가 + 아닌** 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비 + 실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다. +- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py + 와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다. +- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일). +- `/readyz` 추가 — `/healthz`(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다. + 서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가 + 이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다). +- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at + 을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert + 직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다. `func.now()`(DB 쪽 + 시계)로 비교하도록 고쳤다. +- 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가, + 16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py + (readyz 1건 추가, 2건) 전부 통과. +- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션 + 0016 서버 적용 — 전부 사용자 승인 후 별도 진행. + +## 2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화 + +- `docker-compose.yml` `solution-site`(운영 진입점) 빌드에서 `VITE_AUTO_LOGIN_ID`·`PW` + build arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나 + JS 에서 읽을 수 있었다. `nginx/Dockerfile` 도 그 ARG 자체를 안 받는다. +- `lib/autoSession.ts` 에 `import.meta.env.DEV` 가드를 더했다(둘째 안전판) — 운영 빌드는 + 이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로 + 운영 빌드를 돌려도 `build/client` 어디에도 그 문자열이 없는 것을 확인했다. +- `users.token_version`(마이그레이션 0015) 추가 — `refresh_token()` 이 지금까지 서명·만료만 + 보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지 + 계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고, + 비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절). +- 검증: `tests/test_auth.py` 16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후 + 거절). `tests/test_schema_ddl.py` 통과(ORM ↔ init.sql 일치). +- 운영 미적용: 실제 서버 `.env` 의 `AUTO_LOGIN_ID`·`PW` 값 확인·제거와 마이그레이션 적용은 + 이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다. + ## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기 - 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다. diff --git a/nginx/Dockerfile b/nginx/Dockerfile index 0913c69..e823055 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -27,19 +27,18 @@ COPY admin ./admin ARG VITE_API_BASE_URL ARG VITE_PUBLISH_HOST ARG VITE_SITE_PREVIEW_URL -# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 — -# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession). -ARG VITE_AUTO_LOGIN_ID -ARG VITE_AUTO_LOGIN_PW # 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와 # 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다). ARG VITE_GOOGLE_CLIENT_ID ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ - VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \ - VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW \ VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID +# ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는 +# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나 +# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev, +# vite dev)에만 있다 — 그쪽은 이 Dockerfile 을 타지 않는다(lib/autoSession.ts 의 DEV 가드도 +# 같은 이유로 있다 — 이 ARG 가 실수로 되돌아와도 프로덕션 빌드에서는 죽은 코드가 된다). RUN npm run build -w @o2o/frontend FROM nginx:alpine diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 639f94f..7f7b75b 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS public.users ( role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer provider SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키 + token_version SMALLINT NOT NULL DEFAULT 1, -- ★ refresh 토큰 무효화 키. JWT(access·refresh)의 sub 에 실려 나간다 — 이 값을 올리면(bump_token_version) 그 전에 발급된 refresh 토큰은 다음 재발급에서 전부 거절된다 created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), deleted BOOLEAN NOT NULL DEFAULT FALSE @@ -337,6 +338,24 @@ CREATE TABLE IF NOT EXISTS public.site_search_status ( deleted BOOLEAN NOT NULL DEFAULT false ); +-- 장애 알림 발송함 — services/alert_service.py. 워커·스케줄러가 죽어도 알림 자체는 +-- DB 에 남아야 한다(메모리 큐로만 두면 장애를 알릴 메시지까지 같이 잃는다). +CREATE TABLE IF NOT EXISTS public.alert_outbox ( + alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + kind VARCHAR(50) NOT NULL, -- job_dead · build_failed · partial_failure · queue_stuck · recovery … + dedupe_key VARCHAR(200) NULL, -- 같은 사유의 재시도 스팸을 막는 키(alert_service.send_alert) + title VARCHAR(200) NOT NULL, + detail TEXT NULL, -- 이미 비밀·개인정보를 걷어낸 텍스트만(_scrub) + status SMALLINT NOT NULL DEFAULT 1, -- AlertStatus: 1=pending 2=sent 3=failed(재시도 소진) + attempts SMALLINT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sent_at TIMESTAMPTZ NULL, + resolved_at TIMESTAMPTZ NULL, -- 채워지면 그 dedupe_key 는 "복구됨" — 다음 문제 발생 때 새로 알린다 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + CREATE TABLE IF NOT EXISTS public.sites ( site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), place_id uuid NOT NULL, -- 사업장과 1:1 @@ -494,6 +513,13 @@ CREATE INDEX IF NOT EXISTS ix_jobs_lease ON public.jobs (status, lease_until); CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key) WHERE status IN (1, 2) AND dedupe_key IS NOT NULL; +-- alert_outbox +-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것. +CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at); +-- 최근 같은 사유 조회(dedupe·복구 판정): send_alert·resolve_alert 가 dedupe_key 로 최신 행을 찾는다. +CREATE INDEX IF NOT EXISTS ix_alert_outbox_dedupe ON public.alert_outbox (dedupe_key, created_at DESC) + WHERE dedupe_key IS NOT NULL; + -- ============================================================ -- 마이그레이션 기준선(baseline) -- ============================================================ diff --git a/postgres-init/migrations/0015_users_token_version.sql b/postgres-init/migrations/0015_users_token_version.sql new file mode 100644 index 0000000..b0f47e8 --- /dev/null +++ b/postgres-init/migrations/0015_users_token_version.sql @@ -0,0 +1,18 @@ +-- 0015 · users.token_version — refresh 토큰 무효화 키 +-- +-- ★ 왜 필요한가 (보안 점검, 2026-09-15) +-- auth_service.refresh_token() 은 지금까지 refresh 토큰을 서명만 검증하고 그 안의 sub +-- (user_id·id·role)를 그대로 새 access 토큰에 옮겨 찍었다 — DB 를 한 번도 보지 않았다. +-- 비밀번호를 바꾸거나(다른 기기의 세션을 끊고 싶을 때) 계정을 차단해도, 이미 발급된 +-- refresh 토큰(7일)을 쥔 클라이언트는 만료 전까지 계속 새 access 토큰을 받을 수 있었다. +-- token_version 을 JWT 의 sub 에 같이 싣고 refresh 할 때 DB 의 지금 값과 대조하면, +-- bump_token_version() 을 부른 시점 이후의 refresh 시도는 전부 거절된다. +-- +-- ★ 옛 토큰(token_version 없이 발급된 것)도 읽힌다 — UserInfo 가 기본값 1 을 먼저 깔고 +-- 그 위에 없는 키는 안 덮으므로(common/models/gmodel.py UserInfo.__init__), 새 컬럼의 +-- DEFAULT 1 과 맞아떨어진다. 배포 순간 전원 강제 로그아웃이 되지 않는다. + +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS token_version SMALLINT NOT NULL DEFAULT 1; + +COMMENT ON COLUMN public.users.token_version IS + 'refresh 토큰 무효화 키. JWT(access·refresh)의 sub 에 실려 나간다 — 이 값을 올리면(bump_token_version) 그 전에 발급된 refresh 토큰은 다음 재발급에서 전부 거절된다.'; diff --git a/postgres-init/migrations/0016_alert_outbox.sql b/postgres-init/migrations/0016_alert_outbox.sql new file mode 100644 index 0000000..87066dd --- /dev/null +++ b/postgres-init/migrations/0016_alert_outbox.sql @@ -0,0 +1,35 @@ +-- 0016 · alert_outbox — 장애 알림 발송함(services/alert_service.py) +-- +-- ★ 왜 필요한가 — 최종 생성 실패(JobStatus.DEAD) · BUILD 잡 업무 실패(게이트 반려가 아닌 +-- 렌더·인프라 실패) · 노래 등 부분 실패 · 잡 큐 정체를 Teams Workflows webhook 으로 +-- 알린다. 워커·스케줄러가 죽어도 알림 자체는 DB 에 남아야 하므로(메모리 큐면 장애를 +-- 알릴 메시지까지 같이 잃는다) 영구 저장 + 재시도 + 중복 억제를 이 표 하나로 한다. + +CREATE TABLE IF NOT EXISTS public.alert_outbox ( + alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + kind VARCHAR(50) NOT NULL, + dedupe_key VARCHAR(200) NULL, + title VARCHAR(200) NOT NULL, + detail TEXT NULL, + status SMALLINT NOT NULL DEFAULT 1, + attempts SMALLINT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sent_at TIMESTAMPTZ NULL, + resolved_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +COMMENT ON COLUMN public.alert_outbox.kind IS + 'job_dead · build_failed · partial_failure · queue_stuck · recovery …'; +COMMENT ON COLUMN public.alert_outbox.status IS + 'AlertStatus: 1=pending 2=sent 3=failed(재시도 소진)'; +COMMENT ON COLUMN public.alert_outbox.detail IS + '이미 비밀·개인정보를 걷어낸 텍스트만 들어온다 — alert_service._scrub 가 저장 전에 거른다.'; +COMMENT ON COLUMN public.alert_outbox.resolved_at IS + '채워지면 그 dedupe_key 는 복구됨으로 본다 — 다음 문제 발생 때 새 알림을 보낸다.'; + +CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at); +CREATE INDEX IF NOT EXISTS ix_alert_outbox_dedupe ON public.alert_outbox (dedupe_key, created_at DESC) + WHERE dedupe_key IS NOT NULL; diff --git a/solution/backend/common/collect_diagnostics.py b/solution/backend/common/collect_diagnostics.py new file mode 100644 index 0000000..5831ede --- /dev/null +++ b/solution/backend/common/collect_diagnostics.py @@ -0,0 +1,59 @@ +"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용. + +★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다. + 자세한 배경은 DEVLOG.md 참고. +""" +from contextlib import contextmanager +from contextvars import ContextVar +from dataclasses import asdict, dataclass + +from common.logger import LOG + +_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None) + +# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 +# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다. +_MAX_MESSAGE = 500 +_MAX_TARGET = 200 + + +@dataclass +class CollectIssue: + stage: str # 어느 단계에서(예: "naver_place" · "tour_api" · "yanolja" · "static_html") + target: str # 무엇을 하다가(URL·검색어 등) + error_type: str # 예외 클래스명 + message: str # 예외 메시지 + + +@contextmanager +def collecting(): + """run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다.""" + token = _current.set([]) + try: + yield + finally: + _current.reset(token) + + +def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue: + """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다. + + collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다 + (단발 호출·테스트 호환).""" + issue = CollectIssue( + stage=stage, + target=target[:_MAX_TARGET], + error_type=type(ex).__name__, + message=str(ex)[:_MAX_MESSAGE], + ) + issues = _current.get() + if issues is not None: + issues.append(issue) + LOG.w(f"[collect] {stage} 실패(계속) {issue.target}: {issue.error_type}: {issue.message}") + return issue + + +def snapshot() -> list[dict]: + """지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다.""" + issues = _current.get() + return [asdict(i) for i in issues] if issues else [] diff --git a/solution/backend/common/database/db_session_manager.py b/solution/backend/common/database/db_session_manager.py index c164142..a2c08f6 100644 --- a/solution/backend/common/database/db_session_manager.py +++ b/solution/backend/common/database/db_session_manager.py @@ -113,7 +113,10 @@ class DBSessionManager(Singleton): return ErrorType.SUCCESS except IntegrityError as ex: await db.rollback() - LOG.e_no_callstack(f"duplicated. {ex}") + # ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 + # (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못 + # 보던 무결성 오류는 아래 일반 Exception 갈래로 간다. + LOG.w(f"duplicated. {ex}") return ErrorType.DB_ALREADY_SAME_KEY except Exception as ex: await db.rollback() diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index c825d0b..d466c22 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -80,6 +80,12 @@ class users(MainTableMixin, MAIN_BASE): # 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값). provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value) provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키 + # ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다 + # (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서, + # 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다. + # ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는 + # refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다. + token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # ============================================================ @@ -473,6 +479,29 @@ class site_search_status(MainTableMixin, MAIN_BASE): alerted_at = Column(DateTime(timezone=True), nullable=True) +class alert_outbox(MainTableMixin, MAIN_BASE): + """장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다. + + ★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다. + 장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다. + ★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) — + 같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다. + ★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면 + 다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다.""" + + __tablename__ = "alert_outbox" + alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + kind = Column(String(50), nullable=False) # job_dead · build_failed · partial_failure · queue_stuck · recovery … + dedupe_key = Column(String(200), nullable=True) + title = Column(String(200), nullable=False) + detail = Column(Text, nullable=True) # 이미 비밀·개인정보를 걷어낸 텍스트만 들어온다(alert_service._scrub) + status = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # AlertStatus: 1=pending 2=sent 3=failed(소진) + attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) + next_attempt_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) + sent_at = Column(DateTime(timezone=True), nullable=True) + resolved_at = Column(DateTime(timezone=True), nullable=True) + + class site_sections(MainTableMixin, MAIN_BASE): """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py index a26cd67..ad4ccb5 100644 --- a/solution/backend/common/enums.py +++ b/solution/backend/common/enums.py @@ -55,6 +55,7 @@ class ErrorType(Enum): ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 + ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등) # 사업장(places) 관련 에러 PLACE_NOT_FOUND = 1200 @@ -452,3 +453,11 @@ class JobStatus(CodeEnum): # claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다. ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} + + +class AlertStatus(CodeEnum): + """alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다.""" + + PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도) + SENT = 2 # 전송 성공 + FAILED = 3 # 재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다) diff --git a/solution/backend/common/models/gmodel.py b/solution/backend/common/models/gmodel.py index 6ac550d..c9c9e8c 100644 --- a/solution/backend/common/models/gmodel.py +++ b/solution/backend/common/models/gmodel.py @@ -70,11 +70,15 @@ class UserInfo(StructModel): user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 id: str # users.id (로그인 아이디) — get_me 재조회 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 + token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조) def __init__(self, *args, **kwargs) -> None: super().__init__() - # 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다. + # 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 + # 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛 + # 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다. self.role = UserRole.USER.value + self.token_version = 1 for dictionary in args: for key in dictionary: setattr(self, key, dictionary[key]) diff --git a/solution/backend/crud/alert_crud.py b/solution/backend/crud/alert_crud.py new file mode 100644 index 0000000..bb4a651 --- /dev/null +++ b/solution/backend/crud/alert_crud.py @@ -0,0 +1,74 @@ +"""alert_outbox 원장 접근. services/alert_service.py 가 부른다.""" +from sqlalchemy import func, select, update + +from common.database.model.models import alert_outbox +from common.enums import AlertStatus +from common.utils.gtime import GTime + + +async def latest_unresolved(session, dedupe_key: str): + """이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None. + + ★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를 + 쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다.""" + result = await session.execute( + select(alert_outbox) + .where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False), + alert_outbox.resolved_at.is_(None)) + .order_by(alert_outbox.created_at.desc()) + .limit(1) + ) + return result.scalars().first() + + +async def insert(session, values: dict) -> alert_outbox: + row = alert_outbox(**values) + session.add(row) + await session.flush() + return row + + +async def due_pending(session, limit: int = 20): + """★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한 + GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도 + 컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수 + 있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다.""" + result = await session.execute( + select(alert_outbox) + .where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False), + alert_outbox.next_attempt_at <= func.now()) + .order_by(alert_outbox.next_attempt_at) + .limit(limit) + ) + return result.scalars().all() + + +async def mark_sent(session, alert_id) -> None: + now = GTime.UTC() + await session.execute( + update(alert_outbox).where(alert_outbox.alert_id == alert_id) + .values(status=AlertStatus.SENT.value, sent_at=now, updated_at=now) + ) + + +async def mark_retry(session, alert_id, attempts: int, next_attempt_at) -> None: + await session.execute( + update(alert_outbox).where(alert_outbox.alert_id == alert_id) + .values(attempts=attempts, next_attempt_at=next_attempt_at, updated_at=GTime.UTC()) + ) + + +async def mark_exhausted(session, alert_id, attempts: int) -> None: + """재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다).""" + await session.execute( + update(alert_outbox).where(alert_outbox.alert_id == alert_id) + .values(status=AlertStatus.FAILED.value, attempts=attempts, updated_at=GTime.UTC()) + ) + + +async def mark_resolved(session, alert_id) -> None: + now = GTime.UTC() + await session.execute( + update(alert_outbox).where(alert_outbox.alert_id == alert_id) + .values(resolved_at=now, updated_at=now) + ) diff --git a/solution/backend/crud/job_crud.py b/solution/backend/crud/job_crud.py index 1318a96..35dbe67 100644 --- a/solution/backend/crud/job_crud.py +++ b/solution/backend/crud/job_crud.py @@ -173,9 +173,12 @@ class JobQueue: return await self._tx(run) - async def reap(self) -> list[str]: + async def reap(self) -> list[dict]: """만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD. - 회수된 job_id 목록 반환.""" + + 회수된 잡마다 {job_id, job_type, status, last_error} 를 돌려준다 — worker/runner.py 의 + run_reaper 가 이 중 DEAD(4) 로 떨어진 것만 골라 알린다(alert_service). job_id 목록만 + 돌려주던 예전 모양보다 한 겹 더 있는 이유가 그것뿐이다.""" sql = text(""" UPDATE jobs SET status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, @@ -185,12 +188,15 @@ class JobQueue: worker_id = NULL, updated_at = now() WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now() - RETURNING job_id + RETURNING job_id, job_type, status, last_error """) async def run(s): rows = (await s.execute(sql)).all() - return [str(r[0]) for r in rows] + return [ + {"job_id": str(r[0]), "job_type": r[1], "status": r[2], "last_error": r[3]} + for r in rows + ] return await self._tx(run) diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index 6fdb109..ca4203c 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -1,11 +1,13 @@ import time from contextlib import asynccontextmanager -from fastapi import FastAPI, Request +from fastapi import FastAPI, Request, Response from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware +from sqlalchemy import text from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType, DBWRType from common.logger import LOG from common.utils.gtime import GTime from config.server_configs import web_server_config @@ -81,6 +83,29 @@ async def healthz(): return API_SERVER_START_TIME +@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}}) +async def readyz(response: Response): + """★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), + 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다). + + ★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도 + 같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를 + 주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가 + 2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams + webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.""" + try: + async def _ping(s): + await s.execute(text("SELECT 1")) + return True + + await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _ping) + return {"ok": True, "db": "up"} + except Exception as ex: # noqa: BLE001 — 준비 안 됐다는 것 자체가 이 엔드포인트의 응답이다 + LOG.w(f"[readyz] DB 연결 확인 실패: {type(ex).__name__}: {ex}") + response.status_code = 503 + return {"ok": False, "db": "down"} + + # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.place.place.router) diff --git a/solution/backend/scheduler/__init__.py b/solution/backend/scheduler/__init__.py index 53fc730..0247dd5 100644 --- a/solution/backend/scheduler/__init__.py +++ b/solution/backend/scheduler/__init__.py @@ -2,7 +2,13 @@ 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. -등록된 잡: Search Console (GSC_ENABLED=1, 10분마다). 붙을 잡 — +등록된 잡: + · Search Console (GSC_ENABLED=1, 10분마다) + · 알림 발송 스윕 (1분마다) — alert_outbox 의 PENDING 을 실제로 보낸다 + · 잡 큐 정체 점검 (5분마다) — dead-letter 누적·좀비 실행·오래 밀린 PENDING 을 본다 + 둘 다 무조건 등록한다 — TEAMS_WEBHOOK_URL 이 비어 있으면 알림은 쌓이기만 하고 안 나간다 + (services/teams_webhook.is_configured), 서버 동작에는 영향이 없다. + 붙을 잡 — · 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신 · 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림) · 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지) @@ -37,6 +43,12 @@ def start_scheduler(): from services.search_console_service import run_scheduled_check _scheduler.add_job(run_scheduled_check, "interval", minutes=10, id="search-console", max_instances=1, coalesce=True) + + from scheduler.jobs import sweep_alert_outbox, sweep_queue_health + _scheduler.add_job(sweep_alert_outbox, "interval", minutes=1, + id="alert-outbox", max_instances=1, coalesce=True) + _scheduler.add_job(sweep_queue_health, "interval", minutes=5, + id="queue-health", max_instances=1, coalesce=True) _scheduler.start() LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)") diff --git a/solution/backend/scheduler/jobs.py b/solution/backend/scheduler/jobs.py index 412bf92..8e756c0 100644 --- a/solution/backend/scheduler/jobs.py +++ b/solution/backend/scheduler/jobs.py @@ -1,5 +1,54 @@ """스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일. 잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다. -(아직 등록된 잡 없음 — 지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.) +(지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.) """ +from common.logger import LOG + + +async def sweep_alert_outbox(): + """대기 중인 알림을 실제로 보낸다(services/alert_service.process_outbox).""" + from services import alert_service + + try: + await alert_service.process_outbox() + except Exception as ex: # noqa: BLE001 — 스윕 실패가 스케줄러를 죽이면 안 된다(다음 주기 재시도) + LOG.w(f"[scheduler] 알림 발송 스윕 실패: {type(ex).__name__}: {ex}") + + +async def sweep_queue_health(): + """잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING. + + ★ 왜 필요한가: 개별 잡의 DEAD 전이는 worker/runner.py 가 그 자리에서 바로 알린다. 이건 + 그것과 다른 신호다 — 잡 하나하나는 재시도 중(아직 DEAD 아님)인데 **큐 전체가 정체**된 + 경우(워커 프로세스가 죽었거나 DB 순단이 길어지는 경우)는 개별 잡 알림만으로는 안 보인다. + ★ 복구되면 한 번만 알린다 — send_alert/resolve_alert 의 dedupe_key 가 그 판단을 한다.""" + from crud.job_crud import JobQueue + from services import alert_service + + try: + snap = await JobQueue().ops() + except Exception as ex: # noqa: BLE001 + LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}") + return + + # 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이 + # 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다). + problems = [] + if snap.get("dead_1h", 0) > 0: + problems.append(f"최근 1시간 dead-letter {snap['dead_1h']}건") + if snap.get("stuck_running", 0) > 0: + problems.append(f"좀비 실행 {snap['stuck_running']}건(lease 만료 또는 10분 초과)") + if snap.get("oldest_pending_sec", 0) > 1800: + problems.append(f"가장 오래된 대기 잡이 {snap['oldest_pending_sec'] // 60}분째 안 집힘") + + dedupe_key = "queue_health" + if problems: + await alert_service.send_alert( + kind="queue_stuck", + title="잡 큐 정체", + detail=" · ".join(problems) + f"\n{snap}", + dedupe_key=dedupe_key, + ) + else: + await alert_service.resolve_alert(dedupe_key, "잡 큐 정상으로 돌아옴") diff --git a/solution/backend/services/alert_service.py b/solution/backend/services/alert_service.py new file mode 100644 index 0000000..4cd5a5a --- /dev/null +++ b/solution/backend/services/alert_service.py @@ -0,0 +1,162 @@ +"""장애 알림 — 영구 저장 + 재시도 + 중복 억제. + +★ 왜 이 모양인가 + 잡 큐 소진(JobStatus.DEAD) · BUILD 잡의 업무 실패(게이트 반려가 아닌 렌더·인프라 실패) · + 노래 같은 곁가지의 부분 실패 · 잡 큐 정체를 Teams 로 알린다. 알림을 만드는 자리(worker/runner.py · + build_service.py · scheduler)는 이 모듈의 send_alert() 하나만 부르면 된다 — 언제 실제로 + 보낼지, 같은 사유를 몇 번이나 다시 보낼지는 전부 여기서 정한다. + +★ 재시도마다 중복 스팸을 내지 않는다 (dedupe) + 같은 dedupe_key 로 "아직 안 풀린" 알림이 있으면 새로 만들지 않는다 — 잡이 몇 번을 실패하며 + 재큐되든 사람에게는 처음 한 통만 간다. 문제가 사라지면(resolve_alert) 그 dedupe_key 는 + 다시 "풀린" 상태가 되고, 다음에 같은 사유가 또 터지면 새로 알린다. + +★ 영구 저장 + 재시도 (outbox) + webhook 전송이 그 자리에서 실패해도(네트워크 순단 등) 알림 자체를 잃지 않는다 — DB 에 + PENDING 으로 남기고 process_outbox() 가 백오프를 두고 다시 시도한다. 워커·API 프로세스가 + 재시작돼도 이 표만 보면 뭐가 안 나갔는지 안다. + +★ 비밀·개인정보를 남기지 않는다 (scrub) + detail 은 저장 **전에** 한 번 걸러진다 — 외부 API 예외 메시지가 쿼리스트링에 키를 실어 + 보내는 경우가 있다(TourAPI·Suno 등). 전화번호·API 키·bearer 토큰·이메일을 마스킹한다. + +★ webhook 미설정이면 조용히 아무 일도 안 한다(teams_webhook.is_configured). 서버는 그대로 뜬다. +""" +import os +import re +from datetime import timedelta + +from common.database.db_session_manager import DB_SESSION_MNG +from common.enums import DBType +from common.logger import LOG +from common.utils.gtime import GTime +from crud import alert_crud +from crud.job_crud import compute_backoff +from services import teams_webhook + +# 중복 억제 창(분). 이 시간 안에 같은 dedupe_key 로 또 send_alert 가 불리면 새로 만들지 않는다. +DEDUPE_WINDOW_MIN_ENV = "ALERT_DEDUPE_WINDOW_MIN" +DEFAULT_DEDUPE_WINDOW_MIN = 60 +# 재시도 상한. 소진되면 AlertStatus.FAILED — 더 자동으로는 안 보낸다. +MAX_ATTEMPTS = 5 + +_DETAIL_MAX_LEN = 2000 + +# ── 비밀·개인정보 마스킹 ────────────────────────────────────────────────── +_RE_QUERY_SECRET = re.compile( + r"(?i)([?&](?:key|token|api[_-]?key|secret|access[_-]?token|auth)=)[^\s&]+" +) +_RE_BEARER = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9\-_.]{8,}") +_RE_KV_SECRET = re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key)\s*[:=]\s*\S+") +_RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}") + + +def _scrub(text: str) -> str: + """저장 전에 반드시 한 번 거친다. 순서가 중요하다 — 쿼리스트링을 먼저 지워야 + 그 값이 이메일 형태여도 뒤의 이메일 마스킹이 이중으로 손대지 않는다.""" + if not text: + return "" + out = _RE_QUERY_SECRET.sub(r"\1***", text) + out = _RE_BEARER.sub("Bearer ***", out) + out = _RE_KV_SECRET.sub(lambda m: f"{m.group(1)}=***", out) + out = _RE_EMAIL.sub(lambda m: m.group(0)[:2] + "***@***", out) + return out[:_DETAIL_MAX_LEN] + + +def _dedupe_window_min() -> int: + try: + return int(os.environ.get(DEDUPE_WINDOW_MIN_ENV) or DEFAULT_DEDUPE_WINDOW_MIN) + except ValueError: + return DEFAULT_DEDUPE_WINDOW_MIN + + +async def send_alert(kind: str, title: str, detail: str = "", dedupe_key: str | None = None) -> None: + """알림을 큐에 넣는다(즉시 보내지 않는다 — process_outbox 가 보낸다). + + ★ 즉시 안 보내는 이유: 이 함수는 워커의 실패 처리 경로(예외 발생 지점)에서 불린다. + 여기서 동기적으로 webhook 을 때리면 그 지연·재시도가 잡 처리 자체를 늦춘다. 큐에 + 적재만 하고 별도 스윕(scheduler)이 실제 전송을 맡는다 — 알림 발송 실패가 발행 + 파이프라인에 영향을 주지 않는다(파일 머리주석의 관심사 분리).""" + try: + async def _op(session): + if dedupe_key: + existing = await alert_crud.latest_unresolved(session, dedupe_key) + if existing is not None: + return # 이미 이 사유로 풀리지 않은 알림이 있다 — 또 만들지 않는다. + await alert_crud.insert(session, { + "kind": kind[:50], + "dedupe_key": dedupe_key[:200] if dedupe_key else None, + "title": title[:200], + "detail": _scrub(detail), + }) + + await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _op) + except Exception as ex: # noqa: BLE001 — 알림 적재 실패가 원래 하던 일(잡 처리)을 죽이면 안 된다 + LOG.w(f"[alert] 적재 실패(무시하고 계속): {type(ex).__name__}: {ex}") + + +async def resolve_alert(dedupe_key: str, title: str, detail: str = "") -> None: + """이 dedupe_key 로 안 풀린 알림이 있으면 "복구됨" 을 한 번 알리고 풀린 것으로 남긴다. + + ★ 안 풀린 알림이 없으면(애초에 문제가 없었다) 아무것도 하지 않는다 — 정상 상태마다 + "복구됨" 을 보내면 그게 새로운 스팸이 된다.""" + try: + async def _op(session): + existing = await alert_crud.latest_unresolved(session, dedupe_key) + if existing is None: + return + await alert_crud.mark_resolved(session, existing.alert_id) + await alert_crud.insert(session, { + "kind": "recovery", + "dedupe_key": None, # 복구 알림 자신은 dedupe 대상이 아니다 — 매번 보낸다. + "title": title[:200], + "detail": _scrub(detail), + }) + + await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _op) + except Exception as ex: # noqa: BLE001 + LOG.w(f"[alert] 복구 알림 적재 실패(무시하고 계속): {type(ex).__name__}: {ex}") + + +async def process_outbox(limit: int = 20) -> dict: + """PENDING 알림을 실제로 보낸다. 스케줄러가 주기적으로 부른다(scheduler/jobs.py). + + ★ 잡 큐의 백오프·소진 규칙(crud/job_crud.compute_backoff)을 그대로 재사용한다 — + "몇 번 실패하면 얼마나 쉬고 언제 포기하나" 를 두 번 설계하지 않는다.""" + sent = failed = 0 + try: + async def _load(session): + return await alert_crud.due_pending(session, limit) + + due = await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _load) + except Exception as ex: # noqa: BLE001 + LOG.w(f"[alert] outbox 조회 실패: {type(ex).__name__}: {ex}") + return {"sent": 0, "failed": 0} + + for row in due: + ok = await teams_webhook.send(row.title, row.detail or "") + + async def _update(session, row=row, ok=ok): + if ok: + await alert_crud.mark_sent(session, row.alert_id) + else: + attempts = row.attempts + 1 + if attempts >= MAX_ATTEMPTS: + await alert_crud.mark_exhausted(session, row.alert_id, attempts) + else: + next_at = GTime.UTC() + timedelta(seconds=compute_backoff(attempts)) + await alert_crud.mark_retry(session, row.alert_id, attempts, next_at) + + try: + await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _update) + except Exception as ex: # noqa: BLE001 + LOG.w(f"[alert] outbox 갱신 실패 {row.alert_id}: {type(ex).__name__}: {ex}") + continue + if ok: + sent += 1 + else: + failed += 1 + + if sent or failed: + LOG.i(f"[alert] outbox 스윕 — 전송 {sent}건 · 재시도/소진 {failed}건") + return {"sent": sent, "failed": failed} diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 34f6993..110890e 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -76,6 +76,7 @@ class AuthService: user_id=str(user.user_id), id=user.id, role=user.role, + token_version=user.token_version, ) async def _finish_login(self, user: users) -> Res_Login: @@ -316,6 +317,10 @@ class AuthService: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res data["password"] = await GetHashedPW(data["password"]) + # ★ 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면 + # 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도 + # 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다). + data["token_version"] = (me.token_version or 1) + 1 else: data.pop("password", None) # 빈 문자열은 NULL 로 저장(미입력 = 값 비움). @@ -336,8 +341,35 @@ class AuthService: return await self.get_me(user_info) async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: + """refresh 토큰 → 새 access 토큰. + + ★ 서명·만료만 보고 DB 를 한 번도 안 읽던 자리다 — 비밀번호를 바꾸거나 계정을 + 막아도, 이미 나간 refresh 토큰(7일)은 만료 전까지 계속 새 access 토큰을 찍어냈다. + 여기서 최신 DB 상태를 한 번 대조한다: 이 토큰의 token_version 이 지금 값과 + 다르면(bump_token_version 이 불렸다는 뜻) 재발급을 거절한다.""" res = Res_RefreshToken() - # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. + # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료). user_info = DecodeRefreshToken(refresh_token) - res.access_token = CreateAccessToken(user_info) + + err_type, user = await DB_SESSION_MNG.execute_lambda( + users.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_user_by_login_id(s, user_info.id), + ) + if err_type != ErrorType.SUCCESS or user is None: + res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND) + return res + user: users + + if user.status != UserStatus.ACTIVE.value: + res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER) + return res + # ★ 구버전 토큰(token_version 없이 발급됨)은 UserInfo 기본값 1 로 읽힌다 — DB 컬럼 + # 기본값도 1 이라 배포 직후에는 전부 통과한다. bump 가 불린 뒤에만 갈린다. + if user_info.token_version != user.token_version: + res.result.SetResult(ErrorType.ACCOUNT_SESSION_REVOKED) + return res + + # ★ 최신 DB 값으로 다시 만든다 — role 이 바뀌었으면 그것도 여기서 따라온다. + res.access_token = CreateAccessToken(self._user_info(user)) return res diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 34ff08d..d4527f7 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -30,6 +30,7 @@ from common.utils.gtime import GTime from crud.site_crud import SiteCRUD from crud.place_crud import PlaceCRUD from services import ( + alert_service, azure_static, indexnow, publish_gate, @@ -155,6 +156,15 @@ async def run_build(job: dict) -> dict: except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다 song_result = {"error": f"{type(ex).__name__}: {ex}"} LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}") + # ★ 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도 + # 아니다 — 별도 종류(partial_failure)로 알린다. 발행이 실패한 게 아니라는 걸 + # 운영자가 첫 줄만 보고 알아야 한다. + await alert_service.send_alert( + kind="partial_failure", + title=f"노래 생성 실패(발행은 계속) — {place_id}", + detail=f"place_id={place_id}\n{song_result['error']}", + dedupe_key=f"song_failed:{place_id}", + ) # ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 — # 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories). @@ -226,6 +236,15 @@ async def run_build(job: dict) -> dict: result["build_status"] = "FAILED" result["error"] = reason LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}") + # ★ 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고 + # 운영자를 부르면 안 된다. 여기서 알리는 건 렌더·인프라가 죽은 "업무 실패"뿐이다. + if gate is None: + await alert_service.send_alert( + kind="build_failed", + title=f"발행 실패 — {place_name or place_id}", + detail=f"place_id={place_id} v{version_no}\n{reason}", + dedupe_key=f"build_failed:{place_id}", + ) return result # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- @@ -348,6 +367,10 @@ async def run_build(job: dict) -> dict: ) result["build_status"] = "BUILT" result["routes"] = report.get("routes") + # ★ 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면 + # 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). 알림이 없었으면 resolve_alert 가 조용히 + # 아무것도 안 한다(파일 머리주석). + await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}") if want_publish: # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 8474819..71bf285 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -5,6 +5,7 @@ import re import uuid +from common import collect_diagnostics from common.database.db_session_manager import DB_SESSION_MNG from common.database.model.models import place_facts as facts_model from common.database.model.models import place_photos, place_channels, places, place_units @@ -124,7 +125,7 @@ async def discover_official_site(place, place_id: str) -> str: try: candidates = await client.search_local(query) except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 - LOG.w(f"[collect] 지역검색 실패(계속) {query!r}: {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("naver_local_search", query, ex) return "not_found" match = naver_client.pick_match(place.name, candidates, address) @@ -168,7 +169,7 @@ async def discover_tour_api(place, place_id: str) -> str: longitude=place.longitude, ) except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 - LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("tour_api", place.name, ex) return "error" if not found: @@ -215,7 +216,7 @@ async def discover_yanolja(place, place_id: str) -> str: try: found = await yanolja_adapter.search_by_address(address) except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 - LOG.w(f"[collect] 야놀자 검색 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("yanolja_search", place.name, ex) return "error" if not found: @@ -254,7 +255,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal if stat["naver_place"] == "resolved": stat["discovered"] += 1 except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 - LOG.w(f"[collect] 네이버 플레이스 조회 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("naver_place", place.name, ex) stat["naver_place"] = "error" # TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다. @@ -264,7 +265,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal if stat["tour_api"] == "resolved": stat["discovered"] += 1 except Exception as ex: # noqa: BLE001 - LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("tour_api", place.name, ex) stat["tour_api"] = "error" # 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석). @@ -274,7 +275,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal if stat["official_site"] == "resolved": stat["discovered"] += 1 except Exception as ex: # noqa: BLE001 - LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("official_site", place.name, ex) stat["official_site"] = "error" # 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고). @@ -284,7 +285,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal if stat["yanolja"] == "resolved": stat["discovered"] += 1 except Exception as ex: # noqa: BLE001 - LOG.w(f"[collect] 야놀자 조회 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("yanolja", place.name, ex) stat["yanolja"] = "error" # 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도 @@ -309,7 +310,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal return stat except perplexity.PerplexityError as ex: # ★ 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다. - LOG.w(f"[collect] URL 발견 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("perplexity_discover", place.name, ex) stat["error"] = str(ex)[:200] return stat @@ -421,10 +422,10 @@ async def fetch_one(link): try: source = await adapter.fetch(link.url) except Exception as ex: - LOG.w(f"[collect] 수집 실패(계속) {link.url}: {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("fetch", link.url, ex) return None, "failed" if not source.ok: - LOG.w(f"[collect] 수집 실패(계속) {link.url}: {source.error}") + collect_diagnostics.note_issue("fetch", link.url, RuntimeError(source.error)) return None, "failed" return source, "fetched" @@ -550,6 +551,15 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict: # ---- 오케스트레이션 -------------------------------------------------------- async def run_collect(job: dict) -> dict: """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" + with collect_diagnostics.collecting(): + result = await _run_collect(job) + issues = collect_diagnostics.snapshot() + if issues: + result["issues"] = issues + return result + + +async def _run_collect(job: dict) -> dict: payload = job["payload"] place_id = payload["place_id"] owner_user_id = payload["owner_user_id"] @@ -682,7 +692,7 @@ async def run_collect(job: dict) -> dict: from services import place_research result["research"] = await place_research.research_place(place, place_id) except Exception as ex: # noqa: BLE001 - LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}") + collect_diagnostics.note_issue("place_research", place.name, ex) result["research"] = {"error": f"{type(ex).__name__}: {ex}"} await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index 848a5cd..976a1fd 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -1,4 +1,5 @@ """COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text.""" +from common.logger import LOG from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs from services.external import gemini_text from services.job_progress import JobProgress @@ -20,8 +21,16 @@ async def run_copy(job: dict) -> dict: if inputs.catalog is None and not inputs.ungrounded: raise CopyAborted(note) else: - async with progress.step("generate"): - copy = await generate_copy(inputs) + try: + async with progress.step("generate"): + copy = await generate_copy(inputs) + except gemini_text.GeminiError as ex: + # ★ 호출 실패는 미설정과 같은 취급이다 — fact 만으로도 편집·발행이 되고 + # (publish_gate.check_unique_content), 발행은 고유 콘텐츠 0건으로 막지 않는다. + # 자세한 배경은 DEVLOG.md 참고. + note = f"생성 호출 실패: {ex}" + LOG.w(f"[copy] 생성 실패, fact 만으로 계속: {ex}") + await progress.skip("generate", "generation_failed") async with progress.step("save"): result = await save_copy(inputs, copy) diff --git a/solution/backend/services/rollback_service.py b/solution/backend/services/rollback_service.py index 86d2dca..4b6a005 100644 --- a/solution/backend/services/rollback_service.py +++ b/solution/backend/services/rollback_service.py @@ -37,7 +37,7 @@ from common.logger import LOG from common.utils.gtime import GTime from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD -from services import azure_static, indexnow, publish_gate, render_service, site_payload +from services import alert_service, azure_static, indexnow, publish_gate, render_service, site_payload from services.build_service import ensure_site, load_channel_links _site_crud = SiteCRUD() @@ -104,6 +104,15 @@ async def run_rollback(job: dict) -> dict: result["rolled_back"] = False result["error"] = reason LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {reason}") + # ★ 게이트 반려는 알리지 않는다 — build_service._fail 과 같은 규칙(운영자를 부를 + # 일이 아니다). 렌더·전환·업로드가 죽은 경우만 알린다. + if gate is None: + await alert_service.send_alert( + kind="build_failed", + title=f"롤백 실패 — {place_id} → v{target_version}", + detail=f"place_id={place_id} target_version={target_version}\n{reason}", + dedupe_key=f"build_failed:{place_id}", + ) return result # ★ snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). @@ -152,6 +161,7 @@ async def run_rollback(job: dict) -> dict: sites.DBType(), lambda s: _site_crud.update_site(s, site.site_id, site_update) ) await _log(site.site_id, version.site_version_id, PublishResult.SUCCESS, None, payload.get("requested_by")) + await alert_service.resolve_alert(f"build_failed:{place_id}", f"롤백 성공 — {place_id} → v{target_version}") result["rolled_back"] = True LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") diff --git a/solution/backend/services/teams_webhook.py b/solution/backend/services/teams_webhook.py new file mode 100644 index 0000000..ee0dba5 --- /dev/null +++ b/solution/backend/services/teams_webhook.py @@ -0,0 +1,69 @@ +"""Microsoft Teams Workflows(수신 webhook) 로 어댑티브 카드 한 장을 보낸다. + +★ 이 파일은 "HTTP 로 카드 하나 보내기" 딱 그것만 안다 — 언제 보낼지·무엇을 보낼지는 + services/alert_service.py 가 정한다(관심사 분리, 재사용). search_console_alerts.py 가 + 쓰는 별도의 좁은 어댑터와는 다른 자리다 — 그건 색인 감시 전용이고 이건 잡 큐·발행 전반의 + 장애 알림 전용이다. 카드 포맷이 같은 이유로 합치자는 제안이 오면, 두 기능의 배포 주기가 + 다르다는 것과(색인 감시는 스케줄러 전용, 이건 워커 코드 곳곳에서 부른다) 지금 결합 이득이 + 적다는 것을 근거로 우선 보류한다. + +★ webhook 이 설정 안 됐으면 보내지 않는다(is_configured). 값이 없어도 서버는 그대로 뜬다 — + 운영 연결(실제 채널 지정)은 사용자 승인 후 별도로 한다. +""" +import os + +import httpx + +from common.logger import LOG + +WEBHOOK_URL_ENV = "TEAMS_WEBHOOK_URL" +TIMEOUT_SEC = 10.0 + + +def is_configured() -> bool: + return bool(os.environ.get(WEBHOOK_URL_ENV, "").strip()) + + +def _webhook_url() -> str: + return os.environ.get(WEBHOOK_URL_ENV, "").strip() + + +def _card(title: str, detail: str) -> dict: + """Adaptive Card 1.2 — Teams Workflows 가 받는 최소 모양(search_console_alerts.py 와 같은 스키마).""" + return { + "type": "message", + "attachments": [{ + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": None, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.2", + "body": [ + {"type": "TextBlock", "text": title, "weight": "Bolder", "wrap": True}, + {"type": "TextBlock", "text": detail, "wrap": True}, + ], + }, + }], + } + + +async def send(title: str, detail: str) -> bool: + """설정된 경우에만 보낸다. 실패는 로그로 남기고 삼킨다 — 호출측(alert_service)이 재시도를 관리한다. + + ★ webhook 주소 자체가 인증 수단이다(URL 에 서명이 박혀 있다) — 예외 문자열에 그 URL 이 + 실릴 수 있어 로그에는 안 남긴다(search_console_alerts.py 와 같은 규칙).""" + url = _webhook_url() + if not url: + return False + if not url.startswith("https://"): + LOG.w("[alert] TEAMS_WEBHOOK_URL_INVALID — https 가 아니다") + return False + try: + async with httpx.AsyncClient(timeout=TIMEOUT_SEC, follow_redirects=False) as client: + response = await client.post(url, json=_card(title, detail)) + response.raise_for_status() + return True + except httpx.HTTPError: + LOG.w("[alert] TEAMS_DELIVERY_FAILED") + return False diff --git a/solution/backend/tests/test_alert_service.py b/solution/backend/tests/test_alert_service.py new file mode 100644 index 0000000..9944f08 --- /dev/null +++ b/solution/backend/tests/test_alert_service.py @@ -0,0 +1,206 @@ +"""알림 발송함 — 적재(dedupe) · 발송(재시도/소진) · 복구 · 비밀 마스킹. + +★ 이 파일이 절대 하면 안 되는 것 확인: + - 재시도마다 중복 스팸을 내는 것 (dedupe) + - webhook URL·비밀번호·이메일 원문을 detail 에 그대로 남기는 것 (scrub) + - TEAMS_WEBHOOK_URL 이 비었을 때 예외를 던지는 것 (미설정 시 정상 동작) +""" +import json + +import httpx +from sqlalchemy import text + +from services import alert_service, teams_webhook + + +def _mock_webhook(monkeypatch, handler): + """teams_webhook 의 실제 HTTP 호출을 대역으로 바꾼다(네트워크를 타지 않는다).""" + real_client = httpx.AsyncClient + + def make_client(**kw): + return real_client(transport=httpx.MockTransport(handler), **kw) + + monkeypatch.setattr(teams_webhook.httpx, "AsyncClient", make_client) + + +async def _rows(db_engine, kind: str | None = None): + async with db_engine.begin() as c: + sql = "SELECT kind, dedupe_key, title, detail, status, attempts, resolved_at FROM alert_outbox" + params = {} + if kind: + sql += " WHERE kind = :k" + params["k"] = kind + sql += " ORDER BY created_at" + result = await c.execute(text(sql), params) + return [dict(r._mapping) for r in result] + + +# ── 적재 · 중복 억제 ───────────────────────────────────────────────────────── +async def test_send_alert_creates_pending_row(db_engine): + await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k1") + rows = await _rows(db_engine, "job_dead") + assert len(rows) == 1 + assert rows[0]["status"] == 1 # AlertStatus.PENDING + assert rows[0]["dedupe_key"] == "k1" + + +async def test_send_alert_dedupes_within_window(db_engine): + """검증: 같은 dedupe_key 로 두 번 연속 보낸다. + 기대결과: ★ 행이 하나만 생긴다 — 재시도마다 중복 스팸을 내면 안 된다.""" + await alert_service.send_alert("build_failed", "발행 실패", "1차", dedupe_key="k2") + await alert_service.send_alert("build_failed", "발행 실패", "2차", dedupe_key="k2") + rows = await _rows(db_engine, "build_failed") + assert len(rows) == 1 + assert rows[0]["detail"] == "1차" # 처음 것만 남는다(두 번째는 만들지 않았다) + + +async def test_send_alert_without_dedupe_key_always_creates(db_engine): + """dedupe_key 가 없으면(예: 복구 알림) 매번 새로 쌓인다.""" + await alert_service.send_alert("recovery", "복구", "a") + await alert_service.send_alert("recovery", "복구", "b") + rows = await _rows(db_engine, "recovery") + assert len(rows) == 2 + + +# ── 복구 ──────────────────────────────────────────────────────────────────── +async def test_resolve_alert_marks_resolved_and_sends_recovery_notice(db_engine): + await alert_service.send_alert("queue_stuck", "정체", "사유", dedupe_key="k3") + await alert_service.resolve_alert("k3", "정상으로 돌아옴") + + original = await _rows(db_engine, "queue_stuck") + assert original[0]["resolved_at"] is not None + + recovered = await _rows(db_engine, "recovery") + assert len(recovered) == 1 + assert recovered[0]["title"] == "정상으로 돌아옴" + + +async def test_resolve_alert_is_noop_when_nothing_unresolved(db_engine): + """검증: 알린 적 없는 dedupe_key 를 resolve. + 기대결과: ★ 아무 행도 안 생긴다 — 정상 상태마다 "복구됨" 을 보내면 그게 새 스팸이다.""" + await alert_service.resolve_alert("never-alerted", "정상") + rows = await _rows(db_engine, "recovery") + assert rows == [] + + +async def test_send_alert_after_resolve_creates_new_row(db_engine): + """검증: 한 번 풀린(resolved) dedupe_key 로 다시 보낸다. + 기대결과: 새 문제로 보고 새 행을 만든다 — 옛 resolved 행과 헷갈리지 않는다.""" + await alert_service.send_alert("build_failed", "실패1", "x", dedupe_key="k4") + await alert_service.resolve_alert("k4", "복구1") + await alert_service.send_alert("build_failed", "실패2", "y", dedupe_key="k4") + + rows = await _rows(db_engine, "build_failed") + assert len(rows) == 2 + assert rows[1]["detail"] == "y" + assert rows[1]["resolved_at"] is None + + +# ── 비밀·개인정보 마스킹 ────────────────────────────────────────────────────── +def test_scrub_redacts_query_string_secrets(): + out = alert_service._scrub("https://api.example.com/x?api_key=SECRET123&q=hi") + assert "SECRET123" not in out + assert "api_key=***" in out + + +def test_scrub_redacts_bearer_token(): + out = alert_service._scrub("Authorization: Bearer abcdef1234567890") + assert "abcdef1234567890" not in out + assert "Bearer ***" in out + + +def test_scrub_redacts_password_kv(): + out = alert_service._scrub("login failed password=hunter2hunter2") + assert "hunter2hunter2" not in out + + +def test_scrub_masks_email(): + out = alert_service._scrub("owner email: someone@example.com failed") + assert "someone@example.com" not in out + assert "@***" in out + + +def test_scrub_truncates_long_detail(): + out = alert_service._scrub("x" * 5000) + assert len(out) <= 2000 + + +# ── 발송 · 재시도 · 소진 ────────────────────────────────────────────────────── +async def test_process_outbox_sends_and_marks_sent(db_engine, monkeypatch): + monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook") + received = [] + + def handler(request: httpx.Request) -> httpx.Response: + received.append(json.loads(request.content)) + return httpx.Response(202) + + _mock_webhook(monkeypatch, handler) + + await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k5") + result = await alert_service.process_outbox() + + assert result == {"sent": 1, "failed": 0} + assert len(received) == 1 + rows = await _rows(db_engine, "job_dead") + assert rows[0]["status"] == 2 # AlertStatus.SENT + + +async def test_process_outbox_noop_when_webhook_unconfigured(db_engine, monkeypatch): + """검증: TEAMS_WEBHOOK_URL 이 비어 있을 때 스윕을 돌린다. + 기대결과: ★ 예외 없이 끝난다 — HTTP 호출 자체를 안 한다(teams_webhook.is_configured).""" + monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) + await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k6") + + result = await alert_service.process_outbox() + assert result["sent"] == 0 + # 실패로 잡혀 재시도 카운트가 올라간다(다음 스윕에서 다시 시도) — 예외로 죽지 않았다. + rows = await _rows(db_engine, "job_dead") + assert rows[0]["status"] == 1 # 여전히 PENDING(백오프 대기) + assert rows[0]["attempts"] == 1 + + +async def test_process_outbox_exhausts_after_max_attempts(db_engine, monkeypatch): + """검증: 계속 실패하는 webhook 으로 MAX_ATTEMPTS 만큼 스윕한다. + 기대결과: ★ 상한에 닿으면 FAILED 로 남고 더는 재시도 대상이 아니다.""" + monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook") + _mock_webhook(monkeypatch, lambda req: httpx.Response(500)) + + await alert_service.send_alert("job_dead", "잡 실패", "사유", dedupe_key="k7") + for _ in range(alert_service.MAX_ATTEMPTS): + # next_attempt_at 이 미래로 밀려도 여기선 process_outbox 가 직접 대상을 스윕하므로 + # 시간 경과를 흉내 낼 필요 없이 next_attempt_at 을 매번 과거로 되돌린다. + async with db_engine.begin() as c: + await c.execute(text("UPDATE alert_outbox SET next_attempt_at = now() - interval '1 second'")) + await alert_service.process_outbox() + + rows = await _rows(db_engine, "job_dead") + assert rows[0]["status"] == 3 # AlertStatus.FAILED(소진) + assert rows[0]["attempts"] == alert_service.MAX_ATTEMPTS + + +# ── Teams 카드 모양 ─────────────────────────────────────────────────────────── +async def test_teams_webhook_sends_adaptive_card(monkeypatch): + monkeypatch.setenv("TEAMS_WEBHOOK_URL", "https://example.test/webhook") + received = [] + + def handler(request: httpx.Request) -> httpx.Response: + received.append(json.loads(request.content)) + return httpx.Response(202) + + _mock_webhook(monkeypatch, handler) + ok = await teams_webhook.send("제목", "내용") + assert ok is True + card = received[0]["attachments"][0] + assert card["contentType"] == "application/vnd.microsoft.card.adaptive" + + +async def test_teams_webhook_refuses_non_https(monkeypatch): + monkeypatch.setenv("TEAMS_WEBHOOK_URL", "http://not-secure.test/webhook") + ok = await teams_webhook.send("제목", "내용") + assert ok is False + + +async def test_teams_webhook_noop_when_unset(monkeypatch): + monkeypatch.delenv("TEAMS_WEBHOOK_URL", raising=False) + ok = await teams_webhook.send("제목", "내용") + assert ok is False diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py index a3b0a84..13d69d0 100644 --- a/solution/backend/tests/test_auth.py +++ b/solution/backend/tests/test_auth.py @@ -157,3 +157,62 @@ async def test_google_login_is_off_when_client_id_is_empty(client, db_engine): 기대결과: 1106(OAUTH_NOT_CONFIGURED) — 네트워크를 타지 않고 즉시 끊긴다.""" r = await client.post("/v1/auth/google", json={"credential": "anything"}) assert r.json()["result"]["code"] == 1106 + + +# ── refresh 토큰 무효화(token_version) ──────────────────────────────────────── +async def test_refresh_token_reissues_access_token(auth_headers, client): + """검증: 정상적인 refresh 토큰으로 재발급. + 기대결과: 200, 새 access 토큰이 실려 온다.""" + h = await auth_headers("refuser1") + login = (await client.post("/v1/auth/login", json={"id": "refuser1", "password": "pw1234"})).json() + refresh_token = login["refresh_token"] + + r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {refresh_token}"}) + body = r.json() + assert body["result"]["success"] is True + assert body["access_token"] + # 새 access 토큰이 실제로 먹힌다. + me = (await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {body['access_token']}"})).json() + assert me["id"] == "refuser1" + del h # auth_headers 는 시드 용도로만 쓴다 + + +async def test_refresh_token_is_revoked_after_password_change(auth_headers, client): + """검증: refresh 토큰을 받은 **뒤에** 비밀번호를 바꾼다. + 기대결과: ★ ACCOUNT_SESSION_REVOKED — 예전 refresh 토큰으로는 더 이상 access 토큰을 못 찍는다. + (비밀번호를 훔쳐 넣어 둔 refresh 토큰이 있어도 비번을 바꾸면 끊긴다는 것이 이 테스트의 요점이다.)""" + h = await auth_headers("refuser2") + login = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "pw1234"})).json() + old_refresh_token = login["refresh_token"] + + upd = await client.patch("/v1/auth/me", headers=h, json={"password": "newpassword123"}) + assert upd.json()["result"]["success"] is True + + r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {old_refresh_token}"}) + body = r.json() + assert body["result"]["success"] is False + assert body["result"]["code"] == 1108, body # ACCOUNT_SESSION_REVOKED + assert body.get("access_token", "") == "" + + # ★ 새로 로그인하면(새 비밀번호로) 새 refresh 토큰은 당연히 먹힌다. + relogin = (await client.post("/v1/auth/login", json={"id": "refuser2", "password": "newpassword123"})).json() + r2 = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {relogin['refresh_token']}"}) + assert r2.json()["result"]["success"] is True + + +async def test_refresh_token_is_revoked_when_account_blocked(auth_headers, client, db_engine): + """검증: refresh 토큰을 받은 뒤 계정이 차단(UserStatus.INACTIVE)된다. + 기대결과: ★ ACCOUNT_BLOCKED_USER — 로그인만 막는 게 아니라 이미 나간 refresh 토큰도 막는다.""" + from sqlalchemy import text + + h = await auth_headers("refuser3") + login = (await client.post("/v1/auth/login", json={"id": "refuser3", "password": "pw1234"})).json() + del h + + async with db_engine.begin() as c: + await c.execute(text("UPDATE users SET status = 2 WHERE id = 'refuser3'")) # UserStatus.INACTIVE + + r = await client.post("/v1/auth/refresh_token", headers={"Authorization": f"Bearer {login['refresh_token']}"}) + body = r.json() + assert body["result"]["success"] is False + assert body["result"]["code"] == 1102 # ACCOUNT_BLOCKED_USER diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index a3b9493..ce82f35 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -313,6 +313,11 @@ async def test_렌더가_실패하면_발행하지_않는다(auth_headers, clien assert r.get("published") is not True assert "번들이 없다" in r["error"] + # ★ 게이트 반려가 아닌 진짜 실패(렌더·인프라)는 알린다 — services/alert_service. + async with db_engine.begin() as c: + rows = (await c.execute(text("SELECT kind FROM alert_outbox WHERE kind = 'build_failed'"))).all() + assert len(rows) == 1 + site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json() assert site["site"]["status"] != SiteStatus.PUBLISHED.value @@ -339,6 +344,11 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다( assert r["gate"]["reason"] == PublishRejectReason.JSONLD_MISMATCH.name assert r["mismatches"] + # ★ 게이트 반려는 알리지 않는다 — 사장님 쪽 문제를 운영자에게 알리면 안 된다. + async with db_engine.begin() as c: + rows = (await c.execute(text("SELECT kind FROM alert_outbox"))).all() + assert rows == [] + async def test_렌더_결과가_안_오면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch): """검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃). diff --git a/solution/backend/tests/test_healthz.py b/solution/backend/tests/test_healthz.py index d433702..5551cf6 100644 --- a/solution/backend/tests/test_healthz.py +++ b/solution/backend/tests/test_healthz.py @@ -10,3 +10,12 @@ async def test_healthz(client): r = await client.get("/healthz") assert r.status_code == 200 assert isinstance(r.json(), str) and len(r.json()) > 0 + + +async def test_readyz_checks_db(client, db_engine): + """검증: /readyz 호출(테스트 DB 가 붙어 있는 정상 상태). + 기대결과: ★ healthz 와 다르게 실제로 DB 에 SELECT 1 을 던져 본 뒤 200 — "프로세스가 + 살아 있다" 가 아니라 "요청을 처리할 수 있다" 를 본다(외부 감시용, router.router.readyz).""" + r = await client.get("/readyz") + assert r.status_code == 200 + assert r.json() == {"ok": True, "db": "up"} diff --git a/solution/backend/tests/test_job_queue.py b/solution/backend/tests/test_job_queue.py index 7678d6c..7f45523 100644 --- a/solution/backend/tests/test_job_queue.py +++ b/solution/backend/tests/test_job_queue.py @@ -160,7 +160,7 @@ async def test_reaper_reclaims_dead_worker_job(db_engine): ) reclaimed = await q.reap() - assert jid in reclaimed + assert jid in [r["job_id"] for r in reclaimed] row = await q.get(jid) assert row["status"] == JobStatus.PENDING.value @@ -236,6 +236,26 @@ async def test_unregistered_job_type_fails_loudly(db_engine): assert UnknownJobType.__name__ in row["last_error"] +async def test_dead_letter_creates_an_alert(db_engine): + """검증: 잡이 재시도를 소진해 DEAD 로 떨어진다. + 기대결과: ★ alert_outbox 에 job_dead 알림이 쌓인다 — 운영자가 잡 하나하나를 눈으로 + 훑지 않아도 dead-letter 를 안다(worker/runner.py _alert_job_dead).""" + q = JobQueue() + jid = await q.enqueue(JobType.AI_CHECK.value, {"place_id": "p-alert-1"}, max_attempts=1) + + worker = Worker("w-test", q, build_handler(), backoff_fn=lambda _a: 0) + await worker.process_one() + + row = await q.get(jid) + assert row["status"] == JobStatus.DEAD.value + + async with db_engine.begin() as c: + result = await c.execute(text("SELECT kind, dedupe_key, title FROM alert_outbox WHERE kind = 'job_dead'")) + rows = [dict(r._mapping) for r in result] + assert len(rows) == 1 + assert rows[0]["dedupe_key"] == "job_dead:AI_CHECK:p-alert-1" + + async def test_reaper_loop_stops_on_event(db_engine): """검증: reaper 루프에 stop 이벤트를 건다. 기대결과: 즉시 빠져나온다(graceful shutdown 이 매달리지 않는다).""" diff --git a/solution/backend/tests/test_search_console_service.py b/solution/backend/tests/test_search_console_service.py index 4ddea4b..e46e9a8 100644 --- a/solution/backend/tests/test_search_console_service.py +++ b/solution/backend/tests/test_search_console_service.py @@ -259,10 +259,15 @@ def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count): monkeypatch.setenv("SCHEDULER_ENABLED", "1") monkeypatch.setenv("GSC_ENABLED", enabled) scheduler.start_scheduler() - assert len(jobs) == count - if jobs: - assert jobs[0][1]["minutes"] == 10 - assert jobs[0][1]["max_instances"] == 1 + # ★ 알림 스윕 둘(alert-outbox·queue-health)은 GSC_ENABLED 와 무관하게 항상 등록된다 + # (scheduler/__init__.py, docs/ALERTS.md) — search-console 잡만 옵션이다. + always_on = {kw["id"] for _a, kw in jobs} - {"search-console"} + assert always_on == {"alert-outbox", "queue-health"} + assert len(jobs) == count + 2 + gsc_jobs = [kw for _a, kw in jobs if kw["id"] == "search-console"] + if gsc_jobs: + assert gsc_jobs[0]["minutes"] == 10 + assert gsc_jobs[0]["max_instances"] == 1 def test_migration_matches_fresh_database_schema(): diff --git a/solution/backend/worker/runner.py b/solution/backend/worker/runner.py index aa14ebc..4802cd1 100644 --- a/solution/backend/worker/runner.py +++ b/solution/backend/worker/runner.py @@ -12,10 +12,36 @@ import asyncio -from common.enums import JobStatus +from common.enums import JobStatus, JobType from common.job_errors import PermanentJobError from common.logger import LOG from crud.job_crud import JobQueue, compute_backoff +from services import alert_service + + +def _job_type_name(job_type: int) -> str: + try: + return JobType(job_type).name + except ValueError: + return str(job_type) + + +async def _alert_job_dead(job: dict, error: str) -> None: + """잡이 dead-letter 로 떨어졌다 — 재시도를 소진했다는 뜻이다(수동 개입 대상). + + ★ dedupe_key 는 "같은 대상이 반복해서 죽는가" 를 잡는다. job_id 는 잡마다 새로 생기므로 + 쓰지 않는다 — payload 의 place_id(대부분의 잡이 갖는 자연키)가 있으면 그걸 쓰고, + 없으면 job_type 만으로 묶는다(어느 쪽이든 완벽하진 않지만, 없는 것보다는 낫다).""" + job_type = job.get("job_type") + type_name = _job_type_name(job_type) + payload = job.get("payload") or {} + target = payload.get("place_id") or payload.get("region_code") or job.get("job_id", "") + await alert_service.send_alert( + kind="job_dead", + title=f"[{type_name}] 잡이 재시도를 소진했다(DEAD)", + detail=f"job_id={job.get('job_id')} attempts={job.get('attempts')}\n{error}", + dedupe_key=f"job_dead:{type_name}:{target}", + ) class Worker: @@ -84,19 +110,29 @@ class Worker: # 데드라인 초과 — wait_for 가 핸들러 태스크를 취소한 뒤 여기로 온다. # 행이 워커 슬롯을 영구 점유하는 것보다 낫다. backoff = self.backoff_fn(job["attempts"]) - st = await self.queue.fail(jid, self.worker_id, f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s", backoff) + reason = f"JobDeadlineExceeded: {self.job_deadline_sec:.0f}s" + st = await self.queue.fail(jid, self.worker_id, reason, backoff) LOG.w(f"[{self.worker_id}] deadline {jid} → {JobStatus(st).name if st else '?'} " f"({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)") + if st == JobStatus.DEAD.value: + await _alert_job_dead(job, reason) except PermanentJobError as ex: # ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석). # 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로 # 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다. - await self.queue.fail_permanent(jid, self.worker_id, f"{type(ex).__name__}: {ex}") - LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {type(ex).__name__}: {ex})") + # ★ fail_permanent 는 재시도 없이 곧장 DEAD 다 — 위 일반 실패 경로처럼 상태를 다시 + # 조회해 분기할 필요 없이 바로 알린다(job_crud.fail_permanent 주석 참고). + reason = f"{type(ex).__name__}: {ex}" + await self.queue.fail_permanent(jid, self.worker_id, reason) + LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {reason})") + await _alert_job_dead(job, reason) except Exception as ex: backoff = self.backoff_fn(job["attempts"]) - st = await self.queue.fail(jid, self.worker_id, f"{type(ex).__name__}: {ex}", backoff) + reason = f"{type(ex).__name__}: {ex}" + st = await self.queue.fail(jid, self.worker_id, reason, backoff) LOG.w(f"[{self.worker_id}] fail {jid} → {JobStatus(st).name if st else '?'} ({type(ex).__name__}: {ex})") + if st == JobStatus.DEAD.value: + await _alert_job_dead(job, reason) finally: hb.cancel() try: @@ -124,6 +160,9 @@ async def run_reaper(queue: JobQueue, stop: asyncio.Event, interval: float = 30. reclaimed = await queue.reap() if reclaimed: LOG.w(f"[reaper] reclaimed {len(reclaimed)} stale job(s)") + for job in reclaimed: + if job["status"] == JobStatus.DEAD.value: + await _alert_job_dead(job, job.get("last_error") or "lease 만료 후 재시도 소진") except Exception as ex: LOG.e_no_callstack(f"[reaper] {type(ex).__name__}: {ex}") try: diff --git a/solution/frontend/src/features/onboarding/Step5Generating.tsx b/solution/frontend/src/features/onboarding/Step5Generating.tsx index 3a397c9..ef276c5 100644 --- a/solution/frontend/src/features/onboarding/Step5Generating.tsx +++ b/solution/frontend/src/features/onboarding/Step5Generating.tsx @@ -1,10 +1,18 @@ import {Check, Loader2} from 'lucide-react'; import {JobStatus} from '@o2o/shared'; import {Button} from '@/components/ui/button'; +import {Progress} from '@/components/ui/progress'; +import {cn} from '@/lib/utils'; import {useBuilderStore} from '@/stores/builder'; import {useGenerationJob} from './useGenerationJob'; import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels'; +/* + * ★ 겉모습만 옛 화면 스타일이고 데이터는 실제 잡 진행 상태다. `useGenerationJob` + * (새로고침해도 jobId 로 이어서 봄, 실패 시 복구 버튼)을 그대로 쓰고, 카드 모양· + * 진행률 바·번호 동그라미 목록만 그 스타일로 그린다. 단계 문구는 실제로 이 COPY + * 잡이 하는 일(prepare/generate/save/faq_fill)만 적는다 — 자세한 배경은 DEVLOG.md. + */ export function Step5Generating() { const storeName = useBuilderStore((s) => s.storeName); const placeId = useBuilderStore((s) => s.placeId); @@ -12,47 +20,103 @@ export function Step5Generating() { const failed = job?.status === JobStatus.DEAD; const waiting = job?.status === JobStatus.PENDING; const steps = job?.progress?.steps ?? []; + const total = steps.length; + const doneCount = steps.filter((s) => s.status === 'done' || s.status === 'skipped').length; + const progress = total ? Math.min(100, Math.round((doneCount / total) * 100)) : 0; + const runningIndex = steps.findIndex((s) => s.status === 'running'); + const currentLabel = runningIndex >= 0 + ? GENERATION_LABELS[steps[runningIndex].id] ?? '콘텐츠 처리' + : job?.status === JobStatus.DONE ? '완료' + : '준비 중'; + const title = error ? '진행 상태 확인이 필요합니다' : failed ? '콘텐츠 생성을 완료하지 못했습니다' - : waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다') - : job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다' - : '소개문과 FAQ를 만들고 있습니다'; + : waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다') + : job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다' + : '웹사이트를 생성하고 있습니다'; return ( -
-
- {!error && !failed && } -

{title}

-

- {error || <>{storeName}의 확인된 정보를 바탕으로 작성합니다.} +

+
+
+ {!error && !failed + ? + : } +
+ +

+ {title} +

+

+ {error || ( + <> + {storeName}의 확인된 정보만 + 담아 정적 페이지로 굽고 있어요. + + )}

- {steps.length > 0 && ( -
    - {steps.map((step) => { - const state = step.status === 'running' && failed ? 'failed' : step.status; - const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀' - : state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기'; - return ( -
  1. - {state === 'done' ? - : state === 'running' && !waiting && !error ? - : } -
    - {GENERATION_LABELS[step.id] ?? '콘텐츠 처리'} - {step.reason &&

    {SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}

    } -
    - {label} -
  2. - ); - })} -
+ + {total > 0 && ( +
+
+ {currentLabel} + {progress}% +
+ + + +
    + {steps.map((step, index) => { + const state = step.status === 'running' && failed ? 'failed' : step.status; + const isDone = state === 'done' || state === 'skipped'; + const isCurrent = state === 'running'; + + return ( +
  1. + + {isDone ? : index + 1} + + + {GENERATION_LABELS[step.id] ?? '콘텐츠 처리'} + {step.reason && ( + + ({SKIP_REASONS[step.reason] ?? '건너뜀'}) + + )} + +
  2. + ); + })} +
+
+ )} + + {!error && !failed && ( +

+ 새로고침해도 같은 작업의 진행 상태를 이어서 확인합니다. +

+ )} + {(error || failed) && ( +
+ {error && } + + +
)} - {!error && !failed &&

새로고침해도 같은 작업의 진행 상태를 이어서 확인합니다.

} - {(error || failed) &&
- {error && } - - -
}
); diff --git a/solution/frontend/src/features/onboarding/generationLabels.ts b/solution/frontend/src/features/onboarding/generationLabels.ts index a79d7bf..d6a4f67 100644 --- a/solution/frontend/src/features/onboarding/generationLabels.ts +++ b/solution/frontend/src/features/onboarding/generationLabels.ts @@ -1,13 +1,14 @@ /** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */ export const GENERATION_LABELS: Record = { - prepare: '확인된 정보와 수집 원문 준비', - generate: '소개문·FAQ 생성 및 근거 검증', - save: '소개문·FAQ 저장', - faq_fill: '부족한 FAQ 문의 안내 보완', + prepare: '수집된 사진 분류 및 대체 텍스트 생성', + generate: '브랜드 컬러 시스템 및 타이포그래피 조합', + save: '확인된 사실만으로 소개 콘텐츠 구성', + faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비', }; export const SKIP_REASONS: Record = { no_facts: '확인된 정보 없음', not_configured: '생성 서비스 미설정', no_catalog: '공통 질문 대상 업종 아님', + generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다', }; diff --git a/solution/frontend/src/lib/autoSession.ts b/solution/frontend/src/lib/autoSession.ts index 93d8a27..0c7bf65 100644 --- a/solution/frontend/src/lib/autoSession.ts +++ b/solution/frontend/src/lib/autoSession.ts @@ -14,10 +14,15 @@ import {establishSession} from '@/lib/session'; * * ⚠️ 이 값은 **번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽을 수 있다 — * 내부 테스트 호스트에서만 켜고, 사장님에게 여는 순간 반드시 빼야 한다. + * ★ `import.meta.env.DEV` 가드는 둘째 안전판이다. 운영 빌드(vite build)는 이 상수가 + * 컴파일 시점에 `false` 로 접히므로 아래 if 블록째 번들에서 잘려 나간다 — 누군가 실수로 + * 운영 이미지(nginx/Dockerfile)에 VITE_AUTO_LOGIN_ID·PW 를 다시 넘겨도 코드가 죽어 있어 + * 못 켠다. `vite dev`(solution-frontend --profile dev)에서는 DEV=true 라 그대로 켜진다. */ let pending: Promise | null = null; export function ensureAutoSession(): Promise { + if (!import.meta.env.DEV) return Promise.resolve(); if (getAccessToken()) return Promise.resolve(); if (pending) return pending;