feat(cafe24): 예약관리 — 지정 시각에 상세페이지·진열/판매 자동 적용 (Phase 5)

되돌리기(자동 복원)는 요청대로 만들지 않았다. 예약은 "그 시각에 이 내용을 적용"
하나뿐이며, 한 예약에서 상세페이지 HTML·진열·판매를 각각 고를 수 있다. 셋 다
"변경 없음"인 예약은 DB CHECK 로 막는다.

등록은 편집기 아래 「예약 적용」에서 한다. HTML 을 적용하는 예약이면 그 시점의 편집기
내용을 DRAFT revision 으로 저장해 고정한다 — 이후 편집기를 더 고쳐도 예약된 내용이
바뀌지 않아야 한다. 단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로 화면에서
본 값이 그대로 저장된다. 예약 폼은 적용 폼과 형제로 두고(폼 중첩 불가) 편집기 내용을
JS 가 hidden 에 복사한다.

실행은 web 이 아니라 worker 다(app/modules/cafe24/worker.py, compose 서비스
dbx-cafe24-worker, --loop 60). 웹 요청 안에서 기다리면 프록시 타임아웃·재기동에
무너지고, 브라우저를 닫으면 실행되지 않는다.

worker 는 claim_due_schedule 로 한 건씩 FOR UPDATE SKIP LOCKED 로 잠그고 PROCESSING
으로 바꾼 뒤 잠금을 푼다. worker 가 둘 떠도 같은 예약을 두 번 적용하지 않고, 긴 API
호출 동안 DB 잠금을 쥐지 않는다. 적용 순서는 화면 편집과 같다(현재값 재조회 → BACKUP
→ PUT → 감사로그). HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽지도 백업하지도
않는다. 실패는 1분→5분→15분 재시도 후 FAILED 확정이며, 한 건의 오류로 worker 가
죽지 않는다.

진열/판매를 한 번의 PUT 으로 함께 보내려고 products.update_product 를 추가했다
(update_descriptions 는 이 함수로 위임). None 인 필드는 payload 에서 빼므로 "건드리지
않음"이 그대로 표현된다.

DB: scripts/sql/cafe24_db_002_schedule_flags.sql (멱등) — set_display/set_selling
BOOLEAN NULL 추가 + 아무것도 하지 않는 예약 금지 제약. 되돌리기용 end_* 컬럼은 쓰지
않지만 삭제하지 않는다(파괴적).

시각은 KST 로 해석한다(datetime-local 은 타임존이 없다). 과거는 거부하되 폼을 채우는
동안 시간이 흐른 경우를 위해 1분 여유를 뒀다.

