sub-path 배포 지원 (root_path) + HTML 상대경로화

Apache 리버스 프록시로 /plagiarism 같은 sub-path에 배포 가능하도록:
- config.Settings.root_path 추가 (.env의 ROOT_PATH)
- FastAPI app(root_path=...) + uvicorn run(root_path=..., proxy_headers=True, forwarded_allow_ips="*")
- index.html의 모든 절대경로(/v1/..., /docs, /openapi.json)를 상대경로로 변경
- .env / .env.example 에 ROOT_PATH 추가

URL 직접 노출(localhost:8000)이든 sub-path(/plagiarism)든 모두 동작.
main
hbyang 2026-05-14 09:59:38 +09:00
parent 66dea0cc52
commit b8370f5c1a
4 changed files with 17 additions and 8 deletions

View File

@ -3,6 +3,8 @@ HOST=0.0.0.0
PORT=8000 PORT=8000
LOG_LEVEL=info LOG_LEVEL=info
RELOAD=false RELOAD=false
# 리버스 프록시 sub-path 배포 시. 예: /plagiarism (Apache가 /plagiarism → 컨테이너 매핑할 때)
ROOT_PATH=
ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse ENGINE_VERSION=o2o-plagiarism-2.1.0-kosimcse
REFERENCE_CORPUS_DIR=./data/reference REFERENCE_CORPUS_DIR=./data/reference

View File

@ -12,6 +12,7 @@ class Settings(BaseSettings):
port: int = 8000 port: int = 8000
log_level: str = "info" # debug / info / warning / error log_level: str = "info" # debug / info / warning / error
reload: bool = False # 개발용 자동 재시작 reload: bool = False # 개발용 자동 재시작
root_path: str = "" # 리버스 프록시 sub-path (예: /plagiarism)
engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2" engine_version: str = "o2o-plagiarism-2.0.0-pdf-v1.2"
reference_corpus_dir: str = "./data/reference" reference_corpus_dir: str = "./data/reference"

View File

@ -40,6 +40,8 @@ def rebuild_detector(app: FastAPI) -> int:
return app.state.detector.corpus_size return app.state.detector.corpus_size
_settings = get_settings()
app = FastAPI( app = FastAPI(
title="O2O 저작권 침해 여부 탐지 API", title="O2O 저작권 침해 여부 탐지 API",
description=( description=(
@ -48,6 +50,7 @@ app = FastAPI(
), ),
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
root_path=_settings.root_path,
) )
app.include_router(api_router) app.include_router(api_router)
@ -83,6 +86,9 @@ def main() -> None:
port=settings.port, port=settings.port,
log_level=settings.log_level, log_level=settings.log_level,
reload=settings.reload, reload=settings.reload,
root_path=settings.root_path,
forwarded_allow_ips="*", # 리버스 프록시(Apache) 헤더 신뢰
proxy_headers=True,
) )

View File

@ -351,8 +351,8 @@
</main> </main>
<footer> <footer>
<a href="/docs">API 문서 (Swagger)</a> · <a href="docs">API 문서 (Swagger)</a> ·
<a href="/openapi.json">OpenAPI 스펙</a> · <a href="openapi.json">OpenAPI 스펙</a> ·
엔진 버전: <span id="engine-version"></span> · 엔진 버전: <span id="engine-version"></span> ·
레퍼런스 코퍼스: <span id="corpus-size"></span> 레퍼런스 코퍼스: <span id="corpus-size"></span>
</footer> </footer>
@ -445,7 +445,7 @@ async function runDetect() {
document.getElementById("result-body").style.display = "none"; document.getElementById("result-body").style.display = "none";
try { try {
const resp = await fetch("/v1/plagiarism/detect", { const resp = await fetch("v1/plagiarism/detect", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
@ -613,7 +613,7 @@ async function loadCorpus() {
const tbody = document.getElementById("corpus-tbody"); const tbody = document.getElementById("corpus-tbody");
tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>'; tbody.innerHTML = '<tr><td colspan="4" style="color: var(--muted); text-align: center; padding: 20px;">로딩 중…</td></tr>';
try { try {
const resp = await fetch("/v1/corpus"); const resp = await fetch("v1/corpus");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json(); const data = await resp.json();
document.getElementById("corpus-count").textContent = data.total; document.getElementById("corpus-count").textContent = data.total;
@ -664,9 +664,9 @@ async function uploadCorpus() {
fd.append("title", title); fd.append("title", title);
if (docId) fd.append("doc_id", docId); if (docId) fd.append("doc_id", docId);
fd.append("file", file); fd.append("file", file);
resp = await fetch("/v1/corpus/file", { method: "POST", body: fd }); resp = await fetch("v1/corpus/file", { method: "POST", body: fd });
} else { } else {
resp = await fetch("/v1/corpus", { resp = await fetch("v1/corpus", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ doc_id: docId || null, title, text }), body: JSON.stringify({ doc_id: docId || null, title, text }),
@ -695,7 +695,7 @@ async function uploadCorpus() {
async function deleteDoc(docId) { async function deleteDoc(docId) {
if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return; if (!confirm(`'${docId}' 문서를 삭제하시겠습니까? 인덱스가 재빌드됩니다.`)) return;
try { try {
const resp = await fetch(`/v1/corpus/${encodeURIComponent(docId)}`, { const resp = await fetch(`v1/corpus/${encodeURIComponent(docId)}`, {
method: "DELETE", method: "DELETE",
}); });
if (!resp.ok && resp.status !== 204) { if (!resp.ok && resp.status !== 204) {
@ -728,7 +728,7 @@ function escapeJs(s) {
// 헬스 체크 + 코퍼스 정보 표시 // 헬스 체크 + 코퍼스 정보 표시
async function checkHealth() { async function checkHealth() {
try { try {
const resp = await fetch("/v1/health"); const resp = await fetch("v1/health");
if (!resp.ok) throw new Error("not ok"); if (!resp.ok) throw new Error("not ok");
const data = await resp.json(); const data = await resp.json();
const autobio = data.autobiography_mode ? ' · 자서전모드' : ''; const autobio = data.autobiography_mode ? ' · 자서전모드' : '';