From 6dae0e45c9df7a55faeffbd6b26f47df73762b08 Mon Sep 17 00:00:00 2001 From: king Date: Fri, 14 Aug 2026 16:06:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=EC=98=88=EC=95=BD=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20=EC=A7=80=EC=A0=95=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=EC=97=90=20=EC=83=81=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=C2=B7?= =?UTF-8?q?=EC=A7=84=EC=97=B4/=ED=8C=90=EB=A7=A4=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(Phase=205)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 되돌리기(자동 복원)는 요청대로 만들지 않았다. 예약은 "그 시각에 이 내용을 적용" 하나뿐이며, 한 예약에서 상세페이지 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 --- app/integrations/cafe24/products.py | 81 +++++-- app/modules/cafe24/db.py | 143 ++++++++++++ app/modules/cafe24/router.py | 31 +-- app/modules/cafe24/routes_schedules.py | 189 ++++++++++++++++ app/modules/cafe24/store.py | 64 ++++++ .../cafe24/templates/cafe24/_editor.html | 54 +++++ app/modules/cafe24/templates/cafe24/bulk.html | 2 +- .../cafe24/templates/cafe24/products.html | 16 +- .../cafe24/templates/cafe24/schedules.html | 79 ++++++- .../cafe24/templates/cafe24/system.html | 2 +- app/modules/cafe24/tests/test_cafe24.py | 209 +++++++++++++++++- app/modules/cafe24/worker.py | 198 +++++++++++++++++ app/static/cafe24.css | 34 +++ docker-compose.yml | 21 ++ docs/CAFE24_MODULE.md | 59 ++++- scripts/sql/cafe24_db_002_schedule_flags.sql | 44 ++++ 16 files changed, 1157 insertions(+), 69 deletions(-) create mode 100644 app/modules/cafe24/routes_schedules.py create mode 100644 app/modules/cafe24/worker.py create mode 100644 scripts/sql/cafe24_db_002_schedule_flags.sql diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index feab56e..67611d8 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -149,27 +149,72 @@ def fetch_descriptions(client: Cafe24Client, product_no: int) -> Descriptions: return descriptions_from_product(get_product(client, product_no)) +def _flag_value(flag: bool) -> str: + """카페24는 boolean 을 'T'/'F' 문자열로 받는다.""" + return "T" if flag else "F" + + def build_update_payload( *, - description: str, + description: str | None = None, mobile_description: str | None = None, + display: bool | None = None, + selling: bool | None = None, shop_no: int | None = None, ) -> dict[str, Any]: """상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정). - `mobile_description=None` 이면 모바일 필드를 건드리지 않는다. PC/모바일 - 미분리(separated_mobile=False) 상품은 호출부가 같은 HTML 을 두 번 넘겨 - 두 필드를 함께 맞춘다. + `None` 인 항목은 payload 에 넣지 않는다 = 그 필드를 건드리지 않는다. + 예약에서 "진열만 켜기"처럼 상세설명 없이 상태만 바꾸는 경우가 있으므로 + description 도 생략할 수 있다. """ - request: dict[str, Any] = {"description": description} + request: dict[str, Any] = {} + if description is not None: + request["description"] = description if mobile_description is not None: request["mobile_description"] = mobile_description + if display is not None: + request["display"] = _flag_value(display) + if selling is not None: + request["selling"] = _flag_value(selling) payload: dict[str, Any] = {"request": request} if shop_no: payload["shop_no"] = int(shop_no) return payload +def update_product( + client: Cafe24Client, + product_no: int, + *, + description: str | None = None, + mobile_description: str | None = None, + display: bool | None = None, + selling: bool | None = None, + shop_no: int | None = None, +) -> dict[str, Any]: + """상품 부분 수정. 상세설명·진열·판매를 한 번의 호출로 바꿀 수 있다. + + 바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다. + + ⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP + revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다. + """ + payload = build_update_payload( + description=description, + mobile_description=mobile_description, + display=display, + selling=selling, + shop_no=shop_no, + ) + if not payload["request"]: + return {} + no = int(product_no) + response = client.put(f"/admin/products/{no}", json=payload, product_no=no) + product = response.get("product") + return product if isinstance(product, dict) else response + + def update_descriptions( client: Cafe24Client, product_no: int, @@ -178,26 +223,14 @@ def update_descriptions( mobile_description: str | None = None, shop_no: int | None = None, ) -> dict[str, Any]: - """상세설명 교체. 성공하면 카페24가 돌려준 상품 dict. - - 실패는 Cafe24ApiError/Cafe24AuthError 로 올라오므로 호출부는 예외가 없을 - 때만 성공으로 처리하면 된다. - - ⚠️ 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 BACKUP revision 을 남길 - 것(`docs/CAFE24_MODULE.md` 보안 규칙). 이 함수는 백업을 하지 않는다. - """ - no = int(product_no) - payload = client.put( - f"/admin/products/{no}", - json=build_update_payload( - description=description, - mobile_description=mobile_description, - shop_no=shop_no, - ), - product_no=no, + """상세설명만 교체하는 지름길. 실제 전송은 `update_product` 가 한다.""" + return update_product( + client, + product_no, + description=description, + mobile_description=mobile_description, + shop_no=shop_no, ) - product = payload.get("product") - return product if isinstance(product, dict) else payload def normalize_product(raw: dict[str, Any]) -> dict[str, Any]: diff --git a/app/modules/cafe24/db.py b/app/modules/cafe24/db.py index fd13742..fbdc9fc 100644 --- a/app/modules/cafe24/db.py +++ b/app/modules/cafe24/db.py @@ -319,6 +319,149 @@ class Cafe24Store: ).fetchone() return self._serialize(row) + # ════════════════════════════════════════════════════════════ + # 예약 (지정 시각에 상세페이지·진열/판매 적용) + # 되돌리기는 쓰지 않으므로 end_* 컬럼은 건드리지 않는다. + # ════════════════════════════════════════════════════════════ + def create_schedule( + self, + *, + product_no: int, + scheduled_at: datetime, + revision_id: int | None, + set_display: bool | None, + set_selling: bool | None, + memo: str = "", + created_by: str = "", + ) -> int: + with self._pool.connection() as conn: + row = conn.execute( + """ + INSERT INTO cafe24_product_schedules + (product_no, scheduled_at, revision_id, set_display, set_selling, + memo, created_by) + VALUES (%s,%s,%s,%s,%s,%s,%s) + RETURNING id + """, + ( + int(product_no), + scheduled_at, + revision_id, + set_display, + set_selling, + (memo or "")[:500], + created_by or "", + ), + ).fetchone() + return int(row["id"]) if row else 0 + + def list_schedules(self, *, limit: int = 200) -> list[dict[str, Any]]: + """예약 목록. 대기 중인 것을 먼저, 그다음 최근 처리 순.""" + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT s.*, p.product_name, + (s.revision_id IS NOT NULL) AS has_html + FROM cafe24_product_schedules s + LEFT JOIN cafe24_products p ON p.product_no = s.product_no + ORDER BY (s.status = 'PENDING') DESC, + CASE WHEN s.status = 'PENDING' THEN s.scheduled_at END ASC, + s.scheduled_at DESC, s.id DESC + LIMIT %s + """, + (max(1, min(int(limit), 500)),), + ).fetchall() + return [self._serialize(r) for r in rows] + + def cancel_schedule(self, schedule_id: int, *, actor: str = "") -> bool: + """대기 중인 예약만 취소한다. 실행 중/완료된 것은 건드리지 않는다.""" + with self._pool.connection() as conn: + with conn.transaction(): + row = conn.execute( + """ + UPDATE cafe24_product_schedules + SET status = 'CANCELLED', completed_at = now() + WHERE id = %s AND status = 'PENDING' + RETURNING id, product_no + """, + (int(schedule_id),), + ).fetchone() + if row is None: + return False + self._insert_audit( + conn, + actor=actor, + action="schedule_cancel", + product_no=row["product_no"], + schedule_id=row["id"], + result="SUCCESS", + ) + return True + + @contextmanager + def claim_due_schedule(self, *, now: datetime) -> Iterator[dict[str, Any] | None]: + """실행할 예약 1건을 잡아 PROCESSING 으로 바꾼다(worker 전용). + + `FOR UPDATE SKIP LOCKED` 로 잠그므로 worker 가 여러 개 떠 있어도 같은 예약을 + 두 번 실행하지 않는다. 재시도 대기(next_retry_at)가 남아 있으면 건너뛴다. + """ + with self._pool.connection() as conn: + with conn.transaction(): + row = conn.execute( + """ + SELECT * FROM cafe24_product_schedules + WHERE status = 'PENDING' + AND scheduled_at <= %s + AND (next_retry_at IS NULL OR next_retry_at <= %s) + ORDER BY scheduled_at ASC, id ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + """, + (now, now), + ).fetchone() + if row is not None: + conn.execute( + """ + UPDATE cafe24_product_schedules + SET status = 'PROCESSING', started_at = now(), last_error = '' + WHERE id = %s + """, + (row["id"],), + ) + yield dict(row) if row is not None else None + + def finish_schedule( + self, + schedule_id: int, + *, + status: str, + error: str = "", + next_retry_at: datetime | None = None, + retry_count: int | None = None, + ) -> None: + """예약 종료 처리. 재시도로 되돌릴 때는 status='PENDING' + next_retry_at.""" + with self._pool.connection() as conn: + conn.execute( + """ + UPDATE cafe24_product_schedules + SET status = %s, + last_error = %s, + next_retry_at = %s, + retry_count = COALESCE(%s, retry_count), + completed_at = CASE WHEN %s IN ('SUCCESS','FAILED','CANCELLED') + THEN now() ELSE completed_at END + WHERE id = %s + """, + ( + store.normalize_schedule_status(status), + (error or "")[:1000], + next_retry_at, + retry_count, + store.normalize_schedule_status(status), + int(schedule_id), + ), + ) + # ════════════════════════════════════════════════════════════ # 직렬화 — datetime → KST ISO, date → ISO (다른 모듈과 동일) # ════════════════════════════════════════════════════════════ diff --git a/app/modules/cafe24/router.py b/app/modules/cafe24/router.py index fe8a8b0..12cacb0 100644 --- a/app/modules/cafe24/router.py +++ b/app/modules/cafe24/router.py @@ -11,20 +11,19 @@ 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. routes_products 상품 목록/검색 · 상세설명 조회·편집·적용 routes_bulk 일괄수정 (검사 → 선택 적용) + routes_schedules 예약 등록·목록·취소 (실행은 worker.py) routes_system 연결(OAuth)·상태·API 로그·작업 로그 - (Phase 5) routes_schedules """ from __future__ import annotations import logging -from fastapi import APIRouter, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter -from .common import base_ctx, guard from .routes_bulk import bulk_router from .routes_products import products_router +from .routes_schedules import schedules_router from .routes_system import system_router logger = logging.getLogger("cafe24.router") @@ -33,6 +32,7 @@ router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router.include_router(products_router) router.include_router(bulk_router) +router.include_router(schedules_router) router.include_router(system_router) @@ -40,26 +40,3 @@ router.include_router(system_router) def health() -> dict[str, str]: """포털 카드의 상태 점(dot) 용. 인증 불필요 — 상태 문자열만 반환.""" return {"status": "ok"} - - -@router.get("/schedules", response_class=HTMLResponse) -def schedules(request: Request) -> HTMLResponse: - """예약관리 안내. Phase 5 에서 routes_schedules.py 로 옮긴다. - - 상단 탭에 링크가 있으므로 404 를 내지 않고 안내 화면을 보여준다. - """ - from app.main import render_template # noqa: WPS433 - - checked = guard(request) - if not isinstance(checked, tuple): - return checked - _st, user = checked - - ctx = base_ctx(request, user, active_tab="schedules") - ctx.update( - { - "page_title": "카페24 — 예약관리", - "page_subtitle": "예약 적용 · 자동 복원", - } - ) - return render_template(request, "cafe24/schedules.html", ctx) diff --git a/app/modules/cafe24/routes_schedules.py b/app/modules/cafe24/routes_schedules.py new file mode 100644 index 0000000..c3da181 --- /dev/null +++ b/app/modules/cafe24/routes_schedules.py @@ -0,0 +1,189 @@ +"""카페24 예약관리 — 지정 시각에 상세페이지·진열/판매를 자동 적용. + +되돌리기(자동 복원)는 쓰지 않는다. 예약은 "그 시각에 이 내용을 적용" 하나뿐이다. +한 예약에서 세 가지를 각각 고를 수 있다. + + 상세페이지 HTML 편집기에 있는 내용을 그 시각에 적용 (안 고르면 HTML 은 그대로) + 진열 진열 / 미진열 / 변경 없음 + 판매 판매 / 중지 / 변경 없음 + +예약을 만들 때 편집기의 HTML 을 **DRAFT revision 으로 먼저 저장**하고 예약이 그 +버전을 가리킨다. 나중에 편집기에서 내용을 더 고쳐도 예약 내용은 등록 시점 그대로다 +(예약해둔 것이 조용히 바뀌면 안 된다). + +실제 적용은 웹 프로세스가 아니라 **worker** 가 한다(`app/modules/cafe24/worker.py`, +compose 서비스 `dbx-cafe24-worker`). 브라우저를 닫아도 실행되어야 하기 때문이다. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products + +from . import store +from .common import base_ctx, guard +from .routes_products import _no_store + +logger = logging.getLogger("cafe24.schedules") + +schedules_router = APIRouter() + + +@schedules_router.get("/schedules", response_class=HTMLResponse) +def schedules_page(request: Request) -> HTMLResponse: + """예약 목록 — 대기 중인 것이 위, 그다음 최근 처리 순.""" + from app.main import render_template # noqa: WPS433 + + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + rows = [] + for row in st.list_schedules(limit=200): + rows.append( + { + **row, + "status_label": store.SCHEDULE_STATUS_LABELS.get(row.get("status") or "", ""), + "action_label": store.describe_schedule_action( + has_html=bool(row.get("has_html")), + set_display=row.get("set_display"), + set_selling=row.get("set_selling"), + ), + "editable": store.is_editable(row.get("status") or ""), + } + ) + + ctx = base_ctx(request, user, active_tab="schedules") + ctx.update( + { + "page_title": "카페24 — 예약관리", + "page_subtitle": "지정 시각에 상세페이지·진열/판매 자동 적용", + "rows": rows, + "pending": sum(1 for r in rows if r["status"] == store.STATUS_PENDING), + "flash": request.query_params.get("msg", ""), + "flash_error": request.query_params.get("err", ""), + } + ) + return _no_store(render_template(request, "cafe24/schedules.html", ctx)) + + +@schedules_router.post("/schedules") +def schedule_create( + request: Request, + product_no: int = Form(...), + scheduled_at: str = Form(""), + html: str = Form(""), + apply_html: str = Form(""), + set_display: str = Form(""), + set_selling: str = Form(""), + memo: str = Form(""), + list_query: str = Form(""), +): + """편집기에서 예약을 등록한다. + + HTML 을 적용하는 예약이면 지금 편집기 내용을 DRAFT revision 으로 저장해 고정한다. + 단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로, 예약이 실행됐을 때 + 저장되는 값이 화면에서 본 것과 같다. + """ + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + actor = str(user.get("email") or "") + base = f"/cafe24/?{list_query}" if list_query else f"/cafe24/?selected={product_no}" + back = base if f"selected={product_no}" in base else f"{base}&selected={product_no}" + + want_html = bool(apply_html) + display_flag = store.parse_tristate(set_display) + selling_flag = store.parse_tristate(set_selling) + + if not want_html and display_flag is None and selling_flag is None: + return RedirectResponse( + url=f"{back}&err=예약할 내용을 하나 이상 선택하세요(상세페이지 / 진열 / 판매).", + status_code=303, + ) + + try: + run_at = store.parse_schedule_at(scheduled_at) + except ValueError as exc: + return RedirectResponse(url=f"{back}&err={exc}", status_code=303) + + revision_id: int | None = None + if want_html: + body = store.format_html(store.encode_html_urls(html or "")) + if not body.strip(): + return RedirectResponse( + url=f"{back}&err=상세페이지 내용이 비어 있습니다.", status_code=303 + ) + revision_id = st.add_revision( + product_no=product_no, + html_content=body, + revision_type=store.REVISION_DRAFT, + memo=f"예약 등록 ({run_at.strftime('%Y-%m-%d %H:%M')} 적용 예정)", + created_by=actor, + ) + + schedule_id = st.create_schedule( + product_no=product_no, + scheduled_at=run_at, + revision_id=revision_id, + set_display=display_flag, + set_selling=selling_flag, + memo=memo, + created_by=actor, + ) + detail = store.describe_schedule_action( + has_html=want_html, set_display=display_flag, set_selling=selling_flag + ) + st.log_audit( + actor=actor, action="schedule_create", product_no=product_no, + revision_id=revision_id, schedule_id=schedule_id, result="SUCCESS", + detail=f"{run_at.strftime('%Y-%m-%d %H:%M')} — {detail}", + ) + logger.info("카페24 예약 등록 #%s 상품 %s (%s)", schedule_id, product_no, detail) + return RedirectResponse( + url=f"/cafe24/schedules?msg={run_at.strftime('%Y-%m-%d %H:%M')} 예약을 등록했습니다 — {detail}", + status_code=303, + ) + + +@schedules_router.post("/schedules/{schedule_id}/cancel") +def schedule_cancel(request: Request, schedule_id: int): + """대기 중인 예약 취소. 이미 실행됐거나 실행 중이면 아무것도 하지 않는다.""" + checked = guard(request) + if not isinstance(checked, tuple): + return checked + st, user = checked + + ok = st.cancel_schedule(schedule_id, actor=str(user.get("email") or "")) + if ok: + return RedirectResponse(url=f"/cafe24/schedules?msg=예약 #{schedule_id} 을 취소했습니다.", status_code=303) + return RedirectResponse( + url=f"/cafe24/schedules?err=예약 #{schedule_id} 은 이미 처리되었거나 실행 중이라 취소할 수 없습니다.", + status_code=303, + ) + + +@schedules_router.get("/schedules/preview/{product_no}") +def schedule_current_flags(request: Request, product_no: int) -> dict: + """예약 폼의 기본값을 위해 현재 진열/판매 상태를 알려준다(JSON, 읽기 전용).""" + from .common import require_store # noqa: WPS433 + + st, _user = require_store(request) + api = build_cafe24_api(st) + try: + raw = products.get_product(api.client, product_no) + except Cafe24Error as exc: + return {"product_no": product_no, "error": str(exc)} + normalized = products.normalize_product(raw) + return { + "product_no": product_no, + "display": normalized["display"], + "selling": normalized["selling"], + } diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py index 373a48d..9497a9f 100644 --- a/app/modules/cafe24/store.py +++ b/app/modules/cafe24/store.py @@ -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 값 지문. 적용 직전 값과 비교해 충돌을 잡는다. diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html index d5c730e..da64f41 100644 --- a/app/modules/cafe24/templates/cafe24/_editor.html +++ b/app/modules/cafe24/templates/cafe24/_editor.html @@ -63,6 +63,60 @@ +{# 예약 — 적용 폼과 형제로 둔다(폼 중첩은 불가). 위 편집기 내용을 JS 가 hidden 에 + 복사해 함께 보낸다. 등록 시점 내용이 DRAFT 버전으로 고정되므로, 이후 편집기를 + 더 고쳐도 예약된 내용은 바뀌지 않는다. #} +
+ 예약 적용 — 지정한 시각에 자동 반영 +
+ + + + +
+ + + + +
+ +
+ + + 예약 목록 +
+

+ 진열·판매만 바꾸려면 위 체크를 해제하세요. 셋 중 하나 이상은 선택해야 합니다. +

+
+
+
버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %} {% if revisions %} diff --git a/app/modules/cafe24/templates/cafe24/bulk.html b/app/modules/cafe24/templates/cafe24/bulk.html index d9a79dd..3a844b8 100644 --- a/app/modules/cafe24/templates/cafe24/bulk.html +++ b/app/modules/cafe24/templates/cafe24/bulk.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} diff --git a/app/modules/cafe24/templates/cafe24/products.html b/app/modules/cafe24/templates/cafe24/products.html index 9ac32cb..aca3dd7 100644 --- a/app/modules/cafe24/templates/cafe24/products.html +++ b/app/modules/cafe24/templates/cafe24/products.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} @@ -234,6 +234,20 @@ }); } + // 예약 폼 — 편집기 내용을 hidden 에 복사해 함께 보낸다(폼이 서로 형제라서). + var schedForm = pane.querySelector("#cf24-schedule-form"); + if (schedForm) { + schedForm.addEventListener("submit", function (e) { + var box = document.getElementById("cf24-html-pc"); + var holder = document.getElementById("cf24-schedule-html"); + var wantHtml = schedForm.querySelector('[name="apply_html"]').checked; + if (holder && box) holder.value = wantHtml ? box.value : ""; + if (!window.confirm(schedForm.dataset.confirm)) { e.preventDefault(); return; } + // 예약 등록으로 화면을 떠나므로 편집 중 경고를 끈다. + dirty = false; + }); + } + var form = pane.querySelector(".cf24-editor-form"); if (form) { form.addEventListener("submit", function (e) { diff --git a/app/modules/cafe24/templates/cafe24/schedules.html b/app/modules/cafe24/templates/cafe24/schedules.html index 6cfa682..fa4ba08 100644 --- a/app/modules/cafe24/templates/cafe24/schedules.html +++ b/app/modules/cafe24/templates/cafe24/schedules.html @@ -1,20 +1,81 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} {% include "cafe24/_nav.html" %} -
-

