[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:
parent
c6908629f3
commit
11d30bb3d1
@ -1,10 +1,4 @@
|
|||||||
"""어드민 API. 솔루션 API(:9800)와 프로세스·포트가 갈린다.
|
"""어드민 API."""
|
||||||
|
|
||||||
도메인 코드는 solution/backend 것을 PYTHONPATH 로 쓴다 — admin 전용 라우터가 0개라
|
|
||||||
(전부 place·fact) 새로 쓰면 같은 테이블을 두 벌 구현하는 것뿐이다.
|
|
||||||
경로 접두어가 아니라 포트를 가른 이유: 접두어는 같은 프로세스라 사장님이 닿는 서버에
|
|
||||||
내부 엔드포인트가 존재한다. 포트를 가르면 아예 없다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@ -32,7 +26,7 @@ API_SERVER_START_TIME = GTime.UTCStr()
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# 크론은 :9800 담당. 여기서도 돌리면 같은 시각에 중복 실행된다.
|
# 크론은 :9800 담당.
|
||||||
yield
|
yield
|
||||||
await DB_SESSION_MNG.dispose_all()
|
await DB_SESSION_MNG.dispose_all()
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
# 어드민 API 서버 (:9801). 근거는 app.py 주석.
|
# 어드민 API 서버 (:9801).
|
||||||
# PYTHONPATH=../../solution/backend python main.py
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
// 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다.
|
// 최소 게이트 — 전체 스타일 린트가 아니라, tsc 가 못 잡는 런타임 크래시 버그만 막는다.
|
||||||
// (negodata 보일러플레이트에서 이식. 도입 계기는 로컬 const 가 동명 import 를 가려
|
|
||||||
// zustand 셀렉터가 TDZ 참조 → 프로덕션 크래시. 그 케이스는 tsc --noEmit 도 통과했다.)
|
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
|
||||||
@ -18,7 +16,7 @@ export default tseslint.config({
|
|||||||
// 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn.
|
// 훅 호출 순서 위반은 런타임 크래시라 error, deps 누락은 기존 코드가 많아 warn.
|
||||||
'react-hooks/rules-of-hooks': 'error',
|
'react-hooks/rules-of-hooks': 'error',
|
||||||
'react-hooks/exhaustive-deps': 'warn',
|
'react-hooks/exhaustive-deps': 'warn',
|
||||||
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단. 함수/타입 호이스팅은 안전하므로 허용.
|
// 선언보다 위에서 변수를 쓰는 것(TDZ) 차단.
|
||||||
'no-use-before-define': 'off',
|
'no-use-before-define': 'off',
|
||||||
'@typescript-eslint/no-use-before-define': [
|
'@typescript-eslint/no-use-before-define': [
|
||||||
'error',
|
'error',
|
||||||
|
|||||||
@ -5,14 +5,7 @@ import {getAccessToken, me} from '@/api';
|
|||||||
import {queryClient} from '@/lib/query-client';
|
import {queryClient} from '@/lib/query-client';
|
||||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||||
|
|
||||||
/**
|
/** 저장된 액세스 토큰으로 세션을 복구한다. */
|
||||||
* 저장된 액세스 토큰으로 세션을 복구한다.
|
|
||||||
*
|
|
||||||
* ★ 사장님 앱과 결정적으로 다른 점: **여기서는 실패가 곧 차단이다.**
|
|
||||||
* 빌더는 로그인 없이도 돌아야 해서 인증 실패를 삼키지만(solution/frontend/app/provider.tsx),
|
|
||||||
* 내부 운영 화면은 전부 RequireAuth 뒤에 있다. 두 정책을 한 앱에 두면 실수가 늘
|
|
||||||
* 느슨한 쪽으로 나기 때문에 앱을 갈랐다.
|
|
||||||
*/
|
|
||||||
function useRestoreSession() {
|
function useRestoreSession() {
|
||||||
const setUser = useAuthStore((s) => s.setUser);
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
const finishRestore = useAuthStore((s) => s.finishRestore);
|
const finishRestore = useAuthStore((s) => s.finishRestore);
|
||||||
@ -31,7 +24,7 @@ function useRestoreSession() {
|
|||||||
setUser(toAuthUser(res));
|
setUser(toAuthUser(res));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. RequireAuth 가 로그인으로 보낸다. */
|
/* 토큰이 죽었으면 비로그인 상태로 떨어뜨린다. */
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (alive) finishRestore();
|
if (alive) finishRestore();
|
||||||
|
|||||||
@ -10,20 +10,14 @@ import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
|||||||
import {PlaceListPage} from '@admin/pages/PlaceListPage';
|
import {PlaceListPage} from '@admin/pages/PlaceListPage';
|
||||||
import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
||||||
|
|
||||||
/**
|
/** 내부 메뉴는 여기 있다. */
|
||||||
* ★ 내부 메뉴는 여기 있다. AppShell(사장님 앱 소유)에 두면 이 경로 이름들이
|
|
||||||
* 사장님 번들에 문자열로 남는다 — 앱을 가른 이유가 사라진다.
|
|
||||||
*/
|
|
||||||
const ADMIN_NAV: NavItem[] = [
|
const ADMIN_NAV: NavItem[] = [
|
||||||
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
||||||
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
||||||
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
|
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/** 내부 운영 화면. */
|
||||||
* 내부 운영 화면. **전부 RequireAuth 뒤에 둔다** — 예외를 하나 두는 순간
|
|
||||||
* 그 예외가 기본값이 된다. 사장님 앱과 앱을 가른 이유가 이 규칙을 지키기 위해서다.
|
|
||||||
*/
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
// selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
|
// selfServe=false: 내부 운영 계정은 우리가 만들어 준다 — 가입 링크도 구글 로그인도 두지 않는다.
|
||||||
{path: '/login', element: <LoginPage selfServe={false} />},
|
{path: '/login', element: <LoginPage selfServe={false} />},
|
||||||
|
|||||||
@ -1,7 +1,4 @@
|
|||||||
/**
|
/** 사장님 앱 오리진. */
|
||||||
* 사장님 앱 오리진. 빌더는 다른 오리진이라(:3000 vs :3002) react-router Link 로 두면
|
|
||||||
* admin 안에서 404 다. 절대 URL + 새 탭으로 연다.
|
|
||||||
*/
|
|
||||||
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
|
const ORIGIN = import.meta.env.VITE_SOLUTION_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
|
export function builderUrl(params?: {placeId?: string; isNew?: boolean}): string {
|
||||||
|
|||||||
@ -9,8 +9,7 @@ import {customFetch} from '@/api/mutator/custom-fetch';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
type Status = 1 | 2 | 3;
|
type Status = 1 | 2 | 3;
|
||||||
// LocalContentType — common/enums.py 와 값을 맞춘다. WEATHER(1) 은 사업장 발행본에 실시간으로
|
// LocalContentType — common/enums.py 와 값을 맞춘다.
|
||||||
// 붙는 별도 흐름이라 이 화면에서는 다루지 않는다(services/local_content_service.get_weather).
|
|
||||||
type ContentType = 2 | 3 | 4;
|
type ContentType = 2 | 3 | 4;
|
||||||
|
|
||||||
type LocalContent = {
|
type LocalContent = {
|
||||||
@ -107,8 +106,7 @@ export function LocalContentPage() {
|
|||||||
} catch { toast.error('발행하지 못했습니다.'); }
|
} catch { toast.error('발행하지 못했습니다.'); }
|
||||||
};
|
};
|
||||||
const sync = async () => {
|
const sync = async () => {
|
||||||
// ★ 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
|
// 주변정보는 업장 단위(place_contents)다 — 지역 코드가 아니라 사업장 id 로 받는다.
|
||||||
// 이 화면의 목록은 아직 지역 캐시(local_contents)를 보여준다. 업장별 목록 화면은 다음 작업이다.
|
|
||||||
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
|
const placeId = window.prompt('사업장 ID(place_id)를 입력하세요. 사업장 목록 주소의 /places/ 뒤 값입니다.')?.trim();
|
||||||
if (!placeId) return;
|
if (!placeId) return;
|
||||||
setSyncing(true);
|
setSyncing(true);
|
||||||
@ -118,7 +116,6 @@ export function LocalContentPage() {
|
|||||||
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
|
festivals?: number; attractions?: number; restaurants?: number; changed?: boolean;
|
||||||
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
|
}>({url: `/v1/admin/local-content/place/${placeId}/sync`, method: 'POST'});
|
||||||
if (res.result?.success === false) throw new Error(res.msg);
|
if (res.result?.success === false) throw new Error(res.msg);
|
||||||
// ★ 여행코스(코스)는 2026-09-08부터 수집하지 않는다(반경을 넓혀도 데이터가 거의 없었다) — 표기에서 뺀다.
|
|
||||||
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
|
const summary = `축제 ${res.festivals ?? 0} · 관광지 ${res.attractions ?? 0} · 맛집 ${res.restaurants ?? 0}건`;
|
||||||
if (!res.changed) {
|
if (!res.changed) {
|
||||||
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
|
toast.info(`바뀐 내용이 없습니다 (${summary}, TourAPI 원문 그대로).`);
|
||||||
|
|||||||
@ -45,8 +45,7 @@ export function PlaceDetailPage() {
|
|||||||
const transition = useTransitionFact({
|
const transition = useTransitionFact({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (res) => {
|
onSuccess: (res) => {
|
||||||
// ★ 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면
|
// 백엔드는 거절도 200 + result.success=false 로 준다 — 여기서 안 걸러내면 저장되지 않은 값이 '확인됨'으로 보인다.
|
||||||
// 저장되지 않은 값이 '확인됨'으로 보인다.
|
|
||||||
if (res.result?.success === false) {
|
if (res.result?.success === false) {
|
||||||
notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.');
|
notifyApiError({data: res}, '허용되지 않는 상태 전이입니다.');
|
||||||
return;
|
return;
|
||||||
@ -177,8 +176,7 @@ export function PlaceDetailPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* ★ 재수집은 오른쪽 열 맨 위다. 이 화면에 온 사장님의 두 가지 용건이
|
{/* 재수집은 오른쪽 열 맨 위다. */}
|
||||||
"확인 대기 값을 처리한다"(왼쪽)와 "값을 다시 가져온다"(여기)라서다. */}
|
|
||||||
<RecollectPanel placeId={placeId} />
|
<RecollectPanel placeId={placeId} />
|
||||||
|
|
||||||
<PasteFactsPanel placeId={placeId} />
|
<PasteFactsPanel placeId={placeId} />
|
||||||
|
|||||||
@ -35,16 +35,7 @@ const STATUS_LABEL: Record<number, string> = {
|
|||||||
[PlaceStatus.SUSPENDED]: '중지',
|
[PlaceStatus.SUSPENDED]: '중지',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** 사업장 목록 — 이 제품의 허브다. */
|
||||||
* 사업장 목록 — 이 제품의 허브다.
|
|
||||||
*
|
|
||||||
* 흐름은 하나뿐이다:
|
|
||||||
* 빌더(위저드)로 만든다 → **여기 생긴다** → 여기서 에디터로 들어가 고친다 → 재발행하면 HTML 이 다시 구워진다.
|
|
||||||
*
|
|
||||||
* ★ 그래서 줄을 누르면 사업장 상세가 아니라 **에디터**로 간다. 목록에 온 사장님의
|
|
||||||
* 용건은 열에 아홉 "내 사이트 고치기"다. fact 를 하나씩 확인하는 상세 화면은
|
|
||||||
* [정보 확인] 으로 따로 둔다 — 발행 게이트에 걸렸을 때 가는 곳이다.
|
|
||||||
*/
|
|
||||||
export function PlaceListPage() {
|
export function PlaceListPage() {
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||||
@ -155,7 +146,7 @@ export function PlaceListPage() {
|
|||||||
>
|
>
|
||||||
{STATUS_LABEL[place.status] ?? '알 수 없음'}
|
{STATUS_LABEL[place.status] ?? '알 수 없음'}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* ★ verified_at 이 NULL 이면 수집·발행 진입 금지. 목록에서 바로 보이게 둔다. */}
|
{/* verified_at 이 NULL 이면 수집·발행 진입 금지. */}
|
||||||
{place.verified_at ? (
|
{place.verified_at ? (
|
||||||
<span title="동일 업소 검증 완료" className="text-success">
|
<span title="동일 업소 검증 완료" className="text-success">
|
||||||
<ShieldCheck className="size-4" />
|
<ShieldCheck className="size-4" />
|
||||||
|
|||||||
@ -7,12 +7,7 @@ import {Card, CardContent} from '@/components/ui/card';
|
|||||||
import {customFetch} from '@/api/mutator/custom-fetch';
|
import {customFetch} from '@/api/mutator/custom-fetch';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
/**
|
/** 이용 후기 — 손님 글은 이미 화면에 올라가 있다. */
|
||||||
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
|
|
||||||
*
|
|
||||||
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
|
|
||||||
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
|
|
||||||
*/
|
|
||||||
type Review = {
|
type Review = {
|
||||||
review_id: string;
|
review_id: string;
|
||||||
place_id: string;
|
place_id: string;
|
||||||
|
|||||||
@ -3,13 +3,7 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {defineConfig} from 'vite';
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
/**
|
/** 내부 운영 화면. */
|
||||||
* 내부 운영 화면. 사장님 앱(solution/frontend)과 번들이 갈린다.
|
|
||||||
*
|
|
||||||
* `@` 를 이 앱이 아니라 사장님 앱 src 로 겨눈다 — 내부 화면이 쓰는 API·UI·수집 배선이
|
|
||||||
* 거기 한 벌만 있고 그 파일들끼리도 `@/...` 로 서로를 부른다(자기 src 로 잡으면 TS2307 14건).
|
|
||||||
* 이 앱 고유 파일은 `@admin`. 의존 방향은 admin → solution 한 쪽뿐이다.
|
|
||||||
*/
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
@ -6,6 +6,49 @@
|
|||||||
존재하지 않는 템플릿 id를 가리켰고, 음식점 강조색에 오타가 있었고, 섹션 간격이 빌더와 서버에서
|
존재하지 않는 템플릿 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와 파이썬이 같은 파일을 읽는다.
|
- 템플릿 목록은 `solution/shared/src/data/templates.json` 하나다. TS와 파이썬이 같은 파일을 읽는다.
|
||||||
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
|
- 템플릿 id에서 업종을 뗐다. `stay-retro` → `retro`. 기존 DB 값은 마이그레이션 `0023`으로 바꾼다(운영 미적용).
|
||||||
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
|
- 모르는 템플릿 id는 저장·미리보기·발행에서 모두 거절한다. 기본값으로 슬쩍 굽지 않는다.
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
/**
|
/** "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성. */
|
||||||
* "군산 펜션" SEO/AEO 키워드·태그 데이터셋 1,000건 생성.
|
|
||||||
* node scripts/build-dataset.mjs → data/gunsan-pension-keywords.json
|
|
||||||
*
|
|
||||||
* 어휘는 실제 군산 지명·관광지·숙소 시설 용어로 구성했고,
|
|
||||||
* 패턴은 한국 로컬 숙박 검색에서 실제로 쓰이는 조합만 전개한다.
|
|
||||||
* 가치가 높은 순으로 방출하므로 1,000건에서 잘라도 상위 의도가 남는다.
|
|
||||||
*/
|
|
||||||
import { writeFileSync, mkdirSync } from 'node:fs';
|
import { writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
|
// ──────────────────────────────────────────────── 어휘 (실제 군산 기반)
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다.
|
"""docs/architecture.html 의 내용을 PPTX 로 다시 만든다."""
|
||||||
python3 scripts/build-deck.py
|
|
||||||
도식은 이미지가 아니라 네이티브 도형으로 그리므로 PowerPoint 에서 그대로 편집된다."""
|
|
||||||
|
|
||||||
from pptx import Presentation
|
from pptx import Presentation
|
||||||
from pptx.util import Inches, Pt
|
from pptx.util import Inches, Pt
|
||||||
@ -10,7 +8,7 @@ from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
|||||||
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
from pptx.enum.shapes import MSO_SHAPE, MSO_CONNECTOR
|
||||||
from pptx.oxml.ns import qn
|
from pptx.oxml.ns import qn
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
|
# --------------------------------------------------------------- 팔레트 (HTML 문서와 동일)
|
||||||
INK = RGBColor(0x10, 0x18, 0x19)
|
INK = RGBColor(0x10, 0x18, 0x19)
|
||||||
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
|
INK_SOFT = RGBColor(0x3D, 0x4C, 0x4E)
|
||||||
MUTED = RGBColor(0x63, 0x75, 0x7A)
|
MUTED = RGBColor(0x63, 0x75, 0x7A)
|
||||||
@ -32,7 +30,7 @@ W, H = 13.333, 7.5
|
|||||||
MX = 0.75 # 좌우 여백
|
MX = 0.75 # 좌우 여백
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 저수준 헬퍼
|
# --------------------------------------------------------------- 저수준 헬퍼
|
||||||
def _ea(run, name):
|
def _ea(run, name):
|
||||||
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
|
"""한글이 라틴 폰트로 떨어지지 않도록 동아시아 typeface 를 함께 지정."""
|
||||||
rPr = run._r.get_or_add_rPr()
|
rPr = run._r.get_or_add_rPr()
|
||||||
@ -135,7 +133,7 @@ def label(sl, x, y, text, size=9, color=MUTED, font=MONO, align=PP_ALIGN.LEFT, w
|
|||||||
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
|
return textbox(sl, x, y, w, 0.22, [(text, size, False, color, font)], align=align)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 슬라이드 골격
|
# --------------------------------------------------------------- 슬라이드 골격
|
||||||
prs = Presentation()
|
prs = Presentation()
|
||||||
prs.slide_width = Inches(W)
|
prs.slide_width = Inches(W)
|
||||||
prs.slide_height = Inches(H)
|
prs.slide_height = Inches(H)
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
/**
|
/** 전국 지역별 펜션 SEO/AEO 키워드 데이터셋. */
|
||||||
* 전국 지역별 펜션 SEO/AEO 키워드 데이터셋.
|
|
||||||
* node scripts/build-nationwide-dataset.mjs → data/nationwide-pension-keywords.json
|
|
||||||
*
|
|
||||||
* 설계 원칙
|
|
||||||
* · 조합 폭발을 하지 않는다. 군산 단일 지역 974건을 54개 지역에 곱하면 5만 건이 되고
|
|
||||||
* 대부분 검색량 0이 된다 (실측: 저장분의 89% 미사용).
|
|
||||||
* · 지역 성격(해변/산간/호수/도심/섬)에 맞는 시설 키워드만 전개한다.
|
|
||||||
* 산간 지역에 '오션뷰 펜션'을 만들지 않는다.
|
|
||||||
* · 티어를 매겨 주력/보조/롱테일을 구분한다. SEO 는 페이지당 주력 1개다.
|
|
||||||
*/
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
|
const { regions } = JSON.parse(readFileSync('data/regions.json', 'utf8'));
|
||||||
@ -69,7 +59,7 @@ const uniq = (a) => [...new Set(a)];
|
|||||||
for (const r of regions) {
|
for (const r of regions) {
|
||||||
const R = r.name;
|
const R = r.name;
|
||||||
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
|
const feats = uniq([...r.type.flatMap((t) => FEATURES_BY_TYPE[t] ?? []), ...FEATURES_COMMON]);
|
||||||
// '산간'이라고 다 스키장이 있는 건 아니다. 가평·양평·강화에 '스키 펜션'이 생기면 안 된다.
|
// '산간'이라고 다 스키장이 있는 건 아니다.
|
||||||
const seasons = uniq([
|
const seasons = uniq([
|
||||||
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
|
...r.type.flatMap((t) => SEASON_BY_TYPE[t] ?? []),
|
||||||
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
|
...(r.ski ? ['스키', '스키장 근처', '보드'] : []),
|
||||||
@ -77,8 +67,6 @@ for (const r of regions) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// T1 코어 — 주력 후보.
|
// T1 코어 — 주력 후보.
|
||||||
// 별칭(대천/보령 처럼 같은 지역의 다른 검색 표기)도 코어·의도 계층까지는 함께 전개한다.
|
|
||||||
// 전 계층에 곱하면 두 배가 되므로 상위 티어에만 적용한다.
|
|
||||||
const names = [R, ...(r.aliases ?? [])];
|
const names = [R, ...(r.aliases ?? [])];
|
||||||
for (const N of names) {
|
for (const N of names) {
|
||||||
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
|
add(r, `${N} 펜션`, { category: '코어', tier: '주력', relevance: N === R ? 0.98 : 0.94 });
|
||||||
@ -136,7 +124,7 @@ for (const r of regions) {
|
|||||||
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
|
add(r, t, { kind: 'tag', category: '태그', tier: '태그', relevance: 0.5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 광역 단위 롤업. ltree 라벨은 ASCII 만 허용하므로 시군 키에서 마지막 마디를 떼어 쓴다.
|
// 광역 단위 롤업.
|
||||||
const sidoKey = {};
|
const sidoKey = {};
|
||||||
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
|
for (const r of regions) sidoKey[r.sido] ??= r.key.split('.').slice(0, -1).join('.');
|
||||||
const sidoList = uniq(regions.map((x) => x.sido));
|
const sidoList = uniq(regions.map((x) => x.sido));
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용).
|
"""벡터 DB 에 실제로 적재된 내용을 그대로 엑셀로 뽑는다 (배포용)."""
|
||||||
python3 scripts/export-db-xlsx.py
|
|
||||||
데이터셋 JSON 이 아니라 DB 가 기준이다. 임베딩은 엑셀에 담지 않는다 —
|
|
||||||
384개 float × 7천 행이라 의미가 없고, 같은 모델로 재생성하면 동일하게 복원된다."""
|
|
||||||
import csv, io, subprocess, collections
|
import csv, io, subprocess, collections
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""전국 펜션 키워드 데이터셋 → 엑셀.
|
"""전국 펜션 키워드 데이터셋 → 엑셀."""
|
||||||
python3 scripts/export-xlsx.py
|
|
||||||
검색량·경쟁도 열은 비워 둔다 — 네이버 검색광고 키워드도구에서 받아 채우는 자리."""
|
|
||||||
import json, collections
|
import json, collections
|
||||||
from openpyxl import Workbook
|
from openpyxl import Workbook
|
||||||
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
/**
|
/** 외부 연관키워드·검색량을 데이터셋에 병합한다. */
|
||||||
* 외부 연관키워드·검색량을 데이터셋에 병합한다.
|
|
||||||
* npx tsx scripts/import-related.ts data/related-keywords.csv [--apply]
|
|
||||||
*
|
|
||||||
* 입력은 네이버 검색광고 키워드도구 내려받기 형식(CSV) 또는 같은 필드의 JSON.
|
|
||||||
* relKeyword, monthlyPcQcCnt, monthlyMobileQcCnt, compIdx
|
|
||||||
*
|
|
||||||
* API 클라이언트를 두지 않고 파일 임포트로 한 이유: 검색광고 API 는 계정·HMAC 서명이
|
|
||||||
* 필요해 자격증명 없이는 검증할 수 없다. 파일 경로는 지금 바로 동작하고,
|
|
||||||
* 나중에 API 를 붙여도 이 임포터를 그대로 재사용한다.
|
|
||||||
*/
|
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
/**
|
/** data/gunsan-pension-keywords.json 을 pgvector 에 적재한다. */
|
||||||
* data/gunsan-pension-keywords.json 을 pgvector 에 적재한다.
|
|
||||||
* npx tsx scripts/ingest-dataset.ts
|
|
||||||
*
|
|
||||||
* 정책: 주기 수집 없음. 고정 데이터셋 1회 적재.
|
|
||||||
* 중복제거는 어휘 단계(정규화 완전일치)만 자동 병합하고,
|
|
||||||
* 벡터 유사도는 자동 병합하지 않고 "검토 목록"으로만 뽑는다. (이유는 README 참조)
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { createSql, toVector } from '../src/db/db';
|
import { createSql, toVector } from '../src/db/db';
|
||||||
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
|
import { normalizeKeyword, canonicalizeKeyword, isBanned } from '../src/keywords/normalize';
|
||||||
@ -73,8 +66,6 @@ async function main() {
|
|||||||
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
console.log(`\r ✅ 신규 ${inserted} · 갱신 ${updated} `);
|
||||||
|
|
||||||
// 4) 데이터셋에서 빠진 행 정리.
|
// 4) 데이터셋에서 빠진 행 정리.
|
||||||
// upsert 만 하면 재빌드할 때마다 이전 판본 잔여 행이 쌓여 사전이 계속 커진다.
|
|
||||||
// (실제로 974건 데이터셋인데 사전이 1072건까지 불어 있었다)
|
|
||||||
const wanted = uniq.map(([norm]) => norm);
|
const wanted = uniq.map(([norm]) => norm);
|
||||||
const stale = await sql<Array<{ canonical: string }>>`
|
const stale = await sql<Array<{ canonical: string }>>`
|
||||||
DELETE FROM keyword
|
DELETE FROM keyword
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
/**
|
/** 전국 지역별 펜션 키워드를 pgvector 에 적재한다. */
|
||||||
* 전국 지역별 펜션 키워드를 pgvector 에 적재한다.
|
|
||||||
* npx tsx scripts/ingest-nationwide.ts
|
|
||||||
*
|
|
||||||
* 군산 상세 데이터셋(source='dataset')과 공존시킨다.
|
|
||||||
* 이쪽은 source='nationwide' 로 넣고, 잔여 정리도 그 출처 안에서만 한다.
|
|
||||||
*/
|
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { createSql, toVector } from '../src/db/db';
|
import { createSql, toVector } from '../src/db/db';
|
||||||
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
import { canonicalizeKeyword, isBanned, normalizeKeyword } from '../src/keywords/normalize';
|
||||||
@ -30,7 +24,7 @@ async function main() {
|
|||||||
Array<{ sido: string; name: string; key: string }>;
|
Array<{ sido: string; name: string; key: string }>;
|
||||||
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
|
console.log(`📦 ${items.length}건 / ${ds.regionCount}개 지역 · 임베딩 ${embedder.name}`);
|
||||||
|
|
||||||
// 1) 지역 계층 심기 (시도 → 시군). ltree 라벨은 ASCII 만 허용한다.
|
// 1) 지역 계층 심기 (시도 → 시군).
|
||||||
const nodes = new Map<string, string>();
|
const nodes = new Map<string, string>();
|
||||||
for (const r of regions) {
|
for (const r of regions) {
|
||||||
const sidoKey = r.key.split('.').slice(0, -1).join('.');
|
const sidoKey = r.key.split('.').slice(0, -1).join('.');
|
||||||
@ -63,9 +57,6 @@ async function main() {
|
|||||||
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
|
console.log(` 임베딩 ${vecs.length}건 · ${embedder.dimensions}차원 · ${Date.now() - t0}ms`);
|
||||||
|
|
||||||
// 4) 적재.
|
// 4) 적재.
|
||||||
// keyword.normalized 는 (normalized, locale) 유니크다. 지역이 달라도 같은 문자열이면
|
|
||||||
// 한 행으로 합쳐진다 — '오션뷰' 같은 태그가 그렇다. 지역 고유 키워드는 지명이 들어가
|
|
||||||
// 자연히 구분되므로 문제되지 않는다.
|
|
||||||
let inserted = 0, updated = 0;
|
let inserted = 0, updated = 0;
|
||||||
await sql.begin(async (tx) => {
|
await sql.begin(async (tx) => {
|
||||||
for (let i = 0; i < uniq.length; i++) {
|
for (let i = 0; i < uniq.length; i++) {
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
/**
|
/** 고정 데이터셋 정책 위반분 정리. */
|
||||||
* 고정 데이터셋 정책 위반분 정리.
|
|
||||||
* npx tsx scripts/purge-nondataset.ts [--apply]
|
|
||||||
*
|
|
||||||
* 사전(keyword)에는 큐레이션된 데이터셋만 남아야 한다. 과거 generate 테스트가
|
|
||||||
* 만든 source='llm' 행이 섞여 있으면 다른 업종 키워드가 매칭 후보에 들어온다.
|
|
||||||
*/
|
|
||||||
import { createSql } from '../src/db/db';
|
import { createSql } from '../src/db/db';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
/**
|
/** 로컬 엔드투엔드 점검 스크립트. */
|
||||||
* 로컬 엔드투엔드 점검 스크립트.
|
|
||||||
* npm run db:reset && npm start (다른 터미널)
|
|
||||||
* npm run smoke
|
|
||||||
*/
|
|
||||||
const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
|
const BASE = process.env.BASE_URL ?? 'http://localhost:3100';
|
||||||
|
|
||||||
const j = async (method: string, path: string, body?: unknown) => {
|
const j = async (method: string, path: string, body?: unknown) => {
|
||||||
|
|||||||
@ -64,9 +64,7 @@ const merchants = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 실제 업체. 공개 정보로 확인된 항목만 넣는다.
|
// 실제 업체.
|
||||||
// 확인됨 : 상호, 군산 원도심(신흥동 말랭이마을 인근), 독채 2개 동, 기준 2인·최대 4인
|
|
||||||
// 미확인 : 가격, 바베큐/스파/주차/애견동반 여부 ← 사업자 확인 후 채울 것
|
|
||||||
externalId: 'site-3001',
|
externalId: 'site-3001',
|
||||||
name: '스테이머뭄',
|
name: '스테이머뭄',
|
||||||
industryId: 'stay.pension',
|
industryId: 'stay.pension',
|
||||||
|
|||||||
@ -5,10 +5,7 @@ import { EmbedKind, EmbeddingProvider } from './types';
|
|||||||
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
|
/** CommonJS 빌드에서 ESM 전용 패키지를 로드하기 위한 우회 (TS 가 require 로 바꾸지 못하게 한다) */
|
||||||
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
|
const esmImport = new Function('s', 'return import(s)') as (s: string) => Promise<any>;
|
||||||
|
|
||||||
/**
|
/** 로컬 multilingual-e5-small (384차원, onnxruntime CPU). */
|
||||||
* 로컬 multilingual-e5-small (384차원, onnxruntime CPU).
|
|
||||||
* 최초 1회 모델을 내려받아 캐시하며 그 뒤로는 오프라인 동작한다.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LocalEmbeddingProvider extends EmbeddingProvider {
|
export class LocalEmbeddingProvider extends EmbeddingProvider {
|
||||||
readonly name = 'local:multilingual-e5-small';
|
readonly name = 'local:multilingual-e5-small';
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { EMBEDDING_DIM } from '../config/env';
|
|||||||
import { hashEmbedding } from '../llm/mock.provider';
|
import { hashEmbedding } from '../llm/mock.provider';
|
||||||
import { EmbedKind, EmbeddingProvider } from './types';
|
import { EmbedKind, EmbeddingProvider } from './types';
|
||||||
|
|
||||||
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. 의미는 잡지 못한다. */
|
/** 모델 다운로드 없이 파이프라인을 돌리기 위한 문자 bigram 해싱 임베딩. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MockEmbeddingProvider extends EmbeddingProvider {
|
export class MockEmbeddingProvider extends EmbeddingProvider {
|
||||||
readonly name = 'mock:bigram-hash';
|
readonly name = 'mock:bigram-hash';
|
||||||
|
|||||||
@ -28,10 +28,7 @@ export interface ResolveInput {
|
|||||||
regionId: string | null;
|
regionId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 4단계 계단식 중복제거. */
|
||||||
* 4단계 계단식 중복제거.
|
|
||||||
* 값비싼 벡터 비교는 마지막에, 후보 집합 안에서만 수행한다.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DedupService {
|
export class DedupService {
|
||||||
private readonly logger = new Logger(DedupService.name);
|
private readonly logger = new Logger(DedupService.name);
|
||||||
@ -61,12 +58,6 @@ export class DedupService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
|
// 2~3단계 — trigram 후보 + 벡터 ANN 후보를 모아 최고 유사도 판정
|
||||||
//
|
|
||||||
// 주의: 짧은 한글 키워드에서는 문장 임베딩의 절대 코사인이 변별력이 약하다.
|
|
||||||
// 실측(multilingual-e5-small): '선유도 펜션' ↔ '새만금 펜션' = 0.936,
|
|
||||||
// '군산 펜션' ↔ '군산 호텔' = 0.970 — 전혀 다른 키워드인데도 높게 나온다.
|
|
||||||
// 반면 어순만 바뀐 진짜 중복('군산 키즈룸 펜션' ↔ '군산 펜션 키즈룸')은 0.999 대에 몰린다.
|
|
||||||
// 그래서 임계값을 0.99 로 올려 잡고, 자동 병합의 주력은 1~2단계(어휘)에 둔다.
|
|
||||||
const candidates = await this.repo.findDedupCandidates(
|
const candidates = await this.repo.findDedupCandidates(
|
||||||
input.embedding,
|
input.embedding,
|
||||||
normalized,
|
normalized,
|
||||||
|
|||||||
@ -33,10 +33,7 @@ export class KeywordRepository {
|
|||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다. */
|
||||||
* 중복 후보 수집: trigram 인덱스 히트 + 벡터 ANN 상위 N 을 합집합으로 가져온다.
|
|
||||||
* 벡터 비교는 이 후보 집합 안에서만 하므로 전수 비교가 일어나지 않는다.
|
|
||||||
*/
|
|
||||||
async findDedupCandidates(
|
async findDedupCandidates(
|
||||||
embedding: number[],
|
embedding: number[],
|
||||||
normalized: string,
|
normalized: string,
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
/**
|
/** 중복 판정용 정규화. */
|
||||||
* 중복 판정용 정규화.
|
|
||||||
* NFKC → 소문자 → 제로폭 문자 제거 → 구두점 제거 → 공백 전부 제거.
|
|
||||||
* "강남 미용실" 과 "강남미용실" 을 같은 키로 취급하기 위해 공백을 없앤다.
|
|
||||||
*/
|
|
||||||
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
|
const ZERO_WIDTH = /[\u200B-\u200D\uFEFF]/g;
|
||||||
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;
|
const PUNCT = /[!-\/:-@\[-`{-~·ㆍ、。「-』]/g;
|
||||||
|
|
||||||
|
|||||||
@ -10,13 +10,7 @@ import {
|
|||||||
QaCandidate,
|
QaCandidate,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
/**
|
/** API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현. */
|
||||||
* API 키 없이 로컬에서 전체 파이프라인(생성 → 중복제거 → 서빙)을 돌리기 위한 대체 구현.
|
|
||||||
*
|
|
||||||
* embed(): 문자 bigram 해싱 + L2 정규화.
|
|
||||||
* 랜덤이 아니라 "비슷한 문자열이면 비슷한 벡터"가 나오므로
|
|
||||||
* 코사인 임계값 기반 중복제거 동작을 실제와 유사하게 검증할 수 있다.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MockLlmProvider extends LlmProvider {
|
export class MockLlmProvider extends LlmProvider {
|
||||||
readonly name = 'mock';
|
readonly name = 'mock';
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Controller, Get, Header } from '@nestjs/common';
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
/** 로컬 확인용 매칭 데모 페이지. 빌드 산출물이 아니라 public/ 에서 직접 읽는다. */
|
/** 로컬 확인용 매칭 데모 페이지. */
|
||||||
@Controller()
|
@Controller()
|
||||||
export class DemoController {
|
export class DemoController {
|
||||||
@Get('demo')
|
@Get('demo')
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
/**
|
/** 매칭 규칙 테이블. */
|
||||||
* 매칭 규칙 테이블.
|
|
||||||
*
|
|
||||||
* 두 종류가 있다.
|
|
||||||
* · 서브 질의 빌더 — 프로필을 속성별로 쪼개 각각 임베딩한다 (통짜로 넣으면 속성이 희석된다)
|
|
||||||
* · 사실 기반 필터 — 벡터가 못 거르는 모순을 SQL/코드 조건으로 배제한다
|
|
||||||
* (임베딩은 "비슷함"만 알지 "최대 4인 < 단체"를 모른다)
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MerchantFacts {
|
export interface MerchantFacts {
|
||||||
name: string;
|
name: string;
|
||||||
@ -21,7 +14,7 @@ export interface MerchantFacts {
|
|||||||
nearby: string[];
|
nearby: string[];
|
||||||
amenities: Set<string>; // 정규화된 보유 시설
|
amenities: Set<string>; // 정규화된 보유 시설
|
||||||
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
unverified: Set<string>; // 미확인 — 배제하지 않고 보류 처리
|
||||||
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. 사업자가 쓰는 말과 다르므로 별도 레인으로 둔다 */
|
/** 고객 언어 — 인스타 해시태그, 리뷰 빈출어. */
|
||||||
signals: string[];
|
signals: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,20 +117,10 @@ export function checkAmenity(keyword: string, facts: MerchantFacts): AmenityVerd
|
|||||||
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
|
const STAY_TYPE_HINTS = ['독채', '풀빌라', '스테이', '펜션', '글램핑', '카라반', '한옥', '민박', '감성'];
|
||||||
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
|
const CAPACITY_TOKEN = /\d+\s*인|기준|최대|소규모|중규모|대규모|수용/;
|
||||||
|
|
||||||
/**
|
/** 레인 설계 원칙 1. 레인끼리 겹치지 않게 한다. */
|
||||||
* 레인 설계 원칙
|
|
||||||
* 1. 레인끼리 겹치지 않게 한다. 모든 레인에 "군산 펜션"을 넣으면 레인이 상관되고,
|
|
||||||
* 그러면 RRF 가 "여러 레인에 두루 걸린 generic 키워드"를 상위로 올린다.
|
|
||||||
* 지역+업종 앵커는 유형 레인에만 둔다.
|
|
||||||
* 2. 브랜드 레인은 두지 않는다. 상호는 사전에 없으므로 결국 "군산 펜션"만 남아
|
|
||||||
* 가장 generic 한 것들을 끌어온다 (실측에서 상위 6개가 전부 '~예약'으로 도배됐다).
|
|
||||||
* 3. 수용 인원은 레인에 넣지 않는다. 필터 전용이다.
|
|
||||||
*/
|
|
||||||
export function buildLanes(f: MerchantFacts): Lane[] {
|
export function buildLanes(f: MerchantFacts): Lane[] {
|
||||||
const lanes: Lane[] = [];
|
const lanes: Lane[] = [];
|
||||||
// 토큰 단위로 중복을 제거한다. 문자열 단위 Set 만으로는 '신흥동' 과
|
// 토큰 단위로 중복을 제거한다.
|
||||||
// '신흥동 일본식가옥' 이 서로 다른 원소라 같은 낱말이 두 번 실리고,
|
|
||||||
// 그 낱말 쪽으로 레인이 쏠린다 (실제로 말랭이마을이 밀려났다).
|
|
||||||
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
|
const push = (key: string, label: string, weight: number, parts: (string | null | undefined)[]) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const words: string[] = [];
|
const words: string[] = [];
|
||||||
@ -163,8 +146,7 @@ export function buildLanes(f: MerchantFacts): Lane[] {
|
|||||||
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
|
...f.features.filter((x) => !isType(x) && !CAPACITY_TOKEN.test(x) && !keywordAreaGroup(x)),
|
||||||
].slice(0, 6);
|
].slice(0, 6);
|
||||||
|
|
||||||
// 권역과 인근을 한 레인으로 합친다. 나눠 두면 '신흥동' 같은 토큰이 두 레인에 겹쳐
|
// 권역과 인근을 한 레인으로 합친다.
|
||||||
// 같은 위치 키워드가 두 번 가산되고, 상위가 전부 위치 키워드로 쓸려 나간다.
|
|
||||||
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
|
push('type', '유형', 1.0, [f.region, f.industry, ...typeWords]);
|
||||||
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
|
push('place', '위치', 0.7, [f.areaGroup, districtOf(f.address), ...f.nearby.slice(0, 4), '근처']);
|
||||||
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
push('audience', '동반자', 0.6, f.audiences.slice(0, 4));
|
||||||
|
|||||||
@ -9,12 +9,11 @@ import {
|
|||||||
keywordAreaGroup, normalizeAmenities, violatesCapacity,
|
keywordAreaGroup, normalizeAmenities, violatesCapacity,
|
||||||
} from './match.rules';
|
} from './match.rules';
|
||||||
|
|
||||||
// RRF 상수를 관례값 60 대신 20 으로 낮춘다. 60 이면 1위와 40위의 기여도 차이가 1.6배뿐이라
|
// RRF 상수를 관례값 60 대신 20 으로 낮춘다.
|
||||||
// 깊은 순위의 generic 키워드가 여러 레인에서 조금씩 쌓아 상위를 차지한다. 20 이면 2.9배로 벌어진다.
|
|
||||||
const RRF_K = 20;
|
const RRF_K = 20;
|
||||||
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
|
const LANE_DEPTH = 50; // 레인당 후보 깊이 — 깊을수록 generic 이 유리해진다
|
||||||
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
|
const LANE_FLOOR = 0.80; // 이 코사인 미만은 그 레인에서 기여하지 않는다
|
||||||
// 매칭 후보로 인정하는 출처. 고정 데이터셋 정책상 LLM 생성물은 사전에 섞이면 안 된다.
|
// 매칭 후보로 인정하는 출처.
|
||||||
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
|
const MATCH_SOURCES = ['dataset', 'nationwide', 'manual'];
|
||||||
|
|
||||||
interface Hit {
|
interface Hit {
|
||||||
@ -50,8 +49,6 @@ export class MatchService {
|
|||||||
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
const vectors = await this.embedder.embed(lanes.map((l) => l.text), 'query');
|
||||||
|
|
||||||
// 레인별 검색.
|
// 레인별 검색.
|
||||||
// 후보 풀을 업체 업종으로 좁힌다. 사전 전체를 뒤지면 '강남 미용실' 같은
|
|
||||||
// 다른 업종 키워드가 후보에 섞인다 (실제로 섞여 있었다).
|
|
||||||
const perLane = await Promise.all(
|
const perLane = await Promise.all(
|
||||||
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
|
vectors.map((v) => this.laneSearch(v, LANE_DEPTH, merchant?.industry_id ?? null)),
|
||||||
);
|
);
|
||||||
@ -107,7 +104,6 @@ export class MatchService {
|
|||||||
const top = kept.slice(0, limit);
|
const top = kept.slice(0, limit);
|
||||||
|
|
||||||
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
|
// 레인별 상위 — SEO 페이지 배분은 평평한 순위가 아니라 이쪽을 쓴다.
|
||||||
// (주력 키워드는 유형 레인 1위, 주변 여행 페이지는 위치 레인 상위)
|
|
||||||
const keptById = new Map(kept.map((k) => [k.id, k]));
|
const keptById = new Map(kept.map((k) => [k.id, k]));
|
||||||
const byLane = lanes.map((lane, li) => ({
|
const byLane = lanes.map((lane, li) => ({
|
||||||
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
|
key: lane.key, label: lane.label, weight: lane.weight, text: lane.text,
|
||||||
@ -226,12 +222,7 @@ function toFacts(m: MerchantWithTaxonomy): MerchantFacts {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 고객 언어 신호를 모은다. */
|
||||||
* 고객 언어 신호를 모은다.
|
|
||||||
* hashtags : ["#군산독채", "#군산감성숙소", ...] 인스타 등
|
|
||||||
* reviewSignals: [{ term: "바베큐", count: 47 }, ...] 리뷰 원문이 아닌 빈도 집계
|
|
||||||
* 리뷰 원문은 받지 않는다 (저작권·개인정보). 빈도만으로 충분하다.
|
|
||||||
*/
|
|
||||||
function collectSignals(p: Record<string, unknown>): string[] {
|
function collectSignals(p: Record<string, unknown>): string[] {
|
||||||
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
|
const tags = str(p['hashtags']).map((t) => t.replace(/^#/, '').trim()).filter(Boolean);
|
||||||
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
|
const raw = Array.isArray(p['reviewSignals']) ? p['reviewSignals'] : [];
|
||||||
|
|||||||
@ -27,11 +27,7 @@ export class ServingController {
|
|||||||
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
return this.serving.searchKeywords(body.query, Math.min(body.limit ?? 10, 50));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드. */
|
||||||
* 자유 입력(업체명/문장) → 적재된 사전에서 잘 맞는 키워드.
|
|
||||||
* mode=fusion (기본) — 속성별 서브 질의 + 가중 RRF + 사실 기반 필터
|
|
||||||
* mode=single — 프로필을 통짜로 한 벡터에 넣는 이전 방식 (비교용)
|
|
||||||
*/
|
|
||||||
@Post('match')
|
@Post('match')
|
||||||
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
|
match(@Body() body: { query: string; limit?: number; mode?: 'fusion' | 'single' }) {
|
||||||
const limit = Math.min(body.limit ?? 40, 200);
|
const limit = Math.min(body.limit ?? 40, 200);
|
||||||
|
|||||||
@ -96,11 +96,7 @@ export class ServingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다. */
|
||||||
* 자유 입력(업체명 또는 문장) → 적재된 키워드 사전에서 잘 맞는 것을 골라준다.
|
|
||||||
* 업체명이면 먼저 업체를 해석해 프로필 전체를 질의문으로 쓴다 —
|
|
||||||
* 상호만으로 임베딩하면 브랜드명 하나로 검색하는 것과 같아 매칭이 얕아진다.
|
|
||||||
*/
|
|
||||||
async match(rawQuery: string, limit: number) {
|
async match(rawQuery: string, limit: number) {
|
||||||
const query = rawQuery.trim();
|
const query = rawQuery.trim();
|
||||||
const merchant = await this.resolveMerchant(query);
|
const merchant = await this.resolveMerchant(query);
|
||||||
|
|||||||
@ -2,8 +2,5 @@ from common.enums import UserRole
|
|||||||
|
|
||||||
|
|
||||||
def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
|
def is_owner_or_admin(resource_user_id, user_id, role) -> bool:
|
||||||
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True.
|
"""변경 액션 공용 소유권 판정 — 리소스 소유자(user_id 일치) 또는 최고관리자 이상(OWNER/DEVELOPER)이면 True."""
|
||||||
|
|
||||||
프론트의 버튼 게이팅과 같은 규칙을 백엔드에서 강제하는 단일 출처.
|
|
||||||
소유자 없는 공용 리소스(예: user_id NULL 공용카드)는 이 판정 대상이 아니다(도메인별 별도 처리)."""
|
|
||||||
return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value
|
return str(resource_user_id) == str(user_id) or (role or 0) >= UserRole.OWNER.value
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
"""업종별 fact 스키마 패키지.
|
"""업종별 fact 스키마 패키지."""
|
||||||
|
|
||||||
업종마다 필드가 완전히 다르므로(숙박=체크인시간, 카페=브레이크타임) facts 는 key-value 로 두고,
|
|
||||||
'어떤 key 가 존재하는가'는 업종별 JSON 스키마가 정의한다.
|
|
||||||
|
|
||||||
**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄.** 로직 수정 없음.
|
|
||||||
"""
|
|
||||||
from common.category_schema.loader import (
|
from common.category_schema.loader import (
|
||||||
CategorySchema,
|
CategorySchema,
|
||||||
CategorySchemaError,
|
CategorySchemaError,
|
||||||
|
|||||||
@ -1,23 +1,4 @@
|
|||||||
"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스.
|
"""업종별 fact 스키마 — 업종마다 어떤 key 가 존재하는지의 유일한 소스."""
|
||||||
|
|
||||||
resources/*.json 을 최초 사용 시 메모리에 로드한다. DB 에 저장하지 않으며 런타임에 수정하지 않는다.
|
|
||||||
**업종 추가 = resources/ 에 JSON 파일 1개 추가 + PlaceCategory 에 코드 1줄 추가.** 코드 수정은 없다.
|
|
||||||
|
|
||||||
검증 실패 시 예외를 던진다(요청 실패가 아니라 잘못된 리소스 배포를 조기에 드러내기 위함 —
|
|
||||||
파일은 코드와 함께 배포되므로 정상 배포에선 실패하지 않는다).
|
|
||||||
|
|
||||||
필드 속성
|
|
||||||
key : facts.key 에 저장되는 식별자. 업종 안에서 유일해야 한다
|
|
||||||
label : 화면·프롬프트에 쓰는 한글 이름
|
|
||||||
type : text | number | bool | time
|
|
||||||
scope : place(사업장 단위) | unit(객실·메뉴·프로그램 단위)
|
|
||||||
required : 발행 검수 게이트의 필수 항목. 빠지면 PUBLISH_REQUIRED_FACT_MISSING
|
|
||||||
critical : ★ 틀리면 손님이 헛걸음하거나 예약 클레임이 나는 항목.
|
|
||||||
미검증 상태로는 절대 노출하지 않는다(절대규칙 1)
|
|
||||||
allow_llm : LLM 이 값을 만들어도 되는 필드인가. **False 가 기본** — LLM 은 사실을 만들지 않는다.
|
|
||||||
True 인 것은 소개문처럼 '문장' 자체가 산출물인 필드뿐이다(절대규칙 7)
|
|
||||||
unit : 값의 단위(원·명·분…). 없으면 null
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -36,7 +17,7 @@ class CategorySchemaError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
class FieldSpec:
|
class FieldSpec:
|
||||||
"""업종 스키마의 필드 1개. JSON 한 행에 대응한다."""
|
"""업종 스키마의 필드 1개."""
|
||||||
|
|
||||||
__slots__ = ("key", "label", "type", "scope", "required", "critical", "allow_llm", "unit")
|
__slots__ = ("key", "label", "type", "scope", "required", "critical", "allow_llm", "unit")
|
||||||
|
|
||||||
@ -108,17 +89,16 @@ class CategorySchema:
|
|||||||
return [k for k, f in self.fields.items() if f.required and (scope is None or f.scope == scope)]
|
return [k for k, f in self.fields.items() if f.required and (scope is None or f.scope == scope)]
|
||||||
|
|
||||||
def critical_keys(self) -> list[str]:
|
def critical_keys(self) -> list[str]:
|
||||||
"""★ 미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등)."""
|
"""미검증 상태로 노출하면 안 되는 key 목록(체크인·취사·반려동물·취소 규정 등)."""
|
||||||
return [k for k, f in self.fields.items() if f.critical]
|
return [k for k, f in self.fields.items() if f.critical]
|
||||||
|
|
||||||
def llm_writable_keys(self) -> list[str]:
|
def llm_writable_keys(self) -> list[str]:
|
||||||
"""LLM 이 값을 만들어도 되는 key 목록. 나머지는 LLM 이 값을 채울 수 없다."""
|
"""LLM 이 값을 만들어도 되는 key 목록."""
|
||||||
return [k for k, f in self.fields.items() if f.allow_llm]
|
return [k for k, f in self.fields.items() if f.allow_llm]
|
||||||
|
|
||||||
|
|
||||||
def load_schemas() -> None:
|
def load_schemas() -> None:
|
||||||
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회 호출(멱등).
|
"""리소스 디렉터리 전체 로드 + 검증."""
|
||||||
파일을 하나 추가하면 그대로 새 업종이 된다 — 로더 코드는 건드리지 않는다."""
|
|
||||||
global _schemas
|
global _schemas
|
||||||
if _schemas is not None:
|
if _schemas is not None:
|
||||||
return
|
return
|
||||||
@ -146,7 +126,7 @@ def load_schemas() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_schema(category) -> CategorySchema:
|
def get_schema(category) -> CategorySchema:
|
||||||
"""업종 코드(int 또는 PlaceCategory) → 스키마. 없는 업종이면 CategorySchemaError."""
|
"""업종 코드(int 또는 PlaceCategory) → 스키마."""
|
||||||
if _schemas is None:
|
if _schemas is None:
|
||||||
load_schemas()
|
load_schemas()
|
||||||
code = category.value if isinstance(category, PlaceCategory) else category
|
code = category.value if isinstance(category, PlaceCategory) else category
|
||||||
@ -157,14 +137,14 @@ def get_schema(category) -> CategorySchema:
|
|||||||
|
|
||||||
|
|
||||||
def all_schemas() -> dict:
|
def all_schemas() -> dict:
|
||||||
"""전 업종 스키마. {code: CategorySchema}"""
|
"""전 업종 스키마."""
|
||||||
if _schemas is None:
|
if _schemas is None:
|
||||||
load_schemas()
|
load_schemas()
|
||||||
return dict(_schemas)
|
return dict(_schemas)
|
||||||
|
|
||||||
|
|
||||||
def is_valid_key(category, key: str) -> bool:
|
def is_valid_key(category, key: str) -> bool:
|
||||||
"""해당 업종에 존재하는 fact key 인지. facts 쓰기 전 검증에 쓴다(FACT_INVALID_KEY)."""
|
"""해당 업종에 존재하는 fact key 인지."""
|
||||||
try:
|
try:
|
||||||
return get_schema(category).has(key)
|
return get_schema(category).has(key)
|
||||||
except CategorySchemaError:
|
except CategorySchemaError:
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.
|
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용."""
|
||||||
|
|
||||||
★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다.
|
|
||||||
자세한 배경은 DEVLOG.md 참고.
|
|
||||||
"""
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
@ -11,8 +7,7 @@ from common.logger import LOG
|
|||||||
|
|
||||||
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
|
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
|
||||||
|
|
||||||
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가
|
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가 있어 상한을 둔다.
|
||||||
# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다.
|
|
||||||
_MAX_MESSAGE = 500
|
_MAX_MESSAGE = 500
|
||||||
_MAX_TARGET = 200
|
_MAX_TARGET = 200
|
||||||
|
|
||||||
@ -27,7 +22,7 @@ class CollectIssue:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def collecting():
|
def collecting():
|
||||||
"""run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다."""
|
"""run_collect() 진입부에서 한 번 연다."""
|
||||||
token = _current.set([])
|
token = _current.set([])
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@ -36,10 +31,7 @@ def collecting():
|
|||||||
|
|
||||||
|
|
||||||
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
||||||
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.
|
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다."""
|
||||||
|
|
||||||
collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다
|
|
||||||
(단발 호출·테스트 호환)."""
|
|
||||||
issue = CollectIssue(
|
issue = CollectIssue(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
target=target[:_MAX_TARGET],
|
target=target[:_MAX_TARGET],
|
||||||
@ -54,6 +46,6 @@ def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
|||||||
|
|
||||||
|
|
||||||
def snapshot() -> list[dict]:
|
def snapshot() -> list[dict]:
|
||||||
"""지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다."""
|
"""지금까지 쌓인 실패 목록."""
|
||||||
issues = _current.get()
|
issues = _current.get()
|
||||||
return [asdict(i) for i in issues] if issues else []
|
return [asdict(i) for i in issues] if issues else []
|
||||||
|
|||||||
@ -1,20 +1,4 @@
|
|||||||
"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**
|
"""사이트 1건 생성 원가 미터 — **$1 예산을 코드로 강제한다.**"""
|
||||||
|
|
||||||
★ 제품 제약: **업소 1곳의 사이트를 만드는 데 드는 외부 API 비용은 $1(1,400원)을 넘으면 안 된다.**
|
|
||||||
이 모듈은 그 예산을 "문서에 적힌 목표" 가 아니라 **호출을 막는 가드**로 만든다.
|
|
||||||
|
|
||||||
쓰는 법 — 사이트 1건 = 미터 1개:
|
|
||||||
|
|
||||||
meter = CostMeter(place_id=42)
|
|
||||||
meter.guard(Provider.PERPLEXITY, calls=1, tokens=3000) # 호출 "전" 에 물어본다
|
|
||||||
...실제 호출...
|
|
||||||
meter.charge(Provider.PERPLEXITY, calls=1, tokens=3100) # 호출 "후" 에 실측을 적는다
|
|
||||||
|
|
||||||
`guard()` 가 BudgetExceeded 를 던지면 **그 호출은 하지 않는다.** 이미 쓴 돈은 못 돌려받지만
|
|
||||||
다음 호출을 막아 손실이 선형으로 늘어나는 것을 끊는다.
|
|
||||||
|
|
||||||
비용 리스크 순서(큰 것부터): Gemini(사진 장수에 비례) > Perplexity(토큰+요청 요금) > Kakao(건당 2원).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@ -22,9 +6,9 @@ from enum import Enum
|
|||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
# ── 예산 ────────────────────────────────────────────────────────
|
# ── 예산 ────────────────────────────────────────────────────────
|
||||||
USD_KRW = 1400.0 # 환산 환율. 실제 청구 환율과 다를 수 있다.
|
USD_KRW = 1400.0 # 환산 환율.
|
||||||
SITE_BUDGET_KRW = 1400.0 # ★ 사이트 1건당 상한 = $1
|
SITE_BUDGET_KRW = 1400.0 # 사이트 1건당 상한 = $1
|
||||||
# 예산의 몇 %까지 차면 경고를 남길지. 넘겨도 막지는 않는다 — 막는 건 100% 지점이다.
|
# 예산의 몇 %까지 차면 경고를 남길지.
|
||||||
WARN_RATIO = 0.7
|
WARN_RATIO = 0.7
|
||||||
|
|
||||||
|
|
||||||
@ -39,12 +23,7 @@ class Provider(Enum):
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Rate:
|
class Rate:
|
||||||
"""공급자별 단가(원 기준).
|
"""공급자별 단가(원 기준)."""
|
||||||
|
|
||||||
confirmed=False 는 **아직 공식 단가표로 확인하지 않은 추정치**라는 뜻이다.
|
|
||||||
추정치로 예산을 계산하면 "예산 안" 이라는 결론 자체가 추정이 된다 —
|
|
||||||
실제 배치를 돌리기 전에 `assert_rates_confirmed()` 로 막는다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
per_call_krw: float = 0.0 # 호출 1건당 고정비
|
per_call_krw: float = 0.0 # 호출 1건당 고정비
|
||||||
per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금)
|
per_search_krw: float = 0.0 # 검색 1회당 (Perplexity 는 토큰과 별도 과금)
|
||||||
@ -55,26 +34,22 @@ class Rate:
|
|||||||
|
|
||||||
|
|
||||||
# ── 단가표 ──────────────────────────────────────────────────────
|
# ── 단가표 ──────────────────────────────────────────────────────
|
||||||
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
|
|
||||||
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
|
|
||||||
RATES: dict[Provider, Rate] = {
|
RATES: dict[Provider, Rate] = {
|
||||||
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
|
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
|
||||||
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
||||||
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
|
|
||||||
Provider.KAKAO: Rate(
|
Provider.KAKAO: Rate(
|
||||||
per_call_krw=2.0,
|
per_call_krw=2.0,
|
||||||
confirmed=True,
|
confirmed=True,
|
||||||
source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)",
|
source=".env.example — 키워드/카테고리 검색 2원 (무료 쿼터 초과분)",
|
||||||
),
|
),
|
||||||
# Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M.
|
# Sonar 기본 search_context_size=low: 요청 $5/1K + 입력/출력 각각 $1/1M.
|
||||||
# 2026-08-28 환산(USD_KRW=1,400)이다. 내부 검색 횟수에는 별도 요금이 없다.
|
|
||||||
Provider.PERPLEXITY: Rate(
|
Provider.PERPLEXITY: Rate(
|
||||||
per_call_krw=7.0,
|
per_call_krw=7.0,
|
||||||
per_1k_token_krw=1.4,
|
per_1k_token_krw=1.4,
|
||||||
confirmed=True,
|
confirmed=True,
|
||||||
source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context",
|
source="https://docs.perplexity.ai/docs/getting-started/pricing — Sonar low context",
|
||||||
),
|
),
|
||||||
# ★ 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
|
# 미확인 — Google AI Studio 단가표 확인 후 교체할 것.
|
||||||
Provider.GEMINI: Rate(
|
Provider.GEMINI: Rate(
|
||||||
per_image_krw=1.0,
|
per_image_krw=1.0,
|
||||||
per_1k_token_krw=0.5,
|
per_1k_token_krw=0.5,
|
||||||
@ -91,7 +66,6 @@ KAKAO_COORD_KRW = 0.5
|
|||||||
|
|
||||||
|
|
||||||
class BudgetExceeded(Exception):
|
class BudgetExceeded(Exception):
|
||||||
"""사이트 1건 예산을 넘겼다. 호출측은 **더 호출하지 말고** 작업을 중단한다."""
|
|
||||||
|
|
||||||
def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float):
|
def __init__(self, place_id: int | None, spent_krw: float, would_add_krw: float):
|
||||||
self.place_id = place_id
|
self.place_id = place_id
|
||||||
@ -108,10 +82,7 @@ class UnconfirmedRate(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def assert_rates_confirmed(*providers: Provider) -> None:
|
def assert_rates_confirmed(*providers: Provider) -> None:
|
||||||
"""실배치 직전에 호출한다. 추정 단가가 섞여 있으면 막는다.
|
"""실배치 직전에 호출한다."""
|
||||||
|
|
||||||
★ 이걸 건너뛰면 "예산 안에 들어온다" 는 결론이 추정 위에 서게 된다.
|
|
||||||
"""
|
|
||||||
bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed]
|
bad = [p for p in (providers or tuple(RATES)) if not RATES[p].confirmed]
|
||||||
if bad:
|
if bad:
|
||||||
names = ", ".join(p.value for p in bad)
|
names = ", ".join(p.value for p in bad)
|
||||||
@ -129,7 +100,7 @@ def estimate_krw(
|
|||||||
images: int = 0,
|
images: int = 0,
|
||||||
kakao_coord_calls: int = 0,
|
kakao_coord_calls: int = 0,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""이번 호출의 원가(원)를 계산한다. 실측이든 예상이든 같은 식을 쓴다."""
|
"""이번 호출의 원가(원)를 계산한다."""
|
||||||
rate = RATES[provider]
|
rate = RATES[provider]
|
||||||
krw = (
|
krw = (
|
||||||
rate.per_call_krw * calls
|
rate.per_call_krw * calls
|
||||||
@ -144,7 +115,7 @@ def estimate_krw(
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CostMeter:
|
class CostMeter:
|
||||||
"""사이트 1건(업소 1곳)의 원가 누적기. **미터 1개 = 사이트 1건**이다."""
|
"""사이트 1건(업소 1곳)의 원가 누적기."""
|
||||||
|
|
||||||
place_id: int | None = None
|
place_id: int | None = None
|
||||||
budget_krw: float = SITE_BUDGET_KRW
|
budget_krw: float = SITE_BUDGET_KRW
|
||||||
@ -162,20 +133,14 @@ class CostMeter:
|
|||||||
return self.spent_krw / USD_KRW
|
return self.spent_krw / USD_KRW
|
||||||
|
|
||||||
def guard(self, provider: Provider, **units) -> float:
|
def guard(self, provider: Provider, **units) -> float:
|
||||||
"""호출 **전** 에 예산을 확인한다. 넘으면 BudgetExceeded — 호출하지 마라.
|
"""호출 **전** 에 예산을 확인한다."""
|
||||||
|
|
||||||
돌려주는 값은 이번 호출의 예상 원가(원)다.
|
|
||||||
"""
|
|
||||||
krw = estimate_krw(provider, **units)
|
krw = estimate_krw(provider, **units)
|
||||||
if self.spent_krw + krw > self.budget_krw:
|
if self.spent_krw + krw > self.budget_krw:
|
||||||
raise BudgetExceeded(self.place_id, self.spent_krw, krw)
|
raise BudgetExceeded(self.place_id, self.spent_krw, krw)
|
||||||
return krw
|
return krw
|
||||||
|
|
||||||
def charge(self, provider: Provider, **units) -> float:
|
def charge(self, provider: Provider, **units) -> float:
|
||||||
"""호출 **후** 에 실측 사용량을 적는다. 적고 나서 예산을 넘었으면 예외를 던진다.
|
"""이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다."""
|
||||||
|
|
||||||
★ 이미 나간 호출은 되돌릴 수 없다 — 예외의 목적은 **다음 호출을 막는 것**이다.
|
|
||||||
"""
|
|
||||||
krw = estimate_krw(provider, **units)
|
krw = estimate_krw(provider, **units)
|
||||||
self.spent_krw += krw
|
self.spent_krw += krw
|
||||||
self.calls += 1
|
self.calls += 1
|
||||||
|
|||||||
@ -13,16 +13,7 @@ from config.server_configs import main_db_config
|
|||||||
|
|
||||||
|
|
||||||
class DBSessionManager(Singleton):
|
class DBSessionManager(Singleton):
|
||||||
"""DB 세션/엔진 관리자 (싱글톤).
|
"""DB 세션/엔진 관리자 (싱글톤)."""
|
||||||
|
|
||||||
핵심 패턴
|
|
||||||
- DBType(논리 DB) x DBWRType(Read/Write) 조합마다 별도 async 엔진을 둔다.
|
|
||||||
=> 조회는 Read 복제본, 변경은 Write 주 DB 로 자연스럽게 분리된다.
|
|
||||||
- 비즈니스 로직(service)은 직접 세션을 열지 않고 "람다"를 넘긴다.
|
|
||||||
execute_lambda : 단일 쿼리 (주로 조회)
|
|
||||||
execute_lambda_run : 동일 DB 의 여러 변경 쿼리를 한 트랜잭션으로 commit
|
|
||||||
세션 open/close 와 commit/rollback 은 매니저가 책임진다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if DBSessionManager.is_init():
|
if DBSessionManager.is_init():
|
||||||
@ -33,7 +24,7 @@ class DBSessionManager(Singleton):
|
|||||||
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
|
self.__DB_URL_MAP = {"postgresql": "postgresql+asyncpg"}
|
||||||
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
|
# 종료 시 dispose 하기 위해 생성한 엔진을 모아둔다.
|
||||||
self.__engines = []
|
self.__engines = []
|
||||||
# 논리 DB -> config. DB 가 늘어나면 여기에 추가만 하면 된다.
|
# 논리 DB -> config.
|
||||||
self.__db_type_map = {
|
self.__db_type_map = {
|
||||||
DBType.MAIN.value: main_db_config,
|
DBType.MAIN.value: main_db_config,
|
||||||
}
|
}
|
||||||
@ -61,7 +52,7 @@ class DBSessionManager(Singleton):
|
|||||||
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}"
|
db_url = f"{self.__DB_URL_MAP[db_config.db_type]}://{db_config.write_id}{pw}@{db_config.write_host}:{db_config.write_port}/{db_config.name}"
|
||||||
LOG.i(f"Write DB create engine url : {db_url}")
|
LOG.i(f"Write DB create engine url : {db_url}")
|
||||||
|
|
||||||
# SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수. sslmode 가 설정되면 asyncpg 에 전달.
|
# SSL/TLS: 관리형 DB(RDS/Aurora/Azure)는 보통 TLS 필수.
|
||||||
connect_args = {}
|
connect_args = {}
|
||||||
sslmode = (getattr(db_config, "sslmode", "") or "").lower()
|
sslmode = (getattr(db_config, "sslmode", "") or "").lower()
|
||||||
if sslmode and sslmode != "disable":
|
if sslmode and sslmode != "disable":
|
||||||
@ -88,13 +79,11 @@ class DBSessionManager(Singleton):
|
|||||||
return db_type in self.__db_type_map
|
return db_type in self.__db_type_map
|
||||||
|
|
||||||
async def dispose_all(self):
|
async def dispose_all(self):
|
||||||
"""모든 엔진의 커넥션 풀을 정리한다. 앱 종료/테스트 종료 시 호출한다.
|
"""모든 엔진의 커넥션 풀을 정리한다."""
|
||||||
호출하지 않으면 풀 커넥션이 이벤트 루프 종료 후 GC 되며 경고를 남긴다.
|
|
||||||
"""
|
|
||||||
for engine in self.__engines:
|
for engine in self.__engines:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|
||||||
# ---- 세션 lifecycle -------------------------------------------------
|
# 세션 lifecycle
|
||||||
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
|
async def start_session(self, db_type: int, db_wr_type: int) -> AsyncSession:
|
||||||
if db_wr_type == DBWRType.DB_WRITE.value:
|
if db_wr_type == DBWRType.DB_WRITE.value:
|
||||||
return self.__write_session[db_type]()
|
return self.__write_session[db_type]()
|
||||||
@ -106,16 +95,14 @@ class DBSessionManager(Singleton):
|
|||||||
else:
|
else:
|
||||||
await self.__read_session[db_type].remove()
|
await self.__read_session[db_type].remove()
|
||||||
|
|
||||||
# ---- 저수준 DB 연산 (crud 에서 호출) --------------------------------
|
# 저수준 DB 연산 (crud 에서 호출)
|
||||||
async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType:
|
async def run(self, db: AsyncSession, err_msg="DB Run Failed", raise_error=True) -> ErrorType:
|
||||||
try:
|
try:
|
||||||
await db.commit()
|
await db.commit()
|
||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
except IntegrityError as ex:
|
except IntegrityError as ex:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
# ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다
|
# 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다 (services/collect_service.py `_add_link`).
|
||||||
# (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못
|
|
||||||
# 보던 무결성 오류는 아래 일반 Exception 갈래로 간다.
|
|
||||||
LOG.w(f"duplicated. {ex}")
|
LOG.w(f"duplicated. {ex}")
|
||||||
return ErrorType.DB_ALREADY_SAME_KEY
|
return ErrorType.DB_ALREADY_SAME_KEY
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@ -164,8 +151,7 @@ class DBSessionManager(Singleton):
|
|||||||
return err_type
|
return err_type
|
||||||
|
|
||||||
async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]:
|
async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]:
|
||||||
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환.
|
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환."""
|
||||||
조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용."""
|
|
||||||
try:
|
try:
|
||||||
if hasattr(query, "column_descriptions"):
|
if hasattr(query, "column_descriptions"):
|
||||||
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
|
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
|
||||||
@ -196,9 +182,9 @@ class DBSessionManager(Singleton):
|
|||||||
raise RuntimeError(err_type.name, err_msg)
|
raise RuntimeError(err_type.name, err_msg)
|
||||||
return err_type, []
|
return err_type, []
|
||||||
|
|
||||||
# ---- 람다 실행 진입점 (service 에서 호출) ---------------------------
|
# 람다 실행 진입점 (service 에서 호출)
|
||||||
async def execute_lambda(self, db_type: int, db_wr_type: int, func):
|
async def execute_lambda(self, db_type: int, db_wr_type: int, func):
|
||||||
"""단일 쿼리 호출. func(session) 한 개를 실행하고 결과를 그대로 반환."""
|
"""단일 쿼리 호출."""
|
||||||
s = await self.start_session(db_type, db_wr_type)
|
s = await self.start_session(db_type, db_wr_type)
|
||||||
try:
|
try:
|
||||||
return await func(s)
|
return await func(s)
|
||||||
@ -206,9 +192,7 @@ class DBSessionManager(Singleton):
|
|||||||
await self.end_session(db_type, db_wr_type)
|
await self.end_session(db_type, db_wr_type)
|
||||||
|
|
||||||
async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
|
async def execute_lambda_run(self, db_type_list: list[int], func_list: list):
|
||||||
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit.
|
"""동일 DB 의 변경 쿼리 여러 개를 한 트랜잭션으로 실행 후 commit."""
|
||||||
하나라도 SUCCESS 가 아니면 즉시 중단(rollback)된다.
|
|
||||||
"""
|
|
||||||
temp_list = list(set(db_type_list))
|
temp_list = list(set(db_type_list))
|
||||||
if len(temp_list) != 1:
|
if len(temp_list) != 1:
|
||||||
return ErrorType.DB_INVALID_TYPE
|
return ErrorType.DB_INVALID_TYPE
|
||||||
@ -228,12 +212,7 @@ class DBSessionManager(Singleton):
|
|||||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||||
|
|
||||||
async def execute_lambda_write(self, db_type: int, func):
|
async def execute_lambda_write(self, db_type: int, func):
|
||||||
"""Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다.
|
"""Write 세션에서 func(session) 을 실행하고 commit 한 뒤 **func 의 반환값을 그대로** 돌려준다."""
|
||||||
|
|
||||||
execute_lambda_run 은 ErrorType 만, execute_lambda_claim 은 (ErrorType, 적용행수) 만 돌려준다.
|
|
||||||
작업 큐처럼 "변경하면서 값을 받아와야" 하는 경우(RETURNING 절)를 위한 진입점이다 —
|
|
||||||
원자적 claim(FOR UPDATE SKIP LOCKED + UPDATE + RETURNING)은 조회/변경을 나눌 수 없다.
|
|
||||||
예외는 rollback 후 그대로 전파한다(호출측이 잡 실패로 처리)."""
|
|
||||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||||
try:
|
try:
|
||||||
result = await func(s)
|
result = await func(s)
|
||||||
@ -246,9 +225,7 @@ class DBSessionManager(Singleton):
|
|||||||
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
await self.end_session(db_type, DBWRType.DB_WRITE.value)
|
||||||
|
|
||||||
async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]:
|
async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]:
|
||||||
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환.
|
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환."""
|
||||||
동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것.
|
|
||||||
(Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)"""
|
|
||||||
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
|
||||||
try:
|
try:
|
||||||
err_type, rowcount = await func(s)
|
err_type, rowcount = await func(s)
|
||||||
|
|||||||
@ -22,27 +22,13 @@ from common.enums import (
|
|||||||
JobStatus,
|
JobStatus,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 모든 ORM 모델의 베이스. insert 시 isinstance 체크에도 사용된다.
|
# 모든 ORM 모델의 베이스.
|
||||||
MAIN_BASE = declarative_base()
|
MAIN_BASE = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
# 공통 mixin
|
# 공통 mixin DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
||||||
# DB 계약(_DBTypeMixin)과 ERD 공통 컬럼(MainTableMixin)을 분리해 둔다.
|
|
||||||
def _utc_now_sql():
|
def _utc_now_sql():
|
||||||
"""TIMESTAMPTZ 컬럼의 기본값. **init.sql 과 같은 `now()` 여야 한다.**
|
"""TIMESTAMPTZ 컬럼의 기본값."""
|
||||||
|
|
||||||
★ 예전 값은 `(now() AT TIME ZONE 'utc')` 였는데, 이건 timestamptz 에 쓰면 틀린다.
|
|
||||||
AT TIME ZONE 'utc' 는 timestamptz 를 **시간대 없는 벽시계 값**으로 떨어뜨리고,
|
|
||||||
그 값이 timestamptz 컬럼에 들어가며 세션 시간대로 다시 해석된다 — 서버 시간대만큼
|
|
||||||
미래(또는 과거)로 밀린 시각이 저장된다.
|
|
||||||
|
|
||||||
★ 운영에서는 안 드러났다. 운영 DB 는 init.sql(`DEFAULT now()`)로 만들어지고, 이 기본값은
|
|
||||||
**ORM 이 스키마를 만들 때만** 쓰이기 때문이다 — 즉 테스트 DB 뿐이다(conftest).
|
|
||||||
실측(2026-09-10): 테스트에서 잡의 run_after 가 7시간 뒤로 박혀 claim 조건
|
|
||||||
(`run_after <= now()`)에 영영 안 걸렸다. 워커가 잡을 하나도 못 집어 COPY 관련 테스트가
|
|
||||||
"잡이 PENDING 인 채" 로 무더기 실패했고, 원인이 코드가 아니라 스키마라 읽히지 않았다.
|
|
||||||
→ 스키마는 init.sql 이 단일 출처다. ORM 기본값이 그것과 다르면 이런 식으로 갈라진다.
|
|
||||||
"""
|
|
||||||
return text("now()")
|
return text("now()")
|
||||||
|
|
||||||
|
|
||||||
@ -66,11 +52,8 @@ class users(MainTableMixin, MAIN_BASE):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
user_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# 20자였다. 구글 계정의 로그인 아이디를 `google_<sub>`(최대 28자)로 만들면서 넓혔다 —
|
|
||||||
# sub 를 잘라 쓰면 앞자리가 같은 두 계정이 한 아이디로 겹친다.
|
|
||||||
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
|
id = Column(String(64), nullable=False, unique=True, index=True) # 로그인 아이디
|
||||||
# 소셜 계정은 비밀번호가 없다(NULL). 더미 해시를 넣으면 "비번이 있는 계정" 처럼 보여
|
# 소셜 계정은 비밀번호가 없다(NULL).
|
||||||
# id/pw 로그인 경로가 그 계정을 상대로 계속 시도된다.
|
|
||||||
password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
|
password = Column(String(255), nullable=True) # bcrypt 해시 (ERD VARCHAR(30)→255 확장)
|
||||||
name = Column(String(50), nullable=True)
|
name = Column(String(50), nullable=True)
|
||||||
email = Column(String(255), nullable=True)
|
email = Column(String(255), nullable=True)
|
||||||
@ -78,25 +61,16 @@ class users(MainTableMixin, MAIN_BASE):
|
|||||||
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||||
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
|
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
|
||||||
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
|
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
|
||||||
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서
|
# server_default 를 함께 준다 — ORM default 는 raw INSERT(테스트 시드·수동 SQL)에 안 먹어서 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
|
||||||
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
|
|
||||||
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
|
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
|
||||||
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
|
||||||
# ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다
|
# refresh 토큰 무효화 키.
|
||||||
# (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서,
|
|
||||||
# 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다.
|
|
||||||
# ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는
|
|
||||||
# refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다.
|
|
||||||
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
|
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
|
# place : 사업장 / 별칭 / 채널 링크 / 객실·메뉴·프로그램 / 사진
|
||||||
# ============================================================
|
|
||||||
class places(MainTableMixin, MAIN_BASE):
|
class places(MainTableMixin, MAIN_BASE):
|
||||||
"""사업장. 상호명 하나로 시작해서, 카카오 로컬 검증을 통과해야 수집이 열린다.
|
"""사업장."""
|
||||||
|
|
||||||
★ verified_at 이 NULL 이면 collector 진입 금지 — 검증 없이 수집하면 남의 가게가 섞인다."""
|
|
||||||
|
|
||||||
__tablename__ = "places"
|
__tablename__ = "places"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -104,15 +78,13 @@ class places(MainTableMixin, MAIN_BASE):
|
|||||||
)
|
)
|
||||||
|
|
||||||
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
place_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# ★ 스코프 키. 사장님 한 명이 자기 가게만 본다 — 회사(테넌트)를 걷어내면서 이 컬럼이 그 자리를 받았다.
|
# 스코프 키.
|
||||||
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
|
owner_user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 사장님 계정(users)
|
||||||
name = Column(String(200), nullable=False) # 상호명(입력값)
|
name = Column(String(200), nullable=False) # 상호명(입력값)
|
||||||
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
|
category = Column(SmallInteger, nullable=False) # PlaceCategory — 업종 스키마 선택 키
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PlaceStatus.DRAFT.value)
|
||||||
|
|
||||||
# ---- 카카오 로컬 검증 산출물 (동일 업소 판정) ----
|
# 카카오 로컬 검증 산출물 (동일 업소 판정)
|
||||||
# 동일 업소 판정 키. 소스에 따라 있을 수도 없을 수도 있다 —
|
|
||||||
# 카카오는 고유 id 를 주지만 네이버는 안 준다(그 경우 상호명+도로명주소가 대체 키).
|
|
||||||
external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource
|
external_source = Column(SmallInteger, nullable=True) # ExternalPlaceSource
|
||||||
external_place_id = Column(String(64), nullable=True)
|
external_place_id = Column(String(64), nullable=True)
|
||||||
road_address = Column(String(255), nullable=True)
|
road_address = Column(String(255), nullable=True)
|
||||||
@ -121,26 +93,19 @@ class places(MainTableMixin, MAIN_BASE):
|
|||||||
latitude = Column(Numeric(10, 7), nullable=True)
|
latitude = Column(Numeric(10, 7), nullable=True)
|
||||||
longitude = Column(Numeric(10, 7), nullable=True)
|
longitude = Column(Numeric(10, 7), nullable=True)
|
||||||
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
|
region_code = Column(String(10), nullable=True) # 행정구역 코드 — ★ 지역정보 캐시 키(사이트 50개여도 조회 1회)
|
||||||
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션"). 검증 때 박제한다.
|
# 외부 장소 DB 가 준 분류 문자열 원문(카카오 "음식점 > 한식 > 육류" · 네이버 "펜션").
|
||||||
# ★ 쓰임: 주변 맛집에서 **같은 중분류(경쟁 업소)를 빼는** 기준. TourAPI 에 등록된 업장이면 그쪽 분류가 우선이고,
|
|
||||||
# 이 값은 그 폴백이다(services/local_content_service._own_food_class).
|
|
||||||
external_category = Column(String(200), nullable=True)
|
external_category = Column(String(200), nullable=True)
|
||||||
verified_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미검증 → 수집·발행 금지
|
verified_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미검증 → 수집·발행 금지
|
||||||
verified_by = Column(UUID(as_uuid=True), nullable=True)
|
verified_by = Column(UUID(as_uuid=True), nullable=True)
|
||||||
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
# 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각.
|
||||||
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
|
||||||
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
|
# 미니 블로그 승인 메일 수신 주소.
|
||||||
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
|
|
||||||
notify_email = Column(String(255), nullable=True)
|
notify_email = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class place_channels(MainTableMixin, MAIN_BASE):
|
class place_channels(MainTableMixin, MAIN_BASE):
|
||||||
"""Perplexity 가 발견한 채널 URL.
|
"""Perplexity 가 발견한 채널 URL."""
|
||||||
|
|
||||||
★ confirmed_at 이 NULL 이면 크롤링 대상이 아니다 — 카카오 로컬로 동일 업소임을 확인한 URL만 넘긴다.
|
|
||||||
raw 에 Perplexity 응답(본문 + search_results)을 통째로 남긴다. 환각 추적용이며 사실 근거로 쓰지 않는다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_channels"
|
__tablename__ = "place_channels"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -160,14 +125,13 @@ class place_channels(MainTableMixin, MAIN_BASE):
|
|||||||
title = Column(String(300), nullable=True) # 발견 시 제목/스니펫
|
title = Column(String(300), nullable=True) # 발견 시 제목/스니펫
|
||||||
discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력)
|
discovered_by = Column(SmallInteger, nullable=False) # SourceType (API=Perplexity, OWNER=직접 입력)
|
||||||
discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
discovered_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||||
confirmed_at = Column(DateTime(timezone=True), nullable=True) # ★ NULL = 미확정, 크롤링 금지
|
confirmed_at = Column(DateTime(timezone=True), nullable=True) # NULL = 미확정, 크롤링 금지
|
||||||
confirmed_by = Column(UUID(as_uuid=True), nullable=True)
|
confirmed_by = Column(UUID(as_uuid=True), nullable=True)
|
||||||
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
|
raw = Column(JSONB, nullable=True) # Perplexity 응답 원문(본문 + search_results)
|
||||||
|
|
||||||
|
|
||||||
class place_units(MainTableMixin, MAIN_BASE):
|
class place_units(MainTableMixin, MAIN_BASE):
|
||||||
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램.
|
"""업종별 하위 단위 — 숙박=객실, 카페·음식점=메뉴, 피부과·성형외과=프로그램."""
|
||||||
가변 필드는 facts(scope=unit)로 들어가고, 여기에는 목록 렌더에 필요한 뼈대만 둔다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_units"
|
__tablename__ = "place_units"
|
||||||
|
|
||||||
@ -178,11 +142,7 @@ class place_units(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_photos(MainTableMixin, MAIN_BASE):
|
class place_photos(MainTableMixin, MAIN_BASE):
|
||||||
"""사진. Gemini Vision 이 분류 라벨과 alt 를 만든다.
|
"""사진."""
|
||||||
|
|
||||||
★ source_type 을 반드시 남긴다 — 크롤링 이미지의 재게시 권리가 미결이라(docs/DECISIONS.md 1-2),
|
|
||||||
결론에 따라 발행 시 source_type 으로 걸러낼 수 있어야 한다.
|
|
||||||
★ vision_confidence 가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 사람 확인 큐에 둔다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_photos"
|
__tablename__ = "place_photos"
|
||||||
|
|
||||||
@ -203,12 +163,7 @@ class place_photos(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_songs(MainTableMixin, MAIN_BASE):
|
class place_songs(MainTableMixin, MAIN_BASE):
|
||||||
"""이 숙소의 노래. 발행할 때마다 한 곡 만든다 — 가사는 Gemini, 작곡은 Suno.
|
"""이 숙소의 노래."""
|
||||||
|
|
||||||
★ 검증 상태(FactStatus)가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
|
||||||
"맞는가" 를 물을 대상이 아니다. 상태는 "만들어졌는가" 하나다(SongStatus).
|
|
||||||
★ origin_url(Suno 가 준 주소)은 **사이트에 싣지 않는다.** 만료되는 주소라 그대로 두면
|
|
||||||
몇 주 뒤 재생만 조용히 죽는다 — 받아서 보관한 file_name 만 발행본으로 나간다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_songs"
|
__tablename__ = "place_songs"
|
||||||
|
|
||||||
@ -219,30 +174,20 @@ class place_songs(MainTableMixin, MAIN_BASE):
|
|||||||
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
style = Column(String(200), nullable=True) # Suno 에 넘긴 장르·분위기
|
||||||
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
provider = Column(String(40), nullable=False, server_default=text("'suno'"), default="suno")
|
||||||
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
|
provider_task_id = Column(String(120), nullable=True) # Suno taskId — 폴링의 유일한 열쇠
|
||||||
origin_url = Column(String(1000), nullable=True) # ★ 만료되는 주소. 보관용 기록일 뿐이다
|
origin_url = Column(String(1000), nullable=True) # 만료되는 주소.
|
||||||
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
|
file_name = Column(String(200), nullable=True) # solution/site/songs/<이것>
|
||||||
duration_sec = Column(Numeric(6, 2), nullable=True)
|
duration_sec = Column(Numeric(6, 2), nullable=True)
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SongStatus.GENERATING.value)
|
||||||
last_error = Column(Text, nullable=True)
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# fact : 사실 / FAQ
|
# fact : 사실 / FAQ
|
||||||
# ============================================================
|
|
||||||
class place_facts(MainTableMixin, MAIN_BASE):
|
class place_facts(MainTableMixin, MAIN_BASE):
|
||||||
"""★ 가장 중요한 테이블. 모든 사실은 값과 함께 출처·수집시각·검증상태를 갖는다.
|
"""가장 중요한 테이블."""
|
||||||
|
|
||||||
- key 는 업종 스키마(common/category_schema)에 정의된 것만 허용한다.
|
|
||||||
- unit_id 가 NULL 이면 사업장 단위 fact, 있으면 객실·메뉴·프로그램 단위 fact.
|
|
||||||
- ★ VERIFIED / CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES).
|
|
||||||
- ★ CORRECTED(사장님 수정본)는 잠긴다 — 자동 갱신이 덮어쓰지 않는다.
|
|
||||||
|
|
||||||
활성 유니크: 같은 (place, unit, key) 로 살아있는 fact 는 1건. REJECTED/EXPIRED 는 이력으로 남기므로 제외한다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_facts"
|
__tablename__ = "place_facts"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
|
# unit_id 가 NULL 인 행끼리는 유니크가 안 걸리므로 place 단위 / unit 단위를 나눠 건다.
|
||||||
# 노출값은 (사업장, 단위, key) 당 1건. 후보(1,2)·이력(5,6)은 제외 — 재수집이 쌓일 수 있게.
|
|
||||||
Index(
|
Index(
|
||||||
"uq_facts_published_place_key",
|
"uq_facts_published_place_key",
|
||||||
"place_id",
|
"place_id",
|
||||||
@ -291,12 +236,7 @@ class place_facts(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_faqs(MainTableMixin, MAIN_BASE):
|
class place_faqs(MainTableMixin, MAIN_BASE):
|
||||||
"""FAQ. 출처(generated_by)가 셋이고, 근거를 요구하는 정도가 다르다.
|
"""FAQ."""
|
||||||
|
|
||||||
LLM 확보된 fact 로 쓴 문장 — source_fact_ids 에 근거 key 가 있다(없으면 저장하지 않는다)
|
|
||||||
OWNER 사장님이 쓰거나 고친 문장 — 사람이 곧 출처라 근거 key 가 없을 수 있다
|
|
||||||
TEMPLATE 목표 수를 채운 공통 질문 + 문의 안내 답(services/faq_fill) — 주장이 없어 근거도 없다.
|
|
||||||
★ 화면에는 나가지만 FAQPage JSON-LD · llms.txt · 고유 콘텐츠 계수에서는 빠진다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_faqs"
|
__tablename__ = "place_faqs"
|
||||||
|
|
||||||
@ -311,22 +251,7 @@ class place_faqs(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_itineraries(MainTableMixin, MAIN_BASE):
|
class place_itineraries(MainTableMixin, MAIN_BASE):
|
||||||
"""LLM 이 만든 여행 일정. **기간당 한 행**이고 `body` 에 코스 5개가 통째로 든다.
|
"""LLM 이 만든 여행 일정."""
|
||||||
|
|
||||||
★ 왜 area_contents 가 아닌가
|
|
||||||
그 표의 유일성 근거는 셋 다 지역·출처 기준이다((source, external_id) ·
|
|
||||||
(region_code, kind) · (region_code, content_type)). 이 값은 **업장 하나에 붙는다** —
|
|
||||||
업소 이름이 프롬프트에 들어가고, 같은 지역 옆집이 나눠 쓸 수 없다.
|
|
||||||
넷째 근거를 그 표에 더하면 0004·0007 에서 겪은 "제약이 겹쳐 조용히 틀리는" 사고를
|
|
||||||
다시 만든다(area_contents.__table_args__ 주석).
|
|
||||||
|
|
||||||
★ body 는 렌더러 계약 그대로다(`shared/lib/section-data.ts` 의 ItineraryItem[]).
|
|
||||||
읽는 쪽이 모양을 다시 바꾸지 않아야 사장님이 손으로 붙여넣은 것과 갈리지 않는다 —
|
|
||||||
지역 이야기가 body 에 봉투째 담는 것과 같은 이유다.
|
|
||||||
|
|
||||||
★ 코스마다 한 행으로 쪼개지 않는다. 다시 생성할 때 그 한 행을 덮어쓰면 되고,
|
|
||||||
쪼개면 "5개를 받았는데 3개만 갱신된" 상태가 생긴다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "place_itineraries"
|
__tablename__ = "place_itineraries"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -340,7 +265,7 @@ class place_itineraries(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
place_itinerary_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
place_id = Column(UUID(as_uuid=True), nullable=False, index=True)
|
||||||
# '1박 2일' · '2박 3일'. 화면 탭이 되는 값이라 표기를 바꾸지 않는다(prompts/itinerary.DURATIONS).
|
# '1박 2일' · '2박 3일'.
|
||||||
duration = Column(String(20), nullable=False)
|
duration = Column(String(20), nullable=False)
|
||||||
body = Column(JSONB, nullable=False) # ItineraryItem[]
|
body = Column(JSONB, nullable=False) # ItineraryItem[]
|
||||||
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM
|
generated_by = Column(SmallInteger, nullable=False) # SourceType — LLM
|
||||||
@ -348,26 +273,13 @@ class place_itineraries(MainTableMixin, MAIN_BASE):
|
|||||||
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
|
# local : 지역 정보(행정구역 코드 단위 캐시) / 가는 길 / 주변
|
||||||
# ============================================================
|
|
||||||
class area_contents(MainTableMixin, MAIN_BASE):
|
class area_contents(MainTableMixin, MAIN_BASE):
|
||||||
"""지역 정보 캐시. ★ 키는 place_id 가 아니라 region_code 다 —
|
"""지역 정보 캐시."""
|
||||||
같은 지역에 사이트 50개가 생겨도 외부 조회는 1회여야 한다.
|
|
||||||
|
|
||||||
★ 외부 API 실패 시 이 행을 지우거나 비우지 않는다 — 직전 값을 그대로 유지하고 내부 알림만 낸다."""
|
|
||||||
|
|
||||||
__tablename__ = "area_contents"
|
__tablename__ = "area_contents"
|
||||||
# ★ 유일성의 근거가 셋이고 **서로 겹치면 안 된다.** 겹쳐서 조용히 틀린 적이 있다 —
|
|
||||||
# 지역 이야기 다섯 종이 (region_code, content_type=6) 하나를 두고 부딪쳐 **첫 종류만
|
|
||||||
# 저장되고 잡은 "성공" 으로 끝났다**(실측 2026-09-09, 52군산시: 생성 54건 · 저장 1종류).
|
|
||||||
# 그래서 조건에 external_id / kind 의 유무를 넣어 셋이 각자 자기 몫만 보게 가른다.
|
|
||||||
# ★ 이 세 정의는 init.sql · migrations(0004·0007·0008) 과 **같아야 한다.** 테스트 DB 는
|
|
||||||
# 이 모델로 세워지므로, 어긋나면 테스트가 운영과 다른 제약 아래에서 돈다 —
|
|
||||||
# 실제로 그랬다: 여기만 옛 정의로 남아, 운영 DB 가 허용하는 행을 테스트가 거부했다.
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다. **지역과 무관하다** —
|
# 축제·관광지·맛집: 출처가 준 id 하나면 한 행이다.
|
||||||
# 같은 축제가 시군구마다 한 행씩 생기면 "공용 한 벌" 이 아니다(0004).
|
|
||||||
Index(
|
Index(
|
||||||
"uq_local_contents_external",
|
"uq_local_contents_external",
|
||||||
"source",
|
"source",
|
||||||
@ -383,7 +295,7 @@ class area_contents(MainTableMixin, MAIN_BASE):
|
|||||||
unique=True,
|
unique=True,
|
||||||
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
|
postgresql_where=text("deleted = false AND kind IS NOT NULL AND external_id IS NULL"),
|
||||||
),
|
),
|
||||||
# 날씨: 지역 × 종류당 한 행. kind 가 있는 행은 위가 책임지므로 여기서 뺀다(0007).
|
# 날씨: 지역 × 종류당 한 행.
|
||||||
Index(
|
Index(
|
||||||
"uq_local_contents_single",
|
"uq_local_contents_single",
|
||||||
"region_code",
|
"region_code",
|
||||||
@ -394,9 +306,7 @@ class area_contents(MainTableMixin, MAIN_BASE):
|
|||||||
)
|
)
|
||||||
|
|
||||||
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
local_content_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
# ★ nullable 이다. 축제·관광지·맛집은 **전국 공용**이라 지역이 유일성의 근거가 아니다 —
|
# nullable 이다.
|
||||||
# 같은 축제가 시군구마다 한 행씩 생기면 "한 벌" 이 아니다(migrations/0004).
|
|
||||||
# 지역 이야기·날씨만 이 값을 키로 쓴다.
|
|
||||||
region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드
|
region_code = Column(String(10), nullable=True, index=True) # 카카오 행정구역 코드
|
||||||
content_type = Column(SmallInteger, nullable=False) # LocalContentType
|
content_type = Column(SmallInteger, nullable=False) # LocalContentType
|
||||||
source = Column(SmallInteger, nullable=False) # LocalSource
|
source = Column(SmallInteger, nullable=False) # LocalSource
|
||||||
@ -410,18 +320,14 @@ class area_contents(MainTableMixin, MAIN_BASE):
|
|||||||
display_end_at = Column(DateTime(timezone=True), nullable=True)
|
display_end_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
collected_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
||||||
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
|
expires_at = Column(DateTime(timezone=True), nullable=True) # TTL — 지나면 갱신 대상(값은 유지)
|
||||||
# ★ 0004 에서 늘렸다. 좌표는 body 안에도 있지만 거리 계산이 행마다 JSON 을 펴야 해서 꺼냈다.
|
|
||||||
latitude = Column(Numeric(10, 7), nullable=True)
|
latitude = Column(Numeric(10, 7), nullable=True)
|
||||||
longitude = Column(Numeric(10, 7), nullable=True)
|
longitude = Column(Numeric(10, 7), nullable=True)
|
||||||
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류. 장소류는 NULL.
|
# 지역 이야기(songs·people·chronicle·postcard·quiz)의 종류.
|
||||||
kind = Column(String(50), nullable=True)
|
kind = Column(String(50), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class place_area_refs(MainTableMixin, MAIN_BASE):
|
class place_area_refs(MainTableMixin, MAIN_BASE):
|
||||||
"""업장 ↔ 지역 콘텐츠. 업장별로 다른 것은 거리와 숨김뿐이다.
|
"""업장 ↔ 지역 콘텐츠."""
|
||||||
|
|
||||||
★ 예전엔 값을 통째로 들고 키가 place_id 라 업장마다 복제됐다(한 곳에 144행).
|
|
||||||
★ hidden 은 재수집이 덮어쓰지 않는다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_area_refs"
|
__tablename__ = "place_area_refs"
|
||||||
|
|
||||||
@ -433,11 +339,7 @@ class place_area_refs(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class place_posts(MainTableMixin, MAIN_BASE):
|
class place_posts(MainTableMixin, MAIN_BASE):
|
||||||
"""미니 블로그 글 하나. 기획: docs/MINI_BLOG.md
|
"""미니 블로그 글 하나."""
|
||||||
|
|
||||||
★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
|
|
||||||
★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
|
|
||||||
★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_posts"
|
__tablename__ = "place_posts"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -454,11 +356,8 @@ class place_posts(MainTableMixin, MAIN_BASE):
|
|||||||
topic_kind = Column(SmallInteger, nullable=False)
|
topic_kind = Column(SmallInteger, nullable=False)
|
||||||
topic_key = Column(String(120), nullable=False)
|
topic_key = Column(String(120), nullable=False)
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
|
||||||
# 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date).
|
# 이 업장 몫 하루 한 통 배정일(KST).
|
||||||
scheduled_date = Column(Date, nullable=True)
|
scheduled_date = Column(Date, nullable=True)
|
||||||
# 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
|
|
||||||
# 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
|
|
||||||
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
|
|
||||||
generation_meta = Column(JSONB, nullable=True)
|
generation_meta = Column(JSONB, nullable=True)
|
||||||
approve_token_hash = Column(String(64), nullable=True)
|
approve_token_hash = Column(String(64), nullable=True)
|
||||||
token_expires_at = Column(DateTime(timezone=True), 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):
|
class place_reviews(MainTableMixin, MAIN_BASE):
|
||||||
"""손님이 남긴 이용 후기.
|
"""손님이 남긴 이용 후기."""
|
||||||
|
|
||||||
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
|
|
||||||
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
|
|
||||||
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_reviews"
|
__tablename__ = "place_reviews"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -491,8 +386,7 @@ class place_reviews(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class sites(MainTableMixin, MAIN_BASE):
|
class sites(MainTableMixin, MAIN_BASE):
|
||||||
"""발행 대상 사이트. 사업장당 1개.
|
"""발행 대상 사이트."""
|
||||||
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
|
|
||||||
|
|
||||||
__tablename__ = "sites"
|
__tablename__ = "sites"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -504,15 +398,14 @@ class sites(MainTableMixin, MAIN_BASE):
|
|||||||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
place_id = Column(UUID(as_uuid=True), nullable=False)
|
||||||
domain = Column(String(255), nullable=True)
|
domain = Column(String(255), nullable=True)
|
||||||
path_prefix = Column(String(100), nullable=True)
|
path_prefix = Column(String(100), nullable=True)
|
||||||
# 템플릿 id(solution/shared/src/data/templates.json). NULL이면 업종 기본 템플릿으로 굽는다.
|
# 템플릿 id(solution/shared/src/data/templates.json).
|
||||||
template_id = Column(String(100), nullable=True)
|
template_id = Column(String(100), nullable=True)
|
||||||
# 색·섹션(순서·on/off·본문). 내용 키는 프론트가 소유하므로 jsonb로 통째로 담는다.
|
# 색·섹션(순서·on/off·본문).
|
||||||
theme = Column(JSONB, nullable=True)
|
theme = Column(JSONB, nullable=True)
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=SiteStatus.DRAFT.value)
|
||||||
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
|
current_version_id = Column(UUID(as_uuid=True), nullable=True) # site_versions.site_version_id
|
||||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
published_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
# 발행 썸네일(Azure Blob 공개 URL). ★ 발행에 성공한 뒤에만 채운다 — 굽다 만 사이트의 그림을
|
# 발행 썸네일(Azure Blob 공개 URL).
|
||||||
# 쇼케이스에 걸면 없는 페이지로 보낸다. 만들지 못하면 NULL 이고, 화면은 글자 카드로 떨어진다.
|
|
||||||
thumbnail_url = Column(String(500), nullable=True)
|
thumbnail_url = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
@ -536,14 +429,7 @@ class site_search_status(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class alert_outbox(MainTableMixin, MAIN_BASE):
|
class alert_outbox(MainTableMixin, MAIN_BASE):
|
||||||
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다.
|
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다."""
|
||||||
|
|
||||||
★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다.
|
|
||||||
장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다.
|
|
||||||
★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) —
|
|
||||||
같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다.
|
|
||||||
★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면
|
|
||||||
다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다."""
|
|
||||||
|
|
||||||
__tablename__ = "alert_outbox"
|
__tablename__ = "alert_outbox"
|
||||||
alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@ -559,17 +445,7 @@ class alert_outbox(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class site_sections(MainTableMixin, MAIN_BASE):
|
class site_sections(MainTableMixin, MAIN_BASE):
|
||||||
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
|
"""섹션 하나의 콘텐츠."""
|
||||||
|
|
||||||
★ 왜 theme 에서 꺼냈나 (2026-09-09)
|
|
||||||
색·서체(디자인)와 섹션 콘텐츠가 `sites.theme` JSONB 한 칸에 같이 있었다.
|
|
||||||
실측(/s/stay): theme 42,150 B 중 디자인은 636 B(1.5%)이고 콘텐츠가 39,645 B(94%)다.
|
|
||||||
크기가 문제가 아니라 **쓰기 단위**가 문제였다 — 영상 주소 하나(592 B)를 고쳐도
|
|
||||||
42 KB 를 통째로 다시 쓰고, 둘이 만지면 나중 쓰기가 앞을 덮고, 항목마다
|
|
||||||
"누가 넣었나 · 확인됐나"를 물을 자리가 없었다.
|
|
||||||
★ 순서·on/off·배리에이션은 여전히 theme 이 갖는다. 여기는 **내용만** 든다.
|
|
||||||
★ shared_ref 가 있으면 값을 복제하지 않고 원본(region_stories 등)을 가리킨다 —
|
|
||||||
발행할 때 펼쳐 payload 에 싣는다."""
|
|
||||||
|
|
||||||
__tablename__ = "site_sections"
|
__tablename__ = "site_sections"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -592,10 +468,7 @@ class site_sections(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class site_versions(MainTableMixin, MAIN_BASE):
|
class site_versions(MainTableMixin, MAIN_BASE):
|
||||||
"""빌드 버전. ★ 정적 빌드 — snapshot 에 빌드 시점 데이터를 박제하고, 방문자는 DB 와 만나지 않는다.
|
"""빌드 버전."""
|
||||||
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다.
|
|
||||||
★ jsonld 값은 화면에 보이는 값과 같아야 한다 — 불일치면 빌드 실패(PUBLISH_JSONLD_MISMATCH).
|
|
||||||
★ unique_content_count 가 0 이면 발행 API 가 거부한다(스팸 판정 대상)."""
|
|
||||||
|
|
||||||
__tablename__ = "site_versions"
|
__tablename__ = "site_versions"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -608,13 +481,13 @@ class site_versions(MainTableMixin, MAIN_BASE):
|
|||||||
build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value)
|
build_status = Column(SmallInteger, nullable=False, server_default=text("1"), default=BuildStatus.PENDING.value)
|
||||||
snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제
|
snapshot = Column(JSONB, nullable=True) # 빌드 시점 데이터 박제
|
||||||
jsonld = Column(JSONB, nullable=True) # 구조화 데이터
|
jsonld = Column(JSONB, nullable=True) # 구조화 데이터
|
||||||
unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # ★ 0 이면 발행 거부
|
unique_content_count = Column(Integer, nullable=False, server_default=text("0"), default=0) # 0 이면 발행 거부
|
||||||
build_error = Column(Text, nullable=True)
|
build_error = Column(Text, nullable=True)
|
||||||
built_at = Column(DateTime(timezone=True), nullable=True)
|
built_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class site_publish_logs(MainTableMixin, MAIN_BASE):
|
class site_publish_logs(MainTableMixin, MAIN_BASE):
|
||||||
"""발행 시도 기록. 검수 게이트가 막았으면 result=REJECTED + reject_reason 을 남긴다."""
|
"""발행 시도 기록."""
|
||||||
|
|
||||||
__tablename__ = "site_publish_logs"
|
__tablename__ = "site_publish_logs"
|
||||||
|
|
||||||
@ -630,18 +503,7 @@ class site_publish_logs(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class jobs(MainTableMixin, MAIN_BASE):
|
class jobs(MainTableMixin, MAIN_BASE):
|
||||||
"""작업 큐. 수집·비전분석·빌드는 몇 분 걸려 동기 요청으로 처리할 수 없다.
|
"""작업 큐."""
|
||||||
|
|
||||||
- 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING.
|
|
||||||
워커 컨테이너가 몇 개든 같은 잡 이중 할당이 불가능하다.
|
|
||||||
- 복구는 타임아웃 추측이 아니라 **lease 만료 소유권** — 워커가 죽어도 reaper 가 회수한다.
|
|
||||||
(도커에서 컨테이너를 재시작해도 진행 중이던 잡이 증발하지 않는다.)
|
|
||||||
- 재시도·백오프·dead-letter 를 큐에 내장한다.
|
|
||||||
- dedupe_key 로 활성 중복(PENDING/RUNNING)을 막는다 — 같은 사업장 수집이 두 번 돌지 않게.
|
|
||||||
|
|
||||||
※ 이 테이블만 MainTableMixin 의 deleted 를 쓰지 않는다(잡은 이력이지 소프트 삭제 대상이 아니다).
|
|
||||||
그래도 컬럼은 남겨 공통 규약을 깨지 않는다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "jobs"
|
__tablename__ = "jobs"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
@ -658,9 +520,7 @@ class jobs(MainTableMixin, MAIN_BASE):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ★ 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라
|
# 이 테이블만 PK 에 server_default 가 필요하다 — 큐 전이는 raw SQL(RETURNING) 이라 ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다.
|
||||||
# ORM 의 Python 쪽 default(uuid.uuid4)가 적용되지 않는다. init.sql 의 DEFAULT gen_random_uuid() 와 맞춘다.
|
|
||||||
# (다른 테이블은 ORM 으로만 INSERT 하므로 원본 보일러플레이트대로 Python default 만 둔다.)
|
|
||||||
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4)
|
job_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"), default=uuid.uuid4)
|
||||||
job_type = Column(SmallInteger, nullable=False) # JobType
|
job_type = Column(SmallInteger, nullable=False) # JobType
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value)
|
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=JobStatus.PENDING.value)
|
||||||
@ -696,13 +556,7 @@ class owner_social_accounts(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
||||||
"""카카오톡 채널 발화자 ↔ 우리 user_id.
|
"""카카오톡 채널 발화자 ↔ 우리 user_id."""
|
||||||
|
|
||||||
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
|
|
||||||
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
|
|
||||||
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
|
|
||||||
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
|
|
||||||
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
|
|
||||||
|
|
||||||
__tablename__ = "owner_kakao_links"
|
__tablename__ = "owner_kakao_links"
|
||||||
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
@ -714,8 +568,7 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
|||||||
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
||||||
linked_at = Column(DateTime(timezone=True), nullable=True)
|
linked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
# 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다).
|
# pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
|
||||||
# ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
|
|
||||||
current_place_id = Column(UUID(as_uuid=True), nullable=True)
|
current_place_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
pending_tool = Column(String(40), nullable=True)
|
pending_tool = Column(String(40), nullable=True)
|
||||||
pending_args = Column(JSONB, nullable=True)
|
pending_args = Column(JSONB, nullable=True)
|
||||||
|
|||||||
@ -15,12 +15,7 @@ class CodeEnum(Enum):
|
|||||||
|
|
||||||
|
|
||||||
class ErrorType(Enum):
|
class ErrorType(Enum):
|
||||||
"""서버 전역 결과 코드. Res_WebPacketProtocol.result 에 담겨 클라이언트로 전달된다.
|
"""서버 전역 결과 코드."""
|
||||||
HTTP status 와 겹치지 않도록 구간을 분리해서 관리한다.
|
|
||||||
|
|
||||||
도메인 코드는 모듈이 붙을 때 구간을 새로 열어 추가한다
|
|
||||||
(places 1200 / facts 1300 / collector 1400 / generator 1500 / local 1600 / sites 1700 / reports 1800 예약).
|
|
||||||
"""
|
|
||||||
|
|
||||||
SUCCESS = 0
|
SUCCESS = 0
|
||||||
FAIL = 1
|
FAIL = 1
|
||||||
@ -55,28 +50,28 @@ class ErrorType(Enum):
|
|||||||
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
|
||||||
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
|
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
|
||||||
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
|
||||||
ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
|
ACCOUNT_SESSION_REVOKED = auto()
|
||||||
|
|
||||||
# 사업장(places) 관련 에러
|
# 사업장(places) 관련 에러
|
||||||
PLACE_NOT_FOUND = 1200
|
PLACE_NOT_FOUND = 1200
|
||||||
PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록
|
PLACE_ALREADY_EXIST = auto() # 같은 회사 안에 같은 카카오 장소 ID — 중복 등록
|
||||||
PLACE_NOT_VERIFIED = auto() # ★ 동일 업소 검증 전 — 수집·발행 진입 금지
|
PLACE_NOT_VERIFIED = auto() # 동일 업소 검증 전 — 수집·발행 진입 금지
|
||||||
PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음
|
PLACE_VERIFY_NO_CANDIDATE = auto() # 카카오 로컬에서 후보를 못 찾음
|
||||||
PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함
|
PLACE_VERIFY_AMBIGUOUS = auto() # 동명 업소 다수 — 사람이 골라야 함
|
||||||
PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드
|
PLACE_INVALID_CATEGORY = auto() # 지원하지 않는 업종 코드
|
||||||
UNIT_NOT_FOUND = auto()
|
UNIT_NOT_FOUND = auto()
|
||||||
LINK_NOT_FOUND = auto()
|
LINK_NOT_FOUND = auto()
|
||||||
LINK_NOT_CONFIRMED = auto() # ★ 확정 안 된 URL — 크롤링 대상 아님
|
LINK_NOT_CONFIRMED = auto() # 확정 안 된 URL — 크롤링 대상 아님
|
||||||
MEDIA_NOT_FOUND = auto()
|
MEDIA_NOT_FOUND = auto()
|
||||||
|
|
||||||
# fact 관련 에러
|
# fact 관련 에러
|
||||||
FACT_NOT_FOUND = 1300
|
FACT_NOT_FOUND = 1300
|
||||||
FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key
|
FACT_INVALID_KEY = auto() # 업종 스키마에 없는 key
|
||||||
FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이
|
FACT_INVALID_TRANSITION = auto() # 허용되지 않은 검증 상태 전이
|
||||||
FACT_LOCKED = auto() # ★ CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다
|
FACT_LOCKED = auto() # CORRECTED(사장님 수정본) — 자동 갱신이 덮어쓸 수 없다
|
||||||
FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음
|
FACT_SOURCE_REQUIRED = auto() # source_type 이 owner 가 아닌데 source_url 이 없음
|
||||||
FAQ_NOT_FOUND = auto()
|
FAQ_NOT_FOUND = auto()
|
||||||
FAQ_UNGROUNDED = auto() # ★ 확보된 fact 로 뒷받침되지 않는 문장 — 반려
|
FAQ_UNGROUNDED = auto() # 확보된 fact 로 뒷받침되지 않는 문장 — 반려
|
||||||
|
|
||||||
# 수집(collector) 관련 에러
|
# 수집(collector) 관련 에러
|
||||||
COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음
|
COLLECT_ADAPTER_NOT_FOUND = 1400 # 해당 URL 을 처리할 어댑터 없음
|
||||||
@ -93,17 +88,17 @@ class ErrorType(Enum):
|
|||||||
# 지역 정보(local) 관련 에러
|
# 지역 정보(local) 관련 에러
|
||||||
LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정
|
LOCAL_NOT_CONFIGURED = 1600 # KAKAO_REST_API_KEY / TOUR_API_KEY 미설정
|
||||||
LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패
|
LOCAL_REGION_UNKNOWN = auto() # 좌표 → 행정구역 코드 변환 실패
|
||||||
LOCAL_FETCH_FAILED = auto() # ★ 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다
|
LOCAL_FETCH_FAILED = auto() # 실패해도 직전 값을 유지한다 — 빈 값을 내보내지 않는다
|
||||||
|
|
||||||
# 사이트(sites) 관련 에러
|
# 사이트(sites) 관련 에러
|
||||||
SITE_NOT_FOUND = 1700
|
SITE_NOT_FOUND = 1700
|
||||||
SITE_VERSION_NOT_FOUND = auto()
|
SITE_VERSION_NOT_FOUND = auto()
|
||||||
SITE_BUILD_FAILED = auto()
|
SITE_BUILD_FAILED = auto()
|
||||||
PUBLISH_UNVERIFIED_FACT = auto() # ★ 미검증 fact 포함 — 발행 거부
|
PUBLISH_UNVERIFIED_FACT = auto() # 미검증 fact 포함 — 발행 거부
|
||||||
PUBLISH_NO_UNIQUE_CONTENT = auto() # ★ 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상)
|
PUBLISH_NO_UNIQUE_CONTENT = auto() # 고유 콘텐츠 0건 — 발행 거부(스팸 판정 대상)
|
||||||
PUBLISH_JSONLD_MISMATCH = auto() # ★ 구조화 데이터 값 != 화면 값 — 빌드 실패
|
PUBLISH_JSONLD_MISMATCH = auto() # 구조화 데이터 값 != 화면 값 — 빌드 실패
|
||||||
PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락
|
PUBLISH_REQUIRED_FACT_MISSING = auto() # 업종 스키마의 required 필드 누락
|
||||||
SITE_SLUG_LOCKED = auto() # ★ 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다
|
SITE_SLUG_LOCKED = auto() # 이미 발행된 사이트의 주소 변경 — 색인된 페이지가 404 가 된다
|
||||||
|
|
||||||
# 리포트(reports) 관련 에러
|
# 리포트(reports) 관련 에러
|
||||||
REPORT_NOT_FOUND = 1800
|
REPORT_NOT_FOUND = 1800
|
||||||
@ -132,15 +127,13 @@ EXCEPTION_HTTP_INVALID_TOKEN_ACCESS = HTTPException(status_code=ErrorType.HTTP_I
|
|||||||
|
|
||||||
|
|
||||||
class DBType(Enum):
|
class DBType(Enum):
|
||||||
"""논리 DB 구분. 모델마다 DBType() 으로 자신이 속한 DB 를 반환한다.
|
"""논리 DB 구분."""
|
||||||
DB 가 늘어나면 여기에 추가하고 db_session_manager 의 맵에 등록만 하면 된다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
MAIN = 1
|
MAIN = 1
|
||||||
|
|
||||||
|
|
||||||
class DBWRType(Enum):
|
class DBWRType(Enum):
|
||||||
"""Read / Write 접속 구분. 조회는 DB_READ, 변경은 DB_WRITE 엔진을 사용한다."""
|
"""Read / Write 접속 구분."""
|
||||||
|
|
||||||
DB_READ = 1
|
DB_READ = 1
|
||||||
DB_WRITE = 2
|
DB_WRITE = 2
|
||||||
@ -155,9 +148,7 @@ class UserStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class UserRole(CodeEnum):
|
class UserRole(CodeEnum):
|
||||||
"""users.role 코드값.
|
"""users.role 코드값."""
|
||||||
1=일반, 2=최고관리자(고객사 최상위), 3=개발자(우리 내부 운영 계정).
|
|
||||||
개발자 계정은 고객사에 존재를 노출하지 않는다 — 회원 목록에서 빼고 총계에도 넣지 않는다."""
|
|
||||||
|
|
||||||
USER = 1
|
USER = 1
|
||||||
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
|
OWNER = 2 # 최고관리자: 자기 회사 계정 관리 + 회사 설정
|
||||||
@ -165,11 +156,7 @@ class UserRole(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class AuthProvider(CodeEnum):
|
class AuthProvider(CodeEnum):
|
||||||
"""users.provider 코드값. 이 계정이 무엇으로 신원을 증명하는가.
|
"""users.provider 코드값."""
|
||||||
|
|
||||||
한 계정은 수단 하나다 — 같은 이메일이라도 id/pw 계정과 구글 계정을 자동으로 잇지 않는다.
|
|
||||||
이으려면 "먼저 가입한 쪽의 소유"를 증명받아야 하는데, 그 증명 없이 이메일만 보고 이으면
|
|
||||||
남이 먼저 만들어 둔 계정에 내 구글 로그인이 들어간다(계정 선점). 보류 사유는 DECISIONS.md 1절."""
|
|
||||||
|
|
||||||
LOCAL = 1 # id/pw
|
LOCAL = 1 # id/pw
|
||||||
GOOGLE = 2 # 구글 ID 토큰
|
GOOGLE = 2 # 구글 ID 토큰
|
||||||
@ -183,8 +170,7 @@ class CompanyStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class PlaceCategory(CodeEnum):
|
class PlaceCategory(CodeEnum):
|
||||||
"""places.category 코드값. 업종 — 스키마 파일(common/category_schema/resources/*.json)과 1:1.
|
"""places.category 코드값."""
|
||||||
업종 추가 = 여기에 코드 추가 + 스키마 파일 1개 추가."""
|
|
||||||
|
|
||||||
LODGING = 1 # 숙박
|
LODGING = 1 # 숙박
|
||||||
CAFE = 2 # 카페
|
CAFE = 2 # 카페
|
||||||
@ -193,18 +179,14 @@ class PlaceCategory(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class ExternalPlaceSource(CodeEnum):
|
class ExternalPlaceSource(CodeEnum):
|
||||||
"""places.external_source 코드값. 동일 업소 검증에 쓴 외부 장소 DB.
|
"""places.external_source 코드값."""
|
||||||
|
|
||||||
카카오는 안정적인 고유 place id 를 준다 → 그걸로 중복 등록을 막는다.
|
|
||||||
네이버는 고유 id 가 없다(응답의 link 는 업체 홈페이지다) → 상호명+도로명주소로 막는다."""
|
|
||||||
|
|
||||||
KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O
|
KAKAO = 1 # dapi.kakao.com — 고유 place id O · 전화번호 O · 행정구역 코드 O
|
||||||
NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한
|
NAVER = 2 # openapi.naver.com 지역검색 — 고유 id X · 전화번호 X · 5건 제한
|
||||||
|
|
||||||
|
|
||||||
class PlaceStatus(CodeEnum):
|
class PlaceStatus(CodeEnum):
|
||||||
"""places.status 코드값. 사업장 생애주기.
|
"""places.status 코드값."""
|
||||||
해지는 삭제가 아니라 SUSPENDED 로의 상태 전이다(색인된 페이지를 갑자기 404 로 만들지 않는다)."""
|
|
||||||
|
|
||||||
DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전
|
DRAFT = 1 # 등록만 됨 — 동일 업소 검증 전
|
||||||
COLLECTING = 2 # 수집 진행 중
|
COLLECTING = 2 # 수집 진행 중
|
||||||
@ -214,19 +196,17 @@ class PlaceStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class SourceType(CodeEnum):
|
class SourceType(CodeEnum):
|
||||||
"""facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값.
|
"""facts.source_type / media.source_type / place_aliases.source_type / place_faqs.generated_by 공용 코드값."""
|
||||||
값이 어디서 왔는지 — 모든 사실은 출처를 갖는다."""
|
|
||||||
|
|
||||||
OWNER = 1 # 사장님이 직접 입력·업로드
|
OWNER = 1 # 사장님이 직접 입력·업로드
|
||||||
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
|
API = 2 # 공식 API (카카오 로컬 · TourAPI · Open-Meteo · Perplexity)
|
||||||
CRAWL = 3 # 크롤링
|
CRAWL = 3 # 크롤링
|
||||||
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
|
LLM = 4 # LLM 생성 — ★ 사실이 아니라 문장에만 쓴다
|
||||||
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용. fact 에는 못 쓴다
|
TEMPLATE = 5 # FAQ 목표 수를 채운 공통 질문 + 문의 안내(services/faq_fill) — ★ FAQ 전용.
|
||||||
|
|
||||||
|
|
||||||
class FactStatus(CodeEnum):
|
class FactStatus(CodeEnum):
|
||||||
"""facts.status / faqs.status / routes.status 공용 검증 상태.
|
"""facts.status / faqs.status / routes.status 공용 검증 상태."""
|
||||||
★ VERIFIED 와 CORRECTED 만 사이트에 노출한다(PUBLISHABLE_FACT_STATUSES)."""
|
|
||||||
|
|
||||||
UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함
|
UNVERIFIED = 1 # 수집됐으나 아무도 확인 안 함
|
||||||
PENDING_OWNER = 2 # 사장님 확인 대기
|
PENDING_OWNER = 2 # 사장님 확인 대기
|
||||||
@ -236,27 +216,26 @@ class FactStatus(CodeEnum):
|
|||||||
EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상
|
EXPIRED = 6 # 유효기간 지남 — 노출 안 함, 재수집 대상
|
||||||
|
|
||||||
|
|
||||||
# ★ 절대규칙 1: 이 두 상태만 사이트에 노출한다. 발행 게이트가 이 집합으로 필터링한다.
|
# 절대규칙 1: 이 두 상태만 사이트에 노출한다.
|
||||||
PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED}
|
PUBLISHABLE_FACT_STATUSES = {FactStatus.VERIFIED, FactStatus.CORRECTED}
|
||||||
|
|
||||||
# 후보 — 재수집이 올려놓은 확인 대기 항목. 노출값과 달리 (place, unit, key) 당 여러 건 공존한다.
|
# 후보 — 재수집이 올려놓은 확인 대기 항목.
|
||||||
CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER}
|
CANDIDATE_FACT_STATUSES = {FactStatus.UNVERIFIED, FactStatus.PENDING_OWNER}
|
||||||
|
|
||||||
# ★ 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태. 사장님 수정본은 후보로만 도전받는다.
|
# 절대규칙 6: 자동 수집(api/crawl/llm)이 덮어쓸 수 없는 상태.
|
||||||
LOCKED_FACT_STATUSES = {FactStatus.CORRECTED}
|
LOCKED_FACT_STATUSES = {FactStatus.CORRECTED}
|
||||||
|
|
||||||
|
|
||||||
class FactWriteOutcome(CodeEnum):
|
class FactWriteOutcome(CodeEnum):
|
||||||
"""fact 기록 결과. 재수집(업데이트)이 무엇을 했는지 호출측이 알아야 한다 —
|
"""fact 기록 결과."""
|
||||||
특히 사이트 재빌드가 필요한 경우(PUBLISHED_REPLACED)를 구분해야 한다."""
|
|
||||||
|
|
||||||
PUBLISHED_CREATED = 1 # 노출값이 없던 자리에 사람이 직접 넣어 바로 노출됐다
|
PUBLISHED_CREATED = 1
|
||||||
PUBLISHED_REPLACED = 2 # ★ 노출값이 교체됐다 — 사이트 재빌드 대상
|
PUBLISHED_REPLACED = 2
|
||||||
REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신
|
REFRESHED = 3 # 재수집했는데 값이 그대로 — 검증 유지, 확인 시각만 갱신
|
||||||
CANDIDATE_CREATED = 4 # 노출값과 다른 값이 들어와 후보로 쌓였다(사람 확인 대기)
|
CANDIDATE_CREATED = 4
|
||||||
CANDIDATE_UPDATED = 5 # 같은 출처의 기존 후보를 새 수집값으로 갱신했다
|
CANDIDATE_UPDATED = 5
|
||||||
|
|
||||||
# 검증 상태 전이 허용표. 여기에 없는 전이는 FACT_INVALID_TRANSITION 으로 거부한다.
|
# 검증 상태 전이 허용표.
|
||||||
FACT_STATUS_TRANSITIONS = {
|
FACT_STATUS_TRANSITIONS = {
|
||||||
FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED},
|
FactStatus.UNVERIFIED: {FactStatus.PENDING_OWNER, FactStatus.VERIFIED, FactStatus.REJECTED, FactStatus.EXPIRED},
|
||||||
FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED},
|
FactStatus.PENDING_OWNER: {FactStatus.VERIFIED, FactStatus.CORRECTED, FactStatus.REJECTED, FactStatus.EXPIRED},
|
||||||
@ -268,7 +247,7 @@ FACT_STATUS_TRANSITIONS = {
|
|||||||
|
|
||||||
|
|
||||||
class LinkChannel(CodeEnum):
|
class LinkChannel(CodeEnum):
|
||||||
"""place_channels.channel 코드값. Perplexity 가 발견하는 채널 종류."""
|
"""place_channels.channel 코드값."""
|
||||||
|
|
||||||
YANOLJA = 1 # 야놀자
|
YANOLJA = 1 # 야놀자
|
||||||
GOODCHOICE = 2 # 여기어때
|
GOODCHOICE = 2 # 여기어때
|
||||||
@ -276,16 +255,13 @@ class LinkChannel(CodeEnum):
|
|||||||
INSTAGRAM = 4
|
INSTAGRAM = 4
|
||||||
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
|
OFFICIAL_SITE = 5 # 사장님 자체 홈페이지
|
||||||
BLOG = 6
|
BLOG = 6
|
||||||
# ★ 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
|
# 플레이스와 가른 이유: 이건 **예약 화면 그 자체**다.
|
||||||
# 플레이스 홈은 예약 버튼을 한 번 더 눌러야 하고, 자동 발견이 검색 URL 을 물어온
|
|
||||||
# 경우에는 아예 검색 결과가 뜬다 — 발행본의 "예약" 버튼이 그리로 가면 손님은
|
|
||||||
# 예약을 포기한다. 주소는 지어내지 않는다: 플레이스 응답의 naverBookingUrl 그대로다.
|
|
||||||
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
|
NAVER_BOOKING = 7 # 네이버 예약(m.booking.naver.com)
|
||||||
ETC = 99
|
ETC = 99
|
||||||
|
|
||||||
|
|
||||||
class MediaStatus(CodeEnum):
|
class MediaStatus(CodeEnum):
|
||||||
"""media.status 코드값. 비전 결과 신뢰도가 낮으면 자동 반영하지 않고 PENDING_REVIEW 로 둔다."""
|
"""media.status 코드값."""
|
||||||
|
|
||||||
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
|
PENDING_REVIEW = 1 # 사람 확인 큐 — Vision 분석 전이거나 신뢰도가 낮다
|
||||||
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
|
APPROVED = 2 # 사람이 확인함(또는 Vision 신뢰도가 충분히 높음)
|
||||||
@ -293,41 +269,31 @@ class MediaStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class SongStatus(CodeEnum):
|
class SongStatus(CodeEnum):
|
||||||
"""place_songs.status 코드값.
|
"""place_songs.status 코드값."""
|
||||||
|
|
||||||
★ fact·사진과 달리 검증 상태가 없다. 노래는 수집한 사실이 아니라 우리가 만든 창작물이라
|
GENERATING = 1
|
||||||
"맞는가" 를 물을 대상이 아니다. 물을 것은 "만들어졌는가" 하나다.
|
READY = 2
|
||||||
★ 사이트에는 READY 만 나간다 — 생성 중인 곡을 실으면 재생 버튼이 없는 파일을 가리킨다."""
|
FAILED = 3 # 생성 실패.
|
||||||
|
|
||||||
GENERATING = 1 # Suno 가 작곡 중(또는 파일을 아직 못 받았다)
|
|
||||||
READY = 2 # 파일까지 받아 뒀다 — 사이트에 나간다
|
|
||||||
FAILED = 3 # 생성 실패. 발행은 그대로 진행된다(노래만 없다)
|
|
||||||
|
|
||||||
|
|
||||||
# ★ Vision 결과를 자동 반영해도 되는 신뢰도 하한. 이 아래는 사람 확인 큐(PENDING_REVIEW)로 남긴다.
|
# Vision 결과를 자동 반영해도 되는 신뢰도 하한.
|
||||||
# "신뢰도 낮은 항목은 자동 반영하지 말고 사람 확인 큐로 보낸다" 를 한 곳에서만 판단한다.
|
|
||||||
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
VISION_AUTO_APPROVE_CONFIDENCE = 0.7
|
||||||
|
|
||||||
|
|
||||||
class LocalContentType(CodeEnum):
|
class LocalContentType(CodeEnum):
|
||||||
"""local_contents.content_type 코드값. 행정구역 코드 단위로 캐싱되는 지역 정보 종류."""
|
"""local_contents.content_type 코드값."""
|
||||||
|
|
||||||
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
|
WEATHER = 1 # 날씨 (Open-Meteo) — local_contents(지역 캐시)
|
||||||
# ↓ 2~5 는 place_contents(업장 반경 캐시). TourAPI locationBasedList2 contentTypeId 와 짝: 15·12·39·25
|
# ↓ 2~5 는 place_contents(업장 반경 캐시).
|
||||||
FESTIVAL = 2 # 축제/공연/행사 (15)
|
FESTIVAL = 2 # 축제/공연/행사 (15)
|
||||||
ATTRACTION = 3 # 관광지 (12)
|
ATTRACTION = 3 # 관광지 (12)
|
||||||
RESTAURANT = 4 # 음식점 (39)
|
RESTAURANT = 4 # 음식점 (39)
|
||||||
COURSE = 5 # 여행코스 (25) — 백엔드만. 렌더러 자리는 아직 없다
|
COURSE = 5 # 여행코스 (25) — 백엔드만.
|
||||||
# ★ 지역 이야기(가요·일력·인물·연표·엽서·퀴즈). 위 넷과 달리 **좌표가 아니라 행정구역**에 붙는다 —
|
# 지역 이야기(가요·일력·인물·연표·엽서·퀴즈).
|
||||||
# 군산 이야기는 군산 숙소가 같이 쓴다. 여섯을 한 코드로 두고 `area_contents.kind` 로 가르는 이유는,
|
|
||||||
# 종류마다 코드를 주면 종류가 늘 때마다 enum·상한표·읽는 쪽이 함께 늘기 때문이다.
|
|
||||||
STORY = 6
|
STORY = 6
|
||||||
|
|
||||||
|
|
||||||
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
|
# 코드값 ↔ **타입명**.
|
||||||
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
|
|
||||||
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
|
|
||||||
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
|
|
||||||
AREA_KIND = {
|
AREA_KIND = {
|
||||||
LocalContentType.WEATHER.value: "weather",
|
LocalContentType.WEATHER.value: "weather",
|
||||||
LocalContentType.FESTIVAL.value: "festival",
|
LocalContentType.FESTIVAL.value: "festival",
|
||||||
@ -336,22 +302,20 @@ AREA_KIND = {
|
|||||||
LocalContentType.COURSE.value: "course",
|
LocalContentType.COURSE.value: "course",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
|
# 지역 이야기 일곱.
|
||||||
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
|
|
||||||
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
|
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
|
||||||
|
|
||||||
|
|
||||||
class LocalSource(CodeEnum):
|
class LocalSource(CodeEnum):
|
||||||
"""local_contents.source 코드값. 어느 외부 API 에서 왔는지."""
|
"""local_contents.source 코드값."""
|
||||||
|
|
||||||
OPEN_METEO = 1 # 날씨. API 키 불필요
|
OPEN_METEO = 1 # 날씨.
|
||||||
TOUR_API = 2 # 한국관광공사. ★ 자체 areaCode 체계 — 카카오 행정구역 코드와 다르다
|
TOUR_API = 2 # 한국관광공사.
|
||||||
KAKAO_LOCAL = 3
|
KAKAO_LOCAL = 3
|
||||||
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
|
OFFICIAL_WEB = 4 # 지자체·행사 공식 홈페이지에서 운영자가 검수해 등록
|
||||||
# ★ 지역 이야기 생성분. 출처는 항목 안의 source.url 이고 이 값은 '누가 모았나'다 —
|
# 지역 이야기 생성분.
|
||||||
# 화면이 "AI 가 모았습니다"를 밝힐 근거이자, 나중에 통째로 다시 돌릴 때의 선택자다.
|
|
||||||
LLM = 5
|
LLM = 5
|
||||||
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강). docs/DECISIONS.md 1-1 예외 — 봇탐지 우회 없이 공개 응답만 읽는다
|
NAVER_CRAWL = 6 # 네이버 플레이스 크롤링(주변 맛집 보강).
|
||||||
|
|
||||||
|
|
||||||
class LocalContentStatus(CodeEnum):
|
class LocalContentStatus(CodeEnum):
|
||||||
@ -363,7 +327,7 @@ class LocalContentStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class TransportType(CodeEnum):
|
class TransportType(CodeEnum):
|
||||||
"""routes.transport 코드값. 가는 길 수단."""
|
"""routes.transport 코드값."""
|
||||||
|
|
||||||
CAR = 1
|
CAR = 1
|
||||||
PUBLIC = 2
|
PUBLIC = 2
|
||||||
@ -371,7 +335,7 @@ class TransportType(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class SiteStatus(CodeEnum):
|
class SiteStatus(CodeEnum):
|
||||||
"""sites.status 코드값. ★ 해지는 물리 삭제가 아니라 상태 전이로만 처리한다."""
|
"""sites.status 코드값."""
|
||||||
|
|
||||||
DRAFT = 1
|
DRAFT = 1
|
||||||
REVIEW = 2 # 검수 게이트 대기
|
REVIEW = 2 # 검수 게이트 대기
|
||||||
@ -381,7 +345,7 @@ class SiteStatus(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class BuildStatus(CodeEnum):
|
class BuildStatus(CodeEnum):
|
||||||
"""site_versions.build_status 코드값. 정적 빌드는 개별 재빌드 단위로 돈다."""
|
"""site_versions.build_status 코드값."""
|
||||||
|
|
||||||
PENDING = 1
|
PENDING = 1
|
||||||
BUILDING = 2
|
BUILDING = 2
|
||||||
@ -397,7 +361,7 @@ class PublishAction(CodeEnum):
|
|||||||
REBUILD = 3
|
REBUILD = 3
|
||||||
SUSPEND = 4
|
SUSPEND = 4
|
||||||
RESUME = 5
|
RESUME = 5
|
||||||
ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py
|
ROLLBACK = 6
|
||||||
|
|
||||||
|
|
||||||
class PublishResult(CodeEnum):
|
class PublishResult(CodeEnum):
|
||||||
@ -409,7 +373,7 @@ class PublishResult(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class PublishRejectReason(CodeEnum):
|
class PublishRejectReason(CodeEnum):
|
||||||
"""publish_logs.reject_reason 코드값. 검수 게이트가 발행을 막은 이유(절대규칙 1~3)."""
|
"""publish_logs.reject_reason 코드값."""
|
||||||
|
|
||||||
UNVERIFIED_FACT = 1 # 미검증 fact 포함
|
UNVERIFIED_FACT = 1 # 미검증 fact 포함
|
||||||
NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건
|
NO_UNIQUE_CONTENT = 2 # 고유 콘텐츠 0건
|
||||||
@ -418,7 +382,7 @@ class PublishRejectReason(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class AiEngine(CodeEnum):
|
class AiEngine(CodeEnum):
|
||||||
"""ai_check_results.engine 코드값. AI 검색이 우리 사이트를 근거로 답하는지 측정할 대상."""
|
"""ai_check_results.engine 코드값."""
|
||||||
|
|
||||||
CHATGPT = 1
|
CHATGPT = 1
|
||||||
PERPLEXITY = 2
|
PERPLEXITY = 2
|
||||||
@ -427,14 +391,9 @@ class AiEngine(CodeEnum):
|
|||||||
ETC = 99
|
ETC = 99
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다)
|
# 작업 큐 (LPS 의 job 큐 구조를 이식 — PostgreSQL 을 큐로 쓴다)
|
||||||
# ============================================================
|
|
||||||
class JobType(CodeEnum):
|
class JobType(CodeEnum):
|
||||||
"""jobs.job_type 코드값. 수집·비전·빌드는 몇 분씩 걸려 동기 요청으로 처리할 수 없다.
|
"""jobs.job_type 코드값."""
|
||||||
|
|
||||||
무거운 잡(브라우저 필요)과 가벼운 잡(HTTP API 만)을 코드로 갈라 둔다 —
|
|
||||||
크롤링 법무 결론이 나면 무거운 잡만 별도 워커 이미지로 분리한다."""
|
|
||||||
|
|
||||||
COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링
|
COLLECT = 1 # 수집 파이프라인: Perplexity 채널 발견 → 카카오 검증 → 크롤링
|
||||||
VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치)
|
VISION = 2 # 사진 분류 + alt 생성 (Gemini Vision, 20~50장 배치)
|
||||||
@ -442,30 +401,27 @@ class JobType(CodeEnum):
|
|||||||
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
BUILD = 4 # 사이트 정적 빌드 — ★ 개별 재빌드 단위
|
||||||
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
LOCAL_SYNC = 5 # 지역 정보 갱신 — 행정구역 코드 단위(같은 지역 사이트 50개여도 1회)
|
||||||
AI_CHECK = 6 # AI 검색 노출 점검
|
AI_CHECK = 6 # AI 검색 노출 점검
|
||||||
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
|
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno).
|
||||||
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
|
ROLLBACK = 8
|
||||||
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
|
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
|
||||||
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
|
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(CodeEnum):
|
class JobStatus(CodeEnum):
|
||||||
"""jobs.status 코드값. 작업 큐 상태.
|
"""jobs.status 코드값."""
|
||||||
|
|
||||||
전이는 전부 조건부 원자 UPDATE(CAS)로만 한다. 실패는 재시도 가능하면
|
PENDING = 1 # 대기(claim 가능).
|
||||||
PENDING(run_after=백오프)으로 되돌리고, 소진되면 DEAD(dead-letter)."""
|
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유).
|
||||||
|
|
||||||
PENDING = 1 # 대기(claim 가능). run_after <= now() 일 때만 실제 claim 대상
|
|
||||||
RUNNING = 2 # 워커가 점유 중(lease_until 까지 소유). 만료 시 reaper 가 회수
|
|
||||||
DONE = 3 # 완료
|
DONE = 3 # 완료
|
||||||
DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상)
|
DEAD = 4 # dead-letter — max_attempts 소진(수동 개입/알림 대상)
|
||||||
|
|
||||||
|
|
||||||
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
|
# claim 대상이 되는 활성 상태.
|
||||||
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
|
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
|
||||||
|
|
||||||
|
|
||||||
class PostTopicKind(CodeEnum):
|
class PostTopicKind(CodeEnum):
|
||||||
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다."""
|
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가."""
|
||||||
|
|
||||||
WEATHER = 1 # local.weather
|
WEATHER = 1 # local.weather
|
||||||
FESTIVAL = 2 # local.festivals
|
FESTIVAL = 2 # local.festivals
|
||||||
@ -475,20 +431,20 @@ class PostTopicKind(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class PostStatus(CodeEnum):
|
class PostStatus(CodeEnum):
|
||||||
"""place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다."""
|
"""place_posts.status — 글 하나의 일생."""
|
||||||
|
|
||||||
DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다
|
DRAFT = 1
|
||||||
REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
|
REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
|
||||||
SENT = 3 # 사장님에게 메일이 나갔다
|
SENT = 3
|
||||||
APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기
|
APPROVED = 4
|
||||||
PUBLISHED = 5 # 사이트에 올라갔다
|
PUBLISHED = 5
|
||||||
SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
|
SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
|
||||||
|
|
||||||
|
|
||||||
class ReviewStatus(CodeEnum):
|
class ReviewStatus(CodeEnum):
|
||||||
"""place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다."""
|
"""place_reviews.status — 손님이 쓴 글의 일생."""
|
||||||
|
|
||||||
PENDING = 1 # 손님이 막 남겼다
|
PENDING = 1
|
||||||
PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
|
PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
|
||||||
REJECTED = 3 # 반려
|
REJECTED = 3 # 반려
|
||||||
|
|
||||||
@ -499,14 +455,11 @@ class SocialProvider(CodeEnum):
|
|||||||
|
|
||||||
|
|
||||||
class KakaoLinkStatus(str, Enum):
|
class KakaoLinkStatus(str, Enum):
|
||||||
"""owner_kakao_links.status.
|
"""owner_kakao_links.status."""
|
||||||
|
|
||||||
★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번
|
PENDING = "PENDING"
|
||||||
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다."""
|
LINKED = "LINKED"
|
||||||
|
REVOKED = "REVOKED"
|
||||||
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
|
|
||||||
LINKED = "LINKED" # channel_user_key 가 붙었다
|
|
||||||
REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다
|
|
||||||
|
|
||||||
|
|
||||||
class SocialPostStatus(str, Enum):
|
class SocialPostStatus(str, Enum):
|
||||||
@ -523,7 +476,7 @@ class SocialPostStatus(str, Enum):
|
|||||||
|
|
||||||
|
|
||||||
class AlertStatus(CodeEnum):
|
class AlertStatus(CodeEnum):
|
||||||
"""alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다."""
|
"""alert_outbox.status 코드값."""
|
||||||
|
|
||||||
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
|
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
|
||||||
SENT = 2 # 전송 성공
|
SENT = 2 # 전송 성공
|
||||||
|
|||||||
@ -1,17 +1,4 @@
|
|||||||
"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문.
|
"""FAQ 질문 카탈로그 — 생성된 FAQ 가 목표 수에 모자랄 때 채울 업종 공통 질문."""
|
||||||
|
|
||||||
★ 왜 필요한가
|
|
||||||
COPY 잡은 확인된 fact 로만 FAQ 를 쓴다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
|
|
||||||
산하연 풀빌라 4건 — 그 근거로는 FAQ 가 4~8개에서 끝난다. 20개를 채우려면 근거 밖의 문항이 필요하다.
|
|
||||||
|
|
||||||
★ 공통 **답**은 주장을 하지 않는다 — "…은 숙소로 문의 부탁드립니다" 뿐이다.
|
|
||||||
예전 업종 시드 FAQ 에는 "숯과 그릴 세트(25,000원)" 같은 가공의 값이 있었고, 사장님이 팔지도 않는
|
|
||||||
조건이 사이트에 나갔다(frontend canvas/variants/faq/useFaqList.ts). 공통 답에 값·가능 여부를 적으면
|
|
||||||
같은 사고다. 문의 안내만 쓴다.
|
|
||||||
|
|
||||||
resources/*.json 을 최초 사용 시 로드·검증한다. fact_keys 는 업종 스키마에 있는 key 여야 한다 —
|
|
||||||
오타가 난 key 는 영영 "fact 없음" 으로 읽혀, 답이 있는 질문에 문의 안내가 붙는다.
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -33,7 +20,7 @@ class CatalogItem:
|
|||||||
id: str
|
id: str
|
||||||
question: str
|
question: str
|
||||||
topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부")
|
topic: str # 공통 답변 문구에 들어갈 주제("반려동물 동반 가능 여부")
|
||||||
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact. 하나라도 있으면 공통 답으로 채우지 않는다
|
fact_keys: tuple[str, ...] # 이 질문에 답할 수 있는 fact.
|
||||||
keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교)
|
keywords: tuple[str, ...] # 기존 FAQ 질문에 이 낱말이 있으면 같은 주제로 본다(공백 없이 비교)
|
||||||
|
|
||||||
|
|
||||||
@ -48,10 +35,7 @@ class FaqCatalog:
|
|||||||
items: tuple[CatalogItem, ...]
|
items: tuple[CatalogItem, ...]
|
||||||
|
|
||||||
def applies_to(self, category: int, external_category: str | None) -> bool:
|
def applies_to(self, category: int, external_category: str | None) -> bool:
|
||||||
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다.
|
"""업종 코드가 같고, 외부 분류가 제외 목록에 걸리지 않으면 이 카탈로그를 쓴다."""
|
||||||
|
|
||||||
★ 외부 분류가 비어 있으면 **쓴다.** 스테이머뭄처럼 네이버 분류가 없는 펜션이 있다.
|
|
||||||
호텔은 분류가 "호텔" 로 오므로 제외 목록이 막는다 — 호텔에 바비큐·픽업 문항이 붙으면 안 된다."""
|
|
||||||
if category != self.category.value:
|
if category != self.category.value:
|
||||||
return False
|
return False
|
||||||
label = external_category or ""
|
label = external_category or ""
|
||||||
@ -116,7 +100,7 @@ def _parse(doc: dict, source: str) -> FaqCatalog:
|
|||||||
|
|
||||||
|
|
||||||
def load_catalogs() -> list[FaqCatalog]:
|
def load_catalogs() -> list[FaqCatalog]:
|
||||||
"""리소스 디렉터리 전체 로드 + 검증. 최초 1회(멱등)."""
|
"""리소스 디렉터리 전체 로드 + 검증."""
|
||||||
global _catalogs
|
global _catalogs
|
||||||
if _catalogs is None:
|
if _catalogs is None:
|
||||||
loaded = []
|
loaded = []
|
||||||
@ -131,5 +115,5 @@ def load_catalogs() -> list[FaqCatalog]:
|
|||||||
|
|
||||||
|
|
||||||
def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None:
|
def find_catalog(category: int, external_category: str | None) -> FaqCatalog | None:
|
||||||
"""이 사업장에 쓸 카탈로그. 없으면 None — 채우지 않는다(카페·음식점은 아직 목록이 없다)."""
|
"""이 사업장에 쓸 카탈로그."""
|
||||||
return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None)
|
return next((c for c in load_catalogs() if c.applies_to(category, external_category)), None)
|
||||||
|
|||||||
@ -1,18 +1,5 @@
|
|||||||
"""재시도가 의미 없는 잡 실패.
|
"""재시도가 의미 없는 잡 실패."""
|
||||||
|
|
||||||
★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
|
|
||||||
네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
|
|
||||||
사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다.
|
|
||||||
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로
|
|
||||||
세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다.
|
|
||||||
|
|
||||||
★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
|
|
||||||
그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다.
|
|
||||||
|
|
||||||
★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면
|
|
||||||
의존 방향이 뒤집힌다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PermanentJobError(RuntimeError):
|
class PermanentJobError(RuntimeError):
|
||||||
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""
|
"""다시 시도해도 결과가 같은 실패."""
|
||||||
|
|||||||
@ -4,9 +4,7 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
|
|
||||||
class _Logger:
|
class _Logger:
|
||||||
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전.
|
"""원본 DerbyServer LOG 인터페이스를 간소화한 버전."""
|
||||||
LOG.i / LOG.d / LOG.w / LOG.e_no_callstack / LOG.SetPrefix 를 제공한다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._prefix = ""
|
self._prefix = ""
|
||||||
|
|||||||
@ -14,7 +14,7 @@ class StructModel:
|
|||||||
|
|
||||||
|
|
||||||
class ErrorInfo(BaseModel, StructModel):
|
class ErrorInfo(BaseModel, StructModel):
|
||||||
"""모든 응답에 공통으로 실리는 결과 정보. result.success / code / desc 로 내려간다."""
|
"""모든 응답에 공통으로 실리는 결과 정보."""
|
||||||
|
|
||||||
success: Optional[bool] = True
|
success: Optional[bool] = True
|
||||||
code: Optional[int] = ErrorType.SUCCESS.value
|
code: Optional[int] = ErrorType.SUCCESS.value
|
||||||
@ -27,11 +27,7 @@ class ErrorInfo(BaseModel, StructModel):
|
|||||||
self.desc = enum.name
|
self.desc = enum.name
|
||||||
|
|
||||||
|
|
||||||
# ---- Protocol 규약 -------------------------------------------------------
|
# Protocol 규약
|
||||||
# 모든 통신 패킷은 WebPacketProtocol 을 상속한다.
|
|
||||||
# 요청 : Req_xxx (WebPacketProtocol)
|
|
||||||
# 응답 : Res_xxx (Res_WebPacketProtocol) - 항상 result 필드를 가진다.
|
|
||||||
# 각 라우터 폴더의 protocol.py 에 Req_/Res_ 를 정의한다.
|
|
||||||
class WebPacketProtocol(BaseModel, StructModel):
|
class WebPacketProtocol(BaseModel, StructModel):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@ -54,7 +50,7 @@ class Res_PageProtocol(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class PageParams:
|
class PageParams:
|
||||||
# 목록 엔드포인트 공용 쿼리 파라미터. 라우터에서 Depends() 로 주입한다.
|
# 목록 엔드포인트 공용 쿼리 파라미터.
|
||||||
def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
|
def __init__(self, page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100)):
|
||||||
self.page = page
|
self.page = page
|
||||||
self.size = size
|
self.size = size
|
||||||
@ -67,16 +63,14 @@ class PageParams:
|
|||||||
class UserInfo(StructModel):
|
class UserInfo(StructModel):
|
||||||
"""JWT subject 로 인코딩되는 유저 식별 정보."""
|
"""JWT subject 로 인코딩되는 유저 식별 정보."""
|
||||||
|
|
||||||
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
|
user_id: str # users.user_id (uuid) — 데이터 스코프 키.
|
||||||
id: str # users.id (로그인 아이디) — get_me 재조회 키
|
id: str # users.id (로그인 아이디) — get_me 재조회 키
|
||||||
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
|
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
|
||||||
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
|
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로
|
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
|
||||||
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
|
|
||||||
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
|
|
||||||
self.role = UserRole.USER.value
|
self.role = UserRole.USER.value
|
||||||
self.token_version = 1
|
self.token_version = 1
|
||||||
for dictionary in args:
|
for dictionary in args:
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""업종·템플릿 정의. 프론트와 같은 파일(solution/shared/src/data/templates.json)을 읽는다."""
|
"""업종·템플릿 정의."""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,11 @@
|
|||||||
"""좌표 거리 — 공용 한 벌.
|
"""좌표 거리 — 공용 한 벌."""
|
||||||
|
|
||||||
★ 같은 하버사인 공식이 tour_lookup(500m 동일업소 판정)·itinerary(일정 반경)·tour_api(축제 20km 필터)
|
|
||||||
세 곳에 각각 복사돼 있었다(2026-09-08 정리). 지구 반지름·단위가 파일마다 달라지면 같은 두 점의
|
|
||||||
거리가 모듈마다 다르게 나온다 — 거리로 무엇을 넣고 뺄지 정하는 코드가 셋이라 한 벌이어야 한다.
|
|
||||||
|
|
||||||
국내 범위라 하버사인(구면 근사)이면 충분하다. 오차는 수 m 수준으로, 우리가 쓰는 판정
|
|
||||||
(500m 이내·5~20km 반경)에서 결과를 바꾸지 않는다.
|
|
||||||
"""
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
EARTH_RADIUS_M = 6_371_000.0
|
EARTH_RADIUS_M = 6_371_000.0
|
||||||
|
|
||||||
|
|
||||||
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
def haversine_m(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
||||||
"""두 좌표(위도, 경도) 사이의 거리(m). ★ 인자 순서는 (위도, 경도) — mapx/mapy 는 (경도, 위도)라 뒤집어 넣는다."""
|
"""두 좌표(위도, 경도) 사이의 거리(m)."""
|
||||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||||
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
dp, dl = math.radians(lat2 - lat1), math.radians(lng2 - lng1)
|
||||||
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
||||||
|
|||||||
@ -2,7 +2,7 @@ from datetime import datetime, timezone, timedelta
|
|||||||
|
|
||||||
|
|
||||||
class GTime:
|
class GTime:
|
||||||
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸. (원본 DerbyServer 패턴 축약)"""
|
"""서버 전역에서 UTC 기준 시간을 사용하기 위한 유틸."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def UTC() -> datetime:
|
def UTC() -> datetime:
|
||||||
|
|||||||
@ -1,12 +1,4 @@
|
|||||||
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것.
|
"""IP 단위 호출 제한 — 프로세스 메모리에만 있는 아주 단순한 것."""
|
||||||
|
|
||||||
★ 한계를 먼저 적는다. 프로세스가 여럿이면 한도가 그 수만큼 곱해지고, 재시작하면 리셋된다.
|
|
||||||
제대로 하려면 앞단(nginx `limit_req`)이나 공유 저장소가 필요하다.
|
|
||||||
|
|
||||||
★ 그런데도 두는 이유: 이걸 쓰는 곳이 **인증 없이 유료 외부 API 를 부르는 경로**다.
|
|
||||||
방어가 0 이면 새로고침을 누르고 있는 것만으로 요금이 나간다
|
|
||||||
(카카오 키워드 검색은 무료 한도를 넘기면 건당 2원 — services/external/kakao.py 주석).
|
|
||||||
"""
|
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
@ -23,7 +15,7 @@ def allow(key: str, limit: int, window_sec: float) -> bool:
|
|||||||
if len(bucket) >= limit:
|
if len(bucket) >= limit:
|
||||||
return False
|
return False
|
||||||
bucket.append(now)
|
bucket.append(now)
|
||||||
# 안 쓰는 키가 쌓이는 걸 막는다. 호출이 뜸하면 자연히 비워진다.
|
# 안 쓰는 키가 쌓이는 걸 막는다.
|
||||||
if not bucket:
|
if not bucket:
|
||||||
_hits.pop(key, None)
|
_hits.pop(key, None)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
"""TTL 캐시 — 프로세스 메모리에만 있다. rate_limit 과 같은 한계를 갖는다.
|
"""TTL 캐시 — 프로세스 메모리에만 있다."""
|
||||||
|
|
||||||
★ 두는 이유는 속도가 아니라 **차단**이다. 공개 검색 1회가 네이버를 최대 3번 긁는데
|
|
||||||
(넓은 검색 1 + 겨냥 2), 인증 없는 경로라 새로고침만으로도 나간다.
|
|
||||||
실측(2026-09-03): 테스트를 반복하다 m.place.naver.com 에서 429 를 받았다.
|
|
||||||
"""
|
|
||||||
import time
|
import time
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,4 @@
|
|||||||
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다).
|
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다)."""
|
||||||
|
|
||||||
★ SNS 게재(social_config)와 파일을 가른 이유는 도메인이 다르기 때문이다.
|
|
||||||
SNS 게재는 **되돌릴 수 없는** 대외 발화이고, 에이전트는 사장님이 자기 사이트를
|
|
||||||
고치는 창구다. 승인 강도도 보관하는 것도 다르다 — 설정이 한 파일에 섞이면
|
|
||||||
"이 값이 무엇을 여는가" 가 흐려진다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@ -14,30 +8,19 @@ from config.config_models import _BASE
|
|||||||
class AgentConfig(BaseSettings):
|
class AgentConfig(BaseSettings):
|
||||||
model_config = _BASE
|
model_config = _BASE
|
||||||
|
|
||||||
# 카카오톡 채널 공개 ID(`_xaBcD` 형태). 사장님이 채널을 찾아 코드를 입력해야 하므로
|
# 카카오톡 채널 공개 ID(`_xaBcD` 형태).
|
||||||
# ★ 이 값이 없으면 연결 화면 자체를 열지 않는다 — 어디에 코드를 칠지 말해 줄 수
|
|
||||||
# 없는데 코드만 발급하면, 사장님에게는 고장난 화면이다(Threads 카드와 같은 규칙).
|
|
||||||
KAKAO_CHANNEL_PUBLIC_ID: str = ""
|
KAKAO_CHANNEL_PUBLIC_ID: str = ""
|
||||||
# 코드 수명. 사장님이 화면을 보고 카톡을 열어 치는 동작이라 짧아도 된다.
|
# 코드 수명.
|
||||||
KAKAO_LINK_CODE_TTL_MIN: int = 10
|
KAKAO_LINK_CODE_TTL_MIN: int = 10
|
||||||
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다.
|
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다.
|
||||||
KAKAO_LINK_MAX_ATTEMPTS: int = 5
|
KAKAO_LINK_MAX_ATTEMPTS: int = 5
|
||||||
|
|
||||||
# 빌더 화면의 대화창. 2026-09-21 에 한 번 닫았다가(카카오 채널 보류) 채널 인증이
|
# 빌더 화면의 대화창.
|
||||||
# 끝나 다시 열었다(2026-09-22).
|
|
||||||
# ★ 이 값이 "1" 이어도 **LLM 키가 없으면 안 열린다**(runtime.is_configured 가 둘 다 본다) —
|
|
||||||
# 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다.
|
|
||||||
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
|
|
||||||
AGENT_CHAT_ENABLED: str = "1"
|
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_WEBHOOK_SECRET: str = ""
|
||||||
# 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다.
|
# 우리 봇이 맞는지 한 겹 더 본다.
|
||||||
KAKAO_BOT_ID: str = ""
|
KAKAO_BOT_ID: str = ""
|
||||||
|
|
||||||
|
|
||||||
@ -58,6 +41,6 @@ def kakao_link_enabled() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def channel_url() -> str:
|
def channel_url() -> str:
|
||||||
"""사장님이 눌러서 채널로 가는 주소. 공개 ID 가 없으면 빈 문자열이다."""
|
"""사장님이 눌러서 채널로 가는 주소."""
|
||||||
public_id = get("KAKAO_CHANNEL_PUBLIC_ID")
|
public_id = get("KAKAO_CHANNEL_PUBLIC_ID")
|
||||||
return f"http://pf.kakao.com/{public_id}" if public_id else ""
|
return f"http://pf.kakao.com/{public_id}" if public_id else ""
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다.
|
"""설정 모델 — 값은 전부 환경변수(최상위 .env 포함)에서 온다."""
|
||||||
|
|
||||||
★ 환경변수 이름은 validation_alias 로 못 박는다. 필드명만 두면 `port` 가 흔한 `PORT` 를
|
|
||||||
주워 먹어 엉뚱한 포트로 뜬다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -12,16 +8,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# 레포 최상위 .env. 여기서 네 단계 위다 — 세 단계로 두면 solution/.env(없는 파일)를 본다.
|
# 레포 최상위 .env.
|
||||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||||
_DOTENV = os.path.join(_REPO_ROOT, ".env")
|
_DOTENV = os.path.join(_REPO_ROOT, ".env")
|
||||||
|
|
||||||
APP_ENV = os.environ.get("APP_ENV", "local")
|
APP_ENV = os.environ.get("APP_ENV", "local")
|
||||||
|
|
||||||
# ★ APP_ENV=test 면 .env 를 읽지 않는다. 실키가 새면 테스트가 실제 외부 API 를 때린다.
|
# APP_ENV=test 면 .env 를 읽지 않는다.
|
||||||
_ENV_FILE = None if APP_ENV == "test" else _DOTENV
|
_ENV_FILE = None if APP_ENV == "test" else _DOTENV
|
||||||
|
|
||||||
# ★ 테스트는 별도 DB. conftest 가 "이름에 test 없으면 중단" 으로 dev DB 를 지킨다.
|
# 테스트는 별도 DB.
|
||||||
_DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db"
|
_DEFAULT_DB_NAME = "web4ai_test_db" if APP_ENV == "test" else "web4ai_db"
|
||||||
|
|
||||||
_BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False)
|
_BASE = SettingsConfigDict(env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore", case_sensitive=False)
|
||||||
@ -35,7 +31,7 @@ class WebServerConfig(BaseSettings):
|
|||||||
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
|
process_count: int = Field(1, validation_alias="WEB_PROCESS_COUNT")
|
||||||
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
|
is_ssl: bool = Field(False, validation_alias="WEB_IS_SSL")
|
||||||
is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
|
is_test: bool = Field(False, validation_alias="WEB_IS_TEST")
|
||||||
# CORS 허용 오리진(쉼표로 여럿). vite 는 3000 이 막히면 3001, 3002… 로 옮겨 뜬다.
|
# CORS 허용 오리진(쉼표로 여럿).
|
||||||
client_url: str = Field(
|
client_url: str = Field(
|
||||||
"http://localhost:3000,http://localhost:3001,http://localhost:3002,"
|
"http://localhost:3000,http://localhost:3001,http://localhost:3002,"
|
||||||
"http://localhost:3003,http://localhost:3004,http://localhost:3005",
|
"http://localhost:3003,http://localhost:3004,http://localhost:3005",
|
||||||
@ -52,7 +48,7 @@ class LogConfig(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
class MainDBConfig(BaseSettings):
|
class MainDBConfig(BaseSettings):
|
||||||
"""DB read/write 분리. 읽기 접속을 안 주면 쓰기와 같은 곳을 본다(복제 없는 환경이 기본)."""
|
"""DB read/write 분리."""
|
||||||
|
|
||||||
model_config = _BASE
|
model_config = _BASE
|
||||||
|
|
||||||
@ -71,7 +67,6 @@ class MainDBConfig(BaseSettings):
|
|||||||
|
|
||||||
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
|
show_log: bool = Field(False, validation_alias="DB_SHOW_LOG")
|
||||||
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
|
# 동시 커넥션 상한 = (pool_size + max_overflow) x 엔진수(R/W=2) x 워커수.
|
||||||
# PostgreSQL max_connections 를 넘기면 안 된다.
|
|
||||||
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
|
pool_size: int = Field(10, validation_alias="DB_POOL_SIZE")
|
||||||
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
|
max_overflow: int = Field(20, validation_alias="DB_MAX_OVERFLOW")
|
||||||
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
|
# ""/"disable"=로컬 · "require"|"verify-ca"|"verify-full"=관리형 DB
|
||||||
@ -100,12 +95,7 @@ class JwtToken(BaseSettings):
|
|||||||
|
|
||||||
|
|
||||||
class GoogleOAuthConfig(BaseSettings):
|
class GoogleOAuthConfig(BaseSettings):
|
||||||
"""구글 로그인. client_id 가 비면 그 로그인 수단만 꺼진다 — 다른 외부 키들과 같은 규칙이다.
|
"""구글 로그인."""
|
||||||
|
|
||||||
★ client_id 는 비밀이 아니다(프론트 번들에 그대로 들어간다). 서버가 이 값을 갖는 이유는
|
|
||||||
숨기려는 게 아니라 **수신자(aud) 대조** 때문이다 — 남의 앱에 발급된 구글 토큰을 그대로
|
|
||||||
들고 와도 우리 계정이 되지 않게 막는 유일한 검사다.
|
|
||||||
★ client_secret 은 쓰지 않는다. 프론트가 ID 토큰을 받아 오는 방식(GIS)이라 코드 교환이 없다."""
|
|
||||||
|
|
||||||
model_config = _BASE
|
model_config = _BASE
|
||||||
|
|
||||||
@ -119,8 +109,6 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
|
|
||||||
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
|
perplexity_api_key: str = Field("", validation_alias="PERPLEXITY_API_KEY")
|
||||||
# 동일 업소 검증 — 둘 다 있으면 카카오 우선.
|
# 동일 업소 검증 — 둘 다 있으면 카카오 우선.
|
||||||
# 카카오: 15건 · 전화번호 O · 고유 id O · 행정구역 코드 O
|
|
||||||
# 네이버: 5건 · 전화번호 X · 고유 id 불확실 · 행정구역 코드 X → AMBIGUOUS 가 는다
|
|
||||||
kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY")
|
kakao_rest_api_key: str = Field("", validation_alias="KAKAO_REST_API_KEY")
|
||||||
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
|
naver_client_id: str = Field("", validation_alias="NAVER_CLIENT_ID")
|
||||||
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
|
naver_client_secret: str = Field("", validation_alias="NAVER_CLIENT_SECRET")
|
||||||
@ -135,17 +123,15 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(PENDING_REVIEW)에 남긴다.
|
||||||
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
|
||||||
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")
|
||||||
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service). 비면 그 단계만 건너뛴다.
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(services/song_service).
|
||||||
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
|
suno_api_key: str = Field("", validation_alias="SUNO_API_KEY")
|
||||||
# ★ 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
# 콜백은 쓰지 않고 폴링한다 — 우리 백엔드는 로컬·사내망이라 Suno 가 부를 수 있는 주소가 아니다.
|
||||||
# 그래도 API 가 필수로 요구하는 필드라 값을 들고 있는다(services/external/suno.py 주석).
|
|
||||||
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
suno_callback_url: str = Field("", validation_alias="SUNO_CALLBACK_URL")
|
||||||
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology). 비면 그 단계만
|
# 발행 사이트 메타 키워드(keywords · 제목)를 받아 오는 사내 서비스(o2o-site-ontology).
|
||||||
# 건너뛴다 — 제목·메타가 예전 그대로 나간다(services/seo_keywords).
|
|
||||||
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
|
site_ontology_url: str = Field("", validation_alias="SITE_ONTOLOGY_URL")
|
||||||
|
|
||||||
|
|
||||||
# .env 를 요청마다 다시 읽지 않는다. 새 코드는 Depends(get_*) 로 주입받는다.
|
# .env 를 요청마다 다시 읽지 않는다.
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_web_server_config() -> WebServerConfig:
|
def get_web_server_config() -> WebServerConfig:
|
||||||
return WebServerConfig()
|
return WebServerConfig()
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""SNS도 루트 .env만 읽는다. APP_ENV=test에서는 기존 설정 규칙대로 .env를 읽지 않는다."""
|
"""SNS도 루트 .env만 읽는다."""
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
from config.config_models import _BASE
|
from config.config_models import _BASE
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
# 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리).
|
# 테스트는 APP_ENV=test 로 실행한다 (DB 이름 기본값이 web4ai_test_db 로 갈린다, dev DB 와 분리).
|
||||||
# 이 픽스처들은 TRUNCATE 를 하므로 dev DB(web4ai_db)와 절대 공유하면 안 된다(아래 db_engine 안전가드 참고).
|
|
||||||
# config.server_configs 가 import 되는 순간 config.<APP_ENV>.toml 을 읽으므로 가장 먼저 설정.
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
os.environ.setdefault("APP_ENV", "test")
|
os.environ.setdefault("APP_ENV", "test")
|
||||||
@ -18,14 +16,7 @@ from common.enums import UserRole, UserStatus
|
|||||||
from config.server_configs import main_db_config
|
from config.server_configs import main_db_config
|
||||||
|
|
||||||
|
|
||||||
# ★ 스키마는 public 한 벌이다 — 도메인 스키마(company·place·fact·local·site·job)는
|
# 비울 표는 **ORM 이 아는 것**에서 뽑는다.
|
||||||
# 2026-09-09 에 걷어냈다(migrations/0005). 그래서 여기서 스키마를 만들지도, search_path 를
|
|
||||||
# 얹지도 않는다.
|
|
||||||
#
|
|
||||||
# ★ 비울 표는 **ORM 이 아는 것**에서 뽑는다. 예전에는 이름을 손으로 나열했는데,
|
|
||||||
# 0005 가 표 이름을 옮겼을 때 이 문자열만 옛 이름으로 남아 테스트 13건이 통째로
|
|
||||||
# `relation "place_aliases" does not exist` 로 죽었다 — 문자열이라 import 도 타입검사도
|
|
||||||
# pyflakes 도 잡지 못한다. 모델에서 뽑으면 다시 어긋날 수 없다.
|
|
||||||
def _truncate_sql() -> str:
|
def _truncate_sql() -> str:
|
||||||
names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables)
|
names = ", ".join(t.name for t in MAIN_BASE.metadata.sorted_tables)
|
||||||
return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE"
|
return f"TRUNCATE TABLE {names} RESTART IDENTITY CASCADE"
|
||||||
@ -37,14 +28,13 @@ def _write_url(cfg) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _admin_url(cfg) -> str:
|
def _admin_url(cfg) -> str:
|
||||||
"""DB 생성용 관리 접속. CREATE DATABASE 는 대상 DB 안에서 못 하므로 기본 'postgres' DB 로 붙는다."""
|
"""DB 생성용 관리 접속."""
|
||||||
pw = f":{cfg.write_pw}" if cfg.write_pw else ""
|
pw = f":{cfg.write_pw}" if cfg.write_pw else ""
|
||||||
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres"
|
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres"
|
||||||
|
|
||||||
|
|
||||||
async def _drop_test_db(*, recreate: bool):
|
async def _drop_test_db(*, recreate: bool):
|
||||||
"""test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다.
|
"""test DB 를 지운다(있으면)."""
|
||||||
WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB."""
|
|
||||||
engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT")
|
engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT")
|
||||||
try:
|
try:
|
||||||
async with engine.connect() as conn:
|
async with engine.connect() as conn:
|
||||||
@ -57,12 +47,7 @@ async def _drop_test_db(*, recreate: bool):
|
|||||||
|
|
||||||
@pytest_asyncio.fixture(scope="session", autouse=True)
|
@pytest_asyncio.fixture(scope="session", autouse=True)
|
||||||
async def _test_db_lifecycle():
|
async def _test_db_lifecycle():
|
||||||
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다.
|
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다."""
|
||||||
|
|
||||||
매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다.
|
|
||||||
(테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.)
|
|
||||||
안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호).
|
|
||||||
"""
|
|
||||||
assert "test" in main_db_config.name, (
|
assert "test" in main_db_config.name, (
|
||||||
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
|
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
|
||||||
)
|
)
|
||||||
@ -77,13 +62,8 @@ async def _test_db_lifecycle():
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def db_engine(_test_db_lifecycle):
|
async def db_engine(_test_db_lifecycle):
|
||||||
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
|
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다."""
|
||||||
|
# 안전가드: dev DB 오염 방지.
|
||||||
⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(web4ai_db)를 가리키면 실데이터가 날아간다.
|
|
||||||
그래서 test 전용 DB(이름에 'test')가 아니면 즉시 중단한다(APP_ENV=test).
|
|
||||||
앱(DB_SESSION_MNG)도 APP_ENV=test 면 같은 test DB 에 접속하므로 여기서 만든 스키마를 공유한다.
|
|
||||||
"""
|
|
||||||
# 안전가드: dev DB 오염 방지. web4ai_test_db 이외엔 절대 실행하지 않는다.
|
|
||||||
assert "test" in main_db_config.name, (
|
assert "test" in main_db_config.name, (
|
||||||
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
|
f"테스트가 비-test DB('{main_db_config.name}')를 가리킵니다. "
|
||||||
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
|
"APP_ENV=test 로 실행하세요. dev DB 보호를 위해 중단합니다."
|
||||||
@ -99,11 +79,7 @@ async def db_engine(_test_db_lifecycle):
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def owner_id(db_engine) -> str:
|
async def owner_id(db_engine) -> str:
|
||||||
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다.
|
"""사장님 계정 1개를 시드하고 user_id(uuid str)를 돌려준다."""
|
||||||
|
|
||||||
★ 예전엔 `company_id`(소속사)였다. 회사(테넌트)를 걷어내면서 사업장이 `owner_user_id` 로
|
|
||||||
계정에 직접 매이게 됐다 — DB 를 직접 시드하는 테스트가 place 에 넣을 주인이 이 값이다.
|
|
||||||
"""
|
|
||||||
uid = uuid.uuid4()
|
uid = uuid.uuid4()
|
||||||
async with db_engine.begin() as conn:
|
async with db_engine.begin() as conn:
|
||||||
# status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
|
# status·role 은 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
|
||||||
@ -130,13 +106,7 @@ async def client(db_engine):
|
|||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def auth_headers(db_engine, client):
|
async def auth_headers(db_engine, client):
|
||||||
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
|
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리."""
|
||||||
|
|
||||||
계정 생성 API 가 없으므로 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 /v1/auth/login 으로 토큰을 받는다.
|
|
||||||
★ 회사 인자가 없다. 스코프가 계정 자체이므로 **다른 login_id 로 한 번 더 부르면 그게 남**이다
|
|
||||||
— 격리 테스트는 `await auth_headers("o2")` 하나면 된다.
|
|
||||||
호출: `h = await auth_headers("user1")`.
|
|
||||||
"""
|
|
||||||
from router.v1.validator.dependencies import GetHashedPW
|
from router.v1.validator.dependencies import GetHashedPW
|
||||||
|
|
||||||
async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
|
async def _make(login_id, *, password="pw1234", role=UserRole.USER.value, name="n"):
|
||||||
@ -162,18 +132,7 @@ async def auth_headers(db_engine, client):
|
|||||||
# ── 렌더러 스텁 ──────────────────────────────────────────────────────────────
|
# ── 렌더러 스텁 ──────────────────────────────────────────────────────────────
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def fake_renderer(monkeypatch, tmp_path_factory):
|
def fake_renderer(monkeypatch, tmp_path_factory):
|
||||||
"""정적 렌더러(solution/site) 대역.
|
"""정적 렌더러(solution/site) 대역."""
|
||||||
|
|
||||||
★ 왜 필요한가
|
|
||||||
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
|
|
||||||
굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site).
|
|
||||||
파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는
|
|
||||||
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
|
|
||||||
|
|
||||||
★ 구조화 데이터 ↔ 화면 값 대조 자체는 렌더러 쪽 테스트가 본다
|
|
||||||
(solution/site/src/seo/verify.test.ts). 그 규칙을 여기서 다시 구현하지 않는다 —
|
|
||||||
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
|
|
||||||
"""
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@ -242,11 +201,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
)
|
)
|
||||||
place = payload.get("place") or {}
|
place = payload.get("place") or {}
|
||||||
count = _count(payload)
|
count = _count(payload)
|
||||||
# ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다**
|
# 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다** (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에 나가 있으면 크롤러가 읽는다).
|
||||||
# (prerender.ts NoUniqueContentError — 백엔드가 나중에 거부해도 그 전에 디스크에
|
|
||||||
# 나가 있으면 크롤러가 읽는다). 대역이 늘 ok=True 를 주면 백엔드가 그 실패를
|
|
||||||
# NO_UNIQUE_CONTENT 로 되짚는 경로가 통째로 테스트되지 않는다.
|
|
||||||
# mismatches 는 비워 둔다 — 사유가 JSONLD_MISMATCH 로 섞이면 화면 문구가 틀린다.
|
|
||||||
if count <= 0:
|
if count <= 0:
|
||||||
return {
|
return {
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""alert_outbox 원장 접근. services/alert_service.py 가 부른다."""
|
"""alert_outbox 원장 접근."""
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
|
|
||||||
from common.database.model.models import alert_outbox
|
from common.database.model.models import alert_outbox
|
||||||
@ -7,10 +7,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
|
|
||||||
async def latest_unresolved(session, dedupe_key: str):
|
async def latest_unresolved(session, dedupe_key: str):
|
||||||
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None.
|
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림."""
|
||||||
|
|
||||||
★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를
|
|
||||||
쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다."""
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(alert_outbox)
|
select(alert_outbox)
|
||||||
.where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False),
|
.where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False),
|
||||||
@ -29,10 +26,7 @@ async def insert(session, values: dict) -> alert_outbox:
|
|||||||
|
|
||||||
|
|
||||||
async def due_pending(session, limit: int = 20):
|
async def due_pending(session, limit: int = 20):
|
||||||
"""★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한
|
"""`next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다."""
|
||||||
GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도
|
|
||||||
컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수
|
|
||||||
있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다."""
|
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(alert_outbox)
|
select(alert_outbox)
|
||||||
.where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False),
|
.where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False),
|
||||||
|
|||||||
@ -10,10 +10,9 @@ from common.enums import ErrorType, FactStatus
|
|||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
# ★ 사이트에 나가는 상태. PUBLISHABLE_FACT_STATUSES 와 같은 집합이어야 한다.
|
# 사이트에 나가는 상태.
|
||||||
# 유니크 인덱스(uq_facts_published_*)의 조건과도 같아야 한다.
|
|
||||||
_PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
_PUBLISHED = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
||||||
# 후보 — 재수집이 올려놓은 확인 대기 항목. 여러 건 공존한다.
|
# 후보 — 재수집이 올려놓은 확인 대기 항목.
|
||||||
_CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
|
_CANDIDATE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value)
|
||||||
# 화면에 보이는 것 전체(이력 제외).
|
# 화면에 보이는 것 전체(이력 제외).
|
||||||
_ACTIVE = _PUBLISHED + _CANDIDATE
|
_ACTIVE = _PUBLISHED + _CANDIDATE
|
||||||
@ -24,7 +23,7 @@ def _unit_cond(unit_id):
|
|||||||
return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id
|
return place_facts.unit_id.is_(None) if unit_id is None else place_facts.unit_id == unit_id
|
||||||
|
|
||||||
|
|
||||||
# fact CRUD. 항상 place_id 로 스코프한다.
|
# fact CRUD.
|
||||||
class IFactCRUD(ABC):
|
class IFactCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
|
async def add_fact(self, cdb: AsyncSession, fact: place_facts) -> ErrorType:
|
||||||
@ -96,10 +95,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
self, cdb: AsyncSession, place_id, unit_id=None, status: Optional[int] = None,
|
self, cdb: AsyncSession, place_id, unit_id=None, status: Optional[int] = None,
|
||||||
publishable_only: bool = False, active_only: bool = True,
|
publishable_only: bool = False, active_only: bool = True,
|
||||||
) -> Tuple[ErrorType, list]:
|
) -> Tuple[ErrorType, list]:
|
||||||
"""fact 목록.
|
"""fact 목록."""
|
||||||
publishable_only=True → ★ VERIFIED·CORRECTED 만 (사이트 렌더·발행 게이트가 쓰는 경로)
|
|
||||||
active_only=True → REJECTED·EXPIRED 이력 제외 (관리 화면 기본: 노출값 + 후보)
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
|
conditions = [place_facts.place_id == place_id, place_facts.deleted == False] # noqa: E712
|
||||||
if unit_id is not None:
|
if unit_id is not None:
|
||||||
@ -111,7 +107,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
elif active_only:
|
elif active_only:
|
||||||
conditions.append(place_facts.status.in_(_ACTIVE))
|
conditions.append(place_facts.status.in_(_ACTIVE))
|
||||||
|
|
||||||
# 노출값이 먼저, 그 아래 후보. 같은 key 끼리 붙어 보이게 정렬한다.
|
# 노출값이 먼저, 그 아래 후보.
|
||||||
query = select(place_facts).where(and_(*conditions)).order_by(
|
query = select(place_facts).where(and_(*conditions)).order_by(
|
||||||
place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc()
|
place_facts.key.asc(), place_facts.status.desc(), place_facts.collected_at.desc()
|
||||||
)
|
)
|
||||||
@ -122,8 +118,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
|
async def get_published_fact(self, cdb: AsyncSession, place_id, unit_id, key) -> Tuple[ErrorType, place_facts]:
|
||||||
"""★ 지금 사이트에 나가고 있는 값. 없으면 (SUCCESS, None).
|
"""지금 사이트에 나가고 있는 값."""
|
||||||
유니크 인덱스가 1건만 허용하므로 결과는 0 또는 1건이다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(place_facts)
|
select(place_facts)
|
||||||
@ -145,7 +140,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
|
async def get_candidate(self, cdb: AsyncSession, place_id, unit_id, key, source_type) -> Tuple[ErrorType, place_facts]:
|
||||||
"""같은 출처가 이미 올려둔 후보. 재수집이 같은 후보를 계속 쌓지 않도록 갱신 대상을 찾는다."""
|
"""같은 출처가 이미 올려둔 후보."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(place_facts)
|
select(place_facts)
|
||||||
@ -169,9 +164,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]:
|
async def refresh_collected(self, cdb: AsyncSession, fact_id, source_type, source_url, ts) -> Tuple[ErrorType, int]:
|
||||||
"""★ 재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다.
|
"""재수집했는데 값이 그대로일 때 — 검증 상태를 건드리지 않고 '언제 다시 확인했는지'만 갱신한다."""
|
||||||
|
|
||||||
이게 없으면 값이 안 바뀌었는데도 재수집마다 검증이 초기화돼 사이트에서 사실이 사라진다."""
|
|
||||||
try:
|
try:
|
||||||
values = {"collected_at": ts, "updated_at": ts}
|
values = {"collected_at": ts, "updated_at": ts}
|
||||||
if source_url:
|
if source_url:
|
||||||
@ -183,7 +176,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]:
|
async def update_candidate(self, cdb: AsyncSession, fact_id, value, source_url, status: int, ts) -> Tuple[ErrorType, int]:
|
||||||
"""기존 후보를 새 수집값으로 갱신. 같은 출처의 후보가 계속 쌓이는 것을 막는다."""
|
"""기존 후보를 새 수집값으로 갱신."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_facts)
|
update(place_facts)
|
||||||
@ -196,10 +189,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
|
async def transition(self, cdb: AsyncSession, fact_id, from_statuses, to_status: int, data: dict) -> Tuple[ErrorType, int]:
|
||||||
"""검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다.
|
"""검증 상태 전이 — **출발 상태를 WHERE 에 걸어** 조건부로만 바꾼다."""
|
||||||
|
|
||||||
적용행수 0 = 그 사이 다른 사람이 이미 상태를 바꿨다는 뜻(동시 처리 가드).
|
|
||||||
허용 전이 판정 자체는 service 가 FACT_STATUS_TRANSITIONS 로 먼저 한다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_facts)
|
update(place_facts)
|
||||||
@ -216,9 +206,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def expire_published(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
|
async def expire_published(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
|
||||||
"""현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출).
|
"""현재 노출값을 EXPIRED 로 내려 자리를 비운다(후보 승격·직접 교체 직전에 호출)."""
|
||||||
|
|
||||||
지우지 않고 이력으로 남긴다 — 예전에 뭐가 나갔는지 추적할 수 있어야 한다."""
|
|
||||||
try:
|
try:
|
||||||
conditions = [
|
conditions = [
|
||||||
place_facts.place_id == place_id,
|
place_facts.place_id == place_id,
|
||||||
@ -236,9 +224,7 @@ class FactCRUD(IFactCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def reject_candidates(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
|
async def reject_candidates(self, cdb: AsyncSession, place_id, unit_id, key, ts, except_fact_id=None) -> Tuple[ErrorType, int]:
|
||||||
"""남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈).
|
"""남은 후보를 REJECTED 로 정리한다(하나를 승격시켰으니 나머지는 판정된 셈)."""
|
||||||
|
|
||||||
후보를 그대로 두면 사람 확인 큐에 이미 처리된 항목이 계속 남는다."""
|
|
||||||
try:
|
try:
|
||||||
conditions = [
|
conditions = [
|
||||||
place_facts.place_id == place_id,
|
place_facts.place_id == place_id,
|
||||||
|
|||||||
@ -14,7 +14,7 @@ _PUBLISHABLE = (FactStatus.VERIFIED.value, FactStatus.CORRECTED.value)
|
|||||||
_ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE
|
_ACTIVE = (FactStatus.UNVERIFIED.value, FactStatus.PENDING_OWNER.value) + _PUBLISHABLE
|
||||||
|
|
||||||
|
|
||||||
# FAQ CRUD. fact 와 같은 검증 상태 흐름을 탄다 — 생성된 문장도 사람이 확인해야 나간다.
|
# FAQ CRUD.
|
||||||
class IFaqCRUD(ABC):
|
class IFaqCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]:
|
async def list_faqs(self, cdb: AsyncSession, place_id, publishable_only: bool) -> Tuple[ErrorType, list]:
|
||||||
@ -72,17 +72,7 @@ class FaqCRUD(IFaqCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
|
async def expire_generated(self, cdb: AsyncSession, place_id, ts) -> Tuple[ErrorType, int]:
|
||||||
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다.
|
"""재생성 전에 **LLM 이 쓴** FAQ 를 내린다."""
|
||||||
|
|
||||||
★ 사람이 정정한 FAQ 는 건드리지 않는다 — 재생성이 사람의 판단을 덮어쓰면
|
|
||||||
fact 쪽 규칙과 어긋난다.
|
|
||||||
|
|
||||||
★ 가르는 기준이 status 에서 generated_by 로 바뀌었다 (2026-09-10).
|
|
||||||
생성분이 UNVERIFIED 로 들어가던 시절에는 status 만으로 "사람이 손댔는가" 를 알 수
|
|
||||||
있었다. 이제 생성분도 VERIFIED 로 들어가므로(copy_service) status 로는 둘이 구분되지
|
|
||||||
않는다 — 그대로 두면 재생성이 옛 FAQ 를 못 내리고 같은 질문이 쌓인다.
|
|
||||||
책임 주체는 원래부터 여기 적혀 있었다: 사장님이 정정하면 faq_service 가
|
|
||||||
generated_by 를 OWNER 로 바꾼다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_faqs)
|
update(place_faqs)
|
||||||
@ -90,10 +80,8 @@ class FaqCRUD(IFaqCRUD):
|
|||||||
place_faqs.place_id == place_id,
|
place_faqs.place_id == place_id,
|
||||||
place_faqs.deleted == False, # noqa: E712
|
place_faqs.deleted == False, # noqa: E712
|
||||||
# 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다.
|
# 목표 수를 채운 공통 질문(TEMPLATE)도 자동 산출물이다 — 재생성마다 다시 고른다.
|
||||||
# 안 내리면 fact 가 새로 생겨 LLM 이 답한 주제에 옛 문의 안내가 겹쳐 남는다.
|
|
||||||
place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)),
|
place_faqs.generated_by.in_((SourceType.LLM.value, SourceType.TEMPLATE.value)),
|
||||||
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 —
|
# 이미 내려간 것(EXPIRED)과 사장님이 반려한 것(REJECTED)은 그대로 둔다 — 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
|
||||||
# 반려는 판단의 기록이라 재생성이 지울 이유가 없다.
|
|
||||||
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
|
place_faqs.status.not_in((FactStatus.EXPIRED.value, FactStatus.REJECTED.value)),
|
||||||
)
|
)
|
||||||
.values(status=FactStatus.EXPIRED.value, updated_at=ts)
|
.values(status=FactStatus.EXPIRED.value, updated_at=ts)
|
||||||
|
|||||||
@ -1,14 +1,4 @@
|
|||||||
"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다. (LPS `crud/job_crud.py` 이식)
|
"""작업 큐 CRUD — PostgreSQL 을 '제대로' 큐로 쓴다."""
|
||||||
|
|
||||||
- 할당은 **단일 문장 원자 claim**: FOR UPDATE SKIP LOCKED 서브쿼리 + 같은 UPDATE + RETURNING.
|
|
||||||
→ 워커 컨테이너가 몇 개든 같은 잡 이중 할당이 원천 불가. fetch 와 claim 을 분리하지 않는다.
|
|
||||||
- 모든 전이는 **조건부 CAS**(WHERE 에 status/worker_id 가드) + RETURNING.
|
|
||||||
- 복구는 timeout 추측이 아니라 **lease 만료 소유권**(reaper 가 회수).
|
|
||||||
- 재시도/백오프/dead-letter 를 큐에 내장.
|
|
||||||
|
|
||||||
전이가 조회/변경으로 나뉘지 않으므로(RETURNING) execute_lambda_write 로 실행한다.
|
|
||||||
큐 전이만 raw SQL 이다 — 다른 crud 는 전부 SQLAlchemy 표현식을 쓴다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -23,7 +13,7 @@ JOB_NOTIFY_CHANNEL = "web4ai_job"
|
|||||||
|
|
||||||
|
|
||||||
def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float:
|
def compute_backoff(attempts: int, base: float = 5.0, cap: float = 600.0) -> float:
|
||||||
"""지수 백오프(초). attempts 회 시도 후 다음 재시도까지 대기 = base * 2^(attempts-1), cap 상한."""
|
"""지수 백오프(초)."""
|
||||||
return min(cap, base * (2 ** max(0, attempts - 1)))
|
return min(cap, base * (2 ** max(0, attempts - 1)))
|
||||||
|
|
||||||
|
|
||||||
@ -34,7 +24,7 @@ class JobQueue:
|
|||||||
"""쓰기 트랜잭션 — 값 반환이 필요한 큐 전이 전용 진입점."""
|
"""쓰기 트랜잭션 — 값 반환이 필요한 큐 전이 전용 진입점."""
|
||||||
return await DB_SESSION_MNG.execute_lambda_write(self.DB, fn)
|
return await DB_SESSION_MNG.execute_lambda_write(self.DB, fn)
|
||||||
|
|
||||||
# ---- 적재 ----
|
# 적재
|
||||||
async def enqueue(
|
async def enqueue(
|
||||||
self,
|
self,
|
||||||
job_type: int,
|
job_type: int,
|
||||||
@ -43,7 +33,7 @@ class JobQueue:
|
|||||||
dedupe_key: str | None = None,
|
dedupe_key: str | None = None,
|
||||||
max_attempts: int = 3,
|
max_attempts: int = 3,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""잡 적재. dedupe_key 가 활성(PENDING/RUNNING) 중복이면 삽입 없이 None 반환."""
|
"""잡 적재."""
|
||||||
sql = text("""
|
sql = text("""
|
||||||
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
|
INSERT INTO jobs (job_type, priority, payload, dedupe_key, max_attempts)
|
||||||
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
|
VALUES (:t, :p, CAST(:payload AS jsonb), :dk, :ma)
|
||||||
@ -64,10 +54,9 @@ class JobQueue:
|
|||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
# ---- 원자적 claim ----
|
# 원자적 claim
|
||||||
async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None:
|
async def claim(self, worker_id: str, lease_sec: int = 120) -> dict | None:
|
||||||
"""대기 잡 1건을 원자적으로 점유. 없으면 None.
|
"""대기 잡 1건을 원자적으로 점유."""
|
||||||
FOR UPDATE SKIP LOCKED 로 잠근 행을 같은 UPDATE 에서 RUNNING 으로 전이 → 이중 할당 불가."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET
|
UPDATE jobs SET
|
||||||
status = 2,
|
status = 2,
|
||||||
@ -98,7 +87,7 @@ class JobQueue:
|
|||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
# ---- 완료/실패 (소유권 가드) ----
|
# 완료/실패 (소유권 가드)
|
||||||
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
|
async def complete(self, job_id: str, worker_id: str, result: dict | None = None) -> bool:
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
|
UPDATE jobs SET status = 3, result = CAST(:result AS jsonb),
|
||||||
@ -117,8 +106,7 @@ class JobQueue:
|
|||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None:
|
async def fail(self, job_id: str, worker_id: str, error: str, backoff_sec: float = 5.0) -> int | None:
|
||||||
"""실패 처리. 시도 남으면 PENDING(run_after=백오프)으로 재큐, 소진되면 DEAD(dead-letter).
|
"""실패 처리."""
|
||||||
전이 후 status(JobStatus 값)를 반환. 소유 불일치면 None."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET
|
UPDATE jobs SET
|
||||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||||
@ -141,11 +129,7 @@ class JobQueue:
|
|||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool:
|
async def fail_permanent(self, job_id: str, worker_id: str, error: str) -> bool:
|
||||||
"""재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다.
|
"""재시도 없이 바로 DEAD."""
|
||||||
|
|
||||||
★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError).
|
|
||||||
백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은
|
|
||||||
그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET status = 4, last_error = :err,
|
UPDATE jobs SET status = 4, last_error = :err,
|
||||||
lease_until = NULL, worker_id = NULL, updated_at = now()
|
lease_until = NULL, worker_id = NULL, updated_at = now()
|
||||||
@ -159,7 +143,7 @@ class JobQueue:
|
|||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
# ---- lease 갱신(heartbeat) / 회수(reaper) ----
|
# lease 갱신(heartbeat) / 회수(reaper)
|
||||||
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
|
async def renew_lease(self, job_id: str, worker_id: str, lease_sec: int = 120) -> bool:
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
|
UPDATE jobs SET lease_until = now() + make_interval(secs => :lease), updated_at = now()
|
||||||
@ -174,11 +158,7 @@ class JobQueue:
|
|||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def reap(self) -> list[dict]:
|
async def reap(self) -> list[dict]:
|
||||||
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
|
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수."""
|
||||||
|
|
||||||
회수된 잡마다 {job_id, job_type, status, last_error} 를 돌려준다 — worker/runner.py 의
|
|
||||||
run_reaper 가 이 중 DEAD(4) 로 떨어진 것만 골라 알린다(alert_service). job_id 목록만
|
|
||||||
돌려주던 예전 모양보다 한 겹 더 있는 이유가 그것뿐이다."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET
|
UPDATE jobs SET
|
||||||
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
|
||||||
@ -200,7 +180,7 @@ class JobQueue:
|
|||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
# ---- 단건 조회 (상태 폴링) ----
|
# 단건 조회 (상태 폴링)
|
||||||
async def set_progress(self, job: dict, progress: dict) -> bool:
|
async def set_progress(self, job: dict, progress: dict) -> bool:
|
||||||
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
|
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
|
||||||
sql = text("""
|
sql = text("""
|
||||||
@ -221,7 +201,7 @@ class JobQueue:
|
|||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def find_latest(self, dedupe_key: str) -> dict | None:
|
async def find_latest(self, dedupe_key: str) -> dict | None:
|
||||||
"""복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다."""
|
"""복구는 완료·실패 이력도 찾는다."""
|
||||||
async def run(s):
|
async def run(s):
|
||||||
row = (await s.execute(text("""
|
row = (await s.execute(text("""
|
||||||
SELECT job_id, status FROM jobs WHERE dedupe_key = :dk
|
SELECT job_id, status FROM jobs WHERE dedupe_key = :dk
|
||||||
@ -232,7 +212,7 @@ class JobQueue:
|
|||||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||||
|
|
||||||
async def get(self, job_id: str) -> dict | None:
|
async def get(self, job_id: str) -> dict | None:
|
||||||
"""잡 단건 조회(읽기). 없으면 None. status 는 정수(JobStatus 값)."""
|
"""잡 단건 조회(읽기)."""
|
||||||
sql = text("""
|
sql = text("""
|
||||||
SELECT job_id, job_type, status, priority, attempts, max_attempts,
|
SELECT job_id, job_type, status, priority, attempts, max_attempts,
|
||||||
payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at
|
payload, result, progress, last_error, run_after, run_started_at, created_at, updated_at
|
||||||
@ -253,8 +233,7 @@ class JobQueue:
|
|||||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||||
|
|
||||||
async def find_active(self, dedupe_key: str) -> dict | None:
|
async def find_active(self, dedupe_key: str) -> dict | None:
|
||||||
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다.
|
"""dedupe_key 로 활성(PENDING/RUNNING) 잡을 찾는다."""
|
||||||
enqueue 가 중복으로 None 을 돌려줬을 때, 이미 돌고 있는 잡의 id 를 알려주기 위함."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
SELECT job_id, job_type, status FROM jobs
|
SELECT job_id, job_type, status FROM jobs
|
||||||
WHERE dedupe_key = :dk AND status IN (1, 2)
|
WHERE dedupe_key = :dk AND status IN (1, 2)
|
||||||
@ -271,7 +250,7 @@ class JobQueue:
|
|||||||
|
|
||||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||||
|
|
||||||
# ---- 관측(관리 API/알림용) ----
|
# 관측(관리 API/알림용)
|
||||||
async def counts(self) -> dict[str, int]:
|
async def counts(self) -> dict[str, int]:
|
||||||
"""상태별 잡 개수."""
|
"""상태별 잡 개수."""
|
||||||
async def run(s):
|
async def run(s):
|
||||||
@ -282,11 +261,7 @@ class JobQueue:
|
|||||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||||
|
|
||||||
async def ops(self) -> dict:
|
async def ops(self) -> dict:
|
||||||
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) +
|
"""운영 스냅샷(모니터링·알림용): 상태별 카운트 + 큐 지연(가장 오래된 PENDING 나이) + 최근 1시간 DEAD + stuck(좀비 신호)."""
|
||||||
최근 1시간 DEAD + stuck(좀비 신호).
|
|
||||||
|
|
||||||
stuck 은 두 축 — lease 만료(워커 사망인데 reaper 미회수) OR 실행 10분 초과(핸들러 행 —
|
|
||||||
heartbeat 가 lease 를 계속 갱신해 lease 축엔 안 잡히므로 run_started_at 으로 따로 본다)."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
SELECT
|
SELECT
|
||||||
count(*) FILTER (WHERE status = 1) AS pending,
|
count(*) FILTER (WHERE status = 1) AS pending,
|
||||||
@ -310,8 +285,7 @@ class JobQueue:
|
|||||||
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
return await DB_SESSION_MNG.execute_lambda(self.DB, DBWRType.DB_READ.value, run)
|
||||||
|
|
||||||
async def requeue(self, job_id: str) -> str | None:
|
async def requeue(self, job_id: str) -> str | None:
|
||||||
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움.
|
"""DEAD 잡 재큐(관리자 액션): attempts 리셋 + PENDING 전이 + 워커 깨움."""
|
||||||
DEAD 가 아니거나 없으면 None. 같은 dedupe_key 의 활성 잡이 있으면 부분 유니크 위반."""
|
|
||||||
sql = text("""
|
sql = text("""
|
||||||
UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
|
UPDATE jobs SET status = 1, attempts = 0, run_after = now(),
|
||||||
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
|
lease_until = NULL, worker_id = NULL, run_started_at = NULL,
|
||||||
|
|||||||
@ -9,8 +9,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
class LocalContentCRUD:
|
class LocalContentCRUD:
|
||||||
async def list(self, db, status: int | None = None, region_code: str | None = None):
|
async def list(self, db, status: int | None = None, region_code: str | None = None):
|
||||||
"""축제·관광지·맛집·날씨 전 종류. ★ 예전엔 FESTIVAL 로 고정돼 있어 sync_region 이 받은
|
"""축제·관광지·맛집·날씨 전 종류."""
|
||||||
관광지·맛집이 이 목록에 영영 안 보였다(admin 화면이 축제만 검수/발행하는 줄 알게 됨)."""
|
|
||||||
conds = [area_contents.deleted == False] # noqa: E712
|
conds = [area_contents.deleted == False] # noqa: E712
|
||||||
if status is not None:
|
if status is not None:
|
||||||
conds.append(area_contents.status == status)
|
conds.append(area_contents.status == status)
|
||||||
@ -43,20 +42,11 @@ class LocalContentCRUD:
|
|||||||
return await self.update(db, content_id, {"status": 3})
|
return await self.update(db, content_id, {"status": 3})
|
||||||
|
|
||||||
async def upsert_kind(self, db, values: dict):
|
async def upsert_kind(self, db, values: dict):
|
||||||
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신.
|
"""지역 이야기 한 종류(가요·인물·…)의 삽입/갱신."""
|
||||||
|
|
||||||
★ `uq_local_contents_kind`(region_code, kind — kind IS NOT NULL)에 태운다.
|
|
||||||
이 표의 규약은 **한 지역에 종류당 한 벌**이다(migrations/0004). 항목마다 한 행이 아니라
|
|
||||||
`body.items` 에 통째로 담긴다 — 사장님이 붙여넣는 같은 종류의 JSON 과 모양을 맞추기
|
|
||||||
위해서다. 다시 생성하면 그 한 행을 덮어쓴다.
|
|
||||||
★ external_id 는 넣지 않는다. 넣으면 `uq_local_contents_external`(source, external_id)에도
|
|
||||||
걸려, 종류가 다른 두 행이 같은 키로 충돌한다."""
|
|
||||||
stmt = pg_insert(area_contents).values(**values)
|
stmt = pg_insert(area_contents).values(**values)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=[area_contents.region_code, area_contents.kind],
|
index_elements=[area_contents.region_code, area_contents.kind],
|
||||||
# ★ 조건은 인덱스와 **글자 그대로** 같아야 한다. 포스트그레스는 ON CONFLICT 술어가
|
# 조건은 인덱스와 **글자 그대로** 같아야 한다.
|
||||||
# 인덱스 술어를 함의하는지 보고, 아니면 "no unique or exclusion constraint matching"
|
|
||||||
# 으로 거절한다 — 컬럼도 표도 멀쩡해서 눈으로는 원인이 안 보이는 종류다.
|
|
||||||
index_where=and_(
|
index_where=and_(
|
||||||
area_contents.deleted == False, # noqa: E712
|
area_contents.deleted == False, # noqa: E712
|
||||||
area_contents.kind.isnot(None),
|
area_contents.kind.isnot(None),
|
||||||
@ -76,17 +66,7 @@ class LocalContentCRUD:
|
|||||||
return await DB_SESSION_MNG.add(db, stmt)
|
return await DB_SESSION_MNG.add(db, stmt)
|
||||||
|
|
||||||
async def list_kinds(self, db, region_code: str):
|
async def list_kinds(self, db, region_code: str):
|
||||||
"""지역의 **이야기** 행(종류당 1행). cache-aside 판단에 쓴다.
|
"""지역의 **이야기** 행(종류당 1행)."""
|
||||||
|
|
||||||
★ `kind IS NOT NULL` 로 고르면 안 된다. kind 는 이야기 전용 칸이 아니다 —
|
|
||||||
마이그레이션 0008 이 날씨·축제·명소·맛집에도 kind 를 채웠기 때문에(AREA_KIND),
|
|
||||||
그렇게 고르면 **이야기가 한 건도 없는 지역이 "이미 있다"로 판정된다.**
|
|
||||||
실측(2026-09-10, 전북 군산시): 주변정보 116건이 들어온 뒤로 `has_stories` 가 늘 참이라
|
|
||||||
지역 이야기 생성이 영영 건너뛰어졌고, 발행본에서 가요다방·인물열전·시간의 골목·
|
|
||||||
엽서·퀴즈 다섯 섹션이 통째로 비었다. 잡은 성공으로 끝나고 로그도 조용해서
|
|
||||||
"생성기가 없는 것" 처럼 보였다.
|
|
||||||
★ 그래서 STORY_KINDS 를 명시한다. 종류가 늘면 그 상수만 늘린다.
|
|
||||||
"""
|
|
||||||
return await DB_SESSION_MNG.execute(
|
return await DB_SESSION_MNG.execute(
|
||||||
db,
|
db,
|
||||||
select(area_contents).where(
|
select(area_contents).where(
|
||||||
@ -112,8 +92,6 @@ class LocalContentCRUD:
|
|||||||
stmt = pg_insert(area_contents).values(**values)
|
stmt = pg_insert(area_contents).values(**values)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=[area_contents.region_code, area_contents.content_type],
|
index_elements=[area_contents.region_code, area_contents.content_type],
|
||||||
# ★ `kind IS NULL` 이 빠져 있어 이 upsert 가 통째로 실패하고 있었다(0007 이 인덱스에
|
|
||||||
# 그 조건을 더했다). 날씨는 캐시라 실패해도 화면이 안 죽어서 **로그에만 남았다.**
|
|
||||||
index_where=and_(
|
index_where=and_(
|
||||||
area_contents.deleted == False, # noqa: E712
|
area_contents.deleted == False, # noqa: E712
|
||||||
area_contents.external_id.is_(None),
|
area_contents.external_id.is_(None),
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
# 사진 CRUD. 항상 place_id 로 스코프한다.
|
# 사진 CRUD.
|
||||||
class IMediaCRUD(ABC):
|
class IMediaCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_media(
|
async def list_media(
|
||||||
@ -32,18 +32,7 @@ class MediaCRUD(IMediaCRUD):
|
|||||||
async def list_media(
|
async def list_media(
|
||||||
self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False
|
self, cdb: AsyncSession, place_id, status=None, unlabeled_only: bool = False, unit_id=None, alt_required: bool = False
|
||||||
) -> Tuple[ErrorType, list]:
|
) -> Tuple[ErrorType, list]:
|
||||||
"""사진 목록. unlabeled_only=True 면 아직 Vision 분석이 안 된 것만(재분석 비용 절약).
|
"""사진 목록."""
|
||||||
|
|
||||||
★ "분석 안 됨"의 기준은 **alt_text 가 비었는가**다. label 이 아니다.
|
|
||||||
수집 어댑터가 페이지에서 주운 캡션을 label 에 넣어 두기 때문에(base.CollectedMedia
|
|
||||||
주석: "Vision 이 확정하기 전의 후보 라벨"), label 로 판정하면 캡션이 있는 사진은
|
|
||||||
전부 '이미 분석됨'으로 건너뛴다 — 실제로 네이버에서 긁은 사진 10장이 통째로
|
|
||||||
그렇게 빠져 Vision 이 "분석할 사진이 없다"로 끝났고, alt 가 없어 발행도 못 했다.
|
|
||||||
alt 는 Vision 만 채우고 발행 조건이기도 하므로 기준으로 삼기에 정확하다.
|
|
||||||
|
|
||||||
unit_id 는 객실·메뉴 단위 사진만 추린다(빌더가 객실 카드에 붙일 사진을 고를 때).
|
|
||||||
★ alt_required=True 는 status 필터와 짝으로만 쓴다 — alt 가 빈 사진은 빌더가 렌더하지
|
|
||||||
않으므로(services/snapshot.py), '발행되면 실릴 것'을 물었을 때 승인만 보면 답이 틀린다."""
|
|
||||||
try:
|
try:
|
||||||
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
|
conditions = [place_photos.place_id == place_id, place_photos.deleted == False] # noqa: E712
|
||||||
if status is not None:
|
if status is not None:
|
||||||
@ -66,10 +55,7 @@ class MediaCRUD(IMediaCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]:
|
async def apply_vision(self, cdb: AsyncSession, media_id, label, alt_text, confidence, status: int, ts) -> Tuple[ErrorType, int]:
|
||||||
"""Vision 분석 결과를 반영한다.
|
"""Vision 분석 결과를 반영한다."""
|
||||||
|
|
||||||
★ 신뢰도가 낮으면 status 를 PENDING_REVIEW 로 남긴다 — 자동 반영하지 않는다.
|
|
||||||
라벨·alt 는 저장하되(사람이 보고 고칠 재료), 승인 상태로 올리지 않는 게 핵심이다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_photos)
|
update(place_photos)
|
||||||
|
|||||||
@ -7,18 +7,10 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
|
|
||||||
class PlaceContentCRUD:
|
class PlaceContentCRUD:
|
||||||
"""업장 주변의 지역 콘텐츠.
|
"""업장 주변의 지역 콘텐츠."""
|
||||||
|
|
||||||
★ 실체와 관계가 갈려 있다 (2026-09-09).
|
|
||||||
예전에는 한 테이블이 값을 통째로 들고 있었고 키가 place_id 라, 업장마다 TourAPI
|
|
||||||
응답이 복제됐다 — 실측 조이모텔 한 곳에 144행이고 같은 축제가 업장 수만큼 늘었다.
|
|
||||||
지금은 실체가 `area_contents` 에 한 행(전국 공용, external_id 로 유일)이고
|
|
||||||
`place_area_refs` 에는 그 업장에서만 다른 것 — 거리와 숨김 — 만 남는다.
|
|
||||||
그래서 읽을 때 조인이 하나 는다. 그 값으로 복제를 없앴다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
|
async def list_by_place(self, db, place_id, *, include_hidden: bool = True):
|
||||||
"""이 업장 주변의 콘텐츠. 실체(area_contents)와 거리(place_area_refs)를 함께 준다."""
|
"""이 업장 주변의 콘텐츠."""
|
||||||
conds = [
|
conds = [
|
||||||
place_area_refs.place_id == place_id,
|
place_area_refs.place_id == place_id,
|
||||||
place_area_refs.deleted == False, # noqa: E712
|
place_area_refs.deleted == False, # noqa: E712
|
||||||
@ -38,7 +30,7 @@ class PlaceContentCRUD:
|
|||||||
area_contents.latitude,
|
area_contents.latitude,
|
||||||
area_contents.longitude,
|
area_contents.longitude,
|
||||||
area_contents.display_end_at,
|
area_contents.display_end_at,
|
||||||
# ★ 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
|
# 이름을 옛 컬럼과 맞춘다 — 읽는 쪽(snapshot)이 행을 그대로 쓰던 모양이다.
|
||||||
place_area_refs.distance_m.label("distance_m"),
|
place_area_refs.distance_m.label("distance_m"),
|
||||||
place_area_refs.hidden.label("hidden"),
|
place_area_refs.hidden.label("hidden"),
|
||||||
)
|
)
|
||||||
@ -51,11 +43,7 @@ class PlaceContentCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def upsert_content(self, db, values: dict):
|
async def upsert_content(self, db, values: dict):
|
||||||
"""공용 콘텐츠 한 건. (source, external_id) 가 같으면 갱신한다 — 지역과 무관하게 한 벌이다.
|
"""공용 콘텐츠 한 건."""
|
||||||
|
|
||||||
★ RETURNING 을 쓰지 않는다. 세션 매니저의 execute 는 SELECT 만 받고
|
|
||||||
("DO NOT USE NON-SELECT QUERY IN DBJOB"), 쓰기는 add 로 간다. id 는 뒤이어 조회한다.
|
|
||||||
"""
|
|
||||||
stmt = pg_insert(area_contents).values(**values)
|
stmt = pg_insert(area_contents).values(**values)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=[area_contents.source, area_contents.external_id],
|
index_elements=[area_contents.source, area_contents.external_id],
|
||||||
@ -88,7 +76,7 @@ class PlaceContentCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def upsert_ref(self, db, place_id, local_content_id, distance_m):
|
async def upsert_ref(self, db, place_id, local_content_id, distance_m):
|
||||||
"""업장 ↔ 콘텐츠 관계. ★ hidden 은 건드리지 않는다 — 운영자가 숨긴 것을 재수집이 되살리면 안 된다."""
|
"""업장 ↔ 콘텐츠 관계."""
|
||||||
stmt = pg_insert(place_area_refs).values(
|
stmt = pg_insert(place_area_refs).values(
|
||||||
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
|
place_id=place_id, local_content_id=local_content_id, distance_m=distance_m, deleted=False,
|
||||||
)
|
)
|
||||||
@ -99,8 +87,7 @@ class PlaceContentCRUD:
|
|||||||
return await DB_SESSION_MNG.add(db, stmt)
|
return await DB_SESSION_MNG.add(db, stmt)
|
||||||
|
|
||||||
async def soft_delete_missing(self, db, place_id, keep_ids: set):
|
async def soft_delete_missing(self, db, place_id, keep_ids: set):
|
||||||
"""이번 응답에 없는 **관계**를 끊는다. 실체(area_contents)는 지우지 않는다 —
|
"""이번 응답에 없는 **관계**를 끊는다."""
|
||||||
다른 업장이 같은 장소를 가리키고 있을 수 있다."""
|
|
||||||
err, rows = await DB_SESSION_MNG.execute(
|
err, rows = await DB_SESSION_MNG.execute(
|
||||||
db,
|
db,
|
||||||
select(place_area_refs.local_content_id).where(
|
select(place_area_refs.local_content_id).where(
|
||||||
@ -109,7 +96,6 @@ class PlaceContentCRUD:
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
|
# 단일 컬럼 SELECT 라 행이 스칼라로 온다.
|
||||||
# ★ 단일 컬럼 SELECT 는 세션 매니저가 scalars() 로 편다 — 행이 곧 값이다.
|
|
||||||
gone = [r for r in (rows or []) if r not in keep_ids]
|
gone = [r for r in (rows or []) if r not in keep_ids]
|
||||||
if not gone:
|
if not gone:
|
||||||
return err, 0
|
return err, 0
|
||||||
@ -121,7 +107,7 @@ class PlaceContentCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
|
async def set_hidden(self, db, place_id, local_content_id, hidden: bool):
|
||||||
"""이 업장에서만 숨긴다. 실체는 그대로라 다른 업장에는 계속 보인다."""
|
"""이 업장에서만 숨긴다."""
|
||||||
return await DB_SESSION_MNG.add_with_rowcount(
|
return await DB_SESSION_MNG.add_with_rowcount(
|
||||||
db,
|
db,
|
||||||
update(place_area_refs)
|
update(place_area_refs)
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
# 사업장 CRUD. 모든 조회는 owner_user_id(사장님)로 스코프한다 — 남의 가게가 보이면 안 된다.
|
# 사업장 CRUD.
|
||||||
class IPlaceCRUD(ABC):
|
class IPlaceCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
async def add_place(self, cdb: AsyncSession, place: places) -> ErrorType:
|
||||||
@ -95,16 +95,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
|
async def find_by_external(self, cdb: AsyncSession, owner_user_id, source, external_place_id) -> Tuple[ErrorType, list]:
|
||||||
"""같은 사장님이 **이미 갖고 있는** 같은 외부 업소. 중복 사업장 판정용이다.
|
"""같은 사장님이 **이미 갖고 있는** 같은 외부 업소."""
|
||||||
|
|
||||||
★ 소유자까지 함께 본다. 외부 id 만으로 찾으면 다른 사장님의 사업장이 걸리고,
|
|
||||||
그걸 이어 쓰면 남의 가게를 넘겨받는 셈이 된다.
|
|
||||||
★ **쌓인 것이 많은 순**으로 준다. 부르는 쪽은 맨 앞을 정본으로 삼는다.
|
|
||||||
한때 `created_at` 오름차순이었는데, 그러면 위저드가 처음 만들었다가 버린 **빈 행**이
|
|
||||||
정본이 되고 정작 fact·객실·사진이 쌓인 행을 접게 된다(실측 2026-09-10: 정본으로
|
|
||||||
fact 2건짜리 행이 뽑혔다). 나이가 아니라 **내용**이 기준이다.
|
|
||||||
같은 무게면 먼저 만든 쪽이다 — 그 시점부터 사장님이 알고 있던 주소이기 때문이다.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
def _count(model):
|
def _count(model):
|
||||||
return (
|
return (
|
||||||
@ -167,7 +158,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, [], 0
|
return ErrorType.DB_RUN_FAILED, [], 0
|
||||||
|
|
||||||
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
|
async def update_place(self, cdb: AsyncSession, owner_user_id, place_id, data: dict) -> Tuple[ErrorType, int]:
|
||||||
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다. (ErrorType, 적용행수)."""
|
"""회사 스코프를 WHERE 에 걸어 남의 회사 사업장을 못 건드리게 한다."""
|
||||||
try:
|
try:
|
||||||
if not data:
|
if not data:
|
||||||
return ErrorType.SUCCESS, 0
|
return ErrorType.SUCCESS, 0
|
||||||
@ -182,7 +173,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
|
async def delete_place(self, cdb: AsyncSession, owner_user_id, place_id) -> Tuple[ErrorType, int]:
|
||||||
"""사업장을 실제 삭제한다. 회사 스코프 밖의 행은 건드리지 않는다."""
|
"""사업장을 실제 삭제한다."""
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
delete(places)
|
delete(places)
|
||||||
@ -231,7 +222,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
|
async def list_links(self, cdb: AsyncSession, place_id, confirmed_only: bool = False) -> Tuple[ErrorType, list]:
|
||||||
"""채널 URL 목록. confirmed_only=True 면 ★ 크롤링 대상(확정된 URL)만."""
|
"""채널 URL 목록."""
|
||||||
try:
|
try:
|
||||||
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
|
conditions = [place_channels.place_id == place_id, place_channels.deleted == False] # noqa: E712
|
||||||
if confirmed_only:
|
if confirmed_only:
|
||||||
@ -251,11 +242,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]:
|
async def confirm_link_by_url(self, cdb: AsyncSession, place_id, url, user_id, ts) -> Tuple[ErrorType, int]:
|
||||||
"""URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다.
|
"""URL 로 확정한다 — 방금 넣은 링크의 link_id 를 다시 조회하지 않기 위해서다."""
|
||||||
|
|
||||||
(place_id, url) 은 유니크라 대상이 한 건으로 정해진다. 이미 확정된 건 rowcount 0.
|
|
||||||
★ 쓰는 곳은 상호 일치로 찾은 네이버 플레이스 링크 하나뿐이다 — 근거 없이 확정하는
|
|
||||||
경로를 늘리지 않으려고 일부러 좁게 열어 둔다(collect_service.discover_naver_place)."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_channels)
|
update(place_channels)
|
||||||
@ -273,17 +260,7 @@ class PlaceCRUD(IPlaceCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, 0
|
return ErrorType.DB_RUN_FAILED, 0
|
||||||
|
|
||||||
async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
|
async def set_link_raw(self, cdb: AsyncSession, place_id, url, raw) -> Tuple[ErrorType, int]:
|
||||||
"""수집한 원문을 링크에 박제한다.
|
"""수집한 원문을 링크에 박제한다."""
|
||||||
|
|
||||||
★ 왜 fact 가 아니라 여기인가 (2026-08-31)
|
|
||||||
TourAPI 의 `overview` 같은 소개 원문은 **사실 목록이 아니라 글**이다.
|
|
||||||
이걸 `intro` fact 로 넣었더니 457자 원문이 그대로 VERIFIED 가 되어
|
|
||||||
사장님 사이트의 '숙소 소개' 자리를 차지했다 — LLM 이 쓴 소개문은 뒤에서 대기 중인데.
|
|
||||||
`intro` 는 allow_llm=True, 즉 **LLM 의 출력 칸**이라 수집물이 들어가면 안 된다.
|
|
||||||
|
|
||||||
그렇다고 버리면 소개문·FAQ 의 근거가 사라진다(부대시설·주변 거리 같은 정보가
|
|
||||||
여기에만 있다). 그래서 **발행되지 않는 자리**에 원문을 남기고,
|
|
||||||
생성 시점에만 근거로 넘긴다(services/copy_service.py)."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
update(place_channels)
|
update(place_channels)
|
||||||
|
|||||||
@ -8,8 +8,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
class PlaceItineraryCRUD:
|
class PlaceItineraryCRUD:
|
||||||
async def list_by_place(self, db, place_id):
|
async def list_by_place(self, db, place_id):
|
||||||
"""업장의 일정 전부(기간별 한 행). 정렬은 기간 이름 순이 아니라 저장 순이 아니다 —
|
"""업장의 일정 전부(기간별 한 행)."""
|
||||||
화면 탭 순서는 읽는 쪽(`snapshot._local_contents`)이 DURATIONS 순으로 정한다."""
|
|
||||||
return await DB_SESSION_MNG.execute(
|
return await DB_SESSION_MNG.execute(
|
||||||
db,
|
db,
|
||||||
select(place_itineraries).where(
|
select(place_itineraries).where(
|
||||||
@ -19,13 +18,7 @@ class PlaceItineraryCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def upsert(self, db, values: dict):
|
async def upsert(self, db, values: dict):
|
||||||
"""업장 × 기간 한 행의 삽입/갱신.
|
"""업장 × 기간 한 행의 삽입/갱신."""
|
||||||
|
|
||||||
★ `uq_place_itineraries`(place_id, duration — deleted = false)에 태운다.
|
|
||||||
ON CONFLICT 술어는 인덱스 술어와 **글자 그대로** 같아야 한다. 아니면 포스트그레스가
|
|
||||||
"no unique or exclusion constraint matching" 으로 거절한다 — 표도 컬럼도 멀쩡해서
|
|
||||||
눈으로는 원인이 안 보인다(`local_content_crud.upsert_kind` 주석과 같은 함정).
|
|
||||||
"""
|
|
||||||
stmt = pg_insert(place_itineraries).values(**values)
|
stmt = pg_insert(place_itineraries).values(**values)
|
||||||
stmt = stmt.on_conflict_do_update(
|
stmt = stmt.on_conflict_do_update(
|
||||||
index_elements=[place_itineraries.place_id, place_itineraries.duration],
|
index_elements=[place_itineraries.place_id, place_itineraries.duration],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md)."""
|
"""place_posts 접근."""
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@ -9,8 +9,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
class PostCRUD:
|
class PostCRUD:
|
||||||
async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
|
async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
|
||||||
"""생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 —
|
"""생성분 적재."""
|
||||||
회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다)."""
|
|
||||||
for row in rows:
|
for row in rows:
|
||||||
try:
|
try:
|
||||||
cdb.add(place_posts(**row))
|
cdb.add(place_posts(**row))
|
||||||
@ -20,12 +19,7 @@ class PostCRUD:
|
|||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
|
async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
|
||||||
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값
|
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값 (post_id 포함)을 그대로 돌려준다."""
|
||||||
(post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히
|
|
||||||
건너뜀" 으로 끝내면 안 된다.
|
|
||||||
|
|
||||||
★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면
|
|
||||||
detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다."""
|
|
||||||
try:
|
try:
|
||||||
obj = place_posts(**row)
|
obj = place_posts(**row)
|
||||||
cdb.add(obj)
|
cdb.add(obj)
|
||||||
@ -40,7 +34,7 @@ class PostCRUD:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
async def max_scheduled_date(self, cdb: AsyncSession, place_id):
|
async def max_scheduled_date(self, cdb: AsyncSession, place_id):
|
||||||
"""이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다)."""
|
"""이 업장이 이미 배정한 가장 늦은 날짜."""
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
select(func.max(place_posts.scheduled_date))
|
select(func.max(place_posts.scheduled_date))
|
||||||
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
||||||
@ -48,8 +42,7 @@ class PostCRUD:
|
|||||||
return result.scalar()
|
return result.scalar()
|
||||||
|
|
||||||
async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
|
async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
|
||||||
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도
|
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순."""
|
||||||
하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다)."""
|
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
select(place_posts)
|
select(place_posts)
|
||||||
.where(
|
.where(
|
||||||
@ -63,8 +56,7 @@ class PostCRUD:
|
|||||||
return ErrorType.SUCCESS, rows[:limit]
|
return ErrorType.SUCCESS, rows[:limit]
|
||||||
|
|
||||||
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
||||||
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None.
|
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다."""
|
||||||
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
|
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
select(place_posts)
|
select(place_posts)
|
||||||
.where(
|
.where(
|
||||||
@ -99,10 +91,7 @@ class PostCRUD:
|
|||||||
return result.scalars().first()
|
return result.scalars().first()
|
||||||
|
|
||||||
async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
|
async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
|
||||||
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다.
|
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다."""
|
||||||
새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로
|
|
||||||
같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도
|
|
||||||
전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다."""
|
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
select(
|
select(
|
||||||
place_posts.created_at,
|
place_posts.created_at,
|
||||||
@ -124,7 +113,7 @@ class PostCRUD:
|
|||||||
return [row[0] for row in result.all()]
|
return [row[0] for row in result.all()]
|
||||||
|
|
||||||
async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
|
async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
|
||||||
"""화면에 나갈 글. 최신순이고, 게재된 것만."""
|
"""화면에 나갈 글."""
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
select(place_posts)
|
select(place_posts)
|
||||||
.where(
|
.where(
|
||||||
@ -153,8 +142,7 @@ class PostCRUD:
|
|||||||
)
|
)
|
||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이
|
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
|
||||||
# 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
|
|
||||||
_EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
|
_EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
|
||||||
|
|
||||||
async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
|
async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
|
||||||
@ -167,7 +155,7 @@ class PostCRUD:
|
|||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
|
async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||||
"""★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
|
"""토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
|
||||||
await cdb.execute(
|
await cdb.execute(
|
||||||
update(place_posts)
|
update(place_posts)
|
||||||
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
|
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
|
||||||
@ -198,8 +186,7 @@ class PostCRUD:
|
|||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||||
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false
|
"""소프트 삭제."""
|
||||||
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
|
|
||||||
await cdb.execute(
|
await cdb.execute(
|
||||||
update(place_posts)
|
update(place_posts)
|
||||||
.where(place_posts.post_id == post_id)
|
.where(place_posts.post_id == post_id)
|
||||||
|
|||||||
@ -12,12 +12,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
|
|
||||||
def _primary_photo_subquery():
|
def _primary_photo_subquery():
|
||||||
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).
|
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행)."""
|
||||||
|
|
||||||
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
|
|
||||||
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
|
|
||||||
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
|
|
||||||
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
|
|
||||||
return (
|
return (
|
||||||
select(place_photos.url)
|
select(place_photos.url)
|
||||||
.where(
|
.where(
|
||||||
@ -33,7 +28,7 @@ def _primary_photo_subquery():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
# 사이트/버전/발행로그 CRUD.
|
||||||
class ISiteCRUD(ABC):
|
class ISiteCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]:
|
async def get_site_by_place(self, cdb: AsyncSession, place_id) -> Tuple[ErrorType, sites]:
|
||||||
@ -50,8 +45,7 @@ class ISiteCRUD(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
|
async def list_all_sites(self, cdb: AsyncSession, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||||
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용.
|
"""전 계정 사이트 목록(회사 스코프 없음) — 내부 운영(DEVELOPER) 전용."""
|
||||||
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -113,10 +107,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
|
async def get_site_by_domain(self, cdb: AsyncSession, domain: str) -> Tuple[ErrorType, sites]:
|
||||||
"""주소(도메인 라벨)의 주인을 찾는다. 없으면 (SUCCESS, None).
|
"""주소(도메인 라벨)의 주인을 찾는다."""
|
||||||
|
|
||||||
uq_sites_domain(deleted=false AND domain IS NOT NULL)과 같은 조건으로 본다 —
|
|
||||||
인덱스가 막는 것과 조회가 막는 것이 다르면 "확인은 통과, 저장은 실패"가 난다."""
|
|
||||||
try:
|
try:
|
||||||
query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712
|
query = select(sites).where(sites.domain == domain, sites.deleted == False).limit(1) # noqa: E712
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
||||||
@ -128,16 +119,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||||
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진.
|
"""사장님의 사업장 + 사이트 + 마지막 빌드 시각 + 빌더 대표 사진."""
|
||||||
(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).
|
|
||||||
|
|
||||||
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
|
||||||
(위저드만 걸어온 것)도 내려간다 — 빠지면 만들다 만 것을 찾을 길이 없다.
|
|
||||||
|
|
||||||
★ primary_photo_url 은 site_payload.primary_media 와 같은 규칙(사진 중 객실·메뉴가 아닌
|
|
||||||
첫 장, sort_order 순)으로 고른 place_photos.url 이다 — sites.thumbnail_url 이 비어 있을 때
|
|
||||||
(Azure 썸네일 저장소 미설정 등으로 재호스팅에 실패한 경우) 서비스 계층이 이걸로 대신 채운다.
|
|
||||||
여기서는 후보만 얹고, "발행한 적 있는 줄에만 쓴다"는 판단은 서비스 계층 몫이다."""
|
|
||||||
try:
|
try:
|
||||||
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
|
where = and_(places.deleted == False, places.owner_user_id == owner_user_id) # noqa: E712
|
||||||
|
|
||||||
@ -164,8 +146,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, [], 0
|
return ErrorType.DB_RUN_FAILED, [], 0
|
||||||
|
|
||||||
async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
async def list_all_sites(self, cdb: AsyncSession, skip: int, limit: int) -> Tuple[ErrorType, list, int]:
|
||||||
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다.
|
"""list_owner_sites 와 같은 조인이되 owner_user_id 필터가 없다 — 소유자 계정 정보를 같이 얹는다."""
|
||||||
(ErrorType, [(place, site, built_at, primary_photo_url, owner_login_id, owner_email, owner_name)], 총건수)."""
|
|
||||||
try:
|
try:
|
||||||
where = places.deleted == False # noqa: E712
|
where = places.deleted == False # noqa: E712
|
||||||
|
|
||||||
@ -193,7 +174,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, [], 0
|
return ErrorType.DB_RUN_FAILED, [], 0
|
||||||
|
|
||||||
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
async def taken_domains(self, cdb: AsyncSession, domains: list) -> Tuple[ErrorType, set]:
|
||||||
"""후보 주소들 중 이미 쓰이는 것만 추린다. 대안 제안이 후보마다 왕복하지 않게 한 번에 본다."""
|
"""후보 주소들 중 이미 쓰이는 것만 추린다."""
|
||||||
try:
|
try:
|
||||||
if not domains:
|
if not domains:
|
||||||
return ErrorType.SUCCESS, set()
|
return ErrorType.SUCCESS, set()
|
||||||
@ -216,7 +197,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED
|
return ErrorType.DB_RUN_FAILED
|
||||||
|
|
||||||
async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]:
|
async def next_version_no(self, cdb: AsyncSession, site_id) -> Tuple[ErrorType, int]:
|
||||||
"""다음 버전 번호. 1부터 시작한다."""
|
"""다음 버전 번호."""
|
||||||
try:
|
try:
|
||||||
query = select(func.max(site_versions.version)).where(
|
query = select(func.max(site_versions.version)).where(
|
||||||
site_versions.site_id == site_id, site_versions.deleted == False # noqa: E712
|
site_versions.site_id == site_id, site_versions.deleted == False # noqa: E712
|
||||||
@ -259,10 +240,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]:
|
async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]:
|
||||||
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다.
|
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다."""
|
||||||
|
|
||||||
★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고,
|
|
||||||
그 번호가 곧 out/versions/<slug>/<version>/ 디렉토리 이름이다(prerender.ts)."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(site_versions)
|
select(site_versions)
|
||||||
@ -330,15 +308,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, []
|
return ErrorType.DB_RUN_FAILED, []
|
||||||
|
|
||||||
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
|
async def list_published(self, cdb: AsyncSession, limit: int = 12) -> Tuple[ErrorType, list]:
|
||||||
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로. 랜딩 쇼케이스가 읽는 목록이다.
|
"""발행된 사이트 + 그 사업장 + 빌더 대표 사진을 최신순으로."""
|
||||||
(ErrorType, [(site, place, primary_photo_url)]).
|
|
||||||
|
|
||||||
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
|
|
||||||
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
|
|
||||||
여기서 열을 골라 두면 나중에 필드를 늘릴 때 공개 여부를 판단할 자리가 사라진다.
|
|
||||||
|
|
||||||
★ primary_photo_url 은 list_owner_sites 와 같은 서브쿼리(_primary_photo_subquery) —
|
|
||||||
sites.thumbnail_url 이 비어 있을 때 showcase_service 가 이걸로 대신 채운다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(sites, places, _primary_photo_subquery())
|
select(sites, places, _primary_photo_subquery())
|
||||||
|
|||||||
@ -1,15 +1,4 @@
|
|||||||
"""site_sections — **개인화 데이터**의 단일 자리.
|
"""site_sections — **개인화 데이터**의 단일 자리."""
|
||||||
|
|
||||||
★ 규칙(2026-09-09)
|
|
||||||
area_* = 공용. 지역 단위, 여러 사이트가 나눠 쓴다. 렌더러 모양 그대로.
|
|
||||||
site_sections = 개인화 싸그리. 사이트마다 달라지는 것 전부 — 거리·숨김·순서·사장님 편집.
|
|
||||||
|
|
||||||
★ 이 표는 이미 있었는데 **아무도 읽지 않았다**(실측 2026-09-09: 10행이 마이그레이션 0003 으로
|
|
||||||
들어간 뒤 방치, 발행 파이프라인은 `sites.theme.sections[].data` 만 봤다). 그 자리를 정본으로
|
|
||||||
세우면서 CRUD 를 붙인다.
|
|
||||||
|
|
||||||
★ 유일성은 `(site_id, section_id)` 다 — 섹션당 한 행. 그래서 upsert 가 갱신을 겸한다.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
@ -29,12 +18,7 @@ class SiteSectionCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def upsert(self, db, values: dict):
|
async def upsert(self, db, values: dict):
|
||||||
"""섹션 하나의 개인화 값을 넣거나 갱신한다.
|
"""섹션 하나의 개인화 값을 넣거나 갱신한다."""
|
||||||
|
|
||||||
★ `uq_site_contents_section (site_id, section_id) WHERE deleted = false` 에 태운다.
|
|
||||||
★ source_type 은 갱신하지 않는다 — 사장님이 손으로 고친 섹션(OWNER)을 수집이
|
|
||||||
API 값으로 되돌리면, 고쳐 둔 것이 다음 수집에 조용히 사라진다.
|
|
||||||
"""
|
|
||||||
stmt = pg_insert(site_sections).values(**values)
|
stmt = pg_insert(site_sections).values(**values)
|
||||||
return await DB_SESSION_MNG.add(
|
return await DB_SESSION_MNG.add(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@ -52,7 +52,7 @@ async def sweep():
|
|||||||
text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now()
|
text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now()
|
||||||
WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""")
|
WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""")
|
||||||
)
|
)
|
||||||
# POSTING은 외부가 받았을 수 있다. 시간을 근거로 APPROVED로 돌리지 않는다.
|
# POSTING은 외부가 받았을 수 있다.
|
||||||
await s.execute(
|
await s.execute(
|
||||||
text("""UPDATE place_social_posts SET status='UNKNOWN',
|
text("""UPDATE place_social_posts SET status='UNKNOWN',
|
||||||
last_error='POST_RESULT_UNKNOWN', updated_at=now()
|
last_error='POST_RESULT_UNKNOWN', updated_at=now()
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from common.utils.gtime import GTime
|
|||||||
|
|
||||||
|
|
||||||
class SongCRUD:
|
class SongCRUD:
|
||||||
"""place_songs 접근. 발행본이 읽는 것은 `latest_ready` 하나뿐이다."""
|
"""place_songs 접근."""
|
||||||
|
|
||||||
async def insert(self, db, row):
|
async def insert(self, db, row):
|
||||||
return await DB_SESSION_MNG.insert(db, row)
|
return await DB_SESSION_MNG.insert(db, row)
|
||||||
@ -21,11 +21,7 @@ class SongCRUD:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def latest_ready(self, db, place_id):
|
async def latest_ready(self, db, place_id):
|
||||||
"""이 업장의 **가장 최근에 완성된** 곡 하나.
|
"""이 업장의 **가장 최근에 완성된** 곡 하나."""
|
||||||
|
|
||||||
★ READY 만 본다. 발행마다 새 곡을 만들므로 GENERATING 행이 함께 있을 수 있는데,
|
|
||||||
그걸 집으면 아직 없는 파일을 사이트가 가리킨다. 실패(FAILED)도 마찬가지다 —
|
|
||||||
새 곡이 실패하면 사이트는 **직전 곡을 그대로 유지**한다(빈 플레이어보다 낫다)."""
|
|
||||||
return await DB_SESSION_MNG.execute(
|
return await DB_SESSION_MNG.execute(
|
||||||
db,
|
db,
|
||||||
select(place_songs)
|
select(place_songs)
|
||||||
|
|||||||
@ -23,8 +23,6 @@ def _place_count_subquery():
|
|||||||
|
|
||||||
|
|
||||||
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
|
# CRUD 는 인터페이스(I*) 와 구현(*) 으로 분리한다.
|
||||||
# - service 는 인터페이스 타입에 의존하고 Depends 로 구현을 주입받는다 (테스트/교체 용이).
|
|
||||||
# - 모든 메서드는 (session, ...) 을 받는다. session 은 람다 호출 시 매니저가 넘겨준다.
|
|
||||||
class IUserCRUD(ABC):
|
class IUserCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
|
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
|
||||||
@ -62,7 +60,7 @@ class IUserCRUD(ABC):
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
|
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
|
||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
"""내부 운영(DEVELOPER) 전용 전체 계정 목록. (ErrorType, [(user, place_count)], 총건수)."""
|
"""내부 운영(DEVELOPER) 전용 전체 계정 목록."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -81,8 +79,7 @@ class UserCRUD(IUserCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]:
|
async def get_user_by_provider_uid(self, cdb: AsyncSession, provider: int, provider_uid: str) -> Tuple[ErrorType, users]:
|
||||||
"""소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다.
|
"""소셜 계정 조회 키는 provider_uid(구글 sub) 다 — 이메일이 아니다."""
|
||||||
구글은 이메일 변경을 허용하고, 이메일로 찾으면 그때 같은 사람에게 계정이 하나 더 생긴다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(users)
|
select(users)
|
||||||
@ -100,8 +97,7 @@ class UserCRUD(IUserCRUD):
|
|||||||
return ErrorType.DB_RUN_FAILED, None
|
return ErrorType.DB_RUN_FAILED, None
|
||||||
|
|
||||||
async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]:
|
async def get_user_by_email(self, cdb: AsyncSession, email: str) -> Tuple[ErrorType, users]:
|
||||||
"""이메일로 1건. "이미 다른 수단으로 가입돼 있다" 판정에만 쓴다.
|
"""이메일로 1건."""
|
||||||
이메일에는 유니크 제약이 없다(옛 데이터) — 여러 건이면 가장 먼저 만들어진 것을 본다."""
|
|
||||||
try:
|
try:
|
||||||
query = (
|
query = (
|
||||||
select(users)
|
select(users)
|
||||||
@ -173,8 +169,7 @@ class UserCRUD(IUserCRUD):
|
|||||||
async def list_users(
|
async def list_users(
|
||||||
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
|
self, cdb: AsyncSession, roles: list, skip: int, limit: int, search: str | None = None
|
||||||
) -> Tuple[ErrorType, list, int]:
|
) -> Tuple[ErrorType, list, int]:
|
||||||
"""role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다
|
"""role 이 roles 안에 있는 계정만 본다 — 개발자 계정은 호출측이 roles 에서 뺀다 (UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
|
||||||
(UserRole 주석: "개발자 계정은 고객사에 존재를 노출하지 않는다" 원칙을 내부 화면에서도 지킨다)."""
|
|
||||||
try:
|
try:
|
||||||
where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712
|
where = and_(users.deleted == False, users.role.in_(roles)) # noqa: E712
|
||||||
if search:
|
if search:
|
||||||
|
|||||||
@ -48,11 +48,6 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="Web4Ai API", lifespan=lifespan)
|
app = FastAPI(title="Web4Ai API", lifespan=lifespan)
|
||||||
|
|
||||||
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
|
# CORS — 관리자 프론트(client_url) + 랜딩(landing_url, 미설정이면 제외).
|
||||||
#
|
|
||||||
# ★ client_url 은 쉼표로 여러 오리진을 받는다. 로컬 개발에서 vite 는 3000 이 막혀 있으면
|
|
||||||
# 3001, 3002… 로 옮겨 뜨는데(--port 는 희망값이지 고정이 아니다), 그때마다 서버 설정을
|
|
||||||
# 고치게 하면 원인이 CORS 라는 걸 알아내는 데만 반나절이 든다. 개발 포트 몇 개를 한 줄에 적어 둔다.
|
|
||||||
# 운영은 실제 도메인 하나만 적으면 된다.
|
|
||||||
def _origins(*values: str) -> list[str]:
|
def _origins(*values: str) -> list[str]:
|
||||||
seen: list[str] = []
|
seen: list[str] = []
|
||||||
for value in values:
|
for value in values:
|
||||||
@ -98,14 +93,7 @@ async def healthz():
|
|||||||
|
|
||||||
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
|
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
|
||||||
async def readyz(response: Response):
|
async def readyz(response: Response):
|
||||||
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
|
"""healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200), 이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다)."""
|
||||||
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
|
|
||||||
|
|
||||||
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
|
|
||||||
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
|
|
||||||
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
|
|
||||||
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
|
|
||||||
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
|
|
||||||
try:
|
try:
|
||||||
async def _ping(s):
|
async def _ping(s):
|
||||||
await s.execute(text("SELECT 1"))
|
await s.execute(text("SELECT 1"))
|
||||||
@ -119,19 +107,18 @@ async def readyz(response: Response):
|
|||||||
return {"ok": False, "db": "down"}
|
return {"ok": False, "db": "down"}
|
||||||
|
|
||||||
|
|
||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다.
|
||||||
app.include_router(router.v1.auth.account.router)
|
app.include_router(router.v1.auth.account.router)
|
||||||
app.include_router(router.v1.place.place.router)
|
app.include_router(router.v1.place.place.router)
|
||||||
app.include_router(router.v1.fact.fact.router)
|
app.include_router(router.v1.fact.fact.router)
|
||||||
app.include_router(router.v1.faq.faq.router)
|
app.include_router(router.v1.faq.faq.router)
|
||||||
app.include_router(router.v1.media.media.router)
|
app.include_router(router.v1.media.media.router)
|
||||||
# ★ 인증 없는 공개 중계. 발행본(정적 페이지)이 캔버스에 사진을 그릴 때 부른다 —
|
# 인증 없는 공개 중계.
|
||||||
# 남의 CDN 이 CORS 를 안 줘서 캔버스가 오염되는 것을 피하는 유일한 길이다(relay.py).
|
|
||||||
app.include_router(router.v1.media.relay.router)
|
app.include_router(router.v1.media.relay.router)
|
||||||
app.include_router(router.v1.job.job.router)
|
app.include_router(router.v1.job.job.router)
|
||||||
app.include_router(router.v1.site.site.router)
|
app.include_router(router.v1.site.site.router)
|
||||||
app.include_router(router.v1.site.site.my_router)
|
app.include_router(router.v1.site.site.my_router)
|
||||||
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
|
# 인증 없는 공개 목록.
|
||||||
app.include_router(router.v1.site.showcase.router)
|
app.include_router(router.v1.site.showcase.router)
|
||||||
app.include_router(router.v1.site.booking_request.router)
|
app.include_router(router.v1.site.booking_request.router)
|
||||||
app.include_router(router.v1.site.post.router)
|
app.include_router(router.v1.site.post.router)
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
"""사장님 에이전트 대화 — 빌더 화면의 입구.
|
"""사장님 에이전트 대화 — 빌더 화면의 입구."""
|
||||||
|
|
||||||
★ 카카오톡 웹훅이 생겨도 이 파일은 안 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저
|
|
||||||
같은 `runtime.chat()` 을 부르는 두 번째 입구가 된다(docs/AGENT.md).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -27,10 +23,7 @@ _STATUS = {
|
|||||||
|
|
||||||
|
|
||||||
class Confirm(BaseModel):
|
class Confirm(BaseModel):
|
||||||
"""직전 답의 확인 버튼이 그대로 돌려보내는 값.
|
"""직전 답의 확인 버튼이 그대로 돌려보내는 값."""
|
||||||
|
|
||||||
★ 서버는 이 값을 믿지 않는다 — 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
|
|
||||||
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다."""
|
|
||||||
|
|
||||||
tool: str = Field(min_length=1, max_length=40)
|
tool: str = Field(min_length=1, max_length=40)
|
||||||
args: dict = {}
|
args: dict = {}
|
||||||
@ -43,7 +36,7 @@ class Req_Chat(BaseModel):
|
|||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||||
"""대화창을 열 수 있는지. 키가 없으면 화면은 자리를 두고 입력만 죽인다."""
|
"""대화창을 열 수 있는지."""
|
||||||
response.headers["Cache-Control"] = "no-store"
|
response.headers["Cache-Control"] = "no-store"
|
||||||
return {"enabled": runtime.is_configured()}
|
return {"enabled": runtime.is_configured()}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
|
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다."""
|
||||||
|
|
||||||
★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은
|
|
||||||
자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면
|
|
||||||
누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@ -26,14 +21,14 @@ def private_response(response: Response):
|
|||||||
|
|
||||||
@router.get("/link")
|
@router.get("/link")
|
||||||
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||||
"""연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다."""
|
"""연결 상태."""
|
||||||
private_response(response)
|
private_response(response)
|
||||||
return await service.state(UUID(user.user_id))
|
return await service.state(UUID(user.user_id))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/link/code")
|
@router.post("/link/code")
|
||||||
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
||||||
"""일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다."""
|
"""일회용 코드를 낸다."""
|
||||||
private_response(response)
|
private_response(response)
|
||||||
try:
|
try:
|
||||||
return await service.issue_code(UUID(user.user_id))
|
return await service.issue_code(UUID(user.user_id))
|
||||||
|
|||||||
@ -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 asyncio
|
||||||
import hmac
|
import hmac
|
||||||
@ -31,10 +12,9 @@ from services.agent import channel
|
|||||||
|
|
||||||
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
||||||
|
|
||||||
# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 —
|
# 콜백이 꺼져 있을 때만 쓰는 상한.
|
||||||
# 침묵보다 "잠시 뒤 다시" 가 낫다.
|
|
||||||
DEADLINE_SEC = 4.5
|
DEADLINE_SEC = 4.5
|
||||||
# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다.
|
# 콜백이 켜져 있을 때의 상한.
|
||||||
CALLBACK_DEADLINE_SEC = 45.0
|
CALLBACK_DEADLINE_SEC = 45.0
|
||||||
|
|
||||||
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
|
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
|
||||||
@ -43,11 +23,10 @@ _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
|
|||||||
|
|
||||||
|
|
||||||
def _reply(text: str, quick_replies=None) -> dict:
|
def _reply(text: str, quick_replies=None) -> dict:
|
||||||
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
|
"""오픈빌더 스킬 응답(SkillResponse)."""
|
||||||
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
|
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
|
||||||
if quick_replies:
|
if quick_replies:
|
||||||
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
|
# 바로가기는 최대 10개.
|
||||||
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
|
|
||||||
payload["quickReplies"] = [
|
payload["quickReplies"] = [
|
||||||
{"label": label, "action": "message", "messageText": label} for label in quick_replies[:10]
|
{"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:
|
def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None:
|
||||||
expected = config.webhook_secret()
|
expected = config.webhook_secret()
|
||||||
if not expected:
|
if not expected:
|
||||||
# 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다.
|
# 설정이 없으면 이 기능은 존재하지 않는다.
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
given = header_secret or secret_in_path or ""
|
given = header_secret or secret_in_path or ""
|
||||||
if not hmac.compare_digest(given, expected):
|
if not hmac.compare_digest(given, expected):
|
||||||
LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절")
|
LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절")
|
||||||
raise HTTPException(404)
|
raise HTTPException(404)
|
||||||
|
|
||||||
# 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다.
|
# 한 겹 더.
|
||||||
bot_id = config.get("KAKAO_BOT_ID")
|
bot_id = config.get("KAKAO_BOT_ID")
|
||||||
if bot_id and (body.get("bot") or {}).get("id") != bot_id:
|
if bot_id and (body.get("bot") or {}).get("id") != bot_id:
|
||||||
LOG.w("[agent/kakao] 다른 봇의 요청 — 거절")
|
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:
|
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
|
||||||
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
"""대화 한 턴을 SkillResponse 로."""
|
||||||
try:
|
try:
|
||||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
||||||
except asyncio.TimeoutError:
|
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:
|
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
|
||||||
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
"""답을 다 만든 뒤 콜백 주소로 보낸다."""
|
||||||
|
|
||||||
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
|
||||||
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
|
||||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
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 ""
|
utterance = request.get("utterance") or ""
|
||||||
speaker = (request.get("user") or {}).get("id") or ""
|
speaker = (request.get("user") or {}).get("id") or ""
|
||||||
if not speaker:
|
if not speaker:
|
||||||
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
|
# 발화자를 모르면 누구의 가게인지도 모른다.
|
||||||
return _reply("사용자를 확인하지 못했어요.")
|
return _reply("사용자를 확인하지 못했어요.")
|
||||||
|
|
||||||
# ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
|
# 콜백이 켜져 있으면 5초 벽을 넘을 수 있다.
|
||||||
callback_url = request.get("callbackUrl")
|
callback_url = request.get("callbackUrl")
|
||||||
# ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 —
|
# "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다.
|
||||||
# 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 콜백 설정이 그 블록에 없어 조용히 동기로 돈다.
|
|
||||||
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
||||||
f"block={(request.get('block') or {}).get('name')!r}")
|
f"block={(request.get('block') or {}).get('name')!r}")
|
||||||
if callback_url:
|
if callback_url:
|
||||||
@ -126,7 +101,7 @@ async def webhook(
|
|||||||
tasks: BackgroundTasks,
|
tasks: BackgroundTasks,
|
||||||
x_agent_secret: str | None = Header(default=None),
|
x_agent_secret: str | None = Header(default=None),
|
||||||
):
|
):
|
||||||
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
|
"""헤더로 시크릿을 받는 쪽."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
_authorize(None, x_agent_secret, body)
|
_authorize(None, x_agent_secret, body)
|
||||||
return await _handle(body, tasks)
|
return await _handle(body, tasks)
|
||||||
@ -139,9 +114,7 @@ async def webhook_with_path_secret(
|
|||||||
tasks: BackgroundTasks,
|
tasks: BackgroundTasks,
|
||||||
x_agent_secret: str | None = Header(default=None),
|
x_agent_secret: str | None = Header(default=None),
|
||||||
):
|
):
|
||||||
"""헤더를 못 넣는 경우의 대안.
|
"""헤더를 못 넣는 경우의 대안."""
|
||||||
|
|
||||||
★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다."""
|
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
_authorize(secret, x_agent_secret, body)
|
_authorize(secret, x_agent_secret, body)
|
||||||
return await _handle(body, tasks)
|
return await _handle(body, tasks)
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from .protocol import Req_GoogleLogin, Req_Login, Req_Signup, Req_UpdateMe, Res_
|
|||||||
|
|
||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
# 라우터(MVC 의 컨트롤러). 요청 검증 -> service 호출 -> RemoveNoneResponse 반환만 담당.
|
# 라우터(MVC 의 컨트롤러).
|
||||||
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/auth", tags=["Auth"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,11 +15,7 @@ class Req_Login(AuthProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_Signup(AuthProtocol):
|
class Req_Signup(AuthProtocol):
|
||||||
"""id/pw 가입. 가입 = 계정 1개다.
|
"""id/pw 가입."""
|
||||||
|
|
||||||
★ 이메일을 필수로 받는 이유: 같은 이메일이 이미 구글로 가입돼 있는지 판단할 근거가 없으면
|
|
||||||
한 사람에게 계정이 둘 생긴다. 지금 이메일 인증 절차는 없다 — 소유 증명이 아니라
|
|
||||||
**중복 판정용** 이다."""
|
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
password: str = ""
|
password: str = ""
|
||||||
@ -28,10 +24,7 @@ class Req_Signup(AuthProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_GoogleLogin(AuthProtocol):
|
class Req_GoogleLogin(AuthProtocol):
|
||||||
"""구글 로그인. 프론트(GIS)가 받은 ID 토큰을 그대로 넘긴다.
|
"""구글 로그인."""
|
||||||
|
|
||||||
필드 이름이 `credential` 인 이유는 GIS 콜백이 주는 이름 그대로이기 때문이다 —
|
|
||||||
`access_token`/`id_token` 으로 바꿔 부르면 우리 토큰과 헷갈린다."""
|
|
||||||
|
|
||||||
credential: str = ""
|
credential: str = ""
|
||||||
|
|
||||||
@ -43,11 +36,11 @@ class Res_Login(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_UpdateMe(AuthProtocol):
|
class Req_UpdateMe(AuthProtocol):
|
||||||
# 본인 정보 수정. role·id 는 받지 않는다(자기 권한 변경 불가).
|
# 본인 정보 수정.
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = None
|
||||||
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
|
password: Optional[str] = None # 비밀번호 변경(옵션).
|
||||||
|
|
||||||
|
|
||||||
class Res_RefreshToken(Res_WebPacketProtocol):
|
class Res_RefreshToken(Res_WebPacketProtocol):
|
||||||
@ -62,6 +55,5 @@ class Res_Me(Res_WebPacketProtocol):
|
|||||||
email: Optional[str] = None
|
email: Optional[str] = None
|
||||||
contact_number: Optional[str] = None
|
contact_number: Optional[str] = None
|
||||||
role: UserRole = UserRole.USER
|
role: UserRole = UserRole.USER
|
||||||
# 이 계정이 무엇으로 로그인하는가. 구글 계정에는 바꿀 비밀번호가 없어서(update_me 가 막는다)
|
# 이 계정이 무엇으로 로그인하는가.
|
||||||
# 내 정보 화면이 붙을 때 이 값으로 갈라야 한다.
|
|
||||||
provider: AuthProvider = AuthProvider.LOCAL
|
provider: AuthProvider = AuthProvider.LOCAL
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from .protocol import (
|
|||||||
Res_CategorySchema, Res_ExtractFacts, Res_Fact, Res_FactList,
|
Res_CategorySchema, Res_ExtractFacts, Res_Fact, Res_FactList,
|
||||||
)
|
)
|
||||||
|
|
||||||
# fact 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다.
|
# fact 라우터.
|
||||||
router = APIRouter(prefix="/v1/place/{place_id}/fact", tags=["Fact"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/place/{place_id}/fact", tags=["Fact"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,9 +13,7 @@ class FactProtocol(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_UpsertFact(FactProtocol):
|
class Req_UpsertFact(FactProtocol):
|
||||||
"""fact 기록. key 는 사업장 업종의 스키마에 있는 것만 허용한다.
|
"""fact 기록."""
|
||||||
|
|
||||||
★ source_type 이 owner 가 아니면 source_url 이 필수다 — 출처 없는 사실은 받지 않는다."""
|
|
||||||
|
|
||||||
key: str = ""
|
key: str = ""
|
||||||
value: Optional[str] = None
|
value: Optional[str] = None
|
||||||
@ -26,14 +24,7 @@ class Req_UpsertFact(FactProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_ExtractFacts(FactProtocol):
|
class Req_ExtractFacts(FactProtocol):
|
||||||
"""사장님이 붙여넣은 원문에서 fact 를 뽑는다.
|
"""사장님이 붙여넣은 원문에서 fact 를 뽑는다."""
|
||||||
|
|
||||||
★ 왜 이 입구가 필요한가 (2026-08-31)
|
|
||||||
TourAPI 에 없고 네이버에도 요금표뿐인 업소가 흔하다(실측: 조이모텔 — 수집 fact 6건이
|
|
||||||
전부 대실·숙박 요금이었다). 그런 업소는 자동 수집만으로는 발행 근거가 영영 안 찬다.
|
|
||||||
폴백 3단계의 2번(사장님이 직접 붙여넣기)이 여기다.
|
|
||||||
|
|
||||||
★ 뽑은 값은 전부 **후보(UNVERIFIED)** 로 들어간다. 사장님이 확인해야 사이트에 나간다."""
|
|
||||||
|
|
||||||
text: str = ""
|
text: str = ""
|
||||||
|
|
||||||
@ -48,20 +39,17 @@ class Res_ExtractedFact(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Res_ExtractFacts(Res_WebPacketProtocol):
|
class Res_ExtractFacts(Res_WebPacketProtocol):
|
||||||
"""뽑힌 것과 버려진 것을 **둘 다** 돌려준다.
|
"""뽑힌 것과 버려진 것을 **둘 다** 돌려준다."""
|
||||||
|
|
||||||
★ 조용히 버리지 않는다 — 사장님이 "내가 쓴 체크인 시간이 왜 안 들어갔지" 를
|
|
||||||
화면에서 바로 확인할 수 있어야 한다."""
|
|
||||||
|
|
||||||
stored: int = 0
|
stored: int = 0
|
||||||
rejected: int = 0
|
rejected: int = 0
|
||||||
facts: list[Res_ExtractedFact] = []
|
facts: list[Res_ExtractedFact] = []
|
||||||
# (버린 항목, 사유). 모델이 지어낸 값·스키마 밖 key 가 여기로 온다.
|
# (버린 항목, 사유).
|
||||||
rejections: list[list[str]] = []
|
rejections: list[list[str]] = []
|
||||||
|
|
||||||
|
|
||||||
class Req_TransitionFact(FactProtocol):
|
class Req_TransitionFact(FactProtocol):
|
||||||
"""검증 상태 전이. 허용 전이는 FACT_STATUS_TRANSITIONS 가 유일한 소스다."""
|
"""검증 상태 전이."""
|
||||||
|
|
||||||
status: FactStatus = FactStatus.VERIFIED
|
status: FactStatus = FactStatus.VERIFIED
|
||||||
value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시)
|
value: Optional[str] = None # CORRECTED 로 갈 때 고친 값(다른 전이에선 무시)
|
||||||
@ -75,8 +63,7 @@ class FactData(WebPacketProtocol):
|
|||||||
unit_id: Optional[uuid.UUID] = None
|
unit_id: Optional[uuid.UUID] = None
|
||||||
key: str
|
key: str
|
||||||
value: Optional[str] = None
|
value: Optional[str] = None
|
||||||
# ★ 캔버스 미리보기용 축약문. intro/room_intro 원문이 길 때만 채운다 — DB 에는 없다(응답 전용,
|
# 캔버스 미리보기용 축약문.
|
||||||
# FactService._attach_summaries 가 요청마다 계산해 붙인다).
|
|
||||||
summary: Optional[str] = None
|
summary: Optional[str] = None
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
source_type: SourceType
|
source_type: SourceType
|
||||||
@ -96,20 +83,20 @@ class FieldSpecData(WebPacketProtocol):
|
|||||||
type: str
|
type: str
|
||||||
scope: str
|
scope: str
|
||||||
required: bool
|
required: bool
|
||||||
critical: bool # ★ 미검증 노출 금지 대상
|
critical: bool # 미검증 노출 금지 대상
|
||||||
allow_llm: bool
|
allow_llm: bool
|
||||||
unit: Optional[str] = None
|
unit: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Res_FactList(Res_WebPacketProtocol):
|
class Res_FactList(Res_WebPacketProtocol):
|
||||||
facts: list[FactData] = []
|
facts: list[FactData] = []
|
||||||
publishable: int = 0 # ★ 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED)
|
publishable: int = 0 # 사이트에 나갈 수 있는 fact 수(VERIFIED·CORRECTED)
|
||||||
pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지)
|
pending_review: int = 0 # 재수집이 올려놓은 확인 대기 후보 수(관리 화면 배지)
|
||||||
|
|
||||||
|
|
||||||
class Res_Fact(Res_WebPacketProtocol):
|
class Res_Fact(Res_WebPacketProtocol):
|
||||||
fact: Optional[FactData] = None
|
fact: Optional[FactData] = None
|
||||||
# 기록/전이가 무엇을 했는지. PUBLISHED_REPLACED 면 사이트 재빌드 대상이다.
|
# 기록/전이가 무엇을 했는지.
|
||||||
outcome: Optional[FactWriteOutcome] = None
|
outcome: Optional[FactWriteOutcome] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
|||||||
from services.faq_service import FaqService
|
from services.faq_service import FaqService
|
||||||
from .protocol import Req_CreateFaq, Req_TransitionFaq, Res_Faq, Res_FaqList
|
from .protocol import Req_CreateFaq, Req_TransitionFaq, Res_Faq, Res_FaqList
|
||||||
|
|
||||||
# FAQ 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다.
|
# FAQ 라우터.
|
||||||
router = APIRouter(prefix="/v1/place/{place_id}/faq", tags=["Faq"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/place/{place_id}/faq", tags=["Faq"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -13,10 +13,7 @@ class FaqProtocol(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_CreateFaq(FaqProtocol):
|
class Req_CreateFaq(FaqProtocol):
|
||||||
"""사장님이 직접 쓴 FAQ.
|
"""사장님이 직접 쓴 FAQ."""
|
||||||
|
|
||||||
★ generated_by 를 요청으로 받지 않는다 — 받으면 LLM 생성물을 사람이 쓴 것처럼 올려
|
|
||||||
승인 절차를 통째로 건너뛸 수 있다. 출처는 서버가 OWNER 로 고정한다."""
|
|
||||||
|
|
||||||
question: str = ""
|
question: str = ""
|
||||||
answer: str = ""
|
answer: str = ""
|
||||||
@ -24,9 +21,7 @@ class Req_CreateFaq(FaqProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_TransitionFaq(FaqProtocol):
|
class Req_TransitionFaq(FaqProtocol):
|
||||||
"""검증 상태 전이. 허용 전이는 fact 와 같은 표(FACT_STATUS_TRANSITIONS)가 유일한 소스다.
|
"""검증 상태 전이."""
|
||||||
|
|
||||||
CORRECTED 로 갈 때는 고친 question / answer 중 하나 이상이 필요하다(다른 전이에선 무시)."""
|
|
||||||
|
|
||||||
status: FactStatus = FactStatus.VERIFIED
|
status: FactStatus = FactStatus.VERIFIED
|
||||||
question: Optional[str] = None
|
question: Optional[str] = None
|
||||||
@ -40,8 +35,7 @@ class FaqData(WebPacketProtocol):
|
|||||||
place_id: uuid.UUID
|
place_id: uuid.UUID
|
||||||
question: str
|
question: str
|
||||||
answer: str
|
answer: str
|
||||||
# 근거 목록. 컬럼명은 ids 지만 copy 잡이 담는 값은 fact 의 **key** 다 —
|
# 근거 목록.
|
||||||
# 사람이 승인 화면에서 "무슨 사실로 쓴 문장인지" 읽을 수 있어야 하기 때문이다.
|
|
||||||
source_fact_ids: Optional[list[str]] = None
|
source_fact_ids: Optional[list[str]] = None
|
||||||
generated_by: SourceType
|
generated_by: SourceType
|
||||||
status: FactStatus
|
status: FactStatus
|
||||||
@ -52,11 +46,9 @@ class FaqData(WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_FaqList(Res_WebPacketProtocol):
|
class Res_FaqList(Res_WebPacketProtocol):
|
||||||
faqs: list[FaqData] = []
|
faqs: list[FaqData] = []
|
||||||
# ★ 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED). FAQPage JSON-LD 는 이 건수만큼만 나간다.
|
# 사이트에 나갈 수 있는 건수(VERIFIED·CORRECTED).
|
||||||
publishable: int = 0
|
publishable: int = 0
|
||||||
# 승인 대기 건수 — 관리 화면의 '검토할 것' 배지.
|
# 승인 대기 건수 — 관리 화면의 '검토할 것' 배지.
|
||||||
# fact 와 달리 UNVERIFIED 도 포함한다: FAQ 에는 '재수집 후보' 개념이 없고,
|
|
||||||
# LLM 이 만들어 둔 UNVERIFIED 가 곧 사장님 승인 대기 큐다.
|
|
||||||
pending_review: int = 0
|
pending_review: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
|||||||
from services.job_service import JobService
|
from services.job_service import JobService
|
||||||
from .protocol import Res_Job, Res_JobOps
|
from .protocol import Res_Job, Res_JobOps
|
||||||
|
|
||||||
# 작업 큐 라우터. 수집·비전분석·빌드는 몇 분 걸리므로 클라이언트가 여기를 폴링한다.
|
# 작업 큐 라우터.
|
||||||
router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/job", tags=["Job"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class JobData(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Res_Job(Res_WebPacketProtocol):
|
class Res_Job(Res_WebPacketProtocol):
|
||||||
"""잡 상태 폴링 응답. 수집·빌드는 몇 분 걸리므로 클라이언트가 이 엔드포인트를 폴링한다."""
|
"""잡 상태 폴링 응답."""
|
||||||
|
|
||||||
job: Optional[JobData] = None
|
job: Optional[JobData] = None
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ async def list_place_contents(place_id: uuid.UUID, service: LocalContentService
|
|||||||
|
|
||||||
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
|
@router.post("/place/{place_id}/sync", response_model=ResSyncPlace, summary="업장 주변정보 재수집 (TourAPI 반경)")
|
||||||
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
|
async def sync_place(place_id: uuid.UUID, service: LocalContentService = Depends(), _user=Depends(RequireOwner)):
|
||||||
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다. 무인 갱신(스케줄러)은 아직 없다."""
|
"""빌드가 매번 하는 것과 같은 수집을 운영자가 직접 누른다."""
|
||||||
return RemoveNoneResponse(await service.sync_place_by_id(place_id))
|
return RemoveNoneResponse(await service.sync_place_by_id(place_id))
|
||||||
|
|
||||||
|
|
||||||
@ -50,7 +50,7 @@ async def hide_place_content(
|
|||||||
place_content_id: uuid.UUID, req: ReqHidePlaceContent,
|
place_content_id: uuid.UUID, req: ReqHidePlaceContent,
|
||||||
service: LocalContentService = Depends(), _user=Depends(RequireOwner),
|
service: LocalContentService = Depends(), _user=Depends(RequireOwner),
|
||||||
):
|
):
|
||||||
"""숨긴 항목은 재수집이 되살리지 않는다. 다음 빌드부터 발행본에서 빠진다."""
|
"""숨긴 항목은 재수집이 되살리지 않는다."""
|
||||||
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))
|
return RemoveNoneResponse(await service.set_hidden(place_content_id, req.hidden))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -66,7 +66,7 @@ class ResLocalContentList(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class ResSyncPlace(Res_WebPacketProtocol):
|
class ResSyncPlace(Res_WebPacketProtocol):
|
||||||
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤). changed 는 값이 바뀌었는지."""
|
"""업장 반경 동기화 결과 — 종류별로 **남긴** 건수(반경·기간 필터 뒤)."""
|
||||||
|
|
||||||
festivals: int = 0
|
festivals: int = 0
|
||||||
attractions: int = 0
|
attractions: int = 0
|
||||||
@ -76,15 +76,13 @@ class ResSyncPlace(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class ResLocalGuide(Res_WebPacketProtocol):
|
class ResLocalGuide(Res_WebPacketProtocol):
|
||||||
"""에디터 캔버스가 그리는 지역 가이드. ★ 항목 모양은 발행 payload 의 LocalContents 와 **동일**하다
|
"""에디터 캔버스가 그리는 지역 가이드."""
|
||||||
(services/site_payload._local 을 그대로 거친다) — 캔버스와 발행본이 다른 목록을 보이면 안 된다."""
|
|
||||||
|
|
||||||
attractions: list[dict[str, Any]] = []
|
attractions: list[dict[str, Any]] = []
|
||||||
restaurants: list[dict[str, Any]] = []
|
restaurants: list[dict[str, Any]] = []
|
||||||
festivals: list[dict[str, Any]] = []
|
festivals: list[dict[str, Any]] = []
|
||||||
courses: list[dict[str, Any]] = []
|
courses: list[dict[str, Any]] = []
|
||||||
# ★ 1박2일·2박3일 각 5개(services/itinerary_llm_service). 발행본 payload.local.itineraries 와
|
# 1박2일·2박3일 각 5개(services/itinerary_llm_service).
|
||||||
# **같은 값**이다 — 캔버스가 다른 목록을 보이면 "미리보기와 다르다"가 된다.
|
|
||||||
itineraries: list[dict[str, Any]] = []
|
itineraries: list[dict[str, Any]] = []
|
||||||
synced_at: str | None = None
|
synced_at: str | None = None
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneRespo
|
|||||||
from services.media_service import MediaService
|
from services.media_service import MediaService
|
||||||
from .protocol import Res_MediaList
|
from .protocol import Res_MediaList
|
||||||
|
|
||||||
# 사진 라우터. 사업장(place_id) 하위 리소스이며, 회사 스코프는 service 가 사업장 조회로 강제한다.
|
# 사진 라우터.
|
||||||
router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/place/{place_id}/media", tags=["Media"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -15,12 +15,7 @@ class MediaProtocol(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class MediaData(WebPacketProtocol):
|
class MediaData(WebPacketProtocol):
|
||||||
"""사진 1건.
|
"""사진 1건."""
|
||||||
|
|
||||||
★ source_type 과 origin_url 을 반드시 함께 내려보낸다 — 크롤링 이미지의 재게시 권리가
|
|
||||||
아직 미결이라(docs/DECISIONS.md 1-2), 결론이 '불가'로 나면 발행에서 source_type = CRAWL 을
|
|
||||||
통째로 제외해야 한다. 화면이 출처를 모르면 무엇이 빠질지도 미리 보여줄 수 없다.
|
|
||||||
origin_url 은 그때 '이 사진은 어디서 왔는가'를 증명하는 유일한 근거다."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -28,8 +23,8 @@ class MediaData(WebPacketProtocol):
|
|||||||
place_id: uuid.UUID
|
place_id: uuid.UUID
|
||||||
unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결
|
unit_id: Optional[uuid.UUID] = None # 객실·메뉴 사진이면 연결
|
||||||
url: str # 우리가 보관하는 접근 URL
|
url: str # 우리가 보관하는 접근 URL
|
||||||
origin_url: Optional[str] = None # ★ 수집 원본 이미지 URL — 권리 판단의 근거
|
origin_url: Optional[str] = None # 수집 원본 이미지 URL — 권리 판단의 근거
|
||||||
source_type: SourceType # ★ OWNER 업로드 / CRAWL 수집 — 발행 필터 키
|
source_type: SourceType # OWNER 업로드 / CRAWL 수집 — 발행 필터 키
|
||||||
source_url: Optional[str] = None # 수집한 페이지 URL
|
source_url: Optional[str] = None # 수집한 페이지 URL
|
||||||
label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실")
|
label: Optional[str] = None # Vision 분류 라벨 (예: "A동 침실")
|
||||||
alt_text: Optional[str] = None # Vision 생성 alt
|
alt_text: Optional[str] = None # Vision 생성 alt
|
||||||
@ -40,13 +35,11 @@ class MediaData(WebPacketProtocol):
|
|||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
# DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'.
|
# DB 컬럼이 아니라 계산값이다 — '지금 발행하면 이 사진이 사이트에 실리는가'.
|
||||||
# 판단 기준을 services/snapshot.py 와 똑같이 맞춘다(승인 + alt 있음). 화면이 "왜 이 사진은
|
|
||||||
# 안 나오나"를 사장님에게 설명할 수 있어야 하는데, 그 답이 상태 하나로는 안 나오기 때문이다.
|
|
||||||
publishable: bool = False
|
publishable: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Res_MediaList(Res_WebPacketProtocol):
|
class Res_MediaList(Res_WebPacketProtocol):
|
||||||
media: list[MediaData] = []
|
media: list[MediaData] = []
|
||||||
publishable: int = 0 # ★ 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음)
|
publishable: int = 0 # 실제로 사이트에 나갈 수 있는 사진 수(승인 + alt 있음)
|
||||||
pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지)
|
pending_review: int = 0 # 사람 확인 큐에 남은 사진 수(관리 화면 배지)
|
||||||
crawled: int = 0 # ★ 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수
|
crawled: int = 0 # 재게시 권리 미결(1-2) — 결론이 '불가'면 통째로 빠질 사진 수
|
||||||
|
|||||||
@ -1,21 +1,4 @@
|
|||||||
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.
|
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다."""
|
||||||
|
|
||||||
★ 왜 필요한가 (2026-09-15, 실측)
|
|
||||||
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은
|
|
||||||
`Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼**
|
|
||||||
`toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
|
|
||||||
못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
|
|
||||||
|
|
||||||
★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에
|
|
||||||
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는
|
|
||||||
사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다.
|
|
||||||
|
|
||||||
★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다:
|
|
||||||
· https 만
|
|
||||||
· 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN)
|
|
||||||
· 응답이 이미지가 아니면 거절, 크기 상한
|
|
||||||
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다
|
|
||||||
"""
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
@ -26,8 +9,7 @@ from common.logger import LOG
|
|||||||
|
|
||||||
router = APIRouter(prefix="/v1/image", tags=["Image"])
|
router = APIRouter(prefix="/v1/image", tags=["Image"])
|
||||||
|
|
||||||
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다.
|
# 우리 수집기가 사진을 가져오는 곳.
|
||||||
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
|
|
||||||
_ALLOWED_SUFFIXES = (
|
_ALLOWED_SUFFIXES = (
|
||||||
".pstatic.net",
|
".pstatic.net",
|
||||||
"tong.visitkorea.or.kr",
|
"tong.visitkorea.or.kr",
|
||||||
@ -37,7 +19,7 @@ _ALLOWED_SUFFIXES = (
|
|||||||
|
|
||||||
_MAX_BYTES = 8 * 1024 * 1024
|
_MAX_BYTES = 8 * 1024 * 1024
|
||||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
||||||
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다.
|
# 기본 UA 를 거절하는 CDN 이 있다.
|
||||||
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
|
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
|
||||||
|
|
||||||
|
|
||||||
@ -57,8 +39,7 @@ def _allowed(url: str) -> bool:
|
|||||||
|
|
||||||
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
|
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
|
||||||
async def relay(url: str = Query(min_length=8, max_length=2000)):
|
async def relay(url: str = Query(min_length=8, max_length=2000)):
|
||||||
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
|
"""인증을 요구하지 않는다."""
|
||||||
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다)."""
|
|
||||||
if not _allowed(url):
|
if not _allowed(url):
|
||||||
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
|
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,6 @@ from services.ops_service import OpsService
|
|||||||
from .protocol import Res_OpsSites, Res_OpsUsers
|
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"])
|
router = APIRouter(prefix="/v1/ops", tags=["Ops"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,7 @@ from common.models.gmodel import Res_PageProtocol, WebPacketProtocol
|
|||||||
|
|
||||||
|
|
||||||
class OpsSiteData(WebPacketProtocol):
|
class OpsSiteData(WebPacketProtocol):
|
||||||
"""전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자.
|
"""전 계정 사이트 목록의 한 줄 — 사업장(place) + 사이트(site) + 소유자."""
|
||||||
|
|
||||||
★ MySiteData(내 사이트 목록)와 같은 모양에 소유자 식별자만 얹었다 — 화면이 다를 뿐 값의 뜻은 같다."""
|
|
||||||
|
|
||||||
place_id: uuid.UUID
|
place_id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
@ -34,7 +32,7 @@ class Res_OpsSites(Res_PageProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class OpsUserData(WebPacketProtocol):
|
class OpsUserData(WebPacketProtocol):
|
||||||
"""전 계정 목록의 한 줄. ★ role 은 항상 USER/OWNER 다 — 개발자 계정은 서비스가 걸러낸다."""
|
"""전 계정 목록의 한 줄."""
|
||||||
|
|
||||||
user_id: uuid.UUID
|
user_id: uuid.UUID
|
||||||
login_id: str
|
login_id: str
|
||||||
|
|||||||
@ -31,7 +31,7 @@ from .protocol import (
|
|||||||
Res_UnitList,
|
Res_UnitList,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 사업장 라우터. 모든 조회·변경은 토큰의 사장님(places.owner_user_id)으로 스코프된다.
|
# 사업장 라우터.
|
||||||
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/place", tags=["Place"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
@ -61,8 +61,7 @@ async def search_places_public(
|
|||||||
service: PlaceService = Depends(),
|
service: PlaceService = Depends(),
|
||||||
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
|
q: str = Query(..., min_length=2, max_length=100, description="상호명"),
|
||||||
):
|
):
|
||||||
# ★ 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다. FastAPI 는 등록 순서로 매칭해서,
|
# 이 라우트는 반드시 `/{place_id}` **앞에** 있어야 한다.
|
||||||
# 뒤에 두면 "search" 가 place_id 로 잡혀 422 가 난다 — 조용히 틀리는 종류다.
|
|
||||||
client_ip = request.client.host if request.client else "unknown"
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
return RemoveNoneResponse(await service.search_places_public(q, client_ip))
|
return RemoveNoneResponse(await service.search_places_public(q, client_ip))
|
||||||
|
|
||||||
|
|||||||
@ -15,24 +15,18 @@ class PlaceProtocol(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_CreatePlace(PlaceProtocol):
|
class Req_CreatePlace(PlaceProtocol):
|
||||||
# 상호명 하나로 시작한다. 나머지는 카카오 로컬 검증이 채운다.
|
# 상호명 하나로 시작한다.
|
||||||
name: str = ""
|
name: str = ""
|
||||||
category: PlaceCategory = PlaceCategory.LODGING
|
category: PlaceCategory = PlaceCategory.LODGING
|
||||||
# ★ 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place). 여기로 받으면
|
# 주인은 받지 않는다 — 토큰이 정한다(place_service.create_place).
|
||||||
# 남의 계정을 적어 만들자마자 남의 목록에 넣을 수 있다.
|
|
||||||
|
|
||||||
|
|
||||||
class Req_VerifyPlace(PlaceProtocol):
|
class Req_VerifyPlace(PlaceProtocol):
|
||||||
"""카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정).
|
"""카카오 로컬 조회 결과를 사업장에 박제한다(동일 업소 확정)."""
|
||||||
|
|
||||||
★ 이 단계를 통과해야 수집이 열린다.
|
|
||||||
external_place_id 는 소스에 따라 없을 수 있다 — 네이버는 고유 장소 id 를 주지 않는다.
|
|
||||||
그 경우 상호명 + 도로명주소가 중복 판정 키가 되므로 road_address 를 반드시 채워야 한다."""
|
|
||||||
|
|
||||||
source: ExternalPlaceSource = ExternalPlaceSource.NAVER
|
source: ExternalPlaceSource = ExternalPlaceSource.NAVER
|
||||||
external_place_id: str = ""
|
external_place_id: str = ""
|
||||||
# 외부 장소 DB 가 함께 준 업체 홈페이지 URL. 있으면 공식 홈페이지 채널로 자동 등록한다.
|
# 외부 장소 DB 가 함께 준 업체 홈페이지 URL.
|
||||||
# (네이버 지역검색 응답의 link 가 이것 — Perplexity 로는 잘 안 잡히는 채널이라 여기서 건진다.)
|
|
||||||
place_url: Optional[str] = None
|
place_url: Optional[str] = None
|
||||||
road_address: Optional[str] = None
|
road_address: Optional[str] = None
|
||||||
address: Optional[str] = None
|
address: Optional[str] = None
|
||||||
@ -40,42 +34,24 @@ class Req_VerifyPlace(PlaceProtocol):
|
|||||||
latitude: Optional[Decimal] = None
|
latitude: Optional[Decimal] = None
|
||||||
longitude: Optional[Decimal] = None
|
longitude: Optional[Decimal] = None
|
||||||
region_code: Optional[str] = None
|
region_code: Optional[str] = None
|
||||||
# 외부 장소 DB 의 분류 문자열(후보의 category_name). 주변 맛집에서 같은 업태(경쟁 업소)를 빼는 기준으로 박제한다.
|
# 외부 장소 DB 의 분류 문자열(후보의 category_name).
|
||||||
category_name: Optional[str] = None
|
category_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Req_VerifyPlaceByUrl(PlaceProtocol):
|
class Req_VerifyPlaceByUrl(PlaceProtocol):
|
||||||
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다.
|
"""네이버 플레이스 URL 하나로 동일 업소를 확정한다."""
|
||||||
|
|
||||||
★ 왜 이 경로가 필요한가
|
|
||||||
상호 검색으로 place id 를 자동 해석하는 경로는 실패한다(실측: '롯데호텔 서울').
|
|
||||||
Perplexity 도 네이버 플레이스를 못 찾는다 — 안내 페이지를 물어온 적도 있다.
|
|
||||||
그런데 사장님은 **자기 가게 주소를 이미 알고 있다.** 붙여넣게 하는 것이 가장
|
|
||||||
정확하고 빠르며, 그 붙여넣기 자체가 "이 가게가 맞다"는 사람의 확인이다.
|
|
||||||
|
|
||||||
서버는 그 URL 로 네이버 상세를 읽어 상호·주소·좌표를 가져온다 — 사장님이 손으로
|
|
||||||
옮겨 적게 하지 않는다(오타가 곧 남의 가게가 된다).
|
|
||||||
"""
|
|
||||||
|
|
||||||
url: str = ""
|
url: str = ""
|
||||||
|
|
||||||
# ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
|
# 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
|
||||||
#
|
|
||||||
# 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지
|
|
||||||
# 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 —
|
|
||||||
# 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면
|
|
||||||
# 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다).
|
|
||||||
#
|
|
||||||
# False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는
|
|
||||||
# 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19).
|
|
||||||
reuse_existing: bool = True
|
reuse_existing: bool = True
|
||||||
|
|
||||||
|
|
||||||
class Req_UpdatePlace(PlaceProtocol):
|
class Req_UpdatePlace(PlaceProtocol):
|
||||||
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
|
# 주인은 못 바꾼다(위 Req_CreatePlace 주석).
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
status: Optional[PlaceStatus] = None
|
status: Optional[PlaceStatus] = None
|
||||||
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
|
# 미니 블로그 승인 메일 수신 주소.
|
||||||
notify_email: Optional[str] = None
|
notify_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@ -108,8 +84,8 @@ class PlaceData(WebPacketProtocol):
|
|||||||
longitude: Optional[Decimal] = None
|
longitude: Optional[Decimal] = None
|
||||||
region_code: Optional[str] = None
|
region_code: Optional[str] = None
|
||||||
verified_at: Optional[datetime] = None
|
verified_at: Optional[datetime] = None
|
||||||
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
|
content_updated_at: Optional[datetime] = None # 노출값 변경 시각 — 개별 재빌드 대상 판별
|
||||||
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
|
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소.
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
@ -132,7 +108,7 @@ class LinkData(WebPacketProtocol):
|
|||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
discovered_by: SourceType
|
discovered_by: SourceType
|
||||||
discovered_at: Optional[datetime] = None
|
discovered_at: Optional[datetime] = None
|
||||||
confirmed_at: Optional[datetime] = None # ★ NULL = 크롤링 대상 아님
|
confirmed_at: Optional[datetime] = None # NULL = 크롤링 대상 아님
|
||||||
|
|
||||||
|
|
||||||
class Res_PlaceList(Res_PageProtocol):
|
class Res_PlaceList(Res_PageProtocol):
|
||||||
@ -161,31 +137,29 @@ class Res_Link(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_StartCollect(PlaceProtocol):
|
class Req_StartCollect(PlaceProtocol):
|
||||||
"""수집 시작. 몇 분 걸리므로 동기로 처리하지 않고 잡을 적재한 뒤 즉시 응답한다."""
|
"""수집 시작."""
|
||||||
|
|
||||||
# 확정된 채널 URL 만 크롤링한다. 비우면 이 사업장의 확정 링크 전체.
|
# 확정된 채널 URL 만 크롤링한다.
|
||||||
link_ids: list[uuid.UUID] = []
|
link_ids: list[uuid.UUID] = []
|
||||||
# 이미 확보한 fact 를 다시 긁을지. 기본은 아니오(외부 API 호출 비용을 아낀다).
|
# 이미 확보한 fact 를 다시 긁을지.
|
||||||
force: bool = False
|
force: bool = False
|
||||||
# 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지.
|
# 상호·주소로 공개 채널 URL 을 Perplexity 에서 추가 탐색할지.
|
||||||
# 기본 False — 사용자가 화면에서 명시적으로 선택한 회차에만 유료 검색을 실행한다.
|
|
||||||
discover_channels: bool = False
|
discover_channels: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Res_StartCollect(Res_WebPacketProtocol):
|
class Res_StartCollect(Res_WebPacketProtocol):
|
||||||
"""수집 잡 적재 결과. 클라이언트는 job_id 로 GET /v1/job/{job_id} 를 폴링한다."""
|
"""수집 잡 적재 결과."""
|
||||||
|
|
||||||
job_id: Optional[uuid.UUID] = None
|
job_id: Optional[uuid.UUID] = None
|
||||||
status: Optional[JobStatus] = None
|
status: Optional[JobStatus] = None
|
||||||
# 이미 같은 사업장 수집이 돌고 있어 새로 만들지 않았다면 False (기존 잡의 id 를 돌려준다).
|
|
||||||
created: bool = True
|
created: bool = True
|
||||||
confirmed_links: int = 0
|
confirmed_links: int = 0
|
||||||
|
|
||||||
|
|
||||||
class Req_StartVision(PlaceProtocol):
|
class Req_StartVision(PlaceProtocol):
|
||||||
"""사진 분석 시작. 사진 20~50장이라 몇 분 걸린다 — 잡으로 처리한다."""
|
"""사진 분석 시작."""
|
||||||
|
|
||||||
# 이미 분석된 사진도 다시 태울지. 기본은 아니오(같은 사진 재분석은 요금만 나간다).
|
# 이미 분석된 사진도 다시 태울지.
|
||||||
force: bool = False
|
force: bool = False
|
||||||
|
|
||||||
|
|
||||||
@ -196,9 +170,9 @@ class Res_StartVision(Res_WebPacketProtocol):
|
|||||||
pending_media: int = 0 # 분석 대상 사진 수
|
pending_media: int = 0 # 분석 대상 사진 수
|
||||||
|
|
||||||
|
|
||||||
# ---- 동일 업소 후보 (UI 가 사람에게 고르게 한다) ----------------------------
|
# 동일 업소 후보 (UI 가 사람에게 고르게 한다)
|
||||||
class PlaceCandidate(WebPacketProtocol):
|
class PlaceCandidate(WebPacketProtocol):
|
||||||
"""외부 장소 DB 에서 찾은 후보 1건. UI 가 이걸 카드로 그려 사람이 고른다."""
|
"""외부 장소 DB 에서 찾은 후보 1건."""
|
||||||
|
|
||||||
external_place_id: Optional[str] = None # 네이버는 안 준다
|
external_place_id: Optional[str] = None # 네이버는 안 준다
|
||||||
name: str = ""
|
name: str = ""
|
||||||
@ -209,14 +183,11 @@ class PlaceCandidate(WebPacketProtocol):
|
|||||||
longitude: Optional[Decimal] = None
|
longitude: Optional[Decimal] = None
|
||||||
category_name: Optional[str] = None
|
category_name: Optional[str] = None
|
||||||
place_url: 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):
|
class Res_VerifyCandidates(Res_WebPacketProtocol):
|
||||||
"""동일 업소 후보 목록.
|
"""동일 업소 후보 목록."""
|
||||||
|
|
||||||
★ 자동 판정을 신뢰하지 않는다. outcome 이 MATCHED 여도 후보를 전부 내려보내
|
|
||||||
UI 가 사람에게 확인시킬 수 있게 한다 — 남의 가게가 섞이면 그게 제일 비싼 실수다."""
|
|
||||||
|
|
||||||
source: Optional[ExternalPlaceSource] = None
|
source: Optional[ExternalPlaceSource] = None
|
||||||
outcome: str = "" # matched | ambiguous | no_candidate
|
outcome: str = "" # matched | ambiguous | no_candidate
|
||||||
@ -225,40 +196,28 @@ class Res_VerifyCandidates(Res_WebPacketProtocol):
|
|||||||
candidates: list[PlaceCandidate] = []
|
candidates: list[PlaceCandidate] = []
|
||||||
|
|
||||||
|
|
||||||
# ---- 공개 상호명 검색 (랜딩 첫 화면) ----------------------------------------
|
# 공개 상호명 검색 (랜딩 첫 화면)
|
||||||
class PlaceSearchItem(WebPacketProtocol):
|
class PlaceSearchItem(WebPacketProtocol):
|
||||||
"""공개 검색 결과 1건.
|
"""공개 검색 결과 1건."""
|
||||||
|
|
||||||
★ 외부 장소 DB 가 공개적으로 주는 값만 담는다. 우리 DB 값(place_id·소유자)은
|
|
||||||
하나도 나가지 않는다 — 로그인 없이 열려 있는 응답이라 여기에 우리 것을 실으면 그대로 샌다.
|
|
||||||
★ 좌표·전화번호도 뺐다. 랜딩이 하는 일은 '어느 가게인지 고르게 하는 것'뿐이고,
|
|
||||||
확정과 수집은 로그인 뒤 기존 경로(POST /place → verify)가 그대로 한다."""
|
|
||||||
|
|
||||||
name: str = ""
|
name: str = ""
|
||||||
road_address: Optional[str] = None
|
road_address: Optional[str] = None
|
||||||
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
|
category_name: Optional[str] = None # 외부 DB 의 분류 문자열(예: "숙박>펜션")
|
||||||
# 추정 업종. ★ None 이면 못 정한 것이다 — 화면이 사장님에게 직접 고르게 한다.
|
# 추정 업종.
|
||||||
# 값이 있어도 확정이 아니다. 화면은 언제나 바꿀 수 있게 둔다(경계 업종이 실제로 있다).
|
|
||||||
category: Optional[PlaceCategory] = None
|
category: Optional[PlaceCategory] = None
|
||||||
# 자동으로 찾은 네이버 플레이스 주소. ★ 공개 페이지에서 읽은 값이라 우리 DB 것이 아니다.
|
# 자동으로 찾은 네이버 플레이스 주소.
|
||||||
# 못 찾으면 None — 화면이 그때만 사장님에게 지도 주소를 묻는다.
|
|
||||||
naver_place_url: Optional[str] = None
|
naver_place_url: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Res_PlaceSearch(Res_WebPacketProtocol):
|
class Res_PlaceSearch(Res_WebPacketProtocol):
|
||||||
"""상호명 공개 검색 결과.
|
"""상호명 공개 검색 결과."""
|
||||||
|
|
||||||
★ 인증이 없다. 랜딩 첫 화면에서 상호명을 치면 바로 부른다 —
|
|
||||||
만들어 보기도 전에 로그인을 요구하지 않기로 한 결정(로그인 관문은 에디터 진입 하나)의 연장이다."""
|
|
||||||
|
|
||||||
source: Optional[ExternalPlaceSource] = None
|
source: Optional[ExternalPlaceSource] = None
|
||||||
items: list[PlaceSearchItem] = []
|
items: list[PlaceSearchItem] = []
|
||||||
|
|
||||||
|
|
||||||
class Req_StartCopy(PlaceProtocol):
|
class Req_StartCopy(PlaceProtocol):
|
||||||
"""소개문·FAQ 생성 시작.
|
"""소개문·FAQ 생성 시작."""
|
||||||
|
|
||||||
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
|
|
||||||
|
|
||||||
resume: bool = False
|
resume: bool = False
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,4 @@
|
|||||||
"""발행본의 예약 요청 폼 → 사장님 메일.
|
"""발행본의 예약 요청 폼 → 사장님 메일."""
|
||||||
|
|
||||||
★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
|
|
||||||
★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고,
|
|
||||||
보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다.
|
|
||||||
★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은
|
|
||||||
**연락 요청**이고, 화면도 그렇게 말한다.
|
|
||||||
"""
|
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
@ -19,11 +12,11 @@ from services.booking_request_service import BookingRequestService
|
|||||||
|
|
||||||
router = APIRouter(prefix="/v1/site", tags=["Site"])
|
router = APIRouter(prefix="/v1/site", tags=["Site"])
|
||||||
|
|
||||||
# 한 아이피가 한 시간에 보낼 수 있는 통수. 같은 업장으로 몰리는 것도 따로 센다.
|
# 한 아이피가 한 시간에 보낼 수 있는 통수.
|
||||||
IP_LIMIT_PER_HOUR = 5
|
IP_LIMIT_PER_HOUR = 5
|
||||||
PLACE_LIMIT_PER_HOUR = 30
|
PLACE_LIMIT_PER_HOUR = 30
|
||||||
WINDOW_SEC = 3600
|
WINDOW_SEC = 3600
|
||||||
# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. 봇은 즉시 제출한다.
|
# 폼을 연 뒤 이만큼은 지나야 사람으로 친다.
|
||||||
MIN_ELAPSED_MS = 1500
|
MIN_ELAPSED_MS = 1500
|
||||||
|
|
||||||
_hits: dict[str, deque] = defaultdict(deque)
|
_hits: dict[str, deque] = defaultdict(deque)
|
||||||
@ -49,7 +42,7 @@ class ReqBookingRequest(BaseModel):
|
|||||||
guests: str | None = Field(default=None, max_length=30)
|
guests: str | None = Field(default=None, max_length=30)
|
||||||
message: str | None = Field(default=None, max_length=1000)
|
message: str | None = Field(default=None, max_length=1000)
|
||||||
consent: bool
|
consent: bool
|
||||||
# 봇 잡이. 사람에게는 안 보이는 칸이라 값이 있으면 사람이 아니다.
|
# 봇 잡이.
|
||||||
company: str | None = Field(default=None, max_length=100)
|
company: str | None = Field(default=None, max_length=100)
|
||||||
elapsed_ms: int = 0
|
elapsed_ms: int = 0
|
||||||
|
|
||||||
@ -72,7 +65,7 @@ async def send_booking_request(
|
|||||||
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
||||||
or (request.client.host if request.client else "unknown"))
|
or (request.client.host if request.client else "unknown"))
|
||||||
|
|
||||||
# 봇 두 겹. 걸려도 실패로 알리지 않는다 — 무엇에 걸렸는지 알려 주면 다음 시도가 그걸 피한다.
|
# 봇 두 겹.
|
||||||
if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
|
if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
|
||||||
LOG.w("[booking-request] 봇 의심 요청을 버렸다")
|
LOG.w("[booking-request] 봇 의심 요청을 버렸다")
|
||||||
return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다."))
|
return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다."))
|
||||||
|
|||||||
@ -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
|
import html
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from uuid import UUID
|
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:
|
def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse:
|
||||||
# ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 +
|
# redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 + slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라 escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
|
||||||
# slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라
|
|
||||||
# escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
|
|
||||||
redirect = (
|
redirect = (
|
||||||
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
|
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
|
||||||
if redirect_url else ""
|
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"]:
|
if not result["success"]:
|
||||||
return _expired_page()
|
return _expired_page()
|
||||||
redirect_url = result.get("redirect_url")
|
redirect_url = result.get("redirect_url")
|
||||||
# ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
|
# 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
|
||||||
# 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다
|
|
||||||
# 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다.
|
|
||||||
extra = (
|
extra = (
|
||||||
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
|
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
|
||||||
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"
|
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"
|
||||||
|
|||||||
@ -105,7 +105,7 @@ class PublishLogData(WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Req_SiteTemplate(SiteProtocol):
|
class Req_SiteTemplate(SiteProtocol):
|
||||||
"""템플릿 선택 저장. 업종 허용 목록(solution/shared/src/data/templates.json)에 없는 id는 거절한다."""
|
"""템플릿 선택 저장."""
|
||||||
|
|
||||||
template_id: str = ""
|
template_id: str = ""
|
||||||
|
|
||||||
@ -171,7 +171,7 @@ class Req_SiteSlug(SiteProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Res_SlugCheck(Res_WebPacketProtocol):
|
class Res_SlugCheck(Res_WebPacketProtocol):
|
||||||
"""주소 사용 가능 확인. UI 가 타이핑 중에 호출한다."""
|
"""주소 사용 가능 확인."""
|
||||||
|
|
||||||
available: bool = False
|
available: bool = False
|
||||||
# 불가 사유(services/site_slug 의 REASON_*): INVALID_LENGTH / INVALID_FORMAT / RESERVED / TAKEN.
|
# 불가 사유(services/site_slug 의 REASON_*): INVALID_LENGTH / INVALID_FORMAT / RESERVED / TAKEN.
|
||||||
@ -181,7 +181,7 @@ class Res_SlugCheck(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
|
|
||||||
class Res_SiteSlug(Res_WebPacketProtocol):
|
class Res_SiteSlug(Res_WebPacketProtocol):
|
||||||
"""주소 저장 결과. 거부됐으면 왜/대안을 check 와 같은 코드로 돌려준다."""
|
"""주소 저장 결과."""
|
||||||
|
|
||||||
site: Optional[SiteData] = None
|
site: Optional[SiteData] = None
|
||||||
reason: Optional[str] = None
|
reason: Optional[str] = None
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
"""이용 후기 접수 — 발행본에서 손님이 남긴다.
|
"""이용 후기 접수 — 발행본에서 손님이 남긴다."""
|
||||||
|
|
||||||
★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 —
|
|
||||||
허니팟 · 최소 체류시간 · 레이트리밋.
|
|
||||||
★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다.
|
|
||||||
"""
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
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="게재된 후기 — 발행본이 붙은 뒤 받아 간다")
|
@router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다")
|
||||||
async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()):
|
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))
|
return RemoveNoneResponse(await service.list_public(place_id))
|
||||||
|
|||||||
@ -4,8 +4,7 @@ from router.v1.validator.dependencies import RemoveNoneResponse
|
|||||||
from services.showcase_service import ShowcaseService
|
from services.showcase_service import ShowcaseService
|
||||||
from .protocol import Res_Showcase
|
from .protocol import Res_Showcase
|
||||||
|
|
||||||
# 발행 사이트 쇼케이스. ★ 인증이 없다 — 랜딩(비로그인)이 부른다.
|
# 발행 사이트 쇼케이스.
|
||||||
# ★ :9801(어드민 진입점)에는 마운트하지 않는다. 내부 화면이 쓸 목록이 아니다.
|
|
||||||
router = APIRouter(prefix="/v1/showcase", tags=["Showcase"], responses={404: {"description": "Not found"}})
|
router = APIRouter(prefix="/v1/showcase", tags=["Showcase"], responses={404: {"description": "Not found"}})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -43,11 +43,7 @@ async def oauth_callback(
|
|||||||
await service.finish(state, request.cookies.get(COOKIE), code)
|
await service.finish(state, request.cookies.get(COOKIE), code)
|
||||||
ok = True
|
ok = True
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
# ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
|
# 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
|
||||||
# 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때
|
|
||||||
# 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다
|
|
||||||
# (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다).
|
|
||||||
# ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다.
|
|
||||||
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
|
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
|
||||||
elif error:
|
elif error:
|
||||||
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.
|
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.
|
||||||
|
|||||||
@ -24,11 +24,7 @@ from config.server_configs import jwt_token_config
|
|||||||
security = HTTPBearer()
|
security = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
# ---- 비밀번호 해시 (bcrypt) ------------------------------------------------
|
# 비밀번호 해시 (bcrypt)
|
||||||
# bcrypt 는 CPU 바운드 동기 작업이라 그대로 호출하면 asyncio 이벤트 루프를 막아
|
|
||||||
# 같은 워커의 다른 요청(healthz 등)까지 멈춘다. 스레드풀(asyncio.to_thread)로 보낸다.
|
|
||||||
# bcrypt 는 해싱 중 GIL 을 해제하므로 스레드들이 여러 코어에서 실제 병렬로 돈다.
|
|
||||||
# 입력은 최대 72 bytes 까지만 사용하므로 사전에 잘라준다.
|
|
||||||
def _hash_pw(pw: str) -> str:
|
def _hash_pw(pw: str) -> str:
|
||||||
return bcrypt.hashpw(pw.encode("utf-8")[:72], bcrypt.gensalt()).decode("utf-8")
|
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)
|
return await asyncio.to_thread(_verify_pw, pw, hashed_pw)
|
||||||
|
|
||||||
|
|
||||||
# ---- JWT 토큰 발급/검증 ----------------------------------------------------
|
# JWT 토큰 발급/검증
|
||||||
JWT_ALGORITHM = "HS256"
|
JWT_ALGORITHM = "HS256"
|
||||||
JWT_ACCESS_SECRET = jwt_token_config.access_key
|
JWT_ACCESS_SECRET = jwt_token_config.access_key
|
||||||
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
|
JWT_REFRESH_SECRET = jwt_token_config.refresh_key
|
||||||
@ -73,11 +69,7 @@ def CreateRefreshToken(subject: UserInfo) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def CreateDayPassToken(subject: UserInfo) -> str:
|
def CreateDayPassToken(subject: UserInfo) -> str:
|
||||||
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.
|
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용."""
|
||||||
|
|
||||||
★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만
|
|
||||||
쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로
|
|
||||||
되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다."""
|
|
||||||
now_kst = datetime.now(timezone(timedelta(hours=9)))
|
now_kst = datetime.now(timezone(timedelta(hours=9)))
|
||||||
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
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))
|
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)
|
return __decode_token(jwt_token, JWT_REFRESH_SECRET, EXCEPTION_REFRESH_TOKEN_EXPIRED)
|
||||||
|
|
||||||
|
|
||||||
# ---- Depends 용 토큰 검증기 ------------------------------------------------
|
# Depends 용 토큰 검증기
|
||||||
# 보호된 엔드포인트에서 dependencies=[Depends(IsValidAccessToken)] 로 사용.
|
|
||||||
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
async def IsValidAccessToken(credentials: HTTPAuthorizationCredentials = Depends(security)) -> UserInfo:
|
||||||
return DecodeAccessToken(credentials.credentials)
|
return DecodeAccessToken(credentials.credentials)
|
||||||
|
|
||||||
@ -113,24 +104,21 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
|
|||||||
return DecodeRefreshToken(credentials.credentials)
|
return DecodeRefreshToken(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
# 최고관리자 이상(OWNER/DEVELOPER) 게이트. 회원관리·회사설정에 건다.
|
# 최고관리자 이상(OWNER/DEVELOPER) 게이트.
|
||||||
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
|
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
|
||||||
if (user_info.role or 0) < UserRole.OWNER.value:
|
if (user_info.role or 0) < UserRole.OWNER.value:
|
||||||
raise EXCEPTION_FORBIDDEN
|
raise EXCEPTION_FORBIDDEN
|
||||||
return user_info
|
return user_info
|
||||||
|
|
||||||
|
|
||||||
# 개발자(내부 운영) 전용 게이트. 회사 스코프를 넘어 전 고객사 데이터를 보는 /v1/admin 에만 건다.
|
# 개발자(내부 운영) 전용 게이트.
|
||||||
# OWNER 는 자기 회사 최상위일 뿐 남의 회사를 볼 권한이 아니라서 여기선 막힌다.
|
|
||||||
async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
|
async def RequireDeveloper(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
|
||||||
if (user_info.role or 0) < UserRole.DEVELOPER.value:
|
if (user_info.role or 0) < UserRole.DEVELOPER.value:
|
||||||
raise EXCEPTION_FORBIDDEN
|
raise EXCEPTION_FORBIDDEN
|
||||||
return user_info
|
return user_info
|
||||||
|
|
||||||
|
|
||||||
# ---- ResponseNone 처리 -----------------------------------------------------
|
# ResponseNone 처리
|
||||||
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
|
|
||||||
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.
|
|
||||||
def RemoveNoneValues(obj: Any) -> Any:
|
def RemoveNoneValues(obj: Any) -> Any:
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {k: RemoveNoneValues(v) for k, v in obj.items() if v is not None}
|
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:
|
def RemoveNoneResponse(obj) -> JSONResponse:
|
||||||
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서
|
# mode="json": uuid/datetime 등 DB 네이티브 타입(asyncpg.UUID 포함)을 pydantic 단에서 JSON 안전한 문자열로 변환한다.
|
||||||
# JSON 안전한 문자열로 변환한다. content 가 이미 JSON-safe dict 이므로 표준 JSONResponse 사용
|
|
||||||
# (ORJSONResponse 는 최신 FastAPI 에서 deprecated).
|
|
||||||
return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))
|
return JSONResponse(content=RemoveNoneValues(obj.model_dump(mode="json")))
|
||||||
|
|||||||
@ -1,22 +1,4 @@
|
|||||||
"""백그라운드 스케줄러(크론) 패키지 — '언제'(when) 담당.
|
"""백그라운드 스케줄러(크론) 패키지 — '언제'(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 만 개별 재빌드 (전체 재빌드 금지)
|
|
||||||
"""
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||||
@ -33,7 +15,7 @@ def _is_enabled() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def start_scheduler():
|
def start_scheduler():
|
||||||
"""lifespan startup 에서 호출. SCHEDULER_ENABLED=1 일 때만 스케줄러를 띄운다."""
|
"""lifespan startup 에서 호출."""
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if not _is_enabled():
|
if not _is_enabled():
|
||||||
LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)")
|
LOG.i("[scheduler] disabled (SCHEDULER_ENABLED != 1)")
|
||||||
@ -41,12 +23,9 @@ def start_scheduler():
|
|||||||
if _scheduler is not None:
|
if _scheduler is not None:
|
||||||
return
|
return
|
||||||
|
|
||||||
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
|
# 한국시간 기준.
|
||||||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||||
# ★ 1분이 아니라 5분이다. 이 스윕이 하는 일은 "만료 표시" 와 "중단된 초안 정리" 뿐이라
|
# 1분이 아니라 5분이다.
|
||||||
# 분 단위 정밀도가 필요 없고, 주기가 짧으면 쓰기 커넥션을 계속 집어 든다 —
|
|
||||||
# 실측(2026-09-14): 1분 주기로 두자 같은 컨테이너에서 도는 테스트가 커넥션을 못 받아
|
|
||||||
# TimeoutError 로 무더기 실패했다. 운영에서도 같은 풀을 발행·수집과 나눠 쓴다.
|
|
||||||
from scheduler.jobs import sweep_social_posts
|
from scheduler.jobs import sweep_social_posts
|
||||||
_scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True)
|
_scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True)
|
||||||
if os.environ.get("GSC_ENABLED") == "1":
|
if os.environ.get("GSC_ENABLED") == "1":
|
||||||
@ -60,11 +39,6 @@ def start_scheduler():
|
|||||||
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
|
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
|
||||||
id="queue-health", max_instances=1, coalesce=True)
|
id="queue-health", max_instances=1, coalesce=True)
|
||||||
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
|
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(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,
|
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
|
||||||
id="blog-mail", max_instances=1, coalesce=True)
|
id="blog-mail", max_instances=1, coalesce=True)
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
|
|||||||
@ -1,8 +1,4 @@
|
|||||||
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
|
"""스케줄 잡 로직(what)."""
|
||||||
|
|
||||||
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다.
|
|
||||||
(지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.)
|
|
||||||
"""
|
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
"""예약 실행 진입점. 복구 전이는 DB 조건부 UPDATE로 여러 프로세스에서도 안전하다."""
|
"""예약 실행 진입점. 복구 전이는 DB 조건부 UPDATE로 여러 프로세스에서도 안전하다."""
|
||||||
@ -22,12 +18,7 @@ async def sweep_alert_outbox():
|
|||||||
|
|
||||||
|
|
||||||
async def sweep_queue_health():
|
async def sweep_queue_health():
|
||||||
"""잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING.
|
"""잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING."""
|
||||||
|
|
||||||
★ 왜 필요한가: 개별 잡의 DEAD 전이는 worker/runner.py 가 그 자리에서 바로 알린다. 이건
|
|
||||||
그것과 다른 신호다 — 잡 하나하나는 재시도 중(아직 DEAD 아님)인데 **큐 전체가 정체**된
|
|
||||||
경우(워커 프로세스가 죽었거나 DB 순단이 길어지는 경우)는 개별 잡 알림만으로는 안 보인다.
|
|
||||||
★ 복구되면 한 번만 알린다 — send_alert/resolve_alert 의 dedupe_key 가 그 판단을 한다."""
|
|
||||||
from crud.job_crud import JobQueue
|
from crud.job_crud import JobQueue
|
||||||
from services import alert_service
|
from services import alert_service
|
||||||
|
|
||||||
@ -37,8 +28,7 @@ async def sweep_queue_health():
|
|||||||
LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}")
|
LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}")
|
||||||
return
|
return
|
||||||
|
|
||||||
# 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이
|
# 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다).
|
||||||
# 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다).
|
|
||||||
problems = []
|
problems = []
|
||||||
if snap.get("dead_1h", 0) > 0:
|
if snap.get("dead_1h", 0) > 0:
|
||||||
problems.append(f"최근 1시간 dead-letter {snap['dead_1h']}건")
|
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
Loading…
Reference in New Issue
Block a user