merge: feature/template-catalog(숙박 템플릿 8종 · 문서 정리) 을 main 에 병합
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e6a46154b1
@ -11,7 +11,6 @@
|
||||
| 이 제품이 뭘 푸나 · **안 하기로 한 것** | [docs/PRODUCT.md](docs/PRODUCT.md) |
|
||||
| 어떻게 도나 · 앱 경계 | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.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/DEVLOG.md](docs/DEVLOG.md) |
|
||||
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
||||
@ -20,6 +19,9 @@
|
||||
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
|
||||
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -55,13 +55,16 @@ postgres-init/ 스키마 DDL
|
||||
의존 방향은 admin → solution 한 쪽뿐이다. 반대가 생기면 번들을 가른 의미가 사라진다.
|
||||
근거는 [ARCHITECTURE.md 4절](docs/ARCHITECTURE.md).
|
||||
|
||||
> **`admin/` 은 지금 쓰지 않는다.** 개발자용 사이트·유저 관리는 solution 앱 안의 개발자 메뉴로
|
||||
> 가볍게 처리하고 있다(DEVLOG 2026-09-23). 우리가 따로 관리해야 할 만큼 사이트·운영 규모가 커지면
|
||||
> 그때 `admin/` 을 개발한다. 그 전에는 새 기능을 여기에 붙이지 않는다.
|
||||
|
||||
## 문서 지도
|
||||
|
||||
| 문서 | 언제 읽나 |
|
||||
|---|---|
|
||||
| [docs/PRODUCT.md](docs/PRODUCT.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/DEPLOY.md](docs/DEPLOY.md) | 서버에 올릴 때 · 배포 후 재발행 절차 |
|
||||
| [docs/DATA_SOURCE_RESEARCH.md](docs/DATA_SOURCE_RESEARCH.md) | 어디서 콘텐츠를 가져올 수 있나 (실측 근거) |
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다.
|
||||
|
||||
도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라
|
||||
(전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다.
|
||||
경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에
|
||||
내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
|
||||
"""
|
||||
"""어드민 API."""
|
||||
|
||||
import time
|
||||
|
||||
@ -32,7 +26,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다.
|
||||
# 크론은 :9800 담당.
|
||||
yield
|
||||
await DB_SESSION_MNG.dispose_all()
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
# 어드민 API 서버 (:9801). 근거는 app.py 주석.
|
||||
# PYTHONPATH=../../solution/backend python main.py
|
||||
# 어드민 API 서버 (:9801).
|
||||
|
||||
import os
|
||||
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
// 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다.
|
||||
// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려
|
||||
// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.)
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
@ -18,7 +16,7 @@ export default tseslint.config({
|
||||
// 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn.
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용.
|
||||
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단.
|
||||
'no-use-before-define': 'off',
|
||||
'@typescript-eslint/no-use-before-define': [
|
||||
'error',
|
||||
|
||||
@ -5,14 +5,7 @@ import {getAccessToken, me} from '@/api';
|
||||
import {queryClient} from '@/lib/query-client';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* 저장된 액세스 토큰으로 세션을 복구한다.
|
||||
*
|
||||
* ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.**
|
||||
* 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/frontend/app/provider.tsx),
|
||||
* 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘
|
||||
* 느슨한 쪽으로 나기 때문에 앱을 갈랐다.
|
||||
*/
|
||||
/** 저장된 액세스 토큰으로 세션을 복구한다. */
|
||||
function useRestoreSession() {
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
const finishRestore = useAuthStore((s) => s.finishRestore);
|
||||
@ -31,7 +24,7 @@ function useRestoreSession() {
|
||||
setUser(toAuthUser(res));
|
||||
})
|
||||
.catch(() => {
|
||||
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */
|
||||
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. */
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) finishRestore();
|
||||
|
||||
@ -10,20 +10,14 @@ import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
||||
import {PlaceListPage} from '@admin/pages/PlaceListPage';
|
||||
import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
||||
|
||||
/**
|
||||
* ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이
|
||||
* 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다.
|
||||
*/
|
||||
/** 내부 메뉴는 여기 있다. */
|
||||
const ADMIN_NAV: NavItem[] = [
|
||||
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
||||
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
||||
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
|
||||
];
|
||||
|
||||
/**
|
||||
* 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간
|
||||
* 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다.
|
||||
*/
|
||||
/** 내부 운영 화면. */
|
||||
export const router = createBrowserRouter([
|
||||
// selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
|
||||
{path: '/login', element: <LoginPage selfServe={false} />},
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
/**
|
||||
* 사장님 앱 오리진. 빌더는 다른 오리진이라(:3000 vs :3002) react-router Link 로 두면
|
||||
* admin 안에서 404 다. 절대 URL + 새 탭으로 연다.
|
||||
*/
|
||||
/** 사장님 앱 오리진. */
|
||||
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
|
||||
|
||||
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
|
||||
|
||||
@ -9,8 +9,7 @@ import {customFetch} from '@/api/mutator/custom-fetch';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
type Status = 1 | 2 | 3;
|
||||
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
|
||||
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
|
||||
// LocalContentType — common/enums.py 와 값을 맞춘다.
|
||||
type ContentType = 2 | 3 | 4;
|
||||
|
||||
type LocalContent = {
|
||||
@ -107,8 +106,7 @@ export function LocalContentPage() {
|
||||
} catch { toast.error('발행하지 못했습니다.'); }
|
||||
};
|
||||
const sync = async () => {
|
||||
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
|
||||
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
|
||||
// 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
|
||||
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
|
||||
if (!placeId) return;
|
||||
setSyncing(true);
|
||||
@ -118,7 +116,6 @@ export function LocalContentPage() {
|
||||
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
|
||||
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
|
||||
if (res.result?.success === false) throw new Error(res.msg);
|
||||
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
|
||||
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
|
||||
if (!res.changed) {
|
||||
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
|
||||
|
||||
@ -45,8 +45,7 @@ export function PlaceDetailPage() {
|
||||
const transition = useTransitionFact({
|
||||
mutation: {
|
||||
onSuccess: (res) => {
|
||||
// ★ 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면
|
||||
// 저장되지 않은 값이 '확인됨'으로 보인다.
|
||||
// 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 저장되지 않은 값이 '확인됨'으로 보인다.
|
||||
if (res.result?.success === false) {
|
||||
notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.');
|
||||
return;
|
||||
@ -177,8 +176,7 @@ export function PlaceDetailPage() {
|
||||
</section>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* ★ 재수집은 오른쪽 열 맨 위다. 이 화면에 온 사장님의 두 가지 용건이
|
||||
"확인 대기 값을 처리한다"(왼쪽)와 "값을 다시 가져온다"(여기)라서다. */}
|
||||
{/* 재수집은 오른쪽 열 맨 위다. */}
|
||||
<RecollectPanel placeId={placeId} />
|
||||
|
||||
<PasteFactsPanel placeId={placeId} />
|
||||
|
||||
@ -35,16 +35,7 @@ const STATUS_LABEL: Record<number, string> = {
|
||||
[PlaceStatus.SUSPENDED]: '중지',
|
||||
};
|
||||
|
||||
/**
|
||||
* 사업장 목록 — 이 제품의 허브다.
|
||||
*
|
||||
* 흐름은 하나뿐이다:
|
||||
* 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다.
|
||||
*
|
||||
* ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의
|
||||
* 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은
|
||||
* [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다.
|
||||
*/
|
||||
/** 사업장 목록 — 이 제품의 허브다. */
|
||||
export function PlaceListPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
@ -155,7 +146,7 @@ export function PlaceListPage() {
|
||||
>
|
||||
{STATUS_LABEL[place.status] ?? '알 수 없음'}
|
||||
</Badge>
|
||||
{/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */}
|
||||
{/* verified_at 이 NULL 이면 수집·발행 진입 금지. */}
|
||||
{place.verified_at ? (
|
||||
<span title="동일 업소 검증 완료" className="text-success">
|
||||
<ShieldCheck className="size-4" />
|
||||
|
||||
@ -7,12 +7,7 @@ import {Card, CardContent} from '@/components/ui/card';
|
||||
import {customFetch} from '@/api/mutator/custom-fetch';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
/**
|
||||
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
|
||||
*
|
||||
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
|
||||
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
|
||||
*/
|
||||
/** 이용 후기 — 손님 글은 이미 화면에 올라가 있다. */
|
||||
type Review = {
|
||||
review_id: string;
|
||||
place_id: string;
|
||||
|
||||
@ -3,13 +3,7 @@ import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
/**
|
||||
* 내부 운영 화면. 사장님 앱(solution/frontend)과 번들이 갈린다.
|
||||
*
|
||||
* `@` 를 이 앱이 아니라 사장님 앱 src 로 겨눈다 — 내부 화면이 쓰는 API·UI·수집 배선이
|
||||
* 거기 한 벌만 있고 그 파일들끼리도 `@/...` 로 서로를 부른다(자기 src 로 잡으면 TS2307 14건).
|
||||
* 이 앱 고유 파일은 `@admin`. 의존 방향은 admin → solution 한 쪽뿐이다.
|
||||
*/
|
||||
/** 내부 운영 화면. */
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
|
||||
@ -126,7 +126,7 @@ o2o-web4ai/
|
||||
│ ├─ backend/ FastAPI + 워커 · 이 레포의 유일한 백엔드
|
||||
│ ├─ frontend/ 빌더 (위저드 + 에디터 + 발행 게이트)
|
||||
│ ├─ site/ 발행 정적 사이트 (SSR 엔트리 + 프리렌더)
|
||||
│ └─ shared/ frontend·site·백엔드 계약 (SitePayload · slug · 토큰)
|
||||
│ └─ shared/ frontend·site·백엔드 계약 (템플릿 목록 · SitePayload · slug · 토큰)
|
||||
│
|
||||
├─ admin/ 우리 — 전체 사이트 운영
|
||||
│ ├─ backend/ 어드민 API 진입점(:9801). 도메인 코드는 solution/backend
|
||||
@ -142,6 +142,8 @@ negosium 대응: `negodata/{backend, front}` 가 프로젝트 안에서 f/b 를
|
||||
|
||||
### `frontend`(빌더)와 `site`(발행물)는 요구가 정반대다
|
||||
|
||||
세 폴더가 각각 무엇을 하는지, 템플릿이 그려지는 순서는 [TEMPLATES.md](TEMPLATES.md)에 있다.
|
||||
|
||||
같은 `solution/` 안에 있지만 빌드를 나눈 이유가 이 표다. 보일러플레이트는 순수 CSR 이라
|
||||
발행 사이트에 그대로 쓰면 크롤러가 `<div id="root"></div>` 만 읽고 떠난다.
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
||||
|
||||
[에디터]
|
||||
템플릿 고르기 ─────────────→ sites.template_id
|
||||
색·서체·섹션 순서/on-off ──→ sites.theme (JSONB)
|
||||
색·섹션 순서/on-off ───────→ sites.theme (JSONB)
|
||||
섹션 내용 편집 ────────────→ site_sections.data (JSONB, 섹션당 1행)
|
||||
주변정보 숨김·거리 ────────→ place_area_refs.hidden / distance_m
|
||||
미리보기 ──────────────────→ GET /v1/place/{id}/site/preview
|
||||
@ -225,8 +225,8 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
||||
|
||||
| 칸 | 무엇 | 왜 서버에 두나 |
|
||||
|---|---|---|
|
||||
| `template_id` | 사장님이 고른 템플릿 키 | 서버는 **해석하지 않고 보관·반환만** 한다. 템플릿 목록은 프론트가 소유하므로, 서버가 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다 |
|
||||
| `theme` (JSONB) | 색·서체·**섹션 순서/on-off/배리에이션** | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
|
||||
| `template_id` | 사장님이 고른 템플릿 id(`simple` `magazine` `retro` `paper`). NULL 이면 업종 기본 템플릿 | 템플릿 목록은 `solution/shared/src/data/templates.json` 한 파일이고, 서버도 그 파일을 읽어 업종이 못 쓰는 값은 저장·발행 때 거절한다([TEMPLATES.md](TEMPLATES.md)) |
|
||||
| `theme` (JSONB) | 색·**섹션 순서/on-off** (서체·모서리 같은 모양은 템플릿이 정하므로 저장값을 쓰지 않는다) | 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본으로 굽고, 고른 디자인과 발행본이 갈린다. 컬럼으로 펼치지 않는 이유는 항목이 늘 때마다 마이그레이션이 따라오기 때문 |
|
||||
| `status` | 1 DRAFT / 2 REVIEW / 3 PUBLISHED / 4 SUSPENDED / 5 UNPUBLISHED | ★ 해지는 **물리 삭제가 아니라 상태 전이**다 — 색인된 페이지를 갑자기 404 로 만들지 않는다 |
|
||||
| `current_version_id` | 지금 나가 있는 버전 | |
|
||||
| `thumbnail_url` | 쇼케이스 카드 그림 | ★ **발행에 성공한 뒤에만** 채운다. 스크린샷이 아니라 그 사이트의 대표 사진(og:image)이다 — 헤드리스 브라우저는 영구 금지 |
|
||||
|
||||
@ -226,7 +226,7 @@
|
||||
골라도 그 자리가 비었다. 그래서 서버가 채운다.
|
||||
|
||||
**2026-09-10 — 일력(`daily`)을 여섯 번째로 넣는다.** 이 종류만 프롬프트가 빌더
|
||||
(`canvas/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
|
||||
(당시 `canvas/dataSpec.ts`, 지금은 `builder/sections/dataSpec.ts`)에 손으로 적혀 있어 `shared/section-prompts.ts` 에 없었고, 서버는
|
||||
그 종류의 존재 자체를 몰랐다. 렌더러에는 '오늘의 한 장' 탭 자리가 있고 '옛 항구' 템플릿
|
||||
설명도 일력을 약속하는데 채우는 쪽만 없어서, 그 탭은 손으로 넣은 시안에만 있었다.
|
||||
→ 종류 목록의 단일 출처는 `STORY_KINDS` 하나다. 뽑는 스크립트도 그 상수를 읽는다
|
||||
@ -394,7 +394,7 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
|
||||
key 만 보면 사장님 입력(근거 없음)을 놓치고, 키워드만 보면 "주차 및 와이파이" 처럼 묶인 문항의 한쪽을 놓친다.
|
||||
- 답은 **문의 안내뿐**이다("…은 전화(…)로 문의해 주시면 안내해 드립니다"). 값·가능 여부를 적지 않는다.
|
||||
업종 시드 FAQ 가 "숯과 그릴 세트(25,000원)" 같은 가공의 값을 사이트에 내보낸 일을 반복하지 않기 위해서다
|
||||
(`frontend … canvas/variants/faq/useFaqList.ts` 주석).
|
||||
(`frontend … canvas/variants/faq/useFaqList.ts` 주석, 이 파일은 2026-09-28 배치 고르기와 함께 지웠다).
|
||||
- 출처는 `SourceType.TEMPLATE`(5). 재생성 때 LLM 생성분과 함께 내리고 다시 고른다. fact 에는 쓸 수 없다.
|
||||
- ★ **fact 가 0건이어도 20개다.** 근거가 없으면 LLM 은 부르지 않고(환각·유료 호출 방지) 채우기만 돈다.
|
||||
그 경로는 API 키도 필요 없다. 예전에는 `start_copy` 가 `FAQ_UNGROUNDED` 로 잡을 만들지 않아 FAQ 가 0개였다 —
|
||||
|
||||
@ -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)
|
||||
|
||||
1889
docs/DEVLOG.md
1889
docs/DEVLOG.md
File diff suppressed because it is too large
Load Diff
@ -104,8 +104,7 @@
|
||||
## 9. 아직 안 정한 것
|
||||
|
||||
정해지는 대로 이 절에서 위로 올린다. 코드로 미리 풀지 않는다.
|
||||
★ **개발 착수 전에 확정해야 할 결정 목록은
|
||||
[DEVELOPMENT_DIRECTION.md P0](DEVELOPMENT_DIRECTION.md)** 가 단일 출처다 — 여기 복사하지 않는다.
|
||||
★ **보류 중인 결정은 [DECISIONS.md](DECISIONS.md) 1절**이 단일 출처다 — 여기 복사하지 않는다.
|
||||
아래는 그중 **제품 정의**에 해당하는 것만 남긴다.
|
||||
|
||||
- 사업 성공 지표 (7절)
|
||||
|
||||
157
docs/RENDERING.md
Normal file
157
docs/RENDERING.md
Normal 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/<slug>"] --> B["nginx<br/>location ^~ /s/"]
|
||||
B --> C["out/s/<slug><br/>(심볼릭 링크)"]
|
||||
C --> D["out/versions/<slug>/<ver>/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/<slug>/img/*<br/>노래 /s/<slug>/*.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/<slug>.json"]
|
||||
F --> G["render_service.render_site<br/>node prerender.js --stage-only"]
|
||||
G --> H["mirrorMedia → prerenderSite<br/>out/versions/<slug>/<ver>/"]
|
||||
H --> I["보고서<br/>payloads/.status/<slug>.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/<slug> 링크 전환<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) — 백엔드와 렌더러가 디렉토리 하나로만 만나는 이유
|
||||
@ -27,7 +27,7 @@ DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정)
|
||||
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
|
||||
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
|
||||
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
|
||||
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
|
||||
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 레이아웃의 본문 마지막, footer 앞에 최신 3건을 굽는다.
|
||||
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
|
||||
|
||||
## 연동 준비 — 한 번만 하는 일
|
||||
|
||||
197
docs/TEMPLATES.md
Normal file
197
docs/TEMPLATES.md
Normal 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
106
docs/TEMPLATE_DESIGN.md
Normal 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`
|
||||
@ -1,11 +1,4 @@
|
||||
/**
|
||||
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
|
||||
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
|
||||
*
|
||||
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
|
||||
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
|
||||
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
|
||||
*/
|
||||
/** "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. */
|
||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||
|
||||
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.
|
||||
python3 scripts/build-deck.py
|
||||
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
|
||||
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다."""
|
||||
|
||||
from pptx import Presentation
|
||||
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.oxml.ns import qn
|
||||
|
||||
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
|
||||
# --------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
|
||||
INK = RGBColor(0x10, 0x18, 0x19)
|
||||
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
|
||||
MUTED = RGBColor(0x63, 0x75, 0x7A)
|
||||
@ -32,7 +30,7 @@ W, H = 13.333, 7.5
|
||||
MX = 0.75 # 좌우 여백
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 저수준 헬퍼
|
||||
# --------------------------------------------------------------- 저수준 헬퍼
|
||||
def _ea(run, name):
|
||||
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 슬라이드 골격
|
||||
# --------------------------------------------------------------- 슬라이드 골격
|
||||
prs = Presentation()
|
||||
prs.slide_width = Inches(W)
|
||||
prs.slide_height = Inches(H)
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
/**
|
||||
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
|
||||
* node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json
|
||||
*
|
||||
* 설계 원칙
|
||||
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고
|
||||
* 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용).
|
||||
* · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다.
|
||||
* 산간 지역에 '오션뷰 펜션'을 만들지 않는다.
|
||||
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다.
|
||||
*/
|
||||
/** 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. */
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
|
||||
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
|
||||
@ -69,7 +59,7 @@ const uniq = (a) => [...new Set(a)];
|
||||
for (const r of regions) {
|
||||
const R = r.name;
|
||||
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
|
||||
// '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다.
|
||||
// '산간'이라고 다 스키장이 있는 건 아니다.
|
||||
const seasons = uniq([
|
||||
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
|
||||
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
|
||||
@ -77,8 +67,6 @@ for (const r of regions) {
|
||||
]);
|
||||
|
||||
// T1 코어 — 주력 후보.
|
||||
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
|
||||
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
|
||||
const names = [R, ...(r.aliases ?? [])];
|
||||
for (const N of names) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
|
||||
// 광역 단위 롤업.
|
||||
const sidoKey = {};
|
||||
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
|
||||
const sidoList = uniq(regions.map((x) => x.sido));
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용).
|
||||
python3 scripts/export-db-xlsx.py
|
||||
데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 —
|
||||
384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다."""
|
||||
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용)."""
|
||||
import csv, io, subprocess, collections
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""전국 펜션 키워드 데이터셋 → 엑셀.
|
||||
python3 scripts/export-xlsx.py
|
||||
검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리."""
|
||||
"""전국 펜션 키워드 데이터셋 → 엑셀."""
|
||||
import json, collections
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||
|
||||
@ -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 { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
/**
|
||||
* data/gunsan-pension-keywords.json 을 pgvector 에 적재한다.
|
||||
* npx tsx scripts/ingest-dataset.ts
|
||||
*
|
||||
* 정책: 주기 수집 없음. 고정 데이터셋 1회 적재.
|
||||
* 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고,
|
||||
* 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조)
|
||||
*/
|
||||
/** data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createSql, toVector } from '../src/db/db';
|
||||
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
|
||||
@ -73,8 +66,6 @@ async function main() {
|
||||
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
||||
|
||||
// 4) 데이터셋에서 빠진 행 정리.
|
||||
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
|
||||
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
|
||||
const wanted = uniq.map(([norm]) => norm);
|
||||
const stale = await sql<Array<{ canonical: string }>>`
|
||||
DELETE FROM keyword
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 전국 지역별 펜션 키워드를 pgvector 에 적재한다.
|
||||
* npx tsx scripts/ingest-nationwide.ts
|
||||
*
|
||||
* 군산 상세 데이터셋(source='dataset')과 공존시킨다.
|
||||
* 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다.
|
||||
*/
|
||||
/** 전국 지역별 펜션 키워드를 pgvector 에 적재한다. */
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createSql, toVector } from '../src/db/db';
|
||||
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||
@ -30,7 +24,7 @@ async function main() {
|
||||
Array<{ sido: string; name: string; key: string }>;
|
||||
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
|
||||
|
||||
// 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다.
|
||||
// 1) 지역 계층 심기 (시도 → 시군).
|
||||
const nodes = new Map<string, string>();
|
||||
for (const r of regions) {
|
||||
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`);
|
||||
|
||||
// 4) 적재.
|
||||
// keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면
|
||||
// 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가
|
||||
// 자연히 구분되므로 문제되지 않는다.
|
||||
let inserted = 0, updated = 0;
|
||||
await sql.begin(async (tx) => {
|
||||
for (let i = 0; i < uniq.length; i++) {
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 고정 데이터셋 정책 위반분 정리.
|
||||
* npx tsx scripts/purge-nondataset.ts [--apply]
|
||||
*
|
||||
* 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가
|
||||
* 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다.
|
||||
*/
|
||||
/** 고정 데이터셋 정책 위반분 정리. */
|
||||
import { createSql } from '../src/db/db';
|
||||
|
||||
async function main() {
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* 로컬 엔드투엔드 점검 스크립트.
|
||||
* npm run db:reset && npm start (다른 터미널)
|
||||
* npm run smoke
|
||||
*/
|
||||
/** 로컬 엔드투엔드 점검 스크립트. */
|
||||
const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
|
||||
|
||||
const j = async (method: string, path: string, body?: unknown) => {
|
||||
|
||||
@ -64,9 +64,7 @@ const merchants = [
|
||||
},
|
||||
},
|
||||
{
|
||||
// 실제 업체. 공개 정보로 확인된 항목만 넣는다.
|
||||
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
|
||||
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
|
||||
// 실제 업체.
|
||||
externalId: 'site-3001',
|
||||
name: '스테이머뭄',
|
||||
industryId: 'stay.pension',
|
||||
|
||||
@ -5,10 +5,7 @@ import { EmbedKind, EmbeddingProvider } from './types';
|
||||
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
|
||||
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
|
||||
|
||||
/**
|
||||
* 로컬 multilingual-e5-small (384차원, onnxruntime CPU).
|
||||
* 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다.
|
||||
*/
|
||||
/** 로컬 multilingual-e5-small (384차원, onnxruntime CPU). */
|
||||
@Injectable()
|
||||
export class LocalEmbeddingProvider extends EmbeddingProvider {
|
||||
readonly name = 'local:multilingual-e5-small';
|
||||
|
||||
@ -3,7 +3,7 @@ import { EMBEDDING_DIM } from '../config/env';
|
||||
import { hashEmbedding } from '../llm/mock.provider';
|
||||
import { EmbedKind, EmbeddingProvider } from './types';
|
||||
|
||||
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */
|
||||
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. */
|
||||
@Injectable()
|
||||
export class MockEmbeddingProvider extends EmbeddingProvider {
|
||||
readonly name = 'mock:bigram-hash';
|
||||
|
||||
@ -28,10 +28,7 @@ export interface ResolveInput {
|
||||
regionId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 4단계 계단식 중복제거.
|
||||
* 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다.
|
||||
*/
|
||||
/** 4단계 계단식 중복제거. */
|
||||
@Injectable()
|
||||
export class DedupService {
|
||||
private readonly logger = new Logger(DedupService.name);
|
||||
@ -61,12 +58,6 @@ export class DedupService {
|
||||
}
|
||||
|
||||
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
|
||||
//
|
||||
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
|
||||
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
|
||||
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
|
||||
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
|
||||
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
|
||||
const candidates = await this.repo.findDedupCandidates(
|
||||
input.embedding,
|
||||
normalized,
|
||||
|
||||
@ -33,10 +33,7 @@ export class KeywordRepository {
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다.
|
||||
* 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다.
|
||||
*/
|
||||
/** 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. */
|
||||
async findDedupCandidates(
|
||||
embedding: number[],
|
||||
normalized: string,
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
/**
|
||||
* 중복 판정용 정규화.
|
||||
* NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거.
|
||||
* "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다.
|
||||
*/
|
||||
/** 중복 판정용 정규화. */
|
||||
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
|
||||
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;
|
||||
|
||||
|
||||
@ -10,13 +10,7 @@ import {
|
||||
QaCandidate,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현.
|
||||
*
|
||||
* embed(): 문자 bigram 해싱 + L2 정규화.
|
||||
* 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로
|
||||
* 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다.
|
||||
*/
|
||||
/** API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. */
|
||||
@Injectable()
|
||||
export class MockLlmProvider extends LlmProvider {
|
||||
readonly name = 'mock';
|
||||
|
||||
@ -2,7 +2,7 @@ import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */
|
||||
/** 로컬 확인용 매칭 데모 페이지. */
|
||||
@Controller()
|
||||
export class DemoController {
|
||||
@Get('demo')
|
||||
|
||||
@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 매칭 규칙 테이블.
|
||||
*
|
||||
* 두 종류가 있다.
|
||||
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
|
||||
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다
|
||||
* (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다)
|
||||
*/
|
||||
/** 매칭 규칙 테이블. */
|
||||
|
||||
export interface MerchantFacts {
|
||||
name: string;
|
||||
@ -21,7 +14,7 @@ export interface MerchantFacts {
|
||||
nearby: string[];
|
||||
amenities: Set<string>; // 정규화된 보유 시설
|
||||
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
||||
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
|
||||
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. */
|
||||
signals: string[];
|
||||
}
|
||||
|
||||
@ -124,20 +117,10 @@ export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerd
|
||||
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
|
||||
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
|
||||
|
||||
/**
|
||||
* 레인 설계 원칙
|
||||
* 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고,
|
||||
* 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다.
|
||||
* 지역+업종 앵커는 유형 레인에만 둔다.
|
||||
* 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아
|
||||
* 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다).
|
||||
* 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다.
|
||||
*/
|
||||
/** 레인 설계 원칙 1. 레인끼리 겹치지 않게 한다. */
|
||||
export function buildLanes(f: MerchantFacts): Lane[] {
|
||||
const lanes: Lane[] = [];
|
||||
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
|
||||
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
|
||||
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
|
||||
// 토큰 단위로 중복을 제거한다.
|
||||
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
|
||||
const seen = new Set<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)),
|
||||
].slice(0, 6);
|
||||
|
||||
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐
|
||||
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
|
||||
// 권역과 인근을 한 레인으로 합친다.
|
||||
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
|
||||
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
|
||||
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
||||
|
||||
@ -9,12 +9,11 @@ import {
|
||||
keywordAreaGroup, normalizeAmenities, violatesCapacity,
|
||||
} from './match.rules';
|
||||
|
||||
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
||||
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
|
||||
// RRF 상수를 관례값 60 대신 20 으로 낮춘다.
|
||||
const RRF_K = 20;
|
||||
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
|
||||
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
|
||||
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
|
||||
// 매칭 후보로 인정하는 출처.
|
||||
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
|
||||
|
||||
interface Hit {
|
||||
@ -50,8 +49,6 @@ export class MatchService {
|
||||
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
||||
|
||||
// 레인별 검색.
|
||||
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
|
||||
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
|
||||
const perLane = await Promise.all(
|
||||
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);
|
||||
|
||||
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
|
||||
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
|
||||
const keptById = new Map(kept.map((k) => [k.id, k]));
|
||||
const byLane = lanes.map((lane, li) => ({
|
||||
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[] {
|
||||
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
|
||||
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
|
||||
|
||||
@ -27,11 +27,7 @@ export class ServingController {
|
||||
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
||||
}
|
||||
|
||||
/**
|
||||
* 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드.
|
||||
* mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터
|
||||
* mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용)
|
||||
*/
|
||||
/** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. */
|
||||
@Post('match')
|
||||
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
|
||||
const limit = Math.min(body.limit ?? 40, 200);
|
||||
|
||||
@ -96,11 +96,7 @@ export class ServingService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다.
|
||||
* 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 —
|
||||
* 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다.
|
||||
*/
|
||||
/** 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. */
|
||||
async match(rawQuery: string, limit: number) {
|
||||
const query = rawQuery.trim();
|
||||
const merchant = await this.resolveMerchant(query);
|
||||
|
||||
@ -397,7 +397,7 @@ CREATE TABLE IF NOT EXISTS public.sites (
|
||||
place_id uuid NOT NULL, -- 사업장과 1:1
|
||||
domain VARCHAR(255) 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 는 위 컬럼이 소유한다(중복 보관 금지)
|
||||
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
|
||||
@ -408,8 +408,8 @@ CREATE TABLE IF NOT EXISTS public.sites (
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.sites.template_id IS '사장님이 고른 템플릿 키. 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.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)은 발행 때 템플릿 정의가 정한다.';
|
||||
|
||||
-- 섹션 하나의 콘텐츠. ★ **JSON import/export 의 단위**다.
|
||||
-- 실측(2026-09-09, /s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%).
|
||||
|
||||
@ -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)은 발행 때 템플릿 정의가 정한다.';
|
||||
@ -24,6 +24,7 @@ RUN playwright install --with-deps chromium
|
||||
|
||||
COPY solution/backend ./solution/backend
|
||||
COPY admin/backend ./admin/backend
|
||||
COPY solution/shared/src/data ./solution/shared/src/data
|
||||
|
||||
|
||||
ENV APP_ENV=local
|
||||
|
||||
@ -56,6 +56,7 @@ RUN playwright install --with-deps chromium
|
||||
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
|
||||
|
||||
COPY solution/backend ./solution/backend
|
||||
COPY solution/shared/src/data ./solution/shared/src/data
|
||||
# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로
|
||||
# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야
|
||||
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.
|
||||
|
||||
@ -2,8 +2,5 @@ from common.enums import UserRole
|
||||
|
||||
|
||||
def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
|
||||
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True.
|
||||
|
||||
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
|
||||
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리)."""
|
||||
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True."""
|
||||
return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value
|
||||
|
||||
@ -1,10 +1,4 @@
|
||||
"""업종별 fact 스키마 패키지.
|
||||
|
||||
업종마다 필드가 완전히 다르므로(숙박=체크인시간, 카페=브레이크타임) facts 는 key-value 로 두고,
|
||||
'어떤 key 가 존재하는가'는 업종별 JSON 스키마가 정의한다.
|
||||
|
||||
**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄.** 로직 수정 없음.
|
||||
"""
|
||||
"""업종별 fact 스키마 패키지."""
|
||||
from common.category_schema.loader import (
|
||||
CategorySchema,
|
||||
CategorySchemaError,
|
||||
|
||||
@ -1,23 +1,4 @@
|
||||
"""업종별 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
|
||||
"""
|
||||
"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@ -36,7 +17,7 @@ class CategorySchemaError(RuntimeError):
|
||||
|
||||
|
||||
class FieldSpec:
|
||||
"""업종 스키마의 필드 1개. JSON 한 행에 대응한다."""
|
||||
"""업종 스키마의 필드 1개."""
|
||||
|
||||
__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)]
|
||||
|
||||
def critical_keys(self) -> list[str]:
|
||||
"""★ 미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등)."""
|
||||
"""미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등)."""
|
||||
return [k for k, f in self.fields.items() if f.critical]
|
||||
|
||||
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]
|
||||
|
||||
|
||||
def load_schemas() -> None:
|
||||
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회 호출(멱등).
|
||||
파일을 하나 추가하면 그대로 새 업종이 된다 — 로더 코드는 건드리지 않는다."""
|
||||
"""리소스 디렉터리 전체 로드 + 검증."""
|
||||
global _schemas
|
||||
if _schemas is not None:
|
||||
return
|
||||
@ -146,7 +126,7 @@ def load_schemas() -> None:
|
||||
|
||||
|
||||
def get_schema(category) -> CategorySchema:
|
||||
"""업종 코드(int 또는 PlaceCategory) → 스키마. 없는 업종이면 CategorySchemaError."""
|
||||
"""업종 코드(int 또는 PlaceCategory) → 스키마."""
|
||||
if _schemas is None:
|
||||
load_schemas()
|
||||
code = category.value if isinstance(category, PlaceCategory) else category
|
||||
@ -157,14 +137,14 @@ def get_schema(category) -> CategorySchema:
|
||||
|
||||
|
||||
def all_schemas() -> dict:
|
||||
"""전 업종 스키마. {code: CategorySchema}"""
|
||||
"""전 업종 스키마."""
|
||||
if _schemas is None:
|
||||
load_schemas()
|
||||
return dict(_schemas)
|
||||
|
||||
|
||||
def is_valid_key(category, key: str) -> bool:
|
||||
"""해당 업종에 존재하는 fact key 인지. facts 쓰기 전 검증에 쓴다(FACT_INVALID_KEY)."""
|
||||
"""해당 업종에 존재하는 fact key 인지."""
|
||||
try:
|
||||
return get_schema(category).has(key)
|
||||
except CategorySchemaError:
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.
|
||||
|
||||
★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다.
|
||||
자세한 배경은 DEVLOG.md 참고.
|
||||
"""
|
||||
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용."""
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import asdict, dataclass
|
||||
@ -11,8 +7,7 @@ from common.logger import LOG
|
||||
|
||||
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
|
||||
|
||||
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가
|
||||
# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다.
|
||||
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 있어 상한을 둔다.
|
||||
_MAX_MESSAGE = 500
|
||||
_MAX_TARGET = 200
|
||||
|
||||
@ -27,7 +22,7 @@ class CollectIssue:
|
||||
|
||||
@contextmanager
|
||||
def collecting():
|
||||
"""run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다."""
|
||||
"""run_collect() 진입부에서 한 번 연다."""
|
||||
token = _current.set([])
|
||||
try:
|
||||
yield
|
||||
@ -36,10 +31,7 @@ def collecting():
|
||||
|
||||
|
||||
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
||||
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.
|
||||
|
||||
collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다
|
||||
(단발 호출·테스트 호환)."""
|
||||
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다."""
|
||||
issue = CollectIssue(
|
||||
stage=stage,
|
||||
target=target[:_MAX_TARGET],
|
||||
@ -54,6 +46,6 @@ def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
||||
|
||||
|
||||
def snapshot() -> list[dict]:
|
||||
"""지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다."""
|
||||
"""지금까지 쌓인 실패 목록."""
|
||||
issues = _current.get()
|
||||
return [asdict(i) for i in issues] if issues else []
|
||||
|
||||
@ -1,20 +1,4 @@
|
||||
"""사이트 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원).
|
||||
"""
|
||||
"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@ -22,9 +6,9 @@ from enum import Enum
|
||||
from common.logger import LOG
|
||||
|
||||
# ── 예산 ────────────────────────────────────────────────────────
|
||||
USD_KRW = 1400.0 # 환산 환율. 실제 청구 환율과 다를 수 있다.
|
||||
SITE_BUDGET_KRW = 1400.0 # ★ 사이트 1건당 상한 = $1
|
||||
# 예산의 몇 %까지 차면 경고를 남길지. 넘겨도 막지는 않는다 — 막는 건 100% 지점이다.
|
||||
USD_KRW = 1400.0 # 환산 환율.
|
||||
SITE_BUDGET_KRW = 1400.0 # 사이트 1건당 상한 = $1
|
||||
# 예산의 몇 %까지 차면 경고를 남길지.
|
||||
WARN_RATIO = 0.7
|
||||
|
||||
|
||||
@ -39,12 +23,7 @@ class Provider(Enum):
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Rate:
|
||||
"""공급자별 단가(원 기준).
|
||||
|
||||
confirmed=False 는 **아직 공식 단가표로 확인하지 않은 추정치**라는 뜻이다.
|
||||
추정치로 예산을 계산하면 "예산 안" 이라는 결론 자체가 추정이 된다 —
|
||||
실제 배치를 돌리기 전에 `assert_rates_confirmed()` 로 막는다.
|
||||
"""
|
||||
"""공급자별 단가(원 기준)."""
|
||||
|
||||
per_call_krw: float = 0.0 # 호출 1건당 고정비
|
||||
per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금)
|
||||
@ -55,26 +34,22 @@ class Rate:
|
||||
|
||||
|
||||
# ── 단가표 ──────────────────────────────────────────────────────
|
||||
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
|
||||
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
|
||||
RATES: dict[Provider, Rate] = {
|
||||
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
|
||||
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
||||
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
|
||||
Provider.KAKAO: Rate(
|
||||
per_call_krw=2.0,
|
||||
confirmed=True,
|
||||
source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)",
|
||||
),
|
||||
# Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M.
|
||||
# 2026-08-28 환산(USD_KRW=1,400)이다. 내부 검색 횟수에는 별도 요금이 없다.
|
||||
Provider.PERPLEXITY: Rate(
|
||||
per_call_krw=7.0,
|
||||
per_1k_token_krw=1.4,
|
||||
confirmed=True,
|
||||
source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context",
|
||||
),
|
||||
# ★ 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
|
||||
# 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
|
||||
Provider.GEMINI: Rate(
|
||||
per_image_krw=1.0,
|
||||
per_1k_token_krw=0.5,
|
||||
@ -91,7 +66,6 @@ KAKAO_COORD_KRW = 0.5
|
||||
|
||||
|
||||
class BudgetExceeded(Exception):
|
||||
"""사이트 1건 예산을 넘겼다. 호출측은 **더 호출하지 말고** 작업을 중단한다."""
|
||||
|
||||
def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float):
|
||||
self.place_id = place_id
|
||||
@ -108,10 +82,7 @@ class UnconfirmedRate(Exception):
|
||||
|
||||
|
||||
def assert_rates_confirmed(*providers: Provider) -> None:
|
||||
"""실배치 직전에 호출한다. 추정 단가가 섞여 있으면 막는다.
|
||||
|
||||
★ 이걸 건너뛰면 "예산 안에 들어온다" 는 결론이 추정 위에 서게 된다.
|
||||
"""
|
||||
"""실배치 직전에 호출한다."""
|
||||
bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed]
|
||||
if bad:
|
||||
names = ", ".join(p.value for p in bad)
|
||||
@ -129,7 +100,7 @@ def estimate_krw(
|
||||
images: int = 0,
|
||||
kakao_coord_calls: int = 0,
|
||||
) -> float:
|
||||
"""이번 호출의 원가(원)를 계산한다. 실측이든 예상이든 같은 식을 쓴다."""
|
||||
"""이번 호출의 원가(원)를 계산한다."""
|
||||
rate = RATES[provider]
|
||||
krw = (
|
||||
rate.per_call_krw * calls
|
||||
@ -144,7 +115,7 @@ def estimate_krw(
|
||||
|
||||
@dataclass
|
||||
class CostMeter:
|
||||
"""사이트 1건(업소 1곳)의 원가 누적기. **미터 1개 = 사이트 1건**이다."""
|
||||
"""사이트 1건(업소 1곳)의 원가 누적기."""
|
||||
|
||||
place_id: int | None = None
|
||||
budget_krw: float = SITE_BUDGET_KRW
|
||||
@ -162,20 +133,14 @@ class CostMeter:
|
||||
return self.spent_krw / USD_KRW
|
||||
|
||||
def guard(self, provider: Provider, **units) -> float:
|
||||
"""호출 **전** 에 예산을 확인한다. 넘으면 BudgetExceeded — 호출하지 마라.
|
||||
|
||||
돌려주는 값은 이번 호출의 예상 원가(원)다.
|
||||
"""
|
||||
"""호출 **전** 에 예산을 확인한다."""
|
||||
krw = estimate_krw(provider, **units)
|
||||
if self.spent_krw + krw > self.budget_krw:
|
||||
raise BudgetExceeded(self.place_id, self.spent_krw, krw)
|
||||
return krw
|
||||
|
||||
def charge(self, provider: Provider, **units) -> float:
|
||||
"""호출 **후** 에 실측 사용량을 적는다. 적고 나서 예산을 넘었으면 예외를 던진다.
|
||||
|
||||
★ 이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다.
|
||||
"""
|
||||
"""이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다."""
|
||||
krw = estimate_krw(provider, **units)
|
||||
self.spent_krw += krw
|
||||
self.calls += 1
|
||||
|
||||
@ -13,16 +13,7 @@ from config.server_configs import main_db_config
|
||||
|
||||
|
||||
class DBSessionManager(Singleton):
|
||||
"""DB 세션/엔진 관리자 (싱글톤).
|
||||
|
||||
핵심 패턴
|
||||
- DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다.
|
||||
=> 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다.
|
||||
- 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다.
|
||||
execute_lambda : 단일 쿼리 (주로 조회)
|
||||
execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit
|
||||
세션 open/close 와 commit/rollback 은 매니저가 책임진다.
|
||||
"""
|
||||
"""DB 세션/엔진 관리자 (싱글톤)."""
|
||||
|
||||
def __init__(self):
|
||||
if DBSessionManager.is_init():
|
||||
@ -33,7 +24,7 @@ class DBSessionManager(Singleton):
|
||||
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
|
||||
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
|
||||
self.__engines = []
|
||||
# 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다.
|
||||
# 논리 DB -> config.
|
||||
self.__db_type_map = {
|
||||
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}"
|
||||
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 = {}
|
||||
sslmode = (getattr(db_config, "sslmode", "") or "").lower()
|
||||
if sslmode and sslmode != "disable":
|
||||
@ -88,13 +79,11 @@ class DBSessionManager(Singleton):
|
||||
return db_type in self.__db_type_map
|
||||
|
||||
async def dispose_all(self):
|
||||
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
|
||||
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.
|
||||
"""
|
||||
"""모든 엔진의 커넥션 풀을 정리한다."""
|
||||
for engine in self.__engines:
|
||||
await engine.dispose()
|
||||
|
||||
# ---- 세션 lifecycle -------------------------------------------------
|
||||
# 세션 lifecycle
|
||||
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
|
||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||
return self.__write_session[db_type]()
|
||||
@ -106,16 +95,14 @@ class DBSessionManager(Singleton):
|
||||
else:
|
||||
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:
|
||||
try:
|
||||
await db.commit()
|
||||
return ErrorType.SUCCESS
|
||||
except IntegrityError as ex:
|
||||
await db.rollback()
|
||||
# ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다
|
||||
# (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못
|
||||
# 보던 무결성 오류는 아래 일반 Exception 갈래로 간다.
|
||||
# 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 (services/collect_service.py `_add_link`).
|
||||
LOG.w(f"duplicated. {ex}")
|
||||
return ErrorType.DB_ALREADY_SAME_KEY
|
||||
except Exception as ex:
|
||||
@ -164,8 +151,7 @@ class DBSessionManager(Singleton):
|
||||
return err_type
|
||||
|
||||
async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]:
|
||||
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환.
|
||||
조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용."""
|
||||
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환."""
|
||||
try:
|
||||
if hasattr(query, "column_descriptions"):
|
||||
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
|
||||
@ -196,9 +182,9 @@ class DBSessionManager(Singleton):
|
||||
raise RuntimeError(err_type.name, err_msg)
|
||||
return err_type, []
|
||||
|
||||
# ---- 람다 실행 진입점 (service 에서 호출) ---------------------------
|
||||
# 람다 실행 진입점 (service 에서 호출)
|
||||
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)
|
||||
try:
|
||||
return await func(s)
|
||||
@ -206,9 +192,7 @@ class DBSessionManager(Singleton):
|
||||
await self.end_session(db_type, db_wr_type)
|
||||
|
||||
async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
|
||||
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit.
|
||||
하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다.
|
||||
"""
|
||||
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit."""
|
||||
temp_list = list(set(db_type_list))
|
||||
if len(temp_list) != 1:
|
||||
return ErrorType.DB_INVALID_TYPE
|
||||
@ -228,12 +212,7 @@ class DBSessionManager(Singleton):
|
||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||
|
||||
async def execute_lambda_write(self, db_type: int, func):
|
||||
"""Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다.
|
||||
|
||||
execute_lambda_run 은 ErrorType 만, execute_lambda_claim 은 (ErrorType, 적용행수) 만 돌려준다.
|
||||
작업 큐처럼 "변경하면서 값을 받아와야" 하는 경우(RETURNING 절)를 위한 진입점이다 —
|
||||
원자적 claim(FOR UPDATE SKIP LOCKED + UPDATE + RETURNING)은 조회/변경을 나눌 수 없다.
|
||||
예외는 rollback 후 그대로 전파한다(호출측이 잡 실패로 처리)."""
|
||||
"""Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다."""
|
||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||
try:
|
||||
result = await func(s)
|
||||
@ -246,9 +225,7 @@ class DBSessionManager(Singleton):
|
||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||
|
||||
async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]:
|
||||
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환.
|
||||
동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것.
|
||||
(Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)"""
|
||||
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환."""
|
||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||
try:
|
||||
err_type, rowcount = await func(s)
|
||||
|
||||
@ -22,27 +22,13 @@ from common.enums import (
|
||||
JobStatus,
|
||||
)
|
||||
|
||||
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
||||
# 모든 ORM 모델의 베이스.
|
||||
MAIN_BASE = declarative_base()
|
||||
|
||||
|
||||
# 공통 mixin
|
||||
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||||
# 공통 mixin DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||||
def _utc_now_sql():
|
||||
"""TIMESTAMPTZ 컬럼의 기본값. **init.sql 과 같은 `now()` 여야 한다.**
|
||||
|
||||
★ 예전 값은 `(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 기본값이 그것과 다르면 이런 식으로 갈라진다.
|
||||
"""
|
||||
"""TIMESTAMPTZ 컬럼의 기본값."""
|
||||
return text("now()")
|
||||
|
||||
|
||||
@ -66,11 +52,8 @@ class users(MainTableMixin, MAIN_BASE):
|
||||
__tablename__ = "users"
|
||||
|
||||
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) # 로그인 아이디
|
||||
# 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여
|
||||
# id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다.
|
||||
# 소셜 계정은 비밀번호가 없다(NULL).
|
||||
password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
|
||||
name = Column(String(50), 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())
|
||||
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
|
||||
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
|
||||
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서
|
||||
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
|
||||
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
|
||||
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
|
||||
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
||||
# ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다
|
||||
# (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서,
|
||||
# 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다.
|
||||
# ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는
|
||||
# refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다.
|
||||
# refresh 토큰 무효화 키.
|
||||
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
|
||||
# ============================================================
|
||||
class places(MainTableMixin, MAIN_BASE):
|
||||
"""사업장. 상호명 하나로 시작해서, 카카오 로컬 검증을 통과해야 수집이 열린다.
|
||||
|
||||
★ verified_at 이 NULL 이면 collector 진입 금지 — 검증 없이 수집하면 남의 가게가 섞인다."""
|
||||
"""사업장."""
|
||||
|
||||
__tablename__ = "places"
|
||||
__table_args__ = (
|
||||
@ -104,15 +78,13 @@ class places(MainTableMixin, MAIN_BASE):
|
||||
)
|
||||
|
||||
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)
|
||||
name = Column(String(200), nullable=False) # 상호명(입력값)
|
||||
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
|
||||
|
||||
# ---- 카카오 로컬 검증 산출물 (동일 업소 판정) ----
|
||||
# 동일 업소 판정 키. 소스에 따라 있을 수도 없을 수도 있다 —
|
||||
# 카카오는 고유 id 를 주지만 네이버는 안 준다(그 경우 상호명+도로명주소가 대체 키).
|
||||
# 카카오 로컬 검증 산출물 (동일 업소 판정)
|
||||
external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource
|
||||
external_place_id = Column(String(64), 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)
|
||||
longitude = Column(Numeric(10, 7), nullable=True)
|
||||
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
|
||||
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다.
|
||||
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
|
||||
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
|
||||
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션").
|
||||
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/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
||||
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
||||
# 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각.
|
||||
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
|
||||
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
|
||||
# 미니 블로그 승인 메일 수신 주소.
|
||||
notify_email = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
|
||||
class place_channels(MainTableMixin, MAIN_BASE):
|
||||
"""Perplexity 가 발견한 채널 URL.
|
||||
|
||||
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
|
||||
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
|
||||
"""Perplexity 가 발견한 채널 URL."""
|
||||
|
||||
__tablename__ = "place_channels"
|
||||
__table_args__ = (
|
||||
@ -160,14 +125,13 @@ class place_channels(MainTableMixin, MAIN_BASE):
|
||||
title = Column(String(300), nullable=True) # 발견 시 제목/스니펫
|
||||
discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력)
|
||||
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)
|
||||
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
|
||||
|
||||
|
||||
class place_units(MainTableMixin, MAIN_BASE):
|
||||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
|
||||
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
|
||||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램."""
|
||||
|
||||
__tablename__ = "place_units"
|
||||
|
||||
@ -178,11 +142,7 @@ class place_units(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"
|
||||
|
||||
@ -203,12 +163,7 @@ class place_photos(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class place_songs(MainTableMixin, MAIN_BASE):
|
||||
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
||||
|
||||
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
|
||||
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
|
||||
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
|
||||
"""이 숙소의 노래."""
|
||||
|
||||
__tablename__ = "place_songs"
|
||||
|
||||
@ -219,30 +174,20 @@ class place_songs(MainTableMixin, MAIN_BASE):
|
||||
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
||||
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
||||
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/<이것>
|
||||
duration_sec = Column(Numeric(6, 2), nullable=True)
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
||||
last_error = Column(Text, nullable=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# fact : 사실 / FAQ
|
||||
# ============================================================
|
||||
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"
|
||||
__table_args__ = (
|
||||
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
|
||||
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
|
||||
Index(
|
||||
"uq_facts_published_place_key",
|
||||
"place_id",
|
||||
@ -291,12 +236,7 @@ class place_facts(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class place_faqs(MainTableMixin, MAIN_BASE):
|
||||
"""FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다.
|
||||
|
||||
LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다)
|
||||
OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다
|
||||
TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다.
|
||||
★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다."""
|
||||
"""FAQ."""
|
||||
|
||||
__tablename__ = "place_faqs"
|
||||
|
||||
@ -311,22 +251,7 @@ class place_faqs(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class place_itineraries(MainTableMixin, MAIN_BASE):
|
||||
"""LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다.
|
||||
|
||||
★ 왜 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개만 갱신된" 상태가 생긴다.
|
||||
"""
|
||||
"""LLM 이 만든 여행 일정."""
|
||||
|
||||
__tablename__ = "place_itineraries"
|
||||
__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_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)
|
||||
body = Column(JSONB, nullable=False) # ItineraryItem[]
|
||||
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())
|
||||
|
||||
|
||||
# ============================================================
|
||||
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
|
||||
# ============================================================
|
||||
class area_contents(MainTableMixin, MAIN_BASE):
|
||||
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
|
||||
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
|
||||
|
||||
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
|
||||
"""지역 정보 캐시."""
|
||||
|
||||
__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__ = (
|
||||
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** —
|
||||
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
|
||||
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다.
|
||||
Index(
|
||||
"uq_local_contents_external",
|
||||
"source",
|
||||
@ -383,7 +295,7 @@ class area_contents(MainTableMixin, MAIN_BASE):
|
||||
unique=True,
|
||||
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
|
||||
),
|
||||
# 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007).
|
||||
# 날씨: 지역 × 종류당 한 행.
|
||||
Index(
|
||||
"uq_local_contents_single",
|
||||
"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)
|
||||
# ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 —
|
||||
# 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004).
|
||||
# 지역 이야기·날씨만 이 값을 키로 쓴다.
|
||||
# nullable 이다.
|
||||
region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드
|
||||
content_type = Column(SmallInteger, nullable=False) # LocalContentType
|
||||
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)
|
||||
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
|
||||
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
|
||||
latitude = 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)
|
||||
|
||||
|
||||
class place_area_refs(MainTableMixin, MAIN_BASE):
|
||||
"""업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다.
|
||||
|
||||
★ 예전엔 값을 통째로 들고 키가 place_id 라 업장마다 복제됐다(한 곳에 144행).
|
||||
★ hidden 은 재수집이 덮어쓰지 않는다."""
|
||||
"""업장 ↔ 지역 콘텐츠."""
|
||||
|
||||
__tablename__ = "place_area_refs"
|
||||
|
||||
@ -433,11 +339,7 @@ class place_area_refs(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class place_posts(MainTableMixin, MAIN_BASE):
|
||||
"""미니 블로그 글 하나. 기획: docs/MINI_BLOG.md
|
||||
|
||||
★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
|
||||
★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
|
||||
★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
|
||||
"""미니 블로그 글 하나."""
|
||||
|
||||
__tablename__ = "place_posts"
|
||||
__table_args__ = (
|
||||
@ -454,11 +356,8 @@ class place_posts(MainTableMixin, MAIN_BASE):
|
||||
topic_kind = Column(SmallInteger, nullable=False)
|
||||
topic_key = Column(String(120), nullable=False)
|
||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
|
||||
# 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date).
|
||||
# 이 업장 몫 하루 한 통 배정일(KST).
|
||||
scheduled_date = Column(Date, nullable=True)
|
||||
# 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
|
||||
# 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
|
||||
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
|
||||
generation_meta = Column(JSONB, nullable=True)
|
||||
approve_token_hash = Column(String(64), nullable=True)
|
||||
# ★ 메일 '고쳐서 올리려면' 링크의 일회용 코드. 예전에는 그 자리에 빌더 액세스 토큰을
|
||||
@ -472,11 +371,7 @@ class place_posts(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class place_reviews(MainTableMixin, MAIN_BASE):
|
||||
"""손님이 남긴 이용 후기.
|
||||
|
||||
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
|
||||
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
|
||||
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
|
||||
"""손님이 남긴 이용 후기."""
|
||||
|
||||
__tablename__ = "place_reviews"
|
||||
__table_args__ = (
|
||||
@ -494,8 +389,7 @@ class place_reviews(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class sites(MainTableMixin, MAIN_BASE):
|
||||
"""발행 대상 사이트. 사업장당 1개.
|
||||
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
|
||||
"""발행 대상 사이트."""
|
||||
|
||||
__tablename__ = "sites"
|
||||
__table_args__ = (
|
||||
@ -507,22 +401,14 @@ class sites(MainTableMixin, MAIN_BASE):
|
||||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
||||
domain = Column(String(255), nullable=True)
|
||||
path_prefix = Column(String(100), nullable=True)
|
||||
# 사장님이 고른 템플릿 키(프론트 배리에이션 레지스트리의 id). 서버는 해석하지 않고 보관·반환만 한다 —
|
||||
# 템플릿 목록은 프론트가 소유하므로, 서버가 값을 검증하면 템플릿을 늘릴 때마다 백엔드를 고쳐야 한다.
|
||||
# NULL 이면 발행 잡이 업종 기본 템플릿으로 굽는다(services/site_payload).
|
||||
# 템플릿 id(solution/shared/src/data/templates.json).
|
||||
template_id = Column(String(100), nullable=True)
|
||||
# 에디터가 정한 색·서체·섹션(순서·on/off·배리에이션). template_id 와 같은 이유로 서버에 저장한다 —
|
||||
# 브라우저에만 두면 발행 잡이 읽을 곳이 없어 업종 기본 모양으로 굽고, 고른 디자인과 발행본이 갈린다.
|
||||
# ★ 컬럼으로 펼치지 않고 jsonb 로 통째로 담는 이유: 섹션 목록·배리에이션 키·색 토큰 이름은
|
||||
# 프론트가 소유한다. 펼치면 프론트가 항목 하나 늘릴 때마다 마이그레이션이 따라와야 한다.
|
||||
# ★ templateId 는 여기 넣지 않는다 — 위 template_id 컬럼이 소유한다. 두 곳에 두면 어느 쪽이 진짜인지 갈린다.
|
||||
# NULL 이면 발행 잡이 업종 기본 색·서체·섹션으로 굽는다(services/site_payload).
|
||||
# 색·섹션(순서·on/off·본문).
|
||||
theme = Column(JSONB, nullable=True)
|
||||
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
|
||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을
|
||||
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
|
||||
# 발행 썸네일(Azure Blob 공개 URL).
|
||||
thumbnail_url = Column(String(500), nullable=True)
|
||||
|
||||
|
||||
@ -546,14 +432,7 @@ class site_search_status(MainTableMixin, MAIN_BASE):
|
||||
|
||||
|
||||
class alert_outbox(MainTableMixin, MAIN_BASE):
|
||||
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다.
|
||||
|
||||
★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다.
|
||||
장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다.
|
||||
★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) —
|
||||
같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다.
|
||||
★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면
|
||||
다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다."""
|
||||
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다."""
|
||||
|
||||
__tablename__ = "alert_outbox"
|
||||
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):
|
||||
"""섹션 하나의 콘텐츠. **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"
|
||||
__table_args__ = (
|
||||
@ -602,10 +471,7 @@ class site_sections(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"
|
||||
__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)
|
||||
snapshot = 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)
|
||||
built_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
|
||||
class site_publish_logs(MainTableMixin, MAIN_BASE):
|
||||
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
|
||||
"""발행 시도 기록."""
|
||||
|
||||
__tablename__ = "site_publish_logs"
|
||||
|
||||
@ -640,18 +506,7 @@ class site_publish_logs(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"
|
||||
__table_args__ = (
|
||||
@ -668,9 +523,7 @@ class jobs(MainTableMixin, MAIN_BASE):
|
||||
),
|
||||
)
|
||||
|
||||
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라
|
||||
# ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. init.sql 의 DEFAULT gen_random_uuid() 와 맞춘다.
|
||||
# (다른 테이블은 ORM 으로만 INSERT 하므로 원본 보일러플레이트대로 Python default 만 둔다.)
|
||||
# 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 ORM 의 Python 쪽 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
|
||||
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):
|
||||
"""카카오톡 채널 발화자 ↔ 우리 user_id.
|
||||
|
||||
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
|
||||
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
|
||||
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
|
||||
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
|
||||
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
|
||||
"""카카오톡 채널 발화자 ↔ 우리 user_id."""
|
||||
|
||||
__tablename__ = "owner_kakao_links"
|
||||
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'"))
|
||||
linked_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)
|
||||
pending_tool = Column(String(40), nullable=True)
|
||||
pending_args = Column(JSONB, nullable=True)
|
||||
|
||||
@ -15,12 +15,7 @@ class CodeEnum(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
|
||||
FAIL = 1
|
||||
@ -55,28 +50,28 @@ class ErrorType(Enum):
|
||||
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
||||
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
|
||||
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
||||
ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
|
||||
ACCOUNT_SESSION_REVOKED = auto()
|
||||
|
||||
# 사업장(places) 관련 에러
|
||||
PLACE_NOT_FOUND = 1200
|
||||
PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록
|
||||
PLACE_NOT_VERIFIED = auto() # ★ 동일 업소 검증 전 — 수집·발행 진입 금지
|
||||
PLACE_NOT_VERIFIED = auto() # 동일 업소 검증 전 — 수집·발행 진입 금지
|
||||
PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음
|
||||
PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함
|
||||
PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드
|
||||
UNIT_NOT_FOUND = auto()
|
||||
LINK_NOT_FOUND = auto()
|
||||
LINK_NOT_CONFIRMED = auto() # ★ 확정 안 된 URL — 크롤링 대상 아님
|
||||
LINK_NOT_CONFIRMED = auto() # 확정 안 된 URL — 크롤링 대상 아님
|
||||
MEDIA_NOT_FOUND = auto()
|
||||
|
||||
# fact 관련 에러
|
||||
FACT_NOT_FOUND = 1300
|
||||
FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key
|
||||
FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이
|
||||
FACT_LOCKED = auto() # ★ CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다
|
||||
FACT_LOCKED = auto() # CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다
|
||||
FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음
|
||||
FAQ_NOT_FOUND = auto()
|
||||
FAQ_UNGROUNDED = auto() # ★ 확보된 fact 로 뒷받침되지 않는 문장 — 반려
|
||||
FAQ_UNGROUNDED = auto() # 확보된 fact 로 뒷받침되지 않는 문장 — 반려
|
||||
|
||||
# 수집(collector) 관련 에러
|
||||
COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음
|
||||
@ -93,17 +88,17 @@ class ErrorType(Enum):
|
||||
# 지역 정보(local) 관련 에러
|
||||
LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정
|
||||
LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패
|
||||
LOCAL_FETCH_FAILED = auto() # ★ 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다
|
||||
LOCAL_FETCH_FAILED = auto() # 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다
|
||||
|
||||
# 사이트(sites) 관련 에러
|
||||
SITE_NOT_FOUND = 1700
|
||||
SITE_VERSION_NOT_FOUND = auto()
|
||||
SITE_BUILD_FAILED = auto()
|
||||
PUBLISH_UNVERIFIED_FACT = auto() # ★ 미검증 fact 포함 — 발행 거부
|
||||
PUBLISH_NO_UNIQUE_CONTENT = auto() # ★ 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상)
|
||||
PUBLISH_JSONLD_MISMATCH = auto() # ★ 구조화 데이터 값 != 화면 값 — 빌드 실패
|
||||
PUBLISH_UNVERIFIED_FACT = auto() # 미검증 fact 포함 — 발행 거부
|
||||
PUBLISH_NO_UNIQUE_CONTENT = auto() # 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상)
|
||||
PUBLISH_JSONLD_MISMATCH = auto() # 구조화 데이터 값 != 화면 값 — 빌드 실패
|
||||
PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락
|
||||
SITE_SLUG_LOCKED = auto() # ★ 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다
|
||||
SITE_SLUG_LOCKED = auto() # 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다
|
||||
|
||||
# 리포트(reports) 관련 에러
|
||||
REPORT_NOT_FOUND = 1800
|
||||
@ -132,15 +127,13 @@ EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_I
|
||||
|
||||
|
||||
class DBType(Enum):
|
||||
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
|
||||
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
|
||||
"""
|
||||
"""논리 DB 구분."""
|
||||
|
||||
MAIN = 1
|
||||
|
||||
|
||||
class DBWRType(Enum):
|
||||
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
||||
"""Read / Write 접속 구분."""
|
||||
|
||||
DB_READ = 1
|
||||
DB_WRITE = 2
|
||||
@ -155,9 +148,7 @@ class UserStatus(CodeEnum):
|
||||
|
||||
|
||||
class UserRole(CodeEnum):
|
||||
"""users.role 코드값.
|
||||
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
|
||||
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
|
||||
"""users.role 코드값."""
|
||||
|
||||
USER = 1
|
||||
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
|
||||
@ -165,11 +156,7 @@ class UserRole(CodeEnum):
|
||||
|
||||
|
||||
class AuthProvider(CodeEnum):
|
||||
"""users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가.
|
||||
|
||||
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
|
||||
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
|
||||
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절."""
|
||||
"""users.provider 코드값."""
|
||||
|
||||
LOCAL = 1 # id/pw
|
||||
GOOGLE = 2 # 구글 ID 토큰
|
||||
@ -183,8 +170,7 @@ class CompanyStatus(CodeEnum):
|
||||
|
||||
|
||||
class PlaceCategory(CodeEnum):
|
||||
"""places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1.
|
||||
업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가."""
|
||||
"""places.category 코드값."""
|
||||
|
||||
LODGING = 1 # 숙박
|
||||
CAFE = 2 # 카페
|
||||
@ -193,18 +179,14 @@ class PlaceCategory(CodeEnum):
|
||||
|
||||
|
||||
class ExternalPlaceSource(CodeEnum):
|
||||
"""places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB.
|
||||
|
||||
카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다.
|
||||
네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다."""
|
||||
"""places.external_source 코드값."""
|
||||
|
||||
KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O
|
||||
NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한
|
||||
|
||||
|
||||
class PlaceStatus(CodeEnum):
|
||||
"""places.status 코드값. 사업장 생애주기.
|
||||
해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다)."""
|
||||
"""places.status 코드값."""
|
||||
|
||||
DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전
|
||||
COLLECTING = 2 # 수집 진행 중
|
||||
@ -214,19 +196,17 @@ class PlaceStatus(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 # 사장님이 직접 입력·업로드
|
||||
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
|
||||
CRAWL = 3 # 크롤링
|
||||
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
|
||||
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다
|
||||
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용.
|
||||
|
||||
|
||||
class FactStatus(CodeEnum):
|
||||
"""facts.status / faqs.status / routes.status 공용 검증 상태.
|
||||
★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES)."""
|
||||
"""facts.status / faqs.status / routes.status 공용 검증 상태."""
|
||||
|
||||
UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함
|
||||
PENDING_OWNER = 2 # 사장님 확인 대기
|
||||
@ -236,27 +216,26 @@ class FactStatus(CodeEnum):
|
||||
EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상
|
||||
|
||||
|
||||
# ★ 절대규칙 1: 이 두 상태만 사이트에 노출한다. 발행 게이트가 이 집합으로 필터링한다.
|
||||
# 절대규칙 1: 이 두 상태만 사이트에 노출한다.
|
||||
PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED}
|
||||
|
||||
# 후보 — 재수집이 올려놓은 확인 대기 항목. 노출값과 달리 (place, unit, key) 당 여러 건 공존한다.
|
||||
# 후보 — 재수집이 올려놓은 확인 대기 항목.
|
||||
CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER}
|
||||
|
||||
# ★ 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. 사장님 수정본은 후보로만 도전받는다.
|
||||
# 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태.
|
||||
LOCKED_FACT_STATUSES = {FactStatus.CORRECTED}
|
||||
|
||||
|
||||
class FactWriteOutcome(CodeEnum):
|
||||
"""fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 —
|
||||
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다."""
|
||||
"""fact 기록 결과."""
|
||||
|
||||
PUBLISHED_CREATED = 1 # 노출값이 없던 자리에 사람이 직접 넣어 바로 노출됐다
|
||||
PUBLISHED_REPLACED = 2 # ★ 노출값이 교체됐다 — 사이트 재빌드 대상
|
||||
PUBLISHED_CREATED = 1
|
||||
PUBLISHED_REPLACED = 2
|
||||
REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신
|
||||
CANDIDATE_CREATED = 4 # 노출값과 다른 값이 들어와 후보로 쌓였다(사람 확인 대기)
|
||||
CANDIDATE_UPDATED = 5 # 같은 출처의 기존 후보를 새 수집값으로 갱신했다
|
||||
CANDIDATE_CREATED = 4
|
||||
CANDIDATE_UPDATED = 5
|
||||
|
||||
# 검증 상태 전이 허용표. 여기에 없는 전이는 FACT_INVALID_TRANSITION 으로 거부한다.
|
||||
# 검증 상태 전이 허용표.
|
||||
FACT_STATUS_TRANSITIONS = {
|
||||
FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, 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):
|
||||
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류."""
|
||||
"""place_channels.channel 코드값."""
|
||||
|
||||
YANOLJA = 1 # 야놀자
|
||||
GOODCHOICE = 2 # 여기어때
|
||||
@ -276,16 +255,13 @@ class LinkChannel(CodeEnum):
|
||||
INSTAGRAM = 4
|
||||
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
|
||||
BLOG = 6
|
||||
# ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
|
||||
# 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온
|
||||
# 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은
|
||||
# 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다.
|
||||
# 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
|
||||
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
|
||||
ETC = 99
|
||||
|
||||
|
||||
class MediaStatus(CodeEnum):
|
||||
"""media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다."""
|
||||
"""media.status 코드값."""
|
||||
|
||||
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
|
||||
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
|
||||
@ -293,41 +269,31 @@ class MediaStatus(CodeEnum):
|
||||
|
||||
|
||||
class SongStatus(CodeEnum):
|
||||
"""place_songs.status 코드값.
|
||||
"""place_songs.status 코드값."""
|
||||
|
||||
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
||||
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
|
||||
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
|
||||
|
||||
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
|
||||
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
|
||||
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
|
||||
GENERATING = 1
|
||||
READY = 2
|
||||
FAILED = 3 # 생성 실패.
|
||||
|
||||
|
||||
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
||||
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
||||
# Vision 결과를 자동 반영해도 되는 신뢰도 하한.
|
||||
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
||||
|
||||
|
||||
class LocalContentType(CodeEnum):
|
||||
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
|
||||
"""local_contents.content_type 코드값."""
|
||||
|
||||
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
|
||||
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25
|
||||
# ↓ 2~5 는 place_contents(업장 반경 캐시).
|
||||
FESTIVAL = 2 # 축제/공연/행사 (15)
|
||||
ATTRACTION = 3 # 관광지 (12)
|
||||
RESTAURANT = 4 # 음식점 (39)
|
||||
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
|
||||
# ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 —
|
||||
# 군산 이야기는 군산 숙소가 같이 쓴다. 여섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
|
||||
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
|
||||
COURSE = 5 # 여행코스 (25) — 백엔드만.
|
||||
# 지역 이야기(가요·일력·인물·연표·엽서·퀴즈).
|
||||
STORY = 6
|
||||
|
||||
|
||||
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
|
||||
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
|
||||
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
|
||||
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
|
||||
# 코드값 ↔ **타입명**.
|
||||
AREA_KIND = {
|
||||
LocalContentType.WEATHER.value: "weather",
|
||||
LocalContentType.FESTIVAL.value: "festival",
|
||||
@ -336,22 +302,20 @@ AREA_KIND = {
|
||||
LocalContentType.COURSE.value: "course",
|
||||
}
|
||||
|
||||
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
|
||||
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
|
||||
# 지역 이야기 일곱.
|
||||
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
|
||||
|
||||
|
||||
class LocalSource(CodeEnum):
|
||||
"""local_contents.source 코드값. 어느 외부 API 에서 왔는지."""
|
||||
"""local_contents.source 코드값."""
|
||||
|
||||
OPEN_METEO = 1 # 날씨. API 키 불필요
|
||||
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다
|
||||
OPEN_METEO = 1 # 날씨.
|
||||
TOUR_API = 2 # 한국관광공사.
|
||||
KAKAO_LOCAL = 3
|
||||
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
|
||||
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
|
||||
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
|
||||
# 지역 이야기 생성분.
|
||||
LLM = 5
|
||||
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
|
||||
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강).
|
||||
|
||||
|
||||
class LocalContentStatus(CodeEnum):
|
||||
@ -363,7 +327,7 @@ class LocalContentStatus(CodeEnum):
|
||||
|
||||
|
||||
class TransportType(CodeEnum):
|
||||
"""routes.transport 코드값. 가는 길 수단."""
|
||||
"""routes.transport 코드값."""
|
||||
|
||||
CAR = 1
|
||||
PUBLIC = 2
|
||||
@ -371,7 +335,7 @@ class TransportType(CodeEnum):
|
||||
|
||||
|
||||
class SiteStatus(CodeEnum):
|
||||
"""sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다."""
|
||||
"""sites.status 코드값."""
|
||||
|
||||
DRAFT = 1
|
||||
REVIEW = 2 # 검수 게이트 대기
|
||||
@ -381,7 +345,7 @@ class SiteStatus(CodeEnum):
|
||||
|
||||
|
||||
class BuildStatus(CodeEnum):
|
||||
"""site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다."""
|
||||
"""site_versions.build_status 코드값."""
|
||||
|
||||
PENDING = 1
|
||||
BUILDING = 2
|
||||
@ -397,7 +361,7 @@ class PublishAction(CodeEnum):
|
||||
REBUILD = 3
|
||||
SUSPEND = 4
|
||||
RESUME = 5
|
||||
ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py
|
||||
ROLLBACK = 6
|
||||
|
||||
|
||||
class PublishResult(CodeEnum):
|
||||
@ -409,7 +373,7 @@ class PublishResult(CodeEnum):
|
||||
|
||||
|
||||
class PublishRejectReason(CodeEnum):
|
||||
"""publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3)."""
|
||||
"""publish_logs.reject_reason 코드값."""
|
||||
|
||||
UNVERIFIED_FACT = 1 # 미검증 fact 포함
|
||||
NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건
|
||||
@ -418,7 +382,7 @@ class PublishRejectReason(CodeEnum):
|
||||
|
||||
|
||||
class AiEngine(CodeEnum):
|
||||
"""ai_check_results.engine 코드값. AI 검색이 우리 사이트를 근거로 답하는지 측정할 대상."""
|
||||
"""ai_check_results.engine 코드값."""
|
||||
|
||||
CHATGPT = 1
|
||||
PERPLEXITY = 2
|
||||
@ -427,14 +391,9 @@ class AiEngine(CodeEnum):
|
||||
ETC = 99
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다)
|
||||
# ============================================================
|
||||
class JobType(CodeEnum):
|
||||
"""jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다.
|
||||
|
||||
무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 —
|
||||
크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다."""
|
||||
"""jobs.job_type 코드값."""
|
||||
|
||||
COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링
|
||||
VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치)
|
||||
@ -442,30 +401,27 @@ class JobType(CodeEnum):
|
||||
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
||||
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
||||
AI_CHECK = 6 # AI 검색 노출 점검
|
||||
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
|
||||
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
|
||||
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno).
|
||||
ROLLBACK = 8
|
||||
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
|
||||
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
|
||||
|
||||
|
||||
class JobStatus(CodeEnum):
|
||||
"""jobs.status 코드값. 작업 큐 상태.
|
||||
"""jobs.status 코드값."""
|
||||
|
||||
전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면
|
||||
PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter)."""
|
||||
|
||||
PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상
|
||||
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수
|
||||
PENDING = 1 # 대기(claim 가능).
|
||||
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유).
|
||||
DONE = 3 # 완료
|
||||
DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상)
|
||||
|
||||
|
||||
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
|
||||
# claim 대상이 되는 활성 상태.
|
||||
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
|
||||
|
||||
|
||||
class PostTopicKind(CodeEnum):
|
||||
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다."""
|
||||
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가."""
|
||||
|
||||
WEATHER = 1 # local.weather
|
||||
FESTIVAL = 2 # local.festivals
|
||||
@ -475,20 +431,20 @@ class PostTopicKind(CodeEnum):
|
||||
|
||||
|
||||
class PostStatus(CodeEnum):
|
||||
"""place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다."""
|
||||
"""place_posts.status — 글 하나의 일생."""
|
||||
|
||||
DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다
|
||||
DRAFT = 1
|
||||
REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
|
||||
SENT = 3 # 사장님에게 메일이 나갔다
|
||||
APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기
|
||||
PUBLISHED = 5 # 사이트에 올라갔다
|
||||
SENT = 3
|
||||
APPROVED = 4
|
||||
PUBLISHED = 5
|
||||
SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
|
||||
|
||||
|
||||
class ReviewStatus(CodeEnum):
|
||||
"""place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다."""
|
||||
"""place_reviews.status — 손님이 쓴 글의 일생."""
|
||||
|
||||
PENDING = 1 # 손님이 막 남겼다
|
||||
PENDING = 1
|
||||
PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
|
||||
REJECTED = 3 # 반려
|
||||
|
||||
@ -499,14 +455,11 @@ class SocialProvider(CodeEnum):
|
||||
|
||||
|
||||
class KakaoLinkStatus(str, Enum):
|
||||
"""owner_kakao_links.status.
|
||||
"""owner_kakao_links.status."""
|
||||
|
||||
★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번
|
||||
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다."""
|
||||
|
||||
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
|
||||
LINKED = "LINKED" # channel_user_key 가 붙었다
|
||||
REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다
|
||||
PENDING = "PENDING"
|
||||
LINKED = "LINKED"
|
||||
REVOKED = "REVOKED"
|
||||
|
||||
|
||||
class SocialPostStatus(str, Enum):
|
||||
@ -523,7 +476,7 @@ class SocialPostStatus(str, Enum):
|
||||
|
||||
|
||||
class AlertStatus(CodeEnum):
|
||||
"""alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다."""
|
||||
"""alert_outbox.status 코드값."""
|
||||
|
||||
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
|
||||
SENT = 2 # 전송 성공
|
||||
|
||||
@ -1,17 +1,4 @@
|
||||
"""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 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다.
|
||||
"""
|
||||
"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문."""
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@ -33,7 +20,7 @@ class CatalogItem:
|
||||
id: str
|
||||
question: str
|
||||
topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부")
|
||||
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다
|
||||
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact.
|
||||
keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교)
|
||||
|
||||
|
||||
@ -48,10 +35,7 @@ class FaqCatalog:
|
||||
items: tuple[CatalogItem, ...]
|
||||
|
||||
def applies_to(self, category: int, external_category: str | None) -> bool:
|
||||
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다.
|
||||
|
||||
★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다.
|
||||
호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다."""
|
||||
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다."""
|
||||
if category != self.category.value:
|
||||
return False
|
||||
label = external_category or ""
|
||||
@ -116,7 +100,7 @@ def _parse(doc: dict, source: str) -> FaqCatalog:
|
||||
|
||||
|
||||
def load_catalogs() -> list[FaqCatalog]:
|
||||
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등)."""
|
||||
"""리소스 디렉터리 전체 로드 + 검증."""
|
||||
global _catalogs
|
||||
if _catalogs is None:
|
||||
loaded = []
|
||||
@ -131,5 +115,5 @@ def load_catalogs() -> list[FaqCatalog]:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@ -1,18 +1,5 @@
|
||||
"""재시도가 의미 없는 잡 실패.
|
||||
|
||||
★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
|
||||
네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
|
||||
사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다.
|
||||
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로
|
||||
세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다.
|
||||
|
||||
★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
|
||||
그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다.
|
||||
|
||||
★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면
|
||||
의존 방향이 뒤집힌다.
|
||||
"""
|
||||
"""재시도가 의미 없는 잡 실패."""
|
||||
|
||||
|
||||
class PermanentJobError(RuntimeError):
|
||||
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""
|
||||
"""다시 시도해도 결과가 같은 실패."""
|
||||
|
||||
@ -4,9 +4,7 @@ from datetime import datetime, timezone
|
||||
|
||||
|
||||
class _Logger:
|
||||
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전.
|
||||
LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다.
|
||||
"""
|
||||
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전."""
|
||||
|
||||
def __init__(self):
|
||||
self._prefix = ""
|
||||
|
||||
@ -14,7 +14,7 @@ class StructModel:
|
||||
|
||||
|
||||
class ErrorInfo(BaseModel, StructModel):
|
||||
"""모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다."""
|
||||
"""모든 응답에 공통으로 실리는 결과 정보."""
|
||||
|
||||
success: Optional[bool] = True
|
||||
code: Optional[int] = ErrorType.SUCCESS.value
|
||||
@ -27,11 +27,7 @@ class ErrorInfo(BaseModel, StructModel):
|
||||
self.desc = enum.name
|
||||
|
||||
|
||||
# ---- Protocol 규약 -------------------------------------------------------
|
||||
# 모든 통신 패킷은 WebPacketProtocol 을 상속한다.
|
||||
# 요청 : Req_xxx (WebPacketProtocol)
|
||||
# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다.
|
||||
# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다.
|
||||
# Protocol 규약
|
||||
class WebPacketProtocol(BaseModel, StructModel):
|
||||
pass
|
||||
|
||||
@ -54,7 +50,7 @@ class Res_PageProtocol(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class PageParams:
|
||||
# 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다.
|
||||
# 목록 엔드포인트 공용 쿼리 파라미터.
|
||||
def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
|
||||
self.page = page
|
||||
self.size = size
|
||||
@ -67,16 +63,14 @@ class PageParams:
|
||||
class UserInfo(StructModel):
|
||||
"""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 재조회 키
|
||||
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
|
||||
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__()
|
||||
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로
|
||||
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
|
||||
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
|
||||
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
|
||||
self.role = UserRole.USER.value
|
||||
self.token_version = 1
|
||||
for dictionary in args:
|
||||
|
||||
41
solution/backend/common/template_catalog.py
Normal file
41
solution/backend/common/template_catalog.py
Normal 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
|
||||
@ -1,19 +1,11 @@
|
||||
"""좌표 거리 — 공용 한 벌.
|
||||
|
||||
★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터)
|
||||
세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의
|
||||
거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다.
|
||||
|
||||
국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정
|
||||
(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다.
|
||||
"""
|
||||
"""좌표 거리 — 공용 한 벌."""
|
||||
import math
|
||||
|
||||
EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||
"""두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다."""
|
||||
"""두 좌표(위도, 경도) 사이의 거리(m)."""
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
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
|
||||
|
||||
@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
||||
|
||||
|
||||
class GTime:
|
||||
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)"""
|
||||
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸."""
|
||||
|
||||
@staticmethod
|
||||
def UTC() -> datetime:
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것.
|
||||
|
||||
★ 한계를 먼저 적는다. 프로세스가 여럿이면 한도가 그 수만큼 곱해지고, 재시작하면 리셋된다.
|
||||
제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다.
|
||||
|
||||
★ 그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 를 부르는 경로**다.
|
||||
방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다
|
||||
(카카오 키워드 검색은 무료 한도를 넘기면 건당 2원 — services/external/kakao.py 주석).
|
||||
"""
|
||||
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것."""
|
||||
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
@ -23,7 +15,7 @@ def allow(key: str, limit: int, window_sec: float) -> bool:
|
||||
if len(bucket) >= limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
# 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다.
|
||||
# 안 쓰는 키가 쌓이는 걸 막는다.
|
||||
if not bucket:
|
||||
_hits.pop(key, None)
|
||||
return True
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다.
|
||||
|
||||
★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데
|
||||
(넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다.
|
||||
실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다.
|
||||
"""
|
||||
"""TTL 캐시 — 프로세스 메모리에만 있다."""
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다.
|
||||
|
||||
★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를
|
||||
주워 먹어 엉뚱한 포트로 뜬다.
|
||||
"""
|
||||
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다."""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional
|
||||
@ -12,16 +8,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
import os
|
||||
|
||||
# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다.
|
||||
# 레포 최상위 .env.
|
||||
_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")
|
||||
|
||||
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
|
||||
|
||||
# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다.
|
||||
# 테스트는 별도 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)
|
||||
@ -35,7 +31,7 @@ class WebServerConfig(BaseSettings):
|
||||
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
|
||||
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
|
||||
is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
|
||||
# CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다.
|
||||
# CORS 허용 오리진(쉼표로 여럿).
|
||||
client_url: str = Field(
|
||||
"http://localhost:3000,http://localhost:3001,http://localhost:3002,"
|
||||
"http://localhost:3003,http://localhost:3004,http://localhost:3005",
|
||||
@ -52,7 +48,7 @@ class LogConfig(BaseSettings):
|
||||
|
||||
|
||||
class MainDBConfig(BaseSettings):
|
||||
"""DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본)."""
|
||||
"""DB read/write 분리."""
|
||||
|
||||
model_config = _BASE
|
||||
|
||||
@ -71,7 +67,6 @@ class MainDBConfig(BaseSettings):
|
||||
|
||||
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
|
||||
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
|
||||
# PostgreSQL max_connections 를 넘기면 안 된다.
|
||||
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
|
||||
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
|
||||
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
|
||||
@ -100,12 +95,7 @@ class JwtToken(BaseSettings):
|
||||
|
||||
|
||||
class GoogleOAuthConfig(BaseSettings):
|
||||
"""구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
|
||||
|
||||
★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는
|
||||
숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로
|
||||
들고 와도 우리 계정이 되지 않게 막는 유일한 검사다.
|
||||
★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다."""
|
||||
"""구글 로그인."""
|
||||
|
||||
model_config = _BASE
|
||||
|
||||
@ -119,8 +109,6 @@ class ExternalApiConfig(BaseSettings):
|
||||
|
||||
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")
|
||||
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
|
||||
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
|
||||
@ -135,17 +123,15 @@ class ExternalApiConfig(BaseSettings):
|
||||
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
||||
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
||||
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 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
||||
# 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
||||
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
||||
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
|
||||
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
|
||||
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology).
|
||||
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
|
||||
|
||||
|
||||
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
||||
# .env 를 요청마다 다시 읽지 않는다.
|
||||
@lru_cache
|
||||
def get_web_server_config() -> WebServerConfig:
|
||||
return WebServerConfig()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""SNS도 루트 .env만 읽는다. APP_ENV=test에서는 기존 설정 규칙대로 .env를 읽지 않는다."""
|
||||
"""SNS도 루트 .env만 읽는다."""
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
from config.config_models import _BASE
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
# 테스트는 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
|
||||
|
||||
os.environ.setdefault("APP_ENV", "test")
|
||||
@ -18,14 +16,7 @@ from common.enums import UserRole, UserStatus
|
||||
from config.server_configs import main_db_config
|
||||
|
||||
|
||||
# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는
|
||||
# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를
|
||||
# 얹지도 않는다.
|
||||
#
|
||||
# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데,
|
||||
# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로
|
||||
# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도
|
||||
# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다.
|
||||
# 비울 표는 **ORM 이 아는 것**에서 뽑는다.
|
||||
def _truncate_sql() -> str:
|
||||
names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables)
|
||||
return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE"
|
||||
@ -37,14 +28,13 @@ def _write_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 ""
|
||||
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres"
|
||||
|
||||
|
||||
async def _drop_test_db(*, recreate: bool):
|
||||
"""test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다.
|
||||
WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB."""
|
||||
"""test DB 를 지운다(있으면)."""
|
||||
engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT")
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
@ -57,12 +47,7 @@ async def _drop_test_db(*, recreate: bool):
|
||||
|
||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||
async def _test_db_lifecycle():
|
||||
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다.
|
||||
|
||||
매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다.
|
||||
(테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.)
|
||||
안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호).
|
||||
"""
|
||||
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다."""
|
||||
assert "test" in main_db_config.name, (
|
||||
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
|
||||
)
|
||||
@ -77,13 +62,8 @@ async def _test_db_lifecycle():
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db_engine(_test_db_lifecycle):
|
||||
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
|
||||
|
||||
⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(web4ai_db)를 가리키면 실데이터가 날아간다.
|
||||
그래서 test 전용 DB(이름에 'test')가 아니면 즉시 중단한다(APP_ENV=test).
|
||||
앱(DB_SESSION_MNG)도 APP_ENV=test 면 같은 test DB 에 접속하므로 여기서 만든 스키마를 공유한다.
|
||||
"""
|
||||
# 안전가드: dev DB 오염 방지. web4ai_test_db 이외엔 절대 실행하지 않는다.
|
||||
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다."""
|
||||
# 안전가드: dev DB 오염 방지.
|
||||
assert "test" in main_db_config.name, (
|
||||
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
|
||||
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
|
||||
@ -99,11 +79,7 @@ async def db_engine(_test_db_lifecycle):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def owner_id(db_engine) -> str:
|
||||
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
|
||||
|
||||
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
|
||||
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
|
||||
"""
|
||||
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다."""
|
||||
uid = uuid.uuid4()
|
||||
async with db_engine.begin() as conn:
|
||||
# status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
|
||||
@ -130,13 +106,7 @@ async def client(db_engine):
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def auth_headers(db_engine, client):
|
||||
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
|
||||
|
||||
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
|
||||
★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
|
||||
— 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
|
||||
호출: `h = await auth_headers("user1")`.
|
||||
"""
|
||||
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리."""
|
||||
from router.v1.validator.dependencies import GetHashedPW
|
||||
|
||||
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)
|
||||
def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
"""정적 렌더러(solution/site) 대역.
|
||||
|
||||
★ 왜 필요한가
|
||||
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
|
||||
굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site).
|
||||
파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는
|
||||
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
|
||||
|
||||
★ 구조화 데이터 ↔ 화면 값 대조 자체는 렌더러 쪽 테스트가 본다
|
||||
(solution/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 —
|
||||
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
|
||||
"""
|
||||
"""정적 렌더러(solution/site) 대역."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@ -242,11 +201,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
||||
)
|
||||
place = payload.get("place") or {}
|
||||
count = _count(payload)
|
||||
# ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다**
|
||||
# (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에
|
||||
# 나가 있으면 크롤러가 읽는다). 대역이 늘 ok=True 를 주면 백엔드가 그 실패를
|
||||
# NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 테스트되지 않는다.
|
||||
# mismatches 는 비워 둔다 — 사유가 JSONLD_MISMATCH 로 섞이면 화면 문구가 틀린다.
|
||||
# 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 나가 있으면 크롤러가 읽는다).
|
||||
if count <= 0:
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""alert_outbox 원장 접근. services/alert_service.py 가 부른다."""
|
||||
"""alert_outbox 원장 접근."""
|
||||
from sqlalchemy import func, select, update
|
||||
|
||||
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):
|
||||
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None.
|
||||
|
||||
★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를
|
||||
쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다."""
|
||||
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림."""
|
||||
result = await session.execute(
|
||||
select(alert_outbox)
|
||||
.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):
|
||||
"""★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한
|
||||
GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도
|
||||
컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수
|
||||
있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다."""
|
||||
"""`next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다."""
|
||||
result = await session.execute(
|
||||
select(alert_outbox)
|
||||
.where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False),
|
||||
|
||||
@ -10,10 +10,9 @@ from common.enums import ErrorType, FactStatus
|
||||
from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
# ★ 사이트에 나가는 상태. PUBLISHABLE_FACT_STATUSES 와 같은 집합이어야 한다.
|
||||
# 유니크 인덱스(uq_facts_published_*)의 조건과도 같아야 한다.
|
||||
# 사이트에 나가는 상태.
|
||||
_PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
||||
# 후보 — 재수집이 올려놓은 확인 대기 항목. 여러 건 공존한다.
|
||||
# 후보 — 재수집이 올려놓은 확인 대기 항목.
|
||||
_CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
|
||||
# 화면에 보이는 것 전체(이력 제외).
|
||||
_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
|
||||
|
||||
|
||||
# fact CRUD. 항상 place_id 로 스코프한다.
|
||||
# fact CRUD.
|
||||
class IFactCRUD(ABC):
|
||||
@abstractmethod
|
||||
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,
|
||||
publishable_only: bool = False, active_only: bool = True,
|
||||
) -> Tuple[ErrorType, list]:
|
||||
"""fact 목록.
|
||||
publishable_only=True → ★ VERIFIED·CORRECTED 만 (사이트 렌더·발행 게이트가 쓰는 경로)
|
||||
active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보)
|
||||
"""
|
||||
"""fact 목록."""
|
||||
try:
|
||||
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
|
||||
if unit_id is not None:
|
||||
@ -111,7 +107,7 @@ class FactCRUD(IFactCRUD):
|
||||
elif active_only:
|
||||
conditions.append(place_facts.status.in_(_ACTIVE))
|
||||
|
||||
# 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다.
|
||||
# 노출값이 먼저, 그 아래 후보.
|
||||
query = select(place_facts).where(and_(*conditions)).order_by(
|
||||
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, []
|
||||
|
||||
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
|
||||
"""★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None).
|
||||
유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다."""
|
||||
"""지금 사이트에 나가고 있는 값."""
|
||||
try:
|
||||
query = (
|
||||
select(place_facts)
|
||||
@ -145,7 +140,7 @@ class FactCRUD(IFactCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
|
||||
"""같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다."""
|
||||
"""같은 출처가 이미 올려둔 후보."""
|
||||
try:
|
||||
query = (
|
||||
select(place_facts)
|
||||
@ -169,9 +164,7 @@ class FactCRUD(IFactCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]:
|
||||
"""★ 재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다.
|
||||
|
||||
이게 없으면 값이 안 바뀌었는데도 재수집마다 검증이 초기화돼 사이트에서 사실이 사라진다."""
|
||||
"""재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다."""
|
||||
try:
|
||||
values = {"collected_at": ts, "updated_at": ts}
|
||||
if source_url:
|
||||
@ -183,7 +176,7 @@ class FactCRUD(IFactCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]:
|
||||
"""기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다."""
|
||||
"""기존 후보를 새 수집값으로 갱신."""
|
||||
try:
|
||||
query = (
|
||||
update(place_facts)
|
||||
@ -196,10 +189,7 @@ class FactCRUD(IFactCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
|
||||
"""검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다.
|
||||
|
||||
적용행수 0 = 그 사이 다른 사람이 이미 상태를 바꿨다는 뜻(동시 처리 가드).
|
||||
허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다."""
|
||||
"""검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다."""
|
||||
try:
|
||||
query = (
|
||||
update(place_facts)
|
||||
@ -216,9 +206,7 @@ class FactCRUD(IFactCRUD):
|
||||
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]:
|
||||
"""현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출).
|
||||
|
||||
지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다."""
|
||||
"""현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출)."""
|
||||
try:
|
||||
conditions = [
|
||||
place_facts.place_id == place_id,
|
||||
@ -236,9 +224,7 @@ class FactCRUD(IFactCRUD):
|
||||
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]:
|
||||
"""남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈).
|
||||
|
||||
후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다."""
|
||||
"""남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈)."""
|
||||
try:
|
||||
conditions = [
|
||||
place_facts.place_id == place_id,
|
||||
|
||||
@ -14,7 +14,7 @@ _PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
||||
_ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE
|
||||
|
||||
|
||||
# FAQ CRUD. fact 와 같은 검증 상태 흐름을 탄다 — 생성된 문장도 사람이 확인해야 나간다.
|
||||
# FAQ CRUD.
|
||||
class IFaqCRUD(ABC):
|
||||
@abstractmethod
|
||||
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
|
||||
|
||||
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
|
||||
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다.
|
||||
|
||||
★ 사람이 정정한 FAQ 는 건드리지 않는다 — 재생성이 사람의 판단을 덮어쓰면
|
||||
fact 쪽 규칙과 어긋난다.
|
||||
|
||||
★ 가르는 기준이 status 에서 generated_by 로 바뀌었다 (2026-09-10).
|
||||
생성분이 UNVERIFIED 로 들어가던 시절에는 status 만으로 "사람이 손댔는가" 를 알 수
|
||||
있었다. 이제 생성분도 VERIFIED 로 들어가므로(copy_service) status 로는 둘이 구분되지
|
||||
않는다 — 그대로 두면 재생성이 옛 FAQ 를 못 내리고 같은 질문이 쌓인다.
|
||||
책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service 가
|
||||
generated_by 를 OWNER 로 바꾼다."""
|
||||
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다."""
|
||||
try:
|
||||
query = (
|
||||
update(place_faqs)
|
||||
@ -90,10 +80,8 @@ class FaqCRUD(IFaqCRUD):
|
||||
place_faqs.place_id == place_id,
|
||||
place_faqs.deleted == False, # noqa: E712
|
||||
# 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다.
|
||||
# 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다.
|
||||
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)),
|
||||
)
|
||||
.values(status=FactStatus.EXPIRED.value, updated_at=ts)
|
||||
|
||||
@ -1,14 +1,4 @@
|
||||
"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다. (LPS `crud/job_crud.py` 이식)
|
||||
|
||||
- 할당은 **단일 문장 원자 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 표현식을 쓴다.
|
||||
"""
|
||||
"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다."""
|
||||
|
||||
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:
|
||||
"""지수 백오프(초). attempts 회 시도 후 다음 재시도까지 대기 = base * 2^(attempts-1), cap 상한."""
|
||||
"""지수 백오프(초)."""
|
||||
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)
|
||||
|
||||
# ---- 적재 ----
|
||||
# 적재
|
||||
async def enqueue(
|
||||
self,
|
||||
job_type: int,
|
||||
@ -43,7 +33,7 @@ class JobQueue:
|
||||
dedupe_key: str | None = None,
|
||||
max_attempts: int = 3,
|
||||
) -> str | None:
|
||||
"""잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환."""
|
||||
"""잡 적재."""
|
||||
sql = text("""
|
||||
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
|
||||
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
|
||||
@ -64,10 +54,9 @@ class JobQueue:
|
||||
|
||||
return await self._tx(run)
|
||||
|
||||
# ---- 원자적 claim ----
|
||||
# 원자적 claim
|
||||
async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None:
|
||||
"""대기 잡 1건을 원자적으로 점유. 없으면 None.
|
||||
FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가."""
|
||||
"""대기 잡 1건을 원자적으로 점유."""
|
||||
sql = text("""
|
||||
UPDATE jobs SET
|
||||
status = 2,
|
||||
@ -98,7 +87,7 @@ class JobQueue:
|
||||
|
||||
return await self._tx(run)
|
||||
|
||||
# ---- 완료/실패 (소유권 가드) ----
|
||||
# 완료/실패 (소유권 가드)
|
||||
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
|
||||
sql = text("""
|
||||
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
|
||||
@ -117,8 +106,7 @@ class JobQueue:
|
||||
return await self._tx(run)
|
||||
|
||||
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("""
|
||||
UPDATE jobs SET
|
||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||
@ -141,11 +129,7 @@ class JobQueue:
|
||||
return await self._tx(run)
|
||||
|
||||
async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool:
|
||||
"""재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다.
|
||||
|
||||
★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError).
|
||||
백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은
|
||||
그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다."""
|
||||
"""재시도 없이 바로 DEAD."""
|
||||
sql = text("""
|
||||
UPDATE jobs SET status = 4, last_error = :err,
|
||||
lease_until = NULL, worker_id = NULL, updated_at = now()
|
||||
@ -159,7 +143,7 @@ class JobQueue:
|
||||
|
||||
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:
|
||||
sql = text("""
|
||||
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
|
||||
@ -174,11 +158,7 @@ class JobQueue:
|
||||
return await self._tx(run)
|
||||
|
||||
async def reap(self) -> list[dict]:
|
||||
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
|
||||
|
||||
회수된 잡마다 {job_id, job_type, status, last_error} 를 돌려준다 — worker/runner.py 의
|
||||
run_reaper 가 이 중 DEAD(4) 로 떨어진 것만 골라 알린다(alert_service). job_id 목록만
|
||||
돌려주던 예전 모양보다 한 겹 더 있는 이유가 그것뿐이다."""
|
||||
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수."""
|
||||
sql = text("""
|
||||
UPDATE jobs SET
|
||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||
@ -200,7 +180,7 @@ class JobQueue:
|
||||
|
||||
return await self._tx(run)
|
||||
|
||||
# ---- 단건 조회 (상태 폴링) ----
|
||||
# 단건 조회 (상태 폴링)
|
||||
async def set_progress(self, job: dict, progress: dict) -> bool:
|
||||
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
|
||||
sql = text("""
|
||||
@ -221,7 +201,7 @@ class JobQueue:
|
||||
return await self._tx(run)
|
||||
|
||||
async def find_latest(self, dedupe_key: str) -> dict | None:
|
||||
"""복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다."""
|
||||
"""복구는 완료·실패 이력도 찾는다."""
|
||||
async def run(s):
|
||||
row = (await s.execute(text("""
|
||||
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)
|
||||
|
||||
async def get(self, job_id: str) -> dict | None:
|
||||
"""잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값)."""
|
||||
"""잡 단건 조회(읽기)."""
|
||||
sql = text("""
|
||||
SELECT job_id, job_type, status, priority, attempts, max_attempts,
|
||||
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)
|
||||
|
||||
async def find_active(self, dedupe_key: str) -> dict | None:
|
||||
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다.
|
||||
enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함."""
|
||||
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다."""
|
||||
sql = text("""
|
||||
SELECT job_id, job_type, status FROM jobs
|
||||
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)
|
||||
|
||||
# ---- 관측(관리 API/알림용) ----
|
||||
# 관측(관리 API/알림용)
|
||||
async def counts(self) -> dict[str, int]:
|
||||
"""상태별 잡 개수."""
|
||||
async def run(s):
|
||||
@ -282,11 +261,7 @@ class JobQueue:
|
||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||
|
||||
async def ops(self) -> dict:
|
||||
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) +
|
||||
최근 1시간 DEAD + stuck(좀비 신호).
|
||||
|
||||
stuck 은 두 축 — lease 만료(워커 사망인데 reaper 미회수) OR 실행 10분 초과(핸들러 행 —
|
||||
heartbeat 가 lease 를 계속 갱신해 lease 축엔 안 잡히므로 run_started_at 으로 따로 본다)."""
|
||||
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + 최근 1시간 DEAD + stuck(좀비 신호)."""
|
||||
sql = text("""
|
||||
SELECT
|
||||
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)
|
||||
|
||||
async def requeue(self, job_id: str) -> str | None:
|
||||
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
|
||||
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반."""
|
||||
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움."""
|
||||
sql = text("""
|
||||
UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
|
||||
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
|
||||
|
||||
@ -9,8 +9,7 @@ from common.utils.gtime import GTime
|
||||
|
||||
class LocalContentCRUD:
|
||||
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
|
||||
if status is not None:
|
||||
conds.append(area_contents.status == status)
|
||||
@ -43,20 +42,11 @@ class LocalContentCRUD:
|
||||
return await self.update(db, content_id, {"status": 3})
|
||||
|
||||
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 = stmt.on_conflict_do_update(
|
||||
index_elements=[area_contents.region_code, area_contents.kind],
|
||||
# ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가
|
||||
# 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching"
|
||||
# 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다.
|
||||
# 조건은 인덱스와 **글자 그대로** 같아야 한다.
|
||||
index_where=and_(
|
||||
area_contents.deleted == False, # noqa: E712
|
||||
area_contents.kind.isnot(None),
|
||||
@ -76,17 +66,7 @@ class LocalContentCRUD:
|
||||
return await DB_SESSION_MNG.add(db, stmt)
|
||||
|
||||
async def list_kinds(self, db, region_code: str):
|
||||
"""지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다.
|
||||
|
||||
★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 —
|
||||
마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND),
|
||||
그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.**
|
||||
실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라
|
||||
지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목·
|
||||
엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서
|
||||
"생성기가 없는 것" 처럼 보였다.
|
||||
★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다.
|
||||
"""
|
||||
"""지역의 **이야기** 행(종류당 1행)."""
|
||||
return await DB_SESSION_MNG.execute(
|
||||
db,
|
||||
select(area_contents).where(
|
||||
@ -112,8 +92,6 @@ class LocalContentCRUD:
|
||||
stmt = pg_insert(area_contents).values(**values)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[area_contents.region_code, area_contents.content_type],
|
||||
# ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에
|
||||
# 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.**
|
||||
index_where=and_(
|
||||
area_contents.deleted == False, # noqa: E712
|
||||
area_contents.external_id.is_(None),
|
||||
|
||||
@ -11,7 +11,7 @@ from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 사진 CRUD. 항상 place_id 로 스코프한다.
|
||||
# 사진 CRUD.
|
||||
class IMediaCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def list_media(
|
||||
@ -35,18 +35,7 @@ class MediaCRUD(IMediaCRUD):
|
||||
async def list_media(
|
||||
self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False
|
||||
) -> 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:
|
||||
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
|
||||
if status is not None:
|
||||
@ -69,10 +58,7 @@ class MediaCRUD(IMediaCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]:
|
||||
"""Vision 분석 결과를 반영한다.
|
||||
|
||||
★ 신뢰도가 낮으면 status 를 PENDING_REVIEW 로 남긴다 — 자동 반영하지 않는다.
|
||||
라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다."""
|
||||
"""Vision 분석 결과를 반영한다."""
|
||||
try:
|
||||
query = (
|
||||
update(place_photos)
|
||||
|
||||
@ -7,18 +7,10 @@ from common.utils.gtime import GTime
|
||||
|
||||
|
||||
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):
|
||||
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다."""
|
||||
"""이 업장 주변의 콘텐츠."""
|
||||
conds = [
|
||||
place_area_refs.place_id == place_id,
|
||||
place_area_refs.deleted == False, # noqa: E712
|
||||
@ -38,7 +30,7 @@ class PlaceContentCRUD:
|
||||
area_contents.latitude,
|
||||
area_contents.longitude,
|
||||
area_contents.display_end_at,
|
||||
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
|
||||
# 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
|
||||
place_area_refs.distance_m.label("distance_m"),
|
||||
place_area_refs.hidden.label("hidden"),
|
||||
)
|
||||
@ -51,11 +43,7 @@ class PlaceContentCRUD:
|
||||
)
|
||||
|
||||
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 = stmt.on_conflict_do_update(
|
||||
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):
|
||||
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다."""
|
||||
"""업장 ↔ 콘텐츠 관계."""
|
||||
stmt = pg_insert(place_area_refs).values(
|
||||
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)
|
||||
|
||||
async def soft_delete_missing(self, db, place_id, keep_ids: set):
|
||||
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 —
|
||||
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
|
||||
"""이번 응답에 없는 **관계**를 끊는다."""
|
||||
err, rows = await DB_SESSION_MNG.execute(
|
||||
db,
|
||||
select(place_area_refs.local_content_id).where(
|
||||
@ -109,7 +96,6 @@ class PlaceContentCRUD:
|
||||
),
|
||||
)
|
||||
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
|
||||
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
|
||||
gone = [r for r in (rows or []) if r not in keep_ids]
|
||||
if not gone:
|
||||
return err, 0
|
||||
@ -121,7 +107,7 @@ class PlaceContentCRUD:
|
||||
)
|
||||
|
||||
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
|
||||
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다."""
|
||||
"""이 업장에서만 숨긴다."""
|
||||
return await DB_SESSION_MNG.add_with_rowcount(
|
||||
db,
|
||||
update(place_area_refs)
|
||||
|
||||
@ -11,7 +11,7 @@ from common.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
|
||||
# 사업장 CRUD.
|
||||
class IPlaceCRUD(ABC):
|
||||
@abstractmethod
|
||||
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
||||
@ -95,16 +95,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
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:
|
||||
def _count(model):
|
||||
return (
|
||||
@ -167,7 +158,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, [], 0
|
||||
|
||||
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
|
||||
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
|
||||
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다."""
|
||||
try:
|
||||
if not data:
|
||||
return ErrorType.SUCCESS, 0
|
||||
@ -182,7 +173,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
|
||||
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다."""
|
||||
"""사업장을 실제 삭제한다."""
|
||||
try:
|
||||
query = (
|
||||
delete(places)
|
||||
@ -231,7 +222,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
|
||||
"""채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만."""
|
||||
"""채널 URL 목록."""
|
||||
try:
|
||||
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
|
||||
if confirmed_only:
|
||||
@ -251,11 +242,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]:
|
||||
"""URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다.
|
||||
|
||||
(place_id, url) 은 유니크라 대상이 한 건으로 정해진다. 이미 확정된 건 rowcount 0.
|
||||
★ 쓰는 곳은 상호 일치로 찾은 네이버 플레이스 링크 하나뿐이다 — 근거 없이 확정하는
|
||||
경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place)."""
|
||||
"""URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다."""
|
||||
try:
|
||||
query = (
|
||||
update(place_channels)
|
||||
@ -273,17 +260,7 @@ class PlaceCRUD(IPlaceCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, 0
|
||||
|
||||
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:
|
||||
query = (
|
||||
update(place_channels)
|
||||
|
||||
@ -8,8 +8,7 @@ from common.utils.gtime import GTime
|
||||
|
||||
class PlaceItineraryCRUD:
|
||||
async def list_by_place(self, db, place_id):
|
||||
"""업장의 일정 전부(기간별 한 행). 정렬은 기간 이름 순이 아니라 저장 순이 아니다 —
|
||||
화면 탭 순서는 읽는 쪽(`snapshot._local_contents`)이 DURATIONS 순으로 정한다."""
|
||||
"""업장의 일정 전부(기간별 한 행)."""
|
||||
return await DB_SESSION_MNG.execute(
|
||||
db,
|
||||
select(place_itineraries).where(
|
||||
@ -19,13 +18,7 @@ class PlaceItineraryCRUD:
|
||||
)
|
||||
|
||||
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 = stmt.on_conflict_do_update(
|
||||
index_elements=[place_itineraries.place_id, place_itineraries.duration],
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md)."""
|
||||
"""place_posts 접근."""
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@ -9,8 +9,7 @@ from common.utils.gtime import GTime
|
||||
|
||||
class PostCRUD:
|
||||
async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
|
||||
"""생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 —
|
||||
회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다)."""
|
||||
"""생성분 적재."""
|
||||
for row in rows:
|
||||
try:
|
||||
cdb.add(place_posts(**row))
|
||||
@ -20,12 +19,7 @@ class PostCRUD:
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
|
||||
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값
|
||||
(post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히
|
||||
건너뜀" 으로 끝내면 안 된다.
|
||||
|
||||
★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면
|
||||
detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다."""
|
||||
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 (post_id 포함)을 그대로 돌려준다."""
|
||||
try:
|
||||
obj = place_posts(**row)
|
||||
cdb.add(obj)
|
||||
@ -40,7 +34,7 @@ class PostCRUD:
|
||||
return None
|
||||
|
||||
async def max_scheduled_date(self, cdb: AsyncSession, place_id):
|
||||
"""이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다)."""
|
||||
"""이 업장이 이미 배정한 가장 늦은 날짜."""
|
||||
result = await cdb.execute(
|
||||
select(func.max(place_posts.scheduled_date))
|
||||
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
||||
@ -48,8 +42,7 @@ class PostCRUD:
|
||||
return result.scalar()
|
||||
|
||||
async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
|
||||
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도
|
||||
하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다)."""
|
||||
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순."""
|
||||
result = await cdb.execute(
|
||||
select(place_posts)
|
||||
.where(
|
||||
@ -63,8 +56,7 @@ class PostCRUD:
|
||||
return ErrorType.SUCCESS, rows[:limit]
|
||||
|
||||
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
||||
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None.
|
||||
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
|
||||
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다."""
|
||||
result = await cdb.execute(
|
||||
select(place_posts)
|
||||
.where(
|
||||
@ -99,10 +91,7 @@ class PostCRUD:
|
||||
return result.scalars().first()
|
||||
|
||||
async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
|
||||
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다.
|
||||
새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로
|
||||
같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도
|
||||
전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다."""
|
||||
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다."""
|
||||
result = await cdb.execute(
|
||||
select(
|
||||
place_posts.created_at,
|
||||
@ -124,7 +113,7 @@ class PostCRUD:
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
|
||||
"""화면에 나갈 글. 최신순이고, 게재된 것만."""
|
||||
"""화면에 나갈 글."""
|
||||
result = await cdb.execute(
|
||||
select(place_posts)
|
||||
.where(
|
||||
@ -162,8 +151,7 @@ class PostCRUD:
|
||||
)
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이
|
||||
# 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
|
||||
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
|
||||
_EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
|
||||
|
||||
async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
|
||||
@ -176,7 +164,7 @@ class PostCRUD:
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||
"""★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
|
||||
"""토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
|
||||
await cdb.execute(
|
||||
update(place_posts)
|
||||
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
|
||||
@ -207,8 +195,7 @@ class PostCRUD:
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false
|
||||
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
|
||||
"""소프트 삭제."""
|
||||
await cdb.execute(
|
||||
update(place_posts)
|
||||
.where(place_posts.post_id == post_id)
|
||||
|
||||
@ -5,19 +5,14 @@ from sqlalchemy import and_, func, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.logger import LOG
|
||||
from common.utils.gtime import GTime
|
||||
|
||||
|
||||
def _primary_photo_subquery():
|
||||
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).
|
||||
|
||||
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
|
||||
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
|
||||
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
|
||||
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
|
||||
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행)."""
|
||||
return (
|
||||
select(place_photos.url)
|
||||
.where(
|
||||
@ -33,7 +28,7 @@ def _primary_photo_subquery():
|
||||
)
|
||||
|
||||
|
||||
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
||||
# 사이트/버전/발행로그 CRUD.
|
||||
class ISiteCRUD(ABC):
|
||||
@abstractmethod
|
||||
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)], 총건수)."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
||||
pass
|
||||
@ -107,10 +107,7 @@ class SiteCRUD(ISiteCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
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:
|
||||
query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712
|
||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||
@ -122,16 +119,7 @@ class SiteCRUD(ISiteCRUD):
|
||||
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]:
|
||||
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진.
|
||||
(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:
|
||||
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)
|
||||
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]:
|
||||
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
|
||||
"""후보 주소들 중 이미 쓰이는 것만 추린다."""
|
||||
try:
|
||||
if not domains:
|
||||
return ErrorType.SUCCESS, set()
|
||||
@ -181,7 +197,7 @@ class SiteCRUD(ISiteCRUD):
|
||||
return ErrorType.DB_RUN_FAILED
|
||||
|
||||
async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]:
|
||||
"""다음 버전 번호. 1부터 시작한다."""
|
||||
"""다음 버전 번호."""
|
||||
try:
|
||||
query = select(func.max(site_versions.version)).where(
|
||||
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
|
||||
|
||||
async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]:
|
||||
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다.
|
||||
|
||||
★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고,
|
||||
그 번호가 곧 out/versions/<slug>/<version>/ 디렉토리 이름이다(prerender.ts)."""
|
||||
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다."""
|
||||
try:
|
||||
query = (
|
||||
select(site_versions)
|
||||
@ -295,15 +308,7 @@ class SiteCRUD(ISiteCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, []
|
||||
|
||||
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:
|
||||
query = (
|
||||
select(sites, places, _primary_photo_subquery())
|
||||
|
||||
@ -1,15 +1,4 @@
|
||||
"""site_sections — **개인화 데이터**의 단일 자리.
|
||||
|
||||
★ 규칙(2026-09-09)
|
||||
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로.
|
||||
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집.
|
||||
|
||||
★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로
|
||||
들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로
|
||||
세우면서 CRUD 를 붙인다.
|
||||
|
||||
★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다.
|
||||
"""
|
||||
"""site_sections — **개인화 데이터**의 단일 자리."""
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
|
||||
@ -29,12 +18,7 @@ class SiteSectionCRUD:
|
||||
)
|
||||
|
||||
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)
|
||||
return await DB_SESSION_MNG.add(
|
||||
db,
|
||||
|
||||
@ -52,7 +52,7 @@ async def sweep():
|
||||
text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now()
|
||||
WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""")
|
||||
)
|
||||
# POSTING은 외부가 받았을 수 있다. 시간을 근거로 APPROVED로 돌리지 않는다.
|
||||
# POSTING은 외부가 받았을 수 있다.
|
||||
await s.execute(
|
||||
text("""UPDATE place_social_posts SET status='UNKNOWN',
|
||||
last_error='POST_RESULT_UNKNOWN', updated_at=now()
|
||||
|
||||
@ -7,7 +7,7 @@ from common.utils.gtime import GTime
|
||||
|
||||
|
||||
class SongCRUD:
|
||||
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다."""
|
||||
"""place_songs 접근."""
|
||||
|
||||
async def insert(self, db, row):
|
||||
return await DB_SESSION_MNG.insert(db, row)
|
||||
@ -21,11 +21,7 @@ class SongCRUD:
|
||||
)
|
||||
|
||||
async def latest_ready(self, db, place_id):
|
||||
"""이 업장의 **가장 최근에 완성된** 곡 하나.
|
||||
|
||||
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
|
||||
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
|
||||
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
|
||||
"""이 업장의 **가장 최근에 완성된** 곡 하나."""
|
||||
return await DB_SESSION_MNG.execute(
|
||||
db,
|
||||
select(place_songs)
|
||||
|
||||
@ -1,19 +1,28 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy import and_, select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.logger import LOG
|
||||
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*) 와 구현(*) 으로 분리한다.
|
||||
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
|
||||
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
|
||||
class IUserCRUD(ABC):
|
||||
@abstractmethod
|
||||
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:
|
||||
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):
|
||||
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
|
||||
|
||||
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:
|
||||
query = (
|
||||
select(users)
|
||||
@ -82,8 +97,7 @@ class UserCRUD(IUserCRUD):
|
||||
return ErrorType.DB_RUN_FAILED, None
|
||||
|
||||
async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]:
|
||||
"""이메일로 1건. "이미 다른 수단으로 가입돼 있다" 판정에만 쓴다.
|
||||
이메일에는 유니크 제약이 없다(옛 데이터) — 여러 건이면 가장 먼저 만들어진 것을 본다."""
|
||||
"""이메일로 1건."""
|
||||
try:
|
||||
query = (
|
||||
select(users)
|
||||
@ -151,3 +165,33 @@ class UserCRUD(IUserCRUD):
|
||||
except Exception as ex:
|
||||
LOG.e_no_callstack(ex)
|
||||
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
|
||||
|
||||
@ -25,6 +25,7 @@ import router.v1.site.booking_request
|
||||
import router.v1.site.post
|
||||
import router.v1.site.review
|
||||
import router.v1.local.local
|
||||
import router.v1.ops.ops
|
||||
import router.v1.social.social
|
||||
import router.v1.social.oauth
|
||||
import router.v1.agent.kakao
|
||||
@ -47,11 +48,6 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="Web4Ai API", lifespan=lifespan)
|
||||
|
||||
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
|
||||
#
|
||||
# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면
|
||||
# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을
|
||||
# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다.
|
||||
# 운영은 실제 도메인 하나만 적으면 된다.
|
||||
def _origins(*values: str) -> list[str]:
|
||||
seen: list[str] = []
|
||||
for value in values:
|
||||
@ -97,14 +93,7 @@ async def healthz():
|
||||
|
||||
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
|
||||
async def readyz(response: Response):
|
||||
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
|
||||
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
|
||||
|
||||
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
|
||||
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
|
||||
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
|
||||
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
|
||||
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
|
||||
"""healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다)."""
|
||||
try:
|
||||
async def _ping(s):
|
||||
await s.execute(text("SELECT 1"))
|
||||
@ -118,19 +107,18 @@ async def readyz(response: Response):
|
||||
return {"ok": False, "db": "down"}
|
||||
|
||||
|
||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
||||
# 각 도메인 라우터를 등록한다.
|
||||
app.include_router(router.v1.auth.account.router)
|
||||
app.include_router(router.v1.place.place.router)
|
||||
app.include_router(router.v1.fact.fact.router)
|
||||
app.include_router(router.v1.faq.faq.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.job.job.router)
|
||||
app.include_router(router.v1.site.site.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.booking_request.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.local.local.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.oauth.router)
|
||||
|
||||
@ -1,8 +1,4 @@
|
||||
"""사장님 에이전트 대화 — 빌더 화면의 입구.
|
||||
|
||||
★ 카카오톡 웹훅이 생겨도 이 파일은 안 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저
|
||||
같은 `runtime.chat()` 을 부르는 두 번째 입구가 된다(docs/AGENT.md).
|
||||
"""
|
||||
"""사장님 에이전트 대화 — 빌더 화면의 입구."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
@ -27,10 +23,7 @@ _STATUS = {
|
||||
|
||||
|
||||
class Confirm(BaseModel):
|
||||
"""직전 답의 확인 버튼이 그대로 돌려보내는 값.
|
||||
|
||||
★ 서버는 이 값을 믿지 않는다 — 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
|
||||
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다."""
|
||||
"""직전 답의 확인 버튼이 그대로 돌려보내는 값."""
|
||||
|
||||
tool: str = Field(min_length=1, max_length=40)
|
||||
args: dict = {}
|
||||
@ -43,7 +36,7 @@ class Req_Chat(BaseModel):
|
||||
|
||||
@router.get("/status")
|
||||
async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||
"""대화창을 열 수 있는지. 키가 없으면 화면은 자리를 두고 입력만 죽인다."""
|
||||
"""대화창을 열 수 있는지."""
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return {"enabled": runtime.is_configured()}
|
||||
|
||||
|
||||
@ -1,9 +1,4 @@
|
||||
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
|
||||
|
||||
★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은
|
||||
자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면
|
||||
누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다.
|
||||
"""
|
||||
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
@ -26,14 +21,14 @@ def private_response(response: Response):
|
||||
|
||||
@router.get("/link")
|
||||
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||
"""연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다."""
|
||||
"""연결 상태."""
|
||||
private_response(response)
|
||||
return await service.state(UUID(user.user_id))
|
||||
|
||||
|
||||
@router.post("/link/code")
|
||||
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||
"""일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다."""
|
||||
"""일회용 코드를 낸다."""
|
||||
private_response(response)
|
||||
try:
|
||||
return await service.issue_code(UUID(user.user_id))
|
||||
|
||||
@ -8,7 +8,7 @@ from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
||||
# 라우터(MVC 의 컨트롤러).
|
||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
|
||||
@ -15,11 +15,7 @@ class Req_Login(AuthProtocol):
|
||||
|
||||
|
||||
class Req_Signup(AuthProtocol):
|
||||
"""id/pw 가입. 가입 = 계정 1개다.
|
||||
|
||||
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
|
||||
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
|
||||
**중복 판정용** 이다."""
|
||||
"""id/pw 가입."""
|
||||
|
||||
id: str = ""
|
||||
password: str = ""
|
||||
@ -28,10 +24,7 @@ class Req_Signup(AuthProtocol):
|
||||
|
||||
|
||||
class Req_GoogleLogin(AuthProtocol):
|
||||
"""구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다.
|
||||
|
||||
필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 —
|
||||
`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다."""
|
||||
"""구글 로그인."""
|
||||
|
||||
credential: str = ""
|
||||
|
||||
@ -43,11 +36,11 @@ class Res_Login(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_UpdateMe(AuthProtocol):
|
||||
# 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
|
||||
# 본인 정보 수정.
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
contact_number: Optional[str] = None
|
||||
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
|
||||
password: Optional[str] = None # 비밀번호 변경(옵션).
|
||||
|
||||
|
||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||
@ -62,6 +55,5 @@ class Res_Me(Res_WebPacketProtocol):
|
||||
email: Optional[str] = None
|
||||
contact_number: Optional[str] = None
|
||||
role: UserRole = UserRole.USER
|
||||
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
|
||||
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
|
||||
# 이 계정이 무엇으로 로그인하는가.
|
||||
provider: AuthProvider = AuthProvider.LOCAL
|
||||
|
||||
@ -11,7 +11,7 @@ from .protocol import (
|
||||
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"}})
|
||||
|
||||
|
||||
|
||||
@ -13,9 +13,7 @@ class FactProtocol(WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_UpsertFact(FactProtocol):
|
||||
"""fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다.
|
||||
|
||||
★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다."""
|
||||
"""fact 기록."""
|
||||
|
||||
key: str = ""
|
||||
value: Optional[str] = None
|
||||
@ -26,14 +24,7 @@ class Req_UpsertFact(FactProtocol):
|
||||
|
||||
|
||||
class Req_ExtractFacts(FactProtocol):
|
||||
"""사장님이 붙여넣은 원문에서 fact 를 뽑는다.
|
||||
|
||||
★ 왜 이 입구가 필요한가 (2026-08-31)
|
||||
TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이
|
||||
전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다.
|
||||
폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다.
|
||||
|
||||
★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다."""
|
||||
"""사장님이 붙여넣은 원문에서 fact 를 뽑는다."""
|
||||
|
||||
text: str = ""
|
||||
|
||||
@ -48,20 +39,17 @@ class Res_ExtractedFact(WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_ExtractFacts(Res_WebPacketProtocol):
|
||||
"""뽑힌 것과 버려진 것을 **둘 다** 돌려준다.
|
||||
|
||||
★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를
|
||||
화면에서 바로 확인할 수 있어야 한다."""
|
||||
"""뽑힌 것과 버려진 것을 **둘 다** 돌려준다."""
|
||||
|
||||
stored: int = 0
|
||||
rejected: int = 0
|
||||
facts: list[Res_ExtractedFact] = []
|
||||
# (버린 항목, 사유). 모델이 지어낸 값·스키마 밖 key 가 여기로 온다.
|
||||
# (버린 항목, 사유).
|
||||
rejections: list[list[str]] = []
|
||||
|
||||
|
||||
class Req_TransitionFact(FactProtocol):
|
||||
"""검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다."""
|
||||
"""검증 상태 전이."""
|
||||
|
||||
status: FactStatus = FactStatus.VERIFIED
|
||||
value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시)
|
||||
@ -75,8 +63,7 @@ class FactData(WebPacketProtocol):
|
||||
unit_id: Optional[uuid.UUID] = None
|
||||
key: str
|
||||
value: Optional[str] = None
|
||||
# ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용,
|
||||
# FactService._attach_summaries 가 요청마다 계산해 붙인다).
|
||||
# 캔버스 미리보기용 축약문.
|
||||
summary: Optional[str] = None
|
||||
unit: Optional[str] = None
|
||||
source_type: SourceType
|
||||
@ -96,20 +83,20 @@ class FieldSpecData(WebPacketProtocol):
|
||||
type: str
|
||||
scope: str
|
||||
required: bool
|
||||
critical: bool # ★ 미검증 노출 금지 대상
|
||||
critical: bool # 미검증 노출 금지 대상
|
||||
allow_llm: bool
|
||||
unit: Optional[str] = None
|
||||
|
||||
|
||||
class Res_FactList(Res_WebPacketProtocol):
|
||||
facts: list[FactData] = []
|
||||
publishable: int = 0 # ★ 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED)
|
||||
publishable: int = 0 # 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED)
|
||||
pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지)
|
||||
|
||||
|
||||
class Res_Fact(Res_WebPacketProtocol):
|
||||
fact: Optional[FactData] = None
|
||||
# 기록/전이가 무엇을 했는지. PUBLISHED_REPLACED 면 사이트 재빌드 대상이다.
|
||||
# 기록/전이가 무엇을 했는지.
|
||||
outcome: Optional[FactWriteOutcome] = None
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
||||
from services.faq_service import FaqService
|
||||
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"}})
|
||||
|
||||
|
||||
|
||||
@ -13,10 +13,7 @@ class FaqProtocol(WebPacketProtocol):
|
||||
|
||||
|
||||
class Req_CreateFaq(FaqProtocol):
|
||||
"""사장님이 직접 쓴 FAQ.
|
||||
|
||||
★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려
|
||||
승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다."""
|
||||
"""사장님이 직접 쓴 FAQ."""
|
||||
|
||||
question: str = ""
|
||||
answer: str = ""
|
||||
@ -24,9 +21,7 @@ class Req_CreateFaq(FaqProtocol):
|
||||
|
||||
|
||||
class Req_TransitionFaq(FaqProtocol):
|
||||
"""검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다.
|
||||
|
||||
CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시)."""
|
||||
"""검증 상태 전이."""
|
||||
|
||||
status: FactStatus = FactStatus.VERIFIED
|
||||
question: Optional[str] = None
|
||||
@ -40,8 +35,7 @@ class FaqData(WebPacketProtocol):
|
||||
place_id: uuid.UUID
|
||||
question: str
|
||||
answer: str
|
||||
# 근거 목록. 컬럼명은 ids 지만 copy 잡이 담는 값은 fact 의 **key** 다 —
|
||||
# 사람이 승인 화면에서 "무슨 사실로 쓴 문장인지" 읽을 수 있어야 하기 때문이다.
|
||||
# 근거 목록.
|
||||
source_fact_ids: Optional[list[str]] = None
|
||||
generated_by: SourceType
|
||||
status: FactStatus
|
||||
@ -52,11 +46,9 @@ class FaqData(WebPacketProtocol):
|
||||
|
||||
class Res_FaqList(Res_WebPacketProtocol):
|
||||
faqs: list[FaqData] = []
|
||||
# ★ 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED). FAQPage JSON-LD 는 이 건수만큼만 나간다.
|
||||
# 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED).
|
||||
publishable: int = 0
|
||||
# 승인 대기 건수 — 관리 화면의 '검토할 것' 배지.
|
||||
# fact 와 달리 UNVERIFIED 도 포함한다: FAQ 에는 '재수집 후보' 개념이 없고,
|
||||
# LLM 이 만들어 둔 UNVERIFIED 가 곧 사장님 승인 대기 큐다.
|
||||
pending_review: int = 0
|
||||
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
||||
from services.job_service import JobService
|
||||
from .protocol import Res_Job, Res_JobOps
|
||||
|
||||
# 작업 큐 라우터. 수집·비전분석·빌드는 몇 분 걸리므로 클라이언트가 여기를 폴링한다.
|
||||
# 작업 큐 라우터.
|
||||
router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ class JobData(WebPacketProtocol):
|
||||
|
||||
|
||||
class Res_Job(Res_WebPacketProtocol):
|
||||
"""잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다."""
|
||||
"""잡 상태 폴링 응답."""
|
||||
|
||||
job: Optional[JobData] = None
|
||||
|
||||
|
||||
@ -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 반경)")
|
||||
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
|
||||
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다."""
|
||||
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다."""
|
||||
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,
|
||||
service: LocalContentService = Depends(), _user=Depends(RequireOwner),
|
||||
):
|
||||
"""숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다."""
|
||||
"""숨긴 항목은 재수집이 되살리지 않는다."""
|
||||
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))
|
||||
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@ class ResLocalContentList(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class ResSyncPlace(Res_WebPacketProtocol):
|
||||
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지."""
|
||||
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤)."""
|
||||
|
||||
festivals: int = 0
|
||||
attractions: int = 0
|
||||
@ -76,15 +76,13 @@ class ResSyncPlace(Res_WebPacketProtocol):
|
||||
|
||||
|
||||
class ResLocalGuide(Res_WebPacketProtocol):
|
||||
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다
|
||||
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
|
||||
"""에디터 캔버스가 그리는 지역 가이드."""
|
||||
|
||||
attractions: list[dict[str, Any]] = []
|
||||
restaurants: list[dict[str, Any]] = []
|
||||
festivals: 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]] = []
|
||||
synced_at: str | None = None
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
||||
from services.media_service import MediaService
|
||||
from .protocol import Res_MediaList
|
||||
|
||||
# 사진 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다.
|
||||
# 사진 라우터.
|
||||
router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}})
|
||||
|
||||
|
||||
|
||||
@ -15,12 +15,7 @@ class MediaProtocol(WebPacketProtocol):
|
||||
|
||||
|
||||
class MediaData(WebPacketProtocol):
|
||||
"""사진 1건.
|
||||
|
||||
★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가
|
||||
아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을
|
||||
통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다.
|
||||
origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다."""
|
||||
"""사진 1건."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@ -28,8 +23,8 @@ class MediaData(WebPacketProtocol):
|
||||
place_id: uuid.UUID
|
||||
unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결
|
||||
url: str # 우리가 보관하는 접근 URL
|
||||
origin_url: Optional[str] = None # ★ 수집 원본 이미지 URL — 권리 판단의 근거
|
||||
source_type: SourceType # ★ OWNER 업로드 / CRAWL 수집 — 발행 필터 키
|
||||
origin_url: Optional[str] = None # 수집 원본 이미지 URL — 권리 판단의 근거
|
||||
source_type: SourceType # OWNER 업로드 / CRAWL 수집 — 발행 필터 키
|
||||
source_url: Optional[str] = None # 수집한 페이지 URL
|
||||
label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실")
|
||||
alt_text: Optional[str] = None # Vision 생성 alt
|
||||
@ -40,13 +35,11 @@ class MediaData(WebPacketProtocol):
|
||||
sort_order: int = 0
|
||||
created_at: Optional[datetime] = None
|
||||
# DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'.
|
||||
# 판단 기준을 services/snapshot.py 와 똑같이 맞춘다(승인 + alt 있음). 화면이 "왜 이 사진은
|
||||
# 안 나오나"를 사장님에게 설명할 수 있어야 하는데, 그 답이 상태 하나로는 안 나오기 때문이다.
|
||||
publishable: bool = False
|
||||
|
||||
|
||||
class Res_MediaList(Res_WebPacketProtocol):
|
||||
media: list[MediaData] = []
|
||||
publishable: int = 0 # ★ 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음)
|
||||
publishable: int = 0 # 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음)
|
||||
pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지)
|
||||
crawled: int = 0 # ★ 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수
|
||||
crawled: int = 0 # 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수
|
||||
|
||||
@ -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
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@ -26,8 +9,7 @@ from common.logger import LOG
|
||||
|
||||
router = APIRouter(prefix="/v1/image", tags=["Image"])
|
||||
|
||||
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다.
|
||||
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
|
||||
# 우리 수집기가 사진을 가져오는 곳.
|
||||
_ALLOWED_SUFFIXES = (
|
||||
".pstatic.net",
|
||||
"tong.visitkorea.or.kr",
|
||||
@ -37,7 +19,7 @@ _ALLOWED_SUFFIXES = (
|
||||
|
||||
_MAX_BYTES = 8 * 1024 * 1024
|
||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다.
|
||||
# 기본 UA 를 거절하는 CDN 이 있다.
|
||||
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
|
||||
|
||||
|
||||
@ -57,8 +39,7 @@ def _allowed(url: str) -> bool:
|
||||
|
||||
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
|
||||
async def relay(url: str = Query(min_length=8, max_length=2000)):
|
||||
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
|
||||
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다)."""
|
||||
"""인증을 요구하지 않는다."""
|
||||
if not _allowed(url):
|
||||
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user