Merge remote-tracking branch 'origin/main' into feature/owner-kakao-link
This commit is contained in:
commit
627fb1e141
@ -346,6 +346,13 @@ JWT 를 쓰지 않는 이유는 JWT 가 서명만 검증하고 **소비를 못
|
||||
**사람이 누르기 전에** 그 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 포함)를
|
||||
넣는다 — 그게 링크에 실리면 카톡 전달 한 번이 **빌더 전체 권한 양도**다.
|
||||
|
||||
|
||||
@ -120,11 +120,24 @@
|
||||
|
||||
## 5. 승인·수정
|
||||
|
||||
- **승인**: GET 이 로그인 없이 즉시 승인한다 → `status = APPROVED` → BUILD 잡 큐. 만료·
|
||||
재사용은 "처리할 수 없는 링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
|
||||
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문 갱신 +
|
||||
그대로 APPROVED 전환 + BUILD 잡. 실패하면 사유를 보여주고 다시 고치게 한다(사이트에는
|
||||
안 올라간다)
|
||||
- **게재**: 이메일의 승인 링크(로그인 없음, 누르면 즉시 승인) 또는 빌더 앱에 로그인해
|
||||
"바로 발행" 버튼을 눌러도 승인된다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행
|
||||
가능하고 바로발행버튼으로도 발행 가능하도록") — 두 경로 다 열려 있다(`post_service.
|
||||
PostService._approve_and_publish`). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다.
|
||||
- **승인**: 이메일 GET 은 로그인 없이 즉시 승인, "바로 발행" 은 로그인 세션이 신원 →
|
||||
둘 다 `status = APPROVED` → BUILD 잡 큐. 이메일 링크의 만료·재사용은 "처리할 수 없는
|
||||
링크입니다" 안내로 끝낸다(오류 화면을 주지 않는다)
|
||||
- **쓰레드 연동**: 승인되는 순간(경로 무관) 그 업장이 쓰레드에 연결돼 있으면 같은 문구에
|
||||
발행 링크를 붙여 쓰레드에도 즉시 게시한다(2026-09-21) — 별도 승인 없음(`docs/DECISIONS.md`
|
||||
7-1-2 개정, `docs/SOCIAL.md`). 연동 안 돼 있거나 `SOCIAL_POSTING_ENABLED=0`이거나 사이트
|
||||
domain이 미확정이면 조용히 건너뛴다. 실패해도 미니블로그 승인 자체는 막지 않는다
|
||||
(`post_service.PostService._try_social_share`)
|
||||
- **수정**: 빌더 앱 편집 모달에서 저장 → `is_publishable_body` 재검사 → 통과 시 본문만
|
||||
갱신한다. **승인 전환은 하지 않는다** — 실패하면 사유를 보여주고 다시 고치게 한다,
|
||||
통과해도 두 승인 경로 중 하나를 눌러야 사이트에 반영된다
|
||||
- **알림 이메일**: 승인 메일 수신자는 `places.notify_email`(비면 `users.email`) —
|
||||
계정 로그인 이메일과 분리해서 업장별로 다른 담당자에게 보낼 수 있다(빌더 앱 미니블로그
|
||||
관리 화면에서 수정, `PATCH /v1/place/{place_id}`)
|
||||
- ★ BUILD 잡 payload 에는 반드시 `owner_user_id` 가 있어야 한다(`build_service.run_build`
|
||||
가 `payload["owner_user_id"]` 를 무조건 읽는다) — 토큰/day-pass 흐름은 일반 로그인
|
||||
세션과 달라 `post_service.PostService._enqueue_build` 가 `place_id` 로 직접 조회해
|
||||
@ -213,7 +226,8 @@
|
||||
날짜는 LLM 을 부르지 않고 건너뛰고, 구간 안 소재가 떨어지면 그 자리에서 멈춘다 — 응답에
|
||||
`requested`(구간 일수)·`created`(실제로 채운 일수)를 같이 줘서 "N일 중 M일만 채웠습니다"로
|
||||
보여준다. 발행 전 사업장은 애초에 생성 스윕 대상이 아니라(`_published_places`) 여기서도
|
||||
0건이다
|
||||
0건이다. domain 이 아직 확정되지 않은(임시 주소) 사이트도 마찬가지다(2026-09-21 —
|
||||
쓰레드 연동 요구사항과 맞췄다, `docs/SOCIAL.md` 7-1-1과 동일 기준)
|
||||
|
||||
## 8. 진행 (2026-09-17)
|
||||
|
||||
@ -230,6 +244,7 @@
|
||||
| 개별 생성(빈 날짜 하나) | `POST .../post/generate-one` · `blog_jobs.generate_one_for_date` | 완료 |
|
||||
| payload + 화면 | `site_payload.posts[]` · `site/src/sections/BlogSection.tsx` | 완료 |
|
||||
| 재발행 연결 | `build_service` → `mark_published`, `owner_user_id` 버그 수정 | 완료 |
|
||||
| 쓰레드 자동 게재 | `services/social_service.publish_reused_text` · `post_service._try_social_share` | 완료 |
|
||||
|
||||
남은 것: 운영 ACS 에 발신 도메인 등록(지금은 negodata 리소스를 빌려 쓴다),
|
||||
그리고 6절의 발행일 갱신 합의.
|
||||
|
||||
@ -14,6 +14,13 @@ Meta 개발자 문서 일부는 조사 시 429를 반환했다. 실제 앱 권
|
||||
문자열은 NFC로 정규화하고 초과하면 최대 3번 다시 요청한다. 잘라서 게시하지 않는다.
|
||||
같은 사업장·발행 버전은 성공 이후에도 원고 1건만 유지한다. 초안 생성 실패만 같은 행으로 재시도한다.
|
||||
|
||||
미니블로그 승인(이메일 링크 또는 빌더 앱 "바로 발행")도 계정이 연결돼 있으면 같은 문구를
|
||||
그대로 쓰레드에 낸다(`decided_via='mini_blog'`) — 이 경로는 승인 요청·알림톡을 거치지 않고
|
||||
바로 `APPROVED`로 들어간다. 미니블로그 승인 자체가 발화 동의로 취급되기 때문이다(2026-09-21,
|
||||
DECISIONS 7-1-2 개정 — 문구를 그대로 재사용하는 경우에 한정). 쓰레드 전용으로 새로 짓거나
|
||||
내용을 바꾸는 경로(위 "발행 모달 → 소개글 쓰기")는 여전히 계정 연결 → 승인 요청 → 명시적
|
||||
승인을 그대로 거친다.
|
||||
|
||||
- 계약 없이: 초안 작성, 복사, 화면에서 내용 확인/거절, 만료 후 재요청.
|
||||
- 실제 연결 이후: Threads 계정 연결 → 게재 승인 요청 → 화면 또는 알림톡 확인 → 명시적 POST 승인 → 게시.
|
||||
- 계정 미연결 상태의 내용 확인은 게시를 예약하지 않는다. 연결한 뒤 계정을 보여주고 다시 승인받는다.
|
||||
|
||||
@ -13,60 +13,67 @@
|
||||
원본 `current` 블록(`temperature_2m`·`weather_code`·`wind_speed_10m`·`time`)을 어댑터가
|
||||
`{temperature, weather_code, wind_speed, observed_at, timezone, latitude, longitude}`로
|
||||
정규화한다(`open_meteo.py:fetch_current_weather`). `weather_code`는 WMO 표준 정수 코드 그대로
|
||||
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 구간이어야 한다**
|
||||
저장·전달되고, 하늘 상태 문구로 바꾸는 건 아래 두 곳뿐이다 — **반드시 같은 표여야 한다**
|
||||
(하이드레이션 전엔 백엔드 값, 후엔 브라우저 값을 쓰는데 표가 다르면 같은 날씨인데 문구가 바뀐다):
|
||||
|
||||
- 서버: `site_payload._weather_condition()` — 프리렌더 스냅샷에 쓰인다.
|
||||
- 브라우저: `use-live-weather.ts:condition()` — 10분마다 재조회할 때 쓰인다.
|
||||
- 서버: `site_payload._WEATHER_CONDITION_BY_CODE` (조회는 `_weather_condition()`) — 프리렌더 스냅샷에 쓰인다.
|
||||
- 브라우저: `use-live-weather.ts:WEATHER_CONDITION_BY_CODE` (조회는 `condition()`) — 10분마다 재조회할 때 쓰인다.
|
||||
|
||||
| 코드 | 날씨 상태(한국어) | 날씨 상태(영어) | 내부 분류 |
|
||||
|---|---|---|---|
|
||||
| 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 | 뇌우 |
|
||||
둘 다 **코드마다 고유 라벨**을 반환하는 딕셔너리 조회다(구간 검사가 아니다) — 코드 하나가
|
||||
분류 하나에 정확히 대응하므로 "51~57 은 다 이슬비" 식으로 뭉치지 않는다.
|
||||
|
||||
이 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종(맑음/흐림/비/눈)으로
|
||||
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "이슬비"·"강한비"·"어는비"는
|
||||
모두 `/비|우/` 패턴에 "비"가 들어 있어 `비`로 걸린다). `WeatherSection`의 `skyKey`는 노트가
|
||||
실제로 있는 세부 조건(구름많음/안개/소나기/뇌우/이슬비/강한비/어는비)이면 그 조건 그대로 쓰고,
|
||||
그 외(맑음/흐림/비/눈)엔 `mood`로 내려간다 — 어차피 세부 조건과 mood 값이 같기 때문이다.
|
||||
`weatherMood()`(`derive.ts`)는 위 28종을 화면 배경 그림용으로 다시 4종(맑음/흐림/비/눈)으로
|
||||
뭉친다 — 정규식 기반이라 새 분류를 추가해도 대개 자동으로 걸린다(예: "가벼운 착빙성 이슬비"는
|
||||
`/비|우|소나기/` 패턴에 "비"가 들어 있어 `비`로 걸리고, "약한 소나기눈"은 `/눈|설/` 이 먼저 걸려
|
||||
`눈`이 된다 — 검사 순서가 그래서 중요하다). `WeatherSection`의 `skyKey`는 노트에 그 조건 키가
|
||||
실제로 있으면(`notes?.noteSets?.[condition]`) 그 조건 그대로 쓰고, 없으면(옛 payload 등)
|
||||
`mood`로 내려간다 — 화이트리스트를 따로 유지하지 않는 일반화된 조회다.
|
||||
|
||||
`weather_notes.json → weather_notes.py → site_payload._weather → noteSets/tempNoteSets`
|
||||
|
||||
하늘 11종(맑음·구름많음·안개·비·이슬비·강한비·어는비·눈·소나기·뇌우·흐림)·기온 5구간에 각
|
||||
5문구를 싣는다. 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고, 브라우저에서는 무작위 시작
|
||||
후 20초마다 한 바퀴 안에서 중복 없이 순환한다. 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
|
||||
구름많음·안개·소나기·뇌우·이슬비·강한비·어는비는 그림(mood)상 흐림 또는 비와 같이 묶이지만
|
||||
문구는 조건별로 따로 쓴다. **비 중에서도 어는비(착빙성)만 안전 안내가 다르다** — 도로·보행
|
||||
결빙 위험을 짚는 문구가 필요해서 이슬비·강한비와 갈랐다(2026-09-17).
|
||||
하늘 28종(위 표의 "분류" 열 전체)·기온 5구간에 각 5문구를 싣는다(28×5+5×5=165줄, 전부
|
||||
고유해야 순환이 막히지 않는다). 첫 렌더는 첫 문장으로 고정해 하이드레이션을 맞추고,
|
||||
브라우저에서는 무작위 시작 후 20초마다 하늘·기온 두 줄을 한 타이머로 같이 골라 한 바퀴 안에서
|
||||
중복 없이 순환한다(`useWeatherNotes`). 기온 구간은 기존 `weatherBand`의 30·25·20·10도다.
|
||||
|
||||
**세분화 원칙**: 강도(약/보통/강)만 다른 코드도 문구를 따로 쓴다 — 약한 비는 "우산 하나면
|
||||
충분", 강한 비는 "이동을 미루라"처럼 안내 자체가 달라지기 때문이다. 착빙성(어는 비/이슬비)은
|
||||
안개·비·이슬비와 별도로 갈랐다 — 노면 결빙이라는, 세기와는 다른 축의 위험이라 "도로가
|
||||
얼어붙을 수 있으니" 식의 안전 안내가 필요하다(2026-09-18).
|
||||
|
||||
목업 README 2.3의 순환 계약을 제품으로 옮겼다. 군산 전용 시설·장소를 다른 사업장에 복사하지
|
||||
않도록 기본 문구는 장소·시설·영업시간을 주장하지 않는 공통 안내로 구성한다. **LLM 생성이 아니다.**
|
||||
|
||||
@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places (
|
||||
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
|
||||
verified_by uuid NULL,
|
||||
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
|
||||
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||
|
||||
7
postgres-init/migrations/0021_places_notify_email.sql
Normal file
7
postgres-init/migrations/0021_places_notify_email.sql
Normal 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;
|
||||
@ -130,6 +130,9 @@ class places(MainTableMixin, MAIN_BASE):
|
||||
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
||||
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
||||
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
|
||||
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
|
||||
notify_email = Column(String(255), nullable=True)
|
||||
|
||||
|
||||
|
||||
|
||||
@ -62,6 +62,21 @@ class PostCRUD:
|
||||
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
|
||||
return ErrorType.SUCCESS, rows[:limit]
|
||||
|
||||
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
||||
"""이 업장의 오늘 몫 글 하나 — 사장님이 '승인 알림보내기'를 눌렀을 때 쓴다. 없으면 None.
|
||||
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
|
||||
result = await cdb.execute(
|
||||
select(place_posts)
|
||||
.where(
|
||||
place_posts.place_id == place_id, place_posts.status == status,
|
||||
place_posts.deleted == False, # noqa: E712
|
||||
place_posts.scheduled_date <= today,
|
||||
)
|
||||
.order_by(place_posts.scheduled_date)
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
|
||||
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
|
||||
result = await cdb.execute(
|
||||
@ -173,8 +188,21 @@ class PostCRUD:
|
||||
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
|
||||
await cdb.execute(
|
||||
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(),
|
||||
published_version_id=version_id, updated_at=GTime.UTC())
|
||||
)
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false
|
||||
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
|
||||
await cdb.execute(
|
||||
update(place_posts)
|
||||
.where(place_posts.post_id == post_id)
|
||||
.values(deleted=True, updated_at=GTime.UTC())
|
||||
)
|
||||
return ErrorType.SUCCESS
|
||||
|
||||
@ -75,6 +75,8 @@ class Req_UpdatePlace(PlaceProtocol):
|
||||
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
|
||||
name: Optional[str] = None
|
||||
status: Optional[PlaceStatus] = None
|
||||
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
|
||||
notify_email: Optional[str] = None
|
||||
|
||||
|
||||
class Req_CreateUnit(PlaceProtocol):
|
||||
@ -107,6 +109,7 @@ class PlaceData(WebPacketProtocol):
|
||||
region_code: Optional[str] = None
|
||||
verified_at: Optional[datetime] = None
|
||||
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
|
||||
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
|
||||
created_at: Optional[datetime] = None
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,10 @@
|
||||
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
|
||||
글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
|
||||
★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
|
||||
★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve
|
||||
(이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션,
|
||||
"바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 —
|
||||
승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다.
|
||||
"""
|
||||
from datetime import date
|
||||
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)))
|
||||
|
||||
|
||||
@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(
|
||||
place_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))
|
||||
|
||||
|
||||
@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(
|
||||
place_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)))
|
||||
|
||||
|
||||
@owner_router.delete(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다")
|
||||
async def delete_my_post(
|
||||
place_id: UUID,
|
||||
post_id: UUID,
|
||||
service: PostService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
return RemoveNoneResponse(await service.delete_by_owner(user_info, str(place_id), str(post_id)))
|
||||
|
||||
|
||||
@owner_router.post(
|
||||
path="/send-now", response_model=Res_WebPacketProtocol,
|
||||
summary="승인 알림보내기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다",
|
||||
)
|
||||
async def send_my_posts_now(
|
||||
place_id: UUID,
|
||||
service: PostService = Depends(),
|
||||
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||
):
|
||||
return RemoveNoneResponse(await service.send_now(user_info, str(place_id)))
|
||||
|
||||
|
||||
@owner_router.post(
|
||||
path="/generate", response_model=Res_GenerateNow,
|
||||
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",
|
||||
|
||||
@ -31,7 +31,7 @@ async def connect(response: Response, user: UserInfo = Depends(IsValidAccessToke
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
async def oauth_callback(
|
||||
request: Request,
|
||||
state: str = Query("", max_length=2048),
|
||||
code: str = Query("", max_length=4096),
|
||||
|
||||
@ -13,6 +13,7 @@ from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import social_config
|
||||
from common.database.db_session_manager import DB_SESSION_MNG
|
||||
from common.database.model.models import place_posts, places, sites, users
|
||||
from common.enums import DBWRType, PostStatus, SiteStatus
|
||||
@ -49,6 +50,7 @@ async def _published_places() -> list:
|
||||
places.deleted == False, # noqa: E712
|
||||
sites.deleted == False, # noqa: E712
|
||||
sites.status == SiteStatus.PUBLISHED.value,
|
||||
sites.domain.isnot(None),
|
||||
)
|
||||
)
|
||||
|
||||
@ -92,7 +94,7 @@ async def _generate_for_place(place) -> int:
|
||||
continue
|
||||
generated = await blog_service.generate_one(
|
||||
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:
|
||||
continue
|
||||
@ -171,7 +173,7 @@ async def generate_range(place_id: str, start_date: date, end_date: date) -> dic
|
||||
continue
|
||||
generated = await blog_service.generate_one(
|
||||
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:
|
||||
continue
|
||||
@ -219,7 +221,7 @@ async def generate_one_for_date(place_id: str, target_date: date) -> dict | None
|
||||
continue
|
||||
generated = await blog_service.generate_one(
|
||||
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:
|
||||
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:
|
||||
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
|
||||
if not mail_service.is_configured():
|
||||
@ -273,22 +310,45 @@ async def send_reviewed() -> int:
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
targets = {str(place.place_id): (place.name, user) for place, user in await _published_places()}
|
||||
origin = site_payload.publish_origin()
|
||||
places_by_id = {str(place.place_id): (place, user) for place, user in await _published_places()}
|
||||
sent = 0
|
||||
for post in rows:
|
||||
target = targets.get(str(post.place_id))
|
||||
if not target or not mail_service.is_valid_address(target[1].email or ""):
|
||||
target = places_by_id.get(str(post.place_id))
|
||||
if not target:
|
||||
continue
|
||||
place_name, user = target
|
||||
token, token_hash, expires = blog_service.issue_token()
|
||||
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:
|
||||
place, user = target
|
||||
if not mail_service.is_valid_address(_notify_address(place, user) or ""):
|
||||
continue
|
||||
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)],
|
||||
)
|
||||
if await _send_one(place, user, post):
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
|
||||
async def send_now_for_place(place_id: str) -> dict:
|
||||
"""사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의
|
||||
오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록").
|
||||
'하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다."""
|
||||
if not mail_service.is_configured():
|
||||
return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"}
|
||||
|
||||
place = user = None
|
||||
for p, u in await _published_places():
|
||||
if str(p.place_id) == str(place_id):
|
||||
place, user = p, u
|
||||
break
|
||||
if place is None:
|
||||
return {"sent": False, "reason": "NOTHING_DUE"}
|
||||
|
||||
post = await DB_SESSION_MNG.execute_lambda(
|
||||
place_posts.DBType(), DBWRType.DB_READ.value,
|
||||
lambda s: _crud.next_due_for_mail(s, place_id, PostStatus.REVIEWED.value, _today_kst()),
|
||||
)
|
||||
if post is None:
|
||||
return {"sent": False, "reason": "NOTHING_DUE"}
|
||||
|
||||
if not mail_service.is_valid_address(_notify_address(place, user) or ""):
|
||||
return {"sent": False, "reason": "NO_VALID_EMAIL"}
|
||||
|
||||
if not await _send_one(place, user, post):
|
||||
return {"sent": False, "reason": "SEND_FAILED"}
|
||||
return {"sent": True, "reason": None}
|
||||
|
||||
@ -10,7 +10,7 @@ import re
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from common.enums import PostStatus, PostTopicKind
|
||||
from common.enums import PlaceCategory, PostStatus, PostTopicKind
|
||||
from common.logger import LOG
|
||||
|
||||
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
|
||||
@ -64,6 +64,7 @@ def issue_token() -> tuple[str, str, object]:
|
||||
return token, hash_token(token), expires
|
||||
|
||||
|
||||
# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다.
|
||||
TOPIC_RULES: dict[int, str] = {
|
||||
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 = (
|
||||
"규칙\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 "없음"
|
||||
noun = _business_noun(place_category)
|
||||
rules = _topic_rules(place_category)
|
||||
return (
|
||||
f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n"
|
||||
f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n"
|
||||
f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n"
|
||||
f"갈래: {rules.get(topic_kind, '')}\n"
|
||||
f"소재: {material}\n"
|
||||
f"이미 쓴 주제: {used}\n\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,
|
||||
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 — 생성 실패가 잡을 죽이지 않는다.
|
||||
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시)."""
|
||||
from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured
|
||||
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시).
|
||||
|
||||
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
|
||||
|
||||
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
|
||||
material=material, used_topics=used_topics)
|
||||
body = {
|
||||
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
||||
"generationConfig": {"temperature": 0.9},
|
||||
}
|
||||
material=material, used_topics=used_topics, place_category=place_category)
|
||||
owns = client is None
|
||||
if owns:
|
||||
import httpx
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
||||
try:
|
||||
payload = await call(client, DEFAULT_MODEL, body)
|
||||
text = extract_text(payload).strip()
|
||||
return (text, DEFAULT_MODEL) if text else None
|
||||
except GeminiError as error:
|
||||
result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9)
|
||||
text = result.text.strip()
|
||||
return (text, llm.DEFAULT_MODEL) if text else None
|
||||
except LlmError as error:
|
||||
LOG.w(f"[blog] 생성 실패: {error}")
|
||||
return None
|
||||
finally:
|
||||
|
||||
@ -25,10 +25,6 @@ from services.llm.errors import LlmError
|
||||
from services.llm.errors import LlmInvalidOutput as GeminiInvalidOutput
|
||||
from services.llm.errors import LlmNotConfigured as GeminiNotConfigured
|
||||
from services.prompts.copy import RESPONSE_SCHEMA, build_prompt
|
||||
# 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:
|
||||
@ -325,15 +321,22 @@ async def generate_song(
|
||||
|
||||
|
||||
async def generate_social_post(place_name, facts, link_url, provider=2, *, client=None):
|
||||
"""실제 게시 문자열을 검증한다. 초과·근거 실패 시 다시 받고 문장을 자르지 않는다."""
|
||||
"""실제 게시 문자열을 검증한다. 초과·근거 실패 시 다시 받고 문장을 자르지 않는다.
|
||||
|
||||
★ 2026-09-21: 다른 생성 함수(generate_copy 등)와 같은 이유로 공급자 선택을 탄다
|
||||
(`LLM_PROVIDER`, 기본 openai) — Gemini 고정을 없앴다. 인자 이름 `provider` 는
|
||||
SNS 플랫폼(쓰레드=2)을 가리키는 기존 값이라, LLM 공급자 모듈은 `llm_provider` 로
|
||||
따로 들여와 이름이 겹치지 않게 한다."""
|
||||
from services.prompts import social
|
||||
from services.external.social import adapter, weighted_length, URL
|
||||
from services.llm import provider as llm_provider
|
||||
import unicodedata
|
||||
|
||||
if not facts:
|
||||
raise GeminiInvalidOutput('NO_GROUNDED_FACTS')
|
||||
if not is_configured():
|
||||
raise GeminiNotConfigured('GEMINI_NOT_CONFIGURED')
|
||||
llm = llm_provider.active()
|
||||
if not llm.is_configured():
|
||||
raise GeminiNotConfigured(f"{llm.__name__.rsplit('.', 1)[-1].upper()}_API_KEY 가 설정되지 않았다")
|
||||
limit = adapter(provider).weighted_limit()
|
||||
allowed = {f.key for f in facts}
|
||||
feedback = ''
|
||||
@ -341,14 +344,14 @@ async def generate_social_post(place_name, facts, link_url, provider=2, *, clien
|
||||
client = client or httpx.AsyncClient(timeout=45)
|
||||
try:
|
||||
for _ in range(3):
|
||||
request = {'contents': [{'role': 'user', 'parts': [{'text': social.build_prompt(
|
||||
place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)}]}],
|
||||
'generationConfig': {'responseMimeType': 'application/json',
|
||||
'responseSchema': social.RESPONSE_SCHEMA, 'temperature': 0.2,
|
||||
'maxOutputTokens': 1024}}
|
||||
result = await call(client, DEFAULT_TEXT_MODEL, request, max_retries=0)
|
||||
prompt = social.build_prompt(
|
||||
place_name, facts, limit - weighted_length('\n\n' + link_url, provider), feedback)
|
||||
result = await llm.generate(
|
||||
client, llm.DEFAULT_MODEL, prompt=prompt,
|
||||
response_schema=social.RESPONSE_SCHEMA, temperature=0.2, max_retries=0,
|
||||
)
|
||||
try:
|
||||
parsed = 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())
|
||||
keys = parsed['fact_keys']
|
||||
text = body + '\n\n' + link_url
|
||||
|
||||
@ -13,6 +13,7 @@ from common.models.gmodel import PageParams, UserInfo
|
||||
from common.utils.gtime import GTime
|
||||
from crud.job_crud import JobQueue
|
||||
from crud.place_crud import IPlaceCRUD, PlaceCRUD
|
||||
from services import mail_service
|
||||
from router.v1.place.protocol import (
|
||||
Req_VerifyPlaceByUrl,
|
||||
LinkData,
|
||||
@ -150,6 +151,12 @@ class PlaceService:
|
||||
data["status"] = req.status.value
|
||||
if "name" in data:
|
||||
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:
|
||||
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||
|
||||
@ -23,7 +23,7 @@ from common.utils.gtime import GTime
|
||||
from router.v1.site.protocol import (
|
||||
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
|
||||
|
||||
EXPIRED = "처리할 수 없는 링크입니다."
|
||||
@ -75,10 +75,7 @@ class PostService:
|
||||
)
|
||||
return {"success": True, "message": SKIPPED}
|
||||
|
||||
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._approve_and_publish(post_id, place_id)
|
||||
return {"success": True, "message": APPROVED}
|
||||
|
||||
async def _load_place(self, user_info: UserInfo, place_id: str):
|
||||
@ -191,7 +188,10 @@ class PostService:
|
||||
async def edit_by_owner(
|
||||
self, user_info: UserInfo, place_id: str, post_id: str, body: str
|
||||
) -> Res_WebPacketProtocol:
|
||||
"""로그인 세션으로 직접 고치기 — 메일이 아직 안 나간 REVIEWED 글도 여기서 바로 승인된다."""
|
||||
"""로그인 세션으로 직접 고치기 — 저장만 한다. ★ 승인은 여기서 하지 않는다(2026-09-21,
|
||||
사장님 지시: "승인되야 올라가도록 해야 한다") — 저장 후에는 이메일 승인 링크
|
||||
(router/v1/site/post.py approve_page → decide) 또는 바로 아래 approve_by_owner
|
||||
("바로 발행" 버튼)를 명시적으로 눌러야 게재된다."""
|
||||
res = Res_WebPacketProtocol()
|
||||
err_type, _place = await self._load_place(user_info, place_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
@ -208,15 +208,13 @@ class PostService:
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_posts.DBType()], [lambda s: self.crud.update_body(s, pid, body)],
|
||||
)
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
|
||||
)
|
||||
await self._enqueue_build(pid, uuid.UUID(place_id))
|
||||
res.msg = APPROVED
|
||||
res.msg = "저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다."
|
||||
return res
|
||||
|
||||
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()
|
||||
err_type, _place = await self._load_place(user_info, place_id)
|
||||
if err_type != ErrorType.SUCCESS:
|
||||
@ -224,13 +222,37 @@ class PostService:
|
||||
return res
|
||||
|
||||
pid = uuid.UUID(post_id)
|
||||
await DB_SESSION_MNG.execute_lambda_run(
|
||||
[place_posts.DBType()], [lambda s: self.crud.approve(s, pid)],
|
||||
)
|
||||
await self._enqueue_build(pid, uuid.UUID(place_id))
|
||||
await self._approve_and_publish(pid, uuid.UUID(place_id))
|
||||
res.msg = APPROVED
|
||||
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:
|
||||
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다
|
||||
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까")."""
|
||||
@ -274,6 +296,34 @@ class PostService:
|
||||
res.msg = "만들었습니다."
|
||||
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):
|
||||
def query(session):
|
||||
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
|
||||
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:
|
||||
"""게재 = 그 사이트 하나를 다시 굽는 것. 전체 재굽기가 아니다(docs/PUBLISH_VERSION.md).
|
||||
|
||||
|
||||
@ -501,33 +501,48 @@ def _sections(saved_sections, default_spec) -> list:
|
||||
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:
|
||||
try:
|
||||
value = int(code)
|
||||
except (TypeError, ValueError):
|
||||
return "흐림"
|
||||
if value == 0:
|
||||
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 "흐림"
|
||||
return _WEATHER_CONDITION_BY_CODE.get(value, "흐림")
|
||||
|
||||
|
||||
def _weather(row: dict):
|
||||
@ -779,6 +794,12 @@ def publish_origin() -> str:
|
||||
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:
|
||||
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ from common.database.model.models import (
|
||||
place_facts,
|
||||
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 import social_crud as db
|
||||
from services import site_payload, social_account_service as accounts
|
||||
@ -580,3 +580,49 @@ async def run_post(job):
|
||||
except Exception:
|
||||
await set_failure(post_id, "POST_RESULT_UNKNOWN", status="UNKNOWN")
|
||||
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
|
||||
|
||||
@ -7,12 +7,19 @@
|
||||
"다음 일정을 서두르기보다 잠시 여유를 가져 보세요.",
|
||||
"좋아하는 책 몇 장을 넘기며 쉬어 가는 건 어떨까요?"
|
||||
],
|
||||
"구름많음": [
|
||||
"구름이 많은 날에는 실내에서 다음 일정을 천천히 골라 보세요.",
|
||||
"여행 사진을 정리하며 잠깐 쉬어 가세요.",
|
||||
"함께 온 사람과 오늘 가장 좋았던 순간을 이야기해 보세요.",
|
||||
"바깥 일정 사이에 여유로운 휴식 시간을 넣어 보세요.",
|
||||
"오늘의 기분을 짧은 글로 남겨도 좋겠습니다."
|
||||
"대체로 맑음": [
|
||||
"구름이 살짝 있지만 대체로 맑은 하루입니다. 나들이하기 좋은 날씨입니다.",
|
||||
"옅은 구름 사이로 볕이 듭니다. 가볍게 산책을 나서 보세요.",
|
||||
"하늘이 대체로 맑아 야외 일정을 편하게 잡을 수 있습니다.",
|
||||
"구름이 잠깐씩 지나가지만 큰 영향은 없습니다.",
|
||||
"맑은 편이니 오늘 담고 싶은 풍경을 찾아 나서 보세요."
|
||||
],
|
||||
"구름 조금": [
|
||||
"구름이 조금 낀 날입니다. 볕이 강하지 않아 걷기에 오히려 좋습니다.",
|
||||
"하늘에 구름이 떠 있지만 나들이엔 무리가 없습니다.",
|
||||
"구름 사이로 비치는 햇살을 즐기며 산책해 보세요.",
|
||||
"그늘이 반가운 날, 야외 일정을 여유롭게 잡아 보세요.",
|
||||
"가벼운 겉옷 하나면 하루 종일 다니기 좋겠습니다."
|
||||
],
|
||||
"흐림": [
|
||||
"흐린 날에는 실내에서 느긋하게 쉬어 가도 좋겠습니다.",
|
||||
@ -21,20 +28,6 @@
|
||||
"천천히 쉬면서 돌아갈 때 가져갈 추억을 골라 보세요.",
|
||||
"분주한 일정 사이에 조용한 휴식을 끼워 넣어 보세요."
|
||||
],
|
||||
"비": [
|
||||
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
|
||||
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
|
||||
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
|
||||
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
|
||||
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
|
||||
],
|
||||
"눈": [
|
||||
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
|
||||
"다음 이동을 서두르지 말고 쉬어 가세요.",
|
||||
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
|
||||
"실내에서 다음 일정과 이동편을 확인해 보세요.",
|
||||
"눈길을 나서기 전에는 여유 있게 준비하세요."
|
||||
],
|
||||
"안개": [
|
||||
"안개가 낮게 깔린 아침입니다. 서두르지 말고 천천히 나서 보세요.",
|
||||
"시야가 흐린 날에는 가까운 곳부터 둘러보는 편이 좋습니다.",
|
||||
@ -42,26 +35,166 @@
|
||||
"운전하신다면 평소보다 여유 있게 출발하시길 권합니다.",
|
||||
"뿌연 공기 너머로 보이는 풍경도 오늘만의 그림입니다."
|
||||
],
|
||||
"이슬비": [
|
||||
"착빙성 안개": [
|
||||
"서리 안개가 낀 아침입니다. 노면이 미끄러울 수 있으니 조심하세요.",
|
||||
"안개와 함께 기온이 낮아 도로가 얼어 있을 수 있습니다.",
|
||||
"이른 시간 이동은 특히 주의하고 서두르지 마세요.",
|
||||
"안개가 걷히고 기온이 오른 뒤 나서는 편이 안전합니다.",
|
||||
"차량·보행 모두 시야와 노면을 함께 살펴보세요."
|
||||
],
|
||||
"가벼운 이슬비": [
|
||||
"가는 비가 내립니다. 우산 하나면 산책에 무리가 없습니다.",
|
||||
"빗방울이 약해 걷기에는 오히려 조용하고 좋은 날입니다.",
|
||||
"얇은 겉옷 하나 챙기시면 이슬비 정도는 걱정 없습니다.",
|
||||
"젖은 길이 미끄러울 수 있으니 편한 신발을 권합니다.",
|
||||
"빗소리가 작게 깔리는 날, 실내에서 쉬어 가기도 좋습니다."
|
||||
],
|
||||
"소나기": [
|
||||
"보통 이슬비": [
|
||||
"보통 세기의 이슬비가 내립니다. 우산 하나로 충분합니다.",
|
||||
"옷이 젖을 수 있으니 우산이나 방수 겉옷을 챙기세요.",
|
||||
"이슬비가 이어지는 동안은 실내 일정을 먼저 잡아 보세요.",
|
||||
"그치는 틈을 봐서 짧게 이동하는 정도는 괜찮습니다.",
|
||||
"빗소리를 들으며 잠시 여유를 가져도 좋겠습니다."
|
||||
],
|
||||
"강한 이슬비": [
|
||||
"이슬비치고는 제법 굵게 내립니다. 우산을 단단히 챙기세요.",
|
||||
"옷과 신발이 젖기 쉬우니 방수 채비를 해 두세요.",
|
||||
"바깥 일정은 짧게 잡고 실내에서 기다리는 편이 낫겠습니다.",
|
||||
"빗줄기가 가늘어도 오래 맞으면 금방 젖으니 주의하세요.",
|
||||
"그칠 때까지는 무리해서 이동하지 않는 게 좋겠습니다."
|
||||
],
|
||||
"가벼운 착빙성 이슬비": [
|
||||
"가벼운 착빙성 이슬비입니다. 노면이 얼 수 있어 걸음을 조심하세요.",
|
||||
"얼어붙은 길이 있을 수 있으니 평소보다 천천히 움직이세요.",
|
||||
"차량 이동도 서행하고 급제동은 피하세요.",
|
||||
"실내에서 잠시 기다렸다 나서는 편을 권합니다.",
|
||||
"그늘지거나 다리 위 같은 곳은 특히 미끄러우니 유의하세요."
|
||||
],
|
||||
"강한 착빙성 이슬비": [
|
||||
"강한 착빙성 이슬비가 내립니다. 바깥 이동은 최대한 미루세요.",
|
||||
"노면 결빙 위험이 크니 실내에 머무르는 편이 안전합니다.",
|
||||
"부득이 이동한다면 미끄럼 방지 신발을 챙기세요.",
|
||||
"차량도 도로 상태를 확인한 뒤 서행으로 움직이세요.",
|
||||
"기상특보를 확인하고 해빙될 때까지 기다려 보세요."
|
||||
],
|
||||
"약한 비": [
|
||||
"약한 비가 내립니다. 우산 하나면 짧은 이동은 무리 없습니다.",
|
||||
"가볍게 챙기고 나서도 크게 불편하지 않겠습니다.",
|
||||
"비가 잦아드는 시간을 골라 움직여 보세요.",
|
||||
"젖어도 금방 마를 정도니 편하게 다녀오세요.",
|
||||
"우산을 챙기고 평소처럼 일정을 이어가도 좋겠습니다."
|
||||
],
|
||||
"보통 비": [
|
||||
"비가 오는 동안에는 실내에서 쉬며 일정을 조정해 보세요.",
|
||||
"서두르지 말고 비가 잦아드는지 확인한 뒤 움직이세요.",
|
||||
"빗소리를 들으며 읽고 싶었던 글을 펼쳐 보세요.",
|
||||
"젖은 물건을 정리하고 잠깐 쉬어 가세요.",
|
||||
"바깥 일정 대신 함께 찍은 사진을 나눠 봐도 좋겠습니다."
|
||||
],
|
||||
"강한 비": [
|
||||
"빗줄기가 굵습니다. 이동을 미루고 실내에서 기다리세요.",
|
||||
"강한 비가 그칠 때까지 바깥 일정은 잠시 접어 두세요.",
|
||||
"물웅덩이·배수 상황을 살피고 이동 경로를 다시 확인하세요.",
|
||||
"우산만으로는 부족할 수 있으니 방수 옷차림을 챙기세요.",
|
||||
"굵은 빗속 이동은 삼가고 안전한 곳에서 쉬어 가세요."
|
||||
],
|
||||
"약한 착빙성 비": [
|
||||
"약한 착빙성 비입니다. 노면이 얼기 시작할 수 있어 조심하세요.",
|
||||
"걸음을 천천히 옮기고 급한 이동은 피하세요.",
|
||||
"차량은 서행하고 앞뒤 거리를 넉넉히 두세요.",
|
||||
"가능하면 이동을 미루고 실내에서 기다려 보세요.",
|
||||
"다리·그늘진 도로는 특히 미끄러우니 유의하세요."
|
||||
],
|
||||
"강한 착빙성 비": [
|
||||
"강한 착빙성 비가 내립니다. 바깥 이동을 삼가고 실내에 머무르세요.",
|
||||
"도로가 얼어붙을 수 있으니 되도록 나서지 마세요.",
|
||||
"부득이 이동해야 한다면 걸음을 평소보다 훨씬 천천히 옮기세요.",
|
||||
"차량 이동도 미끄러질 수 있으니 서두르지 마세요.",
|
||||
"기상특보를 확인하고 해빙될 때까지 일정을 미뤄 두세요."
|
||||
],
|
||||
"약한 눈": [
|
||||
"약한 눈이 흩날립니다. 짧은 나들이 정도는 다녀올 만합니다.",
|
||||
"쌓이기 전이라면 평소처럼 움직여도 괜찮겠습니다.",
|
||||
"따뜻하게 챙기고 잠깐의 눈길 산책을 즐겨 보세요.",
|
||||
"노면이 젖어 있을 수 있으니 걸음만 조심하세요.",
|
||||
"눈이 그치는 틈을 봐서 이동해도 좋겠습니다."
|
||||
],
|
||||
"보통 눈": [
|
||||
"눈 오는 날에는 실내에서 쉬며 바깥 상황을 살펴보세요.",
|
||||
"다음 이동을 서두르지 말고 쉬어 가세요.",
|
||||
"따뜻하게 머물며 오늘의 여행을 한 줄 남겨 보세요.",
|
||||
"실내에서 다음 일정과 이동편을 확인해 보세요.",
|
||||
"눈길을 나서기 전에는 여유 있게 준비하세요."
|
||||
],
|
||||
"강한 눈": [
|
||||
"눈이 강하게 쌓입니다. 바깥 이동은 미루는 편이 좋습니다.",
|
||||
"제설이 될 때까지 실내에서 기다려 보세요.",
|
||||
"이동해야 한다면 미끄럼 방지 신발과 여유 시간을 챙기세요.",
|
||||
"차량 이동은 도로 상황을 확인한 뒤 움직이세요.",
|
||||
"따뜻하게 머물며 눈이 그치기를 기다려 보세요."
|
||||
],
|
||||
"싸라기눈": [
|
||||
"싸라기눈이 흩날립니다. 노면이 미끄러울 수 있어요.",
|
||||
"짧은 이동이라도 걸음을 조심스럽게 옮기세요.",
|
||||
"우산보다는 모자나 방한용품이 도움이 되겠습니다.",
|
||||
"그치는 틈을 봐서 다음 일정을 잡아 보세요.",
|
||||
"쌓이지 않아도 걸을 때는 발밑을 살펴보세요."
|
||||
],
|
||||
"약한 소나기": [
|
||||
"짧게 스치는 소나기입니다. 잠깐 처마 밑에서 기다려도 됩니다.",
|
||||
"갑자기 내릴 수 있으니 우산을 챙겨 두면 든든합니다.",
|
||||
"소나기가 그치면 이어서 일정을 다니셔도 좋겠습니다.",
|
||||
"젖은 길만 조심하면 크게 무리 없는 날씨입니다.",
|
||||
"짧은 비를 피할 실내 공간을 미리 봐 두세요."
|
||||
],
|
||||
"보통 소나기": [
|
||||
"소나기가 지나가는 중입니다. 잠시 기다리면 금세 갭니다.",
|
||||
"갑자기 쏟아질 수 있으니 우산을 꼭 챙겨 나가세요.",
|
||||
"비가 그친 뒤 공기가 가장 맑습니다. 그때를 노려 보세요.",
|
||||
"지금은 실내에 머물다가 하늘이 열리면 나서는 편이 좋습니다.",
|
||||
"짧고 굵게 지나가는 비입니다. 일정을 통째로 바꾸실 필요는 없습니다."
|
||||
],
|
||||
"강한 소나기": [
|
||||
"갑자기 쏟아지는 강한 소나기입니다. 이동을 잠시 멈추세요.",
|
||||
"그칠 때까지 안전한 실내에서 기다리는 편이 좋습니다.",
|
||||
"젖은 도로가 미끄러울 수 있으니 이동 시 주의하세요.",
|
||||
"우산만으로는 부족할 수 있어 방수 옷차림을 챙기세요.",
|
||||
"소나기가 지나간 뒤 도로 상황을 확인하고 움직이세요."
|
||||
],
|
||||
"약한 소나기눈": [
|
||||
"짧게 흩날리는 눈입니다. 잠깐 피했다 이동해도 됩니다.",
|
||||
"노면이 젖거나 살짝 얼 수 있어 걸음을 조심하세요.",
|
||||
"따뜻하게 챙기고 짧은 외출 정도는 다녀올 만합니다.",
|
||||
"그치는 틈을 봐서 다음 장소로 이동해 보세요.",
|
||||
"쌓이기 전이라면 크게 무리는 없겠습니다."
|
||||
],
|
||||
"강한 소나기눈": [
|
||||
"갑자기 쏟아지는 눈입니다. 이동을 잠시 멈추고 기다리세요.",
|
||||
"노면이 급격히 미끄러워질 수 있어 주의가 필요합니다.",
|
||||
"실내에서 그치기를 기다렸다 움직이는 편이 안전합니다.",
|
||||
"차량 이동은 서행하고 앞차와 거리를 넉넉히 두세요.",
|
||||
"쌓인 눈이 있다면 이동 전 노면 상태부터 확인하세요."
|
||||
],
|
||||
"뇌우": [
|
||||
"천둥번개가 치고 있습니다. 실외 활동은 잠시 미뤄 주세요.",
|
||||
"비바람이 강합니다. 오늘은 실내에서 쉬어 가시길 권합니다.",
|
||||
"번개가 잦을 때는 높은 곳과 물가를 피하는 편이 안전합니다.",
|
||||
"날이 거칩니다. 이동 계획이 있다면 시간을 넉넉히 잡으세요.",
|
||||
"궂은 날씨가 지나가면 하늘이 크게 열립니다. 조금만 기다려 보세요."
|
||||
],
|
||||
"약한 우박 뇌우": [
|
||||
"천둥·번개와 함께 약한 우박이 내릴 수 있습니다. 실내에 머무르세요.",
|
||||
"우박이 그칠 때까지 차양 아래보다는 건물 안이 안전합니다.",
|
||||
"야외 물건이 있다면 잠시 안으로 들여 두세요.",
|
||||
"번개가 잦아든 뒤 이동 여부를 다시 확인하세요.",
|
||||
"차량도 우박 피해가 없는 곳에 세워 두는 편이 좋습니다."
|
||||
],
|
||||
"강한 우박 뇌우": [
|
||||
"굵은 우박을 동반한 뇌우입니다. 반드시 실내에 머무르세요.",
|
||||
"창가나 개방된 곳을 피하고 안전한 공간에서 기다리세요.",
|
||||
"차량은 우박을 피할 수 있는 곳에 세워 두세요.",
|
||||
"기상특보를 확인하고 뇌우가 완전히 지나간 뒤 나서세요.",
|
||||
"야외 활동은 오늘 일정에서 빼는 편이 안전하겠습니다."
|
||||
]
|
||||
},
|
||||
"temperature": {
|
||||
|
||||
@ -2,11 +2,14 @@
|
||||
|
||||
★ 이 파일이 지키는 것:
|
||||
- 로그인한 사장님은 자기 사업장의 글만 본다(남의 가게 글이 섞이면 안 된다)
|
||||
- 아직 메일이 안 나간 REVIEWED 글도 로그인 화면에서 바로 고치고 승인할 수 있다
|
||||
- 아직 메일이 안 나간 REVIEWED 글도 로그인 화면에서 바로 고칠 수 있다 — 단, 저장만
|
||||
한다. 승인은 이메일 승인 링크 또는 로그인 "바로 발행" 버튼, 두 경로 중 하나를
|
||||
명시적으로 눌러야 한다(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고
|
||||
바로발행버튼으로도 발행 가능하도록")
|
||||
- 여기서도 금칙 게이트는 그대로 탄다 — 로그인했다고 우회되지 않는다
|
||||
"""
|
||||
import uuid
|
||||
from datetime import date, timedelta
|
||||
from datetime import date, timedelta, timezone
|
||||
|
||||
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"] == []
|
||||
|
||||
|
||||
async def test_owner_can_edit_and_approve_before_mail_goes_out(client, db_engine, auth_headers):
|
||||
"""REVIEWED 글(아직 메일 안 나감)도 로그인 화면에서 바로 고쳐 승인할 수 있다."""
|
||||
async def test_owner_edit_saves_body_without_approving(client, db_engine, auth_headers):
|
||||
"""로그인 화면에서 바로 고칠 수는 있지만, 저장만 한다 — 승인은 이메일 링크로만 일어난다
|
||||
(2026-09-21, 사장님 지시: "승인되야 올라가도록 해야 한다")."""
|
||||
h = await auth_headers("blogowner4")
|
||||
place_id = await _place(client, h)
|
||||
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})
|
||||
|
||||
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):
|
||||
@ -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
|
||||
|
||||
|
||||
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):
|
||||
"""새벽 크론(04:10)을 기다리지 않고, 사장님이 고른 구간을 그 자리에서 채운다(2026-09-17,
|
||||
사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까") — 발행된 사이트일 때만."""
|
||||
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")
|
||||
|
||||
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="즉시생성펜션")
|
||||
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},
|
||||
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},
|
||||
)
|
||||
start = date.today()
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""미래 날짜로 배정된 글은 그날이 오기 전엔 메일이 안 나간다."""
|
||||
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="예약펜션")
|
||||
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},
|
||||
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": "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
|
||||
|
||||
|
||||
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):
|
||||
"""바로 발행 — 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다."""
|
||||
"""'바로 발행' — 로그인 세션만으로, 본문을 안 고쳐도 승인되고 재발행 잡이 걸린다
|
||||
(2026-09-21, 사장님 지시: "이메일 승인으로도 발행 가능하고 바로발행버튼으로도
|
||||
발행 가능하도록") — 이메일 승인 링크와 별개의 두 번째 경로다."""
|
||||
h = await auth_headers("blogowner6")
|
||||
place_id = await _place(client, h)
|
||||
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):
|
||||
owner = await auth_headers("blogowner7")
|
||||
other = await auth_headers("blogowner8")
|
||||
owner = await auth_headers("blogowner6e")
|
||||
other = await auth_headers("blogowner6f")
|
||||
place_id = await _place(client, owner)
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""발행 전 사업장은 생성 스윕 대상이 아니다(blog_jobs._published_places) — 0건이어야 한다."""
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
h = await auth_headers("bloggen16")
|
||||
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)에 담는다)."""
|
||||
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")
|
||||
|
||||
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="이력펜션")
|
||||
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},
|
||||
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},
|
||||
)
|
||||
start = date.today()
|
||||
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="메일링크펜션")
|
||||
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},
|
||||
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": "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
|
||||
|
||||
|
||||
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):
|
||||
"""사장님 지시: "개별적으로 새로 만들수있게 해줘" — 달력에서 빈 날짜 하나만 콕 집어 채운다."""
|
||||
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")
|
||||
|
||||
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="개별생성펜션")
|
||||
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},
|
||||
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},
|
||||
)
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
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="중복날짜펜션")
|
||||
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},
|
||||
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},
|
||||
)
|
||||
target = date.today() + timedelta(days=3)
|
||||
await _seed_post(db_engine, place_id, scheduled=target)
|
||||
|
||||
@ -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 forbidden(*args, **kwargs):
|
||||
from services.llm import provider as llm_provider
|
||||
|
||||
def forbidden():
|
||||
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"):
|
||||
await gemini_text.generate_social_post("숙소", [], "https://example.com/s/stay")
|
||||
|
||||
|
||||
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입니다."])
|
||||
|
||||
async def call(*args, **kwargs):
|
||||
return {
|
||||
"candidates": [
|
||||
{
|
||||
"content": {
|
||||
"parts": [
|
||||
{
|
||||
"text": json.dumps(
|
||||
{
|
||||
"body": next(bodies),
|
||||
"fact_keys": ["check_in_time"],
|
||||
}
|
||||
)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
class _FakeLlm:
|
||||
__name__ = "services.llm.fake"
|
||||
DEFAULT_MODEL = "fake-model"
|
||||
|
||||
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(
|
||||
"숙소",
|
||||
[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"),
|
||||
{"u": user_id})).first()
|
||||
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
|
||||
|
||||
14
solution/frontend/src/api/generated/model/approvalParams.ts
Normal file
14
solution/frontend/src/api/generated/model/approvalParams.ts
Normal 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;
|
||||
};
|
||||
10
solution/frontend/src/api/generated/model/decision.ts
Normal file
10
solution/frontend/src/api/generated/model/decision.ts
Normal 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;
|
||||
}
|
||||
@ -5,12 +5,14 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export * from "./approvalParams";
|
||||
export * from "./approvePageParams";
|
||||
export * from "./auditCheckData";
|
||||
export * from "./auditCheckDataRecommendation";
|
||||
export * from "./authProvider";
|
||||
export * from "./buildStatus";
|
||||
export * from "./checkSlugParams";
|
||||
export * from "./decision";
|
||||
export * from "./errorInfo";
|
||||
export * from "./errorInfoCode";
|
||||
export * from "./errorInfoDesc";
|
||||
@ -61,6 +63,7 @@ export * from "./linkData";
|
||||
export * from "./linkDataConfirmedAt";
|
||||
export * from "./linkDataDiscoveredAt";
|
||||
export * from "./linkDataTitle";
|
||||
export * from "./linkDecision";
|
||||
export * from "./listContentsParams";
|
||||
export * from "./listFactsParams";
|
||||
export * from "./listFaqsParams";
|
||||
@ -103,6 +106,7 @@ export * from "./mySiteDataSiteId";
|
||||
export * from "./mySiteDataStatus";
|
||||
export * from "./mySiteDataTemplateId";
|
||||
export * from "./mySiteDataThumbnailUrl";
|
||||
export * from "./oauthCallbackParams";
|
||||
export * from "./placeCandidate";
|
||||
export * from "./placeCandidateAddress";
|
||||
export * from "./placeCandidateCategoryName";
|
||||
@ -126,6 +130,7 @@ export * from "./placeDataExternalPlaceId";
|
||||
export * from "./placeDataExternalSource";
|
||||
export * from "./placeDataLatitude";
|
||||
export * from "./placeDataLongitude";
|
||||
export * from "./placeDataNotifyEmail";
|
||||
export * from "./placeDataOwnerUserId";
|
||||
export * from "./placeDataPhone";
|
||||
export * from "./placeDataRegionCode";
|
||||
@ -208,6 +213,7 @@ export * from "./reqUpdateMeName";
|
||||
export * from "./reqUpdateMePassword";
|
||||
export * from "./reqUpdatePlace";
|
||||
export * from "./reqUpdatePlaceName";
|
||||
export * from "./reqUpdatePlaceNotifyEmail";
|
||||
export * from "./reqUpdatePlaceStatus";
|
||||
export * from "./reqUpsertFact";
|
||||
export * from "./reqUpsertFactExpiresAt";
|
||||
@ -372,6 +378,7 @@ export * from "./siteVersionDataBuildError";
|
||||
export * from "./siteVersionDataBuiltAt";
|
||||
export * from "./siteVersionDataCreatedAt";
|
||||
export * from "./sourceType";
|
||||
export * from "./testPost";
|
||||
export * from "./unitData";
|
||||
export * from "./userRole";
|
||||
export * from "./validationError";
|
||||
|
||||
@ -23,4 +23,6 @@ export const JobType = {
|
||||
AI_CHECK: 6,
|
||||
SONG: 7,
|
||||
ROLLBACK: 8,
|
||||
SOCIAL_DRAFT: 9,
|
||||
SOCIAL_POST: 10,
|
||||
} as const;
|
||||
|
||||
15
solution/frontend/src/api/generated/model/linkDecision.ts
Normal file
15
solution/frontend/src/api/generated/model/linkDecision.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -17,6 +17,7 @@ import type { PlaceDataLongitude } from "./placeDataLongitude";
|
||||
import type { PlaceDataRegionCode } from "./placeDataRegionCode";
|
||||
import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt";
|
||||
import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt";
|
||||
import type { PlaceDataNotifyEmail } from "./placeDataNotifyEmail";
|
||||
import type { PlaceDataCreatedAt } from "./placeDataCreatedAt";
|
||||
|
||||
export interface PlaceData {
|
||||
@ -35,5 +36,6 @@ export interface PlaceData {
|
||||
region_code?: PlaceDataRegionCode;
|
||||
verified_at?: PlaceDataVerifiedAt;
|
||||
content_updated_at?: PlaceDataContentUpdatedAt;
|
||||
notify_email?: PlaceDataNotifyEmail;
|
||||
created_at?: PlaceDataCreatedAt;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -6,8 +6,10 @@
|
||||
*/
|
||||
import type { ReqUpdatePlaceName } from "./reqUpdatePlaceName";
|
||||
import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
|
||||
import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail";
|
||||
|
||||
export interface ReqUpdatePlace {
|
||||
name?: ReqUpdatePlaceName;
|
||||
status?: ReqUpdatePlaceStatus;
|
||||
notify_email?: ReqUpdatePlaceNotifyEmail;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
14
solution/frontend/src/api/generated/model/testPost.ts
Normal file
14
solution/frontend/src/api/generated/model/testPost.ts
Normal 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;
|
||||
}
|
||||
@ -2562,7 +2562,7 @@ export function useGetMyPost<
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary 로그인 세션으로 직접 수정·승인
|
||||
* @summary 로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로
|
||||
*/
|
||||
export const editMyPost = (
|
||||
placeId: string,
|
||||
@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost;
|
||||
export type EditMyPostMutationError = HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary 로그인 세션으로 직접 수정·승인
|
||||
* @summary 로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로
|
||||
*/
|
||||
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
|
||||
options?: {
|
||||
@ -2650,7 +2650,92 @@ export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
|
||||
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 = (
|
||||
placeId: string,
|
||||
@ -2713,7 +2798,7 @@ export type ApproveMyPostMutationResult = NonNullable<
|
||||
export type ApproveMyPostMutationError = HTTPValidationError;
|
||||
|
||||
/**
|
||||
* @summary 바로 발행 — 고치지 않고 그대로
|
||||
* @summary 바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인
|
||||
*/
|
||||
export const useApproveMyPost = <
|
||||
TError = HTTPValidationError,
|
||||
@ -2739,6 +2824,91 @@ export const useApproveMyPost = <
|
||||
|
||||
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)을 기다리지 않고, 고른 구간을 채운다
|
||||
*/
|
||||
|
||||
1214
solution/frontend/src/api/generated/social/social.ts
Normal file
1214
solution/frontend/src/api/generated/social/social.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,19 @@
|
||||
import {useEffect, useMemo, useState} from 'react';
|
||||
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 {
|
||||
useApproveMyPost,
|
||||
useDeleteMyPost,
|
||||
useEditMyPost,
|
||||
useGenerateMyPostForDate,
|
||||
useGenerateMyPosts,
|
||||
useGetGenerationHistory,
|
||||
useGetMyPost,
|
||||
useGetPlace,
|
||||
useListMyPosts,
|
||||
useListUpcomingPosts,
|
||||
useSendMyPostsNow,
|
||||
useUpdatePlace,
|
||||
type PostData,
|
||||
} from '@/api';
|
||||
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
|
||||
@ -141,7 +145,10 @@ function publishBadge(post: PostData): {label: string; className: string} | null
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */
|
||||
/**
|
||||
* 수정 폼 — 저장만 한다. 승인은 오직 이메일 링크로만 일어난다(2026-09-21, 사장님 지시:
|
||||
* "승인되야 올라가도록 해야 한다") — 예전엔 저장이 곧 승인이었지만 그 지름길을 없앴다.
|
||||
*/
|
||||
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
|
||||
const [body, setBody] = useState(post.body);
|
||||
const editMutation = useEditMyPost();
|
||||
@ -153,7 +160,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
|
||||
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
|
||||
return;
|
||||
}
|
||||
notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.');
|
||||
notify.success('저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다.');
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notifyApiError(error, '저장하지 못했습니다.');
|
||||
@ -171,7 +178,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
|
||||
<Save />
|
||||
저장하고 승인
|
||||
저장
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
|
||||
<X />
|
||||
@ -186,10 +193,11 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
|
||||
const editable = EDITABLE_STATUSES.has(post.status);
|
||||
const approveMutation = useApproveMyPost();
|
||||
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 {
|
||||
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
|
||||
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 (
|
||||
<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">
|
||||
@ -228,16 +253,36 @@ 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">
|
||||
{editable && (
|
||||
<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}>
|
||||
)}
|
||||
{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>
|
||||
</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
|
||||
*
|
||||
@ -506,6 +623,7 @@ export function BlogPostsPage() {
|
||||
const generateMutation = useGenerateMyPosts();
|
||||
const generateOneMutation = useGenerateMyPostForDate();
|
||||
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
|
||||
const sendNowMutation = useSendMyPostsNow();
|
||||
|
||||
const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
|
||||
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 = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button size="sm" variant="ghost" isLoading={sendNowMutation.isPending} onClick={handleSendNow}>
|
||||
<Send />
|
||||
승인 알림보내기
|
||||
</Button>
|
||||
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
|
||||
<Sparkles />
|
||||
지금 생성하기
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const maxGenerateDate = lastDayOfMonthIso(maxMonth);
|
||||
@ -595,6 +733,8 @@ export function BlogPostsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<NotifyEmailSetting placeId={placeId} />
|
||||
|
||||
{tab === 'main' && (
|
||||
<>
|
||||
{upcomingPosts.length > 0 ? (
|
||||
|
||||
@ -291,8 +291,35 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
|
||||
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
|
||||
export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
|
||||
|
||||
/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */
|
||||
export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우';
|
||||
/** 그림은 WeatherMood 넷으로 그리고, 문구는 WMO weather_code 하나마다 고유한 이 28종으로 고른다. */
|
||||
export type WeatherSky =
|
||||
| WeatherMood
|
||||
| '대체로 맑음'
|
||||
| '구름 조금'
|
||||
| '안개'
|
||||
| '착빙성 안개'
|
||||
| '가벼운 이슬비'
|
||||
| '보통 이슬비'
|
||||
| '강한 이슬비'
|
||||
| '가벼운 착빙성 이슬비'
|
||||
| '강한 착빙성 이슬비'
|
||||
| '약한 비'
|
||||
| '보통 비'
|
||||
| '강한 비'
|
||||
| '약한 착빙성 비'
|
||||
| '강한 착빙성 비'
|
||||
| '약한 눈'
|
||||
| '보통 눈'
|
||||
| '강한 눈'
|
||||
| '싸라기눈'
|
||||
| '약한 소나기'
|
||||
| '보통 소나기'
|
||||
| '강한 소나기'
|
||||
| '약한 소나기눈'
|
||||
| '강한 소나기눈'
|
||||
| '뇌우'
|
||||
| '약한 우박 뇌우'
|
||||
| '강한 우박 뇌우';
|
||||
|
||||
export interface WeatherSnapshot {
|
||||
temperature: number;
|
||||
|
||||
@ -1,28 +1,44 @@
|
||||
import {expect, it} from 'vitest';
|
||||
import {condition} from './use-live-weather';
|
||||
|
||||
it('recognizes fog, showers and thunderstorms as distinct conditions', () => {
|
||||
expect(condition(45)).toBe('안개');
|
||||
expect(condition(48)).toBe('안개');
|
||||
expect(condition(80)).toBe('소나기');
|
||||
expect(condition(81)).toBe('소나기');
|
||||
expect(condition(82)).toBe('소나기');
|
||||
expect(condition(85)).toBe('소나기');
|
||||
expect(condition(86)).toBe('소나기');
|
||||
expect(condition(95)).toBe('뇌우');
|
||||
expect(condition(96)).toBe('뇌우');
|
||||
expect(condition(99)).toBe('뇌우');
|
||||
const WEATHER_CODE_LABELS: 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: '강한 우박 뇌우',
|
||||
};
|
||||
|
||||
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', () => {
|
||||
expect(condition(51)).toBe('이슬비');
|
||||
expect(condition(53)).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('어는비');
|
||||
it('falls back to overcast for unknown codes', () => {
|
||||
expect(condition(4)).toBe('흐림');
|
||||
expect(condition(-1)).toBe('흐림');
|
||||
});
|
||||
|
||||
@ -5,18 +5,43 @@ export interface LiveWeather extends WeatherSnapshot {
|
||||
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 {
|
||||
if (code === 0) return '맑음';
|
||||
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 '흐림';
|
||||
return WEATHER_CONDITION_BY_CODE[code] ?? '흐림';
|
||||
}
|
||||
|
||||
/** 프리렌더 스냅샷으로 시작하고, 하이드레이션이 끝난 뒤에만 최신 캐시를 반영한다. */
|
||||
|
||||
@ -12,20 +12,20 @@ function render(weather?: WeatherSnapshot) {
|
||||
}
|
||||
|
||||
it('uses the first sky and temperature lines for deterministic HTML', () => {
|
||||
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '구름많음', noteSets: {'구름많음': ['구름 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
|
||||
expect(render(weather)).toContain('구름 첫 줄');
|
||||
const weather = {temperature: 30, observedAt: '2026-09-15T12:00', condition: '대체로 맑음', noteSets: {'대체로 맑음': ['맑음 첫 줄', '다음 줄']}, tempNoteSets: {'혹서': ['더위 첫 줄']}};
|
||||
expect(render(weather)).toContain('맑음 첫 줄');
|
||||
expect(render(weather)).toContain('더위 첫 줄');
|
||||
expect(render(weather)).not.toContain('다음 줄');
|
||||
expect(render(weather)).toBe(render(weather));
|
||||
});
|
||||
|
||||
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('소나기 첫 줄');
|
||||
});
|
||||
|
||||
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)).not.toContain('일반 비 첫 줄');
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user