Merge remote-tracking branch 'origin/main' into feature/owner-kakao-link

This commit is contained in:
hbyang 2026-09-22 11:15:09 +09:00
commit 627fb1e141
39 changed files with 2995 additions and 272 deletions

View File

@ -346,6 +346,13 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
**사람이 누르기 전에** 그 URL 을 연다. 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 포함)를 **기존 액세스 토큰을 승인 링크에 얹지 않는다.** 지금 JWT 는 `sub``UserInfo` 통짜(role 포함)를
넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다. 넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다.

View File

@ -120,11 +120,24 @@
## 5. 승인·수정 ## 5. 승인·수정
- **승인**: GET 이 로그인 없이 즉시 승인한다 → `status = APPROVED` → BUILD 잡 큐. 만료· - **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해
재사용은 "처리할 수 없는 링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다) "바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문 갱신 + 가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service.
그대로 APPROVED 전환 + BUILD 잡. 실패하면 사유를 보여주고 다시 고치게 한다(사이트에는 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` - ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
`payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인 `payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
세션과 달라 `post_service.PostService._enqueue_build``place_id` 로 직접 조회해 세션과 달라 `post_service.PostService._enqueue_build``place_id` 로 직접 조회해
@ -213,7 +226,8 @@
날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에 날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
`requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로 `requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도 보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
0건이다 0건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 —
쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준)
## 8. 진행 (2026-09-17) ## 8. 진행 (2026-09-17)
@ -230,6 +244,7 @@
| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 | | 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 | | payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
| 재발행 연결 | `build_service``mark_published`, `owner_user_id` 버그 수정 | 완료 | | 재발행 연결 | `build_service``mark_published`, `owner_user_id` 버그 수정 | 완료 |
| 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 |
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다), 남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
그리고 6절의 발행일 갱신 합의. 그리고 6절의 발행일 갱신 합의.

View File

@ -14,6 +14,13 @@ Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권
문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다. 문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다.
같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다. 같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.
미니블로그 승인(이메일 링크 또는 빌더 앱 "바로 발행")도 계정이 연결돼 있으면 같은 문구를
그대로 쓰레드에 낸다(`decided_via='mini_blog'`) — 이 경로는 승인 요청·알림톡을 거치지 않고
바로 `APPROVED`로 들어간다. 미니블로그 승인 자체가 발화 동의로 취급되기 때문이다(2026-09-21,
DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정). 쓰레드 전용으로 새로 짓거나
내용을 바꾸는 경로(위 "발행 모달 → 소개글 쓰기")는 여전히 계정 연결 → 승인 요청 → 명시적
승인을 그대로 거친다.
- 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청. - 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
- 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시. - 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
- 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다. - 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.

View File

@ -13,60 +13,67 @@
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가 원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}` `{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로 정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 구간이어야 한다** 저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다): (하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
- 서버: `site_payload._weather_condition()` — 프리렌더 스냅샷에 쓰인다. - 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
- 브라우저: `use-live-weather.ts:condition()` — 10분마다 재조회할 때 쓰인다. - 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
| 코드 | 날씨 상태(한국어) | 날씨 상태(영어) | 내부 분류 | 둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
|---|---|---|---| 분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
| 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 | 뇌우 |
이 27개가 Open-Meteo `weather_code`의 전체 정의 값이다 — 표에 없는 값(파싱 실패 포함)만 | 코드 | 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`)는 위 11종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로 `weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "이슬비"·"강한비"·"어는비"는 뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
모두 `/비|우/` 패턴에 "비"가 들어 있어 `비`로 걸린다). `WeatherSection``skyKey`는 노트가 `/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
실제로 있는 세부 조건(구름많음/안개/소나기/뇌우/이슬비/강한비/어는비)이면 그 조건 그대로 쓰고, `눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection``skyKey`는 노트에 그 조건 키가
그 외(맑음/흐림/비/눈)엔 `mood`로 내려간다 — 어차피 세부 조건과 mood 값이 같기 때문이다. 실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets` `weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
하늘 11종(맑음·구름많음·안개·비·이슬비·강한비·어는비·눈·소나기·뇌우·흐림)·기온 5구간에 각 하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, 브라우저에서는 무작위 시작 고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
후 20초마다 한 바퀴 안에서 중복 없이 순환한다. 기온 구간은 기존 `weatherBand`의 30·25·20·10도다. 브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
구름많음·안개·소나기·뇌우·이슬비·강한비·어는비는 그림(mood)상 흐림 또는 비와 같이 묶이지만 중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
문구는 조건별로 따로 쓴다. **비 중에서도 어는비(착빙성)만 안전 안내가 다르다** — 도로·보행
결빙 위험을 짚는 문구가 필요해서 이슬비·강한비와 갈랐다(2026-09-17). **세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지 목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.** 않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**

View File

@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places (
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다 verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
verified_by uuid NULL, verified_by uuid NULL,
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다 content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE deleted BOOLEAN NOT NULL DEFAULT FALSE

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

@ -130,6 +130,9 @@ class places(MainTableMixin, MAIN_BASE):
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 — # ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다. # site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
content_updated_at = Column(DateTime(timezone=True), nullable=True) content_updated_at = Column(DateTime(timezone=True), nullable=True)
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
notify_email = Column(String(255), nullable=True)

View File

@ -62,6 +62,21 @@ class PostCRUD:
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date) rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
return ErrorType.SUCCESS, rows[:limit] 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): async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순.""" """사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
result = await cdb.execute( result = await cdb.execute(
@ -173,8 +188,21 @@ class PostCRUD:
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다.""" """재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
await cdb.execute( await cdb.execute(
update(place_posts) update(place_posts)
.where(place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value) .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(), .values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
published_version_id=version_id, updated_at=GTime.UTC()) published_version_id=version_id, updated_at=GTime.UTC())
) )
return ErrorType.SUCCESS 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

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

View File

@ -9,6 +9,10 @@
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 토큰으로 로그인한 이번 실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 토큰으로 로그인한 이번
편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다. 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
owner_router 로그인 세션이 신원이다 빌더 앱의 "이번 달 생성된 글" 화면. owner_router 로그인 세션이 신원이다 빌더 앱의 "이번 달 생성된 글" 화면.
2026-09-21, 사장님 지시: 게재는 경로 열려 있다 파일 위쪽의 /approve
(이메일 토큰, 로그인 없음), 아래 owner_router POST .../approve(로그인 세션,
"바로 발행" 수정 없이 그대로 승인). PUT(수정) 저장만 하고 자동으로 승인하지 않는다
승인은 경로 하나를 명시적으로 눌러야 한다.
""" """
from datetime import date from datetime import date
from uuid import UUID from uuid import UUID
@ -94,7 +98,7 @@ async def get_my_post(
return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id))) 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="로그인 세션으로 직접 수정·승인") @owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로")
async def edit_my_post( async def edit_my_post(
place_id: UUID, place_id: UUID,
post_id: UUID, post_id: UUID,
@ -105,7 +109,10 @@ async def edit_my_post(
return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body)) 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="바로 발행 — 고치지 않고 그대로") @owner_router.post(
path="/{post_id}/approve", response_model=Res_WebPacketProtocol,
summary="바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인",
)
async def approve_my_post( async def approve_my_post(
place_id: UUID, place_id: UUID,
post_id: UUID, post_id: UUID,
@ -115,6 +122,28 @@ async def approve_my_post(
return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id))) 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( @owner_router.post(
path="/generate", response_model=Res_GenerateNow, path="/generate", response_model=Res_GenerateNow,
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다", summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",

View File

@ -31,7 +31,7 @@ async def connect(response: Response, user: UserInfo = Depends(IsValidAccessToke
@router.get("/callback") @router.get("/callback")
async def callback( async def oauth_callback(
request: Request, request: Request,
state: str = Query("", max_length=2048), state: str = Query("", max_length=2048),
code: str = Query("", max_length=4096), code: str = Query("", max_length=4096),

View File

@ -13,6 +13,7 @@ from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select from sqlalchemy import select
from config import social_config
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_posts, places, sites, users from common.database.model.models import place_posts, places, sites, users
from common.enums import DBWRType, PostStatus, SiteStatus from common.enums import DBWRType, PostStatus, SiteStatus
@ -49,6 +50,7 @@ async def _published_places() -> list:
places.deleted == False, # noqa: E712 places.deleted == False, # noqa: E712
sites.deleted == False, # noqa: E712 sites.deleted == False, # noqa: E712
sites.status == SiteStatus.PUBLISHED.value, sites.status == SiteStatus.PUBLISHED.value,
sites.domain.isnot(None),
) )
) )
@ -92,7 +94,7 @@ async def _generate_for_place(place) -> int:
continue continue
generated = await blog_service.generate_one( generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind, place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set), material=material, used_topics=sorted(used_set), place_category=place.category,
) )
if not generated: if not generated:
continue continue
@ -171,7 +173,7 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic
continue continue
generated = await blog_service.generate_one( generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind, place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set), material=material, used_topics=sorted(used_set), place_category=place.category,
) )
if not generated: if not generated:
continue continue
@ -219,7 +221,7 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None
continue continue
generated = await blog_service.generate_one( generated = await blog_service.generate_one(
place_name=place.name, region=region, topic_kind=kind, place_name=place.name, region=region, topic_kind=kind,
material=material, used_topics=sorted(used_set), material=material, used_topics=sorted(used_set), place_category=place.category,
) )
if not generated: if not generated:
continue continue
@ -261,6 +263,41 @@ def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str)
) )
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: async def send_reviewed() -> int:
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다.""" """검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
if not mail_service.is_configured(): if not mail_service.is_configured():
@ -273,22 +310,45 @@ async def send_reviewed() -> int:
if not rows: if not rows:
return 0 return 0
targets = {str(place.place_id): (place.name, user) for place, user in await _published_places()} places_by_id = {str(place.place_id): (place, user) for place, user in await _published_places()}
origin = site_payload.publish_origin()
sent = 0 sent = 0
for post in rows: for post in rows:
target = targets.get(str(post.place_id)) target = places_by_id.get(str(post.place_id))
if not target or not mail_service.is_valid_address(target[1].email or ""): if not target:
continue continue
place_name, user = target place, user = target
token, token_hash, expires = blog_service.issue_token() if not mail_service.is_valid_address(_notify_address(place, user) or ""):
body = _mail_body(place_name=place_name, post=post, user=user, origin=origin, approve_token=token)
ok = mail_service.send(to=user.email, subject=f"[{place_name}] 이번 글 올릴까요?", text=body)
if not ok:
continue continue
await DB_SESSION_MNG.execute_lambda_run( if await _send_one(place, user, post):
[place_posts.DBType()], sent += 1
[lambda s, pid=post.post_id, h=token_hash, e=expires: _crud.mark_sent(s, pid, h, e)],
)
sent += 1
return sent 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

