Add Maia chess gameplay and deployment
Some checks failed
Deploy Blog Backend / test (push) Successful in 2m6s
Deploy Blog Backend / deploy (push) Failing after 1m8s

This commit is contained in:
박원엽
2026-06-19 14:49:20 +09:00
parent b47258ea53
commit 3042dc898e
26 changed files with 1682 additions and 32 deletions

View File

@@ -19,9 +19,17 @@ jobs:
distribution: temurin
java-version: "21"
- name: Run tests
- name: Run backend tests
run: ./gradlew test --no-daemon
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Compile Maia engine bridge
run: python -m py_compile maia-engine/app/main.py
deploy:
runs-on: ubuntu-latest
needs: test
@@ -38,7 +46,16 @@ jobs:
- name: Build boot jar
run: ./gradlew clean bootJar -x test --no-daemon
- name: Deploy jar over SSH
- name: Package Maia engine
run: |
set -euo pipefail
tar \
--exclude='__pycache__' \
--exclude='*.pyc' \
-czf maia-engine.tar.gz \
maia-engine
- name: Deploy backend and Maia engine over SSH
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
@@ -50,8 +67,11 @@ jobs:
port="${DEPLOY_PORT:-22}"
remote_dir="/root/blog-backend"
service_name="blog-api.service"
maia_service_name="maia-engine.service"
jar_name="blog-backend.jar"
remote_tmp="/tmp/${jar_name}.new"
maia_archive_tmp="/tmp/maia-engine.tar.gz.new"
maia_service_tmp="/tmp/maia-engine.service.new"
jar_file="$(find build/libs -maxdepth 1 -type f -name '*.jar' ! -name '*plain.jar' | sort | head -n 1)"
if [ -z "$jar_file" ]; then
@@ -65,12 +85,125 @@ jobs:
ssh-keyscan -p "$port" "$DEPLOY_HOST" >> ~/.ssh/known_hosts
scp -i ~/.ssh/blog-backend-deploy -P "$port" "$jar_file" "$DEPLOY_USER@$DEPLOY_HOST:$remote_tmp"
scp -i ~/.ssh/blog-backend-deploy -P "$port" maia-engine.tar.gz "$DEPLOY_USER@$DEPLOY_HOST:$maia_archive_tmp"
scp -i ~/.ssh/blog-backend-deploy -P "$port" deploy/systemd/maia-engine.service "$DEPLOY_USER@$DEPLOY_HOST:$maia_service_tmp"
ssh -i ~/.ssh/blog-backend-deploy -p "$port" "$DEPLOY_USER@$DEPLOY_HOST" \
"REMOTE_DIR='$remote_dir' SERVICE_NAME='$service_name' JAR_NAME='$jar_name' REMOTE_TMP='$remote_tmp' bash -s" <<'EOF'
"REMOTE_DIR='$remote_dir' SERVICE_NAME='$service_name' MAIA_SERVICE_NAME='$maia_service_name' JAR_NAME='$jar_name' REMOTE_TMP='$remote_tmp' MAIA_ARCHIVE_TMP='$maia_archive_tmp' MAIA_SERVICE_TMP='$maia_service_tmp' bash -s" <<'EOF'
set -euo pipefail
mkdir -p "$REMOTE_DIR"
MAIA_DIR="$REMOTE_DIR/maia-engine"
MAIA_NEW_DIR="$REMOTE_DIR/maia-engine.new"
MAIA_VENV_DIR="$REMOTE_DIR/maia-engine-venv"
MAIA_CACHE_DIR="$REMOTE_DIR/.cache/huggingface"
MAIA_REQUIREMENTS_HASH_FILE="$MAIA_VENV_DIR/.requirements.sha256"
python_bin=""
if command -v python3.11 >/dev/null 2>&1; then
python_bin="$(command -v python3.11)"
elif command -v python3 >/dev/null 2>&1; then
python_bin="$(command -v python3)"
fi
if [ -z "$python_bin" ]; then
echo "python3.11 or python3 is required on the deploy server"
exit 1
fi
if ! command -v git >/dev/null 2>&1; then
echo "git is required on the deploy server because maia3 is installed from GitHub"
exit 1
fi
rm -rf "$MAIA_NEW_DIR"
mkdir -p "$MAIA_NEW_DIR" "$MAIA_CACHE_DIR"
tar -xzf "$MAIA_ARCHIVE_TMP" -C "$MAIA_NEW_DIR" --strip-components=1
maia_backup_path=""
if [ -d "$MAIA_DIR" ]; then
maia_backup_path="$MAIA_DIR.$(date +%Y%m%d%H%M%S).bak"
mv "$MAIA_DIR" "$maia_backup_path"
fi
mv "$MAIA_NEW_DIR" "$MAIA_DIR"
rollback_maia() {
if [ -n "$maia_backup_path" ] && [ -d "$maia_backup_path" ]; then
rm -rf "$MAIA_DIR"
mv "$maia_backup_path" "$MAIA_DIR"
systemctl restart "$MAIA_SERVICE_NAME" || true
fi
}
if [ ! -d "$MAIA_VENV_DIR" ]; then
if ! "$python_bin" -m venv "$MAIA_VENV_DIR"; then
rollback_maia
echo "failed to create Python venv. Install python3-venv on the deploy server."
exit 1
fi
fi
if ! "$MAIA_VENV_DIR/bin/python" -m pip install --upgrade pip; then
rollback_maia
exit 1
fi
requirements_hash="$(sha256sum "$MAIA_DIR/requirements.txt" | awk '{print $1}')"
saved_requirements_hash=""
if [ -f "$MAIA_REQUIREMENTS_HASH_FILE" ]; then
saved_requirements_hash="$(cat "$MAIA_REQUIREMENTS_HASH_FILE")"
fi
if [ "$requirements_hash" != "$saved_requirements_hash" ]; then
if ! "$MAIA_VENV_DIR/bin/python" -m pip install -r "$MAIA_DIR/requirements.txt"; then
rollback_maia
exit 1
fi
printf '%s\n' "$requirements_hash" > "$MAIA_REQUIREMENTS_HASH_FILE"
fi
if ! "$MAIA_VENV_DIR/bin/python" -m py_compile "$MAIA_DIR/app/main.py"; then
rollback_maia
exit 1
fi
cp "$MAIA_SERVICE_TMP" "/etc/systemd/system/$MAIA_SERVICE_NAME"
chmod 0644 "/etc/systemd/system/$MAIA_SERVICE_NAME"
systemctl daemon-reload
systemctl enable "$MAIA_SERVICE_NAME"
if ! systemctl restart "$MAIA_SERVICE_NAME"; then
rollback_maia
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
exit 1
fi
sleep 5
if ! systemctl is-active --quiet "$MAIA_SERVICE_NAME"; then
rollback_maia
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
exit 1
fi
if command -v curl >/dev/null 2>&1; then
if ! curl -fsS http://127.0.0.1:8000/health >/dev/null; then
rollback_maia
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
exit 1
fi
else
if ! "$python_bin" -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=5).read()"; then
rollback_maia
journalctl -u "$MAIA_SERVICE_NAME" -n 120 --no-pager
exit 1
fi
fi
if [ -n "$maia_backup_path" ]; then
rm -rf "$maia_backup_path"
fi
cd "$REMOTE_DIR"
backup_path=""
@@ -83,7 +216,7 @@ jobs:
chown root:root "$JAR_NAME"
chmod 0644 "$JAR_NAME"
rollback() {
rollback_backend() {
if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then
cp "$backup_path" "$JAR_NAME"
systemctl restart "$SERVICE_NAME" || true
@@ -91,7 +224,7 @@ jobs:
}
if ! systemctl restart "$SERVICE_NAME"; then
rollback
rollback_backend
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
exit 1
fi
@@ -99,10 +232,11 @@ jobs:
sleep 8
if ! systemctl is-active --quiet "$SERVICE_NAME"; then
rollback
rollback_backend
journalctl -u "$SERVICE_NAME" -n 120 --no-pager
exit 1
fi
systemctl is-active "$MAIA_SERVICE_NAME"
systemctl is-active "$SERVICE_NAME"
EOF

5
.gitignore vendored
View File

@@ -1,5 +1,6 @@
HELP.md
.gradle
.gradle-user-home/
.env
build/
!gradle/wrapper/gradle-wrapper.jar
@@ -40,5 +41,9 @@ out/
### Kotlin ###
.kotlin
### Python ###
__pycache__/
*.py[cod]
### docker ###
postgres_data

View File

@@ -80,3 +80,52 @@ allOpen {
tasks.withType<Test> {
useJUnitPlatform()
}
val compileKotlinTask = tasks.named("compileKotlin")
val tempBuildDir = providers.provider {
file(System.getenv("BLOG_BACKEND_ASCII_BUILD_DIR") ?: "${System.getenv("TEMP") ?: layout.buildDirectory.get().asFile.absolutePath}/blog-backend-build")
}
val javacKotlinClassesDir = providers.provider {
tempBuildDir.get().resolve("kotlin-main-classes")
}
val syncKotlinClassesForJavac = tasks.register<Sync>("syncKotlinClassesForJavac") {
dependsOn(compileKotlinTask)
from(compileKotlinTask.map { it.outputs.files })
into(javacKotlinClassesDir)
}
tasks.named<JavaCompile>("compileJava") {
dependsOn(syncKotlinClassesForJavac)
classpath = files(javacKotlinClassesDir) + configurations.compileClasspath.get()
options.isIncremental = false
}
val mainRuntimeClassesDir = providers.provider {
tempBuildDir.get().resolve("main-runtime-classes")
}
val testRuntimeClassesDir = providers.provider {
tempBuildDir.get().resolve("test-runtime-classes")
}
val syncMainClassesForTestRuntime = tasks.register<Sync>("syncMainClassesForTestRuntime") {
dependsOn(tasks.named("classes"))
from(layout.buildDirectory.dir("classes/kotlin/main"))
from(layout.buildDirectory.dir("classes/java/main"))
from(layout.buildDirectory.dir("resources/main"))
into(mainRuntimeClassesDir)
}
val syncTestClassesForTestRuntime = tasks.register<Sync>("syncTestClassesForTestRuntime") {
dependsOn(tasks.named("testClasses"))
from(layout.buildDirectory.dir("classes/kotlin/test"))
from(layout.buildDirectory.dir("classes/java/test"))
from(layout.buildDirectory.dir("resources/test"))
into(testRuntimeClassesDir)
}
tasks.withType<Test> {
dependsOn(syncMainClassesForTestRuntime, syncTestClassesForTestRuntime)
testClassesDirs = files(testRuntimeClassesDir)
classpath = files(testRuntimeClassesDir, mainRuntimeClassesDir) + classpath
}

View File

@@ -0,0 +1,22 @@
[Unit]
Description=Maia3 chess engine bridge
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=root
Group=root
WorkingDirectory=/root/blog-backend/maia-engine
Environment=MAIA_MODEL=maia3-5m
Environment=MAIA_DEVICE=cpu
Environment=MAIA_USE_AMP=false
Environment=HF_HOME=/root/blog-backend/.cache/huggingface
Environment=PYTHONUNBUFFERED=1
ExecStart=/root/blog-backend/maia-engine-venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=5
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target

View File

@@ -12,6 +12,7 @@ services:
- SPRING_DATASOURCE_USERNAME=${DB_USER}
- SPRING_DATASOURCE_PASSWORD=${DB_PASS}
- JWT_SECRET=${JWT_SECRET}
- MAIA_ENGINE_URL=http://maia-engine:8000
# Redis
- SPRING_DATA_REDIS_HOST=redis
- SPRING_DATA_REDIS_PORT=6379
@@ -37,10 +38,31 @@ services:
condition: service_healthy
redis:
condition: service_healthy
maia-engine:
condition: service_healthy
networks:
- blog-net
# 2. 데이터베이스 (PostgreSQL)
maia-engine:
build: ./maia-engine
container_name: maia-engine
restart: always
environment:
- MAIA_MODEL=maia3-5m
- MAIA_DEVICE=cpu
- MAIA_USE_AMP=false
- HF_HOME=/models/huggingface
volumes:
- maia_model_cache:/models
healthcheck:
test: [ "CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=2).read()" ]
interval: 10s
timeout: 5s
retries: 12
networks:
- blog-net
db:
image: postgres:17-alpine
container_name: blog-db
@@ -91,3 +113,6 @@ services:
networks:
blog-net:
driver: bridge
volumes:
maia_model_cache:

View File

@@ -1,33 +1,43 @@
# CI/CD 운영 메모
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 자동 배포도록 구성다.
현재 운영 서버는 Docker Compose가 아니라 Java 21 + systemd 방식으로 실행 중이다.
이 저장소는 Gitea Actions 기준으로 `main` 브랜치에 push되면 백엔드와 Maia3 체스 엔진 브리지를 함께 자동 배포도록 구성되어 있다.
## 현재 운영 방식
- 운영 서비스: `blog-api.service`
- 실행 디렉터리: `/root/blog-backend`
- 실행 파일: `/root/blog-backend/blog-backend.jar`
- 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
- 운영 환경 변수와 외부 DB/Redis/S3 설정은 systemd service에 등록되어 있다.
- 백엔드 서비스: `blog-api.service`
- 백엔드 실행 디렉터리: `/root/blog-backend`
- 백엔드 실행 파일: `/root/blog-backend/blog-backend.jar`
- 백엔드 실행 방식: `java -jar -Duser.timezone=Asia/Seoul -Dspring.profiles.active=prod blog-backend.jar`
- Maia 엔진 서비스: `maia-engine.service`
- Maia 엔진 실행 디렉터리: `/root/blog-backend/maia-engine`
- Maia 엔진 venv: `/root/blog-backend/maia-engine-venv`
- Maia 엔진 URL: `http://127.0.0.1:8000`
## 동작 흐름
백엔드는 기본값으로 `MAIA_ENGINE_URL=http://localhost:8000`을 사용하므로, Maia 엔진을 같은 서버에서 띄우면 별도 환경변수 없이 연동된다.
## 배포 흐름
1. `main` 브랜치 push 또는 수동 실행(`workflow_dispatch`)
2. Java 21로 `./gradlew test --no-daemon` 실행
3. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌드
4. 빌드된 jar를 SSH/SCP로 서버의 `/tmp/blog-backend.jar.new`에 업로
5. 서버에서 기존 `/root/blog-backend/blog-backend.jar`를 timestamp 백업
6. 새 jar `/root/blog-backend/blog-backend.jar`로 교체
7. `systemctl restart blog-api.service` 실행 후 active 상태 확인
8. 재시작 실패 또는 비정상 종료 시 직전 jar로 rollback 시도
3. Python 3.11로 `maia-engine/app/main.py` 문법 검사
4. 테스트 성공 시 `./gradlew clean bootJar -x test --no-daemon`으로 Spring Boot jar 빌
5. `maia-engine` 디렉터리를 tar로 패키징
6. jar, Maia 엔진 tar, `deploy/systemd/maia-engine.service`를 SSH/SCP로 서버에 업로드
7. 서버에서 Maia 엔진 코드를 `/root/blog-backend/maia-engine`으로 교체
8. `/root/blog-backend/maia-engine-venv`를 생성하거나 재사용
9. `requirements.txt` 해시가 바뀌었으면 Python 의존성 재설치
10. `maia-engine.service` 설치, `systemctl daemon-reload`, enable, restart
11. `http://127.0.0.1:8000/health` 헬스체크
12. Spring Boot jar를 `/root/blog-backend/blog-backend.jar`로 교체
13. `blog-api.service` 재시작 후 active 상태 확인
Maia 엔진 배포 실패 시 이전 Maia 코드 디렉터리로 롤백을 시도한다. 백엔드 jar 재시작 실패 시 직전 jar 백업으로 롤백을 시도한다.
## Gitea Actions 준비
- Repository Settings에서 Actions 활성화한다.
- Repository Settings에서 Actions 활성화되어 있어야 한다.
- Gitea Runner가 `ubuntu-latest` label을 처리할 수 있어야 한다.
- 현재 백엔드 서버에는 `blog-backend` runner가 `ubuntu-latest` label로 등록되어 있다.
- Docker는 runner job 실행에 쓰일 수 있지만, 애플리케이션 배포 방식은 systemd jar 교체 방식이다.
- `actions/checkout`, `actions/setup-java`, `actions/setup-python` 액션을 사용할 수 있어야 한다.
## Repository Secrets
@@ -36,14 +46,38 @@ Gitea repository secrets에 다음 값을 등록한다.
| 이름 | 설명 |
| --- | --- |
| `DEPLOY_HOST` | 배포 서버 호스트 또는 IP |
| `DEPLOY_PORT` | SSH 포트, 비우면 22 |
| `DEPLOY_PORT` | SSH 포트. 비우면 22 |
| `DEPLOY_USER` | 배포 서버 SSH 사용자 |
| `DEPLOY_KEY` | 배포 서버 접속용 private key |
## 서버 준비 사항
- 서버에는 Java 21이 설치되어 있어야 한다.
- `blog-api.service``/root/blog-backend/blog-backend.jar`를 실행하도록 구성되어 있어야 한다.
- `DEPLOY_USER``/root/blog-backend`에 jar를 쓸 수 있고 `systemctl restart blog-api.service`를 실행할 수 있어야 한다.
- 운영 DB는 배포 전에 백업하는 것을 권장한다. Flyway migration은 애플리케이션 시작 시 자동 적용된다.
- `docker-compose.yml`은 현재 CI/CD 배포 경로에서 사용하지 않는다.
서버에는 다음이 필요하다.
- Java 21
- systemd
- Python 3.11 또는 Python 3
- Python venv 모듈(`python3-venv` 계열 패키지)
- `git`
- `sha256sum`
- `tar`
- 외부 네트워크 접근. `requirements.txt``git+https://github.com/CSSLab/maia3.git`를 설치한다.
- `DEPLOY_USER``/root/blog-backend`에 쓸 수 있고 `/etc/systemd/system`에 서비스 파일을 설치하며 `systemctl`을 실행할 수 있어야 한다.
첫 설치 또는 Maia3 의존성 변경 시 Python 패키지 설치와 모델 캐시 준비 때문에 배포가 오래 걸릴 수 있다. 모델 캐시는 `/root/blog-backend/.cache/huggingface`에 저장된다.
## 체스 배포 확인
배포 후 서버에서 다음을 확인할 수 있다.
```bash
systemctl status maia-engine.service
curl -fsS http://127.0.0.1:8000/health
systemctl status blog-api.service
```
백엔드 체스 API는 로그인한 사용자만 사용할 수 있다. 퍼즐 API(`/api/chess-puzzles/**`)는 비로그인 접근이 유지된다.
## Docker Compose
`docker-compose.yml`에도 Maia 엔진 서비스가 정의되어 있지만, 현재 CI/CD 경로는 Docker Compose가 아니라 systemd 기반 jar + Maia systemd 서비스 배포를 사용한다.

20
maia-engine/Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV HF_HOME=/models/huggingface
RUN apt-get update \
&& apt-get install -y --no-install-recommends git build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

151
maia-engine/app/main.py Normal file
View File

@@ -0,0 +1,151 @@
import os
import subprocess
import threading
from datetime import datetime, timezone
import chess
import chess.engine
import chess.pgn
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
DEFAULT_MODEL = os.getenv("MAIA_MODEL", "5m").removeprefix("maia3-")
DEFAULT_DEVICE = os.getenv("MAIA_DEVICE", "cpu")
DEFAULT_USE_AMP = os.getenv("MAIA_USE_AMP", "false").lower() == "true"
app = FastAPI(title="Maia Engine Bridge")
engine_lock = threading.Lock()
engines: dict[str, chess.engine.SimpleEngine] = {}
class StateRequest(BaseModel):
moves: list[str] = Field(default_factory=list)
white: str | None = None
black: str | None = None
event: str = "Maia3"
class PlayRequest(StateRequest):
rating: int = Field(default=1500, ge=600, le=2600)
model: str = Field(default=DEFAULT_MODEL, pattern="^(3m|5m|23m|79m)$")
temperature: float = Field(default=0.8, ge=0.0, le=2.0)
topP: float = Field(default=0.95, ge=0.0, le=1.0)
def model_alias(model: str) -> str:
return f"maia3-{model}"
def engine_command(model: str) -> list[str]:
command = [
"maia3-uci",
"--model",
model_alias(model),
"--use-uci-history",
"--device",
DEFAULT_DEVICE,
]
if not DEFAULT_USE_AMP:
command.append("--no-use-amp")
return command
def get_engine(model: str) -> chess.engine.SimpleEngine:
if model not in engines:
try:
engines[model] = chess.engine.SimpleEngine.popen_uci(engine_command(model))
except FileNotFoundError as exc:
raise HTTPException(status_code=503, detail="maia3-uci executable was not found") from exc
except (chess.engine.EngineError, subprocess.SubprocessError, OSError) as exc:
raise HTTPException(status_code=503, detail="failed to start Maia engine") from exc
return engines[model]
def build_board(moves: list[str]) -> chess.Board:
board = chess.Board()
for move in moves:
try:
board.push_uci(move)
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"invalid move: {move}") from exc
return board
def pgn_payload(board: chess.Board, request: StateRequest, result: str | None) -> str:
game = chess.pgn.Game.from_board(board)
game.headers["Event"] = request.event
game.headers["Site"] = "blog-backend"
game.headers["Date"] = datetime.now(timezone.utc).strftime("%Y.%m.%d")
if request.white:
game.headers["White"] = request.white
if request.black:
game.headers["Black"] = request.black
game.headers["Result"] = result or "*"
exporter = chess.pgn.StringExporter(headers=True, variations=False, comments=False)
return game.accept(exporter)
def board_payload(board: chess.Board, request: StateRequest) -> dict:
outcome = board.outcome(claim_draw=True)
if outcome is None:
status = "IN_PROGRESS"
result = None
else:
status = outcome.termination.name
result = outcome.result()
return {
"fen": board.fen(),
"turn": "white" if board.turn == chess.WHITE else "black",
"status": status,
"result": result,
"pgn": pgn_payload(board, request, result),
}
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/maia/state")
def state(request: StateRequest) -> dict:
board = build_board(request.moves)
return board_payload(board, request)
@app.post("/maia/move")
def move(request: PlayRequest) -> dict:
board = build_board(request.moves)
current = board_payload(board, request)
if current["status"] != "IN_PROGRESS":
return {"move": None, **current}
with engine_lock:
maia = get_engine(request.model)
try:
maia.configure(
{
"Elo": request.rating,
"Temperature": request.temperature,
"TopP": request.topP,
}
)
result = maia.play(board, chess.engine.Limit(nodes=1))
except chess.engine.EngineError as exc:
raise HTTPException(status_code=503, detail="Maia engine failed to select a move") from exc
if result.move is None:
raise HTTPException(status_code=503, detail="Maia engine returned no move")
board.push(result.move)
return {"move": result.move.uci(), **board_payload(board, request)}
@app.on_event("shutdown")
def shutdown() -> None:
for maia in engines.values():
maia.quit()
engines.clear()

View File

@@ -0,0 +1,4 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
python-chess==1.999
git+https://github.com/CSSLab/maia3.git

View File

@@ -0,0 +1,82 @@
package me.wypark.blogbackend.api.controller
import jakarta.validation.Valid
import me.wypark.blogbackend.api.common.ApiResponse
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
import me.wypark.blogbackend.api.dto.ChessGameResponse
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import me.wypark.blogbackend.domain.auth.CustomUserDetails
import me.wypark.blogbackend.domain.chess.ChessGameService
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.domain.Sort
import org.springframework.data.web.PageableDefault
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/chess/games")
class ChessGameController(
private val chessGameService: ChessGameService
) {
@PostMapping
fun createGame(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@Valid @RequestBody request: ChessGameCreateRequest
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success(chessGameService.createGame(userDetails.memberId, request)))
}
@GetMapping
fun getGames(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PageableDefault(size = 20, sort = ["updatedAt"], direction = Sort.Direction.DESC) pageable: Pageable
): ResponseEntity<ApiResponse<Page<ChessGameSummaryResponse>>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGames(userDetails.memberId, pageable)))
}
@GetMapping("/stats")
fun getStats(
@AuthenticationPrincipal userDetails: CustomUserDetails
): ResponseEntity<ApiResponse<ChessGameStatsResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getStats(userDetails.memberId)))
}
@GetMapping("/{gameId}")
fun getGame(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getGame(userDetails.memberId, gameId)))
}
@GetMapping("/{gameId}/pgn")
fun getPgn(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String
): ResponseEntity<ApiResponse<ChessGamePgnResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.getPgn(userDetails.memberId, gameId)))
}
@PostMapping("/{gameId}/moves")
fun playMove(
@AuthenticationPrincipal userDetails: CustomUserDetails,
@PathVariable gameId: String,
@Valid @RequestBody request: ChessMoveRequest
): ResponseEntity<ApiResponse<ChessGameResponse>> {
return ResponseEntity.ok(ApiResponse.success(chessGameService.playMove(userDetails.memberId, gameId, request)))
}
}

