Compare commits

...

70 Commits

Author SHA1 Message Date
79962b93e2 Merge branch 'feature/site-features-and-mockup' into main 2026-09-23 13:19:19 +09:00
5e2def200b [chore] site/mockup: 번들 갱신·문서 정리 · 2안(사진) 빌더 스크립트 추가
- vendor/ 옛 해시 번들을 retired/ 로 옮기고 새 해시로 교체
- build_photo6.py 신설 — /s/stay6 "2안(사진)" 판, 슬러그별로 다시 구울 수 있다
  (python3 build_photo6.py <slug>)
- build_pension.py·build_reading.py·build_stay6.py·patch_stay.py 정리
- AUTOPLAY.md·README.md·audit-all.mjs 갱신
2026-09-23 13:18:51 +09:00
a74f7918c6 [feat] solution/site: "고택" 템플릿 레이아웃 신설 — stay2 DOM 이식 1차
기존 template-look 시스템(색·서체 토큰)만으로는 stay2 실물과 구조 자체가 달랐다.
reservation/oasi/studio/pastel/editorial 다섯 레이아웃과 같은 자리(Shell·Hero·Rooms·
SectionHead)에 paper 를 여섯 번째로 추가 — layoutOf(templateId) 로 갈아 끼운다.

- layouts/paper/{Shell,Hero,Rooms,SectionHead}.tsx 신설
  Shell: 세리프 워드마크+미니플레이어(SongPlayer)+전화, 앵커 탭 헤더, 얇은 텍스트 푸터
  Hero: 풀블리드 회전 캐러셀(HeroPension 뼈대) + 왼쪽 정렬 카피, 가격띠 없음
  Rooms: 유닛마다 전체폭 판 + 캐러셀(카드 격자 아님)
- lib/layout.ts: LayoutId 에 'paper', stay/restaurant/cafe-paper → paper 매핑
- App.tsx·HeroSection.tsx·UnitsSection.tsx·lib/ui/Section.tsx: 기존 5분기에 paper 추가

★ 실물 대조 결과 아직 stay2 와 다르다(사장님 지적) — 탭 개수(4개 vs 지금 섹션 수만큼),
  히어로가 여백 있는 박스가 아니라 풀블리드, 객실 카드가 2열 나란히가 아니라 전체폭
  세로 배치, 영문 눈썹 라벨 없음. 다음 커밋에서 이어서 맞춘다.

★ 배포 함정 실측: prerender.ts 의 "성공한 버전은 불변" 규칙 때문에, 같은 site_version 은
  렌더러 코드를 바꿔도 재빌드 때 옛 HTML 을 그대로 재사용한다 — 버전 캐시를 지워야
  강제로 다시 구워진다(운영 영향은 별도 확인 필요).
2026-09-23 13:18:21 +09:00
b7b8cb856c [fix] solution/site: 흐린 보조 글자 대비 올림 · 예약 달력 기본으로 펼침
여러 섹션의 opacity-55~70 짜리 보조 텍스트가 밝은 배경에서 너무 흐렸다 — 85~100 으로
올림(Carousel 카운터·SectionHead 리드문·SNS 소식·주변 안내 거리·공식 채널 링크).
StayBookingDemo: "날짜·시간 선택" 토글을 없애고 달력을 기본으로 펼쳐 보여준다 —
접어 두면 손님이 그 버튼을 눌러야만 날짜를 볼 수 있었다.
2026-09-23 13:18:02 +09:00
362c76d6c9 [fix] solution/frontend: 뒤로가기 후 재검색이 옛 사업장을 되살림 — placeId URL 우선순위 정정
주소창의 placeId 가 store 보다 우선이라(BuilderPage.tsx), 뒤로가기로 검색 단계에
돌아와도 그 placeId 가 그대로 남아 있으면 usePlaceSync 가 옛 사업장을 다시 확정
정보로 채워 넣었다 — 다른 상호로 재검색해도 이전 확정 화면이 그대로 떴다.

- BuilderPage.tsx: step === 'search' 일 땐 주소창 placeId 를 안 쓴다
- Step2PlaceSearch.tsx: 뒤로가기 가드가 store 뿐 아니라 주소창 placeId·flow 도 지운다

[feat] solution/frontend: "고택" 템플릿 — 숙박·요식업·카페 공용, stay2 목업 색·서체 이식

industryData.ts 에 LOOK.paper(크림 종이·가는 명조·그림자 없음, stay2 실물에서 추출)와
paperTemplate() 을 추가해 stay·restaurant·cafe 세 업종의 네 번째 템플릿으로 등록.
DOM 구조 이식은 solution/site 커밋에서 별도로 한다.
2026-09-23 13:17:39 +09:00
951e451ef8 [fix] solution/backend: 네이버 플레이스 수집이 음식점·카페 요금표를 통째로 반려 — 업종별 unit key 분기
_to_unit_facts() 가 업종을 안 가리고 room_type·weekday_price/weekend_price 로만 냈다.
그 key 는 숙박 스키마에만 있어서, 음식점·카페·클리닉은 메뉴/프로그램 요금표가
FACT_INVALID_KEY 로 전량 거부됐다(실측: "도플로" fact 19건 중 19건 반려).

- naver_place_adapter.py: _UNIT_NAME_KEY·_UNIT_PRICE_KEY 로 업종별 key 매핑
  (숙박 room_type/weekday·weekend_price, 카페·음식점 menu_name/menu_price,
  클리닉 program_name/price_adult)
- SourceAdapter.fetch() 계약에 category 파라미터 추가, 어댑터 5개 시그니처 반영
- collect_service.py: fetch_one() 에 place.category 전달

검증: 재수집 후 stored 0 → 35(음식점), test_collector·test_category_schema·
test_fact_api·test_tour_api_adapter 173 passed
2026-09-23 13:17:28 +09:00
c366361513 Merge remote-tracking branch 'origin/main' into feature/site-features-and-mockup 2026-09-23 10:17:18 +09:00
0f58e1485a [fix] site/mockup: stay 날씨 문구 교체 — 없는 시설·어색한 표현 제거
대표 지적: "벽난로와 따뜻한 물이 있는 날씨입니다" 처럼 말이 성립하지 않는 문장.
확인해 보니 벽난로는 사진 설명이 전부 "벽난로 조명" 이라 장식이고, 테라스는
발행본 어디에도 근거가 없었다. 쓰지 않는 표현("실내로 도는", "묶어 도는",
"국물이 도는")과 펜션과 무관한 일반 안전 안내도 섞여 있었다.

- local.weather.noteSets 45줄 · tempNoteSets 25줄 전면 교체
- notes · tempNotes(한 줄짜리 기본값)도 같은 묶음 첫 줄로 맞춤
- 근거 없는 시설(벽난로 · 테라스 · 대문 안쪽 우산) 삭제
- 시각을 못 박던 줄("흐릿하게 보이는 아침입니다") 정리 — 문구는 하루 중 아무 때나 뜬다
- .gitignore: backup/ · king-stay2/ · build6p*/ · siann6/ · _sub*.mjs
  (발행본 원본은 도커 볼륨이라 레포에 사본을 두면 원본이 둘이 된다)

검증: 70줄 중복 0 · 쓴 낱말 31개 전부 발행본에 존재 · 크롬 렌더 오류 0
킹서버 반영 완료(/s/stay e1c0d8ff, 이전본 index.html.bak-20260923 보관)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:17:10 +09:00
36dbe26c91 [feat] solution/backend: 미니 블로그 글을 배정 날짜에 맞는 소재로 만든다
예전에는 소재 목록을 순서대로 뽑아 날짜에 차례로 붙여서, 9월 날짜에
'한겨울' 글이나 끝난 축제 글이 붙을 수 있었다. 이제 날짜를 먼저 정하고
그 날짜에 맞는 소재를 고른다.

- 축제: 시작 14일 전 ~ 종료일 사이만 / 계절: 게시일 절기 하나
  / 날씨: 그 달에 있을 법한 것만(눈 12~2월, 소나기 6~8월) / 주변: 늘 후보
- 프롬프트에 게시일을 넣고, 날씨를 단정하지 않게 문구를 고쳤다
- 계절·날씨·축제 주제 키에 연(월)을 넣어 해마다 다시 쓸 수 있게 했다
- 자동·구간·개별 세 경로가 _compose_for_dates 하나로 만든다

같이 고친 버그: materials 가 스냅샷에 없는 local.festivals·attractions 를
읽어 축제·주변 소재가 늘 비어 있었다. 원문 행(local.contents)을 읽는다.
2026-09-23 09:41:23 +09:00
be3e43162a [chore] solution/backend: 미니 블로그 새벽 자동 생성 잡을 잠시 끈다
사장님이 빌더에서 '생성'을 눌러야 만들어지는 흐름으로 간다. 등록 두 줄만
주석으로 막고 잡 함수는 그대로 둔다. 09:00 메일 발송 잡은 사장님이 만든
글도 보내야 해서 켜 둔다.
2026-09-23 09:41:23 +09:00
324a329b9e [chore] solution/backend: 카톡 요청에 callbackUrl·블록 이름 로그 — 콜백이 왜 안 먹는지 눈에 보이게
"콜백을 켰는데 그대로" 를 로그 없이 추측으로 좁히고 있었다. 스킬이 폴백이 아닌
다른 블록에 붙어 있으면 그 블록에는 콜백 설정이 없어 조용히 동기로 돈다 —
어느 블록이 도는지까지 같이 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 17:01:56 +09:00
bbcdf78c62 [fix] solution/backend: 카톡 응답이 5초 벽에 계속 걸리던 것 — 프롬프트 축소
실사용에서 모든 발화가 4.5초 상한에 걸려 "확인하는 데 시간이 조금 걸리네요" 만
반복됐다(킹서버 로그: 4.60s · 4.51s · 4.51s).

- 이미 연결된 사람이 코드를 또 보내면 LLM 을 부르지 않는다. 실제로 그랬고,
  6자리가 그냥 발화로 넘어가 유료 호출 + 대기만 쌓였다
- 사이트 상태를 프롬프트에서 뺀다. 그 한 줄 때문에 매 턴 사이트 조회 + 슬러그
  계산이 돌았고, 정작 필요할 때는 get_site_status 도구를 부르면 된다
- fact 는 key:value 만, 상한 30개. label 은 항목 목록에 이미 있어 두 번 보내면
  프롬프트만 커지고 모델이 얻는 것이 없다
- 항목 목록도 JSON 대신 `key: 이름` 줄로

★ 근본 해결은 콜백이다(f500210). 오픈빌더에서 '콜백 사용' 이 꺼져 있으면
callbackUrl 이 안 와서 조용히 동기 경로로만 돈다 — 지금 로그가 그 상태다.

test_kakao_webhook·test_agent_runtime 43 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:52:24 +09:00
f500210cd8 [fix] solution/backend: 카톡 5초 벽을 콜백으로 넘는다
실사용 첫날 "시설 편의에서 바비큐 이용 문구 빼줘" 가 타임아웃으로 끝났다.

★ 작은 표본으로 잰 수치를 상한 근거로 삼은 것이 틀렸다. 개발 중 잰 1.3~2.4초는
업종 필드 두 개짜리 장난감 프롬프트였고, 진짜 요청에는 필드 43개 + fact 수십 개가
실린다. "여유가 있다" 고 적어 둔 판단이 하루 만에 깨졌다.

- userRequest.callbackUrl 이 오면 {"useCallback": true} 로 즉답하고 백그라운드에서
  답을 만든 뒤 그 주소로 POST. 콜백 주소는 1분·1회라 재시도하지 않는다 —
  두 번째 POST 는 거절되고 사장님에게는 이미 "확인하고 있어요" 가 가 있다
- 콜백이 꺼져 있으면 예전처럼 동기, 상한만 4.0 → 4.5 (카카오가 5초에 끊는다)

★ 오픈빌더 스킬 설정에서 '콜백 사용' 을 켜야 열린다. 안 켜면 callbackUrl 이 안 와서
조용히 예전 경로로만 돈다.

test_kakao_webhook.py 24 passed(콜백 3건 추가)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:33:16 +09:00
1300652b09 [feat] solution/backend: 카톡 대화에 홈페이지 목록·가게 고르기
실제로 붙여 보니 빠진 것이 드러났다 — 연결은 됐는데 **어느 홈페이지를 다루는
대화인지** 말해 주지 않았다. 가게가 하나면 말없이 자동 선택돼 더 모호했다.

- 연결 직후 목록을 보여준다. 하나면 이름+발행 여부를, 여럿이면 바로가기 버튼으로
- 목록 줄에 발행 여부를 적는다 — 안 그러면 고친 것이 손님에게 보이는 줄 안다
- "목록"·"가게 바꿔줘" 로 언제든 돌아와 바꾼다. ★ 이 경로는 LLM 을 부르지 않는다:
  대화가 막혔을 때 처음 찾는 길이라 늘 통해야 하고, 목록에 돈을 쓸 이유가 없다
- 사업장 목록이 아니라 list_my_sites 를 쓴다 — 사장님이 알아야 하는 건
  "가게가 있다" 가 아니라 "발행돼 있나" 다(/sites 화면이 같은 이유로 그걸 쓴다)

test_kakao_webhook.py 21 passed(목록·전환 4건 추가).
전체 845 passed / 53 failed — 53 은 이번 변경 전과 동일

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 16:26:40 +09:00
d32df10cf7 [feat] solution/backend: 카카오 채널 웹훅 — 에이전트 4단계
런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 —
새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다.

★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고
userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이
통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest)
+ 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다
(401 은 "여기 뭔가 있다" 를 알려 준다).

- router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로
- services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인
- 0022: owner_kakao_links 에 current_place_id · pending_*

빌더 화면과 다른 것 셋:
- 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다
- place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를
  고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다
- 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면
  한참 뒤의 "네" 한 마디에 묵은 발행이 돈다

5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 —
메신저에서는 500 도 침묵으로 보인다.

밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)).
우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜
화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다.

test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일).
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:13:33 +09:00
95350cfdbf Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 14:17:53 +09:00
5b54daac10 [chore] solution/backend: 에이전트 대화창 다시 염 — AGENT_CHAT_ENABLED 기본 1
카카오톡 채널의 통신사 인증이 끝나 어제 걸어 둔 보류를 푼다. 코드는 어제도 오늘도
그대로고 값만 바꿨다 — 닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다는 어제
판단이 하루 만에 값을 쳤다.

- config/agent_config: 기본값 0 → 1
- ★ 켜도 LLM 키가 없으면 안 열린다(runtime.is_configured 가 스위치와 키를 둘 다
  본다). 키 없는 환경에서 켜 둔 채 잊어도 "눌러도 안 되는 입구" 가 안 생긴다
- 스위치 테스트를 새 기본값에 맞춰 갱신 — 키가 없을 때도 안 열리는 것을 함께 검사

카카오 연결 카드는 아직 감춰져 있다(KAKAO_CHANNEL_PUBLIC_ID 미설정). 채우면 코드는
발급되지만 소비할 웹훅(4단계)이 없어 연결이 완성되지 않는다.

test_agent_runtime·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 14:17:48 +09:00
ed0137c9da Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 14:11:13 +09:00
27426dd51c [feat] solution/backend: 이메일 승인 확인 화면에서 발행 사이트로 5초 뒤 자동 이동
사장님 지시: "이메일 승인후에 블로그가 올라가는 페이지에 5초 정도 이후에 연결".
승인만 하고 실제로 어디 올라갔는지 못 찾는 걸 줄인다. 재발행(BUILD 잡)은 몇 분
걸리므로 5초 뒤 반영을 보장하진 않지만, 어디로 가면 되는지는 바로 알려준다.
자동 이동을 못 믿어도 되게 같은 주소를 안내 문구의 링크로도 남긴다.

