백엔드: 내부 운영 API 를 :9801 진입점으로 가른다

admin 이 사장님 API(:9800)를 그대로 보고 있었다. 화면만 갈라 두면 내부 요청이
사장님이 닿는 서버로 나가고, 권한도 엔드포인트마다 흩어진 채로 남는다.

## 코드는 한 벌, 진입점만 둘

  web_main.py   → router/router.py         :9800  사장님
  admin_main.py → router/admin_router.py   :9801  내부

services·crud·models 은 공유한다. **admin 전용 라우터가 0개**이기 때문이다 — 세어봤다:
admin 화면이 부르는 훅(useGetPlace·useListPlaces·useListLinks·useConfirmLink·
useListFacts·useGetSchema·useTransitionFact)이 전부 place·fact 라우터이고,
그 둘은 사장님 빌더도 쓴다. 엔드포인트를 새로 쓰면 같은 DB 의 같은 테이블을 두 벌
구현하는 것뿐이라, 같은 router 객체를 다시 마운트하고 앱 단위로 권한만 덧걸었다.

## 왜 경로 접두어가 아니라 포트인가

/v1/admin/... 는 같은 프로세스 안이라 **사장님이 닿는 서버에 내부 엔드포인트가 존재한다.**
포트를 가르면 사장님이 닿는 네트워크에 아예 없다. compose 에서 이 포트는 127.0.0.1
에만 연다(ADMIN_API_BIND) — 0.0.0.0 으로 열면 가른 의미가 없다.

## 권한

RequireDeveloper 를 앱 단위로 건다. auth 라우터만 게이트 밖이다 —
로그인 자체를 막으면 아무도 들어올 수 없다.

  검증 /v1/place/list :  USER(1) 403 · OWNER(2) 403 · DEVELOPER(3) 200

OWNER 가 막히는 게 핵심이다. 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.

## 그 밖

- 이미지의 HEALTHCHECK 는 :9800 을 찌른다. 그대로 두면 이 컨테이너가 멀쩡히 돌면서
  영원히 unhealthy 라, 포트만 바꿔 다시 걸었다.
- compose 주석에 negosium-db 가 나오는 이유를 적었다 — 베낀 흔적이 아니라 DB 인스턴스를
  따로 안 띄우고 그 postgres 안에 web4ai_db 만 만들어 쓰기 때문이다(DECISIONS.md 3절).
  줄이면서 이유를 날려 읽는 사람이 오해하게 만들었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
This commit is contained in:
Mina Choi 2026-08-31 15:58:50 +09:00
parent c85c577349
commit c6b45fdda4
8 changed files with 228 additions and 68 deletions

View File