View File

@@ -0,0 +1,153 @@
package me.wypark.blogbackend.api.dto
import jakarta.validation.constraints.DecimalMax
import jakarta.validation.constraints.DecimalMin
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Pattern
import me.wypark.blogbackend.domain.chess.ChessGameHistoryStats
import me.wypark.blogbackend.domain.chess.ChessGameRecord
import me.wypark.blogbackend.domain.chess.ChessGameSession
import java.time.LocalDateTime
data class ChessGameCreateRequest(
@field:Min(600, message = "레이팅은 600 이상이어야 합니다.")
@field:Max(2600, message = "레이팅은 2600 이하여야 합니다.")
val rating: Int = 1500,
@field:Pattern(
regexp = "(?i)^(white|black)$",
message = "playerColor는 white 또는 black이어야 합니다."
)
val playerColor: String = "white",
@field:Pattern(
regexp = "^(3m|5m|23m|79m)$",
message = "model은 3m, 5m, 23m, 79m 중 하나여야 합니다."
)
val model: String = "5m",
@field:DecimalMin(value = "0.0", message = "temperature는 0.0 이상이어야 합니다.")
@field:DecimalMax(value = "2.0", message = "temperature는 2.0 이하여야 합니다.")
val temperature: Double = 0.8,
@field:DecimalMin(value = "0.0", message = "topP는 0.0 이상이어야 합니다.")
@field:DecimalMax(value = "1.0", message = "topP는 1.0 이하여야 합니다.")
val topP: Double = 0.95
)
data class ChessMoveRequest(
@field:Pattern(
regexp = "^[a-h][1-8][a-h][1-8][qrbn]?$",
message = "move는 UCI 형식이어야 합니다. 예: e2e4, e7e8q"
)
val move: String
)
data class ChessGameResponse(
val gameId: String,
val rating: Int,
val playerColor: String,
val model: String,
val fen: String,
val turn: String,
val moves: List<String>,
val status: String,
val result: String?,
val outcome: String,
val pgn: String,
val maiaMove: String?
) {
companion object {
fun from(session: ChessGameSession, maiaMove: String? = null): ChessGameResponse {
return ChessGameResponse(
gameId = session.gameId,
rating = session.rating,
playerColor = session.playerColor.value,
model = session.model,
fen = session.fen,
turn = session.turn.value,
moves = session.moves,
status = session.status,
result = session.result,
outcome = session.outcome().name,
pgn = session.pgn,
maiaMove = maiaMove
)
}
fun from(record: ChessGameRecord, maiaMove: String? = null): ChessGameResponse {
return ChessGameResponse(
gameId = record.gameId,
rating = record.rating,
playerColor = record.playerColor.value,
model = record.model,
fen = record.fen,
turn = record.turn.value,
moves = record.moveList(),
status = record.status,
result = record.result,
outcome = record.outcome.name,
pgn = record.pgn,
maiaMove = maiaMove
)
}
}
}
data class ChessGameSummaryResponse(
val gameId: String,
val rating: Int,
val playerColor: String,
val model: String,
val status: String,
val result: String?,
val outcome: String,
val movesCount: Int,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime
) {
companion object {
fun from(record: ChessGameRecord): ChessGameSummaryResponse {
return ChessGameSummaryResponse(
gameId = record.gameId,
rating = record.rating,
playerColor = record.playerColor.value,
model = record.model,
status = record.status,
result = record.result,
outcome = record.outcome.name,
movesCount = record.moveList().size,
createdAt = record.createdAt,
updatedAt = record.updatedAt
)
}
}
}
data class ChessGamePgnResponse(
val gameId: String,
val pgn: String
)
data class ChessGameStatsResponse(
val total: Long,
val inProgress: Long,
val wins: Long,
val losses: Long,
val draws: Long,
val unknown: Long
) {
companion object {
fun from(stats: ChessGameHistoryStats): ChessGameStatsResponse {
return ChessGameStatsResponse(
total = stats.total,
inProgress = stats.inProgress,
wins = stats.wins,
losses = stats.losses,
draws = stats.draws,
unknown = stats.unknown
)
}
}
}

