Compare commits

...

3 Commits

Author SHA1 Message Date
d77f2aa14c [fix] solution/backend: 머지로 들어온 SNS 게재 기능의 죽은 코드 3건 수정
feature/social-post 를 feature/site-features-and-mockup 에 머지한 뒤 실제 배포·테스트로
잡았다 — 두 브랜치가 각자 독립적으로 LLM 공급자 리팩터(services/llm/) 이전/이후 상태로
갈라져 있어 SNS 쪽 코드가 옛 인터페이스를 그대로 참조하고 있었다.

- gemini_text.generate_social_post: 모듈 최상단에 call·DEFAULT_MODEL·extract_text 를
  services.llm.gemini 에서 들여오지 않아 실제 게시 시도가 전부 NameError/AttributeError 로
  죽는 상태였다(테스트도 gemini_text.call 을 monkeypatch 하려다 AttributeError). json 모듈도
  import 가 빠져 있었다.
- common/enums.py JobType: SOCIAL_DRAFT=8 이 기존 ROLLBACK=8 과 값이 겹쳐 있었다 — Python
  enum 은 값이 같으면 뒤 멤버가 앞 멤버의 별칭이 되므로, 워커의 핸들러 등록표에서
  HANDLERS[8] 이 SOCIAL_DRAFT 핸들러로 먼저 채워지고 ROLLBACK 은 "이미 등록됨" 판정으로
  덮어써지지 않았다 — 롤백 요청이 SNS 초안 핸들러로 잘못 라우팅돼 KeyError('post_id') 로
  죽었다. SOCIAL_DRAFT=9, SOCIAL_POST=10 으로 재배정. init.sql 의 job_type 주석도 갱신.

검증: test_social.py 14 passed, test_rollback.py 4 passed, 전체 백엔드 763 passed(기존
LLM 공급자 전환 관련 무관 실패 44건 제외 동일).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:44:40 +09:00
b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00
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
342 changed files with 75001 additions and 9319 deletions

View File

@ -48,6 +48,10 @@ SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100 # 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
SITE_ONTOLOGY_URL= SITE_ONTOLOGY_URL=
# 프리렌더가 절대 굽지 않는 슬러그(쉼표 구분). 손으로 만든 목업(/s/stay·stay2·stay3·stay4·stay5)
# 이름과 같은 슬러그로 실제 발행이 생기면 그 payload 로 목업을 덮어 구워버린다 — 비우지 않는다.
PRERENDER_PROTECTED_SLUGS=stay,stay2,stay3,stay4,stay5
# ── SNS 게재(스레드) ──────────────────────────────────────────────── # ── SNS 게재(스레드) ────────────────────────────────────────────────
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아 # 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
# **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다). # **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다).
@ -143,6 +147,24 @@ AZURE_STORAGE_PREFIX=
AUTO_LOGIN_ID= AUTO_LOGIN_ID=
AUTO_LOGIN_PW= AUTO_LOGIN_PW=
# ── 메일 발송 (예약 요청 알림) ────────────────────────────────────────────
# 비우면 메일을 보내지 않는다. 서버는 그대로 뜨고, 예약 요청 폼은 "전화로 문의" 로 답한다.
# ★ 1순위는 회사 공용 Azure Communication Services 다(negodata 와 같은 리소스).
# 발신 도메인의 SPF·DKIM 을 그쪽이 관리하므로 메일서버를 새로 세울 필요가 없다.
ACS_EMAIL_ENDPOINT=
ACS_EMAIL_ACCESSKEY=
ACS_EMAIL_SENDER=
# ACS 를 못 쓰는 환경의 폴백. 이쪽을 쓰면 SPF·DKIM 을 직접 걸어야 한다.
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
SMTP_FROM_NAME=Web4AI
# starttls(587) · ssl(465) · plain. 비우면 포트로 고른다.
SMTP_TLS=
# SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키. # SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키.
# 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다. # 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다.
SOCIAL_TOKEN_SECRET= SOCIAL_TOKEN_SECRET=

View File

@ -17,6 +17,7 @@
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) | | **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) | | 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
--- ---

View File

@ -22,6 +22,7 @@ import router.v1.fact.fact
import router.v1.job.job import router.v1.job.job
import router.v1.local.local import router.v1.local.local
import router.v1.place.place import router.v1.place.place
import router.v1.site.review_admin
import router.v1.site.site import router.v1.site.site
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@ -84,3 +85,4 @@ app.include_router(router.v1.fact.fact.router, dependencies=_gate)
app.include_router(router.v1.job.job.router, dependencies=_gate) app.include_router(router.v1.job.job.router, dependencies=_gate)
app.include_router(router.v1.site.site.router, dependencies=_gate) app.include_router(router.v1.site.site.router, dependencies=_gate)
app.include_router(router.v1.local.local.router, dependencies=_gate) app.include_router(router.v1.local.local.router, dependencies=_gate)
app.include_router(router.v1.site.review_admin.router, dependencies=_gate)

View File

@ -1,8 +1,9 @@
import {Building2, CalendarDays} from 'lucide-react'; import {Building2, CalendarDays, MessageSquareQuote} from 'lucide-react';
import {createBrowserRouter, Navigate, Outlet} from 'react-router'; import {createBrowserRouter, Navigate, Outlet} from 'react-router';
import {AppShell, type NavItem} from '@/components/layout/AppShell'; import {AppShell, type NavItem} from '@/components/layout/AppShell';
import {RequireAuth} from '@/components/layout/RequireAuth'; import {RequireAuth} from '@/components/layout/RequireAuth';
import {LocalContentPage} from '@admin/pages/LocalContentPage'; import {LocalContentPage} from '@admin/pages/LocalContentPage';
import {ReviewModerationPage} from '@admin/pages/ReviewModerationPage';
import {LoginPage} from '@/pages/LoginPage'; import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage'; import {NotFoundPage} from '@/pages/NotFoundPage';
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage'; import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
@ -16,6 +17,7 @@ import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
const ADMIN_NAV: NavItem[] = [ const ADMIN_NAV: NavItem[] = [
{to: '/places', match: '/places', label: '사업장', icon: Building2}, {to: '/places', match: '/places', label: '사업장', icon: Building2},
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays}, {to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
]; ];
/** /**
@ -41,6 +43,7 @@ export const router = createBrowserRouter([
{path: '/places/:placeId', element: <PlaceDetailPage />}, {path: '/places/:placeId', element: <PlaceDetailPage />},
{path: '/places/:placeId/seo', element: <SeoAuditPage />}, {path: '/places/:placeId/seo', element: <SeoAuditPage />},
{path: '/local-content', element: <LocalContentPage />}, {path: '/local-content', element: <LocalContentPage />},
{path: '/reviews', element: <ReviewModerationPage />},
], ],
}, },

View File

@ -0,0 +1,169 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {RefreshCw, X} from 'lucide-react';
import {PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card, CardContent} from '@/components/ui/card';
import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
/**
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
*
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
*/
type Review = {
review_id: string;
place_id: string;
place_name: string;
body: string;
nickname: string;
status: number;
created_at: string;
};
const STATUS_TABS: {value: number; label: string}[] = [
{value: 2, label: '게재된 후기'},
{value: 3, label: '내린 글'},
];
export function ReviewModerationPage() {
const [status, setStatus] = useState(2);
const [posts, setPosts] = useState<Review[]>([]);
const [picked, setPicked] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await customFetch<{items: Review[]}>({
url: `/v1/admin/review/list?status=${status}&limit=200`,
method: 'GET',
});
setPosts(res.items ?? []);
setPicked(new Set());
} catch {
toast.error('목록을 불러오지 못했습니다.');
} finally {
setLoading(false);
}
}, [status]);
useEffect(() => {
void load();
}, [load]);
const byPlace = useMemo(() => {
const map = new Map<string, Review[]>();
for (const post of posts) {
const key = post.place_name || post.place_id;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(post);
}
return [...map.entries()];
}, [posts]);
const toggle = (id: string) => {
setPicked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const decide = async (approve: boolean) => {
const ids = [...picked];
if (ids.length === 0) return;
try {
await customFetch({url: '/v1/admin/review/decide', method: 'POST', data: {review_ids: ids, publish: approve}});
toast.success(approve ? `${ids.length}건을 다시 올렸습니다.` : `${ids.length}건을 내렸습니다.`);
await load();
} catch {
toast.error('처리하지 못했습니다.');
}
};
return (
<PageContainer
title="이용 후기"
description="손님이 남기면 바로 사이트에 올라갑니다. 문제가 있는 글만 여기서 내립니다."
actions={
<Button variant="outline" onClick={() => void load()} disabled={loading}>
<RefreshCw className="size-4" /> 새로고침
</Button>
}
>
<div className="mb-4 flex flex-wrap gap-1.5">
{STATUS_TABS.map((tab) => (
<Button
key={tab.value}
size="sm"
variant={tab.value === status ? 'primary' : 'outline'}
onClick={() => setStatus(tab.value)}
>
{tab.label}
</Button>
))}
</div>
{status === 2 && (
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-muted-foreground text-sm">{picked.size}건 선택</span>
<Button size="sm" variant="outline" onClick={() => void decide(false)} disabled={picked.size === 0}>
<X className="size-4" /> 내리기
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setPicked(new Set(posts.map((post) => post.review_id)))}
disabled={posts.length === 0}
>
전체 선택
</Button>
</div>
)}
{posts.length === 0 && !loading && (
<p className="text-muted-foreground py-10 text-center text-sm">이 상태의 글이 없습니다.</p>
)}
<div className="flex flex-col gap-5">
{byPlace.map(([placeName, rows]) => (
<section key={placeName}>
<h2 className="mb-2 text-sm font-bold">
{placeName} <span className="text-muted-foreground font-normal">{rows.length}건</span>
</h2>
<div className="flex flex-col gap-2">
{rows.map((post) => (
<Card key={post.review_id}>
<CardContent className="flex items-start gap-3 p-4">
{status === 2 && (
<input
type="checkbox"
id={`review-${post.review_id}`}
checked={picked.has(post.review_id)}
onChange={() => toggle(post.review_id)}
className="mt-1 size-4 shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="mb-1.5 flex flex-wrap items-center gap-2">
<Badge variant="accent">{post.nickname || '손님'}</Badge>
<span className="text-muted-foreground text-xs tabular-nums">{post.body.length}자</span>
</div>
<label htmlFor={`review-${post.review_id}`} className="block text-sm leading-relaxed">
{post.body}
</label>
</div>
</CardContent>
</Card>
))}
</div>
</section>
))}
</div>
</PageContainer>
);
}

View File

@ -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일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는
버튼을 누르면 시작·끝일을 `<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 웹훅 수신자 고장 — 플로우 재생성으로 해결 ## 2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결
원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어 원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어

242
docs/MINI_BLOG.md Normal file
View File

@ -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=`(사장님 지시:
"지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을
캘린더 입력(`<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_service` → `mark_published`, `owner_user_id` 버그 수정 | 완료 |
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
그리고 6절의 발행일 갱신 합의.
## 안 하는 것
- 사진 첨부 (2026-09-16 회의 확정)
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다

View File

@ -94,7 +94,7 @@ CREATE TABLE IF NOT EXISTS public.users (
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS public.jobs ( CREATE TABLE IF NOT EXISTS public.jobs (
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다 job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check 7=song 8=rollback job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check 7=song 8=rollback 9=social_draft 10=social_post
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선 priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb, payload JSONB NOT NULL DEFAULT '{}'::jsonb,
@ -356,6 +356,40 @@ CREATE TABLE IF NOT EXISTS public.alert_outbox (
deleted BOOLEAN NOT NULL DEFAULT FALSE 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 ( CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1 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) 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; 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 -- alert_outbox
-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것. -- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것.
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at); CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,7 +1,7 @@
import uuid import uuid
from sqlalchemy.orm import declarative_base 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.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text from sqlalchemy.sql import text
@ -16,6 +16,8 @@ from common.enums import (
MediaStatus, MediaStatus,
SongStatus, SongStatus,
SiteStatus, SiteStatus,
PostStatus,
ReviewStatus,
BuildStatus, BuildStatus,
JobStatus, 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): class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개. """발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다.""" ★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""

View File

@ -435,10 +435,10 @@ class JobType(CodeEnum):
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위 BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회) LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
AI_CHECK = 6 # AI 검색 노출 점검 AI_CHECK = 6 # AI 검색 노출 점검
SOCIAL_DRAFT = 8
SOCIAL_POST = 9
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다 SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림 ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
class JobStatus(CodeEnum): class JobStatus(CodeEnum):
@ -457,6 +457,35 @@ class JobStatus(CodeEnum):
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} 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 SocialProvider(CodeEnum): class SocialProvider(CodeEnum):
X = 1 X = 1
THREADS = 2 THREADS = 2

View File

@ -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

View File

@ -14,5 +14,6 @@ requests>=2.31 # google-auth 토큰 갱신 transport
apscheduler>=3.10 apscheduler>=3.10
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식) pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19 azure-storage-blob>=12.19
azure-communication-email>=1.0
cryptography>=42 # SNS 위임 토큰 Fernet 암호화(평문 저장 경로 없음) cryptography>=42 # SNS 위임 토큰 Fernet 암호화(평문 저장 경로 없음)
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요) playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)

View File

@ -21,6 +21,9 @@ import router.v1.media.relay
import router.v1.job.job import router.v1.job.job
import router.v1.site.site import router.v1.site.site
import router.v1.site.showcase 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 import router.v1.local.local
import router.v1.social.social import router.v1.social.social
import router.v1.social.oauth import router.v1.social.oauth
@ -126,6 +129,10 @@ app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router) app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다. # ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router) 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.router)
app.include_router(router.v1.local.local.weather_router) app.include_router(router.v1.local.local.weather_router)

View File

@ -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))

View File

@ -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 = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
<title>{title}</title><style>
body{{margin:0;background:#f6f5f1;color:#1b1a15;font:17px/1.7 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}}
.wrap{{max-width:34rem;margin:0 auto;padding:40px 20px}}
h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}}
.meta{{color:#6b7269;font-size:14px}}
</style></head><body><div class="wrap">{content}</div></body></html>"""
def _page(title: str, content: str) -> HTMLResponse:
return HTMLResponse(_PAGE.format(title=title, content=content))
def _expired_page() -> HTMLResponse:
return _page(
"처리할 수 없는 링크입니다",
"<h1>처리할 수 없는 링크입니다</h1><p class='meta'>이미 처리했거나 기한이 지난 링크입니다.</p>",
)
@router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다")
async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
result = await service.decide(t, skip=False)
if not result["success"]:
return _expired_page()
return _page("올렸습니다", f"<h1>{result['message']}</h1><p class='meta'>사이트에 반영되기까지 몇 분 걸립니다.</p>")
@owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록")
async def list_my_posts(
place_id: UUID,
month: str | None = Query(default=None, pattern=r"^\d{4}-\d{2}$", description="YYYY-MM, 기본값 이번 달"),
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.list_for_owner(user_info, str(place_id), month))
@owner_router.get(path="/upcoming", response_model=Res_MyPosts, summary="상단 카로셀 — 오늘부터 N일치, 날짜순")
async def list_upcoming_posts(
place_id: UUID,
days: int = Query(default=7, ge=1, le=30),
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.list_upcoming(user_info, str(place_id), days))
@owner_router.get(path="/history", response_model=Res_GenerationHistory, summary="생성 이력 — 언제 몇 건 만들었는지")
async def get_generation_history(
place_id: UUID,
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.generation_history(user_info, str(place_id)))
@owner_router.get(path="/{post_id}", response_model=Res_MyPosts, summary="글 하나 — 메일 수정 링크(자동 로그인)가 쓴다")
async def get_my_post(
place_id: UUID,
post_id: UUID,
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id)))
@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정·승인")
async def edit_my_post(
place_id: UUID,
post_id: UUID,
req: Req_EditPost,
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body))
@owner_router.post(path="/{post_id}/approve", response_model=Res_WebPacketProtocol, summary="바로 발행 — 고치지 않고 그대로")
async def approve_my_post(
place_id: UUID,
post_id: UUID,
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id)))
@owner_router.post(
path="/generate", response_model=Res_GenerateNow,
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",
)
async def generate_my_posts(
place_id: UUID,
start: date = Query(description="구간 시작일"),
end: date = Query(description="구간 끝일"),
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.generate_range(user_info, str(place_id), start, end))
@owner_router.post(
path="/generate-one", response_model=Res_GenerateOne,
summary="개별 생성 — 달력에서 빈 날짜 하나만 콕 집어 채운다",
)
async def generate_my_post_for_date(
place_id: UUID,
target_date: date = Query(alias="date"),
service: PostService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
):
return RemoveNoneResponse(await service.generate_for_date(user_info, str(place_id), target_date))

View File

@ -1,5 +1,5 @@
import uuid import uuid
from datetime import datetime from datetime import date, datetime
from typing import Any, Optional from typing import Any, Optional
from pydantic import ConfigDict from pydantic import ConfigDict
@ -270,3 +270,59 @@ class ShowcaseItem(WebPacketProtocol):
class Res_Showcase(Res_WebPacketProtocol): class Res_Showcase(Res_WebPacketProtocol):
items: list[ShowcaseItem] = [] items: list[ShowcaseItem] = []
class PostData(WebPacketProtocol):
"""미니 블로그 글 하나 — 빌더 앱 '이번 달 생성된 글' 목록 카드."""
model_config = ConfigDict(from_attributes=True)
post_id: uuid.UUID
body: str
topic_kind: int
status: int
scheduled_date: Optional[date] = None
created_at: Optional[datetime] = None
sent_at: Optional[datetime] = None
approved_at: Optional[datetime] = None
published_at: Optional[datetime] = None
# 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데
# BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed).
build_failed: bool = False
class Res_MyPosts(Res_WebPacketProtocol):
posts: list[PostData] = []
class Req_EditPost(SiteProtocol):
"""수정하고 그대로 승인 — 로그인 세션 버전(메일 없이 목록에서 바로 고칠 때)."""
body: str
class Res_GenerateNow(Res_WebPacketProtocol):
"""즉시 생성 결과 — 사장님이 고른 구간(시작~끝) 중 몇 일을 채웠는지."""
requested: int = 0
created: int = 0
class GenerationBatch(WebPacketProtocol):
"""생성 회차 하나 — 같은 스윕에서 한 번에 만들어진 글 묶음(post_crud.generation_batches)."""
created_at: datetime
count: int
model: Optional[str] = None
class Res_GenerationHistory(Res_WebPacketProtocol):
batches: list[GenerationBatch] = []
class Res_GenerateOne(Res_WebPacketProtocol):
"""개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님
지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나
소재가 바닥났다)."""
post: Optional[PostData] = None

View File

@ -0,0 +1,75 @@
"""이용 후기 접수 — 발행본에서 손님이 남긴다.
★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 —
허니팟 · 최소 체류시간 · 레이트리밋.
★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다.
"""
import uuid
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field
from common.logger import LOG
from router.v1.site.booking_request import MIN_ELAPSED_MS, _allow
from router.v1.validator.dependencies import RemoveNoneResponse
from services.review_service import ReviewService
router = APIRouter(prefix="/v1/site", tags=["Site"])
IP_LIMIT_PER_HOUR = 3
PLACE_LIMIT_PER_HOUR = 30
class ReqReview(BaseModel):
place_id: uuid.UUID
body: str = Field(min_length=1, max_length=1000)
nickname: str | None = Field(default=None, max_length=40)
consent: bool
company: str | None = Field(default=None, max_length=100)
elapsed_ms: int = 0
client_ip: str | None = None
class ResReview(BaseModel):
success: bool
message: str
class PublicReview(BaseModel):
reviewId: str
body: str
nickname: str
publishedAt: str
class ResPublicReviews(BaseModel):
items: list[PublicReview] = []
@router.post(path="/review", response_model=ResReview, summary="이용 후기 남기기")
async def submit_review(body: ReqReview, request: Request, service: ReviewService = 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("[review] 봇 의심 요청을 버렸다")
return RemoveNoneResponse(ResReview(success=True, message="후기를 남겨 주셔서 고맙습니다."))
if not body.consent:
return RemoveNoneResponse(ResReview(success=False, message="공개에 동의해 주세요."))
if not _allow(f"review-ip:{client_ip}", IP_LIMIT_PER_HOUR) or \
not _allow(f"review-place:{body.place_id}", PLACE_LIMIT_PER_HOUR):
return RemoveNoneResponse(ResReview(
success=False, message="요청이 많습니다. 잠시 뒤 다시 남겨 주세요.",
))
body.client_ip = client_ip
return RemoveNoneResponse(await service.submit(body))
@router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다")
async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()):
"""날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고,
화면은 붙은 뒤 이 주소로 최신을 받아 덮는다."""
return RemoveNoneResponse(await service.list_public(place_id))

View File

@ -0,0 +1,48 @@
"""이용 후기 검수 — 어드민 진입점(:9801)에만 붙인다."""
import uuid
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from router.v1.validator.dependencies import RemoveNoneResponse
from services.review_service import ReviewService
router = APIRouter(prefix="/v1/admin/review", tags=["Review"])
class ReviewItem(BaseModel):
review_id: str
place_id: str
place_name: str
body: str
nickname: str
status: int
created_at: str
class ResReviews(BaseModel):
items: list[ReviewItem] = []
total: int = 0
class ReqDecide(BaseModel):
review_ids: list[uuid.UUID]
publish: bool
class ResDecide(BaseModel):
changed: int = 0
@router.get(path="/list", response_model=ResReviews, summary="후기 검수 목록")
async def list_reviews(
status: int = Query(default=1, ge=1, le=3),
limit: int = Query(default=100, ge=1, le=300),
service: ReviewService = Depends(),
):
return RemoveNoneResponse(await service.list_for_review(status, limit))
@router.post(path="/decide", response_model=ResDecide, summary="후기 게재 · 반려")
async def decide(body: ReqDecide, service: ReviewService = Depends()):
return RemoveNoneResponse(await service.decide(body.review_ids, publish=body.publish))

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
from datetime import datetime, timedelta, timezone
from typing import Any, Union from typing import Any, Union
from fastapi import Depends from fastapi import Depends
@ -71,6 +72,18 @@ def CreateRefreshToken(subject: UserInfo) -> str:
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN) return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
def CreateDayPassToken(subject: UserInfo) -> str:
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.
★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만
쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로
되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다."""
now_kst = datetime.now(timezone(timedelta(hours=9)))
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60))
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, expire_min)
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo: def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
try: try:
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM]) decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])

View File

