Merge branch 'main' into feature/auth — 관문은 main 의 에디터 진입으로

main 이 5ef3e5a 로 문 앞 게이트(d6a6c8e·b94daa9)를 되돌렸다. 근거가 내가 못 본 것이었다 —
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고,
앱을 열자마자 로그인 화면이 된다. 그 결정을 따르고, 되돌리기에 휩쓸린 것만 복구한다.

- app/router: `/signup` 라우트 복구. 라우터를 통째로 되돌리면서 같이 날아갔고,
  그 결과 로그인 화면의 [회원가입] 링크가 404 였다
- features/auth/SignInForm: 토큰 심는 순서(signIn → me)를 lib/session 으로. 이 파일 맨 위
  주석이 경고하던 그 중복이다 — 관문이 되살아나면서 사본도 같이 돌아왔다
- pages/BuilderPage: 에디터 관문(main)과 상단 바 사용자 표시(이쪽)를 함께 둔다.
  충돌은 `authUser`/`user` 이름뿐이었다
- docs/DEVLOG: 인증 항목이 되돌리기에 휩쓸려 사라졌다. 지금 설계(에디터 진입 관문)에 맞춰
  다시 썼다 — 문 앞 가드를 시도했다 되돌린 이력도 함께 남긴다
- docs/ARCHITECTURE: 인증 모델 서술과 '아직 안 한 것' 을 지금 상태로

브라우저 확인: 위저드는 로그인 없이 열림 → 가입 → 사이드바 '김사장 · 달빛스테이' →
에디터 상단 바 동일 표시 → 로그아웃. 구글 버튼 렌더까지 확인(실제 로그인은 client_id 필요).
pytest 534 passed / 4 failed(전부 기존 실패). tsc·eslint·vite build 통과.
This commit is contained in:
Mina Choi 2026-09-02 21:33:31 +09:00
commit 01c252287e
49 changed files with 806 additions and 260 deletions

View File

@ -1,29 +1,30 @@
# ★ 빌드 컨텍스트가 레포 루트다(solution/backend/Dockerfile 주석 참조). # 빌드 컨텍스트는 레포 루트고, 백엔드 이미지와 solution-site 이미지가 이것을 **같이** 쓴다.
# 컨텍스트가 넓어진 만큼 여기서 확실히 잘라내야 이미지가 붓지 않는다. # ★ nginx/Dockerfile.dockerignore 는 BuildKit 전용이다. 이 머신엔 buildx 가 없어 레거시
**/__pycache__/ # 빌더가 돌고, 그러면 이 파일만 읽힌다 — 여기서 프론트 소스를 자르면 nginx 빌드가 죽는다.
*.pyc # (백엔드 이미지는 COPY 대상이 solution/backend·admin/backend 뿐이라 영향이 없다.)
**/.pytest_cache/
.git/ .git/
.venv/
**/.venv/
node_modules/ node_modules/
**/node_modules/ **/node_modules/
**/dist/ **/dist/
**/.vite/ **/.vite/
**/__pycache__/
*.pyc
**/.pytest_cache/
.venv/
**/.venv/
# 발행 산출물은 볼륨에서 온다. 이미지에 구우면 사이트가 늘 때마다 이미지가 붓는다.
solution/site/out/
solution/site/payloads/
# 프론트·문서는 백엔드 이미지에 들어갈 이유가 없다.
solution/frontend/
solution/site/
solution/shared/
admin/frontend/
docs/ docs/
nginx/
postgres-init/
**/*.md **/*.md
solution/backend/tests/ solution/backend/tests/
solution/backend/loadtest/ solution/backend/loadtest/
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다. # 시크릿 — 이미지에 굽지 않는다. 백엔드는 example 사본 + compose env, 프론트는 build args.
**/config.local.toml
**/config.test.toml
.env .env
.env.* .env.*
!.env.example !.env.example

View File

@ -43,8 +43,6 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의 (`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.** 수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다). 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다.
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데 - **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는 블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라. CDN 을 앞에 세워야 한다. 아니면 비워라.

View File

@ -24,7 +24,7 @@ const CATEGORY_LABEL: Record<number, string> = {
[PlaceCategory.LODGING]: '숙박', [PlaceCategory.LODGING]: '숙박',
[PlaceCategory.CAFE]: '카페', [PlaceCategory.CAFE]: '카페',
[PlaceCategory.RESTAURANT]: '음식점', [PlaceCategory.RESTAURANT]: '음식점',
[PlaceCategory.TOUR_ACTIVITY]: '관광체험', [PlaceCategory.CLINIC]: '피부과·성형외과',
}; };
const STATUS_LABEL: Record<number, string> = { const STATUS_LABEL: Record<number, string> = {

View File

@ -137,12 +137,13 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다. `UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
라우트 가드는 화면을 가리지 **번들은 못 가린다.** 라우트 가드는 화면을 가리지 **번들은 못 가린다.**
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다. ★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함) 2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
**계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면, ("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면 다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
실수는 늘 **느슨한 쪽으로** 난다. role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
→ 지금은 `LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다 한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
(사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음). → 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
내부: 실패가 곧 차단).
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다. 3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
### 백엔드 — 코드 한 벌, 진입점 둘 ### 백엔드 — 코드 한 벌, 진입점 둘
@ -180,8 +181,8 @@ OWNER role=2 → 403
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다. OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도 `auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고, 같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다. 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`). ⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가 위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
@ -226,12 +227,11 @@ admin 자기 파일만 `@admin` 이다.
### 아직 안 한 것 ### 아직 안 한 것
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로 - 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다. `/builder?new=1`(새로 만들기) 하나뿐이다.
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고 - **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
거절한다 → [DECISIONS.md 1-5](DECISIONS.md) 거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서 - **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을 - 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.** `127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로 - **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로

View File

@ -3,45 +3,28 @@
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. 무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. 결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
## 2026-09-02 — 로그인을 붙이고, 빌더를 그 뒤로 넣었다 ---
## 2026-09-02 — 회원가입과 구글 로그인
**한 일** **한 일**
- id/pw **회원가입**(`POST /v1/auth/signup`) 과 **구글 로그인**(`POST /v1/auth/google`) 추가. - `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub) 추가. `password` 는 가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
NULL 허용, `id` 는 20 → 64자. - `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
- `/builder` 를 `RequireAuth` 뒤로 넣었다. 자동 로그인은 화면 안(`useAutoLogin`) 이 아니라 허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안은 이제 실행되지 않는다. - 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
- 로그인 화면에 구글 버튼 + 가입 링크. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다. 들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
**왜 빌더를 막았나** **왜 가입부터 만들었나**
빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 2단계부터 백엔드를 부르고, 계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
만든 결과는 사업장·사이트로 **계정에 귀속**된다. 로그인 없이 걸어온 사람은 3단계쯤에서 그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
**관문이 두 개였다 — 문 앞 하나로 합쳤다**
같은 날 두 자리에서 같은 문제를 풀었다. main 은 **에디터 진입(6단계)** 에서 받았고
(`EditorSignInGate`·`SignInForm`), 이쪽은 **`/builder` 문 앞**에서 받았다. 둘 다 두면 문 앞이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드가 된다. 문 앞을 남긴 이유:
- 에디터 관문을 쓰려면 **2단계가 토큰 없이 지나가야** 했고, 그래서 토큰이 없을 때 서버를 부르지
않고 입력값으로 신원을 세우는 우회로가 생겼다(`confirmManual`). 그건 이 레포의 단 하나의
규칙(**검증 전에는 수집·발행 금지**)을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을
다시" 다. 열어 둔 값이 공짜가 아니었다.
- 이제 가입이 그 자리에서 끝난다(가입 응답에 토큰이 실린다). 구글이면 클릭 두 번이다 —
문 앞 로그인의 마찰이 "만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.
- 관문이 하나면 로그인 폼도 하나다. `SignInForm` 이 경고하던 "`signIn → me` 순서를 두 벌로
들고 있다"는 `lib/session.establishSession` 한 곳으로 모았다.
지운 것: `features/auth/EditorSignInGate`·`SignInForm`, `usePlaceSearch.confirmManual`,
Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `RequireAuth` 를 벗기고
그 셋을 되살리면 된다(커밋 `969fb67`·`22b7623`).
**왜 가입까지 만들었나**
빌더가 로그인 뒤로 들어간 순간, 계정을 만들 길이 없으면 제품이 닫힌다. 계정 생성 API 가
아예 없어서(그동안 `users` 를 손으로 INSERT 했다) 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다. 로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
**로그인 관문은 에디터 진입 그대로다**
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
관문은 `EditorSignInGate`(969fb67) 한 자리다.
**밟기 쉬운 자리** **밟기 쉬운 자리**
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의 - **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로 수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
@ -52,15 +35,33 @@ Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `Req
None 을 만나 500 이 난다. None 을 만나 500 이 난다.
- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서 - `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
NOT NULL 컬럼이면 그 경로가 통째로 깨진다. NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의 **이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료· **아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과. 버튼 렌더까지는 확인했다(빌려온 client_id 로).
**검증** — 백엔드 auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
`email_verified`·본문 변조 거절). 브라우저: 가입 → 위저드 진입 → 사이드바 표시 → 에디터
상단 바 표시 → 로그아웃. `tsc`·`eslint`·`vite build` 통과.
--- ---
## 2026-09-02 — 직접 쓴 소개문이 발행에서 사라지던 구멍
**왜**
에디터의 소개 섹션 본문은 `sites.theme` 에 저장됐지만 발행 payload 경계에서 버려졌고,
프리렌더도 고유 콘텐츠로 세지 않았다. 사장님이 소개를 써도 발행 화면은 0건이라며 거부했다.
**한 일**
- `SectionSetting.body` 계약을 추가하고 저장값을 payload 까지 전달
- 소개 본문을 발행 HTML에 표시하고, 켜진 소개 섹션의 8자 이상 본문만 고유 콘텐츠로 계수
- 고유 콘텐츠 0건과 JSON-LD 불일치, 계수 실패를 서로 다른 발행 사유로 분리
**검증** — 직접 입력 소개문만 있는 발행 경로 회귀 테스트 추가.
## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개) ## 2026-09-02 — 템플릿이 색만 바꾸던 걸 끝냈다 (5개 → 3개)
**왜** **왜**

