인앱 미니 블로그(AI 자동 포스트, 이메일 승인)·이용후기(즉시 게시)·예약 요청(메일 발송)을
새로 붙였고, 병행해서 /s/stay 목업과 발행 사이트 공통 렌더러(UnitsSection·FestivalSection·
LocalGuideSection·WeatherSection 등)의 UI 버그를 다수 고쳤다. 범위가 넓지만 한 주 분량
작업을 한 커밋으로 묶어 달라는 요청에 따라 하나로 묶는다.
- solution/backend: post/review/booking_request 라우터·서비스·CRUD 추가, 스케줄러에
블로그 초안 생성(새벽 4:10)·발송(아침 9:00) cron 등록, 마이그레이션 4건 추가
- solution/frontend, admin/frontend: 생성된 API 클라이언트 갱신, 리뷰 모더레이션·
블로그 글 관리 페이지 추가
- solution/site/src: 객실 상세+실시간예약(날짜선택·연락처 폼)을 모달로 통합, 축제·
주변안내 카드 클릭 시 모달 전환, 후기 목록 카드 UI, 공용 Modal 컴포넌트 신설,
날씨 문구 동기화 버그 수정(하늘줄·기온줄 한 타이머로), 시설·편의 가능/불가 아이콘
색상 하이라이트, 헤더 메뉴 순서를 실제 섹션 순서에 맞춤, 하단 탭바 아이콘 정렬 버그
(line-height) 수정, 추천일정 점선 연결+데스크톱 자동펼침/모바일 축소, 채널 라벨에
크롤링 원문("NOL")이 새던 것을 bookingLabel() 로 교체
- solution/site/scripts/mockup: /s/stay 패치 스크립트·주입 CSS·JS 다수 수정, stay4~6
빌드 스크립트 추가(다른 세션 작업)
테스트: solution/site `npx tsc --noEmit` 통과, `npx vitest run` 93 passed,
solution/backend `pytest tests/test_booking_request.py` 6 passed(로컬 DB 대상).
예약 요청 메일은 실제 발송까지 확인(place 66894a1b 소유자 이메일 누락을 DB에서 보정).
295 lines
13 KiB
Python
295 lines
13 KiB
Python
"""미니 블로그의 두 스윕 — 만들기와 보내기. 기획: docs/MINI_BLOG.md
|
|
|
|
★ 잡은 '대상을 고르는 것'까지만 하고 실제 일은 서비스가 한다(scheduler/jobs.py 규약).
|
|
★ 한 번에 BATCH_SIZE 건씩 만든다. 한 달치를 한 호출로 뽑으면 앞 회차 주제를 프롬프트에
|
|
못 넣어 중복이 막히지 않는다.
|
|
★ 팀 사전검수 없음 — 금칙 필터(blog_service.filter_drafts)를 통과하면 바로 REVIEWED 로
|
|
쌓이고, send_reviewed() 가 업장당 하루 한 통씩 그대로 사장님에게 보낸다.
|
|
★ 글마다 scheduled_date(KST) 를 하나씩 배정한다 — "언제 만들어졌나"만 있고 "언제 낼
|
|
것인가"가 없으면 달력 화면이 근거 없는 날짜를 지어내야 한다(2026-09-17).
|
|
"""
|
|
import uuid
|
|
from datetime import date, datetime, timedelta, timezone
|
|
|
|
from sqlalchemy import select
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import place_posts, places, sites, users
|
|
from common.enums import DBWRType, PostStatus, SiteStatus
|
|
from common.logger import LOG
|
|
from common.models.gmodel import UserInfo
|
|
from crud.post_crud import PostCRUD
|
|
from router.v1.validator.dependencies import CreateDayPassToken
|
|
from services import blog_service, mail_service, site_payload
|
|
from services.snapshot import build_snapshot
|
|
|
|
BATCH_SIZE = 30
|
|
# 이 수보다 재고(DRAFT)가 적은 업장만 새로 만든다 — 한 달치(하루 한 통 기준 약 30일)를 채운다.
|
|
REFILL_BELOW = 30
|
|
MAIL_PER_SWEEP = 20
|
|
|
|
_KST = timezone(timedelta(hours=9))
|
|
|
|
_crud = PostCRUD()
|
|
|
|
|
|
def _today_kst() -> date:
|
|
return datetime.now(_KST).date()
|
|
|
|
|
|
async def _published_places() -> list:
|
|
"""(place, user) 쌍 — user 전체를 준다. 메일에 email 뿐 아니라(대상 판정) 로그인
|
|
day-pass 토큰(id·role·token_version)도 만들어야 해서 email 만으로는 부족하다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(places, users)
|
|
.join(sites, sites.place_id == places.place_id)
|
|
.join(users, users.user_id == places.owner_user_id)
|
|
.where(
|
|
places.deleted == False, # noqa: E712
|
|
sites.deleted == False, # noqa: E712
|
|
sites.status == SiteStatus.PUBLISHED.value,
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(places.DBType(), DBWRType.DB_READ.value, query)
|
|
return list(result.all()) if result is not None else []
|
|
|
|
|
|
async def _pending_count(place_id) -> int:
|
|
"""아직 사장님에게 안 나간 재고 — 팀 사전검수가 없어 생성 즉시 REVIEWED 로 쌓인다."""
|
|
def query(session):
|
|
return session.execute(
|
|
select(place_posts.post_id).where(
|
|
place_posts.place_id == place_id,
|
|
place_posts.status.in_((PostStatus.DRAFT.value, PostStatus.REVIEWED.value)),
|
|
place_posts.deleted == False, # noqa: E712
|
|
)
|
|
)
|
|
|
|
result = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, query)
|
|
return len(result.all()) if result is not None else 0
|
|
|
|
|
|
async def _generate_for_place(place) -> int:
|
|
"""업장 하나. 재고가 이미 REFILL_BELOW 이상이면 아무것도 안 만든다(만든 수 0)."""
|
|
if await _pending_count(place.place_id) >= REFILL_BELOW:
|
|
return 0
|
|
|
|
used = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
)
|
|
used_set = set(used or [])
|
|
snapshot = await build_snapshot(place)
|
|
region = site_payload.region_label(place.road_address, place.address)
|
|
|
|
rows = []
|
|
for kind, key, material in blog_service.materials(snapshot):
|
|
if len(rows) >= BATCH_SIZE:
|
|
break
|
|
if key in used_set:
|
|
continue
|
|
generated = await blog_service.generate_one(
|
|
place_name=place.name, region=region, topic_kind=kind,
|
|
material=material, used_topics=sorted(used_set),
|
|
)
|
|
if not generated:
|
|
continue
|
|
body, model = generated
|
|
rows.append({
|
|
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
"generation_meta": {"model": model},
|
|
})
|
|
used_set.add(key)
|
|
|
|
kept, dropped = blog_service.filter_drafts(rows)
|
|
if kept:
|
|
latest = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.max_scheduled_date(s, pid),
|
|
)
|
|
next_date = max(latest + timedelta(days=1), _today_kst()) if latest else _today_kst()
|
|
for offset, row in enumerate(kept):
|
|
row["scheduled_date"] = next_date + timedelta(days=offset)
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=kept: _crud.add_many(s, r)],
|
|
)
|
|
if dropped:
|
|
LOG.i(f"[blog] place={place.place_id} 버린 {len(dropped)}건 — {dropped[0][1]}")
|
|
return len(kept)
|
|
|
|
|
|
async def generate_drafts() -> int:
|
|
"""재고가 모자란 업장마다 최대 BATCH_SIZE 건. 만든 수를 돌려준다."""
|
|
made = 0
|
|
for place, _user in await _published_places():
|
|
made += await _generate_for_place(place)
|
|
return made
|
|
|
|
|
|
async def generate_range(place_id: str, start_date: date, end_date: date) -> dict:
|
|
"""사장님이 빌더 화면에서 직접 누르는 즉시 생성 — 이번엔 구간을 직접 고른다
|
|
(2026-09-17, 사장님 지시: "지금 생성하기에서 시작이랑 끝 날짜를 정해야하지 않을까").
|
|
재고 상한(REFILL_BELOW)을 안 본다 — 개별 생성과 같은 이유로, 직접 고른 구간에
|
|
상한 로직이 끼어들 자리가 아니다. 이미 글이 있는 날짜는 LLM 을 부르지 않고 건너뛴다 —
|
|
매번 새로 만들고 유니크 충돌로 버리면 호출만 낭비된다. 소재가 떨어지면(구간 안에서
|
|
더 만들 topic_key 가 없으면) 그 자리에서 멈춘다 — 못 채운 나머지는 그대로 빈 날짜로 남는다."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return {"requested": 0, "created": 0}
|
|
|
|
requested = (end_date - start_date).days + 1
|
|
dates = [start_date + timedelta(days=i) for i in range(requested)]
|
|
|
|
_err, existing_rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.list_for_place(s, pid, start_date, end_date + timedelta(days=1)),
|
|
)
|
|
taken = {row.scheduled_date for row in existing_rows}
|
|
empty_dates = [d for d in dates if d not in taken]
|
|
if not empty_dates:
|
|
return {"requested": requested, "created": 0}
|
|
|
|
used = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
)
|
|
used_set = set(used or [])
|
|
snapshot = await build_snapshot(place)
|
|
region = site_payload.region_label(place.road_address, place.address)
|
|
|
|
rows = []
|
|
for kind, key, material in blog_service.materials(snapshot):
|
|
if len(rows) >= len(empty_dates):
|
|
break
|
|
if key in used_set:
|
|
continue
|
|
generated = await blog_service.generate_one(
|
|
place_name=place.name, region=region, topic_kind=kind,
|
|
material=material, used_topics=sorted(used_set),
|
|
)
|
|
if not generated:
|
|
continue
|
|
body, model = generated
|
|
ok, _reason = blog_service.is_publishable_body(body)
|
|
used_set.add(key)
|
|
if not ok:
|
|
continue
|
|
rows.append({
|
|
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
"generation_meta": {"model": model}, "status": PostStatus.REVIEWED.value,
|
|
})
|
|
|
|
for row, d in zip(rows, empty_dates):
|
|
row["scheduled_date"] = d
|
|
if rows:
|
|
await DB_SESSION_MNG.execute_lambda_run(
|
|
[place_posts.DBType()], [lambda s, r=rows: _crud.add_many(s, r)],
|
|
)
|
|
return {"requested": requested, "created": len(rows)}
|
|
|
|
|
|
async def generate_one_for_date(place_id: str, target_date: date) -> dict | None:
|
|
"""개별 생성 — 달력에서 빈 날짜 하나를 사장님이 콕 집어 채운다(2026-09-17, 사장님 지시:
|
|
"개별적으로 새로 만들수있게 해줘"). 재고 상한(REFILL_BELOW)을 안 본다 — 특정 날짜를
|
|
지정한 요청이라 상한 로직이 끼어들 자리가 아니다. 그 날짜가 이미 차 있으면 None."""
|
|
place = None
|
|
for p, _user in await _published_places():
|
|
if str(p.place_id) == str(place_id):
|
|
place = p
|
|
break
|
|
if place is None:
|
|
return None
|
|
|
|
used = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s, pid=place.place_id: _crud.used_topic_keys(s, pid),
|
|
)
|
|
used_set = set(used or [])
|
|
snapshot = await build_snapshot(place)
|
|
region = site_payload.region_label(place.road_address, place.address)
|
|
|
|
for kind, key, material in blog_service.materials(snapshot):
|
|
if key in used_set:
|
|
continue
|
|
generated = await blog_service.generate_one(
|
|
place_name=place.name, region=region, topic_kind=kind,
|
|
material=material, used_topics=sorted(used_set),
|
|
)
|
|
if not generated:
|
|
continue
|
|
body, model = generated
|
|
ok, _reason = blog_service.is_publishable_body(body)
|
|
if not ok:
|
|
used_set.add(key) # 이 주제는 이번 시도에서 다시 고르지 않는다
|
|
continue
|
|
|
|
row = {
|
|
"place_id": place.place_id, "body": body, "topic_kind": kind, "topic_key": key,
|
|
"scheduled_date": target_date, "generation_meta": {"model": model},
|
|
"status": PostStatus.REVIEWED.value, # 금칙 필터를 이미 통과했다 — 벌크 경로(filter_drafts)와 동일
|
|
}
|
|
inserted = await DB_SESSION_MNG.execute_lambda_write(
|
|
place_posts.DBType(), lambda s, r=row: _crud.add_one(s, r),
|
|
)
|
|
return inserted # None 이면 그 날짜(또는 주제)가 이미 차 있었다 — 다시 시도하지 않는다
|
|
return None
|
|
|
|
|
|
def _mail_body(*, place_name: str, post, user, origin: str, approve_token: str) -> str:
|
|
"""승인(누르면 바로 게재) · 수정(빌더 앱 로그인 상태로 그 글 편집 모달) 두 링크만 둔다
|
|
(2026-09-17, 사장님 지시: "승인이랑 수정하기 있어야해"). 둘 다 오늘 자정(KST)에
|
|
만료된다(2026-09-17, 사장님 지시: "승인이랑 수정모두 자정에 만료") — 그 뒤로는
|
|
로그인해서 빌더 앱에서 처리한다. 수정 링크는 토큰 하나짜리 공개 편집 화면 대신,
|
|
실제 로그인 세션으로 빌더 앱의 편집 모달을 그대로 연다."""
|
|
user_info = UserInfo(user_id=str(user.user_id), id=user.id, role=user.role, token_version=user.token_version)
|
|
auto_token = CreateDayPassToken(user_info)
|
|
edit_link = f"{origin}/blog?placeId={post.place_id}&postId={post.post_id}&auto={auto_token}"
|
|
approve_link = f"{origin}/v1/site/post/approve?t={approve_token}"
|
|
return (
|
|
f"{place_name} 사이트에 올릴 글을 준비했습니다.\n\n"
|
|
f"{post.body}\n\n"
|
|
f"이대로 올리려면(누르면 바로 게재됩니다):\n{approve_link}\n\n"
|
|
f"고쳐서 올리려면:\n{edit_link}\n\n"
|
|
f"두 링크 모두 오늘 자정(KST)에 만료됩니다. 그 뒤엔 로그인해서 빌더 앱에서 처리해 주세요.\n"
|
|
f"— 이 메일은 Web4AI 가 자동으로 보냈습니다."
|
|
)
|
|
|
|
|
|
async def send_reviewed() -> int:
|
|
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
|
|
if not mail_service.is_configured():
|
|
return 0
|
|
|
|
_err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_posts.DBType(), DBWRType.DB_READ.value,
|
|
lambda s: _crud.due_for_mail(s, PostStatus.REVIEWED.value, _today_kst(), MAIL_PER_SWEEP),
|
|
)
|
|
if not rows:
|
|
return 0
|
|
|
|
targets = {str(place.place_id): (place.name, user) for place, user in await _published_places()}
|
|
origin = site_payload.publish_origin()
|
|
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 ""):
|
|
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:
|
|
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)],
|
|
)
|
|
sent += 1
|
|
return sent
|