merge: feature/template-catalog(숙박 템플릿 8종 · 문서 정리) 을 main 에 병합

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-30 14:00:12 +09:00
commit e6a46154b1
813 changed files with 24647 additions and 30364 deletions

View File

@ -11,7 +11,6 @@
| 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) | | 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) |
| 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | | 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) | | **어느 표 어느 칸**에 담기나 · 값이 페이지까지 가는 길 | [docs/DATA_MODEL.md](docs/DATA_MODEL.md) |
| **다음에 뭘 만드나** (우선순위 P0~P4) | [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) |
| 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) | | 미결 사항 · 코드가 그걸 어떻게 격리했나 | [docs/DECISIONS.md](docs/DECISIONS.md) |
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) | | 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) | | 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
@ -20,6 +19,9 @@
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) | | **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) | | **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
| 카톡으로 **무엇을 시킬 수 있나** (운영자·CS 용) | [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) | | 카톡으로 **무엇을 시킬 수 있나** (운영자·CS 용) | [docs/AGENT_GUIDE.md](docs/AGENT_GUIDE.md) |
| **템플릿** 추가 · 렌더링 순서 · frontend/shared/site 역할 | [docs/TEMPLATES.md](docs/TEMPLATES.md) |
| 템플릿 **화면 규칙** (글자 · 간격 · 접기 · ✓ 표시) | [docs/TEMPLATE_DESIGN.md](docs/TEMPLATE_DESIGN.md) |
| **렌더링** 케이스별 흐름(정적 · 미리보기 · 발행)과 담당 파일 | [docs/RENDERING.md](docs/RENDERING.md) |
--- ---

View File

