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
+207 -2
View File
@@ -13,8 +13,8 @@ from datetime import timedelta
from app.integrations.cafe24 import config as cfgmod
from app.integrations.cafe24 import crypto, oauth, products, tokens
from app.integrations.cafe24.errors import Cafe24AuthError, Cafe24ConfigError
from app.modules.cafe24 import store
from app.integrations.cafe24.errors import Cafe24ApiError, Cafe24AuthError, Cafe24ConfigError
from app.modules.cafe24 import store, worker
from app.timezone import now_kst
SECRET = "unit-test-secret"
@@ -598,6 +598,211 @@ def test_fingerprint_detects_change():
assert len(a) == 32
# ════════════════════════════════════════════════════════════
# 예약 — 입력 검증
# ════════════════════════════════════════════════════════════
def test_parse_tristate():
assert store.parse_tristate("on") is True
assert store.parse_tristate("off") is False
for keep in ("", None, "keep", "이상한값"):
assert store.parse_tristate(keep) is None, keep
def test_parse_schedule_at_accepts_future_kst():
base = now_kst()
target = (base + timedelta(hours=3)).replace(second=0, microsecond=0)
parsed = store.parse_schedule_at(target.strftime("%Y-%m-%dT%H:%M"), now=base)
assert parsed.utcoffset() == timedelta(hours=9) # 타임존 표기 없는 입력을 KST 로 해석
assert parsed.hour == target.hour and parsed.minute == target.minute
def test_parse_schedule_at_rejects_past_and_garbage():
base = now_kst()
past = (base - timedelta(hours=1)).strftime("%Y-%m-%dT%H:%M")
for bad in (past, "", "어제", "2026-13-45T99:99"):
try:
store.parse_schedule_at(bad, now=base)
except ValueError:
continue
raise AssertionError(f"{bad!r} 는 거부해야 한다")
def test_parse_schedule_at_allows_one_minute_grace():
"""폼을 채우는 동안 시간이 흐른 경우를 거부하지 않는다."""
base = now_kst()
just_now = (base - timedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M")
assert store.parse_schedule_at(just_now, now=base) is not None
def test_describe_schedule_action():
assert store.describe_schedule_action(
has_html=True, set_display=True, set_selling=False
) == "상세페이지 · 진열 · 판매중지"
assert store.describe_schedule_action(
has_html=False, set_display=None, set_selling=True
) == "판매"
assert store.describe_schedule_action(
has_html=False, set_display=None, set_selling=None
) == "없음"
def test_normalize_schedule_status():
assert store.normalize_schedule_status("success") == store.STATUS_SUCCESS
assert store.normalize_schedule_status("없는상태") == store.STATUS_PENDING
# ════════════════════════════════════════════════════════════
# 예약 — 상품 수정 payload (진열/판매 포함)
# ════════════════════════════════════════════════════════════
def test_update_payload_flags():
payload = products.build_update_payload(display=True, selling=False)
assert payload == {"request": {"display": "T", "selling": "F"}}
def test_update_payload_skips_none():
"""None 인 항목은 아예 보내지 않는다 = 그 필드를 건드리지 않는다."""
payload = products.build_update_payload(description="<p>x</p>")
assert payload["request"] == {"description": "<p>x</p>"}
def test_update_product_skips_empty_request():
"""바꿀 것이 없으면 API 를 호출하지 않는다."""
client = _FakeClient({"product": _PRODUCT})
assert products.update_product(client, 131) == {}
assert client.calls == []
def test_update_product_sends_flags_and_html():
client = _FakeClient({"product": _PRODUCT})
products.update_product(client, 131, description="<p>새</p>", display=False)
call = client.calls[0]
assert call["method"] == "PUT" and call["path"] == "/admin/products/131"
assert call["json"] == {"request": {"description": "<p>새</p>", "display": "F"}}
# ════════════════════════════════════════════════════════════
# 예약 worker — 성공/재시도/최종실패
# ════════════════════════════════════════════════════════════
class _FakeStore:
"""worker 가 쓰는 저장소 메서드만 흉내낸다."""
def __init__(self, rows):
self.rows = list(rows)
self.revisions = {}
self.added = []
self.finished = []
self.audits = []
@contextmanager
def claim_due_schedule(self, *, now):
yield self.rows.pop(0) if self.rows else None
def get_revision(self, revision_id):
return self.revisions.get(int(revision_id), {})
def add_revision(self, **fields):
self.added.append(fields)
return 900 + len(self.added)
def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
self.finished.append(
{"id": schedule_id, "status": status, "error": error,
"next_retry_at": next_retry_at, "retry_count": retry_count}
)
def log_audit(self, **fields):
self.audits.append(fields)
class _FakeApi:
def __init__(self, client):
self.client = client
class _WorkerClient(_FakeClient):
"""PUT 을 실패시킬 수 있는 클라이언트."""
def __init__(self, payload=None, fail_put=None):
super().__init__(payload)
self.fail_put = fail_put
def put(self, path, *, params=None, json=None, product_no=None):
if self.fail_put:
raise self.fail_put
return super().put(path, params=params, json=json, product_no=product_no)
def _schedule_row(**overrides):
row = {
"id": 7, "product_no": 131, "revision_id": 55,
"set_display": True, "set_selling": None, "retry_count": 0,
}
row.update(overrides)
return row
def test_worker_applies_html_and_flags():
st = _FakeStore([_schedule_row()])
st.revisions[55] = {"html_content": "<p>예약 내용</p>"}
client = _WorkerClient({"product": _PRODUCT})
assert worker.process_once(st, _FakeApi(client)) == 1
# 쓰기 직전 현재값을 읽어 BACKUP 을 남겼는가
assert any(r["revision_type"] == store.REVISION_BACKUP for r in st.added)
# HTML 과 진열 상태를 한 번의 PUT 으로 보냈는가
put = [c for c in client.calls if c["method"] == "PUT"][0]
assert put["json"]["request"]["description"] == "<p>예약 내용</p>"
assert put["json"]["request"]["mobile_description"] == "<p>예약 내용</p>"
assert put["json"]["request"]["display"] == "T"
assert "selling" not in put["json"]["request"] # 변경 없음이면 보내지 않는다
assert st.finished == [
{"id": 7, "status": store.STATUS_SUCCESS, "error": "",
"next_retry_at": None, "retry_count": None}
]
def test_worker_flags_only_skips_backup():
"""HTML 없이 진열/판매만 바꾸는 예약은 상세설명을 읽거나 백업하지 않는다."""
st = _FakeStore([_schedule_row(revision_id=None, set_selling=False)])
client = _WorkerClient({"product": _PRODUCT})
assert worker.process_once(st, _FakeApi(client)) == 1
assert st.added == [] # 백업 없음
put = [c for c in client.calls if c["method"] == "PUT"][0]
assert "description" not in put["json"]["request"]
assert put["json"]["request"] == {"display": "T", "selling": "F"}
def test_worker_retries_then_fails():
"""실패는 재시도 예산 안에서 다시 시도하고, 소진되면 FAILED 로 확정한다."""
boom = Cafe24ApiError("서버 오류", status=500)
st = _FakeStore([_schedule_row(retry_count=0)])
st.revisions[55] = {"html_content": "<p>x</p>"}
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
first = st.finished[0]
assert first["status"] == store.STATUS_PENDING # 다시 대기로
assert first["retry_count"] == 1
assert first["next_retry_at"] is not None
st2 = _FakeStore([_schedule_row(retry_count=store.MAX_RETRY)])
st2.revisions[55] = {"html_content": "<p>x</p>"}
worker.process_once(st2, _FakeApi(_WorkerClient({"product": _PRODUCT}, fail_put=boom)))
assert st2.finished[0]["status"] == store.STATUS_FAILED
assert any(a["result"] == "FAIL" for a in st2.audits)
def test_worker_missing_revision_is_failure_not_crash():
st = _FakeStore([_schedule_row(revision_id=999, retry_count=store.MAX_RETRY)])
worker.process_once(st, _FakeApi(_WorkerClient({"product": _PRODUCT})))
assert st.finished[0]["status"] == store.STATUS_FAILED
assert "999" in st.finished[0]["error"]
def test_worker_stops_when_nothing_due():
st = _FakeStore([])
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: