Commit Graph

22 Commits

Author SHA1 Message Date
46171b4f24 Merge branch 'feature/social-post' 2026-09-18 09:00:07 +09:00
1f26bc7065 [feat] solution: 날씨 조건 세분화,
공식채널 단일화, 한일옥 거리 반영
날씨 조건을 7종으로 세분화,
축제 종료 여부와 무관하게 상시 노출,
'지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
2026-09-17 17:02:48 +09:00
dbae2d4f35 [feat] solution/backend: LLM 공급자를 OpenAI 기본값으로 전환, Perplexity 실비용 계측 추가
## 1. Gemini -> OpenAI 공급자 추상화

Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를
OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다.

- services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult
- services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가
- services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16):
  gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is
  supported") — 아예 안 보낸다.
- services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로
  둘 중 하나를 고른다.
- gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고
  내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경.
  단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다.
- config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가.

## 2. Perplexity 실비용 계측 추가

OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니,
services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서
실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리
`{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로
가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸
라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다.

- services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다
  — 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다)
- external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py·
  external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가

실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상),
OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006).

검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP
계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→
소개문·FAQ→발행까지 라이브로 왕복 확인.

## 3. site/EssentialInfoSection.tsx

미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:30:11 +09:00
dd3ad86715 Merge branch 'main' into feature/crawler 2026-09-14 19:45:25 +09:00
a093921b62 feat(backend,site): NOL 수집 및 숙박 안내 구조화와 지역 콘텐츠 연동
수집한 객실·이용 정보가 발행 화면에 연결되지 않던 경로를 보완하고, 숙소 소개와 지역 맛집 표시를 개선한다.

- NOL 브라우저 수집 어댑터와 수집·반영 스크립트 추가
- 크롤링 fact 즉시 노출 및 직접 입력·정정값 보호
- 이용안내 항목별 구조화와 기존 표 연결, 원문 UI 비표시
- 군산 한일옥 고정 등록과 지역 맛집 탐색·보강 경로 추가
- 숙소 소개 요약, 히어로 문구, 지역 콘텐츠·목업 표시 개선

검증: 작업 트리 기준 site 타입·린트·빌드 및 안내 렌더링 테스트 통과, PC·모바일 화면 확인. 스테이징 diff 공백 검사 통과. 사용자 요청에 따라 현재 스테이징된 55개 파일만 포함하며 미스테이징 문서·테스트 등은 제외.
2026-09-14 19:40:04 +09:00
민헌
8a09af6599 [feat] solution,postgres-init: FAQ 를 20개까지 채운다 — 펜션 공통 질문 30개 + 문의 안내
COPY 잡은 확인된 fact 로만 FAQ 를 써서 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건,
산하연 풀빌라 fact 4건 · FAQ 4건). fact 가 0건이면 start_copy 가 FAQ_UNGROUNDED 로 잡을 만들지 않아 0개였다.
생성 상한을 20으로 올리고, 모자라면 펜션 카탈로그에서 겹치지 않는 질문을 **문의 안내** 답으로 채운다.
공통 답에 값·가능 여부를 적으면 업종 시드 FAQ 가 가공의 가격을 사이트에 내보낸 사고와 같다 —
답은 "…은 전화(…)로 문의해 주시면 안내해 드립니다" 뿐이고, 그래서 화면에만 나간다.

- common/faq_catalog(신규): 로더 + resources/pension.json 30문항. fact_keys 가 업종 스키마에 없으면 로드 시 예외
- services/faq_fill.py(신규): 고르기 규칙 — fact 로 답할 수 있는 질문 · 기존 FAQ 와 근거 key 또는 질문 키워드가
  겹치는 질문은 건너뛴다(LLM 은 "주차 및 와이파이" 처럼 묶어 쓰고, 사장님 입력은 근거 key 가 없다)
- copy_service: max_faqs=20, 생성 뒤 _fill_faqs. 근거가 없거나 키가 없으면 LLM 없이 채우기만
- place_service.start_copy: 카탈로그가 있으면 fact 0건이어도 잡 생성(FAQ_UNGROUNDED 는 카탈로그 없는 업종만)
- SourceType.TEMPLATE=5(백엔드·shared·orval 모델). fact_service 규칙 4 로 fact 에는 못 쓴다
- faq_crud.expire_generated: TEMPLATE 도 재생성 때 내린다 — 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남지 않게
- prompts/copy: fact 로 답할 수 있는 카탈로그 질문을 싣고 "한 문항 한 주제" 규칙(생성 FAQ 4건 중 3건이 묶여 있었다)
- shared selectAnsweredFaqs · jsonld · llms · prerender(↔ conftest) · seo_audit: 문의 안내는 FAQPage JSON-LD ·
  llms.txt · 고유 콘텐츠 계수 · FAQ 점수에서 뺀다 — 모든 펜션에 같은 문구라 세면 빈 사이트가 게이트를 통과한다
- site FaqSection: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 문구를 달지 않는다
- frontend FaqPanel "노출 N건 (문의 안내 M)" · notifyCopy 가 faq_fill 을 본다
- postgres-init: 컬럼 변경 없음(CHECK 없는 SMALLINT). 0012 + init.sql 에 generated_by·source_fact_ids COMMENT ON,
  0012 는 컬럼이 있을 때만(DO $$ IF EXISTS). init.sql 의 "비면 발행 게이트가 반려" 주석은 사실이 아니어서 고쳤다
- docs/DECISIONS.md 8절 · DATA_MODEL.md · DEVLOG.md

백엔드 664 passed(신규 test_faq_fill 10건 · test_copy_api 3건). 실패 2건은 이 변경 전 HEAD 에서도 같다:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다
site·frontend·admin tsc 통과 · site vitest 63 passed · FaqPanel·collectNotify eslint 통과
로컬 실사업장(하늘물빛정원, fact 4건): 생성 4건 + 문의 안내 16건 = 20건, 질문 중복 0
0012: 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011yLDuinzgyCxmqAutE1tse
2026-09-14 17:05:43 +09:00
3342981bd2 feat(backend): Gemini 기반 소개문 요약 함수 추가
캔버스 미리보기에서 intro/room_intro 원문이 길 때 CSS로 자르는 대신
실제 문장을 줄여 보여주기 위한 첫 단계. DB에는 저장하지 않고 프로세스
메모리 캐시(sha256 키)로 같은 원문의 재호출만 막는다.
2026-09-14 17:01:00 +09:00
0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00
민헌
b2c8bb033e [feat] solution/backend,site: 발행 사이트 제목·keywords 메타에 SiteOntology 키워드 — 이 가게 자료로 거른 것만
숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를
받고, 이 가게의 확인된 자료로 거른 것만 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
실측(2026-09-14, 스테이머뭄 프로필): 추천 10건 중 `군산 독채 마당 펜션`·`군산 독채 복층 펜션`·
`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다 — SiteOntology 사실 필터는 수용 인원과 일부 시설만 본다.
사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. 메타 태그와 제목은 AI 검색이
그대로 읽는 자리라, 키워드의 모든 낱말이 이 가게 자료에 있을 때만 싣는다.
SiteOntology 쪽 함정도 실측으로 막았다 — 없는 regionId 는 500(외래키), 해석 안 된 query 도 201 로
입력 문자열 검색 결과를 준다.

