feat(cafe24): 읽기 지연 보정("마지막 쓰기가 권위") + 상품 정보 패널

증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야
반영됨. 원인은 우리 캐시가 아니라(전부 no-store) 카페24 관리자 API 가 PUT
뒤 한동안 GET 에서 예전 값을 돌려주는 읽기 지연. 예전 코드는 2.4초만
기다린 뒤 GET 값을 그대로 믿어 예전 소스 표시·지문 충돌 오판·예전 값
백업이 생겼다.

- 상세설명: 쓰기 성공 시 MANUAL/SCHEDULED revision 을 기준으로, 카페24 값이
  유예시간 안의 revision 중 하나와 같으면 지연(pending)으로 보고 마지막
  쓰기를 표시·지문 기준으로 쓴다. 모르는 값이면 외부 변경(external).
  store.resolve_description / db.revision_digests(md5) / 배너 2종.
- 적용(apply)은 유효 현재값으로 BACKUP·지문 대조·변경없음 판정. 재조회
  확인 결과는 감사로그에만 남긴다.
- 스칼라(상품명·가격·이미지·진열/판매): PUT 응답을 cafe24_products.
  last_write_snapshot(JSONB, 마이그레이션 004)에 남기고 GET 의 updated_date
  가 그보다 이전이면 스냅샷으로 덮어씀. 옵션/품목도 섹션별 스냅샷.
- 3분할 화면: 목록 | 편집기 | 상품 정보 패널(_side.html, /pane 이 두 조각을
  한 응답으로). routes_product_info.py JSON API — 상품명/판매가/공급가/
  소비자가, 대표이미지 업로드(POST /admin/products/images → PUT detail_image
  + image_upload_type=A), 옵션 생성/이름·썸네일·표시방식 수정/삭제, 품목
  자체코드·추가금액·진열·판매 일괄 수정. 화면은 PUT 응답으로 그린다.
- client.delete/timeout, products.upload_images·options·variants 래퍼.
- 유닛테스트 21건 추가(88 통과), 문서(CAFE24_MODULE 3-3/3-4, DATABASES,
  .env.example CAFE24_READ_LAG_GRACE_MIN) 갱신.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 18:07:42 +09:00
