[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:
parent
b76144746b
commit
b4085a0e0f
12
.env.example
12
.env.example
@ -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=
|
||||||
|
|||||||
@ -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 을 앞에 세워야 한다. 아니면 비워라.
|
||||||
|
|||||||
@ -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
61
docs/ALERTS.md
Normal 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 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).
|
||||||
@ -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 렌더러를 실행한다.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
|
|||||||
18
postgres-init/migrations/0015_users_token_version.sql
Normal file
18
postgres-init/migrations/0015_users_token_version.sql
Normal 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 토큰은 다음 재발급에서 전부 거절된다.';
|
||||||
35
postgres-init/migrations/0016_alert_outbox.sql
Normal file
35
postgres-init/migrations/0016_alert_outbox.sql
Normal 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;
|
||||||
59
solution/backend/common/collect_diagnostics.py
Normal file
59
solution/backend/common/collect_diagnostics.py
Normal 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 []
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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 의 단위**다.
|
||||||
|
|
||||||
|
|||||||
@ -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 를 봐야 한다)
|
||||||
|
|||||||
@ -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])
|
||||||
|
|||||||
74
solution/backend/crud/alert_crud.py
Normal file
74
solution/backend/crud/alert_crud.py
Normal 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)
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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())}개 잡)")
|
||||||
|
|
||||||
|
|||||||
@ -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, "잡 큐 정상으로 돌아옴")
|
||||||
|
|||||||
162
solution/backend/services/alert_service.py
Normal file
162
solution/backend/services/alert_service.py
Normal 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}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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 에 싣는다 — 못 만들었으면 키를 넣지 않아
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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} 로 되돌림")
|
||||||
|
|||||||
69
solution/backend/services/teams_webhook.py
Normal file
69
solution/backend/services/teams_webhook.py
Normal 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
|
||||||
206
solution/backend/tests/test_alert_service.py
Normal file
206
solution/backend/tests/test_alert_service.py
Normal 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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):
|
||||||
"""검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃).
|
"""검증: 렌더러가 죽어 보고서가 오지 않는다(타임아웃).
|
||||||
|
|||||||
@ -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"}
|
||||||
|
|||||||
@ -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 이 매달리지 않는다)."""
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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: '생성 서비스 응답 실패 — 확인된 정보만으로 계속합니다',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user