@ -10,7 +10,7 @@ import re
import secrets import secrets
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from common.enums import PostStatus, PostTopicKind from common.enums import PlaceCategory, PostStatus, PostTopicKind
from common.logger import LOG from common.logger import LOG
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다. # 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
@ -64,6 +64,7 @@ def issue_token() -> tuple[str, str, object]:
return token, hash_token(token), expires return token, hash_token(token), expires
# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다.
TOPIC_RULES: dict[int, str] = { TOPIC_RULES: dict[int, str] = {
PostTopicKind.WEATHER.value: PostTopicKind.WEATHER.value:
"오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.", "오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.",
@ -77,6 +78,23 @@ TOPIC_RULES: dict[int, str] = {
"확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.", "확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.",
} }
# 업종별 분기 — 지금은 숙소만 채워져 있다. 새 업종을 넣으려면 여기 두 딕셔너리에만 항목을 더한다.
_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 = ( _RULES = (
"규칙\n" "규칙\n"
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n" f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n"
@ -87,12 +105,15 @@ _RULES = (
) )
def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str]) -> str: def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str],
place_category: int = PlaceCategory.LODGING.value) -> str:
"""갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다.""" """갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다."""
used = ", ".join(used_topics[:40]) or "없음" used = ", ".join(used_topics[:40]) or "없음"
noun = _business_noun(place_category)
rules = _topic_rules(place_category)
return ( return (
f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n" f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n"
f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n" f"갈래: {rules.get(topic_kind, '')}\n"
f"소재: {material}\n" f"소재: {material}\n"
f"이미 쓴 주제: {used}\n\n" f"이미 쓴 주제: {used}\n\n"
f"{_RULES}\n본문만 출력한다." f"{_RULES}\n본문만 출력한다."
@ -124,29 +145,37 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]:
async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str, async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str,
used_topics: list[str], client=None) -> tuple[str, str] | None: used_topics: list[str], place_category: int = PlaceCategory.LODGING.value,
client=None) -> tuple[str, str] | None:
"""(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다. """(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 쓴다(2026-09-17, 사장님 지시).""" 모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 쓴다(2026-09-17, 사장님 지시).
from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured
if not is_configured(): 발행 링크는 여기서 붙이지 않는다 호출부가 길이 게이트(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 return None
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind, prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
material=material, used_topics=used_topics) material=material, used_topics=used_topics, place_category=place_category)
body = {
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
"generationConfig": {"temperature": 0.9},
}
owns = client is None owns = client is None
if owns: if owns:
import httpx import httpx
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0)) client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
try: try:
payload = await call(client, DEFAULT_MODEL, body) result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9)
text = extract_text(payload).strip() text = result.text.strip()
return (text, DEFAULT_MODEL) if text else None return (text, llm.DEFAULT_MODEL) if text else None
except GeminiError as error: except LlmError as error:
LOG.w(f"[blog] 생성 실패: {error}") LOG.w(f"[blog] 생성 실패: {error}")
return None return None
finally: finally:

View File

@ -25,10 +25,6 @@ from services.llm.errors import LlmError
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
# generate_social_post 는 공급자 선택(provider.active())을 타지 않고 Gemini 를 직접 부른다 —
# 구조화 출력 재시도 루프(피드백 프롬프트로 다시 묻기)가 Gemini REST 응답 모양에 맞춰져 있다.
# 모듈 최상단에서 이름으로 들여와야 테스트가 gemini_text.call 을 monkeypatch 할 수 있다.
from services.llm.gemini import DEFAULT_MODEL as DEFAULT_TEXT_MODEL, call, extract_text
def is_configured() -> bool: def is_configured() -> bool:
@ -325,15 +321,22 @@ async def generate_song(
async def generate_social_post(place_name, facts, link_url, provider=2, *, client=None): 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.prompts import social
from services.external.social import adapter, weighted_length, URL from services.external.social import adapter, weighted_length, URL
from services.llm import provider as llm_provider
import unicodedata import unicodedata
if not facts: if not facts:
raise GeminiInvalidOutput('NO_GROUNDED_FACTS') raise GeminiInvalidOutput('NO_GROUNDED_FACTS')
if not is_configured(): llm = llm_provider.active()
raise GeminiNotConfigured('GEMINI_NOT_CONFIGURED') if not llm.is_configured():
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
limit = adapter(provider).weighted_limit() limit = adapter(provider).weighted_limit()
allowed = {f.key for f in facts} allowed = {f.key for f in facts}
feedback = '' feedback = ''
@ -341,14 +344,14 @@ async def generate_social_post(place_name, facts, link_url, provider=2, *, clien
client = client or httpx.AsyncClient(timeout=45) client = client or httpx.AsyncClient(timeout=45)
try: try:
for _ in range(3): for _ in range(3):
request = {'contents': [{'role': 'user', 'parts': [{'text': social.build_prompt( prompt = social.build_prompt(
place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)}]}], place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)
'generationConfig': {'responseMimeType': 'application/json', result = await llm.generate(
'responseSchema': social.RESPONSE_SCHEMA, 'temperature': 0.2, client, llm.DEFAULT_MODEL, prompt=prompt,
'maxOutputTokens': 1024}} response_schema=social.RESPONSE_SCHEMA, temperature=0.2, max_retries=0,
result = await call(client, DEFAULT_TEXT_MODEL, request, max_retries=0) )
try: try:
parsed = json.loads(extract_text(result)) parsed = result.json if result.json is not None else json.loads(result.text)
body = unicodedata.normalize('NFC', parsed['body'].strip()) body = unicodedata.normalize('NFC', parsed['body'].strip())
keys = parsed['fact_keys'] keys = parsed['fact_keys']
text = body + '\n\n' + link_url text = body + '\n\n' + link_url

View File

@ -13,6 +13,7 @@ from common.models.gmodel import PageParams, UserInfo
from common.utils.gtime import GTime from common.utils.gtime import GTime
from crud.job_crud import JobQueue from crud.job_crud import JobQueue
from crud.place_crud import IPlaceCRUD, PlaceCRUD from crud.place_crud import IPlaceCRUD, PlaceCRUD
from services import mail_service
from router.v1.place.protocol import ( from router.v1.place.protocol import (
Req_VerifyPlaceByUrl, Req_VerifyPlaceByUrl,
LinkData, LinkData,
@ -150,6 +151,12 @@ class PlaceService:
data["status"] = req.status.value data["status"] = req.status.value
if "name" in data: if "name" in data:
data["name"] = str(data["name"]).strip() data["name"] = str(data["name"]).strip()
if "notify_email" in data:
data["notify_email"] = str(data["notify_email"]).strip()
if data["notify_email"] and not mail_service.is_valid_address(data["notify_email"]):
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
return res
data["notify_email"] = data["notify_email"] or None
if data: if data:
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim( err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(

View File

@ -23,7 +23,7 @@ from common.utils.gtime import GTime
from router.v1.site.protocol import ( from router.v1.site.protocol import (
GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts, GenerationBatch, PostData, Res_GenerateNow, Res_GenerateOne, Res_GenerationHistory, Res_MyPosts,
) )
from services import blog_jobs, blog_service from services import blog_jobs, blog_service, social_service
from services.job_service import enqueue_job from services.job_service import enqueue_job
EXPIRED = "처리할 수 없는 링크입니다." EXPIRED = "처리할 수 없는 링크입니다."
@ -75,10 +75,7 @@ class PostService:
) )
return {"success": True, "message": SKIPPED} return {"success": True, "message": SKIPPED}
await DB_SESSION_MNG.execute_lambda_run( await self._approve_and_publish(post_id, place_id)
[place_posts.DBType()], [lambda s: self.crud.approve(s, post_id)],
)
await self._enqueue_build(post_id, place_id)
return {"success": True, "message": APPROVED} return {"success": True, "message": APPROVED}
async def _load_place(self, user_info: UserInfo, place_id: str): async def _load_place(self, user_info: UserInfo, place_id: str):
@ -191,7 +188,10 @@ class PostService:
async def edit_by_owner( async def edit_by_owner(
self, user_info: UserInfo, place_id: str, post_id: str, body: str self, user_info: UserInfo, place_id: str, post_id: str, body: str
) -> Res_WebPacketProtocol: ) -> Res_WebPacketProtocol:
"""로그인 세션으로 직접 고치기 — 메일이 아직 안 나간 REVIEWED 글도 여기서 바로 승인된다.""" """로그인 세션으로 직접 고치기 — 저장만 한다. ★ 승인은 여기서 하지 않는다(2026-09-21,
사장님 지시: "승인되야 올라가도록 해야 한다") 저장 후에는 이메일 승인 링크
(router/v1/site/post.py approve_page decide) 또는 바로 아래 approve_by_owner
("바로 발행" 버튼) 명시적으로 눌러야 게재된다."""
res = Res_WebPacketProtocol() res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id) err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
@ -208,15 +208,13 @@ class PostService:
await DB_SESSION_MNG.execute_lambda_run( await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.update_body(s, pid, body)], [place_posts.DBType()], [lambda s: self.crud.update_body(s, pid, body)],
) )
await DB_SESSION_MNG.execute_lambda_run( res.msg = "저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다."
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
)
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = APPROVED
return res return res
async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol: async def approve_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
"""로그인 세션으로 바로 발행 — 고치지 않고 그대로. edit_by_owner 와 승인·빌드 경로는 같다.""" """로그인 세션으로 바로 발행 — 고치지 않고 그대로, 또는 방금 edit_by_owner 로 고친
그대로 승인한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고
바로발행버튼으로도 발행 가능하도록"). 이메일 승인 링크와 별개의 두 번째 경로다."""
res = Res_WebPacketProtocol() res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id) err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS: if err_type != ErrorType.SUCCESS:
@ -224,13 +222,37 @@ class PostService:
return res return res
pid = uuid.UUID(post_id) pid = uuid.UUID(post_id)
await DB_SESSION_MNG.execute_lambda_run( await self._approve_and_publish(pid, uuid.UUID(place_id))
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
)
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = APPROVED res.msg = APPROVED
return res return res
async def delete_by_owner(self, user_info: UserInfo, place_id: str, post_id: str) -> Res_WebPacketProtocol:
"""소프트 삭제 — 상태 제한 없이 지운다. 이미 게재된 글이면 그 자리에서 빠지도록
재발행 잡까지 큐에 넣는다( 상태는 사이트에 나간 적이 없어 재발행이 필요 없다)."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
pid = uuid.UUID(post_id)
row = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, pid),
)
if row is None or str(row.place_id) != str(place_id):
res.result.SetResult(ErrorType.PLACE_NOT_FOUND)
return res
was_published = row.status == PostStatus.PUBLISHED.value
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.delete(s, pid)],
)
if was_published:
await self._enqueue_build(pid, uuid.UUID(place_id))
res.msg = "삭제했습니다."
return res
async def generate_range(self, user_info: UserInfo, place_id: str, start_date: date, end_date: date) -> Res_GenerateNow: async def generate_range(self, user_info: UserInfo, place_id: str, start_date: date, end_date: date) -> Res_GenerateNow:
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다 """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").""" (2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까")."""
@ -274,6 +296,34 @@ class PostService:
res.msg = "만들었습니다." res.msg = "만들었습니다."
return res return res
_SEND_NOW_MSG = {
"MAIL_NOT_CONFIGURED": "메일 발송이 설정되어 있지 않습니다.",
"NO_VALID_EMAIL": "받을 이메일 주소가 올바르지 않습니다.",
"SEND_FAILED": "메일 발송에 실패했습니다.",
}
async def send_now(self, user_info: UserInfo, place_id: str) -> Res_WebPacketProtocol:
"""빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을
바로 보낸다(2026-09-21, 사장님 요청). 보낼 없는 오류가 아니다 generate_range
'이미 다 있거나 소재가 없다' 같은 결의 안내로 끝낸다."""
res = Res_WebPacketProtocol()
err_type, _place = await self._load_place(user_info, place_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
outcome = await blog_jobs.send_now_for_place(place_id)
if outcome["sent"]:
res.msg = "메일을 보냈습니다."
return res
reason = outcome.get("reason")
if reason == "NOTHING_DUE":
res.msg = "오늘 보낼 글이 없습니다."
return res
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
res.msg = self._SEND_NOW_MSG.get(reason, "지금은 발송할 수 없습니다.")
return res
async def _owner_user_id(self, place_id): async def _owner_user_id(self, place_id):
def query(session): def query(session):
return session.execute(select(places.owner_user_id).where(places.place_id == place_id)) return session.execute(select(places.owner_user_id).where(places.place_id == place_id))
@ -282,6 +332,28 @@ class PostService:
row = result.first() if result is not None else None row = result.first() if result is not None else None
return row[0] if row else None return row[0] if row else None
async def _approve_and_publish(self, post_id, place_id) -> None:
await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [lambda s: self.crud.approve(s, post_id)],
)
await self._enqueue_build(post_id, place_id)
await self._try_social_share(post_id, place_id)
async def _try_social_share(self, post_id, place_id) -> None:
"""쓰레드 연동 — 실패해도 미니블로그 승인은 이미 끝난 뒤라 예외를 밖으로 던지지
않는다(2026-09-21, DECISIONS 7-1-2 개정)."""
try:
post = await DB_SESSION_MNG.execute_lambda(
place_posts.DBType(), DBWRType.DB_READ.value,
lambda s: self.crud.by_id(s, post_id),
)
owner_user_id = await self._owner_user_id(place_id)
if post is None or owner_user_id is None or not post.body:
return
await social_service.publish_reused_text(owner_user_id, place_id, post.body)
except Exception:
LOG.w(f"[blog] post={post_id} 쓰레드 연동 실패 — 미니블로그 승인은 유지")
async def _enqueue_build(self, post_id, place_id) -> None: async def _enqueue_build(self, post_id, place_id) -> None:
"""게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md). """게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md).

