diff --git a/.gitea/workflows/cicd.yml b/.gitea/workflows/cicd.yml index 2afd9fc..f33f00e 100644 --- a/.gitea/workflows/cicd.yml +++ b/.gitea/workflows/cicd.yml @@ -3,6 +3,9 @@ name: baekjoon-bot-cicd on: push: branches: ["main"] + paths-ignore: + - "**.md" + - "docs/**" jobs: build_push_deploy: @@ -38,7 +41,7 @@ jobs: run: | set -euo pipefail echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USER}" --password-stdin - + - name: Build & push image env: DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }} @@ -67,7 +70,7 @@ jobs: MESSAGE="⚠️ **Build or Deploy Failed!**" COLOR=15158332 # 빨간색 계열 fi - + curl -X POST -H "Content-Type: application/json" \ -d '{ "embeds": [{ @@ -76,4 +79,4 @@ jobs: "color": '"$COLOR"', "timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'" }] - }' $DISCORD_WEBHOOK \ No newline at end of file + }' $DISCORD_WEBHOOK diff --git a/README.md b/README.md index 3b2a594..53cc64d 100644 --- a/README.md +++ b/README.md @@ -1,112 +1,159 @@ # baekjoon-bot -작성: AI / 수정: nkey -매일 백준(BOJ) 문제를 추천해주고, 디스코드 메시지(payload)로 쓸 수 있는 형태로 반환하는 FastAPI 서비스입니다. -`/today`에서 추천 문제 + 링크 + Discord embed payload를 생성하며, (선택) PostgreSQL에 문제집(workbook) 진행상황을 저장해 “문제집에서 아직 안 보낸 문제”를 고르는 모드도 제공합니다. -디스코드 메시지 자동화는 n8n을 활용하였습니다. +매일 백준(BOJ) 문제를 추천하고 Discord embed payload로 반환하는 FastAPI 서비스. +solved.ac 검색 기반 추천과 PostgreSQL 문제집(workbook) 기반 추천 두 가지 모드를 지원하며, n8n 등 외부 스케줄러와 연동하여 Discord 자동 알림을 구성할 수 있다. + +## 주요 기능 + +- **Search 모드**: solved.ac API로 난이도/태그/언어 조건에 맞는 문제를 랜덤 추천 +- **Workbook 모드**: DB에 저장된 문제집에서 아직 보내지 않은 문제를 순서대로 또는 랜덤으로 추천 +- **Discord embed**: 모든 응답에 Discord webhook용 embed payload 포함 +- **Admin API**: 문제집 메타데이터 보강(enrich), 진행상황 초기화(reset) + +## 기술 스택 + +- Python 3.12+ / FastAPI / Uvicorn +- SQLAlchemy 2.0 (async) + asyncpg (PostgreSQL) +- httpx (비동기 HTTP) / requests (동기 HTTP) +- Docker / Gitea Actions CI/CD + +## 빠른 시작 -## Quickstart -### Recommended ```bash -# clone git clone https://nkeystudy.site/gitea/nkey/baekjoon-bot.git cd baekjoon-bot -# venv -python -m venv .venv -source .venv/bin/activate - -# deps +python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt -# env (필수: DATABASE_URL) +# .env 파일 생성 cat > .env <<'EOF' DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME -# (admin API 사용 시 필수) ADMIN_PASSWORD=change-me EOF -# run uvicorn app:app --host 0.0.0.0 --port 8000 - -# verify -curl -s http://localhost:8000/ | cat -curl -s "http://localhost:8000/today" | cat ``` -### Alternative (optional) - Docker +Docker로 실행: ```bash docker build -f dockerfile -t baekjoon-bot:local . docker run --rm -p 8000:8000 --env-file .env baekjoon-bot:local - -# verify -curl -s http://localhost:8000/ | cat ``` -## Requirements -- Runtime/Language: Python 3.12+ (Dockerfile 기준) -- Dependencies: `fastapi`, `uvicorn`, `sqlalchemy>=2.0`, `asyncpg`, `requests`, `httpx`, `python-dotenv` 등 (`requirements.txt`) -- Tools: (optional) Docker +## API 엔드포인트 -## Configuration -### Environment Variables -| Key | Description | Default | Required | -|---|---|---:|:---:| -| `DATABASE_URL` | SQLAlchemy async DB URL (예: `postgresql+asyncpg://...`) | - | ✅ | -| `ADMIN_PASSWORD` | Admin API 인증 비밀번호 (`X-Admin-Password` 헤더와 비교) | `""` | ✅ (admin API) | -| `SOURCE_MODE_DEFAULT` | `/today` 기본 소스 모드 | `search` | | -| `WORKBOOK_ID_DEFAULT` | `source_mode=workbook`에서 `workbook_id` 미지정 시 기본값 | - | | -| `DIFFICULTY_MODE_DEFAULT` | `/today` 기본 난이도 모드 | `easy` | | -| `TAG_MODE_DEFAULT` | `/today` 기본 태그 모드 | `easy` | | -| `LANG_DEFAULT` | `/today` 기본 언어 | `all` | | -| `DIFFICULTY_EASY` | easy 난이도 범위 | `6..10` | | -| `DIFFICULTY_HARD` | hard 난이도 범위 | `11..15` | | -| `DIFFICULTY_ALL` | all 난이도 범위 | `1..30` | | -| `TAGS_EASY` | easy 태그 프리셋(CSV) | `""` | | -| `TAGS_HARD` | hard 태그 프리셋(CSV) | `""` | | -| `TAGS_ALL` | all 태그 프리셋(CSV) | `""` | | -| `TAG_PICK` | 태그 선택 정책 (random/none/전체) | `random` | | -| `TAG_PICK_EASY` | easy 태그 선택 정책 | `TAG_PICK` | | -| `TAG_PICK_HARD` | hard 태그 선택 정책 | `TAG_PICK` | | -| `TAG_PICK_ALL` | all 태그 선택 정책 | `TAG_PICK` 또는 `none` | | -| `TAGS_JOIN` | 태그 결합 방식 | `or` | | +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| GET | `/` | 헬스체크 | - | +| GET | `/today` | 오늘의 추천 문제 | - | +| POST | `/admin/workbooks/{id}/enrich` | 문제집 메타데이터 보강 | O | +| DELETE | `/admin/workbooks/{id}/reset` | 문제집 발송 기록 초기화 | O | -### Ports -| Service | Port | Description | -|---|---:|---| -| API | 8000 | FastAPI(Uvicorn) | +### 사용 예시 -## Usage (minimal) -- 오늘의 추천 문제(검색 모드, 기본값) - - `GET /today` -- 난이도/태그/언어 지정 - - `GET /today?difficulty=6..10&tags=dp,graphs&lang=ko,en` -- 문제집(workbook) 모드로 추천 - - `GET /today?source_mode=workbook&workbook_id=12345&workbook_pick=level_asc` -- Admin: 문제집 메타(제목/레벨/태그) 보강 - - `POST /admin/workbooks/{workbook_id}/enrich` + `X-Admin-Password: ...` +```bash +# 기본 추천 (search 모드) +curl -s "http://localhost:8000/today" -## Docs -- Operations: `docs/OPERATIONS.md` -- Development: `docs/DEVELOPMENT.md` -- API: `docs/API.md` +# 난이도/태그/언어 지정 +curl -s "http://localhost:8000/today?difficulty=6..10&tags=dp,graphs&lang=ko" + +# 문제집 모드 +curl -s "http://localhost:8000/today?source_mode=workbook&workbook_id=12345" + +# 문제집 메타 보강 +curl -s -X POST -H "X-Admin-Password: change-me" \ + "http://localhost:8000/admin/workbooks/12345/enrich" + +# 문제집 진행상황 초기화 +curl -s -X DELETE -H "X-Admin-Password: change-me" \ + "http://localhost:8000/admin/workbooks/12345/reset" +``` + +## Workbook 모드 셋업 + +Workbook 모드를 사용하려면 DB에 문제집 데이터를 직접 넣어야 한다. +(BOJ 자동 크롤링은 스크래핑 차단으로 현재 미지원) + +### 1. 문제집 등록 + +```sql +-- id는 BOJ 문제집 URL의 번호 (acmicpc.net/workbook/view/12345) +INSERT INTO workbooks (id, title) +VALUES (12345, '내 문제집 이름'); +``` + +### 2. 문제 목록 등록 + +```sql +-- 문제 번호만 넣으면 됨 (제목/난이도/태그는 3단계에서 자동으로 채워짐) +INSERT INTO workbook_problems (workbook_id, problem_id) VALUES +(12345, 1000), +(12345, 1001), +(12345, 1002); +``` + +### 3. 메타데이터 자동 보강 + +```bash +# solved.ac API로 제목, 난이도, 태그를 자동으로 가져와서 DB에 채움 +curl -s -X POST \ + -H "X-Admin-Password: change-me" \ + "http://localhost:8000/admin/workbooks/12345/enrich" +``` + +이후 `/today?source_mode=workbook&workbook_id=12345`로 문제를 추천받을 수 있다. +모든 문제를 다 뽑은 뒤 다시 처음부터 시작하려면 reset API를 호출하면 된다. + +## 환경변수 + +| 변수 | 설명 | 기본값 | 필수 | +|------|------|--------|:----:| +| `DATABASE_URL` | PostgreSQL 비동기 연결 URL | - | O | +| `ADMIN_PASSWORD` | Admin API 비밀번호 | `""` | Admin API 사용 시 | +| `SOURCE_MODE_DEFAULT` | 기본 소스 모드 (`search`/`workbook`) | `search` | | +| `WORKBOOK_ID_DEFAULT` | workbook 모드 기본 ID | - | | +| `DIFFICULTY_MODE_DEFAULT` | 기본 난이도 모드 (`easy`/`hard`/`all`) | `easy` | | +| `TAG_MODE_DEFAULT` | 기본 태그 모드 | `easy` | | +| `LANG_DEFAULT` | 기본 언어 필터 | `all` | | +| `DIFFICULTY_EASY` | easy 난이도 범위 | `6..10` | | +| `DIFFICULTY_HARD` | hard 난이도 범위 | `11..15` | | +| `DIFFICULTY_ALL` | all 난이도 범위 | `1..30` | | +| `TAGS_EASY` / `TAGS_HARD` / `TAGS_ALL` | 태그 프리셋 (CSV) | `""` | | +| `TAG_PICK` / `TAG_PICK_EASY` 등 | 태그 선택 정책 (`random`/`none`/전체) | `random` | | +| `TAGS_JOIN` | 태그 결합 방식 (`or`/`and`) | `or` | | + +## 프로젝트 구조 + +``` +app.py # FastAPI 엔트리포인트, 라우팅 +utils.py # 환경변수 헬퍼, solved.ac 검색 쿼리 빌드, HTTP 호출 +db.py # SQLAlchemy async 엔진/세션 +workbook_picker.py # 문제집에서 미발송 문제 1개 선택 + 발송 기록 +workbook_enricher.py # solved.ac로 문제 메타데이터 채우기 +workbook_importer.py # BOJ 문제집 크롤링 (현재 미사용) +dockerfile # Docker 이미지 정의 +requirements.txt # Python 의존성 +``` ## CI/CD -- Workflow: `.gitea/workflows/cicd.yml` -- Trigger: `main` 브랜치로 `push` 시 실행 -- Flow: - 1) (수동) checkout: Gitea 서브패스(`/gitea`)를 고려해 `git init` + `git fetch`로 소스 가져옴 - 2) Docker Hub 로그인 - 3) 이미지 빌드/푸시: `${DOCKERHUB_USERNAME}/baekjoon-bot:latest` - 4) 서버 배포: 서버에 존재하는 app 관련 compose 파일(`/nkeysworld/compose.apps.yml`)로 `pull/up -d` 수행 - 5) Discord Webhook으로 성공/실패 알림 전송 -### Required Secrets -| Key | Used for | -|---|---| -| `NKEY_PAT` | workflow 내 수동 checkout 시 Gitea repo fetch 인증 | -| `DOCKERHUB_USERNAME` | Docker Hub 이미지 네임스페이스 | -| `DOCKERHUB_TOKEN` | Docker Hub 로그인 토큰 | -| `DISCORD_WEBHOOK` | CI/CD 결과 알림 전송 | +- **Workflow**: `.gitea/workflows/cicd.yml` +- **Trigger**: `main` 브랜치 push (단, 문서 파일만 변경된 경우 스킵) +- **Flow**: 수동 checkout → Docker Hub 빌드/푸시 → 서버 배포(`compose.apps.yml`) → Discord 알림 -> NOTE: workflow의 빌드 단계는 `docker build -t ... .`(기본 Dockerfile 사용) 형태입니다. 레포의 빌드 파일명은 `dockerfile`(소문자)이므로, CI 환경에서 기본 Dockerfile을 쓰려면 파일명/옵션 정합성을 확인하세요. +### 필요한 Secrets + +| Secret | 용도 | +|--------|------| +| `NKEY_PAT` | Gitea repo fetch 인증 | +| `DOCKERHUB_USERNAME` | Docker Hub 네임스페이스 | +| `DOCKERHUB_TOKEN` | Docker Hub 로그인 | +| `DISCORD_WEBHOOK` | CI/CD 결과 알림 | + +## 상세 문서 + +- [API 레퍼런스](docs/API.md) +- [개발 가이드](docs/DEVELOPMENT.md) +- [운영 가이드](docs/OPERATIONS.md) diff --git a/docs/API.md b/docs/API.md index 1ece057..7485489 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,105 +1,157 @@ # API Reference -작성: AI / 수정: nkey ## Base URL -- 로컬: `http://localhost:8000` -## Authentication -- Admin API 전용 - - Header: `X-Admin-Password: ` - - `ADMIN_PASSWORD` 미설정 시 admin API는 500으로 실패합니다. +`http://localhost:8000` -## Endpoints (Top) +## 인증 -### GET / -상태 확인(헬스체크). -```bash -curl -s http://localhost:8000/ -``` - -### GET /today -오늘의 추천 문제를 반환합니다. 기본은 solved.ac 검색(search) 모드이며, workbook 모드로도 동작합니다. - -#### Query Parameters -- `source_mode` (string): `search|workbook` (기본값: `SOURCE_MODE_DEFAULT` 또는 `search`) -- workbook 모드: - - `workbook_id` (int, optional): workbook id. 미지정 시 `WORKBOOK_ID_DEFAULT` 사용 시도 - - `workbook_pick` (string): `random|level_asc` (기본: `level_asc`) -- search 모드(필터): - - `difficulty_mode` (string): `easy|hard|all` (기본값: `DIFFICULTY_MODE_DEFAULT` 또는 `easy`) - - `tag_mode` (string): `easy|hard|all` (기본값: `TAG_MODE_DEFAULT` 또는 `easy`) - - `difficulty` (string, optional): 예 `6..10` (지정 시 `difficulty_mode`보다 우선) - - `tags` (string, optional): 예 `dp,graphs` (지정 시 `tag_mode`보다 우선) - - `lang` (string): `ko | en | ko,en | all` (기본값: `LANG_DEFAULT` 또는 `all`) - -#### Examples -- 기본(search) 모드: -```bash -curl -s "http://localhost:8000/today" | cat -``` - -- 난이도/태그/언어 직접 지정: -```bash -curl -s "http://localhost:8000/today?difficulty=6..10&tags=dp,graphs&lang=ko,en" | cat -``` - -- workbook 모드: -```bash -curl -s "http://localhost:8000/today?source_mode=workbook&workbook_id=12345&workbook_pick=level_asc" | cat -``` - -#### Response (성공 예시 형태) -- 공통: `problemId`, `title`, `level`, `problemUrl`, `solvedUrl`, `discordPayload` 포함 -- search 모드: `query`, `difficulty`, `tags`, `difficulty_mode`, `tag_mode`, `lang` 포함 -- workbook 모드: `workbook_id` 포함 - -#### Errors -- workbook 모드에서 `workbook_id`를 확정할 수 없을 때: 400 -- workbook 모드에서 더 이상 뽑을 문제가 없을 때: 409 (`no_more_problems_in_workbook`) -- search 모드에서 solved.ac 조회 실패: 503 (`failed_to_fetch_problem`) +Admin API(`/admin/*`)는 `X-Admin-Password` 헤더가 필요하다. +서버의 `ADMIN_PASSWORD` 환경변수와 일치해야 하며, 미설정 시 500 에러를 반환한다. --- -### POST /admin/workbooks/{workbook_id}/enrich -workbook에 포함된 문제들의 메타(제목/레벨/태그)를 solved.ac `problem/show`로 채웁니다. (DB 필요) +## GET / -#### Auth -- `X-Admin-Password` 필요 +헬스체크. -#### Query Parameters -- `only_missing` (bool, default `true`): `true`면 NULL만 채움 / `false`면 덮어씀 -- `commit_every` (int, default `50`, 1~500): 몇 개마다 commit할지 -- `sleep_sec` (float, default `0.12`, 0.0~2.0): solved.ac 호출 사이 대기 +**응답**: `{"status": "ok"}` + +--- + +## GET /today + +오늘의 추천 문제를 반환한다. Search 모드와 Workbook 모드를 지원한다. + +### 공통 파라미터 + +| 파라미터 | 타입 | 설명 | 기본값 | +|----------|------|------|--------| +| `source_mode` | string | `search` 또는 `workbook` | `SOURCE_MODE_DEFAULT` or `search` | + +### Search 모드 파라미터 + +| 파라미터 | 타입 | 설명 | 기본값 | +|----------|------|------|--------| +| `difficulty_mode` | string | `easy`/`hard`/`all` | `DIFFICULTY_MODE_DEFAULT` or `easy` | +| `tag_mode` | string | `easy`/`hard`/`all` | `TAG_MODE_DEFAULT` or `easy` | +| `difficulty` | string | 난이도 범위 (예: `6..10`). 지정 시 `difficulty_mode`보다 우선 | - | +| `tags` | string | 태그 (예: `dp,graphs`). 지정 시 `tag_mode`보다 우선 | - | +| `lang` | string | 언어 필터: `ko`/`en`/`ko,en`/`all` | `LANG_DEFAULT` or `all` | + +### Workbook 모드 파라미터 + +| 파라미터 | 타입 | 설명 | 기본값 | +|----------|------|------|--------| +| `workbook_id` | int | 문제집 ID | `WORKBOOK_ID_DEFAULT` | +| `workbook_pick` | string | `random` 또는 `level_asc` | `level_asc` | + +### 응답 (Search 모드) + +```json +{ + "source_mode": "search", + "difficulty_mode": "easy", + "tag_mode": "easy", + "lang": "all", + "difficulty": "6..10", + "tags": ["dp"], + "query": "*6..10 (tag:dp)", + "problemId": 1234, + "title": "문제 제목", + "level": 8, + "problemUrl": "https://www.acmicpc.net/problem/1234", + "solvedUrl": "https://solved.ac/problems/id/1234", + "discordPayload": { "embeds": [...] } +} +``` + +### 응답 (Workbook 모드) + +```json +{ + "source_mode": "workbook", + "workbook_id": 12345, + "problemId": 1234, + "title": "문제 제목", + "level": 8, + "problemUrl": "https://www.acmicpc.net/problem/1234", + "solvedUrl": "https://solved.ac/problems/id/1234", + "discordPayload": { "embeds": [...] } +} +``` + +### 에러 + +| 상태 코드 | 조건 | +|-----------|------| +| 400 | `difficulty_mode`/`tag_mode` 값이 유효하지 않음 | +| 400 | Workbook 모드에서 `workbook_id`를 확정할 수 없음 | +| 409 | Workbook에서 더 이상 뽑을 문제가 없음 (`no_more_problems_in_workbook`) | +| 503 | Search 모드에서 solved.ac 조회 실패 (`failed_to_fetch_problem`) | + +--- + +## POST /admin/workbooks/{workbook_id}/enrich + +문제집에 포함된 문제들의 메타데이터(제목, 난이도, 태그)를 solved.ac API로 채운다. + +### 파라미터 + +| 파라미터 | 타입 | 설명 | 기본값 | +|----------|------|------|--------| +| `only_missing` | bool | `true`면 NULL 필드만 채움, `false`면 전체 덮어씀 | `true` | +| `commit_every` | int (1~500) | DB 커밋 배치 크기 | `50` | +| `sleep_sec` | float (0.0~2.0) | solved.ac 호출 간 대기 시간 | `0.12` | + +### 요청 예시 -#### Example ```bash curl -s -X POST \ -H "X-Admin-Password: change-me" \ - "http://localhost:8000/admin/workbooks/12345/enrich?only_missing=true&commit_every=50&sleep_sec=0.12" | cat + "http://localhost:8000/admin/workbooks/12345/enrich?only_missing=true" ``` -#### Response -- `status: ok` -- `result`에 `target_count`, `updated`, `skipped`, `failed` 등 요약 포함 +### 응답 + +```json +{ + "status": "ok", + "result": { + "workbook_id": 12345, + "target_count": 100, + "updated": 95, + "skipped": 3, + "failed": 2, + "only_missing": true, + "commit_every": 50, + "sleep_sec": 0.12, + "message": "enrich done" + } +} +``` --- -### DELETE /admin/workbooks/{workbook_id}/reset -해당 workbook의 전송(픽) 기록을 초기화하여, 다시 문제를 뽑을 수 있게 합니다. (DB 필요) +## DELETE /admin/workbooks/{workbook_id}/reset -#### Auth -- `X-Admin-Password` 필요 +문제집의 발송 기록(`workbook_sends`)을 초기화하여 모든 문제를 다시 추천받을 수 있게 한다. + +### 요청 예시 -#### Example ```bash curl -s -X DELETE \ -H "X-Admin-Password: change-me" \ - "http://localhost:8000/admin/workbooks/12345/reset" | cat + "http://localhost:8000/admin/workbooks/12345/reset" ``` -#### Response -- `deleted_sends`: 삭제된 기록 수 +### 응답 -## Data Models (참고) -- `discordPayload`는 Discord Webhook/봇 메시지에 사용할 수 있는 `embeds` 구조를 포함합니다. - - 실제 전송(Discord webhook 호출)은 이 서비스 밖(외부 스케줄러/워크플로우)에서 수행하는 것을 전제로 합니다. +```json +{ + "status": "ok", + "workbook_id": 12345, + "deleted_sends": 42, + "message": "workbook progress reset (problems can be picked again)" +} +``` diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index adcac57..090d4e5 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,28 +1,23 @@ # Development Guide -작성: AI / 수정: nkey -## Project Layout -- `app.py` : FastAPI 엔트리포인트, 라우팅(`/`, `/today`, `/admin/...`) -- `utils.py` : 환경변수 헬퍼, solved.ac 검색 쿼리 생성, admin 인증, solved.ac 검색 호출 -- `db.py` : SQLAlchemy Async 엔진/세션 및 `get_db()` DI -- `workbook_picker.py` : DB에서 “아직 보내지 않은 문제” 1개 선택 + send 기록 -- `workbook_enricher.py` : solved.ac `problem/show`로 workbook 문제 메타(제목/레벨/태그) 채우기 -- `workbook_importer.py` : BOJ 문제집 페이지에서 문제 ID 수집 + DB upsert (현재 `app.py`에서 라우팅은 주석 처리됨) -- `requirements.txt` : 파이썬 의존성 -- `dockerfile` : 컨테이너 실행 정의(uvicorn, 8000) +## 프로젝트 구조 -## Prerequisites -- Python 3.12+ 권장 (Docker 이미지 기준: `python:3.12-slim`) -- PostgreSQL (workbook 모드/관리 API 사용 시) -- pip / venv +| 파일 | 역할 | +|------|------| +| `app.py` | FastAPI 엔트리포인트. `/`, `/today`, `/admin/*` 라우팅 | +| `utils.py` | 환경변수 헬퍼, solved.ac 검색 쿼리 빌드, HTTP 호출(retry), admin 인증 | +| `db.py` | SQLAlchemy async 엔진/세션 설정, `get_db()` DI | +| `workbook_picker.py` | 문제집에서 미발송 문제 1개 선택 + `workbook_sends` 기록 | +| `workbook_enricher.py` | solved.ac `problem/show`로 문제 메타데이터 채우기 | +| `workbook_importer.py` | BOJ 문제집 페이지 크롤링 (현재 미사용, BOJ 스크래핑 차단) | + +## 로컬 셋업 -## Local Setup ```bash git clone https://nkeystudy.site/gitea/nkey/baekjoon-bot.git cd baekjoon-bot -python -m venv .venv -source .venv/bin/activate +python -m venv .venv && source .venv/bin/activate pip install -r requirements.txt cat > .env <<'EOF' @@ -30,52 +25,39 @@ DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME ADMIN_PASSWORD=change-me EOF -# FastAPI runs uvicorn app:app --reload --host 0.0.0.0 --port 8000 - -# sanity check -curl -s http://localhost:8000/ | cat ``` -## Common Commands -레포에 Makefile/스크립트/테스트 명령이 따로 정의되어 있지 않아, 일반적인 실행 명령만 기재합니다. -```bash -# run -uvicorn app:app --reload --host 0.0.0.0 --port 8000 +## 의존성 -# install deps -pip install -r requirements.txt -``` +`requirements.txt` 기준: -## Environment Variables (Dev) -- 필수: `DATABASE_URL` -- Admin API 개발/테스트 시: `ADMIN_PASSWORD` -- `/today`의 기본값을 바꾸려면 아래 변수를 사용: - - `SOURCE_MODE_DEFAULT`, `WORKBOOK_ID_DEFAULT` - - `DIFFICULTY_MODE_DEFAULT`, `TAG_MODE_DEFAULT`, `LANG_DEFAULT` - - `DIFFICULTY_EASY|HARD|ALL`, `TAGS_EASY|HARD|ALL`, `TAG_PICK*`, `TAGS_JOIN` +- `fastapi` / `uvicorn` - 웹 프레임워크 / ASGI 서버 +- `sqlalchemy` / `asyncpg` - 비동기 PostgreSQL ORM +- `requests` / `httpx` - 동기/비동기 HTTP 클라이언트 +- `python-dotenv` - `.env` 파일 로딩 +- `beautifulsoup4` / `lxml` - HTML 파싱 (workbook_importer용, 현재 미사용) -## DB/Migrations (if applicable) -- 코드에서 참조하는 테이블(DDL은 레포에 없음): - - `workbooks`, `workbook_problems`, `workbook_sends` -```bash --- 1) 문제집 +## 데이터베이스 + +PostgreSQL 필수. DDL은 레포에 포함되어 있지 않으므로 수동으로 생성해야 한다. + +```sql CREATE TABLE IF NOT EXISTS workbooks ( - id BIGINT PRIMARY KEY, -- BOJ workbook id 그대로 사용 + id BIGINT PRIMARY KEY, title TEXT, - source TEXT NOT NULL DEFAULT 'boj', -- 'boj' 고정(필요하면 확장) + source TEXT NOT NULL DEFAULT 'boj', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); --- 2) 문제집-문제 목록 CREATE TABLE IF NOT EXISTS workbook_problems ( workbook_id BIGINT NOT NULL REFERENCES workbooks(id) ON DELETE CASCADE, problem_id INTEGER NOT NULL, title_ko TEXT, title_en TEXT, - level INTEGER, -- solved.ac level(0~30) - tags TEXT[] DEFAULT NULL, -- optional + level INTEGER, + tags TEXT[] DEFAULT NULL, added_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (workbook_id, problem_id) ); @@ -83,7 +65,6 @@ CREATE TABLE IF NOT EXISTS workbook_problems ( CREATE INDEX IF NOT EXISTS idx_workbook_problems_workbook ON workbook_problems(workbook_id); --- 3) 발송/사용 기록(문제집 단위 중복 방지 핵심) CREATE TABLE IF NOT EXISTS workbook_sends ( workbook_id BIGINT NOT NULL REFERENCES workbooks(id) ON DELETE CASCADE, problem_id INTEGER NOT NULL, @@ -93,40 +74,26 @@ CREATE TABLE IF NOT EXISTS workbook_sends ( CREATE INDEX IF NOT EXISTS idx_workbook_sends_workbook ON workbook_sends(workbook_id); - ``` -## CI/CD (Gitea Actions) -Workflow: `.gitea/workflows/cicd.yml` +### 테이블 설명 -### Trigger -- `main` 브랜치로 `push` 시 실행 +| 테이블 | 역할 | +|--------|------| +| `workbooks` | 문제집 메타 (BOJ workbook id를 PK로 사용) | +| `workbook_problems` | 문제집-문제 매핑 + 메타데이터 (제목, 난이도, 태그) | +| `workbook_sends` | 발송 기록. 중복 추천 방지의 핵심 | -### Secrets (레포 Actions/Secrets에 등록) -| Key | Purpose | -|---|---| -| `NKEY_PAT` | 워크플로우에서 Gitea repo를 수동 checkout(fetch)할 때 사용 | -| `DOCKERHUB_USERNAME` | Docker Hub 이미지 네임스페이스 | -| `DOCKERHUB_TOKEN` | Docker Hub 로그인 토큰 | -| `DISCORD_WEBHOOK` | 성공/실패 알림 전송 | +## 환경변수 -### Build & Push -- 워크플로우는 아래와 동일한 형태로 이미지를 빌드/푸시합니다. -```bash -IMAGE="${DOCKERHUB_USERNAME}/baekjoon-bot:latest" -docker build -t "${IMAGE}" . -docker push "${IMAGE}" -``` +필수: +- `DATABASE_URL` - PostgreSQL 연결 URL (미설정 시 앱 시작 불가) -> NOTE: 위 커맨드는 기본 Dockerfile을 사용합니다. 레포에 있는 파일은 `dockerfile`(소문자)이므로, 로컬/CI에서 동일하게 동작시키려면 파일명/옵션(`-f dockerfile`) 정합성을 확인하세요. +Admin API 사용 시: +- `ADMIN_PASSWORD` - `X-Admin-Password` 헤더와 비교 -### Deploy -- 워크플로우 배포는 레포 내부 파일이 아닌 서버(또는 self-hosted runner)에 존재하는 compose 파일을 사용합니다. -```bash -docker compose -f /nkeysworld/compose.apps.yml pull baekjoon-bot -docker compose -f /nkeysworld/compose.apps.yml up -d baekjoon-bot -docker image prune -f -``` - -### Notifications -- 워크플로우는 항상(`if: always()`) Discord webhook으로 결과를 전송합니다. +검색 기본값 커스터마이징: +- `SOURCE_MODE_DEFAULT`, `WORKBOOK_ID_DEFAULT` +- `DIFFICULTY_MODE_DEFAULT`, `TAG_MODE_DEFAULT`, `LANG_DEFAULT` +- `DIFFICULTY_EASY`/`HARD`/`ALL`, `TAGS_EASY`/`HARD`/`ALL` +- `TAG_PICK`, `TAG_PICK_EASY`/`HARD`/`ALL`, `TAGS_JOIN` diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 61c33a3..2de650a 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -1,138 +1,94 @@ # Operations Guide -작성: AI / 수정: nkey -## Supported Deployment Modes -- Dockerfile 기반 단일 컨테이너 실행 (`dockerfile`) -- (참고) 로컬에서 Uvicorn 직접 실행 +## 배포 방식 +### Docker (수동) -## Production Checklist -- Secrets/ENV: - - `DATABASE_URL`는 필수입니다. - - Admin API를 운영에서 사용할 경우 `ADMIN_PASSWORD`를 반드시 설정하고, 클라이언트에서 `X-Admin-Password` 헤더로 전달해야 합니다. -- Networking: - - 인바운드: TCP 8000 (API) - - 아웃바운드: solved.ac API, acmicpc.net(백준) 접근 필요(문제/문제집 정보 조회). -- Storage: - - 앱 자체는 로컬 파일 저장을 전제로 하지 않습니다. - - DB는 외부 PostgreSQL을 사용합니다. (주의) 레포에 DDL/마이그레이션이 포함되어 있지 않으므로, 운영 DB 스키마는 별도로 준비되어 있어야 합니다. -- Observability: - - 기본 Uvicorn 로그(stdout) 기반. 별도 로깅/메트릭 구성은 레포에 없습니다. -- Scaling: - - `/today`의 search 모드는 DB 없이도 동작 가능(단, 코드상 DB 의존성 주입은 존재). - - workbook 모드는 DB 상태(workbook_sends)에 의해 결과가 달라지므로, 다중 인스턴스 운영 시 동일 DB를 바라보도록 구성해야 합니다. - -## Deployment -### Docker ```bash -# build (주의: 파일명이 'Dockerfile'이 아니라 'dockerfile' 입니다) docker build -f dockerfile -t baekjoon-bot:prod . -# run -cat > .env <<'EOF' -DATABASE_URL=postgresql+asyncpg://USER:PASSWORD@HOST:5432/DBNAME -ADMIN_PASSWORD=change-me -EOF - docker run -d --name baekjoon-bot \ --env-file .env \ -p 8000:8000 \ baekjoon-bot:prod - -# verify -curl -s http://localhost:8000/ | cat ``` -## Operations -### Healthcheck -- `GET /` - - 성공 조건: `{"status":"ok"}` 응답 +### CI/CD (Gitea Actions) + +Workflow: `.gitea/workflows/cicd.yml` + +**트리거**: `main` 브랜치 push (문서 파일만 변경된 경우 스킵) + +**파이프라인**: +1. 수동 checkout (Gitea `/gitea` 서브패스 대응) +2. Docker Hub 로그인 +3. 이미지 빌드/푸시: `${DOCKERHUB_USERNAME}/baekjoon-bot:latest` +4. 서버 배포: `/nkeysworld/compose.apps.yml`에서 `baekjoon-bot` 서비스 pull/up +5. 정리: `docker image prune -f` +6. Discord webhook 알림 (성공/실패) + +**필요 Secrets**: +- `NKEY_PAT` - Gitea repo fetch 인증 +- `DOCKERHUB_USERNAME` / `DOCKERHUB_TOKEN` - Docker Hub 인증 +- `DISCORD_WEBHOOK` - 알림 전송 + +**서버 요구사항**: +- `docker`, `docker compose` 실행 가능 +- `/nkeysworld/compose.apps.yml` 파일 존재 +- compose 내 서비스명 `baekjoon-bot` 존재 + +## 운영 체크리스트 + +- `DATABASE_URL` 환경변수 필수 +- Admin API 사용 시 `ADMIN_PASSWORD` 설정 필수 +- 인바운드: TCP 8000 +- 아웃바운드: solved.ac API, acmicpc.net 접근 필요 +- DB 스키마는 별도 준비 필요 (레포에 DDL 미포함, `docs/DEVELOPMENT.md` 참고) +- Uvicorn stdout 기반 로깅 + +## 헬스체크 + +```bash +curl -s http://localhost:8000/ +# {"status": "ok"} +``` + +## 로그 확인 -### Logs -- Docker 실행 시: ```bash docker logs -f baekjoon-bot ``` -## Security Notes (only with evidence) -- Admin API는 `X-Admin-Password` 헤더 기반 단일 비밀번호 체크입니다. - - `ADMIN_PASSWORD`가 비어 있으면 admin API 호출 시 500으로 실패합니다(설정 누락). -- `.env`는 `.gitignore`에 포함되어 있어 커밋되지 않도록 되어 있습니다. +## 장애 대응 -## Incident Runbook (Top 5) -1) **env 누락** -- Symptom: 앱 시작 시 `DATABASE_URL is required...`로 즉시 종료 -- Cause: `DATABASE_URL` 미설정 -- Fix: `.env` 또는 런타임 환경변수에 `DATABASE_URL` 설정 -- Verify: 컨테이너/프로세스 정상 기동 후 `GET /`가 200 +### 1. DATABASE_URL 누락 +- **증상**: 앱 시작 시 `DATABASE_URL is required...`로 즉시 종료 +- **조치**: `.env` 또는 환경변수에 `DATABASE_URL` 설정 -2) **포트 충돌** -- Symptom: 컨테이너 실행 시 `bind: address already in use` 또는 접근 불가 -- Cause: 호스트 8000 포트 사용 중 -- Fix: `-p 18000:8000` 같이 호스트 포트 변경 -- Verify: `curl http://localhost:18000/` +### 2. 포트 충돌 +- **증상**: `bind: address already in use` +- **조치**: `-p 18000:8000` 등으로 호스트 포트 변경 -3) **DB 연결 실패** -- Symptom: `/today` 또는 admin 호출 시 500, 로그에 DB 커넥션 에러 -- Cause: `DATABASE_URL` 잘못됨 / 네트워크/방화벽 / DB 다운 -- Fix: URL 재확인, DB 접근 허용, DB 상태 확인 -- Verify: `/today?source_mode=workbook&workbook_id=...`가 정상 응답(워크북 모드 사용 시) +### 3. DB 연결 실패 +- **증상**: API 호출 시 500, 로그에 DB 커넥션 에러 +- **조치**: URL 확인, DB 접근 허용, DB 상태 확인 -4) **Admin 인증 실패** -- Symptom: admin API가 403 `invalid admin password` -- Cause: `X-Admin-Password` 누락 또는 불일치 -- Fix: `ADMIN_PASSWORD` 값과 동일한 헤더 전달 -- Verify: `POST /admin/workbooks/{id}/enrich`가 200 +### 4. Admin 인증 실패 +- **증상**: admin API 403 `invalid admin password` +- **조치**: `ADMIN_PASSWORD` 값과 `X-Admin-Password` 헤더 일치 확인 -5) **문제집 소진** -- Symptom: `GET /today?source_mode=workbook&workbook_id=...`가 409 + `no_more_problems_in_workbook` -- Cause: 해당 workbook에서 아직 보내지 않은 문제 후보가 없음(`workbook_sends`에 모두 기록됨) -- Fix: 진행상황 초기화 API 호출(주의: 관리자 권한 필요) 또는 다른 workbook 사용 -- Verify: 초기화 후 workbook 모드 호출 시 정상 추천 반환 +### 5. 문제집 소진 +- **증상**: 409 `no_more_problems_in_workbook` +- **조치**: `DELETE /admin/workbooks/{id}/reset`으로 초기화하거나 다른 문제집 사용 -## Supported Deployment Modes -- (기존) Dockerfile 기반 단일 컨테이너 실행: `dockerfile` -- (추가) Gitea Actions 기반 CI/CD: `.gitea/workflows/cicd.yml` - - Docker Hub로 이미지 푸시 후, - - 러너/서버에서 `docker compose -f /nkeysworld/compose.apps.yml pull/up`으로 배포 +## 보안 -> 참고: 레포에는 Docker Compose 파일이 포함되어 있지 않습니다. CI/CD 배포는 서버에 존재하는 `/nkeysworld/compose.apps.yml`을 사용합니다. +- Admin API는 `X-Admin-Password` 헤더 기반 단일 비밀번호 인증 +- `ADMIN_PASSWORD` 미설정 시 admin API는 500 반환 (의도적 차단) +- `.env` 파일은 `.gitignore`에 포함 -## Deployment -### CI/CD (Gitea Actions) -Workflow: `.gitea/workflows/cicd.yml` +## 롤백 -#### Trigger -- `main` 브랜치로 `push` 시 `build_push_deploy` 잡 실행 - -#### What it does -- (1) Manual checkout (Gitea `/gitea` 서브패스 고려) -- (2) Docker Hub 로그인 -- (3) 이미지 빌드/푸시: `${DOCKERHUB_USERNAME}/baekjoon-bot:latest` -- (4) 배포: `docker compose -f /nkeysworld/compose.apps.yml pull baekjoon-bot` + `up -d baekjoon-bot` -- (5) 정리: `docker image prune -f` -- (6) 알림: Discord webhook (성공/실패 모두 전송) - -#### Required Secrets -- `NKEY_PAT`: `https://${ACTOR}:${TOKEN}@nkeystudy.site/gitea/${REPO}.git` fetch에 사용 -- `DOCKERHUB_USERNAME`, `DOCKERHUB_TOKEN`: `docker login`에 사용 -- `DISCORD_WEBHOOK`: 결과 알림 전송에 사용 - -#### Server/Runner Prerequisites -- `docker` 실행 가능 -- `docker compose` 실행 가능(워크플로우에 설치 보완 단계가 있으나, 기본적으로 compose가 동작해야 함) -- `/nkeysworld/compose.apps.yml` 파일이 존재해야 함 -- compose 내 서비스명이 `baekjoon-bot` 이어야 함(워크플로우가 해당 서비스만 `pull/up` 수행) - -#### Rollback (workflow 기준) -- 워크플로우는 `:latest`만 푸시/배포합니다. -- 롤백을 하려면(운영 정책에 따라): - - compose가 특정 태그로 pinning 되도록 변경하거나 - - `latest` 대신 태그 전략을 도입해야 합니다. - -## Operations -### Notifications -- CI/CD 결과는 Discord webhook(`DISCORD_WEBHOOK`)으로 전송됩니다. - - 성공: `Build & Deploy Success` - - 실패: `Build or Deploy Failed` - - 포함 정보: repo, commit sha, actor, timestamp +CI/CD는 `:latest` 태그만 사용하므로, 롤백이 필요한 경우: +- compose 파일에서 특정 태그로 고정하거나 +- 태그 전략을 도입해야 한다 diff --git a/requirements.txt b/requirements.txt index d77e83d..9caf7c4 100644 Binary files a/requirements.txt and b/requirements.txt differ