+
+ );
+}
diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md
index 16dd3b8..0a591d0 100644
--- a/docs/DEVLOG.md
+++ b/docs/DEVLOG.md
@@ -1,5 +1,228 @@
# 개발 일지
+## 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 예약값, 실제 채팅 아님)로 박혀 있어
diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md
new file mode 100644
index 0000000..abbb35e
--- /dev/null
+++ b/docs/MINI_BLOG.md
@@ -0,0 +1,242 @@
+# 미니 블로그 — AI 자동 포스트 생성기 (2026-09-16 기획, 2026-09-17 검수 흐름 개편)
+
+숙소 소개 아래에 붙는 짧은 글 게시판. 사장님에게 최종 결정권이 있다 — **팀 사전검수 단계는
+없다.** 메일 링크는 여전히 로그인 없이 쓰고, 대신 빌더 앱에 로그인하면 이번 달 생성된 글
+전체를 볼 수 있다.
+
+```
+스케줄러(한 달치 생성) → 금칙 필터(자동) → 메일 발송(업장당 하루 한 통, 승인·수정 두 링크)
+ → 사장님이 승인(즉시 게재) / 수정(빌더 앱 자동 로그인 모달) → 재발행 → 정적 HTML에 글 추가
+ ※ 두 링크 다 그날 자정(KST) 만료 — 그 뒤엔 로그인해서 빌더 앱에서 처리
+
+(병행) 빌더 앱 로그인 → 블로그 글 화면(탭: 이번 주 · 달력 · 생성 이력) → 언제든 수정·승인
+```
+
+## 확정된 것
+
+- 스테이 DB의 숙소 정보로 **140~150자** 홍보 문구를 AI가 만든다 (2026-09-16)
+- **텍스트만.** 사진은 넣지 않는다 (2026-09-16)
+- 숙소 소개 하단 **미니 블로그** 형식, 글이 쌓이면 **페이지 번호**로 넘긴다 (2026-09-16)
+- 갈래를 나눠 생성하고 **이전에 다룬 주제와 중복되지 않게** 한다 (2026-09-16)
+- ★ **팀 사전검수 폐지** — 검수는 사장님이 한다. 금칙 필터(자동)를 통과하면 바로 발송
+ 대상이다 (2026-09-17)
+- ★ **한 달치를 미리 쌓아 두고, 업장당 하루 한 통씩** 메일로 내보낸다 (2026-09-17)
+- ★ 메일의 **승인** 링크는 로그인 없음(토큰이 신원) — 누르는 즉시 승인된다(2026-09-17,
+ 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). **수정** 링크는 반대로
+ 로그인 흐름이다 — 그날짜리 자동 로그인 토큰을 실어 보내 빌더 앱의 편집 모달을 그대로
+ 연다(2026-09-17, 사장님 지시: "수정하기는 해당 수정하기 페이지로 가게(모달) 로그인도
+ 크레덴셜로 자동으로 되게"). **두 링크 다 그날 자정(KST) 만료**(2026-09-17, 사장님 지시:
+ "승인이랑 수정모두 자정에 만료") — 넘기면 로그인해서 빌더 앱에서 처리한다
+- ★ 사장님이 문구를 **직접 고쳐서** 승인할 수 있다 — 메일의 수정 링크, 빌더 앱에서도 동일
+ (2026-09-17)
+- ★ 글마다 **배정일(scheduled_date)** 이 있다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가
+ 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). 생성 시 그 업장의 다음
+ 빈 날부터 하루 한 건씩 순서대로 배정한다
+
+## 1. 데이터 — 표 하나
+
+`postgres-init/init-data/init.sql` 과 `postgres-init/migrations/` **둘 다** 고친다.
+
+| 칸 | 타입 | 무엇 |
+|---|---|---|
+| `post_id` | uuid pk | |
+| `place_id` | uuid | 어느 업장 |
+| `body` | varchar(400) | 본문 140~150자 |
+| `topic_kind` | smallint | weather · festival · season · nearby · guide |
+| `topic_key` | varchar(120) | 축제 id · 절기 · 장소 id — **중복 방지의 축** |
+| `status` | smallint | DRAFT → REVIEWED → SENT → APPROVED → PUBLISHED / SKIPPED |
+| `scheduled_date` | date | 이 업장 몫 배정일(KST). 하루 한 통 — 생성 시 순서대로 채운다 (2026-09-17) |
+| `generation_meta` | jsonb | 생성 이력 상세 — 지금은 `{"model": "..."}` 하나뿐(사장님 지시: "생성이력도 상세하게
+ 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나팟거 컬럼", 새 컬럼을 안 늘리고 여기 얹는다, 2026-09-17) |
+| `approve_token_hash` | varchar(64) | sha256. 평문은 메일에만 |
+| `token_expires_at` | timestamptz | 발송 당일 자정(KST) — 수정 링크(day-pass)도 동일(2026-09-17, 이전엔 발송+14일) |
+| `sent_at` · `approved_at` · `published_at` | timestamptz | |
+| `published_version_id` | uuid | `site_versions` 참조 — 롤백 때 필요 |
+
+유니크: `(place_id, topic_key)` — 같은 축제로 두 번 쓰지 않는다.
+유니크: `(place_id, scheduled_date)` — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
+
+## 2. 생성 — 스케줄러 잡
+
+`scheduler/__init__.py` 에 `add_job` 한 줄, 로직은 `scheduler/jobs.py` → `services/blog_service.py`.
+
+- **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다)
+- **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 —
+ 하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다
+- **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다
+ (한 달치를 한 호출로 뽑으면 중복 검사가 안 된다)
+- **배정일**: 그 업장의 `MAX(scheduled_date)` 다음날부터(없으면 오늘부터, KST) 하루 한 건씩
+ 순서대로(`blog_jobs._next_scheduled_date`가 아니라 인라인 계산 — `_generate_for_place`).
+ "지금 생성하기"(수동 트리거)는 사장님이 직접 고른 구간을 채운다 — 같은 소재 선별·게이트
+ 로직을 재사용하지만 배정일이 "다음날부터 자동"이 아니라 "그 구간"이다(`generate_range`,
+ 7절)
+- **갈래 분기**: 날씨·축제·계절·주변장소·이용안내. 갈래마다 프롬프트가 다르고,
+ 근거가 되는 값도 다르다(날씨=`local.weather`, 축제=`local.festivals`, 주변=`local.attractions`)
+- 프롬프트는 `shared/src/lib/section-prompts.ts` 규약을 따른다 → `npm run export:prompts`
+
+### 게이트를 통과하는 문구만 만든다 — 팀 검수를 대신하는 자리
+
+발행 게이트 규칙 1은 **미검증 fact 를 화면에 내지 않는 것**이다(`services/publish_gate.py`).
+홍보 문구가 가격·시설·운영시간을 주장하면 그 주장을 뒷받침할 fact 가 없어 규칙과 부딪힌다.
+★ 팀 사전검수가 없어진 지금, 이 필터가 유일한 자동 관문이다.
+
+- 프롬프트에 금칙을 건다: 숫자로 된 가격·시간·인원·전화번호를 쓰지 않는다
+- 생성 뒤 기계로 한 번 더 거른다(`blog_service.is_publishable_body`) — 통과하면 곧장
+ `REVIEWED` 로 쌓인다(사람이 올릴 필요 없음). 실패분은 로그만 남고 버려진다
+- 통과한 문구는 **고유 콘텐츠**라 오히려 규칙 2(고유 콘텐츠 ≥ 1)에 보탬이 된다
+- 수정 화면(메일·빌더 앱 공통)에서 사장님이 고친 본문도 저장 전에 **같은 필터**를 다시 탄다 —
+ 로그인했다고 우회되지 않는다
+
+## 3. 검수 — 사장님이 한다 (2026-09-17, 팀 사전검수 폐지)
+
+★ `admin`(:9801)의 1차 검수 화면(`blog_admin.py`·`BlogReviewPage`)은 삭제했다. 최종 판단은
+사장님 몫이고, 그 판단은 두 군데서 이뤄진다.
+
+1. **메일** — 업장당 하루 한 통, 승인/수정/넘기기 (4·5절)
+2. **빌더 앱 로그인** — 이번 달 생성된 글 전체를 미리 보고 메일이 오기 전에 바로
+ 승인·수정할 수 있다 (7절 "빌더 앱 화면")
+
+## 4. 발송 — 메일
+
+`services/mail_service.py`(2026-09-16 완성, ACS 우선 · SMTP 폴백)를 그대로 쓴다.
+
+- **업장당 하루 한 통.** `PostCRUD.due_for_mail` 이 `scheduled_date <= 오늘` 이면서
+ `DISTINCT ON (place_id)` 로 업장 하나가 밀려 있어도 그날은 가장 이른 배정일 한 통만
+ 고른다(`blog_jobs.send_reviewed`) — 미래 배정일 글은 그날이 오기 전엔 안 나간다
+- 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`)
+- **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는
+ 즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔
+ sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`)
+- **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다.
+ `CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근
+ 토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다
+ (`solution/frontend/src/app/provider.tsx` 세션 복구 단계에서 처리 — `BlogPostsPage` 안이
+ 아니라 라우트 가드보다 먼저인 지점이어야 한다, 2026-09-17 실측: 늦게 처리하면
+ `RequireAuth` 가 이미 `/login` 으로 튕긴 뒤였다)
+- 메일은 평문으로 흐른다 → 승인 링크로 할 수 있는 일은 **그 글 한 건의 게재**뿐이고,
+ 수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
+ 까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가
+ `place_id` 불일치를 걸러낸다)
+
+## 5. 승인·수정
+
+- **승인**: GET 이 로그인 없이 즉시 승인한다 → `status = APPROVED` → BUILD 잡 큐. 만료·
+ 재사용은 "처리할 수 없는 링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
+- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문 갱신 +
+ 그대로 APPROVED 전환 + BUILD 잡. 실패하면 사유를 보여주고 다시 고치게 한다(사이트에는
+ 안 올라간다)
+- ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
+ 가 `payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
+ 세션과 달라 `post_service.PostService._enqueue_build` 가 `place_id` 로 직접 조회해
+ 채운다. 이게 빠져 있던 게 2026-09-17 발견된 버그였다(회귀 테스트: `test_blog_post.py
+ test_approve_enqueues_build_with_owner_user_id`)
+
+## 6. 게재 — 재발행
+
+`docs/PUBLISH_VERSION.md` 의 파이프라인을 그대로 탄다. payload 에 `posts[]` 를 실어
+**그 사이트 하나만** 다시 굽고 새 버전으로 링크를 전환한다. 전체 재굽기가 아니다.
+
+⚠️ **발행일(`publishedAt`)이 움직인다.** 글 한 건 때문에 사이트 갱신일이 바뀌는 것이
+맞는지 합의가 필요하다 — 색인에는 유리하지만 "사장님이 발행한 적 없는데 날짜가 바뀐다"는
+기존 원칙과 부딪힌다.
+
+## 7. 화면
+
+### 발행된 사이트 — 미니 블로그
+
+`solution/site/src/sections/BlogSection.tsx`, 숙소 소개(`intro`) 바로 아래.
+
+- **글 전부가 HTML 안에 있고 JS 가 10건씩 보여준다.** 페이지를 눌렀을 때 더 불러오지 않는다 —
+ 크롤러는 2페이지를 못 본다
+- 사이트 하나 = 한 장 규칙은 유지한다. 주소를 늘리지 않는다
+- 글이 100건을 넘으면 그때 별도 주소를 다시 논의한다
+- 군산 읽기 전체 노출도 같은 페이지네이션을 쓴다 — 컴포넌트를 한 벌만 만든다
+
+### 빌더 앱 — 이번 달 생성된 글 (2026-09-17)
+
+`solution/frontend/src/pages/BlogPostsPage.tsx`. "내 사이트" 카드의 **관리 메뉴 →
+미니블로그 관리**에서 `?placeId=` 를 들고 들어온다(전역 메뉴 하나로는 어느 사이트인지
+못 고른다 — 사장님 한 명이 사이트 여럿을 가질 수 있다).
+
+- 백엔드: `GET/PUT /v1/place/{place_id}/post`(`router/v1/site/post.py` `owner_router`,
+ :9800). 로그인 세션(`IsValidAccessToken`)이 신원이고, `PlaceCRUD.get_place` 로 소유권을
+ 매번 확인한다 — 토큰 흐름과 인증 방식이 다를 뿐 편집 가드(`is_publishable_body`)는 같다
+- **아직 메일이 안 나간 `REVIEWED` 글도 여기서 바로 승인·수정할 수 있다** —
+ `PostCRUD._EDITABLE = (SENT, REVIEWED)`. 메일을 기다릴 필요가 없다
+- 월 단위 조회(`month=YYYY-MM`, 기본 이번 달, KST 기준) — `scheduled_date` 기준으로 그 달에
+ 배정된 글을 가져온다
+- **화면은 탭 둘뿐이다** (2026-09-17, 사장님 지시: "탭을 왜 이번주 달력 이렇게 나누고
+ 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지" — 카로셀·달력은
+ 같은 화면에 **항상 같이** 뜬다, "생성 이력"만 별도 탭이다)
+ 1. **블로그(카로셀 + 달력, 항상 같이 보인다).**
+ - **카로셀** — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
+ 단위)과 무관하게 **항상 오늘부터 7일치**(`GET .../post/upcoming?days=7`,
+ `PostService.list_upcoming`, 날짜 오름차순). 카드가 겹쳐 쌓여 있고 가로로 넘기면
+ 하나씩 앞으로 나온다(`PostCarousel`). 마우스를 올린 카드는 안 가려지게 z-index 를
+ 맨 앞으로 올린다. 카드를 누르면 그 자리에서 고치는 게 아니라 **모달**을 연다(사장님
+ 지시: "카드클릭해도 모달나와서 수정가능하게 해야지 왜 바로수정하게해") — 카드 자체는
+ 미리보기(`PostPreviewCard`)뿐이고, 수정·바로 발행은 모달 안(`PostCard`)에서만 한다.
+ 카드마다 배정일을 전부 쓰고, 오늘·내일인 카드에는 그 위에 "오늘"/"내일" chip 을 더 단다
+ - **달력** — **이전 달 · 월 · 다음 달** 이 달력 바로 위에 있다(사장님 지시). 이번 달부터
+ 1년 뒤까지만 넘겨볼 수 있다(그 전·그 뒤는 볼 이유가 없다). 글이 0건이어도 칸은 항상
+ 뜬다 — 배정일이 없으면 "이 달에 뭐가 있나"를 훑어볼 기준 자체가 없다. 칸마다 본문
+ 앞부분 스니펫과 **발행완료 · 발행실패 · 발송완료 배지만** 보여준다 — 검수 대기처럼
+ 아직 메일도 안 나간 상태는 아무 표시도 하지 않는다(사장님 지시: "발행전인건 표시하지
+ 말고"), 메일 발송 여부는 크론잡이 실제로 돌았다는 확인이라 따로 보여준다(사장님 지시:
+ "달력에 발송완료 된거는 되었다고 적으라고"). **칸을 누르면 모달**로 그 글 전체 내용과
+ 편집·발행 버튼을 보여준다
+ - **빈 날짜(오늘 이후만) 개별 생성** (2026-09-17, 사장님 지시: "그리고 개별적으로 새로
+ 만들수있게 해줘") — 글이 없는 칸을 누르면 `POST .../post/generate-one?date=`
+ (`PostService.generate_for_date` → `blog_jobs.generate_one_for_date`)가 그 날짜 하나만
+ 채운다. 재고 상한(`REFILL_BELOW`)을 안 본다 — 콕 집은 요청이라 상한이 끼어들 자리가
+ 아니다. 이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.
+ 지난 날짜는 만들 이유가 없어 클릭 자체를 막는다. 성공하면 그 자리에서 모달이 열린다
+ 2. **생성 이력.** 언제 몇 건, 어느 모델로 만들었는지(사장님 지시: "생성이력도 있어야해
+ 몇개 생성했는지" / "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등") —
+ `GET .../post/history`(`PostCRUD.generation_batches`). 새 컬럼 없이 기존 `created_at`
+ 으로 회차를 묶는다(같은 트랜잭션 안의 `add_many` 는 DB `now()` 가 전부 같다). 모델명은
+ `generation_meta->>'model'` 의 대표값(`MAX`) 하나 — 한 회차 = 한 모델이 정상이다
+- **발행실패 판정**: `PostService._latest_build_failed` — 그 업장의 가장 최근 BUILD 잡이
+ `JobStatus.DEAD`(재시도 소진)면, APPROVED 인데 아직 안 나간 글에 `build_failed=true` 를
+ 단다. 글 단위가 아니라 "이 업장 재발행이 지금 막혀 있나" 를 보는 것이다 — BUILD 잡 하나가
+ 그 업장의 승인분 전부를 한 번에 굽기 때문
+- ⚠️ **`scheduled_date` 마이그레이션(0019) 전에 만들어진 글은 그 컬럼이 비어 있다.**
+ 월별·주간 조회 둘 다 `scheduled_date` 로 거르므로, 비어 있으면 화면 어디에도 안 뜬다
+ (실측 2026-09-17: "지금 생성하기"로 만든 실제 글 13건이 이렇게 사라져 보였다). 배포
+ 직후 한 번은 기존 NULL 행에 날짜를 채우는 백필이 필요하다 — 업장별로 `created_at` 순서를
+ 살려 오늘부터 하루씩 순서대로 채운다(1회성, 스크립트로 남기지 않았다).
+- **지금 생성하기** 버튼 — `POST /v1/place/{place_id}/post/generate?start=&end=`(사장님 지시:
+ "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을
+ 캘린더 입력(``)으로 고르는 다이얼로그가 뜬다(사장님 지시: "캘린더
+ UI로 날짜받게"). 재고 상한(`REFILL_BELOW`)을 안 본다 — 개별 생성과 같은 이유로, 직접
+ 고른 구간에 상한 로직이 끼어들 자리가 아니다(`blog_jobs.generate_range`). 이미 글이 있는
+ 날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
+ `requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
+ 보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
+ 0건이다
+
+## 8. 진행 (2026-09-17)
+
+| | 자리 | 상태 |
+|---|---|---|
+| 표 + 마이그레이션 | `migrations/0017_place_posts.sql` · `init.sql` | 완료 |
+| 생성 + 금칙 필터 | `services/blog_service.py` | 완료 |
+| 생성·발송 스윕 | `services/blog_jobs.py` · `scheduler/jobs.py` | 완료 (새벽 4:10 생성 · 아침 9:00 발송, 업장당 하루 한 통) |
+| ~~어드민 검수~~ | ~~`router/v1/site/blog_admin.py`~~ | **폐지(2026-09-17)** — 검수는 사장님이 한다 |
+| 메일 + 승인·수정 | `services/mail_service.py` · `services/post_service.py` · `router/v1/site/post.py` | 완료 |
+| 빌더 앱 로그인 화면(달력) | `router/v1/site/post.py owner_router` · `site/pages/BlogPostsPage.tsx` | 완료 |
+| 배정일(scheduled_date) | `migrations/0019_*.sql` · `blog_jobs._generate_for_place` | 완료 |
+| 생성 이력 상세(모델명, generation_meta) | `migrations/0020_*.sql` · `blog_service.generate_one` | 완료 |
+| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
+| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
+| 재발행 연결 | `build_service` → `mark_published`, `owner_user_id` 버그 수정 | 완료 |
+
+남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
+그리고 6절의 발행일 갱신 합의.
+
+## 안 하는 것
+
+- 사진 첨부 (2026-09-16 회의 확정)
+- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
+- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
+ 대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다
diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql
index 7f7b75b..a3f49ee 100644
--- a/postgres-init/init-data/init.sql
+++ b/postgres-init/init-data/init.sql
@@ -356,6 +356,40 @@ CREATE TABLE IF NOT EXISTS public.alert_outbox (
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
+CREATE TABLE IF NOT EXISTS public.place_posts (
+ post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ place_id uuid NOT NULL,
+ body VARCHAR(400) NOT NULL, -- 본문 140~150자
+ topic_kind SMALLINT NOT NULL, -- PostTopicKind: 1=weather 2=festival 3=season 4=nearby 5=guide
+ topic_key VARCHAR(120) NOT NULL, -- 축제 id · 절기 · 장소 id — 중복 방지의 축
+ status SMALLINT NOT NULL DEFAULT 1, -- PostStatus: 1=draft 2=reviewed 3=sent 4=approved 5=published 6=skipped
+ scheduled_date DATE NULL, -- 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다
+ generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다
+ approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만
+ token_expires_at TIMESTAMPTZ NULL,
+ sent_at TIMESTAMPTZ NULL,
+ approved_at TIMESTAMPTZ NULL,
+ published_at TIMESTAMPTZ NULL,
+ published_version_id uuid NULL, -- site_versions.site_version_id — 롤백 때 필요
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+CREATE TABLE IF NOT EXISTS public.place_reviews (
+ review_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ place_id uuid NOT NULL,
+ body VARCHAR(1000) NOT NULL,
+ nickname VARCHAR(40) NULL, -- 표시 이름. 비면 '손님'
+ status SMALLINT NOT NULL DEFAULT 1, -- ReviewStatus: 1=pending 2=published 3=rejected
+ submitted_ip_hash VARCHAR(64) NULL, -- sha256(ip+소금). 원문 IP 는 남기지 않는다
+ published_at TIMESTAMPTZ NULL,
+ published_version_id uuid NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1
@@ -513,6 +547,29 @@ CREATE INDEX IF NOT EXISTS ix_jobs_lease ON public.jobs (status, lease_until);
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key)
WHERE status IN (1, 2) AND dedupe_key IS NOT NULL;
+-- place_reviews (이용 후기)
+CREATE INDEX IF NOT EXISTS ix_place_reviews_published
+ ON public.place_reviews (place_id, published_at DESC) WHERE deleted = FALSE AND status = 2;
+CREATE INDEX IF NOT EXISTS ix_place_reviews_status
+ ON public.place_reviews (status, created_at) WHERE deleted = FALSE;
+
+-- place_posts (미니 블로그)
+-- 같은 업장에 같은 주제를 두 번 만들지 않는다.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_topic
+ ON public.place_posts (place_id, topic_key) WHERE deleted = FALSE;
+-- 하루 한 통 배정 — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
+CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_scheduled_date
+ ON public.place_posts (place_id, scheduled_date) WHERE deleted = FALSE AND scheduled_date IS NOT NULL;
+-- 화면이 읽는 경로: 그 업장의 게재된 글을 최신순.
+CREATE INDEX IF NOT EXISTS ix_place_posts_published
+ ON public.place_posts (place_id, published_at DESC) WHERE deleted = FALSE AND status = 5;
+-- 운영 경로: 검수 대기·발송 대기 목록.
+CREATE INDEX IF NOT EXISTS ix_place_posts_status
+ ON public.place_posts (status, created_at) WHERE deleted = FALSE;
+-- 승인 링크가 토큰 해시로 글을 찾는다.
+CREATE INDEX IF NOT EXISTS ix_place_posts_token
+ ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
+
-- alert_outbox
-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것.
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);
diff --git a/postgres-init/migrations/0017_place_posts.sql b/postgres-init/migrations/0017_place_posts.sql
new file mode 100644
index 0000000..d2dcc29
--- /dev/null
+++ b/postgres-init/migrations/0017_place_posts.sql
@@ -0,0 +1,42 @@
+-- 0017 · place_posts — 미니 블로그(AI 자동 포스트). 기획: docs/MINI_BLOG.md
+--
+-- ★ 한 표로 끝내는 이유 — 글의 일생이 "만들어짐 → 검수 → 발송 → 승인 → 게재" 한 줄이라
+-- 상태 컬럼 하나면 어디서 멈췄는지가 보인다. 발송함(alert_outbox)을 따로 두지 않는 것도
+-- 같은 이유다: 이 글을 몇 시에 누구에게 보냈는지가 글 자체의 속성이다.
+-- ★ 승인 토큰은 해시만 둔다. 평문은 메일 본문에만 있고 DB 가 새도 링크는 못 쓴다.
+-- ★ (place_id, topic_key) 유니크가 "같은 축제로 두 번 쓰지 않는다"를 DB 수준에서 강제한다 —
+-- 프롬프트에만 맡기면 회차가 갈릴 때 같은 주제가 다시 나온다.
+
+CREATE TABLE IF NOT EXISTS public.place_posts (
+ post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ place_id uuid NOT NULL,
+ body VARCHAR(400) NOT NULL, -- 본문 140~150자
+ topic_kind SMALLINT NOT NULL, -- PostTopicKind: 1=weather 2=festival 3=season 4=nearby 5=guide
+ topic_key VARCHAR(120) NOT NULL, -- 축제 id · 절기 · 장소 id — 중복 방지의 축
+ status SMALLINT NOT NULL DEFAULT 1, -- PostStatus: 1=draft 2=reviewed 3=sent 4=approved 5=published 6=skipped
+ approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일에만
+ token_expires_at TIMESTAMPTZ NULL,
+ sent_at TIMESTAMPTZ NULL,
+ approved_at TIMESTAMPTZ NULL,
+ published_at TIMESTAMPTZ NULL,
+ published_version_id uuid NULL, -- site_versions.site_version_id — 롤백 때 필요
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 같은 업장에 같은 주제를 두 번 만들지 않는다. 지운 글은 비켜 준다(재생성 허용).
+CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_topic
+ ON public.place_posts (place_id, topic_key) WHERE deleted = FALSE;
+
+-- 화면이 읽는 경로: 그 업장의 게재된 글을 최신순.
+CREATE INDEX IF NOT EXISTS ix_place_posts_published
+ ON public.place_posts (place_id, published_at DESC) WHERE deleted = FALSE AND status = 5;
+
+-- 운영 경로: 검수 대기·발송 대기 목록.
+CREATE INDEX IF NOT EXISTS ix_place_posts_status
+ ON public.place_posts (status, created_at) WHERE deleted = FALSE;
+
+-- 승인 링크가 토큰 해시로 글을 찾는다.
+CREATE INDEX IF NOT EXISTS ix_place_posts_token
+ ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
diff --git a/postgres-init/migrations/0018_place_reviews.sql b/postgres-init/migrations/0018_place_reviews.sql
new file mode 100644
index 0000000..b2ab466
--- /dev/null
+++ b/postgres-init/migrations/0018_place_reviews.sql
@@ -0,0 +1,29 @@
+-- 0018 · place_reviews — 이용 후기(손님이 쓴 글).
+--
+-- ★ 사진 칸이 없다 (2026-09-16 대표: "후기사진 X"). 사진을 받는 순간 우리가 남의 파일을
+-- 호스팅하게 되고 — PRODUCT.md 6절 non-goal — EXIF·저작권·신고 대응이 전부 따라온다.
+-- ★ 별점 칸도 없다(회의 확정). 그래서 JSON-LD aggregateRating 도 만들지 않는다 —
+-- 자체 수집 후기는 구글 리치결과 대상이 아니다.
+-- ★ 손님이 남긴 이름은 표시용 한 조각뿐이다. 연락처는 받지 않는다 — 받으면 보관·파기가 따라온다.
+
+CREATE TABLE IF NOT EXISTS public.place_reviews (
+ review_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ place_id uuid NOT NULL,
+ body VARCHAR(1000) NOT NULL,
+ nickname VARCHAR(40) NULL, -- 손님이 적은 표시 이름. 비면 '손님'
+ status SMALLINT NOT NULL DEFAULT 1, -- ReviewStatus: 1=pending 2=published 3=rejected
+ submitted_ip_hash VARCHAR(64) NULL, -- sha256(ip+소금). 원문 IP 는 남기지 않는다
+ published_at TIMESTAMPTZ NULL,
+ published_version_id uuid NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ deleted BOOLEAN NOT NULL DEFAULT FALSE
+);
+
+-- 화면이 읽는 경로: 그 업장의 게재된 후기를 최신순.
+CREATE INDEX IF NOT EXISTS ix_place_reviews_published
+ ON public.place_reviews (place_id, published_at DESC) WHERE deleted = FALSE AND status = 2;
+
+-- 운영 경로: 검수 대기 목록.
+CREATE INDEX IF NOT EXISTS ix_place_reviews_status
+ ON public.place_reviews (status, created_at) WHERE deleted = FALSE;
diff --git a/postgres-init/migrations/0019_place_posts_scheduled_date.sql b/postgres-init/migrations/0019_place_posts_scheduled_date.sql
new file mode 100644
index 0000000..2a56147
--- /dev/null
+++ b/postgres-init/migrations/0019_place_posts_scheduled_date.sql
@@ -0,0 +1,12 @@
+-- 0019 · place_posts.scheduled_date — 글마다 하루를 배정한다. 기획: docs/MINI_BLOG.md
+--
+-- ★ 여태까지는 "언제 만들어졌나"(created_at)만 있고 "언제 낼 것인가"는 없었다 — 달력 화면이
+-- 생기면서 날짜가 실제 데이터여야 했다(사장님 요청 2026-09-17: "포스트들이 다 날짜가
+-- 정해져야하는데"). 생성 시점에 그 업장의 다음 빈 날부터 순서대로 하루씩 배정한다
+-- (services/blog_jobs.py `_next_scheduled_date`).
+-- ★ 유니크로 막는다 — 같은 업장이 같은 날짜를 두 번 차지하면 "하루 한 통" 전제가 깨진다.
+
+ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS scheduled_date DATE NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_scheduled_date
+ ON public.place_posts (place_id, scheduled_date) WHERE deleted = FALSE AND scheduled_date IS NOT NULL;
diff --git a/postgres-init/migrations/0020_place_posts_generation_meta.sql b/postgres-init/migrations/0020_place_posts_generation_meta.sql
new file mode 100644
index 0000000..65e8f4b
--- /dev/null
+++ b/postgres-init/migrations/0020_place_posts_generation_meta.sql
@@ -0,0 +1,7 @@
+-- 0020 · place_posts.generation_meta — 생성 이력에 모델명 등을 남긴다. 기획: docs/MINI_BLOG.md
+--
+-- ★ 컬럼을 늘리는 대신 JSONB 한 칸에 담는다(2026-09-17, 사장님 지시: "생성이력도 상세하게
+-- 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나 파서 컬럼"). 지금은 `model` 하나만
+-- 넣지만, 필드가 늘어도 이 컬럼 안에서 해결된다 — 마이그레이션이 매번 안 따라와도 된다.
+
+ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS generation_meta JSONB NULL;
diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py
index d466c22..496c436 100644
--- a/solution/backend/common/database/model/models.py
+++ b/solution/backend/common/database/model/models.py
@@ -1,7 +1,7 @@
import uuid
from sqlalchemy.orm import declarative_base
-from sqlalchemy import Column, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
+from sqlalchemy import Column, Date, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text
@@ -16,6 +16,8 @@ from common.enums import (
MediaStatus,
SongStatus,
SiteStatus,
+ PostStatus,
+ ReviewStatus,
BuildStatus,
JobStatus,
)
@@ -427,6 +429,64 @@ class place_area_refs(MainTableMixin, MAIN_BASE):
+class place_posts(MainTableMixin, MAIN_BASE):
+ """미니 블로그 글 하나. 기획: docs/MINI_BLOG.md
+
+ ★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
+ ★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
+ ★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
+
+ __tablename__ = "place_posts"
+ __table_args__ = (
+ Index("uq_place_posts_topic", "place_id", "topic_key", unique=True,
+ postgresql_where=text("deleted = false")),
+ Index("uq_place_posts_scheduled_date", "place_id", "scheduled_date", unique=True,
+ postgresql_where=text("deleted = false AND scheduled_date IS NOT NULL")),
+ Index("ix_place_posts_status", "status", "created_at", postgresql_where=text("deleted = false")),
+ )
+
+ post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ place_id = Column(UUID(as_uuid=True), nullable=False)
+ body = Column(String(400), nullable=False)
+ topic_kind = Column(SmallInteger, nullable=False)
+ topic_key = Column(String(120), nullable=False)
+ status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
+ # 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date).
+ scheduled_date = Column(Date, nullable=True)
+ # 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
+ # 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
+ # 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
+ generation_meta = Column(JSONB, nullable=True)
+ approve_token_hash = Column(String(64), nullable=True)
+ token_expires_at = Column(DateTime(timezone=True), nullable=True)
+ sent_at = Column(DateTime(timezone=True), nullable=True)
+ approved_at = Column(DateTime(timezone=True), nullable=True)
+ published_at = Column(DateTime(timezone=True), nullable=True)
+ published_version_id = Column(UUID(as_uuid=True), nullable=True)
+
+
+class place_reviews(MainTableMixin, MAIN_BASE):
+ """손님이 남긴 이용 후기.
+
+ ★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
+ 별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
+ ★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
+
+ __tablename__ = "place_reviews"
+ __table_args__ = (
+ Index("ix_place_reviews_status", "status", "created_at", postgresql_where=text("deleted = false")),
+ )
+
+ review_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
+ place_id = Column(UUID(as_uuid=True), nullable=False)
+ body = Column(String(1000), nullable=False)
+ nickname = Column(String(40), nullable=True)
+ status = Column(SmallInteger, nullable=False, server_default=text("1"), default=ReviewStatus.PENDING.value)
+ submitted_ip_hash = Column(String(64), nullable=True)
+ published_at = Column(DateTime(timezone=True), nullable=True)
+ published_version_id = Column(UUID(as_uuid=True), nullable=True)
+
+
class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
diff --git a/solution/backend/common/enums.py b/solution/backend/common/enums.py
index ad4ccb5..b1ab4fa 100644
--- a/solution/backend/common/enums.py
+++ b/solution/backend/common/enums.py
@@ -455,6 +455,35 @@ class JobStatus(CodeEnum):
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
+class PostTopicKind(CodeEnum):
+ """place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다."""
+
+ WEATHER = 1 # local.weather
+ FESTIVAL = 2 # local.festivals
+ SEASON = 3 # 절기·달
+ NEARBY = 4 # local.attractions / restaurants
+ GUIDE = 5 # 이용 안내(검증된 fact 안에서)
+
+
+class PostStatus(CodeEnum):
+ """place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다."""
+
+ DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다
+ REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
+ SENT = 3 # 사장님에게 메일이 나갔다
+ APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기
+ PUBLISHED = 5 # 사이트에 올라갔다
+ SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
+
+
+class ReviewStatus(CodeEnum):
+ """place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다."""
+
+ PENDING = 1 # 손님이 막 남겼다
+ PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
+ REJECTED = 3 # 반려
+
+
class AlertStatus(CodeEnum):
"""alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다."""
diff --git a/solution/backend/crud/post_crud.py b/solution/backend/crud/post_crud.py
new file mode 100644
index 0000000..18c961c
--- /dev/null
+++ b/solution/backend/crud/post_crud.py
@@ -0,0 +1,180 @@
+"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md)."""
+from sqlalchemy import func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from common.database.model.models import place_posts
+from common.enums import ErrorType, PostStatus
+from common.utils.gtime import GTime
+
+
+class PostCRUD:
+ async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
+ """생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 —
+ 회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다)."""
+ for row in rows:
+ try:
+ cdb.add(place_posts(**row))
+ await cdb.flush()
+ except Exception: # noqa: BLE001 — 유니크 충돌 = 이미 쓴 주제거나 이미 찬 날짜
+ await cdb.rollback()
+ return ErrorType.SUCCESS
+
+ async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
+ """개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값
+ (post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히
+ 건너뜀" 으로 끝내면 안 된다.
+
+ ★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면
+ detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다."""
+ try:
+ obj = place_posts(**row)
+ cdb.add(obj)
+ await cdb.flush()
+ return {
+ "post_id": obj.post_id, "place_id": obj.place_id, "body": obj.body,
+ "topic_kind": obj.topic_kind, "topic_key": obj.topic_key, "status": obj.status,
+ "scheduled_date": obj.scheduled_date, "generation_meta": obj.generation_meta,
+ }
+ except Exception: # noqa: BLE001 — 유니크 충돌(그 날짜 이미 있음 등)
+ await cdb.rollback()
+ return None
+
+ async def max_scheduled_date(self, cdb: AsyncSession, place_id):
+ """이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다)."""
+ result = await cdb.execute(
+ select(func.max(place_posts.scheduled_date))
+ .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
+ )
+ return result.scalar()
+
+ async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
+ """배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도
+ 하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다)."""
+ result = await cdb.execute(
+ select(place_posts)
+ .where(
+ place_posts.status == status, place_posts.deleted == False, # noqa: E712
+ place_posts.scheduled_date <= today,
+ )
+ .distinct(place_posts.place_id)
+ .order_by(place_posts.place_id, place_posts.scheduled_date)
+ )
+ rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
+ return ErrorType.SUCCESS, rows[:limit]
+
+ async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
+ """사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
+ result = await cdb.execute(
+ select(place_posts)
+ .where(
+ place_posts.place_id == place_id,
+ place_posts.deleted == False, # noqa: E712
+ place_posts.scheduled_date >= since,
+ place_posts.scheduled_date < until,
+ )
+ .order_by(place_posts.scheduled_date.desc())
+ )
+ return ErrorType.SUCCESS, list(result.scalars())
+
+ async def by_id(self, cdb: AsyncSession, post_id):
+ """메일의 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다."""
+ result = await cdb.execute(
+ select(place_posts).where(place_posts.post_id == post_id, place_posts.deleted == False) # noqa: E712
+ )
+ return result.scalars().first()
+
+ async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
+ """생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다.
+ 새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로
+ 같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도
+ 전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다."""
+ result = await cdb.execute(
+ select(
+ place_posts.created_at,
+ func.count().label("count"),
+ func.max(place_posts.generation_meta["model"].astext).label("model"),
+ )
+ .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
+ .group_by(place_posts.created_at)
+ .order_by(place_posts.created_at.desc())
+ .limit(limit)
+ )
+ return result.all()
+
+ async def used_topic_keys(self, cdb: AsyncSession, place_id) -> list[str]:
+ result = await cdb.execute(
+ select(place_posts.topic_key)
+ .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
+ )
+ return [row[0] for row in result.all()]
+
+ async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
+ """화면에 나갈 글. 최신순이고, 게재된 것만."""
+ result = await cdb.execute(
+ select(place_posts)
+ .where(
+ place_posts.place_id == place_id,
+ place_posts.status == PostStatus.PUBLISHED.value,
+ place_posts.deleted == False, # noqa: E712
+ )
+ .order_by(place_posts.published_at.desc())
+ .limit(limit)
+ )
+ return list(result.scalars())
+
+ async def by_token_hash(self, cdb: AsyncSession, token_hash: str):
+ result = await cdb.execute(
+ select(place_posts)
+ .where(place_posts.approve_token_hash == token_hash, place_posts.deleted == False) # noqa: E712
+ )
+ return result.scalars().first()
+
+ async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at) -> ErrorType:
+ await cdb.execute(
+ update(place_posts)
+ .where(place_posts.post_id == post_id)
+ .values(status=PostStatus.SENT.value, approve_token_hash=token_hash,
+ token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC())
+ )
+ return ErrorType.SUCCESS
+
+ # 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이
+ # 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
+ _EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
+
+ async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
+ """수정하기 — 이미 승인·게재·반려된 글은 못 고친다."""
+ await cdb.execute(
+ update(place_posts)
+ .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
+ .values(body=body, updated_at=GTime.UTC())
+ )
+ return ErrorType.SUCCESS
+
+ async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
+ """★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
+ await cdb.execute(
+ update(place_posts)
+ .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
+ .values(status=PostStatus.APPROVED.value, approved_at=GTime.UTC(),
+ approve_token_hash=None, updated_at=GTime.UTC())
+ )
+ return ErrorType.SUCCESS
+
+ async def skip(self, cdb: AsyncSession, post_id) -> ErrorType:
+ await cdb.execute(
+ update(place_posts)
+ .where(place_posts.post_id == post_id)
+ .values(status=PostStatus.SKIPPED.value, approve_token_hash=None, updated_at=GTime.UTC())
+ )
+ return ErrorType.SUCCESS
+
+ async def mark_published(self, cdb: AsyncSession, place_id, version_id) -> ErrorType:
+ """재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
+ await cdb.execute(
+ update(place_posts)
+ .where(place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value)
+ .values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
+ published_version_id=version_id, updated_at=GTime.UTC())
+ )
+ return ErrorType.SUCCESS
diff --git a/solution/backend/requirements.txt b/solution/backend/requirements.txt
index aba240b..484ada3 100644
--- a/solution/backend/requirements.txt
+++ b/solution/backend/requirements.txt
@@ -14,4 +14,5 @@ requests>=2.31 # google-auth 토큰 갱신 transport
apscheduler>=3.10
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19
+azure-communication-email>=1.0
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)
diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py
index ca4203c..38074b8 100644
--- a/solution/backend/router/router.py
+++ b/solution/backend/router/router.py
@@ -21,6 +21,9 @@ import router.v1.media.relay
import router.v1.job.job
import router.v1.site.site
import router.v1.site.showcase
+import router.v1.site.booking_request
+import router.v1.site.post
+import router.v1.site.review
import router.v1.local.local
API_SERVER_START_TIME = GTime.UTCStr()
@@ -120,5 +123,9 @@ app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router)
+app.include_router(router.v1.site.booking_request.router)
+app.include_router(router.v1.site.post.router)
+app.include_router(router.v1.site.post.owner_router)
+app.include_router(router.v1.site.review.router)
app.include_router(router.v1.local.local.router)
app.include_router(router.v1.local.local.weather_router)
diff --git a/solution/backend/router/v1/site/booking_request.py b/solution/backend/router/v1/site/booking_request.py
new file mode 100644
index 0000000..88e5eea
--- /dev/null
+++ b/solution/backend/router/v1/site/booking_request.py
@@ -0,0 +1,88 @@
+"""발행본의 예약 요청 폼 → 사장님 메일.
+
+★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
+★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고,
+ 보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다.
+★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은
+ **연락 요청**이고, 화면도 그렇게 말한다.
+"""
+import time
+import uuid
+from collections import defaultdict, deque
+
+from fastapi import APIRouter, Depends, Request
+from pydantic import BaseModel, Field
+
+from common.logger import LOG
+from router.v1.validator.dependencies import RemoveNoneResponse
+from services.booking_request_service import BookingRequestService
+
+router = APIRouter(prefix="/v1/site", tags=["Site"])
+
+# 한 아이피가 한 시간에 보낼 수 있는 통수. 같은 업장으로 몰리는 것도 따로 센다.
+IP_LIMIT_PER_HOUR = 5
+PLACE_LIMIT_PER_HOUR = 30
+WINDOW_SEC = 3600
+# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. 봇은 즉시 제출한다.
+MIN_ELAPSED_MS = 1500
+
+_hits: dict[str, deque] = defaultdict(deque)
+
+
+def _allow(key: str, limit: int) -> bool:
+ now = time.monotonic()
+ hits = _hits[key]
+ while hits and now - hits[0] > WINDOW_SEC:
+ hits.popleft()
+ if len(hits) >= limit:
+ return False
+ hits.append(now)
+ return True
+
+
+class ReqBookingRequest(BaseModel):
+ place_id: uuid.UUID
+ name: str = Field(min_length=1, max_length=40)
+ phone: str = Field(min_length=6, max_length=30)
+ email: str | None = Field(default=None, max_length=255)
+ stay: str | None = Field(default=None, max_length=60)
+ guests: str | None = Field(default=None, max_length=30)
+ message: str | None = Field(default=None, max_length=1000)
+ consent: bool
+ # 봇 잡이. 사람에게는 안 보이는 칸이라 값이 있으면 사람이 아니다.
+ company: str | None = Field(default=None, max_length=100)
+ elapsed_ms: int = 0
+
+
+class ResBookingRequest(BaseModel):
+ success: bool
+ message: str
+
+
+@router.post(
+ path="/booking-request",
+ response_model=ResBookingRequest,
+ summary="예약 요청 — 발행본 폼에서 사장님 메일로 전달",
+)
+async def send_booking_request(
+ body: ReqBookingRequest,
+ request: Request,
+ service: BookingRequestService = Depends(),
+):
+ client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
+ or (request.client.host if request.client else "unknown"))
+
+ # 봇 두 겹. 걸려도 실패로 알리지 않는다 — 무엇에 걸렸는지 알려 주면 다음 시도가 그걸 피한다.
+ if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
+ LOG.w("[booking-request] 봇 의심 요청을 버렸다")
+ return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다."))
+
+ if not body.consent:
+ return RemoveNoneResponse(ResBookingRequest(success=False, message="연락처 수집에 동의해 주세요."))
+
+ if not _allow(f"ip:{client_ip}", IP_LIMIT_PER_HOUR) or not _allow(f"place:{body.place_id}", PLACE_LIMIT_PER_HOUR):
+ return RemoveNoneResponse(ResBookingRequest(
+ success=False, message="요청이 많습니다. 잠시 뒤 다시 시도하거나 전화로 문의해 주세요.",
+ ))
+
+ return RemoveNoneResponse(await service.send(body))
diff --git a/solution/backend/router/v1/site/post.py b/solution/backend/router/v1/site/post.py
new file mode 100644
index 0000000..7a8f5be
--- /dev/null
+++ b/solution/backend/router/v1/site/post.py
@@ -0,0 +1,142 @@
+"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: docs/MINI_BLOG.md
+
+★ /approve 는 로그인이 없다. 링크에 실린 토큰 하나가 신원이고, 누르는(GET) 순간 바로
+ 승인된다(2026-09-17, 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). ★★ 이건
+ 메일 클라이언트의 링크 미리 열기(아웃룩 안전 링크 스캔 등)에 그대로 노출된다는 뜻이다 —
+ 예전에는 이걸 막으려고 GET=확인 화면 / POST=승인 확정으로 나눴었다. 사장님이 그 위험을
+ 알고도 즉시 승인을 택했다.
+★ "수정하기" 는 반대로 로그인 흐름을 탄다 — 메일에 그날 자정(KST)까지만 사는 접근 토큰을
+ 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
+ 글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
+★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
+"""
+from datetime import date
+from uuid import UUID
+
+from fastapi import APIRouter, Depends, Query
+from fastapi.responses import HTMLResponse
+
+from common.models.gmodel import Res_WebPacketProtocol, UserInfo
+from router.v1.site.protocol import (
+ Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
+)
+from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
+from services.post_service import PostService
+
+router = APIRouter(prefix="/v1/site/post", tags=["Site"])
+owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"])
+
+_PAGE = """
+
+{title}