View File

@ -91,7 +91,7 @@ CREATE TABLE IF NOT EXISTS place.places (
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id) company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id) owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
name VARCHAR(200) NOT NULL, -- 상호명(입력값) name VARCHAR(200) NOT NULL, -- 상호명(입력값)
category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=관광체험 category SMALLINT NOT NULL, -- 업종(PlaceCategory): 1=숙박 2=카페 3=음식점 4=피부과·성형외과
status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended status SMALLINT NOT NULL DEFAULT 1, -- 상태(PlaceStatus): 1=draft 2=collecting 3=review 4=published 5=suspended
external_source SMALLINT NULL, -- 검증에 쓴 외부 장소 DB(ExternalPlaceSource): 1=kakao 2=naver external_source SMALLINT NULL, -- 검증에 쓴 외부 장소 DB(ExternalPlaceSource): 1=kakao 2=naver
external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다 external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다

View File

@ -39,7 +39,7 @@ common/category_schema/resources/
├── lodging.json 숙박 필드 30 (critical 14) ├── lodging.json 숙박 필드 30 (critical 14)
├── cafe.json 카페 필드 23 (critical 12) ├── cafe.json 카페 필드 23 (critical 12)
├── restaurant.json 음식점 필드 24 (critical 15) ├── restaurant.json 음식점 필드 24 (critical 15)
└── tour_activity.json 관광체험 필드 24 (critical 17) └── clinic.json 피부과·성형외과 필드 24 (critical 17)
``` ```
**업종 추가 = JSON 파일 1개 + `PlaceCategory` 코드 1줄.** 로직은 건드리지 않는다. **업종 추가 = JSON 파일 1개 + `PlaceCategory` 코드 1줄.** 로직은 건드리지 않는다.

View File

@ -0,0 +1,31 @@
{
"category": "clinic",
"code": 4,
"label": "피부과·성형외과",
"fields": [
{ "key": "operating_hours", "label": "진료시간", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "closed_days", "label": "휴진일", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "specialties", "label": "진료과목", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "medical_staff", "label": "의료진", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_required", "label": "예약 필수", "type": "bool", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_channel", "label": "예약 방법", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "consultation_fee", "label": "상담료", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "insurance_covered", "label": "보험 적용 안내", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "cancel_policy", "label": "예약 변경·취소", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "night_clinic", "label": "야간진료", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "weekend_clinic", "label": "주말진료", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "guide_language", "label": "외국어 상담", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "parking", "label": "주차 가능", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "wheelchair_accessible", "label": "휠체어 접근", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "intro", "label": "병원 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "program_name", "label": "시술명", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "program_duration", "label": "소요 시간", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "분" },
{ "key": "price_adult", "label": "시술 비용", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "anesthesia", "label": "마취 방식", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "downtime", "label": "회복 기간", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "session_count", "label": "권장 횟수", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "aftercare", "label": "시술 후 주의사항", "type": "text", "scope": "unit", "required": false, "critical": true, "allow_llm": false },
{ "key": "program_intro", "label": "시술 설명", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": true }
]
}

View File

@ -1,32 +0,0 @@
{
"category": "tour_activity",
"code": 4,
"label": "관광체험",
"fields": [
{ "key": "operating_hours", "label": "운영시간", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "closed_days", "label": "휴무일", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_required", "label": "예약 필수", "type": "bool", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "reservation_channel", "label": "예약 방법", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "cancel_policy", "label": "취소·환불 규정", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "age_limit", "label": "연령 제한", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "safety_notice", "label": "안전 유의사항", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "weather_dependent", "label": "우천 시 운영", "type": "text", "scope": "place", "required": true, "critical": true, "allow_llm": false },
{ "key": "what_to_bring", "label": "준비물", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "session_times", "label": "회차 시간", "type": "text", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "guide_language", "label": "안내 언어", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "parking", "label": "주차 가능", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "wheelchair_accessible", "label": "휠체어 접근", "type": "bool", "scope": "place", "required": false, "critical": true, "allow_llm": false },
{ "key": "locker_available", "label": "물품보관함", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "shower_available", "label": "샤워시설", "type": "bool", "scope": "place", "required": false, "critical": false, "allow_llm": false },
{ "key": "intro", "label": "체험 소개", "type": "text", "scope": "place", "required": false, "critical": false, "allow_llm": true },
{ "key": "program_name", "label": "프로그램명", "type": "text", "scope": "unit", "required": true, "critical": false, "allow_llm": false },
{ "key": "program_duration", "label": "소요 시간", "type": "number", "scope": "unit", "required": true, "critical": true, "allow_llm": false, "unit": "분" },
{ "key": "program_capacity", "label": "회차당 정원", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "명" },
{ "key": "program_min_people", "label": "최소 출발 인원", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "명" },
{ "key": "price_adult", "label": "성인 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "price_child", "label": "소인 요금", "type": "number", "scope": "unit", "required": false, "critical": true, "allow_llm": false, "unit": "원" },
{ "key": "program_level", "label": "난이도", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": false },
{ "key": "program_intro", "label": "프로그램 소개", "type": "text", "scope": "unit", "required": false, "critical": false, "allow_llm": true }
]
}

View File

@ -169,7 +169,7 @@ class place_links(MainTableMixin, MAIN_BASE):
class units(MainTableMixin, MAIN_BASE): class units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 관광체험=프로그램. """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다.""" 가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "units" __tablename__ = "units"

View File

@ -181,7 +181,7 @@ class PlaceCategory(CodeEnum):
LODGING = 1 # 숙박 LODGING = 1 # 숙박
CAFE = 2 # 카페 CAFE = 2 # 카페
RESTAURANT = 3 # 음식점 RESTAURANT = 3 # 음식점
TOUR_ACTIVITY = 4 # 관광체험 CLINIC = 4 # 피부과·성형외과
class ExternalPlaceSource(CodeEnum): class ExternalPlaceSource(CodeEnum):

View File

@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
count = 0 count = 0
intro = next(
(s for s in (payload.get("theme") or {}).get("sections") or [] if s.get("id") == "intro"),
None,
)
if intro and intro.get("enabled") and long(intro.get("body")):
count += 1
facts = list(payload.get("facts") or []) facts = list(payload.get("facts") or [])
for unit in payload.get("units") or []: for unit in payload.get("units") or []:
facts.extend(unit.get("facts") or []) facts.extend(unit.get("facts") or [])

View File

@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다: ★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}} {"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다 sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다. (별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 — pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
서버는 배달부지 심판이 아니다. 서버는 배달부지 심판이 아니다.

View File

@ -106,7 +106,7 @@ async def set_template(
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다" description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: " "(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\"," "{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. " "\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). " "★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. " "★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
"직렬화 크기(64KB)만 막는다. " "직렬화 크기(64KB)만 막는다. "

View File

@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
) )
mismatches = list(report.get("mismatches") or []) mismatches = list(report.get("mismatches") or [])
unique_count = report.get("uniqueContentCount") or 0 # ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
# 기록·화면에는 0 으로 떨어뜨린다. 뭉개면 디스크 오류가 NO_UNIQUE_CONTENT 로 둔갑한다.
unique_count_raw = report.get("uniqueContentCount")
unique_count = unique_count_raw or 0
jsonld = report.get("jsonld") or [] jsonld = report.get("jsonld") or []
result["unique_content_count"] = unique_count result["unique_content_count"] = unique_count
result["mismatches"] = mismatches[:20] result["mismatches"] = mismatches[:20]
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다. # 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 — # 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다. # 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches) gate = publish_gate.evaluate(
PlaceCategory(place.category), snapshot["facts"], unique_count_raw, mismatches
)
result["gate"] = {"passed": gate.passed, **gate.as_log()} result["gate"] = {"passed": gate.passed, **gate.as_log()}
if not gate.passed: if not gate.passed:
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp) return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
if not report.get("ok"): if not report.get("ok"):
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다. # ★ 렌더러가 거부한 이유에 사유 코드를 붙인다. evaluate() 는 얇은 콘텐츠로 막지
# 않지만 렌더러는 스팸 판정을 피하려 페이지 쓰기를 거부한다 — 그 사유를 "렌더 실패"
# 로 뭉개면 화면이 NO_UNIQUE_CONTENT 문구를 못 고르고, 사장님은 손댈 곳을 모른다.
thin = publish_gate.check_unique_content(unique_count_raw)
if not thin.passed:
# 위에서 evaluate 결과로 채워 둔 gate 를 덮는다 — 화면(GateRejectCard)은 이 값으로
# 문구를 고르는데, passed=True 인 채로 두면 "서버 검수를 통과하지 못했습니다" 만 뜬다.
result["gate"] = {"passed": False, **thin.as_log()}
return await _fail(f"{thin.reason.name}: {thin.as_log()}", thin, stamp)
# 나머지는 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있다.
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp) return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다. # DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.

View File

