사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능". 미니블로그의 두 승인 경로(이메일 GET 토큰, 로그인 "바로 발행")를 공통 메서드로 묶고, 그 끝에서 쓰레드 연동을 시도한다. 미니블로그 승인 자체가 발화 동의로 취급되므로 쓰레드 쪽 별도 승인은 묻지 않는다(DECISIONS 7-1-2 개정, 문구를 그대로 재사용하는 경우에 한정). 겸사겸사 쓰레드 초안 생성(generate_social_post)이 LLM_PROVIDER 를 안 타고 Gemini 를 직접 호출하던 것도 다른 생성 함수와 같은 추상화로 맞췄다. - blog_jobs._published_places: sites.domain IS NOT NULL 조건 추가(쓰레드 기준과 통일) - social_service.publish_reused_text: 연동 없음/게시 비활성/domain 미확정이면 스킵, 정상이면 APPROVED 삽입 + run_post(job_type=9) enqueue — 새 게시 로직은 안 만든다 - post_service: decide/approve_by_owner → _approve_and_publish 로 공통화, _try_social_share 는 실패를 전부 삼켜 미니블로그 승인을 막지 않는다 - gemini_text.generate_social_post: services.llm.provider.active() 로 전환, Gemini 전용 import 제거 - DECISIONS.md 7-1-2, MINI_BLOG.md 5·8절, SOCIAL.md 갱신 test_blog_owner.py·test_social.py 다수 추가/수정, 관련 스위트 전체 PASS
20 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.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)은 삭제했다. 최종 판단은
사장님 몫이고, 그 판단은 두 군데서 이뤄진다.
- 메일 — 업장당 하루 한 통, 승인/수정/넘기기 (4·5절)
- 빌더 앱 로그인 — 이번 달 생성된 글 전체를 미리 보고 메일이 오기 전에 바로 승인·수정할 수 있다 (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.md7-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.pyowner_router, :9800). 로그인 세션(IsValidAccessToken)이 신원이고,PlaceCRUD.get_place로 소유권을 매번 확인한다 — 토큰 흐름과 인증 방식이 다를 뿐 편집 가드(is_publishable_body)는 같다 - 아직 메일이 안 나간
REVIEWED글도 여기서 바로 승인·수정할 수 있다 —PostCRUD._EDITABLE = (SENT, REVIEWED). 메일을 기다릴 필요가 없다 - 월 단위 조회(
month=YYYY-MM, 기본 이번 달, KST 기준) —scheduled_date기준으로 그 달에 배정된 글을 가져온다 - 화면은 탭 둘뿐이다 (2026-09-17, 사장님 지시: "탭을 왜 이번주 달력 이렇게 나누고
지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지" — 카로셀·달력은
같은 화면에 항상 같이 뜬다, "생성 이력"만 별도 탭이다)
- 블로그(카로셀 + 달력, 항상 같이 보인다).
- 카로셀 — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
단위)과 무관하게 항상 오늘부터 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)을 안 본다 — 콕 집은 요청이라 상한이 끼어들 자리가 아니다. 이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다. 지난 날짜는 만들 이유가 없어 클릭 자체를 막는다. 성공하면 그 자리에서 모달이 열린다
- 카로셀 — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
단위)과 무관하게 항상 오늘부터 7일치(
- 생성 이력. 언제 몇 건, 어느 모델로 만들었는지(사장님 지시: "생성이력도 있어야해
몇개 생성했는지" / "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등") —
GET .../post/history(PostCRUD.generation_batches). 새 컬럼 없이 기존created_at으로 회차를 묶는다(같은 트랜잭션 안의add_many는 DBnow()가 전부 같다). 모델명은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건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 — 쓰레드 연동 요구사항과 맞췄다,docs/SOCIAL.md7-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 대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다