구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
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
139d460839
commit
9d25ed613e
14
.gitignore
vendored
14
.gitignore
vendored
@ -20,13 +20,13 @@ venv/
|
||||
# 생성물(FastAPI OpenAPI 스펙). 필요 시 재생성한다.
|
||||
backend/openapi.json
|
||||
|
||||
# 발행 payload 는 frontend/site/payloads/.gitignore 가 단일 출처다 — 여기에 겹쳐 쓰지 않는다.
|
||||
# 발행 payload 는 solution/site/payloads/.gitignore 가 단일 출처다 — 여기에 겹쳐 쓰지 않는다.
|
||||
|
||||
# 정적 산출물 — 운영에서는 Docker named volume(site-out)에 있고, 로컬에서 직접 구우면
|
||||
# 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다.
|
||||
frontend/site/out/
|
||||
frontend/site/dist/
|
||||
frontend/admin/dist/
|
||||
solution/site/out/
|
||||
solution/site/dist/
|
||||
admin/dist/
|
||||
|
||||
# nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.
|
||||
# ★ compose(docker-compose.yml:224)가 ./nginx/site.conf 를 bind mount 한다 —
|
||||
@ -35,6 +35,12 @@ frontend/admin/dist/
|
||||
nginx/site.conf
|
||||
!nginx/site.conf.example
|
||||
|
||||
# ── npm 워크스페이스(루트가 solution/{shared,front,site} + admin 을 묶는다) ──
|
||||
node_modules/
|
||||
dist/
|
||||
.ssr-dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
|
||||
46
AGENTS.md
46
AGENTS.md
@ -29,7 +29,7 @@
|
||||
- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이
|
||||
사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다.
|
||||
→ 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다.
|
||||
- **★ 프론트(`frontend/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
|
||||
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
|
||||
→ `docker compose restart web` 후 `python scripts/republish_all.py`
|
||||
@ -43,7 +43,7 @@
|
||||
(프리렌더는 서브패스 마운트를 지원한다 — `basePath` 가 `/sites/s/joy` 면 자산은 `/sites/assets`.)
|
||||
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
|
||||
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔
|
||||
`frontend/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
|
||||
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
|
||||
- **디렉토리 요청 → `index.html`.** `/s/<slug>` 가 **끝 슬래시 없이** 열려야 한다.
|
||||
정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
|
||||
|
||||
@ -58,19 +58,51 @@
|
||||
코드를 읽으면 알 수 있는 "무엇"은 쓰지 않는다.
|
||||
- 문서는 코드와 **같은 커밋**에서 고친다. 동작을 바꿨는데 문서를 안 고쳤으면 미완이다.
|
||||
|
||||
## 레포 구조
|
||||
|
||||
```
|
||||
solution/ 사장님 — backend(FastAPI+워커) · front(빌더) · site(발행물) · shared(계약)
|
||||
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
|
||||
```
|
||||
|
||||
최상단은 **프로젝트 단위**다(`o2o-negosium` 과 같은 규약). `frontend/` `backend/` 를 최상단
|
||||
묶음 폴더로 쓰지 않는다. 근거와 경계는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
||||
|
||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** admin 이 수집 배선·UI 를 재수출로 가져다 쓴다
|
||||
(`admin/src/**` 의 얇은 파일들). 반대 방향이 생기면 번들을 가른 의미가 사라진다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
docker compose up -d # API :9800 · 워커 · web :3000 · nginx :80
|
||||
docker compose up -d # API :9800 · 워커 · web :3000 · admin :3002 · nginx :80
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>` (admin Vite 가 :3001 정적서버로 프록시)
|
||||
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`). `.env` 없으면 `cp .env.example .env`
|
||||
- 백엔드 스크립트는 `backend/` 에서 `.venv/bin/python scripts/<name>.py`
|
||||
- 테스트: `backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** —
|
||||
- 사장님 앱: `http://localhost:3000` · 내부 운영: `http://localhost:3002`
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>` (front Vite 가 :3001 정적서버로 프록시)
|
||||
- **클론 직후 1회**: `cp .env.example .env` · `cp nginx/site.conf.example nginx/site.conf`
|
||||
(후자를 빼먹으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다)
|
||||
- DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
|
||||
스키마는 `postgres-init/init-data/init.sql` **한 벌**이다 — 누적 ALTER 파일은 없다
|
||||
- npm 워크스페이스 루트는 **레포 루트**다. `npm install` 은 루트에서 한 번.
|
||||
`npm run dev:front` / `dev:admin` / `dev:site`
|
||||
- 백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`
|
||||
- 테스트: `solution/backend/` 에서 `.venv/bin/pytest`. **`APP_ENV=test` 면 `.env` 를 읽지 않는다** —
|
||||
실키가 테스트로 새어 외부 API 요금이 나가는 경로를 막아 뒀다
|
||||
|
||||
## .env 는 어디에 두나
|
||||
|
||||
**루트 `.env` 가 단일 출처다.** compose 가 이 파일만 읽고(`${...}` 치환 + `env_file`),
|
||||
Vite 앱은 구조상 **자기 디렉토리의 `.env` 만** 읽는다 — 그래서 나뉘어 있는 것이지 취향이 아니다.
|
||||
|
||||
| 파일 | 담는 것 |
|
||||
|---|---|
|
||||
| `.env` | DB · JWT · 외부 API 키 · `SITE_PUBLIC_HOST` · `INDEXNOW_KEY` |
|
||||
| `solution/front/.env` · `admin/.env` | 그 앱에만 있는 `VITE_*` |
|
||||
|
||||
★ **두 곳에 같은 값을 적지 않는다.** 발행 호스트는 compose 가 루트의 `SITE_PUBLIC_HOST` 를
|
||||
`VITE_PUBLISH_HOST` 로 흘려보낸다. 각자 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
|
||||
## 커밋
|
||||
|
||||
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.
|
||||
|
||||
14
README.md
14
README.md
@ -25,18 +25,20 @@ docker compose logs -f worker
|
||||
| API 문서 | http://localhost:9800/docs |
|
||||
|
||||
DB 는 compose 밖이다 (호스트 PostgreSQL, `host.docker.internal`).
|
||||
백엔드 스크립트는 `backend/` 에서 `.venv/bin/python scripts/<name>.py`.
|
||||
백엔드 스크립트는 `solution/backend/` 에서 `.venv/bin/python scripts/<name>.py`.
|
||||
|
||||
## 레포 구조
|
||||
|
||||
```
|
||||
backend/ FastAPI + 워커. HTML 을 만들지 않는다 — payload JSON 만 떨어뜨린다
|
||||
frontend/
|
||||
admin/ 빌더(사장님) + 내부 운영 화면 — ★ 분리 예정, ARCHITECTURE.md 4절
|
||||
solution/ 사장님 — 사이트 만들기·관리
|
||||
backend/ FastAPI + 워커. HTML 을 만들지 않는다 — payload JSON 만 떨어뜨린다
|
||||
front/ 빌더(위저드 + 에디터 + 발행 게이트)
|
||||
site/ 발행 사이트. SSR 엔트리 + 프리렌더 + 정적 서버
|
||||
shared/ 두 앱과 백엔드 계약이 만나는 타입·규칙 (SitePayload, slug)
|
||||
shared/ front·site·백엔드 계약이 만나는 타입·규칙 (SitePayload, slug)
|
||||
admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
|
||||
docs/ 아래 표
|
||||
postgres-init/ 스키마 DDL
|
||||
nginx/ 발행 사이트 정적 서빙 (site.conf 는 .example 만 커밋한다)
|
||||
postgres-init/ 스키마 DDL (init.sql 한 벌)
|
||||
```
|
||||
|
||||
## 문서 지도
|
||||
|
||||
4
admin/.env.example
Normal file
4
admin/.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
# 내부 운영 앱. Vite 는 .env 를 **자기 디렉토리에서만** 읽으므로 여기 둔다.
|
||||
# ★ 사장님 앱과 겹치는 값(발행 호스트 등)은 여기 적지 않는다 — 루트 .env 가 단일 출처이고
|
||||
# compose 가 주입한다. 두 곳에 적으면 언젠가 갈라진다.
|
||||
VITE_API_BASE_URL=http://localhost:9800
|
||||
21
admin/index.html
Normal file
21
admin/index.html
Normal file
@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<!-- 내부 운영 화면이다. 색인될 이유가 없다. -->
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<meta name="theme-color" content="#1463ff" />
|
||||
<title>Web4Ai 운영</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
39
admin/package.json
Normal file
39
admin/package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@o2o/admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3002 --host=0.0.0.0",
|
||||
"build": "tsc --noEmit && eslint src && vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "tsc --noEmit && eslint src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@o2o/shared": "*",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-router": "^7.17.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
15
admin/src/app/main.tsx
Normal file
15
admin/src/app/main.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {RouterProvider} from 'react-router';
|
||||
import {Providers} from './provider';
|
||||
import {router} from './router';
|
||||
// 스타일은 사장님 앱과 같은 것을 쓴다 — 같은 회사 화면이라 두 벌로 갈릴 이유가 없다.
|
||||
import '@/index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Providers>
|
||||
<RouterProvider router={router} />
|
||||
</Providers>
|
||||
</StrictMode>,
|
||||
);
|
||||
57
admin/src/app/provider.tsx
Normal file
57
admin/src/app/provider.tsx
Normal file
@ -0,0 +1,57 @@
|
||||
import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {useEffect, type ReactNode} from 'react';
|
||||
import {Toaster} from 'sonner';
|
||||
import {getAccessToken, me} from '@/api';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* 저장된 액세스 토큰으로 세션을 복구한다.
|
||||
*
|
||||
* ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.**
|
||||
* 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/front/app/provider.tsx),
|
||||
* 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘
|
||||
* 느슨한 쪽으로 나기 때문에 앱을 갈랐다.
|
||||
*/
|
||||
function useRestoreSession() {
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const finishRestore = useAuthStore((s) => s.finishRestore);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!getAccessToken()) {
|
||||
finishRestore();
|
||||
return;
|
||||
}
|
||||
void me()
|
||||
.then((res) => {
|
||||
if (!alive || res.result?.success === false) return;
|
||||
// RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다 — 반쪽짜리 사용자를 세우지 않는다.
|
||||
if (!res.user_id || !res.id) return;
|
||||
setUser(toAuthUser(res));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) finishRestore();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [setUser, finishRestore]);
|
||||
}
|
||||
|
||||
function SessionGate({children}: {children: ReactNode}) {
|
||||
useRestoreSession();
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function Providers({children}: {children: ReactNode}) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionGate>{children}</SessionGate>
|
||||
<Toaster richColors position="top-center" expand visibleToasts={9} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
47
admin/src/app/router.tsx
Normal file
47
admin/src/app/router.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {Building2, CalendarDays} from 'lucide-react';
|
||||
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
|
||||
import {AppShell, type NavItem} from '@/components/layout/AppShell';
|
||||
import {RequireAuth} from '@/components/layout/RequireAuth';
|
||||
import {LocalContentPage} from '@admin/pages/LocalContentPage';
|
||||
import {LoginPage} from '@/pages/LoginPage';
|
||||
import {NotFoundPage} from '@/pages/NotFoundPage';
|
||||
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
||||
import {PlaceListPage} from '@admin/pages/PlaceListPage';
|
||||
import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
||||
|
||||
/**
|
||||
* ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이
|
||||
* 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다.
|
||||
*/
|
||||
const ADMIN_NAV: NavItem[] = [
|
||||
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
||||
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
||||
];
|
||||
|
||||
/**
|
||||
* 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간
|
||||
* 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다.
|
||||
*/
|
||||
export const router = createBrowserRouter([
|
||||
{path: '/login', element: <LoginPage />},
|
||||
|
||||
{path: '/', element: <Navigate to="/places" replace />},
|
||||
|
||||
{
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppShell nav={ADMIN_NAV}>
|
||||
<Outlet />
|
||||
</AppShell>
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{path: '/places', element: <PlaceListPage />},
|
||||
{path: '/places/:placeId', element: <PlaceDetailPage />},
|
||||
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
|
||||
{path: '/local-content', element: <LocalContentPage />},
|
||||
],
|
||||
},
|
||||
|
||||
{path: '*', element: <NotFoundPage />},
|
||||
]);
|
||||
14
admin/src/lib/solutionUrl.ts
Normal file
14
admin/src/lib/solutionUrl.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 사장님 앱(`solution/front`)의 오리진.
|
||||
*
|
||||
* ★ 앱을 가른 뒤로 빌더는 **다른 오리진**이다(로컬 :3000 / 내부 :3002).
|
||||
* react-router `<Link to="/builder">` 로 두면 admin 안에서 라우트를 찾다 404 로 떨어진다.
|
||||
* 그래서 절대 URL 로 나가고 새 탭으로 연다 — 돌아오는 길이 탭 닫기 하나로 끝난다.
|
||||
* (사장님 앱에서 admin 으로 돌아오는 링크는 만들지 않는다. 사장님 앱은 admin 을 몰라야 한다.)
|
||||
*/
|
||||
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
|
||||
|
||||
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
|
||||
if (params?.placeId) return `${ORIGIN}/builder?placeId=${encodeURIComponent(params.placeId)}`;
|
||||
return `${ORIGIN}/builder?new=1`;
|
||||
}
|
||||
@ -14,6 +14,7 @@ import {
|
||||
import {PlaceCategory, PlaceStatus} from '@o2o/shared';
|
||||
import {deletePlace, useListPlaces} from '@/api';
|
||||
import {EmptyState, PageContainer} from '@/components/layout/AppShell';
|
||||
import {builderUrl} from '@admin/lib/solutionUrl';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@ -87,13 +88,15 @@ export function PlaceListPage() {
|
||||
className="w-56"
|
||||
/>
|
||||
{/* 새로 만드는 길은 위저드다 — `?new=1` 이 지난번 편집 상태를 비우고 1단계부터 연다. */}
|
||||
<Link
|
||||
to="/builder?new=1"
|
||||
<a
|
||||
href={builderUrl({isNew: true})}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<span>새 사이트 만들기</span>
|
||||
</Link>
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
@ -116,13 +119,15 @@ export function PlaceListPage() {
|
||||
title="아직 만든 사이트가 없습니다"
|
||||
description="상호명 하나만 있으면 시작할 수 있습니다. 주소·좌표는 동일 업소 검증이 채웁니다."
|
||||
action={
|
||||
<Link
|
||||
to="/builder?new=1"
|
||||
<a
|
||||
href={builderUrl({isNew: true})}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
<span>새 사이트 만들기</span>
|
||||
</Link>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@ -130,8 +135,10 @@ export function PlaceListPage() {
|
||||
{places.map((place) => (
|
||||
<li key={place.place_id} className="flex items-center">
|
||||
{/* 줄 전체가 에디터로 가는 링크다 — 목록에 온 용건이 편집이라서다. */}
|
||||
<Link
|
||||
to={`/builder?placeId=${place.place_id}`}
|
||||
<a
|
||||
href={builderUrl({placeId: place.place_id})}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-4 px-4 py-3 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
@ -159,16 +166,18 @@ export function PlaceListPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1 pr-3">
|
||||
<Link
|
||||
to={`/builder?placeId=${place.place_id}`}
|
||||
<a
|
||||
href={builderUrl({placeId: place.place_id})}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-2.5 text-xs font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
<span className="hidden sm:inline">사이트 편집</span>
|
||||
</Link>
|
||||
</a>
|
||||
{/* 발행 게이트에 걸렸을 때 가는 곳 — 수집된 값을 확인·수정한다. */}
|
||||
<Link
|
||||
to={`/places/${place.place_id}`}
|
||||
13
admin/tsconfig.json
Normal file
13
admin/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["vite/client", "node"],
|
||||
"paths": {
|
||||
"@admin/*": ["./src/*"],
|
||||
"@/*": ["../solution/front/src/*"],
|
||||
"@o2o/shared": ["../solution/shared/src/index.ts"],
|
||||
"@o2o/shared/*": ["../solution/shared/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
36
admin/vite.config.ts
Normal file
36
admin/vite.config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
/**
|
||||
* 내부 운영 화면(우리 회사용). 사장님 앱(`solution/front`)과 **번들이 갈린다** —
|
||||
* 이 앱의 라우트 이름과 코드는 사장님 브라우저에 내려가지 않는다. 그게 앱을 가른 이유다.
|
||||
*
|
||||
* ★ `@` 를 이 앱이 아니라 **사장님 앱의 src** 로 겨눈다.
|
||||
* 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 전부 거기 한 벌만 있고,
|
||||
* 그 파일들도 자기들끼리 `@/...` 로 서로를 부르기 때문이다. 여기서 `@` 를 admin/src 로
|
||||
* 잡으면 그 내부 참조가 전부 깨진다(실측: TS2307 14건).
|
||||
* 복제해서 푸는 방법도 있지만 RecollectPanel 주석이 그걸 금지한다 —
|
||||
* "수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
|
||||
*
|
||||
* ★ 이 앱 고유의 파일은 `@admin` 이다. 방향은 admin → solution 한 쪽뿐이고,
|
||||
* 반대가 생기면 번들을 가른 의미가 사라진다.
|
||||
*/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@admin': path.resolve(__dirname, 'src'),
|
||||
'@': path.resolve(__dirname, '../solution/front/src'),
|
||||
// css 는 더 구체적인 별칭이 먼저 와야 한다 — '@o2o/shared' 접두어가 먼저 걸리면
|
||||
// 없는 경로(shared/src/tokens.css)로 떨어진다.
|
||||
'@o2o/shared/tokens.css': path.resolve(__dirname, '../solution/shared/src/styles/tokens.css'),
|
||||
'@o2o/shared/base.css': path.resolve(__dirname, '../solution/shared/src/styles/base.css'),
|
||||
'@o2o/shared': path.resolve(__dirname, '../solution/shared/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
watch: {usePolling: true, interval: 300},
|
||||
},
|
||||
});
|
||||
@ -1,98 +1,47 @@
|
||||
# ★ 프로젝트 이름을 여기 못 박는다. 안 적으면 compose 가 **디렉터리 이름**을 프로젝트 이름으로
|
||||
# 쓰는데, 그러면 폴더를 옮기거나 이름을 바꾸는 순간 컨테이너와 볼륨이 통째로 새로 생긴다
|
||||
# (`o2o-site_site-out` → `o2o-web4ai_site-out`). 이름을 고정하면 폴더가 어디에 있든 같은 것을 본다.
|
||||
# ★ 프로젝트 이름을 못 박는다 — 안 적으면 compose 가 디렉터리 이름을 쓰고, 폴더를 옮기는 순간
|
||||
# 컨테이너와 볼륨이 통째로 새로 생긴다.
|
||||
name: o2o-web4ai
|
||||
|
||||
# o2o-web4ai 백엔드 (API + 작업 큐 워커).
|
||||
#
|
||||
# DB 는 compose 에서 관리하지 않는다 — 컨테이너는 host.docker.internal 로 호스트의 PostgreSQL 에 붙는다
|
||||
# (로컬 dev 는 negosium-db 컨테이너가 5432 를 열어두고 있다). 접속값은 config.local.toml 을
|
||||
# compose 의 DB_* env 가 덮어쓴다.
|
||||
#
|
||||
# docker compose up -d
|
||||
# API: http://localhost:9800/docs
|
||||
# 워커: 포트 없음 — docker compose logs -f o2o-web4ai-worker 로 확인
|
||||
#
|
||||
# DB 준비(최초 1회):
|
||||
# DB 는 compose 밖이다(호스트 PostgreSQL). 최초 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
|
||||
# (기존 DB 보정은 postgres-init/alters/*.sql 을 날짜순으로 적용)
|
||||
#
|
||||
# 외부 API 키(PERPLEXITY/KAKAO/GEMINI/TOUR)는 레포 최상위 .env 에서 읽는다 — cp .env.example .env 후 채운다.
|
||||
# ※ 컨테이너 안에서는 .env 파일을 직접 읽지 못한다(레포 루트가 이미지 밖). compose 의 env_file 이
|
||||
# 실제 환경변수로 주입하고, server_configs 의 env override 가 toml 값을 덮어쓴다.
|
||||
|
||||
# API 와 워커가 공유하는 환경변수. 둘은 **같은 DB 를 본다** — 한쪽만 다른 DB 를 가리키면
|
||||
# API 가 넣은 잡을 워커가 영영 못 본다. 그래서 한 곳에서 정의하고 양쪽이 가져다 쓴다.
|
||||
#
|
||||
# ★ SCHEDULER_ENABLED 는 여기 두지 않는다. 서비스마다 값이 달라야 하는 유일한 항목이라
|
||||
# 공용 블록에 섞으면 실수로 워커에서도 크론이 도는 사고가 난다.
|
||||
x-common-env: &common-env
|
||||
APP_ENV: local
|
||||
PYTHONUNBUFFERED: "1" # 컨테이너 로그 실시간 출력(stdout 버퍼링 끔)
|
||||
# ── DB 접속 ──
|
||||
# 이미지 안 config.local.toml 은 example 사본(플레이스홀더)이라 값이 비어 있다.
|
||||
# 아래 DB_* 가 없으면 `<DB_USER>` 로 접속을 시도하다 실패한다 — 반드시 주입해야 한다.
|
||||
# 기본값은 로컬 dev(negosium-db 컨테이너, postgres/password). 배포는 .env 나 셸 env 로 덮는다.
|
||||
DB_HOST: ${DB_HOST:-host.docker.internal} # 컨테이너→호스트 DB (toml 의 127.0.0.1 override)
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# 이미지 안 config.local.toml 은 플레이스홀더다 — 아래 값이 없으면 접속·서명이 실패한다.
|
||||
DB_HOST: ${DB_HOST:-host.docker.internal}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_USER: ${DB_USER:-postgres}
|
||||
DB_PASSWORD: ${DB_PASSWORD:-password}
|
||||
DB_NAME: ${DB_NAME:-web4ai_db}
|
||||
# ── JWT 서명 키 ──
|
||||
# ★ DB 와 같은 이유로 반드시 주입해야 한다. 이미지의 config.local.toml 은 플레이스홀더라
|
||||
# 주입하지 않으면 "<JWT_ACCESS_SECRET>" 이라는 공개된 문자열이 서명 키가 된다(부팅 시 경고 로그).
|
||||
# 운영 배포에서는 .env 나 배포 시크릿으로 반드시 채운다. 두 컨테이너가 같은 키를 봐야 한다.
|
||||
JWT_ACCESS_SECRET: ${JWT_ACCESS_SECRET:-}
|
||||
JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:-}
|
||||
# ── 발행 호스트 ──
|
||||
# 커스텀 도메인이 없는 사이트의 공개 주소(`https://<이 값>/s/<slug>`).
|
||||
# ★ API 와 워커가 **같은 값**을 봐야 한다. API 가 화면에 보여준 주소와 워커가 구운
|
||||
# canonical·사이트맵이 갈리면, 사장님 화면은 멀쩡한데 검색엔진만 엉뚱한 주소를 받는다.
|
||||
# ★ 프론트(VITE_PUBLISH_HOST)와 같은 값이어야 한다. canonical·og:url·sitemap 이 전부 이걸 쓴다.
|
||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
# ── 발행 산출물 ──
|
||||
# 빌드 잡이 발행마다 payload JSON 을 여기 떨어뜨린다. 호스트의 frontend/site/payloads 로
|
||||
# 바인드돼 있어서, o2o-web4ai-web 의 감시 프로세스가 **바뀐 payload 만** 골라 굽는다 —
|
||||
# docker cp 로 옮기는 수동 단계가 없다.
|
||||
#
|
||||
# ★ 이 디렉토리는 양방향이다. 프리렌더가 사이트마다 결과를
|
||||
# `.status/<slug>.json` 에 써 주고, 백엔드가 그걸 읽어 렌더 상태를 사이트 조회 API 로
|
||||
# 내보낸다(services/render_report). 백엔드에 마운트된 유일한 디렉토리라 여기를 쓴다 —
|
||||
# 이게 없으면 프리렌더가 깨져도 DB 는 "발행됨"이라 답하고 아무도 모른다.
|
||||
SITE_PAYLOAD_DIR: /app/out/payloads
|
||||
SITE_OUTPUT_DIR: /app/out/sites
|
||||
# ── 색인 통보 ──
|
||||
# 발행 즉시 네이버·Bing 에 알린다(구글은 IndexNow 를 지원하지 않는다 — Search Console 사이트맵 제출이 따로다).
|
||||
# ★ 프리렌더(o2o-web4ai-web)와 백엔드가 **같은 값**을 봐야 한다. 프리렌더는 이 키로 루트에
|
||||
# `<key>.txt` 를 굽고, 검색엔진은 통보를 받으면 그 파일을 열어 대조한다. 어긋나면 403 이다.
|
||||
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
|
||||
services:
|
||||
# ── API 서버 ────────────────────────────────────────────────────────────
|
||||
# 요청 접수/조회만 한다. 수집·비전분석·빌드는 잡으로 큐에 넣고 즉시 응답하며,
|
||||
# 클라이언트는 GET /v1/job/{id} 를 폴링한다.
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./solution/backend
|
||||
dockerfile: Dockerfile
|
||||
image: o2o-web4ai-backend # 워커와 공유하는 이미지 태그(한 번만 빌드된다)
|
||||
image: o2o-web4ai-backend
|
||||
container_name: o2o-web4ai-api
|
||||
command: ["python", "web_main.py"]
|
||||
env_file:
|
||||
- .env # 외부 API 키. 없으면 cp .env.example .env
|
||||
- .env
|
||||
environment:
|
||||
<<: *common-env
|
||||
# ★ APScheduler 크론(지역정보 갱신 등)은 **이 컨테이너에서만** 돈다.
|
||||
# 크론은 '시각'으로 발화하므로 프로세스가 여럿이면 같은 시각에 중복 실행된다 —
|
||||
# 그래서 단일 컨테이너로 고정한다. 아래 워커의 잡 큐와는 성격이 다르다:
|
||||
# 잡 큐는 DB 가 원자적으로 한 명에게만 배분(FOR UPDATE SKIP LOCKED)하므로 몇 개를 띄워도 안전하다.
|
||||
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
||||
SCHEDULER_ENABLED: "1"
|
||||
volumes:
|
||||
# ★ 컨테이너 안에만 두면 컨테이너를 지울 때 발행 산출물이 같이 사라진다.
|
||||
# SSG 를 돌리는 쪽(호스트의 frontend/site)이 직접 읽어야 하므로 밖으로 뺀다.
|
||||
- ./frontend/site/payloads:/app/out/payloads
|
||||
- ./solution/site/payloads:/app/out/payloads
|
||||
ports:
|
||||
- "${API_BIND:-0.0.0.0}:9800:9800" # prod 는 API_BIND=127.0.0.1 로 내부만 개방(리버스프록시 뒤)
|
||||
- "${API_BIND:-0.0.0.0}:9800:9800"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@ -100,132 +49,106 @@ services:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# ── 작업 큐 워커 ────────────────────────────────────────────────────────
|
||||
# 수집 파이프라인 · 사진 비전 분석 · 사이트 빌드를 처리한다. 한 건에 3~10분 걸리므로
|
||||
# API 요청 안에서 처리하지 않는다.
|
||||
#
|
||||
# ★ 스케일 안전: docker compose up -d --scale o2o-web4ai-worker=3
|
||||
# 큐가 `UPDATE ... WHERE job_id = (SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING` 단일 문장으로
|
||||
# 할당하므로 워커가 몇 개든 같은 잡이 두 번 돌지 않는다. 컨테이너가 죽어도 lease(JOB_LEASE_SEC)가
|
||||
# 만료되면 reaper 가 회수해 재큐한다 — 잡이 증발하지 않는다.
|
||||
# (스케일하려면 container_name 을 두면 안 된다 — 이름이 충돌한다. 그래서 여기엔 없다.
|
||||
# 프로젝트 이름이 붙어 o2o-web4ai-worker-1 로 뜬다.)
|
||||
worker:
|
||||
build:
|
||||
context: ./backend
|
||||
context: ./solution/backend
|
||||
dockerfile: Dockerfile
|
||||
image: o2o-web4ai-backend # api 와 동일 이미지 — 두 번 빌드되지 않는다
|
||||
image: o2o-web4ai-backend
|
||||
command: ["python", "worker_main.py"]
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
<<: *common-env
|
||||
SCHEDULER_ENABLED: "0" # 크론은 API 컨테이너 담당. 워커는 잡 큐만 돈다
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1} # 한 프로세스가 동시에 처리할 잡 수
|
||||
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900} # 잡 1건 처리 상한. Perplexity(10~30s)+크롤링+Vision(사진 20~50장) 감안
|
||||
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120} # 소유권 임대. heartbeat 가 1/3 주기로 갱신, 워커 사망 시 이만큼 뒤 회수
|
||||
# ★ 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어서 그대로 두면
|
||||
# 멀쩡히 잡을 돌면서도 계속 unhealthy 로 뜬다 — 진짜 고장과 구분이 안 되므로 끈다.
|
||||
# (Dockerfile 주석의 "워커는 이 헬스체크를 쓰지 않는다"를 compose 에서 실제로 반영하는 자리.)
|
||||
# 워커용 하트비트 기반 체크는 필요해질 때 추가한다.
|
||||
SCHEDULER_ENABLED: "0"
|
||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
||||
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
||||
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
||||
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
||||
healthcheck:
|
||||
disable: true
|
||||
volumes:
|
||||
# ★ 컨테이너 안에만 두면 컨테이너를 지울 때 발행 산출물이 같이 사라진다.
|
||||
# SSG 를 돌리는 쪽(호스트의 frontend/site)이 직접 읽어야 하므로 밖으로 뺀다.
|
||||
- ./frontend/site/payloads:/app/out/payloads
|
||||
# 발행 검수 통과 후 워커가 이 산출물을 Azure Blob에 업로드한다.
|
||||
- ./solution/site/payloads:/app/out/payloads
|
||||
- site-out:/app/out/sites:ro
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
# graceful 종료: SIGTERM → 새 잡 claim 중단 → 하던 잡 마무리 → 종료.
|
||||
# 이 시간을 넘기면 Docker 가 SIGKILL 하지만, 그래도 안전하다 — lease 가 만료되면
|
||||
# reaper 가 그 잡을 재큐한다(작업이 유실되지 않고 다음 워커가 이어받는다).
|
||||
# 값은 '배포 속도 vs 하던 일 마무리' 트레이드오프다. JOB_DEADLINE_SEC(900)까지 올리면
|
||||
# 어떤 잡이든 끝까지 기다리지만 docker compose down 이 최대 15분 매달린다.
|
||||
stop_grace_period: 300s
|
||||
depends_on:
|
||||
- api # 이미지 빌드/기동 순서만 맞춘다(런타임 의존은 DB 뿐)
|
||||
- api
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# ── 발행 사이트 빌더 + 정적 서버 ────────────────────────────────────────
|
||||
# 발행 잡이 payload JSON 을 떨어뜨리면(SITE_PAYLOAD_DIR) 이 컨테이너가 그걸 보고
|
||||
# 정적 HTML 을 굽고, 같은 결과물을 그대로 서빙한다.
|
||||
#
|
||||
# ★ 왜 백엔드가 직접 굽지 않나: 굽는 데 Node 와 프론트 의존성이 필요하다. 파이썬 이미지에
|
||||
# 그걸 넣으면 백엔드가 프론트 빌드 도구를 떠안는다. payload 디렉토리를 사이에 두고
|
||||
# 백엔드는 파일만 쓰고, 여기는 파일만 읽는다 — 두 쪽이 서로를 모른다.
|
||||
# 사장님 앱(:3000) + 발행 사이트 프리렌더·정적서버(:3001, `/s/*` 프록시)
|
||||
web:
|
||||
image: node:24-alpine
|
||||
container_name: o2o-web4ai-web
|
||||
working_dir: /app/frontend/site
|
||||
# 의존성이 없으면 설치부터 한다(최초 1회). 그 뒤 감시와 서버를 동시에 띄운다.
|
||||
# ★ `&` 는 그 앞의 명령 전체를 배경으로 돌린다 — 앞에 붙인 `cd` 까지 함께 묶여 나가서
|
||||
# 뒤 명령이 엉뚱한 디렉토리에서 돈다(실측: /app/frontend 에서 스크립트를 찾다 실패).
|
||||
# 그래서 cd 를 먼저 끝내고, 배경으로 보내는 것은 서버 하나뿐이다.
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
cd /app/frontend
|
||||
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 **빈 디렉토리로 이미 존재**해서
|
||||
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다. 실행 파일이 있는지를 본다.
|
||||
cd /app
|
||||
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
|
||||
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
|
||||
[ -x node_modules/.bin/vite ] || npm install
|
||||
# 외부 진입점은 admin Vite(:3000) 하나다. `/s/*`만 같은 컨테이너의
|
||||
# 정적 서버(:3001)로 프록시하고, payload 감시는 백그라운드에서 계속 돈다.
|
||||
node site/scripts/serve-sites.mjs &
|
||||
# 감시 프로세스가 기동 때 번들을 한 번 만들고, 그 뒤로는 바뀐 payload 만 굽는다.
|
||||
# (예전에는 발행 한 건마다 vite 번들 + 전체 사이트를 다시 구웠다 — 사이트가 늘면 못 쓴다.)
|
||||
node site/scripts/watch-payloads.mjs &
|
||||
exec npm run dev -w admin
|
||||
node solution/site/scripts/serve-sites.mjs &
|
||||
node solution/site/scripts/watch-payloads.mjs &
|
||||
exec npm run dev -w @o2o/front
|
||||
environment:
|
||||
PORT: 3001
|
||||
# 프리렌더가 루트에 `<key>.txt` 를 굽는다. 백엔드와 같은 값이어야 한다.
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
|
||||
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-w4ai.o2o.kr}
|
||||
volumes:
|
||||
# 소스와 산출물을 통째로 마운트한다 — 발행 payload 가 호스트에 그대로 보여야
|
||||
# 개발 중 무슨 일이 일어났는지 파일로 확인할 수 있다.
|
||||
- ./frontend:/app/frontend
|
||||
# ★ node_modules 만은 컨테이너 것을 쓴다(익명 볼륨으로 마운트를 덮는다).
|
||||
# 호스트가 macOS(arm64-darwin)라 그 안의 rollup·esbuild 네이티브 바이너리는
|
||||
# 리눅스 컨테이너에서 못 쓴다 — 실측: `Cannot find module '@rollup/rollup-linux-arm64-musl'`
|
||||
# 로 프리렌더가 통째로 실패했고, 발행해도 사이트가 안 구워졌다.
|
||||
- /app/frontend/node_modules
|
||||
- /app/frontend/site/node_modules
|
||||
- /app/frontend/admin/node_modules
|
||||
# ★ 산출물만 named volume 으로 뺀다(./frontend 마운트 위에 덮인다).
|
||||
# 호스트 경로에 두면 재배포로 코드를 갈아엎는 순간 out/ 이 비어 전 사이트가 404 다.
|
||||
# nginx 가 같은 볼륨을 읽는다.
|
||||
- site-out:/app/frontend/site/out
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
- ./tsconfig.base.json:/app/tsconfig.base.json
|
||||
- ./solution:/app/solution
|
||||
- ./admin:/app/admin
|
||||
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
|
||||
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
|
||||
- /app/node_modules
|
||||
- /app/solution/site/node_modules
|
||||
- /app/solution/front/node_modules
|
||||
- /app/admin/node_modules
|
||||
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
|
||||
- site-out:/app/solution/site/out
|
||||
ports:
|
||||
# admin(:3000)이 공개 진입점. `/s/*`는 Vite proxy가 내부 :3001로 전달한다.
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# ── 발행 사이트 정적 서빙 ───────────────────────────────────────────────
|
||||
#
|
||||
# ★ 굽는 쪽(o2o-web4ai-web)과 서빙하는 쪽을 분리한다. 프리렌더는 파일만 쓰고 여기는 파일만
|
||||
# 읽는다 — 볼륨 하나를 사이에 두고 서로를 모른다.
|
||||
#
|
||||
# ★ Azure Blob 으로 프록시하지 않는다. 파일이 이미 이 볼륨에 있는데 클라우드로 보냈다가
|
||||
# 되받아 오면 요청마다 왕복이 하나 더 붙고, Blob 은 커스텀 도메인 TLS 도 못 붙인다.
|
||||
# Blob 업로드(azure_static)는 배달 백업으로 남겨 둔다.
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: o2o-web4ai-nginx
|
||||
# 내부 운영 앱(:3002). 사장님 번들과 갈라 두는 것이 이 서비스의 존재 이유다.
|
||||
admin:
|
||||
image: node:24-alpine
|
||||
container_name: o2o-web4ai-admin
|
||||
working_dir: /app
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
cd /app
|
||||
[ -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}
|
||||
volumes:
|
||||
- site-out:/srv/sites:ro
|
||||
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./package.json:/app/package.json
|
||||
- ./package-lock.json:/app/package-lock.json
|
||||
- ./tsconfig.base.json:/app/tsconfig.base.json
|
||||
- ./solution:/app/solution
|
||||
- ./admin:/app/admin
|
||||
- /app/node_modules
|
||||
- /app/solution/site/node_modules
|
||||
- /app/solution/front/node_modules
|
||||
- /app/admin/node_modules
|
||||
ports:
|
||||
- "80:80"
|
||||
# TLS 를 붙이면 여기에 443 과 인증서 볼륨을 추가한다(certbot 또는 발급받은 인증서).
|
||||
# - "443:443"
|
||||
# ★ 운영에서는 내부망에만 연다. 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
|
||||
- "${ADMIN_BIND:-127.0.0.1}:3002:3002"
|
||||
depends_on:
|
||||
- web
|
||||
restart: unless-stopped
|
||||
@ -233,24 +156,24 @@ services:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# ── (예정) 크롤링 전용 워커 ──────────────────────────────────────────────
|
||||
# Phase 1 은 collector 가 MockAdapter 만 등록하므로 브라우저가 필요 없다.
|
||||
# 크롤링 법무 검토(docs/DECISIONS.md 1-1)가 끝나 HeadlessAdapter 를 붙이면 여기에 서비스를 하나 더 만든다.
|
||||
# 선례는 o2o-negosium 의 lps-worker — 그대로 따라가면 된다:
|
||||
#
|
||||
# o2o-web4ai-crawler:
|
||||
# build: { context: ./backend, dockerfile: Dockerfile.worker } # Chrome + Xvfb + 한글폰트 (~1.5GB)
|
||||
# platform: linux/amd64 # google-chrome-stable(Linux)은 amd64 전용. arm64 맥에선 명시 없으면 빌드 실패
|
||||
# shm_size: "1gb" # Chrome 는 /dev/shm 을 많이 씀 — 부족하면 탭 크래시
|
||||
# environment: { DISPLAY: ":99" }
|
||||
#
|
||||
# API 이미지(~200MB)에 Chrome 을 넣으면 배포마다 1.5GB 를 밀게 되므로 반드시 이미지를 갈라야 한다.
|
||||
# 발행 사이트 정적 서빙. 굽는 쪽(web)과 분리 — 볼륨 하나를 사이에 두고 서로를 모른다.
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: o2o-web4ai-nginx
|
||||
volumes:
|
||||
- site-out:/srv/sites:ro
|
||||
# ★ 클론 직후: cp nginx/site.conf.example nginx/site.conf
|
||||
# 파일이 없으면 Docker 가 그 자리에 디렉토리를 만들어 nginx 가 설정 없이 뜬다.
|
||||
- ./nginx/site.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- web
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: json-file
|
||||
options: { max-size: "10m", max-file: "5" }
|
||||
|
||||
# ── 볼륨 ──────────────────────────────────────────────────────────────────
|
||||
volumes:
|
||||
# 발행 산출물. 프리렌더가 쓰고, 워커(색인 통보·Azure 업로드)와 nginx 가 읽는다.
|
||||
#
|
||||
# ★ `docker compose down` 으로는 지워지지 않는다. `down -v` 만 지운다.
|
||||
# ★ 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다(DEPLOY.md 1절).
|
||||
# 지켜야 할 것은 DB 와 out/payloads 뿐이고, 그 둘은 여전히 호스트에 bind mount 다.
|
||||
# ★ `down -v` 만 지운다. 재생성물이라 백업 대상이 아니다 — 날아가도 payload 로 다시 굽는다.
|
||||
site-out:
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
# API 사용 이력 · 비용 기록
|
||||
|
||||
**집계 시각: 2026-08-27 08:59 KST**
|
||||
집계 대상: o2o-web4ai 프로젝트 전체 (backend · frontend/admin · frontend/site)
|
||||
집계 대상: o2o-web4ai 프로젝트 전체 (backend · solution/front · solution/site)
|
||||
|
||||
---
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
|---|---|---|---|---|---|---|
|
||||
| `4db7aca5` | 메인 (backend 주력) | 08-26 16:23 ~ 08-27 08:55 | 157 | 266,385 | 35,748,750 | **$32.65** |
|
||||
| `99be2a6c` | 메인 | 08-26 17:24 ~ 08-27 08:58 | 92 | 124,641 | 16,660,246 | **$19.79** |
|
||||
| `19be092e` | 메인 (frontend/admin) | 08-27 08:42 ~ 08:58 | 29 | 25,903 | 2,476,298 | $3.37 |
|
||||
| `19be092e` | 메인 (solution/front) | 08-27 08:42 ~ 08:58 | 29 | 25,903 | 2,476,298 | $3.37 |
|
||||
| `agent-a58…` | 서브에이전트 | 08-27 08:51 ~ 08:57 | 13 | 6,173 | 5,741,359 | $3.21 |
|
||||
| `agent-afa…` | 서브에이전트 | 08-27 08:52 ~ 08:55 | 14 | 423 | 6,132,381 | $3.21 |
|
||||
| `6052d29a` | 메인 (이 기록 작성 세션) | 08-27 08:52 ~ 08:58 | 21 | 23,936 | 1,678,581 | $2.92 |
|
||||
|
||||
@ -19,7 +19,7 @@ backend (Python) ──쓴다──▶ out/payloads/<slug>.json ◀──읽
|
||||
**둘을 직접 붙이자는 제안은 이 문서를 근거로 거절한다** — 붙이는 순간 파이썬 프로세스가
|
||||
React 를 렌더해야 하고, 그때부터 디자인 수정에 백엔드 배포가 필요해진다.
|
||||
|
||||
계약의 타입은 `frontend/shared/src/types/site-payload.ts` 하나다.
|
||||
계약의 타입은 `solution/shared/src/types/site-payload.ts` 하나다.
|
||||
|
||||
## 2. 발행 파이프라인
|
||||
|
||||
@ -87,88 +87,71 @@ nginx 는 `try_files $uri $uri/index.html =404` 로 같은 규칙을 맞춘다
|
||||
**전 사이트가 동시에 내려간다.** 지금은 테스트 단계라 감수하는 리스크이고,
|
||||
**실사용 고객이 붙는 시점**이 위 표의 오른쪽으로 넘어가는 트리거다.
|
||||
|
||||
## 4. 앱 경계 — 지금 구조와 나눌 지점
|
||||
## 4. 앱 경계 — 두 앱, 한 백엔드
|
||||
|
||||
### 지금
|
||||
**2026-08-31 실행 완료.** 아래는 "나눌 계획"이 아니라 지금 구조다.
|
||||
|
||||
```
|
||||
frontend/
|
||||
admin/ Vite CSR SPA ── /builder 사장님 위저드·에디터 (로그인 안 걸림)
|
||||
│ └ /places, /places/:id/seo, /local-content
|
||||
│ 내부 운영 화면 (RequireAuth)
|
||||
site/ SSR 엔트리 + 프리렌더 → 정적 HTML 발행 사이트
|
||||
shared/ 타입·slug·디자인 토큰 (npm workspace)
|
||||
```
|
||||
|
||||
**`admin/` 이 성격이 반대인 두 앱을 겸하고 있다.** ([PRODUCT.md 4절](PRODUCT.md) 표)
|
||||
|
||||
크기는 이렇게 갈린다 (손으로 쓴 코드 152개 기준 — `api/generated` 296개는 orval 산출물이라 뺐다):
|
||||
|
||||
| | 규모 |
|
||||
|---|---|
|
||||
| 사장님 — `features/builder`(79) `onboarding`(21) `publish`(10) | **110 파일** |
|
||||
| 내부 운영 — `pages/` 4장 | **814 줄** |
|
||||
|
||||
즉 `admin/` 은 두 앱을 반씩 겸하는 게 아니라, **사실상 사장님 앱인데 내부 화면 4장이 얹혀 있다.**
|
||||
|
||||
### 나눠야 하는 이유 — 취향이 아니라 셋 다 실제 문제다
|
||||
|
||||
1. **내부 기능이 사장님 번들에 실려 나간다.** 한 앱이면 `/local-content`, `/places/:id/seo`
|
||||
같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다.
|
||||
`UserRole.DEVELOPER` 는 코드 주석에 **"고객사에 존재를 노출하지 않는다"** 고 적혀 있는데,
|
||||
번들이 그 약속을 깨고 있다. 라우트 가드는 화면을 가리지 **번들은 못 가린다**.
|
||||
★ 이 문제는 **코드 크기와 무관하다.** 내부가 814줄뿐이어도 사장님 브라우저에 내려가는 건 같다.
|
||||
2. **인증 모델이 갈라진다.** 지금 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다")
|
||||
— `stores/auth.ts` 를 빌더 쪽 어느 파일도 import 하지 않는 것이 그 증거다.
|
||||
그런데 사장님에게 **"내 사이트 관리"** 가 붙는 순간 그쪽도 로그인 뒤로 들어간다.
|
||||
같은 로그인이 아니라 **role 이 다른 로그인**(OWNER vs DEVELOPER)이다.
|
||||
한 앱에서 두 정책을 유지하면 실수는 항상 **느슨한 쪽으로** 난다.
|
||||
3. **배포 리듬이 다르다.** 사장님 화면은 조심스럽게, 내부 화면은 매일 고쳐도 된다.
|
||||
한 번들이면 내부 화면 수정 때문에 사장님 화면을 재배포한다.
|
||||
|
||||
### 권고 구조 — o2o-negosium 과 같은 규약
|
||||
|
||||
**2026-08-31 결정.** 최상단은 **프로젝트 단위**로 평평하게 두고, 프로젝트 안에서 backend/front 를
|
||||
가른다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
||||
**사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.**
|
||||
|
||||
```
|
||||
o2o-web4ai/ ← 레포 하나. 쪼개지 않는다
|
||||
o2o-web4ai/
|
||||
├─ solution/ 사장님 — 사이트 만들기·관리
|
||||
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
|
||||
│ ├─ front/ builder · onboarding · publish
|
||||
│ └─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
|
||||
│ ├─ front/ 빌더 (위저드 + 에디터 + 발행 게이트)
|
||||
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
|
||||
│ └─ shared/ front·site·백엔드 계약 (SitePayload · slug · 토큰)
|
||||
│
|
||||
├─ admin/ 우리 — 전체 사이트 운영. **프론트 전용, 백엔드 없음**
|
||||
│ └─ src/ package.json vite.config.ts
|
||||
│
|
||||
├─ docs/ nginx/ postgres-init/ docker-compose.yml
|
||||
├─ docs/ nginx/ postgres-init/ docker-compose.yml package.json(워크스페이스 루트)
|
||||
```
|
||||
|
||||
**negosium 대응:** `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례이고,
|
||||
`lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다. `admin/` 이 후자다.
|
||||
최상단은 **프로젝트 단위**로 평평하다. `frontend/` `backend/` 를 최상단 묶음 폴더로 쓰지 않는다 —
|
||||
사내 다른 레포(`o2o-negosium`)가 이미 그 규약이고, 이 레포만 다르게 갈 이유가 없다.
|
||||
negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를 가르는 선례,
|
||||
`lps-admin/` 이 **백엔드 없이 프론트만 가진 최상단 폴더**의 선례다 — `admin/` 이 후자다.
|
||||
|
||||
### 왜 갈랐나 — 취향이 아니라 셋 다 실제 문제였다
|
||||
|
||||
1. **내부 기능이 사장님 번들에 실려 나갔다.** 한 앱이면 `/local-content`, `/places/:id/seo`
|
||||
같은 내부 라우트 이름과 화면 코드가 사장님 브라우저에 그대로 내려간다.
|
||||
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
|
||||
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
|
||||
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
|
||||
2. **인증 모델이 갈라진다.** 빌더는 일부러 로그인을 안 세운다("만들어 보기도 전에 막힌다").
|
||||
내부 화면은 전부 `RequireAuth` 뒤다. 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
|
||||
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
|
||||
내부: 실패가 곧 차단).
|
||||
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
|
||||
|
||||
### admin 에 백엔드를 두지 않은 이유
|
||||
|
||||
내부 화면이 부르는 것이 전부 지금 백엔드에 이미 있다 — `useGetPlace` `useListPlaces`
|
||||
`useListFacts` `useListLinks` `useGetSchema` `useConfirmLink` `useTransitionFact`
|
||||
→ `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고, 자체 백엔드를 두면
|
||||
`place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다.
|
||||
|
||||
**`admin/` 에 백엔드를 두지 않는 이유.** 내부 4장이 부르는 것이 전부 지금 백엔드에 이미 있다 —
|
||||
`useGetPlace` `useListPlaces` `useListFacts` `useListLinks` `useGetSchema` `useConfirmLink`
|
||||
`useTransitionFact` → `router/v1/{place, fact, local, validator}`. 새로 만들 게 없고,
|
||||
자체 백엔드를 두면 `place`·`fact`·`link` 도메인을 **같은 DB 에 대고 두 번** 구현하게 된다.
|
||||
→ 대가: `solution/backend` 가 죽으면 admin 도 멈춘다. **내부 도구라 감수한다.**
|
||||
|
||||
**`shared/` 는 없앤다.** 지금 `shared/` 가 지키던 진짜 결합은 슬러그 규칙과 `SitePayload` 이고,
|
||||
그 양쪽(`backend` ↔ `site`)이 **둘 다 `solution/` 안에 있다.** 결합이 프로젝트 하나 안에서 닫히므로
|
||||
공용 워크스페이스가 필요 없다. `admin/` 이 쓰는 것은 열거형 몇 개(`PlaceCategory` `PlaceStatus`
|
||||
`FactStatus` `isPublishableFact`)뿐이라 자기 것으로 갖는다.
|
||||
### 두 앱이 코드를 나눠 갖는 방식 — `@` 가 solution 을 가리킨다
|
||||
|
||||
**왜 레포를 안 쪼개나.** 슬러그 규칙이 `site_payload.publish_slug()` 와 `site` 쪽 `publishUrl()`
|
||||
**두 곳에 있고 같아야 한다.** `SitePayload`(252줄) 도 백엔드 출력과 프론트 입력이 짝이다.
|
||||
한 레포에서는 어긋나면 **타입 에러·테스트 실패**로 잡히고, 레포를 쪼개면 같은 실수가
|
||||
**운영 404** 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다.
|
||||
지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다.
|
||||
`admin/vite.config.ts` 와 `tsconfig.json` 에서 **`@` 는 `solution/front/src`** 다.
|
||||
admin 자기 파일만 `@admin` 이다.
|
||||
|
||||
**백엔드는 쪼개지 않는다.** 마이크로서비스로 가르자는 제안은 지금 근거가 없다 —
|
||||
팀 규모·트래픽 어느 쪽도 그 비용을 정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로
|
||||
갈려 있어 **나중에 진짜 나눠야 할 때 그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
|
||||
지금 할 일은 **라우터 표면을 청중별로 가르는 것**뿐이다:
|
||||
왜 복제하지 않았나: 내부 화면이 쓰는 API 클라이언트·UI 프리미티브·수집 배선이 solution 에
|
||||
한 벌만 있고, **그 파일들끼리도 `@/...` 로 서로를 부른다.** admin 에서 `@` 를 자기 src 로
|
||||
잡으면 그 내부 참조가 전부 깨진다(실측: `TS2307` 14건). 그리고 수집 배선은
|
||||
`RecollectPanel` 주석이 복제를 명시적으로 금지한다 — *"수집 경로를 두 벌 만들면 확정 게이트"* 가
|
||||
갈라진다.
|
||||
|
||||
★ **의존 방향은 `admin` → `solution` 한 쪽뿐이다.** 이 방향이라 사장님 번들에는 내부 코드가
|
||||
섞이지 않는다. 반대 방향이 하나라도 생기면 앱을 가른 의미가 사라진다.
|
||||
|
||||
### 백엔드는 쪼개지 않는다
|
||||
|
||||
마이크로서비스로 가르자는 제안은 지금 근거가 없다 — 팀 규모·트래픽 어느 쪽도 그 비용을
|
||||
정당화하지 못한다. 그리고 `router/v1/` 이 이미 도메인별로 갈려 있어 **나중에 진짜 나눠야 할 때
|
||||
그 선 따라 떨어진다** — 미룬다고 나중이 더 어려워지지 않는다.
|
||||
|
||||
남은 일은 **라우터 표면을 청중별로 가르는 것**뿐이다:
|
||||
|
||||
```
|
||||
/api/v1/... 사장님 (solution/front) — 자기 리소스만
|
||||
@ -176,32 +159,27 @@ o2o-web4ai/ ← 레포 하나. 쪼개지 않는다
|
||||
```
|
||||
|
||||
엔드포인트마다 `if role >= ...` 를 흩뿌리지 않고 **의존성 하나로 라우터에 건다.**
|
||||
지금 `common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니, 그걸 라우터
|
||||
의존성으로 올리면 된다.
|
||||
⚠️ 그 함수의 이름에서 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다.
|
||||
최상단 폴더 `admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다.
|
||||
`common/authz.py is_owner_or_admin()` 이 그 단일 출처 역할을 하고 있으니 라우터 의존성으로 올린다.
|
||||
|
||||
### 마이그레이션 — 되돌리기 쉬운 순서
|
||||
⚠️ 그 함수 이름의 **admin 은 `UserRole.OWNER`(고객사 최상위)** 를 뜻한다. 최상단 폴더
|
||||
`admin/`(우리 내부)과 **반대 뜻**이므로 읽을 때 헷갈리지 않는다. 폴더 이름을 admin 으로 정할 때
|
||||
알고 정한 충돌이다.
|
||||
|
||||
1. `solution/` 생성 → `backend/` 를 통째로 `git mv`. 경로만 바뀌고 내용은 그대로다.
|
||||
2. `frontend/site` → `solution/site`, `frontend/admin` → `solution/front`
|
||||
3. `frontend/shared` 해체 — 타입·slug·토큰을 `solution/` 안으로 흡수
|
||||
4. `solution/front` 에서 내부 4장(`PlaceList` `PlaceDetail` `SeoAudit` `LocalContent`)과
|
||||
`components/layout/AppShell`·`RequireAuth` 를 떼어 `admin/` 으로. 라우터에서도 제거
|
||||
5. `docker-compose.yml` 경로 전부 갱신 + admin Vite 서비스 추가.
|
||||
진입점 `exec npm run dev -w admin` 이 `solution/front` 를 가리키도록 바꾼다
|
||||
6. 백엔드 `/api/v1/admin/*` 라우터 분리 + role 의존성
|
||||
### 왜 레포는 안 쪼개나
|
||||
|
||||
★ **4번의 유일한 얽힘**: `PlaceDetailPage.tsx:18` 이 `@/features/onboarding` 의
|
||||
`PasteFactsPanel`·`RecollectPanel` 을 쓴다. 내부 페이지가 사장님 쪽 feature 를 참조하는
|
||||
**단 하나의 지점**이고, 이 둘만 복제하거나 옮기면 4장은 그냥 떨어진다.
|
||||
슬러그 규칙이 `site_payload.publish_slug()` 와 `solution/shared/src/lib/slug.ts`
|
||||
**두 곳에 있고 같아야 한다.** `SitePayload`(252줄) 도 백엔드 출력과 프론트 입력이 짝이다.
|
||||
한 레포에서는 어긋나면 **타입 에러·테스트 실패**로 잡히고, 레포를 쪼개면 같은 실수가
|
||||
**운영 404** 로 나타난다 — 배포 시점이 달라 언제 깨졌는지도 모른다.
|
||||
지금 만드는 건 세 개의 제품이 아니라 **한 파이프라인의 세 창구**다.
|
||||
|
||||
★ **먼저 정리할 것**: 메인 체크아웃에 미커밋으로 남은 `frontend/admin/src/stores/builder.ts`·
|
||||
`orval.config.ts` 가 이 마이그레이션이 옮길 파일이다. 커밋하든 버리든 **먼저 비우고** 시작한다.
|
||||
### 아직 안 한 것
|
||||
|
||||
**아직 실행하지 않았다.** 지금 구조로도 동작하고, 위 3가지 문제는 실사용 고객이 붙기 전까지는
|
||||
터지지 않는다. 다만 **사장님에게 계정을 열어주기 전에는 반드시 끝내야 한다** — 1번(번들 노출)이
|
||||
그때부터 실제 유출이 되기 때문이다.
|
||||
- `/api/v1/admin/*` 라우터 분리 + role 의존성 (위)
|
||||
- 사장님 **"내 사이트 관리"** 화면. 이게 붙으면 빌더도 로그인 뒤로 들어간다 —
|
||||
그때 `solution/front` 의 인증 정책을 다시 본다.
|
||||
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
|
||||
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
|
||||
|
||||
## 5. 산출물 — 사이트 하나 = 한 장
|
||||
|
||||
|
||||
@ -60,8 +60,8 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
- `backend/services/external/perplexity.py`
|
||||
- `backend/services/prompts/channel_discovery.py`
|
||||
- `frontend/admin/src/features/onboarding/useCollectFlow.ts`
|
||||
- `frontend/admin/src/features/onboarding/ChannelConfirmPanel.tsx`
|
||||
- `solution/front/src/features/onboarding/useCollectFlow.ts`
|
||||
- `solution/front/src/features/onboarding/ChannelConfirmPanel.tsx`
|
||||
|
||||
## 4. 크롤링
|
||||
|
||||
@ -170,7 +170,7 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
- `backend/services/snapshot.py`
|
||||
- `backend/services/site_payload.py`
|
||||
- `backend/services/publish_gate.py`
|
||||
- `frontend/site/`
|
||||
- `solution/site/`
|
||||
|
||||
## 8. SEO 구현
|
||||
|
||||
@ -190,10 +190,10 @@ Perplexity 응답의 영업시간, 가격 같은 본문 정보는 사용하지
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `frontend/site/src/seo/head.ts`
|
||||
- `frontend/site/src/seo/jsonld.ts`
|
||||
- `frontend/site/src/seo/robots.ts`
|
||||
- `frontend/site/src/seo/sitemap.ts`
|
||||
- `solution/site/src/seo/head.ts`
|
||||
- `solution/site/src/seo/jsonld.ts`
|
||||
- `solution/site/src/seo/robots.ts`
|
||||
- `solution/site/src/seo/sitemap.ts`
|
||||
|
||||
## 9. AEO 구현
|
||||
|
||||
@ -212,9 +212,9 @@ JSON-LD 값은 화면에도 동일하게 존재해야 한다. 렌더러가 양
|
||||
|
||||
관련 파일:
|
||||
|
||||
- `frontend/site/src/seo/llms.ts`
|
||||
- `frontend/site/src/seo/verify.ts`
|
||||
- `frontend/site/src/sections/AnswerBlock.tsx`
|
||||
- `solution/site/src/seo/llms.ts`
|
||||
- `solution/site/src/seo/verify.ts`
|
||||
- `solution/site/src/sections/AnswerBlock.tsx`
|
||||
- `backend/services/publish_gate.py`
|
||||
|
||||
## 10. SEO·AEO 진단 점수
|
||||
@ -248,7 +248,7 @@ AEO 항목:
|
||||
|
||||
- `backend/services/seo_audit.py`
|
||||
- `backend/router/v1/site/site.py`
|
||||
- `frontend/admin/src/pages/SeoAuditPage.tsx`
|
||||
- `solution/front/src/pages/SeoAuditPage.tsx`
|
||||
|
||||
API:
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@
|
||||
| 레이어 구조 | 원본 그대로 — `router` → `service` → `crud`, 람다 DB 실행, `Req_*`/`Res_*` 프로토콜, `RemoveNoneResponse` | "기존 컨벤션을 그대로 따른다" |
|
||||
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
|
||||
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 |
|
||||
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql`(전체 DDL, 재실행 안전) + `postgres-init/alters/YYYY-MM-DD-<주제>.sql`(누적 ALTER) | 원본 방식 그대로 |
|
||||
| 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 |
|
||||
| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 |
|
||||
| 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 |
|
||||
| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 |
|
||||
@ -153,7 +153,7 @@
|
||||
| 승인 | 후보 → 노출값, 옛 값 EXPIRED | `PUBLISHED_REPLACED` |
|
||||
| 수정(사람 직접) | 즉시 노출값 교체 | `PUBLISHED_REPLACED` |
|
||||
|
||||
마이그레이션: `postgres-init/alters/2026-08-27-fact-candidate-model.sql`
|
||||
스키마: `postgres-init/init-data/init.sql` (`fact.facts` 활성 유니크 + 후보 상태)
|
||||
|
||||
### 5-2. 그 밖의 결정
|
||||
|
||||
|
||||
@ -74,7 +74,7 @@ site-out/
|
||||
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
|
||||
`backend/scripts/republish_all.py` 다.
|
||||
|
||||
**규칙: `frontend/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||
|
||||
```bash
|
||||
docker compose restart web # 기동하며 전체 재굽기
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
| 원칙 | 왜 | 어디에 박혀 있나 |
|
||||
|---|---|---|
|
||||
| **발행물은 정적 HTML** — 서버도 JS 실행도 없이 읽힌다 | 크롤러가 읽어야 존재하는 것이다 | `frontend/site` 프리렌더 |
|
||||
| **발행물은 정적 HTML** — 서버도 JS 실행도 없이 읽힌다 | 크롤러가 읽어야 존재하는 것이다 | `solution/site` 프리렌더 |
|
||||
| **확인된 값만 발행한다** | 틀린 정보를 1차 출처로 만들면 제품이 해를 끼친다 | `publish_gate.py` 규칙 1 |
|
||||
| **고유 콘텐츠 0건이면 발행 거부** | 같은 템플릿 대량 생성은 검색엔진의 스팸 판정 대상 | `publish_gate.py` 규칙 2 |
|
||||
| **JSON-LD 값 = 화면 값** | 어긋나면 구조화 데이터 조작이다. 색인에서 통째로 불신당한다 | `publish_gate.py` 규칙 3, 빌드 실패 |
|
||||
|
||||
14
frontend/.gitignore
vendored
14
frontend/.gitignore
vendored
@ -1,14 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.ssr-dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# 발행 산출물 — 빌드 잡이 매번 새로 굽는다. 커밋하지 않는다.
|
||||
out/
|
||||
|
||||
# 환경변수: 실제 값은 커밋하지 않는다(.env.example 만 커밋).
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
.DS_Store
|
||||
@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "o2o-web4ai-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"admin",
|
||||
"site"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev -w admin",
|
||||
"dev:admin": "npm run dev -w admin",
|
||||
"dev:site": "npm run dev -w site",
|
||||
"build": "npm run build -w admin && npm run build -w site",
|
||||
"build:admin": "npm run build -w admin",
|
||||
"build:site": "npm run build -w site",
|
||||
"prerender": "npm run prerender -w site",
|
||||
"lint": "npm run lint -w admin && npm run lint -w site",
|
||||
"orval": "npm run orval -w admin",
|
||||
"clean": "rm -rf admin/dist site/dist site/.ssr-dist node_modules/.vite"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
109
frontend/package-lock.json → package-lock.json
generated
109
frontend/package-lock.json → package-lock.json
generated
@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "o2o-web4ai-frontend",
|
||||
"name": "o2o-web4ai",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "o2o-web4ai-frontend",
|
||||
"name": "o2o-web4ai",
|
||||
"version": "0.0.0",
|
||||
"workspaces": [
|
||||
"shared",
|
||||
"admin",
|
||||
"site"
|
||||
"solution/shared",
|
||||
"solution/front",
|
||||
"solution/site",
|
||||
"admin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@ -20,26 +21,18 @@
|
||||
"name": "@o2o/admin",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@o2o/shared": "*",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router": "^7.17.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
@ -48,7 +41,6 @@
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"orval": "^7.3.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
@ -1461,12 +1453,16 @@
|
||||
"resolved": "admin",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@o2o/front": {
|
||||
"resolved": "solution/front",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@o2o/shared": {
|
||||
"resolved": "shared",
|
||||
"resolved": "solution/shared",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@o2o/site": {
|
||||
"resolved": "site",
|
||||
"resolved": "solution/site",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@orval/angular": {
|
||||
@ -8756,12 +8752,89 @@
|
||||
"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/front": {
|
||||
"name": "@o2o/front",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@o2o/shared": "*",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-hook-form": "^7.79.0",
|
||||
"react-router": "^7.17.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"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",
|
||||
"orval": "^7.3.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"typescript-eslint": "^8.45.0",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
},
|
||||
"solution/shared": {
|
||||
"name": "@o2o/shared",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
}
|
||||
},
|
||||
"solution/site": {
|
||||
"name": "@o2o/site",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
29
package.json
Normal file
29
package.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "o2o-web4ai",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
"solution/shared",
|
||||
"solution/front",
|
||||
"solution/site",
|
||||
"admin"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "npm run dev -w @o2o/front",
|
||||
"dev:front": "npm run dev -w @o2o/front",
|
||||
"dev:admin": "npm run dev -w @o2o/admin",
|
||||
"dev:site": "npm run dev -w @o2o/site",
|
||||
"build": "npm run build -w @o2o/front && npm run build -w @o2o/admin && npm run build -w @o2o/site",
|
||||
"build:front": "npm run build -w @o2o/front",
|
||||
"build:admin": "npm run build -w @o2o/admin",
|
||||
"build:site": "npm run build -w @o2o/site",
|
||||
"prerender": "npm run prerender -w @o2o/site",
|
||||
"lint": "npm run lint -w @o2o/front && npm run lint -w @o2o/admin && npm run lint -w @o2o/site",
|
||||
"orval": "npm run orval -w @o2o/front",
|
||||
"clean": "rm -rf solution/front/dist admin/dist solution/site/dist solution/site/.ssr-dist node_modules/.vite"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
# o2o-web4ai frontend
|
||||
# solution — 사장님 앱 (backend · front · site)
|
||||
|
||||
소상공인 홈페이지 자동 생성 솔루션의 프론트엔드.
|
||||
`o2o-negosium/negodata/front` 보일러플레이트를 이식했다 — 빌드 도구·구조·프로토콜 규약은 원본과 같다.
|
||||
@ -6,7 +6,7 @@
|
||||
**목표는 예쁜 사이트가 아니라 AI 검색이 이 가게를 공식 홈페이지 기준으로 설명하게 만드는 것**이다
|
||||
(백엔드 README 와 같은 문장). 그래서 두 앱의 요구사항이 정반대다.
|
||||
|
||||
| | `admin/` (빌더) | `site/` (발행 사이트) |
|
||||
| | `front/` (빌더) | `site/` (발행 사이트) |
|
||||
|---|---|---|
|
||||
| 사용자 | 사장님 · 운영자 | 손님 · **검색/AI 크롤러** |
|
||||
| 렌더링 | CSR SPA | **SSG (정적 HTML)** |
|
||||
@ -14,14 +14,17 @@
|
||||
| 런타임 | Vite dev / 정적 호스팅 | **서버 없음.** 파일만 |
|
||||
| 데이터 | 편집 중 상태(미확인 값 포함) | `SitePayload` — **확인된 값만** |
|
||||
|
||||
한 레포에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
||||
한 프로젝트에 두 앱을 두되 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
||||
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── shared/ 두 앱이 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터
|
||||
├── admin/ 빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트)
|
||||
solution/
|
||||
├── backend/ FastAPI + 워커. payload JSON 만 떨어뜨린다
|
||||
├── shared/ front·site 가 공유 — 도메인 enum · SitePayload 계약 · 디자인 토큰 · fact 필터
|
||||
├── front/ 빌더 (위저드 4단계 + 실시간 에디터 + 발행 게이트)
|
||||
└── site/ 발행 사이트 렌더러 + 프리렌더 스크립트
|
||||
|
||||
내부 운영 화면은 여기 없다 — 최상단 `admin/` 이다(ARCHITECTURE.md 4절).
|
||||
```
|
||||
|
||||
## 실행
|
||||
@ -34,8 +37,8 @@ docker compose up -d
|
||||
|
||||
- 발행 사이트: `http://localhost:3000/s/<slug>`
|
||||
- API/Swagger: `http://localhost:9800/docs`
|
||||
- 정적 사이트 저장 위치: `frontend/site/out/s/<slug>/`
|
||||
- 프리렌더 입력 payload: `frontend/site/payloads/<slug>.json`
|
||||
- 정적 사이트 저장 위치: `solution/site/out/s/<slug>/`
|
||||
- 프리렌더 입력 payload: `solution/site/payloads/<slug>.json`
|
||||
- 컨테이너 내부 정적 서버는 3001을 사용하지만, Docker가 호스트 3000으로만 공개한다.
|
||||
|
||||
예: `http://localhost:3000/s/grazz`
|
||||
@ -118,7 +121,7 @@ API 클라이언트는 **전부 orval 생성물**이다(React Query 훅). 화면
|
||||
npm run orval -w admin # 백엔드가 떠 있을 때
|
||||
|
||||
cd ../backend && python scripts/export_openapi.py # 서버 없이 — 스펙을 먼저 뽑고
|
||||
cd ../frontend && ORVAL_INPUT=../../backend/openapi.json npm run orval -w admin
|
||||
cd .. && ORVAL_INPUT=solution/backend/openapi.json npm run orval
|
||||
```
|
||||
|
||||
열려 있는 도메인은 `auth` / `place` / `fact` / `job` / `site` 전부다.
|
||||
@ -24,7 +24,7 @@ COLLECT_USE_PERPLEXITY=0
|
||||
|
||||
# ── 발행 산출물 ────────────────────────────────────────────
|
||||
# BUILD 잡이 발행 payload JSON 을 떨어뜨릴 디렉토리. 없으면 만든다.
|
||||
# 이 JSON 하나가 정적 렌더러(frontend/site)의 유일한 입력이다 —
|
||||
# 이 JSON 하나가 정적 렌더러(solution/site)의 유일한 입력이다 —
|
||||
# npm run prerender -- --payload=<이 디렉토리 또는 그 안의 파일> → out/<slug>/index.html
|
||||
# 컨테이너 밖(볼륨·오브젝트 스토리지)으로 빼기 쉬우라고 코드가 아니라 env 로 둔다.
|
||||
# ★ 쓰기에 실패해도 발행은 진행된다(경고 로그만). 발행 기록은 DB 가 진실이다.
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
## 현재 상태
|
||||
|
||||
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`frontend/admin`)가 이 API 를 붙여 쓴다.
|
||||
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/front`)가 이 API 를 붙여 쓴다.
|
||||
|
||||
| 모듈 | 상태 | 역할 |
|
||||
|---|---|---|
|
||||
51
solution/backend/config/config.test.toml.example
Normal file
51
solution/backend/config/config.test.toml.example
Normal file
@ -0,0 +1,51 @@
|
||||
# 복사해서 사용: cp config.test.toml.example config.test.toml
|
||||
# 실제 config.test.toml 은 커밋하지 않는다(.gitignore: *.toml).
|
||||
#
|
||||
# ★ 이 파일이 없으면 `.venv/bin/pytest` 가 conftest import 단계에서 죽는다
|
||||
# (server_configs 가 config.<APP_ENV>.toml 을 읽는다). 그래서 템플릿을 둔다.
|
||||
# ★ DB 이름을 dev 와 절대 같게 두지 말 것 — 픽스처가 TRUNCATE 를 돌린다.
|
||||
# conftest 의 안전가드가 이름을 확인하지만, 여기서부터 갈라 두는 게 먼저다.
|
||||
[WebServerConfig]
|
||||
server_name = "O2oSiteServerTest"
|
||||
port = 9800
|
||||
process_count = 1
|
||||
is_ssl = false
|
||||
is_test = true
|
||||
client_url = "http://localhost:3000"
|
||||
landing_url = ""
|
||||
|
||||
[LogConfig]
|
||||
print_console = false
|
||||
log_level = "warning"
|
||||
|
||||
[MainDBConfig]
|
||||
db_type = "postgresql"
|
||||
name = "web4ai_test_db" # ★ dev(web4ai_db)와 다른 DB. 픽스처가 매번 지웠다 만든다
|
||||
write_host = "127.0.0.1"
|
||||
write_port = 5432
|
||||
write_id = "postgres"
|
||||
write_pw = "password"
|
||||
read_host = "127.0.0.1"
|
||||
read_port = 5432
|
||||
read_id = "postgres"
|
||||
read_pw = "password"
|
||||
show_log = false
|
||||
pool_size = 5
|
||||
max_overflow = 10
|
||||
sslmode = ""
|
||||
|
||||
[JwtToken]
|
||||
access_key = "test-access-key-not-a-secret"
|
||||
refresh_key = "test-refresh-key-not-a-secret"
|
||||
access_expire_min = 30
|
||||
refresh_expire_day = 7
|
||||
|
||||
# ★ 전부 빈값으로 둔다. APP_ENV=test 는 .env 를 읽지 않는데(실키가 테스트로 새는 경로 차단),
|
||||
# 여기에 실키를 적으면 그 차단을 우회해 외부 API 요금이 나간다.
|
||||
[ExternalApiConfig]
|
||||
perplexity_api_key = ""
|
||||
kakao_rest_api_key = ""
|
||||
naver_client_id = ""
|
||||
naver_client_secret = ""
|
||||
gemini_api_key = ""
|
||||
tour_api_key = ""
|
||||
@ -173,7 +173,7 @@ async def auth_headers(db_engine, client, company_id):
|
||||
# ── 렌더러 스텁 ──────────────────────────────────────────────────────────────
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
"""정적 렌더러(frontend/site) 대역.
|
||||
"""정적 렌더러(solution/site) 대역.
|
||||
|
||||
★ 왜 필요한가
|
||||
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
|
||||
@ -182,7 +182,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
|
||||
|
||||
★ 구조화 데이터 ↔ 화면 값 대조 자체는 렌더러 쪽 테스트가 본다
|
||||
(frontend/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 —
|
||||
(solution/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 —
|
||||
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
|
||||
"""
|
||||
import json
|
||||
@ -125,10 +125,10 @@ async def main():
|
||||
return
|
||||
|
||||
# ★ HTML 은 백엔드가 만들지 않는다. payload JSON 을 쓰는 것까지가 백엔드의 일이고,
|
||||
# 그걸 정적 페이지로 굽는 것은 frontend/site 의 렌더러다(그게 방문자가 보는 유일한 페이지).
|
||||
# 그걸 정적 페이지로 굽는 것은 solution/site 의 렌더러다(그게 방문자가 보는 유일한 페이지).
|
||||
print(f"\npayload: {r.get('payload_path')}")
|
||||
print(f"페이지: {r.get('routes')}개 · JSON-LD 노드 {len(r.get('mismatches') or []) == 0 and '검증 통과' or '불일치'}")
|
||||
print("정적 파일: frontend/site/out/s/<slug>/ (렌더러가 굽는다)")
|
||||
print("정적 파일: solution/site/out/s/<slug>/ (렌더러가 굽는다)")
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(main())
|
||||
@ -3,7 +3,7 @@
|
||||
python scripts/republish_all.py (backend/ 에서 실행)
|
||||
python scripts/republish_all.py --dry-run (올리지 않고 목록만 본다)
|
||||
|
||||
★ 왜 필요한가 — 렌더러(frontend/site)를 고쳐 배포하면 번들 파일명이 바뀐다
|
||||
★ 왜 필요한가 — 렌더러(solution/site)를 고쳐 배포하면 번들 파일명이 바뀐다
|
||||
(`assets/index-DvNTmLhy.css` → `assets/index-<새해시>.css`). 그런데 평소 업로드 경로
|
||||
(services/azure_static.publish)는 **방금 발행한 사이트 하나**만 올린다. 그래서
|
||||
나머지 사이트의 HTML 은 Blob 에 옛 해시를 가리킨 채로 남는다. 옛 자산 블롭은 지워지지
|
||||
@ -170,7 +170,7 @@ async def run_build(job: dict) -> dict:
|
||||
return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate)
|
||||
|
||||
# ---- 렌더러에 넘긴다 ----
|
||||
# ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(frontend/site)의 유일한 입력이
|
||||
# ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이
|
||||
# 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다.
|
||||
# ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데,
|
||||
# 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user