8125a59366
- 옵션 1개 상품은 한 행에 썸네일·옵션값 이름·품목코드(복사)·자체코드· 추가금액·진열·판매를 두고 「저장」 한 번으로 옵션 PUT → 품목 PUT. - 옵션 없는 상품: 행 추가/삭제 후 저장 → 옵션 생성 → 카페24가 부여한 품목코드를 재시도 조회(products.wait_for_variants)로 받아 자체코드· 추가금액·썸네일까지 이어서 반영. - 순서 드래그는 품목 display_order 로 저장(옵션값 재배열 PUT 은 위치 짝맞춤 때문에 품목코드↔이름이 뒤바뀔 수 있어 사용하지 않음). - 「옵션값 불러오기」: 탭/공백 구분 텍스트(이름·판매가·추가금액·자체코드) 붙여넣기 → 행 자동 채움. 기존 옵션은 이름으로 짝지어 코드/금액만. - 옵션 2개 이상(조합) 상품은 기존 2열 화면 유지. - 모달 JS 를 app/static/cafe24-options.js 로 분리. 유닛테스트 90 통과, 통합 하네스 통과, 헤드리스 크롬 렌더 확인. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
806 lines
33 KiB
Python
806 lines
33 KiB
Python
"""카페24 모듈 순수 로직 — DB/네트워크 I/O 없음(유닛테스트 대상).
|
|
|
|
상수, 상태 전이 규칙, HTML 치환/검증처럼 부수효과 없는 함수만 둔다.
|
|
"""
|
|
|
|
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" # 저장만 한 초안
|
|
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 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:
|
|
number = int(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
raise ValueError("상품번호는 숫자여야 합니다.") from None
|
|
if number <= 0:
|
|
raise ValueError("상품번호는 1 이상이어야 합니다.")
|
|
return number
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 이미지 URL 의 한글 파일명 표시 (%EC%9A%A9… ↔ 용기…)
|
|
#
|
|
# 카페24는 상세페이지 HTML 안 이미지 경로를 퍼센트 인코딩해서 저장한다.
|
|
# src="/web/product/big/%EC%9A%A9%EA%B8%B0…(%ED%99%A9%ED%86%A0)_12.gif"
|
|
# 사람이 읽을 수 없으니 화면에서는 한글로 풀어 보여주고, 카페24에 쓸 때는 다시
|
|
# 원래 형식으로 되돌린다. 두 함수는 서로의 역이며 왕복이 보존돼야 한다
|
|
# (encode(decode(원본)) == 원본).
|
|
#
|
|
# 안전 규칙 두 가지:
|
|
# 1) 디코딩은 **non-ASCII 바이트(%80~%FF)** 만 한다. %20·%3C·%26 같은 ASCII
|
|
# 이스케이프를 풀면 HTML 구조나 쿼리스트링이 깨진다.
|
|
# 2) 인코딩은 **URL 속성값 안의 non-ASCII** 만 한다. 본문 한글 텍스트를
|
|
# 건드리면 페이지가 깨지므로 대상 범위를 정규식으로 좁힌다.
|
|
# ════════════════════════════════════════════════════════════
|
|
# src="..." / href='...' 같은 URL 속성값
|
|
_URL_ATTR_RE = re.compile(
|
|
r"""(?P<head>\b(?:src|href|poster|data-src|data-original)\s*=\s*(?P<q>["']))(?P<url>[^"']*)(?P=q)""",
|
|
re.IGNORECASE,
|
|
)
|
|
# CSS 의 url(...) — 인라인 <style> 안 배경 이미지
|
|
_CSS_URL_RE = re.compile(
|
|
r"""(?P<head>url\(\s*(?P<q>["']?))(?P<url>[^"')]*)(?P<tail>(?P=q)\s*\))""",
|
|
re.IGNORECASE,
|
|
)
|
|
# 연속된 %XX 중 첫 바이트가 0x80 이상인 구간(= UTF-8 멀티바이트 문자)
|
|
_NON_ASCII_PCT_RUN = re.compile(r"(?:%[89A-Fa-f][0-9A-Fa-f])+")
|
|
# 인코딩 대상에서 제외할 문자 = 모든 ASCII 출력문자.
|
|
# 결과적으로 non-ASCII 와 공백만 %XX 로 바뀐다. 괄호·밑줄·마침표는 카페24
|
|
# 원본에서도 인코딩되지 않은 채 쓰이므로 반드시 그대로 남겨야 한다.
|
|
_ASCII_SAFE = "".join(chr(code) for code in range(0x21, 0x7F))
|
|
|
|
|
|
def _decode_pct_run(match: re.Match[str]) -> str:
|
|
text = match.group(0)
|
|
try:
|
|
raw = bytes(int(text[i + 1 : i + 3], 16) for i in range(0, len(text), 3))
|
|
return raw.decode("utf-8")
|
|
except (ValueError, UnicodeDecodeError):
|
|
# UTF-8 이 아니면(EUC-KR 등) 건드리지 않는다 — 깨뜨리는 것보다 낫다.
|
|
return text
|
|
|
|
|
|
def decode_url_value(value: str) -> str:
|
|
return _NON_ASCII_PCT_RUN.sub(_decode_pct_run, value or "")
|
|
|
|
|
|
def encode_url_value(value: str) -> str:
|
|
return quote(value or "", safe=_ASCII_SAFE, encoding="utf-8")
|
|
|
|
|
|
def _map_urls(html: str, transform) -> str:
|
|
def attr(match: re.Match[str]) -> str:
|
|
return f"{match.group('head')}{transform(match.group('url'))}{match.group('q')}"
|
|
|
|
def css(match: re.Match[str]) -> str:
|
|
return f"{match.group('head')}{transform(match.group('url'))}{match.group('tail')}"
|
|
|
|
return _CSS_URL_RE.sub(css, _URL_ATTR_RE.sub(attr, html or ""))
|
|
|
|
|
|
def decode_html_urls(html: str) -> str:
|
|
"""화면 표시용 — URL 안 %XX(한글 등)를 원래 문자로 되돌린다."""
|
|
return _map_urls(html, decode_url_value)
|
|
|
|
|
|
def encode_html_urls(html: str) -> str:
|
|
"""카페24 저장용 — URL 안 non-ASCII 를 퍼센트 인코딩으로 되돌린다."""
|
|
return _map_urls(html, encode_url_value)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 소스 정리(포맷) — 태그마다 줄을 나누고 들여쓴다.
|
|
#
|
|
# ⚠️ 렌더링을 바꾸지 않는 것이 최우선이다. HTML 에서 공백은 의미가 있어서,
|
|
# 인라인 요소 사이에 줄바꿈을 넣으면 화면에 공백이 생긴다(이미지 사이가
|
|
# 벌어지는 고전적인 사고). 그래서 **블록 요소 경계에서만** 줄을 나눈다.
|
|
# img·br·span·a 같은 인라인 요소와 텍스트는 원래 줄에 그대로 둔다.
|
|
# <style>·<script>·<pre>·<textarea> 안은 한 글자도 건드리지 않는다.
|
|
# ════════════════════════════════════════════════════════════
|
|
|
|
# 앞뒤 공백이 렌더링에 영향을 주지 않는 구조 태그만 넣는다.
|
|
_BLOCK_TAGS = frozenset(
|
|
"""html head body div p table thead tbody tfoot tr td th caption colgroup col
|
|
ul ol li dl dt dd section article header footer nav aside main
|
|
figure figcaption form fieldset legend h1 h2 h3 h4 h5 h6 hr center blockquote
|
|
style script iframe noscript""".split()
|
|
)
|
|
# 안쪽을 원문 그대로 보존할 태그
|
|
_RAW_TAGS = frozenset({"style", "script", "pre", "textarea"})
|
|
# 닫는 태그가 없는 태그
|
|
_VOID_TAGS = frozenset(
|
|
"area base br col embed hr img input link meta param source track wbr".split()
|
|
)
|
|
# 들여쓰기가 무한히 깊어지지 않게 (닫는 태그를 생략한 HTML 이 흔하다)
|
|
_MAX_INDENT = 12
|
|
|
|
_TOKEN_RE = re.compile(
|
|
r"(?P<comment><!--.*?-->)"
|
|
r"|(?P<cdata><!\[CDATA\[.*?\]\]>)"
|
|
r"|(?P<decl><![^>]*>)"
|
|
r"|(?P<tag><(?P<slash>/?)\s*(?P<name>[a-zA-Z][\w:.-]*)"
|
|
r"(?P<attrs>(?:\"[^\"]*\"|'[^']*'|[^>\"'])*)>)",
|
|
re.DOTALL,
|
|
)
|
|
|
|
|
|
def format_html(html: str, *, indent: str = " ") -> str:
|
|
"""상세페이지 HTML 을 사람이 읽기 좋게 정리한다.
|
|
|
|
실패하면 원본을 그대로 돌려준다 — 정리보다 안 깨지는 게 중요하다.
|
|
같은 값을 두 번 넣어도 결과가 같다(멱등).
|
|
"""
|
|
source = html or ""
|
|
if not source.strip():
|
|
return source
|
|
try:
|
|
return _format_html(source, indent)
|
|
except Exception: # noqa: BLE001 — 어떤 이유로든 원본을 지키는 쪽을 택한다.
|
|
return source
|
|
|
|
|
|
def _format_html(source: str, indent: str) -> str:
|
|
lines: list[str] = []
|
|
buffer = ""
|
|
depth = 0
|
|
|
|
def pad(level: int) -> str:
|
|
return indent * min(max(level, 0), _MAX_INDENT)
|
|
|
|
def flush() -> None:
|
|
"""모아둔 인라인/텍스트를 내보낸다.
|
|
|
|
원문에 이미 있던 줄바꿈은 **그대로 살린다.** 이미지가 한 줄에 하나씩 적혀
|
|
있으면 그 모양이 저자의 의도이고, 한 줄로 합치면 오히려 읽기 어려워진다.
|
|
각 줄마다 현재 깊이로 들여쓴다(줄 앞 공백은 렌더링에 영향이 없다).
|
|
빈 줄은 연속 한 개까지만 남겨 구획을 유지한다.
|
|
"""
|
|
nonlocal buffer
|
|
# 양 끝 공백을 함께 제거한다. `"\n"` 만 벗기면 끝에 남은 `"\n "` 조각이
|
|
# 빈 줄로 바뀌어 실행마다 빈 줄이 하나씩 늘어난다(멱등 깨짐).
|
|
# 블록 태그 경계의 공백은 렌더링에 영향이 없으므로 제거해도 안전하다.
|
|
text = buffer.strip()
|
|
buffer = ""
|
|
if not text:
|
|
return
|
|
for raw_line in text.split("\n"):
|
|
line = raw_line.strip()
|
|
if not line:
|
|
# 문서 맨 앞이나 빈 줄 뒤에는 빈 줄을 더하지 않는다.
|
|
if lines and lines[-1] != "":
|
|
lines.append("")
|
|
continue
|
|
lines.append(pad(depth) + line)
|
|
|
|
position = 0
|
|
while True:
|
|
match = _TOKEN_RE.search(source, position)
|
|
if match is None:
|
|
buffer += source[position:]
|
|
break
|
|
|
|
buffer += source[position : match.start()]
|
|
position = match.end()
|
|
raw = match.group(0)
|
|
|
|
# 주석·DOCTYPE 등은 흐름에 그대로 둔다.
|
|
# 상세페이지에는 `<!-- 대파_타임랩스 --><img ...>` 처럼 바로 뒤 요소를
|
|
# 설명하는 주석이 많다. 줄을 강제로 나누면 라벨과 대상이 떨어져 오히려
|
|
# 읽기 나빠진다. 원문에서 줄이 나뉘어 있었다면 flush 가 그 줄바꿈을 살린다.
|
|
if match.group("comment") or match.group("cdata") or match.group("decl"):
|
|
buffer += raw
|
|
continue
|
|
|
|
name = (match.group("name") or "").lower()
|
|
closing = bool(match.group("slash"))
|
|
self_closed = (match.group("attrs") or "").rstrip().endswith("/")
|
|
|
|
# <style>/<script>/<pre>/<textarea> 안은 원문 유지
|
|
if name in _RAW_TAGS and not closing:
|
|
end = re.compile(r"</\s*%s\s*>" % re.escape(name), re.IGNORECASE).search(
|
|
source, position
|
|
)
|
|
inner = source[position : end.start()] if end else source[position:]
|
|
flush()
|
|
lines.append(pad(depth) + raw)
|
|
# 앞뒤 빈 줄은 버린다 — 남기면 매번 실행할 때마다 한 줄씩 늘어난다(멱등 깨짐).
|
|
body = inner.strip("\n")
|
|
if body:
|
|
for line in body.split("\n"):
|
|
lines.append(line.rstrip())
|
|
if end:
|
|
lines.append(pad(depth) + end.group(0))
|
|
position = end.end()
|
|
else:
|
|
position = len(source)
|
|
continue
|
|
|
|
# 인라인 태그와 텍스트는 줄을 나누지 않는다 (공백이 생기면 렌더링이 바뀐다)
|
|
if name not in _BLOCK_TAGS:
|
|
buffer += raw
|
|
continue
|
|
|
|
if closing:
|
|
flush()
|
|
depth -= 1
|
|
lines.append(pad(depth) + raw)
|
|
else:
|
|
flush()
|
|
lines.append(pad(depth) + raw)
|
|
if name not in _VOID_TAGS and not self_closed:
|
|
depth += 1
|
|
|
|
flush()
|
|
return "\n".join(_collapse_short_blocks(lines))
|
|
|
|
|
|
# 짧은 블록을 한 줄로 되돌릴 때 쓰는 패턴
|
|
_OPEN_TAG_LINE = re.compile(
|
|
r"^(?P<pad>\s*)<(?P<name>[a-zA-Z][\w:.-]*)(?:\"[^\"]*\"|'[^']*'|[^>\"'])*>$"
|
|
)
|
|
_BLOCK_TAG_IN_TEXT = re.compile(
|
|
r"</?(?:%s)\b" % "|".join(sorted(_BLOCK_TAGS)), re.IGNORECASE
|
|
)
|
|
# 한 줄로 합칠 최대 길이
|
|
_COLLAPSE_WIDTH = 120
|
|
|
|
|
|
def _collapse_short_blocks(lines: list[str]) -> list[str]:
|
|
"""`<td>\n 1\n</td>` 처럼 내용이 한 줄뿐인 짧은 블록은 한 줄로 되돌린다.
|
|
|
|
보기 좋게 하려는 것이며, 합치는 규칙이 결정적이라 멱등성은 유지된다.
|
|
"""
|
|
out: list[str] = []
|
|
index = 0
|
|
while index < len(lines):
|
|
opening = _OPEN_TAG_LINE.match(lines[index])
|
|
if opening and index + 2 < len(lines):
|
|
name = opening.group("name").lower()
|
|
middle = lines[index + 1].strip()
|
|
closing = lines[index + 2].strip()
|
|
merged = lines[index] + middle + closing
|
|
if (
|
|
name not in _VOID_TAGS
|
|
and name not in _RAW_TAGS
|
|
and closing.lower() == f"</{name}>"
|
|
and middle
|
|
and not _BLOCK_TAG_IN_TEXT.search(middle)
|
|
and len(merged) <= _COLLAPSE_WIDTH
|
|
):
|
|
out.append(merged)
|
|
index += 3
|
|
continue
|
|
out.append(lines[index])
|
|
index += 1
|
|
return out
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 예약 입력 검증
|
|
#
|
|
# 되돌리기(자동 복원)는 쓰지 않는다. 예약은 "지정 시각에 이 내용을 적용" 뿐이다.
|
|
# 세 가지를 각각 선택할 수 있다 — 상세페이지 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 값 지문. 적용 직전 값과 비교해 충돌을 잡는다.
|
|
|
|
편집 중에 다른 사람이 카페24 관리자에서 같은 상품을 바꿨다면, 우리가 쓰는
|
|
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
|
|
"""
|
|
return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32]
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 카페24 읽기 지연(read-after-write lag) 보정
|
|
#
|
|
# 실물 관찰: PUT 이 성공하고 쇼핑몰 화면에는 바로 반영되는데도, 관리자 API
|
|
# (`GET /admin/products/{no}`)는 한동안 **직전 값**을 돌려준다. 몇 초로 끝날 때도
|
|
# 있고 훨씬 길 때도 있다. 우리 쪽에는 캐시가 없으므로(no-store) 그 값을 그대로
|
|
# 보여주면 "적용했는데 예전 소스가 보이는" 증상이 된다. 더 나쁜 것은 그 예전 값으로
|
|
# 지문을 만들어 다음 적용 때 충돌로 오판하거나, 예전 값을 백업으로 남기는 것이다.
|
|
#
|
|
# 규칙: **마지막 쓰기가 권위다.**
|
|
# - 카페24 값이 마지막 쓰기와 같다 → synced (따라잡음)
|
|
# - 카페24 값이 우리가 아는 *과거 값* 중 하나 → pending (읽기 지연 — 마지막 쓰기를 보여준다)
|
|
# (최근 유예시간 안의 BACKUP/MANUAL/... revision 해시와 대조)
|
|
# - 카페24 값이 우리가 모르는 값 → external (관리자에서 직접 고침 — 카페24 값을 믿는다)
|
|
# - 최근 쓰기가 없다 → none (카페24 값 그대로)
|
|
# 유예시간(grace)이 지나면 무조건 카페24 값을 믿는다 — 지연은 영원하지 않고,
|
|
# 우리가 영원히 로컬 값을 고집하면 그것이 또 다른 캐시가 된다.
|
|
# ════════════════════════════════════════════════════════════
|
|
SYNC_SYNCED = "synced"
|
|
SYNC_PENDING = "pending"
|
|
SYNC_EXTERNAL = "external"
|
|
SYNC_NONE = "none"
|
|
|
|
# 쓰기 종류 — 이 revision 들은 "우리가 카페24에 올린 값"이다.
|
|
WRITE_REVISION_TYPES: tuple[str, ...] = (REVISION_MANUAL, REVISION_SCHEDULED, REVISION_ROLLBACK)
|
|
|
|
# 읽기 지연 유예시간 기본값(분). 환경변수 CAFE24_READ_LAG_GRACE_MIN 로 조정.
|
|
DEFAULT_READ_LAG_GRACE_MIN = 360
|
|
|
|
|
|
def parse_grace_minutes(value: object, default: int = DEFAULT_READ_LAG_GRACE_MIN) -> int:
|
|
try:
|
|
minutes = int(str(value or "").strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return minutes if minutes > 0 else default
|
|
|
|
|
|
def _as_aware(value: object) -> datetime | None:
|
|
"""ISO 문자열/naive datetime → KST aware datetime. 못 읽으면 None."""
|
|
if isinstance(value, datetime):
|
|
return value if value.tzinfo else value.replace(tzinfo=KST)
|
|
text = str(value or "").strip()
|
|
if not text:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(text)
|
|
except ValueError:
|
|
return None
|
|
return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST)
|
|
|
|
|
|
def within_grace(written_at: object, *, grace_minutes: int, now: datetime | None = None) -> bool:
|
|
"""쓰기 시각이 유예시간 안인지."""
|
|
at = _as_aware(written_at)
|
|
if at is None:
|
|
return False
|
|
current = now or datetime.now(KST)
|
|
return current - at <= timedelta(minutes=max(1, int(grace_minutes)))
|
|
|
|
|
|
def resolve_description(
|
|
cafe24_html: str,
|
|
*,
|
|
last_write: dict | None,
|
|
known_digests: set[str] | frozenset[str],
|
|
grace_minutes: int,
|
|
now: datetime | None = None,
|
|
) -> tuple[str, str]:
|
|
"""(화면·적용에 쓸 유효 HTML, 상태) 를 돌려준다.
|
|
|
|
last_write: 최근 쓰기 revision — {"html_content", "created_at"}.
|
|
known_digests: 유예시간 안의 revision 들의 내용 해시(`content_digest()` 결과) 집합.
|
|
"""
|
|
if not last_write:
|
|
return cafe24_html, SYNC_NONE
|
|
if not within_grace(last_write.get("created_at"), grace_minutes=grace_minutes, now=now):
|
|
return cafe24_html, SYNC_NONE
|
|
|
|
last_html = str(last_write.get("html_content") or "")
|
|
if cafe24_html == last_html:
|
|
return cafe24_html, SYNC_SYNCED
|
|
if content_digest(cafe24_html) in known_digests:
|
|
return last_html, SYNC_PENDING
|
|
return cafe24_html, SYNC_EXTERNAL
|
|
|
|
|
|
def content_digest(html: str) -> str:
|
|
"""revision 내용 대조용 md5 hex — DB 의 `md5(html_content)` 와 같은 값.
|
|
|
|
(`fingerprint` 는 낙관적 잠금용 sha256 이고, 이것은 "우리가 아는 값인가" 대조용이다.
|
|
md5 는 모든 PostgreSQL 에 내장돼 있어 DB 쪽에서 계산할 수 있다.)
|
|
"""
|
|
return hashlib.md5((html or "").encode("utf-8")).hexdigest() # noqa: S324 — 보안 목적 아님
|
|
|
|
|
|
# 쓰기 직후 스냅샷에 보관하고, 지연 판정 시 덮어씌우는 스칼라 필드.
|
|
# description 은 넣지 않는다(크기 — revision 이 담당).
|
|
SNAPSHOT_FIELDS: tuple[str, ...] = (
|
|
"product_name",
|
|
"product_code",
|
|
"price",
|
|
"supply_price",
|
|
"retail_price",
|
|
"display",
|
|
"selling",
|
|
"detail_image",
|
|
"list_image",
|
|
"tiny_image",
|
|
"small_image",
|
|
"updated_date",
|
|
"separated_mobile_description",
|
|
)
|
|
|
|
|
|
def product_snapshot(product: dict) -> dict:
|
|
"""PUT 응답(상품 dict) → 스냅샷(JSONB 저장용). 값은 문자열/숫자/불리언만 남긴다."""
|
|
out: dict = {}
|
|
for key in SNAPSHOT_FIELDS:
|
|
if key in product and product[key] is not None:
|
|
value = product[key]
|
|
out[key] = value if isinstance(value, (bool, int, float)) else str(value)
|
|
return out
|
|
|
|
|
|
def overlay_recent_write(
|
|
fetched: dict,
|
|
*,
|
|
snapshot: dict | None,
|
|
written_at: object,
|
|
grace_minutes: int,
|
|
now: datetime | None = None,
|
|
) -> tuple[dict, bool]:
|
|
"""카페24 GET 결과가 우리 마지막 쓰기보다 오래됐으면 스냅샷 값을 덮어씌운다.
|
|
|
|
판정 근거는 카페24 자신의 `updated_date` 다 — PUT 응답의 updated_date(스냅샷)
|
|
보다 GET 의 updated_date 가 **이전**이면 GET 이 아직 예전 레코드를 돌려주는
|
|
것이다. 우리 서버 시계와 비교하지 않으므로 시계 차이에 영향받지 않는다.
|
|
날짜가 없어 판정할 수 없으면 카페24 값을 그대로 둔다.
|
|
반환: (병합된 상품 dict, 지연 여부)
|
|
"""
|
|
if not snapshot:
|
|
return fetched, False
|
|
if not within_grace(written_at, grace_minutes=grace_minutes, now=now):
|
|
return fetched, False
|
|
fetched_at = _as_aware(fetched.get("updated_date"))
|
|
snap_at = _as_aware(snapshot.get("updated_date"))
|
|
if fetched_at is None or snap_at is None or fetched_at >= snap_at:
|
|
return fetched, False
|
|
merged = dict(fetched)
|
|
for key in SNAPSHOT_FIELDS:
|
|
if key in snapshot and key != "updated_date":
|
|
merged[key] = snapshot[key]
|
|
merged["updated_date"] = snapshot.get("updated_date") or fetched.get("updated_date")
|
|
return merged, True
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 기본 정보(상품명·가격) 입력 검증 — 오른쪽 정보 패널
|
|
# ════════════════════════════════════════════════════════════
|
|
NAME_MAX = 250
|
|
PRICE_MAX = 2_147_483_647
|
|
|
|
|
|
def parse_price(value: object, *, field: str = "가격") -> str | None:
|
|
"""'6,900' / '6900.00' / 6900 → '6900.00'. 빈 값이면 None(변경 안 함)."""
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip().replace(",", "").replace("원", "")
|
|
if not text:
|
|
return None
|
|
try:
|
|
number = int(round(float(text)))
|
|
except ValueError:
|
|
raise ValueError(f"{field}은(는) 숫자여야 합니다.") from None
|
|
if number < 0 or number > PRICE_MAX:
|
|
raise ValueError(f"{field}은(는) 0 이상 {PRICE_MAX:,} 이하여야 합니다.")
|
|
return f"{number}.00"
|
|
|
|
|
|
def price_equal(a: object, b: object) -> bool:
|
|
"""카페24 '6900.00' 과 우리 '6900.00'/'6900' 을 같은 값으로 본다."""
|
|
|
|
def norm(v: object) -> str | None:
|
|
try:
|
|
return parse_price(v)
|
|
except ValueError:
|
|
return str(v)
|
|
|
|
return norm(a) == norm(b)
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════
|
|
# 옵션/품목 payload — 순수 변환(검증)만. 전송은 integrations.products 가 한다.
|
|
# ════════════════════════════════════════════════════════════
|
|
OPTION_DISPLAY_TYPES: tuple[str, ...] = ("S", "P", "B", "R")
|
|
OPTION_DISPLAY_LABELS: dict[str, str] = {
|
|
"S": "셀렉트박스",
|
|
"P": "미리보기(이미지)",
|
|
"B": "텍스트버튼",
|
|
"R": "라디오버튼",
|
|
}
|
|
VARIANT_CODE_RE = re.compile(r"^[A-Z0-9]{12}$")
|
|
CUSTOM_CODE_MAX = 40
|
|
ADDITIONAL_AMOUNT_MAX = 2_147_483_647
|
|
|
|
|
|
def parse_option_values(raw: object) -> list[str]:
|
|
"""'빨강, 파랑\\n노랑' 또는 ['빨강','파랑'] → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).
|
|
|
|
목록으로 오면 항목 안의 쉼표는 이름의 일부로 본다(화면이 행 단위로 보낼 때).
|
|
"""
|
|
if isinstance(raw, (list, tuple)):
|
|
parts = [str(p) for p in raw]
|
|
else:
|
|
parts = re.split(r"[,\n]", str(raw or ""))
|
|
seen: list[str] = []
|
|
for part in parts:
|
|
value = part.strip()
|
|
if value and value not in seen:
|
|
seen.append(value)
|
|
return seen
|
|
|
|
|
|
def build_create_options_request(
|
|
option_name: str, values: list[str], *, display_type: str = "S"
|
|
) -> dict:
|
|
name = (option_name or "").strip()
|
|
if not name:
|
|
raise ValueError("옵션명을 입력하세요.")
|
|
if not values:
|
|
raise ValueError("옵션값을 하나 이상 입력하세요.")
|
|
dtype = (display_type or "S").strip().upper()
|
|
if dtype not in OPTION_DISPLAY_TYPES:
|
|
dtype = "S"
|
|
return {
|
|
"has_option": "T",
|
|
"option_type": "T", # 조합형
|
|
"option_list_type": "S", # 조합 분리선택형
|
|
"options": [
|
|
{
|
|
"option_name": name,
|
|
"option_value": [{"option_text": v} for v in values],
|
|
"option_display_type": dtype,
|
|
}
|
|
],
|
|
}
|
|
|
|
|
|
def build_update_options_request(
|
|
original: list[dict], edited: list[dict], *, option_list_type: str = ""
|
|
) -> dict:
|
|
"""옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청.
|
|
|
|
카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를
|
|
**같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로
|
|
할 수 없으므로 개수가 다르면 거부한다.
|
|
original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록.
|
|
"""
|
|
if len(original) != len(edited):
|
|
raise ValueError("옵션 개수가 카페24와 다릅니다. 다시 읽은 뒤 수정하세요.")
|
|
orig_out: list[dict] = []
|
|
new_out: list[dict] = []
|
|
for o, e in zip(original, edited):
|
|
o_vals = list(o.get("option_value") or [])
|
|
e_vals = list(e.get("option_value") or [])
|
|
if len(o_vals) != len(e_vals):
|
|
raise ValueError(
|
|
f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. "
|
|
"옵션값 추가/삭제는 이 API 로 할 수 없습니다."
|
|
)
|
|
name = str(e.get("option_name") or "").strip()
|
|
if not name:
|
|
raise ValueError("옵션명은 비울 수 없습니다.")
|
|
orig_entry: dict = {
|
|
"option_name": str(o.get("option_name") or ""),
|
|
"option_value": [],
|
|
}
|
|
new_entry: dict = {"option_name": name, "option_value": []}
|
|
if o.get("option_code"):
|
|
orig_entry["option_code"] = o["option_code"]
|
|
new_entry["option_code"] = o["option_code"]
|
|
dtype = str(
|
|
e.get("option_display_type") or o.get("option_display_type") or ""
|
|
).strip().upper()
|
|
if dtype in OPTION_DISPLAY_TYPES:
|
|
new_entry["option_display_type"] = dtype
|
|
for ov, ev in zip(o_vals, e_vals):
|
|
text = str(ev.get("option_text") or "").strip()
|
|
if not text:
|
|
raise ValueError("옵션값 이름은 비울 수 없습니다.")
|
|
o_item: dict = {"option_text": str(ov.get("option_text") or "")}
|
|
n_item: dict = {"option_text": text}
|
|
if ov.get("value_no") is not None:
|
|
o_item["value_no"] = ov["value_no"]
|
|
n_item["value_no"] = ov["value_no"]
|
|
for key in ("option_image_file", "option_link_image", "option_color"):
|
|
value = ev.get(key)
|
|
if value is None:
|
|
continue
|
|
value = str(value).strip()
|
|
if value:
|
|
n_item[key] = value
|
|
orig_entry["option_value"].append(o_item)
|
|
new_entry["option_value"].append(n_item)
|
|
orig_out.append(orig_entry)
|
|
new_out.append(new_entry)
|
|
request: dict = {"original_options": orig_out, "options": new_out}
|
|
list_type = (option_list_type or "").strip().upper()
|
|
if list_type in ("S", "C"):
|
|
request["option_list_type"] = list_type
|
|
return request
|
|
|
|
|
|
def parse_additional_amount(value: object) -> str | None:
|
|
"""'1,000' / '-500' → '1000.00'. 빈 값은 None(변경 안 함)."""
|
|
if value is None:
|
|
return None
|
|
text = str(value).strip().replace(",", "").replace("원", "")
|
|
if not text:
|
|
return None
|
|
try:
|
|
number = int(round(float(text)))
|
|
except ValueError:
|
|
raise ValueError("추가금액은 숫자여야 합니다.") from None
|
|
if abs(number) > ADDITIONAL_AMOUNT_MAX:
|
|
raise ValueError("추가금액 범위를 벗어났습니다.")
|
|
return f"{number}.00"
|
|
|
|
|
|
def build_variant_updates(rows: list[dict]) -> list[dict]:
|
|
"""화면 행 목록 → PUT /variants `requests` 배열. 바뀔 것이 없는 행은 뺀다."""
|
|
out: list[dict] = []
|
|
for row in rows:
|
|
code = str(row.get("variant_code") or "").strip().upper()
|
|
if not VARIANT_CODE_RE.match(code):
|
|
raise ValueError(f"품목코드 형식이 올바르지 않습니다: {code or '(빈 값)'}")
|
|
item: dict = {"variant_code": code}
|
|
if row.get("custom_variant_code") is not None:
|
|
custom = str(row["custom_variant_code"]).strip()
|
|
if len(custom) > CUSTOM_CODE_MAX:
|
|
raise ValueError(f"자체 품목코드는 {CUSTOM_CODE_MAX}자를 넘을 수 없습니다.")
|
|
item["custom_variant_code"] = custom
|
|
if "additional_amount" in row:
|
|
amount = parse_additional_amount(row["additional_amount"])
|
|
if amount is not None:
|
|
item["additional_amount"] = amount
|
|
for flag in ("display", "selling"):
|
|
if row.get(flag) is not None:
|
|
item[flag] = "T" if parse_tristate(row[flag]) else "F"
|
|
# 진열 순서(1~300) — 카페24 문서: 조합형 옵션 품목에만. 화면의 드래그 정렬이 보낸다.
|
|
if row.get("display_order") is not None:
|
|
try:
|
|
order = int(row["display_order"])
|
|
except (TypeError, ValueError):
|
|
raise ValueError("진열 순서는 숫자여야 합니다.") from None
|
|
if not 1 <= order <= 300:
|
|
raise ValueError("진열 순서는 1~300 사이여야 합니다.")
|
|
item["display_order"] = order
|
|
if len(item) > 1:
|
|
out.append(item)
|
|
return out
|