백엔드: 내부 운영 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:
parent
c85c577349
commit
c6b45fdda4
28
AGENTS.md
28
AGENTS.md
@ -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 과 화면 주소가 조용히 갈라진다.
|
||||||
|
|||||||
@ -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 한 벌)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
38
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
29
solution/backend/admin_main.py
Normal file
29
solution/backend/admin_main.py
Normal 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)
|
||||||
97
solution/backend/router/admin_router.py
Normal file
97
solution/backend/router/admin_router.py
Normal 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)
|
||||||
Loading…
Reference in New Issue
Block a user