[fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음

운영 번들 자동 로그인 자격증명 유출, 온보딩 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 알림 실채널 수신 확인.
This commit is contained in:
Mina Choi 2026-09-16 16:25:02 +09:00
parent b76144746b
commit b4085a0e0f
36 changed files with 1320 additions and 84 deletions

View File

@ -87,6 +87,14 @@ GSC_CREDENTIALS_FILE=
GSC_CREDENTIALS_HOST_FILE= GSC_CREDENTIALS_HOST_FILE=
GSC_ALERT_DAYS=7 GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL= 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_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER= AZURE_STORAGE_CONTAINER=
@ -103,6 +111,8 @@ AZURE_STORAGE_PREFIX=
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다. # 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 — # ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts). # 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site # ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site,
# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는
# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다).
AUTO_LOGIN_ID= AUTO_LOGIN_ID=
AUTO_LOGIN_PW= AUTO_LOGIN_PW=

View File

@ -16,6 +16,7 @@
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) | | **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
--- ---
@ -115,6 +116,13 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** 수신자(`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/…` 를 가리키는데 - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라. CDN 을 앞에 세워야 한다. 아니면 비워라.

View File

@ -248,11 +248,10 @@ services:
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost} VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost} VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://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 와 같은 값이다. # 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${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 image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site container_name: o2o-web4ai-solution-site
volumes: volumes:

61
docs/ALERTS.md Normal file
View File

@ -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://<host>/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 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).

View File

@ -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 — 워커 렌더·발행 버전·예약 안내·미리보기 대기 ## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기
- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다. - 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다.

View File

@ -27,19 +27,18 @@ COPY admin ./admin
ARG VITE_API_BASE_URL ARG VITE_API_BASE_URL
ARG VITE_PUBLISH_HOST ARG VITE_PUBLISH_HOST
ARG VITE_SITE_PREVIEW_URL ARG VITE_SITE_PREVIEW_URL
# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 —
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession).
ARG VITE_AUTO_LOGIN_ID
ARG VITE_AUTO_LOGIN_PW
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와 # 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다). # 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
ARG VITE_GOOGLE_CLIENT_ID ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \ VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \ 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_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 RUN npm run build -w @o2o/frontend
FROM nginx:alpine FROM nginx:alpine

View File

@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS public.users (
role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer 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 SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google
provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키 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(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE 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 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 ( CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1 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) 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; 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) -- 마이그레이션 기준선(baseline)
-- ============================================================ -- ============================================================

View File

@ -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 토큰은 다음 재발급에서 전부 거절된다.';

View File

@ -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;

View File

@ -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 []

View File

@ -113,7 +113,10 @@ class DBSessionManager(Singleton):
return ErrorType.SUCCESS return ErrorType.SUCCESS
except IntegrityError as ex: except IntegrityError as ex:
await db.rollback() 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 return ErrorType.DB_ALREADY_SAME_KEY
except Exception as ex: except Exception as ex:
await db.rollback() await db.rollback()

View File

@ -80,6 +80,12 @@ class users(MainTableMixin, MAIN_BASE):
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값). # 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value) provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키 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) 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): class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.

View File

@ -55,6 +55,7 @@ class ErrorType(Enum):
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
# 사업장(places) 관련 에러 # 사업장(places) 관련 에러
PLACE_NOT_FOUND = 1200 PLACE_NOT_FOUND = 1200
@ -452,3 +453,11 @@ class JobStatus(CodeEnum):
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다. # claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} 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 를 봐야 한다)

View File

@ -70,11 +70,15 @@ class UserInfo(StructModel):
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
id: str # users.id (로그인 아이디) — get_me 재조회 키 id: str # users.id (로그인 아이디) — get_me 재조회 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__() super().__init__()
# 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다. # 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
self.role = UserRole.USER.value self.role = UserRole.USER.value
self.token_version = 1
for dictionary in args: for dictionary in args:
for key in dictionary: for key in dictionary:
setattr(self, key, dictionary[key]) setattr(self, key, dictionary[key])

View File

@ -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)
)

View File

