Merge branch 'main' into feat-tourApi

This commit is contained in:
김성경 2026-09-08 13:09:31 +09:00
commit 3b43446fe6
131 changed files with 4624 additions and 1558 deletions

3
.gitignore vendored
View File

@ -26,6 +26,9 @@ solution/backend/openapi.json
# 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다. # 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다.
solution/site/out/ solution/site/out/
solution/site/dist/ solution/site/dist/
# React Router 프레임워크 모드(solution/frontend)의 산출물
build/
.react-router/
admin/dist/ admin/dist/
# nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다. # nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다.

View File

@ -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` 를 **루트 절대경로**로 - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이 - **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다
사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다. `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`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 — `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
`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_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
@ -50,8 +73,15 @@
- **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`). - **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`).
- **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` - **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()`
`solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다. `solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다.
- **디렉토리 요청 → `index.html`.** `/s/<slug>`**끝 슬래시 없이** 열려야 한다. - **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.**
정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다. 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 다.
## 코드 규약 ## 코드 규약

View File

@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200
OWNER role=2 → 403 OWNER role=2 → 403
``` ```
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 `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`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner`

View File

@ -88,9 +88,9 @@
| 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 |
| DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 | | 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 가 생기는 순간 다시 필요해진다 | | 마이그레이션 | 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 모듈과 함께 재이식 예정 | | 뺀 것 | 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 | 원본이 구간을 나눠 쓰는 방식 유지 | | ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 |
| 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 | | 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 |
| 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 | | 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 |

View File

@ -66,18 +66,28 @@ site-out/
``` ```
프론트 수정 → 새 번들(새 해시) 프론트 수정 → 새 번들(새 해시)
로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐
Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제 Azure : 새 해시 추가, 옛 해시 유지 ← 같다
``` ```
프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 **2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가
하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다.
`solution/backend/scripts/republish_all.py` 다. 지금은 `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` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
```bash ```bash
docker compose restart solution-frontend # 기동하며 전체 재굽기 docker compose restart solution-prerender # 기동하며 전체 재굽기
docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기 docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기
docker compose exec solution-worker python scripts/republish_all.py 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. 되돌리기 ## 5. 되돌리기
`out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면
`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`. `docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`.
지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다.

View File

@ -5,6 +5,227 @@
--- ---
## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다
**무슨 일**
사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지
(`/s/<slug>`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/`**슬래시로 시작하는 것만** 잡고,
`/s` 는 맨 아래 `location /` 로 떨어진다.
**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
(실측: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
**바꾼 것**
- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/`
거기로 301. `^~ /s/``index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
- `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/<slug>/` 는 여전히 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/<slug>.<ext>` 로 고정이고 내용만 `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` ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai`
**왜** **왜**

View File

@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
RUN npm run build -w @o2o/frontend RUN npm run build -w @o2o/frontend
FROM nginx:alpine 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

View File

@ -44,8 +44,32 @@ server {
image/svg+xml; image/svg+xml;
# ── 발행 사이트 ──────────────────────────────────────────── # ── 발행 사이트 ────────────────────────────────────────────
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
# `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다.
absolute_redirect off;
# ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/<slug>`)와 형태를 맞춘다.
# 이 블록이 없으면 `/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 들이 끼어들지 못하게 한다.
location ^~ /s/ { location ^~ /s/ {
# ★ `/s/<slug>/`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝
# 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 —
# index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다).
index index.html;
# $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를 # $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를
# 한 번 더 타야 하므로 index.html 을 바로 준다. # 한 번 더 타야 하므로 index.html 을 바로 준다.
try_files $uri $uri/index.html =404; try_files $uri $uri/index.html =404;
@ -117,12 +141,16 @@ server {
try_files $uri =404; try_files $uri =404;
} }
# SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다. # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다.
# ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면 # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면
# 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로
# 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다.
# 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다.
# ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도
# 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면).
location / { location / {
root /srv/app; 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"; add_header Cache-Control "no-cache";
} }

514
package-lock.json generated
View File

@ -215,6 +215,19 @@
"node": ">=6.9.0" "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": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", "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": ">=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": { "node_modules/@babel/helper-globals": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
@ -242,6 +277,20 @@
"node": ">=6.9.0" "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": { "node_modules/@babel/helper-module-imports": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", "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" "@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": { "node_modules/@babel/helper-plugin-utils": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", "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": ">=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": { "node_modules/@babel/helper-string-parser": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", "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": ">=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": { "node_modules/@babel/plugin-transform-react-jsx-self": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", "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" "@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": { "node_modules/@babel/template": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
@ -1425,6 +1608,12 @@
"jsep": "^0.4.0||^1.0.0" "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": { "node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", "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" "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": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3", "version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", "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" "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": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -3478,6 +3821,19 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "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": "^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": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@ -3767,6 +4133,13 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "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" "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": { "node_modules/expect-type": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@ -4565,6 +4966,13 @@
"node": ">=16.9.0" "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": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -5640,6 +6048,15 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -6826,6 +7243,19 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@ -6895,6 +7325,18 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/pony-cause": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
@ -6953,6 +7395,22 @@
"node": ">= 0.8.0" "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": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -7042,9 +7500,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.2", "version": "7.18.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz",
"integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@ -8137,7 +8595,7 @@
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -8272,6 +8730,21 @@
"node": ">= 4" "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": { "node_modules/validator": {
"version": "13.15.23", "version": "13.15.23",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", "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": { "node_modules/vite/node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -8791,10 +9294,12 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*", "@o2o/shared": "*",
"@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^5",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
@ -8808,6 +9313,7 @@
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@ -5,7 +5,7 @@
-- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. -- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다.
-- postgres (1개 서버, 5432) -- postgres (1개 서버, 5432)
-- └── web4ai_db -- └── web4ai_db
-- ├── company : companies, users -- ├── company : users
-- ├── place : places, place_aliases, place_links, units, media -- ├── place : places, place_aliases, place_links, units, media
-- ├── fact : facts, faqs -- ├── fact : facts, faqs
-- ├── local : local_contents, routes, nearby_links -- ├── local : local_contents, routes, nearby_links
@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site;
CREATE SCHEMA IF NOT EXISTS job; 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 ( CREATE TABLE IF NOT EXISTS company.users (
user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) 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_<sub>) id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_<sub>)
password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL
name VARCHAR(50) NULL, -- 이름 name VARCHAR(50) NULL, -- 이름
@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users (
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS place.places ( CREATE TABLE IF NOT EXISTS place.places (
place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK)
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id) owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id)
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값) name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과 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 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)로 건다. -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(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_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 -- 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_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_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); 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 가 조용히 깨졌다 -- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다
-- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). -- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다).
ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; 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;

View File

@ -10,7 +10,6 @@ from common.enums import (
DBType, DBType,
UserStatus, UserStatus,
UserRole, UserRole,
CompanyStatus,
PlaceStatus, PlaceStatus,
FactStatus, FactStatus,
MediaStatus, MediaStatus,
@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin):
# ERD 도메인 모델 # 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): class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = {"schema": "company"} __table_args__ = {"schema": "company"}
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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_<sub>`(최대 28자)로 만들면서 넓혔다 — # 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 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) 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) # 상호명(입력값) name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)

View File

@ -67,9 +67,8 @@ class PageParams:
class UserInfo(StructModel): class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보.""" """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 재조회 키 id: str # users.id (로그인 아이디) — get_me 재조회 키
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:

View File

@ -14,7 +14,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE 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 from config.server_configs import main_db_config
@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle):
# 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute( await conn.execute(
text( 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, " "facts, faqs, local_contents, routes, nearby_links, "
"sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE" "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 @pytest_asyncio.fixture
async def company_id(db_engine) -> str: async def owner_id(db_engine) -> str:
"""테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다. """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
users company_id 요구하므로 계정 생성 테스트의 선행 조건이다.
예전엔 `company_id`(소속사)였다. 회사(테넌트) 걷어내면서 사업장이 `owner_user_id`
계정에 직접 매이게 됐다 DB 직접 시드하는 테스트가 place 넣을 주인이 값이다.
""" """
cid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as conn: 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( await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), text(
{"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value}, "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) return str(uid)
@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)
@pytest_asyncio.fixture @pytest_asyncio.fixture
@ -139,28 +133,27 @@ async def client(db_engine):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def auth_headers(db_engine, client, company_id): async def auth_headers(db_engine, client):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
계정 생성 API 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시) /v1/auth/login 으로 토큰을 받는다. 계정 생성 API 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시) /v1/auth/login 으로 토큰을 받는다.
company 미지정 기본 소속사(company_id 픽스처). role OWNER 계정도 만들 있다. 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 부르면 그게 **이다
호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`. 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
호출: `h = await auth_headers("user1")`.
""" """
from common.enums import UserRole, UserStatus
from router.v1.validator.dependencies import GetHashedPW from router.v1.validator.dependencies import GetHashedPW
async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"): async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
cid = company or company_id
hashed = await GetHashedPW(password) hashed = await GetHashedPW(password)
async with db_engine.begin() as conn: 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( await conn.execute(
text( text(
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())" "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, "name": name, "status": UserStatus.ACTIVE.value, "role": role,
}, },
) )

View File

@ -11,26 +11,26 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다. # 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
class IPlaceCRUD(ABC): class IPlaceCRUD(ABC):
@abstractmethod @abstractmethod
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
pass pass
@abstractmethod @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 pass
@abstractmethod @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 pass
@abstractmethod @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 pass
@abstractmethod @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 pass
@abstractmethod @abstractmethod
@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED 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: try:
query = ( query = (
select(places) 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) .limit(1)
) )
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def list_places( 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, status: Optional[int], skip: int, limit: int,
) -> Tuple[ErrorType, list, int]: ) -> Tuple[ErrorType, list, int]:
try: 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: if category is not None:
conditions.append(places.category == category) conditions.append(places.category == category)
if status is not None: if status is not None:
@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 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, 적용행수).""" """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
try: try:
if not data: if not data:
return ErrorType.SUCCESS, 0 return ErrorType.SUCCESS, 0
query = ( query = (
update(places) 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()) .values(**data, updated_at=GTime.UTC())
) )
return await DB_SESSION_MNG.add_with_rowcount(cdb, query) return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0 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: try:
query = ( query = (
delete(places) 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) return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex: except Exception as ex:

View File

@ -22,7 +22,7 @@ class ISiteCRUD(ABC):
pass pass
@abstractmethod @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 pass
@abstractmethod @abstractmethod
@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). """장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 ) 내려간다 빠지면 만들다 것을 찾을 길이 없다.""" (위저드만 걸어온 ) 내려간다 빠지면 만들다 것을 찾을 길이 없다."""
try: 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)) cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS: if cnt_err != ErrorType.SUCCESS:

View File

@ -1,12 +1,12 @@
from abc import ABC, abstractmethod 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 sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users, companies from common.database.model.models import users
from common.enums import ErrorType, UserRole from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
@ -39,22 +39,6 @@ class IUserCRUD(ABC):
async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType:
pass 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 @abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass pass
@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED 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]: async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try: try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712 query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712

View File

@ -1,7 +1,5 @@
from typing import Optional from typing import Optional
from pydantic import Field
from common.enums import AuthProvider, UserRole from common.enums import AuthProvider, UserRole
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
@ -17,7 +15,7 @@ class Req_Login(AuthProtocol):
class Req_Signup(AuthProtocol): class Req_Signup(AuthProtocol):
"""id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. """id/pw 가입. 가입 = 계정 1개다.
이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
사람에게 계정이 생긴다. 지금 이메일 인증 절차는 없다 소유 증명이 아니라 사람에게 계정이 생긴다. 지금 이메일 인증 절차는 없다 소유 증명이 아니라
@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol):
password: str = "" password: str = ""
name: Optional[str] = None name: Optional[str] = None
email: str = "" email: str = ""
company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다
class Req_GoogleLogin(AuthProtocol): class Req_GoogleLogin(AuthProtocol):
@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol):
class Req_UpdateMe(AuthProtocol): class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가). # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
name: Optional[str] = None name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
contact_number: Optional[str] = None contact_number: Optional[str] = None
@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol):
token_type: str = "bearer" token_type: str = "bearer"
class CompanyData(WebPacketProtocol):
company_id: str = ""
name: str = ""
class Res_Me(Res_WebPacketProtocol): class Res_Me(Res_WebPacketProtocol):
user_id: str = "" user_id: str = ""
id: str = "" id: str = ""
@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol):
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL provider: AuthProvider = AuthProvider.LOCAL
company: Optional[CompanyData] = Field(default=None)

View File

@ -31,7 +31,7 @@ from .protocol import (
Res_UnitList, Res_UnitList,
) )
# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다. # 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})

View File

@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다. # 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
name: str = "" name: str = ""
category: PlaceCategory = PlaceCategory.LODGING category: PlaceCategory = PlaceCategory.LODGING
owner_user_id: Optional[uuid.UUID] = None # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
class Req_VerifyPlace(PlaceProtocol): class Req_VerifyPlace(PlaceProtocol):
@ -60,8 +61,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
class Req_UpdatePlace(PlaceProtocol): class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None name: Optional[str] = None
owner_user_id: Optional[uuid.UUID] = None
status: Optional[PlaceStatus] = None status: Optional[PlaceStatus] = None
@ -214,7 +215,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
class PlaceSearchItem(WebPacketProtocol): class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건. """공개 검색 결과 1건.
외부 장소 DB 공개적으로 주는 값만 담는다. 우리 DB (place_id·company_id·소유자) 외부 장소 DB 공개적으로 주는 값만 담는다. 우리 DB (place_id·소유자)
하나도 나가지 않는다 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. 하나도 나가지 않는다 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 기존 경로(POST /place verify) 그대로 한다.""" 확정과 수집은 로그인 기존 경로(POST /place verify) 그대로 한다."""