View File

@@ -30,6 +30,8 @@ class CorsConfig {
// 2. 신뢰할 수 있는 출처(Origin) 명시
// 로컬 개발 환경과 배포 환경(Production)의 도메인을 각각 등록합니다.
config.addAllowedOrigin("https://blog.wypark.me")
config.addAllowedOrigin("http://localhost:3000")
config.addAllowedOrigin("http://localhost:5173")
// config.addAllowedOrigin("http://localhost:3000") // 로컬 테스트 시 주석 해제
// 3. 허용할 HTTP 메서드 및 헤더

View File

@@ -0,0 +1,29 @@
package me.wypark.blogbackend.core.config
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.client.RestClient
import java.time.Duration
@Configuration
@EnableConfigurationProperties(MaiaProperties::class)
class MaiaConfig {
@Bean
fun maiaRestClient(
builder: RestClient.Builder,
properties: MaiaProperties
): RestClient {
return builder
.baseUrl(properties.engineUrl)
.build()
}
}
@ConfigurationProperties(prefix = "maia")
data class MaiaProperties(
val engineUrl: String = "http://localhost:8000",
val gameSessionTtl: Duration = Duration.ofHours(6)
)

View File

@@ -0,0 +1,69 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.domain.user.MemberRepository
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Repository
import org.springframework.transaction.annotation.Transactional
interface ChessGameHistoryStore {
fun save(session: ChessGameSession)
fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord?
fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
fun getStats(memberId: Long): ChessGameHistoryStats
}
data class ChessGameHistoryStats(
val total: Long,
val inProgress: Long,
val wins: Long,
val losses: Long,
val draws: Long,
val unknown: Long
)
@Repository
class JpaChessGameHistoryStore(
private val chessGameRecordRepository: ChessGameRecordRepository,
private val memberRepository: MemberRepository
) : ChessGameHistoryStore {
@Transactional
override fun save(session: ChessGameSession) {
val existing = chessGameRecordRepository.findByGameId(session.gameId)
if (existing != null) {
require(existing.member.id == session.memberId) { "Chess game not found." }
existing.apply(session)
return
}
val member = memberRepository.getReferenceById(session.memberId)
chessGameRecordRepository.save(ChessGameRecord.from(member, session))
}
@Transactional(readOnly = true)
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? {
return chessGameRecordRepository.findByGameIdAndMember_Id(gameId, memberId)
}
@Transactional(readOnly = true)
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
return chessGameRecordRepository.findAllByMember_Id(memberId, pageable)
}
@Transactional(readOnly = true)
override fun getStats(memberId: Long): ChessGameHistoryStats {
return ChessGameHistoryStats(
total = chessGameRecordRepository.countByMember_Id(memberId),
inProgress = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.IN_PROGRESS),
wins = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.WIN),
losses = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.LOSS),
draws = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.DRAW),
unknown = chessGameRecordRepository.countByMember_IdAndOutcome(memberId, ChessGameOutcome.UNKNOWN)
)
}
}