- post_service._blog_url: 발행된 사이트가 있으면 그 미니블로그 자리(#blog) 주소,
  없으면 None — 호출부가 자동 이동 없이 확인 문구만 보여준다
- router/v1/site/post.py: <meta http-equiv="refresh"> + 안내 링크 추가.
  redirect_url은 서버가 site_payload.publish_url()로 만드는 고정 오리진+slugify
  통과 값이라 사용자 입력은 아니지만, HTML 속성에 꽂는 자리라 html.escape 적용
  (자동 보안 리뷰 지적 반영)
- 신규 테스트 2건(발행 사이트 있을 때/없을 때), 관련 스위트 전체 49 passed

로컬 solution-backend·solution-worker 재빌드해 반영 확인함
2026-09-22 14:10:52 +09:00
acd5383b0f Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 11:44:00 +09:00
df1556f2b1 [fix] solution/backend: 쓰레드 게시 잡이 job_type 번호가 어긋나 워커에 조용히 씹혔다
큐 삽입 쪽(social_crud.decide, social_service.create_draft/publish_reused_text)이
JobType enum(SOCIAL_DRAFT=9, SOCIAL_POST=10)을 안 쓰고 숫자를 하드코딩(8, 9)해서,
워커 디스패처(worker/handlers.py)가 그 숫자로 엉뚱한 핸들러를 불렀다 — "게시" 잡(9)은
run_draft로, "초안" 잡(8)은 run_rollback으로. 둘 다 대상 상태 조건이 안 맞아 에러 없이
{"skipped": true}로 끝나 DONE 처리됐다 — 승인해도 실제로는 한 번도 게시되지 않는데
로그만 보면 정상으로 보이는 조용한 실패였다. SOCIAL_POSTING_ENABLED가 계속 꺼져 있어
지금까지 드러나지 않았다(2026-09-14부터 있던 버그).

- 세 호출부를 JobType.SOCIAL_DRAFT.value/SOCIAL_POST.value로 교체
- 기존 테스트의 job_type 기대값(8→9, 9→10)도 실제 enum에 맞게 수정
- 신규: 디스패처 매핑 정적 대조 + 실제 큐 삽입값으로 하는 엔드투엔드 회귀 테스트
  (되돌려서 새 테스트가 실패하는 것까지 확인함)

전체 회귀 70 passed
2026-09-22 11:36:21 +09:00
627fb1e141 Merge remote-tracking branch 'origin/main' into feature/owner-kakao-link 2026-09-22 11:15:09 +09:00
a553e41197 [chore] solution/backend,frontend: 에이전트 화면 보류 — 설정으로 닫고 코드는 남긴다
카카오톡 채널 개설이 법인폰 본인인증에 걸려 보류됐다. 채널이 없으면 대화창은
사장님에게 **어디에도 닿지 않는 입구**이고, 열려 있으면 "되는 기능" 으로 오해한다.

- config/agent_config: AGENT_CHAT_ENABLED 신설(기본 0)
- runtime.is_configured(): 스위치와 LLM 키를 둘 다 본다 — 화면을 우회해 API 를
  직접 불러도 AGENT_NOT_CONFIGURED 다
- AgentChatDock · KakaoChannelCard: 조건 미충족이면 통째로 감춘다(return null).
  연결 카드는 connection_enabled 가 기준이라 설정만 채우면 그대로 다시 나타난다
- ★ 코드를 지우지 않았다 — 되돌릴 때 커밋을 되짚지 않고 값 둘만 채우면 된다

★ Threads 카드와 판단이 갈린 것이 맞다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라
자리를 두고 버튼만 죽였고, 이쪽은 언제 열릴지 말해 줄 수 없다.

test_agent_runtime(스위치 2건 추가)·test_kakao_link 34 passed. npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 11:15:03 +09:00
09d07ec0dd [fix] solution: Threads OAuth 콜백 함수명이 React useCallback과 충돌 — 빌드 실패
solution-site 빌드가 TS2440(Import declaration conflicts with local
declaration of 'useCallback')로 실패했다. FastAPI 라우트 핸들러 함수명이
`callback`이라 orval이 만든 React Query 훅 이름이 `useCallback`이 됐고,
같은 파일에서 import한 React의 useCallback과 이름이 겹쳤다.

- router/v1/social/oauth.py: 함수명 callback → oauth_callback
- 백엔드 재빌드 후 npm run orval 로 재생성 → useOauthCallback,
  callbackParams.ts → oauthCallbackParams.ts

docker compose build solution-site 로 재검증, 정상 빌드 확인
2026-09-22 09:58:27 +09:00
23c9dd6fcd [chore] solution: "지금 발송하기" 버튼 이름을 "승인 알림보내기"로
사장님 지시. 같은 문구를 인용하던 API summary·주석도 같이 맞췄다
(생성 파일 site.ts 는 손대지 않음 — 백엔드 재빌드 후 orval 로 재생성).
2026-09-22 09:51:42 +09:00
81a61b50f5 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-22 08:53:53 +09:00
81ea676546 [feat] solution: 미니블로그 빌더 기능 확장 — 알림 이메일 분리·삭제·즉시발송·공급자 무관 생성
빌더 앱에서 미니블로그를 운영하는 데 필요했던 몇 가지를 묶었다.

- notify_email: 업장별 승인 메일 수신자를 계정 로그인 이메일과 분리(places.notify_email,
  migrations/0021, PlaceCRUD·protocol.py·place_service.py 검증, BlogPostsPage.tsx 설정 UI)
- 글 삭제(soft delete): 상태 제한 없이 지우고, 이미 게재된 글이면 재발행 잡까지 큐에 넣는다
  (post_crud.py, router/v1/site/post.py DELETE, BlogPostsPage.tsx 삭제 버튼)
- 지금 발송하기: 아침 9시 스윕을 안 기다리고 바로 발송(post_crud.next_due_for_mail,
  BlogPostsPage.tsx 버튼)
- blog_service.generate_one: 하드코딩된 Gemini 대신 services/llm/provider.py(LLM_PROVIDER,
  기본 openai)를 타도록 전환, 업종별 분기 구조(현재 숙소만 구현) 추가
- site_payload.publish_url(place, site): 발행 주소 조합을 한 곳에 모은 헬퍼
- 프론트: orval 로 재생성한 API 클라이언트(notify_email·삭제·즉시발송·social 엔드포인트 반영)

관련 스위트는 별도 커밋(쓰레드 연동 작업)에서 이미 PASS 확인함
2026-09-22 08:37:38 +09:00
adb3460c37 [fix] solution/backend: site_payload 날씨 라벨도 코드별 고유값으로 — 앞 커밋 누락분
직전 커밋(cd77171)에서 문서·프론트만 옮기고 서버 쪽 표(site_payload._weather_condition)를
빠뜨렸다. use-live-weather.ts 와 같은 표를 봐야 하이드레이션 전후 문구가 안 바뀐다는
불변식이 절반만 적용된 상태였다 — 여기서 마저 맞춘다.
2026-09-22 08:36:52 +09:00
cd771719df [fix] site,solution/backend: 날씨 상태 라벨을 코드별 고유값으로 — 구간 뭉치기 제거
WMO weather_code 51~57(이슬비 3단계), 61~67(비/어는비 혼재) 등을 "이슬비"·"비"
같은 큰 구간으로 뭉쳐 표시하던 것을 코드 하나당 고유 라벨로 바꿨다. 서버
프리렌더 스냅샷(site_payload._WEATHER_CONDITION_BY_CODE)과 브라우저 재조회
(use-live-weather.ts WEATHER_CONDITION_BY_CODE)가 같은 표를 봐야 하이드레이션
전후로 문구가 안 바뀐다는 기존 불변식은 유지한다.

- docs/WEATHER.md: 27개 코드 전체를 "구간→분류" 표에서 "코드→고유 라벨" 표로 재작성
- weather_notes.json: 코드별 문구 갱신
- use-live-weather.ts/.test.ts, weather.test.tsx: 새 라벨 반영
2026-09-22 08:36:09 +09:00
47da2f29b3 [feat] solution/backend,docs: 미니블로그 승인 → 쓰레드 자동 게재, 쓰레드 초안 생성 OpenAI 전환
사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능". 미니블로그의 두
승인 경로(이메일 GET 토큰, 로그인 "바로 발행")를 공통 메서드로 묶고, 그 끝에서
쓰레드 연동을 시도한다. 미니블로그 승인 자체가 발화 동의로 취급되므로 쓰레드
쪽 별도 승인은 묻지 않는다(DECISIONS 7-1-2 개정, 문구를 그대로 재사용하는
경우에 한정). 겸사겸사 쓰레드 초안 생성(generate_social_post)이 LLM_PROVIDER
를 안 타고 Gemini 를 직접 호출하던 것도 다른 생성 함수와 같은 추상화로 맞췄다.

- blog_jobs._published_places: sites.domain IS NOT NULL 조건 추가(쓰레드 기준과 통일)
- social_service.publish_reused_text: 연동 없음/게시 비활성/domain 미확정이면 스킵,
  정상이면 APPROVED 삽입 + run_post(job_type=9) enqueue — 새 게시 로직은 안 만든다
- post_service: decide/approve_by_owner → _approve_and_publish 로 공통화,
  _try_social_share 는 실패를 전부 삼켜 미니블로그 승인을 막지 않는다
- gemini_text.generate_social_post: services.llm.provider.active() 로 전환,
  Gemini 전용 import 제거
- DECISIONS.md 7-1-2, MINI_BLOG.md 5·8절, SOCIAL.md 갱신

test_blog_owner.py·test_social.py 다수 추가/수정, 관련 스위트 전체 PASS
2026-09-22 08:33:40 +09:00
59df616a7b [fix] solution/backend: 응답 스키마 대문자 타입 — openai 에서만 400 으로 터지던 것
OpenAI strict 모드는 'STRING' 을 거부한다:
  Invalid schema for response_format: 'STRING' is not valid under any of
  the given schemas
Gemini 는 대소문자를 둘 다 받아서, 대문자로 써 두면 **공급자를 openai 로 바꾸는
순간에만** 터진다. LLM_PROVIDER 기본값이 openai 인데 두 파일만 대문자로 남아
있었다 — 대화창은 첫 발화부터 502 였고 SNS 초안도 같은 이유로 못 돌았다.

- prompts/agent: 소문자로. args 는 strict 가 전 프로퍼티를 required 로 만드므로
  안 쓰는 인자가 빈 문자열로 온다 — 도구는 "" 를 '없음' 으로 읽는다
- prompts/social: 같은 수정. 나머지 프롬프트는 원래 소문자였다

실측 확인: "체크인 시간 3시로 바꿔줘" → set_fact(check_in_time, 15:00)
test_agent_runtime·test_social 34 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:29:29 +09:00
4dd0c604ee [fix] solution/backend: 키 미설정 문구가 엉뚱한 공급자를 가리키던 것
공급자를 openai 로 바꾼 뒤에도 호출측에 "GEMINI_API_KEY 미설정" 이 문자열로
박혀 있어서, **없는 것은 OPENAI_API_KEY 인데 화면은 Gemini 를 탓했다**
(실측 2026-09-21: 로컬에서 소개문이 안 나와 Gemini 키를 한참 들여다봤다).
원인을 정확히 반대로 가리키는 종류다.

- llm/provider: missing_key() 추가 — 활성 공급자에게 필요한 env 이름을 돌려준다
- copy_service·collect_service·song_service: 하드코딩 문구를 그 함수로 교체
- enums: GENERATOR_NOT_CONFIGURED 주석도 공급자 중립으로

관련 테스트 55 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:19:29 +09:00
b1a34ba58d [feat] solution/backend,frontend: 에이전트 도구·런타임·빌더 채팅창 — 2단계
런타임이 채널을 모르므로 채널·챗봇 심사 없이 에이전트 전체를 빌더 화면에서
검증할 수 있다. 웹훅 핸들러 안에 짜면 빌더에서 같은 걸 못 쓰고, 심사가 끝나야
무엇 하나 확인되지 않는다 — 카톡은 나중에 붙는 두 번째 입구다.

- services/agent/tools.py: 도구 넷 + 등급 셋(READ·REVERSIBLE·SEMI).
  ★ 도구는 반드시 services/* 를 통과한다 — crud 를 직접 부르면 스키마 검증·
  출처 필수·정정본 보호가 아무 증상 없이 사라진다. 테스트가 소스로 검사한다
- services/agent/runtime.py: 발화 → 도구 선택(LLM 1콜) → 실행 → 응답
- services/prompts/agent.py: LLM 네 겹 규약대로 프롬프트만 여기
- router/v1/agent/chat.py + features/agent/AgentChatDock.tsx(/sites 우하단)

모델에게 맡기지 않은 셋:
- 등급 — 응답 스키마에 칸 자체가 없다. 모델이 정하면 프롬프트에 끼어든 한 줄이
  확인 절차를 건너뛴다
- 결과 문구 — 도구가 만든다. 모델이 쓰면 하지 않은 일을 했다고 말할 수 있고
  사장님에게는 그 말이 사실로 보인다
- key — set_fact 의 key 는 업종 스키마가 최종 판정이다

확인(SEMI)은 실행하지 않고 되묻는다. 돌아온 confirm 값을 믿지 않고 도구는
레지스트리에서 다시 찾고 인자는 도구가 다시 검증한다 — 확인 절차가 검증을
건너뛰는 구멍이 되면 안 된다.

값을 고치면 재발행 안내를 함께 낸다 — fact 는 바뀌어도 사이트는 안 바뀐다.

test_agent_runtime.py 17 passed(LLM 은 monkeypatch, 실제 모델 호출 없음).
전체 796 passed / 50 failed — 그 50건은 HEAD 에서도 동일한 기존 이슈.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:18:00 +09:00
16b17bc91c [feat] solution/backend,frontend: 카카오톡 채널 신원 연결 — 에이전트 1단계
카카오 채널이 주는 발화자 식별자는 **채널 단위 익명 키**라 우리 user_id 와 관계가 없다.
다른 엔드포인트는 전부 place_crud.get_place(s, owner_user_id, place_id) 로 소유자 범위를
지키는데 채널 발화에는 그 owner_user_id 를 줄 근거가 없다 — 매핑이 없으면 채널
진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.

- postgres-init: owner_kakao_links(0021 + init.sql). 부분 유니크 셋 중
  uq_kakao_link_channel_key(한 카카오 계정 = 한 사장님)가 없으면 "어느 가게
  이야기냐" 가 대화가 아니라 DB 에서 갈라진다
- services/kakao_link_service: 일회성은 코드 값이 아니라 WHERE status='PENDING'
  CAS 한 문장이 보장한다. 실패는 전부 같은 에러 — 없는 코드·만료·시도초과를
  구분해 답하면 6자리의 유효성을 밖에서 탐색할 수 있다
- 코드는 sha256 만 저장. 손으로 치는 짧은 값이라 평문이면 DB 를 읽는 쪽이 곧
  연결 권한이다. 글자에서 0·O·1·I·L 제외 — 잘못 읽으면 원인이 화면에 안 보인다
- router/v1/agent/kakao: 셋 다 no-store·no-referrer·noindex.
  ★ 소비(redeem) 엔드포인트는 일부러 없다 — 웹훅 서명 검증 전에 공개 소비 경로를
  열면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다
- config/agent_config: social_config 와 일부러 가름. SNS 게재는 되돌릴 수 없는
  대외 발화, 에이전트는 자기 사이트를 고치는 창구 — 승인 강도가 다르다
- frontend/features/agent: /sites 의 Threads 카드 옆. 연결은 사람 단위라 같은 자리다
- docs/AGENT.md 신설, CLAUDE.md 색인·함정, DEVLOG

test_kakao_link.py 15 passed. 전체 780 passed / 50 failed —
그 50건은 HEAD 에서도 동일(워크트리 대조), 기존 이슈로 이번 변경과 무관.
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:01:58 +09:00
94ae4a825a Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 15:42:16 +09:00
4513fae23e Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:20:44 +09:00
c26bfb6951 [fix] solution/frontend: 랜딩 입력창 감춤 시 헤드라인·쇼케이스 간격 붕괴 수정
폼을 감췄더니 그 폼을 담던 relative 박스가 높이 0으로 접혀, top-1/2 로 그 박스
가운데에 앉던 쇼케이스 띠가 헤드라인 바로 아래로 붙어버렸다(사장님 스크린샷 실측).
폼이 없을 때만 min-h 로 그 자리를 대신 잡아준다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:20:40 +09:00
00e318d639 Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:16:13 +09:00
5952f0db62 [fix] solution/frontend: 랜딩 부제("가게 이름 한 줄로...")도 ?build=1 뒤로 감춘다
입력 폼만 감췄더니 그 위 부제가 입력창 없는 화면에 혼자 남아 어색했다.
같은 buildMode 로 감싼다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:16:10 +09:00
d061a0e6f8 Merge branch 'feature/site-features-and-mockup' 2026-09-21 14:04:19 +09:00
2252588bf2 [fix] solution/frontend: 랜딩(/) 입력창·로그인 진입점을 ?build=1 뒤로 감춘다
/ 를 그냥 열면 히어로 입력 폼과 헤더 로그인 링크가 항상 떠 있었다. 사장님 지시로
?build=1 쿼리가 있을 때만 두 진입점을 보여주고, 평소 / 는 보여주기 화면으로만 쓴다.

- LandingPage.tsx: useSearchParams 로 buildMode 계산, 히어로 <form> 을 그 값으로 감싼다
- MarketingShell.tsx: showAuthCta prop 추가(기본 true) — 랜딩만 buildMode 를 그대로 넘긴다

SSR 출력으로 확인: / 는 "가게 이름을 입력하세요"·"로그인" 둘 다 없음,
/?build=1 은 둘 다 있음. tsc 통과.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 14:04:13 +09:00
24dc9345de Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 14:03:27 +09:00
8d6e6d7cd0 Merge branch 'feature/site-features-and-mockup' 2026-09-21 13:57:43 +09:00
b386982f34 [fix] site: 모달·갤러리·소식 스크롤 잠금을 훅 하나로 통합 — 복사된 스크롤 버그 재발 방지
모달 스크롤 버그를 Modal.tsx 한 곳만 고쳤더니 같은 잠금·복귀 코드가 그대로 복사돼
있던 GallerySection·EventSection 에는 버그가 그대로 남아 있었다(2026-09-21 실측).
복사한 코드는 복사한 곳마다 따로 고쳐야 하므로 훅 하나로 뺀다.

- lib/ui/use-scroll-lock.ts: 스크롤 잠금·복귀 로직 신규 — scroll-behavior:auto 강제 포함
- lib/ui/Modal.tsx, sections/GallerySection.tsx, sections/items/EventSection.tsx:
  중복 잠금 코드 제거, useScrollLock() 호출로 교체
- lib/ui/index.ts: useScrollLock export 추가

tsc 통과, vitest 100 passed(무관한 날씨 조건 테스트 2건은 이 변경 전부터 실패 중)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:57:33 +09:00
d358ff4b93 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-21 13:02:44 +09:00
05dd9a3ce7 [docs] SERVERS: 킹서버 접속이 경유 없이 14445 직행으로 바뀌었다
인프라가 킹서버 전용 문(59.14.81.3:14445 → 22)을 열어 줘서 `ProxyJump` 가 없어졌다.
문서는 아직 옛 경로(14444 경유)를 가리키고 있어, 새로 합류하는 사람이 그대로 따라 하면
안 붙는다.

★ 14444 와 14445 는 **서로 다른 서버로 가는 문**이다. 14444 는 `.21` 로 가고, 예전에는
  거기서 킹서버로 한 번 더 건너뛰었다. 그래서 `Confluence`(14444) 항목을 14445 로 고치면
  `.21` 쪽이 끊긴다 — 가장 밟기 쉬운 자리라 문서에 못 박았다.

- 접속 표와 `~/.ssh/config` 예시를 직행 경로로. 옛 경로는 `King_admin_jump` 로 남겼다
  (14445 가 막혔을 때의 길이 없으면 곤란하다)
- 비밀번호 로그인은 안 된다(키 등록분만)는 사실을 적었다 — 이번에 그것 때문에 한 바퀴 돌았다
- 첫 접속의 호스트 키 프롬프트가 "서버가 바뀐 게 아니라 대상 이름이 바뀐 것" 임을 지문과 함께
  남겼다. 실측 2026-09-21: SSH 가 known_hosts 의 172.30.1.36 과 같은 키라고 스스로 알려 준다

검증: 새 경로로 접속 확인(`hostname` = king · 26일 가동 · 컨테이너 정상)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:15:01 +09:00
2f674352b0 Merge branch 'feature/site-features-and-mockup' 2026-09-18 17:24:05 +09:00
4260e20a70 [fix] site: 모달 닫기 스크롤 버그 수정 — 이용안내 체크인·체크아웃 병합, UI 다듬기
모달 onClose 가 inline 함수라 부모 리렌더마다 useEffect 가 다시 걸려
scrollY 를 0으로 덮어써 X 버튼으로 닫으면 페이지가 맨 위로 튀었다.

- lib/ui/Modal.tsx: onClose 를 ref 로 들고 [open] 에만 의존하도록 수정
  (BlogSection·ReviewSection 등 Modal 쓰는 7곳 전부 적용)
- sections/EssentialInfoSection.tsx: 체크인·체크아웃 행을 한 줄로 병합
- sections/GallerySection.tsx, UnitsSection.tsx, MobileTabBar.tsx, SiteFooter.tsx,
  items/*: 표시 폭·간격·라벨 정리
- lib/ui/Carousel.tsx: loop 이음매 간격 재점검
- scripts/prerender.ts: countUniqueContent 가 socialPosts(자체 출력)를 세지
  않도록 — 콘텐츠 0건 사이트가 게이트를 우회하던 경로

tsc 통과, vitest 100/102 passed (use-live-weather 실패 2건은 기존·무관)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:23:20 +09:00
07c53d30bf Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-18 13:38:59 +09:00
09e8a7c881 [feat] 연동테스트 2026-09-18 13:38:44 +09:00
b38cff0c0f [fix] solution/backend: Threads 연결이 매번 실패하던 것 — OAuth 엔드포인트는 Bearer 를 안 읽는다
연결을 누르면 Meta 인가까지는 가는데 콜백에서 늘 실패했다. 사유를 로그에 붙이고 나서야 보였다:
`THREADS_REJECTED_400[4279019] Session key invalid` — 첫 단계(코드→단기 토큰)는 지나고
**장기 토큰 교환**에서 떨어지고 있었다.

★ OAuth 토큰 엔드포인트는 데이터 엔드포인트와 규칙이 다르다. `/me`·`/me/threads` 는 Bearer
  헤더로 되지만, 토큰을 발급·교환·갱신하는 자리는 **버전 접두어가 없고 토큰을 쿼리 파라미터로**
  받는다. 같은 토큰으로 두 형식을 나란히 불러 확인했다(2026-09-18):
    /v1.0/refresh_access_token + Bearer → "The parameter access_token is required."
    /refresh_access_token + access_token= → 새 토큰 정상 반환
  즉 헤더를 **읽지도 않는다.** 그래서 "세션 키가 잘못됐다" 는, 원인과 한참 떨어진 말이 돌아왔다.

- exchange: 장기 토큰 교환을 `OAUTH_BASE` + `access_token` 쿼리로
- refresh: 같은 수정. ★ 이건 연결 때는 안 드러나고 **60일 뒤 갱신에서** 터지는 종류다 —
  그때는 계정이 조용히 만료돼 게재만 멈춘다
- ★ debug_token 의 `app_id` 대조를 뺐다. Threads 응답에는 그 필드가 **없다**
  (실측: is_valid·scopes·type·user_id·application 뿐). 없는 값을 `str(None)` 과 비교해
  **항상 불일치**였다 — 장기 토큰을 제대로 받아도 다음 줄에서 반드시 떨어지는, 통과할 수 없는
  검사였다. 있으면 대조하도록 남겨 뒀다

검증: SNS 테스트 14건 통과. 실제 토큰으로 refresh 두 형식 대조 · debug_token 응답 필드 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:31:42 +09:00
6badd5c9f0 [fix] solution/backend: 플랫폼 거절에 사유를 붙인다 — THREADS_REJECTED_400 만으로는 못 고친다
연동이 실패했는데 로그에 `THREADS_REJECTED_400` 만 남았다. 코드가 만료됐는지, 리디렉션
URI 가 안 맞는지, 권한이 모자란지 구별이 안 돼 원인을 세 번 헛짚었다(실측 2026-09-18).
Meta 는 응답 본문에 `error.message` 와 `error_subcode` 로 이유를 정확히 말해 주는데,
우리가 그걸 읽고 버리고 있었다.

- external/threads._read: 거절 코드 뒤에 `[subcode] message` 를 붙인다
- ★ 담는 것은 message·subcode 뿐이다. 토큰·시크릿·인가 code 는 담지 않는다 —
  이 문자열은 로그로 가고 로그는 우리가 아닌 사람도 본다. message 는 160자에서 끊는다

검증: SNS 테스트 14건 통과. 실제 호출로 엔드포인트·자격증명이 정상임을 먼저 확인했다
(더미 code 로 `Invalid verification code` 응답 · debug_token·장기토큰 교환 호출 모양 정상)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:26:38 +09:00
176a77a231 Merge branch 'main' of https://gitea.o2o.kr/Web4ai/o2o-site-AEO 2026-09-18 13:01:41 +09:00
921c85df25 [feat] solution: SNS 연동 확인용 테스트 게시 기능 추가 2026-09-18 13:00:03 +09:00
13cc8cc830 [fix] site: sitemap.xml 오리진이 임의의 옛 사이트로 고정되는 버그 수정
킹서버에서 방금 발행한 사이트도 sitemap.xml 에 죽은 옛 호스트(w4ai.o2o.kr)로 나가고
있었다 — Google Search Console 스윕에 URL_NOT_IN_SITEMAP 으로 잡혀서 발견했다.

원인: solution-worker 는 기동할 때마다(=배포할 때마다) `--seed-assets` 로 prerender 를
한 번 돌리는데, 이 경로는 payload 를 안 읽어서 오리진을 얻을 곳이 없다 — 그래서
findBakedOrigin() 이 out/s/ 를 훑어 **가장 먼저 발견한** 사이트의 baked canonical 을 그대로
쓴다. readdirSync 순서는 보장이 없고, SITE_PUBLIC_HOST 를 web4ai.o2osolution.ai 로 옮긴 뒤
한 번도 재발행 안 한 옛 사이트(목업 포함) 하나가 그 자리에 걸리면, 통합 sitemap.xml
전체가 죽은 옛 호스트로 통째로 구워진다 — 오늘 새로 발행한 사이트(sono, 자기 페이지의
canonical 은 정상)까지 사이트맵에서는 옛 호스트로 나갔다.

고침: 처음 찾은 것 대신 **가장 최근에 구워진(mtime 최신)** 사이트의 오리진을 쓴다 —
최근에 발행된 사이트일수록 지금 SITE_PUBLIC_HOST 를 반영했을 확률이 높다.

검증: tsc --noEmit 통과. 킹서버 solution-worker 재시작 후 sitemap.xml 재확인 예정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:16:30 +09:00
114a6b47d3 [fix] deploy: GSC 서비스 계정 키를 컨테이너에 실제로 마운트
GSC_ENABLED=1 로 킹서버에 켜져 있었는데도 search-console 스윕(10분마다)이
ValueError(GSC_CONFIG_MISSING) 로 매번 조용히 실패하고 있었다 — 로그엔 잡혀 있던
BATCH_FAILED 문구만 남고 원인은 안 보이는 종류였다.

원인: search_console_settings.load_settings() 는 GSC_CREDENTIALS_FILE(컨테이너 경로)을
읽는데, docker-compose.yml 이 GSC_CREDENTIALS_HOST_FILE(.env 의 호스트 경로)을 컨테이너로
마운트하는 줄도, GSC_CREDENTIALS_FILE 을 채워주는 줄도 없었다 — 두 변수 다 문서(.env.example
· SEARCH_CONSOLE.md)에만 있고 컴포즈에 실제로 연결된 적이 없었다. 킹서버엔 서비스 계정 키
파일(/home/o2oadmin/.secrets/search-console.json)도 이미 있었다 — 배선만 빠져 있었다.

고침: solution-backend 서비스에 볼륨 마운트 + 고정 컨테이너 경로 env 추가. 호스트 경로가
비어 있으면(GSC 를 안 켠 환경) /dev/null 을 대신 마운트해 컴포즈 문법이 안 깨지게 했다.

검증: docker compose config --quiet 통과. 킹서버에서 GSC_CONFIG_MISSING 없이 배치가 도는지는
배포 후 로그로 재확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 11:10:38 +09:00
d77f2aa14c [fix] solution/backend: 머지로 들어온 SNS 게재 기능의 죽은 코드 3건 수정
feature/social-post 를 feature/site-features-and-mockup 에 머지한 뒤 실제 배포·테스트로
잡았다 — 두 브랜치가 각자 독립적으로 LLM 공급자 리팩터(services/llm/) 이전/이후 상태로
갈라져 있어 SNS 쪽 코드가 옛 인터페이스를 그대로 참조하고 있었다.

- gemini_text.generate_social_post: 모듈 최상단에 call·DEFAULT_MODEL·extract_text 를
  services.llm.gemini 에서 들여오지 않아 실제 게시 시도가 전부 NameError/AttributeError 로
  죽는 상태였다(테스트도 gemini_text.call 을 monkeypatch 하려다 AttributeError). json 모듈도
  import 가 빠져 있었다.
- common/enums.py JobType: SOCIAL_DRAFT=8 이 기존 ROLLBACK=8 과 값이 겹쳐 있었다 — Python
  enum 은 값이 같으면 뒤 멤버가 앞 멤버의 별칭이 되므로, 워커의 핸들러 등록표에서
  HANDLERS[8] 이 SOCIAL_DRAFT 핸들러로 먼저 채워지고 ROLLBACK 은 "이미 등록됨" 판정으로
  덮어써지지 않았다 — 롤백 요청이 SNS 초안 핸들러로 잘못 라우팅돼 KeyError('post_id') 로
  죽었다. SOCIAL_DRAFT=9, SOCIAL_POST=10 으로 재배정. init.sql 의 job_type 주석도 갱신.

검증: test_social.py 14 passed, test_rollback.py 4 passed, 전체 백엔드 763 passed(기존
LLM 공급자 전환 관련 무관 실패 44건 제외 동일).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 09:44:40 +09:00
b78486b845 Merge commit '46171b4f24f2ddc3b9212b9ad6769925bcaceb03' into feature/site-features-and-mockup
# Conflicts:
#	.env.example
#	solution/backend/common/enums.py
#	solution/backend/requirements.txt
#	solution/backend/services/site_payload.py
#	solution/backend/services/snapshot.py
#	solution/backend/services/weather_notes.json
#	solution/shared/src/types/site-payload.ts
#	solution/site/src/layouts/editorial/Shell.tsx
#	solution/site/src/lib/use-live-weather.ts
#	solution/site/src/pages/HomePage.tsx
#	solution/site/src/sections/SiteFooter.tsx
#	solution/site/src/sections/WeatherSection.tsx
#	solution/site/src/sections/index.ts
2026-09-18 09:44:10 +09:00
872d00f3c4 [feat] solution: 미니 블로그·이용후기·예약요청 추가, /s/stay 목업·발행 사이트 UI 다수 수정
인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.

- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
  블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
  블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
  주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
  날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
  색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
  (line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
  크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
  빌드 스크립트 추가(다른 세션 작업)

테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
2026-09-18 09:03:18 +09:00
46171b4f24 Merge branch 'feature/social-post' 2026-09-18 09:00:07 +09:00
1f26bc7065 [feat] solution: 날씨 조건 세분화,
공식채널 단일화, 한일옥 거리 반영
날씨 조건을 7종으로 세분화,
축제 종료 여부와 무관하게 상시 노출,
'지역 읽기'갈래 축소, 야놀자(NOL) 브랜드명 제거.
2026-09-17 17:02:48 +09:00
29af37f158 [chore] site/mockup: 목업 스크립트·벤더 번들 정리 — stay2/stay3 빌드 도구 추가
patch_stay.py·inject.js/css·audit-all.mjs 갱신, 벤더 번들 교체(index-D9cPXCE3 →
index-D4jgGFP4 등), stay3 백업 스냅샷과 stay2/stay3 빌드 스크립트(build_stay2.py·
build_stay3.py) 및 원본 템플릿(tpl-story·tpl-creative) 추가. 옛 번들은
vendor/retired/ 에 보관.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:02:11 +09:00
dbae2d4f35 [feat] solution/backend: LLM 공급자를 OpenAI 기본값으로 전환, Perplexity 실비용 계측 추가
## 1. Gemini -> OpenAI 공급자 추상화

Gemini 쿼터/인증 실패로 COPY 잡(소개문·FAQ 생성)이 반복 DEAD 되는 걸 보고, 공급자를
OpenAI로 바꾸되 설정 하나로 되돌릴 수 있게 했다.

- services/llm/errors.py·types.py(신규): 공급자 무관 예외·Usage·ImagePart·LlmResult
- services/llm/gemini.py: 기존 call() 은 그대로 두고 generate() 인터페이스 추가
- services/llm/openai.py(신규): OpenAI Chat Completions 구현. 실측(2026-09-16):
  gpt-5.6-luna 는 temperature 커스텀 값을 거부한다("Only the default (1) value is
  supported") — 아예 안 보낸다.
- services/llm/provider.py(신규): LLM_PROVIDER 설정(기본 openai, 모르는 값은 gemini)으로
  둘 중 하나를 고른다.
- gemini_text.py·gemini.py(vision)·gemini_extract.py: 공개 함수 이름은 그대로 두고
  내부만 provider.active() 로 배선 — vision_service.py 등 6개 호출부는 무변경.
  단 model 선택 로직(vision_service.py·copy_steps.py)은 공급자에 맞는 모델명을 고르도록 한 줄씩 고쳤다.
- config_models.py: llm_provider·openai_api_key·openai_text_model·openai_vision_model 추가.

## 2. Perplexity 실비용 계측 추가

OpenAI 전환 김에 실제 발행 파이프라인(스테이,머뭄 기준)을 끝까지 돌려 LLM 비용을 재보니,
services/llm/perplexity.py 에는 애초에 토큰·비용 계측이 없었다. 추가하는 과정에서
실측(2026-09-16, 실제 API 응답): `usage.cost` 는 문서 예시(평평한 숫자)와 달리
`{input_tokens_cost, output_tokens_cost, request_cost, total_cost}` 객체였다 — 그대로
가정하고 배포했다가 지역 이야기 생성(LOCAL_SYNC) 잡이 재시도 3회 후 DEAD 로 떨어지는 걸
라이브에서 확인하고 고쳤다. 어떤 모양이 와도 예외를 던지지 않게 방어했다.

- services/llm/perplexity.py: Usage·read_usage() 추가(usage.cost.total_cost 를 그대로 읽는다
  — 토큰 단가표로 역산하지 않는다. 검색 컨텍스트 요금까지 포함된 진짜 값이라서다)
- external/perplexity.py·place_research.py·story_service.py·itinerary_llm_service.py·
  external/restaurant_discovery.py: 각 호출부에 tokens/비용 로그 추가

실측(스테이,머뭄 1건 발행, 지역 콘텐츠는 캐시): Perplexity $0.050(일정 생성이 절반 이상),
OpenAI $0.019(비전 $0.015 + 소개문·FAQ $0.003 + 가사 $0.0006).

검증: 신규/영향받은 테스트 전부 통과(services/llm 신규 3파일, gemini_extract 최초 HTTP
계층 테스트, perplexity 비용 계측 등). 실 OpenAI/Perplexity API로 사업장 수집→비전→
소개문·FAQ→발행까지 라이브로 왕복 확인.

## 3. site/EssentialInfoSection.tsx

미확인 항목 개수 안내 문구 제거(별도 작업, 스테이징된 상태 그대로 포함).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 17:30:11 +09:00
33d27980c4 docs(DEVLOG): Teams 웹훅 수신자 고장 원인·해결 기록 2026-09-16 16:32:52 +09:00
b4085a0e0f [fix] solution: 온보딩 생성·크롤링 진단·장애 알림 묶음
운영 번들 자동 로그인 자격증명 유출, 온보딩 COPY 잡이 Gemini 429 로 죽던 것,
크롤링 실패가 로그에만 남던 것을 한 번에 정리한다. 실측(2026-09-15 밤, 킹서버):
사진분석 배치가 Gemini 분당 쿼터를 다 써서 같은 키를 쓰는 온보딩 COPY 잡도 같이
429 를 맞고 DEAD 로 갔다 — 확인된 fact 만으로도 편집·발행이 되는데 잡을 죽일
이유가 없었다.

- solution/frontend: `VITE_AUTO_LOGIN_ID`·`PW` 를 운영 진입점에 안 넘긴다(자동 로그인은
  dev 서버 전용) + `Step5Generating` 겉모습을 이전 카드 스타일로, 데이터는 실제 잡
  진행(useGenerationJob) 그대로
- solution/backend: copy_service — Gemini 호출 실패해도 잡을 안 죽이고 fact 만으로 계속.
  db_session_manager — 유니크 제약 충돌(정상 경로) 로그를 ERROR → WARN.
  worker/runner + alert_service + teams_webhook — 잡 dead-letter·발행 실패·큐 정체를
  Teams 로 알림(영구 저장 + 재시도 + dedupe). `/readyz` 추가.
  collect_diagnostics(신규) — 크롤링 채널별 실패를 jobs.result 에 구조화해서 싣는다.
- postgres-init: 0015(users token_version) · 0016(alert_outbox) 마이그레이션

검증: 백엔드 pytest 759 passed. tsc(solution/frontend) 통과. Teams 알림 실채널 수신 확인.
2026-09-16 16:25:02 +09:00
a517439c7d [fix] solution/frontend: 연동 카드를 준비 전에도 보여준다 — 숨기면 기능이 없는 것처럼 보인다
앱 자격증명(THREADS_*)이 없으면 카드를 통째로 숨겼다. 근거는 "누를 수 없는 버튼을 세우지
않는다" 였는데, **이 기능을 만든 사람조차 "연동 버튼이 아예 안 보인다" 고 했다**(2026-09-14).
만든 사람이 못 찾으면 사장님은 더더욱 못 찾는다 — 숨기는 것과 "아직 준비 중" 은 다른 말이고,
화면은 그 둘을 구별해 말해야 한다.

- 자리는 늘 보이고 **버튼만 비활성**이다. "연동 준비 중입니다. 열리면 여기서 계정을 연결합니다."
- 통째로 접는 경우는 하나만 남겼다 — 상태를 아직 못 읽었을 때(로그인 직후 한순간).
  그건 '준비 안 됨' 이 아니라 '모름' 이라 다르게 다뤄야 한다

검증: frontend tsc·eslint 통과. 번들에 새 문구가 실린 것과 `connection_enabled=false` 에서
비활성 상태로 그려지는 것을 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:29:31 +09:00
c8dc68536e [feat] solution: SNS 연동을 '내 사이트' 한 자리로 — 연결은 한 번, 게재는 사이트마다
연결 버튼이 발행 모달 안에 있었다. 그런데 계정은 `user × provider` 하나다(표도 그렇게 생겼다)
— 버튼이 사업장 화면에 있으면 사장님은 **업장마다 연결해야 하는 줄 안다.** 사장님 지적:
"여기 내 사이트에 sns 연동 하나 두고 연동해두고, 사이트 발행 후 게재하는 형식으로".
화면이 데이터 모양을 그대로 말해야 한다 — 연결은 한 번, 게재는 사이트마다다.

- `GET /v1/social/account` 신설: 연결 상태만 준다. 사업장을 고르지 않아도 답할 수 있어야 하는
  값인데, 지금까지는 `/place/{id}` 안에만 있어서 사이트를 하나 고르기 전에는 물어볼 수 없었다
- features/social/SocialConnectionCard: '내 사이트' 목록 위의 연동 카드
  (@핸들 · [연결] · [다시 연결] · [연결 해제]). 앱 자격증명이 없으면 **아무것도 안 그린다** —
  누를 수 없는 버튼을 세우면 사장님에게는 고장난 화면이고 우리에게는 문의가 된다
- SocialPanel(발행 화면)에서 연결·해제 버튼 제거. 대신 계정이 없으면 "막다른 문구"가 아니라
  **갈 곳**을 알린다 — [내 사이트] 로 보낸다. 연결 전에도 소개글 복사는 된다
- docs/SOCIAL.md: 사장님 흐름을 '연결(한 번) / 게재(사이트마다)' 로 다시 씀

검증: SNS 테스트 14건 통과 · frontend tsc·eslint 통과. 로컬에서 임시 자격증명으로 카드가
켜지는 것과 인가 URL 조립을 확인하고 값을 되돌렸다(지금은 connection_enabled=false 로 안 뜬다)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:14:11 +09:00
ecafa19d00 [feat] solution/backend,docs: Threads 계정 연동 — 연결 실패 이유를 남기고, 준비 절차를 적는다
자동 게재가 되려면 사장님이 자기 Threads 계정을 연결해야 한다. 연결 코드(인가 URL·코드 교환·
장기 토큰·암호문 저장·해제)는 이미 있었는데, **실제로 연동하려면 무엇을 해야 하는지**가
어디에도 없었고 실패했을 때 이유를 볼 방법도 없었다.

★ 콜백이 예외를 통째로 삼키고 있었다. 화면에는 `?social=failed` 만 뜨고 우리도 원인을 모른다 —
  키가 틀렸는지 · 쿠키가 안 왔는지 · state 가 만료됐는지 구별이 안 된다. 연결이 안 되는데
  로그에 아무것도 없는 것은 이 레포가 가장 싫어하는 종류다.
  → 서버 로그에는 남기고 화면에는 안 내보낸다(OAuth 응답·state 에 자격증명이 들어 있다).
    남기는 것은 예외 종류와 우리가 만든 사유 문자열뿐 — 토큰·code·state 는 찍지 않는다.
    사장님이 인가를 취소한 경우도 고장과 구별되게 따로 남긴다.

- router/v1/social/oauth: 실패 로그 추가(`[social] 계정 연결 실패: …`)
- tests: 가짜 Threads 서버로 **연결 왕복 전체**를 검증한다(코드 교환 → 장기 토큰 → debug_token
  권한 검증 → 저장 → 해제). 실제 연결은 Meta 앱 등록이 끝나야 시험할 수 있는데, 그때 실패하면
  우리 코드가 틀린 건지 앱 설정이 틀린 건지 구별이 안 된다 — 우리 쪽 왕복은 먼저 못 박는다.
  지키는 것 셋: 저장된 것은 암호문이다 · 권한 검증을 건너뛰지 않는다 · 해제하면 토큰이 지워진다
- docs/SOCIAL.md '연동 준비': Meta 앱 콘솔에서 할 일(제품 추가·리디렉션 URI·권한 둘·
  ★심사 전에는 테스터로 추가된 계정만 인가된다) · 사장님 클릭 흐름 · 실패 시 로그 읽는 법
- .env.example: SOCIAL_*·THREADS_*·ALIMTALK_* 항목과 각각의 "비면 무엇이 꺼지는가"

★ 로컬만으로는 연결을 끝까지 검증할 수 없다 — Meta 는 콜백 URI 를 https 로만 받는다.
  터널로 https 주소를 만들거나 킹서버에서 확인해야 한다. 문서에 적어 뒀다.
★ `SOCIAL_TOKEN_SECRET`(Fernet) 이 없으면 연결 기능 자체가 꺼진다. 이 키를 잃으면 저장된
  토큰을 복호화할 수 없어 전원 재연결이다 — 그 사실도 문서에 적었다.

검증: SNS 테스트 14건 통과(신규 1 — 연결 왕복). 로컬에서 키만 넣고 앱 자격증명이 없는 상태를
확인: connection_enabled=false 로 버튼이 안 뜨고, 강제로 불러도 409 SOCIAL_CONNECTION_DISABLED

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 16:08:22 +09:00
4221dad741 [fix] nginx: 승인 페이지 헤더가 try_files 에서 유실되던 것 — rewrite break 로 같은 블록에 머문다
/approve/ 블록에 Referrer-Policy·no-store·noindex 를 달아 뒀는데 **응답에 하나도 안 붙었다**
(실측 2026-09-14: 200 은 뜨는데 헤더만 없다). `try_files` 의 폴백은 내부 리다이렉트라 요청이
이 블록을 떠나 `location /` 로 다시 들어가고, 거기서 나가는 응답에는 이 블록의 add_header 가
적용되지 않는다. 승인 링크의 nonce 가 Referer 로 새고 검색 색인에 남을 수 있는 상태였다.

- `rewrite ^ /__spa-fallback.html break` 로 바꿨다. break 는 같은 블록에 머물러 헤더가 붙는다.
  승인 링크는 SPA 한 장이라 파일을 찾아 줄 일이 없다 — 곧바로 셸을 준다
- site.conf.example 과 로컬 site.conf 를 같이 고쳤다. ★ 운영 서버의 site.conf 는 gitignore 라
  example 을 고쳐도 따라오지 않는다 — 배포 때 이 블록을 손으로 옮겨야 한다

검증: `curl -D -` 로 Referrer-Policy: no-referrer · Cache-Control: no-store ·
X-Robots-Tag: noindex, nofollow 세 줄 확인. 승인 페이지가 빌더 셸로 뜨는 것도 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:56:33 +09:00
0e0f2cf038 [feat] solution,postgres-init,docs: SNS 게재 — 사장님이 누르면 쓰고, 승인받아, 사장님 계정으로 올린다
발행한 사이트로 사람을 데려올 경로가 제품 안에 없었다. IndexNow 통보와 사이트맵뿐이고 그건
검색엔진이 언제 읽을지 우리가 모른다. 이제 사장님이 [Threads에 알리기] 를 누르면 확인된 fact 로
짧은 글을 쓰고, 승인을 받아 사장님 개인 계정으로 올린다. 올린 글은 발행본 맨 아래에도 실린다.

★ 이 레포가 처음으로 ①외부에 쓰기를 하고 ②남의 계정 자격증명을 보관하고 ③되돌릴 수 없는
  행위를 한다. 아래 결정이 전부 여기서 나왔다.

승인을 다시 둔다 — 7절("승인 없이 나간다")의 예외다(DECISIONS 7-1). 기준은 문장의 참/거짓이
아니라 명의(사장님 계정의 발언) · 회수 가능성(없다) · 무엇이 주로 틀리나(문장이 아니라 링크 —
`_publish_target` 이 계산하므로 앞 게이트가 못 본다)다. 7절의 함정은 구조로 막았다:
시작이 사장님 클릭이라 "안 눌러서 영영 안 나감" 이 생기지 않고, 승인 경로가 둘(화면·알림톡)이며,
미승인은 EXPIRED 로 화면에 보이게 남는다.

★ 게시는 `domain` 이 확정된 사이트에만. 비면 슬러그가 상호명에서 파생돼(`_publish_target`)
  상호를 고치는 순간 주소가 바뀌고, 이미 올라간 글의 링크는 404 가 된다 — 그 글은 수정할 수 없다.
★ 승인은 GET 이 아니라 POST. 메신저 링크 미리보기·백신·프리페치가 사람이 누르기 전에 URL 을
  연다. 일회성은 토큰이 아니라 `status='PENDING_APPROVAL'` 조건이 붙은 단일 UPDATE 가 보장한다.
★ 사진은 올리지 않는다 — 1-2 의 격리("나중에 필터로 뺀다")가 SNS 에서는 구조적으로 불가능하다.
  필터가 아니라 첨부 코드를 아예 만들지 않았다.
★ 게시는 기본으로 꺼져 있다(`SOCIAL_POSTING_ENABLED=0`). 플랫폼 계약과 1-4(해지 시 처리)
  결론을 확인한 뒤 사람이 연다 — 1-4 가 이 기능의 전제조건이 됐다.

플랫폼은 스레드다. X 는 URL 이 든 글에 요청당 $0.20 이 안내돼 있어 "계정 단위 고정비" 라는
처음 가정이 틀렸다(사이트마다 나가는 변동비다). 어댑터 경계는 두되 X 어댑터는 넣지 않았다.

- place_social_posts · owner_social_accounts 신설(init.sql + 0012·0013). 승인 대기는 잡이 아니라
  행의 상태다 — 잡으로 매달면 lease 만료로 DEAD 가 된다
- services/social_service · social_account_service · notify_service · external/{threads,alimtalk,social}
- router/v1/social — GET 은 상태를 바꾸지 않고, POST 가 링크·계정을 재검사한 뒤 CAS 한다
- 빌더 SocialPanel(발행 완료 화면) + 무인증 승인 페이지 `/approve/:postId`
- 발행본 SocialPostsSection — 정적 카드 + 원문 링크. 위젯·임베드 없음. 고유 콘텐츠 계수에서 제외
- nginx: `/approve/` 는 no-referrer · no-store · noindex + 액세스 로그 끔

밟은 함정 둘
- ORM 기본값에 쉼표가 딸려 들어갔다: `text("'[]',")` → `DEFAULT '[]', NOT NULL` 로 나가
  CREATE TABLE 이 통째로 실패. 운영 DB 는 init.sql 로 만들어져 안 드러나고 ORM 이 스키마를
  만드는 테스트 DB 에서만 터진다 — 09-10 의 `now()` 기본값 사고와 같은 자리다
- 승인 스윕이 1분 주기라 쓰기 커넥션을 계속 집어 들었다 → 5분. 이 스윕은 만료 표시와 중단 정리뿐이라
  분 단위 정밀도가 필요 없다

검증: 백엔드 645 passed / 5 failed(전부 환경 — 프론트 소스 부재·레이트리밋).
★ 테스트에 실제 API 키가 새면 BUILD 잡이 Suno·Perplexity 를 진짜로 부른다(실측: 한 파일 12분 →
키를 비우면 10초). 키를 비운 상태가 정상 실행 조건이다.
에디터 목록 대조(test_site_theme) 22건 통과 · tsc·eslint 통과 · vitest 62 passed

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:46:44 +09:00
678 changed files with 172362 additions and 3071 deletions

View File

@ -33,7 +33,7 @@ NAVER_CLIENT_ID=
NAVER_CLIENT_SECRET=
# 미발급. 없으면 네이버 지역검색을 쓴다
KAKAO_REST_API_KEY=
GEMINI_API_KEY=
OPENAI_API_KEY=
# 디코딩된 키(인코딩 키는 이중 인코딩된다)
TOUR_API_KEY=
# 발행할 때 이 숙소의 노래를 한 곡 만든다(가사 Gemini → 작곡 Suno).
@ -48,6 +48,56 @@ SUNO_CALLBACK_URL=https://example.com/api/suno/callback
# 백엔드를 네이티브로 돌리면 http://127.0.0.1:3100
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(웹 애플리케이션)
@ -87,6 +137,14 @@ 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_CONTAINER=
@ -103,6 +161,43 @@ AZURE_STORAGE_PREFIX=
# 자동 로그인 — 위저드 앞에 로그인 화면을 세우지 않으려고 세션을 미리 잡는다.
# ⚠️ 이 값은 **프론트 번들에 구워진다.** 페이지를 연 사람은 누구나 JS 에서 읽는다 —
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession.ts).
# ★ 바꾸면 재빌드해야 한다: ./deploy.sh solution-site
# ★ solution-frontend(--profile dev, vite dev)에서만 읽힌다 — 운영 진입점(solution-site,
# nginx/Dockerfile)은 이 값을 build arg 로 아예 받지 않는다. 여기 채워도 운영 번들에는
# 절대 안 들어간다. 바꾸면 재기동만 하면 된다(운영 이미지 재빌드가 필요 없다).
AUTO_LOGIN_ID=
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
View File

@ -58,3 +58,11 @@ dist/
# 개인용 오버라이드는 레포가 아니라 ~/.claude/CLAUDE.md 나 .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 Normal file
View File

@ -0,0 +1,2 @@
/cache
/project.local.yml

169
.serena/project.yml Normal file
View File

@ -0,0 +1,169 @@
# 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 readonly.
# 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: []

View File

@ -16,6 +16,9 @@
| 최근에 뭘 왜 바꿨나 | [docs/DEVLOG.md](docs/DEVLOG.md) |
| 서버에 올릴 때 | [docs/DEPLOY.md](docs/DEPLOY.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) |
---
@ -115,6 +118,13 @@
(`VITE_GOOGLE_CLIENT_ID`, compose 가 루트 값을 흘려보낸다). 백엔드는 이 값으로 구글 토큰의
수신자(`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/…` 를 가리키는데
블롭은 `ai-for-web/assets/…` 에 놓인다. 접두사를 쓰려면 오리진 경로를 `/ai-for-web` 로 잡는
CDN 을 앞에 세워야 한다. 아니면 비워라.
@ -132,6 +142,55 @@
→ 리다이렉트는 `absolute_redirect off`**상대 Location** 이어야 한다. TLS 를 앞단
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절이 보류한 것은

View File

@ -22,6 +22,7 @@ import router.v1.fact.fact
import router.v1.job.job
import router.v1.local.local
import router.v1.place.place
import router.v1.site.review_admin
import router.v1.site.site
from contextlib import asynccontextmanager
@ -84,3 +85,4 @@ 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.site.site.router, dependencies=_gate)
app.include_router(router.v1.local.local.router, dependencies=_gate)
app.include_router(router.v1.site.review_admin.router, dependencies=_gate)

View File

@ -1,8 +1,9 @@
import {Building2, CalendarDays} from 'lucide-react';
import {Building2, CalendarDays, MessageSquareQuote} from 'lucide-react';
import {createBrowserRouter, Navigate, Outlet} from 'react-router';
import {AppShell, type NavItem} from '@/components/layout/AppShell';
import {RequireAuth} from '@/components/layout/RequireAuth';
import {LocalContentPage} from '@admin/pages/LocalContentPage';
import {ReviewModerationPage} from '@admin/pages/ReviewModerationPage';
import {LoginPage} from '@/pages/LoginPage';
import {NotFoundPage} from '@/pages/NotFoundPage';
import {PlaceDetailPage} from '@admin/pages/PlaceDetailPage';
@ -16,6 +17,7 @@ import {SeoAuditPage} from '@admin/pages/SeoAuditPage';
const ADMIN_NAV: NavItem[] = [
{to: '/places', match: '/places', label: '사업장', icon: Building2},
{to: '/local-content', match: '/local-content', label: '지역 콘텐츠', icon: CalendarDays},
{to: '/reviews', match: '/reviews', label: '이용 후기', icon: MessageSquareQuote},
];
/**
@ -41,6 +43,7 @@ export const router = createBrowserRouter([
{path: '/places/:placeId', element: <PlaceDetailPage />},
{path: '/places/:placeId/seo', element: <SeoAuditPage />},
{path: '/local-content', element: <LocalContentPage />},
{path: '/reviews', element: <ReviewModerationPage />},
],
},

View File

@ -0,0 +1,169 @@
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>
);
}

View File

@ -51,6 +51,13 @@ services:
<<: *common-env
# ★ 크론은 이 컨테이너에서만 돈다. 프로세스가 여럿이면 같은 시각에 중복 실행된다.
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:
- ./solution/site/payloads:/app/solution/site/payloads
# 노래 파일. payload 와 나란히 둔다 — 백엔드가 mp3 를 여기 떨구면 워커가 렌더러를
@ -59,6 +66,9 @@ services:
# ★ 스키마 마이그레이션 SQL. 이미지에 굽지 않고 마운트한다 — 파일이 자주 늘고,
# 이미 세운 DB 를 따라오게 하는 것이 목적이라 코드 배포와 별개로 돌 수 있어야 한다.
- ./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:
- "${API_BIND:-0.0.0.0}:${API_PORT:-9800}:9800"
extra_hosts:
@ -248,11 +258,10 @@ services:
VITE_API_BASE_URL: ${PUBLIC_API_BASE_URL:-http://localhost}
VITE_PUBLISH_HOST: ${SITE_PUBLIC_HOST:-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 와 같은 값이다.
VITE_GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
# ★ VITE_AUTO_LOGIN_ID·PW 는 여기 없다 — nginx/Dockerfile 이 그 ARG 를 아예 안 받는다.
# 자동 로그인이 필요하면 solution-frontend(--profile dev)를 쓴다.
image: o2o-web4ai-solution-site
container_name: o2o-web4ai-solution-site
volumes:

273
docs/AGENT.md Normal file
View File

@ -0,0 +1,273 @@
# 사장님 에이전트 — 신원 연결 · 도구 · 런타임
사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, 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/<시크릿>` 을 쓴다.
**채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.

61
docs/ALERTS.md Normal file
View File

@ -0,0 +1,61 @@
# 장애 알림 (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 행 보관 정책(지금은 무기한 보관 — 운영 부하를 보고 정한다).

View File

@ -79,3 +79,19 @@ DB(`solution/backend/common/database/model/models.py`, 17테이블)에 **API 호
파이프라인을 배치로 돌리기 전에 `api_call_logs`(공급자 · 엔드포인트 · 토큰/호출수 · 추정단가 ·
`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 비용을 무료로 간주하지 않는다.

View File

@ -15,7 +15,7 @@
---
## 0. 표 15개, 스키마는 `public` 한 벌
## 0. 표 17개, 스키마는 `public` 한 벌
도메인별 스키마(`company`·`place`·`fact`·`local`·`site`·`job`)는 2026-09-09 에 걷어냈다.
스키마 한정자가 붙는 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
@ -30,13 +30,15 @@ users 사장님 계정
│ ├ place_facts ★ 사실. 이 제품의 심장
│ ├ place_faqs FAQ
│ ├ place_songs 이 숙소의 노래 — 발행할 때마다 한 곡(가사 Gemini → 작곡 Suno)
│ ├ place_social_posts SNS 게재 글 — 초안 → 승인 → 게시 (사장님이 누를 때만)
│ └ place_area_refs 업장 ↔ 지역콘텐츠 관계(거리 · 숨김)만
├ area_contents ★ 지역 콘텐츠 실체 — 키가 region_code 다(place_id 아님)
└ sites 발행 사이트 — 사업장당 1개
├ site_sections 섹션 콘텐츠(사장님이 넣은 것 · 서버가 채운 것)
├ site_versions ★ 빌드 버전 — snapshot 박제
└ site_publish_logs 발행 시도 기록(반려 사유 포함)
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래
owner_social_accounts 사장님이 연결한 SNS 계정 — ★ 위임받은 토큰을 보관하는 유일한 표
jobs 작업 큐 — 수집 · 비전 · 소개문 · 빌드 · 지역이야기 · 노래 · SNS
```
**FK 제약은 걸지 않는다**(관계 컬럼만 둔다). 삭제는 전부 소프트 삭제(`deleted`)이고,
@ -181,6 +183,26 @@ Gemini 가 쓰고, 곡은 Suno 가 붙인다.
★ 새 곡이 실패해도 직전 곡이 그대로 남는다 — `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` — 지역 콘텐츠
**키가 `region_code` 다.** 같은 지역에 사이트가 몇 개 생기든 외부 조회는 1회.
@ -330,3 +352,15 @@ cd solution/backend && python -m pyflakes services/ crud/ router/ worker/ common
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·근거 원문은 제외한다.

View File

@ -55,6 +55,8 @@
### 1-2. 크롤링한 **이미지**의 재게시 권리
2026-09-14: SNS 사본은 나중에 필터링해 회수할 수 없어 기존 격리를 적용할 수 없다. 미디어 첨부는 구현하지 않는다. 링크 카드의 og:image 캐시는 별도로 남을 수 있다.
| 항목 | 내용 |
|---|---|
| 상태 | **미결** |
@ -77,6 +79,8 @@
### 1-4. 해지 시 사이트 처리 정책
2026-09-14: SNS 운영 게재의 선행조건으로 승격. 외부 링크는 남으므로 UNPUBLISHED는 안내+연락처 페이지여야 한다. 현재 상태 전이만 있고 안내 페이지 생성은 미구현이므로 자동 게재 플래그는 기본 OFF다. 사장님 글을 자동 삭제하지 않는다. 함께 삭제할지는 별도 명시적 선택이며 현재 삭제 API는 제공하지 않는다.
| 항목 | 내용 |
|---|---|
| 상태 | **미결** |
@ -304,6 +308,78 @@ 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)
**왜** — 확인된 fact 로만 쓰면 FAQ 가 4~8개에서 끝난다. 실측(2026-09-14, 로컬): 스테이머뭄 fact 8건,

View File

@ -1,5 +1,474 @@
# 개발 일지
## 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 렌더러를 실행한다.
@ -15,6 +484,68 @@
---
## 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 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.

262
docs/MINI_BLOG.md Normal file
View File

@ -0,0 +1,262 @@
# 미니 블로그 — 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
대표 지시). 목록을 만들려면 그 결정부터 바꿔야 한다

View File

@ -92,6 +92,8 @@
## 8. 제약
비용은 사이트당 변동비·계정/계약당 고정비·일회성 개발비로 구분한다. SNS의 Gemini 생성·알림톡 건당 발송은 변동비다. Threads 직접 API에 공개 과금은 확인되지 않았다. 고정비를 사이트 생성 미터에 배분해 배치 크기에 따라 게이트 판정이 달라지게 하지 않는다. [API_USAGE 5절](API_USAGE.md#5-sns-비용-2026-09-14) 참조.
- **제품 원가 상한: 사이트 1건당 $1 (약 1,400원).** Perplexity·Kakao·Gemini 호출 합계.
이 상한이 "LLM 을 몇 번 부를 수 있나"를 정한다. 현황: [API_USAGE.md](API_USAGE.md)
(★ 개발비와 섞지 말 것 — 그건 일회성이다)

View File

@ -13,15 +13,30 @@ ssh King_admin # ~/.ssh/config 에 정의됨
|---|---|
| 호스트명 | `king` (`172.30.1.36`) — **사설 IP다. 직접 못 닿는다** |
| 계정 | `o2oadmin` |
| 경유 | `ProxyJump Confluence` = `59.14.81.3:14444` |
| 들어가는 문 | `59.14.81.3:14445` → 킹서버 22 **(2026-09-21 신설)** |
★ **14444 와 14445 는 서로 다른 서버로 가는 문이다.**
`14444`**`.21` 서버**로 간다 — 예전에는 그리로 들어가 킹서버로 한 번 더 건너뛰었다
(`ProxyJump`). 인프라가 킹서버 전용 문 `14445` 를 열어 줘서 경유가 없어졌다.
`Confluence`(14444) 항목을 14445 로 **고치면 안 된다.** 그쪽은 `.21` 이 계속 쓴다.
`~/.ssh/config` 는 레포 밖이다. 새로 합류하면 아래를 직접 넣어야 붙는다.
```
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
User o2oadmin
ProxyJump Confluence
IdentityFile ~/.ssh/<본인 >
IdentitiesOnly yes
Host Confluence
HostName 59.14.81.3
@ -29,6 +44,18 @@ Host Confluence
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.

141
docs/SOCIAL.md Normal file
View File

@ -0,0 +1,141 @@
# 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 단위 모든 사업장 연결 해제 |

View File

@ -3,13 +3,77 @@
`Open-Meteo → /v1/local/weather → useLiveWeather → WeatherSection`
관측값은 기존 API를 사용하며 브라우저에서 10분마다 갱신한다. 조회 실패 시 마지막 관측값과
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다.
관측 시각을 유지한다. 날씨 문구는 API 요청마다 생성하지 않는다. **API 키 없음** — Open-Meteo
는 키 발급 없이 쓰는 무료 공개 엔드포인트다(`services/external/open_meteo.py`).
## Open-Meteo 응답 → 내부 스냅샷
`GET https://api.open-meteo.com/v1/forecast?latitude=&longitude=&current=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`
하늘 5종·기온 5구간에 각 5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
브라우저에서는 무작위 시작 후 20초마다 한 바퀴 안에서 중복 없이 순환한다.
기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 구름많음은 그림상 흐림과 같지만 문구는 별도다.
하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
**세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**

View File

@ -27,19 +27,18 @@ COPY admin ./admin
ARG VITE_API_BASE_URL
ARG VITE_PUBLISH_HOST
ARG VITE_SITE_PREVIEW_URL
# ⚠️ 자동 로그인 계정. **번들에 그대로 구워져** 페이지를 연 사람이 JS 에서 읽을 수 있다 —
# 내부 테스트 호스트에서만 채우고, 사장님에게 여는 순간 비운다(lib/autoSession).
ARG VITE_AUTO_LOGIN_ID
ARG VITE_AUTO_LOGIN_PW
# 구글 OAuth 클라이언트 ID. 비밀이 아니라 번들에 들어가도 된다 — 다만 백엔드 GOOGLE_CLIENT_ID 와
# 같은 값이어야 한다(백엔드가 이 값으로 토큰의 aud 를 대조한다).
ARG VITE_GOOGLE_CLIENT_ID
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL \
VITE_PUBLISH_HOST=$VITE_PUBLISH_HOST \
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_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
FROM nginx:alpine

View File

@ -43,6 +43,22 @@ server {
application/javascript application/json application/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` 로
# 절대 URL 을 만드는데, TLS 는 앞단 Apache 가 끊으므로 여기 `$scheme` 는 늘 `http` 다 —
@ -123,6 +139,21 @@ server {
# ── 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) {
proxy_pass $api;
proxy_http_version 1.1;

View File

@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS public.users (
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_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(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
@ -93,7 +94,7 @@ CREATE TABLE IF NOT EXISTS public.users (
-- ============================================================
CREATE TABLE IF NOT EXISTS public.jobs (
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
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
status SMALLINT NOT NULL DEFAULT 1, -- JobStatus: 1=pending 2=running 3=done 4=dead
priority SMALLINT NOT NULL DEFAULT 100, -- 낮을수록 우선
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
@ -133,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places (
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
verified_by uuid NULL,
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
@ -337,6 +339,58 @@ CREATE TABLE IF NOT EXISTS public.site_search_status (
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 (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1
@ -494,6 +548,36 @@ 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)
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)
-- ============================================================
@ -518,3 +602,92 @@ INSERT INTO public.schema_migrations (version) VALUES
('0008_personalization_to_site_sections'),
('0009_align_with_init_sql')
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';

View File

@ -0,0 +1,19 @@
-- 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');

View File

@ -0,0 +1,29 @@
-- 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';

View File

@ -0,0 +1,18 @@
-- 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 토큰은 다음 재발급에서 전부 거절된다.';

View File

@ -0,0 +1,35 @@
-- 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;

View File

@ -0,0 +1,42 @@
-- 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;

View File

@ -0,0 +1,29 @@
-- 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;

View File

@ -0,0 +1,12 @@
-- 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;

View File

@ -0,0 +1,7 @@
-- 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;

View File

@ -0,0 +1,41 @@
-- 카카오톡 채널 신원 연결 — 채널 발화자를 우리 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';

View File

@ -0,0 +1,7 @@
-- 0021 · places.notify_email — 미니 블로그 승인 메일을 받을 주소를 계정 이메일과 분리한다.
--
-- ★ 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일(users.email) 하나로는
-- "이 업장 글은 다른 담당자에게 보낸다" 같은 경우를 못 받는다. 비어 있으면(NULL)
-- 지금처럼 users.email 로 보낸다 — 값이 없는 기존 업장은 동작이 그대로다.
ALTER TABLE public.places ADD COLUMN IF NOT EXISTS notify_email VARCHAR(255) NULL;

View File

@ -0,0 +1,14 @@
-- 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;

View File

@ -0,0 +1,59 @@
"""수집(크롤링) 중 실패를 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 []

View File

@ -34,6 +34,7 @@ class Provider(Enum):
GEMINI = "gemini"
TOUR_API = "tour_api"
OPEN_METEO = "open_meteo"
THREADS = "threads"
@dataclass(frozen=True)
@ -57,6 +58,7 @@ class Rate:
# ★ 확정된 것만 confirmed=True 다. 나머지는 자리만 잡아둔 추정치이므로
# 공식 단가표를 확인해서 교체하기 전에는 실배치를 돌리면 안 된다.
RATES: dict[Provider, Rate] = {
Provider.THREADS: Rate(confirmed=False, source="공개 과금 미확인 — API_USAGE 5절; 계정 계약비는 별도"),
# 레포에 확정값이 있다(.env.example): 키워드/카테고리 검색 2원, 좌표 변환 0.5원.
# 좌표 변환은 per_call 로 따로 세지 않고 호출측이 kakao_coord 로 구분해 넘긴다.
Provider.KAKAO: Rate(

View File

@ -113,7 +113,10 @@ class DBSessionManager(Singleton):
return ErrorType.SUCCESS
except IntegrityError as ex:
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
except Exception as ex:
await db.rollback()

View File

@ -1,7 +1,7 @@
import uuid
from sqlalchemy.orm import declarative_base
from sqlalchemy import Column, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
from sqlalchemy import Column, Date, Index, Integer, SmallInteger, Numeric, String, Text, Boolean, DateTime
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.sql import text
@ -16,6 +16,8 @@ from common.enums import (
MediaStatus,
SongStatus,
SiteStatus,
PostStatus,
ReviewStatus,
BuildStatus,
JobStatus,
)
@ -80,6 +82,12 @@ class users(MainTableMixin, MAIN_BASE):
# 컬럼이 NOT NULL 이면 그 경로가 통째로 깨진다(init.sql 의 DEFAULT 1 과 같은 값).
provider = Column(SmallInteger, nullable=False, server_default=text("1"), default=AuthProvider.LOCAL.value)
provider_uid = Column(String(255), nullable=True) # 구글 sub — 이메일이 바뀌어도 같은 사람인지 판단하는 유일한 키
# ★ refresh 토큰 무효화 키. JWT(access·refresh 둘 다)의 sub 에 이 값을 같이 싣는다
# (common/models/gmodel.py UserInfo). refresh_token() 이 DB 의 지금 값과 대조해서,
# 달라졌으면(비밀번호 변경 등으로 bump_token_version 이 불렸으면) 재발급을 거절한다.
# ★ access 토큰 자체는 검사하지 않는다 — 그건 30분짜리라 노출 창이 이미 좁다. 문제는
# refresh 토큰(7일)이 DB 를 한 번도 안 보고 계속 access 토큰을 찍어 내던 것이었다.
token_version = Column(SmallInteger, nullable=False, server_default=text("1"), default=1)
# ============================================================
@ -122,6 +130,9 @@ class places(MainTableMixin, MAIN_BASE):
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
content_updated_at = Column(DateTime(timezone=True), nullable=True)
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
notify_email = Column(String(255), nullable=True)
@ -421,6 +432,64 @@ 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):
"""발행 대상 사이트. 사업장당 1개.
해지는 물리 삭제가 아니라 status 전이로만 처리한다 색인된 페이지를 갑자기 404 만들지 않는다."""
@ -473,6 +542,29 @@ class site_search_status(MainTableMixin, MAIN_BASE):
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):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.
@ -591,3 +683,77 @@ class jobs(MainTableMixin, MAIN_BASE):
worker_id = Column(String(80), nullable=True) # 현재 점유 워커
run_started_at = Column(DateTime(timezone=True), nullable=True) # RUNNING 진입 시각
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'")),)

View File

@ -55,6 +55,7 @@ class ErrorType(Enum):
ACCOUNT_PROVIDER_CONFLICT = auto() # 이미 다른 로그인 수단으로 가입된 이메일 — 자동 연결하지 않는다(DECISIONS 1절)
OAUTH_NOT_CONFIGURED = auto() # GOOGLE_CLIENT_ID 미설정 — 구글 로그인만 꺼진다
OAUTH_INVALID_TOKEN = auto() # 구글 ID 토큰 서명·수신자·만료 검증 실패
ACCOUNT_SESSION_REVOKED = auto() # ★ refresh 토큰의 token_version 이 지금 DB 값과 다르다 — 그 뒤로 무효화됐다(비밀번호 변경 등)
# 사업장(places) 관련 에러
PLACE_NOT_FOUND = 1200
@ -84,7 +85,7 @@ class ErrorType(Enum):
COLLECT_ALREADY_RUNNING = auto()
# 생성(generator) 관련 에러
GENERATOR_NOT_CONFIGURED = 1500 # GEMINI_API_KEY 미설정
GENERATOR_NOT_CONFIGURED = 1500 # 활성 LLM 공급자의 키 미설정(llm/provider.missing_key)
GENERATOR_CALL_FAILED = auto()
GENERATOR_INVALID_OUTPUT = auto() # 구조화 출력 파싱 실패
GENERATOR_LOW_CONFIDENCE = auto() # 신뢰도 낮음 — 자동 반영 금지, 사람 확인 큐로
@ -112,6 +113,13 @@ class ErrorType(Enum):
JOB_ALREADY_QUEUED = auto() # 같은 dedupe_key 의 활성 잡이 이미 있다
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 한다.
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
@ -436,6 +444,8 @@ class JobType(CodeEnum):
AI_CHECK = 6 # AI 검색 노출 점검
SONG = 7 # 이 숙소의 노래 한 곡 (가사 Gemini → 작곡 Suno). 발행이 이 잡을 건다
ROLLBACK = 8 # 예전 버전 스냅샷으로 다시 굽고 공개 주소를 그 버전으로 되돌림
SOCIAL_DRAFT = 9 # SNS 초안 작성(Gemini) — 확보된 fact 만 근거로
SOCIAL_POST = 10 # 승인된 SNS 초안을 실제 게시
class JobStatus(CodeEnum):
@ -452,3 +462,69 @@ class JobStatus(CodeEnum):
# claim 대상이 되는 활성 상태. dedupe 부분 유니크 인덱스의 조건과 같아야 한다.
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 를 봐야 한다)

View File

@ -70,11 +70,15 @@ class UserInfo(StructModel):
user_id: str # users.user_id (uuid) — 데이터 스코프 키. 사업장은 owner_user_id 로 이 값에 매인다
id: str # users.id (로그인 아이디) — get_me 재조회 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
token_version: int # users.token_version — refresh 토큰 무효화 키(auth_service.refresh_token 이 대조)
def __init__(self, *args, **kwargs) -> None:
super().__init__()
# 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
# 구버전 토큰(role·token_version 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로
# 덮어쓴다. token_version 기본값은 DB 컬럼 기본값(1)과 같아야 한다 — 배포 순간 옛
# 토큰이 전부 "버전이 다르다"로 거절되는 것을 막는다.
self.role = UserRole.USER.value
self.token_version = 1
for dictionary in args:
for key in dictionary:
setattr(self, key, dictionary[key])

View File

@ -0,0 +1,63 @@
"""사장님 에이전트 설정 — 루트 .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 ""

View File

@ -128,6 +128,10 @@ class ExternalApiConfig(BaseSettings):
# 3.7 기본: 라벨이 틀리면 사람 확인 큐 비용이 모델 값 차이(1건 $0.045 vs $0.018)보다 크다.
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")
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)에 남긴다.
vision_confidence_threshold: float = Field(0.7, validation_alias="VISION_CONFIDENCE_THRESHOLD")
tour_api_key: str = Field("", validation_alias="TOUR_API_KEY")

View File

@ -0,0 +1,31 @@
"""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

View File

@ -0,0 +1,74 @@
"""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)
)

View File

@ -173,9 +173,12 @@ class JobQueue:
return await self._tx(run)
async def reap(self) -> list[str]:
async def reap(self) -> list[dict]:
"""만료된 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("""
UPDATE jobs SET
status = CASE WHEN attempts >= max_attempts THEN 4 ELSE 1 END,
@ -185,12 +188,15 @@ class JobQueue:
worker_id = NULL,
updated_at = now()
WHERE status = 2 AND lease_until IS NOT NULL AND lease_until < now()
RETURNING job_id
RETURNING job_id, job_type, status, last_error
""")
async def run(s):
rows = (await s.execute(sql)).all()
return [str(r[0]) for r in rows]
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)

View File

@ -0,0 +1,208 @@
"""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

View File

@ -0,0 +1,67 @@
"""승인 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)

View File

@ -14,4 +14,6 @@ requests>=2.31 # google-auth 토큰 갱신 transport
apscheduler>=3.10
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19
azure-communication-email>=1.0
cryptography>=42 # SNS 위임 토큰 Fernet 암호화(평문 저장 경로 없음)
playwright # services/collector/yanolja_adapter.py 가 요구 (registry.py import 시점에 필요)

View File

@ -1,11 +1,13 @@
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from sqlalchemy import text
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType, DBWRType
from common.logger import LOG
from common.utils.gtime import GTime
from config.server_configs import web_server_config
@ -19,7 +21,15 @@ import router.v1.media.relay
import router.v1.job.job
import router.v1.site.site
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.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()
@ -70,6 +80,10 @@ app.add_middleware(GZipMiddleware, minimum_size=1000)
async def log_time(request: Request, call_next):
start_time = time.time()
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
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
@ -81,6 +95,29 @@ async def healthz():
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.
app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.place.place.router)
@ -95,5 +132,15 @@ app.include_router(router.v1.site.site.router)
app.include_router(router.v1.site.site.my_router)
# ★ 인증 없는 공개 목록. 랜딩이 부른다 — 어드민 진입점(:9801)에는 붙이지 않는다.
app.include_router(router.v1.site.showcase.router)
app.include_router(router.v1.site.booking_request.router)
app.include_router(router.v1.site.post.router)
app.include_router(router.v1.site.post.owner_router)
app.include_router(router.v1.site.review.router)
app.include_router(router.v1.local.local.router)
app.include_router(router.v1.local.local.weather_router)
app.include_router(router.v1.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)

View File

@ -0,0 +1,68 @@
"""사장님 에이전트 대화 — 빌더 화면의 입구.
카카오톡 웹훅이 생겨도 파일은 바뀐다. 런타임이 채널을 모르고, 웹훅은 그저
같은 `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

View File

@ -0,0 +1,51 @@
"""카카오톡 채널 연결 — 빌더에서 코드를 받아 채널에 한 번 입력한다.
소비(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}

View File

@ -0,0 +1,147 @@
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
`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)

View File

@ -75,6 +75,8 @@ class Req_UpdatePlace(PlaceProtocol):
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
name: Optional[str] = None
status: Optional[PlaceStatus] = None
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
notify_email: Optional[str] = None
class Req_CreateUnit(PlaceProtocol):
@ -107,6 +109,7 @@ class PlaceData(WebPacketProtocol):
region_code: Optional[str] = None
verified_at: Optional[datetime] = None
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
created_at: Optional[datetime] = None

View File

@ -0,0 +1,88 @@
"""발행본의 예약 요청 폼 → 사장님 메일.
로그인 없는 공개 엔드포인트다. 손님은 계정이 없다.
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))

View File

@ -0,0 +1,194 @@
"""미니 블로그 승인 — 사장님이 메일에서 누르는 자리, 그리고 빌더 앱 로그인 화면. 기획: 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))

View File

@ -1,5 +1,5 @@
import uuid
from datetime import datetime
from datetime import date, datetime
from typing import Any, Optional
from pydantic import ConfigDict
@ -270,3 +270,59 @@ class ShowcaseItem(WebPacketProtocol):
class Res_Showcase(Res_WebPacketProtocol):
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

View File

@ -0,0 +1,75 @@
"""이용 후기 접수 — 발행본에서 손님이 남긴다.
로그인 없는 공개 엔드포인트다. 예약 요청(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))

View File

@ -0,0 +1,48 @@
"""이용 후기 검수 — 어드민 진입점(: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))

View File

@ -0,0 +1,69 @@
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}

View File

@ -0,0 +1,100 @@
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)

View File

@ -1,5 +1,6 @@
import asyncio
import json
from datetime import datetime, timedelta, timezone
from typing import Any, Union
from fastapi import Depends
@ -71,6 +72,18 @@ def CreateRefreshToken(subject: UserInfo) -> str:
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:
try:
decoded = jwt.decode(jwt_token, secret_key, algorithms=[JWT_ALGORITHM])

View File

@ -2,7 +2,17 @@
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 되므로 SCHEDULER_ENABLED=1 프로세스에서만 등록한다.
등록된 : Search Console (GSC_ENABLED=1, 10분마다). 붙을
등록된
· SNS 승인 만료·중단 복구 : 5 간격 (scheduler/jobs.sweep_social_posts)
붙을
등록된 :
· Search Console (GSC_ENABLED=1, 10분마다)
· 알림 발송 스윕 (1분마다) alert_outbox PENDING 실제로 보낸다
· 정체 점검 (5분마다) dead-letter 누적·좀비 실행·오래 밀린 PENDING 본다
무조건 등록한다 TEAMS_WEBHOOK_URL 비어 있으면 알림은 쌓이기만 하고 나간다
(services/teams_webhook.is_configured), 서버 동작에는 영향이 없다.
붙을
· 지역정보 갱신 : 축제 1 / 관광정보 1 / 날씨 시간 단위 행정구역 코드 단위 캐시 갱신
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 직전 유지 + 내부 알림)
· 사이트 재빌드 : 검증 상태가 바뀐 place 개별 재빌드 (전체 재빌드 금지)
@ -33,11 +43,32 @@ def start_scheduler():
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
_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()
LOG.i("[scheduler] started (KST: SNS 승인 만료·중단 복구)")
LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")

View File

@ -1,5 +1,83 @@
"""스케줄 잡 로직(what). '언제 도느냐'(scheduler/__init__.py)와 분리된, 잡이 실제로 하는 일.
잡은 '대상을 고르는 것'까지만 하고, 실제 처리는 도메인 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}")

View File

@ -1,7 +1,24 @@
"""군산 공통 맛집 한일옥 등록. 기본은 조회, --apply로 현재 설정 DB에 반영한다.
external_id가 없는 지역 공통 항목은 snapshot이 모든 군산 업장에 포함한다.
네이버 ID는 body에 보관하여 자동 수집의 (source, external_id) 갱신과 분리한다.
네이버 ID는 body에 보관하여 자동 수집(NAVER_CRAWL) (source, external_id) 갱신과는 분리한다
source OFFICIAL_WEB 으로 다르므로 자동 크롤링이 행을 건드리지 않는다.
거리(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 asyncio
@ -20,18 +37,47 @@ from sqlalchemy.engine import URL
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import area_contents, places
from common.database.model.models import area_contents, place_area_refs, places
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 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.snapshot import _local_contents
from services.snapshot import _local_contents, _site_places
REGION = '52군산시'
NAVER_ID = '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):
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(
'postgresql+asyncpg', username=cfg.write_id, password=cfg.write_pw,
host=cfg.write_host, port=cfg.write_port, database=cfg.name,
@ -41,8 +87,8 @@ async def main(apply: bool):
rows = (await conn.execute(select(
area_contents.local_content_id, area_contents.title, area_contents.body,
).where(
area_contents.region_code == REGION,
area_contents.kind == 'restaurant', area_contents.external_id.is_(None),
area_contents.region_code == REGION, area_contents.kind == 'restaurant',
area_contents.source == LocalSource.OFFICIAL_WEB.value,
area_contents.deleted.is_(False),
))).mappings().all()
if rows and any(r['body'].get('naverPlaceId') != NAVER_ID for r in rows):
@ -53,12 +99,13 @@ async def main(apply: bool):
local_content_id=content_id, region_code=REGION,
content_type=LocalContentType.RESTAURANT.value, kind='restaurant',
# 사용자 확인을 거친 수기 웹 등록. 크롤링한 값으로 표시하지 않는다.
source=LocalSource.OFFICIAL_WEB.value, external_id=None, title='한일옥',
source=LocalSource.OFFICIAL_WEB.value, external_id=NAVER_ID, title='한일옥',
body={**(rows[0]['body'] if rows else {}),
'name': '한일옥', 'searchQuery': '군산 한일옥',
'naverPlaceId': NAVER_ID,
'sourceUrl': f'https://m.place.naver.com/restaurant/{NAVER_ID}/home',
'registration': 'owner_confirmed_region_default'},
latitude=lat, longitude=lng,
status=LocalContentStatus.PUBLISHED.value, published_at=now,
collected_at=now, display_start_at=None, display_end_at=None,
expires_at=None, deleted=False, updated_at=now,
@ -68,21 +115,72 @@ async def main(apply: bool):
index_elements=[area_contents.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,
))).mappings().all()
print(json.dumps({'applied': apply, 'database': cfg.name, 'region': REGION,
'contentId': str(content_id), 'naverPlaceId': NAVER_ID,
'existingPlaces': [dict(r) for r in targets]},
default=str, ensure_ascii=False))
distances: dict[str, int | None] = {}
for row in targets:
plat, plng = _as_float(row['latitude']), _as_float(row['longitude'])
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:
# place_id 없는 새 군산 업장도 지역 공통 경로만으로 받는지 확인한다.
snapshot = await _local_contents(SimpleNamespace(region_code=REGION))
local, _ = _local(snapshot, None, None)
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:
# ★ 업장마다 다른 거리라 사이트 개인화 맵(site_sections)에도 얹어야 캔버스·발행본이 읽는다
# (services/local_content_service.py::_write_site_places 규약과 동일).
service = LocalContentService()
for row in targets:
place_id = row['place_id']
places_map = dict(await _site_places(place_id))
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'] == '한일옥']
assert len(matches) == 1, '지역 공통 payload에 한일옥이 정확히 한 번 있어야 합니다.'
print(json.dumps({'verifiedRegionalPayload': matches}, ensure_ascii=False))
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:
await engine.dispose()
await DB_SESSION_MNG.dispose_all()

View File

@ -0,0 +1,288 @@
"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.
**카카오를 모른다.** `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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
}

View File

@ -0,0 +1,165 @@
"""에이전트 런타임 — 발화 → 도구 선택 → 실행 → 응답.
**채널을 모른다.** 빌더 화면에서 왔는지 카카오톡에서 왔는지 필요가 없다.
이걸 웹훅 핸들러 안에 짜면 빌더에서 같은 쓰고, 카카오 심사가 끝나야
무엇 하나 검증되지 않는다(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}

View File

@ -0,0 +1,193 @@
"""도구 레지스트리 — 에이전트가 할 수 있는 일의 **전부**가 여기 있다.
도구는 반드시 `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"
]

View File

@ -0,0 +1,162 @@
"""장애 알림 — 영구 저장 + 재시도 + 중복 억제.
모양인가
소진(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}

View File

@ -76,6 +76,7 @@ class AuthService:
user_id=str(user.user_id),
id=user.id,
role=user.role,
token_version=user.token_version,
)
async def _finish_login(self, user: users) -> Res_Login:
@ -316,6 +317,10 @@ class AuthService:
res.result.SetResult(ErrorType.ACCOUNT_PROVIDER_CONFLICT)
return res
data["password"] = await GetHashedPW(data["password"])
# ★ 비밀번호를 바꾸면 그 전에 나간 refresh 토큰을 전부 무효화한다 — 안 그러면
# 누군가 비번을 훔쳐 넣어 둔 refresh 토큰이 이 사람이 비번을 바꾼 뒤로도
# 계속 살아 있다(auth_service.refresh_token 이 이 값을 대조한다).
data["token_version"] = (me.token_version or 1) + 1
else:
data.pop("password", None)
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
@ -336,8 +341,35 @@ class AuthService:
return await self.get_me(user_info)
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()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨(서명·만료).
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

View File

@ -0,0 +1,307 @@
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: 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}

View File

@ -0,0 +1,271 @@
"""미니 블로그 — 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

View File

@ -0,0 +1,86 @@
"""예약 요청 메일 — 발행본 폼이 보낸 것을 사장님에게 전달한다.
저장하지 않는다. 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)

View File

@ -14,7 +14,9 @@ import uuid
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_channels, places, site_publish_logs, site_versions, sites
from common.database.model.models import (
place_channels, place_posts, place_reviews, places, site_publish_logs, site_versions, sites,
)
from common.enums import (
BuildStatus,
DBWRType,
@ -29,7 +31,9 @@ from common.logger import LOG
from common.utils.gtime import GTime
from crud.site_crud import SiteCRUD
from crud.place_crud import PlaceCRUD
from crud.post_crud import PostCRUD
from services import (
alert_service,
azure_static,
indexnow,
publish_gate,
@ -46,6 +50,24 @@ from common.job_errors import PermanentJobError
_site_crud = SiteCRUD()
_place_crud = PlaceCRUD()
_post_crud = PostCRUD()
async def _stamp_reviews(session, place_id, version_id):
from sqlalchemy import update
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 가 끝나기를 기다리는 시간(사진 내려받기 포함).
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 시간 초과"로 실패한다 — 처음 보는 사진을
@ -155,6 +177,15 @@ async def run_build(job: dict) -> dict:
except Exception as ex: # noqa: BLE001 — 노래 실패가 발행을 죽이면 안 된다
song_result = {"error": f"{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)은 **여기서 직접** 부른다. 이건 잡이라 기다리는 사람이 없다 —
# 캔버스 경로가 잡으로 넘기는 것과 사정이 다르다(local_content_service._ensure_region_stories).
@ -226,6 +257,15 @@ async def run_build(job: dict) -> dict:
result["build_status"] = "FAILED"
result["error"] = 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
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
@ -348,6 +388,10 @@ async def run_build(job: dict) -> dict:
)
result["build_status"] = "BUILT"
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:
# 썸네일은 발행 상태 전이와 같은 UPDATE 에 싣는다 — 못 만들었으면 키를 넣지 않아
@ -371,6 +415,16 @@ async def run_build(job: dict) -> dict:
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,
payload.get("requested_by"))
site.status = SiteStatus.PUBLISHED.value

View File

@ -5,6 +5,7 @@
import re
import uuid
from common import collect_diagnostics
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_photos, place_channels, places, place_units
@ -17,6 +18,7 @@ from common.category_schema import get_schema
from services.collector import AdapterDisabled, AdapterNotFound, REGISTRY
from services.collector import yanolja_adapter
from services.external import naver_place_lookup, perplexity, tour_lookup
from services.llm import provider
from services.fact_service import FactService
from router.v1.fact.protocol import Req_UpsertFact
from common.job_errors import PermanentJobError
@ -124,7 +126,7 @@ async def discover_official_site(place, place_id: str) -> str:
try:
candidates = await client.search_local(query)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 지역검색 실패(계속) {query!r}: {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("naver_local_search", query, ex)
return "not_found"
match = naver_client.pick_match(place.name, candidates, address)
@ -168,7 +170,7 @@ async def discover_tour_api(place, place_id: str) -> str:
longitude=place.longitude,
)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("tour_api", place.name, ex)
return "error"
if not found:
@ -215,7 +217,7 @@ async def discover_yanolja(place, place_id: str) -> str:
try:
found = await yanolja_adapter.search_by_address(address)
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 야놀자 검색 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("yanolja_search", place.name, ex)
return "error"
if not found:
@ -254,7 +256,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["naver_place"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001 — 발견 실패가 수집을 죽이면 안 된다
LOG.w(f"[collect] 네이버 플레이스 조회 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("naver_place", place.name, ex)
stat["naver_place"] = "error"
# TourAPI 도 같은 성격의 '직접 해석' 이다 — 검색모델을 거치지 않고, 키가 있으면 항상 시도한다.
@ -264,7 +266,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["tour_api"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] TourAPI 조회 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("tour_api", place.name, ex)
stat["tour_api"] = "error"
# 자체 홈페이지 — 네이버 지역검색이 이미 준 값이라 추가 요금이 없다(위 함수 머리주석).
@ -274,7 +276,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["official_site"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 자체 홈페이지 조회 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("official_site", place.name, ex)
stat["official_site"] = "error"
# 야놀자(NOL) — 숙박 업종에서만 의미가 있고, 상호 대조 실패 시 등록하지 않는다(위 함수 참고).
@ -284,7 +286,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
if stat["yanolja"] == "resolved":
stat["discovered"] += 1
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 야놀자 조회 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("yanolja", place.name, ex)
stat["yanolja"] = "error"
# 오직 요청 옵션으로만 연다. 서버 env 로 일괄 활성화하면 일반 크롤링·재수집에서도
@ -309,7 +311,7 @@ async def discover_links(place, place_id: str, *, include_perplexity: bool = Fal
return stat
except perplexity.PerplexityError as ex:
# ★ 실패해도 파이프라인을 죽이지 않는다 — 이미 등록된 링크로 크롤링은 계속한다.
LOG.w(f"[collect] URL 발견 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("perplexity_discover", place.name, ex)
stat["error"] = str(ex)[:200]
return stat
@ -410,7 +412,7 @@ async def coverage(place, place_id: str) -> dict:
}
async def fetch_one(link):
async def fetch_one(link, category=None):
"""링크 하나를 긁는다. 실패해도 예외를 던지지 않는다 — 나머지 링크가 살아야 한다."""
try:
adapter = REGISTRY.get_adapter(link.url)
@ -419,12 +421,12 @@ async def fetch_one(link):
LOG.w(f"[collect] 어댑터 없음 — 건너뜀 {link.url}: {type(ex).__name__}")
return None, "no_adapter"
try:
source = await adapter.fetch(link.url)
source = await adapter.fetch(link.url, category)
except Exception as ex:
LOG.w(f"[collect] 수집 실패(계속) {link.url}: {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("fetch", link.url, ex)
return None, "failed"
if not source.ok:
LOG.w(f"[collect] 수집 실패(계속) {link.url}: {source.error}")
collect_diagnostics.note_issue("fetch", link.url, RuntimeError(source.error))
return None, "failed"
return source, "fetched"
@ -550,6 +552,15 @@ async def store_media(place_id: str, sources: list, unit_map: dict) -> dict:
# ---- 오케스트레이션 --------------------------------------------------------
async def run_collect(job: dict) -> dict:
"""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"]
place_id = payload["place_id"]
owner_user_id = payload["owner_user_id"]
@ -627,7 +638,7 @@ async def run_collect(job: dict) -> dict:
f"남은 링크 {fetch_stat['skipped_enough']}건 크롤링 생략")
break
source, outcome = await fetch_one(link)
source, outcome = await fetch_one(link, PlaceCategory(place.category))
fetch_stat[outcome] += 1
if source is None:
continue
@ -682,7 +693,7 @@ async def run_collect(job: dict) -> dict:
from services import place_research
result["research"] = await place_research.research_place(place, place_id)
except Exception as ex: # noqa: BLE001
LOG.w(f"[collect] 업소 조사 실패(계속): {type(ex).__name__}: {ex}")
collect_diagnostics.note_issue("place_research", place.name, ex)
result["research"] = {"error": f"{type(ex).__name__}: {ex}"}
await _finish(place_id, owner_user_id, PlaceStatus.REVIEW)
@ -742,7 +753,7 @@ async def _enqueue_vision(place_id: str, owner_user_id: str) -> str | None:
from services.job_service import enqueue_job
if not gemini.is_configured():
LOG.i("[collect] GEMINI_API_KEY 미설정 — 사진 분석 건너뜀(사진은 확인 큐에 남는다)")
LOG.i(f"[collect] {provider.missing_key()} 미설정 — 사진 분석 건너뜀(사진은 확인 큐에 남는다)")
return None
job_id, _created = await enqueue_job(
JobQueue(), JobType.VISION,

View File

@ -11,7 +11,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Optional, Protocol, runtime_checkable
from common.enums import LinkChannel
from common.enums import LinkChannel, PlaceCategory
# ---- 도메인 예외 -----------------------------------------------------------
@ -154,4 +154,4 @@ class SourceAdapter(Protocol):
def can_handle(self, url: str) -> bool: ...
async def fetch(self, url: str) -> RawSource: ...
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource: ...

View File

@ -14,6 +14,7 @@ URL 규약 (업종을 URL 에서 읽어 결정적으로 동작한다)
mock://cafe/cafe-1?channel=naver_place
https://mock.test/restaurant/r-1
"""
from typing import Optional
from urllib.parse import parse_qs, urlparse
from common.category_schema import get_schema
@ -227,7 +228,7 @@ class MockAdapter:
return True
return parsed.scheme in ("http", "https") and parsed.hostname in _HOSTS
async def fetch(self, url: str) -> RawSource:
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
"""URL 에서 업종을 읽어 그 업종의 목데이터를 돌려준다.
업종을 읽으면 예외가 아니라 실패 결과(ok=False) 돌려준다

View File

@ -21,7 +21,7 @@ from typing import Optional
import httpx
from common.enums import LinkChannel
from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -102,6 +102,18 @@ _WEEKEND_TOKENS = ("주말", "금토", "토일", "공휴일")
# 요일 토큰과, 바로 뒤에 붙는 괄호 보충설명("주말(금,토)")까지 한 번에 걷어낸다.
_DAY_TAG = re.compile(rf"({'|'.join(_WEEKDAY_TOKENS + _WEEKEND_TOKENS)})\s*(\([^)]*\))?\s*")
_UNIT_NAME_KEY = {
PlaceCategory.LODGING: "room_type",
PlaceCategory.CAFE: "menu_name",
PlaceCategory.RESTAURANT: "menu_name",
PlaceCategory.CLINIC: "program_name",
}
_UNIT_PRICE_KEY = {
PlaceCategory.CAFE: "menu_price",
PlaceCategory.RESTAURANT: "menu_price",
PlaceCategory.CLINIC: "price_adult",
}
class NaverPlaceAdapter:
"""네이버 플레이스 상세 → fact·사진 후보."""
@ -125,7 +137,7 @@ class NaverPlaceAdapter:
u = (url or "").lower()
return any(h in u for h in _HOSTS)
async def fetch(self, url: str) -> RawSource:
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = LinkChannel.NAVER_PLACE
try:
place_id = await self._resolve_place_id(url)
@ -143,7 +155,7 @@ class NaverPlaceAdapter:
if not base:
return RawSource.failure(url, self.id, "응답에 PlaceDetailBase 가 없다", channel)
facts = self._to_facts(base, state)
facts = self._to_facts(base, state, category)
media = self._to_media(state)
booking_url = self._booking_url(state)
@ -293,7 +305,7 @@ class NaverPlaceAdapter:
raise RuntimeError(f"네트워크 오류: {ex}")
raise RuntimeError(last)
def _to_facts(self, base: dict, state: dict) -> list[CollectedFact]:
def _to_facts(self, base: dict, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]:
"""★ 스키마에 있는 key 만 만든다. 없는 key 는 fact 기록 단계에서 통째로 거부된다."""
facts: list[CollectedFact] = []
@ -345,10 +357,10 @@ class NaverPlaceAdapter:
seen.add(key)
facts.append(CollectedFact(key=key, value="false" if negated else "true"))
facts.extend(self._to_unit_facts(state))
facts.extend(self._to_unit_facts(state, category))
return facts
def _to_unit_facts(self, state: dict) -> list[CollectedFact]:
def _to_unit_facts(self, state: dict, category: Optional[PlaceCategory] = None) -> list[CollectedFact]:
"""요금표(`Menu:*`) → 단위(객실·메뉴·프로그램) 스코프 fact.
필요한가
@ -368,6 +380,9 @@ class NaverPlaceAdapter:
rows = [v for k, v in state.items() if k.startswith("Menu") and isinstance(v, dict)]
rows.sort(key=lambda v: int(v.get("index") or 0))
name_key = _UNIT_NAME_KEY.get(category, "room_type")
flat_price_key = _UNIT_PRICE_KEY.get(category) if category is not None else None
out: list[CollectedFact] = []
seen_names: list[str] = []
for row in rows:
@ -382,16 +397,17 @@ class NaverPlaceAdapter:
if unit_name not in seen_names:
seen_names.append(unit_name)
# room_type 은 숙박 스키마의 unit 필수 필드다. 이름 자체가 상품 구분이므로 그대로 싣는다.
out.append(CollectedFact(
key="room_type", value=unit_name, scope="unit", unit_name=unit_name,
key=name_key, value=unit_name, scope="unit", unit_name=unit_name,
))
# 요금. 네이버는 문자열 숫자("20000")로 준다 — 표기는 렌더 단계(format_value)가 만든다.
price = str(row.get("price") or "").strip().replace(",", "")
if not price.isdigit():
continue
if any(t in raw_name for t in _WEEKEND_TOKENS):
if flat_price_key:
price_key = flat_price_key
elif any(t in raw_name for t in _WEEKEND_TOKENS):
price_key = "weekend_price"
elif any(t in raw_name for t in _WEEKDAY_TOKENS):
price_key = "weekday_price"

View File

@ -47,7 +47,7 @@ from urllib.robotparser import RobotFileParser
import httpx
from common.enums import LinkChannel
from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -200,7 +200,7 @@ class StaticHtmlAdapter:
return False
return not any(host == d or host.endswith("." + d) for d in _DENY_HOSTS)
async def fetch(self, url: str) -> RawSource:
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = self._channel(url)
allowed, why = await self._robots_allows(url)

View File

@ -33,7 +33,7 @@ from urllib.parse import unquote, urlencode
import httpx
from common.enums import LinkChannel
from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG
from config.server_configs import external_api_config
from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -104,7 +104,7 @@ class TourApiAdapter:
return True
return any(h in u for h in _HOSTS) and bool(_COTID.search(u))
async def fetch(self, url: str) -> RawSource:
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
channel = LinkChannel.ETC
key = (external_api_config.tour_api_key or "").strip()
if not key:

View File

@ -23,7 +23,7 @@ from typing import Optional
from playwright.async_api import Page, TimeoutError as PWTimeoutError, async_playwright
from common.enums import LinkChannel
from common.enums import LinkChannel, PlaceCategory
from common.logger import LOG
from services.collector.base import CollectedFact, CollectedMedia, RawSource
@ -249,7 +249,7 @@ class YanoljaAdapter:
def can_handle(self, url: str) -> bool:
return bool(DETAIL_URL_RE.search((url or "").lower()))
async def fetch(self, url: str) -> RawSource:
async def fetch(self, url: str, category: Optional[PlaceCategory] = None) -> RawSource:
pw, browser, page = await _new_page()
try:
await page.goto(url, wait_until="domcontentloaded", timeout=60000)

View File

@ -1,6 +1,8 @@
"""COPY 흐름. 단계 구현: copy_steps / 프롬프트: prompts/copy / 호출·검증: external/gemini_text."""
from common.logger import LOG
from services.copy_steps import CopyAborted, prepare_copy, generate_copy, save_copy, fill_faqs
from services.external import gemini_text
from services.llm import provider
from services.job_progress import JobProgress
COPY_STEPS = ("prepare", "generate", "save", "faq_fill")
@ -15,13 +17,21 @@ async def run_copy(job: dict) -> dict:
copy = None
note = None
if inputs.ungrounded or not gemini_text.is_configured():
note = "근거로 쓸 확인된 fact 가 없다" if inputs.ungrounded else "GEMINI_API_KEY 미설정"
note = "근거로 쓸 확인된 fact 가 없다" if inputs.ungrounded else f"{provider.missing_key()} 미설정"
await progress.skip("generate", "no_facts" if inputs.ungrounded else "not_configured")
if inputs.catalog is None and not inputs.ungrounded:
raise CopyAborted(note)
else:
try:
async with progress.step("generate"):
copy = await generate_copy(inputs)
except gemini_text.GeminiError as ex:
# ★ 호출 실패는 미설정과 같은 취급이다 — fact 만으로도 편집·발행이 되고
# (publish_gate.check_unique_content), 발행은 고유 콘텐츠 0건으로 막지 않는다.
# 자세한 배경은 DEVLOG.md 참고.
note = f"생성 호출 실패: {ex}"
LOG.w(f"[copy] 생성 실패, fact 만으로 계속: {ex}")
await progress.skip("generate", "generation_failed")
async with progress.step("save"):
result = await save_copy(inputs, copy)

View File

@ -37,6 +37,7 @@ from crud.place_crud import PlaceCRUD
from router.v1.fact.protocol import Req_UpsertFact
from services import faq_fill, place_research
from services.external import gemini_text
from services.llm import provider
from services.fact_service import FactService
from common.job_errors import PermanentJobError
@ -181,6 +182,11 @@ async def prepare_copy(place_id: str, owner_user_id: str) -> CopyInputs:
async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
active_provider = provider.active()
model = (
external_api_config.openai_text_model if active_provider.__name__.endswith("openai")
else external_api_config.gemini_text_model
)
try:
return await gemini_text.generate_copy(
inputs.place.name,
@ -190,7 +196,7 @@ async def generate_copy(inputs: CopyInputs) -> gemini_text.GeneratedCopy:
records=inputs.records or None,
suggested_questions=faq_fill.suggested_questions(inputs.catalog, inputs.known_fact_keys) if inputs.catalog else None,
max_faqs=faq_fill.FAQ_TARGET,
model=external_api_config.gemini_text_model,
model=model,
)
except gemini_text.GeminiNotConfigured as ex:
raise CopyAborted(str(ex)) from ex
@ -243,7 +249,7 @@ async def save_copy(inputs: CopyInputs, copy: gemini_text.GeneratedCopy | None)
actor, place_id,
Req_UpsertFact(
key=key, value=text_value.strip(),
source_type=SourceType.LLM, source_url=f"gemini:{external_api_config.gemini_text_model}",
source_type=SourceType.LLM, source_url=f"llm:{copy.source or external_api_config.gemini_text_model}",
),
)
if res.result.success:

View File

@ -0,0 +1,57 @@
"""알림톡 대행사 계약은 여기 한 곳에만 둔다. 승인 URL·전화번호·응답 원문을 로깅하지 않는다."""
import hashlib
import hmac
from config import social_config as config
import secrets
from datetime import datetime, timezone
def is_configured():
return all(
config.get(k)
for k in (
"ALIMTALK_API_KEY",
"ALIMTALK_API_SECRET",
"ALIMTALK_PROFILE_ID",
"ALIMTALK_SENDER",
"ALIMTALK_TEMPLATE_CODE",
)
)
async def send(to, template_code, variables, *, client):
date = datetime.now(timezone.utc).isoformat()
salt = secrets.token_hex(16)
signature = hmac.new(
config.required("ALIMTALK_API_SECRET").encode(),
(date + salt).encode(),
hashlib.sha256,
).hexdigest()
res = await client.post(
"https://api.solapi.com/messages/v4/send-many/detail",
headers={
"Authorization": f"HMAC-SHA256 apiKey={config.required('ALIMTALK_API_KEY')}, date={date}, salt={salt}, signature={signature}"
},
json={
"messages": [
{
"to": to,
"from": config.required("ALIMTALK_SENDER"),
"type": "ATA",
"kakaoOptions": {
"pfId": config.required("ALIMTALK_PROFILE_ID"),
"templateId": template_code,
"variables": variables,
"disableSms": True,
},
}
],
"allowDuplicates": False,
},
)
if res.status_code not in (200, 201):
raise RuntimeError("ALIMTALK_SEND_FAILED")
data = res.json()
if data.get("failedMessageList") or not data.get("groupInfo"):
raise RuntimeError("ALIMTALK_SEND_FAILED")

View File

@ -3,15 +3,13 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/vision.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
무엇을 돌려주는가 여기 배치 나누기 호출 ref 매칭 조립
결과는 순서가 아니라 `ref` 매칭하고, 신뢰도가 낮으면 사람 확인 대상으로 남긴다.
(문장 생성과 달리 여기엔 grounding 겹이 없다 사진 설명은 대조할 fact 없고,
대신 신뢰도 임계값과 사람 확인 큐가 몫을 한다.)
"""
import base64
import json
from dataclasses import dataclass, field
from typing import Optional
@ -19,20 +17,19 @@ import httpx
from common.enums import PlaceCategory
from common.logger import LOG
from services.llm.gemini import (
DEFAULT_MODEL,
GeminiError,
GeminiInvalidOutput,
GeminiNotConfigured,
Usage,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.llm import provider
from services.llm.errors import LlmError as GeminiError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput # noqa: F401 (하위 호환 재노출)
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.llm.types import ImagePart, Usage
from services.prompts.vision import RESPONSE_SCHEMA, build_prompt
def is_configured() -> bool:
"""호출측(vision_service.py 등)은 이 겹만 안다 — 어느 공급자가 활성인지는 몰라도 된다."""
return provider.active().is_configured()
# URL 확장자 대신 파일 시그니처로 MIME을 판별한다.
_MAGIC = (
(b"\x89PNG\r\n\x1a\n", "image/png"),
@ -113,7 +110,7 @@ async def _run_batch(
배치가 통째로 실패해도 예외를 밖으로 던지지 않는다 호출측이 나머지 배치를 계속 돌려야 한다."""
out: dict[str, VisionResult] = {}
ref_map: dict[str, ImageInput] = {}
parts: list[dict] = [{"text": ""}] # 자리를 잡아두고 프롬프트는 아래에서 채운다
images_payload: list[ImagePart] = []
for i, image in enumerate(batch):
ref = f"img-{i}"
@ -127,33 +124,27 @@ async def _run_batch(
)
continue
ref_map[ref] = image
parts.append({"text": f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")})
parts.append({
"inline_data": {"mime_type": image.mime_type or _sniff_mime(data), "data": base64.b64encode(data).decode()}
})
label = f"[{ref}]" + (f" (힌트: {image.unit_name_hint})" if image.unit_name_hint else "")
images_payload.append(ImagePart(mime_type=image.mime_type or _sniff_mime(data), data=data, label=label))
if not ref_map:
return out
parts[0] = {"text": build_prompt(category, unit_names, sorted(ref_map))}
body = {
"contents": [{"role": "user", "parts": parts}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": RESPONSE_SCHEMA,
"temperature": 0,
},
}
prompt = build_prompt(category, unit_names, sorted(ref_map))
llm_provider = provider.active()
usage.batches += 1
try:
payload = await call(client, model, body, max_retries)
parsed = json.loads(extract_text(payload))
llm_result = await llm_provider.generate(
client, model, prompt=prompt, images=images_payload,
response_schema=RESPONSE_SCHEMA, temperature=0, max_retries=max_retries,
)
parsed = llm_result.json
except GeminiNotConfigured:
raise # 키 문제는 전체를 중단시킨다 — 나머지 배치도 어차피 실패한다
except Exception as ex:
usage.failed_batches += 1
LOG.w(f"[gemini] 배치 실패(계속) {len(ref_map)}장: {type(ex).__name__}: {ex}")
LOG.w(f"[vision] 배치 실패(계속) {len(ref_map)}장: {type(ex).__name__}: {ex}")
for image in ref_map.values():
out[image.origin_url] = VisionResult(
origin_url=image.origin_url, ok=False, needs_review=True,
@ -161,7 +152,7 @@ async def _run_batch(
)
return out
batch_usage = read_usage(payload)
batch_usage = llm_result.usage
usage.input_tokens += batch_usage.input_tokens
usage.output_tokens += batch_usage.output_tokens
@ -205,7 +196,7 @@ async def analyze_images(
*,
category: Optional[PlaceCategory] = None,
unit_names: Optional[list[str]] = None,
model: str = DEFAULT_MODEL,
model: Optional[str] = None,
batch_size: int = 10,
confidence_threshold: float = 0.7,
max_retries: int = 2,
@ -217,8 +208,10 @@ async def analyze_images(
호출측이 길이나 순서로 매칭하다 어긋나면 엉뚱한 사진에 alt 붙는다.
needs_review=True 항목은 자동 반영하지 말고 사람 확인 (MediaStatus.PENDING_REVIEW) 보낸다.
"""
if not is_configured():
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
llm_provider = provider.active()
if not llm_provider.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
model = model or llm_provider.DEFAULT_MODEL
if not images:
return []
@ -249,8 +242,9 @@ async def analyze_images(
ok = sum(1 for r in results if r.ok)
review = sum(1 for r in results if r.needs_review)
LOG.i(
f"[gemini] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
f"[vision] 사진분석 {len(results)}장 (성공 {ok} · 확인필요 {review}) · "
f"배치 {usage.batches}(실패 {usage.failed_batches}) · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, Usage(usage.input_tokens, usage.output_tokens))}"
f"tokens in={usage.input_tokens} out={usage.output_tokens} · "
f"약 ${llm_provider.price(model, Usage(usage.input_tokens, usage.output_tokens))}"
)
return results

View File

@ -3,7 +3,7 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/extract.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
답을 믿을 것인가 services/grounding/extract.py evidence 원문 대조
무엇을 돌려주는가 여기 호출 검증 CollectedFact 조립
@ -15,7 +15,6 @@
통과한 fact UNVERIFIED 들어간다. 사장님이 확인해야 사이트에 나간다
게이트는 fact 계층이 담당한다. 여기서는 '원문에 있었다' 까지만 보장한다.
"""
import json
from dataclasses import dataclass, field
from typing import Optional
@ -26,16 +25,9 @@ from common.enums import PlaceCategory
from common.logger import LOG
from services.collector.base import CollectedFact
from services.grounding.extract import verify
from services.llm.gemini import (
DEFAULT_MODEL,
GeminiInvalidOutput,
GeminiNotConfigured,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.llm import provider
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts.extract import RESPONSE_SCHEMA, build_prompt
# 이보다 짧은 원문은 호출하지 않는다. 메뉴판 한 줄도 안 되는 분량에서 나올 fact 는 없고,
@ -65,7 +57,7 @@ async def extract_facts(
source_text: str,
*,
source_url: str,
model: str = DEFAULT_MODEL,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> ExtractResult:
@ -75,38 +67,31 @@ async def extract_facts(
여기서 구조적으로 찍어 둔다(사장님 붙여넣기면 'owner:paste' 같은 식별자라도 넣는다).
원문이 짧으면 **API 호출하지 않는다** 근거가 없는데 부르면 그게 환각 유발이다.
"""
if not is_configured():
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
llm = provider.active()
if not llm.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
if not (source_url or "").strip():
raise ValueError("source_url 이 비었다 — 출처 없는 추출은 하지 않는다")
model = model or llm.DEFAULT_MODEL
text = (source_text or "").strip()
if len(text) < MIN_SOURCE_CHARS:
LOG.i(f"[gemini-extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다")
LOG.i(f"[extract] '{place_name}' 원문 {len(text)}자 — 짧아서 호출하지 않는다")
return ExtractResult(rejected=[("(전체)", f"원문이 {len(text)}자로 너무 짧다 — 호출하지 않았다")])
body = {
"contents": [{"role": "user", "parts": [{"text": build_prompt(place_name, category, text)}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": RESPONSE_SCHEMA,
# ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다.
"temperature": 0.0,
},
}
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
parsed = json.loads(extract_text(payload))
except json.JSONDecodeError as ex:
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
llm_result = await llm.generate(
client, model, prompt=build_prompt(place_name, category, text),
# ★ 0.0 — 옮겨 적는 작업이다. 창의성이 개입할 자리가 없다.
response_schema=RESPONSE_SCHEMA, temperature=0.0, max_retries=max_retries,
)
finally:
if owns_client:
await client.aclose()
rows = parsed.get("facts")
rows = llm_result.json.get("facts") if llm_result.json else None
if not isinstance(rows, list):
raise GeminiInvalidOutput(f"facts 가 배열이 아니다: {type(rows).__name__}")
@ -124,14 +109,14 @@ async def extract_facts(
for row in passed
]
usage = read_usage(payload)
usage = llm_result.usage
LOG.i(
f"[gemini-extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · "
f"[extract] '{place_name}' 추출 {len(rows)}건 → 통과 {len(facts)}건 · "
f"반려 {len(rejected)}건 · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
if rejected:
for label, why in rejected[:10]:
LOG.w(f"[gemini-extract] 반려 {label}{why}")
LOG.w(f"[extract] 반려 {label}{why}")
return ExtractResult(facts=facts, rejected=rejected)

View File

@ -3,7 +3,7 @@
파일이 하는 일은 **엮는 것뿐**이다. 고칠 것이 생기면 해당 겹으로 바로 간다:
무엇을 묻는가 services/prompts/copy.py 프롬프트·응답 스키마
어떻게 부르는가 services/llm/gemini.py HTTP·재시도·토큰·비용
어떻게 부르는가 services/llm/provider.py 공급자 선택(gemini/openai) · HTTP·재시도·토큰·비용
답을 믿을 것인가 services/grounding/copy.py ground_check · faq_polarity_ok
무엇을 돌려주는가 여기 근거 모으기 호출 검증 조립
@ -20,21 +20,18 @@ import httpx
from common.enums import PlaceCategory
from common.logger import LOG
from services.grounding.copy import FactInput, faq_polarity_ok, ground_check
from services.llm.gemini import (
DEFAULT_MODEL as DEFAULT_TEXT_MODEL,
GeminiError,
GeminiInvalidOutput,
GeminiNotConfigured,
Usage,
call,
extract_text,
is_configured,
price,
read_usage,
)
from services.llm import provider
from services.llm.errors import LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
def is_configured() -> bool:
"""호출측(copy_service.py, place_service.py 등)은 이 겹만 안다 — 어느 공급자가 활성인지는 몰라도 된다."""
return provider.active().is_configured()
@dataclass
class GeneratedFaq:
question: str
@ -54,6 +51,7 @@ class GeneratedCopy:
meta_description: Optional[str] = None
faqs: list[GeneratedFaq] = field(default_factory=list)
rejected: list[tuple[str, str]] = field(default_factory=list)
source: str = "" # ★ "openai:gpt-5.6-luna" 형식 — copy_steps.py 가 fact 출처 표기에 쓴다
def _unit_facts(unit_summaries: Optional[list[dict]]) -> list[FactInput]:
@ -100,7 +98,7 @@ async def generate_copy(
records: Optional[list[str]] = None,
suggested_questions: Optional[list[str]] = None,
max_faqs: int = 8,
model: str = DEFAULT_TEXT_MODEL,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> GeneratedCopy:
@ -111,13 +109,15 @@ async def generate_copy(
생성 결과는 전부 ground_check 통과한 것만 담긴다. 통과 항목은 rejected 간다.
생성 대상 필드는 업종 스키마의 allow_llm=True 것뿐이다(호출측이 필터링해서 넘긴다).
"""
if not is_configured():
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
llm = provider.active()
if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
model = model or llm.DEFAULT_MODEL
# ★ 사업장 fact 가 없어도 객실·메뉴 근거가 있으면 쓴다. 요금표만 있는 모텔이 그 경우다 —
# "대실 20,000원" 은 근거 있는 사실이고, 손님이 가장 먼저 묻는 것이기도 하다.
unit_grounding = _unit_facts(unit_summaries)
if not facts and not unit_grounding:
LOG.i(f"[gemini-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)")
LOG.i(f"[llm-text] '{place_name}' 근거 fact 0건 — 생성하지 않는다(호출 없음)")
return GeneratedCopy(rejected=[("(전체)", "근거 fact 가 없다 — 생성하지 않았다")])
# 검증에 쓸 근거 = 넘겨받은 fact + 객실 요약 + 상호명(상호에 숫자가 있어도 근거로 본다)
@ -125,31 +125,22 @@ async def generate_copy(
grounding.append(FactInput(key="place_name", label="상호명", value=place_name))
allowed_keys = {f.key for f in facts} | {f.key for f in grounding}
body = {
"contents": [{"role": "user", "parts": [{
"text": build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions)
}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": RESPONSE_SCHEMA,
"temperature": 0.2,
},
}
prompt = build_prompt(place_name, category, facts, max_faqs, unit_grounding, records, suggested_questions)
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
parsed = json.loads(extract_text(payload))
except json.JSONDecodeError as ex:
raise GeminiInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
llm_result = await llm.generate(
client, model, prompt=prompt, response_schema=RESPONSE_SCHEMA, temperature=0.2, max_retries=max_retries,
)
finally:
if owns_client:
await client.aclose()
usage = read_usage(payload)
parsed = llm_result.json
usage = llm_result.usage
result = GeneratedCopy()
result = GeneratedCopy(source=f"{llm.__name__.rsplit('.', 1)[-1]}:{model}")
# ── 소개문 ──
intro = (parsed.get("intro") or "").strip()
@ -190,10 +181,10 @@ async def generate_copy(
result.faqs.append(GeneratedFaq(question=question, answer=answer, fact_keys=keys))
LOG.i(
f"[gemini-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · "
f"[llm-text] '{place_name}' 생성 — 소개문 {'O' if result.intro else 'X'} · "
f"메타 {'O' if result.meta_description else 'X'} · FAQ {len(result.faqs)}건 · "
f"반려 {len(result.rejected)}건 · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
return result
@ -215,7 +206,7 @@ _SUMMARY_PROMPT = (
async def summarize_text(
text: str,
*,
model: str = DEFAULT_TEXT_MODEL,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> Optional[str]:
@ -227,8 +218,10 @@ async def summarize_text(
stripped = text.strip()
if not stripped:
return None
if not is_configured():
llm = provider.active()
if not llm.is_configured():
return None
model = model or llm.DEFAULT_MODEL
# 길이 기준을 바꾼 뒤 이전 길이의 요약을 재사용하지 않도록 프롬프트도 키에 넣는다.
cache_key = hashlib.sha256((_SUMMARY_PROMPT + stripped).encode("utf-8")).hexdigest()
@ -236,20 +229,13 @@ async def summarize_text(
if cached is not None:
return cached
body = {
"contents": [{"role": "user", "parts": [{
"text": _SUMMARY_PROMPT + stripped,
}]}],
"generationConfig": {"temperature": 0.2},
}
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
summary = extract_text(payload).strip()
except GeminiError as ex:
LOG.w(f"[gemini-text] 요약 실패: {ex}")
result = await llm.generate(client, model, prompt=_SUMMARY_PROMPT + stripped, temperature=0.2, max_retries=max_retries)
summary = result.text.strip()
except LlmError as ex:
LOG.w(f"[llm-text] 요약 실패: {ex}")
return None
finally:
if owns_client:
@ -257,6 +243,11 @@ async def summarize_text(
if not summary:
return None
usage = result.usage
LOG.i(
f"[llm-text] 요약 {len(stripped)}자 → {len(summary)}자 · model={model} · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
if len(_SUMMARY_CACHE) >= _SUMMARY_CACHE_MAX:
_SUMMARY_CACHE.clear() # 간단한 캐시 상한 — 관리 도구 트래픽 규모에는 LRU 가 과하다.
_SUMMARY_CACHE[cache_key] = summary
@ -279,7 +270,7 @@ async def generate_song(
region: str,
grounding: list[str],
intro: str = "",
model: str = DEFAULT_TEXT_MODEL,
model: Optional[str] = None,
max_retries: int = 2,
client: Optional[httpx.AsyncClient] = None,
) -> GeneratedSong:
@ -291,47 +282,87 @@ async def generate_song(
재료가 하나도 없으면 부르지 않는다 소개문과 같은 규칙이다. 상호와 지역만으로 노래는
어느 숙소에 붙여도 말이 되는 노래이고, 그건 기능이 하려던 일이 아니다.
"""
if not is_configured():
raise GeminiNotConfigured("GEMINI_API_KEY 가 설정되지 않았다")
llm = provider.active()
if not llm.is_configured():
raise GeminiNotConfigured("API 키가 설정되지 않았다")
if not grounding and not (intro or "").strip():
raise GeminiInvalidOutput("가사를 쓸 재료가 없다 — 확인된 fact 도 소개문도 없다")
model = model or llm.DEFAULT_MODEL
from common.category_schema import get_schema
from services.prompts.song import RESPONSE_SCHEMA as SONG_SCHEMA, build_prompt as build_song_prompt
body = {
"contents": [{"role": "user", "parts": [{
"text": build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
}]}],
"generationConfig": {
"responseMimeType": "application/json",
"responseSchema": SONG_SCHEMA,
# 소개문(0.2)보다 높다 — 노래는 정확해야 하는 글이 아니라 흥얼거릴 글이다.
"temperature": 0.9,
},
}
prompt = build_song_prompt(place_name, get_schema(category).label, region, grounding, intro)
owns_client = client is None
client = client or httpx.AsyncClient(timeout=httpx.Timeout(120.0, connect=10.0))
try:
payload = await call(client, model, body, max_retries)
parsed = json.loads(extract_text(payload))
except json.JSONDecodeError as ex:
raise GeminiInvalidOutput(f"가사 파싱 실패: {ex}") from ex
llm_result = await llm.generate(
client, model, prompt=prompt, response_schema=SONG_SCHEMA, temperature=0.9, max_retries=max_retries,
)
finally:
if owns_client:
await client.aclose()
parsed = llm_result.json or {}
title = (parsed.get("title") or "").strip()
lyrics = (parsed.get("lyrics") or "").strip()
style = (parsed.get("style") or "").strip()
if not lyrics:
raise GeminiInvalidOutput("가사가 비어 있다")
usage = read_usage(payload)
usage = llm_result.usage
LOG.i(
f"[gemini-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${price(model, usage)}"
f"[llm-text] '{place_name}' 가사 — '{title}' ({style}) · {len(lyrics)}자 · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${llm.price(model, usage)}"
)
# 제목이 비면 상호를 쓴다 — 빈 제목은 플레이어에서 빈 줄로 보인다.
return GeneratedSong(title=title or place_name, lyrics=lyrics, style=style or "acoustic ballad")
async def generate_social_post(place_name, facts, link_url, provider=2, *, client=None):
"""실제 게시 문자열을 검증한다. 초과·근거 실패 시 다시 받고 문장을 자르지 않는다.
2026-09-21: 다른 생성 함수(generate_copy ) 같은 이유로 공급자 선택을 탄다
(`LLM_PROVIDER`, 기본 openai) Gemini 고정을 없앴다. 인자 이름 `provider`
SNS 플랫폼(쓰레드=2) 가리키는 기존 값이라, LLM 공급자 모듈은 `llm_provider`
따로 들여와 이름이 겹치지 않게 한다."""
from services.prompts import social
from services.external.social import adapter, weighted_length, URL
from services.llm import provider as llm_provider
import unicodedata
if not facts:
raise GeminiInvalidOutput('NO_GROUNDED_FACTS')
llm = llm_provider.active()
if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
limit = adapter(provider).weighted_limit()
allowed = {f.key for f in facts}
feedback = ''
owns = client is None
client = client or httpx.AsyncClient(timeout=45)
try:
for _ in range(3):
prompt = social.build_prompt(
place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)
result = await llm.generate(
client, llm.DEFAULT_MODEL, prompt=prompt,
response_schema=social.RESPONSE_SCHEMA, temperature=0.2, max_retries=0,
)
try:
parsed = result.json if result.json is not None else json.loads(result.text)
body = unicodedata.normalize('NFC', parsed['body'].strip())
keys = parsed['fact_keys']
text = body + '\n\n' + link_url
ok, _ = ground_check(body, facts + [FactInput(key='name', label='상호명', value=place_name)])
if (body and isinstance(keys, list) and keys and all(k in allowed for k in keys)
and ok and not URL.search(body) and weighted_length(text, provider) <= limit):
return text
except (ValueError, KeyError, TypeError):
pass
feedback = '이전 응답은 길이 또는 근거 검증에 실패했다. 더 짧게, 제공된 사실만으로 다시 써라.'
raise GeminiInvalidOutput('SOCIAL_INVALID_OUTPUT')
finally:
if owns:
await client.aclose()

View File

@ -30,6 +30,7 @@ from services.llm.perplexity import (
PerplexityNotConfigured,
call,
is_configured,
read_usage,
)
from services.prompts.channel_discovery import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
@ -126,12 +127,13 @@ async def discover_channels(
)
# 내부 검색 횟수는 품질·지연 관측값이다. Sonar 과금은 토큰 + 요청 컨텍스트 요금이다.
usage = payload.get("usage") or {}
usage = read_usage(payload)
reasons = result.reason_counts()
reason_text = " ".join(f"{k}{v}" for k, v in sorted(reasons.items())) or "없음"
LOG.i(
f"[perplexity] '{name}' 검색={searches}회 발견={len(found)} 통과={len(links)} "
f"탈락={len(filtered_out)}({reason_text}) tokens={usage.get('total_tokens', '?')}"
f"탈락={len(filtered_out)}({reason_text}) "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}"
)
if searches > SEARCH_COUNT_WARN_THRESHOLD:
LOG.w(

View File

@ -8,7 +8,7 @@
import json
from common.logger import LOG
from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call
from services.llm.perplexity import DEFAULT_MAX_TOKENS, DEFAULT_MODEL, PerplexityError, call, read_usage
from services.prompts.restaurant_search import RESPONSE_SCHEMA, SYSTEM_PROMPT, build_prompt
MAX_RESULTS = 10
@ -54,4 +54,10 @@ async def search_region_restaurants(
except PerplexityError as ex:
LOG.w(f"[restaurant_discovery] '{region_label}' 검색 실패: {ex}")
return []
return _parse_names(payload)
names = _parse_names(payload)
usage = read_usage(payload)
LOG.i(
f"[restaurant_discovery] '{region_label}' {len(names)}곳 · "
f"tokens in={usage.input_tokens} out={usage.output_tokens} · 약 ${usage.cost}"
)
return names

View File

@ -0,0 +1,50 @@
"""플랫폼 교체 경계. 전송 결과 불명은 재시도 가능한 실패와 절대 섞지 않는다."""
import re
import unicodedata
class SocialError(RuntimeError):
def __init__(self, code="SOCIAL_FAILED", *, reauth=False):
super().__init__(code)
self.reauth = reauth
class SocialOutcomeUnknown(SocialError):
pass
URL = re.compile(r"https?://[^\s]+", re.I)
def weighted_length(text: str, provider: int = 1) -> int:
text = unicodedata.normalize("NFC", text)
if provider == 2:
return len(text)
# 보수적으로 이모지 조합의 각 코드포인트도 센다. 공식 가중치보다 작게 세지 않는다.
def weight(c):
n = ord(c)
return (
1
if n <= 0x10FF
or 0x2000 <= n <= 0x200D
or 0x2010 <= n <= 0x201F
or 0x2032 <= n <= 0x2037
else 2
)
length = 0
pos = 0
for match in URL.finditer(text):
length += sum(weight(c) for c in text[pos : match.start()]) + 23
pos = match.end()
return length + sum(weight(c) for c in text[pos:])
def adapter(provider: int):
if provider == 2:
from services.external import threads
return threads
raise SocialError("UNSUPPORTED_PROVIDER")

View File

@ -0,0 +1,206 @@
"""Meta 공식 Threads API. 장기 액세스 토큰을 갱신하며 X의 refresh-token 계약을 요구하지 않는다."""
from config import social_config as config
from urllib.parse import urlencode, urlparse
import httpx
from services.external.social import SocialError, SocialOutcomeUnknown, weighted_length
BASE = "https://graph.threads.net/v1.0"
# ★ OAuth 토큰 엔드포인트는 **버전 접두어가 없고 토큰을 쿼리 파라미터로 받는다.**
# 데이터 엔드포인트(/me, /me/threads)와 규칙이 다르다 — 거기서만 Bearer 헤더가 통한다.
# 실측(2026-09-18): 장기 토큰 교환을 `/v1.0/access_token` + Bearer 로 부르자
# `[4279019] Session key invalid` 로 거절당해 연결이 매번 실패했다.
OAUTH_BASE = "https://graph.threads.net"
SCOPES = {"threads_basic", "threads_content_publish"}
def is_configured():
return all(
config.get(k)
for k in ("THREADS_APP_ID", "THREADS_APP_SECRET", "THREADS_REDIRECT_URI")
)
def weighted_limit():
return 500
def authorize_url(state, verifier):
# Threads는 서버측 코드 교환이다. X 전용 PKCE 파라미터를 전송하지 않는다.
return "https://threads.net/oauth/authorize?" + urlencode(
dict(
client_id=config.required("THREADS_APP_ID"),
redirect_uri=config.required("THREADS_REDIRECT_URI"),
response_type="code",
scope=",".join(sorted(SCOPES)),
state=state,
)
)
def _read(res):
try:
data = res.json()
except ValueError as ex:
raise SocialError("THREADS_INVALID_RESPONSE") from ex
if res.status_code >= 400 or data.get("error"):
error = data.get("error") or {}
# ★ 플랫폼이 준 사유를 코드에 붙인다. `THREADS_REJECTED_400` 만으로는 무엇이 틀렸는지
# 알 수 없어서 — 코드가 만료됐는지, 리디렉션 URI 가 안 맞는지, 권한이 모자란지 —
# 실제로 원인을 좁히지 못했다(실측 2026-09-18: 연결 실패가 400 이라는 것만 알고
# Meta 가 뭐라고 했는지는 어디에도 안 남아 세 번을 헛짚었다).
# ★ 남기는 것은 message·subcode 뿐이다. 토큰·시크릿·code 는 담지 않는다 —
# 이 문자열은 로그로 가고, 로그는 우리가 아닌 사람도 본다.
detail = str(error.get("message") or "")[:160]
subcode = error.get("error_subcode") or error.get("code")
raise SocialError(
f"THREADS_REJECTED_{res.status_code}"
+ (f"[{subcode}]" if subcode else "")
+ (f" {detail}" if detail else ""),
reauth=error.get("code") == 190 or res.status_code == 401,
)
return data
async def exchange(code, verifier, *, client):
short = _read(
await client.post(
f"{OAUTH_BASE}/oauth/access_token",
data={
"client_id": config.required("THREADS_APP_ID"),
"client_secret": config.required("THREADS_APP_SECRET"),
"grant_type": "authorization_code",
"redirect_uri": config.required("THREADS_REDIRECT_URI"),
"code": code,
},
)
)
result = _read(
await client.get(
f"{OAUTH_BASE}/access_token",
params={
"grant_type": "th_exchange_token",
"client_secret": config.required("THREADS_APP_SECRET"),
"access_token": short["access_token"],
},
)
)
token = result["access_token"]
debug = _read(
await client.get(
f"{BASE}/debug_token",
params={"input_token": token},
headers={
"Authorization": f"Bearer TH|{config.required('THREADS_APP_ID')}|{config.required('THREADS_APP_SECRET')}"
},
)
)["data"]
# ★ `app_id` 로 우리 앱 토큰인지 대조하던 줄을 뺐다. Threads 의 debug_token 응답에는
# 그 필드가 **없다**(실측 2026-09-18: is_valid·scopes·type·user_id·application 뿐).
# 없는 값을 `str(None)` 과 비교하니 **항상 불일치**였다 — 장기 토큰을 제대로 받아도
# 바로 다음 줄에서 THREADS_SCOPES_REQUIRED 로 떨어지는, 통과할 수 없는 검사였다.
# ★ 그렇다고 검사를 통째로 버리지 않는다: 응답에 app_id 가 있으면(다른 플랫폼·향후 변경)
# 그때는 대조한다. 이 토큰은 우리 client_secret 으로 우리가 교환해 받은 것이라
# 남의 앱 토큰이 섞일 경로가 이 함수 안에는 없다.
app_id = debug.get("app_id")
if (
not debug.get("is_valid")
or (app_id is not None and str(app_id) != config.required("THREADS_APP_ID"))
or not SCOPES.issubset(set(debug.get("scopes", [])))
):
raise SocialError("THREADS_SCOPES_REQUIRED", reauth=True)
if int(result.get("expires_in", 0)) < 86400:
raise SocialError("THREADS_LONG_LIVED_TOKEN_REQUIRED", reauth=True)
result["scope"] = " ".join(sorted(SCOPES))
return result
async def refresh(token, *, client):
# ★ 갱신도 OAuth 엔드포인트다 — 버전 접두어 없이, 토큰은 쿼리 파라미터로.
# Bearer 헤더로 보내면 플랫폼이 **헤더를 읽지 않고** `The parameter access_token is
# required.` 로 거절한다(실측 2026-09-18, 같은 토큰으로 두 형식 대조).
# ★ 이건 연결 당시에는 안 드러나고 **60일 뒤 갱신에서** 터지는 종류다 —
# 그때는 사장님 계정이 조용히 만료돼 게재만 멈춘다.
result = _read(
await client.get(
f"{OAUTH_BASE}/refresh_access_token",
params={"grant_type": "th_refresh_token", "access_token": token},
)
)
if not result.get("access_token") or int(result.get("expires_in", 0)) <= 0:
raise SocialError("THREADS_REFRESH_FAILED", reauth=True)
result["scope"] = " ".join(sorted(SCOPES))
return result
async def me(token, *, client):
data = _read(
await client.get(
f"{BASE}/me",
params={"fields": "id,username"},
headers={"Authorization": f"Bearer {token}"},
)
)
return {
"id": data["id"],
"handle": data["username"],
"profile_url": f"https://www.threads.com/@{data['username']}",
}
async def publish(text, token, *, client):
if weighted_length(text, 2) > weighted_limit():
raise SocialError("TEXT_TOO_LONG")
headers = {"Authorization": f"Bearer {token}"}
# 컨테이너 생성은 아직 게시가 아니다. auto_publish_text를 켜면 이 구분이 사라진다.
try:
container = _read(
await client.post(
f"{BASE}/me/threads",
headers=headers,
data={"media_type": "TEXT", "text": text, "auto_publish_text": "false"},
)
)
container_id = container["id"]
except (httpx.TransportError, KeyError, TypeError) as ex:
raise SocialError("THREADS_CONTAINER_FAILED") from ex
try:
res = await client.post(
f"{BASE}/me/threads_publish",
headers=headers,
data={"creation_id": container_id},
)
if res.status_code >= 500 or res.status_code == 408:
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN")
# 성공 응답 파싱 실패·permalink 조회 실패도 이미 게시했을 수 있으므로 재전송 금지.
if res.status_code >= 400:
_read(res)
data = res.json()
post_id = str(data["id"])
if not post_id.isdigit():
raise ValueError()
except SocialError:
raise
except (httpx.TransportError, ValueError, KeyError, TypeError) as ex:
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN") from ex
try:
detail = _read(
await client.get(
f"{BASE}/{post_id}", params={"fields": "permalink"}, headers=headers
)
)
permalink = detail["permalink"]
parsed = urlparse(permalink)
if parsed.scheme != "https" or parsed.hostname not in (
"www.threads.net",
"threads.net",
"www.threads.com",
"threads.com",
):
raise ValueError()
except (httpx.TransportError, SocialError, ValueError, KeyError, TypeError):
# 게시 ID는 확보했다. 링크 조회 실패를 게시 실패로 취급하면 사장님이 다시 올린다.
permalink = None
return {"id": post_id, "permalink": permalink}

View File

@ -21,8 +21,10 @@ collector/tour_api_adapter.py 가 '사업장 1곳'의 fact 를 캐는 쪽이라
진행 예정 축제(군산시간여행축제 ) 끝내 잡혔다. searchFestival2 법정동(시도)
단위로 묻지만 정확하고 기간까지 함께 준다 그래서 시도 전체를 받아 우리가 거리로 거른다.
eventStartDate 파라미터로 날짜 **이후 시작하는** 행사만 거른다(이전에 시작해 아직
진행 중인 행사는 잡히지 않는다 실측). 그래서 항상 ** 1 1** 고정해 부르고,
이미 끝난 행사(eventenddate < 오늘) 우리가 거른다.
진행 중인 행사는 잡히지 않는다 실측). 그래서 항상 ** 1 1** 고정해 부른다.
종료된 행사도 그대로 싣는다(2026-09-17 결정) 시작일을 2020년으로 당겨 실측해도 API 자체가
행사를 추가로 주지 않아(최근~예정 위주) 여기서 거를 실익이 없고, 이미 끝난 축제를
보여줄지는 노출 단계(local_content_service) 몫으로 넘긴다.
이미지 저작권 수집 단계에서 끝낸다 (collector/tour_api_adapter.py 같은 규칙)
firstimage 공공누리 Type1(출처표시)·Type3(출처표시+변경금지) 남긴다.
@ -235,7 +237,7 @@ def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
`_normalize` 같은 규약이다 렌더러 이름으로 바꿔 내보내고, 저장 자리는 부르는 쪽이 정한다.
기간(eventstartdate/enddate) **원값 그대로** 남긴다. 화면 문자열("2026.10.01 ~ …") 미리
구워 두면 노출 기간 필터(`_festival_not_ended`·display_end_at) 읽을 값이 없어진다.
구워 두면 정렬·계절 산출(site_payload._festival) 읽을 값이 없어진다.
날짜는 사실이고 문장은 표기다 사실만 저장한다.
"""
content_id = str(item.get("contentid") or "").strip()
@ -266,26 +268,19 @@ def _normalize_festival(item: dict, distance_m: int) -> Optional[dict]:
return out
def _festival_not_ended(body: dict, today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 신지 않는다. 기간을 아예 모르면 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 유지한다 끝났다는 증거가 없다."""
end, start = body.get("eventenddate"), body.get("eventstartdate")
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)
async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, longitude: float,
*, sido_code: str, today: date) -> list[dict]:
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 이미 끝난 것 제외). 반경으로 자르지 않는다.
"""업장이 속한 시도의 축제 **전부**(정규화, 거리순, 종료 여부와 무관하게 전부). 반경으로 자르지 않는다.
반경을 두는 이유(2026-09-08 결정): 축제는 차로 가는 행사라 20km 자르면 시도 안의
축제가 빠진다. 시도 전체를 그대로 싣고, 거리는 정렬·표시용으로만 잰다.
(종류별 노출 상한은 스냅샷이 20건으로 자른다 사진 있는 우선 가까운 .)
locationBasedList2 위치 색인은 믿어서 searchFestival2 쓴다( 모듈 docstring).
eventStartDate 1 1일로 **고정** "오늘" 넣으면 이전에 시작해 아직 진행 중인
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받고, 끝난 것만 여기서 거른다.
축제가 파라미터 자체에서 빠진다(실측). 연초부터 전부 받는다.
종료된 축제도 거르지 않고 그대로 싣는다(2026-09-17 결정) 시작일을 2020년으로 당겨 실측해도
API 행사를 추가로 주지 않아 거를 실익이 없었고, 실제로 보여줄지는 노출 단계
(local_content_service.py area_contents.display_end_at) 정한다.
좌표 없는 항목은 뺀다 distance_m NOT NULL 이고, 거리 없는 카드는 도보 필터에 얹는다.
"""
start_date = date(today.year, 1, 1).strftime("%Y%m%d")
@ -305,8 +300,6 @@ async def fetch_festivals_in_sido(client: httpx.AsyncClient, latitude: float, lo
body = _normalize_festival(item, round(distance))
if not body or body["contentid"] in seen:
continue
if not _festival_not_ended(body, today):
continue
seen.add(body["contentid"])
out.append(body)
if not items or page * PAGE_SIZE >= total:
@ -334,15 +327,3 @@ async def fetch_content_class(client: httpx.AsyncClient, content_id: str) -> Opt
return None
code = str(items[0].get("lclsSystm2") or "").strip()
return code or None
def festival_is_current(period: Optional[tuple[str, str]], today: date) -> bool:
"""종료일이 지났으면 끝난 축제 — 싣지 않는다. 기간을 아예 모르면(None) 못 믿으니 역시 뺀다.
종료일 없이 시작일만 있으면(무기한 진행) 시작일이 지났어도 유지한다 끝났다는 증거가 없다."""
if not period:
return False
start, end = period
ymd = today.strftime("%Y%m%d")
if end:
return len(end) == 8 and end.isdigit() and end >= ymd
return bool(start)

View File

@ -126,6 +126,8 @@ async def _generate_one(
courses: list[dict] = []
seen: set[frozenset[str]] = set()
notes: list[str] = []
total_in = total_out = 0
total_cost = 0.0
for attempt in range(1, MAX_ATTEMPTS + 1):
try:
payload = await perplexity.call(body, client=client)
@ -136,6 +138,11 @@ async def _generate_one(
notes.append(f"{attempt}차 호출 실패: {ex}")
break # 같은 오류가 반복될 걸 재시도로 밀어붙이지 않는다 — 지금까지 모은 것만 쓴다
usage = perplexity.read_usage(payload)
total_in += usage.input_tokens
total_out += usage.output_tokens
total_cost += usage.cost
new_courses, dropped = grounding.parse_courses(
payload, duration, place_name, place_lat, place_lng, already_seen=seen)
notes += dropped
@ -151,7 +158,10 @@ async def _generate_one(
if len(courses) < TARGET_COURSES:
notes.append(f"{MAX_ATTEMPTS}차 시도 후에도 {len(courses)}/{TARGET_COURSES}개만 채웠다")
LOG.i(f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내")
LOG.i(
f"[itinerary] {place_name} {duration}: {len(courses)}개 채택, {len(notes)}건 버림/안내 · "
f"tokens in={total_in} out={total_out} · 약 ${round(total_cost, 6)}"
)
return courses, notes

View File

@ -0,0 +1,201 @@
"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다.
파일이 없으면 채널 진입점만 소유자 범위 밖에 놓인다. 다른 엔드포인트는 전부
place_crud.get_place(s, owner_user_id, place_id) "없는 것과 남의 것을 똑같이
PLACE_NOT_FOUND " 답하는데, 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가
없다 카카오가 주는 것은 **채널 단위 익명 **뿐이다.
일회성은 코드 값이 아니라 `WHERE status='PENDING'` CAS 보장한다. 조회 갱신으로
나누면 같은 코드가 먹는다(승인 흐름이 같은 이유로 문장이다).
"""
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from uuid import UUID
from sqlalchemy import select, text, update
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_kakao_links as Link
from common.enums import KakaoLinkStatus
from config import agent_config as config
# 사장님이 카톡 대화창에 손으로 친다. 혼동하는 글자(0·O·1·I·L)는 뺀다 —
# 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다.
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
_CODE_LENGTH = 6
class KakaoLinkError(RuntimeError):
"""도메인 예외. 코드 문자열만 담고 HTTP 변환은 라우터가 한다(social 과 같은 규약)."""
def __init__(self, code="KAKAO_LINK_FAILED"):
super().__init__(code)
def enabled() -> bool:
return config.kakao_link_enabled()
def _now():
return datetime.now(timezone.utc)
def _sha(code: str) -> str:
return hashlib.sha256(code.strip().upper().encode()).hexdigest()
def _new_code() -> str:
return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH))
async def _lock_user(s, user_id):
"""연결·재발급·해제가 같은 잠금을 공유한다(social_account_service.lock_user 와 같은 방식).
잠금이 아니라 advisory 이유: PENDING 행이 아직 없을 수도 있어서, 잠글 자체가
없는 순간이 존재한다."""
await s.execute(
text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"),
{"key": f"kakao_link:{user_id}"},
)
async def _active(s, user_id):
return (
await s.execute(
select(Link).where(
Link.user_id == user_id,
Link.deleted.is_(False),
Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]),
)
)
).scalars().first()
async def state(user_id: UUID) -> dict:
"""빌더 카드가 읽는 값. ★ 코드 평문은 여기서 절대 돌려주지 않는다 — 발급 응답에서 한 번만 준다."""
async def run(s):
row = await _active(s, user_id)
return {
"connection_enabled": enabled(),
"channel_url": config.channel_url(),
"status": row.status if row else None,
"linked_at": row.linked_at.isoformat() if row and row.linked_at else None,
"code_expires_at": (
row.code_expires_at.isoformat()
if row and row.status == KakaoLinkStatus.PENDING.value and row.code_expires_at
else None
),
}
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def issue_code(user_id: UUID) -> dict:
"""일회용 코드를 낸다. 이미 PENDING 이면 **같은 행의 코드만 교체**한다.
행을 새로 만들지 않는 이유는 uq_kakao_link_user 때문만이 아니다 사장님이 버튼을
눌렀을 코드가 살아 있으면, 어느 것이 먹을지 화면이 말해 없다."""
if not enabled():
raise KakaoLinkError("KAKAO_LINK_DISABLED")
code = _new_code()
expires = _now() + timedelta(minutes=int(config.get("KAKAO_LINK_CODE_TTL_MIN", 10)))
async def run(s):
await _lock_user(s, user_id)
row = await _active(s, user_id)
if row is not None and row.status == KakaoLinkStatus.LINKED.value:
raise KakaoLinkError("KAKAO_LINK_ALREADY")
if row is None:
row = Link(user_id=user_id, status=KakaoLinkStatus.PENDING.value)
s.add(row)
row.code_sha = _sha(code)
row.code_expires_at = expires
row.code_attempts = 0
return {"code": code, "expires_at": expires.isoformat(), "channel_url": config.channel_url()}
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def redeem(code: str, channel_user_key: str) -> UUID:
"""채널에서 들어온 코드를 소비하고 user_id 를 돌려준다. 실패는 전부 같은 에러다.
"없는 코드" "남의 코드" "만료" 구분해 답하지 않는다 구분해 주면 짧은
코드의 유효성을 외부에서 탐색할 있다.
아직 공개 엔드포인트가 아니다. 채널 웹훅(4단계) 함수를 부르고, 웹훅은
자체 서명 검증을 따로 갖춰야 한다."""
sha = _sha(code)
max_attempts = int(config.get("KAKAO_LINK_MAX_ATTEMPTS", 5))
async def run(s):
# ★ 한 문장 CAS. 조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다.
row = (
await s.execute(
text("""UPDATE owner_kakao_links
SET status='LINKED', channel_user_key=:key, linked_at=now(),
last_seen_at=now(), code_sha=NULL, code_expires_at=NULL, updated_at=now()
WHERE code_sha=:sha AND deleted=false AND status='PENDING'
AND code_expires_at > now() AND code_attempts < :max
RETURNING user_id"""),
{"sha": sha, "key": channel_user_key, "max": max_attempts},
)
).first()
if row is None:
# 맞는 코드가 없으면 셀 행도 없다. 있는 코드에 대한 오입력만 세어진다.
await s.execute(
text("""UPDATE owner_kakao_links SET code_attempts = code_attempts + 1, updated_at=now()
WHERE code_sha=:sha AND deleted=false AND status='PENDING'"""),
{"sha": sha},
)
raise KakaoLinkError("KAKAO_LINK_CODE_INVALID")
return row.user_id
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def resolve(channel_user_key: str) -> UUID | None:
"""채널 발화자 → user_id. 매핑이 없으면 None 이고, 호출측은 거기서 멈춰야 한다.
None "아무 사장님" 으로 흘려보내면 기능 전체가 무의미해진다."""
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
row.last_seen_at = _now()
return row.user_id
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def disconnect(user_id: UUID) -> None:
"""연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다.
channel_user_key 남긴다. 부분 유니크가 status='LINKED' 조건이라 재연결을 막지 않는다."""
async def run(s):
await _lock_user(s, user_id)
result = await s.execute(
update(Link)
.where(
Link.user_id == user_id,
Link.deleted.is_(False),
Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]),
)
.values(status=KakaoLinkStatus.REVOKED.value, code_sha=None, code_expires_at=None)
)
if result.rowcount == 0:
raise KakaoLinkError("KAKAO_LINK_NOT_FOUND")
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)

View File

@ -0,0 +1,16 @@
"""공급자 무관 LLM 예외. Gemini·OpenAI 구현이 둘 다 이 클래스를 던진다.
이름을 'Llm*'으로 새로 지었지만 services/llm/gemini.py GeminiError = LlmError 식으로
같은 클래스를 재노출한다 기존 6 파일의 `except GeminiNotConfigured` 글자도 바뀐다."""
class LlmError(RuntimeError):
"""LLM 호출 실패."""
class LlmNotConfigured(LlmError):
"""API 키 미설정 또는 인증 실패(401/403)."""
class LlmInvalidOutput(LlmError):
"""응답이 기대한 모양이 아니다."""

View File

@ -8,11 +8,17 @@
여기가 책임지지 않는 : 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/).
"""
import asyncio
from dataclasses import dataclass
import base64
import json
import httpx
from config.server_configs import external_api_config
from services.llm.errors import LlmError as GeminiError
from services.llm.errors import LlmInvalidOutput
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput # noqa: F401 (하위 호환 재노출)
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.llm.types import ImagePart, LlmResult, Usage
_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
@ -26,28 +32,6 @@ _PRICE_PER_1M_OUTPUT = {"gemini-3.7-flash": 3.75, "gemini-3.6-flash": 3.75, "gem
_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
class GeminiError(RuntimeError):
"""Gemini 호출 실패 — 호출측은 ErrorType.GENERATOR_CALL_FAILED 로 매핑한다."""
class GeminiNotConfigured(GeminiError):
"""GEMINI_API_KEY 미설정 또는 인증 실패.
서버 부팅은 막지 않는다 어댑터만 비활성이고 나머지 파이프라인은 돈다."""
class GeminiInvalidOutput(GeminiError):
"""응답이 기대한 모양이 아니다 — ErrorType.GENERATOR_INVALID_OUTPUT."""
@dataclass
class Usage:
"""호출 1회(또는 여러 회 합산)의 토큰 사용량. 비용 로그의 근거다."""
input_tokens: int = 0
output_tokens: int = 0
def is_configured() -> bool:
"""키가 있는지 — 어댑터 등록/스킵 판단용. 예외를 던지지 않는다."""
return bool(external_api_config.gemini_api_key)
@ -121,3 +105,37 @@ def price(model: str, usage: Usage) -> float:
inp = _PRICE_PER_1M_INPUT.get(model, 0.0) * usage.input_tokens / 1_000_000
out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000
return round(inp + out, 4)
async def generate(
client: httpx.AsyncClient,
model: str,
*,
prompt: str,
images: list[ImagePart] | None = None,
response_schema: dict | None = None,
temperature: float = 0.2,
max_retries: int = 2,
) -> LlmResult:
"""공급자 무관 인터페이스. services/llm/openai.py 가 같은 시그니처로 구현한다."""
parts: list[dict] = [{"text": prompt}]
for image in images or []:
if image.label:
parts.append({"text": image.label})
parts.append({"inline_data": {"mime_type": image.mime_type, "data": base64.b64encode(image.data).decode()}})
generation_config: dict = {"temperature": temperature}
if response_schema is not None:
generation_config["responseMimeType"] = "application/json"
generation_config["responseSchema"] = response_schema
body = {"contents": [{"role": "user", "parts": parts}], "generationConfig": generation_config}
payload = await call(client, model, body, max_retries)
text = extract_text(payload)
parsed = None
if response_schema is not None:
try:
parsed = json.loads(text)
except json.JSONDecodeError as ex:
raise LlmInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
return LlmResult(json=parsed, text=text, usage=read_usage(payload))

View File

@ -0,0 +1,127 @@
"""OpenAI Chat Completions 호출 — services/llm/gemini.py 와 같은 자리, 다른 공급자.
여기가 책임지는 : 주소·인증 헤더·재시도·구조화 출력 스키마 변환·응답 파싱·토큰 집계·비용 계산.
여기가 책임지지 않는 : 무엇을 물을지(services/prompts/), 답을 믿을지(services/grounding/)."""
import asyncio
import base64
import json
import httpx
from config.server_configs import external_api_config
from services.llm.errors import LlmError, LlmInvalidOutput, LlmNotConfigured
from services.llm.types import ImagePart, LlmResult, Usage
_BASE_URL = "https://api.openai.com/v1/chat/completions"
DEFAULT_MODEL = "gpt-5.6-luna"
_PRICE_PER_1M_INPUT = {"gpt-5.6-luna": 0.20, "gpt-5.6-terra": 2.00, "gpt-5.6-sol": 5.00}
_PRICE_PER_1M_OUTPUT = {"gpt-5.6-luna": 1.20, "gpt-5.6-terra": 12.00, "gpt-5.6-sol": 30.00}
_RETRYABLE_STATUS = {408, 429, 500, 502, 503, 504}
def is_configured() -> bool:
return bool(external_api_config.openai_api_key)
def _to_strict_schema(schema: dict) -> dict:
"""OpenAI strict 모드 요구사항(모든 object 에 additionalProperties:false,
모든 property required) 만족하도록 재귀 변환한다.
Gemini용 RESPONSE_SCHEMA(OpenAPI 서브셋, services/prompts/*.py) 그대로 받아
변환한다 관리하지 않는다."""
schema = dict(schema)
if schema.get("type") == "object" and "properties" in schema:
schema["properties"] = {k: _to_strict_schema(v) for k, v in schema["properties"].items()}
schema["required"] = list(schema["properties"].keys())
schema["additionalProperties"] = False
elif schema.get("type") == "array" and "items" in schema:
schema["items"] = _to_strict_schema(schema["items"])
return schema
def _build_messages(prompt: str, images: list[ImagePart] | None) -> list[dict]:
content: list[dict] = [{"type": "text", "text": prompt}]
for image in images or []:
if image.label:
content.append({"type": "text", "text": image.label})
b64 = base64.b64encode(image.data).decode()
content.append({"type": "image_url", "image_url": {"url": f"data:{image.mime_type};base64,{b64}"}})
return [{"role": "user", "content": content}]
async def generate(
client: httpx.AsyncClient,
model: str,
*,
prompt: str,
images: list[ImagePart] | None = None,
response_schema: dict | None = None,
temperature: float = 0.2,
max_retries: int = 2,
) -> LlmResult:
# ★ 실측(2026-09-16): gpt-5.6-luna 는 temperature 커스텀 값을 거부한다
# ("Only the default (1) value is supported" — 400). Gemini 와 달리 이 파라미터를
# 그냥 안 보낸다 — 공급자가 강제하는 값이라 우리가 흉내 낼 방법이 없다.
body: dict = {
"model": model,
"messages": _build_messages(prompt, images),
}
if response_schema is not None:
body["response_format"] = {
"type": "json_schema",
"json_schema": {"name": "result", "strict": True, "schema": _to_strict_schema(response_schema)},
}
headers = {"Content-Type": "application/json", "Authorization": f"Bearer {external_api_config.openai_api_key}"}
last = None
payload = None
for attempt in range(max_retries + 1):
try:
resp = await client.post(_BASE_URL, json=body, headers=headers)
except (httpx.TimeoutException, httpx.TransportError) as ex:
last = f"{type(ex).__name__}: {ex}"
else:
if resp.status_code == 200:
payload = resp.json()
break
if resp.status_code in (401, 403):
raise LlmNotConfigured(f"인증 실패 status={resp.status_code} — API 키를 확인하세요")
if resp.status_code not in _RETRYABLE_STATUS:
raise LlmError(f"status={resp.status_code} body={resp.text[:200]}")
last = f"status={resp.status_code}"
if attempt < max_retries:
await asyncio.sleep(min(8.0, 1.0 * (2 ** attempt)))
if payload is None:
raise LlmError(f"{max_retries + 1}회 시도 실패: {last}")
choices = payload.get("choices") or []
if not choices:
raise LlmInvalidOutput("choices 가 비었다(안전 필터 차단 가능)")
text = (choices[0].get("message") or {}).get("content") or ""
if not text.strip():
raise LlmInvalidOutput(f"텍스트가 없다 finish_reason={choices[0].get('finish_reason')}")
parsed = None
if response_schema is not None:
try:
parsed = json.loads(text)
except json.JSONDecodeError as ex:
raise LlmInvalidOutput(f"구조화 출력 파싱 실패: {ex}") from ex
usage_raw = payload.get("usage") or {}
usage = Usage(
input_tokens=int(usage_raw.get("prompt_tokens") or 0),
output_tokens=int(usage_raw.get("completion_tokens") or 0),
)
return LlmResult(json=parsed, text=text, usage=usage)
def price(model: str, usage: Usage) -> float:
inp = _PRICE_PER_1M_INPUT.get(model, 0.0) * usage.input_tokens / 1_000_000
out = _PRICE_PER_1M_OUTPUT.get(model, 0.0) * usage.output_tokens / 1_000_000
return round(inp + out, 4)

View File

@ -9,6 +9,7 @@
나중에 환각을 추적할 있게 한다.
"""
import json
from dataclasses import dataclass
import httpx
@ -36,6 +37,39 @@ def is_configured() -> bool:
return bool((external_api_config.perplexity_api_key or "").strip())
@dataclass
class Usage:
"""호출 1회의 실제 사용량·비용.
cost 우리가 계산한 값이 아니라 Perplexity 응답에 직접 실어주는 실제 청구액(USD)이다
(`usage.cost.total_cost`) 검색 컨텍스트 요금(request_cost)까지 포함된 진짜 값이라,
Gemini·OpenAI 처럼 토큰 단가표로 역산하는 것보다 정확하다.
실측(2026-09-16): `usage.cost` 평평한 숫자가 아니라
`{"input_tokens_cost", "output_tokens_cost", "request_cost", "total_cost"}` 객체다
문서 예시(평평한 숫자) 다르다. num_search_queries 비용은 아니지만 검색 남용 감시용으로 같이 둔다."""
input_tokens: int = 0
output_tokens: int = 0
num_search_queries: int = 0
cost: float = 0.0
def read_usage(payload: dict) -> Usage:
"""응답의 usage 를 읽는다. 필드가 없거나 모양이 다르면 0 — 계측 실패가 본 기능을 막으면 안 된다."""
u = payload.get("usage") or {}
cost_field = u.get("cost")
if isinstance(cost_field, dict):
cost = cost_field.get("total_cost")
else:
cost = cost_field
return Usage(
input_tokens=int(u.get("prompt_tokens") or 0),
output_tokens=int(u.get("completion_tokens") or 0),
num_search_queries=int(u.get("num_search_queries") or 0),
cost=float(cost) if isinstance(cost, (int, float)) else 0.0,
)
async def call(body: dict, *, client: httpx.AsyncClient | None = None) -> dict:
"""★ LLM 이 실제로 불리는 지점. chat/completions 1회.

View File

@ -0,0 +1,20 @@
"""LLM_PROVIDER 설정으로 gemini/openai 구현 중 하나를 고른다.
모르는 값은 gemini 떨어진다 오타 하나로 사진분류·소개문·FAQ 전부
조용히 꺼지는 것보다, 기존에 검증된 공급자로 계속 도는 쪽이 안전하다."""
from config.server_configs import external_api_config
from services.llm import gemini, openai
def active():
return openai if external_api_config.llm_provider == "openai" else gemini
def missing_key() -> str:
"""지금 활성인 공급자에게 필요한 env 이름. 키가 없을 때 **그 공급자를** 가리키려고 쓴다.
예전에는 호출측이 "GEMINI_API_KEY 미설정" 문자열로 박아 뒀다. 공급자를 openai
바꾼 뒤에도 문구가 그대로 나가서, **없는 것은 OPENAI_API_KEY 인데 화면은 Gemini
탓했다**(실측 2026-09-21: 로컬에서 소개문이 나와 Gemini 키를 한참 들여다봤다).
원인을 정확히 반대로 가리키는 종류라, 문구를 공급자에서 끌어오게 바꿨다."""
return "OPENAI_API_KEY" if active() is openai else "GEMINI_API_KEY"

View File

@ -0,0 +1,28 @@
"""공급자 무관 값 타입. gemini.py·openai.py 가 동일하게 이 타입을 쓰고 돌려준다."""
from dataclasses import dataclass
from typing import Optional
@dataclass
class Usage:
input_tokens: int = 0
output_tokens: int = 0
@dataclass
class ImagePart:
mime_type: str
data: bytes
label: str = "" # 있으면 이 이미지 직전에 라벨 텍스트를 넣는다(사진 여러 장을 ref로 매칭할 때 쓴다)
@dataclass
class LlmResult:
"""generate() 의 반환값.
json: response_schema 줬을 파싱된 결과(스키마 없이 부르면 None).
text: 원문 텍스트(요약처럼 스키마 없는 호출에서 이걸 쓴다)."""
json: Optional[dict]
text: str
usage: Usage

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