- 지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
- 예약은 dbx-cafe24-worker 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
-
- 지금은 상품 목록에서 현재 상세페이지 HTML 을 확인할 수 있습니다. +{% if flash %}
+ 예약은 상품관리 화면의 편집기 아래 「예약 적용」 에서 등록합니다.
+ 지정한 시각에 dbx-cafe24-worker 가 적용하므로 브라우저를 닫아도 실행됩니다.
+ 적용 직전 내용은 상품별 BACKUP 버전으로 보관됩니다.
+ 대기 상태인 예약만 취소할 수 있습니다.
| 번호 | +예정 시각 | +상품 | +상품명 | +적용 내용 | +상태 | +메모 / 오류 | +등록자 | ++ |
|---|---|---|---|---|---|---|---|---|
| #{{ r.id }} | +{{ (r.scheduled_at or '')[:16] | replace("T", " ") }} | ++ {{ r.product_no }} + | +{{ r.product_name or '—' }} | +{{ r.action_label }} | ++ {% if r.status == 'SUCCESS' %}{{ r.status_label }} + {% elif r.status == 'FAILED' %}{{ r.status_label }} + {% elif r.status == 'PENDING' %}{{ r.status_label }} + {% else %}{{ r.status_label }}{% endif %} + {% if r.retry_count %}({{ r.retry_count }}회){% endif %} + | +
+ {{ r.memo or '' }}
+ {% if r.last_error %} {{ r.last_error }} {% endif %}
+ |
+ {{ r.created_by or '—' }} | ++ {% if r.editable %} + + {% endif %} + | +
등록된 예약이 없습니다. 상품관리 화면에서 상품을 고른 뒤 편집기 아래에서 예약할 수 있습니다.
+ {% endif %}x
") + assert payload["request"] == {"description": "x
"} + + +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="새
", display=False) + call = client.calls[0] + assert call["method"] == "PUT" and call["path"] == "/admin/products/131" + assert call["json"] == {"request": {"description": "새
", "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": "예약 내용
"} + 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"] == "예약 내용
" + assert put["json"]["request"]["mobile_description"] == "예약 내용
" + 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": "x
"} + 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": "x
"} + 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: diff --git a/app/modules/cafe24/worker.py b/app/modules/cafe24/worker.py new file mode 100644 index 0000000..fd0dc15 --- /dev/null +++ b/app/modules/cafe24/worker.py @@ -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()) diff --git a/app/static/cafe24.css b/app/static/cafe24.css index f9441a8..d96de72 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -590,3 +590,37 @@ .cf24-empty h3 { margin: 0 0 var(--sp-8, 8px); } + +/* ── 예약 폼 ── */ +.cf24-schedule-form { + padding-top: var(--sp-8, 8px); +} + +.cf24-schedule-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: var(--sp-8, 8px); +} + +.cf24-schedule-grid label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); +} + +.cf24-schedule-wide { + grid-column: 1 / -1; +} + +.cf24-schedule-input { + height: 32px; + box-sizing: border-box; + padding: 0 var(--sp-8, 8px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-lg, 10px); + font-size: var(--text-body, 14px); + color: var(--color-rich-black, #0a0a0a); + background: var(--color-canvas-white, #fff); +} diff --git a/docker-compose.yml b/docker-compose.yml index 724453d..f94eab4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,27 @@ services: - default - postgres_default # postgres-db 컨테이너와 통신 (DSN host=postgres-db) + # 카페24 예약 실행기 — web 과 같은 이미지로 worker 모듈만 돌린다. + # 예약은 브라우저를 닫아도 그 시각에 실행돼야 하므로 별도 프로세스가 필요하다. + # CAFE24_DB_URL 이 없으면 즉시 종료하며 로그로 사유를 남긴다. + cafe24-worker: + build: . + image: dbx-main:latest + container_name: dbx-cafe24-worker + restart: unless-stopped + command: ["python", "-m", "app.modules.cafe24.worker", "--loop", "60"] + env_file: + - .env + environment: + DATA_DIR: /data + volumes: + - dbx-main-data:/data + depends_on: + - web + networks: + - default + - postgres_default + volumes: dbx-main-data: diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index d0b0cc8..2b56a7f 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -27,6 +27,8 @@ app/modules/cafe24/ ← 상품관리 모듈 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) ├─ routes_bulk.py 일괄수정 (검사 → 선택 적용) +├─ routes_schedules.py 예약 등록·목록·취소 +├─ worker.py 예약 실행기 (compose 서비스 dbx-cafe24-worker) ├─ routes_system.py 연결(OAuth)·상태·API 로그·작업 로그 ├─ common.py 가드/컨텍스트 헬퍼 (순환 import 방지로 분리) ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) @@ -57,7 +59,10 @@ app/modules/cafe24/ ← 상품관리 모듈 | `GET /cafe24/bulk` | 일괄수정 화면 (`