검증: 유닛테스트 66개 통과(신규 15개 — 3-상태 파싱, KST 해석·과거 거부·1분 여유,
요약 문구, payload 의 T/F 와 None 생략, 바꿀 것 없으면 미호출, worker 의 성공 경로
(백업+PC/모바일 동시+진열만 전송)·상태만 변경 시 백업 생략·재시도 후 최종 실패·
버전 누락 시 크래시 대신 실패·처리할 것 없을 때 종료). 예약 목록/편집기 예약 폼 렌더 확인.
라우트 16개. 실제 예약 실행은 서버 배포 후 확인 필요.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 16:06:48 +09:00
parent b2a70ad0ab
commit 6dae0e45c9
16 changed files with 1157 additions and 69 deletions
+198
View File
@@ -0,0 +1,198 @@
"""카페24 예약 실행 worker.
python -m app.modules.cafe24.worker --loop 60 # 60초마다 확인 (운영)
python -m app.modules.cafe24.worker --once # 한 번만 처리하고 종료
왜 별도 프로세스인가
예약은 브라우저를 닫아도, 아무도 화면을 보고 있지 않아도 그 시각에 실행돼야 한다.
웹 요청 안에서 기다리는 방식은 프록시 타임아웃·재기동에 그대로 무너진다.
compose 서비스 `dbx-cafe24-worker` 가 web 과 같은 이미지로 이 모듈을 돌린다.
한 번에 한 건씩 처리한다
`claim_due_schedule` 이 `FOR UPDATE SKIP LOCKED` 로 한 건을 잠그고 PROCESSING 으로
바꾼다. worker 가 실수로 두 개 떠도 같은 예약이 두 번 적용되지 않는다.
적용 순서는 화면 편집과 같다
카페24 현재값 재조회 → BACKUP revision → PUT → SUCCESS + 감사로그
실패하면 재시도 예산(store.MAX_RETRY) 안에서 간격을 두고 다시 시도하고,
소진되면 FAILED 로 확정한다. 되돌리기는 쓰지 않는다.
토큰 갱신은 TokenService 가 행 잠금 안에서 하므로 web 과 동시에 떠 있어도 안전하다.
"""
from __future__ import annotations
import argparse
import logging
import os
import sys
import time
from datetime import timedelta
from typing import Any
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from app.timezone import now_kst
from . import store
logger = logging.getLogger("cafe24.worker")
ACTOR = "SCHEDULER"
def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
"""예약 1건 적용. 성공 시 사람이 읽을 요약 문자열."""
schedule_id = int(row["id"])
product_no = int(row["product_no"])
revision_id = row.get("revision_id")
set_display = row.get("set_display")
set_selling = row.get("set_selling")
html: str | None = None
if revision_id:
revision = store_db.get_revision(int(revision_id))
if not revision:
raise Cafe24Error(f"예약이 가리키는 버전 {revision_id} 을 찾을 수 없습니다.")
html = revision.get("html_content") or ""
if not html.strip():
raise Cafe24Error(f"버전 {revision_id} 의 내용이 비어 있습니다.")
# 상세설명을 바꿀 때는 쓰기 직전 현재값을 읽어 백업한다(로컬 값을 믿지 않는다).
backup_id = 0
mobile_html: str | None = None
if html is not None:
current = products.fetch_descriptions(api.client, product_no)
backup_id = store_db.add_revision(
product_no=product_no,
html_content=current.description,
revision_type=store.REVISION_BACKUP,
memo=f"예약 #{schedule_id} 적용 직전 자동 백업",
created_by=ACTOR,
)
if current.mobile_description and current.mobile_description != current.description:
store_db.add_revision(
product_no=product_no,
html_content=current.mobile_description,
revision_type=store.REVISION_BACKUP,
memo=f"예약 #{schedule_id} 적용 직전 자동 백업 (모바일)",
created_by=ACTOR,
)
# PC/모바일은 구분하지 않는다 — 화면 편집과 같은 방침.
mobile_html = html
products.update_product(
api.client,
product_no,
description=html,
mobile_description=mobile_html,
display=set_display,
selling=set_selling,
)
summary = store.describe_schedule_action(
has_html=html is not None, set_display=set_display, set_selling=set_selling
)
store_db.log_audit(
actor=ACTOR,
action="schedule_apply",
product_no=product_no,
revision_id=int(revision_id) if revision_id else None,
schedule_id=schedule_id,
result="SUCCESS",
detail=summary + (f" (백업 {backup_id})" if backup_id else ""),
)
return summary
def process_once(store_db: Any, api: Any) -> int:
"""실행할 예약을 모두 처리한다. 처리한 건수를 돌려준다."""
handled = 0
while True:
with store_db.claim_due_schedule(now=now_kst()) as row:
if row is None:
return handled
# 잠금은 여기서 이미 풀렸다. 상태가 PROCESSING 이라 다른 worker 가 집지 않는다.
schedule_id = int(row["id"])
product_no = int(row["product_no"])
try:
summary = _apply(store_db, api, row)
except Cafe24Error as exc:
retry_count = int(row.get("retry_count") or 0)
if store.can_retry(retry_count):
wait = store.retry_backoff_seconds(retry_count)
store_db.finish_schedule(
schedule_id,
status=store.STATUS_PENDING,
error=str(exc),
next_retry_at=now_kst() + timedelta(seconds=wait),
retry_count=retry_count + 1,
)
logger.warning(
"예약 #%s 상품 %s 실패 — %s초 후 재시도 (%s/%s): %s",
schedule_id, product_no, wait, retry_count + 1, store.MAX_RETRY, exc,
)
else:
store_db.finish_schedule(
schedule_id, status=store.STATUS_FAILED, error=str(exc)
)
store_db.log_audit(
actor=ACTOR, action="schedule_apply", product_no=product_no,
schedule_id=schedule_id, result="FAIL", detail=str(exc),
)
logger.error("예약 #%s 상품 %s 최종 실패: %s", schedule_id, product_no, exc)
except Exception as exc: # noqa: BLE001 — 한 건의 사고가 worker 를 죽이면 안 된다.
store_db.finish_schedule(
schedule_id, status=store.STATUS_FAILED, error=f"{type(exc).__name__}: {exc}"
)
logger.exception("예약 #%s 처리 중 예상치 못한 오류", schedule_id)
else:
store_db.finish_schedule(schedule_id, status=store.STATUS_SUCCESS)
logger.info("예약 #%s 상품 %s 적용 완료 — %s", schedule_id, product_no, summary)
handled += 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="카페24 예약 실행 worker")
parser.add_argument("--loop", type=int, default=0, help="확인 간격(초). 0 이면 한 번만")
parser.add_argument("--once", action="store_true", help="한 번만 처리하고 종료")
args = parser.parse_args(argv)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
stream=sys.stdout,
)
dsn = (os.getenv("CAFE24_DB_URL") or "").strip()
if not dsn:
logger.error("CAFE24_DB_URL 이 설정되지 않았습니다. worker 를 시작할 수 없습니다.")
return 1
# 지연 import — psycopg 가 없는 개발 환경에서도 이 모듈을 열어볼 수 있게.
from .db import Cafe24Store # noqa: WPS433
store_db = Cafe24Store(dsn)
api = build_cafe24_api(store_db)
interval = 0 if args.once else max(0, int(args.loop))
logger.info("카페24 예약 worker 시작 (간격 %s초)", interval or "단발")
try:
while True:
try:
count = process_once(store_db, api)
if count:
logger.info("예약 %s건 처리", count)
except Exception: # noqa: BLE001 — DB 순간 장애로 죽지 않게
logger.exception("예약 처리 루프에서 오류 — 다음 주기에 다시 시도")
if not interval:
return 0
time.sleep(interval)
except KeyboardInterrupt:
logger.info("종료 요청 — worker 를 멈춥니다.")
return 0
finally:
store_db.close()
if __name__ == "__main__":
raise SystemExit(main())