diff --git a/AGENTS.md b/AGENTS.md index 2109b7a..d59616b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,6 @@ | 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) | | 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | | **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | -| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | | 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) | | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | @@ -20,6 +19,8 @@ | **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) | | **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) | | **템플릿** 추가 · 렌더링 순서 · frontend/shared/site 역할 | [docs/TEMPLATES.md](docs/TEMPLATES.md) | +| 템플릿 **화면 규칙** (글자 · 간격 · 접기 · ✓ 표시) | [docs/TEMPLATE_DESIGN.md](docs/TEMPLATE_DESIGN.md) | +| **렌더링** 케이스별 흐름(정적 · 미리보기 · 발행)과 담당 파일 | [docs/RENDERING.md](docs/RENDERING.md) | --- diff --git a/README.md b/README.md index db1ef28..3f50fe5 100644 --- a/README.md +++ b/README.md @@ -55,13 +55,16 @@ postgres-init/ 스키마 DDL 의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다. 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md). +> **`admin/` 은 지금 쓰지 않는다.** 개발자용 사이트·유저 관리는 solution 앱 안의 개발자 메뉴로 +> 가볍게 처리하고 있다(DEVLOG 2026-09-23). 우리가 따로 관리해야 할 만큼 사이트·운영 규모가 커지면 +> 그때 `admin/` 을 개발한다. 그 전에는 새 기능을 여기에 붙이지 않는다. + ## 문서 지도 | 문서 | 언제 읽나 | |---|---| | [docs/PRODUCT.md](docs/PRODUCT.md) | 이 제품이 뭘 푸는지 · **안 하기로 한 것**이 뭔지 | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 발행 파이프라인 전체 · 두 앱과 한 백엔드의 경계 | -| [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | v19 설계서 대비 격차 · **개발 우선순위(P0~P4)** | | [docs/DECISIONS.md](docs/DECISIONS.md) | 미결 사항과, 코드가 그걸 어떻게 격리해 뒀는지 | | [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 | | [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) | diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index 68c1f16..699061d 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -1,1764 +1,176 @@ # 개발 일지 +무엇을 왜 바꿨는지 날짜순(새 것이 위)으로 요약한다. 결론·배경은 각 문서가 단일 출처고, 여기에는 +**나중에 같은 실수를 막아 주는 것**(결정의 이유·밟은 함정·실측값)만 남긴다. +2026-09-29에 요약본으로 다시 썼다. 원문 전체는 git 히스토리(이 파일의 09-29 이전 버전)에 있다. + +--- + +## 2026-09-28 — 숙박 템플릿 다섯 개 추가 (라운드 · 시네마 · 빅타이포 · 부티크 · 일러스트) + +국내 펜션 사이트 46곳을 모바일에서 재 보니 첫 화면 제목 14~24px, 본문 11~14px였다. 기존 템플릿도 +전부 작은 글씨 쪽이라, 토스·카카오뱅크·당근·해든스테이·스테이인터뷰를 390px에서 실측해 뼈대를 새로 만들었다. + +- `site/src/layouts/` 에 `round` `cinema` `bigtype` `boutique` `graphic`. 섹션·탭 네 개는 고택과 같고 + 예약 시트·폼·캐러셀은 고택 부품을 쓴다. 공통 구조 CSS는 `layouts/kit/kit.css`. +- ★ kit.css 에 고택의 글꼴 규칙을 넣지 않는다 — 넣으면 다른 레이아웃의 워드마크가 17.5px로 눌린다(실측). +- `graphic` 은 사진이 거의 없는 집용. 사진 0장이면 기존 발행 게이트("고유 콘텐츠 0건")에 걸린다. + ## 2026-09-28 — 템플릿 정의를 한 파일로 모았다 -템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이 -존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서 -달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다. - -- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다. -- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용). -- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. -- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다. -- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다. -- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다. - 병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다. - -- 고택(`paper`)을 `/s/stay2` 시안과 같게 다시 만들었다. stay2의 CSS를 `layouts/paper/paper.css`로 - 옮기고, 섹션마다 stay2 마크업으로 그린다. 하위 페이지는 탭으로 바꿔 한 HTML 안에 둔다. - 같은 머뭄 데이터로 구워 1280px·390px에서 stay2와 나란히 찍어 비교했다. -- 프로젝트 코드 주석을 한 줄로 줄이고 히스토리 주석을 지웠다(파일 478개). 파이썬은 정리 전후 문법 - 트리가 같은지, TS는 주석을 뺀 토큰이 같은지 대조했다. - -구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md). - -**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과. -백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다. - -## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로 - -admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안 -갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트 -하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다. - -- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부 - `RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` / - `crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다 - (`UserRole` 주석 원칙을 내부 화면에도 지킨다). -- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`). - `AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다. - ★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐, - 빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다. - 실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다. -- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만. - -**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트· -스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` · -`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요. - -## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다 - -실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"** -로 끝났다. 타임아웃이었다. - -★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드 -두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다. -"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다. - -**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회): -`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서 -답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한). - -★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는 -이미 "확인하고 있어요" 가 가 있다. - -★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도 -`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다. - -**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패). - -## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기 - -실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는 -대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다. - -- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게. -- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다. -- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 — - 대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다. -- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 — - 사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다. - -**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가). -전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다. - -## 2026-09-22 — 카카오 채널 웹훅(4단계) - -카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게 -만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태 -(`channel.py`)뿐이다. - -**★★ 인증 — 오픈빌더는 서명을 주지 않는다** -URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다. -1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`, -`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가 -404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다. - -**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다. -1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다** - (카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다) -2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억. - ★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다** -3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022). - ★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다** - -**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로 -끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에. -어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다. - -**밟은 것** — `DB_SESSION_MNG.execute_lambda` 는 **람다 반환값을 그대로** 준다(CRUD 관례가 -`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든 -예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다. - -**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출). -전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다. - -## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐) - -카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을 -`0` → `1` 로 돌렸다. **코드는 어제도 오늘도 그대로다** — 닫고 여는 일이 커밋을 되짚는 일이 -되면 안 된다는 어제 판단이 하루 만에 값을 쳤다. - -★ 기본을 켜도 **LLM 키가 없으면 안 열린다**(`runtime.is_configured` 가 스위치와 키를 둘 다 -본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다. - -★ 카카오 연결 카드는 아직 감춰져 있다 — `KAKAO_CHANNEL_PUBLIC_ID` 미설정. -채우면 코드는 발급되지만 **소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.** -채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이고, 웹훅이 붙는 쪽은 후자다. - -**검증** — `test_agent_runtime`(스위치 테스트를 새 기본값에 맞춰 갱신)·`test_kakao_link` 34 passed. - -## 2026-09-21 — 에이전트 화면 보류: 설정으로 닫는다(코드는 그대로) - -카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다(사장님 지시: "이 작업은 여기서 딱 -보류하고, 사용못하게 대화 할 수 있는 부분을 숨겨줘"). 채널이 없으면 대화창은 사장님에게 -**어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다. - -- `AGENT_CHAT_ENABLED` 신설(기본 `0`). `runtime.is_configured()` 가 스위치와 LLM 키를 **둘 다** - 본다 — 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다. -- `AgentChatDock` · `KakaoChannelCard` 둘 다 조건 미충족이면 `return null` 로 통째로 감춘다. - 연결 카드는 `connection_enabled=false` 가 기준이라 설정을 채우면 그대로 다시 나타난다. -- ★ **코드를 지우지 않았다.** 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다. - -★ Threads 카드와 판단이 갈린 것이 맞다 — 저쪽은 '자리는 두고 버튼만 죽인다'(사장님이 곧 쓸 수 -있는 기능이라 존재를 알려야 했다), 이쪽은 언제 열릴지 말해 줄 수 없어 감춘다. - -**검증** — `test_agent_runtime`(스위치 테스트 2건 추가)·`test_kakao_link` 34 passed. -`npm run lint` 통과. - -## 2026-09-21 — 사장님 에이전트 2단계: 도구 레지스트리 · 런타임 · 빌더 채팅창 - -**왜 카카오톡보다 이걸 먼저 만드나** -런타임이 채널을 모르므로, 채널·챗봇 심사 없이 **에이전트 전체를 빌더 화면에서 검증**할 수 있다. -웹훅 핸들러 안에 에이전트를 짜면 빌더에서 같은 걸 못 쓰고 심사가 끝나야 무엇 하나 확인되지 않는다. -카톡은 나중에 붙는 두 번째 입구다 — `runtime.chat()` 을 그대로 부른다. - -**한 일** -- `services/agent/tools.py` — 도구 넷과 등급 셋(`READ`·`REVERSIBLE`·`SEMI`). - `get_site_status`·`list_facts`·`set_fact`·`publish`. -- `services/agent/runtime.py` — 발화 → 도구 선택(LLM 1콜) → 실행 → 응답. 채널을 모른다. -- `services/prompts/agent.py` — LLM 네 겹 규약(`services/llm/__init__.py`)대로 프롬프트만 여기. -- `router/v1/agent/chat.py`, 프론트 `features/agent/AgentChatDock.tsx`(`/sites` 우하단). - -**세 가지를 모델에게 맡기지 않았다** -1. **등급** — 확인이 필요한지는 레지스트리가 못 박는다. 응답 스키마에 그 칸 자체가 없고 - 도구 목록에도 등급을 싣지 않는다. 모델이 정하면 프롬프트에 끼어든 한 줄이 확인을 건너뛴다. -2. **결과 문구** — 도구가 만든다. 모델이 쓰면 **하지 않은 일을 했다고 말할 수 있고** - 사장님에게는 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다. -3. **key** — `set_fact` 의 key 는 업종 스키마가 최종 판정이다. 모델이 없는 key 를 지어낸다. - -**확인(SEMI) 한 바퀴** — `publish` 는 고르기만 하고 실행하지 않는다. 화면이 [네, 해주세요] 를 -띄우고, 누르면 `{confirm:{tool,args}}` 로 다시 온다. ★ 서버는 그 값을 믿지 않는다 — 도구는 -레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다. 확인 절차가 검증을 건너뛰는 구멍이 되면 안 된다. - -**값을 고치면 재발행 안내를 함께 낸다** — fact 는 바뀌어도 사이트는 안 바뀐다. -이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다. - -**검증** — `test_agent_runtime.py` 17 passed. 그중 하나는 `tools.py` 소스에서 `crud` 직접 호출이 -없는지 실제로 검사한다(주석이 아니라 코드로 못 박는 자리). 테스트는 LLM 을 monkeypatch 해서 -실제 모델을 부르지 않는다. `npm run lint` 통과. - -## 2026-09-21 — 사장님 에이전트 1단계: 카카오톡 채널 신원 연결 - -**왜 이것부터인가** -카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 `user_id` 와 관계가 없다. -다른 엔드포인트는 전부 `place_crud.get_place(s, owner_user_id, place_id)` 로 소유자 범위를 -지키는데, 채널에서 온 발화에는 그 `owner_user_id` 를 줄 근거가 없다 — 매핑이 없으면 -**채널 진입점만 소유자 범위 밖**에 놓이고 채널에 말을 건 아무나가 남의 가게를 고친다. - -**한 일** -- `owner_kakao_links`(0021 + init.sql) — 부분 유니크 셋. 그중 `uq_kakao_link_channel_key` - (한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다. -- `services/kakao_link_service.py` — 발급·소비·조회·해제. 일회성은 코드 값이 아니라 - `WHERE status='PENDING'` CAS 한 문장이 보장한다. 실패는 전부 같은 에러(`KAKAO_LINK_CODE_INVALID`)다 — - "없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다. -- 코드는 sha256 만 저장한다. 사장님이 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧 - 연결 권한을 갖는다. 글자에서 `0·O·1·I·L` 을 뺐다 — 잘못 읽어 실패하면 원인이 화면에 안 보인다. -- `router/v1/agent/kakao.py` 셋(`link`·`link/code`·`link/disconnect`), 전부 `no-store`/`no-referrer`. -- 프론트 `features/agent/` — `/sites` 의 Threads 카드 옆에 나란히. 연결은 사람 단위라 같은 자리다. -- `config/agent_config.py` 를 `social_config.py` 와 **일부러 갈랐다** — SNS 게재는 되돌릴 수 없는 - 대외 발화, 에이전트는 자기 사이트를 고치는 창구. 승인 강도도 보관하는 것도 다르다. - -**★ 일부러 안 만든 것 — 코드 소비 엔드포인트** -코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다. -검증 없는 공개 소비 경로를 먼저 만들면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 — -이 표가 막으려던 바로 그 일이다. `redeem()` 은 서비스 함수로만 두고 라우터에 붙이지 않았다. - -**검증** — `test_kakao_link.py` 15 passed. 전체 백엔드 `780 passed / 50 failed`인데, -그 50건은 **같은 커밋 이전(HEAD)에서도 동일하게 50건**이다(워크트리로 대조 확인) — -`test_gemini*`·`test_site_theme`·`test_search_console_service` 등 기존 이슈이고 이번 변경과 무관하다. -`npm run lint`(frontend·admin·site) 통과. - -## 2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건 - -**한 일** -- **"지금 생성하기"가 구간을 받는다**(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 - 정해야하지 않을까" → "캘린더 UI로 날짜받게"). `POST .../post/generate?start=&end=` - (`blog_jobs.generate_range`) — 개별 생성과 같은 이유로 재고 상한(`REFILL_BELOW`)을 안 보고, - 이미 글이 있는 날짜는 LLM 호출 없이 건너뛰고, 소재가 떨어지면 그 자리에서 멈춘다. 응답에 - `requested`/`created` 를 같이 줘서 "N일 중 M일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는 - 버튼을 누르면 시작·끝일을 `` 두 개로 받는 다이얼로그가 뜬다. -- 기존 `blog_jobs.generate_now`(재고 상한 기반, "다음 빈 날부터 순서대로")는 삭제하고 - `generate_range` 로 교체 — 호출부가 이 엔드포인트 하나뿐이라 하위호환 어댑터 없이 바로 바꿨다. - -**실배포로 E2E 를 돌리다 잡은 버그 — `blog_service.generate_one` 의 죽은 import** -사장님이 "테스트하고 결과 알려줘"로 시켜서 로컬 docker 를 재배포하고 실제 API 로 전체 플로우를 -돌렸더니(회원가입→사업장→발행 시드→생성→개별생성→승인), "지금 생성하기"가 500 으로 죽었다. -원인: `from services.external.gemini_text import DEFAULT_TEXT_MODEL, is_configured` — -`DEFAULT_TEXT_MODEL` 은 애초에 그 모듈에 있던 적이 없다(LLM 공급자를 gemini/openai 로 가르는 -리팩터로 `services/external/gemini_text.py` 가 "소개문·FAQ 조립" 전용으로 바뀌면서, 모델 -상수·`is_configured`는 `services/llm/gemini.py`(`DEFAULT_MODEL`)로 옮겨갔다). pytest 는 이 -함수를 통째로 monkeypatch 하는 테스트뿐이라 이 import 자체가 실행된 적이 없어 26 passed 로도 -안 잡혔다 — **"단위 테스트가 초록"과 "실제로 돈다"는 다른 것**이라는 걸 이번에 실측으로 -확인했다. 고침: `services.llm.gemini` 에서 `DEFAULT_MODEL`·`is_configured` 를 가져오도록 -import 한 줄만 수정. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 27 passed(신규: 구간 생성 성공/거절). -전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·`test_search_console_service.py` -44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈, 앞선 라운드에서도 확인). `npm run -build -w @o2o/frontend` 통과. 로컬 docker 재배포 후 실제 API 로 회원가입→생성→개별생성→ -구간생성→승인→BUILD 잡 큐잉까지 end-to-end 확인(진짜 Gemini 호출 포함, 브라우저 확장이 -연결되지 않아 화면 클릭 대신 API 레벨로 돌렸다). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 — 탭 3개→2개로 되돌림, 생성 이력에 모델명, 빈 날짜 개별 생성 - -**한 일** -- **탭을 3개(이번 주·달력·생성 이력)에서 2개(블로그·생성 이력)로 되돌렸다.** 지난 라운드에서 - 카로셀·달력을 각자 탭으로 쪼갠 게 오독이었다(사장님 지시: "탭을 왜 이번주 달력 이렇게 - 나누고 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지") — 원래 - 요청은 "달력 위에 카로셀"이지 "카로셀 따로, 달력 따로"가 아니었다. 생성 이력만 별도 탭으로 - 남긴다(`BlogPostsPage.tsx` `Tab = 'main' | 'history'`). -- 달력 칸 배지 문구 "메일 발송됨" → **"발송완료"**(사장님 지시: "달력에 발송완료 된거는 - 되었다고 적으라고", `publishBadge`). -- **생성 이력에 어느 모델을 썼는지 추가**(사장님 지시: "생성이력도 상세하게 기록해놓으셈 - 어느 모델썼는지 등등"). 새 컬럼을 늘리는 대신 `place_posts.generation_meta`(jsonb) 한 - 칸에 `{"model": "..."}` 로 담는다(사장님 지시: "Jsonb 하나팟거 컬럼", - `migrations/0020_place_posts_generation_meta.sql`). `blog_service.generate_one()` 반환값을 - `str | None` → `tuple[str, str] | None`(본문, 모델명)으로 바꾸고, `PostCRUD.generation_batches` - 가 회차별 대표 모델(`MAX(generation_meta->>'model')`)을 같이 뽑는다. -- **빈 날짜 하나만 콕 집어 생성**(사장님 지시: "그리고 개별적으로 새로 만들수있게 해줘"). - `POST /v1/place/{place_id}/post/generate-one?date=`(`PostService.generate_for_date` → - `blog_jobs.generate_one_for_date`) — 재고 상한(`REFILL_BELOW`)을 안 본다, 콕 집은 날짜라 - 상한이 끼어들 자리가 아니다. 프론트는 달력에서 **오늘 이후의 빈 칸**만 누르면 그 날짜로 - 요청하고, 성공하면 그 자리에서 모달을 연다(`Calendar` `onGenerateDay`/`generatingDay`). - 지난 날짜 칸은 클릭을 막는다. - -**밟은 함정 — ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다** -`PostCRUD.add_one`을 처음엔 ORM 객체(`place_posts(**row)`)를 그대로 돌려주게 짰다. -`execute_lambda_write`는 `func(s)` 실행 뒤 **commit까지 하고** 값을 돌려주므로, -호출측이 그 객체의 속성(`post_id` 등)을 읽는 시점엔 세션이 이미 끝나 `DetachedInstanceError` -가 날 자리였다. `post_id`·`status`(둘 다 Python 쪽 `default`)는 `flush()` 직후엔 이미 -채워져 있으므로, **flush 직후 세션이 살아있을 때** 값만 plain dict 로 뽑아 돌려주게 고쳤다 -— ORM 객체 자체를 세션 밖으로 내보내지 않는다. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 26 passed(신규 3건: 개별 생성 성공·날짜 -중복 실패·소유권 스코프). 전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`· -`test_search_console_service.py` 44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈). -`npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 메일 — 승인 즉시 처리 + 수정 자동 로그인, 화면 탭 3개로 - -**한 일** -- 메일 승인 링크: GET 이 확인 화면 없이 **즉시 승인**(`router/v1/site/post.py`). 메일 - 프리페치에 노출된다는 걸 알고도 사장님이 택한 것 — POST `/approve`, GET/POST - `/v1/site/post/edit`(공개 편집 화면) 전부 삭제, `PostService.edit` 도 같이 지웠다. -- 메일 수정 링크: 이제 **로그인 흐름**이다. `CreateDayPassToken`(그날 자정 KST 까지만 - 사는 접근 토큰, `router/v1/validator/dependencies.py`)을 실은 - `/blog?placeId=&postId=&auto=` 로 간다. 빌더 앱이 그 토큰으로 로그인해 편집 모달을 - 바로 연다. -- **승인·수정 링크 둘 다 그날 자정(KST) 만료**로 통일(`blog_service.issue_token`, 예전 - 14일 → 자정). 그 뒤엔 로그인해서 빌더 앱에서 처리한다. -- 신규 엔드포인트: `GET .../post/{post_id}`(메일 수정 링크 전용 단건 조회), - `GET .../post/history`(생성 이력 — 언제 몇 건, 새 컬럼 없이 `created_at` 회차로 묶음). -- `BlogPostsPage.tsx` 를 탭 셋으로 재구성 — **이번 주 · 달력 · 생성 이력**. 카로셀 카드를 - 누르면 그 자리에서 고치는 대신 모달을 연다(미리보기용 `PostPreviewCard` 와 실제 편집용 - `PostCard` 분리). 달력 칸엔 발행완료/발행실패에 **메일 발송됨** 배지를 추가했다(크론잡이 - 실제로 돌았다는 확인). 이전 달/월/다음 달을 달력 탭 안, 달력 바로 위로 옮겼다. - -**밟은 함정 — 세션 복구보다 늦게 로그인시키면 이미 늦다** -`BlogPostsPage` 안에서 `auto` 토큰으로 로그인시켰더니 "메일온거 클릭했더니 로그인하라고 -뜨는데?" — `RequireAuth` 는 라우트 렌더링 시점에 `isRestoring`/`user` 를 보고 그 자리에서 -`/login` 으로 튕긴다. 페이지 컴포넌트는 그 판정 *이후에만* 마운트되므로, 컴포넌트 안의 -`useEffect` 로 로그인시키는 건 이미 늦다. `auto` 파라미터 처리를 세션 복구 -(`app/provider.tsx` `useRestoreSession`) 안으로 옮겨서 고쳤다 — JWT `sub` 클레임을 -그대로 디코드해(`lib/jwt.ts`, 서명 검증은 이미 서버가 함) `useAuthStore` 를 채운다. - -**밟은 함정 — raw SQL 로 timestamptz 에 naive UTC 를 바인딩하면 로컬 시간대로 샌다** -자정 만료로 정밀해지자 테스트 3개가 "이미 만료됨"으로 죽었다. 원인: 테스트 시더가 -`text()` 로 `token_expires_at` 에 naive datetime(`GTime.UTC()` 류)을 직접 바인딩하는데, -컬럼 타입 정보가 없는 raw 바인딩은 asyncpg 가 **드라이버 프로세스의 로컬 시스템 시간대**로 -해석한다 — 이 개발 머신은 KST(UTC+9) 라 9시간이 밀렸다. 예전엔 14일짜리 만료값이라 9시간 -밀려도 부호가 안 바뀌어 안 드러났을 뿐이다. ORM 경로(`update()`/`insert()`)는 컬럼의 -`DateTime(timezone=True)` 프로세서를 타서 이 문제가 없다 — 실제 운영 코드(`mark_sent`)는 -전부 ORM 이라 안전했다. 고침: 테스트 시더에서 바인딩 직전에 `.replace(tzinfo=timezone.utc)` -로 명시(`tests/test_blog_post.py`). **raw text() 로 timestamptz 컬럼에 naive datetime 을 -바인딩하는 코드를 다시 보면, 반드시 이 함정을 의심한다.** - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed. `npm run build -w -@o2o/frontend` 통과. mnchoi@o2o.kr 로 실제 메일 미리보기 발송 확인(가짜 place/post 라 -링크 자체는 동작하지 않음, 형식만 확인). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀은 일주일치·달력은 모달, scheduled_date NULL 백필 - -**한 일** -- `GET /v1/place/{place_id}/post/upcoming?days=7` 신설(`PostService.list_upcoming`) — 카로셀은 - 이제 브라우징 중인 달과 무관하게 **항상 오늘부터 7일치**만, 날짜 오름차순으로 본다. - 기존 `list_for_place` CRUD 를 월 경계 대신 (오늘, 오늘+N) 경계로 그대로 재사용했다. -- 카로셀 카드에 배정일 전부 표시 + 오늘/내일 카드에 chip. 마우스 오버 시 z-index 를 - 최상단으로 올려 겹친 카드가 안 가리게 했다(`PostCarousel` hover 상태). -- 달력 칸 클릭이 "카로셀로 스크롤"에서 **모달**(`Dialog`, 기존 `components/ui/dialog.tsx` - 재사용)로 바뀌었다 — 그 날짜의 글 전체 내용 + 수정·바로 발행 버튼을 그 자리에서 보여준다. -- 달력 이전/다음 달 이동을 **이번 달 ~ 1년 뒤**로 제한(`minMonth`/`maxMonth`, 문자열 - 비교로 버튼 비활성화). 그 밖의 달은 볼 이유가 없다(과거는 비어 있고, 미래는 아직 - 아무것도 배정 안 됨). - -**밟은 함정 — `scheduled_date` NULL 백필** -배포 직후 사장님이 "지금 생성하기"로 실제 만든 글 13건이 화면에서 통째로 사라져 보였다. -원인: 그 글들은 `scheduled_date` 컬럼이 생기기 *전에* 만들어져 값이 비어 있었는데, -월별·주간 조회 둘 다 이제 `scheduled_date` 로 거르는 바람에 `IS NULL` 행이 조용히 -빠졌다(SQL 에서 `NULL <= x` 는 항상 unknown). 실서버 DB 에 1회성 SQL 로 백필했다 — -업장별 `created_at` 순서를 살려 오늘부터 하루씩 순서대로 채움. 새 컬럼을 추가하는 -마이그레이션은 앞으로도 **기존 행에 값이 없을 때 조회에서 조용히 빠지는지**를 먼저 -따져야 한다. - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed(`upcoming` 엔드포인트 날짜 -필터·정렬 회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀(편집) + 달력(발행완료/실패만 표시) - -**한 일** -- `BlogPostsPage.tsx` 를 "리스트 + 달력 클릭 시 펼침" 구조에서 **카로셀(위) + 달력(아래)** - 둘로 나눴다. 카로셀(`PostCarousel`)은 이 달 글 카드를 겹쳐 쌓아 가로로 넘기는 형태고, - 편집·바로 발행 버튼은 이제 여기에만 있다. 달력(`Calendar`)은 보기 전용 — 칸마다 본문 - 앞부분 스니펫과 **발행완료/발행실패 배지만** 단다. 검수 대기·메일 발송 같은 발행 전 - 상태는 아무 배지도 안 단다. 칸을 누르면 카로셀의 해당 카드로 스크롤한다. -- `PostData` 에 `build_failed`(bool) 추가. `PostService._latest_build_failed` 가 그 - 업장의 가장 최근 BUILD 잡이 `JobStatus.DEAD` 인지 보고, APPROVED 인데 아직 안 나간 - 글에만 단다 — BUILD 잡 하나가 업장 승인분 전체를 한 번에 굽는 구조라 글 단위가 아니라 - "이 업장 재발행이 막혀 있나" 를 보는 것이다. - -**왜** -사장님 지시: "위에 겹치는 카로셀로 글들의 카드가 보이는거고 밑에는 달력에 내용앞부분 -약간이랑 발행되었는지 안되었는지 여부 이렇게 표시하면됨 발행전인건 표시하지 말고 -발행완료/발행실패 이것만 표시하면 될듯" — 앞서 만든 "오늘 게재됨/검토 대기" 요약 카드 -2장은 이 의도와 달랐다(집계 카드였지 개별 글 카로셀이 아니었다). - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 22 passed(발행실패 판정 회귀 테스트 -2건 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 빌더 화면 — 달력 + 배정일(scheduled_date) + 즉시 생성·바로 발행 - -**한 일** -- `place_posts.scheduled_date`(date) 추가(`migrations/0019_place_posts_scheduled_date.sql`, - `init.sql`, `models.py`). `(place_id, scheduled_date)` 유니크 — 업장 하나가 같은 날짜를 - 두 번 못 쓴다. 생성 시 그 업장의 `MAX(scheduled_date)` 다음날(없으면 오늘, KST)부터 하루 - 한 건씩 순서대로 배정한다(`blog_jobs._generate_for_place`). -- `PostCRUD.due_for_mail` 이 이제 `scheduled_date <= 오늘` 인 것만 고른다 — 미래 배정 글이 - 그날 되기 전에 새는 것을 막는다. `list_for_place`(빌더 화면 월별 조회)도 `created_at` 대신 - `scheduled_date` 기준으로 바꿨다. -- `BlogPostsPage.tsx` 를 리스트에서 **달력**으로 바꿨다 — 글이 0건이어도 달력 칸 자체는 - 항상 뜬다. 위에 **오늘 게재됨 · 검토 대기** 요약 카드 두 장을 살짝 겹쳐서 배치했다. -- **지금 생성하기**(`POST .../post/generate`) — 새벽 04:10 크론을 안 기다리고 그 자리에서 - 만든다. **바로 발행**(`POST .../post/{post_id}/approve`) — 안 고치고 그대로 승인. -- `SitesPage.tsx` 카드의 "더보기" 메뉴에 **디자인·컨텐츠 관리 / 미니블로그 관리 / - 예약요청 관리** 세 항목을 얹었다(탭이 아니라 메뉴 — 사장님 지시). 예약요청은 아직 화면이 - 없다 — `booking_request.py` 가 요청을 DB 에 남기지 않기로 한 결정(2026-09-16)과 부딪혀서 - 안내만 띄운다. - -**왜** -사장님 요청: "포스트들이 다 날짜가 정해져야하는데" — `created_at`(만들어진 시각)만 있고 -"언제 낼 것인가"가 없어서, 달력을 만들려면 화면이 근거 없는 날짜를 지어내야 했다. 또 -"생성된 포스트가 없어도 달력은 계속 떠야지" — 목록이 비면 화면이 통째로 빈 상태 문구로 -바뀌던 걸 고쳤다. - -**밟은 함정** — `PostCRUD.due_for_mail`/`list_for_place` 시그니처가 바뀌어(`today`/날짜 -경계 타입) 호출부를 같이 안 고치면 조용히 옛 컬럼을 봤을 것 — `_month_range` 를 -UTC datetime 경계에서 KST 순수 date 경계로 바꿔 타임존 변환 자체를 없앴다(scheduled_date 는 -timestamptz 가 아니라 DATE 라 변환이 필요 없다). - -**검증** — `test_blog_post.py`·`test_blog_owner.py` 20 passed(배정일 순서·업장당 하루 한 통 -회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과(typegen·tsc·eslint·vite build). -→ [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-17 — 미니 블로그 팀 사전검수 폐지 — 검수는 사장님이, 빌더 앱에 로그인 화면 추가 - -**한 일** -- `router/v1/site/blog_admin.py` · `services/blog_review_service.py` · `admin/frontend - BlogReviewPage` 삭제. 생성분은 금칙 필터(`is_publishable_body`)만 통과하면 곧장 - `REVIEWED` 로 쌓여 팀 개입 없이 발송 대상이 된다(`blog_service.filter_drafts`). -- `blog_jobs.py` `BATCH_SIZE`·`REFILL_BELOW` 25/40 → 30/30(한 달치). `send_reviewed()` 가 - `PostCRUD.due_for_mail`(`DISTINCT ON (place_id)`)을 써서 업장당 하루 한 통만 보낸다 — - 전엔 전체 업장을 섞어 오래된 순으로 뽑아 밀린 업장이 하루에 두 통 이상 받을 수 있었다. -- 메일 확인 화면에 **수정해서 올리기** 버튼 추가. `GET/POST /v1/site/post/edit` 신설 — - 저장하면 금칙 필터를 다시 타고, 통과하면 본문 갱신 + 그대로 승인. -- `router/v1/site/post.py` 에 `owner_router`(`/v1/place/{place_id}/post`) 신설 — 로그인 - 세션으로 이번 달 생성된 글을 보고, 메일이 아직 안 나간 `REVIEWED` 글도 바로 수정·승인. - `solution/frontend/src/pages/BlogPostsPage.tsx` + `SitesPage` 카드의 "관리" 메뉴에 - 진입점 추가. - -**왜** -2026-09-16 기획은 "팀이 먼저 거르고 사장님은 메일 클릭만" 이었는데, 다시 논의하면서 최종 -판단을 사장님에게 넘기기로 했다 — 팀 검수 단계가 병목이고, 사장님이 자기 사이트 콘텐츠를 -직접 못 보는 것도 이상했다. - -**하는 김에 잡은 버그** -`services/post_service.py` 의 승인 처리가 BUILD 잡 payload 에 `owner_user_id` 를 안 채우고 -있었다. `build_service.run_build:141` 은 `payload["owner_user_id"]` 를 무조건 읽으므로 — -**이메일 승인 클릭이 실제로는 사이트를 재발행하지 못하고 있었을 가능성이 높다**(잡은 -큐에 들어가지만 워커가 돌릴 때 KeyError). `place_id` 로 `owner_user_id` 를 직접 조회해 -채우도록 고쳤다. 회귀 테스트: `test_blog_post.py test_approve_enqueues_build_with_owner_user_id`. - -**결과** — `solution/backend` 전체 pytest 784 passed(기존에도 실패하던 `search_console` -스케줄러 잡 개수 검증 2건은 이번 변경과 무관 — `blog-drafts`·`blog-mail` 상시 잡이 늘어난 -탓, 별도 수정 필요). `tsc` 통과(solution/frontend · admin/frontend). → [MINI_BLOG.md](MINI_BLOG.md) - -## 2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결 - -원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어 -`PostCardToConversation` 호출마다 BadRequest. 플로우 재생성(웹훅 템플릿) + 채널로 지정해서 -해결, 실제 채널 게시 확인함. `TEAMS_WEBHOOK_URL` 갱신함(`.env`, 커밋 안 됨). - -## 2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다 - -`common/collect_diagnostics.py`(신규) + `collect_service.py` 채널별 실패 10곳 연결. -전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다. - -**검증** — `python3 ast` 파싱, 수동 실행 확인. - -## 2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게 - -**한 일** -- `services/copy_service.py` — 소개문·FAQ 생성(`generate` 단계)에서 `GeminiError` 가 나면 - 잡을 실패시키지 않고 `generate` 를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다. - 프론트 사유 라벨: `generationLabels.ts` `SKIP_REASONS.generation_failed`. -- `common/database/db_session_manager.py` — 유니크 제약 충돌(`IntegrityError`) 로그를 - ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라 - `services/collect_service.py` `_add_link` 가 이미 "이미 있으면 그만" 으로 처리한다. - -**왜** -API 키가 아예 없을 때는 이미 `generate` 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데 -**호출이 실패할 때만** 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠 -0건으로 막지 않고(`publish_gate.check_unique_content` — "얇은 콘텐츠로 발행을 막지 않기로 -했다"), 다른 곁들이 콘텐츠(자작곡 등, `build_service.py`)도 실패하면 로그만 남기고 계속 -진행한다 — 이 갈래만 예외였다. - -실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를 -쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다. -화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데 -잡을 죽일 이유가 없었다. - -유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속 -오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그). - -**남은 것** — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 · -잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데, -그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다. - -## 2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz - -- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회, - job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이 - 기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다. -- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 **게이트 반려가 - 아닌** 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비 - 실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다. -- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py - 와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다. -- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일). -- `/readyz` 추가 — `/healthz`(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다. - 서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가 - 이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다). -- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at - 을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert - 직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다. `func.now()`(DB 쪽 - 시계)로 비교하도록 고쳤다. -- 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가, - 16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py - (readyz 1건 추가, 2건) 전부 통과. -- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션 - 0016 서버 적용 — 전부 사용자 승인 후 별도 진행. - -## 2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화 - -- `docker-compose.yml` `solution-site`(운영 진입점) 빌드에서 `VITE_AUTO_LOGIN_ID`·`PW` - build arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나 - JS 에서 읽을 수 있었다. `nginx/Dockerfile` 도 그 ARG 자체를 안 받는다. -- `lib/autoSession.ts` 에 `import.meta.env.DEV` 가드를 더했다(둘째 안전판) — 운영 빌드는 - 이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로 - 운영 빌드를 돌려도 `build/client` 어디에도 그 문자열이 없는 것을 확인했다. -- `users.token_version`(마이그레이션 0015) 추가 — `refresh_token()` 이 지금까지 서명·만료만 - 보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지 - 계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고, - 비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절). -- 검증: `tests/test_auth.py` 16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후 - 거절). `tests/test_schema_ddl.py` 통과(ORM ↔ init.sql 일치). -- 운영 미적용: 실제 서버 `.env` 의 `AUTO_LOGIN_ID`·`PW` 값 확인·제거와 마이그레이션 적용은 - 이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다. - -## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기 - -- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다. -- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다. -- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다. -- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md). -- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다. -- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다. -- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과. - -무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다. -결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다. - ---- - -## 2026-09-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다 - -**추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서 -변경본 648 passed / 2 failed, 변경 전 HEAD 사본 635 passed / 동일한 2 failed를 확인했다. -실패는 기존 `test_rate_limit_closes_the_tap`·썸네일 호스트 기대값 검사이며 SNS 신규 13건은 모두 통과했다. -공용 테스트 DB에서는 다른 실행의 삭제/정리와 충돌했으므로 그 결과는 회귀 판정에서 제외했다. -`npm run lint`·전체 프론트 빌드 통과, site vitest 62 passed. -임시 payload를 실제 프리렌더해 데스크톱·모바일 하단 카드를 확인했고, SNS 글만 있는 payload는 -고유 콘텐츠 0건으로 발행 거부됨을 확인했다. 실제 Threads 게시·알림톡 발송·운영 배포는 실행하지 않았다. -운영 활성화 전제와 남은 정책은 [SOCIAL.md](SOCIAL.md)에 정리했다. - - -**무슨 일** — 발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고, -그건 검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 -짧은 글을 쓰고, 승인을 받아 **사장님 개인 계정**(스레드)으로 올린다. 올린 글은 발행본 맨 아래에도 실린다. - -**★ 이 변경의 크기** — 섹션 하나 추가가 아니다. 이 레포가 처음으로 ①외부에 **쓰기**를 하고 -②**남의 계정 자격증명을 보관**하고 ③**되돌릴 수 없는 행위**를 한다. 아래 결정이 전부 여기서 나왔다. - -**승인을 다시 둔다 — 7절의 예외** ([DECISIONS 7-1절](DECISIONS.md)) -7절("LLM 이 쓴 문장은 승인 없이 나간다")의 "왜 안전한가" 두 줄이 여기서는 둘 다 성립하지 않는다. -기준은 문장의 참/거짓이 아니라 **명의**(사장님 계정의 발언) · **되돌릴 수 있나**(없다) · -**무엇이 주로 틀리나**(문장이 아니라 링크 — `_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. -7절의 함정은 구조로 막았다: 시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, -승인 경로가 둘(알림톡·빌더)이며, 미승인은 만료되어 **화면에 보이게** 남는다. - -**★ 게시는 주소가 확정된 사이트에만.** `sites.domain` 이 비면 발행 슬러그가 **상호명에서 파생**되고 -(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다 — `SITE_SLUG_LOCKED` 는 `domain` 변경만 -막으므로 여기엔 안 걸린다. 이미 올라간 글의 링크는 404 가 되고 **그 글은 수정할 수 없다.** -→ `PUBLISHED` + `current_version_id` + `domain` 셋이 다 있을 때만 허용한다. - -**★ 승인은 GET 이 아니라 POST.** 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을 -연다. GET 승인이면 사장님이 안 눌렀는데 올라가고 로그에는 "승인됨" 으로 남는다. -일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다. - -**게시는 기본으로 꺼져 있다**(`SOCIAL_POSTING_ENABLED=0`). 초안·승인까지는 계약 없이 돌지만 -게시는 되돌릴 수 없어서, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다. -★ 1-4 가 이 기능의 **전제조건**이 됐다 — 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이 -"죽은 링크 정책 미정" 이 된다. - -**사진은 올리지 않는다.** 1-2(이미지 재게시)의 격리는 "나중에 필터로 뺄 수 있다" 는 전제 위에 있는데 -SNS 는 그 전제가 깨진다(플랫폼 서버에 사본이 생긴다). 게다가 지금 OWNER 사진은 존재할 수 없다(5-3). -→ 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않았다.** - -**플랫폼은 스레드다.** X 는 URL 이 든 글을 쓰는 데 **요청당 $0.20** 이 안내돼 있어(공식 가격표), -"계정 단위 고정비" 라는 처음 가정이 틀렸다 — 사이트마다 나가는 변동비다. 스레드는 직접 API 에 -건당 과금 안내가 없다. 어댑터 경계는 그대로 두되 X 어댑터는 넣지 않았다([API_USAGE 5절](API_USAGE.md)). - -**밟은 함정 둘** -- **ORM 기본값에 쉼표가 딸려 들어갔다.** `server_default=text("'[]',")` → `DEFAULT '[]', NOT NULL` - 로 나가 **CREATE TABLE 이 통째로 실패**했다. 운영 DB 는 init.sql 로 만들어져 안 드러나고 - **ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다 — 9월 10일의 `now()` 기본값 사고와 같은 자리다. -- **승인 스윕 주기가 1분이었다.** 쓰기 커넥션을 계속 집어 들어, 같은 컨테이너에서 도는 테스트가 - 커넥션을 못 받아 `TimeoutError` 로 무더기 실패했다(실측). 이 스윕이 하는 일은 "만료 표시" 와 - "중단된 초안 정리" 뿐이라 분 단위 정밀도가 필요 없다 → **5분**. - -**검증** — 백엔드 SNS 테스트 9건 통과(초안 dedup·owner 스코프 · 주소 고정 요구 · GET 프리페치가 -상태를 안 바꾸는지 · 승인 CAS 일회성 · 만료·중단 스윕). `tsc -b`·`eslint` 통과(shared·site·frontend), -vitest 58 passed(신규 3). 스케줄러를 끈 상태에서 snapshot·vision·social 26건 동시 통과. - ---- - -## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측 - -- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림. -- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다. -- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다. -- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행. - -**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현). - -## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구 - -- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거. -- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지. -- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리. -- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md). - -**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과. - ---- - -## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다) - -**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를 -발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고, -화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다. - -**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted) -`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다. -CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고 -그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다. - -→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고 - 저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다. -→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고, - 발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s//img/` 에 두고 payload 주소를 바꾼다) - 저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.** - ---- - -## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내) - -**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서 -겹치지 않는 질문을 골라 **문의 안내** 답으로 채운다. -``` -생성(fact 근거, 최대 20) → 노출 중 FAQ 세기(생성분 + 사장님 입력·정정분) - → 모자란 만큼 카탈로그 순서대로: fact 로 답할 수 있는 질문 · 이미 다룬 주제(근거 key / 질문 키워드) 건너뜀 - → "…은 전화(…)로 문의해 주시면 안내해 드립니다" (generated_by=TEMPLATE, VERIFIED) -``` - -**왜** — 확인된 fact 로만 쓰면 4~8개에서 끝났다(실측 로컬: 스테이머뭄 fact 8건, 산하연 풀빌라 fact 4건 · FAQ 4건). - -**★ 공통 답에 값을 적지 않는다** — 가게마다 다른 값(바비큐 가능·반려동물 불가·체크인 15시)을 공통으로 적으면 -업종 시드 FAQ 가 가공의 가격을 내보낸 사고와 같다. 답은 문의 안내뿐이고, 그래서 **화면에만** 나간다 — -FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수(prerender ↔ conftest) · SEO 감사 FAQ 점수에서는 뺐다. - -**바꾼 곳** -- `common/faq_catalog/`(신규): 카탈로그 로더 + `resources/pension.json`. fact_keys 가 업종 스키마에 없으면 로드 시 예외. -- `services/faq_fill.py`(신규): 고르기 규칙(순수 함수). `copy_service._fill_faqs` 가 부른다. -- `SourceType.TEMPLATE = 5`(백엔드 enum · shared · orval 모델). fact 에는 못 쓴다(`fact_service` 규칙 4). -- `postgres-init/migrations/0012_place_faqs_template_source.sql` + `init.sql`: 컬럼 변경은 없다(CHECK 없는 SMALLINT). - `generated_by` · `source_fact_ids` 에 코드값 뜻을 `COMMENT ON` 으로 남긴다. 0012 는 컬럼이 있을 때만 단다(`DO $$ IF EXISTS`). - init.sql 은 옛 주석("비면 발행 게이트가 반려한다" — 그런 검사는 없었다)을 고치고 같은 `COMMENT ON` 을 붙였다. -- `faq_crud.expire_generated`: TEMPLATE 도 재생성 때 내린다 — 안 내리면 새 fact 로 답이 생긴 주제에 옛 문의 안내가 남는다. -- 프롬프트: fact 로 답할 수 있는 카탈로그 질문을 싣고, "한 문항에 주제 하나" 규칙 추가 - (노출 중 생성 FAQ 4건 중 3건이 "체크인 및 체크아웃" 식으로 묶여 있었다). -- ★ fact 0건이어도 20개: `start_copy` 는 카탈로그가 있으면 잡을 만들고(`FAQ_UNGROUNDED` 는 카탈로그 없는 업종만), - `run_copy` 는 근거가 없거나 키가 없으면 LLM 없이 채우기만 한다. 온보딩 알림(`notifyCopy`)도 `faq_fill` 을 본다. -- 발행본 FAQ 섹션: 문의 안내가 섞이면 "모두 사업자가 확인한 내용" 안내 문구를 달지 않는다. -- 빌더 FAQ 패널: "노출 N건 (문의 안내 M)" 과 문의 안내 표시. - -**남은 것** — 카페·음식점·체험시설 카탈로그. 스키마에 없는 주제(짐 보관·퇴실 정리·보증금·수영장 온수·주변 편의시설)는 -fact key 로 만들면 문의 안내 대신 답이 된다. 결론은 [DECISIONS 8절](DECISIONS.md). - -**검증** — 백엔드 664 passed(신규 `test_faq_fill` 10건 · `test_copy_api` 3건, 기존 2건은 fact 0건 경로에 맞게 고침). -실패 2건(`test_place_search::test_rate_limit_closes_the_tap` · `test_site_thumbnail` 호스트)은 이 변경 전 HEAD 에서도 같게 실패한다. -site·frontend·admin `tsc --noEmit` 통과 · site vitest 63 passed. -로컬 실사업장(2026-09-14, 하늘물빛정원 — fact 4건): 생성 FAQ 4건 + 문의 안내 16건 = 20건, 질문 중복 0. -0012 는 새 DB(init.sql → migrate 규칙)와 로컬 DB 사본 양쪽에서 두 번씩 적용해 통과. - ---- - -## 2026-09-14 — 발행 사이트 제목·keywords 메타에 SiteOntology 키워드를 싣는다 - -**무슨 일** — 숙박 사이트를 빌드할 때 SiteOntology(o2o-site-ontology)에 이 가게 프로필을 보내 검색 키워드를 -받고, 거른 결과를 `` 와 제목 업종어 자리에 싣는다. -``` -스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지) - → POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id) - → 거르기 → snapshot["seo"] → payload.seo - → 스테이,머뭄 · 군산 독채펜션 · -``` - -**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 10건 중 `군산 독채 마당 펜션`· -`군산 독채 복층 펜션`·`군산 커플 프라이빗 펜션` 이 status=ok 로 왔다. SiteOntology 의 사실 필터는 수용 인원과 -일부 시설만 보기 때문이다. 사전에는 `선유도 독채펜션`(다른 권역)·`군산 펜션 최저가`(가격 주장)도 있다. -→ **키워드의 모든 낱말이 이 가게 자료에 있어야** 싣는다. 이 규칙 하나로 셋이 같이 걸리고, 10건이 4건이 됐다. - 제목에는 `예약`·`추천` 이 붙은 것과 시·군 이름이 없는 것도 뺀다. 규칙의 단일 출처는 `services/seo_keywords.py`. - -**★ SiteOntology 쪽 함정 (실측)** -- region 표에 없는 `regionId` 를 보내면 **500**(외래키 위반). 표 내용은 적재한 데이터셋에 따라 달라 우리가 모른다 - → 500 이면 지역 없이 한 번 더 보낸다. -- 해석되지 않은 `query` 에도 **201** 로 입력 문자열 검색 결과를 준다(`나운동 숙소` …) → `resolved` 가 - 우리 place_id 가 아니면 버린다. - -**경계** — SiteOntology 는 **수정하지 않았다**. 설정(`SITE_ONTOLOGY_URL`)이 비면 호출하지 않고, 실패하면 -키워드 없이 예전 제목으로 발행한다. 키워드는 스냅샷에 실려 `site_versions.snapshot` 이 곧 발행 기록이다. - -**남은 것** — SiteOntology 매칭은 지역으로 거르지 않는다. 지금은 낱말 대조가 다른 지역 단어를 막지만, -운영에 전국 데이터가 들어가면 SiteOntology 에 지역 필터를 넣는 것이 맞다. - ---- - -## 2026-09-11 — 발행하면 이 숙소의 노래가 한 곡 생긴다 (가사 Gemini → 작곡 Suno) - -**무슨 일** — `/s/stay` 시안에는 헤더에 노래 플레이어가 있는데, 그건 손으로 채운 목업이라 -새로 발행한 사이트에는 그 자리가 아예 없었다. 이제 발행이 노래를 만든다. - -**흐름** — ★ **발행이 노래를 기다린다.** -``` -발행 누름 → BUILD 잡 - 1. 가사(Gemini) → 2. 작곡(Suno, 실측 30~40초 · 상한 5분) - 3. mp3 를 out/songs/ 에 보관 - 4. 스냅샷 → 게이트 → 발행 ← 여기서 비로소 사이트가 나간다 - 프리렌더가 mp3 를 사이트 디렉토리로 복사 -``` - -**왜 기다리나** — 먼저 굽고 나중에 붙이는 방식으로 먼저 만들어 봤는데, 그러면 발행 직후의 -사이트에는 노래가 없고 몇 분 뒤 조용히 생긴다. 사장님이 [사이트 열기] 로 보는 **첫 화면에 -그 기능이 빠져 있다.** 값은 발행이 그만큼 늦어지는 것이고, 그건 감수한다. -★ 단 실패는 발행을 막지 않는다 — 기다리는 것과 막는 것은 다르다. 키가 없거나 작곡이 실패하면 -노래 없이 발행되고 사유가 빌드 로그와 `place_songs.last_error` 에 남는다. -★ 미리보기 빌드(publish=false)에는 만들지 않는다. 유료 호출이라 눌러 보는 것만으로 돈이 나가면 안 된다. - -**왜 가사를 우리가 쓰나** — Suno 에 "군산 한옥 숙소 노래" 라고만 던지면 가사를 저쪽이 짓는다. -그 가사에는 이 숙소에 없는 것(수영장·조식·오션뷰)이 섞이고 우리는 검증할 방법이 없다 — -사이트의 다른 모든 문장은 확인된 fact 로만 쓰는데 노래만 지어낸 말을 싣는 꼴이다. -→ 가사는 **소개문과 같은 재료**(확인된 fact + 조사 근거 + 소개문)로 Gemini 가 쓰고, - Suno 는 곡만 붙인다. 프롬프트가 **없는 시설·숫자를 말하지 말라**고 못 박는다 - (요금·전화번호를 노래에 넣으면 틀렸을 때 고쳐 부를 수가 없다). -★ 가사에는 `ground_check` 를 걸지 않는다. "밤이 깊어도 불이 켜져 있다" 에 대응하는 fact 는 - 없다 — 문장 단위로 근거를 맞추면 전부 반려된다. 가사는 사실 진술이 아니라 정서다. - -**★ Suno 주소를 그대로 싣지 않는다** -Suno 가 주는 audio_url 은 **만료된다.** payload 에 그 주소를 실으면 발행 직후에는 재생되고 -몇 주 뒤 조용히 죽는다 — 아무도 안 누르면 죽은 줄도 모르는 종류다. mp3 를 받아 보관하고 -우리 경로(`/s//.mp3`)만 발행본에 내보낸다. - -**★ 콜백이 아니라 폴링이다** -Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다. -이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면 -"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다. -(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.) - -**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로 -`out/songs/.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s//` 로 복사한다. -프리렌더는 복사하면서 **지난 발행의 mp3 를 치운다** — 발행마다 새 곡이라 안 치우면 1MB 짜리가 -발행 횟수만큼 쌓이고, Azure 에도 그대로 올라간다. - -**화면** — 헤더의 작은 플레이어(`SongPlayer`). 곡이 없으면 **아무것도 그리지 않는다** — -노래는 발행보다 늦게 도착하므로 그 사이 빈 플레이어를 그리면 고장난 버튼이다. -자동 재생하지 않고(소리가 갑자기 나는 페이지는 닫힌다), 가사를 함께 싣는다 -(오디오 안의 말은 크롤러가 못 듣는다). - -**표** — `place_songs`. 검증 상태가 없다(창작물이라 "맞는가" 를 물을 대상이 아니다). -상태는 `GENERATING`·`READY`·`FAILED` 셋이고 스냅샷은 READY 만 싣는다. 새 곡이 실패하면 -직전 곡이 그대로 남는다. → [DATA_MODEL.md](DATA_MODEL.md) - -**검증** — 실제로 발행해 봤다(스테이,머뭄 v15): 가사 '시간이 머무는 고요한 밤'(acoustic -ballad, 154자, $0.0014) → 작곡 40초 → 1.98MB mp3 → **그 다음** 스냅샷(노래 1) → 발행 완료. -`/s/스테이머뭄-99a887f8` 200, mp3 200 `audio/mpeg`, HTML 에 제목·가사·재생 주소 확인. -지난 발행의 곡은 404 로 치워졌다. `tsc --noEmit` · `eslint` · vitest 55건 통과(신규 4건). - ---- - -## 2026-09-10 — 소개문이 생성되고도 영영 안 나가던 것 (승인 단계 제거) - -**무슨 일** — 힐튼 가든 인 서울 강남을 만들어 보니 소개가 빈칸이었다. 로그는 `[copy] 소개문 O`, -DB 에도 문장이 있는데 `status=1(UNVERIFIED)` 이라 스냅샷이 담지 않았다. 그 자리는 fact 로 조립한 -한 줄("서초구에 있는 …입니다. 체크인 15:00.")이 대신 채우고 있어서, 화면만 보면 생성이 실패한 -것처럼 보이지도 않았다. - -**왜 승인이 안 됐나 — 승인할 화면이 없었다.** -``` -07:29:04 수집 완료 → 여기서 사장님이 [맞아요] 를 눌러 fact 가 VERIFIED 가 된다 -07:31:11 ★ 소개문 도착 — 2분 늦게. 확인 화면은 이미 지나갔다 -``` - -**한 일** -- `fact_service.upsert_fact`: **LLM 출처는 후보가 아니라 노출값으로 앉힌다.** 자동 출처(API·CRAWL)는 - 그대로 후보다. 게이트는 앞에 있다 — 입력이 확인된 fact 뿐이라 이미 승인된 사실로 쓴 문장이다. -- `copy_service`: 생성 FAQ 를 `VERIFIED` 로 저장한다. 근거 없는 FAQ 는 여전히 저장하지 않는다. -- **잠금은 명시적으로 다시 걸었다.** 사장님이 고친 문장(`CORRECTED`)은 LLM 이 못 덮는다. - 지금까지 이 보호는 "자동 출처는 노출값 경로로 못 간다" 는 **경로**가 대신 해 주고 있었다 — - LLM 만 경로를 바꾸면 그 보호가 조용히 사라진다(절대규칙 6). -- `faq_crud.expire_generated`: 재생성 대상을 status 가 아니라 `generated_by` 로 가른다. - 생성분이 VERIFIED 로 들어가면 status 로는 사람이 손댔는지 알 수 없다. 그대로 뒀다면 재생성이 - 옛 FAQ 를 못 내려 같은 질문이 쌓였을 것이다. -- 결론과 근거는 [DECISIONS.md 7절](DECISIONS.md). 6-2 의 "FAQ 에는 넓히지 않는다" 도 함께 고쳤다. - -**곁다리로 잡은 것 — 테스트가 통째로 막혀 있던 진짜 이유** -ORM 의 TIMESTAMPTZ 기본값이 `(now() AT TIME ZONE 'utc')` 였다. timestamptz 에 이걸 쓰면 값이 -시간대 없는 벽시계로 떨어졌다가 세션 시간대로 다시 해석돼 **서버 시간대만큼 미래로 밀린다.** -실측: 잡의 `run_after` 가 7시간 뒤로 박혀 `claim`(`run_after <= now()`)에 영영 안 걸렸고, -COPY 관련 테스트가 "잡이 PENDING 인 채" 무더기로 실패했다. 원인이 코드가 아니라 스키마라 -읽히지 않는 종류다. 운영은 멀쩡했다 — 운영 DB 는 `init.sql`(`DEFAULT now()`)로 만들어지고 -이 기본값은 **ORM 이 스키마를 만들 때만**, 즉 테스트 DB 에서만 쓰인다. -→ `init.sql` 과 같은 `now()` 로 맞췄다. 스키마는 init.sql 이 단일 출처다. - -**검증** — fact·copy·faq 35건 통과(신규 2건: LLM 문장이 승인 없이 노출값이 되는지 · -CORRECTED 를 못 덮는지). - ---- - -## 2026-09-10 — 옛 항구 템플릿을 `/s/stay` 시안에 맞춘다 (렌더러 이식) - -**무슨 일** — 옛 항구를 골라도 시안처럼 안 나왔다. 시안의 출처를 따라가니 이 레포가 아니라 -**`stay-mockup` 워크트리의 커밋되지 않은 작업본**이었다(19파일, +601/−275). 거기서만 살아 있던 -변경이 이 브랜치로 넘어오지 않아, 같은 payload 를 같은 템플릿으로 구워도 화면이 갈렸다. - -**대조 방법** — 시안 HTML 에 박힌 `window.__SITE_PAYLOAD__` 를 떼어 **현재 렌더러로 다시 구워** -마크업을 태그 단위로 diff 했다. 페이로드가 같으니 남는 차이는 전부 렌더러 차이다. -착수 시 실질 diff 129줄 → 이식 뒤 **4줄**. - -**옮긴 것** -- `lib/ui/Carousel.tsx` + `use-rail-autoplay.ts`(신규): 자동 넘김을 훅 한 벌로. **한 번 훑고 멈춘다** — - 되감기(`loop`)를 빼야 embla 가 슬라이드를 개별 transform 으로 옮기지 않아 이음매 간격이 안 붙는다 -- `FestivalSection`: 격자 → **계절별 캐러셀 4개**(봄·여름·가을·겨울) -- `ItinerarySection` + `items/common.tsx`: 코스마다 레일을 쌓던 것을 **탭 하나 = 레일 하나**로. - 실측 payload 에서 캐러셀 20개 → 2개(1박2일·2박3일) -- `lib/format.ts`: 지도 주소에서 **쉼표를 뺀다.** 카카오 `link/to/{이름},{위도},{경도}` 는 쉼표로 칸을 - 가르는데 상호가 "스테이,머뭄" 이면 위도 자리에서 "머뭄" 을 읽고 **목적지를 통째로 버린다** — - 길찾기가 현위치만 뜨던 원인 -- `seo/verify.ts`: JSON-LD 이미지가 절대 URL, HTML 은 루트 절대경로(`/assets/…`)라 **경로로도 대조**한다. - 이게 없어서 사진을 미러한 사이트는 발행 게이트가 통째로 막혔다(시안 payload 재굽기가 9건으로 실패) -- 그 밖에 `GallerySection`(간격) · `VideoSection` · `UnitsTabs` · `UnitsBands` · `LocalGuideSection`(레일 간격) - · `WeatherSection` + `WeatherBand`/`tempNotes`(기온대별 한 줄) · `SiteHeader`(safe-t) · `seo/jsonld`·`head` - -**이 브랜치 것을 지킨 자리** — 충돌 6곳은 손으로 갈랐다. -- `ItinerarySection`: 사장님 일정이 없으면 **서버 조립분**(`local.itineraries`)을 쓰는 폴백을 유지 -- `seo/verify.ts`: 이 브랜치의 `unescaped` 대조와 시안의 경로 대조를 **둘 다** 본다 -- 예약 버튼 문구는 시안(`{채널}로 예약`)이 아니라 이 브랜치의 `bookingActionLabel` 을 남겼다 — - 네이버 예약 채널에서 "네이버 예약로 예약" 이 되는 것을 막는 쪽이 맞다. **남은 diff 4줄이 이것이다** - -**템플릿 쪽** — `TemplateItem` 에 `defaultVariants` 를 더하고 옛 항구에 `photos: 'photos.carousel'` 을 건다. -시안의 사진 갤러리가 캐러셀인데 템플릿이 배리에이션을 지정할 자리가 없어 늘 기본으로 나갔다. -`disabledSectionTypes`(끄고 시작할 섹션) 기구도 함께 두되 **옛 항구에는 쓰지 않는다** — 예약 안내는 나간다. - -**검증** — `tsc`(shared·site·frontend·admin) · eslint 통과. 시안 payload 를 현재 렌더러로 프리렌더 → -**검증 게이트 통과**, 캐러셀 11개가 시안과 같은 구성·순서. site vitest 는 7 failed / 44 passed 로 -**착수 전과 같다**(stay-booking 7건은 이 작업 이전부터 실패). - -⚠️ `/s/stay` 는 건드리지 않았다. 다만 `solution/site/payloads/stay.json` 이 남아 있는 한 -**프리렌더 컨테이너가 기동할 때마다 목업이 그 payload 로 덮인다**(`watch-payloads.mjs` 의 `기동` 전체 재굽기). -목업은 payload 가 없어야 안전하다 — stay2·stay3 가 무사한 이유가 그것이다. - -## 2026-09-10 — 일력(오늘의 한 장)을 서버 생성에 붙인다 · 종류가 늘어도 기존 지역이 따라온다 - -**무슨 일** — '옛 항구' 템플릿을 골라도 `/s/stay` 시안처럼 안 되는 자리를 따라갔더니 하나가 -코드 문제였다. **일력만 서버가 만들지 않는다.** 렌더러에는 '오늘의 한 장' 탭이 있고 -(`StorySection` 다섯 탭 중 둘째) 템플릿 설명도 "도넛판·**일력**·승차권"이라고 약속하는데, -프롬프트가 빌더(`canvas/dataSpec.ts`)에만 손으로 적혀 있어 `shared/section-prompts.ts` 에 -없었다 — 서버는 그 종류가 있는 줄도 몰랐다. 시안에 일력이 있는 건 그때 손으로 넣었기 때문이다. - -**같이 나온 두 번째 함정** — 목록이 두 벌이었다. `export-prompts.mjs` 가 종류 배열을 -손으로 한 벌 더 들고 있어서, `STORY_KINDS` 에 하나를 늘려도 **뽑히지 않는다**. -프론트는 아는데 서버만 모르는 상태가 되고, 그 종류의 탭은 조용히 빈칸으로 남는다. - -**세 번째 — 가드가 정확히 반대로 돈다** — `has_stories()` 는 "한 건이라도 있으면 다시 안 부른다" -였다. "같은 지역 두 번째 숙소"만 생각한 가드라, **종류가 늘어난 날** 이미 다섯이 든 지역 -(52군산시)은 여섯 번째를 영영 못 받는다. 새 지역만 여섯이 되고 기존 지역은 다섯에 멈춰, -같은 템플릿을 골라도 지역에 따라 탭 수가 다른 상태가 된다. - -- `shared/section-prompts.ts`: `daily` 스펙 추가(maxItems 30) · `STORY_KINDS` 를 발행본 탭 순서로 -- `shared/scripts/export-prompts.mjs`: 종류 목록을 손으로 적지 않고 `STORY_KINDS` 에서 읽는다 -- `frontend/canvas/dataSpec.ts`: 일력의 task·rules 를 shared 참조로 — 다섯과 같은 모양이 됐다 -- `backend/story_service.py`: `has_stories` → `missing_kinds` — **없는 종류만** 부른다. - 요금 가드는 그대로다(있는 종류는 여전히 한 번도 다시 안 부른다). 읽기 실패는 "없다"로 - 치지 않는다 — 모르는 상태로 유료 호출을 걸지 않는다 -- `backend/enums.py` · `grounding/story.py` · `init-data/init.sql`: 여섯으로 맞춤 - -**검증** — `tsc --noEmit`(shared·frontend·site) · eslint 통과. 프롬프트 계약 테스트 2건 추가. -실제 payload(`stttt`)의 `local.story.daily` 에 두 건을 넣고 구워, '오늘의 한 장' 탭이 -다섯 번째로 서는 것까지 확인했다. -⚠️ pytest 전체는 이 브랜치 이전부터 로컬 Postgres 인증 실패로 막혀 있다 — 새 테스트는 DB 를 -안 쓰지만 세션 픽스처가 먼저 걸린다. 개별 함수를 직접 호출해 통과를 확인했다. - -**아직 남은 것(코드가 아니라 데이터)** — `/s/stay-mumum-gunsan` 이 시안과 다른 나머지는 -소개·객실·FAQ·영상·소식과 fact 8건이 비어서다. 사장님이 채우거나 수집이 가져와야 한다. - -## 2026-09-09 — 지역 이야기를 서버가 채운다 (가요·인물·연표·엽서·퀴즈) - -**무슨 일** — 이 다섯은 생성기가 없어 **사람이 손으로 넣지 않으면 영영 빈칸**이었다. -`/s/stay` 시안이 다섯을 다 갖고 있는 건 그때 손으로 채웠기 때문이고, 새 업장은 옛 항구 -템플릿을 골라도 그 자리가 비었다. 이제 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다. - -- **키는 지역이다.** `area_contents`(region_code × kind) 에 종류당 한 행, `body.items` 에 항목들. - 사이트별 `sections[].data` 로 복사하지 않는다 — 화면이 읽는 순간에만 사장님이 붙여넣은 것과 - 한 배열로 잇는다(`site/src/lib/derive.ts` `sectionItems`). -- **Perplexity 종류당 1회.** 출처(`search_results`)가 함께 오는 유일한 통로다. 항목에 출처가 - 없으면 버리고, 검색 출처로 때운 항목은 `확인` 이라 우겨도 `확인필요` 로 내린다. -- **프롬프트는 한 벌.** 사장님이 [콘텐츠] 탭에서 복사해 가던 그 문장을 그대로 쓴다 — - `shared/lib/section-prompts.ts` 가 단일 출처, `npm run export:prompts` 로 백엔드용 JSON 을 뽑는다. -- **트리거는 cache-aside.** 에디터 캔버스가 주변 정보를 처음 부를 때 지역 이야기 생성 잡 - (`JobType.LOCAL_SYNC`, 선언만 있고 미배선이던 것)을 하나 넣는다. `dedupe_key = story:{region_code}` - 라 같은 지역 숙소 50곳이 동시에 열어도 잡은 하나다. -- 검수 게이트는 두지 않는다 — 결론과 근거는 [DECISIONS.md 6절](DECISIONS.md). - -**검증** — `tsc -b` 통과 · 지역 이야기 단위 테스트 12건 통과. -⚠️ 이 레포의 pytest 전체는 이 브랜치 이전부터 **로컬 Postgres 인증 실패로 569건 전부 error** 다 -(`password authentication failed for user "postgres"`). 새 테스트는 DB 를 안 쓰는데 세션 픽스처가 -DB 를 먼저 세워서 함께 막힌다 — 환경 문제이고 별건이다. - -## 2026-09-09 — 예약 안내 안에 날짜·시간 목업을 넣는다 (연동 없음) - -**무슨 일** — 예약 흐름을 화면으로 보기 위해 `StayBookingDemo` 를 예약 안내 섹션 안에 넣었다. -날짜(2주) · 도착 시간 · 객실 · 인원을 고르면 확인 화면이 나오고, 거기서 전화로 잇는다. -**어디에도 연동하지 않는다** — 재고 조회도 접수도 결제도 없다(PRODUCT.md 6절은 그대로다). - -**목업이라도 지킨 선** -- **"마감/잔여" 를 만들지 않는다.** 우리는 그 값을 모른다. 그럴듯하게 지어내면 목업이 아니라 - 거짓말이고, 손님은 그 표시를 보고 다른 날을 고른다 -- **시간 후보를 임의로 늘어놓지 않는다.** 체크인 fact(16:00)에서 시작해 5칸을 만든다 — - fact 가 없으면 시간 선택을 아예 내지 않는다. 확인된 값과 어긋나는 선택지는 만들지 않는다 -- **요금은 요금표·JSON-LD 와 같은 출처**(`unitBaseRate`)를 쓴다. 데모라고 다른 숫자를 보이면 - 같은 페이지가 두 값을 말하게 된다 -- 확인 화면은 "접수됐다" 고 쓰지 않는다 — 어디에도 보내지 않으므로 사실이 아니다. - 반대로 "접수되지 않았다" 는 경고도 두지 않는다(2026-09-09 결정: 흐름을 보는 화면이라 - 경고문이 흐름을 가린다). **선택 내용 확인**까지만 말하고 전화로 잇는다 - -**★ 날짜는 브라우저에서 만든다 (mounted 게이트)** -프리렌더가 서버에서 날짜를 구우면 **발행 시각의 날짜가 정적 HTML 에 박힌다.** 한 달 뒤 -크롤러가 그 페이지를 읽으면 지난 날짜가 예약 가능일로 적혀 있다 — 화면은 멀쩡한데 기계가 -읽는 값만 틀리는, 이 레포가 가장 자주 밟은 종류다. 그래서 서버 렌더에서는 달력을 그리지 않고 -안내 한 줄만 내보내고, 달력은 하이드레이션 후에 그린다. 자바스크립트가 꺼진 크롤러가 보는 -것은 "실제 예약 가능 여부와 결제는 아래 예약 창구에서" 뿐이다. - -**구조화 데이터는 건드리지 않았다.** 데모는 JSON-LD 에도 llms.txt 에도 나가지 않는다 — -`makesOffer.availability` 는 여전히 없고(빈 방을 모른다), llms.txt 는 "이 홈페이지는 빈 방 -재고와 결제를 처리하지 않습니다" 를 그대로 말한다. 목업을 AI 에게 예약 창구로 소개하면 -그때부터는 목업이 아니다. - -**연동을 붙일 자리** — `ConfirmPanel` 한 곳이다. 실시간 재고·접수가 생기면 그 함수만 바뀐다. - -**빌더 캔버스도 같이 맞췄다** — 사장님 편집 화면은 여전히 "네이버 실시간 온라인 예약 / -캘린더에서 바로 확정 예약" 을 그리고 있었다. 우리는 실시간 예약을 하지 않는데다, -**에디터에서 본 것과 발행된 사이트가 서로 다른 물건**이었다. -- `booking/BookingCard`: 발행본 구성(날짜 칩 · 도착 시간 · 인원 · 예약 요청 · 전화 창구)의 - 미리보기로 갈아엎었다. 캔버스의 클릭은 "이 섹션을 고른다" 는 뜻이라 상태를 두지 않고 - 첫 칸이 골라진 모습으로 고정한다. 시간 칸은 발행본과 같은 규칙으로 **체크인 fact 가 있을 - 때만** 그린다 -- `booking/BookingBanner` "실시간 캘린더" → "날짜와 시간을 고르고 예약 창구로 이어집니다", - `rooms/RoomCard` "실시간 예약 신청" → "예약 안내 보기", `hero/HeroEditorial` "실시간 예약" - → "예약 안내" -- `LinkChannel.NAVER_BOOKING` 을 orval 생성물에 반영. ★ `npm run orval` 을 그대로 돌리면 - **141파일 6,400줄**이 바뀐다 — 전부 따옴표·줄바꿈 포매팅 드리프트고 스펙 변경은 enum - 한 줄뿐이다. 그래서 생성물을 되돌리고 그 한 줄만 남겼다(실측 2026-09-09) - -**검증** — `tsc·eslint` 통과, `vitest` 51 passed(신규 4건: 날짜가 HTML 에 안 박히는지 · -JSON-LD 무영향 · llms.txt 무영향 · 객실 0개면 안 그림). 실제 발행본 재굽기 후 -`/s/` 에서 데모 껍데기와 안내 문구 확인. - ---- - -## 2026-09-08 — 가짜 발행을 없앴다 — 굽지도 않고 [사이트 열기] 를 그렸다 - -**무슨 일** -발행 모달에서 [발행하기] 를 누르면 "발행 준비가 끝났습니다" 토스트가 뜨고 [사이트 열기] -버튼이 생겼다. **서버를 한 번도 안 불렀고, 그 주소는 404 다.** 목록에도 안 생긴다. -사장님은 발행됐다고 믿는다. - -**왜** -`PublishModal.handlePublish` 가 `publisher.isLive`(= placeId + 토큰)가 거짓이면 서버 호출을 -건너뛰고 `setPublishedUrl(url)` 로 스토어에 주소를 박았다. 그러면 `isDone` 이 참이 되어 완료 -화면이 그려진다. 데모 경로를 위해 둔 분기인데 **로그인한 사장님도 이 길로 온다** — 3단계의 -[수집 없이 다음 단계로](직접 입력)로 나가면 서버에 사업장이 없는 채 에디터까지 가고, -거기서 로그인해도 `placeId` 는 여전히 없다. - -**고친 것** -- 가짜 분기 삭제. `isDone` 은 `state.phase === 'published'` 하나로 줄였다 — 굽지 않은 주소에 - [사이트 열기] 가 붙던 자리가 여기다 -- 발행 불가 사유를 `PublishBlocker`(`signin` · `place`)로 갈라 모달 안에서 말한다. - blocker 가 있으면 주소칸·점검·발행 버튼을 아예 그리지 않는다 -- 비로그인: `/login` 으로 튕기지 않고 모달 안에 로그인 폼을 둔다 — 빌더 스토어는 비영속이라 - 튕기면 만들던 게 날아간다(`EditorSignInGate` 와 같은 이유) -- 로그인 O + 사업장 X: 이유를 말하고 [내 가게 확인하러 가기] → `/builder?step=search`. - 여기서 사업장을 몰래 만들지 않는다 — 생성·검증 순서는 `ensureServerPlace` 한 곳이 소유한다 -- 3단계 버튼을 [발행 없이 화면만 둘러보기] 로 바꾸고 "이 길로 가면 발행이 안 된다" 를 붙였다. - 버튼은 남긴다 — 검증을 못 통과한 사람이 화면을 구경할 길까지 막을 이유는 없다 - -**검증** — 프론트 tsc+eslint 통과. 백엔드가 같은 상황을 어떻게 거절하는지도 확인했다: -검증 안 된 사업장으로 발행하면 `PLACE_NOT_VERIFIED` 다. 서버는 이렇게 분명히 막는데 -프론트만 서버를 안 부르고 성공을 말하고 있었다. - -⚠️ 이 변경의 **코드는 f2dad65 에 섞여 들어갔다** — 같은 레포를 동시에 작업하던 다른 세션이 -커밋할 때 스테이지에 올려 둔 `PublishModal.tsx`·`Step3DataReview.tsx` 를 같이 담았다. -그 커밋 제목은 발행본 목록 주소 얘기라 이 변경을 가리키지 않는다. 기록은 여기에 남긴다. - ---- - -## 2026-09-08 — 발행본 목록의 정본 주소를 `/s` 로 — `/s` 가 앱 셸을 200 으로 주고 있었다 - -**무슨 일** -사이트맵에서 끝 슬래시가 붙은 줄이 무엇이냐는 물음에서 시작했다. 슬러그 페이지 -(`/s/`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다. -목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고, -`/s` 는 맨 아래 `location /` 로 떨어진다. - -**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다 -(실측: `/s` 3.1KB `Web4Ai` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도 -목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다. - -**바꾼 것** -- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는 - 거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s//` 가 그걸로 열린다 -- `absolute_redirect off`: TLS 를 앞단 Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다. - 기본값대로 절대 URL 을 내면 https 페이지가 http 로 내려가는 301 이 나간다 -- `prerender.ts` `indexUrl`: `+ '/'` 제거. canonical·og:url·사이트맵·llms.txt 가 이 값 하나를 - 쓰므로 전부 같이 따라온다 - -**왜 형태를 맞추나** -색인 요청·사이트맵 URL 이 canonical 과 어긋나면 구글이 제출분을 "대체 페이지(적절한 표준 -태그가 있음)" 로 분류한다 — 색인은 되는데 제출 URL 은 0건으로 보인다. 슬러그 쪽에서 한 번 -밟은 함정이고(`prerender.ts` 주석), 목록만 반대 형태로 남아 있었다. - -**남은 것** -`/s//` 는 여전히 200 이다(canonical 로만 접힌다). 목록과 달리 사이트맵에 없어서 -크롤러가 스스로 만들어낼 주소는 아니다. - ---- - -## 2026-09-08 — 내 사이트 목록에 썸네일·주소·시각 — 발행할 때마다 그림이 바뀐다 - -**무슨 일** -목록 줄이 아이콘·상호·배지·주소 넷뿐이었다. 서버는 이미 `road_address`·`created_at`· -`published_at` 을 주고 있는데 화면이 안 썼다. 한 계정에 '버터브루' 가 4줄 있으면 어느 게 -어느 건지 가릴 단서가 화면에 하나도 없다. - -**리서치** (Wix · 아임웹) -- Wix `My Sites` 줄에 보이는 건 이름·URL·Premium·협업자뿐이고 **썸네일도 수정일도 없다.** - 대신 Sites API 문서가 "이렇게 그려라" 로 지목한 조합은 `displayName · thumbnail · viewUrl · - editUrl` 이고, 정렬은 최근 수정순이다 — 화면보다 API 권고 쪽이 우리 상황에 맞다. -- 아임웹 내사이트는 기본 정보 + 액션(관리자 접속·복제·템플릿 변경·소유권 이전), - 리셀러 목록은 **만료일**을 목록에서 바로 본다. 방문자·주문 숫자는 목록이 아니라 - 사이트 안 대시보드에 있다. -- 공통: 목록은 **구분 · 상태 · 여는 길** 셋만 한다. 그리고 **둘 다 생성일을 안 쓴다** — - 구분은 그림·주소·이름이 하고, 시각은 "마지막으로 뭔가 한 시각" 이 쓰인다. - -**바꾼 것** -- `MySiteData.thumbnail_url` 추가(`site_service._my_site_row`). 목록이 사이트 행을 이미 - 조인해 읽고 있어서 쿼리는 그대로다 -- 줄 앞에 썸네일. 없으면 업종 아이콘으로 떨어지고, 로드 실패해도 아이콘으로 되돌린다 — - 블롭이 지워진 옛 주소에서 깨진 그림이 뜨는 것보다 낫다 -- 줄 아래 한 칸: `도로명 주소 · 시각`. 시각은 **발행됐으면 발행일, 아니면 만든 날** 하나만 - 쓴다(위 리서치의 결론). 올해면 연도를 뗀다 — 줄이 좁아 주소가 먼저 잘린다 - -**썸네일이 발행마다 바뀌게** (`site_thumbnail.public_url`) -블롭 이름은 `thumbs/.` 로 고정이고 내용만 `overwrite=True` 로 덮어쓴다. 그래서 -주소가 안 변했고, 사장님이 사진을 바꿔 재발행해도 **캐시에 남은 지난 그림**이 계속 보였다 -(`CACHE_CONTROL` 60초만으로는 그 60초를 못 막는다). 주소에 `?v=<발행 버전>` 을 붙인다. -→ 이름에 버전을 넣지 않는 이유: 사이트당 블롭이 발행 횟수만큼 쌓이는데 지우는 코드가 없다. -→ `scripts/backfill_thumbnails.py` 처럼 그 시점 버전이 없는 경로는 `version=None` 으로 - 그냥 붙이지 않는다. - -**아직 그림이 한 장도 없다** — 로컬·현재 DB 의 사이트 39개 전부 `thumbnail_url` 이 NULL 이다. -버그가 아니라 `AZURE_STORAGE_CONNECTION_STRING` 이 비어 `site_thumbnail.is_configured()` 가 -False 라서다(썸네일은 Blob 에만 올라간다). 키를 채우면 다음 발행부터 채워진다. - -**검증** — 백엔드 전체 통과. 목록 줄이 주소·생성일·썸네일을 들고 오는지, 발행 안 한 줄에 -`thumbnail_url` 키가 아예 없는지, **재발행하면 `?v=1` → `?v=2` 로 주소가 바뀌는지** 4건 추가. -프론트 `tsc + eslint` 통과. - ---- - -## 2026-09-08 — 회사(테넌트)를 걷어냈다 — 사장님 계정이 곧 스코프다 - -**무슨 일** -사장님이 가입하면 회사가 하나 생기고 그 회사의 직원이 됐다. 가입 폼은 "상호"를 묻고, -에디터 헤더에는 "이름 · 회사명" 이 붙었다. 쓰는 사람은 사장님 한 명인데. - -**왜 그랬나** -보일러플레이트(negodata)의 멀티테넌트 스코프 키를 그대로 물려받았다. DECISIONS.md 2절이 -"대행사/운영사 단위로 그대로 쓴다" 로 유지 결정을 적어 뒀던 자리다 — 2026-09-08 철회했다. - -**바꾼 것** -- 스코프 키가 `company_id` → `places.owner_user_id` 다. `UserInfo` 에서 `company_id` 를 뺐고 - (JWT 클레임도 같이 사라진다), `place_crud`·`site_crud` 의 WHERE 가 전부 주인으로 바뀌었다 -- **주인은 토큰이 정한다.** `Req_CreatePlace.owner_user_id` 를 없앴다 — body 로 받으면 남의 - 계정을 적어 만들자마자 남의 목록에 넣을 수 있다. 실측: 기존 92건은 아무도 안 보내서 전부 NULL 이었고, - 스코프는 회사가 대신 하고 있었다 -- 잡 페이로드 키 `company_id` → `owner_user_id`. 워커가 세우는 `UserInfo.user_id` 는 이제 - **사업장 주인**이다 — 예전엔 요청자·검증자·랜덤 uuid 순으로 채웠는데, 그 랜덤 uuid 가 - 스코프 키가 되는 순간 "남의 사업장" 이 되어 fact 조회가 0건이 된다 -- `company.companies` 테이블 · `users.company_id` · `Res_Me.company` · 가입 폼의 상호 칸 삭제 -- 테스트: `company_id`/`other_company_id` 픽스처 → `owner_id` 하나. 격리 테스트는 - `auth_headers("o2")` 를 한 번 더 부르면 그게 남이다 - -**마이그레이션** (`init.sql` 끝, 재실행 안전) -백필 → NOT NULL → 컬럼 삭제 순서다. 회사에 계정이 여럿이던 경우는 **가장 먼저 만든 계정**에게 -몰아준다. 주인을 못 찾은 행은 지운다 — 스코프가 없으면 아무에게도 안 보이는 유령이다. -실측(로컬): 92건 → 91건(고아 1건 삭제), `demoebf050` 56 · `test` 35. - -**남긴 것** — DB 스키마 이름 `company` 는 그대로다. rename 은 모든 모델의 `__table_args__` 를 -건드려야 해서 이번 변경에 섞지 않았다. - ---- -## 2026-09-08 — "예약" 을 누르면 검색 화면이 떴다 — 네이버 예약 주소를 수집해서 쓴다 - -**무슨 일** -발행본의 예약 버튼이 네이버 **플레이스** 링크를 그대로 열었다. 잘해야 가게 홈이라 예약을 한 번 -더 눌러야 하고, 자동 발견이 물어온 URL 이 `map.naver.com/p/search/…`(검색 결과 주소)인 사장님은 -**예약하려고 눌렀는데 검색 화면**을 봤다. 예약하러 온 손님은 거기서 끝난다. - -**근거 — 주소를 지어내지 않아도 된다** -플레이스 모바일 응답(`__APOLLO_STATE__`)의 `ROOT_QUERY.placeDetail(...).naverBooking` 에 -네이버가 예약 주소를 직접 준다(실측 2026-09-08, place 1273971279): - - naverBookingUrl : "https://m.booking.naver.com/booking/6/bizes/1067685" - tabs : [home, feed, menu, booking(예약), review, …] - -★ `bookingBusinessId`(1067685)와 `businessTypeId`(6)로 주소를 **조립하지 않는다.** 조립하면 -예약을 받지 않는 업소에도 그럴듯한 주소가 생기고, 눌러서 빈 화면을 본 손님은 그 가게가 예약을 -안 받는 줄로 읽는다. 응답이 `naverBookingUrl` 을 줄 때만 준 그대로 쓴다(미사용 업소는 null). - -**바꾼 것** -- `LinkChannel.NAVER_BOOKING = 7` (백엔드 enum · shared enum · init.sql 주석). 플레이스와 가른 - 이유는 성격이 다르기 때문이다 — 이건 **예약 화면 그 자체**다 -- `collector/base.py`: `RawSource.booking_url` — 채널이 스스로 알려준 예약 주소를 싣는 자리 -- `naver_place_adapter._booking_url()`: 위 노드에서 읽는다. 키에 질의 인자가 통째로 박혀 있어 - (`placeDetail({"input":…})`) 이름으로 못 찾으므로 접두사로 찾는다 -- `collect_service._store_booking_link()`: 예약 채널 링크로 등록하고 **자동 확정**한다. - 근거는 `discover_naver_place` 와 같다 — 이미 확정된 플레이스가 자기 예약 주소로 내놓은 - 값이라 남의 가게가 섞일 경로가 없다. 여기서 클릭을 한 번 더 받으면 그 사이 예약 버튼은 - 계속 검색 화면으로 간다 -- `site/seo/jsonld.ts` `BOOKING_CHANNELS`: **순서가 우선순위**가 됐다(네이버 예약 → 야놀자 → - 여기어때 → 플레이스). `bookingChannelUrl` 이 이 순서로 고르므로 화면 버튼과 - `makesOffer.url`·`potentialAction` 이 같은 곳을 가리킨다 -- `site/lib/derive.ts`: 예약 버튼을 같은 순서로 정렬하고, **검색 결과 주소는 뺀다** — - 예약하러 온 사람에게 검색 화면을 주는 건 링크가 없는 것보다 나쁘다. 링크가 하나도 없으면 - "온라인 예약 채널은 등록되지 않았습니다" 로 전화만 남는다는 것을 말해 준다 -- `bookingCtaLabel()`: `${채널}에서 예약` 을 일괄로 쓰면 "네이버 예약에서 예약" 이 된다. - 그리고 이 채널만 누르는 즉시 예약 화면이므로 버튼이 그 차이를 말해야 한다 — - "네이버 예약으로 바로 예약하기" -- 빌더도 이 채널을 안다(`useCollectFlow` 라벨, `ChannelUrlInput` 의 호스트 판정) - -**검증** — 실제 네이버 응답으로 어댑터 확인: `RawSource.booking_url = -https://m.booking.naver.com/booking/6/bizes/1067685` · 예약 노드가 없는 응답에서는 None. -`tsc·eslint` 통과, `vitest` 47 passed(신규 4건: 채널 우선순위 · 버튼 문구 · JSON-LD 대상 · -검색 URL 배제). - -## 2026-09-07 — `.env.example` 그대로 쓰면 로컬 발행이 안 됐다 — 함정 둘 - -클론 직후 문서대로 `cp .env.example .env` 하고 `docker compose up -d` 한 다음 발행을 걸어 봤다. -**게이트는 통과하는데 발행만 실패한다.** 두 가지가 겹쳐 있었다. - -**1) `DB_HOST=127.0.0.1`** — 컨테이너 안의 127.0.0.1 은 그 컨테이너다. compose 기본값은 -`host.docker.internal` 인데 `.env` 가 그걸 덮어쓴다. 증상이 고약하다: API 는 `/healthz` 가 -DB 를 안 보므로 **200 healthy** 로 뜨고, **워커만 조용히 재시작을 반복한다** — 화면은 멀쩡하고 -발행 잡만 영원히 안 돈다. - -**2) 줄 끝 주석이 값이 된다.** compose 의 `env_file` 은 `KEY= # 설명` 을 "빈 값"으로 읽지 -않는다 — 값이 `"# 설명"` 이다. 그래서 Azure 를 끈 로컬에서 `is_configured()` 가 참이 되고 -발행 잡이 업로드를 시도해 `Connection string is either blank or malformed` 로 죽었다. -같은 모양이 5개였다: `COLLECT_USE_PERPLEXITY`(값 `0` 이 `"0 # ..."` 가 된다) · -`KAKAO_REST_API_KEY` · `TOUR_API_KEY` · `INDEXNOW_KEY` · `AZURE_STORAGE_CONNECTION_STRING`. - -**3) 앱이 스스로 크로스 오리진을 만든다.** `nginx/site.conf` 는 `/v1` 을 같은 오리진으로 -프록시하고 주석에도 "앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다" 고 적혀 -있는데, compose 의 빌드 인자 기본값이 `VITE_API_BASE_URL=http://localhost:9800` 이었다. -`:80` 으로 앱을 열면 번들이 `:9800` 을 부르므로 크로스 오리진이 되고, `CLIENT_URL` 기본값 -(3000~3005)에 `http://localhost` 가 없어 **로그인만 계속 실패한다.** 증상이 사람을 속인다 — -서버는 200 에 토큰까지 내려보내고, 브라우저가 `allow-origin` 이 없어 그 응답을 버리므로 -화면에는 "로그인에 실패했습니다" 만 뜬다. 비밀번호를 의심하게 된다. - -**고친 것** -- `.env.example`: `DB_HOST` 기본값을 `host.docker.internal` 로. 값 뒤 주석은 전부 **윗줄로** - 올리고, 파일 머리에 "값 뒤에 주석을 붙이지 않는다" 를 근거와 함께 박았다 -- `.env.example` · `docker-compose.yml`: 앱이 부르는 API 주소 기본값을 **앱과 같은 오리진** - (`http://localhost`)으로. CORS 를 허용해서 뚫는 게 아니라 **크로스 오리진을 만들지 않는다** — - nginx 가 이미 같은 오리진으로 프록시하고 있었다. `PUBLIC_API_BASE_URL` 을 주석이 아니라 - 값으로 내놨다(주석으로 두면 compose 기본값이 이기고, 그 기본값이 문제였다) - -**검증** — 새 DB(`web4ai_db`)에 `init.sql` 적용 → `docker compose down -v` 후 `up -d --build` → -번들에 `localhost:9800` 참조 0건 · `POST http://localhost/v1/auth/login` 200(프리플라이트 없음) · -프리렌더가 기동하며 payload 2건 재굽기 → `/` `/s/` `/s/` 전부 200. 그리고 → -`scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고 -`http://localhost/s/` 200. ★ 참고로 `demo_build.py` 는 자기 안에서 워커를 한 번 돌리는데, -compose 워커가 잡을 먼저 집어가므로 **스크립트 출력은 "게이트 거부"로 보인다** — 실제 결과는 -`job.jobs.result` 와 워커 로그에 있다. - -## 2026-09-07 — 숙박 예약 구성 — "실시간 예약" 섹션이 전화번호 한 줄이었다 - -**왜** -숙박으로 발행하면 서버 기본표(`site_payload._DEFAULT_THEME`)가 `booking` 섹션을 켠다. 그런데 -발행본의 `BookingSection` 이 읽는 fact 는 `reservation_required`·`reservation_channel` 두 개이고, -**둘 다 숙박 스키마(`lodging.json`)에 없다.** 그래서 펜션·민박 페이지의 "실시간 예약" 섹션에는 -전화번호 한 줄만 남았다 — 요금도, 인원도, 취소 규정도, 예약 창구도 없었다. 숙박은 예약이 곧 -매출이고 "얼마예요 / 몇 명까지 / 어떻게 예약해요" 가 이 업종 질의의 대부분인데, 그 답의 근거가 -페이지에 없으면 AI 는 OTA 후기에서 추측한다. - -★ **예약을 처리하게 만든 게 아니다.** 빈 방 재고도 결제도 갖지 않는다([PRODUCT.md 6절](PRODUCT.md) -— "사이트는 예약 채널로 보낸다"). 날짜 선택기·예약 폼을 그리지 않았다 — 없는 기능을 화면으로 -흉내내면 손님은 예약한 줄 알고 안 오고, 그 전화는 사장님이 받는다. 대신 **예약에 필요한 사실 + -실제로 예약이 되는 창구**를 한자리에 모았고, "여기서 결제되지 않는다"를 화면 맨 앞과 llms.txt 에 -명시했다. - -**바꾼 것** -- `site/src/sections/StayBookingSection.tsx` (신규) — 객실별 요금·인원 / 예약 창구(전화 + 확정 - 채널) / 예약 전 확인(체크인·체크아웃·취소환불·추가인원·프런트 시간·취사·반려동물·흡연). - 근거가 하나도 없으면 섹션째 안 나간다 -- `site/src/lib/derive.ts` — `stayBookingView()` 가 **그릴지 말지까지** 판단한다. 상단 내비·하단 - 탭이 같은 함수를 본다 — 세 곳이 각자 판단하면 눌러도 아무 일 없는 "예약" 탭이 생긴다. - 예약 창구로 나가는 채널은 문의 목록에서 뺀다(네이버 플레이스가 두 번 보였다) -- `site/src/seo/jsonld.ts` — `unitBaseRate()` 를 **요금 숫자의 단일 출처**로 만들고 화면과 - `makesOffer.price` 가 같이 쓴다(각자 계산하면 절대규칙 3 위반으로 발행이 멈춘다). - `makesOffer`(객실별 1박 요금) · `potentialAction: ReserveAction`(확정 채널만) 추가. - **`availability` 는 넣지 않았다** — 빈 방을 모르는데 InStock 을 주장하면 그게 거짓이다 -- `site/src/seo/llms.ts` — 숙박 `## 예약` 블록. LLM 은 위에서부터 읽는다. 예약 경로가 "공식 채널" - 절 맨 아래에만 있으면 답에 안 실린다 -- `frontend/src/data/industryData.ts` · `backend/services/site_payload.py` — 숙박 기본 섹션 이름을 - **"실시간 예약" → "예약 안내"**. 실시간 예약을 하지 않는데 제목이 그렇게 말하고 있었다. - 두 파일은 `tests/test_site_theme.py` 가 1:1 로 묶어 두므로 같이 고쳤다 -- 데모 fixture 의 theme 에 `rules`·`booking` 을 넣었다 — 서버 기본표에는 있는데 fixture 에만 - 없어서, 개발 서버로는 이 두 섹션을 아예 볼 수 없었다 - -**곁에서 나온 것 — 데모 payload 는 원래 굽히지 않았다** -`npm run prerender`(payload 미지정 = 데모)가 **절대규칙 3 대조 9건으로 실패**하고 있었다. -내 변경 전에도 같은 건수로 실패했다(main 에서 재현 확인). -1. `verify.ts` 가 URL 을 **원본 HTML 문자열**에서 찾았다. 속성으로 나갈 때 `&` 가 `&` 로 - 이스케이프되므로 쿼리스트링 있는 이미지 URL 은 **화면에 있는데도** 절대 안 찾아진다. - → 엔티티를 되돌린 사본에서도 찾아본다. 표기 차이는 거짓이 아니다(숫자 `asShown()` 과 같은 이유). - 되돌린 사본에서도 못 찾으면 그대로 실패다 — 느슨해지지 않았다. -2. `unitCode: 'MTK'`(㎡ 의 UN/CEFACT 코드)를 본문에서 찾고 있었다. 한국어 페이지에 'MTK' 가 - 찍힐 일은 없다 — `priceCurrency`('KRW')와 같은 종류의 메타값이라 `STRUCTURAL` 로 옮겼다. - ★ 사람이 읽는 `unitText` 는 옮기지 않았다 — 그건 화면에 있어야 하는 말이다. - -**검증** — `tsc·eslint` 통과, `vitest` 43 passed(신규 21건: 예약 뷰·발행 HTML·JSON-LD 대조·llms.txt). -데모 payload 재굽기 성공(1개 중 1개) → `npm run serve` 로 `/s/moonlight-stay-jeju` 200 확인. -백엔드 pytest 는 이 환경에 venv 가 없어 못 돌렸다 — 에디터↔서버 섹션표 parity 는 그 테스트와 -같은 방식으로 손으로 대조했다(stay: `예약 안내` 양쪽 일치). - -## 2026-09-07 — (사고 2) 목업 사이트가 죽었다 — 참조된 자산은 기간과 무관하게 남긴다 - -**무슨 일** -`/s/stay` · `/s/stay2` · `/s/stay3` 의 CSS·JS·이미지가 전부 404 가 됐다. 재굽기를 돌려도 -살아나지 않았다. - -**왜** -`out/s/` 에 디렉토리가 8개인데 payload 는 4개뿐이다. 나머지는 **손으로 넣은 목업**이고, -프리렌더는 payload 를 받은 사이트만 굽는다 — 목업은 **재굽기 대상이 아니다.** 그래서 번들 -해시가 바뀌어 옛 자산이 지워지는 순간 영영 복구 불가가 된다. 문서 어디에도 목업 얘기가 -한 줄도 없어서(2026-09-07 grep 0건) 이 존재를 모르고 자산 삭제 코드를 건드렸다. - -**고친 것** (`scripts/prerender.ts`) -- `referencedAssets()` — 굽기 **전에** `out/s/**/index.html` 을 훑어 `/assets/…` 참조를 모은다 -- `pruneAssets` 가 그 목록을 절대 지우지 않는다. **보관 기간보다 우선한다** — - 기간으로 막으면 30일 뒤에 똑같은 사고가 난다 -- AGENTS.md 함정 목록 맨 위에 ★★ 로 박았다. 목업의 존재 자체가 문서에 없던 게 근본 원인이다 - -**복구** — 지워진 파일은 `stay-mockup` 워크트리(`solution/site/out/assets`)에 남아 있어서 -서버 볼륨에 손으로 되돌려 넣었다. `docker cp` → `out/assets`. - -**검증** — 목업 상황 재현: payload 없는 `out/s/mock/index.html` 이 옛 해시를 가리키게 두고 -재굽기 → 참조 3개가 남는다. 대장을 60일 전으로 돌려 만료를 강제해도 그대로 남는다. - ---- - -## 2026-09-07 — (사고) 자산 보관 첫 배포에 운영 사이트 CSS 가 끊겼다 - -**무슨 일** -바로 아래 항목(옛 해시 자산 30일 보관)을 배포하자 **기존 사이트의 CSS·JS 가 전부 404** 가 됐다. -옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다. - -**왜** -`pruneAssets` 가 "대장(`.builds.json`)에 없는 파일" 을 만료로 보고 지웠다. 그런데 **대장은 이 -기능과 함께 처음 생긴다** — 배포 직후 첫 실행에는 대장이 없으므로, 디스크에 있던 기존 자산이 -전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다. 아직 다시 굽지 않은 사이트는 그 순간 죽는다. - -**놓친 것** — 검증을 `out/` 을 비운 상태에서만 돌렸다. 재현해야 했던 건 빈 디렉토리가 아니라 -**"옛 자산은 있는데 대장은 없는"** 상태, 즉 실제 배포 직전의 서버 모습이었다. - -**고친 것** (`scripts/prerender.ts` `pruneAssets`) -- 대장에 없는 파일은 지우지 않고 **"지금 처음 본 것" 으로 입양해** 보관 기간을 새로 준다 -- 규칙으로 굳혀 둔다: **"기록이 없다" 와 "만료됐다" 를 같이 묶지 않는다**(AGENTS.md 함정 목록) - -**복구** — `docker compose restart solution-prerender` (기동하며 전체 재굽기 → HTML 이 새 해시를 -가리킨다). 자산을 되살리는 게 아니라 HTML 을 새로 굽는 쪽이 빠르다. - -**검증** — 배포 직전 상태를 재현: `out/assets` 에 옛 해시 파일만 두고 대장 없이 첫 실행 → -옛 파일 2개가 그대로 남고 대장에 입양 항목으로 들어간다. 재실행해도 대장이 늘지 않는다. - ---- - -## 2026-09-07 — 옛 해시 자산을 30일 남긴다 — 배포와 재굽기를 뗀다 - -**왜** -`writeSharedAssets` 가 빌드마다 `out/assets` 를 통째로 지우고 다시 깔았다. HTML 은 자산 경로를 -파일명 해시까지 박아 굽기 때문에, 렌더러를 배포하는 순간 **아직 다시 굽지 않은 사이트는 전부 -CSS·JS 404** 였다. 구멍을 "기동 시 전체 재굽기" 와 "배포하면 반드시 전체 재업로드" 라는 **규칙** -으로 막고 있었다 — 규칙으로 막는다는 건 구조가 못 막는다는 뜻이다. - -진짜 위험은 방문자가 아니라 크롤러다. 구글은 HTML 을 가져간 뒤 렌더를 **나중에** 돌린다. -그 사이에 자산이 사라지면 스타일도 스크립트도 없는 페이지를 렌더한 것으로 기록한다 — -하필 지금이 신규 도메인이 평가받는 시기다. 유예 창이 필요하다는 건 업계 통념이고 -(Vercel 은 검색봇에 한해 스큐 보호 창을 60일로 늘린다), 우리 창은 0초였다. - -**바꾼 것** (`scripts/prerender.ts`) -- `assets/` 를 통째로 지우지 않는다. 권한 때문에 지웠던 것인데 `copyDirectoryFiles` 가 - **파일마다** 먼저 `rmSync` 하므로 그 문제는 그대로 해결된다 -- `ASSET_RETENTION_DAYS`(30일) · `ASSET_MIN_BUILDS`(2) — 기간이 지나도 직전 빌드는 남는다 -- `out/assets/.builds.json` 대장: 어떤 빌드가 어떤 파일을 깔았는지. **mtime 으로 나이를 재지 - 않는다** — 복사·동기화가 시각을 갈아 버리면 옛 파일이 영원히 젊어지거나 산 파일이 지워진다. - 발행마다 이 함수가 도므로, 번들이 그대로면 줄을 늘리지 않고 맨 앞 줄의 시각만 갱신한다 -- 점(.)으로 시작해 `azure_static` 의 dotfile 필터에 걸러진다 — 대장은 업로드되지 않는다 - -**얻은 것** — 프론트 배포와 전체 재굽기가 **분리된다.** 재굽기를 안 하면 그 사이트만 옛 -디자인으로 뜬다(예전엔 깨졌다). AGENTS.md 의 ★규칙은 남지만 이유가 "안 하면 죽는다" 에서 -"안 하면 반영이 안 된다" 로 내려온다. - -**남은 것** — `azure_static._upload_shared` 가 매 발행마다 `assets/` 전체를 올린다. 보관 기간만큼 -업로드량이 는다. Azure 는 지금 꺼져 있으므로(DEPLOY.md) 켤 때 이미 있는 블롭을 건너뛰도록 고친다. - -**검증** — 실제로 세 번 구워 확인: 번들 해시가 바뀌어도 옛 파일 3개가 그대로 남고, 같은 번들로 -다시 구우면 대장이 늘지 않으며(2줄 유지), 대장의 마지막 줄을 60일 전으로 돌리자 그 빌드의 -파일 3개만 정리됐다. `tsc·eslint` 통과, `vitest` 22 passed. - ---- - -## 2026-09-07 — 사이트맵 lastmod 를 파일 mtime 에서 뗐다 - -**왜** -`lastmod` 를 구운 `index.html` 의 **파일 mtime** 에서 읽고 있었다. 그런데 렌더러를 배포하면 -번들 해시가 바뀌어 **내용이 한 글자도 안 바뀐 사이트까지 전부 다시 구워진다** — mtime 은 -그때마다 오늘이 되고, 사이트맵은 "전 사이트가 오늘 갱신됨" 을 통보한다. - -구글은 lastmod 를 페이지의 실제 수정과 대조해 맞을 때만 쓰고, 어긋나면 **그 필드를 아예 -무시한다**(Search Central: "the date and time of the last significant update" · -"consistently and verifiably accurate"). 즉 이 오염은 지금 당장 뭘 깨뜨리는 게 아니라, -**사장님이 진짜로 내용을 고쳐 재발행한 날의 신호를 미리 죽여 두는** 종류다. 배포할 때마다 -신뢰를 태우고 있었고, 사이트가 100개를 넘기면 되돌리는 데 시간이 걸린다. - -**바꾼 것** -- `seo/directory.ts`: `readBakedTitle` · `readBakedLastmod` — 구운 HTML 에서 목록·사이트맵 - 값을 꺼낸다. lastmod 는 페이지가 head 에 선언한 `dateModified`(= `payload.site.updatedAt`) - **그 값 그대로**다. 구글이 대조하는 값과 글자 그대로 같아 어긋날 수가 없다 -- `scripts/prerender.ts`: `readTitle` 을 위로 옮기고 사이트맵 항목에서 mtime 제거. 파일을 - 한 번만 읽어 제목과 lastmod 를 같이 꺼낸다. mtime 은 `dateModified` 메타가 없던 시절의 - 산출물에만 남는 폴백이다 — 그 사이트를 한 번 다시 구우면 제 값이 들어온다 -- `seo/directory.test.ts`: head.ts 의 메타와 파서의 **커플링을 고정**한다. 태그 모양이 바뀌면 - 파서가 조용히 undefined 를 내고 mtime 으로 되돌아간다 — 빌드도 화면도 멀쩡한 회귀라서 붙였다 - -**검증** — `tsc·eslint` 통과, `vitest` 22 passed (신규 5건). - ---- - -## 2026-09-03 — 레포·발행 호스트 교체 — `o2o-site-AEO` / `web4ai.o2osolution.ai` - -**왜** -레포를 `castad/o2o-web4ai` → `Web4ai/o2o-site-AEO` 로, 공개 주소를 `w4ai.o2o.kr` → -`web4ai.o2osolution.ai` 로 옮겼다. 옛 주소는 앞단에 vhost 가 없어 전 경로가 Apache 404 였다 — -그런데 canonical·og:url·sitemap 이 전부 그 주소를 가리키고 있었다. **화면은 멀쩡하고 기계가 -읽는 값만 틀린** 상태라, 검색엔진 등록을 아무리 해도 색인이 안 되는 종류다. - -**바꾼 것** -- 기본 호스트를 쓰는 자리 전부(`site_payload.DEFAULT_HOST` · compose 의 `:-` 기본값 4곳 · - `vite.config.ts` allowedHosts · `.env.example` 둘 · `check_search_ready.py` · 데모 픽스처) -- `docs/SERVERS.md`: 배포 경로 `~/data2/o2o-site-AEO` · 새 remote · 공개 주소 절 -- `init.sql`: `site.sites.thumbnail_url` 을 ALTER 절에 추가 — 아래 참조 -- `solution/frontend/public/google60b514c02fd6af4e.html`: 새 호스트로 다시 받은 구글 소유확인 - -**밟은 함정 둘** -1. **`origin` 은 payload JSON 에 구워진다.** `.env` 만 고치고 프리렌더를 돌리면 안 바뀐다 — - 백엔드에서 재발행하거나 payload 의 `origin` 을 직접 고쳐야 한다. -2. **`init.sql` 은 DB 최초 생성 때만 돈다.** 41커밋을 건너뛰며 배포했더니 `users.provider` 와 - `sites.thumbnail_url` 이 없어 로그인·쇼케이스가 통째로 죽었는데 **HTTP 는 200 이었다.** - `thumbnail_url` 은 `CREATE TABLE` 에만 추가돼 있어서 **새 DB 는 되고 기존 DB 만** 깨졌다. - -**검증** — 새 호스트로 canonical·og:url·robots.txt·sitemap 3건 전부 확인, 로그인·쇼케이스· -장소검색 정상, 스키마 드리프트 0. - ---- - -## 2026-09-03 — 랜딩 · 요금 · 쇼케이스 — 로그인 전 화면이 생겼다 - -**왜** -`/` 가 곧장 위저드로 튀어서, 이 제품이 무엇을 파는 물건인지 말할 자리가 한 곳도 없었다. -처음 온 사람이 업종 선택 화면부터 만난다. - -**한 일** -- `/` 는 비로그인이면 랜딩, 로그인이면 `/sites`. `/pricing` · `/showcase` 신설 -- `MarketingShell` — 사이드바 없는 문서형 껍데기. `AppShell` 은 작업 화면이라 나눴다 - (b07ade2 가 온보딩에서 사이드바를 뺀 것과 같은 판단) -- 랜딩 상단은 **상호명 한 칸**이다. 업종 칩은 "누구를 위한 서비스인가"를 말하는 용도이고 - 고르지 않아도 된다 — 업종은 검색 결과가 정한다 -- 쇼케이스는 발행 썸네일을 그대로 건다. **예시 데이터로 채우지 않는다** — 이 섹션이 파는 건 - "진짜로 나갔다"는 사실 하나라, 가짜를 걸면 그 자리에서 가치가 0 이다. 없으면 섹션을 감춘다 -- 요금은 플랜 하나(70만원/월). 비교표를 만들지 않는다 — 고를 것이 가격대가 아니다 - -**검증** — tsc·eslint·vite build 통과. - -## 2026-09-03 — 상호명 검색을 로그인 앞으로 · 업종은 LLM 없이 정한다 - -**왜** -랜딩 첫 화면에서 상호명을 치게 하려면 검색이 로그인 앞에 있어야 하는데, -후보 조회는 `place_id` 와 토큰을 둘 다 요구했다(`place.py` 확정 경로). 로그인 관문을 -에디터 진입 하나로 되돌려 놓고도 API 는 그대로였다. -그리고 업종은 사장님에게 고르게 하고 있었는데 — 경계(베이커리 카페, 브런치집)에서 멈춘다. - -**한 일** -- `GET /v1/place/search` 신설(인증 없음). 사업장을 만들지도, 우리 DB 를 읽지도 않는다. - 확정 경로(`/{place_id}/verify/candidates`)는 인증을 그대로 둔다 — 남의 place_id 존재 - 여부까지 열 이유가 없다 -- `place_category.guess_category()`: 카카오 `category_group_code`(AD5·CE7·FD6) 우선, - 없으면 분류 문자열. **LLM 호출 0건** — 상호명 검색 응답에 이미 들어 있던 값이다 -- 못 정하면 `None`. 억지로 고르지 않는다 — 업종은 수집 스키마와 JSON-LD 타입을 통째로 - 정해서 틀리면 되돌리는 비용이 크다. HP8(병원)은 피부과·성형외과일 때만 받는다 -- `rate_limit`: 인증 없이 유료 외부 API 를 부르는 경로라 IP 당 분당 20회. - 프로세스 메모리라 완전하지 않다(앞단 nginx 가 제대로 된 자리) - -**검증** — 전체 562 passed. 공개 응답에 place_id·전화·좌표가 안 나가는 것, -검색만으로 사업장이 생기지 않는 것을 테스트로 고정. - -## 2026-09-03 — 발행하면 썸네일이 남는다 (랜딩 쇼케이스용) - -**왜** -랜딩에 "이렇게 만들어졌습니다" 를 보여줄 그림이 없었다. 사이트는 발행되는데 그 결과물을 -가리킬 이미지가 어디에도 저장되지 않아, 쇼케이스를 만들려면 매번 사람이 캡처를 떠야 했다. - -**썸네일은 스크린샷이 아니라 그 사이트의 대표 사진이다** -헤드리스 브라우저는 봇 탐지 우회 우려로 영구 금지고([DECISIONS 1-1](DECISIONS.md)), -워커(python:3.12-slim)·프리렌더(node:24-alpine) 어디에도 Chromium 이 없다. 넣으면 이미지가 -수백 MB 늘고 금지해 둔 도구를 상비하게 된다. 대신 `og:image` 로 나가는 **대표 사진**을 그대로 -옮긴다 — 검색 결과에 뜨는 그림과 쇼케이스 카드가 같아진다. 대표 사진 선정 규칙은 -`site_payload.primary_media()` 한 곳뿐이라 두 곳이 갈릴 수 없다. - -**한 일** -- `services/site_thumbnail.py` 신설. 대표 사진을 httpx 로 받아(10초 상한 · 리다이렉트 3회 · - image/* 만 · 5MB 상한) `/thumbs/.` 로 올린다. 기존 - `AZURE_STORAGE_CONNECTION_STRING` 을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다. - ★ 사이트 경로(`s//`) 안에 두지 않는다: `azure_static._remove_stale_site_files()` 가 - 매 발행마다 그 경로를 통째로 교체하므로 다음 발행에서 조용히 사라진다. -- `build_service`: `azure_static.publish()` 직후 · IndexNow 통보 전에 저장하고, - 발행 상태 전이 UPDATE 에 `thumbnail_url` 을 실어 보낸다(UPDATE 는 그대로 한 번). - 실패해도 발행을 되돌리지 않는다 — 정적 파일은 이미 올라갔다(`emit_payload` 와 같은 원칙). - 못 만들면 키를 넣지 않아 지난 발행의 그림이 남는다. -- `GET /v1/showcase` 신설(**인증 없음**, 랜딩이 부른다). 발행된 사이트만 최신순, - 기본 12건·상한 48건. 나가는 것은 상호명·업종·지역(시·군·구까지)·발행 주소·썸네일뿐이다 — - place_id·company_id·전화번호·상세 주소는 싣지 않는다. 무엇을 내보낼지 고르는 자리를 - `services/showcase_service.py` 한 곳에 모아 경계를 눈에 보이게 뒀다. - 어드민 진입점(:9801)에는 마운트하지 않는다. - -**곁가지로 고친 것 — 브랜치에 이미 깨져 있던 테스트 4건** -- `conftest.fake_renderer` 가 늘 `ok=True` 를 돌려줬다. 진짜 렌더러는 고유 콘텐츠 0건이면 - 페이지를 쓰지 않는데(prerender.ts `NoUniqueContentError`), 대역이 그 실패를 흉내내지 않아 - 백엔드가 그 사유를 NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 안 돌고 있었다. -- `test_snapshot` 이 "region_code 가 없으면 지역 정보 없음" 을 기대했다. 지금은 도로명주소에서 - 유도한다(`snapshot._local_contents`) — 유도 동작에 테스트가 없었다. 둘로 갈라 채웠다. - -**검증** — `pytest` 전체 552 passed. -## 2026-09-02 — 로그인한 사장님의 홈(내 사이트 · 내 정보) · 위저드에서 사이드바 제거 - -**왜** -로그인해도 갈 곳이 없었다. `/` 는 무조건 위저드였고, 사업장 목록은 내부 운영 앱(admin)으로 -나가서 사장님 앱에는 그 경로가 아예 없다. 만든 사이트를 다시 여는 유일한 길이 -`/builder?placeId=` 를 기억하는 것이었다. - -아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과 -**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로 -비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지 -않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다. - -**한 일** -- `GET /v1/site/list` — places LEFT JOIN sites LEFT JOIN site_versions 한 번. 사업장 목록으로 - 그리면 줄마다 사이트를 다시 물어 N+1 이다. 사이트가 아직 없는 사업장도 내려간다 — - 빠지면 위저드를 걸어오다 만 가게를 다시 찾을 길이 없다. - `render`(정적 파일이 실제로 있는지)는 넣지 않았다 — 보고서 **파일**을 읽는 값이라 줄 수만큼 - 파일 IO 가 된다. 단건(`Res_Site`)이 계속 소유한다. -- `/sites` 내 사이트 · `/account` 내 정보. `/` 는 로그인 여부로 갈린다(비로그인은 그대로 위저드). -- ⋯ 메뉴는 **[발행 내리기] 하나**다. 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 - 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다. -- 위저드에서 `AppShell`(사이드바)을 걷어내고 얇은 상단 바로 바꿨다. 사이드바는 계정 메뉴라, - 만들던 중에 [새 사이트]를 눌러 방금 입력한 것을 지우는 길만 열어 준다. 진행은 `WizardSteps` 가 - 이미 보여주므로 거기 필요한 건 로고와 나가는 길 하나다. -- 에디터 헤더에 [← 내 사이트]. `BuilderPage` 가 "내 사이트 관리가 생기면 그때 잇는다"고 - 비워 뒀던 자리다. - -**검증** — 백엔드 테스트 5건 추가(사이트 없는 사업장 · 조인 · 회사 격리 · 재빌드 판정이 단건과 -일치 · 비로그인 401), 539 passed. `tsc·eslint·vite build` 통과(frontend·admin). -위저드에 사이드바가 사라진 것은 브라우저에서 확인. - -## 2026-09-02 — 계절별 추천 하루는 지금 계절만 · 간절기엔 두 계절 - -**왜** -네 계절 코스를 다 늘어놓으니 손님 앞에 열두 개가 깔렸다. 그건 추천이 아니라 목록이다. -12월에 온 손님에게 봄 벚꽃 코스를 권할 이유가 없다. - -**한 일** -- `shared/currentSeasons()` — 3~5 봄 · 6~8 여름 · 9~11 가을 · 12~2 겨울. - **계절 첫 달의 전반(1~15일)은 간절기**로 보고 앞 계절과 함께 둘을 돌려준다. - 9월 초에 여름 코스만 보이면 지난 계절이고, 가을만 보이면 아직 이른 코스다. -- 발행본은 **HTML 에 전 계절을 굽고 화면에서만 접는다**(`hidden`). 두 가지 이유다 — - ① 정적 페이지는 한 번 구우면 몇 달 산다. 굽는 시점의 계절을 박으면 12월에도 가을이 걸린다. - 그래서 계절 판정을 **브라우저에서** 한다(일력의 '오늘'과 같은 수법). - ② 이 사이트의 존재 이유가 인용이다. 지우면 검색·AI 가 나머지 계절을 못 읽는다. -- 지금 계절에 코스가 없으면(사장님이 그 계절을 안 채웠다) 접지 않고 전부 보여준다 — - 빈 섹션보다 철 지난 코스가 낫다. -- 빌더는 탭을 그대로 두되 **지금 계절로 열리고**, 탭에 '·지금' 표시와 - "손님 화면에는 지금 계절만 나갑니다" 한 줄을 붙였다. 안 적으면 사장님은 손님도 네 계절을 - 다 본다고 오해한다. - -**검증** — `tsc·eslint` 통과(frontend·site). 경계 12일자 단위 확인 -(3/5→겨울·봄, 3/20→봄, 6/7→봄·여름, 9/2→여름·가을, 9/16→가을, 12/10→가을·겨울). -실물 payload(스테이,머뭄 `/s/stay`, 9코스 4계절)로 구워 **오늘(9/2) 여름·가을만 보이고 -봄·겨울은 `hidden`, HTML 에는 네 계절 전부** 있는 것을 브라우저에서 확인. - -## 2026-09-02 — 계절별 추천 하루(시각을 계산해 주는 아이템) · 아이템에서 레트로 하드코딩 제거 - -**왜** -아이템 열 개가 전부 갱지색·주(朱)잉크·간판체를 hex 와 폰트명으로 박고 있었다. 사장님이 템플릿을 -매거진으로 바꿔도 **아이템 섹션만 레트로로 남아** 화면이 두 벌로 보였다. 아이템은 레트로 전용 -부품이 아니라 어느 템플릿에나 들어가는 섹션이다. -그리고 발행본은 **색만** 템플릿을 따랐다 — `theme` 계약에 생김새(look)가 없어서, 레트로를 골라도 -발행 페이지는 늘 같은 고딕으로 나갔다. 캔버스와 발행본이 다르게 보이는 가장 큰 이유였다. - -**한 일** -- 아이템 1종 추가 — **계절별 추천 하루**(`planner.podium`). 계절 탭 + 1·2·3위 카드. - 기존 `schedule` 과 축이 다르다: 저쪽은 사장님이 시각을 적고, 여기는 **시각을 계산한다**. - 사장님은 출발 시각과 "몇 분 걸리나"만 적고, 출발을 당기면 하루가 통째로 밀린다. - 조립 규칙(`planDay`·`plannerTop`·`plannerSeasons`)은 파서와 같은 이유로 `@o2o/shared` 한 벌이다 — - 빌더와 발행본이 같은 조건에서 **같은 시각**을 내야 한다. - 밤 9시를 넘기는 칸은 넣지 않고 **뺐다고 화면에 밝힌다**(숨기면 사장님은 왜 없는지 모른다). -- 아이템 색·서체를 전부 템플릿 토큰(`--tpl-*`)으로. `retro/common.tsx` → `items/common.tsx`, - `RETRO_*` 상수 → `ITEM_*` 토큰. 글자 단계는 stone-400/500/600 대신 **불투명도**로 만든다 — - 팔레트가 바뀌어도 위계가 유지된다. 질감(도넛판 홈·톱니·필름 구멍)도 `currentColor` 로 판다. -- **`SiteTheme.look` 계약 추가** — 서체·모서리·테두리 두께·그림자·섹션 여백이 발행본까지 간다. - 프론트가 저장하고(`toThemePayload`), 서버는 해석 없이 싣고(`_theme`), `seo/head.ts` 가 `--tpl-*` 로 심는다. - 발행본 `.serif`·`body` 도 이 토큰을 읽는다. -- 웹폰트는 **템플릿이 쓰는 것만** 내려보낸다(서체 스택을 훑어 아는 것만). 전부 항상 실으면 - 쓰지도 않는 서체가 모든 발행 사이트의 첫 렌더를 늦춘다. -- 색 유도식(`deriveSurfaces`)을 `@o2o/shared` 로. 캔버스·쇼케이스·**발행본**이 같은 식을 써야 - 미리보기가 거짓말을 하지 않는다. 프론트 `lib/color.ts` 는 재수출만 남겼다. - -**밟은 함정** -- 강조색을 그대로 쓰면 팔레트에 따라 큰 날짜 숫자와 배지가 **사라진다**(연한 accent + 밝은 바탕). - → `color-mix(accent 70%, currentColor)` — 색조는 남고 대비만 확보된다. 어두운 면에서는 밝은 쪽으로 붙는다. -- 순위 배지를 accent 로 채웠더니 같은 이유로 글자가 안 보였다. 1위만 **글자색**으로 채운다. -- '확인/확인필요' 배지는 디자인이 아니라 신호다. 신호색은 지키되 둘레 글자색을 섞어 대비만 맞춘다. - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. -쇼케이스에서 팔레트를 바꿔 제목 서체·날짜 색이 함께 바뀌는 것 확인. -실물 프리렌더(레트로 look + planner): `` 에 `--tpl-font-heading: 'Gugi'…`·`--tpl-border-width: 2px`, -`family=Gugi&family=Gowun+Batang` 링크, 계절 묶음 여름·가을, 순위 1·2위, -계산된 시각(09:30 출발 → 09:45 도착 → 11:15 → 11:25…) 확인. 고유 콘텐츠 12건 · ok=true. -**옛 payload(look 없음)** 로 다시 구워 서체 링크가 예전 두 벌 그대로이고 look 토큰이 안 실리는 것까지 확인. -백엔드는 이 환경에 PostgreSQL 이 없어 pytest 를 못 돌렸다 — `_theme`·`_sections` 는 함수 단위로 직접 확인했다. - -## 2026-09-02 — 붙여넣기 아이템 다섯을 더하고, 아홉 개를 발행 사이트까지 내보낸다 - -**왜** -아이템 카탈로그에서 고른 여덟 중 넷(가요·일력·승차권 + 스케줄)만 있었다. 나머지 다섯은 -빌더에 칸 자체가 없었다. 더 큰 구멍은 그 아래에 있었다 — **아홉 개 전부 발행본에 안 나갔다.** -`SectionSetting` 계약에 `data` 가 없어서, 사장님이 채운 JSON 이 payload 경계에서 통째로 버려졌다 -(소개문 `body` 와 같은 사연). 빌더에서는 보이는데 발행하면 없는 섹션이었다. - -**한 일** -- 아이템 5종 추가 — 인물 열전(필름 스트립) · 시간의 골목(가로 연표) · 문학 서가(책등·세로쓰기) · - 오늘의 엽서(엽서 뒷면) · 뒤집어 보는 질문(갱지 시험지 플립). - `dataSpec` 에 스키마·프롬프트·예시, `registry` 에 배리에이션 한 줄씩. - [+ 섹션 추가] 목록은 `dataSpec` 에서 파생돼(addable.ts) 따로 손댈 곳이 없다. -- **읽는 쪽 계약을 `@o2o/shared` 로 옮겼다**(`lib/section-data.ts`) — 항목 타입 · `parseSectionData`. - 같은 JSON 을 빌더와 발행 사이트가 함께 읽는다. 파서를 각자 두면 슬러그 규칙처럼 조용히 어긋난다. - 빌더에는 **쓰는 쪽**(프롬프트·예시·라벨)만 남았다. -- `SectionSetting.data` 계약 추가 · `site_payload._sections()` 가 그대로 실어 보낸다(서버는 파싱하지 않는다). -- 발행 사이트에 아이템 섹션 아홉(`site/src/sections/items/`). **인터랙션은 옮기지 않았다** — - 캔버스의 턴테이블은 '지금 한 곡'만 펴는데 그러면 나머지 곡의 문장이 HTML 에 없다. - 이 사이트의 존재 이유가 AI·검색의 인용이라 발행본은 전 항목을 펴고 가로로만 민다. -- 프리렌더 고유 콘텐츠 계수에 아이템 항목을 넣었다. 안 세면 "곡을 여덟 개 채웠는데 - 고유 콘텐츠 0건으로 발행이 막힌다"가 된다 — `intro.body` 와 같은 구멍이다(백엔드 fake 도 같이). -- 간판체(Gugi)는 **아이템을 실제로 쓰는 사이트에만** `` 로 내려보낸다. 서체 하나가 - 모든 발행 사이트의 첫 렌더를 늦출 이유가 없다. - -**안 한 것** -레트로 템플릿 시드(`defaultSectionTypes`)는 넷 그대로 뒀다. 붙여넣기 아이템은 내용이 없으면 -빈 섹션이라, 아홉을 시드에 박으면 아무도 안 쓰는 칸이 늘 붙어 있게 된다(addable.ts 의 근거). - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site), site 테스트 17 passed. -실물 프리렌더: 아이템 아홉이 든 payload → `ok=true`, 고유 콘텐츠 18건, 발행 HTML 에 아홉 섹션과 -본문 문장 전부 포함, `family=Gugi` 링크 있음. 같은 payload 에서 아이템을 빼면 8건 · Gugi 링크 없음. -백엔드 pytest 는 이 환경에 PostgreSQL 이 없어 전 건 연결 오류로 못 돌렸다 — -바꾼 `_sections()` 와 conftest 계수는 함수 단위로 직접 돌려 확인했다. - ---- - -## 2026-09-02 — 회원가입과 구글 로그인 - -**한 일** -- `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_` 가 20자를 넘는다). -- 에디터(6단계) 상단 바에 로그인한 사용자와 [로그아웃]. 위저드는 AppShell 사이드바가 - 들고 있었는데 에디터는 전체 화면이라 **신원도 나가는 길도 화면에서 사라져 있었다.** - -**왜 가입부터 만들었나** -계정 생성 API 가 아예 없었다 — 그동안 `users` 를 손으로 INSERT 했다. 로그인 화면은 있는데 -그 뒤에 설 계정을 만들 방법이 제품에 없는 상태였다. 가입 = **새 회사(테넌트) 1개 + 첫 계정 1개** -로 정의했다. `users.company_id` 가 NOT NULL 이고 모든 도메인이 company 로 스코프되기 때문이다. - -**로그인 관문은 에디터 진입 그대로다** -한때 `/builder` 를 통째로 `RequireAuth` 뒤로 옮겼다가 되돌렸다(5ef3e5a). `/` 가 자기 화면 없이 -`/builder` 로 넘기기만 하므로 **문 앞 가드는 곧 루트 가드**이고, 앱을 열자마자 로그인 화면이 된다. -관문은 `EditorSignInGate`(969fb67) 한 자리다. - -**밟기 쉬운 자리** -- **`GOOGLE_CLIENT_ID` 는 백엔드와 프론트가 같아야 한다.** 백엔드는 이 값으로 구글 토큰의 - 수신자(`aud`)를 대조한다 — 이 검사가 없으면 **다른 서비스에 발급된 진짜 구글 토큰**으로 - 우리 계정에 들어온다. 어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. -- **같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.** 이으면 계정 선점이 - 된다 → [DECISIONS.md 1-5](DECISIONS.md) -- 소셜 계정은 `password` 가 NULL 이다. id/pw 로그인 경로에서 먼저 끊지 않으면 해시 검증이 - None 을 만나 500 이 난다. -- `provider` 에 `server_default` 를 같이 줬다. ORM default 는 raw INSERT(테스트 시드)에 안 먹어서 - NOT NULL 컬럼이면 그 경로가 통째로 깨진다. -- **init.sql 에서 새 컬럼의 인덱스는 맨 끝 ALTER 섹션에 둔다.** 인덱스 절이 ALTER 보다 위라, - 기존 DB 에서는 아직 없는 컬럼을 가리켜 스크립트가 통째로 멈춘다(실측으로 밟았다). - -**이미 도는 DB 가 있으면** `postgres-init/init-data/init.sql` 을 다시 적용한다. - -**아직 못 한 것** — 실제 구글 계정 로그인. `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개) - -**왜** -업종마다 템플릿이 다섯이었는데 넷이 "흰 바탕 + 고딕 + 둥근 모서리"에 색조만 달랐다. -고르는 화면의 미리보기도 회색 막대 세 줄 + 색 동그라미라 다섯 장이 전부 같은 그림이었다 — -사장님은 뭐가 다른지 알 수 없으니 아무거나 골랐다. 사용자 말: "가라 UI 로 되어 있어서 뭐가뭔지 모르겠음". - -**한 일** -- `TemplateItem.look`(`TemplateLook`) 신설: 제목·본문 서체, 모서리, 테두리 두께, 그림자, - 제목 자간·굵기, 섹션 여백. **CSS 에 그대로 들어가는 문자열**로 들고 있다 — 숫자로 두면 - 쓰는 쪽에서 단위를 빠뜨린 곳이 조용히 0 이 된다. -- 업종당 **3개**로 정리: 심플(고딕·둥근·그림자) · 매거진(명조 제목·각짐·그림자 없음·여백 큼) · - 레트로(간판체·2px 테두리·오프셋 그림자·갱지). `templatesFor()` 팩토리 하나가 찍어내고 - **업종은 accent 하나만 바꾼다** — 생김새는 업종이 아니라 취향의 문제다. - `industryData.ts` 537줄 → 237줄. -- 고르는 화면의 미리보기를 **그 템플릿의 서체·모서리·테두리·그림자로 실제로 그린다**(`TemplatePreview`). - -**핵심 수법 — Tailwind 테마 변수를 캔버스 안에서만 덮는다** -`.site-canvas` 에 `--radius-*` · `--shadow-*` 를 내려보내면, 변이 파일 40여 개에 흩어진 -`rounded-*` · `shadow-*` 를 **한 줄도 안 고치고** 전부 템플릿을 따르게 된다. -배수는 Tailwind 기본 비율을 그대로 옮겨, 기준값 0.75rem 이면 지금까지와 픽셀 단위로 같고 0 이면 전부 각진다. - -**밟은 함정** -- `.site-canvas` 는 이미 `--tpl-font-heading/body` 를 **읽고 있었는데 아무도 넣지 않았다.** - 서체가 갈리지 않던 진짜 이유가 이 빠진 고리였다. -- 간판체(Gugi)는 굵기가 한 벌뿐이라 `font-weight:700` 을 주면 브라우저가 가짜 볼드를 씌워 획이 뭉갠다. - → `--tpl-heading-weight` 로 템플릿이 400 을 지정할 수 있게 했다. -- `Noto Serif KR` 을 안 불러오고 있었다. 매거진 제목이 Batang 으로 떨어지는데 맥에는 그 서체가 없다. -- 옛 템플릿 id(`stay-warm-wood` 등)가 DB 에 남아 있어도 `resolveTemplate` 이 첫 템플릿으로 떨어뜨린다. - -**검증** — `tsc·eslint·vite build` 통과(frontend·admin·site). 템플릿 12벌의 look 전량 대조, -옛 id 폴백·레트로만 아이템을 데려오는지 확인. - ---- - -## 2026-09-01 — 붙여넣기 아이템 셋: 가요 다방 · 오늘의 한 장 · 반나절 산책 - -**한 일** -- 섹션 타입 3개 추가(`songs` · `daily` · `course`). 데이터가 수집(fact)이 아니라 - **사장님이 붙여넣은 JSON** 에서 온다 — 새 갈래다. -- `canvas/dataSpec.ts` 신설: 스키마·예시·프롬프트가 한 표에 모인다. 배리에이션 레지스트리와 같은 결이라 - 여기 한 줄을 더하면 캔버스·[콘텐츠] 탭·프롬프트가 동시에 는다. -- `SectionItem.data?: string` 추가. **파싱본이 아니라 원문 문자열**을 담는다. -- [콘텐츠] 탭에 JSON 칸 + [프롬프트 복사] [프롬프트 보기] [예시 넣기] [줄맞춤]. -- 업종 시드 넷 모두에 세 섹션을 **꺼진 채로** 넣었다. - -**왜 이 모양인가** -`gunsan_365_story_db.xlsx`(365행)를 분석한 결과 **고유 주제는 52개고 한 주제가 7회씩 돈다** -(접미사 10개만 회전). 날짜 축으로 카드를 늘어놓으면 이레마다 같은 카드가 돌아온다 — -그래서 묶는 축을 주제로 잡고, 날짜는 일력 한 장에만 썼다. -같은 시트 `DB_Guide` 가 **가사·현대문학 원문 전재를 금지**해서 가요 스키마에 `lyrics` 필드를 -아예 두지 않았다. 없는 칸은 채울 수 없다. - -**밟은 함정** -- **테마 상한 64KB**(`site_service._THEME_MAX_BYTES`). 세 섹션이 각자 JSON 을 채우면 넘고, - 거절은 발행 직전에야 드러난다. → `SECTION_DATA_MAX_CHARS`(12,000자)로 화면에서 먼저 끊는다. -- **`JSON.parse` 오류 메시지가 두 형식이다.** `position N (line L column C)` 형과, 위치 없이 - 깨진 조각만 인용하는 형. 앞의 것만 보면 후자에서 위치를 통째로 잃는다 — 조각을 원문에서 되찾아 센다. -- 파싱은 **절대 throw 하지 않는다.** 편집 중인 JSON 은 늘 깨져 있고, 깨진 순간 캔버스가 죽으면 못 고친다. - -**섹션 관리에 붙인 것** -- 좌측 패널 하단 **[+ 섹션 추가]** → 목록에서 골라 넣는다. 시드에 박아 두지 않는 이유는, - 붙여넣기 아이템은 내용이 없으면 빈 칸이라 아무도 안 쓰는 항목이 늘 붙어 있게 되기 때문이다. -- 나중에 넣은 섹션만 휴지통으로 뺄 수 있다(업종 기본 섹션은 스위치로 끈다). -- **레트로 템플릿**(업종마다 하나: 옛 항구 · 옛 다방 · 노포 · 시간여행)을 고르면 세 아이템이 함께 들어온다. - `TemplateItem.defaultSectionTypes` 가 그 계약이고, **넣기만 하고 빼지 않는다** — - 템플릿을 눌러 보다 넣어 둔 섹션이 사라지면 사장님은 자기가 지웠다고 생각한다. -- 저장 payload 에 `type` 을 실었다. 시드에 없는 섹션은 복원 때 `id` 로 못 찾아 **통째로 버려졌다** - (사장님이 채운 JSON 까지 같이). 이제 `type` 으로 되살린다. - -**아직 안 한 것** -- 발행 사이트(`solution/site`)는 `variantId` 도 `data` 도 아직 안 읽는다. 지금은 빌더 캔버스 전용이다. -- 프롬프트는 상호·주소를 박아 내보낸다(빈칸을 남기면 사장님이 못 채우고 그대로 보낸다). - -**검증** — `tsc --noEmit` · `eslint` · `vite build` 통과(frontend·admin). 세 배리에이션 SSR 렌더 확인, -파서 경계 12건 + 추가·삭제·템플릿·저장복원 왕복 12건 확인. - ---- - -## 2026-09-01 — 설정을 `.env` 하나로 모았다 - -**한 일** -- 백엔드 설정을 toml → `pydantic-settings`(FastAPI 공식 방식)로 옮겼다. -- `config_loader.py` · `config.local.toml.example` · `config.test.toml.example` 삭제. -- `server_configs.py` 107줄 → 26줄. `_apply_*_env_override` 함수 4개 제거. -- 호출부 21개 파일은 안 건드렸다 — 같은 이름을 그대로 내보낸다. - -**왜** -키마다 `if os.environ.get(...)` 를 손으로 나열하는 구조였다. 하나 빠뜨리면 조용히 틀리는데, -실제로 `client_url` 이 빠져 있어 **배포 주소의 API 호출이 전부 CORS 로 막혔다**. -`BaseSettings` 는 필드를 선언하면 환경변수가 자동으로 들어와 이 사고가 구조적으로 안 난다. - -**하는 김에 잡은 잠재 버그** -- `.env` 경로가 세 단계라 `solution/.env`(없는 파일)를 보고 있었다. 백엔드를 `solution/` 아래로 - 옮길 때 안 고쳐진 자리다. toml 이 값을 들고 있어 로컬에서 안 드러났고, 도커는 compose 가 - 환경변수를 직접 넣어 역시 멀쩡했다. toml 을 없앤 지금은 유일한 공급원이라 치명적이었다. -- 환경변수 이름을 `validation_alias` 로 못 박았다. 안 그러면 `port` 필드가 흔한 `PORT` 를 - 주워 먹어 엉뚱한 포트로 뜬다. - -**결과** — 백엔드 설정 파일은 최상위 `.env` 하나뿐이다. → [DECISIONS.md](DECISIONS.md) - ---- +빌더·렌더러·백엔드가 템플릿을 따로 적어 서로 어긋나 있었다(없는 기본 id, 강조색 오타, 섹션 간격 차이). + +- 템플릿 목록은 `shared/src/data/templates.json` 하나. TS와 파이썬이 같은 파일을 읽는다. +- id에서 업종을 뗐다(`stay-retro` → `retro`, 마이그레이션 `0023`, 운영 미적용). +- 모르는 템플릿 id는 저장·미리보기·발행 모두 거절한다. 기본값으로 슬쩍 굽지 않는다. +- 고택(`paper`)을 `/s/stay2` 시안과 같게 다시 만들었다. 하위 페이지는 한 HTML 안의 탭이다. +- 구조와 추가 방법: [TEMPLATES.md](TEMPLATES.md). + +## 2026-09-23 — 개발자용 사이트·유저 관리를 solution 앱에 얹었다 + +admin 앱을 키우기엔 이르다(대표 지시). `UserRole.DEVELOPER` 게이트로 `/ops/sites`·`/ops/users`(읽기 전용). +★ 메뉴 문자열은 사장님 번들에도 실린다(런타임 조건부 렌더) — 데이터는 백엔드 게이트가 막는다. + +## 2026-09-21 ~ 22 — 사장님 에이전트 (빌더 대화창 → 카카오톡 채널) + +설계와 함정은 [AGENT.md](AGENT.md)와 AGENTS.md "에이전트에서 조용히 틀리는 것"이 단일 출처다. + +- 순서: 신원 연결(`owner_kakao_links`) → 도구 레지스트리·런타임·빌더 채팅창 → 카카오 웹훅. + 런타임이 채널을 모르게 만들어 두어, 웹훅을 붙일 때 런타임은 한 줄도 안 바뀌었다. +- 모델에게 맡기지 않은 셋: 확인 등급, 결과 문구, fact key. 확인(SEMI)은 서버가 인자를 다시 검증한다. +- ★ 오픈빌더는 서명이 없다 — 공유 시크릿이 유일한 문이고, 없으면 엔드포인트가 404. +- ★ 카톡 5초 벽: 개발 중 잰 1.3~2.4초는 장난감 프롬프트였고 실사용 첫날 타임아웃이 났다. + 콜백(`useCallback`)으로 즉답 후 따로 보낸다. 오픈빌더 스킬 설정에서 콜백을 켜야 이 경로가 열린다. +- 카톡 대화에는 홈페이지 목록·발행 여부·가게 바꾸기를 LLM 없이 보여 준다(대화가 막혔을 때 늘 통해야 한다). +- 밟은 것: `execute_lambda` 는 람다 반환값을 그대로 준다 — 객체만 돌려주면 언패킹 TypeError 가 나는데 + 라우터가 예외를 삼켜 "지금은 처리할 수 없어요"만 보였다. + +## 2026-09-17 — 미니 블로그: 팀 검수 폐지 · 배정일 · 달력 화면 · 메일 승인 + +상세는 [MINI_BLOG.md](MINI_BLOG.md). + +- 팀 사전검수를 없애고 최종 판단을 사장님에게 넘겼다(팀 단계가 병목이었다). 업장당 하루 한 통. +- `place_posts.scheduled_date` 로 글마다 날짜를 정했다. 빌더는 달력 + 그 위 일주일치 카로셀, 생성 이력 탭. +- 메일 승인 링크는 GET 즉시 승인(프리페치 위험을 알고 사장님이 택했다), 링크는 그날 자정 만료. +- 잡은 버그들: + - 승인이 BUILD 잡에 `owner_user_id` 를 안 실어 **메일 승인이 재발행을 못 하고 있었다**. + - `generate_one` 의 죽은 import 로 "지금 생성하기"가 500. 테스트는 그 함수를 monkeypatch 해서 초록이었다 — + 단위 테스트 초록과 실제로 도는 것은 다르다. + - `scheduled_date` 를 추가하자 값이 NULL인 기존 글 13건이 조회에서 조용히 빠졌다 → 백필. + 새 컬럼 마이그레이션은 "기존 행이 조회에서 빠지는지"부터 본다. + - raw `text()` 로 timestamptz 에 naive datetime 을 넣으면 드라이버 로컬 시간대(KST)로 9시간 밀린다. + - 세션 복구보다 늦게 자동 로그인하면 `RequireAuth` 가 이미 `/login` 으로 튕긴다 → 복구 단계로 옮겼다. + - ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다 → flush 직후 dict 로 뽑는다. + +## 2026-09-16 — 생성·수집 실패를 잡이 견디게 + +- Gemini 호출 실패(429 등)가 온보딩 COPY 잡을 DEAD 로 보내지 않는다. fact 만으로 계속한다 — + 키가 없을 때와 같은 동작. 실측: 사진분석 배치가 분당 쿼터를 다 써서 같은 키의 COPY 가 죽었다. +- 재수집 때 나는 유니크 충돌 로그를 ERROR → WARN(정상 경로인데 오류처럼 보였다). +- 크롤링 실패를 `jobs.result` 에 구조화해 남긴다(`common/collect_diagnostics.py`). +- Teams 웹훅: 플로우 수신자가 예약값(`48:notes`)이라 계속 실패 → 플로우 재생성으로 해결. + +## 2026-09-15 — 발행 버전 전환 · 장애 알림 · 보안 · 서치콘솔 · 생성 진행 복구 + +- **워커가 렌더하고 버전별로 보관, 게이트 통과 뒤 공개 링크를 바꾼다.** 상시 프리렌더를 없앴다. + 배포는 기존 HTML과 목업을 다시 굽지 않는다 → [PUBLISH_VERSION.md](PUBLISH_VERSION.md). +- 장애 알림: `alert_outbox` + 재시도·중복 억제·복구 알림, `/readyz`(DB까지 확인) → [ALERTS.md](ALERTS.md). + ★ 앱·DB 시계가 수십 ms만 어긋나도 방금 넣은 알림이 안 잡혔다 → 비교는 DB 시계(`func.now()`). + ★ HTTP 202 는 워크플로 접수일 뿐 채널 게시 성공이 아니다. +- 운영 번들에서 자동 로그인 자격증명 제거(build arg 삭제 + `import.meta.env.DEV` 가드). + `users.token_version` 으로 비밀번호 변경 시 기존 refresh 토큰을 무효화한다(전에는 7일간 계속 통했다). +- Google 사이트맵 자동 제출·색인 관측 → [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). +- 콘텐츠 생성 단계 상태를 DB에 기록, URL의 jobId로 새로고침 복구 → [GENERATION_FLOW.md](GENERATION_FLOW.md). + +## 2026-09-14 — SNS 게재 · 엽서 · FAQ 20개 · SEO 키워드 + +- **SNS(스레드) 게재**: 사장님 클릭 → fact로 초안 → 승인 → 사장님 계정으로 게시. 이 레포가 처음으로 + 외부에 쓰고, 남의 자격증명을 보관하고, 되돌릴 수 없는 일을 한다. 설계는 [SOCIAL.md](SOCIAL.md). + 승인은 POST만, 주소가 확정된(`domain`) 사이트만, 사진은 올리지 않는다, 기본 꺼짐. + X는 URL 글 요청당 $0.20이라 뺐다. + ★ `server_default=text("'[]',")` 쉼표가 CREATE TABLE 을 통째로 실패시켰다(테스트 DB에서만 드러난다). +- **엽서 쓰기**를 발행본에 넣었다. ★ 남의 도메인 사진을 캔버스에 그리면 오염돼 저장·공유가 막힌다 + (네이버 CDN은 CORS를 안 준다) → 발행 때 사진을 우리 오리진으로 내려받는 미러로 해결(AGENTS.md). +- **FAQ를 20개까지**: fact로 쓰면 4~8개에서 끝나서, 펜션 공통 질문 카탈로그로 "문의 안내" 답을 채운다. + ★ 공통 답에 값을 적지 않고, 이 답은 JSON-LD·llms.txt·고유 콘텐츠 계수에서 뺀다. +- **SiteOntology 키워드**를 제목·keywords 메타에 싣는다. ★ 추천 10건 중 사실이 아닌 것(마당·복층)이 + 섞여 와서 "모든 낱말이 이 가게 자료에 있어야" 싣는다(10건 → 4건). 모르는 regionId 는 저쪽이 500을 준다. + +## 2026-09-11 — 발행하면 이 숙소의 노래가 생긴다 (가사 Gemini → 작곡 Suno) + +- 발행이 노래를 기다린다(첫 화면에 기능이 빠져 보이지 않게). 실패해도 발행은 막지 않는다. +- 가사는 소개문과 같은 재료로 우리가 쓴다 — Suno에 맡기면 없는 시설을 노래한다. +- ★ Suno 주소는 만료된다 → mp3를 받아 우리 경로로만 내보낸다. 콜백이 아니라 폴링(우리 서버에 닿을 주소가 없다). +- 미리보기 빌드에는 만들지 않는다(유료 호출). + +## 2026-09-10 — 소개문 승인 단계 제거 · 렌더러 이식 · 일력 + +- **생성된 소개문이 영영 안 나가던 것**: 소개문이 수집 확인 화면보다 2분 늦게 도착해 승인할 화면이 없었다. + LLM 출력은 확인된 fact로만 쓰므로 승인 없이 노출값으로 둔다. 사장님이 고친 문장은 LLM이 못 덮는다 + → [DECISIONS.md 7절](DECISIONS.md). + ★ ORM의 timestamptz 기본값 `(now() AT TIME ZONE 'utc')` 가 값을 서버 시간대만큼 미래로 밀어 + 테스트 DB에서 잡이 영영 안 집혔다 → init.sql과 같은 `now()`. +- `/s/stay` 시안이 다른 워크트리의 **커밋 안 된 작업본**에만 있어 렌더러가 갈렸다 → 시안 payload를 현재 + 렌더러로 다시 구워 태그 단위 diff(129줄 → 4줄). 카카오 길찾기가 상호의 쉼표 때문에 목적지를 버리던 것도 고쳤다. +- 일력을 서버 생성에 붙였다. 종류 목록이 두 벌이라 새 종류가 서버에 안 갔고, "한 건이라도 있으면 안 부른다" + 가드가 기존 지역에 새 종류를 영영 막았다 → 없는 종류만 부른다. + +## 2026-09-09 — 지역 이야기 서버 생성 · 예약 목업 + +- 가요·인물·연표·엽서·퀴즈를 지역 단위로 한 번 생성해 같은 지역 사이트가 나눠 쓴다(Perplexity, 출처 필수) + → [DECISIONS.md 6절](DECISIONS.md). +- 예약 흐름 목업(`StayBookingDemo`). 연동 없음 — "마감/잔여"를 지어내지 않고, 시간 후보는 체크인 fact에서만. + ★ 날짜는 브라우저에서 만든다 — 서버에서 구우면 발행일 날짜가 HTML에 박혀 크롤러가 지난 날을 읽는다. + +## 2026-09-08 — 가짜 발행 제거 · `/s` 정본 주소 · 회사(테넌트) 제거 · 네이버 예약 + +- **가짜 발행**: 사업장이 없으면 서버를 안 부르고 [사이트 열기]를 그렸다(주소는 404). 분기를 지우고 + 발행 불가 사유를 모달 안에서 말한다. +- **`/s` 가 빌더 셸을 200으로 주고 있었다** → nginx `location = /s` + `/s/` 301, `absolute_redirect off`. +- **회사 스코프를 걷어냈다** — 스코프 키는 `places.owner_user_id`, 주인은 토큰이 정한다(body로 받지 않는다). + 워커의 `UserInfo.user_id` 는 사업장 주인이어야 한다(랜덤 uuid면 fact가 0건이 된다). +- **예약 버튼이 검색 화면을 열었다** → 플레이스 응답의 `naverBookingUrl` 을 수집해 쓴다. 주소를 조립하지 않는다. +- 내 사이트 목록에 썸네일·주소·시각. 썸네일 주소에 `?v=<버전>` 을 붙여 재발행하면 그림이 바뀌게 했다. + +## 2026-09-07 — 자산 보관과 두 번의 사고 · 로컬 설정 함정 · 숙박 예약 안내 + +- **옛 해시 자산을 30일 남긴다**(대장 `.builds.json`, mtime 을 쓰지 않는다). 배포와 전체 재굽기를 뗐다. +- **사고 1**: 대장이 없는 첫 실행에서 기존 자산이 전부 "대장에 없음"으로 지워져 운영 CSS가 끊겼다. + → 기록이 없으면 입양한다. "기록이 없다"와 "만료됐다"를 같이 묶지 않는다. + 검증은 빈 디렉토리가 아니라 **배포 직전 서버 모습**으로 재현해야 했다. +- **사고 2**: payload 없는 목업(`stay`·`stay2`·`stay3`)의 자산이 지워져 영영 복구 불가가 됐다. + → HTML이 참조하는 자산은 기간과 무관하게 남긴다(`referencedAssets`). 목업의 존재를 AGENTS.md 맨 위에 적었다. +- 사이트맵 lastmod 를 파일 mtime 에서 뗐다 — 배포마다 전 사이트가 "오늘 갱신"으로 통보되어 구글이 필드를 무시하게 된다. +- `.env.example` 함정: 컨테이너 안의 `DB_HOST=127.0.0.1`(워커만 조용히 재시작), 값 뒤 주석이 값이 됨, + API 기본 주소가 크로스 오리진을 만들어 로그인만 실패. → 같은 오리진 기본값, 주석은 윗줄로. +- 숙박 "실시간 예약" 섹션이 전화번호 한 줄이었다(읽는 fact가 숙박 스키마에 없었다) → "예약 안내"로 이름을 바꾸고 + 요금·인원·규정·창구를 모았다. 예약을 처리하지는 않는다. `availability` 는 넣지 않는다. + +## 2026-09-03 — 레포·호스트 교체 · 랜딩 · 로그인 전 검색 · 썸네일 + +- 레포 `Web4ai/o2o-site-AEO`, 호스트 `web4ai.o2osolution.ai`. + ★ `origin` 은 payload에 구워진다 → 재발행이 필요하다. ★ `init.sql` 은 최초 생성 때만 돈다 — + 기존 DB에 컬럼이 없어 로그인이 죽었는데 HTTP는 200이었다. +- 로그인 전 랜딩·요금(월 70만원 한 플랜)·쇼케이스(진짜 발행본만). 상호 검색을 로그인 앞으로(인증 없음, IP 제한). + 업종은 카카오 카테고리로 정하고 LLM을 부르지 않는다. +- 썸네일은 스크린샷이 아니라 대표 사진이다(헤드리스 브라우저는 영구 금지). + +## 2026-09-02 — 가입·구글 로그인 · 내 사이트 홈 · 아이템 · 템플릿 모양(look) + +- 계정 생성 API가 아예 없었다(손으로 INSERT). ★ `GOOGLE_CLIENT_ID` 는 백엔드·프론트가 같아야 하고 + `aud` 대조가 남의 앱 토큰을 막는다. 같은 이메일이라도 계정을 자동으로 잇지 않는다([DECISIONS 1-5](DECISIONS.md)). +- 로그인한 사장님의 홈(`/sites`·`/account`). 위저드에서 사이드바를 뺐다. 삭제 대신 [발행 내리기]만 둔다. +- 붙여넣기 아이템이 발행본에 **하나도 안 나가고 있었다**(`SectionSetting.data` 계약이 없었다). + 직접 쓴 소개문도 같은 이유로 사라졌다(`body`). 계약에 넣고 고유 콘텐츠로 센다. +- `SiteTheme.look`(서체·모서리·그림자 등)이 발행본까지 가게 했다. 웹폰트는 템플릿이 쓰는 것만. +- 계절 추천은 HTML에 전 계절을 굽고 브라우저에서 지금 계절만 보인다(구운 시점의 계절이 박히지 않게). + +## 2026-09-01 — 설정을 `.env` 하나로 + +toml → `pydantic-settings`. 키마다 손으로 덮던 구조에서 `client_url` 이 빠져 배포 주소 API가 전부 CORS로 막혔다. +★ `.env` 경로가 없는 파일을 보고 있었다. 환경변수 이름은 `validation_alias` 로 못 박는다(`PORT` 를 주워 먹는다). ## 2026-08-31 — 킹서버 최초 배포 -**한 일** -- `~/data2/o2o-web4ai` 에 배포. DB(`web4ai_db`) 생성 + `init.sql` 적용. -- 컴포즈 포트를 전부 `.env` 변수로 뽑았다. 로컬 기본값은 그대로다. -- `deploy.sh` · `log.sh` 추가. - -**왜 포트를 뽑았나** -킹서버는 `:80` 을 호스트 nginx 가 이미 물고 있다. 사내망에 열려 있는 건 30xxx 대역뿐이라 -그 안에서 자리를 잡아야 했다. → [SERVERS.md](SERVERS.md) - -**밟은 함정** -- `PUBLIC_API_BASE_URL` 은 **브라우저가** 부르는 주소다. `localhost` 로 두면 화면은 뜨고 - API 만 죽는다 — 콘솔을 열기 전엔 안 보인다. -- 내부 화면의 "빌더 열기" 가 `VITE_SOLUTION_URL` 미주입으로 죽은 링크였다. 로컬에서는 - 기본값이 맞는 주소라 서버에 올리기 전까지 드러나지 않았다. -- `deploy.sh api` 는 worker·api-admin 도 함께 갈아끼운다. 셋이 이미지 한 벌을 나눠 쓰는데 - 하나만 바꾸면 옛 코드로 도는 컨테이너가 남고, `ps` 로는 셋 다 살아 있어 구분이 안 된다. - -**남은 것** — `w4ai.o2o.kr` DNS + 앞단(59.14.81.3) 포워딩. 서버에 sudo 가 없어 인프라 몫이다. +포트를 전부 `.env` 로 뺐다(`:80` 은 호스트 nginx가 쓴다) → [SERVERS.md](SERVERS.md). +★ `PUBLIC_API_BASE_URL` 은 브라우저가 부르는 주소다. ★ `deploy.sh api` 는 worker·api-admin 도 같이 갈아 끼운다. diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index bd4fd35..7d61075 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -104,8 +104,7 @@ ## 9. 아직 안 정한 것 정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다. -★ **개발 착수 전에 확정해야 할 결정 목록은 -[DEVELOPMENT_DIRECTION.md P0](DEVELOPMENT_DIRECTION.md)** 가 단일 출처다 — 여기 복사하지 않는다. +★ **보류 중인 결정은 [DECISIONS.md](DECISIONS.md) 1절**이 단일 출처다 — 여기 복사하지 않는다. 아래는 그중 **제품 정의**에 해당하는 것만 남긴다. - 사업 성공 지표 (7절) diff --git a/docs/RENDERING.md b/docs/RENDERING.md new file mode 100644 index 0000000..af8e42a --- /dev/null +++ b/docs/RENDERING.md @@ -0,0 +1,157 @@ +# 렌더링 한눈에 보기 + +사이트가 화면에 그려지는 경우는 세 가지다. 셋 다 그리는 코드는 `solution/site/src/App.tsx` 하나이고, +**누가 언제 그리느냐**만 다르다. + +| 경우 | 누가 그리나 | 입력 | +|---|---|---| +| 정적 사이트 | 워커가 미리 구운 HTML → 브라우저가 이어받음 | HTML 안에 심어 둔 payload | +| 미리보기 | 브라우저가 처음부터 | API 가 그때그때 만든 payload | +| 발행 | 워커가 Node 렌더러를 돌려 HTML 을 굽는다 | DB → payload 파일 | + +경로는 레포 루트 기준이다. `site/` 는 `solution/site/`, `backend/` 는 `solution/backend/` 를 줄인 것이다. + +--- + +## 1. 정적 사이트 — 손님·크롤러가 `/s/` 를 받을 때 + +```mermaid +flowchart TD + A["손님 · 크롤러
GET /s/<slug>"] --> B["nginx
location ^~ /s/"] + B --> C["out/s/<slug>
(심볼릭 링크)"] + C --> D["out/versions/<slug>/<ver>/index.html"] + D -->|크롤러는 여기까지| E["HTML · JSON-LD · meta"] + D --> F["브라우저가 /assets/index-해시.js · .css 를 받음"] + F --> G["entry-client.tsx
window.__SITE_PAYLOAD__ 있음"] + G --> H["hydrateRoot(App)
버튼·달력 등 동작이 붙는다"] + D --> I["사진 /s/<slug>/img/*
노래 /s/<slug>/*.mp3"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 요청 받기 | `/s/` 는 구운 파일을 그대로 준다. `/s` 는 목록, `/s/` 는 `/s` 로 301 | `nginx/site.conf.example` (`location = /s`, `location ^~ /s/`) | +| 공개 버전 찾기 | `out/s/` 는 지금 공개 중인 버전 폴더를 가리키는 링크다 | `site/scripts/prerender.ts` `publishVersion` | +| HTML | 본문·``(title·canonical·JSON-LD)·심어 둔 payload 가 한 파일에 있다 | `out/versions///index.html` | +| 번들 | 해시 이름의 JS·CSS. 1년 캐시 | `nginx/site.conf.example` `location ^~ /assets/` → `out/assets/` | +| 이어받기 | 심어 둔 payload 로 같은 화면을 다시 만들어 마크업에 동작을 붙인다 | `site/src/entry-client.tsx` (`hydrateRoot`) | +| 그리기 | 템플릿의 레이아웃을 고르고 섹션을 순서대로 그린다 | `site/src/App.tsx` → `site/src/pages/SectionList.tsx` | + +검색엔진이 읽는 건 구운 HTML 이다. 렌더러를 고쳐도 이미 구운 HTML 은 사장님이 다시 발행하기 전까지 그대로다. + +--- + +## 2. 미리보기 — 빌더 iframe `/preview?placeId=…` + +```mermaid +flowchart TD + A["빌더에서 템플릿·색·섹션 저장
POST …/site/template · …/site/theme"] --> B["onSiteThemeSaved 신호"] + B --> C["SitePreview.tsx
iframe 다시 로드"] + C --> D["GET /preview?placeId=…
nginx location = /preview"] + D --> E["out/preview/index.html
빈 껍데기 + 번들"] + E --> F["entry-client.tsx renderPreview"] + F --> G["GET /v1/place/{id}/site/preview"] + G --> H["SiteService.preview_payload
build_snapshot → prepare_site_payload"] + H --> F + F --> I["themeVars · 폰트 로드"] + I --> J["createRoot(App)"] + J --> K["postMessage o2o:preview-painted"] + K --> L["빌더가 스피너를 걷는다
(12초 상한)"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 다시 그릴 때를 안다 | 저장이 끝나면 iframe 을 새로 고친다. 보던 스크롤 위치는 지킨다 | `solution/frontend/src/features/builder/SitePreview.tsx`, `solution/frontend/src/features/publish/siteTheme.ts` `onSiteThemeSaved` | +| 껍데기 받기 | 본문이 빈 HTML. `noindex` 가 붙어 있다 | `nginx/site.conf.example` `location = /preview` → `out/preview/index.html` (`prerender.ts` `writePreviewShell`) | +| payload 받기 | 로그인 토큰을 붙여 API 를 부른다 | `site/src/entry-client.tsx` `renderPreview` | +| payload 만들기 | 발행과 같은 함수로 만든다. 버전도 파일도 만들지 않는다 | `backend/router/v1/site/site.py` `site_preview` → `backend/services/site_service.py` `preview_payload` → `services/snapshot.py` `build_snapshot` → `services/site_payload.py` `prepare_site_payload` | +| 템플릿 확인 | 모르는 id 면 API 가 422, 화면은 에러 문구로 멈춘다 | `backend/common/template_catalog.py`, `solution/shared/src/lib/catalog.ts` `templateOf` | +| 그리기 | 색 변수·폰트를 먼저 넣고 처음부터 그린다 | `entry-client.tsx` (`themeVars`, `fontHref` ← `site/src/seo/head.ts`), `App.tsx` | +| 완료 알림 | 두 프레임 뒤 부모 창에 알린다. 빌더는 출처와 iframe 을 확인한다 | `entry-client.tsx` `signalPreviewPainted`, `SitePreview.tsx` `PAINT_TIMEOUT_MS` | + +미리보기는 사진을 내려받지 않는다. 원래 주소를 그대로 쓴다. + +--- + +## 3. 발행 — 무엇을 읽고 무엇을 쓰나 + +```mermaid +flowchart TD + A["사장님 '발행하기'
POST /v1/place/{id}/site/build"] --> B["SiteService.start_build
jobs 에 BUILD"] + B --> C["워커 worker/handlers.py
build_service.run_build"] + C --> D["build_snapshot
DB 값 모으기 · site_versions 행 추가"] + D --> E{"1차 게이트
상호·업종·사실 확인 · 템플릿 id"} + E -->|실패| X["버전 FAILED · 발행 로그"] + E --> F["emit_payload
payloads/<slug>.json"] + F --> G["render_service.render_site
node prerender.js --stage-only"] + G --> H["mirrorMedia → prerenderSite
out/versions/<slug>/<ver>/"] + H --> I["보고서
payloads/.status/<slug>.json"] + I --> J{"2차 게이트
publish_gate.evaluate"} + J -->|실패| X + J --> K["render_service.activate_site
node prerender.js --activate=slug:ver"] + K --> L["out/s/<slug> 링크 전환
루트 sitemap · robots · llms 갱신"] + L --> M["Azure 업로드 · 썸네일 · IndexNow"] + M --> N["DB 기록
버전 BUILT · sites PUBLISHED · 발행 로그"] +``` + +| 단계 | 하는 일 | 파일 | +|---|---|---| +| 잡 넣기 | 검증 안 된 사업장은 막는다. 같은 사업장 BUILD 는 겹치지 않는다 | `backend/router/v1/site/site.py` `start_build` → `services/site_service.py` `start_build` | +| 잡 집기 | BUILD 잡을 `run_build` 로 넘긴다 | `backend/worker/handlers.py` | +| 스냅샷 | DB 값을 한 벌로 모아 `site_versions.snapshot` 에 박제한다 | `services/snapshot.py` `build_snapshot`, `services/build_service.py` `run_build` | +| 1차 게이트 | 상호명·업종·사실 확인 여부, 템플릿 id | `services/publish_gate.py` `check_facts_verified`, `common/template_catalog.py` `resolve_template_id` | +| payload | JSON 으로 쓴다. 임시 파일에 쓰고 이름을 바꾼다 | `services/site_payload.py` `emit_payload` → `write_payload` | +| 굽기 | Node 를 직접 실행한다. 파일 잠금으로 한 번에 하나만 돈다 | `services/render_service.py` `render_site` (`.render.lock`) | +| 렌더 | 공개 금지 값 걸러내기 → 사진 내려받기 → HTML·JSON-LD·llms.txt → 대조 | `site/scripts/prerender.ts` `sanitizePayloadForPublish` · `mirrorMedia` · `prerenderSite` · `verifyJsonLd` | +| 2차 게이트 | 보고서의 대조 결과·고유 콘텐츠 건수로 판정 | `services/publish_gate.py` `evaluate`, `services/render_report.py` | +| 공개 전환 | 검증된 버전인지 보고서로 다시 보고 링크를 바꾼다 | `render_service.activate_site` → `prerender.ts` `publishVersion` · `writeRootMachineFiles` | +| 바깥 알리기 | 설정된 경우만 돈다 | `services/azure_static.py` `publish`, `services/site_thumbnail.py` `store`, `services/indexnow.py` `submit` | +| DB 기록 | 버전·사이트·사업장 상태와 발행 로그를 남긴다 | `services/build_service.py` `run_build` · `_log` | + +### 입력 + +| 무엇 | 어디서 | 읽는 쪽 | +|---|---|---| +| DB 값 | `place_facts` `place_units` `place_faqs` `place_photos` `place_songs` `place_posts` `place_reviews` `place_social_posts` `area_contents` `site_sections` `sites` | `services/snapshot.py` `build_snapshot` | +| 템플릿 목록 | `solution/shared/src/data/templates.json` | 백엔드 `common/template_catalog.py`, 렌더러 `shared/src/lib/catalog.ts` | +| payload | `site/payloads/.json` (`SITE_PAYLOAD_DIR`) | `prerender.ts` `loadOne` — `schemaVersion` 1 · 슬러그 · 버전을 본다 | +| 번들 목록 | `site/dist/client/.vite/manifest.json` | `prerender.ts` `readAssets` — 엔트리 JS·CSS 파일명 | +| 번들 파일 | `site/dist/client/assets/`, `site/public/fonts/` | `prerender.ts` `writeSharedAssets` | +| 사진 | `payload.media[].url` 이 가리키는 바깥 주소 | `prerender.ts` `mirrorMedia` (15초 · 8MB) | +| 노래 | `site/songs/*.mp3` | `prerender.ts` `copySongs` | + +### 출력 + +| 무엇 | 어디에 | 누가 쓰나 | +|---|---|---| +| payload | `site/payloads/.json` | `site_payload.py` `write_payload` | +| 렌더 보고서 | `site/payloads/.status/.json` | `prerender.ts` `writeReport` (백엔드가 `render_report.py` 로 읽는다) | +| HTML | `out/versions///index.html` — JSON-LD 는 따로 파일이 없고 `` 안 `