@ -55,13 +55,16 @@ postgres-init/ 스키마 DDL
의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다. 의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다.
근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md). 근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
> **`admin/` 은 지금 쓰지 않는다.** 개발자용 사이트·유저 관리는 solution 앱 안의 개발자 메뉴로
> 가볍게 처리하고 있다(DEVLOG 2026-09-23). 우리가 따로 관리해야 할 만큼 사이트·운영 규모가 커지면
> 그때 `admin/` 을 개발한다. 그 전에는 새 기능을 여기에 붙이지 않는다.
## 문서 지도 ## 문서 지도
| 문서 | 언제 읽나 | | 문서 | 언제 읽나 |
|---|---| |---|---|
| [docs/PRODUCT.md](docs/PRODUCT.md) | 이 제품이 뭘 푸는지 · **안 하기로 한 것**이 뭔지 | | [docs/PRODUCT.md](docs/PRODUCT.md) | 이 제품이 뭘 푸는지 · **안 하기로 한 것**이 뭔지 |
| [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 발행 파이프라인 전체 · 두 앱과 한 백엔드의 경계 | | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) | 발행 파이프라인 전체 · 두 앱과 한 백엔드의 경계 |
| [docs/DEVELOPMENT_DIRECTION.md](docs/DEVELOPMENT_DIRECTION.md) | v19 설계서 대비 격차 · **개발 우선순위(P0~P4)** |
| [docs/DECISIONS.md](docs/DECISIONS.md) | 미결 사항과, 코드가 그걸 어떻게 격리해 뒀는지 | | [docs/DECISIONS.md](docs/DECISIONS.md) | 미결 사항과, 코드가 그걸 어떻게 격리해 뒀는지 |
| [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 | | [docs/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) | | [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |

View File

@ -1,10 +1,4 @@
"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다. """어드민 API."""
도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라
(전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다.
경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에
내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
"""
import time import time
@ -32,7 +26,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다. # 크론은 :9800 담당.
yield yield
await DB_SESSION_MNG.dispose_all() await DB_SESSION_MNG.dispose_all()

View File

@ -1,5 +1,4 @@
# 어드민 API 서버 (:9801). 근거는 app.py 주석. # 어드민 API 서버 (:9801).
# PYTHONPATH=../../solution/backend python main.py
import os import os

View File

@ -1,6 +1,4 @@
// 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다. // 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다.
// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려
// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.)
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
@ -18,7 +16,7 @@ export default tseslint.config({
// 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn. // 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn.
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn', 'react-hooks/exhaustive-deps': 'warn',
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용. // 선언보다 위에서 변수를 쓰는 것(TDZ) 차단.
'no-use-before-define': 'off', 'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [ '@typescript-eslint/no-use-before-define': [
'error', 'error',

View File

@ -5,14 +5,7 @@ import {getAccessToken, me} from '@/api';
import {queryClient} from '@/lib/query-client'; import {queryClient} from '@/lib/query-client';
import {toAuthUser, useAuthStore} from '@/stores/auth'; import {toAuthUser, useAuthStore} from '@/stores/auth';
/** /** 저장된 액세스 토큰으로 세션을 복구한다. */
* 저장된 액세스 토큰으로 세션을 복구한다.
*
* ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.**
* 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/frontend/app/provider.tsx),
* 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘
* 느슨한 쪽으로 나기 때문에 앱을 갈랐다.
*/
function useRestoreSession() { function useRestoreSession() {
const setUser = useAuthStore((s) => s.setUser); const setUser = useAuthStore((s) => s.setUser);
const finishRestore = useAuthStore((s) => s.finishRestore); const finishRestore = useAuthStore((s) => s.finishRestore);
@ -31,7 +24,7 @@ function useRestoreSession() {
setUser(toAuthUser(res)); setUser(toAuthUser(res));
}) })
.catch(() => { .catch(() => {
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */ /* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. */
}) })
.finally(() => { .finally(() => {
if (alive) finishRestore(); if (alive) finishRestore();

View File

@ -10,20 +10,14 @@ import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
import {PlaceListPage} from '@admin/pages/PlaceListPage'; import {PlaceListPage} from '@admin/pages/PlaceListPage';
import {SeoAuditPage} from '@admin/pages/SeoAuditPage'; import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
/** /** 내부 메뉴는 여기 있다. */
* ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이
* 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다.
*/
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}, {to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
]; ];
/** /** 내부 운영 화면. */
* 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간
* 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다.
*/
export const router = createBrowserRouter([ export const router = createBrowserRouter([
// selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다. // selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
{path: '/login', element: <LoginPage selfServe={false} />}, {path: '/login', element: <LoginPage selfServe={false} />},

View File

@ -1,7 +1,4 @@
/** /** 사장님 앱 오리진. */
* 사장님 앱 오리진. 빌더는 다른 오리진이라(:3000 vs :3002) react-router Link 로 두면
* admin 안에서 404 다. 절대 URL + 새 탭으로 연다.
*/
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000'; const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string { export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {

View File

@ -9,8 +9,7 @@ import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner'; import {toast} from 'sonner';
type Status = 1 | 2 | 3; type Status = 1 | 2 | 3;
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로 // LocalContentType — common/enums.py 와 값을 맞춘다.
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
type ContentType = 2 | 3 | 4; type ContentType = 2 | 3 | 4;
type LocalContent = { type LocalContent = {
@ -107,8 +106,7 @@ export function LocalContentPage() {
} catch { toast.error('발행하지 못했습니다.'); } } catch { toast.error('발행하지 못했습니다.'); }
}; };
const sync = async () => { const sync = async () => {
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다. // 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim(); const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
if (!placeId) return; if (!placeId) return;
setSyncing(true); setSyncing(true);
@ -118,7 +116,6 @@ export function LocalContentPage() {
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean; festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'}); }>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
if (res.result?.success === false) throw new Error(res.msg); if (res.result?.success === false) throw new Error(res.msg);
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`; const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
if (!res.changed) { if (!res.changed) {
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`); toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);

View File

@ -45,8 +45,7 @@ export function PlaceDetailPage() {
const transition = useTransitionFact({ const transition = useTransitionFact({
mutation: { mutation: {
onSuccess: (res) => { onSuccess: (res) => {
// ★ 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 // 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 저장되지 않은 값이 '확인됨'으로 보인다.
// 저장되지 않은 값이 '확인됨'으로 보인다.
if (res.result?.success === false) { if (res.result?.success === false) {
notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.'); notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.');
return; return;
@ -177,8 +176,7 @@ export function PlaceDetailPage() {
</section> </section>
<div className="space-y-5"> <div className="space-y-5">
{/* ★ 재수집은 오른쪽 열 맨 위다. 이 화면에 온 사장님의 두 가지 용건이 {/* 재수집은 오른쪽 열 맨 위다. */}
"확인 대기 값을 처리한다"(왼쪽)와 "값을 다시 가져온다"(여기)라서다. */}
<RecollectPanel placeId={placeId} /> <RecollectPanel placeId={placeId} />
<PasteFactsPanel placeId={placeId} /> <PasteFactsPanel placeId={placeId} />

View File

@ -35,16 +35,7 @@ const STATUS_LABEL: Record<number, string> = {
[PlaceStatus.SUSPENDED]: '중지', [PlaceStatus.SUSPENDED]: '중지',
}; };
/** /** 사업장 목록 — 이 제품의 허브다. */
* 사업장 목록 — 이 제품의 허브다.
*
* 흐름은 하나뿐이다:
* 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다.
*
* ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의
* 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은
* [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다.
*/
export function PlaceListPage() { export function PlaceListPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [deletingId, setDeletingId] = useState<string | null>(null); const [deletingId, setDeletingId] = useState<string | null>(null);
@ -155,7 +146,7 @@ export function PlaceListPage() {
> >
{STATUS_LABEL[place.status] ?? '알 수 없음'} {STATUS_LABEL[place.status] ?? '알 수 없음'}
</Badge> </Badge>
{/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */} {/* verified_at 이 NULL 이면 수집·발행 진입 금지. */}
{place.verified_at ? ( {place.verified_at ? (
<span title="동일 업소 검증 완료" className="text-success"> <span title="동일 업소 검증 완료" className="text-success">
<ShieldCheck className="size-4" /> <ShieldCheck className="size-4" />

View File

@ -7,12 +7,7 @@ import {Card, CardContent} from '@/components/ui/card';
import {customFetch} from '@/api/mutator/custom-fetch'; import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner'; import {toast} from 'sonner';
/** /** 이용 후기 — 손님 글은 이미 화면에 올라가 있다. */
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
*
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
*/
type Review = { type Review = {
review_id: string; review_id: string;
place_id: string; place_id: string;

View File

@ -3,13 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import {defineConfig} from 'vite'; import {defineConfig} from 'vite';
/** /** 내부 운영 화면. */
* 내부 운영 화면. 사장님 앱(solution/frontend)과 번들이 갈린다.
*
* `@` 를 이 앱이 아니라 사장님 앱 src 로 겨눈다 — 내부 화면이 쓰는 API·UI·수집 배선이
* 거기 한 벌만 있고 그 파일들끼리도 `@/...` 로 서로를 부른다(자기 src 로 잡으면 TS2307 14건).
* 이 앱 고유 파일은 `@admin`. 의존 방향은 admin → solution 한 쪽뿐이다.
*/
export default defineConfig({ export default defineConfig({
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {

View File

@ -126,7 +126,7 @@ o2o-web4ai/
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드 │ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
│ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트) │ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트)
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더) │ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰) │ └─ shared/ frontend·site·백엔드 계약 (템플릿 목록 · SitePayload · slug · 토큰)
│ │
├─ admin/ 우리 — 전체 사이트 운영 ├─ admin/ 우리 — 전체 사이트 운영
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend │ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
@ -142,6 +142,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다 ### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다
세 폴더가 각각 무엇을 하는지, 템플릿이 그려지는 순서는 [TEMPLATES.md](TEMPLATES.md)에 있다.
같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라 같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다. 발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.

View File

@ -70,7 +70,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
[에디터] [에디터]
템플릿 고르기 ─────────────→ sites.template_id 템플릿 고르기 ─────────────→ sites.template_id
색·서체·섹션 순서/on-off ──→ sites.theme (JSONB) 색·섹션 순서/on-off ───────→ sites.theme (JSONB)
섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행) 섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행)
주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m 주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m
미리보기 ──────────────────→ GET /v1/place/{id}/site/preview 미리보기 ──────────────────→ GET /v1/place/{id}/site/preview
@ -225,8 +225,8 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
| 칸 | 무엇 | 왜 서버에 두나 | | 칸 | 무엇 | 왜 서버에 두나 |
|---|---|---| |---|---|---|
| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 | | `template_id` | 사장님이 고른 템플릿 id(`simple` `magazine` `retro` `paper`). NULL 이면 업종 기본 템플릿 | 템플릿 목록은 `solution/shared/src/data/templates.json` 한 파일이고, 서버도 그 파일을 읽어 업종이 못 쓰는 값은 저장·발행 때 거절한다([TEMPLATES.md](TEMPLATES.md)) |
| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 | | `theme` (JSONB) | 색·**섹션 순서/on-off** (서체·모서리 같은 모양은 템플릿이 정하므로 저장값을 쓰지 않는다) | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
| `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 | | `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 |
| `current_version_id` | 지금 나가 있는 버전 | | | `current_version_id` | 지금 나가 있는 버전 | |
| `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 | | `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 |

View File

@ -226,7 +226,7 @@
골라도 그 자리가 비었다. 그래서 서버가 채운다. 골라도 그 자리가 비었다. 그래서 서버가 채운다.
**2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더 **2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더
(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는 (당시 `canvas/dataSpec.ts`, 지금은 `builder/sections/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿 그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿
설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다. 설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다.
→ 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다 → 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다
@ -394,7 +394,7 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다. key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다. - 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다 업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
(`frontend … canvas/variants/faq/useFaqList.ts` 주석). (`frontend … canvas/variants/faq/useFaqList.ts` 주석, 이 파일은 2026-09-28 배치 고르기와 함께 지웠다).
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다. - 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다. - ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 — 그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —

View File

@ -1,214 +0,0 @@
# Web4AI 개발 방향 — v19 설계서와 현재 구현 비교
> 기준일: 2026-08-31
> 비교 대상: `Web4AI_SW설계서_및_개발일정_v19.pdf`(22쪽)와 이 저장소의 현재 코드·문서
> 목적: 설계서를 그대로 복제하는 것이 아니라, 현재 제품에서 **유지할 결정**, **방향을 다시 정할 결정**, **추가할 개발 항목**을 구분한다.
설계서 표지는 파일명과 달리 `v18 · 2026.08.30`으로 표기되어 있다. 아래에서는 전달받은 파일을 편의상 “v19 설계서”라고 부르되, 계약·일정 확정 전 문서 버전부터 확인해야 한다.
---
## 1. 결론
현재 프로젝트는 설계서 전체의 축소판이 아니라, 설계서의 **Site AEO(A1~A8) 일부를 소상공인용 제품으로 먼저 구현한 별도 MVP**에 가깝다.
- 현재 강점은 `사업장 확인 → 허용된 소스 수집 → fact 승인 → 근거 기반 문구 생성 → 정적 HTML 발행 → IndexNow`가 실제 코드와 테스트로 연결되어 있다는 점이다.
- 가장 큰 공백은 **Brand AEO 전체(B1~B9)**, **규제 검사(A4)**, **소유권 검증(A1)**, **원본 변경·AI 크롤러 재방문 추적(A9)**이다.
- 가장 큰 방향 충돌은 **타겟 업종**, **Playwright 크롤링**, **배포 도메인**, **마이크로서비스·공통 인프라**다. 이 항목은 “미구현”으로 보고 바로 만들면 안 되고 제품·법무·운영 결정을 먼저 내려야 한다.
- 권장 방향은 현 구조를 버리고 5계층/2엔진으로 즉시 재작성하는 것이 아니다. 현재 시스템을 **Site AEO MVP 기준선**으로 유지하고, Brand AEO를 경계가 분명한 모듈로 붙인 뒤 부하와 조직 규모가 실제 분리를 요구할 때 서비스로 분리한다.
### 현재 범위의 대략적인 위치
| 설계서 영역 | 현재 판단 |
|---|---|
| Site AEO A1~A9 | **부분 구현** — A3·A5·A6·A7 일부와 A8 중심 |
| Brand AEO B1~B9 | **미구현** — 준비도 자체 점수만 있으며 실제 AI 응답 측정은 없음 |
| 운영 콘솔 15개 화면 | **부분 구현** — 사업장·빌더·지역정보·SEO/AEO 준비도 중심 |
| 계약 A~G / BFF | **미구현** — 화면이 FastAPI 를 직접 호출 (BFF 없음) |
| 25테이블 append-only Fact Graph | **다른 모델로 구현** — 승인 후보/노출값 중심의 key-value fact 모델 |
| 8개 스프린트 일정 | **현재 코드에 바로 적용 불가** — 이미 구현된 것과 방향 충돌 항목이 섞여 있어 재산정 필요 |
---
## 2. 방향이 다른 부분
아래는 단순히 덜 만든 기능이 아니라, 설계서와 현재 프로젝트가 서로 다른 결정을 내린 항목이다.
| 항목 | v19 설계서 | 현재 프로젝트 | 권장 판단 |
|---|---|---|---|
| 제품 범위 | Site AEO + Brand AEO 이원 플랫폼 | 상호명 기반 소상공인 정적 홈페이지 생성·발행 | 현재 제품을 Site AEO MVP로 명시하고 Brand AEO 확장 여부를 별도 마일스톤으로 승인 |
| 1차 업종 | 숙박, 법무법인, 성형외과 | 숙박, 카페, 음식점, 관광체험 | **반드시 사업 결정 필요.** 법률·의료를 넣으면 데이터 스키마뿐 아니라 규제·승인·감사 체계가 선행되어야 함 |
| 입력 방식 | 고객 원본 사이트를 depth 3·최대 200페이지 크롤 | 공식 API, 사용자 확정 URL, 정적 HTML; 플랫폼 우회 수집 금지 | 현재의 출처·동의·robots 원칙 유지. 대규모 원본 사이트 수집은 별도 제품 모드로 분리 |
| 동적 크롤링 | 정적 우선, Playwright 폴백 | 봇 탐지 우회로 변질될 수 있어 HeadlessAdapter 미등록·금지 | 포괄적인 Playwright 폴백은 채택하지 않음. 소유권이 검증된 고객 도메인에만 허용할지 법무·보안 결정 후 제한적으로 검토 |
| 발행 도메인 | 고객 도메인 서브패스 권장, 서브도메인 차선 | 기본 `web4ai.o2osolution.ai/s/<slug>`, custom domain 경로 미완성 | 설계서 방향이 검색 권위 측면에서 더 적합. 고객 도메인 연결·소유권 검증을 우선 과제로 추가 |
| 백엔드 구조 | Site/Brand 엔진, BFF, 엔진별 DB 분리 | 코드 한 벌 + 진입점 둘(:9800 사장님 / :9801 내부), 단일 PostgreSQL | 청중별 분리는 포트로 끝냈다. 엔진별 물리 분리는 트래픽·팀 소유권 근거가 생긴 뒤 수행 |
| 작업 인프라 | Temporal, Redis, Celery 등 공통 인프라 | PostgreSQL 잡 큐 + lease + dead-letter | 현재 DB 큐 유지. 동일 책임의 인프라를 중복 도입하지 않음. 장기 워크플로 보상·분산 추적 요구가 확인될 때 Temporal 재평가 |
| Fact Graph | 엔티티·predicate·snapshot, append-only | 업종 스키마 기반 key-value fact, 후보/노출/이력 상태 | 현재 모델은 발행 안전성에 적합. Brand 측정 재현성에 필요한 snapshot과 entity 관계만 점진적으로 확장 |
| 점수 | Site AEO Score + 실제 4개 AI 엔진 기반 AVS | 내부 데이터 기반 SEO/AEO **준비도** 점수 | 이름과 의미를 분리 유지. 실제 측정 전 현재 점수를 AVS/가시성 점수라고 부르지 않음 |
| 운영 사용자 | Reviewer/Owner 권한과 15개 통합 화면 | USER/OWNER/DEVELOPER, 사장님 앱(:3000)과 내부 콘솔(:3002)이 갈려 있다 | 규제 기능 도입 전 Reviewer 역할·서버 계산 `allowed_actions` 추가 |
| 이미지 | 원본 사진·영상이 EEAT 근거 | 이미지 호스팅을 현재 non-goal로 두고 외부 URL 사용 | 저작권 결론과 소유자 업로드 저장소가 먼저. 크롤 이미지 재게시를 전제로 개발하지 않음 |
---
## 3. 설계서 항목별 구현 차이
### 3-1. Site AEO A1~A9
| 단계 | 현재 상태 | 코드 근거 | 추가할 것 |
|---|---|---|---|
| A1 사이트 진단·소유권 검증 | **일부** | 사업장 동일 업소 확인과 `verified_at`, SEO 진단은 있으나 DNS TXT/meta/well-known 검증은 없음 | 도메인 소유권 challenge, 만료·재검증, 발행 차단 정책 |
| A2 크롤·추출 | **일부** | collector registry, `StaticHtmlAdapter`, TourAPI, 네이버 장소 조회, 사용자 확정 링크 | 허용 도메인용 crawl run/document 기록, 원본 hash, 제한·재시도·수집 보고서 |
| A3 Fact Graph | **부분 구현** | `facts`, 업종 스키마, 출처·신뢰도·상태 전이, 승인 후보 모델 | source URL의 selector/snippet, entity 관계, 측정용 불변 snapshot |
| A4 규제·과장 검사 | **기초만 존재** | 생성 문구의 과장·근거 없는 숫자/시설 검사는 있으나 업종별 법규 3단 분류와 승인 감사는 없음 | 외부화된 규칙, SAFE/REVIEW/BLOCK 판정, 규칙 버전, 근거 snippet, Reviewer 승인 로그 |
| A5 AEO 콘텐츠 생성 | **구현** | Gemini Text, 소개문·meta·FAQ, 근거 fact key, ground check | 질문은행과 생성 페이지의 연결, 질문형 콘텐츠 단위의 버저닝 |
| A6 JSON-LD | **구현** | 업종별 JSON-LD, FAQPage, Breadcrumb, WebPage, sameAs | 법률·의료 도입 시 타입·자격·저자 스키마 확장 |
| A7 3-way 일치성 | **부분 구현** | JSON-LD↔표시 텍스트 검증, 생성문↔fact 근거 검사, publish gate | 원본↔발행본 SimHash 중복도, 모든 사실 문장의 fact ID 역참조 보고서 |
| A8 배포 | **대부분 구현** | 프리렌더 정적 HTML, canonical, sitemap, robots, llms.txt, IndexNow, nginx/Azure 경로 | 고객 도메인 서브패스·서브도메인 연결, TLS/DNS 자동화, Search Console 제출 자동화 여부 |
| A9 모니터링·변경 감지 | **미구현** | `AI_CHECK` enum 만 있고 worker handler·보고 모듈 없음. 표(`ai_check_results`)는 한 번도 안 써서 마이그레이션 0006 이 뗐다 — 붙일 때 다시 만든다 | CDN 크롤러 로그 수집, 원본 hash 비교, fact 만료/재검토, 재생성 큐 |
### 3-2. Brand AEO B1~B9
현재 `seo_audit.py`의 점수는 “발행 준비도”다. 설계서의 Brand AEO처럼 ChatGPT·Gemini·Claude·Perplexity 응답을 정기 호출하여 브랜드 언급·인용·순위·사실성을 측정하지 않는다.
필요한 기능은 다음 순서가 적절하다.
1. **B1 질문은행**: 업종×지역×의도 질문, 버전, 활성 세트, 고객 편집 이력
2. **B2 측정 스케줄**: 주기, 엔진, 반복 횟수, 비용 상한, 중지 조건
3. **B3~B4 엔진 어댑터와 원문 보존**: 모델·버전·프롬프트·응답·citation 정규화
4. **B5 분석**: 브랜드 alias 언급, 인용 URL, 추천 위치, 감성, fact snapshot 대조
5. **B8 이원 점수**: 원계열, 반복 평균, 신뢰구간, MA4를 모두 보존하고 준비도 점수와 별도 표시
6. **B6~B7 개선 루프**: EEAT 결손과 외부 인용원을 제안하되, 자동 발행하지 않고 fact 확인/A4/A7을 재통과
7. **B9 리포트**: 주간·월간 리포트, 모델 교체 마커, 비용과 데이터 결손 표시
100문항×4엔진×3회라는 설계서 기본값은 테넌트당 주 1,200회 호출이다. 현재 제품 원가 상한인 **사이트당 약 $1**과 충돌할 가능성이 높으므로, 구현 전 모델별 실측 단가와 파일럿 질문 수를 다시 계산해야 한다. 초기에는 10~20개 핵심 질문, 1~2개 엔진, 반복 3회로 시작하고 통계적 유효성과 비용을 함께 측정하는 편이 안전하다.
### 3-3. 콘솔·계약·데이터
- 내부 콘솔(`admin/frontend`)에는 사업장 목록/상세, 지역 콘텐츠, SEO 진단이 있고 빌더는 사장님 앱(`solution/frontend`)에 있다. 설계서의 질문 빌더·규제 승인·AI 퍼포먼스·랭킹·인용출처·정기 리포트 화면은 없다.
- 화면이 API 를 직접 호출한다. 설계서의 BFF, 서비스 JWT 교환, 섹션별 부분 실패, 계약 A~G는 없다.
- DB에는 현재 17개 ORM 모델이 있으며 설계서의 `document`, `predicate_def`, `entity`, `fact_snapshot`, `compliance_rule`, `review`, `publication_question`, `index_state`, `regeneration_request`, `event_outbox`, `audit_log` 등에 해당하는 완성 모델은 없다.
- 현재 fact는 수정 잠금과 후보 이력을 보존하지만, 설계서가 요구하는 전체 append-only 불변식·스냅샷 재현성 모델과 같지는 않다.
계약 A~G를 한 번에 33개 REST/7개 이벤트로 만드는 것보다, 먼저 아래 세 계약만 버전 고정하는 것을 권장한다.
1. `FactSnapshot`: 측정 당시의 발행 사실을 재현할 수 있는 읽기 전용 계약
2. `QuestionSet`: 생성 대상과 측정 대상을 같은 question ID로 연결하는 계약
3. `Publication`: 발행 URL과 question ID를 연결해 인용 성과를 귀속하는 계약
이 세 계약이 있어야 Brand AEO 결과가 단순한 “브랜드가 나왔다”를 넘어 “어떤 질문을 겨냥한 어떤 페이지가 인용됐다”까지 설명할 수 있다.
---
## 4. 권장 개발 우선순위
### P0 — 개발 전에 확정할 결정
- **제품 범위**: Site AEO 소상공인 MVP를 유지할지, 법률·의료와 Brand AEO를 이번 제품 범위에 포함할지
- **1차 파일럿**: Stay 머뭄 1곳 우선인지, 3업종 동시인지
- **도메인 전략**: 고객 서브패스 / 고객 서브도메인 / 플랫폼 공용 경로의 지원 우선순위
- **크롤 정책**: 소유권 검증 고객 도메인의 JS 렌더링 허용 범위. 플랫폼 robots·봇 차단 우회 금지는 유지
- **이미지 권리**: 소유자 업로드만 허용할지, 기존 플랫폼 사진 재게시를 허용할지
- **비용 예산**: Brand 측정의 테넌트당 주간 호출·금액 상한
- **문서 버전**: 전달 파일의 파일명 v19와 표지 v18 불일치 해소
### P1 — 현재 Site AEO를 설계서 수준으로 닫기
1. 도메인 소유권 검증과 만료 시 발행 차단
2. crawl run/document와 원본 hash 저장
3. A7 SimHash 중복도 검사 및 fact 역참조 리포트
4. A9 원본 변경 감지, fact 만료/검토, AI 크롤러 방문 로그
5. 고객 도메인 연결, TLS/DNS 운영 절차
6. 서버 계산 `allowed_actions` (앱 경계 분리는 2026-08-31 완료)
완료 기준은 “페이지가 만들어진다”가 아니라, **소유권이 확인된 원본에서 출처가 남는 fact를 만들고 두 게이트를 통과해 고객 도메인에 발행되며, 변경과 재방문을 관측할 수 있다**는 것이다.
### P2 — 규제 업종을 넣는 경우에만 선행
1. Reviewer 역할과 승인 워크벤치
2. 외부화된 업종별 규칙과 버전 관리
3. SAFE / NEEDS_EVIDENCE / PROHIBITED 3단 판정
4. 규칙 ID·근거 snippet·판정 모델·승인자·시각을 남기는 감사 로그
5. 성형외과 사전심의 상태와 자격/면허 fact 모델
6. 법률·의료 전문가의 규칙 승인 및 변경 절차
A4가 완성되기 전에는 법무법인·성형외과 콘텐츠 자동 발행을 열지 않는다.
### P3 — Brand AEO 최소 측정 루프
1. 질문은행과 publication-question 연결
2. fact snapshot
3. 1~2개 AI 엔진 어댑터와 응답 원문·모델 버전 저장
4. 언급·인용 URL·추천 위치·사실성 분석
5. 반복 측정, 신뢰구간, MA4, 비용 집계
6. 읽기 전용 퍼포먼스·인용출처 화면
처음부터 자동 EEAT 재생성까지 닫지 말고, 먼저 **같은 질문을 반복 측정했을 때 지표가 의사결정에 쓸 만큼 안정적인지** 검증한다.
### P4 — 개선 폐루프와 운영 확장
- EEAT 결손 → 고객 확인 요청 / 재생성 요청 분기
- 재생성 요청의 A4·A7 재통과
- 정기 리포트와 외부 채널 전략
- BFF의 섹션별 부분 실패와 서비스 JWT
- 데이터·트래픽·팀 소유권이 임계에 도달하면 Site/Brand 저장소 및 배포 단위 분리
---
## 5. 재작성하지 않고 유지할 현재 구현
설계서와 다르더라도 아래는 현재 제품에 맞고 이미 안전장치가 있으므로 유지하는 편이 낫다.
- PostgreSQL 기반 잡 큐의 원자적 claim, lease, dedupe, dead-letter
- `VERIFIED`/`CORRECTED`만 발행하고 재수집 후보가 정정값을 덮지 않는 상태 모델
- 백엔드는 payload만 만들고 프론트 프리렌더러가 정적 HTML을 생성하는 경계
- JSON-LD와 화면값 불일치 시 발행을 막는 게이트
- 외부 API 키가 없어도 해당 어댑터만 비활성화하는 구성
- robots.txt와 약관을 우회하지 않는 수집 원칙
- 현재 SEO/AEO 점수를 “준비도”로 명시하는 정직한 표현
---
## 6. 일정 재구성 제안
설계서의 S1~S8은 신규 구축 기준이라 현재 저장소에 그대로 적용하면 이미 끝난 기반 작업을 반복하고, 미결 정책을 코드로 먼저 굳히게 된다. 다음과 같이 게이트 중심으로 다시 잡는다.
| 마일스톤 | 목표 | 종료 조건 |
|---|---|---|
| M0 방향 확정 | 범위·업종·도메인·크롤·비용 결정 | P0 결정 기록과 승인 |
| M1 Site 완결 | A1/A7/A9 공백과 고객 도메인 보완 | 소유권→발행→변경감지 E2E 통과 |
| M2 규제 게이트 | 법률·의료를 할 경우 A4 구축 | 전문가 승인 룰셋과 감사 가능한 차단/승인 |
| M3 Visibility 파일럿 | 질문은행·snapshot·최소 엔진 측정 | 반복 측정의 비용·분산·인용 검출 정확도 보고 |
| M4 개선 루프 | 측정 결과를 안전한 재생성 요청으로 연결 | 고객 확인 또는 A4/A7 재통과 후 발행 |
| M5 플랫폼화 | 콘솔/BFF/서비스 분리 | 실제 트래픽·팀 소유권 기준 충족 시에만 수행 |
주차 추정치는 P0의 업종 수, AI 엔진 수, 외부 전문가 검토 가능일이 정해진 뒤 산정한다. 특히 3개 업종 동시 개발과 4개 엔진×3회 측정을 전제로 한 기존 8스프린트 일정은 현재 인력·비용 정보 없이 확정 일정으로 취급하면 안 된다.
---
## 7. 바로 만들 백로그
| 우선순위 | 에픽 | 대표 산출물 |
|---|---|---|
| 1 | 소유권 검증 | challenge 테이블/API, DNS/meta/well-known 검증기, 만료 정책, 발행 게이트 |
| 2 | 수집 재현성 | crawl run, document hash, source selector/snippet, 변경 비교 |
| 3 | 일치성 강화 | SimHash, fact 역참조 커버리지, 실패 사유 UI |
| 4 | 발행 관측 | crawler visit/index 상태, CDN 로그 적재, 재수집/재생성 조건 |
| 5 | 고객 도메인 | 서브패스/서브도메인 연결, canonical·sitemap 검증, TLS/DNS 운영 |
| 6 | 측정 계약 | question set, fact snapshot, publication-question 연결 |
| 7 | Visibility 파일럿 | 엔진 어댑터, 원문 로그, mention/citation/position/factuality 분석, 비용 상한 |
| 8 | 운영 화면 | 소유권·수집·게이트·발행 상태부터 추가, 이후 질문/성과/인용 화면 |
| 조건부 | 규제 업종 | 규칙 저장소, Reviewer, 승인 워크벤치, 감사 로그, 사전심의 |
---
## 8. 관련 현재 문서
이 문서는 비교와 향후 방향만 다룬다. 현재 제품 원칙과 구현 상세는 중복해서 관리하지 않는다.
- 제품 범위와 non-goal: [PRODUCT.md](PRODUCT.md)
- 현재 수집·생성·발행 흐름: [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md)
- 법무·데이터·작업 큐 결정: [DECISIONS.md](DECISIONS.md)
- 데이터 소스 실측: [DATA_SOURCE_RESEARCH.md](DATA_SOURCE_RESEARCH.md)
- 배포와 도메인 운영: [DEPLOY.md](DEPLOY.md)
- 외부 API 비용: [API_USAGE.md](API_USAGE.md)

File diff suppressed because it is too large Load Diff

View File

@ -104,8 +104,7 @@
## 9. 아직 안 정한 것 ## 9. 아직 안 정한 것
정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다. 정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다.
★ **개발 착수 전에 확정해야 할 결정 목록은 ★ **보류 중인 결정은 [DECISIONS.md](DECISIONS.md) 1절**이 단일 출처다 — 여기 복사하지 않는다.
[DEVELOPMENT_DIRECTION.md P0](DEVELOPMENT_DIRECTION.md)** 가 단일 출처다 — 여기 복사하지 않는다.
아래는 그중 **제품 정의**에 해당하는 것만 남긴다. 아래는 그중 **제품 정의**에 해당하는 것만 남긴다.
- 사업 성공 지표 (7절) - 사업 성공 지표 (7절)

157
docs/RENDERING.md Normal file
View File

@ -0,0 +1,157 @@
# 렌더링 한눈에 보기
사이트가 화면에 그려지는 경우는 세 가지다. 셋 다 그리는 코드는 `solution/site/src/App.tsx` 하나이고,
**누가 언제 그리느냐**만 다르다.
| 경우 | 누가 그리나 | 입력 |
|---|---|---|
| 정적 사이트 | 워커가 미리 구운 HTML → 브라우저가 이어받음 | HTML 안에 심어 둔 payload |
| 미리보기 | 브라우저가 처음부터 | API 가 그때그때 만든 payload |
| 발행 | 워커가 Node 렌더러를 돌려 HTML 을 굽는다 | DB → payload 파일 |
경로는 레포 루트 기준이다. `site/` 는 `solution/site/`, `backend/` 는 `solution/backend/` 를 줄인 것이다.
---
## 1. 정적 사이트 — 손님·크롤러가 `/s/<slug>` 를 받을 때
```mermaid
flowchart TD
A["손님 · 크롤러<br/>GET /s/&lt;slug&gt;"] --> B["nginx<br/>location ^~ /s/"]
B --> C["out/s/&lt;slug&gt;<br/>(심볼릭 링크)"]
C --> D["out/versions/&lt;slug&gt;/&lt;ver&gt;/index.html"]
D -->|크롤러는 여기까지| E["HTML · JSON-LD · meta"]
D --> F["브라우저가 /assets/index-해시.js · .css 를 받음"]
F --> G["entry-client.tsx<br/>window.__SITE_PAYLOAD__ 있음"]
G --> H["hydrateRoot(App)<br/>버튼·달력 등 동작이 붙는다"]
D --> I["사진 /s/&lt;slug&gt;/img/*<br/>노래 /s/&lt;slug&gt;/*.mp3"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 요청 받기 | `/s/<slug>` 는 구운 파일을 그대로 준다. `/s` 는 목록, `/s/` 는 `/s` 로 301 | `nginx/site.conf.example` (`location = /s`, `location ^~ /s/`) |
| 공개 버전 찾기 | `out/s/<slug>` 는 지금 공개 중인 버전 폴더를 가리키는 링크다 | `site/scripts/prerender.ts` `publishVersion` |
| HTML | 본문·`<head>`(title·canonical·JSON-LD)·심어 둔 payload 가 한 파일에 있다 | `out/versions/<slug>/<ver>/index.html` |
| 번들 | 해시 이름의 JS·CSS. 1년 캐시 | `nginx/site.conf.example` `location ^~ /assets/` → `out/assets/` |
| 이어받기 | 심어 둔 payload 로 같은 화면을 다시 만들어 마크업에 동작을 붙인다 | `site/src/entry-client.tsx` (`hydrateRoot`) |
| 그리기 | 템플릿의 레이아웃을 고르고 섹션을 순서대로 그린다 | `site/src/App.tsx` → `site/src/pages/SectionList.tsx` |
검색엔진이 읽는 건 구운 HTML 이다. 렌더러를 고쳐도 이미 구운 HTML 은 사장님이 다시 발행하기 전까지 그대로다.
---
## 2. 미리보기 — 빌더 iframe `/preview?placeId=…`
```mermaid
flowchart TD
A["빌더에서 템플릿·색·섹션 저장<br/>POST …/site/template · …/site/theme"] --> B["onSiteThemeSaved 신호"]
B --> C["SitePreview.tsx<br/>iframe 다시 로드"]
C --> D["GET /preview?placeId=…<br/>nginx location = /preview"]
D --> E["out/preview/index.html<br/>빈 껍데기 + 번들"]
E --> F["entry-client.tsx renderPreview"]
F --> G["GET /v1/place/{id}/site/preview"]
G --> H["SiteService.preview_payload<br/>build_snapshot → prepare_site_payload"]
H --> F
F --> I["themeVars · 폰트 로드"]
I --> J["createRoot(App)"]
J --> K["postMessage o2o:preview-painted"]
K --> L["빌더가 스피너를 걷는다<br/>(12초 상한)"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 다시 그릴 때를 안다 | 저장이 끝나면 iframe 을 새로 고친다. 보던 스크롤 위치는 지킨다 | `solution/frontend/src/features/builder/SitePreview.tsx`, `solution/frontend/src/features/publish/siteTheme.ts` `onSiteThemeSaved` |
| 껍데기 받기 | 본문이 빈 HTML. `noindex` 가 붙어 있다 | `nginx/site.conf.example` `location = /preview` → `out/preview/index.html` (`prerender.ts` `writePreviewShell`) |
| payload 받기 | 로그인 토큰을 붙여 API 를 부른다 | `site/src/entry-client.tsx` `renderPreview` |
| payload 만들기 | 발행과 같은 함수로 만든다. 버전도 파일도 만들지 않는다 | `backend/router/v1/site/site.py` `site_preview` → `backend/services/site_service.py` `preview_payload` → `services/snapshot.py` `build_snapshot` → `services/site_payload.py` `prepare_site_payload` |
| 템플릿 확인 | 모르는 id 면 API 가 422, 화면은 에러 문구로 멈춘다 | `backend/common/template_catalog.py`, `solution/shared/src/lib/catalog.ts` `templateOf` |
| 그리기 | 색 변수·폰트를 먼저 넣고 처음부터 그린다 | `entry-client.tsx` (`themeVars`, `fontHref` ← `site/src/seo/head.ts`), `App.tsx` |
| 완료 알림 | 두 프레임 뒤 부모 창에 알린다. 빌더는 출처와 iframe 을 확인한다 | `entry-client.tsx` `signalPreviewPainted`, `SitePreview.tsx` `PAINT_TIMEOUT_MS` |
미리보기는 사진을 내려받지 않는다. 원래 주소를 그대로 쓴다.
---
## 3. 발행 — 무엇을 읽고 무엇을 쓰나
```mermaid
flowchart TD
A["사장님 '발행하기'<br/>POST /v1/place/{id}/site/build"] --> B["SiteService.start_build<br/>jobs 에 BUILD"]
B --> C["워커 worker/handlers.py<br/>build_service.run_build"]
C --> D["build_snapshot<br/>DB 값 모으기 · site_versions 행 추가"]
D --> E{"1차 게이트<br/>상호·업종·사실 확인 · 템플릿 id"}
E -->|실패| X["버전 FAILED · 발행 로그"]
E --> F["emit_payload<br/>payloads/&lt;slug&gt;.json"]
F --> G["render_service.render_site<br/>node prerender.js --stage-only"]
G --> H["mirrorMedia → prerenderSite<br/>out/versions/&lt;slug&gt;/&lt;ver&gt;/"]
H --> I["보고서<br/>payloads/.status/&lt;slug&gt;.json"]
I --> J{"2차 게이트<br/>publish_gate.evaluate"}
J -->|실패| X
J --> K["render_service.activate_site<br/>node prerender.js --activate=slug:ver"]
K --> L["out/s/&lt;slug&gt; 링크 전환<br/>루트 sitemap · robots · llms 갱신"]
L --> M["Azure 업로드 · 썸네일 · IndexNow"]
M --> N["DB 기록<br/>버전 BUILT · sites PUBLISHED · 발행 로그"]
```
| 단계 | 하는 일 | 파일 |
|---|---|---|
| 잡 넣기 | 검증 안 된 사업장은 막는다. 같은 사업장 BUILD 는 겹치지 않는다 | `backend/router/v1/site/site.py` `start_build` → `services/site_service.py` `start_build` |
| 잡 집기 | BUILD 잡을 `run_build` 로 넘긴다 | `backend/worker/handlers.py` |
| 스냅샷 | DB 값을 한 벌로 모아 `site_versions.snapshot` 에 박제한다 | `services/snapshot.py` `build_snapshot`, `services/build_service.py` `run_build` |
| 1차 게이트 | 상호명·업종·사실 확인 여부, 템플릿 id | `services/publish_gate.py` `check_facts_verified`, `common/template_catalog.py` `resolve_template_id` |
| payload | JSON 으로 쓴다. 임시 파일에 쓰고 이름을 바꾼다 | `services/site_payload.py` `emit_payload` → `write_payload` |
| 굽기 | Node 를 직접 실행한다. 파일 잠금으로 한 번에 하나만 돈다 | `services/render_service.py` `render_site` (`.render.lock`) |
| 렌더 | 공개 금지 값 걸러내기 → 사진 내려받기 → HTML·JSON-LD·llms.txt → 대조 | `site/scripts/prerender.ts` `sanitizePayloadForPublish` · `mirrorMedia` · `prerenderSite` · `verifyJsonLd` |
| 2차 게이트 | 보고서의 대조 결과·고유 콘텐츠 건수로 판정 | `services/publish_gate.py` `evaluate`, `services/render_report.py` |
| 공개 전환 | 검증된 버전인지 보고서로 다시 보고 링크를 바꾼다 | `render_service.activate_site` → `prerender.ts` `publishVersion` · `writeRootMachineFiles` |
| 바깥 알리기 | 설정된 경우만 돈다 | `services/azure_static.py` `publish`, `services/site_thumbnail.py` `store`, `services/indexnow.py` `submit` |
| DB 기록 | 버전·사이트·사업장 상태와 발행 로그를 남긴다 | `services/build_service.py` `run_build` · `_log` |
### 입력
| 무엇 | 어디서 | 읽는 쪽 |
|---|---|---|
| DB 값 | `place_facts` `place_units` `place_faqs` `place_photos` `place_songs` `place_posts` `place_reviews` `place_social_posts` `area_contents` `site_sections` `sites` | `services/snapshot.py` `build_snapshot` |
| 템플릿 목록 | `solution/shared/src/data/templates.json` | 백엔드 `common/template_catalog.py`, 렌더러 `shared/src/lib/catalog.ts` |
| payload | `site/payloads/<slug>.json` (`SITE_PAYLOAD_DIR`) | `prerender.ts` `loadOne` — `schemaVersion` 1 · 슬러그 · 버전을 본다 |
| 번들 목록 | `site/dist/client/.vite/manifest.json` | `prerender.ts` `readAssets` — 엔트리 JS·CSS 파일명 |
| 번들 파일 | `site/dist/client/assets/`, `site/public/fonts/` | `prerender.ts` `writeSharedAssets` |
| 사진 | `payload.media[].url` 이 가리키는 바깥 주소 | `prerender.ts` `mirrorMedia` (15초 · 8MB) |
| 노래 | `site/songs/*.mp3` | `prerender.ts` `copySongs` |
### 출력
| 무엇 | 어디에 | 누가 쓰나 |
|---|---|---|
| payload | `site/payloads/<slug>.json` | `site_payload.py` `write_payload` |
| 렌더 보고서 | `site/payloads/.status/<slug>.json` | `prerender.ts` `writeReport` (백엔드가 `render_report.py` 로 읽는다) |
| HTML | `out/versions/<slug>/<ver>/index.html` — JSON-LD 는 따로 파일이 없고 `<head>` 안 `<script type="application/ld+json">` 이다 | `prerender.ts` `prerenderSite`, `site/src/seo/head.ts` `renderHead` |
| llms.txt | `out/versions/<slug>/<ver>/llms.txt` | `prerenderSite` → `renderLlmsTxt` |
| 사진 | `out/versions/<slug>/<ver>/img/<주소해시>.<확장자>` | `mirrorMedia` |
| 노래 | `out/versions/<slug>/<ver>/*.mp3` | `copySongs` |
| 버전 보고서 | `out/versions/<slug>/<ver>/.render-report.json` — 있으면 그 버전은 다시 굽지 않는다 | `prerender.ts` `main` |
| 공개 링크 | `out/s/<slug>` → `../versions/<slug>/<ver>` (상대 링크) | `publishVersion`. 옛 일반 폴더는 `versions/<slug>/legacy` 로 옮긴다 |
| 옛 버전 정리 | 최근 5개·30일은 남긴다 | `pruneOldVersions` |
| 공용 번들 | `out/assets/`, `out/fonts/`, 대장 `out/assets/.builds.json` | `writeSharedAssets` · `pruneAssets` (HTML 이 참조하는 건 안 지운다 — `referencedAssets`) |
| 미리보기 껍데기 | `out/preview/index.html` | `writePreviewShell` |
| 루트 파일 | `out/robots.txt` `out/sitemap.xml` `out/llms.txt` `out/s/index.html`(목록) `out/<INDEXNOW_KEY>.txt` | `writeRootMachineFiles` · `writeIndexNowKey` |
| DB | `site_versions`(스냅샷·빌드 상태·JSON-LD·고유 콘텐츠 수), `sites`(PUBLISHED·`current_version_id`·`published_at`·썸네일), `places.status`, `place_posts`·`place_reviews` 발행 표시, `site_publish_logs` | `services/build_service.py` `run_build` |
`--stage-only` 로 굽는 단계는 `out/s/<slug>` 를 건드리지 않는다. 공개 주소가 바뀌는 건 2차 게이트를 통과한 뒤
`--activate` 한 번뿐이다. 공개 전환과 DB 기록은 한 트랜잭션이 아니다([PUBLISH_VERSION.md](PUBLISH_VERSION.md)).
---
## 템플릿·레이아웃은 어디서 골라지나
`sites.template_id` 에 저장된 id 를 `solution/shared/src/data/templates.json` 에서 찾아 그 템플릿의 `layout`
(`basic` `paper` `round` `cinema` `bigtype` `boutique` `graphic`)을 얻는다(`shared/src/lib/catalog.ts` `templateOf`).
`site/src/App.tsx` 가 그 값으로 `site/src/layouts/index.ts` 의 `LAYOUTS` 에서 뼈대를 꺼내 `Frame` 으로 감싸고,
안쪽은 `site/src/pages/SectionList.tsx` 가 켜진 섹션을 순서대로 그린다. 레이아웃이 `sections` 에 따로 등록한
섹션은 그 컴포넌트를, 등록하지 않은 섹션은 `site/src/sections/` 의 공용 컴포넌트를 쓴다.
## 관련 문서
- [TEMPLATES.md](TEMPLATES.md) — 템플릿 추가, 세 폴더(frontend · shared · site)의 역할
- [PUBLISH_VERSION.md](PUBLISH_VERSION.md) — 발행 버전, 공개 링크 전환, 롤백
- [ARCHITECTURE.md](ARCHITECTURE.md) — 백엔드와 렌더러가 디렉토리 하나로만 만나는 이유

View File

@ -27,7 +27,7 @@ DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정)
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다. - 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다. - 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다. 알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다. - 사이트 섹션 `social`은 기본 OFF. 켜면 모든 레이아웃의 본문 마지막, footer 앞에 최신 3건을 굽는다.
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다. Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
## 연동 준비 — 한 번만 하는 일 ## 연동 준비 — 한 번만 하는 일

197
docs/TEMPLATES.md Normal file
View File

@ -0,0 +1,197 @@
# 템플릿과 렌더링
화면 규칙(글자 크기 · 간격 · 접기 · ✓ 표시)은 [TEMPLATE_DESIGN.md](TEMPLATE_DESIGN.md).
사장님이 고르는 "템플릿"이 어디에 정의돼 있고, 화면에 어떤 순서로 그려지는지 적은 문서다.
2026-09-28에 구조를 한 번 갈아엎었고, 이 문서는 그 뒤의 모습이다.
## 1. 세 폴더가 하는 일
`solution/` 밑의 세 폴더는 하는 일이 다르다. 한 줄로 말하면 이렇다.
| 폴더 | 누가 보나 | 하는 일 |
|---|---|---|
| `shared/` | 아무도 직접 안 본다 | 나머지 둘과 백엔드가 **같이 쓰는 약속**을 둔다. 템플릿 목록, payload 모양, 슬러그 규칙 |
| `frontend/` | 사장님 | **빌더.** 템플릿·색·섹션을 고르고 내용을 고친다. 고른 값은 서버에 저장만 한다 |
| `site/` | 손님, 검색엔진, AI | **발행된 사이트를 그리는 쪽.** 서버가 만든 payload를 받아 HTML로 굽는다 |
조금 더 풀면:
- **shared** 는 코드라기보다 계약서다. 템플릿이 몇 개인지, 이름이 뭔지, 어느 업종이 뭘 쓸 수
있는지는 전부 `shared/src/data/templates.json` 한 파일에 있다. TS 쪽은
`shared/src/lib/catalog.ts`가, 파이썬 쪽은 `backend/common/template_catalog.py`가 이 파일을
그대로 읽는다. 그래서 템플릿 정보가 두 군데로 갈라질 수 없다.
- **frontend** 는 사이트를 직접 그리지 않는다. 미리보기도 site가 그린 화면을 iframe으로
띄울 뿐이다. 빌더 안에 따로 그리는 코드를 두면 미리보기와 발행본이 조금씩 달라지는데,
예전에 실제로 그랬다.
- **site** 는 DB도 API도 모른다. payload JSON 하나만 받으면 사이트 한 장을 그린다. 발행 때는
워커가 부르는 Node 스크립트로 HTML을 굽고, 미리보기 때는 브라우저에서 같은 코드로 그린다.
## 2. 템플릿은 무엇으로 이뤄지나
`templates.json`에 템플릿 하나는 이렇게 생겼다.
| 칸 | 뜻 |
|---|---|
| `name` · `tag` · `description` | 빌더에서 사장님이 보는 이름과 설명 |
| `layout` | 어떤 뼈대로 그릴지. `basic` · `paper` · `round` · `cinema` · `bigtype` · `boutique` · `graphic` · `coral` · `minimal` · `pine` |
| `colors` | 기본 색. 사장님이 팔레트를 고르면 그 색이 위에 덮인다 |
| `look` | 서체, 모서리, 그림자, 섹션 간격. 사장님이 못 바꾼다 |
| `addSections` | 이 템플릿을 고르면 새로 켜지는 섹션. 레트로의 일력·영상 같은 것 |
지금 템플릿은 아홉 개다.
| id | 이름 | 뼈대 |
|---|---|---|
| `simple` | 심플 | basic |
| `magazine` | 매거진 | basic |
| `retro` | 레트로 | basic |
| `paper` | 고택 | paper |
| `round` | 라운드 | round |
| `cinema` | 시네마 | cinema |
| `bigtype` | 빅타이포 | bigtype |
| `boutique` | 부티크 | boutique |
| `graphic` | 일러스트 | graphic |
| `coral` | 코랄 | coral |
| `minimal` | 미니멀 | minimal |
| `pine` | 솔숲 | pine |
심플·매거진·레트로는 뼈대가 같고 색과 서체만 다르다. 고택은 손으로 만든 시안 `/s/stay2`를
그대로 옮긴 것이라 뼈대부터 다르다. 폭 640px짜리 한 단에, 위쪽 탭 네 개(소개 · 지역 소개 ·
이용안내·예약 · 이야기)로 화면을 나눈다. 탭을 눌러도 페이지를 새로 받지 않는다. 내용은 전부 한
HTML 안에 있고 보이는 묶음만 바뀐다. 검색엔진은 네 묶음을 다 읽는다.
라운드·시네마·빅타이포·부티크·일러스트·코랄·미니멀·솔숲은 고택과 같은 섹션과 탭 네 개를 그대로 받고 모양만 다르다.
예약 시트와 예약 폼은 고택 부품을 가져다 쓴다. 모바일 390px에서 먼저 맞췄다. 일러스트는 사진이
거의 없는 집을 위한 것이라, 첫 화면과 객실 카드를 그림으로 채운다.
업종마다 쓸 수 있는 템플릿과 기본 템플릿은 같은 파일의 `industries`에 적는다. 숙박은 레트로가
기본이고, 병원은 심플과 매거진만 쓸 수 있다.
### 뼈대(레이아웃)는 필요한 것만 바꾼다
레이아웃은 `site/src/layouts/`에 폴더 하나씩이다. 한 레이아웃이 가질 수 있는 건 셋이다.
- `Frame`: 머리글, 본문 자리, 바닥글. 이건 꼭 있어야 한다.
- `SectionHead`: 섹션 제목 모양. 없으면 공용 제목을 쓴다.
- `sections`: 섹션별로 바꿔 그릴 컴포넌트. 여기 안 적은 섹션은 공용 컴포넌트를 그대로 쓴다.
새 레이아웃을 만들 때 전부 새로 그릴 필요는 없다. 다르게 보여야 하는 섹션만 만들면 된다.
고택은 시안과 똑같이 맞추느라 대부분의 섹션을 따로 그렸다. 데이터를 고르는 계산(이용안내 표,
예약 달력, 예약 요청 전송)은 공용 섹션의 함수를 그대로 가져다 쓰고, 모양만 따로 그린다.
고택에서 섹션이 어느 탭에 속하는지는 `layouts/paper/paper.css` 맨 아래 규칙이 정한다. 새 섹션을
만들고 이 규칙에 적지 않으면 첫 탭(소개)에 나온다.
### 이상한 값이 들어오면 멈춘다
DB에 모르는 템플릿 id가 들어 있으면, 조용히 기본값으로 굽지 않고 멈춘다.
- 저장할 때: 그 업종이 못 쓰는 템플릿이면 저장을 거절한다.
- 미리보기: 서버가 422를 주고, 화면에 에러가 뜬다.
- 발행: 잡이 실패로 끝난다.
예전에는 모르는 값이 오면 기본 템플릿으로 슬쩍 구웠다. 그러면 사장님이 고른 디자인과 다른
사이트가 나가도 아무도 모른다.
## 3. 새 템플릿을 추가할 때
### 기존 뼈대를 쓰는 경우 (색·서체만 다른 템플릿)
`templates.json` 한 파일만 고치면 된다.
1. `templates`에 새 항목을 넣는다. id는 영어 소문자로 짓고, 업종 이름은 붙이지 않는다.
2. 쓸 수 있게 할 업종의 `industries.<업종>.templates` 목록에 그 id를 넣는다.
3. 기본 템플릿으로 삼을 거면 `defaultTemplate`도 바꾼다.
타입(`TemplateId`)은 JSON 키에서 자동으로 뽑히므로 손댈 곳이 없다. 빌더 목록, 미리보기, 백엔드
검증에 자동으로 들어간다. 레이아웃 이름을 틀리게 적거나 업종 목록에 없는 id를 적으면, 앱이
뜰 때 바로 에러가 난다.
### 새 뼈대가 필요한 경우
위 세 단계에 더해서 다음을 한다.
1. `site/src/layouts/<새이름>/Frame.tsx`를 만든다. 바꿔 그릴 섹션이 있으면 같은 폴더에 둔다.
2. `site/src/layouts/index.ts`의 `LAYOUTS`에 한 줄 넣는다.
3. `shared/src/types/builder.ts`의 `LayoutId`에 이름을 넣는다.
4. `shared/src/lib/catalog.ts`의 `LAYOUT_IDS`에도 넣는다.
2~4를 하나라도 빠뜨리면 타입체크나 앱 시작 단계에서 걸린다.
### 새 섹션이 딸려 오는 경우
`addSections`에 적는 섹션은 이미 있는 섹션이어야 한다. 섹션 자체를 새로 만드는 건 템플릿과
별개의 일이다. site의 섹션 컴포넌트, 빌더의 섹션 목록, payload 모양을 다 만져야 한다.
### 올릴 때
JSON은 빌드할 때 번들과 이미지에 들어간다. 그래서 템플릿을 추가하면 backend·worker·site·
frontend를 전부 다시 빌드해야 한다. 하나만 올리면 빌더에는 보이는데 저장이 거절되는 식으로
어긋난다.
DB는 건드릴 필요가 없다. 이미 발행된 사이트는 사장님이 다시 발행하기 전까지 그대로다.
## 4. 렌더링 순서
### 빌더에서 고칠 때 (미리보기)
```
사장님이 빌더에서 템플릿·색·섹션을 바꾼다
│ frontend stores/builder.ts (템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼진다)
▼
서버에 저장한다
│ 템플릿 POST /v1/place/{id}/site/template → sites.template_id
│ 색·섹션 POST /v1/place/{id}/site/theme → sites.theme
▼
저장이 끝나면 미리보기 iframe을 다시 연다
│ frontend features/builder/SitePreview.tsx (features/publish/siteTheme.ts 의 저장 완료 신호를 듣는다)
│ iframe 주소 /preview?placeId=… (site가 미리 구워 둔 빈 껍데기 페이지)
▼
껍데기 안의 site 코드가 서버에 payload를 달라고 한다
│ site entry-client.tsx renderPreview
│ GET /v1/place/{id}/site/preview
│ backend services/site_payload.py — 발행 때와 같은 함수로 payload를 만든다
▼
템플릿 id를 확인하고 그린다
│ 모르는 id면 여기서 에러 문구를 띄우고 멈춘다
│ App.tsx → templates.json의 layout을 보고 LAYOUTS에서 뼈대를 고른다
│ Frame 안에 SectionList가 섹션을 순서대로 그린다
▼
다 그렸다고 빌더에 알린다 (postMessage) → 빌더가 로딩 표시를 걷는다
```
### 발행할 때
```
사장님이 "발행하기"를 누른다
▼
jobs 표에 BUILD 잡이 들어간다
▼
워커가 잡을 집는다 backend services/build_service.py run_build
│ 1. 상호명·업종이 있는지, 사실 값이 확인됐는지 본다. 아니면 발행 실패
│ 2. 템플릿 id 확인 common/template_catalog.py — 모르면 발행 실패
│ 3. payload JSON 만들기 services/site_payload.py
│ → site/payloads/<slug>.json 에 떨어뜨린다
▼
워커가 Node 렌더러를 실행한다 site scripts/prerender.ts
│ 1. 공개하면 안 되는 값을 한 번 더 걸러 낸다 shared lib/facts.ts
│ 2. 남의 도메인 사진을 우리 서버로 내려받는다
│ 3. React로 HTML 문자열을 만든다 site entry-server.tsx → App.tsx
│ 4. 검색용 JSON-LD, llms.txt를 같이 만든다
│ 5. payload를 HTML 안에 심는다 (window.__SITE_PAYLOAD__)
│ → out/versions/<slug>/<버전>/ 에 쓴다
▼
결과를 확인하고 공개 주소를 새 버전으로 바꾼다
│ out/s/<slug> 링크를 새 버전 폴더로 갈아 끼운다 (PUBLISH_VERSION.md)
│ DB에 버전과 발행 기록을 남긴다
▼
손님이 /s/<slug> 에 들어온다
│ nginx가 구워 둔 HTML을 그대로 준다. 검색엔진은 여기까지만 읽는다
▼
브라우저가 JS를 받아 화면을 이어받는다 site entry-client.tsx hydrateRoot
심어 둔 payload로 같은 화면을 다시 만들어서, 버튼·달력 같은 동작을 붙인다
```
두 흐름의 차이는 하나다. 미리보기는 브라우저가 처음부터 그리고, 발행은 서버에서 미리 그려 둔
HTML에 브라우저가 동작만 붙인다. 그리는 코드(`App.tsx`)는 같다.

106
docs/TEMPLATE_DESIGN.md Normal file
View File

@ -0,0 +1,106 @@
# 템플릿 디자인 규칙
새 템플릿을 만들거나 기존 템플릿을 고칠 때 지키는 화면 규칙이다. 기준은 고택(`layouts/paper/`)이다.
고택은 손으로 만든 시안 `/s/stay2`(`solution/site/scripts/mockup/king-stay2/`)를 옮긴 것이라
스크롤 길이·접기·글자 크기가 이미 검증돼 있다. 수치가 애매하면 `layouts/paper/paper.css` 를 연다.
템플릿 목록과 등록 방법은 [TEMPLATES.md](TEMPLATES.md), 렌더링 흐름은 [RENDERING.md](RENDERING.md).
## 1. 폭
| 화면 | 규칙 |
|---|---|
| 모바일 390px | 먼저 맞춘다. 가로 넘침 0 |
| 700~1023px | 640px 한 단, 가운데 |
| 1024px 이상 | 템플릿마다 정한다. 넓은 배치면 콘텐츠 최대 1080px, 첫 화면은 가로 전체 |
부티크처럼 모바일 한 단을 데스크톱에서도 그대로 쓰는 템플릿이 있다. 정한 방식은 [TEMPLATES.md](TEMPLATES.md) 에 적는다.
## 2. 글자
- 본문 16~17px, 줄 간격 1.7 이상. 고택은 17px/1.9. **본문 16px 미만 금지**
- 보조 글씨 14~15px. 기능 글씨(버튼·메뉴) 12px 미만 금지
- 섹션 제목 22~30px. 고택은 19px 명조
- 첫 화면 문구: 모바일 28px 안팎, 데스크톱 48px 이하
- 상호를 크게 쓰는 템플릿(빅타이포)도 64px 이하
- `vw` 로 커지는 글자 크기 금지. 넓은 화면에서 끝없이 커진다
- 보조 글씨 대비 4.5:1 이상. `#8b95a1` 같은 연회색은 흰 바탕에서 2.9:1 이라 탈락한다
### 템플릿별 한글 글꼴 — 겹치지 않게 배정했다
| 템플릿 | 제목 | 본문 |
|---|---|---|
| 고택 | Noto Serif KR | 시스템 고딕 |
| 라운드 | Noto Sans KR | Noto Sans KR |
| 시네마 | 송명 | Noto Sans KR |
| 빅타이포 | 함렛 | 함렛 · IBM Plex Mono |
| 부티크 | Cormorant · 나눔명조 | 나눔명조 |
| 일러스트 | 주아 | 고운돋움 |
| 코랄 | Gothic A1 · Aboreto | Gothic A1 |
| 미니멀 | Diphylleia · Cinzel | Questrial · 나눔고딕 |
| 솔숲 | IBM Plex Sans KR | IBM Plex Sans KR |
새 템플릿은 이 표에 없는 글꼴을 쓴다. 웹폰트는 `seo/head.ts` `WEB_FONTS` 에 등록해야 받아 온다.
## 3. 간격
- 제목 위 여백 32px 이상. 제목 위가 아래보다 넓다
- 카드형 목록 사이 10px 이상(선으로 나누는 목록은 예외)
- 같은 목록의 카드는 같은 크기: 사진은 고정 비율 + `object-fit:cover`, 글은 줄 수 말줄임
- 칸 수는 항목 수에 맞춘다(`repeat(auto-fit, minmax(…))`). 3개인데 4칸을 잡아 빈칸을 남기지 않는다
## 4. 섹션 머리
- **제목은 위, 내용은 아래.** 참고한 펜션 사이트들이 모두 이 방식이다
- 제목을 왼쪽, 내용을 오른쪽에 두는 좌우 분할은 쓰지 않는다. 내용이 한두 줄이면 왼쪽이 텅 빈다
(예외: 소개 글과 사진이 둘 다 충분할 때의 소개 섹션)
- 제목 위에 같은 뜻의 작은 라벨(‘객실’ 위 ‘객실 안내’)을 달지 않는다.
참고 사이트의 정체성인 영문 제목(코랄 `Room View`, 미니멀 `Stay`)은 예외
## 5. 스크롤 줄이기 — 몇 개 보이고 접나
`<details>` 로 접는다. 내용은 HTML 에 남아 크롤러가 읽는다. 빼지 않는다.
| 목록 | 처음 보이는 수 | 근거 |
|---|---|---|
| 첫 화면 사진 | 5장 | `paper/Hero.tsx` `MAX_SLIDES` |
| 홈 객실 | 4개(2열) | 나머지는 이용안내 탭 |
| 홈 사진 갤러리 | 큰 1 + 4장 | `paper/Gallery.tsx` `FIRST` |
| 홈 주변 안내 | 3~4곳 | 나머지는 지역 탭 |
| 명소 · 맛집 | 8곳 | `paper/Around.tsx` `FIRST_ROWS` (데스크톱 넓은 배치는 8, 모바일 6도 허용) |
| 축제 | 가로 캐러셀 | 세로로 펼치지 않는다 |
| 추천 일정 | 4개 | `paper/Itinerary.tsx` `FIRST` |
| 노래 | 6곡 | `paper/Story.tsx` |
| 인물 | 4명 | `paper/Story.tsx` |
| 지역 읽기 | 3편, 본문 3줄 말줄임 | `paper/Story.tsx` |
| 자주 묻는 질문 | 8개, 질문은 접힌 상태 | `paper/Faq.tsx` `FIRST` |
## 6. 값 보여주기
- **‘가능 · 있음’은 ✓, ‘불가 · 없음’은 ✕ 목록으로 쓴다.** `주차 / 가능` 같은 표로 쓰지 않는다
- 마크업: `<ul class="amen"><li class="on"><i class="ic">✓</i><span><b>주차</b> 가능</span></li>`
- 색: ✓ `#15803d`, ✕ `#c2410c`. 가능한 것을 먼저 놓는다
- 판정: `sections/EssentialInfoSection.tsx` `ON_VALUES`(가능·있음)
- 사진 없는 항목(인물 등)에 빈 사진 칸을 두지 않는다. 글만 있는 카드로 줄인다
- 거리·이름처럼 붙어 나오는 글은 띄우거나 줄을 나눈다
## 7. 탭 · 버튼
- 페이지 탭은 **밑줄**로 현재 위치를 표시한다. 회색 알약 배경 금지
- 탭·링크를 누르면 **같은 탭이어도 맨 위로** 바로 올라간다(`behavior:'instant'`, 전역 `scroll-behavior:smooth` 를 이긴다)
- 같은 동작은 한 이름: 예약 시트를 여는 버튼은 모두 **‘예약하기’**, 이용안내 탭으로 가는 버튼은 **‘이용안내 보기’**
- 버튼 글자가 두 줄로 접히면 안 된다. 모바일에서 버튼이 3개 이상이면 2열 격자로, 남는 하나는 한 줄 전체
- 키보드 포커스와 글자 선택색은 템플릿 색으로(`kit/kit.css`)
## 8. 하지 않는 것
- 영어 해외 사이트를 레퍼런스로 가져오지 않는다. 한국 사이트에서 UI(글자 크기·배치)로 고른다
- 디자인을 처음부터 손으로 짓지 않는다. 실물 사이트·시안을 받아 우리 콘텐츠에 맞게 옮긴다
- 섹션마다 똑같이 떠오르는 등장 효과, 모든 카드에 같은 그림자 — 생성형 기본값으로 읽힌다
## 9. 검수 순서
1. 해든스테이 테스트 데이터로 굽는다 (굽기 방법은 [RENDERING.md](RENDERING.md))
2. 1440px · 390px 에서 네 탭(홈 · 지역 · 이용안내 · 이야기)을 캡처해 **눈으로** 본다. lazy 이미지는 스크롤해서 띄운 뒤 찍는다
3. 제목 위 여백 32px 미만, 가로 넘침, 대비 부족을 잰다
4. `cd solution/site && npx eslint src/layouts && npx vitest run src/layouts`

View File

@ -1,11 +1,4 @@
/** /** "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. */
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
*
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
*/
import { writeFileSync, mkdirSync } from 'node:fs'; import { writeFileSync, mkdirSync } from 'node:fs';
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반) // ──────────────────────────────────────────────── 어휘 (실제 군산 기반)

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다. """docs/architecture.html 의 내용을 PPTX 로 다시 만든다."""
python3 scripts/build-deck.py
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
from pptx import Presentation from pptx import Presentation
from pptx.util import Inches, Pt from pptx.util import Inches, Pt
@ -10,7 +8,7 @@ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
from pptx.oxml.ns import qn from pptx.oxml.ns import qn
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일) # --------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
INK = RGBColor(0x10, 0x18, 0x19) INK = RGBColor(0x10, 0x18, 0x19)
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E) INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
MUTED = RGBColor(0x63, 0x75, 0x7A) MUTED = RGBColor(0x63, 0x75, 0x7A)
@ -32,7 +30,7 @@ W, H = 13.333, 7.5
MX = 0.75 # 좌우 여백 MX = 0.75 # 좌우 여백
# ---------------------------------------------------------------- 저수준 헬퍼 # --------------------------------------------------------------- 저수준 헬퍼
def _ea(run, name): def _ea(run, name):
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정.""" """한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
rPr = run._r.get_or_add_rPr() rPr = run._r.get_or_add_rPr()
@ -135,7 +133,7 @@ def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align) return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
# ---------------------------------------------------------------- 슬라이드 골격 # --------------------------------------------------------------- 슬라이드 골격
prs = Presentation() prs = Presentation()
prs.slide_width = Inches(W) prs.slide_width = Inches(W)
prs.slide_height = Inches(H) prs.slide_height = Inches(H)

View File

@ -1,14 +1,4 @@
/** /** 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. */
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
* node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json
*
* 설계 원칙
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고
* 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용).
* · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다.
* 산간 지역에 '오션뷰 펜션'을 만들지 않는다.
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다.
*/
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8')); const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
@ -69,7 +59,7 @@ const uniq = (a) => [...new Set(a)];
for (const r of regions) { for (const r of regions) {
const R = r.name; const R = r.name;
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]); const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
// '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다. // '산간'이라고 다 스키장이 있는 건 아니다.
const seasons = uniq([ const seasons = uniq([
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []), ...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
...(r.ski ? ['스키', '스키장 근처', '보드'] : []), ...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
@ -77,8 +67,6 @@ for (const r of regions) {
]); ]);
// T1 코어 — 주력 후보. // T1 코어 — 주력 후보.
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
const names = [R, ...(r.aliases ?? [])]; const names = [R, ...(r.aliases ?? [])];
for (const N of names) { for (const N of names) {
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 }); add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
@ -136,7 +124,7 @@ for (const r of regions) {
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 }); add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
} }
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다. // 광역 단위 롤업.
const sidoKey = {}; const sidoKey = {};
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.'); for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
const sidoList = uniq(regions.map((x) => x.sido)); const sidoList = uniq(regions.map((x) => x.sido));

View File

@ -1,8 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용). """벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용)."""
python3 scripts/export-db-xlsx.py
데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 —
384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다."""
import csv, io, subprocess, collections import csv, io, subprocess, collections
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

View File

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""전국 펜션 키워드 데이터셋 → 엑셀. """전국 펜션 키워드 데이터셋 → 엑셀."""
python3 scripts/export-xlsx.py
검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리."""
import json, collections import json, collections
from openpyxl import Workbook from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side from openpyxl.styles import Font, PatternFill, Alignment, Border, Side

View File

@ -1,14 +1,4 @@
/** /** 외부 연관키워드·검색량을 데이터셋에 병합한다. */
* 외부 연관키워드·검색량을 데이터셋에 병합한다.
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
*
* 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON.
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
*
* API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이
* 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고,
* 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다.
*/
import { readFileSync, writeFileSync } from 'node:fs'; import { readFileSync, writeFileSync } from 'node:fs';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';

View File

@ -1,11 +1,4 @@
/** /** data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. */
* data/gunsan-pension-keywords.json 을 pgvector 에 적재한다.
* npx tsx scripts/ingest-dataset.ts
*
* 정책: 주기 수집 없음. 고정 데이터셋 1회 적재.
* 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고,
* 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조)
*/
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createSql, toVector } from '../src/db/db'; import { createSql, toVector } from '../src/db/db';
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize'; import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
@ -73,8 +66,6 @@ async function main() {
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `); console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
// 4) 데이터셋에서 빠진 행 정리. // 4) 데이터셋에서 빠진 행 정리.
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
const wanted = uniq.map(([norm]) => norm); const wanted = uniq.map(([norm]) => norm);
const stale = await sql<Array<{ canonical: string }>>` const stale = await sql<Array<{ canonical: string }>>`
DELETE FROM keyword DELETE FROM keyword

View File

@ -1,10 +1,4 @@
/** /** 전국 지역별 펜션 키워드를 pgvector 에 적재한다. */
* 전국 지역별 펜션 키워드를 pgvector 에 적재한다.
* npx tsx scripts/ingest-nationwide.ts
*
* 군산 상세 데이터셋(source='dataset')과 공존시킨다.
* 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다.
*/
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { createSql, toVector } from '../src/db/db'; import { createSql, toVector } from '../src/db/db';
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize'; import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
@ -30,7 +24,7 @@ async function main() {
Array<{ sido: string; name: string; key: string }>; Array<{ sido: string; name: string; key: string }>;
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`); console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
// 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다. // 1) 지역 계층 심기 (시도 → 시군).
const nodes = new Map<string, string>(); const nodes = new Map<string, string>();
for (const r of regions) { for (const r of regions) {
const sidoKey = r.key.split('.').slice(0, -1).join('.'); const sidoKey = r.key.split('.').slice(0, -1).join('.');
@ -63,9 +57,6 @@ async function main() {
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`); console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
// 4) 적재. // 4) 적재.
// keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면
// 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가
// 자연히 구분되므로 문제되지 않는다.
let inserted = 0, updated = 0; let inserted = 0, updated = 0;
await sql.begin(async (tx) => { await sql.begin(async (tx) => {
for (let i = 0; i < uniq.length; i++) { for (let i = 0; i < uniq.length; i++) {

View File

@ -1,10 +1,4 @@
/** /** 고정 데이터셋 정책 위반분 정리. */
* 고정 데이터셋 정책 위반분 정리.
* npx tsx scripts/purge-nondataset.ts [--apply]
*
* 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가
* 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다.
*/
import { createSql } from '../src/db/db'; import { createSql } from '../src/db/db';
async function main() { async function main() {

View File

@ -1,8 +1,4 @@
/** /** 로컬 엔드투엔드 점검 스크립트. */
* 로컬 엔드투엔드 점검 스크립트.
* npm run db:reset && npm start (다른 터미널)
* npm run smoke
*/
const BASE = process.env.BASE_URL ?? 'http://localhost:3100'; const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
const j = async (method: string, path: string, body?: unknown) => { const j = async (method: string, path: string, body?: unknown) => {

View File

@ -64,9 +64,7 @@ const merchants = [
}, },
}, },
{ {
// 실제 업체. 공개 정보로 확인된 항목만 넣는다. // 실제 업체.
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
externalId: 'site-3001', externalId: 'site-3001',
name: '스테이머뭄', name: '스테이머뭄',
industryId: 'stay.pension', industryId: 'stay.pension',

View File

@ -5,10 +5,7 @@ import { EmbedKind, EmbeddingProvider } from './types';
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */ /** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>; const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
/** /** 로컬 multilingual-e5-small (384차원, onnxruntime CPU). */
* 로컬 multilingual-e5-small (384차원, onnxruntime CPU).
* 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다.
*/
@Injectable() @Injectable()
export class LocalEmbeddingProvider extends EmbeddingProvider { export class LocalEmbeddingProvider extends EmbeddingProvider {
readonly name = 'local:multilingual-e5-small'; readonly name = 'local:multilingual-e5-small';

View File

@ -3,7 +3,7 @@ import { EMBEDDING_DIM } from '../config/env';
import { hashEmbedding } from '../llm/mock.provider'; import { hashEmbedding } from '../llm/mock.provider';
import { EmbedKind, EmbeddingProvider } from './types'; import { EmbedKind, EmbeddingProvider } from './types';
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */ /** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. */
@Injectable() @Injectable()
export class MockEmbeddingProvider extends EmbeddingProvider { export class MockEmbeddingProvider extends EmbeddingProvider {
readonly name = 'mock:bigram-hash'; readonly name = 'mock:bigram-hash';

View File

@ -28,10 +28,7 @@ export interface ResolveInput {
regionId: string | null; regionId: string | null;
} }
/** /** 4단계 계단식 중복제거. */
* 4단계 계단식 중복제거.
* 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다.
*/
@Injectable() @Injectable()
export class DedupService { export class DedupService {
private readonly logger = new Logger(DedupService.name); private readonly logger = new Logger(DedupService.name);
@ -61,12 +58,6 @@ export class DedupService {
} }
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정 // 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
//
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
const candidates = await this.repo.findDedupCandidates( const candidates = await this.repo.findDedupCandidates(
input.embedding, input.embedding,
normalized, normalized,

View File

@ -33,10 +33,7 @@ export class KeywordRepository {
return rows[0] ?? null; return rows[0] ?? null;
} }
/** /** 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. */
* 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다.
* 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다.
*/
async findDedupCandidates( async findDedupCandidates(
embedding: number[], embedding: number[],
normalized: string, normalized: string,

View File

@ -1,8 +1,4 @@
/** /** 중복 판정용 정규화. */
* 중복 판정용 정규화.
* NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거.
* "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다.
*/
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g; const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g; const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;

View File

@ -10,13 +10,7 @@ import {
QaCandidate, QaCandidate,
} from './types'; } from './types';
/** /** API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. */
* API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현.
*
* embed(): 문자 bigram 해싱 + L2 정규화.
* 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로
* 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다.
*/
@Injectable() @Injectable()
export class MockLlmProvider extends LlmProvider { export class MockLlmProvider extends LlmProvider {
readonly name = 'mock'; readonly name = 'mock';

View File

@ -2,7 +2,7 @@ import { Controller, Get, Header } from '@nestjs/common';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */ /** 로컬 확인용 매칭 데모 페이지. */
@Controller() @Controller()
export class DemoController { export class DemoController {
@Get('demo') @Get('demo')

View File

@ -1,11 +1,4 @@
/** /** 매칭 규칙 테이블. */
* 매칭 규칙 테이블.
*
* 두 종류가 있다.
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다
* (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다)
*/
export interface MerchantFacts { export interface MerchantFacts {
name: string; name: string;
@ -21,7 +14,7 @@ export interface MerchantFacts {
nearby: string[]; nearby: string[];
amenities: Set<string>; // 정규화된 보유 시설 amenities: Set<string>; // 정규화된 보유 시설
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리 unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */ /** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. */
signals: string[]; signals: string[];
} }
@ -124,20 +117,10 @@ export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerd
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성']; const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/; const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
/** /** 레인 설계 원칙 1. 레인끼리 겹치지 않게 한다. */
* 레인 설계 원칙
* 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고,
* 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다.
* 지역+업종 앵커는 유형 레인에만 둔다.
* 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아
* 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다).
* 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다.
*/
export function buildLanes(f: MerchantFacts): Lane[] { export function buildLanes(f: MerchantFacts): Lane[] {
const lanes: Lane[] = []; const lanes: Lane[] = [];
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과 // 토큰 단위로 중복을 제거한다.
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => { const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
const seen = new Set<string>(); const seen = new Set<string>();
const words: string[] = []; const words: string[] = [];
@ -163,8 +146,7 @@ export function buildLanes(f: MerchantFacts): Lane[] {
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)), ...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
].slice(0, 6); ].slice(0, 6);
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐 // 권역과 인근을 한 레인으로 합친다.
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]); push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']); push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
push('audience', '동반자', 0.6, f.audiences.slice(0, 4)); push('audience', '동반자', 0.6, f.audiences.slice(0, 4));

View File

@ -9,12 +9,11 @@ import {
keywordAreaGroup, normalizeAmenities, violatesCapacity, keywordAreaGroup, normalizeAmenities, violatesCapacity,
} from './match.rules'; } from './match.rules';
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라 // RRF 상수를 관례값 60 대신 20 으로 낮춘다.
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
const RRF_K = 20; const RRF_K = 20;
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다 const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다 const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다. // 매칭 후보로 인정하는 출처.
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual']; const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
interface Hit { interface Hit {
@ -50,8 +49,6 @@ export class MatchService {
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query'); const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
// 레인별 검색. // 레인별 검색.
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
const perLane = await Promise.all( const perLane = await Promise.all(
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)), vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
); );
@ -107,7 +104,6 @@ export class MatchService {
const top = kept.slice(0, limit); const top = kept.slice(0, limit);
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다. // 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
const keptById = new Map(kept.map((k) => [k.id, k])); const keptById = new Map(kept.map((k) => [k.id, k]));
const byLane = lanes.map((lane, li) => ({ const byLane = lanes.map((lane, li) => ({
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text, key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
@ -226,12 +222,7 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
}; };
} }
/** /** 고객 언어 신호를 모은다. */
* 고객 언어 신호를 모은다.
* hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등
* reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계
* 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다.
*/
function collectSignals(p: Record<string, unknown>): string[] { function collectSignals(p: Record<string, unknown>): string[] {
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean); const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : []; const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];

View File

@ -27,11 +27,7 @@ export class ServingController {
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50)); return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
} }
/** /** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. */
* 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드.
* mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터
* mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용)
*/
@Post('match') @Post('match')
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) { match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
const limit = Math.min(body.limit ?? 40, 200); const limit = Math.min(body.limit ?? 40, 200);

View File

@ -96,11 +96,7 @@ export class ServingService {
}; };
} }
/** /** 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. */
* 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다.
* 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 —
* 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다.
*/
async match(rawQuery: string, limit: number) { async match(rawQuery: string, limit: number) {
const query = rawQuery.trim(); const query = rawQuery.trim();
const merchant = await this.resolveMerchant(query); const merchant = await this.resolveMerchant(query);

View File

@ -397,7 +397,7 @@ CREATE TABLE IF NOT EXISTS public.sites (
place_id uuid NOT NULL, -- 사업장과 1:1 place_id uuid NOT NULL, -- 사업장과 1:1
domain VARCHAR(255) NULL, domain VARCHAR(255) NULL,
path_prefix VARCHAR(100) NULL, path_prefix VARCHAR(100) NULL,
template_id VARCHAR(100) NULL, -- 사장님이 고른 템플릿 키. ★ 서버는 해석하지 않고 보관·반환만 한다 — 목록은 프론트가 소유한다 template_id VARCHAR(100) NULL, -- 템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿
theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지) theme JSONB NULL, -- ★ 색·서체·섹션 순서/on-off/배리에이션. 내용은 site_sections 로 나갔다. templateId 는 위 컬럼이 소유한다(중복 보관 금지)
status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished status SMALLINT NOT NULL DEFAULT 1, -- SiteStatus: 1=draft 2=review 3=published 4=suspended 5=unpublished
current_version_id uuid NULL, -- site_versions.site_version_id current_version_id uuid NULL, -- site_versions.site_version_id
@ -408,8 +408,8 @@ CREATE TABLE IF NOT EXISTS public.sites (
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE
); );
COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. NULL 이면 업종 기본 템플릿으로 굽는다.'; COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.';
COMMENT ON COLUMN public.sites.theme IS '에디터가 정한 디자인. {"colors":{...},"fontStyle":"...","sections":[{"id","name","enabled","locked","variantId"}]} — 서버는 해석하지 않고 그대로 보관·반환한다(목록은 프론트가 소유). NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다. templateId 는 sites.template_id 가 소유한다.'; COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.';
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다. -- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%). -- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%).

View File

@ -0,0 +1,21 @@
-- 템플릿 id에서 업종 접두어를 뗀다(stay-retro → retro). 모르는 값과 업종 허용 목록 밖의 값은 NULL(업종 기본)로 되돌린다.
-- 허용 목록은 solution/shared/src/data/templates.json 이다. 재실행해도 결과가 같다.
UPDATE sites
SET template_id = CASE
WHEN template_id ~ '^(stay|cafe|restaurant|clinic)-(simple|magazine|retro|paper)$'
THEN regexp_replace(template_id, '^[a-z]+-', '')
ELSE NULL
END
WHERE template_id IS NOT NULL
AND template_id NOT IN ('simple', 'magazine', 'retro', 'paper');
UPDATE sites AS s
SET template_id = NULL
FROM places AS p
WHERE p.place_id = s.place_id
AND p.category = 4
AND s.template_id NOT IN ('simple', 'magazine');
COMMENT ON COLUMN public.sites.template_id IS '템플릿 id(solution/shared/src/data/templates.json). NULL 이면 업종 기본 템플릿으로 굽는다.';
COMMENT ON COLUMN public.sites.theme IS '색·섹션. {"colors":{...},"look":{...},"colorPaletteId":"...","sections":[{"id","name","enabled","locked","body","data"}]}. 모양(look)은 발행 때 템플릿 정의가 정한다.';

View File

@ -24,6 +24,7 @@ RUN playwright install --with-deps chromium
COPY solution/backend ./solution/backend COPY solution/backend ./solution/backend
COPY admin/backend ./admin/backend COPY admin/backend ./admin/backend
COPY solution/shared/src/data ./solution/shared/src/data
ENV APP_ENV=local ENV APP_ENV=local

View File

@ -56,6 +56,7 @@ RUN playwright install --with-deps chromium
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
COPY solution/backend ./solution/backend COPY solution/backend ./solution/backend
COPY solution/shared/src/data ./solution/shared/src/data
# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로 # ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로
# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야 # payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다. # 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.

View File

@ -2,8 +2,5 @@ from common.enums import UserRole
def is_owner_or_admin(resource_user_id, user_id, role) -> bool: def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True. """변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True."""
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리)."""
return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value

View File

@ -1,10 +1,4 @@
"""업종별 fact 스키마 패키지. """업종별 fact 스키마 패키지."""
업종마다 필드가 완전히 다르므로(숙박=체크인시간, 카페=브레이크타임) facts 는 key-value 로 두고,
'어떤 key 가 존재하는가'는 업종별 JSON 스키마가 정의한다.
**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄.** 로직 수정 없음.
"""
from common.category_schema.loader import ( from common.category_schema.loader import (
CategorySchema, CategorySchema,
CategorySchemaError, CategorySchemaError,

View File

@ -1,23 +1,4 @@
"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스. """업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스."""
resources/*.json 을 최초 사용 시 메모리에 로드한다. DB 에 저장하지 않으며 런타임에 수정하지 않는다.
**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄 추가.** 코드 수정은 없다.
검증 실패 시 예외를 던진다(요청 실패가 아니라 잘못된 리소스 배포를 조기에 드러내기 위함 —
파일은 코드와 함께 배포되므로 정상 배포에선 실패하지 않는다).
필드 속성
key : facts.key 에 저장되는 식별자. 업종 안에서 유일해야 한다
label : 화면·프롬프트에 쓰는 한글 이름
type : text | number | bool | time
scope : place(사업장 단위) | unit(객실·메뉴·프로그램 단위)
required : 발행 검수 게이트의 필수 항목. 빠지면 PUBLISH_REQUIRED_FACT_MISSING
critical : ★ 틀리면 손님이 헛걸음하거나 예약 클레임이 나는 항목.
미검증 상태로는 절대 노출하지 않는다(절대규칙 1)
allow_llm : LLM 이 값을 만들어도 되는 필드인가. **False 가 기본** — LLM 은 사실을 만들지 않는다.
True 인 것은 소개문처럼 '문장' 자체가 산출물인 필드뿐이다(절대규칙 7)
unit : 값의 단위(원·명·분…). 없으면 null
"""
import json import json
from pathlib import Path from pathlib import Path
@ -36,7 +17,7 @@ class CategorySchemaError(RuntimeError):
class FieldSpec: class FieldSpec:
"""업종 스키마의 필드 1개. JSON 한 행에 대응한다.""" """업종 스키마의 필드 1개."""
__slots__ = ("key", "label", "type", "scope", "required", "critical", "allow_llm", "unit") __slots__ = ("key", "label", "type", "scope", "required", "critical", "allow_llm", "unit")
@ -108,17 +89,16 @@ class CategorySchema:
return [k for k, f in self.fields.items() if f.required and (scope is None or f.scope == scope)] return [k for k, f in self.fields.items() if f.required and (scope is None or f.scope == scope)]
def critical_keys(self) -> list[str]: def critical_keys(self) -> list[str]:
"""★ 미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등).""" """미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등)."""
return [k for k, f in self.fields.items() if f.critical] return [k for k, f in self.fields.items() if f.critical]
def llm_writable_keys(self) -> list[str]: def llm_writable_keys(self) -> list[str]:
"""LLM 이 값을 만들어도 되는 key 목록. 나머지는 LLM 이 값을 채울 수 없다.""" """LLM 이 값을 만들어도 되는 key 목록."""
return [k for k, f in self.fields.items() if f.allow_llm] return [k for k, f in self.fields.items() if f.allow_llm]
def load_schemas() -> None: def load_schemas() -> None:
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회 호출(멱등). """리소스 디렉터리 전체 로드 + 검증."""
파일을 하나 추가하면 그대로 새 업종이 된다 — 로더 코드는 건드리지 않는다."""
global _schemas global _schemas
if _schemas is not None: if _schemas is not None:
return return
@ -146,7 +126,7 @@ def load_schemas() -> None:
def get_schema(category) -> CategorySchema: def get_schema(category) -> CategorySchema:
"""업종 코드(int 또는 PlaceCategory) → 스키마. 없는 업종이면 CategorySchemaError.""" """업종 코드(int 또는 PlaceCategory) → 스키마."""
if _schemas is None: if _schemas is None:
load_schemas() load_schemas()
code = category.value if isinstance(category, PlaceCategory) else category code = category.value if isinstance(category, PlaceCategory) else category
@ -157,14 +137,14 @@ def get_schema(category) -> CategorySchema:
def all_schemas() -> dict: def all_schemas() -> dict:
"""전 업종 스키마. {code: CategorySchema}""" """전 업종 스키마."""
if _schemas is None: if _schemas is None:
load_schemas() load_schemas()
return dict(_schemas) return dict(_schemas)
def is_valid_key(category, key: str) -> bool: def is_valid_key(category, key: str) -> bool:
"""해당 업종에 존재하는 fact key 인지. facts 쓰기 전 검증에 쓴다(FACT_INVALID_KEY).""" """해당 업종에 존재하는 fact key 인지."""
try: try:
return get_schema(category).has(key) return get_schema(category).has(key)
except CategorySchemaError: except CategorySchemaError:

View File

@ -1,8 +1,4 @@
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용. """수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용."""
★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다.
자세한 배경은 DEVLOG.md 참고.
"""
from contextlib import contextmanager from contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
@ -11,8 +7,7 @@ from common.logger import LOG
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None) _current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 # jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 있어 상한을 둔다.
# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다.
_MAX_MESSAGE = 500 _MAX_MESSAGE = 500
_MAX_TARGET = 200 _MAX_TARGET = 200
@ -27,7 +22,7 @@ class CollectIssue:
@contextmanager @contextmanager
def collecting(): def collecting():
"""run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다.""" """run_collect() 진입부에서 한 번 연다."""
token = _current.set([]) token = _current.set([])
try: try:
yield yield
@ -36,10 +31,7 @@ def collecting():
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue: def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다. """실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다."""
collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다
(단발 호출·테스트 호환)."""
issue = CollectIssue( issue = CollectIssue(
stage=stage, stage=stage,
target=target[:_MAX_TARGET], target=target[:_MAX_TARGET],
@ -54,6 +46,6 @@ def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
def snapshot() -> list[dict]: def snapshot() -> list[dict]:
"""지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다.""" """지금까지 쌓인 실패 목록."""
issues = _current.get() issues = _current.get()
return [asdict(i) for i in issues] if issues else [] return [asdict(i) for i in issues] if issues else []

View File

@ -1,20 +1,4 @@
"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.** """사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**"""
★ 제품 제약: **업소 1곳의 사이트를 만드는 데 드는 외부 API 비용은 $1(1,400원)을 넘으면 안 된다.**
이 모듈은 그 예산을 "문서에 적힌 목표" 가 아니라 **호출을 막는 가드**로 만든다.
쓰는 법 — 사이트 1건 = 미터 1개:
meter = CostMeter(place_id=42)
meter.guard(Provider.PERPLEXITY, calls=1, tokens=3000) # 호출 "전" 에 물어본다
...실제 호출...
meter.charge(Provider.PERPLEXITY, calls=1, tokens=3100) # 호출 "후" 에 실측을 적는다
`guard()` 가 BudgetExceeded 를 던지면 **그 호출은 하지 않는다.** 이미 쓴 돈은 못 돌려받지만
다음 호출을 막아 손실이 선형으로 늘어나는 것을 끊는다.
비용 리스크 순서(큰 것부터): Gemini(사진 장수에 비례) > Perplexity(토큰+요청 요금) > Kakao(건당 2원).
"""
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum from enum import Enum
@ -22,9 +6,9 @@ from enum import Enum
from common.logger import LOG from common.logger import LOG
# ── 예산 ──────────────────────────────────────────────────────── # ── 예산 ────────────────────────────────────────────────────────
USD_KRW = 1400.0 # 환산 환율. 실제 청구 환율과 다를 수 있다. USD_KRW = 1400.0 # 환산 환율.
SITE_BUDGET_KRW = 1400.0 # ★ 사이트 1건당 상한 = $1 SITE_BUDGET_KRW = 1400.0 # 사이트 1건당 상한 = $1
# 예산의 몇 %까지 차면 경고를 남길지. 넘겨도 막지는 않는다 — 막는 건 100% 지점이다. # 예산의 몇 %까지 차면 경고를 남길지.
WARN_RATIO = 0.7 WARN_RATIO = 0.7
@ -39,12 +23,7 @@ class Provider(Enum):
@dataclass(frozen=True) @dataclass(frozen=True)
class Rate: class Rate:
"""공급자별 단가(원 기준). """공급자별 단가(원 기준)."""
confirmed=False 는 **아직 공식 단가표로 확인하지 않은 추정치**라는 뜻이다.
추정치로 예산을 계산하면 "예산 안" 이라는 결론 자체가 추정이 된다 —
실제 배치를 돌리기 전에 `assert_rates_confirmed()` 로 막는다.
"""
per_call_krw: float = 0.0 # 호출 1건당 고정비 per_call_krw: float = 0.0 # 호출 1건당 고정비
per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금) per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금)
@ -55,26 +34,22 @@ class Rate:
# ── 단가표 ────────────────────────────────────────────────────── # ── 단가표 ──────────────────────────────────────────────────────
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
RATES: dict[Provider, Rate] = { RATES: dict[Provider, Rate] = {
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"), Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원. # 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
Provider.KAKAO: Rate( Provider.KAKAO: Rate(
per_call_krw=2.0, per_call_krw=2.0,
confirmed=True, confirmed=True,
source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)", source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)",
), ),
# Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M. # Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M.
# 2026-08-28 환산(USD_KRW=1,400)이다. 내부 검색 횟수에는 별도 요금이 없다.
Provider.PERPLEXITY: Rate( Provider.PERPLEXITY: Rate(
per_call_krw=7.0, per_call_krw=7.0,
per_1k_token_krw=1.4, per_1k_token_krw=1.4,
confirmed=True, confirmed=True,
source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context", source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context",
), ),
# ★ 미확인 — Google AI Studio 단가표 확인 후 교체할 것. # 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
Provider.GEMINI: Rate( Provider.GEMINI: Rate(
per_image_krw=1.0, per_image_krw=1.0,
per_1k_token_krw=0.5, per_1k_token_krw=0.5,
@ -91,7 +66,6 @@ KAKAO_COORD_KRW = 0.5
class BudgetExceeded(Exception): class BudgetExceeded(Exception):
"""사이트 1건 예산을 넘겼다. 호출측은 **더 호출하지 말고** 작업을 중단한다."""
def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float): def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float):
self.place_id = place_id self.place_id = place_id
@ -108,10 +82,7 @@ class UnconfirmedRate(Exception):
def assert_rates_confirmed(*providers: Provider) -> None: def assert_rates_confirmed(*providers: Provider) -> None:
"""실배치 직전에 호출한다. 추정 단가가 섞여 있으면 막는다. """실배치 직전에 호출한다."""
★ 이걸 건너뛰면 "예산 안에 들어온다" 는 결론이 추정 위에 서게 된다.
"""
bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed] bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed]
if bad: if bad:
names = ", ".join(p.value for p in bad) names = ", ".join(p.value for p in bad)
@ -129,7 +100,7 @@ def estimate_krw(
images: int = 0, images: int = 0,
kakao_coord_calls: int = 0, kakao_coord_calls: int = 0,
) -> float: ) -> float:
"""이번 호출의 원가(원)를 계산한다. 실측이든 예상이든 같은 식을 쓴다.""" """이번 호출의 원가(원)를 계산한다."""
rate = RATES[provider] rate = RATES[provider]
krw = ( krw = (
rate.per_call_krw * calls rate.per_call_krw * calls
@ -144,7 +115,7 @@ def estimate_krw(
@dataclass @dataclass
class CostMeter: class CostMeter:
"""사이트 1건(업소 1곳)의 원가 누적기. **미터 1개 = 사이트 1건**이다.""" """사이트 1건(업소 1곳)의 원가 누적기."""
place_id: int | None = None place_id: int | None = None
budget_krw: float = SITE_BUDGET_KRW budget_krw: float = SITE_BUDGET_KRW
@ -162,20 +133,14 @@ class CostMeter:
return self.spent_krw / USD_KRW return self.spent_krw / USD_KRW
def guard(self, provider: Provider, **units) -> float: def guard(self, provider: Provider, **units) -> float:
"""호출 **전** 에 예산을 확인한다. 넘으면 BudgetExceeded — 호출하지 마라. """호출 **전** 에 예산을 확인한다."""
돌려주는 값은 이번 호출의 예상 원가(원)다.
"""
krw = estimate_krw(provider, **units) krw = estimate_krw(provider, **units)
if self.spent_krw + krw > self.budget_krw: if self.spent_krw + krw > self.budget_krw:
raise BudgetExceeded(self.place_id, self.spent_krw, krw) raise BudgetExceeded(self.place_id, self.spent_krw, krw)
return krw return krw
def charge(self, provider: Provider, **units) -> float: def charge(self, provider: Provider, **units) -> float:
"""호출 **후** 에 실측 사용량을 적는다. 적고 나서 예산을 넘었으면 예외를 던진다. """이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다."""
★ 이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다.
"""
krw = estimate_krw(provider, **units) krw = estimate_krw(provider, **units)
self.spent_krw += krw self.spent_krw += krw
self.calls += 1 self.calls += 1

View File

@ -13,16 +13,7 @@ from config.server_configs import main_db_config
class DBSessionManager(Singleton): class DBSessionManager(Singleton):
"""DB 세션/엔진 관리자 (싱글톤). """DB 세션/엔진 관리자 (싱글톤)."""
핵심 패턴
- DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다.
=> 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다.
- 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다.
execute_lambda : 단일 쿼리 (주로 조회)
execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit
세션 open/close 와 commit/rollback 은 매니저가 책임진다.
"""
def __init__(self): def __init__(self):
if DBSessionManager.is_init(): if DBSessionManager.is_init():
@ -33,7 +24,7 @@ class DBSessionManager(Singleton):
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"} self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다. # 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
self.__engines = [] self.__engines = []
# 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다. # 논리 DB -> config.
self.__db_type_map = { self.__db_type_map = {
DBType.MAIN.value: main_db_config, DBType.MAIN.value: main_db_config,
} }
@ -61,7 +52,7 @@ class DBSessionManager(Singleton):
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}" db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}"
LOG.i(f"Write DB create engine url : {db_url}") LOG.i(f"Write DB create engine url : {db_url}")
# SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수. sslmode 가 설정되면 asyncpg 에 전달. # SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수.
connect_args = {} connect_args = {}
sslmode = (getattr(db_config, "sslmode", "") or "").lower() sslmode = (getattr(db_config, "sslmode", "") or "").lower()
if sslmode and sslmode != "disable": if sslmode and sslmode != "disable":
@ -88,13 +79,11 @@ class DBSessionManager(Singleton):
return db_type in self.__db_type_map return db_type in self.__db_type_map
async def dispose_all(self): async def dispose_all(self):
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다. """모든 엔진의 커넥션 풀을 정리한다."""
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.
"""
for engine in self.__engines: for engine in self.__engines:
await engine.dispose() await engine.dispose()
# ---- 세션 lifecycle ------------------------------------------------- # 세션 lifecycle
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession: async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
if db_wr_type == DBWRType.DB_WRITE.value: if db_wr_type == DBWRType.DB_WRITE.value:
return self.__write_session[db_type]() return self.__write_session[db_type]()
@ -106,16 +95,14 @@ class DBSessionManager(Singleton):
else: else:
await self.__read_session[db_type].remove() await self.__read_session[db_type].remove()
# ---- 저수준 DB 연산 (crud 에서 호출) -------------------------------- # 저수준 DB 연산 (crud 에서 호출)
async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType: async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType:
try: try:
await db.commit() await db.commit()
return ErrorType.SUCCESS return ErrorType.SUCCESS
except IntegrityError as ex: except IntegrityError as ex:
await db.rollback() await db.rollback()
# ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 # 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 (services/collect_service.py `_add_link`).
# (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못
# 보던 무결성 오류는 아래 일반 Exception 갈래로 간다.
LOG.w(f"duplicated. {ex}") LOG.w(f"duplicated. {ex}")
return ErrorType.DB_ALREADY_SAME_KEY return ErrorType.DB_ALREADY_SAME_KEY
except Exception as ex: except Exception as ex:
@ -164,8 +151,7 @@ class DBSessionManager(Singleton):
return err_type return err_type
async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]: async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]:
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환. """update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환."""
조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용."""
try: try:
if hasattr(query, "column_descriptions"): if hasattr(query, "column_descriptions"):
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB") raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
@ -196,9 +182,9 @@ class DBSessionManager(Singleton):
raise RuntimeError(err_type.name, err_msg) raise RuntimeError(err_type.name, err_msg)
return err_type, [] return err_type, []
# ---- 람다 실행 진입점 (service 에서 호출) --------------------------- # 람다 실행 진입점 (service 에서 호출)
async def execute_lambda(self, db_type: int, db_wr_type: int, func): async def execute_lambda(self, db_type: int, db_wr_type: int, func):
"""단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환.""" """단일 쿼리 호출."""
s = await self.start_session(db_type, db_wr_type) s = await self.start_session(db_type, db_wr_type)
try: try:
return await func(s) return await func(s)
@ -206,9 +192,7 @@ class DBSessionManager(Singleton):
await self.end_session(db_type, db_wr_type) await self.end_session(db_type, db_wr_type)
async def execute_lambda_run(self, db_type_list: list[int], func_list: list): async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit. """동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit."""
하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다.
"""
temp_list = list(set(db_type_list)) temp_list = list(set(db_type_list))
if len(temp_list) != 1: if len(temp_list) != 1:
return ErrorType.DB_INVALID_TYPE return ErrorType.DB_INVALID_TYPE
@ -228,12 +212,7 @@ class DBSessionManager(Singleton):
await self.end_session(db_type, DBWRType.DB_WRITE.value) await self.end_session(db_type, DBWRType.DB_WRITE.value)
async def execute_lambda_write(self, db_type: int, func): async def execute_lambda_write(self, db_type: int, func):
"""Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다. """Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다."""
execute_lambda_run 은 ErrorType 만, execute_lambda_claim 은 (ErrorType, 적용행수) 만 돌려준다.
작업 큐처럼 "변경하면서 값을 받아와야" 하는 경우(RETURNING 절)를 위한 진입점이다 —
원자적 claim(FOR UPDATE SKIP LOCKED + UPDATE + RETURNING)은 조회/변경을 나눌 수 없다.
예외는 rollback 후 그대로 전파한다(호출측이 잡 실패로 처리)."""
s = await self.start_session(db_type, DBWRType.DB_WRITE.value) s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
try: try:
result = await func(s) result = await func(s)
@ -246,9 +225,7 @@ class DBSessionManager(Singleton):
await self.end_session(db_type, DBWRType.DB_WRITE.value) await self.end_session(db_type, DBWRType.DB_WRITE.value)
async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]: async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]:
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환. """조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환."""
동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것.
(Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)"""
s = await self.start_session(db_type, DBWRType.DB_WRITE.value) s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
try: try:
err_type, rowcount = await func(s) err_type, rowcount = await func(s)

View File

@ -22,27 +22,13 @@ from common.enums import (
JobStatus, JobStatus,
) )
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다. # 모든 ORM 모델의 베이스.
MAIN_BASE = declarative_base() MAIN_BASE = declarative_base()
# 공통 mixin # 공통 mixin DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
def _utc_now_sql(): def _utc_now_sql():
"""TIMESTAMPTZ 컬럼의 기본값. **init.sql 과 같은 `now()` 여야 한다.** """TIMESTAMPTZ 컬럼의 기본값."""
★ 예전 값은 `(now() AT TIME ZONE 'utc')` 였는데, 이건 timestamptz 에 쓰면 틀린다.
AT TIME ZONE 'utc' 는 timestamptz 를 **시간대 없는 벽시계 값**으로 떨어뜨리고,
그 값이 timestamptz 컬럼에 들어가며 세션 시간대로 다시 해석된다 — 서버 시간대만큼
미래(또는 과거)로 밀린 시각이 저장된다.
★ 운영에서는 안 드러났다. 운영 DB 는 init.sql(`DEFAULT now()`)로 만들어지고, 이 기본값은
**ORM 이 스키마를 만들 때만** 쓰이기 때문이다 — 즉 테스트 DB 뿐이다(conftest).
실측(2026-09-10): 테스트에서 잡의 run_after 가 7시간 뒤로 박혀 claim 조건
(`run_after <= now()`)에 영영 안 걸렸다. 워커가 잡을 하나도 못 집어 COPY 관련 테스트가
"잡이 PENDING 인 채" 로 무더기 실패했고, 원인이 코드가 아니라 스키마라 읽히지 않았다.
→ 스키마는 init.sql 이 단일 출처다. ORM 기본값이 그것과 다르면 이런 식으로 갈라진다.
"""
return text("now()") return text("now()")
@ -66,11 +52,8 @@ class users(MainTableMixin, MAIN_BASE):
__tablename__ = "users" __tablename__ = "users"
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디 id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
# 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여 # 소셜 계정은 비밀번호가 없다(NULL).
# id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다.
password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장) password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
name = Column(String(50), nullable=True) name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True) email = Column(String(255), nullable=True)
@ -78,25 +61,16 @@ class users(MainTableMixin, MAIN_BASE):
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value) status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value) role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 # server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value) provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키 provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
# ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다 # refresh 토큰 무효화 키.
# (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서,
# 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다.
# ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는
# refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다.
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
# ============================================================
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진 # place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
# ============================================================
class places(MainTableMixin, MAIN_BASE): class places(MainTableMixin, MAIN_BASE):
"""사업장. 상호명 하나로 시작해서, 카카오 로컬 검증을 통과해야 수집이 열린다. """사업장."""
★ verified_at 이 NULL 이면 collector 진입 금지 — 검증 없이 수집하면 남의 가게가 섞인다."""
__tablename__ = "places" __tablename__ = "places"
__table_args__ = ( __table_args__ = (
@ -104,15 +78,13 @@ class places(MainTableMixin, MAIN_BASE):
) )
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다. # 스코프 키.
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users) owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
name = Column(String(200), nullable=False) # 상호명(입력값) name = Column(String(200), nullable=False) # 상호명(입력값)
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키 category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
# ---- 카카오 로컬 검증 산출물 (동일 업소 판정) ---- # 카카오 로컬 검증 산출물 (동일 업소 판정)
# 동일 업소 판정 키. 소스에 따라 있을 수도 없을 수도 있다 —
# 카카오는 고유 id 를 주지만 네이버는 안 준다(그 경우 상호명+도로명주소가 대체 키).
external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource
external_place_id = Column(String(64), nullable=True) external_place_id = Column(String(64), nullable=True)
road_address = Column(String(255), nullable=True) road_address = Column(String(255), nullable=True)
@ -121,26 +93,19 @@ class places(MainTableMixin, MAIN_BASE):
latitude = Column(Numeric(10, 7), nullable=True) latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True)
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회) region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다. # 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션").
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
external_category = Column(String(200), nullable=True) external_category = Column(String(200), nullable=True)
verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지 verified_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미검증 → 수집·발행 금지
verified_by = Column(UUID(as_uuid=True), nullable=True) verified_by = Column(UUID(as_uuid=True), nullable=True)
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — # 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각.
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
content_updated_at = Column(DateTime(timezone=True), nullable=True) content_updated_at = Column(DateTime(timezone=True), nullable=True)
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) — # 미니 블로그 승인 메일 수신 주소.
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
notify_email = Column(String(255), nullable=True) notify_email = Column(String(255), nullable=True)
class place_channels(MainTableMixin, MAIN_BASE): class place_channels(MainTableMixin, MAIN_BASE):
"""Perplexity 가 발견한 채널 URL. """Perplexity 가 발견한 채널 URL."""
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
__tablename__ = "place_channels" __tablename__ = "place_channels"
__table_args__ = ( __table_args__ = (
@ -160,14 +125,13 @@ class place_channels(MainTableMixin, MAIN_BASE):
title = Column(String(300), nullable=True) # 발견 시 제목/스니펫 title = Column(String(300), nullable=True) # 발견 시 제목/스니펫
discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력) discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력)
discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
confirmed_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미확정, 크롤링 금지 confirmed_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미확정, 크롤링 금지
confirmed_by = Column(UUID(as_uuid=True), nullable=True) confirmed_by = Column(UUID(as_uuid=True), nullable=True)
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results) raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
class place_units(MainTableMixin, MAIN_BASE): class place_units(MainTableMixin, MAIN_BASE):
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램. """업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램."""
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
__tablename__ = "place_units" __tablename__ = "place_units"
@ -178,11 +142,7 @@ class place_units(MainTableMixin, MAIN_BASE):
class place_photos(MainTableMixin, MAIN_BASE): class place_photos(MainTableMixin, MAIN_BASE):
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다. """사진."""
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
__tablename__ = "place_photos" __tablename__ = "place_photos"
@ -203,12 +163,7 @@ class place_photos(MainTableMixin, MAIN_BASE):
class place_songs(MainTableMixin, MAIN_BASE): class place_songs(MainTableMixin, MAIN_BASE):
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno. """이 숙소의 노래."""
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
__tablename__ = "place_songs" __tablename__ = "place_songs"
@ -219,30 +174,20 @@ class place_songs(MainTableMixin, MAIN_BASE):
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기 style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno") provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠 provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다 origin_url = Column(String(1000), nullable=True) # 만료되는 주소.
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것> file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
duration_sec = Column(Numeric(6, 2), nullable=True) duration_sec = Column(Numeric(6, 2), nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
last_error = Column(Text, nullable=True) last_error = Column(Text, nullable=True)
# ============================================================
# fact : 사실 / FAQ # fact : 사실 / FAQ
# ============================================================
class place_facts(MainTableMixin, MAIN_BASE): class place_facts(MainTableMixin, MAIN_BASE):
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다. """가장 중요한 테이블."""
- key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다.
- unit_id 가 NULL 이면 사업장 단위 fact, 있으면 객실·메뉴·프로그램 단위 fact.
- ★ VERIFIED / CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES).
- ★ CORRECTED(사장님 수정본)는 잠긴다 — 자동 갱신이 덮어쓰지 않는다.
활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다."""
__tablename__ = "place_facts" __tablename__ = "place_facts"
__table_args__ = ( __table_args__ = (
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다. # unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
Index( Index(
"uq_facts_published_place_key", "uq_facts_published_place_key",
"place_id", "place_id",
@ -291,12 +236,7 @@ class place_facts(MainTableMixin, MAIN_BASE):
class place_faqs(MainTableMixin, MAIN_BASE): class place_faqs(MainTableMixin, MAIN_BASE):
"""FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다. """FAQ."""
LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다)
OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다
TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다.
★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다."""
__tablename__ = "place_faqs" __tablename__ = "place_faqs"
@ -311,22 +251,7 @@ class place_faqs(MainTableMixin, MAIN_BASE):
class place_itineraries(MainTableMixin, MAIN_BASE): class place_itineraries(MainTableMixin, MAIN_BASE):
"""LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다. """LLM 이 만든 여행 일정."""
★ 왜 area_contents 가 아닌가
그 표의 유일성 근거는 셋 다 지역·출처 기준이다((source, external_id) ·
(region_code, kind) · (region_code, content_type)). 이 값은 **업장 하나에 붙는다** —
업소 이름이 프롬프트에 들어가고, 같은 지역 옆집이 나눠 쓸 수 없다.
넷째 근거를 그 표에 더하면 0004·0007 에서 겪은 "제약이 겹쳐 조용히 틀리는" 사고를
다시 만든다(area_contents.__table_args__ 주석).
★ body 는 렌더러 계약 그대로다(`shared/lib/section-data.ts` 의 ItineraryItem[]).
읽는 쪽이 모양을 다시 바꾸지 않아야 사장님이 손으로 붙여넣은 것과 갈리지 않는다 —
지역 이야기가 body 에 봉투째 담는 것과 같은 이유다.
★ 코스마다 한 행으로 쪼개지 않는다. 다시 생성할 때 그 한 행을 덮어쓰면 되고,
쪼개면 "5개를 받았는데 3개만 갱신된" 상태가 생긴다.
"""
__tablename__ = "place_itineraries" __tablename__ = "place_itineraries"
__table_args__ = ( __table_args__ = (
@ -340,7 +265,7 @@ class place_itineraries(MainTableMixin, MAIN_BASE):
place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
place_id = Column(UUID(as_uuid=True), nullable=False, index=True) place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# '1박 2일' · '2박 3일'. 화면 탭이 되는 값이라 표기를 바꾸지 않는다(prompts/itinerary.DURATIONS). # '1박 2일' · '2박 3일'.
duration = Column(String(20), nullable=False) duration = Column(String(20), nullable=False)
body = Column(JSONB, nullable=False) # ItineraryItem[] body = Column(JSONB, nullable=False) # ItineraryItem[]
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM
@ -348,26 +273,13 @@ class place_itineraries(MainTableMixin, MAIN_BASE):
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
# ============================================================
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변 # local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
# ============================================================
class area_contents(MainTableMixin, MAIN_BASE): class area_contents(MainTableMixin, MAIN_BASE):
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 — """지역 정보 캐시."""
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
__tablename__ = "area_contents" __tablename__ = "area_contents"
# ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 —
# 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만
# 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류).
# 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다.
# ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는
# 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 —
# 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다.
__table_args__ = ( __table_args__ = (
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** — # 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다.
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
Index( Index(
"uq_local_contents_external", "uq_local_contents_external",
"source", "source",
@ -383,7 +295,7 @@ class area_contents(MainTableMixin, MAIN_BASE):
unique=True, unique=True,
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"), postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
), ),
# 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007). # 날씨: 지역 × 종류당 한 행.
Index( Index(
"uq_local_contents_single", "uq_local_contents_single",
"region_code", "region_code",
@ -394,9 +306,7 @@ class area_contents(MainTableMixin, MAIN_BASE):
) )
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
# ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 — # nullable 이다.
# 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004).
# 지역 이야기·날씨만 이 값을 키로 쓴다.
region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드 region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드
content_type = Column(SmallInteger, nullable=False) # LocalContentType content_type = Column(SmallInteger, nullable=False) # LocalContentType
source = Column(SmallInteger, nullable=False) # LocalSource source = Column(SmallInteger, nullable=False) # LocalSource
@ -410,18 +320,14 @@ class area_contents(MainTableMixin, MAIN_BASE):
display_end_at = Column(DateTime(timezone=True), nullable=True) display_end_at = Column(DateTime(timezone=True), nullable=True)
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql()) collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지) expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
latitude = Column(Numeric(10, 7), nullable=True) latitude = Column(Numeric(10, 7), nullable=True)
longitude = Column(Numeric(10, 7), nullable=True) longitude = Column(Numeric(10, 7), nullable=True)
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL. # 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류.
kind = Column(String(50), nullable=True) kind = Column(String(50), nullable=True)
class place_area_refs(MainTableMixin, MAIN_BASE): class place_area_refs(MainTableMixin, MAIN_BASE):
"""업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다. """업장 ↔ 지역 콘텐츠."""
★ 예전엔 값을 통째로 들고 키가 place_id 라 업장마다 복제됐다(한 곳에 144행).
★ hidden 은 재수집이 덮어쓰지 않는다."""
__tablename__ = "place_area_refs" __tablename__ = "place_area_refs"
@ -433,11 +339,7 @@ class place_area_refs(MainTableMixin, MAIN_BASE):
class place_posts(MainTableMixin, MAIN_BASE): class place_posts(MainTableMixin, MAIN_BASE):
"""미니 블로그 글 하나. 기획: docs/MINI_BLOG.md """미니 블로그 글 하나."""
★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
__tablename__ = "place_posts" __tablename__ = "place_posts"
__table_args__ = ( __table_args__ = (
@ -454,11 +356,8 @@ class place_posts(MainTableMixin, MAIN_BASE):
topic_kind = Column(SmallInteger, nullable=False) topic_kind = Column(SmallInteger, nullable=False)
topic_key = Column(String(120), nullable=False) topic_key = Column(String(120), nullable=False)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
# 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date). # 이 업장 몫 하루 한 통 배정일(KST).
scheduled_date = Column(Date, nullable=True) scheduled_date = Column(Date, nullable=True)
# 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
# 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
generation_meta = Column(JSONB, nullable=True) generation_meta = Column(JSONB, nullable=True)
approve_token_hash = Column(String(64), nullable=True) approve_token_hash = Column(String(64), nullable=True)
# ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을 # ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을
@ -472,11 +371,7 @@ class place_posts(MainTableMixin, MAIN_BASE):
class place_reviews(MainTableMixin, MAIN_BASE): class place_reviews(MainTableMixin, MAIN_BASE):
"""손님이 남긴 이용 후기. """손님이 남긴 이용 후기."""
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
__tablename__ = "place_reviews" __tablename__ = "place_reviews"
__table_args__ = ( __table_args__ = (
@ -494,8 +389,7 @@ class place_reviews(MainTableMixin, MAIN_BASE):
class sites(MainTableMixin, MAIN_BASE): class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개. """발행 대상 사이트."""
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
__tablename__ = "sites" __tablename__ = "sites"
__table_args__ = ( __table_args__ = (
@ -507,22 +401,14 @@ class sites(MainTableMixin, MAIN_BASE):
place_id = Column(UUID(as_uuid=True), nullable=False) place_id = Column(UUID(as_uuid=True), nullable=False)
domain = Column(String(255), nullable=True) domain = Column(String(255), nullable=True)
path_prefix = Column(String(100), nullable=True) path_prefix = Column(String(100), nullable=True)
# 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리의 id). 서버는 해석하지 않고 보관·반환만 한다 — # 템플릿 id(solution/shared/src/data/templates.json).
# 템플릿 목록은 프론트가 소유하므로, 서버가 값을 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다.
# NULL 이면 발행 잡이 업종 기본 템플릿으로 굽는다(services/site_payload).
template_id = Column(String(100), nullable=True) template_id = Column(String(100), nullable=True)
# 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션). template_id 와 같은 이유로 서버에 저장한다 — # 색·섹션(순서·on/off·본문).
# 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽고, 고른 디자인과 발행본이 갈린다.
# ★ 컬럼으로 펼치지 않고 jsonb 로 통째로 담는 이유: 섹션 목록·배리에이션 키·색 토큰 이름은
# 프론트가 소유한다. 펼치면 프론트가 항목 하나 늘릴 때마다 마이그레이션이 따라와야 한다.
# ★ templateId 는 여기 넣지 않는다 — 위 template_id 컬럼이 소유한다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
# NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
theme = Column(JSONB, nullable=True) theme = Column(JSONB, nullable=True)
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
published_at = Column(DateTime(timezone=True), nullable=True) published_at = Column(DateTime(timezone=True), nullable=True)
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을 # 발행 썸네일(Azure Blob 공개 URL).
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
thumbnail_url = Column(String(500), nullable=True) thumbnail_url = Column(String(500), nullable=True)
@ -546,14 +432,7 @@ class site_search_status(MainTableMixin, MAIN_BASE):
class alert_outbox(MainTableMixin, MAIN_BASE): class alert_outbox(MainTableMixin, MAIN_BASE):
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다. """장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다."""
★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다.
장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다.
★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) —
같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다.
★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면
다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다."""
__tablename__ = "alert_outbox" __tablename__ = "alert_outbox"
alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -569,17 +448,7 @@ class alert_outbox(MainTableMixin, MAIN_BASE):
class site_sections(MainTableMixin, MAIN_BASE): class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. """섹션 하나의 콘텐츠."""
★ 왜 theme 에서 꺼냈나 (2026-09-09)
색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다.
실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다.
크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도
42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다
"누가 넣었나 · 확인됐나"를 물을 자리가 없었다.
★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다.
★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 —
발행할 때 펼쳐 payload 에 싣는다."""
__tablename__ = "site_sections" __tablename__ = "site_sections"
__table_args__ = ( __table_args__ = (
@ -602,10 +471,7 @@ class site_sections(MainTableMixin, MAIN_BASE):
class site_versions(MainTableMixin, MAIN_BASE): class site_versions(MainTableMixin, MAIN_BASE):
"""빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다. """빌드 버전."""
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다.
★ jsonld 값은 화면에 보이는 값과 같아야 한다 — 불일치면 빌드 실패(PUBLISH_JSONLD_MISMATCH).
★ unique_content_count 가 0 이면 발행 API 가 거부한다(스팸 판정 대상)."""
__tablename__ = "site_versions" __tablename__ = "site_versions"
__table_args__ = ( __table_args__ = (
@ -618,13 +484,13 @@ class site_versions(MainTableMixin, MAIN_BASE):
build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value) build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value)
snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제 snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제
jsonld = Column(JSONB, nullable=True) # 구조화 데이터 jsonld = Column(JSONB, nullable=True) # 구조화 데이터
unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # ★ 0 이면 발행 거부 unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # 0 이면 발행 거부
build_error = Column(Text, nullable=True) build_error = Column(Text, nullable=True)
built_at = Column(DateTime(timezone=True), nullable=True) built_at = Column(DateTime(timezone=True), nullable=True)
class site_publish_logs(MainTableMixin, MAIN_BASE): class site_publish_logs(MainTableMixin, MAIN_BASE):
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다.""" """발행 시도 기록."""
__tablename__ = "site_publish_logs" __tablename__ = "site_publish_logs"
@ -640,18 +506,7 @@ class site_publish_logs(MainTableMixin, MAIN_BASE):
class jobs(MainTableMixin, MAIN_BASE): class jobs(MainTableMixin, MAIN_BASE):
"""작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다. """작업 큐."""
- 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING.
워커 컨테이너가 몇 개든 같은 잡 이중 할당이 불가능하다.
- 복구는 타임아웃 추측이 아니라 **lease 만료 소유권** — 워커가 죽어도 reaper 가 회수한다.
(도커에서 컨테이너를 재시작해도 진행 중이던 잡이 증발하지 않는다.)
- 재시도·백오프·dead-letter 를 큐에 내장한다.
- dedupe_key 로 활성 중복(PENDING/RUNNING)을 막는다 — 같은 사업장 수집이 두 번 돌지 않게.
※ 이 테이블만 MainTableMixin 의 deleted 를 쓰지 않는다(잡은 이력이지 소프트 삭제 대상이 아니다).
그래도 컬럼은 남겨 공통 규약을 깨지 않는다.
"""
__tablename__ = "jobs" __tablename__ = "jobs"
__table_args__ = ( __table_args__ = (
@ -668,9 +523,7 @@ class jobs(MainTableMixin, MAIN_BASE):
), ),
) )
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 # 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다.
# ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. init.sql 의 DEFAULT gen_random_uuid() 와 맞춘다.
# (다른 테이블은 ORM 으로만 INSERT 하므로 원본 보일러플레이트대로 Python default 만 둔다.)
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4) job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4)
job_type = Column(SmallInteger, nullable=False) # JobType job_type = Column(SmallInteger, nullable=False) # JobType
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value) status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value)
@ -706,13 +559,7 @@ class owner_social_accounts(MainTableMixin, MAIN_BASE):
class owner_kakao_links(MainTableMixin, MAIN_BASE): class owner_kakao_links(MainTableMixin, MAIN_BASE):
"""카카오톡 채널 발화자 ↔ 우리 user_id. """카카오톡 채널 발화자 ↔ 우리 user_id."""
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
__tablename__ = "owner_kakao_links" __tablename__ = "owner_kakao_links"
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
@ -724,8 +571,7 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE):
status = Column(String(16), nullable=False, server_default=text("'PENDING'")) status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
linked_at = Column(DateTime(timezone=True), nullable=True) linked_at = Column(DateTime(timezone=True), nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True) last_seen_at = Column(DateTime(timezone=True), nullable=True)
# 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다). # pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
# ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
current_place_id = Column(UUID(as_uuid=True), nullable=True) current_place_id = Column(UUID(as_uuid=True), nullable=True)
pending_tool = Column(String(40), nullable=True) pending_tool = Column(String(40), nullable=True)
pending_args = Column(JSONB, nullable=True) pending_args = Column(JSONB, nullable=True)

View File

@ -15,12 +15,7 @@ class CodeEnum(Enum):
class ErrorType(Enum): class ErrorType(Enum):
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다. """서버 전역 결과 코드."""
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
도메인 코드는 모듈이 붙을 때 구간을 새로 열어 추가한다
(places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 예약).
"""
SUCCESS = 0 SUCCESS = 0
FAIL = 1 FAIL = 1
@ -55,28 +50,28 @@ class ErrorType(Enum):
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절) ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다 OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패 OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등) ACCOUNT_SESSION_REVOKED = auto()
# 사업장(places) 관련 에러 # 사업장(places) 관련 에러
PLACE_NOT_FOUND = 1200 PLACE_NOT_FOUND = 1200
PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록 PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록
PLACE_NOT_VERIFIED = auto() # ★ 동일 업소 검증 전 — 수집·발행 진입 금지 PLACE_NOT_VERIFIED = auto() # 동일 업소 검증 전 — 수집·발행 진입 금지
PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음 PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음
PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함 PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함
PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드 PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드
UNIT_NOT_FOUND = auto() UNIT_NOT_FOUND = auto()
LINK_NOT_FOUND = auto() LINK_NOT_FOUND = auto()
LINK_NOT_CONFIRMED = auto() # ★ 확정 안 된 URL — 크롤링 대상 아님 LINK_NOT_CONFIRMED = auto() # 확정 안 된 URL — 크롤링 대상 아님
MEDIA_NOT_FOUND = auto() MEDIA_NOT_FOUND = auto()
# fact 관련 에러 # fact 관련 에러
FACT_NOT_FOUND = 1300 FACT_NOT_FOUND = 1300
FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key
FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이 FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이
FACT_LOCKED = auto() # ★ CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다 FACT_LOCKED = auto() # CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다
FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음 FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음
FAQ_NOT_FOUND = auto() FAQ_NOT_FOUND = auto()
FAQ_UNGROUNDED = auto() # ★ 확보된 fact 로 뒷받침되지 않는 문장 — 반려 FAQ_UNGROUNDED = auto() # 확보된 fact 로 뒷받침되지 않는 문장 — 반려
# 수집(collector) 관련 에러 # 수집(collector) 관련 에러
COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음 COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음
@ -93,17 +88,17 @@ class ErrorType(Enum):
# 지역 정보(local) 관련 에러 # 지역 정보(local) 관련 에러
LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정 LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정
LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패 LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패
LOCAL_FETCH_FAILED = auto() # ★ 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다 LOCAL_FETCH_FAILED = auto() # 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다
# 사이트(sites) 관련 에러 # 사이트(sites) 관련 에러
SITE_NOT_FOUND = 1700 SITE_NOT_FOUND = 1700
SITE_VERSION_NOT_FOUND = auto() SITE_VERSION_NOT_FOUND = auto()
SITE_BUILD_FAILED = auto() SITE_BUILD_FAILED = auto()
PUBLISH_UNVERIFIED_FACT = auto() # ★ 미검증 fact 포함 — 발행 거부 PUBLISH_UNVERIFIED_FACT = auto() # 미검증 fact 포함 — 발행 거부
PUBLISH_NO_UNIQUE_CONTENT = auto() # ★ 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상) PUBLISH_NO_UNIQUE_CONTENT = auto() # 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상)
PUBLISH_JSONLD_MISMATCH = auto() # ★ 구조화 데이터 값 != 화면 값 — 빌드 실패 PUBLISH_JSONLD_MISMATCH = auto() # 구조화 데이터 값 != 화면 값 — 빌드 실패
PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락 PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락
SITE_SLUG_LOCKED = auto() # ★ 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다 SITE_SLUG_LOCKED = auto() # 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다
# 리포트(reports) 관련 에러 # 리포트(reports) 관련 에러
REPORT_NOT_FOUND = 1800 REPORT_NOT_FOUND = 1800
@ -132,15 +127,13 @@ EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_I
class DBType(Enum): class DBType(Enum):
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다. """논리 DB 구분."""
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
"""
MAIN = 1 MAIN = 1
class DBWRType(Enum): class DBWRType(Enum):
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다.""" """Read / Write 접속 구분."""
DB_READ = 1 DB_READ = 1
DB_WRITE = 2 DB_WRITE = 2
@ -155,9 +148,7 @@ class UserStatus(CodeEnum):
class UserRole(CodeEnum): class UserRole(CodeEnum):
"""users.role 코드값. """users.role 코드값."""
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
USER = 1 USER = 1
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정 OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
@ -165,11 +156,7 @@ class UserRole(CodeEnum):
class AuthProvider(CodeEnum): class AuthProvider(CodeEnum):
"""users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가. """users.provider 코드값."""
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절."""
LOCAL = 1 # id/pw LOCAL = 1 # id/pw
GOOGLE = 2 # 구글 ID 토큰 GOOGLE = 2 # 구글 ID 토큰
@ -183,8 +170,7 @@ class CompanyStatus(CodeEnum):
class PlaceCategory(CodeEnum): class PlaceCategory(CodeEnum):
"""places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1. """places.category 코드값."""
업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가."""
LODGING = 1 # 숙박 LODGING = 1 # 숙박
CAFE = 2 # 카페 CAFE = 2 # 카페
@ -193,18 +179,14 @@ class PlaceCategory(CodeEnum):
class ExternalPlaceSource(CodeEnum): class ExternalPlaceSource(CodeEnum):
"""places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB. """places.external_source 코드값."""
카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다.
네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다."""
KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O
NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한 NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한
class PlaceStatus(CodeEnum): class PlaceStatus(CodeEnum):
"""places.status 코드값. 사업장 생애주기. """places.status 코드값."""
해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다)."""
DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전 DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전
COLLECTING = 2 # 수집 진행 중 COLLECTING = 2 # 수집 진행 중
@ -214,19 +196,17 @@ class PlaceStatus(CodeEnum):
class SourceType(CodeEnum): class SourceType(CodeEnum):
"""facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값. """facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값."""
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다."""
OWNER = 1 # 사장님이 직접 입력·업로드 OWNER = 1 # 사장님이 직접 입력·업로드
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity) API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
CRAWL = 3 # 크롤링 CRAWL = 3 # 크롤링
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다 LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다 TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용.
class FactStatus(CodeEnum): class FactStatus(CodeEnum):
"""facts.status / faqs.status / routes.status 공용 검증 상태. """facts.status / faqs.status / routes.status 공용 검증 상태."""
★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES)."""
UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함 UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함
PENDING_OWNER = 2 # 사장님 확인 대기 PENDING_OWNER = 2 # 사장님 확인 대기
@ -236,27 +216,26 @@ class FactStatus(CodeEnum):
EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상 EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상
# ★ 절대규칙 1: 이 두 상태만 사이트에 노출한다. 발행 게이트가 이 집합으로 필터링한다. # 절대규칙 1: 이 두 상태만 사이트에 노출한다.
PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED} PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED}
# 후보 — 재수집이 올려놓은 확인 대기 항목. 노출값과 달리 (place, unit, key) 당 여러 건 공존한다. # 후보 — 재수집이 올려놓은 확인 대기 항목.
CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER} CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER}
# ★ 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. 사장님 수정본은 후보로만 도전받는다. # 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태.
LOCKED_FACT_STATUSES = {FactStatus.CORRECTED} LOCKED_FACT_STATUSES = {FactStatus.CORRECTED}
class FactWriteOutcome(CodeEnum): class FactWriteOutcome(CodeEnum):
"""fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 — """fact 기록 결과."""
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다."""
PUBLISHED_CREATED = 1 # 노출값이 없던 자리에 사람이 직접 넣어 바로 노출됐다 PUBLISHED_CREATED = 1
PUBLISHED_REPLACED = 2 # ★ 노출값이 교체됐다 — 사이트 재빌드 대상 PUBLISHED_REPLACED = 2
REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신 REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신
CANDIDATE_CREATED = 4 # 노출값과 다른 값이 들어와 후보로 쌓였다(사람 확인 대기) CANDIDATE_CREATED = 4
CANDIDATE_UPDATED = 5 # 같은 출처의 기존 후보를 새 수집값으로 갱신했다 CANDIDATE_UPDATED = 5
# 검증 상태 전이 허용표. 여기에 없는 전이는 FACT_INVALID_TRANSITION 으로 거부한다. # 검증 상태 전이 허용표.
FACT_STATUS_TRANSITIONS = { FACT_STATUS_TRANSITIONS = {
FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED},
FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED}, FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED},
@ -268,7 +247,7 @@ FACT_STATUS_TRANSITIONS = {
class LinkChannel(CodeEnum): class LinkChannel(CodeEnum):
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류.""" """place_channels.channel 코드값."""
YANOLJA = 1 # 야놀자 YANOLJA = 1 # 야놀자
GOODCHOICE = 2 # 여기어때 GOODCHOICE = 2 # 여기어때
@ -276,16 +255,13 @@ class LinkChannel(CodeEnum):
INSTAGRAM = 4 INSTAGRAM = 4
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지 OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
BLOG = 6 BLOG = 6
# ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다. # 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
# 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온
# 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은
# 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다.
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com) NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
ETC = 99 ETC = 99
class MediaStatus(CodeEnum): class MediaStatus(CodeEnum):
"""media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다.""" """media.status 코드값."""
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다 PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음) APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
@ -293,41 +269,31 @@ class MediaStatus(CodeEnum):
class SongStatus(CodeEnum): class SongStatus(CodeEnum):
"""place_songs.status 코드값. """place_songs.status 코드값."""
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라 GENERATING = 1
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다. READY = 2
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다.""" FAILED = 3 # 생성 실패.
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다. # Vision 결과를 자동 반영해도 되는 신뢰도 하한.
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
VISION_AUTO_APPROVE_CONFIDENCE = 0.7 VISION_AUTO_APPROVE_CONFIDENCE = 0.7
class LocalContentType(CodeEnum): class LocalContentType(CodeEnum):
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류.""" """local_contents.content_type 코드값."""
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시) WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25 # ↓ 2~5 는 place_contents(업장 반경 캐시).
FESTIVAL = 2 # 축제/공연/행사 (15) FESTIVAL = 2 # 축제/공연/행사 (15)
ATTRACTION = 3 # 관광지 (12) ATTRACTION = 3 # 관광지 (12)
RESTAURANT = 4 # 음식점 (39) RESTAURANT = 4 # 음식점 (39)
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다 COURSE = 5 # 여행코스 (25) — 백엔드만.
# ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 — # 지역 이야기(가요·일력·인물·연표·엽서·퀴즈).
# 군산 이야기는 군산 숙소가 같이 쓴다. 여섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
STORY = 6 STORY = 6
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 — # 코드값 ↔ **타입명**.
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
AREA_KIND = { AREA_KIND = {
LocalContentType.WEATHER.value: "weather", LocalContentType.WEATHER.value: "weather",
LocalContentType.FESTIVAL.value: "festival", LocalContentType.FESTIVAL.value: "festival",
@ -336,22 +302,20 @@ AREA_KIND = {
LocalContentType.COURSE.value: "course", LocalContentType.COURSE.value: "course",
} }
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다. # 지역 이야기 일곱.
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz") STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
class LocalSource(CodeEnum): class LocalSource(CodeEnum):
"""local_contents.source 코드값. 어느 외부 API 에서 왔는지.""" """local_contents.source 코드값."""
OPEN_METEO = 1 # 날씨. API 키 불필요 OPEN_METEO = 1 # 날씨.
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다 TOUR_API = 2 # 한국관광공사.
KAKAO_LOCAL = 3 KAKAO_LOCAL = 3
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록 OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 — # 지역 이야기 생성분.
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
LLM = 5 LLM = 5
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다 NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강).
class LocalContentStatus(CodeEnum): class LocalContentStatus(CodeEnum):
@ -363,7 +327,7 @@ class LocalContentStatus(CodeEnum):
class TransportType(CodeEnum): class TransportType(CodeEnum):
"""routes.transport 코드값. 가는 길 수단.""" """routes.transport 코드값."""
CAR = 1 CAR = 1
PUBLIC = 2 PUBLIC = 2
@ -371,7 +335,7 @@ class TransportType(CodeEnum):
class SiteStatus(CodeEnum): class SiteStatus(CodeEnum):
"""sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다.""" """sites.status 코드값."""
DRAFT = 1 DRAFT = 1
REVIEW = 2 # 검수 게이트 대기 REVIEW = 2 # 검수 게이트 대기
@ -381,7 +345,7 @@ class SiteStatus(CodeEnum):
class BuildStatus(CodeEnum): class BuildStatus(CodeEnum):
"""site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다.""" """site_versions.build_status 코드값."""
PENDING = 1 PENDING = 1
BUILDING = 2 BUILDING = 2
@ -397,7 +361,7 @@ class PublishAction(CodeEnum):
REBUILD = 3 REBUILD = 3
SUSPEND = 4 SUSPEND = 4
RESUME = 5 RESUME = 5
ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py ROLLBACK = 6
class PublishResult(CodeEnum): class PublishResult(CodeEnum):
@ -409,7 +373,7 @@ class PublishResult(CodeEnum):
class PublishRejectReason(CodeEnum): class PublishRejectReason(CodeEnum):
"""publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3).""" """publish_logs.reject_reason 코드값."""
UNVERIFIED_FACT = 1 # 미검증 fact 포함 UNVERIFIED_FACT = 1 # 미검증 fact 포함
NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건 NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건
@ -418,7 +382,7 @@ class PublishRejectReason(CodeEnum):
class AiEngine(CodeEnum): class AiEngine(CodeEnum):
"""ai_check_results.engine 코드값. AI 검색이 우리 사이트를 근거로 답하는지 측정할 대상.""" """ai_check_results.engine 코드값."""
CHATGPT = 1 CHATGPT = 1
PERPLEXITY = 2 PERPLEXITY = 2
@ -427,14 +391,9 @@ class AiEngine(CodeEnum):
ETC = 99 ETC = 99
# ============================================================
# 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다) # 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다)
# ============================================================
class JobType(CodeEnum): class JobType(CodeEnum):
"""jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다. """jobs.job_type 코드값."""
무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 —
크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다."""
COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링 COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링
VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치) VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치)
@ -442,30 +401,27 @@ 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 검색 노출 점검
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다 SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno).
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림 ROLLBACK = 8
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로 SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시 SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
class JobStatus(CodeEnum): class JobStatus(CodeEnum):
"""jobs.status 코드값. 작업 큐 상태. """jobs.status 코드값."""
전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면 PENDING = 1 # 대기(claim 가능).
PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter).""" RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유).
PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수
DONE = 3 # 완료 DONE = 3 # 완료
DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상) DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상)
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다. # claim 대상이 되는 활성 상태.
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING} ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
class PostTopicKind(CodeEnum): class PostTopicKind(CodeEnum):
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다.""" """place_posts.topic_kind — 어떤 갈래로 쓴 글인가."""
WEATHER = 1 # local.weather WEATHER = 1 # local.weather
FESTIVAL = 2 # local.festivals FESTIVAL = 2 # local.festivals
@ -475,20 +431,20 @@ class PostTopicKind(CodeEnum):
class PostStatus(CodeEnum): class PostStatus(CodeEnum):
"""place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다.""" """place_posts.status — 글 하나의 일생."""
DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다 DRAFT = 1
REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단 REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
SENT = 3 # 사장님에게 메일이 나갔다 SENT = 3
APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기 APPROVED = 4
PUBLISHED = 5 # 사이트에 올라갔다 PUBLISHED = 5
SKIPPED = 6 # 반려(우리) 또는 넘김(사장님) SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
class ReviewStatus(CodeEnum): class ReviewStatus(CodeEnum):
"""place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다.""" """place_reviews.status — 손님이 쓴 글의 일생."""
PENDING = 1 # 손님이 막 남겼다 PENDING = 1
PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다 PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
REJECTED = 3 # 반려 REJECTED = 3 # 반려
@ -499,14 +455,11 @@ class SocialProvider(CodeEnum):
class KakaoLinkStatus(str, Enum): class KakaoLinkStatus(str, Enum):
"""owner_kakao_links.status. """owner_kakao_links.status."""
★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번 PENDING = "PENDING"
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다.""" LINKED = "LINKED"
REVOKED = "REVOKED"
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
LINKED = "LINKED" # channel_user_key 가 붙었다
REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다
class SocialPostStatus(str, Enum): class SocialPostStatus(str, Enum):
@ -523,7 +476,7 @@ class SocialPostStatus(str, Enum):
class AlertStatus(CodeEnum): class AlertStatus(CodeEnum):
"""alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다.""" """alert_outbox.status 코드값."""
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도) PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
SENT = 2 # 전송 성공 SENT = 2 # 전송 성공

View File

@ -1,17 +1,4 @@
"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문. """FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문."""
★ 왜 필요한가
COPY 잡은 확인된 fact 로만 FAQ 를 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
산하연 풀빌라 4건 — 그 근거로는 FAQ 가 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다.
★ 공통 **답**은 주장을 하지 않는다 — "…은 숙소로 문의 부탁드립니다" 뿐이다.
예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는
조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 값·가능 여부를 적으면
같은 사고다. 문의 안내만 쓴다.
resources/*.json 을 최초 사용 시 로드·검증한다. fact_keys 는 업종 스키마에 있는 key 여야 한다 —
오타가 난 key 는 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다.
"""
import json import json
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@ -33,7 +20,7 @@ class CatalogItem:
id: str id: str
question: str question: str
topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부") topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부")
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다 fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact.
keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교) keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교)
@ -48,10 +35,7 @@ class FaqCatalog:
items: tuple[CatalogItem, ...] items: tuple[CatalogItem, ...]
def applies_to(self, category: int, external_category: str | None) -> bool: def applies_to(self, category: int, external_category: str | None) -> bool:
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다. """업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다."""
★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다.
호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다."""
if category != self.category.value: if category != self.category.value:
return False return False
label = external_category or "" label = external_category or ""
@ -116,7 +100,7 @@ def _parse(doc: dict, source: str) -> FaqCatalog:
def load_catalogs() -> list[FaqCatalog]: def load_catalogs() -> list[FaqCatalog]:
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등).""" """리소스 디렉터리 전체 로드 + 검증."""
global _catalogs global _catalogs
if _catalogs is None: if _catalogs is None:
loaded = [] loaded = []
@ -131,5 +115,5 @@ def load_catalogs() -> list[FaqCatalog]:
def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None: def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None:
"""이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다).""" """이 사업장에 쓸 카탈로그."""
return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None) return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None)

View File

@ -1,18 +1,5 @@
"""재시도가 의미 없는 잡 실패. """재시도가 의미 없는 잡 실패."""
★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다.
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로
세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다.
★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다.
★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면
의존 방향이 뒤집힌다.
"""
class PermanentJobError(RuntimeError): class PermanentJobError(RuntimeError):
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다.""" """다시 시도해도 결과가 같은 실패."""

View File

@ -4,9 +4,7 @@ from datetime import datetime, timezone
class _Logger: class _Logger:
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전. """원본 DerbyServer LOG 인터페이스를 간소화한 버전."""
LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다.
"""
def __init__(self): def __init__(self):
self._prefix = "" self._prefix = ""

View File

@ -14,7 +14,7 @@ class StructModel:
class ErrorInfo(BaseModel, StructModel): class ErrorInfo(BaseModel, StructModel):
"""모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다.""" """모든 응답에 공통으로 실리는 결과 정보."""
success: Optional[bool] = True success: Optional[bool] = True
code: Optional[int] = ErrorType.SUCCESS.value code: Optional[int] = ErrorType.SUCCESS.value
@ -27,11 +27,7 @@ class ErrorInfo(BaseModel, StructModel):
self.desc = enum.name self.desc = enum.name
# ---- Protocol 규약 ------------------------------------------------------- # Protocol 규약
# 모든 통신 패킷은 WebPacketProtocol 을 상속한다.
# 요청 : Req_xxx (WebPacketProtocol)
# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다.
# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다.
class WebPacketProtocol(BaseModel, StructModel): class WebPacketProtocol(BaseModel, StructModel):
pass pass
@ -54,7 +50,7 @@ class Res_PageProtocol(Res_WebPacketProtocol):
class PageParams: class PageParams:
# 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다. # 목록 엔드포인트 공용 쿼리 파라미터.
def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)): def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
self.page = page self.page = page
self.size = size self.size = size
@ -67,16 +63,14 @@ class PageParams:
class UserInfo(StructModel): class UserInfo(StructModel):
"""JWT subject 로 인코딩되는 유저 식별 정보.""" """JWT subject 로 인코딩되는 유저 식별 정보."""
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다 user_id: str # users.user_id (uuid) — 데이터 스코프 키.
id: str # users.id (로그인 아이디) — get_me 재조회 키 id: str # users.id (로그인 아이디) — get_me 재조회 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키 role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조) token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
def __init__(self, *args, **kwargs) -> None: def __init__(self, *args, **kwargs) -> None:
super().__init__() super().__init__()
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 # 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
self.role = UserRole.USER.value self.role = UserRole.USER.value
self.token_version = 1 self.token_version = 1
for dictionary in args: for dictionary in args:

View File

@ -0,0 +1,41 @@
"""업종·템플릿 정의."""
import json
from pathlib import Path
from common.enums import PlaceCategory
CATALOG_PATH = Path(__file__).resolve().parents[2] / "shared" / "src" / "data" / "templates.json"
_CATALOG = json.loads(CATALOG_PATH.read_text(encoding="utf-8"))
TEMPLATES: dict = _CATALOG["templates"]
INDUSTRIES: dict = _CATALOG["industries"]
_INDUSTRY_BY_CATEGORY = {
PlaceCategory.LODGING.value: "stay",
PlaceCategory.CAFE.value: "cafe",
PlaceCategory.RESTAURANT.value: "restaurant",
PlaceCategory.CLINIC.value: "clinic",
}
class UnknownTemplate(ValueError):
pass
def industry_of(category: int) -> dict:
key = _INDUSTRY_BY_CATEGORY.get(category)
if key is None:
raise ValueError(f"업종 코드에 맞는 정의가 없다: {category}")
return INDUSTRIES[key]
def is_allowed(category: int, template_id: str) -> bool:
return template_id in industry_of(category)["templates"]
def resolve_template_id(category: int, stored: str | None) -> str:
template_id = (stored or "").strip() or industry_of(category)["defaultTemplate"]
if not is_allowed(category, template_id):
raise UnknownTemplate(f"업종 {category}에서 쓸 수 없는 템플릿: {template_id}")
return template_id

View File

@ -1,19 +1,11 @@
"""좌표 거리 — 공용 한 벌. """좌표 거리 — 공용 한 벌."""
★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터)
세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의
거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다.
국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정
(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다.
"""
import math import math
EARTH_RADIUS_M = 6_371_000.0 EARTH_RADIUS_M = 6_371_000.0
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float: def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
"""두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다.""" """두 좌표(위도, 경도) 사이의 거리(m)."""
p1, p2 = math.radians(lat1), math.radians(lat2) p1, p2 = math.radians(lat1), math.radians(lat2)
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1) dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2

View File

@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
class GTime: class GTime:
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)""" """서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸."""
@staticmethod @staticmethod
def UTC() -> datetime: def UTC() -> datetime:

View File

@ -1,12 +1,4 @@
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것. """IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것."""
★ 한계를 먼저 적는다. 프로세스가 여럿이면 한도가 그 수만큼 곱해지고, 재시작하면 리셋된다.
제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다.
★ 그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 를 부르는 경로**다.
방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다
(카카오 키워드 검색은 무료 한도를 넘기면 건당 2원 — services/external/kakao.py 주석).
"""
import time import time
from collections import defaultdict, deque from collections import defaultdict, deque
@ -23,7 +15,7 @@ def allow(key: str, limit: int, window_sec: float) -> bool:
if len(bucket) >= limit: if len(bucket) >= limit:
return False return False
bucket.append(now) bucket.append(now)
# 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다. # 안 쓰는 키가 쌓이는 걸 막는다.
if not bucket: if not bucket:
_hits.pop(key, None) _hits.pop(key, None)
return True return True

View File

@ -1,9 +1,4 @@
"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다. """TTL 캐시 — 프로세스 메모리에만 있다."""
★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데
(넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다.
실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다.
"""
import time import time
from typing import Any, Optional from typing import Any, Optional

View File

@ -1,8 +1,4 @@
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다. """설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다."""
★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를
주워 먹어 엉뚱한 포트로 뜬다.
"""
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
@ -12,16 +8,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
import os import os
# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다. # 레포 최상위 .env.
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
_DOTENV = os.path.join(_REPO_ROOT, ".env") _DOTENV = os.path.join(_REPO_ROOT, ".env")
APP_ENV = os.environ.get("APP_ENV", "local") APP_ENV = os.environ.get("APP_ENV", "local")
# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다. # APP_ENV=test 면 .env 를 읽지 않는다.
_ENV_FILE = None if APP_ENV == "test" else _DOTENV _ENV_FILE = None if APP_ENV == "test" else _DOTENV
# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다. # 테스트는 별도 DB.
_DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db" _DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db"
_BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False) _BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False)
@ -35,7 +31,7 @@ class WebServerConfig(BaseSettings):
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT") process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL") is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
is_test: bool = Field(False, validation_alias="WEB_IS_TEST") is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
# CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다. # CORS 허용 오리진(쉼표로 여럿).
client_url: str = Field( client_url: str = Field(
"http://localhost:3000,http://localhost:3001,http://localhost:3002," "http://localhost:3000,http://localhost:3001,http://localhost:3002,"
"http://localhost:3003,http://localhost:3004,http://localhost:3005", "http://localhost:3003,http://localhost:3004,http://localhost:3005",
@ -52,7 +48,7 @@ class LogConfig(BaseSettings):
class MainDBConfig(BaseSettings): class MainDBConfig(BaseSettings):
"""DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본).""" """DB read/write 분리."""
model_config = _BASE model_config = _BASE
@ -71,7 +67,6 @@ class MainDBConfig(BaseSettings):
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG") show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수. # 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
# PostgreSQL max_connections 를 넘기면 안 된다.
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE") pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW") max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB # ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
@ -100,12 +95,7 @@ class JwtToken(BaseSettings):
class GoogleOAuthConfig(BaseSettings): class GoogleOAuthConfig(BaseSettings):
"""구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다. """구글 로그인."""
★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는
숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로
들고 와도 우리 계정이 되지 않게 막는 유일한 검사다.
★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다."""
model_config = _BASE model_config = _BASE
@ -119,8 +109,6 @@ class ExternalApiConfig(BaseSettings):
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY") perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
# 동일 업소 검증 — 둘 다 있으면 카카오 우선. # 동일 업소 검증 — 둘 다 있으면 카카오 우선.
# 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O
# 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다
kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY") kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY")
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID") naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET") naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
@ -135,17 +123,15 @@ class ExternalApiConfig(BaseSettings):
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다. # 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD") vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY") tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다. # 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service).
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY") suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다. # 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL") suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만 # 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology).
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL") site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다. # .env 를 요청마다 다시 읽지 않는다.
@lru_cache @lru_cache
def get_web_server_config() -> WebServerConfig: def get_web_server_config() -> WebServerConfig:
return WebServerConfig() return WebServerConfig()

View File

@ -1,4 +1,4 @@
"""SNS도 루트 .env만 읽는다. APP_ENV=test에서는 기존 설정 규칙대로 .env를 읽지 않는다.""" """SNS도 루트 .env만 읽는다."""
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from config.config_models import _BASE from config.config_models import _BASE

View File

@ -1,6 +1,4 @@
# 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리). # 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리).
# 이 픽스처들은 TRUNCATE 를 하므로 dev DB(web4ai_db)와 절대 공유하면 안 된다(아래 db_engine 안전가드 참고).
# config.server_configs 가 import 되는 순간 config.<APP_ENV>.toml 을 읽으므로 가장 먼저 설정.
import os import os
os.environ.setdefault("APP_ENV", "test") os.environ.setdefault("APP_ENV", "test")
@ -18,14 +16,7 @@ from common.enums import UserRole, UserStatus
from config.server_configs import main_db_config from config.server_configs import main_db_config
# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는 # 비울 표는 **ORM 이 아는 것**에서 뽑는다.
# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를
# 얹지도 않는다.
#
# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데,
# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로
# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도
# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다.
def _truncate_sql() -> str: def _truncate_sql() -> str:
names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables) names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables)
return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE" return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE"
@ -37,14 +28,13 @@ def _write_url(cfg) -> str:
def _admin_url(cfg) -> str: def _admin_url(cfg) -> str:
"""DB 생성용 관리 접속. CREATE DATABASE 는 대상 DB 안에서 못 하므로 기본 'postgres' DB 로 붙는다.""" """DB 생성용 관리 접속."""
pw = f":{cfg.write_pw}" if cfg.write_pw else "" pw = f":{cfg.write_pw}" if cfg.write_pw else ""
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres" return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres"
async def _drop_test_db(*, recreate: bool): async def _drop_test_db(*, recreate: bool):
"""test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다. """test DB 를 지운다(있으면)."""
WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB."""
engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT") engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT")
try: try:
async with engine.connect() as conn: async with engine.connect() as conn:
@ -57,12 +47,7 @@ async def _drop_test_db(*, recreate: bool):
@pytest_asyncio.fixture(scope="session", autouse=True) @pytest_asyncio.fixture(scope="session", autouse=True)
async def _test_db_lifecycle(): async def _test_db_lifecycle():
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다. """테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다."""
매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다.
(테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.)
안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호).
"""
assert "test" in main_db_config.name, ( assert "test" in main_db_config.name, (
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요." f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
) )
@ -77,13 +62,8 @@ async def _test_db_lifecycle():
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def db_engine(_test_db_lifecycle): async def db_engine(_test_db_lifecycle):
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다. """테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다."""
# 안전가드: dev DB 오염 방지.
⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(web4ai_db)를 가리키면 실데이터가 날아간다.
그래서 test 전용 DB(이름에 'test')가 아니면 즉시 중단한다(APP_ENV=test).
앱(DB_SESSION_MNG)도 APP_ENV=test 면 같은 test DB 에 접속하므로 여기서 만든 스키마를 공유한다.
"""
# 안전가드: dev DB 오염 방지. web4ai_test_db 이외엔 절대 실행하지 않는다.
assert "test" in main_db_config.name, ( assert "test" in main_db_config.name, (
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. " f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다." "APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
@ -99,11 +79,7 @@ async def db_engine(_test_db_lifecycle):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def owner_id(db_engine) -> str: async def owner_id(db_engine) -> str:
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다. """사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다."""
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
"""
uid = uuid.uuid4() uid = uuid.uuid4()
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
# status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시. # status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
@ -130,13 +106,7 @@ async def client(db_engine):
@pytest_asyncio.fixture @pytest_asyncio.fixture
async def auth_headers(db_engine, client): async def auth_headers(db_engine, client):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리. """테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리."""
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
— 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
호출: `h = await auth_headers("user1")`.
"""
from router.v1.validator.dependencies import GetHashedPW from router.v1.validator.dependencies import GetHashedPW
async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"): async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
@ -162,18 +132,7 @@ async def auth_headers(db_engine, client):
# ── 렌더러 스텁 ────────────────────────────────────────────────────────────── # ── 렌더러 스텁 ──────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def fake_renderer(monkeypatch, tmp_path_factory): def fake_renderer(monkeypatch, tmp_path_factory):
"""정적 렌더러(solution/site) 대역. """정적 렌더러(solution/site) 대역."""
★ 왜 필요한가
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site).
파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
★ 구조화 데이터 ↔ 화면 값 대조 자체는 렌더러 쪽 테스트가 본다
(solution/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 —
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
"""
import json import json
from pathlib import Path from pathlib import Path
@ -242,11 +201,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
) )
place = payload.get("place") or {} place = payload.get("place") or {}
count = _count(payload) count = _count(payload)
# ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** # 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 나가 있으면 크롤러가 읽는다).
# (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에
# 나가 있으면 크롤러가 읽는다). 대역이 늘 ok=True 를 주면 백엔드가 그 실패를
# NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 테스트되지 않는다.
# mismatches 는 비워 둔다 — 사유가 JSONLD_MISMATCH 로 섞이면 화면 문구가 틀린다.
if count <= 0: if count <= 0:
return { return {
"schemaVersion": 1, "schemaVersion": 1,

View File

@ -1,4 +1,4 @@
"""alert_outbox 원장 접근. services/alert_service.py 가 부른다.""" """alert_outbox 원장 접근."""
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from common.database.model.models import alert_outbox from common.database.model.models import alert_outbox
@ -7,10 +7,7 @@ from common.utils.gtime import GTime
async def latest_unresolved(session, dedupe_key: str): async def latest_unresolved(session, dedupe_key: str):
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None. """이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림."""
★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를
쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다."""
result = await session.execute( result = await session.execute(
select(alert_outbox) select(alert_outbox)
.where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False), .where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False),
@ -29,10 +26,7 @@ async def insert(session, values: dict) -> alert_outbox:
async def due_pending(session, limit: int = 20): async def due_pending(session, limit: int = 20):
"""★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한 """`next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다."""
GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도
컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수
있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다."""
result = await session.execute( result = await session.execute(
select(alert_outbox) select(alert_outbox)
.where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False), .where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False),

View File

@ -10,10 +10,9 @@ from common.enums import ErrorType, FactStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
# ★ 사이트에 나가는 상태. PUBLISHABLE_FACT_STATUSES 와 같은 집합이어야 한다. # 사이트에 나가는 상태.
# 유니크 인덱스(uq_facts_published_*)의 조건과도 같아야 한다.
_PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value) _PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
# 후보 — 재수집이 올려놓은 확인 대기 항목. 여러 건 공존한다. # 후보 — 재수집이 올려놓은 확인 대기 항목.
_CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) _CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
# 화면에 보이는 것 전체(이력 제외). # 화면에 보이는 것 전체(이력 제외).
_ACTIVE = _PUBLISHED + _CANDIDATE _ACTIVE = _PUBLISHED + _CANDIDATE
@ -24,7 +23,7 @@ def _unit_cond(unit_id):
return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id
# fact CRUD. 항상 place_id 로 스코프한다. # fact CRUD.
class IFactCRUD(ABC): class IFactCRUD(ABC):
@abstractmethod @abstractmethod
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType: async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
@ -96,10 +95,7 @@ class FactCRUD(IFactCRUD):
self, cdb: AsyncSession, place_id, unit_id=None, status: Optional[int] = None, self, cdb: AsyncSession, place_id, unit_id=None, status: Optional[int] = None,
publishable_only: bool = False, active_only: bool = True, publishable_only: bool = False, active_only: bool = True,
) -> Tuple[ErrorType, list]: ) -> Tuple[ErrorType, list]:
"""fact 목록. """fact 목록."""
publishable_only=True → ★ VERIFIED·CORRECTED 만 (사이트 렌더·발행 게이트가 쓰는 경로)
active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보)
"""
try: try:
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712 conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
if unit_id is not None: if unit_id is not None:
@ -111,7 +107,7 @@ class FactCRUD(IFactCRUD):
elif active_only: elif active_only:
conditions.append(place_facts.status.in_(_ACTIVE)) conditions.append(place_facts.status.in_(_ACTIVE))
# 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다. # 노출값이 먼저, 그 아래 후보.
query = select(place_facts).where(and_(*conditions)).order_by( query = select(place_facts).where(and_(*conditions)).order_by(
place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc() place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc()
) )
@ -122,8 +118,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]: async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
"""★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None). """지금 사이트에 나가고 있는 값."""
유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다."""
try: try:
query = ( query = (
select(place_facts) select(place_facts)
@ -145,7 +140,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]: async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
"""같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다.""" """같은 출처가 이미 올려둔 후보."""
try: try:
query = ( query = (
select(place_facts) select(place_facts)
@ -169,9 +164,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]: async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]:
"""★ 재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다. """재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다."""
이게 없으면 값이 안 바뀌었는데도 재수집마다 검증이 초기화돼 사이트에서 사실이 사라진다."""
try: try:
values = {"collected_at": ts, "updated_at": ts} values = {"collected_at": ts, "updated_at": ts}
if source_url: if source_url:
@ -183,7 +176,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]: async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]:
"""기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다.""" """기존 후보를 새 수집값으로 갱신."""
try: try:
query = ( query = (
update(place_facts) update(place_facts)
@ -196,10 +189,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]: async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
"""검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다. """검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다."""
적용행수 0 = 그 사이 다른 사람이 이미 상태를 바꿨다는 뜻(동시 처리 가드).
허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다."""
try: try:
query = ( query = (
update(place_facts) update(place_facts)
@ -216,9 +206,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def expire_published(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]: async def expire_published(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
"""현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출). """현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출)."""
지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다."""
try: try:
conditions = [ conditions = [
place_facts.place_id == place_id, place_facts.place_id == place_id,
@ -236,9 +224,7 @@ class FactCRUD(IFactCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def reject_candidates(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]: async def reject_candidates(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
"""남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈). """남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈)."""
후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다."""
try: try:
conditions = [ conditions = [
place_facts.place_id == place_id, place_facts.place_id == place_id,

View File

@ -14,7 +14,7 @@ _PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
_ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE _ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE
# FAQ CRUD. fact 와 같은 검증 상태 흐름을 탄다 — 생성된 문장도 사람이 확인해야 나간다. # FAQ CRUD.
class IFaqCRUD(ABC): class IFaqCRUD(ABC):
@abstractmethod @abstractmethod
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]: async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]:
@ -72,17 +72,7 @@ class FaqCRUD(IFaqCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]: async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다. """재생성 전에 **LLM 이 쓴** FAQ 를 내린다."""
★ 사람이 정정한 FAQ 는 건드리지 않는다 — 재생성이 사람의 판단을 덮어쓰면
fact 쪽 규칙과 어긋난다.
★ 가르는 기준이 status 에서 generated_by 로 바뀌었다 (2026-09-10).
생성분이 UNVERIFIED 로 들어가던 시절에는 status 만으로 "사람이 손댔는가" 를 알 수
있었다. 이제 생성분도 VERIFIED 로 들어가므로(copy_service) status 로는 둘이 구분되지
않는다 — 그대로 두면 재생성이 옛 FAQ 를 못 내리고 같은 질문이 쌓인다.
책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service 가
generated_by 를 OWNER 로 바꾼다."""
try: try:
query = ( query = (
update(place_faqs) update(place_faqs)
@ -90,10 +80,8 @@ class FaqCRUD(IFaqCRUD):
place_faqs.place_id == place_id, place_faqs.place_id == place_id,
place_faqs.deleted == False, # noqa: E712 place_faqs.deleted == False, # noqa: E712
# 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다. # 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다.
# 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다.
place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)), place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)),
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — # 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
# 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)), place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
) )
.values(status=FactStatus.EXPIRED.value, updated_at=ts) .values(status=FactStatus.EXPIRED.value, updated_at=ts)

View File

@ -1,14 +1,4 @@
"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다. (LPS `crud/job_crud.py` 이식) """작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다."""
- 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING.
→ 워커 컨테이너가 몇 개든 같은 잡 이중 할당이 원천 불가. fetch 와 claim 을 분리하지 않는다.
- 모든 전이는 **조건부 CAS**(WHERE 에 status/worker_id 가드) + RETURNING.
- 복구는 timeout 추측이 아니라 **lease 만료 소유권**(reaper 가 회수).
- 재시도/백오프/dead-letter 를 큐에 내장.
전이가 조회/변경으로 나뉘지 않으므로(RETURNING) execute_lambda_write 로 실행한다.
큐 전이만 raw SQL 이다 — 다른 crud 는 전부 SQLAlchemy 표현식을 쓴다.
"""
import json import json
@ -23,7 +13,7 @@ JOB_NOTIFY_CHANNEL = "web4ai_job"
def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float: def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float:
"""지수 백오프(초). attempts 회 시도 후 다음 재시도까지 대기 = base * 2^(attempts-1), cap 상한.""" """지수 백오프(초)."""
return min(cap, base * (2 ** max(0, attempts - 1))) return min(cap, base * (2 ** max(0, attempts - 1)))
@ -34,7 +24,7 @@ class JobQueue:
"""쓰기 트랜잭션 — 값 반환이 필요한 큐 전이 전용 진입점.""" """쓰기 트랜잭션 — 값 반환이 필요한 큐 전이 전용 진입점."""
return await DB_SESSION_MNG.execute_lambda_write(self.DB, fn) return await DB_SESSION_MNG.execute_lambda_write(self.DB, fn)
# ---- 적재 ---- # 적재
async def enqueue( async def enqueue(
self, self,
job_type: int, job_type: int,
@ -43,7 +33,7 @@ class JobQueue:
dedupe_key: str | None = None, dedupe_key: str | None = None,
max_attempts: int = 3, max_attempts: int = 3,
) -> str | None: ) -> str | None:
"""잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환.""" """잡 적재."""
sql = text(""" sql = text("""
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts) INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma) VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
@ -64,10 +54,9 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
# ---- 원자적 claim ---- # 원자적 claim
async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None: async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None:
"""대기 잡 1건을 원자적으로 점유. 없으면 None. """대기 잡 1건을 원자적으로 점유."""
FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가."""
sql = text(""" sql = text("""
UPDATE jobs SET UPDATE jobs SET
status = 2, status = 2,
@ -98,7 +87,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
# ---- 완료/실패 (소유권 가드) ---- # 완료/실패 (소유권 가드)
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool: async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
sql = text(""" sql = text("""
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb), UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
@ -117,8 +106,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None: async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None:
"""실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter). """실패 처리."""
전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None."""
sql = text(""" sql = text("""
UPDATE jobs SET UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
@ -141,11 +129,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool: async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool:
"""재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다. """재시도 없이 바로 DEAD."""
★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError).
백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은
그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다."""
sql = text(""" sql = text("""
UPDATE jobs SET status = 4, last_error = :err, UPDATE jobs SET status = 4, last_error = :err,
lease_until = NULL, worker_id = NULL, updated_at = now() lease_until = NULL, worker_id = NULL, updated_at = now()
@ -159,7 +143,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
# ---- lease 갱신(heartbeat) / 회수(reaper) ---- # lease 갱신(heartbeat) / 회수(reaper)
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool: async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
sql = text(""" sql = text("""
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now() UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
@ -174,11 +158,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
async def reap(self) -> list[dict]: async def reap(self) -> list[dict]:
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD. """만료된 lease(워커 사망 등)의 RUNNING 잡을 회수."""
회수된 잡마다 {job_id, job_type, status, last_error} 를 돌려준다 — worker/runner.py 의
run_reaper 가 이 중 DEAD(4) 로 떨어진 것만 골라 알린다(alert_service). job_id 목록만
돌려주던 예전 모양보다 한 겹 더 있는 이유가 그것뿐이다."""
sql = text(""" sql = text("""
UPDATE jobs SET UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END, status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
@ -200,7 +180,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
# ---- 단건 조회 (상태 폴링) ---- # 단건 조회 (상태 폴링)
async def set_progress(self, job: dict, progress: dict) -> bool: async def set_progress(self, job: dict, progress: dict) -> bool:
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다. # 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
sql = text(""" sql = text("""
@ -221,7 +201,7 @@ class JobQueue:
return await self._tx(run) return await self._tx(run)
async def find_latest(self, dedupe_key: str) -> dict | None: async def find_latest(self, dedupe_key: str) -> dict | None:
"""복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다.""" """복구는 완료·실패 이력도 찾는다."""
async def run(s): async def run(s):
row = (await s.execute(text(""" row = (await s.execute(text("""
SELECT job_id, status FROM jobs WHERE dedupe_key = :dk SELECT job_id, status FROM jobs WHERE dedupe_key = :dk
@ -232,7 +212,7 @@ class JobQueue:
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
async def get(self, job_id: str) -> dict | None: async def get(self, job_id: str) -> dict | None:
"""잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값).""" """잡 단건 조회(읽기)."""
sql = text(""" sql = text("""
SELECT job_id, job_type, status, priority, attempts, max_attempts, SELECT job_id, job_type, status, priority, attempts, max_attempts,
payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at
@ -253,8 +233,7 @@ class JobQueue:
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
async def find_active(self, dedupe_key: str) -> dict | None: async def find_active(self, dedupe_key: str) -> dict | None:
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다. """dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다."""
enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함."""
sql = text(""" sql = text("""
SELECT job_id, job_type, status FROM jobs SELECT job_id, job_type, status FROM jobs
WHERE dedupe_key = :dk AND status IN (1, 2) WHERE dedupe_key = :dk AND status IN (1, 2)
@ -271,7 +250,7 @@ class JobQueue:
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
# ---- 관측(관리 API/알림용) ---- # 관측(관리 API/알림용)
async def counts(self) -> dict[str, int]: async def counts(self) -> dict[str, int]:
"""상태별 잡 개수.""" """상태별 잡 개수."""
async def run(s): async def run(s):
@ -282,11 +261,7 @@ class JobQueue:
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
async def ops(self) -> dict: async def ops(self) -> dict:
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + """운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + 최근 1시간 DEAD + stuck(좀비 신호)."""
최근 1시간 DEAD + stuck(좀비 신호).
stuck 은 두 축 — lease 만료(워커 사망인데 reaper 미회수) OR 실행 10분 초과(핸들러 행 —
heartbeat 가 lease 를 계속 갱신해 lease 축엔 안 잡히므로 run_started_at 으로 따로 본다)."""
sql = text(""" sql = text("""
SELECT SELECT
count(*) FILTER (WHERE status = 1) AS pending, count(*) FILTER (WHERE status = 1) AS pending,
@ -310,8 +285,7 @@ class JobQueue:
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run) return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
async def requeue(self, job_id: str) -> str | None: async def requeue(self, job_id: str) -> str | None:
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움. """DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움."""
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반."""
sql = text(""" sql = text("""
UPDATE jobs SET status = 1, attempts = 0, run_after = now(), UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
lease_until = NULL, worker_id = NULL, run_started_at = NULL, lease_until = NULL, worker_id = NULL, run_started_at = NULL,

View File

@ -9,8 +9,7 @@ from common.utils.gtime import GTime
class LocalContentCRUD: class LocalContentCRUD:
async def list(self, db, status: int | None = None, region_code: str | None = None): async def list(self, db, status: int | None = None, region_code: str | None = None):
"""축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은 """축제·관광지·맛집·날씨 전 종류."""
관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨)."""
conds = [area_contents.deleted == False] # noqa: E712 conds = [area_contents.deleted == False] # noqa: E712
if status is not None: if status is not None:
conds.append(area_contents.status == status) conds.append(area_contents.status == status)
@ -43,20 +42,11 @@ class LocalContentCRUD:
return await self.update(db, content_id, {"status": 3}) return await self.update(db, content_id, {"status": 3})
async def upsert_kind(self, db, values: dict): async def upsert_kind(self, db, values: dict):
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신. """지역 이야기 한 종류(가요·인물·…)의 삽입/갱신."""
★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다.
이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라
`body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기
위해서다. 다시 생성하면 그 한 행을 덮어쓴다.
★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도
걸려, 종류가 다른 두 행이 같은 키로 충돌한다."""
stmt = pg_insert(area_contents).values(**values) stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.kind], index_elements=[area_contents.region_code, area_contents.kind],
# ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가 # 조건은 인덱스와 **글자 그대로** 같아야 한다.
# 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching"
# 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다.
index_where=and_( index_where=and_(
area_contents.deleted == False, # noqa: E712 area_contents.deleted == False, # noqa: E712
area_contents.kind.isnot(None), area_contents.kind.isnot(None),
@ -76,17 +66,7 @@ class LocalContentCRUD:
return await DB_SESSION_MNG.add(db, stmt) return await DB_SESSION_MNG.add(db, stmt)
async def list_kinds(self, db, region_code: str): async def list_kinds(self, db, region_code: str):
"""지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다. """지역의 **이야기** 행(종류당 1행)."""
★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 —
마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND),
그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.**
실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라
지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목·
엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서
"생성기가 없는 것" 처럼 보였다.
★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다.
"""
return await DB_SESSION_MNG.execute( return await DB_SESSION_MNG.execute(
db, db,
select(area_contents).where( select(area_contents).where(
@ -112,8 +92,6 @@ class LocalContentCRUD:
stmt = pg_insert(area_contents).values(**values) stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.region_code, area_contents.content_type], index_elements=[area_contents.region_code, area_contents.content_type],
# ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에
# 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.**
index_where=and_( index_where=and_(
area_contents.deleted == False, # noqa: E712 area_contents.deleted == False, # noqa: E712
area_contents.external_id.is_(None), area_contents.external_id.is_(None),

View File

@ -11,7 +11,7 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
# 사진 CRUD. 항상 place_id 로 스코프한다. # 사진 CRUD.
class IMediaCRUD(ABC): class IMediaCRUD(ABC):
@abstractmethod @abstractmethod
async def list_media( async def list_media(
@ -35,18 +35,7 @@ class MediaCRUD(IMediaCRUD):
async def list_media( async def list_media(
self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False
) -> Tuple[ErrorType, list]: ) -> Tuple[ErrorType, list]:
"""사진 목록. unlabeled_only=True 면 아직 Vision 분석이 안 된 것만(재분석 비용 절약). """사진 목록."""
★ "분석 안 됨"의 기준은 **alt_text 가 비었는가**다. label 이 아니다.
수집 어댑터가 페이지에서 주운 캡션을 label 에 넣어 두기 때문에(base.CollectedMedia
주석: "Vision 이 확정하기 전의 후보 라벨"), label 로 판정하면 캡션이 있는 사진은
전부 '이미 분석됨'으로 건너뛴다 — 실제로 네이버에서 긁은 사진 10장이 통째로
그렇게 빠져 Vision 이 "분석할 사진이 없다"로 끝났고, alt 가 없어 발행도 못 했다.
alt 는 Vision 만 채우고 발행 조건이기도 하므로 기준으로 삼기에 정확하다.
unit_id 는 객실·메뉴 단위 사진만 추린다(빌더가 객실 카드에 붙일 사진을 고를 때).
★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지
않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다."""
try: try:
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712 conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
if status is not None: if status is not None:
@ -69,10 +58,7 @@ class MediaCRUD(IMediaCRUD):
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]: async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]:
"""Vision 분석 결과를 반영한다. """Vision 분석 결과를 반영한다."""
★ 신뢰도가 낮으면 status 를 PENDING_REVIEW 로 남긴다 — 자동 반영하지 않는다.
라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다."""
try: try:
query = ( query = (
update(place_photos) update(place_photos)

View File

@ -7,18 +7,10 @@ from common.utils.gtime import GTime
class PlaceContentCRUD: class PlaceContentCRUD:
"""업장 주변의 지역 콘텐츠. """업장 주변의 지역 콘텐츠."""
★ 실체와 관계가 갈려 있다 (2026-09-09).
예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI
응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다.
지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고
`place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다.
그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다.
"""
async def list_by_place(self, db, place_id, *, include_hidden: bool = True): async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다.""" """이 업장 주변의 콘텐츠."""
conds = [ conds = [
place_area_refs.place_id == place_id, place_area_refs.place_id == place_id,
place_area_refs.deleted == False, # noqa: E712 place_area_refs.deleted == False, # noqa: E712
@ -38,7 +30,7 @@ class PlaceContentCRUD:
area_contents.latitude, area_contents.latitude,
area_contents.longitude, area_contents.longitude,
area_contents.display_end_at, area_contents.display_end_at,
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다. # 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
place_area_refs.distance_m.label("distance_m"), place_area_refs.distance_m.label("distance_m"),
place_area_refs.hidden.label("hidden"), place_area_refs.hidden.label("hidden"),
) )
@ -51,11 +43,7 @@ class PlaceContentCRUD:
) )
async def upsert_content(self, db, values: dict): async def upsert_content(self, db, values: dict):
"""공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다. """공용 콘텐츠 한 건."""
★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고
("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다.
"""
stmt = pg_insert(area_contents).values(**values) stmt = pg_insert(area_contents).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[area_contents.source, area_contents.external_id], index_elements=[area_contents.source, area_contents.external_id],
@ -88,7 +76,7 @@ class PlaceContentCRUD:
) )
async def upsert_ref(self, db, place_id, local_content_id, distance_m): async def upsert_ref(self, db, place_id, local_content_id, distance_m):
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다.""" """업장 ↔ 콘텐츠 관계."""
stmt = pg_insert(place_area_refs).values( stmt = pg_insert(place_area_refs).values(
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False, place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
) )
@ -99,8 +87,7 @@ class PlaceContentCRUD:
return await DB_SESSION_MNG.add(db, stmt) return await DB_SESSION_MNG.add(db, stmt)
async def soft_delete_missing(self, db, place_id, keep_ids: set): async def soft_delete_missing(self, db, place_id, keep_ids: set):
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 — """이번 응답에 없는 **관계**를 끊는다."""
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
err, rows = await DB_SESSION_MNG.execute( err, rows = await DB_SESSION_MNG.execute(
db, db,
select(place_area_refs.local_content_id).where( select(place_area_refs.local_content_id).where(
@ -109,7 +96,6 @@ class PlaceContentCRUD:
), ),
) )
# 단일 컬럼 SELECT 라 행이 스칼라로 온다. # 단일 컬럼 SELECT 라 행이 스칼라로 온다.
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
gone = [r for r in (rows or []) if r not in keep_ids] gone = [r for r in (rows or []) if r not in keep_ids]
if not gone: if not gone:
return err, 0 return err, 0
@ -121,7 +107,7 @@ class PlaceContentCRUD:
) )
async def set_hidden(self, db, place_id, local_content_id, hidden: bool): async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다.""" """이 업장에서만 숨긴다."""
return await DB_SESSION_MNG.add_with_rowcount( return await DB_SESSION_MNG.add_with_rowcount(
db, db,
update(place_area_refs) update(place_area_refs)

View File

@ -11,7 +11,7 @@ from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다. # 사업장 CRUD.
class IPlaceCRUD(ABC): class IPlaceCRUD(ABC):
@abstractmethod @abstractmethod
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType: async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
@ -95,16 +95,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]: async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
"""같은 사장님이 **이미 갖고 있는** 같은 외부 업소. 중복 사업장 판정용이다. """같은 사장님이 **이미 갖고 있는** 같은 외부 업소."""
★ 소유자까지 함께 본다. 외부 id 만으로 찾으면 다른 사장님의 사업장이 걸리고,
그걸 이어 쓰면 남의 가게를 넘겨받는 셈이 된다.
★ **쌓인 것이 많은 순**으로 준다. 부르는 쪽은 맨 앞을 정본으로 삼는다.
한때 `created_at` 오름차순이었는데, 그러면 위저드가 처음 만들었다가 버린 **빈 행**이
정본이 되고 정작 fact·객실·사진이 쌓인 행을 접게 된다(실측 2026-09-10: 정본으로
fact 2건짜리 행이 뽑혔다). 나이가 아니라 **내용**이 기준이다.
같은 무게면 먼저 만든 쪽이다 — 그 시점부터 사장님이 알고 있던 주소이기 때문이다.
"""
try: try:
def _count(model): def _count(model):
return ( return (
@ -167,7 +158,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]: async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수).""" """회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다."""
try: try:
if not data: if not data:
return ErrorType.SUCCESS, 0 return ErrorType.SUCCESS, 0
@ -182,7 +173,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]: async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다.""" """사업장을 실제 삭제한다."""
try: try:
query = ( query = (
delete(places) delete(places)
@ -231,7 +222,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]: async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
"""채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만.""" """채널 URL 목록."""
try: try:
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712 conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
if confirmed_only: if confirmed_only:
@ -251,11 +242,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]: async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]:
"""URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다. """URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다."""
(place_id, url) 은 유니크라 대상이 한 건으로 정해진다. 이미 확정된 건 rowcount 0.
★ 쓰는 곳은 상호 일치로 찾은 네이버 플레이스 링크 하나뿐이다 — 근거 없이 확정하는
경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place)."""
try: try:
query = ( query = (
update(place_channels) update(place_channels)
@ -273,17 +260,7 @@ class PlaceCRUD(IPlaceCRUD):
return ErrorType.DB_RUN_FAILED, 0 return ErrorType.DB_RUN_FAILED, 0
async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]: async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
"""수집한 원문을 링크에 박제한다. """수집한 원문을 링크에 박제한다."""
★ 왜 fact 가 아니라 여기인가 (2026-08-31)
TourAPI 의 `overview` 같은 소개 원문은 **사실 목록이 아니라 글**이다.
이걸 `intro` fact 로 넣었더니 457자 원문이 그대로 VERIFIED 가 되어
사장님 사이트의 '숙소 소개' 자리를 차지했다 — LLM 이 쓴 소개문은 뒤에서 대기 중인데.
`intro` 는 allow_llm=True, 즉 **LLM 의 출력 칸**이라 수집물이 들어가면 안 된다.
그렇다고 버리면 소개문·FAQ 의 근거가 사라진다(부대시설·주변 거리 같은 정보가
여기에만 있다). 그래서 **발행되지 않는 자리**에 원문을 남기고,
생성 시점에만 근거로 넘긴다(services/copy_service.py)."""
try: try:
query = ( query = (
update(place_channels) update(place_channels)

View File

@ -8,8 +8,7 @@ from common.utils.gtime import GTime
class PlaceItineraryCRUD: class PlaceItineraryCRUD:
async def list_by_place(self, db, place_id): async def list_by_place(self, db, place_id):
"""업장의 일정 전부(기간별 한 행). 정렬은 기간 이름 순이 아니라 저장 순이 아니다 — """업장의 일정 전부(기간별 한 행)."""
화면 탭 순서는 읽는 쪽(`snapshot._local_contents`)이 DURATIONS 순으로 정한다."""
return await DB_SESSION_MNG.execute( return await DB_SESSION_MNG.execute(
db, db,
select(place_itineraries).where( select(place_itineraries).where(
@ -19,13 +18,7 @@ class PlaceItineraryCRUD:
) )
async def upsert(self, db, values: dict): async def upsert(self, db, values: dict):
"""업장 × 기간 한 행의 삽입/갱신. """업장 × 기간 한 행의 삽입/갱신."""
★ `uq_place_itineraries`(place_id, duration — deleted = false)에 태운다.
ON CONFLICT 술어는 인덱스 술어와 **글자 그대로** 같아야 한다. 아니면 포스트그레스가
"no unique or exclusion constraint matching" 으로 거절한다 — 표도 컬럼도 멀쩡해서
눈으로는 원인이 안 보인다(`local_content_crud.upsert_kind` 주석과 같은 함정).
"""
stmt = pg_insert(place_itineraries).values(**values) stmt = pg_insert(place_itineraries).values(**values)
stmt = stmt.on_conflict_do_update( stmt = stmt.on_conflict_do_update(
index_elements=[place_itineraries.place_id, place_itineraries.duration], index_elements=[place_itineraries.place_id, place_itineraries.duration],

View File

@ -1,4 +1,4 @@
"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md).""" """place_posts 접근."""
from sqlalchemy import func, select, update from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -9,8 +9,7 @@ from common.utils.gtime import GTime
class PostCRUD: class PostCRUD:
async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType: async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
"""생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 — """생성분 적재."""
회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다)."""
for row in rows: for row in rows:
try: try:
cdb.add(place_posts(**row)) cdb.add(place_posts(**row))
@ -20,12 +19,7 @@ class PostCRUD:
return ErrorType.SUCCESS return ErrorType.SUCCESS
async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None: async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 """개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 (post_id 포함)을 그대로 돌려준다."""
(post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히
건너뜀" 으로 끝내면 안 된다.
★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면
detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다."""
try: try:
obj = place_posts(**row) obj = place_posts(**row)
cdb.add(obj) cdb.add(obj)
@ -40,7 +34,7 @@ class PostCRUD:
return None return None
async def max_scheduled_date(self, cdb: AsyncSession, place_id): async def max_scheduled_date(self, cdb: AsyncSession, place_id):
"""이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다).""" """이 업장이 이미 배정한 가장 늦은 날짜."""
result = await cdb.execute( result = await cdb.execute(
select(func.max(place_posts.scheduled_date)) select(func.max(place_posts.scheduled_date))
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712 .where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
@ -48,8 +42,7 @@ class PostCRUD:
return result.scalar() return result.scalar()
async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int): async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도 """배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순."""
하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다)."""
result = await cdb.execute( result = await cdb.execute(
select(place_posts) select(place_posts)
.where( .where(
@ -63,8 +56,7 @@ class PostCRUD:
return ErrorType.SUCCESS, rows[:limit] return ErrorType.SUCCESS, rows[:limit]
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today): async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None. """이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다."""
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
result = await cdb.execute( result = await cdb.execute(
select(place_posts) select(place_posts)
.where( .where(
@ -99,10 +91,7 @@ class PostCRUD:
return result.scalars().first() return result.scalars().first()
async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30): async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다. """생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다."""
새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로
같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도
전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다."""
result = await cdb.execute( result = await cdb.execute(
select( select(
place_posts.created_at, place_posts.created_at,
@ -124,7 +113,7 @@ class PostCRUD:
return [row[0] for row in result.all()] return [row[0] for row in result.all()]
async def published(self, cdb: AsyncSession, place_id, limit: int = 200): async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
"""화면에 나갈 글. 최신순이고, 게재된 것만.""" """화면에 나갈 글."""
result = await cdb.execute( result = await cdb.execute(
select(place_posts) select(place_posts)
.where( .where(
@ -162,8 +151,7 @@ class PostCRUD:
) )
return ErrorType.SUCCESS return ErrorType.SUCCESS
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 # 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
# 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
_EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value) _EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType: async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
@ -176,7 +164,7 @@ class PostCRUD:
return ErrorType.SUCCESS return ErrorType.SUCCESS
async def approve(self, cdb: AsyncSession, post_id) -> ErrorType: async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
"""★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다.""" """토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
await cdb.execute( await cdb.execute(
update(place_posts) update(place_posts)
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE)) .where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
@ -207,8 +195,7 @@ class PostCRUD:
return ErrorType.SUCCESS return ErrorType.SUCCESS
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType: async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false """소프트 삭제."""
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
await cdb.execute( await cdb.execute(
update(place_posts) update(place_posts)
.where(place_posts.post_id == post_id) .where(place_posts.post_id == post_id)

View File

@ -5,19 +5,14 @@ from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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_photos, places, site_publish_logs, site_versions, sites from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites, users
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
def _primary_photo_subquery(): def _primary_photo_subquery():
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행). """place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행)."""
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
return ( return (
select(place_photos.url) select(place_photos.url)
.where( .where(
@ -33,7 +28,7 @@ def _primary_photo_subquery():
) )
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다. # 사이트/버전/발행로그 CRUD.
class ISiteCRUD(ABC): class ISiteCRUD(ABC):
@abstractmethod @abstractmethod
async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]: async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]:
@ -48,6 +43,11 @@ class ISiteCRUD(ABC):
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).""" """(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
pass pass
@abstractmethod
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
pass
@abstractmethod @abstractmethod
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
pass pass
@ -107,10 +107,7 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]: async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
"""주소(도메인 라벨)의 주인을 찾는다. 없으면 (SUCCESS, None). """주소(도메인 라벨)의 주인을 찾는다."""
uq_sites_domain(deleted=false AND domain IS NOT NULL)과 같은 조건으로 본다 —
인덱스가 막는 것과 조회가 막는 것이 다르면 "확인은 통과, 저장은 실패"가 난다."""
try: try:
query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712 query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712
err_type, rows = await DB_SESSION_MNG.execute(cdb, query) err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
@ -122,16 +119,7 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]: async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진. """사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진."""
(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.
★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌
첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때
(Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다.
여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다."""
try: try:
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712 where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
@ -157,8 +145,36 @@ class SiteCRUD(ISiteCRUD):
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0 return ErrorType.DB_RUN_FAILED, [], 0
async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
try:
where = places.deleted == False # noqa: E712
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(places).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(places, sites, site_versions.built_at, _primary_photo_subquery(), users.id, users.email, users.name)
.join(users, users.user_id == places.owner_user_id)
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
.where(where)
.order_by(places.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]: async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다.""" """후보 주소들 중 이미 쓰이는 것만 추린다."""
try: try:
if not domains: if not domains:
return ErrorType.SUCCESS, set() return ErrorType.SUCCESS, set()
@ -181,7 +197,7 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]: async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]:
"""다음 버전 번호. 1부터 시작한다.""" """다음 버전 번호."""
try: try:
query = select(func.max(site_versions.version)).where( query = select(func.max(site_versions.version)).where(
site_versions.site_id == site_id, site_versions.deleted == False # noqa: E712 site_versions.site_id == site_id, site_versions.deleted == False # noqa: E712
@ -224,10 +240,7 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]: async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]:
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다. """롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다."""
★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고,
그 번호가 곧 out/versions/<slug>/<version>/ 디렉토리 이름이다(prerender.ts)."""
try: try:
query = ( query = (
select(site_versions) select(site_versions)
@ -295,15 +308,7 @@ class SiteCRUD(ISiteCRUD):
return ErrorType.DB_RUN_FAILED, [] return ErrorType.DB_RUN_FAILED, []
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]: async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다. """발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로."""
(ErrorType, [(site, place, primary_photo_url)]).
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.
★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) —
sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다."""
try: try:
query = ( query = (
select(sites, places, _primary_photo_subquery()) select(sites, places, _primary_photo_subquery())

View File

@ -1,15 +1,4 @@
"""site_sections — **개인화 데이터**의 단일 자리. """site_sections — **개인화 데이터**의 단일 자리."""
★ 규칙(2026-09-09)
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로.
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집.
★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로
들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로
세우면서 CRUD 를 붙인다.
★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다.
"""
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
@ -29,12 +18,7 @@ class SiteSectionCRUD:
) )
async def upsert(self, db, values: dict): async def upsert(self, db, values: dict):
"""섹션 하나의 개인화 값을 넣거나 갱신한다. """섹션 하나의 개인화 값을 넣거나 갱신한다."""
★ `uq_site_contents_section (site_id, section_id) WHERE deleted = false` 에 태운다.
★ source_type 은 갱신하지 않는다 — 사장님이 손으로 고친 섹션(OWNER)을 수집이
API 값으로 되돌리면, 고쳐 둔 것이 다음 수집에 조용히 사라진다.
"""
stmt = pg_insert(site_sections).values(**values) stmt = pg_insert(site_sections).values(**values)
return await DB_SESSION_MNG.add( return await DB_SESSION_MNG.add(
db, db,

View File

@ -52,7 +52,7 @@ async def sweep():
text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now() text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now()
WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""") WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""")
) )
# POSTING은 외부가 받았을 수 있다. 시간을 근거로 APPROVED로 돌리지 않는다. # POSTING은 외부가 받았을 수 있다.
await s.execute( await s.execute(
text("""UPDATE place_social_posts SET status='UNKNOWN', text("""UPDATE place_social_posts SET status='UNKNOWN',
last_error='POST_RESULT_UNKNOWN', updated_at=now() last_error='POST_RESULT_UNKNOWN', updated_at=now()

View File

@ -7,7 +7,7 @@ from common.utils.gtime import GTime
class SongCRUD: class SongCRUD:
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다.""" """place_songs 접근."""
async def insert(self, db, row): async def insert(self, db, row):
return await DB_SESSION_MNG.insert(db, row) return await DB_SESSION_MNG.insert(db, row)
@ -21,11 +21,7 @@ class SongCRUD:
) )
async def latest_ready(self, db, place_id): async def latest_ready(self, db, place_id):
"""이 업장의 **가장 최근에 완성된** 곡 하나. """이 업장의 **가장 최근에 완성된** 곡 하나."""
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
return await DB_SESSION_MNG.execute( return await DB_SESSION_MNG.execute(
db, db,
select(place_songs) select(place_songs)

View File

@ -1,19 +1,28 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Tuple from typing import Tuple
from sqlalchemy import select, func, update from sqlalchemy import and_, select, func, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
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 users from common.database.model.models import places, users
from common.enums import ErrorType from common.enums import ErrorType
from common.logger import LOG from common.logger import LOG
from common.utils.gtime import GTime from common.utils.gtime import GTime
def _place_count_subquery():
"""계정 하나가 가진 사업장 수 — users 에 상관 서브쿼리로 얹는다(N+1 회피)."""
return (
select(func.count())
.select_from(places)
.where(places.owner_user_id == users.user_id, places.deleted == False) # noqa: E712
.correlate(users)
.scalar_subquery()
)
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다. # CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
class IUserCRUD(ABC): class IUserCRUD(ABC):
@abstractmethod @abstractmethod
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
@ -47,6 +56,13 @@ class IUserCRUD(ABC):
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType: async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
pass pass
@abstractmethod
async def list_users(
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
) -> Tuple[ErrorType, list, int]:
"""내부 운영(DEVELOPER) 전용 전체 계정 목록."""
pass
class UserCRUD(IUserCRUD): class UserCRUD(IUserCRUD):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]: async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
@ -63,8 +79,7 @@ class UserCRUD(IUserCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]: async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]:
"""소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다. """소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다."""
구글은 이메일 변경을 허용하고, 이메일로 찾으면 그때 같은 사람에게 계정이 하나 더 생긴다."""
try: try:
query = ( query = (
select(users) select(users)
@ -82,8 +97,7 @@ class UserCRUD(IUserCRUD):
return ErrorType.DB_RUN_FAILED, None return ErrorType.DB_RUN_FAILED, None
async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]: async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]:
"""이메일로 1건. "이미 다른 수단으로 가입돼 있다" 판정에만 쓴다. """이메일로 1건."""
이메일에는 유니크 제약이 없다(옛 데이터) — 여러 건이면 가장 먼저 만들어진 것을 본다."""
try: try:
query = ( query = (
select(users) select(users)
@ -151,3 +165,33 @@ class UserCRUD(IUserCRUD):
except Exception as ex: except Exception as ex:
LOG.e_no_callstack(ex) LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED return ErrorType.DB_RUN_FAILED
async def list_users(
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
) -> Tuple[ErrorType, list, int]:
"""role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
try:
where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712
if search:
like = f"%{search.strip()}%"
where = and_(where, (users.email.ilike(like) | users.name.ilike(like) | users.id.ilike(like)))
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
query = (
select(users, _place_count_subquery())
.where(where)
.order_by(users.created_at.desc())
.offset(skip)
.limit(limit)
)
list_err, rows = await DB_SESSION_MNG.execute(cdb, query)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0

View File

@ -25,6 +25,7 @@ import router.v1.site.booking_request
import router.v1.site.post import router.v1.site.post
import router.v1.site.review import router.v1.site.review
import router.v1.local.local import router.v1.local.local
import router.v1.ops.ops
import router.v1.social.social import router.v1.social.social
import router.v1.social.oauth import router.v1.social.oauth
import router.v1.agent.kakao import router.v1.agent.kakao
@ -47,11 +48,6 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="Web4Ai API", lifespan=lifespan) app = FastAPI(title="Web4Ai API", lifespan=lifespan)
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외). # CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
#
# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면
# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을
# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다.
# 운영은 실제 도메인 하나만 적으면 된다.
def _origins(*values: str) -> list[str]: def _origins(*values: str) -> list[str]:
seen: list[str] = [] seen: list[str] = []
for value in values: for value in values:
@ -97,14 +93,7 @@ async def healthz():
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}}) @app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
async def readyz(response: Response): async def readyz(response: Response):
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), """healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다)."""
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
try: try:
async def _ping(s): async def _ping(s):
await s.execute(text("SELECT 1")) await s.execute(text("SELECT 1"))
@ -118,19 +107,18 @@ async def readyz(response: Response):
return {"ok": False, "db": "down"} return {"ok": False, "db": "down"}
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include. # 각 도메인 라우터를 등록한다.
app.include_router(router.v1.auth.account.router) app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.place.place.router) app.include_router(router.v1.place.place.router)
app.include_router(router.v1.fact.fact.router) app.include_router(router.v1.fact.fact.router)
app.include_router(router.v1.faq.faq.router) app.include_router(router.v1.faq.faq.router)
app.include_router(router.v1.media.media.router) app.include_router(router.v1.media.media.router)
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 — # 인증 없는 공개 중계.
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
app.include_router(router.v1.media.relay.router) app.include_router(router.v1.media.relay.router)
app.include_router(router.v1.job.job.router) app.include_router(router.v1.job.job.router)
app.include_router(router.v1.site.site.router) 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)에는 붙이지 않는다. # 인증 없는 공개 목록.
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.booking_request.router)
app.include_router(router.v1.site.post.router) app.include_router(router.v1.site.post.router)
@ -138,6 +126,7 @@ app.include_router(router.v1.site.post.owner_router)
app.include_router(router.v1.site.review.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)
app.include_router(router.v1.ops.ops.router)
app.include_router(router.v1.social.social.router) app.include_router(router.v1.social.social.router)
app.include_router(router.v1.social.oauth.router) app.include_router(router.v1.social.oauth.router)

View File

@ -1,8 +1,4 @@
"""사장님 에이전트 대화 — 빌더 화면의 입구. """사장님 에이전트 대화 — 빌더 화면의 입구."""
★ 카카오톡 웹훅이 생겨도 이 파일은 안 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저
같은 `runtime.chat()` 을 부르는 두 번째 입구가 된다(docs/AGENT.md).
"""
from uuid import UUID from uuid import UUID
@ -27,10 +23,7 @@ _STATUS = {
class Confirm(BaseModel): class Confirm(BaseModel):
"""직전 답의 확인 버튼이 그대로 돌려보내는 값. """직전 답의 확인 버튼이 그대로 돌려보내는 값."""
★ 서버는 이 값을 믿지 않는다 — 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다."""
tool: str = Field(min_length=1, max_length=40) tool: str = Field(min_length=1, max_length=40)
args: dict = {} args: dict = {}
@ -43,7 +36,7 @@ class Req_Chat(BaseModel):
@router.get("/status") @router.get("/status")
async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)): async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
"""대화창을 열 수 있는지. 키가 없으면 화면은 자리를 두고 입력만 죽인다.""" """대화창을 열 수 있는지."""
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
return {"enabled": runtime.is_configured()} return {"enabled": runtime.is_configured()}

View File

@ -1,9 +1,4 @@
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다. """카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다."""
★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은
자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면
누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다.
"""
from uuid import UUID from uuid import UUID
@ -26,14 +21,14 @@ def private_response(response: Response):
@router.get("/link") @router.get("/link")
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)): async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
"""연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다.""" """연결 상태."""
private_response(response) private_response(response)
return await service.state(UUID(user.user_id)) return await service.state(UUID(user.user_id))
@router.post("/link/code") @router.post("/link/code")
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)): async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
"""일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다.""" """일회용 코드를 낸다."""
private_response(response) private_response(response)
try: try:
return await service.issue_code(UUID(user.user_id)) return await service.issue_code(UUID(user.user_id))

View File

@ -8,7 +8,7 @@ from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_
security = HTTPBearer() security = HTTPBearer()
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당. # 라우터(MVC 의 컨트롤러).
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})

View File

@ -15,11 +15,7 @@ class Req_Login(AuthProtocol):
class Req_Signup(AuthProtocol): class Req_Signup(AuthProtocol):
"""id/pw 가입. 가입 = 계정 1개다. """id/pw 가입."""
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
**중복 판정용** 이다."""
id: str = "" id: str = ""
password: str = "" password: str = ""
@ -28,10 +24,7 @@ class Req_Signup(AuthProtocol):
class Req_GoogleLogin(AuthProtocol): class Req_GoogleLogin(AuthProtocol):
"""구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다. """구글 로그인."""
필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 —
`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다."""
credential: str = "" credential: str = ""
@ -43,11 +36,11 @@ class Res_Login(Res_WebPacketProtocol):
class Req_UpdateMe(AuthProtocol): class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가). # 본인 정보 수정.
name: Optional[str] = None name: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
contact_number: Optional[str] = None contact_number: Optional[str] = None
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지 password: Optional[str] = None # 비밀번호 변경(옵션).
class Res_RefreshToken(Res_WebPacketProtocol): class Res_RefreshToken(Res_WebPacketProtocol):
@ -62,6 +55,5 @@ class Res_Me(Res_WebPacketProtocol):
email: Optional[str] = None email: Optional[str] = None
contact_number: Optional[str] = None contact_number: Optional[str] = None
role: UserRole = UserRole.USER role: UserRole = UserRole.USER
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다) # 이 계정이 무엇으로 로그인하는가.
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
provider: AuthProvider = AuthProvider.LOCAL provider: AuthProvider = AuthProvider.LOCAL

View File

@ -11,7 +11,7 @@ from .protocol import (
Res_CategorySchema, Res_ExtractFacts, Res_Fact, Res_FactList, Res_CategorySchema, Res_ExtractFacts, Res_Fact, Res_FactList,
) )
# fact 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. # fact 라우터.
router = APIRouter(prefix="/v1/place/{place_id}/fact", tags=["Fact"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place/{place_id}/fact", tags=["Fact"], responses={404: {"description": "Not found"}})

View File

@ -13,9 +13,7 @@ class FactProtocol(WebPacketProtocol):
class Req_UpsertFact(FactProtocol): class Req_UpsertFact(FactProtocol):
"""fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다. """fact 기록."""
★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다."""
key: str = "" key: str = ""
value: Optional[str] = None value: Optional[str] = None
@ -26,14 +24,7 @@ class Req_UpsertFact(FactProtocol):
class Req_ExtractFacts(FactProtocol): class Req_ExtractFacts(FactProtocol):
"""사장님이 붙여넣은 원문에서 fact 를 뽑는다. """사장님이 붙여넣은 원문에서 fact 를 뽑는다."""
★ 왜 이 입구가 필요한가 (2026-08-31)
TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이
전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다.
폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다.
★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다."""
text: str = "" text: str = ""
@ -48,20 +39,17 @@ class Res_ExtractedFact(WebPacketProtocol):
class Res_ExtractFacts(Res_WebPacketProtocol): class Res_ExtractFacts(Res_WebPacketProtocol):
"""뽑힌 것과 버려진 것을 **둘 다** 돌려준다. """뽑힌 것과 버려진 것을 **둘 다** 돌려준다."""
★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를
화면에서 바로 확인할 수 있어야 한다."""
stored: int = 0 stored: int = 0
rejected: int = 0 rejected: int = 0
facts: list[Res_ExtractedFact] = [] facts: list[Res_ExtractedFact] = []
# (버린 항목, 사유). 모델이 지어낸 값·스키마 밖 key 가 여기로 온다. # (버린 항목, 사유).
rejections: list[list[str]] = [] rejections: list[list[str]] = []
class Req_TransitionFact(FactProtocol): class Req_TransitionFact(FactProtocol):
"""검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다.""" """검증 상태 전이."""
status: FactStatus = FactStatus.VERIFIED status: FactStatus = FactStatus.VERIFIED
value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시) value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시)
@ -75,8 +63,7 @@ class FactData(WebPacketProtocol):
unit_id: Optional[uuid.UUID] = None unit_id: Optional[uuid.UUID] = None
key: str key: str
value: Optional[str] = None value: Optional[str] = None
# ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용, # 캔버스 미리보기용 축약문.
# FactService._attach_summaries 가 요청마다 계산해 붙인다).
summary: Optional[str] = None summary: Optional[str] = None
unit: Optional[str] = None unit: Optional[str] = None
source_type: SourceType source_type: SourceType
@ -96,20 +83,20 @@ class FieldSpecData(WebPacketProtocol):
type: str type: str
scope: str scope: str
required: bool required: bool
critical: bool # ★ 미검증 노출 금지 대상 critical: bool # 미검증 노출 금지 대상
allow_llm: bool allow_llm: bool
unit: Optional[str] = None unit: Optional[str] = None
class Res_FactList(Res_WebPacketProtocol): class Res_FactList(Res_WebPacketProtocol):
facts: list[FactData] = [] facts: list[FactData] = []
publishable: int = 0 # ★ 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED) publishable: int = 0 # 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED)
pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지) pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지)
class Res_Fact(Res_WebPacketProtocol): class Res_Fact(Res_WebPacketProtocol):
fact: Optional[FactData] = None fact: Optional[FactData] = None
# 기록/전이가 무엇을 했는지. PUBLISHED_REPLACED 면 사이트 재빌드 대상이다. # 기록/전이가 무엇을 했는지.
outcome: Optional[FactWriteOutcome] = None outcome: Optional[FactWriteOutcome] = None

View File

@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
from services.faq_service import FaqService from services.faq_service import FaqService
from .protocol import Req_CreateFaq, Req_TransitionFaq, Res_Faq, Res_FaqList from .protocol import Req_CreateFaq, Req_TransitionFaq, Res_Faq, Res_FaqList
# FAQ 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. # FAQ 라우터.
router = APIRouter(prefix="/v1/place/{place_id}/faq", tags=["Faq"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place/{place_id}/faq", tags=["Faq"], responses={404: {"description": "Not found"}})

View File

@ -13,10 +13,7 @@ class FaqProtocol(WebPacketProtocol):
class Req_CreateFaq(FaqProtocol): class Req_CreateFaq(FaqProtocol):
"""사장님이 직접 쓴 FAQ. """사장님이 직접 쓴 FAQ."""
★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려
승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다."""
question: str = "" question: str = ""
answer: str = "" answer: str = ""
@ -24,9 +21,7 @@ class Req_CreateFaq(FaqProtocol):
class Req_TransitionFaq(FaqProtocol): class Req_TransitionFaq(FaqProtocol):
"""검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다. """검증 상태 전이."""
CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시)."""
status: FactStatus = FactStatus.VERIFIED status: FactStatus = FactStatus.VERIFIED
question: Optional[str] = None question: Optional[str] = None
@ -40,8 +35,7 @@ class FaqData(WebPacketProtocol):
place_id: uuid.UUID place_id: uuid.UUID
question: str question: str
answer: str answer: str
# 근거 목록. 컬럼명은 ids 지만 copy 잡이 담는 값은 fact 의 **key** 다 — # 근거 목록.
# 사람이 승인 화면에서 "무슨 사실로 쓴 문장인지" 읽을 수 있어야 하기 때문이다.
source_fact_ids: Optional[list[str]] = None source_fact_ids: Optional[list[str]] = None
generated_by: SourceType generated_by: SourceType
status: FactStatus status: FactStatus
@ -52,11 +46,9 @@ class FaqData(WebPacketProtocol):
class Res_FaqList(Res_WebPacketProtocol): class Res_FaqList(Res_WebPacketProtocol):
faqs: list[FaqData] = [] faqs: list[FaqData] = []
# ★ 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED). FAQPage JSON-LD 는 이 건수만큼만 나간다. # 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED).
publishable: int = 0 publishable: int = 0
# 승인 대기 건수 — 관리 화면의 '검토할 것' 배지. # 승인 대기 건수 — 관리 화면의 '검토할 것' 배지.
# fact 와 달리 UNVERIFIED 도 포함한다: FAQ 에는 '재수집 후보' 개념이 없고,
# LLM 이 만들어 둔 UNVERIFIED 가 곧 사장님 승인 대기 큐다.
pending_review: int = 0 pending_review: int = 0

View File

@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
from services.job_service import JobService from services.job_service import JobService
from .protocol import Res_Job, Res_JobOps from .protocol import Res_Job, Res_JobOps
# 작업 큐 라우터. 수집·비전분석·빌드는 몇 분 걸리므로 클라이언트가 여기를 폴링한다. # 작업 큐 라우터.
router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}})

View File

@ -39,7 +39,7 @@ class JobData(WebPacketProtocol):
class Res_Job(Res_WebPacketProtocol): class Res_Job(Res_WebPacketProtocol):
"""잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다.""" """잡 상태 폴링 응답."""
job: Optional[JobData] = None job: Optional[JobData] = None

View File

@ -41,7 +41,7 @@ async def list_place_contents(place_id: uuid.UUID, service: LocalContentService
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)") @router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)): async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다.""" """빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다."""
return RemoveNoneResponse(await service.sync_place_by_id(place_id)) return RemoveNoneResponse(await service.sync_place_by_id(place_id))
@ -50,7 +50,7 @@ async def hide_place_content(
place_content_id: uuid.UUID, req: ReqHidePlaceContent, place_content_id: uuid.UUID, req: ReqHidePlaceContent,
service: LocalContentService = Depends(), _user=Depends(RequireOwner), service: LocalContentService = Depends(), _user=Depends(RequireOwner),
): ):
"""숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다.""" """숨긴 항목은 재수집이 되살리지 않는다."""
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden)) return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))

View File

@ -66,7 +66,7 @@ class ResLocalContentList(Res_WebPacketProtocol):
class ResSyncPlace(Res_WebPacketProtocol): class ResSyncPlace(Res_WebPacketProtocol):
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지.""" """업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤)."""
festivals: int = 0 festivals: int = 0
attractions: int = 0 attractions: int = 0
@ -76,15 +76,13 @@ class ResSyncPlace(Res_WebPacketProtocol):
class ResLocalGuide(Res_WebPacketProtocol): class ResLocalGuide(Res_WebPacketProtocol):
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다 """에디터 캔버스가 그리는 지역 가이드."""
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
attractions: list[dict[str, Any]] = [] attractions: list[dict[str, Any]] = []
restaurants: list[dict[str, Any]] = [] restaurants: list[dict[str, Any]] = []
festivals: list[dict[str, Any]] = [] festivals: list[dict[str, Any]] = []
courses: list[dict[str, Any]] = [] courses: list[dict[str, Any]] = []
# ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와 # 1박2일·2박3일 각 5개(services/itinerary_llm_service).
# **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다.
itineraries: list[dict[str, Any]] = [] itineraries: list[dict[str, Any]] = []
synced_at: str | None = None synced_at: str | None = None

View File

@ -8,7 +8,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
from services.media_service import MediaService from services.media_service import MediaService
from .protocol import Res_MediaList from .protocol import Res_MediaList
# 사진 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다. # 사진 라우터.
router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}}) router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}})

View File

@ -15,12 +15,7 @@ class MediaProtocol(WebPacketProtocol):
class MediaData(WebPacketProtocol): class MediaData(WebPacketProtocol):
"""사진 1건. """사진 1건."""
★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가
아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을
통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다.
origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -28,8 +23,8 @@ class MediaData(WebPacketProtocol):
place_id: uuid.UUID place_id: uuid.UUID
unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결 unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결
url: str # 우리가 보관하는 접근 URL url: str # 우리가 보관하는 접근 URL
origin_url: Optional[str] = None # ★ 수집 원본 이미지 URL — 권리 판단의 근거 origin_url: Optional[str] = None # 수집 원본 이미지 URL — 권리 판단의 근거
source_type: SourceType # ★ OWNER 업로드 / CRAWL 수집 — 발행 필터 키 source_type: SourceType # OWNER 업로드 / CRAWL 수집 — 발행 필터 키
source_url: Optional[str] = None # 수집한 페이지 URL source_url: Optional[str] = None # 수집한 페이지 URL
label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실") label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실")
alt_text: Optional[str] = None # Vision 생성 alt alt_text: Optional[str] = None # Vision 생성 alt
@ -40,13 +35,11 @@ class MediaData(WebPacketProtocol):
sort_order: int = 0 sort_order: int = 0
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
# DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'. # DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'.
# 판단 기준을 services/snapshot.py 와 똑같이 맞춘다(승인 + alt 있음). 화면이 "왜 이 사진은
# 안 나오나"를 사장님에게 설명할 수 있어야 하는데, 그 답이 상태 하나로는 안 나오기 때문이다.
publishable: bool = False publishable: bool = False
class Res_MediaList(Res_WebPacketProtocol): class Res_MediaList(Res_WebPacketProtocol):
media: list[MediaData] = [] media: list[MediaData] = []
publishable: int = 0 # ★ 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음) publishable: int = 0 # 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음)
pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지) pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지)
crawled: int = 0 # ★ 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수 crawled: int = 0 # 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수

View File

@ -1,21 +1,4 @@
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다. """사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다."""
★ 왜 필요한가 (2026-09-15, 실측)
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은
`Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼**
`toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는
사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다.
★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다:
· https 만
· 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN)
· 응답이 이미지가 아니면 거절, 크기 상한
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다
"""
import ipaddress import ipaddress
from urllib.parse import urlparse from urllib.parse import urlparse
@ -26,8 +9,7 @@ from common.logger import LOG
router = APIRouter(prefix="/v1/image", tags=["Image"]) router = APIRouter(prefix="/v1/image", tags=["Image"])
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다. # 우리 수집기가 사진을 가져오는 곳.
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
_ALLOWED_SUFFIXES = ( _ALLOWED_SUFFIXES = (
".pstatic.net", ".pstatic.net",
"tong.visitkorea.or.kr", "tong.visitkorea.or.kr",
@ -37,7 +19,7 @@ _ALLOWED_SUFFIXES = (
_MAX_BYTES = 8 * 1024 * 1024 _MAX_BYTES = 8 * 1024 * 1024
_TIMEOUT = httpx.Timeout(10.0, connect=5.0) _TIMEOUT = httpx.Timeout(10.0, connect=5.0)
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다. # 기본 UA 를 거절하는 CDN 이 있다.
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"} _HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
@ -57,8 +39,7 @@ def _allowed(url: str) -> bool:
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다") @router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
async def relay(url: str = Query(min_length=8, max_length=2000)): async def relay(url: str = Query(min_length=8, max_length=2000)):
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는 """인증을 요구하지 않는다."""
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다)."""
if not _allowed(url): if not _allowed(url):
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다") raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")

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