View File

@@ -0,0 +1,144 @@
package me.wypark.blogbackend.domain.chess
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.FetchType
import jakarta.persistence.GeneratedValue
import jakarta.persistence.GenerationType
import jakarta.persistence.Id
import jakarta.persistence.Index
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.persistence.UniqueConstraint
import me.wypark.blogbackend.domain.common.BaseTimeEntity
import me.wypark.blogbackend.domain.user.Member
import java.time.ZoneId
@Entity
@Table(
name = "chess_game_record",
uniqueConstraints = [
UniqueConstraint(name = "uk_chess_game_record_game_id", columnNames = ["game_id"])
],
indexes = [
Index(name = "idx_chess_game_record_member_updated_at", columnList = "member_id, updated_at"),
Index(name = "idx_chess_game_record_member_outcome", columnList = "member_id, outcome")
]
)
class ChessGameRecord(
@Column(name = "game_id", nullable = false, length = 36)
val gameId: String,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "member_id", nullable = false)
val member: Member,
@Column(nullable = false)
var rating: Int,
@Enumerated(EnumType.STRING)
@Column(name = "player_color", nullable = false, length = 10)
var playerColor: ChessSide,
@Column(nullable = false, length = 10)
var model: String,
@Column(nullable = false)
var temperature: Double,
@Column(name = "top_p", nullable = false)
var topP: Double,
@Column(nullable = false, columnDefinition = "TEXT")
var fen: String,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 10)
var turn: ChessSide,
@Column(nullable = false, columnDefinition = "TEXT")
var moves: String,
@Column(nullable = false, length = 40)
var status: String,
@Column(length = 16)
var result: String?,
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
var outcome: ChessGameOutcome,
@Column(nullable = false, columnDefinition = "TEXT")
var pgn: String
) : BaseTimeEntity() {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long? = null
fun apply(session: ChessGameSession) {
rating = session.rating
playerColor = session.playerColor
model = session.model
temperature = session.temperature
topP = session.topP
fen = session.fen
turn = session.turn
moves = session.moves.joinToString(" ")
status = session.status
result = session.result
outcome = session.outcome()
pgn = session.pgn
}
fun moveList(): List<String> {
return moves.split(" ").filter { it.isNotBlank() }
}
fun toSession(): ChessGameSession {
val zoneId = ZoneId.systemDefault()
val memberId = requireNotNull(member.id) { "member id is required." }
return ChessGameSession(
gameId = gameId,
memberId = memberId,
rating = rating,
playerColor = playerColor,
model = model,
temperature = temperature,
topP = topP,
fen = fen,
turn = turn,
moves = moveList(),
status = status,
result = result,
pgn = pgn,
createdAt = createdAt.atZone(zoneId).toInstant(),
updatedAt = updatedAt.atZone(zoneId).toInstant()
)
}
companion object {
fun from(member: Member, session: ChessGameSession): ChessGameRecord {
return ChessGameRecord(
gameId = session.gameId,
member = member,
rating = session.rating,
playerColor = session.playerColor,
model = session.model,
temperature = session.temperature,
topP = session.topP,
fen = session.fen,
turn = session.turn,
moves = session.moves.joinToString(" "),
status = session.status,
result = session.result,
outcome = session.outcome(),
pgn = session.pgn
)
}
}
}