View File

@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol):
domain: Optional[str] = None domain: Optional[str] = None
template_id: Optional[str] = None template_id: Optional[str] = None
published_at: Optional[datetime] = None published_at: Optional[datetime] = None
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
thumbnail_url: Optional[str] = None
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
needs_rebuild: bool = False needs_rebuild: bool = False
@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol):
"""랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.**
여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다.
place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 사이트 곳을 여는 것과 place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 사이트 곳을 여는 것과
발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 ··구까지만 준다.""" 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 ··구까지만 준다."""
name: str name: str

View File

@ -53,13 +53,11 @@ async def main():
engine = create_async_engine(dsn) engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW 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: async with engine.begin() as c:
await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) "
{"c": cid, "n": "데모대행사"}) "VALUES (:u,:i,:p,:n,:s,:r,now())"),
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
from router.router import app from router.router import app

View File

@ -41,12 +41,11 @@ async def main():
engine = create_async_engine(dsn) engine = create_async_engine(dsn)
from router.v1.validator.dependencies import GetHashedPW 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: 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,id,password,name,status,role,last_accessed_at) "
await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " "VALUES (:u,:i,:p,:n,:s,:r,now())"),
"VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), {"u": uid, "i": login, "p": await GetHashedPW("pw1234"),
{"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"),
"n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value})
await engine.dispose() await engine.dispose()

View File

@ -4,13 +4,12 @@ import uuid
from fastapi import Depends from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import companies, users from common.database.model.models import users
from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG from common.logger import LOG
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import ( from router.v1.auth.protocol import (
CompanyData,
Req_GoogleLogin, Req_GoogleLogin,
Req_Signup, Req_Signup,
Req_UpdateMe, Req_UpdateMe,
@ -71,11 +70,11 @@ class AuthService:
@staticmethod @staticmethod
def _user_info(user: users) -> UserInfo: def _user_info(user: users) -> UserInfo:
# uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다
# — 사업장이 places.owner_user_id 로 이 값에 매여 있다.
return UserInfo( return UserInfo(
user_id=str(user.user_id), user_id=str(user.user_id),
id=user.id, id=user.id,
company_id=str(user.company_id),
role=user.role, role=user.role,
) )
@ -140,26 +139,17 @@ class AuthService:
password_hash: str | None, password_hash: str | None,
name: str | None, name: str | None,
email: str | None, email: str | None,
company_name: str,
provider: AuthProvider, provider: AuthProvider,
provider_uid: str | None, provider_uid: str | None,
) -> tuple[ErrorType, users]: ) -> tuple[ErrorType, users]:
"""회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. """계정 1개를 만든다.
가입은 테넌트다. users.company_id NOT NULL 이고 모든 도메인(사업장·사이트) 예전엔 가입 번이 **회사(테넌트) 하나** 같이 만들었고 모든 도메인이 회사로
company_id 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라
uuid 여기서 미리 만든다. 모델 default flush 시점에 적용돼서, 전에 걷어냈다(2026-09-08) 이제 사업장이 `places.owner_user_id` 계정에 직접 매인다.
company.company_id 읽으면 None 이다 그대로 넣으면 NOT NULL 위반이다.""" uuid 여기서 미리 만든다. 모델 default flush 시점에 적용돼서 전에 읽으면 None 이다."""
company_uuid = uuid.uuid4()
company = companies(
company_id=company_uuid,
name=_fit(company_name, 100),
email=_fit(email, 255),
status=CompanyStatus.ACTIVE.value,
)
user = users( user = users(
user_id=uuid.uuid4(), user_id=uuid.uuid4(),
company_id=company_uuid,
id=login_id, id=login_id,
password=password_hash, password=password_hash,
name=_fit(name, 50), name=_fit(name, 50),
@ -171,10 +161,7 @@ class AuthService:
) )
err_type = await DB_SESSION_MNG.execute_lambda_run( err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()], [users.DBType()],
[ [lambda s: self.user_crud.add_user(s, user)],
lambda s: self.user_crud.add_company(s, company),
lambda s: self.user_crud.add_user(s, user),
],
) )
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
return err_type, None return err_type, None
@ -226,7 +213,6 @@ class AuthService:
password_hash=await GetHashedPW(req.password), password_hash=await GetHashedPW(req.password),
name=name, name=name,
email=email, email=email,
company_name=(req.company_name or "").strip() or name or login_id,
provider=AuthProvider.LOCAL, provider=AuthProvider.LOCAL,
provider_uid=None, provider_uid=None,
) )
@ -268,13 +254,12 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res return res
# 3) 첫 방문 — 계정과 회사를 만든다. # 3) 첫 방문 — 계정 만든다.
err_type, user = await self._create_account( err_type, user = await self._create_account(
login_id=_google_login_id(account.sub), login_id=_google_login_id(account.sub),
password_hash=None, password_hash=None,
name=account.name or None, name=account.name or None,
email=account.email or None, email=account.email or None,
company_name=account.name or account.email or _google_login_id(account.sub),
provider=AuthProvider.GOOGLE, provider=AuthProvider.GOOGLE,
provider_uid=account.sub, provider_uid=account.sub,
) )
@ -299,16 +284,6 @@ class AuthService:
return res return res
user: users 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.user_id = str(user.user_id)
res.id = user.id res.id = user.id
res.name = user.name res.name = user.name
@ -316,7 +291,6 @@ class AuthService:
res.contact_number = user.contact_number res.contact_number = user.contact_number
res.role = UserRole(user.role) res.role = UserRole(user.role)
res.provider = AuthProvider(user.provider) res.provider = AuthProvider(user.provider)
res.company = company
return res return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:

View File

@ -98,18 +98,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult
async def run_build(job: dict) -> dict: 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 게이트를 통과했을 바로 발행까지 한다.""" publish=True 게이트를 통과했을 바로 발행까지 한다."""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
want_publish = bool(payload.get("publish")) want_publish = bool(payload.get("publish"))
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -258,7 +258,7 @@ async def run_build(job: dict) -> dict:
result["azure"] = azure_result 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: if thumbnail_url:
result["thumbnail_url"] = 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( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: _place_crud.update_place( 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, await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,

View File

@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict:
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
payload = job["payload"] payload = job["payload"]
place_id = payload["place_id"] 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( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}") raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict:
if not targets: if not targets:
result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)"
await _finish(place_id, company_id, PlaceStatus.REVIEW) await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
return result return result
# ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). # ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때).
@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict:
if before["enough"] and not payload.get("force"): if before["enough"] and not payload.get("force"):
result["coverage"] = before result["coverage"] = before
result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)" 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']} — 크롤링 생략") LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략")
return result return result
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
actor = 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", id="collector",
company_id=company_id,
role=1, role=1,
) )
@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict:
# 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다. # 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다.
# (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.) # (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.)
if result["media"]["stored"] > 0: 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']}") LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}")
return result 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( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: _place_crud.update_place( 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 common.enums import JobType
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None:
return None return None
job_id, _created = await enqueue_job( job_id, _created = await enqueue_job(
JobQueue(), JobType.VISION, 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}", dedupe_key=f"vision:{place_id}",
) )
return job_id return job_id

View File

@ -40,10 +40,10 @@ class CopyAborted(RuntimeError):
async def run_copy(job: dict) -> dict: 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"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
if not gemini_text.is_configured(): if not gemini_text.is_configured():
raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") 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( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}")
@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict:
} }
actor = 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="generator", id="generator",
company_id=company_id,
role=1, role=1,
) )
service = FactService(_fact_crud, _place_crud) service = FactService(_fact_crud, _place_crud)

View File

@ -73,7 +73,7 @@ class FactService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -38,14 +38,14 @@ class FaqService:
self.crud = crud self.crud = crud
self.place_crud = place_crud self.place_crud = place_crud
# ---- 사업장 로드(사 스코프) ---- # ---- 사업장 로드(장님 스코프) ----
async def _load_place(self, user_info: UserInfo, place_id: str): 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( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -41,7 +41,7 @@ class MediaService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None

View File

@ -70,12 +70,12 @@ class PlaceService:
# ---- 조회 ---- # ---- 조회 ----
async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList: 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) 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( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, DBWRType.DB_READ.value,
lambda s: self.crud.list_places( lambda s: self.crud.list_places(
s, cid, search, s, uid, search,
category.value if isinstance(category, PlaceCategory) else category, category.value if isinstance(category, PlaceCategory) else category,
status.value if isinstance(status, PlaceStatus) else status, status.value if isinstance(status, PlaceStatus) else status,
pg.skip, pg.size, pg.skip, pg.size,
@ -102,7 +102,7 @@ class PlaceService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None
@ -122,8 +122,11 @@ class PlaceService:
return res return res
place = places( place = places(
company_id=uuid.UUID(user_info.company_id), # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데,
owner_user_id=req.owner_user_id, # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다.
# 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면
# 만들자마자 남의 목록에 들어간다.
owner_user_id=uuid.UUID(user_info.user_id),
name=req.name.strip(), name=req.name.strip(),
category=req.category.value, category=req.category.value,
status=PlaceStatus.DRAFT.value, status=PlaceStatus.DRAFT.value,
@ -149,7 +152,7 @@ class PlaceService:
if data: if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -164,7 +167,7 @@ class PlaceService:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.delete_place( 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: if err_type != ErrorType.SUCCESS:
@ -253,7 +256,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place( 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: if verified.place:
@ -294,7 +297,7 @@ class PlaceService:
res.result.SetResult(err_type) res.result.SetResult(err_type)
return res return res
cid = uuid.UUID(user_info.company_id) uid = uuid.UUID(user_info.user_id)
now = GTime.UTC() now = GTime.UTC()
data = { data = {
"external_source": req.source.value, "external_source": req.source.value,
@ -325,7 +328,7 @@ class PlaceService:
data["external_category"] = req.category_name.strip()[:200] data["external_category"] = req.category_name.strip()[:200]
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -513,7 +516,7 @@ class PlaceService:
payload = { payload = {
"place_id": place_id, "place_id": place_id,
"company_id": user_info.company_id, "owner_user_id": user_info.user_id,
"category": place.category, "category": place.category,
# 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재 # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재
# 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가 # 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가
@ -542,7 +545,7 @@ class PlaceService:
await DB_SESSION_MNG.execute_lambda_claim( await DB_SESSION_MNG.execute_lambda_claim(
places.DBType(), places.DBType(),
lambda s: self.crud.update_place( 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}, {"status": PlaceStatus.COLLECTING.value},
), ),
) )
@ -589,7 +592,7 @@ class PlaceService:
job_id, created = await enqueue_job( job_id, created = await enqueue_job(
self.queue, JobType.VISION, 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}", dedupe_key=f"vision:{place_id}",
) )
if job_id is None: if job_id is None:
@ -849,7 +852,7 @@ class PlaceService:
job_id, created = await enqueue_job( job_id, created = await enqueue_job(
self.queue, JobType.COPY, 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}", dedupe_key=f"copy:{place_id}",
) )
if job_id is None: if job_id is None:

View File

@ -5,7 +5,7 @@
고르는 자리를 곳으로 모았다. 사이트 곳을 여는 것과 발행 업소 명단을 통째로 고르는 자리를 곳으로 모았다. 사이트 곳을 여는 것과 발행 업소 명단을 통째로
긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다.
나가지 않는 : place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표. 나가지 않는 : place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표.
""" """
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG

View File

@ -104,7 +104,7 @@ class SiteService:
err_type, place = await DB_SESSION_MNG.execute_lambda( err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None return ErrorType.PLACE_NOT_FOUND, None
@ -420,16 +420,16 @@ class SiteService:
return cleaned return cleaned
async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites:
"""로그인한 계정(회사)이 가진 사이트 전부. """로그인한 사장님이 가진 사이트 전부.
사업장 목록(/v1/place/list) 따로 두는 이유: 화면이 알아야 하는 '사업장이 있다' 아니라 사업장 목록(/v1/place/list) 따로 두는 이유: 화면이 알아야 하는 '사업장이 있다' 아니라
'발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'.""" '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'."""
res = Res_MySites(page=pg.page, size=pg.size) 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( err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type) res.result.SetResult(err_type)
@ -454,6 +454,7 @@ class SiteService:
domain=getattr(site, "domain", None), domain=getattr(site, "domain", None),
template_id=getattr(site, "template_id", None), template_id=getattr(site, "template_id", None),
published_at=getattr(site, "published_at", 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)), 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( job_id, created = await enqueue_job(
self.queue, JobType.BUILD, 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, "publish": req.publish, "requested_by": user_info.user_id,
}, },
dedupe_key=f"build:{place_id}", dedupe_key=f"build:{place_id}",

View File

@ -18,7 +18,7 @@ import asyncio
import os import os
import httpx import httpx
from azure.storage.blob import BlobServiceClient, ContentSettings from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings
from common.logger import LOG from common.logger import LOG
from services import azure_static, site_payload from services import azure_static, site_payload
@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024
CACHE_CONTROL = "public, max-age=60, must-revalidate" 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: 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: 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("/") 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) 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 와 루트 절대경로는 충돌한다').""" (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').
return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}"
`?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: 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: 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() connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip()
container_name = ( container_name = (
os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() 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) service = BlobServiceClient.from_connection_string(connection_string)
container = service.get_container_client(container_name) container = service.get_container_client(container_name)
name = blob_name(slug, ext)
container.upload_blob(
name=name,
data=data,
overwrite=True,
# cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다.
content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings)
)
return name 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(발행은 그대로 간다). """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다).
SDK 동기 I/O 별도 스레드에서 돈다 azure_static.publish 같은 이유로, SDK 동기 I/O 별도 스레드에서 돈다 azure_static.publish 같은 이유로,
@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None:
return None return None
LOG.i(f"[thumbnail] {slug}{name} ({len(data)} bytes · {content_type})") LOG.i(f"[thumbnail] {slug}{name} ({len(data)} bytes · {content_type})")
return public_url(slug, ext) return public_url(slug, ext, version)

View File

@ -28,10 +28,10 @@ class VisionAborted(RuntimeError):
async def run_vision(job: dict) -> dict: 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"] payload = job["payload"]
place_id = payload["place_id"] place_id = payload["place_id"]
company_id = payload["company_id"] owner_user_id = payload["owner_user_id"]
force = bool(payload.get("force")) force = bool(payload.get("force"))
if not gemini.is_configured(): if not gemini.is_configured():
@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict:
err, place = await DB_SESSION_MNG.execute_lambda( err, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), places.DBType(),
DBWRType.DB_READ.value, 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: if err != ErrorType.SUCCESS or place is None:
raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}") raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}")

View File

@ -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 호출. """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
기대결과: 200, 본인 id·name·소속사(company_id) 그대로 반환.""" 기대결과: 200, 본인 id·name 그대로 반환."""
h = await auth_headers("user1", name="홍길동") h = await auth_headers("user1", name="홍길동")
r = await client.get("/v1/auth/me", headers=h) 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() me = r.json()
assert me["id"] == "user1" assert me["id"] == "user1"
assert me["name"] == "홍길동" 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): 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): async def test_signup_creates_account_and_logs_in(client, db_engine):
"""검증: 가입 → 받은 토큰으로 곧바로 /me. """검증: 가입 → 받은 토큰으로 곧바로 /me.
기대결과: 토큰이 실려 오고, /me 방금 만든 신원**새로 생긴 소속사** 돌려준다.""" 기대결과: 토큰이 실려 오고, /me 방금 만든 신원 돌려준다."""
r = await client.post("/v1/auth/signup", json=_SIGNUP) r = await client.post("/v1/auth/signup", json=_SIGNUP)
body = r.json() body = r.json()
assert body["result"]["success"] is True 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["id"] == "sajang1"
assert me["email"] == "boss@example.com" assert me["email"] == "boss@example.com"
assert me["provider"] == 1 # AuthProvider.LOCAL assert me["provider"] == 1 # AuthProvider.LOCAL
assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다
async def test_signup_rejects_duplicate_id(client, db_engine): async def test_signup_rejects_duplicate_id(client, db_engine):

View File

@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine):
assert [v["version"] for v in versions] == [2, 1] 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.""" 기대결과: PLACE_NOT_FOUND."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1, "스코프펜션") 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) r = await client.get(f"/v1/place/{pid}/site", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he
assert job["payload"]["link_ids"] == [confirmed] 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 사업장이 보이니 수집도 건다.""" 기대결과: PLACE_NOT_FOUND 사업장이 보이니 수집도 건다."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1, kakao="c7") pid = await _place(client, h1, kakao="c7")
await _confirmed_link(client, h1, pid) 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={}) r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={})
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client):
assert check_in[0]["status"] == FactStatus.CORRECTED.value 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 from sqlalchemy import text
@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id):
pid = uuid.uuid4() pid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( 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)"), "VALUES (:pid, :cid, :n, 1, 1)"),
{"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"}, {"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"},
) )
q = JobQueue() 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) worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0)
await worker.process_one() await worker.process_one()

View File

@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client):
assert place.get("content_updated_at") is None assert place.get("content_updated_at") is None
async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id): async def test_facts_are_scoped_to_owner(auth_headers, client):
"""검증: 다른 사 계정으로 남의 사업장 fact 를 조회한다. """검증: 다른 장님 계정으로 남의 사업장 fact 를 조회한다.
기대결과: PLACE_NOT_FOUND 사업장이 보이니 fact 보인다.""" 기대결과: PLACE_NOT_FOUND 사업장이 보이니 fact 보인다."""
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _verified_place(client, h1, kakao="p6") 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) r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -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 를 돌려준다.""" """검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다."""
pid = uuid.uuid4() pid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text( 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())" "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, "cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value,
"kakao": "12345678", "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 를 두 번 넣는다. """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다.
기대결과: 번째 INSERT 유니크 인덱스에 막힌다(체크인 시간이 값으로 갈라지지 않는다).""" 기대결과: 번째 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) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED)
with pytest.raises(IntegrityError): with pytest.raises(IntegrityError):
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED) 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", "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", "16:00", FactStatus.PENDING_OWNER)
await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED) 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건이 공존해야 한다" 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 를 새로 노출한다. """검증: 기존 값을 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", "15:00", FactStatus.REJECTED)
await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED) 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 이력과 새 값이 함께 남아야 한다" 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 로 내린 값과 새 수집값의 공존.
기대결과: 통과 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.EXPIRED)
await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED) 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 를 각각 가질 수 있는지. """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지.
기대결과: 통과 A동·B동이 각자의 기준 인원을 갖는다.""" 기대결과: 통과 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() unit_a, unit_b = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
for uid, name in ((unit_a, "A동"), (unit_b, "B동")): 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) 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 를 사업장 단위와 객실 단위로 동시에 갖는 경우. """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우.
기대결과: 통과 부분 인덱스가 unit_id NULL 여부로 갈라져 있다.""" 기대결과: 통과 부분 인덱스가 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() unit_id = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(

View File

@ -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", []) == [] 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): async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine):
"""검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다.
기대결과: PLACE_NOT_FOUND 존재 여부조차 알려주지 않는다.""" 기대결과: PLACE_NOT_FOUND 존재 여부조차 알려주지 않는다."""
h = await auth_headers("u1") h = await auth_headers("u1")
pid = await _place(client, h) pid = await _place(client, h)
fid = await _seed_generated_faq(db_engine, pid) 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 assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value
body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value}) body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value})
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -3,7 +3,7 @@
경로가 절대 하면 되는 : 경로가 절대 하면 되는 :
- 사이트가 아직 없는 사업장을 빼는 위저드를 걸어오다 가게가 목록에서 사라지면 - 사이트가 아직 없는 사업장을 빼는 위저드를 걸어오다 가게가 목록에서 사라지면
사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다).
- 스코프를 놓치는 남의 가게가 목록에 섞이면 그건 목록이 아니라 사고다. - 장님 스코프를 놓치는 남의 가게가 목록에 섞이면 그건 목록이 아니라 사고다.
- 단건(GET /v1/place/{id}/site) 다른 재빌드 판정을 내는 목록과 에디터가 서로 다른 - 단건(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 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.""" 기대결과: 각자 자기 것만 1."""
mine = await auth_headers("my3") 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, mine, "내펜션")
await _place(client, theirs, "남의펜션") await _place(client, theirs, "남의펜션")

View File

@ -1,8 +1,8 @@
"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사 스코프 / 채널 URL 확정 게이트. """places 도메인 e2e — 등록 / 동일 업소 검증 / 장님 스코프 / 채널 URL 확정 게이트.
도메인의 핵심 규칙 개를 고정한다: 도메인의 핵심 규칙 개를 고정한다:
1. 검증(verify) 전에는 채널 URL 확정할 없다 크롤링이 열린다 1. 검증(verify) 전에는 채널 URL 확정할 없다 크롤링이 열린다
2. 남의 회사 사업장은 '없음'으로 보인다 2. 남의 사업장은 '없음'으로 보인다
""" """
import uuid 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): async def test_duplicate_kakao_place_is_allowed(auth_headers, client):
"""검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다. """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다.
기대결과: 등록된다 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 있다.""" 기대결과: 등록된다 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 있다."""
h = await auth_headers("u1") h = await auth_headers("u1")
first = (await _create_place(client, h, "A펜션"))["place"]["place_id"] 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 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 차단).""" 기대결과: PLACE_NOT_FOUND 존재 자체가 보이지 않는다(IDOR 차단)."""
h1 = await auth_headers("owner1") h1 = await auth_headers("owner1")
pid = (await _create_place(client, h1))["place"]["place_id"] 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) r = await client.get(f"/v1/place/{pid}", headers=h2)
assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -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] 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"): "external_place_id", "address"):
assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다" assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다"

View File

@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures():
assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"]) 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") h1 = await auth_headers("audit-owner")
pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"] 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 0 <= own["aeo_score"] <= 100
assert own["checks"] 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() denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json()
assert denied["result"]["success"] is False assert denied["result"]["success"] is False

View File

@ -13,14 +13,14 @@ from sqlalchemy import text
from common.enums import PlaceCategory, PlaceStatus, SiteStatus 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 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다.""" """places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다."""
pid, sid = uuid.uuid4(), uuid.uuid4() pid, sid = uuid.uuid4(), uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( 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)"), "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, "st": PlaceStatus.PUBLISHED.value,
"road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2", "road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2",
"phone": "033-672-0000"}, "phone": "033-672-0000"},
@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m
return str(pid) return str(pid)
async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id): async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id):
"""검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. """검증: 발행본 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") 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") res = await client.get("/v1/showcase")
@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi
assert items[0]["category"] == PlaceCategory.LODGING.value 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] item = (await client.get("/v1/showcase")).json()["items"][0]
@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin
assert "하조대3길" not in body assert "하조대3길" not in body
async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id): async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id):
"""★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 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] 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 assert "thumbnail_url" not in item
async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id): async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id):
await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) await _publish(db_engine, owner_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) await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1)
items = (await client.get("/v1/showcase")).json()["items"] items = (await client.get("/v1/showcase")).json()["items"]
assert [i["name"] for i in items] == ["나중", "먼저"] assert [i["name"] for i in items] == ["나중", "먼저"]

View File

@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine):
assert same["site"]["domain"] == "published-stay" 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(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("slug5") h = await auth_headers("slug5")
intruder = await auth_headers("slug6", other_company_id) intruder = await auth_headers("slug6")
pid = await _place(client, h) pid = await _place(client, h)
assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en
assert changed["needs_rebuild"] is True 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(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("tpl6") h = await auth_headers("tpl6")
intruder = await auth_headers("tpl7", other_company_id) intruder = await auth_headers("tpl7")
pid = await _place(client, h) pid = await _place(client, h)
blocked = await _set_template(client, intruder, pid, "stay-quiet-margin") blocked = await _set_template(client, intruder, pid, "stay-quiet-margin")

View File

@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin
assert changed["needs_rebuild"] is True 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(존재 여부조차 알려주지 않는다).""" 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다)."""
h = await auth_headers("thm7") h = await auth_headers("thm7")
intruder = await auth_headers("thm8", other_company_id) intruder = await auth_headers("thm8")
pid = await _place(client, h) pid = await _place(client, h)
assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -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")) _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}) 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" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg"
assert set(blob.uploads) == {name} assert set(blob.uploads) == {name}
assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") 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 services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts 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 f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}"
monkeypatch.setattr(build_service.site_thumbnail, "store", _store) 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"] 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"].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): async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch):
@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers
from services import build_service from services import build_service
from tests.test_build_publish import _approved_media, _place, _run, _verified_facts 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 return None
monkeypatch.setattr(build_service.site_thumbnail, "store", _store) monkeypatch.setattr(build_service.site_thumbnail, "store", _store)

View File

@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType
from services.snapshot import build_snapshot 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() pid = uuid.uuid4()
async with db_engine.begin() as c: async with db_engine.begin() as c:
await c.execute( 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())"), "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"}, "addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"},
) )
return pid return pid
@ -53,10 +53,10 @@ class _Place:
self.longitude = None 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 를 섞어 넣는다. """검증: 여러 상태의 fact 를 섞어 넣는다.
기대결과: VERIFIED·CORRECTED 스냅샷에 담긴다 미검증 값이 사이트로 새지 않는다.""" 기대결과: 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, "check_in_time", "15:00", FactStatus.VERIFIED)
await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED)
await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED) 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"} 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 신뢰도가 낮아 확인 큐에 남은 사진은 나간다.""" 기대결과: 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/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/pending.jpg", MediaStatus.PENDING_REVIEW)
await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED) 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"] 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 텍스트가 없는 사진.
기대결과: 빠진다 alt 없는 이미지는 접근성도 AI 검색 신호도 없다.""" 기대결과: 빠진다 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/noalt.jpg", MediaStatus.APPROVED, alt="")
await _media(db_engine, pid, "https://cdn.test/withalt.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"] 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 라벨. """검증: 스냅샷의 fact 라벨.
기대결과: 업종 스키마의 한글 라벨이 붙는다 화면이 key 그대로 노출하지 않게.""" 기대결과: 업종 스키마의 한글 라벨이 붙는다 화면이 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) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED)
snap = await build_snapshot(_Place(pid)) 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" 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. """검증: 객실 단위 fact.
기대결과: unit_id 실려 빌더가 객실별로 묶을 있다.""" 기대결과: unit_id 실려 빌더가 객실별로 묶을 있다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
uid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as c: 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)"), 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" 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건으로 거부할 재료가 된다.""" 기대결과: 스냅샷 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다."""
pid = await _seed(db_engine, company_id) pid = await _seed(db_engine, owner_id)
snap = await build_snapshot(_Place(pid)) snap = await build_snapshot(_Place(pid))
assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == []
assert snap["place"]["name"] == "스냅샷펜션" assert snap["place"]["name"] == "스냅샷펜션"
@ -156,13 +156,13 @@ class _RegionPlace(_Place):
self.region_code = region_code 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 원문이 사이트로 새면 기대결과: PUBLISHED 담긴다 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면
'미검증 값 노출 금지' 깨진다(fact VERIFIED 거르는 것과 같은 규칙).""" '미검증 값 노출 금지' 깨진다(fact VERIFIED 거르는 것과 같은 규칙)."""
from common.enums import LocalContentStatus, LocalContentType 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.PUBLISHED, "발행축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제")
await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") 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"]] == ["발행축제"] 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 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 from common.enums import LocalContentStatus, LocalContentType
now = datetime.now(timezone.utc) 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, "지금축제")
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)) 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"]] == ["지금축제"] 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 로 묶인다. """검증: 지역 캐시는 region_code 로 묶인다.
기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다."""
from common.enums import LocalContentStatus, LocalContentType 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.PUBLISHED, "우리지역축제")
await _local(db_engine, "5011025", 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"]] == ["우리지역축제"] 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 가 비어 있지만 도로명주소는 있는 사업장. """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장.
기대결과: 주소에서 지역 키를 유도해 지역 콘텐츠를 담는다 places.region_code 채우는 기대결과: 주소에서 지역 키를 유도해 지역 콘텐츠를 담는다 places.region_code 채우는
코드가 생기기 전에 만들어진 사업장(실측 28 25) 영영 지역 정보 없이 발행되지 않게 한다.""" 코드가 생기기 전에 만들어진 사업장(실측 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 from services.external.naver import region_key
derived = region_key(_Place("x").road_address) 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, "양양축제") await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제")
snap = await build_snapshot(_RegionPlace(pid, "")) 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"]] == ["양양축제"] 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 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.PUBLISHED, "어딘가축제")
place = _RegionPlace(pid, "") place = _RegionPlace(pid, "")

View File

@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey
assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value 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.""" 기대결과: PLACE_NOT_FOUND."""
_patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x")) _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x"))
h1 = await auth_headers("o1") h1 = await auth_headers("o1")
pid = await _place(client, h1) 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() body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json()
assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value

View File

@ -1,28 +0,0 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<!-- 관리자 화면은 색인될 이유가 없다. 발행 사이트만 색인되게 한다. -->
<meta name="robots" content="noindex, nofollow" />
<!-- self-host Pretendard 가 어떤 환경에서든 못 뜰 때 한글이 굴림/맑은고딕으로 떨어지지 않게 하는 안전망 -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300..900&family=Noto+Serif+KR:wght@400;600;700&display=swap"
rel="stylesheet"
/>
<!-- 7080 아이템(가요 다방·일력·승차권) 전용. 이 셋이 없으면 간판/명조가 고딕으로 떨어져 감성이 사라진다 -->
<link
href="https://fonts.googleapis.com/css2?family=Gugi&family=Gowun+Batang:wght@400;700&family=Nanum+Pen+Script&display=swap"
rel="stylesheet"
/>
<link rel="icon" type="image/svg+xml" href="/brand/favicon-w4a.svg?v=14" />
<meta name="theme-color" content="#1463ff" />
<title>Web4Ai · AI 웹 빌더</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

View File

@ -4,11 +4,11 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --port=3000 --host=0.0.0.0", "dev": "react-router dev --port=3000 --host=0.0.0.0",
"build": "tsc --noEmit && eslint src && vite build", "build": "react-router typegen && tsc --noEmit && eslint src && react-router build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "clean": "rm -rf build .react-router",
"lint": "tsc --noEmit && eslint src", "lint": "react-router typegen && tsc --noEmit && eslint src",
"orval": "orval --config ./orval.config.ts" "orval": "orval --config ./orval.config.ts"
}, },
"dependencies": { "dependencies": {
@ -17,10 +17,12 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@o2o/shared": "*", "@o2o/shared": "*",
"@react-router/node": "^7.18.3",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^5",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
@ -34,6 +36,7 @@
"zustand": "^5.0.14" "zustand": "^5.0.14"
}, },
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.18.3",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@ -0,0 +1,24 @@
import type {Config} from '@react-router/dev/config';
/**
* .
*
* `<div id="root"></div>` CSR . (2026-09-07):
* `curl /` 3,021 `<a>` 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;

View File

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

View File

@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation';
export * from './authProvider'; export * from './authProvider';
export * from './buildStatus'; export * from './buildStatus';
export * from './checkSlugParams'; export * from './checkSlugParams';
export * from './companyData';
export * from './errorInfo'; export * from './errorInfo';
export * from './errorInfoCode'; export * from './errorInfoCode';
export * from './errorInfoDesc'; export * from './errorInfoDesc';
@ -83,6 +82,7 @@ export * from './mySiteData';
export * from './mySiteDataCreatedAt'; export * from './mySiteDataCreatedAt';
export * from './mySiteDataDomain'; export * from './mySiteDataDomain';
export * from './mySiteDataPublishedAt'; export * from './mySiteDataPublishedAt';
export * from './mySiteDataThumbnailUrl';
export * from './mySiteDataRoadAddress'; export * from './mySiteDataRoadAddress';
export * from './mySiteDataSiteId'; export * from './mySiteDataSiteId';
export * from './mySiteDataStatus'; export * from './mySiteDataStatus';
@ -139,7 +139,6 @@ export * from './reqGoogleLogin';
export * from './reqLogin'; export * from './reqLogin';
export * from './reqPublishLocalContent'; export * from './reqPublishLocalContent';
export * from './reqSignup'; export * from './reqSignup';
export * from './reqSignupCompanyName';
export * from './reqSignupName'; export * from './reqSignupName';
export * from './reqSiteSlug'; export * from './reqSiteSlug';
export * from './reqSiteStatus'; export * from './reqSiteStatus';
@ -222,7 +221,6 @@ export * from './resLocalContentListMsg';
export * from './resLogin'; export * from './resLogin';
export * from './resLoginMsg'; export * from './resLoginMsg';
export * from './resMe'; export * from './resMe';
export * from './resMeCompany';
export * from './resMeContactNumber'; export * from './resMeContactNumber';
export * from './resMeEmail'; export * from './resMeEmail';
export * from './resMeMsg'; export * from './resMeMsg';

View File

@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus';
import type { MySiteDataDomain } from './mySiteDataDomain'; import type { MySiteDataDomain } from './mySiteDataDomain';
import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; import type { MySiteDataTemplateId } from './mySiteDataTemplateId';
import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt';
import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl';
/** /**
* (place) + (site). * (place) + (site).
@ -32,5 +33,7 @@ export interface MySiteData {
domain?: MySiteDataDomain; domain?: MySiteDataDomain;
template_id?: MySiteDataTemplateId; template_id?: MySiteDataTemplateId;
published_at?: MySiteDataPublishedAt; published_at?: MySiteDataPublishedAt;
/** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */
thumbnail_url?: MySiteDataThumbnailUrl;
needs_rebuild?: boolean; needs_rebuild?: boolean;
} }

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export type ReqSignupCompanyName = string | null; export type MySiteDataThumbnailUrl = string | null;

View File

@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory';
/** /**
* 1. * 1.
DB . DB (place_id·company_id·) DB . DB (place_id·)
. .
· . '어느 가게인지 고르게 하는 것', · . '어느 가게인지 고르게 하는 것',
(POST /place verify) . (POST /place verify) .

View File

@ -5,10 +5,9 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
import type { ReqSignupName } from './reqSignupName'; 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; password?: string;
name?: ReqSignupName; name?: ReqSignupName;
email?: string; email?: string;
company_name?: ReqSignupCompanyName;
} }

View File

@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail';
import type { ResMeContactNumber } from './resMeContactNumber'; import type { ResMeContactNumber } from './resMeContactNumber';
import type { UserRole } from './userRole'; import type { UserRole } from './userRole';
import type { AuthProvider } from './authProvider'; import type { AuthProvider } from './authProvider';
import type { ResMeCompany } from './resMeCompany';
export interface ResMe { export interface ResMe {
result?: ErrorInfo; result?: ErrorInfo;
@ -23,5 +22,4 @@ export interface ResMe {
contact_number?: ResMeContactNumber; contact_number?: ResMeContactNumber;
role?: UserRole; role?: UserRole;
provider?: AuthProvider; provider?: AuthProvider;
company?: ResMeCompany;
} }

View File

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

View File

@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl';
* . ** .** * . ** .**
. .
place_id·company_id·· place_id···
. ·· . . ·· .
*/ */
export interface ShowcaseItem { export interface ShowcaseItem {

View File

@ -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(
<StrictMode>
<Providers>
<RouterProvider router={router} />
</Providers>
</StrictMode>,
);

View File

@ -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 (
<div className="flex h-screen items-center justify-center text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
if (user) return <Navigate to="/sites" replace />;
return <LandingPage />;
}
export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />},
// 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다.
{path: '/', element: <Home />},
// 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다.
{path: '/pricing', element: <PricingPage />},
{path: '/showcase', element: <ShowcasePage />},
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
{
path: '/sites',
element: (
<RequireAuth>
<SitesPage />
</RequireAuth>
),
},
{
path: '/account',
element: (
<RequireAuth>
<AccountPage />
</RequireAuth>
),
},
/**
*
* .
*
* VITE_AUTO_LOGIN_ID·PW useAutoLogin()
* , (2 ) .
*
* (6) AppShell BuilderPage .
*/
{path: '/builder', element: <BuilderPage />},
/**
* (/places, /local-content, /seo) `admin/` .
* ** **:
* . `UserRole.DEVELOPER`
* "고객사에 존재를 노출하지 않는다" .
* (ARCHITECTURE.md 4)
*/
// 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다.
...(import.meta.env.DEV ? [{path: '/dev/showcase', element: <DevShowcasePage />}] : []),
{path: '*', element: <NotFoundPage />},
]);

View File

@ -1,6 +1,6 @@
import type {ComponentType, ReactNode} from 'react'; import type {ComponentType, ReactNode} from 'react';
import {Link, NavLink, useLocation, useNavigate} from 'react-router'; 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 {cn} from '@/lib/utils';
import {userLabel, useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
@ -25,6 +25,15 @@ export type NavItem = {
const OWNER_NAV: NavItem[] = [ const OWNER_NAV: NavItem[] = [
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, {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[]}) { 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" 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)} {userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</Link> </Link>
) : ( ) : (
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground"> <div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
@ -87,7 +95,8 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
// 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다. // 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다.
onClick={() => { onClick={() => {
signOut(); 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" 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"
> >

View File

@ -47,6 +47,11 @@ export function MarketingShell({children}: {children: ReactNode}) {
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */} {/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */}
{/*
[ ] (2026-09-04, )
. .
*/}
{user ? ( {user ? (
<Link <Link
to="/sites" to="/sites"
@ -55,21 +60,12 @@ export function MarketingShell({children}: {children: ReactNode}) {
</Link> </Link>
) : ( ) : (
<>
{/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */}
<Link <Link
to="/login" to="/login"
className="rounded-md border border-border px-4 py-2 text-[13px] font-medium transition-colors hover:bg-muted" className="rounded-md border border-border px-4 py-2 text-[13px] font-medium transition-colors hover:bg-muted"
> >
</Link> </Link>
<Link
to="/builder?new=1"
className="rounded-md bg-primary px-4 py-2 text-[13px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
</Link>
</>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,14 @@
import {Outlet} from 'react-router';
import {RequireAuth} from './RequireAuth';
/**
* . `router.tsx` <RequireAuth>
* .
*/
export default function RequireAuthLayout() {
return (
<RequireAuth>
<Outlet />
</RequireAuth>
);
}

View File

@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder';
import {resolveVariant} from './canvas/registry'; import {resolveVariant} from './canvas/registry';
import {PUBLISH_HOST} from '@/lib/site';
// ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은
// 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다. // 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다.
// 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다. // 같은 규칙(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<ViewportMode, string> = { const VIEWPORT_FRAME: Record<ViewportMode, string> = {
pc: 'w-full max-w-5xl shadow-md', pc: 'w-full max-w-5xl shadow-md',

View File

@ -1,6 +1,7 @@
import {useEffect, useState, type CSSProperties} from 'react'; import {useEffect, useState, type CSSProperties} from 'react';
import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react'; import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react';
import {PlaceCategory} from '@o2o/shared'; import {PlaceCategory} from '@o2o/shared';
import {ORIGIN} from '@/lib/site';
import {fetchShowcase, type ShowcaseItem} from './showcaseApi'; import {fetchShowcase, type ShowcaseItem} from './showcaseApi';
const CATEGORY_ICON: Record<number, typeof Building2> = { const CATEGORY_ICON: Record<number, typeof Building2> = {
@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.CLINIC]: '피부과 · 성형외과', [PlaceCategory.CLINIC]: '피부과 · 성형외과',
}; };
/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/<slug>`). 발행 호스트는 번들에 구워진 값이다. */ /**
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; * (`/s/<slug>`).
*
* window . (`/`) `/showcase`
* window .
*/
function siteHref(url: string): string { function siteHref(url: string): string {
return `${window.location.protocol}//${PUBLISH_HOST}${url}`; return `${ORIGIN}${url}`;
} }
/** /**

View File

@ -320,8 +320,20 @@ export function Step3DataReview() {
2 2
. .
</p> </p>
{/* ** **
, [ ] (PublishModal
PlaceFirstPanel). ,
.
. */}
<p className="rounded-xl border border-border bg-muted/40 p-3 text-[11px] leading-relaxed text-muted-foreground">
<strong className="text-foreground">
.
</strong>{' '}
,
. .
</p>
<Button variant="outline" className="w-full" onClick={() => goToStep('template')}> <Button variant="outline" className="w-full" onClick={() => goToStep('template')}>
<span> </span> <span> </span>
<ArrowRight /> <ArrowRight />
</Button> </Button>
</> </>

