[feat] solution: 미니블로그 빌더 기능 확장 — 알림 이메일 분리·삭제·즉시발송·공급자 무관 생성
빌더 앱에서 미니블로그를 운영하는 데 필요했던 몇 가지를 묶었다. - notify_email: 업장별 승인 메일 수신자를 계정 로그인 이메일과 분리(places.notify_email, migrations/0021, PlaceCRUD·protocol.py·place_service.py 검증, BlogPostsPage.tsx 설정 UI) - 글 삭제(soft delete): 상태 제한 없이 지우고, 이미 게재된 글이면 재발행 잡까지 큐에 넣는다 (post_crud.py, router/v1/site/post.py DELETE, BlogPostsPage.tsx 삭제 버튼) - 지금 발송하기: 아침 9시 스윕을 안 기다리고 바로 발송(post_crud.next_due_for_mail, BlogPostsPage.tsx 버튼) - blog_service.generate_one: 하드코딩된 Gemini 대신 services/llm/provider.py(LLM_PROVIDER, 기본 openai)를 타도록 전환, 업종별 분기 구조(현재 숙소만 구현) 추가 - site_payload.publish_url(place, site): 발행 주소 조합을 한 곳에 모은 헬퍼 - 프론트: orval 로 재생성한 API 클라이언트(notify_email·삭제·즉시발송·social 엔드포인트 반영) 관련 스위트는 별도 커밋(쓰레드 연동 작업)에서 이미 PASS 확인함
This commit is contained in:
parent
adb3460c37
commit
81ea676546
@ -134,6 +134,7 @@ CREATE TABLE IF NOT EXISTS public.places (
|
|||||||
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
|
verified_at TIMESTAMPTZ NULL, -- ★ NULL = 미검증. 수집·발행 금지 — 검증 없이 수집하면 남의 가게가 섞인다
|
||||||
verified_by uuid NULL,
|
verified_by uuid NULL,
|
||||||
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
|
content_updated_at TIMESTAMPTZ NULL, -- ★ 노출값이 마지막으로 바뀐 시각. site_versions.built_at 과 비교해 재빌드 대상을 고른다
|
||||||
|
notify_email VARCHAR(255) NULL, -- 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
deleted BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
|
|||||||
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)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
# ★ 노출값(VERIFIED/CORRECTED fact)이 마지막으로 바뀐 시각. 개별 재빌드 대상 판별용 —
|
||||||
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
# site_versions.built_at < content_updated_at 인 사이트만 다시 빌드한다.
|
||||||
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
content_updated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
# 미니 블로그 승인 메일 수신 주소. 비면 users.email 로 대체(services/blog_jobs.py send_reviewed) —
|
||||||
|
# 사장님 한 명이 사이트를 여러 개 가질 수 있어 계정 이메일 하나로는 업장별 수신자를 못 나눈다.
|
||||||
|
notify_email = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,21 @@ class PostCRUD:
|
|||||||
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
|
rows = sorted(result.scalars(), key=lambda row: row.scheduled_date)
|
||||||
return ErrorType.SUCCESS, rows[:limit]
|
return ErrorType.SUCCESS, rows[:limit]
|
||||||
|
|
||||||
|
async def next_due_for_mail(self, cdb: AsyncSession, place_id, status: int, today):
|
||||||
|
"""이 업장의 오늘 몫 글 하나 — 사장님이 '지금 발송하기'를 눌렀을 때 쓴다. 없으면 None.
|
||||||
|
due_for_mail 과 같은 조건(배정일이 오늘까지 온 것)을 이 업장 하나로 좁힌 것뿐이다."""
|
||||||
|
result = await cdb.execute(
|
||||||
|
select(place_posts)
|
||||||
|
.where(
|
||||||
|
place_posts.place_id == place_id, place_posts.status == status,
|
||||||
|
place_posts.deleted == False, # noqa: E712
|
||||||
|
place_posts.scheduled_date <= today,
|
||||||
|
)
|
||||||
|
.order_by(place_posts.scheduled_date)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return result.scalars().first()
|
||||||
|
|
||||||
async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
|
async def list_for_place(self, cdb: AsyncSession, place_id, since, until):
|
||||||
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
|
"""사장님 빌더 화면 — 이번 달(또는 고른 달)에 배정된 글 전체, 날짜순."""
|
||||||
result = await cdb.execute(
|
result = await cdb.execute(
|
||||||
@ -173,8 +188,21 @@ class PostCRUD:
|
|||||||
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
|
"""재발행이 끝나면 그 업장의 승인분을 한꺼번에 게재로 옮긴다."""
|
||||||
await cdb.execute(
|
await cdb.execute(
|
||||||
update(place_posts)
|
update(place_posts)
|
||||||
.where(place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value)
|
.where(
|
||||||
|
place_posts.place_id == place_id, place_posts.status == PostStatus.APPROVED.value,
|
||||||
|
place_posts.deleted == False, # noqa: E712 — 승인 후 삭제된 글까지 게재로 옮기지 않는다
|
||||||
|
)
|
||||||
.values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
|
.values(status=PostStatus.PUBLISHED.value, published_at=GTime.UTC(),
|
||||||
published_version_id=version_id, updated_at=GTime.UTC())
|
published_version_id=version_id, updated_at=GTime.UTC())
|
||||||
)
|
)
|
||||||
return ErrorType.SUCCESS
|
return ErrorType.SUCCESS
|
||||||
|
|
||||||
|
async def delete(self, cdb: AsyncSession, post_id) -> ErrorType:
|
||||||
|
"""소프트 삭제. (place_id, topic_key)·(place_id, scheduled_date) 유니크가 deleted=false
|
||||||
|
행만 보므로, 지우면 그 날짜·주제가 바로 재생성 대상으로 풀린다."""
|
||||||
|
await cdb.execute(
|
||||||
|
update(place_posts)
|
||||||
|
.where(place_posts.post_id == post_id)
|
||||||
|
.values(deleted=True, updated_at=GTime.UTC())
|
||||||
|
)
|
||||||
|
return ErrorType.SUCCESS
|
||||||
|
|||||||
@ -75,6 +75,8 @@ class Req_UpdatePlace(PlaceProtocol):
|
|||||||
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
|
# ★ 주인은 못 바꾼다(위 Req_CreatePlace 주석). 소유권 이전은 아직 기능이 아니다.
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
status: Optional[PlaceStatus] = None
|
status: Optional[PlaceStatus] = None
|
||||||
|
# 미니 블로그 승인 메일 수신 주소. 빈 문자열이면 지운다(계정 이메일로 되돌린다).
|
||||||
|
notify_email: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class Req_CreateUnit(PlaceProtocol):
|
class Req_CreateUnit(PlaceProtocol):
|
||||||
@ -107,6 +109,7 @@ class PlaceData(WebPacketProtocol):
|
|||||||
region_code: Optional[str] = None
|
region_code: Optional[str] = None
|
||||||
verified_at: Optional[datetime] = None
|
verified_at: Optional[datetime] = None
|
||||||
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
|
content_updated_at: Optional[datetime] = None # ★ 노출값 변경 시각 — 개별 재빌드 대상 판별
|
||||||
|
notify_email: Optional[str] = None # 미니 블로그 승인 메일 수신 주소. 비면 계정 이메일 사용
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@
|
|||||||
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
|
실어 보내고(services/blog_jobs.py _mail_body), 빌더 앱이 그 토큰으로 로그인한 뒤 이번
|
||||||
글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
|
글 편집 모달을 바로 연다(BlogPostsPage.tsx). 별도 공개 편집 화면을 두지 않는다.
|
||||||
★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
|
★ owner_router 는 로그인 세션이 신원이다 — 빌더 앱의 "이번 달 생성된 글" 화면.
|
||||||
|
★★ 2026-09-21, 사장님 지시: 게재는 두 경로 다 열려 있다 — 이 파일 위쪽의 /approve
|
||||||
|
(이메일 토큰, 로그인 없음)와, 아래 owner_router 의 POST .../approve(로그인 세션,
|
||||||
|
"바로 발행" — 수정 없이 그대로 승인). PUT(수정)은 저장만 하고 자동으로 승인하지 않는다 —
|
||||||
|
승인은 이 두 경로 중 하나를 명시적으로 눌러야 한다.
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
@ -94,7 +98,7 @@ async def get_my_post(
|
|||||||
return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id)))
|
return RemoveNoneResponse(await service.get_post(user_info, str(place_id), str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정·승인")
|
@owner_router.put(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로")
|
||||||
async def edit_my_post(
|
async def edit_my_post(
|
||||||
place_id: UUID,
|
place_id: UUID,
|
||||||
post_id: UUID,
|
post_id: UUID,
|
||||||
@ -105,7 +109,10 @@ async def edit_my_post(
|
|||||||
return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body))
|
return RemoveNoneResponse(await service.edit_by_owner(user_info, str(place_id), str(post_id), req.body))
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(path="/{post_id}/approve", response_model=Res_WebPacketProtocol, summary="바로 발행 — 고치지 않고 그대로")
|
@owner_router.post(
|
||||||
|
path="/{post_id}/approve", response_model=Res_WebPacketProtocol,
|
||||||
|
summary="바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인",
|
||||||
|
)
|
||||||
async def approve_my_post(
|
async def approve_my_post(
|
||||||
place_id: UUID,
|
place_id: UUID,
|
||||||
post_id: UUID,
|
post_id: UUID,
|
||||||
@ -115,6 +122,28 @@ async def approve_my_post(
|
|||||||
return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id)))
|
return RemoveNoneResponse(await service.approve_by_owner(user_info, str(place_id), str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@owner_router.delete(path="/{post_id}", response_model=Res_WebPacketProtocol, summary="글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다")
|
||||||
|
async def delete_my_post(
|
||||||
|
place_id: UUID,
|
||||||
|
post_id: UUID,
|
||||||
|
service: PostService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.delete_by_owner(user_info, str(place_id), str(post_id)))
|
||||||
|
|
||||||
|
|
||||||
|
@owner_router.post(
|
||||||
|
path="/send-now", response_model=Res_WebPacketProtocol,
|
||||||
|
summary="지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다",
|
||||||
|
)
|
||||||
|
async def send_my_posts_now(
|
||||||
|
place_id: UUID,
|
||||||
|
service: PostService = Depends(),
|
||||||
|
user_info: UserInfo = Depends(IsValidAccessToken),
|
||||||
|
):
|
||||||
|
return RemoveNoneResponse(await service.send_now(user_info, str(place_id)))
|
||||||
|
|
||||||
|
|
||||||
@owner_router.post(
|
@owner_router.post(
|
||||||
path="/generate", response_model=Res_GenerateNow,
|
path="/generate", response_model=Res_GenerateNow,
|
||||||
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",
|
summary="지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다",
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import re
|
|||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from common.enums import PostStatus, PostTopicKind
|
from common.enums import PlaceCategory, PostStatus, PostTopicKind
|
||||||
from common.logger import LOG
|
from common.logger import LOG
|
||||||
|
|
||||||
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
|
# 본문 길이 — 회의 확정값(140~150자)에 여유를 둔다. 벗어나면 버린다.
|
||||||
@ -64,6 +64,7 @@ def issue_token() -> tuple[str, str, object]:
|
|||||||
return token, hash_token(token), expires
|
return token, hash_token(token), expires
|
||||||
|
|
||||||
|
|
||||||
|
# 숙소(LODGING) 기본 갈래 규칙 — 업종별 규칙이 없을 때의 폴백이기도 하다.
|
||||||
TOPIC_RULES: dict[int, str] = {
|
TOPIC_RULES: dict[int, str] = {
|
||||||
PostTopicKind.WEATHER.value:
|
PostTopicKind.WEATHER.value:
|
||||||
"오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.",
|
"오늘의 날씨와 그 날씨에 이 숙소에서 하기 좋은 일을 한 장면으로 적는다.",
|
||||||
@ -77,6 +78,23 @@ TOPIC_RULES: dict[int, str] = {
|
|||||||
"확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.",
|
"확인된 이용 안내 하나를 손님이 알아두면 좋은 말투로 풀어 적는다.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 업종별 분기 — 지금은 숙소만 채워져 있다. 새 업종을 넣으려면 여기 두 딕셔너리에만 항목을 더한다.
|
||||||
|
_BUSINESS_NOUN_BY_CATEGORY: dict[int, str] = {
|
||||||
|
PlaceCategory.LODGING.value: "숙소",
|
||||||
|
}
|
||||||
|
_TOPIC_RULES_BY_CATEGORY: dict[int, dict[int, str]] = {
|
||||||
|
PlaceCategory.LODGING.value: TOPIC_RULES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _business_noun(place_category: int) -> str:
|
||||||
|
return _BUSINESS_NOUN_BY_CATEGORY.get(place_category, "숙소")
|
||||||
|
|
||||||
|
|
||||||
|
def _topic_rules(place_category: int) -> dict[int, str]:
|
||||||
|
return _TOPIC_RULES_BY_CATEGORY.get(place_category, TOPIC_RULES)
|
||||||
|
|
||||||
|
|
||||||
_RULES = (
|
_RULES = (
|
||||||
"규칙\n"
|
"규칙\n"
|
||||||
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n"
|
f"- {MIN_LEN}~{MAX_LEN}자 사이 한 문단. 제목·해시태그·이모지를 쓰지 않는다.\n"
|
||||||
@ -87,12 +105,15 @@ _RULES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str]) -> str:
|
def build_prompt(*, place_name: str, region: str, topic_kind: int, material: str, used_topics: list[str],
|
||||||
|
place_category: int = PlaceCategory.LODGING.value) -> str:
|
||||||
"""갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다."""
|
"""갈래 하나에 대한 프롬프트 한 벌. 프롬프트를 두 곳에 적지 않으려고 여기서만 만든다."""
|
||||||
used = ", ".join(used_topics[:40]) or "없음"
|
used = ", ".join(used_topics[:40]) or "없음"
|
||||||
|
noun = _business_noun(place_category)
|
||||||
|
rules = _topic_rules(place_category)
|
||||||
return (
|
return (
|
||||||
f"{region}에 있는 숙소 '{place_name}'의 짧은 홍보 글을 쓴다.\n"
|
f"{region}에 있는 {noun} '{place_name}'의 짧은 홍보 글을 쓴다.\n"
|
||||||
f"갈래: {TOPIC_RULES.get(topic_kind, '')}\n"
|
f"갈래: {rules.get(topic_kind, '')}\n"
|
||||||
f"소재: {material}\n"
|
f"소재: {material}\n"
|
||||||
f"이미 쓴 주제: {used}\n\n"
|
f"이미 쓴 주제: {used}\n\n"
|
||||||
f"{_RULES}\n본문만 출력한다."
|
f"{_RULES}\n본문만 출력한다."
|
||||||
@ -124,29 +145,37 @@ def filter_drafts(rows: list[dict]) -> tuple[list[dict], list[tuple[str, str]]]:
|
|||||||
|
|
||||||
|
|
||||||
async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str,
|
async def generate_one(*, place_name: str, region: str, topic_kind: int, material: str,
|
||||||
used_topics: list[str], client=None) -> tuple[str, str] | None:
|
used_topics: list[str], place_category: int = PlaceCategory.LODGING.value,
|
||||||
|
client=None) -> tuple[str, str] | None:
|
||||||
"""(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
|
"""(문구, 모델명) 한 쌍. LLM 이 없거나 실패하면 None — 생성 실패가 잡을 죽이지 않는다.
|
||||||
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시)."""
|
모델명은 생성 이력 화면이 "어느 모델썼는지" 보여주는 데 쓴다(2026-09-17, 사장님 지시).
|
||||||
from services.llm.gemini import DEFAULT_MODEL, GeminiError, call, extract_text, is_configured
|
|
||||||
|
|
||||||
if not is_configured():
|
★ 발행 링크는 여기서 붙이지 않는다 — 호출부가 길이 게이트(is_publishable_body/
|
||||||
|
filter_drafts, MIN_LEN~MAX_LEN)를 이 반환값 그대로에 건다. 링크까지 포함해서
|
||||||
|
길이를 재면 정상 문구도 게이트에 걸려 버려진다. 링크는 게이트를 통과한 뒤 호출부가
|
||||||
|
붙인다.
|
||||||
|
|
||||||
|
★ 공급자는 LLM_PROVIDER 설정을 따른다(services/llm/provider.py) — Gemini 로 고정하지
|
||||||
|
않는다. generate_social_post(services/external/gemini_text.py)와 달리 구조화 출력
|
||||||
|
재시도 루프가 없는 단순 텍스트 생성이라 공급자를 가려도 된다."""
|
||||||
|
from services.llm import provider
|
||||||
|
from services.llm.errors import LlmError
|
||||||
|
|
||||||
|
llm = provider.active()
|
||||||
|
if not llm.is_configured():
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
|
prompt = build_prompt(place_name=place_name, region=region, topic_kind=topic_kind,
|
||||||
material=material, used_topics=used_topics)
|
material=material, used_topics=used_topics, place_category=place_category)
|
||||||
body = {
|
|
||||||
"contents": [{"role": "user", "parts": [{"text": prompt}]}],
|
|
||||||
"generationConfig": {"temperature": 0.9},
|
|
||||||
}
|
|
||||||
owns = client is None
|
owns = client is None
|
||||||
if owns:
|
if owns:
|
||||||
import httpx
|
import httpx
|
||||||
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=10.0))
|
||||||
try:
|
try:
|
||||||
payload = await call(client, DEFAULT_MODEL, body)
|
result = await llm.generate(client, llm.DEFAULT_MODEL, prompt=prompt, temperature=0.9)
|
||||||
text = extract_text(payload).strip()
|
text = result.text.strip()
|
||||||
return (text, DEFAULT_MODEL) if text else None
|
return (text, llm.DEFAULT_MODEL) if text else None
|
||||||
except GeminiError as error:
|
except LlmError as error:
|
||||||
LOG.w(f"[blog] 생성 실패: {error}")
|
LOG.w(f"[blog] 생성 실패: {error}")
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from common.models.gmodel import PageParams, UserInfo
|
|||||||
from common.utils.gtime import GTime
|
from common.utils.gtime import GTime
|
||||||
from crud.job_crud import JobQueue
|
from crud.job_crud import JobQueue
|
||||||
from crud.place_crud import IPlaceCRUD, PlaceCRUD
|
from crud.place_crud import IPlaceCRUD, PlaceCRUD
|
||||||
|
from services import mail_service
|
||||||
from router.v1.place.protocol import (
|
from router.v1.place.protocol import (
|
||||||
Req_VerifyPlaceByUrl,
|
Req_VerifyPlaceByUrl,
|
||||||
LinkData,
|
LinkData,
|
||||||
@ -150,6 +151,12 @@ class PlaceService:
|
|||||||
data["status"] = req.status.value
|
data["status"] = req.status.value
|
||||||
if "name" in data:
|
if "name" in data:
|
||||||
data["name"] = str(data["name"]).strip()
|
data["name"] = str(data["name"]).strip()
|
||||||
|
if "notify_email" in data:
|
||||||
|
data["notify_email"] = str(data["notify_email"]).strip()
|
||||||
|
if data["notify_email"] and not mail_service.is_valid_address(data["notify_email"]):
|
||||||
|
res.result.SetResult(ErrorType.INVALID_REQUEST_DATA)
|
||||||
|
return res
|
||||||
|
data["notify_email"] = data["notify_email"] or None
|
||||||
|
|
||||||
if data:
|
if data:
|
||||||
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
err_type, rowcount = await DB_SESSION_MNG.execute_lambda_claim(
|
||||||
|
|||||||
@ -794,6 +794,12 @@ def publish_origin() -> str:
|
|||||||
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
|
return f"{_scheme(DEFAULT_HOST)}://{DEFAULT_HOST}"
|
||||||
|
|
||||||
|
|
||||||
|
def publish_url(place, site) -> str:
|
||||||
|
"""이 사업장 사이트의 전체 발행 주소. 미니 블로그·SNS 초안이 문구 끝에 붙이는 링크가
|
||||||
|
이 값과 갈리면 안 되므로 origin·slug 조합을 여기 한 곳에서만 한다."""
|
||||||
|
return f"{publish_origin()}/s/{publish_slug(place, site)}"
|
||||||
|
|
||||||
|
|
||||||
def primary_media(snapshot: dict) -> dict | None:
|
def primary_media(snapshot: dict) -> dict | None:
|
||||||
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.
|
"""대표 사진(og:image) — 객실·메뉴 전용이 아닌 첫 장. 없으면 None.
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
21
solution/frontend/src/api/generated/model/callbackParams.ts
Normal file
21
solution/frontend/src/api/generated/model/callbackParams.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v7.21.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Web4Ai API
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CallbackParams = {
|
||||||
|
/**
|
||||||
|
* @maxLength 2048
|
||||||
|
*/
|
||||||
|
state?: string;
|
||||||
|
/**
|
||||||
|
* @maxLength 4096
|
||||||
|
*/
|
||||||
|
code?: string;
|
||||||
|
/**
|
||||||
|
* @maxLength 200
|
||||||
|
*/
|
||||||
|
error?: 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,15 @@
|
|||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export * from "./approvalParams";
|
||||||
export * from "./approvePageParams";
|
export * from "./approvePageParams";
|
||||||
export * from "./auditCheckData";
|
export * from "./auditCheckData";
|
||||||
export * from "./auditCheckDataRecommendation";
|
export * from "./auditCheckDataRecommendation";
|
||||||
export * from "./authProvider";
|
export * from "./authProvider";
|
||||||
export * from "./buildStatus";
|
export * from "./buildStatus";
|
||||||
|
export * from "./callbackParams";
|
||||||
export * from "./checkSlugParams";
|
export * from "./checkSlugParams";
|
||||||
|
export * from "./decision";
|
||||||
export * from "./errorInfo";
|
export * from "./errorInfo";
|
||||||
export * from "./errorInfoCode";
|
export * from "./errorInfoCode";
|
||||||
export * from "./errorInfoDesc";
|
export * from "./errorInfoDesc";
|
||||||
@ -61,6 +64,7 @@ export * from "./linkData";
|
|||||||
export * from "./linkDataConfirmedAt";
|
export * from "./linkDataConfirmedAt";
|
||||||
export * from "./linkDataDiscoveredAt";
|
export * from "./linkDataDiscoveredAt";
|
||||||
export * from "./linkDataTitle";
|
export * from "./linkDataTitle";
|
||||||
|
export * from "./linkDecision";
|
||||||
export * from "./listContentsParams";
|
export * from "./listContentsParams";
|
||||||
export * from "./listFactsParams";
|
export * from "./listFactsParams";
|
||||||
export * from "./listFaqsParams";
|
export * from "./listFaqsParams";
|
||||||
@ -126,6 +130,7 @@ export * from "./placeDataExternalPlaceId";
|
|||||||
export * from "./placeDataExternalSource";
|
export * from "./placeDataExternalSource";
|
||||||
export * from "./placeDataLatitude";
|
export * from "./placeDataLatitude";
|
||||||
export * from "./placeDataLongitude";
|
export * from "./placeDataLongitude";
|
||||||
|
export * from "./placeDataNotifyEmail";
|
||||||
export * from "./placeDataOwnerUserId";
|
export * from "./placeDataOwnerUserId";
|
||||||
export * from "./placeDataPhone";
|
export * from "./placeDataPhone";
|
||||||
export * from "./placeDataRegionCode";
|
export * from "./placeDataRegionCode";
|
||||||
@ -208,6 +213,7 @@ export * from "./reqUpdateMeName";
|
|||||||
export * from "./reqUpdateMePassword";
|
export * from "./reqUpdateMePassword";
|
||||||
export * from "./reqUpdatePlace";
|
export * from "./reqUpdatePlace";
|
||||||
export * from "./reqUpdatePlaceName";
|
export * from "./reqUpdatePlaceName";
|
||||||
|
export * from "./reqUpdatePlaceNotifyEmail";
|
||||||
export * from "./reqUpdatePlaceStatus";
|
export * from "./reqUpdatePlaceStatus";
|
||||||
export * from "./reqUpsertFact";
|
export * from "./reqUpsertFact";
|
||||||
export * from "./reqUpsertFactExpiresAt";
|
export * from "./reqUpsertFactExpiresAt";
|
||||||
@ -372,6 +378,7 @@ export * from "./siteVersionDataBuildError";
|
|||||||
export * from "./siteVersionDataBuiltAt";
|
export * from "./siteVersionDataBuiltAt";
|
||||||
export * from "./siteVersionDataCreatedAt";
|
export * from "./siteVersionDataCreatedAt";
|
||||||
export * from "./sourceType";
|
export * from "./sourceType";
|
||||||
|
export * from "./testPost";
|
||||||
export * from "./unitData";
|
export * from "./unitData";
|
||||||
export * from "./userRole";
|
export * from "./userRole";
|
||||||
export * from "./validationError";
|
export * from "./validationError";
|
||||||
|
|||||||
@ -23,4 +23,6 @@ export const JobType = {
|
|||||||
AI_CHECK: 6,
|
AI_CHECK: 6,
|
||||||
SONG: 7,
|
SONG: 7,
|
||||||
ROLLBACK: 8,
|
ROLLBACK: 8,
|
||||||
|
SOCIAL_DRAFT: 9,
|
||||||
|
SOCIAL_POST: 10,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import type { PlaceDataLongitude } from "./placeDataLongitude";
|
|||||||
import type { PlaceDataRegionCode } from "./placeDataRegionCode";
|
import type { PlaceDataRegionCode } from "./placeDataRegionCode";
|
||||||
import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt";
|
import type { PlaceDataVerifiedAt } from "./placeDataVerifiedAt";
|
||||||
import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt";
|
import type { PlaceDataContentUpdatedAt } from "./placeDataContentUpdatedAt";
|
||||||
|
import type { PlaceDataNotifyEmail } from "./placeDataNotifyEmail";
|
||||||
import type { PlaceDataCreatedAt } from "./placeDataCreatedAt";
|
import type { PlaceDataCreatedAt } from "./placeDataCreatedAt";
|
||||||
|
|
||||||
export interface PlaceData {
|
export interface PlaceData {
|
||||||
@ -35,5 +36,6 @@ export interface PlaceData {
|
|||||||
region_code?: PlaceDataRegionCode;
|
region_code?: PlaceDataRegionCode;
|
||||||
verified_at?: PlaceDataVerifiedAt;
|
verified_at?: PlaceDataVerifiedAt;
|
||||||
content_updated_at?: PlaceDataContentUpdatedAt;
|
content_updated_at?: PlaceDataContentUpdatedAt;
|
||||||
|
notify_email?: PlaceDataNotifyEmail;
|
||||||
created_at?: PlaceDataCreatedAt;
|
created_at?: PlaceDataCreatedAt;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { ReqUpdatePlaceName } from "./reqUpdatePlaceName";
|
||||||
import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
|
import type { ReqUpdatePlaceStatus } from "./reqUpdatePlaceStatus";
|
||||||
|
import type { ReqUpdatePlaceNotifyEmail } from "./reqUpdatePlaceNotifyEmail";
|
||||||
|
|
||||||
export interface ReqUpdatePlace {
|
export interface ReqUpdatePlace {
|
||||||
name?: ReqUpdatePlaceName;
|
name?: ReqUpdatePlaceName;
|
||||||
status?: ReqUpdatePlaceStatus;
|
status?: ReqUpdatePlaceStatus;
|
||||||
|
notify_email?: ReqUpdatePlaceNotifyEmail;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 = (
|
export const editMyPost = (
|
||||||
placeId: string,
|
placeId: string,
|
||||||
@ -2626,7 +2626,7 @@ export type EditMyPostMutationBody = ReqEditPost;
|
|||||||
export type EditMyPostMutationError = HTTPValidationError;
|
export type EditMyPostMutationError = HTTPValidationError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary 로그인 세션으로 직접 수정·승인
|
* @summary 로그인 세션으로 직접 수정 — 저장만, 승인은 이메일로
|
||||||
*/
|
*/
|
||||||
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
|
export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
|
||||||
options?: {
|
options?: {
|
||||||
@ -2650,7 +2650,92 @@ export const useEditMyPost = <TError = HTTPValidationError, TContext = unknown>(
|
|||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* @summary 바로 발행 — 고치지 않고 그대로
|
* @summary 글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다
|
||||||
|
*/
|
||||||
|
export const deleteMyPost = (
|
||||||
|
placeId: string,
|
||||||
|
postId: string,
|
||||||
|
options?: SecondParameter<typeof customFetch>,
|
||||||
|
) => {
|
||||||
|
return customFetch<ResWebPacketProtocol>(
|
||||||
|
{ url: `/v1/place/${placeId}/post/${postId}`, method: "DELETE" },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDeleteMyPostMutationOptions = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string; postId: string },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string; postId: string },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["deleteMyPost"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>,
|
||||||
|
{ placeId: string; postId: string }
|
||||||
|
> = (props) => {
|
||||||
|
const { placeId, postId } = props ?? {};
|
||||||
|
|
||||||
|
return deleteMyPost(placeId, postId, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteMyPostMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type DeleteMyPostMutationError = HTTPValidationError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 글 삭제 — 게재된 글이면 재발행까지 큐에 넣는다
|
||||||
|
*/
|
||||||
|
export const useDeleteMyPost = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string; postId: string },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof deleteMyPost>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string; postId: string },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationOptions = getDeleteMyPostMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* @summary 바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인
|
||||||
*/
|
*/
|
||||||
export const approveMyPost = (
|
export const approveMyPost = (
|
||||||
placeId: string,
|
placeId: string,
|
||||||
@ -2713,7 +2798,7 @@ export type ApproveMyPostMutationResult = NonNullable<
|
|||||||
export type ApproveMyPostMutationError = HTTPValidationError;
|
export type ApproveMyPostMutationError = HTTPValidationError;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary 바로 발행 — 고치지 않고 그대로
|
* @summary 바로 발행 — 로그인 세션으로 고치지 않고 그대로(또는 방금 고친 그대로) 승인
|
||||||
*/
|
*/
|
||||||
export const useApproveMyPost = <
|
export const useApproveMyPost = <
|
||||||
TError = HTTPValidationError,
|
TError = HTTPValidationError,
|
||||||
@ -2739,6 +2824,91 @@ export const useApproveMyPost = <
|
|||||||
|
|
||||||
return useMutation(mutationOptions, queryClient);
|
return useMutation(mutationOptions, queryClient);
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* @summary 지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다
|
||||||
|
*/
|
||||||
|
export const sendMyPostsNow = (
|
||||||
|
placeId: string,
|
||||||
|
options?: SecondParameter<typeof customFetch>,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
) => {
|
||||||
|
return customFetch<ResWebPacketProtocol>(
|
||||||
|
{ url: `/v1/place/${placeId}/post/send-now`, method: "POST", signal },
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSendMyPostsNowMutationOptions = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
}): UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationKey = ["sendMyPostsNow"];
|
||||||
|
const { mutation: mutationOptions, request: requestOptions } = options
|
||||||
|
? options.mutation &&
|
||||||
|
"mutationKey" in options.mutation &&
|
||||||
|
options.mutation.mutationKey
|
||||||
|
? options
|
||||||
|
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||||
|
: { mutation: { mutationKey }, request: undefined };
|
||||||
|
|
||||||
|
const mutationFn: MutationFunction<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>,
|
||||||
|
{ placeId: string }
|
||||||
|
> = (props) => {
|
||||||
|
const { placeId } = props ?? {};
|
||||||
|
|
||||||
|
return sendMyPostsNow(placeId, requestOptions);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { mutationFn, ...mutationOptions };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SendMyPostsNowMutationResult = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type SendMyPostsNowMutationError = HTTPValidationError;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary 지금 발송하기 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 바로 보낸다
|
||||||
|
*/
|
||||||
|
export const useSendMyPostsNow = <
|
||||||
|
TError = HTTPValidationError,
|
||||||
|
TContext = unknown,
|
||||||
|
>(
|
||||||
|
options?: {
|
||||||
|
mutation?: UseMutationOptions<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string },
|
||||||
|
TContext
|
||||||
|
>;
|
||||||
|
request?: SecondParameter<typeof customFetch>;
|
||||||
|
},
|
||||||
|
queryClient?: QueryClient,
|
||||||
|
): UseMutationResult<
|
||||||
|
Awaited<ReturnType<typeof sendMyPostsNow>>,
|
||||||
|
TError,
|
||||||
|
{ placeId: string },
|
||||||
|
TContext
|
||||||
|
> => {
|
||||||
|
const mutationOptions = getSendMyPostsNowMutationOptions(options);
|
||||||
|
|
||||||
|
return useMutation(mutationOptions, queryClient);
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* @summary 지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다
|
* @summary 지금 생성하기 — 새벽 크론(04:10)을 기다리지 않고, 고른 구간을 채운다
|
||||||
*/
|
*/
|
||||||
|
|||||||
1216
solution/frontend/src/api/generated/social/social.ts
Normal file
1216
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 {useEffect, useMemo, useState} from 'react';
|
||||||
import {useNavigate, useSearchParams} from 'react-router';
|
import {useNavigate, useSearchParams} from 'react-router';
|
||||||
import {Loader2, Pencil, Plus, Save, Send, Sparkles, X} from 'lucide-react';
|
import {Loader2, Pencil, Plus, Save, Send, Sparkles, Trash2, X} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useApproveMyPost,
|
useApproveMyPost,
|
||||||
|
useDeleteMyPost,
|
||||||
useEditMyPost,
|
useEditMyPost,
|
||||||
useGenerateMyPostForDate,
|
useGenerateMyPostForDate,
|
||||||
useGenerateMyPosts,
|
useGenerateMyPosts,
|
||||||
useGetGenerationHistory,
|
useGetGenerationHistory,
|
||||||
useGetMyPost,
|
useGetMyPost,
|
||||||
|
useGetPlace,
|
||||||
useListMyPosts,
|
useListMyPosts,
|
||||||
useListUpcomingPosts,
|
useListUpcomingPosts,
|
||||||
|
useSendMyPostsNow,
|
||||||
|
useUpdatePlace,
|
||||||
type PostData,
|
type PostData,
|
||||||
} from '@/api';
|
} from '@/api';
|
||||||
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
|
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
|
||||||
@ -141,7 +145,10 @@ function publishBadge(post: PostData): {label: string; className: string} | null
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 수정 폼 — 저장하면 그대로 승인된다(메일의 "수정해서 올리기"와 같은 규칙). */
|
/**
|
||||||
|
* 수정 폼 — 저장만 한다. 승인은 오직 이메일 링크로만 일어난다(2026-09-21, 사장님 지시:
|
||||||
|
* "승인되야 올라가도록 해야 한다") — 예전엔 저장이 곧 승인이었지만 그 지름길을 없앴다.
|
||||||
|
*/
|
||||||
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
|
function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; onDone: () => void}) {
|
||||||
const [body, setBody] = useState(post.body);
|
const [body, setBody] = useState(post.body);
|
||||||
const editMutation = useEditMyPost();
|
const editMutation = useEditMyPost();
|
||||||
@ -153,7 +160,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
|
|||||||
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
|
notify.error(res.msg || '저장하지 못했습니다. 요금·시간·전화번호처럼 확인되지 않은 내용은 빼 주세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
notify.success('저장하고 승인했습니다. 사이트에 반영되기까지 몇 분 걸립니다.');
|
notify.success('저장했습니다. 이메일 승인 링크를 눌러야 사이트에 반영됩니다.');
|
||||||
onDone();
|
onDone();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifyApiError(error, '저장하지 못했습니다.');
|
notifyApiError(error, '저장하지 못했습니다.');
|
||||||
@ -171,7 +178,7 @@ function PostEditor({placeId, post, onDone}: {placeId: string; post: PostData; o
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
|
<Button size="sm" variant="primary" isLoading={editMutation.isPending} onClick={handleSave}>
|
||||||
<Save />
|
<Save />
|
||||||
저장하고 승인
|
저장
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
|
<Button size="sm" variant="ghost" onClick={onDone} disabled={editMutation.isPending}>
|
||||||
<X />
|
<X />
|
||||||
@ -186,10 +193,11 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
|
const status = STATUS_LABEL[post.status] ?? STATUS_LABEL[1];
|
||||||
const editable = EDITABLE_STATUSES.has(post.status);
|
const editable = EDITABLE_STATUSES.has(post.status);
|
||||||
const approveMutation = useApproveMyPost();
|
|
||||||
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
|
const {text, chip} = post.scheduled_date ? dateLabel(post.scheduled_date) : {text: '', chip: null};
|
||||||
|
const deleteMutation = useDeleteMyPost();
|
||||||
|
const approveMutation = useApproveMyPost();
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handleApprove = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
|
const res = await approveMutation.mutateAsync({placeId, postId: post.post_id});
|
||||||
if (!res.result?.success) {
|
if (!res.result?.success) {
|
||||||
@ -203,6 +211,23 @@ function PostCard({placeId, post, onChanged}: {placeId: string; post: PostData;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
// 이미 게재된 글은 지우면 사이트에서도 빠지기까지 재발행이 걸린다 — 되돌릴 수 없는
|
||||||
|
// 작업이라 한 번 확인한다(SitesPage.tsx 의 발행 내리기와 같은 관례).
|
||||||
|
if (!window.confirm('이 글을 삭제할까요? 게재된 글이면 사이트에서도 곧 사라집니다.')) return;
|
||||||
|
try {
|
||||||
|
const res = await deleteMutation.mutateAsync({placeId, postId: post.post_id});
|
||||||
|
if (!res.result?.success) {
|
||||||
|
notify.error(res.msg || '삭제하지 못했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify.success('삭제했습니다.');
|
||||||
|
onChanged();
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, '삭제하지 못했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg">
|
<div className="flex h-full flex-col rounded-lg border border-border bg-card p-3.5 shadow-lg">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@ -228,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">
|
<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)}>
|
<Button size="sm" variant="ghost" className="flex-1" onClick={() => setIsEditing(true)}>
|
||||||
<Pencil />
|
<Pencil />
|
||||||
수정
|
수정
|
||||||
</Button>
|
</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 />
|
<Send />
|
||||||
바로 발행
|
바로 발행
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="flex-1 text-destructive hover:text-destructive"
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -442,6 +487,78 @@ function GenerationHistoryList({placeId}: {placeId: string}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 승인 메일을 받을 주소 — 계정 로그인 이메일(users.email)과 분리한 업장별 설정
|
||||||
|
* (2026-09-21, 사장님 요청: "그 이메일은 변경 가능하도록"). 비워서 저장하면 계정
|
||||||
|
* 이메일로 되돌아간다.
|
||||||
|
*/
|
||||||
|
function NotifyEmailSetting({placeId}: {placeId: string}) {
|
||||||
|
const {data: placeRes} = useGetPlace(placeId, {query: {enabled: !!placeId}});
|
||||||
|
const savedEmail = placeRes?.place?.notify_email ?? '';
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(savedEmail);
|
||||||
|
const updateMutation = useUpdatePlace();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValue(savedEmail);
|
||||||
|
}, [savedEmail]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
const res = await updateMutation.mutateAsync({placeId, data: {notify_email: value.trim()}});
|
||||||
|
if (!res.result?.success) {
|
||||||
|
notify.error(res.msg || '저장하지 못했습니다. 이메일 형식을 확인해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify.success('알림 이메일을 저장했습니다.');
|
||||||
|
setIsEditing(false);
|
||||||
|
} catch (error) {
|
||||||
|
notifyApiError(error, '저장하지 못했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm">
|
||||||
|
<span className="text-muted-foreground">승인 메일 받을 주소</span>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => setValue(event.target.value)}
|
||||||
|
placeholder="비우면 계정 이메일로 받습니다"
|
||||||
|
className="min-w-52 flex-1 rounded-md border border-border bg-card px-2 py-1 text-sm outline-none focus:border-ring focus:ring-1 focus:ring-ring"
|
||||||
|
/>
|
||||||
|
<Button size="sm" variant="primary" isLoading={updateMutation.isPending} onClick={handleSave}>
|
||||||
|
<Save />
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={updateMutation.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(false);
|
||||||
|
setValue(savedEmail);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="font-medium">{savedEmail || '(계정 이메일 사용)'}</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pencil />
|
||||||
|
변경
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이번 달(또는 고른 달) 생성된 미니 블로그 글. 기획: docs/MINI_BLOG.md
|
* 이번 달(또는 고른 달) 생성된 미니 블로그 글. 기획: docs/MINI_BLOG.md
|
||||||
*
|
*
|
||||||
@ -506,6 +623,7 @@ export function BlogPostsPage() {
|
|||||||
const generateMutation = useGenerateMyPosts();
|
const generateMutation = useGenerateMyPosts();
|
||||||
const generateOneMutation = useGenerateMyPostForDate();
|
const generateOneMutation = useGenerateMyPostForDate();
|
||||||
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
|
const generatingDay = generateOneMutation.isPending ? (generateOneMutation.variables?.params.date ?? null) : null;
|
||||||
|
const sendNowMutation = useSendMyPostsNow();
|
||||||
|
|
||||||
const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
|
const [rangeDialogOpen, setRangeDialogOpen] = useState(false);
|
||||||
const [rangeStart, setRangeStart] = useState(todayIso());
|
const [rangeStart, setRangeStart] = useState(todayIso());
|
||||||
@ -546,11 +664,31 @@ export function BlogPostsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendNow = async () => {
|
||||||
|
try {
|
||||||
|
const res = await sendNowMutation.mutateAsync({placeId});
|
||||||
|
if (!res.result?.success) {
|
||||||
|
notify.error(res.msg || '발송하지 못했습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notify.success(res.msg ?? '보냈습니다.');
|
||||||
|
refetchAll();
|
||||||
|
} catch (sendNowError) {
|
||||||
|
notifyApiError(sendNowError, '발송하지 못했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const generateButton = (
|
const generateButton = (
|
||||||
|
<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)}>
|
<Button size="sm" variant="primary" onClick={() => setRangeDialogOpen(true)}>
|
||||||
<Sparkles />
|
<Sparkles />
|
||||||
지금 생성하기
|
지금 생성하기
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const maxGenerateDate = lastDayOfMonthIso(maxMonth);
|
const maxGenerateDate = lastDayOfMonthIso(maxMonth);
|
||||||
@ -595,6 +733,8 @@ export function BlogPostsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NotifyEmailSetting placeId={placeId} />
|
||||||
|
|
||||||
{tab === 'main' && (
|
{tab === 'main' && (
|
||||||
<>
|
<>
|
||||||
{upcomingPosts.length > 0 ? (
|
{upcomingPosts.length > 0 ? (
|
||||||
|
|||||||
@ -291,8 +291,35 @@ export type WeatherBand = '혹서' | '더움' | '선선' | '쌀쌀' | '추움';
|
|||||||
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
|
/** 날씨를 넷으로만 가른다 — 문장과 그림이 갈리는 최소 단위다. */
|
||||||
export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
|
export type WeatherMood = '맑음' | '흐림' | '비' | '눈';
|
||||||
|
|
||||||
/** 그림은 WeatherMood 넷으로 그리고, 문구는 이 아홉으로 고른다. */
|
/** 그림은 WeatherMood 넷으로 그리고, 문구는 WMO weather_code 하나마다 고유한 이 28종으로 고른다. */
|
||||||
export type WeatherSky = WeatherMood | '구름많음' | '안개' | '이슬비' | '소나기' | '뇌우';
|
export type WeatherSky =
|
||||||
|
| WeatherMood
|
||||||
|
| '대체로 맑음'
|
||||||
|
| '구름 조금'
|
||||||
|
| '안개'
|
||||||
|
| '착빙성 안개'
|
||||||
|
| '가벼운 이슬비'
|
||||||
|
| '보통 이슬비'
|
||||||
|
| '강한 이슬비'
|
||||||
|
| '가벼운 착빙성 이슬비'
|
||||||
|
| '강한 착빙성 이슬비'
|
||||||
|
| '약한 비'
|
||||||
|
| '보통 비'
|
||||||
|
| '강한 비'
|
||||||
|
| '약한 착빙성 비'
|
||||||
|
| '강한 착빙성 비'
|
||||||
|
| '약한 눈'
|
||||||
|
| '보통 눈'
|
||||||
|
| '강한 눈'
|
||||||
|
| '싸라기눈'
|
||||||
|
| '약한 소나기'
|
||||||
|
| '보통 소나기'
|
||||||
|
| '강한 소나기'
|
||||||
|
| '약한 소나기눈'
|
||||||
|
| '강한 소나기눈'
|
||||||
|
| '뇌우'
|
||||||
|
| '약한 우박 뇌우'
|
||||||
|
| '강한 우박 뇌우';
|
||||||
|
|
||||||
export interface WeatherSnapshot {
|
export interface WeatherSnapshot {
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user