런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 — 새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다. ★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고 userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest) + 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다 (401 은 "여기 뭔가 있다" 를 알려 준다). - router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로 - services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인 - 0022: owner_kakao_links 에 current_place_id · pending_* 빌더 화면과 다른 것 셋: - 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다 - place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다 - 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 돈다 5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 — 메신저에서는 500 도 침묵으로 보인다. 밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)). 우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜 화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다. test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일). npm run lint 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1683 lines
130 KiB
Markdown
1683 lines
130 KiB
Markdown
# 개발 일지
|
||
|
||
## 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일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는
|
||
버튼을 누르면 시작·끝일을 `<input type="date">` 두 개로 받는 다이얼로그가 뜬다.
|
||
- 기존 `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/<slug>/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)에 이 가게 프로필을 보내 검색 키워드를
|
||
받고, 거른 결과를 `<meta name="keywords">` 와 제목 업종어 자리에 싣는다.
|
||
```
|
||
스냅샷 → 프로필(확인된 fact · 주소 · 발행되는 주변 관광지)
|
||
→ POST /v1/merchants/publish (generate:false) → POST /v1/match (query=place_id)
|
||
→ 거르기 → snapshot["seo"] → payload.seo
|
||
→ <title>스테이,머뭄 · 군산 독채펜션</title> · <meta name="keywords" content="군산 펜션 독채, …">
|
||
```
|
||
|
||
**★ 거르기가 필요한 이유 (실측)** — 스테이머뭄 프로필로 받은 추천 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/<slug>/<song_id>.mp3`)만 발행본에 내보낸다.
|
||
|
||
**★ 콜백이 아니라 폴링이다**
|
||
Suno 는 `callBackUrl` 로 완료를 알려 주는데, 그러려면 Suno 가 우리 백엔드에 닿아야 한다.
|
||
이 서버는 로컬(:9800)이거나 사내망이라 그런 주소가 없다 — 콜백을 믿게 만들어 두면
|
||
"요청은 성공했는데 결과가 영영 안 옴" 이 되고, 화면상 아무 일도 안 일어나는 실패다.
|
||
(API 가 필수로 요구해서 값은 채워 보내되, 그 주소를 듣지 않는다.)
|
||
|
||
**경계는 그대로다** — 백엔드는 여전히 발행물 디렉토리를 모른다. payload 와 같은 약속으로
|
||
`out/songs/<song_id>.mp3` 에 떨구고, 굽는 쪽인 프리렌더가 `out/s/<slug>/` 로 복사한다.
|
||
프리렌더는 복사하면서 **지난 발행의 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/<slug>` 에서 데모 껍데기와 안내 문구 확인.
|
||
|
||
---
|
||
|
||
## 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/<slug>`)는 이미 슬래시가 없었고, 붙은 건 호스트 루트(`/`)와 목록 페이지(`/s/`) 둘뿐이다.
|
||
목록만 형태가 다른 이유는 nginx 였다 — `location ^~ /s/` 는 **슬래시로 시작하는 것만** 잡고,
|
||
`/s` 는 맨 아래 `location /` 로 떨어진다.
|
||
|
||
**그런데 그게 404 가 아니었다.** `/s` 는 200 을 주고 있었고 내용이 **빌더 SPA 셸**이다
|
||
(실측: `/s` 3.1KB `<title>Web4Ai</title>` · `/s/` 6.7KB 목록). 크롤러 입장에서는 404 도
|
||
목록도 아닌 세 번째 페이지가 오리진에 하나 더 있는 셈이었다.
|
||
|
||
**바꾼 것**
|
||
- `nginx/site.conf(.example)`: `location = /s` 로 목록 index.html 을 직접 준다. `/s/` 는
|
||
거기로 301. `^~ /s/` 의 `index index.html` 은 남긴다 — `/s/<slug>/` 가 그걸로 열린다
|
||
- `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/<slug>/` 는 여전히 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/<slug>.<ext>` 로 고정이고 내용만 `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/<slug>` 전부 200. 그리고 →
|
||
`scripts/demo_build.py` 로 발행: 게이트 통과 · `published: true` · 프리렌더가 굽고
|
||
`http://localhost/s/<slug>` 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 상한) `<prefix>/thumbs/<slug>.<ext>` 로 올린다. 기존
|
||
`AZURE_STORAGE_CONNECTION_STRING` 을 그대로 쓴다 — 새 자격증명 체계를 들이지 않았다.
|
||
★ 사이트 경로(`s/<slug>/`) 안에 두지 않는다: `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=<uuid>` 를 기억하는 것이었다.
|
||
|
||
아임웹을 보면 계층이 둘로 갈려 있다 — **계정 레벨**(내사이트 목록 · 마이페이지)과
|
||
**사이트 레벨**(그 사이트의 관리자 페이지 · 디자인모드). 우리 에디터가 그 사이트 레벨이므로
|
||
비어 있던 것은 계정 레벨이다. 그리고 아임웹도 **사이트 개설 흐름에는 계정 사이드바를 붙이지
|
||
않는다** — 아직 사이트가 아닌 것에 사이트 메뉴를 얹을 수 없어서다.
|
||
|
||
**한 일**
|
||
- `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): `<head>` 에 `--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)는 **아이템을 실제로 쓰는 사이트에만** `<head>` 로 내려보낸다. 서체 하나가
|
||
모든 발행 사이트의 첫 렌더를 늦출 이유가 없다.
|
||
|
||
**안 한 것**
|
||
레트로 템플릿 시드(`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_<sub>` 가 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)
|
||
|
||
---
|
||
|
||
## 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 가 없어 인프라 몫이다.
|