Commit Graph

15 Commits

Author SHA1 Message Date
e0d45eda97 [fix] postgres-init,solution/backend,docs: 스키마 재편이 안 닿은 자리를 전부 잡는다 — init.sql · ORM 인덱스 · 테스트
0005 가 도메인 스키마를 걷어내고 표 이름을 옮겼는데, 문자열로 표 이름을 들고 있던 자리들이
따라오지 않았다. import 도 타입검사도 pyflakes 도 못 잡는 종류라 전부 **실행되는 순간에만**
터졌고, 그동안 pytest 는 569건이 통째로 죽어 있어 아무것도 못 잡고 있었다.

**init.sql 이 새 DB 를 옛 구조로 세우고 있었다**
64ce467 이 이 파일에 94줄을 더하기만 하고 삭제를 0줄 했다. 그래서 이 파일 한 벌로 세운 DB 는
`place.place_links`·`job.jobs` 를 갖고 ORM 은 `public.place_channels`·`public.jobs` 를 찾는다 —
기동은 정상이고 첫 쿼리에서 죽는다. "init.sql 은 새 DB 를 세우는 전체 DDL 이고 계속 최신을
유지한다"(migrations/README.md)는 계약이 깨져 있었다.
- public 한 벌 · 표 14개로 다시 썼다. 옛 스키마가 있는 DB 에서 다시 돌면 RAISE EXCEPTION 으로
  멈춘다 — 그대로 두면 public 에 빈 표가 생기고 0005 가 "relation already exists" 로 실패해
  데이터가 옛 스키마에 갇힌다
- 말미에 **마이그레이션 기준선**을 심는다. 없으면 새 DB 에서 migrate.py 가 0001 부터 다시 돌다가
  `schema "local" does not exist` 로 죽는다

**운영 버그 둘** — 두 DB(새로 세운 것 · 마이그레이션으로 따라온 것)를 pg_dump 로 찍어 비교해 찾았다
- `upsert_weather` 의 ON CONFLICT 술어에 `kind IS NULL` 이 빠져 **날씨 캐시 저장이 계속 실패**하고
  있었다(0007 이 인덱스에 그 조건을 더했다). 캐시라 화면이 안 죽고 로그에만 남았다.
  포스트그레스는 술어가 인덱스 술어를 함의하는지 보고 아니면 "no unique or exclusion constraint
  matching" 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보인다
- ORM 의 `area_contents` 인덱스 정의가 0004·0007·0008 을 하나도 안 따라왔다. 테스트 DB 는 이
  모델로 세워지므로 **테스트가 운영과 다른 제약 아래에서 돌고 있었다**

**0009** — 두 DB 비교에서 나온 어긋남 셋(데이터는 안 건드린다)
- `idx_site_contents_site` 가 기존 DB 에만 없었다(0003 이 유니크만 걸었다) — 섹션 조회가 시퀀셜 스캔
- `places.external_place_id` VARCHAR(32) → (64). ORM 은 64 다 — 긴 id 가 잘리면 동일 업소 판정이 틀린다
- RENAME 이 안 따라간 PK 제약 이름 9개(`facts_pkey` → `place_facts_pkey` …)

**테스트를 살린다**
- conftest 의 TRUNCATE 가 표 이름을 **손으로 나열**하고 있었다. 0005 가 이름을 옮기자 전 테스트가
  `relation "place_aliases" does not exist` 로 죽었다 — 이제 ORM 메타데이터에서 뽑아 다시 어긋날 수 없다
- `test_schema_ddl` 이 모델 표를 `"None.users"` 로 조회해 **한 표도 비교하지 않고 통과**하고 있었다.
  init.sql 이 조용히 어긋난 동안 이 테스트는 초록이었다. 비교한 표 수를 세는 단언을 더한다
- 테스트 SQL 15곳의 옛 표 이름, `_run_worker` 1틱 문제(수집 뒤 따라오는 LOCAL_SYNC 를 집어 가
  정작 기다리던 잡이 PENDING 으로 남았다), 지역 캐시 픽스처(읽는 코드가 옳게 거르는데 테스트가 빨개졌다)

**문서**
- `docs/DATA_MODEL.md` 신설 — 표 14개가 무엇을 담고 누가 쓰는지, 값 하나가 DB 에서 페이지까지
  가는 길, 두 번 도는 게이트, **DB 에 없는 것**
- `SERVERS.md` DB 절을 마이그레이션 체계로. 배포에 `migrate.py` 를 넣는다 — 코드만 갈면 컨테이너는
  정상으로 뜨고 가게 등록·수집·발행만 죽는다
