킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.
- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다
설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다
배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설
정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시
검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
223 lines
10 KiB
TypeScript
223 lines
10 KiB
TypeScript
import {useEffect, useRef} from 'react';
|
|
import {ArrowLeft, ExternalLink, Loader2, TriangleAlert} from 'lucide-react';
|
|
import {Link, useSearchParams} from 'react-router';
|
|
import {SiteStatus} from '@o2o/shared';
|
|
import {AppShell} from '@/components/layout/AppShell';
|
|
import {
|
|
Step1Industry,
|
|
Step2PlaceSearch,
|
|
Step3DataReview,
|
|
Step4Template,
|
|
Step5Generating,
|
|
} from '@/features/onboarding';
|
|
import {EditorLayout} from '@/features/builder';
|
|
import {usePlaceSync} from '@/hooks/usePlaceSync';
|
|
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
|
|
|
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
|
|
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
|
|
|
|
/**
|
|
* "발행본 사이트 열기" 가 향할 주소.
|
|
*
|
|
* ★ 예전엔 서버 루트(:3001)만 열었다. 사이트가 하나뿐이던 시절의 흔적인데, 지금은
|
|
* 여러 사이트가 `/s/<주소>` 아래 놓여서 루트를 열면 아무것도 안 나온다.
|
|
* 사장님이 정한 주소(sites.domain)가 있으면 그 사이트로 보낸다.
|
|
* ★ 도메인이 없으면 null 이다 — 예전엔 서버 루트로 떨어뜨렸는데, 그건 발행 전에도
|
|
* 버튼이 열려 있고 누르면 빈 화면이 뜬다는 뜻이었다. 열 곳이 없으면 열지 않는다.
|
|
*/
|
|
function siteUrl(domain: string | null | undefined): string | null {
|
|
return domain ? `${SITE_PREVIEW_URL}/s/${domain}` : null;
|
|
}
|
|
|
|
export function BuilderPage() {
|
|
/**
|
|
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
|
|
*
|
|
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
|
|
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
|
|
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
|
|
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
|
|
*/
|
|
// 개발 서버에서는 세션을 조용히 확보한다 — 위저드 앞에 로그인 화면을 세우지 않기 위해서다.
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const urlPlaceId = searchParams.get('placeId');
|
|
|
|
/**
|
|
* `?new=1` 로 들어오면 위저드를 1단계(업종 선택)부터 시작한다.
|
|
*
|
|
* ★ 저장된 상태를 그대로 두면 지난번 에디터가 복원된다 — 새 가게를 만들러 온 사람에게는
|
|
* 자기가 만든 적 없는 화면이 뜨는 셈이다. 비운 뒤에는 주소창에서 플래그를 지워,
|
|
* 새로고침할 때마다 작업하던 내용이 날아가지 않게 한다.
|
|
*/
|
|
const reset = useBuilderStore((s) => s.reset);
|
|
const isNew = searchParams.get('new') === '1';
|
|
useEffect(() => {
|
|
if (!isNew) return;
|
|
reset();
|
|
setSearchParams({}, {replace: true});
|
|
}, [isNew, reset, setSearchParams]);
|
|
/**
|
|
* 위저드 2단계에서 확정한 사업장. 주소창에 placeId 가 없어도 이걸로 배선한다.
|
|
*
|
|
* ★ 이게 없으면 위저드를 끝까지 걸어온 사장님이 에디터에서 **업종 예시값**을 본다 —
|
|
* 방금 27건을 확인해 놓고 '독채 3개 동' 같은 남의 가게 값이 뜬다. 실제로 그랬다.
|
|
* 딥링크(/builder?placeId=...)가 우선이다 — 사업장 목록에서 다른 가게를 열 수 있어야 한다.
|
|
*/
|
|
const wizardPlaceId = useBuilderStore((s) => s.confirmedIdentity?.placeId ?? null);
|
|
const placeId = urlPlaceId ?? wizardPlaceId;
|
|
/**
|
|
* 에디터로 바로 들어갈지.
|
|
*
|
|
* ★ **처음 들어온 순간**의 주소창만 본다. 위저드 2단계가 확정 뒤에 `?placeId=` 를 붙이는데
|
|
* (새로고침 복원용), 그걸 실시간으로 보면 "URL 붙여넣고 확인 → 곧장 에디터" 가 된다 —
|
|
* 수집 결과를 보여주는 3·4·5단계가 통째로 건너뛰어진다. 실제로 그렇게 됐다.
|
|
* 사업장 목록에서 딥링크로 들어온 경우(이미 만든 가게를 여는 것)만 에디터로 보낸다.
|
|
*/
|
|
const enteredWithPlace = useRef(
|
|
Boolean(urlPlaceId) && searchParams.get('flow') !== 'onboarding',
|
|
).current;
|
|
const sync = usePlaceSync(placeId, {enterEditor: enteredWithPlace});
|
|
|
|
const step = useBuilderStore((s) => s.step);
|
|
const storeName = useBuilderStore((s) => s.storeName);
|
|
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
|
|
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
|
|
const wiredPlaceId = useBuilderStore((s) => s.placeId);
|
|
|
|
/**
|
|
* 발행본이 실제로 존재하는가.
|
|
*
|
|
* ★ 주소(domain)만으로는 부족하다 — 주소는 발행 **전에** 예약된다(PublishModal 이
|
|
* 빌드보다 먼저 잡아 둔다). 주소만 보고 버튼을 열면 아직 굽지 않은 사이트로
|
|
* 보내 404 를 띄운다. 사이트 상태가 PUBLISHED 인 것까지 확인한다.
|
|
*/
|
|
const publishedUrl =
|
|
sync.site?.status === SiteStatus.PUBLISHED ? siteUrl(sync.site.domain) : null;
|
|
|
|
// 실사업장을 열었는데 아직 못 읽었다 — 이 동안 데모(달빛스테이)를 그리면
|
|
// 사장님은 남의 가게를 자기 가게로 오해한다. 차라리 아무것도 안 그린다.
|
|
if (placeId && sync.isLoading) {
|
|
return (
|
|
<BuilderNotice title="사업장을 불러오는 중입니다" description={placeId} isLoading />
|
|
);
|
|
}
|
|
|
|
if (placeId && (sync.isError || sync.isNotFound)) {
|
|
return (
|
|
<BuilderNotice
|
|
title={sync.isNotFound ? '사업장을 찾지 못했습니다' : '사업장을 불러오지 못했습니다'}
|
|
description={
|
|
sync.isNotFound
|
|
? '주소의 placeId 가 맞는지 확인해 주세요.'
|
|
: ((sync.error as Error)?.message ??
|
|
'로그인이 필요한 데이터입니다. 백엔드가 떠 있는지, 로그인돼 있는지 확인해 주세요.')
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (step === EDITOR_STEP) {
|
|
return (
|
|
<div className="relative flex h-screen w-screen flex-col overflow-hidden">
|
|
<div className="z-40 flex items-center justify-between gap-2 border-b border-border bg-foreground px-4 py-1.5 text-[11px] text-background">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="rounded bg-white/10 px-2 py-0.5 font-mono text-[10px] font-semibold">
|
|
AI-FOR-WEB BUILDER
|
|
</span>
|
|
{/* 지금 화면이 실제 사업장인지 시연용 데모인지 한눈에 구분되게 둔다. */}
|
|
{wiredPlaceId ? (
|
|
<>
|
|
<span className="truncate rounded bg-success/20 px-2 py-0.5 font-semibold text-success">
|
|
실사업장 · {storeName}
|
|
</span>
|
|
{/* ★ 예전엔 여기 [사업장 목록] 링크가 있었다. 그 화면은 내부 운영 앱(admin)으로
|
|
나갔고, 사장님 앱에는 그 경로가 없다 — 남겨두면 404 다. admin 은 빌더를
|
|
새 탭으로 열므로(admin/src/lib/solutionUrl.ts) 돌아가는 길은 탭 닫기다.
|
|
사장님용 "내 사이트 관리"가 생기면 그때 이 자리에 잇는다. */}
|
|
</>
|
|
) : (
|
|
<span className="truncate opacity-80">
|
|
편집한 내용은 [사이트 발행] 을 눌러야 실제 페이지로 구워집니다.
|
|
</span>
|
|
)}
|
|
</div>
|
|
{/* 캔버스는 미리보기다. 진짜 발행본은 별도 렌더러(site)가 굽는다 —
|
|
같은 화면을 두 번 구현하지 않고, 그쪽을 새 탭으로 연다.
|
|
★ 발행 전에는 열지 않는다 — 굽지 않은 주소를 열면 404 다. */}
|
|
{publishedUrl ? (
|
|
<a
|
|
href={publishedUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex shrink-0 items-center gap-1 rounded-md bg-warning px-2.5 py-1 font-bold text-black transition-all hover:opacity-90"
|
|
>
|
|
<ExternalLink className="size-3" />
|
|
<span>발행본 사이트 열기</span>
|
|
</a>
|
|
) : (
|
|
<span
|
|
title="아직 발행 전입니다 — [사이트 발행] 을 마치면 열립니다."
|
|
aria-disabled="true"
|
|
className="flex shrink-0 cursor-not-allowed items-center gap-1 rounded-md bg-white/10 px-2.5 py-1 font-bold text-background/50"
|
|
>
|
|
<ExternalLink className="size-3" />
|
|
<span>발행본 사이트 열기</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-h-0 flex-1">
|
|
<EditorLayout />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 위저드는 관리자 화면의 일부다 — 사이드바(로고·사업장·로그아웃)를 그대로 쓴다.
|
|
// 에디터(EDITOR_STEP)만 전체 화면이라 위에서 먼저 빠져나간다.
|
|
return (
|
|
<AppShell>
|
|
<div className="flex h-full min-h-full flex-col">
|
|
{step === 1 && <Step1Industry />}
|
|
{step === 2 && <Step2PlaceSearch />}
|
|
{step === 3 && <Step3DataReview />}
|
|
{step === 4 && <Step4Template />}
|
|
{step === 5 && <Step5Generating />}
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
/** 실사업장을 못 읽었을 때의 전체 화면. 데모로 돌아갈 길을 항상 같이 준다. */
|
|
function BuilderNotice({
|
|
title,
|
|
description,
|
|
isLoading,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
isLoading?: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex h-screen w-screen flex-col items-center justify-center gap-3 bg-muted px-6 text-center">
|
|
{isLoading ? (
|
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
|
) : (
|
|
<TriangleAlert className="size-6 text-warning" />
|
|
)}
|
|
<p className="text-sm font-bold">{title}</p>
|
|
<p className="max-w-md break-all text-xs text-muted-foreground">{description}</p>
|
|
{!isLoading && (
|
|
<Link
|
|
to="/builder"
|
|
className="mt-1 rounded-md border border-border bg-card px-3 py-1.5 text-xs font-medium transition-colors hover:bg-background"
|
|
>
|
|
데모로 열기
|
|
</Link>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|