diff --git a/AGENTS.md b/AGENTS.md index 01b5ac9..2641397 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,24 +61,38 @@ ## 레포 구조 ``` -solution/ 사장님 — backend(FastAPI+워커) · front(빌더) · site(발행물) · shared(계약) -admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** +solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약) +admin/ 우리 — 전체 사이트 운영. 프론트만. API 는 아래 :9801 을 본다 ``` 최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md). -★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 이 수집 배선·UI 를 재수출로 가져다 쓴다 -(`admin/src/**` 의 얇은 파일들). 반대 방향이 생기면 번들을 가른 의미가 사라진다. +★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를 +가리키고, API 도 solution 백엔드를 본다. 반대 방향이 생기면 가른 의미가 사라진다. + +★ **백엔드는 코드 한 벌, 진입점 둘이다.** + +| | 포트 | 진입점 | 권한 | +|---|---|---|---| +| 사장님 API | 9800 | `web_main.py` → `router/router.py` | 엔드포인트별 | +| 내부 API | 9801 | `admin_main.py` → `router/admin_router.py` | **앱 전체 role >= DEVELOPER** | + +`services`·`crud`·`models` 은 그대로 공유한다. admin 전용 라우터가 0개라(세어봤다: +admin 화면이 부르는 건 전부 place·fact 다) 도메인을 복제하지 않고 **같은 router 객체를 +다시 마운트하면서 앱 단위로 권한만 덧건다.** +경로 접두어(`/v1/admin/...`)가 아니라 **포트**를 가른 이유: 접두어는 같은 프로세스라 +사장님이 닿는 서버에 내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다. +`ADMIN_API_BIND` 기본값이 `127.0.0.1` 인 것도 같은 이유다 — 0.0.0.0 으로 열면 무의미하다. ## 실행 ```bash -docker compose up -d # API :9800 · 워커 · web :3000 · admin :3002 · nginx :80 +docker compose up -d # api :9800 · api-admin :9801 · 워커 · web :3000 · admin :3002 · nginx :80 docker compose logs -f worker ``` -- 사장님 앱: `http://localhost:3000` · 내부 운영: `http://localhost:3002` +- 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림) - 발행 사이트: `http://localhost:3000/s/` (front Vite 가 :3001 정적서버로 프록시) - **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf` (후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다) @@ -98,7 +112,7 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래 | 파일 | 담는 것 | |---|---| | `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` | -| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*` | +| `solution/frontend/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*`. admin 은 API 가 :9801 이다 | ★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를 `VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다. diff --git a/README.md b/README.md index a0eb940..ee67b6a 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,11 @@ DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`). ``` solution/ 사장님 — 사이트 만들기·관리 - backend/ FastAPI + 워커. HTML 을 만들지 않는다 — payload JSON 만 떨어뜨린다 - front/ 빌더(위저드 + 에디터 + 발행 게이트) + backend/ FastAPI + 워커. 진입점 둘 — web_main(:9800 사장님) / admin_main(:9801 내부) + frontend/ 빌더(위저드 + 에디터 + 발행 게이트) site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버 shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug) -admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** +admin/ 우리 — 전체 사이트 운영 (프론트). API 는 solution/backend 의 :9801 진입점 docs/ 아래 표 nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다) postgres-init/ 스키마 DDL (init.sql 한 벌) diff --git a/admin/.env.example b/admin/.env.example index b923695..0d5e53d 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -1,4 +1,4 @@ # 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다. # ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고 # compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다. -VITE_API_BASE_URL=http://localhost:9800 +VITE_API_BASE_URL=http://localhost:9801 diff --git a/docker-compose.yml b/docker-compose.yml index 89c8e09..67378b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,11 @@ # 컨테이너와 볼륨이 통째로 새로 생긴다. name: o2o-web4ai -# DB 는 compose 밖이다(호스트 PostgreSQL). 최초 1회: +# DB 는 compose 밖이다(호스트 PostgreSQL). +# ★ 컨테이너 이름이 negosium-db 인 건 베낀 흔적이 아니다 — web4ai 는 DB 인스턴스를 따로 띄우지 +# 않고, negosium 이 쓰는 postgres(5432) 안에 web4ai_db 라는 database 만 새로 만들어 쓴다 +# (DECISIONS.md 3절). 그래서 아래 명령의 컨테이너 이름이 negosium-db 다. +# 최초 1회: # docker exec -i -e PGPASSWORD=password negosium-db \ # psql -h 127.0.0.1 -U postgres -d postgres -v ON_ERROR_STOP=1 < postgres-init/init-data/init.sql @@ -79,6 +83,41 @@ services: driver: json-file options: { max-size: "10m", max-file: "5" } + # 내부 운영 API(:9801). 코드는 api 와 같은 이미지·같은 소스이고 진입점만 다르다 + # (admin_main.py → router/admin_router.py). place·fact 를 두 번 구현하지 않으면서 + # 프로세스와 포트만 가른다. 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다. + api-admin: + image: o2o-web4ai-backend + container_name: o2o-web4ai-api-admin + command: ["python", "admin_main.py"] + env_file: + - .env + environment: + <<: *common-env + SCHEDULER_ENABLED: "0" + ADMIN_API_PORT: "9801" + # ★ 이미지의 HEALTHCHECK 는 :9800 을 찌른다(api 용). 이 컨테이너는 9801 이라 + # 그대로 두면 멀쩡히 돌면서 영원히 unhealthy 다 — 포트만 바꿔 다시 건다. + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:9801/healthz', timeout=4).status==200 else 1)"] + interval: 30s + timeout: 5s + start_period: 20s + retries: 3 + volumes: + - ./solution/site/payloads:/app/out/payloads + ports: + # ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다. + - "${ADMIN_API_BIND:-127.0.0.1}:9801:9801" + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - api + restart: unless-stopped + logging: + driver: json-file + options: { max-size: "10m", max-file: "5" } + # 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시) web: image: node:24-alpine @@ -135,7 +174,8 @@ services: [ -x node_modules/.bin/vite ] || npm install exec npm run dev -w @o2o/admin environment: - VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9800} + # ★ 내부 API(:9801)를 본다. :9800 을 보면 API 를 가른 의미가 없다. + VITE_API_BASE_URL: ${ADMIN_API_BASE_URL:-http://localhost:9801} volumes: - ./package.json:/app/package.json - ./package-lock.json:/app/package-lock.json diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7c88a5c..2a7bbd0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -99,7 +99,7 @@ o2o-web4ai/ │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) │ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰) │ -├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** +├─ admin/ 우리 — 전체 사이트 운영 (프론트). API 는 :9801 │ ├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트) ``` @@ -122,14 +122,40 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 내부: 실패가 곧 차단). 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. -### admin 에 백엔드를 두지 않은 이유 +### 백엔드 — 코드 한 벌, 진입점 둘 -내부 화면이 부르는 것이 전부 지금 백엔드에 이미 있다 — `useGetPlace` `useListPlaces` -`useListFacts` `useListLinks` `useGetSchema` `useConfirmLink` `useTransitionFact` -→ `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고, 자체 백엔드를 두면 -`place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다. +| | 포트 | 진입점 | 권한 | +|---|---|---|---| +| 사장님 API | 9800 | `web_main.py` → `router/router.py` | 엔드포인트별 | +| 내부 API | **9801** | `admin_main.py` → `router/admin_router.py` | **앱 전체 `role >= DEVELOPER`** | -→ 대가: `solution/backend` 가 죽으면 admin 도 멈춘다. **내부 도구라 감수한다.** +`services`·`crud`·`models` 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다: + +``` +useGetPlace · useListPlaces · useListLinks · useConfirmLink → router/v1/place +useListFacts · useGetSchema · useTransitionFact → router/v1/fact +``` + +admin 화면이 부르는 게 전부 `place`·`fact` 이고, 그 둘은 사장님 빌더도 쓴다. 자체 백엔드에 +엔드포인트를 새로 쓰면 **같은 DB 의 같은 테이블을 두 벌** 구현하는 것뿐이다. +그래서 같은 router 객체를 다시 마운트하고 **앱 단위로 권한만 덧건다.** + +**왜 경로 접두어(`/v1/admin/...`)가 아니라 포트인가.** 접두어는 같은 프로세스 안이라 +사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.** 포트를 가르면 사장님이 닿는 +네트워크에 아예 없다 — 가드보다 강하다. compose 의 `ADMIN_API_BIND` 기본값이 +`127.0.0.1` 인 것도 같은 이유다. **0.0.0.0 으로 열면 가른 의미가 없다.** + +검증(role 별 `/v1/place/list`): + +``` +USER role=1 → 403 DEVELOPER role=3 → 200 +OWNER role=2 → 403 +``` + +OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. +`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). + +→ 대가: `solution/backend` 의 코드에 묶인다. 배포는 갈리지만 소스는 한 벌이다. ### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다 @@ -151,15 +177,8 @@ admin 자기 파일만 `@admin` 이다. 정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때 그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다. -남은 일은 **라우터 표면을 청중별로 가르는 것**뿐이다: - -``` -/api/v1/... 사장님 (solution/frontend) — 자기 리소스만 -/api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제 -``` - -엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.** -`common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니 라우터 의존성으로 올린다. +청중별로 가르는 일은 **포트로 끝냈다**(위 표). 엔드포인트마다 `if role >= ...` 를 +흩뿌리지 않고 `RequireDeveloper` 의존성 하나를 앱에 건다. ⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더 `admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때 @@ -175,7 +194,6 @@ admin 자기 파일만 `@admin` 이다. ### 아직 안 한 것 -- `/api/v1/admin/*` 라우터 분리 + role 의존성 (위) - 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — 그때 `solution/frontend` 의 인증 정책을 다시 본다. - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 diff --git a/package-lock.json b/package-lock.json index 05cf0e8..4f89e18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8749,44 +8749,6 @@ } } }, - "shared": { - "name": "@o2o/shared", - "version": "0.0.0", - "extraneous": true, - "dependencies": { - "clsx": "^2.1.1", - "tailwind-merge": "^3.6.0" - } - }, - "site": { - "name": "@o2o/site", - "version": "0.0.0", - "extraneous": true, - "dependencies": { - "@o2o/shared": "*", - "@tailwindcss/vite": "^4.1.14", - "clsx": "^2.1.1", - "embla-carousel-react": "^8.6.0", - "lucide-react": "^0.546.0", - "react": "^19.0.1", - "react-dom": "^19.0.1", - "react-router": "^7.17.0", - "tailwind-merge": "^3.6.0" - }, - "devDependencies": { - "@types/node": "^22.14.0", - "@types/react": "^19.2.17", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.0.4", - "eslint": "^9.36.0", - "eslint-plugin-react-hooks": "^7.1.1", - "tailwindcss": "^4.1.14", - "typescript": "~5.8.2", - "typescript-eslint": "^8.45.0", - "vite": "^6.2.3", - "vitest": "^4.1.11" - } - }, "solution/frontend": { "name": "@o2o/front", "version": "0.0.0", diff --git a/solution/backend/admin_main.py b/solution/backend/admin_main.py new file mode 100644 index 0000000..c1299a4 --- /dev/null +++ b/solution/backend/admin_main.py @@ -0,0 +1,29 @@ +# 내부 운영 API 서버. 사장님 API(web_main.py, :9800)와 **다른 프로세스·다른 포트**다. +# +# python admin_main.py +# +# ★ 코드는 나누지 않는다. services/crud/models 를 web_main 과 그대로 공유하고 +# 진입점만 둘이다 — 도메인을 두 번 구현하지 않으면서 프로세스·네트워크만 가른다. +# 왜 그래야 하는지는 router/admin_router.py 주석에 있다. + +import os + +import uvicorn + +from common.logger import LOG +from config.server_configs import web_server_config + +LOG.SetPrefix(f"{web_server_config.server_name}-admin") + +import router.admin_router + +ADMIN_PORT = int(os.environ.get("ADMIN_API_PORT", "9801")) + +if __name__ == "__main__": + LOG.i(f"Admin API Port : {ADMIN_PORT}") + run_kwargs = dict(host="0.0.0.0", port=ADMIN_PORT, access_log=False) + if os.environ.get("RELOAD") == "1": + run_kwargs["reload"] = True + else: + run_kwargs["workers"] = 1 + uvicorn.run("router.admin_router:app", **run_kwargs) diff --git a/solution/backend/router/admin_router.py b/solution/backend/router/admin_router.py new file mode 100644 index 0000000..0c5e2e2 --- /dev/null +++ b/solution/backend/router/admin_router.py @@ -0,0 +1,97 @@ +"""내부 운영 API (admin 앱 전용). 사장님 API(:9800)와 **프로세스와 포트가 갈린다.** + +★ 왜 라우터를 새로 쓰지 않고 같은 것을 다시 마운트하나 + admin 화면이 부르는 API 는 전부 place·fact 라우터에 이미 있다(세어봤다: admin 전용 + 라우터는 0개다). 여기서 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌 + 구현하게 된다. 그래서 **같은 router 객체를 그대로 include 하고, 앱 단위로 권한만 덧건다.** + +★ 왜 경로 접두어(/v1/admin/...)가 아니라 포트를 가르나 + 접두어는 같은 프로세스 안에 있다 — 사장님이 닿는 서버에 내부 엔드포인트가 **존재한다.** + 포트를 가르면 내부 API 는 사장님이 닿는 네트워크에 아예 없다. 가드보다 강하다. + (compose 에서 이 포트는 127.0.0.1 에만 연다. 0.0.0.0 으로 열면 가른 의미가 없다.) + +★ 여기 붙는 모든 엔드포인트는 role >= DEVELOPER 다. + OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 막힌다 + (RequireDeveloper 주석 참조). 예외를 한 곳이라도 두면 그 예외가 기본값이 된다. +""" + +import time + +from fastapi import Depends, FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.gzip import GZipMiddleware + +from common.database.db_session_manager import DB_SESSION_MNG +from common.logger import LOG +from common.utils.gtime import GTime +from config.server_configs import web_server_config +from router.v1.validator.dependencies import RequireDeveloper +import router.v1.auth.account +import router.v1.fact.fact +import router.v1.job.job +import router.v1.local.local +import router.v1.place.place +import router.v1.site.site + +from contextlib import asynccontextmanager + +API_SERVER_START_TIME = GTime.UTCStr() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # ★ 스케줄러를 여기서 기동하지 않는다. 크론은 :9800 컨테이너 담당이고, + # 두 프로세스가 같이 돌면 같은 시각에 중복 실행된다. + yield + await DB_SESSION_MNG.dispose_all() + + +app = FastAPI(title="Web4Ai Admin API", lifespan=lifespan) + + +def _origins(*values: str) -> list[str]: + seen: list[str] = [] + for value in values: + for origin in value.split(","): + origin = origin.strip().rstrip("/") + if origin and origin not in seen: + seen.append(origin) + return seen + + +ALLOWED_ORIGINS = _origins(web_server_config.client_url) + +app.add_middleware( + CORSMiddleware, + allow_origins=ALLOWED_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware(GZipMiddleware, minimum_size=1000) + + +@app.middleware("http") +async def log_time(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + LOG.d(f"[admin] {response.status_code} {request.method} {request.url.path} - {time.time() - start_time:.4f}s") + return response + + +@app.get(path="/healthz") +async def healthz(): + return API_SERVER_START_TIME + + +# ★ auth 만 게이트 밖이다 — 로그인 자체를 막으면 아무도 들어올 수 없다. +# (로그인은 되지만 role 이 낮으면 아래 라우터가 전부 403 이다.) +app.include_router(router.v1.auth.account.router) + +# 그 밖의 전부: 앱 단위 DEVELOPER 게이트. +_gate = [Depends(RequireDeveloper)] +app.include_router(router.v1.place.place.router, dependencies=_gate) +app.include_router(router.v1.fact.fact.router, dependencies=_gate) +app.include_router(router.v1.job.job.router, dependencies=_gate) +app.include_router(router.v1.site.site.router, dependencies=_gate) +app.include_router(router.v1.local.local.router, dependencies=_gate)