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
+57 -24
View File
@@ -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]:
+143
View File
@@ -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 (다른 모듈과 동일)
# ════════════════════════════════════════════════════════════
+4 -27
View File
@@ -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)
+189
View File
@@ -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"],
}
+64
View File
@@ -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 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
@@ -63,6 +63,60 @@
</div>
</form>
{# 예약 — 적용 폼과 형제로 둔다(폼 중첩은 불가). 위 편집기 내용을 JS 가 hidden 에
복사해 함께 보낸다. 등록 시점 내용이 DRAFT 버전으로 고정되므로, 이후 편집기를
더 고쳐도 예약된 내용은 바뀌지 않는다. #}
<details class="cf24-details">
<summary>예약 적용 — 지정한 시각에 자동 반영</summary>
<form class="cf24-schedule-form" id="cf24-schedule-form" method="post"
action="/cafe24/schedules"
data-confirm="지정한 시각에 자동으로 반영됩니다. 예약을 등록할까요?">
<input type="hidden" name="product_no" value="{{ product_no }}" />
<input type="hidden" name="list_query" value="{{ list_query }}" />
<input type="hidden" name="html" id="cf24-schedule-html" />
<div class="cf24-schedule-grid">
<label>
<span>예약 시각</span>
<input class="cf24-schedule-input" type="datetime-local" name="scheduled_at" required />
</label>
<label>
<span>진열</span>
<select class="cf24-schedule-input" name="set_display">
<option value="">변경 없음</option>
<option value="on">진열</option>
<option value="off">미진열</option>
</select>
</label>
<label>
<span>판매</span>
<select class="cf24-schedule-input" name="set_selling">
<option value="">변경 없음</option>
<option value="on">판매</option>
<option value="off">중지</option>
</select>
</label>
<label class="cf24-schedule-wide">
<span>메모</span>
<input class="cf24-schedule-input" type="text" name="memo" maxlength="200"
placeholder="예: 8월 프로모션 시작" />
</label>
</div>
<div class="cf24-toolbar" style="margin-top:8px;">
<label class="cf24-check-inline" style="margin-left:0;">
<input type="checkbox" name="apply_html" value="1" checked />
위 편집기 내용을 그 시각에 적용
</label>
<button class="erp-btn erp-btn-primary" type="submit">예약 등록</button>
<a class="erp-btn erp-btn-outline" href="/cafe24/schedules">예약 목록</a>
</div>
<p class="cf24-muted" style="margin:8px 0 0;">
진열·판매만 바꾸려면 위 체크를 해제하세요. 셋 중 하나 이상은 선택해야 합니다.
</p>
</form>
</details>
<details class="cf24-details">
<summary>버전 이력 {% if revisions %}({{ revisions | length }}건){% endif %}</summary>
{% if revisions %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% 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) {
@@ -1,20 +1,81 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}
{% include "cafe24/_nav.html" %}
<div class="erp-card cf24-empty">
<h3>예약관리는 Phase 5 에서 열립니다.</h3>
<p>
지정한 시각에 상세페이지를 자동 적용하고, 종료 시각에 원래대로 되돌리는 기능입니다.
예약은 <code>dbx-cafe24-worker</code> 컨테이너가 처리하므로 브라우저를 닫아도 실행됩니다.
</p>
<p>
지금은 <a href="/cafe24/">상품 목록</a>에서 현재 상세페이지 HTML 을 확인할 수 있습니다.
{% if flash %}<div class="cf24-flash cf24-flash-ok">{{ flash }}</div>{% endif %}
{% if flash_error %}<div class="cf24-flash cf24-flash-err">{{ flash_error }}</div>{% endif %}
<div class="erp-card cf24-card">
<div class="cf24-card-head">
<h3>예약 목록</h3>
<span class="cf24-muted">대기 {{ pending }}건 · 최근 200건</span>
</div>
<p class="cf24-note">
예약은 <strong>상품관리 화면의 편집기 아래 「예약 적용」</strong> 에서 등록합니다.
지정한 시각에 <code>dbx-cafe24-worker</code> 가 적용하므로 브라우저를 닫아도 실행됩니다.
적용 직전 내용은 상품별 <code>BACKUP</code> 버전으로 보관됩니다.
<strong>대기</strong> 상태인 예약만 취소할 수 있습니다.
</p>
{% if rows %}
<div class="cf24-scroll">
<table class="erp-table cf24-compact">
<thead>
<tr>
<th style="width:56px;">번호</th>
<th style="width:130px;">예정 시각</th>
<th style="width:66px;">상품</th>
<th>상품명</th>
<th style="width:150px;">적용 내용</th>
<th style="width:96px;">상태</th>
<th>메모 / 오류</th>
<th style="width:130px;">등록자</th>
<th style="width:60px;"></th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
<td class="cf24-nowrap">#{{ r.id }}</td>
<td class="cf24-nowrap">{{ (r.scheduled_at or '')[:16] | replace("T", " ") }}</td>
<td class="cf24-nowrap">
<a href="/cafe24/?selected={{ r.product_no }}">{{ r.product_no }}</a>
</td>
<td>{{ r.product_name or '—' }}</td>
<td class="cf24-nowrap">{{ r.action_label }}</td>
<td class="cf24-nowrap">
{% if r.status == 'SUCCESS' %}<span class="erp-badge cf24-badge-ok">{{ r.status_label }}</span>
{% elif r.status == 'FAILED' %}<span class="cf24-err">{{ r.status_label }}</span>
{% elif r.status == 'PENDING' %}<span class="cf24-warn">{{ r.status_label }}</span>
{% else %}<span class="cf24-muted">{{ r.status_label }}</span>{% endif %}
{% if r.retry_count %}<span class="cf24-muted">({{ r.retry_count }}회)</span>{% endif %}
</td>
<td>
{{ r.memo or '' }}
{% if r.last_error %}<div class="cf24-err">{{ r.last_error }}</div>{% endif %}
</td>
<td class="cf24-nowrap">{{ r.created_by or '—' }}</td>
<td class="cf24-nowrap">
{% if r.editable %}
<form method="post" action="/cafe24/schedules/{{ r.id }}/cancel" style="display:inline;"
onsubmit="return confirm('예약 #{{ r.id }} 을 취소할까요?');">
<button type="submit" class="erp-btn erp-btn-outline">취소</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="cf24-muted">등록된 예약이 없습니다. 상품관리 화면에서 상품을 고른 뒤 편집기 아래에서 예약할 수 있습니다.</p>
{% endif %}
</div>
{% endblock %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260814n" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260814p" />
{% endblock %}
{% block content %}
+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:
+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())
+34
View File
@@ -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);
}
+21
View File
@@ -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:
+55 -4
View File
@@ -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` | 일괄수정 화면 (`<style>` 통일) | `cafe24` |
| `GET /cafe24/bulk/scan/{product_no}` | 상품 1건 검사 (JSON, 읽기 전용) | `cafe24` |
| `POST /cafe24/bulk/apply/{product_no}` | 상품 1건 적용 (JSON) | `cafe24` |
| `GET /cafe24/schedules` | 예약관리 (Phase 5 안내) | `cafe24` |
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
| `GET /cafe24/schedules/preview/{product_no}` | 현재 진열/판매 상태 (JSON) | `cafe24` |
| `GET /cafe24/system` | 연결 상태 · API 로그 · 작업 로그 | `cafe24` |
| `GET /cafe24/system/oauth/start` | 카페24 인증 시작 | **admin** |
| `GET /cafe24/oauth/callback` | 카페24 콜백 (code→토큰) | **admin** |
@@ -170,6 +175,40 @@ app/modules/cafe24/ ← 상품관리 모듈
---
### 2-4. 예약관리 (지정 시각 자동 적용)
**되돌리기(자동 복원)는 쓰지 않는다.** 예약은 "그 시각에 이 내용을 적용" 하나뿐이다.
한 예약에서 세 가지를 각각 고를 수 있고, 하나 이상은 반드시 골라야 한다(DB CHECK 제약).
| 항목 | 값 |
| --- | --- |
| 상세페이지 HTML | 편집기 내용을 적용 / 적용 안 함 |
| 진열 | 진열 · 미진열 · 변경 없음 |
| 판매 | 판매 · 중지 · 변경 없음 |
- **등록**은 편집기 아래 「예약 적용」에서 한다. HTML 을 적용하는 예약이면 그 시점의
편집기 내용을 **DRAFT revision 으로 저장해 고정**한다 — 이후 편집기를 더 고쳐도
예약된 내용은 바뀌지 않는다(예약해둔 것이 조용히 달라지면 안 된다).
단건 적용과 같은 다듬기(URL 인코딩 → 소스 정리)를 거치므로 화면에서 본 값이 저장된다.
- 예약 폼은 적용 폼과 **형제**로 둔다(HTML 은 폼 중첩을 허용하지 않는다). 편집기 내용은
JS 가 hidden 에 복사해 함께 보낸다.
- **시각은 KST 로 해석**한다(`datetime-local` 은 타임존이 없다). 과거 시각은 거부하되
폼을 채우는 동안 시간이 흐른 경우를 위해 1분 여유를 둔다.
- **실행은 `app/modules/cafe24/worker.py`** — compose 서비스 `dbx-cafe24-worker`
`--loop 60` 으로 돈다. 웹 요청 안에서 기다리는 방식은 프록시 타임아웃·재기동에
무너지므로 별도 프로세스여야 한다.
- worker 는 `claim_due_schedule`**한 건씩 `FOR UPDATE SKIP LOCKED`** 로 잠그고
PROCESSING 으로 바꾼 뒤 잠금을 푼다. worker 가 둘 떠 있어도 같은 예약을 두 번
적용하지 않고, 긴 API 호출 동안 DB 잠금을 쥐고 있지도 않는다.
- 적용 순서는 화면 편집과 같다: **현재값 재조회 → BACKUP revision → PUT → SUCCESS +
감사로그**(`actor=SCHEDULER`, `action=schedule_apply`). HTML 없이 진열/판매만 바꾸는
예약은 상세설명을 읽지도, 백업하지도 않는다.
- 실패는 `store.MAX_RETRY`(3) 안에서 1분 → 5분 → 15분 간격으로 재시도하고, 소진되면
`FAILED` 로 확정한다. 한 건의 오류가 worker 를 죽이지 않는다.
- **대기(PENDING) 상태만 취소**할 수 있다. 실행 중/완료된 예약은 건드리지 않는다.
---
## 3. OAuth 흐름
```
@@ -311,7 +350,19 @@ CAFE24_TOKEN_SECRET=<openssl rand -hex 32>
### 5-4. 재기동 + 권한 부여
```bash
cd /opt/www/main && docker compose up -d --build web
cd /opt/www/main && docker compose up -d --build web cafe24-worker
```
예약 기능을 쓰려면 마이그레이션 002 를 먼저 적용해야 한다(진열/판매 예약 컬럼).
```bash
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_002_schedule_flags.sql
```
worker 가 도는지 확인:
```bash
cd /opt/www/main && docker compose logs --tail=20 cafe24-worker
```
관리자 페이지(`/admin`)에서 직원에게 **카페24 상품관리** 권한을 부여한다.
@@ -337,8 +388,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| 2 | 상품 목록·검색·현재 HTML 조회 | ✅ 완료 |
| 3 | 편집기 · 미리보기 · Diff · 초안 | ◐ 문법 강조 편집기 + 소스 정리 완료. 미리보기·Diff·초안 예정 |
| 4 | 즉시 적용 · BACKUP · Revision · 감사로그 | ✅ 완료 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | 예정 |
| 6 | 자동 종료/복원 · 롤백 | 예정 |
| 5 | 예약 DB · Worker(compose 서비스) · 예약관리 화면 | ✅ 완료 |
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ◐ `<style>` 통일 일괄수정 완료. 일괄 예약 예정 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
@@ -0,0 +1,44 @@
-- =====================================================================
-- cafe24_db 002 — 예약에 진열/판매 상태 변경 추가
-- =====================================================================
-- 멱등(idempotent): 여러 번 실행해도 안전. DROP/TRUNCATE 없음.
--
-- 실행:
-- docker exec -i postgres-db psql -U postgres -d cafe24_db \
-- < scripts/sql/cafe24_db_002_schedule_flags.sql
--
-- 배경:
-- 예약 적용에서 상세페이지 HTML 뿐 아니라 진열/판매 상태도 함께 바꿀 수 있어야
-- 한다("지정 시각에 공개" 같은 운영 시나리오). 세 값 모두 "건드리지 않음"이
-- 가능해야 하므로 NULL 을 허용하는 BOOLEAN 으로 둔다.
-- set_display = NULL → 진열 상태 그대로
-- set_selling = NULL → 판매 상태 그대로
-- revision_id = NULL → 상세페이지 HTML 은 바꾸지 않음(상태만 변경)
--
-- 되돌리기(자동 복원)는 쓰지 않기로 했다. end_at / end_action /
-- end_revision_id / restore_revision_id 컬럼은 남겨두지만 사용하지 않는다
-- (컬럼 삭제는 파괴적이라 하지 않는다).
-- =====================================================================
\set ON_ERROR_STOP on
ALTER TABLE cafe24_product_schedules
ADD COLUMN IF NOT EXISTS set_display BOOLEAN,
ADD COLUMN IF NOT EXISTS set_selling BOOLEAN;
COMMENT ON COLUMN cafe24_product_schedules.set_display IS
'예약 시각에 적용할 진열 상태. NULL 이면 변경하지 않음';
COMMENT ON COLUMN cafe24_product_schedules.set_selling IS
'예약 시각에 적용할 판매 상태. NULL 이면 변경하지 않음';
COMMENT ON COLUMN cafe24_product_schedules.revision_id IS
'적용할 상세페이지 버전. NULL 이면 HTML 은 바꾸지 않고 진열/판매만 변경';
-- 아무것도 하지 않는 예약은 만들 수 없게 한다(실수 방지).
ALTER TABLE cafe24_product_schedules
DROP CONSTRAINT IF EXISTS chk_cafe24_schedule_has_action;
ALTER TABLE cafe24_product_schedules
ADD CONSTRAINT chk_cafe24_schedule_has_action CHECK (
revision_id IS NOT NULL OR set_display IS NOT NULL OR set_selling IS NOT NULL
);
SELECT 'cafe24_db 002 applied' AS status;