View File

@ -501,33 +501,48 @@ def _sections(saved_sections, default_spec) -> list:
return out return out
# WMO weather code → 한 줄 날씨. ★ solution/site/src/lib/use-live-weather.ts 의 condition() 과 # WMO weather code → 한 줄 날씨. 코드 하나마다 고유 문구다(뭉치지 않는다) — ★ solution/site/src/lib/
# **같은 구간**이어야 한다. 렌더러는 프리렌더된 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데, # use-live-weather.ts 의 WEATHER_CONDITION_BY_CODE 와 **같은 표**여야 한다. 렌더러는 프리렌더된
# 두 곳이 다른 표를 쓰면 같은 날씨인데 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다). # 이 값으로 그리다가 하이드레이션 뒤 최신 캐시로 덮어쓰는데, 두 곳이 다른 표를 쓰면 같은 날씨인데
# 화면 문구가 바뀐다(사장님 눈에는 버그로 보인다).
_WEATHER_CONDITION_BY_CODE = {
0: "맑음",
1: "대체로 맑음",
2: "구름 조금",
3: "흐림",
45: "안개",
48: "착빙성 안개",
51: "가벼운 이슬비",
53: "보통 이슬비",
55: "강한 이슬비",
56: "가벼운 착빙성 이슬비",
57: "강한 착빙성 이슬비",
61: "약한 비",
63: "보통 비",
65: "강한 비",
66: "약한 착빙성 비",
67: "강한 착빙성 비",
71: "약한 눈",
73: "보통 눈",
75: "강한 눈",
77: "싸라기눈",
80: "약한 소나기",
81: "보통 소나기",
82: "강한 소나기",
85: "약한 소나기눈",
86: "강한 소나기눈",
95: "뇌우",
96: "약한 우박 뇌우",
99: "강한 우박 뇌우",
}
def _weather_condition(code) -> str: def _weather_condition(code) -> str:
try: try:
value = int(code) value = int(code)
except (TypeError, ValueError): except (TypeError, ValueError):
return "흐림" return "흐림"
if value == 0: return _WEATHER_CONDITION_BY_CODE.get(value, "흐림")
return "맑음"
if 1 <= value <= 2:
return "구름많음"
if value == 3:
return "흐림"
if value in (45, 48):
return "안개"
if 51 <= value <= 57:
return "이슬비"
if 61 <= value <= 67:
return ""
if 71 <= value <= 77 or value in (85, 86):
return ""
if 80 <= value <= 82:
return "소나기"
if 95 <= value <= 99:
return "뇌우"
return "흐림"
def _weather(row: dict): def _weather(row: dict):
@ -779,6 +794,12 @@ def publish_origin() -> str:
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}" return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
def publish_url(place, site) -> str:
"""이 사업장 사이트의 전체 발행 주소. 미니 블로그·SNS 초안이 문구 끝에 붙이는 링크가
값과 갈리면 되므로 origin·slug 조합을 여기 곳에서만 한다."""
return f"{publish_origin()}/s/{publish_slug(place, site)}"
def primary_media(snapshot: dict) -> dict | None: def primary_media(snapshot: dict) -> dict | None:
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None. """대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.

View File

@ -19,7 +19,7 @@ from common.database.model.models import (
place_facts, place_facts,
owner_social_accounts as Account, owner_social_accounts as Account,
) )
from common.enums import SiteStatus, ErrorType, PUBLISHABLE_FACT_STATUSES from common.enums import SiteStatus, ErrorType, PUBLISHABLE_FACT_STATUSES, SocialProvider
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud import social_crud as db from crud import social_crud as db
from services import site_payload, social_account_service as accounts from services import site_payload, social_account_service as accounts
@ -580,3 +580,49 @@ async def run_post(job):
except Exception: except Exception:
await set_failure(post_id, "POST_RESULT_UNKNOWN", status="UNKNOWN") await set_failure(post_id, "POST_RESULT_UNKNOWN", status="UNKNOWN")
raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN") from None raise SocialOutcomeUnknown("POST_RESULT_UNKNOWN") from None
async def publish_reused_text(user_id, place_id, body: str):
"""미니블로그 승인 문구를 그대로 쓰레드에 낸다 — 승인 자체가 발화 동의라 별도 승인을
묻지 않는다(2026-09-21, DECISIONS 7-1-2 개정: 미니블로그 문구를 그대로 재사용하는
경우에 한정한 예외). 연동 있거나 조건 미달이면 조용히 None을 돌려준다 호출부가
실패로 취급하지 않는다."""
if not posting_enabled():
return None
async def run(s):
account = await accounts.account(s, user_id, SocialProvider.THREADS.value)
if not account or account.status != "linked":
return None
_place, site, url = await target(s, user_id, place_id)
full_text = f"{body}\n\n{url}"
row_id = (
await s.execute(
insert(Post)
.values(
place_id=place_id,
user_id=user_id,
site_version_id=site.current_version_id,
provider=SocialProvider.THREADS.value,
account_id=account.account_id,
link_url=url,
body=full_text,
status="APPROVED",
decided_at=datetime.now(timezone.utc),
decided_via="mini_blog",
)
.on_conflict_do_nothing(
index_elements=["place_id", "site_version_id"],
index_where=text("deleted=false"),
)
.returning(Post.post_id)
)
).scalar_one_or_none()
if row_id:
await db.enqueue(s, row_id, 9)
return row_id
try:
return await db.transaction(run)
except HTTPException:
return None

View File

@ -7,12 +7,19 @@
"다음 일정을 서두르기보다 잠시 여유를 가져 보세요.", "다음 일정을 서두르기보다 잠시 여유를 가져 보세요.",
"좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?" "좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"
], ],
"구름많음": [ "대체로 맑음": [
"구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.", "구름이 살짝 있지만 대체로 맑은 하루입니다. 나들이하기 좋은 날씨입니다.",
"여행 사진을 정리하며 잠깐 쉬어 가세요.", "옅은 구름 사이로 볕이 듭니다. 가볍게 산책을 나서 보세요.",
"함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.", "하늘이 대체로 맑아 야외 일정을 편하게 잡을 수 있습니다.",
"바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.", "구름이 잠깐씩 지나가지만 큰 영향은 없습니다.",
"오늘의 기분을 짧은 글로 남겨도 좋겠습니다." "맑은 편이니 오늘 담고 싶은 풍경을 찾아 나서 보세요."
],
"구름 조금": [
"구름이 조금 낀 날입니다. 볕이 강하지 않아 걷기에 오히려 좋습니다.",
"하늘에 구름이 떠 있지만 나들이엔 무리가 없습니다.",
"구름 사이로 비치는 햇살을 즐기며 산책해 보세요.",
"그늘이 반가운 날, 야외 일정을 여유롭게 잡아 보세요.",
"가벼운 겉옷 하나면 하루 종일 다니기 좋겠습니다."
], ],
"흐림": [ "흐림": [
"흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.", "흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.",
@ -21,20 +28,6 @@
"천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.", "천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.",
"분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요." "분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."
], ],
"비": [
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
],
"눈": [
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
"다음 이동을 서두르지 말고 쉬어 가세요.",
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
"실내에서 다음 일정과 이동편을 확인해 보세요.",
"눈길을 나서기 전에는 여유 있게 준비하세요."
],
"안개": [ "안개": [
"안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.", "안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.",
"시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.", "시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.",
@ -42,26 +35,166 @@
"운전하신다면 평소보다 여유 있게 출발하시길 권합니다.", "운전하신다면 평소보다 여유 있게 출발하시길 권합니다.",
"뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다." "뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다."
], ],
"이슬비": [ "착빙성 안개": [
"서리 안개가 낀 아침입니다. 노면이 미끄러울 수 있으니 조심하세요.",
"안개와 함께 기온이 낮아 도로가 얼어 있을 수 있습니다.",
"이른 시간 이동은 특히 주의하고 서두르지 마세요.",
"안개가 걷히고 기온이 오른 뒤 나서는 편이 안전합니다.",
"차량·보행 모두 시야와 노면을 함께 살펴보세요."
],
"가벼운 이슬비": [
"가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.", "가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.",
"빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.", "빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.",
"얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.", "얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.",
"젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.", "젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.",
"빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다." "빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다."
], ],
"소나기": [ "보통 이슬비": [
"보통 세기의 이슬비가 내립니다. 우산 하나로 충분합니다.",
"옷이 젖을 수 있으니 우산이나 방수 겉옷을 챙기세요.",
"이슬비가 이어지는 동안은 실내 일정을 먼저 잡아 보세요.",
"그치는 틈을 봐서 짧게 이동하는 정도는 괜찮습니다.",
"빗소리를 들으며 잠시 여유를 가져도 좋겠습니다."
],
"강한 이슬비": [
"이슬비치고는 제법 굵게 내립니다. 우산을 단단히 챙기세요.",
"옷과 신발이 젖기 쉬우니 방수 채비를 해 두세요.",
"바깥 일정은 짧게 잡고 실내에서 기다리는 편이 낫겠습니다.",
"빗줄기가 가늘어도 오래 맞으면 금방 젖으니 주의하세요.",
"그칠 때까지는 무리해서 이동하지 않는 게 좋겠습니다."
],
"가벼운 착빙성 이슬비": [
"가벼운 착빙성 이슬비입니다. 노면이 얼 수 있어 걸음을 조심하세요.",
"얼어붙은 길이 있을 수 있으니 평소보다 천천히 움직이세요.",
"차량 이동도 서행하고 급제동은 피하세요.",
"실내에서 잠시 기다렸다 나서는 편을 권합니다.",
"그늘지거나 다리 위 같은 곳은 특히 미끄러우니 유의하세요."
],
"강한 착빙성 이슬비": [
"강한 착빙성 이슬비가 내립니다. 바깥 이동은 최대한 미루세요.",
"노면 결빙 위험이 크니 실내에 머무르는 편이 안전합니다.",
"부득이 이동한다면 미끄럼 방지 신발을 챙기세요.",
"차량도 도로 상태를 확인한 뒤 서행으로 움직이세요.",
"기상특보를 확인하고 해빙될 때까지 기다려 보세요."
],
"약한 비": [
"약한 비가 내립니다. 우산 하나면 짧은 이동은 무리 없습니다.",
"가볍게 챙기고 나서도 크게 불편하지 않겠습니다.",
"비가 잦아드는 시간을 골라 움직여 보세요.",
"젖어도 금방 마를 정도니 편하게 다녀오세요.",
"우산을 챙기고 평소처럼 일정을 이어가도 좋겠습니다."
],
"보통 비": [
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
],
"강한 비": [
"빗줄기가 굵습니다. 이동을 미루고 실내에서 기다리세요.",
"강한 비가 그칠 때까지 바깥 일정은 잠시 접어 두세요.",
"물웅덩이·배수 상황을 살피고 이동 경로를 다시 확인하세요.",
"우산만으로는 부족할 수 있으니 방수 옷차림을 챙기세요.",
"굵은 빗속 이동은 삼가고 안전한 곳에서 쉬어 가세요."
],
"약한 착빙성 비": [
"약한 착빙성 비입니다. 노면이 얼기 시작할 수 있어 조심하세요.",
"걸음을 천천히 옮기고 급한 이동은 피하세요.",
"차량은 서행하고 앞뒤 거리를 넉넉히 두세요.",
"가능하면 이동을 미루고 실내에서 기다려 보세요.",
"다리·그늘진 도로는 특히 미끄러우니 유의하세요."
],
"강한 착빙성 비": [
"강한 착빙성 비가 내립니다. 바깥 이동을 삼가고 실내에 머무르세요.",
"도로가 얼어붙을 수 있으니 되도록 나서지 마세요.",
"부득이 이동해야 한다면 걸음을 평소보다 훨씬 천천히 옮기세요.",
"차량 이동도 미끄러질 수 있으니 서두르지 마세요.",
"기상특보를 확인하고 해빙될 때까지 일정을 미뤄 두세요."
],
"약한 눈": [
"약한 눈이 흩날립니다. 짧은 나들이 정도는 다녀올 만합니다.",
"쌓이기 전이라면 평소처럼 움직여도 괜찮겠습니다.",
"따뜻하게 챙기고 잠깐의 눈길 산책을 즐겨 보세요.",
"노면이 젖어 있을 수 있으니 걸음만 조심하세요.",
"눈이 그치는 틈을 봐서 이동해도 좋겠습니다."
],
"보통 눈": [
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
"다음 이동을 서두르지 말고 쉬어 가세요.",
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
"실내에서 다음 일정과 이동편을 확인해 보세요.",
"눈길을 나서기 전에는 여유 있게 준비하세요."
],
"강한 눈": [
"눈이 강하게 쌓입니다. 바깥 이동은 미루는 편이 좋습니다.",
"제설이 될 때까지 실내에서 기다려 보세요.",
"이동해야 한다면 미끄럼 방지 신발과 여유 시간을 챙기세요.",
"차량 이동은 도로 상황을 확인한 뒤 움직이세요.",
"따뜻하게 머물며 눈이 그치기를 기다려 보세요."
],
"싸라기눈": [
"싸라기눈이 흩날립니다. 노면이 미끄러울 수 있어요.",
"짧은 이동이라도 걸음을 조심스럽게 옮기세요.",
"우산보다는 모자나 방한용품이 도움이 되겠습니다.",
"그치는 틈을 봐서 다음 일정을 잡아 보세요.",
"쌓이지 않아도 걸을 때는 발밑을 살펴보세요."
],
"약한 소나기": [
"짧게 스치는 소나기입니다. 잠깐 처마 밑에서 기다려도 됩니다.",
"갑자기 내릴 수 있으니 우산을 챙겨 두면 든든합니다.",
"소나기가 그치면 이어서 일정을 다니셔도 좋겠습니다.",
"젖은 길만 조심하면 크게 무리 없는 날씨입니다.",
"짧은 비를 피할 실내 공간을 미리 봐 두세요."
],
"보통 소나기": [
"소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.", "소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.",
"갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.", "갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.",
"비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.", "비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.",
"지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.", "지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.",
"짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다." "짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다."
], ],
"강한 소나기": [
"갑자기 쏟아지는 강한 소나기입니다. 이동을 잠시 멈추세요.",
"그칠 때까지 안전한 실내에서 기다리는 편이 좋습니다.",
"젖은 도로가 미끄러울 수 있으니 이동 시 주의하세요.",
"우산만으로는 부족할 수 있어 방수 옷차림을 챙기세요.",
"소나기가 지나간 뒤 도로 상황을 확인하고 움직이세요."
],
"약한 소나기눈": [
"짧게 흩날리는 눈입니다. 잠깐 피했다 이동해도 됩니다.",
"노면이 젖거나 살짝 얼 수 있어 걸음을 조심하세요.",
"따뜻하게 챙기고 짧은 외출 정도는 다녀올 만합니다.",
"그치는 틈을 봐서 다음 장소로 이동해 보세요.",
"쌓이기 전이라면 크게 무리는 없겠습니다."
],
"강한 소나기눈": [
"갑자기 쏟아지는 눈입니다. 이동을 잠시 멈추고 기다리세요.",
"노면이 급격히 미끄러워질 수 있어 주의가 필요합니다.",
"실내에서 그치기를 기다렸다 움직이는 편이 안전합니다.",
"차량 이동은 서행하고 앞차와 거리를 넉넉히 두세요.",
"쌓인 눈이 있다면 이동 전 노면 상태부터 확인하세요."
],
"뇌우": [ "뇌우": [
"천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.", "천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.",
"비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.", "비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.",
"번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.", "번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.",
"날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.", "날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.",
"궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요." "궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요."
],
"약한 우박 뇌우": [
"천둥·번개와 함께 약한 우박이 내릴 수 있습니다. 실내에 머무르세요.",
"우박이 그칠 때까지 차양 아래보다는 건물 안이 안전합니다.",
"야외 물건이 있다면 잠시 안으로 들여 두세요.",
"번개가 잦아든 뒤 이동 여부를 다시 확인하세요.",
"차량도 우박 피해가 없는 곳에 세워 두는 편이 좋습니다."
],
"강한 우박 뇌우": [
"굵은 우박을 동반한 뇌우입니다. 반드시 실내에 머무르세요.",
"창가나 개방된 곳을 피하고 안전한 공간에서 기다리세요.",
"차량은 우박을 피할 수 있는 곳에 세워 두세요.",
"기상특보를 확인하고 뇌우가 완전히 지나간 뒤 나서세요.",
"야외 활동은 오늘 일정에서 빼는 편이 안전하겠습니다."
] ]
}, },
"temperature": { "temperature": {

View File

@ -2,11 +2,14 @@
파일이 지키는 : 파일이 지키는 :
- 로그인한 사장님은 자기 사업장의 글만 본다(남의 가게 글이 섞이면 된다) - 로그인한 사장님은 자기 사업장의 글만 본다(남의 가게 글이 섞이면 된다)
- 아직 메일이 나간 REVIEWED 글도 로그인 화면에서 바로 고치고 승인할 있다 - 아직 메일이 나간 REVIEWED 글도 로그인 화면에서 바로 고칠 있다 , 저장만
한다. 승인은 이메일 승인 링크 또는 로그인 "바로 발행" 버튼, 경로 하나를
명시적으로 눌러야 한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고
바로발행버튼으로도 발행 가능하도록")
- 여기서도 금칙 게이트는 그대로 탄다 로그인했다고 우회되지 않는다 - 여기서도 금칙 게이트는 그대로 탄다 로그인했다고 우회되지 않는다
""" """
import uuid import uuid
from datetime import date, timedelta from datetime import date, timedelta, timezone
from sqlalchemy import text from sqlalchemy import text
@ -68,8 +71,9 @@ async def test_owner_cannot_see_someone_elses_posts(client, db_engine, auth_head
assert body["posts"] == [] assert body["posts"] == []
async def test_owner_can_edit_and_approve_before_mail_goes_out(client, db_engine, auth_headers): async def test_owner_edit_saves_body_without_approving(client, db_engine, auth_headers):
"""REVIEWED 글(아직 메일 안 나감)도 로그인 화면에서 바로 고쳐 승인할 수 있다.""" """로그인 화면에서 바로 고칠 수는 있지만, 저장만 한다 — 승인은 이메일 링크로만 일어난다
(2026-09-21, 사장님 지시: "승인되야 올라가도록 해야 한다")."""
h = await auth_headers("blogowner4") h = await auth_headers("blogowner4")
place_id = await _place(client, h) place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
@ -78,7 +82,12 @@ async def test_owner_can_edit_and_approve_before_mail_goes_out(client, db_engine
res = await client.put(f"/v1/place/{place_id}/post/{post_id}", headers=h, json={"body": new_body}) res = await client.put(f"/v1/place/{place_id}/post/{post_id}", headers=h, json={"body": new_body})
assert res.json()["result"]["success"] is True assert res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async with db_engine.begin() as conn:
saved_body = (await conn.execute(
text("SELECT body FROM place_posts WHERE post_id = :id"), {"id": post_id},
)).scalar()
assert saved_body == new_body
async def test_owner_edit_rejects_unverifiable_claims(client, db_engine, auth_headers): async def test_owner_edit_rejects_unverifiable_claims(client, db_engine, auth_headers):
@ -95,12 +104,104 @@ async def test_owner_edit_rejects_unverifiable_claims(client, db_engine, auth_he
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_owner_can_delete_a_post(client, db_engine, auth_headers):
h = await auth_headers("blogdel1")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.delete(f"/v1/place/{place_id}/post/{post_id}", headers=h)
assert res.json()["result"]["success"] is True
async with db_engine.begin() as conn:
deleted = (await conn.execute(
text("SELECT deleted FROM place_posts WHERE post_id = :id"), {"id": post_id},
)).scalar()
assert deleted is True
async def test_owner_cannot_delete_someone_elses_post(client, db_engine, auth_headers):
owner = await auth_headers("blogdel2")
other = await auth_headers("blogdel3")
place_id = await _place(client, owner)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.delete(f"/v1/place/{place_id}/post/{post_id}", headers=other)
assert res.json()["result"]["success"] is False
async with db_engine.begin() as conn:
deleted = (await conn.execute(
text("SELECT deleted FROM place_posts WHERE post_id = :id"), {"id": post_id},
)).scalar()
assert deleted is False
async def test_deleting_a_post_frees_its_date_for_regeneration(client, db_engine, auth_headers, monkeypatch):
"""삭제는 소프트 삭제라 (place_id, scheduled_date) 유니크가 풀린다 — 지운 날짜에
바로 다시 생성할 있어야 한다."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("새로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("blogdel4")
place_id = await _place(client, h, name="재생성펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
today = date.today()
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED, scheduled=today)
del_res = await client.delete(f"/v1/place/{place_id}/post/{post_id}", headers=h)
assert del_res.json()["result"]["success"] is True
gen_res = await client.post(
f"/v1/place/{place_id}/post/generate-one", headers=h, params={"date": today.isoformat()},
)
assert gen_res.json()["result"]["success"] is True
async def test_deleting_a_published_post_enqueues_rebuild(client, db_engine, auth_headers):
h = await auth_headers("blogdel5")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.PUBLISHED)
res = await client.delete(f"/v1/place/{place_id}/post/{post_id}", headers=h)
assert res.json()["result"]["success"] is True
async with db_engine.begin() as conn:
payload = (await conn.execute(
text("SELECT payload FROM jobs WHERE job_type = :jt ORDER BY created_at DESC LIMIT 1"),
{"jt": JobType.BUILD.value},
)).scalar()
assert payload["place_id"] == place_id
async def test_deleting_a_draft_post_does_not_enqueue_rebuild(client, db_engine, auth_headers):
h = await auth_headers("blogdel6")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.delete(f"/v1/place/{place_id}/post/{post_id}", headers=h)
assert res.json()["result"]["success"] is True
async with db_engine.begin() as conn:
count = (await conn.execute(
text("SELECT count(*) FROM jobs WHERE job_type = :jt"), {"jt": JobType.BUILD.value},
)).scalar()
assert count == 0
async def test_generate_now_creates_posts_for_published_site(client, db_engine, auth_headers, monkeypatch): async def test_generate_now_creates_posts_for_published_site(client, db_engine, auth_headers, monkeypatch):
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다(2026-09-17, """새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다(2026-09-17,
사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") 발행된 사이트일 때만.""" 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") 발행된 사이트일 때만."""
from services import blog_service from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics): async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
@ -109,8 +210,8 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine,
place_id = await _place(client, h, name="즉시생성펜션") place_id = await _place(client, h, name="즉시생성펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
start = date.today() start = date.today()
end = start + timedelta(days=29) end = start + timedelta(days=29)
@ -140,6 +241,38 @@ async def test_generate_now_creates_posts_for_published_site(client, db_engine,
assert all(start <= d <= end for d in dates) assert all(start <= d <= end for d in dates)
async def test_generate_now_does_not_append_a_publish_link(client, db_engine, auth_headers, monkeypatch):
"""미니 블로그에 올라가는 글에는 링크를 붙이지 않는다(2026-09-21, 사장님 지시).
site_payload.publish_url() 자체는 남겨둔다 나중에 쓰레드 연동에서 따로 있게."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return (f"테스트로 만든 문구입니다. {BODY}", "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloglink1")
place_id = await _place(client, h, name="링크확인펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
today = date.today()
res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": today.isoformat(), "end": today.isoformat()},
)
assert res.json()["created"] == 1
async with db_engine.begin() as conn:
body = (await conn.execute(
text("SELECT body FROM place_posts WHERE place_id = :pid"), {"pid": place_id},
)).scalar()
assert body == f"테스트로 만든 문구입니다. {BODY}"
async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_headers, monkeypatch): async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_headers, monkeypatch):
"""미래 날짜로 배정된 글은 그날이 오기 전엔 메일이 안 나간다.""" """미래 날짜로 배정된 글은 그날이 오기 전엔 메일이 안 나간다."""
from services import blog_jobs, mail_service from services import blog_jobs, mail_service
@ -152,8 +285,8 @@ async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_
place_id = await _place(client, h, name="예약펜션") place_id = await _place(client, h, name="예약펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen3"}) await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen3"})
@ -168,8 +301,218 @@ async def test_send_reviewed_only_mails_posts_due_today(client, db_engine, auth_
assert await _status(db_engine, future_post) == PostStatus.REVIEWED.value assert await _status(db_engine, future_post) == PostStatus.REVIEWED.value
async def test_send_reviewed_prefers_place_notify_email_over_account_email(client, db_engine, auth_headers, monkeypatch):
"""places.notify_email 이 있으면 계정 로그인 이메일(users.email) 대신 그 주소로 보낸다
(2026-09-21, 사장님 요청 사장님 명이 사이트를 여러 가질 있어 업장별로
다른 담당자에게 보낼 있어야 한다)."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
h = await auth_headers("bloggen4")
place_id = await _place(client, h, name="알림이메일펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen4"})
patch_res = await client.patch(f"/v1/place/{place_id}", headers=h, json={"notify_email": "manager@example.com"})
assert patch_res.json()["result"]["success"] is True
await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert sent_calls[0]["to"] == "manager@example.com"
async def test_update_place_rejects_malformed_notify_email(client, db_engine, auth_headers):
h = await auth_headers("bloggen5")
place_id = await _place(client, h, name="잘못된이메일펜션")
res = await client.patch(f"/v1/place/{place_id}", headers=h, json={"notify_email": "not-an-email"})
assert res.json()["result"]["success"] is False
async def test_send_now_mails_todays_due_post_immediately(client, db_engine, auth_headers, monkeypatch):
"""빌더 화면의 '승인 알림보내기' — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을
바로 보낸다(2026-09-21, 사장님 요청)."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
h = await auth_headers("bloggen6")
place_id = await _place(client, h, name="즉시발송펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner6@example.com", "id": "bloggen6"})
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is True
assert len(sent_calls) == 1
assert sent_calls[0]["to"] == "owner6@example.com"
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_send_now_is_a_noop_when_nothing_is_due(client, db_engine, auth_headers, monkeypatch):
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
h = await auth_headers("bloggen7")
place_id = await _place(client, h, name="빈발송펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await _seed_post(db_engine, place_id, scheduled=date.today() + timedelta(days=3))
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
body = res.json()
assert body["result"]["success"] is True
assert "없습니다" in body["msg"]
assert sent_calls == []
async def test_send_now_fails_clearly_when_mail_is_not_configured(client, db_engine, auth_headers, monkeypatch):
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: False)
h = await auth_headers("bloggen8")
place_id = await _place(client, h, name="메일미설정펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is False
async def test_send_now_cannot_be_triggered_for_someone_elses_place(client, db_engine, auth_headers):
owner = await auth_headers("bloggen9")
other = await auth_headers("bloggen10")
place_id = await _place(client, owner, name="남의발송펜션")
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=other)
assert res.json()["result"]["success"] is False
async def _day_pass_headers(db_engine, login_id: str) -> dict:
"""메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰(2026-09-21,
사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다")."""
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import CreateDayPassToken
async with db_engine.begin() as conn:
row = (await conn.execute(
text("SELECT user_id, role, token_version FROM users WHERE id = :id"), {"id": login_id},
)).first()
user_info = UserInfo(user_id=str(row[0]), id=login_id, role=row[1], token_version=row[2])
token = CreateDayPassToken(user_info)
return {"Authorization": f"Bearer {token}"}
async def test_approve_by_owner_shares_to_threads_when_linked(client, db_engine, auth_headers, monkeypatch):
"""'바로 발행' 버튼으로 승인하면, 쓰레드가 연동돼 있을 때 같은 문구가 쓰레드로도 나간다
(2026-09-21, 사장님 지시: "쓰레드에 연동되어 있으면 같이 업로드 되는 기능")."""
from services import post_service
calls = []
async def fake_publish_reused_text(user_id, place_id, body):
calls.append((user_id, place_id, body))
return uuid.uuid4()
monkeypatch.setattr(post_service.social_service, "publish_reused_text", fake_publish_reused_text)
h = await auth_headers("blogowner7")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=h)
assert res.json()["result"]["success"] is True
assert len(calls) == 1
assert calls[0][2] == BODY
assert str(calls[0][1]) == place_id
async def test_email_token_approve_also_shares_to_threads(client, db_engine, auth_headers, monkeypatch):
"""이메일 GET 승인 경로도 '바로 발행' 버튼과 동일하게 쓰레드 연동을 태운다."""
from services import blog_service, post_service
calls = []
async def fake_publish_reused_text(user_id, place_id, body):
calls.append((user_id, place_id, body))
return uuid.uuid4()
monkeypatch.setattr(post_service.social_service, "publish_reused_text", fake_publish_reused_text)
h = await auth_headers("blogowner8")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.SENT)
token, token_hash, expires = blog_service.issue_token()
aware_expires = expires.replace(tzinfo=timezone.utc) if expires.tzinfo is None else expires
async with db_engine.begin() as conn:
await conn.execute(
text("UPDATE place_posts SET approve_token_hash=:h, token_expires_at=:e WHERE post_id=:id"),
{"h": token_hash, "e": aware_expires, "id": post_id},
)
res = await client.get(f"/v1/site/post/approve?t={token}")
assert res.status_code == 200
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
assert len(calls) == 1
assert calls[0][2] == BODY
async def test_threads_share_failure_does_not_block_approval(client, db_engine, auth_headers, monkeypatch):
"""쓰레드 연동이 예외를 던져도 미니블로그 승인 자체는 그대로 끝나야 한다."""
from services import post_service
async def failing_publish_reused_text(user_id, place_id, body):
raise RuntimeError("threads down")
monkeypatch.setattr(post_service.social_service, "publish_reused_text", failing_publish_reused_text)
h = await auth_headers("blogowner9")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=h)
assert res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async def test_owner_can_publish_as_is_without_editing(client, db_engine, auth_headers): async def test_owner_can_publish_as_is_without_editing(client, db_engine, auth_headers):
"""바로 발행 — 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다.""" """'바로 발행' — 로그인 세션만으로, 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다
(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 바로발행버튼으로도
발행 가능하도록") — 이메일 승인 링크와 별개의 두 번째 경로다."""
h = await auth_headers("blogowner6") h = await auth_headers("blogowner6")
place_id = await _place(client, h) place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
@ -186,8 +529,8 @@ async def test_owner_can_publish_as_is_without_editing(client, db_engine, auth_h
async def test_owner_cannot_publish_someone_elses_post(client, db_engine, auth_headers): async def test_owner_cannot_publish_someone_elses_post(client, db_engine, auth_headers):
owner = await auth_headers("blogowner7") owner = await auth_headers("blogowner6e")
other = await auth_headers("blogowner8") other = await auth_headers("blogowner6f")
place_id = await _place(client, owner) place_id = await _place(client, owner)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED) post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
@ -197,6 +540,43 @@ async def test_owner_cannot_publish_someone_elses_post(client, db_engine, auth_h
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_daypass_session_can_approve_after_editing(client, db_engine, auth_headers):
"""메일 '수정하려면' 링크(day-pass)로 들어와 고친 뒤에는, 다시 메일을 뒤지지 않고
자리에서 승인할 있어야 한다(2026-09-21, 사장님 지시)."""
h = await auth_headers("blogowner6b")
place_id = await _place(client, h)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
day_pass = await _day_pass_headers(db_engine, "blogowner6b")
new_body = BODY.replace("빗소리", "새소리")
edit_res = await client.put(f"/v1/place/{place_id}/post/{post_id}", headers=day_pass, json={"body": new_body})
assert edit_res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
approve_res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=day_pass)
assert approve_res.json()["result"]["success"] is True
assert await _status(db_engine, post_id) == PostStatus.APPROVED.value
async with db_engine.begin() as conn:
saved_body = (await conn.execute(
text("SELECT body FROM place_posts WHERE post_id = :id"), {"id": post_id},
)).scalar()
assert saved_body == new_body
async def test_daypass_approve_cannot_be_used_for_someone_elses_place(client, db_engine, auth_headers):
owner = await auth_headers("blogowner6c")
place_id = await _place(client, owner)
post_id = await _seed_post(db_engine, place_id, status=PostStatus.REVIEWED)
await auth_headers("blogowner6d")
other_day_pass = await _day_pass_headers(db_engine, "blogowner6d")
res = await client.post(f"/v1/place/{place_id}/post/{post_id}/approve", headers=other_day_pass)
assert res.json()["result"]["success"] is False
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_generate_now_is_noop_for_unpublished_site(client, db_engine, auth_headers): async def test_generate_now_is_noop_for_unpublished_site(client, db_engine, auth_headers):
"""발행 전 사업장은 생성 스윕 대상이 아니다(blog_jobs._published_places) — 0건이어야 한다.""" """발행 전 사업장은 생성 스윕 대상이 아니다(blog_jobs._published_places) — 0건이어야 한다."""
h = await auth_headers("bloggen2") h = await auth_headers("bloggen2")
@ -213,6 +593,36 @@ async def test_generate_now_is_noop_for_unpublished_site(client, db_engine, auth
assert body["created"] == 0 assert body["created"] == 0
async def test_generate_now_is_noop_for_site_without_domain(client, db_engine, auth_headers, monkeypatch):
"""domain 미확정(임시 주소) 사이트도 생성 스윕 대상이 아니다 — 쓰레드 연동 요구사항과
맞춘다(2026-09-21). PUBLISHED 여도 domain 없으면 0건이어야 한다. generate_one
실제로 성공하도록 목킹해 둬야 "그냥 LLM 이 설정 안 돼서 0건" 구분된다."""
from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
h = await auth_headers("bloggen17")
place_id = await _place(client, h, name="도메인미정펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value},
)
start = date.today()
res = await client.post(
f"/v1/place/{place_id}/post/generate", headers=h,
params={"start": start.isoformat(), "end": start.isoformat()},
)
body = res.json()
assert body["result"]["success"] is True
assert body["created"] == 0
async def test_generate_now_rejects_end_before_start(client, db_engine, auth_headers): async def test_generate_now_rejects_end_before_start(client, db_engine, auth_headers):
h = await auth_headers("bloggen16") h = await auth_headers("bloggen16")
place_id = await _place(client, h, name="구간역순펜션") place_id = await _place(client, h, name="구간역순펜션")
@ -310,7 +720,7 @@ async def test_generation_history_counts_by_batch(client, db_engine, auth_header
생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다).""" 생성했는지" / "어느 모델썼는지 등등" → JSONB 한 칸(generation_meta)에 담는다)."""
from services import blog_service from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics): async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
@ -319,8 +729,8 @@ async def test_generation_history_counts_by_batch(client, db_engine, auth_header
place_id = await _place(client, h, name="이력펜션") place_id = await _place(client, h, name="이력펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
start = date.today() start = date.today()
generate_res = await client.post( generate_res = await client.post(
@ -351,8 +761,8 @@ async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_en
place_id = await _place(client, h, name="메일링크펜션") place_id = await _place(client, h, name="메일링크펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
await conn.execute( await conn.execute(
text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen11"}, text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner@example.com", "id": "bloggen11"},
@ -369,11 +779,47 @@ async def test_mail_has_one_click_approve_and_autologin_edit_links(client, db_en
assert "auto=" in mail_text assert "auto=" in mail_text
async def test_mail_links_use_the_builder_app_origin_not_the_published_site_origin(
client, db_engine, auth_headers, monkeypatch,
):
"""수정·승인 링크는 빌더 앱(SOCIAL_APP_ORIGIN)으로 가야 한다 — 발행된 사이트 오리진
(site_payload.publish_origin, 로컬에선 solution-site 정적 서버 포트 80)으로 가면
404 난다(2026-09-21 실측: 메일의 '수정하려면' 링크가 거기로 가서 404)."""
from services import blog_jobs, mail_service, site_payload
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
sent_calls = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: sent_calls.append(kwargs) or True)
monkeypatch.setattr(site_payload, "publish_origin", lambda: "http://published-site-origin")
monkeypatch.setattr(
blog_jobs.social_config, "get",
lambda name, default="": "http://builder-app-origin" if name == "SOCIAL_APP_ORIGIN" else default,
)
h = await auth_headers("bloggen12")
place_id = await _place(client, h, name="오리진확인펜션")
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": "owner12@example.com", "id": "bloggen12"})
await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
mail_text = sent_calls[0]["text"]
assert "http://builder-app-origin/blog?placeId=" in mail_text
assert "http://builder-app-origin/v1/site/post/approve?t=" in mail_text
assert "published-site-origin" not in mail_text
async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_headers, monkeypatch): async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_headers, monkeypatch):
"""사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다.""" """사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다."""
from services import blog_service from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics): async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
@ -382,8 +828,8 @@ async def test_generate_one_fills_a_specific_empty_date(client, db_engine, auth_
place_id = await _place(client, h, name="개별생성펜션") place_id = await _place(client, h, name="개별생성펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
target = date.today() + timedelta(days=3) target = date.today() + timedelta(days=3)
@ -401,7 +847,7 @@ async def test_generate_one_fails_when_date_already_taken(client, db_engine, aut
"""이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다.""" """이미 그 날짜에 글이 있으면(유니크 충돌) 조용히 덮지 않고 실패로 답한다."""
from services import blog_service from services import blog_service
async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics): async def fake_generate_one(*, place_name, region, topic_kind, material, used_topics, place_category):
return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model") return ("테스트로 만든 문구입니다. " + BODY, "gemini-test-model")
monkeypatch.setattr(blog_service, "generate_one", fake_generate_one) monkeypatch.setattr(blog_service, "generate_one", fake_generate_one)
@ -410,8 +856,8 @@ async def test_generate_one_fails_when_date_already_taken(client, db_engine, aut
place_id = await _place(client, h, name="중복날짜펜션") place_id = await _place(client, h, name="중복날짜펜션")
async with db_engine.begin() as conn: async with db_engine.begin() as conn:
await conn.execute( await conn.execute(
text("INSERT INTO sites (site_id, place_id, status) VALUES (:sid, :pid, :st)"), text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "st": SiteStatus.PUBLISHED.value}, {"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
) )
target = date.today() + timedelta(days=3) target = date.today() + timedelta(days=3)
await _seed_post(db_engine, place_id, scheduled=target) await _seed_post(db_engine, place_id, scheduled=target)

View File

@ -157,39 +157,43 @@ async def test_expired_and_stale_sweep(client, auth_headers, db_engine):
async def test_no_facts_no_paid_call(monkeypatch): async def test_no_facts_no_paid_call(monkeypatch):
async def forbidden(*args, **kwargs): from services.llm import provider as llm_provider
def forbidden():
raise AssertionError("paid call") raise AssertionError("paid call")
monkeypatch.setattr(gemini_text, "call", forbidden) monkeypatch.setattr(llm_provider, "active", forbidden)
with pytest.raises(gemini_text.GeminiInvalidOutput, match="NO_GROUNDED_FACTS"): with pytest.raises(gemini_text.GeminiInvalidOutput, match="NO_GROUNDED_FACTS"):
await gemini_text.generate_social_post("숙소", [], "https://example.com/s/stay") await gemini_text.generate_social_post("숙소", [], "https://example.com/s/stay")
async def test_long_draft_regenerates(monkeypatch): async def test_long_draft_regenerates(monkeypatch):
monkeypatch.setattr(gemini_text, "is_configured", lambda: True) """generate_social_post 는 LLM_PROVIDER 추상화(services/llm/provider.py)를 탄다
(2026-09-21, Gemini 하드코딩 제거) 여기서는 provider.active() 돌려주는 공급자
자체를 가짜로 바꿔 길이 초과 재요청 통과 흐름만 검증한다."""
from services.llm import provider as llm_provider
from services.llm.types import LlmResult, Usage
bodies = iter(["" * 501, "체크인은 15:00입니다."]) bodies = iter(["" * 501, "체크인은 15:00입니다."])
async def call(*args, **kwargs): class _FakeLlm:
return { __name__ = "services.llm.fake"
"candidates": [ DEFAULT_MODEL = "fake-model"
{
"content": {
"parts": [
{
"text": json.dumps(
{
"body": next(bodies),
"fact_keys": ["check_in_time"],
}
)
}
]
}
}
]
}
monkeypatch.setattr(gemini_text, "call", call) @staticmethod
def is_configured():
return True
@staticmethod
async def generate(client, model, *, prompt, response_schema=None, temperature=0.2, max_retries=0, images=None):
body = next(bodies)
return LlmResult(
json={"body": body, "fact_keys": ["check_in_time"]},
text=json.dumps({"body": body, "fact_keys": ["check_in_time"]}),
usage=Usage(input_tokens=0, output_tokens=0),
)
monkeypatch.setattr(llm_provider, "active", lambda: _FakeLlm())
result = await gemini_text.generate_social_post( result = await gemini_text.generate_social_post(
"숙소", "숙소",
[gemini_text.FactInput(key="check_in_time", label="체크인", value="15:00")], [gemini_text.FactInput(key="check_in_time", label="체크인", value="15:00")],
@ -495,3 +499,103 @@ async def test_oauth_roundtrip_saves_encrypted_account(db_engine, monkeypatch):
"SELECT status, access_token FROM owner_social_accounts WHERE user_id = :u"), "SELECT status, access_token FROM owner_social_accounts WHERE user_id = :u"),
{"u": user_id})).first() {"u": user_id})).first()
assert after.status == "revoked" and after.access_token is None, "해제해도 토큰이 남아 있다" assert after.status == "revoked" and after.access_token is None, "해제해도 토큰이 남아 있다"
async def test_publish_reused_text_skips_when_no_threads_account(db_engine, auth_headers, client):
from services import social_service as svc
h, pid, uid, v = await seed(client, auth_headers, db_engine)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 0
async def test_publish_reused_text_skips_when_posting_disabled(db_engine, auth_headers, client, monkeypatch):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: False)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
async with db_engine.begin() as c:
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": uuid.uuid4(), "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 0
async def test_publish_reused_text_skips_when_domain_not_fixed(db_engine, auth_headers, client, monkeypatch):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: True)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
async with db_engine.begin() as c:
await c.execute(text("UPDATE sites SET domain=NULL WHERE place_id=:p"), {"p": pid})
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": uuid.uuid4(), "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is None
async def test_publish_reused_text_inserts_approved_post_with_link_and_enqueues_run_post(
db_engine, auth_headers, client, monkeypatch
):
from services import social_service as svc
monkeypatch.setattr(svc, "posting_enabled", lambda: True)
h, pid, uid, v = await seed(client, auth_headers, db_engine)
account_id = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text(
"INSERT INTO owner_social_accounts(account_id,user_id,provider,provider_user_id,handle,profile_url,status) "
"VALUES (:a,:u,2,'22','host','https://www.threads.com/@host','linked')"
),
{"a": account_id, "u": uid},
)
result = await svc.publish_reused_text(uid, pid, "오늘도 마당이 조용합니다.")
assert result is not None
expected_url = svc.site_payload.publish_origin() + "/s/social-stay"
async with db_engine.begin() as c:
row = (
await c.execute(
text(
"SELECT status, body, link_url, decided_via, account_id FROM place_social_posts WHERE post_id=:p"
),
{"p": result},
)
).first()
assert row.status == "APPROVED"
assert row.body == f"오늘도 마당이 조용합니다.\n\n{expected_url}"
assert row.link_url == expected_url
assert row.decided_via == "mini_blog"
assert row.account_id == account_id
job_count = (
await c.execute(text("SELECT count(*) FROM jobs WHERE job_type=9"))
).scalar_one()
assert job_count == 1
# 같은 site_version 에 두 번째 호출 — 유니크 인덱스가 중복 삽입을 막는다
second = await svc.publish_reused_text(uid, pid, "다른 문구")
assert second is None
async with db_engine.begin() as c:
assert (
await c.execute(text("SELECT count(*) FROM place_social_posts WHERE place_id=:p"), {"p": pid})
).scalar_one() == 1

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ApprovalParams = {
/**
* @minLength 40
* @maxLength 100
*/
t: string;
};

