[chore] solution,admin,ontology: 코드 주석을 한 줄로 — 히스토리 주석 삭제

여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다.

- ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제
- 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다
- eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다

파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이).
site·frontend·admin tsc, site vitest 105 passed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-09-28 16:05:19 +09:00
parent c6908629f3
commit 11d30bb3d1
479 changed files with 3337 additions and 13091 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />},

View File

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

View File

@ -9,8 +9,7 @@ import {customFetch} from '@/api/mutator/custom-fetch';
import {toast} from 'sonner';
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 원문 그대로).`);

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,49 @@
존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다.
- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
- 레이아웃은 `basic`과 `paper` 둘만 남겼다. 연결 안 된 레이아웃 5개, 배치 고르기, 서체 선택, 빌더 캔버스를 지웠다.
- 템플릿을 바꾸면 이전 템플릿이 켠 섹션은 꺼지고, 빌더에 그 안내가 뜬다.
- 바뀐 동작: 저장된 모양(look)과 배치 선택은 무시한다. 레트로 사진은 캐러셀에서 그리드로 바뀐다.
병원은 날씨·주변 정보가 기본으로 꺼진다. 모두 재발행할 때부터 적용된다.
- 프로젝트 코드 주석을 한 줄로 줄이고 히스토리 주석을 지웠다(파일 478개). 파이썬은 정리 전후 문법
트리가 같은지, TS는 주석을 뺀 토큰이 같은지 대조했다.
구조와 새 템플릿 추가 방법은 [TEMPLATES.md](TEMPLATES.md).
**검증** — shared·site·frontend·admin `tsc`, site `eslint`·`vitest` 105개, frontend `vite build` 통과.
백엔드는 DB 없이 도는 테스트 41개 통과, DB가 필요한 테스트는 로컬 DB 접속 문제로 못 돌렸다.
## 2026-09-23 — 개발자 전용 사이트관리·유저관리를 solution 앱에 경량으로
admin/frontend(:9801)를 새 메뉴로 키우려면 새 도메인이 필요하고 아직 그럴 기능도 안
갖춰졌다(대표 지시) — 그래서 대신 solution 앱(:9800)에 얹었다. `UserRole.DEVELOPER` 게이트
하나로, 회사 스코프를 걷어낸(2026-09-08, DECISIONS.md) 전 계정 사이트·유저 목록(읽기 전용)을 본다.
- **백엔드**: `router/v1/ops/ops.py`(`GET /v1/ops/sites`, `GET /v1/ops/users`, 전부
`RequireDeveloper`) + `services/ops_service.py` + `crud/site_crud.py:list_all_sites` /
`crud/user_crud.py:list_users`. 유저 목록은 USER/OWNER 만 — 개발자 계정은 여기서도 뺀다
(`UserRole` 주석 원칙을 내부 화면에도 지킨다).
- **프론트**: `pages/OpsSitesPage.tsx` · `OpsUsersPage.tsx`(`/ops/sites` · `/ops/users`).
`AppShell.tsx` 의 기본 nav(`OWNER_NAV`)에 `role===DEVELOPER` 일 때만 두 줄을 더 붙인다.
★ 이 문자열은 role 과 무관하게 사장님에게 나가는 번들에도 실린다(런타임 조건부 렌더일 뿐,
빌드 타임에 갈라지지 않는다) — AppShell 주석의 "메뉴가 섞이면 새어 나간다"가 그대로 적용된다.
실제 데이터 접근은 백엔드 게이트가 막으므로 새는 것은 경로 이름 정도다.
- 액션(재발행·상태 토글·강제 로그아웃 등)은 다음 단계 — 이번엔 조회만.
**검증** — DB 접속이 안 되는 환경이라 pytest 는 못 돌렸다: `app.openapi()` 로 라우터 임포트·
스키마 생성 확인, `scripts/export_openapi.py` → `orval` 코드젠 성공, 프론트 `tsc --noEmit` ·
`eslint src` 통과. 실제 DB 조회 동작은 미검증 — docker compose 로 띄운 뒤 확인 필요.
## 2026-09-28 — 템플릿 정의를 한 파일로 모았다
템플릿 정보가 빌더, 렌더러, 백엔드에 따로따로 적혀 있어서 서로 어긋나 있었다. 백엔드 기본값이
존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
달랐다. 병원 "클린" 템플릿은 이름과 실제 모양이 맞지 않았다.
- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.

View File

@ -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';
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'] : [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
token_expires_at = Column(DateTime(timezone=True), nullable=True)
@ -469,11 +368,7 @@ class place_posts(MainTableMixin, MAIN_BASE):
class place_reviews(MainTableMixin, MAIN_BASE):
"""손님이 남긴 이용 후기.
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
"""손님이 남긴 이용 후기."""
__tablename__ = "place_reviews"
__table_args__ = (
@ -491,8 +386,7 @@ class place_reviews(MainTableMixin, MAIN_BASE):
class sites(MainTableMixin, MAIN_BASE):
"""발행 대상 사이트. 사업장당 1개.
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
"""발행 대상 사이트."""
__tablename__ = "sites"
__table_args__ = (
@ -504,15 +398,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(solution/shared/src/data/templates.json). NULL이면 업종 기본 템플릿으로 굽는다.
# 템플릿 id(solution/shared/src/data/templates.json).
template_id = Column(String(100), nullable=True)
# 색·섹션(순서·on/off·본문). 내용 키는 프론트가 소유하므로 jsonb로 통째로 담는다.
# 색·섹션(순서·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)
@ -536,14 +429,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)
@ -559,17 +445,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__ = (
@ -592,10 +468,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__ = (
@ -608,13 +481,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"
@ -630,18 +503,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__ = (
@ -658,9 +520,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)
@ -696,13 +556,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)
@ -714,8 +568,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)

View File

@ -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 # 전송 성공

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,4 @@
"""업종·템플릿 정의. 프론트와 같은 파일(solution/shared/src/data/templates.json)을 읽는다."""
"""업종·템플릿 정의."""
import json
from pathlib import Path

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,4 @@
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다).
★ SNS 게재(social_config)와 파일을 가른 이유는 도메인이 다르기 때문이다.
SNS 게재는 **되돌릴 수 없는** 대외 발화이고, 에이전트는 사장님이 자기 사이트를
고치는 창구다. 승인 강도도 보관하는 것도 다르다 — 설정이 한 파일에 섞이면
"이 값이 무엇을 여는가" 가 흐려진다.
"""
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다)."""
from pydantic_settings import BaseSettings
@ -14,30 +8,19 @@ from config.config_models import _BASE
class AgentConfig(BaseSettings):
model_config = _BASE
# 카카오톡 채널 공개 ID(`_xaBcD` 형태). 사장님이 채널을 찾아 코드를 입력해야 하므로
# ★ 이 값이 없으면 연결 화면 자체를 열지 않는다 — 어디에 코드를 칠지 말해 줄 수
# 없는데 코드만 발급하면, 사장님에게는 고장난 화면이다(Threads 카드와 같은 규칙).
# 카카오톡 채널 공개 ID(`_xaBcD` 형태).
KAKAO_CHANNEL_PUBLIC_ID: str = ""
# 코드 수명. 사장님이 화면을 보고 카톡을 열어 치는 동작이라 짧아도 된다.
# 코드 수명.
KAKAO_LINK_CODE_TTL_MIN: int = 10
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다.
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다.
KAKAO_LINK_MAX_ATTEMPTS: int = 5
# 빌더 화면의 대화창. 2026-09-21 에 한 번 닫았다가(카카오 채널 보류) 채널 인증이
# 끝나 다시 열었다(2026-09-22).
# ★ 이 값이 "1" 이어도 **LLM 키가 없으면 안 열린다**(runtime.is_configured 가 둘 다 본다) —
# 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다.
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
# 빌더 화면의 대화창.
AGENT_CHAT_ENABLED: str = "1"
# ★ 카카오 웹훅 인증. **오픈빌더는 서명을 주지 않는다** — URL 만 알면 누구나 이 엔드포인트를
# 때릴 수 있고, user.id 를 아무 값이나 넣으면 **그 사장님 행세를 한다.** 신원 연결
# (owner_kakao_links)이 통째로 무의미해진다.
# 그래서 이 값이 없으면 **엔드포인트 자체를 띄우지 않는다**(404). 반쯤 열린 상태를
# 만들지 않는 것은 Threads 연결과 같은 규칙이다.
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
# 카카오 웹훅 인증.
KAKAO_WEBHOOK_SECRET: str = ""
# 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다.
# 우리 봇이 맞는지 한 겹 더 본다.
KAKAO_BOT_ID: str = ""
@ -58,6 +41,6 @@ def kakao_link_enabled() -> bool:
def channel_url() -> str:
"""사장님이 눌러서 채널로 가는 주소. 공개 ID 가 없으면 빈 문자열이다."""
"""사장님이 눌러서 채널로 가는 주소."""
public_id = get("KAKAO_CHANNEL_PUBLIC_ID")
return f"http://pf.kakao.com/{public_id}" if public_id else ""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
@ -32,18 +32,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:
@ -66,10 +55,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)

View File

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

View File

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

View File

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

View File

@ -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(
@ -153,8 +142,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:
@ -167,7 +155,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))
@ -198,8 +186,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)

View File

@ -12,12 +12,7 @@ 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]:
@ -50,8 +45,7 @@ class ISiteCRUD(ABC):
@abstractmethod
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
pass
@abstractmethod
@ -113,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)
@ -128,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
@ -164,8 +146,7 @@ class SiteCRUD(ISiteCRUD):
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 필터가 없다 — 소유자 계정 정보를 같이 얹는다.
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
try:
where = places.deleted == False # noqa: E712
@ -193,7 +174,7 @@ class SiteCRUD(ISiteCRUD):
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()
@ -216,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
@ -259,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)
@ -330,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())

View File

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

View File

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

View File

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

View File

@ -23,8 +23,6 @@ def _place_count_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]:
@ -62,7 +60,7 @@ class IUserCRUD(ABC):
async def list_users(
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
) -> Tuple[ErrorType, list, int]:
"""내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수)."""
"""내부 운영(DEVELOPER) 전용 전체 계정 목록."""
pass
@ -81,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)
@ -100,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)
@ -173,8 +169,7 @@ class UserCRUD(IUserCRUD):
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 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
"""role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
try:
where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712
if search:

View File

@ -48,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:
@ -98,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"))
@ -119,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)

View File

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

View File

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

View File

@ -1,23 +1,4 @@
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
`version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을
붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다.
★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고,
`userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결
(`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다.
시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은
Threads 연결과 같은 규칙이다.
★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는
**말없이 실패하는 봇**이 된다.
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다.
콜백 주소는 **1분 · 1회**만 유효하다.
→ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22):
필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본
1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다.
"""
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**."""
import asyncio
import hmac
@ -31,10 +12,9 @@ from services.agent import channel
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 —
# 침묵보다 "잠시 뒤 다시" 가 낫다.
# 콜백이 꺼져 있을 때만 쓰는 상한.
DEADLINE_SEC = 4.5
# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다.
# 콜백이 켜져 있을 때의 상한.
CALLBACK_DEADLINE_SEC = 45.0
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
@ -43,11 +23,10 @@ _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
def _reply(text: str, quick_replies=None) -> dict:
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
"""오픈빌더 스킬 응답(SkillResponse)."""
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
if quick_replies:
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
# 바로가기는 최대 10개.
payload["quickReplies"] = [
{"label": label, "action": "message", "messageText": label} for label in quick_replies[:10]
]
@ -57,14 +36,14 @@ def _reply(text: str, quick_replies=None) -> dict:
def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None:
expected = config.webhook_secret()
if not expected:
# 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다.
# 설정이 없으면 이 기능은 존재하지 않는다.
raise HTTPException(404)
given = header_secret or secret_in_path or ""
if not hmac.compare_digest(given, expected):
LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절")
raise HTTPException(404)
# 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다.
# 한 겹 더.
bot_id = config.get("KAKAO_BOT_ID")
if bot_id and (body.get("bot") or {}).get("id") != bot_id:
LOG.w("[agent/kakao] 다른 봇의 요청 — 거절")
@ -72,7 +51,7 @@ def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
"""대화 한 턴을 SkillResponse 로."""
try:
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
except asyncio.TimeoutError:
@ -85,10 +64,7 @@ async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
"""답을 다 만든 뒤 콜백 주소로 보낸다.
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
"""답을 다 만든 뒤 콜백 주소로 보낸다."""
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
@ -104,13 +80,12 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
utterance = request.get("utterance") or ""
speaker = (request.get("user") or {}).get("id") or ""
if not speaker:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
# 발화자를 모르면 누구의 가게인지도 모른다.
return _reply("사용자를 확인하지 못했어요.")
# ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
# 콜백이 켜져 있으면 5초 벽을 넘을 수 있다.
callback_url = request.get("callbackUrl")
# ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 —
# 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 콜백 설정이 그 블록에 없어 조용히 동기로 돈다.
# "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다.
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
f"block={(request.get('block') or {}).get('name')!r}")
if callback_url:
@ -126,7 +101,7 @@ async def webhook(
tasks: BackgroundTasks,
x_agent_secret: str | None = Header(default=None),
):
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
"""헤더로 시크릿을 받는 쪽."""
body = await request.json()
_authorize(None, x_agent_secret, body)
return await _handle(body, tasks)
@ -139,9 +114,7 @@ async def webhook_with_path_secret(
tasks: BackgroundTasks,
x_agent_secret: str | None = Header(default=None),
):
"""헤더를 못 넣는 경우의 대안.
★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다."""
"""헤더를 못 넣는 경우의 대안."""
body = await request.json()
_authorize(secret, x_agent_secret, body)
return await _handle(body, tasks)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,7 +41,7 @@ async def list_place_contents(place_id: uuid.UUID, service: LocalContentService
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
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))

View File

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

View File

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

View File

@ -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) — 결론이 '불가'면 통째로 빠질 사진 수

View File

@ -1,21 +1,4 @@
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.
★ 왜 필요한가 (2026-09-15, 실측)
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은
`Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼**
`toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는
사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다.
★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다:
· https 만
· 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN)
· 응답이 이미지가 아니면 거절, 크기 상한
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다
"""
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다."""
import ipaddress
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="중계할 수 없는 주소입니다")

View File

@ -6,9 +6,6 @@ from services.ops_service import OpsService
from .protocol import Res_OpsSites, Res_OpsUsers
# 내부 운영(개발자) 전용 조회 — 회사 스코프를 걷어낸 전 계정 사이트·유저 목록.
# admin/frontend(:9801)를 새 도메인으로 키우는 대신 solution 앱(:9800)에 경량으로 얹은 것이다
# (2026-09-23 기획). RequireDeveloper 가 유일한 문이다 — OWNER(사장님)는 관리할 하위 계정이
# 없어(2026-09-08 회사/테넌트 걷어냄) 이 화면을 볼 이유가 없다.
router = APIRouter(prefix="/v1/ops", tags=["Ops"])

View File

@ -7,9 +7,7 @@ from common.models.gmodel import Res_PageProtocol, WebPacketProtocol
class OpsSiteData(WebPacketProtocol):
"""전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.
★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다."""
"""전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자."""
place_id: uuid.UUID
name: str
@ -34,7 +32,7 @@ class Res_OpsSites(Res_PageProtocol):
class OpsUserData(WebPacketProtocol):
"""전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다."""
"""전 계정 목록의 한 줄."""
user_id: uuid.UUID
login_id: str

View File

@ -31,7 +31,7 @@ from .protocol import (
Res_UnitList,
)
# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
# 사업장 라우터.
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
@ -61,8 +61,7 @@ async def search_places_public(
service: PlaceService = Depends(),
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
):
# ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서,
# 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다.
# 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다.
client_ip = request.client.host if request.client else "unknown"
return RemoveNoneResponse(await service.search_places_public(q, client_ip))

View File

@ -15,24 +15,18 @@ class PlaceProtocol(WebPacketProtocol):
class Req_CreatePlace(PlaceProtocol):
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
# 상호명 하나로 시작한다.
name: str = ""
category: PlaceCategory = PlaceCategory.LODGING
# ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
# 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place).
class Req_VerifyPlace(PlaceProtocol):
"""카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).
★ 이 단계를 통과해야 수집이 열린다.
external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다.
그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다."""
"""카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정)."""
source: ExternalPlaceSource = ExternalPlaceSource.NAVER
external_place_id: str = ""
# 외부 장소 DB 가 함께 준 업체 홈페이지 URL. 있으면 공식 홈페이지 채널로 자동 등록한다.
# (네이버 지역검색 응답의 link 가 이것 — Perplexity 로는 잘 안 잡히는 채널이라 여기서 건진다.)
# 외부 장소 DB 가 함께 준 업체 홈페이지 URL.
place_url: Optional[str] = None
road_address: Optional[str] = None
address: Optional[str] = None
@ -40,42 +34,24 @@ class Req_VerifyPlace(PlaceProtocol):
latitude: Optional[Decimal] = None
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
# 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다.
# 외부 장소 DB 의 분류 문자열(후보의 category_name).
category_name: Optional[str] = None
class Req_VerifyPlaceByUrl(PlaceProtocol):
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다.
★ 왜 이 경로가 필요한가
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
"""
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다."""
url: str = ""
# ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
#
# 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지
# 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 —
# 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면
# 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다).
#
# False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는
# 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19).
# 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
reuse_existing: bool = True
class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
# 주인은 못 바꾼다(위 Req_CreatePlace 주석).
name: Optional[str] = None
status: Optional[PlaceStatus] = None
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
# 미니 블로그 승인 메일 수신 주소.
notify_email: Optional[str] = None
@ -108,8 +84,8 @@ class PlaceData(WebPacketProtocol):
longitude: Optional[Decimal] = None
region_code: Optional[str] = None
verified_at: Optional[datetime] = None
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
content_updated_at: Optional[datetime] = None # 노출값 변경 시각 — 개별 재빌드 대상 판별
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소.
created_at: Optional[datetime] = None
@ -132,7 +108,7 @@ class LinkData(WebPacketProtocol):
title: Optional[str] = None
discovered_by: SourceType
discovered_at: Optional[datetime] = None
confirmed_at: Optional[datetime] = None # ★ NULL = 크롤링 대상 아님
confirmed_at: Optional[datetime] = None # NULL = 크롤링 대상 아님
class Res_PlaceList(Res_PageProtocol):
@ -161,31 +137,29 @@ class Res_Link(Res_WebPacketProtocol):
class Req_StartCollect(PlaceProtocol):
"""수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다."""
"""수집 시작."""
# 확정된 채널 URL 만 크롤링한다. 비우면 이 사업장의 확정 링크 전체.
# 확정된 채널 URL 만 크롤링한다.
link_ids: list[uuid.UUID] = []
# 이미 확보한 fact 를 다시 긁을지. 기본은 아니오(외부 API 호출 비용을 아낀다).
# 이미 확보한 fact 를 다시 긁을지.
force: bool = False
# 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지.
# 기본 False — 사용자가 화면에서 명시적으로 선택한 회차에만 유료 검색을 실행한다.
discover_channels: bool = False
class Res_StartCollect(Res_WebPacketProtocol):
"""수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다."""
"""수집 잡 적재 결과."""
job_id: Optional[uuid.UUID] = None
status: Optional[JobStatus] = None
# 이미 같은 사업장 수집이 돌고 있어 새로 만들지 않았다면 False (기존 잡의 id 를 돌려준다).
created: bool = True
confirmed_links: int = 0
class Req_StartVision(PlaceProtocol):
"""사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다."""
"""사진 분석 시작."""
# 이미 분석된 사진도 다시 태울지. 기본은 아니오(같은 사진 재분석은 요금만 나간다).
# 이미 분석된 사진도 다시 태울지.
force: bool = False
@ -196,9 +170,9 @@ class Res_StartVision(Res_WebPacketProtocol):
pending_media: int = 0 # 분석 대상 사진 수
# ---- 동일 업소 후보 (UI 가 사람에게 고르게 한다) ----------------------------
# 동일 업소 후보 (UI 가 사람에게 고르게 한다)
class PlaceCandidate(WebPacketProtocol):
"""외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다."""
"""외부 장소 DB 에서 찾은 후보 1건."""
external_place_id: Optional[str] = None # 네이버는 안 준다
name: str = ""
@ -209,14 +183,11 @@ class PlaceCandidate(WebPacketProtocol):
longitude: Optional[Decimal] = None
category_name: Optional[str] = None
place_url: Optional[str] = None # 업체 홈페이지 — 확정 시 공식 채널로 등록된다
naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스. 없으면 UI가 URL 입력을 요청한다
naver_place_url: Optional[str] = None # 자동 발견한 네이버 플레이스.
class Res_VerifyCandidates(Res_WebPacketProtocol):
"""동일 업소 후보 목록.
★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내
UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다."""
"""동일 업소 후보 목록."""
source: Optional[ExternalPlaceSource] = None
outcome: str = "" # matched | ambiguous | no_candidate
@ -225,40 +196,28 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
candidates: list[PlaceCandidate] = []
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----------------------------------------
# 공개 상호명 검색 (랜딩 첫 화면)
class PlaceSearchItem(WebPacketProtocol):
"""공개 검색 결과 1건.
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
"""공개 검색 결과 1건."""
name: str = ""
road_address: Optional[str] = None
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
# 추정 업종.
category: Optional[PlaceCategory] = None
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
# 자동으로 찾은 네이버 플레이스 주소.
naver_place_url: Optional[str] = None
class Res_PlaceSearch(Res_WebPacketProtocol):
"""상호명 공개 검색 결과.
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다."""
"""상호명 공개 검색 결과."""
source: Optional[ExternalPlaceSource] = None
items: list[PlaceSearchItem] = []
class Req_StartCopy(PlaceProtocol):
"""소개문·FAQ 생성 시작.
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
"""소개문·FAQ 생성 시작."""
resume: bool = False

View File

@ -1,11 +1,4 @@
"""발행본의 예약 요청 폼 → 사장님 메일.
★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고,
보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다.
★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은
**연락 요청**이고, 화면도 그렇게 말한다.
"""
"""발행본의 예약 요청 폼 → 사장님 메일."""
import time
import uuid
from collections import defaultdict, deque
@ -19,11 +12,11 @@ from services.booking_request_service import BookingRequestService
router = APIRouter(prefix="/v1/site", tags=["Site"])
# 한 아이피가 한 시간에 보낼 수 있는 통수. 같은 업장으로 몰리는 것도 따로 센다.
# 한 아이피가 한 시간에 보낼 수 있는 통수.
IP_LIMIT_PER_HOUR = 5
PLACE_LIMIT_PER_HOUR = 30
WINDOW_SEC = 3600
# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. 봇은 즉시 제출한다.
# 폼을 연 뒤 이만큼은 지나야 사람으로 친다.
MIN_ELAPSED_MS = 1500
_hits: dict[str, deque] = defaultdict(deque)
@ -49,7 +42,7 @@ class ReqBookingRequest(BaseModel):
guests: str | None = Field(default=None, max_length=30)
message: str | None = Field(default=None, max_length=1000)
consent: bool
# 봇 잡이. 사람에게는 안 보이는 칸이라 값이 있으면 사람이 아니다.
# 봇 잡이.
company: str | None = Field(default=None, max_length=100)
elapsed_ms: int = 0
@ -72,7 +65,7 @@ async def send_booking_request(
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
or (request.client.host if request.client else "unknown"))
# 봇 두 겹. 걸려도 실패로 알리지 않는다 — 무엇에 걸렸는지 알려 주면 다음 시도가 그걸 피한다.
# 봇 두 겹.
if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
LOG.w("[booking-request] 봇 의심 요청을 버렸다")
return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다."))

View File

@ -1,19 +1,4 @@
"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: docs/MINI_BLOG.md
★ /approve 는 로그인이 없다. 링크에 실린 토큰 하나가 신원이고, 누르는(GET) 순간 바로
승인된다(2026-09-17, 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). ★★ 이건
메일 클라이언트의 링크 미리 열기(아웃룩 안전 링크 스캔 등)에 그대로 노출된다는 뜻이다 —
예전에는 이걸 막으려고 GET=확인 화면 / POST=승인 확정으로 나눴었다. 사장님이 그 위험을
알고도 즉시 승인을 택했다.
★ "수정하기" 는 반대로 로그인 흐름을 탄다 — 메일에 그날 자정(KST)까지만 사는 접근 토큰을
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve
(이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션,
"바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 —
승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다.
"""
"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면."""
import html
from datetime import date
from uuid import UUID
@ -44,9 +29,7 @@ h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}}
def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse:
# ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 +
# slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라
# escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
# redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
redirect = (
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
if redirect_url else ""
@ -67,9 +50,7 @@ async def approve_page(t: str = Query(min_length=8, max_length=200), service: Po
if not result["success"]:
return _expired_page()
redirect_url = result.get("redirect_url")
# ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
# 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다
# 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다.
# 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
extra = (
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"

View File

@ -105,7 +105,7 @@ class PublishLogData(WebPacketProtocol):
class Req_SiteTemplate(SiteProtocol):
"""템플릿 선택 저장. 업종 허용 목록(solution/shared/src/data/templates.json)에 없는 id는 거절한다."""
"""템플릿 선택 저장."""
template_id: str = ""
@ -171,7 +171,7 @@ class Req_SiteSlug(SiteProtocol):
class Res_SlugCheck(Res_WebPacketProtocol):
"""주소 사용 가능 확인. UI 가 타이핑 중에 호출한다."""
"""주소 사용 가능 확인."""
available: bool = False
# 불가 사유(services/site_slug 의 REASON_*): INVALID_LENGTH / INVALID_FORMAT / RESERVED / TAKEN.
@ -181,7 +181,7 @@ class Res_SlugCheck(Res_WebPacketProtocol):
class Res_SiteSlug(Res_WebPacketProtocol):
"""주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다."""
"""주소 저장 결과."""
site: Optional[SiteData] = None
reason: Optional[str] = None

View File

@ -1,9 +1,4 @@
"""이용 후기 접수 — 발행본에서 손님이 남긴다.
★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 —
허니팟 · 최소 체류시간 · 레이트리밋.
★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다.
"""
"""이용 후기 접수 — 발행본에서 손님이 남긴다."""
import uuid
from fastapi import APIRouter, Depends, Query, Request
@ -70,6 +65,5 @@ async def submit_review(body: ReqReview, request: Request, service: ReviewServic
@router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다")
async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()):
"""날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고,
화면은 붙은 뒤 이 주소로 최신을 받아 덮는다."""
"""날씨(/v1/local/weather)와 같은 공개 조회다."""
return RemoveNoneResponse(await service.list_public(place_id))

View File

@ -4,8 +4,7 @@ from router.v1.validator.dependencies import RemoveNoneResponse
from services.showcase_service import ShowcaseService
from .protocol import Res_Showcase
# 발행 사이트 쇼케이스. ★ 인증이 없다 — 랜딩(비로그인)이 부른다.
# ★ :9801(어드민 진입점)에는 마운트하지 않는다. 내부 화면이 쓸 목록이 아니다.
# 발행 사이트 쇼케이스.
router = APIRouter(prefix="/v1/showcase", tags=["Showcase"], responses={404: {"description": "Not found"}})

View File

@ -43,11 +43,7 @@ async def oauth_callback(
await service.finish(state, request.cookies.get(COOKIE), code)
ok = True
except Exception as ex: # noqa: BLE001
# ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
# 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때
# 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다
# (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다).
# ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다.
# 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
elif error:
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.

View File

@ -24,11 +24,7 @@ from config.server_configs import jwt_token_config
security = HTTPBearer()
# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------
# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아
# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다.
# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다.
# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다.
# 비밀번호 해시 (bcrypt)
def _hash_pw(pw: str) -> str:
return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
@ -48,7 +44,7 @@ async def VerifyPW(pw: str, hashed_pw: str) -> bool:
return await asyncio.to_thread(_verify_pw, pw, hashed_pw)
# ---- JWT 토큰 발급/검증 ----------------------------------------------------
# JWT 토큰 발급/검증
JWT_ALGORITHM = "HS256"
JWT_ACCESS_SECRET = jwt_token_config.access_key
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
@ -73,11 +69,7 @@ def CreateRefreshToken(subject: UserInfo) -> str:
def CreateDayPassToken(subject: UserInfo) -> str:
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.
★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만
쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로
되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다."""
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용."""
now_kst = datetime.now(timezone(timedelta(hours=9)))
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60))
@ -103,8 +95,7 @@ def DecodeRefreshToken(jwt_token: str) -> UserInfo:
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
# ---- Depends 용 토큰 검증기 ------------------------------------------------
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
# Depends 용 토큰 검증기
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
return DecodeAccessToken(credentials.credentials)
@ -113,24 +104,21 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
return DecodeRefreshToken(credentials.credentials)
# 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다.
# 최고관리자 이상(OWNER/DEVELOPER) 게이트.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.OWNER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# 개발자(내부 운영) 전용 게이트. 회사 스코프를 넘어 전 고객사 데이터를 보는 /v1/admin 에만 건다.
# OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 여기선 막힌다.
# 개발자(내부 운영) 전용 게이트.
async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if (user_info.role or 0) < UserRole.DEVELOPER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# ---- ResponseNone 처리 -----------------------------------------------------
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.
# ResponseNone 처리
def RemoveNoneValues(obj: Any) -> Any:
if isinstance(obj, dict):
return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None}
@ -140,7 +128,5 @@ def RemoveNoneValues(obj: Any) -> Any:
def RemoveNoneResponse(obj) -> JSONResponse:
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서
# JSON 안전한 문자열로 변환한다. content 가 이미 JSON-safe dict 이므로 표준 JSONResponse 사용
# (ORJSONResponse 는 최신 FastAPI 에서 deprecated).
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 JSON 안전한 문자열로 변환한다.
return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))

