"""개념 우주 웹서버. .venv/bin/python -m server # http://127.0.0.1:8000 """ import hashlib import json import os import time import traceback from contextlib import asynccontextmanager from collections import defaultdict, deque from pathlib import Path from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field from . import auth, db, members from .seed import ATLAS_JSON, seed, seed_if_empty WEB = db.ROOT / "web" TERM_STATUSES = ("draft", "review", "confirmed") REL_STATUSES = ("draft", "review", "confirmed", "rejected") REL_TYPES = ("part_of", "precedes", "informs", "contrasts_with", "related_to") FEEDBACK_STATUSES = ("new", "in_progress", "resolved", "rejected", "duplicate") FEEDBACK_KINDS = ("content", "bug", "search", "suggestion", "client_error", "server_error") @asynccontextmanager async def lifespan(_app): con = db.get() seed_if_empty(con) made = auth.ensure_owner_account(con) if made: print(f"\n 교수자 계정을 만들었습니다 → 아이디: {made['username']} 비밀번호: {made['password']}" f"\n (/login 에서 로그인, 관리 → 설정에서 아이디·비밀번호 변경)\n") yield app = FastAPI(title="지역사회간호학 개념 우주", docs_url="/api/docs", openapi_url="/api/openapi.json", lifespan=lifespan) app.add_middleware(GZipMiddleware, minimum_size=1000) class GracefulCloseMiddleware: """연결을 닫는 요청(HTTP/1.0, Connection: close)에서 응답이 잘리는 문제 방지. Apple container의 포트 전달은 서버가 먼저 연결을 닫으면 아직 전달하지 못한 데이터를 버린다. 시놀로지 리버스 프록시(nginx)는 백엔드에 HTTP/1.0으로 요청하므로 큰 응답의 끝부분이 잘렸다. → 본문을 다 보낸 뒤 바로 끝내지 않고, 상대가 다 받고 연결을 닫을 때까지(최대 60초) 기다린다. """ def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) headers = dict(scope.get("headers") or []) conn = headers.get(b"connection", b"").lower() closing = scope.get("http_version") == "1.0" and b"keep-alive" not in conn or b"close" in conn if not closing: return await self.app(scope, receive, send) held = {"final": False, "start": None, "chunks": None} async def send_wrapper(message): if message["type"] == "http.response.start": hdrs = [(k.lower(), v) for k, v in message.get("headers", [])] if any(k == b"content-length" for k, _ in hdrs) or message["status"] in (204, 304): await send(message) else: # 길이를 모르는 응답(스트리밍 gzip 등)은 모아서 길이를 붙인다 held["start"], held["chunks"] = message, [] return if message["type"] == "http.response.body": last = not message.get("more_body", False) if held["chunks"] is not None: held["chunks"].append(message.get("body", b"")) if not last: return body = b"".join(held["chunks"]) st = held["start"] await send({**st, "headers": [*st.get("headers", []), (b"content-length", str(len(body)).encode())]}) message = {"type": "http.response.body", "body": body} if last: held["final"] = True await send({**message, "more_body": True}) return await send(message) await self.app(scope, receive, send_wrapper) if held["final"]: import asyncio try: while True: msg = await asyncio.wait_for(receive(), timeout=60) if msg["type"] == "http.disconnect": return except asyncio.TimeoutError: pass await send({"type": "http.response.body", "body": b"", "more_body": False}) app.add_middleware(GracefulCloseMiddleware) app.include_router(members.router) app.mount("/static", StaticFiles(directory=WEB / "static"), name="static") # ------------------------------------------------------------------ 오류 기록 def _record_error(kind: str, message: str, stack: str = "", url: str = "", ua: str = "", context=None): con = db.get() first = next((l.strip() for l in (stack or "").splitlines() if l.strip()), "") fp = hashlib.sha1(f"{kind}|{message[:300]}|{first[:300]}".encode()).hexdigest()[:16] with con: r = con.execute("SELECT id FROM feedback WHERE fingerprint=? AND status IN ('new','in_progress')", (fp,)).fetchone() if r: con.execute("UPDATE feedback SET occurrences=occurrences+1, updated_at=? WHERE id=?", (db.now(), r["id"])) return r["id"] cur = con.execute( "INSERT INTO feedback(created_at,updated_at,kind,category,message,context,page_url,user_agent,fingerprint)" " VALUES(?,?,?,?,?,?,?,?,?)", (db.now(), db.now(), kind, "자동 수집 오류", message[:2000], json.dumps({"stack": stack[:8000], **(context or {})}, ensure_ascii=False), url[:500], ua[:300], fp), ) return cur.lastrowid @app.exception_handler(Exception) async def _unhandled(request: Request, exc: Exception): tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)) try: _record_error("server_error", f"{type(exc).__name__}: {exc}", tb, str(request.url), request.headers.get("user-agent", ""), {"method": request.method}) except Exception: pass return JSONResponse({"detail": "서버 오류가 발생했습니다. 자동으로 기록되었습니다."}, status_code=500) # 간단한 IP별 요청 제한 (피드백 남용 방지) _hits: dict[str, deque] = defaultdict(deque) def _rate_limit(request: Request, limit=60, window=600): ip = request.client.host if request.client else "?" q = _hits[ip] t = time.time() while q and q[0] < t - window: q.popleft() if len(q) >= limit: raise HTTPException(429, "잠시 후 다시 시도해 주세요.") q.append(t) # ------------------------------------------------------------------ 페이지 @app.get("/", include_in_schema=False) def page_index(): return FileResponse(WEB / "index.html") @app.get("/feedback", include_in_schema=False) def page_feedback(): return FileResponse(WEB / "feedback.html") @app.get("/login", include_in_schema=False) def page_login(): return FileResponse(WEB / "login.html") @app.get("/signup", include_in_schema=False) def page_signup(): return FileResponse(WEB / "signup.html") @app.get("/account", include_in_schema=False) def page_account(): return FileResponse(WEB / "account.html") @app.get("/members", include_in_schema=False) def page_members(): return FileResponse(WEB / "admin.html") @app.get("/admin", include_in_schema=False) def page_admin(): return FileResponse(WEB / "admin.html") @app.get("/favicon.ico", include_in_schema=False) def favicon(): return FileResponse(WEB / "static" / "favicon.svg", media_type="image/svg+xml") # ------------------------------------------------------------------ 공개 데이터 def _public_filter(con): st = db.get_setting(con, "public_statuses") or list(TERM_STATUSES) return [s for s in st if s in TERM_STATUSES] or ["confirmed"] @app.get("/api/atlas") def api_atlas(request: Request, preview: bool = False): """지도·검색에 필요한 전체 데이터 (간략형). preview=1 은 관리자가 모든 상태를 볼 때.""" con = db.get() is_admin = auth.current_admin(request) is not None statuses = list(TERM_STATUSES) if preview and is_admin else _public_filter(con) ph = ",".join("?" * len(statuses)) books = [dict(r) for r in con.execute("SELECT * FROM books ORDER BY is_primary DESC, book_id")] terms = con.execute( f"SELECT term_id,name,kind,lang,aliases,search_terms,galaxy_id,section_id,item_id,review_status," f" easy,definition_text,example IS NOT NULL AS has_example FROM terms WHERE hidden=0 AND review_status IN ({ph})", statuses).fetchall() visible = {t["term_id"] for t in terms} counts = defaultdict(int) for t in terms: counts[t["galaxy_id"]] += 1 galaxies = [dict(r) | {"count": counts[r["galaxy_id"]]} for r in con.execute("SELECT * FROM galaxies ORDER BY ord")] primary = [b["book_id"] for b in books if b["is_primary"]] toc = con.execute( f"SELECT toc_id,book_id,kind,level,number,title,page,parent_id,galaxy_id,easy_sub FROM toc" f" WHERE book_id IN ({','.join('?' * len(primary))}) AND kind IN ('section','item') ORDER BY book_id, page, level", primary).fetchall() sections = [] by_sec = {} for n in toc: if n["kind"] == "section": s = {"id": n["toc_id"], "galaxy_id": n["galaxy_id"], "number": n["number"], "title": n["title"], "easy_sub": n["easy_sub"], "items": []} sections.append(s) by_sec[n["toc_id"]] = s for n in toc: if n["kind"] == "item" and n["parent_id"] in by_sec: by_sec[n["parent_id"]]["items"].append({"id": n["toc_id"], "title": n["title"], "number": n["number"]}) rel_st = [s for s in statuses if s != "rejected"] rels = con.execute( f"SELECT relation_id,source,target,type,label,directed,review_status,method FROM relations" f" WHERE hidden=0 AND review_status IN ({','.join('?' * len(rel_st))})", rel_st).fetchall() out_terms = [] for t in terms: # 공개 검색은 쉬운 설명만 (교과서 문장은 저작권 때문에 내보내지 않음) text = t["easy"] or "" out_terms.append({ "i": t["term_id"], "n": t["name"], "g": t["galaxy_id"], "s": t["section_id"], "it": t["item_id"], "st": t["review_status"], "k": t["kind"], "l": t["lang"], "a": json.loads(t["aliases"] or "[]"), "q": json.loads(t["search_terms"] or "[]"), "x": text[:260], "e": 1 if t["easy"] else 0, }) out_rels = [{"i": r["relation_id"], "s": r["source"], "t": r["target"], "ty": r["type"], "l": r["label"], "d": r["directed"], "st": r["review_status"], "m": r["method"]} for r in rels if r["source"] in visible and r["target"] in visible] meta = {r["key"]: r["value"] for r in con.execute("SELECT key, value FROM meta")} return { "meta": meta | {"show_status_badges": db.get_setting(con, "show_status_badges"), "statuses": statuses}, "books": books if is_admin else [], "galaxies": galaxies, "sections": sections, "terms": out_terms, "relations": out_rels, } def _term_detail(con, term_id: str, request: Request, admin=False): t = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone()) if not t or (not admin and (t["hidden"] or t["review_status"] not in _public_filter(con))): raise HTTPException(404, "용어를 찾을 수 없습니다.") books = {r["book_id"]: dict(r) for r in con.execute("SELECT * FROM books")} srcs = db.rows(con.execute("SELECT * FROM term_sources WHERE term_id=? ORDER BY id", (term_id,))) for s in srcs: b = books.get(s["book_id"], {}) s["book_short"] = b.get("short") s["book_title"] = b.get("title") s["is_primary"] = b.get("is_primary") srcs.sort(key=lambda s: (-(s["is_primary"] or 0), s["book_id"] or "", s["page"] or 0)) statuses = list(REL_STATUSES) if admin else [s for s in _public_filter(con)] ph = ",".join("?" * len(statuses)) rels = db.rows(con.execute( f"SELECT r.*, CASE WHEN r.source=? THEN r.target ELSE r.source END AS other_id FROM relations r" f" WHERE (r.source=? OR r.target=?) AND r.review_status IN ({ph})" + ("" if admin else " AND r.hidden=0"), (term_id, term_id, term_id, *statuses))) others = {} if rels: ids = list({r["other_id"] for r in rels}) for o in con.execute(f"SELECT term_id,name,galaxy_id,section_id,hidden,review_status FROM terms WHERE term_id IN ({','.join('?'*len(ids))})", ids): others[o["term_id"]] = dict(o) pub = set(_public_filter(con)) out_rels = [] for r in rels: o = others.get(r["other_id"]) if not o or (not admin and (o["hidden"] or o["review_status"] not in pub)): continue r["other"] = o r["outgoing"] = r["source"] == term_id out_rels.append(r) t["sources"] = srcs t["relations"] = out_rels sec = con.execute("SELECT toc_id,title,number,easy_sub FROM toc WHERE toc_id=?", (t["section_id"],)).fetchone() item = con.execute("SELECT toc_id,title,number FROM toc WHERE toc_id=?", (t["item_id"],)).fetchone() t["section"] = dict(sec) if sec else None t["item"] = dict(item) if item else None if t.get("definition_book"): b = books.get(t["definition_book"], {}) t["definition_book_short"] = b.get("short") if not admin and not members.can_view_members_content(con, request): # 사례는 승인된 회원 전용 t["example_locked"] = bool(t.get("example")) t["example"] = None if not admin: # 저작권: 학생에게는 교과서 문장·교재명·쪽수를 보내지 않는다 t["sources"] = [] for k in ("definition_text", "definition_book", "definition_page", "definition_method", "definition_book_short", "note", "reviewer"): t.pop(k, None) for r in t["relations"]: r.pop("evidence", None) t["open_feedback"] = con.execute( "SELECT COUNT(*) FROM feedback WHERE target_type='term' AND target_id=? AND status IN ('new','in_progress')", (term_id,)).fetchone()[0] return t @app.get("/api/terms/{term_id}") def api_term(term_id: str, request: Request): return _term_detail(db.get(), term_id, request) # ------------------------------------------------------------------ 피드백 (공개) class FeedbackIn(BaseModel): kind: str = "content" category: str = Field("", max_length=60) target_type: str | None = None target_id: str | None = Field(None, max_length=80) target_label: str | None = Field(None, max_length=200) message: str = Field(..., min_length=2, max_length=4000) suggestion: str | None = Field(None, max_length=4000) page_url: str | None = Field(None, max_length=500) reporter: str | None = Field(None, max_length=60) contact: str | None = Field(None, max_length=120) website: str | None = None # 스팸 방지용 숨은 칸 @app.post("/api/feedback") def api_feedback_create(body: FeedbackIn, request: Request): _rate_limit(request) if body.website: return {"ok": True, "id": 0} if body.kind not in FEEDBACK_KINDS or body.kind in ("client_error", "server_error"): raise HTTPException(400, "잘못된 유형입니다.") con = db.get() label = body.target_label if body.target_type == "term" and body.target_id: r = con.execute("SELECT name FROM terms WHERE term_id=?", (body.target_id,)).fetchone() if not r: raise HTTPException(400, "존재하지 않는 용어입니다.") label = r["name"] if body.target_type == "relation" and body.target_id: if not con.execute("SELECT 1 FROM relations WHERE relation_id=?", (body.target_id,)).fetchone(): raise HTTPException(400, "존재하지 않는 관계입니다.") with con: cur = con.execute( "INSERT INTO feedback(created_at,updated_at,kind,category,target_type,target_id,target_label,message,suggestion," "page_url,user_agent,reporter,contact) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (db.now(), db.now(), body.kind, body.category, body.target_type, body.target_id, label, body.message.strip(), (body.suggestion or "").strip() or None, body.page_url, request.headers.get("user-agent", "")[:300], (body.reporter or "").strip() or None, (body.contact or "").strip() or None), ) return {"ok": True, "id": cur.lastrowid} @app.get("/api/feedback") def api_feedback_list(status: str | None = None, target_id: str | None = None, limit: int = Query(50, le=200), offset: int = 0): """공개 목록: 연락처·내부 메모는 숨긴다. 자동 수집 오류는 보이지 않음.""" con = db.get() where = ["kind NOT IN ('client_error','server_error')"] args = [] if status: where.append("status=?") args.append(status) if target_id: where.append("target_id=?") args.append(target_id) w = " AND ".join(where) total = con.execute(f"SELECT COUNT(*) FROM feedback WHERE {w}", args).fetchone()[0] items = [dict(r) for r in con.execute( f"SELECT id,created_at,updated_at,kind,category,target_type,target_id,target_label,message,suggestion,status,resolution" f" FROM feedback WHERE {w} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))] return {"total": total, "items": items} class ClientError(BaseModel): message: str = Field(..., max_length=2000) stack: str | None = Field(None, max_length=8000) url: str | None = Field(None, max_length=500) source: str | None = Field(None, max_length=300) @app.post("/api/client-errors") def api_client_error(body: ClientError, request: Request): _rate_limit(request, limit=30, window=600) fid = _record_error("client_error", body.message, body.stack or body.source or "", body.url or "", request.headers.get("user-agent", "")) return {"ok": True, "id": fid} # ------------------------------------------------------------------ 관리자 class LoginIn(BaseModel): username: str = Field(..., min_length=1, max_length=40) password: str def _admin_session(response, request, a): con = db.get() with con: con.execute("UPDATE admins SET last_login=? WHERE admin_id=?", (db.now(), a["admin_id"])) response.set_cookie(auth.COOKIE, auth.make_token(a["name"] or a["username"], "owner"), max_age=auth.TTL, httponly=True, samesite="lax", secure=request.url.scheme == "https") @app.post("/api/admin/login") def admin_login(body: LoginIn, request: Request, response: Response): _rate_limit(request, limit=10, window=300) a = auth.check_login(db.get(), body.username, body.password) if not a: raise HTTPException(401, "아이디 또는 비밀번호가 맞지 않습니다.") _admin_session(response, request, a) return {"ok": True, "name": a["name"] or a["username"], "role": "owner"} @app.post("/api/login") def unified_login(body: LoginIn, request: Request, response: Response): """하나의 로그인 페이지: 교수자 아이디면 관리자 세션, 아니면 학생 회원 세션.""" _rate_limit(request, limit=15, window=300) con = db.get() a = auth.check_login(con, body.username, body.password) if a: _admin_session(response, request, a) return {"ok": True, "kind": "admin", "name": a["name"] or a["username"]} from .members import LoginIn as MemberLogin, login as member_login r = member_login(MemberLogin(username=body.username, password=body.password), request, response) return {"ok": True, "kind": "member", "name": r["name"], "status": r["status"]} @app.get("/api/admin/account") def admin_account(request: Request, _: str = Depends(auth.require_owner)): con = db.get() rows = [dict(r) for r in con.execute("SELECT admin_id, username, name, created_at, last_login FROM admins ORDER BY admin_id")] return {"items": rows} class AccountIn(BaseModel): current_password: str username: str | None = Field(None, min_length=3, max_length=30) name: str | None = Field(None, max_length=40) new_password: str | None = Field(None, min_length=8, max_length=100) @app.put("/api/admin/account") def admin_account_update(body: AccountIn, request: Request, response: Response, who: str = Depends(auth.require_owner)): """교수자 아이디·이름·비밀번호 변경 (현재 비밀번호 확인).""" con = db.get() a = con.execute("SELECT * FROM admins ORDER BY admin_id LIMIT 1").fetchone() if not a or not auth.verify_password(body.current_password, a["password_hash"]): raise HTTPException(400, "현재 비밀번호가 맞지 않습니다.") sets, args = [], [] if body.username and body.username != a["username"]: import re if not re.fullmatch(r"[A-Za-z0-9_.\-]{3,30}", body.username): raise HTTPException(400, "아이디는 영문·숫자·_ . - 로 3~30자입니다.") if con.execute("SELECT 1 FROM users WHERE username=?", (body.username,)).fetchone() or \ con.execute("SELECT 1 FROM admins WHERE username=? AND admin_id<>?", (body.username, a["admin_id"])).fetchone(): raise HTTPException(409, "이미 쓰는 아이디입니다.") sets.append("username=?") args.append(body.username) if body.name is not None: sets.append("name=?") args.append(body.name.strip() or None) if body.new_password: sets.append("password_hash=?") args.append(auth.hash_password(body.new_password)) if sets: with con: con.execute(f"UPDATE admins SET {','.join(sets)} WHERE admin_id=?", (*args, a["admin_id"])) db.audit(con, who, "update", "account", str(a["admin_id"]), None, {k: v for k, v in body.model_dump().items() if k in ("username", "name") and v}) a = con.execute("SELECT * FROM admins WHERE admin_id=?", (a["admin_id"],)).fetchone() _admin_session(response, request, dict(a)) return {"ok": True, "username": a["username"], "name": a["name"]} @app.post("/api/admin/logout") def admin_logout(response: Response): response.delete_cookie(auth.COOKIE) return {"ok": True} @app.get("/api/admin/me") def admin_me(request: Request): return {"name": auth.current_admin(request), "role": auth.current_role(request)} @app.get("/api/admin/stats") def admin_stats(_: str = Depends(auth.require_admin)): con = db.get() q = lambda sql: {r[0]: r[1] for r in con.execute(sql)} return { "terms": q("SELECT review_status, COUNT(*) FROM terms WHERE hidden=0 GROUP BY review_status"), "terms_hidden": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=1").fetchone()[0], "terms_easy": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND easy IS NOT NULL AND easy<>''").fetchone()[0], "terms_definition": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND definition_text IS NOT NULL").fetchone()[0], "terms_section_guess": con.execute("SELECT COUNT(*) FROM terms WHERE hidden=0 AND section_guess=1").fetchone()[0], "relations": q("SELECT review_status, COUNT(*) FROM relations WHERE hidden=0 GROUP BY review_status"), "relations_by_method": q("SELECT method, COUNT(*) FROM relations WHERE hidden=0 GROUP BY method"), "feedback": q("SELECT status, COUNT(*) FROM feedback GROUP BY status"), "feedback_open_by_kind": q("SELECT kind, COUNT(*) FROM feedback WHERE status IN ('new','in_progress') GROUP BY kind"), "by_galaxy": [dict(r) for r in con.execute( "SELECT g.galaxy_id, g.name, COUNT(t.term_id) AS terms," " SUM(CASE WHEN t.review_status='confirmed' THEN 1 ELSE 0 END) AS confirmed" " FROM galaxies g LEFT JOIN terms t ON t.galaxy_id=g.galaxy_id AND t.hidden=0 GROUP BY g.galaxy_id ORDER BY g.ord")], "meta": {r["key"]: r["value"] for r in con.execute("SELECT key,value FROM meta")}, } # ---- 피드백 처리 @app.get("/api/admin/feedback") def admin_feedback_list(status: str | None = None, kind: str | None = None, q: str | None = None, limit: int = Query(100, le=500), offset: int = 0, _: str = Depends(auth.require_admin)): con = db.get() where, args = ["1=1"], [] if status == "open": where.append("status IN ('new','in_progress')") elif status: where.append("status=?") args.append(status) if kind: where.append("kind=?") args.append(kind) if q: where.append("(message LIKE ? OR target_label LIKE ? OR suggestion LIKE ? OR admin_note LIKE ?)") args += [f"%{q}%"] * 4 w = " AND ".join(where) total = con.execute(f"SELECT COUNT(*) FROM feedback WHERE {w}", args).fetchone()[0] items = [dict(r) for r in con.execute(f"SELECT * FROM feedback WHERE {w} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))] return {"total": total, "items": items} @app.get("/api/admin/feedback/{fid}") def admin_feedback_get(fid: int, _: str = Depends(auth.require_admin)): r = db.get().execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone() if not r: raise HTTPException(404) return dict(r) class FeedbackPatch(BaseModel): status: str | None = None admin_note: str | None = None resolution: str | None = None @app.patch("/api/admin/feedback/{fid}") def admin_feedback_patch(fid: int, body: FeedbackPatch, who: str = Depends(auth.require_admin)): con = db.get() before = con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone() if not before: raise HTTPException(404) if body.status and body.status not in FEEDBACK_STATUSES: raise HTTPException(400, "잘못된 상태") changes = {k: v for k, v in body.model_dump().items() if v is not None} if not changes: return dict(before) with con: sets = ",".join(f"{k}=?" for k in changes) con.execute(f"UPDATE feedback SET {sets}, handled_by=?, updated_at=? WHERE id=?", (*changes.values(), who, db.now(), fid)) db.audit(con, who, "update", "feedback", str(fid), {k: before[k] for k in changes}, changes) return dict(con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone()) @app.delete("/api/admin/feedback/{fid}") def admin_feedback_delete(fid: int, who: str = Depends(auth.require_admin)): con = db.get() with con: before = con.execute("SELECT * FROM feedback WHERE id=?", (fid,)).fetchone() con.execute("DELETE FROM feedback WHERE id=?", (fid,)) db.audit(con, who, "delete", "feedback", str(fid), dict(before) if before else None, None) return {"ok": True} # ---- 용어 @app.get("/api/admin/terms") def admin_terms(q: str | None = None, galaxy: str | None = None, section: str | None = None, status: str | None = None, missing: str | None = None, hidden: int | None = None, sort: str = "name", limit: int = Query(100, le=1000), offset: int = 0, _: str = Depends(auth.require_admin)): con = db.get() where, args = ["1=1"], [] if q: where.append("(name LIKE ? OR aliases LIKE ? OR definition_text LIKE ? OR easy LIKE ? OR term_id=?)") args += [f"%{q}%"] * 4 + [q] if galaxy: where.append("galaxy_id=?") args.append(galaxy) if section: where.append("section_id=?") args.append(section) if status: where.append("review_status=?") args.append(status) if hidden is not None: where.append("hidden=?") args.append(hidden) if missing == "easy": where.append("(easy IS NULL OR easy='')") elif missing == "definition": where.append("definition_text IS NULL") elif missing == "section": where.append("section_guess=1") order = {"name": "name", "updated": "updated_at DESC", "status": "review_status, name"}.get(sort, "name") w = " AND ".join(where) total = con.execute(f"SELECT COUNT(*) FROM terms WHERE {w}", args).fetchone()[0] items = db.rows(con.execute( f"SELECT term_id,name,kind,lang,aliases,galaxy_id,section_id,section_guess,review_status,hidden,edited,origin," f" easy IS NOT NULL AND easy<>'' AS has_easy, definition_text IS NOT NULL AS has_def, updated_at, reviewer" f" FROM terms WHERE {w} ORDER BY {order} LIMIT ? OFFSET ?", (*args, limit, offset))) return {"total": total, "items": items} @app.get("/api/admin/terms/{term_id}") def admin_term(term_id: str, request: Request, _: str = Depends(auth.require_admin)): con = db.get() t = _term_detail(con, term_id, request, admin=True) t["feedback"] = [dict(r) for r in con.execute( "SELECT id,created_at,category,message,suggestion,status FROM feedback WHERE target_type='term' AND target_id=? ORDER BY id DESC", (term_id,))] t["history"] = [dict(r) for r in con.execute( "SELECT at,actor,action,before,after FROM audit WHERE entity='term' AND entity_id=? ORDER BY id DESC LIMIT 30", (term_id,))] return t class SourceIn(BaseModel): id: int | None = None book_id: str page: int | None = None pdf_page: int | None = None path: str | None = None snippet: str | None = None section_id: str | None = None class TermIn(BaseModel): name: str | None = Field(None, min_length=1, max_length=120) kind: str | None = None aliases: list[str] | None = None search_terms: list[str] | None = None galaxy_id: str | None = None section_id: str | None = None item_id: str | None = None definition_text: str | None = None definition_book: str | None = None definition_page: int | None = None easy: str | None = None example: str | None = None review_status: str | None = None hidden: bool | None = None note: str | None = None sources: list[SourceIn] | None = None TERM_EDITABLE = ("name", "kind", "aliases", "search_terms", "galaxy_id", "section_id", "item_id", "definition_text", "definition_book", "definition_page", "easy", "example", "review_status", "hidden", "note") def _apply_term(con, term_id, body: TermIn, who, creating=False): data = body.model_dump(exclude_unset=True) if "review_status" in data and data["review_status"] not in TERM_STATUSES: raise HTTPException(400, "잘못된 검수 상태") before = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone()) vals = {} for k in TERM_EDITABLE: if k in data: v = data[k] if k in ("aliases", "search_terms"): v = db.dumps([x.strip() for x in v or [] if x and x.strip()]) elif k == "hidden": v = int(bool(v)) elif isinstance(v, str): v = v.strip() or None vals[k] = v if "section_id" in vals: vals["section_guess"] = 0 if vals["section_id"] and "galaxy_id" not in vals: g = con.execute("SELECT galaxy_id FROM toc WHERE toc_id=?", (vals["section_id"],)).fetchone() if g: vals["galaxy_id"] = g["galaxy_id"] if "definition_text" in vals: vals["definition_method"] = "manual" if "easy" in vals: vals["easy_method"] = "manual" vals["edited"] = 1 vals["reviewer"] = who vals["updated_at"] = db.now() if creating: vals.setdefault("review_status", "draft") cols = ["term_id", *vals, "origin", "created_at"] con.execute(f"INSERT INTO terms({','.join(cols)}) VALUES({','.join('?'*len(cols))})", (term_id, *vals.values(), "manual", db.now())) else: sets = ",".join(f"{k}=?" for k in vals) con.execute(f"UPDATE terms SET {sets} WHERE term_id=?", (*vals.values(), term_id)) if body.sources is not None: keep = {s.id for s in body.sources if s.id} for r in con.execute("SELECT id FROM term_sources WHERE term_id=?", (term_id,)).fetchall(): if r["id"] not in keep: con.execute("DELETE FROM term_sources WHERE id=?", (r["id"],)) for s in body.sources: if s.id: cur = con.execute("SELECT book_id,page,pdf_page,path,snippet,section_id FROM term_sources WHERE id=? AND term_id=?", (s.id, term_id)).fetchone() new = (s.book_id, s.page, s.pdf_page, s.path, s.snippet, s.section_id) if cur and tuple(cur) != new: con.execute("UPDATE term_sources SET book_id=?, page=?, pdf_page=?, path=?, snippet=?, section_id=?, origin='manual' WHERE id=? AND term_id=?", (*new, s.id, term_id)) else: con.execute("INSERT INTO term_sources(term_id,book_id,page,pdf_page,path,snippet,section_id,origin) VALUES(?,?,?,?,?,?,?,'manual')", (term_id, s.book_id, s.page, s.pdf_page, s.path, s.snippet, s.section_id)) db.audit(con, who, "create" if creating else "update", "term", term_id, {k: before.get(k) for k in data if k != "sources"} if before else None, {k: v for k, v in data.items() if k != "sources"} | ({"sources": len(body.sources)} if body.sources is not None else {})) @app.put("/api/admin/terms/{term_id}") def admin_term_update(term_id: str, body: TermIn, request: Request, who: str = Depends(auth.require_admin)): con = db.get() if not con.execute("SELECT 1 FROM terms WHERE term_id=?", (term_id,)).fetchone(): raise HTTPException(404) with con: _apply_term(con, term_id, body, who) return admin_term(term_id, request, who) @app.post("/api/admin/terms") def admin_term_create(body: TermIn, request: Request, who: str = Depends(auth.require_admin)): if not body.name: raise HTTPException(400, "이름이 필요합니다.") con = db.get() import re key = re.sub(r"[\s·\-–_‧∙/'’\.]", "", body.name.lower()) term_id = "t_" + hashlib.sha1(key.encode()).hexdigest()[:10] if con.execute("SELECT 1 FROM terms WHERE term_id=?", (term_id,)).fetchone(): raise HTTPException(409, f"같은 이름의 용어가 이미 있습니다: {term_id}") with con: _apply_term(con, term_id, body, who, creating=True) return admin_term(term_id, request, who) class MergeIn(BaseModel): into: str @app.post("/api/admin/terms/{term_id}/merge") def admin_term_merge(term_id: str, body: MergeIn, request: Request, who: str = Depends(auth.require_admin)): """중복 용어를 다른 용어로 합침: 출처·관계·별칭을 옮기고 원래 용어는 숨김.""" con = db.get() a = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (term_id,)).fetchone()) b = db.row(con.execute("SELECT * FROM terms WHERE term_id=?", (body.into,)).fetchone()) if not a or not b or term_id == body.into: raise HTTPException(400, "합칠 용어를 확인하세요.") with con: aliases = list(dict.fromkeys(b["aliases"] + [a["name"]] + a["aliases"])) con.execute("UPDATE terms SET aliases=?, edited=1, updated_at=? WHERE term_id=?", (db.dumps(aliases), db.now(), b["term_id"])) con.execute("UPDATE term_sources SET term_id=?, origin='manual' WHERE term_id=?", (b["term_id"], term_id)) for r in con.execute("SELECT * FROM relations WHERE source=? OR target=?", (term_id, term_id)).fetchall(): s = b["term_id"] if r["source"] == term_id else r["source"] t = b["term_id"] if r["target"] == term_id else r["target"] if s == t: con.execute("DELETE FROM relations WHERE relation_id=?", (r["relation_id"],)) else: con.execute("UPDATE relations SET source=?, target=?, edited=1 WHERE relation_id=?", (s, t, r["relation_id"])) con.execute("DELETE FROM relations WHERE rowid NOT IN (SELECT MIN(rowid) FROM relations GROUP BY source, target, type)" " AND (source=? OR target=?)", (b["term_id"], b["term_id"])) con.execute("UPDATE terms SET hidden=1, edited=1, note=?, updated_at=? WHERE term_id=?", (f"‘{b['name']}’({b['term_id']})로 합쳐짐", db.now(), term_id)) con.execute("UPDATE feedback SET target_id=? WHERE target_type='term' AND target_id=?", (b["term_id"], term_id)) db.audit(con, who, "merge", "term", term_id, {"name": a["name"]}, {"into": b["term_id"]}) return admin_term(b["term_id"], request, who) # ---- 관계 @app.get("/api/admin/relations") def admin_relations(q: str | None = None, status: str | None = None, type: str | None = None, method: str | None = None, term: str | None = None, galaxy: str | None = None, limit: int = Query(100, le=1000), offset: int = 0, _: str = Depends(auth.require_admin)): con = db.get() where, args = ["1=1"], [] if status: where.append("r.review_status=?") args.append(status) if type: where.append("r.type=?") args.append(type) if method: where.append("r.method=?") args.append(method) if term: where.append("(r.source=? OR r.target=?)") args += [term, term] if galaxy: where.append("(a.galaxy_id=? OR b.galaxy_id=?)") args += [galaxy, galaxy] if q: where.append("(a.name LIKE ? OR b.name LIKE ? OR r.label LIKE ?)") args += [f"%{q}%"] * 3 w = " AND ".join(where) base = f"FROM relations r JOIN terms a ON a.term_id=r.source JOIN terms b ON b.term_id=r.target WHERE {w}" total = con.execute(f"SELECT COUNT(*) {base}", args).fetchone()[0] items = db.rows(con.execute( f"SELECT r.*, a.name AS source_name, a.galaxy_id AS source_galaxy, b.name AS target_name, b.galaxy_id AS target_galaxy" f" {base} ORDER BY r.review_status='draft' DESC, a.name LIMIT ? OFFSET ?", (*args, limit, offset))) return {"total": total, "items": items} class RelationIn(BaseModel): source: str | None = None target: str | None = None type: str | None = None label: str | None = Field(None, max_length=200) reason: str | None = Field(None, max_length=2000) directed: bool | None = None review_status: str | None = None hidden: bool | None = None evidence: list[dict] | None = None def _check_rel(con, data): if "type" in data and data["type"] not in REL_TYPES: raise HTTPException(400, "잘못된 관계 유형") if "review_status" in data and data["review_status"] not in REL_STATUSES: raise HTTPException(400, "잘못된 검수 상태") for k in ("source", "target"): if k in data and not con.execute("SELECT 1 FROM terms WHERE term_id=?", (data[k],)).fetchone(): raise HTTPException(400, f"{k} 용어가 없습니다.") @app.post("/api/admin/relations") def admin_relation_create(body: RelationIn, who: str = Depends(auth.require_admin)): con = db.get() data = body.model_dump(exclude_unset=True) if not (body.source and body.target and body.type): raise HTTPException(400, "시작·도착 용어와 유형이 필요합니다.") if body.source == body.target: raise HTTPException(400, "같은 용어끼리는 연결할 수 없습니다.") _check_rel(con, data) rid = "r_" + hashlib.sha1(f"{body.source}|{body.target}|{body.type}".encode()).hexdigest()[:10] if con.execute("SELECT 1 FROM relations WHERE relation_id=?", (rid,)).fetchone(): raise HTTPException(409, "같은 관계가 이미 있습니다.") directed = body.directed if body.directed is not None else body.type != "contrasts_with" with con: con.execute( "INSERT INTO relations(relation_id,source,target,type,label,reason,directed,method,evidence,review_status,hidden,edited,reviewer,created_at,updated_at)" " VALUES(?,?,?,?,?,?,?,?,?,?,0,1,?,?,?)", (rid, body.source, body.target, body.type, body.label, body.reason, int(directed), "manual", db.dumps(body.evidence or []), body.review_status or "draft", who, db.now(), db.now())) db.audit(con, who, "create", "relation", rid, None, data) return db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone()) @app.put("/api/admin/relations/{rid}") def admin_relation_update(rid: str, body: RelationIn, who: str = Depends(auth.require_admin)): con = db.get() before = db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone()) if not before: raise HTTPException(404) data = body.model_dump(exclude_unset=True) _check_rel(con, data) vals = {} for k, v in data.items(): if k == "evidence": v = db.dumps(v or []) elif k in ("directed", "hidden"): v = int(bool(v)) vals[k] = v vals.update(edited=1, reviewer=who, updated_at=db.now()) with con: con.execute(f"UPDATE relations SET {','.join(f'{k}=?' for k in vals)} WHERE relation_id=?", (*vals.values(), rid)) db.audit(con, who, "update", "relation", rid, {k: before.get(k) for k in data}, data) return db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone()) @app.delete("/api/admin/relations/{rid}") def admin_relation_delete(rid: str, who: str = Depends(auth.require_admin)): con = db.get() before = db.row(con.execute("SELECT * FROM relations WHERE relation_id=?", (rid,)).fetchone()) if not before: raise HTTPException(404) with con: if before["method"] == "manual": con.execute("DELETE FROM relations WHERE relation_id=?", (rid,)) else: # 자동 관계는 다시 생성되지 않도록 숨김 처리 con.execute("UPDATE relations SET hidden=1, review_status='rejected', edited=1, reviewer=?, updated_at=? WHERE relation_id=?", (who, db.now(), rid)) db.audit(con, who, "delete", "relation", rid, before, None) return {"ok": True} class BulkIn(BaseModel): ids: list[str] review_status: str | None = None hidden: bool | None = None @app.post("/api/admin/relations/bulk") def admin_relation_bulk(body: BulkIn, who: str = Depends(auth.require_admin)): con = db.get() if body.review_status and body.review_status not in REL_STATUSES: raise HTTPException(400, "잘못된 검수 상태") with con: for rid in body.ids: sets, args = ["edited=1", "reviewer=?", "updated_at=?"], [who, db.now()] if body.review_status: sets.append("review_status=?") args.append(body.review_status) if body.hidden is not None: sets.append("hidden=?") args.append(int(body.hidden)) con.execute(f"UPDATE relations SET {','.join(sets)} WHERE relation_id=?", (*args, rid)) db.audit(con, who, "bulk", "relation", ",".join(body.ids[:50]), None, body.model_dump(exclude={"ids"}) | {"count": len(body.ids)}) return {"ok": True, "count": len(body.ids)} @app.post("/api/admin/terms/bulk") def admin_term_bulk(body: BulkIn, who: str = Depends(auth.require_admin)): con = db.get() if body.review_status and body.review_status not in TERM_STATUSES: raise HTTPException(400, "잘못된 검수 상태") with con: for tid in body.ids: sets, args = ["edited=1", "reviewer=?", "updated_at=?"], [who, db.now()] if body.review_status: sets.append("review_status=?") args.append(body.review_status) if body.hidden is not None: sets.append("hidden=?") args.append(int(body.hidden)) con.execute(f"UPDATE terms SET {','.join(sets)} WHERE term_id=?", (*args, tid)) db.audit(con, who, "bulk", "term", ",".join(body.ids[:50]), None, body.model_dump(exclude={"ids"}) | {"count": len(body.ids)}) return {"ok": True, "count": len(body.ids)} # ---- 은하·단원 class GalaxyIn(BaseModel): name: str | None = None sub: str | None = None color: str | None = None @app.get("/api/admin/structure") def admin_structure(_: str = Depends(auth.require_admin)): con = db.get() return { "books": [dict(r) for r in con.execute("SELECT * FROM books")], "galaxies": [dict(r) for r in con.execute("SELECT * FROM galaxies ORDER BY ord")], "toc": [dict(r) for r in con.execute("SELECT * FROM toc ORDER BY book_id, page, level")], } @app.put("/api/admin/galaxies/{gid}") def admin_galaxy_update(gid: str, body: GalaxyIn, who: str = Depends(auth.require_admin)): con = db.get() before = con.execute("SELECT * FROM galaxies WHERE galaxy_id=?", (gid,)).fetchone() if not before: raise HTTPException(404) data = body.model_dump(exclude_unset=True) with con: if data: con.execute(f"UPDATE galaxies SET {','.join(f'{k}=?' for k in data)}, edited=1, updated_at=? WHERE galaxy_id=?", (*data.values(), db.now(), gid)) db.audit(con, who, "update", "galaxy", gid, {k: before[k] for k in data}, data) return dict(con.execute("SELECT * FROM galaxies WHERE galaxy_id=?", (gid,)).fetchone()) class TocIn(BaseModel): title: str | None = None easy_sub: str | None = None galaxy_id: str | None = None @app.put("/api/admin/toc/{toc_id}") def admin_toc_update(toc_id: str, body: TocIn, who: str = Depends(auth.require_admin)): con = db.get() before = con.execute("SELECT * FROM toc WHERE toc_id=?", (toc_id,)).fetchone() if not before: raise HTTPException(404) data = body.model_dump(exclude_unset=True) with con: if data: con.execute(f"UPDATE toc SET {','.join(f'{k}=?' for k in data)}, edited=1 WHERE toc_id=?", (*data.values(), toc_id)) if data.get("galaxy_id"): con.execute("UPDATE terms SET galaxy_id=?, updated_at=? WHERE section_id=?", (data["galaxy_id"], db.now(), toc_id)) db.audit(con, who, "update", "toc", toc_id, {k: before[k] for k in data}, data) return dict(con.execute("SELECT * FROM toc WHERE toc_id=?", (toc_id,)).fetchone()) # ---- 설정·기록·내보내기 @app.get("/api/admin/settings") def admin_settings(_: str = Depends(auth.require_admin)): return db.all_settings(db.get()) class SettingsIn(BaseModel): public_statuses: list[str] | None = None show_status_badges: bool | None = None @app.put("/api/admin/settings") def admin_settings_update(body: SettingsIn, who: str = Depends(auth.require_owner)): con = db.get() data = body.model_dump(exclude_unset=True) if "public_statuses" in data and not set(data["public_statuses"]) <= set(TERM_STATUSES): raise HTTPException(400, "잘못된 상태") with con: for k, v in data.items(): db.set_setting(con, k, v) db.audit(con, who, "update", "settings", None, None, data) return db.all_settings(con) @app.get("/api/admin/audit") def admin_audit(entity: str | None = None, limit: int = Query(100, le=500), offset: int = 0, _: str = Depends(auth.require_admin)): con = db.get() where, args = "1=1", [] if entity: where, args = "entity=?", [entity] total = con.execute(f"SELECT COUNT(*) FROM audit WHERE {where}", args).fetchone()[0] return {"total": total, "items": [dict(r) for r in con.execute( f"SELECT * FROM audit WHERE {where} ORDER BY id DESC LIMIT ? OFFSET ?", (*args, limit, offset))]} @app.get("/api/admin/export") def admin_export(_: str = Depends(auth.require_admin)): """현재 DB 전체(용어·관계·출처·구조·피드백)를 JSON으로 내려받기.""" con = db.get() data = { "exported_at": db.now(), "books": [dict(r) for r in con.execute("SELECT * FROM books")], "galaxies": [dict(r) for r in con.execute("SELECT * FROM galaxies")], "toc": [dict(r) for r in con.execute("SELECT * FROM toc")], "terms": db.rows(con.execute("SELECT * FROM terms")), "term_sources": [dict(r) for r in con.execute("SELECT * FROM term_sources")], "relations": db.rows(con.execute("SELECT * FROM relations")), "feedback": [dict(r) for r in con.execute("SELECT * FROM feedback")], } return Response(json.dumps(data, ensure_ascii=False, indent=1), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="atlas-export-{time.strftime("%Y%m%d-%H%M")}.json"'}) @app.post("/api/admin/reseed") def admin_reseed(who: str = Depends(auth.require_admin)): """data/atlas.json(파이프라인 결과)을 다시 가져옴. 사람이 고친 항목은 유지.""" if not ATLAS_JSON.exists(): raise HTTPException(404, "data/atlas.json 이 없습니다.") con = db.get() stats = seed(con, json.loads(ATLAS_JSON.read_text())) with con: db.audit(con, who, "reseed", "atlas", None, None, stats) return stats