- services/external/site_ontology.py: publish(generate:false) → match 두 번 호출. 500 이면 지역 없이
  재시도, resolved 가 우리 place_id 가 아니면 실패로 본다
- services/seo_keywords.py: 스냅샷 → 프로필(있음=features · 없음=뺌 · 모름=unverified), 낱말 대조 거르기,
  업종어뿐인 단어 제외, 제목은 유형 레인 코어 중 시·군 이름을 품고 예약·추천이 없는 것. 숙박만
- services/build_service.py: 스냅샷 직후 호출해 snapshot["seo"] 에 싣는다(= 발행 기록). 실패해도 발행 계속
- services/site_payload.py · shared site-payload.ts: 선택 필드 `seo` — 옛 payload·목업은 그대로
- site/src/seo/meta.ts · head.ts: 제목 `<상호> · <대표 키워드>`(15자 미만이면 예전 제목), keywords 태그는
  키워드가 있을 때만(빈 태그를 만들지 않는다)
- config_models.py · .env.example: SITE_ONTOLOGY_URL — 비우면 호출하지 않는다
- docs/ARCHITECTURE.md 발행 파이프라인 · docs/DEVLOG.md

pytest tests/test_seo_keywords.py 16 passed · 백엔드 전체 650 passed(실패 2건은 이전부터:
test_rate_limit_closes_the_tap · test_사이트_디렉터리_밖의_thumbs_에_올린다)
site tsc·eslint 통과 · vitest 63 passed · frontend·admin tsc 통과
실제 발행 한 바퀴(로컬 SiteOntology :3100): 스테이머뭄 → `<title>스테이머뭄 · 군산 독채펜션</title>` +
keywords 9건, 렌더 게이트 불일치 0건. SiteOntology 없는 payload 는 제목·head 가 예전 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBR4o9Nth3g4eoQEnL1iat
2026-09-14 14:38:24 +09:00
38bb4be9e5 [feat] solution,postgres-init: 발행하면 이 숙소의 노래가 한 곡 생긴다 — 가사 Gemini · 작곡 Suno
/s/stay 시안의 헤더에는 노래 플레이어가 있는데 그건 손으로 채운 목업이라, 새로 발행한
사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다.