예약관리는 Phase 5 에서 열립니다.

-

- 지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다. - 예약은 dbx-cafe24-worker 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다. -

-

- 지금은 상품 목록에서 현재 상세페이지 HTML 을 확인할 수 있습니다. +{% if flash %}

{{ flash }}
{% endif %} +{% if flash_error %}
{{ flash_error }}
{% endif %} + +
+
+

예약 목록

+ 대기 {{ pending }}건 · 최근 200건 +
+ +

+ 예약은 상품관리 화면의 편집기 아래 「예약 적용」 에서 등록합니다. + 지정한 시각에 dbx-cafe24-worker 가 적용하므로 브라우저를 닫아도 실행됩니다. + 적용 직전 내용은 상품별 BACKUP 버전으로 보관됩니다. + 대기 상태인 예약만 취소할 수 있습니다.

+ + {% if rows %} +
+ + + + + + + + + + + + + + + + {% for r in rows %} + + + + + + + + + + + + {% endfor %} + +
번호예정 시각상품상품명적용 내용상태메모 / 오류등록자
#{{ 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 %} +
+
+ {% else %} +

등록된 예약이 없습니다. 상품관리 화면에서 상품을 고른 뒤 편집기 아래에서 예약할 수 있습니다.

+ {% endif %}
{% endblock %} diff --git a/app/modules/cafe24/templates/cafe24/system.html b/app/modules/cafe24/templates/cafe24/system.html index 1d28385..7e34522 100644 --- a/app/modules/cafe24/templates/cafe24/system.html +++ b/app/modules/cafe24/templates/cafe24/system.html @@ -1,7 +1,7 @@ {% extends "erp_base.html" %} {% block head_extra %} - + {% endblock %} {% block content %} diff --git a/app/modules/cafe24/tests/test_cafe24.py b/app/modules/cafe24/tests/test_cafe24.py index 08a38fa..15c0d93 100644 --- a/app/modules/cafe24/tests/test_cafe24.py +++ b/app/modules/cafe24/tests/test_cafe24.py @@ -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="

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` | 일괄수정 화면 (`