@ -29,7 +29,7 @@ _CATEGORY_BY_NAME = {
"lodging": PlaceCategory.LODGING, "lodging": PlaceCategory.LODGING,
"cafe": PlaceCategory.CAFE, "cafe": PlaceCategory.CAFE,
"restaurant": PlaceCategory.RESTAURANT, "restaurant": PlaceCategory.RESTAURANT,
"tour_activity": PlaceCategory.TOUR_ACTIVITY, "clinic": PlaceCategory.CLINIC,
} }
_CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel} _CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel}
@ -86,16 +86,18 @@ _PLACE_FACTS = {
("signature_menu", "한우 물회"), ("signature_menu", "한우 물회"),
("price_range", "15,000~30,000원"), ("price_range", "15,000~30,000원"),
], ],
PlaceCategory.TOUR_ACTIVITY: [ PlaceCategory.CLINIC: [
("operating_hours", "09:00~18:00"), ("operating_hours", "평일 10:00~19:00, 토 10:00~14:00"),
("closed_days", "매주 수요일"), ("closed_days", "일요일·공휴일"),
("specialties", "피부과, 성형외과"),
("medical_staff", "피부과 전문의 2인, 성형외과 전문의 1인"),
("reservation_required", "true"), ("reservation_required", "true"),
("reservation_channel", "네이버 예약"), ("reservation_channel", "네이버 예약, 전화"),
("cancel_policy", "체험 3일 전까지 100% 환불, 당일 취소 불가"), ("consultation_fee", "초진 상담료 없음"),
("age_limit", "만 7세 이상"), ("insurance_covered", "질환 치료는 보험 적용, 미용 시술은 비급여"),
("safety_notice", "구명조끼 착용 필수, 음주 후 참여 불가"), ("cancel_policy", "예약 1일 전까지 변경 가능, 당일 취소는 재예약 제한"),
("weather_dependent", "우천 시 당일 오전 8시에 개별 안내"), ("night_clinic", "목요일 21:00까지"),
("what_to_bring", "여벌 옷, 수건"), ("weekend_clinic", "토요일 14:00까지"),
("parking", "true"), ("parking", "true"),
], ],
} }
@ -130,19 +132,21 @@ _UNIT_FACTS = {
"한우 물회": [("menu_name", "한우 물회"), ("menu_price", "18000")], "한우 물회": [("menu_name", "한우 물회"), ("menu_price", "18000")],
"성게 비빔밥": [("menu_name", "성게 비빔밥"), ("menu_price", "22000"), ("menu_min_order", "2")], "성게 비빔밥": [("menu_name", "성게 비빔밥"), ("menu_price", "22000"), ("menu_min_order", "2")],
}, },
PlaceCategory.TOUR_ACTIVITY: { PlaceCategory.CLINIC: {
"서핑 입문 클래스": [ "레이저 토닝": [
("program_name", "서핑 입문 클래스"), ("program_name", "레이저 토닝"),
("program_duration", "120"), ("program_duration", "30"),
("program_capacity", "8"), ("price_adult", "100000"),
("price_adult", "70000"), ("anesthesia", "마취 없음"),
("price_child", "50000"), ("downtime", "당일 일상생활 가능"),
("session_count", "5회 권장"),
], ],
"패들보드 체험": [ "보톡스": [
("program_name", "패들보드 체험"), ("program_name", "보톡스"),
("program_duration", "90"), ("program_duration", "20"),
("program_capacity", "6"), ("price_adult", "150000"),
("price_adult", "55000"), ("anesthesia", "연고 마취"),
("downtime", "1~2일 미세 부기"),
], ],
}, },
} }
@ -168,11 +172,11 @@ _MEDIA = {
("한우 물회", "한우 물회"), ("한우 물회", "한우 물회"),
("성게 비빔밥", "성게 비빔밥"), ("성게 비빔밥", "성게 비빔밥"),
], ],
PlaceCategory.TOUR_ACTIVITY: [ PlaceCategory.CLINIC: [
("해변 전경", None), ("병원 외관", None),
("장비 보관소", None), ("상담실", None),
("서핑 강습", "서핑 입문 클래스"), ("레이저 장비", "레이저 토닝"),
("패들보드", "패들보드 체험"), ("시술실", "보톡스"),
], ],
} }

View File

@ -38,7 +38,7 @@ _CATEGORY_LABEL = {
PlaceCategory.LODGING: "숙박업소", PlaceCategory.LODGING: "숙박업소",
PlaceCategory.CAFE: "카페", PlaceCategory.CAFE: "카페",
PlaceCategory.RESTAURANT: "음식점", PlaceCategory.RESTAURANT: "음식점",
PlaceCategory.TOUR_ACTIVITY: "관광·체험 시설", PlaceCategory.CLINIC: "관광·체험 시설",
} }

View File

@ -8,7 +8,7 @@ _LABELS = {
PlaceCategory.LODGING: ["외관", "침실", "거실", "욕실", "주방", "수영장", "바비큐장", "주차장", "전망", "부대시설"], PlaceCategory.LODGING: ["외관", "침실", "거실", "욕실", "주방", "수영장", "바비큐장", "주차장", "전망", "부대시설"],
PlaceCategory.CAFE: ["외관", "내부", "좌석", "메뉴", "디저트", "음료", "테라스", "주차장"], PlaceCategory.CAFE: ["외관", "내부", "좌석", "메뉴", "디저트", "음료", "테라스", "주차장"],
PlaceCategory.RESTAURANT: ["외관", "내부", "좌석", "메뉴", "음식", "룸", "주차장"], PlaceCategory.RESTAURANT: ["외관", "내부", "좌석", "메뉴", "음식", "룸", "주차장"],
PlaceCategory.TOUR_ACTIVITY: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"], PlaceCategory.CLINIC: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"],
} }
_DEFAULT_LABELS = ["외관", "내부", "시설", "기타"] _DEFAULT_LABELS = ["외관", "내부", "시설", "기타"]

View File

@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
return GateResult(True) return GateResult(True)
def check_unique_content(unique_content_count: int) -> GateResult: def check_unique_content(unique_content_count: int | None) -> GateResult:
"""고유 콘텐츠 품질 진단용. 발행 게이트에서는 더 이상 호출하지 않는다. """렌더러가 '고유 콘텐츠 0건' 으로 거부했을 때 사유 코드를 붙이는 자리.
evaluate() 는 이걸 부르지 않는다(얇은 콘텐츠로 발행을 막지 않기로 했다).
부르는 곳은 build_service — 렌더 보고서가 실패로 왔을 때 그 이유를 되짚는다.
이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면 이 가게에만 있는 것(소개문·FAQ·객실 설명·사진 alt·템플릿 아닌 fact 값)이 0이면
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.""" 같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
if unique_content_count is None:
return GateResult(True)
if unique_content_count <= 0: if unique_content_count <= 0:
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count}) return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
return GateResult(True) return GateResult(True)
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
return GateResult(True) return GateResult(True)
def evaluate(category: PlaceCategory, facts: list, unique_content_count: int, mismatches: list) -> GateResult: def evaluate(
category: PlaceCategory, facts: list, unique_content_count: int | None, mismatches: list
) -> GateResult:
"""게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다. """게이트 전체. **처음 걸린 것에서 멈춘다** — 운영자가 하나씩 고치게 사유를 하나만 준다.
순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓). 순서는 심각도 순: 미검증(클레임) → JSON-LD 불일치(거짓).
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정). ★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다. 막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고, 영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,

View File

@ -108,16 +108,16 @@ _DEFAULT_THEME = {
("local", "주변 안내", False), ("faq", "자주 묻는 질문", False), ("local", "주변 안내", False), ("faq", "자주 묻는 질문", False),
], ],
}, },
PlaceCategory.TOUR_ACTIVITY.value: { PlaceCategory.CLINIC.value: {
"templateId": "tour-visual-tour", "templateId": "clinic-visual-clinic",
"fontStyle": "Visual Journey", "fontStyle": "Visual Journey",
"colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff", "colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff",
"card": "#f8fafc", "text": "#020617", "accent": "#0284c7"}, "card": "#f8fafc", "text": "#020617", "accent": "#4A9DC4"},
"sections": [ "sections": [
("hero", "히어로", True), ("intro", "소개", False), ("programs", "체험 프로그램", False), ("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False),
("info", "기본 정보", True), ("exhibition", "관람 및 갤러리 안내", False), ("photos", "사진 갤러리", False), ("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False),
("inquiry", "단체 및 출강 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False), ("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
("local", "주변 관광 코스", False), ("faq", "자주 묻는 질문", False), ("local", "주변 정보", False), ("faq", "자주 묻는 질문", False),
], ],
}, },
} }
@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
variant_id = _text(item.get("variantId")) variant_id = _text(item.get("variantId"))
if variant_id: if variant_id:
entry["variantId"] = variant_id entry["variantId"] = variant_id
# ★ 사장님이 에디터에 직접 쓴 섹션 본문. variantId 와 같은 이유로 그대로 싣는다 —
# 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서 통째로 버려졌다.
# 저장(sites.theme)은 되는데 발행본에는 안 나오고, 고유 콘텐츠로도 세지 않아
# "소개를 썼는데 발행이 고유 콘텐츠 0건으로 막힌다" 가 됐다.
# ★ fact 가 아니라 검증 대상이 아니다. 사장님이 자기 가게에 대해 쓴 자기 문장이고,
# 섹션 제목(name)이 이미 같은 경로로 나간다.
body = _text(item.get("body"))
if body:
entry["body"] = body
out.append(entry) out.append(entry)
for sid, label, locked in default_spec: for sid, label, locked in default_spec:

View File

@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name assert r["gate"]["reason"] == PublishRejectReason.NO_UNIQUE_CONTENT.name
async def test_owner_written_intro_counts_as_unique_content(auth_headers, client):
"""검증: fact·FAQ·사진 설명은 없지만 사장님이 소개 섹션 본문을 직접 썼다.
기대결과: 실제 발행본에 표시되는 소개문 1건으로 계수되어 발행된다."""
h = await auth_headers("owner-intro")
pid = await _place(client, h, "직접소개펜션")
for k, v in {**REQUIRED, "cancel_policy": "X"}.items():
await client.post(f"/v1/place/{pid}/fact", headers=h, json={"key": k, "value": v})
await client.post(f"/v1/place/{pid}/site/theme", headers=h, json={"theme": {"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "하조대의 아침 바다를 조용히 바라볼 수 있는 작은 숙소입니다.",
}]}})
job_id = (await client.post(
f"/v1/place/{pid}/site/build", headers=h, json={"publish": True}
)).json()["job_id"]
await _run()
r = (await client.get(f"/v1/job/{job_id}", headers=h)).json()["job"]["result"]
assert r["gate"]["passed"] is True
assert r["unique_content_count"] == 1
assert r["published"] is True
async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine): async def test_missing_required_field_warns_but_publishes(auth_headers, client, db_engine):
"""검증: 취소 규정 없이 빌드한다. """검증: 취소 규정 없이 빌드한다.

View File

@ -10,7 +10,7 @@ from common.enums import PlaceCategory
def test_all_categories_have_schema(): def test_all_categories_have_schema():
"""검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지. """검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지.
기대결과: 4개 업종(숙박·카페·음식점·관광체험)이 모두 로드되고 code 가 1:1로 맞는다.""" 기대결과: 4개 업종(숙박·카페·음식점·피부과·성형외과)이 모두 로드되고 code 가 1:1로 맞는다."""
schemas = all_schemas() schemas = all_schemas()
assert set(schemas) == {c.value for c in PlaceCategory} assert set(schemas) == {c.value for c in PlaceCategory}
for code, schema in schemas.items(): for code, schema in schemas.items():

View File

@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
assert check_unique_content(1).passed is True assert check_unique_content(1).passed is True
def test_unmeasured_unique_content_is_not_zero():
"""검증: 렌더러가 계수를 못 재고(None) 실패했다.
기대결과: 통과 — 디스크·번들 오류에 NO_UNIQUE_CONTENT 라는 엉뚱한 사유가 붙으면 안 된다."""
assert check_unique_content(None).passed is True
def test_jsonld_mismatch_blocks_publish(): def test_jsonld_mismatch_blocks_publish():
"""검증: 구조화 데이터 값이 화면 값과 다르다. """검증: 구조화 데이터 값이 화면 값과 다르다.
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다.""" 기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""