View File

@@ -0,0 +1,18 @@
package me.wypark.blogbackend.domain.chess
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
interface ChessGameRecordRepository : JpaRepository<ChessGameRecord, Long> {
fun findByGameId(gameId: String): ChessGameRecord?
fun findByGameIdAndMember_Id(gameId: String, memberId: Long): ChessGameRecord?
fun findAllByMember_Id(memberId: Long, pageable: Pageable): Page<ChessGameRecord>
fun countByMember_Id(memberId: Long): Long
fun countByMember_IdAndOutcome(memberId: Long, outcome: ChessGameOutcome): Long
}

View File

@@ -0,0 +1,176 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessGamePgnResponse
import me.wypark.blogbackend.api.dto.ChessGameResponse
import me.wypark.blogbackend.api.dto.ChessGameStatsResponse
import me.wypark.blogbackend.api.dto.ChessGameSummaryResponse
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.time.Clock
import java.time.Instant
import java.util.UUID
@Service
@Transactional(readOnly = true)
class ChessGameService(
private val chessGameStore: ChessGameStore,
private val chessGameHistoryStore: ChessGameHistoryStore,
private val maiaEngine: MaiaEngine,
private val clock: Clock
) {
@Transactional
fun createGame(memberId: Long, request: ChessGameCreateRequest): ChessGameResponse {
val playerColor = ChessSide.from(request.playerColor)
val now = Instant.now(clock)
val labels = playerLabels(playerColor, request.rating, request.model)
val initialState = maiaEngine.getState(
MaiaStateRequest(
moves = emptyList(),
white = labels.white,
black = labels.black
)
)
var session = ChessGameSession(
gameId = UUID.randomUUID().toString(),
memberId = memberId,
rating = request.rating,
playerColor = playerColor,
model = request.model,
temperature = request.temperature,
topP = request.topP,
fen = initialState.fen,
turn = ChessSide.from(initialState.turn),
moves = emptyList(),
status = initialState.status,
result = initialState.result,
pgn = initialState.pgn,
createdAt = now,
updatedAt = now
)
var maiaMove: String? = null
if (playerColor == ChessSide.BLACK) {
val maiaResponse = maiaEngine.playMove(session.toMaiaPlayRequest())
maiaMove = maiaResponse.move
session = session.applyEngineResponse(maiaResponse, now)
}
saveSession(session)
return ChessGameResponse.from(session, maiaMove)
}
fun getGames(memberId: Long, pageable: Pageable): Page<ChessGameSummaryResponse> {
return chessGameHistoryStore.findAllByMemberId(memberId, pageable)
.map { ChessGameSummaryResponse.from(it) }
}
fun getStats(memberId: Long): ChessGameStatsResponse {
return ChessGameStatsResponse.from(chessGameHistoryStore.getStats(memberId))
}
fun getGame(memberId: Long, gameId: String): ChessGameResponse {
val session = chessGameStore.findById(gameId)
if (session != null) {
requireOwnedBy(session, memberId)
return ChessGameResponse.from(session)
}
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
?: throw IllegalArgumentException("Chess game not found.")
return ChessGameResponse.from(record)
}
fun getPgn(memberId: Long, gameId: String): ChessGamePgnResponse {
val game = getGame(memberId, gameId)
return ChessGamePgnResponse(gameId = game.gameId, pgn = game.pgn)
}
@Transactional
fun playMove(memberId: Long, gameId: String, request: ChessMoveRequest): ChessGameResponse {
val session = getPlayableSession(memberId, gameId)
require(session.status == "IN_PROGRESS") { "This chess game is already finished." }
require(session.turn == session.playerColor) { "It is not the player's turn." }
val movesAfterPlayer = session.moves + request.move
val maiaResponse = maiaEngine.playMove(
session.toMaiaPlayRequest(moves = movesAfterPlayer)
)
val updated = session.applyEngineResponse(maiaResponse, Instant.now(clock), movesAfterPlayer)
saveSession(updated)
return ChessGameResponse.from(updated, maiaResponse.move)
}
private fun getPlayableSession(memberId: Long, gameId: String): ChessGameSession {
val session = chessGameStore.findById(gameId)
if (session != null) {
requireOwnedBy(session, memberId)
return session
}
val record = chessGameHistoryStore.findByGameIdAndMemberId(gameId, memberId)
?: throw IllegalArgumentException("Chess game not found.")
return record.toSession()
}
private fun saveSession(session: ChessGameSession) {
chessGameStore.save(session)
chessGameHistoryStore.save(session)
}
private fun requireOwnedBy(session: ChessGameSession, memberId: Long) {
require(session.memberId == memberId) { "Chess game not found." }
}
private fun playerLabels(playerColor: ChessSide, rating: Int, model: String): PlayerLabels {
val maiaName = "Maia3-$model-$rating"
return if (playerColor == ChessSide.WHITE) {
PlayerLabels(white = "Player", black = maiaName)
} else {
PlayerLabels(white = maiaName, black = "Player")
}
}
private fun ChessGameSession.toMaiaPlayRequest(
moves: List<String> = this.moves
): MaiaPlayRequest {
val labels = playerLabels(playerColor, rating, model)
return MaiaPlayRequest(
moves = moves,
rating = rating,
model = model,
temperature = temperature,
topP = topP,
white = labels.white,
black = labels.black
)
}
private fun ChessGameSession.applyEngineResponse(
response: MaiaPlayResponse,
now: Instant,
movesBeforeMaia: List<String> = moves
): ChessGameSession {
val nextMoves = response.move?.let { movesBeforeMaia + it } ?: movesBeforeMaia
return copy(
fen = response.fen,
turn = ChessSide.from(response.turn),
moves = nextMoves,
status = response.status,
result = response.result,
pgn = response.pgn,
updatedAt = now
)
}
private data class PlayerLabels(
val white: String,
val black: String
)
}