View File

@ -1,22 +1,4 @@
"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당.
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다.
등록된 잡 —
· SNS 승인 만료·중단 복구 : 5분 간격 (scheduler/jobs.sweep_social_posts)
붙을 잡 —
등록된 잡:
· Search Console (GSC_ENABLED=1, 10분마다)
· 알림 발송 스윕 (1분마다) — alert_outbox 의 PENDING 을 실제로 보낸다
· 잡 큐 정체 점검 (5분마다) — dead-letter 누적·좀비 실행·오래 밀린 PENDING 을 본다
둘 다 무조건 등록한다 — TEAMS_WEBHOOK_URL 이 비어 있으면 알림은 쌓이기만 하고 안 나간다
(services/teams_webhook.is_configured), 서버 동작에는 영향이 없다.
붙을 잡 —
· 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림)
· 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지)
"""
"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당."""
import os
from apscheduler.schedulers.asyncio import AsyncIOScheduler
@ -33,7 +15,7 @@ def _is_enabled() -> bool:
def start_scheduler():
"""lifespan startup 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다."""
"""lifespan startup 에서 호출."""
global _scheduler
if not _is_enabled():
LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)")
@ -41,12 +23,9 @@ def start_scheduler():
if _scheduler is not None:
return
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
# 한국시간 기준.
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
# ★ 1분이 아니라 5분이다. 이 스윕이 하는 일은 "만료 표시" 와 "중단된 초안 정리" 뿐이라
# 분 단위 정밀도가 필요 없고, 주기가 짧으면 쓰기 커넥션을 계속 집어 든다 —
# 실측(2026-09-14): 1분 주기로 두자 같은 컨테이너에서 도는 테스트가 커넥션을 못 받아
# TimeoutError 로 무더기 실패했다. 운영에서도 같은 풀을 발행·수집과 나눠 쓴다.
# 1분이 아니라 5분이다.
from scheduler.jobs import sweep_social_posts
_scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True)
if os.environ.get("GSC_ENABLED") == "1":
@ -60,11 +39,6 @@ def start_scheduler():
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
id="queue-health", max_instances=1, coalesce=True)
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
# LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다.
# 자동 생성은 잠시 끈다 — 사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다(2026-09-23).
# 되살리려면 위 import 에 sweep_blog_drafts 를 다시 넣고 아래 두 줄 주석을 푼다.
# _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
# id="blog-drafts", max_instances=1, coalesce=True)
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
id="blog-mail", max_instances=1, coalesce=True)
_scheduler.start()

