diff --git a/solution/backend/services/build_service.py b/solution/backend/services/build_service.py index 301112b..364b3ba 100644 --- a/solution/backend/services/build_service.py +++ b/solution/backend/services/build_service.py @@ -28,6 +28,7 @@ from services import ( alert_service, azure_static, indexnow, + mockup_blog, publish_gate, render_service, seo_keywords, @@ -136,6 +137,19 @@ async def _log(site_id, version_id, action: PublishAction, result: PublishResult await DB_SESSION_MNG.execute_lambda_run([site_publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)]) +async def _refresh_mockup_blog(place, place_id: str, slug: str) -> dict: + snapshot = await build_snapshot(place) + try: + count = mockup_blog.refresh_posts(slug, snapshot.get("posts") or []) + except mockup_blog.MockupBlogError as ex: + raise BuildAborted(str(ex)) from ex + await DB_SESSION_MNG.execute_lambda_run( + [place_posts.DBType()], + [lambda s: _post_crud.mark_published(s, uuid.UUID(place_id), None)], + ) + return {"place_id": place_id, "build_status": "BUILT", "mockup": slug, "posts": count} + + async def run_build(job: dict) -> dict: """BUILD 잡 핸들러.""" payload = job["payload"] @@ -153,6 +167,10 @@ async def run_build(job: dict) -> dict: site = await ensure_site(place_id) + mockup_slug = site_payload.publish_slug(place, site) + if mockup_blog.is_mockup(mockup_slug): + return await _refresh_mockup_blog(place, place_id, mockup_slug) + # 주변 정보(맛집·관광지·축제·코스)는 빌드 시점에 업장 좌표로 새로 받는다 — 발행본은 정적이라 이때 받은 값이 실린다. try: synced = await LocalContentService().sync_place(place) diff --git a/solution/backend/services/mockup_blog.py b/solution/backend/services/mockup_blog.py new file mode 100644 index 0000000..1ca79d2 --- /dev/null +++ b/solution/backend/services/mockup_blog.py @@ -0,0 +1,60 @@ +"""목업(stay · stay2)은 굽지 않고 index.html 의 미니블로그 글만 갈아 끼운다.""" +import json +import os +from pathlib import Path + +from common.logger import LOG +from services import render_service + +MOCKUP_SLUGS = frozenset({"stay", "stay2"}) +PAYLOAD_MARKER = "window.__SITE_PAYLOAD__" +BACKUP_NAME = "index.html.bak-blog" + + +class MockupBlogError(Exception): + pass + + +def is_mockup(slug: str) -> bool: + return slug in MOCKUP_SLUGS + + +def index_path(slug: str) -> Path: + return Path(render_service.output_dir()) / "s" / slug / "index.html" + + +def _payload_span(html: str) -> tuple[int, int, dict]: + marker = html.find(PAYLOAD_MARKER) + if marker < 0: + raise MockupBlogError("payload 가 없다") + start = html.index("{", marker) + payload, end = json.JSONDecoder().raw_decode(html, start) + return start, end, payload + + +def merge_posts(existing: list[dict], snapshot_posts: list[dict]) -> list[dict]: + merged = {post["postId"]: post for post in existing if post.get("postId")} + for row in snapshot_posts: + body = (row.get("body") or "").strip() + if not body: + continue + merged[row["post_id"]] = {"postId": row["post_id"], "body": row["body"], "publishedAt": row["published_at"]} + return sorted(merged.values(), key=lambda post: post.get("publishedAt") or "", reverse=True) + + +def refresh_posts(slug: str, snapshot_posts: list[dict]) -> int: + path = index_path(slug) + if not path.is_file(): + raise MockupBlogError(f"목업 파일이 없다: {path}") + html = path.read_text(encoding="utf-8") + start, end, payload = _payload_span(html) + payload["posts"] = merge_posts(payload.get("posts") or [], snapshot_posts) + encoded = json.dumps(payload, ensure_ascii=False).replace("", "<\\/") + updated = html[:start] + encoded + html[end:] + + (path.parent / BACKUP_NAME).write_text(html, encoding="utf-8") + tmp = path.with_name(f".{path.name}.tmp") + tmp.write_text(updated, encoding="utf-8") + os.replace(tmp, path) + LOG.i(f"[mockup-blog] {slug} 미니블로그 {len(payload['posts'])}건으로 갱신") + return len(payload["posts"]) diff --git a/solution/backend/tests/test_mockup_blog.py b/solution/backend/tests/test_mockup_blog.py new file mode 100644 index 0000000..00af934 --- /dev/null +++ b/solution/backend/tests/test_mockup_blog.py @@ -0,0 +1,80 @@ +import json +import uuid + +from sqlalchemy import text + +from common.enums import PostStatus +from crud.job_crud import JobQueue +from services import mockup_blog +from worker.handlers import build_handler +from worker.runner import Worker + +MOCKUP_HTML = ( + '