- ARCHITECTURE 2절의 프리렌더 컨테이너가 `solution-frontend` 로 적혀 있었다. 굽는 건
  `solution-prerender` 고 전자는 운영에서 뜨지도 않는다 — AGENTS.md 가 함정으로 적어 둔 그 혼동을
  문서가 만들고 있었다
- 옛 표 이름 잔재(`place_links`·`local_contents`·`job.jobs`·`company.users`·`fact.facts`·`ai_check_results`)

검증: 빈 컨테이너에 init.sql 로 세운 DB ↔ 마이그레이션으로 따라온 DB 를 `pg_dump --schema-only`
로 비교 — 표·인덱스·제약·컬럼 전부 동일. pytest 583건 중 581 통과(남은 2건은 `.env` 누수·
레이트리밋 카운터로 환경 문제다). 구글 로그인 21건 포함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 11:39:43 +09:00
c4af53613e [feat] solution,postgres-init: 지역 이야기를 서버가 채운다 · 공용과 개인화를 이름으로 가른다
가요·인물·연표·엽서·퀴즈는 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다.
`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구
템플릿을 골라도 그 자리가 비었다. 실측(2026-09-09, 전북 군산시): 생성 54건 · 62초 · 버린 항목 0.

**생성**
- Perplexity 종류당 1회, 지역당 1세트. 순차로 돈다 — 동시에 다섯을 띄웠더니 둘이 HTTP 429 였다
  (같은 키라 한 지역이 자기를 막는다). 순차도 건당 9~15초다. 타임아웃 240s — 가요 다방이
  기본 90s 를 넘겼다(후보를 넓게 훑는 프롬프트다).
- 출처 없는 항목은 버린다. 항목 자신의 출처가 없어 검색 출처로 때운 것은 모델이 "확인" 이라
  우겨도 "확인필요" 로 내린다. 항목 **모양은 검사하지 않는다** — shared 계약을 파이썬에
  한 벌 더 적으면 필드가 는 날 서버가 조용히 떨어뜨린다.
- 프롬프트는 한 벌이다(`shared/section-prompts.ts`). 사장님이 [콘텐츠] 탭에서 복사해 가던
  그 문장을 서버도 그대로 쓴다. `npm run export:prompts` 가 백엔드용 JSON 으로 뽑는다(커밋).
- 트리거는 수집 완료 직후다. 전에는 에디터 캔버스가 주변정보를 처음 부를 때 시작해서
  사장님이 처음 보는 화면이 **늘 절반만 그려진 상태**였다.

**자리 가르기**
    area_*        = 공용. 지역 단위, 여러 사이트가 나눠 쓴다 → 렌더러 모양 그대로.
    site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부(거리·숨김·순서·편집).
- `area_contents.body` 가 TourAPI 원문 이름이라 빌드마다 렌더러 이름으로 바꿔 실었다 —
  같은 변환을 발행할 때마다 다시 하는 셈이었다. 수집 시점에 바꿔 넣는다.
- 거리·숨김은 사이트마다 다르니 `site_sections('local').data.places` 맵으로. **맵이지
  배열이 아니다** — 화면에 순서대로 서는 항목이 아니라 ref → 값 조회표다. 정렬 기준은
  읽는 쪽이 갖는다.
- ★ 유일 인덱스 함정 둘. `uq_local_contents_single` 이 kind 를 안 봐서 이야기 다섯 중
  **첫 종류만 저장되고 잡은 "성공" 으로 끝났고**, backfill 때는 인덱스를 먼저 떼지 않으면
  UPDATE 가 통째로 막힌다(`(gunsan, festival) already exists`). 둘 다 조용히 틀리는 종류다.
- 검수 게이트는 두지 않는다(사장님이 에디터에서 뺀다). 근거는 DECISIONS.md 6절.

검증: 지역 이야기 단위 테스트 12건 통과 · 군산 실행 후 payload.local.story 에
songs 8 · people 10 · chronicle 12 · postcard 12 · quiz 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:31 +09:00
64ce467f21 [refactor] postgres-init,solution: DB 구조 재편 — 스키마 해체 · 공용 콘텐츠 한 벌 · 마이그레이션 체계
도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.

- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
  같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
  종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
  컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
  받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
  DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
  Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
  더하면 어긋날 자리가 하나 더 생기기 때문이다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:02 +09:00
7bbeb068a8 Merge branch 'feature/stay-booking'
숙박 예약 구성(요금·인원·창구) · 네이버 예약 딥링크 · 날짜/시간 목업 · 로컬 발행 함정 셋.

충돌 4건 해결:
- seo/verify.ts STRUCTURAL: main 이 unitCode·numberOfRooms 를 이미 넣었다. main 쪽을 살리고
  "사람이 읽는 unitText 는 넣지 않는다" 만 주석으로 얹었다(같은 결론에 각자 도달했다)
- pages/HomePage.tsx: import 목록만 갈렸다 — StorySection(main) · StayBookingSection(feature)
  둘 다 필요하다
- backend/collect_service.py: 테넌트 제거로 _finish 인자가 company_id → owner_user_id 로
  바뀌었다. main 시그니처를 따르고 _store_booking_link 를 그 앞에 둔다.
  place.verified_by · _add_link 시그니처는 그대로라 예약 링크 경로는 손댈 것이 없었다
- docs/DEVLOG.md: 양쪽 새 항목을 날짜 내림차순으로 합쳤다

검증: site tsc·eslint·vitest 51 passed · frontend tsc·eslint 통과 ·
백엔드 이미지 재빌드 후 collect_service import + LinkChannel.NAVER_BOOKING=7 확인.
2026-09-09 10:42:51 +09:00
94551afdaf [refactor] solution/backend,frontend,postgres-init: 회사(테넌트) 제거 — 사장님 계정이 곧 스코프
가입 한 번이 회사를 하나 만들고 사장님이 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고
에디터 헤더에는 "이름 · 회사명" 이 붙었다 — 쓰는 사람은 사장님 한 명인데.
negodata 보일러플레이트의 멀티테넌트 스코프 키를 그대로 물려받은 것이고,
DECISIONS.md 2절이 "대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다.

- gmodel: `UserInfo.company_id` 삭제 — JWT 클레임에서도 사라진다. 스코프 키는 `user_id` 다
- place_crud·site_crud: WHERE 를 `places.owner_user_id` 로. `list_company_sites` → `list_owner_sites`
- place_service: **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 —
  body 로 받으면 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
  실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고 스코프는 회사가 대신 하고 있었다
- 워커(collect·copy·build·vision): 잡 페이로드 키 `company_id` → `owner_user_id`.
  잡이 세우는 `UserInfo.user_id` 는 이제 **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid
  순으로 채웠는데, 그 랜덤 uuid 가 스코프 키가 되는 순간 "남의 사업장" 이라 fact 조회가 0건이 된다
- auth: `Res_Me.company` · `Req_Signup.company_name` · `CompanyData` 삭제
- models·init.sql: `company.companies` 테이블 · `users.company_id` 삭제,
  `places.owner_user_id` NOT NULL. 마이그레이션은 백필 → NOT NULL → DROP 순서다.
  회사에 계정이 여럿이면 **가장 먼저 만든 계정**에게 몰고, 주인을 못 찾은 행은 지운다 —
  스코프가 없으면 아무에게도 안 보이는 유령이다.
  실측(로컬): place 92 → 91(고아 1건 삭제), `demoebf050` 56 · `test` 35
- 프론트: 가입 폼의 상호 칸, 내 정보의 상호 항목, 헤더의 "이름 · 회사명" 삭제
- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나.
  격리는 `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다

