Commit Graph

26 Commits

Author SHA1 Message Date
238d4c25c4 [feat] solution: 네이버 플레이스를 검색 단계에서 찾는다 — 로그인은 수집 직전 한 번
사장님이 후보를 고른 뒤에도 "네이버 플레이스를 자동으로 찾지 못했습니다" 가 떴다.
찾을 수 있는데도 그랬다 — 자동 발견이 확정 경로(로그인 뒤)에만 있었고, 공개 검색은
상호·주소만 돌려줬다. 그리고 확정이 사업장 생성을 요구해서, 로그인 없이 시작하기로 한
위저드가 검색 직후부터 막혔다(자동 로그인이 그걸 가리고 있었다).

- place_service.search_places_public: 응답에 naver_place_url 을 싣는다. 넓은 검색어
  한 페이지에서 못 찾은 후보는 그 후보만 겨냥해 다시 찾는다(상위 2건, 429 회피).
  실측: 12개 상호 전부 발견. 전에는 4개 중 2개
- naver_place_lookup._region_hint: 주소에서 시·군·구까지만 뽑아 검색을 좁힌다.
  첫 토막('경기도')만 쓰면 **다른 동네 동명 업소**가 잡히고, 그 id 로 검증하면 남의
  가게가 이 사이트의 기준 정보가 된다 — 실측으로 한 번 겪었다
- ttl_cache(신규) + 공개 검색 10분 캐시: 검색 1회가 네이버를 최대 3번 긁는데 인증이
  없어 새로고침만으로 나간다. 실측 1.38s → 0.005s. **빈 결과는 캐시하지 않는다** —
  일시적 0건을 굳히면 사장님이 10분간 막힌다
- 확정은 서버를 부르지 않는다(usePlaceSearch). 화면에만 남기고, 수집 직전 로그인 뒤
  ensureServerPlace 가 생성 → 검증을 한 번에 한다. 나눠 두면 "사업장은 생겼는데 검증이
  빠진" 상태가 생기고 수집이 PLACE_NOT_VERIFIED 로 조용히 거절된다
- Step3: 수집 버튼이 로그인 모달을 연다(/login 으로 튕기지 않는다 — 위저드 상태가
  주소창에 없어 돌아올 길이 없다). 로그인하면 이어서 돈다
- Step2: 후보 카드에 '네이버 플레이스 찾음' 배지. 붙여넣기 칸은 접는다 —
  펼쳐 두면 시도도 전에 실패한 것으로 읽힌다. 뒤로 오면 처음 화면으로
- ChannelUrlInput: [추가] → [이 주소로 가져오기]. 로그인 전에는 addLink 가 placeId 가
  없어 **조용히 return** 해서 입력칸만 비워졌다(useChannelLinks.ts:37)
- LoginPage: admin/1234 기본값 제거. 배포 번들에 그대로 나가 있었다
- 기본 발행 호스트를 localhost 로(compose 4곳 · site_payload.DEFAULT_HOST · .env.example).
  운영 도메인을 기본값으로 두면 .env 를 안 채운 로컬 빌드가 조용히 운영 주소를 번들에
  굽는다 — 실측: 로컬에서 만든 링크가 킹서버로 갔다. localhost 는 http 로 조립한다

검증: tsc·eslint·vite build 통과. 브라우저로 전 구간 확인(검색 → 확정 → 로그인 →
자동 발견 → 검증 → 수집 fact 27건·사진 10장·메뉴 23건 → 사진 분석).
백엔드 테스트는 이 워크트리에서 못 돌렸다 — config.test.toml 이 없어 DB 인증이 실패한다.
2026-09-03 16:37:12 +09:00
a4ec508ba5 [fix] solution/frontend: 도는 제목이 안 움직이던 것 + 히어로를 입력 카드 구조로 · 헤더 확대
**애니메이션이 안 보이던 진짜 이유** — `.o2o-rotator-track` 이 <span> 이라 display:inline 이었다.
인라인 요소에는 transform 이 적용되지 않는다. 애니메이션은 걸려 있고 화면만 정지였다.
(computed style 로는 animationName 이 보여서 더 헷갈린다.)

- 히어로를 아임웹 구조로: 한 줄 입력창이 아니라 **큰 입력 카드**, 발행 사이트 띠가 그 카드
  **뒤로 full-bleed** 로 지나간다. 카드 아래 따로 두면 첫 화면이 세로로 길어진다