View File

@ -0,0 +1,10 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface Decision {
approve: boolean;
}

View File

@ -5,12 +5,14 @@
* OpenAPI spec version: 0.1.0 * OpenAPI spec version: 0.1.0
*/ */
export * from "./approvalParams";
export * from "./approvePageParams"; export * from "./approvePageParams";
export * from "./auditCheckData"; export * from "./auditCheckData";
export * from "./auditCheckDataRecommendation"; export * from "./auditCheckDataRecommendation";
export * from "./authProvider"; export * from "./authProvider";
export * from "./buildStatus"; export * from "./buildStatus";
export * from "./checkSlugParams"; export * from "./checkSlugParams";
export * from "./decision";
export * from "./errorInfo"; export * from "./errorInfo";
export * from "./errorInfoCode"; export * from "./errorInfoCode";
export * from "./errorInfoDesc"; export * from "./errorInfoDesc";
@ -61,6 +63,7 @@ export * from "./linkData";
export * from "./linkDataConfirmedAt"; export * from "./linkDataConfirmedAt";
export * from "./linkDataDiscoveredAt"; export * from "./linkDataDiscoveredAt";
export * from "./linkDataTitle"; export * from "./linkDataTitle";
export * from "./linkDecision";
export * from "./listContentsParams"; export * from "./listContentsParams";
export * from "./listFactsParams"; export * from "./listFactsParams";
export * from "./listFaqsParams"; export * from "./listFaqsParams";
@ -103,6 +106,7 @@ export * from "./mySiteDataSiteId";
export * from "./mySiteDataStatus"; export * from "./mySiteDataStatus";
export * from "./mySiteDataTemplateId"; export * from "./mySiteDataTemplateId";
export * from "./mySiteDataThumbnailUrl"; export * from "./mySiteDataThumbnailUrl";
export * from "./oauthCallbackParams";
export * from "./placeCandidate"; export * from "./placeCandidate";
export * from "./placeCandidateAddress"; export * from "./placeCandidateAddress";
export * from "./placeCandidateCategoryName"; export * from "./placeCandidateCategoryName";
@ -126,6 +130,7 @@ export * from "./placeDataExternalPlaceId";
export * from "./placeDataExternalSource"; export * from "./placeDataExternalSource";
export * from "./placeDataLatitude"; export * from "./placeDataLatitude";
export * from "./placeDataLongitude"; export * from "./placeDataLongitude";
export * from "./placeDataNotifyEmail";
export * from "./placeDataOwnerUserId"; export * from "./placeDataOwnerUserId";
export * from "./placeDataPhone"; export * from "./placeDataPhone";
export * from "./placeDataRegionCode"; export * from "./placeDataRegionCode";
@ -208,6 +213,7 @@ export * from "./reqUpdateMeName";
export * from "./reqUpdateMePassword"; export * from "./reqUpdateMePassword";
export * from "./reqUpdatePlace"; export * from "./reqUpdatePlace";
export * from "./reqUpdatePlaceName"; export * from "./reqUpdatePlaceName";
export * from "./reqUpdatePlaceNotifyEmail";
export * from "./reqUpdatePlaceStatus"; export * from "./reqUpdatePlaceStatus";
export * from "./reqUpsertFact"; export * from "./reqUpsertFact";
export * from "./reqUpsertFactExpiresAt"; export * from "./reqUpsertFactExpiresAt";
@ -372,6 +378,7 @@ export * from "./siteVersionDataBuildError";
export * from "./siteVersionDataBuiltAt"; export * from "./siteVersionDataBuiltAt";
export * from "./siteVersionDataCreatedAt"; export * from "./siteVersionDataCreatedAt";
export * from "./sourceType"; export * from "./sourceType";
export * from "./testPost";
export * from "./unitData"; export * from "./unitData";
export * from "./userRole"; export * from "./userRole";
export * from "./validationError"; export * from "./validationError";