★ 발행이 노래를 기다린다. BUILD 잡이 스냅샷을 뜨기 **전에** 곡을 만든다 —
  먼저 굽고 나중에 붙이면 사장님이 [사이트 열기] 로 보는 첫 화면에 그 기능이 빠져 있다.
  값은 발행이 30~40초(실측, 상한 5분) 늦어지는 것이고 그건 감수한다.
  단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이
  실패하면 노래 없이 발행되고 사유가 빌드 로그와 place_songs.last_error 에 남는다.

★ 가사를 우리가 쓴다. Suno 에 주제만 던지면 가사를 저쪽이 짓고, 거기엔 이 숙소에 없는
  것(수영장·조식)이 섞이는데 검증할 방법이 없다 — 다른 모든 문장은 확인된 fact 로만 쓰면서
  노래만 지어낸 말을 싣는 꼴이다. 소개문과 **같은 재료**로 Gemini 가 쓰고 Suno 는 곡만 붙인다.
  가사에 ground_check 는 걸지 않는다(정서는 fact 로 대응되지 않는다). 대신 프롬프트가
  없는 시설·숫자를 말하지 말라고 못 박는다 — 요금을 노래에 넣으면 틀렸을 때 고쳐 부를 수 없다.

★ Suno 주소는 만료된다. 그 주소를 payload 에 실으면 발행 직후엔 재생되고 몇 주 뒤 조용히
  죽는다. mp3 를 받아 보관하고 우리 경로(/s/<slug>/<song_id>.mp3)만 내보낸다.
★ 콜백이 아니라 폴링이다. 우리 백엔드는 Suno 가 닿을 수 있는 주소가 아니라, 콜백을 믿으면
  "요청은 성공했는데 결과가 영영 안 옴" 이 된다.

- services/external/suno.py: 작곡 요청 + record-info 폴링(10초 간격·상한 5분) + 내려받기
- services/external/gemini_text.generate_song · prompts/song.py: 가사·제목·장르
- services/song_service.py: 재료 → 가사 → 작곡 → 파일 보관. ensure_song 을 빌드가 부른다
- build_service: publish 일 때만 ensure_song 을 먼저 부르고 그 뒤 스냅샷(미리보기는 안 만든다 — 유료)
- place_songs 표 신설(init.sql + 0010 마이그레이션 + ORM). 검증 상태가 없다 —
  수집한 사실이 아니라 창작물이라 "맞는가" 가 아니라 "만들어졌는가" 만 묻는다(SongStatus)
- snapshot·site_payload·shared: READY 인 최신 한 곡만 싣는다. audioUrl 은 우리 경로다
- prerender: songs/ 의 파일을 사이트 디렉토리로 복사하고 **지난 발행의 곡은 치운다**
  (발행마다 새 곡이라 안 치우면 1MB 짜리가 쌓이고 블롭에도 그대로 올라간다)
- site/SongPlayer: 헤더의 작은 플레이어. 자동 재생하지 않고, 곡이 없으면 아무것도 안 그린다.
  패널은 hidden 으로 여닫는다 — 조건부 렌더면 닫힌 동안 제목·가사가 DOM 에 없어 크롤러가
  못 읽는다(오디오 안의 말은 어차피 못 듣는다)
- azure_static: .mp3 content-type 과 immutable 캐시. 블롭 업로드는 발행이 사이트째 한다 —
  업로더를 하나 더 두면 같은 컨테이너에 경로·캐시·정리 규칙이 두 벌 생긴다
