Compare commits
No commits in common. "main" and "feature/crawler" have entirely different histories.
main
...
feature/cr
107
.env.example
107
.env.example
@ -33,7 +33,7 @@ NAVER_CLIENT_ID=
|
|||||||
NAVER_CLIENT_SECRET=
|
NAVER_CLIENT_SECRET=
|
||||||
# 미발급. 없으면 네이버 지역검색을 쓴다
|
# 미발급. 없으면 네이버 지역검색을 쓴다
|
||||||
KAKAO_REST_API_KEY=
|
KAKAO_REST_API_KEY=
|
||||||
OPENAI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
|
||||||
TOUR_API_KEY=
|
TOUR_API_KEY=
|
||||||
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
|
||||||
@ -48,56 +48,6 @@ SUNO_CALLBACK_URL=https://example.com/api/suno/callback
|
|||||||
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
|
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
|
||||||
SITE_ONTOLOGY_URL=
|
SITE_ONTOLOGY_URL=
|
||||||
|
|
||||||
# 프리렌더가 절대 굽지 않는 슬러그(쉼표 구분). 손으로 만든 목업(/s/stay·stay2·stay3·stay4·stay5)
|
|
||||||
# 이름과 같은 슬러그로 실제 발행이 생기면 그 payload 로 목업을 덮어 구워버린다 — 비우지 않는다.
|
|
||||||
PRERENDER_PROTECTED_SLUGS=stay,stay2,stay3,stay4,stay5
|
|
||||||
|
|
||||||
# ── SNS 게재(스레드) ────────────────────────────────────────────────
|
|
||||||
# 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로 짧은 글을 쓰고, 승인을 받아
|
|
||||||
# **사장님 개인 계정**으로 올린다. 비우면 그 기능만 꺼진다(서버는 뜬다).
|
|
||||||
#
|
|
||||||
# ★ SOCIAL_TOKEN_SECRET 이 없으면 계정 연결 자체를 막는다 — 위임받은 토큰을
|
|
||||||
# 평문으로 보관하는 길을 열지 않는다. 우리 API 키와 성격이 다르다:
|
|
||||||
# API 키는 우리 돈이 나가고, 이 토큰은 **사장님 이름으로 글이 나간다.**
|
|
||||||
SOCIAL_TOKEN_SECRET=
|
|
||||||
# ★ 실제 게시는 이 값이 '1' 일 때만 열린다. 플랫폼 계약과 해지 안내 페이지 정책
|
|
||||||
# (DECISIONS 1-4)을 확인하기 전에는 초안·승인까지만 돌린다 — 게시는 되돌릴 수 없다.
|
|
||||||
SOCIAL_POSTING_ENABLED=0
|
|
||||||
# 승인 요청의 수명. 지나면 EXPIRED 로 내려가고 화면에 '만료됨 · 다시 보내기' 로 남는다.
|
|
||||||
SOCIAL_APPROVAL_HOURS=24
|
|
||||||
# 승인 화면이 열리는 주소(빌더 SPA). 알림톡 버튼이 이 주소로 간다.
|
|
||||||
SOCIAL_APP_ORIGIN=
|
|
||||||
THREADS_APP_ID=
|
|
||||||
THREADS_APP_SECRET=
|
|
||||||
THREADS_REDIRECT_URI=
|
|
||||||
# 알림톡(대행사). 비면 발송을 건너뛰고 빌더 화면 승인만 쓴다 — 기능은 그대로 돈다.
|
|
||||||
# ★ 템플릿 코드는 심사 대상이라 env 로 둔다. 반려로 코드가 바뀌면 배포 없이 고쳐야 한다.
|
|
||||||
ALIMTALK_API_KEY=
|
|
||||||
ALIMTALK_API_SECRET=
|
|
||||||
ALIMTALK_PROFILE_ID=
|
|
||||||
ALIMTALK_SENDER=
|
|
||||||
ALIMTALK_TEMPLATE_CODE=
|
|
||||||
|
|
||||||
# ── 사장님 에이전트 · 카카오톡 채널 연결 ──────────────────────────────
|
|
||||||
# 사장님이 카톡으로 사이트를 고치려면, 채널 발화자(채널 단위 익명 키)를 우리 계정에
|
|
||||||
# 묶어야 한다. 빌더에서 코드를 받아 채널에 한 번 입력하는 절차다.
|
|
||||||
# ★ 이 값이 비면 연결 화면이 아예 안 뜬다 — 어디에 코드를 칠지 말해 줄 수 없는데
|
|
||||||
# 코드만 발급하면 사장님에게는 고장난 화면이다.
|
|
||||||
# 사장님 대화창(에이전트). 1=사용, 0=감춤.
|
|
||||||
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
|
|
||||||
# "눌러도 안 되는 입구" 가 생기지 않는다.
|
|
||||||
AGENT_CHAT_ENABLED=1
|
|
||||||
# 카카오톡 채널 웹훅(오픈빌더 스킬 서버). ★ 오픈빌더는 서명을 주지 않는다 —
|
|
||||||
# URL 만 알면 누구나 때릴 수 있고 발화자 id 를 위조하면 그 사장님 행세를 한다.
|
|
||||||
# 비우면 웹훅 엔드포인트가 404 다(반쯤 열린 상태를 만들지 않는다).
|
|
||||||
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
KAKAO_WEBHOOK_SECRET=
|
|
||||||
# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다.
|
|
||||||
KAKAO_BOT_ID=
|
|
||||||
KAKAO_CHANNEL_PUBLIC_ID=
|
|
||||||
KAKAO_LINK_CODE_TTL_MIN=10
|
|
||||||
KAKAO_LINK_MAX_ATTEMPTS=5
|
|
||||||
|
|
||||||
|
|
||||||
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
# 구글 로그인. 비우면 구글 로그인만 꺼진다(서버는 뜨고, 화면에 버튼도 안 뜬다).
|
||||||
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
# Google Cloud Console > API 및 서비스 > 사용자 인증 정보 > OAuth 2.0 클라이언트 ID(웹 애플리케이션)
|
||||||
@ -129,22 +79,6 @@ PUBLIC_WEB_BASE_URL=http://localhost
|
|||||||
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai
|
||||||
# 비우면 색인 통보를 건너뛴다(발행은 정상)
|
# 비우면 색인 통보를 건너뛴다(발행은 정상)
|
||||||
INDEXNOW_KEY=
|
INDEXNOW_KEY=
|
||||||
|
|
||||||
# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md).
|
|
||||||
GSC_ENABLED=0
|
|
||||||
GSC_PROPERTY_URL=
|
|
||||||
GSC_CREDENTIALS_FILE=
|
|
||||||
GSC_CREDENTIALS_HOST_FILE=
|
|
||||||
GSC_ALERT_DAYS=7
|
|
||||||
GSC_ALERT_WEBHOOK_URL=
|
|
||||||
|
|
||||||
# 장애 알림(잡 dead-letter·발행 업무 실패·부분 실패·잡 큐 정체) — Teams Workflows 수신 webhook.
|
|
||||||
# GSC_ALERT_WEBHOOK_URL 과 다른 값이다(그건 색인 감시 전용) — docs/ALERTS.md.
|
|
||||||
# 비우면 알림은 DB(alert_outbox)에 쌓이기만 하고 안 나간다. 서버 동작에는 영향 없다.
|
|
||||||
TEAMS_WEBHOOK_URL=
|
|
||||||
# 재시도마다 중복 스팸을 막는 창(분). 기본 60분 — 같은 사유가 이 시간 안에 또 터지면 다시 안 보낸다.
|
|
||||||
ALERT_DEDUPE_WINDOW_MIN=60
|
|
||||||
|
|
||||||
# 비우면 로컬 발행만 한다
|
# 비우면 로컬 발행만 한다
|
||||||
AZURE_STORAGE_CONNECTION_STRING=
|
AZURE_STORAGE_CONNECTION_STRING=
|
||||||
AZURE_STORAGE_CONTAINER=
|
AZURE_STORAGE_CONTAINER=
|
||||||
@ -161,43 +95,6 @@ AZURE_STORAGE_PREFIX=
|
|||||||
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
|
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
|
||||||
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
|
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
|
||||||
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
|
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
|
||||||
# ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site,
|
# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site
|
||||||
# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는
|
|
||||||
# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다).
|
|
||||||
AUTO_LOGIN_ID=
|
AUTO_LOGIN_ID=
|
||||||
AUTO_LOGIN_PW=
|
AUTO_LOGIN_PW=
|
||||||
|
|
||||||
# ── 메일 발송 (예약 요청 알림) ────────────────────────────────────────────
|
|
||||||
# 비우면 메일을 보내지 않는다. 서버는 그대로 뜨고, 예약 요청 폼은 "전화로 문의" 로 답한다.
|
|
||||||
# ★ 1순위는 회사 공용 Azure Communication Services 다(negodata 와 같은 리소스).
|
|
||||||
# 발신 도메인의 SPF·DKIM 을 그쪽이 관리하므로 메일서버를 새로 세울 필요가 없다.
|
|
||||||
ACS_EMAIL_ENDPOINT=
|
|
||||||
ACS_EMAIL_ACCESSKEY=
|
|
||||||
ACS_EMAIL_SENDER=
|
|
||||||
|
|
||||||
# ACS 를 못 쓰는 환경의 폴백. 이쪽을 쓰면 SPF·DKIM 을 직접 걸어야 한다.
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
SMTP_FROM=
|
|
||||||
SMTP_FROM_NAME=Web4AI
|
|
||||||
# starttls(587) · ssl(465) · plain. 비우면 포트로 고른다.
|
|
||||||
SMTP_TLS=
|
|
||||||
|
|
||||||
# SNS — Threads 우선(2026-09-14). SOCIAL_TOKEN_SECRET은 Fernet.generate_key() 형식의 키.
|
|
||||||
# 키·앱 설정 없으면 연결 비활성, 초안/복사/화면 확인은 동작한다.
|
|
||||||
SOCIAL_TOKEN_SECRET=
|
|
||||||
THREADS_APP_ID=
|
|
||||||
THREADS_APP_SECRET=
|
|
||||||
THREADS_REDIRECT_URI=https://web4ai.o2osolution.ai/v1/social/oauth/callback
|
|
||||||
SOCIAL_APP_ORIGIN=https://web4ai.o2osolution.ai
|
|
||||||
SOCIAL_APPROVAL_HOURS=24
|
|
||||||
# 앱 심사·테스트 계정 게시·해지 안내 페이지 정책 검증 후 활성화.
|
|
||||||
SOCIAL_POSTING_ENABLED=0
|
|
||||||
# 대행사 선택 전 비워 둔다. 현재 어댑터는 SOLAPI 계약이며 교체는 external/alimtalk.py만.
|
|
||||||
ALIMTALK_API_KEY=
|
|
||||||
ALIMTALK_API_SECRET=
|
|
||||||
ALIMTALK_PROFILE_ID=
|
|
||||||
ALIMTALK_SENDER=
|
|
||||||
ALIMTALK_TEMPLATE_CODE=
|
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -58,11 +58,3 @@ dist/
|
|||||||
|
|
||||||
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
|
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .claude/settings.local.json 에 둔다.
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|
||||||
# 목업 작업 산출물 — 발행본 원본은 도커 볼륨(out/s)이라 레포에 두지 않는다
|
|
||||||
solution/site/scripts/mockup/backup/
|
|
||||||
solution/site/scripts/mockup/king-stay2/
|
|
||||||
solution/site/scripts/mockup/build6p/
|
|
||||||
solution/site/scripts/mockup/build6p-stay2/
|
|
||||||
solution/site/scripts/mockup/siann6/
|
|
||||||
solution/site/scripts/mockup/_sub*.mjs
|
|
||||||
|
|||||||
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
|
||||||
project_name: "o2o-site-AEO"
|
|
||||||
|
|
||||||
# list of language servers to start when using the LSP backend; choose from:
|
|
||||||
# ada al angular ansible bash
|
|
||||||
# bsl clojure cpp cpp_ccls crystal
|
|
||||||
# csharp csharp_omnisharp cue dart deno
|
|
||||||
# elixir elm erlang fortran fsharp
|
|
||||||
# gdscript gleam go groovy haskell
|
|
||||||
# haxe hlsl html java json
|
|
||||||
# julia kotlin latex lean4 lua
|
|
||||||
# luau markdown matlab msl nextflow
|
|
||||||
# nix ocaml pascal perl php
|
|
||||||
# php_phpactor php_phpantom powershell python python_basedpyright
|
|
||||||
# python_jedi python_pyrefly python_ty qml r
|
|
||||||
# rego ruby ruby_solargraph rust scala
|
|
||||||
# scss solidity svelte swift systemverilog
|
|
||||||
# terraform toml typescript typescript_vts vue
|
|
||||||
# wolfram yaml zig
|
|
||||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
|
||||||
# For the current list, see values of the LanguageServerId enum here:
|
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
|
||||||
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
|
||||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
|
||||||
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
|
|
||||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
|
||||||
# Special requirements:
|
|
||||||
# Some language servers require additional setup/installations.
|
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
||||||
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language server is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
language_servers:
|
|
||||||
- typescript
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
|
||||||
# the command runs in the project root directory and is only executed if the project is trusted
|
|
||||||
# (see trusted_project_path_patterns in the global configuration).
|
|
||||||
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
|
||||||
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
|
||||||
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
|
||||||
# example: activation_command: "npx nx run-many -t build"
|
|
||||||
activation_command:
|
|
||||||
|
|
||||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
|
||||||
# must be a positive number.
|
|
||||||
activation_command_timeout: 180.0
|
|
||||||
|
|
||||||
# line ending convention to use when writing source files.
|
|
||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
||||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
||||||
line_ending:
|
|
||||||
|
|
||||||
# The language backend to use for this project.
|
|
||||||
# If not set, the global setting from serena_config.yml is used.
|
|
||||||
# Valid values: LSP, JetBrains
|
|
||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
|
||||||
# is activated post-init, an error will be returned.
|
|
||||||
language_backend:
|
|
||||||
|
|
||||||
# whether to use project's .gitignore files to ignore files
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# advanced configuration option allowing to configure language server-specific options.
|
|
||||||
# Maps the language key to the options.
|
|
||||||
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
|
||||||
ls_specific_settings: {}
|
|
||||||
|
|
||||||
# list of workspace folder paths (LSP backend only).
|
|
||||||
# These folders will be used to build up Serena's symbol index.
|
|
||||||
# Paths must be within the project root and should thus be relative to the project root.
|
|
||||||
# Furthermore, the paths should not be filtered by ignore settings.
|
|
||||||
# Default setting: The entire project root folder (".") is considered.
|
|
||||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
|
||||||
# ls_workspace_folders:
|
|
||||||
# - "./subproject1"
|
|
||||||
# - "./subproject2"
|
|
||||||
ls_workspace_folders:
|
|
||||||
- "."
|
|
||||||
|
|
||||||
# list of additional workspace folder paths for cross-package reference support.
|
|
||||||
# Paths can be absolute or relative to the project root.
|
|
||||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
||||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
|
||||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
|
||||||
# Example:
|
|
||||||
# additional_workspace_folders:
|
|
||||||
# - ../sibling-package
|
|
||||||
# - ../shared-lib
|
|
||||||
ls_additional_workspace_folders: []
|
|
||||||
|
|
||||||
# list of additional paths to ignore in this project.
|
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
|
||||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
|
||||||
# Example:
|
|
||||||
# ignored_paths:
|
|
||||||
# - "examples/**"
|
|
||||||
# - ".worktrees/**"
|
|
||||||
# - "**/bin/**"
|
|
||||||
# - "**/obj/**"
|
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude.
|
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
||||||
# such as docstrings or parameter information.
|
|
||||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
||||||
# If null or missing, use the setting from the global configuration.
|
|
||||||
symbol_info_budget:
|
|
||||||
|
|
||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
read_only_memory_patterns: []
|
|
||||||
|
|
||||||
# list of regex patterns for memories to completely ignore.
|
|
||||||
# Matching memories will not appear in list_memories or activate_project output
|
|
||||||
# and cannot be accessed via read_memory or write_memory.
|
|
||||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
|
||||||
ignored_memory_patterns: []
|
|
||||||
117
AGENTS.md
117
AGENTS.md
@ -1,8 +1,5 @@
|
|||||||
# AGENTS.md — 이 레포에서 작업하기 전에
|
# AGENTS.md — 이 레포에서 작업하기 전에
|
||||||
|
|
||||||
> 2026-09-15 발행 버전 전환: [docs/PUBLISH_VERSION.md](docs/PUBLISH_VERSION.md)가
|
|
||||||
> 아래의 상시 프리렌더·재굽기·자산 주소 교체 절차를 대체한다. 목업은 변경하지 않는다.
|
|
||||||
|
|
||||||
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
|
에이전트와 신규 합류자가 **먼저 읽는 파일**이다. 여기에는 *밟기 쉬운 함정*과 *규약*만 둔다.
|
||||||
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
|
설명은 각 문서가 단일 출처다 — 여기로 복사하지 말고 링크한다.
|
||||||
|
|
||||||
@ -16,9 +13,6 @@
|
|||||||
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
|
||||||
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.md) |
|
||||||
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
| **어느 서버**에 올리나 (킹서버) | [docs/SERVERS.md](docs/SERVERS.md) |
|
||||||
| 장애가 나면 누가·어떻게 아나 | [docs/ALERTS.md](docs/ALERTS.md) |
|
|
||||||
| **미니 블로그**(AI 자동 포스트) 기획 | [docs/MINI_BLOG.md](docs/MINI_BLOG.md) |
|
|
||||||
| **사장님 에이전트**(카톡으로 관리) · 신원 연결 | [docs/AGENT.md](docs/AGENT.md) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -40,48 +34,7 @@
|
|||||||
→ **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
|
→ **`out/assets` 에서 파일을 지우는 코드는 `out/s/**` 의 HTML 이 참조하는 것을 먼저 뺀다**
|
||||||
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
|
(`prerender.ts` `referencedAssets`). 보관 기간으로는 못 막는다 — 기간이 지나면 같은 일이 난다.
|
||||||
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
|
→ 목업을 다루는 작업은 `out/s/` 를 먼저 열어 **payload 가 없는 디렉토리가 무엇인지** 본다.
|
||||||
→ ★ **`stay` 는 프리렌더가 굽지 않는다** (`prerender.ts` `PROTECTED_SLUGS`, 기본 `stay` ·
|
|
||||||
`PRERENDER_PROTECTED_SLUGS` 로 덮어쓴다). "payload 가 없으면 안 굽는다"
|
|
||||||
는 보호가 못 된다 — **payload 가 생기는 순간** 덮인다. 실측(2026-09-15): 누가 빌더에서
|
|
||||||
슬러그 `stay` 로 발행해 `payloads/stay.json` 이 생기자 프리렌더가 `/s/stay` 를 그 payload 로
|
|
||||||
구워 목업을 통째로 날렸다(캐치프레이즈 100개·미니 플레이어·날씨 문구·주입분 전부).
|
|
||||||
그 payload 는 `solution/site/payloads-mockup-hold/` 로 옮긴다 — 지우면 재발행 때 또 온다.
|
|
||||||
|
|
||||||
- **★ 굽기는 네트워크를 탄다 — 사진을 내려받는다** (`prerender.ts` `mirrorMedia`).
|
|
||||||
`payload.media[].url` 이 남의 도메인이면 `out/s/<slug>/img/<주소해시>.<확장자>` 로 받아 놓고
|
|
||||||
payload 의 주소를 **우리 오리진 절대주소**로 바꾼 뒤에 굽는다. 이유는 캔버스다 —
|
|
||||||
수집처(`*.pstatic.net` · `tong.visitkorea.or.kr`)가 `Access-Control-Allow-Origin` 을 안 줘서
|
|
||||||
그 사진을 캔버스에 그리면 오염돼 `toBlob` 이 막히고, **엽서 쓰기의 저장·공유가 모든 발행
|
|
||||||
사이트에서 죽어 있었다**(실측 2026-09-15). 클라이언트에서는 못 넘는다.
|
|
||||||
→ 못 받은 사진은 **원래 주소를 그대로 쓴다**(사진이 사라지는 것보다 낫다). 로그에 한 줄 남는다.
|
|
||||||
→ 주소가 그대로면 파일명도 그대로라 **다시 구워도 내려받지 않는다.** 처음 한 번만 느리다.
|
|
||||||
→ ★ **이미 나가 있는 사이트는 그대로 둔다.** 새 기능은 사장님이 **다시 발행할 때** 들어간다
|
|
||||||
(아래 항목). 그 사이를 메우는 건 **중계**다 — `/v1/image/relay?url=…`
|
|
||||||
(`backend/router/v1/media/relay.py`). 캔버스가 CORS 로 사진을 못 받으면 같은 오리진의
|
|
||||||
이 주소로 한 번 더 받아 본다(`site/src/lib/postcard-canvas.ts` `loadImage`).
|
|
||||||
열린 프록시가 아니다 — https · 호스트 allowlist · 이미지 타입 · 8MB · 리다이렉트 후
|
|
||||||
호스트 재검사. **호스트를 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는가" 를 먼저 본다.**
|
|
||||||
→ `originUrl` · `sourceType` 은 손대지 않는다 — 재게시 권리(DECISIONS 1-2)가 "불가" 로
|
|
||||||
결론 나면 `sourceType = CRAWL` 을 빼는 그 대응이 그대로 먹어야 한다.
|
|
||||||
- **★★ 배포해도 기존 사이트를 다시 굽지 않는다 — 자산 주소만 갈아 끼운다.**
|
|
||||||
(2026-09-15 대표 지시: "전체 재굽기 할 필요가 없어, 사장님이 재발행하면 끝인데 /
|
|
||||||
css js만 안 깨지게 하란 말이야")
|
|
||||||
예전에는 `solution-prerender` 가 뜰 때마다 payload 를 **전부 다시 구웠다.** 그러면 렌더러를
|
|
||||||
한 줄 고칠 때마다 이미 나가 있는 사이트의 HTML 이 통째로 바뀐다 — 사장님은 발행한 적이
|
|
||||||
없는데 내용이 달라지고, 구글이 다시 읽어 가는 값도 달라진다.
|
|
||||||
지금 기동이 하는 일은 `prerender.js --refresh-assets` 하나다
|
|
||||||
(`watch-payloads.mjs` `refreshAssets` → `prerender.ts` `refreshBakedAssets`):
|
|
||||||
→ 구워진 `index.html` 안의 `assets/index-<해시>.css|js` **파일명만** 새 번들로 바꾼다.
|
|
||||||
내용·구조·payload 는 손대지 않는다. 접두사(`/assets` · `/sites/assets`)도 그대로 둔다.
|
|
||||||
→ **한 번도 안 구워진 payload 만** 굽는다(볼륨이 비었거나 감시가 꺼진 새 발행).
|
|
||||||
→ ★ **payload 가 없는 디렉토리는 건드리지 않는다**(목업 `stay3` · `*.old`, 그리고
|
|
||||||
`PROTECTED_SLUGS`). 손으로 만든 유일본에 최신 번들을 물렸다가 깨지면 되돌릴 수 없다 —
|
|
||||||
그쪽 번들 교체는 사람이 한다(mockup/README "번들만 갈아 끼운다").
|
|
||||||
대상은 `--payload-dir` 에 `<슬러그>.json` 이 있는 사이트뿐이다.
|
|
||||||
★ 그래서 **정적 HTML 은 옛 렌더러의 것이고 스크립트는 새 렌더러**다. 어긋나면 리액트가
|
|
||||||
그 자리에서 다시 그리므로 손님 화면은 새것이지만, **크롤러가 읽는 HTML 은 옛것**이다.
|
|
||||||
둘을 맞추는 방법은 재발행뿐이고 그건 사장님이 누른다. 급하면 `republish_all.py` 지만
|
|
||||||
**먼저 묻는다** — 전 사이트의 발행일이 한꺼번에 움직이는 일이다.
|
|
||||||
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
|
- **번들 파일명은 콘텐츠 해시다.** HTML 은 `/assets/index-DvNTmLhy.css` 를 **루트 절대경로**로
|
||||||
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
|
가리킨다. 경로는 프리렌더가 `dist/client/.vite/manifest.json` 에서 읽어 박는다
|
||||||
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
|
(`prerender.ts:160`). 렌더러 CSS 를 고치면 이름이 바뀐다.
|
||||||
@ -97,19 +50,15 @@
|
|||||||
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
|
없고**, 그때 디스크에 있던 기존 자산이 전부 "대장에 없음" 으로 분류돼 한꺼번에 삭제됐다.
|
||||||
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
옛 자산을 남기려고 만든 코드가 첫 실행에서 정확히 반대로 동작했다.
|
||||||
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
|
→ 자산을 지우는 코드를 손볼 때는 **"기록이 없다"와 "만료됐다"를 절대 같이 묶지 않는다.**
|
||||||
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` — 기동이 공용 자산을
|
→ 이미 끊겼다면 복구는 `docker compose restart solution-prerender` (기동하며 전체 재굽기).
|
||||||
다시 깔고 구워진 HTML 의 자산 주소를 맞춘다(전체 재굽기가 아니다, 위 ★★ 항목).
|
|
||||||
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
|
- **★ 사이트를 굽는 컨테이너는 `solution-prerender` 다.** `solution-frontend` 는 **개발용**이라
|
||||||
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
|
운영에서는 아예 뜨지 않는다(`docker-compose.yml` `profiles: ["dev"]`). 이름이 비슷해서
|
||||||
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
|
`restart solution-frontend` 를 치면 **아무 일도 안 일어나는데 명령은 성공한다** —
|
||||||
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
|
재굽기를 했다고 믿고 넘어가게 된다. 실제로 그렇게 복구가 한 번 헛돌았다(2026-09-07).
|
||||||
- **★ 프론트(`solution/site`)를 고쳐도 기존 사이트의 내용은 안 바뀐다.** 기동은 자산 주소만
|
- **★ 프론트(`solution/site`)를 배포하면 반드시 전체 재굽기 + 전체 재업로드.**
|
||||||
맞춘다(위 ★★ 항목) — 새 렌더러로 다시 그려지는 건 **그 사장님이 다시 발행할 때**다.
|
`azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만 올린다 —
|
||||||
Azure 를 쓰는 경우엔 한 겹 더 있다: `azure_static.publish(slug)` 는 공용 자산 + `s/<slug>` 만
|
**렌더러를 고쳐도 다른 사이트에는 반영되지 않는다.**
|
||||||
올린다 — 다른 사이트의 블롭은 그대로다.
|
→ `docker compose restart solution-prerender` 후 `python scripts/republish_all.py`
|
||||||
→ 전 사이트를 한꺼번에 새 렌더러로 맞춰야 할 일이 생기면
|
|
||||||
`docker compose restart solution-prerender` 후 `python scripts/republish_all.py` 인데,
|
|
||||||
**먼저 묻는다**(발행일이 전부 움직인다).
|
|
||||||
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
- **발행 호스트는 두 곳에 있고 같아야 한다.** 백엔드 `SITE_PUBLIC_HOST`(기본 `web4ai.o2osolution.ai`,
|
||||||
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
`site_payload.py`) ↔ 프론트 `VITE_PUBLISH_HOST`. canonical·og:url·sitemap·IndexNow 가 전부
|
||||||
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
이 값을 쓴다. 그리고 **`origin` 은 payload JSON 에 구워진다** — 호스트를 바꾸면 프리렌더
|
||||||
@ -118,13 +67,6 @@
|
|||||||
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
|
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
|
||||||
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
|
수신자(`aud`)를 대조한다 — **이 검사가 유일하게 "남의 앱에 발급된 진짜 구글 토큰"을 막는다.**
|
||||||
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
|
어긋나면 버튼은 뜨는데 로그인만 계속 거부된다. 비우면 구글 로그인만 꺼진다(서버는 뜬다).
|
||||||
- **★ `VITE_AUTO_LOGIN_ID`·`PW` 는 운영 진입점(`solution-site`, nginx/Dockerfile)에 절대
|
|
||||||
넘기지 않는다.** 예전엔 `docker-compose.yml` 의 `solution-site` build args 에 이 값이
|
|
||||||
실제로 흘러가고 있었다 — `.env` 에 채운 채로 배포하면 자동 로그인 계정이 사장님이 여는
|
|
||||||
운영 번들에 그대로 구워졌다(누구나 JS 에서 읽을 수 있다). 지금은 그 build arg 자체가
|
|
||||||
없다. `lib/autoSession.ts` 의 `import.meta.env.DEV` 가드가 둘째 안전판이다 — 실수로
|
|
||||||
값이 다시 넘어와도 운영 빌드(`vite build`)에서는 죽은 코드로 접혀 번들에서 빠진다.
|
|
||||||
자동 로그인이 필요하면 `solution-frontend`(`--profile dev`, `vite dev`)만 쓴다.
|
|
||||||
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
|
- **`AZURE_STORAGE_PREFIX` 와 루트 절대경로는 충돌한다.** HTML 이 `/assets/…` 를 가리키는데
|
||||||
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
|
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
|
||||||
CDN 을 앞에 세워야 한다. 아니면 비워라.
|
CDN 을 앞에 세워야 한다. 아니면 비워라.
|
||||||
@ -142,55 +84,6 @@
|
|||||||
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
|
→ 리다이렉트는 `absolute_redirect off` 로 **상대 Location** 이어야 한다. TLS 를 앞단
|
||||||
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
|
Apache 가 끊어서 nginx 의 `$scheme` 는 늘 `http` 다 — 절대 URL 로 내면 https→http 다.
|
||||||
|
|
||||||
- **★ SNS 게재 승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저
|
|
||||||
프리페치가 **사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이
|
|
||||||
올라가고 로그에는 "승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
|
|
||||||
링크는 확인 화면을 열 뿐이고 게시는 그 화면의 POST 다([DECISIONS 8-3](docs/DECISIONS.md)).
|
|
||||||
- **★ SNS 게재는 `sites.domain` 이 확정된 사이트에만 허용한다.** `domain` 이 비면 발행 슬러그가
|
|
||||||
**상호명에서 파생**되고(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다. `SITE_SLUG_LOCKED`
|
|
||||||
는 `domain` 변경만 막으므로 여기엔 안 걸린다 — **이미 올라간 글의 링크는 404 가 되고 그 글은
|
|
||||||
수정할 수 없다.**
|
|
||||||
- **★ ORM 의 `server_default=text("'…'")` 에 쉼표를 딸려 보내지 않는다.** `text("'[]',")` 는
|
|
||||||
`DEFAULT '[]', NOT NULL` 로 나가 **CREATE TABLE 이 통째로 실패**한다. 운영 DB 는 init.sql 로
|
|
||||||
만들어져 안 드러나고, **ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다(실측 2026-09-14).
|
|
||||||
|
|
||||||
## SNS에서 조용히 틀리는 것 (2026-09-14)
|
|
||||||
|
|
||||||
- domain NULL은 임시 주소다. SNS는 PUBLISHED + current_version_id + 확정 domain을 모두 요구한다.
|
|
||||||
- 승인 GET은 프리페치가 연다. 상태 전이는 POST의 nonce 해시 + PENDING CAS로만 한다.
|
|
||||||
- Threads는 X의 offline.access/회전 refresh_token 계약을 쓰지 않는다. 장기 access token을 갱신한다.
|
|
||||||
- 토큰 갱신 저장 실패는 재연결. POSTING 중단·응답 유실은 UNKNOWN이며 자동 재게시 금지.
|
|
||||||
- 초기 SOCIAL_POSTING_ENABLED=0. [SOCIAL.md](docs/SOCIAL.md)의 실제 게시·해지 안내 페이지 전제를 확인한 뒤 연다.
|
|
||||||
|
|
||||||
## 에이전트에서 조용히 틀리는 것 (2026-09-21)
|
|
||||||
|
|
||||||
- **도구가 `crud` 를 직접 부르면 게이트가 통째로 뚫린다** — 업종 스키마 검증·출처 필수·정정본
|
|
||||||
보호가 사라지는데 **아무 증상이 없다**(값은 들어가고 빌드도 성공한다). 도구는 반드시
|
|
||||||
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
|
|
||||||
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
|
|
||||||
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
|
|
||||||
- **★ 카카오 웹훅은 서명이 없다 — 시크릿이 유일한 문이다.** 오픈빌더는 서명을 주지 않아서,
|
|
||||||
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
|
|
||||||
`KAKAO_WEBHOOK_SECRET` 이 비면 엔드포인트가 **404**(401 은 존재를 알린다).
|
|
||||||
- **확인 대기에 만료가 없으면 묵은 발행이 돈다** — 카카오톡은 앞선 답을 되돌려 주지 않아
|
|
||||||
서버가 pending 을 들고 있는다. `pending_expires_at`(3분)을 빼면 한참 뒤의 "네" 한 마디에
|
|
||||||
실행된다([AGENT.md](docs/AGENT.md)).
|
|
||||||
- **바로가기 라벨과 '예' 로 읽는 말이 어긋나면 눌러도 안 먹는다** — 사장님은 버튼이 고장난
|
|
||||||
줄 안다. `channel.py` 의 `CONFIRM_LABEL` 상수를 쓰고 문자열을 손으로 적지 않는다.
|
|
||||||
- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`).
|
|
||||||
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
|
|
||||||
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.
|
|
||||||
카카오 연결 카드는 `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 감춰진다 —
|
|
||||||
웹훅(4단계)이 없어 코드를 보내도 연결이 완성되지 않기 때문이다([AGENT.md](docs/AGENT.md)).
|
|
||||||
- **에이전트 등급을 모델이 정하게 두지 않는다** — 확인이 필요한 행위인지는 `services/agent/tools.py`
|
|
||||||
레지스트리가 못 박는다. 응답 스키마에 그 칸을 만들면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다.
|
|
||||||
- **실행 결과 문구를 LLM 이 쓰게 두지 않는다** — 모델은 **하지 않은 일을 했다고 말할 수 있고**,
|
|
||||||
사장님에게는 그 말이 사실로 보인다. 화면의 "바꿨습니다" 는 코드가 보장하는 문장이어야 한다.
|
|
||||||
- **값을 고친 뒤 재발행 안내를 빠뜨리지 않는다** — fact 는 바뀌어도 사이트는 안 바뀐다.
|
|
||||||
사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
|
||||||
- **코드 소비 경로를 웹훅 서명 검증보다 먼저 열지 않는다** — 누구나 6자리를 대입해 남의
|
|
||||||
계정에 자기 카톡을 붙일 수 있다. 지금 `redeem()` 이 라우터에 없는 이유다([AGENT.md](docs/AGENT.md)).
|
|
||||||
|
|
||||||
## 코드 규약
|
## 코드 규약
|
||||||
|
|
||||||
- **미결 사항은 코드로 풀지 않는다.** [DECISIONS.md](docs/DECISIONS.md) 1절이 보류한 것은
|
- **미결 사항은 코드로 풀지 않는다.** [DECISIONS.md](docs/DECISIONS.md) 1절이 보류한 것은
|
||||||
|
|||||||
@ -22,7 +22,6 @@ import router.v1.fact.fact
|
|||||||
import router.v1.job.job
|
import router.v1.job.job
|
||||||
import router.v1.local.local
|
import router.v1.local.local
|
||||||
import router.v1.place.place
|
import router.v1.place.place
|
||||||
import router.v1.site.review_admin
|
|
||||||
import router.v1.site.site
|
import router.v1.site.site
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
@ -85,4 +84,3 @@ app.include_router(router.v1.fact.fact.router, dependencies=_gate)
|
|||||||
app.include_router(router.v1.job.job.router, dependencies=_gate)
|
app.include_router(router.v1.job.job.router, dependencies=_gate)
|
||||||
app.include_router(router.v1.site.site.router, dependencies=_gate)
|
app.include_router(router.v1.site.site.router, dependencies=_gate)
|
||||||
app.include_router(router.v1.local.local.router, dependencies=_gate)
|
app.include_router(router.v1.local.local.router, dependencies=_gate)
|
||||||
app.include_router(router.v1.site.review_admin.router, dependencies=_gate)
|
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import {Building2, CalendarDays, MessageSquareQuote} from 'lucide-react';
|
import {Building2, CalendarDays} from 'lucide-react';
|
||||||
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
|
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
|
||||||
import {AppShell, type NavItem} from '@/components/layout/AppShell';
|
import {AppShell, type NavItem} from '@/components/layout/AppShell';
|
||||||
import {RequireAuth} from '@/components/layout/RequireAuth';
|
import {RequireAuth} from '@/components/layout/RequireAuth';
|
||||||
import {LocalContentPage} from '@admin/pages/LocalContentPage';
|
import {LocalContentPage} from '@admin/pages/LocalContentPage';
|
||||||
import {ReviewModerationPage} from '@admin/pages/ReviewModerationPage';
|
|
||||||
import {LoginPage} from '@/pages/LoginPage';
|
import {LoginPage} from '@/pages/LoginPage';
|
||||||
import {NotFoundPage} from '@/pages/NotFoundPage';
|
import {NotFoundPage} from '@/pages/NotFoundPage';
|
||||||
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
|
||||||
@ -17,7 +16,6 @@ import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
|
|||||||
const ADMIN_NAV: NavItem[] = [
|
const ADMIN_NAV: NavItem[] = [
|
||||||
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
{to: '/places', match: '/places', label: '사업장', icon: Building2},
|
||||||
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
|
||||||
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -43,7 +41,6 @@ export const router = createBrowserRouter([
|
|||||||
{path: '/places/:placeId', element: <PlaceDetailPage />},
|
{path: '/places/:placeId', element: <PlaceDetailPage />},
|
||||||
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
|
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
|
||||||
{path: '/local-content', element: <LocalContentPage />},
|
{path: '/local-content', element: <LocalContentPage />},
|
||||||
{path: '/reviews', element: <ReviewModerationPage />},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -1,169 +0,0 @@
|
|||||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
|
||||||
import {RefreshCw, X} from 'lucide-react';
|
|
||||||
import {PageContainer} from '@/components/layout/AppShell';
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
|
||||||
import {Button} from '@/components/ui/button';
|
|
||||||
import {Card, CardContent} from '@/components/ui/card';
|
|
||||||
import {customFetch} from '@/api/mutator/custom-fetch';
|
|
||||||
import {toast} from 'sonner';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이용 후기 — 손님 글은 이미 화면에 올라가 있다. 이 화면은 **내리는** 자리다(사후 대응).
|
|
||||||
*
|
|
||||||
* ★ 사람 검수를 앞에 두지 않는다(2026-09-16 대표: "그냥 뜨게 하지"). 기계 필터를 통과하면
|
|
||||||
* 그 자리에서 공개되고, 문제 글을 여기서 내린다. 내리면 손님 화면에서도 바로 빠진다.
|
|
||||||
*/
|
|
||||||
type Review = {
|
|
||||||
review_id: string;
|
|
||||||
place_id: string;
|
|
||||||
place_name: string;
|
|
||||||
body: string;
|
|
||||||
nickname: string;
|
|
||||||
status: number;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATUS_TABS: {value: number; label: string}[] = [
|
|
||||||
{value: 2, label: '게재된 후기'},
|
|
||||||
{value: 3, label: '내린 글'},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function ReviewModerationPage() {
|
|
||||||
const [status, setStatus] = useState(2);
|
|
||||||
const [posts, setPosts] = useState<Review[]>([]);
|
|
||||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await customFetch<{items: Review[]}>({
|
|
||||||
url: `/v1/admin/review/list?status=${status}&limit=200`,
|
|
||||||
method: 'GET',
|
|
||||||
});
|
|
||||||
setPosts(res.items ?? []);
|
|
||||||
setPicked(new Set());
|
|
||||||
} catch {
|
|
||||||
toast.error('목록을 불러오지 못했습니다.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void load();
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
const byPlace = useMemo(() => {
|
|
||||||
const map = new Map<string, Review[]>();
|
|
||||||
for (const post of posts) {
|
|
||||||
const key = post.place_name || post.place_id;
|
|
||||||
if (!map.has(key)) map.set(key, []);
|
|
||||||
map.get(key)!.push(post);
|
|
||||||
}
|
|
||||||
return [...map.entries()];
|
|
||||||
}, [posts]);
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setPicked((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const decide = async (approve: boolean) => {
|
|
||||||
const ids = [...picked];
|
|
||||||
if (ids.length === 0) return;
|
|
||||||
try {
|
|
||||||
await customFetch({url: '/v1/admin/review/decide', method: 'POST', data: {review_ids: ids, publish: approve}});
|
|
||||||
toast.success(approve ? `${ids.length}건을 다시 올렸습니다.` : `${ids.length}건을 내렸습니다.`);
|
|
||||||
await load();
|
|
||||||
} catch {
|
|
||||||
toast.error('처리하지 못했습니다.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContainer
|
|
||||||
title="이용 후기"
|
|
||||||
description="손님이 남기면 바로 사이트에 올라갑니다. 문제가 있는 글만 여기서 내립니다."
|
|
||||||
actions={
|
|
||||||
<Button variant="outline" onClick={() => void load()} disabled={loading}>
|
|
||||||
<RefreshCw className="size-4" /> 새로고침
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="mb-4 flex flex-wrap gap-1.5">
|
|
||||||
{STATUS_TABS.map((tab) => (
|
|
||||||
<Button
|
|
||||||
key={tab.value}
|
|
||||||
size="sm"
|
|
||||||
variant={tab.value === status ? 'primary' : 'outline'}
|
|
||||||
onClick={() => setStatus(tab.value)}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{status === 2 && (
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-sm">{picked.size}건 선택</span>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => void decide(false)} disabled={picked.size === 0}>
|
|
||||||
<X className="size-4" /> 내리기
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setPicked(new Set(posts.map((post) => post.review_id)))}
|
|
||||||
disabled={posts.length === 0}
|
|
||||||
>
|
|
||||||
전체 선택
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{posts.length === 0 && !loading && (
|
|
||||||
<p className="text-muted-foreground py-10 text-center text-sm">이 상태의 글이 없습니다.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
{byPlace.map(([placeName, rows]) => (
|
|
||||||
<section key={placeName}>
|
|
||||||
<h2 className="mb-2 text-sm font-bold">
|
|
||||||
{placeName} <span className="text-muted-foreground font-normal">{rows.length}건</span>
|
|
||||||
</h2>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{rows.map((post) => (
|
|
||||||
<Card key={post.review_id}>
|
|
||||||
<CardContent className="flex items-start gap-3 p-4">
|
|
||||||
{status === 2 && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id={`review-${post.review_id}`}
|
|
||||||
checked={picked.has(post.review_id)}
|
|
||||||
onChange={() => toggle(post.review_id)}
|
|
||||||
className="mt-1 size-4 shrink-0"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-1.5 flex flex-wrap items-center gap-2">
|
|
||||||
<Badge variant="accent">{post.nickname || '손님'}</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs tabular-nums">{post.body.length}자</span>
|
|
||||||
</div>
|
|
||||||
<label htmlFor={`review-${post.review_id}`} className="block text-sm leading-relaxed">
|
|
||||||
{post.body}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageContainer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
141
deploy.sh
141
deploy.sh
@ -1,40 +1,127 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# 서버 변경과 꺼진 admin을 보존한다. 렌더러 배포도 기존 사이트를 재굽지 않는다.
|
# 배포 — 코드를 당기고, 지정한 서비스만 다시 빌드해 갈아끼운다.
|
||||||
|
#
|
||||||
|
# ./deploy.sh 전체
|
||||||
|
# ./deploy.sh solution-backend 그 서비스만
|
||||||
|
# ./deploy.sh solution-backend solution-worker 여럿
|
||||||
|
#
|
||||||
|
# 서비스명 대신 컨테이너명(o2o-web4ai-solution-backend)으로 불러도 받는다.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PREFIX=o2o-web4ai
|
||||||
|
# api·worker·api-admin 은 이미지 한 벌(o2o-web4ai-backend)을 나눠 쓴다.
|
||||||
|
BACKEND_SVCS=(solution-backend solution-worker admin-backend)
|
||||||
|
BRANCH=${DEPLOY_BRANCH:-main}
|
||||||
|
|
||||||
PULL=1
|
PULL=1
|
||||||
ONLY=0
|
ONLY=0
|
||||||
TARGETS=()
|
TARGETS=()
|
||||||
for arg in "$@"; do
|
|
||||||
case "$arg" in
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
사용법: ./deploy.sh [옵션] [서비스...]
|
||||||
|
|
||||||
|
옵션
|
||||||
|
--no-pull 코드를 당기지 않는다(디스크에 있는 코드 그대로 빌드)
|
||||||
|
--only 백엔드 형제 서비스를 함께 갈아끼우지 않는다 (아래 ★ 참고)
|
||||||
|
-h, --help
|
||||||
|
|
||||||
|
환경변수
|
||||||
|
DEPLOY_BRANCH 기본 main. 다른 브랜치를 배포할 때만 쓴다
|
||||||
|
|
||||||
|
서비스: solution-backend · solution-worker · solution-prerender · solution-site
|
||||||
|
admin-backend · admin-frontend (프로필 admin, 기본 기동에서 빠져 있다)
|
||||||
|
solution-frontend (프로필 dev, 로컬 HMR 전용 — 운영에서 띄우지 않는다)
|
||||||
|
o2o-web4ai-solution-backend 처럼 컨테이너명으로 적어도 된다
|
||||||
|
|
||||||
|
★ solution-backend·solution-worker·admin-backend 는 이미지가 한 벌이다. 하나를 빌드하면 나머지도 새 이미지로
|
||||||
|
갈아끼워야 한다 — 안 그러면 옛 코드로 도는 컨테이너가 남는데, 셋 다 "살아 있음" 이라
|
||||||
|
화면상으로는 배포가 끝난 것처럼 보인다. --only 는 그걸 알고 건너뛸 때만 쓴다.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
--no-pull) PULL=0 ;;
|
--no-pull) PULL=0 ;;
|
||||||
--only) ONLY=1 ;;
|
--only) ONLY=1 ;;
|
||||||
-h|--help) echo "사용법: ./deploy.sh [--no-pull] [--only] [서비스...]"; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
-*) echo "모르는 옵션: $arg" >&2; exit 2 ;;
|
-*) echo "모르는 옵션: $1" >&2; usage >&2; exit 2 ;;
|
||||||
*) TARGETS+=("${arg#o2o-web4ai-}") ;;
|
*) TARGETS+=("${1#"$PREFIX"-}") ;; # 컨테이너명으로 불러도 받는다
|
||||||
esac
|
esac
|
||||||
|
shift
|
||||||
done
|
done
|
||||||
|
|
||||||
|
ALL_SVCS=$(docker compose config --services)
|
||||||
|
for t in ${TARGETS+"${TARGETS[@]}"}; do
|
||||||
|
grep -qx "$t" <<<"$ALL_SVCS" || {
|
||||||
|
echo "그런 서비스가 없다: $t" >&2
|
||||||
|
echo "있는 것: $(tr '\n' ' ' <<<"$ALL_SVCS")" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── 코드 ────────────────────────────────────────────────────────────
|
||||||
|
# 배포 서버의 작업트리는 main 의 **사본**이지 작업 공간이 아니다. 그래서 머지(pull)가 아니라
|
||||||
|
# origin/$BRANCH 로 하드 리셋한다 — 밖에서 force-push 가 나도 --ff-only 로 막히지 않고,
|
||||||
|
# 서버에서 누가 손댄 흔적이 다음 배포까지 살아남지 않는다.
|
||||||
|
#
|
||||||
|
# ★ fetch 가 실패하면 **리셋하지 않는다.** 이 서버엔 gitea 자격증명이 없어 fetch 가 죽는데,
|
||||||
|
# 그 상태의 origin/$BRANCH 는 마지막으로 fetch 된 낡은 ref 다.
|
||||||
|
# 실측(2026-09-01 킹서버): HEAD=9b4fe40 인데 origin/main=4871e50 — 믿고 리셋하면 한 커밋
|
||||||
|
# 롤백된다. 그러고도 빌드는 성공하고 컨테이너는 뜬다. 조용히 틀리는 종류다.
|
||||||
|
#
|
||||||
|
# ★ git clean 은 하지 않는다. .env 와 nginx/site.conf 는 추적되지 않는 파일이고 서버마다
|
||||||
|
# 다르다 — reset --hard 는 이 둘을 건드리지 않지만 clean 은 지운다.
|
||||||
if [ "$PULL" = 1 ]; then
|
if [ "$PULL" = 1 ]; then
|
||||||
git diff --quiet && git diff --cached --quiet || { echo "서버 변경을 먼저 정리하세요" >&2; exit 1; }
|
echo "▶ git fetch origin $BRANCH"
|
||||||
git pull --ff-only origin "${DEPLOY_BRANCH:-main}"
|
if git fetch --prune origin "$BRANCH" 2>&1; then
|
||||||
|
DIRTY=$(git status --porcelain)
|
||||||
|
if [ -n "$DIRTY" ]; then
|
||||||
|
echo " ! 작업트리 변경을 버린다:"
|
||||||
|
sed 's/^/ /' <<<"$DIRTY"
|
||||||
|
fi
|
||||||
|
echo "▶ git reset --hard origin/$BRANCH"
|
||||||
|
git reset --hard "origin/$BRANCH"
|
||||||
|
else
|
||||||
|
echo " ! fetch 실패 — 리셋을 건너뛰고 디스크에 있는 코드 그대로 간다." >&2
|
||||||
|
echo " origin/$BRANCH 가 낡았을 수 있어 그걸로 리셋하면 배포가 조용히 롤백된다." >&2
|
||||||
|
echo " (밖에서 밀어넣었으면 이대로 두면 되고, 자동화하려면 gitea 배포키를 건다)" >&2
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
[ ${#TARGETS[@]} -gt 0 ] || TARGETS=(solution-backend solution-worker solution-site)
|
echo "▶ 지금 코드: $(git log --oneline -1)"
|
||||||
RUNNING=$(docker compose ps --services --status running)
|
|
||||||
if [ "$ONLY" = 0 ]; then
|
# ── 대상 정하기 ─────────────────────────────────────────────────────
|
||||||
case " ${TARGETS[*]} " in
|
if [ ${#TARGETS[@]} -eq 0 ]; then
|
||||||
*" solution-backend "*|*" solution-worker "*)
|
SVCS=() # 빈 인자 = compose 가 전부로 해석한다
|
||||||
TARGETS+=(solution-backend solution-worker)
|
echo "▶ 대상: 전체"
|
||||||
if grep -qx admin-backend <<<"$RUNNING"; then TARGETS+=(admin-backend); fi ;;
|
else
|
||||||
esac
|
SVCS=("${TARGETS[@]}")
|
||||||
|
# 백엔드 하나를 건드리면 같은 이미지를 쓰는 형제도 함께 갈아끼운다.
|
||||||
|
if [ "$ONLY" = 0 ]; then
|
||||||
|
for t in "${TARGETS[@]}"; do
|
||||||
|
for b in "${BACKEND_SVCS[@]}"; do [ "$t" = "$b" ] || continue
|
||||||
|
for sib in "${BACKEND_SVCS[@]}"; do
|
||||||
|
printf '%s\n' "${SVCS[@]}" | grep -qx "$sib" || {
|
||||||
|
SVCS+=("$sib")
|
||||||
|
echo " + $sib — 같은 이미지를 쓴다(옛 코드로 남지 않게 함께 간다)"
|
||||||
|
}
|
||||||
|
done
|
||||||
|
done
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
echo "▶ 대상: ${SVCS[*]}"
|
||||||
fi
|
fi
|
||||||
SERVICES=()
|
|
||||||
for target in "${TARGETS[@]}"; do
|
# ── 빌드 · 교체 ─────────────────────────────────────────────────────
|
||||||
case " ${SERVICES[*]-} " in *" $target "*) ;; *) SERVICES+=("$target");; esac
|
# build 절이 없는 서비스(node:24-alpine · nginx:alpine)는 build 가 조용히 건너뛴다.
|
||||||
done
|
echo "▶ build"
|
||||||
# site의 렌더러도 worker 이미지에 들어간다.
|
docker compose build ${SVCS+"${SVCS[@]}"}
|
||||||
case " ${SERVICES[*]} " in
|
|
||||||
*" solution-site "*) case " ${SERVICES[*]} " in *" solution-worker "*) ;; *) SERVICES+=(solution-worker);; esac ;;
|
echo "▶ up -d"
|
||||||
esac
|
docker compose up -d --force-recreate ${SVCS+"${SVCS[@]}"}
|
||||||
docker compose build "${SERVICES[@]}"
|
|
||||||
docker compose up -d --no-deps --force-recreate "${SERVICES[@]}"
|
echo
|
||||||
docker compose ps
|
docker compose ps
|
||||||
|
echo
|
||||||
|
echo "로그: ./log.sh"
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
# 선택 연동. 키 파일은 저장소 밖에 두고 기존 API 스케줄러에만 읽기 전용으로 전달한다.
|
|
||||||
# docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d solution-backend
|
|
||||||
services:
|
|
||||||
solution-backend:
|
|
||||||
environment:
|
|
||||||
GSC_CREDENTIALS_FILE: /run/secrets/search-console.json
|
|
||||||
volumes:
|
|
||||||
- type: bind
|
|
||||||
source: ${GSC_CREDENTIALS_HOST_FILE:?Search Console 키 파일 절대경로 필요}
|
|
||||||
target: /run/secrets/search-console.json
|
|
||||||
read_only: true
|
|
||||||
bind:
|
|
||||||
create_host_path: false
|
|
||||||
@ -28,12 +28,8 @@ x-common-env: &common-env
|
|||||||
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
# ★ 기본값은 localhost 다. 운영 도메인을 기본으로 두면 .env 를 안 채운 로컬 빌드가
|
||||||
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
# 조용히 운영 주소를 번들에 굽는다(실측 2026-09-03: 로컬 링크가 킹서버로 갔다).
|
||||||
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
SITE_PUBLIC_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||||
# ★ 렌더러(solution/site/scripts/prerender.ts)가 songs·out 디렉토리를 **자기 파일 위치
|
SITE_PAYLOAD_DIR: /app/out/payloads
|
||||||
# 기준 상대경로**로 찾는다(SITE_ROOT = dist/prerender/../..) — 워커 컨테이너 안에서 그
|
SITE_OUTPUT_DIR: /app/out/sites
|
||||||
# 렌더러를 직접 띄우므로(render_service.py) 세 디렉토리가 실제 설치 자리
|
|
||||||
# (/app/solution/site/) 아래 나란히 있어야 한다. 아래 볼륨 마운트와 짝이 맞아야 한다.
|
|
||||||
SITE_PAYLOAD_DIR: /app/solution/site/payloads
|
|
||||||
SITE_OUTPUT_DIR: /app/solution/site/out
|
|
||||||
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
# ★ 프리렌더와 같은 값이어야 한다. 어긋나면 색인 통보가 403 이다.
|
||||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||||
|
|
||||||
@ -51,24 +47,14 @@ services:
|
|||||||
<<: *common-env
|
<<: *common-env
|
||||||
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
|
||||||
SCHEDULER_ENABLED: "1"
|
SCHEDULER_ENABLED: "1"
|
||||||
# ★ GSC_CREDENTIALS_HOST_FILE(호스트 경로, .env)을 아래 볼륨으로 이 컨테이너 안에 마운트한
|
|
||||||
# 고정 자리다. search_console_settings.load_settings() 가 실제로 읽는 건 이 값이다 —
|
|
||||||
# 호스트 경로를 코드에 그대로 넘기면 컨테이너 안에서 그 경로가 없어 실패한다.
|
|
||||||
# 실측(2026-09-18): 이 줄이 없어서 GSC_ENABLED=1 인데도 10분마다
|
|
||||||
# ValueError(GSC_CONFIG_MISSING) 로 조용히 실패하고 있었다 — 로그엔 BATCH_FAILED 만 남아
|
|
||||||
# 원인이 안 보였다.
|
|
||||||
GSC_CREDENTIALS_FILE: /app/secrets/gsc-credentials.json
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/solution/site/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
# 돌릴 때 사이트 디렉토리로 복사된다(services/song_service · site/scripts/prerender.ts).
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
- ./solution/site/songs:/app/solution/site/songs
|
- ./solution/site/songs:/app/out/songs
|
||||||
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
|
||||||
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
|
||||||
- ./postgres-init:/app/postgres-init:ro
|
- ./postgres-init:/app/postgres-init:ro
|
||||||
# ★ GSC_CREDENTIALS_HOST_FILE 이 비어 있으면 /dev/null 을 마운트한다 — 빈 문자열을 그대로
|
|
||||||
# 쓰면 컴포즈 볼륨 문법이 깨진다. GSC_ENABLED=0 이면 이 파일은 아예 안 읽으므로 무해하다.
|
|
||||||
- ${GSC_CREDENTIALS_HOST_FILE:-/dev/null}:/app/secrets/gsc-credentials.json:ro
|
|
||||||
ports:
|
ports:
|
||||||
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
|
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@ -78,17 +64,13 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "5" }
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
# ★ BUILD·ROLLBACK 잡이 렌더러(solution/site)를 subprocess 로 직접 돌린다
|
|
||||||
# (services/render_service.py) — 예전에 solution-prerender 컨테이너가 하던 일이다.
|
|
||||||
# 그래서 이 서비스만 **다른 이미지**(Dockerfile.worker, Node 런타임 + 컴파일된 렌더러
|
|
||||||
# 포함)를 쓴다. solution-backend·admin-backend 는 그 Node 를 쓸 일이 없다.
|
|
||||||
solution-worker:
|
solution-worker:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: solution/backend/Dockerfile.worker
|
dockerfile: solution/backend/Dockerfile
|
||||||
image: o2o-web4ai-worker
|
image: o2o-web4ai-backend
|
||||||
container_name: o2o-web4ai-solution-worker
|
container_name: o2o-web4ai-solution-worker
|
||||||
command: ["sh", "-c", "node /app/solution/site/dist/prerender/prerender.js --seed-assets --out=/app/solution/site/out && exec python worker_main.py"]
|
command: ["python", "worker_main.py"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@ -97,20 +79,15 @@ services:
|
|||||||
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-1}
|
||||||
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
JOB_DEADLINE_SEC: ${JOB_DEADLINE_SEC:-900}
|
||||||
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
JOB_LEASE_SEC: ${JOB_LEASE_SEC:-120}
|
||||||
# 렌더러 subprocess 가 굽는 동안 기다리는 시간(사진 내려받기 포함). BUILD 의
|
|
||||||
# job_deadline_sec 보다 짧아야 한다 — 안 그러면 잡 전체가 먼저 타임아웃된다.
|
|
||||||
RENDER_TIMEOUT_SEC: ${RENDER_TIMEOUT_SEC:-180}
|
|
||||||
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
# 이미지의 HEALTHCHECK 는 API 용(HTTP :9800)이다. 워커는 포트가 없어 그대로 두면 늘 unhealthy 다.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: true
|
disable: true
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/solution/site/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 렌더러가
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
- ./solution/site/songs:/app/solution/site/songs
|
- ./solution/site/songs:/app/out/songs
|
||||||
# ★ 이제 이 컨테이너가 굽는 쪽이다 — 읽기전용이 아니다(예전 solution-prerender 가
|
- site-out:/app/out/sites:ro
|
||||||
# 쓰던 자리를 그대로 이어받는다).
|
|
||||||
- site-out:/app/solution/site/out
|
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
stop_grace_period: 300s
|
stop_grace_period: 300s
|
||||||
@ -147,10 +124,10 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
retries: 3
|
retries: 3
|
||||||
volumes:
|
volumes:
|
||||||
- ./solution/site/payloads:/app/solution/site/payloads
|
- ./solution/site/payloads:/app/out/payloads
|
||||||
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 프리렌더가
|
||||||
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
# 사이트 디렉토리로 복사한다(services/song_service · site/scripts/prerender.ts).
|
||||||
- ./solution/site/songs:/app/solution/site/songs
|
- ./solution/site/songs:/app/out/songs
|
||||||
ports:
|
ports:
|
||||||
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
# ★ 내부망에만 연다. 0.0.0.0 으로 열면 API 를 가른 의미가 없다.
|
||||||
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
- "${ADMIN_API_BIND:-127.0.0.1}:${ADMIN_API_PORT_PUBLIC:-9801}:9801"
|
||||||
@ -163,6 +140,48 @@ services:
|
|||||||
driver: json-file
|
driver: json-file
|
||||||
options: { max-size: "10m", max-file: "5" }
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
|
# 발행 사이트를 굽는다. **굽기만 한다** — 서빙은 solution-site(nginx)가 맡는다.
|
||||||
|
#
|
||||||
|
# ★ 사장님 앱 dev 서버는 여기서 빠졌다. 운영에 `vite dev` 를 띄우면 요청마다 트랜스파일하고
|
||||||
|
# 기동이 npm install 네트워크에 의존하고 /src 원본이 그대로 나간다. 번들은 solution-site
|
||||||
|
# 이미지가 굽는다(nginx/Dockerfile). 로컬에서 HMR 이 필요하면 `--profile dev`.
|
||||||
|
solution-prerender:
|
||||||
|
image: node:24-alpine
|
||||||
|
container_name: o2o-web4ai-solution-prerender
|
||||||
|
working_dir: /app
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
cd /app
|
||||||
|
# ★ `-d node_modules` 로 판단하면 안 된다. 익명 볼륨은 빈 디렉토리로 이미 존재해서
|
||||||
|
# 설치를 건너뛰고 `vite: not found`(exit 127)로 죽는다.
|
||||||
|
[ -x node_modules/.bin/vite ] || npm install
|
||||||
|
exec node solution/site/scripts/watch-payloads.mjs
|
||||||
|
environment:
|
||||||
|
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||||
|
# ★ 발행 호스트를 프론트 .env 에 따로 적지 않는다 — 루트 .env 의 SITE_PUBLIC_HOST 를
|
||||||
|
# 그대로 흘려보낸다. 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
|
||||||
|
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||||
|
volumes:
|
||||||
|
- ./package.json:/app/package.json
|
||||||
|
- ./package-lock.json:/app/package-lock.json
|
||||||
|
- ./tsconfig.base.json:/app/tsconfig.base.json
|
||||||
|
- ./solution:/app/solution
|
||||||
|
- ./admin:/app/admin
|
||||||
|
# ★ node_modules 는 컨테이너 것을 쓴다. 호스트가 macOS(arm64-darwin)라 그 안의
|
||||||
|
# rollup·esbuild 네이티브 바이너리를 리눅스 컨테이너가 못 쓴다.
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/solution/site/node_modules
|
||||||
|
- /app/solution/frontend/node_modules
|
||||||
|
- /app/admin/frontend/node_modules
|
||||||
|
# ★ 산출물은 named volume. 호스트 경로면 재배포로 코드를 갈아엎는 순간 전 사이트가 404 다.
|
||||||
|
- site-out:/app/solution/site/out
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options: { max-size: "10m", max-file: "5" }
|
||||||
|
|
||||||
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
|
# 사장님 앱 dev 서버 + 발행본 정적서버(:3001). **로컬 전용**이다 — `--profile dev`.
|
||||||
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
|
# 운영에서 이게 뜨면 안 된다(위 solution-prerender 주석).
|
||||||
solution-frontend:
|
solution-frontend:
|
||||||
@ -258,10 +277,11 @@ services:
|
|||||||
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
|
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
|
||||||
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-localhost}
|
||||||
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
VITE_SITE_PREVIEW_URL: ${PUBLIC_WEB_BASE_URL:-http://localhost}
|
||||||
|
# ⚠️ 비어 있으면 자동 로그인은 아예 꺼진다(기본값 없음). 채우면 번들에 구워진다.
|
||||||
|
VITE_AUTO_LOGIN_ID: ${AUTO_LOGIN_ID:-}
|
||||||
|
VITE_AUTO_LOGIN_PW: ${AUTO_LOGIN_PW:-}
|
||||||
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
|
# 비어 있으면 구글 로그인 버튼이 안 뜬다. 백엔드 GOOGLE_CLIENT_ID 와 같은 값이다.
|
||||||
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
|
|
||||||
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
|
|
||||||
image: o2o-web4ai-solution-site
|
image: o2o-web4ai-solution-site
|
||||||
container_name: o2o-web4ai-solution-site
|
container_name: o2o-web4ai-solution-site
|
||||||
volumes:
|
volumes:
|
||||||
@ -274,7 +294,7 @@ services:
|
|||||||
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
|
# 앞단 프록시를 세울 거면 여기서 포트만 옮기고 프록시가 이쪽을 가리키게 한다.
|
||||||
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
|
- "${SITE_HTTP_BIND:-0.0.0.0}:${SITE_HTTP_PORT:-80}:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- solution-backend
|
- solution-prerender
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
|
|||||||
273
docs/AGENT.md
273
docs/AGENT.md
@ -1,273 +0,0 @@
|
|||||||
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
|
|
||||||
|
|
||||||
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지.
|
|
||||||
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
|
|
||||||
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
|
|
||||||
|
|
||||||
1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다.
|
|
||||||
남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.
|
|
||||||
|
|
||||||
## 화면 스위치
|
|
||||||
|
|
||||||
| 화면 | 여는 조건 | 지금 |
|
|
||||||
|---|---|---|
|
|
||||||
| 대화창(`AgentChatDock`) | `AGENT_CHAT_ENABLED=1`(기본) **그리고** LLM 키 | 열림 |
|
|
||||||
| 연결 카드(`KakaoChannelCard`) | `KAKAO_CHANNEL_PUBLIC_ID` 가 채워짐 | 채널 ID 미설정 |
|
|
||||||
|
|
||||||
★ 스위치와 키를 **둘 다** 본다(`runtime.is_configured`). 키만 보면 "잠시 닫아 두기" 를 키를
|
|
||||||
지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서
|
|
||||||
**눌러도 안 되는 입구**가 생긴다.
|
|
||||||
|
|
||||||
★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고,
|
|
||||||
인증이 끝나 2026-09-22 에 다시 열었다. **그때도 코드는 지우지 않고 값만 바꿨다** —
|
|
||||||
닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다.
|
|
||||||
|
|
||||||
★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대
|
|
||||||
판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고,
|
|
||||||
이쪽은 웹훅(4단계)이 없어 아직 연결이 **완성되지 않는다**.
|
|
||||||
|
|
||||||
## 왜 신원 연결이 먼저인가
|
|
||||||
|
|
||||||
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**다. 우리 `user_id` 와 아무 관계가 없다.
|
|
||||||
|
|
||||||
이 레포의 모든 엔드포인트는 `place_crud.get_place(s, owner_user_id, place_id)` 로
|
|
||||||
"없는 것과 남의 것을 똑같이 `PLACE_NOT_FOUND` 로 답하는" 관례를 지킨다. 채널에서 온 발화에는
|
|
||||||
그 `owner_user_id` 를 줄 근거가 없다 — **연결 절차가 없으면 채널 진입점만 소유자 범위 밖에
|
|
||||||
놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.**
|
|
||||||
|
|
||||||
## 절차 — 사장님은 두 번 누른다
|
|
||||||
|
|
||||||
1. `/sites` **내 사이트** 화면의 `카카오톡으로 관리 · 채널 연결` 카드 → **[카카오톡 연결]**
|
|
||||||
2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다
|
|
||||||
|
|
||||||
★ **연결 버튼을 사업장 화면에 두지 않는다.** 연결은 `user` 단위인데 버튼이 사업장 안에 있으면
|
|
||||||
사장님은 업장마다 연결해야 하는 줄 안다(`SocialConnectionCard` 가 같은 이유로 거기 있다).
|
|
||||||
|
|
||||||
★ `KAKAO_CHANNEL_PUBLIC_ID` 가 비면 **카드는 그리되 버튼이 죽는다.** 어디에 코드를 칠지
|
|
||||||
말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면
|
|
||||||
기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).
|
|
||||||
|
|
||||||
## 표 — `owner_kakao_links` (마이그레이션 0021)
|
|
||||||
|
|
||||||
`user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at`
|
|
||||||
|
|
||||||
| 인덱스 | 무엇을 막나 |
|
|
||||||
|---|---|
|
|
||||||
| `uq_kakao_link_user` (PENDING·LINKED) | 한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다 |
|
|
||||||
| `uq_kakao_link_channel_key` (LINKED) | ★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다 |
|
|
||||||
| `uq_kakao_link_code` (PENDING) | 코드 한 행 지목 |
|
|
||||||
|
|
||||||
**코드는 평문으로 저장하지 않는다**(`code_sha`). 사장님이 손으로 치는 짧은 값이라, 평문이면
|
|
||||||
DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 **화면에 한 번 뜨고 다시 볼 수 없다** —
|
|
||||||
카드는 항상 [코드 다시 받기] 를 함께 둔다.
|
|
||||||
|
|
||||||
**코드 글자에서 `0·O·1·I·L` 을 뺐다.** 잘못 읽어 실패하면 원인이 화면에 안 보이고
|
|
||||||
"연결이 안 된다" 로만 보인다.
|
|
||||||
|
|
||||||
## 일회성은 값이 아니라 CAS 가 보장한다
|
|
||||||
|
|
||||||
```sql
|
|
||||||
UPDATE owner_kakao_links
|
|
||||||
SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
|
|
||||||
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
|
|
||||||
AND code_expires_at > now() AND code_attempts < :max
|
|
||||||
RETURNING user_id;
|
|
||||||
```
|
|
||||||
|
|
||||||
조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).
|
|
||||||
|
|
||||||
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
|
|
||||||
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
|
|
||||||
|
|
||||||
## 코드는 웹훅에서만 소비된다
|
|
||||||
|
|
||||||
`redeem()` 은 **공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 —
|
|
||||||
검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| 메서드/경로 | 역할 |
|
|
||||||
|---|---|
|
|
||||||
| `GET /v1/agent/kakao/link` | 연결 상태. ★ 코드 평문은 주지 않는다 |
|
|
||||||
| `POST /v1/agent/kakao/link/code` | 일회용 코드 발급. 평문은 이 응답에서 한 번만 |
|
|
||||||
| `POST /v1/agent/kakao/link/disconnect` | 해제. 행은 `REVOKED` 로 남긴다 |
|
|
||||||
|
|
||||||
셋 다 `Cache-Control: no-store` · `Referrer-Policy: no-referrer` · `X-Robots-Tag: noindex` 다.
|
|
||||||
|
|
||||||
## 설정
|
|
||||||
|
|
||||||
```
|
|
||||||
KAKAO_CHANNEL_PUBLIC_ID= # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
|
|
||||||
KAKAO_LINK_CODE_TTL_MIN=10
|
|
||||||
KAKAO_LINK_MAX_ATTEMPTS=5
|
|
||||||
```
|
|
||||||
|
|
||||||
`config/agent_config.py` 는 `social_config.py` 와 **일부러 갈랐다.** SNS 게재는 되돌릴 수 없는
|
|
||||||
대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면
|
|
||||||
"이 값이 무엇을 여는가" 가 흐려진다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 2단계 — 도구 · 런타임 · 빌더 채팅창
|
|
||||||
|
|
||||||
`/sites` 화면 오른쪽 아래 **[말로 고치기]** 를 누르면 대화창이 열린다.
|
|
||||||
카카오 심사 없이 **에이전트 전체가 여기서 검증된다.**
|
|
||||||
|
|
||||||
## 겹
|
|
||||||
|
|
||||||
```
|
|
||||||
router/v1/agent/chat.py 빌더 화면 입구
|
|
||||||
router/v1/social/kakao_bot.py (4단계) 카톡 입구 — 같은 runtime.chat() 을 부른다
|
|
||||||
↓
|
|
||||||
services/agent/runtime.py 발화 → 도구 선택 → 실행 → 응답. ★ 채널을 모른다
|
|
||||||
services/agent/tools.py 레지스트리 — 할 수 있는 일의 전부 + 등급
|
|
||||||
↓
|
|
||||||
services/fact_service.py · site_service.py ★ 게이트가 사는 곳
|
|
||||||
```
|
|
||||||
|
|
||||||
`services/prompts/agent.py` 가 "무엇을 묻는가" 를 갖는다(LLM 네 겹 규약, `services/llm/__init__.py`).
|
|
||||||
|
|
||||||
## 도구와 등급
|
|
||||||
|
|
||||||
| 등급 | 도구 | 대화에서 |
|
|
||||||
|---|---|---|
|
|
||||||
| `READ` | `get_site_status` · `list_facts` | 바로 답한다 |
|
|
||||||
| `REVERSIBLE` | `set_fact` | 실행하고 알린다 |
|
|
||||||
| `SEMI` | `publish` | **실행 전에 한 번 묻는다** |
|
|
||||||
|
|
||||||
★ **등급은 레지스트리가 못 박는다.** 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인
|
|
||||||
절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.
|
|
||||||
|
|
||||||
★ **결과 문구는 도구가 만든다.** LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
|
|
||||||
사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
|
|
||||||
|
|
||||||
★ **값을 고치면 재발행 안내를 함께 낸다.** fact 는 바뀌어도 사이트는 안 바뀐다 —
|
|
||||||
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
|
||||||
|
|
||||||
★ **모호하면 실행하지 않고 되묻는다.** 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시"
|
|
||||||
로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.
|
|
||||||
|
|
||||||
## 확인(SEMI) 한 바퀴
|
|
||||||
|
|
||||||
1. 발화 → 런타임이 `publish` 를 고른다 → **실행하지 않고** `needs_confirm=true` + 확인 문구
|
|
||||||
2. 화면이 [네, 해주세요] 를 띄운다
|
|
||||||
3. 누르면 `{confirm:{tool,args}}` 로 다시 POST → LLM 을 부르지 않고 그 도구를 실행
|
|
||||||
|
|
||||||
★ 서버는 돌아온 값을 **믿지 않는다.** 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
|
|
||||||
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다.
|
|
||||||
`READ` 등급은 확인 경로로 들어올 수 없다(`AGENT_UNKNOWN_TOOL`).
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| 메서드/경로 | 역할 |
|
|
||||||
|---|---|
|
|
||||||
| `GET /v1/agent/status` | 대화창을 열 수 있는지(LLM 키 유무) |
|
|
||||||
| `POST /v1/agent/chat/{place_id}` | `{message}` 또는 `{confirm:{tool,args}}` |
|
|
||||||
|
|
||||||
소유자 범위는 다른 엔드포인트와 같다 — 남의 `place_id` 는 **없는 것과 똑같이**
|
|
||||||
`PLACE_NOT_FOUND` 다. 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.
|
|
||||||
|
|
||||||
## 다음 단계
|
|
||||||
|
|
||||||
| | 내용 | 심사 |
|
|
||||||
|---|---|---|
|
|
||||||
| 3 | 도구를 더 연다 — 사진 내리기 · 섹션 켜고 끄기 · 검색 노출 조회 | 없음 |
|
|
||||||
| 4 | 카카오 채널 웹훅을 **입구로 추가**(서명 검증 + `redeem` 연결) | 채널 + 챗봇 |
|
|
||||||
|
|
||||||
★ 도구를 늘릴 때도 **반드시 `services/*` 를 통과한다.** `crud` 를 직접 부르면 업종 스키마
|
|
||||||
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
|
|
||||||
`collect_service.store_facts` 가 크롤러에 걸어 둔 문과 같은 문이고,
|
|
||||||
`tests/test_agent_runtime.py` 가 소스에서 그 호출이 없는지 실제로 검사한다.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# 4단계 — 카카오 채널 웹훅
|
|
||||||
|
|
||||||
```
|
|
||||||
router/v1/agent/kakao_bot.py 시크릿 검증 · 카카오 형식 ↔ 우리 모양 ← 카카오를 아는 유일한 파일
|
|
||||||
services/agent/channel.py 신원 · 가게 고르기 · 확인 이어받기 ← 카카오를 모른다
|
|
||||||
services/agent/runtime.py 그대로 — 채널을 모른다
|
|
||||||
```
|
|
||||||
|
|
||||||
## ★★ 인증 — 오픈빌더는 서명을 주지 않는다
|
|
||||||
|
|
||||||
URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.user.id` 를 아무 값이나 넣으면
|
|
||||||
**그 사장님 행세를 한다.** 신원 연결이 통째로 무의미해지는 자리다.
|
|
||||||
|
|
||||||
| 겹 | 방법 |
|
|
||||||
|---|---|
|
|
||||||
| 1 | 공유 시크릿 — 헤더 `X-Agent-Secret` (`hmac.compare_digest`) |
|
|
||||||
| 2 | `KAKAO_BOT_ID` 대조 (시크릿이 아니라 오발송을 거르는 용도, 비워도 됨) |
|
|
||||||
| 3 | 헤더를 못 넣을 때만 경로 시크릿 `/webhook/{secret}` — **최후 수단**, 경로는 로그에 남는다 |
|
|
||||||
|
|
||||||
★ `KAKAO_WEBHOOK_SECRET` 이 비면 **엔드포인트가 404 다.** 401 로 답하면 "여기 뭔가 있다" 를
|
|
||||||
알려 준다. 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다.
|
|
||||||
|
|
||||||
## 빌더 화면과 다른 것 셋
|
|
||||||
|
|
||||||
| | 빌더 화면 | 카카오톡 |
|
|
||||||
|---|---|---|
|
|
||||||
| 신원 | 로그인 토큰 | 연결된 발화자 키 → `user_id` (★ **토큰을 발급하지 않는다**) |
|
|
||||||
| 대상 | `place_id` 가 URL 에 | 대화에서 고르고 `current_place_id` 에 기억 |
|
|
||||||
| 확인 | 프론트가 `{confirm}` 을 되돌려 줌 | **서버가 무엇을 물었는지 들고 있는다** |
|
|
||||||
|
|
||||||
★ **연결되자마자 홈페이지 목록을 보여준다.** 연결만 알리고 끝내면 사장님은 어느 홈페이지를
|
|
||||||
다루는 대화인지 모른 채 말을 걸게 된다. 목록에는 **발행 여부**를 같이 적는다 — 안 그러면
|
|
||||||
고친 것이 손님에게 보이는 줄 안다.
|
|
||||||
|
|
||||||
★ 가게가 여럿이면 **바로가기 버튼으로 고르게 한다.** 이름을 외워 치게 하지 않는다.
|
|
||||||
임의로 첫 가게를 고르지도 않는다 — 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다.
|
|
||||||
"목록"·"가게 바꿔줘" 같은 말로 **언제든 돌아와 바꿀 수 있고**, 이 경로는 LLM 을 부르지 않는다
|
|
||||||
(대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유도 없다).
|
|
||||||
|
|
||||||
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.**
|
|
||||||
다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.
|
|
||||||
|
|
||||||
★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
|
|
||||||
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
|
|
||||||
|
|
||||||
## 5초 벽 — 콜백으로 넘는다
|
|
||||||
|
|
||||||
오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
|
|
||||||
|
|
||||||
★ **실측(2026-09-22): 실제 프롬프트는 4초를 넘겼다.** 개발 중 잰 1.3~2.4초는 항목 두 개짜리
|
|
||||||
장난감 프롬프트였고, 진짜는 업종 필드 43개 + fact 수십 개가 실린다. 작은 표본으로 잰 수치를
|
|
||||||
상한 근거로 삼으면 이렇게 틀린다.
|
|
||||||
|
|
||||||
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
|
|
||||||
|
|
||||||
```
|
|
||||||
카카오 → 우리 발화 + callbackUrl
|
|
||||||
우리 → 카카오 {"version":"2.0","useCallback":true,"data":{"text":"확인하고 있어요…"}} (즉답)
|
|
||||||
… 백그라운드에서 답을 만든다 (상한 45초)
|
|
||||||
우리 → 카카오 POST callbackUrl {"version":"2.0","template":{…}} (완성분)
|
|
||||||
```
|
|
||||||
|
|
||||||
★ 콜백 주소는 **1분 · 1회**만 유효하다. 전송에 실패해도 **재시도하지 않는다** — 두 번째 POST 는
|
|
||||||
어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.
|
|
||||||
|
|
||||||
★ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC = 4.5` 로 끊는다.
|
|
||||||
무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 어느 쪽에서도 걸리지 않는다.
|
|
||||||
|
|
||||||
★ 어떤 실패도 **HTTP 200 + 안내 문구**로 답한다. 메신저에서는 500 도 침묵으로 보인다.
|
|
||||||
|
|
||||||
## 설정
|
|
||||||
|
|
||||||
```
|
|
||||||
KAKAO_WEBHOOK_SECRET= # 비면 웹훅이 404. python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
KAKAO_BOT_ID= # 선택
|
|
||||||
KAKAO_CHANNEL_PUBLIC_ID= # 채워야 연결 카드가 뜬다(채널 검색용 아이디, `_` 로 시작)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 오픈빌더에 등록할 주소
|
|
||||||
|
|
||||||
```
|
|
||||||
https://<발행호스트>/v1/agent/kakao/webhook
|
|
||||||
```
|
|
||||||
|
|
||||||
★ 스킬 설정에서 **커스텀 헤더**를 넣을 수 있으면 `X-Agent-Secret` 을 쓰고, 못 넣으면
|
|
||||||
`/v1/agent/kakao/webhook/<시크릿>` 을 쓴다.
|
|
||||||
|
|
||||||
★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
|
|
||||||
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
# 장애 알림 (2026-09-15)
|
|
||||||
|
|
||||||
구현: `services/alert_service.py`(적재·재시도·중복 억제) · `services/teams_webhook.py`(전송) ·
|
|
||||||
`worker/runner.py` · `services/build_service.py` · `services/rollback_service.py`(발생 지점) ·
|
|
||||||
`scheduler/jobs.py`(발송·큐 정체 스윕). 전용 컨테이너 없음 — 기존 API·워커 프로세스가 한다.
|
|
||||||
|
|
||||||
## 무엇을 알리나
|
|
||||||
|
|
||||||
| kind | 언제 | dedupe_key |
|
|
||||||
|---|---|---|
|
|
||||||
| `job_dead` | 잡이 재시도를 소진해 DEAD | `job_dead:{JobType}:{place_id 또는 job_id}` |
|
|
||||||
| `build_failed` | BUILD·ROLLBACK 이 **게이트 반려가 아닌** 렌더·인프라 실패로 끝남 | `build_failed:{place_id}` |
|
|
||||||
| `partial_failure` | 노래 등 곁가지 생성 실패(발행 자체는 계속) | `song_failed:{place_id}` |
|
|
||||||
| `queue_stuck` | dead-letter 누적·좀비 실행·PENDING 30분 이상 정체 | `queue_health` |
|
|
||||||
| `recovery` | 위 dedupe_key 가 다음 정상 상태에서 풀릴 때 한 번 | 없음(매번 새 행) |
|
|
||||||
|
|
||||||
★ **게이트 반려는 알리지 않는다.** 사장님이 fact 를 안 채웠거나 고유 콘텐츠가 없어서 막힌 건
|
|
||||||
운영자가 손댈 일이 아니다 — `build_service._fail(reason, gate=None)` 일 때만 `build_failed`.
|
|
||||||
|
|
||||||
## 중복 억제·재시도
|
|
||||||
|
|
||||||
`send_alert(kind, title, detail, dedupe_key)` — 같은 dedupe_key 로 "안 풀린"(resolved_at
|
|
||||||
NULL) 알림이 이미 있으면 새로 만들지 않는다. `resolve_alert(dedupe_key, ...)` 가 그 알림을
|
|
||||||
풀고 복구 알림을 한 번 보낸다. 실제 전송은 `scheduler.jobs.sweep_alert_outbox`(1분마다) —
|
|
||||||
실패하면 `crud/job_crud.compute_backoff` 와 같은 백오프로 최대 5회 재시도 후 `FAILED`(소진)로
|
|
||||||
멈춘다. `TEAMS_WEBHOOK_URL` 이 비어 있으면 적재만 되고 전송은 안 나간다(서버 동작엔 영향 없음).
|
|
||||||
|
|
||||||
`detail` 은 저장 **전에** `alert_service._scrub` 이 쿼리스트링 키·Bearer 토큰·`password=` 류·
|
|
||||||
이메일을 마스킹한다 — 외부 API 예외 메시지가 URL 에 키를 실어 보내는 경우가 있다.
|
|
||||||
|
|
||||||
## 설정
|
|
||||||
|
|
||||||
```
|
|
||||||
TEAMS_WEBHOOK_URL= # Teams Workflows 수신 webhook. 비우면 알림이 DB(alert_outbox)에
|
|
||||||
# 쌓이기만 하고 안 나간다 — 서버는 그대로 뜬다.
|
|
||||||
ALERT_DEDUPE_WINDOW_MIN=60
|
|
||||||
```
|
|
||||||
|
|
||||||
`GSC_ALERT_WEBHOOK_URL`(search_console_alerts.py)과는 **다른 값**이다 — 색인 감시 전용과
|
|
||||||
이 잡 큐·발행 알림은 목적이 달라 의도적으로 분리했다(services/teams_webhook.py 머리주석).
|
|
||||||
|
|
||||||
## 서버·DB 전체 장애 — 이 알림 체계로는 못 잡는다
|
|
||||||
|
|
||||||
`alert_service`·`scheduler`가 도는 프로세스 자체가 죽으면(서버 다운·DB 완전 단절) 이 체계는
|
|
||||||
자기 장애를 자기가 못 알린다. **외부 감시가 필요하다** — uptime 모니터 등에서 주기적으로
|
|
||||||
`GET /readyz` 를 찌른다(`router/router.py`). `/healthz` 와 다르다: `/healthz` 는 프로세스
|
|
||||||
생존만(항상 200), `/readyz` 는 **DB 에 실제로 `SELECT 1` 을 던져** 200/503 을 가른다.
|
|
||||||
|
|
||||||
절차:
|
|
||||||
1. 외부 모니터가 `https://<host>/readyz` 를 1~5분 간격으로 확인한다.
|
|
||||||
2. 2xx 가 아니거나 타임아웃이면 **그 모니터 자신의 채널**로 알린다 — 이 레포의
|
|
||||||
`TEAMS_WEBHOOK_URL` 로 보내면 안 된다(webhook 이 죽은 서버 안에 있을 수 있다).
|
|
||||||
3. 이 모니터의 실제 설정(어느 서비스·어느 채널)은 이 세션에서 만들지 않았다 — 운영 계정·
|
|
||||||
외부 서비스 연결은 사용자 승인 후 진행한다.
|
|
||||||
|
|
||||||
## 아직 안 한 것 — 운영 미적용
|
|
||||||
|
|
||||||
- 실제 Teams Workflows webhook 생성·채널 지정 — mock 테스트만 했다(tests/test_alert_service.py).
|
|
||||||
- 외부 uptime 모니터 실제 연결(2절 3번).
|
|
||||||
- 마이그레이션(`0016_alert_outbox.sql`) 서버 적용.
|
|
||||||
- `alert_outbox` 오래된 SENT/FAILED 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).
|
|
||||||
@ -79,19 +79,3 @@ DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호
|
|||||||
|
|
||||||
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
|
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
|
||||||
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
`place_id` · 시각)를 얹어두면, 이 문서를 손으로 갱신하지 않고 쿼리로 뽑을 수 있다.
|
||||||
|
|
||||||
## 5. SNS 비용 (2026-09-14)
|
|
||||||
|
|
||||||
사용자 결정: X 대신 Threads. [Meta 공식 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)에 직접 API 건당 과금·유료 티어가 안내돼 있지 않다. 현재 0원으로 분리 기록하되 영구 무료로 약속하지 않는다.
|
|
||||||
X는 현재 [URL 포함 생성 $0.20/요청](https://docs.x.com/x-api/getting-started/pricing)을 안내하므로 고정비라는 기존 계획을 폐기했다. X 어댑터는 넣지 않는다.
|
|
||||||
|
|
||||||
| 비용 | 처리 |
|
|
||||||
|---|---|
|
|
||||||
| 사이트당 변동비 | Gemini 초안, 알림톡 발송. 초안 최대 3회 재요청, 버전당 원고 1건. 알림톡 단가 미확정이라 활성화 전 계약 확인 |
|
|
||||||
| 계정/계약당 고정비 | 계약에 있다면 별도 운영비. 사이트 생성 CostMeter에 배분하지 않음 |
|
|
||||||
| 개발비 | 일회성 구현·심사 대응 비용. 사이트 원가와 분리 |
|
|
||||||
|
|
||||||
`Provider.THREADS`는 0원, 공개 가격 확정 근거가 없어 confirmed=False로 기록한다.
|
|
||||||
월 고정비라는 잘못된 근거로 confirmed=True를 넣지 않는다. `assert_rates_confirmed()`에 Threads를 포함하는
|
|
||||||
실배치는 운영 과금 확인 전 차단된다. 현재 SNS 서비스는 생성 호출 횟수 상한만 강제하며 누적 사이트 예산 연동은
|
|
||||||
별도 보완이 필요하다. Gemini 비용을 무료로 간주하지 않는다.
|
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
# ARCHITECTURE
|
# ARCHITECTURE
|
||||||
|
|
||||||
> 2026-09-15: 현재 발행 실행·산출물 버전·배포는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
|
|
||||||
> 아래의 별도 프리렌더 컨테이너·폴링·전체 재굽기 설명은 이전 구조다.
|
|
||||||
|
|
||||||
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
|
제품 판단은 [PRODUCT.md](PRODUCT.md), 배포 절차는 [DEPLOY.md](DEPLOY.md),
|
||||||
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
|
에이전트가 밟기 쉬운 함정 목록은 [AGENTS.md](../AGENTS.md). 여기는 **구조와 경계**만 다룬다.
|
||||||
|
|
||||||
@ -72,10 +69,6 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
|
|||||||
|
|
||||||
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
|
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
|
||||||
|
|
||||||
Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해
|
|
||||||
사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다.
|
|
||||||
선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다.
|
|
||||||
|
|
||||||
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
|
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
|
||||||
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** —
|
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** —
|
||||||
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
|
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.
|
||||||
|
|||||||
@ -7,15 +7,13 @@
|
|||||||
여기서는 그 앞뒤를 잇는다.
|
여기서는 그 앞뒤를 잇는다.
|
||||||
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
|
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
|
||||||
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
|
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
|
||||||
- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다.
|
|
||||||
컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md).
|
|
||||||
|
|
||||||
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
|
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
|
||||||
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
|
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 0. 표 17개, 스키마는 `public` 한 벌
|
## 0. 표 15개, 스키마는 `public` 한 벌
|
||||||
|
|
||||||
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
|
||||||
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
|
||||||
@ -30,15 +28,13 @@ users 사장님 계정
|
|||||||
│ ├ place_facts ★ 사실. 이 제품의 심장
|
│ ├ place_facts ★ 사실. 이 제품의 심장
|
||||||
│ ├ place_faqs FAQ
|
│ ├ place_faqs FAQ
|
||||||
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
|
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
|
||||||
│ ├ place_social_posts SNS 게재 글 — 초안 → 승인 → 게시 (사장님이 누를 때만)
|
|
||||||
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
|
||||||
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
|
||||||
└ sites 발행 사이트 — 사업장당 1개
|
└ sites 발행 사이트 — 사업장당 1개
|
||||||
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
|
||||||
├ site_versions ★ 빌드 버전 — snapshot 박제
|
├ site_versions ★ 빌드 버전 — snapshot 박제
|
||||||
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
|
||||||
owner_social_accounts 사장님이 연결한 SNS 계정 — ★ 위임받은 토큰을 보관하는 유일한 표
|
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
|
||||||
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래 · SNS
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
|
||||||
@ -126,23 +122,6 @@ jobs 작업 큐 — 수집 · 비전 · 소개문 ·
|
|||||||
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
|
활성 유니크는 `(place, unit, key)` 당 **노출값 1건**이다(status 3·4 부분 인덱스).
|
||||||
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
|
후보(1·2)와 이력(5·6)은 여러 건 공존한다 — 재수집이 쌓일 수 있어야 하기 때문이다.
|
||||||
|
|
||||||
**수집값은 빈 자리에 바로 노출값(VERIFIED)으로 들어간다** (2026-09-14, `services/fact_service`).
|
|
||||||
예전에는 크롤링 값이 전부 UNVERIFIED 후보였다. 그러면 수집 직후 발행이 "확인된 사실 0건" 으로
|
|
||||||
막혀, 사장님이 한 건씩 승인하기 전에는 사이트가 만들어지지 않았다 — 수집이 끝난 뒤에야 오는
|
|
||||||
값이라 승인할 화면을 이미 지나가 있었다.
|
|
||||||
|
|
||||||
지금 규칙은 **누가 그 자리를 이미 차지했는지**로 갈린다.
|
|
||||||
|
|
||||||
| 그 key 의 현재 노출값 | 수집값이 오면 |
|
|
||||||
|---|---|
|
|
||||||
| 없음 | 바로 노출값(VERIFIED). `verified_by` 는 **비운다** — 사람이 승인한 이력과 구별된다 |
|
|
||||||
| 같은 값 | REFRESHED — 확인 시각만 갱신. 검증을 초기화하지 않는다 |
|
|
||||||
| 사장님이 넣은 값(OWNER) · 정정본(CORRECTED) | 덮지 않는다. PENDING_OWNER **후보**로 쌓여 사람이 고른다 |
|
|
||||||
| 앞선 수집값 | 새 값이 노출값 자리를 가져간다(옛 값은 EXPIRED 이력) |
|
|
||||||
|
|
||||||
즉 자동이 사람을 덮지 못한다는 보호(절대규칙 6)는 그대로이고, 자동끼리는 최신값이 이긴다.
|
|
||||||
UNVERIFIED 는 이제 공식 API 수집이 빈 자리에 넣을 때 생긴다.
|
|
||||||
|
|
||||||
### `place_channels` — 크롤링 대상 URL
|
### `place_channels` — 크롤링 대상 URL
|
||||||
|
|
||||||
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
|
`confirmed_at` 이 NULL 이면 **크롤링하지 않는다.** 카카오 로컬로 동일 업소임을 확인한 URL 만 넘긴다.
|
||||||
@ -183,26 +162,6 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
|||||||
|
|
||||||
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
|
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `latest_ready` 가 `READY` 중 최신 하나를 고른다.
|
||||||
|
|
||||||
### `place_social_posts` · `owner_social_accounts` — SNS 게재
|
|
||||||
|
|
||||||
사장님이 [SNS에 알리기] 를 누를 때만 생긴다. 발행의 부수효과가 아니다 — 발행은 우리 화면을
|
|
||||||
굽는 일이고, 이건 **사장님이 자기 이름으로 하는 말**이다(DECISIONS 8절).
|
|
||||||
|
|
||||||
★ **승인 대기는 잡이 아니라 이 표의 상태다.** 잡으로 매달면 lease(120초)가 만료돼 reaper 가
|
|
||||||
회수하고 attempts 가 올라 결국 DEAD 가 된다. 큐는 "지금 할 일" 만 표현한다.
|
|
||||||
상태: `DRAFTING → PENDING_APPROVAL → APPROVED → POSTING → POSTED`(+ `DECLINED`·`EXPIRED`·
|
|
||||||
`FAILED`·`UNKNOWN`). **발행본에는 `POSTED` 만 나간다.**
|
|
||||||
|
|
||||||
★ `POSTING` 이 10분 넘게 남아 있으면 `UNKNOWN` 으로 내린다 — **시간을 근거로 `APPROVED` 로
|
|
||||||
되돌리지 않는다.** 외부가 이미 받았을 수 있고, 되돌리면 같은 글이 두 번 올라간다.
|
|
||||||
|
|
||||||
★ `approval_token_sha` 는 **해시만** 저장한다(원문은 링크에만 있다). 일회성은 토큰이 아니라
|
|
||||||
`status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다(DECISIONS 8-3).
|
|
||||||
|
|
||||||
★ `owner_social_accounts` 는 **place 가 아니라 user 에 붙는다.** 계정은 사람의 것이고, 사장님이
|
|
||||||
업장을 둘 가져도 계정은 하나다. 토큰은 `SOCIAL_TOKEN_SECRET` 으로 암호화해 넣는다 —
|
|
||||||
이 표만이 위임받은 자격증명을 담는다(`place_channels` 는 공개 URL 목록이라 섞지 않는다).
|
|
||||||
|
|
||||||
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
### `area_contents` + `place_area_refs` — 지역 콘텐츠
|
||||||
|
|
||||||
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
★ **키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
|
||||||
@ -211,7 +170,7 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 WEATHER | Open-Meteo | — |
|
| 1 WEATHER | Open-Meteo | — |
|
||||||
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
|
| 2 FESTIVAL · 3 ATTRACTION · 4 RESTAURANT · 5 COURSE | TourAPI (좌표 반경) | — |
|
||||||
| 6 STORY | Perplexity | `songs` `daily` `people` `chronicle` `reading` `postcard` `quiz` |
|
| 6 STORY | Perplexity | `songs` `people` `chronicle` `postcard` `quiz` |
|
||||||
|
|
||||||
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
|
`body`(JSONB)에 항목이 들어간다. **지역 이야기는 종류당 한 행**이고 항목들은 `body.items` 안에 있다.
|
||||||
|
|
||||||
@ -269,9 +228,6 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
|
|||||||
|
|
||||||
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
|
### `jobs` — 작업 큐 (PostgreSQL 을 큐로)
|
||||||
|
|
||||||
COPY 단계는 `jobs.progress`(JSONB)의 `steps`·`attempt`에 기록한다.
|
|
||||||
생성 화면 복구와 모듈별 책임은 [GENERATION_FLOW.md](GENERATION_FLOW.md).
|
|
||||||
|
|
||||||
| `job_type` | 핸들러 | 하는 일 |
|
| `job_type` | 핸들러 | 하는 일 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
|
| 1 COLLECT | `collect_service.run_collect` | 채널 발견 → 검증 → 크롤링 → fact·사진 적재 |
|
||||||
@ -352,15 +308,3 @@ cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common
|
|||||||
|
|
||||||
2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
|
2026-09-09 에 이걸 안 돌려서 19건이 남았고, 가게 등록 · 수집 시작 · 수집 완료 세 곳이 연달아
|
||||||
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
|
죽었다. 기동은 정상이라 로그를 열기 전에는 안 보였다.
|
||||||
|
|
||||||
## SNS (2026-09-14)
|
|
||||||
|
|
||||||
| 표 | 키·범위 | 데이터·인덱스 |
|
|
||||||
|---|---|---|
|
|
||||||
| owner_social_accounts (0012) | account_id, user_id/provider | provider_user_id·handle·profile_url, 암호화 access/refresh token·만료·scopes·status·last_error. deleted=false, linked/needs_reauth인 user/provider 부분 유니크 |
|
|
||||||
| place_social_posts (0013) | post_id, place_id/user_id/site_version_id | 승인 계정 account_id, provider·본문·고정 URL·grounded_facts, nonce 해시·시각·채널, 게시 ID·permalink·posted_at·last_error. 같은 place/version은 삭제 전까지 유니크. POSTED 최신 조회 인덱스 |
|
|
||||||
|
|
||||||
Provider 1=X 예약값(구현 없음), 2=Threads. 상태는 DRAFTING/DRAFT/PENDING_APPROVAL/APPROVED/POSTING/POSTED/DECLINED/EXPIRED/FAILED/UNKNOWN.
|
|
||||||
DRAFT는 복사 가능한 작성 완료 원고, UNKNOWN은 중복 방지를 위한 수동 확인 상태다.
|
|
||||||
SNS 승인 CAS와 잡 삽입은 같은 트랜잭션. SOCIAL_DRAFT=8, SOCIAL_POST=9, 승인 대기는 잡이 아니다.
|
|
||||||
POSTED 최신 3건만 snapshot → payload.socialPosts로 전달한다. 자격증명·nonce·근거 원문은 제외한다.
|
|
||||||
|
|||||||
@ -34,29 +34,8 @@
|
|||||||
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
|
| 결론이 "불가"일 때 | 폴백 3단계로 간다 — ① 공식 API → ② 사장님이 직접 붙여넣기 → ③ 최소 정보로 생성 + 보완 요청. **생성 자체는 실패시키지 않는다** |
|
||||||
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
|
| 확정 사항 | 캡차 우회 · 봇 탐지 우회 · IP 회전은 **결론과 무관하게 금지**. 구현하지 않는다 |
|
||||||
|
|
||||||
**변경 (2026-09-14 / 확인 2026-09-15) — NOL 전용 어댑터를 등록한다.**
|
|
||||||
위 표의 "야놀자·여기어때 불가" 와 "Playwright 어댑터는 등록하지 않는다" 를 **한 패턴에 한해**
|
|
||||||
연다. 무엇을 열고 무엇을 안 여는지는 정확히 이렇다.
|
|
||||||
|
|
||||||
| | 지금 |
|
|
||||||
|---|---|
|
|
||||||
| `nol.yanolja.com/stay/domestic/<id>` | **전용 어댑터 `yanolja`** 가 Playwright 로 렌더해 읽는다. 기본 활성 |
|
|
||||||
| 그 밖의 `yanolja.com` · `goodchoice.kr` 전부 | **막는다.** 범용 HTML 어댑터의 `_DENY_HOSTS` 에 그대로 있다 |
|
|
||||||
| 캡차 우회 · 봇 탐지 우회 · IP 회전 | **여전히 금지.** 차단되면 그대로 실패로 돌린다 |
|
|
||||||
|
|
||||||
- 레지스트리가 `yanolja` 를 `static_html` 보다 **앞에** 등록하므로 그 한 패턴만 전용 경로로 가고
|
|
||||||
나머지는 예전처럼 `AdapterNotFound` 로 끊긴다. 순서가 곧 이 경계다.
|
|
||||||
- ★ 실측(2026-09-15): 어댑터를 들이면서 `static_html` 의 `_DENY_HOSTS` 에서 `yanolja.com` ·
|
|
||||||
`goodchoice.kr` 이 함께 빠져 있었다. 그러면 전용 어댑터가 아니라 **범용 HTML 수집기가**
|
|
||||||
두 플랫폼을 받는다 — 전용 경로 하나를 여는 것과 범용 수집을 그 플랫폼에 푸는 것은 다른
|
|
||||||
일이라, 차단 목록과 그 법무 근거 주석을 되돌렸다.
|
|
||||||
- 민사 10억 선례(서울중앙지법 2021-08)는 그대로다. **재게시 범위는 1-2 가 따로 정한다** —
|
|
||||||
이 항목은 "읽을 수 있나" 까지만 정하고 "다시 실어도 되나" 는 정하지 않는다.
|
|
||||||
|
|
||||||
### 1-2. 크롤링한 **이미지**의 재게시 권리
|
### 1-2. 크롤링한 **이미지**의 재게시 권리
|
||||||
|
|
||||||
2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다.
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | **미결** |
|
| 상태 | **미결** |
|
||||||
@ -79,8 +58,6 @@
|
|||||||
|
|
||||||
### 1-4. 해지 시 사이트 처리 정책
|
### 1-4. 해지 시 사이트 처리 정책
|
||||||
|
|
||||||
2026-09-14: SNS 운영 게재의 선행조건으로 승격. 외부 링크는 남으므로 UNPUBLISHED는 안내+연락처 페이지여야 한다. 현재 상태 전이만 있고 안내 페이지 생성은 미구현이므로 자동 게재 플래그는 기본 OFF다. 사장님 글을 자동 삭제하지 않는다. 함께 삭제할지는 별도 명시적 선택이며 현재 삭제 API는 제공하지 않는다.
|
|
||||||
|
|
||||||
| 항목 | 내용 |
|
| 항목 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 상태 | **미결** |
|
| 상태 | **미결** |
|
||||||
@ -308,78 +285,6 @@ LLM 만 그 경로를 지나가게 되면서 `fact_service.upsert_fact` 에 잠
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7-1. 사장님 명의의 SNS 발화는 별도 승인 (2026-09-14)
|
|
||||||
|
|
||||||
Threads 우선. 상세 흐름·활성화 전제는 [SOCIAL.md](SOCIAL.md).
|
|
||||||
|
|
||||||
| 기준 | 우리 발행본(7절) | SNS 게재 |
|
|
||||||
|---|---|---|
|
|
||||||
| 명의 | 우리 사이트 | 사장님 개인 계정 |
|
|
||||||
| 회수 | 에디터 수정 후 재빌드 | 플랫폼 사본·인용·캐시를 회수할 수 없음 |
|
|
||||||
| 주요 오류 | 문장 내용, 앞의 사실 게이트 | 명의·주소, LLM이 결정하지 않는 값 |
|
|
||||||
|
|
||||||
폰에서 로그인 없이 확인하고, 화면과 알림톡 두 경로를 둔다. 미승인은 EXPIRED로 남기고
|
|
||||||
게시/발송 실패도 카드에 남긴다. 초안 생성과 발송을 별도 요청으로 나눠 알림톡 실패를
|
|
||||||
초안 생성 성공으로 숨기지 않는다. GET은 승인하지 않는다. 토큰은 nonce와 DB 해시이며 JWT가 아니다.
|
|
||||||
|
|
||||||
POSTING 중단은 UNKNOWN으로 격리한다. 10분 지났다고 자동 재시도하는 설계는 취소한다.
|
|
||||||
게시할 때 승인된 account_id·본문·주소를 재검사한다. 계정 없이 확인한 원고는 나중에 연결해도
|
|
||||||
자동으로 게재하지 않고 다시 승인받는다. 사진 첨부 코드는 없다.
|
|
||||||
|
|
||||||
### 7-1-1. 게시는 주소가 확정된 사이트에만 — ★ 이 기능에서 가장 위험한 자리
|
|
||||||
|
|
||||||
`sites.domain` 이 비어 있어도 사이트는 발행된다. 그때 슬러그는 `_publish_target` 이 만드는
|
|
||||||
임시값이고 **`place.name` 에서 파생된다.** 상호를 고치면 **발행 주소가 통째로 바뀐다.**
|
|
||||||
`set_slug` 의 `SITE_SLUG_LOCKED` 는 `domain` 컬럼 변경만 막으므로 여기엔 안 걸린다.
|
|
||||||
→ 이미 올라간 글의 옛 주소는 404 가 되고, **그 글은 수정할 수 없다.**
|
|
||||||
|
|
||||||
그래서 전제조건을 코드가 강제한다(`social_service.target`):
|
|
||||||
`status == PUBLISHED` **AND** `current_version_id IS NOT NULL` **AND** `domain IS NOT NULL`.
|
|
||||||
임시 슬러그는 "아직 이름이 정해지지 않았다" 는 뜻이지 주소가 아니다.
|
|
||||||
|
|
||||||
### 7-1-2. 승인 링크 — 일회성은 토큰이 아니라 CAS 가 보장한다
|
|
||||||
|
|
||||||
JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못 세기** 때문이다. 승인은
|
|
||||||
`status='PENDING_APPROVAL'` 조건이 붙은 **단일 UPDATE ... RETURNING** 이고 두 번째 클릭은 0행이다.
|
|
||||||
|
|
||||||
★ **승인은 GET 으로 처리하지 않는다.** 메신저의 링크 미리보기 생성기·백신·브라우저 프리페치가
|
|
||||||
**사람이 누르기 전에** 그 URL 을 연다. GET 승인이면 사장님이 안 눌렀는데 글이 올라가고 로그에는
|
|
||||||
"승인됨" 으로 남는다 — 눈으로 원인을 못 찾는 종류다.
|
|
||||||
|
|
||||||
**2026-09-21 개정 — 미니블로그 문구 재사용은 예외.** 미니블로그 승인(이메일 GET 토큰 또는
|
|
||||||
로그인 "바로 발행")은 "이 문구를 공개해도 좋다"는 사장님의 명시적 의사표시이고, 같은 문구를
|
|
||||||
같은 시점에 다른 채널(쓰레드)에도 내보내는 것뿐이므로 별도 승인은 중복 확인이다. 이 예외는
|
|
||||||
**미니블로그 문구를 그대로 재사용하는 경우에 한정**한다 — `social_service.publish_reused_text`
|
|
||||||
가 `decided_via='mini_blog'`로 곧장 `APPROVED` 처리한다. 쓰레드 전용으로 새로 짓거나 내용을
|
|
||||||
바꾸는 경로(`create_draft`/`request_approval`)는 위 CAS 승인을 그대로 거친다.
|
|
||||||
|
|
||||||
★ **기존 액세스 토큰을 승인 링크에 얹지 않는다.** 지금 JWT 는 `sub` 에 `UserInfo` 통짜(role 포함)를
|
|
||||||
넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다.
|
|
||||||
|
|
||||||
### 7-1-3. 사진은 올리지 않는다 — 1-2 의 격리가 여기서는 불가능하다
|
|
||||||
|
|
||||||
1-2(크롤링 이미지 재게시)의 격리는 "결론이 불가면 `source_type=CRAWL` 을 발행 payload 에서
|
|
||||||
빼면 된다" 즉 **되돌릴 수 있다**는 전제 위에 있다. SNS 는 그 전제가 깨진다 — 플랫폼 서버에
|
|
||||||
사본이 생기고, 핫링크를 줘도 플랫폼이 자기 CDN 에 캐시한다. 게다가 지금은 **OWNER 사진이
|
|
||||||
존재할 수 없다**(업로드 경로가 없다, 5-3).
|
|
||||||
→ `source_type` 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않는다.** 필터로 만들면 1-2 가
|
|
||||||
풀리기 전에 OWNER 업로드가 붙는 날 자동으로 열린다.
|
|
||||||
|
|
||||||
### 7-1-4. 실제 게시는 기본으로 꺼져 있다 — 그리고 1-4 가 전제조건이 됐다
|
|
||||||
|
|
||||||
`SOCIAL_POSTING_ENABLED=1` 일 때만 열린다. 초안·승인까지는 계약 없이 돌지만 **게시는
|
|
||||||
되돌릴 수 없어서**, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다.
|
|
||||||
|
|
||||||
★ 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이 **"죽은 링크 정책 미정"** 이 된다.
|
|
||||||
색인은 시간이 지나면 사라지지만 사장님 타임라인에 박힌 링크는 우리가 손댈 수 없다.
|
|
||||||
`UNPUBLISHED` 를 404 로 두면 SNS 에서 온 손님은 빈 화면을 본다.
|
|
||||||
그리고 **우리가 사장님 글을 자동으로 지우지 않는다** — 지우는 것도 사장님 명의의 행위다.
|
|
||||||
|
|
||||||
남은 정책: 만료 24시간의 최종 근거, 야간 발송(현재 화면 채널만 사용), 다계정 선택,
|
|
||||||
장기 미사용 계정의 사전 토큰 갱신. 계정은 현재 user/provider당 하나다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14)
|
## 8. FAQ 는 20개를 채운다 — 모자란 만큼 공통 질문 + 문의 안내 (2026-09-14)
|
||||||
|
|
||||||
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
|
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
# 배포 · 스토리지
|
# 배포 · 스토리지
|
||||||
|
|
||||||
> 2026-09-15 이후 절차는 [PUBLISH_VERSION.md](PUBLISH_VERSION.md)를 따른다.
|
|
||||||
> 기존 사이트 전체 재굽기는 하지 않는다. 최초 전환 때 구 프리렌더를 중지한다.
|
|
||||||
|
|
||||||
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
|
> **현재 결정 (2026-08-31): 발행 사이트는 서버 안에서 nginx 가 정적 서빙한다.**
|
||||||
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
|
> Azure Blob 은 코드에 있으나 **켜지 않는다**(`AZURE_STORAGE_CONNECTION_STRING` 비움).
|
||||||
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
|
> 클라우드는 고도화 때 붙인다 — 근거는 [ARCHITECTURE.md 3절](ARCHITECTURE.md).
|
||||||
|
|||||||
580
docs/DEVLOG.md
580
docs/DEVLOG.md
@ -1,590 +1,10 @@
|
|||||||
# 개발 일지
|
# 개발 일지
|
||||||
|
|
||||||
## 2026-09-22 — 카톡 5초 벽을 콜백으로 넘는다
|
|
||||||
|
|
||||||
실제 카톡에서 "시설 편의에서 바비큐 이용 문구 빼줘" 가 **"확인하는 데 시간이 조금 걸리네요"**
|
|
||||||
로 끝났다. 타임아웃이었다.
|
|
||||||
|
|
||||||
★ **작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다.** 개발 중 잰 1.3~2.4초는 업종 필드
|
|
||||||
두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가 실린다.
|
|
||||||
"여유가 있다" 고 적어 둔 판단이 실사용 첫날에 깨졌다.
|
|
||||||
|
|
||||||
**고친 방법** — 오픈빌더 콜백(스킬 타임아웃 5초, 콜백 주소 1분·1회):
|
|
||||||
`userRequest.callbackUrl` 이 실려 오면 `{"useCallback": true}` 로 **즉답**하고, 백그라운드에서
|
|
||||||
답을 만든 뒤 그 주소로 따로 POST 한다. 콜백이 꺼져 있으면 예전처럼 동기(4.5초 상한).
|
|
||||||
|
|
||||||
★ 콜백 전송 실패는 **재시도하지 않는다** — 1회용 주소라 두 번째 POST 는 거절되고, 사장님에게는
|
|
||||||
이미 "확인하고 있어요" 가 가 있다.
|
|
||||||
|
|
||||||
★ 오픈빌더 스킬 설정에서 **콜백 사용을 켜야** 이 경로가 열린다. 안 켜면 코드가 있어도
|
|
||||||
`callbackUrl` 이 안 와서 동기 경로로만 돈다 — 조용히 예전처럼 동작한다.
|
|
||||||
|
|
||||||
**검증** — `test_kakao_webhook.py` 24 passed(콜백 3건 추가: 즉답 형식·콜백 전송·전송 실패).
|
|
||||||
|
|
||||||
## 2026-09-22 — 카톡 대화에 홈페이지 목록·가게 고르기
|
|
||||||
|
|
||||||
실제로 붙여 보니 빠진 것이 드러났다(사장님 지적): 연결은 됐는데 **어느 홈페이지를 다루는
|
|
||||||
대화인지 화면이 말해 주지 않았다.** 가게가 하나면 말없이 자동 선택돼 더 모호했다.
|
|
||||||
|
|
||||||
- 연결 직후 목록을 보여준다. 하나면 그 이름과 발행 여부를, 여럿이면 **바로가기 버튼**으로 고르게.
|
|
||||||
- 목록 줄에 **발행 여부**를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다.
|
|
||||||
- "목록"·"가게 바꿔줘" 등으로 **언제든 돌아와 바꾼다.** ★ 이 경로는 LLM 을 부르지 않는다 —
|
|
||||||
대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록 보기에 돈을 쓸 이유가 없다.
|
|
||||||
- 목록은 `list_my_sites` 를 쓴다(사업장 목록이 아니라). `/sites` 화면이 같은 이유로 그걸 쓴다 —
|
|
||||||
사장님이 알아야 하는 건 "가게가 있다" 가 아니라 "발행돼 있나" 다.
|
|
||||||
|
|
||||||
**검증** — `test_kakao_webhook.py` 21 passed(목록·전환 4건 추가).
|
|
||||||
전체 `845 passed / 53 failed`, 53 은 이번 변경 전과 같다.
|
|
||||||
|
|
||||||
## 2026-09-22 — 카카오 채널 웹훅(4단계)
|
|
||||||
|
|
||||||
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게
|
|
||||||
만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태
|
|
||||||
(`channel.py`)뿐이다.
|
|
||||||
|
|
||||||
**★★ 인증 — 오픈빌더는 서명을 주지 않는다**
|
|
||||||
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
|
|
||||||
1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`,
|
|
||||||
`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가
|
|
||||||
404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다.
|
|
||||||
|
|
||||||
**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다.
|
|
||||||
1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다**
|
|
||||||
(카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다)
|
|
||||||
2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억.
|
|
||||||
★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다**
|
|
||||||
3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022).
|
|
||||||
★ `pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다**
|
|
||||||
|
|
||||||
**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로
|
|
||||||
끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에.
|
|
||||||
어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다.
|
|
||||||
|
|
||||||
**밟은 것** — `DB_SESSION_MNG.execute_lambda` 는 **람다 반환값을 그대로** 준다(CRUD 관례가
|
|
||||||
`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든
|
|
||||||
예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다.
|
|
||||||
|
|
||||||
**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출).
|
|
||||||
전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다.
|
|
||||||
|
|
||||||
## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐)
|
|
||||||
|
|
||||||
카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을
|
|
||||||
`0` → `1` 로 돌렸다. **코드는 어제도 오늘도 그대로다** — 닫고 여는 일이 커밋을 되짚는 일이
|
|
||||||
되면 안 된다는 어제 판단이 하루 만에 값을 쳤다.
|
|
||||||
|
|
||||||
★ 기본을 켜도 **LLM 키가 없으면 안 열린다**(`runtime.is_configured` 가 스위치와 키를 둘 다
|
|
||||||
본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다.
|
|
||||||
|
|
||||||
★ 카카오 연결 카드는 아직 감춰져 있다 — `KAKAO_CHANNEL_PUBLIC_ID` 미설정.
|
|
||||||
채우면 코드는 발급되지만 **소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.**
|
|
||||||
채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이고, 웹훅이 붙는 쪽은 후자다.
|
|
||||||
|
|
||||||
**검증** — `test_agent_runtime`(스위치 테스트를 새 기본값에 맞춰 갱신)·`test_kakao_link` 34 passed.
|
|
||||||
|
|
||||||
## 2026-09-21 — 에이전트 화면 보류: 설정으로 닫는다(코드는 그대로)
|
|
||||||
|
|
||||||
카카오톡 채널 개설이 **법인폰 본인인증**에 걸려 보류됐다(사장님 지시: "이 작업은 여기서 딱
|
|
||||||
보류하고, 사용못하게 대화 할 수 있는 부분을 숨겨줘"). 채널이 없으면 대화창은 사장님에게
|
|
||||||
**어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다.
|
|
||||||
|
|
||||||
- `AGENT_CHAT_ENABLED` 신설(기본 `0`). `runtime.is_configured()` 가 스위치와 LLM 키를 **둘 다**
|
|
||||||
본다 — 화면을 우회해 API 를 직접 불러도 `AGENT_NOT_CONFIGURED` 다.
|
|
||||||
- `AgentChatDock` · `KakaoChannelCard` 둘 다 조건 미충족이면 `return null` 로 통째로 감춘다.
|
|
||||||
연결 카드는 `connection_enabled=false` 가 기준이라 설정을 채우면 그대로 다시 나타난다.
|
|
||||||
- ★ **코드를 지우지 않았다.** 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다.
|
|
||||||
|
|
||||||
★ Threads 카드와 판단이 갈린 것이 맞다 — 저쪽은 '자리는 두고 버튼만 죽인다'(사장님이 곧 쓸 수
|
|
||||||
있는 기능이라 존재를 알려야 했다), 이쪽은 언제 열릴지 말해 줄 수 없어 감춘다.
|
|
||||||
|
|
||||||
**검증** — `test_agent_runtime`(스위치 테스트 2건 추가)·`test_kakao_link` 34 passed.
|
|
||||||
`npm run lint` 통과.
|
|
||||||
|
|
||||||
## 2026-09-21 — 사장님 에이전트 2단계: 도구 레지스트리 · 런타임 · 빌더 채팅창
|
|
||||||
|
|
||||||
**왜 카카오톡보다 이걸 먼저 만드나**
|
|
||||||
런타임이 채널을 모르므로, 채널·챗봇 심사 없이 **에이전트 전체를 빌더 화면에서 검증**할 수 있다.
|
|
||||||
웹훅 핸들러 안에 에이전트를 짜면 빌더에서 같은 걸 못 쓰고 심사가 끝나야 무엇 하나 확인되지 않는다.
|
|
||||||
카톡은 나중에 붙는 두 번째 입구다 — `runtime.chat()` 을 그대로 부른다.
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `services/agent/tools.py` — 도구 넷과 등급 셋(`READ`·`REVERSIBLE`·`SEMI`).
|
|
||||||
`get_site_status`·`list_facts`·`set_fact`·`publish`.
|
|
||||||
- `services/agent/runtime.py` — 발화 → 도구 선택(LLM 1콜) → 실행 → 응답. 채널을 모른다.
|
|
||||||
- `services/prompts/agent.py` — LLM 네 겹 규약(`services/llm/__init__.py`)대로 프롬프트만 여기.
|
|
||||||
- `router/v1/agent/chat.py`, 프론트 `features/agent/AgentChatDock.tsx`(`/sites` 우하단).
|
|
||||||
|
|
||||||
**세 가지를 모델에게 맡기지 않았다**
|
|
||||||
1. **등급** — 확인이 필요한지는 레지스트리가 못 박는다. 응답 스키마에 그 칸 자체가 없고
|
|
||||||
도구 목록에도 등급을 싣지 않는다. 모델이 정하면 프롬프트에 끼어든 한 줄이 확인을 건너뛴다.
|
|
||||||
2. **결과 문구** — 도구가 만든다. 모델이 쓰면 **하지 않은 일을 했다고 말할 수 있고**
|
|
||||||
사장님에게는 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.
|
|
||||||
3. **key** — `set_fact` 의 key 는 업종 스키마가 최종 판정이다. 모델이 없는 key 를 지어낸다.
|
|
||||||
|
|
||||||
**확인(SEMI) 한 바퀴** — `publish` 는 고르기만 하고 실행하지 않는다. 화면이 [네, 해주세요] 를
|
|
||||||
띄우고, 누르면 `{confirm:{tool,args}}` 로 다시 온다. ★ 서버는 그 값을 믿지 않는다 — 도구는
|
|
||||||
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다. 확인 절차가 검증을 건너뛰는 구멍이 되면 안 된다.
|
|
||||||
|
|
||||||
**값을 고치면 재발행 안내를 함께 낸다** — fact 는 바뀌어도 사이트는 안 바뀐다.
|
|
||||||
이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
|
||||||
|
|
||||||
**검증** — `test_agent_runtime.py` 17 passed. 그중 하나는 `tools.py` 소스에서 `crud` 직접 호출이
|
|
||||||
없는지 실제로 검사한다(주석이 아니라 코드로 못 박는 자리). 테스트는 LLM 을 monkeypatch 해서
|
|
||||||
실제 모델을 부르지 않는다. `npm run lint` 통과.
|
|
||||||
|
|
||||||
## 2026-09-21 — 사장님 에이전트 1단계: 카카오톡 채널 신원 연결
|
|
||||||
|
|
||||||
**왜 이것부터인가**
|
|
||||||
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 `user_id` 와 관계가 없다.
|
|
||||||
다른 엔드포인트는 전부 `place_crud.get_place(s, owner_user_id, place_id)` 로 소유자 범위를
|
|
||||||
지키는데, 채널에서 온 발화에는 그 `owner_user_id` 를 줄 근거가 없다 — 매핑이 없으면
|
|
||||||
**채널 진입점만 소유자 범위 밖**에 놓이고 채널에 말을 건 아무나가 남의 가게를 고친다.
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `owner_kakao_links`(0021 + init.sql) — 부분 유니크 셋. 그중 `uq_kakao_link_channel_key`
|
|
||||||
(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
|
|
||||||
- `services/kakao_link_service.py` — 발급·소비·조회·해제. 일회성은 코드 값이 아니라
|
|
||||||
`WHERE status='PENDING'` CAS 한 문장이 보장한다. 실패는 전부 같은 에러(`KAKAO_LINK_CODE_INVALID`)다 —
|
|
||||||
"없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다.
|
|
||||||
- 코드는 sha256 만 저장한다. 사장님이 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧
|
|
||||||
연결 권한을 갖는다. 글자에서 `0·O·1·I·L` 을 뺐다 — 잘못 읽어 실패하면 원인이 화면에 안 보인다.
|
|
||||||
- `router/v1/agent/kakao.py` 셋(`link`·`link/code`·`link/disconnect`), 전부 `no-store`/`no-referrer`.
|
|
||||||
- 프론트 `features/agent/` — `/sites` 의 Threads 카드 옆에 나란히. 연결은 사람 단위라 같은 자리다.
|
|
||||||
- `config/agent_config.py` 를 `social_config.py` 와 **일부러 갈랐다** — SNS 게재는 되돌릴 수 없는
|
|
||||||
대외 발화, 에이전트는 자기 사이트를 고치는 창구. 승인 강도도 보관하는 것도 다르다.
|
|
||||||
|
|
||||||
**★ 일부러 안 만든 것 — 코드 소비 엔드포인트**
|
|
||||||
코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다.
|
|
||||||
검증 없는 공개 소비 경로를 먼저 만들면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다 —
|
|
||||||
이 표가 막으려던 바로 그 일이다. `redeem()` 은 서비스 함수로만 두고 라우터에 붙이지 않았다.
|
|
||||||
|
|
||||||
**검증** — `test_kakao_link.py` 15 passed. 전체 백엔드 `780 passed / 50 failed`인데,
|
|
||||||
그 50건은 **같은 커밋 이전(HEAD)에서도 동일하게 50건**이다(워크트리로 대조 확인) —
|
|
||||||
`test_gemini*`·`test_site_theme`·`test_search_console_service` 등 기존 이슈이고 이번 변경과 무관하다.
|
|
||||||
`npm run lint`(frontend·admin·site) 통과.
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 — 지금 생성하기에 구간(시작~끝) 지정, 실배포 E2E 로 잡은 버그 1건
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- **"지금 생성하기"가 구간을 받는다**(사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를
|
|
||||||
정해야하지 않을까" → "캘린더 UI로 날짜받게"). `POST .../post/generate?start=&end=`
|
|
||||||
(`blog_jobs.generate_range`) — 개별 생성과 같은 이유로 재고 상한(`REFILL_BELOW`)을 안 보고,
|
|
||||||
이미 글이 있는 날짜는 LLM 호출 없이 건너뛰고, 소재가 떨어지면 그 자리에서 멈춘다. 응답에
|
|
||||||
`requested`/`created` 를 같이 줘서 "N일 중 M일만 채웠습니다"를 보여줄 수 있게 했다. 프론트는
|
|
||||||
버튼을 누르면 시작·끝일을 `<input type="date">` 두 개로 받는 다이얼로그가 뜬다.
|
|
||||||
- 기존 `blog_jobs.generate_now`(재고 상한 기반, "다음 빈 날부터 순서대로")는 삭제하고
|
|
||||||
`generate_range` 로 교체 — 호출부가 이 엔드포인트 하나뿐이라 하위호환 어댑터 없이 바로 바꿨다.
|
|
||||||
|
|
||||||
**실배포로 E2E 를 돌리다 잡은 버그 — `blog_service.generate_one` 의 죽은 import**
|
|
||||||
사장님이 "테스트하고 결과 알려줘"로 시켜서 로컬 docker 를 재배포하고 실제 API 로 전체 플로우를
|
|
||||||
돌렸더니(회원가입→사업장→발행 시드→생성→개별생성→승인), "지금 생성하기"가 500 으로 죽었다.
|
|
||||||
원인: `from services.external.gemini_text import DEFAULT_TEXT_MODEL, is_configured` —
|
|
||||||
`DEFAULT_TEXT_MODEL` 은 애초에 그 모듈에 있던 적이 없다(LLM 공급자를 gemini/openai 로 가르는
|
|
||||||
리팩터로 `services/external/gemini_text.py` 가 "소개문·FAQ 조립" 전용으로 바뀌면서, 모델
|
|
||||||
상수·`is_configured`는 `services/llm/gemini.py`(`DEFAULT_MODEL`)로 옮겨갔다). pytest 는 이
|
|
||||||
함수를 통째로 monkeypatch 하는 테스트뿐이라 이 import 자체가 실행된 적이 없어 26 passed 로도
|
|
||||||
안 잡혔다 — **"단위 테스트가 초록"과 "실제로 돈다"는 다른 것**이라는 걸 이번에 실측으로
|
|
||||||
확인했다. 고침: `services.llm.gemini` 에서 `DEFAULT_MODEL`·`is_configured` 를 가져오도록
|
|
||||||
import 한 줄만 수정.
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 27 passed(신규: 구간 생성 성공/거절).
|
|
||||||
전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·`test_search_console_service.py`
|
|
||||||
44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈, 앞선 라운드에서도 확인). `npm run
|
|
||||||
build -w @o2o/frontend` 통과. 로컬 docker 재배포 후 실제 API 로 회원가입→생성→개별생성→
|
|
||||||
구간생성→승인→BUILD 잡 큐잉까지 end-to-end 확인(진짜 Gemini 호출 포함, 브라우저 확장이
|
|
||||||
연결되지 않아 화면 클릭 대신 API 레벨로 돌렸다). → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 — 탭 3개→2개로 되돌림, 생성 이력에 모델명, 빈 날짜 개별 생성
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- **탭을 3개(이번 주·달력·생성 이력)에서 2개(블로그·생성 이력)로 되돌렸다.** 지난 라운드에서
|
|
||||||
카로셀·달력을 각자 탭으로 쪼갠 게 오독이었다(사장님 지시: "탭을 왜 이번주 달력 이렇게
|
|
||||||
나누고 지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지") — 원래
|
|
||||||
요청은 "달력 위에 카로셀"이지 "카로셀 따로, 달력 따로"가 아니었다. 생성 이력만 별도 탭으로
|
|
||||||
남긴다(`BlogPostsPage.tsx` `Tab = 'main' | 'history'`).
|
|
||||||
- 달력 칸 배지 문구 "메일 발송됨" → **"발송완료"**(사장님 지시: "달력에 발송완료 된거는
|
|
||||||
되었다고 적으라고", `publishBadge`).
|
|
||||||
- **생성 이력에 어느 모델을 썼는지 추가**(사장님 지시: "생성이력도 상세하게 기록해놓으셈
|
|
||||||
어느 모델썼는지 등등"). 새 컬럼을 늘리는 대신 `place_posts.generation_meta`(jsonb) 한
|
|
||||||
칸에 `{"model": "..."}` 로 담는다(사장님 지시: "Jsonb 하나팟거 컬럼",
|
|
||||||
`migrations/0020_place_posts_generation_meta.sql`). `blog_service.generate_one()` 반환값을
|
|
||||||
`str | None` → `tuple[str, str] | None`(본문, 모델명)으로 바꾸고, `PostCRUD.generation_batches`
|
|
||||||
가 회차별 대표 모델(`MAX(generation_meta->>'model')`)을 같이 뽑는다.
|
|
||||||
- **빈 날짜 하나만 콕 집어 생성**(사장님 지시: "그리고 개별적으로 새로 만들수있게 해줘").
|
|
||||||
`POST /v1/place/{place_id}/post/generate-one?date=`(`PostService.generate_for_date` →
|
|
||||||
`blog_jobs.generate_one_for_date`) — 재고 상한(`REFILL_BELOW`)을 안 본다, 콕 집은 날짜라
|
|
||||||
상한이 끼어들 자리가 아니다. 프론트는 달력에서 **오늘 이후의 빈 칸**만 누르면 그 날짜로
|
|
||||||
요청하고, 성공하면 그 자리에서 모달을 연다(`Calendar` `onGenerateDay`/`generatingDay`).
|
|
||||||
지난 날짜 칸은 클릭을 막는다.
|
|
||||||
|
|
||||||
**밟은 함정 — ORM 객체를 commit 뒤까지 들고 있으면 detached 로 깨진다**
|
|
||||||
`PostCRUD.add_one`을 처음엔 ORM 객체(`place_posts(**row)`)를 그대로 돌려주게 짰다.
|
|
||||||
`execute_lambda_write`는 `func(s)` 실행 뒤 **commit까지 하고** 값을 돌려주므로,
|
|
||||||
호출측이 그 객체의 속성(`post_id` 등)을 읽는 시점엔 세션이 이미 끝나 `DetachedInstanceError`
|
|
||||||
가 날 자리였다. `post_id`·`status`(둘 다 Python 쪽 `default`)는 `flush()` 직후엔 이미
|
|
||||||
채워져 있으므로, **flush 직후 세션이 살아있을 때** 값만 plain dict 로 뽑아 돌려주게 고쳤다
|
|
||||||
— ORM 객체 자체를 세션 밖으로 내보내지 않는다.
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 26 passed(신규 3건: 개별 생성 성공·날짜
|
|
||||||
중복 실패·소유권 스코프). 전체 백엔드 `753 passed`(기존에 깨져 있던 `test_gemini*`·
|
|
||||||
`test_search_console_service.py` 44건은 이번 변경과 무관 — LLM 공급자 전환 관련 별개 이슈).
|
|
||||||
`npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 메일 — 승인 즉시 처리 + 수정 자동 로그인, 화면 탭 3개로
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- 메일 승인 링크: GET 이 확인 화면 없이 **즉시 승인**(`router/v1/site/post.py`). 메일
|
|
||||||
프리페치에 노출된다는 걸 알고도 사장님이 택한 것 — POST `/approve`, GET/POST
|
|
||||||
`/v1/site/post/edit`(공개 편집 화면) 전부 삭제, `PostService.edit` 도 같이 지웠다.
|
|
||||||
- 메일 수정 링크: 이제 **로그인 흐름**이다. `CreateDayPassToken`(그날 자정 KST 까지만
|
|
||||||
사는 접근 토큰, `router/v1/validator/dependencies.py`)을 실은
|
|
||||||
`/blog?placeId=&postId=&auto=` 로 간다. 빌더 앱이 그 토큰으로 로그인해 편집 모달을
|
|
||||||
바로 연다.
|
|
||||||
- **승인·수정 링크 둘 다 그날 자정(KST) 만료**로 통일(`blog_service.issue_token`, 예전
|
|
||||||
14일 → 자정). 그 뒤엔 로그인해서 빌더 앱에서 처리한다.
|
|
||||||
- 신규 엔드포인트: `GET .../post/{post_id}`(메일 수정 링크 전용 단건 조회),
|
|
||||||
`GET .../post/history`(생성 이력 — 언제 몇 건, 새 컬럼 없이 `created_at` 회차로 묶음).
|
|
||||||
- `BlogPostsPage.tsx` 를 탭 셋으로 재구성 — **이번 주 · 달력 · 생성 이력**. 카로셀 카드를
|
|
||||||
누르면 그 자리에서 고치는 대신 모달을 연다(미리보기용 `PostPreviewCard` 와 실제 편집용
|
|
||||||
`PostCard` 분리). 달력 칸엔 발행완료/발행실패에 **메일 발송됨** 배지를 추가했다(크론잡이
|
|
||||||
실제로 돌았다는 확인). 이전 달/월/다음 달을 달력 탭 안, 달력 바로 위로 옮겼다.
|
|
||||||
|
|
||||||
**밟은 함정 — 세션 복구보다 늦게 로그인시키면 이미 늦다**
|
|
||||||
`BlogPostsPage` 안에서 `auto` 토큰으로 로그인시켰더니 "메일온거 클릭했더니 로그인하라고
|
|
||||||
뜨는데?" — `RequireAuth` 는 라우트 렌더링 시점에 `isRestoring`/`user` 를 보고 그 자리에서
|
|
||||||
`/login` 으로 튕긴다. 페이지 컴포넌트는 그 판정 *이후에만* 마운트되므로, 컴포넌트 안의
|
|
||||||
`useEffect` 로 로그인시키는 건 이미 늦다. `auto` 파라미터 처리를 세션 복구
|
|
||||||
(`app/provider.tsx` `useRestoreSession`) 안으로 옮겨서 고쳤다 — JWT `sub` 클레임을
|
|
||||||
그대로 디코드해(`lib/jwt.ts`, 서명 검증은 이미 서버가 함) `useAuthStore` 를 채운다.
|
|
||||||
|
|
||||||
**밟은 함정 — raw SQL 로 timestamptz 에 naive UTC 를 바인딩하면 로컬 시간대로 샌다**
|
|
||||||
자정 만료로 정밀해지자 테스트 3개가 "이미 만료됨"으로 죽었다. 원인: 테스트 시더가
|
|
||||||
`text()` 로 `token_expires_at` 에 naive datetime(`GTime.UTC()` 류)을 직접 바인딩하는데,
|
|
||||||
컬럼 타입 정보가 없는 raw 바인딩은 asyncpg 가 **드라이버 프로세스의 로컬 시스템 시간대**로
|
|
||||||
해석한다 — 이 개발 머신은 KST(UTC+9) 라 9시간이 밀렸다. 예전엔 14일짜리 만료값이라 9시간
|
|
||||||
밀려도 부호가 안 바뀌어 안 드러났을 뿐이다. ORM 경로(`update()`/`insert()`)는 컬럼의
|
|
||||||
`DateTime(timezone=True)` 프로세서를 타서 이 문제가 없다 — 실제 운영 코드(`mark_sent`)는
|
|
||||||
전부 ORM 이라 안전했다. 고침: 테스트 시더에서 바인딩 직전에 `.replace(tzinfo=timezone.utc)`
|
|
||||||
로 명시(`tests/test_blog_post.py`). **raw text() 로 timestamptz 컬럼에 naive datetime 을
|
|
||||||
바인딩하는 코드를 다시 보면, 반드시 이 함정을 의심한다.**
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed. `npm run build -w
|
|
||||||
@o2o/frontend` 통과. mnchoi@o2o.kr 로 실제 메일 미리보기 발송 확인(가짜 place/post 라
|
|
||||||
링크 자체는 동작하지 않음, 형식만 확인). → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀은 일주일치·달력은 모달, scheduled_date NULL 백필
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `GET /v1/place/{place_id}/post/upcoming?days=7` 신설(`PostService.list_upcoming`) — 카로셀은
|
|
||||||
이제 브라우징 중인 달과 무관하게 **항상 오늘부터 7일치**만, 날짜 오름차순으로 본다.
|
|
||||||
기존 `list_for_place` CRUD 를 월 경계 대신 (오늘, 오늘+N) 경계로 그대로 재사용했다.
|
|
||||||
- 카로셀 카드에 배정일 전부 표시 + 오늘/내일 카드에 chip. 마우스 오버 시 z-index 를
|
|
||||||
최상단으로 올려 겹친 카드가 안 가리게 했다(`PostCarousel` hover 상태).
|
|
||||||
- 달력 칸 클릭이 "카로셀로 스크롤"에서 **모달**(`Dialog`, 기존 `components/ui/dialog.tsx`
|
|
||||||
재사용)로 바뀌었다 — 그 날짜의 글 전체 내용 + 수정·바로 발행 버튼을 그 자리에서 보여준다.
|
|
||||||
- 달력 이전/다음 달 이동을 **이번 달 ~ 1년 뒤**로 제한(`minMonth`/`maxMonth`, 문자열
|
|
||||||
비교로 버튼 비활성화). 그 밖의 달은 볼 이유가 없다(과거는 비어 있고, 미래는 아직
|
|
||||||
아무것도 배정 안 됨).
|
|
||||||
|
|
||||||
**밟은 함정 — `scheduled_date` NULL 백필**
|
|
||||||
배포 직후 사장님이 "지금 생성하기"로 실제 만든 글 13건이 화면에서 통째로 사라져 보였다.
|
|
||||||
원인: 그 글들은 `scheduled_date` 컬럼이 생기기 *전에* 만들어져 값이 비어 있었는데,
|
|
||||||
월별·주간 조회 둘 다 이제 `scheduled_date` 로 거르는 바람에 `IS NULL` 행이 조용히
|
|
||||||
빠졌다(SQL 에서 `NULL <= x` 는 항상 unknown). 실서버 DB 에 1회성 SQL 로 백필했다 —
|
|
||||||
업장별 `created_at` 순서를 살려 오늘부터 하루씩 순서대로 채움. 새 컬럼을 추가하는
|
|
||||||
마이그레이션은 앞으로도 **기존 행에 값이 없을 때 조회에서 조용히 빠지는지**를 먼저
|
|
||||||
따져야 한다.
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 23 passed(`upcoming` 엔드포인트 날짜
|
|
||||||
필터·정렬 회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 빌더 화면 — 카로셀(편집) + 달력(발행완료/실패만 표시)
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `BlogPostsPage.tsx` 를 "리스트 + 달력 클릭 시 펼침" 구조에서 **카로셀(위) + 달력(아래)**
|
|
||||||
둘로 나눴다. 카로셀(`PostCarousel`)은 이 달 글 카드를 겹쳐 쌓아 가로로 넘기는 형태고,
|
|
||||||
편집·바로 발행 버튼은 이제 여기에만 있다. 달력(`Calendar`)은 보기 전용 — 칸마다 본문
|
|
||||||
앞부분 스니펫과 **발행완료/발행실패 배지만** 단다. 검수 대기·메일 발송 같은 발행 전
|
|
||||||
상태는 아무 배지도 안 단다. 칸을 누르면 카로셀의 해당 카드로 스크롤한다.
|
|
||||||
- `PostData` 에 `build_failed`(bool) 추가. `PostService._latest_build_failed` 가 그
|
|
||||||
업장의 가장 최근 BUILD 잡이 `JobStatus.DEAD` 인지 보고, APPROVED 인데 아직 안 나간
|
|
||||||
글에만 단다 — BUILD 잡 하나가 업장 승인분 전체를 한 번에 굽는 구조라 글 단위가 아니라
|
|
||||||
"이 업장 재발행이 막혀 있나" 를 보는 것이다.
|
|
||||||
|
|
||||||
**왜**
|
|
||||||
사장님 지시: "위에 겹치는 카로셀로 글들의 카드가 보이는거고 밑에는 달력에 내용앞부분
|
|
||||||
약간이랑 발행되었는지 안되었는지 여부 이렇게 표시하면됨 발행전인건 표시하지 말고
|
|
||||||
발행완료/발행실패 이것만 표시하면 될듯" — 앞서 만든 "오늘 게재됨/검토 대기" 요약 카드
|
|
||||||
2장은 이 의도와 달랐다(집계 카드였지 개별 글 카로셀이 아니었다).
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 22 passed(발행실패 판정 회귀 테스트
|
|
||||||
2건 포함). `npm run build -w @o2o/frontend` 통과. → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 빌더 화면 — 달력 + 배정일(scheduled_date) + 즉시 생성·바로 발행
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `place_posts.scheduled_date`(date) 추가(`migrations/0019_place_posts_scheduled_date.sql`,
|
|
||||||
`init.sql`, `models.py`). `(place_id, scheduled_date)` 유니크 — 업장 하나가 같은 날짜를
|
|
||||||
두 번 못 쓴다. 생성 시 그 업장의 `MAX(scheduled_date)` 다음날(없으면 오늘, KST)부터 하루
|
|
||||||
한 건씩 순서대로 배정한다(`blog_jobs._generate_for_place`).
|
|
||||||
- `PostCRUD.due_for_mail` 이 이제 `scheduled_date <= 오늘` 인 것만 고른다 — 미래 배정 글이
|
|
||||||
그날 되기 전에 새는 것을 막는다. `list_for_place`(빌더 화면 월별 조회)도 `created_at` 대신
|
|
||||||
`scheduled_date` 기준으로 바꿨다.
|
|
||||||
- `BlogPostsPage.tsx` 를 리스트에서 **달력**으로 바꿨다 — 글이 0건이어도 달력 칸 자체는
|
|
||||||
항상 뜬다. 위에 **오늘 게재됨 · 검토 대기** 요약 카드 두 장을 살짝 겹쳐서 배치했다.
|
|
||||||
- **지금 생성하기**(`POST .../post/generate`) — 새벽 04:10 크론을 안 기다리고 그 자리에서
|
|
||||||
만든다. **바로 발행**(`POST .../post/{post_id}/approve`) — 안 고치고 그대로 승인.
|
|
||||||
- `SitesPage.tsx` 카드의 "더보기" 메뉴에 **디자인·컨텐츠 관리 / 미니블로그 관리 /
|
|
||||||
예약요청 관리** 세 항목을 얹었다(탭이 아니라 메뉴 — 사장님 지시). 예약요청은 아직 화면이
|
|
||||||
없다 — `booking_request.py` 가 요청을 DB 에 남기지 않기로 한 결정(2026-09-16)과 부딪혀서
|
|
||||||
안내만 띄운다.
|
|
||||||
|
|
||||||
**왜**
|
|
||||||
사장님 요청: "포스트들이 다 날짜가 정해져야하는데" — `created_at`(만들어진 시각)만 있고
|
|
||||||
"언제 낼 것인가"가 없어서, 달력을 만들려면 화면이 근거 없는 날짜를 지어내야 했다. 또
|
|
||||||
"생성된 포스트가 없어도 달력은 계속 떠야지" — 목록이 비면 화면이 통째로 빈 상태 문구로
|
|
||||||
바뀌던 걸 고쳤다.
|
|
||||||
|
|
||||||
**밟은 함정** — `PostCRUD.due_for_mail`/`list_for_place` 시그니처가 바뀌어(`today`/날짜
|
|
||||||
경계 타입) 호출부를 같이 안 고치면 조용히 옛 컬럼을 봤을 것 — `_month_range` 를
|
|
||||||
UTC datetime 경계에서 KST 순수 date 경계로 바꿔 타임존 변환 자체를 없앴다(scheduled_date 는
|
|
||||||
timestamptz 가 아니라 DATE 라 변환이 필요 없다).
|
|
||||||
|
|
||||||
**검증** — `test_blog_post.py`·`test_blog_owner.py` 20 passed(배정일 순서·업장당 하루 한 통
|
|
||||||
회귀 테스트 포함). `npm run build -w @o2o/frontend` 통과(typegen·tsc·eslint·vite build).
|
|
||||||
→ [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-17 — 미니 블로그 팀 사전검수 폐지 — 검수는 사장님이, 빌더 앱에 로그인 화면 추가
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `router/v1/site/blog_admin.py` · `services/blog_review_service.py` · `admin/frontend
|
|
||||||
BlogReviewPage` 삭제. 생성분은 금칙 필터(`is_publishable_body`)만 통과하면 곧장
|
|
||||||
`REVIEWED` 로 쌓여 팀 개입 없이 발송 대상이 된다(`blog_service.filter_drafts`).
|
|
||||||
- `blog_jobs.py` `BATCH_SIZE`·`REFILL_BELOW` 25/40 → 30/30(한 달치). `send_reviewed()` 가
|
|
||||||
`PostCRUD.due_for_mail`(`DISTINCT ON (place_id)`)을 써서 업장당 하루 한 통만 보낸다 —
|
|
||||||
전엔 전체 업장을 섞어 오래된 순으로 뽑아 밀린 업장이 하루에 두 통 이상 받을 수 있었다.
|
|
||||||
- 메일 확인 화면에 **수정해서 올리기** 버튼 추가. `GET/POST /v1/site/post/edit` 신설 —
|
|
||||||
저장하면 금칙 필터를 다시 타고, 통과하면 본문 갱신 + 그대로 승인.
|
|
||||||
- `router/v1/site/post.py` 에 `owner_router`(`/v1/place/{place_id}/post`) 신설 — 로그인
|
|
||||||
세션으로 이번 달 생성된 글을 보고, 메일이 아직 안 나간 `REVIEWED` 글도 바로 수정·승인.
|
|
||||||
`solution/frontend/src/pages/BlogPostsPage.tsx` + `SitesPage` 카드의 "관리" 메뉴에
|
|
||||||
진입점 추가.
|
|
||||||
|
|
||||||
**왜**
|
|
||||||
2026-09-16 기획은 "팀이 먼저 거르고 사장님은 메일 클릭만" 이었는데, 다시 논의하면서 최종
|
|
||||||
판단을 사장님에게 넘기기로 했다 — 팀 검수 단계가 병목이고, 사장님이 자기 사이트 콘텐츠를
|
|
||||||
직접 못 보는 것도 이상했다.
|
|
||||||
|
|
||||||
**하는 김에 잡은 버그**
|
|
||||||
`services/post_service.py` 의 승인 처리가 BUILD 잡 payload 에 `owner_user_id` 를 안 채우고
|
|
||||||
있었다. `build_service.run_build:141` 은 `payload["owner_user_id"]` 를 무조건 읽으므로 —
|
|
||||||
**이메일 승인 클릭이 실제로는 사이트를 재발행하지 못하고 있었을 가능성이 높다**(잡은
|
|
||||||
큐에 들어가지만 워커가 돌릴 때 KeyError). `place_id` 로 `owner_user_id` 를 직접 조회해
|
|
||||||
채우도록 고쳤다. 회귀 테스트: `test_blog_post.py test_approve_enqueues_build_with_owner_user_id`.
|
|
||||||
|
|
||||||
**결과** — `solution/backend` 전체 pytest 784 passed(기존에도 실패하던 `search_console`
|
|
||||||
스케줄러 잡 개수 검증 2건은 이번 변경과 무관 — `blog-drafts`·`blog-mail` 상시 잡이 늘어난
|
|
||||||
탓, 별도 수정 필요). `tsc` 통과(solution/frontend · admin/frontend). → [MINI_BLOG.md](MINI_BLOG.md)
|
|
||||||
|
|
||||||
## 2026-09-16 — Teams 웹훅 수신자 고장 — 플로우 재생성으로 해결
|
|
||||||
|
|
||||||
원인: 플로우의 `body/recipient` 가 `"48:notes"`(Teams 예약값, 실제 채팅 아님)로 박혀 있어
|
|
||||||
`PostCardToConversation` 호출마다 BadRequest. 플로우 재생성(웹훅 템플릿) + 채널로 지정해서
|
|
||||||
해결, 실제 채널 게시 확인함. `TEAMS_WEBHOOK_URL` 갱신함(`.env`, 커밋 안 됨).
|
|
||||||
|
|
||||||
## 2026-09-16 — 크롤링 실패를 jobs.result 에 구조화해서 싣는다
|
|
||||||
|
|
||||||
`common/collect_diagnostics.py`(신규) + `collect_service.py` 채널별 실패 10곳 연결.
|
|
||||||
전엔 로그 한 줄로만 남아 원인 확인하려면 워커 로그를 grep 해야 했다 — 이제 잡 결과에도 남는다.
|
|
||||||
|
|
||||||
**검증** — `python3 ast` 파싱, 수동 실행 확인.
|
|
||||||
|
|
||||||
## 2026-09-16 — Gemini 호출 실패가 온보딩 생성 잡을 죽이지 않게
|
|
||||||
|
|
||||||
**한 일**
|
|
||||||
- `services/copy_service.py` — 소개문·FAQ 생성(`generate` 단계)에서 `GeminiError` 가 나면
|
|
||||||
잡을 실패시키지 않고 `generate` 를 건너뛴 것으로 기록한 뒤 fact 만으로 저장까지 계속한다.
|
|
||||||
프론트 사유 라벨: `generationLabels.ts` `SKIP_REASONS.generation_failed`.
|
|
||||||
- `common/database/db_session_manager.py` — 유니크 제약 충돌(`IntegrityError`) 로그를
|
|
||||||
ERROR → WARN. 재수집 시 이미 등록된 링크를 다시 넣으려는 정상 경로라
|
|
||||||
`services/collect_service.py` `_add_link` 가 이미 "이미 있으면 그만" 으로 처리한다.
|
|
||||||
|
|
||||||
**왜**
|
|
||||||
API 키가 아예 없을 때는 이미 `generate` 를 건너뛰고 fact 만으로 계속하면서, 키는 있는데
|
|
||||||
**호출이 실패할 때만** 잡 전체를 DEAD 로 보내는 건 일관성이 없었다. 발행도 고유 콘텐츠
|
|
||||||
0건으로 막지 않고(`publish_gate.check_unique_content` — "얇은 콘텐츠로 발행을 막지 않기로
|
|
||||||
했다"), 다른 곁들이 콘텐츠(자작곡 등, `build_service.py`)도 실패하면 로그만 남기고 계속
|
|
||||||
진행한다 — 이 갈래만 예외였다.
|
|
||||||
|
|
||||||
실측(2026-09-15 밤, 킹서버): 사진분석(VISION) 배치가 Gemini 분당 쿼터를 다 써서, 같은 키를
|
|
||||||
쓰는 온보딩 COPY 잡의 생성 호출도 429 를 맞고 재시도(총 20초 안팎)를 소진해 DEAD 로 갔다.
|
|
||||||
화면엔 "콘텐츠 생성을 완료하지 못했습니다" 로 떴다 — fact 만으로도 편집·발행이 되는데
|
|
||||||
잡을 죽일 이유가 없었다.
|
|
||||||
|
|
||||||
유니크 제약 쪽은 별개로, 이 로그가 ERROR 레벨이라 킹서버 워커 로그를 보면 크롤링이 계속
|
|
||||||
오류나는 것처럼 보였다(실제로는 매 재수집마다 정상적으로 나는 로그).
|
|
||||||
|
|
||||||
**남은 것** — Gemini 429 자체의 재시도 대기시간은 아직 안 늘렸다(호출 내 최대 8초 백오프 ·
|
|
||||||
잡 재시도 5초/10초). 분당 쿼터가 다 찬 상황을 실제로 견디려면 더 길게 기다려야 하는데,
|
|
||||||
그만큼 워커 슬롯을 오래 묶어 두는 트레이드오프가 있어 다음 작업으로 미룬다.
|
|
||||||
|
|
||||||
## 2026-09-15 — 장애 알림(잡 dead-letter·발행 실패·큐 정체) + /readyz
|
|
||||||
|
|
||||||
- alert_outbox(마이그레이션 0016) + services/alert_service.py — 영구 저장 + 재시도(최대 5회,
|
|
||||||
job_crud 와 같은 백오프) + dedupe_key 로 중복 스팸 억제 + 복구 알림. 전용 컨테이너 없이
|
|
||||||
기존 스케줄러(API 컨테이너, 1분·5분 스윕)와 워커 코드 안 후크로 돈다.
|
|
||||||
- 알리는 지점: 잡이 DEAD 로 떨어질 때(worker/runner.py), BUILD·ROLLBACK 이 **게이트 반려가
|
|
||||||
아닌** 렌더·인프라 실패로 끝날 때, 노래 등 부분 실패, 잡 큐 정체(dead-letter 누적·좀비
|
|
||||||
실행·PENDING 정체). 게이트 반려(사장님 쪽 문제)는 알리지 않는다.
|
|
||||||
- services/teams_webhook.py — Teams Workflows 수신 webhook 어댑터(일반화, search_console_alerts.py
|
|
||||||
와는 별도). TEAMS_WEBHOOK_URL 미설정이면 적재만 되고 전송은 안 나간다.
|
|
||||||
- detail 은 저장 전에 마스킹된다(쿼리스트링 키·Bearer 토큰·password=·이메일).
|
|
||||||
- `/readyz` 추가 — `/healthz`(프로세스 생존)와 달리 DB 에 실제로 SELECT 1 을 던져 본다.
|
|
||||||
서버·DB 가 통째로 죽으면 이 알림 체계도 자기 장애를 못 알리므로, 외부 uptime 모니터가
|
|
||||||
이 경로를 봐야 한다(docs/ALERTS.md — 실제 외부 연결은 이 세션에서 하지 않았다).
|
|
||||||
- ★ 버그 하나 잡음: alert_crud.due_pending 이 파이썬에서 계산한 시각과 DB 의 next_attempt_at
|
|
||||||
을 비교했는데, 앱·DB 서버 시계가 몇 십 ms 만 어긋나도(실측: 로컬에서 재현) send_alert
|
|
||||||
직후 process_outbox 를 부르는 자리에서 방금 넣은 알림이 안 잡혔다. `func.now()`(DB 쪽
|
|
||||||
시계)로 비교하도록 고쳤다.
|
|
||||||
- 검증: tests/test_alert_service.py(신규 17건) · test_job_queue.py(dead-letter 알림 1건 추가,
|
|
||||||
16건) · test_build_publish.py(게이트 반려/업무 실패 구분 확인 추가, 15건) · test_healthz.py
|
|
||||||
(readyz 1건 추가, 2건) 전부 통과.
|
|
||||||
- 운영 미적용: 실제 Teams webhook 생성·채널 지정, 외부 uptime 모니터 연결, 마이그레이션
|
|
||||||
0016 서버 적용 — 전부 사용자 승인 후 별도 진행.
|
|
||||||
|
|
||||||
## 2026-09-15 — 운영 번들의 자동 로그인 자격증명 제거 · refresh 토큰 무효화
|
|
||||||
|
|
||||||
- `docker-compose.yml` `solution-site`(운영 진입점) 빌드에서 `VITE_AUTO_LOGIN_ID`·`PW`
|
|
||||||
build arg 를 없앴다 — 채워진 채로 배포하면 사장님이 여는 번들에 그대로 구워져 누구나
|
|
||||||
JS 에서 읽을 수 있었다. `nginx/Dockerfile` 도 그 ARG 자체를 안 받는다.
|
|
||||||
- `lib/autoSession.ts` 에 `import.meta.env.DEV` 가드를 더했다(둘째 안전판) — 운영 빌드는
|
|
||||||
이 분기가 죽은 코드로 접혀 번들에서 통째로 빠진다. 실측: 자격증명 값을 채운 채로
|
|
||||||
운영 빌드를 돌려도 `build/client` 어디에도 그 문자열이 없는 것을 확인했다.
|
|
||||||
- `users.token_version`(마이그레이션 0015) 추가 — `refresh_token()` 이 지금까지 서명·만료만
|
|
||||||
보고 DB 를 한 번도 안 읽었다. 비밀번호를 바꿔도 이미 나간 refresh 토큰(7일)은 만료 전까지
|
|
||||||
계속 새 access 토큰을 찍어냈다. 이제 재발급마다 DB 의 token_version 을 대조하고,
|
|
||||||
비밀번호 변경이 그 값을 올린다(그 전 refresh 토큰은 다음 재발급부터 거절).
|
|
||||||
- 검증: `tests/test_auth.py` 16건 통과(신규 3건 — 정상 재발급·비번 변경 후 거절·계정 차단 후
|
|
||||||
거절). `tests/test_schema_ddl.py` 통과(ORM ↔ init.sql 일치).
|
|
||||||
- 운영 미적용: 실제 서버 `.env` 의 `AUTO_LOGIN_ID`·`PW` 값 확인·제거와 마이그레이션 적용은
|
|
||||||
이 세션에서 하지 않았다 — 서버 접속·DB 변경은 사용자 승인 후 별도로 진행한다.
|
|
||||||
|
|
||||||
## 2026-09-15 — 워커 렌더·발행 버전·예약 안내·미리보기 대기
|
|
||||||
|
|
||||||
- 상시 프리렌더를 제거하고 워커가 컴파일된 Node 렌더러를 실행한다.
|
|
||||||
- 버전별 HTML을 보존하고 게이트 통과 뒤 공개 링크를 전환한다. 재시도는 저장된 성공본을 사용한다.
|
|
||||||
- 예약 전 확인을 이용안내에 통합하고 iframe 렌더 완료까지 스피너를 표시한다.
|
|
||||||
- 배포는 기존 HTML과 목업을 재굽지 않는다. 상세: [PUBLISH_VERSION.md](PUBLISH_VERSION.md).
|
|
||||||
- 읽기 생성 토큰 상한을 늘리고 추첨 배열을 고정해 반복 렌더를 방지한다.
|
|
||||||
- 편집기 주소는 /builder?placeId=…로 통일한다. 옛 step=editor 주소는 ID 복원 후 정정한다.
|
|
||||||
- 검증: 사이트 81건, 백엔드 발행·롤백·서치콘솔 45건 통과. 빌더·사이트 빌드 통과.
|
|
||||||
|
|
||||||
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
무엇을 왜 바꿨는지 날짜순으로 남긴다. 새 항목을 **위에** 추가한다.
|
||||||
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
결론과 배경은 각 문서가 단일 출처다 — 여기에는 요약과 링크만 둔다.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-09-14 — SNS 게재: 사장님이 누르면 글을 쓰고, 승인받아, 사장님 계정으로 올린다
|
|
||||||
|
|
||||||
**추가 검증 (Threads 전환 완료본)** — 격리 DB `web4ai_social_isolated_test_db`, `SCHEDULER_ENABLED=0`에서
|
|
||||||
변경본 648 passed / 2 failed, 변경 전 HEAD 사본 635 passed / 동일한 2 failed를 확인했다.
|
|
||||||
실패는 기존 `test_rate_limit_closes_the_tap`·썸네일 호스트 기대값 검사이며 SNS 신규 13건은 모두 통과했다.
|
|
||||||
공용 테스트 DB에서는 다른 실행의 삭제/정리와 충돌했으므로 그 결과는 회귀 판정에서 제외했다.
|
|
||||||
`npm run lint`·전체 프론트 빌드 통과, site vitest 62 passed.
|
|
||||||
임시 payload를 실제 프리렌더해 데스크톱·모바일 하단 카드를 확인했고, SNS 글만 있는 payload는
|
|
||||||
고유 콘텐츠 0건으로 발행 거부됨을 확인했다. 실제 Threads 게시·알림톡 발송·운영 배포는 실행하지 않았다.
|
|
||||||
운영 활성화 전제와 남은 정책은 [SOCIAL.md](SOCIAL.md)에 정리했다.
|
|
||||||
|
|
||||||
|
|
||||||
**무슨 일** — 발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고,
|
|
||||||
그건 검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [SNS에 알리기] 를 누르면 확인된 fact 로
|
|
||||||
짧은 글을 쓰고, 승인을 받아 **사장님 개인 계정**(스레드)으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.
|
|
||||||
|
|
||||||
**★ 이 변경의 크기** — 섹션 하나 추가가 아니다. 이 레포가 처음으로 ①외부에 **쓰기**를 하고
|
|
||||||
②**남의 계정 자격증명을 보관**하고 ③**되돌릴 수 없는 행위**를 한다. 아래 결정이 전부 여기서 나왔다.
|
|
||||||
|
|
||||||
**승인을 다시 둔다 — 7절의 예외** ([DECISIONS 7-1절](DECISIONS.md))
|
|
||||||
7절("LLM 이 쓴 문장은 승인 없이 나간다")의 "왜 안전한가" 두 줄이 여기서는 둘 다 성립하지 않는다.
|
|
||||||
기준은 문장의 참/거짓이 아니라 **명의**(사장님 계정의 발언) · **되돌릴 수 있나**(없다) ·
|
|
||||||
**무엇이 주로 틀리나**(문장이 아니라 링크 — `_publish_target` 이 계산하므로 앞 게이트가 못 본다)다.
|
|
||||||
7절의 함정은 구조로 막았다: 시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고,
|
|
||||||
승인 경로가 둘(알림톡·빌더)이며, 미승인은 만료되어 **화면에 보이게** 남는다.
|
|
||||||
|
|
||||||
**★ 게시는 주소가 확정된 사이트에만.** `sites.domain` 이 비면 발행 슬러그가 **상호명에서 파생**되고
|
|
||||||
(`_publish_target`), 상호를 고치면 주소가 통째로 바뀐다 — `SITE_SLUG_LOCKED` 는 `domain` 변경만
|
|
||||||
막으므로 여기엔 안 걸린다. 이미 올라간 글의 링크는 404 가 되고 **그 글은 수정할 수 없다.**
|
|
||||||
→ `PUBLISHED` + `current_version_id` + `domain` 셋이 다 있을 때만 허용한다.
|
|
||||||
|
|
||||||
**★ 승인은 GET 이 아니라 POST.** 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
|
|
||||||
연다. GET 승인이면 사장님이 안 눌렀는데 올라가고 로그에는 "승인됨" 으로 남는다.
|
|
||||||
일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
|
|
||||||
|
|
||||||
**게시는 기본으로 꺼져 있다**(`SOCIAL_POSTING_ENABLED=0`). 초안·승인까지는 계약 없이 돌지만
|
|
||||||
게시는 되돌릴 수 없어서, 플랫폼 계약과 **1-4(해지 시 사이트 처리)** 결론을 확인한 뒤 사람이 연다.
|
|
||||||
★ 1-4 가 이 기능의 **전제조건**이 됐다 — 외부에 영구 링크를 뿌리는 순간 "유예 기간 미정" 이
|
|
||||||
"죽은 링크 정책 미정" 이 된다.
|
|
||||||
|
|
||||||
**사진은 올리지 않는다.** 1-2(이미지 재게시)의 격리는 "나중에 필터로 뺄 수 있다" 는 전제 위에 있는데
|
|
||||||
SNS 는 그 전제가 깨진다(플랫폼 서버에 사본이 생긴다). 게다가 지금 OWNER 사진은 존재할 수 없다(5-3).
|
|
||||||
→ 필터가 아니라 **미디어 첨부 코드를 아예 만들지 않았다.**
|
|
||||||
|
|
||||||
**플랫폼은 스레드다.** X 는 URL 이 든 글을 쓰는 데 **요청당 $0.20** 이 안내돼 있어(공식 가격표),
|
|
||||||
"계정 단위 고정비" 라는 처음 가정이 틀렸다 — 사이트마다 나가는 변동비다. 스레드는 직접 API 에
|
|
||||||
건당 과금 안내가 없다. 어댑터 경계는 그대로 두되 X 어댑터는 넣지 않았다([API_USAGE 5절](API_USAGE.md)).
|
|
||||||
|
|
||||||
**밟은 함정 둘**
|
|
||||||
- **ORM 기본값에 쉼표가 딸려 들어갔다.** `server_default=text("'[]',")` → `DEFAULT '[]', NOT NULL`
|
|
||||||
로 나가 **CREATE TABLE 이 통째로 실패**했다. 운영 DB 는 init.sql 로 만들어져 안 드러나고
|
|
||||||
**ORM 이 스키마를 만드는 테스트 DB 에서만** 터진다 — 9월 10일의 `now()` 기본값 사고와 같은 자리다.
|
|
||||||
- **승인 스윕 주기가 1분이었다.** 쓰기 커넥션을 계속 집어 들어, 같은 컨테이너에서 도는 테스트가
|
|
||||||
커넥션을 못 받아 `TimeoutError` 로 무더기 실패했다(실측). 이 스윕이 하는 일은 "만료 표시" 와
|
|
||||||
"중단된 초안 정리" 뿐이라 분 단위 정밀도가 필요 없다 → **5분**.
|
|
||||||
|
|
||||||
**검증** — 백엔드 SNS 테스트 9건 통과(초안 dedup·owner 스코프 · 주소 고정 요구 · GET 프리페치가
|
|
||||||
상태를 안 바꾸는지 · 승인 CAS 일회성 · 만료·중단 스윕). `tsc -b`·`eslint` 통과(shared·site·frontend),
|
|
||||||
vitest 58 passed(신규 3). 스케줄러를 끈 상태에서 snapshot·vision·social 26건 동시 통과.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측
|
|
||||||
|
|
||||||
- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.
|
|
||||||
- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다.
|
|
||||||
- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다.
|
|
||||||
- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행.
|
|
||||||
|
|
||||||
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
|
|
||||||
|
|
||||||
## 2026-09-15 — 콘텐츠 생성 진행 상태·새로고침 복구
|
|
||||||
|
|
||||||
- COPY의 실제 단계 상태를 DB에 기록하고 Orval 응답으로 표시. 폴링 횟수 기반 진행률 제거.
|
|
||||||
- URL의 jobId로 조회 재개. 구 URL 복구는 완료·실패 이력까지 재사용해 중복 생성 방지.
|
|
||||||
- 실행 흐름·단계 메소드·프롬프트·프론트 조회 훅·화면 문구 분리.
|
|
||||||
- 구조·적용 순서: [GENERATION_FLOW.md](GENERATION_FLOW.md).
|
|
||||||
|
|
||||||
**검증** — 백엔드 관련 테스트 34건·브라우저 복구/실패 시나리오 6건 통과. 프론트 타입검사·lint·빌드 통과.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
|
|
||||||
|
|
||||||
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를
|
|
||||||
발행본 컴포넌트로 옮겼다. 그리기 규칙은 `site/src/lib/postcard-canvas.ts` 한 곳에 두고,
|
|
||||||
화면·입력·공유는 `sections/items/PostcardMakerSection.tsx` 가 맡는다. 사진이 있는 사이트면 나간다.
|
|
||||||
|
|
||||||
**★ 저장·공유가 사진 출처에 걸린다** — 캔버스는 **남의 도메인 사진을 그리면 오염돼서**(tainted)
|
|
||||||
`toBlob` 이 SecurityError 로 막힌다. 미리보기는 멀쩡히 보이는데 저장·공유만 죽는, 눈으로는 못 찾는 종류다.
|
|
||||||
CORS 로 받으면 안 오염되지만 실측(2026-09-14) 발행본 사진은 네이버 CDN(`*.pstatic.net`)에 있고
|
|
||||||
그쪽은 `Access-Control-Allow-Origin` 을 주지 않는다 — `curl -I` 로 확인했다.
|
|
||||||
|
|
||||||
→ 지금은 **정직하게 막는다.** CORS 로 한 번 받아 보고, 실패하면 CORS 없이 다시 받아 미리보기만 세우고
|
|
||||||
저장·공유 단추를 아예 감춘다("이 사진은 다른 사이트에 올라와 있어 …"). 눌러도 안 되는 단추를 두지 않는다.
|
|
||||||
→ **근본 해결은 사진을 우리 오리진으로 옮기는 것이다.** 시연본이 `img/mirror/` 로 그렇게 하고 있고,
|
|
||||||
발행 파이프라인이 같은 일을 하면(빌드 때 내려받아 `out/s/<slug>/img/` 에 두고 payload 주소를 바꾼다)
|
|
||||||
저장·공유가 풀린다. 덤으로 외부 주소 만료·핫링크 문제도 같이 사라진다. **아직 안 했다.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
|
## 2026-09-14 — FAQ 를 20개까지 채운다 (펜션 공통 질문 30개 + 문의 안내)
|
||||||
|
|
||||||
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서
|
**무슨 일** — COPY 잡의 FAQ 생성 상한을 8 → 20 으로 올리고, 그래도 모자라면 펜션 공통 질문 카탈로그에서
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
# 콘텐츠 생성 · 진행 복구
|
|
||||||
|
|
||||||
2026-09-15. `builder?step=generating`은 COPY(소개문·FAQ) 작업이다.
|
|
||||||
사진 분석은 VISION, 정적 사이트·노래 생성은 발행 BUILD에 속한다.
|
|
||||||
|
|
||||||
```text
|
|
||||||
템플릿 선택 → POST /v1/place/{placeId}/copy → jobId를 URL에 기록
|
|
||||||
↓
|
|
||||||
새로고침 ──────────────────────→ GET /v1/job/{jobId}
|
|
||||||
↑
|
|
||||||
COPY 워커: prepare → generate → save → faq_fill
|
|
||||||
각 단계 진입·완료 → jobs.progress(JSONB)
|
|
||||||
↓
|
|
||||||
화면: 서버 단계 표시 → DONE일 때 데이터 갱신 → editor
|
|
||||||
```
|
|
||||||
|
|
||||||
| 책임 | 파일 |
|
|
||||||
|---|---|
|
|
||||||
| 실행 순서 | `solution/backend/services/copy_service.py` |
|
|
||||||
| 단계 구현 | `solution/backend/services/copy_steps.py` — `prepare_copy`, `generate_copy`, `save_copy`, `fill_faqs` |
|
|
||||||
| 프롬프트·응답 스키마 | `solution/backend/services/prompts/copy.py` |
|
|
||||||
| 모델 호출·생성물 검증 | `solution/backend/services/external/gemini_text.py` → `llm/gemini.py`, `grounding/copy.py` |
|
|
||||||
| 단계 기록 | `solution/backend/services/job_progress.py` → `crud/job_crud.py` |
|
|
||||||
| API 계약 | `solution/backend/router/v1/job/protocol.py` → OpenAPI → Orval |
|
|
||||||
| 조회·복구·완료 전환 | `solution/frontend/src/features/onboarding/useGenerationJob.ts` |
|
|
||||||
| 화면 / 문구 | 같은 폴더의 `Step5Generating.tsx` / `generationLabels.ts` |
|
|
||||||
|
|
||||||
- `jobs.status`는 작업 전체 상태, `progress.steps[].status`는 단계 상태다.
|
|
||||||
단계는 `pending/running/done/skipped/failed`. 시간으로 퍼센트나 단계를 올리지 않는다.
|
|
||||||
- `progress.attempt`는 워커 시도 번호다. 재시도는 단계를 처음부터 다시 기록한다.
|
|
||||||
기록은 실행 중인 워커·시도 번호·유효한 lease가 일치할 때만 허용한다.
|
|
||||||
- 새로고침은 GET만 한다. jobId가 없는 구 URL은 `POST copy {resume: true}`로
|
|
||||||
해당 사업장의 최근 COPY를 찾는다. DONE·DEAD도 반환하므로 완료됐다고 새 작업을 만들지 않는다.
|
|
||||||
권한 검사는 사업장 조회가 먼저 한다. URL로 조회하는 COPY도 소유자를 검사한다.
|
|
||||||
- 템플릿의 생성 버튼을 명시적으로 누르면 기본 POST로 새 작업을 요청한다.
|
|
||||||
같은 사업장의 활성 작업이 있으면 기존 중복 방지 규칙으로 그 작업에 연결한다.
|
|
||||||
- 통신 오류는 상태 재조회, DEAD는 이전 단계 또는 편집기로 직접 이동할 수 있다.
|
|
||||||
오류·대기·미설정 상태를 가짜 진행이나 완료 화면으로 바꾸지 않는다.
|
|
||||||
- 노래 단계는 이번 COPY 흐름에 추가하지 않았다. 발행 BUILD 진행 표시 확장은 별도다.
|
|
||||||
|
|
||||||
적용: 마이그레이션 `0013_job_progress.sql`을 먼저 적용한 뒤 API·워커·빌더를 배포한다.
|
|
||||||
기존 잡의 `progress`는 NULL이다. 이 경우 단계 목록을 지어내지 않고 전체 상태만 표시한다.
|
|
||||||
|
|
||||||
검증: `tests/test_copy_api.py`, `tests/test_job_queue.py`, `tests/test_schema_ddl.py`.
|
|
||||||
프론트는 개발 서버를 켜고 `node solution/frontend/tests/generation.mjs <개발 URL>` 실행.
|
|
||||||
브라우저 테스트는 모든 API를 가짜 응답으로 대체한다.
|
|
||||||
@ -1,262 +0,0 @@
|
|||||||
# 미니 블로그 — AI 자동 포스트 생성기 (2026-09-16 기획, 2026-09-17 검수 흐름 개편)
|
|
||||||
|
|
||||||
숙소 소개 아래에 붙는 짧은 글 게시판. 사장님에게 최종 결정권이 있다 — **팀 사전검수 단계는
|
|
||||||
없다.** 메일 링크는 여전히 로그인 없이 쓰고, 대신 빌더 앱에 로그인하면 이번 달 생성된 글
|
|
||||||
전체를 볼 수 있다.
|
|
||||||
|
|
||||||
```
|
|
||||||
스케줄러(한 달치 생성) → 금칙 필터(자동) → 메일 발송(업장당 하루 한 통, 승인·수정 두 링크)
|
|
||||||
→ 사장님이 승인(즉시 게재) / 수정(빌더 앱 자동 로그인 모달) → 재발행 → 정적 HTML에 글 추가
|
|
||||||
※ 두 링크 다 그날 자정(KST) 만료 — 그 뒤엔 로그인해서 빌더 앱에서 처리
|
|
||||||
|
|
||||||
(병행) 빌더 앱 로그인 → 블로그 글 화면(탭: 이번 주 · 달력 · 생성 이력) → 언제든 수정·승인
|
|
||||||
```
|
|
||||||
|
|
||||||
## 확정된 것
|
|
||||||
|
|
||||||
- 스테이 DB의 숙소 정보로 **140~150자** 홍보 문구를 AI가 만든다 (2026-09-16)
|
|
||||||
- **텍스트만.** 사진은 넣지 않는다 (2026-09-16)
|
|
||||||
- 숙소 소개 하단 **미니 블로그** 형식, 글이 쌓이면 **페이지 번호**로 넘긴다 (2026-09-16)
|
|
||||||
- 갈래를 나눠 생성하고 **이전에 다룬 주제와 중복되지 않게** 한다 (2026-09-16)
|
|
||||||
- ★ **팀 사전검수 폐지** — 검수는 사장님이 한다. 금칙 필터(자동)를 통과하면 바로 발송
|
|
||||||
대상이다 (2026-09-17)
|
|
||||||
- ★ **한 달치를 미리 쌓아 두고, 업장당 하루 한 통씩** 메일로 내보낸다 (2026-09-17)
|
|
||||||
- ★ 메일의 **승인** 링크는 로그인 없음(토큰이 신원) — 누르는 즉시 승인된다(2026-09-17,
|
|
||||||
사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). **수정** 링크는 반대로
|
|
||||||
로그인 흐름이다 — 그날짜리 자동 로그인 토큰을 실어 보내 빌더 앱의 편집 모달을 그대로
|
|
||||||
연다(2026-09-17, 사장님 지시: "수정하기는 해당 수정하기 페이지로 가게(모달) 로그인도
|
|
||||||
크레덴셜로 자동으로 되게"). **두 링크 다 그날 자정(KST) 만료**(2026-09-17, 사장님 지시:
|
|
||||||
"승인이랑 수정모두 자정에 만료") — 넘기면 로그인해서 빌더 앱에서 처리한다
|
|
||||||
- ★ 사장님이 문구를 **직접 고쳐서** 승인할 수 있다 — 메일의 수정 링크, 빌더 앱에서도 동일
|
|
||||||
(2026-09-17)
|
|
||||||
- ★ 글마다 **배정일(scheduled_date)** 이 있다 — "언제 만들어졌나"만 있고 "언제 낼 것인가"가
|
|
||||||
없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17). 생성 시 그 업장의 다음
|
|
||||||
빈 날부터 하루 한 건씩 순서대로 배정한다
|
|
||||||
|
|
||||||
## 1. 데이터 — 표 하나
|
|
||||||
|
|
||||||
`postgres-init/init-data/init.sql` 과 `postgres-init/migrations/` **둘 다** 고친다.
|
|
||||||
|
|
||||||
| 칸 | 타입 | 무엇 |
|
|
||||||
|---|---|---|
|
|
||||||
| `post_id` | uuid pk | |
|
|
||||||
| `place_id` | uuid | 어느 업장 |
|
|
||||||
| `body` | varchar(400) | 본문 140~150자 |
|
|
||||||
| `topic_kind` | smallint | weather · festival · season · nearby · guide |
|
|
||||||
| `topic_key` | varchar(120) | 축제 id · 절기 · 장소 id — **중복 방지의 축** |
|
|
||||||
| `status` | smallint | DRAFT → REVIEWED → SENT → APPROVED → PUBLISHED / SKIPPED |
|
|
||||||
| `scheduled_date` | date | 이 업장 몫 배정일(KST). 하루 한 통 — 생성 시 순서대로 채운다 (2026-09-17) |
|
|
||||||
| `generation_meta` | jsonb | 생성 이력 상세 — 지금은 `{"model": "..."}` 하나뿐(사장님 지시: "생성이력도 상세하게
|
|
||||||
기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나팟거 컬럼", 새 컬럼을 안 늘리고 여기 얹는다, 2026-09-17) |
|
|
||||||
| `approve_token_hash` | varchar(64) | sha256. 평문은 메일에만 |
|
|
||||||
| `token_expires_at` | timestamptz | 발송 당일 자정(KST) — 수정 링크(day-pass)도 동일(2026-09-17, 이전엔 발송+14일) |
|
|
||||||
| `sent_at` · `approved_at` · `published_at` | timestamptz | |
|
|
||||||
| `published_version_id` | uuid | `site_versions` 참조 — 롤백 때 필요 |
|
|
||||||
|
|
||||||
유니크: `(place_id, topic_key)` — 같은 축제로 두 번 쓰지 않는다.
|
|
||||||
유니크: `(place_id, scheduled_date)` — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
|
|
||||||
|
|
||||||
## 2. 생성 — 스케줄러 잡
|
|
||||||
|
|
||||||
`scheduler/__init__.py` 에 `add_job` 한 줄, 로직은 `scheduler/jobs.py` → `services/blog_service.py`.
|
|
||||||
|
|
||||||
- **주기**: 하루 1회(KST 새벽 04:10). `SCHEDULER_ENABLED=1` 인 프로세스에서만 돈다(이미 그 규약이다)
|
|
||||||
- **대상**: 발행된 사이트 중 재고(DRAFT+REVIEWED)가 `REFILL_BELOW`(30) 미만인 업장 —
|
|
||||||
하루 한 통씩 나간다고 보면 한 달치를 채우는 셈이다
|
|
||||||
- **한 번에 `BATCH_SIZE`(30)건**씩. 앞 회차의 `topic_key` 목록을 프롬프트에 넣어 중복을 막는다
|
|
||||||
(한 달치를 한 호출로 뽑으면 중복 검사가 안 된다)
|
|
||||||
- **배정일**: 그 업장의 `MAX(scheduled_date)` 다음날부터(없으면 오늘부터, KST) 하루 한 건씩
|
|
||||||
순서대로(`blog_jobs._next_scheduled_date`가 아니라 인라인 계산 — `_generate_for_place`).
|
|
||||||
"지금 생성하기"(수동 트리거)는 사장님이 직접 고른 구간을 채운다 — 같은 소재 선별·게이트
|
|
||||||
로직을 재사용하지만 배정일이 "다음날부터 자동"이 아니라 "그 구간"이다(`generate_range`,
|
|
||||||
7절)
|
|
||||||
- **갈래 분기**: 날씨·축제·계절·주변장소·이용안내. 갈래마다 프롬프트가 다르고,
|
|
||||||
근거가 되는 값도 다르다(날씨=`local.weather`, 축제=`local.festivals`, 주변=`local.attractions`)
|
|
||||||
- 프롬프트는 `shared/src/lib/section-prompts.ts` 규약을 따른다 → `npm run export:prompts`
|
|
||||||
|
|
||||||
### 게이트를 통과하는 문구만 만든다 — 팀 검수를 대신하는 자리
|
|
||||||
|
|
||||||
발행 게이트 규칙 1은 **미검증 fact 를 화면에 내지 않는 것**이다(`services/publish_gate.py`).
|
|
||||||
홍보 문구가 가격·시설·운영시간을 주장하면 그 주장을 뒷받침할 fact 가 없어 규칙과 부딪힌다.
|
|
||||||
★ 팀 사전검수가 없어진 지금, 이 필터가 유일한 자동 관문이다.
|
|
||||||
|
|
||||||
- 프롬프트에 금칙을 건다: 숫자로 된 가격·시간·인원·전화번호를 쓰지 않는다
|
|
||||||
- 생성 뒤 기계로 한 번 더 거른다(`blog_service.is_publishable_body`) — 통과하면 곧장
|
|
||||||
`REVIEWED` 로 쌓인다(사람이 올릴 필요 없음). 실패분은 로그만 남고 버려진다
|
|
||||||
- 통과한 문구는 **고유 콘텐츠**라 오히려 규칙 2(고유 콘텐츠 ≥ 1)에 보탬이 된다
|
|
||||||
- 수정 화면(메일·빌더 앱 공통)에서 사장님이 고친 본문도 저장 전에 **같은 필터**를 다시 탄다 —
|
|
||||||
로그인했다고 우회되지 않는다
|
|
||||||
|
|
||||||
## 3. 검수 — 사장님이 한다 (2026-09-17, 팀 사전검수 폐지)
|
|
||||||
|
|
||||||
★ `admin`(:9801)의 1차 검수 화면(`blog_admin.py`·`BlogReviewPage`)은 삭제했다. 최종 판단은
|
|
||||||
사장님 몫이고, 그 판단은 두 군데서 이뤄진다.
|
|
||||||
|
|
||||||
1. **메일** — 업장당 하루 한 통, 승인/수정/넘기기 (4·5절)
|
|
||||||
2. **빌더 앱 로그인** — 이번 달 생성된 글 전체를 미리 보고 메일이 오기 전에 바로
|
|
||||||
승인·수정할 수 있다 (7절 "빌더 앱 화면")
|
|
||||||
|
|
||||||
## 4. 발송 — 메일
|
|
||||||
|
|
||||||
`services/mail_service.py`(2026-09-16 완성, ACS 우선 · SMTP 폴백)를 그대로 쓴다.
|
|
||||||
|
|
||||||
- **업장당 하루 한 통.** `PostCRUD.due_for_mail` 이 `scheduled_date <= 오늘` 이면서
|
|
||||||
`DISTINCT ON (place_id)` 로 업장 하나가 밀려 있어도 그날은 가장 이른 배정일 한 통만
|
|
||||||
고른다(`blog_jobs.send_reviewed`) — 미래 배정일 글은 그날이 오기 전엔 안 나간다
|
|
||||||
- 본문: 문구 전문 + 승인 링크 + 수정 링크(`blog_jobs._mail_body`)
|
|
||||||
- **승인 링크**: `GET /v1/site/post/approve?t=<토큰>` — 로그인 없음, 토큰이 신원. **누르는
|
|
||||||
즉시 승인된다**(확인 화면 없음, 2026-09-17 사장님 지시). 토큰은 32바이트 랜덤 → DB 엔
|
|
||||||
sha256 만, **단회용 · 그날 자정(KST) 만료**(`blog_service.issue_token`). 승인 확인
|
|
||||||
화면은 그 업장의 발행된 사이트(미니 블로그 자리, `#blog`)로 5초 뒤 자동 이동한다
|
|
||||||
(2026-09-22 사장님 지시 — `router/v1/site/post.py _page`, `PostService._blog_url`).
|
|
||||||
재발행(BUILD 잡)은 몇 분 걸리므로 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다 —
|
|
||||||
그래도 "어디로 가면 보이는지"는 바로 알려준다. 발행된 사이트가 없으면 자동 이동 없이
|
|
||||||
확인 문구만 보여준다
|
|
||||||
- **수정 링크**: `{origin}/blog?placeId=&postId=&auto=<그날짜리 JWT>` — 로그인 흐름이다.
|
|
||||||
`CreateDayPassToken`(`router/v1/validator/dependencies.py`)이 자정까지만 사는 접근
|
|
||||||
토큰을 찍고, 빌더 앱이 그 토큰으로 로그인해 그 글의 편집 모달을 바로 연다
|
|
||||||
(`solution/frontend/src/app/provider.tsx` 세션 복구 단계에서 처리 — `BlogPostsPage` 안이
|
|
||||||
아니라 라우트 가드보다 먼저인 지점이어야 한다, 2026-09-17 실측: 늦게 처리하면
|
|
||||||
`RequireAuth` 가 이미 `/login` 으로 튕긴 뒤였다)
|
|
||||||
- 메일은 평문으로 흐른다 → 승인 링크로 할 수 있는 일은 **그 글 한 건의 게재**뿐이고,
|
|
||||||
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
|
|
||||||
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가
|
|
||||||
`place_id` 불일치를 걸러낸다)
|
|
||||||
|
|
||||||
## 5. 승인·수정
|
|
||||||
|
|
||||||
- **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해
|
|
||||||
"바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행
|
|
||||||
가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service.
|
|
||||||
PostService._approve_and_publish`). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다.
|
|
||||||
- **승인**: 이메일 GET 은 로그인 없이 즉시 승인, "바로 발행" 은 로그인 세션이 신원 →
|
|
||||||
둘 다 `status = APPROVED` → BUILD 잡 큐. 이메일 링크의 만료·재사용은 "처리할 수 없는
|
|
||||||
링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
|
|
||||||
- **쓰레드 연동**: 승인되는 순간(경로 무관) 그 업장이 쓰레드에 연결돼 있으면 같은 문구에
|
|
||||||
발행 링크를 붙여 쓰레드에도 즉시 게시한다(2026-09-21) — 별도 승인 없음(`docs/DECISIONS.md`
|
|
||||||
7-1-2 개정, `docs/SOCIAL.md`). 연동 안 돼 있거나 `SOCIAL_POSTING_ENABLED=0`이거나 사이트
|
|
||||||
domain이 미확정이면 조용히 건너뛴다. 실패해도 미니블로그 승인 자체는 막지 않는다
|
|
||||||
(`post_service.PostService._try_social_share`)
|
|
||||||
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문만
|
|
||||||
갱신한다. **승인 전환은 하지 않는다** — 실패하면 사유를 보여주고 다시 고치게 한다,
|
|
||||||
통과해도 두 승인 경로 중 하나를 눌러야 사이트에 반영된다
|
|
||||||
- **알림 이메일**: 승인 메일 수신자는 `places.notify_email`(비면 `users.email`) —
|
|
||||||
계정 로그인 이메일과 분리해서 업장별로 다른 담당자에게 보낼 수 있다(빌더 앱 미니블로그
|
|
||||||
관리 화면에서 수정, `PATCH /v1/place/{place_id}`)
|
|
||||||
- ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
|
|
||||||
가 `payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
|
|
||||||
세션과 달라 `post_service.PostService._enqueue_build` 가 `place_id` 로 직접 조회해
|
|
||||||
채운다. 이게 빠져 있던 게 2026-09-17 발견된 버그였다(회귀 테스트: `test_blog_post.py
|
|
||||||
test_approve_enqueues_build_with_owner_user_id`)
|
|
||||||
|
|
||||||
## 6. 게재 — 재발행
|
|
||||||
|
|
||||||
`docs/PUBLISH_VERSION.md` 의 파이프라인을 그대로 탄다. payload 에 `posts[]` 를 실어
|
|
||||||
**그 사이트 하나만** 다시 굽고 새 버전으로 링크를 전환한다. 전체 재굽기가 아니다.
|
|
||||||
|
|
||||||
⚠️ **발행일(`publishedAt`)이 움직인다.** 글 한 건 때문에 사이트 갱신일이 바뀌는 것이
|
|
||||||
맞는지 합의가 필요하다 — 색인에는 유리하지만 "사장님이 발행한 적 없는데 날짜가 바뀐다"는
|
|
||||||
기존 원칙과 부딪힌다.
|
|
||||||
|
|
||||||
## 7. 화면
|
|
||||||
|
|
||||||
### 발행된 사이트 — 미니 블로그
|
|
||||||
|
|
||||||
`solution/site/src/sections/BlogSection.tsx`, 숙소 소개(`intro`) 바로 아래.
|
|
||||||
|
|
||||||
- **글 전부가 HTML 안에 있고 JS 가 10건씩 보여준다.** 페이지를 눌렀을 때 더 불러오지 않는다 —
|
|
||||||
크롤러는 2페이지를 못 본다
|
|
||||||
- 사이트 하나 = 한 장 규칙은 유지한다. 주소를 늘리지 않는다
|
|
||||||
- 글이 100건을 넘으면 그때 별도 주소를 다시 논의한다
|
|
||||||
- 군산 읽기 전체 노출도 같은 페이지네이션을 쓴다 — 컴포넌트를 한 벌만 만든다
|
|
||||||
|
|
||||||
### 빌더 앱 — 이번 달 생성된 글 (2026-09-17)
|
|
||||||
|
|
||||||
`solution/frontend/src/pages/BlogPostsPage.tsx`. "내 사이트" 카드의 **관리 메뉴 →
|
|
||||||
미니블로그 관리**에서 `?placeId=` 를 들고 들어온다(전역 메뉴 하나로는 어느 사이트인지
|
|
||||||
못 고른다 — 사장님 한 명이 사이트 여럿을 가질 수 있다).
|
|
||||||
|
|
||||||
- 백엔드: `GET/PUT /v1/place/{place_id}/post`(`router/v1/site/post.py` `owner_router`,
|
|
||||||
:9800). 로그인 세션(`IsValidAccessToken`)이 신원이고, `PlaceCRUD.get_place` 로 소유권을
|
|
||||||
매번 확인한다 — 토큰 흐름과 인증 방식이 다를 뿐 편집 가드(`is_publishable_body`)는 같다
|
|
||||||
- **아직 메일이 안 나간 `REVIEWED` 글도 여기서 바로 승인·수정할 수 있다** —
|
|
||||||
`PostCRUD._EDITABLE = (SENT, REVIEWED)`. 메일을 기다릴 필요가 없다
|
|
||||||
- 월 단위 조회(`month=YYYY-MM`, 기본 이번 달, KST 기준) — `scheduled_date` 기준으로 그 달에
|
|
||||||
배정된 글을 가져온다
|
|
||||||
- **화면은 탭 둘뿐이다** (2026-09-17, 사장님 지시: "탭을 왜 이번주 달력 이렇게 나누고
|
|
||||||
지랄이야 내가 언제그러라그랬어 달력위에 이번주 카드들 보여주라고 했지" — 카로셀·달력은
|
|
||||||
같은 화면에 **항상 같이** 뜬다, "생성 이력"만 별도 탭이다)
|
|
||||||
1. **블로그(카로셀 + 달력, 항상 같이 보인다).**
|
|
||||||
- **카로셀** — "오늘·내일 등 일주일치를 보기 편하게" 모은 것(사장님 표현). 달력(월
|
|
||||||
단위)과 무관하게 **항상 오늘부터 7일치**(`GET .../post/upcoming?days=7`,
|
|
||||||
`PostService.list_upcoming`, 날짜 오름차순). 카드가 겹쳐 쌓여 있고 가로로 넘기면
|
|
||||||
하나씩 앞으로 나온다(`PostCarousel`). 마우스를 올린 카드는 안 가려지게 z-index 를
|
|
||||||
맨 앞으로 올린다. 카드를 누르면 그 자리에서 고치는 게 아니라 **모달**을 연다(사장님
|
|
||||||
지시: "카드클릭해도 모달나와서 수정가능하게 해야지 왜 바로수정하게해") — 카드 자체는
|
|
||||||
미리보기(`PostPreviewCard`)뿐이고, 수정·바로 발행은 모달 안(`PostCard`)에서만 한다.
|
|
||||||
카드마다 배정일을 전부 쓰고, 오늘·내일인 카드에는 그 위에 "오늘"/"내일" chip 을 더 단다
|
|
||||||
- **달력** — **이전 달 · 월 · 다음 달** 이 달력 바로 위에 있다(사장님 지시). 이번 달부터
|
|
||||||
1년 뒤까지만 넘겨볼 수 있다(그 전·그 뒤는 볼 이유가 없다). 글이 0건이어도 칸은 항상
|
|
||||||
뜬다 — 배정일이 없으면 "이 달에 뭐가 있나"를 훑어볼 기준 자체가 없다. 칸마다 본문
|
|
||||||
앞부분 스니펫과 **발행완료 · 발행실패 · 발송완료 배지만** 보여준다 — 검수 대기처럼
|
|
||||||
아직 메일도 안 나간 상태는 아무 표시도 하지 않는다(사장님 지시: "발행전인건 표시하지
|
|
||||||
말고"), 메일 발송 여부는 크론잡이 실제로 돌았다는 확인이라 따로 보여준다(사장님 지시:
|
|
||||||
"달력에 발송완료 된거는 되었다고 적으라고"). **칸을 누르면 모달**로 그 글 전체 내용과
|
|
||||||
편집·발행 버튼을 보여준다
|
|
||||||
- **빈 날짜(오늘 이후만) 개별 생성** (2026-09-17, 사장님 지시: "그리고 개별적으로 새로
|
|
||||||
만들수있게 해줘") — 글이 없는 칸을 누르면 `POST .../post/generate-one?date=`
|
|
||||||
(`PostService.generate_for_date` → `blog_jobs.generate_one_for_date`)가 그 날짜 하나만
|
|
||||||
채운다. 재고 상한(`REFILL_BELOW`)을 안 본다 — 콕 집은 요청이라 상한이 끼어들 자리가
|
|
||||||
아니다. 이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.
|
|
||||||
지난 날짜는 만들 이유가 없어 클릭 자체를 막는다. 성공하면 그 자리에서 모달이 열린다
|
|
||||||
2. **생성 이력.** 언제 몇 건, 어느 모델로 만들었는지(사장님 지시: "생성이력도 있어야해
|
|
||||||
몇개 생성했는지" / "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등") —
|
|
||||||
`GET .../post/history`(`PostCRUD.generation_batches`). 새 컬럼 없이 기존 `created_at`
|
|
||||||
으로 회차를 묶는다(같은 트랜잭션 안의 `add_many` 는 DB `now()` 가 전부 같다). 모델명은
|
|
||||||
`generation_meta->>'model'` 의 대표값(`MAX`) 하나 — 한 회차 = 한 모델이 정상이다
|
|
||||||
- **발행실패 판정**: `PostService._latest_build_failed` — 그 업장의 가장 최근 BUILD 잡이
|
|
||||||
`JobStatus.DEAD`(재시도 소진)면, APPROVED 인데 아직 안 나간 글에 `build_failed=true` 를
|
|
||||||
단다. 글 단위가 아니라 "이 업장 재발행이 지금 막혀 있나" 를 보는 것이다 — BUILD 잡 하나가
|
|
||||||
그 업장의 승인분 전부를 한 번에 굽기 때문
|
|
||||||
- ⚠️ **`scheduled_date` 마이그레이션(0019) 전에 만들어진 글은 그 컬럼이 비어 있다.**
|
|
||||||
월별·주간 조회 둘 다 `scheduled_date` 로 거르므로, 비어 있으면 화면 어디에도 안 뜬다
|
|
||||||
(실측 2026-09-17: "지금 생성하기"로 만든 실제 글 13건이 이렇게 사라져 보였다). 배포
|
|
||||||
직후 한 번은 기존 NULL 행에 날짜를 채우는 백필이 필요하다 — 업장별로 `created_at` 순서를
|
|
||||||
살려 오늘부터 하루씩 순서대로 채운다(1회성, 스크립트로 남기지 않았다).
|
|
||||||
- **지금 생성하기** 버튼 — `POST /v1/place/{place_id}/post/generate?start=&end=`(사장님 지시:
|
|
||||||
"지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까"). 버튼을 누르면 시작일·끝일을
|
|
||||||
캘린더 입력(`<input type="date">`)으로 고르는 다이얼로그가 뜬다(사장님 지시: "캘린더
|
|
||||||
UI로 날짜받게"). 재고 상한(`REFILL_BELOW`)을 안 본다 — 개별 생성과 같은 이유로, 직접
|
|
||||||
고른 구간에 상한 로직이 끼어들 자리가 아니다(`blog_jobs.generate_range`). 이미 글이 있는
|
|
||||||
날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
|
|
||||||
`requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
|
|
||||||
보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
|
|
||||||
0건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 —
|
|
||||||
쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준)
|
|
||||||
|
|
||||||
## 8. 진행 (2026-09-17)
|
|
||||||
|
|
||||||
| | 자리 | 상태 |
|
|
||||||
|---|---|---|
|
|
||||||
| 표 + 마이그레이션 | `migrations/0017_place_posts.sql` · `init.sql` | 완료 |
|
|
||||||
| 생성 + 금칙 필터 | `services/blog_service.py` | 완료 |
|
|
||||||
| 생성·발송 스윕 | `services/blog_jobs.py` · `scheduler/jobs.py` | 완료 (새벽 4:10 생성 · 아침 9:00 발송, 업장당 하루 한 통) |
|
|
||||||
| ~~어드민 검수~~ | ~~`router/v1/site/blog_admin.py`~~ | **폐지(2026-09-17)** — 검수는 사장님이 한다 |
|
|
||||||
| 메일 + 승인·수정 | `services/mail_service.py` · `services/post_service.py` · `router/v1/site/post.py` | 완료 |
|
|
||||||
| 빌더 앱 로그인 화면(달력) | `router/v1/site/post.py owner_router` · `site/pages/BlogPostsPage.tsx` | 완료 |
|
|
||||||
| 배정일(scheduled_date) | `migrations/0019_*.sql` · `blog_jobs._generate_for_place` | 완료 |
|
|
||||||
| 생성 이력 상세(모델명, generation_meta) | `migrations/0020_*.sql` · `blog_service.generate_one` | 완료 |
|
|
||||||
| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
|
|
||||||
| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
|
|
||||||
| 재발행 연결 | `build_service` → `mark_published`, `owner_user_id` 버그 수정 | 완료 |
|
|
||||||
| 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 |
|
|
||||||
|
|
||||||
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
|
|
||||||
그리고 6절의 발행일 갱신 합의.
|
|
||||||
|
|
||||||
## 안 하는 것
|
|
||||||
|
|
||||||
- 사진 첨부 (2026-09-16 회의 확정)
|
|
||||||
- 글마다 별도 URL·목록 페이지 — 한 장 규칙을 깬다
|
|
||||||
- 예약 요청 관리 화면 — `booking_request.py` 는 요청을 DB 에 남기지 않는다(2026-09-16
|
|
||||||
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다
|
|
||||||
@ -92,8 +92,6 @@
|
|||||||
|
|
||||||
## 8. 제약
|
## 8. 제약
|
||||||
|
|
||||||
비용은 사이트당 변동비·계정/계약당 고정비·일회성 개발비로 구분한다. SNS의 Gemini 생성·알림톡 건당 발송은 변동비다. Threads 직접 API에 공개 과금은 확인되지 않았다. 고정비를 사이트 생성 미터에 배분해 배치 크기에 따라 게이트 판정이 달라지게 하지 않는다. [API_USAGE 5절](API_USAGE.md#5-sns-비용-2026-09-14) 참조.
|
|
||||||
|
|
||||||
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
|
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
|
||||||
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
|
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
|
||||||
(★ 개발비와 섞지 말 것 — 그건 일회성이다)
|
(★ 개발비와 섞지 말 것 — 그건 일회성이다)
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
# 발행 버전과 워커 (2026-09-15)
|
|
||||||
|
|
||||||
이 문서가 이전 문서의 프리렌더 상시 기동·전체 재굽기 절차를 대체한다.
|
|
||||||
|
|
||||||
`BUILD → snapshot → payload → Node 렌더 → 결과 게이트 → 공개 링크 전환 → DB 기록`
|
|
||||||
|
|
||||||
- Python은 HTML을 만들지 않는다. 미리 컴파일된 Node를 실행하고 JSON 보고서만 읽는다.
|
|
||||||
- 워커 이미지에 Node와 렌더러를 포함한다. 실행 중 npm 설치·번들 빌드는 없다.
|
|
||||||
- 공유 볼륨의 파일 잠금으로 렌더·공개 전환을 직렬화한다. 수집 등 다른 잡은 동시 실행한다.
|
|
||||||
- `out/versions/<slug>/<version>`에 성공한 HTML과 렌더 보고서를 보존한다.
|
|
||||||
- `out/s/<slug>`는 공개 버전의 상대 심볼릭 링크다. 게이트 통과 후 전환한다.
|
|
||||||
- 성공한 버전은 재시도·롤백 때 다시 쓰지 않는다. 기존 일반 디렉토리는 첫 재발행 때 legacy로 보존한다.
|
|
||||||
- 배포 시 HTML·기존 HTML의 자산 주소를 수정하지 않는다. 공용 자산과 미리보기 셸만 준비한다.
|
|
||||||
- 목업과 보관 버전의 참조 자산도 삭제 대상에서 제외한다. versions는 Azure 공용 업로드에서 제외한다.
|
|
||||||
- 롤백 API: `POST /v1/place/{place_id}/site/version/rollback`, `target_version`. 소유권 검사와 BUILD 중복 방지 키를 공유한다.
|
|
||||||
- 로컬 공개 전환과 DB/Azure는 단일 트랜잭션이 아니다. 외부 저장소·DB 실패 시 재시도 및 운영 확인이 필요하다.
|
|
||||||
- 최초 일반 디렉토리→링크 전환은 두 rename 사이 짧은 공백이 가능하다. 이후 링크 교체는 원자적이다.
|
|
||||||
|
|
||||||
## 배포
|
|
||||||
|
|
||||||
1. 진행 중 잡·서버 변경·목업 및 참조 자산 해시를 확인하고 site-out을 백업한다.
|
|
||||||
2. backend·worker·site 이미지를 빌드한다. admin은 기본 대상이 아니다.
|
|
||||||
3. 기존 worker와 solution-prerender를 중지한 뒤 새 worker를 기동한다. 두 렌더러를 동시에 실행하지 않는다.
|
|
||||||
4. API·미리보기·테스트 발행을 확인하고 목업 해시를 대조한다. 전체 재굽기·republish_all은 실행하지 않는다.
|
|
||||||
|
|
||||||
워커 경로: `SITE_PAYLOAD_DIR=/app/solution/site/payloads`, `SITE_OUTPUT_DIR=/app/solution/site/out`.
|
|
||||||
DB 테이블 추가는 없다. 기존 버전·잡·발행 로그를 사용한다.
|
|
||||||
|
|
||||||
## UI
|
|
||||||
|
|
||||||
예약 전 확인과 요약을 이용안내 및 예약에 통합한다. 별도 요약 섹션과 예약 카드의 중복 규정은 제거한다.
|
|
||||||
빌더 미리보기는 iframe 내부 렌더 완료 신호까지 스피너를 표시한다. 출처·iframe을 확인하고 12초 상한을 둔다.
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# Google Search Console 자동 추적
|
|
||||||
|
|
||||||
`발행 DB 감지 → 공개 사이트맵 확인/제출 → 색인 조회 → 상태 저장·Teams 알림`
|
|
||||||
|
|
||||||
## 경계
|
|
||||||
|
|
||||||
- 기존 API의 스케줄러에서 10분마다 실행한다. 컨테이너 추가 없음.
|
|
||||||
- `sites.status=PUBLISHED`인 사이트만 등록하므로 초안/목업 디렉토리 나열을 작업 원장으로 쓰지 않는다.
|
|
||||||
- 발행 DB에서 재발견한다. 발행 순간 별도 큐 적재가 실패하는 틈이 없고 재시작해도 이어진다.
|
|
||||||
- 발행 트랜잭션/잡과 독립적이다. Google 실패가 사이트 발행을 실패로 바꾸지 않는다.
|
|
||||||
- 한 번에 신규 발행 100개 등록, 조회는 오래 기다린 5개 처리. 정상 조회는 24시간 후 반복.
|
|
||||||
- 현재 렌더러의 단일 루트 urlset만 지원하고 읽기 상한은 5MB다. 향후 sitemap index 분할 시 확장한다.
|
|
||||||
- 오류는 1·2·4·8·16·24시간 간격 재시도. 기본 주기 기준 하루 최대 720회 검사이며,
|
|
||||||
다른 도구의 같은 속성 사용량도 Google 할당량에 포함된다. 대량 백로그는 여러 날에 걸쳐 소진한다.
|
|
||||||
- PostgreSQL transaction advisory lock으로 다중 API 프로세스의 동시 배치를 막는다.
|
|
||||||
단일 배치는 외부 호출 동안 트랜잭션/연결 1개를 점유한다(검사 1건 최대 90초, 최대 5건).
|
|
||||||
- 사이트맵 제출 성공과 URL 색인 성공은 별개다. `first_indexed_at`은 **우리가 처음 PASS를 관측한 시각**이다.
|
|
||||||
Google 내부 색인 시각이나 최신 발행 버전 반영 시각이 아니다. 원본 `lastCrawlTime`도 함께 보관한다.
|
|
||||||
- 재발행 시 해당 발행의 관측 상태를 초기화한다. 지난 관측 이력 전체를 누적하는 이벤트 저장소는 아니다.
|
|
||||||
- `SITE_PUBLIC_HOST` 변경은 기존 지침대로 재발행이 필요하다. 사이트 주소의 단일 출처는 `site_payload`다.
|
|
||||||
|
|
||||||
## 최초 설정 (운영자)
|
|
||||||
|
|
||||||
1. Search Console에서 발행 도메인의 소유권 확인. URL-prefix 속성이면
|
|
||||||
`https://web4ai.o2osolution.ai/`, 도메인 속성이면 `sc-domain:web4ai.o2osolution.ai` 형태.
|
|
||||||
2. Google Cloud에서 Search Console API 활성화, 전용 서비스 계정 생성.
|
|
||||||
3. Search Console 속성 설정 → 사용자 및 권한에서 그 서비스 계정 이메일에 전체 사용자 권한 부여.
|
|
||||||
Google 로그인용 `GOOGLE_CLIENT_ID`와는 다른 인증이다.
|
|
||||||
4. 서비스 계정 JSON 키는 **저장소 밖**에 보관한다. 권한을 최소화하고 git/이미지/로그에 넣지 않는다.
|
|
||||||
5. 루트 `.env` 설정:
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
GSC_ENABLED=1
|
|
||||||
GSC_PROPERTY_URL=https://web4ai.o2osolution.ai/
|
|
||||||
GSC_CREDENTIALS_HOST_FILE=/secure/location/search-console.json
|
|
||||||
GSC_ALERT_DAYS=7
|
|
||||||
GSC_ALERT_WEBHOOK_URL=
|
|
||||||
```
|
|
||||||
|
|
||||||
키 생성/권한 부여/실제 알림 전송은 구현 검증 중 자동 수행하지 않는다.
|
|
||||||
|
|
||||||
## 배포
|
|
||||||
|
|
||||||
먼저 새 이미지에 requirements를 설치하고 `0014_search_console.sql`을 기존 마이그레이션 도구로 적용한다.
|
|
||||||
프로젝트 전체 마이그레이션 순서를 확인한 뒤 실행한다. 아래는 운영자가 실행할 명령이며 자동 배포하지 않았다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T solution-backend python scripts/migrate.py
|
|
||||||
docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d --build solution-backend
|
|
||||||
```
|
|
||||||
|
|
||||||
선택 compose 파일은 API에만 키를 읽기 전용 마운트하고 `GSC_CREDENTIALS_FILE`을 설정한다.
|
|
||||||
없는 파일을 디렉토리로 자동 생성하지 않는다. 이후 배포에서도 이 override를 함께 사용해야 한다.
|
|
||||||
로컬 Python 실행은 `GSC_CREDENTIALS_FILE`에 로컬 키 파일 경로를 지정한다.
|
|
||||||
켜진 스케줄러는 첫 10분 주기부터 기존 발행 사이트도 등록한다. `GSC_ENABLED=0`이면 DB/Google 호출 모두 생략한다.
|
|
||||||
|
|
||||||
## 알림
|
|
||||||
|
|
||||||
Teams Workflows의 webhook 수신 → 채널에 Adaptive Card 게시 흐름 URL을
|
|
||||||
`GSC_ALERT_WEBHOOK_URL`에 넣는다. 비우면 외부 전송 없이 경고 로그/DB만 남는다.
|
|
||||||
API/사이트맵 오류 또는 발행 후 기본 7일 미색인 시 알린다. 성공한 알림은 사이트별 24시간 중복 억제.
|
|
||||||
전송 실패는 `alerted_at`을 갱신하지 않아 다음 검사 때 재시도한다.
|
|
||||||
외부 전송 후 DB commit 전에 죽으면 중복 알림이 가능하다(at-least-once).
|
|
||||||
키·토큰·webhook URL·Google 오류 본문은 알림에 포함하지 않는다.
|
|
||||||
|
|
||||||
## 결과 확인
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose exec -T solution-backend python scripts/search_console_status.py
|
|
||||||
```
|
|
||||||
|
|
||||||
읽기 전용이며 Google API를 추가 호출하지 않는다. 프론트 화면/API 계약은 변경하지 않았다.
|
|
||||||
|
|
||||||
| 파일 | 책임 |
|
|
||||||
|---|---|
|
|
||||||
| `services/search_console_client.py` | 인증·Google HTTP·오류 정규화 |
|
|
||||||
| `services/search_console_settings.py` | 선택 설정·속성 URL 범위 |
|
|
||||||
| `services/search_console_service.py` | 배치 흐름·재시도·관측 결과 |
|
|
||||||
| `crud/search_console_crud.py` | 발행 감지·등록·조회 순서·동시 실행 잠금 |
|
|
||||||
| `services/search_console_alerts.py` | 알림 조건·Teams 전송 |
|
|
||||||
|
|
||||||
## 구글 지원 범위 / 남은 운영 작업
|
|
||||||
|
|
||||||
- [사이트맵 제출 API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)는 지원된다.
|
|
||||||
- [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)는
|
|
||||||
Google이 이미 알고 있는 상태 조회용이며 실시간 페이지 테스트나 색인 요청 API가 아니다.
|
|
||||||
- 일반 숙박 사이트는 [Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api) 대상이 아니다.
|
|
||||||
- [검사 할당량](https://developers.google.com/webmaster-tools/limits)은 속성당 하루 2,000회다.
|
|
||||||
- [Teams webhook 형식](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook).
|
|
||||||
- 실제 서비스 계정 권한/사이트맵 제출/색인 관측/Teams 수신은 설정 후 운영 검증이 필요하다.
|
|
||||||
- 기존 루트 사이트맵의 백업 URL 정리와 IndexNow 개별 사이트맵 참조 문제는 이 기능과 별도다.
|
|
||||||
이 기능은 기존 공개 사이트맵을 제출하며 내용을 다시 만들거나 목업을 삭제하지 않는다.
|
|
||||||
|
|
||||||
## 구현 검증 (2026-09-15)
|
|
||||||
|
|
||||||
- 격리 PostgreSQL에서 클라이언트·배치·스키마·IndexNow 관련 59건 통과.
|
|
||||||
- 발행·설정·사이트 목록 회귀검사: 23건 통과, `test_unverified_fact_blocks_publish` 1건 실패.
|
|
||||||
해당 실패는 변경 전 HEAD `9773bc0`의 발행 코드에서도 동일 재현됨(GSC 비활성).
|
|
||||||
- Google/Teams 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상.
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
# Search Console 클라이언트
|
|
||||||
|
|
||||||
`solution/backend/services/search_console_client.py` — Google Search Console 에
|
|
||||||
사이트맵을 제출하고 URL 색인 상태를 조회하는 REST 클라이언트만 다룬다.
|
|
||||||
DB 저장·스케줄링·발행 감지·환경 설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)를 따른다.
|
|
||||||
|
|
||||||
공식 문서: [Sitemaps.submit](https://developers.google.com/webmaster-tools/v1/sitemaps/submit) ·
|
|
||||||
[urlInspection.index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)
|
|
||||||
|
|
||||||
## 1. 계약
|
|
||||||
|
|
||||||
```python
|
|
||||||
class SearchConsoleClient:
|
|
||||||
def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): ...
|
|
||||||
async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: ...
|
|
||||||
async def inspect_url(self, property_url: str, page_url: str) -> dict: ... # indexStatusResult 만
|
|
||||||
async def aclose(self) -> None: ...
|
|
||||||
# async with SearchConsoleClient(...) as client: ...
|
|
||||||
```
|
|
||||||
|
|
||||||
- `credentials_file`: 서비스 계정 JSON 키 파일 경로.
|
|
||||||
- `transport`: 테스트에서 `httpx.MockTransport` 를 꽂는 자리 — 실제 Google 호출 없이 검증한다.
|
|
||||||
- `inspect_url` 은 응답의 `inspectionResult.indexStatusResult` 만 돌려준다. 그 경로가
|
|
||||||
없거나(검사 실패) 빈 dict 면(실제 검사가 안 된 응답) `SearchConsoleError` 를 올린다 —
|
|
||||||
**"미색인"으로 넘겨짚지 않는다.**
|
|
||||||
|
|
||||||
## 2. 인증
|
|
||||||
|
|
||||||
서비스 계정 JSON 키 파일 + scope `https://www.googleapis.com/auth/webmasters`.
|
|
||||||
`google.oauth2.service_account` · `google.auth.transport.requests.Request` · `requests` 는
|
|
||||||
전부 함수 안에서 import 한다. 두 패키지는 백엔드 `requirements.txt`에 포함되어 있다.
|
|
||||||
|
|
||||||
- 토큰은 클라이언트 인스턴스에 캐시된다(`credentials.valid` 인 동안 재사용, 매 호출
|
|
||||||
갱신하지 않는다). 동시 호출은 `asyncio.Lock` 으로 갱신을 한 번만 태운다.
|
|
||||||
- 갱신은 `asyncio.to_thread` 로 별도 스레드에서 돈다. 내부 `requests.Session` 요청에는
|
|
||||||
타임아웃을 강제로 20초로 덮어씌운다(`Request.__call__` 기본값 120초를 무시) — 만료된
|
|
||||||
키·막힌 네트워크에서 무한정 걸리는 것을 막는다.
|
|
||||||
|
|
||||||
## 3. 오류 — `SearchConsoleError(code)`
|
|
||||||
|
|
||||||
`code` 문자열 하나만 들고 다닌다. **Google 응답 본문·액세스 토큰·키 파일 내용·원본 예외
|
|
||||||
메시지는 절대 담지 않는다** — 로그·잡 상태·관리 화면 어디로 흘러도 안전하다.
|
|
||||||
|
|
||||||
| code | 뜻 |
|
|
||||||
|---|---|
|
|
||||||
| `invalid_credentials_file` | 키 파일을 못 읽거나 형식이 잘못됨 |
|
|
||||||
| `auth_failed` | 토큰 갱신 실패, 또는 갱신 후에도 토큰이 비어 있음 |
|
|
||||||
| `unauthorized` | HTTP 401 |
|
|
||||||
| `forbidden` | HTTP 403 |
|
|
||||||
| `rate_limited` | HTTP 429 |
|
|
||||||
| `server_error` | HTTP 5xx |
|
|
||||||
| `http_<code>` | 그 외 실패 상태코드 |
|
|
||||||
| `timeout` | 요청 타임아웃 |
|
|
||||||
| `transport_error` | 그 외 전송 실패(연결 끊김 등) |
|
|
||||||
| `invalid_json` | 200 인데 본문이 JSON 이 아님 |
|
|
||||||
| `missing_inspection_result` | 응답에 `inspectionResult` 가 없음 |
|
|
||||||
| `missing_index_status_result` | `inspectionResult` 는 있는데 `indexStatusResult` 가 없거나 빈 dict |
|
|
||||||
|
|
||||||
## 4. 테스트
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd solution/backend
|
|
||||||
APP_ENV=test .venv/bin/python -m pytest tests/test_search_console_client.py --confcutdir=tests
|
|
||||||
```
|
|
||||||
|
|
||||||
`--confcutdir=tests` 가 필요한 이유: 저장소 루트 `conftest.py` 의 세션 스코프 autouse
|
|
||||||
픽스처가 실 Postgres 연결을 요구한다(`solution/backend/conftest.py`). 이 클라이언트
|
|
||||||
테스트는 DB 를 전혀 쓰지 않으므로 그 픽스처를 건너뛴다 — `--confcutdir=tests` 로 상위
|
|
||||||
`conftest.py` 탐색을 끊는다. (통합 후 전체 스위트를 돌릴 때는 이 플래그 없이 실행한다.)
|
|
||||||
|
|
||||||
Google 실 API 는 전부 `httpx.MockTransport` 로 막았다 — 네트워크 호출도, 과금도 없다.
|
|
||||||
@ -13,30 +13,15 @@ ssh King_admin # ~/.ssh/config 에 정의됨
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
|
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
|
||||||
| 계정 | `o2oadmin` |
|
| 계정 | `o2oadmin` |
|
||||||
| 들어가는 문 | `59.14.81.3:14445` → 킹서버 22 **(2026-09-21 신설)** |
|
| 경유 | `ProxyJump Confluence` = `59.14.81.3:14444` |
|
||||||
|
|
||||||
★ **14444 와 14445 는 서로 다른 서버로 가는 문이다.**
|
|
||||||
`14444` 는 **`.21` 서버**로 간다 — 예전에는 그리로 들어가 킹서버로 한 번 더 건너뛰었다
|
|
||||||
(`ProxyJump`). 인프라가 킹서버 전용 문 `14445` 를 열어 줘서 경유가 없어졌다.
|
|
||||||
→ `Confluence`(14444) 항목을 14445 로 **고치면 안 된다.** 그쪽은 `.21` 이 계속 쓴다.
|
|
||||||
|
|
||||||
★ `~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
|
★ `~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
|
||||||
|
|
||||||
```
|
```
|
||||||
Host King_admin
|
Host King_admin
|
||||||
HostName 59.14.81.3
|
|
||||||
Port 14445
|
|
||||||
User o2oadmin
|
|
||||||
IdentityFile ~/.ssh/<본인 키>
|
|
||||||
IdentitiesOnly yes
|
|
||||||
|
|
||||||
# 14445 가 막혔을 때의 옛 경로. 경유 서버를 거친다.
|
|
||||||
Host King_admin_jump
|
|
||||||
HostName 172.30.1.36
|
HostName 172.30.1.36
|
||||||
User o2oadmin
|
User o2oadmin
|
||||||
ProxyJump Confluence
|
ProxyJump Confluence
|
||||||
IdentityFile ~/.ssh/<본인 키>
|
|
||||||
IdentitiesOnly yes
|
|
||||||
|
|
||||||
Host Confluence
|
Host Confluence
|
||||||
HostName 59.14.81.3
|
HostName 59.14.81.3
|
||||||
@ -44,18 +29,6 @@ Host Confluence
|
|||||||
User o2oadmin
|
User o2oadmin
|
||||||
```
|
```
|
||||||
|
|
||||||
★ **비밀번호로는 못 들어간다.** 키 등록분만 받는다(연구소 인원). 새 사람이 붙으려면 공개키를
|
|
||||||
등록해야 하고, 그건 이 레포 밖의 일이다.
|
|
||||||
|
|
||||||
★ 처음 붙으면 호스트 키 확인을 묻는다. `[59.14.81.3]:14445` 가 SSH 에게는 새 대상이기 때문이다 —
|
|
||||||
**서버가 바뀐 게 아니다.** 지문이 아래와 같으면 같은 서버다(실측 2026-09-21, SSH 가
|
|
||||||
`known_hosts` 의 `172.30.1.36` 항목과 같은 키라고 스스로 알려 준다).
|
|
||||||
|
|
||||||
```
|
|
||||||
ED25519 SHA256:oa/Nz42Liu0pFPJRnhjeVtfl+ov65aRwC6oVbgXe0/Y
|
|
||||||
ECDSA SHA256:ZzgVwQvycWW0Id0+4NHbHV/6RM7nu38aXU7jNhAJRgk
|
|
||||||
```
|
|
||||||
|
|
||||||
## 무엇이 올라가 있나
|
## 무엇이 올라가 있나
|
||||||
|
|
||||||
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
|
Ubuntu 18.04.6 LTS · 24 core · RAM 125G · Docker 24.0.2 · Docker Compose v2.20.3.
|
||||||
|
|||||||
141
docs/SOCIAL.md
141
docs/SOCIAL.md
@ -1,141 +0,0 @@
|
|||||||
# SNS 게재 — Threads
|
|
||||||
|
|
||||||
2026-09-14: 사용자 결정으로 X 구현을 제거하고 Threads를 첫 플랫폼으로 선택했다.
|
|
||||||
API 직접 연동에 공개된 건당 요금·유료 티어는 확인되지 않았다. 영구 무료를 보장한다는 뜻은 아니다.
|
|
||||||
[Meta 공식 API 컬렉션](https://www.postman.com/meta/threads/documentation/dht3nzz/threads-api)은
|
|
||||||
앱 생성·사용자 인가·장기 토큰·텍스트 컨테이너/게시 API를 설명한다.
|
|
||||||
Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권한·최신 한도는 앱 콘솔에서 최종 확인한다.
|
|
||||||
|
|
||||||
## 사용 흐름
|
|
||||||
|
|
||||||
발행 모달의 **Threads에 알리기 → 소개글 쓰기**로 시작한다. 발행에 자동으로 붙지 않는다.
|
|
||||||
확인된 fact가 없거나, 사이트가 미발행이거나, 확정 domain/current_version_id가 없으면 생성하지 않는다.
|
|
||||||
본문은 완결된 짧은 문장과 서버가 계산한 발행 URL이다. 500자에는 링크도 포함한다.
|
|
||||||
문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다.
|
|
||||||
같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.
|
|
||||||
|
|
||||||
미니블로그 승인(이메일 링크 또는 빌더 앱 "바로 발행")도 계정이 연결돼 있으면 같은 문구를
|
|
||||||
그대로 쓰레드에 낸다(`decided_via='mini_blog'`) — 이 경로는 승인 요청·알림톡을 거치지 않고
|
|
||||||
바로 `APPROVED`로 들어간다. 미니블로그 승인 자체가 발화 동의로 취급되기 때문이다(2026-09-21,
|
|
||||||
DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정). 쓰레드 전용으로 새로 짓거나
|
|
||||||
내용을 바꾸는 경로(위 "발행 모달 → 소개글 쓰기")는 여전히 계정 연결 → 승인 요청 → 명시적
|
|
||||||
승인을 그대로 거친다.
|
|
||||||
|
|
||||||
- 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
|
|
||||||
- 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
|
|
||||||
- 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.
|
|
||||||
- 알림톡이 없거나 번호가 없으면 화면만 사용한다. 야간 21:00~08:00 KST에는 화면만 사용한다.
|
|
||||||
- 발송 실패는 HTTP 502와 카드 오류로 남는다. 초안/승인은 보존하고 재요청은 nonce를 교체한다.
|
|
||||||
알림톡은 별도 명시적 요청에서 보내므로 초안 잡의 성공이 알림톡 성공을 뜻하지 않는다.
|
|
||||||
- 사이트 섹션 `social`은 기본 OFF. 켜면 모든 Shell의 main 마지막, footer 앞에 최신 3건을 굽는다.
|
|
||||||
Threads 글 삭제와 별개인 같은 원고의 사본이며 링크 문구는 **SNS에 올린 글 보기**다.
|
|
||||||
|
|
||||||
## 연동 준비 — 한 번만 하는 일
|
|
||||||
|
|
||||||
계정 연결은 **두 쪽이 나뉜다.** 우리가 한 번 준비하고(앱 등록), 사장님은 버튼 두 번을 누른다.
|
|
||||||
|
|
||||||
### 1. 우리가 한 번 (Meta 앱 콘솔)
|
|
||||||
|
|
||||||
1. 개발자 콘솔에서 앱을 만들고 **Threads API** 제품을 추가한다.
|
|
||||||
★ Threads 자격증명은 페이스북·인스타그램 앱의 것과 **별개**다. Threads 쪽 앱 ID·시크릿을 쓴다.
|
|
||||||
2. **리디렉션 콜백 URL** 에 `https://<발행호스트>/v1/social/oauth/callback` 을 등록한다.
|
|
||||||
★ **https 여야 한다.** `http://localhost` 는 콜백으로 등록되지 않는다 — 로컬에서 끝까지
|
|
||||||
돌려보려면 터널(cloudflared·ngrok)로 https 주소를 만들어 그 주소를 등록하거나,
|
|
||||||
https 가 붙어 있는 킹서버에서 확인한다. 이 제약 때문에 **연결만은 로컬 단독으로 검증되지 않는다.**
|
|
||||||
3. 권한은 `threads_basic` · `threads_content_publish` 둘이다(`services/external/threads.py SCOPES`).
|
|
||||||
4. **심사 전에는 앱 역할에 추가된 계정만 인가된다.** 시험할 사장님 Threads 계정을 테스터로
|
|
||||||
먼저 추가한다 — 이걸 빼먹으면 인가 화면까지 가서 거절당하고, 화면에는 `?social=failed` 만 뜬다.
|
|
||||||
5. 루트 `.env` 에 셋을 채우고 백엔드·워커를 다시 띄운다.
|
|
||||||
```
|
|
||||||
THREADS_APP_ID=…
|
|
||||||
THREADS_APP_SECRET=…
|
|
||||||
THREADS_REDIRECT_URI=https://<발행호스트>/v1/social/oauth/callback
|
|
||||||
```
|
|
||||||
★ `SOCIAL_TOKEN_SECRET`(Fernet 키)이 없으면 **연결 기능 자체가 꺼진다.** 평문으로 토큰을
|
|
||||||
보관하는 길은 만들지 않았다. 만드는 법: `python -c "from cryptography.fernet import Fernet;
|
|
||||||
print(Fernet.generate_key().decode())"`
|
|
||||||
★ 이 키를 잃어버리면 저장된 토큰을 복호화할 수 없다 — 모든 사장님이 **다시 연결**해야 한다
|
|
||||||
(그때 `TOKEN_KEY_CHANGED` 로 `needs_reauth` 가 된다).
|
|
||||||
|
|
||||||
셋 중 하나라도 비면 `connection_enabled=false` 로 내려가 **연결 버튼이 아예 안 뜬다.**
|
|
||||||
버튼을 눌러도 서버는 `409 SOCIAL_CONNECTION_DISABLED` 로 거절한다 — 반쯤 연결된 상태를 만들지 않는다.
|
|
||||||
|
|
||||||
### 2. 사장님이 하는 일 — 연결은 [내 사이트], 게재는 사이트마다
|
|
||||||
|
|
||||||
**연결(한 번)**: `/sites` **내 사이트** 화면 위의 `SNS 연동 · Threads` 카드 →
|
|
||||||
[Threads 계정 연결] → Meta 인가 화면에서 허용 → 돌아오면 카드에 `@핸들` 이 뜬다.
|
|
||||||
|
|
||||||
**게재(사이트마다)**: 발행한 사이트의 발행 화면 → [소개글 쓰기] → [승인 요청] → 승인.
|
|
||||||
|
|
||||||
★ **연결 버튼을 사업장 화면에 두지 않는다.** 계정은 `user × provider` 하나인데 버튼이
|
|
||||||
사업장 안에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 연결은 한 번, 게재는
|
|
||||||
사이트마다다 — 화면이 그 모양을 그대로 말해야 한다.
|
|
||||||
★ 앱 자격증명이 없으면 이 카드는 **아예 안 그려진다**(`GET /v1/social/account` 의
|
|
||||||
`connection_enabled`). 누를 수 없는 버튼을 세워 두면 사장님에게는 고장난 화면이다.
|
|
||||||
|
|
||||||
### 3. 연결이 안 될 때 — 어디를 보나
|
|
||||||
|
|
||||||
콜백은 **화면에 이유를 내보내지 않는다**(OAuth 응답·state 에 자격증명이 들어 있다).
|
|
||||||
대신 서버 로그에 남는다:
|
|
||||||
|
|
||||||
```
|
|
||||||
docker compose logs -f solution-backend | grep "\[social\]"
|
|
||||||
[social] 계정 연결 실패: SocialError: INVALID_OAUTH_STATE ← 쿠키 유실·10분 만료
|
|
||||||
[social] 계정 연결 실패: SocialError: THREADS_REJECTED_400 ← 앱 ID/시크릿·리디렉션 URI 불일치
|
|
||||||
[social] 계정 연결 중단(제공자 응답): access_denied ← 사장님이 인가를 취소함
|
|
||||||
```
|
|
||||||
|
|
||||||
★ 쿠키는 `Secure` 다. https 가 아닌 호스트(예: 사내 IP 로 직접 접속)에서는 브라우저가 쿠키를
|
|
||||||
저장하지 않아 **항상 `INVALID_OAUTH_STATE`** 가 된다. 원인이 화면에 안 보이는 종류라 여기 적어 둔다.
|
|
||||||
|
|
||||||
## 보완한 안전장치
|
|
||||||
|
|
||||||
**POSTING 10분 경과는 UNKNOWN**이다. APPROVED로 되돌리면 응답 유실/프로세스 종료 때 중복 게시한다.
|
|
||||||
동일하게 API 성공 이후 DB 커밋 실패도 UNKNOWN으로 남긴다. 사람이 Threads에서 실제 결과를 확인해야 한다.
|
|
||||||
UNKNOWN에는 재게시 버튼이 없다. 플랫폼이 명확히 거절한 FAILED만 새 승인을 받을 수 있다.
|
|
||||||
게시 ID를 받았으면 permalink 조회 실패에도 POSTED로 기록하고 링크 없이 소식을 보여준다.
|
|
||||||
|
|
||||||
승인은 nonce 32바이트의 SHA-256과 PENDING_APPROVAL 조건부 UPDATE를 쓴다.
|
|
||||||
JWT_ACCESS_SECRET·로그인 토큰·사용자 role은 승인 URL에 들어가지 않는다.
|
|
||||||
승인 전이와 SOCIAL_POST 큐 삽입은 한 트랜잭션이다. GET은 만료 상태를 변경하지 않는다.
|
|
||||||
화면에서도 시각으로 만료를 표시하므로 스윕 지연이 승인 가능 표시로 이어지지 않는다.
|
|
||||||
재연결로 account_id가 바뀌면 옛 승인으로 게시할 수 없다. 게시 직전 소유자·사이트 상태·주소·계정을 재검사한다.
|
|
||||||
|
|
||||||
계정은 user에 붙는다. 연결 교체·해제·갱신·게시는 user/provider DB 잠금을 공유한다.
|
|
||||||
토큰은 Fernet 암호문만 저장하고 키가 없거나 형식이 잘못되면 연결하지 않는다.
|
|
||||||
OAuth state도 암호화하고 10분 TTL과 HttpOnly/Secure/SameSite 쿠키로 요청 브라우저에 묶는다.
|
|
||||||
Threads는 X의 offline.access/refresh_token을 쓰지 않는다. 장기 access token 자체를 갱신하며
|
|
||||||
만료·거절·저장 실패는 재연결 대상으로 처리한다. 자동 주기 갱신은 아직 없으므로 장기 미사용 뒤에는 다시 연결한다.
|
|
||||||
연결 해제는 보관 토큰을 제거하고 모든 업장의 이후 게시를 막는다. Threads에 이미 쓴 글은 지우지 않는다.
|
|
||||||
|
|
||||||
## 활성화 전 확인
|
|
||||||
|
|
||||||
기본 `SOCIAL_POSTING_ENABLED=0`. 지금 실행하지 않은 외부 작업은 다음과 같다.
|
|
||||||
|
|
||||||
1. Meta 앱 등록, 사용자 계정용 `threads_basic`·`threads_content_publish` 권한 심사와 테스트 계정 실게시.
|
|
||||||
2. HTTPS OAuth callback과 동일 오리진 쿠키 동작, 보안 키 보관·복원 절차 확인.
|
|
||||||
3. DECISIONS 1-4의 해지 안내 페이지 구현/검증. 현재 사이트 상태 전이만으로는 안내 HTML이 재생성되지 않는다.
|
|
||||||
이 선행조건을 해결하기 전에 운영 자동 게재를 활성화하지 않는다.
|
|
||||||
4. 알림톡 대행사 확정·발신프로필·템플릿 심사. 초안 리소스는 `services/resources/social_approval.json`.
|
|
||||||
승인 주소는 `#{승인주소}` 버튼 변수에 연결하고 문구는 심사본과 맞춘다. 아직 심사받은 템플릿이 아니다.
|
|
||||||
5. 발신번호·단가·야간 정책·24시간 만료를 운영 정책으로 확정.
|
|
||||||
|
|
||||||
`nginx/site.conf.example`의 `/approve/`·`/v1/social/` 블록을 실제 설정에도 반영한다.
|
|
||||||
앞단 프록시도 query string을 기록하지 않아야 한다. 앱 승인 페이지와 API는 no-store/no-referrer다.
|
|
||||||
마이그레이션 0012/0013 적용 후 빌더/API/워커/프리렌더를 배포한다.
|
|
||||||
발행 렌더러 변경은 전체 재굽기와 `republish_all.py`가 필요하며 payload 없는 목업은 대상이 아니다.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
| 메서드/경로 | 역할 |
|
|
||||||
|---|---|
|
|
||||||
| GET /v1/social/place/{place_id} | 소유자 범위 원고 목록·계정 표시 |
|
|
||||||
| POST /v1/social/place/{place_id}/draft | 초안/잡 원자 생성, 같은 버전 재사용 |
|
|
||||||
| POST /v1/social/posts/{post_id}/request-approval | nonce 발급·계정 고정·선택적 알림톡 |
|
|
||||||
| POST /v1/social/posts/{post_id}/decision | 로그인한 소유자의 화면 승인/거절 |
|
|
||||||
| GET /v1/social/approval/{post_id}?t=… | 무인증 읽기 전용 확인 |
|
|
||||||
| POST /v1/social/approval/{post_id}/decision | `{t, approve}` 일회성 결정 |
|
|
||||||
| GET /v1/social/account | 연결 상태만 — 사업장을 안 고르고 답한다(내 사이트 카드) |
|
|
||||||
| POST /v1/social/oauth/connect | Threads 인가 URL·브라우저 쿠키 발급 |
|
|
||||||
| GET /v1/social/oauth/callback | 코드 교환·암호문 보관 |
|
|
||||||
| POST /v1/social/oauth/disconnect | user 단위 모든 사업장 연결 해제 |
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
# Teams 웹훅 확인 (2026-09-15)
|
|
||||||
|
|
||||||
- Adaptive Card 요청의 `contentUrl: null` 및 `$schema`를 공식 예제에 맞춰 보완했다.
|
|
||||||
- HTTP 202는 워크플로의 요청 접수다. Teams 채널 게시 성공을 뜻하지 않는다.
|
|
||||||
- 실제 전송 2건은 202였지만 사용자가 확인한 워크플로 실행은 실패였다.
|
|
||||||
상세 오류를 확인하지 못했으므로 누락 필드를 실제 실패 원인으로 단정하지 않는다.
|
|
||||||
- 운영 자동 알림 활성화 전, 채널 수신 또는 워크플로의 최종 게시 단계 성공을 확인해야 한다.
|
|
||||||
- 웹훅은 `.env`에만 보관하고 커밋하지 않는다.
|
|
||||||
|
|
||||||
검증: 백엔드에서 `APP_ENV=test PYTHONPATH=. .venv/bin/pytest tests/test_search_console_alerts.py --confcutdir=tests`.
|
|
||||||
공식 형식: https://learn.microsoft.com/en-us/connectors/teams/#adaptivecarditemschema
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
# 오늘의 날씨
|
|
||||||
|
|
||||||
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
|
|
||||||
|
|
||||||
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
|
|
||||||
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. **API 키 없음** — Open-Meteo
|
|
||||||
는 키 발급 없이 쓰는 무료 공개 엔드포인트다(`services/external/open_meteo.py`).
|
|
||||||
|
|
||||||
## Open-Meteo 응답 → 내부 스냅샷
|
|
||||||
|
|
||||||
`GET https://api.open-meteo.com/v1/forecast?latitude=&longitude=¤t=temperature_2m,weather_code,wind_speed_10m&timezone=auto`
|
|
||||||
|
|
||||||
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
|
|
||||||
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로
|
|
||||||
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
|
|
||||||
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
|
|
||||||
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
|
|
||||||
|
|
||||||
- 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
|
|
||||||
- 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
|
|
||||||
|
|
||||||
둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
|
|
||||||
분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
|
|
||||||
|
|
||||||
| 코드 | WMO 의미(영어) | 분류(=조건 라벨) |
|
|
||||||
|---|---|---|
|
|
||||||
| 0 | Clear sky | 맑음 |
|
|
||||||
| 1 | Mainly clear | 대체로 맑음 |
|
|
||||||
| 2 | Partly cloudy | 구름 조금 |
|
|
||||||
| 3 | Overcast | 흐림 |
|
|
||||||
| 45 | Fog | 안개 |
|
|
||||||
| 48 | Depositing rime fog | 착빙성 안개 |
|
|
||||||
| 51 | Drizzle: Light intensity | 가벼운 이슬비 |
|
|
||||||
| 53 | Drizzle: Moderate intensity | 보통 이슬비 |
|
|
||||||
| 55 | Drizzle: Dense intensity | 강한 이슬비 |
|
|
||||||
| 56 | Freezing Drizzle: Light intensity | 가벼운 착빙성 이슬비 |
|
|
||||||
| 57 | Freezing Drizzle: Dense intensity | 강한 착빙성 이슬비 |
|
|
||||||
| 61 | Rain: Slight intensity | 약한 비 |
|
|
||||||
| 63 | Rain: Moderate intensity | 보통 비 |
|
|
||||||
| 65 | Rain: Heavy intensity | 강한 비 |
|
|
||||||
| 66 | Freezing Rain: Light intensity | 약한 착빙성 비 |
|
|
||||||
| 67 | Freezing Rain: Heavy intensity | 강한 착빙성 비 |
|
|
||||||
| 71 | Snow fall: Slight intensity | 약한 눈 |
|
|
||||||
| 73 | Snow fall: Moderate intensity | 보통 눈 |
|
|
||||||
| 75 | Snow fall: Heavy intensity | 강한 눈 |
|
|
||||||
| 77 | Snow grains | 싸라기눈 |
|
|
||||||
| 80 | Rain showers: Slight | 약한 소나기 |
|
|
||||||
| 81 | Rain showers: Moderate | 보통 소나기 |
|
|
||||||
| 82 | Rain showers: Violent | 강한 소나기 |
|
|
||||||
| 85 | Snow showers: Slight | 약한 소나기눈 |
|
|
||||||
| 86 | Snow showers: Heavy | 강한 소나기눈 |
|
|
||||||
| 95 | Thunderstorm: Slight or moderate | 뇌우 |
|
|
||||||
| 96 | Thunderstorm with slight hail | 약한 우박 뇌우 |
|
|
||||||
| 99 | Thunderstorm with heavy hail | 강한 우박 뇌우 |
|
|
||||||
|
|
||||||
이 28개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만
|
|
||||||
안전하게 `흐림`으로 떨어진다(실제로는 도달하지 않는 방어 분기).
|
|
||||||
|
|
||||||
`weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
|
|
||||||
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
|
|
||||||
`/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
|
|
||||||
`눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection`의 `skyKey`는 노트에 그 조건 키가
|
|
||||||
실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
|
|
||||||
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
|
|
||||||
|
|
||||||
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
|
|
||||||
|
|
||||||
하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
|
|
||||||
고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
|
|
||||||
브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
|
|
||||||
중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
|
|
||||||
|
|
||||||
**세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
|
|
||||||
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
|
|
||||||
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
|
|
||||||
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
|
|
||||||
|
|
||||||
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
|
|
||||||
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
|
|
||||||
지역별 장소 추천을 자동 생성하는 작업은 포함하지 않았다. 목업의 수기 문구·산출물은 변경하지 않는다.
|
|
||||||
옛 단일 `note`·`notes`·`tempNotes` payload도 계속 지원한다. 이미 발행된 사이트는 재발행해야 반영된다.
|
|
||||||
@ -27,18 +27,19 @@ COPY admin ./admin
|
|||||||
ARG VITE_API_BASE_URL
|
ARG VITE_API_BASE_URL
|
||||||
ARG VITE_PUBLISH_HOST
|
ARG VITE_PUBLISH_HOST
|
||||||
ARG VITE_SITE_PREVIEW_URL
|
ARG VITE_SITE_PREVIEW_URL
|
||||||
|
# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 —
|
||||||
|
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession).
|
||||||
|
ARG VITE_AUTO_LOGIN_ID
|
||||||
|
ARG VITE_AUTO_LOGIN_PW
|
||||||
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
|
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
|
||||||
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
|
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
|
||||||
ARG VITE_GOOGLE_CLIENT_ID
|
ARG VITE_GOOGLE_CLIENT_ID
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
|
||||||
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
|
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
|
||||||
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
|
VITE_SITE_PREVIEW_URL=$VITE_SITE_PREVIEW_URL \
|
||||||
|
VITE_AUTO_LOGIN_ID=$VITE_AUTO_LOGIN_ID \
|
||||||
|
VITE_AUTO_LOGIN_PW=$VITE_AUTO_LOGIN_PW \
|
||||||
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID
|
||||||
# ★ VITE_AUTO_LOGIN_ID·PW 를 여기서 **절대 받지 않는다.** 이 이미지가 사장님에게 열리는
|
|
||||||
# 운영 진입점(solution-site)이다 — 자동 로그인 계정이 번들에 구워지면 페이지를 연 누구나
|
|
||||||
# JS 에서 그대로 읽는다. 내부 테스트용 자동 로그인은 solution-frontend(--profile dev,
|
|
||||||
# vite dev)에만 있다 — 그쪽은 이 Dockerfile 을 타지 않는다(lib/autoSession.ts 의 DEV 가드도
|
|
||||||
# 같은 이유로 있다 — 이 ARG 가 실수로 되돌아와도 프로덕션 빌드에서는 죽은 코드가 된다).
|
|
||||||
RUN npm run build -w @o2o/frontend
|
RUN npm run build -w @o2o/frontend
|
||||||
|
|
||||||
FROM nginx:alpine
|
FROM nginx:alpine
|
||||||
|
|||||||
@ -43,22 +43,6 @@ server {
|
|||||||
application/javascript application/json application/xml
|
application/javascript application/json application/xml
|
||||||
image/svg+xml;
|
image/svg+xml;
|
||||||
|
|
||||||
# 승인 nonce 가 액세스 로그·Referer·검색 색인으로 새지 않게 이 자리만 따로 준다.
|
|
||||||
#
|
|
||||||
# ★ `try_files` 를 쓰면 헤더가 사라진다. try_files 의 폴백은 **내부 리다이렉트**라
|
|
||||||
# 요청이 이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는
|
|
||||||
# 아래 add_header 가 하나도 붙지 않는다(실측 2026-09-14: 200 은 뜨는데 헤더만 없다).
|
|
||||||
# `rewrite ... break` 는 같은 블록 안에 머문다 — 그래서 이 모양이어야 한다.
|
|
||||||
# ★ 승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다. 곧바로 셸을 준다.
|
|
||||||
location ^~ /approve/ {
|
|
||||||
root /srv/app;
|
|
||||||
access_log off;
|
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
|
||||||
rewrite ^ /__spa-fallback.html break;
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── 발행 사이트 ────────────────────────────────────────────
|
# ── 발행 사이트 ────────────────────────────────────────────
|
||||||
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
# ★ 리다이렉트는 상대 Location 으로 낸다. 기본값(absolute_redirect on)은 `$scheme` 로
|
||||||
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
|
||||||
@ -139,21 +123,6 @@ server {
|
|||||||
|
|
||||||
# ── API ────────────────────────────────────────────────────
|
# ── API ────────────────────────────────────────────────────
|
||||||
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
|
# 앱과 같은 오리진이라 프리플라이트가 아예 발생하지 않는다.
|
||||||
location ^~ /v1/social/ {
|
|
||||||
access_log off;
|
|
||||||
add_header Cache-Control "no-store" always;
|
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
|
||||||
proxy_pass $api;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
|
||||||
# 발행·수집 잡은 분 단위다. 기본 60s 면 게이트웨이가 먼저 끊는다.
|
|
||||||
proxy_read_timeout 300s;
|
|
||||||
proxy_send_timeout 300s;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
|
location ~ ^/(v1/|healthz$|openapi\.json$|docs|redoc) {
|
||||||
proxy_pass $api;
|
proxy_pass $api;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
32
package-lock.json
generated
32
package-lock.json
generated
@ -13,9 +13,6 @@
|
|||||||
"solution/site",
|
"solution/site",
|
||||||
"admin/frontend"
|
"admin/frontend"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
|
||||||
"playwright": "^1.63.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
@ -7340,35 +7337,6 @@
|
|||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"dependencies": {
|
|
||||||
"playwright-core": "1.63.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"playwright": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/playwright-core": {
|
|
||||||
"version": "1.63.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
|
||||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"bin": {
|
|
||||||
"playwright-core": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pony-cause": {
|
"node_modules/pony-cause": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz",
|
||||||
|
|||||||
@ -26,8 +26,5 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"playwright": "^1.63.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -81,7 +81,6 @@ CREATE TABLE IF NOT EXISTS public.users (
|
|||||||
role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer
|
role SMALLINT NOT NULL DEFAULT 1, -- UserRole: 1=user 2=owner 3=developer
|
||||||
provider SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google
|
provider SMALLINT NOT NULL DEFAULT 1, -- AuthProvider: 1=local(id/pw) 2=google
|
||||||
provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키
|
provider_uid VARCHAR(255) NULL, -- 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일 키
|
||||||
token_version SMALLINT NOT NULL DEFAULT 1, -- ★ refresh 토큰 무효화 키. JWT(access·refresh)의 sub 에 실려 나간다 — 이 값을 올리면(bump_token_version) 그 전에 발급된 refresh 토큰은 다음 재발급에서 전부 거절된다
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
@ -94,12 +93,11 @@ CREATE TABLE IF NOT EXISTS public.users (
|
|||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE TABLE IF NOT EXISTS public.jobs (
|
CREATE TABLE IF NOT EXISTS public.jobs (
|
||||||
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다
|
job_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- ★ 이 표만 server_default 가 꼭 필요하다 — 큐 전이가 raw SQL(RETURNING)이라 ORM 의 파이썬 default 가 안 먹는다
|
||||||
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check 7=song 8=rollback 9=social_draft 10=social_post
|
job_type SMALLINT NOT NULL, -- JobType: 1=collect 2=vision 3=copy 4=build 5=local_sync 6=ai_check
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
|
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
|
||||||
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
|
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
|
||||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
result JSONB NULL,
|
result JSONB NULL,
|
||||||
progress JSONB NULL,
|
|
||||||
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code}
|
dedupe_key VARCHAR(200) NULL, -- 활성 중복 방지 키(부분 유니크). 지역 이야기는 story:{region_code}
|
||||||
attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1
|
attempts SMALLINT NOT NULL DEFAULT 0, -- claim 시 +1
|
||||||
max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD
|
max_attempts SMALLINT NOT NULL DEFAULT 3, -- 소진되면 DEAD
|
||||||
@ -134,7 +132,6 @@ CREATE TABLE IF NOT EXISTS public.places (
|
|||||||
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
|
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
|
||||||
verified_by uuid NULL,
|
verified_by uuid NULL,
|
||||||
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
|
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
|
||||||
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
@ -320,77 +317,6 @@ CREATE TABLE IF NOT EXISTS public.place_area_refs (
|
|||||||
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
|
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
|
||||||
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
|
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
CREATE TABLE IF NOT EXISTS public.site_search_status (
|
|
||||||
site_id uuid PRIMARY KEY,
|
|
||||||
site_version_id uuid NOT NULL,
|
|
||||||
property_url TEXT NOT NULL,
|
|
||||||
page_url TEXT NOT NULL,
|
|
||||||
published_at TIMESTAMPTZ NOT NULL,
|
|
||||||
sitemap_submitted_at TIMESTAMPTZ NULL,
|
|
||||||
inspected_at TIMESTAMPTZ NULL,
|
|
||||||
first_indexed_at TIMESTAMPTZ NULL,
|
|
||||||
inspection JSONB NULL,
|
|
||||||
error_code VARCHAR(100) NULL,
|
|
||||||
failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
alerted_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 장애 알림 발송함 — services/alert_service.py. 워커·스케줄러가 죽어도 알림 자체는
|
|
||||||
-- DB 에 남아야 한다(메모리 큐로만 두면 장애를 알릴 메시지까지 같이 잃는다).
|
|
||||||
CREATE TABLE IF NOT EXISTS public.alert_outbox (
|
|
||||||
alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
kind VARCHAR(50) NOT NULL, -- job_dead · build_failed · partial_failure · queue_stuck · recovery …
|
|
||||||
dedupe_key VARCHAR(200) NULL, -- 같은 사유의 재시도 스팸을 막는 키(alert_service.send_alert)
|
|
||||||
title VARCHAR(200) NOT NULL,
|
|
||||||
detail TEXT NULL, -- 이미 비밀·개인정보를 걷어낸 텍스트만(_scrub)
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- AlertStatus: 1=pending 2=sent 3=failed(재시도 소진)
|
|
||||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
|
||||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
sent_at TIMESTAMPTZ NULL,
|
|
||||||
resolved_at TIMESTAMPTZ NULL, -- 채워지면 그 dedupe_key 는 "복구됨" — 다음 문제 발생 때 새로 알린다
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_posts (
|
|
||||||
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
body VARCHAR(400) NOT NULL, -- 본문 140~150자
|
|
||||||
topic_kind SMALLINT NOT NULL, -- PostTopicKind: 1=weather 2=festival 3=season 4=nearby 5=guide
|
|
||||||
topic_key VARCHAR(120) NOT NULL, -- 축제 id · 절기 · 장소 id — 중복 방지의 축
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- PostStatus: 1=draft 2=reviewed 3=sent 4=approved 5=published 6=skipped
|
|
||||||
scheduled_date DATE NULL, -- 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다
|
|
||||||
generation_meta JSONB NULL, -- 생성 당시 부가정보(모델명 등) — 컬럼 안 늘리고 여기 담는다
|
|
||||||
approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일 본문에만
|
|
||||||
token_expires_at TIMESTAMPTZ NULL,
|
|
||||||
sent_at TIMESTAMPTZ NULL,
|
|
||||||
approved_at TIMESTAMPTZ NULL,
|
|
||||||
published_at TIMESTAMPTZ NULL,
|
|
||||||
published_version_id uuid NULL, -- site_versions.site_version_id — 롤백 때 필요
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_reviews (
|
|
||||||
review_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
body VARCHAR(1000) NOT NULL,
|
|
||||||
nickname VARCHAR(40) NULL, -- 표시 이름. 비면 '손님'
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- ReviewStatus: 1=pending 2=published 3=rejected
|
|
||||||
submitted_ip_hash VARCHAR(64) NULL, -- sha256(ip+소금). 원문 IP 는 남기지 않는다
|
|
||||||
published_at TIMESTAMPTZ NULL,
|
|
||||||
published_version_id uuid NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.sites (
|
CREATE TABLE IF NOT EXISTS public.sites (
|
||||||
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
place_id uuid NOT NULL, -- 사업장과 1:1
|
place_id uuid NOT NULL, -- 사업장과 1:1
|
||||||
@ -548,36 +474,6 @@ CREATE INDEX IF NOT EXISTS ix_jobs_lease ON public.jobs (status, lease_until);
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key)
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_dedupe_active ON public.jobs (dedupe_key)
|
||||||
WHERE status IN (1, 2) AND dedupe_key IS NOT NULL;
|
WHERE status IN (1, 2) AND dedupe_key IS NOT NULL;
|
||||||
|
|
||||||
-- place_reviews (이용 후기)
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_reviews_published
|
|
||||||
ON public.place_reviews (place_id, published_at DESC) WHERE deleted = FALSE AND status = 2;
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_reviews_status
|
|
||||||
ON public.place_reviews (status, created_at) WHERE deleted = FALSE;
|
|
||||||
|
|
||||||
-- place_posts (미니 블로그)
|
|
||||||
-- 같은 업장에 같은 주제를 두 번 만들지 않는다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_topic
|
|
||||||
ON public.place_posts (place_id, topic_key) WHERE deleted = FALSE;
|
|
||||||
-- 하루 한 통 배정 — 같은 업장이 같은 날짜를 두 번 차지하지 않는다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_scheduled_date
|
|
||||||
ON public.place_posts (place_id, scheduled_date) WHERE deleted = FALSE AND scheduled_date IS NOT NULL;
|
|
||||||
-- 화면이 읽는 경로: 그 업장의 게재된 글을 최신순.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_published
|
|
||||||
ON public.place_posts (place_id, published_at DESC) WHERE deleted = FALSE AND status = 5;
|
|
||||||
-- 운영 경로: 검수 대기·발송 대기 목록.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_status
|
|
||||||
ON public.place_posts (status, created_at) WHERE deleted = FALSE;
|
|
||||||
-- 승인 링크가 토큰 해시로 글을 찾는다.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_token
|
|
||||||
ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
|
|
||||||
|
|
||||||
-- alert_outbox
|
|
||||||
-- 재시도 경로: PENDING(1) 이면서 next_attempt_at 이 지난 것.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);
|
|
||||||
-- 최근 같은 사유 조회(dedupe·복구 판정): send_alert·resolve_alert 가 dedupe_key 로 최신 행을 찾는다.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_alert_outbox_dedupe ON public.alert_outbox (dedupe_key, created_at DESC)
|
|
||||||
WHERE dedupe_key IS NOT NULL;
|
|
||||||
|
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
-- 마이그레이션 기준선(baseline)
|
-- 마이그레이션 기준선(baseline)
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@ -602,92 +498,3 @@ INSERT INTO public.schema_migrations (version) VALUES
|
|||||||
('0008_personalization_to_site_sections'),
|
('0008_personalization_to_site_sections'),
|
||||||
('0009_align_with_init_sql')
|
('0009_align_with_init_sql')
|
||||||
ON CONFLICT (version) DO NOTHING;
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
-- SNS: credentials and approval records never enter public payloads.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.owner_social_accounts (
|
|
||||||
account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
provider smallint NOT NULL CHECK (provider IN (1,2)),
|
|
||||||
provider_user_id varchar(200) NOT NULL,
|
|
||||||
handle varchar(200) NOT NULL,
|
|
||||||
profile_url text NOT NULL,
|
|
||||||
access_token text,
|
|
||||||
refresh_token text,
|
|
||||||
access_expires_at timestamptz,
|
|
||||||
scopes jsonb NOT NULL DEFAULT '[]',
|
|
||||||
status varchar(20) NOT NULL DEFAULT 'linked',
|
|
||||||
last_error text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_social_account ON public.owner_social_accounts(user_id, provider) WHERE deleted=false AND status IN ('linked','needs_reauth');
|
|
||||||
|
|
||||||
-- 카카오톡 채널 신원 연결 — 채널 발화자를 우리 user_id 에 묶는다(migrations/0021).
|
|
||||||
CREATE TABLE IF NOT EXISTS public.owner_kakao_links (
|
|
||||||
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
-- 연결이 끝나야 채워진다. PENDING 행은 아직 누구의 카톡인지 모른다.
|
|
||||||
channel_user_key varchar(200),
|
|
||||||
code_sha varchar(64),
|
|
||||||
code_expires_at timestamptz,
|
|
||||||
-- 소진된 코드 시도 횟수. 짧은 코드라 무차별 대입을 이 값으로 끊는다.
|
|
||||||
code_attempts smallint NOT NULL DEFAULT 0,
|
|
||||||
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
|
|
||||||
linked_at timestamptz,
|
|
||||||
last_seen_at timestamptz,
|
|
||||||
-- 대화 상태(migrations/0022) — 카카오톡은 앞선 답을 되돌려 주지 않는다.
|
|
||||||
current_place_id uuid,
|
|
||||||
pending_tool varchar(40),
|
|
||||||
pending_args jsonb,
|
|
||||||
pending_expires_at timestamptz,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 한 사장님은 활성 연결 하나. 다시 [연결하기] 를 눌러도 행이 늘지 않고 코드만 바뀐다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_user
|
|
||||||
ON public.owner_kakao_links(user_id)
|
|
||||||
WHERE deleted=false AND status IN ('PENDING','LINKED');
|
|
||||||
|
|
||||||
-- ★ 한 카카오 계정은 한 사장님에만 묶인다. 없으면 같은 카톡 계정이 여러 사장님에
|
|
||||||
-- 연결돼 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_channel_key
|
|
||||||
ON public.owner_kakao_links(channel_user_key)
|
|
||||||
WHERE deleted=false AND status='LINKED';
|
|
||||||
|
|
||||||
-- 코드 소비는 이 인덱스로 한 행을 집는다(일회성은 UPDATE ... WHERE status='PENDING' CAS 가 보장).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_code
|
|
||||||
ON public.owner_kakao_links(code_sha)
|
|
||||||
WHERE deleted=false AND status='PENDING';
|
|
||||||
|
|
||||||
-- SNS: credentials and approval records never enter public payloads.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_social_posts (
|
|
||||||
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
site_version_id uuid NOT NULL,
|
|
||||||
account_id uuid,
|
|
||||||
provider smallint NOT NULL CHECK (provider IN (1,2)),
|
|
||||||
body text NOT NULL DEFAULT '',
|
|
||||||
link_url text NOT NULL,
|
|
||||||
grounded_facts jsonb NOT NULL DEFAULT '[]',
|
|
||||||
status varchar(24) NOT NULL DEFAULT 'DRAFTING',
|
|
||||||
approval_token_sha varchar(64),
|
|
||||||
approval_sent_at timestamptz,
|
|
||||||
approval_channel varchar(20),
|
|
||||||
approval_expires_at timestamptz,
|
|
||||||
decided_at timestamptz,
|
|
||||||
decided_via varchar(20),
|
|
||||||
provider_post_id varchar(200),
|
|
||||||
permalink text,
|
|
||||||
posted_at timestamptz,
|
|
||||||
last_error text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
-- 게시 성공 뒤의 이중 클릭도 막는다. 같은 버전은 기존 초안을 재사용한다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_social_version ON public.place_social_posts(place_id, site_version_id) WHERE deleted=false;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_social_posted ON public.place_social_posts(place_id, posted_at DESC) WHERE deleted=false AND status='POSTED';
|
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
-- SNS: credentials and approval records never enter public payloads.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.owner_social_accounts (
|
|
||||||
account_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
provider smallint NOT NULL CHECK (provider IN (1,2)),
|
|
||||||
provider_user_id varchar(200) NOT NULL,
|
|
||||||
handle varchar(200) NOT NULL,
|
|
||||||
profile_url text NOT NULL,
|
|
||||||
access_token text,
|
|
||||||
refresh_token text,
|
|
||||||
access_expires_at timestamptz,
|
|
||||||
scopes jsonb NOT NULL DEFAULT '[]',
|
|
||||||
status varchar(20) NOT NULL DEFAULT 'linked',
|
|
||||||
last_error text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_social_account ON public.owner_social_accounts(user_id, provider) WHERE deleted=false AND status IN ('linked','needs_reauth');
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
-- 새로고침 후에도 워커가 마지막으로 기록한 단계를 조회한다.
|
|
||||||
ALTER TABLE public.jobs ADD COLUMN IF NOT EXISTS progress JSONB NULL;
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
-- SNS: credentials and approval records never enter public payloads.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_social_posts (
|
|
||||||
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
site_version_id uuid NOT NULL,
|
|
||||||
account_id uuid,
|
|
||||||
provider smallint NOT NULL CHECK (provider IN (1,2)),
|
|
||||||
body text NOT NULL DEFAULT '',
|
|
||||||
link_url text NOT NULL,
|
|
||||||
grounded_facts jsonb NOT NULL DEFAULT '[]',
|
|
||||||
status varchar(24) NOT NULL DEFAULT 'DRAFTING',
|
|
||||||
approval_token_sha varchar(64),
|
|
||||||
approval_sent_at timestamptz,
|
|
||||||
approval_channel varchar(20),
|
|
||||||
approval_expires_at timestamptz,
|
|
||||||
decided_at timestamptz,
|
|
||||||
decided_via varchar(20),
|
|
||||||
provider_post_id varchar(200),
|
|
||||||
permalink text,
|
|
||||||
posted_at timestamptz,
|
|
||||||
last_error text,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
-- 게시 성공 뒤의 이중 클릭도 막는다. 같은 버전은 기존 초안을 재사용한다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_social_version ON public.place_social_posts(place_id, site_version_id) WHERE deleted=false;
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_social_posted ON public.place_social_posts(place_id, posted_at DESC) WHERE deleted=false AND status='POSTED';
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- 발행 상태를 바꾸지 않고 Google 제출/검사 결과를 추적한다.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.site_search_status (
|
|
||||||
site_id uuid PRIMARY KEY,
|
|
||||||
site_version_id uuid NOT NULL,
|
|
||||||
property_url TEXT NOT NULL,
|
|
||||||
page_url TEXT NOT NULL,
|
|
||||||
published_at TIMESTAMPTZ NOT NULL,
|
|
||||||
sitemap_submitted_at TIMESTAMPTZ NULL,
|
|
||||||
inspected_at TIMESTAMPTZ NULL,
|
|
||||||
first_indexed_at TIMESTAMPTZ NULL,
|
|
||||||
inspection JSONB NULL,
|
|
||||||
error_code VARCHAR(100) NULL,
|
|
||||||
failures INTEGER NOT NULL DEFAULT 0,
|
|
||||||
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
alerted_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
-- 0015 · users.token_version — refresh 토큰 무효화 키
|
|
||||||
--
|
|
||||||
-- ★ 왜 필요한가 (보안 점검, 2026-09-15)
|
|
||||||
-- auth_service.refresh_token() 은 지금까지 refresh 토큰을 서명만 검증하고 그 안의 sub
|
|
||||||
-- (user_id·id·role)를 그대로 새 access 토큰에 옮겨 찍었다 — DB 를 한 번도 보지 않았다.
|
|
||||||
-- 비밀번호를 바꾸거나(다른 기기의 세션을 끊고 싶을 때) 계정을 차단해도, 이미 발급된
|
|
||||||
-- refresh 토큰(7일)을 쥔 클라이언트는 만료 전까지 계속 새 access 토큰을 받을 수 있었다.
|
|
||||||
-- token_version 을 JWT 의 sub 에 같이 싣고 refresh 할 때 DB 의 지금 값과 대조하면,
|
|
||||||
-- bump_token_version() 을 부른 시점 이후의 refresh 시도는 전부 거절된다.
|
|
||||||
--
|
|
||||||
-- ★ 옛 토큰(token_version 없이 발급된 것)도 읽힌다 — UserInfo 가 기본값 1 을 먼저 깔고
|
|
||||||
-- 그 위에 없는 키는 안 덮으므로(common/models/gmodel.py UserInfo.__init__), 새 컬럼의
|
|
||||||
-- DEFAULT 1 과 맞아떨어진다. 배포 순간 전원 강제 로그아웃이 되지 않는다.
|
|
||||||
|
|
||||||
ALTER TABLE public.users ADD COLUMN IF NOT EXISTS token_version SMALLINT NOT NULL DEFAULT 1;
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.users.token_version IS
|
|
||||||
'refresh 토큰 무효화 키. JWT(access·refresh)의 sub 에 실려 나간다 — 이 값을 올리면(bump_token_version) 그 전에 발급된 refresh 토큰은 다음 재발급에서 전부 거절된다.';
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
-- 0016 · alert_outbox — 장애 알림 발송함(services/alert_service.py)
|
|
||||||
--
|
|
||||||
-- ★ 왜 필요한가 — 최종 생성 실패(JobStatus.DEAD) · BUILD 잡 업무 실패(게이트 반려가 아닌
|
|
||||||
-- 렌더·인프라 실패) · 노래 등 부분 실패 · 잡 큐 정체를 Teams Workflows webhook 으로
|
|
||||||
-- 알린다. 워커·스케줄러가 죽어도 알림 자체는 DB 에 남아야 하므로(메모리 큐면 장애를
|
|
||||||
-- 알릴 메시지까지 같이 잃는다) 영구 저장 + 재시도 + 중복 억제를 이 표 하나로 한다.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.alert_outbox (
|
|
||||||
alert_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
kind VARCHAR(50) NOT NULL,
|
|
||||||
dedupe_key VARCHAR(200) NULL,
|
|
||||||
title VARCHAR(200) NOT NULL,
|
|
||||||
detail TEXT NULL,
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1,
|
|
||||||
attempts SMALLINT NOT NULL DEFAULT 0,
|
|
||||||
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
sent_at TIMESTAMPTZ NULL,
|
|
||||||
resolved_at TIMESTAMPTZ NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
COMMENT ON COLUMN public.alert_outbox.kind IS
|
|
||||||
'job_dead · build_failed · partial_failure · queue_stuck · recovery …';
|
|
||||||
COMMENT ON COLUMN public.alert_outbox.status IS
|
|
||||||
'AlertStatus: 1=pending 2=sent 3=failed(재시도 소진)';
|
|
||||||
COMMENT ON COLUMN public.alert_outbox.detail IS
|
|
||||||
'이미 비밀·개인정보를 걷어낸 텍스트만 들어온다 — alert_service._scrub 가 저장 전에 거른다.';
|
|
||||||
COMMENT ON COLUMN public.alert_outbox.resolved_at IS
|
|
||||||
'채워지면 그 dedupe_key 는 복구됨으로 본다 — 다음 문제 발생 때 새 알림을 보낸다.';
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_alert_outbox_pending ON public.alert_outbox (status, next_attempt_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_alert_outbox_dedupe ON public.alert_outbox (dedupe_key, created_at DESC)
|
|
||||||
WHERE dedupe_key IS NOT NULL;
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
-- 0017 · place_posts — 미니 블로그(AI 자동 포스트). 기획: docs/MINI_BLOG.md
|
|
||||||
--
|
|
||||||
-- ★ 한 표로 끝내는 이유 — 글의 일생이 "만들어짐 → 검수 → 발송 → 승인 → 게재" 한 줄이라
|
|
||||||
-- 상태 컬럼 하나면 어디서 멈췄는지가 보인다. 발송함(alert_outbox)을 따로 두지 않는 것도
|
|
||||||
-- 같은 이유다: 이 글을 몇 시에 누구에게 보냈는지가 글 자체의 속성이다.
|
|
||||||
-- ★ 승인 토큰은 해시만 둔다. 평문은 메일 본문에만 있고 DB 가 새도 링크는 못 쓴다.
|
|
||||||
-- ★ (place_id, topic_key) 유니크가 "같은 축제로 두 번 쓰지 않는다"를 DB 수준에서 강제한다 —
|
|
||||||
-- 프롬프트에만 맡기면 회차가 갈릴 때 같은 주제가 다시 나온다.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_posts (
|
|
||||||
post_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
body VARCHAR(400) NOT NULL, -- 본문 140~150자
|
|
||||||
topic_kind SMALLINT NOT NULL, -- PostTopicKind: 1=weather 2=festival 3=season 4=nearby 5=guide
|
|
||||||
topic_key VARCHAR(120) NOT NULL, -- 축제 id · 절기 · 장소 id — 중복 방지의 축
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- PostStatus: 1=draft 2=reviewed 3=sent 4=approved 5=published 6=skipped
|
|
||||||
approve_token_hash VARCHAR(64) NULL, -- sha256(평문). 평문은 메일에만
|
|
||||||
token_expires_at TIMESTAMPTZ NULL,
|
|
||||||
sent_at TIMESTAMPTZ NULL,
|
|
||||||
approved_at TIMESTAMPTZ NULL,
|
|
||||||
published_at TIMESTAMPTZ NULL,
|
|
||||||
published_version_id uuid NULL, -- site_versions.site_version_id — 롤백 때 필요
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 같은 업장에 같은 주제를 두 번 만들지 않는다. 지운 글은 비켜 준다(재생성 허용).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_topic
|
|
||||||
ON public.place_posts (place_id, topic_key) WHERE deleted = FALSE;
|
|
||||||
|
|
||||||
-- 화면이 읽는 경로: 그 업장의 게재된 글을 최신순.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_published
|
|
||||||
ON public.place_posts (place_id, published_at DESC) WHERE deleted = FALSE AND status = 5;
|
|
||||||
|
|
||||||
-- 운영 경로: 검수 대기·발송 대기 목록.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_status
|
|
||||||
ON public.place_posts (status, created_at) WHERE deleted = FALSE;
|
|
||||||
|
|
||||||
-- 승인 링크가 토큰 해시로 글을 찾는다.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_posts_token
|
|
||||||
ON public.place_posts (approve_token_hash) WHERE approve_token_hash IS NOT NULL;
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
-- 0018 · place_reviews — 이용 후기(손님이 쓴 글).
|
|
||||||
--
|
|
||||||
-- ★ 사진 칸이 없다 (2026-09-16 대표: "후기사진 X"). 사진을 받는 순간 우리가 남의 파일을
|
|
||||||
-- 호스팅하게 되고 — PRODUCT.md 6절 non-goal — EXIF·저작권·신고 대응이 전부 따라온다.
|
|
||||||
-- ★ 별점 칸도 없다(회의 확정). 그래서 JSON-LD aggregateRating 도 만들지 않는다 —
|
|
||||||
-- 자체 수집 후기는 구글 리치결과 대상이 아니다.
|
|
||||||
-- ★ 손님이 남긴 이름은 표시용 한 조각뿐이다. 연락처는 받지 않는다 — 받으면 보관·파기가 따라온다.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS public.place_reviews (
|
|
||||||
review_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
place_id uuid NOT NULL,
|
|
||||||
body VARCHAR(1000) NOT NULL,
|
|
||||||
nickname VARCHAR(40) NULL, -- 손님이 적은 표시 이름. 비면 '손님'
|
|
||||||
status SMALLINT NOT NULL DEFAULT 1, -- ReviewStatus: 1=pending 2=published 3=rejected
|
|
||||||
submitted_ip_hash VARCHAR(64) NULL, -- sha256(ip+소금). 원문 IP 는 남기지 않는다
|
|
||||||
published_at TIMESTAMPTZ NULL,
|
|
||||||
published_version_id uuid NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 화면이 읽는 경로: 그 업장의 게재된 후기를 최신순.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_reviews_published
|
|
||||||
ON public.place_reviews (place_id, published_at DESC) WHERE deleted = FALSE AND status = 2;
|
|
||||||
|
|
||||||
-- 운영 경로: 검수 대기 목록.
|
|
||||||
CREATE INDEX IF NOT EXISTS ix_place_reviews_status
|
|
||||||
ON public.place_reviews (status, created_at) WHERE deleted = FALSE;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
-- 0019 · place_posts.scheduled_date — 글마다 하루를 배정한다. 기획: docs/MINI_BLOG.md
|
|
||||||
--
|
|
||||||
-- ★ 여태까지는 "언제 만들어졌나"(created_at)만 있고 "언제 낼 것인가"는 없었다 — 달력 화면이
|
|
||||||
-- 생기면서 날짜가 실제 데이터여야 했다(사장님 요청 2026-09-17: "포스트들이 다 날짜가
|
|
||||||
-- 정해져야하는데"). 생성 시점에 그 업장의 다음 빈 날부터 순서대로 하루씩 배정한다
|
|
||||||
-- (services/blog_jobs.py `_next_scheduled_date`).
|
|
||||||
-- ★ 유니크로 막는다 — 같은 업장이 같은 날짜를 두 번 차지하면 "하루 한 통" 전제가 깨진다.
|
|
||||||
|
|
||||||
ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS scheduled_date DATE NULL;
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_place_posts_scheduled_date
|
|
||||||
ON public.place_posts (place_id, scheduled_date) WHERE deleted = FALSE AND scheduled_date IS NOT NULL;
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
-- 0020 · place_posts.generation_meta — 생성 이력에 모델명 등을 남긴다. 기획: docs/MINI_BLOG.md
|
|
||||||
--
|
|
||||||
-- ★ 컬럼을 늘리는 대신 JSONB 한 칸에 담는다(2026-09-17, 사장님 지시: "생성이력도 상세하게
|
|
||||||
-- 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나 파서 컬럼"). 지금은 `model` 하나만
|
|
||||||
-- 넣지만, 필드가 늘어도 이 컬럼 안에서 해결된다 — 마이그레이션이 매번 안 따라와도 된다.
|
|
||||||
|
|
||||||
ALTER TABLE public.place_posts ADD COLUMN IF NOT EXISTS generation_meta JSONB NULL;
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
-- 카카오톡 채널 신원 연결 — 채널 발화자를 우리 user_id 에 묶는다.
|
|
||||||
--
|
|
||||||
-- ★ 카카오 채널이 주는 발화자 식별자(channel_user_key)는 **채널 단위 익명 키**다.
|
|
||||||
-- 우리 user_id 와 아무 관계가 없다. 이 표가 없으면 채널 진입점만 소유자 범위
|
|
||||||
-- 밖에 놓여, 채널에 말을 건 아무나가 남의 가게를 고친다 — 다른 모든 엔드포인트가
|
|
||||||
-- place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
|
|
||||||
--
|
|
||||||
-- ★ 코드는 평문으로 두지 않는다(code_sha). 사장님이 카톡에 손으로 치는 값이라 짧고,
|
|
||||||
-- 짧은 값을 평문으로 들고 있으면 DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다.
|
|
||||||
CREATE TABLE IF NOT EXISTS public.owner_kakao_links (
|
|
||||||
link_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
user_id uuid NOT NULL,
|
|
||||||
-- 연결이 끝나야 채워진다. PENDING 행은 아직 누구의 카톡인지 모른다.
|
|
||||||
channel_user_key varchar(200),
|
|
||||||
code_sha varchar(64),
|
|
||||||
code_expires_at timestamptz,
|
|
||||||
-- 소진된 코드 시도 횟수. 짧은 코드라 무차별 대입을 이 값으로 끊는다.
|
|
||||||
code_attempts smallint NOT NULL DEFAULT 0,
|
|
||||||
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
|
|
||||||
linked_at timestamptz,
|
|
||||||
last_seen_at timestamptz,
|
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
||||||
deleted boolean NOT NULL DEFAULT false
|
|
||||||
);
|
|
||||||
|
|
||||||
-- 한 사장님은 활성 연결 하나. 다시 [연결하기] 를 눌러도 행이 늘지 않고 코드만 바뀐다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_user
|
|
||||||
ON public.owner_kakao_links(user_id)
|
|
||||||
WHERE deleted=false AND status IN ('PENDING','LINKED');
|
|
||||||
|
|
||||||
-- ★ 한 카카오 계정은 한 사장님에만 묶인다. 없으면 같은 카톡 계정이 여러 사장님에
|
|
||||||
-- 연결돼 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다.
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_channel_key
|
|
||||||
ON public.owner_kakao_links(channel_user_key)
|
|
||||||
WHERE deleted=false AND status='LINKED';
|
|
||||||
|
|
||||||
-- 코드 소비는 이 인덱스로 한 행을 집는다(일회성은 UPDATE ... WHERE status='PENDING' CAS 가 보장).
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_kakao_link_code
|
|
||||||
ON public.owner_kakao_links(code_sha)
|
|
||||||
WHERE deleted=false AND status='PENDING';
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
-- 0021 · places.notify_email — 미니 블로그 승인 메일을 받을 주소를 계정 이메일과 분리한다.
|
|
||||||
--
|
|
||||||
-- ★ 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일(users.email) 하나로는
|
|
||||||
-- "이 업장 글은 다른 담당자에게 보낸다" 같은 경우를 못 받는다. 비어 있으면(NULL)
|
|
||||||
-- 지금처럼 users.email 로 보낸다 — 값이 없는 기존 업장은 동작이 그대로다.
|
|
||||||
|
|
||||||
ALTER TABLE public.places ADD COLUMN IF NOT EXISTS notify_email VARCHAR(255) NULL;
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
-- 0022 · owner_kakao_links 에 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다.
|
|
||||||
--
|
|
||||||
-- ★ 빌더 화면은 확인(SEMI) 한 바퀴를 프론트가 이어 줬다. `{confirm:{tool,args}}` 를 그대로
|
|
||||||
-- 돌려보내므로 서버가 아무것도 기억하지 않아도 됐다.
|
|
||||||
-- 카카오톡에서 돌아오는 것은 **텍스트 한 줄**뿐이다("네, 해주세요"). 그래서 무엇을 물었는지
|
|
||||||
-- 서버가 들고 있어야 한다.
|
|
||||||
--
|
|
||||||
-- ★ pending_expires_at 이 없으면 조용히 틀린다: 사장님이 한참 뒤 다른 맥락에서 "네" 라고
|
|
||||||
-- 치는 순간 **묵은 발행이 실행된다.** 그 사이에 값이 더 바뀌었을 수도 있다.
|
|
||||||
ALTER TABLE public.owner_kakao_links
|
|
||||||
ADD COLUMN IF NOT EXISTS current_place_id uuid,
|
|
||||||
ADD COLUMN IF NOT EXISTS pending_tool varchar(40),
|
|
||||||
ADD COLUMN IF NOT EXISTS pending_args jsonb,
|
|
||||||
ADD COLUMN IF NOT EXISTS pending_expires_at timestamptz;
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
# 워커 전용 이미지 — solution-backend(:9800)·admin-backend(:9801) 와 **다른 이미지**다.
|
|
||||||
#
|
|
||||||
# ★ 왜 갈랐나 (프리렌더를 워커 실행으로 통합, 2026-09-15)
|
|
||||||
# BUILD·ROLLBACK 잡은 이제 컴파일된 Node 렌더러(solution/site)를 subprocess 로 직접
|
|
||||||
# 돌린다(services/render_service.py) — 예전에 별도 컨테이너(solution-prerender)가 하던
|
|
||||||
# 일이다. 그 렌더러가 필요한 건 **워커뿐**이다. Dockerfile(api/admin 겸용)에 Node 를
|
|
||||||
# 같이 넣으면 API 를 여는 요청만 받는 컨테이너에도 쓰지 않는 Node 런타임과 렌더러
|
|
||||||
# 번들이 실려 나간다 — 이미지가 커지고 공격 표면도 는다.
|
|
||||||
#
|
|
||||||
# ★ 기동·잡 실행 중에 npm install 을 하지 않는다. site-builder 스테이지가 이미지 빌드
|
|
||||||
# 시점에 한 번 굽고, 최종 이미지는 컴파일된 산출물만 COPY 한다.
|
|
||||||
# ★ node_modules 는 안 들어간다 — solution/site/vite.config.ts 가 SSR 번들에
|
|
||||||
# `ssr.noExternal: true` 로 react 등 의존성까지 전부 접어 넣는다(실측: node_modules 를
|
|
||||||
# 지우고 실행해도 그대로 돈다, dist/prerender/prerender.js 단일 파일 1.8MB).
|
|
||||||
#
|
|
||||||
# ★ 빌드 컨텍스트는 solution-backend 와 같은 **레포 루트**다 — solution/site 와
|
|
||||||
# solution/backend 를 한 이미지에 담아야 한다.
|
|
||||||
|
|
||||||
# ── 1단계: 렌더러 컴파일 (Node) ──────────────────────────────────────────────
|
|
||||||
FROM node:24-bookworm AS site-builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json package-lock.json tsconfig.base.json ./
|
|
||||||
COPY solution/shared/package.json solution/shared/
|
|
||||||
COPY solution/frontend/package.json solution/frontend/
|
|
||||||
COPY solution/site/package.json solution/site/
|
|
||||||
COPY admin/frontend/package.json admin/frontend/
|
|
||||||
# ★ npm workspaces 는 루트 package.json 의 workspaces 목록에 있는 자리마다 package.json 을
|
|
||||||
# 요구한다 — site 만 빌드해도 4개를 전부 COPY 해야 npm ci 가 성공한다(nginx/Dockerfile 과 같다).
|
|
||||||
RUN npm ci
|
|
||||||
|
|
||||||
COPY solution/shared ./solution/shared
|
|
||||||
COPY solution/site ./solution/site
|
|
||||||
RUN npm run build:client -w @o2o/site \
|
|
||||||
&& npm run build:prerender -w @o2o/site
|
|
||||||
|
|
||||||
# ── 2단계: Node 런타임만 (바이너리를 뽑아 쓴다) ──────────────────────────────
|
|
||||||
# ★ python:3.12-slim 과 같은 계열(Debian bookworm)의 태그를 써야 한다 — glibc ABI 가
|
|
||||||
# 어긋나면 바이너리만 복사해서는 못 돈다("cannot execute binary file" 류로 죽는다).
|
|
||||||
FROM node:24-bookworm-slim AS node-runtime
|
|
||||||
|
|
||||||
# ── 3단계: 최종 워커 이미지 ───────────────────────────────────────────────
|
|
||||||
FROM python:3.12-slim-bookworm
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY solution/backend/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
# ★ yanolja_adapter.py 가 Playwright 로 페이지를 렌더링한다(수집 파이프라인, 워커 전용 경로) —
|
|
||||||
# 패키지(pip)만으로는 브라우저 실행 파일이 없다. --with-deps 가 Chromium 이 필요로 하는
|
|
||||||
# 시스템 라이브러리(libnss3 등)까지 apt 로 같이 깐다.
|
|
||||||
RUN playwright install --with-deps chromium
|
|
||||||
|
|
||||||
# Node 바이너리만 옮겨 온다 — npm·corepack 은 쓰지 않는다(빌드는 site-builder 스테이지가
|
|
||||||
# 이미 끝냈다). /usr/local/bin/node 하나로 `node dist/prerender/prerender.js` 를 돌리는
|
|
||||||
# 데 충분하다(실측: 아래 COPY 만으로 `node --version` 과 렌더 스모크 테스트가 통과한다).
|
|
||||||
COPY --from=node-runtime /usr/local/bin/node /usr/local/bin/node
|
|
||||||
|
|
||||||
COPY solution/backend ./solution/backend
|
|
||||||
# ★ SITE_ROOT(solution/site/scripts/prerender.ts)가 자기 파일 위치 기준 상대경로로
|
|
||||||
# payloads·songs·out 을 찾는다 — dist·public 이 이 자리(/app/solution/site/)에 있어야
|
|
||||||
# 워커가 컨테이너 안에서 렌더러를 그대로 실행할 수 있다.
|
|
||||||
COPY --from=site-builder /app/solution/site/dist ./solution/site/dist
|
|
||||||
COPY --from=site-builder /app/solution/site/public ./solution/site/public
|
|
||||||
|
|
||||||
ENV APP_ENV=local
|
|
||||||
ENV PYTHONPATH=/app/solution/backend
|
|
||||||
WORKDIR /app/solution/backend
|
|
||||||
|
|
||||||
# 워커는 포트가 없다 — 이미지 공통 HEALTHCHECK 를 두지 않는다(compose 가 disable 한다).
|
|
||||||
CMD ["python", "worker_main.py"]
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
"""수집(크롤링) 중 실패를 jobs.result 에 구조화해서 싣는다 — 워커 로그 grep 없이 확인용.
|
|
||||||
|
|
||||||
★ contextvars 로 든다 — 실패 지점이 흩어진 여러 함수에 리스트를 관통시키지 않는다.
|
|
||||||
자세한 배경은 DEVLOG.md 참고.
|
|
||||||
"""
|
|
||||||
from contextlib import contextmanager
|
|
||||||
from contextvars import ContextVar
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
|
|
||||||
from common.logger import LOG
|
|
||||||
|
|
||||||
_current: ContextVar[list["CollectIssue"] | None] = ContextVar("_collect_issues", default=None)
|
|
||||||
|
|
||||||
# jobs.result 는 DB 에 그대로 쌓인다 — 예외 메시지가 길어지는(HTML 응답 전체를 문 등) 경우가
|
|
||||||
# 있어 상한을 둔다. 잘린 메시지도 원인 파악엔 충분하고, 전체는 여전히 로그에 남는다.
|
|
||||||
_MAX_MESSAGE = 500
|
|
||||||
_MAX_TARGET = 200
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class CollectIssue:
|
|
||||||
stage: str # 어느 단계에서(예: "naver_place" · "tour_api" · "yanolja" · "static_html")
|
|
||||||
target: str # 무엇을 하다가(URL·검색어 등)
|
|
||||||
error_type: str # 예외 클래스명
|
|
||||||
message: str # 예외 메시지
|
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
|
||||||
def collecting():
|
|
||||||
"""run_collect() 진입부에서 한 번 연다. 중첩 호출은 바깥 것을 그대로 쓴다."""
|
|
||||||
token = _current.set([])
|
|
||||||
try:
|
|
||||||
yield
|
|
||||||
finally:
|
|
||||||
_current.reset(token)
|
|
||||||
|
|
||||||
|
|
||||||
def note_issue(stage: str, target: str, ex: Exception) -> CollectIssue:
|
|
||||||
"""실패 한 건을 기록하고 기존과 같은 형식으로 로그도 남긴다.
|
|
||||||
|
|
||||||
collecting() 없이 불러도 죽지 않는다 — 그때는 기록만 안 되고 로그는 그대로 남는다
|
|
||||||
(단발 호출·테스트 호환)."""
|
|
||||||
issue = CollectIssue(
|
|
||||||
stage=stage,
|
|
||||||
target=target[:_MAX_TARGET],
|
|
||||||
error_type=type(ex).__name__,
|
|
||||||
message=str(ex)[:_MAX_MESSAGE],
|
|
||||||
)
|
|
||||||
issues = _current.get()
|
|
||||||
if issues is not None:
|
|
||||||
issues.append(issue)
|
|
||||||
LOG.w(f"[collect] {stage} 실패(계속) {issue.target}: {issue.error_type}: {issue.message}")
|
|
||||||
return issue
|
|
||||||
|
|
||||||
|
|
||||||
def snapshot() -> list[dict]:
|
|
||||||
"""지금까지 쌓인 실패 목록. run_collect() 가 끝에서 jobs.result 에 싣는다."""
|
|
||||||
issues = _current.get()
|
|
||||||
return [asdict(i) for i in issues] if issues else []
|
|
||||||
@ -34,7 +34,6 @@ class Provider(Enum):
|
|||||||
GEMINI = "gemini"
|
GEMINI = "gemini"
|
||||||
TOUR_API = "tour_api"
|
TOUR_API = "tour_api"
|
||||||
OPEN_METEO = "open_meteo"
|
OPEN_METEO = "open_meteo"
|
||||||
THREADS = "threads"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -58,7 +57,6 @@ class Rate:
|
|||||||
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
|
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
|
||||||
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
|
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
|
||||||
RATES: dict[Provider, Rate] = {
|
RATES: dict[Provider, Rate] = {
|
||||||
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
|
|
||||||
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
|
||||||
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
|
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
|
||||||
Provider.KAKAO: Rate(
|
Provider.KAKAO: Rate(
|
||||||
|
|||||||
@ -113,10 +113,7 @@ class DBSessionManager(Singleton):
|
|||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
except IntegrityError as ex:
|
except IntegrityError as ex:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
# ★ 유니크 제약 충돌은 호출부가 "이미 있음"으로 처리하는 정상 경로다
|
LOG.e_no_callstack(f"duplicated. {ex}")
|
||||||
# (services/collect_service.py `_add_link`). ERROR 로 찍지 않는다 — 진짜 못
|
|
||||||
# 보던 무결성 오류는 아래 일반 Exception 갈래로 간다.
|
|
||||||
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:
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy.orm import declarative_base
|
from sqlalchemy.orm import declarative_base
|
||||||
from sqlalchemy import Column, Date, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
|
from sqlalchemy import Column, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
from sqlalchemy.sql import text
|
from sqlalchemy.sql import text
|
||||||
|
|
||||||
@ -16,8 +16,6 @@ from common.enums import (
|
|||||||
MediaStatus,
|
MediaStatus,
|
||||||
SongStatus,
|
SongStatus,
|
||||||
SiteStatus,
|
SiteStatus,
|
||||||
PostStatus,
|
|
||||||
ReviewStatus,
|
|
||||||
BuildStatus,
|
BuildStatus,
|
||||||
JobStatus,
|
JobStatus,
|
||||||
)
|
)
|
||||||
@ -82,12 +80,6 @@ class users(MainTableMixin, MAIN_BASE):
|
|||||||
# 컬럼이 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 에 이 값을 같이 싣는다
|
|
||||||
# (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)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@ -130,9 +122,6 @@ class places(MainTableMixin, MAIN_BASE):
|
|||||||
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
||||||
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
# 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)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -432,64 +421,6 @@ class place_area_refs(MainTableMixin, MAIN_BASE):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class place_posts(MainTableMixin, MAIN_BASE):
|
|
||||||
"""미니 블로그 글 하나. 기획: docs/MINI_BLOG.md
|
|
||||||
|
|
||||||
★ 승인 토큰은 해시만 둔다 — 평문은 메일 본문에만 있다.
|
|
||||||
★ (place_id, topic_key) 가 유니크라 같은 주제로 두 번 만들어지지 않는다.
|
|
||||||
★ (place_id, scheduled_date) 도 유니크다 — 하루 한 통 배정이라 같은 날을 두 번 못 쓴다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_posts"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("uq_place_posts_topic", "place_id", "topic_key", unique=True,
|
|
||||||
postgresql_where=text("deleted = false")),
|
|
||||||
Index("uq_place_posts_scheduled_date", "place_id", "scheduled_date", unique=True,
|
|
||||||
postgresql_where=text("deleted = false AND scheduled_date IS NOT NULL")),
|
|
||||||
Index("ix_place_posts_status", "status", "created_at", postgresql_where=text("deleted = false")),
|
|
||||||
)
|
|
||||||
|
|
||||||
post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
body = Column(String(400), nullable=False)
|
|
||||||
topic_kind = Column(SmallInteger, nullable=False)
|
|
||||||
topic_key = Column(String(120), nullable=False)
|
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=PostStatus.DRAFT.value)
|
|
||||||
# 이 업장 몫 하루 한 통 배정일(KST). 생성 시 순서대로 채운다(blog_jobs._next_scheduled_date).
|
|
||||||
scheduled_date = Column(Date, nullable=True)
|
|
||||||
# 생성 당시 부가정보(모델명 등) — 컬럼을 늘리지 않고 JSONB 한 칸에 담는다(2026-09-17,
|
|
||||||
# 사장님 지시: "생성이력도 상세하게 기록해놓으셈 어느 모델썼는지 등등" → "Jsonb 하나
|
|
||||||
# 파서 컬럼"). 새 필드가 늘어도 마이그레이션이 안 따라온다.
|
|
||||||
generation_meta = Column(JSONB, nullable=True)
|
|
||||||
approve_token_hash = Column(String(64), nullable=True)
|
|
||||||
token_expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
approved_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
published_version_id = Column(UUID(as_uuid=True), nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class place_reviews(MainTableMixin, MAIN_BASE):
|
|
||||||
"""손님이 남긴 이용 후기.
|
|
||||||
|
|
||||||
★ 사진도 별점도 받지 않는다(2026-09-16 회의). 사진은 호스팅 non-goal 을 여는 일이고,
|
|
||||||
별점은 자체 수집 후기라 구조화 데이터로 나갈 수 없다.
|
|
||||||
★ IP 는 해시로만 둔다 — 도배를 세는 데는 충분하고 개인정보는 남지 않는다."""
|
|
||||||
|
|
||||||
__tablename__ = "place_reviews"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_place_reviews_status", "status", "created_at", postgresql_where=text("deleted = false")),
|
|
||||||
)
|
|
||||||
|
|
||||||
review_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
body = Column(String(1000), nullable=False)
|
|
||||||
nickname = Column(String(40), nullable=True)
|
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=ReviewStatus.PENDING.value)
|
|
||||||
submitted_ip_hash = Column(String(64), nullable=True)
|
|
||||||
published_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
published_version_id = Column(UUID(as_uuid=True), nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class sites(MainTableMixin, MAIN_BASE):
|
class sites(MainTableMixin, MAIN_BASE):
|
||||||
"""발행 대상 사이트. 사업장당 1개.
|
"""발행 대상 사이트. 사업장당 1개.
|
||||||
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
|
★ 해지는 물리 삭제가 아니라 status 전이로만 처리한다 — 색인된 페이지를 갑자기 404 로 만들지 않는다."""
|
||||||
@ -523,48 +454,6 @@ class sites(MainTableMixin, MAIN_BASE):
|
|||||||
thumbnail_url = Column(String(500), nullable=True)
|
thumbnail_url = Column(String(500), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class site_search_status(MainTableMixin, MAIN_BASE):
|
|
||||||
"""발행 성공과 Google 색인 성공은 다른 사건이라 별도 보관한다."""
|
|
||||||
|
|
||||||
__tablename__ = "site_search_status"
|
|
||||||
site_id = Column(UUID(as_uuid=True), primary_key=True)
|
|
||||||
site_version_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
property_url = Column(Text, nullable=False)
|
|
||||||
page_url = Column(Text, nullable=False)
|
|
||||||
published_at = Column(DateTime(timezone=True), nullable=False)
|
|
||||||
sitemap_submitted_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
inspected_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
first_indexed_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
inspection = Column(JSONB, nullable=True)
|
|
||||||
error_code = Column(String(100), nullable=True)
|
|
||||||
failures = Column(Integer, nullable=False, server_default=text("0"))
|
|
||||||
next_check_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
|
||||||
alerted_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class alert_outbox(MainTableMixin, MAIN_BASE):
|
|
||||||
"""장애 알림 발송함 — services/alert_service.py 가 쓰고 읽는다.
|
|
||||||
|
|
||||||
★ 왜 영구 저장하나: 워커 프로세스가 죽으면 메모리에만 쌓아 둔 알림은 그대로 사라진다.
|
|
||||||
장애가 나서 죽었는데 그 장애를 알릴 메시지까지 같이 잃으면 본말전도다.
|
|
||||||
★ dedupe_key + 최근 전송 시각으로 재시도마다 중복 스팸을 막는다(alert_service.send_alert) —
|
|
||||||
같은 사유가 몇 분 간격으로 계속 터져도 사람에게는 한 통만 간다.
|
|
||||||
★ resolved_at 은 "복구 알림"의 근거다 — 이 키로 마지막에 안 풀린 알림이 있으면
|
|
||||||
다음 정상 상태에서 복구 메시지를 한 번 보내고 이 값을 채운다."""
|
|
||||||
|
|
||||||
__tablename__ = "alert_outbox"
|
|
||||||
alert_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
kind = Column(String(50), nullable=False) # job_dead · build_failed · partial_failure · queue_stuck · recovery …
|
|
||||||
dedupe_key = Column(String(200), nullable=True)
|
|
||||||
title = Column(String(200), nullable=False)
|
|
||||||
detail = Column(Text, nullable=True) # 이미 비밀·개인정보를 걷어낸 텍스트만 들어온다(alert_service._scrub)
|
|
||||||
status = Column(SmallInteger, nullable=False, server_default=text("1"), default=1) # AlertStatus: 1=pending 2=sent 3=failed(소진)
|
|
||||||
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
|
||||||
next_attempt_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
|
|
||||||
sent_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
resolved_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class site_sections(MainTableMixin, MAIN_BASE):
|
class site_sections(MainTableMixin, MAIN_BASE):
|
||||||
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
|
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
|
||||||
|
|
||||||
@ -674,7 +563,6 @@ class jobs(MainTableMixin, MAIN_BASE):
|
|||||||
priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선
|
priority = Column(SmallInteger, nullable=False, server_default=text("100"), default=100) # 낮을수록 우선
|
||||||
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
|
payload = Column(JSONB, nullable=False, server_default=text("'{}'::jsonb")) # 잡 입력
|
||||||
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
|
result = Column(JSONB, nullable=True) # 잡 출력(완료 시)
|
||||||
progress = Column(JSONB, nullable=True) # 워커가 기록한 단계 상태
|
|
||||||
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
|
dedupe_key = Column(String(200), nullable=True) # 활성 중복 방지 키(부분 유니크)
|
||||||
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1
|
attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0) # claim 시 +1
|
||||||
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3)
|
max_attempts = Column(SmallInteger, nullable=False, server_default=text("3"), default=3)
|
||||||
@ -683,77 +571,3 @@ class jobs(MainTableMixin, MAIN_BASE):
|
|||||||
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
|
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
|
||||||
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각
|
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각
|
||||||
last_error = Column(Text, nullable=True)
|
last_error = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class owner_social_accounts(MainTableMixin, MAIN_BASE):
|
|
||||||
__tablename__ = "owner_social_accounts"
|
|
||||||
account_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
provider = Column(SmallInteger, nullable=False)
|
|
||||||
provider_user_id = Column(String(200), nullable=False)
|
|
||||||
handle = Column(String(200), nullable=False)
|
|
||||||
profile_url = Column(Text, nullable=False)
|
|
||||||
access_token = Column(Text, nullable=True)
|
|
||||||
refresh_token = Column(Text, nullable=True)
|
|
||||||
access_expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
scopes = Column(JSONB, nullable=False, server_default=text("'[]'"))
|
|
||||||
status = Column(String(20), nullable=False, server_default=text("'linked'"))
|
|
||||||
last_error = Column(Text, nullable=True)
|
|
||||||
__table_args__ = (Index("uq_social_account", "user_id", "provider", unique=True, postgresql_where=text("deleted=false AND status IN ('linked','needs_reauth')")),)
|
|
||||||
|
|
||||||
|
|
||||||
class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
|
||||||
"""카카오톡 채널 발화자 ↔ 우리 user_id.
|
|
||||||
|
|
||||||
★ channel_user_key 는 **채널 단위 익명 키**라 우리 계정과 아무 관계가 없다. 이 표가
|
|
||||||
없으면 채널 진입점만 소유자 범위 밖에 놓인다 — 다른 엔드포인트가 전부
|
|
||||||
place_crud.get_place(s, owner_user_id, place_id) 로 지키는 경계다.
|
|
||||||
★ 코드는 sha256 만 둔다. 사장님이 카톡에 손으로 치는 짧은 값이라, 평문으로 들고 있으면
|
|
||||||
DB 를 읽는 쪽이 곧 연결 권한을 갖는다."""
|
|
||||||
|
|
||||||
__tablename__ = "owner_kakao_links"
|
|
||||||
link_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
channel_user_key = Column(String(200), nullable=True)
|
|
||||||
code_sha = Column(String(64), nullable=True)
|
|
||||||
code_expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
code_attempts = Column(SmallInteger, nullable=False, server_default=text("0"), default=0)
|
|
||||||
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
|
||||||
linked_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
last_seen_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
# 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다).
|
|
||||||
# ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
|
|
||||||
current_place_id = Column(UUID(as_uuid=True), nullable=True)
|
|
||||||
pending_tool = Column(String(40), nullable=True)
|
|
||||||
pending_args = Column(JSONB, nullable=True)
|
|
||||||
pending_expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
__table_args__ = (
|
|
||||||
Index("uq_kakao_link_user", "user_id", unique=True, postgresql_where=text("deleted=false AND status IN ('PENDING','LINKED')")),
|
|
||||||
Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")),
|
|
||||||
Index("uq_kakao_link_code", "code_sha", unique=True, postgresql_where=text("deleted=false AND status='PENDING'")),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class place_social_posts(MainTableMixin, MAIN_BASE):
|
|
||||||
__tablename__ = "place_social_posts"
|
|
||||||
post_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
place_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
user_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
site_version_id = Column(UUID(as_uuid=True), nullable=False)
|
|
||||||
account_id = Column(UUID(as_uuid=True), nullable=True)
|
|
||||||
provider = Column(SmallInteger, nullable=False)
|
|
||||||
body = Column(Text, nullable=False, server_default=text("''"))
|
|
||||||
link_url = Column(Text, nullable=False)
|
|
||||||
grounded_facts = Column(JSONB, nullable=False, server_default=text("'[]'"))
|
|
||||||
status = Column(String(24), nullable=False, server_default=text("'DRAFTING'"))
|
|
||||||
approval_token_sha = Column(String(64), nullable=True)
|
|
||||||
approval_sent_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
approval_channel = Column(String(20), nullable=True)
|
|
||||||
approval_expires_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
decided_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
decided_via = Column(String(20), nullable=True)
|
|
||||||
provider_post_id = Column(String(200), nullable=True)
|
|
||||||
permalink = Column(Text, nullable=True)
|
|
||||||
posted_at = Column(DateTime(timezone=True), nullable=True)
|
|
||||||
last_error = Column(Text, nullable=True)
|
|
||||||
__table_args__ = (Index("uq_social_version", "place_id", "site_version_id", unique=True, postgresql_where=text("deleted=false")), Index("idx_social_posted", "place_id", "posted_at", postgresql_where=text("deleted=false AND status='POSTED'")),)
|
|
||||||
|
|||||||
@ -55,7 +55,6 @@ 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 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
|
|
||||||
|
|
||||||
# 사업장(places) 관련 에러
|
# 사업장(places) 관련 에러
|
||||||
PLACE_NOT_FOUND = 1200
|
PLACE_NOT_FOUND = 1200
|
||||||
@ -85,7 +84,7 @@ class ErrorType(Enum):
|
|||||||
COLLECT_ALREADY_RUNNING = auto()
|
COLLECT_ALREADY_RUNNING = auto()
|
||||||
|
|
||||||
# 생성(generator) 관련 에러
|
# 생성(generator) 관련 에러
|
||||||
GENERATOR_NOT_CONFIGURED = 1500 # 활성 LLM 공급자의 키 미설정(llm/provider.missing_key)
|
GENERATOR_NOT_CONFIGURED = 1500 # GEMINI_API_KEY 미설정
|
||||||
GENERATOR_CALL_FAILED = auto()
|
GENERATOR_CALL_FAILED = auto()
|
||||||
GENERATOR_INVALID_OUTPUT = auto() # 구조화 출력 파싱 실패
|
GENERATOR_INVALID_OUTPUT = auto() # 구조화 출력 파싱 실패
|
||||||
GENERATOR_LOW_CONFIDENCE = auto() # 신뢰도 낮음 — 자동 반영 금지, 사람 확인 큐로
|
GENERATOR_LOW_CONFIDENCE = auto() # 신뢰도 낮음 — 자동 반영 금지, 사람 확인 큐로
|
||||||
@ -113,13 +112,6 @@ class ErrorType(Enum):
|
|||||||
JOB_ALREADY_QUEUED = auto() # 같은 dedupe_key 의 활성 잡이 이미 있다
|
JOB_ALREADY_QUEUED = auto() # 같은 dedupe_key 의 활성 잡이 이미 있다
|
||||||
JOB_NOT_DEAD = auto() # DEAD 가 아닌 잡을 재큐하려 함
|
JOB_NOT_DEAD = auto() # DEAD 가 아닌 잡을 재큐하려 함
|
||||||
|
|
||||||
# 카카오톡 채널 신원 연결 관련 에러
|
|
||||||
KAKAO_LINK_DISABLED = 2000 # KAKAO_CHANNEL_PUBLIC_ID 미설정 — 연결 화면 자체를 열지 않는다
|
|
||||||
KAKAO_LINK_ALREADY = auto() # 이미 연결된 사장님이 다시 코드를 받으려 함
|
|
||||||
KAKAO_LINK_CODE_INVALID = auto() # 코드가 없거나 만료 — ★ 없는 코드와 남의 코드를 구분해 답하지 않는다
|
|
||||||
KAKAO_LINK_NOT_FOUND = auto() # 해제할 연결이 없음
|
|
||||||
KAKAO_LINK_TAKEN = auto() # 그 카카오 계정이 이미 다른 사장님에 묶여 있다
|
|
||||||
|
|
||||||
|
|
||||||
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
|
||||||
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
|
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
|
||||||
@ -326,7 +318,7 @@ class LocalContentType(CodeEnum):
|
|||||||
|
|
||||||
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
|
# 코드값 ↔ **타입명**. `area_contents.kind` 와 `site_sections.data.items[].kind` 가 같은 어휘를 쓴다 —
|
||||||
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
|
# 개인화 행(거리·숨김)이 어느 공용 실체를 가리키는지 이름만 보고 알 수 있어야 한다.
|
||||||
# ★ STORY 는 여기 없다. 그것들(songs·people·chronicle·reading·postcard·quiz)은 kind 가 곧 타입명이고,
|
# ★ STORY 는 여기 없다. 그 다섯(songs·people·chronicle·postcard·quiz)은 kind 가 곧 타입명이고,
|
||||||
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
|
# 코드값 하나(6)를 나눠 쓴다. 아래 표는 kind 가 비어 있던 장소류를 채우기 위한 것이다.
|
||||||
AREA_KIND = {
|
AREA_KIND = {
|
||||||
LocalContentType.WEATHER.value: "weather",
|
LocalContentType.WEATHER.value: "weather",
|
||||||
@ -336,9 +328,9 @@ AREA_KIND = {
|
|||||||
LocalContentType.COURSE.value: "course",
|
LocalContentType.COURSE.value: "course",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 지역 이야기 일곱. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
|
# 지역 이야기 여섯. `services/prompts/story.py` 의 산출물 키와 같아야 한다.
|
||||||
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
|
# ★ 순서는 발행본 '지역 이야기' 탭 순서다(`site/sections/items/StorySection.tsx`).
|
||||||
STORY_KINDS = ("songs", "daily", "people", "chronicle", "reading", "postcard", "quiz")
|
STORY_KINDS = ("songs", "daily", "people", "chronicle", "postcard", "quiz")
|
||||||
|
|
||||||
|
|
||||||
class LocalSource(CodeEnum):
|
class LocalSource(CodeEnum):
|
||||||
@ -397,7 +389,6 @@ class PublishAction(CodeEnum):
|
|||||||
REBUILD = 3
|
REBUILD = 3
|
||||||
SUSPEND = 4
|
SUSPEND = 4
|
||||||
RESUME = 5
|
RESUME = 5
|
||||||
ROLLBACK = 6 # 예전 버전으로 공개 주소를 되돌림 — services/rollback_service.py
|
|
||||||
|
|
||||||
|
|
||||||
class PublishResult(CodeEnum):
|
class PublishResult(CodeEnum):
|
||||||
@ -443,9 +434,6 @@ class JobType(CodeEnum):
|
|||||||
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 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
|
|
||||||
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
|
|
||||||
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
|
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(CodeEnum):
|
class JobStatus(CodeEnum):
|
||||||
@ -462,69 +450,3 @@ class JobStatus(CodeEnum):
|
|||||||
|
|
||||||
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
|
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
|
||||||
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
|
ACTIVE_JOB_STATUSES = {JobStatus.PENDING, JobStatus.RUNNING}
|
||||||
|
|
||||||
|
|
||||||
class PostTopicKind(CodeEnum):
|
|
||||||
"""place_posts.topic_kind — 어떤 갈래로 쓴 글인가. 갈래마다 근거로 삼는 값이 다르다."""
|
|
||||||
|
|
||||||
WEATHER = 1 # local.weather
|
|
||||||
FESTIVAL = 2 # local.festivals
|
|
||||||
SEASON = 3 # 절기·달
|
|
||||||
NEARBY = 4 # local.attractions / restaurants
|
|
||||||
GUIDE = 5 # 이용 안내(검증된 fact 안에서)
|
|
||||||
|
|
||||||
|
|
||||||
class PostStatus(CodeEnum):
|
|
||||||
"""place_posts.status — 글 하나의 일생. 어디서 멈췄는지가 운영 질문의 전부다."""
|
|
||||||
|
|
||||||
DRAFT = 1 # AI 가 만들었고 아직 아무도 안 봤다
|
|
||||||
REVIEWED = 2 # 우리가 검수해 내보내도 된다고 판단
|
|
||||||
SENT = 3 # 사장님에게 메일이 나갔다
|
|
||||||
APPROVED = 4 # 사장님이 눌렀다 — 재발행 대기
|
|
||||||
PUBLISHED = 5 # 사이트에 올라갔다
|
|
||||||
SKIPPED = 6 # 반려(우리) 또는 넘김(사장님)
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewStatus(CodeEnum):
|
|
||||||
"""place_reviews.status — 손님이 쓴 글의 일생. 검수를 통과해야 화면에 나간다."""
|
|
||||||
|
|
||||||
PENDING = 1 # 손님이 막 남겼다
|
|
||||||
PUBLISHED = 2 # 검수 통과 — 다음 굽기에 실린다
|
|
||||||
REJECTED = 3 # 반려
|
|
||||||
|
|
||||||
|
|
||||||
class SocialProvider(CodeEnum):
|
|
||||||
X = 1
|
|
||||||
THREADS = 2
|
|
||||||
|
|
||||||
|
|
||||||
class KakaoLinkStatus(str, Enum):
|
|
||||||
"""owner_kakao_links.status.
|
|
||||||
|
|
||||||
★ 코드는 PENDING 행에만 산다. 연결이 끝나면 code_sha 를 비워 같은 코드가 두 번
|
|
||||||
먹지 않게 한다 — 일회성은 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다."""
|
|
||||||
|
|
||||||
PENDING = "PENDING" # 코드는 냈고 아직 카톡에서 입력되지 않았다
|
|
||||||
LINKED = "LINKED" # channel_user_key 가 붙었다
|
|
||||||
REVOKED = "REVOKED" # 사장님이 해제했다. 행은 남겨 이력을 잃지 않는다
|
|
||||||
|
|
||||||
|
|
||||||
class SocialPostStatus(str, Enum):
|
|
||||||
DRAFTING = "DRAFTING"
|
|
||||||
DRAFT = "DRAFT"
|
|
||||||
PENDING_APPROVAL = "PENDING_APPROVAL"
|
|
||||||
APPROVED = "APPROVED"
|
|
||||||
POSTING = "POSTING"
|
|
||||||
POSTED = "POSTED"
|
|
||||||
DECLINED = "DECLINED"
|
|
||||||
EXPIRED = "EXPIRED"
|
|
||||||
FAILED = "FAILED"
|
|
||||||
UNKNOWN = "UNKNOWN" # 응답 유실·워커 중단: 자동 재시도는 중복 게시가 된다.
|
|
||||||
|
|
||||||
|
|
||||||
class AlertStatus(CodeEnum):
|
|
||||||
"""alert_outbox.status 코드값. services/alert_service.py 가 이 상태로 재시도를 판단한다."""
|
|
||||||
|
|
||||||
PENDING = 1 # 아직 안 보냄(다음 process_outbox 스윕에서 시도)
|
|
||||||
SENT = 2 # 전송 성공
|
|
||||||
FAILED = 3 # 재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다)
|
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
"""재시도가 의미 없는 잡 실패.
|
|
||||||
|
|
||||||
★ 왜 따로 두나 — 큐는 실패를 전부 "일시적" 으로 보고 백오프 재큐한다(crud/job_crud.fail).
|
|
||||||
네트워크가 끊겼거나 외부 API 가 잠깐 죽은 것이라면 맞는 판단이다. 그런데 사장님이
|
|
||||||
사업장을 지운 뒤에 남은 잡, 지원하지 않는 업종 같은 것은 **몇 번을 다시 해도 같은 결과**다.
|
|
||||||
실측(2026-09-15): 진행 중이던 소개문 잡이 사업장 삭제 뒤 "사업장을 찾을 수 없다" 로
|
|
||||||
세 번 재시도하고 DEAD 로 갔다 — 큐 지연과 DEAD 알림만 늘었다.
|
|
||||||
|
|
||||||
★ 각 도메인의 `*Aborted` 는 이미 머리주석에 "재시도해도 소용없는 중단" 이라고 적고 있었다.
|
|
||||||
그 뜻을 워커가 읽을 수 있는 자리로 옮긴 것이지, 새 규칙을 만든 게 아니다.
|
|
||||||
|
|
||||||
★ services 와 worker 가 함께 쓰므로 common 에 둔다 — services 가 worker 를 import 하면
|
|
||||||
의존 방향이 뒤집힌다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class PermanentJobError(RuntimeError):
|
|
||||||
"""다시 시도해도 결과가 같은 실패. 워커가 재큐하지 않고 바로 DEAD 로 보낸다."""
|
|
||||||
@ -70,15 +70,11 @@ class UserInfo(StructModel):
|
|||||||
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
|
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
|
||||||
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 이 대조)
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로
|
# 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
|
||||||
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
|
|
||||||
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
|
|
||||||
self.role = UserRole.USER.value
|
self.role = UserRole.USER.value
|
||||||
self.token_version = 1
|
|
||||||
for dictionary in args:
|
for dictionary in args:
|
||||||
for key in dictionary:
|
for key in dictionary:
|
||||||
setattr(self, key, dictionary[key])
|
setattr(self, key, dictionary[key])
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
"""사장님 에이전트 설정 — 루트 .env 하나만 읽는다(APP_ENV=test 면 .env 를 읽지 않는다).
|
|
||||||
|
|
||||||
★ SNS 게재(social_config)와 파일을 가른 이유는 도메인이 다르기 때문이다.
|
|
||||||
SNS 게재는 **되돌릴 수 없는** 대외 발화이고, 에이전트는 사장님이 자기 사이트를
|
|
||||||
고치는 창구다. 승인 강도도 보관하는 것도 다르다 — 설정이 한 파일에 섞이면
|
|
||||||
"이 값이 무엇을 여는가" 가 흐려진다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
from config.config_models import _BASE
|
|
||||||
|
|
||||||
|
|
||||||
class AgentConfig(BaseSettings):
|
|
||||||
model_config = _BASE
|
|
||||||
|
|
||||||
# 카카오톡 채널 공개 ID(`_xaBcD` 형태). 사장님이 채널을 찾아 코드를 입력해야 하므로
|
|
||||||
# ★ 이 값이 없으면 연결 화면 자체를 열지 않는다 — 어디에 코드를 칠지 말해 줄 수
|
|
||||||
# 없는데 코드만 발급하면, 사장님에게는 고장난 화면이다(Threads 카드와 같은 규칙).
|
|
||||||
KAKAO_CHANNEL_PUBLIC_ID: str = ""
|
|
||||||
# 코드 수명. 사장님이 화면을 보고 카톡을 열어 치는 동작이라 짧아도 된다.
|
|
||||||
KAKAO_LINK_CODE_TTL_MIN: int = 10
|
|
||||||
# 코드가 짧아서(사람이 손으로 친다) 무차별 대입이 가능하다. 시도 수로 끊는다.
|
|
||||||
KAKAO_LINK_MAX_ATTEMPTS: int = 5
|
|
||||||
|
|
||||||
# 빌더 화면의 대화창. 2026-09-21 에 한 번 닫았다가(카카오 채널 보류) 채널 인증이
|
|
||||||
# 끝나 다시 열었다(2026-09-22).
|
|
||||||
# ★ 이 값이 "1" 이어도 **LLM 키가 없으면 안 열린다**(runtime.is_configured 가 둘 다 본다) —
|
|
||||||
# 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 생기지 않는다.
|
|
||||||
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
|
|
||||||
AGENT_CHAT_ENABLED: str = "1"
|
|
||||||
|
|
||||||
# ★ 카카오 웹훅 인증. **오픈빌더는 서명을 주지 않는다** — URL 만 알면 누구나 이 엔드포인트를
|
|
||||||
# 때릴 수 있고, user.id 를 아무 값이나 넣으면 **그 사장님 행세를 한다.** 신원 연결
|
|
||||||
# (owner_kakao_links)이 통째로 무의미해진다.
|
|
||||||
# 그래서 이 값이 없으면 **엔드포인트 자체를 띄우지 않는다**(404). 반쯤 열린 상태를
|
|
||||||
# 만들지 않는 것은 Threads 연결과 같은 규칙이다.
|
|
||||||
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
|
||||||
KAKAO_WEBHOOK_SECRET: str = ""
|
|
||||||
# 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다.
|
|
||||||
KAKAO_BOT_ID: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def get(name, default=""):
|
|
||||||
return getattr(AgentConfig(), name, default) or default
|
|
||||||
|
|
||||||
|
|
||||||
def chat_enabled() -> bool:
|
|
||||||
return get("AGENT_CHAT_ENABLED", "0") == "1"
|
|
||||||
|
|
||||||
|
|
||||||
def webhook_secret() -> str:
|
|
||||||
return get("KAKAO_WEBHOOK_SECRET")
|
|
||||||
|
|
||||||
|
|
||||||
def kakao_link_enabled() -> bool:
|
|
||||||
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))
|
|
||||||
|
|
||||||
|
|
||||||
def channel_url() -> str:
|
|
||||||
"""사장님이 눌러서 채널로 가는 주소. 공개 ID 가 없으면 빈 문자열이다."""
|
|
||||||
public_id = get("KAKAO_CHANNEL_PUBLIC_ID")
|
|
||||||
return f"http://pf.kakao.com/{public_id}" if public_id else ""
|
|
||||||
@ -128,10 +128,6 @@ class ExternalApiConfig(BaseSettings):
|
|||||||
# 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다.
|
# 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다.
|
||||||
gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL")
|
gemini_vision_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_VISION_MODEL")
|
||||||
gemini_text_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_TEXT_MODEL")
|
gemini_text_model: str = Field("gemini-3.7-flash", validation_alias="GEMINI_TEXT_MODEL")
|
||||||
llm_provider: str = Field("openai", validation_alias="LLM_PROVIDER")
|
|
||||||
openai_api_key: str = Field("", validation_alias="OPENAI_API_KEY")
|
|
||||||
openai_text_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_TEXT_MODEL")
|
|
||||||
openai_vision_model: str = Field("gpt-5.6-luna", validation_alias="OPENAI_VISION_MODEL")
|
|
||||||
# 이 값 미만이면 자동 반영하지 않고 사람 확인 큐(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")
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
"""SNS도 루트 .env만 읽는다. APP_ENV=test에서는 기존 설정 규칙대로 .env를 읽지 않는다."""
|
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from config.config_models import _BASE
|
|
||||||
|
|
||||||
|
|
||||||
class SocialConfig(BaseSettings):
|
|
||||||
model_config = _BASE
|
|
||||||
SOCIAL_TOKEN_SECRET: str = ""
|
|
||||||
SOCIAL_POSTING_ENABLED: str = "0"
|
|
||||||
SOCIAL_APPROVAL_HOURS: int = 24
|
|
||||||
SOCIAL_APP_ORIGIN: str = ""
|
|
||||||
THREADS_APP_ID: str = ""
|
|
||||||
THREADS_APP_SECRET: str = ""
|
|
||||||
THREADS_REDIRECT_URI: str = ""
|
|
||||||
ALIMTALK_API_KEY: str = ""
|
|
||||||
ALIMTALK_API_SECRET: str = ""
|
|
||||||
ALIMTALK_PROFILE_ID: str = ""
|
|
||||||
ALIMTALK_SENDER: str = ""
|
|
||||||
ALIMTALK_TEMPLATE_CODE: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def get(name, default=""):
|
|
||||||
return getattr(SocialConfig(), name, default) or default
|
|
||||||
|
|
||||||
|
|
||||||
def required(name):
|
|
||||||
value = get(name)
|
|
||||||
if not value:
|
|
||||||
raise ValueError("SOCIAL_SETTING_REQUIRED")
|
|
||||||
return value
|
|
||||||
@ -166,7 +166,7 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
|
|
||||||
★ 왜 필요한가
|
★ 왜 필요한가
|
||||||
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
|
발행 게이트는 이제 **실제로 나갈 HTML** 을 보고 판정한다. 그 HTML 은 Node 렌더러가
|
||||||
굽고, BUILD 잡은 렌더러 subprocess 를 직접 돌린다(services/render_service.render_site).
|
굽고, BUILD 잡은 그 결과 보고서를 기다린다(services/render_report.wait_for).
|
||||||
파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는
|
파이썬 테스트 환경에는 Node 렌더러가 없으므로, payload 를 읽어 보고서를 만들어 주는
|
||||||
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
|
대역을 끼운다 — 여기서 검사하려는 건 **백엔드가 보고서를 어떻게 처리하는가** 다.
|
||||||
|
|
||||||
@ -175,9 +175,8 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
|
두 벌로 두면 어긋나고, 어긋난 걸 아무도 모르는 게 원래 문제였다.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from services import render_report, render_service, site_payload
|
from services import render_report, site_payload
|
||||||
|
|
||||||
payload_dir = tmp_path_factory.mktemp("payloads")
|
payload_dir = tmp_path_factory.mktemp("payloads")
|
||||||
monkeypatch.setattr(site_payload, "payload_dir", lambda: payload_dir)
|
monkeypatch.setattr(site_payload, "payload_dir", lambda: payload_dir)
|
||||||
@ -230,16 +229,13 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
count += sum(1 for m in payload.get("media") or [] if long(m.get("alt")))
|
count += sum(1 for m in payload.get("media") or [] if long(m.get("alt")))
|
||||||
return count
|
return count
|
||||||
|
|
||||||
async def _render_site(payload_path, site_version, timeout_sec):
|
async def _wait_for(slug, site_version, timeout_sec, poll_sec=1.0):
|
||||||
path = Path(payload_path)
|
path = payload_dir / f"{slug}.json"
|
||||||
slug = path.stem
|
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
raise render_service.RenderFailed(f"payload 파일이 없다: {payload_path}")
|
return None
|
||||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
if payload["site"]["version"] != site_version:
|
if payload["site"]["version"] != site_version:
|
||||||
raise render_service.RenderFailed(
|
return None
|
||||||
f"버전이 다르다(payload={payload['site']['version']} 요청={site_version})"
|
|
||||||
)
|
|
||||||
place = payload.get("place") or {}
|
place = payload.get("place") or {}
|
||||||
count = _count(payload)
|
count = _count(payload)
|
||||||
# ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다**
|
# ★ 고유 콘텐츠 0건이면 렌더러는 **페이지를 쓰지 않는다**
|
||||||
@ -280,8 +276,5 @@ def fake_renderer(monkeypatch, tmp_path_factory):
|
|||||||
"error": None,
|
"error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(render_service, "render_site", _render_site)
|
monkeypatch.setattr(render_report, "wait_for", _wait_for)
|
||||||
async def _activate_site(slug, site_version):
|
|
||||||
return None
|
|
||||||
monkeypatch.setattr(render_service, "activate_site", _activate_site)
|
|
||||||
return payload_dir
|
return payload_dir
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
"""alert_outbox 원장 접근. services/alert_service.py 가 부른다."""
|
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
|
|
||||||
from common.database.model.models import alert_outbox
|
|
||||||
from common.enums import AlertStatus
|
|
||||||
from common.utils.gtime import GTime
|
|
||||||
|
|
||||||
|
|
||||||
async def latest_unresolved(session, dedupe_key: str):
|
|
||||||
"""이 dedupe_key 로 아직 안 풀린(resolved_at IS NULL) 가장 최근 알림. 없으면 None.
|
|
||||||
|
|
||||||
★ send_alert 의 중복 억제와 resolve_alert 의 "지금 알람 상태인가" 판정이 **같은 질의**를
|
|
||||||
쓴다 — 따로 구현하면 두 판단이 어긋날 수 있다."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(alert_outbox)
|
|
||||||
.where(alert_outbox.dedupe_key == dedupe_key, alert_outbox.deleted.is_(False),
|
|
||||||
alert_outbox.resolved_at.is_(None))
|
|
||||||
.order_by(alert_outbox.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
|
|
||||||
async def insert(session, values: dict) -> alert_outbox:
|
|
||||||
row = alert_outbox(**values)
|
|
||||||
session.add(row)
|
|
||||||
await session.flush()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
async def due_pending(session, limit: int = 20):
|
|
||||||
"""★ `next_attempt_at <= func.now()` — **DB 서버의** 지금 시각과 비교한다. 파이썬에서 계산한
|
|
||||||
GTime.UTC() 와 비교하면 앱 서버와 DB 서버의 시계가 몇 십 ms 만 어긋나도(흔하다 — 별도
|
|
||||||
컨테이너) send_alert 직후 process_outbox 를 부르는 자리에서 방금 넣은 행이 안 잡힐 수
|
|
||||||
있다(실측: 로컬에서 그렇게 재현됐다). 비교를 DB 쪽 시계 하나로 통일하면 이 경합이 없다."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(alert_outbox)
|
|
||||||
.where(alert_outbox.status == AlertStatus.PENDING.value, alert_outbox.deleted.is_(False),
|
|
||||||
alert_outbox.next_attempt_at <= func.now())
|
|
||||||
.order_by(alert_outbox.next_attempt_at)
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_sent(session, alert_id) -> None:
|
|
||||||
now = GTime.UTC()
|
|
||||||
await session.execute(
|
|
||||||
update(alert_outbox).where(alert_outbox.alert_id == alert_id)
|
|
||||||
.values(status=AlertStatus.SENT.value, sent_at=now, updated_at=now)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_retry(session, alert_id, attempts: int, next_attempt_at) -> None:
|
|
||||||
await session.execute(
|
|
||||||
update(alert_outbox).where(alert_outbox.alert_id == alert_id)
|
|
||||||
.values(attempts=attempts, next_attempt_at=next_attempt_at, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_exhausted(session, alert_id, attempts: int) -> None:
|
|
||||||
"""재시도 상한 소진 — 더 시도하지 않는다(사람이 outbox 를 봐야 한다)."""
|
|
||||||
await session.execute(
|
|
||||||
update(alert_outbox).where(alert_outbox.alert_id == alert_id)
|
|
||||||
.values(status=AlertStatus.FAILED.value, attempts=attempts, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def mark_resolved(session, alert_id) -> None:
|
|
||||||
now = GTime.UTC()
|
|
||||||
await session.execute(
|
|
||||||
update(alert_outbox).where(alert_outbox.alert_id == alert_id)
|
|
||||||
.values(resolved_at=now, updated_at=now)
|
|
||||||
)
|
|
||||||
@ -83,7 +83,7 @@ class JobQueue:
|
|||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
)
|
)
|
||||||
RETURNING job_id, job_type, payload, attempts, max_attempts, worker_id
|
RETURNING job_id, job_type, payload, attempts, max_attempts
|
||||||
""")
|
""")
|
||||||
|
|
||||||
async def run(s):
|
async def run(s):
|
||||||
@ -140,25 +140,6 @@ 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:
|
|
||||||
"""재시도 없이 바로 DEAD. 시도 횟수가 남아 있어도 보내지 않는다.
|
|
||||||
|
|
||||||
★ 다시 해도 같은 결과인 실패에 쓴다(common/job_errors.PermanentJobError).
|
|
||||||
백오프 재큐는 '일시적 장애' 라는 판단인데, 사업장이 지워졌거나 업종이 없는 잡은
|
|
||||||
그 판단이 틀렸다 — 큐만 붙들고 DEAD 알림을 세 배로 늘린다."""
|
|
||||||
sql = text("""
|
|
||||||
UPDATE jobs SET status = 4, last_error = :err,
|
|
||||||
lease_until = NULL, worker_id = NULL, updated_at = now()
|
|
||||||
WHERE job_id = CAST(:id AS uuid) AND status = 2 AND worker_id = :wid
|
|
||||||
RETURNING job_id
|
|
||||||
""")
|
|
||||||
|
|
||||||
async def run(s):
|
|
||||||
row = (await s.execute(sql, {"id": job_id, "wid": worker_id, "err": error[:2000]})).first()
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
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("""
|
||||||
@ -173,12 +154,9 @@ class JobQueue:
|
|||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def reap(self) -> list[dict]:
|
async def reap(self) -> list[str]:
|
||||||
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
|
"""만료된 lease(워커 사망 등)의 RUNNING 잡을 회수. 시도 남으면 즉시 재큐, 소진되면 DEAD.
|
||||||
|
회수된 job_id 목록 반환."""
|
||||||
회수된 잡마다 {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,
|
||||||
@ -188,54 +166,21 @@ class JobQueue:
|
|||||||
worker_id = NULL,
|
worker_id = NULL,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now()
|
WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now()
|
||||||
RETURNING job_id, job_type, status, last_error
|
|
||||||
""")
|
|
||||||
|
|
||||||
async def run(s):
|
|
||||||
rows = (await s.execute(sql)).all()
|
|
||||||
return [
|
|
||||||
{"job_id": str(r[0]), "job_type": r[1], "status": r[2], "last_error": r[3]}
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
return await self._tx(run)
|
|
||||||
|
|
||||||
# ---- 단건 조회 (상태 폴링) ----
|
|
||||||
async def set_progress(self, job: dict, progress: dict) -> bool:
|
|
||||||
# 회수된 옛 워커가 새 시도의 진행 상태를 덮지 못하게 한다.
|
|
||||||
sql = text("""
|
|
||||||
UPDATE jobs SET progress = CAST(:progress AS jsonb), updated_at = now()
|
|
||||||
WHERE job_id = CAST(:id AS uuid) AND status = 2
|
|
||||||
AND worker_id = :wid AND attempts = :attempt
|
|
||||||
AND lease_until > now()
|
|
||||||
RETURNING job_id
|
RETURNING job_id
|
||||||
""")
|
""")
|
||||||
|
|
||||||
async def run(s):
|
async def run(s):
|
||||||
row = (await s.execute(sql, {
|
rows = (await s.execute(sql)).all()
|
||||||
"id": job["job_id"], "wid": job["worker_id"], "attempt": job["attempts"],
|
return [str(r[0]) for r in rows]
|
||||||
"progress": json.dumps(progress),
|
|
||||||
})).first()
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
return await self._tx(run)
|
return await self._tx(run)
|
||||||
|
|
||||||
async def find_latest(self, dedupe_key: str) -> dict | None:
|
# ---- 단건 조회 (상태 폴링) ----
|
||||||
"""복구는 완료·실패 이력도 찾는다. 활성 중복 방지와 다른 조회다."""
|
|
||||||
async def run(s):
|
|
||||||
row = (await s.execute(text("""
|
|
||||||
SELECT job_id, status FROM jobs WHERE dedupe_key = :dk
|
|
||||||
ORDER BY created_at DESC, job_id DESC LIMIT 1
|
|
||||||
"""), {"dk": dedupe_key})).mappings().first()
|
|
||||||
return {**row, "job_id": str(row["job_id"])} if row else None
|
|
||||||
|
|
||||||
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 값)."""
|
"""잡 단건 조회(읽기). 없으면 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, last_error, run_after, run_started_at, created_at, updated_at
|
||||||
FROM jobs WHERE job_id = CAST(:id AS uuid)
|
FROM jobs WHERE job_id = CAST(:id AS uuid)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@ -245,7 +190,7 @@ class JobQueue:
|
|||||||
return None
|
return None
|
||||||
d = dict(row)
|
d = dict(row)
|
||||||
d["job_id"] = str(d["job_id"])
|
d["job_id"] = str(d["job_id"])
|
||||||
for key in ("payload", "result", "progress"):
|
for key in ("payload", "result"):
|
||||||
if isinstance(d.get(key), str):
|
if isinstance(d.get(key), str):
|
||||||
d[key] = json.loads(d[key])
|
d[key] = json.loads(d[key])
|
||||||
return d
|
return d
|
||||||
|
|||||||
@ -1,208 +0,0 @@
|
|||||||
"""place_posts 접근. 미니 블로그 글의 일생을 이 표 하나로 본다(docs/MINI_BLOG.md)."""
|
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from common.database.model.models import place_posts
|
|
||||||
from common.enums import ErrorType, PostStatus
|
|
||||||
from common.utils.gtime import GTime
|
|
||||||
|
|
||||||
|
|
||||||
class PostCRUD:
|
|
||||||
async def add_many(self, cdb: AsyncSession, rows: list[dict]) -> ErrorType:
|
|
||||||
"""생성분 적재. 같은 주제가 이미 있거나 같은 날짜를 이미 썼으면 그 건만 건너뛴다 —
|
|
||||||
회차 전체를 버리지 않는다(topic_key 유니크와 scheduled_date 유니크가 각각 막는다)."""
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
cdb.add(place_posts(**row))
|
|
||||||
await cdb.flush()
|
|
||||||
except Exception: # noqa: BLE001 — 유니크 충돌 = 이미 쓴 주제거나 이미 찬 날짜
|
|
||||||
await cdb.rollback()
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
async def add_one(self, cdb: AsyncSession, row: dict) -> dict | None:
|
|
||||||
"""개별 생성(빈 날짜 하나 채우기) 전용 — `add_many` 와 달리 성공하면 삽입된 값
|
|
||||||
(post_id 포함)을 그대로 돌려준다. 사장님이 콕 집은 날짜라 "이미 있어서 조용히
|
|
||||||
건너뜀" 으로 끝내면 안 된다.
|
|
||||||
|
|
||||||
★ ORM 객체를 그대로 돌려주지 않는다 — 호출측이 commit 뒤에 속성을 읽으면
|
|
||||||
detached 라 깨진다. flush() 직후(아직 세션이 살아있을 때) 값만 뽑아 dict 로 준다."""
|
|
||||||
try:
|
|
||||||
obj = place_posts(**row)
|
|
||||||
cdb.add(obj)
|
|
||||||
await cdb.flush()
|
|
||||||
return {
|
|
||||||
"post_id": obj.post_id, "place_id": obj.place_id, "body": obj.body,
|
|
||||||
"topic_kind": obj.topic_kind, "topic_key": obj.topic_key, "status": obj.status,
|
|
||||||
"scheduled_date": obj.scheduled_date, "generation_meta": obj.generation_meta,
|
|
||||||
}
|
|
||||||
except Exception: # noqa: BLE001 — 유니크 충돌(그 날짜 이미 있음 등)
|
|
||||||
await cdb.rollback()
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def max_scheduled_date(self, cdb: AsyncSession, place_id):
|
|
||||||
"""이 업장이 이미 배정한 가장 늦은 날짜. 없으면 None(오늘부터 채운다)."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(func.max(place_posts.scheduled_date))
|
|
||||||
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
|
||||||
)
|
|
||||||
return result.scalar()
|
|
||||||
|
|
||||||
async def due_for_mail(self, cdb: AsyncSession, status: int, today, limit: int):
|
|
||||||
"""배정일이 오늘까지 온 것 중 업장당 1건만, 이른 날짜순. 업장 하나가 밀려 있어도
|
|
||||||
하루 한 통만 나간다(규모가 작아 DISTINCT ON 결과를 파이썬에서 정렬해도 무리 없다)."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts)
|
|
||||||
.where(
|
|
||||||
place_posts.status == status, place_posts.deleted == False, # noqa: E712
|
|
||||||
place_posts.scheduled_date <= today,
|
|
||||||
)
|
|
||||||
.distinct(place_posts.place_id)
|
|
||||||
.order_by(place_posts.place_id, place_posts.scheduled_date)
|
|
||||||
)
|
|
||||||
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
|
|
||||||
return ErrorType.SUCCESS, rows[:limit]
|
|
||||||
|
|
||||||
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
|
||||||
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None.
|
|
||||||
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts)
|
|
||||||
.where(
|
|
||||||
place_posts.place_id == place_id, place_posts.status == status,
|
|
||||||
place_posts.deleted == False, # noqa: E712
|
|
||||||
place_posts.scheduled_date <= today,
|
|
||||||
)
|
|
||||||
.order_by(place_posts.scheduled_date)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
|
|
||||||
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts)
|
|
||||||
.where(
|
|
||||||
place_posts.place_id == place_id,
|
|
||||||
place_posts.deleted == False, # noqa: E712
|
|
||||||
place_posts.scheduled_date >= since,
|
|
||||||
place_posts.scheduled_date < until,
|
|
||||||
)
|
|
||||||
.order_by(place_posts.scheduled_date.desc())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS, list(result.scalars())
|
|
||||||
|
|
||||||
async def by_id(self, cdb: AsyncSession, post_id):
|
|
||||||
"""메일의 '수정하기' 링크(자동 로그인) 전용 — postId 하나로 바로 찾는다."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts).where(place_posts.post_id == post_id, place_posts.deleted == False) # noqa: E712
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def generation_batches(self, cdb: AsyncSession, place_id, limit: int = 30):
|
|
||||||
"""생성 이력 — 한 번의 생성 스윕(같은 트랜잭션의 created_at)을 한 회차로 묶는다.
|
|
||||||
새 컬럼 없이 기존 created_at 만으로 센다 — add_many 가 한 트랜잭션 안에서 넣으므로
|
|
||||||
같은 회차의 created_at 은 DB now() 기준으로 전부 같다. 모델명은 같은 회차 안에서도
|
|
||||||
전부 같아야 정상이지만(한 스윕 = 한 모델), `max()` 로 대표값 하나만 뽑는다."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(
|
|
||||||
place_posts.created_at,
|
|
||||||
func.count().label("count"),
|
|
||||||
func.max(place_posts.generation_meta["model"].astext).label("model"),
|
|
||||||
)
|
|
||||||
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
|
||||||
.group_by(place_posts.created_at)
|
|
||||||
.order_by(place_posts.created_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
async def used_topic_keys(self, cdb: AsyncSession, place_id) -> list[str]:
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts.topic_key)
|
|
||||||
.where(place_posts.place_id == place_id, place_posts.deleted == False) # noqa: E712
|
|
||||||
)
|
|
||||||
return [row[0] for row in result.all()]
|
|
||||||
|
|
||||||
async def published(self, cdb: AsyncSession, place_id, limit: int = 200):
|
|
||||||
"""화면에 나갈 글. 최신순이고, 게재된 것만."""
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts)
|
|
||||||
.where(
|
|
||||||
place_posts.place_id == place_id,
|
|
||||||
place_posts.status == PostStatus.PUBLISHED.value,
|
|
||||||
place_posts.deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(place_posts.published_at.desc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
return list(result.scalars())
|
|
||||||
|
|
||||||
async def by_token_hash(self, cdb: AsyncSession, token_hash: str):
|
|
||||||
result = await cdb.execute(
|
|
||||||
select(place_posts)
|
|
||||||
.where(place_posts.approve_token_hash == token_hash, place_posts.deleted == False) # noqa: E712
|
|
||||||
)
|
|
||||||
return result.scalars().first()
|
|
||||||
|
|
||||||
async def mark_sent(self, cdb: AsyncSession, post_id, token_hash: str, expires_at) -> ErrorType:
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(place_posts.post_id == post_id)
|
|
||||||
.values(status=PostStatus.SENT.value, approve_token_hash=token_hash,
|
|
||||||
token_expires_at=expires_at, sent_at=GTime.UTC(), updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
# 메일(SENT)뿐 아니라 아직 안 보낸 재고(REVIEWED)도 고칠·승인할 수 있다 — 사장님이
|
|
||||||
# 빌더 앱에 로그인해 이번 달 글 목록에서 직접 고를 때는 메일이 먼저 나가 있을 필요가 없다.
|
|
||||||
_EDITABLE = (PostStatus.SENT.value, PostStatus.REVIEWED.value)
|
|
||||||
|
|
||||||
async def update_body(self, cdb: AsyncSession, post_id, body: str) -> ErrorType:
|
|
||||||
"""수정하기 — 이미 승인·게재·반려된 글은 못 고친다."""
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
|
|
||||||
.values(body=body, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
async def approve(self, cdb: AsyncSession, post_id) -> ErrorType:
|
|
||||||
"""★ 토큰을 지우면서 승인한다 — 같은 링크를 두 번 눌러도 두 번 게재되지 않는다."""
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(place_posts.post_id == post_id, place_posts.status.in_(self._EDITABLE))
|
|
||||||
.values(status=PostStatus.APPROVED.value, approved_at=GTime.UTC(),
|
|
||||||
approve_token_hash=None, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
async def skip(self, cdb: AsyncSession, post_id) -> ErrorType:
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(place_posts.post_id == post_id)
|
|
||||||
.values(status=PostStatus.SKIPPED.value, approve_token_hash=None, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
async def mark_published(self, cdb: AsyncSession, place_id, version_id) -> ErrorType:
|
|
||||||
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(
|
|
||||||
place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value,
|
|
||||||
place_posts.deleted == False, # noqa: E712 — 승인 후 삭제된 글까지 게재로 옮기지 않는다
|
|
||||||
)
|
|
||||||
.values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
|
|
||||||
published_version_id=version_id, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
|
||||||
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false
|
|
||||||
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
|
|
||||||
await cdb.execute(
|
|
||||||
update(place_posts)
|
|
||||||
.where(place_posts.post_id == post_id)
|
|
||||||
.values(deleted=True, updated_at=GTime.UTC())
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
@ -1,57 +0,0 @@
|
|||||||
"""발행 DB를 작업 원장으로 사용해 알림 적재 실패/재시작에도 대상을 다시 찾는다."""
|
|
||||||
from sqlalchemy import select, or_, text
|
|
||||||
from sqlalchemy.dialects.postgresql import insert
|
|
||||||
|
|
||||||
from common.database.model.models import sites, places, site_versions, site_search_status as Status
|
|
||||||
from common.enums import SiteStatus
|
|
||||||
|
|
||||||
|
|
||||||
def published_conditions():
|
|
||||||
return (
|
|
||||||
sites.deleted.is_(False), places.deleted.is_(False),
|
|
||||||
sites.status == SiteStatus.PUBLISHED.value,
|
|
||||||
sites.published_at.is_not(None), sites.current_version_id.is_not(None),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def lock_batch(session) -> bool:
|
|
||||||
# 여러 API 프로세스가 같은 크론을 등록해도 외부 호출은 한 곳만 수행한다.
|
|
||||||
return bool(await session.scalar(text("SELECT pg_try_advisory_xact_lock(734920151)")))
|
|
||||||
|
|
||||||
|
|
||||||
async def new_publications(session, property_url: str):
|
|
||||||
result = await session.execute(
|
|
||||||
select(sites, places, site_versions)
|
|
||||||
.join(places, places.place_id == sites.place_id)
|
|
||||||
.join(site_versions, site_versions.site_version_id == sites.current_version_id)
|
|
||||||
.outerjoin(Status, Status.site_id == sites.site_id)
|
|
||||||
.where(*published_conditions(), site_versions.deleted.is_(False), or_(
|
|
||||||
Status.site_id.is_(None), Status.site_version_id != sites.current_version_id,
|
|
||||||
Status.property_url != property_url, Status.published_at != sites.published_at,
|
|
||||||
))
|
|
||||||
.order_by(sites.published_at).limit(100)
|
|
||||||
)
|
|
||||||
return result.all()
|
|
||||||
|
|
||||||
|
|
||||||
async def register(session, values: dict):
|
|
||||||
reset = dict(values, sitemap_submitted_at=None, inspected_at=None, first_indexed_at=None,
|
|
||||||
inspection=None, error_code=None, failures=0, alerted_at=None,
|
|
||||||
next_check_at=text("now()"), updated_at=text("now()"), deleted=False)
|
|
||||||
await session.execute(insert(Status).values(**values).on_conflict_do_update(
|
|
||||||
index_elements=[Status.site_id], set_=reset,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
async def due_sites(session, property_url: str):
|
|
||||||
result = await session.execute(
|
|
||||||
select(Status).join(sites, sites.site_id == Status.site_id)
|
|
||||||
.join(places, places.place_id == sites.place_id)
|
|
||||||
.where(*published_conditions(), Status.deleted.is_(False),
|
|
||||||
Status.site_version_id == sites.current_version_id,
|
|
||||||
Status.published_at == sites.published_at, Status.property_url == property_url,
|
|
||||||
Status.next_check_at <= text("now()"))
|
|
||||||
.order_by(Status.next_check_at).limit(5)
|
|
||||||
.with_for_update(of=Status)
|
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
@ -5,34 +5,12 @@ from sqlalchemy import and_, func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import place_photos, places, site_publish_logs, site_versions, sites
|
from common.database.model.models import places, site_publish_logs, site_versions, sites
|
||||||
from common.enums import BuildStatus, ErrorType, MediaStatus, SiteStatus
|
from common.enums import BuildStatus, ErrorType, SiteStatus
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
|
|
||||||
|
|
||||||
def _primary_photo_subquery():
|
|
||||||
"""place_photos 에서 대표 사진 한 장의 url 만 고르는 상관 서브쿼리(사업장당 1행).
|
|
||||||
|
|
||||||
site_payload.primary_media 와 같은 규칙 — 객실·메뉴 사진(unit_id 있음)이 아닌 첫 장,
|
|
||||||
sort_order 순. `.correlate(places)` 라서 바깥 쿼리가 `places` 를 셀렉트에 들고 있어야 한다.
|
|
||||||
sites.thumbnail_url 이 비어 있을 때(Azure 썸네일 저장소 미설정 등) 서비스 계층이 이걸로
|
|
||||||
대신 채운다 — 여기서는 후보만 얹고, 언제 쓸지는 서비스 계층 몫이다."""
|
|
||||||
return (
|
|
||||||
select(place_photos.url)
|
|
||||||
.where(
|
|
||||||
place_photos.place_id == places.place_id,
|
|
||||||
place_photos.deleted == False, # noqa: E712
|
|
||||||
place_photos.status == MediaStatus.APPROVED.value,
|
|
||||||
place_photos.unit_id.is_(None),
|
|
||||||
)
|
|
||||||
.order_by(place_photos.sort_order.asc(), place_photos.created_at.asc())
|
|
||||||
.limit(1)
|
|
||||||
.correlate(places)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
# 사이트/버전/발행로그 CRUD. 항상 place_id 또는 site_id 로 스코프한다.
|
||||||
class ISiteCRUD(ABC):
|
class ISiteCRUD(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -45,7 +23,6 @@ class ISiteCRUD(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
async def list_owner_sites(self, cdb: AsyncSession, owner_user_id, skip, limit) -> Tuple[ErrorType, list, int]:
|
||||||
"""(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수)."""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@ -68,10 +45,6 @@ class ISiteCRUD(ABC):
|
|||||||
async def get_version(self, cdb: AsyncSession, site_id, site_version_id) -> Tuple[ErrorType, site_versions]:
|
async def get_version(self, cdb: AsyncSession, site_id, site_version_id) -> Tuple[ErrorType, site_versions]:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_version_by_number(self, cdb: AsyncSession, site_id, version: int) -> Tuple[ErrorType, site_versions]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]:
|
async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]:
|
||||||
pass
|
pass
|
||||||
@ -90,7 +63,6 @@ class ISiteCRUD(ABC):
|
|||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
|
async def list_published(self, cdb: AsyncSession, limit: int) -> Tuple[ErrorType, list]:
|
||||||
"""(ErrorType, [(site, place, primary_photo_url)])."""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@ -122,16 +94,10 @@ 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)], 총건수).
|
||||||
(ErrorType, [(place, site, built_at, primary_photo_url)], 총건수).
|
|
||||||
|
|
||||||
따로 읽으면 줄마다 사이트를 다시 물어 N+1 이다. LEFT JOIN 이라 사이트가 없는 사업장
|
따로 읽으면 줄마다 사이트를 다시 물어 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
|
||||||
|
|
||||||
@ -141,7 +107,7 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
total = int(cnt_rows[0] or 0) if cnt_rows else 0
|
||||||
|
|
||||||
query = (
|
query = (
|
||||||
select(places, sites, site_versions.built_at, _primary_photo_subquery())
|
select(places, sites, site_versions.built_at)
|
||||||
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
|
.outerjoin(sites, and_(sites.place_id == places.place_id, sites.deleted == False)) # noqa: E712
|
||||||
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
|
.outerjoin(site_versions, site_versions.site_version_id == sites.current_version_id)
|
||||||
.where(where)
|
.where(where)
|
||||||
@ -223,31 +189,6 @@ class SiteCRUD(ISiteCRUD):
|
|||||||
LOG.e_no_callstack(ex)
|
LOG.e_no_callstack(ex)
|
||||||
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]:
|
|
||||||
"""롤백 대상 조회 — site_versions.version(사람이 보는 번호) 로 찾는다.
|
|
||||||
|
|
||||||
★ site_version_id(uuid) 가 아니다. 화면·API 는 버전 번호로 고르는 게 자연스럽고,
|
|
||||||
그 번호가 곧 out/versions/<slug>/<version>/ 디렉토리 이름이다(prerender.ts)."""
|
|
||||||
try:
|
|
||||||
query = (
|
|
||||||
select(site_versions)
|
|
||||||
.where(
|
|
||||||
site_versions.site_id == site_id,
|
|
||||||
site_versions.version == version,
|
|
||||||
site_versions.deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
|
|
||||||
if err_type != ErrorType.SUCCESS:
|
|
||||||
return err_type, None
|
|
||||||
if len(rows) != 1:
|
|
||||||
return ErrorType.DB_INVALID_KEY, None
|
|
||||||
return ErrorType.SUCCESS, rows[0]
|
|
||||||
except Exception as ex:
|
|
||||||
LOG.e_no_callstack(ex)
|
|
||||||
return ErrorType.DB_RUN_FAILED, None
|
|
||||||
|
|
||||||
async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]:
|
async def finish_version(self, cdb: AsyncSession, site_version_id, data: dict) -> Tuple[ErrorType, int]:
|
||||||
"""빌드 결과를 버전에 기록한다(BUILT 또는 FAILED)."""
|
"""빌드 결과를 버전에 기록한다(BUILT 또는 FAILED)."""
|
||||||
try:
|
try:
|
||||||
@ -295,18 +236,14 @@ 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 가 쓴다). 그래서 행을 통째로
|
★ 회사 스코프가 없는 **유일한** 사이트 조회다(비로그인 API 가 쓴다). 그래서 행을 통째로
|
||||||
돌려주고, 무엇이 밖으로 나갈지는 services/showcase_service 한 곳에서만 고른다 —
|
돌려주고, 무엇이 밖으로 나갈지는 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)
|
||||||
.join(places, places.place_id == sites.place_id)
|
.join(places, places.place_id == sites.place_id)
|
||||||
.where(
|
.where(
|
||||||
sites.status == SiteStatus.PUBLISHED.value,
|
sites.status == SiteStatus.PUBLISHED.value,
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
"""승인 CAS와 큐 적재를 같은 트랜잭션으로 묶어 승인 후 잡 유실을 막는다."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from sqlalchemy import text
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import place_social_posts as Post
|
|
||||||
from common.enums import JobType
|
|
||||||
|
|
||||||
|
|
||||||
async def transaction(fn):
|
|
||||||
return await DB_SESSION_MNG.execute_lambda_write(Post.DBType(), fn)
|
|
||||||
|
|
||||||
|
|
||||||
async def enqueue(s, post_id, job_type):
|
|
||||||
await s.execute(
|
|
||||||
text("""INSERT INTO jobs(job_type, payload, dedupe_key, max_attempts)
|
|
||||||
VALUES (:type, CAST(:payload AS jsonb), :key, 1)
|
|
||||||
ON CONFLICT (dedupe_key) WHERE status IN (1,2) AND dedupe_key IS NOT NULL DO NOTHING"""),
|
|
||||||
{
|
|
||||||
"type": job_type,
|
|
||||||
"payload": json.dumps({"post_id": str(post_id)}),
|
|
||||||
"key": f"social:{job_type}:{post_id}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await s.execute(text("SELECT pg_notify('web4ai_job', '')"))
|
|
||||||
|
|
||||||
|
|
||||||
async def decide(s, post_id, sha, approve, via):
|
|
||||||
row = (
|
|
||||||
await s.execute(
|
|
||||||
text("""UPDATE place_social_posts
|
|
||||||
SET status=:status, decided_at=now(), decided_via=:via, updated_at=now()
|
|
||||||
WHERE post_id=:id AND deleted=false AND status='PENDING_APPROVAL'
|
|
||||||
AND approval_token_sha=:sha AND approval_expires_at>now()
|
|
||||||
RETURNING post_id, account_id"""),
|
|
||||||
{
|
|
||||||
"status": "APPROVED" if approve else "DECLINED",
|
|
||||||
"via": via,
|
|
||||||
"id": post_id,
|
|
||||||
"sha": sha,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
if row and approve and row.account_id:
|
|
||||||
await enqueue(s, post_id, JobType.SOCIAL_POST.value)
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
async def sweep():
|
|
||||||
async def run(s):
|
|
||||||
await s.execute(
|
|
||||||
text("""UPDATE place_social_posts SET status='EXPIRED', updated_at=now()
|
|
||||||
WHERE deleted=false AND status='PENDING_APPROVAL' AND approval_expires_at<=now()""")
|
|
||||||
)
|
|
||||||
# POSTING은 외부가 받았을 수 있다. 시간을 근거로 APPROVED로 돌리지 않는다.
|
|
||||||
await s.execute(
|
|
||||||
text("""UPDATE place_social_posts SET status='UNKNOWN',
|
|
||||||
last_error='POST_RESULT_UNKNOWN', updated_at=now()
|
|
||||||
WHERE deleted=false AND status='POSTING' AND updated_at<now()-interval '10 minutes'""")
|
|
||||||
)
|
|
||||||
await s.execute(
|
|
||||||
text("""UPDATE place_social_posts SET status='FAILED',
|
|
||||||
last_error='DRAFT_INTERRUPTED', updated_at=now()
|
|
||||||
WHERE deleted=false AND status='DRAFTING' AND updated_at<now()-interval '10 minutes'""")
|
|
||||||
)
|
|
||||||
|
|
||||||
await transaction(run)
|
|
||||||
@ -9,11 +9,7 @@ orjson
|
|||||||
pydantic>=2.0
|
pydantic>=2.0
|
||||||
python-multipart
|
python-multipart
|
||||||
httpx
|
httpx
|
||||||
google-auth>=2.0 # Search Console 서비스 계정 인증 (선택 기능)
|
|
||||||
requests>=2.31 # google-auth 토큰 갱신 transport
|
|
||||||
apscheduler>=3.10
|
apscheduler>=3.10
|
||||||
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
|
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
|
||||||
azure-storage-blob>=12.19
|
azure-storage-blob>=12.19
|
||||||
azure-communication-email>=1.0
|
|
||||||
cryptography>=42 # SNS 위임 토큰 Fernet 암호화(평문 저장 경로 없음)
|
|
||||||
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)
|
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import time
|
import time
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.middleware.gzip import GZipMiddleware
|
from fastapi.middleware.gzip import GZipMiddleware
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.enums import DBType, DBWRType
|
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from config.server_configs import web_server_config
|
from config.server_configs import web_server_config
|
||||||
@ -17,19 +15,10 @@ import router.v1.place.place
|
|||||||
import router.v1.fact.fact
|
import router.v1.fact.fact
|
||||||
import router.v1.faq.faq
|
import router.v1.faq.faq
|
||||||
import router.v1.media.media
|
import router.v1.media.media
|
||||||
import router.v1.media.relay
|
|
||||||
import router.v1.job.job
|
import router.v1.job.job
|
||||||
import router.v1.site.site
|
import router.v1.site.site
|
||||||
import router.v1.site.showcase
|
import router.v1.site.showcase
|
||||||
import router.v1.site.booking_request
|
|
||||||
import router.v1.site.post
|
|
||||||
import router.v1.site.review
|
|
||||||
import router.v1.local.local
|
import router.v1.local.local
|
||||||
import router.v1.social.social
|
|
||||||
import router.v1.social.oauth
|
|
||||||
import router.v1.agent.kakao
|
|
||||||
import router.v1.agent.chat
|
|
||||||
import router.v1.agent.kakao_bot
|
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
API_SERVER_START_TIME = GTime.UTCStr()
|
||||||
|
|
||||||
@ -80,10 +69,6 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
|
|||||||
async def log_time(request: Request, call_next):
|
async def log_time(request: Request, call_next):
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
if request.url.path.startswith('/v1/social/'):
|
|
||||||
response.headers['Cache-Control'] = 'no-store'
|
|
||||||
response.headers['Referrer-Policy'] = 'no-referrer'
|
|
||||||
response.headers['X-Robots-Tag'] = 'noindex, nofollow'
|
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
|
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
|
||||||
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
|
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
|
||||||
@ -95,52 +80,16 @@ async def healthz():
|
|||||||
return API_SERVER_START_TIME
|
return API_SERVER_START_TIME
|
||||||
|
|
||||||
|
|
||||||
@app.get(path="/readyz", responses={404: {"description": "Not found"}, 503: {"description": "Not ready"}})
|
|
||||||
async def readyz(response: Response):
|
|
||||||
"""★ healthz 와 다른 걸 본다 — healthz 는 "프로세스가 살아 있나"(항상 200),
|
|
||||||
이건 "요청을 실제로 처리할 수 있나"(DB 에 붙는지 실제로 한 번 물어본다).
|
|
||||||
|
|
||||||
★ 왜 필요한가: 이 서버·DB 가 통째로 죽으면 우리 알림(alert_service, Teams webhook)도
|
|
||||||
같이 죽는다 — 자기 장애를 자기가 알릴 수 없다. 외부 감시(uptime 모니터 등)가 이 경로를
|
|
||||||
주기적으로 찔러야 전체 다운을 잡는다. DEPLOY.md·SERVERS.md 에 붙일 절차: 이 경로가
|
|
||||||
2xx 가 아니면(또는 응답이 없으면) 그 감시 서비스 **자신의** 채널로 알린다 — Teams
|
|
||||||
webhook 이 죽은 원인 그 자체일 수 있으므로 같은 경로로 알리면 안 된다."""
|
|
||||||
try:
|
|
||||||
async def _ping(s):
|
|
||||||
await s.execute(text("SELECT 1"))
|
|
||||||
return True
|
|
||||||
|
|
||||||
await DB_SESSION_MNG.execute_lambda(DBType.MAIN.value, DBWRType.DB_READ.value, _ping)
|
|
||||||
return {"ok": True, "db": "up"}
|
|
||||||
except Exception as ex: # noqa: BLE001 — 준비 안 됐다는 것 자체가 이 엔드포인트의 응답이다
|
|
||||||
LOG.w(f"[readyz] DB 연결 확인 실패: {type(ex).__name__}: {ex}")
|
|
||||||
response.status_code = 503
|
|
||||||
return {"ok": False, "db": "down"}
|
|
||||||
|
|
||||||
|
|
||||||
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
|
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 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.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)에는 붙이지 않는다.
|
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
|
||||||
app.include_router(router.v1.site.showcase.router)
|
app.include_router(router.v1.site.showcase.router)
|
||||||
app.include_router(router.v1.site.booking_request.router)
|
|
||||||
app.include_router(router.v1.site.post.router)
|
|
||||||
app.include_router(router.v1.site.post.owner_router)
|
|
||||||
app.include_router(router.v1.site.review.router)
|
|
||||||
app.include_router(router.v1.local.local.router)
|
app.include_router(router.v1.local.local.router)
|
||||||
app.include_router(router.v1.local.local.weather_router)
|
app.include_router(router.v1.local.local.weather_router)
|
||||||
|
|
||||||
app.include_router(router.v1.social.social.router)
|
|
||||||
app.include_router(router.v1.social.oauth.router)
|
|
||||||
app.include_router(router.v1.agent.kakao.router)
|
|
||||||
app.include_router(router.v1.agent.chat.router)
|
|
||||||
app.include_router(router.v1.agent.kakao_bot.router)
|
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
"""사장님 에이전트 대화 — 빌더 화면의 입구.
|
|
||||||
|
|
||||||
★ 카카오톡 웹훅이 생겨도 이 파일은 안 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저
|
|
||||||
같은 `runtime.chat()` 을 부르는 두 번째 입구가 된다(docs/AGENT.md).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken
|
|
||||||
from services.agent import runtime
|
|
||||||
from services.agent.runtime import AgentError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/agent", tags=["Agent"])
|
|
||||||
|
|
||||||
_STATUS = {
|
|
||||||
"PLACE_NOT_FOUND": 404,
|
|
||||||
"AGENT_NOT_CONFIGURED": 409,
|
|
||||||
"AGENT_UNKNOWN_TOOL": 409,
|
|
||||||
"AGENT_EMPTY_MESSAGE": 400,
|
|
||||||
"AGENT_MESSAGE_TOO_LONG": 400,
|
|
||||||
"AGENT_CALL_FAILED": 502,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Confirm(BaseModel):
|
|
||||||
"""직전 답의 확인 버튼이 그대로 돌려보내는 값.
|
|
||||||
|
|
||||||
★ 서버는 이 값을 믿지 않는다 — 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가
|
|
||||||
다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다."""
|
|
||||||
|
|
||||||
tool: str = Field(min_length=1, max_length=40)
|
|
||||||
args: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Req_Chat(BaseModel):
|
|
||||||
message: str = Field(default="", max_length=runtime.MAX_MESSAGE)
|
|
||||||
confirm: Confirm | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
|
||||||
async def status(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
"""대화창을 열 수 있는지. 키가 없으면 화면은 자리를 두고 입력만 죽인다."""
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
return {"enabled": runtime.is_configured()}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat/{place_id}")
|
|
||||||
async def chat(
|
|
||||||
place_id: UUID,
|
|
||||||
req: Req_Chat,
|
|
||||||
response: Response,
|
|
||||||
user: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
try:
|
|
||||||
return await runtime.chat(
|
|
||||||
user,
|
|
||||||
str(place_id),
|
|
||||||
req.message,
|
|
||||||
confirm=req.confirm.model_dump() if req.confirm else None,
|
|
||||||
)
|
|
||||||
except AgentError as ex:
|
|
||||||
raise HTTPException(_STATUS.get(str(ex), 409), str(ex)) from ex
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
|
|
||||||
|
|
||||||
★ 소비(redeem) 엔드포인트는 여기 없다. 코드를 소비하는 쪽은 채널 웹훅이고, 그 웹훅은
|
|
||||||
자체 서명 검증을 갖춘 뒤에야 열 수 있다. 검증 없는 공개 소비 경로를 먼저 만들면
|
|
||||||
누구나 코드를 대입해 남의 계정에 자기 카톡을 붙일 수 있다 — 이 표가 막으려던 바로 그 일이다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
||||||
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken
|
|
||||||
from services import kakao_link_service as service
|
|
||||||
from services.kakao_link_service import KakaoLinkError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
|
||||||
|
|
||||||
|
|
||||||
def private_response(response: Response):
|
|
||||||
"""코드가 오가는 응답이다 — 캐시·리퍼러·색인을 모두 막는다(social 라우터와 같은 규약)."""
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/link")
|
|
||||||
async def link_state(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
"""연결 상태. 사업장을 고르지 않아도 답할 수 있어야 하는 값이다 — 계정은 사람에 붙는다."""
|
|
||||||
private_response(response)
|
|
||||||
return await service.state(UUID(user.user_id))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/link/code")
|
|
||||||
async def issue_code(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
"""일회용 코드를 낸다. ★ 평문 코드는 이 응답에서 한 번만 나가고 DB 에는 sha256 만 남는다."""
|
|
||||||
private_response(response)
|
|
||||||
try:
|
|
||||||
return await service.issue_code(UUID(user.user_id))
|
|
||||||
except KakaoLinkError as ex:
|
|
||||||
raise HTTPException(409, str(ex)) from ex
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/link/disconnect")
|
|
||||||
async def disconnect(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
private_response(response)
|
|
||||||
try:
|
|
||||||
await service.disconnect(UUID(user.user_id))
|
|
||||||
except KakaoLinkError as ex:
|
|
||||||
raise HTTPException(409, str(ex)) from ex
|
|
||||||
return {"disconnected": True}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
|
|
||||||
|
|
||||||
`version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을
|
|
||||||
붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다.
|
|
||||||
|
|
||||||
★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고,
|
|
||||||
`userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결
|
|
||||||
(`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다.
|
|
||||||
시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은
|
|
||||||
Threads 연결과 같은 규칙이다.
|
|
||||||
|
|
||||||
★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는
|
|
||||||
**말없이 실패하는 봇**이 된다.
|
|
||||||
→ 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다.
|
|
||||||
그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다.
|
|
||||||
콜백 주소는 **1분 · 1회**만 유효하다.
|
|
||||||
→ 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22):
|
|
||||||
필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본
|
|
||||||
1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import hmac
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request
|
|
||||||
|
|
||||||
from common.logger import LOG
|
|
||||||
from config import agent_config as config
|
|
||||||
from services.agent import channel
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
|
|
||||||
|
|
||||||
# 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 —
|
|
||||||
# 침묵보다 "잠시 뒤 다시" 가 낫다.
|
|
||||||
DEADLINE_SEC = 4.5
|
|
||||||
# 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다.
|
|
||||||
CALLBACK_DEADLINE_SEC = 45.0
|
|
||||||
|
|
||||||
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
|
|
||||||
_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."
|
|
||||||
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
|
|
||||||
|
|
||||||
|
|
||||||
def _reply(text: str, quick_replies=None) -> dict:
|
|
||||||
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
|
|
||||||
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
|
|
||||||
if quick_replies:
|
|
||||||
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
|
|
||||||
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
|
|
||||||
payload["quickReplies"] = [
|
|
||||||
{"label": label, "action": "message", "messageText": label} for label in quick_replies[:10]
|
|
||||||
]
|
|
||||||
return {"version": "2.0", "template": payload}
|
|
||||||
|
|
||||||
|
|
||||||
def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None:
|
|
||||||
expected = config.webhook_secret()
|
|
||||||
if not expected:
|
|
||||||
# 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다.
|
|
||||||
raise HTTPException(404)
|
|
||||||
given = header_secret or secret_in_path or ""
|
|
||||||
if not hmac.compare_digest(given, expected):
|
|
||||||
LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절")
|
|
||||||
raise HTTPException(404)
|
|
||||||
|
|
||||||
# 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다.
|
|
||||||
bot_id = config.get("KAKAO_BOT_ID")
|
|
||||||
if bot_id and (body.get("bot") or {}).get("id") != bot_id:
|
|
||||||
LOG.w("[agent/kakao] 다른 봇의 요청 — 거절")
|
|
||||||
raise HTTPException(404)
|
|
||||||
|
|
||||||
|
|
||||||
async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
|
|
||||||
"""대화 한 턴을 SkillResponse 로. 어떤 실패도 문구로 바꾼다."""
|
|
||||||
try:
|
|
||||||
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
|
|
||||||
return _reply(_TIMEOUT_TEXT)
|
|
||||||
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
|
|
||||||
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
|
|
||||||
return _reply(_ERROR_TEXT)
|
|
||||||
return _reply(answer["text"], answer.get("quick_replies"))
|
|
||||||
|
|
||||||
|
|
||||||
async def _push(callback_url: str, utterance: str, speaker: str) -> None:
|
|
||||||
"""답을 다 만든 뒤 콜백 주소로 보낸다.
|
|
||||||
|
|
||||||
★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피
|
|
||||||
거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다."""
|
|
||||||
payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC)
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
||||||
res = await client.post(callback_url, json=payload)
|
|
||||||
if res.status_code >= 400:
|
|
||||||
LOG.w(f"[agent/kakao] 콜백 전송 실패: {res.status_code}")
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[agent/kakao] 콜백 전송 실패: {type(ex).__name__}")
|
|
||||||
|
|
||||||
|
|
||||||
async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
|
|
||||||
request = body.get("userRequest") or {}
|
|
||||||
utterance = request.get("utterance") or ""
|
|
||||||
speaker = (request.get("user") or {}).get("id") or ""
|
|
||||||
if not speaker:
|
|
||||||
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
|
|
||||||
return _reply("사용자를 확인하지 못했어요.")
|
|
||||||
|
|
||||||
# ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
|
|
||||||
callback_url = request.get("callbackUrl")
|
|
||||||
# ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 —
|
|
||||||
# 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 콜백 설정이 그 블록에 없어 조용히 동기로 돈다.
|
|
||||||
LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} "
|
|
||||||
f"block={(request.get('block') or {}).get('name')!r}")
|
|
||||||
if callback_url:
|
|
||||||
tasks.add_task(_push, callback_url, utterance, speaker)
|
|
||||||
return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}}
|
|
||||||
|
|
||||||
return await _answer(utterance, speaker, DEADLINE_SEC)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook")
|
|
||||||
async def webhook(
|
|
||||||
request: Request,
|
|
||||||
tasks: BackgroundTasks,
|
|
||||||
x_agent_secret: str | None = Header(default=None),
|
|
||||||
):
|
|
||||||
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
|
|
||||||
body = await request.json()
|
|
||||||
_authorize(None, x_agent_secret, body)
|
|
||||||
return await _handle(body, tasks)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/webhook/{secret}")
|
|
||||||
async def webhook_with_path_secret(
|
|
||||||
secret: str,
|
|
||||||
request: Request,
|
|
||||||
tasks: BackgroundTasks,
|
|
||||||
x_agent_secret: str | None = Header(default=None),
|
|
||||||
):
|
|
||||||
"""헤더를 못 넣는 경우의 대안.
|
|
||||||
|
|
||||||
★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다."""
|
|
||||||
body = await request.json()
|
|
||||||
_authorize(secret, x_agent_secret, body)
|
|
||||||
return await _handle(body, tasks)
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireDeveloper
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireDeveloper
|
||||||
from services.job_service import JobService
|
from services.job_service import JobService
|
||||||
@ -28,8 +27,8 @@ async def job_ops(service: JobService = Depends()):
|
|||||||
dependencies=[Depends(IsValidAccessToken)],
|
dependencies=[Depends(IsValidAccessToken)],
|
||||||
summary="잡 상태 조회(폴링)",
|
summary="잡 상태 조회(폴링)",
|
||||||
)
|
)
|
||||||
async def get_job(job_id: UUID, service: JobService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
async def get_job(job_id: UUID, service: JobService = Depends()):
|
||||||
return RemoveNoneResponse(await service.get_job(str(job_id), user_info))
|
return RemoveNoneResponse(await service.get_job(str(job_id)))
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from common.enums import JobStatus, JobType
|
from common.enums import JobStatus, JobType
|
||||||
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
|
||||||
@ -11,26 +10,13 @@ class JobProtocol(WebPacketProtocol):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class JobStep(WebPacketProtocol):
|
|
||||||
id: str
|
|
||||||
status: Literal["pending", "running", "done", "skipped", "failed"]
|
|
||||||
reason: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class JobProgress(WebPacketProtocol):
|
|
||||||
steps: list[JobStep]
|
|
||||||
attempt: int
|
|
||||||
|
|
||||||
|
|
||||||
class JobData(WebPacketProtocol):
|
class JobData(WebPacketProtocol):
|
||||||
job_id: uuid.UUID
|
job_id: uuid.UUID
|
||||||
place_id: Optional[uuid.UUID] = None
|
|
||||||
job_type: JobType
|
job_type: JobType
|
||||||
status: JobStatus
|
status: JobStatus
|
||||||
attempts: int = 0
|
attempts: int = 0
|
||||||
max_attempts: int = 3
|
max_attempts: int = 3
|
||||||
result: Optional[Any] = None
|
result: Optional[Any] = None
|
||||||
progress: Optional[JobProgress] = None
|
|
||||||
last_error: Optional[str] = None
|
last_error: Optional[str] = None
|
||||||
run_after: Optional[datetime] = None
|
run_after: Optional[datetime] = None
|
||||||
run_started_at: Optional[datetime] = None
|
run_started_at: Optional[datetime] = None
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
"""사진 중계 — 남의 도메인 사진을 **우리 오리진으로** 흘려보낸다.
|
|
||||||
|
|
||||||
★ 왜 필요한가 (2026-09-15, 실측)
|
|
||||||
수집한 사진은 `*.pstatic.net` · `tong.visitkorea.or.kr` 에 있고 그쪽은
|
|
||||||
`Access-Control-Allow-Origin` 을 주지 않는다. 그 사진을 캔버스에 그리면 캔버스가 **오염돼**
|
|
||||||
`toBlob` 이 막힌다 — 엽서 쓰기의 저장·공유가 죽는다. 브라우저 정책이라 클라이언트에서는
|
|
||||||
못 넘는다. CORS 없는 `fetch` 도 같은 벽이다. **같은 오리진에서 바이트가 와야** 풀린다.
|
|
||||||
|
|
||||||
★ 굽는 쪽(`site/scripts/prerender.ts` mirrorMedia)이 이미 사진을 내려받아 사이트 폴더에
|
|
||||||
놓는다. 그게 근본이다. 다만 그건 **다시 굽는 사이트에만** 적용된다 — 이미 나가 있는
|
|
||||||
사이트는 사장님이 재발행할 때까지 옛 주소를 문다. 이 중계가 그 사이를 메운다.
|
|
||||||
|
|
||||||
★ 열린 프록시가 되지 않게 좁혀 둔다. 이건 "아무 주소나 가져다주는 통로" 가 아니다:
|
|
||||||
· https 만
|
|
||||||
· 호스트가 `_ALLOWED_SUFFIXES` 에 있는 것만 (우리 수집기가 쓰는 사진 CDN)
|
|
||||||
· 응답이 이미지가 아니면 거절, 크기 상한
|
|
||||||
· 리다이렉트를 따라가되 최종 호스트도 다시 검사한다 — 안 그러면 allowlist 를 우회한다
|
|
||||||
"""
|
|
||||||
import ipaddress
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, Query, Response
|
|
||||||
|
|
||||||
from common.logger import LOG
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/image", tags=["Image"])
|
|
||||||
|
|
||||||
# 우리 수집기가 사진을 가져오는 곳. 여기 없는 호스트는 중계하지 않는다.
|
|
||||||
# ★ 늘릴 때는 "우리가 이미 그 사진을 화면에 싣고 있는 곳인가" 를 먼저 본다.
|
|
||||||
_ALLOWED_SUFFIXES = (
|
|
||||||
".pstatic.net",
|
|
||||||
"tong.visitkorea.or.kr",
|
|
||||||
".visitkorea.or.kr",
|
|
||||||
"upload.wikimedia.org",
|
|
||||||
)
|
|
||||||
|
|
||||||
_MAX_BYTES = 8 * 1024 * 1024
|
|
||||||
_TIMEOUT = httpx.Timeout(10.0, connect=5.0)
|
|
||||||
# 기본 UA 를 거절하는 CDN 이 있다. 탐지 우회가 아니라 평범한 브라우저로 보이게 하는 것뿐이다.
|
|
||||||
_HEADERS = {"user-agent": "Mozilla/5.0 (compatible; o2o-web4ai/1.0)"}
|
|
||||||
|
|
||||||
|
|
||||||
def _allowed(url: str) -> bool:
|
|
||||||
parsed = urlparse(url)
|
|
||||||
if parsed.scheme != "https" or not parsed.hostname:
|
|
||||||
return False
|
|
||||||
host = parsed.hostname.lower()
|
|
||||||
# 숫자 주소는 받지 않는다 — 사내망으로 향하는 통로를 만들지 않는다.
|
|
||||||
try:
|
|
||||||
ipaddress.ip_address(host)
|
|
||||||
return False
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return any(host == suffix.lstrip(".") or host.endswith(suffix) for suffix in _ALLOWED_SUFFIXES)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/relay", summary="사진 중계 — 캔버스 오염을 피하려고 같은 오리진으로 흘려보낸다")
|
|
||||||
async def relay(url: str = Query(min_length=8, max_length=2000)):
|
|
||||||
"""인증을 요구하지 않는다. 발행본은 로그인 없이 열리는 정적 페이지이고, 여기서 나가는
|
|
||||||
것은 **그 페이지가 이미 화면에 싣고 있는 사진**뿐이다(allowlist 가 그걸 보장한다)."""
|
|
||||||
if not _allowed(url):
|
|
||||||
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=_TIMEOUT, headers=_HEADERS, follow_redirects=True) as client:
|
|
||||||
res = await client.get(url)
|
|
||||||
except httpx.HTTPError as ex:
|
|
||||||
LOG.w(f"[image] 중계 실패 {url}: {ex}")
|
|
||||||
raise HTTPException(status_code=502, detail="사진을 가져오지 못했습니다")
|
|
||||||
|
|
||||||
# 리다이렉트로 allowlist 밖으로 나갔으면 거기서 끊는다.
|
|
||||||
if not _allowed(str(res.url)):
|
|
||||||
raise HTTPException(status_code=400, detail="중계할 수 없는 주소입니다")
|
|
||||||
|
|
||||||
kind = (res.headers.get("content-type") or "").split(";")[0].strip().lower()
|
|
||||||
if res.status_code != 200 or not kind.startswith("image/"):
|
|
||||||
raise HTTPException(status_code=502, detail="사진이 아닙니다")
|
|
||||||
if len(res.content) == 0 or len(res.content) > _MAX_BYTES:
|
|
||||||
raise HTTPException(status_code=502, detail="사진 크기가 범위를 벗어났습니다")
|
|
||||||
|
|
||||||
# 주소가 곧 내용이다(원본이 바뀌면 주소도 바뀐다) — 길게 캐시해서 중계 횟수를 줄인다.
|
|
||||||
return Response(
|
|
||||||
content=res.content,
|
|
||||||
media_type=kind,
|
|
||||||
headers={"cache-control": "public, max-age=604800, immutable"},
|
|
||||||
)
|
|
||||||
@ -59,24 +59,11 @@ class Req_VerifyPlaceByUrl(PlaceProtocol):
|
|||||||
|
|
||||||
url: str = ""
|
url: str = ""
|
||||||
|
|
||||||
# ★ 같은 가게를 이미 갖고 있을 때 그 사업장으로 이어붙일지.
|
|
||||||
#
|
|
||||||
# 기본값이 True 인 것은 이 API 를 부르는 다른 자리(주소 재확인 등)의 동작을 바꾸지
|
|
||||||
# 않기 위해서다. 위저드의 **[새로 크롤링하고 사이트 생성하기]** 는 False 로 보낸다 —
|
|
||||||
# 사장님이 새로 만들겠다고 누른 것을 서버가 "이미 있으니 그걸 쓰세요" 로 바꿔 버리면
|
|
||||||
# 같은 화면을 눌러도 기존 에디터가 열린다(실측 2026-09-15: 그게 지금 증상이다).
|
|
||||||
#
|
|
||||||
# False 라도 **비어 있는 중복 행은 치운다.** 그건 위저드를 중간에 나갔을 때 남는
|
|
||||||
# 찌꺼기라 잃을 것이 없다 — 원래 막으려던 것도 그 누적이었다(ba90a19).
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class Req_CreateUnit(PlaceProtocol):
|
class Req_CreateUnit(PlaceProtocol):
|
||||||
@ -109,7 +96,6 @@ class PlaceData(WebPacketProtocol):
|
|||||||
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 # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
|
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
@ -260,7 +246,7 @@ class Req_StartCopy(PlaceProtocol):
|
|||||||
|
|
||||||
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
|
★ 확인된 fact 만 근거로 쓴다. 근거가 없으면 생성하지 않는다(유료 호출조차 안 한다)."""
|
||||||
|
|
||||||
resume: bool = False
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Res_StartCopy(Res_WebPacketProtocol):
|
class Res_StartCopy(Res_WebPacketProtocol):
|
||||||
|
|||||||
@ -1,88 +0,0 @@
|
|||||||
"""발행본의 예약 요청 폼 → 사장님 메일.
|
|
||||||
|
|
||||||
★ 로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
|
|
||||||
★ DB 에 남기지 않는다(2026-09-16 대표 지시). 예약자 연락처는 메일 본문에만 실리고,
|
|
||||||
보내고 나면 우리 쪽에 남는 것은 로그 한 줄뿐이다 — 보관하지 않으니 파기 절차도 없다.
|
|
||||||
★ 예약을 처리하지 않는다. 빈 방도 결제도 우리 것이 아니다(PRODUCT.md 6절). 받는 것은
|
|
||||||
**연락 요청**이고, 화면도 그렇게 말한다.
|
|
||||||
"""
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from collections import defaultdict, deque
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from common.logger import LOG
|
|
||||||
from router.v1.validator.dependencies import RemoveNoneResponse
|
|
||||||
from services.booking_request_service import BookingRequestService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/site", tags=["Site"])
|
|
||||||
|
|
||||||
# 한 아이피가 한 시간에 보낼 수 있는 통수. 같은 업장으로 몰리는 것도 따로 센다.
|
|
||||||
IP_LIMIT_PER_HOUR = 5
|
|
||||||
PLACE_LIMIT_PER_HOUR = 30
|
|
||||||
WINDOW_SEC = 3600
|
|
||||||
# 폼을 연 뒤 이만큼은 지나야 사람으로 친다. 봇은 즉시 제출한다.
|
|
||||||
MIN_ELAPSED_MS = 1500
|
|
||||||
|
|
||||||
_hits: dict[str, deque] = defaultdict(deque)
|
|
||||||
|
|
||||||
|
|
||||||
def _allow(key: str, limit: int) -> bool:
|
|
||||||
now = time.monotonic()
|
|
||||||
hits = _hits[key]
|
|
||||||
while hits and now - hits[0] > WINDOW_SEC:
|
|
||||||
hits.popleft()
|
|
||||||
if len(hits) >= limit:
|
|
||||||
return False
|
|
||||||
hits.append(now)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
class ReqBookingRequest(BaseModel):
|
|
||||||
place_id: uuid.UUID
|
|
||||||
name: str = Field(min_length=1, max_length=40)
|
|
||||||
phone: str = Field(min_length=6, max_length=30)
|
|
||||||
email: str | None = Field(default=None, max_length=255)
|
|
||||||
stay: str | None = Field(default=None, max_length=60)
|
|
||||||
guests: str | None = Field(default=None, max_length=30)
|
|
||||||
message: str | None = Field(default=None, max_length=1000)
|
|
||||||
consent: bool
|
|
||||||
# 봇 잡이. 사람에게는 안 보이는 칸이라 값이 있으면 사람이 아니다.
|
|
||||||
company: str | None = Field(default=None, max_length=100)
|
|
||||||
elapsed_ms: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ResBookingRequest(BaseModel):
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
path="/booking-request",
|
|
||||||
response_model=ResBookingRequest,
|
|
||||||
summary="예약 요청 — 발행본 폼에서 사장님 메일로 전달",
|
|
||||||
)
|
|
||||||
async def send_booking_request(
|
|
||||||
body: ReqBookingRequest,
|
|
||||||
request: Request,
|
|
||||||
service: BookingRequestService = Depends(),
|
|
||||||
):
|
|
||||||
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
||||||
or (request.client.host if request.client else "unknown"))
|
|
||||||
|
|
||||||
# 봇 두 겹. 걸려도 실패로 알리지 않는다 — 무엇에 걸렸는지 알려 주면 다음 시도가 그걸 피한다.
|
|
||||||
if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
|
|
||||||
LOG.w("[booking-request] 봇 의심 요청을 버렸다")
|
|
||||||
return RemoveNoneResponse(ResBookingRequest(success=True, message="요청을 보냈습니다."))
|
|
||||||
|
|
||||||
if not body.consent:
|
|
||||||
return RemoveNoneResponse(ResBookingRequest(success=False, message="연락처 수집에 동의해 주세요."))
|
|
||||||
|
|
||||||
if not _allow(f"ip:{client_ip}", IP_LIMIT_PER_HOUR) or not _allow(f"place:{body.place_id}", PLACE_LIMIT_PER_HOUR):
|
|
||||||
return RemoveNoneResponse(ResBookingRequest(
|
|
||||||
success=False, message="요청이 많습니다. 잠시 뒤 다시 시도하거나 전화로 문의해 주세요.",
|
|
||||||
))
|
|
||||||
|
|
||||||
return RemoveNoneResponse(await service.send(body))
|
|
||||||
@ -1,194 +0,0 @@
|
|||||||
"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: docs/MINI_BLOG.md
|
|
||||||
|
|
||||||
★ /approve 는 로그인이 없다. 링크에 실린 토큰 하나가 신원이고, 누르는(GET) 순간 바로
|
|
||||||
승인된다(2026-09-17, 사장님 지시: "승인은 바로 승인 되게 그 링크만 클릭하면"). ★★ 이건
|
|
||||||
메일 클라이언트의 링크 미리 열기(아웃룩 안전 링크 스캔 등)에 그대로 노출된다는 뜻이다 —
|
|
||||||
예전에는 이걸 막으려고 GET=확인 화면 / POST=승인 확정으로 나눴었다. 사장님이 그 위험을
|
|
||||||
알고도 즉시 승인을 택했다.
|
|
||||||
★ "수정하기" 는 반대로 로그인 흐름을 탄다 — 메일에 그날 자정(KST)까지만 사는 접근 토큰을
|
|
||||||
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
|
|
||||||
글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
|
|
||||||
★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
|
|
||||||
★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve
|
|
||||||
(이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션,
|
|
||||||
"바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 —
|
|
||||||
승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다.
|
|
||||||
"""
|
|
||||||
import html
|
|
||||||
from datetime import date
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from common.models.gmodel import Res_WebPacketProtocol, UserInfo
|
|
||||||
from router.v1.site.protocol import (
|
|
||||||
Req_EditPost, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
|
|
||||||
)
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
|
||||||
from services.post_service import PostService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/site/post", tags=["Site"])
|
|
||||||
owner_router = APIRouter(prefix="/v1/place/{place_id}/post", tags=["Site"])
|
|
||||||
|
|
||||||
_REDIRECT_DELAY_SEC = 5
|
|
||||||
|
|
||||||
_PAGE = """<!doctype html><html lang="ko"><head><meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex">
|
|
||||||
{redirect}<title>{title}</title><style>
|
|
||||||
body{{margin:0;background:#f6f5f1;color:#1b1a15;font:17px/1.7 -apple-system,'Apple SD Gothic Neo','Noto Sans KR',sans-serif}}
|
|
||||||
.wrap{{max-width:34rem;margin:0 auto;padding:40px 20px}}
|
|
||||||
h1{{font-size:21px;margin:0 0 6px}} p{{margin:0 0 14px}}
|
|
||||||
.meta{{color:#6b7269;font-size:14px}} a{{color:#1b1a15}}
|
|
||||||
</style></head><body><div class="wrap">{content}</div></body></html>"""
|
|
||||||
|
|
||||||
|
|
||||||
def _page(title: str, content: str, *, redirect_url: str | None = None) -> HTMLResponse:
|
|
||||||
# ★ redirect_url 은 항상 서버가 site_payload.publish_url() 로 만든 값(고정 오리진 +
|
|
||||||
# slugify 통과 슬러그)이라 사용자 입력이 아니지만, HTML 속성에 그대로 꽂는 자리라
|
|
||||||
# escape 를 걸어 둔다 — 이 함수가 나중에 다른 값을 받게 되더라도 안전하게.
|
|
||||||
redirect = (
|
|
||||||
f'<meta http-equiv="refresh" content="{_REDIRECT_DELAY_SEC};url={html.escape(redirect_url, quote=True)}">'
|
|
||||||
if redirect_url else ""
|
|
||||||
)
|
|
||||||
return HTMLResponse(_PAGE.format(title=title, content=content, redirect=redirect))
|
|
||||||
|
|
||||||
|
|
||||||
def _expired_page() -> HTMLResponse:
|
|
||||||
return _page(
|
|
||||||
"처리할 수 없는 링크입니다",
|
|
||||||
"<h1>처리할 수 없는 링크입니다</h1><p class='meta'>이미 처리했거나 기한이 지난 링크입니다.</p>",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="/approve", response_class=HTMLResponse, summary="승인 확정 — 누르는 즉시 게재 큐에 넣는다")
|
|
||||||
async def approve_page(t: str = Query(min_length=8, max_length=200), service: PostService = Depends()):
|
|
||||||
result = await service.decide(t, skip=False)
|
|
||||||
if not result["success"]:
|
|
||||||
return _expired_page()
|
|
||||||
redirect_url = result.get("redirect_url")
|
|
||||||
# ★ 재발행은 몇 분 걸린다(BUILD 잡) — 5초 뒤에 이 글이 이미 반영돼 있다는 보장은 없다.
|
|
||||||
# 그래도 "어디로 가면 보이는지" 를 알려주는 게 사장님 입장에서 "눌렀는데 어디 갔지" 보다
|
|
||||||
# 낫다(2026-09-22, 사장님 지시). 링크 자체는 안내 문구에도 남겨 자동 이동을 못 믿어도 되게 한다.
|
|
||||||
extra = (
|
|
||||||
f"<p class='meta'>{_REDIRECT_DELAY_SEC}초 뒤 자동으로 이동합니다. "
|
|
||||||
f"바로 가려면 <a href='{html.escape(redirect_url, quote=True)}'>여기</a>를 눌러주세요.</p>"
|
|
||||||
if redirect_url else ""
|
|
||||||
)
|
|
||||||
return _page(
|
|
||||||
"올렸습니다",
|
|
||||||
f"<h1>{result['message']}</h1><p class='meta'>사이트에 반영되기까지 몇 분 걸립니다.</p>{extra}",
|
|
||||||
redirect_url=redirect_url,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.get(path="", response_model=Res_MyPosts, summary="이번 달(또는 고른 달) 생성된 글 목록")
|
|
||||||
async def list_my_posts(
|
|
||||||
place_id: UUID,
|
|
||||||
month: str | None = Query(default=None, pattern=r"^\d{4}-\d{2}$", description="YYYY-MM, 기본값 이번 달"),
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.list_for_owner(user_info, str(place_id), month))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.get(path="/upcoming", response_model=Res_MyPosts, summary="상단 카로셀 — 오늘부터 N일치, 날짜순")
|
|
||||||
async def list_upcoming_posts(
|
|
||||||
place_id: UUID,
|
|
||||||
days: int = Query(default=7, ge=1, le=30),
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.list_upcoming(user_info, str(place_id), days))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.get(path="/history", response_model=Res_GenerationHistory, summary="생성 이력 — 언제 몇 건 만들었는지")
|
|
||||||
async def get_generation_history(
|
|
||||||
place_id: UUID,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.generation_history(user_info, str(place_id)))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.get(path="/{post_id}", response_model=Res_MyPosts, summary="글 하나 — 메일 수정 링크(자동 로그인)가 쓴다")
|
|
||||||
async def get_my_post(
|
|
||||||
place_id: UUID,
|
|
||||||
post_id: UUID,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id)))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로")
|
|
||||||
async def edit_my_post(
|
|
||||||
place_id: UUID,
|
|
||||||
post_id: UUID,
|
|
||||||
req: Req_EditPost,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(
|
|
||||||
path="/{post_id}/approve", response_model=Res_WebPacketProtocol,
|
|
||||||
summary="바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인",
|
|
||||||
)
|
|
||||||
async def approve_my_post(
|
|
||||||
place_id: UUID,
|
|
||||||
post_id: UUID,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id)))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.delete(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다")
|
|
||||||
async def delete_my_post(
|
|
||||||
place_id: UUID,
|
|
||||||
post_id: UUID,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.delete_by_owner(user_info, str(place_id), str(post_id)))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(
|
|
||||||
path="/send-now", response_model=Res_WebPacketProtocol,
|
|
||||||
summary="승인 알림보내기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다",
|
|
||||||
)
|
|
||||||
async def send_my_posts_now(
|
|
||||||
place_id: UUID,
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.send_now(user_info, str(place_id)))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(
|
|
||||||
path="/generate", response_model=Res_GenerateNow,
|
|
||||||
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",
|
|
||||||
)
|
|
||||||
async def generate_my_posts(
|
|
||||||
place_id: UUID,
|
|
||||||
start: date = Query(description="구간 시작일"),
|
|
||||||
end: date = Query(description="구간 끝일"),
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.generate_range(user_info, str(place_id), start, end))
|
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(
|
|
||||||
path="/generate-one", response_model=Res_GenerateOne,
|
|
||||||
summary="개별 생성 — 달력에서 빈 날짜 하나만 콕 집어 채운다",
|
|
||||||
)
|
|
||||||
async def generate_my_post_for_date(
|
|
||||||
place_id: UUID,
|
|
||||||
target_date: date = Query(alias="date"),
|
|
||||||
service: PostService = Depends(),
|
|
||||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.generate_for_date(user_info, str(place_id), target_date))
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
@ -36,17 +36,6 @@ class Res_StartBuild(Res_WebPacketProtocol):
|
|||||||
created: bool = True
|
created: bool = True
|
||||||
|
|
||||||
|
|
||||||
class Req_Rollback(SiteProtocol):
|
|
||||||
"""예전 버전으로 공개 주소를 되돌린다.
|
|
||||||
|
|
||||||
★ 재굽기가 아니다 — 대상 버전이 디스크에 아직 있으면 심볼릭 링크만 돌린다. 지워졌으면
|
|
||||||
(보관 정책, prerender.ts pruneOldVersions) site_versions.snapshot 으로 다시 굽고 나서
|
|
||||||
돌린다. 어느 경우든 게이트를 다시 통과해야 한다(사장님이 이미 확인한 값이라 대개는
|
|
||||||
그대로 통과한다)."""
|
|
||||||
|
|
||||||
target_version: int
|
|
||||||
|
|
||||||
|
|
||||||
class SiteVersionData(WebPacketProtocol):
|
class SiteVersionData(WebPacketProtocol):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
@ -97,8 +86,6 @@ class MySiteData(WebPacketProtocol):
|
|||||||
template_id: Optional[str] = None
|
template_id: Optional[str] = None
|
||||||
published_at: Optional[datetime] = None
|
published_at: Optional[datetime] = None
|
||||||
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
# 목록 카드의 그림. 발행에 성공해야 채워지고, 발행마다 `?v=` 가 바뀐다(site_thumbnail.public_url).
|
||||||
# Azure 썸네일 저장소가 안 꺼져 있으면(로컬 개발) 빌더가 쓰는 대표 사진으로 대신 채운다
|
|
||||||
# (site_service._my_site_row) — 이때는 `?v=` 가 없다.
|
|
||||||
thumbnail_url: Optional[str] = None
|
thumbnail_url: Optional[str] = None
|
||||||
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
# 단건과 같은 규칙 — 노출값이 마지막 빌드보다 나중에 바뀌었으면 재발행 대상이다.
|
||||||
needs_rebuild: bool = False
|
needs_rebuild: bool = False
|
||||||
@ -241,14 +228,9 @@ class Res_SiteSlug(Res_WebPacketProtocol):
|
|||||||
|
|
||||||
class Req_SiteStatus(SiteProtocol):
|
class Req_SiteStatus(SiteProtocol):
|
||||||
"""발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
|
"""발행 상태 전이. ★ 해지는 삭제가 아니라 상태 전이다 —
|
||||||
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다.
|
색인된 페이지를 갑자기 404 로 만들면 그 자리를 다시 OTA 가 가져간다."""
|
||||||
|
|
||||||
★ 기본값을 두지 않는다. SUSPEND 가 기본이던 동안에는 필드 이름을 틀리게 보내도
|
action: PublishAction = PublishAction.SUSPEND
|
||||||
(`{"status": 5}` 처럼) 422 가 아니라 **발행 중지가 실행됐다** — 파괴적인 전이가
|
|
||||||
'아무것도 안 적었을 때' 의 자리에 있었다(실측 2026-09-15).
|
|
||||||
무엇을 할지는 부르는 쪽이 적는다."""
|
|
||||||
|
|
||||||
action: PublishAction
|
|
||||||
|
|
||||||
|
|
||||||
class ShowcaseItem(WebPacketProtocol):
|
class ShowcaseItem(WebPacketProtocol):
|
||||||
@ -270,59 +252,3 @@ class ShowcaseItem(WebPacketProtocol):
|
|||||||
|
|
||||||
class Res_Showcase(Res_WebPacketProtocol):
|
class Res_Showcase(Res_WebPacketProtocol):
|
||||||
items: list[ShowcaseItem] = []
|
items: list[ShowcaseItem] = []
|
||||||
|
|
||||||
|
|
||||||
class PostData(WebPacketProtocol):
|
|
||||||
"""미니 블로그 글 하나 — 빌더 앱 '이번 달 생성된 글' 목록 카드."""
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
post_id: uuid.UUID
|
|
||||||
body: str
|
|
||||||
topic_kind: int
|
|
||||||
status: int
|
|
||||||
scheduled_date: Optional[date] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
sent_at: Optional[datetime] = None
|
|
||||||
approved_at: Optional[datetime] = None
|
|
||||||
published_at: Optional[datetime] = None
|
|
||||||
# 화면은 발행완료/발행실패만 보여준다(발행 전 상태는 안 보여준다) — 승인됐는데
|
|
||||||
# BUILD 잡이 dead-letter 로 끝났을 때만 true(PostService._latest_build_failed).
|
|
||||||
build_failed: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class Res_MyPosts(Res_WebPacketProtocol):
|
|
||||||
posts: list[PostData] = []
|
|
||||||
|
|
||||||
|
|
||||||
class Req_EditPost(SiteProtocol):
|
|
||||||
"""수정하고 그대로 승인 — 로그인 세션 버전(메일 없이 목록에서 바로 고칠 때)."""
|
|
||||||
|
|
||||||
body: str
|
|
||||||
|
|
||||||
|
|
||||||
class Res_GenerateNow(Res_WebPacketProtocol):
|
|
||||||
"""즉시 생성 결과 — 사장님이 고른 구간(시작~끝) 중 몇 일을 채웠는지."""
|
|
||||||
|
|
||||||
requested: int = 0
|
|
||||||
created: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class GenerationBatch(WebPacketProtocol):
|
|
||||||
"""생성 회차 하나 — 같은 스윕에서 한 번에 만들어진 글 묶음(post_crud.generation_batches)."""
|
|
||||||
|
|
||||||
created_at: datetime
|
|
||||||
count: int
|
|
||||||
model: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Res_GenerationHistory(Res_WebPacketProtocol):
|
|
||||||
batches: list[GenerationBatch] = []
|
|
||||||
|
|
||||||
|
|
||||||
class Res_GenerateOne(Res_WebPacketProtocol):
|
|
||||||
"""개별 생성 결과 — 달력에서 빈 날짜 하나를 콕 집어 만들었을 때(2026-09-17, 사장님
|
|
||||||
지시: "개별적으로 새로 만들수있게 해줘"). 실패하면 post 가 없다(그 날짜가 이미 찼거나
|
|
||||||
소재가 바닥났다)."""
|
|
||||||
|
|
||||||
post: Optional[PostData] = None
|
|
||||||
|
|||||||
@ -1,75 +0,0 @@
|
|||||||
"""이용 후기 접수 — 발행본에서 손님이 남긴다.
|
|
||||||
|
|
||||||
★ 로그인 없는 공개 엔드포인트다. 예약 요청(booking_request.py)과 같은 방어를 쓴다 —
|
|
||||||
허니팟 · 최소 체류시간 · 레이트리밋.
|
|
||||||
★ 검수를 통과해야 화면에 나간다. 그래서 접수 응답이 "게시됐다"고 말하지 않는다.
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, Request
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from common.logger import LOG
|
|
||||||
from router.v1.site.booking_request import MIN_ELAPSED_MS, _allow
|
|
||||||
from router.v1.validator.dependencies import RemoveNoneResponse
|
|
||||||
from services.review_service import ReviewService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/site", tags=["Site"])
|
|
||||||
|
|
||||||
IP_LIMIT_PER_HOUR = 3
|
|
||||||
PLACE_LIMIT_PER_HOUR = 30
|
|
||||||
|
|
||||||
|
|
||||||
class ReqReview(BaseModel):
|
|
||||||
place_id: uuid.UUID
|
|
||||||
body: str = Field(min_length=1, max_length=1000)
|
|
||||||
nickname: str | None = Field(default=None, max_length=40)
|
|
||||||
consent: bool
|
|
||||||
company: str | None = Field(default=None, max_length=100)
|
|
||||||
elapsed_ms: int = 0
|
|
||||||
client_ip: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ResReview(BaseModel):
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
class PublicReview(BaseModel):
|
|
||||||
reviewId: str
|
|
||||||
body: str
|
|
||||||
nickname: str
|
|
||||||
publishedAt: str
|
|
||||||
|
|
||||||
|
|
||||||
class ResPublicReviews(BaseModel):
|
|
||||||
items: list[PublicReview] = []
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/review", response_model=ResReview, summary="이용 후기 남기기")
|
|
||||||
async def submit_review(body: ReqReview, request: Request, service: ReviewService = Depends()):
|
|
||||||
client_ip = (request.headers.get("x-forwarded-for", "").split(",")[0].strip()
|
|
||||||
or (request.client.host if request.client else "unknown"))
|
|
||||||
|
|
||||||
if body.company or body.elapsed_ms < MIN_ELAPSED_MS:
|
|
||||||
LOG.w("[review] 봇 의심 요청을 버렸다")
|
|
||||||
return RemoveNoneResponse(ResReview(success=True, message="후기를 남겨 주셔서 고맙습니다."))
|
|
||||||
|
|
||||||
if not body.consent:
|
|
||||||
return RemoveNoneResponse(ResReview(success=False, message="공개에 동의해 주세요."))
|
|
||||||
|
|
||||||
if not _allow(f"review-ip:{client_ip}", IP_LIMIT_PER_HOUR) or \
|
|
||||||
not _allow(f"review-place:{body.place_id}", PLACE_LIMIT_PER_HOUR):
|
|
||||||
return RemoveNoneResponse(ResReview(
|
|
||||||
success=False, message="요청이 많습니다. 잠시 뒤 다시 남겨 주세요.",
|
|
||||||
))
|
|
||||||
|
|
||||||
body.client_ip = client_ip
|
|
||||||
return RemoveNoneResponse(await service.submit(body))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="/reviews", response_model=ResPublicReviews, summary="게재된 후기 — 발행본이 붙은 뒤 받아 간다")
|
|
||||||
async def list_reviews(place_id: uuid.UUID = Query(), service: ReviewService = Depends()):
|
|
||||||
"""날씨(/v1/local/weather)와 같은 공개 조회다. 구운 HTML 에는 굽는 시점의 후기가 들어 있고,
|
|
||||||
화면은 붙은 뒤 이 주소로 최신을 받아 덮는다."""
|
|
||||||
return RemoveNoneResponse(await service.list_public(place_id))
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
"""이용 후기 검수 — 어드민 진입점(:9801)에만 붙인다."""
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
from router.v1.validator.dependencies import RemoveNoneResponse
|
|
||||||
from services.review_service import ReviewService
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/admin/review", tags=["Review"])
|
|
||||||
|
|
||||||
|
|
||||||
class ReviewItem(BaseModel):
|
|
||||||
review_id: str
|
|
||||||
place_id: str
|
|
||||||
place_name: str
|
|
||||||
body: str
|
|
||||||
nickname: str
|
|
||||||
status: int
|
|
||||||
created_at: str
|
|
||||||
|
|
||||||
|
|
||||||
class ResReviews(BaseModel):
|
|
||||||
items: list[ReviewItem] = []
|
|
||||||
total: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
class ReqDecide(BaseModel):
|
|
||||||
review_ids: list[uuid.UUID]
|
|
||||||
publish: bool
|
|
||||||
|
|
||||||
|
|
||||||
class ResDecide(BaseModel):
|
|
||||||
changed: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="/list", response_model=ResReviews, summary="후기 검수 목록")
|
|
||||||
async def list_reviews(
|
|
||||||
status: int = Query(default=1, ge=1, le=3),
|
|
||||||
limit: int = Query(default=100, ge=1, le=300),
|
|
||||||
service: ReviewService = Depends(),
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.list_for_review(status, limit))
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(path="/decide", response_model=ResDecide, summary="후기 게재 · 반려")
|
|
||||||
async def decide(body: ReqDecide, service: ReviewService = Depends()):
|
|
||||||
return RemoveNoneResponse(await service.decide(body.review_ids, publish=body.publish))
|
|
||||||
@ -7,7 +7,6 @@ from common.models.gmodel import PageParams, UserInfo
|
|||||||
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
|
||||||
from services.site_service import SiteService
|
from services.site_service import SiteService
|
||||||
from .protocol import (
|
from .protocol import (
|
||||||
Req_Rollback,
|
|
||||||
Req_SiteSlug,
|
Req_SiteSlug,
|
||||||
Req_SiteStatus,
|
Req_SiteStatus,
|
||||||
Req_SiteTemplate,
|
Req_SiteTemplate,
|
||||||
@ -179,20 +178,6 @@ async def site_preview(
|
|||||||
return JSONResponse(content=payload)
|
return JSONResponse(content=payload)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
path="/version/rollback",
|
|
||||||
response_model=Res_StartBuild,
|
|
||||||
summary="예전 버전으로 되돌리기(비동기)",
|
|
||||||
description="target_version 을 다시 공개 주소로 내보낸다. 재수집·재생성을 하지 않는다 — "
|
|
||||||
"그 버전이 발행될 때 박제된 snapshot 을 그대로 다시 굽는다. 렌더러가 그 사이 "
|
|
||||||
"바뀌었을 수 있어 게이트는 다시 통과해야 한다. 실패하면 지금 공개된 버전은 그대로다.",
|
|
||||||
)
|
|
||||||
async def rollback_version(
|
|
||||||
place_id: UUID, req: Req_Rollback, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
|
|
||||||
):
|
|
||||||
return RemoveNoneResponse(await service.start_rollback(user_info, str(place_id), req))
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
|
@router.get(path="/version/list", response_model=Res_SiteVersions, summary="빌드 버전 목록")
|
||||||
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
async def list_versions(place_id: UUID, service: SiteService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
|
||||||
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))
|
return RemoveNoneResponse(await service.list_versions(user_info, str(place_id)))
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
from uuid import UUID
|
|
||||||
from fastapi import APIRouter, Depends, Request, Response, HTTPException, Query
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
from common.logger import LOG
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken
|
|
||||||
from services import social_account_service as service
|
|
||||||
from services.external.social import SocialError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/social/oauth", tags=["Social"])
|
|
||||||
COOKIE = "social_oauth_browser"
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/connect")
|
|
||||||
async def connect(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
try:
|
|
||||||
url, browser = service.begin(UUID(user.user_id), 2)
|
|
||||||
except SocialError as ex:
|
|
||||||
raise HTTPException(409, str(ex)) from ex
|
|
||||||
response.set_cookie(
|
|
||||||
COOKIE,
|
|
||||||
browser,
|
|
||||||
httponly=True,
|
|
||||||
secure=True,
|
|
||||||
samesite="lax",
|
|
||||||
max_age=600,
|
|
||||||
path="/v1/social/oauth",
|
|
||||||
)
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
return {"url": url}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/callback")
|
|
||||||
async def oauth_callback(
|
|
||||||
request: Request,
|
|
||||||
state: str = Query("", max_length=2048),
|
|
||||||
code: str = Query("", max_length=4096),
|
|
||||||
error: str = Query("", max_length=200),
|
|
||||||
):
|
|
||||||
ok = False
|
|
||||||
if not error and code and state:
|
|
||||||
try:
|
|
||||||
await service.finish(state, request.cookies.get(COOKIE), code)
|
|
||||||
ok = True
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
# ★ 화면에는 원문을 내보내지 않는다 — OAuth 응답·state 에는 자격증명이 들어 있다.
|
|
||||||
# 대신 **서버 로그에는 반드시 남긴다.** 예전에는 통째로 삼켜서, 연결이 안 될 때
|
|
||||||
# 화면에 `?social=failed` 만 뜨고 우리도 이유를 알 방법이 없었다
|
|
||||||
# (키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다).
|
|
||||||
# ★ 남기는 것은 **예외 종류와 우리가 만든 사유 문자열**뿐이다. 토큰·code·state 는 찍지 않는다.
|
|
||||||
LOG.w(f"[social] 계정 연결 실패: {type(ex).__name__}: {ex}")
|
|
||||||
elif error:
|
|
||||||
# 사장님이 Meta 화면에서 취소한 경우도 여기로 온다 — 고장과 구별되게 남긴다.
|
|
||||||
LOG.i(f"[social] 계정 연결 중단(제공자 응답): {error[:80]}")
|
|
||||||
response = RedirectResponse(
|
|
||||||
"/sites?social=" + ("connected" if ok else "failed"), status_code=303
|
|
||||||
)
|
|
||||||
response.delete_cookie(
|
|
||||||
COOKIE, path="/v1/social/oauth", secure=True, httponly=True, samesite="lax"
|
|
||||||
)
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/disconnect")
|
|
||||||
async def disconnect(user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
await service.disconnect(UUID(user.user_id), 2)
|
|
||||||
return {"disconnected": True}
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
from uuid import UUID
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from router.v1.validator.dependencies import IsValidAccessToken
|
|
||||||
from services import social_service as service
|
|
||||||
from services.external.social import SocialError
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/v1/social", tags=["Social"])
|
|
||||||
|
|
||||||
|
|
||||||
class Decision(BaseModel):
|
|
||||||
approve: bool
|
|
||||||
|
|
||||||
|
|
||||||
class LinkDecision(Decision):
|
|
||||||
t: str = Field(min_length=40, max_length=100)
|
|
||||||
|
|
||||||
|
|
||||||
def private_response(response: Response):
|
|
||||||
response.headers["Cache-Control"] = "no-store"
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
response.headers["X-Robots-Tag"] = "noindex, nofollow"
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/account")
|
|
||||||
async def account(response: Response, user: UserInfo = Depends(IsValidAccessToken)):
|
|
||||||
"""연결 상태만 준다 — 사업장을 고르지 않아도 답할 수 있어야 하는 값이다."""
|
|
||||||
private_response(response)
|
|
||||||
return await service.account_state(UUID(user.user_id))
|
|
||||||
|
|
||||||
|
|
||||||
class TestPost(BaseModel):
|
|
||||||
text: str = Field(min_length=1, max_length=500)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-post")
|
|
||||||
async def test_post(
|
|
||||||
req: TestPost, response: Response, user: UserInfo = Depends(IsValidAccessToken)
|
|
||||||
):
|
|
||||||
"""연동 확인용 즉시 게시 — 승인 없이 바로 연결된 계정으로 올라간다."""
|
|
||||||
private_response(response)
|
|
||||||
try:
|
|
||||||
return await service.test_post(UUID(user.user_id), req.text)
|
|
||||||
except SocialError as ex:
|
|
||||||
raise HTTPException(409, str(ex)) from ex
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/place/{place_id}")
|
|
||||||
async def list_posts(
|
|
||||||
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
|
|
||||||
):
|
|
||||||
private_response(response)
|
|
||||||
return await service.list_posts(UUID(user.user_id), place_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/place/{place_id}/draft")
|
|
||||||
async def draft(
|
|
||||||
place_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
|
|
||||||
):
|
|
||||||
private_response(response)
|
|
||||||
return await service.create_draft(UUID(user.user_id), place_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{post_id}/request-approval")
|
|
||||||
async def request_approval(
|
|
||||||
post_id: UUID, response: Response, user: UserInfo = Depends(IsValidAccessToken)
|
|
||||||
):
|
|
||||||
private_response(response)
|
|
||||||
try:
|
|
||||||
return await service.request_approval(UUID(user.user_id), post_id)
|
|
||||||
except (SocialError, RuntimeError) as ex:
|
|
||||||
raise HTTPException(
|
|
||||||
502, "APPROVAL_NOTIFICATION_FAILED_SCREEN_AVAILABLE"
|
|
||||||
) from ex
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/posts/{post_id}/decision")
|
|
||||||
async def owner_decision(
|
|
||||||
post_id: UUID,
|
|
||||||
req: Decision,
|
|
||||||
response: Response,
|
|
||||||
user: UserInfo = Depends(IsValidAccessToken),
|
|
||||||
):
|
|
||||||
private_response(response)
|
|
||||||
return await service.owner_decision(UUID(user.user_id), post_id, req.approve)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/approval/{post_id}")
|
|
||||||
async def approval(
|
|
||||||
post_id: UUID, response: Response, t: str = Query(min_length=40, max_length=100)
|
|
||||||
):
|
|
||||||
private_response(response)
|
|
||||||
return await service.approval(post_id, t)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/approval/{post_id}/decision")
|
|
||||||
async def decision(post_id: UUID, req: LinkDecision, response: Response):
|
|
||||||
private_response(response)
|
|
||||||
return await service.approval(post_id, req.t, approve=req.approve)
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from typing import Any, Union
|
from typing import Any, Union
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
@ -72,18 +71,6 @@ def CreateRefreshToken(subject: UserInfo) -> str:
|
|||||||
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
|
return __create_token(subject.to_json(), JWT_REFRESH_SECRET, REFRESH_TOKEN_EXPIRE_MIN)
|
||||||
|
|
||||||
|
|
||||||
def CreateDayPassToken(subject: UserInfo) -> str:
|
|
||||||
"""그날 자정(KST)까지만 사는 접근 토큰 — 미니 블로그 메일의 "수정하기" 링크 전용.
|
|
||||||
|
|
||||||
★ 일반 로그인 세션과 다르다 — 사장님이 메일에서 그 글 하나를 고치러 들어오는 맥락에서만
|
|
||||||
쓰이고, 유효기간도 그만큼 짧다(2026-09-17, 사장님 지시: "로그인도 크레덴셜로 자동으로
|
|
||||||
되게 (그날까지만)"). refresh 토큰은 안 준다 — 그날이 지나면 다시 메일을 받아야 한다."""
|
|
||||||
now_kst = datetime.now(timezone(timedelta(hours=9)))
|
|
||||||
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
expire_min = max(1, int((midnight_kst - now_kst).total_seconds() // 60))
|
|
||||||
return __create_token(subject.to_json(), JWT_ACCESS_SECRET, expire_min)
|
|
||||||
|
|
||||||
|
|
||||||
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
|
def __decode_token(jwt_token: str, secret_key: str, expired_exception) -> UserInfo:
|
||||||
try:
|
try:
|
||||||
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])
|
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])
|
||||||
|
|||||||
@ -2,17 +2,7 @@
|
|||||||
|
|
||||||
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다.
|
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 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회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신
|
· 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신
|
||||||
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림)
|
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림)
|
||||||
· 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지)
|
· 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지)
|
||||||
@ -43,33 +33,8 @@ def start_scheduler():
|
|||||||
|
|
||||||
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
|
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
|
||||||
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
|
||||||
# ★ 1분이 아니라 5분이다. 이 스윕이 하는 일은 "만료 표시" 와 "중단된 초안 정리" 뿐이라
|
|
||||||
# 분 단위 정밀도가 필요 없고, 주기가 짧으면 쓰기 커넥션을 계속 집어 든다 —
|
|
||||||
# 실측(2026-09-14): 1분 주기로 두자 같은 컨테이너에서 도는 테스트가 커넥션을 못 받아
|
|
||||||
# TimeoutError 로 무더기 실패했다. 운영에서도 같은 풀을 발행·수집과 나눠 쓴다.
|
|
||||||
from scheduler.jobs import sweep_social_posts
|
|
||||||
_scheduler.add_job(sweep_social_posts, 'interval', minutes=5, max_instances=1, coalesce=True)
|
|
||||||
if os.environ.get("GSC_ENABLED") == "1":
|
|
||||||
from services.search_console_service import run_scheduled_check
|
|
||||||
_scheduler.add_job(run_scheduled_check, "interval", minutes=10,
|
|
||||||
id="search-console", max_instances=1, coalesce=True)
|
|
||||||
|
|
||||||
from scheduler.jobs import sweep_alert_outbox, sweep_blog_mail, sweep_queue_health
|
|
||||||
_scheduler.add_job(sweep_alert_outbox, "interval", minutes=1,
|
|
||||||
id="alert-outbox", max_instances=1, coalesce=True)
|
|
||||||
_scheduler.add_job(sweep_queue_health, "interval", minutes=5,
|
|
||||||
id="queue-health", max_instances=1, coalesce=True)
|
|
||||||
# 미니 블로그 — 새벽에 재고를 채우고, 아침에 검수 통과분을 보낸다(docs/MINI_BLOG.md).
|
|
||||||
# LLM 키나 메일 설정이 없으면 두 잡 모두 아무 일도 안 하고 돌아온다.
|
|
||||||
# 자동 생성은 잠시 끈다 — 사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다(2026-09-23).
|
|
||||||
# 되살리려면 위 import 에 sweep_blog_drafts 를 다시 넣고 아래 두 줄 주석을 푼다.
|
|
||||||
# _scheduler.add_job(sweep_blog_drafts, "cron", hour=4, minute=10,
|
|
||||||
# id="blog-drafts", max_instances=1, coalesce=True)
|
|
||||||
_scheduler.add_job(sweep_blog_mail, "cron", hour=9, minute=0,
|
|
||||||
id="blog-mail", max_instances=1, coalesce=True)
|
|
||||||
_scheduler.start()
|
_scheduler.start()
|
||||||
LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)")
|
LOG.i("[scheduler] started (KST: 등록된 잡 없음)")
|
||||||
LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")
|
|
||||||
|
|
||||||
|
|
||||||
def shutdown_scheduler():
|
def shutdown_scheduler():
|
||||||
|
|||||||
@ -1,83 +1,5 @@
|
|||||||
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
|
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
|
||||||
|
|
||||||
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다.
|
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 service 가 책임진다.
|
||||||
(지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.)
|
(아직 등록된 잡 없음 — 지역정보 갱신 · 수집 재시도 · 개별 사이트 재빌드가 여기로 들어온다.)
|
||||||
"""
|
"""
|
||||||
from common.logger import LOG
|
|
||||||
|
|
||||||
"""예약 실행 진입점. 복구 전이는 DB 조건부 UPDATE로 여러 프로세스에서도 안전하다."""
|
|
||||||
from crud.social_crud import sweep
|
|
||||||
|
|
||||||
async def sweep_social_posts():
|
|
||||||
await sweep()
|
|
||||||
|
|
||||||
async def sweep_alert_outbox():
|
|
||||||
"""대기 중인 알림을 실제로 보낸다(services/alert_service.process_outbox)."""
|
|
||||||
from services import alert_service
|
|
||||||
|
|
||||||
try:
|
|
||||||
await alert_service.process_outbox()
|
|
||||||
except Exception as ex: # noqa: BLE001 — 스윕 실패가 스케줄러를 죽이면 안 된다(다음 주기 재시도)
|
|
||||||
LOG.w(f"[scheduler] 알림 발송 스윕 실패: {type(ex).__name__}: {ex}")
|
|
||||||
|
|
||||||
|
|
||||||
async def sweep_queue_health():
|
|
||||||
"""잡 큐가 막혔는지 주기적으로 본다 — dead-letter 누적·좀비 실행·오래 밀린 PENDING.
|
|
||||||
|
|
||||||
★ 왜 필요한가: 개별 잡의 DEAD 전이는 worker/runner.py 가 그 자리에서 바로 알린다. 이건
|
|
||||||
그것과 다른 신호다 — 잡 하나하나는 재시도 중(아직 DEAD 아님)인데 **큐 전체가 정체**된
|
|
||||||
경우(워커 프로세스가 죽었거나 DB 순단이 길어지는 경우)는 개별 잡 알림만으로는 안 보인다.
|
|
||||||
★ 복구되면 한 번만 알린다 — send_alert/resolve_alert 의 dedupe_key 가 그 판단을 한다."""
|
|
||||||
from crud.job_crud import JobQueue
|
|
||||||
from services import alert_service
|
|
||||||
|
|
||||||
try:
|
|
||||||
snap = await JobQueue().ops()
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[scheduler] 큐 상태 조회 실패: {type(ex).__name__}: {ex}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# 기준값: dead-letter 가 최근 1시간에 쌓였거나, 좀비 실행이 있거나, 가장 오래된 PENDING 이
|
|
||||||
# 30분 넘게 안 집혔다(정상 워커라면 대기 잡을 몇 초 안에 claim 한다).
|
|
||||||
problems = []
|
|
||||||
if snap.get("dead_1h", 0) > 0:
|
|
||||||
problems.append(f"최근 1시간 dead-letter {snap['dead_1h']}건")
|
|
||||||
if snap.get("stuck_running", 0) > 0:
|
|
||||||
problems.append(f"좀비 실행 {snap['stuck_running']}건(lease 만료 또는 10분 초과)")
|
|
||||||
if snap.get("oldest_pending_sec", 0) > 1800:
|
|
||||||
problems.append(f"가장 오래된 대기 잡이 {snap['oldest_pending_sec'] // 60}분째 안 집힘")
|
|
||||||
|
|
||||||
dedupe_key = "queue_health"
|
|
||||||
if problems:
|
|
||||||
await alert_service.send_alert(
|
|
||||||
kind="queue_stuck",
|
|
||||||
title="잡 큐 정체",
|
|
||||||
detail=" · ".join(problems) + f"\n{snap}",
|
|
||||||
dedupe_key=dedupe_key,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
await alert_service.resolve_alert(dedupe_key, "잡 큐 정상으로 돌아옴")
|
|
||||||
|
|
||||||
|
|
||||||
async def sweep_blog_drafts():
|
|
||||||
"""미니 블로그 재고 채우기(services/blog_jobs.generate_drafts)."""
|
|
||||||
from services import blog_jobs
|
|
||||||
|
|
||||||
try:
|
|
||||||
made = await blog_jobs.generate_drafts()
|
|
||||||
if made:
|
|
||||||
LOG.i(f"[scheduler] 미니 블로그 초안 {made}건 생성")
|
|
||||||
except Exception as ex: # noqa: BLE001 — 생성 실패가 스케줄러를 죽이면 안 된다
|
|
||||||
LOG.w(f"[scheduler] 미니 블로그 생성 실패: {type(ex).__name__}: {ex}")
|
|
||||||
|
|
||||||
|
|
||||||
async def sweep_blog_mail():
|
|
||||||
"""검수를 통과한 글을 사장님에게 보낸다(services/blog_jobs.send_reviewed)."""
|
|
||||||
from services import blog_jobs
|
|
||||||
|
|
||||||
try:
|
|
||||||
sent = await blog_jobs.send_reviewed()
|
|
||||||
if sent:
|
|
||||||
LOG.i(f"[scheduler] 미니 블로그 메일 {sent}통 발송")
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[scheduler] 미니 블로그 발송 실패: {type(ex).__name__}: {ex}")
|
|
||||||
|
|||||||
@ -1,24 +1,7 @@
|
|||||||
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
|
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
|
||||||
|
|
||||||
네이버 ID는 body에 보관하여 자동 수집(NAVER_CRAWL)의 (source, external_id) 갱신과는 분리한다
|
external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다.
|
||||||
— source 가 OFFICIAL_WEB 으로 다르므로 자동 크롤링이 이 행을 건드리지 않는다.
|
네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다.
|
||||||
|
|
||||||
★ 거리(2026-09-17 추가): 처음엔 external_id 없이 "지역 공통"(모든 군산 업장에 거리 없이 노출)
|
|
||||||
으로만 등록했다. 하지만 한일옥은 실존 업소라 업장마다 실제 거리가 다르고, 지역 공통 캐시
|
|
||||||
경로(services/snapshot.py::_local_contents, external_id IS NULL)는 거리를 업장마다 못 담는
|
|
||||||
설계라 거리가 안 나갔다(2026-09-17 확인). 그래서 external_id 를 네이버 place id 로 채워
|
|
||||||
그 경로에서 빠지게 하고, TourAPI·NAVER_CRAWL 맛집과 같은 개인화 경로(place_area_refs +
|
|
||||||
site_sections, services/local_restaurant_enrichment.py 와 동일한 패턴)로 업장별 거리를 얹는다.
|
|
||||||
★ 대가: 더는 "새 군산 업장에 자동으로 붙는" 지역 공통이 아니다 — 새 업장이 생기면
|
|
||||||
이 스크립트를 다시 돌려야 그 업장에도 한일옥이 연결된다.
|
|
||||||
|
|
||||||
사용법 (solution/backend 에서, 가상환경 안에서) — 호스트(Windows) 실행은 PGSSLMODE=disable 필수
|
|
||||||
(한글 홈 경로 탓에 asyncpg 인증서 로딩이 깨진다, dev-env-quirks 메모):
|
|
||||||
PowerShell: $env:PGSSLMODE = "disable"; python scripts/pin_gunsan_hanilok.py [--apply]
|
|
||||||
|
|
||||||
배포서버 실행방법
|
|
||||||
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py # 드라이런 먼저
|
|
||||||
docker compose exec solution-backend python scripts/pin_gunsan_hanilok.py --apply # 반영
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
@ -37,47 +20,18 @@ from sqlalchemy.engine import URL
|
|||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import area_contents, place_area_refs, places
|
from common.database.model.models import area_contents, places
|
||||||
from common.enums import LocalContentStatus, LocalContentType, LocalSource
|
from common.enums import LocalContentStatus, LocalContentType, LocalSource
|
||||||
from common.utils.geo import haversine_m
|
|
||||||
from config.server_configs import main_db_config as cfg
|
from config.server_configs import main_db_config as cfg
|
||||||
from services.collector.naver_place_adapter import NaverPlaceAdapter
|
|
||||||
from services.external import naver_place_lookup
|
|
||||||
from services.local_content_service import LocalContentService
|
|
||||||
from services.site_payload import _local
|
from services.site_payload import _local
|
||||||
from services.snapshot import _local_contents, _site_places
|
from services.snapshot import _local_contents
|
||||||
|
|
||||||
REGION = '52군산시'
|
REGION = '52군산시'
|
||||||
NAVER_ID = '11861452'
|
NAVER_ID = '11861452'
|
||||||
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
|
CONTENT_ID = uuid.uuid5(uuid.NAMESPACE_URL, 'web4ai:52군산시:restaurant:11861452')
|
||||||
|
|
||||||
|
|
||||||
def _as_float(value) -> float | None:
|
|
||||||
try:
|
|
||||||
return float(value) if value is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_coordinates() -> tuple[float, float] | None:
|
|
||||||
"""한일옥 좌표. TourAPI 에 없는 수기 등록이라 네이버 상세 페이지에서 가져온다
|
|
||||||
(services/local_restaurant_enrichment.py 가 자동 크롤링 맛집에 쓰는 것과 같은 어댑터)."""
|
|
||||||
summary = await NaverPlaceAdapter().fetch_summary(naver_place_lookup.place_url(NAVER_ID))
|
|
||||||
if not summary:
|
|
||||||
return None
|
|
||||||
lat, lng = _as_float(summary.get('latitude')), _as_float(summary.get('longitude'))
|
|
||||||
if lat is None or lng is None:
|
|
||||||
return None
|
|
||||||
return lat, lng
|
|
||||||
|
|
||||||
|
|
||||||
async def main(apply: bool):
|
async def main(apply: bool):
|
||||||
lat_lng = await _fetch_coordinates()
|
|
||||||
if lat_lng is None:
|
|
||||||
print(json.dumps({'error': '네이버에서 한일옥 좌표를 가져오지 못했습니다.'}, ensure_ascii=False))
|
|
||||||
return
|
|
||||||
lat, lng = lat_lng
|
|
||||||
|
|
||||||
engine = create_async_engine(URL.create(
|
engine = create_async_engine(URL.create(
|
||||||
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
|
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
|
||||||
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
|
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
|
||||||
@ -87,8 +41,8 @@ async def main(apply: bool):
|
|||||||
rows = (await conn.execute(select(
|
rows = (await conn.execute(select(
|
||||||
area_contents.local_content_id, area_contents.title, area_contents.body,
|
area_contents.local_content_id, area_contents.title, area_contents.body,
|
||||||
).where(
|
).where(
|
||||||
area_contents.region_code == REGION, area_contents.kind == 'restaurant',
|
area_contents.region_code == REGION,
|
||||||
area_contents.source == LocalSource.OFFICIAL_WEB.value,
|
area_contents.kind == 'restaurant', area_contents.external_id.is_(None),
|
||||||
area_contents.deleted.is_(False),
|
area_contents.deleted.is_(False),
|
||||||
))).mappings().all()
|
))).mappings().all()
|
||||||
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
|
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
|
||||||
@ -99,13 +53,12 @@ async def main(apply: bool):
|
|||||||
local_content_id=content_id, region_code=REGION,
|
local_content_id=content_id, region_code=REGION,
|
||||||
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
|
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
|
||||||
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
|
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
|
||||||
source=LocalSource.OFFICIAL_WEB.value, external_id=NAVER_ID, title='한일옥',
|
source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥',
|
||||||
body={**(rows[0]['body'] if rows else {}),
|
body={**(rows[0]['body'] if rows else {}),
|
||||||
'name': '한일옥', 'searchQuery': '군산 한일옥',
|
'name': '한일옥', 'searchQuery': '군산 한일옥',
|
||||||
'naverPlaceId': NAVER_ID,
|
'naverPlaceId': NAVER_ID,
|
||||||
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
|
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
|
||||||
'registration': 'owner_confirmed_region_default'},
|
'registration': 'owner_confirmed_region_default'},
|
||||||
latitude=lat, longitude=lng,
|
|
||||||
status=LocalContentStatus.PUBLISHED.value, published_at=now,
|
status=LocalContentStatus.PUBLISHED.value, published_at=now,
|
||||||
collected_at=now, display_start_at=None, display_end_at=None,
|
collected_at=now, display_start_at=None, display_end_at=None,
|
||||||
expires_at=None, deleted=False, updated_at=now,
|
expires_at=None, deleted=False, updated_at=now,
|
||||||
@ -115,72 +68,21 @@ async def main(apply: bool):
|
|||||||
index_elements=[area_contents.local_content_id],
|
index_elements=[area_contents.local_content_id],
|
||||||
set_={k: v for k, v in values.items() if k != 'local_content_id'},
|
set_={k: v for k, v in values.items() if k != 'local_content_id'},
|
||||||
))
|
))
|
||||||
|
targets = (await conn.execute(select(places.place_id, places.name).where(
|
||||||
targets = (await conn.execute(select(
|
|
||||||
places.place_id, places.name, places.latitude, places.longitude,
|
|
||||||
).where(
|
|
||||||
places.deleted.is_(False), places.region_code == REGION,
|
places.deleted.is_(False), places.region_code == REGION,
|
||||||
))).mappings().all()
|
))).mappings().all()
|
||||||
|
print(json.dumps({'applied': apply, 'database': cfg.name, 'region': REGION,
|
||||||
distances: dict[str, int | None] = {}
|
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
|
||||||
for row in targets:
|
'existingPlaces': [dict(r) for r in targets]},
|
||||||
plat, plng = _as_float(row['latitude']), _as_float(row['longitude'])
|
default=str, ensure_ascii=False))
|
||||||
distances[str(row['place_id'])] = (
|
|
||||||
round(haversine_m(plat, plng, lat, lng)) if plat is not None and plng is not None else None
|
|
||||||
)
|
|
||||||
|
|
||||||
if apply:
|
|
||||||
for row in targets:
|
|
||||||
stmt = insert(place_area_refs).values(
|
|
||||||
place_id=row['place_id'], local_content_id=content_id,
|
|
||||||
distance_m=distances[str(row['place_id'])], deleted=False,
|
|
||||||
).on_conflict_do_update(
|
|
||||||
index_elements=[place_area_refs.place_id, place_area_refs.local_content_id],
|
|
||||||
set_={'distance_m': distances[str(row['place_id'])], 'deleted': False, 'updated_at': now},
|
|
||||||
)
|
|
||||||
await conn.execute(stmt)
|
|
||||||
|
|
||||||
print(json.dumps({
|
|
||||||
'applied': apply, 'database': cfg.name, 'region': REGION,
|
|
||||||
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
|
|
||||||
'coordinates': {'latitude': lat, 'longitude': lng},
|
|
||||||
'existingPlaces': [
|
|
||||||
{**{k: v for k, v in r.items() if k not in ('latitude', 'longitude')},
|
|
||||||
'distanceMeters': distances[str(r['place_id'])]}
|
|
||||||
for r in targets
|
|
||||||
],
|
|
||||||
}, default=str, ensure_ascii=False))
|
|
||||||
|
|
||||||
if apply:
|
if apply:
|
||||||
# ★ 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다
|
# place_id 없는 새 군산 업장도 지역 공통 경로만으로 받는지 확인한다.
|
||||||
# (services/local_content_service.py::_write_site_places 규약과 동일).
|
snapshot = await _local_contents(SimpleNamespace(region_code=REGION))
|
||||||
service = LocalContentService()
|
local, _ = _local(snapshot, None, None)
|
||||||
for row in targets:
|
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
|
||||||
place_id = row['place_id']
|
assert len(matches) == 1, '지역 공통 payload에 한일옥이 정확히 한 번 있어야 합니다.'
|
||||||
places_map = dict(await _site_places(place_id))
|
print(json.dumps({'verifiedRegionalPayload': matches}, ensure_ascii=False))
|
||||||
prev = places_map.get(str(content_id)) or {}
|
|
||||||
places_map[str(content_id)] = {
|
|
||||||
'kind': 'restaurant',
|
|
||||||
'distanceMeters': distances[str(place_id)],
|
|
||||||
'hidden': bool(prev.get('hidden', False)),
|
|
||||||
}
|
|
||||||
await service._write_site_places(place_id, places_map)
|
|
||||||
|
|
||||||
# 업장마다 거리를 포함해 정확히 한 번 실렸는지 확인한다.
|
|
||||||
mismatches = []
|
|
||||||
for row in targets:
|
|
||||||
place = SimpleNamespace(
|
|
||||||
place_id=row['place_id'], region_code=REGION,
|
|
||||||
latitude=row['latitude'], longitude=row['longitude'],
|
|
||||||
)
|
|
||||||
snapshot = await _local_contents(place)
|
|
||||||
local, _ = _local(snapshot, _as_float(row['latitude']), _as_float(row['longitude']))
|
|
||||||
matches = [r for r in local['restaurants'] if r['name'] == '한일옥']
|
|
||||||
expected = distances[str(row['place_id'])]
|
|
||||||
ok = len(matches) == 1 and (expected is None or matches[0].get('distanceMeters') == expected)
|
|
||||||
if not ok:
|
|
||||||
mismatches.append({'place': row['name'], 'matches': matches, 'expectedDistanceMeters': expected})
|
|
||||||
print(json.dumps({'verified': not mismatches, 'mismatches': mismatches}, ensure_ascii=False))
|
|
||||||
finally:
|
finally:
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
await DB_SESSION_MNG.dispose_all()
|
await DB_SESSION_MNG.dispose_all()
|
||||||
|
|||||||
@ -1,32 +0,0 @@
|
|||||||
"""저장된 관측값만 출력한다. Google 호출/색인 요청은 하지 않는다."""
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import site_search_status as Status
|
|
||||||
from common.enums import DBType
|
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
|
||||||
async def read(session):
|
|
||||||
rows = (await session.execute(select(Status).where(Status.deleted.is_(False))
|
|
||||||
.order_by(Status.published_at.desc()))).scalars().all()
|
|
||||||
return [{"url": row.page_url, "published_at": row.published_at,
|
|
||||||
"sitemap_submitted_at": row.sitemap_submitted_at,
|
|
||||||
"inspected_at": row.inspected_at, "first_indexed_at": row.first_indexed_at,
|
|
||||||
"inspection": row.inspection, "error_code": row.error_code,
|
|
||||||
"next_check_at": row.next_check_at, "alerted_at": row.alerted_at} for row in rows]
|
|
||||||
try:
|
|
||||||
rows = await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, read)
|
|
||||||
print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
|
|
||||||
finally:
|
|
||||||
await DB_SESSION_MNG.dispose_all()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
@ -1,288 +0,0 @@
|
|||||||
"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.
|
|
||||||
|
|
||||||
★★ **카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 한 글자도 여기 없다.
|
|
||||||
그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 — 새면 다른 채널을 붙일 때 전부
|
|
||||||
걷어내야 하고, 알림톡 어댑터에 건 것과 같은 규칙이다.
|
|
||||||
|
|
||||||
★ 빌더 화면과 무엇이 다른가 — 셋뿐이다.
|
|
||||||
1. 로그인 토큰이 없다 → 연결된 발화자 키로 사장님을 찾는다
|
|
||||||
2. place_id 가 URL 에 없다 → 대화에서 고르고 기억한다
|
|
||||||
3. 확인을 되돌려 줄 프론트가 없다 → 무엇을 물었는지 서버가 들고 있는다
|
|
||||||
나머지(도구·등급·게이트)는 `runtime.chat()` 그대로다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import owner_kakao_links as Link
|
|
||||||
from common.database.model.models import users
|
|
||||||
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from crud.place_crud import PlaceCRUD
|
|
||||||
from crud.site_crud import SiteCRUD
|
|
||||||
from crud.job_crud import JobQueue
|
|
||||||
from common.enums import SiteStatus
|
|
||||||
from common.models.gmodel import PageParams
|
|
||||||
from services.site_service import SiteService
|
|
||||||
from services import kakao_link_service as link_service
|
|
||||||
from services.agent import runtime
|
|
||||||
from services.agent.tools import REGISTRY
|
|
||||||
from services.kakao_link_service import KakaoLinkError
|
|
||||||
|
|
||||||
# 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합).
|
|
||||||
CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}")
|
|
||||||
|
|
||||||
# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
|
|
||||||
PENDING_MINUTES = 3
|
|
||||||
|
|
||||||
# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이
|
|
||||||
# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다.
|
|
||||||
CONFIRM_LABEL = "네, 해주세요"
|
|
||||||
PUBLISH_LABEL = "네, 발행해주세요"
|
|
||||||
DECLINE_LABEL = "아니요"
|
|
||||||
|
|
||||||
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
|
|
||||||
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
|
|
||||||
|
|
||||||
# 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고,
|
|
||||||
# "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다.
|
|
||||||
_LIST_WORDS = {
|
|
||||||
"목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록",
|
|
||||||
"가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _now():
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def _say(text: str, quick: list[str] | None = None) -> dict:
|
|
||||||
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다."""
|
|
||||||
return {"text": text, "quick_replies": quick or []}
|
|
||||||
|
|
||||||
|
|
||||||
async def _user_info(user_id) -> UserInfo | None:
|
|
||||||
"""user_id → UserInfo. ★ 토큰을 발급하지 않는다.
|
|
||||||
|
|
||||||
프로세스 안에서 쓸 객체만 만든다 — 카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취
|
|
||||||
경로다(docs/AGENT.md)."""
|
|
||||||
|
|
||||||
async def run(s):
|
|
||||||
row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first()
|
|
||||||
return ErrorType.SUCCESS, row
|
|
||||||
|
|
||||||
# ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라
|
|
||||||
# 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다.
|
|
||||||
err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run)
|
|
||||||
if err != ErrorType.SUCCESS or row is None:
|
|
||||||
return None
|
|
||||||
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
|
|
||||||
|
|
||||||
|
|
||||||
async def _link_row(channel_user_key: str):
|
|
||||||
async def run(s):
|
|
||||||
row = (
|
|
||||||
await s.execute(
|
|
||||||
select(Link).where(
|
|
||||||
Link.channel_user_key == channel_user_key,
|
|
||||||
Link.deleted.is_(False),
|
|
||||||
Link.status == KakaoLinkStatus.LINKED.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
return ErrorType.SUCCESS, row
|
|
||||||
|
|
||||||
_err, row = await DB_SESSION_MNG.execute_lambda(Link.DBType(), DBWRType.DB_READ.value, run)
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
async def _update_link(channel_user_key: str, **values):
|
|
||||||
async def run(s):
|
|
||||||
row = (
|
|
||||||
await s.execute(
|
|
||||||
select(Link).where(
|
|
||||||
Link.channel_user_key == channel_user_key,
|
|
||||||
Link.deleted.is_(False),
|
|
||||||
Link.status == KakaoLinkStatus.LINKED.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().first()
|
|
||||||
if row is None:
|
|
||||||
return None
|
|
||||||
for name, value in values.items():
|
|
||||||
setattr(row, name, value)
|
|
||||||
return row
|
|
||||||
|
|
||||||
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
|
|
||||||
|
|
||||||
|
|
||||||
async def _clear_pending(key):
|
|
||||||
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
|
|
||||||
|
|
||||||
|
|
||||||
async def _sites(user: UserInfo) -> list:
|
|
||||||
"""사장님의 가게 + 그 사이트 상태를 한 번에.
|
|
||||||
|
|
||||||
★ 사업장 목록이 아니라 **사이트 목록**을 쓴다. 대화에서 사장님이 알아야 하는 것은
|
|
||||||
"가게가 있다" 가 아니라 "발행돼 있나 · 주소가 뭔가" 다 — `/sites` 화면이 같은 이유로
|
|
||||||
`list_my_sites` 를 쓴다."""
|
|
||||||
service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue())
|
|
||||||
res = await service.list_my_sites(user, PageParams(page=1, size=20))
|
|
||||||
return list(res.sites or [])
|
|
||||||
|
|
||||||
|
|
||||||
def _line(row) -> str:
|
|
||||||
"""목록 한 줄. ★ 발행 여부를 같이 말한다 — 안 그러면 사장님은 고친 것이 손님에게
|
|
||||||
보이는 줄 안다."""
|
|
||||||
if row.status == SiteStatus.PUBLISHED and row.published_at:
|
|
||||||
when = row.published_at.strftime("%m월 %d일")
|
|
||||||
return f"· {row.name} — {when} 발행"
|
|
||||||
return f"· {row.name} — 아직 발행 전"
|
|
||||||
|
|
||||||
|
|
||||||
def _list_reply(rows: list, head: str) -> dict:
|
|
||||||
body = "\n".join(_line(r) for r in rows[:10])
|
|
||||||
more = f"\n(그 밖에 {len(rows) - 10}곳 더)" if len(rows) > 10 else ""
|
|
||||||
tail = "\n\n어느 가게 이야기일까요?" if len(rows) > 1 else ""
|
|
||||||
return _say(f"{head}\n{body}{more}{tail}", [r.name for r in rows[:10]] if len(rows) > 1 else [])
|
|
||||||
|
|
||||||
|
|
||||||
async def _pick_place(user: UserInfo, row, utterance: str):
|
|
||||||
"""어느 가게 이야기인지 정한다.
|
|
||||||
|
|
||||||
★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를
|
|
||||||
고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다.
|
|
||||||
|
|
||||||
반환: (place_id, 되물을 답 or None)"""
|
|
||||||
rows = await _sites(user)
|
|
||||||
if not rows:
|
|
||||||
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
|
|
||||||
|
|
||||||
# ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다.
|
|
||||||
if utterance in _LIST_WORDS:
|
|
||||||
await _update_link(row.channel_user_key, current_place_id=None,
|
|
||||||
pending_tool=None, pending_args=None, pending_expires_at=None)
|
|
||||||
return None, _list_reply(rows, "관리 중인 홈페이지입니다.")
|
|
||||||
|
|
||||||
# 바로가기를 눌렀거나 가게 이름을 그대로 말한 경우 — 그 가게로 맞춘다.
|
|
||||||
chosen = {r.name.strip(): r for r in rows}.get(utterance.strip())
|
|
||||||
if chosen is not None:
|
|
||||||
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
|
|
||||||
pending_tool=None, pending_args=None, pending_expires_at=None)
|
|
||||||
return None, _say(f"'{chosen.name}' 으로 맞췄습니다. 무엇을 도와드릴까요?\n"
|
|
||||||
f"예) 체크인 시간 3시로 바꿔줘")
|
|
||||||
|
|
||||||
if len(rows) == 1:
|
|
||||||
if row.current_place_id != rows[0].place_id:
|
|
||||||
await _update_link(row.channel_user_key, current_place_id=rows[0].place_id)
|
|
||||||
return str(rows[0].place_id), None
|
|
||||||
|
|
||||||
if row.current_place_id is not None:
|
|
||||||
return str(row.current_place_id), None
|
|
||||||
|
|
||||||
return None, _list_reply(rows, "관리 중인 홈페이지입니다.")
|
|
||||||
|
|
||||||
|
|
||||||
async def handle(utterance: str, channel_user_key: str) -> dict:
|
|
||||||
"""대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다."""
|
|
||||||
utterance = (utterance or "").strip()
|
|
||||||
if not utterance:
|
|
||||||
return _say("무엇을 도와드릴까요?")
|
|
||||||
|
|
||||||
row = await _link_row(channel_user_key)
|
|
||||||
|
|
||||||
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
|
|
||||||
if row is None:
|
|
||||||
found = CODE_PATTERN.fullmatch(utterance.upper())
|
|
||||||
if not found:
|
|
||||||
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
|
|
||||||
try:
|
|
||||||
user_id = await link_service.redeem(utterance, channel_user_key)
|
|
||||||
except KakaoLinkError:
|
|
||||||
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
|
|
||||||
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
|
|
||||||
|
|
||||||
# ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채
|
|
||||||
# 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다.
|
|
||||||
user = await _user_info(user_id)
|
|
||||||
rows = await _sites(user) if user else []
|
|
||||||
if not rows:
|
|
||||||
return _say("연결됐습니다. 아직 등록된 가게가 없어요 — 홈페이지를 먼저 만들어 주세요.")
|
|
||||||
if len(rows) == 1:
|
|
||||||
await _update_link(channel_user_key, current_place_id=rows[0].place_id)
|
|
||||||
return _say(
|
|
||||||
f"연결됐습니다. '{rows[0].name}' 홈페이지를 여기서 고칠 수 있어요.\n"
|
|
||||||
f"{_line(rows[0])}\n\n예) 체크인 시간 3시로 바꿔줘"
|
|
||||||
)
|
|
||||||
return _list_reply(rows, "연결됐습니다. 관리 중인 홈페이지입니다.")
|
|
||||||
|
|
||||||
user = await _user_info(row.user_id)
|
|
||||||
if user is None:
|
|
||||||
return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
|
|
||||||
|
|
||||||
# ★ 이미 연결된 사람이 코드를 또 보내는 일이 실제로 있었다(2026-09-22). 그대로 두면
|
|
||||||
# 6자리가 그냥 발화로 모델에 넘어가 유료 호출 + 대기만 쌓인다 — 여기서 끊는다.
|
|
||||||
if CODE_PATTERN.fullmatch(utterance.upper()):
|
|
||||||
return _say("이미 연결되어 있어요. 바로 말씀하시면 됩니다.\n예) 체크인 시간 3시로 바꿔줘")
|
|
||||||
|
|
||||||
# ── 확인 이어받기 ────────────────────────────────────────────────────
|
|
||||||
pending = None
|
|
||||||
if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now():
|
|
||||||
pending = {"tool": row.pending_tool, "args": row.pending_args or {}}
|
|
||||||
elif row.pending_tool:
|
|
||||||
# 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다.
|
|
||||||
await _clear_pending(channel_user_key)
|
|
||||||
|
|
||||||
if pending is not None:
|
|
||||||
if utterance in _YES:
|
|
||||||
await _clear_pending(channel_user_key)
|
|
||||||
result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending)
|
|
||||||
return _say(result["reply"])
|
|
||||||
if utterance in _NO:
|
|
||||||
await _clear_pending(channel_user_key)
|
|
||||||
return _say("알겠습니다. 그대로 두겠습니다.")
|
|
||||||
# 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다.
|
|
||||||
await _clear_pending(channel_user_key)
|
|
||||||
|
|
||||||
# ── 가게 고르기 ──────────────────────────────────────────────────────
|
|
||||||
place_id, ask = await _pick_place(user, row, utterance)
|
|
||||||
if ask is not None:
|
|
||||||
return ask
|
|
||||||
|
|
||||||
# ── 도구 ─────────────────────────────────────────────────────────────
|
|
||||||
try:
|
|
||||||
result = await runtime.chat(user, place_id, utterance)
|
|
||||||
except runtime.AgentError as ex:
|
|
||||||
return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."))
|
|
||||||
|
|
||||||
if result.get("needs_confirm") and result.get("tool"):
|
|
||||||
await _update_link(
|
|
||||||
channel_user_key,
|
|
||||||
pending_tool=result["tool"],
|
|
||||||
pending_args=result.get("args") or {},
|
|
||||||
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
|
|
||||||
)
|
|
||||||
return _say(result["reply"], [CONFIRM_LABEL, DECLINE_LABEL])
|
|
||||||
|
|
||||||
# 값을 고쳤으면 재발행을 바로 누를 수 있게 바로가기를 붙인다 — 도구가 이미 그렇게 묻는다.
|
|
||||||
quick = [PUBLISH_LABEL, DECLINE_LABEL] if result.get("done") and result.get("tool") != REGISTRY["publish"].name else []
|
|
||||||
if quick:
|
|
||||||
await _update_link(
|
|
||||||
channel_user_key,
|
|
||||||
pending_tool="publish",
|
|
||||||
pending_args={},
|
|
||||||
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
|
|
||||||
)
|
|
||||||
return _say(result["reply"], quick)
|
|
||||||
|
|
||||||
|
|
||||||
_ERRORS = {
|
|
||||||
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
|
|
||||||
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",
|
|
||||||
"AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.",
|
|
||||||
"AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
|
|
||||||
}
|
|
||||||
@ -1,165 +0,0 @@
|
|||||||
"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답.
|
|
||||||
|
|
||||||
★★ **채널을 모른다.** 빌더 화면에서 왔는지 카카오톡에서 왔는지 알 필요가 없다.
|
|
||||||
이걸 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 카카오 심사가 끝나야
|
|
||||||
무엇 하나 검증되지 않는다(docs/AGENT.md).
|
|
||||||
|
|
||||||
★ 확인이 필요한지는 **레지스트리의 등급**이 정한다. 모델이 정하게 두면 프롬프트에
|
|
||||||
끼어든 한 줄이 확인 절차를 건너뛴다.
|
|
||||||
|
|
||||||
★ 실행 결과 문구는 도구가 만든다(tools.py). LLM 문장은 '되묻기' 에만 쓴다 —
|
|
||||||
모델이 결과를 쓰면 하지 않은 일을 했다고 말할 수 있다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from common.category_schema.loader import get_schema
|
|
||||||
from common.enums import DBWRType, ErrorType, PlaceCategory
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import places
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from config import agent_config as config
|
|
||||||
from config.server_configs import external_api_config
|
|
||||||
from crud.fact_crud import FactCRUD
|
|
||||||
from crud.place_crud import PlaceCRUD
|
|
||||||
from services.agent import tools as registry
|
|
||||||
from services.agent.tools import ToolContext, ToolGrade, ToolRejected
|
|
||||||
from services.fact_service import FactService
|
|
||||||
from services.llm import provider
|
|
||||||
from services.llm.errors import LlmError
|
|
||||||
from services.prompts import agent as prompt
|
|
||||||
from common.logger import LOG
|
|
||||||
|
|
||||||
# 발화 길이 상한. 프롬프트 비용은 입력 토큰에 비례하고, 사장님이 한 번에 치는 말은 길지 않다.
|
|
||||||
MAX_MESSAGE = 500
|
|
||||||
# 도구 선택은 짧은 프롬프트라 빠르다. 카카오 웹훅의 5초 벽 안에 들어가야 한다(docs/AGENT.md).
|
|
||||||
REQUEST_TIMEOUT = httpx.Timeout(20.0, connect=5.0)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentError(RuntimeError):
|
|
||||||
"""라우터가 HTTP 로 옮길 도메인 예외. 코드 문자열만 담는다(social 과 같은 규약)."""
|
|
||||||
|
|
||||||
|
|
||||||
def is_configured() -> bool:
|
|
||||||
"""대화창을 열 수 있나 — 스위치와 LLM 키를 **둘 다** 본다.
|
|
||||||
|
|
||||||
★ 스위치(`AGENT_CHAT_ENABLED`)와 키를 **둘 다** 보는 이유: 키만 보면 "잠시 닫아 두기" 를
|
|
||||||
키를 지워서 해야 하는데 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면
|
|
||||||
키 없는 환경에서 **눌러도 안 되는 입구**가 생긴다.
|
|
||||||
실제로 2026-09-21 에 카카오 채널 보류로 한 번 닫았고, 채널 인증이 끝나 다시 열었다."""
|
|
||||||
return config.chat_enabled() and provider.active().is_configured()
|
|
||||||
|
|
||||||
|
|
||||||
async def _load_place(user: UserInfo, place_id: str):
|
|
||||||
"""★ 소유자 범위. 없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로 답한다(레포 관례).
|
|
||||||
|
|
||||||
에이전트가 이 관례를 벗어나면 대화창이 소유자 스코프를 우회하는 유일한 입구가 된다."""
|
|
||||||
err, place = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
places.DBType(),
|
|
||||||
DBWRType.DB_READ.value,
|
|
||||||
lambda s: PlaceCRUD().get_place(s, uuid.UUID(user.user_id), uuid.UUID(place_id)),
|
|
||||||
)
|
|
||||||
if err != ErrorType.SUCCESS or place is None:
|
|
||||||
raise AgentError("PLACE_NOT_FOUND")
|
|
||||||
return place
|
|
||||||
|
|
||||||
|
|
||||||
async def _context_facts(user: UserInfo, place_id: str, place) -> list[dict]:
|
|
||||||
"""모델에게 줄 '지금 값'. 이게 없으면 "3시로 바꿔줘" 가 무엇을 바꾸는지 모델이 모른다."""
|
|
||||||
res = await FactService(FactCRUD(), PlaceCRUD()).list_facts(user, place_id, publishable_only=True)
|
|
||||||
schema = get_schema(PlaceCategory(place.category))
|
|
||||||
out = []
|
|
||||||
for f in (res.facts or []):
|
|
||||||
spec = schema.get(f.key)
|
|
||||||
if spec and spec.scope == "place" and (f.value or "").strip():
|
|
||||||
# ★ label 은 싣지 않는다 — 아래 '항목 목록' 에 이미 key↔label 이 있다.
|
|
||||||
# 같은 표를 두 번 보내면 프롬프트만 커지고 모델이 얻는 것은 없다.
|
|
||||||
out.append({f.key: f.value})
|
|
||||||
# ★ 상한을 둔다. 실측(2026-09-22): 필드 43 + fact 수십 개가 실린 프롬프트가 5초 벽을
|
|
||||||
# 넘겼다. 무한정 싣지 않는다 — 대화 한 턴에 필요한 맥락은 그렇게 많지 않다.
|
|
||||||
return out[:30]
|
|
||||||
|
|
||||||
|
|
||||||
async def _choose(place, fields, facts, message) -> dict:
|
|
||||||
"""LLM 한 번. 고른 도구 이름과 인자만 받는다."""
|
|
||||||
active = provider.active()
|
|
||||||
async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT) as client:
|
|
||||||
result = await active.generate(
|
|
||||||
client,
|
|
||||||
external_api_config.gemini_text_model if active.__name__.endswith("gemini") else external_api_config.openai_text_model,
|
|
||||||
prompt=prompt.build_prompt(
|
|
||||||
place_name=place.name,
|
|
||||||
tools=registry.describe(),
|
|
||||||
fields=fields,
|
|
||||||
facts=facts,
|
|
||||||
message=message,
|
|
||||||
),
|
|
||||||
response_schema=prompt.RESPONSE_SCHEMA,
|
|
||||||
temperature=0.0,
|
|
||||||
)
|
|
||||||
return result.json or {}
|
|
||||||
|
|
||||||
|
|
||||||
async def chat(user: UserInfo, place_id: str, message: str, confirm: dict | None = None) -> dict:
|
|
||||||
"""대화 한 번.
|
|
||||||
|
|
||||||
confirm 이 오면 LLM 을 부르지 않는다 — 사장님이 직전에 본 확인 문구에 '네' 를 누른 것이고,
|
|
||||||
그 문장이 가리키는 도구를 그대로 실행한다. **인자는 다시 검증한다** — 화면에서 온 값을
|
|
||||||
믿고 실행하면, 확인 절차가 오히려 검증을 건너뛰는 구멍이 된다.
|
|
||||||
"""
|
|
||||||
message = (message or "").strip()
|
|
||||||
if confirm is None and not message:
|
|
||||||
raise AgentError("AGENT_EMPTY_MESSAGE")
|
|
||||||
if len(message) > MAX_MESSAGE:
|
|
||||||
raise AgentError("AGENT_MESSAGE_TOO_LONG")
|
|
||||||
|
|
||||||
place = await _load_place(user, place_id)
|
|
||||||
ctx = ToolContext(user=user, place_id=place_id, place=place)
|
|
||||||
|
|
||||||
if confirm is not None:
|
|
||||||
tool = registry.REGISTRY.get(confirm.get("tool") or "")
|
|
||||||
if tool is None or tool.grade == ToolGrade.READ:
|
|
||||||
raise AgentError("AGENT_UNKNOWN_TOOL")
|
|
||||||
return await _execute(ctx, tool, confirm.get("args") or {})
|
|
||||||
|
|
||||||
if not is_configured():
|
|
||||||
raise AgentError("AGENT_NOT_CONFIGURED")
|
|
||||||
|
|
||||||
fields = registry.fields_of(place)
|
|
||||||
facts = await _context_facts(user, place_id, place)
|
|
||||||
# ★ 사이트 상태는 프롬프트에 싣지 않는다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그 계산이
|
|
||||||
# 돌았고, 정작 모델이 필요할 때는 `get_site_status` 도구를 부르면 된다.
|
|
||||||
|
|
||||||
try:
|
|
||||||
choice = await _choose(place, fields, facts, message)
|
|
||||||
except LlmError as ex:
|
|
||||||
LOG.w(f"[agent] 도구 선택 실패: {type(ex).__name__}")
|
|
||||||
raise AgentError("AGENT_CALL_FAILED") from ex
|
|
||||||
|
|
||||||
name = (choice.get("tool") or "").strip()
|
|
||||||
tool = registry.REGISTRY.get(name)
|
|
||||||
if tool is None:
|
|
||||||
# ★ 모르는 이름을 지어냈거나 모델이 되묻기를 골랐다. 둘 다 '실행하지 않는다' 로 같다.
|
|
||||||
return {
|
|
||||||
"reply": (choice.get("message") or "").strip() or "무엇을 도와드릴까요?",
|
|
||||||
"tool": None,
|
|
||||||
"needs_confirm": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
args = choice.get("args") or {}
|
|
||||||
if tool.grade == ToolGrade.SEMI:
|
|
||||||
# 실행하지 않는다. 사장님이 한 번 더 눌러야 한다.
|
|
||||||
return {"reply": tool.confirm, "tool": tool.name, "args": args, "needs_confirm": True}
|
|
||||||
|
|
||||||
return await _execute(ctx, tool, args)
|
|
||||||
|
|
||||||
|
|
||||||
async def _execute(ctx: ToolContext, tool, args: dict) -> dict:
|
|
||||||
try:
|
|
||||||
reply = await tool.run(ctx, args)
|
|
||||||
except ToolRejected as ex:
|
|
||||||
# 도구가 거절한 이유는 사장님께 그대로 보여 준다 — 실패를 숨기면 다시 시도한다.
|
|
||||||
return {"reply": str(ex), "tool": tool.name, "needs_confirm": False, "rejected": True}
|
|
||||||
return {"reply": reply, "tool": tool.name, "needs_confirm": False, "done": tool.grade != ToolGrade.READ}
|
|
||||||
@ -1,193 +0,0 @@
|
|||||||
"""도구 레지스트리 — 에이전트가 할 수 있는 일의 **전부**가 여기 있다.
|
|
||||||
|
|
||||||
★★ 도구는 반드시 `services/*` 를 통과한다. `crud`·`models` 를 직접 부르면 업종 스키마
|
|
||||||
검증 · 출처 필수 · 정정본 보호 · 소유자 범위가 통째로 사라지는데, **아무 증상이 없다** —
|
|
||||||
값은 들어가고 빌드는 성공하고 화면도 뜬다. `collect_service.store_facts` 가
|
|
||||||
"크롤러가 우회할 수 있는 뒷문을 만들지 않는다" 로 막아 둔 그 문이고, 에이전트에게만
|
|
||||||
열어 줄 이유가 없다.
|
|
||||||
|
|
||||||
★ 결과 문구는 도구가 만든다. LLM 이 쓰게 두면 **하지 않은 일을 했다고 말할 수 있고**,
|
|
||||||
사장님에게는 그 말이 사실로 보인다.
|
|
||||||
|
|
||||||
★ 등급은 여기서 못 박는다. LLM 이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를
|
|
||||||
건너뛴다 — 되돌릴 수 없는 행위일수록 그 값을 모델에 맡기면 안 된다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Awaitable, Callable
|
|
||||||
|
|
||||||
from common.category_schema.loader import get_schema
|
|
||||||
from common.enums import ErrorType, PlaceCategory, SourceType
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from crud.fact_crud import FactCRUD
|
|
||||||
from crud.job_crud import JobQueue
|
|
||||||
from crud.place_crud import PlaceCRUD
|
|
||||||
from crud.site_crud import SiteCRUD
|
|
||||||
from router.v1.fact.protocol import Req_UpsertFact
|
|
||||||
from router.v1.site.protocol import Req_StartBuild
|
|
||||||
from services import site_payload
|
|
||||||
from services.fact_service import FactService
|
|
||||||
from services.site_service import SiteService
|
|
||||||
|
|
||||||
|
|
||||||
class ToolGrade(str, Enum):
|
|
||||||
"""되돌릴 수 있느냐가 승인 강도를 정한다 — 분류가 아니라 동작을 가르는 값이다."""
|
|
||||||
|
|
||||||
READ = "READ" # 승인 없음
|
|
||||||
REVERSIBLE = "REVERSIBLE" # 실행하고 알린다. 사장님이 다시 고치면 된다
|
|
||||||
SEMI = "SEMI" # 실행 전에 한 번 묻는다(되돌릴 수는 있으나 그 사이 밖에서 읽힌다)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ToolContext:
|
|
||||||
user: UserInfo
|
|
||||||
place_id: str
|
|
||||||
place: object
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Tool:
|
|
||||||
name: str
|
|
||||||
grade: ToolGrade
|
|
||||||
summary: str
|
|
||||||
args: dict = field(default_factory=dict)
|
|
||||||
run: Callable[[ToolContext, dict], Awaitable[str]] = None
|
|
||||||
# SEMI 도구가 실행 전에 사장님께 보일 문장.
|
|
||||||
confirm: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
def _services():
|
|
||||||
"""서비스는 매 호출 새로 만든다 — 라우터가 Depends 로 받는 것과 같은 수명이다.
|
|
||||||
|
|
||||||
★ Depends 기본값에 기대지 않고 의존을 손으로 넣는다. FastAPI 밖에서 부르면
|
|
||||||
기본값이 `Depends(...)` 객체 그대로라 서비스가 조용히 엉뚱한 것을 들고 돈다."""
|
|
||||||
place_crud = PlaceCRUD()
|
|
||||||
return FactService(FactCRUD(), place_crud), SiteService(SiteCRUD(), place_crud, JobQueue())
|
|
||||||
|
|
||||||
|
|
||||||
# ── 읽기 ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _get_site_status(ctx: ToolContext, args: dict) -> str:
|
|
||||||
_fact, site_service = _services()
|
|
||||||
res = await site_service.get_site(ctx.user, ctx.place_id)
|
|
||||||
site = res.site
|
|
||||||
if site is None or site.published_at is None:
|
|
||||||
return "아직 발행 전입니다. 준비가 되면 발행해 드릴게요."
|
|
||||||
# ★ 주소는 site_payload 의 함수로 만든다. 문자열로 조립하면 canonical 과 갈린다
|
|
||||||
# (CLAUDE.md '슬러그 규칙은 두 곳에 있고 같아야 한다').
|
|
||||||
url = f"{site_payload.publish_origin()}/s/{site_payload.publish_slug(ctx.place, site)}"
|
|
||||||
when = site.published_at.strftime("%Y-%m-%d %H:%M")
|
|
||||||
return f"발행되어 있습니다.\n주소: {url}\n마지막 발행: {when}"
|
|
||||||
|
|
||||||
|
|
||||||
async def _list_facts(ctx: ToolContext, args: dict) -> str:
|
|
||||||
fact_service, _site = _services()
|
|
||||||
res = await fact_service.list_facts(ctx.user, ctx.place_id, publishable_only=True)
|
|
||||||
rows = [f for f in (res.facts or []) if (f.value or "").strip()]
|
|
||||||
schema = get_schema(PlaceCategory(ctx.place.category))
|
|
||||||
keyword = (args.get("keyword") or "").strip()
|
|
||||||
if keyword:
|
|
||||||
rows = [f for f in rows if keyword in f.key or keyword in ((schema.get(f.key).label if schema.get(f.key) else ""))]
|
|
||||||
if not rows:
|
|
||||||
return "저장된 가게 정보가 아직 없습니다." if not keyword else f"'{keyword}' 로 찾은 정보가 없습니다."
|
|
||||||
lines = []
|
|
||||||
for f in rows[:20]:
|
|
||||||
spec = schema.get(f.key)
|
|
||||||
lines.append(f"· {spec.label if spec else f.key}: {f.value}")
|
|
||||||
more = f"\n(그 밖에 {len(rows) - 20}개 더 있습니다)" if len(rows) > 20 else ""
|
|
||||||
return "지금 저장된 정보입니다.\n" + "\n".join(lines) + more
|
|
||||||
|
|
||||||
|
|
||||||
# ── 되돌릴 수 있는 쓰기 ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _set_fact(ctx: ToolContext, args: dict) -> str:
|
|
||||||
key, value = (args.get("key") or "").strip(), (args.get("value") or "").strip()
|
|
||||||
if not key or not value:
|
|
||||||
raise ToolRejected("무엇을 어떤 값으로 바꿀지 알려 주세요.")
|
|
||||||
|
|
||||||
schema = get_schema(PlaceCategory(ctx.place.category))
|
|
||||||
spec = schema.get(key)
|
|
||||||
# ★ LLM 이 없는 key 를 지어낼 수 있다. 스키마가 최종 판정이다.
|
|
||||||
if spec is None:
|
|
||||||
raise ToolRejected("그 항목은 이 가게에서 쓰지 않는 정보라 고칠 수 없어요.")
|
|
||||||
if spec.scope != "place":
|
|
||||||
raise ToolRejected(f"{spec.label} 은 객실·메뉴마다 다른 값이라 대화로는 아직 고칠 수 없어요.")
|
|
||||||
|
|
||||||
fact_service, _site = _services()
|
|
||||||
# ★ FactService 를 그대로 통과시킨다. source_type=OWNER 라 노출값을 즉시 교체하고,
|
|
||||||
# 정정본 잠금·업종 스키마 검증이 전부 거기서 걸린다.
|
|
||||||
res = await fact_service.upsert_fact(
|
|
||||||
ctx.user, ctx.place_id, Req_UpsertFact(key=key, value=value, source_type=SourceType.OWNER)
|
|
||||||
)
|
|
||||||
if not res.result.success:
|
|
||||||
raise ToolRejected("그 값을 저장하지 못했습니다. 형식을 확인해 주세요.")
|
|
||||||
|
|
||||||
# ★ fact 는 바뀌었지만 사이트는 안 바뀐다. 이 한 줄이 빠지면 사장님은 반영된 줄 알고
|
|
||||||
# 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.
|
|
||||||
return f"{spec.label} 을(를) {value} 로 바꿨습니다. 사이트에 반영하려면 다시 발행해야 해요 — 지금 할까요?"
|
|
||||||
|
|
||||||
|
|
||||||
# ── 반쯤 되돌릴 수 있는 것 ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _publish(ctx: ToolContext, args: dict) -> str:
|
|
||||||
_fact, site_service = _services()
|
|
||||||
res = await site_service.start_build(ctx.user, ctx.place_id, Req_StartBuild(publish=True))
|
|
||||||
if not res.result.success:
|
|
||||||
if res.result.code == ErrorType.PLACE_NOT_VERIFIED.value:
|
|
||||||
raise ToolRejected("가게 확인이 끝나지 않아 발행할 수 없어요. 빌더 화면에서 가게 정보를 먼저 확인해 주세요.")
|
|
||||||
raise ToolRejected("발행을 시작하지 못했습니다. 빌더 화면에서 확인해 주세요.")
|
|
||||||
return "발행을 시작했습니다. 1분쯤 걸리고, 끝나면 사이트에 반영됩니다."
|
|
||||||
|
|
||||||
|
|
||||||
class ToolRejected(RuntimeError):
|
|
||||||
"""도구가 실행을 거절했다 — 사장님께 그대로 보여 줄 한국어 문장을 담는다."""
|
|
||||||
|
|
||||||
|
|
||||||
REGISTRY: dict[str, Tool] = {
|
|
||||||
t.name: t
|
|
||||||
for t in [
|
|
||||||
Tool(
|
|
||||||
name="get_site_status",
|
|
||||||
grade=ToolGrade.READ,
|
|
||||||
summary="홈페이지가 발행됐는지, 주소와 마지막 발행 시각을 알려준다.",
|
|
||||||
run=_get_site_status,
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="list_facts",
|
|
||||||
grade=ToolGrade.READ,
|
|
||||||
summary="지금 저장된 가게 정보를 보여준다.",
|
|
||||||
args={"keyword": "찾고 싶은 항목이 있으면 그 말(선택)"},
|
|
||||||
run=_list_facts,
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="set_fact",
|
|
||||||
grade=ToolGrade.REVERSIBLE,
|
|
||||||
summary="가게 정보 한 항목을 고친다. 사이트에 반영되려면 발행이 따로 필요하다.",
|
|
||||||
args={"key": "아래 항목 목록의 key", "value": "바꿀 값"},
|
|
||||||
run=_set_fact,
|
|
||||||
),
|
|
||||||
Tool(
|
|
||||||
name="publish",
|
|
||||||
grade=ToolGrade.SEMI,
|
|
||||||
summary="바뀐 내용을 홈페이지에 반영한다(재발행).",
|
|
||||||
run=_publish,
|
|
||||||
confirm="지금 홈페이지를 다시 발행할까요? 바뀐 내용이 손님에게 보이게 됩니다.",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def describe() -> list[dict]:
|
|
||||||
"""프롬프트에 실을 도구 목록. ★ 등급은 싣지 않는다 — 모델이 알 필요도, 정할 이유도 없다."""
|
|
||||||
return [{"name": t.name, "설명": t.summary, "args": t.args} for t in REGISTRY.values()]
|
|
||||||
|
|
||||||
|
|
||||||
def fields_of(place) -> list[dict]:
|
|
||||||
schema = get_schema(PlaceCategory(place.category))
|
|
||||||
return [
|
|
||||||
{"key": k, "label": spec.label, "type": spec.type}
|
|
||||||
for k, spec in schema.fields.items()
|
|
||||||
if spec.scope == "place"
|
|
||||||
]
|
|
||||||
@ -1,162 +0,0 @@
|
|||||||
"""장애 알림 — 영구 저장 + 재시도 + 중복 억제.
|
|
||||||
|
|
||||||
★ 왜 이 모양인가
|
|
||||||
잡 큐 소진(JobStatus.DEAD) · BUILD 잡의 업무 실패(게이트 반려가 아닌 렌더·인프라 실패) ·
|
|
||||||
노래 같은 곁가지의 부분 실패 · 잡 큐 정체를 Teams 로 알린다. 알림을 만드는 자리(worker/runner.py ·
|
|
||||||
build_service.py · scheduler)는 이 모듈의 send_alert() 하나만 부르면 된다 — 언제 실제로
|
|
||||||
보낼지, 같은 사유를 몇 번이나 다시 보낼지는 전부 여기서 정한다.
|
|
||||||
|
|
||||||
★ 재시도마다 중복 스팸을 내지 않는다 (dedupe)
|
|
||||||
같은 dedupe_key 로 "아직 안 풀린" 알림이 있으면 새로 만들지 않는다 — 잡이 몇 번을 실패하며
|
|
||||||
재큐되든 사람에게는 처음 한 통만 간다. 문제가 사라지면(resolve_alert) 그 dedupe_key 는
|
|
||||||
다시 "풀린" 상태가 되고, 다음에 같은 사유가 또 터지면 새로 알린다.
|
|
||||||
|
|
||||||
★ 영구 저장 + 재시도 (outbox)
|
|
||||||
webhook 전송이 그 자리에서 실패해도(네트워크 순단 등) 알림 자체를 잃지 않는다 — DB 에
|
|
||||||
PENDING 으로 남기고 process_outbox() 가 백오프를 두고 다시 시도한다. 워커·API 프로세스가
|
|
||||||
재시작돼도 이 표만 보면 뭐가 안 나갔는지 안다.
|
|
||||||
|
|
||||||
★ 비밀·개인정보를 남기지 않는다 (scrub)
|
|
||||||
detail 은 저장 **전에** 한 번 걸러진다 — 외부 API 예외 메시지가 쿼리스트링에 키를 실어
|
|
||||||
보내는 경우가 있다(TourAPI·Suno 등). 전화번호·API 키·bearer 토큰·이메일을 마스킹한다.
|
|
||||||
|
|
||||||
★ webhook 미설정이면 조용히 아무 일도 안 한다(teams_webhook.is_configured). 서버는 그대로 뜬다.
|
|
||||||
"""
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.enums import DBType
|
|
||||||
from common.logger import LOG
|
|
||||||
from common.utils.gtime import GTime
|
|
||||||
from crud import alert_crud
|
|
||||||
from crud.job_crud import compute_backoff
|
|
||||||
from services import teams_webhook
|
|
||||||
|
|
||||||
# 중복 억제 창(분). 이 시간 안에 같은 dedupe_key 로 또 send_alert 가 불리면 새로 만들지 않는다.
|
|
||||||
DEDUPE_WINDOW_MIN_ENV = "ALERT_DEDUPE_WINDOW_MIN"
|
|
||||||
DEFAULT_DEDUPE_WINDOW_MIN = 60
|
|
||||||
# 재시도 상한. 소진되면 AlertStatus.FAILED — 더 자동으로는 안 보낸다.
|
|
||||||
MAX_ATTEMPTS = 5
|
|
||||||
|
|
||||||
_DETAIL_MAX_LEN = 2000
|
|
||||||
|
|
||||||
# ── 비밀·개인정보 마스킹 ──────────────────────────────────────────────────
|
|
||||||
_RE_QUERY_SECRET = re.compile(
|
|
||||||
r"(?i)([?&](?:key|token|api[_-]?key|secret|access[_-]?token|auth)=)[^\s&]+"
|
|
||||||
)
|
|
||||||
_RE_BEARER = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9\-_.]{8,}")
|
|
||||||
_RE_KV_SECRET = re.compile(r"(?i)\b(password|passwd|pwd|secret|api[_-]?key)\s*[:=]\s*\S+")
|
|
||||||
_RE_EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
||||||
|
|
||||||
|
|
||||||
def _scrub(text: str) -> str:
|
|
||||||
"""저장 전에 반드시 한 번 거친다. 순서가 중요하다 — 쿼리스트링을 먼저 지워야
|
|
||||||
그 값이 이메일 형태여도 뒤의 이메일 마스킹이 이중으로 손대지 않는다."""
|
|
||||||
if not text:
|
|
||||||
return ""
|
|
||||||
out = _RE_QUERY_SECRET.sub(r"\1***", text)
|
|
||||||
out = _RE_BEARER.sub("Bearer ***", out)
|
|
||||||
out = _RE_KV_SECRET.sub(lambda m: f"{m.group(1)}=***", out)
|
|
||||||
out = _RE_EMAIL.sub(lambda m: m.group(0)[:2] + "***@***", out)
|
|
||||||
return out[:_DETAIL_MAX_LEN]
|
|
||||||
|
|
||||||
|
|
||||||
def _dedupe_window_min() -> int:
|
|
||||||
try:
|
|
||||||
return int(os.environ.get(DEDUPE_WINDOW_MIN_ENV) or DEFAULT_DEDUPE_WINDOW_MIN)
|
|
||||||
except ValueError:
|
|
||||||
return DEFAULT_DEDUPE_WINDOW_MIN
|
|
||||||
|
|
||||||
|
|
||||||
async def send_alert(kind: str, title: str, detail: str = "", dedupe_key: str | None = None) -> None:
|
|
||||||
"""알림을 큐에 넣는다(즉시 보내지 않는다 — process_outbox 가 보낸다).
|
|
||||||
|
|
||||||
★ 즉시 안 보내는 이유: 이 함수는 워커의 실패 처리 경로(예외 발생 지점)에서 불린다.
|
|
||||||
여기서 동기적으로 webhook 을 때리면 그 지연·재시도가 잡 처리 자체를 늦춘다. 큐에
|
|
||||||
적재만 하고 별도 스윕(scheduler)이 실제 전송을 맡는다 — 알림 발송 실패가 발행
|
|
||||||
파이프라인에 영향을 주지 않는다(파일 머리주석의 관심사 분리)."""
|
|
||||||
try:
|
|
||||||
async def _op(session):
|
|
||||||
if dedupe_key:
|
|
||||||
existing = await alert_crud.latest_unresolved(session, dedupe_key)
|
|
||||||
if existing is not None:
|
|
||||||
return # 이미 이 사유로 풀리지 않은 알림이 있다 — 또 만들지 않는다.
|
|
||||||
await alert_crud.insert(session, {
|
|
||||||
"kind": kind[:50],
|
|
||||||
"dedupe_key": dedupe_key[:200] if dedupe_key else None,
|
|
||||||
"title": title[:200],
|
|
||||||
"detail": _scrub(detail),
|
|
||||||
})
|
|
||||||
|
|
||||||
await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _op)
|
|
||||||
except Exception as ex: # noqa: BLE001 — 알림 적재 실패가 원래 하던 일(잡 처리)을 죽이면 안 된다
|
|
||||||
LOG.w(f"[alert] 적재 실패(무시하고 계속): {type(ex).__name__}: {ex}")
|
|
||||||
|
|
||||||
|
|
||||||
async def resolve_alert(dedupe_key: str, title: str, detail: str = "") -> None:
|
|
||||||
"""이 dedupe_key 로 안 풀린 알림이 있으면 "복구됨" 을 한 번 알리고 풀린 것으로 남긴다.
|
|
||||||
|
|
||||||
★ 안 풀린 알림이 없으면(애초에 문제가 없었다) 아무것도 하지 않는다 — 정상 상태마다
|
|
||||||
"복구됨" 을 보내면 그게 새로운 스팸이 된다."""
|
|
||||||
try:
|
|
||||||
async def _op(session):
|
|
||||||
existing = await alert_crud.latest_unresolved(session, dedupe_key)
|
|
||||||
if existing is None:
|
|
||||||
return
|
|
||||||
await alert_crud.mark_resolved(session, existing.alert_id)
|
|
||||||
await alert_crud.insert(session, {
|
|
||||||
"kind": "recovery",
|
|
||||||
"dedupe_key": None, # 복구 알림 자신은 dedupe 대상이 아니다 — 매번 보낸다.
|
|
||||||
"title": title[:200],
|
|
||||||
"detail": _scrub(detail),
|
|
||||||
})
|
|
||||||
|
|
||||||
await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _op)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[alert] 복구 알림 적재 실패(무시하고 계속): {type(ex).__name__}: {ex}")
|
|
||||||
|
|
||||||
|
|
||||||
async def process_outbox(limit: int = 20) -> dict:
|
|
||||||
"""PENDING 알림을 실제로 보낸다. 스케줄러가 주기적으로 부른다(scheduler/jobs.py).
|
|
||||||
|
|
||||||
★ 잡 큐의 백오프·소진 규칙(crud/job_crud.compute_backoff)을 그대로 재사용한다 —
|
|
||||||
"몇 번 실패하면 얼마나 쉬고 언제 포기하나" 를 두 번 설계하지 않는다."""
|
|
||||||
sent = failed = 0
|
|
||||||
try:
|
|
||||||
async def _load(session):
|
|
||||||
return await alert_crud.due_pending(session, limit)
|
|
||||||
|
|
||||||
due = await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _load)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[alert] outbox 조회 실패: {type(ex).__name__}: {ex}")
|
|
||||||
return {"sent": 0, "failed": 0}
|
|
||||||
|
|
||||||
for row in due:
|
|
||||||
ok = await teams_webhook.send(row.title, row.detail or "")
|
|
||||||
|
|
||||||
async def _update(session, row=row, ok=ok):
|
|
||||||
if ok:
|
|
||||||
await alert_crud.mark_sent(session, row.alert_id)
|
|
||||||
else:
|
|
||||||
attempts = row.attempts + 1
|
|
||||||
if attempts >= MAX_ATTEMPTS:
|
|
||||||
await alert_crud.mark_exhausted(session, row.alert_id, attempts)
|
|
||||||
else:
|
|
||||||
next_at = GTime.UTC() + timedelta(seconds=compute_backoff(attempts))
|
|
||||||
await alert_crud.mark_retry(session, row.alert_id, attempts, next_at)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, _update)
|
|
||||||
except Exception as ex: # noqa: BLE001
|
|
||||||
LOG.w(f"[alert] outbox 갱신 실패 {row.alert_id}: {type(ex).__name__}: {ex}")
|
|
||||||
continue
|
|
||||||
if ok:
|
|
||||||
sent += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
if sent or failed:
|
|
||||||
LOG.i(f"[alert] outbox 스윕 — 전송 {sent}건 · 재시도/소진 {failed}건")
|
|
||||||
return {"sent": sent, "failed": failed}
|
|
||||||
@ -76,7 +76,6 @@ class AuthService:
|
|||||||
user_id=str(user.user_id),
|
user_id=str(user.user_id),
|
||||||
id=user.id,
|
id=user.id,
|
||||||
role=user.role,
|
role=user.role,
|
||||||
token_version=user.token_version,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _finish_login(self, user: users) -> Res_Login:
|
async def _finish_login(self, user: users) -> Res_Login:
|
||||||
@ -317,10 +316,6 @@ class AuthService:
|
|||||||
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
|
||||||
return res
|
return res
|
||||||
data["password"] = await GetHashedPW(data["password"])
|
data["password"] = await GetHashedPW(data["password"])
|
||||||
# ★ 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면
|
|
||||||
# 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도
|
|
||||||
# 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다).
|
|
||||||
data["token_version"] = (me.token_version or 1) + 1
|
|
||||||
else:
|
else:
|
||||||
data.pop("password", None)
|
data.pop("password", None)
|
||||||
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
|
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
|
||||||
@ -341,35 +336,8 @@ class AuthService:
|
|||||||
return await self.get_me(user_info)
|
return await self.get_me(user_info)
|
||||||
|
|
||||||
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
|
||||||
"""refresh 토큰 → 새 access 토큰.
|
|
||||||
|
|
||||||
★ 서명·만료만 보고 DB 를 한 번도 안 읽던 자리다 — 비밀번호를 바꾸거나 계정을
|
|
||||||
막아도, 이미 나간 refresh 토큰(7일)은 만료 전까지 계속 새 access 토큰을 찍어냈다.
|
|
||||||
여기서 최신 DB 상태를 한 번 대조한다: 이 토큰의 token_version 이 지금 값과
|
|
||||||
다르면(bump_token_version 이 불렸다는 뜻) 재발급을 거절한다."""
|
|
||||||
res = Res_RefreshToken()
|
res = Res_RefreshToken()
|
||||||
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료).
|
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.
|
||||||
user_info = DecodeRefreshToken(refresh_token)
|
user_info = DecodeRefreshToken(refresh_token)
|
||||||
|
res.access_token = CreateAccessToken(user_info)
|
||||||
err_type, user = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
users.DBType(),
|
|
||||||
DBWRType.DB_READ.value,
|
|
||||||
lambda s: self.user_crud.get_user_by_login_id(s, user_info.id),
|
|
||||||
)
|
|
||||||
if err_type != ErrorType.SUCCESS or user is None:
|
|
||||||
res.result.SetResult(ErrorType.ACCOUNT_NOT_FOUND)
|
|
||||||
return res
|
|
||||||
user: users
|
|
||||||
|
|
||||||
if user.status != UserStatus.ACTIVE.value:
|
|
||||||
res.result.SetResult(ErrorType.ACCOUNT_BLOCKED_USER)
|
|
||||||
return res
|
|
||||||
# ★ 구버전 토큰(token_version 없이 발급됨)은 UserInfo 기본값 1 로 읽힌다 — DB 컬럼
|
|
||||||
# 기본값도 1 이라 배포 직후에는 전부 통과한다. bump 가 불린 뒤에만 갈린다.
|
|
||||||
if user_info.token_version != user.token_version:
|
|
||||||
res.result.SetResult(ErrorType.ACCOUNT_SESSION_REVOKED)
|
|
||||||
return res
|
|
||||||
|
|
||||||
# ★ 최신 DB 값으로 다시 만든다 — role 이 바뀌었으면 그것도 여기서 따라온다.
|
|
||||||
res.access_token = CreateAccessToken(self._user_info(user))
|
|
||||||
return res
|
return res
|
||||||
|
|||||||
@ -22,7 +22,7 @@ def is_configured() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def output_dir() -> Path:
|
def output_dir() -> Path:
|
||||||
return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/solution/site/out"))
|
return Path(os.environ.get("SITE_OUTPUT_DIR", "/app/out/sites"))
|
||||||
|
|
||||||
|
|
||||||
def _content_type(path: Path) -> str:
|
def _content_type(path: Path) -> str:
|
||||||
@ -104,7 +104,7 @@ def _upload_shared(container, root: Path, prefix: str) -> set[str]:
|
|||||||
for entry in sorted(root.iterdir()):
|
for entry in sorted(root.iterdir()):
|
||||||
# `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다.
|
# `s/` 는 사이트별 디렉터리다 — 발행한 사이트 하나만 따로 올린다.
|
||||||
# (여기서 함께 올리면 한 명이 발행할 때마다 전체 사이트를 다시 올리게 된다.)
|
# (여기서 함께 올리면 한 명이 발행할 때마다 전체 사이트를 다시 올리게 된다.)
|
||||||
if entry.name.startswith(".") or entry.name in {SITE_ROOT_DIR, "versions"}:
|
if entry.name.startswith(".") or entry.name == SITE_ROOT_DIR:
|
||||||
continue
|
continue
|
||||||
if entry.is_dir():
|
if entry.is_dir():
|
||||||
uploaded |= _upload_tree(container, root, entry.name, prefix)
|
uploaded |= _upload_tree(container, root, entry.name, prefix)
|
||||||
|
|||||||
@ -1,307 +0,0 @@
|
|||||||
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md
|
|
||||||
|
|
||||||
★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약).
|
|
||||||
★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에
|
|
||||||
못 넣어 중복이 막히지 않는다.
|
|
||||||
★ 팀 사전검수 없음 — 금칙 필터(blog_service.is_publishable_body)를 통과하면 바로 REVIEWED 로
|
|
||||||
쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다.
|
|
||||||
★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼
|
|
||||||
것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17).
|
|
||||||
"""
|
|
||||||
import uuid
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from config import social_config
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import place_posts, places, sites, users
|
|
||||||
from common.enums import DBWRType, PostStatus, SiteStatus
|
|
||||||
from common.logger import LOG
|
|
||||||
from common.models.gmodel import UserInfo
|
|
||||||
from crud.post_crud import PostCRUD
|
|
||||||
from router.v1.validator.dependencies import CreateDayPassToken
|
|
||||||
from services import blog_service, mail_service, site_payload
|
|
||||||
from services.snapshot import build_snapshot
|
|
||||||
|
|
||||||
BATCH_SIZE = 30
|
|
||||||
# 이 수보다 재고(DRAFT)가 적은 업장만 새로 만든다 — 한 달치(하루 한 통 기준 약 30일)를 채운다.
|
|
||||||
REFILL_BELOW = 30
|
|
||||||
MAIL_PER_SWEEP = 20
|
|
||||||
|
|
||||||
_KST = timezone(timedelta(hours=9))
|
|
||||||
|
|
||||||
_crud = PostCRUD()
|
|
||||||
|
|
||||||
|
|
||||||
def _today_kst() -> date:
|
|
||||||
return datetime.now(_KST).date()
|
|
||||||
|
|
||||||
|
|
||||||
async def _published_places() -> list:
|
|
||||||
"""(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인
|
|
||||||
day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다."""
|
|
||||||
def query(session):
|
|
||||||
return session.execute(
|
|
||||||
select(places, users)
|
|
||||||
.join(sites, sites.place_id == places.place_id)
|
|
||||||
.join(users, users.user_id == places.owner_user_id)
|
|
||||||
.where(
|
|
||||||
places.deleted == False, # noqa: E712
|
|
||||||
sites.deleted == False, # noqa: E712
|
|
||||||
sites.status == SiteStatus.PUBLISHED.value,
|
|
||||||
sites.domain.isnot(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
|
|
||||||
return list(result.all()) if result is not None else []
|
|
||||||
|
|
||||||
|
|
||||||
async def _pending_count(place_id) -> int:
|
|
||||||
"""아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다."""
|
|
||||||
def query(session):
|
|
||||||
return session.execute(
|
|
||||||
select(place_posts.post_id).where(
|
|
||||||
place_posts.place_id == place_id,
|
|
||||||
place_posts.status.in_((PostStatus.DRAFT.value, PostStatus.REVIEWED.value)),
|
|
||||||
place_posts.deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
|
|
||||||
return len(result.all()) if result is not None else 0
|
|
||||||
|
|
||||||
|
|
||||||
async def _compose_for_dates(place, dates: list[date]) -> list[dict]:
|
|
||||||
"""날짜마다 그 날짜에 맞는 소재(blog_service.materials(snapshot, d))로 한 편씩 만든다 —
|
|
||||||
세 생성 경로(자동·구간·개별)가 같이 쓴다. 저장은 부르는 쪽이 한다.
|
|
||||||
|
|
||||||
★ 날짜를 먼저 정하고 소재를 고른다(2026-09-23). 예전에는 소재 목록을 순서대로 뽑아 날짜에
|
|
||||||
차례로 붙여서, 글 내용이 배정된 날짜와 무관했다.
|
|
||||||
★ 그 날짜에 맞는 소재가 없으면 그 날짜만 비워 두고 다음 날짜로 간다 — 뒤 날짜엔 축제가 걸릴 수 있다.
|
|
||||||
★ LLM 이 없거나 실패하면(None) 그 자리에서 멈춘다 — 날짜마다 소재를 전부 돌며 헛호출하지 않는다."""
|
|
||||||
used = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
||||||
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
||||||
)
|
|
||||||
used_set = set(used or [])
|
|
||||||
snapshot = await build_snapshot(place)
|
|
||||||
region = site_payload.region_label(place.road_address, place.address)
|
|
||||||
|
|
||||||
rows = []
|
|
||||||
for target in dates:
|
|
||||||
for kind, key, material in blog_service.materials(snapshot, target):
|
|
||||||
if key in used_set:
|
|
||||||
continue
|
|
||||||
generated = await blog_service.generate_one(
|
|
||||||
place_name=place.name, region=region, topic_kind=kind, material=material,
|
|
||||||
used_topics=sorted(used_set), place_category=place.category, post_date=target,
|
|
||||||
)
|
|
||||||
if not generated:
|
|
||||||
return rows
|
|
||||||
body, model = generated
|
|
||||||
ok, reason = blog_service.is_publishable_body(body)
|
|
||||||
used_set.add(key) # 버린 주제도 이번 회차에서 다시 고르지 않는다
|
|
||||||
if not ok:
|
|
||||||
LOG.i(f"[blog] place={place.place_id} {target} 버림 — {reason}")
|
|
||||||
continue
|
|
||||||
rows.append({
|
|
||||||
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
||||||
"scheduled_date": target, "generation_meta": {"model": model},
|
|
||||||
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 팀 사전검수 없음
|
|
||||||
})
|
|
||||||
break
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
async def _generate_for_place(place) -> int:
|
|
||||||
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
|
|
||||||
if await _pending_count(place.place_id) >= REFILL_BELOW:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
latest = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
||||||
lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid),
|
|
||||||
)
|
|
||||||
next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst()
|
|
||||||
rows = await _compose_for_dates(place, [next_date + timedelta(days=i) for i in range(BATCH_SIZE)])
|
|
||||||
if rows:
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
||||||
)
|
|
||||||
return len(rows)
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_drafts() -> int:
|
|
||||||
"""재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다."""
|
|
||||||
made = 0
|
|
||||||
for place, _user in await _published_places():
|
|
||||||
made += await _generate_for_place(place)
|
|
||||||
return made
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_range(place_id: str, start_date: date, end_date: date) -> dict:
|
|
||||||
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다
|
|
||||||
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").
|
|
||||||
재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에
|
|
||||||
상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 —
|
|
||||||
매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 그 날짜에 맞는 소재가 없으면
|
|
||||||
그 날짜는 빈 날짜로 남는다(_compose_for_dates)."""
|
|
||||||
place = None
|
|
||||||
for p, _user in await _published_places():
|
|
||||||
if str(p.place_id) == str(place_id):
|
|
||||||
place = p
|
|
||||||
break
|
|
||||||
if place is None:
|
|
||||||
return {"requested": 0, "created": 0}
|
|
||||||
|
|
||||||
requested = (end_date - start_date).days + 1
|
|
||||||
dates = [start_date + timedelta(days=i) for i in range(requested)]
|
|
||||||
|
|
||||||
_err, existing_rows = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
||||||
lambda s, pid=place.place_id: _crud.list_for_place(s, pid, start_date, end_date + timedelta(days=1)),
|
|
||||||
)
|
|
||||||
taken = {row.scheduled_date for row in existing_rows}
|
|
||||||
empty_dates = [d for d in dates if d not in taken]
|
|
||||||
if not empty_dates:
|
|
||||||
return {"requested": requested, "created": 0}
|
|
||||||
|
|
||||||
rows = await _compose_for_dates(place, empty_dates)
|
|
||||||
if rows:
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
||||||
)
|
|
||||||
return {"requested": requested, "created": len(rows)}
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_one_for_date(place_id: str, target_date: date) -> dict | None:
|
|
||||||
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시:
|
|
||||||
"개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를
|
|
||||||
지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None."""
|
|
||||||
place = None
|
|
||||||
for p, _user in await _published_places():
|
|
||||||
if str(p.place_id) == str(place_id):
|
|
||||||
place = p
|
|
||||||
break
|
|
||||||
if place is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
rows = await _compose_for_dates(place, [target_date])
|
|
||||||
if not rows:
|
|
||||||
return None
|
|
||||||
return await DB_SESSION_MNG.execute_lambda_write(
|
|
||||||
place_posts.DBType(), lambda s, r=rows[0]: _crud.add_one(s, r),
|
|
||||||
) # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
|
|
||||||
|
|
||||||
|
|
||||||
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str:
|
|
||||||
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
|
|
||||||
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
|
|
||||||
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
|
|
||||||
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
|
|
||||||
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
|
|
||||||
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version)
|
|
||||||
auto_token = CreateDayPassToken(user_info)
|
|
||||||
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}"
|
|
||||||
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
|
|
||||||
return (
|
|
||||||
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
|
|
||||||
f"{post.body}\n\n"
|
|
||||||
f"이대로 올리려면(누르면 바로 게재됩니다):\n{approve_link}\n\n"
|
|
||||||
f"고쳐서 올리려면:\n{edit_link}\n\n"
|
|
||||||
f"두 링크 모두 오늘 자정(KST)에 만료됩니다. 그 뒤엔 로그인해서 빌더 앱에서 처리해 주세요.\n"
|
|
||||||
f"— 이 메일은 Web4AI 가 자동으로 보냈습니다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _notify_address(place, user) -> str:
|
|
||||||
# notify_email 이 있으면 그 업장 전용 수신자다 — 없으면 계정 이메일(users.email)로 대체한다
|
|
||||||
# (사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다).
|
|
||||||
return place.notify_email or user.email
|
|
||||||
|
|
||||||
|
|
||||||
def _app_origin() -> str:
|
|
||||||
"""메일의 승인·수정 링크가 향할 곳 — 빌더 앱(과 그 앞의 API)이 사는 오리진.
|
|
||||||
|
|
||||||
★ site_payload.publish_origin() 을 쓰면 안 된다 — 그건 발행된 고객 사이트(/s/<slug>)
|
|
||||||
전용이다. 로컬에선 그게 solution-site 정적 서버(포트 80)라, 메일의 "수정하려면"
|
|
||||||
링크(/blog?...)가 거기로 가서 404 났다(2026-09-21 실측). SNS 알림(notify_service.py)이
|
|
||||||
이미 같은 목적으로 쓰는 SOCIAL_APP_ORIGIN 을 그대로 재사용한다 — 설정을 두 벌 안 둔다.
|
|
||||||
비어 있으면(로컬에서 안 채웠으면) publish_origin() 으로 폴백해 링크가 아예 상대경로로
|
|
||||||
깨지는 것보다는 낫게 한다."""
|
|
||||||
return social_config.get("SOCIAL_APP_ORIGIN") or site_payload.publish_origin()
|
|
||||||
|
|
||||||
|
|
||||||
async def _send_one(place, user, post) -> bool:
|
|
||||||
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다."""
|
|
||||||
token, token_hash, expires = blog_service.issue_token()
|
|
||||||
body = _mail_body(
|
|
||||||
place_name=place.name, post=post, user=user,
|
|
||||||
origin=_app_origin(), approve_token=token,
|
|
||||||
)
|
|
||||||
ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
|
|
||||||
if not ok:
|
|
||||||
return False
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[place_posts.DBType()],
|
|
||||||
[lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)],
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def send_reviewed() -> int:
|
|
||||||
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
|
|
||||||
if not mail_service.is_configured():
|
|
||||||
return 0
|
|
||||||
|
|
||||||
_err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
||||||
lambda s: _crud.due_for_mail(s, PostStatus.REVIEWED.value, _today_kst(), MAIL_PER_SWEEP),
|
|
||||||
)
|
|
||||||
if not rows:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
places_by_id = {str(place.place_id): (place, user) for place, user in await _published_places()}
|
|
||||||
sent = 0
|
|
||||||
for post in rows:
|
|
||||||
target = places_by_id.get(str(post.place_id))
|
|
||||||
if not target:
|
|
||||||
continue
|
|
||||||
place, user = target
|
|
||||||
if not mail_service.is_valid_address(_notify_address(place, user) or ""):
|
|
||||||
continue
|
|
||||||
if await _send_one(place, user, post):
|
|
||||||
sent += 1
|
|
||||||
return sent
|
|
||||||
|
|
||||||
|
|
||||||
async def send_now_for_place(place_id: str) -> dict:
|
|
||||||
"""사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의
|
|
||||||
오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록").
|
|
||||||
'하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다."""
|
|
||||||
if not mail_service.is_configured():
|
|
||||||
return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"}
|
|
||||||
|
|
||||||
place = user = None
|
|
||||||
for p, u in await _published_places():
|
|
||||||
if str(p.place_id) == str(place_id):
|
|
||||||
place, user = p, u
|
|
||||||
break
|
|
||||||
if place is None:
|
|
||||||
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
||||||
|
|
||||||
post = await DB_SESSION_MNG.execute_lambda(
|
|
||||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
||||||
lambda s: _crud.next_due_for_mail(s, place_id, PostStatus.REVIEWED.value, _today_kst()),
|
|
||||||
)
|
|
||||||
if post is None:
|
|
||||||
return {"sent": False, "reason": "NOTHING_DUE"}
|
|
||||||
|
|
||||||
if not mail_service.is_valid_address(_notify_address(place, user) or ""):
|
|
||||||
return {"sent": False, "reason": "NO_VALID_EMAIL"}
|
|
||||||
|
|
||||||
if not await _send_one(place, user, post):
|
|
||||||
return {"sent": False, "reason": "SEND_FAILED"}
|
|
||||||
return {"sent": True, "reason": None}
|
|
||||||
@ -1,271 +0,0 @@
|
|||||||
"""미니 블로그 — AI 자동 포스트. 기획: docs/MINI_BLOG.md
|
|
||||||
|
|
||||||
★ 발행 게이트와 부딪히지 않게 만든다. 규칙 1(미검증 fact 는 화면에 내지 않는다)은 홍보 문구에도
|
|
||||||
그대로 걸린다 — 가격·시간·인원을 문구가 주장하면 그 주장을 뒷받침할 fact 가 없다.
|
|
||||||
프롬프트로 금지하고, 생성 뒤 `is_publishable_body()` 로 한 번 더 거른다.
|
|
||||||
★ 중복은 프롬프트가 아니라 DB 가 막는다 — (place_id, topic_key) 유니크.
|
|
||||||
"""
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
from datetime import date, datetime, timedelta, timezone
|
|
||||||
|
|
||||||
from common.enums import LocalContentType, PlaceCategory, PostStatus, PostTopicKind
|
|
||||||
from common.logger import LOG
|
|
||||||
|
|
||||||
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
|
|
||||||
MIN_LEN = 120
|
|
||||||
MAX_LEN = 170
|
|
||||||
|
|
||||||
_KST = timezone(timedelta(hours=9))
|
|
||||||
|
|
||||||
# 문구가 주장하면 안 되는 것. 게이트가 잡기 전에 여기서 버린다.
|
|
||||||
_FORBIDDEN = (
|
|
||||||
re.compile(r"\d{1,3},\d{3}\s*원"), # 198,000원
|
|
||||||
re.compile(r"\d+\s*원"), # 50000원 · 3만원 은 아래에서
|
|
||||||
re.compile(r"\d+\s*만\s*원"),
|
|
||||||
re.compile(r"\d{1,2}\s*:\s*\d{2}"), # 15:00
|
|
||||||
re.compile(r"\d+\s*시\s*(\d+\s*분)?\s*(부터|까지|에)"),
|
|
||||||
re.compile(r"\d+\s*(인|명)\s*(까지|기준|이상)"),
|
|
||||||
re.compile(r"\d{2,3}-\d{3,4}-\d{4}"), # 전화번호
|
|
||||||
re.compile(r"(무료|공짜)\s*(제공|이용|주차)"),
|
|
||||||
re.compile(r"(최고|최저|1위|유일)"), # 근거를 못 대는 최상급
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_publishable_body(text: str) -> tuple[bool, str]:
|
|
||||||
"""(통과 여부, 사유). 사유는 로그·검수 화면에 그대로 쓴다."""
|
|
||||||
body = (text or "").strip()
|
|
||||||
if not body:
|
|
||||||
return False, "빈 글"
|
|
||||||
if len(body) < MIN_LEN or len(body) > MAX_LEN:
|
|
||||||
return False, f"길이 {len(body)}자 — {MIN_LEN}~{MAX_LEN} 밖"
|
|
||||||
for pattern in _FORBIDDEN:
|
|
||||||
hit = pattern.search(body)
|
|
||||||
if hit:
|
|
||||||
return False, f"확인되지 않은 주장: {hit.group(0)}"
|
|
||||||
return True, ""
|
|
||||||
|
|
||||||
|
|
||||||
def hash_token(token: str) -> str:
|
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def issue_token() -> tuple[str, str, object]:
|
|
||||||
"""(평문, 해시, 만료시각=오늘 자정 KST). 평문은 메일 본문에만 나가고 DB 에는 해시만 둔다.
|
|
||||||
|
|
||||||
★ 승인·수정 두 링크 다 그날까지만 산다(2026-09-17, 사장님 지시: "승인이랑 수정모두
|
|
||||||
자정에 만료"). 그 뒤로는 로그인해서 빌더 앱에서 처리한다 — 메일 링크는 "오늘 온 것을
|
|
||||||
오늘 처리하라"는 뜻이지 보관함이 아니다."""
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
now_kst = datetime.now(_KST)
|
|
||||||
midnight_kst = (now_kst + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
||||||
expires = midnight_kst.astimezone(timezone.utc).replace(tzinfo=None)
|
|
||||||
return token, hash_token(token), expires
|
|
||||||
|
|
||||||
|
|
||||||
# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다.
|
|
||||||
TOPIC_RULES: dict[int, str] = {
|
|
||||||
# ★ 게시일의 실제 날씨는 모른다(글은 며칠·몇 주 앞서 만든다) — "오늘은 비가 옵니다"라고 단정하게 두지 않는다.
|
|
||||||
PostTopicKind.WEATHER.value:
|
|
||||||
"소재로 주어진 날씨인 날, 이 숙소에서 하기 좋은 일을 한 장면으로 적는다. 게시일의 날씨를 단정하지 않는다.",
|
|
||||||
PostTopicKind.FESTIVAL.value:
|
|
||||||
"주어진 축제 하나를 게시일 기준으로(곧 열리는지, 열리는 중인지) 언급하고, 숙소에서 그곳까지 어떻게 가는지를 걸음 단위로 적는다.",
|
|
||||||
PostTopicKind.SEASON.value:
|
|
||||||
"게시일 무렵 절기에 이 지역과 숙소가 어떻게 달라지는지를 적는다.",
|
|
||||||
PostTopicKind.NEARBY.value:
|
|
||||||
"주어진 주변 장소 하나를 손님 시선에서 적는다. 영업시간과 가격은 쓰지 않는다.",
|
|
||||||
PostTopicKind.GUIDE.value:
|
|
||||||
"확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 업종별 분기 — 지금은 숙소만 채워져 있다. 새 업종을 넣으려면 여기 두 딕셔너리에만 항목을 더한다.
|
|
||||||
_BUSINESS_NOUN_BY_CATEGORY: dict[int, str] = {
|
|
||||||
PlaceCategory.LODGING.value: "숙소",
|
|
||||||
}
|
|
||||||
_TOPIC_RULES_BY_CATEGORY: dict[int, dict[int, str]] = {
|
|
||||||
PlaceCategory.LODGING.value: TOPIC_RULES,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _business_noun(place_category: int) -> str:
|
|
||||||
return _BUSINESS_NOUN_BY_CATEGORY.get(place_category, "숙소")
|
|
||||||
|
|
||||||
|
|
||||||
def _topic_rules(place_category: int) -> dict[int, str]:
|
|
||||||
return _TOPIC_RULES_BY_CATEGORY.get(place_category, TOPIC_RULES)
|
|
||||||
|
|
||||||
|
|
||||||
_RULES = (
|
|
||||||
"규칙\n"
|
|
||||||
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n"
|
|
||||||
"- 숫자로 된 요금·시간·인원·전화번호를 쓰지 않는다. 확인되지 않은 주장을 하지 않는다.\n"
|
|
||||||
"- '최고' '유일' 같은 최상급을 쓰지 않는다.\n"
|
|
||||||
"- 손님에게 말하듯 존댓말로 적는다.\n"
|
|
||||||
"- 아래 '이미 쓴 주제'와 겹치는 소재를 고르지 않는다.\n"
|
|
||||||
"- 게시일과 맞지 않는 계절·날씨·행사 이야기를 쓰지 않는다.\n"
|
|
||||||
)
|
|
||||||
|
|
||||||
_WEEKDAYS = "월화수목금토일"
|
|
||||||
|
|
||||||
|
|
||||||
def season_term(on: date) -> str:
|
|
||||||
"""게시일 → 절기 이름(materials 의 계절 소재와 같은 말). 달로만 가른다."""
|
|
||||||
return {
|
|
||||||
3: "봄", 4: "봄", 5: "봄",
|
|
||||||
6: "초여름", 7: "한여름", 8: "한여름",
|
|
||||||
9: "초가을", 10: "늦가을", 11: "늦가을",
|
|
||||||
12: "초겨울", 1: "한겨울", 2: "한겨울",
|
|
||||||
}[on.month]
|
|
||||||
|
|
||||||
|
|
||||||
def _date_line(on: date) -> str:
|
|
||||||
return f"게시일: {on.year}년 {on.month}월 {on.day}일({_WEEKDAYS[on.weekday()]}) · {season_term(on)}\n"
|
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str],
|
|
||||||
place_category: int = PlaceCategory.LODGING.value, post_date: date | None = None) -> str:
|
|
||||||
"""갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다.
|
|
||||||
post_date 가 있으면 게시일을 알려 준다 — 글이 그 날짜의 계절·행사와 맞게 쓰이도록(2026-09-23)."""
|
|
||||||
used = ", ".join(used_topics[:40]) or "없음"
|
|
||||||
noun = _business_noun(place_category)
|
|
||||||
rules = _topic_rules(place_category)
|
|
||||||
return (
|
|
||||||
f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n"
|
|
||||||
f"{_date_line(post_date) if post_date else ''}"
|
|
||||||
f"갈래: {rules.get(topic_kind, '')}\n"
|
|
||||||
f"소재: {material}\n"
|
|
||||||
f"이미 쓴 주제: {used}\n\n"
|
|
||||||
f"{_RULES}\n본문만 출력한다."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]:
|
|
||||||
"""(통과한 것, 버린 것[(본문앞부분, 사유)]). 버린 이유를 세어 프롬프트를 고칠 근거로 남긴다."""
|
|
||||||
kept, dropped = [], []
|
|
||||||
seen_keys = set()
|
|
||||||
for row in rows:
|
|
||||||
ok, reason = is_publishable_body(row.get("body", ""))
|
|
||||||
key = (row.get("topic_key") or "").strip()
|
|
||||||
if not ok:
|
|
||||||
dropped.append((row.get("body", "")[:24], reason))
|
|
||||||
continue
|
|
||||||
if not key:
|
|
||||||
dropped.append((row.get("body", "")[:24], "주제 키가 없다"))
|
|
||||||
continue
|
|
||||||
if key in seen_keys:
|
|
||||||
dropped.append((row.get("body", "")[:24], f"같은 회차에서 주제 중복: {key}"))
|
|
||||||
continue
|
|
||||||
seen_keys.add(key)
|
|
||||||
# 팀 사전검수 없음 — 금칙 필터를 통과하면 그대로 발송 대상이다.
|
|
||||||
kept.append({**row, "topic_key": key, "status": PostStatus.REVIEWED.value})
|
|
||||||
if dropped:
|
|
||||||
LOG.i(f"[blog] 생성분 {len(rows)}건 중 {len(dropped)}건 버림")
|
|
||||||
return kept, dropped
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str,
|
|
||||||
used_topics: list[str], place_category: int = PlaceCategory.LODGING.value,
|
|
||||||
post_date: date | None = None, client=None) -> tuple[str, str] | None:
|
|
||||||
"""(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
|
|
||||||
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시).
|
|
||||||
|
|
||||||
★ 발행 링크는 여기서 붙이지 않는다 — 호출부가 길이 게이트(is_publishable_body/
|
|
||||||
filter_drafts, MIN_LEN~MAX_LEN)를 이 반환값 그대로에 건다. 링크까지 포함해서
|
|
||||||
길이를 재면 정상 문구도 게이트에 걸려 버려진다. 링크는 게이트를 통과한 뒤 호출부가
|
|
||||||
붙인다.
|
|
||||||
|
|
||||||
★ 공급자는 LLM_PROVIDER 설정을 따른다(services/llm/provider.py) — Gemini 로 고정하지
|
|
||||||
않는다. generate_social_post(services/external/gemini_text.py)와 달리 구조화 출력
|
|
||||||
재시도 루프가 없는 단순 텍스트 생성이라 공급자를 가려도 된다."""
|
|
||||||
from services.llm import provider
|
|
||||||
from services.llm.errors import LlmError
|
|
||||||
|
|
||||||
llm = provider.active()
|
|
||||||
if not llm.is_configured():
|
|
||||||
return None
|
|
||||||
|
|
||||||
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
|
|
||||||
material=material, used_topics=used_topics, place_category=place_category,
|
|
||||||
post_date=post_date)
|
|
||||||
owns = client is None
|
|
||||||
if owns:
|
|
||||||
import httpx
|
|
||||||
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
|
||||||
try:
|
|
||||||
result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9)
|
|
||||||
text = result.text.strip()
|
|
||||||
return (text, llm.DEFAULT_MODEL) if text else None
|
|
||||||
except LlmError as error:
|
|
||||||
LOG.w(f"[blog] 생성 실패: {error}")
|
|
||||||
return None
|
|
||||||
finally:
|
|
||||||
if owns:
|
|
||||||
await client.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
# 축제 글을 시작일 며칠 전부터 낼 수 있나. 끝난 축제는 내지 않는다.
|
|
||||||
FESTIVAL_LEAD_DAYS = 14
|
|
||||||
|
|
||||||
# 그 달에 말이 되는 날씨만 소재로 쓴다 — 여름에 "눈인 날" 글이 나가지 않게.
|
|
||||||
_SKIES = ("맑음", "흐림", "비", "안개")
|
|
||||||
_SKIES_BY_MONTH = {12: ("눈",), 1: ("눈",), 2: ("눈",), 6: ("소나기",), 7: ("소나기",), 8: ("소나기",)}
|
|
||||||
|
|
||||||
|
|
||||||
def _ymd(value) -> date | None:
|
|
||||||
digits = "".join(ch for ch in str(value or "") if ch.isdigit())
|
|
||||||
if len(digits) != 8:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date(int(digits[:4]), int(digits[4:6]), int(digits[6:]))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def materials(snapshot: dict, on: date) -> list[tuple[int, str, str]]:
|
|
||||||
"""게시일 on 에 맞는 (갈래, topic_key, 소재). 앞에 있을수록 먼저 고른다 — 축제 → 계절 → 주변 → 날씨.
|
|
||||||
소재가 없는 갈래는 아예 만들지 않는다 — 지어내지 않는다.
|
|
||||||
|
|
||||||
★ 날짜에 맞춘다(2026-09-23). 예전에는 날짜와 무관한 한 줄 목록이라, 9월 날짜에 '한겨울' 글이나
|
|
||||||
이미 끝난 축제 글이 붙을 수 있었다.
|
|
||||||
- 축제: 시작 FESTIVAL_LEAD_DAYS 일 전 ~ 끝나는 날 사이에만. 기간을 모르는 축제는 쓰지 않는다.
|
|
||||||
- 계절: 게시일의 절기 하나. 키에 연도를 넣어 해마다 한 번씩 다시 쓸 수 있다.
|
|
||||||
- 날씨: 그 달에 있을 법한 것만. 키에 연·월을 넣어 달마다 다시 쓸 수 있다.
|
|
||||||
- 주변 장소: 날짜와 무관해 늘 후보다.
|
|
||||||
★ 스냅샷의 지역 정보는 원문 행 목록(snapshot["local"]["contents"])이다. 예전 코드는
|
|
||||||
site_payload 모양(local.festivals·attractions)을 읽어 축제·주변 소재가 늘 비어 있었다."""
|
|
||||||
contents = (snapshot.get("local") or {}).get("contents") or []
|
|
||||||
by_type: dict[int, list[dict]] = {}
|
|
||||||
for row in contents:
|
|
||||||
if isinstance(row, dict):
|
|
||||||
by_type.setdefault(row.get("content_type"), []).append(row)
|
|
||||||
out: list[tuple[int, str, str]] = []
|
|
||||||
|
|
||||||
for row in by_type.get(LocalContentType.FESTIVAL.value, []):
|
|
||||||
body = row.get("body") or {}
|
|
||||||
name = str(body.get("name") or row.get("title") or "").strip()
|
|
||||||
start = _ymd(body.get("eventstartdate"))
|
|
||||||
end = _ymd(body.get("eventenddate")) or start
|
|
||||||
if not name or start is None or not (start - timedelta(days=FESTIVAL_LEAD_DAYS) <= on <= end):
|
|
||||||
continue
|
|
||||||
period = f"{start:%Y.%m.%d}" + (f" ~ {end:%Y.%m.%d}" if end != start else "")
|
|
||||||
detail = f"{body.get('location') or ''} {str(body.get('overview') or '')[:300]}".strip()
|
|
||||||
out.append((PostTopicKind.FESTIVAL.value, f"festival:{start.year}:{name}"[:120],
|
|
||||||
f"{name} (기간 {period}) — {detail}".strip(" —")))
|
|
||||||
|
|
||||||
term = season_term(on)
|
|
||||||
out.append((PostTopicKind.SEASON.value, f"season:{on.year}:{term}", term))
|
|
||||||
|
|
||||||
spots = (by_type.get(LocalContentType.ATTRACTION.value, [])
|
|
||||||
+ by_type.get(LocalContentType.RESTAURANT.value, []))
|
|
||||||
for row in spots[:20]:
|
|
||||||
body = row.get("body") or {}
|
|
||||||
name = str(body.get("name") or row.get("title") or "").strip()
|
|
||||||
if name:
|
|
||||||
detail = body.get("description") or body.get("overview") or body.get("location") or ""
|
|
||||||
out.append((PostTopicKind.NEARBY.value, f"nearby:{name}"[:120], f"{name} — {detail}".strip(" —")))
|
|
||||||
|
|
||||||
for sky in _SKIES + _SKIES_BY_MONTH.get(on.month, ()):
|
|
||||||
out.append((PostTopicKind.WEATHER.value, f"weather:{on:%Y-%m}:{sky}", f"{sky}인 날"))
|
|
||||||
|
|
||||||
return out
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다.
|
|
||||||
|
|
||||||
★ 저장하지 않는다. place_id 로 받는 사람만 찾고, 나머지는 전부 메일 본문으로 나간다.
|
|
||||||
★ 발행된 사이트의 업장만 받는다 — place_id 를 손으로 바꿔 아무 업장에나 메일을 쏘는 길을 막는다.
|
|
||||||
"""
|
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
|
||||||
from common.database.model.models import places, sites, users
|
|
||||||
from common.enums import DBWRType, SiteStatus
|
|
||||||
from common.logger import LOG
|
|
||||||
from services import mail_service
|
|
||||||
|
|
||||||
NOT_AVAILABLE = "지금은 온라인 예약 요청을 받을 수 없습니다. 전화로 문의해 주세요."
|
|
||||||
SENT = "예약 요청을 보냈습니다. 사장님이 확인 후 연락드립니다."
|
|
||||||
|
|
||||||
|
|
||||||
class BookingRequestService:
|
|
||||||
async def send(self, body):
|
|
||||||
from router.v1.site.booking_request import ResBookingRequest
|
|
||||||
|
|
||||||
target = await self._target(body.place_id)
|
|
||||||
if not target:
|
|
||||||
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
|
|
||||||
|
|
||||||
place_name, owner_email = target
|
|
||||||
if not mail_service.is_configured() or not mail_service.is_valid_address(owner_email):
|
|
||||||
LOG.w("[booking-request] 받는 주소나 SMTP 설정이 없어 전달하지 못했다")
|
|
||||||
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
|
|
||||||
|
|
||||||
reply_to = body.email if body.email and mail_service.is_valid_address(body.email) else None
|
|
||||||
ok = mail_service.send(
|
|
||||||
to=owner_email,
|
|
||||||
subject=f"[예약 요청] {place_name} — {body.name} 님",
|
|
||||||
text=_body(place_name, body),
|
|
||||||
reply_to=reply_to,
|
|
||||||
)
|
|
||||||
if not ok:
|
|
||||||
return ResBookingRequest(success=False, message=NOT_AVAILABLE)
|
|
||||||
|
|
||||||
LOG.i(f"[booking-request] 전달 완료 place={body.place_id}")
|
|
||||||
return ResBookingRequest(success=True, message=SENT)
|
|
||||||
|
|
||||||
async def _target(self, place_id) -> tuple[str, str] | None:
|
|
||||||
"""(상호명, 사장님 이메일). 발행된 사이트가 없으면 None."""
|
|
||||||
def query(session):
|
|
||||||
return session.execute(
|
|
||||||
select(places.name, users.email)
|
|
||||||
.join(sites, sites.place_id == places.place_id)
|
|
||||||
.join(users, users.user_id == places.owner_user_id)
|
|
||||||
.where(
|
|
||||||
places.place_id == place_id,
|
|
||||||
places.deleted == False, # noqa: E712
|
|
||||||
sites.deleted == False, # noqa: E712
|
|
||||||
sites.status == SiteStatus.PUBLISHED.value,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
|
|
||||||
row = result.first() if result is not None else None
|
|
||||||
if not row or not row[1]:
|
|
||||||
return None
|
|
||||||
return str(row[0] or ""), str(row[1])
|
|
||||||
|
|
||||||
|
|
||||||
def _body(place_name: str, body) -> str:
|
|
||||||
lines = [
|
|
||||||
f"{place_name} 예약 요청이 도착했습니다.",
|
|
||||||
"",
|
|
||||||
f"성함 {body.name}",
|
|
||||||
f"연락처 {body.phone}",
|
|
||||||
]
|
|
||||||
if body.email:
|
|
||||||
lines.append(f"이메일 {body.email}")
|
|
||||||
if body.stay:
|
|
||||||
lines.append(f"일정 {body.stay}")
|
|
||||||
if body.guests:
|
|
||||||
lines.append(f"인원 {body.guests}")
|
|
||||||
if body.message:
|
|
||||||
lines += ["", "요청사항", body.message.strip()]
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
"— 이 메일은 발행 사이트의 예약 요청 폼에서 자동으로 보냈습니다.",
|
|
||||||
" 예약이 확정된 것은 아니며, 손님에게 직접 연락하셔야 합니다.",
|
|
||||||
]
|
|
||||||
return "\n".join(lines)
|
|
||||||
@ -14,9 +14,7 @@ import uuid
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import (
|
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites
|
||||||
place_channels, place_posts, place_reviews, places, site_publish_logs, site_versions, sites,
|
|
||||||
)
|
|
||||||
from common.enums import (
|
from common.enums import (
|
||||||
BuildStatus,
|
BuildStatus,
|
||||||
DBWRType,
|
DBWRType,
|
||||||
@ -31,13 +29,11 @@ from common.logger import LOG
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.site_crud import SiteCRUD
|
from crud.site_crud import SiteCRUD
|
||||||
from crud.place_crud import PlaceCRUD
|
from crud.place_crud import PlaceCRUD
|
||||||
from crud.post_crud import PostCRUD
|
|
||||||
from services import (
|
from services import (
|
||||||
alert_service,
|
|
||||||
azure_static,
|
azure_static,
|
||||||
indexnow,
|
indexnow,
|
||||||
publish_gate,
|
publish_gate,
|
||||||
render_service,
|
render_report,
|
||||||
seo_keywords,
|
seo_keywords,
|
||||||
site_payload,
|
site_payload,
|
||||||
site_thumbnail,
|
site_thumbnail,
|
||||||
@ -46,36 +42,17 @@ from services import (
|
|||||||
from services.local_content_service import LocalContentService
|
from services.local_content_service import LocalContentService
|
||||||
from services.site_payload import emit_payload
|
from services.site_payload import emit_payload
|
||||||
from services.snapshot import build_snapshot
|
from services.snapshot import build_snapshot
|
||||||
from common.job_errors import PermanentJobError
|
|
||||||
|
|
||||||
_site_crud = SiteCRUD()
|
_site_crud = SiteCRUD()
|
||||||
_place_crud = PlaceCRUD()
|
_place_crud = PlaceCRUD()
|
||||||
_post_crud = PostCRUD()
|
|
||||||
|
|
||||||
|
# 렌더러가 이 버전을 굽고 보고서를 돌려줄 때까지 기다리는 시간.
|
||||||
async def _stamp_reviews(session, place_id, version_id):
|
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 대기 초과"로 실패한다 —
|
||||||
from sqlalchemy import update
|
# 렌더러는 payload 를 2초 주기로 보고, 사이트 하나 굽는 데 1초 남짓 걸린다.
|
||||||
|
|
||||||
from common.enums import ReviewStatus
|
|
||||||
|
|
||||||
await session.execute(
|
|
||||||
update(place_reviews)
|
|
||||||
.where(
|
|
||||||
place_reviews.place_id == place_id,
|
|
||||||
place_reviews.status == ReviewStatus.PUBLISHED.value,
|
|
||||||
place_reviews.published_version_id.is_(None),
|
|
||||||
)
|
|
||||||
.values(published_version_id=version_id)
|
|
||||||
)
|
|
||||||
return ErrorType.SUCCESS
|
|
||||||
|
|
||||||
# 렌더러 subprocess 가 끝나기를 기다리는 시간(사진 내려받기 포함).
|
|
||||||
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을
|
|
||||||
# 내려받는 발행은 몇 초가 더 걸린다(mirrorMedia, prerender.ts).
|
|
||||||
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
|
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
|
||||||
|
|
||||||
|
|
||||||
class BuildAborted(PermanentJobError):
|
class BuildAborted(RuntimeError):
|
||||||
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
|
||||||
|
|
||||||
|
|
||||||
@ -96,13 +73,10 @@ async def ensure_site(place_id: str) -> "sites":
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
async def load_channel_links(place_id: str) -> list:
|
async def _load_links(place_id: str) -> list:
|
||||||
"""채널 링크(야놀자·네이버 플레이스·인스타…).
|
"""채널 링크(야놀자·네이버 플레이스·인스타…).
|
||||||
|
|
||||||
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다 — 재빌드(run_build)
|
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다.
|
||||||
도 롤백(rollback_service.run_rollback)도 같은 함수를 쓴다. **스냅샷에는 안 싣는다** —
|
|
||||||
롤백이 옛 스냅샷으로 다시 구워도 링크는 항상 지금 확정된 것을 보여줘야 한다(끊긴 링크를
|
|
||||||
옛 버전째 되살리면 안 된다).
|
|
||||||
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
|
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
|
||||||
err, rows = await DB_SESSION_MNG.execute_lambda(
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
||||||
place_channels.DBType(),
|
place_channels.DBType(),
|
||||||
@ -177,15 +151,6 @@ async def run_build(job: dict) -> dict:
|
|||||||
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
|
||||||
song_result = {"error": f"{type(ex).__name__}: {ex}"}
|
song_result = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
|
LOG.w(f"[build] place={place_id} 노래 실패(노래 없이 발행): {type(ex).__name__}: {ex}")
|
||||||
# ★ 발행 자체는 계속되므로(사이트는 노래 없이 나간다) 이건 REJECTED 도 FAILED 도
|
|
||||||
# 아니다 — 별도 종류(partial_failure)로 알린다. 발행이 실패한 게 아니라는 걸
|
|
||||||
# 운영자가 첫 줄만 보고 알아야 한다.
|
|
||||||
await alert_service.send_alert(
|
|
||||||
kind="partial_failure",
|
|
||||||
title=f"노래 생성 실패(발행은 계속) — {place_id}",
|
|
||||||
detail=f"place_id={place_id}\n{song_result['error']}",
|
|
||||||
dedupe_key=f"song_failed:{place_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
# ★ 일정(LLM)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
|
||||||
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
|
||||||
@ -257,15 +222,6 @@ async def run_build(job: dict) -> dict:
|
|||||||
result["build_status"] = "FAILED"
|
result["build_status"] = "FAILED"
|
||||||
result["error"] = reason
|
result["error"] = reason
|
||||||
LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}")
|
LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}")
|
||||||
# ★ 게이트 반려(gate is not None)는 알리지 않는다 — 사장님이 값을 안 채웠다고
|
|
||||||
# 운영자를 부르면 안 된다. 여기서 알리는 건 렌더·인프라가 죽은 "업무 실패"뿐이다.
|
|
||||||
if gate is None:
|
|
||||||
await alert_service.send_alert(
|
|
||||||
kind="build_failed",
|
|
||||||
title=f"발행 실패 — {place_name or place_id}",
|
|
||||||
detail=f"place_id={place_id} v{version_no}\n{reason}",
|
|
||||||
dedupe_key=f"build_failed:{place_id}",
|
|
||||||
)
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
|
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
|
||||||
@ -293,25 +249,24 @@ async def run_build(job: dict) -> dict:
|
|||||||
site.status = SiteStatus.PUBLISHED.value
|
site.status = SiteStatus.PUBLISHED.value
|
||||||
site.published_at = site.published_at or now
|
site.published_at = site.published_at or now
|
||||||
|
|
||||||
links = await load_channel_links(place_id)
|
links = await _load_links(place_id)
|
||||||
payload_path = await emit_payload(place, snapshot, site, version, links, publish=want_publish)
|
payload_path = await emit_payload(place, snapshot, site, version, links)
|
||||||
if not payload_path:
|
if not payload_path:
|
||||||
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
|
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
|
||||||
result["payload_path"] = payload_path
|
result["payload_path"] = payload_path
|
||||||
|
|
||||||
slug = site_payload.publish_slug(place, site)
|
slug = site_payload.publish_slug(place, site)
|
||||||
|
|
||||||
# ---- 렌더러를 직접 돌린다 ----
|
# ---- 렌더 결과를 기다린다 ----
|
||||||
# ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
|
# ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
|
||||||
# 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
|
# 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
|
||||||
# ★ payload=False(미발행)면 렌더러는 `out/versions/<slug>/<version>/` 에만 굽고 공개
|
report = await render_report.wait_for(slug, version_no, RENDER_TIMEOUT_SEC)
|
||||||
# 주소(`out/s/<slug>`)는 건드리지 않는다 — emit_payload 에 실은 publish 플래그가 정한다.
|
if report is None:
|
||||||
try:
|
|
||||||
report = await render_service.render_site(payload_path, version_no, RENDER_TIMEOUT_SEC)
|
|
||||||
except render_service.RenderFailed as ex:
|
|
||||||
# ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
|
# ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
|
||||||
# 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
|
# 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
|
||||||
return await _fail(str(ex))
|
return await _fail(
|
||||||
|
f"렌더 결과를 {RENDER_TIMEOUT_SEC}초 안에 받지 못했다 — 렌더러가 도는지 확인이 필요하다"
|
||||||
|
)
|
||||||
|
|
||||||
mismatches = list(report.get("mismatches") or [])
|
mismatches = list(report.get("mismatches") or [])
|
||||||
# ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
|
# ★ None(재지 못했다)과 0(재 봤더니 0건)을 뭉개지 않는다. 게이트는 raw 를 보고,
|
||||||
@ -353,9 +308,8 @@ async def run_build(job: dict) -> dict:
|
|||||||
thumbnail_url = None
|
thumbnail_url = None
|
||||||
if want_publish:
|
if want_publish:
|
||||||
try:
|
try:
|
||||||
await render_service.activate_site(slug, version_no)
|
|
||||||
azure_result = await azure_static.publish(slug)
|
azure_result = await azure_static.publish(slug)
|
||||||
except (azure_static.AzurePublishError, render_service.RenderFailed) as ex:
|
except azure_static.AzurePublishError as ex:
|
||||||
return await _fail(str(ex), None, stamp)
|
return await _fail(str(ex), None, stamp)
|
||||||
if azure_result:
|
if azure_result:
|
||||||
result["azure"] = azure_result
|
result["azure"] = azure_result
|
||||||
@ -388,10 +342,6 @@ async def run_build(job: dict) -> dict:
|
|||||||
)
|
)
|
||||||
result["build_status"] = "BUILT"
|
result["build_status"] = "BUILT"
|
||||||
result["routes"] = report.get("routes")
|
result["routes"] = report.get("routes")
|
||||||
# ★ 빌드가 렌더·인프라 실패 없이 끝났다 — 직전에 build_failed 알림이 안 풀린 채 있었으면
|
|
||||||
# 지금 풀렸다는 뜻이다(정상 발행이 재개됐다). 알림이 없었으면 resolve_alert 가 조용히
|
|
||||||
# 아무것도 안 한다(파일 머리주석).
|
|
||||||
await alert_service.resolve_alert(f"build_failed:{place_id}", f"발행 재개 — {place_name or place_id}")
|
|
||||||
|
|
||||||
if want_publish:
|
if want_publish:
|
||||||
# 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아
|
# 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아
|
||||||
@ -415,16 +365,6 @@ async def run_build(job: dict) -> dict:
|
|||||||
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
|
s, uuid.UUID(owner_user_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
# 승인된 미니 블로그 글은 이 굽기에 실렸다 — 이제 게재로 넘긴다(docs/MINI_BLOG.md).
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[place_posts.DBType()],
|
|
||||||
[lambda s: _post_crud.mark_published(s, uuid.UUID(place_id), version.site_version_id)],
|
|
||||||
)
|
|
||||||
# 검수를 통과한 후기도 이 버전에 실렸다 — 어느 굽기에 들어갔는지 남긴다.
|
|
||||||
await DB_SESSION_MNG.execute_lambda_run(
|
|
||||||
[place_reviews.DBType()],
|
|
||||||
[lambda s: _stamp_reviews(s, uuid.UUID(place_id), version.site_version_id)],
|
|
||||||
)
|
|
||||||
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
|
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
|
||||||
payload.get("requested_by"))
|
payload.get("requested_by"))
|
||||||
site.status = SiteStatus.PUBLISHED.value
|
site.status = SiteStatus.PUBLISHED.value
|
||||||
@ -443,3 +383,4 @@ async def run_build(job: dict) -> dict:
|
|||||||
version.build_status = BuildStatus.BUILT.value
|
version.build_status = BuildStatus.BUILT.value
|
||||||
version.built_at = now
|
version.built_at = now
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,6 @@
|
|||||||
import re
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from common import collect_diagnostics
|
|
||||||
from common.database.db_session_manager import DB_SESSION_MNG
|
from common.database.db_session_manager import DB_SESSION_MNG
|
||||||
from common.database.model.models import place_facts as facts_model
|
from common.database.model.models import place_facts as facts_model
|
||||||
from common.database.model.models import place_photos, place_channels, places, place_units
|
from common.database.model.models import place_photos, place_channels, places, place_units
|
||||||
@ -18,16 +17,14 @@ from common.category_schema import get_schema
|
|||||||
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
|
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
|
||||||
from services.collector import yanolja_adapter
|
from services.collector import yanolja_adapter
|
||||||
from services.external import naver_place_lookup, perplexity, tour_lookup
|
from services.external import naver_place_lookup, perplexity, tour_lookup
|
||||||
from services.llm import provider
|
|
||||||
from services.fact_service import FactService
|
from services.fact_service import FactService
|
||||||
from router.v1.fact.protocol import Req_UpsertFact
|
from router.v1.fact.protocol import Req_UpsertFact
|
||||||
from common.job_errors import PermanentJobError
|
|
||||||
|
|
||||||
_place_crud = PlaceCRUD()
|
_place_crud = PlaceCRUD()
|
||||||
_fact_crud = FactCRUD()
|
_fact_crud = FactCRUD()
|
||||||
|
|
||||||
|
|
||||||
class CollectAborted(PermanentJobError):
|
class CollectAborted(RuntimeError):
|
||||||
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남아 운영자가 본다."""
|
||||||
|
|
||||||
|
|
||||||
@ -126,7 +123,7 @@ async def discover_official_site(place, place_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
candidates = await client.search_local(query)
|
candidates = await client.search_local(query)
|
||||||
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
||||||
collect_diagnostics.note_issue("naver_local_search", query, ex)
|
LOG.w(f"[collect] 지역검색 실패(계속) {query!r}: {type(ex).__name__}: {ex}")
|
||||||
return "not_found"
|
return "not_found"
|
||||||
|
|
||||||
match = naver_client.pick_match(place.name, candidates, address)
|
match = naver_client.pick_match(place.name, candidates, address)
|
||||||
@ -170,7 +167,7 @@ async def discover_tour_api(place, place_id: str) -> str:
|
|||||||
longitude=place.longitude,
|
longitude=place.longitude,
|
||||||
)
|
)
|
||||||
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
||||||
collect_diagnostics.note_issue("tour_api", place.name, ex)
|
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
@ -217,7 +214,7 @@ async def discover_yanolja(place, place_id: str) -> str:
|
|||||||
try:
|
try:
|
||||||
found = await yanolja_adapter.search_by_address(address)
|
found = await yanolja_adapter.search_by_address(address)
|
||||||
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
||||||
collect_diagnostics.note_issue("yanolja_search", place.name, ex)
|
LOG.w(f"[collect] 야놀자 검색 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
return "error"
|
return "error"
|
||||||
|
|
||||||
if not found:
|
if not found:
|
||||||
@ -256,7 +253,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
|||||||
if stat["naver_place"] == "resolved":
|
if stat["naver_place"] == "resolved":
|
||||||
stat["discovered"] += 1
|
stat["discovered"] += 1
|
||||||
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
|
||||||
collect_diagnostics.note_issue("naver_place", place.name, ex)
|
LOG.w(f"[collect] 네이버 플레이스 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["naver_place"] = "error"
|
stat["naver_place"] = "error"
|
||||||
|
|
||||||
# TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다.
|
# TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다.
|
||||||
@ -266,7 +263,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
|||||||
if stat["tour_api"] == "resolved":
|
if stat["tour_api"] == "resolved":
|
||||||
stat["discovered"] += 1
|
stat["discovered"] += 1
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
collect_diagnostics.note_issue("tour_api", place.name, ex)
|
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["tour_api"] = "error"
|
stat["tour_api"] = "error"
|
||||||
|
|
||||||
# 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석).
|
# 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석).
|
||||||
@ -276,7 +273,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
|||||||
if stat["official_site"] == "resolved":
|
if stat["official_site"] == "resolved":
|
||||||
stat["discovered"] += 1
|
stat["discovered"] += 1
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
collect_diagnostics.note_issue("official_site", place.name, ex)
|
LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["official_site"] = "error"
|
stat["official_site"] = "error"
|
||||||
|
|
||||||
# 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고).
|
# 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고).
|
||||||
@ -286,7 +283,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
|||||||
if stat["yanolja"] == "resolved":
|
if stat["yanolja"] == "resolved":
|
||||||
stat["discovered"] += 1
|
stat["discovered"] += 1
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
collect_diagnostics.note_issue("yanolja", place.name, ex)
|
LOG.w(f"[collect] 야놀자 조회 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["yanolja"] = "error"
|
stat["yanolja"] = "error"
|
||||||
|
|
||||||
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
|
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
|
||||||
@ -311,7 +308,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
|
|||||||
return stat
|
return stat
|
||||||
except perplexity.PerplexityError as ex:
|
except perplexity.PerplexityError as ex:
|
||||||
# ★ 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다.
|
# ★ 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다.
|
||||||
collect_diagnostics.note_issue("perplexity_discover", place.name, ex)
|
LOG.w(f"[collect] URL 발견 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
stat["error"] = str(ex)[:200]
|
stat["error"] = str(ex)[:200]
|
||||||
return stat
|
return stat
|
||||||
|
|
||||||
@ -412,7 +409,7 @@ async def coverage(place, place_id: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def fetch_one(link, category=None):
|
async def fetch_one(link):
|
||||||
"""링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다."""
|
"""링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다."""
|
||||||
try:
|
try:
|
||||||
adapter = REGISTRY.get_adapter(link.url)
|
adapter = REGISTRY.get_adapter(link.url)
|
||||||
@ -421,12 +418,12 @@ async def fetch_one(link, category=None):
|
|||||||
LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}")
|
LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}")
|
||||||
return None, "no_adapter"
|
return None, "no_adapter"
|
||||||
try:
|
try:
|
||||||
source = await adapter.fetch(link.url, category)
|
source = await adapter.fetch(link.url)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
collect_diagnostics.note_issue("fetch", link.url, ex)
|
LOG.w(f"[collect] 수집 실패(계속) {link.url}: {type(ex).__name__}: {ex}")
|
||||||
return None, "failed"
|
return None, "failed"
|
||||||
if not source.ok:
|
if not source.ok:
|
||||||
collect_diagnostics.note_issue("fetch", link.url, RuntimeError(source.error))
|
LOG.w(f"[collect] 수집 실패(계속) {link.url}: {source.error}")
|
||||||
return None, "failed"
|
return None, "failed"
|
||||||
return source, "fetched"
|
return source, "fetched"
|
||||||
|
|
||||||
@ -552,15 +549,6 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
|
|||||||
# ---- 오케스트레이션 --------------------------------------------------------
|
# ---- 오케스트레이션 --------------------------------------------------------
|
||||||
async def run_collect(job: dict) -> dict:
|
async def run_collect(job: dict) -> dict:
|
||||||
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
|
"""COLLECT 잡 핸들러. 반환값이 jobs.result 에 저장돼 폴링·감사에 쓰인다."""
|
||||||
with collect_diagnostics.collecting():
|
|
||||||
result = await _run_collect(job)
|
|
||||||
issues = collect_diagnostics.snapshot()
|
|
||||||
if issues:
|
|
||||||
result["issues"] = issues
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_collect(job: dict) -> dict:
|
|
||||||
payload = job["payload"]
|
payload = job["payload"]
|
||||||
place_id = payload["place_id"]
|
place_id = payload["place_id"]
|
||||||
owner_user_id = payload["owner_user_id"]
|
owner_user_id = payload["owner_user_id"]
|
||||||
@ -638,7 +626,7 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
|
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
|
||||||
break
|
break
|
||||||
|
|
||||||
source, outcome = await fetch_one(link, PlaceCategory(place.category))
|
source, outcome = await fetch_one(link)
|
||||||
fetch_stat[outcome] += 1
|
fetch_stat[outcome] += 1
|
||||||
if source is None:
|
if source is None:
|
||||||
continue
|
continue
|
||||||
@ -693,7 +681,7 @@ async def _run_collect(job: dict) -> dict:
|
|||||||
from services import place_research
|
from services import place_research
|
||||||
result["research"] = await place_research.research_place(place, place_id)
|
result["research"] = await place_research.research_place(place, place_id)
|
||||||
except Exception as ex: # noqa: BLE001
|
except Exception as ex: # noqa: BLE001
|
||||||
collect_diagnostics.note_issue("place_research", place.name, ex)
|
LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}")
|
||||||
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
|
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
|
||||||
|
|
||||||
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
|
||||||
@ -753,7 +741,7 @@ async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None:
|
|||||||
from services.job_service import enqueue_job
|
from services.job_service import enqueue_job
|
||||||
|
|
||||||
if not gemini.is_configured():
|
if not gemini.is_configured():
|
||||||
LOG.i(f"[collect] {provider.missing_key()} 미설정 — 사진 분석 건너뜀(사진은 확인 큐에 남는다)")
|
LOG.i("[collect] GEMINI_API_KEY 미설정 — 사진 분석 건너뜀(사진은 확인 큐에 남는다)")
|
||||||
return None
|
return None
|
||||||
job_id, _created = await enqueue_job(
|
job_id, _created = await enqueue_job(
|
||||||
JobQueue(), JobType.VISION,
|
JobQueue(), JobType.VISION,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Protocol, runtime_checkable
|
from typing import Optional, Protocol, runtime_checkable
|
||||||
|
|
||||||
from common.enums import LinkChannel, PlaceCategory
|
from common.enums import LinkChannel
|
||||||
|
|
||||||
|
|
||||||
# ---- 도메인 예외 -----------------------------------------------------------
|
# ---- 도메인 예외 -----------------------------------------------------------
|
||||||
@ -154,4 +154,4 @@ class SourceAdapter(Protocol):
|
|||||||
|
|
||||||
def can_handle(self, url: str) -> bool: ...
|
def can_handle(self, url: str) -> bool: ...
|
||||||
|
|
||||||
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: ...
|
async def fetch(self, url: str) -> RawSource: ...
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user