@ -54,11 +54,17 @@ def start_scheduler():
_scheduler.add_job(run_scheduled_check, "interval", minutes=10, _scheduler.add_job(run_scheduled_check, "interval", minutes=10,
id="search-console", max_instances=1, coalesce=True) id="search-console", max_instances=1, coalesce=True)
from scheduler.jobs import sweep_alert_outbox, sweep_queue_health from scheduler.jobs import sweep_alert_outbox, sweep_blog_drafts, sweep_blog_mail, sweep_queue_health
_scheduler.add_job(sweep_alert_outbox, "interval", minutes=1, _scheduler.add_job(sweep_alert_outbox, "interval", minutes=1,
id="alert-outbox", max_instances=1, coalesce=True) id="alert-outbox", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_queue_health, "interval", minutes=5, _scheduler.add_job(sweep_queue_health, "interval", minutes=5,
id="queue-health", max_instances=1, coalesce=True) id="queue-health", max_instances=1, coalesce=True)
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
# LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다.
_scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
id="blog-drafts", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
id="blog-mail", max_instances=1, coalesce=True)
_scheduler.start() _scheduler.start()
LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)") LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)")
LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)") LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")

View File

@ -57,3 +57,27 @@ async def sweep_queue_health():
) )
else: else:
await alert_service.resolve_alert(dedupe_key, "잡 큐 정상으로 돌아옴") await alert_service.resolve_alert(dedupe_key, "잡 큐 정상으로 돌아옴")
async def sweep_blog_drafts():
"""미니 블로그 재고 채우기(services/blog_jobs.generate_drafts)."""
from services import blog_jobs
try:
made = await blog_jobs.generate_drafts()
if made:
LOG.i(f"[scheduler] 미니 블로그 초안 {made}건 생성")
except Exception as ex: # noqa: BLE001 — 생성 실패가 스케줄러를 죽이면 안 된다
LOG.w(f"[scheduler] 미니 블로그 생성 실패: {type(ex).__name__}: {ex}")
async def sweep_blog_mail():
"""검수를 통과한 글을 사장님에게 보낸다(services/blog_jobs.send_reviewed)."""
from services import blog_jobs
try:
sent = await blog_jobs.send_reviewed()
if sent:
LOG.i(f"[scheduler] 미니 블로그 메일 {sent}통 발송")
except Exception as ex: # noqa: BLE001
LOG.w(f"[scheduler] 미니 블로그 발송 실패: {type(ex).__name__}: {ex}")

View File

@ -0,0 +1,294 @@
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md
★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약).
★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에
못 넣어 중복이 막히지 않는다.
★ 팀 사전검수 없음 — 금칙 필터(blog_service.filter_drafts)를 통과하면 바로 REVIEWED 로
쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다.
★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼
것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17).
"""
import uuid
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_posts, places, sites, users
from common.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.post_crud import PostCRUD
from router.v1.validator.dependencies import CreateDayPassToken
from services import blog_service, mail_service, site_payload
from services.snapshot import build_snapshot
BATCH_SIZE = 30
# 이 수보다 재고(DRAFT)가 적은 업장만 새로 만든다 — 한 달치(하루 한 통 기준 약 30일)를 채운다.
REFILL_BELOW = 30
MAIL_PER_SWEEP = 20
_KST = timezone(timedelta(hours=9))
_crud = PostCRUD()
def _today_kst() -> date:
return datetime.now(_KST).date()
async def _published_places() -> list:
"""(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인
day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다."""
def query(session):
return session.execute(
select(places, users)
.join(sites, sites.place_id == places.place_id)
.join(users, users.user_id == places.owner_user_id)
.where(
places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
return list(result.all()) if result is not None else []
async def _pending_count(place_id) -> int:
"""아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다."""
def query(session):
return session.execute(
select(place_posts.post_id).where(
place_posts.place_id == place_id,
place_posts.status.in_((PostStatus.DRAFT.value, PostStatus.REVIEWED.value)),
place_posts.deleted == False, # noqa: E712
)
)
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
return len(result.all()) if result is not None else 0
async def _generate_for_place(place) -> int:
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
if await _pending_count(place.place_id) >= REFILL_BELOW:
return 0
used = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
)
used_set = set(used or [])
snapshot = await build_snapshot(place)
region = site_payload.region_label(place.road_address, place.address)
rows = []
for kind, key, material in blog_service.materials(snapshot):
if len(rows) >= BATCH_SIZE:
break
if key in used_set:
continue
generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set),
)
if not generated:
continue
body, model = generated
rows.append({
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
"generation_meta": {"model": model},
})
used_set.add(key)
kept, dropped = blog_service.filter_drafts(rows)
if kept:
latest = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid),
)
next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst()
for offset, row in enumerate(kept):
row["scheduled_date"] = next_date + timedelta(days=offset)
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s, r=kept: _crud.add_many(s, r)],
)
if dropped:
LOG.i(f"[blog] place={place.place_id} 버린 {len(dropped)}건 — {dropped[0][1]}")
return len(kept)
async def generate_drafts() -> int:
"""재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다."""
made = 0
for place, _user in await _published_places():
made += await _generate_for_place(place)
return made
async def generate_range(place_id: str, start_date: date, end_date: date) -> dict:
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").
재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에
상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 —
매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 소재가 떨어지면(구간 안에서
더 만들 topic_key 가 없으면) 그 자리에서 멈춘다 — 못 채운 나머지는 그대로 빈 날짜로 남는다."""
place = None
for p, _user in await _published_places():
if str(p.place_id) == str(place_id):
place = p
break
if place is None:
return {"requested": 0, "created": 0}
requested = (end_date - start_date).days + 1
dates = [start_date + timedelta(days=i) for i in range(requested)]
_err, existing_rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s, pid=place.place_id: _crud.list_for_place(s, pid, start_date, end_date + timedelta(days=1)),
)
taken = {row.scheduled_date for row in existing_rows}
empty_dates = [d for d in dates if d not in taken]
if not empty_dates:
return {"requested": requested, "created": 0}
used = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
)
used_set = set(used or [])
snapshot = await build_snapshot(place)
region = site_payload.region_label(place.road_address, place.address)
rows = []
for kind, key, material in blog_service.materials(snapshot):
if len(rows) >= len(empty_dates):
break
if key in used_set:
continue
generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set),
)
if not generated:
continue
body, model = generated
ok, _reason = blog_service.is_publishable_body(body)
used_set.add(key)
if not ok:
continue
rows.append({
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
"generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value,
})
for row, d in zip(rows, empty_dates):
row["scheduled_date"] = d
if rows:
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
)
return {"requested": requested, "created": len(rows)}
async def generate_one_for_date(place_id: str, target_date: date) -> dict | None:
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시:
"개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를
지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None."""
place = None
for p, _user in await _published_places():
if str(p.place_id) == str(place_id):
place = p
break
if place is None:
return None
used = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
)
used_set = set(used or [])
snapshot = await build_snapshot(place)
region = site_payload.region_label(place.road_address, place.address)
for kind, key, material in blog_service.materials(snapshot):
if key in used_set:
continue
generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set),
)
if not generated:
continue
body, model = generated
ok, _reason = blog_service.is_publishable_body(body)
if not ok:
used_set.add(key) # 이 주제는 이번 시도에서 다시 고르지 않는다
continue
row = {
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
"scheduled_date": target_date, "generation_meta": {"model": model},
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 벌크 경로(filter_drafts)와 동일
}
inserted = await DB_SESSION_MNG.execute_lambda_write(
place_posts.DBType(), lambda s, r=row: _crud.add_one(s, r),
)
return inserted # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
return None
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str:
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version)
auto_token = CreateDayPassToken(user_info)
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}"
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
return (
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
f"{post.body}\n\n"
f"이대로 올리려면(누르면 바로 게재됩니다):\n{approve_link}\n\n"
f"고쳐서 올리려면:\n{edit_link}\n\n"
f"두 링크 모두 오늘 자정(KST)에 만료됩니다. 그 뒤엔 로그인해서 빌더 앱에서 처리해 주세요.\n"
f"— 이 메일은 Web4AI 가 자동으로 보냈습니다."
)
async def send_reviewed() -> int:
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
if not mail_service.is_configured():
return 0
_err, rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: _crud.due_for_mail(s, PostStatus.REVIEWED.value, _today_kst(), MAIL_PER_SWEEP),
)
if not rows:
return 0
targets = {str(place.place_id): (place.name, user) for place, user in await _published_places()}
origin = site_payload.publish_origin()
sent = 0
for post in rows:
target = targets.get(str(post.place_id))
if not target or not mail_service.is_valid_address(target[1].email or ""):
continue
place_name, user = target
token, token_hash, expires = blog_service.issue_token()
body = _mail_body(place_name=place_name, post=post, user=user, origin=origin, approve_token=token)
ok = mail_service.send(to=user.email, subject=f"[{place_name}] 이번 글 올릴까요?", text=body)
if not ok:
continue
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()],
[lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)],
)
sent += 1
return sent

View File

@ -0,0 +1,180 @@
"""미니 블로그 — AI 자동 포스트. 기획: docs/MINI_BLOG.md
★ 발행 게이트와 부딪히지 않게 만든다. 규칙 1(미검증 fact 는 화면에 내지 않는다)은 홍보 문구에도
그대로 걸린다 — 가격·시간·인원을 문구가 주장하면 그 주장을 뒷받침할 fact 가 없다.
프롬프트로 금지하고, 생성 뒤 `is_publishable_body()` 로 한 번 더 거른다.
★ 중복은 프롬프트가 아니라 DB 가 막는다 — (place_id, topic_key) 유니크.
"""
import hashlib
import re
import secrets
from datetime import datetime, timedelta, timezone
from common.enums import PostStatus, PostTopicKind
from common.logger import LOG
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
MIN_LEN = 120
MAX_LEN = 170
_KST = timezone(timedelta(hours=9))
# 문구가 주장하면 안 되는 것. 게이트가 잡기 전에 여기서 버린다.
_FORBIDDEN = (
re.compile(r"\d{1,3},\d{3}\s*원"), # 198,000원
re.compile(r"\d+\s*원"), # 50000원 · 3만원 은 아래에서
re.compile(r"\d+\s*만\s*원"),
re.compile(r"\d{1,2}\s*:\s*\d{2}"), # 15:00
re.compile(r"\d+\s*시\s*(\d+\s*분)?\s*(부터|까지|에)"),
re.compile(r"\d+\s*(인|명)\s*(까지|기준|이상)"),
re.compile(r"\d{2,3}-\d{3,4}-\d{4}"), # 전화번호
re.compile(r"(무료|공짜)\s*(제공|이용|주차)"),
re.compile(r"(최고|최저|1위|유일)"), # 근거를 못 대는 최상급
)
def is_publishable_body(text: str) -> tuple[bool, str]:
"""(통과 여부, 사유). 사유는 로그·검수 화면에 그대로 쓴다."""
body = (text or "").strip()
if not body:
return False, "빈 글"
if len(body) < MIN_LEN or len(body) > MAX_LEN:
return False, f"길이 {len(body)}자 — {MIN_LEN}~{MAX_LEN} 밖"
for pattern in _FORBIDDEN:
hit = pattern.search(body)
if hit:
return False, f"확인되지 않은 주장: {hit.group(0)}"
return True, ""
def hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def issue_token() -> tuple[str, str, object]:
"""(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다.
★ 승인·수정 두 링크 다 그날까지만 산다(2026-09-17, 사장님 지시: "승인이랑 수정모두
자정에 만료"). 그 뒤로는 로그인해서 빌더 앱에서 처리한다 — 메일 링크는 "오늘 온 것을
오늘 처리하라"는 뜻이지 보관함이 아니다."""
token = secrets.token_urlsafe(32)
now_kst = datetime.now(_KST)
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
expires = midnight_kst.astimezone(timezone.utc).replace(tzinfo=None)
return token, hash_token(token), expires
TOPIC_RULES: dict[int, str] = {
PostTopicKind.WEATHER.value:
"오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.",
PostTopicKind.FESTIVAL.value:
"주어진 축제 하나를 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.",
PostTopicKind.SEASON.value:
"지금 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.",
PostTopicKind.NEARBY.value:
"주어진 주변 장소 하나를 손님 시선에서 적는다. 영업시간과 가격은 쓰지 않는다.",
PostTopicKind.GUIDE.value:
"확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.",
}
_RULES = (
"규칙\n"
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n"
"- 숫자로 된 요금·시간·인원·전화번호를 쓰지 않는다. 확인되지 않은 주장을 하지 않는다.\n"
"- '최고' '유일' 같은 최상급을 쓰지 않는다.\n"
"- 손님에게 말하듯 존댓말로 적는다.\n"
"- 아래 '이미 쓴 주제'와 겹치는 소재를 고르지 않는다.\n"
)
def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str]) -> str:
"""갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다."""
used = ", ".join(used_topics[:40]) or "없음"
return (
f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n"
f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n"
f"소재: {material}\n"
f"이미 쓴 주제: {used}\n\n"
f"{_RULES}\n본문만 출력한다."
)
def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]:
"""(통과한 것, 버린 것[(본문앞부분, 사유)]). 버린 이유를 세어 프롬프트를 고칠 근거로 남긴다."""
kept, dropped = [], []
seen_keys = set()
for row in rows:
ok, reason = is_publishable_body(row.get("body", ""))
key = (row.get("topic_key") or "").strip()
if not ok:
dropped.append((row.get("body", "")[:24], reason))
continue
if not key:
dropped.append((row.get("body", "")[:24], "주제 키가 없다"))
continue
if key in seen_keys:
dropped.append((row.get("body", "")[:24], f"같은 회차에서 주제 중복: {key}"))
continue
seen_keys.add(key)
# 팀 사전검수 없음 — 금칙 필터를 통과하면 그대로 발송 대상이다.
kept.append({**row, "topic_key": key, "status": PostStatus.REVIEWED.value})
if dropped:
LOG.i(f"[blog] 생성분 {len(rows)}건 중 {len(dropped)}건 버림")
return kept, dropped
async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str,
used_topics: list[str], client=None) -> tuple[str, str] | None:
"""(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시)."""
from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured
if not is_configured():
return None
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
material=material, used_topics=used_topics)
body = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.9},
}
owns = client is None
if owns:
import httpx
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try:
payload = await call(client, DEFAULT_MODEL, body)
text = extract_text(payload).strip()
return (text, DEFAULT_MODEL) if text else None
except GeminiError as error:
LOG.w(f"[blog] 생성 실패: {error}")
return None
finally:
if owns:
await client.aclose()
def materials(snapshot: dict) -> list[tuple[int, str, str]]:
"""(갈래, topic_key, 소재). 소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다."""
local = snapshot.get("local") or {}
out: list[tuple[int, str, str]] = []
for festival in (local.get("festivals") or [])[:12]:
name = (festival.get("name") or "").strip()
if name:
out.append((PostTopicKind.FESTIVAL.value, f"festival:{name}",
f"{name} — {festival.get('location') or ''} {festival.get('description') or ''}".strip()))
for spot in ((local.get("attractions") or []) + (local.get("restaurants") or []))[:20]:
name = (spot.get("name") or "").strip()
if name:
out.append((PostTopicKind.NEARBY.value, f"nearby:{name}",
f"{name} — {spot.get('description') or spot.get('address') or ''}".strip()))
for sky in ("맑음", "흐림", "비", "눈", "안개", "소나기"):
out.append((PostTopicKind.WEATHER.value, f"weather:{sky}", f"{sky}인 날"))
for term in ("봄", "초여름", "한여름", "초가을", "늦가을", "초겨울", "한겨울"):
out.append((PostTopicKind.SEASON.value, f"season:{term}", term))
return out

View File

@ -0,0 +1,86 @@
"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다.
★ 저장하지 않는다. place_id 로 받는 사람만 찾고, 나머지는 전부 메일 본문으로 나간다.
★ 발행된 사이트의 업장만 받는다 — place_id 를 손으로 바꿔 아무 업장에나 메일을 쏘는 길을 막는다.
"""
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import places, sites, users
from common.enums import DBWRType, SiteStatus
from common.logger import LOG
from services import mail_service
NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요."
SENT = "예약 요청을 보냈습니다. 사장님이 확인 후 연락드립니다."
class BookingRequestService:
async def send(self, body):
from router.v1.site.booking_request import ResBookingRequest
target = await self._target(body.place_id)
if not target:
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
place_name, owner_email = target
if not mail_service.is_configured() or not mail_service.is_valid_address(owner_email):
LOG.w("[booking-request] 받는 주소나 SMTP 설정이 없어 전달하지 못했다")
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
reply_to = body.email if body.email and mail_service.is_valid_address(body.email) else None
ok = mail_service.send(
to=owner_email,
subject=f"[예약 요청] {place_name} — {body.name} 님",
text=_body(place_name, body),
reply_to=reply_to,
)
if not ok:
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
LOG.i(f"[booking-request] 전달 완료 place={body.place_id}")
return ResBookingRequest(success=True, message=SENT)
async def _target(self, place_id) -> tuple[str, str] | None:
"""(상호명, 사장님 이메일). 발행된 사이트가 없으면 None."""
def query(session):
return session.execute(
select(places.name, users.email)
.join(sites, sites.place_id == places.place_id)
.join(users, users.user_id == places.owner_user_id)
.where(
places.place_id == place_id,
places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
if not row or not row[1]:
return None
return str(row[0] or ""), str(row[1])
def _body(place_name: str, body) -> str:
lines = [
f"{place_name} 예약 요청이 도착했습니다.",
"",
f"성함 {body.name}",
f"연락처 {body.phone}",
]
if body.email:
lines.append(f"이메일 {body.email}")
if body.stay:
lines.append(f"일정 {body.stay}")
if body.guests:
lines.append(f"인원 {body.guests}")
if body.message:
lines += ["", "요청사항", body.message.strip()]
lines += [
"",
"— 이 메일은 발행 사이트의 예약 요청 폼에서 자동으로 보냈습니다.",
" 예약이 확정된 것은 아니며, 손님에게 직접 연락하셔야 합니다.",
]
return "\n".join(lines)

View File

@ -14,7 +14,9 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites from common.database.model.models import (
place_channels, place_posts, place_reviews, places, site_publish_logs, site_versions, sites,
)
from common.enums import ( from common.enums import (
BuildStatus, BuildStatus,
DBWRType, DBWRType,
@ -29,6 +31,7 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD
from services import ( from services import (
alert_service, alert_service,
azure_static, azure_static,
@ -47,6 +50,24 @@ from common.job_errors import PermanentJobError
_site_crud = SiteCRUD() _site_crud = SiteCRUD()
_place_crud = PlaceCRUD() _place_crud = PlaceCRUD()
_post_crud = PostCRUD()
async def _stamp_reviews(session, place_id, version_id):
from sqlalchemy import update
from common.enums import ReviewStatus
await session.execute(
update(place_reviews)
.where(
place_reviews.place_id == place_id,
place_reviews.status == ReviewStatus.PUBLISHED.value,
place_reviews.published_version_id.is_(None),
)
.values(published_version_id=version_id)
)
return ErrorType.SUCCESS
# 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함). # 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함).
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을 # ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을
@ -394,6 +415,16 @@ async def run_build(job: dict) -> dict:
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value} s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
), ),
) )
# 승인된 미니 블로그 글은 이 굽기에 실렸다 — 이제 게재로 넘긴다(docs/MINI_BLOG.md).
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()],
[lambda s: _post_crud.mark_published(s, uuid.UUID(place_id), version.site_version_id)],
)
# 검수를 통과한 후기도 이 버전에 실렸다 — 어느 굽기에 들어갔는지 남긴다.
await DB_SESSION_MNG.execute_lambda_run(
[place_reviews.DBType()],
[lambda s: _stamp_reviews(s, uuid.UUID(place_id), version.site_version_id)],
)
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None, await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
payload.get("requested_by")) payload.get("requested_by"))
site.status = SiteStatus.PUBLISHED.value site.status = SiteStatus.PUBLISHED.value

View File

@ -11,6 +11,7 @@
어디를 봐야 할지가 파일 안에서 갈리지 않았다. 어디를 봐야 할지가 파일 안에서 갈리지 않았다.
""" """
import hashlib import hashlib
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
@ -24,6 +25,10 @@ from services.llm.errors import LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
# generate_social_post 는 공급자 선택(provider.active())을 타지 않고 Gemini 를 직접 부른다 —
# 구조화 출력 재시도 루프(피드백 프롬프트로 다시 묻기)가 Gemini REST 응답 모양에 맞춰져 있다.
# 모듈 최상단에서 이름으로 들여와야 테스트가 gemini_text.call 을 monkeypatch 할 수 있다.
from services.llm.gemini import DEFAULT_MODEL as DEFAULT_TEXT_MODEL, call, extract_text
def is_configured() -> bool: def is_configured() -> bool:

View File

@ -0,0 +1,139 @@
"""메일 한 통을 보낸다 — Azure Communication Services 우선, SMTP 폴백.
★ 설정이 없으면 보내지 않고 False 를 돌려준다(teams_webhook 과 같은 규약). 값이 없어도 서버는 뜬다.
★ 이 파일은 "메일 한 통 보내기" 만 안다 — 무엇을 언제 보낼지는 부르는 쪽이 정한다.
★ ACS 를 1순위로 두는 이유: 회사가 이미 공용 리소스를 쓰고 있고(negodata), 발신 도메인의
SPF·DKIM 을 그쪽이 관리한다. SMTP 는 그 리소스를 못 쓰는 환경의 폴백이다.
"""
import os
import re
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr, parseaddr
from common.logger import LOG
ACS_ENDPOINT_ENV = "ACS_EMAIL_ENDPOINT"
ACS_KEY_ENV = "ACS_EMAIL_ACCESSKEY"
ACS_SENDER_ENV = "ACS_EMAIL_SENDER"
HOST_ENV = "SMTP_HOST"
PORT_ENV = "SMTP_PORT"
USER_ENV = "SMTP_USER"
PASSWORD_ENV = "SMTP_PASSWORD"
FROM_ENV = "SMTP_FROM"
FROM_NAME_ENV = "SMTP_FROM_NAME"
TLS_ENV = "SMTP_TLS"
TIMEOUT_SEC = 15.0
DEFAULT_FROM_NAME = "Web4AI"
def is_configured() -> bool:
return _acs_configured() or _smtp_configured()
def _acs_configured() -> bool:
return bool(_env(ACS_ENDPOINT_ENV) and _env(ACS_KEY_ENV) and _env(ACS_SENDER_ENV))
def _smtp_configured() -> bool:
return bool(_env(HOST_ENV) and _sender())
def _env(name: str, default: str = "") -> str:
return os.environ.get(name, default).strip()
def _sender() -> str:
return _env(FROM_ENV) or _env(USER_ENV)
def _port() -> int:
try:
return int(_env(PORT_ENV) or "587")
except ValueError:
return 587
def _mode() -> str:
"""starttls | ssl | plain. 기본은 starttls(587)이고 465 는 ssl 로 떨어진다."""
value = _env(TLS_ENV).lower()
if value in {"ssl", "starttls", "plain"}:
return value
return "ssl" if _port() == 465 else "starttls"
def is_valid_address(value: str) -> bool:
address = parseaddr(value or "")[1]
return bool(re.fullmatch(r"[^@\s]+@[^@\s.]+(\.[^@\s.]+)+", address))
def _send_acs(*, to: str, subject: str, text: str, reply_to: str | None) -> bool:
try:
from azure.communication.email import EmailClient
client = EmailClient.from_connection_string(
f"endpoint={_env(ACS_ENDPOINT_ENV)};accesskey={_env(ACS_KEY_ENV)}"
)
message = {
"senderAddress": _env(ACS_SENDER_ENV),
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text},
}
if reply_to:
message["replyTo"] = [{"address": reply_to}]
client.begin_send(message).result()
return True
except Exception as error: # noqa: BLE001 — 폴백(SMTP)이 남아 있다
LOG.e(f"[mail] ACS 발송 실패: {type(error).__name__}")
return False
def send(*, to: str, subject: str, text: str, reply_to: str | None = None) -> bool:
"""보냈으면 True. 설정이 없거나 실패하면 False — 예외를 밖으로 던지지 않는다.
★ 손님이 누른 버튼 하나가 메일 서버 장애로 500 이 되면 안 된다. 부르는 쪽이 False 를 보고
"전달하지 못했다"를 손님 화면의 말로 바꾼다."""
if not is_configured():
LOG.w("[mail] SMTP 미설정 — 보내지 않는다")
return False
if not is_valid_address(to):
LOG.w("[mail] 받는 주소가 형식에 맞지 않는다")
return False
if _acs_configured():
if _send_acs(to=to, subject=subject, text=text, reply_to=reply_to):
return True
if not _smtp_configured():
return False
if not _smtp_configured():
return False
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((_env(FROM_NAME_ENV) or DEFAULT_FROM_NAME, _sender()))
message["To"] = to
if reply_to and is_valid_address(reply_to):
message["Reply-To"] = reply_to
message.set_content(text)
host, port, mode = _env(HOST_ENV), _port(), _mode()
user, password = _env(USER_ENV), _env(PASSWORD_ENV)
try:
if mode == "ssl":
client = smtplib.SMTP_SSL(host, port, timeout=TIMEOUT_SEC, context=ssl.create_default_context())
else:
client = smtplib.SMTP(host, port, timeout=TIMEOUT_SEC)
with client:
if mode == "starttls":
client.starttls(context=ssl.create_default_context())
if user and password:
client.login(user, password)
client.send_message(message)
return True
except Exception as error: # noqa: BLE001 — 발송 실패를 호출측 실패로 만들지 않는다
LOG.e(f"[mail] 발송 실패: {type(error).__name__}")
return False

View File

@ -0,0 +1,302 @@
"""미니 블로그 승인 처리. 기획: docs/MINI_BLOG.md
★ 메일 링크는 소유권 검사가 토큰 하나다. 그래서 토큰으로 할 수 있는 일을 한 건의 게재로
못 박는다 — post_id 를 바꿔 넣을 자리가 없고(토큰 해시로 글을 찾는다), 다른 API 를
부르지도 못한다.
★ 빌더 앱 로그인 화면(list_for_owner/edit_by_owner)은 반대로 세션이 신원이다 — place_id 가
그 사장님 소유인지를 매번 PlaceCRUD.get_place 로 확인한다.
"""
import uuid
from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
from crud.job_crud import JobQueue
from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import jobs as jobs_table, place_posts, places
from common.enums import DBWRType, ErrorType, JobStatus, JobType, PostStatus
from common.logger import LOG
from common.models.gmodel import Res_WebPacketProtocol, UserInfo
from common.utils.gtime import GTime
from router.v1.site.protocol import (
GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
)
from services import blog_jobs, blog_service
from services.job_service import enqueue_job
EXPIRED = "처리할 수 없는 링크입니다."
APPROVED = "올렸습니다."
SKIPPED = "이번 글은 넘겼습니다."
_KST = timezone(timedelta(hours=9))
def _month_range(month: str | None) -> tuple[date, date]:
""""YYYY-MM"(KST 기준, 없으면 이번 달) → 날짜 경계 [시작, 다음달 시작). scheduled_date 가
타임존 없는 순수 DATE 라 KST 로 자른 뒤 다시 UTC 로 바꿀 필요가 없다."""
now_kst = datetime.now(_KST)
year, mon = (int(part) for part in month.split("-")) if month else (now_kst.year, now_kst.month)
start = date(year, mon, 1)
end = date(year + (mon == 12), mon % 12 + 1, 1)
return start, end
class PostService:
def __init__(self):
self.crud = PostCRUD()
self.place_crud = PlaceCRUD()
self.queue = JobQueue()
async def find_by_token(self, token: str):
"""살아 있는 토큰이면 글, 아니면 None. 만료와 이미 처리됨을 구분하지 않는다 —
둘 다 손님(사장님)에게는 '못 쓰는 링크' 하나다."""
token_hash = blog_service.hash_token(token)
post = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_token_hash(s, token_hash),
)
if not post or post.status != PostStatus.SENT.value:
return None
if post.token_expires_at and post.token_expires_at.replace(tzinfo=None) < GTime.UTC():
return None
return post
async def decide(self, token: str, *, skip: bool) -> dict:
post = await self.find_by_token(token)
if not post:
return {"success": False, "message": EXPIRED}
post_id, place_id = post.post_id, post.place_id
if skip:
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.skip(s, post_id)],
)
return {"success": True, "message": SKIPPED}
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.approve(s, post_id)],
)
await self._enqueue_build(post_id, place_id)
return {"success": True, "message": APPROVED}
async def _load_place(self, user_info: UserInfo, place_id: str):
err_type, place = await DB_SESSION_MNG.execute_lambda(
places.DBType(), DBWRType.DB_READ.value,
lambda s: self.place_crud.get_place(s, uuid.UUID(user_info.user_id), uuid.UUID(place_id)),
)
if err_type != ErrorType.SUCCESS:
return ErrorType.PLACE_NOT_FOUND, None
return ErrorType.SUCCESS, place
async def list_for_owner(self, user_info: UserInfo, place_id: str, month: str | None) -> Res_MyPosts:
"""빌더 앱 — 이번 달(또는 고른 달) 생성된 글 전체. 소유 아니면 빈 목록으로 끝낸다."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
since, until = _month_range(month)
res.posts = await self._fetch_posts(place_id, since, until)
return res
async def list_upcoming(self, user_info: UserInfo, place_id: str, days: int = 7) -> Res_MyPosts:
"""빌더 앱 상단 카로셀 — 오늘부터 days 일치, 날짜 오름차순. 달력(월 단위)과 별개로
"당장 챙길 것"만 보여준다(2026-09-17, 사장님 지시)."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
today = datetime.now(_KST).date()
res.posts = await self._fetch_posts(place_id, today, today + timedelta(days=days))
res.posts.sort(key=lambda post: post.scheduled_date or date.max)
return res
async def get_post(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_MyPosts:
"""메일 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다. 다른 업장
글이면(place_id 불일치) 빈 목록으로 끝낸다 — day-pass 토큰 소유자와 업장이
어긋나면 그 링크로 남의 글을 못 보게 한다."""
res = Res_MyPosts()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
row = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, uuid.UUID(post_id)),
)
if row is not None and str(row.place_id) == str(place_id):
res.posts = [PostData.model_validate(row)]
return res
async def generation_history(self, user_info: UserInfo, place_id: str) -> Res_GenerationHistory:
"""생성 이력 — 언제 몇 건 만들었는지(2026-09-17, 사장님 지시)."""
res = Res_GenerationHistory()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.generation_batches(s, uuid.UUID(place_id)),
)
res.batches = [
GenerationBatch(created_at=created_at, count=count, model=model)
for created_at, count, model in (rows or [])
]
return res
async def _fetch_posts(self, place_id: str, since: date, until: date) -> list[PostData]:
_err, rows = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.list_for_place(s, uuid.UUID(place_id), since, until),
)
posts = [PostData.model_validate(row) for row in (rows or [])]
# 승인됐는데 아직 안 나간 글이 있을 때만 잡을 들여다본다 — 화면은 발행완료/발행실패만
# 보여주면 되고(사장님 지시), 그 판정에 필요한 만큼만 조회한다.
if any(post.status == PostStatus.APPROVED.value for post in posts):
if await self._latest_build_failed(place_id):
for post in posts:
if post.status == PostStatus.APPROVED.value:
post.build_failed = True
return posts
async def _latest_build_failed(self, place_id) -> bool:
"""이 업장의 가장 최근 BUILD 잡이 dead-letter 로 끝났는가.
★ BUILD 잡 하나가 그 업장의 승인분 전부를 한 번에 굽는다 — 글 단위 성공/실패가
아니라 "이 업장 재발행이 지금 막혀 있나"를 본다."""
def query(session):
return session.execute(
select(jobs_table.status)
.where(
jobs_table.job_type == JobType.BUILD.value,
jobs_table.payload["place_id"].astext == str(place_id),
)
.order_by(jobs_table.created_at.desc())
.limit(1)
)
result = await DB_SESSION_MNG.execute_lambda(jobs_table.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
return bool(row) and row[0] == JobStatus.DEAD.value
async def edit_by_owner(
self, user_info: UserInfo, place_id: str, post_id: str, body: str
) -> Res_WebPacketProtocol:
"""로그인 세션으로 직접 고치기 — 메일이 아직 안 나간 REVIEWED 글도 여기서 바로 승인된다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
ok, reason = blog_service.is_publishable_body(body)
if not ok:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = reason
return res
pid = uuid.UUID(post_id)
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.update_body(s, pid, body)],
)
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
)
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = APPROVED
return res
async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
"""로그인 세션으로 바로 발행 — 고치지 않고 그대로. edit_by_owner 와 승인·빌드 경로는 같다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
pid = uuid.UUID(post_id)
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
)
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = APPROVED
return res
async def generate_range(self, user_info: UserInfo, place_id: str, start_date: date, end_date: date) -> Res_GenerateNow:
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까")."""
res = Res_GenerateNow()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
if end_date < start_date:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "끝 날짜가 시작 날짜보다 앞설 수 없습니다."
return res
outcome = await blog_jobs.generate_range(place_id, start_date, end_date)
res.requested = outcome["requested"]
res.created = outcome["created"]
if res.created == res.requested:
res.msg = f"{res.created}건 만들었습니다."
elif res.created:
res.msg = f"{res.requested}일 중 {res.created}일만 채웠습니다 — 나머지는 새로 쓸 소재가 없습니다."
else:
res.msg = "이 구간엔 만들지 못했습니다 — 이미 다 있거나, 새로 쓸 소재가 없습니다."
return res
async def generate_for_date(self, user_info: UserInfo, place_id: str, target_date: date) -> Res_GenerateOne:
"""개별 생성 — 달력에서 빈 날짜 하나를 콕 집어 채운다(2026-09-17, 사장님 지시:
"개별적으로 새로 만들수있게 해줘")."""
res = Res_GenerateOne()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
row = await blog_jobs.generate_one_for_date(place_id, target_date)
if row is None:
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = "이 날짜엔 만들지 못했습니다 — 이미 글이 있거나, 새로 쓸 만한 소재가 없습니다."
return res
res.post = PostData.model_validate(row)
res.msg = "만들었습니다."
return res
async def _owner_user_id(self, place_id):
def query(session):
return session.execute(select(places.owner_user_id).where(places.place_id == place_id))
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
row = result.first() if result is not None else None
return row[0] if row else None
async def _enqueue_build(self, post_id, place_id) -> None:
"""게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md).
★ owner_user_id 없이 넣으면 build_service.run_build 가 payload["owner_user_id"] 를
그대로 읽다 KeyError 로 죽는다 — 정상 발행 경로(site_service.py)는 로그인 세션에서
채우지만, 이 경로는 토큰뿐이라 place 에서 직접 찾아야 한다."""
owner_user_id = await self._owner_user_id(place_id)
if owner_user_id is None:
LOG.w(f"[blog] place={place_id} owner_user_id 를 못 찾아 재발행 잡을 만들지 않는다")
return
job_id, _created = await enqueue_job(
self.queue, JobType.BUILD,
{"place_id": str(place_id), "owner_user_id": str(owner_user_id),
"publish": True, "requested_by": "blog-approval"},
dedupe_key=f"build:{place_id}",
)
LOG.i(f"[blog] post={post_id} → build job={job_id}")

View File

@ -0,0 +1,182 @@
"""이용 후기 — 손님이 남기면 **그 자리에서 뜬다**.
★ 정적 사이트인데도 즉시 보이는 방법은 날씨(useLiveWeather)가 이미 쓰는 것이다 —
구운 HTML 에는 굽는 시점까지의 후기가 들어가고, 브라우저가 붙은 뒤 최신 목록을 한 번 더
받아 덮는다. 크롤러는 구운 것을 읽고 손님은 최신을 본다.
★ 그래서 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 대신 기계가
거른다 — 길이·연락처. 문제 글은 어드민에서 **내린다**(사후 대응).
★ 사진은 받지 않는다. 받는 순간 남의 파일을 우리가 호스팅한다.
★ JSON-LD 로 내보내지 않는다 — 자체 수집 후기는 구글 리치결과 대상이 아니다.
"""
import hashlib
import os
import re
from sqlalchemy import select, update
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_reviews, places, sites
from common.enums import DBWRType, ErrorType, ReviewStatus, SiteStatus
from common.logger import LOG
from common.utils.gtime import GTime
MIN_LEN = 10
MAX_LEN = 500
NOT_AVAILABLE = "지금은 후기를 받을 수 없습니다."
RECEIVED = "후기를 남겨 주셔서 고맙습니다."
TOO_SHORT = f"{MIN_LEN}자 이상 적어 주세요."
# 연락처가 본문에 섞여 들어오는 것을 막는다 — 받지 않기로 한 값이 글로 들어오면 결국 보관하게 된다.
_CONTACT = (
re.compile(r"\d{2,3}-\d{3,4}-\d{4}"),
re.compile(r"01[016789][-\s]?\d{3,4}[-\s]?\d{4}"),
re.compile(r"[^@\s]+@[^@\s]+\.[A-Za-z]{2,}"),
)
def _ip_hash(ip: str) -> str:
salt = os.environ.get("JWT_ACCESS_SECRET", "")
return hashlib.sha256(f"{salt}{ip}".encode("utf-8")).hexdigest()
def check_body(text: str) -> tuple[bool, str]:
body = (text or "").strip()
if len(body) < MIN_LEN:
return False, TOO_SHORT
if len(body) > MAX_LEN:
return False, f"{MAX_LEN}자까지 적을 수 있습니다."
for pattern in _CONTACT:
if pattern.search(body):
return False, "전화번호·이메일은 적지 말아 주세요. 공개되는 글입니다."
return True, ""
class ReviewService:
async def submit(self, body):
from router.v1.site.review import ResReview
ok, reason = check_body(body.body)
if not ok:
return ResReview(success=False, message=reason)
if not await self._is_published(body.place_id):
return ResReview(success=False, message=NOT_AVAILABLE)
nickname = (body.nickname or "").strip()[:40] or None
text_body = body.body.strip()
ip_hash = _ip_hash(body.client_ip or "")
async def run(session):
session.add(place_reviews(
place_id=body.place_id, body=text_body, nickname=nickname,
status=ReviewStatus.PUBLISHED.value, published_at=GTime.UTC(),
submitted_ip_hash=ip_hash,
))
return ErrorType.SUCCESS
await DB_SESSION_MNG.execute_lambda_run([place_reviews.DBType()], [run])
LOG.i(f"[review] 접수 place={body.place_id}")
return ResReview(success=True, message=RECEIVED)
async def _is_published(self, place_id) -> bool:
def query(session):
return session.execute(
select(sites.site_id).join(places, places.place_id == sites.place_id).where(
sites.place_id == place_id,
sites.deleted == False, # noqa: E712
places.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value,
)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
return bool(result and result.first())
async def list_public(self, place_id, limit: int = 200):
"""화면이 붙은 뒤 받아 가는 최신 목록. 내려간 글은 여기서 빠진다."""
from router.v1.site.review import PublicReview, ResPublicReviews
def query(session):
return session.execute(
select(place_reviews)
.where(
place_reviews.place_id == place_id,
place_reviews.status == ReviewStatus.PUBLISHED.value,
place_reviews.deleted == False, # noqa: E712
)
.order_by(place_reviews.published_at.desc())
.limit(limit)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
rows = list(result.scalars()) if result is not None else []
res = ResPublicReviews()
res.items = [
PublicReview(
reviewId=str(row.review_id),
body=row.body,
nickname=row.nickname or "",
publishedAt=row.published_at.isoformat() if row.published_at else "",
)
for row in rows
]
return res
async def list_for_review(self, status: int, limit: int):
from router.v1.site.review_admin import ResReviews, ReviewItem
def query(session):
return session.execute(
select(place_reviews, places.name)
.join(places, places.place_id == place_reviews.place_id)
.where(place_reviews.status == status, place_reviews.deleted == False) # noqa: E712
.order_by(place_reviews.created_at)
.limit(limit)
)
result = await DB_SESSION_MNG.execute_lambda(
place_reviews.DBType(), DBWRType.DB_READ.value, query
)
rows = list(result.all()) if result is not None else []
res = ResReviews(total=len(rows))
res.items = [
ReviewItem(
review_id=str(review.review_id),
place_id=str(review.place_id),
place_name=name or "",
body=review.body,
nickname=review.nickname or "",
status=int(review.status),
created_at=review.created_at.isoformat() if review.created_at else "",
)
for review, name in rows
]
return res
async def decide(self, review_ids, *, publish: bool):
from router.v1.site.review_admin import ResDecide
ids = list(review_ids)
if not ids:
return ResDecide(changed=0)
target = ReviewStatus.PUBLISHED.value if publish else ReviewStatus.REJECTED.value
async def run(session):
# 올리기도 내리기도 여기서 한다 — 사후 대응이라 상태를 가리지 않는다.
await session.execute(
update(place_reviews)
.where(
place_reviews.review_id.in_(ids),
place_reviews.deleted == False, # noqa: E712
)
.values(status=target, published_at=GTime.UTC() if publish else None,
updated_at=GTime.UTC())
)
return ErrorType.SUCCESS
await DB_SESSION_MNG.execute_lambda_run([place_reviews.DBType()], [run])
return ResDecide(changed=len(ids))

View File

@ -511,21 +511,19 @@ def _weather_condition(code) -> str:
return "흐림" return "흐림"
if value == 0: if value == 0:
return "맑음" return "맑음"
if 1 <= value <= 3: if 1 <= value <= 2:
return "구름많음" return "구름많음"
if value == 3:
return "흐림"
if value in (45, 48): if value in (45, 48):
return "안개" return "안개"
if value in (51, 53, 55): if 51 <= value <= 57:
return "이슬비" return "이슬비"
if value in (56, 57, 66, 67): if 61 <= value <= 67:
return "어는비"
if value in (61, 63):
return "비" return "비"
if value == 65: if 71 <= value <= 77 or value in (85, 86):
return "강한비"
if 70 <= value <= 79:
return "눈" return "눈"
if value in (80, 81, 82, 85, 86): if 80 <= value <= 82:
return "소나기" return "소나기"
if 95 <= value <= 99: if 95 <= value <= 99:
return "뇌우" return "뇌우"
@ -1012,6 +1010,28 @@ def to_site_payload(place, snapshot: dict, site, version, links, publish: bool =
for row in (snapshot.get("songs") or []) for row in (snapshot.get("songs") or [])
if (row.get("file_name") or "").strip() if (row.get("file_name") or "").strip()
], ],
# 미니 블로그 — 숙소 소개 아래 게시판(docs/MINI_BLOG.md). 글 전부가 HTML 에 들어가고
# 화면은 JS 로 나눠 보여준다. 페이지를 눌러 더 불러오면 크롤러가 2페이지를 못 본다.
"posts": [
{
"postId": row["post_id"],
"body": row["body"],
"publishedAt": row["published_at"],
}
for row in (snapshot.get("posts") or [])
if (row.get("body") or "").strip()
],
# 이용 후기 — 별점 없이 글만(2026-09-16 회의). JSON-LD 로는 내보내지 않는다.
"reviews": [
{
"reviewId": row["review_id"],
"body": row["body"],
"nickname": row.get("nickname") or "",
"publishedAt": row["published_at"],
}
for row in (snapshot.get("reviews") or [])
if (row.get("body") or "").strip()
],
"socialPosts": [{"postId": r["post_id"], "provider": r["provider"], "body": r["body"], "socialPosts": [{"postId": r["post_id"], "provider": r["provider"], "body": r["body"],
"postedAt": r["posted_at"], "permalink": r.get("permalink")} "postedAt": r["posted_at"], "permalink": r.get("permalink")}
for r in snapshot.get("social_posts", [])], for r in snapshot.get("social_posts", [])],

View File

@ -52,10 +52,16 @@ RESERVED_SLUGS = frozenset({
# payload 가 없어 프리렌더가 굽지 않는 자리인데, 사장님이 이 주소로 발행하면 # payload 가 없어 프리렌더가 굽지 않는 자리인데, 사장님이 이 주소로 발행하면
# **payload 가 생기는 순간** 덮여서 유일본이 영영 사라진다(AGENTS.md ★★ 항목). # **payload 가 생기는 순간** 덮여서 유일본이 영영 사라진다(AGENTS.md ★★ 항목).
# 실측(2026-09-15): `stay` 는 주인이 있어 우연히 TAKEN 이었을 뿐이고 # 실측(2026-09-15): `stay` 는 주인이 있어 우연히 TAKEN 이었을 뿐이고
# `stay2` · `stay3` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다. # `stay2` · `stay3` 는 주인도 예약도 없어 "쓸 수 있다" 로 나갔다 — 이 판단이 틀렸다.
# 실측(2026-09-18): `stay2` 는 이미 버터브루가 쓰고 있는 라이브 사이트였다(payload
# `stay2.json`, 2026-09-14 발행). 여기 예약은 **새 배정만** 막지 이미 있던 배정은
# 못 되돌린다 — 그 상태에서 목업 배포 스크립트가 같은 자리에 손으로 덮어써 버터브루의
# 실제 사이트가 시연용 목업으로 바뀌었다(백업에서 복구). 그래서 예약어를 추가할 때는
# "지금 비어 있다"만 보지 말고 `solution/site/payloads/<slug>.json` 로 실제 배정
# 여부를 먼저 확인한다.
# ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 — # ★ 굽기 쪽 보호(prerender PROTECTED_SLUGS)와 겹쳐 두는 것이지 대신하는 게 아니다 —
# 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다. # 저쪽은 이미 나간 발행을 막고, 여기는 애초에 고르지 못하게 한다.
"stay", "stay2", "stay3", "stay", "stay2", "stay3", "stay4", "stay5", "stay6",
}) })