@ -173,9 +173,12 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
async def reap(self) -> list[str]: async def reap(self) -> list[dict]:
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD. """만료된 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(""" sql = text("""
UPDATE jobs SET UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
@ -185,12 +188,15 @@ class JobQueue:
worker_id = NULL, worker_id = NULL,
updated_at = now() updated_at = now()
WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < 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): async def run(s):
rows = (await s.execute(sql)).all() 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) return await self._tx(run)

View File

@ -1,11 +1,13 @@
import time import time
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
from config.server_configs import web_server_config from config.server_configs import web_server_config
@ -81,6 +83,29 @@ async def healthz():
return API_SERVER_START_TIME 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.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router) app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.place.place.router) app.include_router(router.v1.place.place.router)

View File

@ -2,7 +2,13 @@
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 되므로 SCHEDULER_ENABLED=1 프로세스에서만 등록한다. 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 되므로 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 / 날씨 시간 단위 행정구역 코드 단위 캐시 갱신 · 지역정보 갱신 : 축제 1 / 관광정보 1 / 날씨 시간 단위 행정구역 코드 단위 캐시 갱신
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 직전 유지 + 내부 알림) · 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 직전 유지 + 내부 알림)
· 사이트 재빌드 : 검증 상태가 바뀐 place 개별 재빌드 (전체 재빌드 금지) · 사이트 재빌드 : 검증 상태가 바뀐 place 개별 재빌드 (전체 재빌드 금지)
@ -37,6 +43,12 @@ def start_scheduler():
from services.search_console_service import run_scheduled_check from services.search_console_service import run_scheduled_check
_scheduler.add_job(run_scheduled_check, "interval", minutes=10, _scheduler.add_job(run_scheduled_check, "interval", minutes=10,
id="search-console", max_instances=1, coalesce=True) 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() _scheduler.start()
LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)") LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")

View File

@ -1,5 +1,54 @@
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일. """스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 책임진다. 잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 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, "잡 큐 정상으로 돌아옴")

View File

@ -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}

View File

@ -76,6 +76,7 @@ class AuthService:
user_id=str(user.user_id), user_id=str(user.user_id),
id=user.id, id=user.id,
role=user.role, role=user.role,
token_version=user.token_version,
) )
async def _finish_login(self, user: users) -> Res_Login: async def _finish_login(self, user: users) -> Res_Login:
@ -316,6 +317,10 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res return res
data["password"] = await GetHashedPW(data["password"]) data["password"] = await GetHashedPW(data["password"])
# ★ 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면
# 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도
# 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다).
data["token_version"] = (me.token_version or 1) + 1
else: else:
data.pop("password", None) data.pop("password", None)
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움). # 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
@ -336,8 +341,35 @@ class AuthService:
return await self.get_me(user_info) return await self.get_me(user_info)
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken: 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() res = Res_RefreshToken()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨. # refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료).
user_info = DecodeRefreshToken(refresh_token) 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 return res

View File