parent 8ea0db9245
commit 40766d805d
21 changed files with 2782 additions and 99 deletions
+6
View File
@@ -105,6 +105,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/
# 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
# CAFE24_SHOP_URL=https://www.miras.co.kr
#
# 카페24 읽기 지연 유예시간(분). 카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전
# 값을 돌려준다. 이 시간 안에 우리가 쓴 값이 있으면, GET 이 그 이전 값을 돌려줘도
# 마지막 쓰기(revision·스냅샷)를 화면·적용 기준으로 삼는다. 지나면 카페24 값을 믿는다.
# 미설정 시 360(6시간).
# CAFE24_READ_LAG_GRACE_MIN=360
#
# access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
# 카페24 재연결(재인증)이 필요하다.
+1 -1
View File
@@ -35,7 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver`
- 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia`
- 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md`
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·버전 이력. 3분할 화면(목록 | HTML 편집기 | 상품 정보 패널). 정보 패널(`routes_product_info.py`, `_side.html`)에서 상품명·판매가·공급가·소비자가, 대표이미지(업로드 → `POST /admin/products/images` → PUT `detail_image`+`image_upload_type=A`), 옵션(생성/이름·썸네일·표시방식 수정/삭제)·품목(자체코드·추가금액·진열·판매)을 수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). **카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전 값을 돌려준다(읽기 지연)** — 우리 캐시 문제가 아니다. 그래서 "마지막 쓰기가 권위": 상세설명은 카페24 값이 최근 revision 중 하나와 같으면 지연으로 보고 마지막 MANUAL/SCHEDULED 를 표시·지문 기준으로 쓰고(`store.resolve_description`), 스칼라(상품명·가격·이미지·진열/판매)는 PUT 응답 스냅샷(`cafe24_products.last_write_snapshot`, 마이그레이션 004)과 GET 의 `updated_date` 를 비교해 덮어씌운다(`store.overlay_recent_write`). 유예시간 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 쓰기 후 화면은 PUT 응답으로 그리고 다시 GET 하지 않는다. 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md`
- 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md`
상세는 `docs/PROJECT_OVERVIEW.md`.
+9 -2
View File
@@ -148,8 +148,12 @@ class Cafe24Client:
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
product_no: int | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환."""
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.
timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값.
"""
if not self._config.configured:
raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
@@ -167,7 +171,7 @@ class Cafe24Client:
status: int | None = None
try:
access_token = self._tokens.get_access_token()
with httpx.Client(timeout=self._timeout) as client:
with httpx.Client(timeout=timeout or self._timeout) as client:
response = client.request(
method,
url,
@@ -256,3 +260,6 @@ class Cafe24Client:
def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("POST", path, **kwargs)
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("DELETE", path, **kwargs)
+143 -1
View File
@@ -192,6 +192,11 @@ def build_update_payload(
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
price: str | None = None,
supply_price: str | None = None,
retail_price: str | None = None,
detail_image: str | None = None,
image_upload_type: str | None = None,
) -> dict[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
@@ -217,6 +222,19 @@ def build_update_payload(
request["display"] = _flag_value(display)
if selling is not None:
request["selling"] = _flag_value(selling)
# 가격은 카페24 예제 형식('11000.00') 문자열 그대로 보낸다.
if price is not None:
request["price"] = price
if supply_price is not None:
request["supply_price"] = supply_price
if retail_price is not None:
request["retail_price"] = retail_price
# 대표 이미지: /admin/products/images 로 먼저 올린 경로를 detail_image 에 넣고
# image_upload_type="A"(대표이미지등록) 로 목록/작은목록/축소 이미지를 카페24가
# 리사이징하게 한다. (문서: A 대표이미지등록 / B 개별이미지등록 / C 웹FTP)
if detail_image is not None:
request["detail_image"] = detail_image
request["image_upload_type"] = image_upload_type or "A"
payload: dict[str, Any] = {"request": request}
if shop_no:
payload["shop_no"] = int(shop_no)
@@ -234,10 +252,18 @@ def update_product(
display: bool | None = None,
selling: bool | None = None,
shop_no: int | None = None,
price: str | None = None,
supply_price: str | None = None,
retail_price: str | None = None,
detail_image: str | None = None,
image_upload_type: str | None = None,
) -> dict[str, Any]:
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다.
"""상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로.
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을
돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로
남겨 화면을 맞춘다(`store.product_snapshot`).
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
@@ -250,6 +276,11 @@ def update_product(
display=display,
selling=selling,
shop_no=shop_no,
price=price,
supply_price=supply_price,
retail_price=retail_price,
detail_image=detail_image,
image_upload_type=image_upload_type,
)
if not payload["request"]:
return {}
@@ -283,6 +314,117 @@ def update_descriptions(
)
# ════════════════════════════════════════════════════════════
# 이미지 업로드 — POST /admin/products/images
# 문서: base64 인코딩 이미지, 1건 10MB, 1호출 30MB, 1회 20장.
# 응답 {"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]} 의 path 를
# 상품 detail_image / 옵션 option_image_file 등에 그대로 넣는다.
# ════════════════════════════════════════════════════════════
IMAGE_MAX_BYTES = 10 * 1024 * 1024
UPLOAD_TIMEOUT = 120.0
def upload_images(client: Cafe24Client, images_b64: list[str]) -> list[str]:
"""base64 문자열 목록 → 업로드된 경로 목록(입력 순서 유지)."""
if not images_b64:
return []
payload = {"requests": [{"image": b64} for b64 in images_b64[:20]]}
response = client.post("/admin/products/images", json=payload, timeout=UPLOAD_TIMEOUT)
images = response.get("images")
if not isinstance(images, list):
return []
return [str(item.get("path") or "") for item in images if isinstance(item, dict)]
def upload_image_bytes(client: Cafe24Client, data: bytes) -> str:
"""이미지 1장(바이트) 업로드 → 경로. 비어 있거나 응답이 이상하면 빈 문자열."""
import base64 # noqa: WPS433
if not data:
return ""
paths = upload_images(client, [base64.b64encode(data).decode("ascii")])
return paths[0] if paths else ""
def set_main_image(client: Cafe24Client, product_no: int, image_path: str) -> dict[str, Any]:
"""대표이미지 교체 — 목록/작은목록/축소 이미지는 카페24가 리사이징(A 타입)."""
return update_product(client, product_no, detail_image=image_path, image_upload_type="A")
# ════════════════════════════════════════════════════════════
# 옵션 — /admin/products/{no}/options
# GET → {"option": {has_option, option_type, option_list_type, options:[...]}}
# POST → {"request": {has_option:"T", option_type:"T", options:[...]}} (품목 자동 생성)
# PUT → {"request": {original_options:[...], options:[...]}} (이름/값/이미지만 수정)
# DELETE → 옵션 사용안함 + 품목 전부 삭제(주의)
# ════════════════════════════════════════════════════════════
def get_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
no = int(product_no)
payload = client.get(f"/admin/products/{no}/options", product_no=no)
option = payload.get("option")
return option if isinstance(option, dict) else {}
def create_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
no = int(product_no)
payload = client.post(
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
)
option = payload.get("option")
return option if isinstance(option, dict) else payload
def update_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]:
no = int(product_no)
payload = client.put(
f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no
)
option = payload.get("option")
return option if isinstance(option, dict) else payload
def delete_options(client: Cafe24Client, product_no: int) -> dict[str, Any]:
no = int(product_no)
return client.delete(f"/admin/products/{no}/options", product_no=no)
# ════════════════════════════════════════════════════════════
# 품목(variants) — /admin/products/{no}/variants
# GET → {"variants":[{variant_code, options:[{name,value}], custom_variant_code,
# display, selling, additional_amount, quantity, image?…}]}
# PUT (여러 건) → {"shop_no":1, "requests":[{variant_code, display, selling,
# custom_variant_code, additional_amount, …}]}
# ════════════════════════════════════════════════════════════
def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]:
no = int(product_no)
payload = client.get(f"/admin/products/{no}/variants", product_no=no)
variants = payload.get("variants")
return variants if isinstance(variants, list) else []
def update_variants(
client: Cafe24Client, product_no: int, requests: list[dict[str, Any]]
) -> list[dict[str, Any]]:
"""여러 품목 부분 수정. 100건씩 나눠 보낸다(문서상 1회 100건 제한)."""
no = int(product_no)
out: list[dict[str, Any]] = []
for start in range(0, len(requests), 100):
chunk = requests[start : start + 100]
if not chunk:
continue
payload = client.put(
f"/admin/products/{no}/variants", json={"shop_no": 1, "requests": chunk}, product_no=no
)
result = payload.get("variants") if isinstance(payload, dict) else None
if isinstance(result, list):
out.extend(result)
elif isinstance(result, dict):
out.append(result)
elif isinstance(payload.get("variant"), dict):
out.append(payload["variant"])
return out
def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
try:
+13
View File
@@ -29,6 +29,19 @@ def get_store(request: Request) -> Any:
return getattr(request.app.state, "cafe24_store", None)
def read_lag_grace_minutes() -> int:
"""카페24 읽기 지연 유예시간(분). `CAFE24_READ_LAG_GRACE_MIN`, 기본 360.
이 시간 안에 우리가 쓴 값이 있으면, 카페24 GET 이 예전 값을 돌려줘도 우리
마지막 쓰기를 화면·적용 기준으로 삼는다(store.resolve_description 참고).
"""
import os # noqa: WPS433
from . import store # noqa: WPS433
return store.parse_grace_minutes(os.getenv("CAFE24_READ_LAG_GRACE_MIN"))
def require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # noqa: WPS433
+124
View File
@@ -41,6 +41,18 @@ _TOKEN_FIELDS: tuple[str, ...] = (
)
def _flag_bool(value: Any, default: bool) -> bool:
"""카페24 'T'/'F' 또는 bool → bool."""
if isinstance(value, bool):
return value
text = str(value or "").strip().upper()
if text in ("T", "TRUE", "1"):
return True
if text in ("F", "FALSE", "0"):
return False
return default
class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
@@ -263,6 +275,118 @@ class Cafe24Store:
).fetchone()
return self._serialize(row)
# ── 쓰기 직후 스냅샷 (읽기 지연 보정용) ──
# 카페24 PUT 응답의 상품 값을 남겨 둔다. GET 이 아직 예전 레코드를 돌려주는
# 동안(updated_date 가 스냅샷보다 이전) 이 값으로 화면을 덮어씌운다.
# 마이그레이션 004 의 두 컬럼(last_write_snapshot, last_written_at)을 쓴다.
# 스냅샷 JSON 모양:
# {"product": {"data": {...store.SNAPSHOT_FIELDS...}, "written_at": ISO},
# "options": {"data": {...GET/PUT options 응답...}, "written_at": ISO},
# "variants": {"data": {variant_code: {...}}, "written_at": ISO}}
# 섹션별로 따로 갱신한다(가격만 바꿨는데 옵션 스냅샷이 사라지면 안 된다).
def save_write_snapshot(self, product_no: int, section: str, data: Any) -> None:
from psycopg.types.json import Jsonb # noqa: WPS433
no = int(product_no)
if no <= 0 or section not in ("product", "options", "variants"):
return
with self._pool.connection() as conn:
with conn.transaction():
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s FOR UPDATE",
(no,),
).fetchone()
current = dict(row["last_write_snapshot"]) if row and row.get("last_write_snapshot") else {}
if section == "variants" and isinstance(data, dict):
# 품목은 코드별로 누적 병합 — 일부만 바꿔도 이전에 바꾼 것을 잃지 않게.
merged = dict((current.get("variants") or {}).get("data") or {})
merged.update(data)
data = merged
current[section] = {
"data": data,
"written_at": datetime.now(KST).isoformat(timespec="seconds"),
}
product = data if section == "product" and isinstance(data, dict) else {}
conn.execute(
"""
INSERT INTO cafe24_products
(product_no, product_code, product_name, display, selling,
last_synced_at, last_write_snapshot, last_written_at)
VALUES (%s, %s, %s, %s, %s, now(), %s, now())
ON CONFLICT (product_no) DO UPDATE SET
product_code = COALESCE(NULLIF(EXCLUDED.product_code, ''), cafe24_products.product_code),
product_name = COALESCE(NULLIF(EXCLUDED.product_name, ''), cafe24_products.product_name),
display = CASE WHEN %s THEN EXCLUDED.display ELSE cafe24_products.display END,
selling = CASE WHEN %s THEN EXCLUDED.selling ELSE cafe24_products.selling END,
last_synced_at = now(),
last_write_snapshot = EXCLUDED.last_write_snapshot,
last_written_at = now()
""",
(
no,
str(product.get("product_code") or ""),
str(product.get("product_name") or ""),
_flag_bool(product.get("display"), True),
_flag_bool(product.get("selling"), True),
Jsonb(current),
bool(product),
bool(product),
),
)
def get_write_snapshot(self, product_no: int) -> dict[str, Any]:
"""섹션별 스냅샷 dict. 없으면 {}."""
with self._pool.connection() as conn:
row = conn.execute(
"SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s",
(int(product_no),),
).fetchone()
if not row or not row.get("last_write_snapshot"):
return {}
return dict(row["last_write_snapshot"])
# ── 읽기 지연 판정용 revision 조회 ──
def latest_write_revision(self, product_no: int, *, since: datetime) -> dict[str, Any] | None:
"""유예시간 안의 가장 최근 '쓰기' revision(MANUAL/SCHEDULED/ROLLBACK) — HTML 포함."""
with self._pool.connection() as conn:
row = conn.execute(
"""
SELECT id, product_no, revision_type, html_content, memo, created_by, created_at
FROM cafe24_product_revisions
WHERE product_no = %s
AND revision_type = ANY(%s)
AND created_at >= %s
ORDER BY created_at DESC, id DESC
LIMIT 1
""",
(int(product_no), list(store.WRITE_REVISION_TYPES), since),
).fetchone()
if not row:
return None
out = dict(row)
at = out.get("created_at")
if isinstance(at, datetime) and at.tzinfo is None:
out["created_at"] = at.replace(tzinfo=KST)
return out
def revision_digests(self, product_no: int, *, since: datetime) -> set[str]:
"""유예시간 안의 revision 들의 내용 해시(store.content_digest 와 같은 md5 hex).
내용을 통째로 옮기지 않고 DB 에서 해시만 계산한다(상세페이지는 수 MB 일 수 있다).
md5 를 쓰는 이유: 모든 PostgreSQL 버전에 있고, 여기서는 충돌 저항이 아니라
"같은 내용인가"만 필요하다.
"""
with self._pool.connection() as conn:
rows = conn.execute(
"""
SELECT md5(html_content) AS digest
FROM cafe24_product_revisions
WHERE product_no = %s AND created_at >= %s
""",
(int(product_no), since),
).fetchall()
return {str(r["digest"]) for r in rows if r.get("digest")}
# ════════════════════════════════════════════════════════════
# 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
+4
View File
@@ -10,6 +10,8 @@
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
routes_product_info 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
routes_system 연결(OAuth)·상태·API 로그·작업 로그
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
@@ -24,6 +26,7 @@ import logging
from fastapi import APIRouter
from .routes_design_files import design_files_router
from .routes_product_info import product_info_router
from .routes_products import products_router
from .routes_schedules import schedules_router
from .routes_system import system_router
@@ -33,6 +36,7 @@ logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router)
router.include_router(product_info_router)
router.include_router(schedules_router)
router.include_router(design_files_router)
router.include_router(system_router)
+498
View File
@@ -0,0 +1,498 @@
"""카페24 상품 정보 패널 — 오른쪽 칸 JSON API.
화면(products.html 의 `_side.html` 조각)이 fetch 로 부른다. 전부 `def`(동기) 핸들러.
POST /products/{no}/basic 상품명 · 판매가 · 공급가 · 소비자가
POST /products/{no}/image 대표 이미지 교체 (multipart 파일)
GET /products/{no}/options 옵션 + 품목 조회 (읽기 지연 보정 포함)
POST /products/{no}/options 옵션 생성 (옵션 없는 상품 — 품목 자동 생성)
PUT /products/{no}/options 옵션명 · 옵션값 이름/이미지/표시방식 수정
DELETE /products/{no}/options 옵션 삭제 (품목도 함께 삭제 — 확인 후)
POST /products/{no}/options/image 옵션값 썸네일 업로드만 (경로 반환)
PUT /products/{no}/variants 품목 자체코드 · 추가금액 · 진열 · 판매
공통 규칙
- 카페24 호출은 `app.integrations.cafe24` 만 통한다(httpx 직접 호출 금지).
- 쓰기 전에 현재값을 읽되, **읽기 지연을 보정한 유효 현재값**과 비교해 바뀐
것만 보낸다(routes_products.load_product). 이걸 안 하면 "A→B 로 바꾼 직후
다시 B→A" 가 카페24의 예전 값(A)과 같다고 판단돼 무시된다.
- PUT 응답이 곧 현재값이다. 응답을 스냅샷(`save_write_snapshot`)에 남기고 화면도
응답으로 그린다 — 다시 GET 하지 않는다(GET 은 한동안 예전 값을 돌려준다).
- 모든 쓰기는 감사로그(`cafe24_audit_logs`)에 남긴다.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Body, File, HTTPException, Request, UploadFile
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from . import store
from .common import read_lag_grace_minutes, require_store
from .routes_products import load_product, remember_write
logger = logging.getLogger("cafe24.product_info")
product_info_router = APIRouter()
# 업로드 허용 이미지 형식 (카페24 상품 이미지 규격)
_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
def _http_from_cafe24(exc: Cafe24Error) -> HTTPException:
return HTTPException(status_code=502, detail=str(exc))
def _read_image(upload: UploadFile) -> bytes:
"""업로드 파일 검증 + 바이트. 형식/크기 오류는 400."""
content_type = (upload.content_type or "").lower()
if content_type not in _IMAGE_TYPES:
raise HTTPException(status_code=400, detail="JPG·PNG·GIF·WEBP 이미지만 올릴 수 있습니다.")
data = upload.file.read(products.IMAGE_MAX_BYTES + 1)
if not data:
raise HTTPException(status_code=400, detail="빈 파일입니다.")
if len(data) > products.IMAGE_MAX_BYTES:
raise HTTPException(status_code=400, detail="이미지는 10MB 를 넘을 수 없습니다.")
return data
def _scalar_view(product: dict[str, Any]) -> dict[str, Any]:
"""화면이 바로 쓰는 스칼라 값들(PUT 응답 또는 보정된 GET 에서)."""
snap = store.product_snapshot(product)
return {
"product_name": snap.get("product_name", ""),
"price": snap.get("price", ""),
"supply_price": snap.get("supply_price", ""),
"retail_price": snap.get("retail_price", ""),
"display": products._flag(snap.get("display")), # noqa: SLF001 — 같은 규칙 재사용
"selling": products._flag(snap.get("selling")), # noqa: SLF001
"detail_image": snap.get("detail_image", ""),
"list_image": snap.get("list_image", ""),
"tiny_image": snap.get("tiny_image", ""),
"small_image": snap.get("small_image", ""),
"updated_date": snap.get("updated_date", ""),
}
# ════════════════════════════════════════════════════════════
# 기본 정보 — 상품명 · 가격
# ════════════════════════════════════════════════════════════
@product_info_router.post("/products/{product_no}/basic")
def product_basic(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
st, user = require_store(request)
actor = str(user.get("email") or "")
want: dict[str, str] = {}
if "product_name" in payload:
name = str(payload.get("product_name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="상품명을 입력하세요.")
if len(name) > store.NAME_MAX:
raise HTTPException(status_code=400, detail=f"상품명은 {store.NAME_MAX}자를 넘을 수 없습니다.")
want["product_name"] = name
try:
for key, label in (("price", "판매가"), ("supply_price", "공급가"), ("retail_price", "소비자가")):
if key in payload:
parsed = store.parse_price(payload.get(key), field=label)
if parsed is not None:
want[key] = parsed
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not want:
raise HTTPException(status_code=400, detail="바꿀 항목이 없습니다.")
api = build_cafe24_api(st)
try:
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="FAIL", detail=f"현재값 조회 실패: {exc}")
raise _http_from_cafe24(exc) from exc
if not current:
raise HTTPException(status_code=404, detail="카페24에서 상품을 찾지 못했습니다.")
changes: dict[str, str] = {}
before: dict[str, str] = {}
for key, value in want.items():
old = str(current.get(key) or "")
same = old == value if key == "product_name" else store.price_equal(old, value)
if not same:
changes[key] = value
before[key] = old
if not changes:
return {"ok": True, "changed": [], "product": _scalar_view(current)}
try:
updated = products.update_product(api.client, product_no, **changes)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="FAIL", detail=f"{changes} 실패: {exc}")
logger.warning("카페24 상품 %s 기본정보 변경 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
merged = {**current, **(updated if isinstance(updated, dict) else {}), **changes}
detail = ", ".join(f"{k}: '{before[k]}''{v}'" for k, v in changes.items())
st.log_audit(actor=actor, action="update_basic", product_no=product_no,
result="SUCCESS", detail=detail)
logger.info("카페24 상품 %s 기본정보 변경 (%s): %s", product_no, actor, detail)
return {"ok": True, "changed": list(changes.keys()), "product": _scalar_view(merged)}
# ════════════════════════════════════════════════════════════
# 대표 이미지
# ════════════════════════════════════════════════════════════
@product_info_router.post("/products/{product_no}/image")
def product_image(
request: Request,
product_no: int,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""대표 이미지 교체. 업로드(/products/images) → PUT detail_image(A 타입).
A(대표이미지등록) 타입이면 목록/작은목록/축소 이미지는 카페24가 리사이징한다.
이전 이미지 경로는 감사로그에 남긴다(되돌리려면 그 경로를 다시 넣는다).
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
data = _read_image(file)
api = build_cafe24_api(st)
try:
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
before = str(current.get("detail_image") or "")
try:
path = products.upload_image_bytes(api.client, data)
if not path:
raise Cafe24Error("카페24가 업로드 경로를 돌려주지 않았습니다.")
updated = products.set_main_image(api.client, product_no, path)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
result="FAIL", detail=f"업로드/적용 실패: {exc}")
logger.warning("카페24 상품 %s 대표이미지 변경 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
merged = {**current, **(updated if isinstance(updated, dict) else {})}
if not merged.get("detail_image"):
merged["detail_image"] = path
st.log_audit(actor=actor, action="set_main_image", product_no=product_no,
result="SUCCESS", detail=f"'{before}''{merged.get('detail_image')}' (업로드 {path})")
logger.info("카페24 상품 %s 대표이미지 변경 (%s)", product_no, actor)
return {"ok": True, "product": _scalar_view(merged)}
# ════════════════════════════════════════════════════════════
# 옵션 · 품목
# ════════════════════════════════════════════════════════════
def _variant_view(v: dict[str, Any]) -> dict[str, Any]:
opts = v.get("options") if isinstance(v.get("options"), list) else []
return {
"variant_code": str(v.get("variant_code") or ""),
"options": [
{"name": str(o.get("name") or ""), "value": str(o.get("value") or "")}
for o in opts if isinstance(o, dict)
],
"custom_variant_code": str(v.get("custom_variant_code") or ""),
"additional_amount": str(v.get("additional_amount") or "0.00"),
"display": products._flag(v.get("display")), # noqa: SLF001
"selling": products._flag(v.get("selling")), # noqa: SLF001
"quantity": v.get("quantity"),
"use_inventory": products._flag(v.get("use_inventory"), default=False), # noqa: SLF001
"image": str(v.get("image") or ""),
}
def _options_view(option: dict[str, Any]) -> dict[str, Any]:
raw_options = option.get("options") if isinstance(option.get("options"), list) else []
out_options = []
for o in raw_options:
if not isinstance(o, dict):
continue
values = o.get("option_value") if isinstance(o.get("option_value"), list) else []
out_options.append(
{
"option_code": str(o.get("option_code") or ""),
"option_name": str(o.get("option_name") or ""),
"option_display_type": str(o.get("option_display_type") or "S"),
"required_option": str(o.get("required_option") or "T"),
"option_value": [
{
"option_text": str(v.get("option_text") or ""),
"option_image_file": str(v.get("option_image_file") or ""),
"option_link_image": str(v.get("option_link_image") or ""),
"option_color": str(v.get("option_color") or ""),
"value_no": v.get("value_no"),
}
for v in values if isinstance(v, dict)
],
}
)
return {
"has_option": products._flag(option.get("has_option"), default=False), # noqa: SLF001
"option_type": str(option.get("option_type") or ""),
"option_list_type": str(option.get("option_list_type") or ""),
"option_preset_code": str(option.get("option_preset_code") or ""),
"options": out_options,
}
def _apply_variant_snapshot(st: Any, product_no: int, variants: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""GET 품목 목록에 유예시간 안의 우리 쓰기(스냅샷)를 덮어씌운다."""
section = (st.get_write_snapshot(product_no) or {}).get("variants") or {}
data = section.get("data") or {}
if not data or not store.within_grace(section.get("written_at"), grace_minutes=read_lag_grace_minutes()):
return variants
out = []
for v in variants:
code = str(v.get("variant_code") or "")
patch = data.get(code)
out.append({**v, **patch} if isinstance(patch, dict) else v)
return out
def _apply_options_snapshot(st: Any, product_no: int, option: dict[str, Any]) -> dict[str, Any]:
"""GET 옵션이 우리 마지막 쓰기와 다르고 유예시간 안이면 스냅샷(우리 쓰기)을 쓴다."""
section = (st.get_write_snapshot(product_no) or {}).get("options") or {}
data = section.get("data")
if not isinstance(data, dict) or not store.within_grace(
section.get("written_at"), grace_minutes=read_lag_grace_minutes()
):
return option
return data
def _load_options_and_variants(st: Any, api: Any, product_no: int) -> dict[str, Any]:
option = _apply_options_snapshot(st, product_no, products.get_options(api.client, product_no))
view = _options_view(option)
variants: list[dict[str, Any]] = []
if view["has_option"]:
variants = _apply_variant_snapshot(st, product_no, products.list_variants(api.client, product_no))
return {"option": view, "variants": [_variant_view(v) for v in variants], "raw_options": option}
@product_info_router.get("/products/{product_no}/options")
def options_get(request: Request, product_no: int) -> dict[str, Any]:
st, _user = require_store(request)
api = build_cafe24_api(st)
try:
loaded = _load_options_and_variants(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
return {"ok": True, "option": loaded["option"], "variants": loaded["variants"],
"display_types": store.OPTION_DISPLAY_LABELS}
@product_info_router.post("/products/{product_no}/options")
def options_create(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""옵션 없는 상품에 조합형 옵션 1개(옵션명 + 옵션값들)를 만든다. 품목은 자동 생성."""
st, user = require_store(request)
actor = str(user.get("email") or "")
try:
body = store.build_create_options_request(
str(payload.get("option_name") or ""),
store.parse_option_values(payload.get("values")),
display_type=str(payload.get("display_type") or "S"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
api = build_cafe24_api(st)
try:
existing = products.get_options(api.client, product_no)
if products._flag(existing.get("has_option"), default=False): # noqa: SLF001
raise HTTPException(status_code=409, detail="이미 옵션이 있는 상품입니다. 수정하거나 삭제한 뒤 다시 만드세요.")
created = products.create_options(api.client, product_no, body)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="create_options", product_no=product_no,
result="FAIL", detail=f"{body['options'][0]['option_name']} 실패: {exc}")
raise _http_from_cafe24(exc) from exc
st.save_write_snapshot(product_no, "options", created)
values = [v["option_text"] for v in body["options"][0]["option_value"]]
st.log_audit(actor=actor, action="create_options", product_no=product_no, result="SUCCESS",
detail=f"옵션 '{body['options'][0]['option_name']}' 생성: {', '.join(values)}")
logger.info("카페24 상품 %s 옵션 생성 (%s)", product_no, actor)
try:
variants = products.list_variants(api.client, product_no)
except Cafe24Error as exc: # 옵션은 만들어졌다 — 품목 목록만 비워서 돌려준다.
logger.warning("카페24 상품 %s 옵션 생성 후 품목 조회 실패: %s", product_no, exc)
variants = []
return {"ok": True, "option": _options_view(created), "variants": [_variant_view(v) for v in variants]}
@product_info_router.put("/products/{product_no}/options")
def options_update(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""옵션명 · 옵션값 이름/썸네일/연결이미지/색상 · 표시방식 수정.
카페24 PUT 은 `original_options`(수정 전)와 `options`(수정 후)를 짝지어 받는다.
수정 전 값은 화면이 들고 있던 것이 아니라 **지금 카페24에서 다시 읽은 값**(읽기
지연 보정 후)을 쓴다 — 그래야 다른 곳에서 바뀐 이름과 어긋나지 않는다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
edited = payload.get("options")
if not isinstance(edited, list) or not edited:
raise HTTPException(status_code=400, detail="옵션 목록이 비어 있습니다.")
api = build_cafe24_api(st)
try:
loaded = _load_options_and_variants(st, api, product_no)
except Cafe24Error as exc:
raise _http_from_cafe24(exc) from exc
if not loaded["option"]["has_option"]:
raise HTTPException(status_code=409, detail="옵션이 없는 상품입니다. 먼저 옵션을 만드세요.")
original = loaded["option"]["options"]
try:
body = store.build_update_options_request(
original, edited, option_list_type=str(payload.get("option_list_type") or "")
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if loaded["option"]["option_preset_code"]:
body["option_preset_code"] = loaded["option"]["option_preset_code"]
try:
updated = products.update_options(api.client, product_no, body)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_options", product_no=product_no,
result="FAIL", detail=str(exc))
logger.warning("카페24 상품 %s 옵션 수정 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
st.save_write_snapshot(product_no, "options", updated)
summary = "; ".join(
f"{o['option_name']}: " + ", ".join(v["option_text"] for v in o["option_value"])
for o in body["options"]
)
st.log_audit(actor=actor, action="update_options", product_no=product_no,
result="SUCCESS", detail=summary[:900])
logger.info("카페24 상품 %s 옵션 수정 (%s)", product_no, actor)
# 품목의 옵션값 표기는 이름을 바꾼 만큼 달라진다 — 응답값으로 다시 맞춘다.
variants = loaded["variants"]
rename: dict[tuple[str, str], tuple[str, str]] = {}
for o, n in zip(original, body["options"]):
for ov, nv in zip(o["option_value"], n["option_value"]):
rename[(o["option_name"], ov["option_text"])] = (n["option_name"], nv["option_text"])
for v in variants:
v["options"] = [
dict(zip(("name", "value"), rename.get((opt["name"], opt["value"]), (opt["name"], opt["value"]))))
for opt in v["options"]
]
return {"ok": True, "option": _options_view(updated), "variants": variants}
@product_info_router.delete("/products/{product_no}/options")
def options_delete(request: Request, product_no: int) -> dict[str, Any]:
"""옵션 삭제 — 카페24가 품목도 함께 지운다. 화면에서 확인창을 거친다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
api = build_cafe24_api(st)
try:
before = products.get_options(api.client, product_no)
products.delete_options(api.client, product_no)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
result="FAIL", detail=str(exc))
raise _http_from_cafe24(exc) from exc
names = ", ".join(
str(o.get("option_name") or "") for o in (before.get("options") or []) if isinstance(o, dict)
)
st.save_write_snapshot(product_no, "options", {"has_option": "F", "options": []})
st.save_write_snapshot(product_no, "variants", {})
st.log_audit(actor=actor, action="delete_options", product_no=product_no,
result="SUCCESS", detail=f"삭제된 옵션: {names or '(없음)'}")
logger.info("카페24 상품 %s 옵션 삭제 (%s)", product_no, actor)
return {"ok": True, "option": _options_view({"has_option": "F"}), "variants": []}
@product_info_router.post("/products/{product_no}/options/image")
def option_image_upload(
request: Request,
product_no: int,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""옵션값 썸네일 업로드만 한다. 경로를 돌려주면 화면이 옵션 저장(PUT)에 담는다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
data = _read_image(file)
api = build_cafe24_api(st)
try:
path = products.upload_image_bytes(api.client, data)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
result="FAIL", detail=str(exc))
raise _http_from_cafe24(exc) from exc
if not path:
raise HTTPException(status_code=502, detail="카페24가 업로드 경로를 돌려주지 않았습니다.")
st.log_audit(actor=actor, action="upload_option_image", product_no=product_no,
result="SUCCESS", detail=path)
return {"ok": True, "path": path}
@product_info_router.put("/products/{product_no}/variants")
def variants_update(
request: Request,
product_no: int,
payload: dict[str, Any] = Body(default_factory=dict),
) -> dict[str, Any]:
"""품목 여러 건의 자체코드 · 추가금액 · 진열 · 판매를 한 번에 바꾼다."""
st, user = require_store(request)
actor = str(user.get("email") or "")
rows = payload.get("rows")
if not isinstance(rows, list) or not rows:
raise HTTPException(status_code=400, detail="바꿀 품목이 없습니다.")
try:
requests = store.build_variant_updates(rows)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not requests:
raise HTTPException(status_code=400, detail="바뀐 값이 없습니다.")
api = build_cafe24_api(st)
try:
results = products.update_variants(api.client, product_no, requests)
except Cafe24Error as exc:
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
result="FAIL", detail=f"{len(requests)}건 실패: {exc}")
logger.warning("카페24 상품 %s 품목 수정 실패: %s", product_no, exc)
raise _http_from_cafe24(exc) from exc
# 우리가 보낸 값이 곧 현재값이다(카페24가 받아들였으므로). 응답에 담긴 값이 있으면
# 그것을 우선한다.
by_code: dict[str, dict[str, Any]] = {}
for r in requests:
by_code[r["variant_code"]] = {k: v for k, v in r.items() if k != "variant_code"}
for r in results:
code = str(r.get("variant_code") or "")
if code in by_code:
for key in ("custom_variant_code", "additional_amount", "display", "selling"):
if key in r and r[key] is not None:
by_code[code][key] = r[key]
st.save_write_snapshot(product_no, "variants", by_code)
detail = "; ".join(
f"{code}: " + ", ".join(f"{k}={v}" for k, v in patch.items()) for code, patch in by_code.items()
)
st.log_audit(actor=actor, action="update_variants", product_no=product_no,
result="SUCCESS", detail=detail[:900])
logger.info("카페24 상품 %s 품목 %s건 수정 (%s)", product_no, len(by_code), actor)
return {"ok": True, "updated": {code: _variant_view({"variant_code": code, **patch})
for code, patch in by_code.items()}}
+172 -51
View File
@@ -1,22 +1,38 @@
"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집).
"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널).
화면 구성
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬.
가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게.
오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목.
(JSON API 는 routes_product_info.py)
목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다.
상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 조각을
한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT
MANUAL 버전 + 감사로그
카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 →
지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다.
── 카페24 읽기 지연(read-after-write lag) ──
카페24 관리자 API 는 PUT 직후 한동안 GET 에서 **예전 값**을 돌려준다(실물 관찰,
몇 초에서 훨씬 길게). 우리 쪽 캐시 문제가 아니다(모든 응답 no-store). 그 값을 그대로
믿으면 "적용했는데 예전 소스가 보이고" 그 예전 값으로 지문을 만들어 다음 적용 때
충돌로 오판한다. 그래서 **마지막 쓰기가 권위**다:
- 상세설명: 카페24 값이 우리가 최근(유예시간 안)에 남긴 revision 중 하나와 같으면
"아직 예전 값" → 마지막 쓰기(MANUAL/SCHEDULED)를 보여주고 그 지문을 쓴다.
우리가 모르는 값이면 관리자에서 직접 고친 것 → 카페24 값을 믿는다.
(`store.resolve_description`, `_resolve_description`)
- 상품명/가격/이미지/진열/판매: PUT 응답을 스냅샷으로 남기고, GET 의 updated_date
가 스냅샷보다 이전이면 스냅샷으로 덮어씌운다(`store.overlay_recent_write`).
유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값.
PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
@@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
from __future__ import annotations
import logging
from datetime import timedelta
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode
@@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from app.timezone import now_kst
from . import store
from .common import base_ctx, guard, require_store
from .common import base_ctx, guard, read_lag_grace_minutes, require_store
logger = logging.getLogger("cafe24.products")
@@ -84,6 +102,17 @@ def _price(value: Any) -> str:
return f"{won:,}"
def _price_plain(value: Any) -> str:
"""'6900.00''6900' (입력칸 초기값용)."""
text = str(value or "").strip()
if not text:
return ""
try:
return str(int(Decimal(text)))
except (ArithmeticError, ValueError):
return text
def _short_dt(value: Any) -> str:
"""'2026-08-14T11:38:18+09:00''2026-08-14 11:38'."""
text = str(value or "").strip()
@@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str:
return urlencode(params)
# ════════════════════════════════════════════════════════════
# 읽기 지연 보정 — 상품 dict 와 상세설명에 각각 적용한다.
# 다른 라우트(routes_product_info)도 같은 함수를 쓴다.
# ════════════════════════════════════════════════════════════
def load_product(st: Any, api: Any, product_no: int) -> tuple[dict[str, Any], bool]:
"""카페24 GET + 최근 쓰기 스냅샷 덮어씌우기. (상품 dict, 스칼라 지연 여부).
지연 판정은 카페24의 updated_date 끼리 비교한다 — GET 이 PUT 응답보다 이전
레코드를 돌려주면 스냅샷 값(상품명·가격·이미지·진열/판매)을 쓴다.
"""
product = products.get_product(api.client, product_no)
if not product:
return product, False
section = (st.get_write_snapshot(product_no) or {}).get("product") or {}
merged, stale = store.overlay_recent_write(
product,
snapshot=section.get("data"),
written_at=section.get("written_at"),
grace_minutes=read_lag_grace_minutes(),
)
return merged, stale
def resolve_description(
st: Any, product_no: int, cafe24_html: str
) -> tuple[str, str, dict[str, Any] | None]:
"""(유효 HTML, 상태, 마지막 쓰기 revision). 상태는 store.SYNC_*."""
grace = read_lag_grace_minutes()
since = now_kst() - timedelta(minutes=grace)
last_write = st.latest_write_revision(product_no, since=since)
if not last_write:
return cafe24_html, store.SYNC_NONE, None
digests = st.revision_digests(product_no, since=since)
effective, state = store.resolve_description(
cafe24_html, last_write=last_write, known_digests=digests, grace_minutes=grace
)
return effective, state, last_write
def remember_write(st: Any, product_no: int, updated: dict[str, Any]) -> dict[str, Any]:
"""PUT 응답(상품 dict)을 캐시·스냅샷에 남긴다. 정규화된 캐시 행을 돌려준다."""
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
st.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
return info
def _info(product: dict[str, Any]) -> dict[str, Any]:
"""편집기·정보 패널 공용 상품 요약."""
info = products.normalize_product(product) if product else {}
return {
**info,
"price": _price(product.get("price")),
"price_plain": _price_plain(product.get("price")),
"supply_price": _price(product.get("supply_price")),
"supply_price_plain": _price_plain(product.get("supply_price")),
"retail_price": _price(product.get("retail_price")),
"retail_price_plain": _price_plain(product.get("retail_price")),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
"detail_image": str(product.get("detail_image") or ""),
"list_image": str(product.get("list_image") or ""),
"tiny_image": str(product.get("tiny_image") or ""),
"small_image": str(product.get("small_image") or ""),
}
def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다."""
"""편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회."""
api = build_cafe24_api(st)
product: dict[str, Any] = {}
desc = None
error = ""
scalar_stale = False
html_effective = ""
sync_state = store.SYNC_NONE
last_write: dict[str, Any] | None = None
try:
product = products.get_product(api.client, product_no)
product, scalar_stale = load_product(st, api, product_no)
desc = products.descriptions_from_product(product)
st.upsert_products([products.normalize_product(product)])
html_effective, sync_state, last_write = resolve_description(st, product_no, desc.description)
except Cafe24Error as exc:
error = str(exc)
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
info = products.normalize_product(product) if product else {}
return {
"product_no": product_no,
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
"product_url": api.config.product_url(product_no),
"info": {
**info,
"price": _price(product.get("price")),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
},
"info": _info(product),
"desc": desc,
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
# (2) 태그마다 줄을 나눠 정리해서 보여준다.
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "",
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로).
"fingerprint": store.fingerprint(desc.description) if desc else "",
# 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다.
"html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "",
# 지문은 **인코딩된 유효 현재값**으로 만든다(적용 직전 같은 규칙으로 계산한
# 유효 현재값과 비교하므로).
"fingerprint": store.fingerprint(html_effective) if desc else "",
"sync_state": sync_state,
"sync_pending": sync_state == store.SYNC_PENDING,
"sync_external": sync_state == store.SYNC_EXTERNAL,
"scalar_stale": scalar_stale,
"last_write_at": _short_dt(last_write.get("created_at").isoformat() if last_write and last_write.get("created_at") else ""),
"last_write_by": (last_write or {}).get("created_by") or "",
"revisions": st.list_revisions(product_no, limit=20),
"editor_error": error,
"option_display_types": store.OPTION_DISPLAY_LABELS,
}
@products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse:
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다."""
"""3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
from app.main import render_template # noqa: WPS433
checked = guard(request)
@@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse:
ctx.update(
{
"page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약",
"page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
"rows": rows,
"total": total,
"shown": len(rows),
@@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse:
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
def product_pane(request: Request, product_no: int) -> HTMLResponse:
"""오른쪽 편집기 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다."""
"""편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.
두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기
위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다.
"""
from app.main import render_template # noqa: WPS433
checked = guard(request)
@@ -246,11 +355,11 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse:
ctx = base_ctx(request, user, active_tab="products")
ctx.update(_editor_ctx(st, product_no))
ctx["list_query"] = _list_query(request)
return _no_store(render_template(request, "cafe24/_editor.html", ctx))
return _no_store(render_template(request, "cafe24/_panes.html", ctx))
# 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
NAME_MAX = 250
NAME_MAX = store.NAME_MAX
@products_router.post("/products/{product_no}/name")
@@ -263,7 +372,7 @@ def product_rename(
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
(되돌리려면 로그를 보고 다시 바꾼다).
(되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
"""
st, user = require_store(request)
actor = str(user.get("email") or "")
@@ -276,7 +385,7 @@ def product_rename(
api = build_cafe24_api(st)
try:
current = products.get_product(api.client, product_no)
current, _ = load_product(st, api, product_no)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
@@ -298,9 +407,7 @@ def product_rename(
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
info = remember_write(st, product_no, updated)
after = str(info.get("product_name") or name)
st.log_audit(
actor=actor, action="rename_product", product_no=product_no,
@@ -348,14 +455,12 @@ def product_status(
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
if "display" not in updated or "selling" not in updated:
try:
updated = products.get_product(api.client, product_no)
updated, _ = load_product(st, api, product_no)
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
updated = {}
info = products.normalize_product(updated) if updated else {}
if info.get("product_no"):
st.upsert_products([info])
info = remember_write(st, product_no, updated)
state = {
"display": bool(info.get("display", want if field == "display" else True)),
"selling": bool(info.get("selling", want if field == "selling" else True)),
@@ -370,7 +475,7 @@ def product_status(
@products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
"""옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
@@ -387,9 +492,11 @@ def product_apply(
순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
4) 쓰고, MANUAL 버전과 감사로그를 남긴다
2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
우리 마지막 쓰기가 현재값이다)
3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
@@ -423,11 +530,15 @@ def product_apply(
)
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303)
# 읽기 지연 판정 — 카페24가 아직 예전 값을 돌려주면 우리 마지막 쓰기가 현재값이다.
effective, sync_state, _ = resolve_description(st, product_no, current.description)
pending = sync_state == store.SYNC_PENDING
backup_id = st.add_revision(
product_no=product_no,
html_content=current.description,
html_content=effective,
revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업",
memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
created_by=actor,
)
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
@@ -441,7 +552,7 @@ def product_apply(
created_by=actor,
)
if base_fingerprint and base_fingerprint != store.fingerprint(current.description):
if base_fingerprint and base_fingerprint != store.fingerprint(effective):
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
@@ -451,13 +562,13 @@ def product_apply(
status_code=303,
)
if submitted == current.description:
if submitted == effective:
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
try:
# mobile_description 은 보내지 않는다 — update_descriptions 가
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다).
products.update_descriptions(api.client, product_no, description=submitted)
updated = products.update_descriptions(api.client, product_no, description=submitted)
except Cafe24Error as exc:
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
@@ -469,11 +580,11 @@ def product_apply(
status_code=303,
)
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
products.wait_for_description(api.client, product_no, submitted)
# PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등).
remember_write(st, product_no, updated if isinstance(updated, dict) else {})
# MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이
# 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다.
revision_id = st.add_revision(
product_no=product_no,
html_content=submitted,
@@ -481,13 +592,23 @@ def product_apply(
memo=memo,
created_by=actor,
)
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위
# MANUAL revision 을 기준으로 그리므로 문제없다.
confirmed = products.wait_for_description(api.client, product_no, submitted)
st.log_audit(
actor=actor, action="apply_description", product_no=product_no,
revision_id=revision_id, result="SUCCESS",
detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)",
detail=(
f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지, "
f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})"
),
)
logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor)
logger.info("카페24 상품 %s 상세설명 적용 (%s, 재조회 확인=%s)", product_no, actor, confirmed)
note = "" if confirmed else " 카페24 관리자 API 반영은 잠시 늦을 수 있어 방금 적용한 내용을 표시합니다."
return RedirectResponse(
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.",
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}",
status_code=303,
)
+349
View File
@@ -439,3 +439,352 @@ def fingerprint(html: str) -> str:
순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다.
"""
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노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지)."""
text = str(raw or "")
seen: list[str] = []
for part in re.split(r"[,\n]", text):
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"
if len(item) > 1:
out.append(item)
return out
@@ -1,6 +1,6 @@
{# 오른쪽 편집기 조각.
{# 가운데 편집기 조각.
전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
같은 조각 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
같은 조각(+ 오른쪽 정보 패널 _side.html)을 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
삽입 후 cf24BindEditor() 로 다시 연결한다). #}
@@ -11,6 +11,23 @@
</div>
{% endif %}
{# 읽기 지연 배너 — 카페24 관리자 API 가 아직 이전 값을 돌려주는 동안에는 마지막으로
적용한 내용(MANUAL/SCHEDULED revision)을 편집기에 보여준다. 쇼핑몰 화면에는 이미
반영돼 있다(실물 관찰). 지문도 이 값으로 만들어 다음 적용이 충돌로 오판되지 않는다. #}
{% if sync_pending %}
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
<strong>카페24 반영 대기 중</strong> — 카페24 관리자 API 가 아직 이전 소스를 돌려주고 있어
마지막으로 적용한 내용{% if last_write_at %}({{ last_write_at }}{% if last_write_by %}, {{ last_write_by }}{% endif %}){% endif %}을 표시합니다.
쇼핑몰 화면에는 이미 반영되어 있습니다.
<button type="button" class="erp-btn erp-btn-outline cf24-btn-inline" data-reload="{{ product_no }}">다시 확인</button>
</div>
{% elif sync_external %}
<div class="cf24-flash cf24-flash-warn" id="cf24-sync-banner">
이 상품은 최근 여기서 적용한 뒤 <strong>카페24 관리자에서 직접 수정</strong>된 것으로 보입니다.
카페24의 현재 소스를 표시합니다.
</div>
{% endif %}
<div class="cf24-editor-head">
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name.
@@ -0,0 +1,6 @@
{# 상품 선택 시 JS 가 가져가는 조각 묶음 — 가운데(편집기) + 오른쪽(정보 패널).
한 응답에 둘을 담아 카페24 상품 조회를 한 번만 한다. products.html 의 select()
가 [data-pane] 별로 나눠 각 칸에 끼워 넣는다. 여기에 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다). #}
<div data-pane="editor">{% include "cafe24/_editor.html" %}</div>
<div data-pane="side">{% include "cafe24/_side.html" %}</div>
@@ -0,0 +1,96 @@
{# 오른쪽 상품 정보 패널 조각.
기본 정보(상품명·판매가·공급가·소비자가) · 대표 이미지 · 옵션/품목.
전체 페이지(products.html)가 include 하고, 상품을 바꾸면 JS 가 /products/{no}/pane
에서 같은 조각을 다시 받아 끼워 넣는다. 그래서 <script> JS
products.html 의 cf24BindSide() 에 있다. 쓰기는 전부 routes_product_info.py 의
JSON API 로 하고, 화면은 **응답값**으로 다시 그린다(요청값을 낙관 반영하지 않는다). #}
{% if desc %}
<div class="cf24-side" id="cf24-side" data-product-no="{{ product_no }}">
{# ── 기본 정보 ── #}
<section class="cf24-side-sec">
<h4 class="cf24-side-title">기본 정보</h4>
{% if scalar_stale %}
<p class="cf24-side-note cf24-side-note-warn">
카페24 관리자 API 가 아직 이전 값을 돌려주고 있어 마지막으로 적용한 값을 표시합니다.
</p>
{% endif %}
<form class="cf24-side-form" id="cf24-basic-form" autocomplete="off">
<label class="cf24-side-field">
<span>상품명</span>
<input type="text" name="product_name" maxlength="250" value="{{ info.product_name }}" />
</label>
<div class="cf24-side-grid3">
<label class="cf24-side-field">
<span>판매가</span>
<input type="text" name="price" inputmode="numeric" value="{{ info.price_plain }}" placeholder="원" />
</label>
<label class="cf24-side-field">
<span>공급가</span>
<input type="text" name="supply_price" inputmode="numeric" value="{{ info.supply_price_plain }}" placeholder="원" />
</label>
<label class="cf24-side-field">
<span>소비자가</span>
<input type="text" name="retail_price" inputmode="numeric" value="{{ info.retail_price_plain }}" placeholder="원" />
</label>
</div>
<div class="cf24-side-actions">
<span class="cf24-muted" id="cf24-basic-msg"></span>
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-basic-save">카페24에 저장</button>
</div>
</form>
</section>
{# ── 대표 이미지 ── #}
<section class="cf24-side-sec">
<h4 class="cf24-side-title">대표 이미지</h4>
<div class="cf24-image-box" id="cf24-image-box">
{% if info.detail_image %}
<a href="{{ info.detail_image }}" target="_blank" rel="noopener noreferrer" id="cf24-image-link">
<img class="cf24-image-main" id="cf24-image-main" src="{{ info.detail_image }}" alt="대표 이미지" />
</a>
{% else %}
<a href="#" target="_blank" rel="noopener noreferrer" id="cf24-image-link" class="is-hidden">
<img class="cf24-image-main" id="cf24-image-main" src="" alt="대표 이미지" />
</a>
<div class="cf24-image-empty" id="cf24-image-empty">등록된 대표 이미지가 없습니다.</div>
{% endif %}
</div>
<div class="cf24-image-thumbs" id="cf24-image-thumbs">
{# 이미지가 없는 종류는 칸째로 숨긴다(빈 칸에 이름만 남으면 어색하다).
업로드 뒤 응답에 경로가 오면 JS 가 칸을 다시 보인다. #}
{% for key, label in (("list_image", "목록"), ("tiny_image", "작은목록"), ("small_image", "축소")) %}
<figure class="cf24-image-thumb{% if not info[key] %} is-hidden{% endif %}" data-image-figure="{{ key }}">
<img src="{{ info[key] }}" alt="{{ label }}" data-image-key="{{ key }}" />
<figcaption>{{ label }}</figcaption>
</figure>
{% endfor %}
</div>
<form class="cf24-side-form" id="cf24-image-form">
<label class="cf24-file-pick">
<input type="file" name="file" id="cf24-image-file" accept="image/jpeg,image/png,image/gif,image/webp" />
<span class="erp-btn erp-btn-outline">파일 선택</span>
<span class="cf24-file-name" id="cf24-image-filename">선택된 파일 없음</span>
</label>
<div class="cf24-side-actions">
<span class="cf24-muted" id="cf24-image-msg">JPG·PNG·GIF·WEBP, 10MB 이하. 목록/축소 이미지는 카페24가 자동 생성합니다.</span>
<button class="erp-btn erp-btn-primary" type="submit" id="cf24-image-save" disabled>업로드하여 교체</button>
</div>
</form>
</section>
{# ── 옵션 / 품목 — 펼칠 때 JSON 으로 불러온다(카페24 호출 2회) ── #}
<details class="cf24-side-sec cf24-side-details" id="cf24-options-details">
<summary class="cf24-side-title">옵션 / 품목 <span class="cf24-muted" id="cf24-options-count"></span></summary>
<div class="cf24-options-body" id="cf24-options-body">
<p class="cf24-muted">불러오는 중…</p>
</div>
</details>
</div>
{% else %}
<div class="cf24-side cf24-side-empty">
<p class="cf24-muted">카페24 조회에 실패해 상품 정보를 표시할 수 없습니다.</p>
</div>
{% endif %}
@@ -1,7 +1,7 @@
{% extends "erp_base.html" %}
{% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" />
<link rel="stylesheet" href="/static/cafe24.css?v=20260918a" />
{% endblock %}
{% block content %}
@@ -18,7 +18,7 @@
</div>
{% endif %}
<div class="cf24-split">
<div class="cf24-split cf24-split-3">
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
<aside class="erp-card cf24-pane cf24-pane-list">
@@ -83,7 +83,7 @@
</div>
</aside>
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #}
{# ── 가운데: 상세페이지 편집 ──────────────────────────────── #}
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
{% if selected %}
{% include "cafe24/_editor.html" %}
@@ -97,6 +97,17 @@
</div>
{% endif %}
</section>
{# ── 오른쪽: 상품 정보 패널(상품명·가격 / 대표 이미지 / 옵션·품목) ── #}
<section class="erp-card cf24-pane cf24-pane-side" id="cf24-side-pane">
{% if selected %}
{% include "cafe24/_side.html" %}
{% else %}
<div class="cf24-empty-pane">
<p class="cf24-muted">상품을 선택하면 상품명·가격, 대표 이미지, 옵션/품목을 여기서 수정할 수 있습니다.</p>
</div>
{% endif %}
</section>
</div>
{% endblock %}
@@ -374,15 +385,15 @@
});
});
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다.
var reloadBtn = pane.querySelector("[data-reload]");
if (reloadBtn) {
// 「다시 읽기」/「다시 확인」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로
// 다시 받는다(도구 막대 버튼 + 반영 대기 배너 버튼, 둘 다 같은 동작).
pane.querySelectorAll("[data-reload]").forEach(function (reloadBtn) {
reloadBtn.addEventListener("click", function () {
confirmLeave().then(function (ok) {
if (ok) select(reloadBtn.dataset.reload, false);
});
});
}
});
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분").
@@ -647,6 +658,509 @@
}
};
/* ════════════════════════════════════════════════════════════
오른쪽 상품 정보 패널 — 상품명·가격 / 대표 이미지 / 옵션·품목
쓰기는 전부 routes_product_info.py 의 JSON API. 화면은 **응답값**으로 그린다
(요청값을 낙관 반영하면 실패했을 때 카페24와 어긋난다). 카페24 GET 은 쓰기
직후 한동안 예전 값을 돌려주므로, 저장 뒤에 다시 GET 하지 않는다.
════════════════════════════════════════════════════════════ */
function escHtml(text) {
return String(text == null ? "" : text).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function apiJson(method, url, body) {
var opts = { method: method, credentials: "same-origin", cache: "no-store", headers: {} };
if (body instanceof FormData) {
opts.body = body;
} else if (body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(body);
}
return fetch(url, opts).then(function (res) {
return res.json().catch(function () { return {}; }).then(function (data) {
if (!res.ok) throw new Error(data.detail || "HTTP " + res.status);
return data;
});
});
}
function wonText(value) {
var n = parseInt(String(value || "").replace(/,/g, ""), 10);
return isNaN(n) ? "" : n.toLocaleString("ko-KR") + "원";
}
function bust(url) {
// 같은 경로에 새 이미지가 올라가면 브라우저가 옛 그림을 재사용한다 — 시각을 붙인다.
if (!url) return "";
return url + (url.indexOf("?") === -1 ? "?" : "&") + "t=" + Date.now();
}
function setMsg(el, text, kind) {
if (!el) return;
el.textContent = text || "";
el.classList.remove("is-ok", "is-err");
if (kind) el.classList.add(kind);
}
// 입력칸의 값이 처음 값(defaultValue)과 다르면 표시한다.
function trackDirty(container) {
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
var mark = function () {
var base = el.tagName === "SELECT" ? el.dataset.initial : el.defaultValue;
el.classList.toggle("is-dirty", el.value !== (base == null ? "" : base));
};
if (el.tagName === "SELECT" && el.dataset.initial == null) el.dataset.initial = el.value;
el.addEventListener("input", mark);
el.addEventListener("change", mark);
});
}
function commitValues(container) {
container.querySelectorAll("input[type='text'], select, textarea").forEach(function (el) {
if (el.tagName === "SELECT") el.dataset.initial = el.value;
else el.defaultValue = el.value;
el.classList.remove("is-dirty");
});
}
window.cf24BindSide = function () {
var sideRoot = document.getElementById("cf24-side-pane");
var root = sideRoot && sideRoot.querySelector("#cf24-side");
if (!root) return;
var no = root.dataset.productNo;
// ── 기본 정보: 상품명 · 판매가 · 공급가 · 소비자가 ──
(function setupBasic() {
var form = root.querySelector("#cf24-basic-form");
if (!form) return;
var msg = form.querySelector("#cf24-basic-msg");
var btn = form.querySelector("#cf24-basic-save");
trackDirty(form);
function paintTitle(name) {
var text = document.getElementById("cf24-name-text");
if (text) text.textContent = name;
var input = document.getElementById("cf24-name-input");
if (input) input.value = name;
var tr = document.querySelector('#cf24-list tr.cf24-row[data-no="' + no + '"]');
if (!tr) return;
tr.dataset.name = name;
var cell = tr.querySelector(".cf24-col-name");
if (!cell) return;
cell.title = name;
var link = cell.querySelector("a");
(link || cell).textContent = name;
}
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {};
var labels = { product_name: "상품명", price: "판매가", supply_price: "공급가", retail_price: "소비자가" };
var lines = [];
form.querySelectorAll("input[type='text']").forEach(function (el) {
if (el.value !== el.defaultValue) {
payload[el.name] = el.value.trim();
lines.push(labels[el.name] + ": " + (el.defaultValue || "(없음)") + " → " + (el.value.trim() || "(없음)"));
}
});
if (!lines.length) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
if (payload.product_name === "") {
window.erpAlert("상품명은 비울 수 없습니다.");
return;
}
window.erpConfirm("다음 항목을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.join("\n"))
.then(function (ok) {
if (!ok) return;
btn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/basic", payload)
.then(function (data) {
var p = data.product || {};
form.querySelector('[name="product_name"]').value = p.product_name || "";
form.querySelector('[name="price"]').value = p.price ? String(parseInt(p.price, 10)) : "";
form.querySelector('[name="supply_price"]').value = p.supply_price ? String(parseInt(p.supply_price, 10)) : "";
form.querySelector('[name="retail_price"]').value = p.retail_price ? String(parseInt(p.retail_price, 10)) : "";
commitValues(form);
if (p.product_name) paintTitle(p.product_name);
setMsg(msg, (data.changed && data.changed.length) ? "카페24에 반영했습니다." : "변경된 값이 없어 보내지 않았습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { btn.disabled = false; });
});
});
})();
// ── 대표 이미지 ──
(function setupImage() {
var form = root.querySelector("#cf24-image-form");
if (!form) return;
var fileEl = form.querySelector("#cf24-image-file");
var nameEl = form.querySelector("#cf24-image-filename");
var msg = form.querySelector("#cf24-image-msg");
var btn = form.querySelector("#cf24-image-save");
var img = root.querySelector("#cf24-image-main");
var link = root.querySelector("#cf24-image-link");
var empty = root.querySelector("#cf24-image-empty");
function paint(p) {
if (p.detail_image && img) {
img.src = bust(p.detail_image);
if (link) { link.href = p.detail_image; link.classList.remove("is-hidden"); }
if (empty) empty.classList.add("is-hidden");
}
root.querySelectorAll("[data-image-key]").forEach(function (el) {
var v = p[el.dataset.imageKey];
if (!v) return;
el.src = bust(v);
var fig = el.closest("[data-image-figure]");
if (fig) fig.classList.remove("is-hidden");
});
}
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
nameEl.textContent = f ? f.name + " (" + Math.round(f.size / 1024) + "KB)" : "선택된 파일 없음";
btn.disabled = !f;
if (f && f.size > 10 * 1024 * 1024) {
setMsg(msg, "10MB 를 넘는 파일은 올릴 수 없습니다.", "is-err");
btn.disabled = true;
return;
}
if (f && img) {
// 업로드 전 미리보기 — 실제 교체는 응답을 받은 뒤 서버 경로로 다시 그린다.
img.src = URL.createObjectURL(f);
if (link) link.classList.remove("is-hidden");
if (empty) empty.classList.add("is-hidden");
setMsg(msg, "미리보기입니다. 「업로드하여 교체」를 누르면 카페24에 반영됩니다.", "");
}
});
form.addEventListener("submit", function (e) {
e.preventDefault();
var f = fileEl.files && fileEl.files[0];
if (!f) return;
window.erpConfirm("대표 이미지를 「" + f.name + "」(으)로 교체합니다.\n카페24 쇼핑몰에 바로 반영되며 목록/축소 이미지도 함께 바뀝니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var fd = new FormData();
fd.append("file", f);
btn.disabled = true;
setMsg(msg, "업로드 중… (파일 크기에 따라 수십 초 걸릴 수 있습니다)", "");
apiJson("POST", "/cafe24/products/" + no + "/image", fd)
.then(function (data) {
paint(data.product || {});
fileEl.value = "";
nameEl.textContent = "선택된 파일 없음";
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); btn.disabled = false; });
});
});
})();
// ── 옵션 / 품목 — 펼칠 때 불러온다 ──
(function setupOptions() {
var details = root.querySelector("#cf24-options-details");
var body = root.querySelector("#cf24-options-body");
var count = root.querySelector("#cf24-options-count");
if (!details || !body) return;
var loaded = false;
var state = { option: null, variants: [], displayTypes: {} };
details.addEventListener("toggle", function () {
if (details.open && !loaded) { loaded = true; load(); }
});
function load() {
body.innerHTML = '<p class="cf24-muted">불러오는 중…</p>';
apiJson("GET", "/cafe24/products/" + no + "/options")
.then(function (data) {
state.option = data.option;
state.variants = data.variants || [];
state.displayTypes = data.display_types || {};
render();
})
.catch(function (err) {
loaded = false;
body.innerHTML = '<p class="cf24-err">옵션을 불러오지 못했습니다: ' + escHtml(err.message) +
'</p><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-retry">다시 시도</button>';
body.querySelector("#cf24-opt-retry").addEventListener("click", function () { loaded = true; load(); });
});
}
function typeSelect(current, name) {
var out = '<select name="' + name + '">';
Object.keys(state.displayTypes).forEach(function (k) {
out += '<option value="' + k + '"' + (k === current ? " selected" : "") + ">" + escHtml(state.displayTypes[k]) + "</option>";
});
return out + "</select>";
}
function render() {
var opt = state.option || { has_option: false, options: [] };
var variants = state.variants || [];
if (count) count.textContent = opt.has_option ? "(" + variants.length + "품목)" : "(옵션 없음)";
if (!opt.has_option) {
body.innerHTML =
'<p class="cf24-side-note">이 상품에는 옵션이 없습니다. 옵션명과 옵션값을 넣어 조합형 옵션을 만들면 품목이 자동으로 생성됩니다.</p>' +
'<form class="cf24-side-form cf24-opt-create" id="cf24-opt-create">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" placeholder="예: 색상" /></label>' +
' <label class="cf24-side-field"><span>옵션값 (쉼표 또는 줄바꿈으로 구분)</span><textarea name="values" placeholder="예: 빨강, 파랑, 노랑"></textarea></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect("S", "display_type") + "</label>" +
' <div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 만들기</button></div>' +
"</form>";
var form = body.querySelector("#cf24-opt-create");
form.addEventListener("submit", function (e) {
e.preventDefault();
var payload = {
option_name: form.querySelector('[name="option_name"]').value.trim(),
values: form.querySelector('[name="values"]').value,
display_type: form.querySelector('[name="display_type"]').value
};
if (!payload.option_name || !payload.values.trim()) {
window.erpAlert("옵션명과 옵션값을 입력하세요.");
return;
}
window.erpConfirm("옵션 「" + payload.option_name + "」을(를) 만들고 품목을 생성합니다.\n카페24에 바로 반영됩니다. 계속할까요?")
.then(function (ok) {
if (!ok) return;
var msg = form.querySelector("#cf24-opt-msg");
setMsg(msg, "생성 중…", "");
apiJson("POST", "/cafe24/products/" + no + "/options", payload)
.then(function (data) { state.option = data.option; state.variants = data.variants || []; render(); })
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
});
return;
}
// 옵션 정의 편집
var html = '<form class="cf24-side-form" id="cf24-opt-form">';
opt.options.forEach(function (o, gi) {
html += '<div class="cf24-opt-group" data-group="' + gi + '">' +
'<div class="cf24-opt-head">' +
' <label class="cf24-side-field"><span>옵션명</span><input type="text" name="option_name" value="' + escHtml(o.option_name) + '" /></label>' +
' <label class="cf24-side-field cf24-side-field-type"><span>표시방식</span>' + typeSelect(o.option_display_type || "S", "option_display_type") + "</label>" +
"</div>";
o.option_value.forEach(function (v, vi) {
var thumb = v.option_image_file
? '<img class="cf24-opt-thumb" src="' + escHtml(v.option_image_file) + '" alt="" />'
: '<div class="cf24-opt-thumb-empty">없음</div>';
html += '<div class="cf24-opt-value" data-value="' + vi + '">' +
'<span class="cf24-opt-thumb-slot">' + thumb + "</span>" +
'<input type="text" name="option_text" value="' + escHtml(v.option_text) + '" placeholder="옵션값 이름" />' +
'<input type="hidden" name="option_image_file" value="' + escHtml(v.option_image_file) + '" />' +
'<input type="hidden" name="option_link_image" value="' + escHtml(v.option_link_image) + '" />' +
'<label class="cf24-file-pick"><input type="file" accept="image/jpeg,image/png,image/gif,image/webp" />' +
'<span class="erp-btn erp-btn-outline">썸네일</span></label>' +
"</div>";
});
html += "</div>";
});
html += '<p class="cf24-side-note">옵션값 추가·삭제는 카페24 API 가 지원하지 않습니다(옵션 삭제 후 다시 만들어야 합니다). 썸네일은 표시방식이 「미리보기(이미지)」일 때 쇼핑몰에 보입니다.</p>' +
'<div class="cf24-side-actions"><span class="cf24-muted" id="cf24-opt-msg"></span>' +
' <button class="erp-btn erp-btn-primary" type="submit">옵션 저장</button></div>' +
"</form>";
// 품목 표
html += '<div class="cf24-opt-group"><h5 class="cf24-side-title">품목 (' + variants.length + ')</h5>';
if (!variants.length) {
html += '<p class="cf24-muted">품목이 아직 조회되지 않았습니다. 잠시 뒤 「옵션 / 품목」을 접었다 펴서 다시 불러오세요.</p>';
} else {
html += '<div class="cf24-variants-wrap"><table class="cf24-variants"><thead><tr>' +
"<th>옵션</th><th class=\"cf24-v-code\">자체코드</th><th class=\"cf24-v-amount\">추가금액</th>" +
'<th class="cf24-v-flag">진열</th><th class="cf24-v-flag">판매</th></tr></thead><tbody>';
variants.forEach(function (v) {
var label = v.options.map(function (o) { return o.value; }).join(" / ") || v.variant_code;
var amount = String(parseInt(v.additional_amount || "0", 10) || 0);
html += '<tr data-code="' + escHtml(v.variant_code) + '">' +
'<td class="cf24-v-name" title="' + escHtml(v.variant_code) + '">' +
(v.image ? '<img class="cf24-opt-thumb" style="width:28px;height:28px;vertical-align:middle;margin-right:4px" src="' + escHtml(v.image) + '" alt="" />' : "") +
escHtml(label) + "</td>" +
'<td class="cf24-v-code"><input type="text" name="custom_variant_code" maxlength="40" value="' + escHtml(v.custom_variant_code) + '" /></td>' +
'<td class="cf24-v-amount"><input type="text" name="additional_amount" inputmode="numeric" value="' + escHtml(amount) + '" /></td>' +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.display ? " is-on" : "") + '" data-flag="display" data-on="' + (v.display ? 1 : 0) + '" data-initial="' + (v.display ? 1 : 0) + '">' + (v.display ? "진열" : "미진열") + "</button></td>" +
'<td class="cf24-v-flag"><button type="button" class="cf24-v-toggle' + (v.selling ? " is-on" : "") + '" data-flag="selling" data-on="' + (v.selling ? 1 : 0) + '" data-initial="' + (v.selling ? 1 : 0) + '">' + (v.selling ? "판매" : "중지") + "</button></td>" +
"</tr>";
});
html += "</tbody></table></div>" +
'<div class="cf24-side-actions" style="margin-top:8px;"><span class="cf24-muted" id="cf24-var-msg"></span>' +
' <button type="button" class="erp-btn erp-btn-primary" id="cf24-var-save">품목 저장</button></div>';
}
html += '<div class="cf24-opt-danger"><button type="button" class="erp-btn erp-btn-outline" id="cf24-opt-delete">옵션 전체 삭제</button></div></div>';
body.innerHTML = html;
var optForm = body.querySelector("#cf24-opt-form");
trackDirty(optForm);
bindOptionImages(optForm);
optForm.addEventListener("submit", function (e) { e.preventDefault(); saveOptions(optForm); });
bindVariants();
body.querySelector("#cf24-opt-delete").addEventListener("click", deleteOptions);
}
function bindOptionImages(optForm) {
optForm.querySelectorAll(".cf24-opt-value").forEach(function (row) {
var fileEl = row.querySelector('input[type="file"]');
fileEl.addEventListener("change", function () {
var f = fileEl.files && fileEl.files[0];
if (!f) return;
var slot = row.querySelector(".cf24-opt-thumb-slot");
var msg = optForm.querySelector("#cf24-opt-msg");
setMsg(msg, "썸네일 업로드 중…", "");
var fd = new FormData();
fd.append("file", f);
apiJson("POST", "/cafe24/products/" + no + "/options/image", fd)
.then(function (data) {
row.querySelector('[name="option_image_file"]').value = data.path;
row.querySelector('[name="option_link_image"]').value = data.path;
slot.innerHTML = '<img class="cf24-opt-thumb is-dirty" src="' + escHtml(data.path) + '" alt="" />';
setMsg(msg, "썸네일을 올렸습니다. 「옵션 저장」을 눌러야 카페24에 반영됩니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "썸네일 업로드 실패: " + err.message, "is-err"); })
.then(function () { fileEl.value = ""; });
});
});
}
function saveOptions(optForm) {
var msg = optForm.querySelector("#cf24-opt-msg");
var edited = [];
var changed = false;
optForm.querySelectorAll(".cf24-opt-group[data-group]").forEach(function (g, gi) {
var orig = state.option.options[gi];
var entry = {
option_name: g.querySelector('[name="option_name"]').value.trim(),
option_display_type: g.querySelector('[name="option_display_type"]').value,
option_value: []
};
if (entry.option_name !== orig.option_name || entry.option_display_type !== (orig.option_display_type || "S")) changed = true;
g.querySelectorAll(".cf24-opt-value").forEach(function (row, vi) {
var ov = orig.option_value[vi] || {};
var item = {
option_text: row.querySelector('[name="option_text"]').value.trim(),
option_image_file: row.querySelector('[name="option_image_file"]').value,
option_link_image: row.querySelector('[name="option_link_image"]').value,
option_color: ov.option_color || ""
};
if (item.option_text !== ov.option_text || item.option_image_file !== (ov.option_image_file || "") ||
item.option_link_image !== (ov.option_link_image || "")) changed = true;
entry.option_value.push(item);
});
edited.push(entry);
});
if (!changed) { setMsg(msg, "바뀐 값이 없습니다.", ""); return; }
window.erpConfirm("옵션명·옵션값을 카페24에 바로 반영합니다. 계속할까요?").then(function (ok) {
if (!ok) return;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/options", { options: edited, option_list_type: state.option.option_list_type })
.then(function (data) {
state.option = data.option;
state.variants = data.variants || state.variants;
render();
setMsg(body.querySelector("#cf24-opt-msg"), "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); });
});
}
function bindVariants() {
var saveBtn = body.querySelector("#cf24-var-save");
if (!saveBtn) return;
var wrap = body.querySelector(".cf24-variants-wrap");
trackDirty(wrap);
wrap.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
btn.addEventListener("click", function () {
var on = btn.dataset.on !== "1";
btn.dataset.on = on ? "1" : "0";
btn.classList.toggle("is-on", on);
btn.textContent = btn.dataset.flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
btn.classList.toggle("is-dirty", btn.dataset.on !== btn.dataset.initial);
});
});
saveBtn.addEventListener("click", function () {
var msg = body.querySelector("#cf24-var-msg");
var rows = [];
var lines = [];
wrap.querySelectorAll("tr[data-code]").forEach(function (tr) {
var row = { variant_code: tr.dataset.code };
var any = false;
var code = tr.querySelector('[name="custom_variant_code"]');
var amount = tr.querySelector('[name="additional_amount"]');
if (code.value !== code.defaultValue) { row.custom_variant_code = code.value.trim(); any = true; }
if (amount.value !== amount.defaultValue) { row.additional_amount = amount.value.trim() || "0"; any = true; }
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
if (btn.dataset.on !== btn.dataset.initial) { row[btn.dataset.flag] = btn.dataset.on === "1" ? "on" : "off"; any = true; }
});
if (any) {
rows.push(row);
lines.push(tr.querySelector(".cf24-v-name").textContent.trim());
}
});
if (!rows.length) { setMsg(msg, "바뀐 품목이 없습니다.", ""); return; }
window.erpConfirm("품목 " + rows.length + "건을 카페24에 바로 반영합니다. 계속할까요?\n\n" + lines.slice(0, 8).join("\n") + (lines.length > 8 ? "\n…" : ""))
.then(function (ok) {
if (!ok) return;
saveBtn.disabled = true;
setMsg(msg, "저장 중…", "");
apiJson("PUT", "/cafe24/products/" + no + "/variants", { rows: rows })
.then(function (data) {
var updated = data.updated || {};
Object.keys(updated).forEach(function (code) {
var u = updated[code];
var tr = wrap.querySelector('tr[data-code="' + code + '"]');
if (!tr) return;
var codeEl = tr.querySelector('[name="custom_variant_code"]');
var amountEl = tr.querySelector('[name="additional_amount"]');
if (u.custom_variant_code !== undefined) codeEl.value = u.custom_variant_code;
if (u.additional_amount !== undefined) amountEl.value = String(parseInt(u.additional_amount, 10) || 0);
tr.querySelectorAll(".cf24-v-toggle").forEach(function (btn) {
var flag = btn.dataset.flag;
if (u[flag] === undefined) return;
var on = !!u[flag];
btn.dataset.on = on ? "1" : "0";
btn.dataset.initial = btn.dataset.on;
btn.classList.toggle("is-on", on);
btn.classList.remove("is-dirty");
btn.textContent = flag === "display" ? (on ? "진열" : "미진열") : (on ? "판매" : "중지");
});
// 상태 보관 — 접었다 펴도 응답값이 유지되게
state.variants.forEach(function (v) {
if (v.variant_code !== code) return;
if (u.custom_variant_code !== undefined) v.custom_variant_code = u.custom_variant_code;
if (u.additional_amount !== undefined) v.additional_amount = u.additional_amount;
if (u.display !== undefined) v.display = !!u.display;
if (u.selling !== undefined) v.selling = !!u.selling;
});
});
commitValues(wrap);
setMsg(msg, "카페24에 반영했습니다.", "is-ok");
})
.catch(function (err) { setMsg(msg, "실패: " + err.message, "is-err"); })
.then(function () { saveBtn.disabled = false; });
});
});
}
function deleteOptions() {
window.erpConfirm("옵션을 전부 삭제합니다. 카페24가 이 상품의 **품목도 모두 삭제**하며 되돌릴 수 없습니다.\n정말 삭제할까요?")
.then(function (ok) {
if (!ok) return;
body.innerHTML = '<p class="cf24-muted">삭제 중…</p>';
apiJson("DELETE", "/cafe24/products/" + no + "/options")
.then(function (data) { state.option = data.option; state.variants = []; render(); })
.catch(function (err) {
body.innerHTML = '<p class="cf24-err">삭제 실패: ' + escHtml(err.message) + "</p>";
loaded = false;
});
});
}
})();
};
// 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
// 돌려주므로 호출부는 모두 then() 안에서 이어간다.
function confirmLeave() {
@@ -654,9 +1168,13 @@
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
}
// ── 목록 클릭 → 오른쪽만 교체 ──
// ── 목록 클릭 → 가운데(편집기)·오른쪽(정보 패널)만 교체 ──
// /pane 응답 하나에 두 조각이 담겨 온다([data-pane=editor] / [data-pane=side]).
var sidePane = document.getElementById("cf24-side-pane");
function select(no, push) {
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
if (sidePane) sidePane.innerHTML = "";
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
fetch(url, { credentials: "same-origin", cache: "no-store" })
@@ -667,9 +1185,16 @@
})
.then(function (htmlText) {
if (htmlText === null) return;
pane.innerHTML = htmlText;
var holder = document.createElement("div");
holder.innerHTML = htmlText;
var editorPart = holder.querySelector('[data-pane="editor"]');
var sidePart = holder.querySelector('[data-pane="side"]');
pane.innerHTML = editorPart ? editorPart.innerHTML : htmlText;
if (sidePane) sidePane.innerHTML = sidePart ? sidePart.innerHTML : "";
window.cf24BindEditor();
window.cf24BindSide();
pane.scrollTop = 0;
if (sidePane) sidePane.scrollTop = 0;
if (push) {
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
history.pushState({ no: no }, "", target);
@@ -746,6 +1271,7 @@
});
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
window.cf24BindSide();
})();
</script>
{% endblock %}
+237
View File
@@ -802,6 +802,10 @@ class _FakeStore:
self.added.append(fields)
return 900 + len(self.added)
def save_write_snapshot(self, product_no, section, data):
self.snapshots = getattr(self, "snapshots", [])
self.snapshots.append((product_no, section, data))
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,
@@ -904,6 +908,239 @@ def test_worker_stops_when_nothing_due():
assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0
# ════════════════════════════════════════════════════════════
# 카페24 읽기 지연 보정 — 마지막 쓰기가 권위
# ════════════════════════════════════════════════════════════
def test_resolve_description_no_recent_write_uses_cafe24():
html, state = store.resolve_description("cafe", last_write=None, known_digests=set(), grace_minutes=60)
assert (html, state) == ("cafe", store.SYNC_NONE)
def _write(html, minutes_ago=1):
return {"html_content": html, "created_at": now_kst() - timedelta(minutes=minutes_ago)}
def test_resolve_description_synced_when_cafe24_caught_up():
html, state = store.resolve_description(
"NEW", last_write=_write("NEW"), known_digests={store.content_digest("OLD")}, grace_minutes=60
)
assert (html, state) == ("NEW", store.SYNC_SYNCED)
def test_resolve_description_pending_when_cafe24_returns_known_old_value():
# 카페24가 아직 적용 직전 값(BACKUP 으로 남긴 값)을 돌려준다 → 마지막 쓰기를 보여준다
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description("OLD", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("NEW", store.SYNC_PENDING)
def test_resolve_description_pending_for_older_known_value_too():
# 연속 두 번 적용(A→B→C) 뒤 카페24가 A 를 돌려줘도 '아는 값'이므로 지연으로 본다
digests = {store.content_digest(v) for v in ("A", "B", "C")}
html, state = store.resolve_description("A", last_write=_write("C"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("C", store.SYNC_PENDING)
def test_resolve_description_external_when_unknown_value():
# 관리자에서 직접 고친 값 — 카페24를 믿는다
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description("HAND", last_write=_write("NEW"), known_digests=digests, grace_minutes=60)
assert (html, state) == ("HAND", store.SYNC_EXTERNAL)
def test_resolve_description_grace_expired_trusts_cafe24():
digests = {store.content_digest("OLD"), store.content_digest("NEW")}
html, state = store.resolve_description(
"OLD", last_write=_write("NEW", minutes_ago=500), known_digests=digests, grace_minutes=60
)
assert (html, state) == ("OLD", store.SYNC_NONE)
def test_resolve_description_accepts_iso_created_at():
lw = {"html_content": "NEW", "created_at": (now_kst() - timedelta(minutes=2)).isoformat()}
html, state = store.resolve_description("OLD", last_write=lw, known_digests={store.content_digest("OLD")}, grace_minutes=60)
assert (html, state) == ("NEW", store.SYNC_PENDING)
def test_parse_grace_minutes():
assert store.parse_grace_minutes("30") == 30
assert store.parse_grace_minutes("") == store.DEFAULT_READ_LAG_GRACE_MIN
assert store.parse_grace_minutes("-5") == store.DEFAULT_READ_LAG_GRACE_MIN
assert store.parse_grace_minutes("abc") == store.DEFAULT_READ_LAG_GRACE_MIN
def test_overlay_recent_write_uses_snapshot_when_get_is_older():
fetched = {"product_name": "OLD", "price": "1000.00", "updated_date": "2026-09-18T10:00:00+09:00",
"description": "keep"}
snap = {"product_name": "NEW", "price": "2000.00", "updated_date": "2026-09-18T10:05:00+09:00"}
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
assert stale is True
assert merged["product_name"] == "NEW" and merged["price"] == "2000.00"
assert merged["description"] == "keep" # 스냅샷에 없는 필드는 그대로
assert merged["updated_date"] == snap["updated_date"]
def test_overlay_recent_write_keeps_cafe24_when_caught_up():
fetched = {"product_name": "NEWER", "updated_date": "2026-09-18T10:06:00+09:00"}
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60)
assert stale is False and merged is fetched
def test_overlay_recent_write_skips_without_dates_or_snapshot():
fetched = {"product_name": "X"}
assert store.overlay_recent_write(fetched, snapshot=None, written_at=now_kst(), grace_minutes=60) == (fetched, False)
snap = {"product_name": "NEW"}
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) == (fetched, False)
def test_overlay_recent_write_respects_grace():
fetched = {"product_name": "OLD", "updated_date": "2026-09-18T10:00:00+09:00"}
snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"}
old = (now_kst() - timedelta(hours=10)).isoformat()
assert store.overlay_recent_write(fetched, snapshot=snap, written_at=old, grace_minutes=60) == (fetched, False)
def test_product_snapshot_keeps_only_scalar_fields():
snap = store.product_snapshot({"product_name": "A", "price": "1.00", "description": "<big>", "updated_date": None, "display": "T"})
assert snap == {"product_name": "A", "price": "1.00", "display": "T"}
# ════════════════════════════════════════════════════════════
# 기본 정보 · 옵션 · 품목 입력 검증
# ════════════════════════════════════════════════════════════
def test_parse_price():
assert store.parse_price("6,900") == "6900.00"
assert store.parse_price("6900.00") == "6900.00"
assert store.parse_price(6900) == "6900.00"
assert store.parse_price("") is None
assert store.parse_price(None) is None
for bad in ("abc", "-1"):
try:
store.parse_price(bad)
except ValueError:
pass
else:
raise AssertionError(bad)
assert store.price_equal("6900.00", "6900") and not store.price_equal("6900.00", "7000")
def test_update_payload_price_and_image():
payload = products.build_update_payload(price="1000.00", supply_price="500.00", detail_image="/web/x.jpg")
assert payload["request"] == {
"price": "1000.00", "supply_price": "500.00", "detail_image": "/web/x.jpg", "image_upload_type": "A",
}
def test_parse_option_values():
assert store.parse_option_values("빨강, 파랑\n노랑,, 빨강 ") == ["빨강", "파랑", "노랑"]
def test_build_create_options_request():
body = store.build_create_options_request("색상", ["빨강", "파랑"], display_type="p")
assert body["has_option"] == "T" and body["option_type"] == "T"
assert body["options"][0]["option_display_type"] == "P"
assert [v["option_text"] for v in body["options"][0]["option_value"]] == ["빨강", "파랑"]
for name, values in (("", ["a"]), ("x", [])):
try:
store.build_create_options_request(name, values)
except ValueError:
pass
else:
raise AssertionError((name, values))
def test_build_update_options_request_pairs_original_and_new():
original = [{"option_code": "O1", "option_name": "Color", "option_display_type": "S",
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
edited = [{"option_name": "Colors", "option_display_type": "P",
"option_value": [{"option_text": "Jet Black", "option_image_file": "https://d/x.png"},
{"option_text": "Deep Red"}]}]
body = store.build_update_options_request(original, edited, option_list_type="S")
assert body["option_list_type"] == "S"
assert body["original_options"] == [{"option_code": "O1", "option_name": "Color",
"option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}]
assert body["options"][0]["option_name"] == "Colors"
assert body["options"][0]["option_display_type"] == "P"
assert body["options"][0]["option_value"][0] == {"option_text": "Jet Black", "value_no": 1, "option_image_file": "https://d/x.png"}
assert body["options"][0]["option_value"][1] == {"option_text": "Deep Red", "value_no": 2}
def test_build_update_options_request_rejects_count_mismatch():
original = [{"option_name": "A", "option_value": [{"option_text": "1"}]}]
for edited in ([], [{"option_name": "A", "option_value": []}], [{"option_name": "", "option_value": [{"option_text": "1"}]}]):
try:
store.build_update_options_request(original, edited)
except ValueError:
pass
else:
raise AssertionError(edited)
def test_build_variant_updates():
rows = [
{"variant_code": "p000000r000a", "custom_variant_code": " ABC ", "additional_amount": "1,000", "display": "on"},
{"variant_code": "P000000R000B"}, # 바뀐 것 없음 → 제외
{"variant_code": "P000000R000C", "selling": "off", "additional_amount": ""},
]
out = store.build_variant_updates(rows)
assert out == [
{"variant_code": "P000000R000A", "custom_variant_code": "ABC", "additional_amount": "1000.00", "display": "T"},
{"variant_code": "P000000R000C", "selling": "F"},
]
try:
store.build_variant_updates([{"variant_code": "bad"}])
except ValueError:
pass
else:
raise AssertionError("bad code accepted")
class _RouteClient:
"""경로별 응답을 돌려주는 가짜 클라이언트 (get/put/post/delete)."""
def __init__(self, routes):
self.routes = routes
self.calls: list[tuple] = []
def _call(self, method, path, json=None, **_kw):
self.calls.append((method, path, json))
return self.routes.get(f"{method} {path}", {})
def get(self, path, **kw):
return self._call("GET", path, **kw)
def put(self, path, **kw):
return self._call("PUT", path, **kw)
def post(self, path, **kw):
return self._call("POST", path, **kw)
def delete(self, path, **kw):
return self._call("DELETE", path, **kw)
def test_upload_images_and_variants_wrappers():
client = _RouteClient({
"POST /admin/products/images": {"images": [{"path": "https://d/a.png"}, {"path": "https://d/b.png"}]},
"PUT /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A", "display": "F"}]},
"GET /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A"}]},
"GET /admin/products/7/options": {"option": {"has_option": "T", "options": []}},
"DELETE /admin/products/7/options": {"option": {"product_no": 7}},
})
assert products.upload_images(client, ["AAA", "BBB"]) == ["https://d/a.png", "https://d/b.png"]
assert products.upload_image_bytes(client, b"\x89PNG") == "https://d/a.png"
assert products.list_variants(client, 7) == [{"variant_code": "P000000R000A"}]
assert products.get_options(client, 7)["has_option"] == "T"
assert products.update_variants(client, 7, [{"variant_code": "P000000R000A", "display": "F"}]) == [
{"variant_code": "P000000R000A", "display": "F"}
]
sent = [c for c in client.calls if c[0] == "PUT"][-1]
assert sent[2]["requests"][0]["variant_code"] == "P000000R000A"
products.delete_options(client, 7)
assert client.calls[-1][0] == "DELETE"
def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns:
+17 -1
View File
@@ -80,7 +80,7 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
# PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
products.update_product(
updated = products.update_product(
api.client,
product_no,
description=html,
@@ -88,10 +88,26 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
display=set_display,
selling=set_selling,
)
# PUT 응답은 쓰기 직후의 실제 값이다 — 스냅샷으로 남겨 화면이 GET 의 읽기 지연에
# 흔들리지 않게 한다(routes_products._load_product 가 덮어씌운다).
if isinstance(updated, dict) and updated.get("product_no"):
try:
store_db.save_write_snapshot(product_no, "product", store.product_snapshot(updated))
except Exception: # noqa: BLE001 — 스냅샷 실패가 예약 결과를 바꾸면 안 된다.
logger.exception("예약 #%s 상품 %s 스냅샷 저장 실패", schedule_id, product_no)
if html is not None:
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
# 확인이 안 돼도 실패가 아니다 — 화면은 마지막 쓰기(SCHEDULED revision)를 기준으로
# 그린다. 여기서 SCHEDULED revision 을 남겨야 그 기준이 생긴다.
products.wait_for_description(api.client, product_no, html)
store_db.add_revision(
product_no=product_no,
html_content=html,
revision_type=store.REVISION_SCHEDULED,
memo=f"예약 #{schedule_id} 적용",
created_by=ACTOR,
)
summary = store.describe_schedule_action(
has_html=html is not None, set_display=set_display, set_selling=set_selling
+396 -4
View File
@@ -11,7 +11,7 @@
flex-shrink: 0;
}
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
/* 분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
.erp-page {
@@ -24,8 +24,8 @@
}
/* ════════════════════════════════════════════════════════════
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게)
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
좌우 분할: 왼쪽 목록(좁게) | 가운데 상세페이지 편집(넓게) | 오른쪽 정보 패널
(.cf24-split-3 — 아래 3분할 절). 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
════════════════════════════════════════════════════════════ */
.cf24-split {
display: grid;
@@ -134,7 +134,7 @@
.cf24-col-name { word-break: break-word; }
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */
전체 이름은 title 툴팁과 편집기 제목에서 확인한다. */
.cf24-col-name a {
color: inherit;
text-decoration: none;
@@ -803,3 +803,395 @@
font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px;
}
/* ════════════════════════════════════════════════════════════
3분할 — 목록 | 편집기 | 상품 정보 패널
왼쪽 목록은 좁게, 가운데 편집기가 남는 폭을, 오른쪽 패널은 고정폭.
════════════════════════════════════════════════════════════ */
.cf24-split.cf24-split-3 {
/* 목록은 320~460px 사이에서 줄어들고, 편집기가 남는 폭을, 패널은 380px 고정.
1920px: 460 + 380 → 편집기 약 1000px. 1400px: 320 + 380 → 편집기 약 640px. */
grid-template-columns: minmax(320px, 460px) minmax(0, 1fr) 380px;
}
@media (max-width: 1360px) {
/* 폭이 모자라면 정보 패널을 편집기 아래로 내린다(목록은 그대로 왼쪽).
아래 칸은 높이를 제한해 편집기가 너무 눌리지 않게 한다(패널 안에서 스크롤). */
.cf24-split.cf24-split-3 {
grid-template-columns: minmax(300px, 380px) minmax(0, 1fr);
grid-template-areas: "list editor" "list side";
grid-template-rows: minmax(0, 1fr) auto;
}
.cf24-split-3 > .cf24-pane-list { grid-area: list; }
.cf24-split-3 > .cf24-pane-editor { grid-area: editor; }
.cf24-split-3 > .cf24-pane-side { grid-area: side; max-height: 38vh; }
}
@media (max-width: 1100px) {
.cf24-split.cf24-split-3 {
grid-template-columns: minmax(0, 1fr);
grid-template-areas: none;
}
.cf24-split-3 > .cf24-pane-list,
.cf24-split-3 > .cf24-pane-editor,
.cf24-split-3 > .cf24-pane-side { grid-area: auto; max-height: none; }
}
.cf24-pane-side {
display: flex;
flex-direction: column;
gap: var(--sp-12, 12px);
padding: var(--sp-12, 12px);
}
/* 읽기 지연 안내 배너 */
.cf24-flash-warn {
background: #fff8e6;
border: 1px solid #f0b429;
}
.cf24-btn-inline {
margin-left: var(--sp-8, 8px);
padding: 2px 8px;
font-size: 12px;
}
/* ── 정보 패널 공통 ── */
.cf24-side {
display: flex;
flex-direction: column;
gap: var(--sp-12, 12px);
}
.cf24-side-sec {
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-lg, 10px);
padding: var(--sp-10, 10px) var(--sp-12, 12px);
background: var(--color-canvas-white, #fff);
}
.cf24-side-title {
margin: 0 0 var(--sp-8, 8px);
font-size: var(--text-body, 14px);
font-weight: 600;
letter-spacing: -0.3px;
}
.cf24-side-details > summary.cf24-side-title {
cursor: pointer;
margin-bottom: 0;
}
.cf24-side-details[open] > summary.cf24-side-title {
margin-bottom: var(--sp-8, 8px);
}
.cf24-side-note {
margin: 0 0 var(--sp-8, 8px);
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
}
.cf24-side-note-warn {
color: #9a6700;
}
.cf24-side-form {
display: flex;
flex-direction: column;
gap: var(--sp-8, 8px);
}
.cf24-side-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
min-width: 0;
}
.cf24-side-field input[type="text"],
.cf24-side-field select {
height: 32px;
box-sizing: border-box;
width: 100%;
padding: 0 var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: var(--text-body, 14px);
color: var(--color-rich-black, #0a0a0a);
background: var(--color-canvas-white, #fff);
}
.cf24-side-field input:focus,
.cf24-side-field select:focus {
outline: none;
border-color: var(--color-rich-black, #0a0a0a);
}
/* 값이 바뀐 입력칸은 테두리로 표시 — 무엇이 저장될지 보이게 */
.cf24-side input.is-dirty,
.cf24-side select.is-dirty {
border-color: #f0b429;
background: #fffbea;
}
.cf24-side-grid3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-8, 8px);
}
.cf24-side-actions {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-8, 8px);
}
.cf24-side-actions .cf24-muted {
min-width: 0;
flex: 1 1 auto;
}
.cf24-side-actions .is-ok { color: var(--color-success-green, #10c22b); }
.cf24-side-actions .is-err { color: var(--color-callout-red, #c22b10); }
/* ── 대표 이미지 ── */
.cf24-image-box {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
background: var(--color-ghost-gray, #f6f8fa);
margin-bottom: var(--sp-8, 8px);
overflow: hidden;
}
.cf24-image-main {
display: block;
max-width: 100%;
max-height: 260px;
object-fit: contain;
}
.cf24-image-empty {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
padding: var(--sp-16, 16px);
}
.cf24-image-thumbs {
display: flex;
gap: var(--sp-8, 8px);
margin-bottom: var(--sp-8, 8px);
}
.cf24-image-thumb {
margin: 0;
flex: 1 1 0;
min-width: 0;
text-align: center;
font-size: 11px;
color: var(--color-midtone-gray, #737373);
}
.cf24-image-thumb img {
display: block;
width: 100%;
height: 64px;
object-fit: contain;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
background: #fff;
}
.cf24-image-thumb img.is-hidden,
.cf24-side .is-hidden {
display: none;
}
.cf24-file-pick {
display: flex;
align-items: center;
gap: var(--sp-8, 8px);
cursor: pointer;
min-width: 0;
}
.cf24-file-pick input[type="file"] {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
overflow: hidden;
}
.cf24-file-name {
font-size: var(--text-caption, 12px);
color: var(--color-midtone-gray, #737373);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* ── 옵션 / 품목 ── */
.cf24-options-body {
display: flex;
flex-direction: column;
gap: var(--sp-10, 10px);
}
.cf24-opt-group {
border-top: 1px solid var(--color-subtle-ash, #e5e5e5);
padding-top: var(--sp-8, 8px);
}
.cf24-opt-head {
display: flex;
gap: var(--sp-8, 8px);
align-items: flex-end;
margin-bottom: var(--sp-8, 8px);
}
.cf24-opt-head .cf24-side-field { flex: 1 1 auto; }
.cf24-opt-head .cf24-side-field-type { flex: 0 0 140px; }
.cf24-opt-value {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: var(--sp-8, 8px);
align-items: center;
padding: 4px 0;
}
.cf24-opt-thumb {
width: 44px;
height: 44px;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
background: var(--color-ghost-gray, #f6f8fa);
object-fit: cover;
display: block;
}
.cf24-opt-thumb-empty {
width: 44px;
height: 44px;
border: 1px dashed var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: var(--color-midtone-gray, #737373);
}
.cf24-opt-value input[type="text"] {
height: 30px;
box-sizing: border-box;
width: 100%;
padding: 0 var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: 13px;
}
.cf24-opt-value .cf24-file-pick .erp-btn {
padding: 3px 8px;
font-size: 12px;
}
/* 품목 표 — 좁은 칸에 맞춘 작은 글자 */
.cf24-variants {
width: 100%;
border-collapse: collapse;
font-size: 12px;
table-layout: fixed;
}
.cf24-variants th,
.cf24-variants td {
padding: 4px 4px;
border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5);
vertical-align: middle;
text-align: left;
}
.cf24-variants th {
font-weight: 500;
color: var(--color-midtone-gray, #737373);
white-space: nowrap;
}
.cf24-variants td.cf24-v-name {
word-break: break-word;
}
.cf24-variants input[type="text"] {
height: 28px;
box-sizing: border-box;
width: 100%;
padding: 0 6px;
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-sm, 4px);
font-size: 12px;
}
.cf24-variants .cf24-v-amount input { text-align: right; }
/* "미진열"(3글자)이 한 줄에 들어가는 폭. 실측: 44px 에서는 두 줄로 꺾였다. */
.cf24-variants .cf24-v-flag { text-align: center; width: 54px; }
.cf24-variants .cf24-v-code { width: 92px; }
.cf24-variants .cf24-v-amount { width: 76px; }
/* 진열/판매 토글 — 저장 전까지는 화면만 바뀐다(배지와 같은 색 규칙) */
.cf24-v-toggle {
border: 1px solid transparent;
border-radius: 999px;
padding: 2px 6px;
font-size: 11px;
white-space: nowrap;
cursor: pointer;
background: var(--color-ghost-gray, #f2f2f2);
color: var(--color-rich-black, #0a0a0a);
}
.cf24-v-toggle.is-on {
background: var(--color-success-green, #10c22b);
color: #fff;
}
.cf24-v-toggle.is-dirty {
box-shadow: 0 0 0 2px #f0b429;
}
.cf24-variants-wrap {
max-height: 320px;
overflow: auto;
}
.cf24-opt-danger {
display: flex;
justify-content: flex-end;
padding-top: var(--sp-8, 8px);
}
.cf24-opt-danger .erp-btn {
color: var(--color-callout-red, #c22b10);
border-color: var(--color-callout-red, #c22b10);
}
.cf24-opt-create textarea {
width: 100%;
box-sizing: border-box;
min-height: 64px;
padding: var(--sp-8, 8px);
border: 1px solid var(--color-subtle-ash, #e5e5e5);
border-radius: var(--r-md, 6px);
font-size: 13px;
resize: vertical;
}
+129 -27
View File
@@ -26,7 +26,9 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리
app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기)
├─ routes_products.py 3분할 화면 · 편집기+정보패널 조각 · 적용(쓰기) · 읽기 지연 보정
├─ routes_product_info.py 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
│ 옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
├─ routes_schedules.py 예약 등록·목록·취소
├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프·
│ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유)
@@ -36,8 +38,9 @@ app/modules/cafe24/ ← 상품관리 모듈
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html(2분할) ·
_editor.html(오른쪽 조각) · design_editor.html ·
└─ templates/cafe24/ _nav.html · products.html(3분할 + 모든 JS) ·
_editor.html(가운데 조각) · _side.html(오른쪽 정보 패널 조각) ·
_panes.html(두 조각 묶음 — /pane 응답) · design_editor.html ·
schedules.html · system.html
```
@@ -54,12 +57,20 @@ app/modules/cafe24/ ← 상품관리 모듈
| 경로 | 화면 | 권한 |
| --- | --- | --- |
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` |
| `GET /cafe24/` | 3분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
| `GET /cafe24/products/{product_no}/pane` | 편집기 + 정보 패널 조각 묶음 (JS 가 가져가 `[data-pane]` 별로 끼움) | `cafe24` |
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` |
| `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` |
| `POST /cafe24/products/{product_no}/basic` | 상품명·판매가·공급가·소비자가 (JSON, 바뀐 것만 PUT). 감사로그 `update_basic` | `cafe24` |
| `POST /cafe24/products/{product_no}/image` | 대표 이미지 교체 (multipart `file`) — 업로드 후 PUT `detail_image` + `image_upload_type=A`. 감사로그 `set_main_image` | `cafe24` |
| `GET /cafe24/products/{product_no}/options` | 옵션 + 품목 조회 (JSON, 읽기 지연 보정 포함) | `cafe24` |
| `POST /cafe24/products/{product_no}/options` | 옵션 생성 (`{option_name, values, display_type}`) — 조합형, 품목 자동 생성 | `cafe24` |
| `PUT /cafe24/products/{product_no}/options` | 옵션명·옵션값 이름/썸네일/표시방식 수정 (`{options:[...], option_list_type}`) | `cafe24` |
| `DELETE /cafe24/products/{product_no}/options` | 옵션 삭제 — **품목도 함께 삭제**(화면 확인창) | `cafe24` |
| `POST /cafe24/products/{product_no}/options/image` | 옵션값 썸네일 업로드만 (multipart → `{path}`) | `cafe24` |
| `PUT /cafe24/products/{product_no}/variants` | 품목 자체코드·추가금액·진열·판매 일괄 수정 (`{rows:[...]}`, 바뀐 행만) | `cafe24` |
| `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
@@ -120,19 +131,29 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
---
### 2-1. 상품관리 화면 구성 (2분할)
### 2-1. 상품관리 화면 구성 (3분할)
```
┌─ 550px ──────────────────────────남은 폭 전부 ─────────┐
│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매
│ ☑진열중 ☑판매중 (기본 체크) │ 상세설명 HTML 편집기
│ ── 목록(전체, 스크롤) ── │ (PC/모바일 공통 · 문법 강조)
│ 번호 상품명 진열 판매 수정 │ [메모][복사][다시 읽기][적용]
│ (제목행 클릭 = 정렬) │ ▸ 버전 이력
└───────────────────────────┴─────────────────────────────────┘
┌─ 320~460px ────────────────── 남은 폭 전부 ────────380px ─────────────┐
│ 상품명 검색(한 줄) │ [반영 대기 배너 — 지연 시] │ 기본 정보
│ ☑진열중 ☑판매중 │ 상품 이름 · 번호 · 진열/판매 │ 상품명/판매가/공급가
│ ── 목록(전체) ── 상세설명 HTML 편집기 │ /소비자가 [저장]
│ 번호 상품명 진열 판매 │ (PC/모바일 공통 · 문법 강조) │ 대표 이미지
│ (제목행 클릭 = 정렬) │ [메모][복사][다시 읽기][적용] │ 큰 그림 + 목록/축소
│ ▸ 예약 적용 [파일 선택][교체]
│ │ ▸ 버전 이력 │ ▸ 옵션 / 품목 (지연 로드) │
└───────────────────────┴───────────────────────────────┴──────────────────────┘
```
- 오른쪽 **상품 정보 패널**(`_side.html`)은 "소스 수정 외 기능"을 모아둔 별도 공간이다.
상품 클릭 시 `/products/{no}/pane` 이 편집기 조각과 패널 조각을 **한 응답**(`_panes.html`,
`[data-pane=editor]`/`[data-pane=side]`)으로 돌려주고 JS 가 각각 끼운다 — 카페24 상품
조회는 1회. 옵션/품목은 `<details>` 를 펼칠 때 JSON 으로 따로 불러온다(호출 2회 절약).
- 1360px 이하에서는 패널이 편집기 아래로 내려가고(높이 38vh, 안에서 스크롤), 1100px 이하는
한 열이다.
- 패널의 모든 쓰기는 JSON API(`routes_product_info.py`)이며 화면은 **응답값**으로 다시
그린다. 저장 뒤 카페24를 다시 GET 하지 않는다(아래 3-3 읽기 지연 참고).
- 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
@@ -148,8 +169,8 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다.
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
덕분에 목록 전체가 대상이 된다.
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
- **상품 클릭 시 편집기·정보 패널만 교체**한다(`/pane` 조각 묶음을 fetch → 삽입). 목록을
다시 받지 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
@@ -341,12 +362,16 @@ cafe24_oauth_tokens 저장
정리된 소스가 그대로 저장된다).
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
1-1. **읽기 지연 판정으로 유효 현재값을 정한다**(3-3). 카페24가 아직 예전 값을
돌려주는 중이면 우리 마지막 쓰기(MANUAL/SCHEDULED)가 현재값이다.
2. 유효 현재값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
덮어쓰는 것을 막는 낙관적 잠금이다.
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`).
유효 현재값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
덮어쓰는 것을 막는 낙관적 잠금이다. (카페24의 지연된 예전 값과 비교하면
방금 적용한 것이 "충돌"로 오판된다 — 그래서 유효 현재값과 비교한다.)
4. 내용이 유효 현재값과 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
5. PUT 적용 → PUT 응답을 **스냅샷**으로 저장 → **MANUAL revision** → 짧은 재조회
확인(결과는 감사로그에만) → 감사로그(`apply_description`).
추가 규칙:
@@ -361,12 +386,79 @@ cafe24_oauth_tokens 저장
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
- **적용 직후 짧게 재확인한다(`products.wait_for_description`).** 카페24 관리자
API(`GET /admin/products/{no}`)는 PUT 직후 몇 초간 이전 값을 돌려줄 때가
있다(쇼핑몰 화면에는 바로 반영됨 — 실물 관찰). 그 상태에서 다른 상품을 봤다가
돌아오면 우리 편집기만 "적용 안 된 것"처럼 보인다. 적용/예약 실행 직후
0.8초 간격으로 최대 3회 재조회해 새 값이 확인될 때까지 기다린 뒤 화면으로
돌아간다(실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다).
- 적용 직후 `products.wait_for_description` 으로 0.8초 × 3회 재조회해 보지만, 이것은
**확인용**일 뿐 화면의 기준이 아니다(3-3). 확인 여부는 감사로그 detail 에 남는다.
---
## 3-3. 카페24 읽기 지연(read-after-write lag) — "마지막 쓰기가 권위"
### 증상과 원인
"소스를 수정해 적용했는데 편집기에는 수정 전 소스가 보이고, 한참 뒤에 다시 들어가면
반영돼 있다." 우리 쪽 캐시가 아니다 — 화면·조각 응답은 전부 `Cache-Control: no-store`
이고 fetch 도 `no-store` 다. **카페24 관리자 API 가 PUT 직후 한동안 `GET
/admin/products/{no}` 에서 이전 값을 돌려준다**(실물 관찰. 몇 초일 때도, 훨씬 길 때도
있다. 쇼핑몰 고객 화면에는 바로 반영된다). 예전 코드는 2.4초만 기다리고 포기한 뒤 GET
값을 그대로 믿었기 때문에
1. 편집기에 예전 소스가 보이고,
2. 그 예전 값으로 지문을 만들어 다음 적용이 "충돌"로 거부되거나,
3. 예전 값이 BACKUP 으로 남고 "변경 없음" 판정이 어긋났다.
### 규칙
쓰기가 성공하면 **우리가 쓴 값을 DB 에 남기고, 유예시간 안에서는 그것을 현재값으로
삼는다.** 유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값을
믿는다 — 지연은 영원하지 않고, 영원히 로컬 값을 고집하면 그것이 또 다른 캐시다.
| 대상 | 우리가 남기는 것 | 지연 판정 | 구현 |
| --- | --- | --- | --- |
| 상세설명 HTML | MANUAL/SCHEDULED revision (+ 적용 직전 BACKUP) | 카페24 값이 유예시간 안의 **revision 중 하나와 같으면** 지연(pending) → 마지막 쓰기를 표시하고 그 지문을 쓴다. 마지막 쓰기와 같으면 synced. 우리가 모르는 값이면 external(관리자에서 직접 고침) → 카페24 값 표시 | `store.resolve_description`, `routes_products.resolve_description`, DB `revision_digests`(md5) |
| 상품명·가격·이미지·진열/판매 | PUT 응답을 `cafe24_products.last_write_snapshot.product` 에 저장 | GET 의 `updated_date` 가 스냅샷의 `updated_date` 보다 **이전**이면 지연 → 스냅샷 값으로 덮어씀. 카페24 자신의 시각끼리 비교하므로 서버 시계와 무관 | `store.overlay_recent_write`, `routes_products.load_product` |
| 옵션 | PUT/POST 응답을 `…snapshot.options` 에 저장 | 유예시간 안이면 스냅샷 우선 | `routes_product_info._apply_options_snapshot` |
| 품목 | 보낸 값(+응답)을 `…snapshot.variants` 에 코드별 누적 | 유예시간 안이면 해당 코드의 필드만 덮어씀 | `routes_product_info._apply_variant_snapshot` |
- 편집기 위에 **「카페24 반영 대기 중」 배너**(pending) 또는 **「관리자에서 직접 수정된
것으로 보임」 배너**(external)를 띄운다. 「다시 확인」은 「다시 읽기」와 같다.
- 적용·기본정보·상태 등 **모든 쓰기는 유효 현재값과 비교**해 바뀐 것만 보낸다. 이걸
안 하면 "A→B 로 바꾼 직후 B→A" 가 카페24의 지연된 값(A)과 같다고 판단돼 무시된다.
- 쓰기 뒤 화면은 **PUT 응답으로 그린다.** 응답은 쓰기 직후의 실제 값이다(GET 과 달리
지연이 없다 — 실물 예제 응답에 `updated_date` 가 갱신되어 온다). 다시 GET 하지 않는다.
- 스냅샷은 마이그레이션 `cafe24_db_004_write_snapshot.sql`(멱등)의 두 컬럼
(`last_write_snapshot` JSONB, `last_written_at`)을 쓴다. 미적용 상태에서는 스냅샷
저장이 실패해 로그가 남지만 적용 자체는 성공한다 — 반드시 적용할 것.
---
## 3-4. 정보 패널 API 사실 (카페24 문서 실물 예제 기준)
- **이미지 업로드**: `POST /admin/products/images` body `{"requests":[{"image":"<base64>"}]}`
→ `{"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]}`. 1장 10MB, 1호출 30MB,
1회 20장. 상품 대표이미지는 그 경로를 `PUT /admin/products/{no}` 의 `detail_image` 에
넣고 `image_upload_type: "A"`(대표이미지등록 — 목록/작은목록/축소를 카페24가 리사이징).
`B` 는 네 이미지를 각각 지정, `C` 는 웹FTP.
- **가격**: `price`(판매가) · `supply_price`(공급가, 문서상 "참조 목적") · `retail_price`
(소비자가). 문자열 `"11000.00"` 형식으로 보낸다(`store.parse_price`). 쇼핑몰 설정의
"판매가 계산 기준"이 상품가(B)면 `price` 대신 `price_excluding_tax` 를 써야 한다는 문서
주석이 있다 — 그 경우 카페24 오류 메시지가 그대로 화면에 뜬다.
- **옵션**: `GET/POST/PUT/DELETE /admin/products/{no}/options`, 응답 루트 `option`.
생성은 조합형(`option_type:"T"`, `option_list_type:"S"`) + 옵션값 목록 → **품목 자동
생성**. 수정(PUT)은 `original_options`(수정 전)와 `options`(수정 후)를 **같은 순서·개수**로
짝지어 보낸다 — 옵션명·옵션값 이름·`option_image_file`(옵션 버튼 이미지)·
`option_link_image`(연결 이미지)·`option_color`·`option_display_type`(S 셀렉트/P 미리보기/
B 버튼/R 라디오)만 바꿀 수 있고 **옵션값 추가/삭제는 불가**(문서). 연동형(E)은
`option_code`/`value_no`/`option_preset_code` 를 함께 보낸다. DELETE 는 옵션 사용안함 +
**품목 전부 삭제**(화면에서 확인창).
- **품목**: `GET /admin/products/{no}/variants` → `variants[]`(`variant_code` 12자,
`options[{name,value}]`, `custom_variant_code`, `additional_amount`, `display`, `selling`,
`quantity`, `image`…). `PUT /admin/products/{no}/variants` body `{"shop_no":1,
"requests":[{variant_code, custom_variant_code, additional_amount, display, selling…}]}`
1회 100건(초과 시 나눠 보냄). 재고(`quantity`)는 이 화면에서 건드리지 않는다.
- "옵션별 상품 이름" 은 옵션값(`option_text`)이고, "옵션별 썸네일" 은 `option_image_file`
(+ `option_link_image` 에도 같은 경로) 이며, "옵션별 상품코드/추가금액/진열" 은 품목
(`custom_variant_code`/`additional_amount`/`display`)이다 — 카페24 모델상 둘은 다른
리소스라 화면에서도 「옵션 저장」과 「품목 저장」이 따로 있다.
---
@@ -441,6 +533,14 @@ docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_003_swiper_revisions.sql
```
읽기 지연 보정(3-3)·정보 패널을 쓰려면 마이그레이션 004 를 적용해야 한다(멱등).
```bash
docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql
```
선택 env: `CAFE24_READ_LAG_GRACE_MIN`(기본 360) — 3-3 의 유예시간(분).
worker 가 도는지 확인:
```bash
@@ -474,6 +574,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
| 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) |
| 9 | 카페24 읽기 지연 보정("마지막 쓰기가 권위", 3-3) | ✅ 완료 (마이그레이션 004) |
| 10 | 상품 정보 패널 — 상품명/가격 · 대표이미지 · 옵션/품목 (3-4) | ✅ 완료 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다.
+3 -1
View File
@@ -303,7 +303,7 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이
| 테이블 | 용도 |
| --- | --- |
| `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 |
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 |
| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24. `last_write_snapshot`(JSONB, 마이그레이션 004) 에 카페24 PUT 응답을 섹션별(`product`/`options`/`variants`)로 남겨 **읽기 지연**(PUT 뒤 GET 이 한동안 예전 값을 돌려줌) 동안 화면 기준으로 쓴다 |
| `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
@@ -315,6 +315,8 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이
- 예약의 `restore_revision_id`**예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
- 토큰 암호화 키는 `.env``CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.**
- 기존 운영 DB 에는 마이그레이션 002·003·004 를 순서대로 적용한다(전부 멱등):
`docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql`
### 운영 서버 초기화 (1회, 사용자 승인 후)
@@ -0,0 +1,19 @@
-- cafe24_db 마이그레이션 004 — 쓰기 직후 스냅샷 (카페24 읽기 지연 보정)
--
-- 카페24 관리자 API 는 PUT 직후 한동안 GET 에서 예전 값을 돌려준다.
-- PUT 응답의 상품 값(상품명·가격·이미지·진열/판매·updated_date)을 여기 남겨,
-- GET 의 updated_date 가 스냅샷보다 이전이면 스냅샷으로 화면을 덮어씌운다.
-- 상세설명(HTML)은 cafe24_product_revisions 가 담당하므로 넣지 않는다.
--
-- 적용:
-- docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql
-- 멱등 — 여러 번 실행해도 안전.
ALTER TABLE cafe24_products
ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB,
ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ;
COMMENT ON COLUMN cafe24_products.last_write_snapshot IS
'카페24 PUT 응답 상품 값(상품명·가격·이미지·진열/판매·updated_date). 읽기 지연 보정용';
COMMENT ON COLUMN cafe24_products.last_written_at IS
'마지막으로 카페24에 쓴 시각. 유예시간(CAFE24_READ_LAG_GRACE_MIN) 판정용';
+6
View File
@@ -94,9 +94,15 @@ CREATE TABLE IF NOT EXISTS cafe24_products (
display BOOLEAN NOT NULL DEFAULT TRUE,
selling BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ,
-- 쓰기 직후 스냅샷(카페24 읽기 지연 보정, 마이그레이션 004)
last_write_snapshot JSONB,
last_written_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE cafe24_products
ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB,
ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);