Add persistent multi-user sessions and Docker deployment
This commit is contained in:
parent
51a6e845d1
commit
104577c826
24 changed files with 2094 additions and 468 deletions
217
backend/auth.py
217
backend/auth.py
|
|
@ -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
459
backend/database.py
Normal 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()
|
||||
261
backend/main.py
261
backend/main.py
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue