o2o-site-AEO/docs/MINI_BLOG.md
Mina Choi 872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00

19 KiB

미니 블로그 — 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.sqlpostgres-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__.pyadd_job 한 줄, 로직은 scheduler/jobs.pyservices/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_mailscheduled_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_postplace_id 불일치를 걸러낸다)

5. 승인·수정

  • 승인: GET 이 로그인 없이 즉시 승인한다 → status = APPROVED → BUILD 잡 큐. 만료· 재사용은 "처리할 수 없는 링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
  • 수정: 빌더 앱 편집 모달에서 저장 → is_publishable_body 재검사 → 통과 시 본문 갱신 + 그대로 APPROVED 전환 + BUILD 잡. 실패하면 사유를 보여주고 다시 고치게 한다(사이트에는 안 올라간다)
  • ★ BUILD 잡 payload 에는 반드시 owner_user_id 가 있어야 한다(build_service.run_buildpayload["owner_user_id"] 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인 세션과 달라 post_service.PostService._enqueue_buildplace_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_dateblog_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=(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을 캘린더 입력(<input type="date">)으로 고르는 다이얼로그가 뜬다(사장님 지시: "캘린더 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_servicemark_published, owner_user_id 버그 수정 완료

남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다), 그리고 6절의 발행일 갱신 합의.

안 하는 것

  • 사진 첨부 (2026-09-16 회의 확정)
  • 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
  • 예약 요청 관리 화면 — booking_request.py 는 요청을 DB 에 남기지 않는다(2026-09-16 대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다