@ -30,6 +30,7 @@ from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from services import ( from services import (
alert_service,
azure_static, azure_static,
indexnow, indexnow,
publish_gate, publish_gate,
@ -155,6 +156,15 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다 except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
song_result = {"error": f"{type(ex).__name__}: {ex}"} song_result = {"error": f"{type(ex).__name__}: {ex}"}
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {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)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 — # ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories). # 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
@ -226,6 +236,15 @@ async def run_build(job: dict) -> dict:
result["build_status"] = "FAILED" result["build_status"] = "FAILED"
result["error"] = reason result["error"] = reason
LOG.w(f"[build] place={place_id} v{version_no} 실패: {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 return result
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ---- # ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
@ -348,6 +367,10 @@ async def run_build(job: dict) -> dict:
) )
result["build_status"] = "BUILT" result["build_status"] = "BUILT"
result["routes"] = report.get("routes") 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: if want_publish:
# 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아 # 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아

View File

@ -5,6 +5,7 @@
import re import re
import uuid import uuid
from common import collect_diagnostics
from common.database.db_session_manager import DB_SESSION_MNG 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_facts as facts_model
from common.database.model.models import place_photos, place_channels, places, place_units 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: try:
candidates = await client.search_local(query) candidates = await client.search_local(query)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 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" return "not_found"
match = naver_client.pick_match(place.name, candidates, address) 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, longitude=place.longitude,
) )
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 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" return "error"
if not found: if not found:
@ -215,7 +216,7 @@ async def discover_yanolja(place, place_id: str) -> str:
try: try:
found = await yanolja_adapter.search_by_address(address) found = await yanolja_adapter.search_by_address(address)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 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" return "error"
if not found: if not found:
@ -254,7 +255,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["naver_place"] == "resolved": if stat["naver_place"] == "resolved":
stat["discovered"] += 1 stat["discovered"] += 1
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다 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" stat["naver_place"] = "error"
# TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다. # TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다.
@ -264,7 +265,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["tour_api"] == "resolved": if stat["tour_api"] == "resolved":
stat["discovered"] += 1 stat["discovered"] += 1
except Exception as ex: # noqa: BLE001 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" 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": if stat["official_site"] == "resolved":
stat["discovered"] += 1 stat["discovered"] += 1
except Exception as ex: # noqa: BLE001 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" stat["official_site"] = "error"
# 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고). # 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고).
@ -284,7 +285,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["yanolja"] == "resolved": if stat["yanolja"] == "resolved":
stat["discovered"] += 1 stat["discovered"] += 1
except Exception as ex: # noqa: BLE001 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" stat["yanolja"] = "error"
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도 # 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
@ -309,7 +310,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
return stat return stat
except perplexity.PerplexityError as ex: 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] stat["error"] = str(ex)[:200]
return stat return stat
@ -421,10 +422,10 @@ async def fetch_one(link):
try: try:
source = await adapter.fetch(link.url) source = await adapter.fetch(link.url)
except Exception as ex: 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" return None, "failed"
if not source.ok: 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 None, "failed"
return source, "fetched" 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: async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" """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"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"] owner_user_id = payload["owner_user_id"]
@ -682,7 +692,7 @@ async def run_collect(job: dict) -> dict:
from services import place_research from services import place_research
result["research"] = await place_research.research_place(place, place_id) result["research"] = await place_research.research_place(place, place_id)
except Exception as ex: # noqa: BLE001 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}"} result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)

View File