- compose: solution/site/songs 볼륨. .env.example 에 SUNO_API_KEY·SUNO_CALLBACK_URL

검증: 실제 발행(스테이,머뭄 v15) — 가사 154자 $0.0014 → 작곡 40초 → 1.98MB → 스냅샷(노래 1)
→ 발행 완료. /s/스테이머뭄-99a887f8 200, mp3 200 audio/mpeg, HTML 에 제목·가사·주소 확인,
지난 곡 404. tsc --noEmit · eslint · vitest 55 passed(신규 4) · 백엔드 관련 188 passed
(실패 5건은 전부 컨테이너 환경 유입 — 프론트 소스 부재·SITE_PUBLIC_HOST)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:21:16 +09:00
a7fcc14e7d [feat] solution: 인물 사진은 위키미디어에서 · 사진 없는 엽서는 싣지 않는다
시안과 나란히 놓으니 세 가지가 달랐다(2026-09-10 실측).
① 엽서 네 장이 **같은 사진**이었다 ② 그 네 장이 전부 같은 박물관의 관람안내(주소·휴관일·
운영시간)였다 ③ 인물 열전은 사진이 한 장도 없었다.

★ 인물 사진 — 위키미디어 공식 API (services/external/wikimedia.py)
  공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다. 인물 사진이 공개돼 있으면서
  **재게시 권리를 기계가 읽을 수 있게** 알려주는 곳은 사실상 위키백과·위키공용뿐이다.
  - 크롤링이 아니라 MediaWiki API 다. 페이지를 긁어 파싱하지 않는다
  - 권리 판정을 여기서 끝낸다: 위키에는 자유 저작물만 있지 않다 — 인물에는 특히 '공정 이용'
    (비자유) 파일이 섞이고, 그걸 발행본에 실으면 상업적 이용이라 바로 침해다.
    PD·CC0·CC BY·CC BY-SA 만 통과시키고, NC·ND·fair use·판정 불가는 버린다
  - 통과한 사진에는 **출처 표시가 따라붙는다**(CC BY 계열의 조건). imageCredit 이 그 값이고
    화면에 찍는다 — 표시하지 않을 거면 애초에 쓰지 않는다
  - 실측: 10명 중 2명(전봉준·신석정류)만 자유 저작물이 있다. 나머지는 없는 것이 정답이고
    그 자리는 렌더러가 이니셜로 세운다. 비슷한 이름의 다른 사람 사진을 붙이는 게 더 나쁘다

★ 사진이 본체인 종류는 사진 없는 항목을 버린다 (_IMAGE_REQUIRED_KINDS = postcard)
  엽서는 앞면 사진이 본체다. 없으면 뒷면만 남아 빈 카드로 보인다. 연표는 다르다 —
  활자만으로도 레일에 서므로 버리면 오히려 구멍이 난다. 실측: 12건 중 6건이 빠졌다

★ 같은 사진을 두 번 쓰지 않는다
  네 항목이 같은 시설을 말하면 검색이 같은 사진을 네 번 준다. 두 번째부터는 없는 것으로 친다

★ 프롬프트(shared 한 벌, export 포함)
  - postcard: "같은 대상을 두 번 쓰지 않는다" · "운영시간·휴관일·주소·요금은 엽서에 적지
    않는다 — 그건 이용 정보지 엽서 문장이 아니다" · place 가 사진을 찾는 열쇠임을 명시
- shared/section-data: PeopleItem·ChronicleItem·PostcardItem 에 imageCredit 추가
- site: SourceLine 이 사진 출처를 함께 찍는다(글 출처가 없어도 사진 출처만으로 한 줄 선다).
  엽서는 사진 바로 아래에 따로 찍는다 — 앞면이 사진이라 거기 붙는 게 맞다

실측(전북 군산시) 발행본: 인물 2장 · 엽서 6장(전부 고유) · 연표 3장(전부 고유),
사진 11장 모두 출처 표시. site vitest 51 passed · story·snapshot 23 passed.
2026-09-10 15:19:23 +09:00
56ed951a6e [feat] solution/backend,shared: 지역 이야기에 사진을 붙인다 — 수집하는 그 자리에서 함께
지역 이야기(연표·엽서)가 활자만으로 서 있었다. 계약(`shared/section-data.ts`)과 렌더러는
`imageUrl` 을 이미 받는데 **아무도 채우지 않았다** — 생성 프롬프트가 사진을 묻지 않고,
붙이는 코드도 없었다.

★ 이미 DB 에 있는 사진을 가져다 쓰지 않는다. 그건 "이 항목의 사진" 이 아니라 "마침 우리가
  갖고 있던 사진" 이고, 엉뚱한 장소가 그 해의 사진으로 붙는다. **이야기를 만드는 그 순간
  그 대상의 이름으로** 찾아온 것만 쓴다.

★ 사진을 모델에게 묻지 않는다. 모델이 주는 이미지 주소는 대개 존재하지 않거나 남의
  저작물이다. 공공데이터(TourAPI)가 그 대상의 사진으로 준 것만 쓴다.

- external/tour_api.find_image: 이름으로 사진 한 장. 권리 판정은 이 파일의 기존 규칙 그대로
  공공누리 **Type1·Type3 만**(발행본은 상업적 이용이라 Type2·Type4 는 못 싣고, 유형을
  모르면 버린다). searchKeyword2 는 전국에서 이름만 맞으면 주므로 **주소에 지역 토막이
  없는 결과는 버린다** — '군산항' 을 찾다 다른 지역 동명 시설이 붙으면 그 사진은 이 이야기와
  아무 관계가 없다. 실패하면 None 이다(사진 한 장 때문에 생성을 실패시키지 않는다)
- story_service._attach_images: 연표(place→title) · 엽서(place→postmark)만 찾는다.
  **인물은 넣지 않았다** — 공공데이터는 관광지 사진을 주지 사람 얼굴을 주지 않는다.
  열 명을 찔러야 0건이고, 그 자리는 렌더러가 이니셜로 세우도록 이미 설계돼 있다
  (PeopleItem.imageUrl 주석). 가요·퀴즈도 시안에 사진이 없다. 종류당 6건 상한
- shared/section-prompts(chronicle): "찾아갈 수 있는 자리가 있으면 **반드시** 적는다" 를
  더했다. 기존 문구가 "없으면 뺀다" 뿐이라 모델이 행정 개편류에 place 를 통째로 비웠고
  (실측: 12건 전부 비었다), 그러면 붙일 사진을 찾을 키워드가 없다.
  프롬프트는 한 벌이라 `npm run export:prompts` 로 백엔드 JSON 도 함께 갱신했다

실측(전북 군산시): 연표 3장 · 엽서 6장이 발행본에 실렸다(공공누리 Type1/Type3).
site tsc·eslint 통과 · story·tour_api 테스트 59 passed.
2026-09-10 15:08:03 +09:00
7b238efffb [feat] solution/backend: 업소 조사 — 소개문이 쓸 재료를 출처와 함께 찾아온다
소개문이 "군산시에 있는 스테이,머뭄입니다. 주차 가능." 한 줄이었다. 생성기 잘못이 아니라
**쓸 재료가 그것뿐**이었다 — 네이버 플레이스가 준 fact 3건이 전부이고, TourAPI 는 미등록,
예약 페이지와 인스타그램은 robots 가 자동 수집을 금지한다. 그런데 이 업소의 내력
(1925년 적산가옥 · 히로쓰 가옥 후문 옆 · A동 B동 컨셉)은 블로그·기사에 공개돼 있다.
그걸 가져오는 단계가 없었을 뿐이다.

★ 문장을 검색모델에게 시키지 않는다. 재료만 모으고 소개문은 지금처럼 Gemini 가 쓴다 —
  소개문을 바로 시키면 그 문장의 근거를 우리가 못 갖고, ground_check 가 전부 반려한다.

- services/prompts/place_research.py: 사실 조각을 **출처와 함께** 요구한다. 요금·객실 수·
  체크인·취소 규정은 묻지 않는다 — 그건 fact 이고 블로그의 옛값이 섞이면 예약 클레임이다
- services/grounding/place_research.py: 출처 없는 항목은 버린다(story 와 같은 규율) +
  **상호 대조**를 더한다. 지역 이야기는 틀려도 지역 이야기지만, 업소 조사가 틀리면 남의
  가게 이야기가 이 사장님 소개문이 된다 — 이 레포에서 가장 비싼 실수다
- services/place_research.py: 조사 → place_channels.raw 에 근거 적재. **확정하지 않는다** —
  남이 쓴 글이라 공식 채널·sameAs 로 나가면 안 된다. 새 표를 만들지 않았다
