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:
@@ -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]:
|
||||
|
||||
@@ -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 (다른 모듈과 동일)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user