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)으로 대체된다. # 미설정 시 카페24 기본 도메인(https://<mall_id>.cafe24.com)으로 대체된다.
# CAFE24_SHOP_URL=https://www.miras.co.kr # 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 암호화해서 저장할 때 쓰는 키. # access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키.
# openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어 # openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어
# 카페24 재연결(재인증)이 필요하다. # 카페24 재연결(재인증)이 필요하다.
+1 -1
View File
@@ -35,7 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아
- 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 휴가 관리 (`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/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` - 말레이시아 배송 (`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` - 프로젝트 관리 (`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`. 상세는 `docs/PROJECT_OVERVIEW.md`.
+9 -2
View File
@@ -148,8 +148,12 @@ class Cafe24Client:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
product_no: int | None = None, product_no: int | None = None,
timeout: float | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.""" """카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.
timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값.
"""
if not self._config.configured: if not self._config.configured:
raise Cafe24ConfigError( raise Cafe24ConfigError(
"카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing) "카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing)
@@ -167,7 +171,7 @@ class Cafe24Client:
status: int | None = None status: int | None = None
try: try:
access_token = self._tokens.get_access_token() 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( response = client.request(
method, method,
url, url,
@@ -256,3 +260,6 @@ class Cafe24Client:
def post(self, path: str, **kwargs: Any) -> dict[str, Any]: def post(self, path: str, **kwargs: Any) -> dict[str, Any]:
return self.request("POST", path, **kwargs) 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, display: bool | None = None,
selling: bool | None = None, selling: bool | None = None,
shop_no: int | 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[str, Any]:
"""상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정). """상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정).
@@ -217,6 +222,19 @@ def build_update_payload(
request["display"] = _flag_value(display) request["display"] = _flag_value(display)
if selling is not None: if selling is not None:
request["selling"] = _flag_value(selling) 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} payload: dict[str, Any] = {"request": request}
if shop_no: if shop_no:
payload["shop_no"] = int(shop_no) payload["shop_no"] = int(shop_no)
@@ -234,10 +252,18 @@ def update_product(
display: bool | None = None, display: bool | None = None,
selling: bool | None = None, selling: bool | None = None,
shop_no: int | 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[str, Any]:
"""상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다. """상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로.
바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다. 바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다.
응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을
돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로
남겨 화면을 맞춘다(`store.product_snapshot`).
⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP ⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP
revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다. revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다.
@@ -250,6 +276,11 @@ def update_product(
display=display, display=display,
selling=selling, selling=selling,
shop_no=shop_no, 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"]: if not payload["request"]:
return {} 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]: def normalize_product(raw: dict[str, Any]) -> dict[str, Any]:
"""카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화.""" """카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화."""
try: try:
+13
View File
@@ -29,6 +29,19 @@ def get_store(request: Request) -> Any:
return getattr(request.app.state, "cafe24_store", None) 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]: def require_user(request: Request) -> dict[str, Any]:
from app.main import get_current_user_record # noqa: WPS433 from app.main import get_current_user_record # noqa: WPS433
from app.store import has_module # 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: class TokenLock:
"""token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장.""" """token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장."""
@@ -263,6 +275,118 @@ class Cafe24Store:
).fetchone() ).fetchone()
return self._serialize(row) 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 하지 않는다) # 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다)
# 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다. # 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다.
+4
View File
@@ -10,6 +10,8 @@
라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에 라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에
확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다.
routes_products 상품 목록/검색 · 상세설명 조회·편집·적용 routes_products 상품 목록/검색 · 상세설명 조회·편집·적용
routes_product_info 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
routes_schedules 예약 등록·목록·취소 (실행은 worker.py) routes_schedules 예약 등록·목록·취소 (실행은 worker.py)
routes_system 연결(OAuth)·상태·API 로그·작업 로그 routes_system 연결(OAuth)·상태·API 로그·작업 로그
routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 — routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 —
@@ -24,6 +26,7 @@ import logging
from fastapi import APIRouter from fastapi import APIRouter
from .routes_design_files import design_files_router from .routes_design_files import design_files_router
from .routes_product_info import product_info_router
from .routes_products import products_router from .routes_products import products_router
from .routes_schedules import schedules_router from .routes_schedules import schedules_router
from .routes_system import system_router from .routes_system import system_router
@@ -33,6 +36,7 @@ logger = logging.getLogger("cafe24.router")
router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router = APIRouter(prefix="/cafe24", tags=["cafe24"])
router.include_router(products_router) router.include_router(products_router)
router.include_router(product_info_router)
router.include_router(schedules_router) router.include_router(schedules_router)
router.include_router(design_files_router) router.include_router(design_files_router)
router.include_router(system_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`). 필터·정렬을 목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을
브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음 브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음
페이지에 있는 해당 상품이 빠진다. 페이지에 있는 해당 상품이 빠진다.
상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을 상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 조각을
돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로 한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로
끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다. 카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크
(`/cafe24/?selected=`)로 동작한다.
쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다. 쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다.
카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT 카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 →
MANUAL 버전 + 감사로그 지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그
로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다. 로컬 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` 만 쓰고 PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
`separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로 `separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로
맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자 맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자
@@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고
from __future__ import annotations from __future__ import annotations
import logging import logging
from datetime import timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
@@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products
from app.timezone import now_kst
from . import store 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") logger = logging.getLogger("cafe24.products")
@@ -84,6 +102,17 @@ def _price(value: Any) -> str:
return f"{won:,}" 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: def _short_dt(value: Any) -> str:
"""'2026-08-14T11:38:18+09:00''2026-08-14 11:38'.""" """'2026-08-14T11:38:18+09:00''2026-08-14 11:38'."""
text = str(value or "").strip() text = str(value or "").strip()
@@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str:
return urlencode(params) 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]: def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]:
"""오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다.""" """편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회."""
api = build_cafe24_api(st) api = build_cafe24_api(st)
product: dict[str, Any] = {} product: dict[str, Any] = {}
desc = None desc = None
error = "" error = ""
scalar_stale = False
html_effective = ""
sync_state = store.SYNC_NONE
last_write: dict[str, Any] | None = None
try: try:
product = products.get_product(api.client, product_no) product, scalar_stale = load_product(st, api, product_no)
desc = products.descriptions_from_product(product) desc = products.descriptions_from_product(product)
st.upsert_products([products.normalize_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: except Cafe24Error as exc:
error = str(exc) error = str(exc)
logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc) logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc)
info = products.normalize_product(product) if product else {}
return { return {
"product_no": product_no, "product_no": product_no,
# 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인) # 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인)
"product_url": api.config.product_url(product_no), "product_url": api.config.product_url(product_no),
"info": { "info": _info(product),
**info,
"price": _price(product.get("price")),
"updated_date": _short_dt(product.get("updated_date")),
"summary_description": product.get("summary_description") or "",
},
"desc": desc, "desc": desc,
# 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고 # 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고
# (2) 태그마다 줄을 나눠 정리해서 보여준다. # (2) 태그마다 줄을 나눠 정리해서 보여준다.
# 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다). # 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다).
"html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "", # 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다.
# 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). "html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "",
"fingerprint": store.fingerprint(desc.description) 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), "revisions": st.list_revisions(product_no, limit=20),
"editor_error": error, "editor_error": error,
"option_display_types": store.OPTION_DISPLAY_LABELS,
} }
@products_router.get("/", response_class=HTMLResponse) @products_router.get("/", response_class=HTMLResponse)
def product_list(request: Request) -> HTMLResponse: def product_list(request: Request) -> HTMLResponse:
"""2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다.""" """3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다."""
from app.main import render_template # noqa: WPS433 from app.main import render_template # noqa: WPS433
checked = guard(request) checked = guard(request)
@@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse:
ctx.update( ctx.update(
{ {
"page_title": "카페24 상품관리", "page_title": "카페24 상품관리",
"page_subtitle": "상품 상세페이지 조회·편집·예약", "page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정",
"rows": rows, "rows": rows,
"total": total, "total": total,
"shown": len(rows), "shown": len(rows),
@@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse:
@products_router.get("/products/{product_no}/pane", response_class=HTMLResponse) @products_router.get("/products/{product_no}/pane", response_class=HTMLResponse)
def product_pane(request: Request, product_no: int) -> 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 from app.main import render_template # noqa: WPS433
checked = guard(request) 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 = base_ctx(request, user, active_tab="products")
ctx.update(_editor_ctx(st, product_no)) ctx.update(_editor_ctx(st, product_no))
ctx["list_query"] = _list_query(request) 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가 거절하므로 미리 막는다. # 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다.
NAME_MAX = 250 NAME_MAX = store.NAME_MAX
@products_router.post("/products/{product_no}/name") @products_router.post("/products/{product_no}/name")
@@ -263,7 +372,7 @@ def product_rename(
상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴 상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴
HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다 HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다
(되돌리려면 로그를 보고 다시 바꾼다). (되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다.
""" """
st, user = require_store(request) st, user = require_store(request)
actor = str(user.get("email") or "") actor = str(user.get("email") or "")
@@ -276,7 +385,7 @@ def product_rename(
api = build_cafe24_api(st) api = build_cafe24_api(st)
try: try:
current = products.get_product(api.client, product_no) current, _ = load_product(st, api, product_no)
except Cafe24Error as exc: except Cafe24Error as exc:
st.log_audit( st.log_audit(
actor=actor, action="rename_product", product_no=product_no, actor=actor, action="rename_product", product_no=product_no,
@@ -298,9 +407,7 @@ def product_rename(
logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc) logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc)
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
info = products.normalize_product(updated) if updated else {} info = remember_write(st, product_no, updated)
if info.get("product_no"):
st.upsert_products([info])
after = str(info.get("product_name") or name) after = str(info.get("product_name") or name)
st.log_audit( st.log_audit(
actor=actor, action="rename_product", product_no=product_no, actor=actor, action="rename_product", product_no=product_no,
@@ -348,14 +455,12 @@ def product_status(
# 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다. # 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다.
if "display" not in updated or "selling" not in updated: if "display" not in updated or "selling" not in updated:
try: try:
updated = products.get_product(api.client, product_no) updated, _ = load_product(st, api, product_no)
except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다. except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다.
logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc) logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc)
updated = {} updated = {}
info = products.normalize_product(updated) if updated else {} info = remember_write(st, product_no, updated)
if info.get("product_no"):
st.upsert_products([info])
state = { state = {
"display": bool(info.get("display", want if field == "display" else True)), "display": bool(info.get("display", want if field == "display" else True)),
"selling": bool(info.get("selling", want if field == "selling" 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}") @products_router.get("/products/{product_no}")
def product_redirect(request: Request, product_no: int): def product_redirect(request: Request, product_no: int):
"""옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" """옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다."""
return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303) return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303)
@@ -387,9 +492,11 @@ def product_apply(
순서를 지키는 것이 이 함수의 핵심이다. 순서를 지키는 것이 이 함수의 핵심이다.
1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다) 1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다)
2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단 2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면
3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다 우리 마지막 쓰기가 현재값이다)
4) 쓰고, MANUAL 버전과 감사로그를 남긴다 3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단
4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다
5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다
PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의 PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의
모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다. 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다.
@@ -423,11 +530,15 @@ def product_apply(
) )
return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) 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( backup_id = st.add_revision(
product_no=product_no, product_no=product_no,
html_content=current.description, html_content=effective,
revision_type=store.REVISION_BACKUP, revision_type=store.REVISION_BACKUP,
memo="적용 직전 자동 백업", memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""),
created_by=actor, created_by=actor,
) )
# 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게 # 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게
@@ -441,7 +552,7 @@ def product_apply(
created_by=actor, 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( st.log_audit(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨", revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨",
@@ -451,13 +562,13 @@ def product_apply(
status_code=303, status_code=303,
) )
if submitted == current.description: if submitted == effective:
return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303)
try: try:
# mobile_description 은 보내지 않는다 — update_descriptions 가 # mobile_description 은 보내지 않는다 — update_descriptions 가
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고 # separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고
# 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 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: except Cafe24Error as exc:
st.log_audit( st.log_audit(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
@@ -469,11 +580,11 @@ def product_apply(
status_code=303, status_code=303,
) )
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰 # PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등).
# 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리 remember_write(st, product_no, updated if isinstance(updated, dict) else {})
# 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다).
products.wait_for_description(api.client, product_no, submitted)
# MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이
# 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다.
revision_id = st.add_revision( revision_id = st.add_revision(
product_no=product_no, product_no=product_no,
html_content=submitted, html_content=submitted,
@@ -481,13 +592,23 @@ def product_apply(
memo=memo, memo=memo,
created_by=actor, created_by=actor,
) )
# 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰
# 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위
# MANUAL revision 을 기준으로 그리므로 문제없다.
confirmed = products.wait_for_description(api.client, product_no, submitted)
st.log_audit( st.log_audit(
actor=actor, action="apply_description", product_no=product_no, actor=actor, action="apply_description", product_no=product_no,
revision_id=revision_id, result="SUCCESS", 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( return RedirectResponse(
url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}",
status_code=303, 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] 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 으로 전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로
같은 조각 다시 받아 끼워 넣는다. 그래서 여기에는 <script> 같은 조각(+ 오른쪽 정보 패널 _side.html)을 다시 받아 끼워 넣는다. 그래서 여기에는 <script>
(innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고 (innerHTML 로 삽입된 script 는 실행되지 않는다 — JS 는 products.html 에 있고
삽입 후 cf24BindEditor() 로 다시 연결한다). #} 삽입 후 cf24BindEditor() 로 다시 연결한다). #}
@@ -11,6 +11,23 @@
</div> </div>
{% endif %} {% 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"> <div class="cf24-editor-head">
{# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 — {# 제목 = 상품명. 연필 버튼을 누르면 입력칸으로 바뀐다(평소에는 읽기 전용 —
클릭만으로 실수로 고쳐지지 않게). 저장은 JS 가 POST /products/{no}/name. 클릭만으로 실수로 고쳐지지 않게). 저장은 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" %} {% extends "erp_base.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/cafe24.css?v=20260819c" /> <link rel="stylesheet" href="/static/cafe24.css?v=20260918a" />
{% endblock %} {% endblock %}
{% block content %} {% block content %}
@@ -18,7 +18,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="cf24-split"> <div class="cf24-split cf24-split-3">
{# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #} {# ── 왼쪽: 상품 목록 ──────────────────────────────────────── #}
<aside class="erp-card cf24-pane cf24-pane-list"> <aside class="erp-card cf24-pane cf24-pane-list">
@@ -83,7 +83,7 @@
</div> </div>
</aside> </aside>
{# ── 오른쪽: 상세페이지 편집 ──────────────────────────────── #} {# ── 가운데: 상세페이지 편집 ──────────────────────────────── #}
<section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane"> <section class="erp-card cf24-pane cf24-pane-editor" id="cf24-editor-pane">
{% if selected %} {% if selected %}
{% include "cafe24/_editor.html" %} {% include "cafe24/_editor.html" %}
@@ -97,6 +97,17 @@
</div> </div>
{% endif %} {% endif %}
</section> </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> </div>
{% endblock %} {% endblock %}
@@ -374,15 +385,15 @@
}); });
}); });
// 「다시 읽기」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로 다시 받는다. // 「다시 읽기」/「다시 확인」 — 카페24 관리자에서 방금 고친 경우 현재 소스를 강제로
var reloadBtn = pane.querySelector("[data-reload]"); // 다시 받는다(도구 막대 버튼 + 반영 대기 배너 버튼, 둘 다 같은 동작).
if (reloadBtn) { pane.querySelectorAll("[data-reload]").forEach(function (reloadBtn) {
reloadBtn.addEventListener("click", function () { reloadBtn.addEventListener("click", function () {
confirmLeave().then(function (ok) { confirmLeave().then(function (ok) {
if (ok) select(reloadBtn.dataset.reload, false); if (ok) select(reloadBtn.dataset.reload, false);
}); });
}); });
} });
// 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는 // 예약 시각 — 네이티브 date/time input 은 투명하게 두고(opacity:0), 보이는
// 글자는 우리가 원하는 형식으로 직접 그린다("2026년 08월 20일" / "오후 07시 30분"). // 글자는 우리가 원하는 형식으로 직접 그린다("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 를 // 공용 확인창(erp-dialog.js)은 비동기다 — 기본 confirm() 과 달리 Promise 를
// 돌려주므로 호출부는 모두 then() 안에서 이어간다. // 돌려주므로 호출부는 모두 then() 안에서 이어간다.
function confirmLeave() { function confirmLeave() {
@@ -654,9 +1168,13 @@
return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?"); return window.erpConfirm("편집한 내용이 저장되지 않았습니다. 이동할까요?");
} }
// ── 목록 클릭 → 오른쪽만 교체 ── // ── 목록 클릭 → 가운데(편집기)·오른쪽(정보 패널)만 교체 ──
// /pane 응답 하나에 두 조각이 담겨 온다([data-pane=editor] / [data-pane=side]).
var sidePane = document.getElementById("cf24-side-pane");
function select(no, push) { function select(no, push) {
pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>'; pane.innerHTML = '<p class="cf24-muted" style="padding:16px;">불러오는 중…</p>';
if (sidePane) sidePane.innerHTML = "";
var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : ""); var url = "/cafe24/products/" + no + "/pane" + (listQuery ? "?" + listQuery : "");
// no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다. // no-store: 캐시된 조각이 뜨면 카페24의 현재 소스가 아닌 예전 것을 편집하게 된다.
fetch(url, { credentials: "same-origin", cache: "no-store" }) fetch(url, { credentials: "same-origin", cache: "no-store" })
@@ -667,9 +1185,16 @@
}) })
.then(function (htmlText) { .then(function (htmlText) {
if (htmlText === null) return; 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.cf24BindEditor();
window.cf24BindSide();
pane.scrollTop = 0; pane.scrollTop = 0;
if (sidePane) sidePane.scrollTop = 0;
if (push) { if (push) {
var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no; var target = "/cafe24/?" + (listQuery ? listQuery + "&" : "") + "selected=" + no;
history.pushState({ no: no }, "", target); history.pushState({ no: no }, "", target);
@@ -746,6 +1271,7 @@
}); });
if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor(); if (pane.querySelector(".cf24-editor-form")) window.cf24BindEditor();
window.cf24BindSide();
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+237
View File
@@ -802,6 +802,10 @@ class _FakeStore:
self.added.append(fields) self.added.append(fields)
return 900 + len(self.added) 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): def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None):
self.finished.append( self.finished.append(
{"id": schedule_id, "status": status, "error": error, {"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 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(): def _run_all():
fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
for fn in fns: 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 은 보내지 않고 # PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고
# separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다. # separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다.
# (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.) # (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.)
products.update_product( updated = products.update_product(
api.client, api.client,
product_no, product_no,
description=html, description=html,
@@ -88,10 +88,26 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str:
display=set_display, display=set_display,
selling=set_selling, 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: if html is not None:
# 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을 # 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을
# 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다). # 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다).
# 확인이 안 돼도 실패가 아니다 — 화면은 마지막 쓰기(SCHEDULED revision)를 기준으로
# 그린다. 여기서 SCHEDULED revision 을 남겨야 그 기준이 생긴다.
products.wait_for_description(api.client, product_no, html) 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( summary = store.describe_schedule_action(
has_html=html is not None, set_display=set_display, set_selling=set_selling 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; flex-shrink: 0;
} }
/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다. /* 분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다.
box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서 box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서
max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */ max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */
.erp-page { .erp-page {
@@ -24,8 +24,8 @@
} }
/* ════════════════════════════════════════════════════════════ /* ════════════════════════════════════════════════════════════
좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게) 좌우 분할: 왼쪽 목록(좁게) | 가운데 상세페이지 편집(넓게) | 오른쪽 정보 패널
각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다. (.cf24-split-3 — 아래 3분할 절). 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다.
════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════ */
.cf24-split { .cf24-split {
display: grid; display: grid;
@@ -134,7 +134,7 @@
.cf24-col-name { word-break: break-word; } .cf24-col-name { word-break: break-word; }
/* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다. /* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다.
전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */ 전체 이름은 title 툴팁과 편집기 제목에서 확인한다. */
.cf24-col-name a { .cf24-col-name a {
color: inherit; color: inherit;
text-decoration: none; text-decoration: none;
@@ -803,3 +803,395 @@
font-family: var(--font-geist-mono, ui-monospace, monospace); font-family: var(--font-geist-mono, ui-monospace, monospace);
font-size: 12px; 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/ ← 상품관리 모듈 app/modules/cafe24/ ← 상품관리 모듈
├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합
├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) ├─ routes_products.py 3분할 화면 · 편집기+정보패널 조각 · 적용(쓰기) · 읽기 지연 보정
├─ routes_product_info.py 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 ·
│ 옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매)
├─ routes_schedules.py 예약 등록·목록·취소 ├─ routes_schedules.py 예약 등록·목록·취소
├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프· ├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프·
│ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유) │ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유)
@@ -36,8 +38,9 @@ app/modules/cafe24/ ← 상품관리 모듈
├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL)
├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증
├─ tests/ DB/네트워크 없는 유닛테스트 ├─ tests/ DB/네트워크 없는 유닛테스트
└─ templates/cafe24/ _nav.html · products.html(2분할) · └─ templates/cafe24/ _nav.html · products.html(3분할 + 모든 JS) ·
_editor.html(오른쪽 조각) · design_editor.html · _editor.html(가운데 조각) · _side.html(오른쪽 정보 패널 조각) ·
_panes.html(두 조각 묶음 — /pane 응답) · design_editor.html ·
schedules.html · system.html schedules.html · system.html
``` ```
@@ -54,12 +57,20 @@ app/modules/cafe24/ ← 상품관리 모듈
| 경로 | 화면 | 권한 | | 경로 | 화면 | 권한 |
| --- | --- | --- | | --- | --- | --- |
| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` | | `GET /cafe24/` | 3분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` |
| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` | | `GET /cafe24/products/{product_no}/pane` | 편집기 + 정보 패널 조각 묶음 (JS 가 가져가 `[data-pane]` 별로 끼움) | `cafe24` |
| `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` | | `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` |
| `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `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}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` |
| `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `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` | | `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` |
| `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` | | `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` |
| `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` | | `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` |
@@ -120,19 +131,29 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
--- ---
### 2-1. 상품관리 화면 구성 (2분할) ### 2-1. 상품관리 화면 구성 (3분할)
``` ```
┌─ 550px ──────────────────────────남은 폭 전부 ─────────┐ ┌─ 320~460px ────────────────── 남은 폭 전부 ────────380px ─────────────┐
│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매 │ 상품명 검색(한 줄) │ [반영 대기 배너 — 지연 시] │ 기본 정보
│ ☑진열중 ☑판매중 (기본 체크) │ 상세설명 HTML 편집기 │ ☑진열중 ☑판매중 │ 상품 이름 · 번호 · 진열/판매 │ 상품명/판매가/공급가
│ ── 목록(전체, 스크롤) ── │ (PC/모바일 공통 · 문법 강조) │ ── 목록(전체) ── 상세설명 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` 가 세로 - 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로
flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다. flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다.
실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것. 실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것.
@@ -148,8 +169,8 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p
이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다. 이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다.
- **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔 - **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔
덕분에 목록 전체가 대상이 된다. 덕분에 목록 전체가 대상이 된다.
- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지 - **상품 클릭 시 편집기·정보 패널만 교체**한다(`/pane` 조각 묶음을 fetch → 삽입). 목록을
않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다. 다시 받지 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다.
- 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」· - 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」·
「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시 「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시
카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은 카페24 기본 도메인(`https://<mall_id>.cafe24.com`)으로 대체한다 — 커스텀 도메인은
@@ -341,12 +362,16 @@ cafe24_oauth_tokens 저장
정리된 소스가 그대로 저장된다). 정리된 소스가 그대로 저장된다).
1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금 1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금
올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다). 올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다).
2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다. 1-1. **읽기 지연 판정으로 유효 현재값을 정한다**(3-3). 카페24가 아직 예전 값을
돌려주는 중이면 우리 마지막 쓰기(MANUAL/SCHEDULED)가 현재값이다.
2. 유효 현재값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다.
3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금 3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금
카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히 유효 현재값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히
덮어쓰는 것을 막는 낙관적 잠금이다. 덮어쓰는 것을 막는 낙관적 잠금이다. (카페24의 지연된 예전 값과 비교하면
4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지). 방금 적용한 것이 "충돌"로 오판된다 — 그래서 유효 현재값과 비교한다.)
5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`). 4. 내용이 유효 현재값과 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지).
5. PUT 적용 → PUT 응답을 **스냅샷**으로 저장 → **MANUAL revision** → 짧은 재조회
확인(결과는 감사로그에만) → 감사로그(`apply_description`).
추가 규칙: 추가 규칙:
@@ -361,12 +386,79 @@ cafe24_oauth_tokens 저장
분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP
revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다). revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다).
- 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다. - 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다.
- **적용 직후 짧게 재확인한다(`products.wait_for_description`).** 카페24 관리자 - 적용 직후 `products.wait_for_description` 으로 0.8초 × 3회 재조회해 보지만, 이것은
API(`GET /admin/products/{no}`)는 PUT 직후 몇 초간 이전 값을 돌려줄 때가 **확인용**일 뿐 화면의 기준이 아니다(3-3). 확인 여부는 감사로그 detail 에 남는다.
있다(쇼핑몰 화면에는 바로 반영됨 — 실물 관찰). 그 상태에서 다른 상품을 봤다가
돌아오면 우리 편집기만 "적용 안 된 것"처럼 보인다. 적용/예약 실행 직후 ---
0.8초 간격으로 최대 3회 재조회해 새 값이 확인될 때까지 기다린 뒤 화면으로
돌아간다(실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다). ## 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 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 가 도는지 확인: worker 가 도는지 확인:
```bash ```bash
@@ -474,6 +574,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노
| 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 | | 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 |
| 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 | | 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 |
| 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) | | 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) |
| 9 | 카페24 읽기 지연 보정("마지막 쓰기가 권위", 3-3) | ✅ 완료 (마이그레이션 004) |
| 10 | 상품 정보 패널 — 상품명/가격 · 대표이미지 · 옵션/품목 (3-4) | ✅ 완료 |
Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile`
`COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다. `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_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_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK |
| `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 | | `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 |
| `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` | | `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` |
@@ -315,6 +315,8 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이
- 예약의 `restore_revision_id`**예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님). - 예약의 `restore_revision_id`**예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님).
- 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다. - 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다.
- 토큰 암호화 키는 `.env``CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.** - 토큰 암호화 키는 `.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회, 사용자 승인 후) ### 운영 서버 초기화 (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, display BOOLEAN NOT NULL DEFAULT TRUE,
selling BOOLEAN NOT NULL DEFAULT TRUE, selling BOOLEAN NOT NULL DEFAULT TRUE,
last_synced_at TIMESTAMPTZ, last_synced_at TIMESTAMPTZ,
-- 쓰기 직후 스냅샷(카페24 읽기 지연 보정, 마이그레이션 004)
last_write_snapshot JSONB,
last_written_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_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_name ON cafe24_products (product_name);
CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code); CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);