- 카드 안: 브랜드 라벨 + [업종부터 고르기] · 큰 입력 · 하단 업종 안내 + 원형 제출 버튼
- 제목 sm:text-6xl lg:text-7xl, leading 1.15
- 헤더 h-14→h-16, 로고 h-6→h-8, 메뉴 14px, 로그인도 버튼(맨 텍스트면 눌리는 걸로 안 보인다)
- 헤더가 커진 만큼 히어로 높이 계산도 4rem 으로 — 안 바꾸면 스크롤바가 생긴다
2026-09-03 11:02:42 +09:00
ee8ba59036 [fix] solution/frontend: 히어로 검색창을 pill 에서 rounded-xl 로 — 페이지 카드 곡률과 어긋났다 2026-09-03 10:59:13 +09:00
0c3f8dd3b0 [feat] solution/frontend: 히어로를 첫 화면 높이로 · 검색창 pill · 쇼케이스 카드 축소
콘텐츠가 세로로 퍼져서 첫 화면에 임팩트가 없었다.

- 히어로 min-h-[calc(100dvh-3.5rem)] + 세로 가운데. 헤더(h-14)를 빼야 스크롤바가 안 생기고,
  vh 가 아니라 dvh 인 이유는 모바일에서 주소창이 접혔다 펴져 vh 가 흔들리기 때문
- 검색창·버튼을 rounded-full h-13 로. 아이콘 여백도 같이 밀었다
- 마퀴 카드 w-52 → w-40, 간격·글자 축소. 히어로 안에 들어와야 한 화면에 다 담긴다
2026-09-03 10:58:02 +09:00
d8ed0766d6 [feat] solution/frontend: 히어로 문구 여섯으로 · 보조 문구 제거 — 세로로 퍼지던 걸 줄인다
앞말이 넷이라 금방 반복됐고, 그 아래 보조 문구 세 줄이 히어로를 세로로 늘려 임팩트를 깎았다.

- 앞말 6개: SEO · AEO 최적화 / AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 /
  손님이 먼저 만나는 / 우리 가게가 직접 말하는
- ★ 문구 개수와 index.css 의 o2o-rotate-6 키프레임은 한 몸이다. 늘리면 stop 도 고쳐야 한다 —
  안 고치면 뒤쪽이 영영 안 보이거나 빈 줄이 지나간다
- 보조 문구 3줄 삭제, 제목 sm:text-6xl 로 키우고 세로 여백 축소
2026-09-03 10:53:41 +09:00
d5e9b87e3f [feat] solution/frontend: 히어로 제목의 앞말이 돈다 — '홈페이지' 는 고정
"SEO · AEO 되는 웹사이트" 는 규격 이름이지 문구가 아니었다. 아임웹처럼
[형용사절] + [명사] 구조로 바꾸고, 명사를 고정한 채 앞말만 갈아 끼운다.

- AI가 먼저 찾는 / 챗GPT가 인용하는 / 검색에 바로 걸리는 / 손님이 먼저 만나는
- CSS 만으로 돈다(마퀴와 같은 이유 — 타이머는 백그라운드 탭에서 밀린다)
- 문구를 다섯 줄 쌓고 마지막을 첫 줄의 복제로 둔다. -80% 에서 0% 로 되감을 때 글자가 안 튄다
- 줄 높이를 1.25em 으로 못 박는다 — 창 1줄 · 트랙 5줄이라 한 칸이 정확히 20% 여야 한다
- 복제분은 aria-hidden. SEO·AEO 는 제목에서 빼 보조 문구로 내렸다

tsc·eslint·vite build 통과
2026-09-03 10:45:38 +09:00
4ef8e7d232 [feat] solution/frontend: 히어로 아래 발행 사이트 마퀴 — 입력칸만으론 뭐가 나오는지 모른다
상단에 입력칸 하나만 있으면 무엇이 만들어지는지 알 수 없다. 결과물을 바로 밑에서 흘린다.

- CSS 만으로 돈다(index.css o2o-marquee). setInterval 로 돌리면 탭이 백그라운드일 때
  프레임이 밀려 돌아왔을 때 툭 끊긴 것처럼 보인다
- 같은 목록을 두 벌 그리고 트랙을 -50% 까지만 민다 — 한 벌이면 끝에서 빈 화면이 지나간다
- 개수가 늘어도 흐르는 속도는 그대로(카드 수에 비례해 duration)
- 복제분은 aria-hidden·tabIndex=-1 — 스크린리더가 같은 목록을 두 번 읽지 않게
- hover 하면 멈추고, prefers-reduced-motion 이면 아예 안 움직인다
- CTA 문구를 [확인하기] 로(시안과 같게)