남긴 것 — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를
건드려야 해서 이번 변경에 섞지 않았다.

검증: 전체 568 passed(실패 1건은 HEAD 에서도 깨지는 레이트리밋 테스트) ·
프론트 tsc+eslint 통과 · 실제 API 로 가입→사업장→목록→격리→발행 한 바퀴

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLWEFx4X3XRmKewUKjJWow
2026-09-08 13:01:14 +09:00
66f81f3631 [feat] solution/backend,site: 예약 버튼을 네이버 예약 화면으로 — 검색 화면이 뜨던 것
발행본의 예약 버튼이 네이버 플레이스 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번
더 눌러야 하고, 자동 발견이 물어온 URL 이 map.naver.com/p/search/… 인 사장님은 예약하려고
눌렀는데 검색 화면을 봤다. 예약하러 온 손님은 거기서 끝난다.

주소를 지어낼 필요가 없다 — 플레이스 응답의 __APOLLO_STATE__ 가 예약 주소를 직접 준다
(실측 2026-09-08, place 1273971279):
  naverBooking.naverBookingUrl = https://m.booking.naver.com/booking/6/bizes/1067685
★ bookingBusinessId + businessTypeId 로 조립하지 않는다. 조립하면 예약을 안 받는 업소에도
  주소가 생기고, 빈 화면을 본 손님은 그 가게가 예약을 안 받는 줄로 읽는다.

