c6fb8ed375
카페24 관리자에 직접 접속하지 않고 상품 상세페이지(description HTML)를 편집·예약 적용·복원하기 위한 모듈의 기반을 만든다. Phase 1 은 공통 Integration 계층, cafe24_db, OAuth 연결 화면까지다. 카페24 OAuth/API 클라이언트를 상품관리 모듈 안에 두지 않고 app/integrations/cafe24/ 로 분리했다. 향후 추가할 주문관리(주문 조회·송장 일괄등록·취소/반품/교환)가 같은 토큰과 클라이언트를 그대로 재사용해야 하기 때문이다. 라우터에서 httpx 를 직접 부르지 않고 Cafe24Client 만 쓰게 해서 재시도·rate limit·API 로그·토큰 갱신을 한 곳에 모았다. 토큰은 Fernet 으로 암호화해 저장한다(CAFE24_TOKEN_SECRET). DB 덤프가 유출돼도 access/refresh token 이 평문으로 남지 않게 하기 위함이며, API 로그와 연결 상태 화면에는 토큰·시크릿을 일절 기록/표시하지 않는다. 토큰 갱신은 행 잠금(SELECT ... FOR UPDATE) 안에서 한다. 카페24는 refresh token 을 회전시키므로, 이후 추가될 예약 worker 컨테이너와 web 컨테이너가 동시에 갱신하면 한쪽 토큰이 무효화된다. 기존 파일 변경은 목록에 한 줄씩 추가하는 형태로 44줄뿐이며 기존 라우트· 테이블·인증 로직은 건드리지 않았다. CAFE24_DB_URL 미설정 시 store 가 None 이라 앱은 정상 기동하고 모듈만 "설정 필요" 안내를 표시한다. 가드 헬퍼를 common.py 로 분리한 것은 router.py 가 routes_system.py 를 include 하는 구조에서 순환 import 가 생기기 때문이다. 검증: 신규 테스트 16개 통과(암호화 왕복, 토큰 만료·자동갱신, 상태 노출 시 토큰 미유출, 재시도 예산, 예약 상태 전이). dispatch 기존 테스트 9개 통과. cafe24_db_init.sql 은 로컬에 Docker 가 없어 미실행 — 서버 적용 시 확인 필요. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
107 lines
3.3 KiB
Python
107 lines
3.3 KiB
Python
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
|
|
|
|
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
# ── 상세페이지 버전 종류 (cafe24_product_revisions.revision_type) ──
|
|
REVISION_SYNC = "SYNC" # 카페24 현재값 스냅샷
|
|
REVISION_DRAFT = "DRAFT" # 저장만 한 초안
|
|
REVISION_BACKUP = "BACKUP" # 쓰기 직전 자동 백업 ← 복원 기준
|
|
REVISION_MANUAL = "MANUAL" # 즉시 적용
|
|
REVISION_SCHEDULED = "SCHEDULED" # 예약 적용
|
|
REVISION_ROLLBACK = "ROLLBACK" # 과거 버전 되돌림
|
|
|
|
REVISION_TYPES: tuple[str, ...] = (
|
|
REVISION_SYNC,
|
|
REVISION_DRAFT,
|
|
REVISION_BACKUP,
|
|
REVISION_MANUAL,
|
|
REVISION_SCHEDULED,
|
|
REVISION_ROLLBACK,
|
|
)
|
|
|
|
REVISION_LABELS: dict[str, str] = {
|
|
REVISION_SYNC: "현재값 동기화",
|
|
REVISION_DRAFT: "초안",
|
|
REVISION_BACKUP: "적용 직전 자동백업",
|
|
REVISION_MANUAL: "즉시 적용",
|
|
REVISION_SCHEDULED: "예약 적용",
|
|
REVISION_ROLLBACK: "복원",
|
|
}
|
|
|
|
# ── 예약 상태 (cafe24_product_schedules.status) ──
|
|
STATUS_PENDING = "PENDING"
|
|
STATUS_PROCESSING = "PROCESSING"
|
|
STATUS_SUCCESS = "SUCCESS"
|
|
STATUS_FAILED = "FAILED"
|
|
STATUS_CANCELLED = "CANCELLED"
|
|
|
|
SCHEDULE_STATUSES: tuple[str, ...] = (
|
|
STATUS_PENDING,
|
|
STATUS_PROCESSING,
|
|
STATUS_SUCCESS,
|
|
STATUS_FAILED,
|
|
STATUS_CANCELLED,
|
|
)
|
|
|
|
SCHEDULE_STATUS_LABELS: dict[str, str] = {
|
|
STATUS_PENDING: "대기",
|
|
STATUS_PROCESSING: "실행중",
|
|
STATUS_SUCCESS: "완료",
|
|
STATUS_FAILED: "실패",
|
|
STATUS_CANCELLED: "취소",
|
|
}
|
|
|
|
# 사용자가 손댈 수 있는 상태 — PROCESSING/SUCCESS 는 임의 변경 금지
|
|
EDITABLE_STATUSES: tuple[str, ...] = (STATUS_PENDING,)
|
|
|
|
# 예약 실패 시 최대 재시도 횟수
|
|
MAX_RETRY = 3
|
|
|
|
# 종료 후 동작 (cafe24_product_schedules.end_action)
|
|
END_NONE = ""
|
|
END_RESTORE = "restore" # 적용 직전 BACKUP 으로 복원
|
|
END_REVISION = "revision" # 지정한 버전 적용
|
|
END_ACTIONS: tuple[str, ...] = (END_NONE, END_RESTORE, END_REVISION)
|
|
|
|
|
|
def is_editable(status: str) -> bool:
|
|
"""예약을 수정/취소할 수 있는 상태인지."""
|
|
return (status or "").strip().upper() in EDITABLE_STATUSES
|
|
|
|
|
|
def can_retry(retry_count: int) -> bool:
|
|
"""재시도 여지가 남았는지. 소진되면 FAILED 로 확정한다."""
|
|
try:
|
|
return int(retry_count) < MAX_RETRY
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
|
|
def retry_backoff_seconds(retry_count: int) -> int:
|
|
"""재시도 간격(초). 1분 → 5분 → 15분. 무한 재시도는 하지 않는다."""
|
|
table = (60, 300, 900)
|
|
try:
|
|
index = max(0, int(retry_count))
|
|
except (TypeError, ValueError):
|
|
index = 0
|
|
return table[min(index, len(table) - 1)]
|
|
|
|
|
|
def normalize_revision_type(value: str) -> str:
|
|
text = (value or "").strip().upper()
|
|
return text if text in REVISION_TYPES else REVISION_DRAFT
|
|
|
|
|
|
def parse_product_no(value: object) -> int:
|
|
"""상품번호 정규화. 잘못된 값이면 ValueError."""
|
|
try:
|
|
number = int(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
raise ValueError("상품번호는 숫자여야 합니다.") from None
|
|
if number <= 0:
|
|
raise ValueError("상품번호는 1 이상이어야 합니다.")
|
|
return number
|