View File

@@ -0,0 +1,63 @@
package me.wypark.blogbackend.domain.chess
import java.time.Instant
data class ChessGameSession(
val gameId: String,
val memberId: Long,
val rating: Int,
val playerColor: ChessSide,
val model: String,
val temperature: Double,
val topP: Double,
val fen: String,
val turn: ChessSide,
val moves: List<String>,
val status: String,
val result: String?,
val pgn: String,
val createdAt: Instant,
val updatedAt: Instant
) {
fun outcome(): ChessGameOutcome {
return ChessGameOutcome.from(result = result, playerColor = playerColor, status = status)
}
}
enum class ChessSide(val value: String) {
WHITE("white"),
BLACK("black");
fun opposite(): ChessSide = if (this == WHITE) BLACK else WHITE
companion object {
fun from(value: String): ChessSide {
return entries.firstOrNull { it.value.equals(value, ignoreCase = true) }
?: throw IllegalArgumentException("playerColor must be white or black.")
}
}
}
enum class ChessGameOutcome {
IN_PROGRESS,
WIN,
LOSS,
DRAW,
UNKNOWN;
companion object {
fun from(result: String?, playerColor: ChessSide, status: String): ChessGameOutcome {
if (status == "IN_PROGRESS") {
return IN_PROGRESS
}
return when (result) {
"1-0" -> if (playerColor == ChessSide.WHITE) WIN else LOSS
"0-1" -> if (playerColor == ChessSide.BLACK) WIN else LOSS
"1/2-1/2" -> DRAW
null, "", "*" -> UNKNOWN
else -> UNKNOWN
}
}
}
}