- copy_service: 확정 링크만 읽던 근거를 raw.kind=research 까지 넓혔다. 확정 여부는
  "화면에 채널로 낼 것인가" 의 판단이지 "근거로 읽을 것인가" 의 판단이 아니다
- prompts/copy: 조사 기록을 fact 목록이 아니라 **별도 절**로 준다(fact 자리에 섞으니
  모델이 값 하나로 읽고 안 썼다). 소개문 분량 100~250자 → 200~600자·2~3문단 —
  옛 길이로는 확인된 사실을 나열하면 끝나 기록이 들어갈 자리가 없었다
- collect_service: 수집이 끝난 **뒤** 조사한다. 앞에 두면 네이버·TourAPI 가 이미 준 것을
  다시 묻는 꼴이라 검색 요금이 헛돈다

실측(스테이,머뭄): 조사 8건 채택·0건 버림 → 소개문이
"1925년에 지어진 100년 된 적산가옥을 리노베이션한 숙소 … 히로쓰 가옥 후문 바로 옆" 으로.
발행본 본문 10,798자 → 11,100자. 생성물은 여전히 PENDING_OWNER 로 들어가 사장님이 확인해야
노출된다(절대규칙 1).
2026-09-10 14:17:44 +09:00
ee51d89e83 [fix] solution/backend,site: 지역 이야기가 영영 안 생기던 것 + 자체 홈페이지 자동 등록
스테이,머뭄으로 실제 발행해 시안(/s/stay)과 대조한 결과에서 나온 셋이다.

★ 지역 이야기 다섯이 통째로 비어 있었다 (섹션 13 → 18)
  `has_stories()` 가 "이 지역에 이야기가 있나" 를 `kind IS NOT NULL` 로 판정했다. 그런데
  kind 는 이야기 전용 칸이 아니다 — 마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를
  채웠다(AREA_KIND). 그래서 주변정보가 한 건이라도 들어온 지역은 이야기가 0건이어도
  "이미 있다" 로 판정돼 생성이 영영 건너뛰어졌다. 잡은 성공으로 끝나고 로그도 조용해서
  생성기가 없는 것처럼 보였다. STORY_KINDS 를 명시해서 고친다.
  실측(전북 군산시): 고친 뒤 54건 생성(가요 8·인물 10·연표 12·엽서 12·퀴즈 12),
  발행본 섹션 13 → 18, 본문 6,598자 → 10,798자.

