diff --git a/.gitignore b/.gitignore index 8e202b1..2d394da 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ solution/backend/openapi.json # 여기 생긴다. 어느 쪽이든 payload 로 다시 굽는 재생성물이라 git 이 관리할 대상이 아니다. solution/site/out/ solution/site/dist/ +# React Router 프레임워크 모드(solution/frontend)의 산출물 +build/ +.react-router/ admin/dist/ # nginx 설정: 서버마다 다르므로 실제 파일은 커밋하지 않는다. 템플릿만 커밋한다. diff --git a/AGENTS.md b/AGENTS.md index 5652113..b682a08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,16 +25,39 @@ 밟으면 **조용히 틀린다** — 빌드는 성공하고 화면도 뜨는데 결과가 잘못된 종류다. +- **★★ `out/s/` 에는 payload 가 없는 사이트가 있다 — 목업(`stay` · `stay2` · `stay3` · `*.old`).** + 프리렌더는 **payload 를 받은 사이트만** 굽는다. 목업은 손으로 넣은 것이라 재굽기 대상이 + 아니고, **자산이 한 번 지워지면 영영 복구되지 않는다** — 재굽기를 몇 번 돌려도 안 살아나고 + 사람이 파일을 되돌려 넣어야 한다. 실측(2026-09-07): 번들 해시가 바뀌자 목업 3개의 CSS·JS· + 이미지가 전부 404 가 됐고, 그 파일들은 `stay-mockup` 워크트리에서 손으로 꺼내 복구했다. + → **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다** + (`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다. + → 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다. + - **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로 가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다 (`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다. -- **로컬 `out/assets` 는 빌드마다 통째로 갈린다** (`prerender.ts:573` `rmSync`). 옛 해시 파일이 - 사라지므로 새 번들로 일부 사이트만 구우면 나머지는 CSS 가 404 다. - → 프리렌더 기동 시 전체 재굽기가 이 구멍을 메운다. +- **옛 해시 자산은 30일 남는다** (`prerender.ts` `ASSET_RETENTION_DAYS`). 예전에는 빌드마다 + `out/assets` 를 통째로 갈아서, 새 번들로 일부만 구우면 나머지 사이트가 CSS 404 였다. + 지금은 남긴다 — 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌리므로, 그 사이 자산이 사라지면 + 스타일 없는 페이지를 렌더한 것으로 기록된다. 보관 근거는 `out/assets/.builds.json` 대장이고 + 파일 mtime 이 아니다. + → 재굽기는 여전히 필요하지만 **급하지 않다**. 디자인이 반영 안 될 뿐, 깨지지는 않는다. +- **★ 대장(`out/assets/.builds.json`)에 없는 자산은 지우지 않는다** — "지금 처음 본 것" 으로 + 치고 보관 기간을 새로 준다(`pruneAssets`). **이 규칙을 깨면 운영 사이트가 즉시 끊긴다.** + 실제로 그랬다(2026-09-07): 대장은 이 기능과 함께 생겼으므로 **배포 직후 첫 실행에는 대장이 + 없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. + 옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + → 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.** + → 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기). +- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라 + 운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서 + `restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** — + 재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07). - **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** `azure_static.publish(slug)` 는 공용 자산 + `s/` 만 올린다 — **렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.** - → `docker compose restart solution-frontend` 후 `python scripts/republish_all.py` + → `docker compose restart solution-prerender` 후 `python scripts/republish_all.py` - **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`, `site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부 이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더 @@ -50,8 +73,15 @@ - **`AZURE_STORAGE_CONTAINER=$web`** — 셸에서 export 할 땐 반드시 작은따옴표(`'$web'`). - **슬러그 규칙은 두 곳에 있고 같아야 한다**: `site_payload.publish_slug()` ↔ `solution/shared/src/lib/slug.ts publishUrl`. 어긋나면 발행은 성공하고 주소만 404 다. -- **디렉토리 요청 → `index.html`.** `/s/` 가 **끝 슬래시 없이** 열려야 한다. - 정적 서버를 바꾸든 nginx 설정을 만지든 이 규칙부터 확인한다. +- **★ 발행본 주소는 끝 슬래시가 없다 — 목록 페이지 `/s` 도 마찬가지다.** + canonical · 사이트맵 · llms.txt · 서치콘솔 색인 요청이 전부 이 형태여야 한다. 어긋나면 + 구글이 제출분을 **"대체 페이지(적절한 표준 태그가 있음)"** 로 분류한다 — 색인은 되는데 + 제출 URL 은 0건으로 보이는, 눈으로 원인을 못 찾는 종류다. + → nginx 는 `location = /s` 로 목록 index.html 을 **직접** 주고 `/s/` 는 거기로 301 한다. + 이 블록을 지우면 `/s` 가 맨 아래 `location /` 로 떨어져 **빌더 SPA 셸이 200 으로 나간다** + — 404 도 목록도 아닌 세 번째 페이지가 크롤러에 잡힌다(실측 2026-09-08). + → 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단 + Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다. ## 코드 규약 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2f0c996..96df61c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -186,10 +186,11 @@ USER role=1 → 403 DEVELOPER role=3 → 200 OWNER role=2 → 403 ``` -OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. +OWNER 가 막히는 게 핵심이다 — 내부 운영 화면을 볼 권한이 아니다. `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 -같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기 -회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. +같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 +`places.owner_user_id` 가 자기 계정인 사업장만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. +(2026-09-08 전에는 이 스코프가 회사(테넌트)였다 — DECISIONS.md 2절) ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index cd6d4a5..4de16a1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -88,9 +88,9 @@ | 포트 | **9800** | negosium 9300 / negodata 9400 / agent 9500 / lps 9600 / anchoring 9700 다음 번호 | | DB | `web4ai_db` (테스트 `web4ai_test_db`), 기존 로컬 postgres(`negosium-db` 컨테이너, 5432) 안의 **별도 database** | 원본과 같은 인스턴스·다른 DB. 스키마 네임스페이스 컨벤션 유지 | | 마이그레이션 | Alembic 안 씀. `postgres-init/init-data/init.sql` **한 벌**(전체 DDL, 재실행 안전) | 2026-08-31: 누적 ALTER 파일(`alters/`)을 없앴다. 아직 git·서버 어디에도 안 올라가 **보정할 기존 DB 가 없다** — init.sql 에 이미 전부 반영돼 있어 두 벌을 유지할 이유가 없었다. 운영 DB 가 생기는 순간 다시 필요해진다 | -| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.companies`/`company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | +| 남긴 것 | config 로더 · 로거 · 싱글톤 · DB 세션 매니저(R/W 분리) · gmodel · gtime · authz · JWT/bcrypt dependencies · `company.users` · auth 라우터 · 스케줄러 껍데기 · conftest(테스트 DB 자동 생성/삭제) | 전 모듈이 공통으로 쓰는 인프라. 인증은 places·facts·sites 전부가 `IsValidAccessToken` 에 의존한다 | | 뺀 것 | quotation · supplier · item · card · dashboard · statistics · learning · renegotiation · landing · admin · notification · LPS 연동 · anchoring · 초청메일(ACS/SMTP) · Azure Blob 클라이언트 | negodata 고유 도메인. Blob 클라이언트만 1-2 결론 후 media 모듈과 함께 재이식 예정 | -| `companies` 테이블 유지 | 유지 | 보일러플레이트의 멀티테넌트 스코프 키(`UserInfo.company_id`)가 전 계층에 박혀 있다. 대행사/운영사 단위로 그대로 쓴다 | +| `companies` 테이블 유지 | **2026-09-08 철회 — 걷어냈다** | 보일러플레이트를 그대로 둔 결정이었는데, 이 제품의 사용자는 사장님 한 명이다. 가입 한 번이 회사를 만들고 사장님이 자기 회사의 직원이 되는 구조가 화면에까지 나왔다(가입 폼의 "상호", 헤더의 "이름 · 회사명"). 스코프 키를 `places.owner_user_id` 로 옮기고 `company.companies` 테이블 · `users.company_id` · `UserInfo.company_id` 를 삭제했다. 스키마 이름 `company` 만 남았다 — rename 은 모든 모델의 `__table_args__` 를 건드려서 따로 둔다 | | ErrorType 구간 | 계정 = 1100. 도메인 구간 예약 — places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 | 원본이 구간을 나눠 쓰는 방식 유지 | | 외부 API 키 | `[ExternalApiConfig]` 로 toml + env override. **키가 비면 해당 어댑터만 비활성, 서버는 그대로 뜬다** | 부팅이 외부 계약에 묶이면 안 됨 | | 백그라운드 작업 | 원본에 전용 작업 큐 없음(APScheduler 크론만). 수집·비전분석·빌드는 몇 분 걸리므로 **큐를 새로 얹어야 한다** — 방식 미정 | 원본에 없는 것이라 팀 컨벤션 확인 필요. 아래 3번 참고 | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 2490f3b..9c180f5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -66,18 +66,28 @@ site-out/ ``` 프론트 수정 → 새 번들(새 해시) - 로컬 out/assets : 통째로 교체 (옛 해시 삭제) ← 재굽기 안 한 사이트는 CSS 404 - Azure : 새 해시 추가, 옛 해시 유지 ← 안 깨지지만 옛 디자인 그대로 박제 + 로컬 out/assets : 새 해시 추가, 옛 해시 30일 보관 ← 안 깨진다. 옛 디자인으로 뜰 뿐 + Azure : 새 해시 추가, 옛 해시 유지 ← 같다 ``` -프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 그래서 로컬 out/ 은 재시작만 -하면 정합이 맞는다. 하지만 Azure 는 발행 잡이 도는 사이트 하나씩만 올린다 — 그 짝을 맞추는 게 -`solution/backend/scripts/republish_all.py` 다. +**2026-09-07 이전에는 로컬 `out/assets` 를 통째로 갈았다.** 그래서 재굽기 전까지 나머지 사이트가 +CSS 404 였다 — 하필 크롤러가 그 순간 렌더하면 스타일 없는 페이지를 본 것으로 기록된다. +지금은 `ASSET_RETENTION_DAYS`(30일) 동안 옛 해시를 남긴다. 보관 근거는 `out/assets/.builds.json` +대장이다(파일 mtime 이 아니다 — 복사·동기화가 시각을 갈아 버린다). + +**그래서 재굽기는 여전히 필요하지만 급하지는 않다.** 안 하면 그 사이트만 옛 디자인으로 뜬다. +프리렌더 컨테이너는 **기동할 때 payload 전체를 다시 굽는다.** 하지만 Azure 는 발행 잡이 도는 +사이트 하나씩만 올린다 — 그 짝을 맞추는 게 `solution/backend/scripts/republish_all.py` 다. + +⚠️ 남은 것: `azure_static._upload_shared` 는 매 발행마다 `assets/` **전체**를 다시 올린다. +옛 해시를 남기기 시작했으므로 보관 기간만큼 업로드량이 는다. Azure 를 켤 때는 이미 있는 +블롭(해시 파일이라 이름이 같으면 내용도 같다)을 건너뛰도록 먼저 고친다. + **규칙: `solution/site` 를 배포하면 반드시 전체 재굽기 + 전체 재업로드.** ```bash -docker compose restart solution-frontend # 기동하며 전체 재굽기 +docker compose restart solution-prerender # 기동하며 전체 재굽기 docker compose logs -f solution-frontend # "[watch] 기동" 배치가 끝날 때까지 대기 docker compose exec solution-worker python scripts/republish_all.py ``` @@ -215,5 +225,5 @@ docker compose exec solution-worker python scripts/republish_all.py ## 5. 되돌리기 `out/` 은 재생성물이라 백업이 필요 없다. 문제가 생기면 -`docker compose restart solution-frontend` → 전체 재굽기 → `republish_all.py`. +`docker compose restart solution-prerender` → 전체 재굽기 → `republish_all.py`. 지켜야 할 건 **DB 와 `out/payloads/`** 뿐이다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 9541b9e..14852b8 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -5,6 +5,227 @@ --- +## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다 + +**무슨 일** +사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지 +(`/s/`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다. +목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고, +`/s` 는 맨 아래 `location /` 로 떨어진다. + +**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다 +(실측: `/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도 +목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다. + +**바꾼 것** +- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는 + 거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다 +- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다. + 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다 +- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 + 쓰므로 전부 같이 따라온다 + +**왜 형태를 맞추나** +색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준 +태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번 +밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다. + +**남은 것** +`/s//` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서 +크롤러가 스스로 만들어낼 주소는 아니다. + +--- + +## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 + +**무슨 일** +목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`· +`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게 +어느 건지 가릴 단서가 화면에 하나도 없다. + +**리서치** (Wix · 아임웹) +- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.** + 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl · + editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다. +- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전), + 리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라 + 사이트 안 대시보드에 있다. +- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** — + 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다. + +**바꾼 것** +- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미 + 조인해 읽고 있어서 쿼리는 그대로다 +- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 — + 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다 +- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만 + 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다 + +**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`) +블롭 이름은 `thumbs/.` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서 +주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다 +(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다. +→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다. +→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로 + 그냥 붙이지 않는다. + +**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다. +버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가 +False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다. + +**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에 +`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가. +프론트 `tsc + eslint` 통과. + +--- + +## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 + +**무슨 일** +사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고, +에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데. + +**왜 그랬나** +보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이 +"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다. + +**바꾼 것** +- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고 + (JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다 +- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의 + 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고, + 스코프는 회사가 대신 하고 있었다 +- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제 + **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가 + 스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다 +- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제 +- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는 + `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다 + +**마이그레이션** (`init.sql` 끝, 재실행 안전) +백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게 +몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다. +실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35. + +**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를 +건드려야 해서 이번 변경에 섞지 않았다. + +--- + +## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 + +**무슨 일** +`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도 +살아나지 않았다. + +**왜** +`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고, +프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들 +해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가 +한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다. + +**고친 것** (`scripts/prerender.ts`) +- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다 +- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** — + 기간으로 막으면 30일 뒤에 똑같은 사고가 난다 +- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다 + +**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서 +서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`. + +**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고 +재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다. + +--- + +## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 + +**무슨 일** +바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다. +옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. + +**왜** +`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이 +기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 +전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. + +**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라 +**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다. + +**고친 것** (`scripts/prerender.ts` `pruneAssets`) +- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 +- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) + +**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를 +가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. + +**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → +옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. + +--- + +## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 + +**왜** +`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 +파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부 +CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙** +으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. + +진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. +그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — +하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 +(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. + +**바꾼 것** (`scripts/prerender.ts`) +- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가 + **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다 +- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다 +- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지 + 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. + 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 +- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다 + +**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛 +디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 +"안 하면 반영이 안 된다" 로 내려온다. + +**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼 +업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다. + +**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로 +다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의 +파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed. + +--- + +## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 + +**왜** +`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면 +번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은 +그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다. + +구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예 +무시한다**(Search Central: "the date and time of the last significant update" · +"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라, +**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다 +신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. + +**바꾼 것** +- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵 + 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`) + **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다 +- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 + 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의 + 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다 +- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면 + 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다 + +**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건). + +--- + ## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` **왜** diff --git a/nginx/Dockerfile b/nginx/Dockerfile index f370b72..0913c69 100644 --- a/nginx/Dockerfile +++ b/nginx/Dockerfile @@ -43,4 +43,6 @@ ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \ RUN npm run build -w @o2o/frontend FROM nginx:alpine -COPY --from=build /app/solution/frontend/dist /srv/app +# ★ 프레임워크 모드(React Router)의 산출물은 `build/client` 다 — 예전 `dist` 가 아니다. +# 경로가 어긋나면 COPY 가 조용히 빈 디렉토리를 만들고 컨테이너는 정상으로 뜬다. +COPY --from=build /app/solution/frontend/build/client /srv/app diff --git a/nginx/site.conf.example b/nginx/site.conf.example index 273b729..be044e8 100644 --- a/nginx/site.conf.example +++ b/nginx/site.conf.example @@ -44,8 +44,32 @@ server { image/svg+xml; # ── 발행 사이트 ──────────────────────────────────────────── + # ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로 + # 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 — + # `/s/` 를 접으면 https 페이지가 http 로 내려가는 리다이렉트가 나간다. + absolute_redirect off; + + # ★ 발행본 목록의 정본 주소는 **`/s`** 다 — 슬러그 페이지(`/s/`)와 형태를 맞춘다. + # 이 블록이 없으면 `/s` 는 `^~ /s/` 에 안 걸려 맨 아래 `location /` 로 떨어지고 + # **빌더 SPA 셸이 200 으로 나간다.** 404 도 목록도 아닌 세 번째 페이지가 크롤러에 + # 잡힌다(실측 2026-09-08: `/s` 3.1KB 앱 셸 · `/s/` 6.7KB 목록). + location = /s { + root /srv/sites; + try_files /s/index.html =404; + add_header Cache-Control "public, max-age=300, must-revalidate"; + } + + # 옛 주소. 사이트맵·서치콘솔에 `/s/` 로 제출된 것이 남아 있다. + location = /s/ { + return 301 /s; + } + # ^~ 로 잡아 아래 정규식 location 들이 끼어들지 못하게 한다. location ^~ /s/ { + # ★ `/s//`(끝 슬래시) 를 위해 필요하다. try_files 의 첫 인자 `$uri` 가 끝 + # 슬래시면 nginx 는 **디렉토리 검사**로 읽고, 디렉토리가 있으면 거기서 멈춘다 — + # index 지시자가 없으면 그 순간 403 이다(=404 로도 안 떨어진다). + index index.html; # $uri/ 를 거치면 nginx 가 끝 슬래시로 301 을 내보낸다. 크롤러가 리다이렉트를 # 한 번 더 타야 하므로 index.html 을 바로 준다. try_files $uri $uri/index.html =404; @@ -117,12 +141,16 @@ server { try_files $uri =404; } - # SPA 다. 없는 경로는 index.html 로 넘겨 클라이언트 라우터가 받게 한다. - # ★ index.html 은 캐시하지 않는다 — 여기에 번들 해시가 박혀 있어서, 캐시되면 - # 새로 배포해도 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). + # ★ 폴백은 `/index.html` 이 아니라 `__spa-fallback.html` 이다. + # 프리렌더를 켠 뒤로 `/index.html` 은 **랜딩이 구워진 파일**이다 — 여기로 넘기면 + # `/builder` 를 열었는데 랜딩 HTML 이 내려가고, 클라이언트 라우터는 다른 주소로 + # 하이드레이트한다. 화면은 뜨는데 한 번 깜빡이고 마크업이 어긋나는 종류다. + # 구워진 경로(`/` `/pricing` `/showcase`)는 그 앞의 `$uri/index.html` 이 먼저 잡는다. + # ★ HTML 은 캐시하지 않는다 — 번들 해시가 박혀 있어서, 캐시되면 새로 배포해도 + # 브라우저가 옛 번들 주소를 계속 부른다(404 → 흰 화면). location / { root /srv/app; - try_files $uri $uri/index.html /index.html; + try_files $uri $uri/index.html /__spa-fallback.html; add_header Cache-Control "no-cache"; } diff --git a/package-lock.json b/package-lock.json index 032438f..d6075fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -215,6 +215,19 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", @@ -232,6 +245,28 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-globals": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", @@ -242,6 +277,20 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", @@ -274,6 +323,19 @@ "@babel/core": "^7.0.0" } }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-plugin-utils": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", @@ -284,6 +346,38 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", @@ -344,6 +438,55 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-transform-react-jsx-self": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", @@ -376,6 +519,46 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -1425,6 +1608,12 @@ "jsep": "^0.4.0||^1.0.0" } }, + "node_modules/@mjackson/node-fetch-server": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@mjackson/node-fetch-server/-/node-fetch-server-0.2.0.tgz", + "integrity": "sha512-EMlH1e30yzmTpGLQjlFmaDAjyOeZhng1/XCd7DExR8PNAnG/G1tyruZxEoUe11ClnwGhGrtsdnyyUx1frSzjng==", + "license": "MIT" + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1631,6 +1820,153 @@ "openapi3-ts": "4.5.0" } }, + "node_modules/@react-router/dev": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.18.3.tgz", + "integrity": "sha512-smLBdktEcLw1BgjaeWZG+TDRpmK9Mry4DzgNv4q556/Kq9qDo9Lfxu9Gp4/4BGOctFQG2UuyvxOnOhz0tEyzWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.7", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/preset-typescript": "^7.27.1", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", + "@react-router/node": "7.18.3", + "@remix-run/node-fetch-server": "^0.13.0", + "arg": "^5.0.1", + "babel-dead-code-elimination": "^1.0.6", + "chokidar": "^4.0.0", + "dedent": "^1.5.3", + "es-module-lexer": "^1.3.1", + "exit-hook": "2.2.1", + "isbot": "^5.1.11", + "jsesc": "3.0.2", + "lodash": "^4.17.21", + "p-map": "^7.0.3", + "pathe": "^1.1.2", + "picocolors": "^1.1.1", + "pkg-types": "^2.3.0", + "prettier": "^3.6.2", + "react-refresh": "^0.14.0", + "semver": "^7.3.7", + "tinyglobby": "^0.2.14", + "valibot": "^1.2.0", + "vite-node": "^3.2.2" + }, + "bin": { + "react-router": "bin.js" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@react-router/serve": "^7.18.3", + "@vitejs/plugin-rsc": "~0.5.21", + "react-router": "^7.18.3", + "react-server-dom-webpack": "^19.2.3", + "typescript": "^5.1.0 || ^6.0.0", + "vite": "^5.1.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "wrangler": "^3.28.2 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@react-router/serve": { + "optional": true + }, + "@vitejs/plugin-rsc": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + }, + "typescript": { + "optional": true + }, + "wrangler": { + "optional": true + } + } + }, + "node_modules/@react-router/dev/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@react-router/dev/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-router/dev/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@react-router/dev/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@react-router/node": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/@react-router/node/-/node-7.18.3.tgz", + "integrity": "sha512-wIBFSsmp+uA/F2MEHN1BxFoWAhh9rdIH/Zd39KYyLhZSeb35YlRi8ARtOMDYj7EqhhZfkoetf/SEmYywK3nUkA==", + "license": "MIT", + "dependencies": { + "@mjackson/node-fetch-server": "^0.2.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react-router": "7.18.3", + "typescript": "^5.1.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@remix-run/node-fetch-server": { + "version": "0.13.3", + "resolved": "https://registry.npmjs.org/@remix-run/node-fetch-server/-/node-fetch-server-0.13.3.tgz", + "integrity": "sha512-UfjOXed/DQteaM5VyTfqTeGpHwyL2J5aoRGY6cydip4tt1ehNNeSwuXCC7AEGE0RWBs/7bgKxYkL/B/+UDe4AA==", + "dev": true, + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3376,6 +3712,13 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3478,6 +3821,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3556,6 +3912,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3767,6 +4133,13 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", + "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -3881,6 +4254,21 @@ } } }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4545,6 +4933,19 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exit-hook": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz", + "integrity": "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4565,6 +4966,13 @@ "node": ">=16.9.0" } }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -5640,6 +6048,15 @@ "dev": true, "license": "MIT" }, + "node_modules/isbot": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz", + "integrity": "sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6826,6 +7243,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.7.tgz", + "integrity": "sha512-VaWRu2i4FJNRtiRWCuuQRgfQ1B7a6+gMSrO+3j0EQi/k0ULfS9kosRxGoiqwzIjZTDI02tGfk5mXXltLg6QtfQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6895,6 +7325,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.2.tgz", + "integrity": "sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.0", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, "node_modules/pony-cause": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", @@ -6953,6 +7395,22 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -7042,9 +7500,9 @@ } }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", + "integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -8137,7 +8595,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -8272,6 +8730,21 @@ "node": ">= 4" } }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/validator": { "version": "13.15.23", "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.23.tgz", @@ -8356,6 +8829,36 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -8791,10 +9294,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -8808,6 +9313,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 093edf2..8954fe8 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -5,7 +5,7 @@ -- 단일 PostgreSQL 인스턴스, 단일 database(web4ai_db) 안에서 도메인별 schema 로 묶는다. -- postgres (1개 서버, 5432) -- └── web4ai_db --- ├── company : companies, users +-- ├── company : users -- ├── place : places, place_aliases, place_links, units, media -- ├── fact : facts, faqs -- ├── local : local_contents, routes, nearby_links @@ -46,28 +46,12 @@ CREATE SCHEMA IF NOT EXISTS site; CREATE SCHEMA IF NOT EXISTS job; -- ============================================================ --- company : 회사 / 내부 유저 +-- company : 계정 +-- ★ 스키마 이름만 company 다. 회사(테넌트) 개념은 2026-09-08 에 걷어냈다 — +-- 스키마 rename 은 모든 모델의 __table_args__ 를 건드려야 해서 따로 둔다. -- ============================================================ -CREATE TABLE IF NOT EXISTS company.companies ( - company_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 회사 식별자(PK) - name VARCHAR(100) NOT NULL, -- 회사명 - business_number VARCHAR(30) NULL, -- 사업자등록번호 - code INTEGER NULL, -- 회사코드 (내부 인덱스용) - representative_name VARCHAR(50) NULL, -- 대표자명 - email VARCHAR(255) NULL, -- 대표 이메일 - contact_number VARCHAR(20) NULL, -- 대표 연락처 - website_url VARCHAR(255) NULL, -- 홈페이지 URL - industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 ) - status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) - settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작) - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) - updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) - deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 -); - CREATE TABLE IF NOT EXISTS company.users ( user_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 유저 식별자(PK) - company_id uuid NOT NULL, -- 소속 회사(company.companies.company_id) id VARCHAR(64) NOT NULL, -- 로그인 ID (구글 계정은 google_) password VARCHAR(255) NULL, -- bcrypt 해시. 소셜 계정은 NULL name VARCHAR(50) NULL, -- 이름 @@ -88,8 +72,7 @@ CREATE TABLE IF NOT EXISTS company.users ( -- ============================================================ CREATE TABLE IF NOT EXISTS place.places ( place_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 사업장 식별자(PK) - company_id uuid NOT NULL, -- 테넌트(company.companies.company_id) - owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id) + owner_user_id uuid NOT NULL, -- ★ 스코프 키. 사장님 계정(company.users.user_id) name VARCHAR(200) NOT NULL, -- 상호명(입력값) category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과 status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended @@ -375,15 +358,11 @@ CREATE TABLE IF NOT EXISTS job.jobs ( -- ============================================================ -- 인덱스 -- ============================================================ -CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id); -- 소프트 삭제를 쓰므로 자연키 유니크는 부분 인덱스(deleted = FALSE)로 건다. CREATE UNIQUE INDEX IF NOT EXISTS uq_users_id ON company.users (id) WHERE deleted = FALSE; -CREATE UNIQUE INDEX IF NOT EXISTS uq_companies_biz_number ON company.companies (business_number) WHERE deleted = FALSE AND business_number IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_companies_code ON company.companies (code) WHERE deleted = FALSE; -- place -CREATE INDEX IF NOT EXISTS idx_places_company_id ON place.places (company_id); CREATE INDEX IF NOT EXISTS idx_places_owner_user_id ON place.places (owner_user_id); CREATE INDEX IF NOT EXISTS idx_places_region_code ON place.places (region_code) WHERE deleted = FALSE; CREATE INDEX IF NOT EXISTS idx_place_aliases_place ON place.place_aliases (place_id); @@ -470,3 +449,26 @@ CREATE UNIQUE INDEX IF NOT EXISTS uq_users_provider_uid ON company.users (provid -- 2026-09-03 쇼케이스 카드 썸네일. CREATE TABLE 에만 있어서 기존 DB 가 조용히 깨졌다 -- (실측: 킹서버에서 GET /v1/showcase 가 200 인데 내용은 비었다). ALTER TABLE site.sites ADD COLUMN IF NOT EXISTS thumbnail_url VARCHAR(500) NULL; + +-- 2026-09-08 회사(테넌트) 제거. 쓰는 사람은 사장님 혼자인데 가입 한 번이 회사를 하나 만들고 +-- 그 회사의 직원이 되는 구조였다. 사업장을 계정에 직접 매단다. +-- ★ 순서가 중요하다 — 백필 → NOT NULL → 컬럼 삭제. 반대로 하면 주인을 잃은 행이 남는다. +-- ★ 회사에 계정이 여럿이던 경우(내부 운영 계정)는 **가장 먼저 만든 계정**에게 몰아준다. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema='place' AND table_name='places' AND column_name='company_id') THEN + UPDATE place.places p + SET owner_user_id = ( + SELECT u.user_id FROM company.users u + WHERE u.company_id = p.company_id AND u.deleted = FALSE + ORDER BY u.created_at LIMIT 1) + WHERE p.owner_user_id IS NULL; + -- 주인을 못 찾은 행(회사가 통째로 지워진 경우)은 남겨 둘 수 없다 — 스코프가 없으면 아무에게도 안 보인다. + DELETE FROM place.places WHERE owner_user_id IS NULL; + ALTER TABLE place.places ALTER COLUMN owner_user_id SET NOT NULL; + ALTER TABLE place.places DROP COLUMN company_id; + END IF; +END $$; +ALTER TABLE company.users DROP COLUMN IF EXISTS company_id; +DROP TABLE IF EXISTS company.companies; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index a97eb1f..ea91b8c 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -10,7 +10,6 @@ from common.enums import ( DBType, UserStatus, UserRole, - CompanyStatus, PlaceStatus, FactStatus, MediaStatus, @@ -45,30 +44,11 @@ class MainTableMixin(_DBTypeMixin): # ERD 도메인 모델 -class companies(MainTableMixin, MAIN_BASE): - __tablename__ = "companies" - __table_args__ = {"schema": "company"} - - company_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - name = Column(String(100), nullable=False) - business_number = Column(String(30), nullable=True) - code = Column(Integer, nullable=True) # 내부 인덱스용 - representative_name = Column(String(50), nullable=True) - email = Column(String(255), nullable=True) - contact_number = Column(String(20), nullable=True) - website_url = Column(String(255), nullable=True) - industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) - status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus - # 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작) - settings = Column(JSONB, nullable=True) - - class users(MainTableMixin, MAIN_BASE): __tablename__ = "users" __table_args__ = {"schema": "company"} user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 20자였다. 구글 계정의 로그인 아이디를 `google_`(최대 28자)로 만들면서 넓혔다 — # sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다. id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 @@ -102,8 +82,8 @@ class places(MainTableMixin, MAIN_BASE): ) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - company_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 테넌트 스코프(company.companies) - owner_user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 사장님 계정(company.users) + # ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다. + owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(company.users) name = Column(String(200), nullable=False) # 상호명(입력값) category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) diff --git a/solution/backend/common/models/gmodel.py b/solution/backend/common/models/gmodel.py index bb956fe..6ac550d 100644 --- a/solution/backend/common/models/gmodel.py +++ b/solution/backend/common/models/gmodel.py @@ -67,9 +67,8 @@ class PageParams: class UserInfo(StructModel): """JWT subject 로 인코딩되는 유저 식별 정보.""" - user_id: str # users.user_id (uuid) — 데이터 스코프 키 + user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 id: str # users.id (로그인 아이디) — get_me 재조회 키 - company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 def __init__(self, *args, **kwargs) -> None: diff --git a/solution/backend/conftest.py b/solution/backend/conftest.py index 4ab1cfd..ec606ed 100644 --- a/solution/backend/conftest.py +++ b/solution/backend/conftest.py @@ -14,7 +14,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from common.database.model.models import MAIN_BASE -from common.enums import CompanyStatus +from common.enums import UserRole, UserStatus from config.server_configs import main_db_config @@ -92,7 +92,7 @@ async def db_engine(_test_db_lifecycle): # 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망) await conn.execute( text( - "TRUNCATE TABLE users, companies, places, place_aliases, place_links, units, media, " + "TRUNCATE TABLE users, places, place_aliases, place_links, units, media, " "facts, faqs, local_contents, routes, nearby_links, " "sites, site_versions, publish_logs, ai_check_results, jobs RESTART IDENTITY CASCADE" ) @@ -102,30 +102,24 @@ async def db_engine(_test_db_lifecycle): @pytest_asyncio.fixture -async def company_id(db_engine) -> str: - """테스트용 소속사 1개를 시드하고 company_id(uuid str)를 돌려준다. - users 는 company_id 를 요구하므로 계정 생성 테스트의 선행 조건이다. +async def owner_id(db_engine) -> str: + """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다. + + ★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로 + 계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다. """ - cid = uuid.uuid4() + uid = uuid.uuid4() async with db_engine.begin() as conn: - # status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. + # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. await conn.execute( - text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), - {"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value}, + text( + "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :id, NULL, :name, :status, :role, now())" + ), + {"uid": uid, "id": f"seed{uid.hex[:8]}", "name": "시드사장", + "status": UserStatus.ACTIVE.value, "role": UserRole.USER.value}, ) - return str(cid) - - -@pytest_asyncio.fixture -async def other_company_id(db_engine) -> str: - """company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용).""" - cid = uuid.uuid4() - async with db_engine.begin() as conn: - await conn.execute( - text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"), - {"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value}, - ) - return str(cid) + return str(uid) @pytest_asyncio.fixture @@ -139,28 +133,27 @@ async def client(db_engine): @pytest_asyncio.fixture -async def auth_headers(db_engine, client, company_id): +async def auth_headers(db_engine, client): """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. 계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다. - company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다. - 호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`. + ★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다 + — 격리 테스트는 `await auth_headers("o2")` 하나면 된다. + 호출: `h = await auth_headers("user1")`. """ - from common.enums import UserRole, UserStatus from router.v1.validator.dependencies import GetHashedPW - async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"): - cid = company or company_id + async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"): hashed = await GetHashedPW(password) async with db_engine.begin() as conn: - # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일). + # status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시. await conn.execute( text( - "INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) " - "VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())" + "INSERT INTO users (user_id, id, password, name, status, role, last_accessed_at) " + "VALUES (:uid, :id, :pw, :name, :status, :role, now())" ), { - "uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed, + "uid": uuid.uuid4(), "id": login_id, "pw": hashed, "name": name, "status": UserStatus.ACTIVE.value, "role": role, }, ) diff --git a/solution/backend/crud/place_crud.py b/solution/backend/crud/place_crud.py index 8e75980..70316aa 100644 --- a/solution/backend/crud/place_crud.py +++ b/solution/backend/crud/place_crud.py @@ -11,26 +11,26 @@ from common.logger import LOG from common.utils.gtime import GTime -# 사업장 CRUD. 모든 조회는 company_id(테넌트)로 스코프한다 — 남의 회사 사업장이 보이면 안 된다. +# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다. class IPlaceCRUD(ABC): @abstractmethod async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: pass @abstractmethod - async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: + async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]: pass @abstractmethod - async def list_places(self, cdb: AsyncSession, company_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_places(self, cdb: AsyncSession, owner_user_id, search, category, status, skip, limit) -> Tuple[ErrorType, list, int]: pass @abstractmethod - async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: + async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: pass @abstractmethod - async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: + async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: pass @abstractmethod @@ -73,11 +73,11 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def get_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, places]: + async def get_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, places]: try: query = ( select(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 .limit(1) ) err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) @@ -91,11 +91,11 @@ class PlaceCRUD(IPlaceCRUD): return ErrorType.DB_RUN_FAILED, None async def list_places( - self, cdb: AsyncSession, company_id, search: Optional[str], category: Optional[int], + self, cdb: AsyncSession, owner_user_id, search: Optional[str], category: Optional[int], status: Optional[int], skip: int, limit: int, ) -> Tuple[ErrorType, list, int]: try: - conditions = [places.deleted == False, places.company_id == company_id] # noqa: E712 + conditions = [places.deleted == False, places.owner_user_id == owner_user_id] # noqa: E712 if category is not None: conditions.append(places.category == category) if status is not None: @@ -126,14 +126,14 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, [], 0 - async def update_place(self, cdb: AsyncSession, company_id, place_id, data: dict) -> Tuple[ErrorType, int]: + async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수).""" try: if not data: return ErrorType.SUCCESS, 0 query = ( update(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 .values(**data, updated_at=GTime.UTC()) ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) @@ -141,12 +141,12 @@ class PlaceCRUD(IPlaceCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0 - async def delete_place(self, cdb: AsyncSession, company_id, place_id) -> Tuple[ErrorType, int]: + async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: """사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다.""" try: query = ( delete(places) - .where(places.place_id == place_id, places.company_id == company_id, places.deleted == False) # noqa: E712 + .where(places.place_id == place_id, places.owner_user_id == owner_user_id, places.deleted == False) # noqa: E712 ) return await DB_SESSION_MNG.add_with_rowcount(cdb, query) except Exception as ex: diff --git a/solution/backend/crud/site_crud.py b/solution/backend/crud/site_crud.py index febc075..be7b47a 100644 --- a/solution/backend/crud/site_crud.py +++ b/solution/backend/crud/site_crud.py @@ -22,7 +22,7 @@ class ISiteCRUD(ABC): pass @abstractmethod - async def list_company_sites(self, cdb: AsyncSession, company_id, skip, limit) -> Tuple[ErrorType, list, int]: + async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]: pass @abstractmethod @@ -93,13 +93,13 @@ class SiteCRUD(ISiteCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None - async def list_company_sites(self, cdb: AsyncSession, company_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: - """회사의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). + async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: + """사장님의 사업장 + 사이트 + 마지막 빌드 시각. (ErrorType, [(place, site, built_at)], 총건수). 따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장 (위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.""" try: - where = and_(places.deleted == False, places.company_id == company_id) # noqa: E712 + where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712 cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where)) if cnt_err != ErrorType.SUCCESS: diff --git a/solution/backend/crud/user_crud.py b/solution/backend/crud/user_crud.py index 30b22ee..d24088c 100644 --- a/solution/backend/crud/user_crud.py +++ b/solution/backend/crud/user_crud.py @@ -1,12 +1,12 @@ from abc import ABC, abstractmethod -from typing import Optional, Tuple +from typing import Tuple -from sqlalchemy import select, func, and_, or_, update +from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import users, companies -from common.enums import ErrorType, UserRole +from common.database.model.models import users +from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -39,22 +39,6 @@ class IUserCRUD(ABC): async def update_last_accessed(self, cdb: AsyncSession, user_id) -> ErrorType: pass - @abstractmethod - async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: - pass - - @abstractmethod - async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: - pass - - @abstractmethod - async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: - pass - - @abstractmethod - async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit, hide_dev: bool = False) -> Tuple[ErrorType, list, int]: - pass - @abstractmethod async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: pass @@ -145,72 +129,6 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED - async def add_company(self, cdb: AsyncSession, company: companies) -> ErrorType: - try: - return await DB_SESSION_MNG.insert(cdb, company) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED - - async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: - try: - query = select(companies).where(companies.company_id == company_id, companies.deleted == False).limit(1) # noqa: E712 - err_type, row_list = await DB_SESSION_MNG.execute(cdb, query) - if err_type != ErrorType.SUCCESS: - return err_type, None - if len(row_list) != 1: - return ErrorType.DB_INVALID_KEY, None - return ErrorType.SUCCESS, row_list[0] - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, None - - async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: - try: - query = ( - update(companies) - .where(companies.company_id == company_id, companies.deleted == False) # noqa: E712 - .values(settings=settings, updated_at=GTime.UTC()) - ) - return await DB_SESSION_MNG.add(cdb, query) - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED - - async def list_by_company( - self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int, hide_dev: bool = False - ) -> Tuple[ErrorType, list, int]: - try: - conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712 - # 개발자(내부 운영) 계정은 고객사에 존재 자체가 보이면 안 된다 — 목록에서 빼고 총계에도 넣지 않는다. - if hide_dev: - conditions.append(users.role != UserRole.DEVELOPER.value) - if search: - conditions.append( - or_( - users.id.ilike(f"%{search}%"), - users.name.ilike(f"%{search}%"), - users.email.ilike(f"%{search}%"), - ) - ) - where = and_(*conditions) - - cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where)) - if cnt_err != ErrorType.SUCCESS: - return cnt_err, [], 0 - total = int(cnt_rows[0] or 0) if cnt_rows else 0 - - list_err, rows = await DB_SESSION_MNG.execute( - cdb, - select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit), - ) - if list_err != ErrorType.SUCCESS: - return list_err, [], 0 - return ErrorType.SUCCESS, list(rows), total - except Exception as ex: - LOG.e_no_callstack(ex) - return ErrorType.DB_RUN_FAILED, [], 0 - async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]: try: query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712 diff --git a/solution/backend/router/v1/auth/protocol.py b/solution/backend/router/v1/auth/protocol.py index 99b699c..425e9cf 100644 --- a/solution/backend/router/v1/auth/protocol.py +++ b/solution/backend/router/v1/auth/protocol.py @@ -1,7 +1,5 @@ from typing import Optional -from pydantic import Field - from common.enums import AuthProvider, UserRole from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol @@ -17,7 +15,7 @@ class Req_Login(AuthProtocol): class Req_Signup(AuthProtocol): - """id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + """id/pw 가입. 가입 = 계정 1개다. ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 @@ -27,7 +25,6 @@ class Req_Signup(AuthProtocol): password: str = "" name: Optional[str] = None email: str = "" - company_name: Optional[str] = None # 상호. 비우면 이름 → 아이디 순으로 채운다 class Req_GoogleLogin(AuthProtocol): @@ -46,7 +43,7 @@ class Res_Login(Res_WebPacketProtocol): class Req_UpdateMe(AuthProtocol): - # 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가). + # 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가). name: Optional[str] = None email: Optional[str] = None contact_number: Optional[str] = None @@ -58,11 +55,6 @@ class Res_RefreshToken(Res_WebPacketProtocol): token_type: str = "bearer" -class CompanyData(WebPacketProtocol): - company_id: str = "" - name: str = "" - - class Res_Me(Res_WebPacketProtocol): user_id: str = "" id: str = "" @@ -73,4 +65,3 @@ class Res_Me(Res_WebPacketProtocol): # 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) # 내 정보 화면이 붙을 때 이 값으로 갈라야 한다. provider: AuthProvider = AuthProvider.LOCAL - company: Optional[CompanyData] = Field(default=None) diff --git a/solution/backend/router/v1/place/place.py b/solution/backend/router/v1/place/place.py index 3b67e4d..67622c9 100644 --- a/solution/backend/router/v1/place/place.py +++ b/solution/backend/router/v1/place/place.py @@ -31,7 +31,7 @@ from .protocol import ( Res_UnitList, ) -# 사업장 라우터. 모든 조회·변경은 토큰의 회사(company_id)로 스코프된다. +# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다. router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}}) diff --git a/solution/backend/router/v1/place/protocol.py b/solution/backend/router/v1/place/protocol.py index 7d67556..94e4898 100644 --- a/solution/backend/router/v1/place/protocol.py +++ b/solution/backend/router/v1/place/protocol.py @@ -18,7 +18,8 @@ class Req_CreatePlace(PlaceProtocol): # 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다. name: str = "" category: PlaceCategory = PlaceCategory.LODGING - owner_user_id: Optional[uuid.UUID] = None + # ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면 + # 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. class Req_VerifyPlace(PlaceProtocol): @@ -60,8 +61,8 @@ class Req_VerifyPlaceByUrl(PlaceProtocol): class Req_UpdatePlace(PlaceProtocol): + # ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다. name: Optional[str] = None - owner_user_id: Optional[uuid.UUID] = None status: Optional[PlaceStatus] = None @@ -214,7 +215,7 @@ class Res_VerifyCandidates(Res_WebPacketProtocol): class PlaceSearchItem(WebPacketProtocol): """공개 검색 결과 1건. - ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 + ★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다.""" diff --git a/solution/backend/router/v1/site/protocol.py b/solution/backend/router/v1/site/protocol.py index 573df50..816980c 100644 --- a/solution/backend/router/v1/site/protocol.py +++ b/solution/backend/router/v1/site/protocol.py @@ -85,6 +85,8 @@ class MySiteData(WebPacketProtocol): domain: Optional[str] = None template_id: Optional[str] = None published_at: Optional[datetime] = None + # 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). + thumbnail_url: Optional[str] = None # 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다. needs_rebuild: bool = False @@ -235,7 +237,7 @@ class ShowcaseItem(WebPacketProtocol): """랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다.""" name: str diff --git a/solution/backend/scripts/demo_build.py b/solution/backend/scripts/demo_build.py index 9a3104c..1c8266b 100644 --- a/solution/backend/scripts/demo_build.py +++ b/solution/backend/scripts/demo_build.py @@ -53,13 +53,11 @@ async def main(): engine = create_async_engine(dsn) from router.v1.validator.dependencies import GetHashedPW - cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" + uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" async with engine.begin() as c: - await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), - {"c": cid, "n": "데모대행사"}) - await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " - "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), - {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"), + await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) " + "VALUES (:u,:i,:p,:n,:s,:r,now())"), + {"u": uid, "i": login, "p": await GetHashedPW("pw1234"), "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) from router.router import app diff --git a/solution/backend/scripts/demo_pipeline.py b/solution/backend/scripts/demo_pipeline.py index 5e26544..29b758e 100644 --- a/solution/backend/scripts/demo_pipeline.py +++ b/solution/backend/scripts/demo_pipeline.py @@ -41,12 +41,11 @@ async def main(): engine = create_async_engine(dsn) from router.v1.validator.dependencies import GetHashedPW - cid, uid, login = uuid.uuid4(), uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" + uid, login = uuid.uuid4(), f"demo{uuid.uuid4().hex[:6]}" async with engine.begin() as c: - await c.execute(text("INSERT INTO company.companies (company_id,name,status) VALUES (:c,:n,1)"), {"c": cid, "n": "데모대행사"}) - await c.execute(text("INSERT INTO company.users (user_id,company_id,id,password,name,status,role,last_accessed_at) " - "VALUES (:u,:c,:i,:p,:n,:s,:r,now())"), - {"u": uid, "c": cid, "i": login, "p": await GetHashedPW("pw1234"), + await c.execute(text("INSERT INTO company.users (user_id,id,password,name,status,role,last_accessed_at) " + "VALUES (:u,:i,:p,:n,:s,:r,now())"), + {"u": uid, "i": login, "p": await GetHashedPW("pw1234"), "n": "데모", "s": UserStatus.ACTIVE.value, "r": UserRole.OWNER.value}) await engine.dispose() diff --git a/solution/backend/services/auth_service.py b/solution/backend/services/auth_service.py index 63a59fb..34f6993 100644 --- a/solution/backend/services/auth_service.py +++ b/solution/backend/services/auth_service.py @@ -4,13 +4,12 @@ import uuid from fastapi import Depends from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import companies, users -from common.enums import AuthProvider, CompanyStatus, DBWRType, ErrorType, UserRole, UserStatus +from common.database.model.models import users +from common.enums import AuthProvider, DBWRType, ErrorType, UserRole, UserStatus from common.logger import LOG from common.models.gmodel import UserInfo from crud.user_crud import IUserCRUD, UserCRUD from router.v1.auth.protocol import ( - CompanyData, Req_GoogleLogin, Req_Signup, Req_UpdateMe, @@ -71,11 +70,11 @@ class AuthService: @staticmethod def _user_info(user: users) -> UserInfo: - # uuid → str (JWT json 직렬화 위해). 기능 라우터는 company_id 로 스코프한다. + # uuid → str (JWT json 직렬화 위해). 기능 라우터는 user_id 로 스코프한다 + # — 사업장이 places.owner_user_id 로 이 값에 매여 있다. return UserInfo( user_id=str(user.user_id), id=user.id, - company_id=str(user.company_id), role=user.role, ) @@ -140,26 +139,17 @@ class AuthService: password_hash: str | None, name: str | None, email: str | None, - company_name: str, provider: AuthProvider, provider_uid: str | None, ) -> tuple[ErrorType, users]: - """회사 1개 + 그 회사의 첫 계정 1개를 한 트랜잭션으로 만든다. + """계정 1개를 만든다. - ★ 가입은 곧 새 테넌트다. users.company_id 가 NOT NULL 이고 모든 도메인(사업장·사이트)이 - company_id 로 스코프되므로, 회사 없는 계정은 아무것도 만들지 못한다. - ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서, 그 전에 - company.company_id 를 읽으면 None 이다 — 그대로 넣으면 NOT NULL 위반이다.""" - company_uuid = uuid.uuid4() - company = companies( - company_id=company_uuid, - name=_fit(company_name, 100), - email=_fit(email, 255), - status=CompanyStatus.ACTIVE.value, - ) + ★ 예전엔 가입 한 번이 **회사(테넌트) 하나**를 같이 만들었고 모든 도메인이 그 회사로 + 스코프됐다. 쓰는 사람은 사장님 혼자인데 자기 회사에 소속된 직원이 되는 구조라 + 걷어냈다(2026-09-08) — 이제 사업장이 `places.owner_user_id` 로 이 계정에 직접 매인다. + ★ uuid 를 여기서 미리 만든다. 모델 default 는 flush 시점에 적용돼서 그 전에 읽으면 None 이다.""" user = users( user_id=uuid.uuid4(), - company_id=company_uuid, id=login_id, password=password_hash, name=_fit(name, 50), @@ -171,10 +161,7 @@ class AuthService: ) err_type = await DB_SESSION_MNG.execute_lambda_run( [users.DBType()], - [ - lambda s: self.user_crud.add_company(s, company), - lambda s: self.user_crud.add_user(s, user), - ], + [lambda s: self.user_crud.add_user(s, user)], ) if err_type != ErrorType.SUCCESS: return err_type, None @@ -226,7 +213,6 @@ class AuthService: password_hash=await GetHashedPW(req.password), name=name, email=email, - company_name=(req.company_name or "").strip() or name or login_id, provider=AuthProvider.LOCAL, provider_uid=None, ) @@ -268,13 +254,12 @@ class AuthService: res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT) return res - # 3) 첫 방문 — 계정과 회사를 만든다. + # 3) 첫 방문 — 계정을 만든다. err_type, user = await self._create_account( login_id=_google_login_id(account.sub), password_hash=None, name=account.name or None, email=account.email or None, - company_name=account.name or account.email or _google_login_id(account.sub), provider=AuthProvider.GOOGLE, provider_uid=account.sub, ) @@ -299,16 +284,6 @@ class AuthService: return res user: users - # 2) 소속사 조회 (없어도 치명적 아님) - company = None - c_err, company_row = await DB_SESSION_MNG.execute_lambda( - users.DBType(), - DBWRType.DB_READ.value, - lambda s: self.user_crud.get_company(s, user.company_id), - ) - if c_err == ErrorType.SUCCESS and company_row is not None: - company = CompanyData(company_id=str(company_row.company_id), name=company_row.name) - res.user_id = str(user.user_id) res.id = user.id res.name = user.name @@ -316,7 +291,6 @@ class AuthService: res.contact_number = user.contact_number res.role = UserRole(user.role) res.provider = AuthProvider(user.provider) - res.company = company return res async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me: diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 986e548..371bf23 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -98,18 +98,18 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult async def run_build(job: dict) -> dict: - """BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?} + """BUILD 잡 핸들러. payload: {place_id, owner_user_id, publish?, requested_by?} publish=True 면 게이트를 통과했을 때 바로 발행까지 한다.""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] want_publish = bool(payload.get("publish")) err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -258,7 +258,7 @@ async def run_build(job: dict) -> dict: result["azure"] = azure_result # ★ 페이지가 실제로 올라간 뒤에 썸네일을 남긴다 — 없는 페이지의 그림을 쇼케이스에 걸지 않는다. # 실패해도 발행은 성공이다(스크린샷이 아니라 대표 사진이라, 없으면 글자 카드로 떨어진다). - thumbnail_url = await site_thumbnail.store(slug, snapshot) + thumbnail_url = await site_thumbnail.store(slug, snapshot, version_no) if thumbnail_url: result["thumbnail_url"] = thumbnail_url # ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다. @@ -305,7 +305,7 @@ async def run_build(job: dict) -> dict: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( - s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} + s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} ), ) await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, diff --git a/solution/backend/services/collect_service.py b/solution/backend/services/collect_service.py index 8234d26..368cba6 100644 --- a/solution/backend/services/collect_service.py +++ b/solution/backend/services/collect_service.py @@ -431,12 +431,12 @@ async def run_collect(job: dict) -> dict: """COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다.""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise CollectAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -464,7 +464,7 @@ async def run_collect(job: dict) -> dict: if not targets: result["note"] = "크롤링 대상이 없다(어댑터가 처리할 수 있는 확정 URL 0건)" - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) return result # ★ 이미 충분하면 크롤링 자체를 건너뛴다(force 가 아닐 때). @@ -472,16 +472,19 @@ async def run_collect(job: dict) -> dict: if before["enough"] and not payload.get("force"): result["coverage"] = before result["note"] = "이미 필수 항목이 다 차 있다 — 크롤링 생략(force=true 로 강제 가능)" - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) LOG.i(f"[collect] 충분함 place={place_id} {before['covered']}/{before['total']} — 크롤링 생략") return result from common.models.gmodel import UserInfo actor = UserInfo( - user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), + # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 + # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. + # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 + # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + user_id=owner_user_id, id="collector", - company_id=company_id, role=1, ) @@ -538,24 +541,24 @@ async def run_collect(job: dict) -> dict: # 사진이 들어왔으면 분석을 이어서 건다 — 수집과 분석은 각각 몇 분이라 한 잡에 묶지 않는다. # (묶으면 분석에서 죽었을 때 수집까지 다시 하게 되고, 유료 API 를 두 번 태운다.) if result["media"]["stored"] > 0: - result["vision_job_id"] = await _enqueue_vision(place_id, company_id) + result["vision_job_id"] = await _enqueue_vision(place_id, owner_user_id) - await _finish(place_id, company_id, PlaceStatus.REVIEW) + await _finish(place_id, owner_user_id, PlaceStatus.REVIEW) LOG.i(f"[collect] 완료 place={place_id} fact {result['facts']['stored']}건 · 사진 {result['media']['stored']}장") return result -async def _finish(place_id: str, company_id: str, status: PlaceStatus): +async def _finish(place_id: str, owner_user_id: str, status: PlaceStatus): """수집이 끝나면 사업장을 검수 대기로 돌린다 — 수집값은 전부 후보라 사람이 봐야 한다.""" await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: _place_crud.update_place( - s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": status.value} + s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": status.value} ), ) -async def _enqueue_vision(place_id: str, company_id: str) -> str | None: +async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None: """사진 분석 잡을 적재한다. 키가 없거나 중복이면 조용히 건너뛴다(수집 자체는 성공이다).""" from common.enums import JobType from crud.job_crud import JobQueue @@ -567,7 +570,7 @@ async def _enqueue_vision(place_id: str, company_id: str) -> str | None: return None job_id, _created = await enqueue_job( JobQueue(), JobType.VISION, - {"place_id": place_id, "company_id": company_id}, + {"place_id": place_id, "owner_user_id": owner_user_id}, dedupe_key=f"vision:{place_id}", ) return job_id diff --git a/solution/backend/services/copy_service.py b/solution/backend/services/copy_service.py index d796553..2869edb 100644 --- a/solution/backend/services/copy_service.py +++ b/solution/backend/services/copy_service.py @@ -40,10 +40,10 @@ class CopyAborted(RuntimeError): async def run_copy(job: dict) -> dict: - """COPY 잡 핸들러. payload: {place_id, company_id, requested_by?}""" + """COPY 잡 핸들러. payload: {place_id, owner_user_id, requested_by?}""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] if not gemini_text.is_configured(): raise CopyAborted("GEMINI_API_KEY 미설정 — 소개문·FAQ 를 생성할 수 없다") @@ -51,7 +51,7 @@ async def run_copy(job: dict) -> dict: err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise CopyAborted(f"사업장을 찾을 수 없다: {place_id}") @@ -165,9 +165,12 @@ async def run_copy(job: dict) -> dict: } actor = UserInfo( - user_id=payload.get("requested_by") or str(place.verified_by or uuid.uuid4()), + # ★ 잡이 쓰는 신원. user_id 는 **사업장 주인**이어야 한다 — FactService 가 이 값으로 + # 사업장을 스코프하고(fact_service._load_place) verified_by 에도 그대로 박는다. + # 회사를 걷어내기 전에는 스코프가 company_id 였고 여기엔 요청자·검증자·랜덤 uuid 가 + # 순서대로 들어갔다. 그 랜덤 uuid 가 이제는 "남의 사업장" 이 되어 조회가 0건이 된다. + user_id=owner_user_id, id="generator", - company_id=company_id, role=1, ) service = FactService(_fact_crud, _place_crud) diff --git a/solution/backend/services/fact_service.py b/solution/backend/services/fact_service.py index cfd8b8b..c22ded9 100644 --- a/solution/backend/services/fact_service.py +++ b/solution/backend/services/fact_service.py @@ -73,7 +73,7 @@ class FactService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/faq_service.py b/solution/backend/services/faq_service.py index c4c2efa..ccb2020 100644 --- a/solution/backend/services/faq_service.py +++ b/solution/backend/services/faq_service.py @@ -38,14 +38,14 @@ class FaqService: self.crud = crud self.place_crud = place_crud - # ---- 사업장 로드(회사 스코프) ---- + # ---- 사업장 로드(사장님 스코프) ---- async def _load_place(self, user_info: UserInfo, place_id: str): - """company_id 를 WHERE 에 걸어 조회한다 — 남의 회사 place_id 를 넣으면 PLACE_NOT_FOUND. + """owner_user_id 를 WHERE 에 걸어 조회한다 — 남의 place_id 를 넣으면 PLACE_NOT_FOUND. '없다'와 '권한 없다'를 구분해 주지 않는 것도 의도다(존재 여부를 흘리지 않는다).""" err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/media_service.py b/solution/backend/services/media_service.py index 9a43007..b423ca6 100644 --- a/solution/backend/services/media_service.py +++ b/solution/backend/services/media_service.py @@ -41,7 +41,7 @@ class MediaService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None diff --git a/solution/backend/services/place_service.py b/solution/backend/services/place_service.py index 4453cca..f212615 100644 --- a/solution/backend/services/place_service.py +++ b/solution/backend/services/place_service.py @@ -70,12 +70,12 @@ class PlaceService: # ---- 조회 ---- async def list_places(self, user_info: UserInfo, pg: PageParams, search=None, category=None, status=None) -> Res_PlaceList: res = Res_PlaceList(page=pg.page, size=pg.size) - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, lambda s: self.crud.list_places( - s, cid, search, + s, uid, search, category.value if isinstance(category, PlaceCategory) else category, status.value if isinstance(status, PlaceStatus) else status, pg.skip, pg.size, @@ -102,7 +102,7 @@ class PlaceService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None @@ -122,8 +122,11 @@ class PlaceService: return res place = places( - company_id=uuid.UUID(user_info.company_id), - owner_user_id=req.owner_user_id, + # ★ 주인은 **토큰이 정한다.** 예전엔 요청 body 의 owner_user_id 를 그대로 넣었는데, + # 그 값은 아무도 안 보내서 92건 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다. + # 회사를 걷어내면서 이 컬럼이 스코프 키가 됐다 — body 로 남의 계정을 적을 수 있으면 + # 만들자마자 남의 목록에 들어간다. + owner_user_id=uuid.UUID(user_info.user_id), name=req.name.strip(), category=req.category.value, status=PlaceStatus.DRAFT.value, @@ -149,7 +152,7 @@ class PlaceService: if data: err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), - lambda s: self.crud.update_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), data), + lambda s: self.crud.update_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), data), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -164,7 +167,7 @@ class PlaceService: err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.delete_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id) + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id) ), ) if err_type != ErrorType.SUCCESS: @@ -253,7 +256,7 @@ class PlaceService: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), {"name": official} + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"name": official} ), ) if verified.place: @@ -294,7 +297,7 @@ class PlaceService: res.result.SetResult(err_type) return res - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) now = GTime.UTC() data = { "external_source": req.source.value, @@ -325,7 +328,7 @@ class PlaceService: data["external_category"] = req.category_name.strip()[:200] err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), - lambda s: self.crud.update_place(s, cid, uuid.UUID(place_id), data), + lambda s: self.crud.update_place(s, uid, uuid.UUID(place_id), data), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -513,7 +516,7 @@ class PlaceService: payload = { "place_id": place_id, - "company_id": user_info.company_id, + "owner_user_id": user_info.user_id, "category": place.category, # 명시적으로 고른 링크가 있을 때만 대상을 제한한다. 기본 요청에서 현재 # 확정 링크를 복사하면, 잡의 discover 단계가 새로 확정한 네이버 링크가 @@ -542,7 +545,7 @@ class PlaceService: await DB_SESSION_MNG.execute_lambda_claim( places.DBType(), lambda s: self.crud.update_place( - s, uuid.UUID(user_info.company_id), uuid.UUID(place_id), + s, uuid.UUID(user_info.user_id), uuid.UUID(place_id), {"status": PlaceStatus.COLLECTING.value}, ), ) @@ -589,7 +592,7 @@ class PlaceService: job_id, created = await enqueue_job( self.queue, JobType.VISION, - {"place_id": place_id, "company_id": user_info.company_id, "force": req.force}, + {"place_id": place_id, "owner_user_id": user_info.user_id, "force": req.force}, dedupe_key=f"vision:{place_id}", ) if job_id is None: @@ -849,7 +852,7 @@ class PlaceService: job_id, created = await enqueue_job( self.queue, JobType.COPY, - {"place_id": place_id, "company_id": user_info.company_id, "requested_by": user_info.user_id}, + {"place_id": place_id, "owner_user_id": user_info.user_id, "requested_by": user_info.user_id}, dedupe_key=f"copy:{place_id}", ) if job_id is None: diff --git a/solution/backend/services/showcase_service.py b/solution/backend/services/showcase_service.py index 4f058ed..835ef8f 100644 --- a/solution/backend/services/showcase_service.py +++ b/solution/backend/services/showcase_service.py @@ -5,7 +5,7 @@ 고르는 자리를 한 곳으로 모았다. 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이라, 페이지에 이미 적혀 있는 것만 나간다. - 나가지 않는 것: place_id · company_id · site_id · 전화번호 · 상세 주소 · 좌표. + 나가지 않는 것: place_id · 소유자 · site_id · 전화번호 · 상세 주소 · 좌표. """ from common.database.db_session_manager import DB_SESSION_MNG diff --git a/solution/backend/services/site_service.py b/solution/backend/services/site_service.py index 3103797..a5fc221 100644 --- a/solution/backend/services/site_service.py +++ b/solution/backend/services/site_service.py @@ -104,7 +104,7 @@ class SiteService: err_type, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.company_id), uuid.UUID(place_id)), + lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)), ) if err_type != ErrorType.SUCCESS: return ErrorType.PLACE_NOT_FOUND, None @@ -420,16 +420,16 @@ class SiteService: return cleaned async def list_my_sites(self, user_info: UserInfo, pg: PageParams) -> Res_MySites: - """로그인한 계정(회사)이 가진 사이트 전부. + """로그인한 사장님이 가진 사이트 전부. 사업장 목록(/v1/place/list)과 따로 두는 이유: 화면이 알아야 하는 건 '사업장이 있다'가 아니라 '발행돼 있나 · 주소가 뭔가 · 다시 구워야 하나'다.""" res = Res_MySites(page=pg.page, size=pg.size) - cid = uuid.UUID(user_info.company_id) + uid = uuid.UUID(user_info.user_id) err_type, rows, total = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: self.crud.list_company_sites(s, cid, pg.skip, pg.size), + lambda s: self.crud.list_owner_sites(s, uid, pg.skip, pg.size), ) if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) @@ -454,6 +454,7 @@ class SiteService: domain=getattr(site, "domain", None), template_id=getattr(site, "template_id", None), published_at=getattr(site, "published_at", None), + thumbnail_url=getattr(site, "thumbnail_url", None), needs_rebuild=bool(site is not None and changed and (built_at is None or changed > built_at)), ) @@ -526,7 +527,7 @@ class SiteService: job_id, created = await enqueue_job( self.queue, JobType.BUILD, { - "place_id": place_id, "company_id": user_info.company_id, + "place_id": place_id, "owner_user_id": user_info.user_id, "publish": req.publish, "requested_by": user_info.user_id, }, dedupe_key=f"build:{place_id}", diff --git a/solution/backend/services/site_thumbnail.py b/solution/backend/services/site_thumbnail.py index 92b9e24..376b476 100644 --- a/solution/backend/services/site_thumbnail.py +++ b/solution/backend/services/site_thumbnail.py @@ -18,7 +18,7 @@ import asyncio import os import httpx -from azure.storage.blob import BlobServiceClient, ContentSettings +from azure.storage.blob import BlobClient, BlobServiceClient, ContentSettings from common.logger import LOG from services import azure_static, site_payload @@ -46,19 +46,71 @@ MAX_BYTES = 5 * 1024 * 1024 CACHE_CONTROL = "public, max-age=60, must-revalidate" +# ── 썸네일 전용 저장소 ──────────────────────────────────────────────────────── +# ★ 왜 스위치를 따로 두나 +# 원래는 `AZURE_STORAGE_CONNECTION_STRING` 하나가 사이트 업로드(azure_static)와 썸네일을 +# **같이** 켰다. 그런데 그 둘은 필요한 저장소가 다르다 — 사이트는 정적 호스팅(`$web`)이고 +# 썸네일은 그냥 이미지 버킷이면 된다. 하나로 묶어 두면 "썸네일 좀 보자" 고 키를 꽂는 순간 +# **발행할 때마다 사이트 전체가 그 버킷에 업로드된다.** 지금 우리가 빌려 쓰는 곳은 +# negodata·infinith 와 공용인 미디어 컨테이너라 그렇게 되면 안 된다. +# +# ★ 값 출처: o2o-negosium/negodata/backend/config/config.local.toml `[StorageConfig]`. +# 같은 계정/컨테이너를 root 디렉터리로만 가른다(negodata/ · infinith/ · web4ai/) — +# 그쪽 관례를 그대로 따른 것이지 우리 계정이 아니다. +# +# ⚠️ **임시다.** 이 컨테이너는 정적 사이트 호스팅이 아니라 발행본을 못 올린다. 그리고 SAS 가 +# 컨테이너 전체에 racwdl(삭제 포함)이라, 남의 프로젝트 파일에 닿을 수 있는 자리다 — +# web4ai 전용 스토리지 계정이 생기면 이 블록을 걷고 azure_static 쪽으로 되돌린다. +_BASE_ENV = "THUMBNAIL_BLOB_BASE_URL" # https://<계정>.blob.core.windows.net/<컨테이너> +_SAS_ENV = "THUMBNAIL_BLOB_SAS_TOKEN" # `?sv=...` (앞의 물음표는 있어도 없어도 된다) +_ROOT_ENV = "THUMBNAIL_BLOB_ROOT" # 컨테이너 안에서 우리가 쓰는 디렉터리. 예: web4ai + + +def _blob_base() -> str: + return os.environ.get(_BASE_ENV, "").strip().rstrip("/") + + +def _blob_sas() -> str: + return os.environ.get(_SAS_ENV, "").strip().lstrip("?") + + +def _blob_root() -> str: + return os.environ.get(_ROOT_ENV, "").strip().strip("/") + + +def uses_blob_store() -> bool: + """썸네일 전용 저장소를 쓰는가. 아니면 예전대로 azure_static 설정을 따른다.""" + return bool(_blob_base() and _blob_sas()) + + def is_configured() -> bool: - return azure_static.is_configured() + return uses_blob_store() or azure_static.is_configured() def blob_name(slug: str, ext: str) -> str: + if uses_blob_store(): + # base_url 에 컨테이너까지 들어 있다 — 여기서는 컨테이너 안쪽 경로만 만든다. + return "/".join(part for part in (_blob_root(), THUMB_DIR, f"{slug}.{ext}") if part) prefix = os.environ.get("AZURE_STORAGE_PREFIX", azure_static.DEFAULT_PREFIX).strip().strip("/") return "/".join(part for part in (prefix, THUMB_DIR, f"{slug}.{ext}") if part) -def public_url(slug: str, ext: str) -> str: +def public_url(slug: str, ext: str, version: int | None = None) -> str: """공개 주소. 발행 사이트와 같은 오리진이다 — 접두사는 오리진 경로로 흡수된다 - (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다').""" - return f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + (CLAUDE.md 'AZURE_STORAGE_PREFIX 와 루트 절대경로는 충돌한다'). + + ★ `?v=<버전>` 은 캐시 무효화다. 블롭 이름은 발행마다 그대로고 내용만 덮어쓰므로 + (overwrite=True), 주소가 안 변하면 브라우저·CDN 이 **옛 그림을 계속 보여준다.** + 아래 CACHE_CONTROL(60초)만으로는 부족하다 — 그 60초 동안 사장님은 방금 바꾼 사진이 + 아니라 지난 발행의 사진을 본다. 버전을 붙이면 발행 즉시 새 주소가 된다. + ★ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이고, 지우는 코드가 없다. + ★ version 이 None 이면 붙이지 않는다 — 옛 발행분을 사후에 채우는 경로 + (scripts/backfill_thumbnails.py)에는 그 시점의 버전이 없다.""" + # ★ 저장소가 발행 오리진 밖이면 주소도 그쪽이다. 여기서 publish_origin 을 쓰면 + # 그림은 블롭에 올라가 있는데 카드는 우리 사이트 주소를 가리켜 전부 404 다. + base = f"{_blob_base()}/{blob_name(slug, ext)}" if uses_blob_store() \ + else f"{site_payload.publish_origin()}/{THUMB_DIR}/{slug}.{ext}" + return f"{base}?v={version}" if version is not None else base async def _fetch(url: str) -> tuple[bytes, str, str] | None: @@ -109,6 +161,18 @@ async def _fetch(url: str) -> tuple[bytes, str, str] | None: def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: + name = blob_name(slug, ext) + settings = ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL) + + if uses_blob_store(): + # ★ SAS 는 연결 문자열이 아니다 — from_connection_string 이 못 받는다. + # 블롭 주소에 토큰을 붙여 그 한 파일에만 붙는다(컨테이너 클라이언트를 만들지 않는다: + # 남의 디렉터리를 훑을 수 있는 핸들을 굳이 들고 있지 않는다). + BlobClient.from_blob_url(f"{_blob_base()}/{name}?{_blob_sas()}").upload_blob( + data, overwrite=True, content_settings=settings + ) + return name + connection_string = os.environ["AZURE_STORAGE_CONNECTION_STRING"].strip() container_name = ( os.environ.get("AZURE_STORAGE_CONTAINER", azure_static.DEFAULT_CONTAINER).strip() @@ -116,18 +180,12 @@ def _upload_sync(slug: str, data: bytes, content_type: str, ext: str) -> str: ) service = BlobServiceClient.from_connection_string(connection_string) container = service.get_container_client(container_name) - name = blob_name(slug, ext) - container.upload_blob( - name=name, - data=data, - overwrite=True, - # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. - content_settings=ContentSettings(content_type=content_type, cache_control=CACHE_CONTROL), - ) + # cache_control 은 ContentSettings 에 담아야 블롭 속성으로 실제로 박힌다. + container.upload_blob(name=name, data=data, overwrite=True, content_settings=settings) return name -async def store(slug: str, snapshot: dict) -> str | None: +async def store(slug: str, snapshot: dict, version: int | None = None) -> str | None: """대표 사진을 썸네일로 올리고 공개 URL 을 돌려준다. 못 하면 None(발행은 그대로 간다). SDK 의 동기 I/O 는 별도 스레드에서 돈다 — azure_static.publish 와 같은 이유로, @@ -153,4 +211,4 @@ async def store(slug: str, snapshot: dict) -> str | None: return None LOG.i(f"[thumbnail] {slug} → {name} ({len(data)} bytes · {content_type})") - return public_url(slug, ext) + return public_url(slug, ext, version) diff --git a/solution/backend/services/vision_service.py b/solution/backend/services/vision_service.py index 9f7e4a8..0b1e534 100644 --- a/solution/backend/services/vision_service.py +++ b/solution/backend/services/vision_service.py @@ -28,10 +28,10 @@ class VisionAborted(RuntimeError): async def run_vision(job: dict) -> dict: - """VISION 잡 핸들러. payload: {place_id, company_id, force?}""" + """VISION 잡 핸들러. payload: {place_id, owner_user_id, force?}""" payload = job["payload"] place_id = payload["place_id"] - company_id = payload["company_id"] + owner_user_id = payload["owner_user_id"] force = bool(payload.get("force")) if not gemini.is_configured(): @@ -40,7 +40,7 @@ async def run_vision(job: dict) -> dict: err, place = await DB_SESSION_MNG.execute_lambda( places.DBType(), DBWRType.DB_READ.value, - lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)), + lambda s: _place_crud.get_place(s, uuid.UUID(owner_user_id), uuid.UUID(place_id)), ) if err != ErrorType.SUCCESS or place is None: raise VisionAborted(f"사업장을 찾을 수 없다: {place_id}") diff --git a/solution/backend/tests/test_auth.py b/solution/backend/tests/test_auth.py index c596f4a..a3b0a84 100644 --- a/solution/backend/tests/test_auth.py +++ b/solution/backend/tests/test_auth.py @@ -4,9 +4,9 @@ """ -async def test_login_and_me_flow(auth_headers, client, company_id): +async def test_login_and_me_flow(auth_headers, client): """검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출. - 기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환.""" + 기대결과: 200, 본인 id·name 이 그대로 반환.""" h = await auth_headers("user1", name="홍길동") r = await client.get("/v1/auth/me", headers=h) @@ -14,7 +14,8 @@ async def test_login_and_me_flow(auth_headers, client, company_id): me = r.json() assert me["id"] == "user1" assert me["name"] == "홍길동" - assert me["company"]["company_id"] == company_id + # ★ 소속사 필드는 없다. 회사(테넌트)를 걷어냈다(2026-09-08) — 쓰는 사람은 사장님 혼자다. + assert "company" not in me async def test_login_with_wrong_password(auth_headers, client): @@ -49,7 +50,7 @@ _SIGNUP = {"id": "sajang1", "password": "pw12345678", "name": "김사장", "emai async def test_signup_creates_account_and_logs_in(client, db_engine): """검증: 가입 → 받은 토큰으로 곧바로 /me. - 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원과 **새로 생긴 소속사**를 돌려준다.""" + 기대결과: 토큰이 실려 오고, /me 가 방금 만든 신원을 돌려준다.""" r = await client.post("/v1/auth/signup", json=_SIGNUP) body = r.json() assert body["result"]["success"] is True @@ -59,7 +60,6 @@ async def test_signup_creates_account_and_logs_in(client, db_engine): assert me["id"] == "sajang1" assert me["email"] == "boss@example.com" assert me["provider"] == 1 # AuthProvider.LOCAL - assert me["company"]["name"] == "김사장" # 회사명 미입력 → 이름으로 채운다 async def test_signup_rejects_duplicate_id(client, db_engine): diff --git a/solution/backend/tests/test_build_publish.py b/solution/backend/tests/test_build_publish.py index 9aab5b2..87c5f78 100644 --- a/solution/backend/tests/test_build_publish.py +++ b/solution/backend/tests/test_build_publish.py @@ -264,12 +264,12 @@ async def test_versions_accumulate(auth_headers, client, db_engine): assert [v["version"] for v in versions] == [2, 1] -async def test_site_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사이트를 본다. +async def test_site_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사이트를 본다. 기대결과: PLACE_NOT_FOUND.""" h1 = await auth_headers("o1") pid = await _place(client, h1, "스코프펜션") - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/site", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_collect_api.py b/solution/backend/tests/test_collect_api.py index c2716e2..205a3da 100644 --- a/solution/backend/tests/test_collect_api.py +++ b/solution/backend/tests/test_collect_api.py @@ -156,13 +156,13 @@ async def test_targeted_collect_payload_carries_requested_confirmed_link(auth_he assert job["payload"]["link_ids"] == [confirmed] -async def test_collect_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장 수집을 시작한다. +async def test_collect_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장 수집을 시작한다. 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 수집도 못 건다.""" h1 = await auth_headers("o1") pid = await _place(client, h1, kakao="c7") await _confirmed_link(client, h1, pid) - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.post(f"/v1/place/{pid}/collect", headers=h2, json={}) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_collect_pipeline.py b/solution/backend/tests/test_collect_pipeline.py index 4b5d9d7..5ca4391 100644 --- a/solution/backend/tests/test_collect_pipeline.py +++ b/solution/backend/tests/test_collect_pipeline.py @@ -208,7 +208,7 @@ async def test_recollect_cannot_overwrite_corrected_value(auth_headers, client): assert check_in[0]["status"] == FactStatus.CORRECTED.value -async def test_pipeline_refuses_unverified_place(db_engine, company_id): +async def test_pipeline_refuses_unverified_place(db_engine, owner_id): """검증: 검증 안 된 사업장의 수집 잡이 큐에 직접 들어간 경우(잡 적재 후 검증이 취소된 상황). 기대결과: 잡이 실패한다 — ★ 잡 실행 시점에도 게이트를 다시 확인한다.""" from sqlalchemy import text @@ -216,13 +216,13 @@ async def test_pipeline_refuses_unverified_place(db_engine, company_id): pid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( - text("INSERT INTO places (place_id, company_id, name, category, status) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status) " "VALUES (:pid, :cid, :n, 1, 1)"), - {"pid": pid, "cid": uuid.UUID(company_id), "n": "미검증펜션"}, + {"pid": pid, "cid": uuid.UUID(owner_id), "n": "미검증펜션"}, ) q = JobQueue() - job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "company_id": company_id}, max_attempts=1) + job_id = await q.enqueue(JobType.COLLECT.value, {"place_id": str(pid), "owner_user_id": owner_id}, max_attempts=1) worker = Worker("test-worker", q, build_handler(), backoff_fn=lambda _a: 0) await worker.process_one() diff --git a/solution/backend/tests/test_fact_api.py b/solution/backend/tests/test_fact_api.py index 91a0bfc..38f4e0c 100644 --- a/solution/backend/tests/test_fact_api.py +++ b/solution/backend/tests/test_fact_api.py @@ -269,12 +269,12 @@ async def test_crawl_only_does_not_mark_rebuild(auth_headers, client): assert place.get("content_updated_at") is None -async def test_facts_are_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장 fact 를 조회한다. +async def test_facts_are_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장 fact 를 조회한다. 기대결과: PLACE_NOT_FOUND — 사업장이 안 보이니 fact 도 안 보인다.""" h1 = await auth_headers("o1") pid = await _verified_place(client, h1, kakao="p6") - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") r = await client.get(f"/v1/place/{pid}/fact/list", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_fact_schema.py b/solution/backend/tests/test_fact_schema.py index fe04ef9..a357071 100644 --- a/solution/backend/tests/test_fact_schema.py +++ b/solution/backend/tests/test_fact_schema.py @@ -22,17 +22,17 @@ from common.enums import ( ) -async def _seed_place(db_engine, company_id) -> str: +async def _seed_place(db_engine, owner_id) -> str: """검증까지 끝난 사업장 1개를 시드하고 place_id 를 돌려준다.""" pid = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( text( - "INSERT INTO places (place_id, company_id, name, category, status, external_place_id, verified_at) " + "INSERT INTO places (place_id, owner_user_id, name, category, status, external_place_id, verified_at) " "VALUES (:pid, :cid, :name, :cat, :status, :kakao, now())" ), { - "pid": pid, "cid": uuid.UUID(company_id), "name": "테스트펜션", + "pid": pid, "cid": uuid.UUID(owner_id), "name": "테스트펜션", "cat": PlaceCategory.LODGING.value, "status": PlaceStatus.DRAFT.value, "kakao": "12345678", }, @@ -54,20 +54,20 @@ async def _insert_fact(db_engine, place_id, key, value, status, unit_id=None): ) -async def test_published_fact_is_unique_per_place_and_key(db_engine, company_id): +async def test_published_fact_is_unique_per_place_and_key(db_engine, owner_id): """검증: 같은 사업장·같은 key 로 노출 상태 fact 를 두 번 넣는다. 기대결과: 두 번째 INSERT 가 유니크 인덱스에 막힌다(체크인 시간이 두 값으로 갈라지지 않는다).""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) with pytest.raises(IntegrityError): await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.CORRECTED) -async def test_candidates_coexist_with_published_value(db_engine, company_id): +async def test_candidates_coexist_with_published_value(db_engine, owner_id): """검증: 노출값이 있는 상태에서 재수집 후보를 여러 건 넣는다. 기대결과: 전부 공존한다 — ★ 재수집이 노출 중인 사실을 밀어내지 않는다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.VERIFIED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.PENDING_OWNER) await _insert_fact(db_engine, place_id, "check_in_time", "14:00", FactStatus.UNVERIFIED) @@ -80,10 +80,10 @@ async def test_candidates_coexist_with_published_value(db_engine, company_id): assert len(rows) == 3, "노출값 1건 + 후보 2건이 공존해야 한다" -async def test_rejected_fact_frees_the_key(db_engine, company_id): +async def test_rejected_fact_frees_the_key(db_engine, owner_id): """검증: 기존 값을 REJECTED 로 내린 뒤 같은 key 를 새로 노출한다. 기대결과: 통과 — 틀린 값은 이력으로 남고, 새 값이 노출 자리를 차지한다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "check_in_time", "15:00", FactStatus.REJECTED) await _insert_fact(db_engine, place_id, "check_in_time", "16:00", FactStatus.VERIFIED) @@ -95,18 +95,18 @@ async def test_rejected_fact_frees_the_key(db_engine, company_id): assert len(rows) == 2, "REJECTED 이력과 새 값이 함께 남아야 한다" -async def test_expired_fact_frees_the_key(db_engine, company_id): +async def test_expired_fact_frees_the_key(db_engine, owner_id): """검증: 유효기간이 지나 EXPIRED 로 내린 값과 새 수집값의 공존. 기대결과: 통과 — EXPIRED 도 유니크에서 빠진다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) await _insert_fact(db_engine, place_id, "cancel_policy", "구 규정", FactStatus.EXPIRED) await _insert_fact(db_engine, place_id, "cancel_policy", "새 규정", FactStatus.VERIFIED) -async def test_same_key_allowed_across_units(db_engine, company_id): +async def test_same_key_allowed_across_units(db_engine, owner_id): """검증: 객실이 다르면 같은 key 를 각각 가질 수 있는지. 기대결과: 통과 — A동·B동이 각자의 기준 인원을 갖는다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) unit_a, unit_b = uuid.uuid4(), uuid.uuid4() async with db_engine.begin() as conn: for uid, name in ((unit_a, "A동"), (unit_b, "B동")): @@ -122,10 +122,10 @@ async def test_same_key_allowed_across_units(db_engine, company_id): await _insert_fact(db_engine, place_id, "standard_capacity", "6", FactStatus.CORRECTED, unit_id=unit_a) -async def test_unit_fact_and_place_fact_are_separate(db_engine, company_id): +async def test_unit_fact_and_place_fact_are_separate(db_engine, owner_id): """검증: 같은 key 를 사업장 단위와 객실 단위로 동시에 갖는 경우. 기대결과: 통과 — 부분 인덱스가 unit_id NULL 여부로 갈라져 있다.""" - place_id = await _seed_place(db_engine, company_id) + place_id = await _seed_place(db_engine, owner_id) unit_id = uuid.uuid4() async with db_engine.begin() as conn: await conn.execute( diff --git a/solution/backend/tests/test_faq_api.py b/solution/backend/tests/test_faq_api.py index aa79abb..6adda5c 100644 --- a/solution/backend/tests/test_faq_api.py +++ b/solution/backend/tests/test_faq_api.py @@ -56,14 +56,14 @@ async def test_generated_faq_is_pending_and_not_publishable(auth_headers, client assert (await _list(client, h, pid, publishable_only=True)).get("faqs", []) == [] -async def test_other_company_cannot_read_or_touch_faq(auth_headers, client, db_engine, other_company_id): - """검증: 남의 회사 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. +async def test_other_owner_cannot_read_or_touch_faq(auth_headers, client, db_engine): + """검증: 남의 사용자가 place_id 를 알아내 FAQ 를 조회·전이한다. 기대결과: PLACE_NOT_FOUND — 존재 여부조차 알려주지 않는다.""" h = await auth_headers("u1") pid = await _place(client, h) fid = await _seed_generated_faq(db_engine, pid) - other = await auth_headers("u2", other_company_id) + other = await auth_headers("u2") assert (await _list(client, other, pid))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value body = await _transition(client, other, pid, fid, {"status": FactStatus.VERIFIED.value}) assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_my_sites.py b/solution/backend/tests/test_my_sites.py index 3ad532b..3abae3d 100644 --- a/solution/backend/tests/test_my_sites.py +++ b/solution/backend/tests/test_my_sites.py @@ -3,7 +3,7 @@ 이 경로가 절대 하면 안 되는 것: - 사이트가 아직 없는 사업장을 빼는 것 — 위저드를 걸어오다 만 가게가 목록에서 사라지면 사장님은 그걸 다시 찾을 길이 없다(에디터 주소를 아무도 기억하지 않는다). - - 회사 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. + - 사장님 스코프를 놓치는 것 — 남의 가게가 내 목록에 섞이면 그건 목록이 아니라 사고다. - 단건(GET /v1/place/{id}/site)과 다른 재빌드 판정을 내는 것 — 목록과 에디터가 서로 다른 답을 하면 사장님은 어느 쪽을 믿을지 알 수 없다. """ @@ -53,11 +53,47 @@ async def test_site_row_is_joined_into_the_line(auth_headers, client): assert row["status"] == SiteStatus.DRAFT.value -async def test_other_company_sites_are_not_listed(auth_headers, client, other_company_id): - """검증: 회사(테넌트) 스코프. 남의 회사 사업장은 보이지 않는다. +async def test_row_carries_what_the_card_draws(auth_headers, client, db_engine): + """검증: 목록 줄이 카드가 그릴 값을 다 들고 온다 — 주소·생성일·썸네일. + + ★ 왜 이걸 본다: 같은 상호로 만든 사업장이 여러 줄일 때(실측: 한 계정에 '버터브루' 4줄) + 이름과 상태 배지만으로는 어느 게 어느 건지 가릴 수 없다. 가르는 값은 주소와 시각이고, + **한 번이라도 발행한 줄은 그림**이다.""" + h = await auth_headers("my2b") + pid = await _place(client, h, "카드펜션") + async with db_engine.begin() as conn: + await conn.execute( + text("UPDATE places SET road_address = :addr WHERE place_id = :pid"), + {"addr": "강원특별자치도 양양군 현북면 하조대3길 12", "pid": uuid.UUID(pid)}, + ) + await conn.execute( + text("INSERT INTO sites (site_id, place_id, domain, status, thumbnail_url) " + "VALUES (:s, :p, :d, :st, :t)"), + {"s": uuid.uuid4(), "p": uuid.UUID(pid), "d": "card-stay", + "st": SiteStatus.PUBLISHED.value, "t": "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2"}, + ) + + row = (await _list(client, h))["sites"][0] + assert row["road_address"] == "강원특별자치도 양양군 현북면 하조대3길 12" + assert row["created_at"] + assert row["thumbnail_url"] == "https://w4ai.o2o.kr/thumbs/card-stay.jpg?v=2" + + +async def test_row_without_a_site_has_no_thumbnail(auth_headers, client): + """검증: 아직 발행 안 한 줄은 그림이 없다(키 자체가 없다). + 기대결과: 화면이 '그림 없음' 자리를 그릴 근거가 된다 — 빈 문자열로 오면 깨진 이미지가 뜬다.""" + h = await auth_headers("my2c") + await _place(client, h, "그림없는펜션") + + row = (await _list(client, h))["sites"][0] + assert "thumbnail_url" not in row + + +async def test_other_owners_sites_are_not_listed(auth_headers, client): + """검증: 사장님 스코프. 남의 사업장은 보이지 않는다. 기대결과: 각자 자기 것만 1건.""" mine = await auth_headers("my3") - theirs = await auth_headers("my3b", other_company_id) + theirs = await auth_headers("my3b") await _place(client, mine, "내펜션") await _place(client, theirs, "남의펜션") diff --git a/solution/backend/tests/test_place.py b/solution/backend/tests/test_place.py index 8b74e4d..9c080ed 100644 --- a/solution/backend/tests/test_place.py +++ b/solution/backend/tests/test_place.py @@ -1,8 +1,8 @@ -"""places 도메인 e2e — 등록 / 동일 업소 검증 / 회사 스코프 / 채널 URL 확정 게이트. +"""places 도메인 e2e — 등록 / 동일 업소 검증 / 사장님 스코프 / 채널 URL 확정 게이트. ★ 이 도메인의 핵심 규칙 두 개를 고정한다: 1. 검증(verify) 전에는 채널 URL 을 확정할 수 없다 → 크롤링이 안 열린다 - 2. 남의 회사 사업장은 '없음'으로 보인다 + 2. 남의 사업장은 '없음'으로 보인다 """ import uuid @@ -61,7 +61,7 @@ async def test_verify_place_opens_collection(auth_headers, client): async def test_duplicate_kakao_place_is_allowed(auth_headers, client): - """검증: 같은 회사에서 같은 카카오 장소를 두 사업장에 붙인다. + """검증: 같은 사장님이 같은 카카오 장소를 두 사업장에 붙인다. 기대결과: 둘 다 등록된다 — 한 사용자가 같은 실제 업장으로 여러 프로젝트를 만들 수 있다.""" h = await auth_headers("u1") first = (await _create_place(client, h, "A펜션"))["place"]["place_id"] @@ -138,13 +138,13 @@ async def test_verify_without_any_identifier_is_rejected(auth_headers, client): assert r.json()["result"]["code"] == ErrorType.PLACE_VERIFY_NO_CANDIDATE.value -async def test_place_is_scoped_to_company(auth_headers, client, other_company_id): - """검증: 다른 회사 계정으로 남의 사업장을 조회한다. +async def test_place_is_scoped_to_owner(auth_headers, client): + """검증: 다른 사장님 계정으로 남의 사업장을 조회한다. 기대결과: PLACE_NOT_FOUND — 존재 자체가 보이지 않는다(IDOR 차단).""" h1 = await auth_headers("owner1") pid = (await _create_place(client, h1))["place"]["place_id"] - h2 = await auth_headers("owner2", other_company_id) + h2 = await auth_headers("owner2") r = await client.get(f"/v1/place/{pid}", headers=h2) assert r.json()["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_place_search.py b/solution/backend/tests/test_place_search.py index 5e140f7..fb98581 100644 --- a/solution/backend/tests/test_place_search.py +++ b/solution/backend/tests/test_place_search.py @@ -80,7 +80,7 @@ async def test_search_leaks_nothing_of_ours(client, monkeypatch): item = (await client.get("/v1/place/search", params={"q": "하조대펜션"})).json()["items"][0] - for leaked in ("place_id", "company_id", "owner_user_id", "phone", "latitude", "longitude", + for leaked in ("place_id", "owner_user_id", "phone", "latitude", "longitude", "external_place_id", "address"): assert leaked not in item, f"{leaked} 가 공개 응답에 나갔다" diff --git a/solution/backend/tests/test_seo_audit.py b/solution/backend/tests/test_seo_audit.py index d880ebc..512a211 100644 --- a/solution/backend/tests/test_seo_audit.py +++ b/solution/backend/tests/test_seo_audit.py @@ -31,7 +31,7 @@ def test_empty_site_returns_actionable_failures(): assert any(c["status"] == "fail" and c["recommendation"] for c in report["checks"]) -async def test_audit_api_is_company_scoped(auth_headers, client, other_company_id): +async def test_audit_api_is_owner_scoped(auth_headers, client): h1 = await auth_headers("audit-owner") pid = (await client.post("/v1/place", headers=h1, json={"name": "진단가게", "category": 2})).json()["place"]["place_id"] @@ -41,6 +41,6 @@ async def test_audit_api_is_company_scoped(auth_headers, client, other_company_i assert 0 <= own["aeo_score"] <= 100 assert own["checks"] - h2 = await auth_headers("audit-other", other_company_id) + h2 = await auth_headers("audit-other") denied = (await client.get(f"/v1/place/{pid}/site/audit", headers=h2)).json() assert denied["result"]["success"] is False diff --git a/solution/backend/tests/test_showcase_api.py b/solution/backend/tests/test_showcase_api.py index e3f7c9b..331240b 100644 --- a/solution/backend/tests/test_showcase_api.py +++ b/solution/backend/tests/test_showcase_api.py @@ -13,14 +13,14 @@ from sqlalchemy import text from common.enums import PlaceCategory, PlaceStatus, SiteStatus -async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, minutes_ago=0): +async def _publish(db_engine, owner_id, name, *, status, domain, thumb=None, minutes_ago=0): """places + sites 를 직접 넣는다 — 여기서 보는 건 목록 조회지 빌드 파이프라인이 아니다.""" pid, sid = uuid.uuid4(), uuid.uuid4() async with db_engine.begin() as c: await c.execute( - text("INSERT INTO places (place_id, company_id, name, category, status, road_address, address, phone) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, address, phone) " "VALUES (:p,:c,:n,:cat,:st,:road,:addr,:phone)"), - {"p": pid, "c": uuid.UUID(company_id), "n": name, "cat": PlaceCategory.LODGING.value, + {"p": pid, "c": uuid.UUID(owner_id), "n": name, "cat": PlaceCategory.LODGING.value, "st": PlaceStatus.PUBLISHED.value, "road": "강원특별자치도 양양군 현북면 하조대3길 12-3", "addr": "강원특별자치도 양양군 현북면 하광정리 1-2", "phone": "033-672-0000"}, @@ -34,12 +34,12 @@ async def _publish(db_engine, company_id, name, *, status, domain, thumb=None, m return str(pid) -async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, company_id): +async def test_발행된_사이트만_로그인_없이_보인다(client, db_engine, owner_id): """검증: 발행본 1개 + 미발행(DRAFT) 1개를 두고 인증 헤더 없이 부른다. 기대결과: 발행본만 나온다.""" - await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, + await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae", thumb="https://w4ai.o2o.kr/thumbs/hajodae.jpg") - await _publish(db_engine, company_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") + await _publish(db_engine, owner_id, "아직펜션", status=SiteStatus.DRAFT.value, domain="notyet") res = await client.get("/v1/showcase") @@ -51,10 +51,10 @@ async def test_발행된_사이트만_로그인_없이_보인다(client, db_engi assert items[0]["category"] == PlaceCategory.LODGING.value -async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, company_id): +async def test_개인정보와_내부값은_나가지_않는다(client, db_engine, owner_id): """검증: 응답 항목의 키를 그대로 본다. 기대결과: 상호명·업종·지역·주소·썸네일뿐. 지역은 시·군까지고 상세 주소는 없다.""" - await _publish(db_engine, company_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") + await _publish(db_engine, owner_id, "하조대펜션", status=SiteStatus.PUBLISHED.value, domain="hajodae") item = (await client.get("/v1/showcase")).json()["items"][0] @@ -66,10 +66,10 @@ async def test_개인정보와_내부값은_나가지_않는다(client, db_engin assert "하조대3길" not in body -async def test_썸네일이_없으면_키가_없다(client, db_engine, company_id): +async def test_썸네일이_없으면_키가_없다(client, db_engine, owner_id): """★ 썸네일은 발행의 부수 효과라 실패할 수 있다(대표 사진이 없거나 CDN 이 죽었거나). 그때 카드는 글자로 떨어져야지 목록에서 사라지면 안 된다.""" - await _publish(db_engine, company_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") + await _publish(db_engine, owner_id, "그림없는집", status=SiteStatus.PUBLISHED.value, domain="nopic") item = (await client.get("/v1/showcase")).json()["items"][0] @@ -77,9 +77,9 @@ async def test_썸네일이_없으면_키가_없다(client, db_engine, company_i assert "thumbnail_url" not in item -async def test_최신_발행순이고_limit_로_자른다(client, db_engine, company_id): - await _publish(db_engine, company_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) - await _publish(db_engine, company_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) +async def test_최신_발행순이고_limit_로_자른다(client, db_engine, owner_id): + await _publish(db_engine, owner_id, "먼저", status=SiteStatus.PUBLISHED.value, domain="first", minutes_ago=60) + await _publish(db_engine, owner_id, "나중", status=SiteStatus.PUBLISHED.value, domain="second", minutes_ago=1) items = (await client.get("/v1/showcase")).json()["items"] assert [i["name"] for i in items] == ["나중", "먼저"] diff --git a/solution/backend/tests/test_site_slug.py b/solution/backend/tests/test_site_slug.py index 0c78dd5..a589d0b 100644 --- a/solution/backend/tests/test_site_slug.py +++ b/solution/backend/tests/test_site_slug.py @@ -129,11 +129,11 @@ async def test_published_site_slug_is_locked(auth_headers, client, db_engine): assert same["site"]["domain"] == "published-stay" -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장 주소는 확인도 예약도 못 한다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장 주소는 확인도 예약도 못 한다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("slug5") - intruder = await auth_headers("slug6", other_company_id) + intruder = await auth_headers("slug6") pid = await _place(client, h) assert (await _check(client, intruder, pid, "doflo"))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_site_template.py b/solution/backend/tests/test_site_template.py index 2e87685..fa643e8 100644 --- a/solution/backend/tests/test_site_template.py +++ b/solution/backend/tests/test_site_template.py @@ -99,11 +99,11 @@ async def test_published_site_template_is_not_locked(auth_headers, client, db_en assert changed["needs_rebuild"] is True -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장의 템플릿은 바꿀 수 없다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장의 템플릿은 바꿀 수 없다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("tpl6") - intruder = await auth_headers("tpl7", other_company_id) + intruder = await auth_headers("tpl7") pid = await _place(client, h) blocked = await _set_template(client, intruder, pid, "stay-quiet-margin") diff --git a/solution/backend/tests/test_site_theme.py b/solution/backend/tests/test_site_theme.py index 3b6c811..a47a12d 100644 --- a/solution/backend/tests/test_site_theme.py +++ b/solution/backend/tests/test_site_theme.py @@ -129,11 +129,11 @@ async def test_published_site_theme_is_not_locked(auth_headers, client, db_engin assert changed["needs_rebuild"] is True -async def test_other_company_place_is_blocked(auth_headers, client, other_company_id): - """검증: 남의 회사 사업장의 디자인은 바꿀 수 없다. +async def test_other_owners_place_is_blocked(auth_headers, client): + """검증: 남의 사업장의 디자인은 바꿀 수 없다. 기대결과: PLACE_NOT_FOUND(존재 여부조차 알려주지 않는다).""" h = await auth_headers("thm7") - intruder = await auth_headers("thm8", other_company_id) + intruder = await auth_headers("thm8") pid = await _place(client, h) assert (await _set_theme(client, intruder, pid, _THEME))["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/backend/tests/test_site_thumbnail.py b/solution/backend/tests/test_site_thumbnail.py index 44b2808..3d81522 100644 --- a/solution/backend/tests/test_site_thumbnail.py +++ b/solution/backend/tests/test_site_thumbnail.py @@ -110,9 +110,11 @@ async def test_사이트_디렉터리_밖의_thumbs_에_올린다(blob, monkeypa _transport(monkeypatch, lambda req: httpx.Response(200, headers={"content-type": "image/jpeg"}, content=b"jpegbytes")) snapshot = _snapshot({"media_id": "m1", "url": "https://cdn.test/front.jpg", "unit_id": None}) - url = await site_thumbnail.store("butter", snapshot) + url = await site_thumbnail.store("butter", snapshot, 3) - assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg" + # ★ `?v=` 는 캐시 무효화다. 블롭 이름은 그대로 덮어쓰므로 주소가 안 변하면 + # 브라우저·CDN 이 지난 발행의 그림을 계속 보여준다(site_thumbnail.public_url). + assert url == "https://w4ai.o2o.kr/thumbs/butter.jpg?v=3" name = f"{azure_static.DEFAULT_PREFIX}/thumbs/butter.jpg" assert set(blob.uploads) == {name} assert not name.startswith(f"{azure_static.DEFAULT_PREFIX}/{azure_static.SITE_ROOT_DIR}/") @@ -131,8 +133,8 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): - return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg" + async def _store(slug, snapshot, version=None): + return f"https://w4ai.o2o.kr/thumbs/{slug}.jpg?v={version}" monkeypatch.setattr(build_service.site_thumbnail, "store", _store) @@ -146,6 +148,15 @@ async def test_발행하면_사이트에_썸네일_주소가_남는다(auth_head site = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] assert site["thumbnail_url"].startswith("https://w4ai.o2o.kr/thumbs/") + assert site["thumbnail_url"].endswith("?v=1") + + # ★ 재발행하면 주소가 바뀌어야 한다. 블롭 이름은 그대로 덮어쓰므로, 주소가 그대로면 + # 사장님은 사진을 바꾸고 다시 발행해도 캐시에 남은 **지난 그림**을 계속 본다. + await client.post(f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}) + await _run() + again = (await client.get(f"/v1/place/{pid}/site", headers=h)).json()["site"] + assert again["thumbnail_url"].endswith("?v=2") + assert again["thumbnail_url"] != site["thumbnail_url"] async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers, client, db_engine, monkeypatch): @@ -154,7 +165,7 @@ async def test_썸네일을_못_만들어도_발행은_성공한다(auth_headers from services import build_service from tests.test_build_publish import _approved_media, _place, _run, _verified_facts - async def _store(slug, snapshot): + async def _store(slug, snapshot, version=None): return None monkeypatch.setattr(build_service.site_thumbnail, "store", _store) diff --git a/solution/backend/tests/test_snapshot.py b/solution/backend/tests/test_snapshot.py index e8d1d41..38ca3e5 100644 --- a/solution/backend/tests/test_snapshot.py +++ b/solution/backend/tests/test_snapshot.py @@ -10,13 +10,13 @@ from common.enums import FactStatus, MediaStatus, PlaceCategory, SourceType from services.snapshot import build_snapshot -async def _seed(db_engine, company_id, category=PlaceCategory.LODGING): +async def _seed(db_engine, owner_id, category=PlaceCategory.LODGING): pid = uuid.uuid4() async with db_engine.begin() as c: await c.execute( - text("INSERT INTO places (place_id, company_id, name, category, status, road_address, phone, verified_at) " + text("INSERT INTO places (place_id, owner_user_id, name, category, status, road_address, phone, verified_at) " "VALUES (:p,:c,:n,:cat,3,:addr,:tel,now())"), - {"p": pid, "c": uuid.UUID(company_id), "n": "스냅샷펜션", "cat": category.value, + {"p": pid, "c": uuid.UUID(owner_id), "n": "스냅샷펜션", "cat": category.value, "addr": "강원특별자치도 양양군 현북면 하조대3길 11", "tel": "033-000-0000"}, ) return pid @@ -53,10 +53,10 @@ class _Place: self.longitude = None -async def test_only_publishable_facts_enter_snapshot(db_engine, company_id): +async def test_only_publishable_facts_enter_snapshot(db_engine, owner_id): """검증: 여러 상태의 fact 를 섞어 넣는다. 기대결과: ★ VERIFIED·CORRECTED 만 스냅샷에 담긴다 — 미검증 값이 사이트로 새지 않는다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) await _fact(db_engine, pid, "wifi", "true", FactStatus.CORRECTED) await _fact(db_engine, pid, "parking", "true", FactStatus.UNVERIFIED) @@ -69,10 +69,10 @@ async def test_only_publishable_facts_enter_snapshot(db_engine, company_id): assert keys == {"check_in_time", "wifi"} -async def test_only_approved_media_enters_snapshot(db_engine, company_id): +async def test_only_approved_media_enters_snapshot(db_engine, owner_id): """검증: 승인/확인대기/반려 사진을 섞어 넣는다. 기대결과: ★ APPROVED 만 담긴다 — Vision 신뢰도가 낮아 확인 큐에 남은 사진은 안 나간다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/ok.jpg", MediaStatus.APPROVED) await _media(db_engine, pid, "https://cdn.test/pending.jpg", MediaStatus.PENDING_REVIEW) await _media(db_engine, pid, "https://cdn.test/no.jpg", MediaStatus.REJECTED) @@ -81,10 +81,10 @@ async def test_only_approved_media_enters_snapshot(db_engine, company_id): assert [m["url"] for m in snap["media"]] == ["https://cdn.test/ok.jpg"] -async def test_media_without_alt_is_excluded(db_engine, company_id): +async def test_media_without_alt_is_excluded(db_engine, owner_id): """검증: 승인됐지만 alt 텍스트가 없는 사진. 기대결과: 빠진다 — alt 없는 이미지는 접근성도 AI 검색 신호도 없다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _media(db_engine, pid, "https://cdn.test/noalt.jpg", MediaStatus.APPROVED, alt="") await _media(db_engine, pid, "https://cdn.test/withalt.jpg", MediaStatus.APPROVED, alt="침실 사진") @@ -92,10 +92,10 @@ async def test_media_without_alt_is_excluded(db_engine, company_id): assert [m["url"] for m in snap["media"]] == ["https://cdn.test/withalt.jpg"] -async def test_fact_labels_come_from_category_schema(db_engine, company_id): +async def test_fact_labels_come_from_category_schema(db_engine, owner_id): """검증: 스냅샷의 fact 라벨. 기대결과: 업종 스키마의 한글 라벨이 붙는다 — 화면이 key 를 그대로 노출하지 않게.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _fact(db_engine, pid, "check_in_time", "15:00", FactStatus.VERIFIED) snap = await build_snapshot(_Place(pid)) @@ -104,10 +104,10 @@ async def test_fact_labels_come_from_category_schema(db_engine, company_id): assert f["scope"] == "place" -async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id): +async def test_unit_scoped_facts_carry_unit_id(db_engine, owner_id): """검증: 객실 단위 fact. 기대결과: unit_id 가 실려 빌더가 객실별로 묶을 수 있다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) uid = uuid.uuid4() async with db_engine.begin() as c: await c.execute(text("INSERT INTO units (unit_id, place_id, name, sort_order) VALUES (:u,:p,:n,0)"), @@ -120,10 +120,10 @@ async def test_unit_scoped_facts_carry_unit_id(db_engine, company_id): assert snap["facts"][0]["scope"] == "unit" -async def test_empty_place_gives_empty_snapshot(db_engine, company_id): +async def test_empty_place_gives_empty_snapshot(db_engine, owner_id): """검증: 아무것도 없는 사업장. 기대결과: 빈 스냅샷 — 게이트가 고유 콘텐츠 0건으로 거부할 재료가 된다.""" - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) snap = await build_snapshot(_Place(pid)) assert snap["facts"] == [] and snap["media"] == [] and snap["faqs"] == [] assert snap["place"]["name"] == "스냅샷펜션" @@ -156,13 +156,13 @@ class _RegionPlace(_Place): self.region_code = region_code -async def test_only_published_local_content_enters_snapshot(db_engine, company_id): +async def test_only_published_local_content_enters_snapshot(db_engine, owner_id): """검증: 검수대기·종료·발행 지역 정보를 섞어 넣는다. 기대결과: ★ PUBLISHED 만 담긴다 — 운영자가 검수하지 않은 외부 API 원문이 사이트로 새면 '미검증 값 노출 금지'가 깨진다(fact 를 VERIFIED 로 거르는 것과 같은 규칙).""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "발행축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.REVIEW, "검수대기축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.ENDED, "종료축제") @@ -171,7 +171,7 @@ async def test_only_published_local_content_enters_snapshot(db_engine, company_i assert [c["title"] for c in snap["local"]["contents"]] == ["발행축제"] -async def test_local_content_outside_display_window_is_excluded(db_engine, company_id): +async def test_local_content_outside_display_window_is_excluded(db_engine, owner_id): """검증: 발행됐지만 노출 기간을 벗어난 지역 정보. 기대결과: 빠진다 — 끝난 축제를 '이번 주말 행사'로 걸어두는 것도 틀린 정보다.""" from datetime import datetime, timedelta, timezone @@ -179,7 +179,7 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa from common.enums import LocalContentStatus, LocalContentType now = datetime.now(timezone.utc) - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "지금축제") await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "끝난축제", display_end_at=now - timedelta(days=1)) @@ -190,12 +190,12 @@ async def test_local_content_outside_display_window_is_excluded(db_engine, compa assert [c["title"] for c in snap["local"]["contents"]] == ["지금축제"] -async def test_local_content_is_scoped_to_the_places_region(db_engine, company_id): +async def test_local_content_is_scoped_to_the_places_region(db_engine, owner_id): """검증: 지역 캐시는 region_code 로 묶인다. 기대결과: 다른 지역의 발행 콘텐츠는 담기지 않는다.""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "우리지역축제") await _local(db_engine, "5011025", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "남의지역축제") @@ -203,7 +203,7 @@ async def test_local_content_is_scoped_to_the_places_region(db_engine, company_i assert [c["title"] for c in snap["local"]["contents"]] == ["우리지역축제"] -async def test_region_code_is_derived_from_the_address_when_missing(db_engine, company_id): +async def test_region_code_is_derived_from_the_address_when_missing(db_engine, owner_id): """검증: region_code 가 비어 있지만 도로명주소는 있는 사업장. 기대결과: 주소에서 지역 키를 유도해 그 지역 콘텐츠를 담는다 — places.region_code 를 채우는 코드가 생기기 전에 만들어진 사업장(실측 28곳 중 25곳)이 영영 지역 정보 없이 발행되지 않게 한다.""" @@ -211,7 +211,7 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c from services.external.naver import region_key derived = region_key(_Place("x").road_address) - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, derived, LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "양양축제") snap = await build_snapshot(_RegionPlace(pid, "")) @@ -219,12 +219,12 @@ async def test_region_code_is_derived_from_the_address_when_missing(db_engine, c assert [c["title"] for c in snap["local"]["contents"]] == ["양양축제"] -async def test_place_without_any_region_key_gets_no_local_content(db_engine, company_id): +async def test_place_without_any_region_key_gets_no_local_content(db_engine, owner_id): """검증: 지역 코드도 읽을 만한 주소도 없는 사업장. 기대결과: 빈 목록 — 조회할 캐시 키가 없다. 지어내지 않는다.""" from common.enums import LocalContentStatus, LocalContentType - pid = await _seed(db_engine, company_id) + pid = await _seed(db_engine, owner_id) await _local(db_engine, "4113500", LocalContentType.FESTIVAL, LocalContentStatus.PUBLISHED, "어딘가축제") place = _RegionPlace(pid, "") diff --git a/solution/backend/tests/test_verify_candidates.py b/solution/backend/tests/test_verify_candidates.py index 5a3e45e..275236b 100644 --- a/solution/backend/tests/test_verify_candidates.py +++ b/solution/backend/tests/test_verify_candidates.py @@ -136,13 +136,13 @@ async def test_candidates_require_configured_source(auth_headers, client, monkey assert body["result"]["code"] == ErrorType.LOCAL_NOT_CONFIGURED.value -async def test_candidates_scoped_to_company(auth_headers, client, other_company_id, monkeypatch): - """검증: 다른 회사 계정으로 남의 사업장 후보를 조회한다. +async def test_candidates_scoped_to_owner(auth_headers, client, monkeypatch): + """검증: 다른 사장님 계정으로 남의 사업장 후보를 조회한다. 기대결과: PLACE_NOT_FOUND.""" _patch_naver(monkeypatch, _match(kakao_client.MatchOutcome.MATCHED, _np("a", "b"), [], "x")) h1 = await auth_headers("o1") pid = await _place(client, h1) - h2 = await auth_headers("o2", other_company_id) + h2 = await auth_headers("o2") body = (await client.get(f"/v1/place/{pid}/verify/candidates", headers=h2)).json() assert body["result"]["code"] == ErrorType.PLACE_NOT_FOUND.value diff --git a/solution/frontend/index.html b/solution/frontend/index.html deleted file mode 100644 index f5ab125..0000000 --- a/solution/frontend/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - Web4Ai · AI 웹 빌더 - - -
- - - diff --git a/solution/frontend/package.json b/solution/frontend/package.json index 0ffa08b..01b234a 100644 --- a/solution/frontend/package.json +++ b/solution/frontend/package.json @@ -4,11 +4,11 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite --port=3000 --host=0.0.0.0", - "build": "tsc --noEmit && eslint src && vite build", + "dev": "react-router dev --port=3000 --host=0.0.0.0", + "build": "react-router typegen && tsc --noEmit && eslint src && react-router build", "preview": "vite preview", - "clean": "rm -rf dist", - "lint": "tsc --noEmit && eslint src", + "clean": "rm -rf build .react-router", + "lint": "react-router typegen && tsc --noEmit && eslint src", "orval": "orval --config ./orval.config.ts" }, "dependencies": { @@ -17,10 +17,12 @@ "@dnd-kit/utilities": "^3.2.2", "@hookform/resolvers": "^5.4.0", "@o2o/shared": "*", + "@react-router/node": "^7.18.3", "@tailwindcss/vite": "^4.1.14", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "isbot": "^5", "lucide-react": "^0.546.0", "motion": "^12.23.24", "react": "^19.0.1", @@ -34,6 +36,7 @@ "zustand": "^5.0.14" }, "devDependencies": { + "@react-router/dev": "^7.18.3", "@types/node": "^22.14.0", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", diff --git a/solution/frontend/react-router.config.ts b/solution/frontend/react-router.config.ts new file mode 100644 index 0000000..0273023 --- /dev/null +++ b/solution/frontend/react-router.config.ts @@ -0,0 +1,24 @@ +import type {Config} from '@react-router/dev/config'; + +/** + * 프리렌더 설정. + * + * ★ 왜 하나 — 랜딩은 `
` 만 내보내는 CSR 이었다. 실측(2026-09-07): + * `curl /` 는 3,021바이트에 `` 0개·본문 0자. 같은 호스트의 발행본은 48,072바이트다. + * 구글은 JS 를 실행하지만 **렌더링 큐가 따로** 돌고 신규 도메인은 뒤로 밀린다 — + * 그동안 색인에는 "제목만 있고 내용 없는 페이지"로 들어가 있다. + * + * ★ `ssr: false` 와 함께 쓴다. 런타임 Node 서버를 두지 않는다는 뜻이다 — + * 아래 목록만 HTML 로 굽고, 나머지(`/builder` `/login` `/sites` `/account`)는 + * 지금까지처럼 SPA 폴백으로 나간다. nginx 설정도 배포 구조도 그대로다. + * + * ★ 로그인 뒤에만 의미가 있는 화면은 굽지 않는다. 구울 내용이 없고(데이터가 사용자별), + * robots.txt 로 막을 대상이기도 하다. + */ +export default { + // 이 레포는 소스가 `src/` 밑이다(기본값 `app/` 이 아니다). + // root.tsx · routes.ts 를 여기서 찾는다. + appDirectory: 'src', + ssr: false, + prerender: ['/', '/pricing', '/showcase'], +} satisfies Config; diff --git a/solution/frontend/src/api/generated/model/companyData.ts b/solution/frontend/src/api/generated/model/companyData.ts deleted file mode 100644 index 1229a4c..0000000 --- a/solution/frontend/src/api/generated/model/companyData.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ - -export interface CompanyData { - company_id?: string; - name?: string; -} diff --git a/solution/frontend/src/api/generated/model/index.ts b/solution/frontend/src/api/generated/model/index.ts index d616f6f..2e38a7b 100644 --- a/solution/frontend/src/api/generated/model/index.ts +++ b/solution/frontend/src/api/generated/model/index.ts @@ -10,7 +10,6 @@ export * from './auditCheckDataRecommendation'; export * from './authProvider'; export * from './buildStatus'; export * from './checkSlugParams'; -export * from './companyData'; export * from './errorInfo'; export * from './errorInfoCode'; export * from './errorInfoDesc'; @@ -83,6 +82,7 @@ export * from './mySiteData'; export * from './mySiteDataCreatedAt'; export * from './mySiteDataDomain'; export * from './mySiteDataPublishedAt'; +export * from './mySiteDataThumbnailUrl'; export * from './mySiteDataRoadAddress'; export * from './mySiteDataSiteId'; export * from './mySiteDataStatus'; @@ -139,7 +139,6 @@ export * from './reqGoogleLogin'; export * from './reqLogin'; export * from './reqPublishLocalContent'; export * from './reqSignup'; -export * from './reqSignupCompanyName'; export * from './reqSignupName'; export * from './reqSiteSlug'; export * from './reqSiteStatus'; @@ -222,7 +221,6 @@ export * from './resLocalContentListMsg'; export * from './resLogin'; export * from './resLoginMsg'; export * from './resMe'; -export * from './resMeCompany'; export * from './resMeContactNumber'; export * from './resMeEmail'; export * from './resMeMsg'; diff --git a/solution/frontend/src/api/generated/model/mySiteData.ts b/solution/frontend/src/api/generated/model/mySiteData.ts index cb463b1..b3ef1d2 100644 --- a/solution/frontend/src/api/generated/model/mySiteData.ts +++ b/solution/frontend/src/api/generated/model/mySiteData.ts @@ -13,6 +13,7 @@ import type { MySiteDataStatus } from './mySiteDataStatus'; import type { MySiteDataDomain } from './mySiteDataDomain'; import type { MySiteDataTemplateId } from './mySiteDataTemplateId'; import type { MySiteDataPublishedAt } from './mySiteDataPublishedAt'; +import type { MySiteDataThumbnailUrl } from './mySiteDataThumbnailUrl'; /** * 내 사이트 목록의 한 줄 — 사업장(place) + 사이트(site). @@ -32,5 +33,7 @@ export interface MySiteData { domain?: MySiteDataDomain; template_id?: MySiteDataTemplateId; published_at?: MySiteDataPublishedAt; + /** 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url). */ + thumbnail_url?: MySiteDataThumbnailUrl; needs_rebuild?: boolean; } diff --git a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts similarity index 68% rename from solution/frontend/src/api/generated/model/reqSignupCompanyName.ts rename to solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts index 5b6614a..16daca9 100644 --- a/solution/frontend/src/api/generated/model/reqSignupCompanyName.ts +++ b/solution/frontend/src/api/generated/model/mySiteDataThumbnailUrl.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ReqSignupCompanyName = string | null; +export type MySiteDataThumbnailUrl = string | null; diff --git a/solution/frontend/src/api/generated/model/placeSearchItem.ts b/solution/frontend/src/api/generated/model/placeSearchItem.ts index 92eac8d..ee94833 100644 --- a/solution/frontend/src/api/generated/model/placeSearchItem.ts +++ b/solution/frontend/src/api/generated/model/placeSearchItem.ts @@ -11,7 +11,7 @@ import type { PlaceSearchItemCategory } from './placeSearchItemCategory'; /** * 공개 검색 결과 1건. -★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·company_id·소유자)은 +★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은 하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다. ★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고, 확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다. diff --git a/solution/frontend/src/api/generated/model/reqSignup.ts b/solution/frontend/src/api/generated/model/reqSignup.ts index 61d3a32..ca5de09 100644 --- a/solution/frontend/src/api/generated/model/reqSignup.ts +++ b/solution/frontend/src/api/generated/model/reqSignup.ts @@ -5,10 +5,9 @@ * OpenAPI spec version: 0.1.0 */ import type { ReqSignupName } from './reqSignupName'; -import type { ReqSignupCompanyName } from './reqSignupCompanyName'; /** - * id/pw 가입. 가입 = 새 테넌트(회사) 1개 + 그 회사의 첫 계정 1개다. + * id/pw 가입. 가입 = 계정 1개다. ★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면 한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라 @@ -19,5 +18,4 @@ export interface ReqSignup { password?: string; name?: ReqSignupName; email?: string; - company_name?: ReqSignupCompanyName; } diff --git a/solution/frontend/src/api/generated/model/resMe.ts b/solution/frontend/src/api/generated/model/resMe.ts index 84fa745..2441fe4 100644 --- a/solution/frontend/src/api/generated/model/resMe.ts +++ b/solution/frontend/src/api/generated/model/resMe.ts @@ -11,7 +11,6 @@ import type { ResMeEmail } from './resMeEmail'; import type { ResMeContactNumber } from './resMeContactNumber'; import type { UserRole } from './userRole'; import type { AuthProvider } from './authProvider'; -import type { ResMeCompany } from './resMeCompany'; export interface ResMe { result?: ErrorInfo; @@ -23,5 +22,4 @@ export interface ResMe { contact_number?: ResMeContactNumber; role?: UserRole; provider?: AuthProvider; - company?: ResMeCompany; } diff --git a/solution/frontend/src/api/generated/model/resMeCompany.ts b/solution/frontend/src/api/generated/model/resMeCompany.ts deleted file mode 100644 index 4a08cff..0000000 --- a/solution/frontend/src/api/generated/model/resMeCompany.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Generated by orval v7.21.0 🍺 - * Do not edit manually. - * Web4Ai API - * OpenAPI spec version: 0.1.0 - */ -import type { CompanyData } from './companyData'; - -export type ResMeCompany = CompanyData | null; diff --git a/solution/frontend/src/api/generated/model/showcaseItem.ts b/solution/frontend/src/api/generated/model/showcaseItem.ts index 5d3d0a7..b6374d3 100644 --- a/solution/frontend/src/api/generated/model/showcaseItem.ts +++ b/solution/frontend/src/api/generated/model/showcaseItem.ts @@ -12,7 +12,7 @@ import type { ShowcaseItemThumbnailUrl } from './showcaseItemThumbnailUrl'; * 랜딩 쇼케이스 카드 한 장. **로그인 없이 나가는 값이다.** ★ 여기 있는 것은 전부 이미 발행된 페이지에 적혀 있는 것뿐이다. - place_id·company_id·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 + place_id·소유자·전화번호·상세 주소는 절대 싣지 않는다 — 사이트 한 곳을 여는 것과 발행 업소 명단을 통째로 긁는 것은 다른 일이다. 지역도 시·군·구까지만 준다. */ export interface ShowcaseItem { diff --git a/solution/frontend/src/app/main.tsx b/solution/frontend/src/app/main.tsx deleted file mode 100644 index 867493b..0000000 --- a/solution/frontend/src/app/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import {StrictMode} from 'react'; -import {createRoot} from 'react-dom/client'; -import {RouterProvider} from 'react-router'; -import {Providers} from './provider'; -import {router} from './router'; -import '../index.css'; - -createRoot(document.getElementById('root')!).render( - - - - - , -); diff --git a/solution/frontend/src/app/router.tsx b/solution/frontend/src/app/router.tsx deleted file mode 100644 index f8f6066..0000000 --- a/solution/frontend/src/app/router.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import {createBrowserRouter, Navigate} from 'react-router'; -import {Loader2} from 'lucide-react'; -import {AccountPage} from '@/pages/AccountPage'; -import {BuilderPage} from '@/pages/BuilderPage'; -import {DevShowcasePage} from '@/pages/DevShowcasePage'; -import {LandingPage} from '@/pages/LandingPage'; -import {LoginPage} from '@/pages/LoginPage'; -import {NotFoundPage} from '@/pages/NotFoundPage'; -import {PricingPage} from '@/pages/PricingPage'; -import {ShowcasePage} from '@/pages/ShowcasePage'; -import {SignupPage} from '@/pages/SignupPage'; -import {SitesPage} from '@/pages/SitesPage'; -import {RequireAuth} from '@/components/layout/RequireAuth'; -import {useAuthStore} from '@/stores/auth'; - -/** - * 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라 - * "내 것 고치기"다. 비로그인은 **랜딩**을 본다. - * - * ★ 예전엔 비로그인을 곧장 위저드로 보냈다. 그러면 이 제품이 무엇을 파는 물건인지 말할 자리가 - * 한 곳도 없다 — 처음 온 사람이 업종 선택 화면부터 만난다. - * ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 화면이 한 번 번쩍이고 목록으로 튄다. - */ -function Home() { - const isRestoring = useAuthStore((s) => s.isRestoring); - const user = useAuthStore((s) => s.user); - - if (isRestoring) { - return ( -
- -
- ); - } - if (user) return ; - return ; -} - -export const router = createBrowserRouter([ - {path: '/login', element: }, - // 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다. - {path: '/signup', element: }, - - // 비로그인 = 랜딩, 로그인 = 내 사이트. Home 이 그걸 가른다. - {path: '/', element: }, - - // 로그인 전 화면. ★ 랜딩과 같은 껍데기(MarketingShell)를 쓴다 — 사이드바 없는 문서형이다. - {path: '/pricing', element: }, - {path: '/showcase', element: }, - - // 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다. - { - path: '/sites', - element: ( - - - - ), - }, - { - path: '/account', - element: ( - - - - ), - }, - - /** - * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면 - * 만들어 보기도 전에 막힌다. - * - * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이 - * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다. - * - * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조. - */ - {path: '/builder', element: }, - - /** - * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. - * 라우트 가드는 화면을 가리지 **번들은 못 가린다**: 한 앱이면 내부 라우트 이름과 코드가 - * 사장님 브라우저에 그대로 내려간다. `UserRole.DEVELOPER` 주석의 - * "고객사에 존재를 노출하지 않는다"를 지키려면 번들 자체가 갈라져야 한다. - * (ARCHITECTURE.md 4절) - */ - - // 개발 빌드에서만 열리는 컴포넌트 쇼케이스. 운영 번들에서는 라우트 자체가 없다. - ...(import.meta.env.DEV ? [{path: '/dev/showcase', element: }] : []), - - {path: '*', element: }, -]); diff --git a/solution/frontend/src/components/layout/AppShell.tsx b/solution/frontend/src/components/layout/AppShell.tsx index 990aba0..da25315 100644 --- a/solution/frontend/src/components/layout/AppShell.tsx +++ b/solution/frontend/src/components/layout/AppShell.tsx @@ -1,6 +1,6 @@ import type {ComponentType, ReactNode} from 'react'; import {Link, NavLink, useLocation, useNavigate} from 'react-router'; -import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react'; +import {LayoutGrid, LogIn, LogOut, Receipt, Search, Store, Wand2} from 'lucide-react'; import {cn} from '@/lib/utils'; import {userLabel, useAuthStore} from '@/stores/auth'; @@ -25,6 +25,15 @@ export type NavItem = { const OWNER_NAV: NavItem[] = [ {to: '/sites', match: '/sites', label: '내 사이트', icon: Store}, {to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2}, + /* + * ★ 요금은 **로그인한 뒤에도** 갈 길이 있어야 한다 (2026-09-04, 사장님 지적) + * /pricing 은 살아 있는데 링크가 MarketingShell 과 랜딩에만 있었다. 둘 다 로그인하면 + * 안 보이는 화면이라(로그인하면 / 가 /sites 로 간다), 사장님은 주소를 직접 쳐야 했다. + * 요금은 쓰는 도중에 확인하는 값이지 가입 전에만 보는 값이 아니다. + * ★ 목적지가 MarketingShell(사이드바 없는 문서형)이라 사이드바가 사라진다. 그래도 갇히지 + * 않는다 — 저쪽 헤더가 로그인 상태를 알아보고 [내 사이트] 버튼을 세운다. + */ + {to: '/pricing', match: '/pricing', label: '요금', icon: Receipt}, ]; export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?: NavItem[]}) { @@ -73,7 +82,6 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" > {userLabel(user)} - {user.companyName ? ` · ${user.companyName}` : ''} ) : (
@@ -87,7 +95,8 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav? // 눌러도 아무 일이 없는 것처럼 보인다 — 로그인 화면으로 보낸다. onClick={() => { signOut(); - navigate('/login'); + // ★ 로그인 화면이 아니라 랜딩으로. 나간 사람에게 다시 로그인 폼을 들이밀지 않는다. + navigate('/'); }} className="flex w-full cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60" > diff --git a/solution/frontend/src/components/layout/MarketingShell.tsx b/solution/frontend/src/components/layout/MarketingShell.tsx index e52aa77..2acf72b 100644 --- a/solution/frontend/src/components/layout/MarketingShell.tsx +++ b/solution/frontend/src/components/layout/MarketingShell.tsx @@ -47,6 +47,11 @@ export function MarketingShell({children}: {children: ReactNode}) {
{/* ★ 이미 사이트를 가진 사장님에게 [로그인] 을 다시 보여주지 않는다 — 갈 곳은 내 사이트다. */} + {/* + ★ [무료로 만들기] 를 뺐다 (2026-09-04, 사장님 지시) + 히어로의 큰 입력 카드가 이미 그 자리다 — 시작하는 문이 한 화면에 둘이면 + 어느 쪽이 진짜인지 고르게 만든다. 헤더는 로그인만 받는다. + */} {user ? ( ) : ( - <> - {/* 아임웹처럼 둘 다 버튼이다 — 로그인만 맨 텍스트면 눌리는 것으로 안 보인다. */} - - 로그인 - - - 무료로 만들기 - - + + 로그인 + )}
diff --git a/solution/frontend/src/components/layout/RequireAuthLayout.tsx b/solution/frontend/src/components/layout/RequireAuthLayout.tsx new file mode 100644 index 0000000..75eb24f --- /dev/null +++ b/solution/frontend/src/components/layout/RequireAuthLayout.tsx @@ -0,0 +1,14 @@ +import {Outlet} from 'react-router'; +import {RequireAuth} from './RequireAuth'; + +/** + * 인증이 필요한 라우트들의 부모. 예전 `router.tsx` 가 페이지마다 로 + * 감싸던 것을 레이아웃 라우트 하나로 모았다 — 가드가 한 자리에 있어야 빠뜨리지 않는다. + */ +export default function RequireAuthLayout() { + return ( + + + + ); +} diff --git a/solution/frontend/src/features/builder/CanvasView.tsx b/solution/frontend/src/features/builder/CanvasView.tsx index 4ed1789..a41eee3 100644 --- a/solution/frontend/src/features/builder/CanvasView.tsx +++ b/solution/frontend/src/features/builder/CanvasView.tsx @@ -6,11 +6,14 @@ import {deriveSurfaces} from '@/lib/color'; import {cn} from '@/lib/utils'; import {useBuilderStore, useCurrentTemplate} from '@/stores/builder'; import {resolveVariant} from './canvas/registry'; +import {PUBLISH_HOST} from '@/lib/site'; // ★ 호스트를 상수로 박지 않는다. PublishModal 과 **다른 주소**를 보여주면 사장님은 // 미리보기에서 본 주소와 발행 후 안내받는 주소가 달라 어느 쪽이 진짜인지 알 수 없다. // 같은 규칙(VITE_PUBLISH_HOST → 없으면 현재 호스트)을 쓴다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) const VIEWPORT_FRAME: Record = { pc: 'w-full max-w-5xl shadow-md', diff --git a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx index 9405014..3ec3925 100644 --- a/solution/frontend/src/features/marketing/ShowcaseGrid.tsx +++ b/solution/frontend/src/features/marketing/ShowcaseGrid.tsx @@ -1,6 +1,7 @@ import {useEffect, useState, type CSSProperties} from 'react'; import {Building2, Coffee, ImageOff, Stethoscope, UtensilsCrossed} from 'lucide-react'; import {PlaceCategory} from '@o2o/shared'; +import {ORIGIN} from '@/lib/site'; import {fetchShowcase, type ShowcaseItem} from './showcaseApi'; const CATEGORY_ICON: Record = { @@ -17,11 +18,14 @@ const CATEGORY_LABEL: Record = { [PlaceCategory.CLINIC]: '피부과 · 성형외과', }; -/** 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). 발행 호스트는 번들에 구워진 값이다. */ -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; - +/** + * 발행 사이트 주소는 루트 상대경로로 온다(`/s/`). + * + * ★ window 로 떨어지지 않는다. 이 컴포넌트는 랜딩(`/`)과 `/showcase` 에 있고 그 둘은 + * 프리렌더 대상이다 — 빌드 때 브라우저 없이 한 번 그려지므로 window 를 만지면 죽는다. + */ function siteHref(url: string): string { - return `${window.location.protocol}//${PUBLISH_HOST}${url}`; + return `${ORIGIN}${url}`; } /** diff --git a/solution/frontend/src/features/onboarding/Step3DataReview.tsx b/solution/frontend/src/features/onboarding/Step3DataReview.tsx index 713c468..110cbac 100644 --- a/solution/frontend/src/features/onboarding/Step3DataReview.tsx +++ b/solution/frontend/src/features/onboarding/Step3DataReview.tsx @@ -320,8 +320,20 @@ export function Step3DataReview() { 가게만 채널을 긁을 수 있습니다 — 2단계에서 지도 검색으로 가게를 확인하시면 주소 붙여넣기와 자동 찾기가 모두 열립니다.

+ {/* ★ 이 길로 나가면 **발행까지 못 간다** — 서버에 사업장이 없어서 구울 대상이 + 없고, 발행 모달은 [내 가게 확인하러 가기] 만 내놓는다(PublishModal + PlaceFirstPanel). 예전에는 아무 말 없이 통과시켜서, 사장님은 사이트를 다 + 만든 뒤 발행을 누르는 자리에서야 그 사실을 알았다. + 버튼은 남긴다 — 검증을 못 통과한 분이 화면을 구경할 길까지 막지는 않는다. */} +

+ + 이대로 진행하면 화면은 만들 수 있지만 발행은 되지 않습니다. + {' '} + 발행본은 확인된 가게 한 곳에 묶여서, 가게 확인 전에는 만들어 둘 페이지가 + 없습니다. 둘러보신 뒤 언제든 가게를 확인하시면 그때 발행이 열립니다. +

diff --git a/solution/frontend/src/features/publish/PublishModal.tsx b/solution/frontend/src/features/publish/PublishModal.tsx index 0e8aaac..755ea91 100644 --- a/solution/frontend/src/features/publish/PublishModal.tsx +++ b/solution/frontend/src/features/publish/PublishModal.tsx @@ -1,24 +1,33 @@ import {useCallback, useEffect, useMemo, useState} from 'react'; +import {useNavigate} from 'react-router'; import { AlertTriangle, ArrowUpRight, Check, Copy, ExternalLink, + Eye, Loader2, RefreshCw, + Search, ServerCrash, ShieldAlert, + Store, } from 'lucide-react'; import {publishUrlString, toSlug} from '@o2o/shared'; +import {getAccessToken} from '@/api'; import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Dialog} from '@/components/ui/dialog'; +import {SignInForm} from '@/features/auth/SignInForm'; +import type {WizardStep} from '@/features/onboarding/wizardUrl'; import {notify, notifyApiError} from '@/lib/notify'; import {cn} from '@/lib/utils'; +import {useAuthStore} from '@/stores/auth'; import {useBuilderStore} from '@/stores/builder'; import {runPublishGate, type GateFinding} from './publishGate'; import {checkSiteSlug, reserveSiteSlug} from './siteSlug'; +import {PUBLISH_HOST} from '@/lib/site'; import {localValidate, SlugField, type SlugStatus} from './SlugField'; import { GATE_REASON_LABEL, @@ -29,7 +38,30 @@ import { // 개발에서는 admin 과 같은 :3000을 공개 주소로 쓴다. `/s` 요청은 Vite가 정적 사이트 // 서버(:3001)로 프록시한다. 운영에서는 VITE_PUBLISH_HOST로 공개 호스트를 명시할 수 있다. -const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host; +// ★ window 로 떨어지지 않는다. 서버 번들은 라우트를 한 파일로 묶어서, 프리렌더가 아닌 +// 화면의 모듈 최상위 코드도 빌드 때 한 번 실행된다 — 여기서 window 를 만지면 빌드가 죽는다. +// (PUBLISH_HOST 는 @/lib/site 가 유일한 출처다) + +/** + * 가게 확인이 안 끝난 사장님을 돌려보낼 곳. + * + * ★ 단계 이름은 wizardUrl 이 소유한다 — 문자열을 그냥 박아 두면 이름이 바뀌는 날 + * 주소창의 step 을 아무도 못 알아보고 조용히 첫 화면이 열린다(defaultStep). + */ +const PLACE_SEARCH_STEP: WizardStep = 'search'; +const PLACE_SEARCH_URL = `/builder?step=${PLACE_SEARCH_STEP}`; + +/** + * 발행을 **시작조차 할 수 없는** 상태. 서버를 부를 수 없으므로 발행 버튼을 그리지 않는다. + * + * ★ 예전에는 이 두 경우가 [지금 발행하기] 로 그대로 흘러 들어와, 서버를 한 번도 부르지 않고 + * 성공 토스트를 띄운 뒤 [사이트 열기] 까지 그렸다. 그 주소는 아무것도 굽지 않은 주소라 + * 404 였고 내 사이트 목록에도 없었다 — 사장님만 발행됐다고 믿는 상태가 남았다. + * ★ 'place' 가 진짜 함정이다. 로그인은 했는데(에디터 관문을 지났다) 3단계의 + * [발행 없이 화면만 둘러보기] 로 나오면 서버에 사업장이 없다 — 화면은 끝까지 도는데 + * 발행만 안 된다. + */ +type PublishBlocker = 'signin' | 'place'; export function PublishModal() { const isOpen = useBuilderStore((s) => s.isPublishModalOpen); @@ -54,11 +86,28 @@ export function PublishModal() { /** * 실사업장이면 진짜 빌드를 태운다(`POST /site/build {publish:true}` → 잡 폴링). - * ★ placeId 가 없는 데모 경로는 예전처럼 주소만 확정한다 — 없는 사업장을 서버에 빌드시킬 수 없다. + * ★ 그럴 수 없는 상태(로그인 전 · 사업장 미확정)에서는 발행 버튼 자체를 그리지 않는다. + * PublishBlocker 주석 참고. */ const publisher = usePublishSite(placeId); const {state, reset} = publisher; + const navigate = useNavigate(); + + /* + * ★ 토큰은 스토어 밖(custom-fetch)에 있어 구독할 수 없다. 로그인이 auth 스토어의 user 도 + * 함께 심으므로(lib/session.establishSession) 그걸 **재렌더 신호로만** 구독한다 — + * 없으면 아래 로그인 폼으로 로그인을 마쳐도 모달은 계속 "로그인해 주세요" 로 남는다. + */ + useAuthStore((s) => s.user); + + /** 판정 기준은 usePublishSite.isLive 와 같다(placeId + 토큰) — 갈리면 버튼과 실제가 어긋난다. */ + const blocker: PublishBlocker | null = publisher.isLive + ? null + : getAccessToken() + ? 'place' + : 'signin'; + const gate = useMemo( () => runPublishGate({ @@ -107,7 +156,12 @@ export function PublishModal() { if (!isOpen) reset(); }, [isOpen, reset]); - const isDone = state.phase === 'published' || (!publisher.isLive && Boolean(publishedUrl)); + /** + * ★ 서버가 발행을 확정한 것만 '발행됨'이다. + * 예전에는 `!isLive && publishedUrl` 도 완료로 쳤는데, 그 publishedUrl 을 채우던 것이 + * 서버를 한 번도 부르지 않는 가짜 경로였다 — 굽지도 않은 주소에 [사이트 열기] 가 붙었다. + */ + const isDone = state.phase === 'published'; /** * 이미 한 번 발행한 사이트인가 — 문구를 '발행'과 '재발행'으로 가른다. @@ -146,25 +200,22 @@ export function PublishModal() { }, [slug, placeId]); const handlePublish = async () => { - if (!gate.canPublish || publisher.isPublishing || !slugReady) return; + // ★ blocker 가 있으면 이 버튼은 그려지지도 않는다. 그래도 한 번 더 막는다 — + // 서버를 못 부르는 상태로 여기를 지나가는 것이 곧 '가짜 발행'이다. + if (blocker || !gate.canPublish || publisher.isPublishing || !slugReady) return; - if (publisher.isLive) { - // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, - // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. - if (!isSlugLocked) { - try { - await reserveSiteSlug(placeId ?? '', slug); - } catch (error) { - notifyApiError(error, '주소를 확정하지 못했습니다.'); - return; - } + // ★ 주소를 먼저 확정하고 빌드한다. 순서가 반대면 주소 없는 사이트가 발행되고, + // 그 뒤에 주소를 붙이면 이미 색인된 주소가 하나 더 생긴다. + if (!isSlugLocked) { + try { + await reserveSiteSlug(placeId ?? '', slug); + } catch (error) { + notifyApiError(error, '주소를 확정하지 못했습니다.'); + return; } - // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. - publisher.publish(); - return; } - setPublishedUrl(url); - notify.success('발행 준비가 끝났습니다', '확인된 정보만 담긴 정적 페이지가 생성됩니다.'); + // 진짜 게이트는 서버에 있다(services/publish_gate). 여기 gate 는 왕복을 줄이는 사전 점검일 뿐이다. + publisher.publish(); }; const handleCopy = async () => { @@ -181,11 +232,25 @@ export function PublishModal() { - {isDone ? ( + {/* ★ 로그인 전에는 여기에 아무 버튼도 두지 않는다 — 다음 행동(로그인)은 본문의 + 폼이고, 옆에 [발행하기] 를 세워 두면 누를 수 있는 것처럼 보인다. */} + {blocker === 'signin' ? null : blocker === 'place' ? ( + + ) : isDone ? ( + )} + + + {/* 건수를 배지가 아니라 칸 안에 붙인다 — "34가 어디 있나"는 칸을 눌러 보기 전에 보여야 한다. */} +
+ {FILTER_TABS.map((key) => ( + + ))} +
+ + )} + + {/* ★ 처음 온 사람의 빈 화면과 섞지 않는다 — 사이트가 35개인데 "아직 없습니다" 라고 하면 + 사장님은 목록이 아니라 자기 사이트가 사라진 줄 안다. */} + {!isLoading && !isError && rows.length > 0 && visible.length === 0 && ( + + 조건 지우기 + + } + /> + )} + + {visible.length > 0 && ( + /* ★ 줄이 아니라 카드다. 아임웹 내사이트 화면을 보고 바꿨다(2026-09-08) — 거기 썸네일은 + 줄 높이의 대부분을 차지한다. 사이트 목록에서 그림은 장식이 아니라 **유일한 구분자**라 + (실측: 같은 상호 '버터브루' 4줄) 작으면 있으나 마나다. + 비율은 16:10 — 사이트 미리보기라 브라우저 창 비율이어야 한다. 1:1 은 사진첩이지 사이트가 아니다. */ +
    + {visible.map((row) => { const Icon = CATEGORY_ICON[row.category] ?? Building2; const badge = statusBadge(row); const url = publishedUrl(row); + const isLive = bucketOf(row) === 'live'; const editHref = `/builder?placeId=${row.place_id}`; return ( -
  • - - - -
    - {row.name} - {badge.label} -
    -

    - {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} -

    + /* ★ 카드는 **상태와 무관하게 같은 골격**이다. 예전엔 발행 전 카드에만 '할 일' + 줄이 하나 더 붙어서 같은 줄의 카드끼리 높이와 버튼 위치가 어긋났다(사장님 지적). + h-full + flex-col + mt-auto 로 액션 줄을 항상 카드 바닥에 붙인다. */ +
  • + + -
    - {url && ( - + {badge.label} + + +
    + +

    {row.name}

    + {/* ★ 나가 있는 카드만 주소를 진하게. 나머지의 이 자리는 "아직 없다"는 안내라 + 같은 색이면 34개의 안내문 사이에 진짜 주소가 묻힌다. */} +

    - - 사이트 열기 - - )} - - + {url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')} +

    + {/* 같은 상호가 여럿일 때 가르는 값 — 주소와 시각. */} +

    + {[row.road_address, whenLabel(row)].filter(Boolean).join(' · ')} +

    + + +
    + {/* 버튼 문구도 하나로 둔다 — '편집' 과 '이어서 만들기' 는 사장님이 할 일이 + 같은데(에디터를 연다) 글자만 달라 카드마다 폭이 들쭉날쭉했다. */} + + {url && ( + + + 열기 + + )} + +
    {menuId === row.place_id && ( @@ -190,7 +433,7 @@ export function SitesPage() { className="fixed inset-0 z-10 cursor-default" onClick={() => setMenuId(null)} /> -
    +