tsc·eslint·vite build 통과
2026-09-03 10:34:02 +09:00
af391b4c56 [feat] solution/frontend,docs: 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 없었다
`/` 가 곧장 위저드로 튀어서 이 제품이 무엇을 파는 물건인지 말할 자리가 없었다.
처음 온 사람이 업종 선택 화면부터 만난다.

- router: `/` 는 비로그인 랜딩 · 로그인 /sites. /pricing · /showcase 추가
- MarketingShell: 사이드바 없는 문서형 껍데기. AppShell(작업 화면)과 나눴다
- 랜딩 상단은 상호명 한 칸. 문구는 SEO·AEO 축으로만 쓴다 —
  '쉽게·빠르게'로 말하면 홈페이지 빌더와 같은 자리에서 비교당한다(PRODUCT 1절)
- ShowcaseGrid: 발행 썸네일을 그대로 건다. 예시 데이터로 채우지 않고, 없으면 섹션을 감춘다.
  ★ 생성 클라이언트를 안 쓴다 — 토큰 길목을 지나면 비로그인에서 못 부른다
- 요금은 플랜 하나(70만원/월) + 월 산출물. 비교표를 만들지 않는다

tsc·eslint·vite build 통과
2026-09-03 10:21:16 +09:00
a61f9724ea [feat] solution/frontend: 온보딩을 상호명부터 — 단계를 주소창으로 옮기고 업종은 검색이 정한다
업종을 먼저 고르게 하면 경계에서 멈춘다("우리는 카페인가 음식점인가"). 그런데 상호명은
100% 안다. 그리고 업종은 AI 를 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가
이미 들어 있고(category_group_code), 지금까지 받아 놓고 안 썼다.

- 단계가 스토어에서 주소창으로: ?step=search|industry|collect|template|generating|editor.
  번호가 아니라 이름인 이유 — 단계가 4→3 으로 줄어 옛 북마크가 다른 화면을 연다
- 시작점이 상호명 검색(Step2PlaceSearch)이다. 업종 선택은 못 정했을 때의 갈래로 남는다
- 업종은 후보의 category 로 잡히고, 못 정하면 고르게 하고, 정해져도 [바꾸기] 로 바꾼다.
  ★ 확정 뒤 변경은 신원 확인부터 다시 받는다 — Req_UpdatePlace 에 category 가 없어
    PATCH 로 못 고치고, placeAdapter 가 리페치마다 덮어써서 조용히 되돌아간다
- 랜딩 진입: ?new=1 · ?q=<상호명> · ?industry=<업종>. 한 번 읽고 replace 로 지운다
- ★ ?new=1 이 setSearchParams({}) 로 **모든 쿼리를 날리던 것**을 고쳤다 — q 가 읽히기 전에 사라졌다
- selectIndustry 가 사장님이 친 상호·위치를 지우던 것도 고쳤다(업종이 첫 화면일 땐 늘 빈 값이라 안 보였다)
- 로고는 어디서나 / 로 간다. 에디터에서는 span 이라 아예 안 눌렸다

tsc·eslint·vite build 통과
2026-09-03 10:20:57 +09:00
a52d166fae Merge branch 'main' into feature/auth 2026-09-02 22:56:27 +09:00
e2d15955b0 [fix] solution/frontend,deploy: :80 에서 로그인이 CORS 로 막히던 것 — API 를 같은 오리진으로
화면은 http://localhost(:80) 인데 번들이 http://localhost:9800 을 직접 불렀다. 백엔드 허용
오리진 기본값은 :3000~3005 뿐이라 브라우저가 막았고, 화면에는 '로그인에 실패했습니다'
(네트워크 예외 문구)만 떴다 — 아이디·비번 문제로 보인다.

nginx 가 이미 /v1 을 프록시한다(site.conf). 그쪽으로 부르면 CORS 를 아예 안 탄다.

- .env: PUBLIC_API_BASE_URL=http://localhost — 번들이 같은 오리진을 보게 한다
- LoginPage: 개발 편의로 admin/1234 기본값. ★ 운영 전에 빈 문자열로 되돌릴 것