View File

@ -222,6 +222,18 @@ def test_owner_disabled_section_stays_disabled():
assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False assert next(s for s in theme["sections"] if s["id"] == "photos")["enabled"] is False
def test_owner_written_section_body_reaches_publish_payload():
"""검증: 에디터에서 직접 쓴 소개 본문.
기대결과: 발행 payload 에 그대로 남는다 — 저장만 되고 경계에서 버려지면 발행본과 계수에 못 쓴다."""
theme = _payload_theme({"sections": [{
"id": "intro", "name": "소개", "enabled": True, "locked": False,
"body": "바다를 보며 조용히 쉬어가는 작은 숙소입니다.",
}]})
assert next(s for s in theme["sections"] if s["id"] == "intro")["body"] == (
"바다를 보며 조용히 쉬어가는 작은 숙소입니다."
)
def test_partial_colors_are_filled_from_category_default(): def test_partial_colors_are_filled_from_category_default():
"""검증: 저장된 색이 일부 키만 담고 있을 때. """검증: 저장된 색이 일부 키만 담고 있을 때.
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로 기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
@ -255,15 +267,15 @@ def test_saved_theme_does_not_override_chosen_template():
# 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다. # 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다.
# 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다. # 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다.
_ADMIN_SECTIONS_TS = ( _ADMIN_SECTIONS_TS = (
pathlib.Path(__file__).resolve().parents[2] / "front/src/data/industryData.ts" # parents[2] = solution/ pathlib.Path(__file__).resolve().parents[2] / "frontend/src/data/industryData.ts" # parents[2] = solution/
) )
# 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(tour vs TOUR_ACTIVITY). # 에디터 업종 키 → PlaceCategory. 이름이 다른 건 두 층의 어휘가 달라서다(clinic vs CLINIC).
_INDUSTRY_TO_CATEGORY = { _INDUSTRY_TO_CATEGORY = {
"stay": PlaceCategory.LODGING.value, "stay": PlaceCategory.LODGING.value,
"cafe": PlaceCategory.CAFE.value, "cafe": PlaceCategory.CAFE.value,
"restaurant": PlaceCategory.RESTAURANT.value, "restaurant": PlaceCategory.RESTAURANT.value,
"tour": PlaceCategory.TOUR_ACTIVITY.value, "clinic": PlaceCategory.CLINIC.value,
} }

View File

@ -17,5 +17,5 @@ export const PlaceCategory = {
LODGING: 1, LODGING: 1,
CAFE: 2, CAFE: 2,
RESTAURANT: 3, RESTAURANT: 3,
TOUR_ACTIVITY: 4, CLINIC: 4,
} as const; } as const;

View File

