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:
@@ -7,8 +7,11 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.timezone import KST
|
||||
|
||||
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
|
||||
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
|
||||
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
|
||||
@@ -99,6 +102,12 @@ def normalize_revision_type(value: str) -> str:
|
||||
return text if text in REVISION_TYPES else REVISION_DRAFT
|
||||
|
||||
|
||||
def normalize_schedule_status(value: str) -> str:
|
||||
"""DB CHECK 제약에 걸리지 않게 상태값을 정규화한다."""
|
||||
text = (value or "").strip().upper()
|
||||
return text if text in SCHEDULE_STATUSES else STATUS_PENDING
|
||||
|
||||
|
||||
def parse_product_no(value: object) -> int:
|
||||
"""상품번호 정규화. 잘못된 값이면 ValueError."""
|
||||
try:
|
||||
@@ -396,6 +405,61 @@ def replace_first_style_block(html: str, new_block: str) -> str:
|
||||
return source[: match.start()] + new_block + source[match.end() :]
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 예약 입력 검증
|
||||
#
|
||||
# 되돌리기(자동 복원)는 쓰지 않는다. 예약은 "지정 시각에 이 내용을 적용" 뿐이다.
|
||||
# 세 가지를 각각 선택할 수 있다 — 상세페이지 HTML / 진열 / 판매.
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 화면의 select 값 → 3-상태. 빈 값·미지정이면 "변경하지 않음"(None).
|
||||
_TRISTATE: dict[str, bool] = {
|
||||
"on": True, "off": False,
|
||||
"t": True, "f": False,
|
||||
"true": True, "false": False,
|
||||
"1": True, "0": False,
|
||||
}
|
||||
|
||||
|
||||
def parse_tristate(value: object) -> bool | None:
|
||||
"""'on'/'off'/'' → True/False/None. 알 수 없는 값은 "변경하지 않음"으로 본다."""
|
||||
return _TRISTATE.get(str(value or "").strip().lower())
|
||||
|
||||
|
||||
def parse_schedule_at(value: object, *, now: datetime | None = None) -> datetime:
|
||||
"""`datetime-local` 입력('2026-08-20T14:00') → KST aware datetime.
|
||||
|
||||
타임존 표기가 없으므로 KST 로 해석한다(운영 기준 시간대).
|
||||
과거 시각은 거부한다 — worker 가 즉시 실행해버려 "예약"의 의미가 없어진다.
|
||||
"""
|
||||
text = str(value or "").strip().replace(" ", "T")
|
||||
if not text:
|
||||
raise ValueError("예약 시각을 입력하세요.")
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
raise ValueError("예약 시각 형식이 올바르지 않습니다.") from None
|
||||
aware = parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
||||
current = now or datetime.now(KST)
|
||||
# 1분 여유 — 폼을 채우는 동안 시간이 흐른 경우를 걸러내지 않기 위해.
|
||||
if aware < current - timedelta(minutes=1):
|
||||
raise ValueError("예약 시각이 이미 지났습니다. 앞으로의 시각을 지정하세요.")
|
||||
return aware
|
||||
|
||||
|
||||
def describe_schedule_action(
|
||||
*, has_html: bool, set_display: bool | None, set_selling: bool | None
|
||||
) -> str:
|
||||
"""예약 내용을 한 줄로 요약(목록·로그 표시용)."""
|
||||
parts: list[str] = []
|
||||
if has_html:
|
||||
parts.append("상세페이지")
|
||||
if set_display is not None:
|
||||
parts.append("진열" if set_display else "미진열")
|
||||
if set_selling is not None:
|
||||
parts.append("판매" if set_selling else "판매중지")
|
||||
return " · ".join(parts) if parts else "없음"
|
||||
|
||||
|
||||
def fingerprint(html: str) -> str:
|
||||
"""편집 시작 시점의 카페24 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user