View File

@ -1,8 +1,4 @@
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다.
(지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.)
"""
"""스케줄 잡 로직(what)."""
from common.logger import LOG
"""예약 실행 진입점. 복구 전이는 DB 조건부 UPDATE로 여러 프로세스에서도 안전하다."""
@ -22,12 +18,7 @@ async def sweep_alert_outbox():
async def sweep_queue_health():
"""잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING.
★ 왜 필요한가: 개별 잡의 DEAD 전이는 worker/runner.py 가 그 자리에서 바로 알린다. 이건
그것과 다른 신호다 — 잡 하나하나는 재시도 중(아직 DEAD 아님)인데 **큐 전체가 정체**된
경우(워커 프로세스가 죽었거나 DB 순단이 길어지는 경우)는 개별 잡 알림만으로는 안 보인다.
★ 복구되면 한 번만 알린다 — send_alert/resolve_alert 의 dedupe_key 가 그 판단을 한다."""
"""잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING."""
from crud.job_crud import JobQueue
from services import alert_service
@ -37,8 +28,7 @@ async def sweep_queue_health():
LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}")
return
# 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이
# 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다).
# 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다).
problems = []
if snap.get("dead_1h", 0) > 0:
problems.append(f"최근 1시간 dead-letter {snap['dead_1h']}건")

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