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:
commit
01c252287e
@ -1,29 +1,30 @@
|
||||
# ★ 빌드 컨텍스트가 레포 루트다(solution/backend/Dockerfile 주석 참조).
|
||||
# 컨텍스트가 넓어진 만큼 여기서 확실히 잘라내야 이미지가 붓지 않는다.
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/.pytest_cache/
|
||||
# 빌드 컨텍스트는 레포 루트고, 백엔드 이미지와 solution-site 이미지가 이것을 **같이** 쓴다.
|
||||
# ★ nginx/Dockerfile.dockerignore 는 BuildKit 전용이다. 이 머신엔 buildx 가 없어 레거시
|
||||
# 빌더가 돌고, 그러면 이 파일만 읽힌다 — 여기서 프론트 소스를 자르면 nginx 빌드가 죽는다.
|
||||
# (백엔드 이미지는 COPY 대상이 solution/backend·admin/backend 뿐이라 영향이 없다.)
|
||||
.git/
|
||||
.venv/
|
||||
**/.venv/
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
**/dist/
|
||||
**/.vite/
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
**/.pytest_cache/
|
||||
.venv/
|
||||
**/.venv/
|
||||
|
||||
# 발행 산출물은 볼륨에서 온다. 이미지에 구우면 사이트가 늘 때마다 이미지가 붓는다.
|
||||
solution/site/out/
|
||||
solution/site/payloads/
|
||||
|
||||
# 프론트·문서는 백엔드 이미지에 들어갈 이유가 없다.
|
||||
solution/frontend/
|
||||
solution/site/
|
||||
solution/shared/
|
||||
admin/frontend/
|
||||
docs/
|
||||
nginx/
|
||||
postgres-init/
|
||||
**/*.md
|
||||
solution/backend/tests/
|
||||
solution/backend/loadtest/
|
||||
|
||||
# 시크릿 — 이미지에 굽지 않는다. Dockerfile 이 example 을 복사해 넣고 실값은 compose env 로 준다.
|
||||
# 시크릿 — 이미지에 굽지 않는다. 백엔드는 example 사본 + compose env, 프론트는 build args.
|
||||
**/config.local.toml
|
||||
**/config.test.toml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
@ -43,8 +43,6 @@
|
||||
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
|
||||
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
|
||||
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
|
||||
- **`/builder` 는 로그인 뒤에 있다.** 자동 로그인(`AUTO_LOGIN_ID`·`PW`)은 화면 안이 아니라
|
||||
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안에서 부르면 늦다.
|
||||
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
|
||||
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
|
||||
CDN 을 앞에 세워야 한다. 아니면 비워라.
|
||||
|
||||
@ -24,7 +24,7 @@ const CATEGORY_LABEL: Record<number, string> = {
|
||||
[PlaceCategory.LODGING]: '숙박',
|
||||
[PlaceCategory.CAFE]: '카페',
|
||||
[PlaceCategory.RESTAURANT]: '음식점',
|
||||
[PlaceCategory.TOUR_ACTIVITY]: '관광체험',
|
||||
[PlaceCategory.CLINIC]: '피부과·성형외과',
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<number, string> = {
|
||||
|
||||
@ -137,12 +137,13 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
|
||||
`UserRole.DEVELOPER` 주석의 **"고객사에 존재를 노출하지 않는다"** 를 번들이 깨고 있었다.
|
||||
라우트 가드는 화면을 가리지 **번들은 못 가린다.**
|
||||
★ 이 문제는 **코드 크기와 무관하다.** 내부 화면이 814줄뿐이어도 내려가는 건 같다.
|
||||
2. **인증 모델이 갈라진다.** 둘 다 `RequireAuth` 뒤로 들어갔지만(2026-09-02 빌더 포함)
|
||||
**계정이 생기는 방식**이 다르다 — 사장님은 스스로 가입하고 구글로도 들어오는 반면,
|
||||
내부 운영 계정은 우리가 만들고 role >= DEVELOPER 여야 한다. 한 앱에서 두 정책을 유지하면
|
||||
실수는 늘 **느슨한 쪽으로** 난다.
|
||||
→ 지금은 `LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다
|
||||
(사장님: 가입 링크 + 구글 버튼 / 내부: 둘 다 없음).
|
||||
2. **인증 모델이 갈라진다.** 빌더는 위저드를 열어 두고 **에디터 진입에서 한 번** 받는다
|
||||
("만들어 보기도 전에 막힌다"). 내부 화면은 전부 `RequireAuth` 뒤다. 계정이 생기는 방식도
|
||||
다르다 — 사장님은 스스로 가입하고 구글로도 들어오지만, 내부 운영 계정은 우리가 만들고
|
||||
role >= DEVELOPER 여야 한다(`LoginPage` 의 `selfServe` 플래그가 그 차이를 한 곳에서 드러낸다).
|
||||
한 앱에서 두 정책을 유지하면 실수는 늘 **느슨한 쪽으로** 난다.
|
||||
→ 지금은 두 `provider.tsx` 가 그 차이를 각자 명시한다(사장님: 인증 실패를 삼킨다 /
|
||||
내부: 실패가 곧 차단).
|
||||
3. **배포 리듬이 다르다.** 내부 화면을 고치려고 사장님 화면을 재배포하지 않는다.
|
||||
|
||||
### 백엔드 — 코드 한 벌, 진입점 둘
|
||||
@ -180,8 +181,8 @@ OWNER role=2 → 403
|
||||
|
||||
OWNER 가 막히는 게 핵심이다 — 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니다.
|
||||
`auth` 라우터만 게이트 밖이다(로그인 자체를 막으면 아무도 못 들어온다). `signup`·`google` 도
|
||||
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고,
|
||||
자기 회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
|
||||
같은 이유로 토큰 없이 열려 있다 — **여기서 만들어지는 계정은 언제나 `role=USER` 이고 자기
|
||||
회사(새 테넌트) 하나만 본다.** 권한이 올라가는 경로는 이 문 뒤에 없다.
|
||||
|
||||
⚠️ **`/v1/admin/local-content` 는 아직 :9800 에도 마운트돼 있다**(`router/router.py`).
|
||||
위 논리대로라면 이 라우터는 :9801 에만 있어야 한다. 지금은 엔드포인트별 `RequireOwner` 가
|
||||
@ -226,12 +227,11 @@ admin 자기 파일만 `@admin` 이다.
|
||||
|
||||
### 아직 안 한 것
|
||||
|
||||
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 빌더는 2026-09-02 에 로그인 뒤로
|
||||
들어갔고, 로그인 후 도착지는 아직 `/builder?new=1`(새로 만들기) 하나뿐이다.
|
||||
- 사장님 **"내 사이트 관리"** 화면(내 사업장 목록). 로그인 후 도착지가 아직
|
||||
`/builder?new=1`(새로 만들기) 하나뿐이다.
|
||||
- **계정 연결** — 같은 사람의 id/pw 계정과 구글 계정을 잇는 경로. 지금은 잇지 않고
|
||||
거절한다 → [DECISIONS.md 1-5](DECISIONS.md)
|
||||
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다. 그래서
|
||||
비밀번호를 잊으면 운영자가 손으로 바꿔 주는 수밖에 없다.
|
||||
- **비밀번호 재설정** — 이메일을 받아 두지만 소유 증명(인증 메일) 절차가 없다.
|
||||
- 운영 배포에서 `admin`(:3002)을 내부망에만 여는 것. compose 는 `ADMIN_BIND` 기본값을
|
||||
`127.0.0.1` 로 두었다. **0.0.0.0 으로 열면 앱을 가른 의미가 없다.**
|
||||
- **폰트 self-host** — `solution/site/public/fonts/PretendardVariable.woff2` 가 없어 Noto Sans KR 로
|
||||
|
||||
@ -3,45 +3,28 @@
|
||||
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
||||
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
||||
|
||||
## 2026-09-02 — 로그인을 붙이고, 빌더를 그 뒤로 넣었다
|
||||
---
|
||||
|
||||
## 2026-09-02 — 회원가입과 구글 로그인
|
||||
|
||||
**한 일**
|
||||
- id/pw **회원가입**(`POST /v1/auth/signup`) 과 **구글 로그인**(`POST /v1/auth/google`) 추가.
|
||||
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub) 추가. `password` 는
|
||||
NULL 허용, `id` 는 20 → 64자.
|
||||
- `/builder` 를 `RequireAuth` 뒤로 넣었다. 자동 로그인은 화면 안(`useAutoLogin`) 이 아니라
|
||||
부팅(`app/provider.tsx`)에서 붙는다 — 가드가 먼저 판단하므로 화면 안은 이제 실행되지 않는다.
|
||||
- 로그인 화면에 구글 버튼 + 가입 링크. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
|
||||
- `POST /v1/auth/signup`(id/pw) · `POST /v1/auth/google` 추가. 로그인 화면에 구글 버튼과
|
||||
가입 링크, `/signup` 화면. 내부 운영 화면은 `selfServe={false}` 로 둘 다 안 뜬다.
|
||||
- `company.users` 에 `provider`(AuthProvider) · `provider_uid`(구글 sub). `password` 는 NULL
|
||||
허용(소셜 계정), `id` 는 20 → 64자(`google_<sub>` 가 20자를 넘는다).
|
||||
- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가
|
||||
들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.**
|
||||
|
||||
**왜 빌더를 막았나**
|
||||
빌더는 "만들어 보기 전에 막지 않으려고" 열려 있었다. 그런데 위저드 2단계부터 백엔드를 부르고,
|
||||
만든 결과는 사업장·사이트로 **계정에 귀속**된다. 로그인 없이 걸어온 사람은 3단계쯤에서
|
||||
"로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다 — 만료가 아니라 처음부터 세션이
|
||||
없었던 것이다. 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
|
||||
|
||||
**관문이 두 개였다 — 문 앞 하나로 합쳤다**
|
||||
같은 날 두 자리에서 같은 문제를 풀었다. 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개**
|
||||
**왜 가입부터 만들었나**
|
||||
계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데
|
||||
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**
|
||||
로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다.
|
||||
|
||||
**로그인 관문은 에디터 진입 그대로다**
|
||||
한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이
|
||||
`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다.
|
||||
관문은 `EditorSignInGate`(969fb67) 한 자리다.
|
||||
|
||||
**밟기 쉬운 자리**
|
||||
- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의
|
||||
수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로
|
||||
@ -52,15 +35,33 @@ Step2 의 토큰 없을 때 우회로. 되돌리려면 `app/router.tsx` 의 `Req
|
||||
None 을 만나 500 이 난다.
|
||||
- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
|
||||
NOT NULL 컬럼이면 그 경로가 통째로 깨진다.
|
||||
- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라,
|
||||
기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다).
|
||||
|
||||
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다 — 말미의
|
||||
"기존 DB 보정(ALTER)" 섹션이 새 컬럼을 채우고 `id` 를 넓힌다. 안 하면 로그인부터 500 이다.
|
||||
**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다.
|
||||
|
||||
**검증** — 백엔드 `pytest` auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·
|
||||
`email_verified` 거절 확인), 프론트 `tsc` · `eslint` · `vite build` 통과.
|
||||
**아직 못 한 것** — 실제 구글 계정 로그인. `GOOGLE_CLIENT_ID` 가 있어야 버튼이 뜬다.
|
||||
버튼 렌더까지는 확인했다(빌려온 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개)
|
||||
|
||||
**왜**
|
||||
|
||||
@ -91,7 +91,7 @@ CREATE TABLE IF NOT EXISTS place.places (
|
||||
company_id uuid NOT NULL, -- 테넌트(company.companies.company_id)
|
||||
owner_user_id uuid NULL, -- 사장님 계정(company.users.user_id)
|
||||
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
|
||||
external_source SMALLINT NULL, -- 검증에 쓴 외부 장소 DB(ExternalPlaceSource): 1=kakao 2=naver
|
||||
external_place_id VARCHAR(64) NULL, -- 외부 고유 장소 id — ★ 카카오는 주고 네이버는 안 준다
|
||||
|
||||
@ -39,7 +39,7 @@ common/category_schema/resources/
|
||||
├── lodging.json 숙박 필드 30 (critical 14)
|
||||
├── cafe.json 카페 필드 23 (critical 12)
|
||||
├── restaurant.json 음식점 필드 24 (critical 15)
|
||||
└── tour_activity.json 관광체험 필드 24 (critical 17)
|
||||
└── clinic.json 피부과·성형외과 필드 24 (critical 17)
|
||||
```
|
||||
|
||||
**업종 추가 = JSON 파일 1개 + `PlaceCategory` 코드 1줄.** 로직은 건드리지 않는다.
|
||||
|
||||
@ -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 }
|
||||
]
|
||||
}
|
||||
@ -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 }
|
||||
]
|
||||
}
|
||||
@ -169,7 +169,7 @@ class place_links(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class units(MainTableMixin, MAIN_BASE):
|
||||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 관광체험=프로그램.
|
||||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
|
||||
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
|
||||
|
||||
__tablename__ = "units"
|
||||
|
||||
@ -181,7 +181,7 @@ class PlaceCategory(CodeEnum):
|
||||
LODGING = 1 # 숙박
|
||||
CAFE = 2 # 카페
|
||||
RESTAURANT = 3 # 음식점
|
||||
TOUR_ACTIVITY = 4 # 관광체험
|
||||
CLINIC = 4 # 피부과·성형외과
|
||||
|
||||
|
||||
class ExternalPlaceSource(CodeEnum):
|
||||
|
||||
@ -201,6 +201,12 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
return len(str(value or "").strip()) >= MIN_UNIQUE_TEXT
|
||||
|
||||
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 [])
|
||||
for unit in payload.get("units") or []:
|
||||
facts.extend(unit.get("facts") or [])
|
||||
|
||||
@ -88,8 +88,8 @@ class Req_SiteTheme(SiteProtocol):
|
||||
|
||||
★ 그래서 필드를 펼치지 않고 dict 하나로 받는다. 계약은 이렇다:
|
||||
{"theme": {"colors": {...}, "fontStyle": "...", "colorPaletteId": "...", "sections": [...]}}
|
||||
sections 는 {id, name, enabled, locked, variantId?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 는 고른 게 있을 때만 키가 붙는다.
|
||||
sections 는 {id, name, enabled, locked, variantId?, body?} 의 목록이고 **배열 순서가 곧 섹션 순서**다
|
||||
(별도 order 필드가 없다). variantId 와 직접 입력 본문 body 는 값이 있을 때만 키가 붙는다.
|
||||
pydantic 으로 모양을 고정하면 프론트가 항목을 추가한 순간 백엔드가 그걸 조용히 떨어뜨린다 —
|
||||
서버는 배달부지 심판이 아니다.
|
||||
|
||||
|
||||
@ -106,7 +106,7 @@ async def set_template(
|
||||
description="에디터가 정한 색·서체·섹션(순서·on/off·배리에이션)을 sites.theme 에 저장한다"
|
||||
"(사이트 행이 없으면 만든다). body 최상위 키는 theme 하나다: "
|
||||
"{\"theme\":{\"colors\":{...},\"fontStyle\":\"...\",\"colorPaletteId\":\"...\","
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\"}]}}. "
|
||||
"\"sections\":[{\"id\",\"name\",\"enabled\",\"locked\",\"variantId\",\"body\"}]}}. "
|
||||
"★ sections 의 배열 순서가 곧 섹션 순서다(별도 order 필드 없음). "
|
||||
"★ 서버는 값을 해석하지 않는다 — 섹션 목록·배리에이션 키·색 토큰은 프론트가 소유한다. "
|
||||
"직렬화 크기(64KB)만 막는다. "
|
||||
|
||||
@ -200,7 +200,10 @@ async def run_build(job: dict) -> dict:
|
||||
)
|
||||
|
||||
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 []
|
||||
result["unique_content_count"] = unique_count
|
||||
result["mismatches"] = mismatches[:20]
|
||||
@ -211,14 +214,25 @@ async def run_build(job: dict) -> dict:
|
||||
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
|
||||
# 여기서 사유를 정확히 골라야 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()}
|
||||
|
||||
if not gate.passed:
|
||||
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
|
||||
|
||||
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)
|
||||
|
||||
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
|
||||
|
||||
@ -29,7 +29,7 @@ _CATEGORY_BY_NAME = {
|
||||
"lodging": PlaceCategory.LODGING,
|
||||
"cafe": PlaceCategory.CAFE,
|
||||
"restaurant": PlaceCategory.RESTAURANT,
|
||||
"tour_activity": PlaceCategory.TOUR_ACTIVITY,
|
||||
"clinic": PlaceCategory.CLINIC,
|
||||
}
|
||||
|
||||
_CHANNEL_BY_NAME = {c.name.lower(): c for c in LinkChannel}
|
||||
@ -86,16 +86,18 @@ _PLACE_FACTS = {
|
||||
("signature_menu", "한우 물회"),
|
||||
("price_range", "15,000~30,000원"),
|
||||
],
|
||||
PlaceCategory.TOUR_ACTIVITY: [
|
||||
("operating_hours", "09:00~18:00"),
|
||||
("closed_days", "매주 수요일"),
|
||||
PlaceCategory.CLINIC: [
|
||||
("operating_hours", "평일 10:00~19:00, 토 10:00~14:00"),
|
||||
("closed_days", "일요일·공휴일"),
|
||||
("specialties", "피부과, 성형외과"),
|
||||
("medical_staff", "피부과 전문의 2인, 성형외과 전문의 1인"),
|
||||
("reservation_required", "true"),
|
||||
("reservation_channel", "네이버 예약"),
|
||||
("cancel_policy", "체험 3일 전까지 100% 환불, 당일 취소 불가"),
|
||||
("age_limit", "만 7세 이상"),
|
||||
("safety_notice", "구명조끼 착용 필수, 음주 후 참여 불가"),
|
||||
("weather_dependent", "우천 시 당일 오전 8시에 개별 안내"),
|
||||
("what_to_bring", "여벌 옷, 수건"),
|
||||
("reservation_channel", "네이버 예약, 전화"),
|
||||
("consultation_fee", "초진 상담료 없음"),
|
||||
("insurance_covered", "질환 치료는 보험 적용, 미용 시술은 비급여"),
|
||||
("cancel_policy", "예약 1일 전까지 변경 가능, 당일 취소는 재예약 제한"),
|
||||
("night_clinic", "목요일 21:00까지"),
|
||||
("weekend_clinic", "토요일 14:00까지"),
|
||||
("parking", "true"),
|
||||
],
|
||||
}
|
||||
@ -130,19 +132,21 @@ _UNIT_FACTS = {
|
||||
"한우 물회": [("menu_name", "한우 물회"), ("menu_price", "18000")],
|
||||
"성게 비빔밥": [("menu_name", "성게 비빔밥"), ("menu_price", "22000"), ("menu_min_order", "2")],
|
||||
},
|
||||
PlaceCategory.TOUR_ACTIVITY: {
|
||||
"서핑 입문 클래스": [
|
||||
("program_name", "서핑 입문 클래스"),
|
||||
("program_duration", "120"),
|
||||
("program_capacity", "8"),
|
||||
("price_adult", "70000"),
|
||||
("price_child", "50000"),
|
||||
PlaceCategory.CLINIC: {
|
||||
"레이저 토닝": [
|
||||
("program_name", "레이저 토닝"),
|
||||
("program_duration", "30"),
|
||||
("price_adult", "100000"),
|
||||
("anesthesia", "마취 없음"),
|
||||
("downtime", "당일 일상생활 가능"),
|
||||
("session_count", "5회 권장"),
|
||||
],
|
||||
"패들보드 체험": [
|
||||
("program_name", "패들보드 체험"),
|
||||
("program_duration", "90"),
|
||||
("program_capacity", "6"),
|
||||
("price_adult", "55000"),
|
||||
"보톡스": [
|
||||
("program_name", "보톡스"),
|
||||
("program_duration", "20"),
|
||||
("price_adult", "150000"),
|
||||
("anesthesia", "연고 마취"),
|
||||
("downtime", "1~2일 미세 부기"),
|
||||
],
|
||||
},
|
||||
}
|
||||
@ -168,11 +172,11 @@ _MEDIA = {
|
||||
("한우 물회", "한우 물회"),
|
||||
("성게 비빔밥", "성게 비빔밥"),
|
||||
],
|
||||
PlaceCategory.TOUR_ACTIVITY: [
|
||||
("해변 전경", None),
|
||||
("장비 보관소", None),
|
||||
("서핑 강습", "서핑 입문 클래스"),
|
||||
("패들보드", "패들보드 체험"),
|
||||
PlaceCategory.CLINIC: [
|
||||
("병원 외관", None),
|
||||
("상담실", None),
|
||||
("레이저 장비", "레이저 토닝"),
|
||||
("시술실", "보톡스"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -38,7 +38,7 @@ _CATEGORY_LABEL = {
|
||||
PlaceCategory.LODGING: "숙박업소",
|
||||
PlaceCategory.CAFE: "카페",
|
||||
PlaceCategory.RESTAURANT: "음식점",
|
||||
PlaceCategory.TOUR_ACTIVITY: "관광·체험 시설",
|
||||
PlaceCategory.CLINIC: "관광·체험 시설",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ _LABELS = {
|
||||
PlaceCategory.LODGING: ["외관", "침실", "거실", "욕실", "주방", "수영장", "바비큐장", "주차장", "전망", "부대시설"],
|
||||
PlaceCategory.CAFE: ["외관", "내부", "좌석", "메뉴", "디저트", "음료", "테라스", "주차장"],
|
||||
PlaceCategory.RESTAURANT: ["외관", "내부", "좌석", "메뉴", "음식", "룸", "주차장"],
|
||||
PlaceCategory.TOUR_ACTIVITY: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"],
|
||||
PlaceCategory.CLINIC: ["외관", "시설", "장비", "활동", "안전장비", "주차장", "매표소"],
|
||||
}
|
||||
_DEFAULT_LABELS = ["외관", "내부", "시설", "기타"]
|
||||
|
||||
|
||||
@ -63,11 +63,20 @@ def check_required_fields(category: PlaceCategory, facts: list) -> GateResult:
|
||||
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이면
|
||||
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다."""
|
||||
같은 템플릿 대량 생성으로 보인다. 그건 스팸 판정 대상이고, 판정되면 사이트가 통째로 무의미해진다.
|
||||
|
||||
★ None 은 '0건' 이 아니라 '재지 못했다' 다. 렌더러가 디스크·번들 문제로 죽으면 계수가
|
||||
없는 채로 보고서가 온다 — 그걸 0 으로 읽으면 디스크 오류에 NO_UNIQUE_CONTENT 라는
|
||||
엉뚱한 사유가 붙는다. 판정하지 않고 통과시키고, 진짜 사유는 report.error 가 말한다."""
|
||||
if unique_content_count is None:
|
||||
return GateResult(True)
|
||||
if unique_content_count <= 0:
|
||||
return GateResult(False, PublishRejectReason.NO_UNIQUE_CONTENT, {"unique_content_count": unique_content_count})
|
||||
return GateResult(True)
|
||||
@ -83,11 +92,17 @@ def check_jsonld_matches(mismatches: list) -> GateResult:
|
||||
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 불일치(거짓).
|
||||
|
||||
★ unique_content_count 는 받되 여기서 막지 않는다 — 얇은 콘텐츠는 '틀린 것' 이 아니다
|
||||
(test_evaluate_does_not_block_thin_content). 실제로 페이지 쓰기를 거부하는 쪽은
|
||||
렌더러이고, 그 사유는 build_service 가 check_unique_content 로 되짚어 라벨을 붙인다.
|
||||
|
||||
★ 업종 필수 항목 누락은 **막지 않는다**(2026-08-27 결정).
|
||||
막아야 할 것은 "틀린 정보가 나가는 것"이지 "정보가 덜 찬 것"이 아니다.
|
||||
영업시간이 비어 있어도 주소·전화가 확인된 페이지는 그 자체로 쓸모가 있고,
|
||||
|
||||
@ -108,16 +108,16 @@ _DEFAULT_THEME = {
|
||||
("local", "주변 안내", False), ("faq", "자주 묻는 질문", False),
|
||||
],
|
||||
},
|
||||
PlaceCategory.TOUR_ACTIVITY.value: {
|
||||
"templateId": "tour-visual-tour",
|
||||
PlaceCategory.CLINIC.value: {
|
||||
"templateId": "clinic-visual-clinic",
|
||||
"fontStyle": "Visual Journey",
|
||||
"colors": {"primary": "#0f172a", "secondary": "#475569", "bg": "#ffffff",
|
||||
"card": "#f8fafc", "text": "#020617", "accent": "#0284c7"},
|
||||
"card": "#f8fafc", "text": "#020617", "accent": "#4A9DC4"},
|
||||
"sections": [
|
||||
("hero", "히어로", True), ("intro", "소개", False), ("programs", "체험 프로그램", False),
|
||||
("info", "기본 정보", True), ("exhibition", "관람 및 갤러리 안내", False), ("photos", "사진 갤러리", False),
|
||||
("inquiry", "단체 및 출강 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
||||
("local", "주변 관광 코스", False), ("faq", "자주 묻는 질문", False),
|
||||
("hero", "히어로", True), ("intro", "병원 소개", False), ("programs", "시술 안내", False),
|
||||
("info", "기본 정보", True), ("exhibition", "진료 안내", False), ("photos", "사진 갤러리", False),
|
||||
("inquiry", "상담 문의", False), ("map", "오시는 길", True), ("weather", "날씨", False),
|
||||
("local", "주변 정보", False), ("faq", "자주 묻는 질문", False),
|
||||
],
|
||||
},
|
||||
}
|
||||
@ -363,6 +363,15 @@ def _sections(saved_sections, default_spec) -> list:
|
||||
variant_id = _text(item.get("variantId"))
|
||||
if 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)
|
||||
|
||||
for sid, label, locked in default_spec:
|
||||
|
||||
@ -136,6 +136,29 @@ async def test_no_unique_content_blocks_publish(auth_headers, client):
|
||||
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):
|
||||
"""검증: 취소 규정 없이 빌드한다.
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ from common.enums import PlaceCategory
|
||||
|
||||
def test_all_categories_have_schema():
|
||||
"""검증: PlaceCategory 의 모든 업종에 스키마 파일이 있는지.
|
||||
기대결과: 4개 업종(숙박·카페·음식점·관광체험)이 모두 로드되고 code 가 1:1로 맞는다."""
|
||||
기대결과: 4개 업종(숙박·카페·음식점·피부과·성형외과)이 모두 로드되고 code 가 1:1로 맞는다."""
|
||||
schemas = all_schemas()
|
||||
assert set(schemas) == {c.value for c in PlaceCategory}
|
||||
for code, schema in schemas.items():
|
||||
|
||||
@ -79,6 +79,12 @@ def test_zero_unique_content_blocks_publish():
|
||||
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():
|
||||
"""검증: 구조화 데이터 값이 화면 값과 다르다.
|
||||
기대결과: ★ 규칙 3 — JSONLD_MISMATCH 로 거부. 검색엔진에만 다른 말을 하면 안 된다."""
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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():
|
||||
"""검증: 저장된 색이 일부 키만 담고 있을 때.
|
||||
기대결과: 빠진 자리는 업종 기본이 메운다 — 렌더러 타입이 6개를 모두 요구하므로
|
||||
@ -255,15 +267,15 @@ def test_saved_theme_does_not_override_chosen_template():
|
||||
# 여기에 없는 섹션은 사장님이 에디터에서 아무리 봐도 사이트에 나오지 않는다.
|
||||
# 실측으로 날씨·실시간 예약·대관 문의·관람 안내가 그렇게 빠져 있었다.
|
||||
_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 = {
|
||||
"stay": PlaceCategory.LODGING.value,
|
||||
"cafe": PlaceCategory.CAFE.value,
|
||||
"restaurant": PlaceCategory.RESTAURANT.value,
|
||||
"tour": PlaceCategory.TOUR_ACTIVITY.value,
|
||||
"clinic": PlaceCategory.CLINIC.value,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -17,5 +17,5 @@ export const PlaceCategory = {
|
||||
LODGING: 1,
|
||||
CAFE: 2,
|
||||
RESTAURANT: 3,
|
||||
TOUR_ACTIVITY: 4,
|
||||
CLINIC: 4,
|
||||
} as const;
|
||||
|
||||
@ -2,17 +2,13 @@ import {QueryClientProvider} from '@tanstack/react-query';
|
||||
import {useEffect, type ReactNode} from 'react';
|
||||
import {Toaster} from 'sonner';
|
||||
import {getAccessToken, me} from '@/api';
|
||||
import {ensureAutoSession} from '@/lib/autoSession';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* 저장된 액세스 토큰으로 세션을 복구한다. 실패해도 앱은 뜬다 —
|
||||
* 로그인 화면·가입 화면은 로그인 없이 열려야 하므로, 인증 실패가 전체를 막으면 안 된다.
|
||||
*
|
||||
* ★ 자동 로그인(VITE_AUTO_LOGIN_ID·PW)을 **여기서** 시도한다. 예전엔 빌더 화면 안에서
|
||||
* 불렀는데, 빌더가 RequireAuth 뒤로 들어가면서 그 자리는 영영 실행되지 않는다 —
|
||||
* 가드가 먼저 판단하고 로그인 화면으로 보내 버린다. 계정이 안 주입돼 있으면 즉시 끝난다.
|
||||
* 저장된 액세스 토큰으로 세션을 복구한다.
|
||||
* 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가
|
||||
* 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다.
|
||||
*/
|
||||
function useRestoreSession() {
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
@ -20,27 +16,24 @@ function useRestoreSession() {
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void (async () => {
|
||||
if (!getAccessToken()) await ensureAutoSession();
|
||||
if (!getAccessToken()) {
|
||||
if (alive) finishRestore();
|
||||
return;
|
||||
}
|
||||
// 자동 로그인이 방금 신원까지 채웠으면 me() 를 두 번 부르지 않는다.
|
||||
if (useAuthStore.getState().user) {
|
||||
if (alive) finishRestore();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await me();
|
||||
if (!getAccessToken()) {
|
||||
finishRestore();
|
||||
return;
|
||||
}
|
||||
void me()
|
||||
.then((res) => {
|
||||
if (!alive || res.result?.success === false) return;
|
||||
// ★ RemoveNoneResponse 라 신원 필드가 통째로 빠져 올 수 있다.
|
||||
// 반쪽짜리 사용자를 세우면 화면은 로그인된 것처럼 굴면서 요청은 401 이 난다 — 세우지 않는다.
|
||||
if (alive && res.result?.success !== false && res.user_id && res.id) setUser(toAuthUser(res));
|
||||
} catch {
|
||||
if (!res.user_id || !res.id) return;
|
||||
setUser(toAuthUser(res));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 토큰이 죽었으면 비로그인 상태로 계속 간다. */
|
||||
}
|
||||
if (alive) finishRestore();
|
||||
})();
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) finishRestore();
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import {createBrowserRouter, Navigate} from 'react-router';
|
||||
import {RequireAuth} from '@/components/layout/RequireAuth';
|
||||
import {BuilderPage} from '@/pages/BuilderPage';
|
||||
import {DevShowcasePage} from '@/pages/DevShowcasePage';
|
||||
import {LoginPage} from '@/pages/LoginPage';
|
||||
@ -8,6 +7,7 @@ import {SignupPage} from '@/pages/SignupPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{path: '/login', element: <LoginPage />},
|
||||
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
|
||||
{path: '/signup', element: <SignupPage />},
|
||||
|
||||
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
|
||||
@ -17,26 +17,15 @@ export const router = createBrowserRouter([
|
||||
{path: '/', element: <Navigate to="/builder?new=1" replace />},
|
||||
|
||||
/**
|
||||
* ★ 빌더부터는 로그인한 사람만 들어온다.
|
||||
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
|
||||
* 만들어 보기도 전에 막힌다.
|
||||
*
|
||||
* 예전엔 가드 없이 열어 뒀다(만들어 보기 전에 막지 않으려고). 그런데 위저드 2단계부터
|
||||
* 백엔드를 부르고, 만든 결과는 사업장·사이트로 **계정에 귀속**된다 — 로그인 없이 걸어온
|
||||
* 사람은 3단계쯤에서 "로그인이 만료되었습니다"를 만나고 그때까지 넣은 걸 잃었다.
|
||||
* 문 앞에서 막는 편이 걸어 들어온 뒤에 막는 것보다 낫다.
|
||||
* 대신 세션은 조용히 확보한다 — VITE_AUTO_LOGIN_ID·PW 가 주입돼 있으면 useAutoLogin() 이
|
||||
* 그 계정으로 붙고, 없으면 서버가 필요한 순간(2단계 검색)에만 알린다.
|
||||
*
|
||||
* 자동 로그인(VITE_AUTO_LOGIN_ID·PW)은 그대로 산다 — 다만 이제 화면 안이 아니라
|
||||
* 부팅 때 붙는다(app/provider.tsx). 가드가 먼저 판단하므로 화면 안에서는 늦다.
|
||||
*
|
||||
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
|
||||
* 에디터(6단계)는 전체 화면이 필요해 AppShell 을 스스로 끄고 켠다 — BuilderPage 참조.
|
||||
*/
|
||||
{
|
||||
path: '/builder',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<BuilderPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{path: '/builder', element: <BuilderPage />},
|
||||
|
||||
/**
|
||||
* ★ 내부 운영 화면(/places, /local-content, /seo)은 여기 없다 — 최상단 `admin/` 앱으로 나갔다.
|
||||
|
||||
@ -103,7 +103,7 @@ function templatesFor(
|
||||
fontStyle: '옛 간판체',
|
||||
look: LOOK.retro,
|
||||
// 이 템플릿이 팔려는 게 바로 이 아이템들이다.
|
||||
defaultSectionTypes: ['songs', 'daily', 'course'],
|
||||
defaultSectionTypes: ['songs', 'daily', 'course', 'schedule'],
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -197,32 +197,32 @@ export const INDUSTRY_CONFIGS: Record<IndustryType, IndustryData> = {
|
||||
}),
|
||||
},
|
||||
|
||||
tour: {
|
||||
id: 'tour',
|
||||
name: '관광 · 체험',
|
||||
subName: '체험공간 · 전시',
|
||||
description: '원데이 클래스, 도자기·목공 공방, 사설 갤러리, 팜스테이',
|
||||
clinic: {
|
||||
id: 'clinic',
|
||||
name: '피부과 · 성형외과',
|
||||
subName: '의원 · 클리닉',
|
||||
description: '피부과, 성형외과, 미용 클리닉',
|
||||
channels: [
|
||||
{ id: 'naver_place', name: '네이버 플레이스', checked: true },
|
||||
{ id: 'frip', name: '프립 (체험 플랫폼)', checked: true },
|
||||
{ id: 'kakao_channel', name: '카카오톡 채널', checked: true },
|
||||
{ id: 'instagram', name: '인스타그램', checked: true },
|
||||
],
|
||||
sections: [
|
||||
{ id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '대표 프로그램 비주얼 및 감성 슬로건' },
|
||||
{ id: 'intro', type: 'intro', 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: 'exhibition', type: 'exhibition', 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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '이천 예스파크 진입로 및 주차장' },
|
||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: true, description: '현재 기온과 체험장 주변 날씨' },
|
||||
{ id: 'local', type: 'local', name: '주변 관광 코스', isLocked: false, isEnabled: true, description: '도예마을 맛집 및 주변 힐링 스팟' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '작품 수령 기간, 복장, 환불 규정' },
|
||||
{ id: 'hero', type: 'hero', name: '히어로', isLocked: true, isEnabled: true, description: '병원 대표 이미지와 진료 분야' },
|
||||
{ id: 'intro', type: 'intro', 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: 'exhibition', type: 'exhibition', 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: 'map', type: 'map', name: '오시는 길', isLocked: true, isEnabled: true, description: '역에서 오는 길과 주차장' },
|
||||
{ id: 'weather', type: 'weather', name: '날씨', isLocked: false, isEnabled: false, description: '현재 기온과 주변 날씨' },
|
||||
{ id: 'local', type: 'local', name: '주변 정보', isLocked: false, isEnabled: false, description: '주변 편의시설' },
|
||||
{ id: 'faq', type: 'faq', name: '자주 묻는 질문', isLocked: false, isEnabled: true, description: '예약 변경, 회복 기간, 주의사항' },
|
||||
],
|
||||
templates: templatesFor('tour', '#7c3aed', {
|
||||
name: '시간여행',
|
||||
description: '갱지 바탕에 간판체. 일력과 승차권으로 지역의 시간을 걸어 다니게 만듭니다.',
|
||||
templates: templatesFor('clinic', '#4A9DC4', {
|
||||
name: '클린',
|
||||
description: '여백과 낮은 채도. 과장 없이 정보를 먼저 보여줍니다.',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal file
37
solution/frontend/src/features/auth/EditorSignInGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
solution/frontend/src/features/auth/SignInForm.tsx
Normal file
95
solution/frontend/src/features/auth/SignInForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -55,6 +55,26 @@ export interface CourseItem {
|
||||
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 {
|
||||
/** JSON 봉투의 `kind`. 섹션 타입과 같은 값이라 다른 아이템 JSON 을 붙여넣으면 바로 잡힌다. */
|
||||
kind: string;
|
||||
@ -302,6 +322,56 @@ export const SECTION_DATA_SPEC: Record<string, SectionDataSpec> = {
|
||||
· 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 {
|
||||
|
||||
@ -67,6 +67,7 @@ import {ExhibitionNotice} from './variants/exhibition/ExhibitionNotice';
|
||||
import {SongsTurntable} from './variants/songs/SongsTurntable';
|
||||
import {DailyCalendar} from './variants/daily/DailyCalendar';
|
||||
import {CourseTickets} from './variants/course/CourseTickets';
|
||||
import {ScheduleTimetable} from './variants/schedule/ScheduleTimetable';
|
||||
|
||||
export const SECTION_VARIANTS: Record<string, SectionVariant[]> = {
|
||||
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: [
|
||||
{
|
||||
id: 'exhibition.gallery',
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -29,11 +29,11 @@ const SOURCE = [
|
||||
{industry: 'restaurant', id: 'food-sage', name: '세이지 다이닝', swatches: ['#F7E7DC', '#FFF8F3', '#758694', '#405D72']},
|
||||
{industry: 'restaurant', id: 'food-night', name: '나이트 다이닝', swatches: ['#F5E8C7', '#DEBA9D', '#9E7777', '#6F4C5B']},
|
||||
|
||||
{industry: 'tour', id: 'tour-teal', name: '틸 앰버', swatches: ['#FFE2AF', '#E37434', '#24B1B1', '#007979']},
|
||||
{industry: 'tour', id: 'tour-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']},
|
||||
{industry: 'tour', id: 'tour-spring', name: '스프링 필드', swatches: ['#F6F193', '#C5EBAA', '#A5DD9B', '#A0D683']},
|
||||
{industry: 'tour', id: 'tour-sunset', name: '선셋 어드벤처', swatches: ['#FFEEA9', '#FFBF78', '#FF7D29', '#FF4E88']},
|
||||
{industry: 'tour', id: 'tour-violet', name: '바이올렛 플레이', swatches: ['#F3F8FF', '#E26EE5', '#7E30E1', '#49108B']},
|
||||
{industry: 'clinic', id: 'clinic-clean', name: '클린 블루', swatches: ['#F7FAFC', '#E3F0F7', '#4A9DC4', '#1E5F7A']},
|
||||
{industry: 'clinic', id: 'clinic-sky', name: '클리어 스카이', swatches: ['#F9F7F7', '#DBE2EF', '#3F72AF', '#112D4E']},
|
||||
{industry: 'clinic', id: 'clinic-sage', name: '세이지 그린', swatches: ['#F6F7F2', '#DCE5D5', '#9FB89A', '#5E7A61']},
|
||||
{industry: 'clinic', id: 'clinic-nude', name: '누드 베이지', swatches: ['#FBF6F0', '#EFE0D1', '#C9A88A', '#8C6A4F']},
|
||||
{industry: 'clinic', id: 'clinic-mono', name: '모노 그레이', swatches: ['#FAFAFA', '#E5E5E5', '#8A8A8A', '#2B2B2B']},
|
||||
] satisfies Array<{industry: IndustryType; id: string; name: string; swatches: string[]}>;
|
||||
|
||||
export const COLOR_PALETTE_PRESETS: ColorPalettePreset[] = SOURCE.flatMap((preset) => {
|
||||
|
||||
@ -18,7 +18,7 @@ export const CATEGORY_TO_INDUSTRY: Record<number, IndustryType> = {
|
||||
[PlaceCategory.LODGING]: 'stay',
|
||||
[PlaceCategory.CAFE]: 'cafe',
|
||||
[PlaceCategory.RESTAURANT]: 'restaurant',
|
||||
[PlaceCategory.TOUR_ACTIVITY]: 'tour',
|
||||
[PlaceCategory.CLINIC]: 'clinic',
|
||||
};
|
||||
|
||||
/** facts.source_type — 사장님이 [맞아요] 를 누를 판단 근거. 출처 없는 값은 보여주지 않는다. */
|
||||
|
||||
@ -7,7 +7,7 @@ import {INDUSTRY_ICONS} from './industryIcons';
|
||||
import {WizardFooter} from './WizardFooter';
|
||||
import {WizardSteps} from './WizardSteps';
|
||||
|
||||
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'tour'];
|
||||
const INDUSTRY_ORDER: IndustryType[] = ['stay', 'cafe', 'restaurant', 'clinic'];
|
||||
|
||||
export function Step1Industry() {
|
||||
const industry = useBuilderStore((s) => s.industry);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import {useState} from 'react';
|
||||
import {useSearchParams} from 'react-router';
|
||||
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 {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
@ -83,6 +83,14 @@ export function Step2PlaceSearch() {
|
||||
const runSearch = () => {
|
||||
clearIdentity(); // 상호를 고쳐 다시 찾는 것이므로 앞서 확정한 신원은 물린다
|
||||
setPickedIndex(null);
|
||||
// ★ 로그인 전에는 서버를 부르지 않는다. 로그인은 에디터 진입에서 한 번 받는 것이 이 앱의 흐름인데,
|
||||
// 장소 API 는 전부 토큰을 요구해서(place.py) 여기서 부르면 2단계가 로그인 벽이 된다.
|
||||
// 입력한 값으로 신원을 세우고 넘어간다 — 검증은 로그인 뒤에 다시 할 수 있다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
void search.search(storeName, location);
|
||||
};
|
||||
|
||||
@ -113,6 +121,12 @@ export function Step2PlaceSearch() {
|
||||
const pickByUrl = async () => {
|
||||
const url = placeUrl.trim();
|
||||
if (!url) return;
|
||||
// URL 확인도 서버가 토큰을 요구한다 — 로그인 전에는 입력값으로 넘어간다.
|
||||
if (!getAccessToken()) {
|
||||
confirmIdentity(search.confirmManual(storeName, location));
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
const identity = await search.confirmByUrl(url);
|
||||
if (!identity) return;
|
||||
confirmIdentity(identity);
|
||||
|
||||
@ -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';
|
||||
|
||||
/** 업종 아이콘. lucide 하나로 통일한다 — 손으로 그린 SVG 세트를 따로 두지 않는다. */
|
||||
@ -6,5 +6,5 @@ export const INDUSTRY_ICONS: Record<IndustryType, typeof Building2> = {
|
||||
stay: Building2,
|
||||
cafe: Coffee,
|
||||
restaurant: UtensilsCrossed,
|
||||
tour: Compass,
|
||||
clinic: Stethoscope,
|
||||
};
|
||||
|
||||
@ -20,7 +20,7 @@ const INDUSTRY_TO_CATEGORY: Record<IndustryType, PlaceCategory> = {
|
||||
stay: PlaceCategoryEnum.LODGING,
|
||||
cafe: PlaceCategoryEnum.CAFE,
|
||||
restaurant: PlaceCategoryEnum.RESTAURANT,
|
||||
tour: PlaceCategoryEnum.TOUR_ACTIVITY,
|
||||
clinic: PlaceCategoryEnum.CLINIC,
|
||||
};
|
||||
|
||||
/** 후보를 어느 장소 DB 에서 찾았는지 — 사장님이 판단할 근거로 카드에 그대로 붙인다. */
|
||||
@ -132,14 +132,10 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
// 누른 검색이 토큰 없이 나가 '로그인 만료'로 떨어진다 — 만료가 아니라 경합이다.
|
||||
await ensureAutoSession();
|
||||
|
||||
// ★ /builder 가 RequireAuth 뒤라 여기까지 왔으면 토큰이 있어야 한다. 없다면 위저드를
|
||||
// 걷는 도중에 세션이 끊긴 것이다 — 그건 실제로 '만료' 이므로 그렇게 말한다.
|
||||
// ★ 토큰이 없으면 화면(Step2)이 검색을 부르지 않고 입력값으로 넘어간다 — 2단계는 로그인 벽이 아니다.
|
||||
// 그래도 여기 도달했다면 세션이 도중에 끊긴 것이므로, 로그인을 요구하지 말고 조용히 물러난다.
|
||||
if (!getAccessToken()) {
|
||||
setState({
|
||||
...INITIAL,
|
||||
phase: 'unavailable',
|
||||
unavailableReason: '로그인이 만료되었습니다. 다시 로그인한 뒤 검색해 주세요.',
|
||||
});
|
||||
setState({...INITIAL});
|
||||
return;
|
||||
}
|
||||
|
||||
@ -270,5 +266,15 @@ export function usePlaceSearch(industry: IndustryType, existingPlaceId: string |
|
||||
* ★ 동일 업소 검증(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};
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ export const SCHEMA_TYPE_BY_INDUSTRY: Record<IndustryType, string> = {
|
||||
stay: 'LodgingBusiness',
|
||||
cafe: 'CafeOrCoffeeShop',
|
||||
restaurant: 'Restaurant',
|
||||
tour: 'TouristAttraction',
|
||||
clinic: 'MedicalClinic',
|
||||
};
|
||||
|
||||
interface AeoItem {
|
||||
|
||||
14
solution/frontend/src/hooks/useAutoLogin.ts
Normal file
14
solution/frontend/src/hooks/useAutoLogin.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {useEffect} from 'react';
|
||||
import {ensureAutoSession} from '@/lib/autoSession';
|
||||
|
||||
/**
|
||||
* 화면이 열리자마자 세션을 확보한다.
|
||||
*
|
||||
* 실제 로직은 `lib/autoSession` 에 있다 — 서버를 부르는 쪽(usePlaceSearch)도 같은 약속을
|
||||
* 기다려야 하기 때문에, 훅 바깥에 두고 공유한다.
|
||||
*/
|
||||
export function useAutoLogin() {
|
||||
useEffect(() => {
|
||||
void ensureAutoSession();
|
||||
}, []);
|
||||
}
|
||||
@ -2,7 +2,9 @@ import {useEffect, useRef} from 'react';
|
||||
import {ArrowLeft, ExternalLink, Loader2, LogOut, TriangleAlert} from 'lucide-react';
|
||||
import {Link, useSearchParams} from 'react-router';
|
||||
import {SiteStatus} from '@o2o/shared';
|
||||
import {getAccessToken} from '@/api';
|
||||
import {AppShell} from '@/components/layout/AppShell';
|
||||
import {EditorSignInGate} from '@/features/auth/EditorSignInGate';
|
||||
import {
|
||||
Step1Industry,
|
||||
Step2PlaceSearch,
|
||||
@ -11,9 +13,10 @@ import {
|
||||
Step5Generating,
|
||||
} from '@/features/onboarding';
|
||||
import {EditorLayout} from '@/features/builder';
|
||||
import {useAutoLogin} from '@/hooks/useAutoLogin';
|
||||
import {usePlaceSync} from '@/hooks/usePlaceSync';
|
||||
import {EDITOR_STEP, useBuilderStore} from '@/stores/builder';
|
||||
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;
|
||||
@ -32,12 +35,14 @@ function siteUrl(domain: string | null | undefined): string | null {
|
||||
}
|
||||
|
||||
export function BuilderPage() {
|
||||
useAutoLogin();
|
||||
/**
|
||||
* 어떤 사업장을 편집할지는 쿼리스트링으로 받는다 — `/builder?placeId=<uuid>`.
|
||||
*
|
||||
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: placeId 는 있을 수도 없을 수도 있는
|
||||
* 선택값이다(새로 만들기 vs 사업장 열기). 쿼리스트링이면 라우트를 하나도 안 건드리고
|
||||
* 두 경우를 같은 화면이 받는다. placeId 가 없으면 아래 훅은 네트워크를 타지 않는다.
|
||||
* ★ 라우트(`/builder/:placeId`)로 받지 않는 이유: 빌더는 로그인 없이 도는 데모 경로이고
|
||||
* (router.tsx 주석), placeId 는 있을 수도 없을 수도 있는 선택값이다. 쿼리스트링이면
|
||||
* 라우트를 하나도 안 건드리고 두 경우를 같은 화면이 받는다.
|
||||
* placeId 가 없으면 아래 훅은 네트워크를 한 번도 타지 않는다 — 데모는 지금 그대로다.
|
||||
*/
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const urlPlaceId = searchParams.get('placeId');
|
||||
@ -84,6 +89,8 @@ export function BuilderPage() {
|
||||
// 에디터는 AppShell(사이드바)을 안 쓴다 — 누구로 로그인했는지·나가는 길이 여기 없으면 아예 없다.
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const signOut = useAuthStore((s) => s.signOut);
|
||||
// ★ 스토어의 user 만 보면 자동 로그인이 심어 둔 토큰을 놓친다 — 둘 다 본다.
|
||||
const isSignedIn = Boolean(user) || Boolean(getAccessToken());
|
||||
// 배지는 주소창이 아니라 스토어가 기준이다 — [처음부터]로 데모로 돌아간 뒤에도
|
||||
// 주소창에는 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) {
|
||||
return (
|
||||
<div className="relative flex h-screen w-screen flex-col overflow-hidden">
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* SitePayload 는 "발행 잡이 구운 결과". 편집 상태는 저장 전까지 서버에 없다.
|
||||
*/
|
||||
|
||||
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'tour';
|
||||
export type IndustryType = 'stay' | 'cafe' | 'restaurant' | 'clinic';
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
@ -178,7 +178,7 @@ export interface IndustryCustomData {
|
||||
menuList?: IndustryListItem[];
|
||||
/** 음식점 — 코스 목록 */
|
||||
courseList?: IndustryListItem[];
|
||||
/** 관광체험 — 프로그램 목록 */
|
||||
/** 피부과·성형외과 — 시술 목록 */
|
||||
programList?: IndustryListItem[];
|
||||
/** 이용 규정 문장들 */
|
||||
rules?: string[];
|
||||
|
||||
@ -12,7 +12,7 @@ export const PlaceCategory = {
|
||||
LODGING: 1,
|
||||
CAFE: 2,
|
||||
RESTAURANT: 3,
|
||||
TOUR_ACTIVITY: 4,
|
||||
CLINIC: 4,
|
||||
} as const;
|
||||
export type PlaceCategory = (typeof PlaceCategory)[keyof typeof PlaceCategory];
|
||||
|
||||
|
||||
@ -249,4 +249,13 @@ export interface SectionSetting {
|
||||
* 렌더러가 모르는 키를 만나도 마찬가지다 — 서버는 값을 해석하지 않고 그대로 싣는다.
|
||||
*/
|
||||
variantId?: string;
|
||||
/**
|
||||
* 사장님이 에디터에서 직접 쓴 섹션 본문.
|
||||
*
|
||||
* ★ variantId 와 같은 사연이다 — 이 필드가 없던 동안 캔버스에 쓴 소개문은 payload 경계에서
|
||||
* 버려졌다. 저장(sites.theme)은 되는데 발행본에 안 나왔고, 고유 콘텐츠로도 세지 않아
|
||||
* "소개를 썼는데 고유 콘텐츠 0건으로 발행이 막힌다" 가 됐다.
|
||||
* ★ 줄바꿈이 문단 구분이다. 렌더러가 빈 줄을 기준으로 <p> 를 나눈다.
|
||||
*/
|
||||
body?: string;
|
||||
}
|
||||
|
||||
@ -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 로
|
||||
* 라우트를 갈라 사이트 하나에 30개 안팎의 HTML 을 구웠다.
|
||||
@ -321,10 +339,7 @@ function prerenderSite(input: SitePayload, outRoot: string, assets: ReturnType<t
|
||||
* 페이지가 디스크에 나가 있으면 크롤러가 그걸 읽는다.
|
||||
*/
|
||||
if (uniqueContentCount <= 0) {
|
||||
throw new VerifyError(
|
||||
['고유 콘텐츠가 0건이다 — 이 가게에만 있는 내용이 없으면 발행하지 않는다'],
|
||||
uniqueContentCount,
|
||||
);
|
||||
throw new NoUniqueContentError(uniqueContentCount);
|
||||
}
|
||||
|
||||
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;
|
||||
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 은 전부 템플릿 값이라 가게를 구분하지 못한다.
|
||||
for (const fact of [...payload.facts, ...payload.units.flatMap((unit) => unit.facts)]) {
|
||||
if (fact.type === 'text' && long(fact.value)) count += 1;
|
||||
@ -585,11 +605,14 @@ function main() {
|
||||
} catch (ex) {
|
||||
// ★ 한 사이트가 깨졌다고 나머지를 못 굽게 두지 않는다. 실패는 보고서로 남긴다 —
|
||||
// 조용히 넘어가면 "발행했는데 페이지가 없다"가 다시 반복된다.
|
||||
// ★ 계수를 못 잰 실패(디스크·번들·payload 파손)는 null 로 남긴다. 0 으로 적으면
|
||||
// 백엔드가 "고유 콘텐츠 0건" 으로 읽어 또 엉뚱한 사유를 붙인다.
|
||||
const counted = ex instanceof VerifyError || ex instanceof NoUniqueContentError ? ex : null;
|
||||
fail(
|
||||
entry,
|
||||
ex instanceof Error ? ex.message : String(ex),
|
||||
ex instanceof VerifyError ? ex.mismatches : [],
|
||||
ex instanceof VerifyError ? ex.uniqueContentCount : null,
|
||||
counted?.mismatches ?? [],
|
||||
counted?.uniqueContentCount ?? null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,6 +121,12 @@ export function isSectionEnabled(payload: SitePayload, id: string): boolean {
|
||||
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) {
|
||||
return UNIT_SPEC[payload.place.category];
|
||||
}
|
||||
@ -201,7 +207,7 @@ export function ruleRows(payload: SitePayload): InfoRow[] {
|
||||
/**
|
||||
* 예약 안내에 실을 fact.
|
||||
*
|
||||
* 숙박에는 없고(예약은 채널이 받는다) 음식점·관광체험 스키마에만 있는 key 다.
|
||||
* 숙박에는 없고(예약은 채널이 받는다) 음식점·피부과·성형외과 스키마에만 있는 key 다.
|
||||
* 값이 없는 업종에서는 그냥 빠진다.
|
||||
*/
|
||||
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) 표에 이미 나간다.
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
import {useSite} from '@/lib/site-context';
|
||||
import {galleryImages} from '@/lib/derive';
|
||||
import {galleryImages, sectionBody} from '@/lib/derive';
|
||||
|
||||
export function AboutSection() {
|
||||
const payload = useSite();
|
||||
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);
|
||||
@ -46,7 +50,7 @@ export function AboutSection() {
|
||||
</h2>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -28,7 +28,7 @@ export const SCHEMA_TYPE: Record<PlaceCategory, string> = {
|
||||
[PlaceCategory.LODGING]: 'LodgingBusiness',
|
||||
[PlaceCategory.CAFE]: 'CafeOrCoffeeShop',
|
||||
[PlaceCategory.RESTAURANT]: 'Restaurant',
|
||||
[PlaceCategory.TOUR_ACTIVITY]: 'TouristAttraction',
|
||||
[PlaceCategory.CLINIC]: 'MedicalClinic',
|
||||
};
|
||||
|
||||
/** 업종 → 하위 단위의 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.CAFE]: {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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user