@ -1,4 +1,5 @@
"""COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text.""" """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.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs
from services.external import gemini_text from services.external import gemini_text
from services.job_progress import JobProgress 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: if inputs.catalog is None and not inputs.ungrounded:
raise CopyAborted(note) raise CopyAborted(note)
else: else:
try:
async with progress.step("generate"): async with progress.step("generate"):
copy = await generate_copy(inputs) 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"): async with progress.step("save"):
result = await save_copy(inputs, copy) result = await save_copy(inputs, copy)

View File

@ -37,7 +37,7 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import SiteCRUD 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 from services.build_service import ensure_site, load_channel_links
_site_crud = SiteCRUD() _site_crud = SiteCRUD()
@ -104,6 +104,15 @@ async def run_rollback(job: dict) -> dict:
result["rolled_back"] = False result["rolled_back"] = False
result["error"] = reason result["error"] = reason
LOG.w(f"[rollback] place={place_id} v{target_version} 실패: {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 return result
# ★ snapshot 을 그대로 옮긴다 — 재수집하지 않는다(파일 머리주석 참조). # ★ 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) 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 _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 result["rolled_back"] = True
LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림") LOG.i(f"[rollback] place={place_id} v{target_version} 로 되돌림")

View File

@ -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

View File

@ -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

View File

@ -157,3 +157,62 @@ async def test_google_login_is_off_when_client_id_is_empty(client, db_engine):
기대결과: 1106(OAUTH_NOT_CONFIGURED) 네트워크를 타지 않고 즉시 끊긴다.""" 기대결과: 1106(OAUTH_NOT_CONFIGURED) 네트워크를 타지 않고 즉시 끊긴다."""
r = await client.post("/v1/auth/google", json={"credential": "anything"}) r = await client.post("/v1/auth/google", json={"credential": "anything"})
assert r.json()["result"]["code"] == 1106 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

View File

@ -313,6 +313,11 @@ async def test_렌더가_실패하면_발행하지_않는다(auth_headers, clien
assert r.get("published") is not True assert r.get("published") is not True
assert "번들이 없다" in r["error"] 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() site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()
assert site["site"]["status"] != SiteStatus.PUBLISHED.value assert site["site"]["status"] != SiteStatus.PUBLISHED.value
@ -339,6 +344,11 @@ async def test_구조화데이터가_화면과_다르면_발행하지_않는다(
assert r["gate"]["reason"] == PublishRejectReason.JSONLD_MISMATCH.name assert r["gate"]["reason"] == PublishRejectReason.JSONLD_MISMATCH.name
assert r["mismatches"] 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): async def test_렌더_결과가_안_오면_발행하지_않는다(auth_headers, client, db_engine, monkeypatch):
"""검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃). """검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃).

View File

@ -10,3 +10,12 @@ async def test_healthz(client):
r = await client.get("/healthz") r = await client.get("/healthz")
assert r.status_code == 200 assert r.status_code == 200
assert isinstance(r.json(), str) and len(r.json()) > 0 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"}

View File

@ -160,7 +160,7 @@ async def test_reaper_reclaims_dead_worker_job(db_engine):
) )
reclaimed = await q.reap() reclaimed = await q.reap()
assert jid in reclaimed assert jid in [r["job_id"] for r in reclaimed]
row = await q.get(jid) row = await q.get(jid)
assert row["status"] == JobStatus.PENDING.value 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"] 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): async def test_reaper_loop_stops_on_event(db_engine):
"""검증: reaper 루프에 stop 이벤트를 건다. """검증: reaper 루프에 stop 이벤트를 건다.
기대결과: 즉시 빠져나온다(graceful shutdown 매달리지 않는다).""" 기대결과: 즉시 빠져나온다(graceful shutdown 매달리지 않는다)."""

View File

@ -259,10 +259,15 @@ def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count):
monkeypatch.setenv("SCHEDULER_ENABLED", "1") monkeypatch.setenv("SCHEDULER_ENABLED", "1")
monkeypatch.setenv("GSC_ENABLED", enabled) monkeypatch.setenv("GSC_ENABLED", enabled)
scheduler.start_scheduler() scheduler.start_scheduler()
assert len(jobs) == count # ★ 알림 스윕 둘(alert-outbox·queue-health)은 GSC_ENABLED 와 무관하게 항상 등록된다
if jobs: # (scheduler/__init__.py, docs/ALERTS.md) — search-console 잡만 옵션이다.
assert jobs[0][1]["minutes"] == 10 always_on = {kw["id"] for _a, kw in jobs} - {"search-console"}
assert jobs[0][1]["max_instances"] == 1 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(): def test_migration_matches_fresh_database_schema():

View File

@ -12,10 +12,36 @@
import asyncio import asyncio
from common.enums import JobStatus from common.enums import JobStatus, JobType
from common.job_errors import PermanentJobError from common.job_errors import PermanentJobError
from common.logger import LOG from common.logger import LOG
from crud.job_crud import JobQueue, compute_backoff 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: class Worker:
@ -84,19 +110,29 @@ class Worker:
# 데드라인 초과 — wait_for 가 핸들러 태스크를 취소한 뒤 여기로 온다. # 데드라인 초과 — wait_for 가 핸들러 태스크를 취소한 뒤 여기로 온다.
# 행이 워커 슬롯을 영구 점유하는 것보다 낫다. # 행이 워커 슬롯을 영구 점유하는 것보다 낫다.
backoff = self.backoff_fn(job["attempts"]) 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 '?'} " LOG.w(f"[{self.worker_id}] deadline {jid}{JobStatus(st).name if st else '?'} "
f"({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)") f"({self.job_deadline_sec:.0f}s 초과, 핸들러 취소)")
if st == JobStatus.DEAD.value:
await _alert_job_dead(job, reason)
except PermanentJobError as ex: except PermanentJobError as ex:
# ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석). # ★ 재시도하지 않는다 — 다시 해도 같은 결과다(common/job_errors 주석).
# 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로 # 실측(2026-09-15): 사업장이 지워진 뒤 남은 소개문 잡이 "사업장을 찾을 수 없다" 로
# 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다. # 세 번 돌고 DEAD 로 갔다. 결과는 같고 큐 지연과 알림만 늘었다.
await self.queue.fail_permanent(jid, self.worker_id, f"{type(ex).__name__}: {ex}") # ★ fail_permanent 는 재시도 없이 곧장 DEAD 다 — 위 일반 실패 경로처럼 상태를 다시
LOG.w(f"[{self.worker_id}] fail {jid} → DEAD (재시도 안 함: {type(ex).__name__}: {ex})") # 조회해 분기할 필요 없이 바로 알린다(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: except Exception as ex:
backoff = self.backoff_fn(job["attempts"]) 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})") 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: finally:
hb.cancel() hb.cancel()
try: try:
@ -124,6 +160,9 @@ async def run_reaper(queue: JobQueue, stop: asyncio.Event, interval: float = 30.
reclaimed = await queue.reap() reclaimed = await queue.reap()
if reclaimed: if reclaimed:
LOG.w(f"[reaper] reclaimed {len(reclaimed)} stale job(s)") 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: except Exception as ex:
LOG.e_no_callstack(f"[reaper] {type(ex).__name__}: {ex}") LOG.e_no_callstack(f"[reaper] {type(ex).__name__}: {ex}")
try: try:

View File

@ -1,10 +1,18 @@
import {Check, Loader2} from 'lucide-react'; import {Check, Loader2} from 'lucide-react';
import {JobStatus} from '@o2o/shared'; import {JobStatus} from '@o2o/shared';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Progress} from '@/components/ui/progress';
import {cn} from '@/lib/utils';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {useGenerationJob} from './useGenerationJob'; import {useGenerationJob} from './useGenerationJob';
import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels'; import {GENERATION_LABELS, SKIP_REASONS} from './generationLabels';
/*
* . `useGenerationJob`
* ( jobId , ) , ·
* · . COPY
* (prepare/generate/save/faq_fill) DEVLOG.md.
*/
export function Step5Generating() { export function Step5Generating() {
const storeName = useBuilderStore((s) => s.storeName); const storeName = useBuilderStore((s) => s.storeName);
const placeId = useBuilderStore((s) => s.placeId); const placeId = useBuilderStore((s) => s.placeId);
@ -12,47 +20,103 @@ export function Step5Generating() {
const failed = job?.status === JobStatus.DEAD; const failed = job?.status === JobStatus.DEAD;
const waiting = job?.status === JobStatus.PENDING; const waiting = job?.status === JobStatus.PENDING;
const steps = job?.progress?.steps ?? []; 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 ? '진행 상태 확인이 필요합니다' const title = error ? '진행 상태 확인이 필요합니다'
: failed ? '콘텐츠 생성을 완료하지 못했습니다' : failed ? '콘텐츠 생성을 완료하지 못했습니다'
: waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다') : waiting ? (job.attempts ? '생성을 다시 시도할 예정입니다' : '생성 순서를 기다리고 있습니다')
: job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다' : job?.status === JobStatus.DONE ? '콘텐츠 생성을 마쳤습니다'
: '소개문과 FAQ를 만들고 있습니다'; : '웹사이트를 생성하고 있습니다';
return ( return (
<div className="flex flex-1 items-center justify-center bg-muted/30 px-4 py-16"> <div className="flex flex-1 flex-col items-center justify-center bg-muted/30 px-4 py-16 sm:px-6 sm:py-24 lg:px-8">
<div className="w-full max-w-md space-y-6 text-center"> <div className="mx-auto flex w-full max-w-md flex-col items-center text-center">
{!error && !failed && <Loader2 className="mx-auto size-8 animate-spin text-primary" />} <div className="mb-6 flex size-16 items-center justify-center rounded-2xl border border-border bg-card">
<h1 className="text-xl font-bold" aria-live="polite">{title}</h1> {!error && !failed
<p className="text-sm text-muted-foreground"> ? <Loader2 className="size-8 animate-spin text-primary" />
{error || <><strong>{storeName}</strong> .</>} : <span className="text-2xl" aria-hidden="true"></span>}
</p>
{steps.length > 0 && (
<ol className="space-y-4 rounded-2xl border border-border bg-card p-6 text-left text-sm">
{steps.map((step) => {
const state = step.status === 'running' && failed ? 'failed' : step.status;
const label = state === 'done' ? '완료' : state === 'skipped' ? '건너뜀'
: state === 'failed' ? '중단' : state === 'running' ? (waiting ? '재시도 대기' : '진행 중') : '대기';
return (
<li key={step.id} className="flex items-center gap-3">
{state === 'done' ? <Check className="size-4 shrink-0 text-success" />
: state === 'running' && !waiting && !error ? <Loader2 className="size-4 shrink-0 animate-spin" />
: <span className="size-4 shrink-0 rounded-full border border-border" />}
<div className="flex-1">
{GENERATION_LABELS[step.id] ?? '콘텐츠 처리'}
{step.reason && <p className="mt-1 text-xs text-muted-foreground">{SKIP_REASONS[step.reason] ?? '이 단계는 생략했습니다'}</p>}
</div> </div>
<span className="text-xs text-muted-foreground">{label}</span>
<h1 className="mb-2 text-2xl font-bold tracking-tight sm:text-3xl" aria-live="polite">
{title}
</h1>
<p className="mb-8 max-w-md text-xs text-muted-foreground sm:text-sm">
{error || (
<>
<strong className="font-semibold text-foreground">{storeName}</strong>
.
</>
)}
</p>
{total > 0 && (
<div className="w-full space-y-4 rounded-2xl border border-border bg-card p-6 text-left">
<div className="flex items-center justify-between text-xs">
<span className="font-bold">{currentLabel}</span>
<span className="font-mono font-bold">{progress}%</span>
</div>
<Progress value={progress} aria-label="사이트 생성 진행률" />
<ol className="space-y-2.5 pt-2">
{steps.map((step, index) => {
const state = step.status === 'running' && failed ? 'failed' : step.status;
const isDone = state === 'done' || state === 'skipped';
const isCurrent = state === 'running';
return (
<li
key={step.id}
className={cn(
'flex items-center gap-3 text-xs transition-colors',
isDone || isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
<span
className={cn(
'flex size-4 shrink-0 items-center justify-center rounded-full text-[10px] font-bold',
isDone || isCurrent
? 'bg-primary text-primary-foreground'
: 'border border-border bg-muted',
isCurrent && 'ring-2 ring-primary/20',
)}
>
{isDone ? <Check className="size-2.5" strokeWidth={3} /> : index + 1}
</span>
<span className="truncate">
{GENERATION_LABELS[step.id] ?? '콘텐츠 처리'}
{step.reason && (
<span className="ml-1 text-muted-foreground">
({SKIP_REASONS[step.reason] ?? '건너뜀'})
</span>
)}
</span>
</li> </li>
); );
})} })}
</ol> </ol>
</div>
)} )}
{!error && !failed && <p className="text-xs text-muted-foreground"> .</p>}
{(error || failed) && <div className="flex flex-wrap justify-center gap-2"> {!error && !failed && (
<p className="mt-6 text-xs text-muted-foreground">
.
</p>
)}
{(error || failed) && (
<div className="mt-6 flex flex-wrap justify-center gap-2">
{error && <Button onClick={checkAgain}> </Button>} {error && <Button onClick={checkAgain}> </Button>}
<Button variant="outline" onClick={goBack}> </Button> <Button variant="outline" onClick={goBack}> </Button>
<Button variant="outline" onClick={openEditor}> </Button> <Button variant="outline" onClick={openEditor}> </Button>
</div>} </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,13 +1,14 @@
/** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */ /** 화면 문구만 둔다. 단계 순서·완료 여부는 jobs.progress가 보낸다. */
export const GENERATION_LABELS: Record<string, string> = { export const GENERATION_LABELS: Record<string, string> = {
prepare: '확인된 정보와 수집 원문 준비', prepare: '수집된 사진 분류 및 대체 텍스트 생성',
generate: '소개문·FAQ 생성 및 근거 검증', generate: '브랜드 컬러 시스템 및 타이포그래피 조합',
save: '소개문·FAQ 저장', save: '확인된 사실만으로 소개 콘텐츠 구성',
faq_fill: '부족한 FAQ 문의 안내 보완', faq_fill: '위치 기반 길찾기 연동 및 구조화 데이터(JSON-LD) 준비',
}; };
export const SKIP_REASONS: Record<string, string> = { export const SKIP_REASONS: Record<string, string> = {
no_facts: '확인된 정보 없음', no_facts: '확인된 정보 없음',
not_configured: '생성 서비스 미설정', not_configured: '생성 서비스 미설정',
no_catalog: '공통 질문 대상 업종 아님', no_catalog: '공통 질문 대상 업종 아님',
generation_failed: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
}; };

View File

@ -14,10 +14,15 @@ import {establishSession} from '@/lib/session';
* *
* ** .** JS * ** .** 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<void> | null = null; let pending: Promise<void> | null = null;
export function ensureAutoSession(): Promise<void> { export function ensureAutoSession(): Promise<void> {
if (!import.meta.env.DEV) return Promise.resolve();
if (getAccessToken()) return Promise.resolve(); if (getAccessToken()) return Promise.resolve();
if (pending) return pending; if (pending) return pending;