@ -61,24 +61,38 @@
## 레포 구조 ## 레포 구조
``` ```
solution/ 사장님 — backend(FastAPI+워커) · front(빌더) · site(발행물) · shared(계약) solution/ 사장님 — backend · frontend(빌더) · site(발행물) · shared(계약)
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** admin/ 우리 — 전체 사이트 운영. 프론트만. API 는 아래 :9801 을 본다
``` ```
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단 최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md). 묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 이 수집 배선·UI 를 재수출로 가져다 쓴다 ★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 의 `@` 별칭이 `solution/frontend/src` 를
(`admin/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 ```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 docker compose logs -f worker
``` ```
- 사장님 앱: `http://localhost:3000` · 내부 운영: `http://localhost:3002` - 사장님 앱 `:3000`(API :9800) · 내부 운영 `:3002`(API :9801, 로컬호스트에만 열림)
- 발행 사이트: `http://localhost:3000/s/<slug>` (front Vite 가 :3001 정적서버로 프록시) - 발행 사이트: `http://localhost:3000/s/<slug>` (front Vite 가 :3001 정적서버로 프록시)
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf` - **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다) (후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
@ -98,7 +112,7 @@ Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래
| 파일 | 담는 것 | | 파일 | 담는 것 |
|---|---| |---|---|
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` | | `.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` 를 ★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다. `VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.

View File

@ -31,11 +31,11 @@ DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
``` ```
solution/ 사장님 — 사이트 만들기·관리 solution/ 사장님 — 사이트 만들기·관리
backend/ FastAPI + 워커. HTML 을 만들지 않는다 — payload JSON 만 떨어뜨린다 backend/ FastAPI + 워커. 진입점 둘 — web_main(:9800 사장님) / admin_main(:9801 내부)
front/ 빌더(위저드 + 에디터 + 발행 게이트) frontend/ 빌더(위저드 + 에디터 + 발행 게이트)
site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버 site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버
shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug) shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug)
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** admin/ 우리 — 전체 사이트 운영 (프론트). API 는 solution/backend 의 :9801 진입점
docs/ 아래 표 docs/ 아래 표
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다) nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다)
postgres-init/ 스키마 DDL (init.sql 한 벌) postgres-init/ 스키마 DDL (init.sql 한 벌)

View File

@ -1,4 +1,4 @@
# 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다. # 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다.
# ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고 # ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고
# compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다. # compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다.
VITE_API_BASE_URL=http://localhost:9800 VITE_API_BASE_URL=http://localhost:9801

View File

@ -2,7 +2,11 @@
# 컨테이너와 볼륨이 통째로 새로 생긴다. # 컨테이너와 볼륨이 통째로 새로 생긴다.
name: o2o-web4ai 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 \ # 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 # 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 driver: json-file
options: { max-size: "10m", max-file: "5" } 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/*` 프록시) # 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
web: web:
image: node:24-alpine image: node:24-alpine
@ -135,7 +174,8 @@ services:
[ -x node_modules/.bin/vite ] || npm install [ -x node_modules/.bin/vite ] || npm install
exec npm run dev -w @o2o/admin exec npm run dev -w @o2o/admin
environment: 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: volumes:
- ./package.json:/app/package.json - ./package.json:/app/package.json
- ./package-lock.json:/app/package-lock.json - ./package-lock.json:/app/package-lock.json

View File

@ -99,7 +99,7 @@ o2o-web4ai/
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰) │ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
│ │
├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음** ├─ admin/ 우리 — 전체 사이트 운영 (프론트). API 는 :9801
│ │
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트) ├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
``` ```
@ -122,14 +122,40 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
내부: 실패가 곧 차단). 내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### admin 에 백엔드를 두지 않은 이유 ### 백엔드 — 코드 한 벌, 진입점 둘
내부 화면이 부르는 것이 전부 지금 백엔드에 이미 있다 — `useGetPlace` `useListPlaces` | | 포트 | 진입점 | 권한 |
`useListFacts` `useListLinks` `useGetSchema` `useConfirmLink` `useTransitionFact` |---|---|---|---|
→ `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고, 자체 백엔드를 두면 | 사장님 API | 9800 | `web_main.py` → `router/router.py` | 엔드포인트별 |
`place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다. | 내부 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 을 가리킨다 ### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다
@ -151,15 +177,8 @@ admin 자기 파일만 `@admin` 이다.
정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때 정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때
그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다. 그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
남은 일은 **라우터 표면을 청중별로 가르는 것**뿐이다: 청중별로 가르는 일은 **포트로 끝냈다**(위 표). 엔드포인트마다 `if role >= ...` 를
흩뿌리지 않고 `RequireDeveloper` 의존성 하나를 앱에 건다.
```
/api/v1/... 사장님 (solution/frontend) — 자기 리소스만
/api/v1/admin/... 내부 (admin) — 라우터 레벨에서 role >= DEVELOPER 강제
```
엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.**
`common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니 라우터 의존성으로 올린다.
⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더 ⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더
`admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때 `admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때
@ -175,7 +194,6 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것 ### 아직 안 한 것
- `/api/v1/admin/*` 라우터 분리 + role 의존성 (위)
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 — - 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
그때 `solution/frontend` 의 인증 정책을 다시 본다. 그때 `solution/frontend` 의 인증 정책을 다시 본다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을

38
package-lock.json generated
View File

@ -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": { "solution/frontend": {
"name": "@o2o/front", "name": "@o2o/front",
"version": "0.0.0", "version": "0.0.0",

View File

@ -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)

View File

@ -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)