View File

@ -23,7 +23,7 @@ from sqlalchemy import or_, select
from common.category_schema import get_schema from common.category_schema import get_schema
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import ( from common.database.model.models import (
place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_songs, place_units, place_facts, place_faqs, area_contents, place_photos, place_area_refs, place_posts, place_reviews, place_songs, place_units,
site_sections, sites, place_social_posts, site_sections, sites, place_social_posts,
) )
from common.enums import ( from common.enums import (
@ -36,6 +36,8 @@ from common.enums import (
LocalSource, LocalSource,
MediaStatus, MediaStatus,
PlaceCategory, PlaceCategory,
PostStatus,
ReviewStatus,
SongStatus, SongStatus,
) )
from common.logger import LOG from common.logger import LOG
@ -101,6 +103,24 @@ async def build_snapshot(place) -> dict:
).order_by(place_songs.created_at.desc()).limit(1) ).order_by(place_songs.created_at.desc()).limit(1)
) )
# 미니 블로그 — 사장님이 승인한 글과 이미 게재된 글. 승인분은 이번 굽기에 처음 실린다.
post_rows = await _select(
select(place_posts).where(
place_posts.place_id == pid,
place_posts.deleted == False, # noqa: E712
place_posts.status.in_((PostStatus.APPROVED.value, PostStatus.PUBLISHED.value)),
).order_by(place_posts.created_at.desc()).limit(200)
)
# 이용 후기 — 검수를 통과한 것만. 대기·반려는 발행본에 나가지 않는다.
review_rows = await _select(
select(place_reviews).where(
place_reviews.place_id == pid,
place_reviews.deleted == False, # noqa: E712
place_reviews.status == ReviewStatus.PUBLISHED.value,
).order_by(place_reviews.published_at.desc()).limit(200)
)
social_rows = await _select(select(place_social_posts).where( social_rows = await _select(select(place_social_posts).where(
place_social_posts.place_id == pid, place_social_posts.deleted == False, # noqa: E712 place_social_posts.place_id == pid, place_social_posts.deleted == False, # noqa: E712
place_social_posts.status == 'POSTED', place_social_posts.posted_at.is_not(None), place_social_posts.status == 'POSTED', place_social_posts.posted_at.is_not(None),
@ -196,11 +216,31 @@ async def build_snapshot(place) -> dict:
for r in song_rows for r in song_rows
if (r.file_name or "").strip() if (r.file_name or "").strip()
], ],
# ★ 본문과 날짜만 싣는다. 토큰·상태는 운영 값이라 발행본에 나가면 안 된다.
"posts": [
{
"post_id": str(r.post_id),
"body": r.body,
"topic_kind": int(r.topic_kind),
"published_at": (r.published_at or r.approved_at or r.created_at).isoformat(),
}
for r in post_rows
],
# ★ 손님이 적은 표시 이름만 싣는다. IP 해시·상태는 운영 값이라 발행본에 나가면 안 된다.
"reviews": [
{
"review_id": str(r.review_id),
"body": r.body,
"nickname": r.nickname or "",
"published_at": (r.published_at or r.created_at).isoformat(),
}
for r in review_rows
],
} }
LOG.i( LOG.i(
f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · " f"[snapshot] place={pid} fact {len(snapshot['facts'])} · 객실 {len(snapshot['units'])} · "
f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · " f"FAQ {len(snapshot['faqs'])} · 사진 {len(snapshot['media'])} · "
f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])}" f"지역 {len(snapshot['local']['contents'])} · 노래 {len(snapshot['songs'])} · 글 {len(snapshot['posts'])} · 후기 {len(snapshot['reviews'])}"
) )
return snapshot return snapshot

View File