View File

@ -1,24 +1,33 @@
import {useCallback, useEffect, useMemo, useState} from 'react'; import {useCallback, useEffect, useMemo, useState} from 'react';
import {useNavigate} from 'react-router';
import { import {
AlertTriangle, AlertTriangle,
ArrowUpRight, ArrowUpRight,
Check, Check,
Copy, Copy,
ExternalLink, ExternalLink,
Eye,
Loader2, Loader2,
RefreshCw, RefreshCw,
Search,
ServerCrash, ServerCrash,
ShieldAlert, ShieldAlert,
Store,
} from 'lucide-react'; } from 'lucide-react';
import {publishUrlString, toSlug} from '@o2o/shared'; import {publishUrlString, toSlug} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog'; 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 {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import {useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder'; import {useBuilderStore} from '@/stores/builder';
import {runPublishGate, type GateFinding} from './publishGate'; import {runPublishGate, type GateFinding} from './publishGate';
import {checkSiteSlug, reserveSiteSlug} from './siteSlug'; import {checkSiteSlug, reserveSiteSlug} from './siteSlug';
import {PUBLISH_HOST} from '@/lib/site';
import {localValidate, SlugField, type SlugStatus} from './SlugField'; import {localValidate, SlugField, type SlugStatus} from './SlugField';
import { import {
GATE_REASON_LABEL, GATE_REASON_LABEL,
@ -29,7 +38,30 @@ import {
// 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트 // 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트
// 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다. // 서버(: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() { export function PublishModal() {
const isOpen = useBuilderStore((s) => s.isPublishModalOpen); const isOpen = useBuilderStore((s) => s.isPublishModalOpen);
@ -54,11 +86,28 @@ export function PublishModal() {
/** /**
* (`POST /site/build {publish:true}` ). * (`POST /site/build {publish:true}` ).
* placeId . * ( · ) .
* PublishBlocker .
*/ */
const publisher = usePublishSite(placeId); const publisher = usePublishSite(placeId);
const {state, reset} = publisher; 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( const gate = useMemo(
() => () =>
runPublishGate({ runPublishGate({
@ -107,7 +156,12 @@ export function PublishModal() {
if (!isOpen) reset(); if (!isOpen) reset();
}, [isOpen, reset]); }, [isOpen, reset]);
const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl)); /**
* '발행됨'.
* `!isLive && publishedUrl` , publishedUrl
* [ ] .
*/
const isDone = state.phase === 'published';
/** /**
* '발행' '재발행' . * '발행' '재발행' .
@ -146,9 +200,10 @@ export function PublishModal() {
}, [slug, placeId]); }, [slug, placeId]);
const handlePublish = async () => { const handlePublish = async () => {
if (!gate.canPublish || publisher.isPublishing || !slugReady) return; // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 —
// 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다.
if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return;
if (publisher.isLive) {
// ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고,
// 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다.
if (!isSlugLocked) { if (!isSlugLocked) {
@ -161,10 +216,6 @@ export function PublishModal() {
} }
// 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다.
publisher.publish(); publisher.publish();
return;
}
setPublishedUrl(url);
notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.');
}; };
const handleCopy = async () => { const handleCopy = async () => {
@ -181,9 +232,23 @@ export function PublishModal() {
<Dialog <Dialog
open={isOpen} open={isOpen}
onClose={close} onClose={close}
title={isDone ? '발행 완료' : isRepublish ? '재발행 전 점검' : '발행 전 점검'} title={
blocker === 'signin'
? '지금 화면은 미리보기입니다'
: blocker === 'place'
? '가게 확인이 먼저입니다'
: isDone
? '발행 완료'
: isRepublish
? '재발행 전 점검'
: '발행 전 점검'
}
description={ description={
isDone blocker === 'signin'
? '만드신 화면은 아직 어디에도 올라가 있지 않습니다.'
: blocker === 'place'
? '발행본은 확인된 가게 한 곳에 묶입니다.'
: isDone
? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.' ? '확인된 정보만 담긴 정적 페이지가 생성되었습니다.'
: '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.' : '검색·AI 노출에 직접 영향을 주는 항목부터 확인합니다.'
} }
@ -194,7 +259,23 @@ export function PublishModal() {
</Button> </Button>
{isDone ? ( {/* ()
, [] . */}
{blocker === 'signin' ? null : blocker === 'place' ? (
<Button
variant="primary"
className="flex-1"
onClick={() => {
// 모달을 먼저 닫는다 — 같은 화면(BuilderPage) 안에서 단계만 바뀌는 이동이라
// 열어 둔 채로 가면 가게 찾기 위에 이 모달이 그대로 떠 있는다.
close();
navigate(PLACE_SEARCH_URL);
}}
>
<Search />
<span> </span>
</Button>
) : isDone ? (
<Button <Button
variant="primary" variant="primary"
className="flex-1" className="flex-1"
@ -233,8 +314,11 @@ export function PublishModal() {
} }
> >
<div className="space-y-4"> <div className="space-y-4">
{blocker === 'signin' && <SignInFirstPanel />}
{blocker === 'place' && <PlaceFirstPanel />}
{/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */} {/* 서버가 보는 현재 상태. 편집 중 상태만 보고 판단하지 않게 맨 위에 둔다. */}
{publisher.isLive && !isDone && ( {!blocker && !isDone && (
<SiteStatusRow <SiteStatusRow
version={publisher.currentVersion?.version} version={publisher.currentVersion?.version}
needsRebuild={publisher.needsRebuild} needsRebuild={publisher.needsRebuild}
@ -242,8 +326,10 @@ export function PublishModal() {
/> />
)} )}
{/* 주소는 발행의 전제다 — 점검 항목보다 먼저 정해야 [발행하기] 가 열린다. */} {/* [] .
{!isDone && ( blocker . ·
. */}
{!blocker && !isDone && (
<SlugField <SlugField
value={slug} value={slug}
host={PUBLISH_HOST} host={PUBLISH_HOST}
@ -258,7 +344,7 @@ export function PublishModal() {
/> />
)} )}
{!isDone && gate.blockers.length > 0 && ( {!blocker && !isDone && gate.blockers.length > 0 && (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive"> <h3 className="flex items-center gap-1.5 text-xs font-bold text-destructive">
<ShieldAlert className="size-3.5" /> <ShieldAlert className="size-3.5" />
@ -270,7 +356,7 @@ export function PublishModal() {
</section> </section>
)} )}
{!isDone && gate.warnings.length > 0 && ( {!blocker && !isDone && gate.warnings.length > 0 && (
<section className="space-y-2"> <section className="space-y-2">
<h3 className="flex items-center gap-1.5 text-xs font-bold text-warning"> <h3 className="flex items-center gap-1.5 text-xs font-bold text-warning">
<AlertTriangle className="size-3.5" /> <AlertTriangle className="size-3.5" />
@ -282,7 +368,7 @@ export function PublishModal() {
</section> </section>
)} )}
{!isDone && gate.findings.length === 0 && state.phase === 'idle' && ( {!blocker && !isDone && gate.findings.length === 0 && state.phase === 'idle' && (
<div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success"> <div className="flex items-center gap-2 rounded-lg border border-success/30 bg-success/8 p-3 text-xs text-success">
<Check className="size-4 shrink-0" /> <Check className="size-4 shrink-0" />
<span> <span>
@ -316,6 +402,71 @@ export function PublishModal() {
); );
} }
/**
* ** , .**
*
* /login . ·
* (stores/builder ) .
* (EditorSignInGate) .
* blocker
* ( PlaceFirstPanel ).
*/
function SignInFirstPanel() {
return (
<div className="space-y-3">
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Eye className="size-3.5" />
<span> </span>
</div>
<p className="leading-relaxed text-muted-foreground">
·AI (HTML) .
, .
</p>
<p className="mt-1.5 text-[11px] font-medium">
.
</p>
</div>
<div className="flex justify-center">
<SignInForm
submitLabel="로그인하고 발행 준비"
header={
<p className="text-center text-xs leading-relaxed text-muted-foreground">
.
</p>
}
/>
</div>
</div>
);
}
/**
* 3 .
*
* .
* features/onboarding/ensureServerPlace
* , .
*/
function PlaceFirstPanel() {
return (
<div className="rounded-lg border border-warning/40 bg-warning/8 p-3 text-xs">
<div className="mb-1 flex items-center gap-1.5 font-bold text-warning">
<Store className="size-3.5" />
<span> </span>
</div>
<p className="leading-relaxed text-muted-foreground">
··
·AI .
, .
</p>
<p className="mt-1.5 text-[11px] font-medium">
.
</p>
</div>
);
}
/** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */ /** 서버가 보는 사이트 상태 한 줄. 편집 중 상태(gate)와 저장된 상태를 구분해 준다. */
/** /**
* . * .

View File

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

View File

@ -10,8 +10,8 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /**
* `PATCH /v1/auth/me` . * `PATCH /v1/auth/me` .
* *
* (company) . Req_UpdateMe * . () (2026-09-08)
* , . * (place) .
* ( ACCOUNT_PROVIDER_CONFLICT ) * ( ACCOUNT_PROVIDER_CONFLICT )
* . * .
*/ */
@ -77,13 +77,6 @@ export function AccountPage() {
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'} {isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
</p> </p>
</Field> </Field>
<Field label="상호">
<p className="text-sm">{data?.company?.name ?? '-'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
.
</p>
</Field>
</section> </section>
<section className="space-y-4 rounded-xl border border-border bg-card p-5"> <section className="space-y-4 rounded-xl border border-border bg-card p-5">
@ -142,3 +135,6 @@ function Field({label, children}: {label: string; children: React.ReactNode}) {
</label> </label>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default AccountPage;

View File

@ -18,9 +18,12 @@ import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {userLabel, useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
import {useBuilderStore} from '@/stores/builder'; 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=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */ /** 랜딩이 `?industry=` 로 넘길 수 있는 값. 주소창 값이라 아무 문자열이나 들어올 수 있다. */
const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic']; const INDUSTRY_VALUES: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
@ -209,10 +212,9 @@ export function BuilderPage() {
<span className="h-3.5 w-px bg-white/20" /> <span className="h-3.5 w-px bg-white/20" />
<span <span
className="max-w-[14rem] truncate text-background/70" className="max-w-[14rem] truncate text-background/70"
title={user.companyName ? `${userLabel(user)} · ${user.companyName}` : userLabel(user)} title={userLabel(user)}
> >
{userLabel(user)} {userLabel(user)}
{user.companyName ? ` · ${user.companyName}` : ''}
</span> </span>
<button <button
type="button" type="button"
@ -307,3 +309,6 @@ function BuilderNotice({
</div> </div>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default BuilderPage;

View File

@ -701,3 +701,6 @@ function GroupList({
</div> </div>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default DevShowcasePage;

View File

@ -4,6 +4,44 @@ import {ArrowRight, ArrowUp, Check} from 'lucide-react';
import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell'; import {MarketingShell, Section, SectionHead} from '@/components/layout/MarketingShell';
import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid'; import {ShowcaseGrid, ShowcasePeeks} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button'; 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() {
</MarketingShell> </MarketingShell>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default LandingPage;

View File

@ -17,7 +17,22 @@ import {useAuthStore} from '@/stores/auth';
* . admin `/signup` * . admin `/signup`
* 404 (admin/src/app/router.tsx ). * 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 navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
@ -28,9 +43,8 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// ★ 기본 도착지를 '/' 로 둔다. 앱마다 홈이 다르고(사장님 → 빌더, 내부 → 사업장 목록) // 원래 가려던 곳이 있으면 그리로, 없으면 앱이 준 홈으로.
// 각 라우터의 '/' 리다이렉트가 이미 그걸 안다. 여기 경로를 박으면 한쪽에서 404 다. const from = (location.state as {from?: string} | null)?.from ?? homePath;
const from = (location.state as {from?: string} | null)?.from ?? '/';
if (user) return <Navigate to={from} replace />; if (user) return <Navigate to={from} replace />;
@ -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" className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
> >
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<img {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. 로그인 화면에서 나갈 길이 여기뿐이었다. */}
src="/brand/web4ai-wordmark.svg" <Link to="/" className="mx-auto mb-3 block w-fit">
alt="Web4Ai" <img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-9 w-auto" />
className="mx-auto mb-3 h-9 w-auto" </Link>
/>
<h1 className="text-sm font-medium text-muted-foreground"> <h1 className="text-sm font-medium text-muted-foreground">
{selfServe ? '로그인' : '관리자'} {selfServe ? '로그인' : '관리자'}
</h1> </h1>
@ -153,3 +166,6 @@ export function LoginPage({selfServe = true}: {selfServe?: boolean}) {
</div> </div>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default LoginPage;

View File

@ -14,3 +14,6 @@ export function NotFoundPage() {
</div> </div>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default NotFoundPage;

View File

@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {Check} from 'lucide-react'; import {Check} from 'lucide-react';
import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {Button} from '@/components/ui/button'; 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() {
</MarketingShell> </MarketingShell>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default PricingPage;

View File

@ -2,6 +2,20 @@ import {Link} from 'react-router';
import {MarketingShell, Section} from '@/components/layout/MarketingShell'; import {MarketingShell, Section} from '@/components/layout/MarketingShell';
import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid'; import {ShowcaseGrid} from '@/features/marketing/ShowcaseGrid';
import {Button} from '@/components/ui/button'; 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() {
</MarketingShell> </MarketingShell>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default ShowcasePage;

View File

@ -31,11 +31,10 @@ export function SignupPage() {
passwordConfirm: '', passwordConfirm: '',
name: '', name: '',
email: '', email: '',
companyName: '',
}); });
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
if (user) return <Navigate to="/" replace />; if (user) return <Navigate to="/sites" replace />;
const set = (key: keyof typeof form) => (e: {target: {value: string}}) => const set = (key: keyof typeof form) => (e: {target: {value: string}}) =>
setForm((prev) => ({...prev, [key]: e.target.value})); setForm((prev) => ({...prev, [key]: e.target.value}));
@ -63,7 +62,6 @@ export function SignupPage() {
password: form.password, password: form.password,
name: form.name.trim() || null, name: form.name.trim() || null,
email: form.email.trim(), email: form.email.trim(),
company_name: form.companyName.trim() || null,
}); });
if (res.result?.success === false) { if (res.result?.success === false) {
notifyApiError({data: res}, '가입하지 못했습니다.'); notifyApiError({data: res}, '가입하지 못했습니다.');
@ -76,7 +74,7 @@ export function SignupPage() {
return; return;
} }
notify.success('가입이 완료되었습니다.'); notify.success('가입이 완료되었습니다.');
navigate('/', {replace: true}); navigate('/sites', {replace: true});
} catch (error) { } catch (error) {
notifyApiError(error, '가입하지 못했습니다.'); notifyApiError(error, '가입하지 못했습니다.');
} finally { } finally {
@ -97,7 +95,7 @@ export function SignupPage() {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.'); notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return; return;
} }
navigate('/', {replace: true}); navigate('/sites', {replace: true});
} catch (error) { } catch (error) {
notifyApiError(error, '구글로 가입하지 못했습니다.'); notifyApiError(error, '구글로 가입하지 못했습니다.');
} finally { } 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" className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
> >
<div className="space-y-1 text-center"> <div className="space-y-1 text-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" /> {/* 로고는 어디서든 랜딩으로 돌아가는 문이다. */}
<Link to="/" className="mx-auto mb-3 block w-fit">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="h-9 w-auto" />
</Link>
<h1 className="text-sm font-medium text-muted-foreground"></h1> <h1 className="text-sm font-medium text-muted-foreground"></h1>
<p className="text-xs text-muted-foreground"> .</p> <p className="text-xs text-muted-foreground"> .</p>
</div> </div>
@ -177,17 +178,6 @@ export function SignupPage() {
required required
/> />
</div> </div>
<div>
<label htmlFor="signup-company" className="mb-1.5 block text-xs font-semibold">
<span className="font-normal text-muted-foreground">()</span>
</label>
<Input
id="signup-company"
value={form.companyName}
onChange={set('companyName')}
placeholder="비우면 이름으로 채웁니다"
/>
</div>
</div> </div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}> <Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
@ -216,3 +206,6 @@ export function SignupPage() {
</div> </div>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SignupPage;

View File

@ -1,4 +1,4 @@
import {useState} from 'react'; import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate} from 'react-router';
import { import {
Building2, Building2,
@ -8,19 +8,27 @@ import {
MoreHorizontal, MoreHorizontal,
Pencil, Pencil,
Plus, Plus,
Search,
SearchX,
Stethoscope, Stethoscope,
UtensilsCrossed, UtensilsCrossed,
Wand2, Wand2,
X,
} from 'lucide-react'; } from 'lucide-react';
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared'; import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api'; import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notify, notifyApiError} from '@/lib/notify'; import {notify, notifyApiError} from '@/lib/notify';
import {PUBLISH_HOST} from '@/lib/site';
import {cn} from '@/lib/utils';
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다. // 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; // ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌
// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다.
// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다)
const CATEGORY_ICON: Record<number, typeof Building2> = { const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.LODGING]: Building2, [PlaceCategory.LODGING]: Building2,
@ -29,24 +37,51 @@ const CATEGORY_ICON: Record<number, typeof Building2> = {
[PlaceCategory.CLINIC]: Stethoscope, [PlaceCategory.CLINIC]: Stethoscope,
}; };
/**
* . **·· .**
*
* '발행됨(1)' '내림'
* . 믿 .
* . '전체' .
*/
type SiteBucket = 'live' | 'draft';
type SiteFilter = SiteBucket | 'all';
/**
* ****. '만드는 중'( ) ,
* "아직 안 나가 있다". `site_id` DB
* , 34 .
*/
const FILTER_LABEL: Record<SiteFilter, string> = {
all: '전체',
live: '발행됨',
draft: '발행 전',
};
const FILTER_TABS: readonly SiteFilter[] = ['all', 'live', 'draft'];
/** 나가 있는 것부터 본다 — 실측(계정 test): 35개 중 34개가 발행 전이라 1개가 묻힌다. */
const BUCKET_ORDER: Record<SiteBucket, number> = {live: 0, draft: 1};
function bucketOf(row: MySiteData): SiteBucket {
return row.site_id && row.status === SiteStatus.PUBLISHED ? 'live' : 'draft';
}
/** /**
* . ** (sites.status) ** (places.status) * . ** (sites.status) ** (places.status)
* "지금 나가 있나" . * "지금 나가 있나" .
*/ */
function statusBadge(row: MySiteData) { function statusBadge(row: MySiteData) {
if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const}; if (bucketOf(row) === 'live') {
switch (row.status) {
case SiteStatus.PUBLISHED:
return row.needs_rebuild return row.needs_rebuild
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const} ? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
: {label: '발행됨', variant: 'success' 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 (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 다. */ /** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
@ -55,19 +90,135 @@ function publishedUrl(row: MySiteData): string | null {
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST); 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).
* <img> .
* .
* .
* .
*/
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 (
<div className="flex aspect-[16/10] w-full items-center justify-center border-b border-border bg-muted/60">
<Icon className="size-7 text-muted-foreground/60" />
</div>
);
}
return (
<img
src={src}
alt=""
loading="lazy"
onError={() => setFailed(true)}
className="aspect-[16/10] w-full border-b border-border object-cover"
/>
);
}
/** /**
* . * .
* *
* 하나다: 위저드로 . * 하나다: 위저드로 .
* . "내 사이트 고치기". * . "내 사이트 고치기".
*
* .
* · (Wix· ).
*/ */
export function SitesPage() { export function SitesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50}); const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
const [menuId, setMenuId] = useState<string | null>(null); const [menuId, setMenuId] = useState<string | null>(null);
const [query, setQuery] = useState('');
const [filter, setFilter] = useState<SiteFilter>('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<SiteFilter, number> = {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 로 만들면 // 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석). // 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
@ -132,29 +283,124 @@ export function SitesPage() {
/> />
)} )}
{rows.length > 0 && ( {!isLoading && !isError && rows.length > 0 && (
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card"> <div className="mb-3 flex flex-wrap items-center gap-2">
{rows.map((row) => { <div className="relative min-w-56 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="상호나 주소로 찾기"
aria-label="상호나 주소로 찾기"
className="h-9 pr-8 pl-8"
/>
{query && (
<button
type="button"
aria-label="검색어 지우기"
onClick={() => setQuery('')}
className="absolute top-1/2 right-2 -translate-y-1/2 cursor-pointer text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-3.5" />
</button>
)}
</div>
{/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
{FILTER_TABS.map((key) => (
<button
key={key}
type="button"
aria-pressed={filter === key}
onClick={() => 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]}
<span className="ml-1 tabular-nums opacity-60">{counts[key]}</span>
</button>
))}
</div>
</div>
)}
{/* 35 " "
. */}
{!isLoading && !isError && rows.length > 0 && visible.length === 0 && (
<EmptyState
icon={SearchX}
title="조건에 맞는 사이트가 없습니다"
description={`가진 사이트 ${rows.length}개 중에 없습니다. 검색어를 줄이거나 다른 칸을 보세요.`}
action={
<Button size="sm" onClick={resetView}>
</Button>
}
/>
)}
{visible.length > 0 && (
/* . (2026-09-08)
. ** **
(실측: 같은 '버터브루' 4) .
16:10 . 1:1 . */
<ul className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((row) => {
const Icon = CATEGORY_ICON[row.category] ?? Building2; const Icon = CATEGORY_ICON[row.category] ?? Building2;
const badge = statusBadge(row); const badge = statusBadge(row);
const url = publishedUrl(row); const url = publishedUrl(row);
const isLive = bucketOf(row) === 'live';
const editHref = `/builder?placeId=${row.place_id}`; const editHref = `/builder?placeId=${row.place_id}`;
return ( return (
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40"> /* ** **. ' '
<Icon className="size-4 shrink-0 text-muted-foreground" /> ( ).
h-full + flex-col + mt-auto . */
<li
key={row.place_id}
className="group relative flex h-full flex-col overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md"
>
<Link to={editHref} className="block">
<SiteThumb row={row} Icon={Icon} />
</Link>
<Link to={editHref} className="min-w-0 flex-1"> {/* 배지는 그림 위에 얹는다 — 카드에서 상태는 제목보다 먼저 읽혀야 한다. */}
<div className="flex items-center gap-2"> <Badge variant={badge.variant} className="absolute top-2.5 left-2.5 shadow-sm">
<span className="truncate text-sm font-semibold">{row.name}</span> {badge.label}
<Badge variant={badge.variant}>{badge.label}</Badge> </Badge>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground"> <div className="flex flex-1 flex-col p-3.5">
<Link to={editHref} className="block min-w-0">
<p className="truncate text-sm font-semibold">{row.name}</p>
{/* . " "
34 . */}
<p
className={cn(
'mt-1 truncate text-xs',
isLive ? 'font-medium text-foreground' : 'text-muted-foreground',
)}
>
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
</p> </p>
{/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */}
<p className="mt-1 mb-3 truncate text-[11px] text-muted-foreground/70">
{[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')}
</p>
</Link> </Link>
<div className="flex shrink-0 items-center gap-1.5"> <div className="mt-auto flex items-center gap-1.5 border-t border-border pt-3">
{/* '' ' '
( ) . */}
<Button size="sm" className="flex-1" onClick={() => navigate(editHref)}>
<Pencil />
</Button>
{url && ( {url && (
<a <a
href={url} href={url}
@ -163,13 +409,9 @@ export function SitesPage() {
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted" className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
> >
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
)} )}
<Button size="sm" onClick={() => navigate(editHref)}>
<Pencil />
{row.site_id ? '편집' : '이어서 만들기'}
</Button>
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@ -180,6 +422,7 @@ export function SitesPage() {
{busyId === row.place_id ? null : <MoreHorizontal />} {busyId === row.place_id ? null : <MoreHorizontal />}
</Button> </Button>
</div> </div>
</div>
{menuId === row.place_id && ( {menuId === row.place_id && (
<> <>
@ -190,7 +433,7 @@ export function SitesPage() {
className="fixed inset-0 z-10 cursor-default" className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)} onClick={() => setMenuId(null)}
/> />
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md"> <div className="absolute right-3.5 bottom-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
<button <button
type="button" type="button"
disabled={row.status !== SiteStatus.PUBLISHED} disabled={row.status !== SiteStatus.PUBLISHED}
@ -207,7 +450,11 @@ export function SitesPage() {
})} })}
</ul> </ul>
)} )}
</PageContainer> </PageContainer>
</AppShell> </AppShell>
); );
} }
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default SitesPage;

View File

@ -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 , <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 (
<html lang="ko">
<head>
<meta charSet="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1463ff" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function Root() {
return (
<Providers>
<Outlet />
</Providers>
);
}
export function ErrorBoundary({error}: {error: unknown}) {
const is404 = isRouteErrorResponse(error) && error.status === 404;
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 px-6 text-center">
<p className="text-5xl font-extrabold tracking-tight">{is404 ? '404' : '오류'}</p>
<p className="text-muted-foreground">
{is404 ? '없는 주소입니다.' : '화면을 그리는 중 문제가 생겼습니다.'}
</p>
<a href="/" className="underline underline-offset-4">
</a>
</div>
);
}

View File

@ -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'),
// 로그인한 사장님의 홈. 예전엔 페이지마다 <RequireAuth> 로 감쌌는데,
// 가드는 한 자리에 있어야 빠뜨리지 않아서 레이아웃 라우트로 모았다.
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;

View File

@ -8,8 +8,6 @@ export interface AuthUser {
name?: string; name?: string;
email?: string; email?: string;
role: number; role: number;
companyId?: string;
companyName?: string;
} }
/** /**
@ -26,8 +24,6 @@ export function toAuthUser(res: ResMe): AuthUser {
name: res.name ?? undefined, name: res.name ?? undefined,
email: res.email ?? undefined, email: res.email ?? undefined,
role: res.role ?? UserRole.USER, role: res.role ?? UserRole.USER,
companyId: res.company?.company_id,
companyName: res.company?.name,
}; };
} }

View File

@ -1,10 +1,12 @@
import {reactRouter} from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import {defineConfig} from 'vite'; import {defineConfig} from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], // ★ @vitejs/plugin-react 를 따로 넣지 않는다 — reactRouter() 가 안에서 켠다.
// 둘 다 넣으면 리프레시 런타임이 두 번 주입돼 HMR 이 깨진다.
plugins: [reactRouter(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
@ -18,6 +20,9 @@ export default defineConfig({
'@o2o/shared': path.resolve(__dirname, '../shared/src'), '@o2o/shared': path.resolve(__dirname, '../shared/src'),
}, },
}, },
// ★ 산출물이 `dist/` 가 아니라 `build/client/` 로 나간다(프레임워크 모드 기본값).
// nginx/Dockerfile 의 COPY 경로가 이 값과 맞아야 한다 — 어긋나면 빈 이미지가 구워지고
// 컨테이너는 정상으로 뜬다.
build: { build: {
// ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른 // ★ 발행본과 **같은 오리진**을 쓰므로 `/assets/` 를 서로 뺏는다. 빌더 번들만 다른
// 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고 // 디렉토리로 뺀다 — 안 그러면 nginx 의 `/assets/` 규칙이 발행본 것만 주고

View File

@ -33,6 +33,13 @@ export function deriveSurfaces(colors: {bg: string; card: string; text: string;
surface: mix(colors.bg, colors.text, 0.06), surface: mix(colors.bg, colors.text, 0.06),
surfaceAlt: colors.card, surfaceAlt: colors.card,
inverse: '#1c1917', inverse: '#1c1917',
border: colors.secondary, /**
* .
*
* secondary '본문 다음으로 진한 글자색'
* ( ).
* 20% (#e4dac0 #bcb49e).
*/
border: mix(colors.bg, colors.text, 0.2),
}; };
} }

View File

@ -85,10 +85,16 @@ export interface PeopleItem {
role?: string; role?: string;
oneLine?: string; oneLine?: string;
/** /**
* . URL · , * . .
* (LocalPlace.searchQuery ).
*/ */
imageQuery?: string; imageQuery?: string;
/**
* .
*
* ** .** ( ) ,
* . URL .
*/
imageUrl?: string;
verified?: DataVerified; verified?: DataVerified;
source?: DataSource; source?: DataSource;
} }
@ -101,6 +107,8 @@ export interface ChronicleItem {
place?: string; place?: string;
/** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */ /** 도시의 성격을 바꾼 해. 레일의 붉은 점이 이 값이다 — 점의 색이 장식이 아니라 정보다. */
turning?: boolean; turning?: boolean;
/** 사진. 재게시 권리가 확실한 출처(공공누리·위키미디어)만 싣는다. 없으면 글자만 나간다. */
imageUrl?: string;
verified?: DataVerified; verified?: DataVerified;
source?: DataSource; source?: DataSource;
} }
@ -124,6 +132,8 @@ export interface PostcardItem {
place?: string; place?: string;
/** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */ /** 소인에 찍을 짧은 지명. 없으면 place 가 그 자리에 들어간다. */
postmark?: string; postmark?: string;
/** 엽서 앞면 사진. 권리가 확실한 출처만. 없으면 글자만 있는 뒷면 한 장이 된다. */
imageUrl?: string;
verified?: DataVerified; verified?: DataVerified;
source?: DataSource; source?: DataSource;
} }
@ -138,6 +148,56 @@ export interface QuizItem {
source?: DataSource; 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 { export interface PlannerStop {
name: string; name: string;
/** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */ /** 여기서 머무는 시간(분). 없으면 60분으로 본다 — 못 재면 시각을 계산할 수 없다. */
@ -181,6 +241,10 @@ export const SECTION_ITEM_REQUIRED_KEY: Record<string, string> = {
postcard: 'line', postcard: 'line',
quiz: 'question', quiz: 'question',
planner: 'name', planner: 'name',
// ★ 여기 없는 kind 는 파서가 통째로 건너뛴다 — payload 에 실려 와도 화면에서 사라진다.
itinerary: 'name',
event: 'title',
video: 'url',
}; };
export interface ParsedSectionData<T> { export interface ParsedSectionData<T> {

View File

@ -106,6 +106,13 @@ export interface TemplateLook {
headingWeight: string; headingWeight: string;
/** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */ /** 섹션 세로 여백. 이 값 하나로 페이지의 호흡이 바뀐다. */
sectionSpace: string; sectionSpace: string;
/**
* `background-image` (`.paper`).
*
* () .
* . .
*/
texture?: string;
} }
export interface PhotoItem { export interface PhotoItem {

View File

@ -189,8 +189,13 @@ export interface LocalPlace {
description?: string; description?: string;
/** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */ /** 외부 검색으로 보내는 질의어. 우리가 지어낸 URL 을 링크하지 않는다. */
searchQuery: string; searchQuery: string;
/**
* . ( ) .
*
* .
*/
imageUrl?: string; imageUrl?: string;
/** 업장 좌표 기준 거리(m). 도보 시간 필터 계산용 원값 — distanceText 는 이걸 사람이 읽게 바꾼 것. */ /** 사업장에서 잰 직선거리(m). 도보 시간을 화면에서 환산하는 근거다. */
distanceMeters?: number; distanceMeters?: number;
} }

View File

@ -22,10 +22,16 @@ import {render} from '@/entry-server';
import { import {
collectJsonLd, collectJsonLd,
homeMeta, homeMeta,
readBakedLastmod,
readBakedTitle,
renderHead, renderHead,
renderLlmsTxt, renderLlmsTxt,
renderRootLlmsTxt,
renderRootRobotsTxt, renderRootRobotsTxt,
renderSiteIndex,
renderSiteUrlset, renderSiteUrlset,
type DirectoryEntry,
type SiteEntry,
verifyGeo, verifyGeo,
verifyJsonLd, verifyJsonLd,
} from '@/seo'; } from '@/seo';
@ -279,7 +285,12 @@ function assetPlan(payload: SitePayload, outRoot: string, siteDir: string) {
}; };
} }
function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<typeof readAssets>) { function prerenderSite(
input: SitePayload,
outRoot: string,
assets: ReturnType<typeof readAssets>,
referenced: Set<string>,
) {
// ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 — // ★ 여기서 한 번 깎고, 그 뒤로는 깎인 payload 만 쓴다 —
// 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다. // 서버 렌더 · JSON-LD · llms.txt · HTML 에 심는 블롭이 전부 같은 객체를 본다.
// (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.) // (블롭만 원본으로 두면 미검증 fact 가 HTML 소스로 새고, AI 크롤러는 그걸 읽는다.)
@ -364,7 +375,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
// 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다 // 하이드레이션용 번들. 공용 호스트면 out/ 루트 한 벌을 공유하므로 여기서는 아무것도 안 한다
// (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다. // (main 이 사이트를 굽기 전에 한 번 깔아 둔다). 커스텀 도메인일 때만 사이트 안에 복사한다.
if (!plan.shared) { if (!plan.shared) {
writeSharedAssets(plan.dir); writeSharedAssets(plan.dir, referenced);
} }
// 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는 // 이전 구현이 사이트마다 복사해 둔 자산이 남아 있으면 지운다 — 공용으로 바뀐 뒤에는
@ -473,18 +484,166 @@ function writeReport(payloadFile: string, report: RenderReport) {
renameSync(tmp, target); renameSync(tmp, target);
} }
/**
* .
*
* HTML ** **
* (`/assets/index-DvNTmLhy.css`). ,
* CSS·JS **404** . .
*
* . HTML **** .
*
* .
* (Vercel 60 ), 0.
*
* 400KB 10MB .
*/
const ASSET_RETENTION_DAYS = 30;
/** 하루에 여러 번 배포해도 직전 빌드는 반드시 남는다(보관 기간과 무관). */
const ASSET_MIN_BUILDS = 2;
/**
* . ** .**
*
* mtime ·
* . (.)
* (azure_static dotfile ).
*/
const ASSET_LEDGER = '.builds.json';
/** 자산 대장 한 줄 — 한 번의 번들 빌드가 깐 파일 목록. */
interface AssetBuild {
/** 이 번들을 마지막으로 깐 시각(ISO). 같은 번들로 다시 구우면 갱신된다. */
at: string;
/** assets/ 기준 상대 경로. */
files: string[];
}
/** 디렉토리 안의 파일을 상대 경로로 편다(하위 디렉토리 포함). */
function listRelativeFiles(dir: string, prefix = ''): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, {withFileTypes: true})) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) found.push(...listRelativeFiles(join(dir, entry.name), rel));
else if (entry.isFile()) found.push(rel);
}
return found;
}
/**
* ** ** . .
*
* `out/s/` **payload ** ().
* . payload , payload
* ** .**
* , .
*
* (2026-09-07): `/s/stay` · `/s/stay2` · `/s/stay3`
* CSS·JS· 404 . (30)
* . ** ** .
*
* . .
*/
function referencedAssets(outRoot: string): Set<string> {
const sitesDir = join(outRoot, SITE_DIR);
const found = new Set<string>();
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<string>) {
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/ . * public/ .
* *
* Docker bind mount * assets/ ( ).
* assets/ ( ). * ASSET_RETENTION_DAYS . ,
* copyDirectoryFiles **** rmSync .
*/ */
function writeSharedAssets(destRoot: string) { function writeSharedAssets(destRoot: string, referenced: Set<string>) {
const assetsSrc = join(CLIENT_DIR, 'assets'); const assetsSrc = join(CLIENT_DIR, 'assets');
if (existsSync(assetsSrc)) { if (existsSync(assetsSrc)) {
const assetsDest = join(destRoot, 'assets'); const assetsDest = join(destRoot, 'assets');
rmSync(assetsDest, {recursive: true, force: true});
copyDirectoryFiles(assetsSrc, assetsDest); copyDirectoryFiles(assetsSrc, assetsDest);
pruneAssets(assetsDest, listRelativeFiles(assetsSrc), referenced);
} }
const publicDir = join(SITE_ROOT, 'public'); const publicDir = join(SITE_ROOT, 'public');
if (existsSync(publicDir)) { if (existsSync(publicDir)) {
@ -509,21 +668,44 @@ function writeRootMachineFiles(outRoot: string, origin: string) {
const sitesDir = join(outRoot, SITE_DIR); const sitesDir = join(outRoot, SITE_DIR);
if (!existsSync(sitesDir)) return; if (!existsSync(sitesDir)) return;
const entries = readdirSync(sitesDir, {withFileTypes: true}) const sites: DirectoryEntry[] = readdirSync(sitesDir, {withFileTypes: true})
.filter((entry) => entry.isDirectory()) .filter((entry) => entry.isDirectory())
.map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')})) .map((entry) => ({slug: entry.name, file: join(sitesDir, entry.name, 'index.html')}))
// index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다. // index.html 이 없으면 발행이 끝나지 않은(또는 실패한) 디렉토리다. 사이트맵에 넣지 않는다.
.filter((entry) => existsSync(entry.file)) .filter((entry) => existsSync(entry.file))
.map((entry) => ({ .map((entry) => {
loc: joinUrl(origin, SITE_DIR, entry.slug) + '/', // 제목과 lastmod 가 같은 HTML 에서 나온다 — 파일은 한 번만 읽는다.
// 페이지는 그 사이트를 구울 때마다 다시 쓰인다 — 파일 mtime 이 곧 마지막 발행 시각이다. const html = readFileSync(entry.file, 'utf-8');
lastmod: statSync(entry.file).mtime.toISOString(), return {
})) // ★ 끝 슬래시를 붙이지 않는다. 페이지의 canonical 은 `/s/<slug>` 다(shared/lib/slug.ts
// publishUrl). 사이트맵이 `/s/<slug>/` 로 어긋나 있던 동안 서치콘솔은 제출한 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)); .sort((a, b) => a.loc.localeCompare(b.loc));
// ★ `/s` 목록 페이지. 크롤러가 발행본에 닿는 두 번째 경로다 —
// 사이트맵만 있을 때 서치콘솔은 "참조 페이지: 감지된 페이지 없음" 이라고 답했다.
// ★ 끝 슬래시를 붙이지 않는다 — 슬러그 페이지(`/s/<slug>`)와 같은 형태여야 한다.
// 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, 'robots.txt'), renderRootRobotsTxt(origin), 'utf-8');
writeFileSync(join(outRoot, 'sitemap.xml'), renderSiteUrlset(entries), '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); writeIndexNowKey(outRoot);
} }
@ -555,8 +737,12 @@ function main() {
console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`); console.log(`[prerender] 사이트 ${loaded.length}개 → ${args.out}`);
// ★ 굽기 **전에** 참조를 훑는다. 이번에 다시 굽지 않는 사이트(payload 가 없는 목업 포함)가
// 무엇을 가리키고 있는지는 지금 디스크에 있는 HTML 만 안다.
const referenced = referencedAssets(args.out);
// ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다. // ★ 공용 자산은 사이트를 굽기 전에 딱 한 번 깐다. 사이트마다 복사하던 걸 여기로 뺐다.
writeSharedAssets(args.out); writeSharedAssets(args.out, referenced);
let failed = 0; let failed = 0;
/** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */ /** 루트 기계용 파일을 쓸 오리진. 이 호스트의 사이트는 전부 같은 오리진을 쓴다. */
@ -600,7 +786,7 @@ function main() {
} }
try { try {
const result = prerenderSite(entry.payload, args.out, assets); const result = prerenderSite(entry.payload, args.out, assets, referenced);
const payload = result.payload; const payload = result.payload;
origin = origin || payload.site.origin; origin = origin || payload.site.origin;
const home = joinUrl(payload.site.origin, payload.site.basePath); const home = joinUrl(payload.site.origin, payload.site.basePath);

View File

@ -15,6 +15,28 @@
--tpl-card: #fafafa; --tpl-card: #fafafa;
--tpl-text: #09090b; --tpl-text: #09090b;
--tpl-accent: #2563eb; --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 { @theme inline {
@ -68,4 +90,91 @@ body {
padding-inline: 1.5rem; 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;
}
} }

View File

@ -5,6 +5,7 @@ import {
EssentialInfoSection, EssentialInfoSection,
ExhibitionSection, ExhibitionSection,
FaqSection, FaqSection,
FestivalSection,
GallerySection, GallerySection,
HeroSection, HeroSection,
InquirySection, InquirySection,
@ -14,11 +15,20 @@ import {
LocationSection, LocationSection,
RulesSection, RulesSection,
SpaceSection, SpaceSection,
StorySection,
UnitsSection, UnitsSection,
} from '@/sections'; } from '@/sections';
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {isSectionEnabled} from '@/lib/derive'; 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, exhibition: ExhibitionSection,
photos: GallerySection, photos: GallerySection,
local: LocalGuideSection, local: LocalGuideSection,
festival: FestivalSection,
weather: WeatherSection, weather: WeatherSection,
map: LocationSection, map: LocationSection,
faq: FaqSection, faq: FaqSection,
// 붙여넣기 아이템 아홉. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다. // 붙여넣기 아이템. 데이터가 fact 가 아니라 theme.sections[].data 의 JSON 에서 온다.
// ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다. // ★ 같은 컴포넌트를 두 번 그리지 않는 아래 규칙과 상관없다 — 아이템마다 컴포넌트가 다르다.
...ITEM_SECTIONS, ...ITEM_SECTIONS,
// ★ 지역 이야기(가요·인물·연표·엽서·퀴즈·문학·일력)는 StorySection 이 탭으로 묶어 그린다.
// 그래서 그 일곱은 위 표에서 **가려낸다** — 여기 남겨 두면 탭 안과 밖에 두 번 나온다.
...Object.fromEntries(STORY_KINDS.map((kind) => [kind, StorySection])),
}; };
const rendered = new Set<string>(); const rendered = new Set<string>();

View File

@ -1,6 +1,13 @@
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {galleryImages, sectionBody} from '@/lib/derive'; import {galleryImages, sectionBody} from '@/lib/derive';
/**
* .
*
* (/s/stay `<section id="about">`) .
* (7:5)·(rounded-xl)· (1.8)
* .
*/
export function AboutSection() { export function AboutSection() {
const payload = useSite(); const payload = useSite();
const {narrative, place} = payload; const {narrative, place} = payload;
@ -17,15 +24,14 @@ export function AboutSection() {
<section <section
id="about" id="about"
aria-labelledby="about-heading" aria-labelledby="about-heading"
className="w-full border-b border-black/8 py-16 sm:py-24" className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--tpl-surface, #ffffff)', paddingBlock: 'var(--section-space)'}}
> >
<div className="shell"> <div className="shell">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider opacity-50">About</p> <div className="grid grid-cols-1 items-center gap-8 lg:grid-cols-12 lg:gap-14">
<div className="grid grid-cols-1 items-center gap-8 md:grid-cols-12 lg:gap-12">
{image && ( {image && (
<figure className="md:col-span-6"> <figure className="lg:col-span-7">
<div className="relative aspect-4/3 overflow-hidden rounded-2xl md:aspect-5/4"> <div className="tpl-border border-line relative aspect-4/3 max-h-[26rem] overflow-hidden rounded-xl border lg:aspect-3/2">
<img <img
src={image.url} src={image.url}
alt={image.alt} alt={image.alt}
@ -36,20 +42,16 @@ export function AboutSection() {
className="size-full object-cover" className="size-full object-cover"
/> />
</div> </div>
{image.caption && (
<figcaption className="mt-2 text-xs opacity-60">{image.caption}</figcaption>
)}
</figure> </figure>
)} )}
<div className={image ? 'md:col-span-6' : 'md:col-span-12'}> <div className={image ? 'lg:col-span-5' : 'lg:col-span-12'}>
{/* . <h2 id="about-heading" className="h2 mb-6 flex items-center gap-2.5">
( "○○ 소개") . */} <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<h2 id="about-heading" className="serif mb-4 text-2xl font-bold leading-snug tracking-tight sm:text-3xl lg:text-4xl"> <span className="min-w-0">{`${place.name} 소개`}</span>
{narrative.heroSubline ?? `${place.name} 소개`}
</h2> </h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80"> <div className="measure space-y-4 text-[length:var(--fs-lead)] leading-[1.8]">
{about.map((paragraph, index) => ( {about.map((paragraph, index) => (
<p key={index}>{paragraph}</p> <p key={index}>{paragraph}</p>
))} ))}

View File

@ -11,6 +11,9 @@ import {formatKoreanDate} from '@/lib/format';
* (dl) label-value . * (dl) label-value .
* *
* fact . essentialRows() . * fact . essentialRows() .
* (/s/stay `<section id="summary">`) .
* 5:7 ·, . h2 h3
* .
*/ */
export function AnswerBlock() { export function AnswerBlock() {
const payload = useSite(); const payload = useSite();
@ -23,34 +26,37 @@ export function AnswerBlock() {
<section <section
id="summary" id="summary"
aria-labelledby="summary-heading" aria-labelledby="summary-heading"
className="w-full border-b border-black/8 py-10 sm:py-12" className="border-line w-full border-b py-10 sm:py-14"
style={{backgroundColor: 'var(--color-surface-alt)'}} style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)'}}
> >
<div className="shell"> <div className="shell">
<h2 id="summary-heading" className="serif mb-1 text-lg font-bold sm:text-xl"> <div className="grid gap-8 lg:grid-cols-12 lg:gap-12">
<div className="lg:col-span-5">
<h2 id="summary-heading" className="h3">
{payload.place.name} {payload.place.name}
</h2> </h2>
<p className="mb-5 text-xs opacity-60"> {/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="measure mt-3 text-[length:var(--fs-body)] leading-relaxed">{summary}</p>
<p className="text-muted mt-3 text-[length:var(--fs-xs)]">
{formatKoreanDate(payload.site.updatedAt)} , . {formatKoreanDate(payload.site.updatedAt)} , .
</p> </p>
</div>
{/* 단정문 한 덩어리 — 인용되기 좋은 형태. */}
<p className="mb-5 max-w-3xl text-sm leading-relaxed sm:text-base">{summary}</p>
{rows.length > 0 && ( {rows.length > 0 && (
<dl className="grid grid-cols-1 gap-x-8 gap-y-0 sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-x-10 lg:col-span-7 xl:grid-cols-2">
{rows.map((row) => ( {rows.map((row) => (
<div <div
key={row.label} key={row.label}
className="flex items-start justify-between gap-4 border-b border-black/8 py-2.5 text-sm" className="border-line flex flex-col gap-0.5 py-2.5 sm:flex-row sm:gap-4 sm:border-b"
> >
<dt className="shrink-0 font-medium opacity-60">{row.label}</dt> <dt className="text-muted shrink-0 text-[length:var(--fs-sm)] sm:w-28">{row.label}</dt>
<dd className="text-right font-semibold">{row.value}</dd> <dd className="text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</div> </div>
))} ))}
</dl> </dl>
)} )}
</div> </div>
</div>
</section> </section>
); );
} }

View File

@ -28,8 +28,8 @@ export function BookingSection() {
<section <section
id="booking" id="booking"
aria-labelledby="booking-heading" aria-labelledby="booking-heading"
className="w-full border-b border-black/8 py-16 sm:py-24" className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)'}} style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
> >
<div className="shell"> <div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -37,8 +37,9 @@ export function BookingSection() {
<span>Booking</span> <span>Booking</span>
</p> </p>
<h2 id="booking-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> <h2 id="booking-heading" className="h2 flex items-center gap-2.5">
{sectionName(payload, 'booking', '예약 안내')} <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'booking', '예약 안내')}</span>
</h2> </h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
{payload.place.name} . {payload.place.name} .

View File

@ -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 {useSite} from '@/lib/site-context';
import {essentialRows} from '@/lib/derive'; import {bookingLinks, channelLabel, essentialRows} from '@/lib/derive';
import {formatKoreanDate} from '@/lib/format'; import {formatKoreanDate} from '@/lib/format';
/** /**
* . * .
* *
* AnswerBlock 6 . * (/s/stay `<section id="info">`) .
* "확인 안 된 항목이 N개 있다" * "예약 전 확인"(critical) "시설 · 편의".
* . . * critical ,
* · .
* .
* .
* "확인 중인 항목 N개" .
*/ */
export function EssentialInfoSection() { export function EssentialInfoSection() {
const payload = useSite(); const payload = useSite();
const rows = essentialRows(payload); 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( const hiddenCount = payload.facts.filter(
(fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label), (fact) => fact.scope === 'place' && !rows.some((row) => row.label === fact.label),
).length; ).length;
const links = bookingLinks(payload);
if (rows.length === 0) return null; const phone = payload.place.phone;
const half = Math.ceil(rows.length / 2);
return ( return (
<section <section
id="info" id="info"
aria-labelledby="info-heading" aria-labelledby="info-heading"
className="w-full border-b border-black/8 py-16 sm:py-24" className="border-line paper w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)'}} style={{backgroundColor: 'var(--tpl-surface-alt, #f5f5f4)', paddingBlock: 'var(--section-space)'}}
> >
<div className="shell"> <div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
<Info className="size-4" />
<span>Essential Information</span>
</p>
<div className="mb-8 flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div> <div>
<h2 id="info-heading" className="serif text-2xl font-bold tracking-tight sm:text-3xl"> <header className="mb-6 sm:mb-8">
{'이용 및 예약 안내'} <div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between sm:gap-6">
<div className="min-w-0">
<h2 id="info-heading" className="h2 flex items-center gap-2.5">
<i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0"> </span>
</h2> </h2>
<p className="mt-1 text-xs opacity-60 sm:text-sm"> <p className="text-muted measure mt-2 pl-[calc(3px+0.625rem)] text-[length:var(--fs-sm)]">
. .
</p> </p>
</div> </div>
</div> </div>
</header>
</div>
<div <div className="space-y-8">
className="overflow-hidden rounded-2xl border border-black/8" {groups.map((group) => (
style={{backgroundColor: 'var(--color-surface)'}} <section key={group.title}>
> <h3 className="border-line mb-3 flex items-center gap-2 border-b pb-2 text-[length:var(--fs-sm)] font-bold">
<div className="grid grid-cols-1 divide-y divide-black/8 md:grid-cols-2 md:divide-x md:divide-y-0"> {group.accent && (
{[rows.slice(0, half), rows.slice(half)].map((column, columnIndex) => ( <i
<dl key={columnIndex} className="divide-y divide-black/5"> aria-hidden="true"
{column.map((row) => ( className="h-[1em] w-[3px] shrink-0"
style={{backgroundColor: 'var(--color-accent)'}}
/>
)}
<span>{group.title}</span>
</h3>
<dl className="divide-line divide-y">
{group.rows.map((row) => (
<div <div
key={row.label} key={row.label}
className="flex flex-col justify-between gap-1 p-4 sm:flex-row sm:items-start sm:gap-4 sm:p-5" className="flex flex-col gap-1 py-3.5 sm:flex-row sm:items-baseline sm:gap-6"
> >
<dt className="shrink-0 text-xs font-bold sm:w-32 sm:text-sm">{row.label}</dt> <dt className="text-muted text-[length:var(--fs-sm)] sm:w-40 sm:shrink-0">
<dd className="flex-1 text-left sm:text-right"> {row.label}
<p className="text-xs font-medium sm:text-sm">{row.value}</p> </dt>
{row.note && <p className="mt-0.5 text-[11px] opacity-50">{row.note}</p>} <dd className="measure text-[length:var(--fs-sm)] font-semibold">{row.value}</dd>
</dd>
</div> </div>
))} ))}
</dl> </dl>
</section>
))}
{(phone || links.length > 0) && (
<div className="border-line flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[length:var(--fs-sm)] font-semibold">
{payload.place.name} .
</p>
<div className="flex flex-wrap gap-2">
{phone && (
<a
href={`tel:${phone}`}
className="tap border-line tpl-border inline-flex items-center justify-center gap-1.5 rounded-lg border px-4 text-[length:var(--fs-sm)] font-semibold"
>
<Phone className="size-4" aria-hidden="true" />
<span>{phone}</span>
</a>
)}
{links.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="tap inline-flex items-center justify-center rounded-lg px-6 text-[length:var(--fs-sm)] font-bold transition-opacity hover:opacity-90"
style={{backgroundColor: 'var(--color-brand)', color: 'var(--tpl-bg, #fff)'}}
>
{channelLabel(link)}
</a>
))} ))}
</div> </div>
</div>
)}
</div>
<div className="flex flex-col items-center justify-between gap-2 border-t border-black/8 px-4 py-3.5 text-xs opacity-60 sm:flex-row sm:px-6"> <p className="text-muted mt-5 text-[length:var(--fs-xs)]">
<span className="flex flex-col gap-1 sm:flex-row sm:justify-between">
<span> <span>
{hiddenCount > 0 {hiddenCount > 0
? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.` ? `확인 중인 항목 ${hiddenCount}개는 표시하지 않았습니다. 필요하시면 전화로 문의해 주세요.`
: '모든 항목이 사업자 확인을 거쳤습니다.'} : '모든 항목이 사업자 확인을 거쳤습니다.'}
</span> </span>
<span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} </span> <span className="font-medium">{formatKoreanDate(payload.site.updatedAt)} </span>
</div> </span>
</div> </p>
</div> </div>
</section> </section>
); );

View File

@ -27,8 +27,8 @@ export function ExhibitionSection() {
<section <section
id="exhibition" id="exhibition"
aria-labelledby="exhibition-heading" aria-labelledby="exhibition-heading"
className="w-full border-b border-black/8 py-16 sm:py-24" className="paper border-line w-full border-b"
style={{backgroundColor: 'var(--color-surface-alt)'}} style={{backgroundColor: 'var(--color-surface-alt)', paddingBlock: 'var(--section-space)'}}
> >
<div className="shell"> <div className="shell">
<p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50"> <p className="mb-2 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider opacity-50">
@ -36,8 +36,9 @@ export function ExhibitionSection() {
<span>Exhibition</span> <span>Exhibition</span>
</p> </p>
<h2 id="exhibition-heading" className="serif mb-2 text-2xl font-bold tracking-tight sm:text-3xl lg:text-4xl"> <h2 id="exhibition-heading" className="h2 flex items-center gap-2.5">
{sectionName(payload, 'exhibition', '관람 안내')} <i aria-hidden="true" className="h-[1em] w-[3px] shrink-0 bg-current opacity-25" />
<span className="min-w-0">{sectionName(payload, 'exhibition', '관람 안내')}</span>
</h2> </h2>
<p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm"> <p className="mb-8 max-w-xl text-xs opacity-60 sm:text-sm">
. .

Some files were not shown because too many files have changed in this diff Show More