최상단을 프로젝트 단위로 평평하게 둔다 — o2o-negosium 과 같은 규약이고, 이 레포만
다르게 갈 이유가 없다. negodata/{backend,front} 가 프로젝트 안에서 f/b 를 가르는 선례,
lps-admin/ 이 백엔드 없이 프론트만 가진 최상단 폴더의 선례다.
backend/ frontend/{admin,site,shared} → solution/{backend,front,site,shared} + admin/
## 왜
내부 라우트(/local-content, /places/:id/seo)의 이름과 화면 코드가 사장님 번들에
그대로 실려 나가고 있었다. UserRole.DEVELOPER 주석의 "고객사에 존재를 노출하지 않는다"를
번들이 깨고 있었다 — 라우트 가드는 화면을 가리지 번들은 못 가린다.
번들을 갈라 확인했다: 사장님 dist 에서 local-content · /places · SeoAudit 이 전부 0건이다.
그 과정에서 두 곳이 더 새고 있었다.
- AppShell 의 NAV 배열이 내부 메뉴를 하드코딩하고 있었다. 앱을 가른 뒤에도 dist 에
local-content 가 남아서 찾았다. 메뉴는 이제 앱이 prop 으로 들고 온다.
- EditorHeader·BuilderPage·LoginPage 가 /places 로 링크하고 있었다. 그 화면이 admin 으로
나갔으니 사장님 앱에서는 404 다. 링크를 걷어내고 LoginPage 기본 도착지는 '/' 로 바꿨다
(앱마다 홈이 다르고 각 라우터의 '/' 가 이미 그걸 안다).
## admin 에 백엔드를 두지 않았다
내부 화면이 부르는 훅이 전부 router/v1/{place,fact,local,validator} 에 이미 있다.
자체 백엔드를 두면 place·fact·link 를 같은 DB 에 대고 두 번 구현하게 된다.
대가는 solution/backend 가 죽으면 admin 도 멈추는 것 — 내부 도구라 감수한다.
## admin 의 `@` 는 solution/front/src 를 가리킨다
내부 화면이 쓰는 API 클라이언트·UI·수집 배선이 solution 에 한 벌만 있고 그 파일들끼리도
`@/...` 로 서로를 부른다. admin 에서 `@` 를 자기 src 로 잡으면 그 참조가 전부 깨진다
(실측 TS2307 14건). 복제하는 길도 있지만 RecollectPanel 주석이 금지한다 —
"수집 경로를 두 벌 만들면 확정 게이트"가 갈라진다.
admin 자기 파일만 `@admin` 이고, 의존 방향은 admin → solution 한 쪽뿐이다.
admin 이 여는 빌더는 다른 오리진이라 절대 URL + 새 탭이다(admin/src/lib/solutionUrl.ts).
react-router Link 로 두면 admin 안에서 라우트를 찾다 404 다.
## 그 밖
- npm 워크스페이스 루트를 레포 루트로 올렸다(admin 이 solution 밖이라).
- docker-compose 를 255→174줄로 줄이고 admin(:3002) 서비스를 넣었다. ADMIN_BIND 기본값은
127.0.0.1 — 0.0.0.0 으로 열면 앱을 가른 의미가 없다.
- 발행 호스트를 프론트 .env 에 따로 적지 않는다. compose 가 루트의 SITE_PUBLIC_HOST 를
VITE_PUBLISH_HOST 로 흘려보낸다 — 두 곳에 적으면 canonical 과 화면 주소가 조용히 갈라진다.
- nginx/site.conf 를 git 에서 빼고 .example 만 남겼다(.env·*.toml 과 같은 규약).
compose 가 bind mount 하므로 클론 직후 복사해야 한다 — 없으면 Docker 가 그 자리에
디렉토리를 만들어 nginx 가 설정 없이 뜬다.
- config.test.toml.example 을 추가했다. 없으면 클론한 사람이 pytest 를 아예 못 돌린다
(conftest import 단계에서 죽는다). 외부 API 키는 전부 빈값이다 —
APP_ENV=test 가 .env 를 안 읽는 이유를 여기서 우회하면 안 된다.
- 경로가 한 칸 깊어져 test_schema_ddl(parents[2]→[3]) 과 test_site_theme 을 고쳤다.
검증: front·admin·site 전부 lint 0 / build 0. 백엔드 514 passed.
남은 4건(test_build_publish 3 · test_snapshot 1)은 이 변경 전부터 실패하던 것으로,
손대지 않은 메인 체크아웃에서 같은 4건이 같게 실패하는 것을 확인했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uYhHQdssRubirPirrdJJC
292 lines
14 KiB
Python
292 lines
14 KiB
Python
"""정적 빌드 + 발행 — BUILD 잡이 하는 일.
|
|
|
|
스냅샷 조립 → 빌드(HTML + JSON-LD) → 발행 검수 게이트 → site_version 기록
|
|
|
|
★ 정적 빌드다. DB 는 여기서만 읽고, 그 결과가 site_versions.snapshot 에 박제된다.
|
|
방문자는 DB 와 만나지 않는다.
|
|
★ 개별 재빌드 단위다. 사이트 1,000개에서 전체 재빌드는 못 쓴다 —
|
|
places.content_updated_at 이 바뀐 사업장만 다시 빌드하면 된다.
|
|
★ 게이트를 통과하지 못하면 버전은 FAILED 로 남고 발행되지 않는다. 사유가 publish_logs 에 남는다.
|
|
"""
|
|
import os
|
|
import uuid
|
|
|
|
from sqlalchemy import select
|
|
|
|
from common.database.db_session_manager import DB_SESSION_MNG
|
|
from common.database.model.models import place_links, places, publish_logs, site_versions, sites
|
|
from common.enums import (
|
|
BuildStatus,
|
|
DBWRType,
|
|
ErrorType,
|
|
PlaceCategory,
|
|
PlaceStatus,
|
|
PublishAction,
|
|
PublishResult,
|
|
SiteStatus,
|
|
)
|
|
from common.logger import LOG
|
|
from common.utils.gtime import GTime
|
|
from crud.site_crud import SiteCRUD
|
|
from crud.place_crud import PlaceCRUD
|
|
from services import azure_static, indexnow, publish_gate, render_report, site_payload
|
|
from services.site_payload import emit_payload
|
|
from services.snapshot import build_snapshot
|
|
|
|
_site_crud = SiteCRUD()
|
|
_place_crud = PlaceCRUD()
|
|
|
|
# 렌더러가 이 버전을 굽고 보고서를 돌려줄 때까지 기다리는 시간.
|
|
# ★ 넉넉해야 한다. 짧으면 멀쩡한 발행이 "렌더 대기 초과"로 실패한다 —
|
|
# 렌더러는 payload 를 2초 주기로 보고, 사이트 하나 굽는 데 1초 남짓 걸린다.
|
|
RENDER_TIMEOUT_SEC = float(os.environ.get("RENDER_TIMEOUT_SEC") or 180)
|
|
|
|
|
|
class BuildAborted(RuntimeError):
|
|
"""재시도해도 소용없는 중단 — 잡의 last_error 로 남는다."""
|
|
|
|
|
|
async def ensure_site(place_id: str) -> "sites":
|
|
"""사업장의 사이트 행을 보장한다(없으면 만든다). 사업장당 1개."""
|
|
pid = uuid.UUID(place_id)
|
|
err, site = await DB_SESSION_MNG.execute_lambda(
|
|
sites.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.get_site_by_place(s, pid)
|
|
)
|
|
if err == ErrorType.SUCCESS and site is not None:
|
|
return site
|
|
row = sites(place_id=pid, status=SiteStatus.DRAFT.value)
|
|
run_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[sites.DBType()], [lambda s: _site_crud.add_site(s, row)]
|
|
)
|
|
if run_err != ErrorType.SUCCESS:
|
|
raise BuildAborted(f"사이트 생성 실패: {run_err.name}")
|
|
return row
|
|
|
|
|
|
async def _load_links(place_id: str) -> list:
|
|
"""채널 링크(야놀자·네이버 플레이스·인스타…).
|
|
|
|
스냅샷에 담기지 않는 유일한 발행 재료라 여기서 읽어 payload 로 넘긴다.
|
|
★ 실패해도 빈 목록으로 진행한다 — 링크가 없다고 발행을 막을 이유가 없다."""
|
|
err, rows = await DB_SESSION_MNG.execute_lambda(
|
|
place_links.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: DB_SESSION_MNG.execute(
|
|
s,
|
|
select(place_links).where(
|
|
place_links.place_id == uuid.UUID(place_id),
|
|
place_links.deleted == False, # noqa: E712
|
|
),
|
|
),
|
|
)
|
|
return list(rows) if err == ErrorType.SUCCESS else []
|
|
|
|
|
|
async def _log(site_id, version_id, action: PublishAction, result: PublishResult, gate=None, actor=None):
|
|
"""발행 시도를 기록한다. 거부됐으면 사유와 상세를 그대로 남긴다 — 운영자가 뭘 고칠지 알아야 한다."""
|
|
row = publish_logs(
|
|
site_id=site_id,
|
|
site_version_id=version_id,
|
|
action=action.value,
|
|
result=result.value,
|
|
reject_reason=(gate.reason.value if gate is not None and gate.reason else None),
|
|
detail=(gate.as_log() if gate is not None and not gate.passed else None),
|
|
actor_user_id=uuid.UUID(actor) if actor else None,
|
|
)
|
|
await DB_SESSION_MNG.execute_lambda_run([publish_logs.DBType()], [lambda s: _site_crud.add_log(s, row)])
|
|
|
|
|
|
async def run_build(job: dict) -> dict:
|
|
"""BUILD 잡 핸들러. payload: {place_id, company_id, publish?, requested_by?}
|
|
|
|
publish=True 면 게이트를 통과했을 때 바로 발행까지 한다."""
|
|
payload = job["payload"]
|
|
place_id = payload["place_id"]
|
|
company_id = payload["company_id"]
|
|
want_publish = bool(payload.get("publish"))
|
|
|
|
err, place = await DB_SESSION_MNG.execute_lambda(
|
|
places.DBType(),
|
|
DBWRType.DB_READ.value,
|
|
lambda s: _place_crud.get_place(s, uuid.UUID(company_id), uuid.UUID(place_id)),
|
|
)
|
|
if err != ErrorType.SUCCESS or place is None:
|
|
raise BuildAborted(f"사업장을 찾을 수 없다: {place_id}")
|
|
|
|
site = await ensure_site(place_id)
|
|
snapshot = await build_snapshot(place)
|
|
|
|
v_err, version_no = await DB_SESSION_MNG.execute_lambda(
|
|
site_versions.DBType(), DBWRType.DB_READ.value, lambda s: _site_crud.next_version_no(s, site.site_id)
|
|
)
|
|
if v_err != ErrorType.SUCCESS:
|
|
raise BuildAborted(f"버전 번호 조회 실패: {v_err.name}")
|
|
|
|
version = site_versions(
|
|
site_id=site.site_id,
|
|
version=version_no,
|
|
build_status=BuildStatus.BUILDING.value,
|
|
snapshot=snapshot,
|
|
)
|
|
add_err = await DB_SESSION_MNG.execute_lambda_run(
|
|
[site_versions.DBType()], [lambda s: _site_crud.add_version(s, version)]
|
|
)
|
|
if add_err != ErrorType.SUCCESS:
|
|
raise BuildAborted(f"버전 생성 실패: {add_err.name}")
|
|
|
|
result = {"place_id": place_id, "site_id": str(site.site_id), "version": version_no,
|
|
"site_version_id": str(version.site_version_id)}
|
|
now = GTime.UTC()
|
|
|
|
async def _fail(reason: str, gate: publish_gate.GateResult | None = None, extra: dict | None = None):
|
|
"""버전을 FAILED 로 남기고 사유를 기록한다. 발행하지 않는다."""
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
site_versions.DBType(),
|
|
lambda s: _site_crud.finish_version(
|
|
s, version.site_version_id,
|
|
{"build_status": BuildStatus.FAILED.value, "build_error": reason[:2000], **(extra or {})},
|
|
),
|
|
)
|
|
action = PublishAction.PUBLISH if gate is not None else PublishAction.REBUILD
|
|
outcome = PublishResult.REJECTED if gate is not None else PublishResult.FAILED
|
|
await _log(site.site_id, version.site_version_id, action, outcome, gate, payload.get("requested_by"))
|
|
result["build_status"] = "FAILED"
|
|
result["error"] = reason
|
|
LOG.w(f"[build] place={place_id} v{version_no} 실패: {reason}")
|
|
return result
|
|
|
|
# ---- 1차 게이트: 렌더 없이 판정 가능한 것 ----
|
|
# ★ 미검증 fact 는 payload 를 쓰기 전에 막는다. 렌더러에 넘긴 뒤에 막으면 검증 안 된 값이
|
|
# 디스크에 한 번 나갔다 들어오는 셈이 된다.
|
|
place_name = str((snapshot.get("place") or {}).get("name") or "").strip()
|
|
if not place_name:
|
|
return await _fail("상호명이 없다 — 사이트를 만들 수 없다")
|
|
if (snapshot.get("place") or {}).get("category") is None:
|
|
return await _fail("업종이 없다 — 어떤 스키마로 렌더할지 알 수 없다")
|
|
|
|
facts_gate = publish_gate.check_facts_verified(snapshot["facts"])
|
|
if not facts_gate.passed:
|
|
result["gate"] = {"passed": False, **facts_gate.as_log()}
|
|
return await _fail(f"{facts_gate.reason.name}: {facts_gate.as_log()}", facts_gate)
|
|
|
|
# ---- 렌더러에 넘긴다 ----
|
|
# ★ 여기가 "발행 기록"과 "실제 페이지"를 잇는 자리다. 렌더러(solution/site)의 유일한 입력이
|
|
# 이 payload JSON 이고, 그게 굽는 HTML 이 방문자와 크롤러가 보는 유일한 페이지다.
|
|
# ★ payload 에 실릴 발행 상태를 미리 맞춘다. 렌더러는 이 값으로 datePublished 를 굽는데,
|
|
# 발행 뒤에 payload 를 쓰던 예전 순서에서는 그게 채워져 있었다. 순서가 바뀌었다고
|
|
# 페이지의 발행일이 비면 AI 검색이 보는 신선도 신호가 사라진다.
|
|
# ★ 게이트에서 떨어지면 DB 에는 반영하지 않는다(아래에서 실제로 쓸 때만 저장한다).
|
|
if want_publish:
|
|
site.status = SiteStatus.PUBLISHED.value
|
|
site.published_at = site.published_at or now
|
|
|
|
links = await _load_links(place_id)
|
|
payload_path = emit_payload(place, snapshot, site, version, links)
|
|
if not payload_path:
|
|
return await _fail("payload 를 쓰지 못했다 — 렌더러에 넘길 입력이 없다")
|
|
result["payload_path"] = payload_path
|
|
|
|
slug = site_payload.publish_slug(place, site)
|
|
|
|
# ---- 렌더 결과를 기다린다 ----
|
|
# ★ 게이트는 **실제로 나갈 HTML** 을 보고 판정해야 한다. 렌더러가 자기 산출물을 대조해
|
|
# 구조화 데이터 불일치와 고유 콘텐츠 수를 보고서로 돌려준다.
|
|
report = await render_report.wait_for(slug, version_no, RENDER_TIMEOUT_SEC)
|
|
if report is None:
|
|
# ★ 발행하지 않는다. 페이지가 있는지 확인하지 못한 채 "발행됨"으로 남기면
|
|
# 사장님은 [사이트 열기] 를 눌러야 404 로 알게 된다.
|
|
return await _fail(
|
|
f"렌더 결과를 {RENDER_TIMEOUT_SEC}초 안에 받지 못했다 — 렌더러가 도는지 확인이 필요하다"
|
|
)
|
|
|
|
mismatches = list(report.get("mismatches") or [])
|
|
unique_count = report.get("uniqueContentCount") or 0
|
|
jsonld = report.get("jsonld") or []
|
|
result["unique_content_count"] = unique_count
|
|
result["mismatches"] = mismatches[:20]
|
|
stamp = {"jsonld": jsonld, "unique_content_count": unique_count}
|
|
|
|
# ---- 2차 게이트: 렌더 산출물 기준 ----
|
|
# ★ 렌더가 실패했더라도 게이트를 **먼저** 돌린다. 렌더러가 페이지 쓰기를 거부한 이유가
|
|
# 대개 게이트 사유(고유 콘텐츠 0건·구조화 데이터 불일치)이기 때문이다.
|
|
# 여기서 사유를 정확히 골라야 publish_logs 에 '무엇을 고쳐야 하는지' 가 남는다 —
|
|
# 전부 "렌더 실패"로 뭉뚱그리면 운영자가 손댈 곳을 알 수 없다.
|
|
gate = publish_gate.evaluate(PlaceCategory(place.category), snapshot["facts"], unique_count, mismatches)
|
|
result["gate"] = {"passed": gate.passed, **gate.as_log()}
|
|
|
|
if not gate.passed:
|
|
return await _fail(f"{gate.reason.name}: {gate.as_log()}", gate, stamp)
|
|
|
|
if not report.get("ok"):
|
|
# 게이트로 설명되지 않는 실패(디스크·번들·payload 파손). 재시도가 의미 있는 쪽이다.
|
|
return await _fail(str(report.get("error") or "렌더 실패"), None, stamp)
|
|
|
|
# DB 발행 상태를 바꾸기 전에 정적 파일을 외부 저장소에 올린다.
|
|
if want_publish:
|
|
try:
|
|
azure_result = await azure_static.publish(slug)
|
|
except azure_static.AzurePublishError as ex:
|
|
return await _fail(str(ex), None, stamp)
|
|
if azure_result:
|
|
result["azure"] = azure_result
|
|
# ★ 정적 파일이 올라간 **뒤에** 통보한다. 먼저 알리면 크롤러가 옛 파일을 가져간다.
|
|
# 실패해도 발행은 성공이다 — 색인 통보는 부수 효과이고, 다음 발행에서 다시 보낸다.
|
|
indexnow_result = await indexnow.submit(slug)
|
|
if indexnow_result:
|
|
result["indexnow"] = indexnow_result
|
|
|
|
# ---- 빌드 성공 ----
|
|
# ★ jsonld·고유콘텐츠 수는 **렌더러가 실제로 내보낸 값**이다. 백엔드가 따로 계산하지 않는다 —
|
|
# 따로 계산하던 시절엔 게이트가 통과시킨 근거와 실제 페이지가 어긋날 수 있었다.
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
site_versions.DBType(),
|
|
lambda s: _site_crud.finish_version(
|
|
s, version.site_version_id,
|
|
{
|
|
"build_status": BuildStatus.BUILT.value,
|
|
"jsonld": jsonld,
|
|
"unique_content_count": unique_count,
|
|
"built_at": now,
|
|
"build_error": None,
|
|
},
|
|
),
|
|
)
|
|
result["build_status"] = "BUILT"
|
|
result["routes"] = report.get("routes")
|
|
|
|
if want_publish:
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
sites.DBType(),
|
|
lambda s: _site_crud.update_site(
|
|
s, site.site_id,
|
|
{"status": SiteStatus.PUBLISHED.value, "current_version_id": version.site_version_id,
|
|
"published_at": now},
|
|
),
|
|
)
|
|
# ★ 사업장 상태도 같이 올린다. 여기서 안 올리면 places.status 는 영원히 REVIEW 라,
|
|
# 발행을 마친 가게가 사업장 목록에서 '발행 전'으로 남는다 — 사장님은 목록만 보고
|
|
# 자기 사이트가 나갔는지 알 수 없다. 목록은 사이트 행을 읽지 않는다(N+1).
|
|
await DB_SESSION_MNG.execute_lambda_claim(
|
|
places.DBType(),
|
|
lambda s: _place_crud.update_place(
|
|
s, uuid.UUID(company_id), uuid.UUID(place_id), {"status": PlaceStatus.PUBLISHED.value}
|
|
),
|
|
)
|
|
await _log(site.site_id, version.site_version_id, PublishAction.PUBLISH, PublishResult.SUCCESS, None,
|
|
payload.get("requested_by"))
|
|
site.status = SiteStatus.PUBLISHED.value
|
|
site.current_version_id = version.site_version_id
|
|
site.published_at = now
|
|
result["published"] = True
|
|
LOG.i(f"[build] place={place_id} v{version_no} 발행 완료 "
|
|
f"(고유 콘텐츠 {unique_count}건 · {report.get('routes')} 페이지)")
|
|
else:
|
|
await _log(site.site_id, version.site_version_id, PublishAction.REBUILD, PublishResult.SUCCESS, None,
|
|
payload.get("requested_by"))
|
|
LOG.i(f"[build] place={place_id} v{version_no} 빌드 완료(미발행)")
|
|
|
|
version.build_status = BuildStatus.BUILT.value
|
|
version.built_at = now
|
|
return result
|
|
|