Compare commits
16 Commits
122b367bed
...
v2026-01-2
| Author | SHA1 | Date | |
|---|---|---|---|
| 25149d0ccf | |||
|
|
ac75dcce7e | ||
|
|
0ff648dd7e | ||
|
|
9ef7a01d9a | ||
|
|
66c5dbdb90 | ||
|
|
f113210631 | ||
|
|
6d522b4759 | ||
|
|
7eab1ba57b | ||
|
|
044c725656 | ||
|
|
a5d4895bb9 | ||
|
|
cd59ebbd03 | ||
|
|
3906de1803 | ||
|
|
5b67d6f192 | ||
|
|
edc3a58503 | ||
| ce040e1ce9 | |||
|
|
1a67844e41 |
@@ -3,4 +3,6 @@ __pycache__/
|
||||
*.py[cod]
|
||||
venv/
|
||||
.env
|
||||
.git/
|
||||
.git/
|
||||
|
||||
*.md
|
||||
79
.gitea/workflows/cicd.yml
Normal file
79
.gitea/workflows/cicd.yml
Normal file
@@ -0,0 +1,79 @@
|
||||
name: baekjoon-bot-cicd
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
build_push_deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Manual checkout (supports /gitea subpath)
|
||||
env:
|
||||
ACTOR: ${{ github.actor }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
TOKEN: ${{ secrets.NKEY_PAT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init .
|
||||
git remote add origin "https://${ACTOR}:${TOKEN}@nkeystudy.site/gitea/${REPO}.git"
|
||||
git fetch --no-tags --prune --depth=1 origin "${SHA}"
|
||||
git checkout -q FETCH_HEAD
|
||||
|
||||
- name: Ensure docker compose available
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker version
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
# docker:24-git(Alpine) 기준
|
||||
apk add --no-cache docker-cli-compose
|
||||
fi
|
||||
docker compose version
|
||||
|
||||
- name: Docker login
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "${DOCKERHUB_TOKEN}" | docker login -u "${DOCKERHUB_USER}" --password-stdin
|
||||
|
||||
- name: Build & push image
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
IMAGE_NAME: baekjoon-bot
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMAGE="${DOCKERHUB_USER}/${IMAGE_NAME}:latest"
|
||||
docker build -t "${IMAGE}" .
|
||||
docker push "${IMAGE}"
|
||||
|
||||
- name: Deploy on server (compose pull/up)
|
||||
run: |
|
||||
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
|
||||
|
||||
- name: Discord Notification
|
||||
if: always() # 빌드 성공/실패 여부와 상관없이 항상 실행
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
run: |
|
||||
if [ "${{ job.status }}" = "success" ]; then
|
||||
MESSAGE="🚀 **Build & Deploy Success!**"
|
||||
COLOR=3066993 # 녹색 계열
|
||||
else
|
||||
MESSAGE="⚠️ **Build or Deploy Failed!**"
|
||||
COLOR=15158332 # 빨간색 계열
|
||||
fi
|
||||
|
||||
curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"embeds": [{
|
||||
"title": "'"$MESSAGE"'",
|
||||
"description": "**Repo:** ${{ github.repository }}\n**Commit:** ${{ github.sha }}\n**Actor:** ${{ github.actor }}",
|
||||
"color": '"$COLOR"',
|
||||
"timestamp": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"
|
||||
}]
|
||||
}' $DISCORD_WEBHOOK
|
||||
112
README.md
Normal file
112
README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# baekjoon-bot(폴더 구조화 이전 버전)
|
||||
작성: AI / 수정: nkey
|
||||
|
||||
매일 백준(BOJ) 문제를 추천해주고, 디스코드 메시지(payload)로 쓸 수 있는 형태로 반환하는 FastAPI 서비스입니다.
|
||||
`/today`에서 추천 문제 + 링크 + Discord embed payload를 생성하며, (선택) PostgreSQL에 문제집(workbook) 진행상황을 저장해 “문제집에서 아직 안 보낸 문제”를 고르는 모드도 제공합니다.
|
||||
디스코드 메시지 자동화는 n8n을 활용하였습니다.
|
||||
|
||||
## 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
|
||||
pip install -r requirements.txt
|
||||
|
||||
# env (필수: DATABASE_URL)
|
||||
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
|
||||
```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
|
||||
|
||||
## 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` | |
|
||||
|
||||
### 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: ...`
|
||||
|
||||
## Docs
|
||||
- Operations: `docs/OPERATIONS.md`
|
||||
- Development: `docs/DEVELOPMENT.md`
|
||||
- API: `docs/API.md`
|
||||
|
||||
## 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 결과 알림 전송 |
|
||||
|
||||
> NOTE: workflow의 빌드 단계는 `docker build -t ... .`(기본 Dockerfile 사용) 형태입니다. 레포의 빌드 파일명은 `dockerfile`(소문자)이므로, CI 환경에서 기본 Dockerfile을 쓰려면 파일명/옵션 정합성을 확인하세요.
|
||||
7
app.py
7
app.py
@@ -6,11 +6,11 @@ from dotenv import load_dotenv
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from utils import env, resolve_difficulty, resolve_tags, build_query, get_problem
|
||||
from utils import env, resolve_difficulty, resolve_tags, build_query, get_problem, require_admin
|
||||
from db import get_db
|
||||
from workbook_picker import pick_from_workbook
|
||||
from workbook_importer import import_workbook
|
||||
from workbook_enricher import enrich_workbook
|
||||
# from workbook_importer import import_workbook
|
||||
|
||||
|
||||
load_dotenv()
|
||||
@@ -30,6 +30,7 @@ async def admin_enrich_workbook(
|
||||
commit_every: int = Query(50, ge=1, le=500, description="몇 개마다 commit 할지"),
|
||||
sleep_sec: float = Query(0.12, ge=0.0, le=2.0, description="solved.ac 호출 사이 sleep"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = Depends(require_admin),
|
||||
):
|
||||
result = await enrich_workbook(
|
||||
db,
|
||||
@@ -45,6 +46,7 @@ async def admin_enrich_workbook(
|
||||
async def reset_workbook_progress(
|
||||
workbook_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_: None = Depends(require_admin),
|
||||
):
|
||||
try:
|
||||
res = await db.execute(
|
||||
@@ -174,6 +176,7 @@ async def today(
|
||||
}
|
||||
|
||||
|
||||
# 백준 사이트는 beautifulsoap 크롤링이 안됨
|
||||
# @app.post("/admin/workbooks/{workbook_id}/import")
|
||||
# async def admin_import_workbook(
|
||||
# workbook_id: int,
|
||||
|
||||
105
docs/API.md
Normal file
105
docs/API.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# API Reference
|
||||
작성: AI / 수정: nkey
|
||||
|
||||
## Base URL
|
||||
- 로컬: `http://localhost:8000`
|
||||
|
||||
## Authentication
|
||||
- Admin API 전용
|
||||
- Header: `X-Admin-Password: <ADMIN_PASSWORD>`
|
||||
- `ADMIN_PASSWORD` 미설정 시 admin API는 500으로 실패합니다.
|
||||
|
||||
## 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`)
|
||||
|
||||
---
|
||||
|
||||
### POST /admin/workbooks/{workbook_id}/enrich
|
||||
workbook에 포함된 문제들의 메타(제목/레벨/태그)를 solved.ac `problem/show`로 채웁니다. (DB 필요)
|
||||
|
||||
#### 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 호출 사이 대기
|
||||
|
||||
#### 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
|
||||
```
|
||||
|
||||
#### Response
|
||||
- `status: ok`
|
||||
- `result`에 `target_count`, `updated`, `skipped`, `failed` 등 요약 포함
|
||||
|
||||
---
|
||||
|
||||
### DELETE /admin/workbooks/{workbook_id}/reset
|
||||
해당 workbook의 전송(픽) 기록을 초기화하여, 다시 문제를 뽑을 수 있게 합니다. (DB 필요)
|
||||
|
||||
#### Auth
|
||||
- `X-Admin-Password` 필요
|
||||
|
||||
#### Example
|
||||
```bash
|
||||
curl -s -X DELETE \
|
||||
-H "X-Admin-Password: change-me" \
|
||||
"http://localhost:8000/admin/workbooks/12345/reset" | cat
|
||||
```
|
||||
|
||||
#### Response
|
||||
- `deleted_sends`: 삭제된 기록 수
|
||||
|
||||
## Data Models (참고)
|
||||
- `discordPayload`는 Discord Webhook/봇 메시지에 사용할 수 있는 `embeds` 구조를 포함합니다.
|
||||
- 실제 전송(Discord webhook 호출)은 이 서비스 밖(외부 스케줄러/워크플로우)에서 수행하는 것을 전제로 합니다.
|
||||
132
docs/DEVELOPMENT.md
Normal file
132
docs/DEVELOPMENT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# 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
|
||||
|
||||
## Local Setup
|
||||
```bash
|
||||
git clone https://nkeystudy.site/gitea/nkey/baekjoon-bot.git
|
||||
cd baekjoon-bot
|
||||
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
cat > .env <<'EOF'
|
||||
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
|
||||
```
|
||||
|
||||
## 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`
|
||||
|
||||
## DB/Migrations (if applicable)
|
||||
- 코드에서 참조하는 테이블(DDL은 레포에 없음):
|
||||
- `workbooks`, `workbook_problems`, `workbook_sends`
|
||||
```bash
|
||||
-- 1) 문제집
|
||||
CREATE TABLE IF NOT EXISTS workbooks (
|
||||
id BIGINT PRIMARY KEY, -- BOJ workbook id 그대로 사용
|
||||
title TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'boj', -- '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
|
||||
added_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workbook_id, problem_id)
|
||||
);
|
||||
|
||||
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,
|
||||
sent_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (workbook_id, problem_id)
|
||||
);
|
||||
|
||||
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` 시 실행
|
||||
|
||||
### 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}"
|
||||
```
|
||||
|
||||
> NOTE: 위 커맨드는 기본 Dockerfile을 사용합니다. 레포에 있는 파일은 `dockerfile`(소문자)이므로, 로컬/CI에서 동일하게 동작시키려면 파일명/옵션(`-f dockerfile`) 정합성을 확인하세요.
|
||||
|
||||
### 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으로 결과를 전송합니다.
|
||||
138
docs/OPERATIONS.md
Normal file
138
docs/OPERATIONS.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Operations Guide
|
||||
작성: AI / 수정: nkey
|
||||
|
||||
## Supported Deployment Modes
|
||||
- Dockerfile 기반 단일 컨테이너 실행 (`dockerfile`)
|
||||
- (참고) 로컬에서 Uvicorn 직접 실행
|
||||
|
||||
|
||||
## 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"}` 응답
|
||||
|
||||
### 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
|
||||
|
||||
2) **포트 충돌**
|
||||
- Symptom: 컨테이너 실행 시 `bind: address already in use` 또는 접근 불가
|
||||
- Cause: 호스트 8000 포트 사용 중
|
||||
- Fix: `-p 18000:8000` 같이 호스트 포트 변경
|
||||
- Verify: `curl http://localhost:18000/`
|
||||
|
||||
3) **DB 연결 실패**
|
||||
- Symptom: `/today` 또는 admin 호출 시 500, 로그에 DB 커넥션 에러
|
||||
- Cause: `DATABASE_URL` 잘못됨 / 네트워크/방화벽 / DB 다운
|
||||
- Fix: URL 재확인, DB 접근 허용, DB 상태 확인
|
||||
- Verify: `/today?source_mode=workbook&workbook_id=...`가 정상 응답(워크북 모드 사용 시)
|
||||
|
||||
4) **Admin 인증 실패**
|
||||
- Symptom: admin API가 403 `invalid admin password`
|
||||
- Cause: `X-Admin-Password` 누락 또는 불일치
|
||||
- Fix: `ADMIN_PASSWORD` 값과 동일한 헤더 전달
|
||||
- Verify: `POST /admin/workbooks/{id}/enrich`가 200
|
||||
|
||||
5) **문제집 소진**
|
||||
- Symptom: `GET /today?source_mode=workbook&workbook_id=...`가 409 + `no_more_problems_in_workbook`
|
||||
- Cause: 해당 workbook에서 아직 보내지 않은 문제 후보가 없음(`workbook_sends`에 모두 기록됨)
|
||||
- Fix: 진행상황 초기화 API 호출(주의: 관리자 권한 필요) 또는 다른 workbook 사용
|
||||
- Verify: 초기화 후 workbook 모드 호출 시 정상 추천 반환
|
||||
|
||||
## 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`을 사용합니다.
|
||||
|
||||
## 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
|
||||
12
utils.py
12
utils.py
@@ -5,6 +5,8 @@ from typing import Optional, Tuple, List
|
||||
|
||||
import requests
|
||||
|
||||
from fastapi import HTTPException, Header
|
||||
|
||||
# ====== HTTP Session ======
|
||||
SESSION = requests.Session()
|
||||
SESSION.headers.update({"User-Agent": "baekjoon-n8n-bot/1.0"})
|
||||
@@ -13,6 +15,16 @@ SESSION.headers.update({"User-Agent": "baekjoon-n8n-bot/1.0"})
|
||||
KNOWN_LANGS = ["ko", "en", "ja", "ru", "zh", "de", "fr", "es", "pt", "it"]
|
||||
|
||||
|
||||
def require_admin(x_admin_password: str | None = Header(default=None, alias="X-Admin-Password")):
|
||||
expected = env("ADMIN_PASSWORD", "")
|
||||
if not expected:
|
||||
raise HTTPException(status_code=500, detail="ADMIN_PASSWORD is not configured")
|
||||
|
||||
if not x_admin_password or x_admin_password != expected:
|
||||
raise HTTPException(status_code=403, detail="invalid admin password")
|
||||
|
||||
|
||||
|
||||
def fetch_json_with_retry(url: str, params: dict, retries: int = 3, timeout=(3.05, 10)) -> dict:
|
||||
last_err = None
|
||||
for i in range(retries):
|
||||
|
||||
Reference in New Issue
Block a user