Add persistent multi-user sessions and Docker deployment

This commit is contained in:
burnintuna 2026-07-31 02:39:00 +09:00
parent 51a6e845d1
commit 104577c826
24 changed files with 2094 additions and 468 deletions

View file

@ -1,7 +1,8 @@
"""JWT-based authentication with config-file user management."""
"""JWT authentication and SQLite-backed administrator-managed users."""
import os
import json
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
@ -10,9 +11,9 @@ import bcrypt
import jwt
from dotenv import load_dotenv
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
# ── Config ────────────────────────────────────────────
from backend.database import database
PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env")
@ -22,113 +23,201 @@ def _load_jwt_secret() -> str:
configured = os.getenv("JWT_SECRET_KEY")
if configured:
return configured
secret_path = Path(
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "config" / ".jwt-secret"))
os.getenv("JWT_SECRET_FILE", str(PROJECT_ROOT / "data" / ".jwt-secret"))
)
secret_path.parent.mkdir(parents=True, exist_ok=True)
try:
secret = secret_path.read_text(encoding="utf-8").strip()
if secret:
return secret
except FileNotFoundError:
pass
secret = secrets.token_urlsafe(48)
try:
descriptor = os.open(secret_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
except FileExistsError:
secret = secret_path.read_text(encoding="utf-8").strip()
if secret:
return secret
existing = secret_path.read_text(encoding="utf-8").strip()
if existing:
return existing
raise RuntimeError(f"JWT secret file is empty: {secret_path}")
except OSError as exc:
raise RuntimeError(
"Set JWT_SECRET_KEY because the JWT secret file could not be created: "
f"{secret_path}"
) from exc
with os.fdopen(descriptor, "w", encoding="utf-8") as secret_file:
secret_file.write(secret)
logging.getLogger(__name__).warning("Created JWT secret file at %s", secret_path)
return secret
JWT_SECRET_KEY = _load_jwt_secret()
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = int(os.getenv("JWT_EXPIRE_HOURS", "24"))
security = HTTPBearer(auto_error=False)
# ── User Loading ───────────────────────────────────────
def _migrate_users() -> None:
"""Import users.json exactly once, then optionally bootstrap an administrator."""
logger = logging.getLogger(__name__)
with database._lock, database.connect() as connection:
marker = connection.execute(
"SELECT value FROM schema_meta WHERE key = 'users_json_v1'"
).fetchone()
if marker is None:
config_path = Path(
os.getenv("USERS_CONFIG_PATH", str(PROJECT_ROOT / "config" / "users.json"))
)
records: list[tuple[str, str, int]] = []
sanitized_users: list[dict] = []
contained_plaintext = False
try:
raw = json.loads(config_path.read_text(encoding="utf-8"))
if not isinstance(raw, list):
raise ValueError("users.json must contain a list")
seen: set[str] = set()
for item in raw:
user_id = str(item.get("id") or "").strip()
if not user_id or user_id in seen:
raise ValueError("users.json contains an empty or duplicate id")
seen.add(user_id)
password_hash = item.get("password")
if not password_hash and item.get("password_plain"):
contained_plaintext = True
password_hash = bcrypt.hashpw(
str(item["password_plain"]).encode(), bcrypt.gensalt()
).decode()
if not password_hash:
raise ValueError(f"missing password for user {user_id}")
try:
bcrypt.checkpw(b"hash-validation", password_hash.encode())
except ValueError as exc:
raise ValueError(f"invalid bcrypt hash for user {user_id}") from exc
records.append(
(user_id, password_hash, int(bool(item.get("is_admin") or user_id == "admin")))
)
sanitized = dict(item)
sanitized.pop("password_plain", None)
sanitized["password"] = password_hash
sanitized_users.append(sanitized)
except FileNotFoundError:
records = []
except (json.JSONDecodeError, ValueError) as exc:
raise RuntimeError(f"Cannot migrate users from {config_path}: {exc}") from exc
def _load_users_file():
"""Load users from config file. Auto-hash plain-text passwords on first run."""
import json as j
config_path = os.getenv(
"USERS_CONFIG_PATH", str(Path(__file__).parent.parent / "config" / "users.json")
)
with open(config_path, encoding="utf-8") as f:
users_raw = j.load(f)
if contained_plaintext:
temporary_path = config_path.with_suffix(config_path.suffix + ".tmp")
try:
temporary_path.write_text(
json.dumps(sanitized_users, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
os.replace(temporary_path, config_path)
except OSError as exc:
temporary_path.unlink(missing_ok=True)
raise RuntimeError(
f"Cannot remove plaintext passwords from {config_path}; "
"make the file writable for the first migration"
) from exc
# Migrate plain-text passwords to bcrypt hashes (one-time on startup)
migrated = False
for user in users_raw:
if "password_plain" in user and "password" not in user:
pw_bytes = user.pop("password_plain").encode("utf-8")
user["password"] = bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8")
migrated = True
now = datetime.now(timezone.utc).isoformat()
connection.execute("BEGIN IMMEDIATE")
try:
for user_id, password_hash, is_admin in records:
connection.execute(
"INSERT OR IGNORE INTO users(id, password_hash, is_admin, is_active, "
"token_version, created_at, updated_at) VALUES(?, ?, ?, 1, 0, ?, ?)",
(user_id, password_hash, is_admin, now, now),
)
connection.execute(
"INSERT OR IGNORE INTO schema_meta(key, value) VALUES('users_json_v1', ?)",
(now,),
)
connection.execute("COMMIT")
except Exception:
connection.execute("ROLLBACK")
raise
logger.info("Imported %d user(s) from users.json", len(records))
if migrated:
with open(config_path, "w", encoding="utf-8") as f:
j.dump(users_raw, f, ensure_ascii=False, indent=2)
return users_raw
if database.count_admins() == 0:
admin_id = os.getenv("BOOTSTRAP_ADMIN_ID")
admin_password = os.getenv("BOOTSTRAP_ADMIN_PASSWORD")
if admin_id and admin_password:
password_hash = bcrypt.hashpw(admin_password.encode(), bcrypt.gensalt()).decode()
if database.get_user(admin_id):
database.update_user(
admin_id,
password_hash=password_hash,
is_admin=True,
is_active=True,
)
else:
database.create_user(admin_id, password_hash, is_admin=True)
logger.warning("Created bootstrap administrator %s", admin_id)
else:
raise RuntimeError(
"No active administrator exists. Mark a users.json account as admin or "
"provide BOOTSTRAP_ADMIN_ID and BOOTSTRAP_ADMIN_PASSWORD."
)
_users_db = _load_users_file()
_migrate_users()
def get_user_by_username(username: str):
for u in _users_db:
if u["id"] == username:
return u
return None
return database.get_user(username)
# ── Token Helpers ─────────────────────────────────────
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
payload = data.copy()
payload["exp"] = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def decode_access_token(token: str) -> dict | None:
try:
return jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
return None
# ── Dependency ───────────────────────────────────────
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
"""Extract and validate JWT, returning the user_id."""
def _authenticated_user(credentials: HTTPAuthorizationCredentials | None) -> dict:
if credentials is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="인증이 필요합니다",
)
raise HTTPException(status_code=401, detail="인증이 필요합니다")
payload = decode_access_token(credentials.credentials)
user_id = payload.get("user_id") if payload else None
if not isinstance(user_id, str) or get_user_by_username(user_id) is None:
user = database.get_user(user_id) if isinstance(user_id, str) else None
if (
user is None
or not user["is_active"]
or payload.get("token_version") != user["token_version"]
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="유효하지 않거나 만료된 토큰입니다",
)
return user_id
return user
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
return str(_authenticated_user(credentials)["id"])
async def require_admin(
credentials: HTTPAuthorizationCredentials | None = Depends(security),
) -> str:
user = _authenticated_user(credentials)
if not user["is_admin"]:
raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다")
return str(user["id"])
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
__all__ = [
"create_access_token",
"get_current_user",
"get_user_by_username",
"hash_password",
"require_admin",
]

459
backend/database.py Normal file
View file

@ -0,0 +1,459 @@
"""SQLite persistence for users, translation sessions, and one prior revision."""
import json
import os
import sqlite3
import threading
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterator
from dotenv import load_dotenv
from backend.models import TranslationSession
PROJECT_ROOT = Path(__file__).parent.parent
load_dotenv(PROJECT_ROOT / ".env")
class SessionConflictError(Exception):
"""Raised when an optimistic session update uses a stale version."""
class SessionArchivedError(Exception):
"""Raised when content mutation is attempted on an archived session."""
class LastAdminError(Exception):
"""Raised when an update would remove the final active administrator."""
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _default_title(source_text: str) -> str:
first_line = next((line.strip() for line in source_text.splitlines() if line.strip()), "")
return first_line[:80] or "새 번역"
class Database:
def __init__(self, path: str | Path | None = None):
configured = path or os.getenv(
"DATABASE_PATH",
str(PROJECT_ROOT / "data" / "translator.db"),
)
self.path = Path(configured)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self.initialize()
@contextmanager
def connect(self) -> Iterator[sqlite3.Connection]:
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA busy_timeout = 10000")
try:
yield connection
finally:
connection.close()
def initialize(self) -> None:
with self._lock, self.connect() as connection:
connection.execute("PRAGMA journal_mode = WAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
password_hash TEXT NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0,
is_active INTEGER NOT NULL DEFAULT 1,
token_version INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS translation_sessions (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
current_phase INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 0,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
completed_at TEXT,
archived_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_owner_updated
ON translation_sessions(owner_id, archived_at, updated_at DESC);
CREATE TABLE IF NOT EXISTS session_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL UNIQUE
REFERENCES translation_sessions(id) ON DELETE CASCADE,
snapshot_json TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
connection.execute(
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES('schema_version', '1')"
)
# Users
def count_users(self) -> int:
with self.connect() as connection:
return int(connection.execute("SELECT COUNT(*) FROM users").fetchone()[0])
def count_admins(self) -> int:
with self.connect() as connection:
return int(
connection.execute(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND is_active = 1"
).fetchone()[0]
)
def get_user(self, user_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
return dict(row) if row else None
def list_users(self) -> list[dict[str, Any]]:
with self.connect() as connection:
rows = connection.execute(
"SELECT id, is_admin, is_active, created_at, updated_at "
"FROM users ORDER BY id"
).fetchall()
return [dict(row) for row in rows]
def create_user(
self, user_id: str, password_hash: str, *, is_admin: bool = False
) -> None:
now = _now()
with self._lock, self.connect() as connection:
connection.execute(
"INSERT INTO users(id, password_hash, is_admin, is_active, "
"token_version, created_at, updated_at) VALUES(?, ?, ?, 1, 0, ?, ?)",
(user_id, password_hash, int(is_admin), now, now),
)
def update_user(
self,
user_id: str,
*,
password_hash: str | None = None,
is_admin: bool | None = None,
is_active: bool | None = None,
) -> bool:
assignments: list[str] = []
values: list[Any] = []
invalidate_tokens = False
if password_hash is not None:
assignments.append("password_hash = ?")
values.append(password_hash)
invalidate_tokens = True
if is_admin is not None:
assignments.append("is_admin = ?")
values.append(int(is_admin))
if is_active is not None:
assignments.append("is_active = ?")
values.append(int(is_active))
if not is_active:
invalidate_tokens = True
if invalidate_tokens:
assignments.append("token_version = token_version + 1")
if not assignments:
return self.get_user(user_id) is not None
assignments.append("updated_at = ?")
values.extend([_now(), user_id])
with self._lock, self.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
current = connection.execute(
"SELECT is_admin, is_active FROM users WHERE id = ?", (user_id,)
).fetchone()
if not current:
connection.execute("ROLLBACK")
return False
removes_admin = current["is_admin"] and current["is_active"] and (
is_admin is False or is_active is False
)
if removes_admin:
admin_count = connection.execute(
"SELECT COUNT(*) FROM users WHERE is_admin = 1 AND is_active = 1"
).fetchone()[0]
if admin_count <= 1:
connection.execute("ROLLBACK")
raise LastAdminError
cursor = connection.execute(
f"UPDATE users SET {', '.join(assignments)} WHERE id = ?", values
)
connection.execute("COMMIT")
return cursor.rowcount == 1
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
# Sessions
def create_session(
self, session: TranslationSession, owner_id: str, title: str | None = None
) -> str:
session_id = uuid.uuid4().hex[:16]
now = _now()
data = session.model_copy(update={"progress": None}).model_dump(mode="json")
phase = self._phase_from_data(data)
status = "completed" if phase >= 4 else "draft"
with self._lock, self.connect() as connection:
connection.execute(
"INSERT INTO translation_sessions(id, owner_id, title, status, current_phase, "
"data_json, created_at, updated_at, completed_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
session_id,
owner_id,
title or _default_title(session.source_text),
status,
phase,
json.dumps(data, ensure_ascii=False),
now,
now,
now if phase >= 4 else None,
),
)
return session_id
def get_session(
self, session_id: str, owner_id: str
) -> tuple[TranslationSession, int, dict[str, Any]] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT * FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return None
metadata = dict(row)
return TranslationSession(**json.loads(row["data_json"])), row["version"], metadata
def update_session(
self,
session_id: str,
owner_id: str,
patch: dict[str, Any],
*,
expected_version: int | None = None,
snapshot_reason: str | None = None,
current_phase: int | None = None,
) -> tuple[TranslationSession, int] | None:
with self._lock, self.connect() as connection:
connection.execute("BEGIN IMMEDIATE")
try:
row = connection.execute(
"SELECT * FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
connection.execute("ROLLBACK")
return None
if row["archived_at"] is not None:
connection.execute("ROLLBACK")
raise SessionArchivedError
if expected_version is not None and row["version"] != expected_version:
connection.execute("ROLLBACK")
raise SessionConflictError
old_data = json.loads(row["data_json"])
if snapshot_reason and any(
old_data.get(key)
for key in ("phase1_result", "phase3_result", "phase4_result")
):
connection.execute(
"INSERT INTO session_revisions(session_id, snapshot_json, reason, created_at) "
"VALUES(?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET "
"snapshot_json = excluded.snapshot_json, reason = excluded.reason, "
"created_at = excluded.created_at",
(session_id, row["data_json"], snapshot_reason, _now()),
)
merged = {**old_data, **patch, "progress": None}
session = TranslationSession(**merged)
version = row["version"] + 1
phase = current_phase if current_phase is not None else row["current_phase"]
status = "completed" if phase >= 4 else "draft"
completed_at = _now() if phase >= 4 else None
title = row["title"]
if patch.get("source_text") is not None and title in {"새 번역", _default_title(old_data.get("source_text", ""))}:
title = _default_title(session.source_text)
cursor = connection.execute(
"UPDATE translation_sessions SET title = ?, status = ?, current_phase = ?, "
"version = ?, data_json = ?, updated_at = ?, completed_at = ? "
"WHERE id = ? AND owner_id = ? AND version = ?",
(
title,
status,
phase,
version,
json.dumps(session.model_dump(mode="json"), ensure_ascii=False),
_now(),
completed_at,
session_id,
owner_id,
row["version"],
),
)
if cursor.rowcount != 1:
connection.execute("ROLLBACK")
raise SessionConflictError
connection.execute("COMMIT")
return session, version
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
def list_sessions(
self, owner_id: str, *, include_archived: bool = False, limit: int = 50
) -> list[dict[str, Any]]:
archived_clause = "" if include_archived else "AND archived_at IS NULL"
with self.connect() as connection:
rows = connection.execute(
f"SELECT id, title, status, current_phase, version, data_json, "
f"created_at, updated_at, completed_at, archived_at "
f"FROM translation_sessions WHERE owner_id = ? {archived_clause} "
f"ORDER BY updated_at DESC LIMIT ?",
(owner_id, min(max(limit, 1), 100)),
).fetchall()
summaries = []
for row in rows:
data = json.loads(row["data_json"])
summaries.append(
{
"session_id": row["id"],
"title": row["title"],
"source_preview": data.get("source_text", "")[:120],
"target_language": data.get("target_language", "한국어"),
"status": row["status"],
"current_phase": row["current_phase"],
"version": row["version"],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
"archived_at": row["archived_at"],
}
)
return summaries
def set_archived(self, session_id: str, owner_id: str, archived: bool) -> bool:
with self._lock, self.connect() as connection:
row = connection.execute(
"SELECT current_phase FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return False
cursor = connection.execute(
"UPDATE translation_sessions SET archived_at = ?, status = ?, updated_at = ?, "
"version = version + 1 "
"WHERE id = ? AND owner_id = ?",
(
_now() if archived else None,
"archived" if archived else ("completed" if row["current_phase"] >= 4 else "draft"),
_now(),
session_id,
owner_id,
),
)
return cursor.rowcount == 1
@staticmethod
def _phase_from_data(data: dict[str, Any]) -> int:
if data.get("phase4_result"):
return 4
if data.get("phase3_result"):
return 3
if data.get("phase2_confirmed"):
return 2
if data.get("phase1_result"):
return 1
return 0
def rename_session(self, session_id: str, owner_id: str, title: str) -> bool:
with self._lock, self.connect() as connection:
cursor = connection.execute(
"UPDATE translation_sessions SET title = ?, updated_at = ? "
"WHERE id = ? AND owner_id = ?",
(title.strip()[:120] or "새 번역", _now(), session_id, owner_id),
)
return cursor.rowcount == 1
def delete_session(self, session_id: str, owner_id: str) -> bool:
with self._lock, self.connect() as connection:
cursor = connection.execute(
"DELETE FROM translation_sessions WHERE id = ? AND owner_id = ?",
(session_id, owner_id),
)
return cursor.rowcount == 1
def clone_session(self, session_id: str, owner_id: str) -> str | None:
result = self.get_session(session_id, owner_id)
if not result:
return None
session, _, metadata = result
clone = session.model_copy(update={"progress": None})
return self.create_session(clone, owner_id, f"{metadata['title']} (복사본)")
def get_revision(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
with self.connect() as connection:
row = connection.execute(
"SELECT r.snapshot_json, r.reason, r.created_at FROM session_revisions r "
"JOIN translation_sessions s ON s.id = r.session_id "
"WHERE r.session_id = ? AND s.owner_id = ?",
(session_id, owner_id),
).fetchone()
if not row:
return None
return {
"data": json.loads(row["snapshot_json"]),
"reason": row["reason"],
"created_at": row["created_at"],
}
def restore_revision(self, session_id: str, owner_id: str) -> bool:
revision = self.get_revision(session_id, owner_id)
if not revision:
return False
result = self.get_session(session_id, owner_id)
if not result:
return False
_, version, _ = result
data = revision["data"]
phase = self._phase_from_data(data)
restored = self.update_session(
session_id,
owner_id,
data,
expected_version=version,
snapshot_reason="revision_restore",
current_phase=phase,
)
return restored is not None
def healthcheck(self) -> bool:
with self.connect() as connection:
return connection.execute("SELECT 1").fetchone()[0] == 1
database = Database()

View file

@ -3,6 +3,7 @@
import json
import os
import re
import sqlite3
from collections import Counter
from pathlib import Path
from time import monotonic
@ -14,7 +15,8 @@ from openai import APITimeoutError, OpenAIError
from pydantic import ValidationError
from backend import auth as auth_mod
from backend.auth import get_current_user
from backend.auth import get_current_user, hash_password, require_admin
from backend.database import LastAdminError, database
from backend.llm_client import (
chat_complete,
get_llm_configs,
@ -22,16 +24,20 @@ from backend.llm_client import (
)
from backend.models import (
CreateSessionRequest,
CreateUserRequest,
LoginRequest,
Phase1Result,
Phase2ConfirmRequest,
PhaseUpdateRequest,
ProperNoun,
SessionResponse,
SessionSummary,
TokenResponse,
TranslationChunk,
UserInfo,
TranslationSession,
UpdateUserRequest,
UserSummary,
)
from backend.prompts import (
SYSTEM_PROMPT_PHASE1,
@ -41,7 +47,7 @@ from backend.prompts import (
build_phase3_user_prompt,
build_phase4_user_prompt,
)
from backend.sessions import SessionConflictError, session_store
from backend.sessions import SessionArchivedError, SessionConflictError, session_store
# ── App Setup ────────────────────────────────────────
@ -69,20 +75,63 @@ async def login(req: LoginRequest):
user = auth_mod.get_user_by_username(req.id)
try:
authenticated = user is not None and _bcrypt.checkpw(
req.password.encode("utf-8"), user["password"].encode("utf-8")
req.password.encode("utf-8"), user["password_hash"].encode("utf-8")
)
except (KeyError, ValueError):
authenticated = False
if not authenticated:
raise HTTPException(status_code=401, detail="ID 또는 비밀번호가 틀렸습니다")
token = auth_mod.create_access_token({"user_id": user["id"]})
if not user["is_active"]:
raise HTTPException(status_code=401, detail="비활성화된 계정입니다")
token = auth_mod.create_access_token(
{"user_id": user["id"], "token_version": user["token_version"]}
)
return TokenResponse(access_token=token)
@app.get("/api/auth/me")
async def me(user_id: str = Depends(get_current_user)):
return UserInfo(user_id=user_id)
user = database.get_user(user_id)
return UserInfo(user_id=user_id, is_admin=bool(user["is_admin"]))
@app.get("/api/admin/users")
async def list_users(_admin_id: str = Depends(require_admin)):
return [UserSummary(**user).model_dump() for user in database.list_users()]
@app.post("/api/admin/users", status_code=201)
async def create_user(req: CreateUserRequest, _admin_id: str = Depends(require_admin)):
try:
database.create_user(req.id, hash_password(req.password), is_admin=req.is_admin)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="이미 존재하는 사용자입니다") from exc
return {"created": True, "id": req.id}
@app.patch("/api/admin/users/{target_user_id}")
async def update_user(
target_user_id: str,
req: UpdateUserRequest,
admin_id: str = Depends(require_admin),
):
if target_user_id == admin_id and (req.is_active is False or req.is_admin is False):
raise HTTPException(status_code=400, detail="자기 관리자 권한을 제거할 수 없습니다")
try:
updated = database.update_user(
target_user_id,
password_hash=hash_password(req.password) if req.password else None,
is_admin=req.is_admin,
is_active=req.is_active,
)
except LastAdminError as exc:
raise HTTPException(
status_code=409, detail="최소 한 명의 활성 관리자가 필요합니다"
) from exc
if not updated:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다")
return {"updated": True}
# ── LLM Config Routes ────────────────────────────────
@ -117,16 +166,28 @@ async def create_session(
model_phase3=req.model_phase3,
model_phase4=req.model_phase4,
)
session_id = session_store.create(session_data, user_id)
session_id = session_store.db.create_session(session_data, user_id, req.title)
return {"session_id": session_id}
@app.get("/api/sessions")
async def list_sessions(
include_archived: bool = False,
limit: int = 50,
user_id: str = Depends(get_current_user),
):
return [
SessionSummary(**item).model_dump()
for item in session_store.list(user_id, include_archived, limit)
]
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str, user_id: str = Depends(get_current_user)):
data = session_store.get(session_id, user_id)
if data is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=data).model_dump()
return _session_response(session_id, user_id, data)
@app.get("/api/sessions/{session_id}/progress")
@ -147,6 +208,48 @@ async def delete_session(session_id: str, user_id: str = Depends(get_current_use
return {"deleted": True}
@app.post("/api/sessions/{session_id}/archive")
async def archive_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"archived": True}
@app.post("/api/sessions/{session_id}/restore")
async def restore_session(session_id: str, user_id: str = Depends(get_current_user)):
if not session_store.archive(session_id, user_id, archived=False):
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"restored": True}
@app.post("/api/sessions/{session_id}/clone", status_code=201)
async def clone_session(session_id: str, user_id: str = Depends(get_current_user)):
cloned_id = session_store.clone(session_id, user_id)
if not cloned_id:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return {"session_id": cloned_id}
@app.get("/api/sessions/{session_id}/revision")
async def get_revision(session_id: str, user_id: str = Depends(get_current_user)):
revision = session_store.get_revision(session_id, user_id)
if not revision:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
return revision
@app.post("/api/sessions/{session_id}/revision/restore")
async def restore_revision(session_id: str, user_id: str = Depends(get_current_user)):
try:
restored = session_store.restore_revision(session_id, user_id)
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if not restored:
raise HTTPException(status_code=404, detail="저장된 직전 버전이 없습니다")
data = session_store.get(session_id, user_id)
return _session_response(session_id, user_id, data)
@app.patch("/api/sessions/{session_id}")
async def update_session(
session_id: str,
@ -154,6 +257,8 @@ async def update_session(
user_id: str = Depends(get_current_user),
):
data = req.model_dump(exclude_none=True)
title = data.pop("title", None)
expected_version = data.pop("expected_version", None)
_validate_model_aliases(data)
current = session_store.get(session_id, user_id)
if current is None:
@ -166,6 +271,7 @@ async def update_session(
"phase1_chunks": [],
"phase2_proper_nouns": [],
"phase2_style": "",
"phase2_confirmed": False,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
@ -181,10 +287,34 @@ async def update_session(
elif changed & {"model_phase4"}:
data.update({"phase4_result": "", "progress": None})
updated = session_store.update(session_id, data, user_id)
snapshot_reason = "session_edit" if changed else None
current_phase = None
if changed & {"source_text", "source_language", "target_language", "model_phase1"}:
current_phase = 0
elif changed & {"model_phase3"}:
current_phase = 2 if current.phase2_confirmed else (1 if current.phase1_result else 0)
elif changed & {"model_phase4"}:
current_phase = 3 if current.phase3_result else (1 if current.phase1_result else 0)
try:
updated = session_store.update(
session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
if title is not None:
session_store.rename(session_id, user_id, title)
return _session_response(session_id, user_id, updated)
# ── Phase Execution Routes ───────────────────────────
@ -196,6 +326,7 @@ async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
alias = session.model_phase1
if not alias:
@ -247,16 +378,24 @@ async def run_phase1(session_id: str, user_id: str = Depends(get_current_user)):
],
"phase2_proper_nouns": [pn.model_dump() for pn in result.proper_nouns],
"phase2_style": result.style,
"phase2_confirmed": False,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
}
updated = _update_phase_result(session_id, update_data, user_id, version)
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase1_rerun",
current_phase=1,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase2")
@ -269,20 +408,38 @@ async def run_phase2(
session = session_store.get(session_id, user_id)
if session is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
if session.phase1_result is None:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
_ensure_not_archived(session_id, user_id)
update_data = {
"phase2_proper_nouns": [pn.model_dump() for pn in req.proper_nouns],
"phase2_style": req.style,
"phase2_confirmed": True,
"phase3_result": "",
"phase3_chunks": [],
"phase4_result": "",
"progress": None,
}
updated = session_store.update(session_id, update_data, user_id)
try:
updated = session_store.update(
session_id,
update_data,
user_id,
expected_version=req.expected_version,
snapshot_reason="phase2_edit",
current_phase=2,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409, detail="다른 변경이 먼저 저장되었습니다. 세션을 다시 불러오세요"
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase3")
@ -292,6 +449,7 @@ async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
if not session.phase1_result:
raise HTTPException(status_code=400, detail="먼저 Phase 1을 실행하세요")
@ -334,11 +492,18 @@ async def run_phase3(session_id: str, user_id: str = Depends(get_current_user)):
"phase4_result": "",
"progress": None,
}
updated = _update_phase_result(session_id, update_data, user_id, version)
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase3_rerun",
current_phase=3,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
return _session_response(session_id, user_id, updated)
@app.post("/api/translate/{session_id}/phase4")
@ -348,6 +513,7 @@ async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
if snapshot is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
session, version = snapshot
_ensure_not_archived(session_id, user_id)
if not session.phase3_result:
raise HTTPException(status_code=400, detail="먼저 Phase 3을 실행하세요")
@ -385,11 +551,18 @@ async def run_phase4(session_id: str, user_id: str = Depends(get_current_user)):
"phase4_result": "\n\n".join(result_chunks),
"progress": None,
}
updated = _update_phase_result(session_id, update_data, user_id, version)
updated = _update_phase_result(
session_id,
update_data,
user_id,
version,
snapshot_reason="phase4_rerun",
current_phase=4,
)
if updated is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(session_id=session_id, data=updated).model_dump()
return _session_response(session_id, user_id, updated)
# ── Helpers ───────────────────────────────────────────
@ -627,17 +800,55 @@ async def _chat_complete(
def _update_phase_result(
session_id: str, data: dict, user_id: str, expected_version: int
session_id: str,
data: dict,
user_id: str,
expected_version: int,
*,
snapshot_reason: str,
current_phase: int,
) -> TranslationSession | None:
try:
return session_store.update(
session_id, data, user_id, expected_version=expected_version
session_id,
data,
user_id,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
except SessionConflictError as exc:
raise HTTPException(
status_code=409,
detail="처리 중 세션이 변경되었습니다. 현재 상태에서 단계를 다시 실행하세요",
) from exc
except SessionArchivedError as exc:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다") from exc
def _session_response(
session_id: str, user_id: str, data: TranslationSession
) -> dict:
metadata = session_store.metadata(session_id, user_id)
if metadata is None:
raise HTTPException(status_code=404, detail="세션을 찾을 수 없습니다")
return SessionResponse(
session_id=session_id,
data=data,
title=metadata["title"],
status=metadata["status"],
current_phase=metadata["current_phase"],
version=metadata["version"],
created_at=metadata["created_at"],
updated_at=metadata["updated_at"],
archived_at=metadata["archived_at"],
).model_dump()
def _ensure_not_archived(session_id: str, user_id: str) -> None:
metadata = session_store.metadata(session_id, user_id)
if metadata and metadata["archived_at"] is not None:
raise HTTPException(status_code=409, detail="보관된 세션은 수정할 수 없습니다")
def _ensure_context_capacity(alias: str, content: str) -> None:
@ -666,6 +877,18 @@ def _extract_json(text: str) -> str:
return text[start : end + 1] if start >= 0 and end > start else text
@app.get("/api/health/live")
async def health_live():
return {"status": "ok"}
@app.get("/api/health/ready")
async def health_ready():
if not database.healthcheck() or not get_llm_configs():
raise HTTPException(status_code=503, detail="서비스가 준비되지 않았습니다")
return {"status": "ready"}
# ── Static File Serving (Frontend) ───────────────────
frontend_dist = os.getenv(

View file

@ -17,6 +17,27 @@ class TokenResponse(BaseModel):
class UserInfo(BaseModel):
user_id: str
is_admin: bool = False
class CreateUserRequest(BaseModel):
id: str = Field(min_length=1, max_length=50, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=200)
is_admin: bool = False
class UpdateUserRequest(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=200)
is_admin: bool | None = None
is_active: bool | None = None
class UserSummary(BaseModel):
id: str
is_admin: bool
is_active: bool
created_at: str
updated_at: str
# ── LLM Config ───────────────────────────────────────
@ -42,7 +63,7 @@ class Phase1Result(BaseModel):
proper_nouns: list[ProperNoun] # 추출된 고유명사 리스트
summary: str # 내용 요약
style: str # 문체 분석 결과
warnings: list[str] = [] # JSON 복구/폴백 등 품질 관련 알림
warnings: list[str] = Field(default_factory=list)
class TranslationChunk(BaseModel):
@ -69,11 +90,12 @@ class TranslationSession(BaseModel):
model_phase3: str = "" # Phase 3 LLM 모델 alias
model_phase4: str = "" # Phase 4 LLM 모델 alias
phase1_result: Phase1Result | None = None # 초벌 번역 결과
phase1_chunks: list[TranslationChunk] = [] # 장문 분할 시 원문/초벌 번역 쌍
phase2_proper_nouns: list[ProperNoun] = [] # Phase 2에서 최종 확정된 고유명사
phase1_chunks: list[TranslationChunk] = Field(default_factory=list)
phase2_proper_nouns: list[ProperNoun] = Field(default_factory=list)
phase2_style: str = "" # Phase 2에서 최종 확정된 문체
phase2_confirmed: bool = False
phase3_result: str = "" # 재번역 결과
phase3_chunks: list[str] = [] # 장문 분할 재번역 결과
phase3_chunks: list[str] = Field(default_factory=list)
phase4_result: str = "" # 마무리 다듬기 결과
progress: PhaseProgress | None = None # 장기 작업 진행 상태
@ -88,6 +110,7 @@ class CreateSessionRequest(BaseModel):
model_phase2: str = ""
model_phase3: str = ""
model_phase4: str = ""
title: str | None = Field(default=None, max_length=120)
class PhaseUpdateRequest(BaseModel):
@ -100,6 +123,8 @@ class PhaseUpdateRequest(BaseModel):
model_phase2: str | None = None
model_phase3: str | None = None
model_phase4: str | None = None
title: str | None = Field(default=None, max_length=120)
expected_version: int
class Phase2ConfirmRequest(BaseModel):
@ -107,6 +132,7 @@ class Phase2ConfirmRequest(BaseModel):
proper_nouns: list[ProperNoun]
style: str
expected_version: int
# ── API Responses ──────────────────────────────────────
@ -114,3 +140,29 @@ class Phase2ConfirmRequest(BaseModel):
class SessionResponse(BaseModel):
session_id: str
data: TranslationSession
title: str = ""
status: str = "draft"
current_phase: int = 0
version: int = 0
created_at: str = ""
updated_at: str = ""
archived_at: str | None = None
class SessionSummary(BaseModel):
session_id: str
title: str
source_preview: str
target_language: str
status: str
current_phase: int
version: int
created_at: str
updated_at: str
archived_at: str | None = None
class RevisionResponse(BaseModel):
data: TranslationSession
reason: str
created_at: str

View file

@ -1,37 +1,25 @@
"""In-memory translation session store with 24h TTL."""
"""Persistent translation session store with transient in-memory progress."""
import uuid
from datetime import datetime, timezone, timedelta
import threading
from typing import Any
from backend.models import TranslationSession
class SessionConflictError(Exception):
"""Raised when a long-running phase tries to update a changed session."""
from backend.database import (
Database,
SessionArchivedError,
SessionConflictError,
database,
)
from backend.models import PhaseProgress, TranslationSession
class SessionStore:
"""Process-local in-memory store for user-owned translation sessions.
Each session is keyed by a UUID and has a 24-hour TTL.
Expired sessions are lazily cleaned on access.
"""
def __init__(self, ttl_hours: int = 24):
self._store: dict[str, dict[str, Any]] = {}
self.ttl_hours = ttl_hours
def __init__(self, db: Database | None = None):
self.db = db or database
self._progress: dict[tuple[str, str], dict[str, Any]] = {}
self._progress_lock = threading.RLock()
def create(self, session_data: TranslationSession, owner_id: str) -> str:
session_id = uuid.uuid4().hex[:16]
self._store[session_id] = {
"data": session_data.model_dump(),
"owner_id": owner_id,
"version": 0,
"created_at": datetime.now(timezone.utc),
"updated_at": datetime.now(timezone.utc),
}
return session_id
return self.db.create_session(session_data, owner_id)
def get(self, session_id: str, owner_id: str) -> TranslationSession | None:
result = self.get_with_version(session_id, owner_id)
@ -40,18 +28,15 @@ class SessionStore:
def get_with_version(
self, session_id: str, owner_id: str
) -> tuple[TranslationSession, int] | None:
entry = self._store.get(session_id)
if entry is None or entry["owner_id"] != owner_id:
result = self.db.get_session(session_id, owner_id)
if not result:
return None
# Check TTL
now = datetime.now(timezone.utc)
age = now - entry["updated_at"]
if age > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return None
# Touch (refresh TTL)
entry["updated_at"] = now
return TranslationSession(**entry["data"]), entry["version"]
session, version, _ = result
with self._progress_lock:
progress = self._progress.get((owner_id, session_id))
if progress:
session = session.model_copy(update={"progress": PhaseProgress(**progress)})
return session, version
def update(
self,
@ -59,53 +44,68 @@ class SessionStore:
data: dict[str, Any],
owner_id: str,
expected_version: int | None = None,
*,
snapshot_reason: str | None = None,
current_phase: int | None = None,
) -> TranslationSession | None:
entry = self._store.get(session_id)
if entry is None or entry["owner_id"] != owner_id:
result = self.db.update_session(
session_id,
owner_id,
data,
expected_version=expected_version,
snapshot_reason=snapshot_reason,
current_phase=current_phase,
)
if not result:
return None
now = datetime.now(timezone.utc)
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return None
if expected_version is not None and entry["version"] != expected_version:
raise SessionConflictError
entry["data"].update(data)
entry["version"] += 1
entry["updated_at"] = now
return TranslationSession(**entry["data"])
self.set_progress(session_id, None, owner_id)
return result[0]
def delete(self, session_id: str, owner_id: str) -> bool:
entry = self._store.get(session_id)
if entry is not None and entry["owner_id"] == owner_id:
del self._store[session_id]
return True
return False
self.set_progress(session_id, None, owner_id)
return self.db.delete_session(session_id, owner_id)
def set_progress(
self, session_id: str, progress: dict[str, Any] | None, owner_id: str
) -> bool:
"""Update transient progress without changing the semantic session version."""
entry = self._store.get(session_id)
if entry is None or entry["owner_id"] != owner_id:
return False
now = datetime.now(timezone.utc)
if now - entry["updated_at"] > timedelta(hours=self.ttl_hours):
del self._store[session_id]
return False
entry["data"]["progress"] = progress
entry["updated_at"] = now
key = (owner_id, session_id)
with self._progress_lock:
if progress is None:
self._progress.pop(key, None)
else:
self._progress[key] = progress
return True
def cleanup_expired(self):
"""Remove all expired sessions."""
now = datetime.now(timezone.utc)
expired_keys = [
k for k, v in self._store.items()
if (now - v["updated_at"]) > timedelta(hours=self.ttl_hours)
]
for k in expired_keys:
del self._store[k]
def list(self, owner_id: str, include_archived: bool = False, limit: int = 50):
return self.db.list_sessions(
owner_id, include_archived=include_archived, limit=limit
)
def metadata(self, session_id: str, owner_id: str) -> dict[str, Any] | None:
result = self.db.get_session(session_id, owner_id)
return result[2] if result else None
def rename(self, session_id: str, owner_id: str, title: str) -> bool:
return self.db.rename_session(session_id, owner_id, title)
def archive(self, session_id: str, owner_id: str, archived: bool = True) -> bool:
return self.db.set_archived(session_id, owner_id, archived)
def clone(self, session_id: str, owner_id: str) -> str | None:
return self.db.clone_session(session_id, owner_id)
def get_revision(self, session_id: str, owner_id: str):
return self.db.get_revision(session_id, owner_id)
def restore_revision(self, session_id: str, owner_id: str) -> bool:
return self.db.restore_revision(session_id, owner_id)
# Singleton instance
session_store = SessionStore(ttl_hours=24)
session_store = SessionStore()
__all__ = [
"SessionArchivedError",
"SessionConflictError",
"SessionStore",
"session_store",
]