@ -1,22 +1,104 @@
{ {
"sky": { "sky": {
"맑음": ["맑은 날에는 잠깐 쉬어 가며 오늘 담은 사진을 골라 보세요.", "분주했던 걸음을 멈추고 느긋하게 쉬어 가도 좋겠습니다.", "오늘 기억하고 싶은 장면을 한 줄 남겨 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"], "맑음": [
"구름많음": ["구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "여행 사진을 정리하며 잠깐 쉬어 가세요.", "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "오늘의 기분을 짧은 글로 남겨도 좋겠습니다."], "맑은 날에는 잠깐 쉬어 가며 오늘 담은 사진을 골라 보세요.",
"흐림": ["흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "잠깐 책을 읽으며 다음 일정을 기다려 보세요.", "바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."], "분주했던 걸음을 멈추고 느긋하게 쉬어 가도 좋겠습니다.",
"비": ["비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.", "서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.", "빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.", "젖은 물건을 정리하고 잠깐 쉬어 가세요.", "바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."], "오늘 기억하고 싶은 장면을 한 줄 남겨 보세요.",
"눈": ["눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.", "다음 이동을 서두르지 말고 쉬어 가세요.", "따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.", "실내에서 다음 일정과 이동편을 확인해 보세요.", "눈길을 나서기 전에는 여유 있게 준비하세요."], "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.",
"안개": ["안개가 낀 날에는 이동 전에 시야와 도로 상황을 확인해 보세요.", "짙은 안개 속에서는 서두르지 말고 여유 있게 움직이세요.", "안개가 걷히기를 기다리며 실내에서 쉬어 가도 좋겠습니다.", "다음 이동 경로의 시야 확보 여부를 미리 확인해 두세요.", "안개 속 야외 일정보다는 가까운 곳부터 살펴보세요."], "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"
"소나기": ["소나기가 지나가는 동안에는 잠시 실내에서 머물러 보세요.", "갑자기 쏟아지는 비에 대비해 우산이나 우비를 챙기세요.", "소나기가 그치면 나설 수 있도록 다음 일정에 여유를 두세요.", "젖은 길은 미끄러울 수 있으니 이동할 때 주의하세요.", "소나기 소식이 있다면 바깥 일정은 짧게 잡아 보세요."], ],
"뇌우": ["천둥·번개가 치는 동안에는 야외 활동을 멈추고 실내에 머물러 있으세요.", "뇌우가 지나갈 때까지 개방된 장소나 높은 곳은 피하세요.", "기상특보를 확인하고 이동 일정을 조정해 보세요.", "번개가 잦아들 때까지 실내에서 안전하게 기다려 보세요.", "뇌우가 지나간 뒤에는 도로 상황을 확인한 뒤 움직이세요."], "구름많음": [
"이슬비": ["가벼운 이슬비라면 짧은 외출 정도는 괜찮습니다.", "이슬비가 흩날릴 때는 가벼운 겉옷 하나면 충분합니다.", "우산 없이도 짧은 이동이라면 크게 무리는 없겠습니다.", "이슬비가 그치는 틈을 봐서 다음 일정을 잡아 보세요.", "옷이 살짝 젖을 수 있으니 여벌 준비 정도만 해 두세요."], "구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.",
"강한비": ["빗줄기가 굵을 때는 이동을 미루고 실내에서 기다리세요.", "강한 비가 그칠 때까지 바깥 일정은 잠시 접어 두세요.", "물웅덩이·배수 상황을 살피고 이동 경로를 다시 확인하세요.", "우산만으로는 부족할 수 있으니 방수 옷차림을 챙기세요.", "굵은 빗속 이동은 삼가고 안전한 곳에서 쉬어 가세요."], "여행 사진을 정리하며 잠깐 쉬어 가세요.",
"어는비": ["어는비가 내릴 때는 바깥 이동을 삼가고 실내에 머무르세요.", "도로가 얼어붙을 수 있으니 되도록 나서지 마세요.", "부득이 이동해야 한다면 걸음을 평소보다 훨씬 천천히 옮기세요.", "차량 이동도 미끄러질 수 있으니 서두르지 마세요.", "기상특보를 확인하고 해빙될 때까지 일정을 미뤄 두세요."] "함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.",
"바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.",
"오늘의 기분을 짧은 글로 남겨도 좋겠습니다."
],
"흐림": [
"흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.",
"잠깐 책을 읽으며 다음 일정을 기다려 보세요.",
"바깥 풍경 대신 오늘 나눈 이야기에 집중해 보세요.",
"천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.",
"분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."
],
"비": [
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
],
"눈": [
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
"다음 이동을 서두르지 말고 쉬어 가세요.",
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
"실내에서 다음 일정과 이동편을 확인해 보세요.",
"눈길을 나서기 전에는 여유 있게 준비하세요."
],
"안개": [
"안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.",
"시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.",
"안개 걷히는 시간을 기다리며 느긋하게 아침을 보내 보세요.",
"운전하신다면 평소보다 여유 있게 출발하시길 권합니다.",
"뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다."
],
"이슬비": [
"가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.",
"빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.",
"얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.",
"젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.",
"빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다."
],
"소나기": [
"소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.",
"갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.",
"비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.",
"지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.",
"짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다."
],
"뇌우": [
"천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.",
"비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.",
"번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.",
"날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.",
"궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요."
]
}, },
"temperature": { "temperature": {
"혹서": ["더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.", "한낮 산책은 짧게 하고 충분히 쉬어 가세요.", "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.", "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.", "긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."], "혹서": [
"더움": ["바깥 일정 사이에 실내에서 쉬는 시간을 넣어 보세요.", "외출할 때에는 물을 챙기고 무리한 이동은 줄이세요.", "주변을 둘러보더라도 한 번에 오래 걷지는 마세요.", "방문할 곳의 운영 시간을 먼저 확인하고 이동하세요.", "더위가 느껴지면 일정을 줄이고 쉬어 가세요."], "더위가 강한 시간에는 바깥 이동을 줄이고 실내 위주로 일정을 잡아 보세요.",
"선선": ["비나 눈이 없다면 주변을 가볍게 둘러보는 건 어떨까요?", "날씨와 이동 경로를 확인하고 짧은 산책을 계획해 보세요.", "바깥 일정을 잡기 전에 방문할 곳의 운영 여부를 확인하세요.", "편한 신발을 챙기고 자신의 속도로 동네를 둘러보세요.", "비가 오지 않는다면 일정 사이에 잠깐 걸어 보세요."], "한낮 산책은 짧게 하고 충분히 쉬어 가세요.",
"쌀쌀": ["외출할 때에는 걸칠 옷을 챙겨 보세요.", "바깥에 오래 머물기보다 쉬어 갈 곳을 함께 정해 두세요.", "일교차에 대비해 옷차림을 조절하세요.", "이동 경로를 짧게 잡고 실내 일정도 함께 살펴보세요.", "돌아오는 시간의 기온도 확인한 뒤 외출하세요."], "외출 전 기상특보와 방문할 곳의 운영 여부를 확인하세요.",
"추움": ["따뜻한 옷을 챙기고 바깥 이동은 짧게 잡아 보세요.", "찬 바람을 피할 수 있는 실내 일정을 살펴보세요.", "출발 전 눈이나 결빙으로 이동이 어려운 곳은 없는지 확인하세요.", "야외에 오래 머무르지 않도록 쉬는 시간을 충분히 두세요.", "방문할 곳의 운영 여부를 확인하고 따뜻하게 준비하세요."] "다음 장소로 이동할 때에는 쉴 시간을 넉넉히 두세요.",
"긴 야외 일정은 미루고 가까운 동선부터 살펴보세요."
],
"더움": [
"바깥 일정 사이에 실내에서 쉬는 시간을 넣어 보세요.",
"외출할 때에는 물을 챙기고 무리한 이동은 줄이세요.",
"주변을 둘러보더라도 한 번에 오래 걷지는 마세요.",
"방문할 곳의 운영 시간을 먼저 확인하고 이동하세요.",
"더위가 느껴지면 일정을 줄이고 쉬어 가세요."
],
"선선": [
"비나 눈이 없다면 주변을 가볍게 둘러보는 건 어떨까요?",
"날씨와 이동 경로를 확인하고 짧은 산책을 계획해 보세요.",
"바깥 일정을 잡기 전에 방문할 곳의 운영 여부를 확인하세요.",
"편한 신발을 챙기고 자신의 속도로 동네를 둘러보세요.",
"비가 오지 않는다면 일정 사이에 잠깐 걸어 보세요."
],
"쌀쌀": [
"외출할 때에는 걸칠 옷을 챙겨 보세요.",
"바깥에 오래 머물기보다 쉬어 갈 곳을 함께 정해 두세요.",
"일교차에 대비해 옷차림을 조절하세요.",
"이동 경로를 짧게 잡고 실내 일정도 함께 살펴보세요.",
"돌아오는 시간의 기온도 확인한 뒤 외출하세요."
],
"추움": [
"따뜻한 옷을 챙기고 바깥 이동은 짧게 잡아 보세요.",
"찬 바람을 피할 수 있는 실내 일정을 살펴보세요.",
"출발 전 눈이나 결빙으로 이동이 어려운 곳은 없는지 확인하세요.",
"야외에 오래 머무르지 않도록 쉬는 시간을 충분히 두세요.",
"방문할 곳의 운영 여부를 확인하고 따뜻하게 준비하세요."
]
} }
} }

View File

@ -0,0 +1,438 @@
"""미니 블로그 — 빌더 앱 로그인 화면(이번 달 생성된 글). 기획: docs/MINI_BLOG.md
★ 이 파일이 지키는 것:
- 로그인한 사장님은 자기 사업장의 글만 본다(남의 가게 글이 섞이면 안 된다)
- 아직 메일이 안 나간 REVIEWED 글도 로그인 화면에서 바로 고치고 승인할 수 있다
- 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다
"""
import uuid
from datetime import date, timedelta
from sqlalchemy import text
from common.enums import JobStatus, JobType, PostStatus, PostTopicKind, SiteStatus
BODY = (
"비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 "
"하루가 지나갑니다. 우산은 현관에 넉넉히 두었으니 편하게 다녀오세요. 젖은 길은 미끄러우니 "
"편한 신발을 권합니다. 마당 평상은 비가 그치면 금세 마릅니다."
)
async def _place(client, headers, name="블로그펜션") -> str:
r = await client.post("/v1/place", headers=headers, json={"name": name, "category": 1})
return r.json()["place"]["place_id"]
async def _seed_post(db_engine, place_id, *, status=PostStatus.REVIEWED, scheduled=None) -> str:
post_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO place_posts (post_id, place_id, body, topic_kind, topic_key, status, scheduled_date) "
"VALUES (:id, :pid, :body, :kind, :key, :st, :sched)"),
{"id": post_id, "pid": place_id, "body": BODY, "kind": PostTopicKind.WEATHER.value,
"key": f"weather:{post_id.hex[:6]}", "st": status.value, "sched": scheduled or date.today()},
)
return str(post_id)
async def _status(db_engine, post_id) -> int:
async with db_engine.begin() as conn:
row = await conn.execute(text("SELECT status FROM place_posts WHERE post_id = :id"), {"id": post_id})
return row.scalar()
async def test_owner_sees_this_months_posts(client, db_engine, auth_headers):
h = await auth_headers("blogowner1")
place_id = await _place(client, h)
await _seed_post(db_engine, place_id)
res = await client.get(f"/v1/place/{place_id}/post", headers=h)
body = res.json()
assert body["result"]["code"] == 0
assert len(body["posts"]) == 1
assert body["posts"][0]["body"] == BODY
async def test_owner_cannot_see_someone_elses_posts(client, db_engine, auth_headers):
owner = await auth_headers("blogowner2")
other = await auth_headers("blogowner3")
place_id = await _place(client, owner)
await _seed_post(db_engine, place_id)
res = await client.get(f"/v1/place/{place_id}/post", headers=other)
body = res.json()
assert body["result"]["success"] is False
assert body["posts"] == []
async def test_owner_can_edit_and_approve_before_mail_goes_out(client, db_engine, auth_headers):
"""REVIEWED 글(아직 메일 안 나감)도 로그인 화면에서 바로 고쳐 승인할 수 있다."""
h = await auth_headers("blogowner4")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
new_body = BODY.replace("빗소리", "새소리")
res = await client.put(f"/v1/place/{place_id}/post/{post_id}", headers=h, json={"body": new_body})
assert res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_owner_edit_rejects_unverifiable_claims(client, db_engine, auth_headers):
h = await auth_headers("blogowner5")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id)
bad_body = BODY[:120] + " 주중 198,000원입니다."
res = await client.put(f"/v1/place/{place_id}/post/{post_id}", headers=h, json={"body": bad_body})
body = res.json()
assert body["result"]["success"] is False
assert "198,000원" in body["msg"]
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_generate_now_creates_posts_for_published_site(client, db_engine, auth_headers, monkeypatch):
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다(2026-09-17,
사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") — 발행된 사이트일 때만."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloggen1")
place_id = await _place(client, h, name="즉시생성펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
start = date.today()
end = start + timedelta(days=29)
res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": start.isoformat(), "end": end.isoformat()},
)
body = res.json()
assert body["result"]["success"] is True
assert body["requested"] == 30
assert body["created"] > 0
async with db_engine.begin() as conn:
count = (await conn.execute(
text("SELECT count(*) FROM place_posts WHERE place_id = :pid"), {"pid": place_id},
)).scalar()
dates = [row[0] for row in (await conn.execute(
text("SELECT scheduled_date FROM place_posts WHERE place_id = :pid ORDER BY scheduled_date"),
{"pid": place_id},
)).all()]
assert count == body["created"]
# 구간 안, 오늘부터 순서대로, 겹치는 날짜 없이.
assert dates[0] == start
assert dates == sorted(set(dates))
assert all(start <= d <= end for d in dates)
async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_headers, monkeypatch):
"""미래 날짜로 배정된 글은 그날이 오기 전엔 메일이 안 나간다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
h = await auth_headers("bloggen3")
place_id = await _place(client, h, name="예약펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen3"})
today_post = await _seed_post(db_engine, place_id, scheduled=date.today())
future_post = await _seed_post(db_engine, place_id, scheduled=date.today() + timedelta(days=5))
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert len(sent_calls) == 1
assert await _status(db_engine, today_post) == PostStatus.SENT.value
assert await _status(db_engine, future_post) == PostStatus.REVIEWED.value
async def test_owner_can_publish_as_is_without_editing(client, db_engine, auth_headers):
"""바로 발행 — 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다."""
h = await auth_headers("blogowner6")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=h)
assert res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async with db_engine.begin() as conn:
body_row = (await conn.execute(
text("SELECT body FROM place_posts WHERE post_id = :id"), {"id": post_id},
)).scalar()
assert body_row == BODY
async def test_owner_cannot_publish_someone_elses_post(client, db_engine, auth_headers):
owner = await auth_headers("blogowner7")
other = await auth_headers("blogowner8")
place_id = await _place(client, owner)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=other)
assert res.json()["result"]["success"] is False
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_generate_now_is_noop_for_unpublished_site(client, db_engine, auth_headers):
"""발행 전 사업장은 생성 스윕 대상이 아니다(blog_jobs._published_places) — 0건이어야 한다."""
h = await auth_headers("bloggen2")
place_id = await _place(client, h, name="발행전펜션")
start = date.today()
res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": start.isoformat(), "end": start.isoformat()},
)
body = res.json()
assert body["result"]["success"] is True
assert body["created"] == 0
async def test_generate_now_rejects_end_before_start(client, db_engine, auth_headers):
h = await auth_headers("bloggen16")
place_id = await _place(client, h, name="구간역순펜션")
start = date.today()
res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": start.isoformat(), "end": (start - timedelta(days=1)).isoformat()},
)
body = res.json()
assert body["result"]["success"] is False
async def test_approved_post_flags_build_failed_when_job_dead(client, db_engine, auth_headers):
"""화면은 발행완료/발행실패만 본다(사장님 지시) — 승인됐는데 BUILD 잡이 dead-letter 면
build_failed=true, 그 외(대기 중인 잡·아직 승인 전)에는 false 로 남는다."""
h = await auth_headers("bloggen4")
place_id = await _place(client, h, name="실패펜션")
failed_post = await _seed_post(db_engine, place_id, status=PostStatus.APPROVED)
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO jobs (job_type, status, payload) VALUES (:jt, :st, :pl)"),
{"jt": JobType.BUILD.value, "st": JobStatus.DEAD.value, "pl": f'{{"place_id": "{place_id}"}}'},
)
res = await client.get(f"/v1/place/{place_id}/post", headers=h)
posts = {p["post_id"]: p for p in res.json()["posts"]}
assert posts[failed_post]["build_failed"] is True
async def test_pending_build_job_does_not_flag_failure(client, db_engine, auth_headers):
h = await auth_headers("bloggen5")
place_id = await _place(client, h, name="대기펜션")
post_id = await _seed_post(db_engine, place_id, status=PostStatus.APPROVED)
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO jobs (job_type, status, payload) VALUES (:jt, :st, :pl)"),
{"jt": JobType.BUILD.value, "st": JobStatus.PENDING.value, "pl": f'{{"place_id": "{place_id}"}}'},
)
res = await client.get(f"/v1/place/{place_id}/post", headers=h)
posts = {p["post_id"]: p for p in res.json()["posts"]}
assert posts[post_id].get("build_failed", False) is False
async def test_upcoming_only_returns_next_week_in_date_order(client, db_engine, auth_headers):
"""상단 카로셀 — 오늘부터 N일치만, 날짜 오름차순. 그 뒤 배정분은 안 보인다."""
h = await auth_headers("bloggen6")
place_id = await _place(client, h, name="주간펜션")
far = await _seed_post(db_engine, place_id, scheduled=date.today() + timedelta(days=20))
tomorrow = await _seed_post(db_engine, place_id, scheduled=date.today() + timedelta(days=1))
today = await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.get(f"/v1/place/{place_id}/post/upcoming", headers=h, params={"days": 7})
ids = [p["post_id"] for p in res.json()["posts"]]
assert ids == [today, tomorrow]
assert far not in ids
async def test_get_post_by_id_for_mail_edit_link(client, db_engine, auth_headers):
"""메일 '수정하기' 링크(자동 로그인)가 postId 하나로 그 글을 바로 찾는 경로."""
h = await auth_headers("bloggen7")
place_id = await _place(client, h, name="단건조회펜션")
post_id = await _seed_post(db_engine, place_id)
res = await client.get(f"/v1/place/{place_id}/post/{post_id}", headers=h)
body = res.json()
assert body["result"]["success"] is True
assert len(body["posts"]) == 1
assert body["posts"][0]["post_id"] == post_id
async def test_get_post_by_id_scoped_to_owner(client, db_engine, auth_headers):
owner = await auth_headers("bloggen8")
other = await auth_headers("bloggen9")
place_id = await _place(client, owner, name="타인조회펜션")
post_id = await _seed_post(db_engine, place_id)
res = await client.get(f"/v1/place/{place_id}/post/{post_id}", headers=other)
body = res.json()
assert body["result"]["success"] is False
assert body["posts"] == []
async def test_generation_history_counts_by_batch(client, db_engine, auth_headers, monkeypatch):
"""생성 이력 — 한 번에 몇 건 · 어느 모델(사장님 지시: "생성이력도 있어야해 몇개
생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다)."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloggen10")
place_id = await _place(client, h, name="이력펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
start = date.today()
generate_res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": start.isoformat(), "end": (start + timedelta(days=29)).isoformat()},
)
created = generate_res.json()["created"]
assert created > 0
res = await client.get(f"/v1/place/{place_id}/post/history", headers=h)
batches = res.json()["batches"]
assert len(batches) == 1
assert batches[0]["count"] == created
assert batches[0]["model"] == "gemini-test-model"
async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_engine, auth_headers, monkeypatch):
"""사장님 지시: "승인이랑 수정하기 있어야해" — 승인은 토큰 링크 하나, 수정은
그날짜리 자동 로그인 토큰을 실은 빌더 앱 링크."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
h = await auth_headers("bloggen11")
place_id = await _place(client, h, name="메일링크펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(
text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen11"},
)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
mail_text = sent_calls[0]["text"]
assert "/v1/site/post/approve?t=" in mail_text
assert "/blog?placeId=" in mail_text
assert f"postId={post_id}" in mail_text
assert "auto=" in mail_text
async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_headers, monkeypatch):
"""사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloggen12")
place_id = await _place(client, h, name="개별생성펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
target = date.today() + timedelta(days=3)
res = await client.post(
f"/v1/place/{place_id}/post/generate-one", headers=h, params={"date": target.isoformat()},
)
body = res.json()
assert body["result"]["success"] is True
assert body["post"]["scheduled_date"] == target.isoformat()
assert await _status(db_engine, body["post"]["post_id"]) == PostStatus.REVIEWED.value
async def test_generate_one_fails_when_date_already_taken(client, db_engine, auth_headers, monkeypatch):
"""이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloggen13")
place_id = await _place(client, h, name="중복날짜펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
target = date.today() + timedelta(days=3)
await _seed_post(db_engine, place_id, scheduled=target)
res = await client.post(
f"/v1/place/{place_id}/post/generate-one", headers=h, params={"date": target.isoformat()},
)
body = res.json()
assert body["result"]["success"] is False
assert body.get("post") is None
async def test_generate_one_is_scoped_to_owner(client, db_engine, auth_headers):
owner = await auth_headers("bloggen14")
other = await auth_headers("bloggen15")
place_id = await _place(client, owner, name="타인개별생성펜션")
res = await client.post(
f"/v1/place/{place_id}/post/generate-one", headers=other,
params={"date": date.today().isoformat()},
)
assert res.json()["result"]["success"] is False

View File

@ -0,0 +1,135 @@
"""미니 블로그 — 문구 필터와 메일 승인.
★ 이 파일이 지키는 것:
- 확인되지 않은 주장(요금·시간·인원·전화)이 든 문구는 화면에 못 간다
- 같은 링크를 두 번 눌러도 두 번 올라가지 않는다
- 만료된 링크는 오류가 아니라 안내로 끝난다
★ 2026-09-17 이후 GET 이 곧 승인이다(사장님 지시: "승인은 바로 승인 되게 그 링크만
클릭하면"). 메일 클라이언트의 링크 미리 열기에 그대로 노출된다는 걸 알고도 택한 것 —
예전처럼 "GET 은 확인 화면만" 을 기대하는 테스트를 두지 않는다.
"""
import uuid
from datetime import timezone
from sqlalchemy import text
from common.enums import JobType, PlaceCategory, PostStatus, PostTopicKind
from services import blog_service
BODY = (
"비가 한 차례 지나간 뒤 마당 돌이 검게 젖었습니다. 이런 날에는 대청마루에 앉아 빗소리만 들어도 "
"하루가 지나갑니다. 우산은 현관에 넉넉히 두었으니 편하게 다녀오세요. 젖은 길은 미끄러우니 "
"편한 신발을 권합니다. 마당 평상은 비가 그치면 금세 마릅니다."
)
def test_filter_rejects_unverifiable_claims():
for bad, word in [
(BODY[:120] + " 주중 198,000원입니다.", "198,000원"),
(BODY[:120] + " 입실은 15:00 입니다.", "15:00"),
(BODY[:120] + " 문의는 010-1234-5678 로.", "010-1234-5678"),
(BODY[:120] + " 군산 최고의 숙소입니다.", "최고"),
]:
ok, reason = blog_service.is_publishable_body(bad)
assert ok is False
assert word in reason
def test_filter_accepts_a_plain_scene():
ok, reason = blog_service.is_publishable_body(BODY)
assert ok is True, reason
def test_filter_drops_same_topic_twice_in_one_batch():
rows = [
{"body": BODY, "topic_key": "festival:야행", "topic_kind": PostTopicKind.FESTIVAL.value},
{"body": BODY, "topic_key": "festival:야행", "topic_kind": PostTopicKind.FESTIVAL.value},
]
kept, dropped = blog_service.filter_drafts(rows)
assert len(kept) == 1
assert "주제 중복" in dropped[0][1]
async def _seed_post(db_engine, owner_id, *, status=PostStatus.SENT, expires_days=14):
place_id, post_id = uuid.uuid4(), uuid.uuid4()
token, token_hash, expires = blog_service.issue_token()
if expires_days < 0:
from common.utils.gtime import GTime
expires = GTime.AddDays(expires_days)
# ★ 함정: 이 시각은 naive-UTC 다. raw text() 로 바인딩하면 asyncpg 가 컬럼 타입 정보 없이
# 드라이버 기본값(로컬 시스템 시간대)으로 해석해 KST(UTC+9) 서버에서는 9시간 밀린다 —
# 14일짜리 옛 만료값에서는 안 드러났는데(그래도 미래), 자정 단위(issue_token 2026-09-17
# 개편)로 정밀해지자 자정 만료가 "9시간 전에 이미 만료"로 뒤집혔다. ORM 경로(update()/
# insert())는 컬럼의 DateTime(timezone=True) 프로세서를 타서 이 문제가 없다 — raw text()
# 로 timestamptz 컬럼에 naive datetime 을 바인딩할 때만 명시적으로 UTC 를 달아야 한다.
aware_expires = expires.replace(tzinfo=timezone.utc)
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :uid, :name, :cat, 1)"),
{"pid": place_id, "uid": owner_id, "name": "스테이,머뭄", "cat": PlaceCategory.LODGING.value},
)
await conn.execute(
text("INSERT INTO place_posts (post_id, place_id, body, topic_kind, topic_key, status, "
"approve_token_hash, token_expires_at) "
"VALUES (:id, :pid, :body, :kind, :key, :st, :hash, :exp)"),
{"id": post_id, "pid": place_id, "body": BODY, "kind": PostTopicKind.WEATHER.value,
"key": f"weather:{post_id.hex[:6]}", "st": status.value, "hash": token_hash, "exp": aware_expires},
)
return token, post_id
async def _status(db_engine, post_id) -> int:
async with db_engine.begin() as conn:
row = await conn.execute(text("SELECT status FROM place_posts WHERE post_id = :id"), {"id": post_id})
return row.scalar()
async def test_get_approves_immediately(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "올렸습니다" in res.text
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_approve_link_is_single_use(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id)
first = await client.get(f"/v1/site/post/approve?t={token}")
second = await client.get(f"/v1/site/post/approve?t={token}")
assert "올렸습니다" in first.text
assert "처리할 수 없는 링크" in second.text
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_expired_link_is_an_answer_not_an_error(client, db_engine, owner_id):
token, post_id = await _seed_post(db_engine, owner_id, expires_days=-1)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert "처리할 수 없는 링크" in res.text
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_approve_enqueues_build_with_owner_user_id(client, db_engine, owner_id):
"""회귀: BUILD 잡 payload 에 owner_user_id 가 없으면 build_service.run_build 가
payload["owner_user_id"] 에서 KeyError 로 죽는다 — 승인 클릭이 실제로는 사이트를
재발행하지 않는 채로 '올렸습니다' 만 보여주고 있었다."""
token, post_id = await _seed_post(db_engine, owner_id)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert "올렸습니다" in res.text
async with db_engine.begin() as conn:
row = await conn.execute(
text("SELECT payload FROM jobs WHERE job_type = :jt ORDER BY created_at DESC LIMIT 1"),
{"jt": JobType.BUILD.value},
)
payload = row.scalar()
assert payload["owner_user_id"] == owner_id

View File

@ -0,0 +1,109 @@
"""예약 요청 폼 → 사장님 메일.
★ 이 파일이 지키는 것:
- 발행된 사이트의 업장만 받는다 (place_id 를 바꿔 아무 업장에나 메일을 쏘지 못한다)
- DB 에 아무것도 남기지 않는다
- 봇(허니팟·즉시 제출)은 실패로 알리지 않고 조용히 버린다
- SMTP 미설정이면 500 이 아니라 "전화로 문의" 로 답한다
"""
import uuid
import pytest
from sqlalchemy import text
from common.enums import PlaceCategory, SiteStatus
from services import mail_service
@pytest.fixture
def sent(monkeypatch):
box = []
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
monkeypatch.setattr(mail_service, "send", lambda **kw: box.append(kw) or True)
return box
async def _seed(db_engine, owner_id, *, status=SiteStatus.PUBLISHED, email="owner@example.com") -> str:
place_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(text("UPDATE users SET email = :email WHERE user_id = :uid"),
{"email": email, "uid": owner_id})
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:pid, :uid, :name, :cat, 1)"),
{"pid": place_id, "uid": owner_id, "name": "스테이,머뭄", "cat": PlaceCategory.LODGING.value},
)
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": status.value},
)
return str(place_id)
def _form(place_id, **over):
body = {
"place_id": place_id, "name": "김손님", "phone": "010-1234-5678",
"email": "guest@example.com", "stay": "9월 20일 – 21일", "guests": "2명",
"message": "늦게 도착합니다", "consent": True, "elapsed_ms": 9000,
}
body.update(over)
return body
async def test_sends_mail_to_owner(client, db_engine, owner_id, sent):
place_id = await _seed(db_engine, owner_id)
res = await client.post("/v1/site/booking-request", json=_form(place_id))
assert res.status_code == 200
assert res.json()["success"] is True
assert len(sent) == 1
assert sent[0]["to"] == "owner@example.com"
assert sent[0]["reply_to"] == "guest@example.com"
for value in ("김손님", "010-1234-5678", "늦게 도착합니다"):
assert value in sent[0]["text"]
async def test_rejects_unpublished_place(client, db_engine, owner_id, sent):
place_id = await _seed(db_engine, owner_id, status=SiteStatus.DRAFT)
res = await client.post("/v1/site/booking-request", json=_form(place_id))
assert res.json()["success"] is False
assert sent == []
async def test_unknown_place_does_not_send(client, db_engine, owner_id, sent):
res = await client.post("/v1/site/booking-request", json=_form(str(uuid.uuid4())))
assert res.json()["success"] is False
assert sent == []
async def test_honeypot_and_instant_submit_are_dropped(client, db_engine, owner_id, sent):
place_id = await _seed(db_engine, owner_id)
bot = await client.post("/v1/site/booking-request", json=_form(place_id, company="광고회사"))
fast = await client.post("/v1/site/booking-request", json=_form(place_id, elapsed_ms=10))
# 봇에게는 걸렸다고 알리지 않는다 — 알리면 다음 시도가 그 조건을 피한다.
assert bot.json()["success"] is True
assert fast.json()["success"] is True
assert sent == []
async def test_requires_consent(client, db_engine, owner_id, sent):
place_id = await _seed(db_engine, owner_id)
res = await client.post("/v1/site/booking-request", json=_form(place_id, consent=False))
assert res.json()["success"] is False
assert sent == []
async def test_without_smtp_answers_instead_of_failing(client, db_engine, owner_id, monkeypatch):
monkeypatch.setattr(mail_service, "is_configured", lambda: False)
place_id = await _seed(db_engine, owner_id)
res = await client.post("/v1/site/booking-request", json=_form(place_id))
assert res.status_code == 200
assert res.json()["success"] is False
assert "전화" in res.json()["message"]

View File

@ -0,0 +1,113 @@
"""이용 후기 — 접수·검수·게재.
★ 이 파일이 지키는 것:
- 발행된 사이트에만 후기를 받는다
- 전화번호·이메일이 든 글은 받지 않는다(받지 않기로 한 값이 글로 들어오는 경로를 막는다)
- 검수를 통과해야 화면에 나간다
- 사장님이 닿는 :9800 에 검수 API 가 없다
"""
import uuid
from sqlalchemy import text
from common.enums import PlaceCategory, ReviewStatus, SiteStatus
from services.review_service import check_body
BODY = "마당이 조용해서 오래 앉아 있었습니다. 대청마루에서 보는 저녁 하늘이 좋았어요."
def test_contact_in_the_body_is_refused():
for bad in ("좋았어요 010-1234-5678 로 연락주세요", "문의는 me@example.com 으로", "짧음"):
ok, _reason = check_body(bad)
assert ok is False
assert check_body(BODY)[0] is True
def test_solution_port_does_not_expose_the_review_review_api():
import router.router as solution_app
paths = solution_app.app.openapi().get("paths", {})
assert not [p for p in paths if "admin/review" in p]
assert "/v1/site/review" in paths
async def _seed_place(db_engine, owner_id, *, status=SiteStatus.PUBLISHED) -> str:
place_id = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO places (place_id, owner_user_id, name, category, status) "
"VALUES (:p, :u, :n, :c, 1)"),
{"p": place_id, "u": owner_id, "n": "스테이,머뭄", "c": PlaceCategory.LODGING.value},
)
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:s, :p, :st)"),
{"s": uuid.uuid4(), "p": place_id, "st": status.value},
)
return str(place_id)
def _form(place_id, **over):
body = {"place_id": place_id, "body": BODY, "nickname": "머뭄손님",
"consent": True, "elapsed_ms": 9000}
body.update(over)
return body
async def _rows(db_engine, place_id):
async with db_engine.begin() as conn:
result = await conn.execute(
text("SELECT review_id, status, nickname, submitted_ip_hash FROM place_reviews "
"WHERE place_id = :p"),
{"p": uuid.UUID(place_id)},
)
return result.all()
async def test_review_is_published_immediately(client, db_engine, owner_id):
place_id = await _seed_place(db_engine, owner_id)
res = await client.post("/v1/site/review", json=_form(place_id))
assert res.json()["success"] is True
rows = await _rows(db_engine, place_id)
assert len(rows) == 1
# 사람 검수를 기다리지 않는다 — 기계 필터를 통과하면 그 자리에서 공개다.
assert rows[0][1] == ReviewStatus.PUBLISHED.value
# 원문 IP 를 남기지 않는다 — 해시만.
assert rows[0][3] and len(rows[0][3]) == 64
async def test_unpublished_place_does_not_take_reviews(client, db_engine, owner_id):
place_id = await _seed_place(db_engine, owner_id, status=SiteStatus.DRAFT)
res = await client.post("/v1/site/review", json=_form(place_id))
assert res.json()["success"] is False
assert await _rows(db_engine, place_id) == []
async def test_bot_submissions_are_dropped(client, db_engine, owner_id):
place_id = await _seed_place(db_engine, owner_id)
await client.post("/v1/site/review", json=_form(place_id, company="광고회사"))
await client.post("/v1/site/review", json=_form(place_id, elapsed_ms=10))
assert await _rows(db_engine, place_id) == []
async def test_public_list_shows_it_and_admin_can_take_it_down(client, db_engine, owner_id):
from services.review_service import ReviewService
place_id = await _seed_place(db_engine, owner_id)
await client.post("/v1/site/review", json=_form(place_id))
review_id = (await _rows(db_engine, place_id))[0][0]
# 화면이 붙은 뒤 받아 가는 목록에 바로 들어 있다.
public = await client.get(f"/v1/site/reviews?place_id={place_id}")
assert any(item["reviewId"] == str(review_id) for item in public.json()["items"])
# 문제 글은 사후에 내린다 — 내리면 공개 목록에서 빠진다.
await ReviewService().decide([review_id], publish=False)
assert (await _rows(db_engine, place_id))[0][1] == ReviewStatus.REJECTED.value
after = await client.get(f"/v1/site/reviews?place_id={place_id}")
assert after.json()["items"] == []

View File

@ -4,10 +4,11 @@ from services.site_payload import _weather
def test_all_weather_cases_have_five_unique_lines(): def test_all_weather_cases_have_five_unique_lines():
notes = weather_notes() notes = weather_notes()
assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "비", "눈"} assert set(notes["noteSets"]) == {"맑음", "구름많음", "흐림", "안개", "이슬비", "비", "소나기", "눈", "뇌우"}
assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"} assert set(notes["tempNoteSets"]) == {"혹서", "더움", "선선", "쌀쌀", "추움"}
lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group] lines = [line for key in ("noteSets", "tempNoteSets") for group in notes[key].values() for line in group]
assert len(lines) == len(set(lines)) == 50 # 하늘 9 · 기온대 5, 케이스마다 다섯 줄. 같은 문장이 두 번 나오면 순환이 멈춘 것처럼 보인다.
assert len(lines) == len(set(lines)) == 70
def test_templates_are_not_mutated_by_a_payload(): def test_templates_are_not_mutated_by_a_payload():

View File

@ -174,3 +174,161 @@ export function useHealthzHealthzGet<
return query; return query;
} }
/**
* ★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다.
* @summary Readyz
*/
export const readyzReadyzGet = (
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<unknown>(
{ url: `/readyz`, method: "GET", signal },
options,
);
};
export const getReadyzReadyzGetQueryKey = () => {
return [`/readyz`] as const;
};
export const getReadyzReadyzGetQueryOptions = <
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = void,
>(options?: {
query?: Partial<
UseQueryOptions<Awaited<ReturnType<typeof readyzReadyzGet>>, TError, TData>
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getReadyzReadyzGetQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof readyzReadyzGet>>> = ({
signal,
}) => readyzReadyzGet(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
> & { queryKey: DataTag<QueryKey, TData, TError> };
};
export type ReadyzReadyzGetQueryResult = NonNullable<
Awaited<ReturnType<typeof readyzReadyzGet>>
>;
export type ReadyzReadyzGetQueryError = void;
export function useReadyzReadyzGet<
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = void,
>(
options: {
query: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>
> &
Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
Awaited<ReturnType<typeof readyzReadyzGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): DefinedUseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useReadyzReadyzGet<
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = void,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>
> &
Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
Awaited<ReturnType<typeof readyzReadyzGet>>
>,
"initialData"
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
export function useReadyzReadyzGet<
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = void,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
};
/**
* @summary Readyz
*/
export function useReadyzReadyzGet<
TData = Awaited<ReturnType<typeof readyzReadyzGet>>,
TError = void,
>(
options?: {
query?: Partial<
UseQueryOptions<
Awaited<ReturnType<typeof readyzReadyzGet>>,
TError,
TData
>
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseQueryResult<TData, TError> & {
queryKey: DataTag<QueryKey, TData, TError>;
} {
const queryOptions = getReadyzReadyzGetQueryOptions(options);
const query = useQuery(queryOptions, queryClient) as UseQueryResult<
TData,
TError
> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey;
return query;
}

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ApprovePageParams = {
/**
* @minLength 8
* @maxLength 200
*/
t: string;
};

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type GenerateMyPostForDateParams = {
date: string;
};

View File

@ -0,0 +1,17 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type GenerateMyPostsParams = {
/**
* 구간 시작일
*/
start: string;
/**
* 구간 끝일
*/
end: string;
};

View File

@ -0,0 +1,16 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { GenerationBatchModel } from "./generationBatchModel";
/**
* 생성 회차 하나 — 같은 스윕에서 한 번에 만들어진 글 묶음(post_crud.generation_batches).
*/
export interface GenerationBatch {
created_at: string;
count: number;
model?: GenerationBatchModel;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type GenerationBatchModel = string | null;

View File

@ -5,6 +5,7 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export * from "./approvePageParams";
export * from "./auditCheckData"; export * from "./auditCheckData";
export * from "./auditCheckDataRecommendation"; export * from "./auditCheckDataRecommendation";
export * from "./authProvider"; export * from "./authProvider";
@ -33,17 +34,27 @@ export * from "./faqDataSourceFactIds";
export * from "./faqDataUpdatedAt"; export * from "./faqDataUpdatedAt";
export * from "./fieldSpecData"; export * from "./fieldSpecData";
export * from "./fieldSpecDataUnit"; export * from "./fieldSpecDataUnit";
export * from "./generateMyPostForDateParams";
export * from "./generateMyPostsParams";
export * from "./generationBatch";
export * from "./generationBatchModel";
export * from "./getGuideParams"; export * from "./getGuideParams";
export * from "./getWeatherParams"; export * from "./getWeatherParams";
export * from "./hTTPValidationError"; export * from "./hTTPValidationError";
export * from "./jobData"; export * from "./jobData";
export * from "./jobDataCreatedAt"; export * from "./jobDataCreatedAt";
export * from "./jobDataLastError"; export * from "./jobDataLastError";
export * from "./jobDataPlaceId";
export * from "./jobDataProgress";
export * from "./jobDataResult"; export * from "./jobDataResult";
export * from "./jobDataRunAfter"; export * from "./jobDataRunAfter";
export * from "./jobDataRunStartedAt"; export * from "./jobDataRunStartedAt";
export * from "./jobDataUpdatedAt"; export * from "./jobDataUpdatedAt";
export * from "./jobProgress";
export * from "./jobStatus"; export * from "./jobStatus";
export * from "./jobStep";
export * from "./jobStepReason";
export * from "./jobStepStatus";
export * from "./jobType"; export * from "./jobType";
export * from "./linkChannel"; export * from "./linkChannel";
export * from "./linkData"; export * from "./linkData";
@ -55,9 +66,12 @@ export * from "./listFactsParams";
export * from "./listFaqsParams"; export * from "./listFaqsParams";
export * from "./listLinksParams"; export * from "./listLinksParams";
export * from "./listMediaParams"; export * from "./listMediaParams";
export * from "./listMyPostsParams";
export * from "./listMySitesParams"; export * from "./listMySitesParams";
export * from "./listPlacesParams"; export * from "./listPlacesParams";
export * from "./listReviewsParams";
export * from "./listShowcaseParams"; export * from "./listShowcaseParams";
export * from "./listUpcomingPostsParams";
export * from "./localContentData"; export * from "./localContentData";
export * from "./localContentDataBody"; export * from "./localContentDataBody";
export * from "./localContentDataCollectedAt"; export * from "./localContentDataCollectedAt";
@ -123,6 +137,13 @@ export * from "./placeSearchItemCategoryName";
export * from "./placeSearchItemNaverPlaceUrl"; export * from "./placeSearchItemNaverPlaceUrl";
export * from "./placeSearchItemRoadAddress"; export * from "./placeSearchItemRoadAddress";
export * from "./placeStatus"; export * from "./placeStatus";
export * from "./postData";
export * from "./postDataApprovedAt";
export * from "./postDataCreatedAt";
export * from "./postDataPublishedAt";
export * from "./postDataScheduledDate";
export * from "./postDataSentAt";
export * from "./publicReview";
export * from "./publishAction"; export * from "./publishAction";
export * from "./publishLogData"; export * from "./publishLogData";
export * from "./publishLogDataCreatedAt"; export * from "./publishLogDataCreatedAt";
@ -135,17 +156,28 @@ export * from "./renderStatusData";
export * from "./renderStatusDataError"; export * from "./renderStatusDataError";
export * from "./renderStatusDataRenderedAt"; export * from "./renderStatusDataRenderedAt";
export * from "./renderStatusDataRenderedVersion"; export * from "./renderStatusDataRenderedVersion";
export * from "./reqBookingRequest";
export * from "./reqBookingRequestCompany";
export * from "./reqBookingRequestEmail";
export * from "./reqBookingRequestGuests";
export * from "./reqBookingRequestMessage";
export * from "./reqBookingRequestStay";
export * from "./reqCreateFaq"; export * from "./reqCreateFaq";
export * from "./reqCreateFaqSortOrder"; export * from "./reqCreateFaqSortOrder";
export * from "./reqCreateLink"; export * from "./reqCreateLink";
export * from "./reqCreateLinkTitle"; export * from "./reqCreateLinkTitle";
export * from "./reqCreatePlace"; export * from "./reqCreatePlace";
export * from "./reqCreateUnit"; export * from "./reqCreateUnit";
export * from "./reqEditPost";
export * from "./reqExtractFacts"; export * from "./reqExtractFacts";
export * from "./reqGoogleLogin"; export * from "./reqGoogleLogin";
export * from "./reqHidePlaceContent"; export * from "./reqHidePlaceContent";
export * from "./reqLogin"; export * from "./reqLogin";
export * from "./reqPublishLocalContent"; export * from "./reqPublishLocalContent";
export * from "./reqReview";
export * from "./reqReviewClientIp";
export * from "./reqReviewCompany";
export * from "./reqReviewNickname";
export * from "./reqRollback"; export * from "./reqRollback";
export * from "./reqSignup"; export * from "./reqSignup";
export * from "./reqSignupName"; export * from "./reqSignupName";
@ -192,6 +224,7 @@ export * from "./reqVerifyPlacePhone";
export * from "./reqVerifyPlacePlaceUrl"; export * from "./reqVerifyPlacePlaceUrl";
export * from "./reqVerifyPlaceRegionCode"; export * from "./reqVerifyPlaceRegionCode";
export * from "./reqVerifyPlaceRoadAddress"; export * from "./reqVerifyPlaceRoadAddress";
export * from "./resBookingRequest";
export * from "./resCategorySchema"; export * from "./resCategorySchema";
export * from "./resCategorySchemaCategory"; export * from "./resCategorySchemaCategory";
export * from "./resCategorySchemaLabel"; export * from "./resCategorySchemaLabel";
@ -212,6 +245,13 @@ export * from "./resFaqFaq";
export * from "./resFaqList"; export * from "./resFaqList";
export * from "./resFaqListMsg"; export * from "./resFaqListMsg";
export * from "./resFaqMsg"; export * from "./resFaqMsg";
export * from "./resGenerateNow";
export * from "./resGenerateNowMsg";
export * from "./resGenerateOne";
export * from "./resGenerateOneMsg";
export * from "./resGenerateOnePost";
export * from "./resGenerationHistory";
export * from "./resGenerationHistoryMsg";
export * from "./resJob"; export * from "./resJob";
export * from "./resJobJob"; export * from "./resJobJob";
export * from "./resJobMsg"; export * from "./resJobMsg";
@ -241,6 +281,8 @@ export * from "./resMeMsg";
export * from "./resMeName"; export * from "./resMeName";
export * from "./resMediaList"; export * from "./resMediaList";
export * from "./resMediaListMsg"; export * from "./resMediaListMsg";
export * from "./resMyPosts";
export * from "./resMyPostsMsg";
export * from "./resMySites"; export * from "./resMySites";
export * from "./resMySitesMsg"; export * from "./resMySitesMsg";
export * from "./resPlace"; export * from "./resPlace";
@ -253,10 +295,12 @@ export * from "./resPlacePlace";
export * from "./resPlaceSearch"; export * from "./resPlaceSearch";
export * from "./resPlaceSearchMsg"; export * from "./resPlaceSearchMsg";
export * from "./resPlaceSearchSource"; export * from "./resPlaceSearchSource";
export * from "./resPublicReviews";
export * from "./resPublishLogs"; export * from "./resPublishLogs";
export * from "./resPublishLogsMsg"; export * from "./resPublishLogsMsg";
export * from "./resRefreshToken"; export * from "./resRefreshToken";
export * from "./resRefreshTokenMsg"; export * from "./resRefreshTokenMsg";
export * from "./resReview";
export * from "./resSeoAudit"; export * from "./resSeoAudit";
export * from "./resSeoAuditMsg"; export * from "./resSeoAuditMsg";
export * from "./resSeoAuditSummary"; export * from "./resSeoAuditSummary";
@ -308,6 +352,8 @@ export * from "./resVerifyCandidatesSource";
export * from "./resWeather"; export * from "./resWeather";
export * from "./resWeatherMsg"; export * from "./resWeatherMsg";
export * from "./resWeatherWeather"; export * from "./resWeatherWeather";
export * from "./resWebPacketProtocol";
export * from "./resWebPacketProtocolMsg";
export * from "./searchPlacesPublicParams"; export * from "./searchPlacesPublicParams";
export * from "./showcaseItem"; export * from "./showcaseItem";
export * from "./showcaseItemRegion"; export * from "./showcaseItemRegion";

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListMyPostsParams = {
/**
* YYYY-MM, 기본값 이번 달
*/
month?: string | null;
};

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListReviewsParams = {
place_id: string;
};

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ListUpcomingPostsParams = {
/**
* @minimum 1
* @maximum 30
*/
days?: number;
};

View File

@ -0,0 +1,27 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PostDataScheduledDate } from "./postDataScheduledDate";
import type { PostDataCreatedAt } from "./postDataCreatedAt";
import type { PostDataSentAt } from "./postDataSentAt";
import type { PostDataApprovedAt } from "./postDataApprovedAt";
import type { PostDataPublishedAt } from "./postDataPublishedAt";
/**
* 미니 블로그 글 하나 — 빌더 앱 '이번 달 생성된 글' 목록 카드.
*/
export interface PostData {
post_id: string;
body: string;
topic_kind: number;
status: number;
scheduled_date?: PostDataScheduledDate;
created_at?: PostDataCreatedAt;
sent_at?: PostDataSentAt;
approved_at?: PostDataApprovedAt;
published_at?: PostDataPublishedAt;
build_failed?: boolean;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PostDataApprovedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PostDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PostDataPublishedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PostDataScheduledDate = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PostDataSentAt = string | null;

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface PublicReview {
reviewId: string;
body: string;
nickname: string;
publishedAt: string;
}

View File

@ -0,0 +1,32 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ReqBookingRequestEmail } from "./reqBookingRequestEmail";
import type { ReqBookingRequestStay } from "./reqBookingRequestStay";
import type { ReqBookingRequestGuests } from "./reqBookingRequestGuests";
import type { ReqBookingRequestMessage } from "./reqBookingRequestMessage";
import type { ReqBookingRequestCompany } from "./reqBookingRequestCompany";
export interface ReqBookingRequest {
place_id: string;
/**
* @minLength 1
* @maxLength 40
*/
name: string;
/**
* @minLength 6
* @maxLength 30
*/
phone: string;
email?: ReqBookingRequestEmail;
stay?: ReqBookingRequestStay;
guests?: ReqBookingRequestGuests;
message?: ReqBookingRequestMessage;
consent: boolean;
company?: ReqBookingRequestCompany;
elapsed_ms?: number;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqBookingRequestCompany = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqBookingRequestEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqBookingRequestGuests = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqBookingRequestMessage = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqBookingRequestStay = string | null;

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
/**
* 수정하고 그대로 승인 — 로그인 세션 버전(메일 없이 목록에서 바로 고칠 때).
*/
export interface ReqEditPost {
body: string;
}

View File

@ -0,0 +1,23 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ReqReviewNickname } from "./reqReviewNickname";
import type { ReqReviewCompany } from "./reqReviewCompany";
import type { ReqReviewClientIp } from "./reqReviewClientIp";
export interface ReqReview {
place_id: string;
/**
* @minLength 1
* @maxLength 1000
*/
body: string;
nickname?: ReqReviewNickname;
consent: boolean;
company?: ReqReviewCompany;
elapsed_ms?: number;
client_ip?: ReqReviewClientIp;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqReviewClientIp = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqReviewCompany = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqReviewNickname = string | null;

View File

@ -9,7 +9,12 @@ import type { PublishAction } from "./publishAction";
/** /**
* 발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 — * 발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다. 색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.
★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도
(`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가
'아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15).
무엇을 할지는 부르는 쪽이 적는다.
*/ */
export interface ReqSiteStatus { export interface ReqSiteStatus {
action?: PublishAction; action: PublishAction;
} }

View File

@ -19,6 +19,5 @@
*/ */
export interface ReqVerifyPlaceByUrl { export interface ReqVerifyPlaceByUrl {
url?: string; url?: string;
/** 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지. 기본 true. */
reuse_existing?: boolean; reuse_existing?: boolean;
} }

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface ResBookingRequest {
success: boolean;
message: string;
}

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResGenerateNowMsg } from "./resGenerateNowMsg";
/**
* 즉시 생성 결과 — 사장님이 고른 구간(시작~끝) 중 몇 일을 채웠는지.
*/
export interface ResGenerateNow {
result?: ErrorInfo;
msg?: ResGenerateNowMsg;
requested?: number;
created?: number;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResGenerateNowMsg = string | null;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResGenerateOneMsg } from "./resGenerateOneMsg";
import type { ResGenerateOnePost } from "./resGenerateOnePost";
/**
* 개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님
지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나
소재가 바닥났다).
*/
export interface ResGenerateOne {
result?: ErrorInfo;
msg?: ResGenerateOneMsg;
post?: ResGenerateOnePost;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResGenerateOneMsg = string | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PostData } from "./postData";
export type ResGenerateOnePost = PostData | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResGenerationHistoryMsg } from "./resGenerationHistoryMsg";
import type { GenerationBatch } from "./generationBatch";
export interface ResGenerationHistory {
result?: ErrorInfo;
msg?: ResGenerationHistoryMsg;
batches?: GenerationBatch[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResGenerationHistoryMsg = string | null;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResMyPostsMsg } from "./resMyPostsMsg";
import type { PostData } from "./postData";
export interface ResMyPosts {
result?: ErrorInfo;
msg?: ResMyPostsMsg;
posts?: PostData[];
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResMyPostsMsg = string | null;

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { PublicReview } from "./publicReview";
export interface ResPublicReviews {
items?: PublicReview[];
}

View File

@ -0,0 +1,11 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface ResReview {
success: boolean;
message: string;
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
import type { ErrorInfo } from "./errorInfo";
import type { ResWebPacketProtocolMsg } from "./resWebPacketProtocolMsg";
export interface ResWebPacketProtocol {
result?: ErrorInfo;
msg?: ResWebPacketProtocolMsg;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ResWebPacketProtocolMsg = string | null;

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
import {QueryClientProvider} from '@tanstack/react-query'; import {QueryClientProvider} from '@tanstack/react-query';
import {useEffect, type ReactNode} from 'react'; import {useEffect, type ReactNode} from 'react';
import {Toaster} from 'sonner'; import {Toaster} from 'sonner';
import {getAccessToken, me} from '@/api'; import {getAccessToken, me, setTokens} from '@/api';
import {decodeJwtSubject} from '@/lib/jwt';
import {queryClient} from '@/lib/query-client'; import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {toAuthUser, useAuthStore} from '@/stores/auth';
@ -9,6 +10,11 @@ import {toAuthUser, useAuthStore} from '@/stores/auth';
* 저장된 액세스 토큰으로 세션을 복구한다. * 저장된 액세스 토큰으로 세션을 복구한다.
* 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가 * 실패해도 앱은 뜬다 — 빌더는 로그인 없이도 도는 화면이라, 인증 실패가
* 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다. * 전체를 막으면 데모조차 못 본다. 백엔드가 필요한 화면만 가드가 막는다.
*
* ★ 미니 블로그 메일의 "수정하기" 링크(`?auto=<그날짜리 토큰>`)도 여기서 받는다 —
* `RequireAuth` 가 라우트 단에서 로그인 여부를 판단하므로, 그보다 늦게(예: BlogPostsPage
* 안에서) 로그인 처리를 하면 판단이 이미 끝나 `/login` 으로 튕긴 뒤다(2026-09-17 실측:
* "메일온거 클릭했더니 로그인하라고 뜨는데?"). 세션 복구보다 먼저 처리해야 한다.
*/ */
function useRestoreSession() { function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
@ -16,6 +22,16 @@ function useRestoreSession() {
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const autoToken = new URLSearchParams(window.location.search).get('auto');
const claims = autoToken ? decodeJwtSubject(autoToken) : null;
if (autoToken && claims) {
setTokens(autoToken);
setUser({userId: claims.user_id, id: claims.id, role: claims.role});
finishRestore();
return;
}
if (!getAccessToken()) { if (!getAccessToken()) {
finishRestore(); finishRestore();
return; return;

View File

@ -0,0 +1,14 @@
/**
* JWT 의 sub 클레임만 읽는다 — 서명 검증은 서버가 이미 했다(이 토큰은 우리 백엔드가 방금
* 발급해 URL 에 실어 보낸 것). 여기서는 화면 상태(useAuthStore)를 채우는 데 필요한
* user_id/id/role 만 꺼낸다.
*/
export function decodeJwtSubject(token: string): {user_id: string; id: string; role: number} | null {
try {
const payloadB64 = token.split('.')[1];
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
return JSON.parse(payload.sub);
} catch {
return null;
}
}

View File

@ -0,0 +1,723 @@
import {useEffect, useMemo, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router';
import {Loader2, Pencil, Plus, Save, Send, Sparkles, X} from 'lucide-react';
import {
useApproveMyPost,
useEditMyPost,
useGenerateMyPostForDate,
useGenerateMyPosts,
useGetGenerationHistory,
useGetMyPost,
useListMyPosts,
useListUpcomingPosts,
type PostData,
} from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog} from '@/components/ui/dialog';
import {notify, notifyApiError} from '@/lib/notify';
import {cn} from '@/lib/utils';
type BadgeVariant = 'default' | 'outline' | 'warning' | 'success';
// common/enums.py PostStatus 와 값이 같아야 한다. DB 는 숫자만 준다 — 라벨은 화면 몫이다.
// 카드(카로셀 · 달력 모달) 안에서만 쓴다 — 달력 칸 자체는 발행완료/발행실패만 보여준다.
const STATUS_LABEL: Record<number, {label: string; variant: BadgeVariant}> = {
1: {label: '생성됨', variant: 'default'},
2: {label: '발송 대기', variant: 'default'},
3: {label: '메일 보냄 · 승인 대기', variant: 'warning'},
4: {label: '승인됨 · 반영 중', variant: 'success'},
5: {label: '게재됨', variant: 'success'},
6: {label: '건너뜀', variant: 'outline'},
};
// common/enums.py PostTopicKind.
const TOPIC_LABEL: Record<number, string> = {
1: '날씨',
2: '축제',
3: '계절',
4: '주변',
5: '이용 안내',
};
// crud/post_crud.py PostCRUD._EDITABLE 과 같아야 한다 — 승인·게재·건너뜀은 되돌릴 수 없다.
const EDITABLE_STATUSES = new Set([2, 3]);
const SENT_STATUS = 3;
const PUBLISHED_STATUS = 5;
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
const UPCOMING_DAYS = 7;
function currentMonth(): string {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}
function monthLabel(month: string): string {
const [year, mon] = month.split('-').map(Number);
return `${year}년 ${mon}월`;
}
function shiftMonth(month: string, delta: number): string {
const [year, mon] = month.split('-').map(Number);
const date = new Date(year, mon - 1 + delta, 1);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
}
type YMD = {year: number; month: number; day: number};
/** scheduled_date("YYYY-MM-DD")를 파싱한다 — 타임존 없는 순수 날짜라 문자열만 자르면 된다. */
function parseScheduled(scheduledDate: string): YMD {
const [year, month, day] = scheduledDate.split('-').map(Number);
return {year, month, day};
}
function dayOf(scheduledDate: string): number {
return parseScheduled(scheduledDate).day;
}
function todayKst(): YMD {
const kst = new Date(Date.now() + 9 * 60 * 60 * 1000);
return {year: kst.getUTCFullYear(), month: kst.getUTCMonth() + 1, day: kst.getUTCDate()};
}
function addDays(base: YMD, days: number): YMD {
const d = new Date(Date.UTC(base.year, base.month - 1, base.day + days));
return {year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate()};
}
function sameDate(a: YMD, b: YMD): boolean {
return a.year === b.year && a.month === b.month && a.day === b.day;
}
function isBeforeToday(year: number, month: number, day: number, today: YMD): boolean {
if (year !== today.year) return year < today.year;
if (month !== today.month) return month < today.month;
return day < today.day;
}
function isoDate(year: number, month: number, day: number): string {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function todayIso(): string {
const t = todayKst();
return isoDate(t.year, t.month, t.day);
}
function addDaysIso(iso: string, days: number): string {
const [y, m, d] = iso.split('-').map(Number);
const next = new Date(Date.UTC(y, m - 1, d + days));
return isoDate(next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate());
}
function lastDayOfMonthIso(month: string): string {
const [year, mon] = month.split('-').map(Number);
const lastDay = new Date(Date.UTC(year, mon, 0)).getUTCDate();
return isoDate(year, mon, lastDay);
}
/** 카드에 다는 날짜 표시 — 날짜는 전부 쓰고, 오늘·내일만 chip 을 더 단다(사장님 지시). */
function dateLabel(scheduledDate: string): {text: string; chip: '오늘' | '내일' | null} {
const d = parseScheduled(scheduledDate);
const today = todayKst();
const text = `${d.month}월 ${d.day}일`;
if (sameDate(d, today)) return {text, chip: '오늘'};
if (sameDate(d, addDays(today, 1))) return {text, chip: '내일'};
return {text, chip: null};
}
/**
* 달력 칸에 다는 배지. 검수 대기(REVIEWED)처럼 아직 메일도 안 나간 상태는 아무것도
* 보여주지 않는다(사장님 지시: "발행전인건 표시하지 말고") — 메일 발송 여부는 그 자체가
* 크론잡이 실제로 돌았다는 확인이라 별도로 보여준다(사장님 지시: "완료되었는지 여부도
* 출력해야할꺼같아").
*/
function publishBadge(post: PostData): {label: string; className: string} | null {
if (post.status === PUBLISHED_STATUS) return {label: '발행완료', className: 'bg-success/15 text-success'};
if (post.build_failed) return {label: '발행실패', className: 'bg-destructive/10 text-destructive'};
if (post.status === SENT_STATUS) return {label: '발송완료', className: 'bg-primary/10 text-primary'};
return null;
}
/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
const [body, setBody] = useState(post.body);
const editMutation = useEditMyPost();
const handleSave = async () => {
try {
const res = await editMutation.mutateAsync({placeId, postId: post.post_id, data: {body}});
if (!res.result?.success) {
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
return;
}
notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.');
onDone();
} catch (error) {
notifyApiError(error, '저장하지 못했습니다.');
}
};
return (
<div className="mt-3 space-y-2">
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
rows={5}
className="w-full rounded-md border border-border bg-muted/50 px-3 py-2 text-sm text-foreground outline-none transition-colors focus:border-ring focus:bg-card focus:ring-1 focus:ring-ring"
/>
<div className="flex items-center gap-2">
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
<Save />
저장하고 승인
</Button>
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
<X />
취소
</Button>
</div>
</div>
);
}
function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData; onChanged: () => void}) {
const [isEditing, setIsEditing] = useState(false);
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
const editable = EDITABLE_STATUSES.has(post.status);
const approveMutation = useApproveMyPost();
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
const handlePublish = async () => {
try {
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
if (!res.result?.success) {
notify.error(res.msg || '발행하지 못했습니다.');
return;
}
notify.success('발행했습니다. 사이트에 반영되기까지 몇 분 걸립니다.');
onChanged();
} catch (error) {
notifyApiError(error, '발행하지 못했습니다.');
}
};
return (
<div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{chip && (
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
{chip}
</span>
)}
{text && <span className="text-xs font-medium text-muted-foreground">{text}</span>}
</div>
<Badge variant={status.variant}>{status.label}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{TOPIC_LABEL[post.topic_kind] ?? '기타'}</p>
{!isEditing && <p className="mt-2 line-clamp-6 flex-1 text-sm leading-relaxed">{post.body}</p>}
{isEditing && (
<PostEditor
placeId={placeId}
post={post}
onDone={() => {
setIsEditing(false);
onChanged();
}}
/>
)}
{editable && !isEditing && (
<div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3">
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
<Pencil />
수정
</Button>
<Button size="sm" variant="primary" className="flex-1" isLoading={approveMutation.isPending} onClick={handlePublish}>
<Send />
바로 발행
</Button>
</div>
)}
</div>
);
}
/** 카로셀용 미리보기 카드 — 누르면 모달이 뜬다. 수정·발행은 모달 안에서만 한다. */
function PostPreviewCard({post, onClick}: {post: PostData; onClick: () => void}) {
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
return (
<button
type="button"
onClick={onClick}
className="flex h-full w-full flex-col rounded-lg border border-border bg-card p-3.5 text-left shadow-lg transition-colors hover:bg-muted"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
{chip && (
<span className="rounded-full bg-primary px-2 py-0.5 text-[11px] font-bold text-primary-foreground">
{chip}
</span>
)}
{text && <span className="text-xs font-medium text-muted-foreground">{text}</span>}
</div>
<Badge variant={status.variant}>{status.label}</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground">{TOPIC_LABEL[post.topic_kind] ?? '기타'}</p>
<p className="mt-2 line-clamp-6 flex-1 text-sm leading-relaxed">{post.body}</p>
</button>
);
}
/**
* 겹쳐 쌓은 글 카드 카로셀 — 오늘부터 일주일치만(당장 챙길 것). 가로로 스크롤하면 카드가
* 하나씩 앞으로 나온다. 마우스를 올리면 그 카드가 가려지지 않고 맨 앞으로 온다.
* ★ 카드를 눌러도 그 자리에서 고치지 않는다 — 달력 칸과 똑같이 모달을 연다(사장님 지시:
* "카드클릭해도 모달나와서 수정가능하게 해야지").
*/
function PostCarousel({posts, onSelectPost}: {posts: PostData[]; onSelectPost: (post: PostData) => void}) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<div className="flex snap-x snap-mandatory overflow-x-auto px-1 pt-1 pb-4">
{posts.map((post, index) => {
const hovered = hoveredId === post.post_id;
return (
<div
key={post.post_id}
onMouseEnter={() => setHoveredId(post.post_id)}
onMouseLeave={() => setHoveredId(null)}
className={cn(
'w-72 shrink-0 snap-start transition-transform duration-150',
index > 0 && '-ml-10',
hovered && 'scale-105',
)}
style={{zIndex: hovered ? 999 : posts.length - index}}
>
<PostPreviewCard post={post} onClick={() => onSelectPost(post)} />
</div>
);
})}
</div>
);
}
/**
* 달력 한 장. **글이 0건이어도 항상 뜬다** — 그날그날 뭐가 있는지 훑어보는 게 목적이라
* 목록이 비었다고 화면 자체가 사라지면 "이 달은 아무것도 없다"는 걸 확인할 방법이 없다.
* ★ 여기서는 발행완료/발행실패만 표시한다(사장님 지시) — 칸을 누르면 모달로 전체 내용을 본다.
* ★ 빈 날짜(오늘 이후)를 누르면 그 날짜 하나만 콕 집어 생성한다(사장님 지시: "개별적으로
* 새로 만들수있게 해줘"). 지난 날짜는 이제 와서 만들 이유가 없어 클릭을 막는다.
*/
function Calendar({month, postsByDay, onSelectPost, onGenerateDay, generatingDay}: {
month: string;
postsByDay: Map<number, PostData>;
onSelectPost: (post: PostData) => void;
onGenerateDay: (date: string) => void;
generatingDay: string | null;
}) {
const [year, mon] = month.split('-').map(Number);
const daysInMonth = new Date(Date.UTC(year, mon, 0)).getUTCDate();
const firstWeekday = new Date(Date.UTC(year, mon - 1, 1)).getUTCDay();
const today = todayKst();
const isCurrentMonth = today.year === year && today.month === mon;
const cells: (number | null)[] = [
...Array.from({length: firstWeekday}, () => null),
...Array.from({length: daysInMonth}, (_, i) => i + 1),
];
return (
<div className="overflow-hidden rounded-lg border border-border">
<div className="grid grid-cols-7 border-b border-border bg-muted/40">
{WEEKDAYS.map((label) => (
<div key={label} className="py-2 text-center text-xs font-medium text-muted-foreground">
{label}
</div>
))}
</div>
<div className="grid grid-cols-7">
{cells.map((day, index) => {
if (day === null) {
return <div key={`blank-${index}`} className="aspect-square border-b border-r border-border/60 bg-muted/10" />;
}
const post = postsByDay.get(day);
const badge = post ? publishBadge(post) : null;
const isToday = isCurrentMonth && today.day === day;
const isPast = isBeforeToday(year, mon, day, today);
const cellDate = isoDate(year, mon, day);
const isGenerating = generatingDay === cellDate;
const clickable = !!post || (!isPast && !isGenerating);
const handleClick = post
? () => onSelectPost(post)
: (!isPast && !isGenerating ? () => onGenerateDay(cellDate) : undefined);
return (
<div
key={day}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={handleClick}
onKeyDown={handleClick ? (event) => event.key === 'Enter' && handleClick() : undefined}
className={cn(
'group flex aspect-square flex-col items-start gap-1 border-b border-r border-border/60 p-1.5 text-left sm:p-2',
clickable && 'cursor-pointer transition-colors hover:bg-muted',
isGenerating && 'cursor-wait',
)}
>
<span
className={cn(
'flex size-5 items-center justify-center rounded-full text-xs tabular-nums',
isToday ? 'bg-primary text-primary-foreground font-semibold' : 'text-foreground',
)}
>
{day}
</span>
{post && (
<p className="line-clamp-2 w-full text-[10px] leading-tight break-all text-muted-foreground">
{post.body.slice(0, 20)}
</p>
)}
{badge && (
<span className={cn('mt-auto rounded px-1 py-0.5 text-[9px] font-medium', badge.className)}>
{badge.label}
</span>
)}
{!post && !isPast && (
<span
className={cn(
'mt-auto flex items-center gap-0.5 text-[10px] text-muted-foreground transition-opacity',
isGenerating ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
)}
>
{isGenerating ? <Loader2 className="size-3 animate-spin" /> : <Plus className="size-3" />}
{isGenerating ? '만드는 중' : '생성'}
</span>
)}
</div>
);
})}
</div>
</div>
);
}
type Tab = 'main' | 'history';
const TAB_LABEL: Record<Tab, string> = {main: '블로그', history: '생성 이력'};
/** 생성 이력 — 언제 몇 건 만들었는지(사장님 지시: "생성이력도 있어야해 몇개 생성했는지"). */
function GenerationHistoryList({placeId}: {placeId: string}) {
const {data, isLoading} = useGetGenerationHistory(placeId);
const batches = data?.batches ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center py-16 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
);
}
if (batches.length === 0) {
return <p className="text-sm text-muted-foreground">아직 생성한 적이 없습니다.</p>;
}
return (
<ul className="divide-y divide-border rounded-lg border border-border">
{batches.map((batch) => {
const at = new Date(batch.created_at);
const label = Number.isNaN(at.getTime())
? batch.created_at
: at.toLocaleString('ko-KR', {month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit'});
return (
<li key={batch.created_at} className="flex items-center justify-between px-4 py-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-semibold tabular-nums">{batch.count}건</span>
</li>
);
})}
</ul>
);
}
/**
* 이번 달(또는 고른 달) 생성된 미니 블로그 글. 기획: docs/MINI_BLOG.md
*
* ★ 사이트 하나를 고르고 들어온다 — `/sites` 카드의 관리 메뉴가 `?placeId=` 를 채운다.
* 여러 사이트를 가진 사장님도 있어 전역 메뉴 하나로는 어느 사이트인지 알 수 없다.
* ★ 화면이 둘로 나뉜다 — 위 카로셀은 "당장 챙길 것"(오늘부터 일주일)을 편집·발행하는 곳,
* 아래 달력은 한 달 전체를 훑어보는 곳(칸을 누르면 모달로 본다).
*/
export function BlogPostsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const placeId = searchParams.get('placeId') ?? '';
// 메일 "수정하기" 링크 — 그날짜리 자동 로그인(auto) 은 provider.tsx 의 세션 복구에서
// 이미 처리됐다(라우트 가드보다 먼저 처리해야 해서 여기가 아니라 거기다). 여기서는
// postId 로 그 글을 찾아 모달만 연다.
const linkedPostId = searchParams.get('postId');
useEffect(() => {
if (!searchParams.has('auto')) return;
const next = new URLSearchParams(searchParams);
next.delete('auto');
setSearchParams(next, {replace: true});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {data: linkedPostData} = useGetMyPost(placeId, linkedPostId ?? '', {
query: {enabled: !!placeId && !!linkedPostId},
});
// 과거 달·너무 먼 미래는 볼 이유가 없다 — 이번 달부터 1년 뒤까지만 넘겨볼 수 있다.
const minMonth = currentMonth();
const maxMonth = shiftMonth(minMonth, 12);
const [month, setMonth] = useState(minMonth);
const [modalPost, setModalPost] = useState<PostData | null>(null);
const [tab, setTab] = useState<Tab>('main');
useEffect(() => {
const linkedPost = linkedPostData?.posts?.[0];
if (!linkedPost) return;
setModalPost(linkedPost);
const next = new URLSearchParams(searchParams);
next.delete('postId');
setSearchParams(next, {replace: true});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [linkedPostData]);
const {data, isLoading, isError, error, refetch} = useListMyPosts(placeId, {month});
const posts = useMemo(() => data?.posts ?? [], [data]);
const postsByDay = useMemo(() => {
const map = new Map<number, PostData>();
for (const post of posts) {
if (!post.scheduled_date) continue;
map.set(dayOf(post.scheduled_date), post);
}
return map;
}, [posts]);
const {data: upcomingData, refetch: refetchUpcoming} = useListUpcomingPosts(placeId, {days: UPCOMING_DAYS});
const upcomingPosts = useMemo(() => upcomingData?.posts ?? [], [upcomingData]);
const generateMutation = useGenerateMyPosts();
const generateOneMutation = useGenerateMyPostForDate();
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
const [rangeStart, setRangeStart] = useState(todayIso());
const [rangeEnd, setRangeEnd] = useState(addDaysIso(todayIso(), 29));
const refetchAll = () => {
void refetch();
void refetchUpcoming();
};
const handleGenerate = async () => {
try {
const res = await generateMutation.mutateAsync({placeId, params: {start: rangeStart, end: rangeEnd}});
if (!res.result?.success) {
notify.error(res.msg || '생성하지 못했습니다.');
return;
}
notify.success(res.msg ?? `${res.created}건 만들었습니다.`);
setRangeDialogOpen(false);
refetchAll();
} catch (generateError) {
notifyApiError(generateError, '생성하지 못했습니다.');
}
};
const handleGenerateDay = async (date: string) => {
try {
const res = await generateOneMutation.mutateAsync({placeId, params: {date}});
if (!res.result?.success) {
notify.error(res.msg || '이 날짜엔 만들지 못했습니다.');
return;
}
notify.success(res.msg ?? '만들었습니다.');
refetchAll();
if (res.post) setModalPost(res.post);
} catch (generateOneError) {
notifyApiError(generateOneError, '만들지 못했습니다.');
}
};
const generateButton = (
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
<Sparkles />
지금 생성하기
</Button>
);
const maxGenerateDate = lastDayOfMonthIso(maxMonth);
if (!placeId) {
return (
<AppShell>
<PageContainer title="블로그 글" description="어느 사이트의 글을 볼지 먼저 골라 주세요.">
<EmptyState
title="사이트를 먼저 선택하세요"
description="내 사이트 목록에서 사이트를 고르면 그 사이트의 글이 보입니다."
action={
<Button size="sm" onClick={() => navigate('/sites')}>
내 사이트로
</Button>
}
/>
</PageContainer>
</AppShell>
);
}
return (
<AppShell>
<PageContainer title="블로그 글" description="AI 가 만든 글이 매일 조금씩 쌓입니다. 마음에 안 들면 고쳐서 올리세요." actions={generateButton}>
{/* 탭은 생성 이력만 따로 뺀다 — 카로셀·달력은 계속 같이 보인다(사장님 지시: "탭으로
나누지 말고 달력 위에 이번 주 카드들 보여주라고 했지"). */}
<div className="mb-4 flex w-fit items-center gap-0.5 rounded-md border border-border p-0.5">
{(Object.keys(TAB_LABEL) as Tab[]).map((key) => (
<button
key={key}
type="button"
aria-pressed={tab === key}
onClick={() => setTab(key)}
className={cn(
'h-8 cursor-pointer rounded px-3 text-xs font-medium transition-colors',
tab === key ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground',
)}
>
{TAB_LABEL[key]}
</button>
))}
</div>
{tab === 'main' && (
<>
{upcomingPosts.length > 0 ? (
<PostCarousel posts={upcomingPosts} onSelectPost={setModalPost} />
) : (
<p className="mb-3 text-sm text-muted-foreground">앞으로 일주일 안에 예정된 글이 없습니다.</p>
)}
{/* 이전 달 · 월 · 다음 달 — 달력 바로 위(사장님 지시). */}
<div className="mt-4 mb-3 flex items-center gap-1">
<Button size="sm" variant="ghost" onClick={() => setMonth((m) => (m <= minMonth ? m : shiftMonth(m, -1)))}>
이전 달
</Button>
<span className="px-2 text-sm font-medium tabular-nums">{monthLabel(month)}</span>
<Button size="sm" variant="ghost" onClick={() => setMonth((m) => (m >= maxMonth ? m : shiftMonth(m, 1)))}>
다음 달
</Button>
</div>
{isLoading && (
<div className="flex items-center justify-center py-20 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{isError && (
<EmptyState
title="글을 불러오지 못했습니다"
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
action={
<Button size="sm" onClick={() => refetch()}>
다시 시도
</Button>
}
/>
)}
{!isLoading && !isError && (
<>
{posts.length === 0 && (
<p className="mb-3 text-sm text-muted-foreground">
이 달엔 생성된 글이 없습니다. 새벽마다(04:10) 채워지거나, 지금 생성하기를 눌러 바로 만들 수 있습니다.
</p>
)}
<Calendar
month={month}
postsByDay={postsByDay}
onSelectPost={setModalPost}
onGenerateDay={handleGenerateDay}
generatingDay={generatingDay}
/>
</>
)}
</>
)}
{tab === 'history' && <GenerationHistoryList placeId={placeId} />}
</PageContainer>
<Dialog
open={!!modalPost}
onClose={() => setModalPost(null)}
title={modalPost?.scheduled_date ? `${dateLabel(modalPost.scheduled_date).text} 글` : '글'}
>
{modalPost && (
<PostCard
placeId={placeId}
post={modalPost}
onChanged={() => {
refetchAll();
setModalPost(null);
}}
/>
)}
</Dialog>
<Dialog
open={rangeDialogOpen}
onClose={() => setRangeDialogOpen(false)}
title="지금 생성하기"
description="채울 구간을 고르세요. 이미 글이 있는 날짜는 건너뛰고, 새로 쓸 소재가 떨어지면 거기서 멈춥니다."
footer={
<div className="flex justify-end gap-2">
<Button size="sm" variant="ghost" onClick={() => setRangeDialogOpen(false)} disabled={generateMutation.isPending}>
취소
</Button>
<Button size="sm" variant="primary" isLoading={generateMutation.isPending} onClick={handleGenerate}>
<Sparkles />
생성
</Button>
</div>
}
>
<div className="flex items-center gap-2">
<label className="flex flex-1 flex-col gap-1 text-xs text-muted-foreground">
시작일
<input
type="date"
value={rangeStart}
min={todayIso()}
max={rangeEnd || maxGenerateDate}
onChange={(event) => setRangeStart(event.target.value)}
className="rounded-md border border-border bg-muted/50 px-2 py-1.5 text-sm text-foreground outline-none focus:border-ring focus:bg-card focus:ring-1 focus:ring-ring"
/>
</label>
<span className="mt-5 text-muted-foreground">~</span>
<label className="flex flex-1 flex-col gap-1 text-xs text-muted-foreground">
끝일
<input
type="date"
value={rangeEnd}
min={rangeStart || todayIso()}
max={maxGenerateDate}
onChange={(event) => setRangeEnd(event.target.value)}
className="rounded-md border border-border bg-muted/50 px-2 py-1.5 text-sm text-foreground outline-none focus:border-ring focus:bg-card focus:ring-1 focus:ring-ring"
/>
</label>
</div>
</Dialog>
</AppShell>
);
}
// 라우트 모듈은 default export 를 요구한다(routes.ts 가 이 파일을 가리킨다).
export default BlogPostsPage;

View File

@ -4,10 +4,12 @@ import {useMemo, useState} from 'react';
import {Link, useNavigate} from 'react-router'; import {Link, useNavigate} from 'react-router';
import { import {
Building2, Building2,
CalendarClock,
Coffee, Coffee,
ExternalLink, ExternalLink,
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Newspaper,
Pencil, Pencil,
Plus, Plus,
Search, Search,
@ -420,7 +422,7 @@ export function SitesPage() {
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
aria-label="더보기" aria-label="관리 메뉴"
isLoading={busyId === row.place_id} isLoading={busyId === row.place_id}
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)} onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
> >
@ -431,19 +433,59 @@ export function SitesPage() {
{menuId === row.place_id && ( {menuId === row.place_id && (
<> <>
{/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */} {/* 바깥을 눌러 닫는다. 메뉴가 몇 줄 안 돼 팝오버 라이브러리를 들이지 않는다. */}
<button <button
type="button" type="button"
aria-label="닫기" aria-label="닫기"
className="fixed inset-0 z-10 cursor-default" className="fixed inset-0 z-10 cursor-default"
onClick={() => setMenuId(null)} onClick={() => setMenuId(null)}
/> />
<div className="absolute right-3.5 bottom-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md"> <div className="absolute right-3.5 bottom-12 z-20 w-52 rounded-md border border-border bg-card py-1 shadow-md">
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(editHref);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Pencil className="size-3.5" />
디자인·컨텐츠 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
navigate(`/blog?placeId=${row.place_id}`);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<Newspaper className="size-3.5" />
미니블로그 관리
</button>
<button
type="button"
onClick={() => {
setMenuId(null);
// ★ 예약 요청은 DB 에 안 남는다(2026-09-16 대표 지시, booking_request.py) —
// 메일로만 가고 우리 쪽엔 로그 한 줄만 남는다. 목록 화면을 만들려면
// 그 결정부터 뒤집어야 한다 — 지금은 안내만 한다.
notify.info(
'예약 요청은 메일로만 전달됩니다',
'연락처를 따로 저장하지 않기로 했습니다(2026-09-16). 목록이 필요하면 먼저 이 방침을 바꿔야 합니다.',
);
}}
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-xs transition-colors hover:bg-muted"
>
<CalendarClock className="size-3.5" />
예약요청 관리
</button>
<div className="my-1 border-t border-border" />
<button <button
type="button" type="button"
disabled={row.status !== SiteStatus.PUBLISHED} disabled={row.status !== SiteStatus.PUBLISHED}
onClick={() => handleUnpublish(row)} onClick={() => handleUnpublish(row)}
className="w-full cursor-pointer px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40" className="w-full cursor-pointer px-3 py-2 text-left text-xs text-destructive transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
> >
발행 내리기 발행 내리기
</button> </button>

View File

@ -26,6 +26,9 @@ export default [
layout('components/layout/RequireAuthLayout.tsx', [ layout('components/layout/RequireAuthLayout.tsx', [
route('sites', 'pages/SitesPage.tsx'), route('sites', 'pages/SitesPage.tsx'),
route('account', 'pages/AccountPage.tsx'), route('account', 'pages/AccountPage.tsx'),
// "내 사이트" 카드의 관리 메뉴에서 온다(?placeId= 로 어느 사이트인지 받는다) —
// 사장님 여럿이 사이트 여럿을 가질 수 있어 전역 메뉴 하나로는 못 고른다.
route('blog', 'pages/BlogPostsPage.tsx'),
]), ]),
/** /**

View File

@ -28,25 +28,20 @@
* 데스크톱에서 12px 는 읽으라고 만든 크기가 아니다. 화면 폭과 함께 늘린다. * 데스크톱에서 12px 는 읽으라고 만든 크기가 아니다. 화면 폭과 함께 늘린다.
*/ */
:where(.site, .site-canvas) { :where(.site, .site-canvas) {
/*
* ★ 한 번 더 줄였다. 앞선 값(제목 최대 38px · 히어로 64px)은 서구 편집형 스케일이다.
* 국내 숙박·공간 사이트는 제목이 24~28px, 히어로가 40px 안팎이고 대신 정보 밀도가 높다.
* 큰 활자 + 넓은 여백은 국내 사용자에게 '내용이 없는 페이지'로 읽힌다.
*/
--fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem); --fs-display: clamp(1.75rem, 1.25rem + 2.2vw, 3rem);
--fs-h2: clamp(1.3125rem, 1.15rem + 0.7vw, 1.75rem); --fs-h2: clamp(1.3125rem, 1.2rem + 0.55vw, 1.625rem);
--fs-h3: clamp(1.0625rem, 1rem + 0.3vw, 1.25rem); --fs-h3: clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem);
--fs-lead: clamp(0.9375rem, 0.9rem + 0.22vw, 1.0625rem); --fs-lead: clamp(1.0625rem, 1rem + 0.25vw, 1.1875rem);
--fs-body: clamp(0.9375rem, 0.91rem + 0.15vw, 1rem); --fs-body: clamp(1.0625rem, 1.03rem + 0.15vw, 1.125rem);
--fs-sm: clamp(0.8125rem, 0.8rem + 0.1vw, 0.875rem); --fs-sm: clamp(0.96875rem, 0.95rem + 0.1vw, 1rem);
--fs-xs: clamp(0.75rem, 0.74rem + 0.06vw, 0.78125rem); --fs-xs: clamp(0.875rem, 0.86rem + 0.06vw, 0.90625rem);
/* /*
* 섹션 세로 여백. * 섹션 세로 여백.
* 템플릿 값(`--tpl-section-space`)을 상한으로 두고 화면이 좁아지면 줄인다 — * 템플릿 값(`--tpl-section-space`)을 상한으로 두고 화면이 좁아지면 줄인다 —
* 매거진의 5rem 을 모바일에 그대로 주면 한 화면에 제목만 남는다. * 매거진의 5rem 을 모바일에 그대로 주면 한 화면에 제목만 남는다.
*/ */
--section-space: clamp(2.25rem, 5vw, var(--tpl-section-space, 3.5rem)); --section-space: clamp(1.75rem, 4vw, var(--tpl-section-space, 2.75rem));
} }
/* /*
@ -59,7 +54,7 @@
--site-line: color-mix(in oklab, currentColor 14%, transparent); --site-line: color-mix(in oklab, currentColor 14%, transparent);
--site-line-soft: color-mix(in oklab, currentColor 8%, transparent); --site-line-soft: color-mix(in oklab, currentColor 8%, transparent);
/* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */ /* 본문 보조 글자. opacity-60 을 반복해 쓰던 자리를 색 하나로 모은다. */
--site-muted: color-mix(in oklab, currentColor 62%, transparent); --site-muted: color-mix(in oklab, currentColor 96%, transparent);
} }
/* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 — /* 제목 서체. 이름은 'serif' 로 남아 있지만 값은 템플릿이 정한다 —
@ -216,6 +211,7 @@
*/ */
:where(.site, .site-canvas) .slider-viewport { :where(.site, .site-canvas) .slider-viewport {
overflow-x: auto; overflow-x: auto;
touch-action: pan-y pinch-zoom;
scrollbar-width: none; scrollbar-width: none;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
@ -229,6 +225,10 @@
} }
:where(.site, .site-canvas) .slider-track { :where(.site, .site-canvas) .slider-track {
display: flex; display: flex;
/* ★ embla 필수 설정인데 빠져 있었다 — 이게 없으면 레일 위에서 세로로 밀 때 브라우저가
가로 드래그로 잡아 **페이지가 안 내려간다**(실측: 레일 4개 위에서 스크롤이 걸림).
pan-y 는 세로 스크롤을 브라우저에 그대로 넘기고, 가로만 스크립트가 가져간다. */
touch-action: pan-y pinch-zoom;
} }
:where(.site, .site-canvas) .slider-track > * { :where(.site, .site-canvas) .slider-track > * {
scroll-snap-align: start; scroll-snap-align: start;

View File

@ -49,6 +49,10 @@ export interface SitePayload {
* 렌더러는 비면 플레이어를 아예 그리지 않는다. * 렌더러는 비면 플레이어를 아예 그리지 않는다.
*/ */
songs: SongTrack[]; songs: SongTrack[];
/** 미니 블로그 글. 전부 HTML 에 들어가고 화면이 나눠 보여준다(docs/MINI_BLOG.md). */
posts?: PostEntry[];
/** 검수를 통과한 이용 후기. 별점은 없다(2026-09-16 회의). */
reviews?: ReviewEntry[];
/** 같은 원고의 발행된 사본. 고유 콘텐츠·SEO 메타의 근거로 세지 않는다. */ /** 같은 원고의 발행된 사본. 고유 콘텐츠·SEO 메타의 근거로 세지 않는다. */
socialPosts?: SocialPostItem[]; socialPosts?: SocialPostItem[];
@ -67,6 +71,19 @@ export interface SitePayload {
seo?: SiteSeo; seo?: SiteSeo;
} }
export interface PostEntry {
postId: string;
body: string;
publishedAt: string;
}
export interface ReviewEntry {
reviewId: string;
body: string;
nickname: string;
publishedAt: string;
}
export interface SiteSeo { export interface SiteSeo {
/** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */ /** `<meta name="keywords">` 로 나간다. SiteOntology 융합 순위 그대로, 최대 10개. */
keywords: string[]; keywords: string[];
@ -274,6 +291,9 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */
export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우';
export interface WeatherSnapshot { export interface WeatherSnapshot {
temperature: number; temperature: number;
condition: string; condition: string;
@ -289,9 +309,7 @@ export interface WeatherSnapshot {
* ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다. * ★ 사장님이 자기 집을 두고 쓰는 문장이다 — 날씨 예보가 아니다.
*/ */
notes?: Partial<Record<WeatherMood, string>>; notes?: Partial<Record<WeatherMood, string>>;
noteSets?: Partial< noteSets?: Partial<Record<WeatherSky, string[]>>;
Record<WeatherMood | '구름많음' | '안개' | '소나기' | '뇌우' | '이슬비' | '강한비' | '어는비', string[]>
>;
tempNoteSets?: Partial<Record<WeatherBand, string[]>>; tempNoteSets?: Partial<Record<WeatherBand, string[]>>;
/** /**
* 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.** * 기온대별 한 줄 — **오늘 이 동네에서 뭘 하면 되나.**
@ -365,6 +383,12 @@ export interface RouteEntry {
} }
export interface Narrative { export interface Narrative {
/** 히어로 둘째 줄에서 순환할 문구. 없으면 렌더러의 공통 문구를 쓴다. */
catchphrases?: {
version: 1;
items: {text: string; kind?: 'general' | 'season' | 'month' | 'weather'}[];
};
heroHeadline?: string; heroHeadline?: string;
heroSubline?: string; heroSubline?: string;
tagline?: string; tagline?: string;

View File

@ -676,7 +676,7 @@ docker compose up -d
cd solution/site/scripts/mockup cd solution/site/scripts/mockup
python3 patch_stay.py python3 patch_stay.py
C=o2o-web4ai-solution-prerender C=o2o-web4ai-solution-worker
docker exec $C mkdir -p /app/solution/site/out/s/stay/img/people \ docker exec $C mkdir -p /app/solution/site/out/s/stay/img/people \
/app/solution/site/out/s/stay/img/mirror /app/solution/site/out/s/stay/audio \ /app/solution/site/out/s/stay/img/mirror /app/solution/site/out/s/stay/audio \
/app/solution/site/out/s/stay/vendor /app/solution/site/out/s/stay/vendor
@ -716,7 +716,7 @@ node audit-all.mjs https://web4ai.o2osolution.ai/s/stay
목업은 **다시 만들지 않는다.** `vendor/` 안의 css·js 한 벌만 새 번들로 바꾸고 주소를 다시 박는다. 목업은 **다시 만들지 않는다.** `vendor/` 안의 css·js 한 벌만 새 번들로 바꾸고 주소를 다시 박는다.
```bash ```bash
C=o2o-web4ai-solution-prerender C=o2o-web4ai-solution-worker
CSS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.css$" | head -1') CSS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.css$" | head -1')
JS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.js$" | head -1') JS=$(docker exec $C sh -c 'ls -t /app/solution/site/out/assets | grep -E "^index-.*\.js$" | head -1')
mkdir -p vendor/retired && mv vendor/index-*.css vendor/index-*.js vendor/retired/ mkdir -p vendor/retired && mv vendor/index-*.css vendor/index-*.js vendor/retired/
@ -759,7 +759,7 @@ python3 build_itinerary.py && python3 build_story.py && python3 build_reading.py
로컬 반영 · 검사: 로컬 반영 · 검사:
```bash ```bash
docker cp build/index.html o2o-web4ai-solution-prerender:/app/solution/site/out/s/stay/index.html docker cp build/index.html o2o-web4ai-solution-worker:/app/solution/site/out/s/stay/index.html
node audit-all.mjs # 47개 항목 node audit-all.mjs # 47개 항목
node rails-test.mjs # 카로셀 13개 (모바일은 +3) node rails-test.mjs # 카로셀 13개 (모바일은 +3)
python3 audit_schedule.py # 일정 전수 검수표(SCHEDULE.md) python3 audit_schedule.py # 일정 전수 검수표(SCHEDULE.md)
@ -773,7 +773,7 @@ python3 audit_schedule.py # 일정 전수 검수
tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio vendor tar czf /tmp/stay-bundle.tgz -C . build/index.html img audio vendor
scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/ scp -C /tmp/stay-bundle.tgz King_admin:~/data2/o2o-site-AEO/tmp-stay-deploy/
ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio vendor && tar xzf stay-bundle.tgz ssh King_admin "cd ~/data2/o2o-site-AEO/tmp-stay-deploy && rm -rf build img audio vendor && tar xzf stay-bundle.tgz
C=o2o-web4ai-solution-prerender C=o2o-web4ai-solution-worker
docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/img/mirror \ docker exec \$C mkdir -p /app/solution/site/out/s/stay/img/people /app/solution/site/out/s/stay/img/mirror \
/app/solution/site/out/s/stay/audio /app/solution/site/out/s/stay/vendor /app/solution/site/out/s/stay/audio /app/solution/site/out/s/stay/vendor
docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html docker cp build/index.html \$C:/app/solution/site/out/s/stay/index.html
@ -1205,7 +1205,7 @@ docker cp vendor/. \$C:/app/solution/site/out/s/stay/vendor/"
git rm -r solution/site/scripts/mockup git rm -r solution/site/scripts/mockup
# ② 서버 볼륨에서 (킹서버 · 로컬 각각) # ② 서버 볼륨에서 (킹서버 · 로컬 각각)
docker exec o2o-web4ai-solution-prerender rm -rf /app/solution/site/out/s/stay docker exec o2o-web4ai-solution-worker rm -rf /app/solution/site/out/s/stay
# ③ 킹서버 배포 임시 폴더 # ③ 킹서버 배포 임시 폴더
ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy" ssh King_admin "rm -rf ~/data2/o2o-site-AEO/tmp-stay-deploy"

View File

@ -0,0 +1,32 @@
import {chromium} from 'playwright';
const b = await chromium.launch({channel:'chrome'});
const p = await b.newContext({viewport:{width:390,height:844},deviceScaleFactor:2,isMobile:true,hasTouch:true}).then(c=>c.newPage());
await p.goto('http://localhost/s/stay',{waitUntil:'domcontentloaded'});
await p.waitForTimeout(7000);
console.log(JSON.stringify(await p.evaluate(() => {
const vis=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;};
const hdr = document.querySelector('header');
const burger = [...document.querySelectorAll('button')].filter(vis).find(b => /메뉴|menu/i.test((b.getAttribute('aria-label')||'')+b.className));
return {headerTag: hdr ? hdr.outerHTML.slice(0,320) : null,
burger: burger ? {label:burger.getAttribute('aria-label'), cls:burger.className.slice(0,80),
expanded:burger.getAttribute('aria-expanded'), controls:burger.getAttribute('aria-controls')} : null,
buttons: [...document.querySelectorAll('header button')].map(b=>({al:b.getAttribute('aria-label'), id:b.id, cls:b.className.slice(0,50)}))};
}), null, 1));
// 햄버거 열기
await p.evaluate(() => {
const vis=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&r.width>0;};
const b=[...document.querySelectorAll('header button')].filter(vis).pop();
b.click();
});
await p.waitForTimeout(1000);
console.log('=== 메뉴 열린 뒤');
console.log(JSON.stringify(await p.evaluate(() => {
const vis=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;};
const panel = document.querySelector('[role="dialog"], nav[id], #w4d-menu, [data-menu]') ;
const links = [...document.querySelectorAll('a')].filter(vis).filter(a=>(a.getAttribute('href')||'').startsWith('#'));
return {panelId: panel?panel.id||panel.tagName:null,
anchorLinks: links.map(a=>a.textContent.trim()+' '+a.getAttribute('href')).slice(0,20),
panelHTML: links.length ? links[0].parentElement.parentElement.outerHTML.slice(0,400) : null};
}), null, 1));
await p.screenshot({path: process.argv[2]+'/menu.png'});
await b.close();

View File

@ -0,0 +1,29 @@
import {chromium} from 'playwright';
const O = process.argv[2];
const b = await chromium.launch({channel:'chrome'});
const p = await b.newContext({viewport:{width:390,height:844},deviceScaleFactor:2,isMobile:true,hasTouch:true}).then(c=>c.newPage());
const bad=[],errs=[];
p.on('response',r=>{if(r.status()>=400)bad.push(r.status()+' '+r.url().slice(-48));});
p.on('console',m=>{if(m.type()==='error')errs.push(m.text().slice(0,90));});
for (const [n,u] of [['home','http://localhost/s/stay6'],['gunsan','http://localhost/s/stay6/gunsan'],['booking','http://localhost/s/stay6/booking']]) {
await p.goto(u,{waitUntil:'domcontentloaded'}); await p.waitForTimeout(7000);
await p.evaluate(()=>window.scrollTo(0,document.body.scrollHeight)); await p.waitForTimeout(1600);
await p.evaluate(()=>window.scrollTo(0,0)); await p.waitForTimeout(1200);
const i = await p.evaluate(()=>{
const vis=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();
return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0&&!e.closest('[hidden]');};
const taps=[...document.querySelectorAll('a,button,summary,input,textarea')].filter(vis);
return {h:document.body.scrollHeight, screens:+(document.body.scrollHeight/844).toFixed(1),
sections:[...document.querySelectorAll('section')].filter(vis).length,
h2:[...document.querySelectorAll('h2')].filter(vis).map(x=>x.textContent.trim()),
nav: [...document.querySelectorAll('#w4d-pages a')].map(a=>a.textContent.trim()+(a.getAttribute('aria-current')?'*':'')),
taps:taps.length, small:taps.filter(e=>{const r=e.getBoundingClientRect();return r.height<44||r.width<44;}).length,
ov:document.documentElement.scrollWidth>window.innerWidth};
});
console.log('== '+n, JSON.stringify(i));
const k=Math.min(Math.ceil(i.h/844),10);
for(let j=0;j<k;j++){ await p.evaluate(y=>window.scrollTo(0,y),j*844); await p.waitForTimeout(550);
await p.screenshot({path:`${O}/${n}-${String(j).padStart(2,'0')}.png`}); }
}
console.log('errors:',errs.slice(0,4)); console.log('bad:',[...new Set(bad)].slice(0,6));
await b.close();

View File

@ -0,0 +1,26 @@
import {chromium} from 'playwright';
const O = process.argv[2];
const b = await chromium.launch({channel:'chrome'});
const p = await b.newContext({viewport:{width:390,height:844},deviceScaleFactor:2,isMobile:true,hasTouch:true}).then(c=>c.newPage());
const bad=[],errs=[];
p.on('response',r=>{if(r.status()>=400)bad.push(r.status()+' '+r.url().slice(-44));});
p.on('console',m=>{if(m.type()==='error')errs.push(m.text().slice(0,80));});
for (const [n,u] of [['home','http://localhost/s/stay6'],['gunsan','http://localhost/s/stay6/gunsan'],['booking','http://localhost/s/stay6/booking']]) {
await p.goto(u,{waitUntil:'domcontentloaded'}); await p.waitForTimeout(6500);
const i = await p.evaluate(()=>{
const vis=e=>{const s=getComputedStyle(e),r=e.getBoundingClientRect();return s.display!=='none'&&s.visibility!=='hidden'&&r.width>0&&r.height>0;};
const bars=[...document.querySelectorAll('header,#w4d-pages')].filter(vis)
.map(e=>({tag:e.tagName+(e.id?'#'+e.id:''), h:Math.round(e.getBoundingClientRect().height), top:Math.round(e.getBoundingClientRect().top)}));
const nav=[...document.querySelectorAll('header a')].filter(vis).map(a=>a.textContent.trim()+' → '+a.getAttribute('href'));
const ids=[...document.querySelectorAll('section[id]')].filter(vis).map(s=>s.id);
const dead=[...document.querySelectorAll('a[href^="#"]')].filter(vis)
.map(a=>a.getAttribute('href').slice(1)).filter(h=>h&&!document.getElementById(h));
return {bars, nav, sectionIds:ids, deadAnchors:[...new Set(dead)],
screens:+(document.body.scrollHeight/844).toFixed(1),
ov:document.documentElement.scrollWidth>window.innerWidth};
});
console.log('== '+n, JSON.stringify(i));
await p.screenshot({path:`${O}/${n}-top.png`});
}
console.log('errors:',errs.slice(0,3)); console.log('bad:',[...new Set(bad)].slice(0,5));
await b.close();

View File

@ -0,0 +1,63 @@
Creative Commons Attribution 3.0 Unported
http://creativecommons.org/licenses/by/3.0/
License
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED.
BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH TERMS AND CONDITIONS.
1. Definitions
1. "Adaptation" means a work based upon the Work, or upon the Work and other pre-existing works, such as a translation, adaptation, derivative work, arrangement of music or other alterations of a literary or artistic work, or phonogram or performance and includes cinematographic adaptations or any other form in which the Work may be recast, transformed, or adapted including in any form recognizably derived from the original, except that a work that constitutes a Collection will not be considered an Adaptation for the purpose of this License. For the avoidance of doubt, where the Work is a musical work, performance or phonogram, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered an Adaptation for the purpose of this License.
2. "Collection" means a collection of literary or artistic works, such as encyclopedias and anthologies, or performances, phonograms or broadcasts, or other works or subject matter other than works listed in Section 1(f) below, which, by reason of the selection and arrangement of their contents, constitute intellectual creations, in which the Work is included in its entirety in unmodified form along with one or more other contributions, each constituting separate and independent works in themselves, which together are assembled into a collective whole. A work that constitutes a Collection will not be considered an Adaptation (as defined above) for the purposes of this License.
3. "Distribute" means to make available to the public the original and copies of the Work or Adaptation, as appropriate, through sale or other transfer of ownership.
4. "Licensor" means the individual, individuals, entity or entities that offer(s) the Work under the terms of this License.
5. "Original Author" means, in the case of a literary or artistic work, the individual, individuals, entity or entities who created the Work or if no individual or entity can be identified, the publisher; and in addition (i) in the case of a performance the actors, singers, musicians, dancers, and other persons who act, sing, deliver, declaim, play in, interpret or otherwise perform literary or artistic works or expressions of folklore; (ii) in the case of a phonogram the producer being the person or legal entity who first fixes the sounds of a performance or other sounds; and, (iii) in the case of broadcasts, the organization that transmits the broadcast.
6. "Work" means the literary and/or artistic work offered under the terms of this License including without limitation any production in the literary, scientific and artistic domain, whatever may be the mode or form of its expression including digital form, such as a book, pamphlet and other writing; a lecture, address, sermon or other work of the same nature; a dramatic or dramatico-musical work; a choreographic work or entertainment in dumb show; a musical composition with or without words; a cinematographic work to which are assimilated works expressed by a process analogous to cinematography; a work of drawing, painting, architecture, sculpture, engraving or lithography; a photographic work to which are assimilated works expressed by a process analogous to photography; a work of applied art; an illustration, map, plan, sketch or three-dimensional work relative to geography, topography, architecture or science; a performance; a broadcast; a phonogram; a compilation of data to the extent it is protected as a copyrightable work; or a work performed by a variety or circus performer to the extent it is not otherwise considered a literary or artistic work.
7. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation.
8. "Publicly Perform" means to perform public recitations of the Work and to communicate to the public those public recitations, by any means or process, including by wire or wireless means or public digital performances; to make available to the public Works in such a way that members of the public may access these Works from a place and at a place individually chosen by them; to perform the Work to the public by any means or process and the communication to the public of the performances of the Work, including by public digital performance; to broadcast and rebroadcast the Work by any means including signs, sounds or images.
9. "Reproduce" means to make copies of the Work by any means including without limitation by sound or visual recordings and the right of fixation and reproducing fixations of the Work, including storage of a protected performance or phonogram in digital form or other electronic medium.
2. Fair Dealing Rights. Nothing in this License is intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in connection with the copyright protection under copyright law or other applicable laws.
3. License Grant. Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below:
1. to Reproduce the Work, to incorporate the Work into one or more Collections, and to Reproduce the Work as incorporated in the Collections;
2. to create and Reproduce Adaptations provided that any such Adaptation, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified.";
3. to Distribute and Publicly Perform the Work including as incorporated in Collections; and,
4. to Distribute and Publicly Perform Adaptations.
5.
For the avoidance of doubt:
1. Non-waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme cannot be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License;
2. Waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme can be waived, the Licensor waives the exclusive right to collect such royalties for any exercise by You of the rights granted under this License; and,
3. Voluntary License Schemes. The Licensor waives the right to collect royalties, whether individually or, in the event that the Licensor is a member of a collecting society that administers voluntary licensing schemes, via that society, from any exercise by You of the rights granted under this License.
The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. Subject to Section 8(f), all rights not expressly granted by Licensor are hereby reserved.
4. Restrictions. The license granted in Section 3 above is expressly made subject to and limited by the following restrictions:
1. You may Distribute or Publicly Perform the Work only under the terms of this License. You must include a copy of, or the Uniform Resource Identifier (URI) for, this License with every copy of the Work You Distribute or Publicly Perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of the recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties with every copy of the Work You Distribute or Publicly Perform. When You Distribute or Publicly Perform the Work, You may not impose any effective technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collection, but this does not require the Collection apart from the Work itself to be made subject to the terms of this License. If You create a Collection, upon notice from any Licensor You must, to the extent practicable, remove from the Collection any credit as required by Section 4(b), as requested. If You create an Adaptation, upon notice from any Licensor You must, to the extent practicable, remove from the Adaptation any credit as required by Section 4(b), as requested.
2. If You Distribute, or Publicly Perform the Work or any Adaptations or Collections, You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or if the Original Author and/or Licensor designate another party or parties (e.g., a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; (ii) the title of the Work if supplied; (iii) to the extent reasonably practicable, the URI, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and (iv) , consistent with Section 3(b), in the case of an Adaptation, a credit identifying the use of the Work in the Adaptation (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4 (b) may be implemented in any reasonable manner; provided, however, that in the case of a Adaptation or Collection, at a minimum such credit will appear, if a credit for all contributing authors of the Adaptation or Collection appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties.
3. Except as otherwise agreed in writing by the Licensor or as may be otherwise permitted by applicable law, if You Reproduce, Distribute or Publicly Perform the Work either by itself or as part of any Adaptations or Collections, You must not distort, mutilate, modify or take other derogatory action in relation to the Work which would be prejudicial to the Original Author's honor or reputation. Licensor agrees that in those jurisdictions (e.g. Japan), in which any exercise of the right granted in Section 3(b) of this License (the right to make Adaptations) would be deemed to be a distortion, mutilation, modification or other derogatory action prejudicial to the Original Author's honor and reputation, the Licensor will waive or not assert, as appropriate, this Section, to the fullest extent permitted by the applicable national law, to enable You to reasonably exercise Your right under Section 3(b) of this License (right to make Adaptations) but not otherwise.
5. Representations, Warranties and Disclaimer
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
6. Limitation on Liability. EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
7. Termination
1. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Adaptations or Collections from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License.
2. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above.
8. Miscellaneous
1. Each time You Distribute or Publicly Perform the Work or a Collection, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License.
2. Each time You Distribute or Publicly Perform an Adaptation, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License.
3. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable.
4. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent.
5. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. This License may not be modified without the mutual written agreement of the Licensor and You.
6. The rights granted under, and the subject matter referenced, in this License were drafted utilizing the terminology of the Berne Convention for the Protection of Literary and Artistic Works (as amended on September 28, 1979), the Rome Convention of 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and the Universal Copyright Convention (as revised on July 24, 1971). These rights and subject matter take effect in the relevant jurisdiction in which the License terms are sought to be enforced according to the corresponding provisions of the implementation of those treaty provisions in the applicable national law. If the standard suite of rights granted under applicable copyright law includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law.

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More