View File

@@ -0,0 +1,8 @@
package me.wypark.blogbackend.domain.chess
interface ChessGameStore {
fun save(session: ChessGameSession): ChessGameSession
fun findById(gameId: String): ChessGameSession?
}

View File

@@ -0,0 +1,43 @@
package me.wypark.blogbackend.domain.chess
interface MaiaEngine {
fun getState(request: MaiaStateRequest): MaiaStateResponse
fun playMove(request: MaiaPlayRequest): MaiaPlayResponse
}
data class MaiaStateRequest(
val moves: List<String>,
val white: String? = null,
val black: String? = null,
val event: String = "Maia3"
)
data class MaiaPlayRequest(
val moves: List<String>,
val rating: Int,
val model: String,
val temperature: Double,
val topP: Double,
val white: String? = null,
val black: String? = null,
val event: String = "Maia3"
)
data class MaiaStateResponse(
val fen: String,
val turn: String,
val status: String,
val result: String?,
val pgn: String = ""
)
data class MaiaPlayResponse(
val move: String?,
val fen: String,
val turn: String,
val status: String,
val result: String?,
val pgn: String = ""
)

View File

@@ -0,0 +1,41 @@
package me.wypark.blogbackend.domain.chess
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
import org.springframework.web.client.RestClientResponseException
@Component
class MaiaEngineClient(
private val maiaRestClient: RestClient
) : MaiaEngine {
private val log = LoggerFactory.getLogger(this.javaClass)
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
return exchange("/maia/state", request, MaiaStateResponse::class.java)
}
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
return exchange("/maia/move", request, MaiaPlayResponse::class.java)
}
private fun <T : Any> exchange(path: String, request: Any, responseType: Class<T>): T {
try {
return maiaRestClient
.post()
.uri(path)
.body(request)
.retrieve()
.body(responseType)
?: throw IllegalStateException("Maia 엔진 응답이 비어 있습니다.")
} catch (e: RestClientResponseException) {
log.warn("Maia engine returned {}: {}", e.statusCode, e.responseBodyAsString)
throw IllegalArgumentException("Maia 엔진이 요청을 처리하지 못했습니다.")
} catch (e: RestClientException) {
log.error("Failed to call Maia engine", e)
throw IllegalStateException("Maia 엔진에 연결할 수 없습니다.")
}
}
}

View File

@@ -0,0 +1,32 @@
package me.wypark.blogbackend.domain.chess
import com.fasterxml.jackson.databind.ObjectMapper
import me.wypark.blogbackend.core.config.MaiaProperties
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Repository
import java.util.concurrent.TimeUnit
@Repository
class RedisChessGameStore(
private val redisTemplate: RedisTemplate<String, String>,
private val objectMapper: ObjectMapper,
private val maiaProperties: MaiaProperties
) : ChessGameStore {
override fun save(session: ChessGameSession): ChessGameSession {
redisTemplate.opsForValue().set(
key(session.gameId),
objectMapper.writeValueAsString(session),
maiaProperties.gameSessionTtl.toMillis(),
TimeUnit.MILLISECONDS
)
return session
}
override fun findById(gameId: String): ChessGameSession? {
val json = redisTemplate.opsForValue().get(key(gameId)) ?: return null
return objectMapper.readValue(json, ChessGameSession::class.java)
}
private fun key(gameId: String): String = "CHESS_GAME:$gameId"
}

View File