- LinkChannel.NAVER_BOOKING=7 (백엔드·shared·init.sql 주석)
- collector/base: RawSource.booking_url — 채널이 스스로 알려준 예약 주소
- naver_place_adapter._booking_url: ROOT_QUERY.placeDetail(...).naverBooking 에서 읽는다
- collect_service._store_booking_link: 예약 채널로 등록·자동 확정(근거는 discover_naver_place
  와 같다 — 이미 확정된 플레이스가 내놓은 자기 예약 주소다)
- site/seo/jsonld BOOKING_CHANNELS: 순서가 우선순위(예약→야놀자→여기어때→플레이스).
  화면 버튼과 makesOffer.url·potentialAction 이 같은 함수를 쓴다
- site/lib/derive: 같은 순서로 정렬 + 검색 결과 주소 배제. 문구는 bookingCtaLabel 이
  "네이버 예약으로 바로 예약하기"로 낸다("네이버 예약에서 예약"이 되지 않게)
- 빌더도 채널을 안다(useCollectFlow 라벨 · ChannelUrlInput 호스트 판정)

tsc·eslint 통과, vitest 47 passed(신규 4). 어댑터는 실제 네이버 응답으로 확인.
2026-09-08 10:52:36 +09:00
bce0928385 [chore] deploy,solution,docs: 레포·발행 호스트 교체 — o2o-site-AEO / web4ai.o2osolution.ai
옛 주소 w4ai.o2o.kr 은 앞단에 vhost 가 없어 전 경로가 Apache 자체 404 다(인증서도
CN=actions.o2o.kr, 2024 만료). 그런데 canonical·og:url·sitemap 이 전부 그 주소를
가리키고 있었다 — **화면은 멀쩡하고 기계가 읽는 값만 틀린** 상태라, 검색엔진에
아무리 등록해도 색인이 안 되는 종류다.

- 기본 호스트를 쓰는 자리 전부: site_payload.DEFAULT_HOST · compose 의 `:-` 기본값 4곳 ·
  vite.config.ts allowedHosts · .env.example 둘 · check_search_ready.py · 데모 픽스처
- init.sql: site.sites.thumbnail_url 을 "기존 DB 보정(ALTER)" 절에 추가.
  CREATE TABLE 에만 있어서 **새 DB 는 되고 기존 DB 만 조용히 깨졌다** —
  실측(킹서버): GET /v1/showcase 가 200 인데 내용이 비었다
- docs/SERVERS.md: 배포 경로 ~/data2/o2o-site-AEO · 새 remote · 공개 주소 절 ·
  init.sql 이 DB 최초 생성 때만 돈다는 함정
- docs/DEVLOG.md: 항목 추가

테스트 픽스처의 w4ai.o2o.kr 은 그대로 뒀다 — 자기가 넣은 값을 자기가 검증해서
기본 호스트와 무관하다.

tsc·eslint 통과. vite build 는 도커에서 확인(로컬 node_modules 의 rollup 네이티브 누락).
2026-09-03 11:31:34 +09:00
41e49c693b Merge branch 'main' into 홈페이지-레이아웃-구상
# Conflicts:
#	docs/DEVLOG.md
#	solution/backend/crud/site_crud.py
#	solution/backend/router/router.py
#	solution/backend/router/v1/site/protocol.py
2026-09-03 09:44:51 +09:00
e8cda02a4b [feat] solution/backend,postgres-init: 발행 썸네일 저장 + 공개 쇼케이스 목록 — 랜딩이 실물을 걸 자리
랜딩의 "이렇게 나옵니다" 섹션이 걸 그림이 없었다. 발행은 되는데 그 사이트가
어떻게 생겼는지 밖에서 알 방법이 payload 안에만 있었다.

★ 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다. 헤드리스 브라우저는
  봇 탐지 우회 우려로 영구 금지돼 있고(DECISIONS 1-1), 워커(python:slim)·
  프리렌더(node:alpine) 어디에도 Chromium 이 없다.

- site_thumbnail: 대표 사진을 받아 <prefix>/thumbs/<slug>.<ext> 로 올린다.
  s/<slug>/ 안에 두지 않는 이유 — _remove_stale_site_files 가 매 발행마다
  그 경로를 프리렌더 산출물로 통째로 교체해 조용히 지운다
- site_payload: primary_media()·publish_origin()·region_label() 공개.
  isPrimary 계산을 한 곳으로 모아 og:image 와 썸네일이 갈릴 수 없게 했다
- build_service: azure publish 직후·IndexNow 전에 저장. 실패해도 발행은 그대로
  (payload 와 같은 원칙). thumbnail_url 은 발행 상태 전이 UPDATE 에 합쳐 1회