브라우저 확인(localhost:80): 로그인 → /builder, 사이드바 '관리자 · 데모대행사'.
2026-09-02 22:56:22 +09:00
b07ade25b2 [fix] solution/frontend,docs: 온보딩 위저드에서 사이드바 제거 — 사이트가 되기 전엔 사이트 메뉴가 없다
사이드바는 계정 메뉴(내 사이트·새 사이트)다. 아직 사이트가 아닌 것 위에 그걸 얹으면,
만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다.
아임웹도 사이트 개설 흐름에는 계정 사이드바를 붙이지 않는다.

- BuilderPage: 위저드를 AppShell 대신 얇은 상단 바(로고 + 나가는 길)로. 진행은 WizardSteps 가
  이미 보여준다. 비로그인은 돌아갈 목록이 없어 그 자리에 [로그인] 을 둔다
- BuilderPage: 에디터 헤더에 [← 내 사이트] — "내 사이트 관리가 생기면 그때 잇는다"고
  비워 뒀던 자리다
- DEVLOG: 계정 레벨/사이트 레벨을 가른 근거

검증 — tsc·eslint·vite build 통과. 위저드에 사이드바가 사라진 것은 브라우저에서 확인
2026-09-02 22:43:38 +09:00
282427e10b [feat] solution/frontend: 내 사이트 · 내 정보 — 로그인 후에 갈 곳이 생겼다
로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가
없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과
가른다 — 우리는 그 사이트 레벨이 에디터다.

- pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기").
  [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다.
  ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면
  그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석)
- pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다
  (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다
- router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 —
  아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다
- AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구

검증 — tsc·eslint·vite build 통과(frontend·admin)
2026-09-02 22:43:10 +09:00
128596fc74 [feat] solution/frontend: 구글 버튼을 로그인 관문에도 · 문구 한글화
"구글 로그인 버튼이 없다" 는 지적이 맞았다. 버튼을 LoginPage·SignupPage 에만 붙여 뒀는데,
이 앱에서 사장님이 실제로 로그인 화면을 만나는 자리는 **에디터 진입 관문**(EditorSignInGate →
SignInForm)이다. 정작 거기엔 없었다.

- features/auth/SignInForm: 구글 버튼 추가. 관문·로그인 화면이 같은 폼을 쓰므로 한 곳만 고치면 된다
- lib/googleIdentity: 스크립트를 ?hl=ko 로 받는다. renderButton 의 locale 옵션은 안 먹었다 —
  'ko'·'ko_KR' 둘 다 'Continue with Google' 이 그대로 나왔다(실측)
- 버튼 문구는 signin_with('Google 계정으로 로그인'). 가입 화면만 signup_with 로 둔다.
  문구 자체는 고를 수 없다 — 구글 브랜드 가이드라 GIS 가 주는 번역을 그대로 쓴다

브라우저 확인(localhost:80): 로그인 화면에 'Google 계정으로 로그인' 한글 노출.
tsc·eslint·vite build 통과.
2026-09-02 22:34:28 +09:00
01c252287e 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 통과.
2026-09-02 21:33:31 +09:00
4d6802b4f2 [feat] solution/frontend: 에디터에 로그인 사용자 표시 — 6단계엔 신원도 나가는 길도 없었다
위저드(1~5단계)는 AppShell 사이드바가 사용자와 [로그아웃]을 들고 있는데, 에디터(6단계)는
전체 화면이라 AppShell 을 안 쓴다. 그래서 편집 화면에 들어가는 순간 **누구로 로그인했는지도,
나가는 방법도 화면에서 사라졌다.**

- BuilderPage: 에디터 상단 바 오른쪽에 사용자 · 상호와 [로그아웃] 추가
- stores/auth.userLabel: 이름 → 이메일 → 아이디 순. 구글 계정의 로그인 아이디는
  google_<sub> 라 그대로 보이면 안 된다. AppShell 도 같은 규칙을 쓰게 바꿨다
  (기존 `name ?? id` 는 이름이 빈 문자열이면 그대로 통과시켰다)

브라우저 확인: 가입 → 로그인 → 사이드바 '김사장 · 달빛스테이', 에디터 상단 바 동일 표시,
[로그아웃] 클릭 시 RequireAuth 가 /login 으로 되돌림. tsc·eslint·vite build 통과.
2026-09-02 17:29:02 +09:00
5ef3e5a7de 업종 4번째를 관광체험 → 피부과·성형외과 로 바꾸고, 로그인 관문을 에디터 진입으로 되돌린다
## 업종 교체 (tour → clinic)

PlaceCategory 코드 4번의 의미를 바꾼다. 아직 배포 전이라 데이터 마이그레이션은 없다.

- category_schema: tour_activity.json → clinic.json. 체험 스키마(안전 유의사항·우천 시
  운영·준비물)를 진료 스키마(진료과목·의료진·상담료·보험 적용·야간/주말진료)로 바꿨다.
  unit 은 프로그램 → 시술이다(마취 방식·회복 기간·권장 횟수·시술 후 주의사항).
- 소개문 계열만 allow_llm 이다. 시술 효과·비용 같은 값은 LLM 이 못 쓴다 —
  이 레포의 "검증 전에는 발행 금지" 규칙이 의료 문구에서 특히 중요하다.
- jsonld: TouristAttraction → MedicalClinic. 프론트 AeoReadiness 의 같은 표도 맞췄다.
- 색 팔레트를 병원 톤(클린 블루·세이지·누드·모노)으로, 아이콘을 Compass → Stethoscope 로.
- mock_adapter 목데이터를 시술 기준으로 교체. 스키마에 없는 key 를 쓰면 수집이 죽는다.
- site_payload 의 기본 섹션표를 에디터(industryData)와 같게 맞췄다 —
  test_site_theme 이 이 둘을 대조한다.

## 로그인 관문 되돌리기 (b94daa9·d6a6c8e revert)

두 커밋이 /builder 를 통째로 RequireAuth 뒤로 옮겨 `/` 가 곧바로 로그인 화면이 됐다.
`/` 는 자기 화면 없이 /builder 로 넘기기만 하므로, 문 앞 가드는 곧 루트 가드다.
위저드를 열어 두고 에디터 진입에서 한 번 받는 969fb67 설계로 되돌린다.
d6a6c8e 가 스스로 "969fb67 과 정면으로 다른 설계"라고 적어 두었다.

## 그 밖

- test_site_theme 의 경로가 solution/front 로 남아 있었다(frontend 개명 누락).
- .dockerignore: 이 머신에 buildx 가 없어 레거시 빌더가 돌고, 그러면
  nginx/Dockerfile.dockerignore 가 무시된다. 루트 것 하나로 두 이미지를 다 커버한다.

검증: frontend·admin·site lint·build 0. 백엔드 534 passed / 4 failed —
그 4개(test_build_publish 3 · test_snapshot 1)는 이 변경 전부터 실패하던 것이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xa8ME5FQJy4VA8pPokTo1a
2026-09-02 15:42:34 +09:00
b94daa9dcf [refactor] solution/frontend: 로그인 관문을 문 앞 하나로 — 에디터 관문과 2단계 우회로 제거
같은 날 두 자리에서 같은 문제를 풀어 관문이 두 겹이 됐다. 둘 다 두면 문 앞(RequireAuth)이
먼저 걸려 에디터 관문은 영영 안 뜨는 죽은 코드다. 문 앞을 남긴 이유는 열어 둔 값이
공짜가 아니었기 때문이다.

에디터 관문을 쓰려면 2단계가 토큰 없이 지나가야 했고, 그래서 토큰이 없을 때 서버를 부르지 않고
입력값으로 신원을 세우는 우회로가 생겼다(confirmManual). 그건 이 레포의 단 하나의 규칙
— 검증 전에는 수집·발행 금지 — 을 화면이 비켜 가는 모양이고, 대가는 "로그인 뒤에 검증을 다시"다.
게다가 가입이 이제 그 자리에서 끝나므로(가입 응답에 토큰이 실린다) 문 앞 로그인의 마찰은
"만들어 보기도 전에 막는다" 던 시절보다 훨씬 작다.

- features/auth/EditorSignInGate·SignInForm 삭제. 관문이 하나면 폼도 하나다 —
  SignInForm 이 경고하던 'signIn → me 를 두 벌로 들고 있다' 를 lib/session 한 곳으로 모았다
- Step2PlaceSearch: 토큰 없을 때 검증을 건너뛰던 두 갈래 제거
- usePlaceSearch: confirmManual 제거. 토큰이 없으면 이제 진짜 '만료'다(문 앞을 통과했으므로)
  — 문구를 사실대로 되돌린다
- BuilderPage: 에디터 진입 분기 제거

되돌리려면 app/router 의 RequireAuth 를 벗기고 969fb67·22b7623 을 되살리면 된다.

tsc·eslint·vite build 통과(사장님 앱·admin).
2026-09-02 09:41:28 +09:00
e8e695c5a9 Merge branch 'main' into feature/auth
텍스트 충돌은 없었지만 **로그인 관문이 두 겹**이 됐다. 같은 문제를 오늘 두 자리에서 풀었다.

- main 969fb67 : 에디터 진입(6단계)에서 받는다 — 위저드 1~5단계는 열어 둔다
- 이 브랜치 : /builder 문 앞에서 받는다 — 위저드 진입부터 계정을 요구한다

지금은 문 앞 게이트가 먼저 걸리므로 EditorSignInGate 는 세션이 도중에 끊긴 경우에만 뜬다.
둘 중 하나를 고르는 건 제품 결정이라 코드로 정하지 않았다. 에디터 게이트만 남기려면
app/router.tsx 의 RequireAuth 한 겹을 벗기면 된다.

남은 중복: features/auth/SignInForm 과 lib/session 이 'signIn → me' 순서를 각자 들고 있다.
게이트 결정이 난 뒤 한 벌로 합친다.

pytest 527 passed / 8 failed(전부 기존 실패). tsc·eslint·vite build 통과.
2026-09-02 09:37:11 +09:00
d6a6c8e229 [feat] solution/frontend: 빌더를 로그인 뒤로 — 걸어 들어온 뒤에 막지 않는다
위저드 2단계부터 백엔드를 부르고, 만든 결과는 사업장·사이트로 계정에 귀속된다. 로그인 없이
걸어온 사람은 3단계쯤에서 '로그인이 만료되었습니다' 를 만나고 그때까지 넣은 걸 잃었다 —
만료가 아니라 처음부터 세션이 없었던 것이다. 문 앞에서 막는 편이 낫다.

- app/router: /builder 를 RequireAuth 뒤로
- app/provider: 자동 로그인(AUTO_LOGIN_ID·PW)을 부팅에서 붙인다. 화면 안(useAutoLogin)은
  가드가 먼저 판단하므로 영영 실행되지 않는다 — 그래서 훅을 지웠다
- 자동 로그인이 신원까지 채웠으면 me() 를 두 번 부르지 않는다

⚠️ main 의 969fb67(에디터 진입에서 한 번만 로그인)·22b7623 과 **정면으로 다른 설계**다.
   되돌리려면 router 의 RequireAuth 한 겹만 벗기면 된다.

tsc·eslint·vite build 통과.
2026-09-02 09:34:13 +09:00
48109fdc99 [feat] solution/backend,frontend: id/pw 가입 · 구글 로그인 — 계정을 만들 길이 없던 걸 연다
계정 생성 API 가 아예 없었다(그동안 users 를 손으로 INSERT 했다). 로그인 화면은 있는데
그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다.

- auth_service.signup: 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개**. users.company_id 가
  NOT NULL 이고 모든 도메인이 company 로 스코프돼서, 회사 없는 계정은 아무것도 못 만든다
- services/external/google_identity: 구글 ID 토큰의 서명·iss·만료에 더해 **aud(우리 client_id)와
  email_verified 를 본다.** aud 검사가 빠지면 남의 앱에 발급된 '진짜' 구글 토큰으로 우리 계정에
  들어온다 — 서명도 발급자도 전부 맞으므로 다른 검사로는 안 걸린다
- users.provider/provider_uid 추가, password NULL 허용, id 20→64자(google_<sub> 가 20자를 넘는다).
  provider 에 server_default 를 같이 준 이유: ORM default 는 raw INSERT(테스트 시드)에 안 먹어서
  NOT NULL 컬럼이면 그 경로가 통째로 깨진다
- attempt_login: 소셜 계정을 먼저 끊는다. 안 끊으면 bcrypt 가 None 해시를 만나 500 이다
- 같은 이메일이라도 id/pw 계정과 구글 계정을 **잇지 않는다.** 이으면 계정 선점이다 —
  남의 이메일로 먼저 만들어 둔 계정에 그 사람의 구글 로그인이 들어간다 → DECISIONS 1-5
- LoginPage 는 admin 과 공유라 selfServe 로 갈랐다. admin 은 가입 링크도 구글 버튼도 안 뜬다
  (admin 라우터에 /signup 이 없어 404 가 난다)
- GOOGLE_CLIENT_ID 는 루트 .env 한 곳. compose 가 VITE_GOOGLE_CLIENT_ID 로 흘려보낸다 —
  두 곳에 적으면 백엔드 aud 대조와 화면 버튼이 조용히 갈라진다

★ 이미 도는 DB 는 init.sql 을 다시 적용해야 한다(말미 ALTER 섹션).

pytest: auth 13건 + 구글 토큰 검증 8건(진짜 RSA 서명으로 aud·iss·만료·email_verified·변조
거절 확인) 통과. 전체 527 passed / 8 failed(전부 기존 실패, 인증과 무관).
tsc·eslint·vite build 통과.
2026-09-02 09:33:59 +09:00
94a083c895 [chore] solution/frontend: LoginPage 원복 — 로그인 화면은 건드릴 이유가 없었다
에디터 관문을 만들면서 로그인 화면까지 공용 폼으로 갈아엎었다. 요청 밖이라 되돌린다.
관문은 features/auth/SignInForm 을 그대로 쓴다.
2026-09-02 09:05:39 +09:00
969fb6773c [feat] solution/frontend: 로그인을 에디터 진입에서 한 번만 받는다
위저드를 걷는 동안 '로그인이 만료되었습니다' 가 떴다. 만료가 아니라 **한 번도 로그인한 적이
없는 것**이었다(자동 로그인 계정이 없으면 ensureAutoSession 이 즉시 끝난다). 문구가 사실과 달라
고장으로 읽혔다.

- features/auth/EditorSignInGate: 에디터에 들어갈 때만 로그인을 받는다. 위저드 1~5단계는
  요구하지 않는다 — 만들어 보기도 전에 막으면 아무도 안 만든다. /login 으로 튕기지 않는 이유는
  위저드에서 쌓은 상태를 들고 돌아올 방법을 사장님이 알 수 없기 때문이다
- features/auth/SignInForm: 로그인 화면과 관문이 같은 폼을 쓴다. 두 벌로 두면 토큰을 심는
  순서(signIn → me)가 한쪽에서만 지켜지고, 그 실수는 "로그인은 됐는데 계속 401" 로 나타난다
- usePlaceSearch: 토큰이 없을 때의 문구에서 '만료' 를 걷어낸다. 검색은 서버가 토큰을 요구하므로
  (place.py 전 엔드포인트가 IsValidAccessToken) 프론트가 없앨 수 있는 제약이 아니다

tsc·eslint·vite build 통과
2026-09-02 09:03:17 +09:00
db58e94540 [feat] solution/frontend: 자동 로그인 복원 — 계정이 주입돼 있을 때만 붙는다
9b4fe40 이 개발 전용 자동 로그인을 지우면서 빌더 2단계가 막다른 길이 됐다.
로그인한 적도 없는 사람에게 "로그인이 만료되었습니다" 라고 쓰고, 그 화면에는
로그인으로 갈 링크가 없다. 실측(킹서버): 계정이 DB 에 0개라 아무도 통과 못 했다.

- lib/autoSession.ts: 옛 devSession 을 되살리되 `import.meta.env.DEV` 게이트를 뺐다.
  운영 번들에서도 돌아야 한다 — 대신 VITE_AUTO_LOGIN_ID·PW 가 **둘 다** 있을 때만
  움직이고 기본값은 없다. 진행 중인 로그인을 하나의 약속으로 공유하는 구조는 그대로
  가져왔다: 훅 안에만 두면 로그인 전에 누른 검색이 토큰 없이 나가 '만료'로 떨어진다.
- usePlaceSearch: 검색 전에 그 약속을 기다린다(경합을 만료로 오인하던 자리).
- Dockerfile·compose: VITE_* 는 번들에 구워지므로 build args 다. 값을 바꾸면
  `./deploy.sh solution-site` 로 다시 굽는다.

⚠️ 계정이 번들에 그대로 들어간다. 내부 테스트 호스트 전용이고, 사장님에게 열기 전에
   AUTO_LOGIN_* 을 비우고 재빌드해야 한다.

tsc --noEmit · eslint 통과
2026-09-01 12:00:07 +09:00
9b4fe4030b [feat] deploy,backend,site: 킹서버 배포 + 설정을 최상위 .env 하나로 통합
킹서버(o2oadmin@172.30.1.36)에 처음 올리면서, 서버에 올려야만 드러나는 결함 넷을 잡았다.
전부 "화면은 뜨는데 안 되는" 종류라 로컬에서는 끝까지 보이지 않는다.

- CORS 허용 오리진(client_url)만 env override 가 없었다. 도커가 굽던 config.local.toml 은
  플레이스홀더라 허용 목록이 localhost:3000~3005 뿐이고, 배포 주소에서는 모든 API 호출이
  프리플라이트에서 죽었다. 서버 로그에는 400 만 남아 원인이 CORS 라는 게 안 보인다
- .env 경로가 세 단계라 solution/.env(없는 파일)를 보고 있었다. 백엔드를 solution/ 아래로
  옮길 때 안 고쳐진 자리. toml 이 값을 들고 있어 로컬에서 드러나지 않았다
- admin 의 "빌더 열기" 가 VITE_SOLUTION_URL 미주입으로 localhost:3000 을 가리켰다
- PUBLIC_API_BASE_URL 은 브라우저가 부르는 주소인데 기본값이 localhost 라 서버에서 즉시 틀린다

설정 — toml 층 제거, pydantic-settings 로 전환 (FastAPI 공식 방식)
- config_loader.py · config.{local,test}.toml.example 삭제, 기본값은 config_models 로
- BaseSettings + env_file. `_apply_*_env_override` 4개 제거 — 키를 손으로 나열하는 구조라
  하나 빠뜨리면 조용히 틀렸고, 실제로 client_url 이 빠져 있었다
- 환경변수 이름은 validation_alias 로 못 박음. 필드명만 두면 `port` 가 흔한 `PORT` 를 먹는다
- 테스트 DB 분리(web4ai_test_db)는 config.test.toml 이 하던 몫이라 APP_ENV 기본값으로 이관
- lru_cache 로 .env 재읽기 방지. 새 코드는 Depends(get_*) 주입 가능
- 호출부 21개 파일 무변경 — server_configs 가 같은 이름을 계속 내보낸다

배포 — 킹서버는 :80 을 호스트 nginx 가 물고 있고 사내망에 열린 건 30xxx 뿐이다
- 컴포즈 포트를 전부 .env 변수로 추출(기본값은 기존 값 그대로, 로컬 무영향)
- 컨테이너 이름을 폴더 구조에 맞춤 — solution-backend·solution-worker·solution-frontend·
  solution-site·admin-backend·admin-frontend. api·web·nginx 는 어느 폴더 코드인지
  이름만으로 알 수 없었고, 백엔드 셋이 이미지 한 벌을 나눠 써서 특히 헷갈렸다
- worker 에 container_name 을 붙여 `-1` 접미사 제거(동시성은 WORKER_CONCURRENCY 가 맡는다)
- 어드민 앱·API 는 compose 프로필 뒤로 — 지금 안 쓴다. 켤 때 --profile admin
- deploy.sh: 서비스 하나를 지정해도 백엔드 형제를 함께 교체한다. 이미지 한 벌을 나눠 써서
  하나만 바꾸면 옛 코드로 도는 컨테이너가 남는데 `ps` 로는 셋 다 살아 있다
- log.sh: 1=전체, 2번부터 개별. compose v2.20 이 커스텀 --format 을 파싱하지 못해 상태가
  전부 "미기동" 으로 보이던 것도 --services --filter 로 교정
- docs/SERVERS.md 신설(접속·경로·포트·DB·sudo 없음), docs/DEVLOG.md 신설

정리
- 개발 전용 자동 로그인 제거 — 편의 하나에 검색 경로의 비동기 대기가 딸려 있었고,
  평문 비밀번호를 .env 에 두라고 권하는 모양새였다
- API 이름을 디렉토리에 맞춤: 사장님/내부 → 솔루션 API · 어드민 API (21곳)
- .env.example 을 읽는 폴더 기준 구역으로 재편 (solution/backend · solution/frontend ·
  solution/site · compose)
- AGENTS.md 에 negosium 브랜치·커밋 규약 명시

검증(킹서버 실측) — 컨테이너 4개 새 이름으로 기동, 솔루션 API·사장님 앱 200,
발행 사이트 404(발행물 없음, 정상), CORS 허용/차단 각 확인, toml 없이 부팅,
APP_ENV=test 시 web4ai_test_db·실키 미주입 확인.
2026-09-01 10:04:36 +09:00
c85c577349 이름: solution/front → solution/frontend
`backend` 옆에 `front` 가 있을 이유가 없었다. negosium 의 negodata/front 를 그대로
베꼈고 그게 왜 front 인지는 따져보지 않았다 — 근거 없이 들여온 이름이라 바로잡는다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
2026-08-31 15:27:16 +09:00