★ 업소 자체 홈페이지를 아무도 등록하지 않고 버리고 있었다
  네이버 지역검색 응답의 `link` 가 업체 홈페이지인데(external/naver.py 머리주석이 "채널 URL
  발견에 쓸 수 있는 부수입" 이라 적어 뒀다) 채널로 등록하는 코드가 없었다. 숙박은 자체
  홈페이지 보유율이 3업종 중 가장 높고(표본 25건 중 19건), 네이버 플레이스가 fact 를 3건밖에
  주지 않는 업소에서는 **그게 유일한 공개 출처**다. discover_official_site 로 등록·확정한다.
  - 추측이 아니다. 네이버가 그 업소 레코드에 달아 둔 값이고 동일 업소 판정(pick_match)을
    통과했을 때만 쓴다 — discover_naver_place 와 같은 근거라 자동 확정한다
  - 수집 금지 호스트(인스타·OTA)도 **등록은 한다**. 크롤은 static_html 의 _DENY_HOSTS 와
    robots 가 막지만, 공식 채널·sameAs 로는 유효한 사실이다
  - ★ 검색어에 `naver.region_key()` 를 쓰면 안 된다 — 그건 지명이 아니라 행정구역
    코드('52군산시')라 후보가 0건이 된다(실측). `naver_place_lookup.region_hint` 로 쓴다
  - collect_service 에 남아 있던 옛 표 이름(place_links.DBType) 한 곳도 같이 고쳤다 —
    스키마 재편 때 놓친 자리이고, 실행되는 순간에만 NameError 로 터진다

★ 숙박 예약 분기 복구 — `booking: isLodging ? StayBookingSection : BookingSection`
  64ce467 에서 사라져 숙박 발행이 절대규칙 3 대조에 걸려 통째로 막혀 있었다.

검증: site tsc·eslint·vitest 51 passed. 백엔드 pytest 529 passed / 52 failed —
**52건은 이 변경 전 main 에서도 같은 수로 실패한다**(기준선 확인). 스테이,머뭄 실발행으로
공식 채널·예약 채널 노출과 18섹션 확인.
2026-09-10 13:48:44 +09:00
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
387783b766 [fix] solution/backend: 옛 테이블 이름 잔재로 빌더가 통째로 안 돌던 것
웹빌더가 세 자리에서 연달아 죽었다 — 가게 등록 · 수집 시작 · 수집 완료. 전부 같은 뿌리다:
DB 구조 재편이 테이블 이름을 옮기면서 **참조 세 종류 중 일부만** 따라갔다.

- **생성자 12군데** (`place_links(...)` → `place_channels(...)`)
  import 와 `DBType()` 은 고쳤는데 생성자를 빠뜨렸다. 클래스가 없어도 import 는 통과하므로
  기동은 정상이고, 그 줄이 실제로 실행되는 순간에만 터진다.
- **raw SQL 12군데** (`job.jobs` → `jobs`)
  잡 큐만 raw SQL 이라 ORM 이름 변경에 안 딸려 왔다. 큐가 안 도니 수집·비전·소개문·빌드·
  지역데이터가 하나도 못 들어간다. 화면에는 "버튼만 안 먹는" 것으로 보였다.
- **같은 이름의 속성 5군데** (`source.place_facts` → `source.facts` 등)
  이름만 보고 일괄 치환해 테이블과 무관한 자리까지 바뀌었다. `RawSource` 는 수집기 결과
  객체지 테이블이 아니다.
- **뗀 표를 계속 부르던 5군데** (`ai_check_results`)
  한 번도 쓰지 않아 마이그레이션이 뗀 표다. 부르면 SEO 진단이 통째로 죽는다.

★ 하나씩 터질 때마다 고치다가 멈추고 정적 검사로 남은 것을 한 번에 셌다 — pyflakes 가 19건을
  짚었다. 이 종류는 import 도 타입검사도 안 잡는다. 테이블 이름을 옮긴 뒤에는
  `python -m pyflakes services/ crud/ router/ worker/ common/ | grep "undefined name"` 을 돌린다.

검증: 직접 수집 실행(스테이,머뭄) — 잡 DONE · 재시도 0 · fact 2 · 사진 10 · 채널 2 저장.
정의 안 된 이름 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:08:51 +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
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
2026fde80f [feat] solution/backend: 상호명 공개 검색 + 업종 자동 판별 — 랜딩이 로그인 앞에서 부른다
랜딩 첫 화면이 상호명을 받으려면 검색이 로그인 앞에 있어야 하는데, 후보 조회는
place_id 와 토큰을 둘 다 요구했다. 로그인 관문을 에디터 진입 하나로 되돌려 놓고도
(b94daa9) API 는 그대로였다.

업종은 AI 를 한 번 더 부를 필요가 없다 — 카카오·네이버 검색 응답에 분류가 이미
들어 있고(category_group_code / category_name), 지금까지 받아 놓고 안 썼다.

- place.py: GET /v1/place/search 신설(인증 없음). ★ /{place_id} 앞에 둬야 한다 —
  뒤에 두면 "search" 가 place_id 로 잡혀 422 다
- place_category: AD5·CE7·FD6 우선, 없으면 분류 문자열. 못 정하면 None —
  억지로 고르면 틀린 스키마로 시작한다. HP8 은 피부과·성형외과일 때만
- kakao: KakaoPlace 에 category_group_code. 한글 분류는 바뀌어도 코드는 안 바뀐다
- rate_limit: 인증 없이 유료 API 를 부르는 경로라 IP 당 분당 20회(프로세스 메모리)
- 확정 경로(verify/candidates)는 인증 유지 — 남의 place_id 존재 여부를 열지 않는다

전체 562 passed
2026-09-03 09:41: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
9d25ed613e 구조: 사장님(solution)과 내부 운영(admin)을 두 앱으로 가른다
최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.

  backend/ frontend/{admin,site,shared}  →  solution/{backend,front,site,shared} + admin/

## 왜

내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.

그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
  local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
  나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
  (앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).

## admin 에 백엔드를 두지 않았다

내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.

## admin 의 `@` 는 solution/front/src 를 가리킨다

내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.

admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.

## 그 밖

- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
  127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
  VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
  compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
  디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
  (conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
  APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.

검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.

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