From 40766d805da909ef3cfd4eec13cb87077ee989ad Mon Sep 17 00:00:00 2001 From: king Date: Fri, 18 Sep 2026 18:07:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(cafe24):=20=EC=9D=BD=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=97=B0=20=EB=B3=B4=EC=A0=95("=EB=A7=88=EC=A7=80=EB=A7=89=20?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=EA=B0=80=20=EA=B6=8C=EC=9C=84")=20+=20?= =?UTF-8?q?=EC=83=81=ED=92=88=20=EC=A0=95=EB=B3=B4=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 증상: 상세페이지를 적용해도 편집기에 수정 전 소스가 보이고 한참 뒤에야 반영됨. 원인은 우리 캐시가 아니라(전부 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 --- .env.example | 6 + CLAUDE.md | 2 +- app/integrations/cafe24/client.py | 11 +- app/integrations/cafe24/products.py | 144 ++++- app/modules/cafe24/common.py | 13 + app/modules/cafe24/db.py | 124 ++++ app/modules/cafe24/router.py | 4 + app/modules/cafe24/routes_product_info.py | 498 ++++++++++++++++ app/modules/cafe24/routes_products.py | 223 +++++-- app/modules/cafe24/store.py | 349 +++++++++++ .../cafe24/templates/cafe24/_editor.html | 21 +- .../cafe24/templates/cafe24/_panes.html | 6 + .../cafe24/templates/cafe24/_side.html | 96 ++++ .../cafe24/templates/cafe24/products.html | 544 +++++++++++++++++- app/modules/cafe24/tests/test_cafe24.py | 237 ++++++++ app/modules/cafe24/worker.py | 18 +- app/static/cafe24.css | 400 ++++++++++++- docs/CAFE24_MODULE.md | 156 ++++- docs/DATABASES.md | 4 +- scripts/sql/cafe24_db_004_write_snapshot.sql | 19 + scripts/sql/cafe24_db_init.sql | 6 + 21 files changed, 2782 insertions(+), 99 deletions(-) create mode 100644 app/modules/cafe24/routes_product_info.py create mode 100644 app/modules/cafe24/templates/cafe24/_panes.html create mode 100644 app/modules/cafe24/templates/cafe24/_side.html create mode 100644 scripts/sql/cafe24_db_004_write_snapshot.sql diff --git a/.env.example b/.env.example index e151cba..b29bf27 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,12 @@ CUSTOMER_ORDER_LIST_URL=/orderlist/ # 미설정 시 카페24 기본 도메인(https://.cafe24.com)으로 대체된다. # CAFE24_SHOP_URL=https://www.miras.co.kr # +# 카페24 읽기 지연 유예시간(분). 카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전 +# 값을 돌려준다. 이 시간 안에 우리가 쓴 값이 있으면, GET 이 그 이전 값을 돌려줘도 +# 마지막 쓰기(revision·스냅샷)를 화면·적용 기준으로 삼는다. 지나면 카페24 값을 믿는다. +# 미설정 시 360(6시간). +# CAFE24_READ_LAG_GRACE_MIN=360 +# # access/refresh token 을 DB 에 Fernet 암호화해서 저장할 때 쓰는 키. # openssl rand -hex 32 로 생성. ⚠️ 값을 바꾸면 기존 토큰을 복호화할 수 없어 # 카페24 재연결(재인증)이 필요하다. diff --git a/CLAUDE.md b/CLAUDE.md index 2e052f0..04dc4ee 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ Claude Code는 이 저장소에서 작업을 시작하기 전에 **반드시 아 - 휴가 관리 (`app/modules/vacation/`, `vacation_db`) — 월간 달력(구글식 bar)/연차·반차 신청/승인 워크플로/공휴일·연차 설정. 권한키 `vacation`·`vacation_approver` - 말레이시아 창고 재고관리 (`app/modules/malaysia/`, `malaysia_stock_db`) — 낱개(MT/MX/MZ) 입출고·조정, 세트(MY) BOM, 일일 재고조사(세트→낱개 자동 분해), 현재고 현황. 뚜껑(MD-)은 재고 집계 제외 — 단, 창고 랙에는 위치 확인용으로 배치 가능(`store.LID_ITEMS`). 상품은 `itemcode_db` 읽기 전용. 권한키 `malaysia` - 말레이시아 배송 (`app/modules/dispatch/`, `dispatch_db`) — TikTok·Shopee 출고관리. 플랫폼별 데이터 엑셀 업로드(TikTok=03_TikTok_Order_Export.xlsx, Shopee=Packing List.Doorstep Delivery.xlsx) → 1상자=1카드 출고 작업 리스트·SKU 피킹 요약·Kagayaku 전달표 자동 생성. 1상자 묶음 기준 Package ID > Tracking ID > Order ID, 같은 상자 같은 SKU 합산. 작업 상태 토글(`dispatch_logs` 기록). 받는 사람 이름/전화/주소는 상자 단위로 저장(작업 카드 표시 + 출고 엑셀 생성용 — 개인정보). 배치 다운로드 zip 에 업로드 원본 + 취합 출고 엑셀(`YYYY.MM.DD(Ddd)_tictoc|shopee.xlsx`) 포함. 엑셀은 openpyxl 파싱/생성. 권한키 `dispatch`. 상세는 `docs/DISPATCH_MODULE.md` -- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·자동복원·버전 롤백·일괄수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md` +- 카페24 상품관리 (`app/modules/cafe24/`, `cafe24_db`) — 카페24 관리자에 들어가지 않고 상품 상세페이지(description HTML) 조회·편집·즉시적용·예약적용·버전 이력. 3분할 화면(목록 | HTML 편집기 | 상품 정보 패널). 정보 패널(`routes_product_info.py`, `_side.html`)에서 상품명·판매가·공급가·소비자가, 대표이미지(업로드 → `POST /admin/products/images` → PUT `detail_image`+`image_upload_type=A`), 옵션(생성/이름·썸네일·표시방식 수정/삭제)·품목(자체코드·추가금액·진열·판매)을 수정. 카페24 OAuth/API 클라이언트는 향후 주문관리와 공유하기 위해 **공통 계층 `app/integrations/cafe24/`** 에 둔다 — 라우터에서 `httpx`/`requests` 직접 호출 금지. 토큰은 Fernet 암호화 저장(`CAFE24_TOKEN_SECRET`), 로그/화면에 토큰·시크릿 절대 미출력. 쓰기 직전 항상 카페24 현재 HTML 을 다시 읽어 `BACKUP` revision 생성(로컬 값을 현재값으로 가정 금지). **카페24 관리자 API 는 PUT 뒤 한동안 GET 에서 예전 값을 돌려준다(읽기 지연)** — 우리 캐시 문제가 아니다. 그래서 "마지막 쓰기가 권위": 상세설명은 카페24 값이 최근 revision 중 하나와 같으면 지연으로 보고 마지막 MANUAL/SCHEDULED 를 표시·지문 기준으로 쓰고(`store.resolve_description`), 스칼라(상품명·가격·이미지·진열/판매)는 PUT 응답 스냅샷(`cafe24_products.last_write_snapshot`, 마이그레이션 004)과 GET 의 `updated_date` 를 비교해 덮어씌운다(`store.overlay_recent_write`). 유예시간 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 쓰기 후 화면은 PUT 응답으로 그리고 다시 GET 하지 않는다. 예약은 DB 저장 + 별도 worker(`app/modules/cafe24/worker.py`, compose 서비스 `dbx-cafe24-worker`)가 처리 — 웹 프로세스에서 대기하지 않는다. 권한키 `cafe24`(연결/해제는 admin 전용). 상세는 `docs/CAFE24_MODULE.md` - 프로젝트 관리 (`app/modules/project/`, `project_db`) — 아사나식. 프로젝트/서브프로젝트(self-FK `parent_id`, CASCADE)·업무(`tasks`: 담당자·우선순위·시작/마감)·진행단계(`project_stages` 칸반, 생성시 기본 4단계 seed)·멤버 배정(`project_members`)·활동이력(`project_activity`). 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 버튼 토글 + 보드(드래그로 단계 이동)/리스트. 진입 권한키 `project`(관리자 페이지 토글로 직원별 부여, admin 자동). 프로젝트 생성/삭제·사용자 배정은 `is_admin` 만, 배정 멤버(또는 owner)는 서브프로젝트/업무/단계 CRUD. 멤버 배정 후보는 `project` 권한 보유 등록 사용자에서 자동 목록(`GET /project/api/assignable-users`). 업무 배정·완료 시 관리자에게 메일(`app/mail.py` stdlib smtplib, `SMTP_*`+`PROJECT_NOTIFY_EMAIL` env, 미설정 시 조용히 skip, `BackgroundTasks` 비동기). 상세는 `docs/PROJECT_MODULE.md` 상세는 `docs/PROJECT_OVERVIEW.md`. diff --git a/app/integrations/cafe24/client.py b/app/integrations/cafe24/client.py index 1bfc00e..20b0292 100644 --- a/app/integrations/cafe24/client.py +++ b/app/integrations/cafe24/client.py @@ -148,8 +148,12 @@ class Cafe24Client: params: dict[str, Any] | None = None, json: dict[str, Any] | None = None, product_no: int | None = None, + timeout: float | None = None, ) -> dict[str, Any]: - """카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환.""" + """카페24 Admin API 호출. 성공 시 응답 JSON(dict) 반환. + + timeout 은 호출별 초과 지정(이미지 업로드처럼 본문이 큰 요청용). 없으면 기본값. + """ if not self._config.configured: raise Cafe24ConfigError( "카페24 설정이 없습니다. 미설정 항목: " + ", ".join(self._config.missing) @@ -167,7 +171,7 @@ class Cafe24Client: status: int | None = None try: access_token = self._tokens.get_access_token() - with httpx.Client(timeout=self._timeout) as client: + with httpx.Client(timeout=timeout or self._timeout) as client: response = client.request( method, url, @@ -256,3 +260,6 @@ class Cafe24Client: def post(self, path: str, **kwargs: Any) -> dict[str, Any]: return self.request("POST", path, **kwargs) + + def delete(self, path: str, **kwargs: Any) -> dict[str, Any]: + return self.request("DELETE", path, **kwargs) diff --git a/app/integrations/cafe24/products.py b/app/integrations/cafe24/products.py index 6ad1654..845d9ce 100644 --- a/app/integrations/cafe24/products.py +++ b/app/integrations/cafe24/products.py @@ -192,6 +192,11 @@ def build_update_payload( display: bool | None = None, selling: bool | None = None, shop_no: int | None = None, + price: str | None = None, + supply_price: str | None = None, + retail_price: str | None = None, + detail_image: str | None = None, + image_upload_type: str | None = None, ) -> dict[str, Any]: """상품 수정 PUT body. 준 필드만 바뀌고 나머지는 유지된다(부분 수정). @@ -217,6 +222,19 @@ def build_update_payload( request["display"] = _flag_value(display) if selling is not None: request["selling"] = _flag_value(selling) + # 가격은 카페24 예제 형식('11000.00') 문자열 그대로 보낸다. + if price is not None: + request["price"] = price + if supply_price is not None: + request["supply_price"] = supply_price + if retail_price is not None: + request["retail_price"] = retail_price + # 대표 이미지: /admin/products/images 로 먼저 올린 경로를 detail_image 에 넣고 + # image_upload_type="A"(대표이미지등록) 로 목록/작은목록/축소 이미지를 카페24가 + # 리사이징하게 한다. (문서: A 대표이미지등록 / B 개별이미지등록 / C 웹FTP) + if detail_image is not None: + request["detail_image"] = detail_image + request["image_upload_type"] = image_upload_type or "A" payload: dict[str, Any] = {"request": request} if shop_no: payload["shop_no"] = int(shop_no) @@ -234,10 +252,18 @@ def update_product( display: bool | None = None, selling: bool | None = None, shop_no: int | None = None, + price: str | None = None, + supply_price: str | None = None, + retail_price: str | None = None, + detail_image: str | None = None, + image_upload_type: str | None = None, ) -> dict[str, Any]: - """상품 부분 수정. 상세설명·상품명·진열·판매를 한 번의 호출로 바꿀 수 있다. + """상품 부분 수정. 상세설명·상품명·가격·대표이미지·진열·판매를 한 번의 호출로. 바꿀 것이 하나도 없으면 호출하지 않고 빈 dict 를 돌려준다. + 응답의 `product` dict 는 **쓰기 직후의 실제 값**이다 — GET 이 한동안 예전 값을 + 돌려주는 것과 달리 PUT 응답은 즉시 새 값을 담으므로, 호출부는 이것을 스냅샷으로 + 남겨 화면을 맞춘다(`store.product_snapshot`). ⚠️ 상세설명을 바꿀 때는 쓰기 직전 카페24 현재 HTML 을 다시 읽어 BACKUP revision 을 남길 것(`docs/CAFE24_MODULE.md` 규칙). 이 함수는 백업하지 않는다. @@ -250,6 +276,11 @@ def update_product( display=display, selling=selling, shop_no=shop_no, + price=price, + supply_price=supply_price, + retail_price=retail_price, + detail_image=detail_image, + image_upload_type=image_upload_type, ) if not payload["request"]: return {} @@ -283,6 +314,117 @@ def update_descriptions( ) +# ════════════════════════════════════════════════════════════ +# 이미지 업로드 — POST /admin/products/images +# 문서: base64 인코딩 이미지, 1건 10MB, 1호출 30MB, 1회 20장. +# 응답 {"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]} 의 path 를 +# 상품 detail_image / 옵션 option_image_file 등에 그대로 넣는다. +# ════════════════════════════════════════════════════════════ +IMAGE_MAX_BYTES = 10 * 1024 * 1024 +UPLOAD_TIMEOUT = 120.0 + + +def upload_images(client: Cafe24Client, images_b64: list[str]) -> list[str]: + """base64 문자열 목록 → 업로드된 경로 목록(입력 순서 유지).""" + if not images_b64: + return [] + payload = {"requests": [{"image": b64} for b64 in images_b64[:20]]} + response = client.post("/admin/products/images", json=payload, timeout=UPLOAD_TIMEOUT) + images = response.get("images") + if not isinstance(images, list): + return [] + return [str(item.get("path") or "") for item in images if isinstance(item, dict)] + + +def upload_image_bytes(client: Cafe24Client, data: bytes) -> str: + """이미지 1장(바이트) 업로드 → 경로. 비어 있거나 응답이 이상하면 빈 문자열.""" + import base64 # noqa: WPS433 + + if not data: + return "" + paths = upload_images(client, [base64.b64encode(data).decode("ascii")]) + return paths[0] if paths else "" + + +def set_main_image(client: Cafe24Client, product_no: int, image_path: str) -> dict[str, Any]: + """대표이미지 교체 — 목록/작은목록/축소 이미지는 카페24가 리사이징(A 타입).""" + return update_product(client, product_no, detail_image=image_path, image_upload_type="A") + + +# ════════════════════════════════════════════════════════════ +# 옵션 — /admin/products/{no}/options +# GET → {"option": {has_option, option_type, option_list_type, options:[...]}} +# POST → {"request": {has_option:"T", option_type:"T", options:[...]}} (품목 자동 생성) +# PUT → {"request": {original_options:[...], options:[...]}} (이름/값/이미지만 수정) +# DELETE → 옵션 사용안함 + 품목 전부 삭제(주의) +# ════════════════════════════════════════════════════════════ +def get_options(client: Cafe24Client, product_no: int) -> dict[str, Any]: + no = int(product_no) + payload = client.get(f"/admin/products/{no}/options", product_no=no) + option = payload.get("option") + return option if isinstance(option, dict) else {} + + +def create_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]: + no = int(product_no) + payload = client.post( + f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no + ) + option = payload.get("option") + return option if isinstance(option, dict) else payload + + +def update_options(client: Cafe24Client, product_no: int, request: dict[str, Any]) -> dict[str, Any]: + no = int(product_no) + payload = client.put( + f"/admin/products/{no}/options", json={"shop_no": 1, "request": request}, product_no=no + ) + option = payload.get("option") + return option if isinstance(option, dict) else payload + + +def delete_options(client: Cafe24Client, product_no: int) -> dict[str, Any]: + no = int(product_no) + return client.delete(f"/admin/products/{no}/options", product_no=no) + + +# ════════════════════════════════════════════════════════════ +# 품목(variants) — /admin/products/{no}/variants +# GET → {"variants":[{variant_code, options:[{name,value}], custom_variant_code, +# display, selling, additional_amount, quantity, image?…}]} +# PUT (여러 건) → {"shop_no":1, "requests":[{variant_code, display, selling, +# custom_variant_code, additional_amount, …}]} +# ════════════════════════════════════════════════════════════ +def list_variants(client: Cafe24Client, product_no: int) -> list[dict[str, Any]]: + no = int(product_no) + payload = client.get(f"/admin/products/{no}/variants", product_no=no) + variants = payload.get("variants") + return variants if isinstance(variants, list) else [] + + +def update_variants( + client: Cafe24Client, product_no: int, requests: list[dict[str, Any]] +) -> list[dict[str, Any]]: + """여러 품목 부분 수정. 100건씩 나눠 보낸다(문서상 1회 100건 제한).""" + no = int(product_no) + out: list[dict[str, Any]] = [] + for start in range(0, len(requests), 100): + chunk = requests[start : start + 100] + if not chunk: + continue + payload = client.put( + f"/admin/products/{no}/variants", json={"shop_no": 1, "requests": chunk}, product_no=no + ) + result = payload.get("variants") if isinstance(payload, dict) else None + if isinstance(result, list): + out.extend(result) + elif isinstance(result, dict): + out.append(result) + elif isinstance(payload.get("variant"), dict): + out.append(payload["variant"]) + return out + + def normalize_product(raw: dict[str, Any]) -> dict[str, Any]: """카페24 상품 dict → 캐시 테이블(cafe24_products) 컬럼 모양으로 정규화.""" try: diff --git a/app/modules/cafe24/common.py b/app/modules/cafe24/common.py index c5a7b45..ccbaeef 100644 --- a/app/modules/cafe24/common.py +++ b/app/modules/cafe24/common.py @@ -29,6 +29,19 @@ def get_store(request: Request) -> Any: return getattr(request.app.state, "cafe24_store", None) +def read_lag_grace_minutes() -> int: + """카페24 읽기 지연 유예시간(분). `CAFE24_READ_LAG_GRACE_MIN`, 기본 360. + + 이 시간 안에 우리가 쓴 값이 있으면, 카페24 GET 이 예전 값을 돌려줘도 우리 + 마지막 쓰기를 화면·적용 기준으로 삼는다(store.resolve_description 참고). + """ + import os # noqa: WPS433 + + from . import store # noqa: WPS433 + + return store.parse_grace_minutes(os.getenv("CAFE24_READ_LAG_GRACE_MIN")) + + def require_user(request: Request) -> dict[str, Any]: from app.main import get_current_user_record # noqa: WPS433 from app.store import has_module # noqa: WPS433 diff --git a/app/modules/cafe24/db.py b/app/modules/cafe24/db.py index bf3e580..312c028 100644 --- a/app/modules/cafe24/db.py +++ b/app/modules/cafe24/db.py @@ -41,6 +41,18 @@ _TOKEN_FIELDS: tuple[str, ...] = ( ) +def _flag_bool(value: Any, default: bool) -> bool: + """카페24 'T'/'F' 또는 bool → bool.""" + if isinstance(value, bool): + return value + text = str(value or "").strip().upper() + if text in ("T", "TRUE", "1"): + return True + if text in ("F", "FALSE", "0"): + return False + return default + + class TokenLock: """token_lock() 이 넘겨주는 핸들. 잠긴 행 조회 + 같은 트랜잭션 안 저장.""" @@ -263,6 +275,118 @@ class Cafe24Store: ).fetchone() return self._serialize(row) + # ── 쓰기 직후 스냅샷 (읽기 지연 보정용) ── + # 카페24 PUT 응답의 상품 값을 남겨 둔다. GET 이 아직 예전 레코드를 돌려주는 + # 동안(updated_date 가 스냅샷보다 이전) 이 값으로 화면을 덮어씌운다. + # 마이그레이션 004 의 두 컬럼(last_write_snapshot, last_written_at)을 쓴다. + # 스냅샷 JSON 모양: + # {"product": {"data": {...store.SNAPSHOT_FIELDS...}, "written_at": ISO}, + # "options": {"data": {...GET/PUT options 응답...}, "written_at": ISO}, + # "variants": {"data": {variant_code: {...}}, "written_at": ISO}} + # 섹션별로 따로 갱신한다(가격만 바꿨는데 옵션 스냅샷이 사라지면 안 된다). + def save_write_snapshot(self, product_no: int, section: str, data: Any) -> None: + from psycopg.types.json import Jsonb # noqa: WPS433 + + no = int(product_no) + if no <= 0 or section not in ("product", "options", "variants"): + return + with self._pool.connection() as conn: + with conn.transaction(): + row = conn.execute( + "SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s FOR UPDATE", + (no,), + ).fetchone() + current = dict(row["last_write_snapshot"]) if row and row.get("last_write_snapshot") else {} + if section == "variants" and isinstance(data, dict): + # 품목은 코드별로 누적 병합 — 일부만 바꿔도 이전에 바꾼 것을 잃지 않게. + merged = dict((current.get("variants") or {}).get("data") or {}) + merged.update(data) + data = merged + current[section] = { + "data": data, + "written_at": datetime.now(KST).isoformat(timespec="seconds"), + } + product = data if section == "product" and isinstance(data, dict) else {} + conn.execute( + """ + INSERT INTO cafe24_products + (product_no, product_code, product_name, display, selling, + last_synced_at, last_write_snapshot, last_written_at) + VALUES (%s, %s, %s, %s, %s, now(), %s, now()) + ON CONFLICT (product_no) DO UPDATE SET + product_code = COALESCE(NULLIF(EXCLUDED.product_code, ''), cafe24_products.product_code), + product_name = COALESCE(NULLIF(EXCLUDED.product_name, ''), cafe24_products.product_name), + display = CASE WHEN %s THEN EXCLUDED.display ELSE cafe24_products.display END, + selling = CASE WHEN %s THEN EXCLUDED.selling ELSE cafe24_products.selling END, + last_synced_at = now(), + last_write_snapshot = EXCLUDED.last_write_snapshot, + last_written_at = now() + """, + ( + no, + str(product.get("product_code") or ""), + str(product.get("product_name") or ""), + _flag_bool(product.get("display"), True), + _flag_bool(product.get("selling"), True), + Jsonb(current), + bool(product), + bool(product), + ), + ) + + def get_write_snapshot(self, product_no: int) -> dict[str, Any]: + """섹션별 스냅샷 dict. 없으면 {}.""" + with self._pool.connection() as conn: + row = conn.execute( + "SELECT last_write_snapshot FROM cafe24_products WHERE product_no = %s", + (int(product_no),), + ).fetchone() + if not row or not row.get("last_write_snapshot"): + return {} + return dict(row["last_write_snapshot"]) + + # ── 읽기 지연 판정용 revision 조회 ── + def latest_write_revision(self, product_no: int, *, since: datetime) -> dict[str, Any] | None: + """유예시간 안의 가장 최근 '쓰기' revision(MANUAL/SCHEDULED/ROLLBACK) — HTML 포함.""" + with self._pool.connection() as conn: + row = conn.execute( + """ + SELECT id, product_no, revision_type, html_content, memo, created_by, created_at + FROM cafe24_product_revisions + WHERE product_no = %s + AND revision_type = ANY(%s) + AND created_at >= %s + ORDER BY created_at DESC, id DESC + LIMIT 1 + """, + (int(product_no), list(store.WRITE_REVISION_TYPES), since), + ).fetchone() + if not row: + return None + out = dict(row) + at = out.get("created_at") + if isinstance(at, datetime) and at.tzinfo is None: + out["created_at"] = at.replace(tzinfo=KST) + return out + + def revision_digests(self, product_no: int, *, since: datetime) -> set[str]: + """유예시간 안의 revision 들의 내용 해시(store.content_digest 와 같은 md5 hex). + + 내용을 통째로 옮기지 않고 DB 에서 해시만 계산한다(상세페이지는 수 MB 일 수 있다). + md5 를 쓰는 이유: 모든 PostgreSQL 버전에 있고, 여기서는 충돌 저항이 아니라 + "같은 내용인가"만 필요하다. + """ + with self._pool.connection() as conn: + rows = conn.execute( + """ + SELECT md5(html_content) AS digest + FROM cafe24_product_revisions + WHERE product_no = %s AND created_at >= %s + """, + (int(product_no), since), + ).fetchall() + return {str(r["digest"]) for r in rows if r.get("digest")} + # ════════════════════════════════════════════════════════════ # 상세페이지 HTML 버전 (append-only — UPDATE/DELETE 하지 않는다) # 쓰기 직전 BACKUP 을 남기는 것이 유일한 복구 수단이다. diff --git a/app/modules/cafe24/router.py b/app/modules/cafe24/router.py index 6857135..f8bbd03 100644 --- a/app/modules/cafe24/router.py +++ b/app/modules/cafe24/router.py @@ -10,6 +10,8 @@ 라우트가 많아 기능별 파일로 나눈다(다른 모듈의 단일 router.py 패턴을 규모 때문에 확장한 것). 여기서는 루트 라우터를 만들고 서브 라우터를 결합한다. routes_products 상품 목록/검색 · 상세설명 조회·편집·적용 + routes_product_info 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 · + 옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매) routes_schedules 예약 등록·목록·취소 (실행은 worker.py) routes_system 연결(OAuth)·상태·API 로그·작업 로그 routes_design_files 모바일 스와이프·PC/모바일 상품상세 템플릿 편집 — @@ -24,6 +26,7 @@ import logging from fastapi import APIRouter from .routes_design_files import design_files_router +from .routes_product_info import product_info_router from .routes_products import products_router from .routes_schedules import schedules_router from .routes_system import system_router @@ -33,6 +36,7 @@ logger = logging.getLogger("cafe24.router") router = APIRouter(prefix="/cafe24", tags=["cafe24"]) router.include_router(products_router) +router.include_router(product_info_router) router.include_router(schedules_router) router.include_router(design_files_router) router.include_router(system_router) diff --git a/app/modules/cafe24/routes_product_info.py b/app/modules/cafe24/routes_product_info.py new file mode 100644 index 0000000..fe10e9e --- /dev/null +++ b/app/modules/cafe24/routes_product_info.py @@ -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()}} diff --git a/app/modules/cafe24/routes_products.py b/app/modules/cafe24/routes_products.py index c96b738..3be291c 100644 --- a/app/modules/cafe24/routes_products.py +++ b/app/modules/cafe24/routes_products.py @@ -1,22 +1,38 @@ -"""카페24 상품 화면 — 좌우 2분할(목록 | 상세페이지 편집). +"""카페24 상품 화면 — 좌우 3분할(목록 | 상세페이지 편집 | 상품 정보 패널). 화면 구성 - 왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬. - 오른쪽 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게. + 왼쪽 전체 상품 목록. 좁게. 진열/판매 필터(중복 선택) + 제목행 클릭 정렬. + 가운데 선택한 상품의 상세설명 HTML 편집기 + 버전 이력. 넓게. + 오른쪽 상품 정보 패널 — 상품명/판매가/공급가 · 대표 이미지 · 옵션/품목. + (JSON API 는 routes_product_info.py) 목록은 페이지를 넘겨가며 **전체**를 한 번에 받는다(`list_all_products`). 필터·정렬을 브라우저에서 처리하려면 전체가 있어야 정확하다 — 한 페이지만 받아 걸러내면 다음 페이지에 있는 해당 상품이 빠진다. -상품을 클릭하면 오른쪽만 교체한다(`GET /products/{no}/pane` 이 편집기 조각을 -돌려주고 JS 가 끼워 넣는다). 목록을 다시 불러오지 않으므로 카페24 호출이 1회로 -끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크(`/cafe24/?selected=`)로 동작한다. +상품을 클릭하면 가운데·오른쪽만 교체한다(`GET /products/{no}/pane` 이 두 조각을 +한 응답으로 돌려주고 JS 가 각각 끼워 넣는다). 목록을 다시 불러오지 않으므로 +카페24 호출이 1회로 끝난다. JS 가 없거나 실패하면 각 행은 그냥 링크 +(`/cafe24/?selected=`)로 동작한다. 쓰기(`POST /products/{no}/apply`)는 반드시 이 순서를 지킨다. - 카페24 현재값 재조회 → BACKUP 버전 저장 → 지문 대조(충돌 거부) → PUT → - MANUAL 버전 + 감사로그 + 카페24 현재값 재조회 → 읽기 지연 판정(유효 현재값) → BACKUP 버전 저장 → + 지문 대조(충돌 거부) → PUT → 스냅샷 + MANUAL 버전 + 감사로그 로컬 DB 의 마지막 버전을 "지금 카페24에 올라간 값"으로 가정하지 않는다. +── 카페24 읽기 지연(read-after-write lag) ── +카페24 관리자 API 는 PUT 직후 한동안 GET 에서 **예전 값**을 돌려준다(실물 관찰, +몇 초에서 훨씬 길게). 우리 쪽 캐시 문제가 아니다(모든 응답 no-store). 그 값을 그대로 +믿으면 "적용했는데 예전 소스가 보이고" 그 예전 값으로 지문을 만들어 다음 적용 때 +충돌로 오판한다. 그래서 **마지막 쓰기가 권위**다: + - 상세설명: 카페24 값이 우리가 최근(유예시간 안)에 남긴 revision 중 하나와 같으면 + "아직 예전 값" → 마지막 쓰기(MANUAL/SCHEDULED)를 보여주고 그 지문을 쓴다. + 우리가 모르는 값이면 관리자에서 직접 고친 것 → 카페24 값을 믿는다. + (`store.resolve_description`, `_resolve_description`) + - 상품명/가격/이미지/진열/판매: PUT 응답을 스냅샷으로 남기고, GET 의 updated_date + 가 스냅샷보다 이전이면 스냅샷으로 덮어씌운다(`store.overlay_recent_write`). + 유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값. + PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고 `separated_mobile_description="F"` 를 강제해 카페24가 모바일 값을 PC 와 자동으로 맞추게 한다(운영 방침). `mobile_description` 필드를 직접 보내면 카페24 관리자 @@ -34,6 +50,7 @@ PC/모바일은 구분하지 않는다 — 적용 시 `description` 만 쓰고 from __future__ import annotations import logging +from datetime import timedelta from decimal import Decimal from typing import Any from urllib.parse import urlencode @@ -42,9 +59,10 @@ from fastapi import APIRouter, Body, Form, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.integrations.cafe24 import Cafe24Error, build_cafe24_api, products +from app.timezone import now_kst from . import store -from .common import base_ctx, guard, require_store +from .common import base_ctx, guard, read_lag_grace_minutes, require_store logger = logging.getLogger("cafe24.products") @@ -84,6 +102,17 @@ def _price(value: Any) -> str: return f"{won:,}원" +def _price_plain(value: Any) -> str: + """'6900.00' → '6900' (입력칸 초기값용).""" + text = str(value or "").strip() + if not text: + return "" + try: + return str(int(Decimal(text))) + except (ArithmeticError, ValueError): + return text + + def _short_dt(value: Any) -> str: """'2026-08-14T11:38:18+09:00' → '2026-08-14 11:38'.""" text = str(value or "").strip() @@ -134,46 +163,122 @@ def _list_query(request: Request, *, selected: int | None = None) -> str: return urlencode(params) +# ════════════════════════════════════════════════════════════ +# 읽기 지연 보정 — 상품 dict 와 상세설명에 각각 적용한다. +# 다른 라우트(routes_product_info)도 같은 함수를 쓴다. +# ════════════════════════════════════════════════════════════ +def load_product(st: Any, api: Any, product_no: int) -> tuple[dict[str, Any], bool]: + """카페24 GET + 최근 쓰기 스냅샷 덮어씌우기. (상품 dict, 스칼라 지연 여부). + + 지연 판정은 카페24의 updated_date 끼리 비교한다 — GET 이 PUT 응답보다 이전 + 레코드를 돌려주면 스냅샷 값(상품명·가격·이미지·진열/판매)을 쓴다. + """ + product = products.get_product(api.client, product_no) + if not product: + return product, False + section = (st.get_write_snapshot(product_no) or {}).get("product") or {} + merged, stale = store.overlay_recent_write( + product, + snapshot=section.get("data"), + written_at=section.get("written_at"), + grace_minutes=read_lag_grace_minutes(), + ) + return merged, stale + + +def resolve_description( + st: Any, product_no: int, cafe24_html: str +) -> tuple[str, str, dict[str, Any] | None]: + """(유효 HTML, 상태, 마지막 쓰기 revision). 상태는 store.SYNC_*.""" + grace = read_lag_grace_minutes() + since = now_kst() - timedelta(minutes=grace) + last_write = st.latest_write_revision(product_no, since=since) + if not last_write: + return cafe24_html, store.SYNC_NONE, None + digests = st.revision_digests(product_no, since=since) + effective, state = store.resolve_description( + cafe24_html, last_write=last_write, known_digests=digests, grace_minutes=grace + ) + return effective, state, last_write + + +def remember_write(st: Any, product_no: int, updated: dict[str, Any]) -> dict[str, Any]: + """PUT 응답(상품 dict)을 캐시·스냅샷에 남긴다. 정규화된 캐시 행을 돌려준다.""" + info = products.normalize_product(updated) if updated else {} + if info.get("product_no"): + st.upsert_products([info]) + st.save_write_snapshot(product_no, "product", store.product_snapshot(updated)) + return info + + +def _info(product: dict[str, Any]) -> dict[str, Any]: + """편집기·정보 패널 공용 상품 요약.""" + info = products.normalize_product(product) if product else {} + return { + **info, + "price": _price(product.get("price")), + "price_plain": _price_plain(product.get("price")), + "supply_price": _price(product.get("supply_price")), + "supply_price_plain": _price_plain(product.get("supply_price")), + "retail_price": _price(product.get("retail_price")), + "retail_price_plain": _price_plain(product.get("retail_price")), + "updated_date": _short_dt(product.get("updated_date")), + "summary_description": product.get("summary_description") or "", + "detail_image": str(product.get("detail_image") or ""), + "list_image": str(product.get("list_image") or ""), + "tiny_image": str(product.get("tiny_image") or ""), + "small_image": str(product.get("small_image") or ""), + } + + def _editor_ctx(st: Any, product_no: int) -> dict[str, Any]: - """오른쪽 편집기 조각에 필요한 컨텍스트. 전체 페이지와 조각이 함께 쓴다.""" + """편집기(가운데)·정보 패널(오른쪽) 조각에 필요한 컨텍스트. 카페24 호출 1회.""" api = build_cafe24_api(st) product: dict[str, Any] = {} desc = None error = "" + scalar_stale = False + html_effective = "" + sync_state = store.SYNC_NONE + last_write: dict[str, Any] | None = None try: - product = products.get_product(api.client, product_no) + product, scalar_stale = load_product(st, api, product_no) desc = products.descriptions_from_product(product) st.upsert_products([products.normalize_product(product)]) + html_effective, sync_state, last_write = resolve_description(st, product_no, desc.description) except Cafe24Error as exc: error = str(exc) logger.warning("카페24 상품 %s 조회 실패: %s", product_no, exc) - info = products.normalize_product(product) if product else {} return { "product_no": product_no, # 고객이 보는 상세페이지 주소 (CAFE24_SHOP_URL, 없으면 카페24 기본 도메인) "product_url": api.config.product_url(product_no), - "info": { - **info, - "price": _price(product.get("price")), - "updated_date": _short_dt(product.get("updated_date")), - "summary_description": product.get("summary_description") or "", - }, + "info": _info(product), "desc": desc, # 편집기에는 (1) 이미지 경로의 %EC%9A%A9… 을 한글로 풀고 # (2) 태그마다 줄을 나눠 정리해서 보여준다. # 저장할 때 같은 정리를 거친 값을 카페24에 쓴다(화면과 저장값이 같다). - "html_pc": store.format_html(store.decode_html_urls(desc.description)) if desc else "", - # 지문은 **인코딩된 원본**으로 만든다(적용 직전 카페24 값과 비교하므로). - "fingerprint": store.fingerprint(desc.description) if desc else "", + # 값은 카페24 GET 그대로가 아니라 읽기 지연을 보정한 **유효 현재값**이다. + "html_pc": store.format_html(store.decode_html_urls(html_effective)) if desc else "", + # 지문은 **인코딩된 유효 현재값**으로 만든다(적용 직전 같은 규칙으로 계산한 + # 유효 현재값과 비교하므로). + "fingerprint": store.fingerprint(html_effective) if desc else "", + "sync_state": sync_state, + "sync_pending": sync_state == store.SYNC_PENDING, + "sync_external": sync_state == store.SYNC_EXTERNAL, + "scalar_stale": scalar_stale, + "last_write_at": _short_dt(last_write.get("created_at").isoformat() if last_write and last_write.get("created_at") else ""), + "last_write_by": (last_write or {}).get("created_by") or "", "revisions": st.list_revisions(product_no, limit=20), "editor_error": error, + "option_display_types": store.OPTION_DISPLAY_LABELS, } @products_router.get("/", response_class=HTMLResponse) def product_list(request: Request) -> HTMLResponse: - """2분할 화면. `selected` 가 있으면 오른쪽 편집기까지 서버에서 그린다.""" + """3분할 화면. `selected` 가 있으면 편집기·정보 패널까지 서버에서 그린다.""" from app.main import render_template # noqa: WPS433 checked = guard(request) @@ -213,7 +318,7 @@ def product_list(request: Request) -> HTMLResponse: ctx.update( { "page_title": "카페24 상품관리", - "page_subtitle": "상품 상세페이지 조회·편집·예약", + "page_subtitle": "상품 상세페이지 조회·편집·예약 · 상품 정보 수정", "rows": rows, "total": total, "shown": len(rows), @@ -235,7 +340,11 @@ def product_list(request: Request) -> HTMLResponse: @products_router.get("/products/{product_no}/pane", response_class=HTMLResponse) def product_pane(request: Request, product_no: int) -> HTMLResponse: - """오른쪽 편집기 조각만 — 목록을 다시 그리지 않기 위해 JS 가 가져간다.""" + """편집기 + 정보 패널 조각 — 목록을 다시 그리지 않기 위해 JS 가 가져간다. + + 두 조각을 한 응답에 담는다(`_panes.html`). 카페24 상품 조회를 한 번만 하기 + 위해서다. JS 가 `[data-pane=editor]` / `[data-pane=side]` 로 나눠 끼운다. + """ from app.main import render_template # noqa: WPS433 checked = guard(request) @@ -246,11 +355,11 @@ def product_pane(request: Request, product_no: int) -> HTMLResponse: ctx = base_ctx(request, user, active_tab="products") ctx.update(_editor_ctx(st, product_no)) ctx["list_query"] = _list_query(request) - return _no_store(render_template(request, "cafe24/_editor.html", ctx)) + return _no_store(render_template(request, "cafe24/_panes.html", ctx)) # 카페24 상품명 최대 길이(API 문서 기준). 넘기면 카페24가 거절하므로 미리 막는다. -NAME_MAX = 250 +NAME_MAX = store.NAME_MAX @products_router.post("/products/{product_no}/name") @@ -263,7 +372,7 @@ def product_rename( 상세설명과 마찬가지로 **쓰기 전에 카페24의 현재값을 읽는다.** 여기서는 되돌릴 HTML 이 없으므로 revision 은 만들지 않고, 대신 이전 이름을 감사로그에 남긴다 - (되돌리려면 로그를 보고 다시 바꾼다). + (되돌리려면 로그를 보고 다시 바꾼다). 현재값은 읽기 지연을 보정한 값이다. """ st, user = require_store(request) actor = str(user.get("email") or "") @@ -276,7 +385,7 @@ def product_rename( api = build_cafe24_api(st) try: - current = products.get_product(api.client, product_no) + current, _ = load_product(st, api, product_no) except Cafe24Error as exc: st.log_audit( actor=actor, action="rename_product", product_no=product_no, @@ -298,9 +407,7 @@ def product_rename( logger.warning("카페24 상품 %s 이름 변경 실패: %s", product_no, exc) raise HTTPException(status_code=502, detail=str(exc)) from exc - info = products.normalize_product(updated) if updated else {} - if info.get("product_no"): - st.upsert_products([info]) + info = remember_write(st, product_no, updated) after = str(info.get("product_name") or name) st.log_audit( actor=actor, action="rename_product", product_no=product_no, @@ -348,14 +455,12 @@ def product_status( # 응답이 상품 dict 면 그것이 곧 현재 상태다. 모양이 다르면(방어) 다시 조회한다. if "display" not in updated or "selling" not in updated: try: - updated = products.get_product(api.client, product_no) + updated, _ = load_product(st, api, product_no) except Cafe24Error as exc: # 쓰기는 됐다 — 화면만 요청값으로 맞춘다. logger.warning("카페24 상품 %s 상태 재조회 실패: %s", product_no, exc) updated = {} - info = products.normalize_product(updated) if updated else {} - if info.get("product_no"): - st.upsert_products([info]) + info = remember_write(st, product_no, updated) state = { "display": bool(info.get("display", want if field == "display" else True)), "selling": bool(info.get("selling", want if field == "selling" else True)), @@ -370,7 +475,7 @@ def product_status( @products_router.get("/products/{product_no}") def product_redirect(request: Request, product_no: int): - """옛 단독 화면 주소 → 2분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" + """옛 단독 화면 주소 → 3분할 화면에서 해당 상품을 선택한 상태로 보낸다.""" return RedirectResponse(url=f"/cafe24/?selected={product_no}", status_code=303) @@ -387,9 +492,11 @@ def product_apply( 순서를 지키는 것이 이 함수의 핵심이다. 1) 카페24에서 **현재** HTML 을 다시 읽는다(로컬 값을 현재값으로 믿지 않는다) - 2) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단 - 3) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다 - 4) 쓰고, MANUAL 버전과 감사로그를 남긴다 + 2) 읽기 지연을 판정해 **유효 현재값**을 정한다(예전 값을 돌려주는 중이면 + 우리 마지막 쓰기가 현재값이다) + 3) 그 값으로 BACKUP 버전을 남긴다 ← 유일한 복구 수단 + 4) 편집 시작 시점의 지문과 비교해 충돌이면 거부한다 + 5) 쓰고, 스냅샷·MANUAL 버전·감사로그를 남긴다 PC/모바일을 구분하지 않는다 — 두 필드에 같은 HTML 을 쓴다. 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 그 내용도 BACKUP 으로 남긴다. @@ -423,11 +530,15 @@ def product_apply( ) return RedirectResponse(url=f"{back}&err=카페24 현재값을 읽지 못해 중단했습니다: {exc}", status_code=303) + # 읽기 지연 판정 — 카페24가 아직 예전 값을 돌려주면 우리 마지막 쓰기가 현재값이다. + effective, sync_state, _ = resolve_description(st, product_no, current.description) + pending = sync_state == store.SYNC_PENDING + backup_id = st.add_revision( product_no=product_no, - html_content=current.description, + html_content=effective, revision_type=store.REVISION_BACKUP, - memo="적용 직전 자동 백업", + memo="적용 직전 자동 백업" + (" (카페24 읽기 지연 — 마지막 적용값 기준)" if pending else ""), created_by=actor, ) # 모바일 내용이 PC 와 달랐다면 그것도 따로 남긴다. 아래에서 모바일을 PC 와 같게 @@ -441,7 +552,7 @@ def product_apply( created_by=actor, ) - if base_fingerprint and base_fingerprint != store.fingerprint(current.description): + if base_fingerprint and base_fingerprint != store.fingerprint(effective): st.log_audit( actor=actor, action="apply_description", product_no=product_no, revision_id=backup_id, result="FAIL", detail="충돌 — 편집 중 카페24 값이 변경됨", @@ -451,13 +562,13 @@ def product_apply( status_code=303, ) - if submitted == current.description: + if submitted == effective: return RedirectResponse(url=f"{back}&msg=변경된 내용이 없어 적용하지 않았습니다.", status_code=303) try: # mobile_description 은 보내지 않는다 — update_descriptions 가 # separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제하고 # 카페24가 모바일 값을 자동으로 맞춰준다(모바일도 항상 PC와 같다). - products.update_descriptions(api.client, product_no, description=submitted) + updated = products.update_descriptions(api.client, product_no, description=submitted) except Cafe24Error as exc: st.log_audit( actor=actor, action="apply_description", product_no=product_no, @@ -469,11 +580,11 @@ def product_apply( status_code=303, ) - # 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰 - # 화면에는 바로 반영됨). 여기서 짧게 확인해, 화면으로 돌아갔을 때 우리 - # 편집기에도 이미 새 값이 보이게 한다(실패해도 적용 자체는 이미 끝났다). - products.wait_for_description(api.client, product_no, submitted) + # PUT 응답은 쓰기 직후의 실제 값 — 스냅샷으로 남긴다(상품명·가격·updated_date 등). + remember_write(st, product_no, updated if isinstance(updated, dict) else {}) + # MANUAL revision 을 **먼저** 남긴다. 이것이 "마지막 쓰기" 기준이 되어, 카페24 GET 이 + # 한동안 예전 값을 돌려줘도 편집기는 방금 적용한 내용을 보여준다. revision_id = st.add_revision( product_no=product_no, html_content=submitted, @@ -481,13 +592,23 @@ def product_apply( memo=memo, created_by=actor, ) + + # 카페24 관리자 API 는 쓰기 직후 몇 초간 이전 값을 돌려줄 때가 있다(쇼핑몰 + # 화면에는 바로 반영됨). 여기서 짧게 확인만 한다 — 확인이 안 돼도 화면은 위 + # MANUAL revision 을 기준으로 그리므로 문제없다. + confirmed = products.wait_for_description(api.client, product_no, submitted) + st.log_audit( actor=actor, action="apply_description", product_no=product_no, revision_id=revision_id, result="SUCCESS", - detail=f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지)", + detail=( + f"{len(submitted)}자 적용 (백업 {backup_id}, 모바일 PC와 동일 유지, " + f"카페24 재조회 {'확인' if confirmed else '지연 — 마지막 적용값 표시'})" + ), ) - logger.info("카페24 상품 %s 상세설명 적용 (%s)", product_no, actor) + logger.info("카페24 상품 %s 상세설명 적용 (%s, 재조회 확인=%s)", product_no, actor, confirmed) + note = "" if confirmed else " 카페24 관리자 API 반영은 잠시 늦을 수 있어 방금 적용한 내용을 표시합니다." return RedirectResponse( - url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.", + url=f"{back}&msg=카페24에 적용했습니다. 직전 내용은 버전 {backup_id} 로 보관됩니다.{note}", status_code=303, ) diff --git a/app/modules/cafe24/store.py b/app/modules/cafe24/store.py index 9f5e8d8..59f711c 100644 --- a/app/modules/cafe24/store.py +++ b/app/modules/cafe24/store.py @@ -439,3 +439,352 @@ def fingerprint(html: str) -> str: 순간 그 변경이 조용히 사라진다. 그것을 막기 위한 낙관적 잠금이다. """ return hashlib.sha256((html or "").encode("utf-8")).hexdigest()[:32] + + +# ════════════════════════════════════════════════════════════ +# 카페24 읽기 지연(read-after-write lag) 보정 +# +# 실물 관찰: PUT 이 성공하고 쇼핑몰 화면에는 바로 반영되는데도, 관리자 API +# (`GET /admin/products/{no}`)는 한동안 **직전 값**을 돌려준다. 몇 초로 끝날 때도 +# 있고 훨씬 길 때도 있다. 우리 쪽에는 캐시가 없으므로(no-store) 그 값을 그대로 +# 보여주면 "적용했는데 예전 소스가 보이는" 증상이 된다. 더 나쁜 것은 그 예전 값으로 +# 지문을 만들어 다음 적용 때 충돌로 오판하거나, 예전 값을 백업으로 남기는 것이다. +# +# 규칙: **마지막 쓰기가 권위다.** +# - 카페24 값이 마지막 쓰기와 같다 → synced (따라잡음) +# - 카페24 값이 우리가 아는 *과거 값* 중 하나 → pending (읽기 지연 — 마지막 쓰기를 보여준다) +# (최근 유예시간 안의 BACKUP/MANUAL/... revision 해시와 대조) +# - 카페24 값이 우리가 모르는 값 → external (관리자에서 직접 고침 — 카페24 값을 믿는다) +# - 최근 쓰기가 없다 → none (카페24 값 그대로) +# 유예시간(grace)이 지나면 무조건 카페24 값을 믿는다 — 지연은 영원하지 않고, +# 우리가 영원히 로컬 값을 고집하면 그것이 또 다른 캐시가 된다. +# ════════════════════════════════════════════════════════════ +SYNC_SYNCED = "synced" +SYNC_PENDING = "pending" +SYNC_EXTERNAL = "external" +SYNC_NONE = "none" + +# 쓰기 종류 — 이 revision 들은 "우리가 카페24에 올린 값"이다. +WRITE_REVISION_TYPES: tuple[str, ...] = (REVISION_MANUAL, REVISION_SCHEDULED, REVISION_ROLLBACK) + +# 읽기 지연 유예시간 기본값(분). 환경변수 CAFE24_READ_LAG_GRACE_MIN 로 조정. +DEFAULT_READ_LAG_GRACE_MIN = 360 + + +def parse_grace_minutes(value: object, default: int = DEFAULT_READ_LAG_GRACE_MIN) -> int: + try: + minutes = int(str(value or "").strip()) + except (TypeError, ValueError): + return default + return minutes if minutes > 0 else default + + +def _as_aware(value: object) -> datetime | None: + """ISO 문자열/naive datetime → KST aware datetime. 못 읽으면 None.""" + if isinstance(value, datetime): + return value if value.tzinfo else value.replace(tzinfo=KST) + text = str(value or "").strip() + if not text: + return None + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=KST) + + +def within_grace(written_at: object, *, grace_minutes: int, now: datetime | None = None) -> bool: + """쓰기 시각이 유예시간 안인지.""" + at = _as_aware(written_at) + if at is None: + return False + current = now or datetime.now(KST) + return current - at <= timedelta(minutes=max(1, int(grace_minutes))) + + +def resolve_description( + cafe24_html: str, + *, + last_write: dict | None, + known_digests: set[str] | frozenset[str], + grace_minutes: int, + now: datetime | None = None, +) -> tuple[str, str]: + """(화면·적용에 쓸 유효 HTML, 상태) 를 돌려준다. + + last_write: 최근 쓰기 revision — {"html_content", "created_at"}. + known_digests: 유예시간 안의 revision 들의 내용 해시(`content_digest()` 결과) 집합. + """ + if not last_write: + return cafe24_html, SYNC_NONE + if not within_grace(last_write.get("created_at"), grace_minutes=grace_minutes, now=now): + return cafe24_html, SYNC_NONE + + last_html = str(last_write.get("html_content") or "") + if cafe24_html == last_html: + return cafe24_html, SYNC_SYNCED + if content_digest(cafe24_html) in known_digests: + return last_html, SYNC_PENDING + return cafe24_html, SYNC_EXTERNAL + + +def content_digest(html: str) -> str: + """revision 내용 대조용 md5 hex — DB 의 `md5(html_content)` 와 같은 값. + + (`fingerprint` 는 낙관적 잠금용 sha256 이고, 이것은 "우리가 아는 값인가" 대조용이다. + md5 는 모든 PostgreSQL 에 내장돼 있어 DB 쪽에서 계산할 수 있다.) + """ + return hashlib.md5((html or "").encode("utf-8")).hexdigest() # noqa: S324 — 보안 목적 아님 + + +# 쓰기 직후 스냅샷에 보관하고, 지연 판정 시 덮어씌우는 스칼라 필드. +# description 은 넣지 않는다(크기 — revision 이 담당). +SNAPSHOT_FIELDS: tuple[str, ...] = ( + "product_name", + "product_code", + "price", + "supply_price", + "retail_price", + "display", + "selling", + "detail_image", + "list_image", + "tiny_image", + "small_image", + "updated_date", + "separated_mobile_description", +) + + +def product_snapshot(product: dict) -> dict: + """PUT 응답(상품 dict) → 스냅샷(JSONB 저장용). 값은 문자열/숫자/불리언만 남긴다.""" + out: dict = {} + for key in SNAPSHOT_FIELDS: + if key in product and product[key] is not None: + value = product[key] + out[key] = value if isinstance(value, (bool, int, float)) else str(value) + return out + + +def overlay_recent_write( + fetched: dict, + *, + snapshot: dict | None, + written_at: object, + grace_minutes: int, + now: datetime | None = None, +) -> tuple[dict, bool]: + """카페24 GET 결과가 우리 마지막 쓰기보다 오래됐으면 스냅샷 값을 덮어씌운다. + + 판정 근거는 카페24 자신의 `updated_date` 다 — PUT 응답의 updated_date(스냅샷) + 보다 GET 의 updated_date 가 **이전**이면 GET 이 아직 예전 레코드를 돌려주는 + 것이다. 우리 서버 시계와 비교하지 않으므로 시계 차이에 영향받지 않는다. + 날짜가 없어 판정할 수 없으면 카페24 값을 그대로 둔다. + 반환: (병합된 상품 dict, 지연 여부) + """ + if not snapshot: + return fetched, False + if not within_grace(written_at, grace_minutes=grace_minutes, now=now): + return fetched, False + fetched_at = _as_aware(fetched.get("updated_date")) + snap_at = _as_aware(snapshot.get("updated_date")) + if fetched_at is None or snap_at is None or fetched_at >= snap_at: + return fetched, False + merged = dict(fetched) + for key in SNAPSHOT_FIELDS: + if key in snapshot and key != "updated_date": + merged[key] = snapshot[key] + merged["updated_date"] = snapshot.get("updated_date") or fetched.get("updated_date") + return merged, True + + +# ════════════════════════════════════════════════════════════ +# 기본 정보(상품명·가격) 입력 검증 — 오른쪽 정보 패널 +# ════════════════════════════════════════════════════════════ +NAME_MAX = 250 +PRICE_MAX = 2_147_483_647 + + +def parse_price(value: object, *, field: str = "가격") -> str | None: + """'6,900' / '6900.00' / 6900 → '6900.00'. 빈 값이면 None(변경 안 함).""" + if value is None: + return None + text = str(value).strip().replace(",", "").replace("원", "") + if not text: + return None + try: + number = int(round(float(text))) + except ValueError: + raise ValueError(f"{field}은(는) 숫자여야 합니다.") from None + if number < 0 or number > PRICE_MAX: + raise ValueError(f"{field}은(는) 0 이상 {PRICE_MAX:,} 이하여야 합니다.") + return f"{number}.00" + + +def price_equal(a: object, b: object) -> bool: + """카페24 '6900.00' 과 우리 '6900.00'/'6900' 을 같은 값으로 본다.""" + + def norm(v: object) -> str | None: + try: + return parse_price(v) + except ValueError: + return str(v) + + return norm(a) == norm(b) + + +# ════════════════════════════════════════════════════════════ +# 옵션/품목 payload — 순수 변환(검증)만. 전송은 integrations.products 가 한다. +# ════════════════════════════════════════════════════════════ +OPTION_DISPLAY_TYPES: tuple[str, ...] = ("S", "P", "B", "R") +OPTION_DISPLAY_LABELS: dict[str, str] = { + "S": "셀렉트박스", + "P": "미리보기(이미지)", + "B": "텍스트버튼", + "R": "라디오버튼", +} +VARIANT_CODE_RE = re.compile(r"^[A-Z0-9]{12}$") +CUSTOM_CODE_MAX = 40 +ADDITIONAL_AMOUNT_MAX = 2_147_483_647 + + +def parse_option_values(raw: object) -> list[str]: + """'빨강, 파랑\\n노랑' → ['빨강','파랑','노랑'] (중복·빈 값 제거, 순서 유지).""" + text = str(raw or "") + seen: list[str] = [] + for part in re.split(r"[,\n]", text): + value = part.strip() + if value and value not in seen: + seen.append(value) + return seen + + +def build_create_options_request( + option_name: str, values: list[str], *, display_type: str = "S" +) -> dict: + name = (option_name or "").strip() + if not name: + raise ValueError("옵션명을 입력하세요.") + if not values: + raise ValueError("옵션값을 하나 이상 입력하세요.") + dtype = (display_type or "S").strip().upper() + if dtype not in OPTION_DISPLAY_TYPES: + dtype = "S" + return { + "has_option": "T", + "option_type": "T", # 조합형 + "option_list_type": "S", # 조합 분리선택형 + "options": [ + { + "option_name": name, + "option_value": [{"option_text": v} for v in values], + "option_display_type": dtype, + } + ], + } + + +def build_update_options_request( + original: list[dict], edited: list[dict], *, option_list_type: str = "" +) -> dict: + """옵션명/옵션값(이름·이미지·색상·표시방식) 수정 요청. + + 카페24 PUT options 는 `original_options`(수정 전) 와 `options`(수정 후) 를 + **같은 순서·같은 개수**로 받아 짝을 맞춘다. 옵션 항목 추가/삭제는 이 API 로 + 할 수 없으므로 개수가 다르면 거부한다. + original: GET 응답의 options 그대로. edited: 화면에서 보낸 같은 모양의 목록. + """ + if len(original) != len(edited): + raise ValueError("옵션 개수가 카페24와 다릅니다. 다시 읽은 뒤 수정하세요.") + orig_out: list[dict] = [] + new_out: list[dict] = [] + for o, e in zip(original, edited): + o_vals = list(o.get("option_value") or []) + e_vals = list(e.get("option_value") or []) + if len(o_vals) != len(e_vals): + raise ValueError( + f"옵션 「{o.get('option_name')}」 의 옵션값 개수가 카페24와 다릅니다. " + "옵션값 추가/삭제는 이 API 로 할 수 없습니다." + ) + name = str(e.get("option_name") or "").strip() + if not name: + raise ValueError("옵션명은 비울 수 없습니다.") + orig_entry: dict = { + "option_name": str(o.get("option_name") or ""), + "option_value": [], + } + new_entry: dict = {"option_name": name, "option_value": []} + if o.get("option_code"): + orig_entry["option_code"] = o["option_code"] + new_entry["option_code"] = o["option_code"] + dtype = str( + e.get("option_display_type") or o.get("option_display_type") or "" + ).strip().upper() + if dtype in OPTION_DISPLAY_TYPES: + new_entry["option_display_type"] = dtype + for ov, ev in zip(o_vals, e_vals): + text = str(ev.get("option_text") or "").strip() + if not text: + raise ValueError("옵션값 이름은 비울 수 없습니다.") + o_item: dict = {"option_text": str(ov.get("option_text") or "")} + n_item: dict = {"option_text": text} + if ov.get("value_no") is not None: + o_item["value_no"] = ov["value_no"] + n_item["value_no"] = ov["value_no"] + for key in ("option_image_file", "option_link_image", "option_color"): + value = ev.get(key) + if value is None: + continue + value = str(value).strip() + if value: + n_item[key] = value + orig_entry["option_value"].append(o_item) + new_entry["option_value"].append(n_item) + orig_out.append(orig_entry) + new_out.append(new_entry) + request: dict = {"original_options": orig_out, "options": new_out} + list_type = (option_list_type or "").strip().upper() + if list_type in ("S", "C"): + request["option_list_type"] = list_type + return request + + +def parse_additional_amount(value: object) -> str | None: + """'1,000' / '-500' → '1000.00'. 빈 값은 None(변경 안 함).""" + if value is None: + return None + text = str(value).strip().replace(",", "").replace("원", "") + if not text: + return None + try: + number = int(round(float(text))) + except ValueError: + raise ValueError("추가금액은 숫자여야 합니다.") from None + if abs(number) > ADDITIONAL_AMOUNT_MAX: + raise ValueError("추가금액 범위를 벗어났습니다.") + return f"{number}.00" + + +def build_variant_updates(rows: list[dict]) -> list[dict]: + """화면 행 목록 → PUT /variants `requests` 배열. 바뀔 것이 없는 행은 뺀다.""" + out: list[dict] = [] + for row in rows: + code = str(row.get("variant_code") or "").strip().upper() + if not VARIANT_CODE_RE.match(code): + raise ValueError(f"품목코드 형식이 올바르지 않습니다: {code or '(빈 값)'}") + item: dict = {"variant_code": code} + if row.get("custom_variant_code") is not None: + custom = str(row["custom_variant_code"]).strip() + if len(custom) > CUSTOM_CODE_MAX: + raise ValueError(f"자체 품목코드는 {CUSTOM_CODE_MAX}자를 넘을 수 없습니다.") + item["custom_variant_code"] = custom + if "additional_amount" in row: + amount = parse_additional_amount(row["additional_amount"]) + if amount is not None: + item["additional_amount"] = amount + for flag in ("display", "selling"): + if row.get(flag) is not None: + item[flag] = "T" if parse_tristate(row[flag]) else "F" + if len(item) > 1: + out.append(item) + return out diff --git a/app/modules/cafe24/templates/cafe24/_editor.html b/app/modules/cafe24/templates/cafe24/_editor.html index 8a6fd40..199cf03 100644 --- a/app/modules/cafe24/templates/cafe24/_editor.html +++ b/app/modules/cafe24/templates/cafe24/_editor.html @@ -1,6 +1,6 @@ -{# 오른쪽 편집기 조각. +{# 가운데 편집기 조각. 전체 페이지(products.html)가 include 하고, JS 가 /products/{no}/pane 으로 - 같은 조각만 다시 받아 끼워 넣는다. 그래서 여기에는 {% endblock %} diff --git a/app/modules/cafe24/tests/test_cafe24.py b/app/modules/cafe24/tests/test_cafe24.py index bf57505..ff0a7d9 100644 --- a/app/modules/cafe24/tests/test_cafe24.py +++ b/app/modules/cafe24/tests/test_cafe24.py @@ -802,6 +802,10 @@ class _FakeStore: self.added.append(fields) return 900 + len(self.added) + def save_write_snapshot(self, product_no, section, data): + self.snapshots = getattr(self, "snapshots", []) + self.snapshots.append((product_no, section, data)) + def finish_schedule(self, schedule_id, *, status, error="", next_retry_at=None, retry_count=None): self.finished.append( {"id": schedule_id, "status": status, "error": error, @@ -904,6 +908,239 @@ def test_worker_stops_when_nothing_due(): assert worker.process_once(st, _FakeApi(_WorkerClient())) == 0 +# ════════════════════════════════════════════════════════════ +# 카페24 읽기 지연 보정 — 마지막 쓰기가 권위 +# ════════════════════════════════════════════════════════════ +def test_resolve_description_no_recent_write_uses_cafe24(): + html, state = store.resolve_description("cafe", last_write=None, known_digests=set(), grace_minutes=60) + assert (html, state) == ("cafe", store.SYNC_NONE) + + +def _write(html, minutes_ago=1): + return {"html_content": html, "created_at": now_kst() - timedelta(minutes=minutes_ago)} + + +def test_resolve_description_synced_when_cafe24_caught_up(): + html, state = store.resolve_description( + "NEW", last_write=_write("NEW"), known_digests={store.content_digest("OLD")}, grace_minutes=60 + ) + assert (html, state) == ("NEW", store.SYNC_SYNCED) + + +def test_resolve_description_pending_when_cafe24_returns_known_old_value(): + # 카페24가 아직 적용 직전 값(BACKUP 으로 남긴 값)을 돌려준다 → 마지막 쓰기를 보여준다 + digests = {store.content_digest("OLD"), store.content_digest("NEW")} + html, state = store.resolve_description("OLD", last_write=_write("NEW"), known_digests=digests, grace_minutes=60) + assert (html, state) == ("NEW", store.SYNC_PENDING) + + +def test_resolve_description_pending_for_older_known_value_too(): + # 연속 두 번 적용(A→B→C) 뒤 카페24가 A 를 돌려줘도 '아는 값'이므로 지연으로 본다 + digests = {store.content_digest(v) for v in ("A", "B", "C")} + html, state = store.resolve_description("A", last_write=_write("C"), known_digests=digests, grace_minutes=60) + assert (html, state) == ("C", store.SYNC_PENDING) + + +def test_resolve_description_external_when_unknown_value(): + # 관리자에서 직접 고친 값 — 카페24를 믿는다 + digests = {store.content_digest("OLD"), store.content_digest("NEW")} + html, state = store.resolve_description("HAND", last_write=_write("NEW"), known_digests=digests, grace_minutes=60) + assert (html, state) == ("HAND", store.SYNC_EXTERNAL) + + +def test_resolve_description_grace_expired_trusts_cafe24(): + digests = {store.content_digest("OLD"), store.content_digest("NEW")} + html, state = store.resolve_description( + "OLD", last_write=_write("NEW", minutes_ago=500), known_digests=digests, grace_minutes=60 + ) + assert (html, state) == ("OLD", store.SYNC_NONE) + + +def test_resolve_description_accepts_iso_created_at(): + lw = {"html_content": "NEW", "created_at": (now_kst() - timedelta(minutes=2)).isoformat()} + html, state = store.resolve_description("OLD", last_write=lw, known_digests={store.content_digest("OLD")}, grace_minutes=60) + assert (html, state) == ("NEW", store.SYNC_PENDING) + + +def test_parse_grace_minutes(): + assert store.parse_grace_minutes("30") == 30 + assert store.parse_grace_minutes("") == store.DEFAULT_READ_LAG_GRACE_MIN + assert store.parse_grace_minutes("-5") == store.DEFAULT_READ_LAG_GRACE_MIN + assert store.parse_grace_minutes("abc") == store.DEFAULT_READ_LAG_GRACE_MIN + + +def test_overlay_recent_write_uses_snapshot_when_get_is_older(): + fetched = {"product_name": "OLD", "price": "1000.00", "updated_date": "2026-09-18T10:00:00+09:00", + "description": "keep"} + snap = {"product_name": "NEW", "price": "2000.00", "updated_date": "2026-09-18T10:05:00+09:00"} + merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) + assert stale is True + assert merged["product_name"] == "NEW" and merged["price"] == "2000.00" + assert merged["description"] == "keep" # 스냅샷에 없는 필드는 그대로 + assert merged["updated_date"] == snap["updated_date"] + + +def test_overlay_recent_write_keeps_cafe24_when_caught_up(): + fetched = {"product_name": "NEWER", "updated_date": "2026-09-18T10:06:00+09:00"} + snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"} + merged, stale = store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) + assert stale is False and merged is fetched + + +def test_overlay_recent_write_skips_without_dates_or_snapshot(): + fetched = {"product_name": "X"} + assert store.overlay_recent_write(fetched, snapshot=None, written_at=now_kst(), grace_minutes=60) == (fetched, False) + snap = {"product_name": "NEW"} + assert store.overlay_recent_write(fetched, snapshot=snap, written_at=now_kst(), grace_minutes=60) == (fetched, False) + + +def test_overlay_recent_write_respects_grace(): + fetched = {"product_name": "OLD", "updated_date": "2026-09-18T10:00:00+09:00"} + snap = {"product_name": "NEW", "updated_date": "2026-09-18T10:05:00+09:00"} + old = (now_kst() - timedelta(hours=10)).isoformat() + assert store.overlay_recent_write(fetched, snapshot=snap, written_at=old, grace_minutes=60) == (fetched, False) + + +def test_product_snapshot_keeps_only_scalar_fields(): + snap = store.product_snapshot({"product_name": "A", "price": "1.00", "description": "", "updated_date": None, "display": "T"}) + assert snap == {"product_name": "A", "price": "1.00", "display": "T"} + + +# ════════════════════════════════════════════════════════════ +# 기본 정보 · 옵션 · 품목 입력 검증 +# ════════════════════════════════════════════════════════════ +def test_parse_price(): + assert store.parse_price("6,900") == "6900.00" + assert store.parse_price("6900.00") == "6900.00" + assert store.parse_price(6900) == "6900.00" + assert store.parse_price("") is None + assert store.parse_price(None) is None + for bad in ("abc", "-1"): + try: + store.parse_price(bad) + except ValueError: + pass + else: + raise AssertionError(bad) + assert store.price_equal("6900.00", "6900") and not store.price_equal("6900.00", "7000") + + +def test_update_payload_price_and_image(): + payload = products.build_update_payload(price="1000.00", supply_price="500.00", detail_image="/web/x.jpg") + assert payload["request"] == { + "price": "1000.00", "supply_price": "500.00", "detail_image": "/web/x.jpg", "image_upload_type": "A", + } + + +def test_parse_option_values(): + assert store.parse_option_values("빨강, 파랑\n노랑,, 빨강 ") == ["빨강", "파랑", "노랑"] + + +def test_build_create_options_request(): + body = store.build_create_options_request("색상", ["빨강", "파랑"], display_type="p") + assert body["has_option"] == "T" and body["option_type"] == "T" + assert body["options"][0]["option_display_type"] == "P" + assert [v["option_text"] for v in body["options"][0]["option_value"]] == ["빨강", "파랑"] + for name, values in (("", ["a"]), ("x", [])): + try: + store.build_create_options_request(name, values) + except ValueError: + pass + else: + raise AssertionError((name, values)) + + +def test_build_update_options_request_pairs_original_and_new(): + original = [{"option_code": "O1", "option_name": "Color", "option_display_type": "S", + "option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}] + edited = [{"option_name": "Colors", "option_display_type": "P", + "option_value": [{"option_text": "Jet Black", "option_image_file": "https://d/x.png"}, + {"option_text": "Deep Red"}]}] + body = store.build_update_options_request(original, edited, option_list_type="S") + assert body["option_list_type"] == "S" + assert body["original_options"] == [{"option_code": "O1", "option_name": "Color", + "option_value": [{"option_text": "Black", "value_no": 1}, {"option_text": "Red", "value_no": 2}]}] + assert body["options"][0]["option_name"] == "Colors" + assert body["options"][0]["option_display_type"] == "P" + assert body["options"][0]["option_value"][0] == {"option_text": "Jet Black", "value_no": 1, "option_image_file": "https://d/x.png"} + assert body["options"][0]["option_value"][1] == {"option_text": "Deep Red", "value_no": 2} + + +def test_build_update_options_request_rejects_count_mismatch(): + original = [{"option_name": "A", "option_value": [{"option_text": "1"}]}] + for edited in ([], [{"option_name": "A", "option_value": []}], [{"option_name": "", "option_value": [{"option_text": "1"}]}]): + try: + store.build_update_options_request(original, edited) + except ValueError: + pass + else: + raise AssertionError(edited) + + +def test_build_variant_updates(): + rows = [ + {"variant_code": "p000000r000a", "custom_variant_code": " ABC ", "additional_amount": "1,000", "display": "on"}, + {"variant_code": "P000000R000B"}, # 바뀐 것 없음 → 제외 + {"variant_code": "P000000R000C", "selling": "off", "additional_amount": ""}, + ] + out = store.build_variant_updates(rows) + assert out == [ + {"variant_code": "P000000R000A", "custom_variant_code": "ABC", "additional_amount": "1000.00", "display": "T"}, + {"variant_code": "P000000R000C", "selling": "F"}, + ] + try: + store.build_variant_updates([{"variant_code": "bad"}]) + except ValueError: + pass + else: + raise AssertionError("bad code accepted") + + +class _RouteClient: + """경로별 응답을 돌려주는 가짜 클라이언트 (get/put/post/delete).""" + + def __init__(self, routes): + self.routes = routes + self.calls: list[tuple] = [] + + def _call(self, method, path, json=None, **_kw): + self.calls.append((method, path, json)) + return self.routes.get(f"{method} {path}", {}) + + def get(self, path, **kw): + return self._call("GET", path, **kw) + + def put(self, path, **kw): + return self._call("PUT", path, **kw) + + def post(self, path, **kw): + return self._call("POST", path, **kw) + + def delete(self, path, **kw): + return self._call("DELETE", path, **kw) + + +def test_upload_images_and_variants_wrappers(): + client = _RouteClient({ + "POST /admin/products/images": {"images": [{"path": "https://d/a.png"}, {"path": "https://d/b.png"}]}, + "PUT /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A", "display": "F"}]}, + "GET /admin/products/7/variants": {"variants": [{"variant_code": "P000000R000A"}]}, + "GET /admin/products/7/options": {"option": {"has_option": "T", "options": []}}, + "DELETE /admin/products/7/options": {"option": {"product_no": 7}}, + }) + assert products.upload_images(client, ["AAA", "BBB"]) == ["https://d/a.png", "https://d/b.png"] + assert products.upload_image_bytes(client, b"\x89PNG") == "https://d/a.png" + assert products.list_variants(client, 7) == [{"variant_code": "P000000R000A"}] + assert products.get_options(client, 7)["has_option"] == "T" + assert products.update_variants(client, 7, [{"variant_code": "P000000R000A", "display": "F"}]) == [ + {"variant_code": "P000000R000A", "display": "F"} + ] + sent = [c for c in client.calls if c[0] == "PUT"][-1] + assert sent[2]["requests"][0]["variant_code"] == "P000000R000A" + products.delete_options(client, 7) + assert client.calls[-1][0] == "DELETE" + + def _run_all(): fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] for fn in fns: diff --git a/app/modules/cafe24/worker.py b/app/modules/cafe24/worker.py index d4b8927..6c04fed 100644 --- a/app/modules/cafe24/worker.py +++ b/app/modules/cafe24/worker.py @@ -80,7 +80,7 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str: # PC/모바일은 구분하지 않는다 — mobile_description 은 보내지 않고 # separated_mobile_description="F" 로 "PC 상세설명과 동일"을 강제한다. # (mobile_description 을 직접 보내면 카페24가 그 설정을 "직접 등록"으로 바꿔버린다.) - products.update_product( + updated = products.update_product( api.client, product_no, description=html, @@ -88,10 +88,26 @@ def _apply(store_db: Any, api: Any, row: dict[str, Any]) -> str: display=set_display, selling=set_selling, ) + # PUT 응답은 쓰기 직후의 실제 값이다 — 스냅샷으로 남겨 화면이 GET 의 읽기 지연에 + # 흔들리지 않게 한다(routes_products._load_product 가 덮어씌운다). + if isinstance(updated, dict) and updated.get("product_no"): + try: + store_db.save_write_snapshot(product_no, "product", store.product_snapshot(updated)) + except Exception: # noqa: BLE001 — 스냅샷 실패가 예약 결과를 바꾸면 안 된다. + logger.exception("예약 #%s 상품 %s 스냅샷 저장 실패", schedule_id, product_no) if html is not None: # 화면 편집(apply)과 동일 — 카페24 관리자 API 의 쓰기 직후 읽기 지연을 # 여기서 짧게 흡수한다(쇼핑몰에는 바로 반영되지만 관리자 조회만 뒤쳐질 때가 있다). + # 확인이 안 돼도 실패가 아니다 — 화면은 마지막 쓰기(SCHEDULED revision)를 기준으로 + # 그린다. 여기서 SCHEDULED revision 을 남겨야 그 기준이 생긴다. products.wait_for_description(api.client, product_no, html) + store_db.add_revision( + product_no=product_no, + html_content=html, + revision_type=store.REVISION_SCHEDULED, + memo=f"예약 #{schedule_id} 적용", + created_by=ACTOR, + ) summary = store.describe_schedule_action( has_html=html is not None, set_display=set_display, set_selling=set_selling diff --git a/app/static/cafe24.css b/app/static/cafe24.css index bf9d55d..8212a48 100644 --- a/app/static/cafe24.css +++ b/app/static/cafe24.css @@ -11,7 +11,7 @@ flex-shrink: 0; } -/* 2분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다. +/* 분할 화면은 편집 영역을 최대한 넓게 쓴다 — 이 페이지에서만 폭 제한을 푼다. box-sizing 을 함께 바꿔야 한다: .erp-page 는 width:100% + padding:24px 라서 max-width 를 풀면 padding 이 폭에 더해져 문서 전체에 가로 스크롤이 생긴다. */ .erp-page { @@ -24,8 +24,8 @@ } /* ════════════════════════════════════════════════════════════ - 좌우 2분할: 왼쪽 목록(좁게) | 오른쪽 상세페이지 편집(넓게) - 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다. + 좌우 분할: 왼쪽 목록(좁게) | 가운데 상세페이지 편집(넓게) | 오른쪽 정보 패널 + (.cf24-split-3 — 아래 3분할 절). 각 칸이 따로 스크롤되고, 전체 높이는 화면에 맞춘다. ════════════════════════════════════════════════════════════ */ .cf24-split { display: grid; @@ -134,7 +134,7 @@ .cf24-col-name { word-break: break-word; } /* 긴 상품명은 2줄까지만 — 행 높이를 고르게 유지해 목록을 훑기 쉽게 한다. - 전체 이름은 title 툴팁과 오른쪽 편집기 제목에서 확인한다. */ + 전체 이름은 title 툴팁과 편집기 제목에서 확인한다. */ .cf24-col-name a { color: inherit; text-decoration: none; @@ -803,3 +803,395 @@ font-family: var(--font-geist-mono, ui-monospace, monospace); font-size: 12px; } + +/* ════════════════════════════════════════════════════════════ + 3분할 — 목록 | 편집기 | 상품 정보 패널 + 왼쪽 목록은 좁게, 가운데 편집기가 남는 폭을, 오른쪽 패널은 고정폭. + ════════════════════════════════════════════════════════════ */ +.cf24-split.cf24-split-3 { + /* 목록은 320~460px 사이에서 줄어들고, 편집기가 남는 폭을, 패널은 380px 고정. + 1920px: 460 + 380 → 편집기 약 1000px. 1400px: 320 + 380 → 편집기 약 640px. */ + grid-template-columns: minmax(320px, 460px) minmax(0, 1fr) 380px; +} + +@media (max-width: 1360px) { + /* 폭이 모자라면 정보 패널을 편집기 아래로 내린다(목록은 그대로 왼쪽). + 아래 칸은 높이를 제한해 편집기가 너무 눌리지 않게 한다(패널 안에서 스크롤). */ + .cf24-split.cf24-split-3 { + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); + grid-template-areas: "list editor" "list side"; + grid-template-rows: minmax(0, 1fr) auto; + } + .cf24-split-3 > .cf24-pane-list { grid-area: list; } + .cf24-split-3 > .cf24-pane-editor { grid-area: editor; } + .cf24-split-3 > .cf24-pane-side { grid-area: side; max-height: 38vh; } +} + +@media (max-width: 1100px) { + .cf24-split.cf24-split-3 { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: none; + } + .cf24-split-3 > .cf24-pane-list, + .cf24-split-3 > .cf24-pane-editor, + .cf24-split-3 > .cf24-pane-side { grid-area: auto; max-height: none; } +} + +.cf24-pane-side { + display: flex; + flex-direction: column; + gap: var(--sp-12, 12px); + padding: var(--sp-12, 12px); +} + +/* 읽기 지연 안내 배너 */ +.cf24-flash-warn { + background: #fff8e6; + border: 1px solid #f0b429; +} + +.cf24-btn-inline { + margin-left: var(--sp-8, 8px); + padding: 2px 8px; + font-size: 12px; +} + +/* ── 정보 패널 공통 ── */ +.cf24-side { + display: flex; + flex-direction: column; + gap: var(--sp-12, 12px); +} + +.cf24-side-sec { + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-lg, 10px); + padding: var(--sp-10, 10px) var(--sp-12, 12px); + background: var(--color-canvas-white, #fff); +} + +.cf24-side-title { + margin: 0 0 var(--sp-8, 8px); + font-size: var(--text-body, 14px); + font-weight: 600; + letter-spacing: -0.3px; +} + +.cf24-side-details > summary.cf24-side-title { + cursor: pointer; + margin-bottom: 0; +} + +.cf24-side-details[open] > summary.cf24-side-title { + margin-bottom: var(--sp-8, 8px); +} + +.cf24-side-note { + margin: 0 0 var(--sp-8, 8px); + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); +} + +.cf24-side-note-warn { + color: #9a6700; +} + +.cf24-side-form { + display: flex; + flex-direction: column; + gap: var(--sp-8, 8px); +} + +.cf24-side-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); + min-width: 0; +} + +.cf24-side-field input[type="text"], +.cf24-side-field select { + height: 32px; + box-sizing: border-box; + width: 100%; + padding: 0 var(--sp-8, 8px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-md, 6px); + font-size: var(--text-body, 14px); + color: var(--color-rich-black, #0a0a0a); + background: var(--color-canvas-white, #fff); +} + +.cf24-side-field input:focus, +.cf24-side-field select:focus { + outline: none; + border-color: var(--color-rich-black, #0a0a0a); +} + +/* 값이 바뀐 입력칸은 테두리로 표시 — 무엇이 저장될지 보이게 */ +.cf24-side input.is-dirty, +.cf24-side select.is-dirty { + border-color: #f0b429; + background: #fffbea; +} + +.cf24-side-grid3 { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--sp-8, 8px); +} + +.cf24-side-actions { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--sp-8, 8px); +} + +.cf24-side-actions .cf24-muted { + min-width: 0; + flex: 1 1 auto; +} + +.cf24-side-actions .is-ok { color: var(--color-success-green, #10c22b); } +.cf24-side-actions .is-err { color: var(--color-callout-red, #c22b10); } + +/* ── 대표 이미지 ── */ +.cf24-image-box { + display: flex; + align-items: center; + justify-content: center; + min-height: 160px; + border: 1px dashed var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-md, 6px); + background: var(--color-ghost-gray, #f6f8fa); + margin-bottom: var(--sp-8, 8px); + overflow: hidden; +} + +.cf24-image-main { + display: block; + max-width: 100%; + max-height: 260px; + object-fit: contain; +} + +.cf24-image-empty { + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); + padding: var(--sp-16, 16px); +} + +.cf24-image-thumbs { + display: flex; + gap: var(--sp-8, 8px); + margin-bottom: var(--sp-8, 8px); +} + +.cf24-image-thumb { + margin: 0; + flex: 1 1 0; + min-width: 0; + text-align: center; + font-size: 11px; + color: var(--color-midtone-gray, #737373); +} + +.cf24-image-thumb img { + display: block; + width: 100%; + height: 64px; + object-fit: contain; + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-sm, 4px); + background: #fff; +} + +.cf24-image-thumb img.is-hidden, +.cf24-side .is-hidden { + display: none; +} + +.cf24-file-pick { + display: flex; + align-items: center; + gap: var(--sp-8, 8px); + cursor: pointer; + min-width: 0; +} + +.cf24-file-pick input[type="file"] { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + overflow: hidden; +} + +.cf24-file-name { + font-size: var(--text-caption, 12px); + color: var(--color-midtone-gray, #737373); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +/* ── 옵션 / 품목 ── */ +.cf24-options-body { + display: flex; + flex-direction: column; + gap: var(--sp-10, 10px); +} + +.cf24-opt-group { + border-top: 1px solid var(--color-subtle-ash, #e5e5e5); + padding-top: var(--sp-8, 8px); +} + +.cf24-opt-head { + display: flex; + gap: var(--sp-8, 8px); + align-items: flex-end; + margin-bottom: var(--sp-8, 8px); +} + +.cf24-opt-head .cf24-side-field { flex: 1 1 auto; } +.cf24-opt-head .cf24-side-field-type { flex: 0 0 140px; } + +.cf24-opt-value { + display: grid; + grid-template-columns: 44px minmax(0, 1fr) auto; + gap: var(--sp-8, 8px); + align-items: center; + padding: 4px 0; +} + +.cf24-opt-thumb { + width: 44px; + height: 44px; + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-sm, 4px); + background: var(--color-ghost-gray, #f6f8fa); + object-fit: cover; + display: block; +} + +.cf24-opt-thumb-empty { + width: 44px; + height: 44px; + border: 1px dashed var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-sm, 4px); + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: var(--color-midtone-gray, #737373); +} + +.cf24-opt-value input[type="text"] { + height: 30px; + box-sizing: border-box; + width: 100%; + padding: 0 var(--sp-8, 8px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-md, 6px); + font-size: 13px; +} + +.cf24-opt-value .cf24-file-pick .erp-btn { + padding: 3px 8px; + font-size: 12px; +} + +/* 품목 표 — 좁은 칸에 맞춘 작은 글자 */ +.cf24-variants { + width: 100%; + border-collapse: collapse; + font-size: 12px; + table-layout: fixed; +} + +.cf24-variants th, +.cf24-variants td { + padding: 4px 4px; + border-bottom: 1px solid var(--color-subtle-ash, #e5e5e5); + vertical-align: middle; + text-align: left; +} + +.cf24-variants th { + font-weight: 500; + color: var(--color-midtone-gray, #737373); + white-space: nowrap; +} + +.cf24-variants td.cf24-v-name { + word-break: break-word; +} + +.cf24-variants input[type="text"] { + height: 28px; + box-sizing: border-box; + width: 100%; + padding: 0 6px; + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-sm, 4px); + font-size: 12px; +} + +.cf24-variants .cf24-v-amount input { text-align: right; } +/* "미진열"(3글자)이 한 줄에 들어가는 폭. 실측: 44px 에서는 두 줄로 꺾였다. */ +.cf24-variants .cf24-v-flag { text-align: center; width: 54px; } +.cf24-variants .cf24-v-code { width: 92px; } +.cf24-variants .cf24-v-amount { width: 76px; } + +/* 진열/판매 토글 — 저장 전까지는 화면만 바뀐다(배지와 같은 색 규칙) */ +.cf24-v-toggle { + border: 1px solid transparent; + border-radius: 999px; + padding: 2px 6px; + font-size: 11px; + white-space: nowrap; + cursor: pointer; + background: var(--color-ghost-gray, #f2f2f2); + color: var(--color-rich-black, #0a0a0a); +} + +.cf24-v-toggle.is-on { + background: var(--color-success-green, #10c22b); + color: #fff; +} + +.cf24-v-toggle.is-dirty { + box-shadow: 0 0 0 2px #f0b429; +} + +.cf24-variants-wrap { + max-height: 320px; + overflow: auto; +} + +.cf24-opt-danger { + display: flex; + justify-content: flex-end; + padding-top: var(--sp-8, 8px); +} + +.cf24-opt-danger .erp-btn { + color: var(--color-callout-red, #c22b10); + border-color: var(--color-callout-red, #c22b10); +} + +.cf24-opt-create textarea { + width: 100%; + box-sizing: border-box; + min-height: 64px; + padding: var(--sp-8, 8px); + border: 1px solid var(--color-subtle-ash, #e5e5e5); + border-radius: var(--r-md, 6px); + font-size: 13px; + resize: vertical; +} diff --git a/docs/CAFE24_MODULE.md b/docs/CAFE24_MODULE.md index f056d8e..ee15cdf 100644 --- a/docs/CAFE24_MODULE.md +++ b/docs/CAFE24_MODULE.md @@ -26,7 +26,9 @@ app/integrations/cafe24/ ← 공통 (상품관리 + 향후 주문관리 app/modules/cafe24/ ← 상품관리 모듈 ├─ router.py 루트 라우터(prefix=/cafe24) + 서브 라우터 결합 -├─ routes_products.py 2분할 화면 · 편집기 조각 · 적용(쓰기) +├─ routes_products.py 3분할 화면 · 편집기+정보패널 조각 · 적용(쓰기) · 읽기 지연 보정 +├─ routes_product_info.py 오른쪽 정보 패널 JSON API — 상품명/가격 · 대표이미지 · +│ 옵션(생성/수정/삭제) · 품목(자체코드/추가금액/진열/판매) ├─ routes_schedules.py 예약 등록·목록·취소 ├─ routes_design_files.py 디자인 보관함 파일 편집·적용(FTP) — 모바일 스와이프· │ PC/모바일 상품상세 템플릿(file_key 로 하나의 라우트 공유) @@ -36,8 +38,9 @@ app/modules/cafe24/ ← 상품관리 모듈 ├─ db.py Cafe24Store (cafe24_db, psycopg3 raw SQL) ├─ store.py 순수 로직 — 상수·상태전이·재시도 규칙·검증 ├─ tests/ DB/네트워크 없는 유닛테스트 -└─ templates/cafe24/ _nav.html · products.html(2분할) · - _editor.html(오른쪽 조각) · design_editor.html · +└─ templates/cafe24/ _nav.html · products.html(3분할 + 모든 JS) · + _editor.html(가운데 조각) · _side.html(오른쪽 정보 패널 조각) · + _panes.html(두 조각 묶음 — /pane 응답) · design_editor.html · schedules.html · system.html ``` @@ -54,12 +57,20 @@ app/modules/cafe24/ ← 상품관리 모듈 | 경로 | 화면 | 권한 | | --- | --- | --- | -| `GET /cafe24/` | 2분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` | -| `GET /cafe24/products/{product_no}/pane` | 오른쪽 편집기 조각 (JS 가 가져감) | `cafe24` | +| `GET /cafe24/` | 3분할 화면 (`q`, `display`, `selling`, `selected`) | `cafe24` | +| `GET /cafe24/products/{product_no}/pane` | 편집기 + 정보 패널 조각 묶음 (JS 가 가져가 `[data-pane]` 별로 끼움) | `cafe24` | | `GET /cafe24/products/{product_no}` | → `/cafe24/?selected=` 로 리다이렉트(옛 주소) | `cafe24` | | `POST /cafe24/products/{product_no}/apply` | 편집한 HTML 을 카페24에 즉시 적용 | `cafe24` | | `POST /cafe24/products/{product_no}/status` | 진열/판매 토글 (JSON: `{field, value}` → 적용 후 상태) | `cafe24` | | `POST /cafe24/products/{product_no}/name` | 상품명 변경 (JSON: `{name}` → 적용된 이름). 이전 이름은 감사로그 `rename_product` | `cafe24` | +| `POST /cafe24/products/{product_no}/basic` | 상품명·판매가·공급가·소비자가 (JSON, 바뀐 것만 PUT). 감사로그 `update_basic` | `cafe24` | +| `POST /cafe24/products/{product_no}/image` | 대표 이미지 교체 (multipart `file`) — 업로드 후 PUT `detail_image` + `image_upload_type=A`. 감사로그 `set_main_image` | `cafe24` | +| `GET /cafe24/products/{product_no}/options` | 옵션 + 품목 조회 (JSON, 읽기 지연 보정 포함) | `cafe24` | +| `POST /cafe24/products/{product_no}/options` | 옵션 생성 (`{option_name, values, display_type}`) — 조합형, 품목 자동 생성 | `cafe24` | +| `PUT /cafe24/products/{product_no}/options` | 옵션명·옵션값 이름/썸네일/표시방식 수정 (`{options:[...], option_list_type}`) | `cafe24` | +| `DELETE /cafe24/products/{product_no}/options` | 옵션 삭제 — **품목도 함께 삭제**(화면 확인창) | `cafe24` | +| `POST /cafe24/products/{product_no}/options/image` | 옵션값 썸네일 업로드만 (multipart → `{path}`) | `cafe24` | +| `PUT /cafe24/products/{product_no}/variants` | 품목 자체코드·추가금액·진열·판매 일괄 수정 (`{rows:[...]}`, 바뀐 행만) | `cafe24` | | `GET /cafe24/schedules` | 예약 목록 · 취소 | `cafe24` | | `POST /cafe24/schedules` | 예약 등록 (편집기에서) | `cafe24` | | `POST /cafe24/schedules/{id}/cancel` | 대기 중 예약 취소 | `cafe24` | @@ -120,19 +131,29 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p --- -### 2-1. 상품관리 화면 구성 (2분할) +### 2-1. 상품관리 화면 구성 (3분할) ``` -┌─ 550px ───────────────────┬───────── 남은 폭 전부 ─────────┐ -│ 상품명 검색(한 줄) │ 상품 이름 · 번호 · 진열/판매 │ -│ ☑진열중 ☑판매중 (기본 체크) │ 상세설명 HTML 편집기 │ -│ ── 목록(전체, 스크롤) ── │ (PC/모바일 공통 · 문법 강조) │ -│ 번호 상품명 진열 판매 수정 │ [메모][복사][다시 읽기][적용] │ -│ (제목행 클릭 = 정렬) │ ▸ 버전 이력 │ -│ │ │ -└───────────────────────────┴─────────────────────────────────┘ +┌─ 320~460px ──────────┬──────── 남은 폭 전부 ────────┬─ 380px ─────────────┐ +│ 상품명 검색(한 줄) │ [반영 대기 배너 — 지연 시] │ 기본 정보 │ +│ ☑진열중 ☑판매중 │ 상품 이름 · 번호 · 진열/판매 │ 상품명/판매가/공급가 │ +│ ── 목록(전체) ── │ 상세설명 HTML 편집기 │ /소비자가 [저장] │ +│ 번호 상품명 진열 판매 │ (PC/모바일 공통 · 문법 강조) │ 대표 이미지 │ +│ (제목행 클릭 = 정렬) │ [메모][복사][다시 읽기][적용] │ 큰 그림 + 목록/축소 │ +│ │ ▸ 예약 적용 │ [파일 선택][교체] │ +│ │ ▸ 버전 이력 │ ▸ 옵션 / 품목 (지연 로드) │ +└───────────────────────┴───────────────────────────────┴──────────────────────┘ ``` +- 오른쪽 **상품 정보 패널**(`_side.html`)은 "소스 수정 외 기능"을 모아둔 별도 공간이다. + 상품 클릭 시 `/products/{no}/pane` 이 편집기 조각과 패널 조각을 **한 응답**(`_panes.html`, + `[data-pane=editor]`/`[data-pane=side]`)으로 돌려주고 JS 가 각각 끼운다 — 카페24 상품 + 조회는 1회. 옵션/품목은 `
` 를 펼칠 때 JSON 으로 따로 불러온다(호출 2회 절약). +- 1360px 이하에서는 패널이 편집기 아래로 내려가고(높이 38vh, 안에서 스크롤), 1100px 이하는 + 한 열이다. +- 패널의 모든 쓰기는 JSON API(`routes_product_info.py`)이며 화면은 **응답값**으로 다시 + 그린다. 저장 뒤 카페24를 다시 GET 하지 않는다(아래 3-3 읽기 지연 참고). + - 검색 입력란은 **한 줄 높이로 고정**한다(`height: 32px`). `.cf24-filters` 가 세로 flex 이므로 `flex-basis` 를 주면 그 값이 **높이**로 적용돼 입력란이 거대해진다. 실제로 그 사고가 있었다 — flex 방향을 항상 확인할 것. @@ -148,8 +169,8 @@ Admin API(OAuth)로는 스킨 파일을 읽거나 쓸 방법이 없다(`config.p 이 표식은 `_list_query` 가 링크·리다이렉트에도 이어 붙여 해제 상태가 유지된다. - **정렬**은 제목행 클릭(오름↔내림 토글). 브라우저에서 처리하므로 전체를 받아둔 덕분에 목록 전체가 대상이 된다. -- **상품 클릭 시 오른쪽만 교체**한다(`/pane` 조각을 fetch → 삽입). 목록을 다시 받지 - 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다. +- **상품 클릭 시 편집기·정보 패널만 교체**한다(`/pane` 조각 묶음을 fetch → 삽입). 목록을 + 다시 받지 않으므로 카페24 호출이 1회로 끝난다. JS 실패 시 각 행의 링크로 정상 동작한다. - 편집기 상단에 **상품 다이렉트 주소**(고객이 보는 상세페이지 URL)와 「주소 복사」· 「쇼핑몰에서 열기」를 둔다. 주소는 `CAFE24_SHOP_URL` 기준으로 만들고, 미설정 시 카페24 기본 도메인(`https://.cafe24.com`)으로 대체한다 — 커스텀 도메인은 @@ -341,12 +362,16 @@ cafe24_oauth_tokens 저장 정리된 소스가 그대로 저장된다). 1. **카페24에서 현재 HTML 을 다시 읽는다.** 로컬 DB 의 마지막 버전을 "지금 올라간 값"으로 가정하지 않는다(카페24 관리자에서 직접 고쳤을 수 있다). -2. 그 값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다. +1-1. **읽기 지연 판정으로 유효 현재값을 정한다**(3-3). 카페24가 아직 예전 값을 + 돌려주는 중이면 우리 마지막 쓰기(MANUAL/SCHEDULED)가 현재값이다. +2. 유효 현재값으로 **BACKUP revision** 을 남긴다. 유일한 복구 수단이다. 3. **지문 대조** — 편집 화면을 열 때의 `fingerprint`(sha256 앞 32자)와 지금 - 카페24 값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히 - 덮어쓰는 것을 막는 낙관적 잠금이다. -4. 내용이 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지). -5. PUT 적용 → **MANUAL revision** + 감사로그(`apply_description`). + 유효 현재값의 지문이 다르면 적용을 거부한다. 편집 중 남이 바꾼 내용을 조용히 + 덮어쓰는 것을 막는 낙관적 잠금이다. (카페24의 지연된 예전 값과 비교하면 + 방금 적용한 것이 "충돌"로 오판된다 — 그래서 유효 현재값과 비교한다.) +4. 내용이 유효 현재값과 같으면 호출하지 않는다(불필요한 쓰기·API 호출 방지). +5. PUT 적용 → PUT 응답을 **스냅샷**으로 저장 → **MANUAL revision** → 짧은 재조회 + 확인(결과는 감사로그에만) → 감사로그(`apply_description`). 추가 규칙: @@ -361,12 +386,79 @@ cafe24_oauth_tokens 저장 분리 사용 상품의 모바일 내용이 PC 와 달랐다면 덮어쓰기 전에 **그 내용도 BACKUP revision 으로 남긴다**(백업이 없으면 되찾을 방법이 없다). - 실패해도 BACKUP 은 이미 남아 있으므로 오류 메시지에 버전 번호를 알려준다. -- **적용 직후 짧게 재확인한다(`products.wait_for_description`).** 카페24 관리자 - API(`GET /admin/products/{no}`)는 PUT 직후 몇 초간 이전 값을 돌려줄 때가 - 있다(쇼핑몰 화면에는 바로 반영됨 — 실물 관찰). 그 상태에서 다른 상품을 봤다가 - 돌아오면 우리 편집기만 "적용 안 된 것"처럼 보인다. 적용/예약 실행 직후 - 0.8초 간격으로 최대 3회 재조회해 새 값이 확인될 때까지 기다린 뒤 화면으로 - 돌아간다(실패해도 PUT 자체는 이미 성공했으므로 예외를 던지지 않는다). +- 적용 직후 `products.wait_for_description` 으로 0.8초 × 3회 재조회해 보지만, 이것은 + **확인용**일 뿐 화면의 기준이 아니다(3-3). 확인 여부는 감사로그 detail 에 남는다. + +--- + +## 3-3. 카페24 읽기 지연(read-after-write lag) — "마지막 쓰기가 권위" + +### 증상과 원인 + +"소스를 수정해 적용했는데 편집기에는 수정 전 소스가 보이고, 한참 뒤에 다시 들어가면 +반영돼 있다." 우리 쪽 캐시가 아니다 — 화면·조각 응답은 전부 `Cache-Control: no-store` +이고 fetch 도 `no-store` 다. **카페24 관리자 API 가 PUT 직후 한동안 `GET +/admin/products/{no}` 에서 이전 값을 돌려준다**(실물 관찰. 몇 초일 때도, 훨씬 길 때도 +있다. 쇼핑몰 고객 화면에는 바로 반영된다). 예전 코드는 2.4초만 기다리고 포기한 뒤 GET +값을 그대로 믿었기 때문에 + +1. 편집기에 예전 소스가 보이고, +2. 그 예전 값으로 지문을 만들어 다음 적용이 "충돌"로 거부되거나, +3. 예전 값이 BACKUP 으로 남고 "변경 없음" 판정이 어긋났다. + +### 규칙 + +쓰기가 성공하면 **우리가 쓴 값을 DB 에 남기고, 유예시간 안에서는 그것을 현재값으로 +삼는다.** 유예시간은 `CAFE24_READ_LAG_GRACE_MIN`(기본 360분). 지나면 무조건 카페24 값을 +믿는다 — 지연은 영원하지 않고, 영원히 로컬 값을 고집하면 그것이 또 다른 캐시다. + +| 대상 | 우리가 남기는 것 | 지연 판정 | 구현 | +| --- | --- | --- | --- | +| 상세설명 HTML | MANUAL/SCHEDULED revision (+ 적용 직전 BACKUP) | 카페24 값이 유예시간 안의 **revision 중 하나와 같으면** 지연(pending) → 마지막 쓰기를 표시하고 그 지문을 쓴다. 마지막 쓰기와 같으면 synced. 우리가 모르는 값이면 external(관리자에서 직접 고침) → 카페24 값 표시 | `store.resolve_description`, `routes_products.resolve_description`, DB `revision_digests`(md5) | +| 상품명·가격·이미지·진열/판매 | PUT 응답을 `cafe24_products.last_write_snapshot.product` 에 저장 | GET 의 `updated_date` 가 스냅샷의 `updated_date` 보다 **이전**이면 지연 → 스냅샷 값으로 덮어씀. 카페24 자신의 시각끼리 비교하므로 서버 시계와 무관 | `store.overlay_recent_write`, `routes_products.load_product` | +| 옵션 | PUT/POST 응답을 `…snapshot.options` 에 저장 | 유예시간 안이면 스냅샷 우선 | `routes_product_info._apply_options_snapshot` | +| 품목 | 보낸 값(+응답)을 `…snapshot.variants` 에 코드별 누적 | 유예시간 안이면 해당 코드의 필드만 덮어씀 | `routes_product_info._apply_variant_snapshot` | + +- 편집기 위에 **「카페24 반영 대기 중」 배너**(pending) 또는 **「관리자에서 직접 수정된 + 것으로 보임」 배너**(external)를 띄운다. 「다시 확인」은 「다시 읽기」와 같다. +- 적용·기본정보·상태 등 **모든 쓰기는 유효 현재값과 비교**해 바뀐 것만 보낸다. 이걸 + 안 하면 "A→B 로 바꾼 직후 B→A" 가 카페24의 지연된 값(A)과 같다고 판단돼 무시된다. +- 쓰기 뒤 화면은 **PUT 응답으로 그린다.** 응답은 쓰기 직후의 실제 값이다(GET 과 달리 + 지연이 없다 — 실물 예제 응답에 `updated_date` 가 갱신되어 온다). 다시 GET 하지 않는다. +- 스냅샷은 마이그레이션 `cafe24_db_004_write_snapshot.sql`(멱등)의 두 컬럼 + (`last_write_snapshot` JSONB, `last_written_at`)을 쓴다. 미적용 상태에서는 스냅샷 + 저장이 실패해 로그가 남지만 적용 자체는 성공한다 — 반드시 적용할 것. + +--- + +## 3-4. 정보 패널 API 사실 (카페24 문서 실물 예제 기준) + +- **이미지 업로드**: `POST /admin/products/images` body `{"requests":[{"image":""}]}` + → `{"images":[{"path":"https://{domain}/web/upload/NNEditor/…"}]}`. 1장 10MB, 1호출 30MB, + 1회 20장. 상품 대표이미지는 그 경로를 `PUT /admin/products/{no}` 의 `detail_image` 에 + 넣고 `image_upload_type: "A"`(대표이미지등록 — 목록/작은목록/축소를 카페24가 리사이징). + `B` 는 네 이미지를 각각 지정, `C` 는 웹FTP. +- **가격**: `price`(판매가) · `supply_price`(공급가, 문서상 "참조 목적") · `retail_price` + (소비자가). 문자열 `"11000.00"` 형식으로 보낸다(`store.parse_price`). 쇼핑몰 설정의 + "판매가 계산 기준"이 상품가(B)면 `price` 대신 `price_excluding_tax` 를 써야 한다는 문서 + 주석이 있다 — 그 경우 카페24 오류 메시지가 그대로 화면에 뜬다. +- **옵션**: `GET/POST/PUT/DELETE /admin/products/{no}/options`, 응답 루트 `option`. + 생성은 조합형(`option_type:"T"`, `option_list_type:"S"`) + 옵션값 목록 → **품목 자동 + 생성**. 수정(PUT)은 `original_options`(수정 전)와 `options`(수정 후)를 **같은 순서·개수**로 + 짝지어 보낸다 — 옵션명·옵션값 이름·`option_image_file`(옵션 버튼 이미지)· + `option_link_image`(연결 이미지)·`option_color`·`option_display_type`(S 셀렉트/P 미리보기/ + B 버튼/R 라디오)만 바꿀 수 있고 **옵션값 추가/삭제는 불가**(문서). 연동형(E)은 + `option_code`/`value_no`/`option_preset_code` 를 함께 보낸다. DELETE 는 옵션 사용안함 + + **품목 전부 삭제**(화면에서 확인창). +- **품목**: `GET /admin/products/{no}/variants` → `variants[]`(`variant_code` 12자, + `options[{name,value}]`, `custom_variant_code`, `additional_amount`, `display`, `selling`, + `quantity`, `image`…). `PUT /admin/products/{no}/variants` body `{"shop_no":1, + "requests":[{variant_code, custom_variant_code, additional_amount, display, selling…}]}` + 1회 100건(초과 시 나눠 보냄). 재고(`quantity`)는 이 화면에서 건드리지 않는다. +- "옵션별 상품 이름" 은 옵션값(`option_text`)이고, "옵션별 썸네일" 은 `option_image_file` + (+ `option_link_image` 에도 같은 경로) 이며, "옵션별 상품코드/추가금액/진열" 은 품목 + (`custom_variant_code`/`additional_amount`/`display`)이다 — 카페24 모델상 둘은 다른 + 리소스라 화면에서도 「옵션 저장」과 「품목 저장」이 따로 있다. --- @@ -441,6 +533,14 @@ docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_003_swiper_revisions.sql ``` +읽기 지연 보정(3-3)·정보 패널을 쓰려면 마이그레이션 004 를 적용해야 한다(멱등). + +```bash +docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql +``` + +선택 env: `CAFE24_READ_LAG_GRACE_MIN`(기본 360) — 3-3 의 유예시간(분). + worker 가 도는지 확인: ```bash @@ -474,6 +574,8 @@ DB·네트워크 없이 암호화 왕복, 토큰 만료/자동갱신, 상태 노 | 6 | 자동 종료/복원 · 롤백 | ✖ 되돌리기는 쓰지 않기로 결정. 버전 선택 복원만 남음 | | 7 | 일괄 수정 · 일괄 예약 · Rate limit 제어 | ✖ 일괄수정은 사용하지 않기로 제거. 필요해지면 다시 논의 | | 8 | 디자인 보관함 파일 편집(모바일 스와이프 · PC/모바일 상품상세 템플릿) — FTP | ✅ 완료. 예약 적용은 없음(상품 전용 개념이라 범위 밖) | +| 9 | 카페24 읽기 지연 보정("마지막 쓰기가 권위", 3-3) | ✅ 완료 (마이그레이션 004) | +| 10 | 상품 정보 패널 — 상품명/가격 · 대표이미지 · 옵션/품목 (3-4) | ✅ 완료 | Phase 5 의 worker 는 `app/modules/cafe24/worker.py` 에 둔다 — `Dockerfile` 이 `COPY app/ ./app/` 만 하므로 `scripts/` 에 두면 이미지에 포함되지 않는다. diff --git a/docs/DATABASES.md b/docs/DATABASES.md index f44d0ac..097368c 100644 --- a/docs/DATABASES.md +++ b/docs/DATABASES.md @@ -303,7 +303,7 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이 | 테이블 | 용도 | | --- | --- | | `cafe24_oauth_tokens` | 쇼핑몰별 OAuth 토큰(`mall_id` UNIQUE). access/refresh 는 **Fernet 암호문**으로 저장. 상품관리 + 향후 주문관리가 공유 | -| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24 | +| `cafe24_products` | 상품 캐시(`product_no` UNIQUE). 목록/검색 속도용이며 source of truth 는 언제나 카페24. `last_write_snapshot`(JSONB, 마이그레이션 004) 에 카페24 PUT 응답을 섹션별(`product`/`options`/`variants`)로 남겨 **읽기 지연**(PUT 뒤 GET 이 한동안 예전 값을 돌려줌) 동안 화면 기준으로 쓴다 | | `cafe24_product_revisions` | 상세페이지 HTML 버전(append-only). `revision_type` SYNC/DRAFT/**BACKUP**/MANUAL/SCHEDULED/ROLLBACK | | `cafe24_product_schedules` | 예약 작업. `status` PENDING/PROCESSING/SUCCESS/FAILED/CANCELLED, 재시도·자동종료·복원 대상 포함 | | `cafe24_audit_logs` | 누가 무엇을 바꿨나. worker 수행분은 `actor='SCHEDULER'` | @@ -315,6 +315,8 @@ DDL: `scripts/sql/cafe24_db_init.sql` (멱등). DB·역할(`cafe24_app`)·테이 - 예약의 `restore_revision_id` 는 **예약 실행 순간** 만든 BACKUP 을 가리킨다(예약 생성 시점 값이 아님). - 일괄 예약은 상품 1건당 1행 + 공통 `parent_job_id` — 한 상품 실패가 나머지를 막지 않는다. - 토큰 암호화 키는 `.env` 의 `CAFE24_TOKEN_SECRET`. **값을 바꾸면 기존 토큰을 복호화할 수 없어 카페24 재연결이 필요하다.** +- 기존 운영 DB 에는 마이그레이션 002·003·004 를 순서대로 적용한다(전부 멱등): + `docker exec -i postgres-db psql -U postgres -d cafe24_db < scripts/sql/cafe24_db_004_write_snapshot.sql` ### 운영 서버 초기화 (1회, 사용자 승인 후) diff --git a/scripts/sql/cafe24_db_004_write_snapshot.sql b/scripts/sql/cafe24_db_004_write_snapshot.sql new file mode 100644 index 0000000..8f1d7c9 --- /dev/null +++ b/scripts/sql/cafe24_db_004_write_snapshot.sql @@ -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) 판정용'; diff --git a/scripts/sql/cafe24_db_init.sql b/scripts/sql/cafe24_db_init.sql index 528165a..6f64ac6 100644 --- a/scripts/sql/cafe24_db_init.sql +++ b/scripts/sql/cafe24_db_init.sql @@ -94,9 +94,15 @@ CREATE TABLE IF NOT EXISTS cafe24_products ( display BOOLEAN NOT NULL DEFAULT TRUE, selling BOOLEAN NOT NULL DEFAULT TRUE, last_synced_at TIMESTAMPTZ, + -- 쓰기 직후 스냅샷(카페24 읽기 지연 보정, 마이그레이션 004) + last_write_snapshot JSONB, + last_written_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); +ALTER TABLE cafe24_products + ADD COLUMN IF NOT EXISTS last_write_snapshot JSONB, + ADD COLUMN IF NOT EXISTS last_written_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_cafe24_products_name ON cafe24_products (product_name); CREATE INDEX IF NOT EXISTS idx_cafe24_products_code ON cafe24_products (product_code);