@@ -3,3 +3,7 @@ spring:
name: blog-api
profiles:
default: prod
maia:
engine-url: ${MAIA_ENGINE_URL:http://localhost:8000}
game-session-ttl: ${MAIA_GAME_SESSION_TTL:PT6H}

View File

@@ -0,0 +1,28 @@
CREATE TABLE chess_game_record (
id BIGSERIAL PRIMARY KEY,
game_id VARCHAR(36) NOT NULL,
member_id BIGINT NOT NULL,
rating INTEGER NOT NULL,
player_color VARCHAR(10) NOT NULL,
model VARCHAR(10) NOT NULL,
temperature DOUBLE PRECISION NOT NULL,
top_p DOUBLE PRECISION NOT NULL,
fen TEXT NOT NULL,
turn VARCHAR(10) NOT NULL,
moves TEXT NOT NULL DEFAULT '',
status VARCHAR(40) NOT NULL,
result VARCHAR(16),
outcome VARCHAR(20) NOT NULL,
pgn TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_chess_game_record_game_id UNIQUE (game_id),
CONSTRAINT fk_chess_game_record_member
FOREIGN KEY (member_id) REFERENCES member (id) ON DELETE CASCADE
);
CREATE INDEX idx_chess_game_record_member_updated_at
ON chess_game_record (member_id, updated_at DESC);
CREATE INDEX idx_chess_game_record_member_outcome
ON chess_game_record (member_id, outcome);

View File

@@ -0,0 +1,37 @@
package me.wypark.blogbackend.api.controller
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
class ChessGameControllerSecurityIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `chess game creation requires login`() {
mockMvc.perform(
post("/api/chess/games")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"rating":1500,"playerColor":"white","model":"5m"}""")
)
.andExpect(status().isUnauthorized)
}
@Test
fun `chess game history requires login`() {
mockMvc.perform(get("/api/chess/games"))
.andExpect(status().isUnauthorized)
}
}

View File

@@ -0,0 +1,277 @@
package me.wypark.blogbackend.domain.chess
import me.wypark.blogbackend.api.dto.ChessGameCreateRequest
import me.wypark.blogbackend.api.dto.ChessMoveRequest
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.data.domain.Page
import org.springframework.data.domain.PageImpl
import org.springframework.data.domain.Pageable
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ChessGameServiceTest {
private lateinit var store: FakeChessGameStore
private lateinit var historyStore: FakeChessGameHistoryStore
private lateinit var engine: FakeMaiaEngine
private lateinit var service: ChessGameService
@BeforeEach
fun setUp() {
store = FakeChessGameStore()
historyStore = FakeChessGameHistoryStore()
engine = FakeMaiaEngine()
service = ChessGameService(
chessGameStore = store,
chessGameHistoryStore = historyStore,
maiaEngine = engine,
clock = Clock.fixed(Instant.parse("2026-06-19T00:00:00Z"), ZoneOffset.UTC)
)
}
@Test
fun `creates white game without an immediate Maia move`() {
val response = service.createGame(
memberId = MEMBER_ID,
request = ChessGameCreateRequest(rating = 1500, playerColor = "white", model = "5m")
)
assertEquals(1500, response.rating)
assertEquals("white", response.playerColor)
assertEquals(emptyList(), response.moves)
assertEquals(null, response.maiaMove)
assertEquals("white", response.turn)
assertEquals("IN_PROGRESS", response.status)
assertEquals("IN_PROGRESS", response.outcome)
assertEquals(MEMBER_ID, historyStore.savedSessions.single().memberId)
}
@Test
fun `creates black game with Maia opening move`() {
engine.playResponses.add(
MaiaPlayResponse(
move = "e2e4",
fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1",
turn = "black",
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 *"
)
)
val response = service.createGame(
memberId = MEMBER_ID,
request = ChessGameCreateRequest(rating = 1500, playerColor = "black", model = "5m")
)
assertEquals(listOf("e2e4"), response.moves)
assertEquals("e2e4", response.maiaMove)
assertEquals("black", response.turn)
assertEquals("1. e4 *", response.pgn)
assertEquals(1, engine.playRequests.size)
}
@Test
fun `stores player move Maia reply PGN and outcome`() {
val session = store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
engine.playResponses.add(
MaiaPlayResponse(
move = "c7c5",
fen = "rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR w KQkq c6 0 2",
turn = "white",
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 c5 *"
)
)
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
assertEquals(listOf("e2e4", "c7c5"), response.moves)
assertEquals("c7c5", response.maiaMove)
assertEquals("white", response.turn)
assertEquals("1. e4 c5 *", response.pgn)
assertEquals("IN_PROGRESS", response.outcome)
assertEquals(listOf("e2e4"), engine.playRequests.single().moves)
assertEquals("1. e4 c5 *", historyStore.savedSessions.last().pgn)
}
@Test
fun `records a player win when result favors player color`() {
val session = store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
engine.playResponses.add(
MaiaPlayResponse(
move = null,
fen = "8/8/8/8/8/8/8/8 b - - 0 1",
turn = "black",
status = "CHECKMATE",
result = "1-0",
pgn = "1. e4 1-0"
)
)
val response = service.playMove(MEMBER_ID, session.gameId, ChessMoveRequest("e2e4"))
assertEquals("WIN", response.outcome)
assertEquals("WIN", historyStore.savedSessions.last().outcome().name)
}
@Test
fun `rejects move when it is not player's turn`() {
store.save(
ChessGameSession(
gameId = "game-1",
memberId = MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.BLACK,
moves = listOf("e2e4"),
status = "IN_PROGRESS",
result = null,
pgn = "1. e4 *",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
assertFailsWith<IllegalArgumentException> {
service.playMove(MEMBER_ID, "game-1", ChessMoveRequest("d2d4"))
}
}
@Test
fun `rejects access to another member game`() {
store.save(
ChessGameSession(
gameId = "game-1",
memberId = OTHER_MEMBER_ID,
rating = 1500,
playerColor = ChessSide.WHITE,
model = "5m",
temperature = 0.8,
topP = 0.95,
fen = FakeMaiaEngine.START_FEN,
turn = ChessSide.WHITE,
moves = emptyList(),
status = "IN_PROGRESS",
result = null,
pgn = "*",
createdAt = Instant.parse("2026-06-19T00:00:00Z"),
updatedAt = Instant.parse("2026-06-19T00:00:00Z")
)
)
assertFailsWith<IllegalArgumentException> {
service.getGame(MEMBER_ID, "game-1")
}
}
companion object {
private const val MEMBER_ID = 1L
private const val OTHER_MEMBER_ID = 2L
}
}
private class FakeChessGameStore : ChessGameStore {
private val sessions = mutableMapOf<String, ChessGameSession>()
override fun save(session: ChessGameSession): ChessGameSession {
sessions[session.gameId] = session
return session
}
override fun findById(gameId: String): ChessGameSession? = sessions[gameId]
}
private class FakeChessGameHistoryStore : ChessGameHistoryStore {
val savedSessions = mutableListOf<ChessGameSession>()
override fun save(session: ChessGameSession) {
savedSessions.add(session)
}
override fun findByGameIdAndMemberId(gameId: String, memberId: Long): ChessGameRecord? = null
override fun findAllByMemberId(memberId: Long, pageable: Pageable): Page<ChessGameRecord> {
return PageImpl(emptyList(), pageable, 0)
}
override fun getStats(memberId: Long): ChessGameHistoryStats {
return ChessGameHistoryStats(
total = 0,
inProgress = 0,
wins = 0,
losses = 0,
draws = 0,
unknown = 0
)
}
}
private class FakeMaiaEngine : MaiaEngine {
val playResponses = ArrayDeque<MaiaPlayResponse>()
val playRequests = mutableListOf<MaiaPlayRequest>()
override fun getState(request: MaiaStateRequest): MaiaStateResponse {
return MaiaStateResponse(
fen = START_FEN,
turn = "white",
status = "IN_PROGRESS",
result = null,
pgn = "*"
)
}
override fun playMove(request: MaiaPlayRequest): MaiaPlayResponse {
playRequests.add(request)
return playResponses.removeFirst()
}
companion object {
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
}
}