diff --git a/.gitignore b/.gitignore
index 8e202b1..2d394da 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,6 +26,9 @@ solution/backend/openapi.json
# 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다.
solution/site/out/
solution/site/dist/
+# React Router 프레임워크 모드(solution/frontend)의 산출물
+build/
+.react-router/
admin/dist/
# nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.
diff --git a/AGENTS.md b/AGENTS.md
index 5652113..b682a08 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,16 +25,39 @@
밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다.
+- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).**
+ 프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이
+ 아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고
+ 사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS·
+ 이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다.
+ → **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
+ (`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
+ → 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
+
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
-- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이
- 사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다.
- → 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다.
+- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다
+ `out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다.
+ 지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면
+ 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고
+ 파일 mtime 이 아니다.
+ → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다.
+- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로
+ 치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.**
+ 실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이
+ 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
+ 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
+ → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
+ → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
+- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
+ 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
+ `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
+ 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
- → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py`
+ → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py`
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
@@ -50,8 +73,15 @@
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
-- **디렉토리 요청 → `index.html`.** `/s/` 가 **끝 슬래시 없이** 열려야 한다.
- 정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다.
+- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
+ canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면
+ 구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데
+ 제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
+ → nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다.
+ 이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다**
+ — 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08).
+ → 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
+ Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
## 코드 규약
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2f0c996..96df61c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403
```
-OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
+OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
-같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
-회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
+같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고
+`places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
+(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절)
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md
index cd6d4a5..4de16a1 100644
--- a/docs/DECISIONS.md
+++ b/docs/DECISIONS.md
@@ -88,9 +88,9 @@
| 포트 | **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, 재실행 안전) | 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` 에 의존한다 |
+| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `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`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 |
+| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 |
| ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 |
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |
diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md
index 2490f3b..9c180f5 100644
--- a/docs/DEPLOY.md
+++ b/docs/DEPLOY.md
@@ -66,18 +66,28 @@ site-out/
```
프론트 수정 → 새 번들(새 해시)
- 로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404
- Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제
+ 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐
+ Azure : 새 해시 추가, 옛 해시 유지 ← 같다
```
-프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만
-하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게
-`solution/backend/scripts/republish_all.py` 다.
+**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가
+CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다.
+지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json`
+대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다).
+
+**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다.
+프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는
+사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다.
+
+⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다.
+옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는
+블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다.
+
**규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash
-docker compose restart solution-frontend # 기동하며 전체 재굽기
+docker compose restart solution-prerender # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py
```
@@ -215,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py
## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
-`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`.
+`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 9541b9e..14852b8 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -5,6 +5,227 @@
---
+## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
+
+**무슨 일**
+사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
+(`/s/`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
+목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고,
+`/s` 는 맨 아래 `location /` 로 떨어진다.
+
+**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
+(실측: `/s` 3.1KB `Web4Ai ` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
+목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
+
+**바꾼 것**
+- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는
+ 거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다
+- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다.
+ 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다
+- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를
+ 쓰므로 전부 같이 따라온다
+
+**왜 형태를 맞추나**
+색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준
+태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번
+밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다.
+
+**남은 것**
+`/s//` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서
+크롤러가 스스로 만들어낼 주소는 아니다.
+
+---
+
+## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다
+
+**무슨 일**
+목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`·
+`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게
+어느 건지 가릴 단서가 화면에 하나도 없다.
+
+**리서치** (Wix · 아임웹)
+- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.**
+ 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl ·
+ editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다.
+- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전),
+ 리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라
+ 사이트 안 대시보드에 있다.
+- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** —
+ 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다.
+
+**바꾼 것**
+- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미
+ 조인해 읽고 있어서 쿼리는 그대로다
+- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 —
+ 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다
+- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만
+ 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다
+
+**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`)
+블롭 이름은 `thumbs/.` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서
+주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다
+(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다.
+→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다.
+→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로
+ 그냥 붙이지 않는다.
+
+**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다.
+버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가
+False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다.
+
+**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에
+`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가.
+프론트 `tsc + eslint` 통과.
+
+---
+
+## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다
+
+**무슨 일**
+사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고,
+에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데.
+
+**왜 그랬나**
+보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이
+"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다.
+
+**바꾼 것**
+- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고
+ (JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다
+- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의
+ 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고,
+ 스코프는 회사가 대신 하고 있었다
+- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제
+ **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가
+ 스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다
+- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제
+- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는
+ `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다
+
+**마이그레이션** (`init.sql` 끝, 재실행 안전)
+백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게
+몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다.
+실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35.
+
+**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
+건드려야 해서 이번 변경에 섞지 않았다.
+
+---
+
+## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다
+
+**무슨 일**
+`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도
+살아나지 않았다.
+
+**왜**
+`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고,
+프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들
+해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가
+한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다.
+
+**고친 것** (`scripts/prerender.ts`)
+- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다
+- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** —
+ 기간으로 막으면 30일 뒤에 똑같은 사고가 난다
+- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다
+
+**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서
+서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`.
+
+**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고
+재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다.
+
+---
+
+## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다
+
+**무슨 일**
+바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다.
+옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
+
+**왜**
+`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이
+기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이
+전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다.
+
+**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라
+**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다.
+
+**고친 것** (`scripts/prerender.ts` `pruneAssets`)
+- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다
+- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록)
+
+**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를
+가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다.
+
+**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 →
+옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다.
+
+---
+
+## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다
+
+**왜**
+`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를
+파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부
+CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙**
+으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다.
+
+진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다.
+그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 —
+하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고
+(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다.
+
+**바꾼 것** (`scripts/prerender.ts`)
+- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가
+ **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다
+- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다
+- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지
+ 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다.
+ 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다
+- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다
+
+**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛
+디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서
+"안 하면 반영이 안 된다" 로 내려온다.
+
+**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼
+업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다.
+
+**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로
+다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의
+파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed.
+
+---
+
+## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다
+
+**왜**
+`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면
+번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은
+그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다.
+
+구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예
+무시한다**(Search Central: "the date and time of the last significant update" ·
+"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라,
+**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다
+신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다.
+
+**바꾼 것**
+- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵
+ 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`)
+ **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다
+- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을
+ 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의
+ 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다
+- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면
+ 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다
+
+**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건).
+
+---
+
## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai`
**왜**
diff --git a/nginx/Dockerfile b/nginx/Dockerfile
index f370b72..0913c69 100644
--- a/nginx/Dockerfile
+++ b/nginx/Dockerfile
@@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
RUN npm run build -w @o2o/frontend
FROM nginx:alpine
-COPY --from=build /app/solution/frontend/dist /srv/app
+# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다.
+# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다.
+COPY --from=build /app/solution/frontend/build/client /srv/app
diff --git a/nginx/site.conf.example b/nginx/site.conf.example
index 273b729..be044e8 100644
--- a/nginx/site.conf.example
+++ b/nginx/site.conf.example
@@ -44,8 +44,32 @@ server {
image/svg+xml;
# ── 발행 사이트 ────────────────────────────────────────────
+ # ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
+ # 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
+ # `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
+ absolute_redirect off;
+
+ # ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/`)와 형태를 맞춘다.
+ # 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고
+ # **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에
+ # 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록).
+ location = /s {
+ root /srv/sites;
+ try_files /s/index.html =404;
+ add_header Cache-Control "public, max-age=300, must-revalidate";
+ }
+
+ # 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다.
+ location = /s/ {
+ return 301 /s;
+ }
+
# ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다.
location ^~ /s/ {
+ # ★ `/s//`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
+ # 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
+ # index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
+ index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404;
@@ -117,12 +141,16 @@ server {
try_files $uri =404;
}
- # SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다.
- # ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면
- # 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
+ # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
+ # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
+ # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
+ # 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
+ # 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
+ # ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
+ # 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
location / {
root /srv/app;
- try_files $uri $uri/index.html /index.html;
+ try_files $uri $uri/index.html /__spa-fallback.html;
add_header Cache-Control "no-cache";
}
diff --git a/package-lock.json b/package-lock.json
index 032438f..d6075fc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -215,6 +215,19 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
@@ -232,6 +245,28 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
"node_modules/@babel/helper-globals": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@@ -242,6 +277,20 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+ "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-module-imports": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
@@ -274,6 +323,19 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+ "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
@@ -284,6 +346,38 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+ "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+ "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
@@ -344,6 +438,55 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@babel/plugin-syntax-jsx": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz",
+ "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz",
+ "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+ "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
@@ -376,6 +519,46 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/plugin-transform-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz",
+ "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/plugin-syntax-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/preset-typescript": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz",
+ "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-syntax-jsx": "^7.29.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.29.7",
+ "@babel/plugin-transform-typescript": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@@ -1425,6 +1608,12 @@
"jsep": "^0.4.0||^1.0.0"
}
},
+ "node_modules/@mjackson/node-fetch-server": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz",
+ "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==",
+ "license": "MIT"
+ },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1631,6 +1820,153 @@
"openapi3-ts": "4.5.0"
}
},
+ "node_modules/@react-router/dev": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz",
+ "integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.27.7",
+ "@babel/generator": "^7.27.5",
+ "@babel/parser": "^7.27.7",
+ "@babel/plugin-syntax-jsx": "^7.27.1",
+ "@babel/preset-typescript": "^7.27.1",
+ "@babel/traverse": "^7.27.7",
+ "@babel/types": "^7.27.7",
+ "@react-router/node": "7.18.3",
+ "@remix-run/node-fetch-server": "^0.13.0",
+ "arg": "^5.0.1",
+ "babel-dead-code-elimination": "^1.0.6",
+ "chokidar": "^4.0.0",
+ "dedent": "^1.5.3",
+ "es-module-lexer": "^1.3.1",
+ "exit-hook": "2.2.1",
+ "isbot": "^5.1.11",
+ "jsesc": "3.0.2",
+ "lodash": "^4.17.21",
+ "p-map": "^7.0.3",
+ "pathe": "^1.1.2",
+ "picocolors": "^1.1.1",
+ "pkg-types": "^2.3.0",
+ "prettier": "^3.6.2",
+ "react-refresh": "^0.14.0",
+ "semver": "^7.3.7",
+ "tinyglobby": "^0.2.14",
+ "valibot": "^1.2.0",
+ "vite-node": "^3.2.2"
+ },
+ "bin": {
+ "react-router": "bin.js"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "@react-router/serve": "^7.18.3",
+ "@vitejs/plugin-rsc": "~0.5.21",
+ "react-router": "^7.18.3",
+ "react-server-dom-webpack": "^19.2.3",
+ "typescript": "^5.1.0 || ^6.0.0",
+ "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "wrangler": "^3.28.2 || ^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@react-router/serve": {
+ "optional": true
+ },
+ "@vitejs/plugin-rsc": {
+ "optional": true
+ },
+ "react-server-dom-webpack": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ },
+ "wrangler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@react-router/dev/node_modules/jsesc": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz",
+ "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@react-router/dev/node_modules/react-refresh": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
+ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/@react-router/dev/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@react-router/node": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz",
+ "integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==",
+ "license": "MIT",
+ "dependencies": {
+ "@mjackson/node-fetch-server": "^0.2.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react-router": "7.18.3",
+ "typescript": "^5.1.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@remix-run/node-fetch-server": {
+ "version": "0.13.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz",
+ "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -3376,6 +3712,13 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -3478,6 +3821,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/babel-dead-code-elimination": {
+ "version": "1.0.12",
+ "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
+ "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.23.7",
+ "@babel/parser": "^7.23.6",
+ "@babel/traverse": "^7.23.7",
+ "@babel/types": "^7.23.6"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3556,6 +3912,16 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/call-bind": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -3767,6 +4133,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/confbox": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
+ "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -3881,6 +4254,21 @@
}
}
},
+ "node_modules/dedent": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
+ "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "babel-plugin-macros": "^3.1.0"
+ },
+ "peerDependenciesMeta": {
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -4545,6 +4933,19 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
+ "node_modules/exit-hook": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
+ "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -4565,6 +4966,13 @@
"node": ">=16.9.0"
}
},
+ "node_modules/exsolve": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz",
+ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -5640,6 +6048,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/isbot": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz",
+ "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6826,6 +7243,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/p-map": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz",
+ "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -6895,6 +7325,18 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pkg-types": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz",
+ "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.3.0",
+ "exsolve": "^1.1.1",
+ "pathe": "^2.0.3"
+ }
+ },
"node_modules/pony-cause": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
@@ -6953,6 +7395,22 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -7042,9 +7500,9 @@
}
},
"node_modules/react-router": {
- "version": "7.18.2",
- "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz",
- "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==",
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
+ "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -8137,7 +8595,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8272,6 +8730,21 @@
"node": ">= 4"
}
},
+ "node_modules/valibot": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz",
+ "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==",
+ "devOptional": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "typescript": ">=5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/validator": {
"version": "13.15.23",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz",
@@ -8356,6 +8829,36 @@
}
}
},
+ "node_modules/vite-node": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
+ "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.4.1",
+ "es-module-lexer": "^1.7.0",
+ "pathe": "^2.0.3",
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vite-node/node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -8791,10 +9294,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*",
+ "@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "isbot": "^5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -8808,6 +9313,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql
index 093edf2..8954fe8 100644
--- a/postgres-init/init-data/init.sql
+++ b/postgres-init/init-data/init.sql
@@ -5,7 +5,7 @@
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다.
-- postgres (1개 서버, 5432)
-- └── web4ai_db
--- ├── company : companies, users
+-- ├── company : users
-- ├── place : places, place_aliases, place_links, units, media
-- ├── fact : facts, faqs
-- ├── local : local_contents, routes, nearby_links
@@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site;
CREATE SCHEMA IF NOT EXISTS job;
-- ============================================================
--- company : 회사 / 내부 유저
+-- company : 계정
+-- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 —
+-- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다.
-- ============================================================
-CREATE TABLE IF NOT EXISTS company.companies (
- company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK)
- name VARCHAR(100) NOT NULL, -- 회사명
- business_number VARCHAR(30) NULL, -- 사업자등록번호
- code INTEGER NULL, -- 회사코드 (내부 인덱스용)
- representative_name VARCHAR(50) NULL, -- 대표자명
- email VARCHAR(255) NULL, -- 대표 이메일
- contact_number VARCHAR(20) NULL, -- 대표 연락처
- website_url VARCHAR(255) NULL, -- 홈페이지 URL
- industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 )
- status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성)
- settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작)
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
- deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
-);
-
CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK)
- company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id)
id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_)
password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
name VARCHAR(50) NULL, -- 이름
@@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users (
-- ============================================================
CREATE TABLE IF NOT EXISTS place.places (
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK)
- company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
- owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
+ owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과
status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended
@@ -375,15 +358,11 @@ CREATE TABLE IF NOT EXISTS job.jobs (
-- ============================================================
-- 인덱스
-- ============================================================
-CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
-- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다.
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE;
-CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL;
-CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE;
-- place
-CREATE INDEX IF NOT EXISTS idx_places_company_id ON place.places (company_id);
CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id);
CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE;
CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id);
@@ -470,3 +449,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid
-- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL;
+
+-- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고
+-- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다.
+-- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다.
+-- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다.
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM information_schema.columns
+ WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN
+ UPDATE place.places p
+ SET owner_user_id = (
+ SELECT u.user_id FROM company.users u
+ WHERE u.company_id = p.company_id AND u.deleted = FALSE
+ ORDER BY u.created_at LIMIT 1)
+ WHERE p.owner_user_id IS NULL;
+ -- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다.
+ DELETE FROM place.places WHERE owner_user_id IS NULL;
+ ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL;
+ ALTER TABLE place.places DROP COLUMN company_id;
+ END IF;
+END $$;
+ALTER TABLE company.users DROP COLUMN IF EXISTS company_id;
+DROP TABLE IF EXISTS company.companies;
diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py
index a97eb1f..ea91b8c 100644
--- a/solution/backend/common/database/model/models.py
+++ b/solution/backend/common/database/model/models.py
@@ -10,7 +10,6 @@ from common.enums import (
DBType,
UserStatus,
UserRole,
- CompanyStatus,
PlaceStatus,
FactStatus,
MediaStatus,
@@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델
-class companies(MainTableMixin, MAIN_BASE):
- __tablename__ = "companies"
- __table_args__ = {"schema": "company"}
-
- company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- name = Column(String(100), nullable=False)
- business_number = Column(String(30), nullable=True)
- code = Column(Integer, nullable=True) # 내부 인덱스용
- representative_name = Column(String(50), nullable=True)
- email = Column(String(255), nullable=True)
- contact_number = Column(String(20), nullable=True)
- website_url = Column(String(255), nullable=True)
- industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT)
- status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus
- # 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)
- settings = Column(JSONB, nullable=True)
-
-
class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users"
__table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- company_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# 20자였다. 구글 계정의 로그인 아이디를 `google_`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
@@ -102,8 +82,8 @@ class places(MainTableMixin, MAIN_BASE):
)
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
- company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies)
- owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.users)
+ # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
+ owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users)
name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
diff --git a/solution/backend/common/models/gmodel.py b/solution/backend/common/models/gmodel.py
index bb956fe..6ac550d 100644
--- a/solution/backend/common/models/gmodel.py
+++ b/solution/backend/common/models/gmodel.py
@@ -67,9 +67,8 @@ class PageParams:
class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보."""
- user_id: str # users.user_id (uuid) — 데이터 스코프 키
+ user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
id: str # users.id (로그인 아이디) — get_me 재조회 키
- company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None:
diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py
index 4ab1cfd..ec606ed 100644
--- a/solution/backend/conftest.py
+++ b/solution/backend/conftest.py
@@ -14,7 +14,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE
-from common.enums import CompanyStatus
+from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config
@@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle):
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute(
text(
- "TRUNCATE TABLE users, companies, places, place_aliases, place_links, units, media, "
+ "TRUNCATE TABLE users, places, place_aliases, place_links, units, media, "
"facts, faqs, local_contents, routes, nearby_links, "
"sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE"
)
@@ -102,30 +102,24 @@ async def db_engine(_test_db_lifecycle):
@pytest_asyncio.fixture
-async def company_id(db_engine) -> str:
- """테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다.
- users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다.
+async def owner_id(db_engine) -> str:
+ """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
+
+ ★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
+ 계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
"""
- cid = uuid.uuid4()
+ uid = uuid.uuid4()
async with db_engine.begin() as conn:
- # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
+ # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
await conn.execute(
- text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
- {"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value},
+ text(
+ "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
+ "VALUES (:uid, :id, NULL, :name, :status, :role, now())"
+ ),
+ {"uid": uid, "id": f"seed{uid.hex[:8]}", "name": "시드사장",
+ "status": UserStatus.ACTIVE.value, "role": UserRole.USER.value},
)
- return str(cid)
-
-
-@pytest_asyncio.fixture
-async def other_company_id(db_engine) -> str:
- """company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용)."""
- cid = uuid.uuid4()
- async with db_engine.begin() as conn:
- await conn.execute(
- text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
- {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value},
- )
- return str(cid)
+ return str(uid)
@pytest_asyncio.fixture
@@ -139,28 +133,27 @@ async def client(db_engine):
@pytest_asyncio.fixture
-async def auth_headers(db_engine, client, company_id):
+async def auth_headers(db_engine, client):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
- company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다.
- 호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`.
+ ★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
+ — 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
+ 호출: `h = await auth_headers("user1")`.
"""
- from common.enums import UserRole, UserStatus
from router.v1.validator.dependencies import GetHashedPW
- async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"):
- cid = company or company_id
+ async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
hashed = await GetHashedPW(password)
async with db_engine.begin() as conn:
- # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일).
+ # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시.
await conn.execute(
text(
- "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
- "VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())"
+ "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
+ "VALUES (:uid, :id, :pw, :name, :status, :role, now())"
),
{
- "uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed,
+ "uid": uuid.uuid4(), "id": login_id, "pw": hashed,
"name": name, "status": UserStatus.ACTIVE.value, "role": role,
},
)
diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py
index 8e75980..70316aa 100644
--- a/solution/backend/crud/place_crud.py
+++ b/solution/backend/crud/place_crud.py
@@ -11,26 +11,26 @@ from common.logger import LOG
from common.utils.gtime import GTime
-# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다.
+# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
class IPlaceCRUD(ABC):
@abstractmethod
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
pass
@abstractmethod
- async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]:
+ async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
pass
@abstractmethod
- async def list_places(self, cdb: AsyncSession, company_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]:
+ async def list_places(self, cdb: AsyncSession, owner_user_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
- async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]:
+ async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
pass
@abstractmethod
- async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]:
+ async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
@@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
- async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]:
+ async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]:
try:
query = (
select(places)
- .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
+ .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.limit(1)
)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, None
async def list_places(
- self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[int],
+ self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int],
status: Optional[int], skip: int, limit: int,
) -> Tuple[ErrorType, list, int]:
try:
- conditions = [places.deleted == False, places.company_id == company_id] # noqa: E712
+ conditions = [places.deleted == False, places.owner_user_id == owner_user_id] # noqa: E712
if category is not None:
conditions.append(places.category == category)
if status is not None:
@@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
- async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]:
+ async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
try:
if not data:
return ErrorType.SUCCESS, 0
query = (
update(places)
- .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
+ .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
.values(**data, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
- async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]:
+ async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다."""
try:
query = (
delete(places)
- .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712
+ .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py
index febc075..be7b47a 100644
--- a/solution/backend/crud/site_crud.py
+++ b/solution/backend/crud/site_crud.py
@@ -22,7 +22,7 @@ class ISiteCRUD(ABC):
pass
@abstractmethod
- async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]:
+ async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
@@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
- async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
- """회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
+ async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
+ """사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다."""
try:
- where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712
+ where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:
diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py
index 30b22ee..d24088c 100644
--- a/solution/backend/crud/user_crud.py
+++ b/solution/backend/crud/user_crud.py
@@ -1,12 +1,12 @@
from abc import ABC, abstractmethod
-from typing import Optional, Tuple
+from typing import Tuple
-from sqlalchemy import select, func, and_, or_, update
+from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
-from common.database.model.models import users, companies
-from common.enums import ErrorType, UserRole
+from common.database.model.models import users
+from common.enums import ErrorType
from common.logger import LOG
from common.utils.gtime import GTime
@@ -39,22 +39,6 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
pass
- @abstractmethod
- async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
- pass
-
- @abstractmethod
- async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
- pass
-
- @abstractmethod
- async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
- pass
-
- @abstractmethod
- async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]:
- pass
-
@abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass
@@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
- async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType:
- try:
- return await DB_SESSION_MNG.insert(cdb, company)
- except Exception as ex:
- LOG.e_no_callstack(ex)
- return ErrorType.DB_RUN_FAILED
-
- async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
- try:
- query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712
- err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
- if err_type != ErrorType.SUCCESS:
- return err_type, None
- if len(row_list) != 1:
- return ErrorType.DB_INVALID_KEY, None
- return ErrorType.SUCCESS, row_list[0]
- except Exception as ex:
- LOG.e_no_callstack(ex)
- return ErrorType.DB_RUN_FAILED, None
-
- async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType:
- try:
- query = (
- update(companies)
- .where(companies.company_id == company_id, companies.deleted == False) # noqa: E712
- .values(settings=settings, updated_at=GTime.UTC())
- )
- return await DB_SESSION_MNG.add(cdb, query)
- except Exception as ex:
- LOG.e_no_callstack(ex)
- return ErrorType.DB_RUN_FAILED
-
- async def list_by_company(
- self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False
- ) -> Tuple[ErrorType, list, int]:
- try:
- conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
- # 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다.
- if hide_dev:
- conditions.append(users.role != UserRole.DEVELOPER.value)
- if search:
- conditions.append(
- or_(
- users.id.ilike(f"%{search}%"),
- users.name.ilike(f"%{search}%"),
- users.email.ilike(f"%{search}%"),
- )
- )
- where = and_(*conditions)
-
- cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
- if cnt_err != ErrorType.SUCCESS:
- return cnt_err, [], 0
- total = int(cnt_rows[0] or 0) if cnt_rows else 0
-
- list_err, rows = await DB_SESSION_MNG.execute(
- cdb,
- select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
- )
- if list_err != ErrorType.SUCCESS:
- return list_err, [], 0
- return ErrorType.SUCCESS, list(rows), total
- except Exception as ex:
- LOG.e_no_callstack(ex)
- return ErrorType.DB_RUN_FAILED, [], 0
-
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712
diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py
index 99b699c..425e9cf 100644
--- a/solution/backend/router/v1/auth/protocol.py
+++ b/solution/backend/router/v1/auth/protocol.py
@@ -1,7 +1,5 @@
from typing import Optional
-from pydantic import Field
-
from common.enums import AuthProvider, UserRole
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@@ -17,7 +15,7 @@ class Req_Login(AuthProtocol):
class Req_Signup(AuthProtocol):
- """id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다.
+ """id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol):
password: str = ""
name: Optional[str] = None
email: str = ""
- company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다
class Req_GoogleLogin(AuthProtocol):
@@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol):
class Req_UpdateMe(AuthProtocol):
- # 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
+ # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
@@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol):
token_type: str = "bearer"
-class CompanyData(WebPacketProtocol):
- company_id: str = ""
- name: str = ""
-
-
class Res_Me(Res_WebPacketProtocol):
user_id: str = ""
id: str = ""
@@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol):
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL
- company: Optional[CompanyData] = Field(default=None)
diff --git a/solution/backend/router/v1/place/place.py b/solution/backend/router/v1/place/place.py
index 3b67e4d..67622c9 100644
--- a/solution/backend/router/v1/place/place.py
+++ b/solution/backend/router/v1/place/place.py
@@ -31,7 +31,7 @@ from .protocol import (
Res_UnitList,
)
-# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다.
+# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py
index 7d67556..94e4898 100644
--- a/solution/backend/router/v1/place/protocol.py
+++ b/solution/backend/router/v1/place/protocol.py
@@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
name: str = ""
category: PlaceCategory = PlaceCategory.LODGING
- owner_user_id: Optional[uuid.UUID] = None
+ # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
+ # 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
class Req_VerifyPlace(PlaceProtocol):
@@ -60,8 +61,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
class Req_UpdatePlace(PlaceProtocol):
+ # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None
- owner_user_id: Optional[uuid.UUID] = None
status: Optional[PlaceStatus] = None
@@ -214,7 +215,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건.
- ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
+ ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py
index 573df50..816980c 100644
--- a/solution/backend/router/v1/site/protocol.py
+++ b/solution/backend/router/v1/site/protocol.py
@@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol):
domain: Optional[str] = None
template_id: Optional[str] = None
published_at: Optional[datetime] = None
+ # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
+ thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False
@@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
- place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
+ place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다."""
name: str
diff --git a/solution/backend/scripts/demo_build.py b/solution/backend/scripts/demo_build.py
index 9a3104c..1c8266b 100644
--- a/solution/backend/scripts/demo_build.py
+++ b/solution/backend/scripts/demo_build.py
@@ -53,13 +53,11 @@ async def main():
engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW
- cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
+ uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c:
- await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"),
- {"c": cid, "n": "데모대행사"})
- await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) "
- "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
- {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
+ await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
+ "VALUES (:u,:i,:p,:n,:s,:r,now())"),
+ {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
from router.router import app
diff --git a/solution/backend/scripts/demo_pipeline.py b/solution/backend/scripts/demo_pipeline.py
index 5e26544..29b758e 100644
--- a/solution/backend/scripts/demo_pipeline.py
+++ b/solution/backend/scripts/demo_pipeline.py
@@ -41,12 +41,11 @@ async def main():
engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW
- cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
+ uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}"
async with engine.begin() as c:
- await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), {"c": cid, "n": "데모대행사"})
- await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) "
- "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
- {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
+ await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
+ "VALUES (:u,:i,:p,:n,:s,:r,now())"),
+ {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
await engine.dispose()
diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py
index 63a59fb..34f6993 100644
--- a/solution/backend/services/auth_service.py
+++ b/solution/backend/services/auth_service.py
@@ -4,13 +4,12 @@ import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
-from common.database.model.models import companies, users
-from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus
+from common.database.model.models import users
+from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import (
- CompanyData,
Req_GoogleLogin,
Req_Signup,
Req_UpdateMe,
@@ -71,11 +70,11 @@ class AuthService:
@staticmethod
def _user_info(user: users) -> UserInfo:
- # uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다.
+ # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다
+ # — 사업장이 places.owner_user_id 로 이 값에 매여 있다.
return UserInfo(
user_id=str(user.user_id),
id=user.id,
- company_id=str(user.company_id),
role=user.role,
)
@@ -140,26 +139,17 @@ class AuthService:
password_hash: str | None,
name: str | None,
email: str | None,
- company_name: str,
provider: AuthProvider,
provider_uid: str | None,
) -> tuple[ErrorType, users]:
- """회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다.
+ """계정 1개를 만든다.
- ★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이
- company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다.
- ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에
- company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다."""
- company_uuid = uuid.uuid4()
- company = companies(
- company_id=company_uuid,
- name=_fit(company_name, 100),
- email=_fit(email, 255),
- status=CompanyStatus.ACTIVE.value,
- )
+ ★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로
+ 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라
+ 걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다.
+ ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다."""
user = users(
user_id=uuid.uuid4(),
- company_id=company_uuid,
id=login_id,
password=password_hash,
name=_fit(name, 50),
@@ -171,10 +161,7 @@ class AuthService:
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
- [
- lambda s: self.user_crud.add_company(s, company),
- lambda s: self.user_crud.add_user(s, user),
- ],
+ [lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
return err_type, None
@@ -226,7 +213,6 @@ class AuthService:
password_hash=await GetHashedPW(req.password),
name=name,
email=email,
- company_name=(req.company_name or "").strip() or name or login_id,
provider=AuthProvider.LOCAL,
provider_uid=None,
)
@@ -268,13 +254,12 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res
- # 3) 첫 방문 — 계정과 회사를 만든다.
+ # 3) 첫 방문 — 계정을 만든다.
err_type, user = await self._create_account(
login_id=_google_login_id(account.sub),
password_hash=None,
name=account.name or None,
email=account.email or None,
- company_name=account.name or account.email or _google_login_id(account.sub),
provider=AuthProvider.GOOGLE,
provider_uid=account.sub,
)
@@ -299,16 +284,6 @@ class AuthService:
return res
user: users
- # 2) 소속사 조회 (없어도 치명적 아님)
- company = None
- c_err, company_row = await DB_SESSION_MNG.execute_lambda(
- users.DBType(),
- DBWRType.DB_READ.value,
- lambda s: self.user_crud.get_company(s, user.company_id),
- )
- if c_err == ErrorType.SUCCESS and company_row is not None:
- company = CompanyData(company_id=str(company_row.company_id), name=company_row.name)
-
res.user_id = str(user.user_id)
res.id = user.id
res.name = user.name
@@ -316,7 +291,6 @@ class AuthService:
res.contact_number = user.contact_number
res.role = UserRole(user.role)
res.provider = AuthProvider(user.provider)
- res.company = company
return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py
index 986e548..371bf23 100644
--- a/solution/backend/services/build_service.py
+++ b/solution/backend/services/build_service.py
@@ -98,18 +98,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict:
- """BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?}
+ """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?}
publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
payload = job["payload"]
place_id = payload["place_id"]
- company_id = payload["company_id"]
+ owner_user_id = payload["owner_user_id"]
want_publish = bool(payload.get("publish"))
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
+ lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
@@ -258,7 +258,7 @@ async def run_build(job: dict) -> dict:
result["azure"] = azure_result
# ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다.
# 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다).
- thumbnail_url = await site_thumbnail.store(slug, snapshot)
+ thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no)
if thumbnail_url:
result["thumbnail_url"] = thumbnail_url
# ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
@@ -305,7 +305,7 @@ async def run_build(job: dict) -> dict:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
- s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
+ s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
),
)
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py
index 8234d26..368cba6 100644
--- a/solution/backend/services/collect_service.py
+++ b/solution/backend/services/collect_service.py
@@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
payload = job["payload"]
place_id = payload["place_id"]
- company_id = payload["company_id"]
+ owner_user_id = payload["owner_user_id"]
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
+ lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}")
@@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict:
if not targets:
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
- await _finish(place_id, company_id, PlaceStatus.REVIEW)
+ await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
return result
# ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때).
@@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict:
if before["enough"] and not payload.get("force"):
result["coverage"] = before
result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)"
- await _finish(place_id, company_id, PlaceStatus.REVIEW)
+ await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략")
return result
from common.models.gmodel import UserInfo
actor = UserInfo(
- user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()),
+ # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
+ # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
+ # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
+ # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
+ user_id=owner_user_id,
id="collector",
- company_id=company_id,
role=1,
)
@@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict:
# 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다.
# (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.)
if result["media"]["stored"] > 0:
- result["vision_job_id"] = await _enqueue_vision(place_id, company_id)
+ result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id)
- await _finish(place_id, company_id, PlaceStatus.REVIEW)
+ await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장")
return result
-async def _finish(place_id: str, company_id: str, status: PlaceStatus):
+async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus):
"""수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다."""
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: _place_crud.update_place(
- s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": status.value}
+ s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": status.value}
),
)
-async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
+async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None:
"""사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다)."""
from common.enums import JobType
from crud.job_crud import JobQueue
@@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
return None
job_id, _created = await enqueue_job(
JobQueue(), JobType.VISION,
- {"place_id": place_id, "company_id": company_id},
+ {"place_id": place_id, "owner_user_id": owner_user_id},
dedupe_key=f"vision:{place_id}",
)
return job_id
diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py
index d796553..2869edb 100644
--- a/solution/backend/services/copy_service.py
+++ b/solution/backend/services/copy_service.py
@@ -40,10 +40,10 @@ class CopyAborted(RuntimeError):
async def run_copy(job: dict) -> dict:
- """COPY 잡 핸들러. payload: {place_id, company_id, requested_by?}"""
+ """COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}"""
payload = job["payload"]
place_id = payload["place_id"]
- company_id = payload["company_id"]
+ owner_user_id = payload["owner_user_id"]
if not gemini_text.is_configured():
raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다")
@@ -51,7 +51,7 @@ async def run_copy(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
+ lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
@@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict:
}
actor = UserInfo(
- user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()),
+ # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로
+ # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다.
+ # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가
+ # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다.
+ user_id=owner_user_id,
id="generator",
- company_id=company_id,
role=1,
)
service = FactService(_fact_crud, _place_crud)
diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py
index cfd8b8b..c22ded9 100644
--- a/solution/backend/services/fact_service.py
+++ b/solution/backend/services/fact_service.py
@@ -73,7 +73,7 @@ class FactService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
+ lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py
index c4c2efa..ccb2020 100644
--- a/solution/backend/services/faq_service.py
+++ b/solution/backend/services/faq_service.py
@@ -38,14 +38,14 @@ class FaqService:
self.crud = crud
self.place_crud = place_crud
- # ---- 사업장 로드(회사 스코프) ----
+ # ---- 사업장 로드(사장님 스코프) ----
async def _load_place(self, user_info: UserInfo, place_id: str):
- """company_id 를 WHERE 에 걸어 조회한다 — 남의 회사 place_id 를 넣으면 PLACE_NOT_FOUND.
+ """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND.
'없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다)."""
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
+ lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py
index 9a43007..b423ca6 100644
--- a/solution/backend/services/media_service.py
+++ b/solution/backend/services/media_service.py
@@ -41,7 +41,7 @@ class MediaService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
+ lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py
index 4453cca..f212615 100644
--- a/solution/backend/services/place_service.py
+++ b/solution/backend/services/place_service.py
@@ -70,12 +70,12 @@ class PlaceService:
# ---- 조회 ----
async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList:
res = Res_PlaceList(page=pg.page, size=pg.size)
- cid = uuid.UUID(user_info.company_id)
+ uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_places(
- s, cid, search,
+ s, uid, search,
category.value if isinstance(category, PlaceCategory) else category,
status.value if isinstance(status, PlaceStatus) else status,
pg.skip, pg.size,
@@ -102,7 +102,7 @@ class PlaceService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
+ lambda s: self.crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
@@ -122,8 +122,11 @@ class PlaceService:
return res
place = places(
- company_id=uuid.UUID(user_info.company_id),
- owner_user_id=req.owner_user_id,
+ # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데,
+ # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다.
+ # 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면
+ # 만들자마자 남의 목록에 들어간다.
+ owner_user_id=uuid.UUID(user_info.user_id),
name=req.name.strip(),
category=req.category.value,
status=PlaceStatus.DRAFT.value,
@@ -149,7 +152,7 @@ class PlaceService:
if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
- lambda s: self.crud.update_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), data),
+ lambda s: self.crud.update_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), data),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@@ -164,7 +167,7 @@ class PlaceService:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.delete_place(
- s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)
+ s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)
),
)
if err_type != ErrorType.SUCCESS:
@@ -253,7 +256,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(
- s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), {"name": official}
+ s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"name": official}
),
)
if verified.place:
@@ -294,7 +297,7 @@ class PlaceService:
res.result.SetResult(err_type)
return res
- cid = uuid.UUID(user_info.company_id)
+ uid = uuid.UUID(user_info.user_id)
now = GTime.UTC()
data = {
"external_source": req.source.value,
@@ -325,7 +328,7 @@ class PlaceService:
data["external_category"] = req.category_name.strip()[:200]
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
- lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data),
+ lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@@ -513,7 +516,7 @@ class PlaceService:
payload = {
"place_id": place_id,
- "company_id": user_info.company_id,
+ "owner_user_id": user_info.user_id,
"category": place.category,
# 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재
# 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가
@@ -542,7 +545,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(),
lambda s: self.crud.update_place(
- s, uuid.UUID(user_info.company_id), uuid.UUID(place_id),
+ s, uuid.UUID(user_info.user_id), uuid.UUID(place_id),
{"status": PlaceStatus.COLLECTING.value},
),
)
@@ -589,7 +592,7 @@ class PlaceService:
job_id, created = await enqueue_job(
self.queue, JobType.VISION,
- {"place_id": place_id, "company_id": user_info.company_id, "force": req.force},
+ {"place_id": place_id, "owner_user_id": user_info.user_id, "force": req.force},
dedupe_key=f"vision:{place_id}",
)
if job_id is None:
@@ -849,7 +852,7 @@ class PlaceService:
job_id, created = await enqueue_job(
self.queue, JobType.COPY,
- {"place_id": place_id, "company_id": user_info.company_id, "requested_by": user_info.user_id},
+ {"place_id": place_id, "owner_user_id": user_info.user_id, "requested_by": user_info.user_id},
dedupe_key=f"copy:{place_id}",
)
if job_id is None:
diff --git a/solution/backend/services/showcase_service.py b/solution/backend/services/showcase_service.py
index 4f058ed..835ef8f 100644
--- a/solution/backend/services/showcase_service.py
+++ b/solution/backend/services/showcase_service.py
@@ -5,7 +5,7 @@
고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로
긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다.
- 나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표.
+ 나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표.
"""
from common.database.db_session_manager import DB_SESSION_MNG
diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py
index 3103797..a5fc221 100644
--- a/solution/backend/services/site_service.py
+++ b/solution/backend/services/site_service.py
@@ -104,7 +104,7 @@ class SiteService:
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)),
+ lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
@@ -420,16 +420,16 @@ class SiteService:
return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
- """로그인한 계정(회사)이 가진 사이트 전부.
+ """로그인한 사장님이 가진 사이트 전부.
사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다."""
res = Res_MySites(page=pg.page, size=pg.size)
- cid = uuid.UUID(user_info.company_id)
+ uid = uuid.UUID(user_info.user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size),
+ lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
@@ -454,6 +454,7 @@ class SiteService:
domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", None),
+ thumbnail_url=getattr(site, "thumbnail_url", None),
needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)),
)
@@ -526,7 +527,7 @@ class SiteService:
job_id, created = await enqueue_job(
self.queue, JobType.BUILD,
{
- "place_id": place_id, "company_id": user_info.company_id,
+ "place_id": place_id, "owner_user_id": user_info.user_id,
"publish": req.publish, "requested_by": user_info.user_id,
},
dedupe_key=f"build:{place_id}",
diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py
index 92b9e24..376b476 100644
--- a/solution/backend/services/site_thumbnail.py
+++ b/solution/backend/services/site_thumbnail.py
@@ -18,7 +18,7 @@ import asyncio
import os
import httpx
-from azure.storage.blob import BlobServiceClient, ContentSettings
+from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings
from common.logger import LOG
from services import azure_static, site_payload
@@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024
CACHE_CONTROL = "public, max-age=60, must-revalidate"
+# ── 썸네일 전용 저장소 ────────────────────────────────────────────────────────
+# ★ 왜 스위치를 따로 두나
+# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을
+# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고
+# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간
+# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은
+# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다.
+#
+# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`.
+# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) —
+# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다.
+#
+# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가
+# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 —
+# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다.
+_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너>
+_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다)
+_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai
+
+
+def _blob_base() -> str:
+ return os.environ.get(_BASE_ENV, "").strip().rstrip("/")
+
+
+def _blob_sas() -> str:
+ return os.environ.get(_SAS_ENV, "").strip().lstrip("?")
+
+
+def _blob_root() -> str:
+ return os.environ.get(_ROOT_ENV, "").strip().strip("/")
+
+
+def uses_blob_store() -> bool:
+ """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다."""
+ return bool(_blob_base() and _blob_sas())
+
+
def is_configured() -> bool:
- return azure_static.is_configured()
+ return uses_blob_store() or azure_static.is_configured()
def blob_name(slug: str, ext: str) -> str:
+ if uses_blob_store():
+ # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다.
+ return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part)
prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/")
return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part)
-def public_url(slug: str, ext: str) -> str:
+def public_url(slug: str, ext: str, version: int | None = None) -> str:
"""공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다
- (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다')."""
- return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
+ (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').
+
+ ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로
+ (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.**
+ 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이
+ 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다.
+ ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다.
+ ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로
+ (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다."""
+ # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면
+ # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다.
+ base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \
+ else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
+ return f"{base}?v={version}" if version is not None else base
async def _fetch(url: str) -> tuple[bytes, str, str] | None:
@@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None:
def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
+ name = blob_name(slug, ext)
+ settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL)
+
+ if uses_blob_store():
+ # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다.
+ # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다:
+ # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다).
+ BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob(
+ data, overwrite=True, content_settings=settings
+ )
+ return name
+
connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip()
container_name = (
os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip()
@@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str:
)
service = BlobServiceClient.from_connection_string(connection_string)
container = service.get_container_client(container_name)
- name = blob_name(slug, ext)
- container.upload_blob(
- name=name,
- data=data,
- overwrite=True,
- # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
- content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL),
- )
+ # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
+ container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
return name
-async def store(slug: str, snapshot: dict) -> str | None:
+async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None:
"""대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다).
SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로,
@@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None:
return None
LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})")
- return public_url(slug, ext)
+ return public_url(slug, ext, version)
diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py
index 9f7e4a8..0b1e534 100644
--- a/solution/backend/services/vision_service.py
+++ b/solution/backend/services/vision_service.py
@@ -28,10 +28,10 @@ class VisionAborted(RuntimeError):
async def run_vision(job: dict) -> dict:
- """VISION 잡 핸들러. payload: {place_id, company_id, force?}"""
+ """VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}"""
payload = job["payload"]
place_id = payload["place_id"]
- company_id = payload["company_id"]
+ owner_user_id = payload["owner_user_id"]
force = bool(payload.get("force"))
if not gemini.is_configured():
@@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(),
DBWRType.DB_READ.value,
- lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
+ lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)),
)
if err != ErrorType.SUCCESS or place is None:
raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}")
diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py
index c596f4a..a3b0a84 100644
--- a/solution/backend/tests/test_auth.py
+++ b/solution/backend/tests/test_auth.py
@@ -4,9 +4,9 @@
"""
-async def test_login_and_me_flow(auth_headers, client, company_id):
+async def test_login_and_me_flow(auth_headers, client):
"""검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
- 기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환."""
+ 기대결과: 200, 본인 id·name 이 그대로 반환."""
h = await auth_headers("user1", name="홍길동")
r = await client.get("/v1/auth/me", headers=h)
@@ -14,7 +14,8 @@ async def test_login_and_me_flow(auth_headers, client, company_id):
me = r.json()
assert me["id"] == "user1"
assert me["name"] == "홍길동"
- assert me["company"]["company_id"] == company_id
+ # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다.
+ assert "company" not in me
async def test_login_with_wrong_password(auth_headers, client):
@@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai
async def test_signup_creates_account_and_logs_in(client, db_engine):
"""검증: 가입 → 받은 토큰으로 곧바로 /me.
- 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다."""
+ 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다."""
r = await client.post("/v1/auth/signup", json=_SIGNUP)
body = r.json()
assert body["result"]["success"] is True
@@ -59,7 +60,6 @@ async def test_signup_creates_account_and_logs_in(client, db_engine):
assert me["id"] == "sajang1"
assert me["email"] == "boss@example.com"
assert me["provider"] == 1 # AuthProvider.LOCAL
- assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
async def test_signup_rejects_duplicate_id(client, db_engine):
diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py
index 9aab5b2..87c5f78 100644
--- a/solution/backend/tests/test_build_publish.py
+++ b/solution/backend/tests/test_build_publish.py
@@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine):
assert [v["version"] for v in versions] == [2, 1]
-async def test_site_is_scoped_to_company(auth_headers, client, other_company_id):
- """검증: 다른 회사 계정으로 남의 사이트를 본다.
+async def test_site_is_scoped_to_owner(auth_headers, client):
+ """검증: 다른 사장님 계정으로 남의 사이트를 본다.
기대결과: PLACE_NOT_FOUND."""
h1 = await auth_headers("o1")
pid = await _place(client, h1, "스코프펜션")
- h2 = await auth_headers("o2", other_company_id)
+ h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/site", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_collect_api.py b/solution/backend/tests/test_collect_api.py
index c2716e2..205a3da 100644
--- a/solution/backend/tests/test_collect_api.py
+++ b/solution/backend/tests/test_collect_api.py
@@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he
assert job["payload"]["link_ids"] == [confirmed]
-async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id):
- """검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다.
+async def test_collect_is_scoped_to_owner(auth_headers, client):
+ """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다."""
h1 = await auth_headers("o1")
pid = await _place(client, h1, kakao="c7")
await _confirmed_link(client, h1, pid)
- h2 = await auth_headers("o2", other_company_id)
+ h2 = await auth_headers("o2")
r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={})
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py
index 4b5d9d7..5ca4391 100644
--- a/solution/backend/tests/test_collect_pipeline.py
+++ b/solution/backend/tests/test_collect_pipeline.py
@@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client):
assert check_in[0]["status"] == FactStatus.CORRECTED.value
-async def test_pipeline_refuses_unverified_place(db_engine, company_id):
+async def test_pipeline_refuses_unverified_place(db_engine, owner_id):
"""검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황).
기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다."""
from sqlalchemy import text
@@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id):
pid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
- text("INSERT INTO places (place_id, company_id, name, category, status) "
+ text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :cid, :n, 1, 1)"),
- {"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"},
+ {"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"},
)
q = JobQueue()
- job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "company_id": company_id}, max_attempts=1)
+ job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "owner_user_id": owner_id}, max_attempts=1)
worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0)
await worker.process_one()
diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py
index 91a0bfc..38f4e0c 100644
--- a/solution/backend/tests/test_fact_api.py
+++ b/solution/backend/tests/test_fact_api.py
@@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client):
assert place.get("content_updated_at") is None
-async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id):
- """검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다.
+async def test_facts_are_scoped_to_owner(auth_headers, client):
+ """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다.
기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다."""
h1 = await auth_headers("o1")
pid = await _verified_place(client, h1, kakao="p6")
- h2 = await auth_headers("o2", other_company_id)
+ h2 = await auth_headers("o2")
r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py
index fe04ef9..a357071 100644
--- a/solution/backend/tests/test_fact_schema.py
+++ b/solution/backend/tests/test_fact_schema.py
@@ -22,17 +22,17 @@ from common.enums import (
)
-async def _seed_place(db_engine, company_id) -> str:
+async def _seed_place(db_engine, owner_id) -> str:
"""검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다."""
pid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text(
- "INSERT INTO places (place_id, company_id, name, category, status, external_place_id, verified_at) "
+ "INSERT INTO places (place_id, owner_user_id, name, category, status, external_place_id, verified_at) "
"VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())"
),
{
- "pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션",
+ "pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션",
"cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value,
"kakao": "12345678",
},
@@ -54,20 +54,20 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None):
)
-async def test_published_fact_is_unique_per_place_and_key(db_engine, company_id):
+async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id):
"""검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다.
기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다)."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
with pytest.raises(IntegrityError):
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED)
-async def test_candidates_coexist_with_published_value(db_engine, company_id):
+async def test_candidates_coexist_with_published_value(db_engine, owner_id):
"""검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다.
기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER)
await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED)
@@ -80,10 +80,10 @@ async def test_candidates_coexist_with_published_value(db_engine, company_id):
assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다"
-async def test_rejected_fact_frees_the_key(db_engine, company_id):
+async def test_rejected_fact_frees_the_key(db_engine, owner_id):
"""검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다.
기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED)
@@ -95,18 +95,18 @@ async def test_rejected_fact_frees_the_key(db_engine, company_id):
assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다"
-async def test_expired_fact_frees_the_key(db_engine, company_id):
+async def test_expired_fact_frees_the_key(db_engine, owner_id):
"""검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존.
기대결과: 통과 — EXPIRED 도 유니크에서 빠진다."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED)
await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED)
-async def test_same_key_allowed_across_units(db_engine, company_id):
+async def test_same_key_allowed_across_units(db_engine, owner_id):
"""검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지.
기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
unit_a, unit_b = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as conn:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")):
@@ -122,10 +122,10 @@ async def test_same_key_allowed_across_units(db_engine, company_id):
await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a)
-async def test_unit_fact_and_place_fact_are_separate(db_engine, company_id):
+async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id):
"""검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우.
기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다."""
- place_id = await _seed_place(db_engine, company_id)
+ place_id = await _seed_place(db_engine, owner_id)
unit_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py
index aa79abb..6adda5c 100644
--- a/solution/backend/tests/test_faq_api.py
+++ b/solution/backend/tests/test_faq_api.py
@@ -56,14 +56,14 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client
assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == []
-async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id):
- """검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
+async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
+ """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다."""
h = await auth_headers("u1")
pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid)
- other = await auth_headers("u2", other_company_id)
+ other = await auth_headers("u2")
assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py
index 3ad532b..3abae3d 100644
--- a/solution/backend/tests/test_my_sites.py
+++ b/solution/backend/tests/test_my_sites.py
@@ -3,7 +3,7 @@
이 경로가 절대 하면 안 되는 것:
- 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- - 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
+ - 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른
답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다.
"""
@@ -53,11 +53,47 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client):
assert row["status"] == SiteStatus.DRAFT.value
-async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id):
- """검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다.
+async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine):
+ """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일.
+
+ ★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄)
+ 이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고,
+ **한 번이라도 발행한 줄은 그림**이다."""
+ h = await auth_headers("my2b")
+ pid = await _place(client, h, "카드펜션")
+ async with db_engine.begin() as conn:
+ await conn.execute(
+ text("UPDATE places SET road_address = :addr WHERE place_id = :pid"),
+ {"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)},
+ )
+ await conn.execute(
+ text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) "
+ "VALUES (:s, :p, :d, :st, :t)"),
+ {"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay",
+ "st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"},
+ )
+
+ row = (await _list(client, h))["sites"][0]
+ assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12"
+ assert row["created_at"]
+ assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"
+
+
+async def test_row_without_a_site_has_no_thumbnail(auth_headers, client):
+ """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다).
+ 기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다."""
+ h = await auth_headers("my2c")
+ await _place(client, h, "그림없는펜션")
+
+ row = (await _list(client, h))["sites"][0]
+ assert "thumbnail_url" not in row
+
+
+async def test_other_owners_sites_are_not_listed(auth_headers, client):
+ """검증: 사장님 스코프. 남의 사업장은 보이지 않는다.
기대결과: 각자 자기 것만 1건."""
mine = await auth_headers("my3")
- theirs = await auth_headers("my3b", other_company_id)
+ theirs = await auth_headers("my3b")
await _place(client, mine, "내펜션")
await _place(client, theirs, "남의펜션")
diff --git a/solution/backend/tests/test_place.py b/solution/backend/tests/test_place.py
index 8b74e4d..9c080ed 100644
--- a/solution/backend/tests/test_place.py
+++ b/solution/backend/tests/test_place.py
@@ -1,8 +1,8 @@
-"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트.
+"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트.
★ 이 도메인의 핵심 규칙 두 개를 고정한다:
1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다
- 2. 남의 회사 사업장은 '없음'으로 보인다
+ 2. 남의 사업장은 '없음'으로 보인다
"""
import uuid
@@ -61,7 +61,7 @@ async def test_verify_place_opens_collection(auth_headers, client):
async def test_duplicate_kakao_place_is_allowed(auth_headers, client):
- """검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다.
+ """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.
기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다."""
h = await auth_headers("u1")
first = (await _create_place(client, h, "A펜션"))["place"]["place_id"]
@@ -138,13 +138,13 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client):
assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value
-async def test_place_is_scoped_to_company(auth_headers, client, other_company_id):
- """검증: 다른 회사 계정으로 남의 사업장을 조회한다.
+async def test_place_is_scoped_to_owner(auth_headers, client):
+ """검증: 다른 사장님 계정으로 남의 사업장을 조회한다.
기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단)."""
h1 = await auth_headers("owner1")
pid = (await _create_place(client, h1))["place"]["place_id"]
- h2 = await auth_headers("owner2", other_company_id)
+ h2 = await auth_headers("owner2")
r = await client.get(f"/v1/place/{pid}", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py
index 5e140f7..fb98581 100644
--- a/solution/backend/tests/test_place_search.py
+++ b/solution/backend/tests/test_place_search.py
@@ -80,7 +80,7 @@ async def test_search_leaks_nothing_of_ours(client, monkeypatch):
item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0]
- for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude",
+ for leaked in ("place_id", "owner_user_id", "phone", "latitude", "longitude",
"external_place_id", "address"):
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"
diff --git a/solution/backend/tests/test_seo_audit.py b/solution/backend/tests/test_seo_audit.py
index d880ebc..512a211 100644
--- a/solution/backend/tests/test_seo_audit.py
+++ b/solution/backend/tests/test_seo_audit.py
@@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures():
assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"])
-async def test_audit_api_is_company_scoped(auth_headers, client, other_company_id):
+async def test_audit_api_is_owner_scoped(auth_headers, client):
h1 = await auth_headers("audit-owner")
pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"]
@@ -41,6 +41,6 @@ async def test_audit_api_is_company_scoped(auth_headers, client, other_company_i
assert 0 <= own["aeo_score"] <= 100
assert own["checks"]
- h2 = await auth_headers("audit-other", other_company_id)
+ h2 = await auth_headers("audit-other")
denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json()
assert denied["result"]["success"] is False
diff --git a/solution/backend/tests/test_showcase_api.py b/solution/backend/tests/test_showcase_api.py
index e3f7c9b..331240b 100644
--- a/solution/backend/tests/test_showcase_api.py
+++ b/solution/backend/tests/test_showcase_api.py
@@ -13,14 +13,14 @@ from sqlalchemy import text
from common.enums import PlaceCategory, PlaceStatus, SiteStatus
-async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0):
+async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, minutes_ago=0):
"""places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
pid, sid = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
- text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) "
+ text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, address, phone) "
"VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"),
- {"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value,
+ {"p": pid, "c": uuid.UUID(owner_id), "n": name, "cat": PlaceCategory.LODGING.value,
"st": PlaceStatus.PUBLISHED.value,
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
"phone": "033-672-0000"},
@@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m
return str(pid)
-async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id):
+async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id):
"""검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다.
기대결과: 발행본만 나온다."""
- await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
+ await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value,
domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg")
- await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
+ await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet")
res = await client.get("/v1/showcase")
@@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi
assert items[0]["category"] == PlaceCategory.LODGING.value
-async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id):
+async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id):
"""검증: 응답 항목의 키를 그대로 본다.
기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다."""
- await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
+ await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae")
item = (await client.get("/v1/showcase")).json()["items"][0]
@@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin
assert "하조대3길" not in body
-async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id):
+async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id):
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나).
그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다."""
- await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
+ await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic")
item = (await client.get("/v1/showcase")).json()["items"][0]
@@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i
assert "thumbnail_url" not in item
-async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id):
- await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
- await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
+async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id):
+ await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60)
+ await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
items = (await client.get("/v1/showcase")).json()["items"]
assert [i["name"] for i in items] == ["나중", "먼저"]
diff --git a/solution/backend/tests/test_site_slug.py b/solution/backend/tests/test_site_slug.py
index 0c78dd5..a589d0b 100644
--- a/solution/backend/tests/test_site_slug.py
+++ b/solution/backend/tests/test_site_slug.py
@@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine):
assert same["site"]["domain"] == "published-stay"
-async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
- """검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다.
+async def test_other_owners_place_is_blocked(auth_headers, client):
+ """검증: 남의 사업장 주소는 확인도 예약도 못 한다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("slug5")
- intruder = await auth_headers("slug6", other_company_id)
+ intruder = await auth_headers("slug6")
pid = await _place(client, h)
assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py
index 2e87685..fa643e8 100644
--- a/solution/backend/tests/test_site_template.py
+++ b/solution/backend/tests/test_site_template.py
@@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en
assert changed["needs_rebuild"] is True
-async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
- """검증: 남의 회사 사업장의 템플릿은 바꿀 수 없다.
+async def test_other_owners_place_is_blocked(auth_headers, client):
+ """검증: 남의 사업장의 템플릿은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("tpl6")
- intruder = await auth_headers("tpl7", other_company_id)
+ intruder = await auth_headers("tpl7")
pid = await _place(client, h)
blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")
diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py
index 3b6c811..a47a12d 100644
--- a/solution/backend/tests/test_site_theme.py
+++ b/solution/backend/tests/test_site_theme.py
@@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
assert changed["needs_rebuild"] is True
-async def test_other_company_place_is_blocked(auth_headers, client, other_company_id):
- """검증: 남의 회사 사업장의 디자인은 바꿀 수 없다.
+async def test_other_owners_place_is_blocked(auth_headers, client):
+ """검증: 남의 사업장의 디자인은 바꿀 수 없다.
기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("thm7")
- intruder = await auth_headers("thm8", other_company_id)
+ intruder = await auth_headers("thm8")
pid = await _place(client, h)
assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py
index 44b2808..3d81522 100644
--- a/solution/backend/tests/test_site_thumbnail.py
+++ b/solution/backend/tests/test_site_thumbnail.py
@@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa
_transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes"))
snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None})
- url = await site_thumbnail.store("butter", snapshot)
+ url = await site_thumbnail.store("butter", snapshot, 3)
- assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg"
+ # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면
+ # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url).
+ assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3"
name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"
assert set(blob.uploads) == {name}
assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/")
@@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
- async def _store(slug, snapshot):
- return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg"
+ async def _store(slug, snapshot, version=None):
+ return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}"
monkeypatch.setattr(build_service.site_thumbnail, "store", _store)
@@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head
site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/")
+ assert site["thumbnail_url"].endswith("?v=1")
+
+ # ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면
+ # 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다.
+ await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True})
+ await _run()
+ again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"]
+ assert again["thumbnail_url"].endswith("?v=2")
+ assert again["thumbnail_url"] != site["thumbnail_url"]
async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch):
@@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers
from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts
- async def _store(slug, snapshot):
+ async def _store(slug, snapshot, version=None):
return None
monkeypatch.setattr(build_service.site_thumbnail, "store", _store)
diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py
index e8d1d41..38ca3e5 100644
--- a/solution/backend/tests/test_snapshot.py
+++ b/solution/backend/tests/test_snapshot.py
@@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
from services.snapshot import build_snapshot
-async def _seed(db_engine, company_id, category=PlaceCategory.LODGING):
+async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING):
pid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
- text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) "
+ text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) "
"VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"),
- {"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value,
+ {"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value,
"addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
)
return pid
@@ -53,10 +53,10 @@ class _Place:
self.longitude = None
-async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
+async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id):
"""검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED)
@@ -69,10 +69,10 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, company_id):
assert keys == {"check_in_time", "wifi"}
-async def test_only_approved_media_enters_snapshot(db_engine, company_id):
+async def test_only_approved_media_enters_snapshot(db_engine, owner_id):
"""검증: 승인/확인대기/반려 사진을 섞어 넣는다.
기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED)
await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW)
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED)
@@ -81,10 +81,10 @@ async def test_only_approved_media_enters_snapshot(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"]
-async def test_media_without_alt_is_excluded(db_engine, company_id):
+async def test_media_without_alt_is_excluded(db_engine, owner_id):
"""검증: 승인됐지만 alt 텍스트가 없는 사진.
기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="")
await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진")
@@ -92,10 +92,10 @@ async def test_media_without_alt_is_excluded(db_engine, company_id):
assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"]
-async def test_fact_labels_come_from_category_schema(db_engine, company_id):
+async def test_fact_labels_come_from_category_schema(db_engine, owner_id):
"""검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
snap = await build_snapshot(_Place(pid))
@@ -104,10 +104,10 @@ async def test_fact_labels_come_from_category_schema(db_engine, company_id):
assert f["scope"] == "place"
-async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
+async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id):
"""검증: 객실 단위 fact.
기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"),
@@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id):
assert snap["facts"][0]["scope"] == "unit"
-async def test_empty_place_gives_empty_snapshot(db_engine, company_id):
+async def test_empty_place_gives_empty_snapshot(db_engine, owner_id):
"""검증: 아무것도 없는 사업장.
기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
snap = await build_snapshot(_Place(pid))
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
assert snap["place"]["name"] == "스냅샷펜션"
@@ -156,13 +156,13 @@ class _RegionPlace(_Place):
self.region_code = region_code
-async def test_only_published_local_content_enters_snapshot(db_engine, company_id):
+async def test_only_published_local_content_enters_snapshot(db_engine, owner_id):
"""검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다.
기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제")
@@ -171,7 +171,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"]
-async def test_local_content_outside_display_window_is_excluded(db_engine, company_id):
+async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id):
"""검증: 발행됐지만 노출 기간을 벗어난 지역 정보.
기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다."""
from datetime import datetime, timedelta, timezone
@@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
from common.enums import LocalContentStatus, LocalContentType
now = datetime.now(timezone.utc)
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제",
display_end_at=now - timedelta(days=1))
@@ -190,12 +190,12 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa
assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"]
-async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id):
+async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id):
"""검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제")
@@ -203,7 +203,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i
assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"]
-async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id):
+async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id):
"""검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는
코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다."""
@@ -211,7 +211,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
from services.external.naver import region_key
derived = region_key(_Place("x").road_address)
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제")
snap = await build_snapshot(_RegionPlace(pid, ""))
@@ -219,12 +219,12 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c
assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"]
-async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id):
+async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id):
"""검증: 지역 코드도 읽을 만한 주소도 없는 사업장.
기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다."""
from common.enums import LocalContentStatus, LocalContentType
- pid = await _seed(db_engine, company_id)
+ pid = await _seed(db_engine, owner_id)
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제")
place = _RegionPlace(pid, "")
diff --git a/solution/backend/tests/test_verify_candidates.py b/solution/backend/tests/test_verify_candidates.py
index 5a3e45e..275236b 100644
--- a/solution/backend/tests/test_verify_candidates.py
+++ b/solution/backend/tests/test_verify_candidates.py
@@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey
assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value
-async def test_candidates_scoped_to_company(auth_headers, client, other_company_id, monkeypatch):
- """검증: 다른 회사 계정으로 남의 사업장 후보를 조회한다.
+async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch):
+ """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다.
기대결과: PLACE_NOT_FOUND."""
_patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x"))
h1 = await auth_headers("o1")
pid = await _place(client, h1)
- h2 = await auth_headers("o2", other_company_id)
+ h2 = await auth_headers("o2")
body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json()
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
diff --git a/solution/frontend/index.html b/solution/frontend/index.html
deleted file mode 100644
index f5ab125..0000000
--- a/solution/frontend/index.html
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Web4Ai · AI 웹 빌더
-
-
-
-
-
-
diff --git a/solution/frontend/package.json b/solution/frontend/package.json
index 0ffa08b..01b234a 100644
--- a/solution/frontend/package.json
+++ b/solution/frontend/package.json
@@ -4,11 +4,11 @@
"version": "0.0.0",
"type": "module",
"scripts": {
- "dev": "vite --port=3000 --host=0.0.0.0",
- "build": "tsc --noEmit && eslint src && vite build",
+ "dev": "react-router dev --port=3000 --host=0.0.0.0",
+ "build": "react-router typegen && tsc --noEmit && eslint src && react-router build",
"preview": "vite preview",
- "clean": "rm -rf dist",
- "lint": "tsc --noEmit && eslint src",
+ "clean": "rm -rf build .react-router",
+ "lint": "react-router typegen && tsc --noEmit && eslint src",
"orval": "orval --config ./orval.config.ts"
},
"dependencies": {
@@ -17,10 +17,12 @@
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*",
+ "@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "isbot": "^5",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -34,6 +36,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
+ "@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts
new file mode 100644
index 0000000..0273023
--- /dev/null
+++ b/solution/frontend/react-router.config.ts
@@ -0,0 +1,24 @@
+import type {Config} from '@react-router/dev/config';
+
+/**
+ * 프리렌더 설정.
+ *
+ * ★ 왜 하나 — 랜딩은 `
` 만 내보내는 CSR 이었다. 실측(2026-09-07):
+ * `curl /` 는 3,021바이트에 `` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다.
+ * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 —
+ * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다.
+ *
+ * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 —
+ * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는
+ * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다.
+ *
+ * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별),
+ * robots.txt 로 막을 대상이기도 하다.
+ */
+export default {
+ // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다).
+ // root.tsx · routes.ts 를 여기서 찾는다.
+ appDirectory: 'src',
+ ssr: false,
+ prerender: ['/', '/pricing', '/showcase'],
+} satisfies Config;
diff --git a/solution/frontend/src/api/generated/model/companyData.ts b/solution/frontend/src/api/generated/model/companyData.ts
deleted file mode 100644
index 1229a4c..0000000
--- a/solution/frontend/src/api/generated/model/companyData.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * Generated by orval v7.21.0 🍺
- * Do not edit manually.
- * Web4Ai API
- * OpenAPI spec version: 0.1.0
- */
-
-export interface CompanyData {
- company_id?: string;
- name?: string;
-}
diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts
index d616f6f..2e38a7b 100644
--- a/solution/frontend/src/api/generated/model/index.ts
+++ b/solution/frontend/src/api/generated/model/index.ts
@@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation';
export * from './authProvider';
export * from './buildStatus';
export * from './checkSlugParams';
-export * from './companyData';
export * from './errorInfo';
export * from './errorInfoCode';
export * from './errorInfoDesc';
@@ -83,6 +82,7 @@ export * from './mySiteData';
export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt';
+export * from './mySiteDataThumbnailUrl';
export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId';
export * from './mySiteDataStatus';
@@ -139,7 +139,6 @@ export * from './reqGoogleLogin';
export * from './reqLogin';
export * from './reqPublishLocalContent';
export * from './reqSignup';
-export * from './reqSignupCompanyName';
export * from './reqSignupName';
export * from './reqSiteSlug';
export * from './reqSiteStatus';
@@ -222,7 +221,6 @@ export * from './resLocalContentListMsg';
export * from './resLogin';
export * from './resLoginMsg';
export * from './resMe';
-export * from './resMeCompany';
export * from './resMeContactNumber';
export * from './resMeEmail';
export * from './resMeMsg';
diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts
index cb463b1..b3ef1d2 100644
--- a/solution/frontend/src/api/generated/model/mySiteData.ts
+++ b/solution/frontend/src/api/generated/model/mySiteData.ts
@@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
+import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl';
/**
* 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site).
@@ -32,5 +33,7 @@ export interface MySiteData {
domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt;
+ /** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */
+ thumbnail_url?: MySiteDataThumbnailUrl;
needs_rebuild?: boolean;
}
diff --git a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts
similarity index 68%
rename from solution/frontend/src/api/generated/model/reqSignupCompanyName.ts
rename to solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts
index 5b6614a..16daca9 100644
--- a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts
+++ b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts
@@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
-export type ReqSignupCompanyName = string | null;
+export type MySiteDataThumbnailUrl = string | null;
diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts
index 92eac8d..ee94833 100644
--- a/solution/frontend/src/api/generated/model/placeSearchItem.ts
+++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts
@@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/**
* 공개 검색 결과 1건.
-★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은
+★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.
diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts
index 61d3a32..ca5de09 100644
--- a/solution/frontend/src/api/generated/model/reqSignup.ts
+++ b/solution/frontend/src/api/generated/model/reqSignup.ts
@@ -5,10 +5,9 @@
* OpenAPI spec version: 0.1.0
*/
import type { ReqSignupName } from './reqSignupName';
-import type { ReqSignupCompanyName } from './reqSignupCompanyName';
/**
- * id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다.
+ * id/pw 가입. 가입 = 계정 1개다.
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
@@ -19,5 +18,4 @@ export interface ReqSignup {
password?: string;
name?: ReqSignupName;
email?: string;
- company_name?: ReqSignupCompanyName;
}
diff --git a/solution/frontend/src/api/generated/model/resMe.ts b/solution/frontend/src/api/generated/model/resMe.ts
index 84fa745..2441fe4 100644
--- a/solution/frontend/src/api/generated/model/resMe.ts
+++ b/solution/frontend/src/api/generated/model/resMe.ts
@@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole';
import type { AuthProvider } from './authProvider';
-import type { ResMeCompany } from './resMeCompany';
export interface ResMe {
result?: ErrorInfo;
@@ -23,5 +22,4 @@ export interface ResMe {
contact_number?: ResMeContactNumber;
role?: UserRole;
provider?: AuthProvider;
- company?: ResMeCompany;
}
diff --git a/solution/frontend/src/api/generated/model/resMeCompany.ts b/solution/frontend/src/api/generated/model/resMeCompany.ts
deleted file mode 100644
index 4a08cff..0000000
--- a/solution/frontend/src/api/generated/model/resMeCompany.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-/**
- * Generated by orval v7.21.0 🍺
- * Do not edit manually.
- * Web4Ai API
- * OpenAPI spec version: 0.1.0
- */
-import type { CompanyData } from './companyData';
-
-export type ResMeCompany = CompanyData | null;
diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts
index 5d3d0a7..b6374d3 100644
--- a/solution/frontend/src/api/generated/model/showcaseItem.ts
+++ b/solution/frontend/src/api/generated/model/showcaseItem.ts
@@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
* 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
- place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
+ place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.
*/
export interface ShowcaseItem {
diff --git a/solution/frontend/src/app/main.tsx b/solution/frontend/src/app/main.tsx
deleted file mode 100644
index 867493b..0000000
--- a/solution/frontend/src/app/main.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-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(
-
-
-
-
- ,
-);
diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx
deleted file mode 100644
index f8f6066..0000000
--- a/solution/frontend/src/app/router.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import {createBrowserRouter, Navigate} from 'react-router';
-import {Loader2} from 'lucide-react';
-import {AccountPage} from '@/pages/AccountPage';
-import {BuilderPage} from '@/pages/BuilderPage';
-import {DevShowcasePage} from '@/pages/DevShowcasePage';
-import {LandingPage} from '@/pages/LandingPage';
-import {LoginPage} from '@/pages/LoginPage';
-import {NotFoundPage} from '@/pages/NotFoundPage';
-import {PricingPage} from '@/pages/PricingPage';
-import {ShowcasePage} from '@/pages/ShowcasePage';
-import {SignupPage} from '@/pages/SignupPage';
-import {SitesPage} from '@/pages/SitesPage';
-import {RequireAuth} from '@/components/layout/RequireAuth';
-import {useAuthStore} from '@/stores/auth';
-
-/**
- * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라
- * "내 것 고치기"다. 비로그인은 **랜딩**을 본다.
- *
- * ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가
- * 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다.
- * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다.
- */
-function Home() {
- const isRestoring = useAuthStore((s) => s.isRestoring);
- const user = useAuthStore((s) => s.user);
-
- if (isRestoring) {
- return (
-
-
-
- );
- }
- if (user) return ;
- return ;
-}
-
-export const router = createBrowserRouter([
- {path: '/login', element: },
- // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
- {path: '/signup', element: },
-
- // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
- {path: '/', element: },
-
- // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
- {path: '/pricing', element: },
- {path: '/showcase', element: },
-
- // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
- {
- path: '/sites',
- element: (
-
-
-
- ),
- },
- {
- path: '/account',
- element: (
-
-
-
- ),
- },
-
- /**
- * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
- * 만들어 보기도 전에 막힌다.
- *
- * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
- * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
- *
- * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
- */
- {path: '/builder', element: },
-
- /**
- * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.
- * 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가
- * 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의
- * "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다.
- * (ARCHITECTURE.md 4절)
- */
-
- // 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다.
- ...(import.meta.env.DEV ? [{path: '/dev/showcase', element: }] : []),
-
- {path: '*', element: },
-]);
diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx
index 990aba0..da25315 100644
--- a/solution/frontend/src/components/layout/AppShell.tsx
+++ b/solution/frontend/src/components/layout/AppShell.tsx
@@ -1,6 +1,6 @@
import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
-import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
+import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react';
import {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth';
@@ -25,6 +25,15 @@ export type NavItem = {
const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
+ /*
+ * ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적)
+ * /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면
+ * 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다.
+ * 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다.
+ * ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지
+ * 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다.
+ */
+ {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt},
];
export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) {
@@ -73,7 +82,6 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
{userLabel(user)}
- {user.companyName ? ` · ${user.companyName}` : ''}
) : (
@@ -87,7 +95,8 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
onClick={() => {
signOut();
- navigate('/login');
+ // ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다.
+ navigate('/');
}}
className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
>
diff --git a/solution/frontend/src/components/layout/MarketingShell.tsx b/solution/frontend/src/components/layout/MarketingShell.tsx
index e52aa77..2acf72b 100644
--- a/solution/frontend/src/components/layout/MarketingShell.tsx
+++ b/solution/frontend/src/components/layout/MarketingShell.tsx
@@ -47,6 +47,11 @@ export function MarketingShell({children}: {children: ReactNode}) {
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */}
+ {/*
+ ★ [무료로 만들기] 를 뺐다 (2026-09-04, 사장님 지시)
+ 히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면
+ 어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다.
+ */}
{user ? (
) : (
- <>
- {/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */}
-
- 로그인
-
-
- 무료로 만들기
-
- >
+
+ 로그인
+
)}
diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx
new file mode 100644
index 0000000..75eb24f
--- /dev/null
+++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx
@@ -0,0 +1,14 @@
+import {Outlet} from 'react-router';
+import {RequireAuth} from './RequireAuth';
+
+/**
+ * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 로
+ * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다.
+ */
+export default function RequireAuthLayout() {
+ return (
+
+
+
+ );
+}
diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx
index 4ed1789..a41eee3 100644
--- a/solution/frontend/src/features/builder/CanvasView.tsx
+++ b/solution/frontend/src/features/builder/CanvasView.tsx
@@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {resolveVariant} from './canvas/registry';
+import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다.
// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const VIEWPORT_FRAME: Record = {
pc: 'w-full max-w-5xl shadow-md',
diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
index 9405014..3ec3925 100644
--- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
+++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx
@@ -1,6 +1,7 @@
import {useEffect, useState, type CSSProperties} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared';
+import {ORIGIN} from '@/lib/site';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
const CATEGORY_ICON: Record = {
@@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record = {
[PlaceCategory.CLINIC]: '피부과 · 성형외과',
};
-/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
-
+/**
+ * 발행 사이트 주소는 루트 상대경로로 온다(`/s/`).
+ *
+ * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은
+ * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다.
+ */
function siteHref(url: string): string {
- return `${window.location.protocol}//${PUBLISH_HOST}${url}`;
+ return `${ORIGIN}${url}`;
}
/**
diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx
index 713c468..110cbac 100644
--- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx
+++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx
@@ -320,8 +320,20 @@ export function Step3DataReview() {
가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면
주소 붙여넣기와 자동 찾기가 모두 열립니다.
+ {/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이
+ 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal
+ PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다
+ 만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다.
+ 버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */}
+
+
+ 이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다.
+ {' '}
+ 발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가
+ 없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다.
+
goToStep('template')}>
- 수집 없이 다음 단계로
+ 발행 없이 화면만 둘러보기
>
diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx
index 0e8aaac..755ea91 100644
--- a/solution/frontend/src/features/publish/PublishModal.tsx
+++ b/solution/frontend/src/features/publish/PublishModal.tsx
@@ -1,24 +1,33 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
+import {useNavigate} from 'react-router';
import {
AlertTriangle,
ArrowUpRight,
Check,
Copy,
ExternalLink,
+ Eye,
Loader2,
RefreshCw,
+ Search,
ServerCrash,
ShieldAlert,
+ Store,
} from 'lucide-react';
import {publishUrlString, toSlug} from '@o2o/shared';
+import {getAccessToken} from '@/api';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog';
+import {SignInForm} from '@/features/auth/SignInForm';
+import type {WizardStep} from '@/features/onboarding/wizardUrl';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
+import {useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
+import {PUBLISH_HOST} from '@/lib/site';
import {localValidate, SlugField, type SlugStatus} from './SlugField';
import {
GATE_REASON_LABEL,
@@ -29,7 +38,30 @@ import {
// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트
// 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
+
+/**
+ * 가게 확인이 안 끝난 사장님을 돌려보낼 곳.
+ *
+ * ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날
+ * 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep).
+ */
+const PLACE_SEARCH_STEP: WizardStep = 'search';
+const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`;
+
+/**
+ * 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다.
+ *
+ * ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고
+ * 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라
+ * 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다.
+ * ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의
+ * [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데
+ * 발행만 안 된다.
+ */
+type PublishBlocker = 'signin' | 'place';
export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
@@ -54,11 +86,28 @@ export function PublishModal() {
/**
* 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링).
- * ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다.
+ * ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다.
+ * PublishBlocker 주석 참고.
*/
const publisher = usePublishSite(placeId);
const {state, reset} = publisher;
+ const navigate = useNavigate();
+
+ /*
+ * ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도
+ * 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 —
+ * 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다.
+ */
+ useAuthStore((s) => s.user);
+
+ /** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */
+ const blocker: PublishBlocker | null = publisher.isLive
+ ? null
+ : getAccessToken()
+ ? 'place'
+ : 'signin';
+
const gate = useMemo(
() =>
runPublishGate({
@@ -107,7 +156,12 @@ export function PublishModal() {
if (!isOpen) reset();
}, [isOpen, reset]);
- const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl));
+ /**
+ * ★ 서버가 발행을 확정한 것만 '발행됨'이다.
+ * 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이
+ * 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다.
+ */
+ const isDone = state.phase === 'published';
/**
* 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다.
@@ -146,25 +200,22 @@ export function PublishModal() {
}, [slug, placeId]);
const handlePublish = async () => {
- if (!gate.canPublish || publisher.isPublishing || !slugReady) return;
+ // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
+ // 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
+ if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
- if (publisher.isLive) {
- // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
- // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
- if (!isSlugLocked) {
- try {
- await reserveSiteSlug(placeId ?? '', slug);
- } catch (error) {
- notifyApiError(error, '주소를 확정하지 못했습니다.');
- return;
- }
+ // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
+ // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
+ if (!isSlugLocked) {
+ try {
+ await reserveSiteSlug(placeId ?? '', slug);
+ } catch (error) {
+ notifyApiError(error, '주소를 확정하지 못했습니다.');
+ return;
}
- // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
- publisher.publish();
- return;
}
- setPublishedUrl(url);
- notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.');
+ // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
+ publisher.publish();
};
const handleCopy = async () => {
@@ -181,11 +232,25 @@ export function PublishModal() {
- {isDone ? (
+ {/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의
+ 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */}
+ {blocker === 'signin' ? null : blocker === 'place' ? (
+ {
+ // 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
+ // 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
+ close();
+ navigate(PLACE_SEARCH_URL);
+ }}
+ >
+
+ 내 가게 확인하러 가기
+
+ ) : isDone ? (
+ {blocker === 'signin' &&
}
+ {blocker === 'place' &&
}
+
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
- {publisher.isLive && !isDone && (
+ {!blocker && !isDone && (
)}
- {/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */}
- {!isDone && (
+ {/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다.
+ ★ blocker 상태에서는 그리지 않는다. 주소 중복 확인이 사업장·토큰을 요구하므로
+ 입력칸만 열어 두면 확인 버튼이 매번 실패한다. */}
+ {!blocker && !isDone && (
)}
- {!isDone && gate.blockers.length > 0 && (
+ {!blocker && !isDone && gate.blockers.length > 0 && (
@@ -270,7 +356,7 @@ export function PublishModal() {
)}
- {!isDone && gate.warnings.length > 0 && (
+ {!blocker && !isDone && gate.warnings.length > 0 && (
@@ -282,7 +368,7 @@ export function PublishModal() {
)}
- {!isDone && gate.findings.length === 0 && state.phase === 'idle' && (
+ {!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
@@ -316,6 +402,71 @@ export function PublishModal() {
);
}
+/**
+ * 로그인 전 — **미리보기라고 말하고, 그 자리에서 로그인을 받는다.**
+ *
+ * ★ /login 으로 튕기지 않는다. 위저드·에디터 상태는 브라우저에 저장하지 않으므로
+ * (stores/builder 주석) 화면을 떠나는 순간 지금까지 만든 것이 통째로 사라진다.
+ * 에디터 관문(EditorSignInGate)이 같은 이유로 같은 폼을 그 자리에 놓는다.
+ * ★ 로그인이 끝나면 폼이 심은 토큰으로 blocker 가 저절로 다시 계산된다 — 이 패널이 사라지고
+ * 발행 점검 화면이 뜬다(사업장이 아직 없으면 아래 PlaceFirstPanel 로 넘어간다).
+ */
+function SignInFirstPanel() {
+ return (
+
+
+
+
+ 여기까지는 미리보기입니다
+
+
+ 발행하면 검색·AI 가 읽는 정적 페이지(HTML)가 구워집니다. 그 페이지는 계정과 실제
+ 가게에 묶이므로, 로그인과 내 가게 확인이 끝나야 시작할 수 있습니다.
+
+
+ → 여기서 로그인하시면 지금까지 만든 내용은 그대로 둔 채 이어집니다.
+
+
+
+
+ 발행본은 나중에 고치고 내릴 수 있어야 해서 계정에 묶어 둡니다.
+
+ }
+ />
+
+
+ );
+}
+
+/**
+ * 로그인은 했는데 서버에 사업장이 없다 — 3단계에서 수집을 건너뛰고 나온 경로다.
+ *
+ * ★ 여기서 사업장을 몰래 만들지 않는다. 생성과 네이버 검증의 순서는
+ * features/onboarding/ensureServerPlace 한 곳이 소유한다 — 검증을 건너뛰고 만든 사업장은
+ * 수집도 발행도 못 하는 껍데기로 남고, 그 사실은 화면에 안 나온다.
+ */
+function PlaceFirstPanel() {
+ return (
+
+
+
+ 내 가게가 아직 확인되지 않았습니다
+
+
+ 발행본은 실제 가게 한 곳에 묶입니다 — 주소·전화·영업시간이 그 가게의 것이라는 근거가
+ 있어야 검색·AI 가 이 페이지를 출처로 씁니다. 직접 입력으로만 진행하셨다면 그 가게가
+ 아직 서버에 없어서, 발행을 눌러도 구울 대상이 없습니다.
+
+
+ → 상호로 내 가게를 찾아 확인하시면 그때부터 발행이 열립니다.
+
+
+ );
+}
+
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
/**
* 서버가 보는 현재 발행 상태 한 줄.
diff --git a/solution/frontend/src/lib/site.ts b/solution/frontend/src/lib/site.ts
new file mode 100644
index 0000000..eedc0d1
--- /dev/null
+++ b/solution/frontend/src/lib/site.ts
@@ -0,0 +1,15 @@
+/**
+ * 이 앱이 서비스되는 공개 주소.
+ *
+ * ★ 호스트를 문자열로 두 곳에 적지 않는다 — compose 가 루트 `SITE_PUBLIC_HOST` 를
+ * `VITE_PUBLISH_HOST` 로 흘려보낸다(AGENTS.md). 여기가 프론트에서의 유일한 자리다.
+ * ★ `window.location` 으로 떨어지지 않는다. 프리렌더는 브라우저 없이 도는데,
+ * 그때 window 를 만지면 빌드가 죽는다.
+ */
+const HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai';
+
+/** `https://web4ai.o2osolution.ai` — 끝 슬래시 없음. */
+export const ORIGIN = `https://${HOST}`;
+
+/** 발행 사이트 주소를 만들 때 쓰는 호스트. */
+export const PUBLISH_HOST = HOST;
diff --git a/solution/frontend/src/pages/AccountPage.tsx b/solution/frontend/src/pages/AccountPage.tsx
index 79fa550..0d07484 100644
--- a/solution/frontend/src/pages/AccountPage.tsx
+++ b/solution/frontend/src/pages/AccountPage.tsx
@@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
/**
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
*
- * ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
- * 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
+ * ★ 상호 칸은 없다. 회사(테넌트)를 걷어내면서(2026-09-08) 계정에 상호가 없어졌다 —
+ * 가게 이름은 사업장(place)이 갖는다.
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
* 입력칸 자체를 그리지 않는다.
*/
@@ -77,13 +77,6 @@ export function AccountPage() {
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
-
-
- {data?.company?.name ?? '-'}
-
- 상호 변경은 고객센터로 문의해 주세요.
-
-
@@ -142,3 +135,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default AccountPage;
diff --git a/solution/frontend/src/pages/BuilderPage.tsx b/solution/frontend/src/pages/BuilderPage.tsx
index bedb166..653e16f 100644
--- a/solution/frontend/src/pages/BuilderPage.tsx
+++ b/solution/frontend/src/pages/BuilderPage.tsx
@@ -18,9 +18,12 @@ import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder';
+import {ORIGIN} from '@/lib/site';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
-const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL || ORIGIN;
/** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
@@ -209,10 +212,9 @@ export function BuilderPage() {
{userLabel(user)}
- {user.companyName ? ` · ${user.companyName}` : ''}
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default BuilderPage;
diff --git a/solution/frontend/src/pages/DevShowcasePage.tsx b/solution/frontend/src/pages/DevShowcasePage.tsx
index 1a0dfc0..1be8b83 100644
--- a/solution/frontend/src/pages/DevShowcasePage.tsx
+++ b/solution/frontend/src/pages/DevShowcasePage.tsx
@@ -701,3 +701,6 @@ function GroupList({
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default DevShowcasePage;
diff --git a/solution/frontend/src/pages/LandingPage.tsx b/solution/frontend/src/pages/LandingPage.tsx
index 0698917..8352693 100644
--- a/solution/frontend/src/pages/LandingPage.tsx
+++ b/solution/frontend/src/pages/LandingPage.tsx
@@ -4,6 +4,44 @@ import {ArrowRight, ArrowUp, Check} from 'lucide-react';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+/**
+ * ★ 제목은 브랜드가 아니라 **사람이 검색창에 치는 말**로 시작한다. "Web4Ai" 는 검색량이
+ * 없는 신규 이름이고, 예전 제목("Web4Ai · AI 웹 빌더")에는 검색어가 한 단어도 없었다.
+ * seo 규칙: 핵심 키워드 앞, 브랜드 뒤, 50~60자.
+ */
+export const meta: MetaFunction = () => [
+ {title: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로 | Web4Ai'},
+ {
+ name: 'description',
+ content:
+ '네이버 플레이스만 있으면 우리 가게 홈페이지가 만들어집니다. 주소·영업시간·메뉴가 구조화 데이터로 함께 나가 검색엔진과 AI 답변이 함께 읽습니다. 사업자가 확인한 정보만 발행합니다.',
+ },
+ {name: 'robots', content: 'index, follow, max-snippet:-1, max-image-preview:large'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/`},
+ {property: 'og:title', content: '가게 홈페이지 만들기 · AI 검색에 인용되는 방식으로'},
+ {
+ property: 'og:description',
+ content: '네이버 플레이스만 있으면 검색·AI 답변이 함께 읽는 우리 가게 홈페이지가 만들어집니다.',
+ },
+ {property: 'og:url', content: `${ORIGIN}/`},
+ // 이 앱이 무엇인지 기계에게 말하는 유일한 자리다. 발행본에는 LodgingBusiness 가 나가는데
+ // 정작 랜딩에는 구조화 데이터가 하나도 없었다.
+ {
+ 'script:ld+json': {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ '@id': `${ORIGIN}/#org`,
+ name: 'Web4Ai',
+ url: `${ORIGIN}/`,
+ logo: `${ORIGIN}/brand/favicon-w4a.svg`,
+ description:
+ '네이버 플레이스 정보를 사업자가 확인해 가게 공식 홈페이지로 발행하는 서비스. 검색엔진과 AI 답변엔진이 함께 읽는 형식으로 내보냅니다.',
+ },
+ },
+];
+
/**
* 히어로 제목의 앞말. '홈페이지' 는 고정이고 이 여섯이 돌아간다.
@@ -279,3 +317,6 @@ export function LandingPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default LandingPage;
diff --git a/solution/frontend/src/pages/LoginPage.tsx b/solution/frontend/src/pages/LoginPage.tsx
index 863eb26..2733f7a 100644
--- a/solution/frontend/src/pages/LoginPage.tsx
+++ b/solution/frontend/src/pages/LoginPage.tsx
@@ -17,7 +17,22 @@ import {useAuthStore} from '@/stores/auth';
* 우리가 만들어 준다. 가입 링크를 두 곳에 다 두면 admin 라우터에 없는 `/signup` 으로 보내
* 404 가 난다(admin/src/app/router.tsx 에 그 경로는 없다).
*/
-export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
+export function LoginPage({
+ selfServe = true,
+ // 로그인 직후엔 내 사이트로. 예전엔 router.tsx 가 넘기던 값이다.
+ homePath = '/sites',
+}: {
+ selfServe?: boolean;
+ /**
+ * 로그인 직후 도착지.
+ *
+ * ★ 예전엔 '/' 로 보내고 각 앱의 '/' 리다이렉트가 알아서 가르게 했다. 그런데 사장님 앱에서
+ * 그 관문이 **로그인한 사람에게 랜딩을 영영 못 보게** 만들었다 — 로고를 눌러도 '/' 가
+ * /sites 로 튕긴다(사장님 지적). 관문을 걷고 도착지를 여기서 받는다.
+ * ★ 기본값은 '/' 다. 내부 운영 앱은 자기 '/' 가 사업장 목록으로 보내므로 그대로 맞는다.
+ */
+ homePath?: string;
+}) {
const navigate = useNavigate();
const location = useLocation();
const user = useAuthStore((s) => s.user);
@@ -28,9 +43,8 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
- // ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록)
- // 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다.
- const from = (location.state as {from?: string} | null)?.from ?? '/';
+ // 원래 가려던 곳이 있으면 그리로, 없으면 앱이 준 홈으로.
+ const from = (location.state as {from?: string} | null)?.from ?? homePath;
if (user) return
;
@@ -82,11 +96,10 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
-
+ {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. 로그인 화면에서 나갈 길이 여기뿐이었다. */}
+
+
+
{selfServe ? '로그인' : '관리자'}
@@ -153,3 +166,6 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default LoginPage;
diff --git a/solution/frontend/src/pages/NotFoundPage.tsx b/solution/frontend/src/pages/NotFoundPage.tsx
index 9170b7b..e103633 100644
--- a/solution/frontend/src/pages/NotFoundPage.tsx
+++ b/solution/frontend/src/pages/NotFoundPage.tsx
@@ -14,3 +14,6 @@ export function NotFoundPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default NotFoundPage;
diff --git a/solution/frontend/src/pages/PricingPage.tsx b/solution/frontend/src/pages/PricingPage.tsx
index 0388cb7..98f7d8b 100644
--- a/solution/frontend/src/pages/PricingPage.tsx
+++ b/solution/frontend/src/pages/PricingPage.tsx
@@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+export const meta: MetaFunction = () => [
+ {title: '요금 · 가게 홈페이지 제작 비용 | Web4Ai'},
+ {
+ name: 'description',
+ content: 'Web4Ai 로 가게 홈페이지를 만들고 발행하는 데 드는 비용입니다. 발행 후에도 AI 인용 상태를 계속 확인합니다.',
+ },
+ {name: 'robots', content: 'index, follow'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/pricing`},
+ {property: 'og:title', content: '요금 · 가게 홈페이지 제작 비용'},
+ {property: 'og:url', content: `${ORIGIN}/pricing`},
+];
+
/**
* 요금 — 플랜 하나다.
@@ -107,3 +121,6 @@ export function PricingPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default PricingPage;
diff --git a/solution/frontend/src/pages/ShowcasePage.tsx b/solution/frontend/src/pages/ShowcasePage.tsx
index 7dd3fed..fcafe95 100644
--- a/solution/frontend/src/pages/ShowcasePage.tsx
+++ b/solution/frontend/src/pages/ShowcasePage.tsx
@@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button';
+import type {MetaFunction} from 'react-router';
+import {ORIGIN} from '@/lib/site';
+export const meta: MetaFunction = () => [
+ {title: '먼저 시작한 가게들 · 발행 사례 | Web4Ai'},
+ {
+ name: 'description',
+ content: 'Web4Ai 로 실제 발행된 가게 홈페이지 사례입니다. 모든 정보는 사업자가 확인한 것만 담겨 있습니다.',
+ },
+ {name: 'robots', content: 'index, follow'},
+ {tagName: 'link', rel: 'canonical', href: `${ORIGIN}/showcase`},
+ {property: 'og:title', content: '먼저 시작한 가게들 · 발행 사례'},
+ {property: 'og:url', content: `${ORIGIN}/showcase`},
+];
+
/**
* 이렇게 나옵니다 — 실제로 발행된 홈페이지 목록.
@@ -35,3 +49,6 @@ export function ShowcasePage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default ShowcasePage;
diff --git a/solution/frontend/src/pages/SignupPage.tsx b/solution/frontend/src/pages/SignupPage.tsx
index 4d2691b..3c355f6 100644
--- a/solution/frontend/src/pages/SignupPage.tsx
+++ b/solution/frontend/src/pages/SignupPage.tsx
@@ -31,11 +31,10 @@ export function SignupPage() {
passwordConfirm: '',
name: '',
email: '',
- companyName: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
- if (user) return ;
+ if (user) return ;
const set = (key: keyof typeof form) => (e: {target: {value: string}}) =>
setForm((prev) => ({...prev, [key]: e.target.value}));
@@ -63,7 +62,6 @@ export function SignupPage() {
password: form.password,
name: form.name.trim() || null,
email: form.email.trim(),
- company_name: form.companyName.trim() || null,
});
if (res.result?.success === false) {
notifyApiError({data: res}, '가입하지 못했습니다.');
@@ -76,7 +74,7 @@ export function SignupPage() {
return;
}
notify.success('가입이 완료되었습니다.');
- navigate('/', {replace: true});
+ navigate('/sites', {replace: true});
} catch (error) {
notifyApiError(error, '가입하지 못했습니다.');
} finally {
@@ -97,7 +95,7 @@ export function SignupPage() {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
- navigate('/', {replace: true});
+ navigate('/sites', {replace: true});
} catch (error) {
notifyApiError(error, '구글로 가입하지 못했습니다.');
} finally {
@@ -112,7 +110,10 @@ export function SignupPage() {
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
-
+ {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. */}
+
+
+
회원가입
내 가게 사이트를 만들려면 계정이 필요합니다.
@@ -177,17 +178,6 @@ export function SignupPage() {
required
/>
-
-
- 상호 (선택)
-
-
-
@@ -216,3 +206,6 @@ export function SignupPage() {
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default SignupPage;
diff --git a/solution/frontend/src/pages/SitesPage.tsx b/solution/frontend/src/pages/SitesPage.tsx
index f6f10e7..862ab78 100644
--- a/solution/frontend/src/pages/SitesPage.tsx
+++ b/solution/frontend/src/pages/SitesPage.tsx
@@ -1,4 +1,4 @@
-import {useState} from 'react';
+import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {
Building2,
@@ -8,19 +8,27 @@ import {
MoreHorizontal,
Pencil,
Plus,
+ Search,
+ SearchX,
Stethoscope,
UtensilsCrossed,
Wand2,
+ X,
} from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
+import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify';
+import {PUBLISH_HOST} from '@/lib/site';
+import {cn} from '@/lib/utils';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
-const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
+// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
+// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
+// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const CATEGORY_ICON: Record = {
[PlaceCategory.LODGING]: Building2,
@@ -29,24 +37,51 @@ const CATEGORY_ICON: Record = {
[PlaceCategory.CLINIC]: Stethoscope,
};
+/**
+ * 줄이 속하는 칸. **배지·필터·정렬이 전부 이 하나로 판정한다.**
+ *
+ * ★ 판정을 갈라 쓰면 조용히 틀린다 — '발행됨(1)' 을 눌렀는데 '내림' 배지가 낀 줄이 같이
+ * 나오는 종류다. 건수까지 틀리므로 사장님은 목록을 못 믿게 된다.
+ * ★ 세 칸이 목록을 빈틈없이 나눈다. 하나라도 빠지면 '전체' 건수와 칸 건수의 합이 어긋난다.
+ */
+type SiteBucket = 'live' | 'draft';
+type SiteFilter = SiteBucket | 'all';
+
+/**
+ * ★ 칸은 **둘**이다. 예전엔 '만드는 중'(사이트 행 없음)을 따로 뒀는데, 사장님에게 그 둘은
+ * 같은 상태다 — "아직 안 나가 있다". `site_id` 가 있고 없고는 우리 DB 사정이지 사장님의
+ * 구분이 아니고, 칸이 셋이면 34개가 어디 있는지 두 번 세게 된다.
+ */
+const FILTER_LABEL: Record = {
+ all: '전체',
+ live: '발행됨',
+ draft: '발행 전',
+};
+
+const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft'];
+
+/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */
+const BUCKET_ORDER: Record = {live: 0, draft: 1};
+
+function bucketOf(row: MySiteData): SiteBucket {
+ return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft';
+}
+
/**
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
*/
function statusBadge(row: MySiteData) {
- if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const};
- switch (row.status) {
- case SiteStatus.PUBLISHED:
- return row.needs_rebuild
- ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
- : {label: '발행됨', variant: 'success' as const};
- case SiteStatus.SUSPENDED:
- return {label: '중지', variant: 'outline' as const};
- case SiteStatus.UNPUBLISHED:
- return {label: '내림', variant: 'outline' as const};
- default:
- return {label: '발행 전', variant: 'default' as const};
+ if (bucketOf(row) === 'live') {
+ return row.needs_rebuild
+ ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
+ : {label: '발행됨', variant: 'success' as const};
}
+ // 같은 '발행 전' 이어도 **한 번 나갔다가 내린 것**은 말해 준다 — 되돌리는 일과 처음 내는 일은
+ // 사장님이 할 행동이 다르다. 그 외(초안·수집 중)는 전부 '발행 전' 한 마디다.
+ if (row.status === SiteStatus.SUSPENDED) return {label: '중지', variant: 'outline' as const};
+ if (row.status === SiteStatus.UNPUBLISHED) return {label: '내림', variant: 'outline' as const};
+ return {label: '발행 전', variant: 'default' as const};
}
/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
@@ -55,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null {
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
}
+/**
+ * 줄 뒤에 붙는 시각. **발행됐으면 발행일, 아니면 만든 날**이다.
+ *
+ * ★ 두 개를 같이 걸지 않는다. Wix·아임웹 목록이 시각을 한 칸만 쓰는 이유와 같다 —
+ * 목록에서 궁금한 건 "이게 언제 나갔나" 하나이고, 아직 안 나간 줄에만 만든 날이 의미가 있다.
+ * ★ 연도는 올해면 뗀다. 줄이 좁아 주소가 먼저 잘린다.
+ */
+function whenLabel(row: MySiteData): string {
+ const raw = row.published_at ?? row.created_at;
+ if (!raw) return '';
+ const at = new Date(raw);
+ if (Number.isNaN(at.getTime())) return '';
+ const now = new Date();
+ const date = at.toLocaleDateString('ko-KR', {
+ ...(at.getFullYear() === now.getFullYear() ? {} : {year: 'numeric'}),
+ month: 'long',
+ day: 'numeric',
+ });
+ return row.published_at ? `${date} 발행` : `${date} 만듦`;
+}
+
+/** 정렬용 시각. whenLabel 이 고른 값과 같은 것을 쓴다 — 화면에 보이는 날짜와 순서가 갈라지면 안 된다. */
+function rowTime(row: MySiteData): number {
+ const raw = row.published_at ?? row.created_at;
+ if (!raw) return 0;
+ const at = new Date(raw).getTime();
+ return Number.isNaN(at) ? 0 : at;
+}
+
+/**
+ * 검색 비교용 정규화. **공백을 지운다** — 실측(계정 test)에 같은 상호 '버터브루' 가 4줄인데
+ * 사장님은 '버터 브루' 로도 친다. 한국어 상호는 띄어쓰기가 원본마다 다르다.
+ */
+function normalizeText(value: string): string {
+ return value.toLowerCase().replace(/\s+/g, '');
+}
+
+/** 상호와 도로명 주소만 본다 — 상호가 겹치는 줄을 실제로 가르는 건 주소다. */
+function matchesQuery(row: MySiteData, needle: string): boolean {
+ if (!needle) return true;
+ return normalizeText(row.name).includes(needle) || normalizeText(row.road_address ?? '').includes(needle);
+}
+
+/**
+ * 줄 앞의 그림. 발행에 성공한 사이트만 썸네일이 있고(sites.thumbnail_url),
+ * 없으면 업종 아이콘으로 떨어진다.
+ *
+ * ★ 주소에 `?v=<버전>` 이 붙어 있다 — 발행할 때마다 바뀐다(site_thumbnail.public_url).
+ * 그래서 에 캐시 무효화를 따로 걸지 않는다. 여기서 또 붙이면 발행하지 않은
+ * 재방문에도 매번 새로 받는다.
+ * ★ 못 받으면 아이콘으로 되돌린다. 블롭이 지워졌거나 옛 주소인 줄에서 깨진 그림이
+ * 뜨는 것보다 낫다.
+ */
+function SiteThumb({row, Icon}: {row: MySiteData; Icon: typeof Building2}) {
+ const [failed, setFailed] = useState(false);
+ const src = row.thumbnail_url;
+
+ // ★ 비율은 **16:10 — 브라우저 창 비율**이다. 이 그림은 사이트 미리보기라 1:1 로 자르면
+ // 위아래가 잘려 무슨 사이트인지 알아볼 수 없다(사진첩이 아니다).
+ // ★ 크기는 아임웹 내사이트 화면을 보고 키웠다(2026-09-08). 거기 썸네일도 줄 높이의 대부분을
+ // 차지한다 — 같은 상호가 여러 줄일 때 그림이 유일한 구분자인데 작으면 있으나 마나다.
+ if (!src || failed) {
+ return (
+
+
+
+ );
+ }
+ return (
+ setFailed(true)}
+ className="aspect-[16/10] w-full border-b border-border object-cover"
+ />
+ );
+}
+
/**
* 내 사이트 — 로그인한 사장님의 홈이다.
*
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
+ *
+ * ★ 목록이 하는 일은 셋뿐이다 — ① 어느 게 어느 건지 가르고 ② 지금 상태를 말하고 ③ 여는 길을 준다.
+ * 방문자·주문 같은 숫자는 여기 넣지 않는다(Wix·아임웹도 사이트 안 대시보드에 둔다).
*/
export function SitesPage() {
const navigate = useNavigate();
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
const [busyId, setBusyId] = useState(null);
const [menuId, setMenuId] = useState(null);
+ const [query, setQuery] = useState('');
+ const [filter, setFilter] = useState('all');
- const rows = data?.sites ?? [];
+ const rows = useMemo(() => data?.sites ?? [], [data]);
+
+ // ★ 서버로 안 보낸다. 한 번에 50줄을 이미 다 받아 놓았고(실측 35줄), 서버 검색을 붙이면
+ // 글자마다 왕복 + 디바운스 + 늦게 온 응답이 최신 결과를 덮는 경합까지 따라온다.
+ // → 50줄을 넘어 페이지가 생기는 날이 오면 그때는 서버가 맞다(이 필터는 첫 페이지만 본다).
+ const found = useMemo(() => {
+ const needle = normalizeText(query);
+ return rows.filter((row) => matchesQuery(row, needle));
+ }, [rows, query]);
+
+ // ★ 건수는 **검색 결과 위에서** 센다. 검색어를 친 뒤 "발행됨 0 · 만드는 중 3" 이 보여야
+ // 찾는 게 어느 칸에 있는지 알 수 있다. 검색 전 건수를 그대로 두면 칸을 눌러 보고서야 안다.
+ const counts = useMemo(() => {
+ const tally: Record = {all: found.length, live: 0, draft: 0};
+ for (const row of found) tally[bucketOf(row)] += 1;
+ return tally;
+ }, [found]);
+
+ // 서버는 사업장 생성 역순으로만 준다(site_crud.list_owner_sites) — 발행 여부를 모른다.
+ // 나가 있는 것을 위로 올리는 건 여기서 한다.
+ const visible = useMemo(() => {
+ const picked = filter === 'all' ? found : found.filter((row) => bucketOf(row) === filter);
+ return [...picked].sort(
+ (a, b) => BUCKET_ORDER[bucketOf(a)] - BUCKET_ORDER[bucketOf(b)] || rowTime(b) - rowTime(a),
+ );
+ }, [found, filter]);
+
+ const isNarrowed = query.trim().length > 0 || filter !== 'all';
+
+ const resetView = () => {
+ setQuery('');
+ setFilter('all');
+ };
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
@@ -132,53 +283,145 @@ export function SitesPage() {
/>
)}
- {rows.length > 0 && (
-
- {rows.map((row) => {
+ {!isLoading && !isError && rows.length > 0 && (
+
+
+
+ setQuery(event.target.value)}
+ placeholder="상호나 주소로 찾기"
+ aria-label="상호나 주소로 찾기"
+ className="h-9 pr-8 pl-8"
+ />
+ {query && (
+ setQuery('')}
+ className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
+ >
+
+
+ )}
+
+
+ {/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
+
+ {FILTER_TABS.map((key) => (
+ setFilter(key)}
+ className={cn(
+ 'h-8 cursor-pointer rounded px-2.5 text-xs font-medium transition-colors',
+ filter === key
+ ? 'bg-muted text-foreground'
+ : 'text-muted-foreground hover:text-foreground',
+ )}
+ >
+ {FILTER_LABEL[key]}
+ {counts[key]}
+
+ ))}
+
+
+ )}
+
+ {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면
+ 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */}
+ {!isLoading && !isError && rows.length > 0 && visible.length === 0 && (
+
+ 조건 지우기
+
+ }
+ />
+ )}
+
+ {visible.length > 0 && (
+ /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은
+ 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라
+ (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다.
+ 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */
+
+ {visible.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row);
const url = publishedUrl(row);
+ const isLive = bucketOf(row) === 'live';
const editHref = `/builder?placeId=${row.place_id}`;
return (
-
-
-
-
-
- {row.name}
- {badge.label}
-
-
- {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
-
+ /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일'
+ 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적).
+ h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */
+
+
+
-
- {url && (
-
+ {badge.label}
+
+
+
+
+
{row.name}
+ {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라
+ 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */}
+
-
- 사이트 열기
-
- )}
- navigate(editHref)}>
-
- {row.site_id ? '편집' : '이어서 만들기'}
-
- setMenuId(menuId === row.place_id ? null : row.place_id)}
- >
- {busyId === row.place_id ? null : }
-
+ {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
+
+ {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
+
+ {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
+
+
+
+
+ {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이
+ 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */}
+
navigate(editHref)}>
+
+ 편집
+
+ {url && (
+
+
+ 열기
+
+ )}
+
setMenuId(menuId === row.place_id ? null : row.place_id)}
+ >
+ {busyId === row.place_id ? null : }
+
+
{menuId === row.place_id && (
@@ -190,7 +433,7 @@ export function SitesPage() {
className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)}
/>
-
+
)}
+
);
}
+
+// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
+export default SitesPage;
diff --git a/solution/frontend/src/root.tsx b/solution/frontend/src/root.tsx
new file mode 100644
index 0000000..cece0f4
--- /dev/null
+++ b/solution/frontend/src/root.tsx
@@ -0,0 +1,82 @@
+import {isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration} from 'react-router';
+import type {LinksFunction, MetaFunction} from 'react-router';
+import {Providers} from '@/app/provider';
+import './index.css';
+
+/**
+ * 문서 껍데기. 예전 `index.html` 이 하던 일을 여기서 한다 —
+ * 프레임워크 모드에는 index.html 이 없고, 이 컴포넌트가 부터 그린다.
+ *
+ * ★ 발행 호스트를 여기 문자열로 적지 않는다. `VITE_PUBLISH_HOST` 는 compose 가 루트의
+ * `SITE_PUBLIC_HOST` 를 흘려보내는 값이다 — 두 곳에 적으면 canonical 과 화면 주소가
+ * 조용히 갈라진다(AGENTS.md).
+ */
+const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST || 'web4ai.o2osolution.ai';
+export const ORIGIN = `https://${PUBLISH_HOST}`;
+
+export const links: LinksFunction = () => [
+ {rel: 'icon', type: 'image/svg+xml', href: '/brand/favicon-w4a.svg?v=14'},
+ // self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망
+ {rel: 'preconnect', href: 'https://fonts.googleapis.com'},
+ {rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous'},
+ {
+ rel: 'stylesheet',
+ href: 'https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap',
+ },
+ // 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다
+ {
+ rel: 'stylesheet',
+ href: 'https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap',
+ },
+];
+
+/** 라우트가 자기 것을 안 내놓을 때의 기본값. 각 페이지는 `meta` 를 export 해 덮어쓴다. */
+export const meta: MetaFunction = () => [
+ {title: 'Web4Ai'},
+ {property: 'og:site_name', content: 'Web4Ai'},
+ {property: 'og:locale', content: 'ko_KR'},
+ {property: 'og:type', content: 'website'},
+ {name: 'twitter:card', content: 'summary_large_image'},
+];
+
+export function Layout({children}: {children: React.ReactNode}) {
+ return (
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+export default function Root() {
+ return (
+
+
+
+ );
+}
+
+export function ErrorBoundary({error}: {error: unknown}) {
+ const is404 = isRouteErrorResponse(error) && error.status === 404;
+ return (
+
+
{is404 ? '404' : '오류'}
+
+ {is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'}
+
+
+ 홈으로
+
+
+ );
+}
diff --git a/solution/frontend/src/routes.ts b/solution/frontend/src/routes.ts
new file mode 100644
index 0000000..dac2bb8
--- /dev/null
+++ b/solution/frontend/src/routes.ts
@@ -0,0 +1,42 @@
+import {index, layout, route, type RouteConfig} from '@react-router/dev/routes';
+
+/**
+ * 라우트 표. 예전 `app/router.tsx` 의 배열이 여기로 왔다.
+ *
+ * ★ 순서·경로는 그대로다. 바뀐 건 선언 방식뿐이고, 페이지 컴포넌트는 손대지 않았다.
+ * ★ 프리렌더 대상은 여기가 아니라 `react-router.config.ts` 가 정한다.
+ * 목록에 없는 경로는 지금까지처럼 SPA 폴백으로 나간다.
+ */
+export default [
+ // 비로그인 = 랜딩. ★ 예전엔 로그인 여부로 갈랐는데 지금은 둘 다 랜딩이다
+ // (2026-09-04, 사장님 지적: 로그인하면 랜딩·요금에 갈 길이 없었다).
+ index('pages/LandingPage.tsx'),
+
+ route('login', 'pages/LoginPage.tsx'),
+ // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
+ route('signup', 'pages/SignupPage.tsx'),
+
+ // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
+ route('pricing', 'pages/PricingPage.tsx'),
+ route('showcase', 'pages/ShowcasePage.tsx'),
+
+ // 로그인한 사장님의 홈. 예전엔 페이지마다 로 감쌌는데,
+ // 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다.
+ layout('components/layout/RequireAuthLayout.tsx', [
+ route('sites', 'pages/SitesPage.tsx'),
+ route('account', 'pages/AccountPage.tsx'),
+ ]),
+
+ /**
+ * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
+ * 만들어 보기도 전에 막힌다. 세션은 useAutoLogin() 이 조용히 확보한다.
+ */
+ route('builder', 'pages/BuilderPage.tsx'),
+
+ // 개발 빌드에서만 열린다. 운영 번들에는 라우트 자체가 없다.
+ ...(process.env.NODE_ENV !== 'production'
+ ? [route('dev/showcase', 'pages/DevShowcasePage.tsx')]
+ : []),
+
+ route('*', 'pages/NotFoundPage.tsx'),
+] satisfies RouteConfig;
diff --git a/solution/frontend/src/stores/auth.ts b/solution/frontend/src/stores/auth.ts
index 82fad43..99fa40f 100644
--- a/solution/frontend/src/stores/auth.ts
+++ b/solution/frontend/src/stores/auth.ts
@@ -8,8 +8,6 @@ export interface AuthUser {
name?: string;
email?: string;
role: number;
- companyId?: string;
- companyName?: string;
}
/**
@@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser {
name: res.name ?? undefined,
email: res.email ?? undefined,
role: res.role ?? UserRole.USER,
- companyId: res.company?.company_id,
- companyName: res.company?.name,
};
}
diff --git a/solution/frontend/vite.config.ts b/solution/frontend/vite.config.ts
index 66f41dd..738d6bb 100644
--- a/solution/frontend/vite.config.ts
+++ b/solution/frontend/vite.config.ts
@@ -1,10 +1,12 @@
+import {reactRouter} from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
-import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig({
- plugins: [react(), tailwindcss()],
+ // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다.
+ // 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다.
+ plugins: [reactRouter(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
@@ -18,6 +20,9 @@ export default defineConfig({
'@o2o/shared': path.resolve(__dirname, '../shared/src'),
},
},
+ // ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).
+ // nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고
+ // 컨테이너는 정상으로 뜬다.
build: {
// ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른
// 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고
diff --git a/solution/shared/src/lib/color.ts b/solution/shared/src/lib/color.ts
index 821c275..4be2645 100644
--- a/solution/shared/src/lib/color.ts
+++ b/solution/shared/src/lib/color.ts
@@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string;
surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card,
inverse: '#1c1917',
- border: colors.secondary,
+ /**
+ * 선 색.
+ *
+ * ★ secondary 를 그대로 쓰면 안 된다 — 그건 '본문 다음으로 진한 글자색' 이라
+ * 선으로 쓰면 표와 카드가 격자무늬처럼 새까맣게 그어진다(갱지 팔레트에서 특히).
+ * 바탕에서 글자색 쪽으로 20%만 간 값이 목업의 선 색이다(#e4dac0 → #bcb49e).
+ */
+ border: mix(colors.bg, colors.text, 0.2),
};
}
diff --git a/solution/shared/src/lib/section-data.ts b/solution/shared/src/lib/section-data.ts
index be4c7ec..ce8cb5b 100644
--- a/solution/shared/src/lib/section-data.ts
+++ b/solution/shared/src/lib/section-data.ts
@@ -85,10 +85,16 @@ export interface PeopleItem {
role?: string;
oneLine?: string;
/**
- * 사진 검색어. ★ 이미지 URL 을 받지 않는다 — 초상권·저작권 확인은 사장님 몫이고,
- * 모델이 지어낸 주소를 링크하면 깨진 사진이 인물 얼굴 자리에 남는다(LocalPlace.searchQuery 와 같은 규약).
+ * 사진 검색어. 모델은 이 값만 만든다 — 주소를 지어내면 깨진 사진이 인물 얼굴 자리에 남는다.
*/
imageQuery?: string;
+ /**
+ * 초상 사진.
+ *
+ * ★ **모델이 채우는 칸이 아니다.** 권리가 확인된 출처(위키미디어 등)에서 사람이 넣거나,
+ * 수집기가 라이선스를 확인하고 넣는다. 초상권은 지어낸 URL 로 감당할 수 없다.
+ */
+ imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@@ -101,6 +107,8 @@ export interface ChronicleItem {
place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean;
+ /** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */
+ imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@@ -124,6 +132,8 @@ export interface PostcardItem {
place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string;
+ /** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */
+ imageUrl?: string;
verified?: DataVerified;
source?: DataSource;
}
@@ -138,6 +148,56 @@ export interface QuizItem {
source?: DataSource;
}
+export interface ItineraryStop {
+ name: string;
+ minutes?: number;
+ note?: string;
+ searchQuery?: string;
+ /**
+ * 좌표.
+ *
+ * ★ **모델이 만드는 값이 아니다.** 지도에 핀을 찍는 값이라 한 자리만 틀려도 손님이
+ * 엉뚱한 데로 간다. 장소명으로 공식 API(TourAPI·카카오)를 조회해 채운다.
+ * 없으면 지도를 그리지 않고 타임라인만 낸다.
+ */
+ latitude?: number;
+ longitude?: number;
+ /** 사진. 권리가 확실한 출처만. */
+ imageUrl?: string;
+}
+
+export interface ItineraryDay {
+ label: string;
+ startTime?: string;
+ stops?: ItineraryStop[];
+}
+
+/** 며칠 묵느냐로 갈리는 일정. days 가 하루씩이다. */
+export interface ItineraryItem {
+ name: string;
+ duration?: string;
+ audience?: string;
+ why?: string;
+ days?: ItineraryDay[];
+ verified?: DataVerified;
+ source?: DataSource;
+}
+
+/** 사장님이 쓰는 공지·소식. 지역 리서치가 아니라 업소가 소유한 글이다. */
+export interface EventItem {
+ kind?: string;
+ title: string;
+ summary?: string;
+ body?: string;
+ verified?: DataVerified;
+}
+
+export interface VideoItem {
+ url: string;
+ caption?: string;
+ verified?: DataVerified;
+}
+
export interface PlannerStop {
name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
@@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record = {
postcard: 'line',
quiz: 'question',
planner: 'name',
+ // ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다.
+ itinerary: 'name',
+ event: 'title',
+ video: 'url',
};
export interface ParsedSectionData {
diff --git a/solution/shared/src/types/builder.ts b/solution/shared/src/types/builder.ts
index 15db900..dd491af 100644
--- a/solution/shared/src/types/builder.ts
+++ b/solution/shared/src/types/builder.ts
@@ -106,6 +106,13 @@ export interface TemplateLook {
headingWeight: string;
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string;
+ /**
+ * 종이 질감 — `background-image` 에 그대로 들어간다(`.paper`).
+ *
+ * ★ 레트로(갱지)의 인상이 사실상 이 값에서 온다. 없으면 색만 갱지고 면은 매끈해서
+ * 같은 팔레트인데도 인쇄물로 안 보인다. 비워 두면 아무것도 깔지 않는다.
+ */
+ texture?: string;
}
export interface PhotoItem {
diff --git a/solution/shared/src/types/site-payload.ts b/solution/shared/src/types/site-payload.ts
index e1331a7..55f8a29 100644
--- a/solution/shared/src/types/site-payload.ts
+++ b/solution/shared/src/types/site-payload.ts
@@ -189,8 +189,13 @@ export interface LocalPlace {
description?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string;
+ /**
+ * 대표 사진. 재게시 권리가 확실한 출처(공공누리 등)에서 온 것만 싣는다.
+ *
+ * ★ 없으면 카드가 글자만 남는다 — 그래도 지어내지 않는다.
+ */
imageUrl?: string;
- /** 업장 좌표 기준 거리(m). 도보 시간 필터 계산용 원값 — distanceText 는 이걸 사람이 읽게 바꾼 것. */
+ /** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */
distanceMeters?: number;
}
diff --git a/solution/site/scripts/prerender.ts b/solution/site/scripts/prerender.ts
index a42c424..d3d5b21 100644
--- a/solution/site/scripts/prerender.ts
+++ b/solution/site/scripts/prerender.ts
@@ -22,10 +22,16 @@ import {render} from '@/entry-server';
import {
collectJsonLd,
homeMeta,
+ readBakedLastmod,
+ readBakedTitle,
renderHead,
renderLlmsTxt,
+ renderRootLlmsTxt,
renderRootRobotsTxt,
+ renderSiteIndex,
renderSiteUrlset,
+ type DirectoryEntry,
+ type SiteEntry,
verifyGeo,
verifyJsonLd,
} from '@/seo';
@@ -279,7 +285,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
};
}
-function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType) {
+function prerenderSite(
+ input: SitePayload,
+ outRoot: string,
+ assets: ReturnType,
+ referenced: Set,
+) {
// ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 —
// 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다.
// (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.)
@@ -364,7 +375,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType {
+ const sitesDir = join(outRoot, SITE_DIR);
+ const found = new Set();
+ if (!existsSync(sitesDir)) return found;
+
+ for (const entry of readdirSync(sitesDir, {withFileTypes: true})) {
+ if (!entry.isDirectory()) continue;
+ const file = join(sitesDir, entry.name, 'index.html');
+ if (!existsSync(file)) continue;
+ // basePath 가 붙어도(`/sites/assets/…`) `/assets/` 뒤만 집으면 파일 경로가 나온다.
+ for (const match of readFileSync(file, 'utf-8').matchAll(/\/assets\/([^"'\s)\\]+)/g)) {
+ found.add(decodeURIComponent(match[1]));
+ }
+ }
+ return found;
+}
+
+function readAssetLedger(assetsDir: string): AssetBuild[] {
+ try {
+ const parsed = JSON.parse(readFileSync(join(assetsDir, ASSET_LEDGER), 'utf-8')) as {
+ builds?: AssetBuild[];
+ };
+ return Array.isArray(parsed.builds) ? parsed.builds : [];
+ } catch {
+ // 없거나 깨졌으면 빈 대장으로 시작한다. 디스크에 있던 파일은 pruneAssets 가
+ // "처음 본 것" 으로 입양하므로 지워지지 않는다 — 그 ★ 주석이 이 실패의 근거다.
+ return [];
+ }
+}
+
+/**
+ * 보관 기간이 지난 옛 해시 파일만 지운다.
+ *
+ * ★ 발행할 때마다 이 함수가 돈다(사이트 하나만 구울 때도). 번들이 그대로면 대장에 줄이
+ * 늘지 않고 맨 앞 줄의 시각만 갱신된다 — 안 그러면 발행 횟수만큼 대장이 자란다.
+ */
+function pruneAssets(assetsDir: string, current: string[], referenced: Set) {
+ const signature = (files: string[]) => [...files].sort().join('\n');
+ const now = new Date().toISOString();
+ const previous = readAssetLedger(assetsDir);
+ const head: AssetBuild = {at: now, files: current};
+ const builds =
+ previous[0] && signature(previous[0].files) === signature(current)
+ ? [head, ...previous.slice(1)]
+ : [head, ...previous];
+
+ /**
+ * ★ 대장에 없는 파일은 **지우지 않는다.** 언제 깔렸는지 모를 뿐이므로 지금 처음 본 것으로
+ * 치고 보관 기간을 새로 준다.
+ *
+ * 이 줄이 없어서 실제로 운영 사이트가 끊겼다(2026-09-07). 대장은 이 기능과 함께 생겼으니
+ * **배포 직후 첫 실행에는 대장이 없다** — 그때 디스크에 있던 기존 자산이 전부 "대장에 없음"
+ * 으로 분류돼 한꺼번에 삭제됐고, 아직 다시 굽지 않은 사이트의 CSS 가 통째로 404 가 됐다.
+ * 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
+ */
+ const recorded = new Set(builds.flatMap((build) => build.files));
+ const adopted = listRelativeFiles(assetsDir).filter(
+ (file) => file !== ASSET_LEDGER && !recorded.has(file),
+ );
+ if (adopted.length > 0) {
+ builds.push({at: now, files: adopted});
+ console.log(` ✓ 대장에 없던 자산 ${adopted.length}개를 보관 대상으로 넣는다`);
+ }
+
+ const cutoff = Date.now() - ASSET_RETENTION_DAYS * 24 * 60 * 60 * 1000;
+ const kept = builds.filter(
+ (build, index) => index < ASSET_MIN_BUILDS || Date.parse(build.at) >= cutoff,
+ );
+ const alive = new Set(kept.flatMap((build) => build.files));
+
+ let removed = 0;
+ for (const file of listRelativeFiles(assetsDir)) {
+ // ★ 참조가 살아 있으면 기간과 무관하게 남긴다 — referencedAssets 주석 참조.
+ if (file === ASSET_LEDGER || alive.has(file) || referenced.has(file)) continue;
+ rmSync(join(assetsDir, file), {force: true});
+ removed += 1;
+ }
+
+ writeFileSync(
+ join(assetsDir, ASSET_LEDGER),
+ JSON.stringify({schemaVersion: 1, builds: kept}, null, 2),
+ 'utf-8',
+ );
+ if (removed > 0) {
+ console.log(` ✓ 보관 기간(${ASSET_RETENTION_DAYS}일)이 지난 자산 ${removed}개 정리`);
+ }
+}
+
/**
* 클라이언트 번들과 public/ 을 대상 디렉토리에 깐다.
*
- * Docker bind mount 에서 이전 빌드가 남긴 파일 권한이 호스트와 어긋날 수 있어
- * assets/ 는 통째로 교체한다(낡은 해시 파일과 권한을 함께 제거).
+ * ★ assets/ 를 통째로 지우고 다시 깔지 않는다(예전 구현). 옛 해시 파일은 보관 기간까지
+ * 남겨야 한다 — 근거는 ASSET_RETENTION_DAYS 주석. 권한 때문에 통째로 지웠던 것인데,
+ * copyDirectoryFiles 가 **파일마다** 먼저 rmSync 하므로 그 문제는 그대로 해결된다.
*/
-function writeSharedAssets(destRoot: string) {
+function writeSharedAssets(destRoot: string, referenced: Set) {
const assetsSrc = join(CLIENT_DIR, 'assets');
if (existsSync(assetsSrc)) {
const assetsDest = join(destRoot, 'assets');
- rmSync(assetsDest, {recursive: true, force: true});
copyDirectoryFiles(assetsSrc, assetsDest);
+ pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced);
}
const publicDir = join(SITE_ROOT, 'public');
if (existsSync(publicDir)) {
@@ -509,21 +668,44 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return;
- const entries = readdirSync(sitesDir, {withFileTypes: true})
+ const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true})
.filter((entry) => entry.isDirectory())
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
.filter((entry) => existsSync(entry.file))
- .map((entry) => ({
- loc: joinUrl(origin, SITE_DIR, entry.slug) + '/',
- // 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다.
- lastmod: statSync(entry.file).mtime.toISOString(),
- }))
+ .map((entry) => {
+ // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다.
+ const html = readFileSync(entry.file, 'utf-8');
+ return {
+ // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/` 다(shared/lib/slug.ts
+ // publishUrl). 사이트맵이 `/s//` 로 어긋나 있던 동안 서치콘솔은 제출한 URL 을
+ // 전부 "대체 페이지(적절한 표준 태그가 있음)" 로 분류했다 — 색인은 되는데 제출분은
+ // 0건으로 보이는, 눈으로 원인을 못 찾는 종류다.
+ loc: joinUrl(origin, SITE_DIR, entry.slug),
+ title: readBakedTitle(html) || entry.slug,
+ // ★ mtime 으로 떨어지는 건 dateModified 메타가 없던 시절의 산출물뿐이다.
+ // 그 사이트를 한 번 다시 구우면 제 값이 들어온다(readBakedLastmod 주석 참조).
+ lastmod: readBakedLastmod(html) ?? statSync(entry.file).mtime.toISOString(),
+ };
+ })
.sort((a, b) => a.loc.localeCompare(b.loc));
+ // ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
+ // 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
+ // ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/`)와 같은 형태여야 한다.
+ // nginx 가 `location = /s` 로 이 파일을 직접 주고 `/s/` 는 여기로 301 한다
+ // (nginx/site.conf). 그 블록이 없으면 `/s` 는 빌더 SPA 셸을 200 으로 내준다.
+ const indexUrl = joinUrl(origin, SITE_DIR);
+ writeFileSync(join(sitesDir, 'index.html'), renderSiteIndex(origin, indexUrl, sites), 'utf-8');
+
+ // 사이트맵에는 랜딩·목록 페이지도 담는다. 랜딩은 이 호스트의 첫 페이지이고,
+ // 목록은 발행본 전부로 이어지는 허브다 — 둘 다 크롤러가 먼저 열어야 하는 자리다.
+ const entries: SiteEntry[] = [{loc: origin + '/'}, {loc: indexUrl}, ...sites];
+
writeFileSync(join(outRoot, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8');
writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), 'utf-8');
- console.log(` ✓ 루트 robots.txt · sitemap.xml (사이트 ${entries.length}개)`);
+ writeFileSync(join(outRoot, 'llms.txt'), renderRootLlmsTxt(origin, indexUrl, sites), 'utf-8');
+ console.log(` ✓ 루트 robots.txt · sitemap.xml · llms.txt · /s/ 목록 (사이트 ${sites.length}개)`);
writeIndexNowKey(outRoot);
}
@@ -555,8 +737,12 @@ function main() {
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
+ // ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가
+ // 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다.
+ const referenced = referencedAssets(args.out);
+
// ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다.
- writeSharedAssets(args.out);
+ writeSharedAssets(args.out, referenced);
let failed = 0;
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
@@ -600,7 +786,7 @@ function main() {
}
try {
- const result = prerenderSite(entry.payload, args.out, assets);
+ const result = prerenderSite(entry.payload, args.out, assets, referenced);
const payload = result.payload;
origin = origin || payload.site.origin;
const home = joinUrl(payload.site.origin, payload.site.basePath);
diff --git a/solution/site/src/index.css b/solution/site/src/index.css
index 7977d64..597707d 100644
--- a/solution/site/src/index.css
+++ b/solution/site/src/index.css
@@ -15,6 +15,28 @@
--tpl-card: #fafafa;
--tpl-text: #09090b;
--tpl-accent: #2563eb;
+
+ /*
+ * 유동 타이포 — 화면 폭에 따라 자라는 글자 크기.
+ *
+ * ★ 이 토큰이 없던 동안 히어로 제목이 본문과 같은 크기로 나왔다. 섹션 컴포넌트들이
+ * var(--fs-display) 를 쓰는데 정의가 어디에도 없어 그냥 무시됐다(브라우저는 조용히 넘어간다).
+ * ★ 값은 목업(/s/stay)이 쓰던 것을 그대로 옮겼다 — 화면이 달라지면 목업과 어긋난다.
+ */
+ --fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem);
+ --fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem);
+ --fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem);
+ --fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem);
+ --fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem);
+ --fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem);
+ --fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem);
+
+ /* 섹션 세로 여백. 템플릿의 sectionSpace 를 상한으로 두고 좁은 화면에서만 줄인다. */
+ --section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem));
+
+ /* 선·흐린 글자 — 템플릿 색에서 유도한다. 고정 회색을 쓰면 갱지 바탕에서 뜬다. */
+ --color-line: var(--tpl-border, color-mix(in oklab, var(--tpl-text, #09090b) 14%, transparent));
+ --color-muted: color-mix(in oklab, var(--tpl-text, #09090b) 60%, transparent);
}
@theme inline {
@@ -68,4 +90,91 @@ body {
padding-inline: 1.5rem;
}
}
+
+ /* 본문 한 줄 길이 상한. 68자를 넘기면 눈이 다음 줄을 못 찾는다. */
+ .measure {
+ max-width: 68ch;
+ }
+
+ /* 종이 질감 — 템플릿이 texture 를 주면 깔린다(레트로 갱지). 없으면 아무것도 안 깔린다. */
+ .paper {
+ background-image: var(--tpl-texture, none);
+ }
+
+ /* 카드·패널. 바탕보다 한 겹 앞으로 나온 면. */
+ .panel {
+ background-color: color-mix(in oklab, currentcolor 5%, transparent);
+ border: var(--tpl-border-width, 1px) solid var(--color-line);
+ border-radius: var(--tpl-radius, 0.75rem);
+ box-shadow: var(--tpl-shadow, none);
+ }
+ .panel-sunken {
+ background-color: color-mix(in oklab, currentcolor 6%, transparent);
+ }
+
+ .border-line {
+ border-color: var(--color-line);
+ }
+ /* divide-y 의 사이 선도 같은 색으로. Tailwind 기본은 회색이라 갱지 위에서 뜬다. */
+ .divide-line > :not([hidden]) ~ :not([hidden]) {
+ border-color: var(--color-line);
+ }
+ .text-muted {
+ color: var(--color-muted);
+ }
+
+ /* 소제목. h1 은 히어로가 인라인으로 쓰고, 그 아래 단계는 이 두 클래스가 맡는다. */
+ .h2 {
+ font-family: var(--tpl-font-heading, var(--font-serif));
+ font-size: var(--fs-h2);
+ font-weight: var(--tpl-heading-weight, 700);
+ letter-spacing: var(--tpl-heading-tracking, -0.01em);
+ line-height: 1.2;
+ }
+ .h3 {
+ font-family: var(--tpl-font-heading, var(--font-serif));
+ font-size: var(--fs-h3);
+ font-weight: var(--tpl-heading-weight, 700);
+ letter-spacing: var(--tpl-heading-tracking, -0.005em);
+ line-height: 1.35;
+ }
+ /* 간판체처럼 굵기가 한 벌뿐인 서체에 가짜 볼드가 씌워지는 것을 막는다. */
+ .serif,
+ .h2,
+ .h3 {
+ font-synthesis-weight: none;
+ }
+
+ .label {
+ font-size: var(--fs-xs);
+ color: var(--color-muted);
+ font-weight: 600;
+ }
+
+ .tpl-shadow {
+ box-shadow: var(--tpl-shadow, none);
+ }
+}
+
+@layer components {
+ /*
+ * 가로 레일 — 목업과 같은 클래스 이름. 캐러셀 라이브러리 없이 스크롤 스냅으로만 만든다.
+ * ★ 스크립트가 없어도 손가락·트랙패드로 밀린다. 라이브러리를 얹으면 그게 죽었을 때
+ * 레일 전체가 한 줄로 굳는다.
+ */
+ .slider-viewport {
+ overflow-x: auto;
+ scrollbar-width: none;
+ padding-bottom: 0.75rem;
+ }
+ .slider-viewport::-webkit-scrollbar {
+ display: none;
+ }
+ .slider-track {
+ display: flex;
+ scroll-snap-type: x mandatory;
+ }
+ .slider-track > * {
+ scroll-snap-align: center;
+ }
}
diff --git a/solution/site/src/pages/HomePage.tsx b/solution/site/src/pages/HomePage.tsx
index cbc3635..c194473 100644
--- a/solution/site/src/pages/HomePage.tsx
+++ b/solution/site/src/pages/HomePage.tsx
@@ -5,6 +5,7 @@ import {
EssentialInfoSection,
ExhibitionSection,
FaqSection,
+ FestivalSection,
GallerySection,
HeroSection,
InquirySection,
@@ -14,11 +15,20 @@ import {
LocationSection,
RulesSection,
SpaceSection,
+ StorySection,
UnitsSection,
} from '@/sections';
import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive';
+/**
+ * StorySection 이 탭으로 묶어 그리는 아이템.
+ *
+ * ★ `sections/StorySection.tsx` 의 STORY_KINDS 와 같은 목록이어야 한다.
+ * 어긋나면 어떤 아이템은 탭 밖에 따로 서고, 어떤 아이템은 어디에도 안 나온다.
+ */
+const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'];
+
/**
* 홈.
*
@@ -59,12 +69,16 @@ export function HomePage() {
exhibition: ExhibitionSection,
photos: GallerySection,
local: LocalGuideSection,
+ festival: FestivalSection,
weather: WeatherSection,
map: LocationSection,
faq: FaqSection,
- // 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
+ // 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다.
...ITEM_SECTIONS,
+ // ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다.
+ // 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다.
+ ...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])),
};
const rendered = new Set();
diff --git a/solution/site/src/sections/AboutSection.tsx b/solution/site/src/sections/AboutSection.tsx
index 8c74e49..09764a8 100644
--- a/solution/site/src/sections/AboutSection.tsx
+++ b/solution/site/src/sections/AboutSection.tsx
@@ -1,6 +1,13 @@
import {useSite} from '@/lib/site-context';
import {galleryImages, sectionBody} from '@/lib/derive';
+/**
+ * 소개.
+ *
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮긴 것이다.
+ * 비슷하게 다시 짜지 않는다 — 그리드 비율(7:5)·모서리(rounded-xl)·본문 행간(1.8)이
+ * 조금만 달라도 같은 페이지로 안 보인다.
+ */
export function AboutSection() {
const payload = useSite();
const {narrative, place} = payload;
@@ -17,15 +24,14 @@ export function AboutSection() {
-
About
-
-
+
{image && (
-
-
+
+
- {image.caption && (
- {image.caption}
- )}
)}
-
- {/* 제목은 사장님이 붙인 섹션 이름이 먼저다. 이름이 비어 있을 때만
- 지금까지 쓰던 문구(한 줄 소개 → "○○ 소개")로 떨어진다. */}
-
- {narrative.heroSubline ?? `${place.name} 소개`}
+
+
+
+ {`${place.name} 소개`}
-
+
{about.map((paragraph, index) => (
{paragraph}
))}
diff --git a/solution/site/src/sections/AnswerBlock.tsx b/solution/site/src/sections/AnswerBlock.tsx
index e10325c..900cbec 100644
--- a/solution/site/src/sections/AnswerBlock.tsx
+++ b/solution/site/src/sections/AnswerBlock.tsx
@@ -11,6 +11,9 @@ import {formatKoreanDate} from '@/lib/format';
* 표(dl)로 두는 이유는 label-value 관계가 마크업으로 드러나야 기계가 짝을 짓기 때문이다.
*
* ★ 여기 나오는 값은 전부 확인된 fact 다. essentialRows() 가 걸러 준다.
+ * ★ 마크업은 목업(/s/stay 의 `
`)에서 그대로 옮겼다.
+ * 좌우 5:7 그리드에 왼쪽이 제목·요약, 오른쪽이 두 칸 표다. 제목은 h2 태그지만 h3 급으로
+ * 그린다 — 이 블록은 섹션이 아니라 히어로에 딸린 요약이라 같은 크기로 서면 위계가 무너진다.
*/
export function AnswerBlock() {
const payload = useSite();
@@ -23,33 +26,36 @@ export function AnswerBlock() {
-
- 한눈에 보는 {payload.place.name}
-
-
- {formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
-
+
+
+
+ 한눈에 보는 {payload.place.name}
+
+ {/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
+
{summary}
+
+ {formatKoreanDate(payload.site.updatedAt)} 기준, 사업자가 확인한 정보입니다.
+
+
- {/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
-
{summary}
-
- {rows.length > 0 && (
-
- {rows.map((row) => (
-
-
{row.label}
- {row.value}
-
- ))}
-
- )}
+ {rows.length > 0 && (
+
+ {rows.map((row) => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
+ )}
+
);
diff --git a/solution/site/src/sections/BookingSection.tsx b/solution/site/src/sections/BookingSection.tsx
index d0e82b0..0ed3b33 100644
--- a/solution/site/src/sections/BookingSection.tsx
+++ b/solution/site/src/sections/BookingSection.tsx
@@ -28,8 +28,8 @@ export function BookingSection() {
@@ -37,8 +37,9 @@ export function BookingSection() {
Booking
-
- {sectionName(payload, 'booking', '예약 안내')}
+
+
+ {sectionName(payload, 'booking', '예약 안내')}
{payload.place.name} 예약은 아래 경로로 받습니다.
diff --git a/solution/site/src/sections/EssentialInfoSection.tsx b/solution/site/src/sections/EssentialInfoSection.tsx
index 7a6586a..a4f472b 100644
--- a/solution/site/src/sections/EssentialInfoSection.tsx
+++ b/solution/site/src/sections/EssentialInfoSection.tsx
@@ -1,82 +1,137 @@
-import {Info} from 'lucide-react';
+import {Phone} from 'lucide-react';
+import {selectPublishable} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
-import {essentialRows} from '@/lib/derive';
+import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format';
/**
- * 이용 정보 표 전체.
+ * 이용안내 및 예약.
*
- * AnswerBlock 이 상위 6개만 보여준다면 여기는 확인된 것 전부를 낸다.
- * ★ "확인 안 된 항목이 N개 있다"를 숨기지 않는다 — 없는 척하면
- * 손님이 다른 데서 틀린 값을 찾아 온다. 있다고 말하고 문의로 보낸다.
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮겼다.
+ * ★ 표를 두 덩이로 가른다 — "예약 전 확인"(critical)과 "시설 · 편의".
+ * critical 은 틀리면 예약 클레임이 나는 항목이라 손님이 먼저 봐야 하고,
+ * 와이파이·주차와 같은 무게로 섞이면 그 위계가 사라진다.
+ * ★ 예약 버튼이 이 섹션 안에 있다. 규정을 읽은 자리에서 바로 예약으로 이어지지 않으면
+ * 손님은 다시 위로 올라가야 한다.
+ * ★ "확인 중인 항목 N개"를 숨기지 않는다 — 없는 척하면 손님이 다른 데서 틀린 값을 찾아 온다.
*/
export function EssentialInfoSection() {
const payload = useSite();
const rows = essentialRows(payload);
+ if (rows.length === 0) return null;
+
+ // critical 여부는 fact 에만 있다. 표 행(label/value)에 다시 붙여 둘로 가른다.
+ const criticalLabels = new Set(
+ selectPublishable(payload.facts)
+ .filter((fact) => fact.scope === 'place' && fact.critical)
+ .map((fact) => fact.label),
+ );
+ const groups = [
+ {title: '예약 전 확인', accent: true, rows: rows.filter((row) => criticalLabels.has(row.label))},
+ {title: '시설 · 편의', accent: false, rows: rows.filter((row) => !criticalLabels.has(row.label))},
+ ].filter((group) => group.rows.length > 0);
+
const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length;
-
- if (rows.length === 0) return null;
-
- const half = Math.ceil(rows.length / 2);
+ const links = bookingLinks(payload);
+ const phone = payload.place.phone;
return (
-
-
- Essential Information
-
-
-
-
-
- {'이용 및 예약 안내'}
-
-
- 방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
-
-
+
+
+
+
+
+
+ 이용안내 및 예약
+
+
+ 방문 전 확인이 필요한 운영 규정과 시설 안내입니다.
+
+
+
+
-
-
- {[rows.slice(0, half), rows.slice(half)].map((column, columnIndex) => (
-
- {column.map((row) => (
+
+ {groups.map((group) => (
+
+
+ {group.accent && (
+
+ )}
+ {group.title}
+
+
+ {group.rows.map((row) => (
-
{row.label}
-
- {row.value}
- {row.note && {row.note}
}
-
+
+ {row.label}
+
+
{row.value}
))}
- ))}
-
+
+ ))}
-
+ {(phone || links.length > 0) && (
+
+
+ {payload.place.name} 예약은 아래로 받습니다.
+
+
+
+ )}
+
+
+
+
{hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '모든 항목이 사업자 확인을 거쳤습니다.'}
{formatKoreanDate(payload.site.updatedAt)} 기준
-
-
+
+
);
diff --git a/solution/site/src/sections/ExhibitionSection.tsx b/solution/site/src/sections/ExhibitionSection.tsx
index c8fd8d3..726c9c0 100644
--- a/solution/site/src/sections/ExhibitionSection.tsx
+++ b/solution/site/src/sections/ExhibitionSection.tsx
@@ -27,8 +27,8 @@ export function ExhibitionSection() {
@@ -36,8 +36,9 @@ export function ExhibitionSection() {
Exhibition
-
- {sectionName(payload, 'exhibition', '관람 안내')}
+
+
+ {sectionName(payload, 'exhibition', '관람 안내')}
방문 전 관람 조건을 확인해 주세요.
diff --git a/solution/site/src/sections/FaqSection.tsx b/solution/site/src/sections/FaqSection.tsx
index 06e1983..478fd59 100644
--- a/solution/site/src/sections/FaqSection.tsx
+++ b/solution/site/src/sections/FaqSection.tsx
@@ -1,4 +1,3 @@
-import {HelpCircle} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {faqList} from '@/lib/derive';
@@ -20,36 +19,42 @@ export function FaqSection() {
-
-
- FAQ
-
+
+
+
+
+
+
+ 자주 묻는 질문
+
+
+ 아래 답변은 모두 사업자가 확인한 내용입니다.
+
+
+
+
+
-
- {'자주 묻는 질문'}
-
-
- 아래 답변은 모두 사업자가 확인한 내용입니다.
-
-
-
+ {/* ★ 두 칸으로 편다. 한 줄로 세우면 질문 열 개가 화면 두 개를 잡아먹는다.
+ `h-fit` 이라 펼친 카드만 길어지고 옆 카드는 따라 늘어나지 않는다. */}
+
{faqs.map((faq, index) => (
-
- Q.
- {faq.question}
+
+ Q.
+ {faq.question}
+ +
-
+
{faq.answer}
diff --git a/solution/site/src/sections/FestivalSection.tsx b/solution/site/src/sections/FestivalSection.tsx
new file mode 100644
index 0000000..2bf282e
--- /dev/null
+++ b/solution/site/src/sections/FestivalSection.tsx
@@ -0,0 +1,162 @@
+/**
+ * 계절별 축제.
+ *
+ * ★ 왜 계절로 묶나 — 지역 축제는 날짜보다 '언제쯤'이 먼저다. 손님은 "가을에 뭐 있나"를 묻지
+ * "10월 3일에 뭐 있나"를 묻지 않는다. 그리고 정적 페이지는 몇 달 산다 — 날짜로 줄 세우면
+ * 구운 다음 날부터 지난 목록이 된다.
+ * ★ **전 계절을 HTML 에 굽고 화면에서만 접는다.** 접힌 계절이 HTML 에 없으면 검색·AI 가
+ * 나머지를 못 읽는다(items 규칙과 같다).
+ * ★ 링크는 검색으로 보낸다 — 축제 공식 페이지 주소를 우리가 지어내지 않는다.
+ */
+import {useState} from 'react';
+import {ArrowUpRight, MapPin} from 'lucide-react';
+import {useSite} from '@/lib/site-context';
+import {sectionName} from '@/lib/derive';
+
+const SEASONS = ['봄', '여름', '가을', '겨울'] as const;
+type Season = (typeof SEASONS)[number] | '전체';
+
+/** '3월' → 봄. 월을 못 읽으면 어느 계절로도 넣지 않는다(지어내지 않는다). */
+function seasonOf(month?: string): (typeof SEASONS)[number] | null {
+ const n = Number((month ?? '').replace(/[^0-9]/g, ''));
+ if (!Number.isFinite(n) || n < 1 || n > 12) return null;
+ if (n >= 3 && n <= 5) return '봄';
+ if (n >= 6 && n <= 8) return '여름';
+ if (n >= 9 && n <= 11) return '가을';
+ return '겨울';
+}
+
+export function FestivalSection() {
+ const payload = useSite();
+ const festivals = payload.local.festivals ?? [];
+ const [active, setActive] = useState
('전체');
+ const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
+
+ if (festivals.length === 0) return null;
+
+ const grouped = SEASONS.map((season) => ({
+ season,
+ items: festivals.filter((f) => seasonOf(f.month) === season),
+ })).filter((group) => group.items.length > 0);
+ const unknown = festivals.filter((f) => seasonOf(f.month) === null);
+
+ return (
+
+
+
+
+
+ {[...grouped.map((g) => g.season), '전체' as const].map((season) => (
+ setActive(season)}
+ className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
+ style={
+ season === active
+ ? {
+ backgroundColor: 'var(--tpl-text, #09090b)',
+ color: 'var(--tpl-bg, #ffffff)',
+ borderColor: 'var(--tpl-text, #09090b)',
+ }
+ : undefined
+ }
+ >
+ {season}
+
+ ))}
+
+
+
+ {[...grouped, ...(unknown.length > 0 ? [{season: '그 밖에' as const, items: unknown}] : [])].map(
+ (group) => (
+
+ ),
+ )}
+
+
+
+ );
+}
diff --git a/solution/site/src/sections/GallerySection.tsx b/solution/site/src/sections/GallerySection.tsx
index 1f35278..decf854 100644
--- a/solution/site/src/sections/GallerySection.tsx
+++ b/solution/site/src/sections/GallerySection.tsx
@@ -1,38 +1,35 @@
-import {useCallback, useEffect, useState} from 'react';
-import useEmblaCarousel from 'embla-carousel-react';
+import {useRef, useState} from 'react';
import {ChevronLeft, ChevronRight, X} from 'lucide-react';
import {useSite} from '@/lib/site-context';
-import {galleryImages} from '@/lib/derive';
+import {galleryImages, sectionName} from '@/lib/derive';
+/**
+ * 사진 갤러리.
+ *
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮겼다.
+ * ★ 좁은 화면은 가로로 미는 한 줄, `lg` 이상은 4열 격자다. 캐러셀 라이브러리를 쓰지 않는다 —
+ * `scroll-snap` 만으로 같은 동작이 나오고, 스크립트가 없어도 사진이 다 보인다.
+ * ★ 전 장을 HTML 에 편다. 접거나 지연 삽입하면 크롤러가 나머지를 못 읽는다.
+ */
export function GallerySection() {
const payload = useSite();
const images = galleryImages(payload);
- const setting = payload.theme.sections.find((section) => section.id === 'photos');
- const variantId = setting?.variantId ?? 'photos.grid';
+ const railRef = useRef(null);
+ const [index, setIndex] = useState(0);
const [openIndex, setOpenIndex] = useState(null);
- /**
- * ★ 슬라이드를 화면 폭보다 좁게 잡아 **양옆이 보이게** 한다.
- * 100% 폭이면 정지 화면에서 사진 한 장과 구분이 안 된다 — 손님은 옆으로 더 있다는 걸
- * 모르고 지나간다. 옆 사진이 살짝 걸쳐 보이는 것이 "밀어서 볼 수 있다"는 유일한 신호다.
- */
- const [carouselRef, carouselApi] = useEmblaCarousel({loop: true, align: 'center', skipSnaps: false});
- const [selected, setSelected] = useState(0);
-
- // 화살표·드래그·키보드 어느 쪽으로 움직여도 인디케이터가 따라오게 한다.
- useEffect(() => {
- if (!carouselApi) return;
- const sync = () => setSelected(carouselApi.selectedScrollSnap());
- sync();
- carouselApi.on('select', sync);
- return () => {
- carouselApi.off('select', sync);
- };
- }, [carouselApi]);
-
- const scrollTo = useCallback((index: number) => carouselApi?.scrollTo(index), [carouselApi]);
if (images.length === 0) return null;
+ const onScroll = () => {
+ const rail = railRef.current;
+ if (!rail) return;
+ setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
+ };
+ const move = (delta: number) => {
+ const rail = railRef.current;
+ if (!rail) return;
+ rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
+ };
const step = (delta: number) => {
setOpenIndex((current) =>
current === null ? null : (current + delta + images.length) % images.length,
@@ -43,105 +40,91 @@ export function GallerySection() {
-
Gallery
-
- {setting?.name || '공간 갤러리'}
-
-
- {/* 비전 분석 결과는 검색·접근성 메타데이터로만 사용하고 화면에는 사진만 보인다. */}
- {variantId === 'photos.carousel' ? (
-
{
- if (event.key === 'ArrowLeft') carouselApi?.scrollPrev();
- if (event.key === 'ArrowRight') carouselApi?.scrollNext();
- }}
- >
- {/* 양옆 사진이 걸쳐 보이도록 컨테이너 밖으로 넘치는 부분만 잘라낸다. */}
-
-
- {images.map((image, index) => (
-
- setOpenIndex(index)}
- // 지금 보고 있지 않은 사진은 눌러서 흐리게 — 어느 것이 현재인지 눈에 바로 들어온다.
- className={`aspect-16/9 rounded-2xl transition-opacity duration-300 ${
- index === selected ? 'opacity-100' : 'opacity-45'
- }`}
- />
-
- ))}
-
+
- {images.length > 1 && (
- <>
-
carouselApi?.scrollPrev()} />
- carouselApi?.scrollNext()} />
-
- {/* 몇 장 중 몇 번째인지. 점만 있으면 사진이 많을 때 셀 수 없다. */}
-
-
- {images.map((image, index) => (
-
- scrollTo(index)}
- aria-label={`${index + 1}번째 사진으로`}
- aria-current={index === selected}
- className={`h-1.5 rounded-full transition-all ${
- index === selected ? 'w-5 bg-black/70' : 'w-1.5 bg-black/20 hover:bg-black/40'
- }`}
- />
-
- ))}
-
-
- {selected + 1} / {images.length}
-
-
- >
- )}
-
- ) : variantId === 'photos.masonry' ? (
-
- {images.map((image, index) => (
-
- setOpenIndex(index)} className="rounded-xl" free />
+
+
+ {images.map((image, i) => (
+
+ setOpenIndex(i)}
+ className="border-line tpl-border group relative block aspect-4/3 w-full cursor-pointer overflow-hidden rounded-lg border"
+ aria-label={`${image.alt} 크게 보기`}
+ >
+
+
))}
- ) : (
-
- {images.map((image, index) => (
-
- setOpenIndex(index)} className="aspect-square rounded-xl" />
-
- ))}
-
- )}
+
+ {images.length > 1 && (
+ <>
+
move(-1)}
+ className={`absolute left-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
+ index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
+ }`}
+ >
+
+
+
= images.length - 1}
+ tabIndex={index >= images.length - 1 ? -1 : 0}
+ onClick={() => move(1)}
+ className={`absolute right-2 top-1/2 z-10 flex size-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 lg:hidden ${
+ index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
+ }`}
+ >
+
+
+
+ {index + 1} / {images.length}
+
+ >
+ )}
+
{openIndex !== null && (
setOpenIndex(null)}
>
-
{
@@ -164,7 +146,6 @@ export function GallerySection() {
>
-
{
@@ -176,7 +157,6 @@ export function GallerySection() {
>
-
event.stopPropagation()}
@@ -192,43 +172,3 @@ export function GallerySection() {
);
}
-
-function GalleryImage({image, onOpen, className, free = false}: {
- image: ReturnType
[number];
- onOpen: () => void;
- className: string;
- free?: boolean;
-}) {
- return (
-
-
-
- );
-}
-
-function CarouselButton({label, side, onClick}: {label: string; side: 'left' | 'right'; onClick: () => void}) {
- const Icon = side === 'left' ? ChevronLeft : ChevronRight;
- return (
-
-
-
- );
-}
diff --git a/solution/site/src/sections/HeroSection.tsx b/solution/site/src/sections/HeroSection.tsx
index 53d7a19..e60b0a8 100644
--- a/solution/site/src/sections/HeroSection.tsx
+++ b/solution/site/src/sections/HeroSection.tsx
@@ -1,78 +1,102 @@
-import {ArrowRight, MapPin} from 'lucide-react';
+import {ChevronDown, MapPin} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {primaryImage} from '@/seo/meta';
import {unitSpec} from '@/lib/derive';
+/**
+ * 첫 화면.
+ *
+ * ★ 가운데 정렬이 아니라 **좌하단**이다. 사진이 주인공인 화면에서 글자를 한가운데 얹으면
+ * 사진의 피사체를 정확히 가린다 — 실제로 그 이유로 좌하단으로 내렸다(/s/stay).
+ * ★ 색은 전부 템플릿 토큰이다. 여기에 zinc-950 같은 고정색을 쓰면 레트로(갱지)를 골라도
+ * 첫 화면만 검게 남는다.
+ * ★ 버튼을 두 개 세우지 않는다. 첫 화면에서 물어볼 것은 "방을 보겠는가" 하나다.
+ */
export function HeroSection() {
const payload = useSite();
const {place, narrative} = payload;
const image = primaryImage(payload);
const spec = unitSpec(payload);
+ const region = place.addressLocality ?? place.addressRegion;
+ const subline = narrative.tagline ?? narrative.heroSubline;
// id="top" — 상단 로고와 하단 탭의 '홈' 이 여기로 돌아온다.
return (
-
- {image && (
-
-
-
-
- )}
-
-
- {(place.addressLocality ?? place.addressRegion) && (
-
-
- {place.addressLocality ?? place.addressRegion}
-
+
+
+ {image && (
+
+
+ {/* 글자가 놓이는 아래쪽만 짙게. 위쪽은 사진을 그대로 보여준다. */}
+
+
)}
- {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
-
- {narrative.heroHeadline ? (
- <>
- {narrative.heroHeadline}
- {place.name}
- >
- ) : (
- place.name
+
+ {region && (
+
+
+ {region}
+
)}
-
- {(narrative.tagline ?? narrative.heroSubline) && (
-
- {narrative.tagline ?? narrative.heroSubline}
-
- )}
+ {/* h1 은 페이지에 하나. 가게 이름이 들어가야 "○○ 홈페이지" 질의에 잡힌다. */}
+
+ {narrative.heroHeadline ? (
+ <>
+ {narrative.heroHeadline}
+ {place.name}
+ >
+ ) : (
+ place.name
+ )}
+
+
+ {subline && (
+
+ {subline}
+
+ )}
-
diff --git a/solution/site/src/sections/InquirySection.tsx b/solution/site/src/sections/InquirySection.tsx
index cba1492..95f435b 100644
--- a/solution/site/src/sections/InquirySection.tsx
+++ b/solution/site/src/sections/InquirySection.tsx
@@ -26,7 +26,7 @@ export function InquirySection() {
@@ -34,8 +34,9 @@ export function InquirySection() {
Contact
-
- {sectionName(payload, 'inquiry', '문의 안내')}
+
+
+ {sectionName(payload, 'inquiry', '문의 안내')}
궁금한 점은 아래로 연락 주시면 {place.name}에서 직접 안내해 드립니다.
@@ -43,7 +44,7 @@ export function InquirySection() {
{place.phone && (
diff --git a/solution/site/src/sections/LocalGuideSection.tsx b/solution/site/src/sections/LocalGuideSection.tsx
index 453239a..3113f12 100644
--- a/solution/site/src/sections/LocalGuideSection.tsx
+++ b/solution/site/src/sections/LocalGuideSection.tsx
@@ -1,145 +1,217 @@
-import {ArrowUpRight, Calendar, MapPin, Utensils} from 'lucide-react';
+import {useState} from 'react';
+import {ArrowUpRight, MapPin, Utensils} from 'lucide-react';
import type {LocalPlace} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {formatKoreanDate, naverSearchUrl} from '@/lib/format';
+import {sectionName} from '@/lib/derive';
/**
- * 주변 정보.
+ * 주변 안내.
*
- * "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮겼다.
+ * ★ "○○ 근처 숙소" 같은 지역 질의에 걸리려면 지역 고유명사가 본문에 있어야 한다.
* ★ 우리가 남의 가게 URL 을 지어내지 않는다 — 검색 링크로만 보낸다.
* 틀린 링크는 사용자를 엉뚱한 데로 보내고, 그 책임은 이 홈페이지가 진다.
+ * ★ 거리 탭은 **좌표가 있을 때만** 뜬다. 거리 없이 "걸어서 5분"을 만들면 손님이 헛걸음한다.
*/
+
+/** 분속 80m 로 환산한 도보 시간. 직선거리 기준이라 실제 길은 이보다 길다. */
+const WALK_M_PER_MIN = 80;
+
+function walkMinutes(place: LocalPlace): number | null {
+ return typeof place.distanceMeters === 'number'
+ ? Math.max(1, Math.round(place.distanceMeters / WALK_M_PER_MIN))
+ : null;
+}
+
+function distanceLabel(place: LocalPlace): string | null {
+ const meters = place.distanceMeters;
+ if (typeof meters !== 'number') return place.distanceText ?? null;
+ return meters >= 1000 ? `${(meters / 1000).toFixed(1)}km` : `${Math.round(meters)}m`;
+}
+
+const BANDS = [
+ {key: 'all', label: '전체', test: () => true},
+ {key: 'w5', label: '걸어서 5분 이내', test: (m: number | null) => m !== null && m <= 5},
+ {key: 'w10', label: '걸어서 10분 이내', test: (m: number | null) => m !== null && m <= 10},
+ {key: 'far', label: '걸어서 10분 이상', test: (m: number | null) => m !== null && m > 10},
+] as const;
+
+function PlaceRail({items, label, on}: {items: LocalPlace[]; label: string; on: (p: LocalPlace) => boolean}) {
+ if (items.length === 0) return null;
+ return (
+
+
+ {items.map((place) => {
+ const minutes = walkMinutes(place);
+ const distance = distanceLabel(place);
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
export function LocalGuideSection() {
const payload = useSite();
const {local} = payload;
- const hasAnything =
- local.restaurants.length > 0 || local.attractions.length > 0 || local.festivals.length > 0;
+ const [band, setBand] = useState('all');
+
+ const hasAnything = local.restaurants.length > 0 || local.attractions.length > 0;
if (!hasAnything) return null;
+ const all = [...local.restaurants, ...local.attractions];
+ const hasDistance = all.some((place) => typeof place.distanceMeters === 'number');
+ const active = BANDS.find((b) => b.key === band) ?? BANDS[0];
+ // ★ 거르지 않고 **접는다**(hidden). 걸러 버리면 접힌 가게가 HTML 에서 사라져
+ // 검색·AI 가 못 읽는다 — 이 섹션을 둔 이유가 지역 고유명사를 본문에 두는 것이다.
+ const isOn = (place: LocalPlace) => band === 'all' || active.test(walkMinutes(place));
+
+ const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
+ const groups = [
+ {title: '주변 맛집', Icon: Utensils, items: local.restaurants},
+ {title: '주변 명소', Icon: MapPin, items: local.attractions},
+ ].filter((group) => group.items.length > 0);
+
return (
-
-
-
-
- {'주변 안내'}
-
-
- {payload.place.addressLocality ?? '주변'} 지역의 맛집 · 명소 · 행사 안내입니다.
-
-
- {local.syncedAt && (
-
- {formatKoreanDate(local.syncedAt)} 갱신
-
- )}
+
+
+
+
+
+
+
+ {sectionName(payload, 'local', '주변 안내')}
+
+
+ {region} 지역의 맛집 · 명소 안내입니다.
+
+
+ {local.syncedAt && (
+
+
+ {formatKoreanDate(local.syncedAt)} 갱신
+
+
+ )}
+
+
- {local.restaurants.length > 0 && (
-
- )}
- {local.attractions.length > 0 && (
-
- )}
-
- {local.festivals.length > 0 && (
-
-
-
- 지역 행사
-
-
-
- {local.festivals.map((festival) => (
-
-
-
-
- {festival.month}
-
-
{festival.name}
-
-
- {festival.description ?? festival.period}
-
-
-
-
- 자세히
-
-
- ))}
-
+ {hasDistance && (
+
+ {BANDS.map((b) => {
+ const count =
+ b.key === 'all' ? all.length : all.filter((p) => b.test(walkMinutes(p))).length;
+ if (count === 0) return null;
+ const on = b.key === band;
+ return (
+ setBand(b.key)}
+ className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
+ style={
+ on
+ ? {
+ backgroundColor: 'var(--color-brand)',
+ color: 'var(--tpl-bg, #fff)',
+ borderColor: 'transparent',
+ }
+ : undefined
+ }
+ >
+ {b.label} {count}
+
+ );
+ })}
)}
+
+
+ {groups.map((group) => (
+
+
+
+ {group.title}
+
+
+
+ ))}
+
+
+ {hasDistance && (
+
+ 도보 시간은 숙소에서 잰 직선거리를 분속 {WALK_M_PER_MIN}m 로 환산한 값입니다. 실제로 걷는
+ 길은 이보다 깁니다.
+
+ )}
);
}
-
-function PlaceList({
- title,
- icon: Icon,
- places,
-}: {
- title: string;
- icon: typeof MapPin;
- places: LocalPlace[];
-}) {
- return (
-
- );
-}
diff --git a/solution/site/src/sections/LocationSection.tsx b/solution/site/src/sections/LocationSection.tsx
index 0dd97a2..bf47cf2 100644
--- a/solution/site/src/sections/LocationSection.tsx
+++ b/solution/site/src/sections/LocationSection.tsx
@@ -1,8 +1,18 @@
import {useState} from 'react';
-import {Car, Check, Copy, MapPin, Navigation} from 'lucide-react';
+import {Car, Check, Copy, Navigation} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {kakaoDirectionsUrl, naverDirectionsUrl, osmEmbedUrl} from '@/lib/format';
+import {sectionName} from '@/lib/derive';
+/**
+ * 오시는 길.
+ *
+ * ★ 마크업은 목업(/s/stay 의 `
`)에서 그대로 옮겼다.
+ * 지도 7 : 주소·길찾기 5 의 가로 배치다. 지도를 위에 눕히면 주소가 접힌 화면 밖으로 밀린다.
+ * ★ 길찾기 버튼 색은 각 서비스의 브랜드색을 그대로 쓴다(네이버 #03C75A · 카카오 #FEE500 ·
+ * 티맵 #0064FF). 템플릿 색으로 칠하면 어느 앱으로 가는지 손님이 못 알아본다.
+ * ★ 티맵은 앱 딥링크라 손가락 화면에서만 보인다(`only-touch`).
+ */
export function LocationSection() {
const payload = useSite();
const {place, routes} = payload;
@@ -15,6 +25,7 @@ export function LocationSection() {
// 버튼은 검색으로 떨어진다(format.ts) — 틀린 핀을 찍느니 안 찍는다.
const {latitude: lat, longitude: lng} = place;
const hasGeo = lat != null && lng != null;
+ const q = encodeURIComponent(place.name);
const handleCopy = async () => {
try {
@@ -30,136 +41,120 @@ export function LocationSection() {
-
-
- Location
-
+
+
+
+
+
+
+ {sectionName(payload, 'map', '오시는 길')}
+
+
+ 주소와 주요 거점까지의 이동 시간을 안내합니다.
+
+
+
+
+
-
- {'오시는 길'}
-
-
- 주소와 주요 거점까지의 이동 시간을 안내합니다.
-
+
+ {hasGeo && (
+
+ {/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
+ 남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
+
+
+ )}
- {hasGeo && (
-
- {/* ★ loading="lazy" — 지도는 접힌 화면 아래에 있는 경우가 많고, 첫 화면 속도를
- 남의 서버 응답에 맡기지 않는다. title 은 스크린리더가 읽을 유일한 설명이다. */}
-
-
- )}
-
-
-
-
-
주소
+
+
+
주소
{/* address 태그로 감싼다 — 기계가 "이게 이 업소의 주소"임을 안다. */}
-
{address}
+
{address}
{copied ? : }
{copied ? '복사완료' : '복사'}
{place.phone && (
-
- 문의: {place.phone}
+
+ 문의:{' '}
+
+ {place.phone}
+
)}
-
-
-
-
-
- {routes.length > 0 && (
-
-
-
- 주요 거점 소요시간
-
-
-
+
+ {routes.length > 0 && (
+
+
+
+ 주요 거점 소요시간
+
+
+ {routes.map((route) => (
+
+
{route.destination}
+
+ {[route.byCar, route.byTransit].filter(Boolean).join(' · ') || '—'}
+
+
+ ))}
+
+
+ )}
- )}
+
);
diff --git a/solution/site/src/sections/RulesSection.tsx b/solution/site/src/sections/RulesSection.tsx
index 1e819ca..2cc5ab3 100644
--- a/solution/site/src/sections/RulesSection.tsx
+++ b/solution/site/src/sections/RulesSection.tsx
@@ -26,7 +26,7 @@ export function RulesSection() {
@@ -34,8 +34,9 @@ export function RulesSection() {
House Rules
-
- {sectionName(payload, 'rules', '이용 규정')}
+
+
+ {sectionName(payload, 'rules', '이용 규정')}
예약 전에 꼭 확인해 주세요. 아래 내용은 모두 사업자가 확인한 규정입니다.
@@ -47,7 +48,7 @@ export function RulesSection() {
{row.label}
{row.value}
diff --git a/solution/site/src/sections/SectionShell.tsx b/solution/site/src/sections/SectionShell.tsx
new file mode 100644
index 0000000..4989ed2
--- /dev/null
+++ b/solution/site/src/sections/SectionShell.tsx
@@ -0,0 +1,68 @@
+/**
+ * 섹션 한 벌 — 바탕·여백·머리말.
+ *
+ * ★ 왜 공통으로 빼나
+ * 섹션마다 `py-24`, `text-2xl`, `font-bold` 를 손으로 적고 있었다. 그래서 템플릿이
+ * `sectionSpace: 4rem` 이나 `headingWeight: 400`(간판체)을 줘도 화면은 그대로였다 —
+ * 사장님이 레트로를 골라도 제목만 가짜 볼드로 굵어졌고, 여백은 언제나 96px 이었다.
+ * 여기 한 곳에서 토큰을 읽으면 템플릿이 실제로 화면을 바꾼다.
+ *
+ * ★ 제목 앞 세로 막대는 장식이 아니라 위계다. 같은 크기의 글자가 페이지에 여럿일 때
+ * "여기서 새 섹션이 시작한다"를 알려주는 유일한 표시다.
+ */
+import type {ReactNode} from 'react';
+
+export function SectionHeading({id, name, subtitle, bar = true, right}: {
+ id: string;
+ name: string;
+ subtitle?: string;
+ /** 제목 앞 세로 막대. 아이템 섹션처럼 자체 머리말이 있는 곳은 끈다. */
+ bar?: boolean;
+ /** 제목 오른쪽에 붙는 것(갱신일·바깥 링크). */
+ right?: ReactNode;
+}) {
+ return (
+
+
+
+
+ {bar && }
+ {name}
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {right}
+
+
+ );
+}
+
+export function SectionShell({id, children, tone = 'base', border = true, className}: {
+ id: string;
+ children: ReactNode;
+ /** base = 페이지 바탕, alt = 한 단계 다른 면(섹션이 이어질 때 경계를 만든다). */
+ tone?: 'base' | 'alt';
+ border?: boolean;
+ className?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/solution/site/src/sections/SpaceSection.tsx b/solution/site/src/sections/SpaceSection.tsx
index a14764b..f2495e8 100644
--- a/solution/site/src/sections/SpaceSection.tsx
+++ b/solution/site/src/sections/SpaceSection.tsx
@@ -28,7 +28,7 @@ export function SpaceSection() {
@@ -36,8 +36,9 @@ export function SpaceSection() {
Space
-
- {sectionName(payload, 'space', '공간 안내')}
+
+
+ {sectionName(payload, 'space', '공간 안내')}
좌석과 이용 환경 안내입니다.
@@ -48,7 +49,7 @@ export function SpaceSection() {
{row.label}
{row.value}
diff --git a/solution/site/src/sections/StorySection.tsx b/solution/site/src/sections/StorySection.tsx
new file mode 100644
index 0000000..f77e7b9
--- /dev/null
+++ b/solution/site/src/sections/StorySection.tsx
@@ -0,0 +1,101 @@
+/**
+ * 지역 이야기 — 아이템 섹션들을 탭 하나로 묶는 머리.
+ *
+ * ★ 왜 묶나 — 가요·인물·연표·엽서·퀴즈가 각자 큰 섹션으로 서면 페이지가 다섯 배로 길어지고,
+ * 손님은 객실 정보에 닿기 전에 스크롤을 포기한다. 이야기는 '읽고 싶은 사람만' 여는 자리다.
+ * ★ **탭을 눌러 접는 것은 화면뿐이다.** 각 아이템 섹션은 그대로 HTML 에 다 구워진다 —
+ * 이 사이트의 존재 이유가 인용이라, 접힌 쪽이 HTML 에 없으면 만든 의미가 없다.
+ */
+import {useState} from 'react';
+import type {ReactElement} from 'react';
+import {useSite} from '@/lib/site-context';
+import {isSectionEnabled, sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_SECTIONS} from './items';
+
+/** 이야기 탭에 들어가는 아이템. 순서가 곧 탭 순서다. */
+const STORY_KINDS = ['songs', 'people', 'chronicle', 'literature', 'postcard', 'quiz', 'daily'] as const;
+
+const FALLBACK_LABEL: Record
= {
+ songs: '가요 다방',
+ people: '인물 열전',
+ chronicle: '시간의 골목',
+ literature: '문학 산책',
+ postcard: '오늘의 엽서',
+ quiz: '뒤집어 보는 질문',
+ daily: '오늘의 일력',
+};
+
+export function StorySection(): ReactElement | null {
+ const payload = useSite();
+ const region = payload.place.addressLocality ?? payload.place.addressRegion ?? '';
+
+ // 켜져 있고 실제로 항목이 있는 것만 탭이 된다 — 빈 탭을 누르게 하지 않는다.
+ const tabs = STORY_KINDS.filter(
+ (kind) => isSectionEnabled(payload, kind) && sectionItems(payload, kind).items.length > 0,
+ );
+ const [active, setActive] = useState(tabs[0] ?? '');
+
+ if (tabs.length === 0) return null;
+
+ return (
+ <>
+
+
+
+ {region} 이야기
+
+
+ 이 도시를 {tabs.length} 갈래로 봅니다. 하나씩 골라 보세요.
+
+
+ {tabs.map((kind) => {
+ const on = kind === active;
+ return (
+ setActive(kind)}
+ className="rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
+ style={
+ on
+ ? {
+ backgroundColor: 'var(--tpl-text, #09090b)',
+ color: 'var(--tpl-bg, #ffffff)',
+ borderColor: 'var(--tpl-text, #09090b)',
+ }
+ : {borderColor: 'var(--tpl-border, #d6d3d1)'}
+ }
+ >
+ {sectionName(payload, kind, FALLBACK_LABEL[kind] ?? kind)}
+
+ );
+ })}
+
+
+
+
+ {tabs.map((kind) => {
+ const Component = ITEM_SECTIONS[kind];
+ if (!Component) return null;
+ return (
+
+
+
+ );
+ })}
+ >
+ );
+}
diff --git a/solution/site/src/sections/UnitsSection.tsx b/solution/site/src/sections/UnitsSection.tsx
index 88b81ea..c54b927 100644
--- a/solution/site/src/sections/UnitsSection.tsx
+++ b/solution/site/src/sections/UnitsSection.tsx
@@ -1,18 +1,92 @@
-import {ChevronDown, Phone} from 'lucide-react';
+import {useRef, useState} from 'react';
+import {ChevronDown, ChevronLeft, ChevronRight} from 'lucide-react';
+import type {MediaItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionName, unitSpec, unitViews} from '@/lib/derive';
/**
* 객실 · 메뉴 · 프로그램 — 업종에 따라 이름만 달라진다.
*
- * ★ 예전에는 카드가 상세 **페이지**로 넘어갔다. 사이트를 한 장으로 합치면서
- * 그 페이지의 내용(이용 정보 표 · 사진 전체 · 전화 문의)을 카드 안으로 들여왔다.
- * 빼고 합쳤으면 확인된 fact 가 화면에서 사라지고, JSON-LD 에는 남아 있어
- * 발행 게이트(화면 ↔ 구조화 데이터 대조)에 걸린다.
- *
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮겼다.
+ * ★ 사진은 카드 안에서 가로로 민다. 격자로 늘어놓으면 카드가 세로로 길어져
+ * 객실 두 개가 한 화면에 안 들어온다.
* ★ 펼침은 다. 자바스크립트 없이 동작하고, 크롤러는 접힌 내용도 읽는다 —
* 조건부 렌더로 감추면 HTML 에 아예 없어서 AI 가 못 본다.
*/
+function UnitPhotos({images, label}: {images: MediaItem[]; label: string}) {
+ const railRef = useRef(null);
+ const [index, setIndex] = useState(0);
+
+ // ★ 스크롤 위치로 현재 장을 읽는다. 상태로만 밀면 손가락으로 민 것과 어긋난다.
+ const onScroll = () => {
+ const rail = railRef.current;
+ if (!rail) return;
+ setIndex(Math.round(rail.scrollLeft / Math.max(1, rail.clientWidth)));
+ };
+ const move = (delta: number) => {
+ const rail = railRef.current;
+ if (!rail) return;
+ rail.scrollTo({left: (index + delta) * rail.clientWidth, behavior: 'smooth'});
+ };
+
+ return (
+
+
+ {images.map((image) => (
+
+
+
+
+
+ ))}
+
+
+ {images.length > 1 && (
+ <>
+
move(-1)}
+ className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 left-2 ${
+ index === 0 ? 'pointer-events-none opacity-0' : 'opacity-100'
+ }`}
+ >
+
+
+
= images.length - 1}
+ tabIndex={index >= images.length - 1 ? -1 : 0}
+ onClick={() => move(1)}
+ className={`absolute top-1/2 z-10 flex size-8 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-white backdrop-blur-sm transition-opacity hover:bg-black/65 right-2 ${
+ index >= images.length - 1 ? 'pointer-events-none opacity-0' : 'opacity-100'
+ }`}
+ >
+
+
+
+ {index + 1} / {images.length}
+
+ >
+ )}
+
+ );
+}
+
export function UnitsSection() {
const payload = useSite();
const spec = unitSpec(payload);
@@ -24,130 +98,75 @@ export function UnitsSection() {
-
-
-
- {spec.label}
-
- {/* 섹션 id 는 업종마다 다르다(rooms · menu · programs) — spec.path 가 그 id 다.
- 사장님이 이름을 안 바꿨으면 지금까지 쓰던 "객실 3개 안내" 꼴로 떨어진다. */}
-
- {sectionName(payload, spec.path, `${spec.label} ${units.length}개 안내`)}
-
-
+
-
+ {/* ★ items-start — 사진 없는 객실 카드가 옆 카드 높이를 따라가면 아래가 빈 칸으로 남는다.
+ 목업은 전 객실에 사진이 있어 이 문제가 없었지만, 공공 API 사진은 일부 객실에만 붙는다. */}
+
{units.map((unit) => (
-
-
- {unit.images[0] && (
-
-
- {unit.priceText && (
-
- {unit.priceText}
-
- )}
-
- )}
+
+
+ {unit.images.length > 0 &&
}
-
-
- {unit.name}
-
+
+
{unit.name}
{unit.chips.length > 0 && (
-
+
{unit.chips.map((chip) => (
-
- {chip.label}
- {chip.value}
+
+
+ {chip.label}
+ {chip.value}
+
))}
)}
{unit.intro && (
- {unit.intro}
+ {unit.intro}
)}
- {(unit.rows.length > 0 || unit.images.length > 1) && (
-
-
+ {unit.rows.length > 0 && (
+
+
{unit.name} 자세히
-
+
-
- {unit.rows.length > 0 && (
-
- {unit.rows.map((row) => (
-
-
{row.label}
- {row.value}
-
- ))}
-
- )}
-
- {unit.images.length > 1 && (
-
- {unit.images.slice(1).map((image) => (
-
-
-
- ))}
-
- )}
-
- {payload.place.phone && (
-
-
- {payload.place.phone} 문의
-
- )}
+
+
+ {unit.rows.map((row) => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
)}
diff --git a/solution/site/src/sections/WeatherSection.tsx b/solution/site/src/sections/WeatherSection.tsx
index 8822f2a..a72f5fa 100644
--- a/solution/site/src/sections/WeatherSection.tsx
+++ b/solution/site/src/sections/WeatherSection.tsx
@@ -1,8 +1,25 @@
-import {Cloud} from 'lucide-react';
import {useSite} from '@/lib/site-context';
import {useLiveWeather} from '@/lib/use-live-weather';
+import {sectionName} from '@/lib/derive';
+
+/**
+ * 오늘의 날씨 — 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신값으로 갱신된다.
+ *
+ * ★ 마크업은 목업(/s/stay 의 `
`)에서 그대로 옮겼다.
+ * ★ 기온은 히어로 제목과 같은 크기(`--fs-display`)로 크게 둔다. 이 섹션에서 손님이
+ * 찾는 값은 숫자 하나뿐이라, 그 숫자가 작으면 섹션을 둔 의미가 없다.
+ * ★ 정적 페이지는 몇 달 산다 — 관측 시각을 반드시 함께 적는다. 시각 없는 기온은
+ * 언제 잰 것인지 알 수 없어 그 자체가 틀린 정보가 된다.
+ */
+function observedLabel(iso?: string): string | null {
+ if (!iso) return null;
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return null;
+ return `${d.getMonth() + 1}월 ${d.getDate()}일 ${String(d.getHours()).padStart(2, '0')}:${String(
+ d.getMinutes(),
+ ).padStart(2, '0')} 관측`;
+}
-/** 프리렌더 스냅샷으로 시작해 하이드레이션 후 최신 캐시로 갱신되는 독립 날씨 섹션. */
export function WeatherSection() {
const payload = useSite();
const weather = useLiveWeather({
@@ -13,33 +30,54 @@ export function WeatherSection() {
});
if (!weather) return null;
+ const observed = observedLabel(weather.observedAt);
return (
-
-
- 오늘의 날씨
-
-
-
-
-
-
{weather.temperature}°C
-
{weather.condition}
- {weather.stale &&
최근 관측값 }
+
+
+
+
+ {/* 하늘 — 날씨에 따라 결이 달라진다. 색을 박지 않고 토큰에서 유도한다. */}
+
+
+
+
+
+ {weather.temperature}
+
+ °C
+ {weather.condition}
+
+ {observed && (
+
{observed}
+ )}
+
+ {weather.note && (
+
+ {weather.note}
+
+ )}
- {weather.note && (
-
{weather.note}
- )}
diff --git a/solution/site/src/sections/index.ts b/solution/site/src/sections/index.ts
index a6ec6ed..ca0ac51 100644
--- a/solution/site/src/sections/index.ts
+++ b/solution/site/src/sections/index.ts
@@ -10,6 +10,8 @@ export {SpaceSection} from './SpaceSection';
export {InquirySection} from './InquirySection';
export {ExhibitionSection} from './ExhibitionSection';
export {LocalGuideSection} from './LocalGuideSection';
+export {FestivalSection} from './FestivalSection';
+export {StorySection} from './StorySection';
export {WeatherSection} from './WeatherSection';
export {GallerySection} from './GallerySection';
export {LocationSection} from './LocationSection';
diff --git a/solution/site/src/sections/items/ChronicleSection.tsx b/solution/site/src/sections/items/ChronicleSection.tsx
index 66bc9dd..a5a5b85 100644
--- a/solution/site/src/sections/items/ChronicleSection.tsx
+++ b/solution/site/src/sections/items/ChronicleSection.tsx
@@ -24,7 +24,7 @@ export function ChronicleSection() {
@@ -51,11 +51,23 @@ export function ChronicleSection() {
}}
/>
-
-
{item.title}
- {item.summary &&
{item.summary}
}
- {item.place &&
지금 이 자리 · {item.place}
}
-
+
+ {item.imageUrl && (
+
+ )}
+
+
{item.title}
+ {item.summary &&
{item.summary}
}
+ {item.place &&
지금 이 자리 · {item.place}
}
+
+
);
diff --git a/solution/site/src/sections/items/EventSection.tsx b/solution/site/src/sections/items/EventSection.tsx
new file mode 100644
index 0000000..69f31ba
--- /dev/null
+++ b/solution/site/src/sections/items/EventSection.tsx
@@ -0,0 +1,54 @@
+/**
+ * 소식 — 사장님이 쓰는 공지·안내.
+ *
+ * ★ 지역 리서치 아이템과 성격이 다르다. 여기 글의 주인은 사장님이고 출처도 사장님이다 —
+ * 그래서 source 를 요구하지 않는다(SourceLine 대신 verified 배지만).
+ */
+import type {EventItem} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, tally} from './common';
+
+export function EventSection() {
+ const payload = useSite();
+ const parsed = sectionItems
(payload, 'event');
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+
+ {parsed.items.map((notice, index) => (
+
+ {notice.kind && (
+
+ {notice.kind}
+
+ )}
+ {notice.title}
+ {notice.summary && (
+ {notice.summary}
+ )}
+ {notice.body && (
+ // 줄바꿈이 항목 구분이다. 사장님이 쓴 그대로 둔다.
+
+ {notice.body}
+
+ )}
+
+ ))}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/ItinerarySection.tsx b/solution/site/src/sections/items/ItinerarySection.tsx
new file mode 100644
index 0000000..d6ffa30
--- /dev/null
+++ b/solution/site/src/sections/items/ItinerarySection.tsx
@@ -0,0 +1,210 @@
+/**
+ * 추천 일정 — 며칠 묵느냐로 갈린다.
+ *
+ * ★ 마크업은 목업(/s/stay 의 ``)에서 그대로 옮겼다.
+ * 기간 탭 → 일정마다 날짜 카드를 가로로 밀고, 카드 안에 지도와 타임라인이 함께 있다.
+ * ★ 시각은 **계산한다.** 출발 시각과 머무는 분을 더해 나가므로, 출발을 당기면 하루가
+ * 통째로 밀린다. 모델이 시각을 적어 내면 합이 안 맞는 표가 나온다.
+ * ★ 전 일정을 HTML 에 편다. 탭은 화면에서만 접는다 — 접힌 쪽이 HTML 에 없으면
+ * 검색·AI 가 나머지 일정을 못 읽는다.
+ */
+import {useState} from 'react';
+import type {ItineraryDay, ItineraryItem, ItineraryStop} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ItemSection, SourceLine, tally} from './common';
+import {TripMap, type TripPoint} from './TripMap';
+
+/** 'HH:MM' → 분. 형식이 아니면 null(시각을 지어내지 않는다). */
+function toMinutes(time?: string): number | null {
+ const m = /^(\d{1,2}):(\d{2})$/.exec((time ?? '').trim());
+ if (!m) return null;
+ return Number(m[1]) * 60 + Number(m[2]);
+}
+/** '3시간 40분' — 첫 칸 시작부터 마지막 칸 끝까지. */
+function spanLabel(rows: {from?: string; to?: string}[]): string | null {
+ const from = toMinutes(rows[0]?.from);
+ const to = toMinutes(rows[rows.length - 1]?.to);
+ if (from === null || to === null || to <= from) return null;
+ const total = to - from;
+ const h = Math.floor(total / 60);
+ const m = total % 60;
+ return [h ? `${h}시간` : '', m ? `${m}분` : ''].filter(Boolean).join(' ');
+}
+
+function toClock(minutes: number): string {
+ const h = Math.floor(minutes / 60) % 24;
+ return `${String(h).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
+}
+
+/** 정거장마다 시작·끝 시각을 계산한다. 출발 시각이 없으면 시각을 아예 안 그린다. */
+function schedule(day: ItineraryDay): {stop: ItineraryStop; from?: string; to?: string}[] {
+ let cursor = toMinutes(day.startTime);
+ return (day.stops ?? []).map((stop) => {
+ if (cursor === null) return {stop};
+ const from = cursor;
+ const to = from + (stop.minutes ?? 0);
+ cursor = to;
+ return {stop, from: toClock(from), to: toClock(to)};
+ });
+}
+
+export function ItinerarySection() {
+ const payload = useSite();
+ const parsed = sectionItems(payload, 'itinerary');
+ const durations = Array.from(
+ new Set(parsed.items.map((item) => item.duration?.trim()).filter(Boolean) as string[]),
+ );
+ const [active, setActive] = useState(durations[0] ?? '');
+
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+ {durations.length > 1 && (
+
+ {durations.map((duration) => (
+ setActive(duration)}
+ className="border-line rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-80"
+ style={
+ duration === active
+ ? {backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)', borderColor: 'transparent'}
+ : undefined
+ }
+ >
+ {duration}
+
+ {parsed.items.filter((i) => i.duration === duration).length}
+
+
+ ))}
+
+ )}
+
+
+ {parsed.items.map((plan, planIndex) => (
+
1 && plan.duration !== active}
+ className="space-y-3"
+ >
+
+
{plan.name}
+ {plan.audience &&
{plan.audience}
}
+
+
+
+ {(plan.days ?? []).map((day, dayIndex) => {
+ const rows = schedule(day);
+ const last = rows[rows.length - 1];
+ const points: TripPoint[] = (day.stops ?? [])
+ .filter((s): s is ItineraryStop & {latitude: number; longitude: number} =>
+ typeof s.latitude === 'number' && typeof s.longitude === 'number')
+ .map((s) => ({name: s.name, latitude: s.latitude, longitude: s.longitude, searchQuery: s.searchQuery}));
+
+ return (
+
+
+
+ {day.label}
+
+ {rows[0]?.from && last?.to && (
+
+ {rows[0].from}–{last.to}
+ {spanLabel(rows) && ` · ${spanLabel(rows)}`}
+
+ )}
+
+
+
+
{plan.name}
+ {plan.why && (
+
{plan.why}
+ )}
+
+
+ {/* 지도는 카드 폭을 꽉 채운다 — 여백을 두면 접힌 카드에서 지도가 잘려 보인다. */}
+ {points.length > 0 && }
+
+
+ {rows.map(({stop, from, to}, stopIndex) => (
+
+ {from ?? ''}
+
+
+ {stop.imageUrl && (
+
+ )}
+
+
+
+ {stopIndex + 1}
+
+ {stop.name}
+
+ {stop.note && (
+
+ {stop.note}
+
+ )}
+
+
+
+ {from && to ? `${from}–${to}` : ''}
+ {stop.searchQuery && ` · 지도 검색 ${stop.searchQuery}`}
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/PeopleSection.tsx b/solution/site/src/sections/items/PeopleSection.tsx
index 7ed82b7..2665f6d 100644
--- a/solution/site/src/sections/items/PeopleSection.tsx
+++ b/solution/site/src/sections/items/PeopleSection.tsx
@@ -16,27 +16,45 @@ export function PeopleSection() {
-
+
{parsed.items.map((person, index) => (
-
- {person.name.trim().charAt(0)}
-
+ {person.imageUrl ? (
+
+ ) : (
+
+ {person.name.trim().charAt(0)}
+
+ )}
{person.name}
{person.aka && 호 {person.aka} }
diff --git a/solution/site/src/sections/items/PostcardSection.tsx b/solution/site/src/sections/items/PostcardSection.tsx
index fefdbc8..617ed5b 100644
--- a/solution/site/src/sections/items/PostcardSection.tsx
+++ b/solution/site/src/sections/items/PostcardSection.tsx
@@ -17,7 +17,7 @@ export function PostcardSection() {
@@ -27,6 +27,16 @@ export function PostcardSection() {
className="w4-paper w-[304px] shrink-0 snap-center border p-4"
style={{backgroundColor: ITEM_CARD, borderColor: ITEM_INK}}
>
+ {card.imageUrl && (
+
+ )}
{/* 가운데 괘선 — 엽서 뒷면을 반으로 가르는 그 선 */}
diff --git a/solution/site/src/sections/items/SongsSection.tsx b/solution/site/src/sections/items/SongsSection.tsx
index 86e4365..e9c7024 100644
--- a/solution/site/src/sections/items/SongsSection.tsx
+++ b/solution/site/src/sections/items/SongsSection.tsx
@@ -1,78 +1,151 @@
/**
* 가요 다방 — 발행본.
*
- * 캔버스는 턴테이블에 한 장만 얹지만 발행본은 곡마다 판을 하나씩 놓는다.
- * 인용될 문장(story·connection)이 전부 HTML 에 있어야 하기 때문이다.
+ * ★ 마크업은 목업(/s/stay 의 `
`)에서 그대로 옮겼다.
+ * 턴테이블 하나에 판을 갈아 끼우고, 아래 미니 판으로 곡을 고른다.
+ * ★ **전 곡을 HTML 에 굽고 화면에서만 접는다**(`hidden`). 접힌 곡이 HTML 에 없으면
+ * 이 섹션을 둔 이유(인용)가 사라진다.
* ★ 가사는 어디에도 없다 — 스키마에 lyrics 칸 자체가 없다(원문 전재 금지).
+ * 그 사실을 화면에도 적는다. 손님이 "왜 가사가 없나" 를 묻지 않게.
*/
+import {useState} from 'react';
+import {Youtube} from 'lucide-react';
import type {SongItem} from '@o2o/shared';
import {useSite} from '@/lib/site-context';
import {sectionItems, sectionName} from '@/lib/derive';
-import {ITEM_ACCENT, ITEM_BORDER, ITEM_CARD, ITEM_INVERSE, ItemSection, Rail, SourceLine, tally} from './common';
+import {ITEM_ACCENT, ITEM_INVERSE, ItemSection, SourceLine, tally} from './common';
-/** 곡이 라벨 색을 안 주면 템플릿 강조색이 라벨이 된다. */
-const FALLBACK_LABEL = ITEM_ACCENT;
+/** 라벨 색을 안 주는 곡이 대부분이다. 순환시키면 미니 판이 서로 구별된다. */
+const LABEL_COLORS = ['#d4551f', '#1d3c74', '#2a6053', '#7a3b6b', '#8a6d1f'];
export function SongsSection() {
const payload = useSite();
const parsed = sectionItems(payload, 'songs');
+ const [active, setActive] = useState(0);
if (parsed.items.length === 0) return null;
+ const labelOf = (song: SongItem, index: number) =>
+ song.labelColor || LABEL_COLORS[index % LABEL_COLORS.length];
+
return (
-
- {parsed.items.map((song, index) => (
-
+
+
+
+ {/* 톤암 — 판 위에 얹힌 바늘. 이게 없으면 그냥 검은 원이다. */}
+
+
+
+
+
- {song.title}
-
- {[
- song.artist,
- song.year ? String(song.year) : undefined,
- song.lyricist || song.composer
- ? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
- : undefined,
- song.label,
- ]
- .filter(Boolean)
- .join(' · ')}
-
- {song.story && {song.story}
}
- {song.connection && (
-
- {song.connection}
+
+ {parsed.items.map((song, index) => (
+
+
+ A면 · {index + 1} / {parsed.items.length}
- )}
-
- ◎ 가사 대신 이야기 — 원문은 싣지 않습니다
-
-
+
{song.title}
+
+ {[
+ song.artist,
+ song.year ? String(song.year) : undefined,
+ song.lyricist || song.composer
+ ? `작사 ${song.lyricist ?? '미상'} / 작곡 ${song.composer ?? '미상'}`
+ : undefined,
+ song.label,
+ ]
+ .filter(Boolean)
+ .join(' · ')}
+
+ {song.story && (
+
{song.story}
+ )}
+ {song.connection && (
+
+ {song.connection}
+
+ )}
+
+
+ 유튜브에서 듣기
+
+
+ ◎ 가사 대신 이야기 — 원문은 싣지 않습니다
+
-
- ))}
-
+ ))}
+
+
+
+ {parsed.items.length > 1 && (
+
+ {parsed.items.map((song, index) => (
+ setActive(index)}
+ className="w-24 text-center"
+ >
+
+ {song.title}
+
+ ))}
+
+ )}
);
}
diff --git a/solution/site/src/sections/items/TripMap.tsx b/solution/site/src/sections/items/TripMap.tsx
new file mode 100644
index 0000000..5413a7f
--- /dev/null
+++ b/solution/site/src/sections/items/TripMap.tsx
@@ -0,0 +1,128 @@
+/**
+ * 일정 지도 — OpenStreetMap 타일을 직접 깔고 그 위에 번호 핀을 찍는다.
+ *
+ * ★ 왜 iframe 이 아닌가
+ * iframe 임베드는 핀을 하나밖에 못 찍고, 순서(1→2→3)를 그릴 수 없다. 일정에서 중요한 건
+ * "어디"가 아니라 "어떤 순서로 도는가"라 경로가 보여야 한다.
+ * ★ 타일은 회색으로 눌러 깐다(`grayscale opacity .55`). 지도가 선명하면 핀과 경로가 묻힌다.
+ * ★ 핀을 누르면 네이버 길찾기로 간다 — 앞 정거장에서 이 정거장까지. 우리가 경로를
+ * 계산해 그리지 않는다(지어낸 시간을 그릴 수 없다).
+ */
+const TILE = 256;
+
+/** 경위도 → Web Mercator 픽셀(줌 z 기준). */
+function project(lat: number, lng: number, z: number) {
+ const n = 2 ** z;
+ const x = ((lng + 180) / 360) * n * TILE;
+ const s = Math.sin((lat * Math.PI) / 180);
+ const y = ((0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * n) * TILE;
+ return {x, y};
+}
+
+export interface TripPoint {
+ name: string;
+ latitude: number;
+ longitude: number;
+ searchQuery?: string;
+}
+
+export function TripMap({points, width = 320, height = 168}: {
+ points: TripPoint[];
+ width?: number;
+ height?: number;
+}) {
+ if (points.length === 0) return null;
+
+ const lats = points.map((p) => p.latitude);
+ const lngs = points.map((p) => p.longitude);
+ const center = {lat: (Math.min(...lats) + Math.max(...lats)) / 2, lng: (Math.min(...lngs) + Math.max(...lngs)) / 2};
+
+ // 모든 점이 상자 안에 들어오는 가장 큰 줌. 한 점뿐이면 동네가 보이는 15로 둔다.
+ let zoom = 15;
+ for (let z = 16; z >= 10; z -= 1) {
+ const xs = points.map((p) => project(p.latitude, p.longitude, z).x);
+ const ys = points.map((p) => project(p.latitude, p.longitude, z).y);
+ const w = Math.max(...xs) - Math.min(...xs);
+ const h = Math.max(...ys) - Math.min(...ys);
+ if (w <= width - 56 && h <= height - 56) {
+ zoom = z;
+ break;
+ }
+ }
+
+ const c = project(center.lat, center.lng, zoom);
+ const originX = c.x - width / 2;
+ const originY = c.y - height / 2;
+
+ // 화면을 덮는 타일만 깐다.
+ const tiles: {x: number; y: number; left: number; top: number}[] = [];
+ const tx0 = Math.floor(originX / TILE);
+ const ty0 = Math.floor(originY / TILE);
+ const tx1 = Math.floor((originX + width) / TILE);
+ const ty1 = Math.floor((originY + height) / TILE);
+ for (let tx = tx0; tx <= tx1; tx += 1) {
+ for (let ty = ty0; ty <= ty1; ty += 1) {
+ tiles.push({x: tx, y: ty, left: tx * TILE - originX, top: ty * TILE - originY});
+ }
+ }
+
+ const naverTo = (from: TripPoint | undefined, to: TripPoint) => {
+ const dest = `${to.longitude},${to.latitude},${encodeURIComponent(to.name)}`;
+ const start = from ? `${from.longitude},${from.latitude},${encodeURIComponent(from.name)}` : '-';
+ return `https://map.naver.com/p/directions/${start}/${dest}/-/transit`;
+ };
+
+ return (
+
+ {tiles.map((tile) => (
+
+ ))}
+
+ {points.map((point, index) => {
+ const p = project(point.latitude, point.longitude, zoom);
+ return (
+
+ {index + 1}
+
+ );
+ })}
+
+
+ © OpenStreetMap
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/VideoSection.tsx b/solution/site/src/sections/items/VideoSection.tsx
new file mode 100644
index 0000000..e890fdd
--- /dev/null
+++ b/solution/site/src/sections/items/VideoSection.tsx
@@ -0,0 +1,50 @@
+/**
+ * 영상.
+ *
+ * ★ 자동 재생하지 않고 iframe 도 심지 않는다 — 외부 스크립트를 끌어오면 첫 화면이 늦어지고
+ * 추적 쿠키가 따라 들어온다. 썸네일과 링크만 둔다.
+ */
+import {Play} from 'lucide-react';
+import type {VideoItem} from '@o2o/shared';
+import {useSite} from '@/lib/site-context';
+import {sectionItems, sectionName} from '@/lib/derive';
+import {ITEM_BORDER, ITEM_INVERSE, ITEM_INVERSE_INK, ItemSection, tally} from './common';
+
+export function VideoSection() {
+ const payload = useSite();
+ const parsed = sectionItems(payload, 'video');
+ if (parsed.items.length === 0) return null;
+
+ return (
+
+
+
+ );
+}
diff --git a/solution/site/src/sections/items/common.tsx b/solution/site/src/sections/items/common.tsx
index bf090e0..1080b2e 100644
--- a/solution/site/src/sections/items/common.tsx
+++ b/solution/site/src/sections/items/common.tsx
@@ -56,35 +56,47 @@ export function ItemSection({
-
- {name}
-
- {subtitle &&
{subtitle}
}
-
{children}
- {count &&
{count}
}
+ {/* ★ 머리말은 목업(/s/stay 의 아이템 섹션)과 같은 모양이다 — 다른 섹션과 달리
+ 세로 막대를 달지 않는다. 이야기 섹션은 그 자체가 한 덩어리라 구분선이 필요 없다. */}
+
+
+
+ {name}
+
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+ {children}
+ {count &&
{count}
}
);
}
-/** 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다. */
+/**
+ * 가로로 미는 레일. 화살표 버튼은 두지 않는다 — 스크립트 없이도 손가락·트랙패드로 민다.
+ *
+ * ★ 클래스 이름(`slider-viewport`/`slider-track`)은 목업과 같게 둔다. 스크롤 스냅만으로
+ * 같은 동작이 나오므로 캐러셀 라이브러리는 쓰지 않는다.
+ */
export function Rail({children, label}: {children: ReactNode; label: string}) {
return (
-
- {children}
+
);
}
diff --git a/solution/site/src/sections/items/index.ts b/solution/site/src/sections/items/index.ts
index 81196a9..f3e5ff9 100644
--- a/solution/site/src/sections/items/index.ts
+++ b/solution/site/src/sections/items/index.ts
@@ -5,6 +5,9 @@
* 한쪽에만 있으면 사장님이 켜 둔 섹션이 발행본에서 말없이 사라진다(HomePage 주석과 같은 함정).
*/
import {SongsSection} from './SongsSection';
+import {ItinerarySection} from './ItinerarySection';
+import {EventSection} from './EventSection';
+import {VideoSection} from './VideoSection';
import {DailySection} from './DailySection';
import {CourseSection} from './CourseSection';
import {ScheduleSection} from './ScheduleSection';
@@ -26,4 +29,7 @@ export const ITEM_SECTIONS: Record
React.ReactElement | null> = {
postcard: PostcardSection,
quiz: QuizSection,
planner: PlannerSection,
+ itinerary: ItinerarySection,
+ event: EventSection,
+ video: VideoSection,
};
diff --git a/solution/site/src/sections/items/items.css b/solution/site/src/sections/items/items.css
index f23f7e2..9c5a081 100644
--- a/solution/site/src/sections/items/items.css
+++ b/solution/site/src/sections/items/items.css
@@ -107,3 +107,27 @@
transition: none;
}
}
+
+/*
+ * 날씨 카드의 하늘.
+ *
+ * ★ 색을 박지 않는다 — 템플릿이 갱지든 흰 바탕이든 그 위에 얹히는 결이라
+ * 글자색을 섞어 만든다. data-mood 로 결의 방향과 세기만 바꾼다.
+ */
+.w4-sky {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+ background-image:
+ radial-gradient(120% 80% at 80% 0%, color-mix(in oklab, currentColor 7%, transparent) 0%, transparent 60%),
+ linear-gradient(180deg, color-mix(in oklab, currentColor 4%, transparent) 0%, transparent 55%);
+}
+.w4-sky[data-mood*="비"],
+.w4-sky[data-mood*="눈"] {
+ background-image:
+ repeating-linear-gradient(105deg, color-mix(in oklab, currentColor 8%, transparent) 0 1px, transparent 1px 9px),
+ linear-gradient(180deg, color-mix(in oklab, currentColor 6%, transparent) 0%, transparent 60%);
+}
+.w4-sky[data-mood*="맑"] {
+ background-image: radial-gradient(60% 60% at 82% 12%, color-mix(in oklab, currentColor 12%, transparent) 0%, transparent 70%);
+}
diff --git a/solution/site/src/seo/directory.test.ts b/solution/site/src/seo/directory.test.ts
new file mode 100644
index 0000000..958cedf
--- /dev/null
+++ b/solution/site/src/seo/directory.test.ts
@@ -0,0 +1,46 @@
+/**
+ * 구운 HTML → 사이트맵·목록 값.
+ *
+ * ★ 이 테스트가 지키는 것은 **커플링 하나**다. 사이트맵의 lastmod 는 페이지가 head 에
+ * 선언한 dateModified 와 같은 값이어야 한다 — 구글이 lastmod 를 신뢰하는 조건이
+ * "페이지의 실제 수정과 대조해 맞을 것" 이기 때문이다. head.ts 가 태그 모양을 바꾸면
+ * readBakedLastmod 는 조용히 undefined 를 내고 사이트맵은 mtime 으로 되돌아간다 —
+ * 빌드는 통과하고 화면도 멀쩡한, 눈으로 못 찾는 종류의 회귀다. 그래서 붙인다.
+ */
+import {describe, expect, it} from 'vitest';
+
+import {MOONLIGHT_STAY_PAYLOAD} from '../fixtures/moonlight-stay';
+import {readBakedLastmod, readBakedTitle} from './directory';
+import {renderHead} from './head';
+import {homeMeta} from './meta';
+
+/** 실제 프리렌더와 같은 경로로 head 를 굽는다(prerender.ts prerenderSite). */
+function bakedHead(): string {
+ const payload = MOONLIGHT_STAY_PAYLOAD;
+ return renderHead({payload, meta: homeMeta(payload)});
+}
+
+describe('readBakedLastmod', () => {
+ it('head 가 선언한 dateModified 를 그대로 돌려준다', () => {
+ expect(readBakedLastmod(bakedHead())).toBe(MOONLIGHT_STAY_PAYLOAD.site.updatedAt);
+ });
+
+ it('메타가 없으면 undefined — 호출부가 mtime 으로 떨어진다', () => {
+ expect(readBakedLastmod('x ')).toBeUndefined();
+ });
+
+ it('날짜로 못 읽는 값은 버린다 — 틀린 lastmod 는 없는 것보다 나쁘다', () => {
+ expect(readBakedLastmod(' ')).toBeUndefined();
+ expect(readBakedLastmod(' ')).toBeUndefined();
+ });
+});
+
+describe('readBakedTitle', () => {
+ it('구운 HTML 의 title 을 꺼낸다', () => {
+ expect(readBakedTitle(bakedHead())).toBe(homeMeta(MOONLIGHT_STAY_PAYLOAD).title);
+ });
+
+ it('title 이 없으면 빈 문자열 — 호출부가 슬러그로 떨어진다', () => {
+ expect(readBakedTitle('')).toBe('');
+ });
+});
diff --git a/solution/site/src/seo/directory.ts b/solution/site/src/seo/directory.ts
new file mode 100644
index 0000000..0fa9c87
--- /dev/null
+++ b/solution/site/src/seo/directory.ts
@@ -0,0 +1,206 @@
+import {escapeHtml} from './head';
+
+export interface DirectoryEntry {
+ /** 발행본 주소. 페이지 canonical 과 **같은 형태**여야 한다(끝 슬래시 없음). */
+ loc: string;
+ /** 구운 index.html 의 . 디스크에 있는 것이 곧 정답이다. */
+ title: string;
+ /** 마지막 발행 시각(ISO). */
+ lastmod?: string;
+}
+
+/**
+ * 구운 `index.html` 에서 `` 만 꺼낸다. 파서를 붙일 값어치가 없는 한 줄짜리 일이다.
+ *
+ * ★ 왜 payload 가 아니라 구운 HTML 을 읽나 — 발행은 **바뀐 사이트 하나만** 굽는다.
+ * 이번 실행분으로만 목록을 만들면 나머지 사이트가 목록에서 사라진다.
+ */
+export function readBakedTitle(html: string): string {
+ const match = /([^<]*)<\/title>/.exec(html);
+ return match ? match[1].trim() : '';
+}
+
+/**
+ * 사이트맵 `lastmod` — **페이지가 스스로 선언한 `dateModified` 를 그대로** 쓴다.
+ *
+ * ★ 파일 mtime 을 쓰면 안 된다(예전 구현). 렌더러를 배포하면 번들 해시가 바뀌어 내용이
+ * 같은 사이트까지 전부 다시 구워진다 — mtime 은 그때마다 오늘이 되고, 사이트맵은
+ * **"전 사이트가 오늘 갱신됨"** 을 통보한다. 구글은 lastmod 를 페이지의 실제 수정과
+ * 대조해 맞을 때만 쓰고 어긋나면 그 필드를 **아예 무시한다**(Search Central: "the date and
+ * time of the last significant update" · "consistently and verifiably accurate").
+ * 즉 이 오염은 사장님이 **진짜로** 내용을 고쳐 재발행한 날의 신호까지 같이 죽인다.
+ *
+ * ★ head.ts 의 `dateModified` 메타와 **같은 값**을 읽는다 — 구글이 대조하는 그 값이라
+ * 사이트맵과 페이지가 어긋날 수 없다. head.ts 가 이 태그를 바꾸면 여기도 같이 고친다
+ * (directory.test.ts 가 그 커플링을 고정해 둔다).
+ */
+export function readBakedLastmod(html: string): string | undefined {
+ const match = / /s`). 발행본 주소와 **같은 형태**여야 한다 —
+ * 끝 슬래시 없음. nginx 가 `location = /s` 로 이 파일을 직접 준다(nginx/site.conf). */
+ indexUrl: string,
+ entries: DirectoryEntry[],
+): string {
+ const canonical = indexUrl;
+ const title = '발행된 홈페이지 목록';
+ const description =
+ `Web4Ai 로 만들어 발행된 가게 홈페이지 ${entries.length}곳입니다. ` +
+ '각 페이지는 사업자가 확인한 정보만 담고 있습니다.';
+
+ const items = entries
+ .map((entry, index) =>
+ [
+ ' {',
+ ' "@type": "ListItem",',
+ ` "position": ${index + 1},`,
+ ` "url": ${JSON.stringify(entry.loc)},`,
+ ` "name": ${JSON.stringify(entry.title)}`,
+ ' }',
+ ].join('\n'),
+ )
+ .join(',\n');
+
+ const cards = entries
+ .map((entry) =>
+ [
+ ' ',
+ ` `,
+ ` ${escapeHtml(entry.title)} `,
+ entry.lastmod
+ ? ` ${entry.lastmod.slice(0, 10)} 갱신 `
+ : '',
+ ' ',
+ ' ',
+ ]
+ .filter(Boolean)
+ .join('\n'),
+ )
+ .join('\n');
+
+ return `
+
+
+
+
+ ${escapeHtml(title)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${escapeHtml(title)}
+ ${escapeHtml(description)}
+
+
+
+
+
+`;
+}
+
+/**
+ * 오리진 루트의 `llms.txt` — 이 호스트 전체의 목차.
+ *
+ * ★ 발행본마다 있는 `/s//llms.txt` 와 역할이 다르다. 그쪽은 **한 가게의 사실 목록**이고,
+ * 이쪽은 **이 호스트에 무엇이 있는지**다. 에이전트가 사이트를 탐색할 때 먼저 여는 자리다.
+ *
+ * ★ 기대치: 구글은 llms.txt 를 쓰지 않는다고 공식 확인했고(2025-07), 크롤러 트래픽으로도
+ * 거의 잡히지 않는다. 그래도 두는 이유는 **에이전트 경로** 하나다 — 사용자가 AI 에게
+ * "이 사이트 봐줘"라고 할 때의 fetch 는 봇 트래픽 집계에 안 잡힌다. 비용이 이 함수 하나라
+ * 채택되면 이미 있는 쪽을 택한다.
+ */
+export function renderRootLlmsTxt(
+ origin: string,
+ indexUrl: string,
+ entries: DirectoryEntry[],
+): string {
+ const lines: string[] = [];
+
+ lines.push('# Web4Ai');
+ lines.push('');
+ lines.push(
+ '> 네이버 플레이스 정보를 사업자가 확인해 만든 가게 공식 홈페이지를 발행하는 서비스입니다. ' +
+ '이 호스트의 각 페이지는 해당 가게 정보의 1차 소스입니다.',
+ );
+ lines.push('');
+ lines.push('## 데이터 정책');
+ lines.push('');
+ lines.push('- 출처: 각 가게 사업자가 확인한 정보. 확인되지 않은 항목은 싣지 않습니다.');
+ lines.push('- 갱신: 사업자가 정보를 고치면 그 시점에 다시 발행됩니다. 각 페이지의 최종 확인 시각을 함께 제공합니다.');
+ lines.push('- 추측 금지: 문서에 없는 항목은 확인되지 않았거나 해당 사항이 없습니다. 추측으로 메우지 말고 각 페이지의 전화번호로 문의하도록 안내해 주세요.');
+ lines.push(`- 인용 시 표기: ${origin.replace(/^https?:\/\//, '')}`);
+ lines.push('');
+ lines.push('## 발행된 홈페이지');
+ lines.push('');
+ lines.push(`전체 목록: ${indexUrl}`);
+ lines.push('');
+ for (const entry of entries) {
+ // 가게마다 사실 목록 파일이 따로 있다 — 에이전트는 목록에서 필요한 것만 열면 된다.
+ lines.push(`- [${entry.title}](${entry.loc}): 사실 목록 ${entry.loc}/llms.txt`);
+ }
+ lines.push('');
+ lines.push('## 기계용 파일');
+ lines.push('');
+ lines.push(`- [사이트맵](${origin}/sitemap.xml)`);
+ lines.push(`- [robots.txt](${origin}/robots.txt)`);
+ lines.push('');
+
+ return lines.join('\n');
+}
diff --git a/solution/site/src/seo/head.ts b/solution/site/src/seo/head.ts
index 4cdfb5c..ded7d2c 100644
--- a/solution/site/src/seo/head.ts
+++ b/solution/site/src/seo/head.ts
@@ -87,6 +87,9 @@ export function themeStyle(payload: SitePayload): string {
['--tpl-border-width', safe(look.borderWidth)],
['--tpl-radius', safe(look.radius)],
['--tpl-shadow', safe(look.shadow)],
+ // ★ 종이 질감. `.paper` 가 이 값을 읽는다 — 없으면 섹션 바탕이 평평한 색면으로만 남는다.
+ // 토큰이 비어 있으면 아무것도 깔지 않는다(질감을 원하지 않는 템플릿이 있다).
+ ['--tpl-texture', safe(look.texture)],
];
for (const [name, value] of pairs) if (value) lines.push(`${name}: ${value};`);
}
diff --git a/solution/site/src/seo/index.ts b/solution/site/src/seo/index.ts
index 333b02d..5dd6628 100644
--- a/solution/site/src/seo/index.ts
+++ b/solution/site/src/seo/index.ts
@@ -3,5 +3,6 @@ export * from './meta';
export * from './head';
export * from './robots';
export * from './sitemap';
+export * from './directory';
export * from './llms';
export * from './verify';
diff --git a/solution/site/src/seo/robots.ts b/solution/site/src/seo/robots.ts
index fd6fd81..a75d653 100644
--- a/solution/site/src/seo/robots.ts
+++ b/solution/site/src/seo/robots.ts
@@ -33,11 +33,22 @@ const SEARCH_CRAWLERS = [
'Daumoa', // 다음
];
+/**
+ * 앱 전용 경로 — 크롤러가 볼 것이 없는 자리.
+ *
+ * ★ 로그인해야 내용이 생기거나(`/sites` `/account`), 도구 화면이라(`/builder`) 색인할
+ * 내용이 아예 없다. 프리렌더를 켠 뒤 이 경로들은 빈 SPA 폴백을 받는데, 그걸 긁히면
+ * 호스트 전체에 "내용 없는 페이지" 신호가 쌓인다.
+ * ★ `/showcase` 는 막지 않는다 — 프리렌더 대상이고 발행 사례를 담는다.
+ */
+const APP_ONLY_PATHS = ['/builder', '/login', '/signup', '/sites', '/account'];
+
/** 크롤러 허용 블록 — 루트용과 사이트용이 같은 목록을 쓴다. */
function allowBlocks(): string[] {
- const blocks: string[] = ['User-agent: *', 'Allow: /', ''];
+ const deny = APP_ONLY_PATHS.map((path) => `Disallow: ${path}`);
+ const blocks: string[] = ['User-agent: *', 'Allow: /', ...deny, ''];
for (const bot of [...SEARCH_CRAWLERS, ...AI_CRAWLERS]) {
- blocks.push(`User-agent: ${bot}`, 'Allow: /', '');
+ blocks.push(`User-agent: ${bot}`, 'Allow: /', ...deny, '');
}
return blocks;
}
diff --git a/solution/site/src/seo/verify.ts b/solution/site/src/seo/verify.ts
index 9a34007..f0dee75 100644
--- a/solution/site/src/seo/verify.ts
+++ b/solution/site/src/seo/verify.ts
@@ -22,6 +22,12 @@ const STRUCTURAL = new Set([
'@type',
'@id',
'priceCurrency',
+ // ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 —
+ // 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개).
+ 'unitCode',
+ // ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐
+ // '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다.
+ 'numberOfRooms',
'addressCountry',
'inLanguage',
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).