# 미니 블로그 — 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. 승인·수정 - **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해 "바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service. PostService._approve_and_publish`). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다. - **승인**: 이메일 GET 은 로그인 없이 즉시 승인, "바로 발행" 은 로그인 세션이 신원 → 둘 다 `status = APPROVED` → BUILD 잡 큐. 이메일 링크의 만료·재사용은 "처리할 수 없는 링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다) - **쓰레드 연동**: 승인되는 순간(경로 무관) 그 업장이 쓰레드에 연결돼 있으면 같은 문구에 발행 링크를 붙여 쓰레드에도 즉시 게시한다(2026-09-21) — 별도 승인 없음(`docs/DECISIONS.md` 7-1-2 개정, `docs/SOCIAL.md`). 연동 안 돼 있거나 `SOCIAL_POSTING_ENABLED=0`이거나 사이트 domain이 미확정이면 조용히 건너뛴다. 실패해도 미니블로그 승인 자체는 막지 않는다 (`post_service.PostService._try_social_share`) - **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문만 갱신한다. **승인 전환은 하지 않는다** — 실패하면 사유를 보여주고 다시 고치게 한다, 통과해도 두 승인 경로 중 하나를 눌러야 사이트에 반영된다 - **알림 이메일**: 승인 메일 수신자는 `places.notify_email`(비면 `users.email`) — 계정 로그인 이메일과 분리해서 업장별로 다른 담당자에게 보낼 수 있다(빌더 앱 미니블로그 관리 화면에서 수정, `PATCH /v1/place/{place_id}`) - ★ 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건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 — 쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준) ## 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` 버그 수정 | 완료 | | 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 | 남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다), 그리고 6절의 발행일 갱신 합의. ## 안 하는 것 - 사진 첨부 (2026-09-16 회의 확정) - 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다 - 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16 대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다