구조: 사장님(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:
Mina Choi 2026-08-31 15:12:09 +09:00
parent 139d460839
commit 9d25ed613e
714 changed files with 782 additions and 440 deletions

14
.gitignore vendored
View File

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

View File

@ -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 과 화면 주소가 조용히 갈라진다.
## 커밋
- 한 커밋 = 한 가지 변경. 문서와 그 문서가 설명하는 코드는 같은 커밋에 둔다.

View File

@ -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
View File

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

21
admin/index.html Normal file
View 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
View 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
View 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>,
);

View 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
View 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 />},
]);

View 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`;
}

View File

@ -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
View 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
View 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},
},
});

View File

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

View File

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

View File

@ -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. 산출물 — 사이트 하나 = 한 장

View File

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

View File

@ -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. 그 밖의 결정

View File

@ -74,7 +74,7 @@ site-out/
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
`backend/scripts/republish_all.py` 다.
**규칙: `frontend/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
docker compose restart web # 기동하며 전체 재굽기

View File

@ -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
View File

@ -1,14 +0,0 @@
node_modules/
dist/
.ssr-dist/
*.tsbuildinfo
# 발행 산출물 — 빌드 잡이 매번 새로 굽는다. 커밋하지 않는다.
out/
# 환경변수: 실제 값은 커밋하지 않는다(.env.example 만 커밋).
.env
.env.*
!.env.example
.DS_Store

View File

@ -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"
}
}

View File

@ -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
View 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"
}
}

View File

@ -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` 전부다.

View File

@ -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 가 진실이다.

View File

@ -7,7 +7,7 @@
## 현재 상태
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`frontend/admin`)가 이 API 를 붙여 쓴다.
**수집 → 생성 → 빌드 → 발행 파이프라인이 API 로 다 열렸다.** 프론트(`solution/front`)가 이 API 를 붙여 쓴다.
| 모듈 | 상태 | 역할 |
|---|---|---|

View 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 = ""

View File

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

View File

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

View File

@ -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 해시를 가리킨 채로 남는다. 자산 블롭은 지워지지

View File

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