- GET /v1/showcase: 인증 없음. 발행된 사이트만, place_id·전화·상세주소는 안 나간다
- conftest: fake_renderer 가 늘 ok=True 라 NO_UNIQUE_CONTENT 되짚기 경로가
  통째로 안 돌고 있었다(기존에 깨져 있던 테스트 4건 포함 수정)

전체 562 passed
2026-09-03 09:41:10 +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
5fa83933e4 [fix] postgres-init: 구글 로그인 컬럼의 인덱스를 ALTER 뒤로 — 기존 DB 에서 스크립트가 멈춘다
실측: dev DB(web4ai_db)에 재적용하니 인덱스 절에서 끊겼다.

  ERROR:  column "provider_uid" does not exist

파일 구조가 CREATE TABLE → 인덱스 → 기존 DB 보정(ALTER) 순이라, 새 DB 에서는 CREATE TABLE 이
컬럼을 만들어 주지만 **기존 DB 에서는 컬럼이 맨 끝 ALTER 로 생긴다.** 인덱스를 위에 두면
ON_ERROR_STOP 에서 나머지 보정까지 통째로 안 돈다 — 새 DB 에서만 테스트하면 안 보이는 종류다.

- uq_users_provider_uid 를 ALTER 섹션 끝으로 옮기고, 왜 거기 있어야 하는지 주석으로 못 박았다

dev DB 재적용으로 확인: ALTER 4건 + CREATE INDEX 통과, company.users 에 provider/provider_uid
생성 및 password NULL 허용 확인.
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
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
08a95e2bd7 .gitignore: CLAUDE.md 심볼릭 링크를 전역 무시에서 되살린다
최초 커밋에서 CLAUDE.md 가 빠져 있었다. 레포 .gitignore 에서는 뺐지만
이 머신의 ~/.gitignore_global 5번 줄이 CLAUDE.md 를 전역으로 무시한다.
전역 설정은 사람마다 달라 레포가 의존할 수 없으므로 `!CLAUDE.md` 로
레포가 스스로 되살린다.

내용 자체는 AGENTS.md 로 이미 커밋돼 있었다 — 빠진 건 링크뿐이다.
2026-08-31 13:58:34 +09:00
6784e59ca5 최초 커밋 — 기존 코드 전체 + 문서 체계 신설
git 저장소가 없어 히스토리·협업 기반이 아예 없던 상태를 연다.
함께 문서를 재편했다. 그동안 문서가 있어도 "이 제품이 뭘 푸는가"와
"어떻게 도는가"를 담은 문서가 없어서, 목표 문장이 backend/frontend
README 두 곳에 복붙돼 있었다 — 상위 문서가 없어 아래로 샌 것이다.

신설
  README.md               레포 진입점 + 문서 지도 + 문서 규칙 4가지
  AGENTS.md               에이전트·신규 합류자용 함정 목록과 규약
                          (CLAUDE.md 는 여기로 걸린 심볼릭 링크)
  docs/PRODUCT.md         제품 정의 — 문제·사용자·원칙·**non-goals**·성공 기준
  docs/ARCHITECTURE.md    payload 경계·발행 파이프라인·서빙 결정·앱 분리 설계

이동
  backend/docs/DECISIONS.md → docs/DECISIONS.md
    백엔드만의 결정이 아니다. 게다가 코드 주석 ~25곳이 이미
    `docs/DECISIONS.md` 로 적고 있어 레포 루트 기준으로는 그게 맞다.

갱신
  docs/DEPLOY.md          서빙 결정 반영 — nginx 정적 서빙이 지금 경로(3절),
                          Azure 는 나중에 켤 때(4절)로 분리
  docs/ARCHITECTURE.md    사이트 = 한 장(2026-08-31) 구조 반영
  docs/COLLECTION_SEO_AEO_FLOW.md
                          robots.txt·sitemap.xml 은 오리진 루트에만 굽는다는 점 명시
  frontend/site/scripts/prerender.ts
                          헤더 주석의 렌더 보고서 경로가 실제(422줄)와 달라 수정

.gitignore
  ★ CLAUDE.md 를 더 이상 무시하지 않는다. 에이전트 지침은 팀과 모든
    에이전트가 공유하는 규약이라 커밋해야 한다 — 무시하면 클론한 사람이
    "배포 후 republish_all.py 필수" 같은 함정을 전달받지 못한다.
    개인용 오버라이드는 ~/.claude/CLAUDE.md 에 둔다.
2026-08-31 13:57:59 +09:00