@ -2,17 +2,13 @@ import {QueryClientProvider} from '@tanstack/react-query';
import {useEffect, type ReactNode} from 'react'; import {useEffect, type ReactNode} from 'react';
import {Toaster} from 'sonner'; import {Toaster} from 'sonner';
import {getAccessToken, me} from '@/api'; import {getAccessToken, me} from '@/api';
import {ensureAutoSession} from '@/lib/autoSession';
import {queryClient} from '@/lib/query-client'; import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /**
* 저장된 액세스 토큰으로 세션을 복구한다. 실패해도 앱은 뜬다 — * 저장된 액세스 토큰으로 세션을 복구한다.
* 로그인 화면·가입 화면은 로그인 없이 열려야 하므로, 인증 실패가 전체를 막으면 안 된다. * 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가
* * 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다.
* ★ 자동 로그인(VITE_AUTO_LOGIN_ID·PW)을 **여기서** 시도한다. 예전엔 빌더 화면 안에서
* 불렀는데, 빌더가 RequireAuth 뒤로 들어가면서 그 자리는 영영 실행되지 않는다 —
* 가드가 먼저 판단하고 로그인 화면으로 보내 버린다. 계정이 안 주입돼 있으면 즉시 끝난다.
*/ */
function useRestoreSession() { function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
@ -20,27 +16,24 @@ function useRestoreSession() {
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
void (async () => { if (!getAccessToken()) {
if (!getAccessToken()) await ensureAutoSession(); finishRestore();
if (!getAccessToken()) { return;
if (alive) finishRestore(); }
return; void me()
} .then((res) => {
// 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다. if (!alive || res.result?.success === false) return;
if (useAuthStore.getState().user) {
if (alive) finishRestore();
return;
}
try {
const res = await me();
// ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다. // ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다.
// 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다. // 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다.
if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res)); if (!res.user_id || !res.id) return;
} catch { setUser(toAuthUser(res));
})
.catch(() => {
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */ /* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
} })
if (alive) finishRestore(); .finally(() => {
})(); if (alive) finishRestore();
});
return () => { return () => {
alive = false; alive = false;
}; };

View File

@ -1,5 +1,4 @@
import {createBrowserRouter, Navigate} from 'react-router'; import {createBrowserRouter, Navigate} from 'react-router';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {BuilderPage} from '@/pages/BuilderPage'; import {BuilderPage} from '@/pages/BuilderPage';
import {DevShowcasePage} from '@/pages/DevShowcasePage'; import {DevShowcasePage} from '@/pages/DevShowcasePage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
@ -8,6 +7,7 @@ import {SignupPage} from '@/pages/SignupPage';
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{path: '/login', element: <LoginPage />}, {path: '/login', element: <LoginPage />},
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
{path: '/signup', element: <SignupPage />}, {path: '/signup', element: <SignupPage />},
// ★ 첫 화면은 업종 선택(위저드 1단계)이다. // ★ 첫 화면은 업종 선택(위저드 1단계)이다.
@ -17,26 +17,15 @@ export const router = createBrowserRouter([
{path: '/', element: <Navigate to="/builder?new=1" replace />}, {path: '/', element: <Navigate to="/builder?new=1" replace />},
/** /**
* ★ 빌더부터는 로그인한 사람만 들어온다. * 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
* 만들어 보기도 전에 막힌다.
* *
* 예전엔 가드 없이 열어 뒀다(만들어 보기 전에 막지 않으려고). 그런데 위저드 2단계부터 * 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
* 백엔드를 부르고, 만든 결과는 사업장·사이트로 **계정에 귀속**된다 — 로그인 없이 걸어온 * 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
* 사람은 3단계쯤에서 "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다.
* 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
* *
* 자동 로그인(VITE_AUTO_LOGIN_ID·PW)은 그대로 산다 — 다만 이제 화면 안이 아니라 * 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
* 부팅 때 붙는다(app/provider.tsx). 가드가 먼저 판단하므로 화면 안에서는 늦다.
*
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
*/ */
{ {path: '/builder', element: <BuilderPage />},
path: '/builder',
element: (
<RequireAuth>
<BuilderPage />
</RequireAuth>
),
},
/** /**
* ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다. * ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.

View File

@ -103,7 +103,7 @@ function templatesFor(
fontStyle: '옛 간판체', fontStyle: '옛 간판체',
look: LOOK.retro, look: LOOK.retro,
// 이 템플릿이 팔려는 게 바로 이 아이템들이다. // 이 템플릿이 팔려는 게 바로 이 아이템들이다.
defaultSectionTypes: ['songs', 'daily', 'course'], defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
}, },
]; ];
} }
@ -197,32 +197,32 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
}), }),
}, },
tour: { clinic: {
id: 'tour', id: 'clinic',
name: '관광 · 체험', name: '피부과 · 성형외과',
subName: '체험공간 · 전시', subName: '의원 · 클리닉',
description: '원데이 클래스, 도자기·목공 공방, 사설 갤러리, 팜스테이', description: '피부과, 성형외과, 미용 클리닉',
channels: [ channels: [
{ id: 'naver_place', name: '네이버 플레이스', checked: true }, { id: 'naver_place', name: '네이버 플레이스', checked: true },
{ id: 'frip', name: '프립 (체험 플랫폼)', checked: true }, { id: 'kakao_channel', name: '카카오톡 채널', checked: true },
{ id: 'instagram', name: '인스타그램', checked: true }, { id: 'instagram', name: '인스타그램', checked: true },
], ],
sections: [ sections: [
{ id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '대표 프로그램 비주얼 및 감성 슬로건' }, { id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '병원 대표 이미지와 진료 분야' },
{ id: 'intro', type: 'intro', name: '소개', isLocked: false, isEnabled: true, description: '스튜디오 역사와 작가진 소개' }, { id: 'intro', type: 'intro', name: '병원 소개', isLocked: false, isEnabled: true, description: '진료 철학과 의료진 소개' },
{ id: 'programs', type: 'programs', name: '체험 프로그램', isLocked: false, isEnabled: true, description: '물레 체험, 핸드빌딩, 키즈 클래스' }, { id: 'programs', type: 'programs', name: '시술 안내', isLocked: false, isEnabled: true, description: '시술명, 소요 시간, 비용' },
{ id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '운영시간, 입장료, 주차, 예약 방식' }, { id: 'info', type: 'info', name: '기본 정보', isLocked: true, isEnabled: true, description: '진료시간, 휴진일, 예약, 주차' },
{ id: 'exhibition', type: 'exhibition', name: '관람 및 갤러리 안내', isLocked: false, isEnabled: true, description: '전시 일정 및 도자기 아트숍' }, { id: 'exhibition', type: 'exhibition', name: '진료 안내', isLocked: false, isEnabled: true, description: '상담 절차와 보험 적용 안내' },
{ id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '체험 모습, 완성 작품, 공방 풍경' }, { id: 'photos', type: 'photos', name: '사진 갤러리', isLocked: false, isEnabled: true, description: '진료실, 상담실, 대기 공간' },
{ id: 'inquiry', type: 'inquiry', name: '단체 및 출강 문의', isLocked: false, isEnabled: true, description: '기업 워크숍, 학교 단체 체험 접수' }, { id: 'inquiry', type: 'inquiry', name: '상담 문의', isLocked: false, isEnabled: true, description: '방문·전화 상담 접수' },
{ id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '이천 예스파크 진입로 및 주차장' }, { id: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '역에서 오는 길과 주차장' },
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' }, { id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: false, description: '현재 기온과 주변 날씨' },
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' }, { id: 'local', type: 'local', name: '주변 정보', isLocked: false, isEnabled: false, description: '주변 편의시설' },
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' }, { id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '예약 변경, 회복 기간, 주의사항' },
], ],
templates: templatesFor('tour', '#7c3aed', { templates: templatesFor('clinic', '#4A9DC4', {
name: '시간여행', name: '클린',
description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.', description: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.',
}), }),
}, },
}; };

View File

@ -0,0 +1,37 @@
/**
* 에디터 앞의 로그인 관문.
*
* ★ 위저드(1~5단계)는 로그인을 요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다.
* 에디터부터는 편집한 것을 저장하고 발행해야 하는데 그게 전부 토큰을 쓴다. 토큰 없이 들여보내면
* 저장이 조용히 실패하고 사장님은 발행하고 나서야 안다.
* ★ /login 으로 튕기지 않는다. 위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없다.
*/
import {SignInForm} from './SignInForm';
export function EditorSignInGate({onBack}: {onBack: () => void}) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted/30 px-4">
<SignInForm
submitLabel="로그인하고 편집 시작"
header={
<div className="space-y-1 text-center">
<img src="/brand/web4ai-wordmark.svg" alt="Web4Ai" className="mx-auto mb-3 h-9 w-auto" />
<h1 className="text-sm font-bold">편집을 시작하려면 로그인해 주세요</h1>
<p className="text-xs leading-relaxed text-muted-foreground">
여기까지 만든 내용은 그대로 있습니다. 편집한 것을 저장하고 발행하는 데 계정이 필요합니다.
</p>
</div>
}
footer={
<button
type="button"
onClick={onBack}
className="w-full text-center text-[11px] text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
디자인 고르기로 돌아가기
</button>
}
/>
</div>
);
}

View File

@ -0,0 +1,95 @@
/**
* 로그인 폼 한 벌.
*
* ★ 로그인 화면과 에디터 진입 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는 순서
* (signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다.
*/
import {useState, type FormEvent, type ReactNode} from 'react';
import {LogIn} from 'lucide-react';
import {login} from '@/api';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {notifyApiError} from '@/lib/notify';
import {establishSession} from '@/lib/session';
interface SignInFormProps {
/** 폼 위에 붙는 제목·설명. 화면마다 하는 말이 다르다. */
header: ReactNode;
/** 폼 아래 각주(빌더로 돌아가기 등). */
footer?: ReactNode;
submitLabel?: string;
onSignedIn?: () => void;
}
export function SignInForm({header, footer, submitLabel = '로그인', onSignedIn}: SignInFormProps) {
const [id, setId] = useState('');
const [password, setPassword] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
try {
const res = await login({id, password});
if (res.result?.success === false) {
notifyApiError({data: res}, '아이디 또는 비밀번호를 확인해 주세요.');
return;
}
// 토큰 심는 순서(signIn → me)는 lib/session 한 곳에만 둔다 — 이 파일 맨 위 주석이
// 경고하던 그 중복이다. 로그인 화면·가입 화면·자동 로그인이 전부 같은 함수를 쓴다.
if (!(await establishSession(res, id))) {
notifyApiError({data: res}, '로그인 응답에 토큰이 없습니다.');
return;
}
onSignedIn?.();
} catch (error) {
notifyApiError(error, '로그인에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
return (
<form
onSubmit={handleSubmit}
className="w-full max-w-sm space-y-4 rounded-2xl border border-border bg-card p-7"
>
{header}
<div className="space-y-3">
<div>
<label htmlFor="login-id" className="mb-1.5 block text-xs font-semibold">
아이디
</label>
<Input
id="login-id"
value={id}
onChange={(e) => setId(e.target.value)}
autoComplete="username"
required
/>
</div>
<div>
<label htmlFor="login-pw" className="mb-1.5 block text-xs font-semibold">
비밀번호
</label>
<Input
id="login-pw"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
</div>
<Button type="submit" variant="primary" className="w-full" isLoading={isSubmitting}>
<LogIn />
<span>{submitLabel}</span>
</Button>
{footer}
</form>
);
}

View File

@ -55,6 +55,26 @@ export interface CourseItem {
source?: DataSource; source?: DataSource;
} }
export interface ScheduleSlot {
/** 24시간 표기 "09:30". 정렬·플립 시각 표시가 이 값을 그대로 쓴다. */
time: string;
title: string;
place?: string;
minutes?: number;
note?: string;
searchQuery?: string;
}
export interface ScheduleItem {
name: string;
/** 누구를 위한 하루인가("혼자 온 손님" · "아이와 함께"). 고르는 기준이 된다. */
audience?: string;
season?: string;
slots?: ScheduleSlot[];
verified?: DataVerified;
source?: DataSource;
}
export interface SectionDataSpec { export interface SectionDataSpec {
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */ /** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
kind: string; kind: string;
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
· minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다. · minutes 는 도보 기준이다. 차로만 갈 수 있으면 note 에 "차로 이동" 이라고 적는다.
· 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`, · 영업시간·요금은 넣지 않는다. 바뀌면 손님이 헛걸음한다.`,
}, },
schedule: {
kind: 'schedule',
label: '여행 스케줄',
requiredKey: 'name',
sample: JSON.stringify(
{
kind: 'schedule',
version: 1,
title: '여행 스케줄',
items: [
{
name: '비 오는 날의 하루',
audience: '혼자 온 손님',
season: '장마',
slots: [
{time: '09:00', title: '늦은 아침', place: '스테이 머뭄', minutes: 60, note: '창가 자리에서 비 소리를 먼저 듣습니다.'},
{time: '10:30', title: '실내로 피신', place: '군산근대역사박물관', minutes: 90, note: '항구 도시가 어떻게 만들어졌는지 한 바퀴.', searchQuery: '군산근대역사박물관'},
{time: '12:30', title: '점심', place: '한일옥', minutes: 60, note: '무국 한 그릇으로 몸을 데웁니다.', searchQuery: '군산 한일옥'},
{time: '14:00', title: '책과 커피', place: '마리서사', minutes: 120, note: '비 그칠 때까지 앉아 있기 좋은 곳입니다.', searchQuery: '군산 마리서사'},
{time: '17:00', title: '해 질 무렵 산책', place: '경암동 철길마을', minutes: 60, note: '비 온 뒤 철길에 물이 고여 하늘이 두 번 보입니다.', searchQuery: '군산 경암동 철길마을'},
],
verified: '확인필요',
source: {name: '군산시 문화관광', url: 'https://www.gunsan.go.kr/tour'},
},
],
},
null,
2,
),
task: `[해야 할 일]
[업소]에 묵거나 들른 손님이 [지역]에서 하루를 어떻게 보내면 좋을지 여행 스케줄을 2~3개 만든다.
스케줄마다 시간대 5~7개. 아침부터 저녁까지 시각 순서로 배열한다.
스케줄은 서로 성격이 달라야 한다 — 날씨(비 오는 날)·동행(아이와 함께)·계절 중 하나로 가른다.
[스키마]
{ "kind":"schedule", "version":1, "title":"여행 스케줄", "items":[
{ "name":"스케줄 이름", "audience":"누구를 위한 하루", "season":"계절·날씨",
"slots":[ {"time":"09:00","title":"무엇을 하나","place":"장소",
"minutes":60,"note":"한 문장","searchQuery":"지도 검색어"} ],
"verified":"확인|확인필요",
"source":{"name":"출처명","url":"https://..."} } ] }`,
rules: `
[이 아이템만의 규칙]
· time 은 24시간 "HH:MM" 로만 적는다. "오전 9시" 처럼 쓰지 않는다.
· 그 장소의 영업시간·휴무일을 안다고 가정하지 않는다. 바뀌면 손님이 헛걸음한다.
· 첫 칸은 [업소]에서 시작하고, 이동은 걸어서 또는 대중교통으로 갈 수 있는 범위로 짠다.
· 장소에 url 을 넣지 않는다. searchQuery 만 넣는다.
· 예약이 필요한 곳은 note 에 "예약 필요" 라고만 적고 연락처는 쓰지 않는다.`,
},
}; };
export function dataSpecFor(sectionType: string): SectionDataSpec | undefined { export function dataSpecFor(sectionType: string): SectionDataSpec | undefined {

View File

@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
import {SongsTurntable} from './variants/songs/SongsTurntable'; import {SongsTurntable} from './variants/songs/SongsTurntable';
import {DailyCalendar} from './variants/daily/DailyCalendar'; import {DailyCalendar} from './variants/daily/DailyCalendar';
import {CourseTickets} from './variants/course/CourseTickets'; import {CourseTickets} from './variants/course/CourseTickets';
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = { export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
hero: [ hero: [
@ -435,6 +436,17 @@ export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
}, },
], ],
schedule: [
{
id: 'schedule.timetable',
name: '대합실 시간표',
description: '칸 하나가 시간대 하나. 검은 플립보드에 시각이 먼저 뜬다.',
thumb: 'carousel',
Component: ScheduleTimetable,
isDefault: true,
},
],
exhibition: [ exhibition: [
{ {
id: 'exhibition.gallery', id: 'exhibition.gallery',

View File

@ -0,0 +1,174 @@
/**
* 여행 스케줄 — 대합실 시간표 캐러셀.
*
* 칸 하나가 시간대 하나다. 위쪽 검은 판은 역 대합실의 플립보드(가운데 접힘선)를 그대로 옮긴 것 —
* 시각이 먼저 읽히고 무엇을 하는지가 뒤따라야 시간표로 읽힌다.
* ★ 승차권(course)과 일부러 다르게 짰다. 저쪽은 '어디를 도는가'(순번), 여기는 '언제 무엇을'(시각)이다.
* ★ 링크는 만들지 않는다. searchQuery 만 보여준다 — 지어낸 주소를 링크하지 않는 이 레포의 규약.
*/
import {SectionBody, SectionFrame} from '../../primitives';
import type {SectionRenderProps} from '../../types';
import {parseSectionData, type ScheduleItem, type ScheduleSlot} from '../../dataSpec';
import {
CarouselNav,
ParseError,
PasteHint,
RETRO_BODY,
RETRO_INK,
RETRO_LINE,
RETRO_PAPER_LIGHT,
RETRO_RED,
RETRO_SIGN,
SourceLine,
useCarousel,
} from '../retro/common';
import '../retro/retro.css';
/** "09:30" → 9시 30분. 형식이 어긋나면 원문을 그대로 보여준다(지어내지 않는다). */
function splitTime(time: string): {head: string; tail?: string} {
const match = /^(\d{1,2}):(\d{2})$/.exec(time.trim());
if (!match) return {head: time.trim()};
return {head: match[1].padStart(2, '0'), tail: match[2]};
}
function Slot({slot, isLast}: {slot: ScheduleSlot; isLast: boolean}) {
const {head, tail} = splitTime(slot.time ?? '');
return (
<div className="flex shrink-0 snap-center items-stretch">
<div
className="w-[228px] border shadow-[4px_4px_0_rgba(27,26,21,.13)]"
style={{backgroundColor: RETRO_PAPER_LIGHT, borderColor: RETRO_INK}}
>
{/* 플립보드 — 가운데 접힘선이 이 판을 시계로 만든다 */}
<div className="relative px-4 py-3 text-center" style={{backgroundColor: RETRO_INK}}>
<span
className="inline-flex items-baseline gap-1 leading-none text-[#f2ebd9]"
style={{fontFamily: RETRO_SIGN, fontSize: 30}}
>
{head}
{tail && <span className="text-[#c0b493]">:{tail}</span>}
</span>
<i
aria-hidden
className="pointer-events-none absolute inset-x-0 top-1/2 border-t"
style={{borderColor: 'rgba(242,235,217,.22)'}}
/>
</div>
<div className="space-y-2 px-4 pb-3 pt-3.5">
<h4 className="text-base font-bold text-stone-900" style={{fontFamily: RETRO_BODY}}>
{slot.title}
</h4>
{slot.place && (
<p className="text-[12px] font-semibold" style={{fontFamily: RETRO_BODY, color: RETRO_RED}}>
{slot.place}
</p>
)}
{slot.note && (
<p className="text-[13px] leading-relaxed text-stone-600" style={{fontFamily: RETRO_BODY}}>
{slot.note}
</p>
)}
</div>
<div
className="flex justify-between gap-2 border-t border-dashed px-4 py-2 text-[10px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
<span>{slot.minutes ? `${slot.minutes}분 머묾` : '머무는 시간 미정'}</span>
{slot.searchQuery && <span className="truncate">지도 검색 · {slot.searchQuery}</span>}
</div>
</div>
{/* 칸과 칸 사이의 시간 — 점선이 이어져야 '흐른다'로 읽힌다 */}
{!isLast && (
<div aria-hidden className="flex w-8 items-center justify-center">
<i className="block h-px w-full border-t border-dashed" style={{borderColor: RETRO_LINE}} />
</div>
)}
</div>
);
}
function ScheduleRow({schedule}: {schedule: ScheduleItem}) {
const {ref, scrollBy} = useCarousel<HTMLDivElement>();
const slots = schedule.slots ?? [];
const span =
slots.length > 1 ? `${slots[0]?.time ?? ''}–${slots[slots.length - 1]?.time ?? ''}` : undefined;
return (
<div className="space-y-2.5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div className="flex flex-wrap items-baseline gap-2.5">
<h3 className="text-lg text-stone-900" style={{fontFamily: RETRO_SIGN}}>
{schedule.name}
</h3>
<span className="text-[11px] text-stone-500">
{[schedule.audience, schedule.season, span, `${slots.length}칸`]
.filter(Boolean)
.join(' · ')}
</span>
</div>
{slots.length > 2 && (
<CarouselNav onPrev={() => scrollBy(-1)} onNext={() => scrollBy(1)} label={schedule.name} />
)}
</div>
{slots.length === 0 ? (
<p
className="border border-dashed px-4 py-5 text-center text-[11px] text-stone-500"
style={{borderColor: RETRO_LINE}}
>
시간대가 아직 없습니다. JSON 의 slots 배열을 채워 주세요.
</p>
) : (
<div ref={ref} className="w4-scroll flex snap-x snap-mandatory overflow-x-auto pb-3">
{slots.map((slot, index) => (
<Slot
key={`${slot.time}-${slot.title}-${index}`}
slot={slot}
isLast={index === slots.length - 1}
/>
))}
</div>
)}
<SourceLine source={schedule.source} verified={schedule.verified} />
</div>
);
}
export function ScheduleTimetable(props: SectionRenderProps) {
const {section, isSelected, onSelect} = props;
const parsed = parseSectionData<ScheduleItem>(section.type, section.data);
return (
<SectionFrame section={section} isSelected={isSelected} onSelect={onSelect} tone="tint">
<SectionBody width="wide">
<div className="space-y-1.5">
<h2 className="text-2xl text-stone-900 sm:text-3xl" style={{fontFamily: RETRO_SIGN}}>
{parsed.title || section.name}
</h2>
{(parsed.subtitle || section.description) && (
<p className="text-sm text-stone-600" style={{fontFamily: RETRO_BODY}}>
{parsed.subtitle || section.description}
</p>
)}
</div>
{parsed.error ? (
<ParseError message={parsed.error} />
) : parsed.items.length === 0 ? (
<PasteHint label="여행 스케줄" />
) : (
<div className="space-y-8">
{parsed.items.map((schedule, index) => (
<ScheduleRow key={`${schedule.name}-${index}`} schedule={schedule} />
))}
</div>
)}
</SectionBody>
</SectionFrame>
);
}

View File

@ -29,11 +29,11 @@ const SOURCE = [
{industry: 'restaurant', id: 'food-sage', name: '세이지 다이닝', swatches: ['#F7E7DC', '#FFF8F3', '#758694', '#405D72']}, {industry: 'restaurant', id: 'food-sage', name: '세이지 다이닝', swatches: ['#F7E7DC', '#FFF8F3', '#758694', '#405D72']},
{industry: 'restaurant', id: 'food-night', name: '나이트 다이닝', swatches: ['#F5E8C7', '#DEBA9D', '#9E7777', '#6F4C5B']}, {industry: 'restaurant', id: 'food-night', name: '나이트 다이닝', swatches: ['#F5E8C7', '#DEBA9D', '#9E7777', '#6F4C5B']},
{industry: 'tour', id: 'tour-teal', name: '틸 앰버', swatches: ['#FFE2AF', '#E37434', '#24B1B1', '#007979']}, {industry: 'clinic', id: 'clinic-clean', name: '클린 블루', swatches: ['#F7FAFC', '#E3F0F7', '#4A9DC4', '#1E5F7A']},
{industry: 'tour', id: 'tour-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']}, {industry: 'clinic', id: 'clinic-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']},
{industry: 'tour', id: 'tour-spring', name: '스프링 필드', swatches: ['#F6F193', '#C5EBAA', '#A5DD9B', '#A0D683']}, {industry: 'clinic', id: 'clinic-sage', name: '세이지 그린', swatches: ['#F6F7F2', '#DCE5D5', '#9FB89A', '#5E7A61']},
{industry: 'tour', id: 'tour-sunset', name: '선셋 어드벤처', swatches: ['#FFEEA9', '#FFBF78', '#FF7D29', '#FF4E88']}, {industry: 'clinic', id: 'clinic-nude', name: '누드 베이지', swatches: ['#FBF6F0', '#EFE0D1', '#C9A88A', '#8C6A4F']},
{industry: 'tour', id: 'tour-violet', name: '바이올렛 플레이', swatches: ['#F3F8FF', '#E26EE5', '#7E30E1', '#49108B']}, {industry: 'clinic', id: 'clinic-mono', name: '모노 그레이', swatches: ['#FAFAFA', '#E5E5E5', '#8A8A8A', '#2B2B2B']},
] satisfies Array<{industry: IndustryType; id: string; name: string; swatches: string[]}>; ] satisfies Array<{industry: IndustryType; id: string; name: string; swatches: string[]}>;
export const COLOR_PALETTE_PRESETS: ColorPalettePreset[] = SOURCE.flatMap((preset) => { export const COLOR_PALETTE_PRESETS: ColorPalettePreset[] = SOURCE.flatMap((preset) => {

View File

@ -18,7 +18,7 @@ export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = {
[PlaceCategory.LODGING]: 'stay', [PlaceCategory.LODGING]: 'stay',
[PlaceCategory.CAFE]: 'cafe', [PlaceCategory.CAFE]: 'cafe',
[PlaceCategory.RESTAURANT]: 'restaurant', [PlaceCategory.RESTAURANT]: 'restaurant',
[PlaceCategory.TOUR_ACTIVITY]: 'tour', [PlaceCategory.CLINIC]: 'clinic',
}; };
/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */ /** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */

View File

@ -7,7 +7,7 @@ import {INDUSTRY_ICONS} from './industryIcons';
import {WizardFooter} from './WizardFooter'; import {WizardFooter} from './WizardFooter';
import {WizardSteps} from './WizardSteps'; import {WizardSteps} from './WizardSteps';
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'tour']; const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
export function Step1Industry() { export function Step1Industry() {
const industry = useBuilderStore((s) => s.industry); const industry = useBuilderStore((s) => s.industry);

View File

@ -1,7 +1,7 @@
import {useState} from 'react'; import {useState} from 'react';
import {useSearchParams} from 'react-router'; import {useSearchParams} from 'react-router';
import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react'; import {ArrowRight, Check, MapPin, Phone, Search, TriangleAlert} from 'lucide-react';
import type {PlaceCandidate} from '@/api'; import {getAccessToken, type PlaceCandidate} from '@/api';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
@ -83,6 +83,14 @@ export function Step2PlaceSearch() {
const runSearch = () => { const runSearch = () => {
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다 clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
setPickedIndex(null); setPickedIndex(null);
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
void search.search(storeName, location); void search.search(storeName, location);
}; };
@ -113,6 +121,12 @@ export function Step2PlaceSearch() {
const pickByUrl = async () => { const pickByUrl = async () => {
const url = placeUrl.trim(); const url = placeUrl.trim();
if (!url) return; if (!url) return;
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
if (!getAccessToken()) {
confirmIdentity(search.confirmManual(storeName, location));
goToStep(3);
return;
}
const identity = await search.confirmByUrl(url); const identity = await search.confirmByUrl(url);
if (!identity) return; if (!identity) return;
confirmIdentity(identity); confirmIdentity(identity);

View File

@ -1,4 +1,4 @@
import {Building2, Coffee, Compass, UtensilsCrossed} from 'lucide-react'; import {Building2, Coffee, Stethoscope, UtensilsCrossed} from 'lucide-react';
import type {IndustryType} from '@o2o/shared'; import type {IndustryType} from '@o2o/shared';
/** 업종 아이콘. lucide 하나로 통일한다 — 손으로 그린 SVG 세트를 따로 두지 않는다. */ /** 업종 아이콘. lucide 하나로 통일한다 — 손으로 그린 SVG 세트를 따로 두지 않는다. */
@ -6,5 +6,5 @@ export const INDUSTRY_ICONS: Record<IndustryType, typeof Building2> = {
stay: Building2, stay: Building2,
cafe: Coffee, cafe: Coffee,
restaurant: UtensilsCrossed, restaurant: UtensilsCrossed,
tour: Compass, clinic: Stethoscope,
}; };

View File

@ -20,7 +20,7 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
stay: PlaceCategoryEnum.LODGING, stay: PlaceCategoryEnum.LODGING,
cafe: PlaceCategoryEnum.CAFE, cafe: PlaceCategoryEnum.CAFE,
restaurant: PlaceCategoryEnum.RESTAURANT, restaurant: PlaceCategoryEnum.RESTAURANT,
tour: PlaceCategoryEnum.TOUR_ACTIVITY, clinic: PlaceCategoryEnum.CLINIC,
}; };
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */ /** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
@ -132,14 +132,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다. // 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
await ensureAutoSession(); await ensureAutoSession();
// ★ /builder 가 RequireAuth 뒤라 여기까지 왔으면 토큰이 있어야 한다. 없다면 위저드를 // ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
// 걷는 도중에 세션이 끊긴 것이다 — 그건 실제로 '만료' 이므로 그렇게 말한다. // 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
if (!getAccessToken()) { if (!getAccessToken()) {
setState({ setState({...INITIAL});
...INITIAL,
phase: 'unavailable',
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
});
return; return;
} }
@ -270,5 +266,15 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
* ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을 * ★ 동일 업소 검증(POST /verify)을 하지 않는다. 검증 없이 수집을 열면 남의 가게 URL 을
* 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다. * 긁을 수 있다. 그래서 이 경로는 수집 없이 직접 입력한 정보로만 사이트를 만든다.
*/ */
return {...state, isConfirming, search, confirm, confirmByUrl, reset}; const confirmManual = useCallback((name: string, address: string): ConfirmedIdentity => {
return {
placeId: placeIdRef.current,
name: name.trim(),
address: address.trim(),
origin: 'owner',
sourceLabel: '직접 입력',
};
}, []);
return {...state, isConfirming, search, confirm, confirmByUrl, confirmManual, reset};
} }

View File

@ -7,7 +7,7 @@ export const SCHEMA_TYPE_BY_INDUSTRY: Record<IndustryType, string> = {
stay: 'LodgingBusiness', stay: 'LodgingBusiness',
cafe: 'CafeOrCoffeeShop', cafe: 'CafeOrCoffeeShop',
restaurant: 'Restaurant', restaurant: 'Restaurant',
tour: 'TouristAttraction', clinic: 'MedicalClinic',
}; };
interface AeoItem { interface AeoItem {

View File

@ -0,0 +1,14 @@
import {useEffect} from 'react';
import {ensureAutoSession} from '@/lib/autoSession';
/**
* 화면이 열리자마자 세션을 확보한다.
*
* 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을
* 기다려야 하기 때문에, 훅 바깥에 두고 공유한다.
*/
export function useAutoLogin() {
useEffect(() => {
void ensureAutoSession();
}, []);
}

View File

@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react';
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react'; import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
import {Link, useSearchParams} from 'react-router'; import {Link, useSearchParams} from 'react-router';
import {SiteStatus} from '@o2o/shared'; import {SiteStatus} from '@o2o/shared';
import {getAccessToken} from '@/api';
import {AppShell} from '@/components/layout/AppShell'; import {AppShell} from '@/components/layout/AppShell';
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
import { import {
Step1Industry, Step1Industry,
Step2PlaceSearch, Step2PlaceSearch,
@ -11,9 +13,10 @@ import {
Step5Generating, Step5Generating,
} from '@/features/onboarding'; } from '@/features/onboarding';
import {EditorLayout} from '@/features/builder'; import {EditorLayout} from '@/features/builder';
import {useAutoLogin} from '@/hooks/useAutoLogin';
import {usePlaceSync} from '@/hooks/usePlaceSync'; import {usePlaceSync} from '@/hooks/usePlaceSync';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
import {userLabel, useAuthStore} from '@/stores/auth'; import {userLabel, useAuthStore} from '@/stores/auth';
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
/** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */ /** 발행 사이트 렌더러의 개발 서버. 프로덕션에서는 실제 발행 주소로 바뀐다. */
const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin; const SITE_PREVIEW_URL = import.meta.env.VITE_SITE_PREVIEW_URL ?? window.location.origin;
@ -32,12 +35,14 @@ function siteUrl(domain: string | null | undefined): string | null {
} }
export function BuilderPage() { export function BuilderPage() {
useAutoLogin();
/** /**
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`. * 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
* *
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: placeId 는 있을 수도 없을 수도 있는 * ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
* 선택값이다(새로 만들기 vs 사업장 열기). 쿼리스트링이면 라우트를 하나도 안 건드리고 * (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
* 두 경우를 같은 화면이 받는다. placeId 가 없으면 아래 훅은 네트워크를 타지 않는다. * 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
*/ */
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const urlPlaceId = searchParams.get('placeId'); const urlPlaceId = searchParams.get('placeId');
@ -84,6 +89,8 @@ export function BuilderPage() {
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다. // 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const signOut = useAuthStore((s) => s.signOut); const signOut = useAuthStore((s) => s.signOut);
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
const isSignedIn = Boolean(user) || Boolean(getAccessToken());
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도 // 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
// 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다. // 주소창에는 placeId 가 남아 있어서, 그걸 믿으면 데모를 실사업장이라고 표시한다.
const wiredPlaceId = useBuilderStore((s) => s.placeId); const wiredPlaceId = useBuilderStore((s) => s.placeId);
@ -120,6 +127,11 @@ export function BuilderPage() {
); );
} }
// 에디터에 들어갈 때 로그인을 받는다. 위저드(1~5단계)는 요구하지 않는다.
if (step === EDITOR_STEP && !isSignedIn) {
return <EditorSignInGate onBack={() => goToStep(4)} />;
}
if (step === EDITOR_STEP) { if (step === EDITOR_STEP) {
return ( return (
<div className="relative flex h-screen w-screen flex-col overflow-hidden"> <div className="relative flex h-screen w-screen flex-col overflow-hidden">

View File

@ -5,7 +5,7 @@
* SitePayload 는 "발행 잡이 구운 결과". 편집 상태는 저장 전까지 서버에 없다. * SitePayload 는 "발행 잡이 구운 결과". 편집 상태는 저장 전까지 서버에 없다.
*/ */
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'tour'; export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'clinic';
export type TemplateTone = 'photo' | 'info' | 'book'; export type TemplateTone = 'photo' | 'info' | 'book';
@ -152,7 +152,7 @@ export type SlateNode = SlateElementNode | SlateTextNode;
* 업종 고유 목록의 한 줄(객실 · 메뉴 · 코스 · 프로그램). * 업종 고유 목록의 한 줄(객실 · 메뉴 · 코스 · 프로그램).
* *
* 업종마다 채우는 칸이 달라 전부 선택값이다 — 없는 칸은 화면에서 그냥 빠진다. * 업종마다 채우는 칸이 달라 전부 선택값이다 — 없는 칸은 화면에서 그냥 빠진다.
* (숙박은 size/spec, 카페는 tag, 관광체험은 duration/target 을 쓴다.) * (숙박은 size/spec, 카페는 tag, 피부과·성형외과는 duration/target 을 쓴다.)
*/ */
export interface IndustryListItem { export interface IndustryListItem {
name: string; name: string;
@ -178,7 +178,7 @@ export interface IndustryCustomData {
menuList?: IndustryListItem[]; menuList?: IndustryListItem[];
/** 음식점 — 코스 목록 */ /** 음식점 — 코스 목록 */
courseList?: IndustryListItem[]; courseList?: IndustryListItem[];
/** 관광체험 — 프로그램 목록 */ /** 피부과·성형외과 — 시술 목록 */
programList?: IndustryListItem[]; programList?: IndustryListItem[];
/** 이용 규정 문장들 */ /** 이용 규정 문장들 */
rules?: string[]; rules?: string[];

View File

@ -12,7 +12,7 @@ export const PlaceCategory = {
LODGING: 1, LODGING: 1,
CAFE: 2, CAFE: 2,
RESTAURANT: 3, RESTAURANT: 3,
TOUR_ACTIVITY: 4, CLINIC: 4,
} as const; } as const;
export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory]; export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory];

View File

@ -249,4 +249,13 @@ export interface SectionSetting {
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다. * 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
*/ */
variantId?: string; variantId?: string;
/**
* 사장님이 에디터에서 직접 쓴 섹션 본문.
*
* ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
* 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
*/
body?: string;
} }

View File

@ -192,6 +192,24 @@ class VerifyError extends Error {
} }
} }
/**
* 고유 콘텐츠 0건. **VerifyError 와 갈라 둔다.**
*
* ★ 예전에는 이 사유를 VerifyError 의 mismatches 에 실어 던졌다. 백엔드 게이트는
* mismatches 가 비지 않았다는 것만 보고 JSONLD_MISMATCH 로 판정했고, 사장님 화면에는
* "구조화 데이터와 화면 값이 다릅니다" 라는 **틀린 문구**가 떴다 — 구조화 데이터는
* 멀쩡했다. 사유가 다르면 예외도 갈라야 라벨이 안 섞인다.
*/
class NoUniqueContentError extends Error {
/** 백엔드가 JSONLD_MISMATCH 로 오인하지 않도록 늘 비어 있다. */
readonly mismatches: string[] = [];
constructor(readonly uniqueContentCount: number) {
super('고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다');
this.name = 'NoUniqueContentError';
}
}
/** /**
* 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로 * 사이트는 **한 장**이다(2026-08-31). 예전에는 홈·객실·객실상세·주변·오시는길·FAQ 로
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다. * 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다. * 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
*/ */
if (uniqueContentCount <= 0) { if (uniqueContentCount <= 0) {
throw new VerifyError( throw new NoUniqueContentError(uniqueContentCount);
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
uniqueContentCount,
);
} }
writeFile(siteDir, 'index.html', html); writeFile(siteDir, 'index.html', html);
@ -377,6 +392,11 @@ function countUniqueContent(payload: SitePayload): number {
const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT; const long = (value: unknown) => String(value ?? '').trim().length >= MIN_UNIQUE_TEXT;
let count = 0; let count = 0;
// 소개 섹션의 직접 입력 본문은 실제 AboutSection 에 표시되는 가게 고유 문장이다.
// ★ enabled 를 함께 본다. 꺼서 페이지에 없는 문장까지 세면 빈 페이지가 게이트를 통과한다.
const intro = payload.theme.sections.find((section) => section.id === 'intro');
if (intro?.enabled && long(intro.body)) count += 1;
// 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다. // 문장형 fact 만 센다 — bool·number·time 은 전부 템플릿 값이라 가게를 구분하지 못한다.
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) { for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
if (fact.type === 'text' && long(fact.value)) count += 1; if (fact.type === 'text' && long(fact.value)) count += 1;
@ -585,11 +605,14 @@ function main() {
} catch (ex) { } catch (ex) {
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 — // ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다. // 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
fail( fail(
entry, entry,
ex instanceof Error ? ex.message : String(ex), ex instanceof Error ? ex.message : String(ex),
ex instanceof VerifyError ? ex.mismatches : [], counted?.mismatches ?? [],
ex instanceof VerifyError ? ex.uniqueContentCount : null, counted?.uniqueContentCount ?? null,
); );
} }
} }

View File

@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false; return payload.theme.sections.find((section) => section.id === id)?.enabled ?? false;
} }
/** 사장님이 에디터에서 직접 쓴 섹션 본문. 빈 줄을 문단 경계로 쓴다. */
export function sectionBody(payload: SitePayload, id: string): string[] {
const body = payload.theme.sections.find((section) => section.id === id)?.body;
return body?.split(/\n\s*\n/).map((paragraph) => paragraph.trim()).filter(Boolean) ?? [];
}
export function unitSpec(payload: SitePayload) { export function unitSpec(payload: SitePayload) {
return UNIT_SPEC[payload.place.category]; return UNIT_SPEC[payload.place.category];
} }
@ -201,7 +207,7 @@ export function ruleRows(payload: SitePayload): InfoRow[] {
/** /**
* 예약 안내에 실을 fact. * 예약 안내에 실을 fact.
* *
* 숙박에는 없고(예약은 채널이 받는다) 음식점·관광체험 스키마에만 있는 key 다. * 숙박에는 없고(예약은 채널이 받는다) 음식점·피부과·성형외과 스키마에만 있는 key 다.
* 값이 없는 업종에서는 그냥 빠진다. * 값이 없는 업종에서는 그냥 빠진다.
*/ */
const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const; const BOOKING_FACT_KEYS = ['reservation_required', 'reservation_channel'] as const;
@ -230,7 +236,7 @@ export function spaceRows(payload: SitePayload): InfoRow[] {
} }
/** /**
* 관람 안내에 실을 fact — 관광체험 스키마(tour_activity.json)의 관람 관련 key. * 안내에 실을 fact — 피부과·성형외과 스키마(clinic.json)의 안내 관련 key.
* *
* ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고, * ★ 준비물·안전 유의사항은 뺐다. 그건 "관람 안내"가 아니라 체험 전 주의사항이고,
* 이용 정보(info) 표에 이미 나간다. * 이용 정보(info) 표에 이미 나간다.

View File

@ -1,10 +1,14 @@
import {useSite} from '@/lib/site-context'; import {useSite} from '@/lib/site-context';
import {galleryImages} from '@/lib/derive'; import {galleryImages, sectionBody} from '@/lib/derive';
export function AboutSection() { export function AboutSection() {
const payload = useSite(); const payload = useSite();
const {narrative, place} = payload; const {narrative, place} = payload;
if (narrative.about.length === 0) return null; // 직접 입력한 소개가 있으면 그 문장이 이긴다. 없을 때만 수집·승인된 intro fact 로 떨어진다.
// ★ 에디터 본문을 계수만 하고 화면에 안 내면 빈 페이지가 발행 게이트를 통과한다.
const paragraphs = sectionBody(payload, 'intro');
const about = paragraphs.length > 0 ? paragraphs : narrative.about;
if (about.length === 0) return null;
// 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게. // 대표가 아닌 사진 중 첫 장. 히어로와 같은 사진이 두 번 나오지 않게.
const image = galleryImages(payload).find((m) => !m.isPrimary); const image = galleryImages(payload).find((m) => !m.isPrimary);
@ -46,7 +50,7 @@ export function AboutSection() {
</h2> </h2>
<div className="space-y-3 text-sm leading-relaxed opacity-80"> <div className="space-y-3 text-sm leading-relaxed opacity-80">
{narrative.about.map((paragraph, index) => ( {about.map((paragraph, index) => (
<p key={index}>{paragraph}</p> <p key={index}>{paragraph}</p>
))} ))}
</div> </div>

View File

@ -28,7 +28,7 @@ export const SCHEMA_TYPE: Record<PlaceCategory, string> = {
[PlaceCategory.LODGING]: 'LodgingBusiness', [PlaceCategory.LODGING]: 'LodgingBusiness',
[PlaceCategory.CAFE]: 'CafeOrCoffeeShop', [PlaceCategory.CAFE]: 'CafeOrCoffeeShop',
[PlaceCategory.RESTAURANT]: 'Restaurant', [PlaceCategory.RESTAURANT]: 'Restaurant',
[PlaceCategory.TOUR_ACTIVITY]: 'TouristAttraction', [PlaceCategory.CLINIC]: 'MedicalClinic',
}; };
/** 업종 → 하위 단위의 Schema.org 타입과 URL 경로. */ /** 업종 → 하위 단위의 Schema.org 타입과 URL 경로. */
@ -36,7 +36,7 @@ export const UNIT_SPEC: Record<PlaceCategory, {type: string; path: string; label
[PlaceCategory.LODGING]: {type: 'HotelRoom', path: 'rooms', label: '객실'}, [PlaceCategory.LODGING]: {type: 'HotelRoom', path: 'rooms', label: '객실'},
[PlaceCategory.CAFE]: {type: 'MenuItem', path: 'menu', label: '메뉴'}, [PlaceCategory.CAFE]: {type: 'MenuItem', path: 'menu', label: '메뉴'},
[PlaceCategory.RESTAURANT]: {type: 'MenuItem', path: 'menu', label: '메뉴'}, [PlaceCategory.RESTAURANT]: {type: 'MenuItem', path: 'menu', label: '메뉴'},
[PlaceCategory.TOUR_ACTIVITY]: {type: 'Product', path: 'programs', label: '프로그램'}, [PlaceCategory.CLINIC]: {type: 'Product', path: 'programs', label: '시술'},
}; };
export type Json = Record<string, unknown>; export type Json = Record<string, unknown>;