View File

@ -23,4 +23,6 @@ export const JobType = {
AI_CHECK: 6, AI_CHECK: 6,
SONG: 7, SONG: 7,
ROLLBACK: 8, ROLLBACK: 8,
SOCIAL_DRAFT: 9,
SOCIAL_POST: 10,
} as const; } as const;

View File

@ -0,0 +1,15 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface LinkDecision {
approve: boolean;
/**
* @minLength 40
* @maxLength 100
*/
t: string;
}

View File

@ -0,0 +1,21 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type OauthCallbackParams = {
/**
* @maxLength 2048
*/
state?: string;
/**
* @maxLength 4096
*/
code?: string;
/**
* @maxLength 200
*/
error?: string;
};

View File

@ -17,6 +17,7 @@ import type { PlaceDataLongitude } from "./placeDataLongitude";
import type { PlaceDataRegionCode } from "./placeDataRegionCode"; import type { PlaceDataRegionCode } from "./placeDataRegionCode";
import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt"; import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt";
import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt"; import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt";
import type { PlaceDataNotifyEmail } from "./placeDataNotifyEmail";
import type { PlaceDataCreatedAt } from "./placeDataCreatedAt"; import type { PlaceDataCreatedAt } from "./placeDataCreatedAt";
export interface PlaceData { export interface PlaceData {
@ -35,5 +36,6 @@ export interface PlaceData {
region_code?: PlaceDataRegionCode; region_code?: PlaceDataRegionCode;
verified_at?: PlaceDataVerifiedAt; verified_at?: PlaceDataVerifiedAt;
content_updated_at?: PlaceDataContentUpdatedAt; content_updated_at?: PlaceDataContentUpdatedAt;
notify_email?: PlaceDataNotifyEmail;
created_at?: PlaceDataCreatedAt; created_at?: PlaceDataCreatedAt;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type PlaceDataNotifyEmail = string | null;

View File

@ -6,8 +6,10 @@
*/ */
import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName"; import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName";
import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus"; import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail";
export interface ReqUpdatePlace { export interface ReqUpdatePlace {
name?: ReqUpdatePlaceName; name?: ReqUpdatePlaceName;
status?: ReqUpdatePlaceStatus; status?: ReqUpdatePlaceStatus;
notify_email?: ReqUpdatePlaceNotifyEmail;
} }

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export type ReqUpdatePlaceNotifyEmail = string | null;

View File

@ -0,0 +1,14 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Web4Ai API
* OpenAPI spec version: 0.1.0
*/
export interface TestPost {
/**
* @minLength 1
* @maxLength 500
*/
text: string;
}

View File

@ -2562,7 +2562,7 @@ export function useGetMyPost<
} }
/** /**
* @summary · * @summary ,
*/ */
export const editMyPost = ( export const editMyPost = (
placeId: string, placeId: string,
@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost;
export type EditMyPostMutationError = HTTPValidationError; export type EditMyPostMutationError = HTTPValidationError;
/** /**
* @summary · * @summary ,
*/ */
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>( export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
options?: { options?: {
@ -2650,7 +2650,92 @@ export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/** /**
* @summary * @summary
*/
export const deleteMyPost = (
placeId: string,
postId: string,
options?: SecondParameter<typeof customFetch>,
) => {
return customFetch<ResWebPacketProtocol>(
{ url: `/v1/place/${placeId}/post/${postId}`, method: "DELETE" },
options,
);
};
export const getDeleteMyPostMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
> => {
const mutationKey = ["deleteMyPost"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteMyPost>>,
{ placeId: string; postId: string }
> = (props) => {
const { placeId, postId } = props ?? {};
return deleteMyPost(placeId, postId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteMyPostMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteMyPost>>
>;
export type DeleteMyPostMutationError = HTTPValidationError;
/**
* @summary
*/
export const useDeleteMyPost = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof deleteMyPost>>,
TError,
{ placeId: string; postId: string },
TContext
> => {
const mutationOptions = getDeleteMyPostMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/**
* @summary ( )
*/ */
export const approveMyPost = ( export const approveMyPost = (
placeId: string, placeId: string,
@ -2713,7 +2798,7 @@ export type ApproveMyPostMutationResult = NonNullable<
export type ApproveMyPostMutationError = HTTPValidationError; export type ApproveMyPostMutationError = HTTPValidationError;
/** /**
* @summary * @summary ( )
*/ */
export const useApproveMyPost = < export const useApproveMyPost = <
TError = HTTPValidationError, TError = HTTPValidationError,
@ -2739,6 +2824,91 @@ export const useApproveMyPost = <
return useMutation(mutationOptions, queryClient); return useMutation(mutationOptions, queryClient);
}; };
/**
* @summary 9
*/
export const sendMyPostsNow = (
placeId: string,
options?: SecondParameter<typeof customFetch>,
signal?: AbortSignal,
) => {
return customFetch<ResWebPacketProtocol>(
{ url: `/v1/place/${placeId}/post/send-now`, method: "POST", signal },
options,
);
};
export const getSendMyPostsNowMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
> => {
const mutationKey = ["sendMyPostsNow"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof sendMyPostsNow>>,
{ placeId: string }
> = (props) => {
const { placeId } = props ?? {};
return sendMyPostsNow(placeId, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type SendMyPostsNowMutationResult = NonNullable<
Awaited<ReturnType<typeof sendMyPostsNow>>
>;
export type SendMyPostsNowMutationError = HTTPValidationError;
/**
* @summary 9
*/
export const useSendMyPostsNow = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
>;
request?: SecondParameter<typeof customFetch>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof sendMyPostsNow>>,
TError,
{ placeId: string },
TContext
> => {
const mutationOptions = getSendMyPostsNowMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};
/** /**
* @summary (04:10) , * @summary (04:10) ,
*/ */

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,19 @@
import {useEffect, useMemo, useState} from 'react'; import {useEffect, useMemo, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router'; import {useNavigate, useSearchParams} from 'react-router';
import {Loader2, Pencil, Plus, Save, Send, Sparkles, X} from 'lucide-react'; import {Loader2, Pencil, Plus, Save, Send, Sparkles, Trash2, X} from 'lucide-react';
import { import {
useApproveMyPost, useApproveMyPost,
useDeleteMyPost,
useEditMyPost, useEditMyPost,
useGenerateMyPostForDate, useGenerateMyPostForDate,
useGenerateMyPosts, useGenerateMyPosts,
useGetGenerationHistory, useGetGenerationHistory,
useGetMyPost, useGetMyPost,
useGetPlace,
useListMyPosts, useListMyPosts,
useListUpcomingPosts, useListUpcomingPosts,
useSendMyPostsNow,
useUpdatePlace,
type PostData, type PostData,
} from '@/api'; } from '@/api';
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell'; import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
@ -141,7 +145,10 @@ function publishBadge(post: PostData): {label: string; className: string} | null
return null; return null;
} }
/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */ /**
* . (2026-09-21, :
* "승인되야 올라가도록 해야 한다") .
*/
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) { function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
const [body, setBody] = useState(post.body); const [body, setBody] = useState(post.body);
const editMutation = useEditMyPost(); const editMutation = useEditMyPost();
@ -153,7 +160,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.'); notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
return; return;
} }
notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.'); notify.success('저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다.');
onDone(); onDone();
} catch (error) { } catch (error) {
notifyApiError(error, '저장하지 못했습니다.'); notifyApiError(error, '저장하지 못했습니다.');
@ -171,7 +178,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}> <Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
<Save /> <Save />
</Button> </Button>
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}> <Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
<X /> <X />
@ -186,10 +193,11 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1]; const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
const editable = EDITABLE_STATUSES.has(post.status); const editable = EDITABLE_STATUSES.has(post.status);
const approveMutation = useApproveMyPost();
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null}; const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
const deleteMutation = useDeleteMyPost();
const approveMutation = useApproveMyPost();
const handlePublish = async () => { const handleApprove = async () => {
try { try {
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id}); const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
if (!res.result?.success) { if (!res.result?.success) {
@ -203,6 +211,23 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
} }
}; };
const handleDelete = async () => {
// 이미 게재된 글은 지우면 사이트에서도 빠지기까지 재발행이 걸린다 — 되돌릴 수 없는
// 작업이라 한 번 확인한다(SitesPage.tsx 의 발행 내리기와 같은 관례).
if (!window.confirm('이 글을 삭제할까요? 게재된 글이면 사이트에서도 곧 사라집니다.')) return;
try {
const res = await deleteMutation.mutateAsync({placeId, postId: post.post_id});
if (!res.result?.success) {
notify.error(res.msg || '삭제하지 못했습니다.');
return;
}
notify.success('삭제했습니다.');
onChanged();
} catch (error) {
notifyApiError(error, '삭제하지 못했습니다.');
}
};
return ( return (
<div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg"> <div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@ -228,15 +253,35 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
}} }}
/> />
)} )}
{editable && !isEditing && ( {!isEditing && (
<div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3"> <div className="mt-3 flex items-center gap-1.5 border-t border-border pt-3">
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}> {editable && (
<Pencil /> <Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
<Pencil />
</Button>
<Button size="sm" variant="primary" className="flex-1" isLoading={approveMutation.isPending} onClick={handlePublish}> </Button>
<Send /> )}
{editable && (
<Button
size="sm"
variant="primary"
className="flex-1"
isLoading={approveMutation.isPending}
onClick={handleApprove}
>
<Send />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="flex-1 text-destructive hover:text-destructive"
isLoading={deleteMutation.isPending}
onClick={handleDelete}
>
<Trash2 />
</Button> </Button>
</div> </div>
)} )}
@ -442,6 +487,78 @@ function GenerationHistoryList({placeId}: {placeId: string}) {
); );
} }
/**
* (users.email)
* (2026-09-21, : "그 이메일은 변경 가능하도록").
* .
*/
function NotifyEmailSetting({placeId}: {placeId: string}) {
const {data: placeRes} = useGetPlace(placeId, {query: {enabled: !!placeId}});
const savedEmail = placeRes?.place?.notify_email ?? '';
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(savedEmail);
const updateMutation = useUpdatePlace();
useEffect(() => {
setValue(savedEmail);
}, [savedEmail]);
const handleSave = async () => {
try {
const res = await updateMutation.mutateAsync({placeId, data: {notify_email: value.trim()}});
if (!res.result?.success) {
notify.error(res.msg || '저장하지 못했습니다. 이메일 형식을 확인해 주세요.');
return;
}
notify.success('알림 이메일을 저장했습니다.');
setIsEditing(false);
} catch (error) {
notifyApiError(error, '저장하지 못했습니다.');
}
};
return (
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
<span className="text-muted-foreground"> </span>
{isEditing ? (
<>
<input
type="email"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="비우면 계정 이메일로 받습니다"
className="min-w-52 flex-1 rounded-md border border-border bg-card px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
/>
<Button size="sm" variant="primary" isLoading={updateMutation.isPending} onClick={handleSave}>
<Save />
</Button>
<Button
size="sm"
variant="ghost"
disabled={updateMutation.isPending}
onClick={() => {
setIsEditing(false);
setValue(savedEmail);
}}
>
<X />
</Button>
</>
) : (
<>
<span className="font-medium">{savedEmail || '(계정 이메일 사용)'}</span>
<Button size="sm" variant="ghost" onClick={() => setIsEditing(true)}>
<Pencil />
</Button>
</>
)}
</div>
);
}
/** /**
* ( ) . 기획: docs/MINI_BLOG.md * ( ) . 기획: docs/MINI_BLOG.md
* *
@ -506,6 +623,7 @@ export function BlogPostsPage() {
const generateMutation = useGenerateMyPosts(); const generateMutation = useGenerateMyPosts();
const generateOneMutation = useGenerateMyPostForDate(); const generateOneMutation = useGenerateMyPostForDate();
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null; const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
const sendNowMutation = useSendMyPostsNow();
const [rangeDialogOpen, setRangeDialogOpen] = useState(false); const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
const [rangeStart, setRangeStart] = useState(todayIso()); const [rangeStart, setRangeStart] = useState(todayIso());
@ -546,11 +664,31 @@ export function BlogPostsPage() {
} }
}; };
const handleSendNow = async () => {
try {
const res = await sendNowMutation.mutateAsync({placeId});
if (!res.result?.success) {
notify.error(res.msg || '발송하지 못했습니다.');
return;
}
notify.success(res.msg ?? '보냈습니다.');
refetchAll();
} catch (sendNowError) {
notifyApiError(sendNowError, '발송하지 못했습니다.');
}
};
const generateButton = ( const generateButton = (
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}> <div className="flex items-center gap-1.5">
<Sparkles /> <Button size="sm" variant="ghost" isLoading={sendNowMutation.isPending} onClick={handleSendNow}>
<Send />
</Button>
</Button>
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
<Sparkles />
</Button>
</div>
); );
const maxGenerateDate = lastDayOfMonthIso(maxMonth); const maxGenerateDate = lastDayOfMonthIso(maxMonth);
@ -595,6 +733,8 @@ export function BlogPostsPage() {
))} ))}
</div> </div>
<NotifyEmailSetting placeId={placeId} />
{tab === 'main' && ( {tab === 'main' && (
<> <>
{upcomingPosts.length > 0 ? ( {upcomingPosts.length > 0 ? (

View File

@ -291,8 +291,35 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */ /** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
export type WeatherMood = '맑음' | '흐림' | '비' | '눈'; export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */ /** 그림은 WeatherMood 넷으로 그리고, 문구는 WMO weather_code 하나마다 고유한 이 28종으로 고른다. */
export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우'; export type WeatherSky =
| WeatherMood
| '대체로 맑음'
| '구름 조금'
| '안개'
| '착빙성 안개'
| '가벼운 이슬비'
| '보통 이슬비'
| '강한 이슬비'
| '가벼운 착빙성 이슬비'
| '강한 착빙성 이슬비'
| '약한 비'
| '보통 비'
| '강한 비'
| '약한 착빙성 비'
| '강한 착빙성 비'
| '약한 눈'
| '보통 눈'
| '강한 눈'
| '싸라기눈'
| '약한 소나기'
| '보통 소나기'
| '강한 소나기'
| '약한 소나기눈'
| '강한 소나기눈'
| '뇌우'
| '약한 우박 뇌우'
| '강한 우박 뇌우';
export interface WeatherSnapshot { export interface WeatherSnapshot {
temperature: number; temperature: number;

View File

@ -1,28 +1,44 @@
import {expect, it} from 'vitest'; import {expect, it} from 'vitest';
import {condition} from './use-live-weather'; import {condition} from './use-live-weather';
it('recognizes fog, showers and thunderstorms as distinct conditions', () => { const WEATHER_CODE_LABELS: Record<number, string> = {
expect(condition(45)).toBe('안개'); 0: '맑음',
expect(condition(48)).toBe('안개'); 1: '대체로 맑음',
expect(condition(80)).toBe('소나기'); 2: '구름 조금',
expect(condition(81)).toBe('소나기'); 3: '흐림',
expect(condition(82)).toBe('소나기'); 45: '안개',
expect(condition(85)).toBe('소나기'); 48: '착빙성 안개',
expect(condition(86)).toBe('소나기'); 51: '가벼운 이슬비',
expect(condition(95)).toBe('뇌우'); 53: '보통 이슬비',
expect(condition(96)).toBe('뇌우'); 55: '강한 이슬비',
expect(condition(99)).toBe('뇌우'); 56: '가벼운 착빙성 이슬비',
57: '강한 착빙성 이슬비',
61: '약한 비',
63: '보통 비',
65: '강한 비',
66: '약한 착빙성 비',
67: '강한 착빙성 비',
71: '약한 눈',
73: '보통 눈',
75: '강한 눈',
77: '싸라기눈',
80: '약한 소나기',
81: '보통 소나기',
82: '강한 소나기',
85: '약한 소나기눈',
86: '강한 소나기눈',
95: '뇌우',
96: '약한 우박 뇌우',
99: '강한 우박 뇌우',
};
it('gives every WMO weather code its own label', () => {
for (const [code, label] of Object.entries(WEATHER_CODE_LABELS)) {
expect(condition(Number(code))).toBe(label);
}
}); });
it('splits rain into drizzle, normal, heavy and freezing', () => { it('falls back to overcast for unknown codes', () => {
expect(condition(51)).toBe('이슬비'); expect(condition(4)).toBe('흐림');
expect(condition(53)).toBe('이슬비'); expect(condition(-1)).toBe('흐림');
expect(condition(55)).toBe('이슬비');
expect(condition(61)).toBe('비');
expect(condition(63)).toBe('비');
expect(condition(65)).toBe('강한비');
expect(condition(56)).toBe('어는비');
expect(condition(57)).toBe('어는비');
expect(condition(66)).toBe('어는비');
expect(condition(67)).toBe('어는비');
}); });

View File

@ -5,18 +5,43 @@ export interface LiveWeather extends WeatherSnapshot {
stale?: boolean; stale?: boolean;
} }
/** WMO weather code → 한 줄. ★ solution/backend/services/site_payload.py 의 _weather_condition() 과 같은 구간이어야 한다. */ /**
* WMO weather code . ( )
* solution/backend/services/site_payload.py _WEATHER_CONDITION_BY_CODE .
*/
const WEATHER_CONDITION_BY_CODE: Record<number, string> = {
0: '맑음',
1: '대체로 맑음',
2: '구름 조금',
3: '흐림',
45: '안개',
48: '착빙성 안개',
51: '가벼운 이슬비',
53: '보통 이슬비',
55: '강한 이슬비',
56: '가벼운 착빙성 이슬비',
57: '강한 착빙성 이슬비',
61: '약한 비',
63: '보통 비',
65: '강한 비',
66: '약한 착빙성 비',
67: '강한 착빙성 비',
71: '약한 눈',
73: '보통 눈',
75: '강한 눈',
77: '싸라기눈',
80: '약한 소나기',
81: '보통 소나기',
82: '강한 소나기',
85: '약한 소나기눈',
86: '강한 소나기눈',
95: '뇌우',
96: '약한 우박 뇌우',
99: '강한 우박 뇌우',
};
export function condition(code: number): string { export function condition(code: number): string {
if (code === 0) return '맑음'; return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
if (code >= 1 && code <= 2) return '구름많음';
if (code === 3) return '흐림';
if (code === 45 || code === 48) return '안개';
if (code >= 51 && code <= 57) return '이슬비';
if (code >= 61 && code <= 67) return '비';
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return '눈';
if (code >= 80 && code <= 82) return '소나기';
if (code >= 95 && code <= 99) return '뇌우';
return '흐림';
} }
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */ /** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */

View File

@ -12,20 +12,20 @@ function render(weather?: WeatherSnapshot) {
} }
it('uses the first sky and temperature lines for deterministic HTML', () => { it('uses the first sky and temperature lines for deterministic HTML', () => {
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '구름많음', noteSets: {'구름많음': ['구름 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}}; const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
expect(render(weather)).toContain('구름 첫 줄'); expect(render(weather)).toContain('맑음 첫 줄');
expect(render(weather)).toContain('더위 첫 줄'); expect(render(weather)).toContain('더위 첫 줄');
expect(render(weather)).not.toContain('다음 줄'); expect(render(weather)).not.toContain('다음 줄');
expect(render(weather)).toBe(render(weather)); expect(render(weather)).toBe(render(weather));
}); });
it('uses condition-specific notes for fog, showers and thunderstorms', () => { it('uses condition-specific notes for fog, showers and thunderstorms', () => {
const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '소나기', noteSets: {'소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}}; const weather = {temperature: 20, observedAt: '2026-09-15T12:00', condition: '보통 소나기', noteSets: {'보통 소나기': ['소나기 첫 줄']}, tempNoteSets: {'선선': ['선선 첫 줄']}};
expect(render(weather)).toContain('소나기 첫 줄'); expect(render(weather)).toContain('소나기 첫 줄');
}); });
it('uses condition-specific notes for the rain subtypes', () => { it('uses condition-specific notes for the rain subtypes', () => {
const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '어는비', noteSets: {'어는비': ['어는비 첫 줄'], '비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}}; const weather = {temperature: 5, observedAt: '2026-09-15T12:00', condition: '강한 착빙성 비', noteSets: {'강한 착빙성 비': ['어는비 첫 줄'], '보통 비': ['일반 비 첫 줄']}, tempNoteSets: {'추움': ['추움 첫 줄']}};
expect(render(weather)).toContain('어는비 첫 줄'); expect(render(weather)).toContain('어는비 첫 줄');
expect(render(weather)).not.toContain('일반 비 첫 줄'); expect(render(weather)).not.toContain('일반 비 첫 줄');
}); });